@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user