mirror of
https://github.com/Hopiu/bowser.git
synced 2026-03-16 19:10:24 +00:00
This commit introduces several enhancements to the browser rendering engine. Key changes include the ability to handle link clicks, improved link detection, and enhanced color parsing for proper rendering of styled links. The following modifications are included:
- **Link Detection and Navigation**: Added functionality to detect if a mouse click falls within a link area. If a link is clicked, the browser now navigates to the corresponding URL while logging the action. This also includes handling relative URLs based on the current page context. - **Line Layout Enhancements**: The `LayoutLine` class now includes optional attributes for color and href, allowing links to maintain their designated colors in the rendered output. - **Color Parsing**: Implemented a new `_parse_color` method in the `RenderPipeline` class to convert various color formats (hex and named colors) to Skia-compatible values. This ensures that default link colors are correctly applied and that extremely light colors are rendered as black for visibility. - **Rendering Links**: During the rendering process, links in the text layout are now rendered with their specified colors, and an underline is drawn under links to indicate interactivity. - **Document Layout Updates**: The document parsing system has been updated to extract link information correctly while preserving text hierarchy. - **Tests**: A comprehensive suite of tests has been added, including tests for link parsing, layout characteristics, styling application, and default color handling for links.
This commit is contained in:
parent
762dd22e31
commit
8c2d360515
16 changed files with 1012 additions and 431 deletions
|
|
@ -149,7 +149,7 @@ class Chrome:
|
|||
self.drawing_area.set_can_focus(True) # Allow focus for keyboard events
|
||||
self.drawing_area.set_focusable(True)
|
||||
content_box.append(self.drawing_area)
|
||||
|
||||
|
||||
# Set up redraw callback for async image loading
|
||||
self.render_pipeline.set_redraw_callback(self._request_redraw)
|
||||
|
||||
|
|
@ -412,7 +412,7 @@ class Chrome:
|
|||
|
||||
# Sync debug mode with render pipeline
|
||||
self.render_pipeline.debug_mode = self.debug_mode
|
||||
|
||||
|
||||
# Set base URL for resolving relative image paths
|
||||
if self.browser.active_tab and self.browser.active_tab.current_url:
|
||||
self.render_pipeline.base_url = str(self.browser.active_tab.current_url)
|
||||
|
|
@ -561,7 +561,7 @@ class Chrome:
|
|||
"""Trigger redraw of the drawing area."""
|
||||
if self.drawing_area and self.window:
|
||||
self.drawing_area.queue_draw()
|
||||
|
||||
|
||||
def _request_redraw(self):
|
||||
"""Request a redraw, called when async images finish loading."""
|
||||
# This is called from the main thread via GLib.idle_add
|
||||
|
|
@ -736,11 +736,32 @@ class Chrome:
|
|||
self.drawing_area.grab_focus()
|
||||
|
||||
def _on_mouse_released(self, gesture, n_press, x, y):
|
||||
"""Handle mouse button release for text selection."""
|
||||
"""Handle mouse button release for text selection or link clicks."""
|
||||
click_x = x
|
||||
click_y = y + self.scroll_y
|
||||
|
||||
if self.is_selecting:
|
||||
self.selection_end = (x, y + self.scroll_y)
|
||||
self.selection_end = (click_x, click_y)
|
||||
self.is_selecting = False
|
||||
# Extract selected text
|
||||
|
||||
# Check if this is a click (not a drag)
|
||||
if self.selection_start:
|
||||
dx = abs(click_x - self.selection_start[0])
|
||||
dy = abs(click_y - self.selection_start[1])
|
||||
is_click = dx < 5 and dy < 5
|
||||
|
||||
if is_click:
|
||||
# Check if we clicked on a link
|
||||
href = self._get_link_at_position(click_x, click_y)
|
||||
if href:
|
||||
self.logger.info(f"Link clicked: {href}")
|
||||
self._navigate_to_link(href)
|
||||
# Clear selection since we're navigating
|
||||
self.selection_start = None
|
||||
self.selection_end = None
|
||||
return
|
||||
|
||||
# Extract selected text (for drag selection)
|
||||
selected_text = self._get_selected_text()
|
||||
if selected_text:
|
||||
self.logger.info(f"Selected text: {selected_text[:100]}...")
|
||||
|
|
@ -748,6 +769,46 @@ class Chrome:
|
|||
self._copy_to_clipboard(selected_text)
|
||||
self.paint()
|
||||
|
||||
def _get_link_at_position(self, x: float, y: float) -> str | None:
|
||||
"""Get the href of a link at the given position, or None."""
|
||||
for line_info in self.text_layout:
|
||||
line_top = line_info["y"]
|
||||
line_bottom = line_info["y"] + line_info["height"]
|
||||
line_left = line_info["x"]
|
||||
line_right = line_info["x"] + line_info["width"]
|
||||
|
||||
# Check if click is within this line's bounding box
|
||||
if line_top <= y <= line_bottom and line_left <= x <= line_right:
|
||||
href = line_info.get("href")
|
||||
if href:
|
||||
return href
|
||||
return None
|
||||
|
||||
def _navigate_to_link(self, href: str):
|
||||
"""Navigate to a link, handling relative URLs."""
|
||||
if not href:
|
||||
return
|
||||
|
||||
# Handle special URLs
|
||||
if href.startswith("#"):
|
||||
# Anchor link - for now just ignore (future: scroll to anchor)
|
||||
self.logger.debug(f"Ignoring anchor link: {href}")
|
||||
return
|
||||
|
||||
if href.startswith("javascript:"):
|
||||
# JavaScript URLs - ignore for security
|
||||
self.logger.debug(f"Ignoring javascript link: {href}")
|
||||
return
|
||||
|
||||
# Resolve relative URLs against current page URL
|
||||
if self.browser.active_tab and self.browser.active_tab.current_url:
|
||||
base_url = self.browser.active_tab.current_url
|
||||
resolved_url = base_url.resolve(href)
|
||||
self.browser.navigate_to(str(resolved_url))
|
||||
else:
|
||||
# No current URL, treat href as absolute
|
||||
self.browser.navigate_to(href)
|
||||
|
||||
def _on_mouse_motion(self, controller, x, y):
|
||||
"""Handle mouse motion for drag selection."""
|
||||
if self.is_selecting:
|
||||
|
|
|
|||
|
|
@ -8,12 +8,15 @@ from .embed import ImageLayout
|
|||
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, font_family: str = ""):
|
||||
def __init__(self, text: str, x: float, y: float, font_size: int,
|
||||
char_positions: list = None, font_family: str = "", color: str = None, href: str = None):
|
||||
self.text = text
|
||||
self.x = x
|
||||
self.y = y # Top of line
|
||||
self.font_size = font_size
|
||||
self.font_family = font_family
|
||||
self.color = color # Text color (e.g., "#0066cc" for links)
|
||||
self.href = href # Link target URL if this is a link
|
||||
self.height = linespace(font_size)
|
||||
self.width = 0
|
||||
self.char_positions = char_positions or []
|
||||
|
|
@ -34,12 +37,12 @@ class LayoutImage:
|
|||
# Store initial dimensions but also provide dynamic access
|
||||
self._initial_width = image_layout.width
|
||||
self._initial_height = image_layout.height
|
||||
|
||||
|
||||
@property
|
||||
def width(self) -> float:
|
||||
"""Get current width (may update after async image load)."""
|
||||
return self.image_layout.width if self.image_layout.width > 0 else self._initial_width
|
||||
|
||||
|
||||
@property
|
||||
def height(self) -> float:
|
||||
"""Get current height (may update after async image load)."""
|
||||
|
|
@ -104,18 +107,18 @@ class DocumentLayout:
|
|||
margin_top = block_info.get("margin_top", 6)
|
||||
margin_bottom = block_info.get("margin_bottom", 10)
|
||||
y += margin_top
|
||||
|
||||
|
||||
# Position the image
|
||||
image_layout.x = x_margin
|
||||
image_layout.y = y
|
||||
|
||||
|
||||
# Add to images list for rendering
|
||||
layout_image = LayoutImage(image_layout, x_margin, y)
|
||||
self.images.append(layout_image)
|
||||
|
||||
|
||||
y += image_layout.height + margin_bottom
|
||||
continue
|
||||
|
||||
|
||||
font_size = block_info.get("font_size", 14)
|
||||
font_family = block_info.get("font_family", "")
|
||||
text = block_info.get("text", "")
|
||||
|
|
@ -123,6 +126,8 @@ class DocumentLayout:
|
|||
margin_bottom = block_info.get("margin_bottom", 10)
|
||||
block_type = block_info.get("block_type", "block")
|
||||
tag = block_info.get("tag", "")
|
||||
color = block_info.get("color") # Text color from style
|
||||
href = block_info.get("href") # Link target URL
|
||||
|
||||
if not text:
|
||||
y += font_size * 0.6
|
||||
|
|
@ -172,7 +177,9 @@ class DocumentLayout:
|
|||
y=y, # Top of line, baseline is y + font_size
|
||||
font_size=font_size,
|
||||
char_positions=char_positions,
|
||||
font_family=font_family
|
||||
font_family=font_family,
|
||||
color=color,
|
||||
href=href
|
||||
)
|
||||
|
||||
layout_block.lines.append(layout_line)
|
||||
|
|
@ -228,13 +235,13 @@ class DocumentLayout:
|
|||
# Skip style and script tags - they shouldn't be rendered
|
||||
if tag in {"style", "script", "head", "title", "meta", "link"}:
|
||||
continue
|
||||
|
||||
|
||||
# Handle img tags
|
||||
if tag == "img":
|
||||
image_layout = ImageLayout(child)
|
||||
image_layout.load(self.base_url, async_load=self.async_images)
|
||||
image_layout.layout(max_width=self.width - 40 if self.width > 40 else 800)
|
||||
|
||||
|
||||
# Get computed style for margins
|
||||
style = getattr(child, "computed_style", None)
|
||||
if style:
|
||||
|
|
@ -243,7 +250,7 @@ class DocumentLayout:
|
|||
else:
|
||||
margin_top = 6
|
||||
margin_bottom = 10
|
||||
|
||||
|
||||
blocks.append({
|
||||
"is_image": True,
|
||||
"image_layout": image_layout,
|
||||
|
|
@ -257,10 +264,57 @@ class DocumentLayout:
|
|||
blocks.extend(self._collect_blocks(child))
|
||||
continue
|
||||
|
||||
# For other elements (p, h1, etc), first collect any embedded images
|
||||
# Inline elements inside block elements are handled by _text_of
|
||||
# Only create separate blocks for inline elements if they're direct
|
||||
# children of container elements (handled above via recursion)
|
||||
if tag in {"span", "strong", "em", "b", "i", "code"}:
|
||||
# Skip - these are handled as part of parent's text
|
||||
continue
|
||||
|
||||
# Handle anchor elements - they can be inline or standalone
|
||||
if tag == "a":
|
||||
# Get the href and treat this as a clickable block
|
||||
href = child.attributes.get("href")
|
||||
content = self._text_of(child)
|
||||
if not content:
|
||||
continue
|
||||
|
||||
style = getattr(child, "computed_style", None)
|
||||
if style:
|
||||
font_size = style.get_int("font-size", 14)
|
||||
color = style.get("color")
|
||||
font_family = style.get("font-family", "")
|
||||
else:
|
||||
font_size = 14
|
||||
color = None
|
||||
font_family = ""
|
||||
|
||||
# Default link color
|
||||
if not color:
|
||||
color = "#0066cc"
|
||||
|
||||
blocks.append({
|
||||
"text": content,
|
||||
"font_size": font_size,
|
||||
"font_family": font_family,
|
||||
"margin_top": 0,
|
||||
"margin_bottom": 0,
|
||||
"block_type": "inline",
|
||||
"tag": tag,
|
||||
"bullet": False,
|
||||
"style": style,
|
||||
"color": color,
|
||||
"href": href
|
||||
})
|
||||
continue
|
||||
|
||||
# For block elements (p, h1, etc), first collect any embedded images
|
||||
embedded_images = self._collect_images(child)
|
||||
blocks.extend(embedded_images)
|
||||
|
||||
|
||||
# Check if this element contains only a link
|
||||
link_info = self._extract_single_link(child)
|
||||
|
||||
content = self._text_of(child)
|
||||
if not content:
|
||||
continue
|
||||
|
|
@ -275,6 +329,7 @@ class DocumentLayout:
|
|||
margin_bottom = style.get_int("margin-bottom", 10)
|
||||
display = style.get("display", "block")
|
||||
font_family = style.get("font-family", "")
|
||||
color = style.get("color") # Get text color from style
|
||||
else:
|
||||
# Fallback to hardcoded defaults
|
||||
font_size = self._get_default_font_size(tag)
|
||||
|
|
@ -282,6 +337,14 @@ class DocumentLayout:
|
|||
margin_bottom = self._get_default_margin_bottom(tag)
|
||||
display = "inline" if tag in {"span", "a", "strong", "em", "b", "i", "code"} else "block"
|
||||
font_family = ""
|
||||
color = None
|
||||
|
||||
# If block contains only a link, use link info for href and color
|
||||
href = None
|
||||
if link_info:
|
||||
href = link_info.get("href")
|
||||
if not color:
|
||||
color = link_info.get("color", "#0066cc")
|
||||
|
||||
# Determine block type
|
||||
block_type = "inline" if display == "inline" else "block"
|
||||
|
|
@ -300,7 +363,9 @@ class DocumentLayout:
|
|||
"block_type": block_type,
|
||||
"tag": tag,
|
||||
"bullet": bullet,
|
||||
"style": style
|
||||
"style": style,
|
||||
"color": color,
|
||||
"href": href
|
||||
})
|
||||
|
||||
return blocks
|
||||
|
|
@ -326,20 +391,56 @@ class DocumentLayout:
|
|||
}
|
||||
return margins.get(tag, 0)
|
||||
|
||||
def _extract_single_link(self, node) -> dict | None:
|
||||
"""Extract link info if node contains only a single link.
|
||||
|
||||
Returns dict with href and color if the element contains only
|
||||
a link (possibly with some whitespace text), None otherwise.
|
||||
"""
|
||||
if not isinstance(node, Element):
|
||||
return None
|
||||
|
||||
links = []
|
||||
has_other_content = False
|
||||
|
||||
for child in node.children:
|
||||
if isinstance(child, Text):
|
||||
# Whitespace-only text is okay
|
||||
if child.text.strip():
|
||||
has_other_content = True
|
||||
elif isinstance(child, Element):
|
||||
if child.tag.lower() == "a":
|
||||
links.append(child)
|
||||
else:
|
||||
# Has other elements besides links
|
||||
has_other_content = True
|
||||
|
||||
# Return link info only if there's exactly one link and no other content
|
||||
if len(links) == 1 and not has_other_content:
|
||||
link = links[0]
|
||||
style = getattr(link, "computed_style", None)
|
||||
color = style.get("color") if style else None
|
||||
return {
|
||||
"href": link.attributes.get("href"),
|
||||
"color": color or "#0066cc"
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
def _collect_images(self, node) -> list:
|
||||
"""Recursively collect all img elements from a node."""
|
||||
images = []
|
||||
|
||||
|
||||
if not isinstance(node, Element):
|
||||
return images
|
||||
|
||||
|
||||
for child in getattr(node, "children", []):
|
||||
if isinstance(child, Element):
|
||||
if child.tag.lower() == "img":
|
||||
image_layout = ImageLayout(child)
|
||||
image_layout.load(self.base_url, async_load=self.async_images)
|
||||
image_layout.layout(max_width=self.width - 40 if self.width > 40 else 800)
|
||||
|
||||
|
||||
style = getattr(child, "computed_style", None)
|
||||
if style:
|
||||
margin_top = style.get_int("margin-top", 6)
|
||||
|
|
@ -347,7 +448,7 @@ class DocumentLayout:
|
|||
else:
|
||||
margin_top = 6
|
||||
margin_bottom = 10
|
||||
|
||||
|
||||
images.append({
|
||||
"is_image": True,
|
||||
"image_layout": image_layout,
|
||||
|
|
@ -357,9 +458,9 @@ class DocumentLayout:
|
|||
else:
|
||||
# Recurse into children
|
||||
images.extend(self._collect_images(child))
|
||||
|
||||
|
||||
return images
|
||||
|
||||
|
||||
def _text_of(self, node) -> str:
|
||||
"""Extract text content from a node."""
|
||||
if isinstance(node, Text):
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import logging
|
|||
from typing import Optional, Callable
|
||||
import skia
|
||||
|
||||
from ..network.images import load_image, load_image_async, ImageCache
|
||||
from ..network.images import load_image, load_image_async
|
||||
|
||||
|
||||
logger = logging.getLogger("bowser.layout.embed")
|
||||
|
|
@ -16,10 +16,10 @@ OnImageLoadedCallback = Callable[["ImageLayout"], None]
|
|||
|
||||
class ImageLayout:
|
||||
"""Layout for an <img> element."""
|
||||
|
||||
|
||||
# Global callback for image load completion (set by render pipeline)
|
||||
_on_any_image_loaded: Optional[Callable[[], None]] = None
|
||||
|
||||
|
||||
def __init__(self, node, parent=None, previous=None, frame=None):
|
||||
self.node = node
|
||||
self.parent = parent
|
||||
|
|
@ -36,38 +36,38 @@ class ImageLayout:
|
|||
self._load_task_id: Optional[int] = None
|
||||
self._src = ""
|
||||
self._base_url: Optional[str] = None
|
||||
|
||||
|
||||
def load(self, base_url: Optional[str] = None, async_load: bool = False):
|
||||
"""
|
||||
Load the image from the src attribute.
|
||||
|
||||
|
||||
Args:
|
||||
base_url: Base URL for resolving relative paths
|
||||
async_load: If True, load in background thread (non-blocking)
|
||||
"""
|
||||
if not hasattr(self.node, 'attributes'):
|
||||
return
|
||||
|
||||
|
||||
src = self.node.attributes.get('src', '')
|
||||
if not src:
|
||||
logger.warning("Image element has no src attribute")
|
||||
return
|
||||
|
||||
|
||||
# Get alt text
|
||||
self.alt_text = self.node.attributes.get('alt', '')
|
||||
self._src = src
|
||||
self._base_url = base_url
|
||||
|
||||
|
||||
if async_load:
|
||||
self._load_async(src, base_url)
|
||||
else:
|
||||
# Synchronous load (for tests or cached images)
|
||||
self.image = load_image(src, base_url)
|
||||
|
||||
|
||||
def _load_async(self, src: str, base_url: Optional[str]):
|
||||
"""Load image asynchronously."""
|
||||
self._loading = True
|
||||
|
||||
|
||||
def on_complete(image: Optional[skia.Image]):
|
||||
self._loading = False
|
||||
self.image = image
|
||||
|
|
@ -78,25 +78,25 @@ class ImageLayout:
|
|||
# Trigger re-render
|
||||
if ImageLayout._on_any_image_loaded:
|
||||
ImageLayout._on_any_image_loaded()
|
||||
|
||||
|
||||
def on_error(e: Exception):
|
||||
self._loading = False
|
||||
logger.error(f"Async image load failed: {src}: {e}")
|
||||
|
||||
|
||||
self._load_task_id = load_image_async(src, base_url, on_complete, on_error)
|
||||
|
||||
|
||||
def _update_dimensions(self):
|
||||
"""Update dimensions based on loaded image."""
|
||||
if not self.image:
|
||||
return
|
||||
|
||||
|
||||
# Get explicit width/height attributes
|
||||
width_attr = self.node.attributes.get('width', '') if hasattr(self.node, 'attributes') else ''
|
||||
height_attr = self.node.attributes.get('height', '') if hasattr(self.node, 'attributes') else ''
|
||||
|
||||
|
||||
intrinsic_width = self.image.width()
|
||||
intrinsic_height = self.image.height()
|
||||
|
||||
|
||||
# Calculate dimensions based on attributes or intrinsic size
|
||||
if width_attr and height_attr:
|
||||
# Both specified - use them
|
||||
|
|
@ -134,12 +134,12 @@ class ImageLayout:
|
|||
# No explicit dimensions - use intrinsic size
|
||||
self.width = intrinsic_width
|
||||
self.height = intrinsic_height
|
||||
|
||||
|
||||
@property
|
||||
def is_loading(self) -> bool:
|
||||
"""True if image is currently being loaded."""
|
||||
return self._loading
|
||||
|
||||
|
||||
def cancel_load(self):
|
||||
"""Cancel any pending async load."""
|
||||
if self._load_task_id is not None:
|
||||
|
|
@ -147,11 +147,11 @@ class ImageLayout:
|
|||
cancel_task(self._load_task_id)
|
||||
self._load_task_id = None
|
||||
self._loading = False
|
||||
|
||||
|
||||
def layout(self, max_width: Optional[float] = None):
|
||||
"""
|
||||
Calculate the layout dimensions for this image.
|
||||
|
||||
|
||||
Returns:
|
||||
Width of the image (for inline layout)
|
||||
"""
|
||||
|
|
@ -161,15 +161,15 @@ class ImageLayout:
|
|||
self.width = 100
|
||||
self.height = 100
|
||||
return self.width
|
||||
|
||||
|
||||
# Get explicit width/height attributes
|
||||
width_attr = self.node.attributes.get('width', '') if hasattr(self.node, 'attributes') else ''
|
||||
height_attr = self.node.attributes.get('height', '') if hasattr(self.node, 'attributes') else ''
|
||||
|
||||
|
||||
# Get intrinsic dimensions
|
||||
intrinsic_width = self.image.width()
|
||||
intrinsic_height = self.image.height()
|
||||
|
||||
|
||||
# Calculate display dimensions
|
||||
if width_attr and height_attr:
|
||||
# Both specified
|
||||
|
|
@ -207,13 +207,13 @@ class ImageLayout:
|
|||
# No dimensions specified - use intrinsic size
|
||||
self.width = intrinsic_width
|
||||
self.height = intrinsic_height
|
||||
|
||||
|
||||
# Constrain to max_width if specified
|
||||
if max_width and self.width > max_width:
|
||||
aspect_ratio = intrinsic_height / intrinsic_width if intrinsic_width > 0 else 1
|
||||
self.width = max_width
|
||||
self.height = self.width * aspect_ratio
|
||||
|
||||
|
||||
return self.width
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -18,10 +18,10 @@ ASSETS_DIR = Path(__file__).parent.parent.parent / "assets"
|
|||
|
||||
class ImageCache:
|
||||
"""Thread-safe global cache for loaded images."""
|
||||
|
||||
|
||||
_instance = None
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def __new__(cls):
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
|
|
@ -29,22 +29,22 @@ class ImageCache:
|
|||
cls._instance._cache = {}
|
||||
cls._instance._cache_lock = threading.Lock()
|
||||
return cls._instance
|
||||
|
||||
|
||||
def get(self, url: str) -> Optional[skia.Image]:
|
||||
"""Get a cached image by URL."""
|
||||
with self._cache_lock:
|
||||
return self._cache.get(url)
|
||||
|
||||
|
||||
def set(self, url: str, image: skia.Image):
|
||||
"""Cache an image by URL."""
|
||||
with self._cache_lock:
|
||||
self._cache[url] = image
|
||||
|
||||
|
||||
def has(self, url: str) -> bool:
|
||||
"""Check if URL is cached."""
|
||||
with self._cache_lock:
|
||||
return url in self._cache
|
||||
|
||||
|
||||
def clear(self):
|
||||
"""Clear all cached images."""
|
||||
with self._cache_lock:
|
||||
|
|
@ -60,40 +60,40 @@ BytesCallback = Callable[[Optional[bytes], str], None]
|
|||
def load_image(url: str, base_url: Optional[str] = None) -> Optional[skia.Image]:
|
||||
"""
|
||||
Load an image from a URL or file path (synchronous).
|
||||
|
||||
|
||||
Args:
|
||||
url: Image URL or file path
|
||||
base_url: Base URL for resolving relative URLs
|
||||
|
||||
|
||||
Returns:
|
||||
Skia Image object, or None if loading failed
|
||||
"""
|
||||
try:
|
||||
# Resolve the full URL first
|
||||
full_url = _resolve_url(url, base_url)
|
||||
|
||||
|
||||
# Check cache with resolved URL
|
||||
cache = ImageCache()
|
||||
cached = cache.get(full_url)
|
||||
if cached is not None:
|
||||
logger.debug(f"Image cache hit: {full_url}")
|
||||
return cached
|
||||
|
||||
|
||||
logger.info(f"Loading image: {full_url}")
|
||||
|
||||
|
||||
# Load raw bytes
|
||||
data = _load_image_bytes(full_url)
|
||||
if data is None:
|
||||
return None
|
||||
|
||||
|
||||
# Decode with Skia
|
||||
image = skia.Image.MakeFromEncoded(data)
|
||||
if image:
|
||||
cache.set(full_url, image)
|
||||
logger.debug(f"Loaded image: {full_url} ({image.width()}x{image.height()})")
|
||||
|
||||
|
||||
return image
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load image {url}: {e}")
|
||||
return None
|
||||
|
|
@ -105,19 +105,19 @@ def _load_image_bytes(full_url: str) -> Optional[bytes]:
|
|||
# Handle data URLs
|
||||
if full_url.startswith('data:'):
|
||||
return _load_data_url_bytes(full_url)
|
||||
|
||||
|
||||
# Handle file URLs
|
||||
if full_url.startswith('file://'):
|
||||
file_path = full_url[7:] # Remove 'file://'
|
||||
return _load_file_bytes(file_path)
|
||||
|
||||
|
||||
# Handle HTTP/HTTPS URLs
|
||||
if full_url.startswith(('http://', 'https://')):
|
||||
return _load_http_bytes(full_url)
|
||||
|
||||
|
||||
# Try as local file path
|
||||
return _load_file_bytes(full_url)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load image bytes from {full_url}: {e}")
|
||||
return None
|
||||
|
|
@ -131,16 +131,16 @@ def load_image_async(
|
|||
) -> int:
|
||||
"""
|
||||
Load an image asynchronously in a background thread.
|
||||
|
||||
|
||||
Bytes are loaded in background, but Skia decoding happens on main thread
|
||||
to avoid threading issues with Skia objects.
|
||||
|
||||
|
||||
Args:
|
||||
url: Image URL or file path
|
||||
base_url: Base URL for resolving relative URLs
|
||||
on_complete: Callback with loaded image (or None if failed), called on main thread
|
||||
on_error: Callback with exception if loading failed, called on main thread
|
||||
|
||||
|
||||
Returns:
|
||||
Task ID that can be used to cancel the load
|
||||
"""
|
||||
|
|
@ -148,10 +148,10 @@ def load_image_async(
|
|||
import gi
|
||||
gi.require_version("GLib", "2.0")
|
||||
from gi.repository import GLib
|
||||
|
||||
|
||||
# Resolve URL synchronously (fast operation)
|
||||
full_url = _resolve_url(url, base_url)
|
||||
|
||||
|
||||
# Check cache first (avoid thread overhead)
|
||||
cache = ImageCache()
|
||||
cached = cache.get(full_url)
|
||||
|
|
@ -161,18 +161,18 @@ def load_image_async(
|
|||
# Use GLib to call on main thread
|
||||
GLib.idle_add(lambda: on_complete(cached) or False)
|
||||
return -1 # No task needed
|
||||
|
||||
|
||||
def do_load_bytes():
|
||||
"""Load raw bytes in background thread."""
|
||||
return _load_image_bytes(full_url)
|
||||
|
||||
|
||||
def on_bytes_loaded(data: Optional[bytes]):
|
||||
"""Decode image on main thread and call user callback."""
|
||||
if data is None:
|
||||
if on_complete:
|
||||
on_complete(None)
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
# Decode image on main thread (Skia thread safety)
|
||||
decoded = skia.Image.MakeFromEncoded(data)
|
||||
|
|
@ -183,7 +183,7 @@ def load_image_async(
|
|||
canvas = surface.getCanvas()
|
||||
canvas.drawImage(decoded, 0, 0)
|
||||
image = surface.makeImageSnapshot()
|
||||
|
||||
|
||||
cache.set(full_url, image)
|
||||
logger.debug(f"Async loaded image: {full_url} ({image.width()}x{image.height()})")
|
||||
if on_complete:
|
||||
|
|
@ -197,7 +197,7 @@ def load_image_async(
|
|||
on_complete(None)
|
||||
if on_complete:
|
||||
on_complete(None)
|
||||
|
||||
|
||||
# Always use on_bytes_loaded to ensure caching happens
|
||||
return submit_task(do_load_bytes, on_bytes_loaded, on_error)
|
||||
|
||||
|
|
@ -207,7 +207,7 @@ def _resolve_url(url: str, base_url: Optional[str]) -> str:
|
|||
# Already absolute
|
||||
if url.startswith(('http://', 'https://', 'data:', 'file://')):
|
||||
return url
|
||||
|
||||
|
||||
# Handle about: pages - resolve relative to assets directory
|
||||
if base_url and base_url.startswith('about:'):
|
||||
# For about: pages, resolve relative to the assets/pages directory
|
||||
|
|
@ -222,11 +222,11 @@ def _resolve_url(url: str, base_url: Optional[str]) -> str:
|
|||
return str(asset_path)
|
||||
# Return resolved path even if not found (will fail later with proper error)
|
||||
return str(resolved_path)
|
||||
|
||||
|
||||
# No base URL - treat as local file
|
||||
if not base_url:
|
||||
return url
|
||||
|
||||
|
||||
# Use URL class for proper resolution
|
||||
try:
|
||||
base = URL(base_url)
|
||||
|
|
@ -261,14 +261,14 @@ def _load_http_bytes(url: str) -> Optional[bytes]:
|
|||
try:
|
||||
url_obj = URL(url)
|
||||
status, content_type, body = request(url_obj)
|
||||
|
||||
|
||||
if status != 200:
|
||||
logger.warning(f"HTTP {status} when loading image: {url}")
|
||||
return None
|
||||
|
||||
|
||||
logger.debug(f"Loaded {len(body)} bytes from HTTP: {url}")
|
||||
return body
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load from HTTP {url}: {e}")
|
||||
return None
|
||||
|
|
@ -280,16 +280,16 @@ def _load_data_url_bytes(data_url: str) -> Optional[bytes]:
|
|||
# Parse data URL: data:[<mediatype>][;base64],<data>
|
||||
if not data_url.startswith('data:'):
|
||||
return None
|
||||
|
||||
|
||||
# Split off the 'data:' prefix
|
||||
_, rest = data_url.split(':', 1)
|
||||
|
||||
|
||||
# Split metadata from data
|
||||
if ',' not in rest:
|
||||
return None
|
||||
|
||||
|
||||
metadata, data = rest.split(',', 1)
|
||||
|
||||
|
||||
# Check if base64 encoded
|
||||
if ';base64' in metadata:
|
||||
import base64
|
||||
|
|
@ -298,10 +298,10 @@ def _load_data_url_bytes(data_url: str) -> Optional[bytes]:
|
|||
# URL-encoded data
|
||||
import urllib.parse
|
||||
decoded = urllib.parse.unquote(data).encode('utf-8')
|
||||
|
||||
|
||||
logger.debug(f"Extracted {len(decoded)} bytes from data URL")
|
||||
return decoded
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to parse data URL: {e}")
|
||||
return None
|
||||
|
|
@ -312,22 +312,22 @@ def _load_from_http(url: str) -> Optional[skia.Image]:
|
|||
try:
|
||||
url_obj = URL(url)
|
||||
status, content_type, body = request(url_obj)
|
||||
|
||||
|
||||
if status != 200:
|
||||
logger.warning(f"HTTP {status} when loading image: {url}")
|
||||
return None
|
||||
|
||||
|
||||
# Decode image from bytes
|
||||
image = skia.Image.MakeFromEncoded(body)
|
||||
|
||||
|
||||
if image:
|
||||
# Cache it
|
||||
cache = ImageCache()
|
||||
cache.set(url, image)
|
||||
logger.debug(f"Loaded image from HTTP: {url} ({image.width()}x{image.height()})")
|
||||
|
||||
|
||||
return image
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load image from HTTP {url}: {e}")
|
||||
return None
|
||||
|
|
@ -338,17 +338,17 @@ def _load_from_file(file_path: str) -> Optional[skia.Image]:
|
|||
try:
|
||||
with open(file_path, 'rb') as f:
|
||||
data = f.read()
|
||||
|
||||
|
||||
image = skia.Image.MakeFromEncoded(data)
|
||||
|
||||
|
||||
if image:
|
||||
# Cache it
|
||||
cache = ImageCache()
|
||||
cache.set(file_path, image)
|
||||
logger.debug(f"Loaded image from file: {file_path} ({image.width()}x{image.height()})")
|
||||
|
||||
|
||||
return image
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load image from file {file_path}: {e}")
|
||||
return None
|
||||
|
|
@ -360,16 +360,16 @@ def _load_data_url(data_url: str) -> Optional[skia.Image]:
|
|||
# Parse data URL: data:[<mediatype>][;base64],<data>
|
||||
if not data_url.startswith('data:'):
|
||||
return None
|
||||
|
||||
|
||||
# Split off the 'data:' prefix
|
||||
_, rest = data_url.split(':', 1)
|
||||
|
||||
|
||||
# Split metadata from data
|
||||
if ',' not in rest:
|
||||
return None
|
||||
|
||||
|
||||
metadata, data = rest.split(',', 1)
|
||||
|
||||
|
||||
# Check if base64 encoded
|
||||
if ';base64' in metadata:
|
||||
import base64
|
||||
|
|
@ -378,15 +378,15 @@ def _load_data_url(data_url: str) -> Optional[skia.Image]:
|
|||
# URL-encoded data
|
||||
import urllib.parse
|
||||
decoded = urllib.parse.unquote(data).encode('utf-8')
|
||||
|
||||
|
||||
image = skia.Image.MakeFromEncoded(decoded)
|
||||
|
||||
|
||||
if image:
|
||||
# Don't cache data URLs (they're already embedded)
|
||||
logger.debug(f"Loaded image from data URL ({image.width()}x{image.height()})")
|
||||
|
||||
|
||||
return image
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load image from data URL: {e}")
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -3,13 +3,12 @@
|
|||
import logging
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor, Future
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Optional, Any
|
||||
from queue import Queue
|
||||
import gi
|
||||
|
||||
gi.require_version("GLib", "2.0")
|
||||
from gi.repository import GLib
|
||||
from gi.repository import GLib # noqa: E402
|
||||
|
||||
logger = logging.getLogger("bowser.tasks")
|
||||
|
||||
|
|
@ -17,12 +16,12 @@ logger = logging.getLogger("bowser.tasks")
|
|||
@dataclass
|
||||
class Task:
|
||||
"""A task to be executed in the background."""
|
||||
|
||||
|
||||
func: Callable[[], Any]
|
||||
on_complete: Optional[Callable[[Any], None]] = None
|
||||
on_error: Optional[Callable[[Exception], None]] = None
|
||||
priority: int = 0 # Lower = higher priority
|
||||
|
||||
|
||||
def __lt__(self, other):
|
||||
return self.priority < other.priority
|
||||
|
||||
|
|
@ -30,24 +29,24 @@ class Task:
|
|||
class TaskQueue:
|
||||
"""
|
||||
Background task queue using a thread pool.
|
||||
|
||||
|
||||
Uses GTK's GLib.idle_add for thread-safe UI updates.
|
||||
"""
|
||||
|
||||
|
||||
_instance: Optional["TaskQueue"] = None
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def __new__(cls) -> "TaskQueue":
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
|
||||
def __init__(self, max_workers: int = 4):
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
|
||||
self._executor = ThreadPoolExecutor(
|
||||
max_workers=max_workers,
|
||||
thread_name_prefix="bowser-task"
|
||||
|
|
@ -57,9 +56,9 @@ class TaskQueue:
|
|||
self._task_lock = threading.Lock()
|
||||
self._initialized = True
|
||||
self._shutdown = False
|
||||
|
||||
|
||||
logger.debug(f"TaskQueue initialized with {max_workers} workers")
|
||||
|
||||
|
||||
def submit(
|
||||
self,
|
||||
func: Callable[[], Any],
|
||||
|
|
@ -68,23 +67,23 @@ class TaskQueue:
|
|||
) -> int:
|
||||
"""
|
||||
Submit a task for background execution.
|
||||
|
||||
|
||||
Args:
|
||||
func: Function to run in background (no arguments)
|
||||
on_complete: Callback with result (runs on main thread)
|
||||
on_error: Callback with exception (runs on main thread)
|
||||
|
||||
|
||||
Returns:
|
||||
Task ID that can be used to cancel
|
||||
"""
|
||||
if self._shutdown:
|
||||
logger.warning("TaskQueue is shutdown, ignoring task")
|
||||
return -1
|
||||
|
||||
|
||||
with self._task_lock:
|
||||
task_id = self._task_id
|
||||
self._task_id += 1
|
||||
|
||||
|
||||
def wrapped():
|
||||
try:
|
||||
result = func()
|
||||
|
|
@ -100,15 +99,15 @@ class TaskQueue:
|
|||
finally:
|
||||
with self._task_lock:
|
||||
self._pending.pop(task_id, None)
|
||||
|
||||
|
||||
future = self._executor.submit(wrapped)
|
||||
|
||||
|
||||
with self._task_lock:
|
||||
self._pending[task_id] = future
|
||||
|
||||
|
||||
logger.debug(f"Submitted task {task_id}")
|
||||
return task_id
|
||||
|
||||
|
||||
def _call_on_main(self, callback: Callable, arg: Any) -> bool:
|
||||
"""Execute a callback on the main thread. Returns False to remove from idle."""
|
||||
try:
|
||||
|
|
@ -116,7 +115,7 @@ class TaskQueue:
|
|||
except Exception as e:
|
||||
logger.error(f"Callback error: {e}")
|
||||
return False # Don't repeat
|
||||
|
||||
|
||||
def cancel(self, task_id: int) -> bool:
|
||||
"""Cancel a pending task. Returns True if cancelled."""
|
||||
with self._task_lock:
|
||||
|
|
@ -128,7 +127,7 @@ class TaskQueue:
|
|||
logger.debug(f"Cancelled task {task_id}")
|
||||
return cancelled
|
||||
return False
|
||||
|
||||
|
||||
def cancel_all(self):
|
||||
"""Cancel all pending tasks."""
|
||||
with self._task_lock:
|
||||
|
|
@ -136,20 +135,20 @@ class TaskQueue:
|
|||
future.cancel()
|
||||
self._pending.clear()
|
||||
logger.debug("Cancelled all tasks")
|
||||
|
||||
|
||||
@property
|
||||
def pending_count(self) -> int:
|
||||
"""Number of pending tasks."""
|
||||
with self._task_lock:
|
||||
return len(self._pending)
|
||||
|
||||
|
||||
def shutdown(self, wait: bool = True):
|
||||
"""Shutdown the task queue."""
|
||||
self._shutdown = True
|
||||
self.cancel_all()
|
||||
self._executor.shutdown(wait=wait)
|
||||
logger.debug("TaskQueue shutdown")
|
||||
|
||||
|
||||
@classmethod
|
||||
def reset_instance(cls):
|
||||
"""Reset the singleton (for testing)."""
|
||||
|
|
|
|||
|
|
@ -180,7 +180,7 @@ class CSSParser:
|
|||
|
||||
# Split multi-selectors by comma
|
||||
selector_parts = [s.strip() for s in selector_text.split(',') if s.strip()]
|
||||
|
||||
|
||||
if len(selector_parts) == 1:
|
||||
# Single selector
|
||||
return CSSRule(Selector(selector_text), declarations)
|
||||
|
|
|
|||
|
|
@ -129,6 +129,11 @@ class _DOMBuilder(HTMLParser):
|
|||
if self.current is self.root:
|
||||
self._ensure_body()
|
||||
|
||||
# Handle implicit closure for certain elements
|
||||
# A new <p> tag closes any open <p> tag (HTML5 implicit paragraph closure)
|
||||
if tag == "p" and self.current.tag == "p":
|
||||
self._pop("p")
|
||||
|
||||
self._push(el)
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
|
|
@ -193,7 +198,6 @@ def parse_html_with_styles(html_text: str, apply_styles: bool = True) -> Element
|
|||
"""
|
||||
from .css import parse as parse_css
|
||||
from .style import StyleResolver
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Parse HTML
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ class FontCache:
|
|||
"""Cache for Skia fonts and typefaces."""
|
||||
|
||||
_instance = None
|
||||
|
||||
|
||||
# Common emoji/symbol fonts to try as last resort before showing tofu
|
||||
_EMOJI_FALLBACK_FONTS = (
|
||||
'Noto Color Emoji',
|
||||
|
|
@ -51,7 +51,7 @@ class FontCache:
|
|||
# This dramatically reduces cache entries and font lookups
|
||||
is_emoji = text and self._is_emoji_char(text[0])
|
||||
cache_key = (families, is_emoji)
|
||||
|
||||
|
||||
if cache_key in self._typeface_cache:
|
||||
return self._typeface_cache[cache_key]
|
||||
|
||||
|
|
@ -60,7 +60,7 @@ class FontCache:
|
|||
# Skip generic families that won't resolve to specific fonts
|
||||
if family.lower() in ('serif', 'sans-serif', 'monospace', 'cursive', 'fantasy'):
|
||||
continue
|
||||
|
||||
|
||||
typeface = skia.Typeface.MakeFromName(family, skia.FontStyle.Normal())
|
||||
if typeface and typeface.getFamilyName() == family:
|
||||
# Font was actually found - check if it has glyphs for sample text
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ class DrawRect(PaintCommand):
|
|||
class DrawImage(PaintCommand):
|
||||
"""Command to draw an image."""
|
||||
|
||||
def __init__(self, x: float, y: float, width: float, height: float,
|
||||
def __init__(self, x: float, y: float, width: float, height: float,
|
||||
image: skia.Image, alt_text: str = ""):
|
||||
super().__init__((x, y, x + width, y + height))
|
||||
self.x = x
|
||||
|
|
@ -82,11 +82,11 @@ class DrawImage(PaintCommand):
|
|||
if paint is None:
|
||||
paint = skia.Paint()
|
||||
paint.setAntiAlias(True)
|
||||
|
||||
|
||||
# Calculate scale factor
|
||||
scale_x = self.width / self.image.width()
|
||||
scale_y = self.height / self.image.height()
|
||||
|
||||
|
||||
# Use canvas transform for scaling
|
||||
canvas.save()
|
||||
canvas.translate(self.x, self.y)
|
||||
|
|
@ -99,7 +99,7 @@ class DrawImage(PaintCommand):
|
|||
logger.error(f"Failed to draw image: {e}")
|
||||
# If drawing fails, fall back to placeholder
|
||||
self._draw_placeholder(canvas, paint)
|
||||
|
||||
|
||||
def _draw_placeholder(self, canvas: skia.Canvas, paint: skia.Paint = None):
|
||||
"""Draw a placeholder for a missing or failed image."""
|
||||
if paint is None:
|
||||
|
|
@ -108,14 +108,14 @@ class DrawImage(PaintCommand):
|
|||
paint.setStyle(skia.Paint.kFill_Style)
|
||||
rect = skia.Rect.MakeLTRB(self.x, self.y, self.x + self.width, self.y + self.height)
|
||||
canvas.drawRect(rect, paint)
|
||||
|
||||
|
||||
# Draw border
|
||||
border_paint = skia.Paint()
|
||||
border_paint.setColor(skia.ColorGRAY)
|
||||
border_paint.setStyle(skia.Paint.kStroke_Style)
|
||||
border_paint.setStrokeWidth(1)
|
||||
canvas.drawRect(rect, border_paint)
|
||||
|
||||
|
||||
# Draw alt text if available
|
||||
if self.alt_text:
|
||||
text_paint = skia.Paint()
|
||||
|
|
|
|||
|
|
@ -22,26 +22,26 @@ class RenderPipeline:
|
|||
# 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():
|
||||
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:
|
||||
|
|
@ -60,7 +60,7 @@ class RenderPipeline:
|
|||
|
||||
# Build new layout with base_url for resolving image paths
|
||||
self._layout = DocumentLayout(
|
||||
document,
|
||||
document,
|
||||
base_url=self.base_url,
|
||||
async_images=self.async_images
|
||||
)
|
||||
|
|
@ -110,7 +110,25 @@ class RenderPipeline:
|
|||
continue
|
||||
|
||||
font = get_font(line.font_size, getattr(line, "font_family", ""), text=line.text)
|
||||
canvas.drawString(line.text, line.x, baseline_y, font, self._text_paint)
|
||||
|
||||
# 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:
|
||||
|
|
@ -122,12 +140,12 @@ class RenderPipeline:
|
|||
# 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.x,
|
||||
layout_image.y,
|
||||
img_width,
|
||||
img_width,
|
||||
img_height,
|
||||
image_layout.image, # May be None, DrawImage handles this
|
||||
image_layout.alt_text
|
||||
|
|
@ -188,8 +206,8 @@ class RenderPipeline:
|
|||
|
||||
def get_text_layout(self) -> list:
|
||||
"""
|
||||
Get the text layout for text selection.
|
||||
Returns list of line info dicts with char_positions.
|
||||
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 []
|
||||
|
|
@ -203,7 +221,8 @@ class RenderPipeline:
|
|||
"width": line.width,
|
||||
"height": line.height,
|
||||
"font_size": line.font_size,
|
||||
"char_positions": line.char_positions
|
||||
"char_positions": line.char_positions,
|
||||
"href": getattr(line, "href", None)
|
||||
})
|
||||
return result
|
||||
|
||||
|
|
@ -218,3 +237,63 @@ class RenderPipeline:
|
|||
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
|
||||
|
|
|
|||
|
|
@ -1,94 +1,93 @@
|
|||
"""Tests for CSS parsing and style computation."""
|
||||
|
||||
import pytest
|
||||
from src.parser.css import (
|
||||
Selector, CSSRule, CSSParser, parse, parse_inline_style
|
||||
Selector, parse, parse_inline_style
|
||||
)
|
||||
from src.parser.html import Element, Text
|
||||
from src.parser.style import (
|
||||
ComputedStyle, StyleResolver, DEFAULT_STYLES, INHERITED_PROPERTIES
|
||||
ComputedStyle, StyleResolver
|
||||
)
|
||||
|
||||
|
||||
class TestSelector:
|
||||
"""Test CSS selector parsing and matching."""
|
||||
|
||||
|
||||
def test_tag_selector(self):
|
||||
sel = Selector("p")
|
||||
assert sel.tag == "p"
|
||||
assert sel.id is None
|
||||
assert sel.classes == []
|
||||
|
||||
|
||||
def test_class_selector(self):
|
||||
sel = Selector(".container")
|
||||
assert sel.tag is None
|
||||
assert sel.classes == ["container"]
|
||||
|
||||
|
||||
def test_id_selector(self):
|
||||
sel = Selector("#header")
|
||||
assert sel.id == "header"
|
||||
assert sel.tag is None
|
||||
|
||||
|
||||
def test_compound_selector(self):
|
||||
sel = Selector("div.container")
|
||||
assert sel.tag == "div"
|
||||
assert sel.classes == ["container"]
|
||||
|
||||
|
||||
def test_complex_compound_selector(self):
|
||||
sel = Selector("div#main.container.active")
|
||||
assert sel.tag == "div"
|
||||
assert sel.id == "main"
|
||||
assert set(sel.classes) == {"container", "active"}
|
||||
|
||||
|
||||
def test_specificity_tag_only(self):
|
||||
sel = Selector("p")
|
||||
assert sel.specificity() == (0, 0, 1)
|
||||
|
||||
|
||||
def test_specificity_class_only(self):
|
||||
sel = Selector(".container")
|
||||
assert sel.specificity() == (0, 1, 0)
|
||||
|
||||
|
||||
def test_specificity_id_only(self):
|
||||
sel = Selector("#header")
|
||||
assert sel.specificity() == (1, 0, 0)
|
||||
|
||||
|
||||
def test_specificity_compound(self):
|
||||
sel = Selector("div#main.container.active")
|
||||
assert sel.specificity() == (1, 2, 1)
|
||||
|
||||
|
||||
def test_matches_tag(self):
|
||||
sel = Selector("p")
|
||||
elem = Element("p")
|
||||
assert sel.matches(elem) is True
|
||||
|
||||
|
||||
elem2 = Element("div")
|
||||
assert sel.matches(elem2) is False
|
||||
|
||||
|
||||
def test_matches_class(self):
|
||||
sel = Selector(".container")
|
||||
elem = Element("div", {"class": "container sidebar"})
|
||||
assert sel.matches(elem) is True
|
||||
|
||||
|
||||
elem2 = Element("div", {"class": "sidebar"})
|
||||
assert sel.matches(elem2) is False
|
||||
|
||||
|
||||
def test_matches_id(self):
|
||||
sel = Selector("#header")
|
||||
elem = Element("div", {"id": "header"})
|
||||
assert sel.matches(elem) is True
|
||||
|
||||
|
||||
elem2 = Element("div", {"id": "footer"})
|
||||
assert sel.matches(elem2) is False
|
||||
|
||||
|
||||
def test_matches_compound(self):
|
||||
sel = Selector("div.container")
|
||||
elem = Element("div", {"class": "container"})
|
||||
assert sel.matches(elem) is True
|
||||
|
||||
|
||||
# Wrong tag
|
||||
elem2 = Element("p", {"class": "container"})
|
||||
assert sel.matches(elem2) is False
|
||||
|
||||
|
||||
# Wrong class
|
||||
elem3 = Element("div", {"class": "sidebar"})
|
||||
assert sel.matches(elem3) is False
|
||||
|
|
@ -96,18 +95,18 @@ class TestSelector:
|
|||
|
||||
class TestCSSParser:
|
||||
"""Test CSS stylesheet parsing."""
|
||||
|
||||
|
||||
def test_empty_stylesheet(self):
|
||||
rules = parse("")
|
||||
assert rules == []
|
||||
|
||||
|
||||
def test_single_rule(self):
|
||||
css = "p { color: red; }"
|
||||
rules = parse(css)
|
||||
assert len(rules) == 1
|
||||
assert rules[0].selector.tag == "p"
|
||||
assert rules[0].declarations == {"color": "red"}
|
||||
|
||||
|
||||
def test_multiple_rules(self):
|
||||
css = """
|
||||
p { color: red; }
|
||||
|
|
@ -117,7 +116,7 @@ class TestCSSParser:
|
|||
assert len(rules) == 2
|
||||
assert rules[0].selector.tag == "p"
|
||||
assert rules[1].selector.tag == "div"
|
||||
|
||||
|
||||
def test_multiple_declarations(self):
|
||||
css = "p { color: red; font-size: 14px; margin: 10px; }"
|
||||
rules = parse(css)
|
||||
|
|
@ -127,7 +126,7 @@ class TestCSSParser:
|
|||
"font-size": "14px",
|
||||
"margin": "10px"
|
||||
}
|
||||
|
||||
|
||||
def test_multiline_declarations(self):
|
||||
css = """
|
||||
p {
|
||||
|
|
@ -143,7 +142,7 @@ class TestCSSParser:
|
|||
"font-size": "14px",
|
||||
"margin": "10px"
|
||||
}
|
||||
|
||||
|
||||
def test_no_semicolon_on_last_declaration(self):
|
||||
css = "p { color: red; font-size: 14px }"
|
||||
rules = parse(css)
|
||||
|
|
@ -151,34 +150,34 @@ class TestCSSParser:
|
|||
"color": "red",
|
||||
"font-size": "14px"
|
||||
}
|
||||
|
||||
|
||||
def test_class_selector_rule(self):
|
||||
css = ".container { width: 100%; }"
|
||||
rules = parse(css)
|
||||
assert len(rules) == 1
|
||||
assert rules[0].selector.classes == ["container"]
|
||||
assert rules[0].declarations == {"width": "100%"}
|
||||
|
||||
|
||||
def test_id_selector_rule(self):
|
||||
css = "#header { height: 50px; }"
|
||||
rules = parse(css)
|
||||
assert len(rules) == 1
|
||||
assert rules[0].selector.id == "header"
|
||||
assert rules[0].declarations == {"height": "50px"}
|
||||
|
||||
|
||||
def test_compound_selector_rule(self):
|
||||
css = "div.container { padding: 20px; }"
|
||||
rules = parse(css)
|
||||
assert len(rules) == 1
|
||||
assert rules[0].selector.tag == "div"
|
||||
assert rules[0].selector.classes == ["container"]
|
||||
|
||||
|
||||
def test_whitespace_handling(self):
|
||||
css = " p { color : red ; } "
|
||||
rules = parse(css)
|
||||
assert len(rules) == 1
|
||||
assert rules[0].declarations == {"color": "red"}
|
||||
|
||||
|
||||
def test_comments(self):
|
||||
css = """
|
||||
/* This is a comment */
|
||||
|
|
@ -190,38 +189,38 @@ class TestCSSParser:
|
|||
assert len(rules) == 2
|
||||
assert rules[0].selector.tag == "p"
|
||||
assert rules[1].selector.tag == "div"
|
||||
|
||||
|
||||
def test_property_values_with_spaces(self):
|
||||
css = "p { font-family: Arial, sans-serif; }"
|
||||
rules = parse(css)
|
||||
assert rules[0].declarations == {"font-family": "Arial, sans-serif"}
|
||||
|
||||
|
||||
def test_complex_stylesheet(self):
|
||||
css = """
|
||||
/* Reset */
|
||||
* { margin: 0; padding: 0; }
|
||||
|
||||
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
|
||||
h1 {
|
||||
font-size: 32px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
|
||||
.container {
|
||||
width: 960px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
||||
#header {
|
||||
background: #f0f0f0;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
|
||||
div.highlight {
|
||||
background: yellow;
|
||||
font-weight: bold;
|
||||
|
|
@ -229,7 +228,7 @@ class TestCSSParser:
|
|||
"""
|
||||
rules = parse(css)
|
||||
assert len(rules) == 6
|
||||
|
||||
|
||||
# Check body rule
|
||||
body_rule = next(r for r in rules if r.selector.tag == "body")
|
||||
assert "font-family" in body_rule.declarations
|
||||
|
|
@ -238,34 +237,34 @@ class TestCSSParser:
|
|||
|
||||
class TestInlineStyleParser:
|
||||
"""Test inline style attribute parsing."""
|
||||
|
||||
|
||||
def test_empty_style(self):
|
||||
decls = parse_inline_style("")
|
||||
assert decls == {}
|
||||
|
||||
|
||||
def test_single_declaration(self):
|
||||
decls = parse_inline_style("color: red")
|
||||
assert decls == {"color": "red"}
|
||||
|
||||
|
||||
def test_multiple_declarations(self):
|
||||
decls = parse_inline_style("color: red; font-size: 14px")
|
||||
assert decls == {"color": "red", "font-size": "14px"}
|
||||
|
||||
|
||||
def test_trailing_semicolon(self):
|
||||
decls = parse_inline_style("color: red; font-size: 14px;")
|
||||
assert decls == {"color": "red", "font-size": "14px"}
|
||||
|
||||
|
||||
def test_whitespace_handling(self):
|
||||
decls = parse_inline_style(" color : red ; font-size : 14px ")
|
||||
assert decls == {"color": "red", "font-size": "14px"}
|
||||
|
||||
|
||||
def test_complex_values(self):
|
||||
decls = parse_inline_style("font-family: Arial, sans-serif; margin: 10px 20px")
|
||||
assert decls == {
|
||||
"font-family": "Arial, sans-serif",
|
||||
"margin": "10px 20px"
|
||||
}
|
||||
|
||||
|
||||
def test_malformed_ignored(self):
|
||||
# Missing colon
|
||||
decls = parse_inline_style("color red; font-size: 14px")
|
||||
|
|
@ -274,36 +273,36 @@ class TestInlineStyleParser:
|
|||
|
||||
class TestComputedStyle:
|
||||
"""Test computed style value accessors."""
|
||||
|
||||
|
||||
def test_empty_style(self):
|
||||
style = ComputedStyle()
|
||||
assert style.get("color") == ""
|
||||
assert style.get("color", "black") == "black"
|
||||
|
||||
|
||||
def test_get_set(self):
|
||||
style = ComputedStyle()
|
||||
style.set("color", "red")
|
||||
assert style.get("color") == "red"
|
||||
|
||||
|
||||
def test_get_int(self):
|
||||
style = ComputedStyle()
|
||||
style.set("font-size", "16px")
|
||||
assert style.get_int("font-size") == 16
|
||||
|
||||
|
||||
def test_get_int_no_unit(self):
|
||||
style = ComputedStyle()
|
||||
style.set("font-size", "16")
|
||||
assert style.get_int("font-size") == 16
|
||||
|
||||
|
||||
def test_get_int_default(self):
|
||||
style = ComputedStyle()
|
||||
assert style.get_int("font-size", 14) == 14
|
||||
|
||||
|
||||
def test_get_float(self):
|
||||
style = ComputedStyle()
|
||||
style.set("margin", "10.5px")
|
||||
assert style.get_float("margin") == 10.5
|
||||
|
||||
|
||||
def test_get_float_default(self):
|
||||
style = ComputedStyle()
|
||||
assert style.get_float("margin", 5.5) == 5.5
|
||||
|
|
@ -311,44 +310,44 @@ class TestComputedStyle:
|
|||
|
||||
class TestStyleResolver:
|
||||
"""Test style resolution with cascade and inheritance."""
|
||||
|
||||
|
||||
def test_default_styles(self):
|
||||
resolver = StyleResolver()
|
||||
elem = Element("p")
|
||||
style = resolver.resolve_style(elem)
|
||||
|
||||
|
||||
assert style.get("display") == "block"
|
||||
assert style.get("margin-top") == "16px"
|
||||
assert style.get("margin-bottom") == "16px"
|
||||
|
||||
|
||||
def test_no_default_for_unknown_tag(self):
|
||||
resolver = StyleResolver()
|
||||
elem = Element("unknown")
|
||||
style = resolver.resolve_style(elem)
|
||||
|
||||
|
||||
# Should have empty properties (no defaults)
|
||||
assert style.get("display") == ""
|
||||
|
||||
|
||||
def test_stylesheet_overrides_default(self):
|
||||
rules = parse("p { margin-top: 20px; }")
|
||||
resolver = StyleResolver(rules)
|
||||
elem = Element("p")
|
||||
style = resolver.resolve_style(elem)
|
||||
|
||||
|
||||
# Stylesheet should override default
|
||||
assert style.get("margin-top") == "20px"
|
||||
# But default not overridden should remain
|
||||
assert style.get("margin-bottom") == "16px"
|
||||
|
||||
|
||||
def test_inline_overrides_stylesheet(self):
|
||||
rules = parse("p { color: blue; }")
|
||||
resolver = StyleResolver(rules)
|
||||
elem = Element("p", {"style": "color: red"})
|
||||
style = resolver.resolve_style(elem)
|
||||
|
||||
|
||||
# Inline should win
|
||||
assert style.get("color") == "red"
|
||||
|
||||
|
||||
def test_specificity_class_over_tag(self):
|
||||
rules = parse("""
|
||||
p { color: blue; }
|
||||
|
|
@ -357,10 +356,10 @@ class TestStyleResolver:
|
|||
resolver = StyleResolver(rules)
|
||||
elem = Element("p", {"class": "highlight"})
|
||||
style = resolver.resolve_style(elem)
|
||||
|
||||
|
||||
# Class selector has higher specificity
|
||||
assert style.get("color") == "red"
|
||||
|
||||
|
||||
def test_specificity_id_over_class(self):
|
||||
rules = parse("""
|
||||
p { color: blue; }
|
||||
|
|
@ -370,53 +369,53 @@ class TestStyleResolver:
|
|||
resolver = StyleResolver(rules)
|
||||
elem = Element("p", {"class": "highlight", "id": "main"})
|
||||
style = resolver.resolve_style(elem)
|
||||
|
||||
|
||||
# ID selector has highest specificity
|
||||
assert style.get("color") == "green"
|
||||
|
||||
|
||||
def test_inheritance_from_parent(self):
|
||||
rules = parse("body { color: blue; font-size: 16px; }")
|
||||
resolver = StyleResolver(rules)
|
||||
|
||||
|
||||
parent = Element("body")
|
||||
parent_style = resolver.resolve_style(parent)
|
||||
|
||||
|
||||
child = Element("div")
|
||||
child_style = resolver.resolve_style(child, parent_style)
|
||||
|
||||
|
||||
# Should inherit color and font-size
|
||||
assert child_style.get("color") == "blue"
|
||||
assert child_style.get("font-size") == "16px"
|
||||
|
||||
|
||||
def test_non_inherited_properties(self):
|
||||
rules = parse("body { margin: 10px; }")
|
||||
resolver = StyleResolver(rules)
|
||||
|
||||
|
||||
parent = Element("body")
|
||||
parent_style = resolver.resolve_style(parent)
|
||||
|
||||
|
||||
child = Element("div")
|
||||
child_style = resolver.resolve_style(child, parent_style)
|
||||
|
||||
|
||||
# Margin should not inherit
|
||||
assert child_style.get("margin") == ""
|
||||
|
||||
|
||||
def test_child_overrides_inherited(self):
|
||||
rules = parse("""
|
||||
body { color: blue; }
|
||||
p { color: red; }
|
||||
""")
|
||||
resolver = StyleResolver(rules)
|
||||
|
||||
|
||||
parent = Element("body")
|
||||
parent_style = resolver.resolve_style(parent)
|
||||
|
||||
|
||||
child = Element("p")
|
||||
child_style = resolver.resolve_style(child, parent_style)
|
||||
|
||||
|
||||
# Child's own style should override inherited
|
||||
assert child_style.get("color") == "red"
|
||||
|
||||
|
||||
def test_resolve_tree(self):
|
||||
css = """
|
||||
body { color: blue; font-size: 16px; }
|
||||
|
|
@ -425,7 +424,7 @@ class TestStyleResolver:
|
|||
"""
|
||||
rules = parse(css)
|
||||
resolver = StyleResolver(rules)
|
||||
|
||||
|
||||
# Build tree
|
||||
root = Element("body")
|
||||
p1 = Element("p", parent=root)
|
||||
|
|
@ -433,46 +432,46 @@ class TestStyleResolver:
|
|||
text = Text("Hello", parent=p1)
|
||||
root.children = [p1, p2]
|
||||
p1.children = [text]
|
||||
|
||||
|
||||
# Resolve entire tree
|
||||
resolver.resolve_tree(root)
|
||||
|
||||
|
||||
# Check root
|
||||
assert root.computed_style.get("color") == "blue"
|
||||
assert root.computed_style.get("font-size") == "16px"
|
||||
|
||||
|
||||
# Check p1 (inherits color)
|
||||
assert p1.computed_style.get("color") == "blue"
|
||||
assert p1.computed_style.get("margin") == "10px"
|
||||
|
||||
|
||||
# Check p2 (inherits + has class)
|
||||
assert p2.computed_style.get("color") == "blue"
|
||||
assert p2.computed_style.get("background") == "yellow"
|
||||
|
||||
|
||||
# Check text (has parent style)
|
||||
assert text.computed_style.get("color") == "blue"
|
||||
|
||||
|
||||
def test_heading_defaults(self):
|
||||
resolver = StyleResolver()
|
||||
|
||||
|
||||
h1 = Element("h1")
|
||||
h1_style = resolver.resolve_style(h1)
|
||||
assert h1_style.get("font-size") == "32px"
|
||||
assert h1_style.get("font-weight") == "bold"
|
||||
|
||||
|
||||
h2 = Element("h2")
|
||||
h2_style = resolver.resolve_style(h2)
|
||||
assert h2_style.get("font-size") == "24px"
|
||||
|
||||
|
||||
def test_inline_elements(self):
|
||||
resolver = StyleResolver()
|
||||
|
||||
|
||||
a = Element("a")
|
||||
a_style = resolver.resolve_style(a)
|
||||
assert a_style.get("display") == "inline"
|
||||
assert a_style.get("color") == "blue"
|
||||
assert a_style.get("text-decoration") == "underline"
|
||||
|
||||
|
||||
span = Element("span")
|
||||
span_style = resolver.resolve_style(span)
|
||||
assert span_style.get("display") == "inline"
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ class TestParseHTML:
|
|||
if hasattr(child, "tag") and child.tag == "style":
|
||||
style_elem = child
|
||||
break
|
||||
|
||||
|
||||
assert style_elem is not None
|
||||
# Style content should be in the element
|
||||
joined = " ".join(collect_text(style_elem))
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
"""Tests for image loading and rendering."""
|
||||
|
||||
import pytest
|
||||
import skia
|
||||
from src.network.images import load_image, ImageCache, _load_data_url
|
||||
from src.network.images import ImageCache, _load_data_url
|
||||
from src.layout.embed import ImageLayout
|
||||
from src.parser.html import Element, parse_html
|
||||
from src.render.paint import DrawImage
|
||||
from src.layout.document import DocumentLayout, LayoutImage
|
||||
from io import BytesIO
|
||||
|
||||
|
||||
def create_test_image(width=100, height=100):
|
||||
|
|
@ -21,47 +19,47 @@ def create_test_image(width=100, height=100):
|
|||
|
||||
class TestImageCache:
|
||||
"""Test image caching."""
|
||||
|
||||
|
||||
def test_cache_singleton(self):
|
||||
"""ImageCache should be a singleton."""
|
||||
cache1 = ImageCache()
|
||||
cache2 = ImageCache()
|
||||
assert cache1 is cache2
|
||||
|
||||
|
||||
def test_cache_get_set(self):
|
||||
"""Test basic cache operations."""
|
||||
cache = ImageCache()
|
||||
cache.clear()
|
||||
|
||||
|
||||
# Create a simple test image
|
||||
image = create_test_image(100, 100)
|
||||
|
||||
|
||||
# Initially empty
|
||||
assert cache.get("test_url") is None
|
||||
|
||||
|
||||
# Set and get
|
||||
cache.set("test_url", image)
|
||||
cached = cache.get("test_url")
|
||||
assert cached is not None
|
||||
assert cached.width() == 100
|
||||
assert cached.height() == 100
|
||||
|
||||
|
||||
def test_cache_clear(self):
|
||||
"""Test cache clearing."""
|
||||
cache = ImageCache()
|
||||
cache.clear()
|
||||
|
||||
|
||||
image = create_test_image(100, 100)
|
||||
cache.set("test_url", image)
|
||||
assert cache.get("test_url") is not None
|
||||
|
||||
|
||||
cache.clear()
|
||||
assert cache.get("test_url") is None
|
||||
|
||||
|
||||
class TestDataURLLoading:
|
||||
"""Test data URL image loading."""
|
||||
|
||||
|
||||
def test_load_base64_png(self):
|
||||
"""Test loading a base64-encoded PNG data URL."""
|
||||
# Simple 1x1 red PNG
|
||||
|
|
@ -69,29 +67,29 @@ class TestDataURLLoading:
|
|||
"data:image/png;base64,"
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="
|
||||
)
|
||||
|
||||
|
||||
image = _load_data_url(data_url)
|
||||
assert image is not None
|
||||
assert image.width() == 1
|
||||
assert image.height() == 1
|
||||
|
||||
|
||||
def test_load_invalid_data_url(self):
|
||||
"""Test loading an invalid data URL."""
|
||||
image = _load_data_url("data:invalid")
|
||||
assert image is None
|
||||
|
||||
|
||||
image = _load_data_url("not_a_data_url")
|
||||
assert image is None
|
||||
|
||||
|
||||
class TestImageLayout:
|
||||
"""Test ImageLayout class."""
|
||||
|
||||
|
||||
def test_image_layout_init(self):
|
||||
"""Test ImageLayout initialization."""
|
||||
node = Element("img", {"src": "test.png"})
|
||||
layout = ImageLayout(node)
|
||||
|
||||
|
||||
assert layout.node == node
|
||||
assert layout.x == 0
|
||||
assert layout.y == 0
|
||||
|
|
@ -99,109 +97,109 @@ class TestImageLayout:
|
|||
assert layout.height == 0
|
||||
assert layout.image is None
|
||||
assert layout.is_inline is True
|
||||
|
||||
|
||||
def test_layout_with_intrinsic_size(self):
|
||||
"""Test layout calculation with intrinsic image size."""
|
||||
node = Element("img", {"src": "test.png"})
|
||||
layout = ImageLayout(node)
|
||||
|
||||
|
||||
# Create a test image
|
||||
layout.image = create_test_image(200, 150)
|
||||
|
||||
|
||||
width = layout.layout()
|
||||
|
||||
|
||||
assert layout.width == 200
|
||||
assert layout.height == 150
|
||||
assert width == 200
|
||||
|
||||
|
||||
def test_layout_with_explicit_width(self):
|
||||
"""Test layout with explicit width attribute."""
|
||||
node = Element("img", {"src": "test.png", "width": "100"})
|
||||
layout = ImageLayout(node)
|
||||
|
||||
|
||||
# Create a test image (200x150)
|
||||
layout.image = create_test_image(200, 150)
|
||||
|
||||
|
||||
layout.layout()
|
||||
|
||||
|
||||
# Should maintain aspect ratio
|
||||
assert layout.width == 100
|
||||
assert layout.height == 75 # 100 * (150/200)
|
||||
|
||||
|
||||
def test_layout_with_explicit_height(self):
|
||||
"""Test layout with explicit height attribute."""
|
||||
node = Element("img", {"src": "test.png", "height": "100"})
|
||||
layout = ImageLayout(node)
|
||||
|
||||
|
||||
# Create a test image (200x150)
|
||||
layout.image = create_test_image(200, 150)
|
||||
|
||||
|
||||
layout.layout()
|
||||
|
||||
|
||||
# Should maintain aspect ratio
|
||||
assert layout.height == 100
|
||||
assert abs(layout.width - 133.33) < 1 # 100 * (200/150)
|
||||
|
||||
|
||||
def test_layout_with_both_dimensions(self):
|
||||
"""Test layout with both width and height specified."""
|
||||
node = Element("img", {"src": "test.png", "width": "100", "height": "50"})
|
||||
layout = ImageLayout(node)
|
||||
|
||||
|
||||
# Create a test image
|
||||
layout.image = create_test_image(200, 150)
|
||||
|
||||
|
||||
layout.layout()
|
||||
|
||||
|
||||
# Should use explicit dimensions (no aspect ratio preservation)
|
||||
assert layout.width == 100
|
||||
assert layout.height == 50
|
||||
|
||||
|
||||
def test_layout_with_max_width(self):
|
||||
"""Test layout constrained by max_width."""
|
||||
node = Element("img", {"src": "test.png"})
|
||||
layout = ImageLayout(node)
|
||||
|
||||
|
||||
# Create a large test image
|
||||
layout.image = create_test_image(1000, 500)
|
||||
|
||||
|
||||
layout.layout(max_width=400)
|
||||
|
||||
|
||||
# Should constrain to max_width and maintain aspect ratio
|
||||
assert layout.width == 400
|
||||
assert layout.height == 200 # 400 * (500/1000)
|
||||
|
||||
|
||||
def test_layout_no_image(self):
|
||||
"""Test layout when image fails to load."""
|
||||
node = Element("img", {"src": "test.png", "alt": "Test image"})
|
||||
layout = ImageLayout(node)
|
||||
|
||||
|
||||
# Don't set an image (simulating load failure)
|
||||
layout.alt_text = "Test image"
|
||||
layout.layout()
|
||||
|
||||
|
||||
# Should use placeholder dimensions
|
||||
assert layout.width == 100
|
||||
assert layout.height == 100
|
||||
|
||||
|
||||
def test_alt_text_extraction(self):
|
||||
"""Test alt text extraction."""
|
||||
node = Element("img", {"src": "test.png", "alt": "Description"})
|
||||
layout = ImageLayout(node)
|
||||
|
||||
|
||||
layout.load()
|
||||
|
||||
|
||||
assert layout.alt_text == "Description"
|
||||
|
||||
|
||||
class TestDrawImage:
|
||||
"""Test DrawImage paint command."""
|
||||
|
||||
|
||||
def test_draw_image_init(self):
|
||||
"""Test DrawImage initialization."""
|
||||
image = create_test_image(100, 100)
|
||||
|
||||
|
||||
cmd = DrawImage(10, 20, 100, 100, image, "Test")
|
||||
|
||||
|
||||
assert cmd.x == 10
|
||||
assert cmd.y == 20
|
||||
assert cmd.width == 100
|
||||
|
|
@ -209,75 +207,75 @@ class TestDrawImage:
|
|||
assert cmd.image is image
|
||||
assert cmd.alt_text == "Test"
|
||||
assert cmd.rect == (10, 20, 110, 120)
|
||||
|
||||
|
||||
def test_draw_image_with_valid_image(self):
|
||||
"""Test drawing a valid image."""
|
||||
image = create_test_image(100, 100)
|
||||
|
||||
|
||||
# Create a surface to draw on
|
||||
surface = skia.Surface(200, 200)
|
||||
canvas = surface.getCanvas()
|
||||
|
||||
|
||||
cmd = DrawImage(10, 20, 100, 100, image)
|
||||
cmd.execute(canvas)
|
||||
|
||||
|
||||
# If it doesn't throw, it worked
|
||||
assert True
|
||||
|
||||
|
||||
def test_draw_image_with_null_image(self):
|
||||
"""Test drawing when image is None (placeholder)."""
|
||||
# Create a surface to draw on
|
||||
surface = skia.Surface(200, 200)
|
||||
canvas = surface.getCanvas()
|
||||
|
||||
|
||||
cmd = DrawImage(10, 20, 100, 100, None, "Failed to load")
|
||||
cmd.execute(canvas)
|
||||
|
||||
|
||||
# Should draw placeholder without error
|
||||
assert True
|
||||
|
||||
|
||||
class TestDocumentLayoutImages:
|
||||
"""Test image integration in DocumentLayout."""
|
||||
|
||||
|
||||
def test_parse_img_element(self):
|
||||
"""Test that img elements are parsed correctly."""
|
||||
html = '<img src="test.png" alt="Test image" width="100">'
|
||||
root = parse_html(html)
|
||||
|
||||
|
||||
# Find the img element
|
||||
body = root.children[0]
|
||||
img = body.children[0]
|
||||
|
||||
|
||||
assert img.tag == "img"
|
||||
assert img.attributes["src"] == "test.png"
|
||||
assert img.attributes["alt"] == "Test image"
|
||||
assert img.attributes["width"] == "100"
|
||||
|
||||
|
||||
def test_layout_with_image(self):
|
||||
"""Test document layout with an image."""
|
||||
html = '<p>Text before</p><img src="test.png" width="100" height="75"><p>Text after</p>'
|
||||
root = parse_html(html)
|
||||
|
||||
|
||||
layout = DocumentLayout(root)
|
||||
|
||||
|
||||
# Mock the image loading by creating the images manually
|
||||
# This would normally happen in _collect_blocks
|
||||
# For now, just verify the structure is created
|
||||
lines = layout.layout(800)
|
||||
|
||||
|
||||
# Should have lines and potentially images
|
||||
assert isinstance(lines, list)
|
||||
|
||||
|
||||
def test_layout_image_class(self):
|
||||
"""Test LayoutImage class."""
|
||||
node = Element("img", {"src": "test.png"})
|
||||
image_layout = ImageLayout(node)
|
||||
image_layout.image = create_test_image(100, 100)
|
||||
image_layout.layout()
|
||||
|
||||
|
||||
layout_image = LayoutImage(image_layout, 10, 20)
|
||||
|
||||
|
||||
assert layout_image.x == 10
|
||||
assert layout_image.y == 20
|
||||
assert layout_image.width == 100
|
||||
|
|
@ -287,7 +285,7 @@ class TestDocumentLayoutImages:
|
|||
|
||||
class TestImageIntegration:
|
||||
"""Integration tests for the complete image pipeline."""
|
||||
|
||||
|
||||
def test_html_with_data_url_image(self):
|
||||
"""Test parsing and layout of HTML with data URL image."""
|
||||
# 1x1 red PNG
|
||||
|
|
@ -295,10 +293,10 @@ class TestImageIntegration:
|
|||
"data:image/png;base64,"
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="
|
||||
)
|
||||
|
||||
|
||||
html = f'<p>Before</p><img src="{data_url}" width="50" height="50"><p>After</p>'
|
||||
root = parse_html(html)
|
||||
|
||||
|
||||
# Verify structure
|
||||
body = root.children[0]
|
||||
# The img tag is self-closing, so the second p tag becomes a child of img
|
||||
|
|
@ -306,7 +304,7 @@ class TestImageIntegration:
|
|||
assert len(body.children) >= 2
|
||||
assert body.children[0].tag == "p"
|
||||
assert body.children[1].tag == "img"
|
||||
|
||||
|
||||
def test_nested_image_in_paragraph(self):
|
||||
"""Test that images inside paragraphs are collected."""
|
||||
# 1x1 red PNG
|
||||
|
|
@ -314,28 +312,28 @@ class TestImageIntegration:
|
|||
"data:image/png;base64,"
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="
|
||||
)
|
||||
|
||||
|
||||
html = f'<p>Text before <img src="{data_url}" width="50" height="50"> text after</p>'
|
||||
root = parse_html(html)
|
||||
|
||||
|
||||
# Create layout and verify images are collected
|
||||
layout = DocumentLayout(root)
|
||||
layout.layout(800)
|
||||
|
||||
|
||||
# Should have at least one image collected
|
||||
assert len(layout.images) >= 1
|
||||
|
||||
|
||||
def test_image_with_alt_text_placeholder(self):
|
||||
"""Test that failed images show placeholder with alt text."""
|
||||
html = '<img src="nonexistent.png" width="200" height="100" alt="Image failed">'
|
||||
root = parse_html(html)
|
||||
|
||||
|
||||
layout = DocumentLayout(root)
|
||||
layout.layout(800)
|
||||
|
||||
|
||||
# Should have image layout even though load failed
|
||||
assert len(layout.images) >= 1
|
||||
|
||||
|
||||
# Check alt text is set
|
||||
if layout.images:
|
||||
img = layout.images[0]
|
||||
|
|
@ -344,41 +342,41 @@ class TestImageIntegration:
|
|||
|
||||
class TestURLResolution:
|
||||
"""Test URL resolution for images."""
|
||||
|
||||
|
||||
def test_resolve_about_page_relative_url(self):
|
||||
"""Test resolving relative URLs for about: pages."""
|
||||
from src.network.images import _resolve_url, ASSETS_DIR
|
||||
|
||||
|
||||
# Relative URL from about:startpage should resolve to assets directory
|
||||
resolved = _resolve_url("../WebBowserLogo.jpeg", "about:startpage")
|
||||
|
||||
|
||||
# Should be an absolute path to the assets directory
|
||||
assert "WebBowserLogo.jpeg" in resolved
|
||||
assert str(ASSETS_DIR) in resolved or resolved.endswith("WebBowserLogo.jpeg")
|
||||
|
||||
|
||||
def test_resolve_http_relative_url(self):
|
||||
"""Test resolving relative URLs for HTTP pages."""
|
||||
from src.network.images import _resolve_url
|
||||
|
||||
|
||||
# Relative URL from HTTP page
|
||||
resolved = _resolve_url("images/photo.jpg", "https://example.com/page/index.html")
|
||||
|
||||
|
||||
assert resolved == "https://example.com/page/images/photo.jpg"
|
||||
|
||||
|
||||
def test_resolve_absolute_url(self):
|
||||
"""Test that absolute URLs are returned unchanged."""
|
||||
from src.network.images import _resolve_url
|
||||
|
||||
|
||||
url = "https://example.com/image.png"
|
||||
resolved = _resolve_url(url, "https://other.com/page.html")
|
||||
|
||||
|
||||
assert resolved == url
|
||||
|
||||
|
||||
def test_resolve_data_url(self):
|
||||
"""Test that data URLs are returned unchanged."""
|
||||
from src.network.images import _resolve_url
|
||||
|
||||
|
||||
url = "data:image/png;base64,abc123"
|
||||
resolved = _resolve_url(url, "https://example.com/")
|
||||
|
||||
|
||||
assert resolved == url
|
||||
|
|
|
|||
341
tests/test_links.py
Normal file
341
tests/test_links.py
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
"""Tests for link parsing, rendering, and navigation."""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.parser.html import parse_html, parse_html_with_styles, Element, Text
|
||||
from src.layout.document import DocumentLayout, LayoutLine
|
||||
from src.network.url import URL
|
||||
|
||||
|
||||
class TestLinkParsing:
|
||||
"""Tests for parsing anchor elements from HTML."""
|
||||
|
||||
def test_parse_simple_link(self):
|
||||
"""Test parsing a simple anchor tag."""
|
||||
html = "<html><body><a href='https://example.com'>Click here</a></body></html>"
|
||||
root = parse_html(html)
|
||||
|
||||
# Find the anchor element
|
||||
body = root.children[0]
|
||||
assert body.tag == "body"
|
||||
anchor = body.children[0]
|
||||
assert anchor.tag == "a"
|
||||
assert anchor.attributes.get("href") == "https://example.com"
|
||||
|
||||
def test_parse_link_with_text(self):
|
||||
"""Test that link text content is preserved."""
|
||||
html = "<html><body><a href='/page'>Link Text</a></body></html>"
|
||||
root = parse_html(html)
|
||||
|
||||
body = root.children[0]
|
||||
anchor = body.children[0]
|
||||
assert len(anchor.children) == 1
|
||||
assert isinstance(anchor.children[0], Text)
|
||||
assert anchor.children[0].text.strip() == "Link Text"
|
||||
|
||||
def test_parse_link_in_paragraph(self):
|
||||
"""Test parsing a link inside a paragraph."""
|
||||
html = "<html><body><p>Visit <a href='https://test.com'>our site</a> today!</p></body></html>"
|
||||
root = parse_html(html)
|
||||
|
||||
body = root.children[0]
|
||||
# The parser may flatten this - check for anchor presence
|
||||
found_anchor = False
|
||||
|
||||
def find_anchor(node):
|
||||
nonlocal found_anchor
|
||||
if isinstance(node, Element) and node.tag == "a":
|
||||
found_anchor = True
|
||||
assert node.attributes.get("href") == "https://test.com"
|
||||
if hasattr(node, "children"):
|
||||
for child in node.children:
|
||||
find_anchor(child)
|
||||
|
||||
find_anchor(body)
|
||||
assert found_anchor, "Anchor element not found"
|
||||
|
||||
def test_parse_link_with_relative_href(self):
|
||||
"""Test parsing a link with a relative URL."""
|
||||
html = "<html><body><a href='/about'>About</a></body></html>"
|
||||
root = parse_html(html)
|
||||
|
||||
body = root.children[0]
|
||||
anchor = body.children[0]
|
||||
assert anchor.attributes.get("href") == "/about"
|
||||
|
||||
def test_parse_link_with_anchor_href(self):
|
||||
"""Test parsing a link with an anchor reference."""
|
||||
html = "<html><body><a href='#section'>Jump</a></body></html>"
|
||||
root = parse_html(html)
|
||||
|
||||
body = root.children[0]
|
||||
anchor = body.children[0]
|
||||
assert anchor.attributes.get("href") == "#section"
|
||||
|
||||
|
||||
class TestLinkLayout:
|
||||
"""Tests for link layout and styling."""
|
||||
|
||||
def test_link_layout_has_href(self):
|
||||
"""Test that layout lines for links include href."""
|
||||
html = "<html><body><a href='https://example.com'>Link</a></body></html>"
|
||||
root = parse_html_with_styles(html)
|
||||
|
||||
layout = DocumentLayout(root)
|
||||
layout.layout(800)
|
||||
|
||||
# Find line with href
|
||||
link_lines = [line for line in layout.lines if line.href]
|
||||
assert len(link_lines) > 0, "No link lines found"
|
||||
assert link_lines[0].href == "https://example.com"
|
||||
|
||||
def test_link_layout_has_color(self):
|
||||
"""Test that layout lines for links have a color."""
|
||||
html = "<html><body><a href='https://example.com'>Link</a></body></html>"
|
||||
root = parse_html_with_styles(html)
|
||||
|
||||
layout = DocumentLayout(root)
|
||||
layout.layout(800)
|
||||
|
||||
# Find line with color
|
||||
link_lines = [line for line in layout.lines if line.href]
|
||||
assert len(link_lines) > 0
|
||||
# Should have either CSS-specified color or default link color
|
||||
assert link_lines[0].color is not None
|
||||
|
||||
def test_non_link_has_no_href(self):
|
||||
"""Test that non-link elements don't have href."""
|
||||
html = "<html><body><p>Regular paragraph</p></body></html>"
|
||||
root = parse_html_with_styles(html)
|
||||
|
||||
layout = DocumentLayout(root)
|
||||
layout.layout(800)
|
||||
|
||||
# All lines should have no href
|
||||
for line in layout.lines:
|
||||
assert line.href is None
|
||||
|
||||
def test_layout_line_constructor(self):
|
||||
"""Test LayoutLine constructor with color and href."""
|
||||
line = LayoutLine(
|
||||
text="Click me",
|
||||
x=10,
|
||||
y=20,
|
||||
font_size=14,
|
||||
color="#0066cc",
|
||||
href="https://example.com"
|
||||
)
|
||||
|
||||
assert line.text == "Click me"
|
||||
assert line.color == "#0066cc"
|
||||
assert line.href == "https://example.com"
|
||||
|
||||
def test_layout_line_default_values(self):
|
||||
"""Test LayoutLine defaults for color and href."""
|
||||
line = LayoutLine(
|
||||
text="Normal text",
|
||||
x=10,
|
||||
y=20,
|
||||
font_size=14
|
||||
)
|
||||
|
||||
assert line.color is None
|
||||
assert line.href is None
|
||||
|
||||
|
||||
class TestLinkURLResolution:
|
||||
"""Tests for URL resolution of links."""
|
||||
|
||||
def test_resolve_absolute_url(self):
|
||||
"""Test that absolute URLs are preserved."""
|
||||
base = URL("https://example.com/page")
|
||||
resolved = base.resolve("https://other.com/path")
|
||||
assert str(resolved) == "https://other.com/path"
|
||||
|
||||
def test_resolve_relative_url(self):
|
||||
"""Test resolving a relative URL."""
|
||||
base = URL("https://example.com/page")
|
||||
resolved = base.resolve("/about")
|
||||
assert str(resolved) == "https://example.com/about"
|
||||
|
||||
def test_resolve_relative_path(self):
|
||||
"""Test resolving a relative path."""
|
||||
base = URL("https://example.com/dir/page")
|
||||
resolved = base.resolve("other")
|
||||
assert str(resolved) == "https://example.com/dir/other"
|
||||
|
||||
def test_resolve_parent_relative(self):
|
||||
"""Test resolving a parent-relative path."""
|
||||
base = URL("https://example.com/dir/subdir/page")
|
||||
resolved = base.resolve("../other")
|
||||
assert str(resolved) == "https://example.com/dir/other"
|
||||
|
||||
def test_resolve_anchor_only(self):
|
||||
"""Test resolving an anchor-only URL."""
|
||||
base = URL("https://example.com/page")
|
||||
resolved = base.resolve("#section")
|
||||
assert str(resolved) == "https://example.com/page#section"
|
||||
|
||||
def test_resolve_query_string(self):
|
||||
"""Test resolving a URL with query string."""
|
||||
base = URL("https://example.com/page")
|
||||
resolved = base.resolve("?query=value")
|
||||
assert str(resolved) == "https://example.com/page?query=value"
|
||||
|
||||
|
||||
class TestRenderPipelineColorParsing:
|
||||
"""Tests for color parsing in the render pipeline.
|
||||
|
||||
Note: These tests only run when skia is NOT mocked (i.e., when run in isolation).
|
||||
When run after test_render.py, skia becomes a MagicMock and these tests are skipped.
|
||||
"""
|
||||
|
||||
def test_parse_hex_color_6digit(self):
|
||||
"""Test parsing 6-digit hex colors."""
|
||||
from src.render.pipeline import RenderPipeline
|
||||
import skia
|
||||
|
||||
# Skip if skia is mocked
|
||||
if hasattr(skia.Color, '_mock_name'):
|
||||
pytest.skip("skia is mocked")
|
||||
|
||||
pipeline = RenderPipeline()
|
||||
color = pipeline._parse_color("#0066cc")
|
||||
|
||||
# Extract RGB components (Skia color is ARGB)
|
||||
r = (color >> 16) & 0xFF
|
||||
g = (color >> 8) & 0xFF
|
||||
b = color & 0xFF
|
||||
|
||||
assert r == 0x00
|
||||
assert g == 0x66
|
||||
assert b == 0xcc
|
||||
|
||||
def test_parse_hex_color_3digit(self):
|
||||
"""Test parsing 3-digit hex colors."""
|
||||
from src.render.pipeline import RenderPipeline
|
||||
import skia
|
||||
|
||||
# Skip if skia is mocked
|
||||
if hasattr(skia.Color, '_mock_name'):
|
||||
pytest.skip("skia is mocked")
|
||||
|
||||
pipeline = RenderPipeline()
|
||||
color = pipeline._parse_color("#abc")
|
||||
|
||||
# #abc should expand to #aabbcc
|
||||
r = (color >> 16) & 0xFF
|
||||
g = (color >> 8) & 0xFF
|
||||
b = color & 0xFF
|
||||
|
||||
# Each digit is doubled: a->aa, b->bb, c->cc
|
||||
# But our implementation uses int("a" * 2, 16) which is int("aa", 16) = 170
|
||||
assert r == 0xaa
|
||||
assert g == 0xbb
|
||||
assert b == 0xcc
|
||||
|
||||
def test_parse_named_color(self):
|
||||
"""Test parsing named colors."""
|
||||
from src.render.pipeline import RenderPipeline
|
||||
import skia
|
||||
|
||||
# Skip if skia is mocked
|
||||
if hasattr(skia.ColorBLACK, '_mock_name'):
|
||||
pytest.skip("skia is mocked")
|
||||
|
||||
pipeline = RenderPipeline()
|
||||
|
||||
# Test that named colors return a valid integer color value
|
||||
black = pipeline._parse_color("black")
|
||||
white = pipeline._parse_color("white")
|
||||
red = pipeline._parse_color("red")
|
||||
|
||||
# Black should be 0xFF000000 (opaque black in ARGB)
|
||||
assert isinstance(black, int)
|
||||
assert (black & 0xFFFFFF) == 0x000000 # RGB is 0
|
||||
|
||||
# White is converted to black because it would be invisible on white bg
|
||||
assert isinstance(white, int)
|
||||
assert (white & 0xFFFFFF) == 0x000000 # Converted to black
|
||||
|
||||
# Red should have R=255, G=0, B=0
|
||||
assert isinstance(red, int)
|
||||
r = (red >> 16) & 0xFF
|
||||
assert r == 0xFF # Red component should be 255
|
||||
|
||||
def test_parse_invalid_color_returns_black(self):
|
||||
"""Test that invalid colors return black."""
|
||||
from src.render.pipeline import RenderPipeline
|
||||
import skia
|
||||
|
||||
# Skip if skia is mocked
|
||||
if hasattr(skia.ColorBLACK, '_mock_name'):
|
||||
pytest.skip("skia is mocked")
|
||||
|
||||
pipeline = RenderPipeline()
|
||||
|
||||
invalid = pipeline._parse_color("invalid")
|
||||
invalid2 = pipeline._parse_color("#xyz")
|
||||
invalid3 = pipeline._parse_color("")
|
||||
|
||||
# All should return black (integer value)
|
||||
assert isinstance(invalid, int)
|
||||
assert isinstance(invalid2, int)
|
||||
assert isinstance(invalid3, int)
|
||||
|
||||
# RGB components should be 0 (black)
|
||||
assert (invalid & 0xFFFFFF) == 0x000000
|
||||
|
||||
|
||||
class TestGetTextLayoutWithHref:
|
||||
"""Tests for text layout including href information."""
|
||||
|
||||
def test_get_text_layout_includes_href(self):
|
||||
"""Test that get_text_layout includes href for links."""
|
||||
from src.render.pipeline import RenderPipeline
|
||||
|
||||
html = "<html><body><a href='https://example.com'>Click</a></body></html>"
|
||||
root = parse_html_with_styles(html)
|
||||
|
||||
pipeline = RenderPipeline()
|
||||
pipeline.layout(root, 800)
|
||||
|
||||
text_layout = pipeline.get_text_layout()
|
||||
|
||||
# Find the link line
|
||||
link_entries = [entry for entry in text_layout if entry.get("href")]
|
||||
assert len(link_entries) > 0
|
||||
assert link_entries[0]["href"] == "https://example.com"
|
||||
|
||||
def test_get_text_layout_normal_text_no_href(self):
|
||||
"""Test that normal text has no href in layout."""
|
||||
from src.render.pipeline import RenderPipeline
|
||||
|
||||
html = "<html><body><p>Normal text</p></body></html>"
|
||||
root = parse_html_with_styles(html)
|
||||
|
||||
pipeline = RenderPipeline()
|
||||
pipeline.layout(root, 800)
|
||||
|
||||
text_layout = pipeline.get_text_layout()
|
||||
|
||||
# All entries should have href=None
|
||||
for entry in text_layout:
|
||||
assert entry.get("href") is None
|
||||
|
||||
|
||||
class TestLinkDefaultStyling:
|
||||
"""Tests for default link styling from CSS."""
|
||||
|
||||
def test_link_default_color_in_css(self):
|
||||
"""Test that default.css defines link color."""
|
||||
from pathlib import Path
|
||||
|
||||
css_path = Path(__file__).parent.parent / "assets" / "default.css"
|
||||
assert css_path.exists(), "default.css should exist"
|
||||
|
||||
css_content = css_path.read_text()
|
||||
|
||||
# Check that 'a' selector is defined with a color
|
||||
assert "a {" in css_content or "a{" in css_content.replace(" ", "")
|
||||
assert "color:" in css_content
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
"""Integration tests for CSS styling system."""
|
||||
|
||||
import pytest
|
||||
from src.parser.html import parse_html_with_styles, Element
|
||||
from src.parser.html import parse_html_with_styles
|
||||
from src.layout.document import DocumentLayout
|
||||
|
||||
|
||||
class TestStyleIntegration:
|
||||
"""Test end-to-end CSS parsing and layout integration."""
|
||||
|
||||
|
||||
def test_parse_with_style_tag(self):
|
||||
html = """
|
||||
<html>
|
||||
|
|
@ -22,7 +22,7 @@ class TestStyleIntegration:
|
|||
</html>
|
||||
"""
|
||||
root = parse_html_with_styles(html)
|
||||
|
||||
|
||||
# Find the p element
|
||||
p_elem = None
|
||||
for child in root.children:
|
||||
|
|
@ -31,12 +31,12 @@ class TestStyleIntegration:
|
|||
if hasattr(grandchild, "tag") and grandchild.tag == "p":
|
||||
p_elem = grandchild
|
||||
break
|
||||
|
||||
|
||||
assert p_elem is not None
|
||||
assert hasattr(p_elem, "computed_style")
|
||||
assert p_elem.computed_style.get("color") == "red"
|
||||
assert p_elem.computed_style.get("font-size") == "18px"
|
||||
|
||||
|
||||
def test_inline_style_override(self):
|
||||
html = """
|
||||
<html>
|
||||
|
|
@ -46,7 +46,7 @@ class TestStyleIntegration:
|
|||
</html>
|
||||
"""
|
||||
root = parse_html_with_styles(html)
|
||||
|
||||
|
||||
# Find the p element
|
||||
for child in root.children:
|
||||
if hasattr(child, "tag") and child.tag == "body":
|
||||
|
|
@ -56,9 +56,9 @@ class TestStyleIntegration:
|
|||
assert p_elem.computed_style.get("color") == "blue"
|
||||
assert p_elem.computed_style.get("font-size") == "20px"
|
||||
return
|
||||
|
||||
|
||||
pytest.fail("P element not found")
|
||||
|
||||
|
||||
def test_cascade_priority(self):
|
||||
html = """
|
||||
<html>
|
||||
|
|
@ -78,24 +78,24 @@ class TestStyleIntegration:
|
|||
</html>
|
||||
"""
|
||||
root = parse_html_with_styles(html)
|
||||
|
||||
|
||||
# Find body
|
||||
body = None
|
||||
for child in root.children:
|
||||
if hasattr(child, "tag") and child.tag == "body":
|
||||
body = child
|
||||
break
|
||||
|
||||
|
||||
assert body is not None
|
||||
paragraphs = [c for c in body.children if hasattr(c, "tag") and c.tag == "p"]
|
||||
assert len(paragraphs) == 4
|
||||
|
||||
|
||||
# Check cascade
|
||||
assert paragraphs[0].computed_style.get("color") == "red" # Tag only
|
||||
assert paragraphs[1].computed_style.get("color") == "green" # Class wins
|
||||
assert paragraphs[2].computed_style.get("color") == "blue" # ID wins
|
||||
assert paragraphs[3].computed_style.get("color") == "purple" # Inline wins
|
||||
|
||||
|
||||
def test_inheritance(self):
|
||||
html = """
|
||||
<html>
|
||||
|
|
@ -112,7 +112,7 @@ class TestStyleIntegration:
|
|||
</html>
|
||||
"""
|
||||
root = parse_html_with_styles(html)
|
||||
|
||||
|
||||
# Find the nested p element
|
||||
for child in root.children:
|
||||
if hasattr(child, "tag") and child.tag == "body":
|
||||
|
|
@ -150,10 +150,10 @@ class TestStyleIntegration:
|
|||
lines = layout.layout(800)
|
||||
# H1 should use custom font size
|
||||
assert lines[0].font_size == 40
|
||||
|
||||
|
||||
# P should use custom font size
|
||||
assert lines[1].font_size == 20
|
||||
|
||||
|
||||
def test_multiple_classes(self):
|
||||
html = """
|
||||
<html>
|
||||
|
|
@ -169,7 +169,7 @@ class TestStyleIntegration:
|
|||
</html>
|
||||
"""
|
||||
root = parse_html_with_styles(html)
|
||||
|
||||
|
||||
# Find the p element
|
||||
for child in root.children:
|
||||
if hasattr(child, "tag") and child.tag == "body":
|
||||
|
|
@ -179,9 +179,9 @@ class TestStyleIntegration:
|
|||
assert grandchild.computed_style.get("font-size") == "24px"
|
||||
assert grandchild.computed_style.get("color") == "red"
|
||||
return
|
||||
|
||||
|
||||
pytest.fail("P element not found")
|
||||
|
||||
|
||||
def test_default_styles_applied(self):
|
||||
html = """
|
||||
<html>
|
||||
|
|
@ -193,20 +193,20 @@ class TestStyleIntegration:
|
|||
</html>
|
||||
"""
|
||||
root = parse_html_with_styles(html)
|
||||
|
||||
|
||||
# Find elements
|
||||
body = None
|
||||
for child in root.children:
|
||||
if hasattr(child, "tag") and child.tag == "body":
|
||||
body = child
|
||||
break
|
||||
|
||||
|
||||
assert body is not None
|
||||
|
||||
|
||||
h1 = next((c for c in body.children if hasattr(c, "tag") and c.tag == "h1"), None)
|
||||
p = next((c for c in body.children if hasattr(c, "tag") and c.tag == "p"), None)
|
||||
a = next((c for c in body.children if hasattr(c, "tag") and c.tag == "a"), None)
|
||||
|
||||
|
||||
# Check default styles from default.css
|
||||
assert h1 is not None
|
||||
# Font-size from default.css is 2.5rem
|
||||
|
|
@ -220,7 +220,7 @@ class TestStyleIntegration:
|
|||
# Link color from default.css
|
||||
assert a.computed_style.get("color") == "#0066cc"
|
||||
assert a.computed_style.get("text-decoration") == "none"
|
||||
|
||||
|
||||
def test_no_styles_when_disabled(self):
|
||||
html = """
|
||||
<html>
|
||||
|
|
@ -235,7 +235,7 @@ class TestStyleIntegration:
|
|||
</html>
|
||||
"""
|
||||
root = parse_html_with_styles(html, apply_styles=False)
|
||||
|
||||
|
||||
# Find the p element
|
||||
for child in root.children:
|
||||
if hasattr(child, "tag") and child.tag == "body":
|
||||
|
|
@ -244,5 +244,5 @@ class TestStyleIntegration:
|
|||
# Should not have computed_style when disabled
|
||||
assert not hasattr(grandchild, "computed_style")
|
||||
return
|
||||
|
||||
|
||||
pytest.fail("P element not found")
|
||||
|
|
|
|||
|
|
@ -1,190 +1,189 @@
|
|||
"""Tests for the async task queue system."""
|
||||
|
||||
import pytest
|
||||
import time
|
||||
import threading
|
||||
from unittest.mock import Mock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class TestTaskQueue:
|
||||
"""Tests for the TaskQueue class."""
|
||||
|
||||
|
||||
def test_task_queue_singleton(self):
|
||||
"""Test that TaskQueue is a singleton."""
|
||||
from src.network.tasks import TaskQueue
|
||||
|
||||
|
||||
# Reset singleton for clean test
|
||||
TaskQueue.reset_instance()
|
||||
|
||||
|
||||
q1 = TaskQueue()
|
||||
q2 = TaskQueue()
|
||||
|
||||
|
||||
assert q1 is q2
|
||||
|
||||
|
||||
# Clean up
|
||||
TaskQueue.reset_instance()
|
||||
|
||||
|
||||
def test_submit_task_returns_id(self):
|
||||
"""Test that submit returns a task ID."""
|
||||
from src.network.tasks import TaskQueue
|
||||
|
||||
|
||||
TaskQueue.reset_instance()
|
||||
queue = TaskQueue()
|
||||
|
||||
|
||||
# Mock GLib.idle_add to avoid GTK dependency
|
||||
with patch('src.network.tasks.GLib') as mock_glib:
|
||||
mock_glib.idle_add = lambda cb, *args: cb(*args) if args else cb()
|
||||
|
||||
|
||||
task_id = queue.submit(lambda: 42)
|
||||
|
||||
|
||||
# Task ID should be non-negative (or -1 for cached)
|
||||
assert isinstance(task_id, int)
|
||||
|
||||
|
||||
# Wait for task to complete
|
||||
time.sleep(0.1)
|
||||
TaskQueue.reset_instance()
|
||||
|
||||
|
||||
def test_task_executes_function(self):
|
||||
"""Test that submitted tasks are executed."""
|
||||
from src.network.tasks import TaskQueue
|
||||
|
||||
|
||||
TaskQueue.reset_instance()
|
||||
queue = TaskQueue()
|
||||
|
||||
|
||||
result = []
|
||||
event = threading.Event()
|
||||
|
||||
threading.Event()
|
||||
|
||||
def task():
|
||||
result.append("executed")
|
||||
return "done"
|
||||
|
||||
|
||||
with patch('src.network.tasks.GLib') as mock_glib:
|
||||
mock_glib.idle_add = lambda cb, *args: cb(*args) if args else cb()
|
||||
|
||||
|
||||
queue.submit(task)
|
||||
|
||||
|
||||
# Wait for task to complete
|
||||
time.sleep(0.2)
|
||||
|
||||
|
||||
assert "executed" in result
|
||||
|
||||
|
||||
TaskQueue.reset_instance()
|
||||
|
||||
|
||||
def test_on_complete_callback(self):
|
||||
"""Test that on_complete callback is called with result."""
|
||||
from src.network.tasks import TaskQueue
|
||||
|
||||
|
||||
TaskQueue.reset_instance()
|
||||
queue = TaskQueue()
|
||||
|
||||
|
||||
results = []
|
||||
|
||||
|
||||
def task():
|
||||
return 42
|
||||
|
||||
|
||||
def on_complete(result):
|
||||
results.append(result)
|
||||
|
||||
|
||||
with patch('src.network.tasks.GLib') as mock_glib:
|
||||
# Make idle_add execute immediately
|
||||
mock_glib.idle_add = lambda cb, *args: cb(*args) if args else cb()
|
||||
|
||||
|
||||
queue.submit(task, on_complete=on_complete)
|
||||
|
||||
|
||||
# Wait for task to complete (may need more time under load)
|
||||
for _ in range(10):
|
||||
if 42 in results:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
|
||||
|
||||
assert 42 in results
|
||||
|
||||
|
||||
TaskQueue.reset_instance()
|
||||
|
||||
|
||||
def test_on_error_callback(self):
|
||||
"""Test that on_error callback is called on exception."""
|
||||
from src.network.tasks import TaskQueue
|
||||
|
||||
|
||||
TaskQueue.reset_instance()
|
||||
queue = TaskQueue()
|
||||
|
||||
|
||||
errors = []
|
||||
|
||||
|
||||
def failing_task():
|
||||
raise ValueError("Test error")
|
||||
|
||||
|
||||
def on_error(e):
|
||||
errors.append(str(e))
|
||||
|
||||
|
||||
with patch('src.network.tasks.GLib') as mock_glib:
|
||||
mock_glib.idle_add = lambda cb, *args: cb(*args) if args else cb()
|
||||
|
||||
|
||||
queue.submit(failing_task, on_error=on_error)
|
||||
|
||||
|
||||
# Wait for task to complete (may need more time under load)
|
||||
for _ in range(10):
|
||||
if len(errors) == 1:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
|
||||
|
||||
assert len(errors) == 1
|
||||
assert "Test error" in errors[0]
|
||||
|
||||
|
||||
TaskQueue.reset_instance()
|
||||
|
||||
|
||||
def test_cancel_task(self):
|
||||
"""Test task cancellation."""
|
||||
from src.network.tasks import TaskQueue
|
||||
|
||||
|
||||
TaskQueue.reset_instance()
|
||||
queue = TaskQueue()
|
||||
|
||||
|
||||
result = []
|
||||
|
||||
|
||||
def slow_task():
|
||||
time.sleep(1)
|
||||
result.append("completed")
|
||||
return True
|
||||
|
||||
|
||||
with patch('src.network.tasks.GLib') as mock_glib:
|
||||
mock_glib.idle_add = lambda cb, *args: cb(*args) if args else cb()
|
||||
|
||||
|
||||
task_id = queue.submit(slow_task)
|
||||
|
||||
|
||||
# Cancel immediately
|
||||
cancelled = queue.cancel(task_id)
|
||||
|
||||
|
||||
# May or may not be cancellable depending on timing
|
||||
assert isinstance(cancelled, bool)
|
||||
|
||||
|
||||
# Wait briefly
|
||||
time.sleep(0.1)
|
||||
|
||||
|
||||
TaskQueue.reset_instance()
|
||||
|
||||
|
||||
def test_pending_count(self):
|
||||
"""Test pending task count."""
|
||||
from src.network.tasks import TaskQueue
|
||||
|
||||
|
||||
TaskQueue.reset_instance()
|
||||
queue = TaskQueue()
|
||||
|
||||
|
||||
initial_count = queue.pending_count
|
||||
assert initial_count >= 0
|
||||
|
||||
|
||||
TaskQueue.reset_instance()
|
||||
|
||||
|
||||
class TestAsyncImageLoading:
|
||||
"""Tests for async image loading."""
|
||||
|
||||
|
||||
def test_load_image_async_cached(self):
|
||||
"""Test that cached images return -1 (no task needed)."""
|
||||
from src.network.images import load_image_async, load_image, ImageCache
|
||||
|
||||
|
||||
# Clear cache
|
||||
ImageCache().clear()
|
||||
|
||||
|
||||
# Load an image synchronously first (to cache it)
|
||||
data_url = (
|
||||
"data:image/png;base64,"
|
||||
|
|
@ -192,43 +191,43 @@ class TestAsyncImageLoading:
|
|||
)
|
||||
image = load_image(data_url)
|
||||
assert image is not None
|
||||
|
||||
|
||||
# Now load async - should hit cache and return -1 (no task)
|
||||
# We don't need a callback for this test - just checking return value
|
||||
task_id = load_image_async(data_url, on_complete=None)
|
||||
|
||||
|
||||
# Cached loads return -1 (no task created)
|
||||
assert task_id == -1
|
||||
|
||||
|
||||
def test_load_image_async_uncached(self):
|
||||
"""Test that uncached images create tasks."""
|
||||
from src.network.images import load_image_async, ImageCache
|
||||
from src.network.tasks import TaskQueue
|
||||
|
||||
|
||||
# Clear cache
|
||||
ImageCache().clear()
|
||||
TaskQueue.reset_instance()
|
||||
|
||||
|
||||
# Use a data URL that's not cached
|
||||
data_url = (
|
||||
"data:image/png;base64,"
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAADklEQVR42mP8z8DwHwYAAQYBA/5h2aw4AAAAAElFTkSuQmCC"
|
||||
)
|
||||
|
||||
|
||||
# Patch GLib.idle_add to call callbacks immediately (no GTK main loop in tests)
|
||||
with patch('src.network.tasks.GLib') as mock_glib:
|
||||
mock_glib.idle_add = lambda cb, *args: cb(*args) if args else cb()
|
||||
|
||||
|
||||
# Without a callback, it just submits the task
|
||||
task_id = load_image_async(data_url, on_complete=None)
|
||||
|
||||
|
||||
# Should create a task (non-negative ID)
|
||||
assert task_id >= 0
|
||||
|
||||
|
||||
# Wait for task to complete
|
||||
time.sleep(0.3)
|
||||
|
||||
|
||||
# Image should now be cached
|
||||
assert ImageCache().has(data_url)
|
||||
|
||||
|
||||
TaskQueue.reset_instance()
|
||||
|
|
|
|||
Loading…
Reference in a new issue