import sys import pygame import requests from bs4 import BeautifulSoup, NavigableString def parse_css(css_text): """ A simple CSS parser mapping selectors to a dictionary of property names and values. """ styles = {} css_text = css_text.replace("\n", "") blocks = css_text.split("}") for block in blocks: if "{" in block: selector, props = block.split("{", 1) selector = selector.strip() props = props.strip() prop_dict = {} for declaration in props.split(";"): if ":" in declaration: key, value = declaration.split(":", 1) key = key.strip() value = value.strip() # For font-size, store the raw value so percentages can be processed. if key == "font-size": prop_dict[key] = value else: prop_dict[key] = value styles[selector] = prop_dict return styles def get_computed_style(element, css_styles): """ Merge CSS rules (by tag name) with inline style declarations. """ style = {} if element.name in css_styles: style.update(css_styles[element.name]) if 'style' in element.attrs: declarations = element['style'].split(";") for decl in declarations: if ":" in decl: key, value = decl.split(":", 1) style[key.strip()] = value.strip() return style def render_element(surface, element, x, y, css_styles, base_font): """ Recursively render an element (or text node) on the surface. Returns the total height rendered. """ total_height = 0 # Handle text nodes. if isinstance(element, NavigableString): text = str(element).strip() if text: text_surface = base_font.render(text, True, pygame.Color("black")) surface.blit(text_surface, (x, y)) return text_surface.get_height() return 0 # For tag nodes, compute the style. computed_style = get_computed_style(element, css_styles) # Handle margins (defaults: top=0, bottom=5). try: margin_top = int(computed_style.get("margin-top", "0")) except ValueError: margin_top = 0 try: margin_bottom = int(computed_style.get("margin-bottom", "5")) except ValueError: margin_bottom = 5 total_height += margin_top # Process font size. fs_val = computed_style.get("font-size", None) if fs_val: fs_val = fs_val.strip() if fs_val.endswith('%'): try: percent = float(fs_val.strip('%')) font_size = int(base_font.get_height() * percent / 100.0) except Exception: font_size = base_font.get_height() else: try: font_size = int(fs_val) except ValueError: font_size = base_font.get_height() else: font_size = base_font.get_height() # Process color, with fallback. color_str = computed_style.get("color", "black") try: color = pygame.Color(color_str) except ValueError: color = pygame.Color("black") # Create a new font with the computed font size. font = pygame.font.SysFont(None, font_size) # Apply simple tag-based styling. if element.name == "b": font.set_bold(True) elif element.name == "i": font.set_italic(True) # Recursively render children. for child in element.children: child_height = render_element(surface, child, x, y + total_height, css_styles, font) total_height += child_height total_height += margin_bottom return total_height def render_html(surface, html, css_text): """ Renders the HTML content onto a Pygame surface. Returns the total height used. """ css_styles = parse_css(css_text) soup = BeautifulSoup(html, "html.parser") # Remove tags that are non-renderable. for tag in soup.find_all(["script", "style", "meta", "link", "head"]): tag.decompose() y = 10 base_font = pygame.font.SysFont(None, 24) # Prefer content inside
, if available. body = soup.find("body") if body: for element in body.children: y += render_element(surface, element, 10, y, css_styles, base_font) else: for element in soup.contents: y += render_element(surface, element, 10, y, css_styles, base_font) return y def fetch_html(url): """ Download the HTML content from a URL. """ response = requests.get(url) response.raise_for_status() return response.text def render_html_to_surface(html, css_text, width): """ Render the HTML to an offscreen surface for scrolling. """ # Create a temporary surface with extra height. temp_surface = pygame.Surface((width, 3000)) rendered_height = render_html(temp_surface, html, css_text) # If nothing was rendered (common with complex pages), fallback to plain text. if rendered_height < 20: plain_text = BeautifulSoup(html, "html.parser").get_text() temp_surface.fill((255, 255, 255)) font = pygame.font.SysFont(None, 24) y = 10 for line in plain_text.splitlines(): line = line.strip() if line: text_surface = font.render(line, True, pygame.Color("black")) temp_surface.blit(text_surface, (10, y)) y += text_surface.get_height() + 5 rendered_height = y # Create a surface exactly as tall as the rendered content. content_surface = pygame.Surface((width, rendered_height)) content_surface.blit(temp_surface, (0, 0)) return content_surface, rendered_height def main(): pygame.init() screen_width, screen_height = 800, 600 screen = pygame.display.set_mode((screen_width, screen_height)) pygame.display.set_caption("HTML Renderer with URL Fetch, CSS, and Scrolling") # Fetch HTML from a URL if provided; otherwise, use sample HTML. if len(sys.argv) > 1: url = sys.argv[1] try: html = fetch_html(url) except Exception as e: print(f"Error fetching {url}: {e}") return else: html = """This is a proof of concept for rendering HTML with CSS in Pygame.
This paragraph is red!
This paragraph is larger.
This paragraph has margins!
Scroll down for more content!
""" # A minimal default CSS. css_text = """ h1 { font-size: 36; color: blue; margin-bottom: 10; } p { font-size: 24; color: black; } """ # Render the HTML to an offscreen content surface. content_surface, content_height = render_html_to_surface(html, css_text, screen_width) scroll_offset = 0 clock = pygame.time.Clock() max_scroll = max(0, content_height - screen_height) running = True while running: for event in pygame.event.get(): if event.type == pygame.QUIT: running = False # Handle keyboard scrolling. if event.type == pygame.KEYDOWN: if event.key == pygame.K_DOWN: scroll_offset = min(max_scroll, scroll_offset + 20) elif event.key == pygame.K_UP: scroll_offset = max(0, scroll_offset - 20) # Handle mouse wheel scrolling. if event.type == pygame.MOUSEBUTTONDOWN: if event.button == 4: # Wheel up. scroll_offset = max(0, scroll_offset - 20) elif event.button == 5: # Wheel down. scroll_offset = min(max_scroll, scroll_offset + 20) screen.fill((255, 255, 255)) screen.blit(content_surface, (0, -scroll_offset)) pygame.display.flip() clock.tick(30) pygame.quit() if __name__ == "__main__": main()