Added links
Python CI / test (push) Failing after 6m34s

This commit is contained in:
2025-11-04 13:39:21 +01:00
parent de18b1c2cc
commit 4fe5f8cf60
8 changed files with 805 additions and 34 deletions
+86
View File
@@ -1467,6 +1467,92 @@ class Image(Block):
return info
class LinkedImage(Image):
"""
An Image that is also a Link - clickable images that navigate or trigger callbacks.
"""
def __init__(self, source: str, alt_text: str, location: str,
width: Optional[int] = None, height: Optional[int] = None,
link_type = None,
callback: Optional[Any] = None,
params: Optional[Dict[str, Any]] = None,
title: Optional[str] = None):
"""
Initialize a linked image.
Args:
source: The image source URL or path
alt_text: Alternative text for accessibility
location: The link target (URL, bookmark, etc.)
width: Optional image width in pixels
height: Optional image height in pixels
link_type: Type of link (INTERNAL, EXTERNAL, etc.)
callback: Optional callback for link activation
params: Parameters for the link
title: Tooltip/title for the link
"""
# Initialize Image
super().__init__(source, alt_text, width, height)
# Store link properties
# Import here to avoid circular imports at module level
from pyWebLayout.abstract.functional import LinkType
self._location = location
self._link_type = link_type or LinkType.EXTERNAL
self._callback = callback
self._params = params or {}
self._link_title = title
@property
def location(self) -> str:
"""Get the link target location"""
return self._location
@property
def link_type(self):
"""Get the type of link"""
return self._link_type
@property
def link_callback(self) -> Optional[Any]:
"""Get the link callback"""
return self._callback
@property
def params(self) -> Dict[str, Any]:
"""Get the link parameters"""
return self._params
@property
def link_title(self) -> Optional[str]:
"""Get the link title/tooltip"""
return self._link_title
def execute_link(self, context: Optional[Dict[str, Any]] = None) -> Any:
"""
Execute the link action.
Args:
context: Optional context dict (e.g., {'alt_text': image.alt_text})
Returns:
The result of the link execution
"""
from pyWebLayout.abstract.functional import LinkType
# Add image info to context
full_context = {**self._params, 'alt_text': self._alt_text, 'source': self._source}
if context:
full_context.update(context)
if self._link_type in (LinkType.API, LinkType.FUNCTION) and self._callback:
return self._callback(self._location, **full_context)
else:
# For INTERNAL and EXTERNAL links, return the location
return self._location
class HorizontalRule(Block):
"""
A horizontal rule element (hr tag).
+88
View File
@@ -270,6 +270,94 @@ class FormattedSpan:
return word
class LinkedWord(Word):
"""
A Word that is also a Link - combines text content with hyperlink functionality.
When a word is part of a hyperlink, it becomes clickable and can trigger
navigation or callbacks. Multiple words can share the same link destination.
"""
def __init__(self, text: str, style: Union[Font, 'AbstractStyle'],
location: str, link_type: 'LinkType' = None,
callback: Optional[Callable] = None,
background=None, previous: Optional[Word] = None,
params: Optional[Dict[str, Any]] = None,
title: Optional[str] = None):
"""
Initialize a linked word.
Args:
text: The text content of the word
style: The font style
location: The link target (URL, bookmark, etc.)
link_type: Type of link (INTERNAL, EXTERNAL, etc.)
callback: Optional callback for link activation
background: Optional background color
previous: Previous word in sequence
params: Parameters for the link
title: Tooltip/title for the link
"""
# Initialize Word first
super().__init__(text, style, background, previous)
# Store link properties
from pyWebLayout.abstract.functional import LinkType
self._location = location
self._link_type = link_type or LinkType.EXTERNAL
self._callback = callback
self._params = params or {}
self._title = title
@property
def location(self) -> str:
"""Get the link target location"""
return self._location
@property
def link_type(self):
"""Get the type of link"""
return self._link_type
@property
def link_callback(self) -> Optional[Callable]:
"""Get the link callback (distinct from word callback)"""
return self._callback
@property
def params(self) -> Dict[str, Any]:
"""Get the link parameters"""
return self._params
@property
def link_title(self) -> Optional[str]:
"""Get the link title/tooltip"""
return self._title
def execute_link(self, context: Optional[Dict[str, Any]] = None) -> Any:
"""
Execute the link action.
Args:
context: Optional context dict (e.g., {'text': word.text})
Returns:
The result of the link execution
"""
from pyWebLayout.abstract.functional import LinkType
# Add word text to context
full_context = {**self._params, 'text': self._text}
if context:
full_context.update(context)
if self._link_type in (LinkType.API, LinkType.FUNCTION) and self._callback:
return self._callback(self._location, **full_context)
else:
# For INTERNAL and EXTERNAL links, return the location
return self._location
class LineBreak():
"""
A line break element that forces a new line within text content.
+21 -2
View File
@@ -383,14 +383,33 @@ class Line(Box):
- success: True if word/part was added, False if it couldn't fit
- overflow_text: Remaining text if word was hyphenated, None otherwise
"""
# Import LinkedWord here to avoid circular imports
from pyWebLayout.abstract.inline import LinkedWord
from pyWebLayout.concrete.functional import LinkText
# First, add any pretext from previous hyphenation
if part is not None:
self._text_objects.append(part)
self._words.append(word)
part.add_line(self)
# Try to add the full word
text = Text.from_word(word, self._draw)
# Try to add the full word - create LinkText for LinkedWord, regular Text otherwise
if isinstance(word, LinkedWord):
# Create a LinkText which includes the link functionality
# LinkText constructor needs: (link, text, font, draw, source, line)
# But LinkedWord itself contains the link properties
# We'll create a Link object from the LinkedWord properties
from pyWebLayout.abstract.functional import Link
link = Link(
location=word.location,
link_type=word.link_type,
callback=word.link_callback,
params=word.params,
title=word.link_title
)
text = LinkText(link, word.text, word.style, self._draw, source=word, line=self)
else:
text = Text.from_word(word, self._draw)
self._text_objects.append(text)
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
self._text_objects, self._size[0], self._spacing[0], self._spacing[1]
+45 -5
View File
@@ -348,15 +348,18 @@ def apply_background_styles(
def extract_text_content(element: Tag, context: StyleContext) -> List[Word]:
"""
Extract text content from an element, handling inline formatting.
Extract text content from an element, handling inline formatting and links.
Args:
element: BeautifulSoup Tag object
context: Current style context
Returns:
List of Word objects
List of Word objects (including LinkedWord for hyperlinks)
"""
from pyWebLayout.abstract.inline import LinkedWord
from pyWebLayout.abstract.functional import LinkType
words = []
for child in element.children:
@@ -369,10 +372,47 @@ def extract_text_content(element: Tag, context: StyleContext) -> List[Word]:
if word_text:
words.append(Word(word_text, context.font, context.background))
elif isinstance(child, Tag):
# Process inline elements
if child.name.lower() in [
# Special handling for <a> tags (hyperlinks)
if child.name.lower() == "a":
href = child.get('href', '')
if href:
# Determine link type based on href
if href.startswith(('http://', 'https://')):
link_type = LinkType.EXTERNAL
elif href.startswith('#'):
link_type = LinkType.INTERNAL
elif href.startswith('javascript:') or href.startswith('api:'):
link_type = LinkType.API
else:
link_type = LinkType.INTERNAL
# Apply link styling
child_context = apply_element_styling(context, child)
# Extract text and create LinkedWord for each word
link_text = child.get_text(strip=True)
title = child.get('title', '')
for word_text in link_text.split():
if word_text:
linked_word = LinkedWord(
text=word_text,
style=child_context.font,
location=href,
link_type=link_type,
background=child_context.background,
title=title if title else None
)
words.append(linked_word)
else:
# <a> without href - treat as normal text
child_context = apply_element_styling(context, child)
child_words = extract_text_content(child, child_context)
words.extend(child_words)
# Process other inline elements
elif child.name.lower() in [
"span",
"a",
"strong",
"b",
"em",
+151
View File
@@ -3,7 +3,11 @@ from __future__ import annotations
from typing import List, Tuple, Optional, Union
from pyWebLayout.concrete import Page, Line, Text
from pyWebLayout.concrete.image import RenderableImage
from pyWebLayout.concrete.functional import LinkText
from pyWebLayout.abstract import Paragraph, Word, Link
from pyWebLayout.abstract.block import Image as AbstractImage
from pyWebLayout.abstract.inline import LinkedWord
from pyWebLayout.style.concrete_style import ConcreteStyleRegistry, RenderingContext, StyleResolver
def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pretext: Optional[Text] = None, alignment_override: Optional['Alignment'] = None) -> Tuple[bool, Optional[int], Optional[Text]]:
@@ -130,6 +134,12 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
# Process words starting from start_word
for i, word in enumerate(paragraph.words[start_word:], start=start_word):
# Check if this is a LinkedWord and needs special handling in concrete layer
# Note: The Line.add_word method will create Text objects internally,
# but we may want to create LinkText for LinkedWord instances in future
# For now, the abstract layer (LinkedWord) carries the link info,
# and the concrete layer (LinkText) would be created during rendering
success, overflow_text = current_line.add_word(word, current_pretext)
if success:
@@ -191,3 +201,144 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
# All words processed successfully
return True, None, None
def image_layouter(image: AbstractImage, page: Page, max_width: Optional[int] = None,
max_height: Optional[int] = None) -> bool:
"""
Layout an image within a given page.
This function places an image on the page, respecting size constraints
and available space. Images are centered horizontally by default.
Args:
image: The abstract Image object to layout
page: The page to layout the image on
max_width: Maximum width constraint (defaults to page available width)
max_height: Maximum height constraint (defaults to remaining page height)
Returns:
bool: True if image was successfully laid out, False if page ran out of space
"""
from pyWebLayout.style import Alignment
# Use page available width if max_width not specified
if max_width is None:
max_width = page.available_width
# Calculate available height on page
available_height = page.size[1] - page._current_y_offset - page.border_size
if max_height is None:
max_height = available_height
else:
max_height = min(max_height, available_height)
# Calculate scaled dimensions
scaled_width, scaled_height = image.calculate_scaled_dimensions(max_width, max_height)
# Check if image fits on current page
if scaled_height is None or scaled_height > available_height:
return False
# Create renderable image
x_offset = page.border_size
y_offset = page._current_y_offset
renderable_image = RenderableImage(
image=image,
canvas=page.canvas,
max_width=max_width,
max_height=max_height,
origin=(x_offset, y_offset),
size=(scaled_width or max_width, scaled_height or max_height),
halign=Alignment.CENTER,
valign=Alignment.TOP
)
# Add to page
page.add_child(renderable_image)
return True
class DocumentLayouter:
"""
Document layouter that orchestrates layout of various abstract elements.
Delegates to specialized layouters for different content types:
- paragraph_layouter for text paragraphs
- image_layouter for images (future)
- table_layouter for tables (future)
This class acts as a coordinator, managing the overall document flow
and page context while delegating specific layout tasks to specialized
layouter functions.
"""
def __init__(self, page: Page):
"""
Initialize the document layouter with a page.
Args:
page: The page to layout content on
"""
self.page = page
self.style_registry = ConcreteStyleRegistry(page.style_resolver)
def layout_paragraph(self, paragraph: Paragraph, start_word: int = 0,
pretext: Optional[Text] = None) -> Tuple[bool, Optional[int], Optional[Text]]:
"""
Layout a paragraph using the paragraph_layouter.
Args:
paragraph: The paragraph to layout
start_word: Index of the first word to process (for continuation)
pretext: Optional pretext from a previous hyphenated word
Returns:
Tuple of (success, failed_word_index, remaining_pretext)
"""
return paragraph_layouter(paragraph, self.page, start_word, pretext)
def layout_image(self, image: AbstractImage, max_width: Optional[int] = None,
max_height: Optional[int] = None) -> bool:
"""
Layout an image using the image_layouter.
Args:
image: The abstract Image object to layout
max_width: Maximum width constraint (defaults to page available width)
max_height: Maximum height constraint (defaults to remaining page height)
Returns:
bool: True if image was successfully laid out, False if page ran out of space
"""
return image_layouter(image, self.page, max_width, max_height)
def layout_document(self, elements: List[Union[Paragraph, AbstractImage]]) -> bool:
"""
Layout a list of abstract elements (paragraphs and images).
This method delegates to specialized layouters based on element type:
- Paragraphs are handled by layout_paragraph
- Images are handled by layout_image
- Tables and other elements can be added in the future
Args:
elements: List of abstract elements to layout
Returns:
True if all elements were successfully laid out, False otherwise
"""
for element in elements:
if isinstance(element, Paragraph):
success, _, _ = self.layout_paragraph(element)
if not success:
return False
elif isinstance(element, AbstractImage):
success = self.layout_image(element)
if not success:
return False
# Future: elif isinstance(element, Table): use table_layouter
# Future: elif isinstance(element, CodeBlock): use code_layouter
return True