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:
Benedikt Willi 2026-01-13 13:06:20 +01:00
parent 762dd22e31
commit 8c2d360515
16 changed files with 1012 additions and 431 deletions

View file

@ -149,7 +149,7 @@ class Chrome:
self.drawing_area.set_can_focus(True) # Allow focus for keyboard events self.drawing_area.set_can_focus(True) # Allow focus for keyboard events
self.drawing_area.set_focusable(True) self.drawing_area.set_focusable(True)
content_box.append(self.drawing_area) content_box.append(self.drawing_area)
# Set up redraw callback for async image loading # Set up redraw callback for async image loading
self.render_pipeline.set_redraw_callback(self._request_redraw) self.render_pipeline.set_redraw_callback(self._request_redraw)
@ -412,7 +412,7 @@ class Chrome:
# Sync debug mode with render pipeline # Sync debug mode with render pipeline
self.render_pipeline.debug_mode = self.debug_mode self.render_pipeline.debug_mode = self.debug_mode
# Set base URL for resolving relative image paths # Set base URL for resolving relative image paths
if self.browser.active_tab and self.browser.active_tab.current_url: if self.browser.active_tab and self.browser.active_tab.current_url:
self.render_pipeline.base_url = str(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.""" """Trigger redraw of the drawing area."""
if self.drawing_area and self.window: if self.drawing_area and self.window:
self.drawing_area.queue_draw() self.drawing_area.queue_draw()
def _request_redraw(self): def _request_redraw(self):
"""Request a redraw, called when async images finish loading.""" """Request a redraw, called when async images finish loading."""
# This is called from the main thread via GLib.idle_add # This is called from the main thread via GLib.idle_add
@ -736,11 +736,32 @@ class Chrome:
self.drawing_area.grab_focus() self.drawing_area.grab_focus()
def _on_mouse_released(self, gesture, n_press, x, y): 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: if self.is_selecting:
self.selection_end = (x, y + self.scroll_y) self.selection_end = (click_x, click_y)
self.is_selecting = False 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() selected_text = self._get_selected_text()
if selected_text: if selected_text:
self.logger.info(f"Selected text: {selected_text[:100]}...") self.logger.info(f"Selected text: {selected_text[:100]}...")
@ -748,6 +769,46 @@ class Chrome:
self._copy_to_clipboard(selected_text) self._copy_to_clipboard(selected_text)
self.paint() 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): def _on_mouse_motion(self, controller, x, y):
"""Handle mouse motion for drag selection.""" """Handle mouse motion for drag selection."""
if self.is_selecting: if self.is_selecting:

View file

@ -8,12 +8,15 @@ from .embed import ImageLayout
class LayoutLine: class LayoutLine:
"""A laid-out line ready for rendering.""" """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.text = text
self.x = x self.x = x
self.y = y # Top of line self.y = y # Top of line
self.font_size = font_size self.font_size = font_size
self.font_family = font_family 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.height = linespace(font_size)
self.width = 0 self.width = 0
self.char_positions = char_positions or [] self.char_positions = char_positions or []
@ -34,12 +37,12 @@ class LayoutImage:
# Store initial dimensions but also provide dynamic access # Store initial dimensions but also provide dynamic access
self._initial_width = image_layout.width self._initial_width = image_layout.width
self._initial_height = image_layout.height self._initial_height = image_layout.height
@property @property
def width(self) -> float: def width(self) -> float:
"""Get current width (may update after async image load).""" """Get current width (may update after async image load)."""
return self.image_layout.width if self.image_layout.width > 0 else self._initial_width return self.image_layout.width if self.image_layout.width > 0 else self._initial_width
@property @property
def height(self) -> float: def height(self) -> float:
"""Get current height (may update after async image load).""" """Get current height (may update after async image load)."""
@ -104,18 +107,18 @@ class DocumentLayout:
margin_top = block_info.get("margin_top", 6) margin_top = block_info.get("margin_top", 6)
margin_bottom = block_info.get("margin_bottom", 10) margin_bottom = block_info.get("margin_bottom", 10)
y += margin_top y += margin_top
# Position the image # Position the image
image_layout.x = x_margin image_layout.x = x_margin
image_layout.y = y image_layout.y = y
# Add to images list for rendering # Add to images list for rendering
layout_image = LayoutImage(image_layout, x_margin, y) layout_image = LayoutImage(image_layout, x_margin, y)
self.images.append(layout_image) self.images.append(layout_image)
y += image_layout.height + margin_bottom y += image_layout.height + margin_bottom
continue continue
font_size = block_info.get("font_size", 14) font_size = block_info.get("font_size", 14)
font_family = block_info.get("font_family", "") font_family = block_info.get("font_family", "")
text = block_info.get("text", "") text = block_info.get("text", "")
@ -123,6 +126,8 @@ class DocumentLayout:
margin_bottom = block_info.get("margin_bottom", 10) margin_bottom = block_info.get("margin_bottom", 10)
block_type = block_info.get("block_type", "block") block_type = block_info.get("block_type", "block")
tag = block_info.get("tag", "") 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: if not text:
y += font_size * 0.6 y += font_size * 0.6
@ -172,7 +177,9 @@ class DocumentLayout:
y=y, # Top of line, baseline is y + font_size y=y, # Top of line, baseline is y + font_size
font_size=font_size, font_size=font_size,
char_positions=char_positions, char_positions=char_positions,
font_family=font_family font_family=font_family,
color=color,
href=href
) )
layout_block.lines.append(layout_line) layout_block.lines.append(layout_line)
@ -228,13 +235,13 @@ class DocumentLayout:
# Skip style and script tags - they shouldn't be rendered # Skip style and script tags - they shouldn't be rendered
if tag in {"style", "script", "head", "title", "meta", "link"}: if tag in {"style", "script", "head", "title", "meta", "link"}:
continue continue
# Handle img tags # Handle img tags
if tag == "img": if tag == "img":
image_layout = ImageLayout(child) image_layout = ImageLayout(child)
image_layout.load(self.base_url, async_load=self.async_images) 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) image_layout.layout(max_width=self.width - 40 if self.width > 40 else 800)
# Get computed style for margins # Get computed style for margins
style = getattr(child, "computed_style", None) style = getattr(child, "computed_style", None)
if style: if style:
@ -243,7 +250,7 @@ class DocumentLayout:
else: else:
margin_top = 6 margin_top = 6
margin_bottom = 10 margin_bottom = 10
blocks.append({ blocks.append({
"is_image": True, "is_image": True,
"image_layout": image_layout, "image_layout": image_layout,
@ -257,10 +264,57 @@ class DocumentLayout:
blocks.extend(self._collect_blocks(child)) blocks.extend(self._collect_blocks(child))
continue 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) embedded_images = self._collect_images(child)
blocks.extend(embedded_images) blocks.extend(embedded_images)
# Check if this element contains only a link
link_info = self._extract_single_link(child)
content = self._text_of(child) content = self._text_of(child)
if not content: if not content:
continue continue
@ -275,6 +329,7 @@ class DocumentLayout:
margin_bottom = style.get_int("margin-bottom", 10) margin_bottom = style.get_int("margin-bottom", 10)
display = style.get("display", "block") display = style.get("display", "block")
font_family = style.get("font-family", "") font_family = style.get("font-family", "")
color = style.get("color") # Get text color from style
else: else:
# Fallback to hardcoded defaults # Fallback to hardcoded defaults
font_size = self._get_default_font_size(tag) font_size = self._get_default_font_size(tag)
@ -282,6 +337,14 @@ class DocumentLayout:
margin_bottom = self._get_default_margin_bottom(tag) margin_bottom = self._get_default_margin_bottom(tag)
display = "inline" if tag in {"span", "a", "strong", "em", "b", "i", "code"} else "block" display = "inline" if tag in {"span", "a", "strong", "em", "b", "i", "code"} else "block"
font_family = "" 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 # Determine block type
block_type = "inline" if display == "inline" else "block" block_type = "inline" if display == "inline" else "block"
@ -300,7 +363,9 @@ class DocumentLayout:
"block_type": block_type, "block_type": block_type,
"tag": tag, "tag": tag,
"bullet": bullet, "bullet": bullet,
"style": style "style": style,
"color": color,
"href": href
}) })
return blocks return blocks
@ -326,20 +391,56 @@ class DocumentLayout:
} }
return margins.get(tag, 0) 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: def _collect_images(self, node) -> list:
"""Recursively collect all img elements from a node.""" """Recursively collect all img elements from a node."""
images = [] images = []
if not isinstance(node, Element): if not isinstance(node, Element):
return images return images
for child in getattr(node, "children", []): for child in getattr(node, "children", []):
if isinstance(child, Element): if isinstance(child, Element):
if child.tag.lower() == "img": if child.tag.lower() == "img":
image_layout = ImageLayout(child) image_layout = ImageLayout(child)
image_layout.load(self.base_url, async_load=self.async_images) 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) image_layout.layout(max_width=self.width - 40 if self.width > 40 else 800)
style = getattr(child, "computed_style", None) style = getattr(child, "computed_style", None)
if style: if style:
margin_top = style.get_int("margin-top", 6) margin_top = style.get_int("margin-top", 6)
@ -347,7 +448,7 @@ class DocumentLayout:
else: else:
margin_top = 6 margin_top = 6
margin_bottom = 10 margin_bottom = 10
images.append({ images.append({
"is_image": True, "is_image": True,
"image_layout": image_layout, "image_layout": image_layout,
@ -357,9 +458,9 @@ class DocumentLayout:
else: else:
# Recurse into children # Recurse into children
images.extend(self._collect_images(child)) images.extend(self._collect_images(child))
return images return images
def _text_of(self, node) -> str: def _text_of(self, node) -> str:
"""Extract text content from a node.""" """Extract text content from a node."""
if isinstance(node, Text): if isinstance(node, Text):

View file

@ -4,7 +4,7 @@ import logging
from typing import Optional, Callable from typing import Optional, Callable
import skia 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") logger = logging.getLogger("bowser.layout.embed")
@ -16,10 +16,10 @@ OnImageLoadedCallback = Callable[["ImageLayout"], None]
class ImageLayout: class ImageLayout:
"""Layout for an <img> element.""" """Layout for an <img> element."""
# Global callback for image load completion (set by render pipeline) # Global callback for image load completion (set by render pipeline)
_on_any_image_loaded: Optional[Callable[[], None]] = None _on_any_image_loaded: Optional[Callable[[], None]] = None
def __init__(self, node, parent=None, previous=None, frame=None): def __init__(self, node, parent=None, previous=None, frame=None):
self.node = node self.node = node
self.parent = parent self.parent = parent
@ -36,38 +36,38 @@ class ImageLayout:
self._load_task_id: Optional[int] = None self._load_task_id: Optional[int] = None
self._src = "" self._src = ""
self._base_url: Optional[str] = None self._base_url: Optional[str] = None
def load(self, base_url: Optional[str] = None, async_load: bool = False): def load(self, base_url: Optional[str] = None, async_load: bool = False):
""" """
Load the image from the src attribute. Load the image from the src attribute.
Args: Args:
base_url: Base URL for resolving relative paths base_url: Base URL for resolving relative paths
async_load: If True, load in background thread (non-blocking) async_load: If True, load in background thread (non-blocking)
""" """
if not hasattr(self.node, 'attributes'): if not hasattr(self.node, 'attributes'):
return return
src = self.node.attributes.get('src', '') src = self.node.attributes.get('src', '')
if not src: if not src:
logger.warning("Image element has no src attribute") logger.warning("Image element has no src attribute")
return return
# Get alt text # Get alt text
self.alt_text = self.node.attributes.get('alt', '') self.alt_text = self.node.attributes.get('alt', '')
self._src = src self._src = src
self._base_url = base_url self._base_url = base_url
if async_load: if async_load:
self._load_async(src, base_url) self._load_async(src, base_url)
else: else:
# Synchronous load (for tests or cached images) # Synchronous load (for tests or cached images)
self.image = load_image(src, base_url) self.image = load_image(src, base_url)
def _load_async(self, src: str, base_url: Optional[str]): def _load_async(self, src: str, base_url: Optional[str]):
"""Load image asynchronously.""" """Load image asynchronously."""
self._loading = True self._loading = True
def on_complete(image: Optional[skia.Image]): def on_complete(image: Optional[skia.Image]):
self._loading = False self._loading = False
self.image = image self.image = image
@ -78,25 +78,25 @@ class ImageLayout:
# Trigger re-render # Trigger re-render
if ImageLayout._on_any_image_loaded: if ImageLayout._on_any_image_loaded:
ImageLayout._on_any_image_loaded() ImageLayout._on_any_image_loaded()
def on_error(e: Exception): def on_error(e: Exception):
self._loading = False self._loading = False
logger.error(f"Async image load failed: {src}: {e}") logger.error(f"Async image load failed: {src}: {e}")
self._load_task_id = load_image_async(src, base_url, on_complete, on_error) self._load_task_id = load_image_async(src, base_url, on_complete, on_error)
def _update_dimensions(self): def _update_dimensions(self):
"""Update dimensions based on loaded image.""" """Update dimensions based on loaded image."""
if not self.image: if not self.image:
return return
# Get explicit width/height attributes # Get explicit width/height attributes
width_attr = self.node.attributes.get('width', '') if hasattr(self.node, 'attributes') else '' 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 '' height_attr = self.node.attributes.get('height', '') if hasattr(self.node, 'attributes') else ''
intrinsic_width = self.image.width() intrinsic_width = self.image.width()
intrinsic_height = self.image.height() intrinsic_height = self.image.height()
# Calculate dimensions based on attributes or intrinsic size # Calculate dimensions based on attributes or intrinsic size
if width_attr and height_attr: if width_attr and height_attr:
# Both specified - use them # Both specified - use them
@ -134,12 +134,12 @@ class ImageLayout:
# No explicit dimensions - use intrinsic size # No explicit dimensions - use intrinsic size
self.width = intrinsic_width self.width = intrinsic_width
self.height = intrinsic_height self.height = intrinsic_height
@property @property
def is_loading(self) -> bool: def is_loading(self) -> bool:
"""True if image is currently being loaded.""" """True if image is currently being loaded."""
return self._loading return self._loading
def cancel_load(self): def cancel_load(self):
"""Cancel any pending async load.""" """Cancel any pending async load."""
if self._load_task_id is not None: if self._load_task_id is not None:
@ -147,11 +147,11 @@ class ImageLayout:
cancel_task(self._load_task_id) cancel_task(self._load_task_id)
self._load_task_id = None self._load_task_id = None
self._loading = False self._loading = False
def layout(self, max_width: Optional[float] = None): def layout(self, max_width: Optional[float] = None):
""" """
Calculate the layout dimensions for this image. Calculate the layout dimensions for this image.
Returns: Returns:
Width of the image (for inline layout) Width of the image (for inline layout)
""" """
@ -161,15 +161,15 @@ class ImageLayout:
self.width = 100 self.width = 100
self.height = 100 self.height = 100
return self.width return self.width
# Get explicit width/height attributes # Get explicit width/height attributes
width_attr = self.node.attributes.get('width', '') if hasattr(self.node, 'attributes') else '' 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 '' height_attr = self.node.attributes.get('height', '') if hasattr(self.node, 'attributes') else ''
# Get intrinsic dimensions # Get intrinsic dimensions
intrinsic_width = self.image.width() intrinsic_width = self.image.width()
intrinsic_height = self.image.height() intrinsic_height = self.image.height()
# Calculate display dimensions # Calculate display dimensions
if width_attr and height_attr: if width_attr and height_attr:
# Both specified # Both specified
@ -207,13 +207,13 @@ class ImageLayout:
# No dimensions specified - use intrinsic size # No dimensions specified - use intrinsic size
self.width = intrinsic_width self.width = intrinsic_width
self.height = intrinsic_height self.height = intrinsic_height
# Constrain to max_width if specified # Constrain to max_width if specified
if max_width and self.width > max_width: if max_width and self.width > max_width:
aspect_ratio = intrinsic_height / intrinsic_width if intrinsic_width > 0 else 1 aspect_ratio = intrinsic_height / intrinsic_width if intrinsic_width > 0 else 1
self.width = max_width self.width = max_width
self.height = self.width * aspect_ratio self.height = self.width * aspect_ratio
return self.width return self.width

View file

@ -18,10 +18,10 @@ ASSETS_DIR = Path(__file__).parent.parent.parent / "assets"
class ImageCache: class ImageCache:
"""Thread-safe global cache for loaded images.""" """Thread-safe global cache for loaded images."""
_instance = None _instance = None
_lock = threading.Lock() _lock = threading.Lock()
def __new__(cls): def __new__(cls):
with cls._lock: with cls._lock:
if cls._instance is None: if cls._instance is None:
@ -29,22 +29,22 @@ class ImageCache:
cls._instance._cache = {} cls._instance._cache = {}
cls._instance._cache_lock = threading.Lock() cls._instance._cache_lock = threading.Lock()
return cls._instance return cls._instance
def get(self, url: str) -> Optional[skia.Image]: def get(self, url: str) -> Optional[skia.Image]:
"""Get a cached image by URL.""" """Get a cached image by URL."""
with self._cache_lock: with self._cache_lock:
return self._cache.get(url) return self._cache.get(url)
def set(self, url: str, image: skia.Image): def set(self, url: str, image: skia.Image):
"""Cache an image by URL.""" """Cache an image by URL."""
with self._cache_lock: with self._cache_lock:
self._cache[url] = image self._cache[url] = image
def has(self, url: str) -> bool: def has(self, url: str) -> bool:
"""Check if URL is cached.""" """Check if URL is cached."""
with self._cache_lock: with self._cache_lock:
return url in self._cache return url in self._cache
def clear(self): def clear(self):
"""Clear all cached images.""" """Clear all cached images."""
with self._cache_lock: 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]: def load_image(url: str, base_url: Optional[str] = None) -> Optional[skia.Image]:
""" """
Load an image from a URL or file path (synchronous). Load an image from a URL or file path (synchronous).
Args: Args:
url: Image URL or file path url: Image URL or file path
base_url: Base URL for resolving relative URLs base_url: Base URL for resolving relative URLs
Returns: Returns:
Skia Image object, or None if loading failed Skia Image object, or None if loading failed
""" """
try: try:
# Resolve the full URL first # Resolve the full URL first
full_url = _resolve_url(url, base_url) full_url = _resolve_url(url, base_url)
# Check cache with resolved URL # Check cache with resolved URL
cache = ImageCache() cache = ImageCache()
cached = cache.get(full_url) cached = cache.get(full_url)
if cached is not None: if cached is not None:
logger.debug(f"Image cache hit: {full_url}") logger.debug(f"Image cache hit: {full_url}")
return cached return cached
logger.info(f"Loading image: {full_url}") logger.info(f"Loading image: {full_url}")
# Load raw bytes # Load raw bytes
data = _load_image_bytes(full_url) data = _load_image_bytes(full_url)
if data is None: if data is None:
return None return None
# Decode with Skia # Decode with Skia
image = skia.Image.MakeFromEncoded(data) image = skia.Image.MakeFromEncoded(data)
if image: if image:
cache.set(full_url, image) cache.set(full_url, image)
logger.debug(f"Loaded image: {full_url} ({image.width()}x{image.height()})") logger.debug(f"Loaded image: {full_url} ({image.width()}x{image.height()})")
return image return image
except Exception as e: except Exception as e:
logger.error(f"Failed to load image {url}: {e}") logger.error(f"Failed to load image {url}: {e}")
return None return None
@ -105,19 +105,19 @@ def _load_image_bytes(full_url: str) -> Optional[bytes]:
# Handle data URLs # Handle data URLs
if full_url.startswith('data:'): if full_url.startswith('data:'):
return _load_data_url_bytes(full_url) return _load_data_url_bytes(full_url)
# Handle file URLs # Handle file URLs
if full_url.startswith('file://'): if full_url.startswith('file://'):
file_path = full_url[7:] # Remove 'file://' file_path = full_url[7:] # Remove 'file://'
return _load_file_bytes(file_path) return _load_file_bytes(file_path)
# Handle HTTP/HTTPS URLs # Handle HTTP/HTTPS URLs
if full_url.startswith(('http://', 'https://')): if full_url.startswith(('http://', 'https://')):
return _load_http_bytes(full_url) return _load_http_bytes(full_url)
# Try as local file path # Try as local file path
return _load_file_bytes(full_url) return _load_file_bytes(full_url)
except Exception as e: except Exception as e:
logger.error(f"Failed to load image bytes from {full_url}: {e}") logger.error(f"Failed to load image bytes from {full_url}: {e}")
return None return None
@ -131,16 +131,16 @@ def load_image_async(
) -> int: ) -> int:
""" """
Load an image asynchronously in a background thread. Load an image asynchronously in a background thread.
Bytes are loaded in background, but Skia decoding happens on main thread Bytes are loaded in background, but Skia decoding happens on main thread
to avoid threading issues with Skia objects. to avoid threading issues with Skia objects.
Args: Args:
url: Image URL or file path url: Image URL or file path
base_url: Base URL for resolving relative URLs base_url: Base URL for resolving relative URLs
on_complete: Callback with loaded image (or None if failed), called on main thread 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 on_error: Callback with exception if loading failed, called on main thread
Returns: Returns:
Task ID that can be used to cancel the load Task ID that can be used to cancel the load
""" """
@ -148,10 +148,10 @@ def load_image_async(
import gi import gi
gi.require_version("GLib", "2.0") gi.require_version("GLib", "2.0")
from gi.repository import GLib from gi.repository import GLib
# Resolve URL synchronously (fast operation) # Resolve URL synchronously (fast operation)
full_url = _resolve_url(url, base_url) full_url = _resolve_url(url, base_url)
# Check cache first (avoid thread overhead) # Check cache first (avoid thread overhead)
cache = ImageCache() cache = ImageCache()
cached = cache.get(full_url) cached = cache.get(full_url)
@ -161,18 +161,18 @@ def load_image_async(
# Use GLib to call on main thread # Use GLib to call on main thread
GLib.idle_add(lambda: on_complete(cached) or False) GLib.idle_add(lambda: on_complete(cached) or False)
return -1 # No task needed return -1 # No task needed
def do_load_bytes(): def do_load_bytes():
"""Load raw bytes in background thread.""" """Load raw bytes in background thread."""
return _load_image_bytes(full_url) return _load_image_bytes(full_url)
def on_bytes_loaded(data: Optional[bytes]): def on_bytes_loaded(data: Optional[bytes]):
"""Decode image on main thread and call user callback.""" """Decode image on main thread and call user callback."""
if data is None: if data is None:
if on_complete: if on_complete:
on_complete(None) on_complete(None)
return return
try: try:
# Decode image on main thread (Skia thread safety) # Decode image on main thread (Skia thread safety)
decoded = skia.Image.MakeFromEncoded(data) decoded = skia.Image.MakeFromEncoded(data)
@ -183,7 +183,7 @@ def load_image_async(
canvas = surface.getCanvas() canvas = surface.getCanvas()
canvas.drawImage(decoded, 0, 0) canvas.drawImage(decoded, 0, 0)
image = surface.makeImageSnapshot() image = surface.makeImageSnapshot()
cache.set(full_url, image) cache.set(full_url, image)
logger.debug(f"Async loaded image: {full_url} ({image.width()}x{image.height()})") logger.debug(f"Async loaded image: {full_url} ({image.width()}x{image.height()})")
if on_complete: if on_complete:
@ -197,7 +197,7 @@ def load_image_async(
on_complete(None) on_complete(None)
if on_complete: if on_complete:
on_complete(None) on_complete(None)
# Always use on_bytes_loaded to ensure caching happens # Always use on_bytes_loaded to ensure caching happens
return submit_task(do_load_bytes, on_bytes_loaded, on_error) 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 # Already absolute
if url.startswith(('http://', 'https://', 'data:', 'file://')): if url.startswith(('http://', 'https://', 'data:', 'file://')):
return url return url
# Handle about: pages - resolve relative to assets directory # Handle about: pages - resolve relative to assets directory
if base_url and base_url.startswith('about:'): if base_url and base_url.startswith('about:'):
# For about: pages, resolve relative to the assets/pages directory # 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 str(asset_path)
# Return resolved path even if not found (will fail later with proper error) # Return resolved path even if not found (will fail later with proper error)
return str(resolved_path) return str(resolved_path)
# No base URL - treat as local file # No base URL - treat as local file
if not base_url: if not base_url:
return url return url
# Use URL class for proper resolution # Use URL class for proper resolution
try: try:
base = URL(base_url) base = URL(base_url)
@ -261,14 +261,14 @@ def _load_http_bytes(url: str) -> Optional[bytes]:
try: try:
url_obj = URL(url) url_obj = URL(url)
status, content_type, body = request(url_obj) status, content_type, body = request(url_obj)
if status != 200: if status != 200:
logger.warning(f"HTTP {status} when loading image: {url}") logger.warning(f"HTTP {status} when loading image: {url}")
return None return None
logger.debug(f"Loaded {len(body)} bytes from HTTP: {url}") logger.debug(f"Loaded {len(body)} bytes from HTTP: {url}")
return body return body
except Exception as e: except Exception as e:
logger.error(f"Failed to load from HTTP {url}: {e}") logger.error(f"Failed to load from HTTP {url}: {e}")
return None return None
@ -280,16 +280,16 @@ def _load_data_url_bytes(data_url: str) -> Optional[bytes]:
# Parse data URL: data:[<mediatype>][;base64],<data> # Parse data URL: data:[<mediatype>][;base64],<data>
if not data_url.startswith('data:'): if not data_url.startswith('data:'):
return None return None
# Split off the 'data:' prefix # Split off the 'data:' prefix
_, rest = data_url.split(':', 1) _, rest = data_url.split(':', 1)
# Split metadata from data # Split metadata from data
if ',' not in rest: if ',' not in rest:
return None return None
metadata, data = rest.split(',', 1) metadata, data = rest.split(',', 1)
# Check if base64 encoded # Check if base64 encoded
if ';base64' in metadata: if ';base64' in metadata:
import base64 import base64
@ -298,10 +298,10 @@ def _load_data_url_bytes(data_url: str) -> Optional[bytes]:
# URL-encoded data # URL-encoded data
import urllib.parse import urllib.parse
decoded = urllib.parse.unquote(data).encode('utf-8') decoded = urllib.parse.unquote(data).encode('utf-8')
logger.debug(f"Extracted {len(decoded)} bytes from data URL") logger.debug(f"Extracted {len(decoded)} bytes from data URL")
return decoded return decoded
except Exception as e: except Exception as e:
logger.error(f"Failed to parse data URL: {e}") logger.error(f"Failed to parse data URL: {e}")
return None return None
@ -312,22 +312,22 @@ def _load_from_http(url: str) -> Optional[skia.Image]:
try: try:
url_obj = URL(url) url_obj = URL(url)
status, content_type, body = request(url_obj) status, content_type, body = request(url_obj)
if status != 200: if status != 200:
logger.warning(f"HTTP {status} when loading image: {url}") logger.warning(f"HTTP {status} when loading image: {url}")
return None return None
# Decode image from bytes # Decode image from bytes
image = skia.Image.MakeFromEncoded(body) image = skia.Image.MakeFromEncoded(body)
if image: if image:
# Cache it # Cache it
cache = ImageCache() cache = ImageCache()
cache.set(url, image) cache.set(url, image)
logger.debug(f"Loaded image from HTTP: {url} ({image.width()}x{image.height()})") logger.debug(f"Loaded image from HTTP: {url} ({image.width()}x{image.height()})")
return image return image
except Exception as e: except Exception as e:
logger.error(f"Failed to load image from HTTP {url}: {e}") logger.error(f"Failed to load image from HTTP {url}: {e}")
return None return None
@ -338,17 +338,17 @@ def _load_from_file(file_path: str) -> Optional[skia.Image]:
try: try:
with open(file_path, 'rb') as f: with open(file_path, 'rb') as f:
data = f.read() data = f.read()
image = skia.Image.MakeFromEncoded(data) image = skia.Image.MakeFromEncoded(data)
if image: if image:
# Cache it # Cache it
cache = ImageCache() cache = ImageCache()
cache.set(file_path, image) cache.set(file_path, image)
logger.debug(f"Loaded image from file: {file_path} ({image.width()}x{image.height()})") logger.debug(f"Loaded image from file: {file_path} ({image.width()}x{image.height()})")
return image return image
except Exception as e: except Exception as e:
logger.error(f"Failed to load image from file {file_path}: {e}") logger.error(f"Failed to load image from file {file_path}: {e}")
return None return None
@ -360,16 +360,16 @@ def _load_data_url(data_url: str) -> Optional[skia.Image]:
# Parse data URL: data:[<mediatype>][;base64],<data> # Parse data URL: data:[<mediatype>][;base64],<data>
if not data_url.startswith('data:'): if not data_url.startswith('data:'):
return None return None
# Split off the 'data:' prefix # Split off the 'data:' prefix
_, rest = data_url.split(':', 1) _, rest = data_url.split(':', 1)
# Split metadata from data # Split metadata from data
if ',' not in rest: if ',' not in rest:
return None return None
metadata, data = rest.split(',', 1) metadata, data = rest.split(',', 1)
# Check if base64 encoded # Check if base64 encoded
if ';base64' in metadata: if ';base64' in metadata:
import base64 import base64
@ -378,15 +378,15 @@ def _load_data_url(data_url: str) -> Optional[skia.Image]:
# URL-encoded data # URL-encoded data
import urllib.parse import urllib.parse
decoded = urllib.parse.unquote(data).encode('utf-8') decoded = urllib.parse.unquote(data).encode('utf-8')
image = skia.Image.MakeFromEncoded(decoded) image = skia.Image.MakeFromEncoded(decoded)
if image: if image:
# Don't cache data URLs (they're already embedded) # Don't cache data URLs (they're already embedded)
logger.debug(f"Loaded image from data URL ({image.width()}x{image.height()})") logger.debug(f"Loaded image from data URL ({image.width()}x{image.height()})")
return image return image
except Exception as e: except Exception as e:
logger.error(f"Failed to load image from data URL: {e}") logger.error(f"Failed to load image from data URL: {e}")
return None return None

View file

@ -3,13 +3,12 @@
import logging import logging
import threading import threading
from concurrent.futures import ThreadPoolExecutor, Future from concurrent.futures import ThreadPoolExecutor, Future
from dataclasses import dataclass, field from dataclasses import dataclass
from typing import Callable, Optional, Any from typing import Callable, Optional, Any
from queue import Queue
import gi import gi
gi.require_version("GLib", "2.0") gi.require_version("GLib", "2.0")
from gi.repository import GLib from gi.repository import GLib # noqa: E402
logger = logging.getLogger("bowser.tasks") logger = logging.getLogger("bowser.tasks")
@ -17,12 +16,12 @@ logger = logging.getLogger("bowser.tasks")
@dataclass @dataclass
class Task: class Task:
"""A task to be executed in the background.""" """A task to be executed in the background."""
func: Callable[[], Any] func: Callable[[], Any]
on_complete: Optional[Callable[[Any], None]] = None on_complete: Optional[Callable[[Any], None]] = None
on_error: Optional[Callable[[Exception], None]] = None on_error: Optional[Callable[[Exception], None]] = None
priority: int = 0 # Lower = higher priority priority: int = 0 # Lower = higher priority
def __lt__(self, other): def __lt__(self, other):
return self.priority < other.priority return self.priority < other.priority
@ -30,24 +29,24 @@ class Task:
class TaskQueue: class TaskQueue:
""" """
Background task queue using a thread pool. Background task queue using a thread pool.
Uses GTK's GLib.idle_add for thread-safe UI updates. Uses GTK's GLib.idle_add for thread-safe UI updates.
""" """
_instance: Optional["TaskQueue"] = None _instance: Optional["TaskQueue"] = None
_lock = threading.Lock() _lock = threading.Lock()
def __new__(cls) -> "TaskQueue": def __new__(cls) -> "TaskQueue":
with cls._lock: with cls._lock:
if cls._instance is None: if cls._instance is None:
cls._instance = super().__new__(cls) cls._instance = super().__new__(cls)
cls._instance._initialized = False cls._instance._initialized = False
return cls._instance return cls._instance
def __init__(self, max_workers: int = 4): def __init__(self, max_workers: int = 4):
if self._initialized: if self._initialized:
return return
self._executor = ThreadPoolExecutor( self._executor = ThreadPoolExecutor(
max_workers=max_workers, max_workers=max_workers,
thread_name_prefix="bowser-task" thread_name_prefix="bowser-task"
@ -57,9 +56,9 @@ class TaskQueue:
self._task_lock = threading.Lock() self._task_lock = threading.Lock()
self._initialized = True self._initialized = True
self._shutdown = False self._shutdown = False
logger.debug(f"TaskQueue initialized with {max_workers} workers") logger.debug(f"TaskQueue initialized with {max_workers} workers")
def submit( def submit(
self, self,
func: Callable[[], Any], func: Callable[[], Any],
@ -68,23 +67,23 @@ class TaskQueue:
) -> int: ) -> int:
""" """
Submit a task for background execution. Submit a task for background execution.
Args: Args:
func: Function to run in background (no arguments) func: Function to run in background (no arguments)
on_complete: Callback with result (runs on main thread) on_complete: Callback with result (runs on main thread)
on_error: Callback with exception (runs on main thread) on_error: Callback with exception (runs on main thread)
Returns: Returns:
Task ID that can be used to cancel Task ID that can be used to cancel
""" """
if self._shutdown: if self._shutdown:
logger.warning("TaskQueue is shutdown, ignoring task") logger.warning("TaskQueue is shutdown, ignoring task")
return -1 return -1
with self._task_lock: with self._task_lock:
task_id = self._task_id task_id = self._task_id
self._task_id += 1 self._task_id += 1
def wrapped(): def wrapped():
try: try:
result = func() result = func()
@ -100,15 +99,15 @@ class TaskQueue:
finally: finally:
with self._task_lock: with self._task_lock:
self._pending.pop(task_id, None) self._pending.pop(task_id, None)
future = self._executor.submit(wrapped) future = self._executor.submit(wrapped)
with self._task_lock: with self._task_lock:
self._pending[task_id] = future self._pending[task_id] = future
logger.debug(f"Submitted task {task_id}") logger.debug(f"Submitted task {task_id}")
return task_id return task_id
def _call_on_main(self, callback: Callable, arg: Any) -> bool: def _call_on_main(self, callback: Callable, arg: Any) -> bool:
"""Execute a callback on the main thread. Returns False to remove from idle.""" """Execute a callback on the main thread. Returns False to remove from idle."""
try: try:
@ -116,7 +115,7 @@ class TaskQueue:
except Exception as e: except Exception as e:
logger.error(f"Callback error: {e}") logger.error(f"Callback error: {e}")
return False # Don't repeat return False # Don't repeat
def cancel(self, task_id: int) -> bool: def cancel(self, task_id: int) -> bool:
"""Cancel a pending task. Returns True if cancelled.""" """Cancel a pending task. Returns True if cancelled."""
with self._task_lock: with self._task_lock:
@ -128,7 +127,7 @@ class TaskQueue:
logger.debug(f"Cancelled task {task_id}") logger.debug(f"Cancelled task {task_id}")
return cancelled return cancelled
return False return False
def cancel_all(self): def cancel_all(self):
"""Cancel all pending tasks.""" """Cancel all pending tasks."""
with self._task_lock: with self._task_lock:
@ -136,20 +135,20 @@ class TaskQueue:
future.cancel() future.cancel()
self._pending.clear() self._pending.clear()
logger.debug("Cancelled all tasks") logger.debug("Cancelled all tasks")
@property @property
def pending_count(self) -> int: def pending_count(self) -> int:
"""Number of pending tasks.""" """Number of pending tasks."""
with self._task_lock: with self._task_lock:
return len(self._pending) return len(self._pending)
def shutdown(self, wait: bool = True): def shutdown(self, wait: bool = True):
"""Shutdown the task queue.""" """Shutdown the task queue."""
self._shutdown = True self._shutdown = True
self.cancel_all() self.cancel_all()
self._executor.shutdown(wait=wait) self._executor.shutdown(wait=wait)
logger.debug("TaskQueue shutdown") logger.debug("TaskQueue shutdown")
@classmethod @classmethod
def reset_instance(cls): def reset_instance(cls):
"""Reset the singleton (for testing).""" """Reset the singleton (for testing)."""

View file

@ -180,7 +180,7 @@ class CSSParser:
# Split multi-selectors by comma # Split multi-selectors by comma
selector_parts = [s.strip() for s in selector_text.split(',') if s.strip()] selector_parts = [s.strip() for s in selector_text.split(',') if s.strip()]
if len(selector_parts) == 1: if len(selector_parts) == 1:
# Single selector # Single selector
return CSSRule(Selector(selector_text), declarations) return CSSRule(Selector(selector_text), declarations)

View file

@ -129,6 +129,11 @@ class _DOMBuilder(HTMLParser):
if self.current is self.root: if self.current is self.root:
self._ensure_body() 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) self._push(el)
def handle_endtag(self, tag): 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 .css import parse as parse_css
from .style import StyleResolver from .style import StyleResolver
import os
from pathlib import Path from pathlib import Path
# Parse HTML # Parse HTML

View file

@ -23,7 +23,7 @@ class FontCache:
"""Cache for Skia fonts and typefaces.""" """Cache for Skia fonts and typefaces."""
_instance = None _instance = None
# Common emoji/symbol fonts to try as last resort before showing tofu # Common emoji/symbol fonts to try as last resort before showing tofu
_EMOJI_FALLBACK_FONTS = ( _EMOJI_FALLBACK_FONTS = (
'Noto Color Emoji', 'Noto Color Emoji',
@ -51,7 +51,7 @@ class FontCache:
# This dramatically reduces cache entries and font lookups # This dramatically reduces cache entries and font lookups
is_emoji = text and self._is_emoji_char(text[0]) is_emoji = text and self._is_emoji_char(text[0])
cache_key = (families, is_emoji) cache_key = (families, is_emoji)
if cache_key in self._typeface_cache: if cache_key in self._typeface_cache:
return self._typeface_cache[cache_key] return self._typeface_cache[cache_key]
@ -60,7 +60,7 @@ class FontCache:
# Skip generic families that won't resolve to specific fonts # Skip generic families that won't resolve to specific fonts
if family.lower() in ('serif', 'sans-serif', 'monospace', 'cursive', 'fantasy'): if family.lower() in ('serif', 'sans-serif', 'monospace', 'cursive', 'fantasy'):
continue continue
typeface = skia.Typeface.MakeFromName(family, skia.FontStyle.Normal()) typeface = skia.Typeface.MakeFromName(family, skia.FontStyle.Normal())
if typeface and typeface.getFamilyName() == family: if typeface and typeface.getFamilyName() == family:
# Font was actually found - check if it has glyphs for sample text # Font was actually found - check if it has glyphs for sample text

View file

@ -61,7 +61,7 @@ class DrawRect(PaintCommand):
class DrawImage(PaintCommand): class DrawImage(PaintCommand):
"""Command to draw an image.""" """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 = ""): image: skia.Image, alt_text: str = ""):
super().__init__((x, y, x + width, y + height)) super().__init__((x, y, x + width, y + height))
self.x = x self.x = x
@ -82,11 +82,11 @@ class DrawImage(PaintCommand):
if paint is None: if paint is None:
paint = skia.Paint() paint = skia.Paint()
paint.setAntiAlias(True) paint.setAntiAlias(True)
# Calculate scale factor # Calculate scale factor
scale_x = self.width / self.image.width() scale_x = self.width / self.image.width()
scale_y = self.height / self.image.height() scale_y = self.height / self.image.height()
# Use canvas transform for scaling # Use canvas transform for scaling
canvas.save() canvas.save()
canvas.translate(self.x, self.y) canvas.translate(self.x, self.y)
@ -99,7 +99,7 @@ class DrawImage(PaintCommand):
logger.error(f"Failed to draw image: {e}") logger.error(f"Failed to draw image: {e}")
# If drawing fails, fall back to placeholder # If drawing fails, fall back to placeholder
self._draw_placeholder(canvas, paint) self._draw_placeholder(canvas, paint)
def _draw_placeholder(self, canvas: skia.Canvas, paint: skia.Paint = None): def _draw_placeholder(self, canvas: skia.Canvas, paint: skia.Paint = None):
"""Draw a placeholder for a missing or failed image.""" """Draw a placeholder for a missing or failed image."""
if paint is None: if paint is None:
@ -108,14 +108,14 @@ class DrawImage(PaintCommand):
paint.setStyle(skia.Paint.kFill_Style) paint.setStyle(skia.Paint.kFill_Style)
rect = skia.Rect.MakeLTRB(self.x, self.y, self.x + self.width, self.y + self.height) rect = skia.Rect.MakeLTRB(self.x, self.y, self.x + self.width, self.y + self.height)
canvas.drawRect(rect, paint) canvas.drawRect(rect, paint)
# Draw border # Draw border
border_paint = skia.Paint() border_paint = skia.Paint()
border_paint.setColor(skia.ColorGRAY) border_paint.setColor(skia.ColorGRAY)
border_paint.setStyle(skia.Paint.kStroke_Style) border_paint.setStyle(skia.Paint.kStroke_Style)
border_paint.setStrokeWidth(1) border_paint.setStrokeWidth(1)
canvas.drawRect(rect, border_paint) canvas.drawRect(rect, border_paint)
# Draw alt text if available # Draw alt text if available
if self.alt_text: if self.alt_text:
text_paint = skia.Paint() text_paint = skia.Paint()

View file

@ -22,26 +22,26 @@ class RenderPipeline:
# Paint cache # Paint cache
self._text_paint: Optional[skia.Paint] = None self._text_paint: Optional[skia.Paint] = None
self._display_list: Optional[DisplayList] = None self._display_list: Optional[DisplayList] = None
# Base URL for resolving relative paths # Base URL for resolving relative paths
self.base_url: Optional[str] = None self.base_url: Optional[str] = None
# Debug mode # Debug mode
self.debug_mode = False self.debug_mode = False
# Async image loading # Async image loading
self.async_images = True # Enable async image loading by default self.async_images = True # Enable async image loading by default
self._on_needs_redraw: Optional[Callable[[], None]] = None self._on_needs_redraw: Optional[Callable[[], None]] = None
def set_redraw_callback(self, callback: Callable[[], None]): def set_redraw_callback(self, callback: Callable[[], None]):
"""Set a callback to be called when async images finish loading.""" """Set a callback to be called when async images finish loading."""
self._on_needs_redraw = callback self._on_needs_redraw = callback
# Also set on ImageLayout class for global notification # Also set on ImageLayout class for global notification
def on_image_loaded(): def on_image_loaded():
if self._on_needs_redraw: if self._on_needs_redraw:
self._on_needs_redraw() self._on_needs_redraw()
ImageLayout._on_any_image_loaded = on_image_loaded ImageLayout._on_any_image_loaded = on_image_loaded
def layout(self, document: Element, width: int) -> DocumentLayout: def layout(self, document: Element, width: int) -> DocumentLayout:
@ -60,7 +60,7 @@ class RenderPipeline:
# Build new layout with base_url for resolving image paths # Build new layout with base_url for resolving image paths
self._layout = DocumentLayout( self._layout = DocumentLayout(
document, document,
base_url=self.base_url, base_url=self.base_url,
async_images=self.async_images async_images=self.async_images
) )
@ -110,7 +110,25 @@ class RenderPipeline:
continue continue
font = get_font(line.font_size, getattr(line, "font_family", ""), text=line.text) 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) # Render visible images (both loaded and placeholder)
for layout_image in layout.images: for layout_image in layout.images:
@ -122,12 +140,12 @@ class RenderPipeline:
# Use image_layout dimensions directly for accurate sizing after async load # 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_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 img_height = image_layout.height if image_layout.height > 0 else layout_image.height
# Always create DrawImage command - it handles None images as placeholders # Always create DrawImage command - it handles None images as placeholders
draw_cmd = DrawImage( draw_cmd = DrawImage(
layout_image.x, layout_image.x,
layout_image.y, layout_image.y,
img_width, img_width,
img_height, img_height,
image_layout.image, # May be None, DrawImage handles this image_layout.image, # May be None, DrawImage handles this
image_layout.alt_text image_layout.alt_text
@ -188,8 +206,8 @@ class RenderPipeline:
def get_text_layout(self) -> list: def get_text_layout(self) -> list:
""" """
Get the text layout for text selection. Get the text layout for text selection and link hit testing.
Returns list of line info dicts with char_positions. Returns list of line info dicts with char_positions and href.
""" """
if self._layout is None: if self._layout is None:
return [] return []
@ -203,7 +221,8 @@ class RenderPipeline:
"width": line.width, "width": line.width,
"height": line.height, "height": line.height,
"font_size": line.font_size, "font_size": line.font_size,
"char_positions": line.char_positions "char_positions": line.char_positions,
"href": getattr(line, "href", None)
}) })
return result return result
@ -218,3 +237,63 @@ class RenderPipeline:
self._layout = None self._layout = None
self._layout_doc_id = None self._layout_doc_id = None
self._display_list = 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

View file

@ -1,94 +1,93 @@
"""Tests for CSS parsing and style computation.""" """Tests for CSS parsing and style computation."""
import pytest
from src.parser.css import ( 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.html import Element, Text
from src.parser.style import ( from src.parser.style import (
ComputedStyle, StyleResolver, DEFAULT_STYLES, INHERITED_PROPERTIES ComputedStyle, StyleResolver
) )
class TestSelector: class TestSelector:
"""Test CSS selector parsing and matching.""" """Test CSS selector parsing and matching."""
def test_tag_selector(self): def test_tag_selector(self):
sel = Selector("p") sel = Selector("p")
assert sel.tag == "p" assert sel.tag == "p"
assert sel.id is None assert sel.id is None
assert sel.classes == [] assert sel.classes == []
def test_class_selector(self): def test_class_selector(self):
sel = Selector(".container") sel = Selector(".container")
assert sel.tag is None assert sel.tag is None
assert sel.classes == ["container"] assert sel.classes == ["container"]
def test_id_selector(self): def test_id_selector(self):
sel = Selector("#header") sel = Selector("#header")
assert sel.id == "header" assert sel.id == "header"
assert sel.tag is None assert sel.tag is None
def test_compound_selector(self): def test_compound_selector(self):
sel = Selector("div.container") sel = Selector("div.container")
assert sel.tag == "div" assert sel.tag == "div"
assert sel.classes == ["container"] assert sel.classes == ["container"]
def test_complex_compound_selector(self): def test_complex_compound_selector(self):
sel = Selector("div#main.container.active") sel = Selector("div#main.container.active")
assert sel.tag == "div" assert sel.tag == "div"
assert sel.id == "main" assert sel.id == "main"
assert set(sel.classes) == {"container", "active"} assert set(sel.classes) == {"container", "active"}
def test_specificity_tag_only(self): def test_specificity_tag_only(self):
sel = Selector("p") sel = Selector("p")
assert sel.specificity() == (0, 0, 1) assert sel.specificity() == (0, 0, 1)
def test_specificity_class_only(self): def test_specificity_class_only(self):
sel = Selector(".container") sel = Selector(".container")
assert sel.specificity() == (0, 1, 0) assert sel.specificity() == (0, 1, 0)
def test_specificity_id_only(self): def test_specificity_id_only(self):
sel = Selector("#header") sel = Selector("#header")
assert sel.specificity() == (1, 0, 0) assert sel.specificity() == (1, 0, 0)
def test_specificity_compound(self): def test_specificity_compound(self):
sel = Selector("div#main.container.active") sel = Selector("div#main.container.active")
assert sel.specificity() == (1, 2, 1) assert sel.specificity() == (1, 2, 1)
def test_matches_tag(self): def test_matches_tag(self):
sel = Selector("p") sel = Selector("p")
elem = Element("p") elem = Element("p")
assert sel.matches(elem) is True assert sel.matches(elem) is True
elem2 = Element("div") elem2 = Element("div")
assert sel.matches(elem2) is False assert sel.matches(elem2) is False
def test_matches_class(self): def test_matches_class(self):
sel = Selector(".container") sel = Selector(".container")
elem = Element("div", {"class": "container sidebar"}) elem = Element("div", {"class": "container sidebar"})
assert sel.matches(elem) is True assert sel.matches(elem) is True
elem2 = Element("div", {"class": "sidebar"}) elem2 = Element("div", {"class": "sidebar"})
assert sel.matches(elem2) is False assert sel.matches(elem2) is False
def test_matches_id(self): def test_matches_id(self):
sel = Selector("#header") sel = Selector("#header")
elem = Element("div", {"id": "header"}) elem = Element("div", {"id": "header"})
assert sel.matches(elem) is True assert sel.matches(elem) is True
elem2 = Element("div", {"id": "footer"}) elem2 = Element("div", {"id": "footer"})
assert sel.matches(elem2) is False assert sel.matches(elem2) is False
def test_matches_compound(self): def test_matches_compound(self):
sel = Selector("div.container") sel = Selector("div.container")
elem = Element("div", {"class": "container"}) elem = Element("div", {"class": "container"})
assert sel.matches(elem) is True assert sel.matches(elem) is True
# Wrong tag # Wrong tag
elem2 = Element("p", {"class": "container"}) elem2 = Element("p", {"class": "container"})
assert sel.matches(elem2) is False assert sel.matches(elem2) is False
# Wrong class # Wrong class
elem3 = Element("div", {"class": "sidebar"}) elem3 = Element("div", {"class": "sidebar"})
assert sel.matches(elem3) is False assert sel.matches(elem3) is False
@ -96,18 +95,18 @@ class TestSelector:
class TestCSSParser: class TestCSSParser:
"""Test CSS stylesheet parsing.""" """Test CSS stylesheet parsing."""
def test_empty_stylesheet(self): def test_empty_stylesheet(self):
rules = parse("") rules = parse("")
assert rules == [] assert rules == []
def test_single_rule(self): def test_single_rule(self):
css = "p { color: red; }" css = "p { color: red; }"
rules = parse(css) rules = parse(css)
assert len(rules) == 1 assert len(rules) == 1
assert rules[0].selector.tag == "p" assert rules[0].selector.tag == "p"
assert rules[0].declarations == {"color": "red"} assert rules[0].declarations == {"color": "red"}
def test_multiple_rules(self): def test_multiple_rules(self):
css = """ css = """
p { color: red; } p { color: red; }
@ -117,7 +116,7 @@ class TestCSSParser:
assert len(rules) == 2 assert len(rules) == 2
assert rules[0].selector.tag == "p" assert rules[0].selector.tag == "p"
assert rules[1].selector.tag == "div" assert rules[1].selector.tag == "div"
def test_multiple_declarations(self): def test_multiple_declarations(self):
css = "p { color: red; font-size: 14px; margin: 10px; }" css = "p { color: red; font-size: 14px; margin: 10px; }"
rules = parse(css) rules = parse(css)
@ -127,7 +126,7 @@ class TestCSSParser:
"font-size": "14px", "font-size": "14px",
"margin": "10px" "margin": "10px"
} }
def test_multiline_declarations(self): def test_multiline_declarations(self):
css = """ css = """
p { p {
@ -143,7 +142,7 @@ class TestCSSParser:
"font-size": "14px", "font-size": "14px",
"margin": "10px" "margin": "10px"
} }
def test_no_semicolon_on_last_declaration(self): def test_no_semicolon_on_last_declaration(self):
css = "p { color: red; font-size: 14px }" css = "p { color: red; font-size: 14px }"
rules = parse(css) rules = parse(css)
@ -151,34 +150,34 @@ class TestCSSParser:
"color": "red", "color": "red",
"font-size": "14px" "font-size": "14px"
} }
def test_class_selector_rule(self): def test_class_selector_rule(self):
css = ".container { width: 100%; }" css = ".container { width: 100%; }"
rules = parse(css) rules = parse(css)
assert len(rules) == 1 assert len(rules) == 1
assert rules[0].selector.classes == ["container"] assert rules[0].selector.classes == ["container"]
assert rules[0].declarations == {"width": "100%"} assert rules[0].declarations == {"width": "100%"}
def test_id_selector_rule(self): def test_id_selector_rule(self):
css = "#header { height: 50px; }" css = "#header { height: 50px; }"
rules = parse(css) rules = parse(css)
assert len(rules) == 1 assert len(rules) == 1
assert rules[0].selector.id == "header" assert rules[0].selector.id == "header"
assert rules[0].declarations == {"height": "50px"} assert rules[0].declarations == {"height": "50px"}
def test_compound_selector_rule(self): def test_compound_selector_rule(self):
css = "div.container { padding: 20px; }" css = "div.container { padding: 20px; }"
rules = parse(css) rules = parse(css)
assert len(rules) == 1 assert len(rules) == 1
assert rules[0].selector.tag == "div" assert rules[0].selector.tag == "div"
assert rules[0].selector.classes == ["container"] assert rules[0].selector.classes == ["container"]
def test_whitespace_handling(self): def test_whitespace_handling(self):
css = " p { color : red ; } " css = " p { color : red ; } "
rules = parse(css) rules = parse(css)
assert len(rules) == 1 assert len(rules) == 1
assert rules[0].declarations == {"color": "red"} assert rules[0].declarations == {"color": "red"}
def test_comments(self): def test_comments(self):
css = """ css = """
/* This is a comment */ /* This is a comment */
@ -190,38 +189,38 @@ class TestCSSParser:
assert len(rules) == 2 assert len(rules) == 2
assert rules[0].selector.tag == "p" assert rules[0].selector.tag == "p"
assert rules[1].selector.tag == "div" assert rules[1].selector.tag == "div"
def test_property_values_with_spaces(self): def test_property_values_with_spaces(self):
css = "p { font-family: Arial, sans-serif; }" css = "p { font-family: Arial, sans-serif; }"
rules = parse(css) rules = parse(css)
assert rules[0].declarations == {"font-family": "Arial, sans-serif"} assert rules[0].declarations == {"font-family": "Arial, sans-serif"}
def test_complex_stylesheet(self): def test_complex_stylesheet(self):
css = """ css = """
/* Reset */ /* Reset */
* { margin: 0; padding: 0; } * { margin: 0; padding: 0; }
body { body {
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
font-size: 16px; font-size: 16px;
color: #333; color: #333;
} }
h1 { h1 {
font-size: 32px; font-size: 32px;
margin-bottom: 20px; margin-bottom: 20px;
} }
.container { .container {
width: 960px; width: 960px;
margin: 0 auto; margin: 0 auto;
} }
#header { #header {
background: #f0f0f0; background: #f0f0f0;
padding: 10px; padding: 10px;
} }
div.highlight { div.highlight {
background: yellow; background: yellow;
font-weight: bold; font-weight: bold;
@ -229,7 +228,7 @@ class TestCSSParser:
""" """
rules = parse(css) rules = parse(css)
assert len(rules) == 6 assert len(rules) == 6
# Check body rule # Check body rule
body_rule = next(r for r in rules if r.selector.tag == "body") body_rule = next(r for r in rules if r.selector.tag == "body")
assert "font-family" in body_rule.declarations assert "font-family" in body_rule.declarations
@ -238,34 +237,34 @@ class TestCSSParser:
class TestInlineStyleParser: class TestInlineStyleParser:
"""Test inline style attribute parsing.""" """Test inline style attribute parsing."""
def test_empty_style(self): def test_empty_style(self):
decls = parse_inline_style("") decls = parse_inline_style("")
assert decls == {} assert decls == {}
def test_single_declaration(self): def test_single_declaration(self):
decls = parse_inline_style("color: red") decls = parse_inline_style("color: red")
assert decls == {"color": "red"} assert decls == {"color": "red"}
def test_multiple_declarations(self): def test_multiple_declarations(self):
decls = parse_inline_style("color: red; font-size: 14px") decls = parse_inline_style("color: red; font-size: 14px")
assert decls == {"color": "red", "font-size": "14px"} assert decls == {"color": "red", "font-size": "14px"}
def test_trailing_semicolon(self): def test_trailing_semicolon(self):
decls = parse_inline_style("color: red; font-size: 14px;") decls = parse_inline_style("color: red; font-size: 14px;")
assert decls == {"color": "red", "font-size": "14px"} assert decls == {"color": "red", "font-size": "14px"}
def test_whitespace_handling(self): def test_whitespace_handling(self):
decls = parse_inline_style(" color : red ; font-size : 14px ") decls = parse_inline_style(" color : red ; font-size : 14px ")
assert decls == {"color": "red", "font-size": "14px"} assert decls == {"color": "red", "font-size": "14px"}
def test_complex_values(self): def test_complex_values(self):
decls = parse_inline_style("font-family: Arial, sans-serif; margin: 10px 20px") decls = parse_inline_style("font-family: Arial, sans-serif; margin: 10px 20px")
assert decls == { assert decls == {
"font-family": "Arial, sans-serif", "font-family": "Arial, sans-serif",
"margin": "10px 20px" "margin": "10px 20px"
} }
def test_malformed_ignored(self): def test_malformed_ignored(self):
# Missing colon # Missing colon
decls = parse_inline_style("color red; font-size: 14px") decls = parse_inline_style("color red; font-size: 14px")
@ -274,36 +273,36 @@ class TestInlineStyleParser:
class TestComputedStyle: class TestComputedStyle:
"""Test computed style value accessors.""" """Test computed style value accessors."""
def test_empty_style(self): def test_empty_style(self):
style = ComputedStyle() style = ComputedStyle()
assert style.get("color") == "" assert style.get("color") == ""
assert style.get("color", "black") == "black" assert style.get("color", "black") == "black"
def test_get_set(self): def test_get_set(self):
style = ComputedStyle() style = ComputedStyle()
style.set("color", "red") style.set("color", "red")
assert style.get("color") == "red" assert style.get("color") == "red"
def test_get_int(self): def test_get_int(self):
style = ComputedStyle() style = ComputedStyle()
style.set("font-size", "16px") style.set("font-size", "16px")
assert style.get_int("font-size") == 16 assert style.get_int("font-size") == 16
def test_get_int_no_unit(self): def test_get_int_no_unit(self):
style = ComputedStyle() style = ComputedStyle()
style.set("font-size", "16") style.set("font-size", "16")
assert style.get_int("font-size") == 16 assert style.get_int("font-size") == 16
def test_get_int_default(self): def test_get_int_default(self):
style = ComputedStyle() style = ComputedStyle()
assert style.get_int("font-size", 14) == 14 assert style.get_int("font-size", 14) == 14
def test_get_float(self): def test_get_float(self):
style = ComputedStyle() style = ComputedStyle()
style.set("margin", "10.5px") style.set("margin", "10.5px")
assert style.get_float("margin") == 10.5 assert style.get_float("margin") == 10.5
def test_get_float_default(self): def test_get_float_default(self):
style = ComputedStyle() style = ComputedStyle()
assert style.get_float("margin", 5.5) == 5.5 assert style.get_float("margin", 5.5) == 5.5
@ -311,44 +310,44 @@ class TestComputedStyle:
class TestStyleResolver: class TestStyleResolver:
"""Test style resolution with cascade and inheritance.""" """Test style resolution with cascade and inheritance."""
def test_default_styles(self): def test_default_styles(self):
resolver = StyleResolver() resolver = StyleResolver()
elem = Element("p") elem = Element("p")
style = resolver.resolve_style(elem) style = resolver.resolve_style(elem)
assert style.get("display") == "block" assert style.get("display") == "block"
assert style.get("margin-top") == "16px" assert style.get("margin-top") == "16px"
assert style.get("margin-bottom") == "16px" assert style.get("margin-bottom") == "16px"
def test_no_default_for_unknown_tag(self): def test_no_default_for_unknown_tag(self):
resolver = StyleResolver() resolver = StyleResolver()
elem = Element("unknown") elem = Element("unknown")
style = resolver.resolve_style(elem) style = resolver.resolve_style(elem)
# Should have empty properties (no defaults) # Should have empty properties (no defaults)
assert style.get("display") == "" assert style.get("display") == ""
def test_stylesheet_overrides_default(self): def test_stylesheet_overrides_default(self):
rules = parse("p { margin-top: 20px; }") rules = parse("p { margin-top: 20px; }")
resolver = StyleResolver(rules) resolver = StyleResolver(rules)
elem = Element("p") elem = Element("p")
style = resolver.resolve_style(elem) style = resolver.resolve_style(elem)
# Stylesheet should override default # Stylesheet should override default
assert style.get("margin-top") == "20px" assert style.get("margin-top") == "20px"
# But default not overridden should remain # But default not overridden should remain
assert style.get("margin-bottom") == "16px" assert style.get("margin-bottom") == "16px"
def test_inline_overrides_stylesheet(self): def test_inline_overrides_stylesheet(self):
rules = parse("p { color: blue; }") rules = parse("p { color: blue; }")
resolver = StyleResolver(rules) resolver = StyleResolver(rules)
elem = Element("p", {"style": "color: red"}) elem = Element("p", {"style": "color: red"})
style = resolver.resolve_style(elem) style = resolver.resolve_style(elem)
# Inline should win # Inline should win
assert style.get("color") == "red" assert style.get("color") == "red"
def test_specificity_class_over_tag(self): def test_specificity_class_over_tag(self):
rules = parse(""" rules = parse("""
p { color: blue; } p { color: blue; }
@ -357,10 +356,10 @@ class TestStyleResolver:
resolver = StyleResolver(rules) resolver = StyleResolver(rules)
elem = Element("p", {"class": "highlight"}) elem = Element("p", {"class": "highlight"})
style = resolver.resolve_style(elem) style = resolver.resolve_style(elem)
# Class selector has higher specificity # Class selector has higher specificity
assert style.get("color") == "red" assert style.get("color") == "red"
def test_specificity_id_over_class(self): def test_specificity_id_over_class(self):
rules = parse(""" rules = parse("""
p { color: blue; } p { color: blue; }
@ -370,53 +369,53 @@ class TestStyleResolver:
resolver = StyleResolver(rules) resolver = StyleResolver(rules)
elem = Element("p", {"class": "highlight", "id": "main"}) elem = Element("p", {"class": "highlight", "id": "main"})
style = resolver.resolve_style(elem) style = resolver.resolve_style(elem)
# ID selector has highest specificity # ID selector has highest specificity
assert style.get("color") == "green" assert style.get("color") == "green"
def test_inheritance_from_parent(self): def test_inheritance_from_parent(self):
rules = parse("body { color: blue; font-size: 16px; }") rules = parse("body { color: blue; font-size: 16px; }")
resolver = StyleResolver(rules) resolver = StyleResolver(rules)
parent = Element("body") parent = Element("body")
parent_style = resolver.resolve_style(parent) parent_style = resolver.resolve_style(parent)
child = Element("div") child = Element("div")
child_style = resolver.resolve_style(child, parent_style) child_style = resolver.resolve_style(child, parent_style)
# Should inherit color and font-size # Should inherit color and font-size
assert child_style.get("color") == "blue" assert child_style.get("color") == "blue"
assert child_style.get("font-size") == "16px" assert child_style.get("font-size") == "16px"
def test_non_inherited_properties(self): def test_non_inherited_properties(self):
rules = parse("body { margin: 10px; }") rules = parse("body { margin: 10px; }")
resolver = StyleResolver(rules) resolver = StyleResolver(rules)
parent = Element("body") parent = Element("body")
parent_style = resolver.resolve_style(parent) parent_style = resolver.resolve_style(parent)
child = Element("div") child = Element("div")
child_style = resolver.resolve_style(child, parent_style) child_style = resolver.resolve_style(child, parent_style)
# Margin should not inherit # Margin should not inherit
assert child_style.get("margin") == "" assert child_style.get("margin") == ""
def test_child_overrides_inherited(self): def test_child_overrides_inherited(self):
rules = parse(""" rules = parse("""
body { color: blue; } body { color: blue; }
p { color: red; } p { color: red; }
""") """)
resolver = StyleResolver(rules) resolver = StyleResolver(rules)
parent = Element("body") parent = Element("body")
parent_style = resolver.resolve_style(parent) parent_style = resolver.resolve_style(parent)
child = Element("p") child = Element("p")
child_style = resolver.resolve_style(child, parent_style) child_style = resolver.resolve_style(child, parent_style)
# Child's own style should override inherited # Child's own style should override inherited
assert child_style.get("color") == "red" assert child_style.get("color") == "red"
def test_resolve_tree(self): def test_resolve_tree(self):
css = """ css = """
body { color: blue; font-size: 16px; } body { color: blue; font-size: 16px; }
@ -425,7 +424,7 @@ class TestStyleResolver:
""" """
rules = parse(css) rules = parse(css)
resolver = StyleResolver(rules) resolver = StyleResolver(rules)
# Build tree # Build tree
root = Element("body") root = Element("body")
p1 = Element("p", parent=root) p1 = Element("p", parent=root)
@ -433,46 +432,46 @@ class TestStyleResolver:
text = Text("Hello", parent=p1) text = Text("Hello", parent=p1)
root.children = [p1, p2] root.children = [p1, p2]
p1.children = [text] p1.children = [text]
# Resolve entire tree # Resolve entire tree
resolver.resolve_tree(root) resolver.resolve_tree(root)
# Check root # Check root
assert root.computed_style.get("color") == "blue" assert root.computed_style.get("color") == "blue"
assert root.computed_style.get("font-size") == "16px" assert root.computed_style.get("font-size") == "16px"
# Check p1 (inherits color) # Check p1 (inherits color)
assert p1.computed_style.get("color") == "blue" assert p1.computed_style.get("color") == "blue"
assert p1.computed_style.get("margin") == "10px" assert p1.computed_style.get("margin") == "10px"
# Check p2 (inherits + has class) # Check p2 (inherits + has class)
assert p2.computed_style.get("color") == "blue" assert p2.computed_style.get("color") == "blue"
assert p2.computed_style.get("background") == "yellow" assert p2.computed_style.get("background") == "yellow"
# Check text (has parent style) # Check text (has parent style)
assert text.computed_style.get("color") == "blue" assert text.computed_style.get("color") == "blue"
def test_heading_defaults(self): def test_heading_defaults(self):
resolver = StyleResolver() resolver = StyleResolver()
h1 = Element("h1") h1 = Element("h1")
h1_style = resolver.resolve_style(h1) h1_style = resolver.resolve_style(h1)
assert h1_style.get("font-size") == "32px" assert h1_style.get("font-size") == "32px"
assert h1_style.get("font-weight") == "bold" assert h1_style.get("font-weight") == "bold"
h2 = Element("h2") h2 = Element("h2")
h2_style = resolver.resolve_style(h2) h2_style = resolver.resolve_style(h2)
assert h2_style.get("font-size") == "24px" assert h2_style.get("font-size") == "24px"
def test_inline_elements(self): def test_inline_elements(self):
resolver = StyleResolver() resolver = StyleResolver()
a = Element("a") a = Element("a")
a_style = resolver.resolve_style(a) a_style = resolver.resolve_style(a)
assert a_style.get("display") == "inline" assert a_style.get("display") == "inline"
assert a_style.get("color") == "blue" assert a_style.get("color") == "blue"
assert a_style.get("text-decoration") == "underline" assert a_style.get("text-decoration") == "underline"
span = Element("span") span = Element("span")
span_style = resolver.resolve_style(span) span_style = resolver.resolve_style(span)
assert span_style.get("display") == "inline" assert span_style.get("display") == "inline"

View file

@ -60,7 +60,7 @@ class TestParseHTML:
if hasattr(child, "tag") and child.tag == "style": if hasattr(child, "tag") and child.tag == "style":
style_elem = child style_elem = child
break break
assert style_elem is not None assert style_elem is not None
# Style content should be in the element # Style content should be in the element
joined = " ".join(collect_text(style_elem)) joined = " ".join(collect_text(style_elem))

View file

@ -1,13 +1,11 @@
"""Tests for image loading and rendering.""" """Tests for image loading and rendering."""
import pytest
import skia 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.layout.embed import ImageLayout
from src.parser.html import Element, parse_html from src.parser.html import Element, parse_html
from src.render.paint import DrawImage from src.render.paint import DrawImage
from src.layout.document import DocumentLayout, LayoutImage from src.layout.document import DocumentLayout, LayoutImage
from io import BytesIO
def create_test_image(width=100, height=100): def create_test_image(width=100, height=100):
@ -21,47 +19,47 @@ def create_test_image(width=100, height=100):
class TestImageCache: class TestImageCache:
"""Test image caching.""" """Test image caching."""
def test_cache_singleton(self): def test_cache_singleton(self):
"""ImageCache should be a singleton.""" """ImageCache should be a singleton."""
cache1 = ImageCache() cache1 = ImageCache()
cache2 = ImageCache() cache2 = ImageCache()
assert cache1 is cache2 assert cache1 is cache2
def test_cache_get_set(self): def test_cache_get_set(self):
"""Test basic cache operations.""" """Test basic cache operations."""
cache = ImageCache() cache = ImageCache()
cache.clear() cache.clear()
# Create a simple test image # Create a simple test image
image = create_test_image(100, 100) image = create_test_image(100, 100)
# Initially empty # Initially empty
assert cache.get("test_url") is None assert cache.get("test_url") is None
# Set and get # Set and get
cache.set("test_url", image) cache.set("test_url", image)
cached = cache.get("test_url") cached = cache.get("test_url")
assert cached is not None assert cached is not None
assert cached.width() == 100 assert cached.width() == 100
assert cached.height() == 100 assert cached.height() == 100
def test_cache_clear(self): def test_cache_clear(self):
"""Test cache clearing.""" """Test cache clearing."""
cache = ImageCache() cache = ImageCache()
cache.clear() cache.clear()
image = create_test_image(100, 100) image = create_test_image(100, 100)
cache.set("test_url", image) cache.set("test_url", image)
assert cache.get("test_url") is not None assert cache.get("test_url") is not None
cache.clear() cache.clear()
assert cache.get("test_url") is None assert cache.get("test_url") is None
class TestDataURLLoading: class TestDataURLLoading:
"""Test data URL image loading.""" """Test data URL image loading."""
def test_load_base64_png(self): def test_load_base64_png(self):
"""Test loading a base64-encoded PNG data URL.""" """Test loading a base64-encoded PNG data URL."""
# Simple 1x1 red PNG # Simple 1x1 red PNG
@ -69,29 +67,29 @@ class TestDataURLLoading:
"data:image/png;base64," "data:image/png;base64,"
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==" "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="
) )
image = _load_data_url(data_url) image = _load_data_url(data_url)
assert image is not None assert image is not None
assert image.width() == 1 assert image.width() == 1
assert image.height() == 1 assert image.height() == 1
def test_load_invalid_data_url(self): def test_load_invalid_data_url(self):
"""Test loading an invalid data URL.""" """Test loading an invalid data URL."""
image = _load_data_url("data:invalid") image = _load_data_url("data:invalid")
assert image is None assert image is None
image = _load_data_url("not_a_data_url") image = _load_data_url("not_a_data_url")
assert image is None assert image is None
class TestImageLayout: class TestImageLayout:
"""Test ImageLayout class.""" """Test ImageLayout class."""
def test_image_layout_init(self): def test_image_layout_init(self):
"""Test ImageLayout initialization.""" """Test ImageLayout initialization."""
node = Element("img", {"src": "test.png"}) node = Element("img", {"src": "test.png"})
layout = ImageLayout(node) layout = ImageLayout(node)
assert layout.node == node assert layout.node == node
assert layout.x == 0 assert layout.x == 0
assert layout.y == 0 assert layout.y == 0
@ -99,109 +97,109 @@ class TestImageLayout:
assert layout.height == 0 assert layout.height == 0
assert layout.image is None assert layout.image is None
assert layout.is_inline is True assert layout.is_inline is True
def test_layout_with_intrinsic_size(self): def test_layout_with_intrinsic_size(self):
"""Test layout calculation with intrinsic image size.""" """Test layout calculation with intrinsic image size."""
node = Element("img", {"src": "test.png"}) node = Element("img", {"src": "test.png"})
layout = ImageLayout(node) layout = ImageLayout(node)
# Create a test image # Create a test image
layout.image = create_test_image(200, 150) layout.image = create_test_image(200, 150)
width = layout.layout() width = layout.layout()
assert layout.width == 200 assert layout.width == 200
assert layout.height == 150 assert layout.height == 150
assert width == 200 assert width == 200
def test_layout_with_explicit_width(self): def test_layout_with_explicit_width(self):
"""Test layout with explicit width attribute.""" """Test layout with explicit width attribute."""
node = Element("img", {"src": "test.png", "width": "100"}) node = Element("img", {"src": "test.png", "width": "100"})
layout = ImageLayout(node) layout = ImageLayout(node)
# Create a test image (200x150) # Create a test image (200x150)
layout.image = create_test_image(200, 150) layout.image = create_test_image(200, 150)
layout.layout() layout.layout()
# Should maintain aspect ratio # Should maintain aspect ratio
assert layout.width == 100 assert layout.width == 100
assert layout.height == 75 # 100 * (150/200) assert layout.height == 75 # 100 * (150/200)
def test_layout_with_explicit_height(self): def test_layout_with_explicit_height(self):
"""Test layout with explicit height attribute.""" """Test layout with explicit height attribute."""
node = Element("img", {"src": "test.png", "height": "100"}) node = Element("img", {"src": "test.png", "height": "100"})
layout = ImageLayout(node) layout = ImageLayout(node)
# Create a test image (200x150) # Create a test image (200x150)
layout.image = create_test_image(200, 150) layout.image = create_test_image(200, 150)
layout.layout() layout.layout()
# Should maintain aspect ratio # Should maintain aspect ratio
assert layout.height == 100 assert layout.height == 100
assert abs(layout.width - 133.33) < 1 # 100 * (200/150) assert abs(layout.width - 133.33) < 1 # 100 * (200/150)
def test_layout_with_both_dimensions(self): def test_layout_with_both_dimensions(self):
"""Test layout with both width and height specified.""" """Test layout with both width and height specified."""
node = Element("img", {"src": "test.png", "width": "100", "height": "50"}) node = Element("img", {"src": "test.png", "width": "100", "height": "50"})
layout = ImageLayout(node) layout = ImageLayout(node)
# Create a test image # Create a test image
layout.image = create_test_image(200, 150) layout.image = create_test_image(200, 150)
layout.layout() layout.layout()
# Should use explicit dimensions (no aspect ratio preservation) # Should use explicit dimensions (no aspect ratio preservation)
assert layout.width == 100 assert layout.width == 100
assert layout.height == 50 assert layout.height == 50
def test_layout_with_max_width(self): def test_layout_with_max_width(self):
"""Test layout constrained by max_width.""" """Test layout constrained by max_width."""
node = Element("img", {"src": "test.png"}) node = Element("img", {"src": "test.png"})
layout = ImageLayout(node) layout = ImageLayout(node)
# Create a large test image # Create a large test image
layout.image = create_test_image(1000, 500) layout.image = create_test_image(1000, 500)
layout.layout(max_width=400) layout.layout(max_width=400)
# Should constrain to max_width and maintain aspect ratio # Should constrain to max_width and maintain aspect ratio
assert layout.width == 400 assert layout.width == 400
assert layout.height == 200 # 400 * (500/1000) assert layout.height == 200 # 400 * (500/1000)
def test_layout_no_image(self): def test_layout_no_image(self):
"""Test layout when image fails to load.""" """Test layout when image fails to load."""
node = Element("img", {"src": "test.png", "alt": "Test image"}) node = Element("img", {"src": "test.png", "alt": "Test image"})
layout = ImageLayout(node) layout = ImageLayout(node)
# Don't set an image (simulating load failure) # Don't set an image (simulating load failure)
layout.alt_text = "Test image" layout.alt_text = "Test image"
layout.layout() layout.layout()
# Should use placeholder dimensions # Should use placeholder dimensions
assert layout.width == 100 assert layout.width == 100
assert layout.height == 100 assert layout.height == 100
def test_alt_text_extraction(self): def test_alt_text_extraction(self):
"""Test alt text extraction.""" """Test alt text extraction."""
node = Element("img", {"src": "test.png", "alt": "Description"}) node = Element("img", {"src": "test.png", "alt": "Description"})
layout = ImageLayout(node) layout = ImageLayout(node)
layout.load() layout.load()
assert layout.alt_text == "Description" assert layout.alt_text == "Description"
class TestDrawImage: class TestDrawImage:
"""Test DrawImage paint command.""" """Test DrawImage paint command."""
def test_draw_image_init(self): def test_draw_image_init(self):
"""Test DrawImage initialization.""" """Test DrawImage initialization."""
image = create_test_image(100, 100) image = create_test_image(100, 100)
cmd = DrawImage(10, 20, 100, 100, image, "Test") cmd = DrawImage(10, 20, 100, 100, image, "Test")
assert cmd.x == 10 assert cmd.x == 10
assert cmd.y == 20 assert cmd.y == 20
assert cmd.width == 100 assert cmd.width == 100
@ -209,75 +207,75 @@ class TestDrawImage:
assert cmd.image is image assert cmd.image is image
assert cmd.alt_text == "Test" assert cmd.alt_text == "Test"
assert cmd.rect == (10, 20, 110, 120) assert cmd.rect == (10, 20, 110, 120)
def test_draw_image_with_valid_image(self): def test_draw_image_with_valid_image(self):
"""Test drawing a valid image.""" """Test drawing a valid image."""
image = create_test_image(100, 100) image = create_test_image(100, 100)
# Create a surface to draw on # Create a surface to draw on
surface = skia.Surface(200, 200) surface = skia.Surface(200, 200)
canvas = surface.getCanvas() canvas = surface.getCanvas()
cmd = DrawImage(10, 20, 100, 100, image) cmd = DrawImage(10, 20, 100, 100, image)
cmd.execute(canvas) cmd.execute(canvas)
# If it doesn't throw, it worked # If it doesn't throw, it worked
assert True assert True
def test_draw_image_with_null_image(self): def test_draw_image_with_null_image(self):
"""Test drawing when image is None (placeholder).""" """Test drawing when image is None (placeholder)."""
# Create a surface to draw on # Create a surface to draw on
surface = skia.Surface(200, 200) surface = skia.Surface(200, 200)
canvas = surface.getCanvas() canvas = surface.getCanvas()
cmd = DrawImage(10, 20, 100, 100, None, "Failed to load") cmd = DrawImage(10, 20, 100, 100, None, "Failed to load")
cmd.execute(canvas) cmd.execute(canvas)
# Should draw placeholder without error # Should draw placeholder without error
assert True assert True
class TestDocumentLayoutImages: class TestDocumentLayoutImages:
"""Test image integration in DocumentLayout.""" """Test image integration in DocumentLayout."""
def test_parse_img_element(self): def test_parse_img_element(self):
"""Test that img elements are parsed correctly.""" """Test that img elements are parsed correctly."""
html = '<img src="test.png" alt="Test image" width="100">' html = '<img src="test.png" alt="Test image" width="100">'
root = parse_html(html) root = parse_html(html)
# Find the img element # Find the img element
body = root.children[0] body = root.children[0]
img = body.children[0] img = body.children[0]
assert img.tag == "img" assert img.tag == "img"
assert img.attributes["src"] == "test.png" assert img.attributes["src"] == "test.png"
assert img.attributes["alt"] == "Test image" assert img.attributes["alt"] == "Test image"
assert img.attributes["width"] == "100" assert img.attributes["width"] == "100"
def test_layout_with_image(self): def test_layout_with_image(self):
"""Test document layout with an image.""" """Test document layout with an image."""
html = '<p>Text before</p><img src="test.png" width="100" height="75"><p>Text after</p>' html = '<p>Text before</p><img src="test.png" width="100" height="75"><p>Text after</p>'
root = parse_html(html) root = parse_html(html)
layout = DocumentLayout(root) layout = DocumentLayout(root)
# Mock the image loading by creating the images manually # Mock the image loading by creating the images manually
# This would normally happen in _collect_blocks # This would normally happen in _collect_blocks
# For now, just verify the structure is created # For now, just verify the structure is created
lines = layout.layout(800) lines = layout.layout(800)
# Should have lines and potentially images # Should have lines and potentially images
assert isinstance(lines, list) assert isinstance(lines, list)
def test_layout_image_class(self): def test_layout_image_class(self):
"""Test LayoutImage class.""" """Test LayoutImage class."""
node = Element("img", {"src": "test.png"}) node = Element("img", {"src": "test.png"})
image_layout = ImageLayout(node) image_layout = ImageLayout(node)
image_layout.image = create_test_image(100, 100) image_layout.image = create_test_image(100, 100)
image_layout.layout() image_layout.layout()
layout_image = LayoutImage(image_layout, 10, 20) layout_image = LayoutImage(image_layout, 10, 20)
assert layout_image.x == 10 assert layout_image.x == 10
assert layout_image.y == 20 assert layout_image.y == 20
assert layout_image.width == 100 assert layout_image.width == 100
@ -287,7 +285,7 @@ class TestDocumentLayoutImages:
class TestImageIntegration: class TestImageIntegration:
"""Integration tests for the complete image pipeline.""" """Integration tests for the complete image pipeline."""
def test_html_with_data_url_image(self): def test_html_with_data_url_image(self):
"""Test parsing and layout of HTML with data URL image.""" """Test parsing and layout of HTML with data URL image."""
# 1x1 red PNG # 1x1 red PNG
@ -295,10 +293,10 @@ class TestImageIntegration:
"data:image/png;base64," "data:image/png;base64,"
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==" "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="
) )
html = f'<p>Before</p><img src="{data_url}" width="50" height="50"><p>After</p>' html = f'<p>Before</p><img src="{data_url}" width="50" height="50"><p>After</p>'
root = parse_html(html) root = parse_html(html)
# Verify structure # Verify structure
body = root.children[0] body = root.children[0]
# The img tag is self-closing, so the second p tag becomes a child of img # 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 len(body.children) >= 2
assert body.children[0].tag == "p" assert body.children[0].tag == "p"
assert body.children[1].tag == "img" assert body.children[1].tag == "img"
def test_nested_image_in_paragraph(self): def test_nested_image_in_paragraph(self):
"""Test that images inside paragraphs are collected.""" """Test that images inside paragraphs are collected."""
# 1x1 red PNG # 1x1 red PNG
@ -314,28 +312,28 @@ class TestImageIntegration:
"data:image/png;base64," "data:image/png;base64,"
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==" "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="
) )
html = f'<p>Text before <img src="{data_url}" width="50" height="50"> text after</p>' html = f'<p>Text before <img src="{data_url}" width="50" height="50"> text after</p>'
root = parse_html(html) root = parse_html(html)
# Create layout and verify images are collected # Create layout and verify images are collected
layout = DocumentLayout(root) layout = DocumentLayout(root)
layout.layout(800) layout.layout(800)
# Should have at least one image collected # Should have at least one image collected
assert len(layout.images) >= 1 assert len(layout.images) >= 1
def test_image_with_alt_text_placeholder(self): def test_image_with_alt_text_placeholder(self):
"""Test that failed images show placeholder with alt text.""" """Test that failed images show placeholder with alt text."""
html = '<img src="nonexistent.png" width="200" height="100" alt="Image failed">' html = '<img src="nonexistent.png" width="200" height="100" alt="Image failed">'
root = parse_html(html) root = parse_html(html)
layout = DocumentLayout(root) layout = DocumentLayout(root)
layout.layout(800) layout.layout(800)
# Should have image layout even though load failed # Should have image layout even though load failed
assert len(layout.images) >= 1 assert len(layout.images) >= 1
# Check alt text is set # Check alt text is set
if layout.images: if layout.images:
img = layout.images[0] img = layout.images[0]
@ -344,41 +342,41 @@ class TestImageIntegration:
class TestURLResolution: class TestURLResolution:
"""Test URL resolution for images.""" """Test URL resolution for images."""
def test_resolve_about_page_relative_url(self): def test_resolve_about_page_relative_url(self):
"""Test resolving relative URLs for about: pages.""" """Test resolving relative URLs for about: pages."""
from src.network.images import _resolve_url, ASSETS_DIR from src.network.images import _resolve_url, ASSETS_DIR
# Relative URL from about:startpage should resolve to assets directory # Relative URL from about:startpage should resolve to assets directory
resolved = _resolve_url("../WebBowserLogo.jpeg", "about:startpage") resolved = _resolve_url("../WebBowserLogo.jpeg", "about:startpage")
# Should be an absolute path to the assets directory # Should be an absolute path to the assets directory
assert "WebBowserLogo.jpeg" in resolved assert "WebBowserLogo.jpeg" in resolved
assert str(ASSETS_DIR) in resolved or resolved.endswith("WebBowserLogo.jpeg") assert str(ASSETS_DIR) in resolved or resolved.endswith("WebBowserLogo.jpeg")
def test_resolve_http_relative_url(self): def test_resolve_http_relative_url(self):
"""Test resolving relative URLs for HTTP pages.""" """Test resolving relative URLs for HTTP pages."""
from src.network.images import _resolve_url from src.network.images import _resolve_url
# Relative URL from HTTP page # Relative URL from HTTP page
resolved = _resolve_url("images/photo.jpg", "https://example.com/page/index.html") resolved = _resolve_url("images/photo.jpg", "https://example.com/page/index.html")
assert resolved == "https://example.com/page/images/photo.jpg" assert resolved == "https://example.com/page/images/photo.jpg"
def test_resolve_absolute_url(self): def test_resolve_absolute_url(self):
"""Test that absolute URLs are returned unchanged.""" """Test that absolute URLs are returned unchanged."""
from src.network.images import _resolve_url from src.network.images import _resolve_url
url = "https://example.com/image.png" url = "https://example.com/image.png"
resolved = _resolve_url(url, "https://other.com/page.html") resolved = _resolve_url(url, "https://other.com/page.html")
assert resolved == url assert resolved == url
def test_resolve_data_url(self): def test_resolve_data_url(self):
"""Test that data URLs are returned unchanged.""" """Test that data URLs are returned unchanged."""
from src.network.images import _resolve_url from src.network.images import _resolve_url
url = "data:image/png;base64,abc123" url = "data:image/png;base64,abc123"
resolved = _resolve_url(url, "https://example.com/") resolved = _resolve_url(url, "https://example.com/")
assert resolved == url assert resolved == url

341
tests/test_links.py Normal file
View 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

View file

@ -1,13 +1,13 @@
"""Integration tests for CSS styling system.""" """Integration tests for CSS styling system."""
import pytest 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 from src.layout.document import DocumentLayout
class TestStyleIntegration: class TestStyleIntegration:
"""Test end-to-end CSS parsing and layout integration.""" """Test end-to-end CSS parsing and layout integration."""
def test_parse_with_style_tag(self): def test_parse_with_style_tag(self):
html = """ html = """
<html> <html>
@ -22,7 +22,7 @@ class TestStyleIntegration:
</html> </html>
""" """
root = parse_html_with_styles(html) root = parse_html_with_styles(html)
# Find the p element # Find the p element
p_elem = None p_elem = None
for child in root.children: for child in root.children:
@ -31,12 +31,12 @@ class TestStyleIntegration:
if hasattr(grandchild, "tag") and grandchild.tag == "p": if hasattr(grandchild, "tag") and grandchild.tag == "p":
p_elem = grandchild p_elem = grandchild
break break
assert p_elem is not None assert p_elem is not None
assert hasattr(p_elem, "computed_style") assert hasattr(p_elem, "computed_style")
assert p_elem.computed_style.get("color") == "red" assert p_elem.computed_style.get("color") == "red"
assert p_elem.computed_style.get("font-size") == "18px" assert p_elem.computed_style.get("font-size") == "18px"
def test_inline_style_override(self): def test_inline_style_override(self):
html = """ html = """
<html> <html>
@ -46,7 +46,7 @@ class TestStyleIntegration:
</html> </html>
""" """
root = parse_html_with_styles(html) root = parse_html_with_styles(html)
# Find the p element # Find the p element
for child in root.children: for child in root.children:
if hasattr(child, "tag") and child.tag == "body": 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("color") == "blue"
assert p_elem.computed_style.get("font-size") == "20px" assert p_elem.computed_style.get("font-size") == "20px"
return return
pytest.fail("P element not found") pytest.fail("P element not found")
def test_cascade_priority(self): def test_cascade_priority(self):
html = """ html = """
<html> <html>
@ -78,24 +78,24 @@ class TestStyleIntegration:
</html> </html>
""" """
root = parse_html_with_styles(html) root = parse_html_with_styles(html)
# Find body # Find body
body = None body = None
for child in root.children: for child in root.children:
if hasattr(child, "tag") and child.tag == "body": if hasattr(child, "tag") and child.tag == "body":
body = child body = child
break break
assert body is not None assert body is not None
paragraphs = [c for c in body.children if hasattr(c, "tag") and c.tag == "p"] paragraphs = [c for c in body.children if hasattr(c, "tag") and c.tag == "p"]
assert len(paragraphs) == 4 assert len(paragraphs) == 4
# Check cascade # Check cascade
assert paragraphs[0].computed_style.get("color") == "red" # Tag only assert paragraphs[0].computed_style.get("color") == "red" # Tag only
assert paragraphs[1].computed_style.get("color") == "green" # Class wins assert paragraphs[1].computed_style.get("color") == "green" # Class wins
assert paragraphs[2].computed_style.get("color") == "blue" # ID wins assert paragraphs[2].computed_style.get("color") == "blue" # ID wins
assert paragraphs[3].computed_style.get("color") == "purple" # Inline wins assert paragraphs[3].computed_style.get("color") == "purple" # Inline wins
def test_inheritance(self): def test_inheritance(self):
html = """ html = """
<html> <html>
@ -112,7 +112,7 @@ class TestStyleIntegration:
</html> </html>
""" """
root = parse_html_with_styles(html) root = parse_html_with_styles(html)
# Find the nested p element # Find the nested p element
for child in root.children: for child in root.children:
if hasattr(child, "tag") and child.tag == "body": if hasattr(child, "tag") and child.tag == "body":
@ -150,10 +150,10 @@ class TestStyleIntegration:
lines = layout.layout(800) lines = layout.layout(800)
# H1 should use custom font size # H1 should use custom font size
assert lines[0].font_size == 40 assert lines[0].font_size == 40
# P should use custom font size # P should use custom font size
assert lines[1].font_size == 20 assert lines[1].font_size == 20
def test_multiple_classes(self): def test_multiple_classes(self):
html = """ html = """
<html> <html>
@ -169,7 +169,7 @@ class TestStyleIntegration:
</html> </html>
""" """
root = parse_html_with_styles(html) root = parse_html_with_styles(html)
# Find the p element # Find the p element
for child in root.children: for child in root.children:
if hasattr(child, "tag") and child.tag == "body": 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("font-size") == "24px"
assert grandchild.computed_style.get("color") == "red" assert grandchild.computed_style.get("color") == "red"
return return
pytest.fail("P element not found") pytest.fail("P element not found")
def test_default_styles_applied(self): def test_default_styles_applied(self):
html = """ html = """
<html> <html>
@ -193,20 +193,20 @@ class TestStyleIntegration:
</html> </html>
""" """
root = parse_html_with_styles(html) root = parse_html_with_styles(html)
# Find elements # Find elements
body = None body = None
for child in root.children: for child in root.children:
if hasattr(child, "tag") and child.tag == "body": if hasattr(child, "tag") and child.tag == "body":
body = child body = child
break break
assert body is not None assert body is not None
h1 = next((c for c in body.children if hasattr(c, "tag") and c.tag == "h1"), 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) 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) a = next((c for c in body.children if hasattr(c, "tag") and c.tag == "a"), None)
# Check default styles from default.css # Check default styles from default.css
assert h1 is not None assert h1 is not None
# Font-size from default.css is 2.5rem # Font-size from default.css is 2.5rem
@ -220,7 +220,7 @@ class TestStyleIntegration:
# Link color from default.css # Link color from default.css
assert a.computed_style.get("color") == "#0066cc" assert a.computed_style.get("color") == "#0066cc"
assert a.computed_style.get("text-decoration") == "none" assert a.computed_style.get("text-decoration") == "none"
def test_no_styles_when_disabled(self): def test_no_styles_when_disabled(self):
html = """ html = """
<html> <html>
@ -235,7 +235,7 @@ class TestStyleIntegration:
</html> </html>
""" """
root = parse_html_with_styles(html, apply_styles=False) root = parse_html_with_styles(html, apply_styles=False)
# Find the p element # Find the p element
for child in root.children: for child in root.children:
if hasattr(child, "tag") and child.tag == "body": if hasattr(child, "tag") and child.tag == "body":
@ -244,5 +244,5 @@ class TestStyleIntegration:
# Should not have computed_style when disabled # Should not have computed_style when disabled
assert not hasattr(grandchild, "computed_style") assert not hasattr(grandchild, "computed_style")
return return
pytest.fail("P element not found") pytest.fail("P element not found")

View file

@ -1,190 +1,189 @@
"""Tests for the async task queue system.""" """Tests for the async task queue system."""
import pytest
import time import time
import threading import threading
from unittest.mock import Mock, patch from unittest.mock import patch
class TestTaskQueue: class TestTaskQueue:
"""Tests for the TaskQueue class.""" """Tests for the TaskQueue class."""
def test_task_queue_singleton(self): def test_task_queue_singleton(self):
"""Test that TaskQueue is a singleton.""" """Test that TaskQueue is a singleton."""
from src.network.tasks import TaskQueue from src.network.tasks import TaskQueue
# Reset singleton for clean test # Reset singleton for clean test
TaskQueue.reset_instance() TaskQueue.reset_instance()
q1 = TaskQueue() q1 = TaskQueue()
q2 = TaskQueue() q2 = TaskQueue()
assert q1 is q2 assert q1 is q2
# Clean up # Clean up
TaskQueue.reset_instance() TaskQueue.reset_instance()
def test_submit_task_returns_id(self): def test_submit_task_returns_id(self):
"""Test that submit returns a task ID.""" """Test that submit returns a task ID."""
from src.network.tasks import TaskQueue from src.network.tasks import TaskQueue
TaskQueue.reset_instance() TaskQueue.reset_instance()
queue = TaskQueue() queue = TaskQueue()
# Mock GLib.idle_add to avoid GTK dependency # Mock GLib.idle_add to avoid GTK dependency
with patch('src.network.tasks.GLib') as mock_glib: with patch('src.network.tasks.GLib') as mock_glib:
mock_glib.idle_add = lambda cb, *args: cb(*args) if args else cb() mock_glib.idle_add = lambda cb, *args: cb(*args) if args else cb()
task_id = queue.submit(lambda: 42) task_id = queue.submit(lambda: 42)
# Task ID should be non-negative (or -1 for cached) # Task ID should be non-negative (or -1 for cached)
assert isinstance(task_id, int) assert isinstance(task_id, int)
# Wait for task to complete # Wait for task to complete
time.sleep(0.1) time.sleep(0.1)
TaskQueue.reset_instance() TaskQueue.reset_instance()
def test_task_executes_function(self): def test_task_executes_function(self):
"""Test that submitted tasks are executed.""" """Test that submitted tasks are executed."""
from src.network.tasks import TaskQueue from src.network.tasks import TaskQueue
TaskQueue.reset_instance() TaskQueue.reset_instance()
queue = TaskQueue() queue = TaskQueue()
result = [] result = []
event = threading.Event() threading.Event()
def task(): def task():
result.append("executed") result.append("executed")
return "done" return "done"
with patch('src.network.tasks.GLib') as mock_glib: with patch('src.network.tasks.GLib') as mock_glib:
mock_glib.idle_add = lambda cb, *args: cb(*args) if args else cb() mock_glib.idle_add = lambda cb, *args: cb(*args) if args else cb()
queue.submit(task) queue.submit(task)
# Wait for task to complete # Wait for task to complete
time.sleep(0.2) time.sleep(0.2)
assert "executed" in result assert "executed" in result
TaskQueue.reset_instance() TaskQueue.reset_instance()
def test_on_complete_callback(self): def test_on_complete_callback(self):
"""Test that on_complete callback is called with result.""" """Test that on_complete callback is called with result."""
from src.network.tasks import TaskQueue from src.network.tasks import TaskQueue
TaskQueue.reset_instance() TaskQueue.reset_instance()
queue = TaskQueue() queue = TaskQueue()
results = [] results = []
def task(): def task():
return 42 return 42
def on_complete(result): def on_complete(result):
results.append(result) results.append(result)
with patch('src.network.tasks.GLib') as mock_glib: with patch('src.network.tasks.GLib') as mock_glib:
# Make idle_add execute immediately # Make idle_add execute immediately
mock_glib.idle_add = lambda cb, *args: cb(*args) if args else cb() mock_glib.idle_add = lambda cb, *args: cb(*args) if args else cb()
queue.submit(task, on_complete=on_complete) queue.submit(task, on_complete=on_complete)
# Wait for task to complete (may need more time under load) # Wait for task to complete (may need more time under load)
for _ in range(10): for _ in range(10):
if 42 in results: if 42 in results:
break break
time.sleep(0.05) time.sleep(0.05)
assert 42 in results assert 42 in results
TaskQueue.reset_instance() TaskQueue.reset_instance()
def test_on_error_callback(self): def test_on_error_callback(self):
"""Test that on_error callback is called on exception.""" """Test that on_error callback is called on exception."""
from src.network.tasks import TaskQueue from src.network.tasks import TaskQueue
TaskQueue.reset_instance() TaskQueue.reset_instance()
queue = TaskQueue() queue = TaskQueue()
errors = [] errors = []
def failing_task(): def failing_task():
raise ValueError("Test error") raise ValueError("Test error")
def on_error(e): def on_error(e):
errors.append(str(e)) errors.append(str(e))
with patch('src.network.tasks.GLib') as mock_glib: with patch('src.network.tasks.GLib') as mock_glib:
mock_glib.idle_add = lambda cb, *args: cb(*args) if args else cb() mock_glib.idle_add = lambda cb, *args: cb(*args) if args else cb()
queue.submit(failing_task, on_error=on_error) queue.submit(failing_task, on_error=on_error)
# Wait for task to complete (may need more time under load) # Wait for task to complete (may need more time under load)
for _ in range(10): for _ in range(10):
if len(errors) == 1: if len(errors) == 1:
break break
time.sleep(0.05) time.sleep(0.05)
assert len(errors) == 1 assert len(errors) == 1
assert "Test error" in errors[0] assert "Test error" in errors[0]
TaskQueue.reset_instance() TaskQueue.reset_instance()
def test_cancel_task(self): def test_cancel_task(self):
"""Test task cancellation.""" """Test task cancellation."""
from src.network.tasks import TaskQueue from src.network.tasks import TaskQueue
TaskQueue.reset_instance() TaskQueue.reset_instance()
queue = TaskQueue() queue = TaskQueue()
result = [] result = []
def slow_task(): def slow_task():
time.sleep(1) time.sleep(1)
result.append("completed") result.append("completed")
return True return True
with patch('src.network.tasks.GLib') as mock_glib: with patch('src.network.tasks.GLib') as mock_glib:
mock_glib.idle_add = lambda cb, *args: cb(*args) if args else cb() mock_glib.idle_add = lambda cb, *args: cb(*args) if args else cb()
task_id = queue.submit(slow_task) task_id = queue.submit(slow_task)
# Cancel immediately # Cancel immediately
cancelled = queue.cancel(task_id) cancelled = queue.cancel(task_id)
# May or may not be cancellable depending on timing # May or may not be cancellable depending on timing
assert isinstance(cancelled, bool) assert isinstance(cancelled, bool)
# Wait briefly # Wait briefly
time.sleep(0.1) time.sleep(0.1)
TaskQueue.reset_instance() TaskQueue.reset_instance()
def test_pending_count(self): def test_pending_count(self):
"""Test pending task count.""" """Test pending task count."""
from src.network.tasks import TaskQueue from src.network.tasks import TaskQueue
TaskQueue.reset_instance() TaskQueue.reset_instance()
queue = TaskQueue() queue = TaskQueue()
initial_count = queue.pending_count initial_count = queue.pending_count
assert initial_count >= 0 assert initial_count >= 0
TaskQueue.reset_instance() TaskQueue.reset_instance()
class TestAsyncImageLoading: class TestAsyncImageLoading:
"""Tests for async image loading.""" """Tests for async image loading."""
def test_load_image_async_cached(self): def test_load_image_async_cached(self):
"""Test that cached images return -1 (no task needed).""" """Test that cached images return -1 (no task needed)."""
from src.network.images import load_image_async, load_image, ImageCache from src.network.images import load_image_async, load_image, ImageCache
# Clear cache # Clear cache
ImageCache().clear() ImageCache().clear()
# Load an image synchronously first (to cache it) # Load an image synchronously first (to cache it)
data_url = ( data_url = (
"data:image/png;base64," "data:image/png;base64,"
@ -192,43 +191,43 @@ class TestAsyncImageLoading:
) )
image = load_image(data_url) image = load_image(data_url)
assert image is not None assert image is not None
# Now load async - should hit cache and return -1 (no task) # Now load async - should hit cache and return -1 (no task)
# We don't need a callback for this test - just checking return value # We don't need a callback for this test - just checking return value
task_id = load_image_async(data_url, on_complete=None) task_id = load_image_async(data_url, on_complete=None)
# Cached loads return -1 (no task created) # Cached loads return -1 (no task created)
assert task_id == -1 assert task_id == -1
def test_load_image_async_uncached(self): def test_load_image_async_uncached(self):
"""Test that uncached images create tasks.""" """Test that uncached images create tasks."""
from src.network.images import load_image_async, ImageCache from src.network.images import load_image_async, ImageCache
from src.network.tasks import TaskQueue from src.network.tasks import TaskQueue
# Clear cache # Clear cache
ImageCache().clear() ImageCache().clear()
TaskQueue.reset_instance() TaskQueue.reset_instance()
# Use a data URL that's not cached # Use a data URL that's not cached
data_url = ( data_url = (
"data:image/png;base64," "data:image/png;base64,"
"iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAADklEQVR42mP8z8DwHwYAAQYBA/5h2aw4AAAAAElFTkSuQmCC" "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAADklEQVR42mP8z8DwHwYAAQYBA/5h2aw4AAAAAElFTkSuQmCC"
) )
# Patch GLib.idle_add to call callbacks immediately (no GTK main loop in tests) # Patch GLib.idle_add to call callbacks immediately (no GTK main loop in tests)
with patch('src.network.tasks.GLib') as mock_glib: with patch('src.network.tasks.GLib') as mock_glib:
mock_glib.idle_add = lambda cb, *args: cb(*args) if args else cb() mock_glib.idle_add = lambda cb, *args: cb(*args) if args else cb()
# Without a callback, it just submits the task # Without a callback, it just submits the task
task_id = load_image_async(data_url, on_complete=None) task_id = load_image_async(data_url, on_complete=None)
# Should create a task (non-negative ID) # Should create a task (non-negative ID)
assert task_id >= 0 assert task_id >= 0
# Wait for task to complete # Wait for task to complete
time.sleep(0.3) time.sleep(0.3)
# Image should now be cached # Image should now be cached
assert ImageCache().has(data_url) assert ImageCache().has(data_url)
TaskQueue.reset_instance() TaskQueue.reset_instance()