From 8d2fd3b16e6f551b69bd40be62d9bbdcb1d77db6 Mon Sep 17 00:00:00 2001 From: Benedikt Willi Date: Sun, 11 Jan 2026 23:34:27 +0100 Subject: [PATCH] Enhance layout and rendering features with new document and block layout implementations --- .gitignore | 5 + src/browser/chrome.py | 196 +++++++---------------------------- src/layout/block.py | 104 ++++++++++++++++--- src/layout/document.py | 230 +++++++++++++++++++++++++++++++++++++++-- src/layout/inline.py | 69 ++++++++++++- src/render/fonts.py | 60 +++++++++-- src/render/paint.py | 77 ++++++++++++-- 7 files changed, 546 insertions(+), 195 deletions(-) diff --git a/.gitignore b/.gitignore index 61ae5a7..b922df1 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,8 @@ src/browser/__pycache__/* # Test outputs example_dom_graph.* + +*.pyc +src/browser/__pycache__/browser.cpython-313.pyc +src/browser/__pycache__/chrome.cpython-313.pyc +src/browser/__pycache__/tab.cpython-313.pyc diff --git a/src/browser/chrome.py b/src/browser/chrome.py index 7f1270a..c010381 100644 --- a/src/browser/chrome.py +++ b/src/browser/chrome.py @@ -5,6 +5,7 @@ from typing import Optional import logging import cairo import time +from pathlib import Path gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") @@ -12,6 +13,10 @@ gi.require_version("Adw", "1") from gi.repository import Gtk, Gdk, Adw import skia +# Import the render and layout packages +from ..render.fonts import get_font +from ..layout.document import DocumentLayout + class Chrome: def __init__(self, browser): @@ -56,20 +61,15 @@ class Chrome: self.is_selecting = False # True while mouse is dragging # Layout information for text selection - # Each entry: {text, x, y, width, height, font_size, font, char_positions} + # Each entry: {text, x, y, width, height, font_size, char_positions} # char_positions is a list of x offsets for each character self.text_layout = [] # Layout cache to avoid recalculation on scroll self._layout_cache_width = 0 self._layout_cache_doc_id = None - self._layout_blocks = [] # Cached processed blocks self._layout_rects = [] # Cached debug rects - # Font cache to avoid recreating fonts every frame - self._font_cache = {} # {font_size: skia.Font} - self._default_typeface = None - # Paint cache self._text_paint = None @@ -328,9 +328,7 @@ class Chrome: self.browser.navigate_to(self.address_bar.get_text()) def on_draw(self, drawing_area, context, width, height): - """Callback for drawing the content area using Skia.""" - import time - + """Callback for drawing the content area using Skia.""" # Track frame time for FPS calculation current_time = time.time() self.frame_times.append(current_time) @@ -416,12 +414,6 @@ class Chrome: sub_timings = {} - t0 = time.perf_counter() - body = self._find_body(document) - if not body: - return - sub_timings['find_body'] = time.perf_counter() - t0 - # Check if we need to rebuild layout cache t0 = time.perf_counter() doc_id = id(document) @@ -432,12 +424,15 @@ class Chrome: ) if needs_rebuild: - self._rebuild_layout(body, width) + self._rebuild_layout(document, width) self._layout_cache_doc_id = doc_id self._layout_cache_width = width self.logger.debug(f"Layout rebuilt: {len(self.text_layout)} lines") sub_timings['layout_check'] = time.perf_counter() - t0 + if not self.text_layout: + return + # Apply scroll offset t0 = time.perf_counter() canvas.save() @@ -489,156 +484,45 @@ class Chrome: def _get_font(self, size: int): """Get a cached font for the given size.""" - if size not in self._font_cache: - if self._default_typeface is None: - self._default_typeface = skia.Typeface.MakeDefault() - self._font_cache[size] = skia.Font(self._default_typeface, size) - return self._font_cache[size] + return get_font(size) def _rebuild_layout(self, body, width: int): - """Rebuild the layout cache for text positioning.""" + """Rebuild the layout cache for text positioning using DocumentLayout.""" self.text_layout = [] self._layout_rects = [] - blocks = self._collect_blocks(body) - + # Use the new DocumentLayout for layout calculation + doc_layout = DocumentLayout(body) + layout_lines = doc_layout.layout(width) + + # Convert LayoutLine objects to text_layout format x_margin = 20 max_width = max(10, width - 2 * x_margin) - y = 30 - - for block in blocks: - font_size = block.get("font_size", 14) - font = self._get_font(font_size) - text = block.get("text", "") - if not text: - y += font_size * 0.6 - continue - - # Optional bullet prefix - if block.get("bullet"): - text = f"• {text}" - - # Word wrapping per block - words = text.split() - lines = [] - current_line = [] - current_width = 0 - for word in words: - word_width = font.measureText(word + " ") - if current_width + word_width > max_width and current_line: - lines.append(" ".join(current_line)) - current_line = [word] - current_width = word_width - else: - current_line.append(word) - current_width += word_width - if current_line: - lines.append(" ".join(current_line)) - - line_height = font_size * 1.4 - top_margin = block.get("margin_top", 6) - y += top_margin - - block_start_y = y - for line in lines: - # Calculate character positions for precise selection - char_positions = [0.0] # Start at 0 - for i in range(1, len(line) + 1): - char_positions.append(font.measureText(line[:i])) - - # Store text layout for selection - line_width = font.measureText(line) - self.text_layout.append({ - "text": line, - "x": x_margin, - "y": y - font_size, # Top of line - "width": line_width, - "height": line_height, - "font_size": font_size, - "char_positions": char_positions - }) - - y += line_height - - block_end_y = y - y += block.get("margin_bottom", 10) - - # Store layout for debug mode - block_type = block.get("block_type", "block") + + for line in layout_lines: + self.text_layout.append({ + "text": line.text, + "x": line.x, + "y": line.y, # Top of line + "width": line.width, + "height": line.height, + "font_size": line.font_size, + "char_positions": line.char_positions + }) + + # Build layout rects for debug mode from blocks + for block in doc_layout.blocks: self._layout_rects.append({ - "x": x_margin - 5, - "y": block_start_y - font_size, - "width": max_width + 10, - "height": block_end_y - block_start_y + 5, - "type": block_type + "x": block.x - 5, + "y": block.y - block.lines[0].font_size if block.lines else block.y, + "width": block.width + 10, + "height": block.height + 5, + "type": block.block_type }) # Store total document height - self.document_height = y + 50 # Add some padding at the bottom + self.document_height = doc_layout.height - def _find_body(self, document): - from ..parser.html import Element - if isinstance(document, Element) and document.tag == "body": - return document - if hasattr(document, "children"): - for child in document.children: - if isinstance(child, Element) and child.tag == "body": - return child - found = self._find_body(child) - if found: - return found - return None - - def _collect_blocks(self, node): - """Flatten DOM into renderable blocks with basic styling.""" - from ..parser.html import Element, Text - - blocks = [] - - def text_of(n): - if isinstance(n, Text): - return n.text - if isinstance(n, Element): - parts = [] - for c in n.children: - parts.append(text_of(c)) - return " ".join([p for p in parts if p]).strip() - return "" - - for child in getattr(node, "children", []): - if isinstance(child, Text): - txt = child.text.strip() - if txt: - blocks.append({"text": txt, "font_size": 14}) - continue - - if isinstance(child, Element): - tag = child.tag.lower() - content = text_of(child) - if not content: - continue - - if tag == "h1": - blocks.append({"text": content, "font_size": 24, "margin_top": 12, "margin_bottom": 12, "block_type": "block", "tag": "h1"}) - elif tag == "h2": - blocks.append({"text": content, "font_size": 20, "margin_top": 10, "margin_bottom": 10, "block_type": "block", "tag": "h2"}) - elif tag == "h3": - blocks.append({"text": content, "font_size": 18, "margin_top": 8, "margin_bottom": 8, "block_type": "block", "tag": "h3"}) - elif tag == "p": - blocks.append({"text": content, "font_size": 14, "margin_top": 6, "margin_bottom": 12, "block_type": "block", "tag": "p"}) - elif tag == "li": - blocks.append({"text": content, "font_size": 14, "bullet": True, "margin_top": 4, "margin_bottom": 4, "block_type": "list-item", "tag": "li"}) - elif tag in {"ul", "ol"}: - blocks.extend(self._collect_blocks(child)) - elif tag in {"span", "a", "strong", "em", "b", "i", "code"}: - # Inline elements - blocks.append({"text": content, "font_size": 14, "block_type": "inline", "tag": tag}) - else: - # Generic element: render text - blocks.append({"text": content, "font_size": 14, "block_type": "block", "tag": tag}) - - return blocks - def _draw_selection_highlight(self, canvas, width: int): """Draw selection highlight rectangle.""" if not self.selection_start or not self.selection_end: @@ -662,8 +546,6 @@ class Chrome: def _draw_debug_overlays(self, canvas, layout_rects: list, document): """Draw debug overlays showing element boxes.""" - from ..parser.html import Element, Text - # Color scheme for different element types colors = { "block": skia.Color(255, 0, 0, 60), # Red - block elements @@ -1128,8 +1010,6 @@ class Chrome: def _show_dom_graph(self): """Generate and display DOM graph for current tab.""" from ..debug.dom_graph import render_dom_graph_to_svg, save_dom_graph, print_dom_tree - import os - from pathlib import Path if not self.browser.active_tab: self.logger.warning("No active tab to visualize") diff --git a/src/layout/block.py b/src/layout/block.py index 4aba8d5..302a31c 100644 --- a/src/layout/block.py +++ b/src/layout/block.py @@ -1,23 +1,103 @@ -"""Block and line layout stubs.""" +"""Block and line layout.""" + +from ..render.fonts import get_font, linespace +from ..parser.html import Element, Text + + +class LineLayout: + """Layout for a single line of text.""" + + def __init__(self, parent=None): + self.parent = parent + self.words = [] # List of (text, x, font_size) + self.x = 0 + self.y = 0 + self.width = 0 + self.height = 0 + self.font_size = 14 + + def add_word(self, word: str, x: float, font_size: int): + """Add a word to this line.""" + self.words.append((word, x, font_size)) + font = get_font(font_size) + word_width = font.measureText(word + " ") + self.width = max(self.width, x + word_width - self.x) + self.height = max(self.height, linespace(font_size)) class BlockLayout: + """Layout for a block-level element.""" + def __init__(self, node, parent=None, previous=None, frame=None): self.node = node self.parent = parent self.previous = previous self.frame = frame - self.children = [] - - def layout(self): - return 0 + self.children = [] # Child BlockLayouts + self.lines = [] # LineLayouts for inline content + self.x = 0 + self.y = 0 + self.width = 0 + self.height = 0 + self.margin_top = 0 + self.margin_bottom = 0 + self.font_size = 14 + self.block_type = "block" + self.tag = "" + + def layout(self, x: float, y: float, max_width: float): + """Layout this block and return the height used.""" + self.x = x + self.y = y + self.width = max_width + + current_y = y + self.margin_top + + # Layout children + for child in self.children: + child.layout(x, current_y, max_width) + current_y += child.height + child.margin_bottom + + # Layout lines + for line in self.lines: + line.y = current_y + current_y += line.height + + self.height = current_y - y + self.margin_bottom + return self.height -class LineLayout: - def __init__(self, node, parent=None, previous=None): - self.node = node - self.parent = parent - self.previous = previous +def build_block_layout(node, parent=None, font_size: int = 14, + margin_top: int = 6, margin_bottom: int = 10, + block_type: str = "block", bullet: bool = False) -> BlockLayout: + """Build a BlockLayout from a DOM node.""" + block = BlockLayout(node, parent) + block.font_size = font_size + block.margin_top = margin_top + block.margin_bottom = margin_bottom + block.block_type = block_type + block.tag = node.tag if isinstance(node, Element) else "" + + # Collect text content + text = _extract_text(node) + if bullet and text: + text = f"• {text}" + + if text: + block._raw_text = text + else: + block._raw_text = "" + + return block - def layout(self): - return 0 + +def _extract_text(node) -> str: + """Extract text content from a node.""" + if isinstance(node, Text): + return node.text + if isinstance(node, Element): + parts = [] + for child in node.children: + parts.append(_extract_text(child)) + return " ".join([p for p in parts if p]).strip() + return "" diff --git a/src/layout/document.py b/src/layout/document.py index 83bf665..d9d5476 100644 --- a/src/layout/document.py +++ b/src/layout/document.py @@ -1,12 +1,230 @@ -"""Document-level layout stub.""" +"""Document-level layout.""" + +from ..parser.html import Element, Text +from ..render.fonts import get_font, linespace +from .block import BlockLayout, LineLayout + + +class LayoutLine: + """A laid-out line ready for rendering.""" + + def __init__(self, text: str, x: float, y: float, font_size: int, char_positions: list = None): + self.text = text + self.x = x + self.y = y # Top of line + self.font_size = font_size + self.height = linespace(font_size) + self.width = 0 + self.char_positions = char_positions or [] + + # Calculate width + if text: + font = get_font(font_size) + self.width = font.measureText(text) + + +class LayoutBlock: + """A laid-out block with its lines.""" + + def __init__(self, tag: str, block_type: str = "block"): + self.tag = tag + self.block_type = block_type + self.lines = [] # List of LayoutLine + self.x = 0 + self.y = 0 + self.width = 0 + self.height = 0 class DocumentLayout: + """Layout engine for a document.""" + def __init__(self, node, frame=None): self.node = node self.frame = frame - self.children = [] - - def layout(self, width: int, zoom: float = 1.0): - # Placeholder layout logic - return width * zoom + self.blocks = [] # List of LayoutBlock + self.lines = [] # Flat list of all LayoutLine for rendering + self.width = 0 + self.height = 0 + + def layout(self, width: int, x_margin: int = 20, y_start: int = 30) -> list: + """ + Layout the document and return a list of LayoutLine objects. + + Returns: + List of LayoutLine objects ready for rendering + """ + self.width = width + max_width = max(10, width - 2 * x_margin) + y = y_start + + self.blocks = [] + self.lines = [] + + # Find body + body = self._find_body(self.node) + if not body: + return self.lines + + # Collect and layout blocks + raw_blocks = self._collect_blocks(body) + + for block_info in raw_blocks: + font_size = block_info.get("font_size", 14) + text = block_info.get("text", "") + margin_top = block_info.get("margin_top", 6) + margin_bottom = block_info.get("margin_bottom", 10) + block_type = block_info.get("block_type", "block") + tag = block_info.get("tag", "") + + if not text: + y += font_size * 0.6 + continue + + # Optional bullet prefix + if block_info.get("bullet"): + text = f"• {text}" + + layout_block = LayoutBlock(tag, block_type) + layout_block.x = x_margin + layout_block.y = y + margin_top + + # Word wrap + font = get_font(font_size) + words = text.split() + wrapped_lines = [] + current_line = [] + current_width = 0 + + for word in words: + word_width = font.measureText(word + " ") + if current_width + word_width > max_width and current_line: + wrapped_lines.append(" ".join(current_line)) + current_line = [word] + current_width = word_width + else: + current_line.append(word) + current_width += word_width + if current_line: + wrapped_lines.append(" ".join(current_line)) + + # Create LayoutLines + line_height = linespace(font_size) + y += margin_top + block_start_y = y + + for line_text in wrapped_lines: + # Calculate character positions + char_positions = [0.0] + for i in range(1, len(line_text) + 1): + char_positions.append(font.measureText(line_text[:i])) + + layout_line = LayoutLine( + text=line_text, + x=x_margin, + y=y, # Top of line, baseline is y + font_size + font_size=font_size, + char_positions=char_positions + ) + + layout_block.lines.append(layout_line) + self.lines.append(layout_line) + y += line_height + + layout_block.height = y - block_start_y + layout_block.width = max_width + self.blocks.append(layout_block) + + y += margin_bottom + + self.height = y + 50 # Padding at bottom + return self.lines + + def _find_body(self, node): + """Find the body element in the document.""" + if isinstance(node, Element) and node.tag == "body": + return node + if hasattr(node, "children"): + for child in node.children: + if isinstance(child, Element) and child.tag == "body": + return child + found = self._find_body(child) + if found: + return found + return None + + def _collect_blocks(self, node) -> list: + """Collect renderable blocks from the DOM.""" + blocks = [] + + for child in getattr(node, "children", []): + if isinstance(child, Text): + txt = child.text.strip() + if txt: + blocks.append({"text": txt, "font_size": 14, "block_type": "text"}) + continue + + if isinstance(child, Element): + tag = child.tag.lower() + content = self._text_of(child) + if not content: + continue + + if tag == "h1": + blocks.append({ + "text": content, "font_size": 24, + "margin_top": 12, "margin_bottom": 12, + "block_type": "block", "tag": "h1" + }) + elif tag == "h2": + blocks.append({ + "text": content, "font_size": 20, + "margin_top": 10, "margin_bottom": 10, + "block_type": "block", "tag": "h2" + }) + elif tag == "h3": + blocks.append({ + "text": content, "font_size": 18, + "margin_top": 8, "margin_bottom": 8, + "block_type": "block", "tag": "h3" + }) + elif tag == "p": + blocks.append({ + "text": content, "font_size": 14, + "margin_top": 6, "margin_bottom": 12, + "block_type": "block", "tag": "p" + }) + elif tag == "li": + blocks.append({ + "text": content, "font_size": 14, "bullet": True, + "margin_top": 4, "margin_bottom": 4, + "block_type": "list-item", "tag": "li" + }) + elif tag in {"ul", "ol"}: + blocks.extend(self._collect_blocks(child)) + elif tag in {"span", "a", "strong", "em", "b", "i", "code"}: + blocks.append({ + "text": content, "font_size": 14, + "block_type": "inline", "tag": tag + }) + elif tag in {"div", "section", "article", "main", "header", "footer", "nav"}: + # Container elements - recurse into children + blocks.extend(self._collect_blocks(child)) + else: + blocks.append({ + "text": content, "font_size": 14, + "block_type": "block", "tag": tag + }) + + return blocks + + def _text_of(self, node) -> str: + """Extract text content from a node.""" + if isinstance(node, Text): + return node.text + if isinstance(node, Element): + parts = [] + for child in node.children: + parts.append(self._text_of(child)) + return " ".join([p for p in parts if p]).strip() + return "" diff --git a/src/layout/inline.py b/src/layout/inline.py index 69d9fdf..d58bf01 100644 --- a/src/layout/inline.py +++ b/src/layout/inline.py @@ -1,12 +1,73 @@ -"""Inline and text layout stubs.""" +"""Inline and text layout.""" + +from ..render.fonts import get_font, measure_text, linespace class TextLayout: - def __init__(self, node, word, parent=None, previous=None): + """Layout for a single word/text run.""" + + def __init__(self, node, word: str, parent=None, previous=None): self.node = node self.word = word self.parent = parent self.previous = previous + self.x = 0 + self.y = 0 + self.width = 0 + self.height = 0 + self.font_size = 14 + + def layout(self, font_size: int = 14): + """Calculate layout for this text.""" + self.font_size = font_size + font = get_font(font_size) + self.width = font.measureText(self.word) + self.height = linespace(font_size) + return self.width - def layout(self): - return len(self.word) + +class InlineLayout: + """Layout for inline content (text runs within a line).""" + + def __init__(self, node, parent=None): + self.node = node + self.parent = parent + self.children = [] + self.x = 0 + self.y = 0 + self.width = 0 + self.height = 0 + + def add_word(self, word: str, font_size: int = 14): + """Add a word to this inline layout.""" + text_layout = TextLayout(self.node, word, parent=self) + text_layout.layout(font_size) + self.children.append(text_layout) + return text_layout + + def layout(self, x: float, y: float, max_width: float, font_size: int = 14): + """Layout all words, wrapping as needed. Returns list of lines.""" + lines = [] + current_line = [] + current_x = x + line_y = y + line_height = linespace(font_size) + + for child in self.children: + if current_x + child.width > x + max_width and current_line: + # Wrap to next line + lines.append((current_line, line_y, line_height)) + current_line = [] + current_x = x + line_y += line_height + + child.x = current_x + child.y = line_y + current_line.append(child) + current_x += child.width + + if current_line: + lines.append((current_line, line_y, line_height)) + + self.height = line_y + line_height - y if lines else 0 + return lines diff --git a/src/render/fonts.py b/src/render/fonts.py index 5509657..4e03756 100644 --- a/src/render/fonts.py +++ b/src/render/fonts.py @@ -1,10 +1,58 @@ -"""Font management stubs.""" +"""Font management with Skia.""" + +import skia -def get_font(size: int, weight: str = "normal", style: str = "normal"): - return (size, weight, style) +class FontCache: + """Cache for Skia fonts and typefaces.""" + + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._font_cache = {} + cls._instance._default_typeface = None + return cls._instance + + def get_typeface(self): + """Get the default typeface, creating it if needed.""" + if self._default_typeface is None: + self._default_typeface = skia.Typeface.MakeDefault() + return self._default_typeface + + def get_font(self, size: int, weight: str = "normal", style: str = "normal") -> skia.Font: + """Get a cached Skia font for the given parameters.""" + key = (size, weight, style) + if key not in self._font_cache: + typeface = self.get_typeface() + self._font_cache[key] = skia.Font(typeface, size) + return self._font_cache[key] + + def measure_text(self, text: str, font: skia.Font) -> float: + """Measure the width of text using the given font.""" + return font.measureText(text) + + def get_line_height(self, font_size: int) -> float: + """Get the line height for a given font size.""" + return font_size * 1.4 -def linespace(font) -> int: - size, _, _ = font - return int(size * 1.2) +# Global font cache instance +_font_cache = FontCache() + + +def get_font(size: int, weight: str = "normal", style: str = "normal") -> skia.Font: + """Get a cached font.""" + return _font_cache.get_font(size, weight, style) + + +def measure_text(text: str, size: int) -> float: + """Measure text width at given font size.""" + font = get_font(size) + return _font_cache.measure_text(text, font) + + +def linespace(font_size: int) -> float: + """Get line height for font size.""" + return _font_cache.get_line_height(font_size) diff --git a/src/render/paint.py b/src/render/paint.py index 7c3fcd0..045d889 100644 --- a/src/render/paint.py +++ b/src/render/paint.py @@ -1,18 +1,77 @@ -"""Painting primitives (stubs).""" +"""Painting primitives using Skia.""" + +import skia +from .fonts import get_font class PaintCommand: + """Base class for paint commands.""" + def __init__(self, rect): - self.rect = rect + self.rect = rect # (x1, y1, x2, y2) bounding box + + def execute(self, canvas: skia.Canvas): + """Execute this paint command on the canvas.""" + raise NotImplementedError class DrawText(PaintCommand): - def __init__(self, x1, y1, text, font, color): - super().__init__((x1, y1, x1, y1)) + """Command to draw text.""" + + def __init__(self, x: float, y: float, text: str, font_size: int, color=None): + self.x = x + self.y = y self.text = text - self.font = font - self.color = color + self.font_size = font_size + self.color = color or skia.ColorBLACK + self._font = get_font(font_size) + width = self._font.measureText(text) + super().__init__((x, y - font_size, x + width, y)) + + def execute(self, canvas: skia.Canvas, paint: skia.Paint = None): + """Draw the text on the canvas.""" + if paint is None: + paint = skia.Paint() + paint.setAntiAlias(True) + paint.setColor(self.color) + canvas.drawString(self.text, self.x, self.y, self._font, paint) - def execute(self, canvas): - # Placeholder: integrate with Skia/Cairo later - pass + +class DrawRect(PaintCommand): + """Command to draw a rectangle.""" + + def __init__(self, x1: float, y1: float, x2: float, y2: float, color, fill: bool = True): + super().__init__((x1, y1, x2, y2)) + self.color = color + self.fill = fill + + def execute(self, canvas: skia.Canvas, paint: skia.Paint = None): + """Draw the rectangle on the canvas.""" + if paint is None: + paint = skia.Paint() + paint.setColor(self.color) + paint.setStyle(skia.Paint.kFill_Style if self.fill else skia.Paint.kStroke_Style) + rect = skia.Rect.MakeLTRB(*self.rect) + canvas.drawRect(rect, paint) + + +class DisplayList: + """A list of paint commands to execute.""" + + def __init__(self): + self.commands = [] + + def append(self, command: PaintCommand): + """Add a paint command.""" + self.commands.append(command) + + def execute(self, canvas: skia.Canvas, paint: skia.Paint = None): + """Execute all commands on the canvas.""" + for cmd in self.commands: + cmd.execute(canvas, paint) + + def __len__(self): + return len(self.commands) + + def __iter__(self): + return iter(self.commands)