mirror of
https://github.com/Hopiu/bowser.git
synced 2026-03-16 19:10:24 +00:00
This commit introduces several enhancements to the browser rendering engine. Key changes include the ability to handle link clicks, improved link detection, and enhanced color parsing for proper rendering of styled links. The following modifications are included:
- **Link Detection and Navigation**: Added functionality to detect if a mouse click falls within a link area. If a link is clicked, the browser now navigates to the corresponding URL while logging the action. This also includes handling relative URLs based on the current page context. - **Line Layout Enhancements**: The `LayoutLine` class now includes optional attributes for color and href, allowing links to maintain their designated colors in the rendered output. - **Color Parsing**: Implemented a new `_parse_color` method in the `RenderPipeline` class to convert various color formats (hex and named colors) to Skia-compatible values. This ensures that default link colors are correctly applied and that extremely light colors are rendered as black for visibility. - **Rendering Links**: During the rendering process, links in the text layout are now rendered with their specified colors, and an underline is drawn under links to indicate interactivity. - **Document Layout Updates**: The document parsing system has been updated to extract link information correctly while preserving text hierarchy. - **Tests**: A comprehensive suite of tests has been added, including tests for link parsing, layout characteristics, styling application, and default color handling for links.
This commit is contained in:
parent
762dd22e31
commit
8c2d360515
16 changed files with 1012 additions and 431 deletions
|
|
@ -736,11 +736,32 @@ class Chrome:
|
|||
self.drawing_area.grab_focus()
|
||||
|
||||
def _on_mouse_released(self, gesture, n_press, x, y):
|
||||
"""Handle mouse button release for text selection."""
|
||||
"""Handle mouse button release for text selection or link clicks."""
|
||||
click_x = x
|
||||
click_y = y + self.scroll_y
|
||||
|
||||
if self.is_selecting:
|
||||
self.selection_end = (x, y + self.scroll_y)
|
||||
self.selection_end = (click_x, click_y)
|
||||
self.is_selecting = False
|
||||
# Extract selected text
|
||||
|
||||
# Check if this is a click (not a drag)
|
||||
if self.selection_start:
|
||||
dx = abs(click_x - self.selection_start[0])
|
||||
dy = abs(click_y - self.selection_start[1])
|
||||
is_click = dx < 5 and dy < 5
|
||||
|
||||
if is_click:
|
||||
# Check if we clicked on a link
|
||||
href = self._get_link_at_position(click_x, click_y)
|
||||
if href:
|
||||
self.logger.info(f"Link clicked: {href}")
|
||||
self._navigate_to_link(href)
|
||||
# Clear selection since we're navigating
|
||||
self.selection_start = None
|
||||
self.selection_end = None
|
||||
return
|
||||
|
||||
# Extract selected text (for drag selection)
|
||||
selected_text = self._get_selected_text()
|
||||
if selected_text:
|
||||
self.logger.info(f"Selected text: {selected_text[:100]}...")
|
||||
|
|
@ -748,6 +769,46 @@ class Chrome:
|
|||
self._copy_to_clipboard(selected_text)
|
||||
self.paint()
|
||||
|
||||
def _get_link_at_position(self, x: float, y: float) -> str | None:
|
||||
"""Get the href of a link at the given position, or None."""
|
||||
for line_info in self.text_layout:
|
||||
line_top = line_info["y"]
|
||||
line_bottom = line_info["y"] + line_info["height"]
|
||||
line_left = line_info["x"]
|
||||
line_right = line_info["x"] + line_info["width"]
|
||||
|
||||
# Check if click is within this line's bounding box
|
||||
if line_top <= y <= line_bottom and line_left <= x <= line_right:
|
||||
href = line_info.get("href")
|
||||
if href:
|
||||
return href
|
||||
return None
|
||||
|
||||
def _navigate_to_link(self, href: str):
|
||||
"""Navigate to a link, handling relative URLs."""
|
||||
if not href:
|
||||
return
|
||||
|
||||
# Handle special URLs
|
||||
if href.startswith("#"):
|
||||
# Anchor link - for now just ignore (future: scroll to anchor)
|
||||
self.logger.debug(f"Ignoring anchor link: {href}")
|
||||
return
|
||||
|
||||
if href.startswith("javascript:"):
|
||||
# JavaScript URLs - ignore for security
|
||||
self.logger.debug(f"Ignoring javascript link: {href}")
|
||||
return
|
||||
|
||||
# Resolve relative URLs against current page URL
|
||||
if self.browser.active_tab and self.browser.active_tab.current_url:
|
||||
base_url = self.browser.active_tab.current_url
|
||||
resolved_url = base_url.resolve(href)
|
||||
self.browser.navigate_to(str(resolved_url))
|
||||
else:
|
||||
# No current URL, treat href as absolute
|
||||
self.browser.navigate_to(href)
|
||||
|
||||
def _on_mouse_motion(self, controller, x, y):
|
||||
"""Handle mouse motion for drag selection."""
|
||||
if self.is_selecting:
|
||||
|
|
|
|||
|
|
@ -8,12 +8,15 @@ from .embed import ImageLayout
|
|||
class LayoutLine:
|
||||
"""A laid-out line ready for rendering."""
|
||||
|
||||
def __init__(self, text: str, x: float, y: float, font_size: int, char_positions: list = None, font_family: str = ""):
|
||||
def __init__(self, text: str, x: float, y: float, font_size: int,
|
||||
char_positions: list = None, font_family: str = "", color: str = None, href: str = None):
|
||||
self.text = text
|
||||
self.x = x
|
||||
self.y = y # Top of line
|
||||
self.font_size = font_size
|
||||
self.font_family = font_family
|
||||
self.color = color # Text color (e.g., "#0066cc" for links)
|
||||
self.href = href # Link target URL if this is a link
|
||||
self.height = linespace(font_size)
|
||||
self.width = 0
|
||||
self.char_positions = char_positions or []
|
||||
|
|
@ -123,6 +126,8 @@ class DocumentLayout:
|
|||
margin_bottom = block_info.get("margin_bottom", 10)
|
||||
block_type = block_info.get("block_type", "block")
|
||||
tag = block_info.get("tag", "")
|
||||
color = block_info.get("color") # Text color from style
|
||||
href = block_info.get("href") # Link target URL
|
||||
|
||||
if not text:
|
||||
y += font_size * 0.6
|
||||
|
|
@ -172,7 +177,9 @@ class DocumentLayout:
|
|||
y=y, # Top of line, baseline is y + font_size
|
||||
font_size=font_size,
|
||||
char_positions=char_positions,
|
||||
font_family=font_family
|
||||
font_family=font_family,
|
||||
color=color,
|
||||
href=href
|
||||
)
|
||||
|
||||
layout_block.lines.append(layout_line)
|
||||
|
|
@ -257,10 +264,57 @@ class DocumentLayout:
|
|||
blocks.extend(self._collect_blocks(child))
|
||||
continue
|
||||
|
||||
# For other elements (p, h1, etc), first collect any embedded images
|
||||
# Inline elements inside block elements are handled by _text_of
|
||||
# Only create separate blocks for inline elements if they're direct
|
||||
# children of container elements (handled above via recursion)
|
||||
if tag in {"span", "strong", "em", "b", "i", "code"}:
|
||||
# Skip - these are handled as part of parent's text
|
||||
continue
|
||||
|
||||
# Handle anchor elements - they can be inline or standalone
|
||||
if tag == "a":
|
||||
# Get the href and treat this as a clickable block
|
||||
href = child.attributes.get("href")
|
||||
content = self._text_of(child)
|
||||
if not content:
|
||||
continue
|
||||
|
||||
style = getattr(child, "computed_style", None)
|
||||
if style:
|
||||
font_size = style.get_int("font-size", 14)
|
||||
color = style.get("color")
|
||||
font_family = style.get("font-family", "")
|
||||
else:
|
||||
font_size = 14
|
||||
color = None
|
||||
font_family = ""
|
||||
|
||||
# Default link color
|
||||
if not color:
|
||||
color = "#0066cc"
|
||||
|
||||
blocks.append({
|
||||
"text": content,
|
||||
"font_size": font_size,
|
||||
"font_family": font_family,
|
||||
"margin_top": 0,
|
||||
"margin_bottom": 0,
|
||||
"block_type": "inline",
|
||||
"tag": tag,
|
||||
"bullet": False,
|
||||
"style": style,
|
||||
"color": color,
|
||||
"href": href
|
||||
})
|
||||
continue
|
||||
|
||||
# For block elements (p, h1, etc), first collect any embedded images
|
||||
embedded_images = self._collect_images(child)
|
||||
blocks.extend(embedded_images)
|
||||
|
||||
# Check if this element contains only a link
|
||||
link_info = self._extract_single_link(child)
|
||||
|
||||
content = self._text_of(child)
|
||||
if not content:
|
||||
continue
|
||||
|
|
@ -275,6 +329,7 @@ class DocumentLayout:
|
|||
margin_bottom = style.get_int("margin-bottom", 10)
|
||||
display = style.get("display", "block")
|
||||
font_family = style.get("font-family", "")
|
||||
color = style.get("color") # Get text color from style
|
||||
else:
|
||||
# Fallback to hardcoded defaults
|
||||
font_size = self._get_default_font_size(tag)
|
||||
|
|
@ -282,6 +337,14 @@ class DocumentLayout:
|
|||
margin_bottom = self._get_default_margin_bottom(tag)
|
||||
display = "inline" if tag in {"span", "a", "strong", "em", "b", "i", "code"} else "block"
|
||||
font_family = ""
|
||||
color = None
|
||||
|
||||
# If block contains only a link, use link info for href and color
|
||||
href = None
|
||||
if link_info:
|
||||
href = link_info.get("href")
|
||||
if not color:
|
||||
color = link_info.get("color", "#0066cc")
|
||||
|
||||
# Determine block type
|
||||
block_type = "inline" if display == "inline" else "block"
|
||||
|
|
@ -300,7 +363,9 @@ class DocumentLayout:
|
|||
"block_type": block_type,
|
||||
"tag": tag,
|
||||
"bullet": bullet,
|
||||
"style": style
|
||||
"style": style,
|
||||
"color": color,
|
||||
"href": href
|
||||
})
|
||||
|
||||
return blocks
|
||||
|
|
@ -326,6 +391,42 @@ class DocumentLayout:
|
|||
}
|
||||
return margins.get(tag, 0)
|
||||
|
||||
def _extract_single_link(self, node) -> dict | None:
|
||||
"""Extract link info if node contains only a single link.
|
||||
|
||||
Returns dict with href and color if the element contains only
|
||||
a link (possibly with some whitespace text), None otherwise.
|
||||
"""
|
||||
if not isinstance(node, Element):
|
||||
return None
|
||||
|
||||
links = []
|
||||
has_other_content = False
|
||||
|
||||
for child in node.children:
|
||||
if isinstance(child, Text):
|
||||
# Whitespace-only text is okay
|
||||
if child.text.strip():
|
||||
has_other_content = True
|
||||
elif isinstance(child, Element):
|
||||
if child.tag.lower() == "a":
|
||||
links.append(child)
|
||||
else:
|
||||
# Has other elements besides links
|
||||
has_other_content = True
|
||||
|
||||
# Return link info only if there's exactly one link and no other content
|
||||
if len(links) == 1 and not has_other_content:
|
||||
link = links[0]
|
||||
style = getattr(link, "computed_style", None)
|
||||
color = style.get("color") if style else None
|
||||
return {
|
||||
"href": link.attributes.get("href"),
|
||||
"color": color or "#0066cc"
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
def _collect_images(self, node) -> list:
|
||||
"""Recursively collect all img elements from a node."""
|
||||
images = []
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import logging
|
|||
from typing import Optional, Callable
|
||||
import skia
|
||||
|
||||
from ..network.images import load_image, load_image_async, ImageCache
|
||||
from ..network.images import load_image, load_image_async
|
||||
|
||||
|
||||
logger = logging.getLogger("bowser.layout.embed")
|
||||
|
|
|
|||
|
|
@ -3,13 +3,12 @@
|
|||
import logging
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor, Future
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Optional, Any
|
||||
from queue import Queue
|
||||
import gi
|
||||
|
||||
gi.require_version("GLib", "2.0")
|
||||
from gi.repository import GLib
|
||||
from gi.repository import GLib # noqa: E402
|
||||
|
||||
logger = logging.getLogger("bowser.tasks")
|
||||
|
||||
|
|
|
|||
|
|
@ -129,6 +129,11 @@ class _DOMBuilder(HTMLParser):
|
|||
if self.current is self.root:
|
||||
self._ensure_body()
|
||||
|
||||
# Handle implicit closure for certain elements
|
||||
# A new <p> tag closes any open <p> tag (HTML5 implicit paragraph closure)
|
||||
if tag == "p" and self.current.tag == "p":
|
||||
self._pop("p")
|
||||
|
||||
self._push(el)
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
|
|
@ -193,7 +198,6 @@ def parse_html_with_styles(html_text: str, apply_styles: bool = True) -> Element
|
|||
"""
|
||||
from .css import parse as parse_css
|
||||
from .style import StyleResolver
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Parse HTML
|
||||
|
|
|
|||
|
|
@ -110,7 +110,25 @@ class RenderPipeline:
|
|||
continue
|
||||
|
||||
font = get_font(line.font_size, getattr(line, "font_family", ""), text=line.text)
|
||||
canvas.drawString(line.text, line.x, baseline_y, font, self._text_paint)
|
||||
|
||||
# Use line color if specified (for links), otherwise black
|
||||
paint = skia.Paint()
|
||||
paint.setAntiAlias(True)
|
||||
if line.color:
|
||||
paint.setColor(self._parse_color(line.color))
|
||||
else:
|
||||
paint.setColor(skia.ColorBLACK)
|
||||
|
||||
canvas.drawString(line.text, line.x, baseline_y, font, paint)
|
||||
|
||||
# Draw underline for links
|
||||
if line.href:
|
||||
underline_paint = skia.Paint()
|
||||
underline_paint.setColor(paint.getColor())
|
||||
underline_paint.setStyle(skia.Paint.kStroke_Style)
|
||||
underline_paint.setStrokeWidth(1)
|
||||
underline_y = baseline_y + 2
|
||||
canvas.drawLine(line.x, underline_y, line.x + line.width, underline_y, underline_paint)
|
||||
|
||||
# Render visible images (both loaded and placeholder)
|
||||
for layout_image in layout.images:
|
||||
|
|
@ -188,8 +206,8 @@ class RenderPipeline:
|
|||
|
||||
def get_text_layout(self) -> list:
|
||||
"""
|
||||
Get the text layout for text selection.
|
||||
Returns list of line info dicts with char_positions.
|
||||
Get the text layout for text selection and link hit testing.
|
||||
Returns list of line info dicts with char_positions and href.
|
||||
"""
|
||||
if self._layout is None:
|
||||
return []
|
||||
|
|
@ -203,7 +221,8 @@ class RenderPipeline:
|
|||
"width": line.width,
|
||||
"height": line.height,
|
||||
"font_size": line.font_size,
|
||||
"char_positions": line.char_positions
|
||||
"char_positions": line.char_positions,
|
||||
"href": getattr(line, "href", None)
|
||||
})
|
||||
return result
|
||||
|
||||
|
|
@ -218,3 +237,63 @@ class RenderPipeline:
|
|||
self._layout = None
|
||||
self._layout_doc_id = None
|
||||
self._display_list = None
|
||||
|
||||
def _parse_color(self, color_str: str) -> int:
|
||||
"""Parse a CSS color string to a Skia color value.
|
||||
|
||||
Supports:
|
||||
- Hex colors: #rgb, #rrggbb
|
||||
- Named colors (limited set)
|
||||
|
||||
Note: Very light colors (like white) that would be invisible on
|
||||
our white background are converted to black.
|
||||
"""
|
||||
if not color_str:
|
||||
return skia.ColorBLACK
|
||||
|
||||
color_str = color_str.strip().lower()
|
||||
|
||||
# Named colors
|
||||
named_colors = {
|
||||
"black": skia.ColorBLACK,
|
||||
"white": skia.ColorBLACK, # White is invisible on white bg, use black
|
||||
"red": skia.ColorRED,
|
||||
"green": skia.ColorGREEN,
|
||||
"blue": skia.ColorBLUE,
|
||||
"yellow": skia.ColorYELLOW,
|
||||
"cyan": skia.ColorCYAN,
|
||||
"magenta": skia.ColorMAGENTA,
|
||||
"gray": skia.ColorGRAY,
|
||||
"grey": skia.ColorGRAY,
|
||||
}
|
||||
|
||||
if color_str in named_colors:
|
||||
return named_colors[color_str]
|
||||
|
||||
# Hex colors
|
||||
if color_str.startswith("#"):
|
||||
hex_str = color_str[1:]
|
||||
try:
|
||||
if len(hex_str) == 3:
|
||||
# #rgb -> #rrggbb
|
||||
r = int(hex_str[0] * 2, 16)
|
||||
g = int(hex_str[1] * 2, 16)
|
||||
b = int(hex_str[2] * 2, 16)
|
||||
elif len(hex_str) == 6:
|
||||
r = int(hex_str[0:2], 16)
|
||||
g = int(hex_str[2:4], 16)
|
||||
b = int(hex_str[4:6], 16)
|
||||
else:
|
||||
return skia.ColorBLACK
|
||||
|
||||
# Check if color is too light (would be invisible on white)
|
||||
# Use relative luminance approximation
|
||||
if r > 240 and g > 240 and b > 240:
|
||||
return skia.ColorBLACK
|
||||
|
||||
return skia.Color(r, g, b, 255)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Fallback to black
|
||||
return skia.ColorBLACK
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
"""Tests for CSS parsing and style computation."""
|
||||
|
||||
import pytest
|
||||
from src.parser.css import (
|
||||
Selector, CSSRule, CSSParser, parse, parse_inline_style
|
||||
Selector, parse, parse_inline_style
|
||||
)
|
||||
from src.parser.html import Element, Text
|
||||
from src.parser.style import (
|
||||
ComputedStyle, StyleResolver, DEFAULT_STYLES, INHERITED_PROPERTIES
|
||||
ComputedStyle, StyleResolver
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
"""Tests for image loading and rendering."""
|
||||
|
||||
import pytest
|
||||
import skia
|
||||
from src.network.images import load_image, ImageCache, _load_data_url
|
||||
from src.network.images import ImageCache, _load_data_url
|
||||
from src.layout.embed import ImageLayout
|
||||
from src.parser.html import Element, parse_html
|
||||
from src.render.paint import DrawImage
|
||||
from src.layout.document import DocumentLayout, LayoutImage
|
||||
from io import BytesIO
|
||||
|
||||
|
||||
def create_test_image(width=100, height=100):
|
||||
|
|
|
|||
341
tests/test_links.py
Normal file
341
tests/test_links.py
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
"""Tests for link parsing, rendering, and navigation."""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.parser.html import parse_html, parse_html_with_styles, Element, Text
|
||||
from src.layout.document import DocumentLayout, LayoutLine
|
||||
from src.network.url import URL
|
||||
|
||||
|
||||
class TestLinkParsing:
|
||||
"""Tests for parsing anchor elements from HTML."""
|
||||
|
||||
def test_parse_simple_link(self):
|
||||
"""Test parsing a simple anchor tag."""
|
||||
html = "<html><body><a href='https://example.com'>Click here</a></body></html>"
|
||||
root = parse_html(html)
|
||||
|
||||
# Find the anchor element
|
||||
body = root.children[0]
|
||||
assert body.tag == "body"
|
||||
anchor = body.children[0]
|
||||
assert anchor.tag == "a"
|
||||
assert anchor.attributes.get("href") == "https://example.com"
|
||||
|
||||
def test_parse_link_with_text(self):
|
||||
"""Test that link text content is preserved."""
|
||||
html = "<html><body><a href='/page'>Link Text</a></body></html>"
|
||||
root = parse_html(html)
|
||||
|
||||
body = root.children[0]
|
||||
anchor = body.children[0]
|
||||
assert len(anchor.children) == 1
|
||||
assert isinstance(anchor.children[0], Text)
|
||||
assert anchor.children[0].text.strip() == "Link Text"
|
||||
|
||||
def test_parse_link_in_paragraph(self):
|
||||
"""Test parsing a link inside a paragraph."""
|
||||
html = "<html><body><p>Visit <a href='https://test.com'>our site</a> today!</p></body></html>"
|
||||
root = parse_html(html)
|
||||
|
||||
body = root.children[0]
|
||||
# The parser may flatten this - check for anchor presence
|
||||
found_anchor = False
|
||||
|
||||
def find_anchor(node):
|
||||
nonlocal found_anchor
|
||||
if isinstance(node, Element) and node.tag == "a":
|
||||
found_anchor = True
|
||||
assert node.attributes.get("href") == "https://test.com"
|
||||
if hasattr(node, "children"):
|
||||
for child in node.children:
|
||||
find_anchor(child)
|
||||
|
||||
find_anchor(body)
|
||||
assert found_anchor, "Anchor element not found"
|
||||
|
||||
def test_parse_link_with_relative_href(self):
|
||||
"""Test parsing a link with a relative URL."""
|
||||
html = "<html><body><a href='/about'>About</a></body></html>"
|
||||
root = parse_html(html)
|
||||
|
||||
body = root.children[0]
|
||||
anchor = body.children[0]
|
||||
assert anchor.attributes.get("href") == "/about"
|
||||
|
||||
def test_parse_link_with_anchor_href(self):
|
||||
"""Test parsing a link with an anchor reference."""
|
||||
html = "<html><body><a href='#section'>Jump</a></body></html>"
|
||||
root = parse_html(html)
|
||||
|
||||
body = root.children[0]
|
||||
anchor = body.children[0]
|
||||
assert anchor.attributes.get("href") == "#section"
|
||||
|
||||
|
||||
class TestLinkLayout:
|
||||
"""Tests for link layout and styling."""
|
||||
|
||||
def test_link_layout_has_href(self):
|
||||
"""Test that layout lines for links include href."""
|
||||
html = "<html><body><a href='https://example.com'>Link</a></body></html>"
|
||||
root = parse_html_with_styles(html)
|
||||
|
||||
layout = DocumentLayout(root)
|
||||
layout.layout(800)
|
||||
|
||||
# Find line with href
|
||||
link_lines = [line for line in layout.lines if line.href]
|
||||
assert len(link_lines) > 0, "No link lines found"
|
||||
assert link_lines[0].href == "https://example.com"
|
||||
|
||||
def test_link_layout_has_color(self):
|
||||
"""Test that layout lines for links have a color."""
|
||||
html = "<html><body><a href='https://example.com'>Link</a></body></html>"
|
||||
root = parse_html_with_styles(html)
|
||||
|
||||
layout = DocumentLayout(root)
|
||||
layout.layout(800)
|
||||
|
||||
# Find line with color
|
||||
link_lines = [line for line in layout.lines if line.href]
|
||||
assert len(link_lines) > 0
|
||||
# Should have either CSS-specified color or default link color
|
||||
assert link_lines[0].color is not None
|
||||
|
||||
def test_non_link_has_no_href(self):
|
||||
"""Test that non-link elements don't have href."""
|
||||
html = "<html><body><p>Regular paragraph</p></body></html>"
|
||||
root = parse_html_with_styles(html)
|
||||
|
||||
layout = DocumentLayout(root)
|
||||
layout.layout(800)
|
||||
|
||||
# All lines should have no href
|
||||
for line in layout.lines:
|
||||
assert line.href is None
|
||||
|
||||
def test_layout_line_constructor(self):
|
||||
"""Test LayoutLine constructor with color and href."""
|
||||
line = LayoutLine(
|
||||
text="Click me",
|
||||
x=10,
|
||||
y=20,
|
||||
font_size=14,
|
||||
color="#0066cc",
|
||||
href="https://example.com"
|
||||
)
|
||||
|
||||
assert line.text == "Click me"
|
||||
assert line.color == "#0066cc"
|
||||
assert line.href == "https://example.com"
|
||||
|
||||
def test_layout_line_default_values(self):
|
||||
"""Test LayoutLine defaults for color and href."""
|
||||
line = LayoutLine(
|
||||
text="Normal text",
|
||||
x=10,
|
||||
y=20,
|
||||
font_size=14
|
||||
)
|
||||
|
||||
assert line.color is None
|
||||
assert line.href is None
|
||||
|
||||
|
||||
class TestLinkURLResolution:
|
||||
"""Tests for URL resolution of links."""
|
||||
|
||||
def test_resolve_absolute_url(self):
|
||||
"""Test that absolute URLs are preserved."""
|
||||
base = URL("https://example.com/page")
|
||||
resolved = base.resolve("https://other.com/path")
|
||||
assert str(resolved) == "https://other.com/path"
|
||||
|
||||
def test_resolve_relative_url(self):
|
||||
"""Test resolving a relative URL."""
|
||||
base = URL("https://example.com/page")
|
||||
resolved = base.resolve("/about")
|
||||
assert str(resolved) == "https://example.com/about"
|
||||
|
||||
def test_resolve_relative_path(self):
|
||||
"""Test resolving a relative path."""
|
||||
base = URL("https://example.com/dir/page")
|
||||
resolved = base.resolve("other")
|
||||
assert str(resolved) == "https://example.com/dir/other"
|
||||
|
||||
def test_resolve_parent_relative(self):
|
||||
"""Test resolving a parent-relative path."""
|
||||
base = URL("https://example.com/dir/subdir/page")
|
||||
resolved = base.resolve("../other")
|
||||
assert str(resolved) == "https://example.com/dir/other"
|
||||
|
||||
def test_resolve_anchor_only(self):
|
||||
"""Test resolving an anchor-only URL."""
|
||||
base = URL("https://example.com/page")
|
||||
resolved = base.resolve("#section")
|
||||
assert str(resolved) == "https://example.com/page#section"
|
||||
|
||||
def test_resolve_query_string(self):
|
||||
"""Test resolving a URL with query string."""
|
||||
base = URL("https://example.com/page")
|
||||
resolved = base.resolve("?query=value")
|
||||
assert str(resolved) == "https://example.com/page?query=value"
|
||||
|
||||
|
||||
class TestRenderPipelineColorParsing:
|
||||
"""Tests for color parsing in the render pipeline.
|
||||
|
||||
Note: These tests only run when skia is NOT mocked (i.e., when run in isolation).
|
||||
When run after test_render.py, skia becomes a MagicMock and these tests are skipped.
|
||||
"""
|
||||
|
||||
def test_parse_hex_color_6digit(self):
|
||||
"""Test parsing 6-digit hex colors."""
|
||||
from src.render.pipeline import RenderPipeline
|
||||
import skia
|
||||
|
||||
# Skip if skia is mocked
|
||||
if hasattr(skia.Color, '_mock_name'):
|
||||
pytest.skip("skia is mocked")
|
||||
|
||||
pipeline = RenderPipeline()
|
||||
color = pipeline._parse_color("#0066cc")
|
||||
|
||||
# Extract RGB components (Skia color is ARGB)
|
||||
r = (color >> 16) & 0xFF
|
||||
g = (color >> 8) & 0xFF
|
||||
b = color & 0xFF
|
||||
|
||||
assert r == 0x00
|
||||
assert g == 0x66
|
||||
assert b == 0xcc
|
||||
|
||||
def test_parse_hex_color_3digit(self):
|
||||
"""Test parsing 3-digit hex colors."""
|
||||
from src.render.pipeline import RenderPipeline
|
||||
import skia
|
||||
|
||||
# Skip if skia is mocked
|
||||
if hasattr(skia.Color, '_mock_name'):
|
||||
pytest.skip("skia is mocked")
|
||||
|
||||
pipeline = RenderPipeline()
|
||||
color = pipeline._parse_color("#abc")
|
||||
|
||||
# #abc should expand to #aabbcc
|
||||
r = (color >> 16) & 0xFF
|
||||
g = (color >> 8) & 0xFF
|
||||
b = color & 0xFF
|
||||
|
||||
# Each digit is doubled: a->aa, b->bb, c->cc
|
||||
# But our implementation uses int("a" * 2, 16) which is int("aa", 16) = 170
|
||||
assert r == 0xaa
|
||||
assert g == 0xbb
|
||||
assert b == 0xcc
|
||||
|
||||
def test_parse_named_color(self):
|
||||
"""Test parsing named colors."""
|
||||
from src.render.pipeline import RenderPipeline
|
||||
import skia
|
||||
|
||||
# Skip if skia is mocked
|
||||
if hasattr(skia.ColorBLACK, '_mock_name'):
|
||||
pytest.skip("skia is mocked")
|
||||
|
||||
pipeline = RenderPipeline()
|
||||
|
||||
# Test that named colors return a valid integer color value
|
||||
black = pipeline._parse_color("black")
|
||||
white = pipeline._parse_color("white")
|
||||
red = pipeline._parse_color("red")
|
||||
|
||||
# Black should be 0xFF000000 (opaque black in ARGB)
|
||||
assert isinstance(black, int)
|
||||
assert (black & 0xFFFFFF) == 0x000000 # RGB is 0
|
||||
|
||||
# White is converted to black because it would be invisible on white bg
|
||||
assert isinstance(white, int)
|
||||
assert (white & 0xFFFFFF) == 0x000000 # Converted to black
|
||||
|
||||
# Red should have R=255, G=0, B=0
|
||||
assert isinstance(red, int)
|
||||
r = (red >> 16) & 0xFF
|
||||
assert r == 0xFF # Red component should be 255
|
||||
|
||||
def test_parse_invalid_color_returns_black(self):
|
||||
"""Test that invalid colors return black."""
|
||||
from src.render.pipeline import RenderPipeline
|
||||
import skia
|
||||
|
||||
# Skip if skia is mocked
|
||||
if hasattr(skia.ColorBLACK, '_mock_name'):
|
||||
pytest.skip("skia is mocked")
|
||||
|
||||
pipeline = RenderPipeline()
|
||||
|
||||
invalid = pipeline._parse_color("invalid")
|
||||
invalid2 = pipeline._parse_color("#xyz")
|
||||
invalid3 = pipeline._parse_color("")
|
||||
|
||||
# All should return black (integer value)
|
||||
assert isinstance(invalid, int)
|
||||
assert isinstance(invalid2, int)
|
||||
assert isinstance(invalid3, int)
|
||||
|
||||
# RGB components should be 0 (black)
|
||||
assert (invalid & 0xFFFFFF) == 0x000000
|
||||
|
||||
|
||||
class TestGetTextLayoutWithHref:
|
||||
"""Tests for text layout including href information."""
|
||||
|
||||
def test_get_text_layout_includes_href(self):
|
||||
"""Test that get_text_layout includes href for links."""
|
||||
from src.render.pipeline import RenderPipeline
|
||||
|
||||
html = "<html><body><a href='https://example.com'>Click</a></body></html>"
|
||||
root = parse_html_with_styles(html)
|
||||
|
||||
pipeline = RenderPipeline()
|
||||
pipeline.layout(root, 800)
|
||||
|
||||
text_layout = pipeline.get_text_layout()
|
||||
|
||||
# Find the link line
|
||||
link_entries = [entry for entry in text_layout if entry.get("href")]
|
||||
assert len(link_entries) > 0
|
||||
assert link_entries[0]["href"] == "https://example.com"
|
||||
|
||||
def test_get_text_layout_normal_text_no_href(self):
|
||||
"""Test that normal text has no href in layout."""
|
||||
from src.render.pipeline import RenderPipeline
|
||||
|
||||
html = "<html><body><p>Normal text</p></body></html>"
|
||||
root = parse_html_with_styles(html)
|
||||
|
||||
pipeline = RenderPipeline()
|
||||
pipeline.layout(root, 800)
|
||||
|
||||
text_layout = pipeline.get_text_layout()
|
||||
|
||||
# All entries should have href=None
|
||||
for entry in text_layout:
|
||||
assert entry.get("href") is None
|
||||
|
||||
|
||||
class TestLinkDefaultStyling:
|
||||
"""Tests for default link styling from CSS."""
|
||||
|
||||
def test_link_default_color_in_css(self):
|
||||
"""Test that default.css defines link color."""
|
||||
from pathlib import Path
|
||||
|
||||
css_path = Path(__file__).parent.parent / "assets" / "default.css"
|
||||
assert css_path.exists(), "default.css should exist"
|
||||
|
||||
css_content = css_path.read_text()
|
||||
|
||||
# Check that 'a' selector is defined with a color
|
||||
assert "a {" in css_content or "a{" in css_content.replace(" ", "")
|
||||
assert "color:" in css_content
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"""Integration tests for CSS styling system."""
|
||||
|
||||
import pytest
|
||||
from src.parser.html import parse_html_with_styles, Element
|
||||
from src.parser.html import parse_html_with_styles
|
||||
from src.layout.document import DocumentLayout
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
"""Tests for the async task queue system."""
|
||||
|
||||
import pytest
|
||||
import time
|
||||
import threading
|
||||
from unittest.mock import Mock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class TestTaskQueue:
|
||||
|
|
@ -52,7 +51,7 @@ class TestTaskQueue:
|
|||
queue = TaskQueue()
|
||||
|
||||
result = []
|
||||
event = threading.Event()
|
||||
threading.Event()
|
||||
|
||||
def task():
|
||||
result.append("executed")
|
||||
|
|
|
|||
Loading…
Reference in a new issue