mirror of
https://github.com/Hopiu/bowser.git
synced 2026-03-16 19:10:24 +00:00
Enhance layout and rendering features with new document and block layout implementations
This commit is contained in:
parent
39b03bf9cc
commit
8d2fd3b16e
7 changed files with 546 additions and 195 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue