mirror of
https://github.com/Hopiu/bowser.git
synced 2026-03-16 19:10:24 +00:00
- Implement PNG rendering for DOM graphs, with fallback to DOT format. - Add support for max-width constraints in image layout based on CSS styles. - Introduce caching mechanisms for image loading, including tracking failed and pending loads. - Update HTML parser to handle void elements correctly. - Modify template rendering to support PNG graph files.
301 lines
10 KiB
Python
301 lines
10 KiB
Python
"""Render pipeline - coordinates layout and painting."""
|
|
|
|
import skia
|
|
from typing import Optional, Callable
|
|
from ..parser.html import Element
|
|
from ..layout.document import DocumentLayout
|
|
from ..layout.embed import ImageLayout
|
|
from .fonts import get_font
|
|
from .paint import DisplayList, DrawImage
|
|
|
|
|
|
class RenderPipeline:
|
|
"""Coordinates layout calculation and rendering to a Skia canvas."""
|
|
|
|
def __init__(self):
|
|
# Layout cache
|
|
self._layout: Optional[DocumentLayout] = None
|
|
self._layout_width = 0
|
|
self._layout_doc_id = None
|
|
self._layout_base_url = None
|
|
|
|
# Paint cache
|
|
self._text_paint: Optional[skia.Paint] = None
|
|
self._display_list: Optional[DisplayList] = None
|
|
|
|
# Base URL for resolving relative paths
|
|
self.base_url: Optional[str] = None
|
|
|
|
# Debug mode
|
|
self.debug_mode = False
|
|
|
|
# Async image loading
|
|
self.async_images = True # Enable async image loading by default
|
|
self._on_needs_redraw: Optional[Callable[[], None]] = None
|
|
|
|
def set_redraw_callback(self, callback: Callable[[], None]):
|
|
"""Set a callback to be called when async images finish loading."""
|
|
self._on_needs_redraw = callback
|
|
|
|
# Also set on ImageLayout class for global notification
|
|
def on_image_loaded():
|
|
# Invalidate layout cache so positions are recalculated with actual image sizes
|
|
self.invalidate()
|
|
if self._on_needs_redraw:
|
|
self._on_needs_redraw()
|
|
|
|
ImageLayout._on_any_image_loaded = on_image_loaded
|
|
|
|
def layout(self, document: Element, width: int) -> DocumentLayout:
|
|
"""
|
|
Calculate layout for the document.
|
|
Returns the DocumentLayout with all positioned elements.
|
|
"""
|
|
doc_id = id(document)
|
|
|
|
# Check cache (also invalidate if base_url changed)
|
|
if (self._layout_doc_id == doc_id and
|
|
self._layout_width == width and
|
|
self._layout_base_url == self.base_url and
|
|
self._layout is not None):
|
|
return self._layout
|
|
|
|
# Build new layout with base_url for resolving image paths
|
|
self._layout = DocumentLayout(
|
|
document,
|
|
base_url=self.base_url,
|
|
async_images=self.async_images
|
|
)
|
|
self._layout.layout(width)
|
|
self._layout_doc_id = doc_id
|
|
self._layout_width = width
|
|
self._layout_base_url = self.base_url
|
|
|
|
return self._layout
|
|
|
|
def render(self, canvas: skia.Canvas, document: Element,
|
|
width: int, height: int, scroll_y: float = 0):
|
|
"""
|
|
Render the document to the canvas.
|
|
|
|
Args:
|
|
canvas: Skia canvas to draw on
|
|
document: DOM document root
|
|
width: Viewport width
|
|
height: Viewport height
|
|
scroll_y: Vertical scroll offset
|
|
"""
|
|
# Get or update layout
|
|
layout = self.layout(document, width)
|
|
|
|
if not layout.lines and not layout.images:
|
|
return
|
|
|
|
# Apply scroll transform
|
|
canvas.save()
|
|
canvas.translate(0, -scroll_y)
|
|
|
|
# Get paint
|
|
if self._text_paint is None:
|
|
self._text_paint = skia.Paint()
|
|
self._text_paint.setAntiAlias(True)
|
|
self._text_paint.setColor(skia.ColorBLACK)
|
|
|
|
# Render visible content
|
|
visible_top = scroll_y - 50
|
|
visible_bottom = scroll_y + height + 50
|
|
|
|
# Render visible lines
|
|
for line in layout.lines:
|
|
baseline_y = line.y + line.font_size
|
|
if baseline_y < visible_top or line.y > visible_bottom:
|
|
continue
|
|
|
|
font = get_font(line.font_size, getattr(line, "font_family", ""), text=line.text)
|
|
|
|
# Use line color if specified (for links), otherwise black
|
|
paint = skia.Paint()
|
|
paint.setAntiAlias(True)
|
|
if line.color:
|
|
paint.setColor(self._parse_color(line.color))
|
|
else:
|
|
paint.setColor(skia.ColorBLACK)
|
|
|
|
canvas.drawString(line.text, line.x, baseline_y, font, paint)
|
|
|
|
# Draw underline for links
|
|
if line.href:
|
|
underline_paint = skia.Paint()
|
|
underline_paint.setColor(paint.getColor())
|
|
underline_paint.setStyle(skia.Paint.kStroke_Style)
|
|
underline_paint.setStrokeWidth(1)
|
|
underline_y = baseline_y + 2
|
|
canvas.drawLine(line.x, underline_y, line.x + line.width, underline_y, underline_paint)
|
|
|
|
# Render visible images (both loaded and placeholder)
|
|
for layout_image in layout.images:
|
|
image_bottom = layout_image.y + layout_image.height
|
|
if image_bottom < visible_top or layout_image.y > visible_bottom:
|
|
continue
|
|
|
|
image_layout = layout_image.image_layout
|
|
# Use image_layout dimensions directly for accurate sizing after async load
|
|
img_width = image_layout.width if image_layout.width > 0 else layout_image.width
|
|
img_height = image_layout.height if image_layout.height > 0 else layout_image.height
|
|
|
|
# Always create DrawImage command - it handles None images as placeholders
|
|
draw_cmd = DrawImage(
|
|
layout_image.x,
|
|
layout_image.y,
|
|
img_width,
|
|
img_height,
|
|
image_layout.image, # May be None, DrawImage handles this
|
|
image_layout.alt_text
|
|
)
|
|
draw_cmd.execute(canvas)
|
|
|
|
# Draw debug overlays if enabled
|
|
if self.debug_mode:
|
|
self._render_debug_overlays(canvas, layout)
|
|
|
|
canvas.restore()
|
|
|
|
def _render_debug_overlays(self, canvas: skia.Canvas, layout: DocumentLayout):
|
|
"""Render debug bounding boxes for layout blocks."""
|
|
# Color scheme for different block types
|
|
colors = {
|
|
"block": (255, 0, 0, 60), # Red
|
|
"inline": (0, 0, 255, 60), # Blue
|
|
"list-item": (0, 255, 0, 60), # Green
|
|
"text": (255, 255, 0, 60), # Yellow
|
|
}
|
|
|
|
border_colors = {
|
|
"block": (255, 0, 0, 180),
|
|
"inline": (0, 0, 255, 180),
|
|
"list-item": (0, 255, 0, 180),
|
|
"text": (255, 255, 0, 180),
|
|
}
|
|
|
|
for block in layout.blocks:
|
|
block_type = block.block_type
|
|
|
|
# Calculate block bounds from lines
|
|
if not block.lines:
|
|
continue
|
|
|
|
x = block.x - 5
|
|
y = block.y - block.lines[0].font_size if block.lines else block.y
|
|
w = block.width + 10
|
|
h = block.height + 5
|
|
|
|
# Fill
|
|
fill_paint = skia.Paint()
|
|
c = colors.get(block_type, colors["block"])
|
|
fill_paint.setColor(skia.Color(*c))
|
|
fill_paint.setStyle(skia.Paint.kFill_Style)
|
|
|
|
rect = skia.Rect.MakeLTRB(x, y, x + w, y + h)
|
|
canvas.drawRect(rect, fill_paint)
|
|
|
|
# Border
|
|
border_paint = skia.Paint()
|
|
bc = border_colors.get(block_type, border_colors["block"])
|
|
border_paint.setColor(skia.Color(*bc))
|
|
border_paint.setStyle(skia.Paint.kStroke_Style)
|
|
border_paint.setStrokeWidth(1)
|
|
canvas.drawRect(rect, border_paint)
|
|
|
|
def get_text_layout(self) -> list:
|
|
"""
|
|
Get the text layout for text selection and link hit testing.
|
|
Returns list of line info dicts with char_positions and href.
|
|
"""
|
|
if self._layout is None:
|
|
return []
|
|
|
|
result = []
|
|
for line in self._layout.lines:
|
|
result.append({
|
|
"text": line.text,
|
|
"x": line.x,
|
|
"y": line.y,
|
|
"width": line.width,
|
|
"height": line.height,
|
|
"font_size": line.font_size,
|
|
"char_positions": line.char_positions,
|
|
"href": getattr(line, "href", None)
|
|
})
|
|
return result
|
|
|
|
def get_document_height(self) -> float:
|
|
"""Get the total document height for scrolling."""
|
|
if self._layout is None:
|
|
return 0
|
|
return self._layout.height
|
|
|
|
def invalidate(self):
|
|
"""Invalidate the layout cache, forcing recalculation."""
|
|
self._layout = None
|
|
self._layout_doc_id = None
|
|
self._display_list = None
|
|
|
|
def _parse_color(self, color_str: str) -> int:
|
|
"""Parse a CSS color string to a Skia color value.
|
|
|
|
Supports:
|
|
- Hex colors: #rgb, #rrggbb
|
|
- Named colors (limited set)
|
|
|
|
Note: Very light colors (like white) that would be invisible on
|
|
our white background are converted to black.
|
|
"""
|
|
if not color_str:
|
|
return skia.ColorBLACK
|
|
|
|
color_str = color_str.strip().lower()
|
|
|
|
# Named colors
|
|
named_colors = {
|
|
"black": skia.ColorBLACK,
|
|
"white": skia.ColorBLACK, # White is invisible on white bg, use black
|
|
"red": skia.ColorRED,
|
|
"green": skia.ColorGREEN,
|
|
"blue": skia.ColorBLUE,
|
|
"yellow": skia.ColorYELLOW,
|
|
"cyan": skia.ColorCYAN,
|
|
"magenta": skia.ColorMAGENTA,
|
|
"gray": skia.ColorGRAY,
|
|
"grey": skia.ColorGRAY,
|
|
}
|
|
|
|
if color_str in named_colors:
|
|
return named_colors[color_str]
|
|
|
|
# Hex colors
|
|
if color_str.startswith("#"):
|
|
hex_str = color_str[1:]
|
|
try:
|
|
if len(hex_str) == 3:
|
|
# #rgb -> #rrggbb
|
|
r = int(hex_str[0] * 2, 16)
|
|
g = int(hex_str[1] * 2, 16)
|
|
b = int(hex_str[2] * 2, 16)
|
|
elif len(hex_str) == 6:
|
|
r = int(hex_str[0:2], 16)
|
|
g = int(hex_str[2:4], 16)
|
|
b = int(hex_str[4:6], 16)
|
|
else:
|
|
return skia.ColorBLACK
|
|
|
|
# Check if color is too light (would be invisible on white)
|
|
# Use relative luminance approximation
|
|
if r > 240 and g > 240 and b > 240:
|
|
return skia.ColorBLACK
|
|
|
|
return skia.Color(r, g, b, 255)
|
|
except ValueError:
|
|
pass
|
|
|
|
# Fallback to black
|
|
return skia.ColorBLACK
|