Enhance layout and rendering features with new document and block layout implementations

This commit is contained in:
Benedikt Willi 2026-01-11 23:34:27 +01:00
parent 39b03bf9cc
commit 8d2fd3b16e
7 changed files with 546 additions and 195 deletions

5
.gitignore vendored
View file

@ -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

View file

@ -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
@ -329,8 +329,6 @@ class Chrome:
def on_draw(self, drawing_area, context, width, height):
"""Callback for drawing the content area using Skia."""
import time
# 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,155 +484,44 @@ 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
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
})
# 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")
# 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
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
self.document_height = doc_layout.height
def _draw_selection_highlight(self, canvas, width: int):
"""Draw selection highlight rectangle."""
@ -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")

View file

@ -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 = []
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):
return 0
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 ""
def layout(self):
return 0
# 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 _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 ""

View file

@ -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 = []
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, zoom: float = 1.0):
# Placeholder layout logic
return width * zoom
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 ""

View file

@ -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):
return len(self.word)
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
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

View file

@ -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)

View file

@ -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))
self.text = text
self.font = font
self.color = color
"""Command to draw text."""
def execute(self, canvas):
# Placeholder: integrate with Skia/Cairo later
pass
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_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)
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)