auto flake and corrections

This commit is contained in:
Duncan Tourolle 2025-11-08 23:46:15 +01:00
parent 1ea870eef5
commit 781a9b6c08
81 changed files with 4646 additions and 3718 deletions

View File

@ -11,6 +11,8 @@ This example demonstrates:
This is a foundational example showing the basic Page API.
"""
from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.concrete.page import Page
import sys
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
@ -18,9 +20,6 @@ from PIL import Image, ImageDraw, ImageFont
# Add pyWebLayout to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from pyWebLayout.concrete.page import Page
from pyWebLayout.style.page_style import PageStyle
def draw_placeholder_content(page: Page):
"""Draw some placeholder content directly on the page to visualize the layout."""
@ -46,13 +45,32 @@ def draw_placeholder_content(page: Page):
# Add some text labels
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 12)
except:
except BaseException:
font = ImageFont.load_default()
# Label the areas
draw.text((content_x + 10, content_y + 10), "Content Area", fill=(100, 100, 100), font=font)
draw.text((10, 10), f"Border: {page.border_size}px", fill=(150, 150, 150), font=font)
draw.text((content_x + 10, content_y + 30), f"Size: {content_w}x{content_h}", fill=(100, 100, 100), font=font)
draw.text(
(content_x + 10,
content_y + 10),
"Content Area",
fill=(
100,
100,
100),
font=font)
draw.text(
(10, 10), f"Border: {
page.border_size}px", fill=(
150, 150, 150), font=font)
draw.text(
(content_x + 10,
content_y + 30),
f"Size: {content_w}x{content_h}",
fill=(
100,
100,
100),
font=font)
def create_example_1():
@ -117,7 +135,7 @@ def create_example_4():
def combine_into_grid(pages, title):
"""Combine multiple pages into a 2x2 grid with title."""
print(f"\n Combining pages into grid...")
print("\n Combining pages into grid...")
# Render all pages
images = [page.render() for page in pages]
@ -141,8 +159,9 @@ def combine_into_grid(pages, title):
# Draw title
try:
title_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 20)
except:
title_font = ImageFont.truetype(
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 20)
except BaseException:
title_font = ImageFont.load_default()
# Center the title
@ -187,7 +206,7 @@ def main():
output_path = output_dir / "example_01_page_rendering.png"
combined_image.save(output_path)
print(f"\n✓ Example completed!")
print("\n✓ Example completed!")
print(f" Output saved to: {output_path}")
print(f" Image size: {combined_image.size[0]}x{combined_image.size[1]} pixels")
print(f" Created {len(pages)} page examples")

View File

@ -11,6 +11,10 @@ This example demonstrates text rendering using the pyWebLayout system:
This example uses the HTML parsing system to create rich text layouts.
"""
from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.concrete.page import Page
from pyWebLayout.style import Font
from pyWebLayout.io.readers.html_extraction import parse_html_string
import sys
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
@ -18,11 +22,6 @@ from PIL import Image, ImageDraw, ImageFont
# Add pyWebLayout to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from pyWebLayout.io.readers.html_extraction import parse_html_string
from pyWebLayout.style import Font
from pyWebLayout.concrete.page import Page
from pyWebLayout.style.page_style import PageStyle
def create_sample_document():
"""Create different HTML samples demonstrating various features."""
@ -37,7 +36,8 @@ def create_sample_document():
<p>This is left-aligned text. It is the default alignment for most text.</p>
<h2>Justified Text</h2>
<p style="text-align: justify;">This paragraph is justified. The text stretches to fill the entire width of the line, creating clean edges on both sides.</p>
<p style="text-align: justify;">This paragraph is justified. The text stretches to fill
the entire width of the line, creating clean edges on both sides.</p>
<h2>Centered</h2>
<p style="text-align: center;">This text is centered.</p>
@ -112,7 +112,7 @@ def render_html_to_image(html_content, page_size=(500, 400)):
# Add a note that this is HTML-parsed content
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 11)
except:
except BaseException:
font = ImageFont.load_default()
# Draw info about what was parsed
@ -128,7 +128,7 @@ def render_html_to_image(html_content, page_size=(500, 400)):
for i, block in enumerate(blocks[:10]): # Show first 10
block_type = type(block).__name__
draw.text((content_x, y_offset),
f" {i+1}. {block_type}",
f" {i + 1}. {block_type}",
fill=(60, 60, 60), font=font)
y_offset += 18
@ -150,8 +150,9 @@ def combine_samples(samples):
# Add title to image
draw = ImageDraw.Draw(img)
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 14)
except:
font = ImageFont.truetype(
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 14)
except BaseException:
font = ImageFont.load_default()
draw.text((10, 10), title, fill=(50, 50, 150), font=font)
@ -201,11 +202,11 @@ def main():
output_path = output_dir / "example_02_text_and_layout.png"
combined_image.save(output_path)
print(f"\n✓ Example completed!")
print("\n✓ Example completed!")
print(f" Output saved to: {output_path}")
print(f" Image size: {combined_image.size[0]}x{combined_image.size[1]} pixels")
print(f" Note: This example demonstrates HTML parsing")
print(f" Full layout rendering requires the typesetting engine")
print(" Note: This example demonstrates HTML parsing")
print(" Full layout rendering requires the typesetting engine")
return combined_image

View File

@ -11,6 +11,8 @@ This example demonstrates different page layout configurations:
Shows how the pyWebLayout system handles different page dimensions.
"""
from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.concrete.page import Page
import sys
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
@ -18,9 +20,6 @@ from PIL import Image, ImageDraw, ImageFont
# Add pyWebLayout to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from pyWebLayout.concrete.page import Page
from pyWebLayout.style.page_style import PageStyle
def add_page_info(page: Page, title: str):
"""Add informational text to a page showing its properties."""
@ -30,9 +29,11 @@ def add_page_info(page: Page, title: str):
draw = page.draw
try:
font_large = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 14)
font_small = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 11)
except:
font_large = ImageFont.truetype(
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 14)
font_small = ImageFont.truetype(
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 11)
except BaseException:
font_large = ImageFont.load_default()
font_small = ImageFont.load_default()
@ -164,13 +165,15 @@ def create_layout_showcase(layouts):
# Find max dimensions for each row/column
max_widths = []
for col in range(cols):
col_images = [images[row * cols + col][1] for row in range(rows) if row * cols + col < len(images)]
col_images = [images[row * cols + col][1]
for row in range(rows) if row * cols + col < len(images)]
if col_images:
max_widths.append(max(img.size[0] for img in col_images))
max_heights = []
for row in range(rows):
row_images = [images[row * cols + col][1] for col in range(cols) if row * cols + col < len(images)]
row_images = [images[row * cols + col][1]
for col in range(cols) if row * cols + col < len(images)]
if row_images:
max_heights.append(max(img.size[1] for img in row_images))
@ -184,8 +187,9 @@ def create_layout_showcase(layouts):
# Add title
try:
title_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 24)
except:
title_font = ImageFont.truetype(
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 24)
except BaseException:
title_font = ImageFont.load_default()
title_text = "Page Layout Examples"
@ -231,7 +235,7 @@ def main():
output_path = output_dir / "example_03_page_layouts.png"
combined_image.save(output_path)
print(f"\n✓ Example completed!")
print("\n✓ Example completed!")
print(f" Output saved to: {output_path}")
print(f" Image size: {combined_image.size[0]}x{combined_image.size[1]} pixels")
print(f" Created {len(layouts)} layout examples")

View File

@ -12,6 +12,13 @@ This example demonstrates rendering HTML tables:
Shows the HTML-first rendering pipeline.
"""
from pyWebLayout.abstract.block import Table
from pyWebLayout.style import Font
from pyWebLayout.io.readers.html_extraction import parse_html_string
from pyWebLayout.layout.document_layouter import DocumentLayouter
from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.concrete.table import TableStyle
from pyWebLayout.concrete.page import Page
import sys
from pathlib import Path
from PIL import Image, ImageDraw
@ -19,14 +26,6 @@ from PIL import Image, ImageDraw
# Add pyWebLayout to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from pyWebLayout.concrete.page import Page
from pyWebLayout.concrete.table import TableStyle
from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.layout.document_layouter import DocumentLayouter
from pyWebLayout.io.readers.html_extraction import parse_html_string
from pyWebLayout.style import Font
from pyWebLayout.abstract.block import Table
def create_simple_table_example():
"""Create a simple table from HTML."""
@ -179,7 +178,13 @@ def create_data_table_example():
return html, "Data Table"
def render_table_example(html: str, title: str, style_variant: int = 0, page_size=(500, 400)):
def render_table_example(
html: str,
title: str,
style_variant: int = 0,
page_size=(
500,
400)):
"""Render a table from HTML to an image using DocumentLayouter."""
# Create page with varying backgrounds
bg_colors = [
@ -299,8 +304,9 @@ def combine_examples(examples):
# Add main title
from PIL import ImageFont
try:
main_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 20)
except:
main_font = ImageFont.truetype(
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 20)
except BaseException:
main_font = ImageFont.load_default()
title_text = "Table Rendering Examples"
@ -346,7 +352,7 @@ def main():
output_path = output_dir / "example_04_table_rendering.png"
combined_image.save(output_path)
print(f"\n✓ Example completed!")
print("\n✓ Example completed!")
print(f" Output saved to: {output_path}")
print(f" Image size: {combined_image.size[0]}x{combined_image.size[1]} pixels")
print(f" Created {len(examples)} table examples")

View File

@ -10,6 +10,12 @@ This example demonstrates the complete pipeline:
No custom rendering code needed - DocumentLayouter handles everything!
"""
from pyWebLayout.style import Font
from pyWebLayout.concrete.table import TableStyle
from pyWebLayout.layout.document_layouter import DocumentLayouter
from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.concrete.page import Page
from pyWebLayout.io.readers.html_extraction import parse_html_string
import sys
from pathlib import Path
from PIL import Image
@ -17,20 +23,13 @@ from PIL import Image
# Add pyWebLayout to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from pyWebLayout.io.readers.html_extraction import parse_html_string
from pyWebLayout.concrete.page import Page
from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.layout.document_layouter import DocumentLayouter
from pyWebLayout.concrete.table import TableStyle
from pyWebLayout.style import Font
def create_book_catalog_html():
"""Create HTML for a book catalog table with actual <img> tags."""
# Get base path for images - use absolute paths for the img src
data_path = Path(__file__).parent.parent / "tests" / "data"
Path(__file__).parent.parent / "tests" / "data"
html = f"""
html = """
<html>
<body>
<table>
@ -77,9 +76,9 @@ def create_book_catalog_html():
def create_product_showcase_html():
"""Create HTML for a product showcase table with images."""
data_path = Path(__file__).parent.parent / "tests" / "data"
Path(__file__).parent.parent / "tests" / "data"
html = f"""
html = """
<html>
<body>
<table>
@ -107,8 +106,8 @@ def create_product_showcase_html():
def render_html_with_layouter(html_string: str, title: str,
table_style: TableStyle,
page_size=(600, 500)):
table_style: TableStyle,
page_size=(600, 500)):
"""
Render HTML using DocumentLayouter - the proper way!
@ -163,7 +162,7 @@ def render_html_with_layouter(html_string: str, title: str,
if not success:
print(f" ⚠ Warning: Block {type(block).__name__} didn't fit on page")
print(f" ✓ Layout complete!")
print(" ✓ Layout complete!")
# Step 5: Get the rendered canvas
# Note: Tables render directly onto page._canvas
@ -257,14 +256,14 @@ def main():
output_path = output_dir / "example_05_html_table_with_images.png"
combined.save(output_path)
print(f"\n✓ Example completed!")
print("\n✓ Example completed!")
print(f" Output saved to: {output_path}")
print(f" Image size: {combined.size[0]}x{combined.size[1]} pixels")
print(f"\nThe complete pipeline:")
print(f" 1. HTML with <img> tags → parse_html_string() → Abstract blocks")
print(f" 2. Abstract blocks → DocumentLayouter → Concrete objects")
print(f" 3. Page.render() → PNG output")
print(f"\n ✓ Using DocumentLayouter - NO custom rendering code!")
print("\nThe complete pipeline:")
print(" 1. HTML with <img> tags → parse_html_string() → Abstract blocks")
print(" 2. Abstract blocks → DocumentLayouter → Concrete objects")
print(" 3. Page.render() → PNG output")
print("\n ✓ Using DocumentLayouter - NO custom rendering code!")
return combined

View File

@ -11,19 +11,12 @@ save state, and resume rendering.
__version__ = '0.1.0'
# Core abstractions
from pyWebLayout.core import Renderable, Interactable, Layoutable, Queriable
# Style components
from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration
# Abstract document model
from pyWebLayout.abstract.document import Document, Book, Chapter, MetadataType
# Concrete implementations
from pyWebLayout.concrete.box import Box
from pyWebLayout.concrete.text import Line
from pyWebLayout.concrete.page import Page
# Abstract components
from pyWebLayout.abstract.inline import Word

View File

@ -1,7 +1,22 @@
from .block import Block, BlockType, Paragraph, Heading, HeadingLevel, Quote, CodeBlock
from .block import HList, ListItem, ListStyle, Table, TableRow, TableCell
from .block import HorizontalRule, Image
from .interactive_image import InteractiveImage
from .inline import Word, FormattedSpan, LineBreak
from .document import Document, MetadataType, Chapter, Book
from .functional import Link, LinkType, Button, Form, FormField, FormFieldType
"""
Abstract layer for the pyWebLayout library.
This package contains abstract representations of document elements that are
independent of rendering specifics.
"""
from .inline import Word, FormattedSpan
from .block import Paragraph, Heading, Image, HeadingLevel
from .document import Document
from .functional import LinkType
__all__ = [
'Word',
'FormattedSpan',
'Paragraph',
'Heading',
'Image',
'HeadingLevel',
'Document',
'LinkType',
]

File diff suppressed because it is too large Load Diff

View File

@ -2,8 +2,6 @@ from __future__ import annotations
from typing import List, Dict, Optional, Tuple, Union, Any
from enum import Enum
from .block import Block, BlockType, Heading, HeadingLevel, Paragraph
from .functional import Link, Button, Form
from .inline import Word, FormattedSpan
from ..style import Font, FontWeight, FontStyle, TextDecoration
from ..style.abstract_style import AbstractStyle, AbstractStyleRegistry, FontFamily, FontSize
from ..style.concrete_style import ConcreteStyleRegistry, RenderingContext, StyleResolver
@ -34,7 +32,11 @@ class Document(FontRegistry, MetadataContainer):
Uses MetadataContainer mixin for metadata management.
"""
def __init__(self, title: Optional[str] = None, language: str = "en-US", default_style=None):
def __init__(
self,
title: Optional[str] = None,
language: str = "en-US",
default_style=None):
"""
Initialize a new document.
@ -49,13 +51,13 @@ class Document(FontRegistry, MetadataContainer):
self._resources: Dict[str, Any] = {} # External resources like images
self._stylesheets: List[Dict[str, Any]] = [] # CSS stylesheets
self._scripts: List[str] = [] # JavaScript code
# Style management with new abstract/concrete system
self._abstract_style_registry = AbstractStyleRegistry()
self._rendering_context = RenderingContext(default_language=language)
self._style_resolver = StyleResolver(self._rendering_context)
self._concrete_style_registry = ConcreteStyleRegistry(self._style_resolver)
# Set default style
if default_style is None:
# Create a default abstract style
@ -68,45 +70,46 @@ class Document(FontRegistry, MetadataContainer):
color=default_style.colour,
language=default_style.language
)
style_id, default_style = self._abstract_style_registry.get_or_create_style(default_style)
style_id, default_style = self._abstract_style_registry.get_or_create_style(
default_style)
self._default_style = default_style
# Set basic metadata
if title:
self.set_metadata(MetadataType.TITLE, title)
self.set_metadata(MetadataType.LANGUAGE, language)
@property
def blocks(self) -> List[Block]:
"""Get the top-level blocks in this document"""
return self._blocks
@property
def default_style(self):
"""Get the default style for this document"""
return self._default_style
@default_style.setter
def default_style(self, style):
"""Set the default style for this document"""
self._default_style = style
def add_block(self, block: Block):
"""
Add a block to this document.
Args:
block: The block to add
"""
self._blocks.append(block)
def create_paragraph(self, style=None) -> Paragraph:
"""
Create a new paragraph and add it to this document.
Args:
style: Optional style override. If None, inherits from document
Returns:
The newly created Paragraph object
"""
@ -115,15 +118,18 @@ class Document(FontRegistry, MetadataContainer):
paragraph = Paragraph(style)
self.add_block(paragraph)
return paragraph
def create_heading(self, level: HeadingLevel = HeadingLevel.H1, style=None) -> Heading:
def create_heading(
self,
level: HeadingLevel = HeadingLevel.H1,
style=None) -> Heading:
"""
Create a new heading and add it to this document.
Args:
level: The heading level
style: Optional style override. If None, inherits from document
Returns:
The newly created Heading object
"""
@ -132,16 +138,20 @@ class Document(FontRegistry, MetadataContainer):
heading = Heading(level, style)
self.add_block(heading)
return heading
def create_chapter(self, title: Optional[str] = None, level: int = 1, style=None) -> 'Chapter':
def create_chapter(
self,
title: Optional[str] = None,
level: int = 1,
style=None) -> 'Chapter':
"""
Create a new chapter with inherited style.
Args:
title: The chapter title
level: The chapter level
style: Optional style override. If None, inherits from document
Returns:
The newly created Chapter object
"""
@ -154,148 +164,148 @@ class Document(FontRegistry, MetadataContainer):
def add_anchor(self, name: str, target: Block):
"""
Add a named anchor to this document.
Args:
name: The anchor name
target: The target block
"""
self._anchors[name] = target
def get_anchor(self, name: str) -> Optional[Block]:
"""
Get a named anchor from this document.
Args:
name: The anchor name
Returns:
The target block, or None if not found
"""
return self._anchors.get(name)
def add_resource(self, name: str, resource: Any):
"""
Add a resource to this document.
Args:
name: The resource name
resource: The resource data
"""
self._resources[name] = resource
def get_resource(self, name: str) -> Optional[Any]:
"""
Get a resource from this document.
Args:
name: The resource name
Returns:
The resource data, or None if not found
"""
return self._resources.get(name)
def add_stylesheet(self, stylesheet: Dict[str, Any]):
"""
Add a stylesheet to this document.
Args:
stylesheet: The stylesheet data
"""
self._stylesheets.append(stylesheet)
def add_script(self, script: str):
"""
Add a script to this document.
Args:
script: The script code
"""
self._scripts.append(script)
def get_title(self) -> Optional[str]:
"""
Get the document title.
Returns:
The document title, or None if not set
"""
return self.get_metadata(MetadataType.TITLE)
def set_title(self, title: str):
"""
Set the document title.
Args:
title: The document title
"""
self.set_metadata(MetadataType.TITLE, title)
@property
def title(self) -> Optional[str]:
"""
Get the document title as a property.
Returns:
The document title, or None if not set
"""
return self.get_title()
@title.setter
def title(self, title: str):
"""
Set the document title as a property.
Args:
title: The document title
"""
self.set_title(title)
def find_blocks_by_type(self, block_type: BlockType) -> List[Block]:
"""
Find all blocks of a specific type.
Args:
block_type: The type of blocks to find
Returns:
A list of matching blocks
"""
result = []
def _find_recursive(blocks: List[Block]):
for block in blocks:
if block.block_type == block_type:
result.append(block)
# Check for child blocks based on block type
if hasattr(block, '_blocks'):
_find_recursive(block._blocks)
elif hasattr(block, '_items') and isinstance(block._items, list):
_find_recursive(block._items)
_find_recursive(self._blocks)
return result
def find_headings(self) -> List[Heading]:
"""
Find all headings in the document.
Returns:
A list of heading blocks
"""
blocks = self.find_blocks_by_type(BlockType.HEADING)
return [block for block in blocks if isinstance(block, Heading)]
def generate_table_of_contents(self) -> List[Tuple[int, str, Block]]:
"""
Generate a table of contents from headings.
Returns:
A list of tuples containing (level, title, heading_block)
"""
headings = self.find_headings()
toc = []
for heading in headings:
# Extract text from the heading
@ -303,26 +313,26 @@ class Document(FontRegistry, MetadataContainer):
for _, word in heading.words_iter():
title += word.text + " "
title = title.strip()
# Add to TOC
level = heading.level.value # Get numeric value from HeadingLevel enum
toc.append((level, title, heading))
return toc
def get_or_create_style(self,
font_family: FontFamily = FontFamily.SERIF,
font_size: Union[FontSize, int] = FontSize.MEDIUM,
font_weight: FontWeight = FontWeight.NORMAL,
font_style: FontStyle = FontStyle.NORMAL,
text_decoration: TextDecoration = TextDecoration.NONE,
color: Union[str, Tuple[int, int, int]] = "black",
background_color: Optional[Union[str, Tuple[int, int, int, int]]] = None,
language: str = "en-US",
**kwargs) -> Tuple[str, AbstractStyle]:
def get_or_create_style(self,
font_family: FontFamily = FontFamily.SERIF,
font_size: Union[FontSize, int] = FontSize.MEDIUM,
font_weight: FontWeight = FontWeight.NORMAL,
font_style: FontStyle = FontStyle.NORMAL,
text_decoration: TextDecoration = TextDecoration.NONE,
color: Union[str, Tuple[int, int, int]] = "black",
background_color: Optional[Union[str, Tuple[int, int, int, int]]] = None,
language: str = "en-US",
**kwargs) -> Tuple[str, AbstractStyle]:
"""
Get or create an abstract style with the specified properties.
Args:
font_family: Semantic font family
font_size: Font size (semantic or numeric)
@ -333,7 +343,7 @@ class Document(FontRegistry, MetadataContainer):
background_color: Background color
language: Language code
**kwargs: Additional style properties
Returns:
Tuple of (style_id, AbstractStyle)
"""
@ -348,34 +358,34 @@ class Document(FontRegistry, MetadataContainer):
language=language,
**kwargs
)
return self._abstract_style_registry.get_or_create_style(abstract_style)
def get_font_for_style(self, abstract_style: AbstractStyle) -> Font:
"""
Get a Font object for an AbstractStyle (for rendering).
Args:
abstract_style: The abstract style to get a font for
Returns:
Font object ready for rendering
"""
return self._concrete_style_registry.get_font(abstract_style)
def update_rendering_context(self, **kwargs):
"""
Update the rendering context (user preferences, device settings, etc.).
Args:
**kwargs: Context properties to update (base_font_size, font_scale_factor, etc.)
"""
self._style_resolver.update_context(**kwargs)
def get_style_registry(self) -> AbstractStyleRegistry:
"""Get the abstract style registry for this document."""
return self._abstract_style_registry
def get_concrete_style_registry(self) -> ConcreteStyleRegistry:
"""Get the concrete style registry for this document."""
return self._concrete_style_registry
@ -392,7 +402,12 @@ class Chapter(FontRegistry, MetadataContainer):
Uses MetadataContainer mixin for metadata management.
"""
def __init__(self, title: Optional[str] = None, level: int = 1, style=None, parent=None):
def __init__(
self,
title: Optional[str] = None,
level: int = 1,
style=None,
parent=None):
"""
Initialize a new chapter.
@ -408,53 +423,53 @@ class Chapter(FontRegistry, MetadataContainer):
self._blocks: List[Block] = []
self._style = style
self._parent = parent
@property
def title(self) -> Optional[str]:
"""Get the chapter title"""
return self._title
@title.setter
def title(self, title: str):
"""Set the chapter title"""
self._title = title
@property
def level(self) -> int:
"""Get the chapter level"""
return self._level
@property
def blocks(self) -> List[Block]:
"""Get the blocks in this chapter"""
return self._blocks
@property
def style(self):
"""Get the default style for this chapter"""
return self._style
@style.setter
def style(self, style):
"""Set the default style for this chapter"""
self._style = style
def add_block(self, block: Block):
"""
Add a block to this chapter.
Args:
block: The block to add
"""
self._blocks.append(block)
def create_paragraph(self, style=None) -> Paragraph:
"""
Create a new paragraph and add it to this chapter.
Args:
style: Optional style override. If None, inherits from chapter
Returns:
The newly created Paragraph object
"""
@ -463,15 +478,18 @@ class Chapter(FontRegistry, MetadataContainer):
paragraph = Paragraph(style)
self.add_block(paragraph)
return paragraph
def create_heading(self, level: HeadingLevel = HeadingLevel.H1, style=None) -> Heading:
def create_heading(
self,
level: HeadingLevel = HeadingLevel.H1,
style=None) -> Heading:
"""
Create a new heading and add it to this chapter.
Args:
level: The heading level
style: Optional style override. If None, inherits from chapter
Returns:
The newly created Heading object
"""
@ -490,12 +508,12 @@ class Book(Document):
Abstract representation of an ebook.
A book is a document that contains chapters.
"""
def __init__(self, title: Optional[str] = None, author: Optional[str] = None,
def __init__(self, title: Optional[str] = None, author: Optional[str] = None,
language: str = "en-US", default_style=None):
"""
Initialize a new book.
Args:
title: The book title
author: The book author
@ -504,33 +522,37 @@ class Book(Document):
"""
super().__init__(title, language, default_style)
self._chapters: List[Chapter] = []
if author:
self.set_metadata(MetadataType.AUTHOR, author)
@property
def chapters(self) -> List[Chapter]:
"""Get the chapters in this book"""
return self._chapters
def add_chapter(self, chapter: Chapter):
"""
Add a chapter to this book.
Args:
chapter: The chapter to add
"""
self._chapters.append(chapter)
def create_chapter(self, title: Optional[str] = None, level: int = 1, style=None) -> Chapter:
def create_chapter(
self,
title: Optional[str] = None,
level: int = 1,
style=None) -> Chapter:
"""
Create and add a new chapter with inherited style.
Args:
title: The chapter title
level: The chapter level
style: Optional style override. If None, inherits from book
Returns:
The new chapter
"""
@ -539,29 +561,29 @@ class Book(Document):
chapter = Chapter(title, level, style)
self.add_chapter(chapter)
return chapter
def get_author(self) -> Optional[str]:
"""
Get the book author.
Returns:
The book author, or None if not set
"""
return self.get_metadata(MetadataType.AUTHOR)
def set_author(self, author: str):
"""
Set the book author.
Args:
author: The book author
"""
self.set_metadata(MetadataType.AUTHOR, author)
def generate_table_of_contents(self) -> List[Tuple[int, str, Chapter]]:
"""
Generate a table of contents from chapters.
Returns:
A list of tuples containing (level, title, chapter)
"""
@ -569,5 +591,5 @@ class Book(Document):
for chapter in self._chapters:
if chapter.title:
toc.append((chapter.level, chapter.title, chapter))
return toc

View File

@ -1,6 +1,6 @@
from __future__ import annotations
from enum import Enum
from typing import Callable, Dict, Any, Optional, Union, List, Tuple
from typing import Callable, Dict, Any, Optional, List, Tuple
from pyWebLayout.core.base import Interactable
@ -18,7 +18,7 @@ class Link(Interactable):
Links can be used for navigation within a document, to external resources,
or to trigger API calls for functionality like settings management.
"""
def __init__(self,
location: str,
link_type: LinkType = LinkType.INTERNAL,
@ -43,22 +43,22 @@ class Link(Interactable):
self._params = params or {}
self._title = title
self._html_id = html_id
@property
def location(self) -> str:
"""Get the target location of this link"""
return self._location
@property
def link_type(self) -> LinkType:
"""Get the type of this link"""
return self._link_type
@property
def params(self) -> Dict[str, Any]:
"""Get the parameters for this link"""
return self._params
@property
def title(self) -> Optional[str]:
"""Get the title/tooltip for this link"""
@ -95,7 +95,7 @@ class Button(Interactable):
A button that can be clicked to execute an action.
Buttons are similar to function links but are rendered differently.
"""
def __init__(self,
label: str,
callback: Callable,
@ -117,27 +117,27 @@ class Button(Interactable):
self._params = params or {}
self._enabled = enabled
self._html_id = html_id
@property
def label(self) -> str:
"""Get the button label"""
return self._label
@label.setter
def label(self, label: str):
"""Set the button label"""
self._label = label
@property
def enabled(self) -> bool:
"""Check if the button is enabled"""
return self._enabled
@enabled.setter
def enabled(self, enabled: bool):
"""Enable or disable the button"""
self._enabled = enabled
@property
def params(self) -> Dict[str, Any]:
"""Get the button parameters"""
@ -168,7 +168,7 @@ class Form(Interactable):
A form that can contain input fields and be submitted.
Forms can be used for user input and settings configuration.
"""
def __init__(self,
form_id: str,
action: Optional[str] = None,
@ -188,12 +188,12 @@ class Form(Interactable):
self._action = action
self._fields: Dict[str, FormField] = {}
self._html_id = html_id
@property
def form_id(self) -> str:
"""Get the form ID"""
return self._form_id
@property
def action(self) -> Optional[str]:
"""Get the form action"""
@ -207,46 +207,46 @@ class Form(Interactable):
def add_field(self, field: FormField):
"""
Add a field to this form.
Args:
field: The FormField to add
"""
self._fields[field.name] = field
field.form = self
def get_field(self, name: str) -> Optional[FormField]:
"""
Get a field by name.
Args:
name: The name of the field to get
Returns:
The FormField with the specified name, or None if not found
"""
return self._fields.get(name)
def get_values(self) -> Dict[str, Any]:
"""
Get the current values of all fields in this form.
Returns:
A dictionary mapping field names to their current values
"""
return {name: field.value for name, field in self._fields.items()}
def execute(self) -> Any:
"""
Submit the form, executing the callback with the form values.
Returns:
The result of the callback function, or the form values if no callback is provided.
"""
values = self.get_values()
if self._callback:
return self._callback(self._form_id, values)
return values
@ -272,8 +272,8 @@ class FormField:
"""
A field in a form that can accept user input.
"""
def __init__(self,
def __init__(self,
name: str,
field_type: FormFieldType,
label: Optional[str] = None,
@ -282,7 +282,7 @@ class FormField:
options: Optional[List[Tuple[str, str]]] = None):
"""
Initialize a form field.
Args:
name: The name of this field
field_type: The type of this field
@ -298,47 +298,47 @@ class FormField:
self._required = required
self._options = options or []
self._form: Optional[Form] = None
@property
def name(self) -> str:
"""Get the field name"""
return self._name
@property
def field_type(self) -> FormFieldType:
"""Get the field type"""
return self._field_type
@property
def label(self) -> str:
"""Get the field label"""
return self._label
@property
def value(self) -> Any:
"""Get the current field value"""
return self._value
@value.setter
def value(self, value: Any):
"""Set the field value"""
self._value = value
@property
def required(self) -> bool:
"""Check if the field is required"""
return self._required
@property
def options(self) -> List[Tuple[str, str]]:
"""Get the field options"""
return self._options
@property
def form(self) -> Optional[Form]:
"""Get the form containing this field"""
return self._form
@form.setter
def form(self, form: Form):
"""Set the form containing this field"""

View File

@ -1,5 +1,4 @@
from __future__ import annotations
from pyWebLayout.core.base import Queriable
from pyWebLayout.core import Hierarchical
from pyWebLayout.style import Font
from pyWebLayout.style.abstract_style import AbstractStyle
@ -10,20 +9,25 @@ import pyphen
from pyWebLayout.abstract.functional import LinkType
class Word:
"""
An abstract representation of a word in a document. Words can be split across
lines or pages during rendering. This class manages the logical representation
of a word without any rendering specifics.
Now uses AbstractStyle objects for memory efficiency and proper style management.
"""
def __init__(self, text: str, style: Union[Font, AbstractStyle], background=None, previous: Union['Word', None] = None):
def __init__(self,
text: str,
style: Union[Font,
AbstractStyle],
background=None,
previous: Union['Word',
None] = None):
"""
Initialize a new Word.
Args:
text: The text content of the word
style: AbstractStyle object or Font object (for backward compatibility)
@ -40,25 +44,25 @@ class Word:
previous.add_next(self)
@classmethod
def create_and_add_to(cls, text: str, container, style: Optional[Font] = None,
background=None) -> 'Word':
def create_and_add_to(cls, text: str, container, style: Optional[Font] = None,
background=None) -> 'Word':
"""
Create a new Word and add it to a container, inheriting style and language
from the container if not explicitly provided.
This method provides a convenient way to create words that automatically
inherit styling from their container (Paragraph, FormattedSpan, etc.)
without copying string values - using object references instead.
Args:
text: The text content of the word
container: The container to add the word to (must have add_word method and style property)
style: Optional Font style override. If None, inherits from container
background: Optional background color override. If None, inherits from container
Returns:
The newly created Word object
Raises:
AttributeError: If the container doesn't have the required add_word method or style property
"""
@ -67,12 +71,14 @@ class Word:
if hasattr(container, 'style'):
style = container.style
else:
raise AttributeError(f"Container {type(container).__name__} must have a 'style' property")
raise AttributeError(
f"Container {
type(container).__name__} must have a 'style' property")
# Inherit background from container if not provided
if background is None and hasattr(container, 'background'):
background = container.background
# Determine the previous word for proper linking
previous = None
if hasattr(container, '_words') and container._words:
@ -86,21 +92,21 @@ class Word:
previous = word
except (StopIteration, TypeError):
previous = None
# Create the new word
word = cls(text, style, background, previous)
# Link the previous word to this new one
if previous:
previous.add_next(word)
# Add the word to the container
if hasattr(container, 'add_word'):
# Check if add_word expects a Word object or text string
import inspect
sig = inspect.signature(container.add_word)
params = list(sig.parameters.keys())
if len(params) > 0:
# Peek at the parameter name to guess the expected type
param_name = params[0]
@ -110,7 +116,8 @@ class Word:
else:
# Might expect text string (like FormattedSpan.add_word)
# In this case, we can't use the container's add_word as it would create
# a duplicate Word. We need to add directly to the container's word list.
# a duplicate Word. We need to add directly to the container's word
# list.
if hasattr(container, '_words'):
container._words.append(word)
else:
@ -120,72 +127,72 @@ class Word:
# No parameters, shouldn't happen with add_word methods
container.add_word(word)
else:
raise AttributeError(f"Container {type(container).__name__} must have an 'add_word' method")
raise AttributeError(
f"Container {
type(container).__name__} must have an 'add_word' method")
return word
def add_concete(self, text: Union[Any, Tuple[Any,Any]]):
def add_concete(self, text: Union[Any, Tuple[Any, Any]]):
self.concrete = text
@property
def text(self) -> str:
"""Get the text content of the word"""
return self._text
@property
def style(self) -> Font:
"""Get the font style of the word"""
return self._style
@property
def background(self):
"""Get the background color of the word"""
return self._background
@property
def previous(self) -> Union['Word', None]:
"""Get the previous word in sequence"""
return self._previous
@property
def next(self) -> Union['Word', None]:
"""Get the next word in sequence"""
return self._next
def add_next(self, next_word: 'Word'):
"""Set the next word in sequence"""
self._next = next_word
def possible_hyphenation(self, language: str = None) -> bool:
"""
Hyphenate the word and store the parts.
Args:
language: Language code for hyphenation. If None, uses the style's language.
Returns:
bool: True if the word was hyphenated, False otherwise.
"""
dic = pyphen.Pyphen(lang=self._style.language)
return list(dic.iterate(self._text))
...
...
class FormattedSpan:
"""
A run of words with consistent formatting.
This represents a sequence of words that share the same style attributes.
"""
def __init__(self, style: Font, background=None):
"""
Initialize a new formatted span.
Args:
style: Font style information for all words in this span
background: Optional background color override
@ -193,21 +200,25 @@ class FormattedSpan:
self._style = style
self._background = background if background else style.background
self._words: List[Word] = []
@classmethod
def create_and_add_to(cls, container, style: Optional[Font] = None, background=None) -> 'FormattedSpan':
def create_and_add_to(
cls,
container,
style: Optional[Font] = None,
background=None) -> 'FormattedSpan':
"""
Create a new FormattedSpan and add it to a container, inheriting style from
the container if not explicitly provided.
Args:
container: The container to add the span to (must have add_span method and style property)
style: Optional Font style override. If None, inherits from container
background: Optional background color override
Returns:
The newly created FormattedSpan object
Raises:
AttributeError: If the container doesn't have the required add_span method or style property
"""
@ -216,72 +227,76 @@ class FormattedSpan:
if hasattr(container, 'style'):
style = container.style
else:
raise AttributeError(f"Container {type(container).__name__} must have a 'style' property")
raise AttributeError(
f"Container {
type(container).__name__} must have a 'style' property")
# Inherit background from container if not provided
if background is None and hasattr(container, 'background'):
background = container.background
# Create the new span
span = cls(style, background)
# Add the span to the container
if hasattr(container, 'add_span'):
container.add_span(span)
else:
raise AttributeError(f"Container {type(container).__name__} must have an 'add_span' method")
raise AttributeError(
f"Container {
type(container).__name__} must have an 'add_span' method")
return span
@property
def style(self) -> Font:
"""Get the font style of this span"""
return self._style
@property
def background(self):
"""Get the background color of this span"""
return self._background
@property
def words(self) -> List[Word]:
"""Get the list of words in this span"""
return self._words
def add_word(self, text: str) -> Word:
"""
Create and add a new word to this span.
Args:
text: The text content of the word
Returns:
The newly created Word object
"""
# Get the previous word if any
previous = self._words[-1] if self._words else None
# Create the new word
word = Word(text, self._style, self._background, previous)
# Link the previous word to this new one
if previous:
previous.add_next(word)
# Add the word to our list
self._words.append(word)
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: Optional['LinkType'] = None,
callback: Optional[Callable] = None,
@ -290,7 +305,7 @@ class LinkedWord(Word):
title: Optional[str] = None):
"""
Initialize a linked word.
Args:
text: The text content of the word
style: The font style
@ -304,46 +319,46 @@ class LinkedWord(Word):
"""
# Initialize Word first
super().__init__(text, style, background, previous)
# Store link properties
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
"""
@ -351,7 +366,7 @@ class LinkedWord(Word):
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:
@ -379,21 +394,21 @@ class LineBreak(Hierarchical):
def block_type(self):
"""Get the block type for this line break"""
return self._block_type
@classmethod
def create_and_add_to(cls, container) -> 'LineBreak':
"""
Create a new LineBreak and add it to a container.
Args:
container: The container to add the line break to
Returns:
The newly created LineBreak object
"""
# Create the new line break
line_break = cls()
# Add the line break to the container if it has an appropriate method
if hasattr(container, 'add_line_break'):
container.add_line_break(line_break)
@ -405,5 +420,5 @@ class LineBreak(Hierarchical):
else:
# Set parent relationship manually
line_break.parent = container
return line_break

View File

@ -9,7 +9,7 @@ proper bounding box detection.
from typing import Optional, Callable, Tuple
import numpy as np
from .block import Image, BlockType
from .block import Image
from ..core.base import Interactable, Queriable
@ -54,7 +54,12 @@ class InteractiveImage(Image, Interactable, Queriable):
callback: Function to call when image is tapped (receives point coordinates)
"""
# Initialize Image
Image.__init__(self, source=source, alt_text=alt_text, width=width, height=height)
Image.__init__(
self,
source=source,
alt_text=alt_text,
width=width,
height=height)
# Initialize Interactable
Interactable.__init__(self, callback=callback)

View File

@ -1,6 +1,25 @@
from .box import Box
from .page import Page
"""
Concrete layer for the pyWebLayout library.
This package contains concrete implementations that can be directly rendered.
"""
from .text import Text, Line
from .functional import LinkText, ButtonText, FormFieldText, create_link_text, create_button_text, create_form_field_text
from .box import Box
from .image import RenderableImage
from .table import TableRenderer, TableRowRenderer, TableCellRenderer, TableStyle
from .page import Page
from pyWebLayout.abstract.block import Table, TableRow as Row, TableCell as Cell
from .functional import LinkText, ButtonText
__all__ = [
'Text',
'Line',
'Box',
'RenderableImage',
'Page',
'Table',
'Row',
'Cell',
'LinkText',
'ButtonText',
]

View File

@ -1,12 +1,12 @@
from __future__ import annotations
import numpy as np
from PIL import Image
from typing import Tuple, Union, List, Optional, Dict
from pyWebLayout.core.base import Renderable, Queriable
from pyWebLayout.core import Geometric
from pyWebLayout.style import Alignment
class Box(Geometric, Renderable, Queriable):
"""
A box with geometric properties (origin and size).
@ -14,12 +14,20 @@ class Box(Geometric, Renderable, Queriable):
Uses Geometric mixin for origin and size management.
"""
def __init__(self,origin, size, callback = None, sheet : Image = None, mode: bool = None, halign=Alignment.CENTER, valign = Alignment.CENTER):
def __init__(
self,
origin,
size,
callback=None,
sheet: Image = None,
mode: bool = None,
halign=Alignment.CENTER,
valign=Alignment.CENTER):
super().__init__(origin=origin, size=size)
self._end = self._origin + self._size
self._end = self._origin + self._size
self._callback = callback
self._sheet : Image = sheet
if self._sheet == None:
self._sheet: Image = sheet
if self._sheet is None:
self._mode = mode
else:
self._mode = sheet.mode

View File

@ -1,10 +1,10 @@
from __future__ import annotations
from typing import Optional, Dict, Any, Tuple, List, Union
from typing import Optional, Tuple
import numpy as np
from PIL import Image, ImageDraw, ImageFont
from PIL import ImageDraw
from pyWebLayout.core.base import Interactable, Queriable
from pyWebLayout.abstract.functional import Link, Button, Form, FormField, LinkType, FormFieldType
from pyWebLayout.abstract.functional import Link, Button, FormField, LinkType, FormFieldType
from pyWebLayout.style import Font, TextDecoration
from .text import Text
@ -14,12 +14,12 @@ class LinkText(Text, Interactable, Queriable):
A Text subclass that can handle Link interactions.
Combines text rendering with clickable link functionality.
"""
def __init__(self, link: Link, text: str, font: Font, draw: ImageDraw.Draw,
def __init__(self, link: Link, text: str, font: Font, draw: ImageDraw.Draw,
source=None, line=None):
"""
Initialize a linkable text object.
Args:
link: The abstract Link object to handle interactions
text: The text content to render
@ -33,35 +33,35 @@ class LinkText(Text, Interactable, Queriable):
if link.link_type == LinkType.INTERNAL:
link_font = link_font.with_colour((0, 0, 200)) # Blue for internal links
elif link.link_type == LinkType.EXTERNAL:
link_font = link_font.with_colour((0, 0, 180)) # Darker blue for external links
link_font = link_font.with_colour(
(0, 0, 180)) # Darker blue for external links
elif link.link_type == LinkType.API:
link_font = link_font.with_colour((150, 0, 0)) # Red for API links
elif link.link_type == LinkType.FUNCTION:
link_font = link_font.with_colour((0, 120, 0)) # Green for function links
# Initialize Text with the styled font
Text.__init__(self, text, link_font, draw, source, line)
# Initialize Interactable with the link's execute method
Interactable.__init__(self, link.execute)
# Store the link object
self._link = link
self._hovered = False
# Ensure _origin is initialized as numpy array
if not hasattr(self, '_origin') or self._origin is None:
self._origin = np.array([0, 0])
@property
def link(self) -> Link:
"""Get the associated Link object"""
return self._link
def set_hovered(self, hovered: bool):
"""Set the hover state for visual feedback"""
self._hovered = hovered
def render(self, next_text: Optional['Text'] = None, spacing: int = 0):
"""
@ -73,12 +73,12 @@ class LinkText(Text, Interactable, Queriable):
"""
# Call the parent Text render method with parameters
super().render(next_text, spacing)
# Add hover effect if needed
if self._hovered:
# Draw a subtle highlight background
highlight_color = (220, 220, 255, 100) # Light blue with alpha
# Handle mock objects in tests
size = self.size
if hasattr(size, '__call__'): # It's a Mock
@ -86,13 +86,15 @@ class LinkText(Text, Interactable, Queriable):
size = np.array([100, 20])
else:
size = np.array(size)
# Ensure origin is a numpy array
origin = np.array(self._origin) if not isinstance(self._origin, np.ndarray) else self._origin
self._draw.rectangle([origin, origin + size],
fill=highlight_color)
origin = np.array(
self._origin) if not isinstance(
self._origin,
np.ndarray) else self._origin
self._draw.rectangle([origin, origin + size],
fill=highlight_color)
class ButtonText(Text, Interactable, Queriable):
@ -100,13 +102,13 @@ class ButtonText(Text, Interactable, Queriable):
A Text subclass that can handle Button interactions.
Renders text as a clickable button with visual states.
"""
def __init__(self, button: Button, font: Font, draw: ImageDraw.Draw,
padding: Tuple[int, int, int, int] = (4, 8, 4, 8),
source=None, line=None):
"""
Initialize a button text object.
Args:
button: The abstract Button object to handle interactions
font: The base font style
@ -117,40 +119,41 @@ class ButtonText(Text, Interactable, Queriable):
"""
# Initialize Text with the button label
Text.__init__(self, button.label, font, draw, source, line)
# Initialize Interactable with the button's execute method
Interactable.__init__(self, button.execute)
# Store button properties
self._button = button
self._padding = padding
self._pressed = False
self._hovered = False
# Recalculate dimensions to include padding
# Use getattr to handle mock objects in tests
text_width = getattr(self, '_width', 0) if not hasattr(self._width, '__call__') else 0
text_width = getattr(
self, '_width', 0) if not hasattr(
self._width, '__call__') else 0
self._padded_width = text_width + padding[1] + padding[3]
self._padded_height = self._style.font_size + padding[0] + padding[2]
@property
def button(self) -> Button:
"""Get the associated Button object"""
return self._button
@property
def size(self) -> np.ndarray:
"""Get the padded size of the button"""
return np.array([self._padded_width, self._padded_height])
def set_pressed(self, pressed: bool):
"""Set the pressed state"""
self._pressed = pressed
def set_hovered(self, hovered: bool):
"""Set the hover state"""
self._hovered = hovered
def render(self):
"""
@ -177,7 +180,7 @@ class ButtonText(Text, Interactable, Queriable):
bg_color = (100, 150, 200)
border_color = (70, 120, 170)
text_color = (255, 255, 255)
# Draw button background with rounded corners
# rounded_rectangle expects [x0, y0, x1, y1] format
button_rect = [
@ -187,8 +190,8 @@ class ButtonText(Text, Interactable, Queriable):
int(self._origin[1] + self.size[1])
]
self._draw.rounded_rectangle(button_rect, fill=bg_color,
outline=border_color, width=1, radius=4)
outline=border_color, width=1, radius=4)
# Update text color and render text centered within padding
self._style = self._style.with_colour(text_color)
text_x = self._origin[0] + self._padding[3] # left padding
@ -209,28 +212,28 @@ class ButtonText(Text, Interactable, Queriable):
# Temporarily set origin for text rendering
original_origin = self._origin.copy()
self._origin = np.array([text_x, text_y])
# Call parent render method for the text
super().render()
# Restore original origin
self._origin = original_origin
def in_object(self, point) -> bool:
"""
Check if a point is within this button.
Args:
point: The coordinates to check
Returns:
True if the point is within the button bounds (including padding)
"""
point_array = np.array(point)
relative_point = point_array - self._origin
# Check if the point is within the padded button boundaries
return (0 <= relative_point[0] < self._padded_width and
return (0 <= relative_point[0] < self._padded_width and
0 <= relative_point[1] < self._padded_height)
@ -239,12 +242,12 @@ class FormFieldText(Text, Interactable, Queriable):
A Text subclass that can handle FormField interactions.
Renders form field labels and input areas.
"""
def __init__(self, field: FormField, font: Font, draw: ImageDraw.Draw,
field_height: int = 24, source=None, line=None):
"""
Initialize a form field text object.
Args:
field: The abstract FormField object to handle interactions
font: The base font style for the label
@ -255,68 +258,70 @@ class FormFieldText(Text, Interactable, Queriable):
"""
# Initialize Text with the field label
Text.__init__(self, field.label, font, draw, source, line)
# Initialize Interactable - form fields don't have direct callbacks
# but can notify of focus/value changes
Interactable.__init__(self, None)
# Store field properties
self._field = field
self._field_height = field_height
self._focused = False
# Calculate total height (label + gap + field)
self._total_height = self._style.font_size + 5 + field_height
# Field width should be at least as wide as the label
# Use getattr to handle mock objects in tests
text_width = getattr(self, '_width', 0) if not hasattr(self._width, '__call__') else 0
text_width = getattr(
self, '_width', 0) if not hasattr(
self._width, '__call__') else 0
self._field_width = max(text_width, 150)
@property
def field(self) -> FormField:
"""Get the associated FormField object"""
return self._field
@property
def size(self) -> np.ndarray:
"""Get the total size including label and field"""
return np.array([self._field_width, self._total_height])
def set_focused(self, focused: bool):
"""Set the focus state"""
self._focused = focused
def render(self):
"""
Render the form field with label and input area.
"""
# Render the label
super().render()
# Calculate field position (below label with 5px gap)
field_x = self._origin[0]
field_y = self._origin[1] + self._style.font_size + 5
# Draw field background and border
bg_color = (255, 255, 255)
border_color = (100, 150, 200) if self._focused else (200, 200, 200)
field_rect = [(field_x, field_y),
(field_x + self._field_width, field_y + self._field_height)]
field_rect = [(field_x, field_y),
(field_x + self._field_width, field_y + self._field_height)]
self._draw.rectangle(field_rect, fill=bg_color, outline=border_color, width=1)
# Render field value if present
if self._field.value is not None:
value_text = str(self._field.value)
# For password fields, mask the text
if self._field.field_type == FormFieldType.PASSWORD:
value_text = "" * len(value_text)
# Create a temporary Text object for the value
value_font = self._style.with_colour((0, 0, 0))
# Position value text within field (with some padding)
# Get font metrics to properly center the baseline
ascent, descent = value_font.font.getmetrics()
@ -326,61 +331,62 @@ class FormFieldText(Text, Interactable, Queriable):
vertical_center = self._field_height / 2
value_x = field_x + 5
value_y = field_y + vertical_center + (descent / 2)
# Draw the value text
self._draw.text((value_x, value_y), value_text,
font=value_font.font, fill=value_font.colour, anchor="ls")
self._draw.text((value_x, value_y), value_text,
font=value_font.font, fill=value_font.colour, anchor="ls")
def handle_click(self, point) -> bool:
"""
Handle clicks on the form field.
Args:
point: The click coordinates relative to this field
Returns:
True if the field was clicked and focused
"""
# Calculate field area
field_y = self._style.font_size + 5
# Check if click is within the input field area (not just the label)
if (0 <= point[0] <= self._field_width and
field_y <= point[1] <= field_y + self._field_height):
field_y <= point[1] <= field_y + self._field_height):
self.set_focused(True)
return True
return False
def in_object(self, point) -> bool:
"""
Check if a point is within this form field (including label and input area).
Args:
point: The coordinates to check
Returns:
True if the point is within the field bounds
"""
point_array = np.array(point)
relative_point = point_array - self._origin
# Check if the point is within the total field area
return (0 <= relative_point[0] < self._field_width and
return (0 <= relative_point[0] < self._field_width and
0 <= relative_point[1] < self._total_height)
# Factory functions for creating functional text objects
def create_link_text(link: Link, text: str, font: Font, draw: ImageDraw.Draw) -> LinkText:
def create_link_text(link: Link, text: str, font: Font,
draw: ImageDraw.Draw) -> LinkText:
"""
Factory function to create a LinkText object.
Args:
link: The Link object to associate with the text
text: The text content to display
font: The base font style
draw: The drawing context
Returns:
A LinkText object ready for rendering and interaction
"""
@ -388,16 +394,16 @@ def create_link_text(link: Link, text: str, font: Font, draw: ImageDraw.Draw) ->
def create_button_text(button: Button, font: Font, draw: ImageDraw.Draw,
padding: Tuple[int, int, int, int] = (4, 8, 4, 8)) -> ButtonText:
padding: Tuple[int, int, int, int] = (4, 8, 4, 8)) -> ButtonText:
"""
Factory function to create a ButtonText object.
Args:
button: The Button object to associate with the text
font: The base font style
draw: The drawing context
padding: Padding around the button text
Returns:
A ButtonText object ready for rendering and interaction
"""
@ -405,16 +411,16 @@ def create_button_text(button: Button, font: Font, draw: ImageDraw.Draw,
def create_form_field_text(field: FormField, font: Font, draw: ImageDraw.Draw,
field_height: int = 24) -> FormFieldText:
field_height: int = 24) -> FormFieldText:
"""
Factory function to create a FormFieldText object.
Args:
field: The FormField object to associate with the text
font: The base font style for the label
draw: The drawing context
field_height: Height of the input field area
Returns:
A FormFieldText object ready for rendering and interaction
"""

View File

@ -1,10 +1,9 @@
import os
from typing import Optional, Tuple, Union, Dict, Any
from typing import Optional
import numpy as np
from PIL import Image as PILImage, ImageDraw, ImageFont
from pyWebLayout.core.base import Renderable, Queriable
from pyWebLayout.abstract.block import Image as AbstractImage
from .box import Box
from pyWebLayout.style import Alignment
@ -12,14 +11,14 @@ class RenderableImage(Renderable, Queriable):
"""
A concrete implementation for rendering Image objects.
"""
def __init__(self, image: AbstractImage, canvas: PILImage.Image,
max_width: Optional[int] = None, max_height: Optional[int] = None,
origin=None, size=None, callback=None, sheet=None, mode=None,
halign=Alignment.CENTER, valign=Alignment.CENTER):
"""
Initialize a renderable image.
Args:
image: The abstract Image object to render
draw: The PIL ImageDraw object to draw on
@ -40,52 +39,54 @@ class RenderableImage(Renderable, Queriable):
self._error_message = None
self._halign = halign
self._valign = valign
# Set origin as numpy array
self._origin = np.array(origin) if origin is not None else np.array([0, 0])
# Try to load the image
self._load_image()
# Calculate the size if not provided
if size is None:
size = image.calculate_scaled_dimensions(max_width, max_height)
# Ensure we have valid dimensions, fallback to defaults if None
if size[0] is None or size[1] is None:
size = (100, 100) # Default size when image dimensions are unavailable
# Set size as numpy array
self._size = np.array(size)
@property
def origin(self) -> np.ndarray:
"""Get the origin of the image"""
return self._origin
@property
def size(self) -> np.ndarray:
"""Get the size of the image"""
return self._size
@property
def width(self) -> int:
"""Get the width of the image"""
return self._size[0]
def set_origin(self, origin: np.ndarray):
"""Set the origin of this image element"""
self._origin = origin
def _load_image(self):
"""Load the image from the source path"""
try:
# Check if the image has already been loaded into memory
if hasattr(self._abstract_image, '_loaded_image') and self._abstract_image._loaded_image is not None:
if hasattr(
self._abstract_image,
'_loaded_image') and self._abstract_image._loaded_image is not None:
self._pil_image = self._abstract_image._loaded_image
return
source = self._abstract_image.source
# Handle different types of sources
if os.path.isfile(source):
# Local file
@ -96,22 +97,23 @@ class RenderableImage(Renderable, Queriable):
try:
import requests
from io import BytesIO
response = requests.get(source, stream=True)
if response.status_code == 200:
self._pil_image = PILImage.open(BytesIO(response.content))
self._abstract_image._loaded_image = self._pil_image
else:
self._error_message = f"Failed to load image: HTTP status {response.status_code}"
self._error_message = f"Failed to load image: HTTP status {
response.status_code}"
except ImportError:
self._error_message = "Requests library not available for URL loading"
else:
self._error_message = f"Unable to load image from source: {source}"
except Exception as e:
self._error_message = f"Error loading image: {str(e)}"
self._abstract_image._error = self._error_message
def render(self):
"""
Render the image directly into the canvas using the provided draw object.
@ -119,11 +121,11 @@ class RenderableImage(Renderable, Queriable):
if self._pil_image:
# Resize the image to fit the box while maintaining aspect ratio
resized_image = self._resize_image()
# Calculate position based on alignment
img_width, img_height = resized_image.size
box_width, box_height = self._size
# Horizontal alignment
if self._halign == Alignment.LEFT:
x_offset = 0
@ -131,7 +133,7 @@ class RenderableImage(Renderable, Queriable):
x_offset = box_width - img_width
else: # CENTER is default
x_offset = (box_width - img_width) // 2
# Vertical alignment
if self._valign == Alignment.TOP:
y_offset = 0
@ -139,55 +141,62 @@ class RenderableImage(Renderable, Queriable):
y_offset = box_height - img_height
else: # CENTER is default
y_offset = (box_height - img_height) // 2
# Calculate final position on canvas
final_x = int(self._origin[0] + x_offset)
final_y = int(self._origin[1] + y_offset)
# Get the underlying image from the draw object to paste onto
self._canvas.paste(resized_image, (final_x, final_y, final_x + img_width, final_y + img_height))
self._canvas.paste(
resized_image,
(final_x,
final_y,
final_x +
img_width,
final_y +
img_height))
else:
# Draw error placeholder
self._draw_error_placeholder()
def _resize_image(self) -> PILImage.Image:
"""
Resize the image to fit within the box while maintaining aspect ratio.
Returns:
A resized PIL Image
"""
if not self._pil_image:
return PILImage.new('RGBA', tuple(self._size), (200, 200, 200, 100))
# Get the target dimensions
target_width, target_height = self._size
# Get the original dimensions
orig_width, orig_height = self._pil_image.size
# Calculate the scaling factor to maintain aspect ratio
width_ratio = target_width / orig_width
height_ratio = target_height / orig_height
# Use the smaller ratio to ensure the image fits within the box
ratio = min(width_ratio, height_ratio)
# Calculate new dimensions
new_width = int(orig_width * ratio)
new_height = int(orig_height * ratio)
# Resize the image
if self._pil_image.mode == 'RGBA':
resized = self._pil_image.resize((new_width, new_height), PILImage.LANCZOS)
else:
# Convert to RGBA if needed
resized = self._pil_image.convert('RGBA').resize((new_width, new_height), PILImage.LANCZOS)
resized = self._pil_image.convert('RGBA').resize(
(new_width, new_height), PILImage.LANCZOS)
return resized
def _draw_error_placeholder(self):
"""
Draw a placeholder for when the image can't be loaded.
@ -197,68 +206,69 @@ class RenderableImage(Renderable, Queriable):
y1 = int(self._origin[1])
x2 = int(self._origin[0] + self._size[0])
y2 = int(self._origin[1] + self._size[1])
self._draw = ImageDraw.Draw(self._canvas)
# Draw a gray box with a border
self._draw.rectangle([(x1, y1), (x2, y2)], fill=(240, 240, 240), outline=(180, 180, 180), width=2)
self._draw.rectangle([(x1, y1), (x2, y2)], fill=(
240, 240, 240), outline=(180, 180, 180), width=2)
# Draw an X across the box
self._draw.line([(x1, y1), (x2, y2)], fill=(180, 180, 180), width=2)
self._draw.line([(x1, y2), (x2, y1)], fill=(180, 180, 180), width=2)
# Add error text if available
if self._error_message:
try:
# Try to use a basic font
font = ImageFont.load_default()
# Draw the error message, wrapped to fit
error_text = "Error: " + self._error_message
# Simple text wrapping - split by words and add lines
words = error_text.split()
lines = []
current_line = ""
for word in words:
test_line = current_line + " " + word if current_line else word
text_bbox = self._draw.textbbox((0, 0), test_line, font=font)
text_width = text_bbox[2] - text_bbox[0]
if text_width <= self._size[0] - 20: # 10px padding on each side
current_line = test_line
else:
lines.append(current_line)
current_line = word
if current_line:
lines.append(current_line)
# Draw each line
y_pos = y1 + 10
for line in lines:
text_bbox = self._draw.textbbox((0, 0), line, font=font)
text_width = text_bbox[2] - text_bbox[0]
text_height = text_bbox[3] - text_bbox[1]
# Center the text horizontally
x_pos = x1 + (self._size[0] - text_width) // 2
# Draw the text
self._draw.text((x_pos, y_pos), line, fill=(80, 80, 80), font=font)
# Move to the next line
y_pos += text_height + 2
except Exception:
# If text rendering fails, just draw a generic error indicator
pass
def in_object(self, point):
"""Check if a point is within this image"""
point_array = np.array(point)
relative_point = point_array - self._origin
# Check if the point is within the image boundaries
return (0 <= relative_point[0] < self._size[0] and
return (0 <= relative_point[0] < self._size[0] and
0 <= relative_point[1] < self._size[1])

View File

@ -2,12 +2,11 @@ from typing import List, Tuple, Optional
import numpy as np
from PIL import Image, ImageDraw
from pyWebLayout.core.base import Renderable, Layoutable, Queriable
from pyWebLayout.core.base import Renderable, Queriable
from pyWebLayout.core.query import QueryResult, SelectionRange
from pyWebLayout.core.callback_registry import CallbackRegistry
from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.style import Alignment
from .box import Box
class Page(Renderable, Queriable):
"""
@ -41,29 +40,33 @@ class Page(Renderable, Queriable):
"""Get the remaining space on the page"""
return (self._size[0], self._size[1] - self._current_y_offset)
def can_fit_line(self, baseline_spacing: int, ascent: int = 0, descent: int = 0) -> bool:
def can_fit_line(
self,
baseline_spacing: int,
ascent: int = 0,
descent: int = 0) -> bool:
"""
Check if a line with the given metrics can fit on the page.
Args:
baseline_spacing: Distance from current position to next baseline
ascent: Font ascent (height above baseline), defaults to 0 for backward compat
descent: Font descent (height below baseline), defaults to 0 for backward compat
Returns:
True if the line fits within page boundaries
"""
# Calculate the maximum Y position allowed (bottom boundary)
max_y = self._size[1] - self._style.border_width - self._style.padding_bottom
# If ascent/descent not provided, use simple check (backward compatibility)
if ascent == 0 and descent == 0:
return (self._current_y_offset + baseline_spacing) <= max_y
# Calculate where the bottom of the text would be
# Text bottom = current_y_offset + ascent + descent
text_bottom = self._current_y_offset + ascent + descent
# Check if text bottom would exceed the boundary
return text_bottom <= max_y
@ -183,11 +186,15 @@ class Page(Renderable, Queriable):
Height in pixels
"""
if hasattr(child, '_size') and child._size is not None:
if isinstance(child._size, (list, tuple, np.ndarray)) and len(child._size) >= 2:
if isinstance(
child._size, (list, tuple, np.ndarray)) and len(
child._size) >= 2:
return int(child._size[1])
if hasattr(child, 'size') and child.size is not None:
if isinstance(child.size, (list, tuple, np.ndarray)) and len(child.size) >= 2:
if isinstance(
child.size, (list, tuple, np.ndarray)) and len(
child.size) >= 2:
return int(child.size[1])
if hasattr(child, 'height'):
@ -326,7 +333,7 @@ class Page(Renderable, Queriable):
if isinstance(child, Queriable) and hasattr(child, 'in_object'):
try:
return child.in_object(point)
except:
except BaseException:
pass # Fall back to bounds checking
# Get child position and size for bounds checking
@ -353,11 +360,15 @@ class Page(Renderable, Queriable):
Tuple of (width, height) or None if size cannot be determined
"""
if hasattr(child, '_size') and child._size is not None:
if isinstance(child._size, (list, tuple, np.ndarray)) and len(child._size) >= 2:
if isinstance(
child._size, (list, tuple, np.ndarray)) and len(
child._size) >= 2:
return (int(child._size[0]), int(child._size[1]))
if hasattr(child, 'size') and child.size is not None:
if isinstance(child.size, (list, tuple, np.ndarray)) and len(child.size) >= 2:
if isinstance(
child.size, (list, tuple, np.ndarray)) and len(
child.size) >= 2:
return (int(child.size[0]), int(child.size[1]))
if hasattr(child, 'width') and hasattr(child, 'height'):
@ -422,7 +433,8 @@ class Page(Renderable, Queriable):
bounds=bounds
)
def query_range(self, start: Tuple[int, int], end: Tuple[int, int]) -> SelectionRange:
def query_range(self, start: Tuple[int, int],
end: Tuple[int, int]) -> SelectionRange:
"""
Query all text objects between two points (for text selection).
Uses Queriable.in_object() to determine which objects are in range.

View File

@ -9,15 +9,13 @@ This module provides the concrete rendering classes for tables, including:
from __future__ import annotations
from typing import Tuple, List, Optional, Dict
import numpy as np
from PIL import Image, ImageDraw
from dataclasses import dataclass
from pyWebLayout.core.base import Renderable, Queriable
from pyWebLayout.core.base import Renderable
from pyWebLayout.concrete.box import Box
from pyWebLayout.abstract.block import Table, TableRow, TableCell, Paragraph, Heading, Image as AbstractImage
from pyWebLayout.abstract.interactive_image import InteractiveImage
from pyWebLayout.style import Font, Alignment
@dataclass
@ -49,8 +47,15 @@ class TableCellRenderer(Box):
Supports paragraphs, headings, images, and links within cells.
"""
def __init__(self, cell: TableCell, origin: Tuple[int, int], size: Tuple[int, int],
draw: ImageDraw.Draw, style: TableStyle, is_header_section: bool = False,
def __init__(self,
cell: TableCell,
origin: Tuple[int,
int],
size: Tuple[int,
int],
draw: ImageDraw.Draw,
style: TableStyle,
is_header_section: bool = False,
canvas: Optional[Image.Image] = None):
"""
Initialize a table cell renderer.
@ -111,17 +116,20 @@ class TableCellRenderer(Box):
# Get font
try:
if self._is_header_section and self._style.header_text_bold:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 12)
font = ImageFont.truetype(
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 12)
else:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 12)
except:
font = ImageFont.truetype(
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 12)
except BaseException:
font = ImageFont.load_default()
# Render each block in the cell
for block in self._cell.blocks():
if isinstance(block, AbstractImage):
# Render image
current_y = self._render_image_in_cell(block, x, current_y, width, height - (current_y - y))
current_y = self._render_image_in_cell(
block, x, current_y, width, height - (current_y - y))
elif isinstance(block, (Paragraph, Heading)):
# Extract and render text
words = []
@ -137,7 +145,8 @@ class TableCellRenderer(Box):
if words:
text = " ".join(words)
if current_y <= y + height - 15:
self._draw.text((x + 2, current_y), text, fill=(0, 0, 0), font=font)
self._draw.text((x + 2, current_y), text,
fill=(0, 0, 0), font=font)
current_y += 16
if current_y > y + height - 10: # Don't overflow cell
@ -145,10 +154,18 @@ class TableCellRenderer(Box):
# If no structured content, try to get any text representation
if current_y == y + 2 and hasattr(self._cell, '_text_content'):
self._draw.text((x + 2, current_y), self._cell._text_content, fill=(0, 0, 0), font=font)
self._draw.text(
(x + 2,
current_y),
self._cell._text_content,
fill=(
0,
0,
0),
font=font)
def _render_image_in_cell(self, image_block: AbstractImage, x: int, y: int,
max_width: int, max_height: int) -> int:
max_width: int, max_height: int) -> int:
"""
Render an image block inside a table cell.
@ -181,7 +198,8 @@ class TableCellRenderer(Box):
# Use more of the cell space for images
img_width, img_height = img.size
scale_w = max_width / img_width if img_width > max_width else 1
scale_h = (max_height - 10) / img_height if img_height > (max_height - 10) else 1
scale_h = (max_height - 10) / \
img_height if img_height > (max_height - 10) else 1
scale = min(scale_w, scale_h, 1.0) # Don't upscale
new_width = int(img_width * scale)
@ -210,8 +228,9 @@ class TableCellRenderer(Box):
# Draw image indicator text
from PIL import ImageFont
try:
small_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 9)
except:
small_font = ImageFont.truetype(
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 9)
except BaseException:
small_font = ImageFont.load_default()
text = f"[Image: {new_width}x{new_height}]"
@ -219,7 +238,9 @@ class TableCellRenderer(Box):
text_width = bbox[2] - bbox[0]
text_x = img_x + (new_width - text_width) // 2
text_y = y + (new_height - 12) // 2
self._draw.text((text_x, text_y), text, fill=(100, 100, 100), font=small_font)
self._draw.text(
(text_x, text_y), text, fill=(
100, 100, 100), font=small_font)
# Set bounds on InteractiveImage objects for tap detection
if isinstance(image_block, InteractiveImage):
@ -230,7 +251,7 @@ class TableCellRenderer(Box):
return y + new_height + 5 # Add some spacing after image
except Exception as e:
except Exception:
# If image loading fails, just return current position
return y + 20
@ -240,9 +261,15 @@ class TableRowRenderer(Box):
Renders a single table row containing multiple cells.
"""
def __init__(self, row: TableRow, origin: Tuple[int, int],
column_widths: List[int], row_height: int,
draw: ImageDraw.Draw, style: TableStyle, is_header_section: bool = False,
def __init__(self,
row: TableRow,
origin: Tuple[int,
int],
column_widths: List[int],
row_height: int,
draw: ImageDraw.Draw,
style: TableStyle,
is_header_section: bool = False,
canvas: Optional[Image.Image] = None):
"""
Initialize a table row renderer.
@ -309,9 +336,14 @@ class TableRenderer(Box):
Handles layout calculation, row/cell placement, and overall table structure.
"""
def __init__(self, table: Table, origin: Tuple[int, int],
available_width: int, draw: ImageDraw.Draw,
style: Optional[TableStyle] = None, canvas: Optional[Image.Image] = None):
def __init__(self,
table: Table,
origin: Tuple[int,
int],
available_width: int,
draw: ImageDraw.Draw,
style: Optional[TableStyle] = None,
canvas: Optional[Image.Image] = None):
"""
Initialize a table renderer.
@ -331,8 +363,10 @@ class TableRenderer(Box):
# Calculate table dimensions
self._column_widths, self._row_heights = self._calculate_dimensions()
total_width = sum(self._column_widths) + self._style.border_width * (len(self._column_widths) + 1)
total_height = sum(self._row_heights.values()) + self._style.border_width * (len(self._row_heights) + 1)
total_width = sum(self._column_widths) + \
self._style.border_width * (len(self._column_widths) + 1)
total_height = sum(self._row_heights.values()) + \
self._style.border_width * (len(self._row_heights) + 1)
super().__init__(origin, (total_width, total_height))
self._row_renderers: List[TableRowRenderer] = []
@ -362,7 +396,8 @@ class TableRenderer(Box):
column_widths = [column_width] * num_columns
# Calculate row heights
header_height = 35 if any(1 for section, _ in all_rows if section == "header") else 0
header_height = 35 if any(1 for section,
_ in all_rows if section == "header") else 0
# Check if any body rows contain images - if so, use larger height
body_height = 30
@ -375,7 +410,8 @@ class TableRenderer(Box):
body_height = max(body_height, 120)
break
footer_height = 30 if any(1 for section, _ in all_rows if section == "footer") else 0
footer_height = 30 if any(1 for section,
_ in all_rows if section == "footer") else 0
row_heights = {
"header": header_height,
@ -428,8 +464,9 @@ class TableRenderer(Box):
from PIL import ImageFont
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 13)
except:
font = ImageFont.truetype(
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 13)
except BaseException:
font = ImageFont.load_default()
# Center the caption

View File

@ -2,15 +2,16 @@ from __future__ import annotations
from pyWebLayout.core.base import Renderable, Queriable
from pyWebLayout.core.query import QueryResult
from .box import Box
from pyWebLayout.style import Alignment, Font, FontStyle, FontWeight, TextDecoration
from pyWebLayout.style import Alignment, Font, TextDecoration
from pyWebLayout.abstract import Word
from pyWebLayout.abstract.inline import LinkedWord
from pyWebLayout.abstract.functional import Link
from PIL import Image, ImageDraw, ImageFont
from typing import Tuple, Union, List, Optional, Protocol
from PIL import ImageDraw
from typing import Tuple, List, Optional
import numpy as np
from abc import ABC, abstractmethod
class AlignmentHandler(ABC):
"""
Abstract base class for text alignment handlers.
@ -19,8 +20,8 @@ class AlignmentHandler(ABC):
@abstractmethod
def calculate_spacing_and_position(self, text_objects: List['Text'],
available_width: int, min_spacing: int,
max_spacing: int) -> Tuple[int, int, bool]:
available_width: int, min_spacing: int,
max_spacing: int) -> Tuple[int, int, bool]:
"""
Calculate the spacing between words and starting position for the line.
@ -33,16 +34,16 @@ class AlignmentHandler(ABC):
Returns:
Tuple of (spacing_between_words, starting_x_position)
"""
pass
class LeftAlignmentHandler(AlignmentHandler):
"""Handler for left-aligned text."""
def calculate_spacing_and_position(self,
text_objects: List['Text'],
available_width: int,
min_spacing: int,
max_spacing: int) -> Tuple[int, int, bool]:
text_objects: List['Text'],
available_width: int,
min_spacing: int,
max_spacing: int) -> Tuple[int, int, bool]:
"""
Calculate spacing and position for left-aligned text objects.
CREngine-inspired: never allow negative spacing, always use minimum spacing for overflow.
@ -69,7 +70,8 @@ class LeftAlignmentHandler(AlignmentHandler):
# Calculate minimum space needed (text + minimum gaps)
min_total_width = text_length + (min_spacing * num_gaps)
# Check if we have overflow (CREngine pattern: always use min_spacing for overflow)
# Check if we have overflow (CREngine pattern: always use min_spacing for
# overflow)
if min_total_width > available_width:
return min_spacing, 0, True # Overflow - but use safe minimum spacing
@ -87,6 +89,7 @@ class LeftAlignmentHandler(AlignmentHandler):
else:
return actual_spacing, 0, False # Use calculated spacing
class CenterRightAlignmentHandler(AlignmentHandler):
"""Handler for center and right-aligned text."""
@ -94,8 +97,8 @@ class CenterRightAlignmentHandler(AlignmentHandler):
self._alignment = alignment
def calculate_spacing_and_position(self, text_objects: List['Text'],
available_width: int, min_spacing: int,
max_spacing: int) -> Tuple[int, int, bool]:
available_width: int, min_spacing: int,
max_spacing: int) -> Tuple[int, int, bool]:
"""Center/right alignment uses minimum spacing with calculated start position."""
word_length = sum([word.width for word in text_objects])
residual_space = available_width - word_length
@ -108,12 +111,12 @@ class CenterRightAlignmentHandler(AlignmentHandler):
start_position = available_width - word_length
return 0, max(0, start_position), False
actual_spacing = residual_space // (len(text_objects)-1)
ideal_space = (min_spacing + max_spacing)/2
if actual_spacing > 0.5*(min_spacing + max_spacing):
actual_spacing = 0.5*(min_spacing + max_spacing)
actual_spacing = residual_space // (len(text_objects) - 1)
ideal_space = (min_spacing + max_spacing) / 2
if actual_spacing > 0.5 * (min_spacing + max_spacing):
actual_spacing = 0.5 * (min_spacing + max_spacing)
content_length = word_length + (len(text_objects)-1) * actual_spacing
content_length = word_length + (len(text_objects) - 1) * actual_spacing
if self._alignment == Alignment.CENTER:
start_position = (available_width - content_length) // 2
else:
@ -124,12 +127,13 @@ class CenterRightAlignmentHandler(AlignmentHandler):
return ideal_space, max(0, start_position), False
class JustifyAlignmentHandler(AlignmentHandler):
"""Handler for justified text with full justification."""
def calculate_spacing_and_position(self, text_objects: List['Text'],
available_width: int, min_spacing: int,
max_spacing: int) -> Tuple[int, int, bool]:
available_width: int, min_spacing: int,
max_spacing: int) -> Tuple[int, int, bool]:
"""Justified alignment distributes space to fill the entire line width."""
word_length = sum([word.width for word in text_objects])
@ -137,7 +141,7 @@ class JustifyAlignmentHandler(AlignmentHandler):
num_gaps = max(1, len(text_objects) - 1)
actual_spacing = residual_space // num_gaps
ideal_space = (min_spacing + max_spacing)//2
ideal_space = (min_spacing + max_spacing) // 2
# can we touch the end?
if actual_spacing < max_spacing:
if actual_spacing < min_spacing:
@ -146,13 +150,20 @@ class JustifyAlignmentHandler(AlignmentHandler):
return max(min_spacing, actual_spacing), 0, False
return ideal_space, 0, False
class Text(Renderable, Queriable):
"""
Concrete implementation for rendering text.
This class handles the visual representation of text fragments.
"""
def __init__(self, text: str, style: Font, draw: ImageDraw.Draw, source: Optional[Word] = None, line: Optional[Line] = None):
def __init__(
self,
text: str,
style: Font,
draw: ImageDraw.Draw,
source: Optional[Word] = None,
line: Optional[Line] = None):
"""
Initialize a Text object.
@ -181,8 +192,8 @@ class Text(Renderable, Queriable):
self._middle_y = ascent - descent / 2
@classmethod
def from_word(cls,word:Word, draw: ImageDraw.Draw):
return cls(word.text,word.style, draw)
def from_word(cls, word: Word, draw: ImageDraw.Draw):
return cls(word.text, word.style, draw)
@property
def text(self) -> str:
@ -219,7 +230,7 @@ class Text(Renderable, Queriable):
"""Get the width of the text"""
return np.array((self._width, self._style.font_size))
def set_origin(self, origin:np.generic):
def set_origin(self, origin: np.generic):
"""Set the origin (left baseline ("ls")) of this text element"""
self._origin = origin
@ -230,51 +241,51 @@ class Text(Renderable, Queriable):
def _apply_decoration(self, next_text: Optional['Text'] = None, spacing: int = 0):
"""
Apply text decoration (underline or strikethrough).
Args:
next_text: The next Text object in the line (if any)
spacing: The spacing to the next text object
"""
if self._style.decoration == TextDecoration.UNDERLINE:
# Draw underline at about 90% of the height
y_position = self._origin[1] - 0.1*self._style.font_size
y_position = self._origin[1] - 0.1 * self._style.font_size
line_width = max(1, int(self._style.font_size / 15))
# Determine end x-coordinate
end_x = self._origin[0] + self._width
# If next text also has underline decoration, extend to connect them
if (next_text is not None and
if (next_text is not None and
next_text.style.decoration == TextDecoration.UNDERLINE and
next_text.style.colour == self._style.colour):
next_text.style.colour == self._style.colour):
# Extend the underline through the spacing to connect with next word
end_x += spacing
self._draw.line([(self._origin[0], y_position), (end_x, y_position)],
fill=self._style.colour, width=line_width)
fill=self._style.colour, width=line_width)
elif self._style.decoration == TextDecoration.STRIKETHROUGH:
# Draw strikethrough at about 50% of the height
y_position = self._origin[1] + self._middle_y
line_width = max(1, int(self._style.font_size / 15))
# Determine end x-coordinate
end_x = self._origin[0] + self._width
# If next text also has strikethrough decoration, extend to connect them
if (next_text is not None and
if (next_text is not None and
next_text.style.decoration == TextDecoration.STRIKETHROUGH and
next_text.style.colour == self._style.colour):
next_text.style.colour == self._style.colour):
# Extend the strikethrough through the spacing to connect with next word
end_x += spacing
self._draw.line([(self._origin[0], y_position), (end_x, y_position)],
fill=self._style.colour, width=line_width)
fill=self._style.colour, width=line_width)
def render(self, next_text: Optional['Text'] = None, spacing: int = 0):
"""
Render the text to an image.
Args:
next_text: The next Text object in the line (if any)
spacing: The spacing to the next text object
@ -285,24 +296,42 @@ class Text(Renderable, Queriable):
# Draw the text background if specified
if self._style.background and self._style.background[3] > 0: # If alpha > 0
self._draw.rectangle([self._origin, self._origin+self._size], fill=self._style.background)
self._draw.rectangle([self._origin, self._origin +
self._size], fill=self._style.background)
# Draw the text using baseline as anchor point ("ls" = left-baseline)
# This ensures the origin represents the baseline, not the top-left
self._draw.text((self.origin[0], self._origin[1]), self._text, font=self._style.font, fill=self._style.colour, anchor="ls")
self._draw.text(
(self.origin[0],
self._origin[1]),
self._text,
font=self._style.font,
fill=self._style.colour,
anchor="ls")
# Apply any text decorations with knowledge of next text
self._apply_decoration(next_text, spacing)
class Line(Box):
"""
A line of text consisting of Text objects with consistent spacing.
Each Text represents a word or word fragment that can be rendered.
"""
def __init__(self, spacing: Tuple[int, int], origin, size, draw: ImageDraw.Draw,font: Optional[Font] = None,
callback=None, sheet=None, mode=None, halign=Alignment.CENTER,
valign=Alignment.CENTER, previous = None,
def __init__(self,
spacing: Tuple[int,
int],
origin,
size,
draw: ImageDraw.Draw,
font: Optional[Font] = None,
callback=None,
sheet=None,
mode=None,
halign=Alignment.CENTER,
valign=Alignment.CENTER,
previous=None,
min_word_length_for_brute_force: int = 8,
min_chars_before_hyphen: int = 2,
min_chars_after_hyphen: int = 2):
@ -329,16 +358,16 @@ class Line(Box):
self._spacing = spacing # (min_spacing, max_spacing)
self._font = font if font else Font() # Use default font if none provided
self._current_width = 0 # Track the current width used
self._words : List['Word'] = []
self._words: List['Word'] = []
self._previous = previous
self._next = None
ascent, descent = self._font.font.getmetrics()
# Store baseline as offset from line origin (top), not absolute position
self._baseline = ascent
self._draw = draw
self._spacing_render = (spacing[0] + spacing[1]) //2
self._spacing_render = (spacing[0] + spacing[1]) // 2
self._position_render = 0
# Hyphenation configuration parameters
self._min_word_length_for_brute_force = min_word_length_for_brute_force
self._min_chars_before_hyphen = min_chars_before_hyphen
@ -373,7 +402,10 @@ class Line(Box):
"""Set the next line in sequence"""
self._next = line
def add_word(self, word: 'Word', part:Optional[Text]=None) -> Tuple[bool, Optional['Text']]:
def add_word(self,
word: 'Word',
part: Optional[Text] = None) -> Tuple[bool,
Optional['Text']]:
"""
Add a word to this line using intelligent word fitting strategies.
@ -392,7 +424,8 @@ class Line(Box):
self._words.append(word)
part.add_line(self)
# Try to add the full word - create LinkText for LinkedWord, regular Text otherwise
# Try to add the full word - create LinkText for LinkedWord, regular Text
# otherwise
if isinstance(word, LinkedWord):
# Import here to avoid circular dependency
from .functional import LinkText
@ -407,14 +440,19 @@ class Line(Box):
params=word.params,
title=word.link_title
)
text = LinkText(link, word.text, word.style, self._draw, source=word, line=self)
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]
)
self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
if not overflow:
# Word fits! Add it completely
self._words.append(word)
@ -426,44 +464,53 @@ class Line(Box):
# Word doesn't fit, remove it and try hyphenation
_ = self._text_objects.pop()
# Step 1: Try pyphen hyphenation
pyphen_splits = word.possible_hyphenation()
valid_splits = []
if pyphen_splits:
# Create Text objects for each possible split and check if they fit
for pair in pyphen_splits:
first_part_text = pair[0] + "-"
second_part_text = pair[1]
# Validate minimum character requirements
if len(pair[0]) < self._min_chars_before_hyphen:
continue
if len(pair[1]) < self._min_chars_after_hyphen:
continue
# Create Text objects
first_text = Text(first_part_text, word.style, self._draw, line=self, source=word)
second_text = Text(second_part_text, word.style, self._draw, line=self, source=word)
first_text = Text(
first_part_text,
word.style,
self._draw,
line=self,
source=word)
second_text = Text(
second_part_text,
word.style,
self._draw,
line=self,
source=word)
# Check if first part fits
self._text_objects.append(first_text)
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
self._text_objects, self._size[0], self._spacing[0], self._spacing[1]
)
self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
_ = self._text_objects.pop()
if not overflow:
# This split fits! Add it to valid options
valid_splits.append((first_text, second_text, spacing, position))
# Step 2: If we have valid pyphen splits, choose the best one
if valid_splits:
# Select the split with the best (minimum) spacing
best_split = min(valid_splits, key=lambda x: x[2])
first_text, second_text, spacing, position = best_split
# Apply the split
self._text_objects.append(first_text)
first_text.line = self
@ -472,48 +519,58 @@ class Line(Box):
self._position_render = position
self._words.append(word)
return True, second_text
# Step 3: Try brute force hyphenation (only for long words)
if len(word.text) >= self._min_word_length_for_brute_force:
# Calculate available space for the word
word_length = sum([text.width for text in self._text_objects])
spacing_length = self._spacing[0] * max(0, len(self._text_objects) - 1)
remaining = self._size[0] - word_length - spacing_length
if remaining > 0:
# Create a hyphenated version to measure
test_text = Text(word.text + "-", word.style, self._draw)
if test_text.width > 0:
# Calculate what fraction of the hyphenated word fits
fraction = remaining / test_text.width
# Convert fraction to character position
# We need at least min_chars_before_hyphen and leave at least min_chars_after_hyphen
# We need at least min_chars_before_hyphen and leave at least
# min_chars_after_hyphen
max_split_pos = len(word.text) - self._min_chars_after_hyphen
min_split_pos = self._min_chars_before_hyphen
# Calculate ideal split position based on available space
ideal_split = int(fraction * len(word.text))
split_pos = max(min_split_pos, min(ideal_split, max_split_pos))
# Ensure we meet minimum requirements
if (split_pos >= self._min_chars_before_hyphen and
len(word.text) - split_pos >= self._min_chars_after_hyphen):
if (split_pos >= self._min_chars_before_hyphen and
len(word.text) - split_pos >= self._min_chars_after_hyphen):
# Create the split
first_part_text = word.text[:split_pos] + "-"
second_part_text = word.text[split_pos:]
first_text = Text(first_part_text, word.style, self._draw, line=self, source=word)
second_text = Text(second_part_text, word.style, self._draw, line=self, source=word)
first_text = Text(
first_part_text,
word.style,
self._draw,
line=self,
source=word)
second_text = Text(
second_part_text,
word.style,
self._draw,
line=self,
source=word)
# Verify the first part actually fits
self._text_objects.append(first_text)
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
self._text_objects, self._size[0], self._spacing[0], self._spacing[1]
)
self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
if not overflow:
# Brute force split works!
first_text.line = self
@ -526,7 +583,7 @@ class Line(Box):
else:
# Doesn't fit, remove it
_ = self._text_objects.pop()
# Step 4: Word cannot be hyphenated or split, move to next line
return False, None
@ -540,8 +597,7 @@ class Line(Box):
# Recalculate spacing and position for current text objects to ensure accuracy
if len(self._text_objects) > 0:
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
self._text_objects, self._size[0], self._spacing[0], self._spacing[1]
)
self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
self._spacing_render = spacing
self._position_render = position
@ -553,13 +609,14 @@ class Line(Box):
# Update text draw context to current draw context
text._draw = self._draw
text.set_origin(np.array([x_cursor, y_cursor]))
# Determine next text object for continuous decoration
next_text = self._text_objects[i + 1] if i + 1 < len(self._text_objects) else None
next_text = self._text_objects[i + 1] if i + \
1 < len(self._text_objects) else None
# Render with next text information for continuous underline/strikethrough
text.render(next_text, self._spacing_render)
x_cursor += self._spacing_render + text.width # x-spacing + width of text object
x_cursor += self._spacing_render + text.width # x-spacing + width of text object
def query_point(self, point: Tuple[int, int]) -> Optional['QueryResult']:
"""
@ -583,7 +640,8 @@ class Line(Box):
size = text_obj.size
# Text origin is at baseline (anchor="ls"), so visual top is origin[1] - ascent
# Bounds should be (x, visual_top, width, height) for proper highlighting
# Bounds should be (x, visual_top, width, height) for proper
# highlighting
visual_top = int(origin[1] - text_obj._ascent)
bounds = (
int(origin[0]),
@ -602,8 +660,9 @@ class Line(Box):
bounds=bounds,
text=text_obj._text,
is_interactive=True,
link_target=text_obj._link.location if hasattr(text_obj, '_link') else None
)
link_target=text_obj._link.location if hasattr(
text_obj,
'_link') else None)
elif isinstance(text_obj, ButtonText):
result = QueryResult(
object=text_obj,
@ -611,8 +670,9 @@ class Line(Box):
bounds=bounds,
text=text_obj._text,
is_interactive=True,
callback=text_obj._callback if hasattr(text_obj, '_callback') else None
)
callback=text_obj._callback if hasattr(
text_obj,
'_callback') else None)
else:
result = QueryResult(
object=text_obj,

View File

@ -5,8 +5,30 @@ This package contains the core abstractions and base classes that form the found
of the pyWebLayout rendering system.
"""
from pyWebLayout.core.base import (
Renderable, Interactable, Layoutable, Queriable,
Hierarchical, Geometric, Styleable, FontRegistry,
MetadataContainer, BlockContainer, ContainerAware
from .base import (
Renderable,
Interactable,
Layoutable,
Queriable,
Hierarchical,
Geometric,
Styleable,
FontRegistry,
MetadataContainer,
BlockContainer,
ContainerAware,
)
__all__ = [
'Renderable',
'Interactable',
'Layoutable',
'Queriable',
'Hierarchical',
'Geometric',
'Styleable',
'FontRegistry',
'MetadataContainer',
'BlockContainer',
'ContainerAware',
]

View File

@ -1,11 +1,9 @@
from abc import ABC
from typing import Optional, Tuple, List, TYPE_CHECKING, Any, Dict
from typing import Optional, Tuple, TYPE_CHECKING, Any, Dict
import numpy as np
from pyWebLayout.style.alignment import Alignment
if TYPE_CHECKING:
from pyWebLayout.core.query import QueryResult
from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration
@ -14,57 +12,62 @@ class Renderable(ABC):
Abstract base class for any object that can be rendered to an image.
All renderable objects must implement the render method.
"""
def render(self):
"""
Render the object to an image.
Returns:
PIL.Image: The rendered image
"""
pass
@property
def origin(self):
return self._origin
class Interactable(ABC):
"""
Abstract base class for any object that can be interacted with.
Interactable objects must have a callback that is executed when interacted with.
"""
def __init__(self, callback=None):
"""
Initialize an interactable object.
Args:
callback: The function to call when this object is interacted with
"""
self._callback = callback
def interact(self, point: np.generic):
"""
Handle interaction at the given point.
Args:
point: The coordinates of the interaction
Returns:
The result of calling the callback function with the point
"""
if self._callback is None:
return None
return self._callback(point)
class Layoutable(ABC):
"""
Abstract base class for any object that can be laid out.
Layoutable objects must implement the layout method which arranges their contents.
"""
def layout(self):
"""
Layout the object's contents.
This method should be called before rendering to properly arrange the object's contents.
"""
pass
class Queriable(ABC):
@ -181,15 +184,15 @@ class FontRegistry:
self._fonts: Dict[str, 'Font'] = {}
def get_or_create_font(self,
font_path: Optional[str] = None,
font_size: int = 16,
colour: Tuple[int, int, int] = (0, 0, 0),
weight: 'FontWeight' = None,
style: 'FontStyle' = None,
decoration: 'TextDecoration' = None,
background: Optional[Tuple[int, int, int, int]] = None,
language: str = "en_EN",
min_hyphenation_width: Optional[int] = None) -> 'Font':
font_path: Optional[str] = None,
font_size: int = 16,
colour: Tuple[int, int, int] = (0, 0, 0),
weight: 'FontWeight' = None,
style: 'FontStyle' = None,
decoration: 'TextDecoration' = None,
background: Optional[Tuple[int, int, int, int]] = None,
language: str = "en_EN",
min_hyphenation_width: Optional[int] = None) -> 'Font':
"""
Get or create a font with the specified properties.
@ -222,7 +225,11 @@ class FontRegistry:
decoration = TextDecoration.NONE
# If we have a parent with font management, delegate to parent
if hasattr(self, '_parent') and self._parent and hasattr(self._parent, 'get_or_create_font'):
if hasattr(
self,
'_parent') and self._parent and hasattr(
self._parent,
'get_or_create_font'):
return self._parent.get_or_create_font(
font_path=font_path,
font_size=font_size,
@ -409,8 +416,8 @@ class ContainerAware:
"""
if not hasattr(container, required_method):
raise AttributeError(
f"Container {type(container).__name__} must have a '{required_method}' method"
)
f"Container {
type(container).__name__} must have a '{required_method}' method")
@classmethod
def _inherit_style(cls, container, style=None):

View File

@ -8,7 +8,7 @@ and managing their callbacks. Supports multiple binding strategies:
- Type-based batch operations
"""
from typing import Dict, List, Optional, Callable, Any
from typing import Dict, List, Optional, Callable
from pyWebLayout.core.base import Interactable
@ -30,7 +30,7 @@ class CallbackRegistry:
"""Initialize an empty callback registry."""
self._by_reference: Dict[int, Interactable] = {} # id(obj) -> obj
self._by_id: Dict[str, Interactable] = {} # HTML id or auto id -> obj
self._by_type: Dict[str, List[Interactable]] = {} # type name -> [objs]
self._by_type: Dict[str, List[Interactable]] = {} # type name -> [objs]
self._auto_counter: int = 0
def register(self, obj: Interactable, html_id: Optional[str] = None) -> str:

View File

@ -148,7 +148,8 @@ class HighlightManager:
self.highlights.clear()
self._save_highlights()
def get_highlights_for_page(self, page_bounds: Tuple[int, int, int, int]) -> List[Highlight]:
def get_highlights_for_page(
self, page_bounds: Tuple[int, int, int, int]) -> List[Highlight]:
"""
Get highlights that appear on a specific page.
@ -165,7 +166,7 @@ class HighlightManager:
# Check if any highlight bounds overlap with page
for hx, hy, hw, hh in highlight.bounds:
if (hx < page_x + page_w and hx + hw > page_x and
hy < page_y + page_h and hy + hh > page_y):
hy < page_y + page_h and hy + hh > page_y):
page_highlights.append(highlight)
break

View File

@ -9,7 +9,6 @@ and text selection.
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional, Tuple, List, Any, TYPE_CHECKING
import numpy as np
if TYPE_CHECKING:
from pyWebLayout.core.base import Queriable

View File

@ -6,4 +6,3 @@ including HTML, EPUB, and other document formats.
"""
# Readers
from pyWebLayout.io.readers.epub_reader import EPUBReader

View File

@ -8,13 +8,12 @@ to pyWebLayout's abstract document model.
import os
import zipfile
import tempfile
from typing import Dict, List, Optional, Any, Tuple, Callable
from typing import Dict, List, Optional, Any, Callable
import xml.etree.ElementTree as ET
import re
import urllib.parse
from PIL import Image as PILImage, ImageOps
from pyWebLayout.abstract.document import Document, Book, Chapter, MetadataType
from pyWebLayout.abstract.document import Book, Chapter, MetadataType
from pyWebLayout.abstract.block import PageBreak
from pyWebLayout.io.readers.html_extraction import parse_html_string
@ -33,38 +32,39 @@ def default_eink_processor(img: PILImage.Image) -> PILImage.Image:
"""
Process image for 4-bit e-ink display using PIL only.
Applies histogram equalization and 4-bit quantization.
Args:
img: PIL Image to process
Returns:
Processed PIL Image in L mode (grayscale) with 4-bit quantization
"""
# Convert to grayscale if needed
if img.mode != 'L':
img = img.convert('L')
# Apply histogram equalization for contrast enhancement
img = ImageOps.equalize(img)
# Quantize to 4-bit (16 grayscale levels: 0, 17, 34, ..., 255)
img = img.point(lambda x: (x // 16) * 17)
return img
class EPUBReader:
"""
Reader for EPUB documents.
This class extracts content from EPUB files and converts it to
pyWebLayout's abstract document model.
"""
def __init__(self, epub_path: str, image_processor: Optional[Callable[[PILImage.Image], PILImage.Image]] = default_eink_processor):
def __init__(self, epub_path: str, image_processor: Optional[Callable[[
PILImage.Image], PILImage.Image]] = default_eink_processor):
"""
Initialize an EPUB reader.
Args:
epub_path: Path to the EPUB file
image_processor: Optional function to process images for display optimization.
@ -82,11 +82,11 @@ class EPUBReader:
self.spine = []
self.manifest = {}
self.cover_id = None # ID of the cover image in manifest
def read(self) -> Book:
"""
Read the EPUB file and convert it to a Book.
Returns:
Book: The parsed book
"""
@ -100,45 +100,47 @@ class EPUBReader:
# Add chapters to the book
self._add_chapters()
# Process images for e-ink display optimization
self._process_content_images()
return self.book
finally:
# Clean up temporary files
if self.temp_dir:
import shutil
shutil.rmtree(self.temp_dir, ignore_errors=True)
def _extract_epub(self):
"""Extract the EPUB file to a temporary directory."""
with zipfile.ZipFile(self.epub_path, 'r') as zip_ref:
zip_ref.extractall(self.temp_dir)
# Find the content directory (typically OEBPS or OPS)
container_path = os.path.join(self.temp_dir, 'META-INF', 'container.xml')
if os.path.exists(container_path):
tree = ET.parse(container_path)
root = tree.getroot()
# Get the path to the package document (content.opf)
for rootfile in root.findall('.//{urn:oasis:names:tc:opendocument:xmlns:container}rootfile'):
for rootfile in root.findall(
'.//{urn:oasis:names:tc:opendocument:xmlns:container}rootfile'):
full_path = rootfile.get('full-path')
if full_path:
self.content_dir = os.path.dirname(os.path.join(self.temp_dir, full_path))
self.content_dir = os.path.dirname(
os.path.join(self.temp_dir, full_path))
return
# Fallback: look for common content directories
for content_dir in ['OEBPS', 'OPS', 'Content']:
if os.path.exists(os.path.join(self.temp_dir, content_dir)):
self.content_dir = os.path.join(self.temp_dir, content_dir)
return
# If no content directory found, use the root
self.content_dir = self.temp_dir
def _parse_package_document(self):
"""Parse the package document (content.opf)."""
# Find the package document
@ -150,27 +152,27 @@ class EPUBReader:
break
if opf_path:
break
if not opf_path:
raise ValueError("No package document (.opf) found in EPUB")
# Parse the package document
tree = ET.parse(opf_path)
root = tree.getroot()
# Parse metadata
self._parse_metadata(root)
# Parse manifest
self._parse_manifest(root)
# Parse spine
self._parse_spine(root)
def _parse_metadata(self, root: ET.Element):
"""
Parse metadata from the package document.
Args:
root: Root element of the package document
"""
@ -178,14 +180,14 @@ class EPUBReader:
metadata_elem = root.find('.//{{{0}}}metadata'.format(NAMESPACES['opf']))
if metadata_elem is None:
return
# Parse DC metadata
for elem in metadata_elem:
if elem.tag.startswith('{{{0}}}'.format(NAMESPACES['dc'])):
# Get the local name (without namespace)
name = elem.tag.split('}', 1)[1]
value = elem.text
if name == 'title':
self.metadata['title'] = value
elif name == 'creator':
@ -207,20 +209,20 @@ class EPUBReader:
else:
# Store other metadata
self.metadata[name] = value
# Parse meta elements for cover reference
for meta in metadata_elem.findall('.//{{{0}}}meta'.format(NAMESPACES['opf'])):
name = meta.get('name')
content = meta.get('content')
if name == 'cover' and content:
# This is a reference to the cover image in the manifest
self.cover_id = content
def _parse_manifest(self, root: ET.Element):
"""
Parse manifest from the package document.
Args:
root: Root element of the package document
"""
@ -228,28 +230,28 @@ class EPUBReader:
manifest_elem = root.find('.//{{{0}}}manifest'.format(NAMESPACES['opf']))
if manifest_elem is None:
return
# Parse items
for item in manifest_elem.findall('.//{{{0}}}item'.format(NAMESPACES['opf'])):
id = item.get('id')
href = item.get('href')
media_type = item.get('media-type')
if id and href:
# Resolve relative path
href = urllib.parse.unquote(href)
path = os.path.normpath(os.path.join(self.content_dir, href))
self.manifest[id] = {
'href': href,
'path': path,
'media_type': media_type
}
def _parse_spine(self, root: ET.Element):
"""
Parse spine from the package document.
Args:
root: Root element of the package document
"""
@ -257,21 +259,25 @@ class EPUBReader:
spine_elem = root.find('.//{{{0}}}spine'.format(NAMESPACES['opf']))
if spine_elem is None:
return
# Get the toc attribute (NCX file ID)
toc_id = spine_elem.get('toc')
if toc_id and toc_id in self.manifest:
self.toc_path = self.manifest[toc_id]['path']
# Parse itemrefs
for itemref in spine_elem.findall('.//{{{0}}}itemref'.format(NAMESPACES['opf'])):
for itemref in spine_elem.findall(
'.//{{{0}}}itemref'.format(NAMESPACES['opf'])):
idref = itemref.get('idref')
if idref and idref in self.manifest:
self.spine.append(idref)
def _parse_toc(self):
"""Parse the table of contents."""
if not hasattr(self, 'toc_path') or not self.toc_path or not os.path.exists(self.toc_path):
if not hasattr(
self,
'toc_path') or not self.toc_path or not os.path.exists(
self.toc_path):
# Try to find the toc.ncx file
for root, dirs, files in os.walk(self.content_dir):
for file in files:
@ -280,27 +286,30 @@ class EPUBReader:
break
if hasattr(self, 'toc_path') and self.toc_path:
break
if not hasattr(self, 'toc_path') or not self.toc_path or not os.path.exists(self.toc_path):
if not hasattr(
self,
'toc_path') or not self.toc_path or not os.path.exists(
self.toc_path):
# No TOC found
return
# Parse the NCX file
tree = ET.parse(self.toc_path)
root = tree.getroot()
# Parse navMap
nav_map = root.find('.//{{{0}}}navMap'.format(NAMESPACES['ncx']))
if nav_map is None:
return
# Parse navPoints
self._parse_nav_points(nav_map, [])
def _parse_nav_points(self, parent: ET.Element, path: List[Dict[str, Any]]):
"""
Recursively parse navPoints from the NCX file.
Args:
parent: Parent element containing navPoints
path: Current path in the TOC hierarchy
@ -309,16 +318,17 @@ class EPUBReader:
# Get navPoint attributes
id = nav_point.get('id')
play_order = nav_point.get('playOrder')
# Get navLabel
nav_label = nav_point.find('.//{{{0}}}navLabel'.format(NAMESPACES['ncx']))
text_elem = nav_label.find('.//{{{0}}}text'.format(NAMESPACES['ncx'])) if nav_label else None
text_elem = nav_label.find(
'.//{{{0}}}text'.format(NAMESPACES['ncx'])) if nav_label else None
label = text_elem.text if text_elem is not None else ""
# Get content
content = nav_point.find('.//{{{0}}}content'.format(NAMESPACES['ncx']))
src = content.get('src') if content is not None else ""
# Create a TOC entry
entry = {
'id': id,
@ -327,78 +337,83 @@ class EPUBReader:
'play_order': play_order,
'children': []
}
# Add to TOC
if path:
path[-1]['children'].append(entry)
else:
self.toc.append(entry)
# Parse child navPoints
self._parse_nav_points(nav_point, path + [entry])
def _create_book(self):
"""Create a Book object from the parsed metadata."""
# Set book metadata
if 'title' in self.metadata:
self.book.set_title(self.metadata['title'])
if 'creator' in self.metadata:
self.book.set_metadata(MetadataType.AUTHOR, self.metadata['creator'])
if 'language' in self.metadata:
self.book.set_metadata(MetadataType.LANGUAGE, self.metadata['language'])
if 'description' in self.metadata:
self.book.set_metadata(MetadataType.DESCRIPTION, self.metadata['description'])
self.book.set_metadata(
MetadataType.DESCRIPTION,
self.metadata['description'])
if 'subjects' in self.metadata:
self.book.set_metadata(MetadataType.KEYWORDS, ', '.join(self.metadata['subjects']))
self.book.set_metadata(
MetadataType.KEYWORDS, ', '.join(
self.metadata['subjects']))
if 'date' in self.metadata:
self.book.set_metadata(MetadataType.PUBLICATION_DATE, self.metadata['date'])
if 'identifier' in self.metadata:
self.book.set_metadata(MetadataType.IDENTIFIER, self.metadata['identifier'])
if 'publisher' in self.metadata:
self.book.set_metadata(MetadataType.PUBLISHER, self.metadata['publisher'])
def _add_cover_chapter(self):
"""Add a cover chapter if a cover image is available."""
if not self.cover_id or self.cover_id not in self.manifest:
return
# Get the cover image path from the manifest
cover_item = self.manifest[self.cover_id]
cover_path = cover_item['path']
# Check if the file exists
if not os.path.exists(cover_path):
print(f"Warning: Cover image file not found: {cover_path}")
return
# Create a cover chapter
cover_chapter = self.book.create_chapter("Cover", 0)
try:
# Create an Image block for the cover
from pyWebLayout.abstract.block import Image as AbstractImage
from PIL import Image as PILImage
import io
# Load the image into memory before the temp directory is cleaned up
# We need to fully copy the image data to ensure it persists after temp cleanup
# We need to fully copy the image data to ensure it persists after temp
# cleanup
with open(cover_path, 'rb') as f:
image_bytes = f.read()
# Create PIL image from bytes in memory
pil_image = PILImage.open(io.BytesIO(image_bytes))
pil_image.load() # Force loading into memory
# Create a copy to ensure all data is in memory
pil_image = pil_image.copy()
# Apply image processing if enabled
if self.image_processor:
try:
@ -406,20 +421,21 @@ class EPUBReader:
except Exception as e:
print(f"Warning: Image processing failed for cover: {str(e)}")
# Continue with unprocessed image
# Create an AbstractImage block with the cover image path
cover_image = AbstractImage(source=cover_path, alt_text="Cover Image")
# Set dimensions from the loaded image
cover_image._width = pil_image.width
cover_image._height = pil_image.height
# Store the loaded PIL image in the abstract image so it persists after temp cleanup
# Store the loaded PIL image in the abstract image so it persists after
# temp cleanup
cover_image._loaded_image = pil_image
# Add the image to the cover chapter
cover_chapter.add_block(cover_image)
except Exception as e:
print(f"Error creating cover chapter: {str(e)}")
import traceback
@ -427,16 +443,16 @@ class EPUBReader:
# If we can't create the cover image, remove the chapter
if hasattr(self.book, 'chapters') and cover_chapter in self.book.chapters:
self.book.chapters.remove(cover_chapter)
def _process_chapter_images(self, chapter: Chapter):
"""
Process images in a single chapter.
Args:
chapter: The chapter containing images to process
"""
from pyWebLayout.abstract.block import Image as AbstractImage
for block in chapter.blocks:
if isinstance(block, AbstractImage):
# Only process if image has been loaded and processor is enabled
@ -444,25 +460,28 @@ class EPUBReader:
try:
block._loaded_image = self.image_processor(block._loaded_image)
except Exception as e:
print(f"Warning: Image processing failed for image '{block.alt_text}': {str(e)}")
print(
f"Warning: Image processing failed for image '{
block.alt_text}': {
str(e)}")
# Continue with unprocessed image
def _process_content_images(self):
"""Apply image processing to all images in chapters."""
if not self.image_processor:
return
for chapter in self.book.chapters:
self._process_chapter_images(chapter)
def _add_chapters(self):
"""Add chapters to the book based on the spine and TOC."""
# Add cover chapter first if available
self._add_cover_chapter()
# Create a mapping from src to TOC entry
toc_map = {}
def add_to_toc_map(entries):
for entry in entries:
if entry['src']:
@ -470,58 +489,58 @@ class EPUBReader:
src_parts = entry['src'].split('#', 1)
path = src_parts[0]
toc_map[path] = entry
# Process children
if entry['children']:
add_to_toc_map(entry['children'])
add_to_toc_map(self.toc)
# Process spine items
# Start from chapter_index = 1 if cover was added, otherwise 0
chapter_index = 1 if (self.cover_id and self.cover_id in self.manifest) else 0
for i, idref in enumerate(self.spine):
if idref not in self.manifest:
continue
item = self.manifest[idref]
path = item['path']
href = item['href']
# Skip navigation files
if (idref == 'nav' or
item.get('media_type') == 'application/xhtml+xml' and
('nav' in href.lower() or 'toc' in href.lower())):
if (idref == 'nav' or
item.get('media_type') == 'application/xhtml+xml' and
('nav' in href.lower() or 'toc' in href.lower())):
continue
# Check if this item is in the TOC
chapter_title = None
if href in toc_map:
chapter_title = toc_map[href]['label']
# Create a chapter
chapter_index += 1
chapter = self.book.create_chapter(chapter_title, chapter_index)
# Parse the HTML content
try:
# Read the HTML file
with open(path, 'r', encoding='utf-8') as f:
html = f.read()
# Parse HTML and add blocks to chapter
blocks = parse_html_string(html, document=self.book)
# Copy blocks to the chapter
for block in blocks:
chapter.add_block(block)
# Add a PageBreak after the chapter to ensure next chapter starts on new page
# This helps maintain chapter boundaries during pagination
chapter.add_block(PageBreak())
except Exception as e:
print(f"Error parsing chapter {i+1}: {str(e)}")
print(f"Error parsing chapter {i + 1}: {str(e)}")
# Add an error message block
from pyWebLayout.abstract.block import Paragraph
from pyWebLayout.abstract.inline import Word
@ -529,7 +548,11 @@ class EPUBReader:
error_para = Paragraph()
# Create a default font style for the error message
default_font = Font()
error_para.add_word(Word(f"Error loading chapter: {str(e)}", default_font))
error_para.add_word(
Word(
f"Error loading chapter: {
str(e)}",
default_font))
chapter.add_block(error_para)
# Still add PageBreak even after error
chapter.add_block(PageBreak())
@ -538,10 +561,10 @@ class EPUBReader:
def read_epub(epub_path: str) -> Book:
"""
Read an EPUB file and convert it to a Book.
Args:
epub_path: Path to the EPUB file
Returns:
Book: The parsed book
"""

View File

@ -6,10 +6,9 @@ used by pyWebLayout, including paragraphs, headings, lists, tables, and inline f
Each handler function has a robust signature that handles style hints, CSS classes, and attributes.
"""
import re
from typing import List, Dict, Any, Optional, Union, Callable, Tuple, NamedTuple
from bs4 import BeautifulSoup, Tag, NavigableString
from pyWebLayout.abstract.inline import Word, FormattedSpan
from pyWebLayout.abstract.inline import Word
from pyWebLayout.abstract.block import (
Block,
Paragraph,
@ -27,8 +26,6 @@ from pyWebLayout.abstract.block import (
Image,
)
from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration
from pyWebLayout.style.abstract_style import AbstractStyle, FontFamily, FontSize
from pyWebLayout.style import Alignment as TextAlign
class StyleContext(NamedTuple):
@ -72,7 +69,9 @@ class StyleContext(NamedTuple):
return self._replace(parent_elements=self.parent_elements + [element_name])
def create_base_context(base_font: Optional[Font] = None, document=None) -> StyleContext:
def create_base_context(
base_font: Optional[Font] = None,
document=None) -> StyleContext:
"""
Create a base style context with default values.
@ -130,7 +129,8 @@ def apply_element_styling(context: StyleContext, element: Tag) -> StyleContext:
new_context = new_context.with_css_styles(css_styles)
# Apply element-specific default styles
font = apply_element_font_styles(new_context.font, tag_name, css_styles, new_context)
font = apply_element_font_styles(
new_context.font, tag_name, css_styles, new_context)
new_context = new_context.with_font(font)
# Apply background from styles
@ -158,9 +158,11 @@ def parse_inline_styles(style_text: str) -> Dict[str, str]:
return styles
def apply_element_font_styles(
font: Font, tag_name: str, css_styles: Dict[str, str], context: Optional[StyleContext] = None
) -> Font:
def apply_element_font_styles(font: Font,
tag_name: str,
css_styles: Dict[str,
str],
context: Optional[StyleContext] = None) -> Font:
"""
Apply font styling based on HTML element and CSS styles.
Uses document's font registry when available to avoid creating duplicate fonts.
@ -273,17 +275,19 @@ def apply_element_font_styles(
pass
# Use document's style registry if available to avoid creating duplicate styles
if context and context.document and hasattr(context.document, 'get_or_create_style'):
if context and context.document and hasattr(
context.document, 'get_or_create_style'):
# Create an abstract style first
from pyWebLayout.style.abstract_style import FontFamily, FontSize
# Map font properties to abstract style properties
font_family = FontFamily.SERIF # Default - could be enhanced to detect from font_path
if font_size:
font_size_value = font_size if isinstance(font_size, int) else FontSize.MEDIUM
font_size_value = font_size if isinstance(
font_size, int) else FontSize.MEDIUM
else:
font_size_value = FontSize.MEDIUM
# Create abstract style and register it
style_id, abstract_style = context.document.get_or_create_style(
font_family=font_family,
@ -294,7 +298,7 @@ def apply_element_font_styles(
color=colour,
language=language
)
# Get the concrete font for this style
return context.document.get_font_for_style(abstract_style)
elif context and context.document and hasattr(context.document, 'get_or_create_font'):
@ -359,7 +363,7 @@ def extract_text_content(element: Tag, context: StyleContext) -> List[Word]:
"""
from pyWebLayout.abstract.inline import LinkedWord
from pyWebLayout.abstract.functional import LinkType
words = []
for child in element.children:
@ -385,14 +389,14 @@ def extract_text_content(element: Tag, context: StyleContext) -> List[Word]:
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(
@ -409,7 +413,7 @@ def extract_text_content(element: Tag, context: StyleContext) -> List[Word]:
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",
@ -435,7 +439,8 @@ def extract_text_content(element: Tag, context: StyleContext) -> List[Word]:
child_words = extract_text_content(child, child_context)
words.extend(child_words)
else:
# Block element - shouldn't happen in well-formed HTML but handle gracefully
# Block element - shouldn't happen in well-formed HTML but handle
# gracefully
child_context = apply_element_styling(context, child)
child_result = process_element(child, child_context)
if isinstance(child_result, list):
@ -469,7 +474,8 @@ def process_element(
# Handler function signatures:
# All handlers receive (element: Tag, context: StyleContext) -> Union[Block, List[Block], None]
# All handlers receive (element: Tag, context: StyleContext) ->
# Union[Block, List[Block], None]
def paragraph_handler(element: Tag, context: StyleContext) -> Paragraph:

View File

@ -5,16 +5,22 @@ import numpy as np
from pyWebLayout.concrete import Page, Line, Text
from pyWebLayout.concrete.image import RenderableImage
from pyWebLayout.concrete.functional import LinkText, ButtonText, FormFieldText
from pyWebLayout.concrete.functional import ButtonText, FormFieldText
from pyWebLayout.concrete.table import TableRenderer, TableStyle
from pyWebLayout.abstract import Paragraph, Word, Link
from pyWebLayout.abstract import Paragraph, Word
from pyWebLayout.abstract.block import Image as AbstractImage, PageBreak, Table
from pyWebLayout.abstract.functional import Button, Form, FormField
from pyWebLayout.abstract.inline import LinkedWord
from pyWebLayout.style.concrete_style import ConcreteStyleRegistry, RenderingContext, StyleResolver
from pyWebLayout.style import Font, Alignment
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]]:
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]]:
"""
Layout a paragraph of text within a given page.
@ -44,7 +50,7 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
# paragraph.style is already a Font object (concrete), not AbstractStyle
# We need to get word spacing constraints from the Font's abstract style if available
# For now, use reasonable defaults based on font size
if isinstance(paragraph.style, Font):
# paragraph.style is already a Font (concrete style)
font = paragraph.style
@ -63,7 +69,7 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
base_font_size = 16
else:
base_font_size = int(paragraph.style.font_size)
rendering_context = RenderingContext(base_font_size=base_font_size)
style_resolver = StyleResolver(rendering_context)
style_registry = ConcreteStyleRegistry(style_resolver)
@ -76,7 +82,11 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
text_align = concrete_style.text_align
# Apply page-level word spacing override if specified
if hasattr(page.style, 'word_spacing') and isinstance(page.style.word_spacing, int) and page.style.word_spacing > 0:
if hasattr(
page.style,
'word_spacing') and isinstance(
page.style.word_spacing,
int) and page.style.word_spacing > 0:
# Add the page-level word spacing to both min and max constraints
min_ws, max_ws = word_spacing_constraints
word_spacing_constraints = (
@ -87,7 +97,7 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
# Apply alignment override if provided
if alignment_override is not None:
text_align = alignment_override
# Cap font size to page maximum if needed
if font.font_size > page.style.max_font_size:
font = Font(
@ -99,7 +109,7 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
decoration=font.decoration,
background=font.background
)
# Calculate baseline-to-baseline spacing: font size + additional line spacing
# This is the vertical distance between baselines of consecutive lines
# Formula: baseline_spacing = font_size + line_spacing (absolute pixels)
@ -108,18 +118,20 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
if not isinstance(line_spacing_value, int):
line_spacing_value = 5
baseline_spacing = font.font_size + line_spacing_value
# Get font metrics for boundary checking
ascent, descent = font.font.getmetrics()
def create_new_line(word: Optional[Union[Word, Text]] = None, is_first_line: bool = False) -> Optional[Line]:
def create_new_line(word: Optional[Union[Word, Text]] = None,
is_first_line: bool = False) -> Optional[Line]:
"""Helper function to create a new line, returns None if page is full."""
# Check if this line's baseline and descenders would fit on the page
if not page.can_fit_line(baseline_spacing, ascent, descent):
return None
# For the first line, position it so text starts at the top boundary
# For subsequent lines, use current y_offset which tracks baseline-to-baseline spacing
# For subsequent lines, use current y_offset which tracks
# baseline-to-baseline spacing
if is_first_line:
# Position line origin so that baseline (origin + ascent) is close to top
# We want minimal space above the text, so origin should be at boundary
@ -131,9 +143,9 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
# Create a temporary Text object to calculate word width
if word:
temp_text = Text.from_word(word, page.draw)
word_width = temp_text.width
temp_text.width
else:
word_width = 0
pass
return Line(
spacing=word_spacing_constraints,
@ -163,7 +175,7 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
# 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:
@ -195,7 +207,19 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
# Word is too wide for the line, we need to hyphenate it
if len(word.text) >= 6:
# Try to hyphenate the word
splits = [(Text(pair[0], word.style, page.draw, line=current_line, source=word), Text(pair[1], word.style, page.draw, line=current_line, source=word)) for pair in word.possible_hyphenation()]
splits = [
(Text(
pair[0],
word.style,
page.draw,
line=current_line,
source=word),
Text(
pair[1],
word.style,
page.draw,
line=current_line,
source=word)) for pair in word.possible_hyphenation()]
if len(splits) > 0:
# Use the first hyphenation point
first_part, second_part = splits[0]
@ -230,15 +254,15 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
def pagebreak_layouter(page_break: PageBreak, page: Page) -> bool:
"""
Handle a page break element.
A page break signals that all subsequent content should start on a new page.
This function always returns False to indicate that the current page is complete
and a new page should be created for subsequent content.
Args:
page_break: The PageBreak block
page: The current page (not used, but kept for consistency)
Returns:
bool: Always False to force creation of a new page
"""
@ -246,48 +270,49 @@ def pagebreak_layouter(page_break: PageBreak, page: Page) -> bool:
return False
def image_layouter(image: AbstractImage, page: Page, max_width: Optional[int] = 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
"""
# 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)
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
# Access page.draw to ensure canvas is initialized
_ = page.draw
renderable_image = RenderableImage(
image=image,
canvas=page._canvas,
@ -298,14 +323,17 @@ def image_layouter(image: AbstractImage, page: Page, max_width: Optional[int] =
halign=Alignment.CENTER,
valign=Alignment.TOP
)
# Add to page
page.add_child(renderable_image)
return True
def table_layouter(table: Table, page: Page, style: Optional[TableStyle] = None) -> bool:
def table_layouter(
table: Table,
page: Page,
style: Optional[TableStyle] = None) -> bool:
"""
Layout a table within a given page.
@ -356,8 +384,17 @@ def table_layouter(table: Table, page: Page, style: Optional[TableStyle] = None)
return True
def button_layouter(button: Button, page: Page, font: Optional[Font] = None,
padding: Tuple[int, int, int, int] = (4, 8, 4, 8)) -> Tuple[bool, str]:
def button_layouter(button: Button,
page: Page,
font: Optional[Font] = None,
padding: Tuple[int,
int,
int,
int] = (4,
8,
4,
8)) -> Tuple[bool,
str]:
"""
Layout a button within a given page and register it for callback binding.
@ -510,17 +547,17 @@ def form_layouter(form: Form, page: Page, font: Optional[Font] = None,
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
- table_layouter for tables
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.
@ -538,24 +575,28 @@ class DocumentLayouter:
context = RenderingContext()
style_resolver = StyleResolver(context)
self.style_registry = ConcreteStyleRegistry(style_resolver)
def layout_paragraph(self, paragraph: Paragraph, start_word: int = 0,
pretext: Optional[Text] = None) -> Tuple[bool, Optional[int], Optional[Text]]:
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:
max_height: Optional[int] = None) -> bool:
"""
Layout an image using the image_layouter.
@ -582,8 +623,17 @@ class DocumentLayouter:
"""
return table_layouter(table, self.page, style)
def layout_button(self, button: Button, font: Optional[Font] = None,
padding: Tuple[int, int, int, int] = (4, 8, 4, 8)) -> Tuple[bool, str]:
def layout_button(self,
button: Button,
font: Optional[Font] = None,
padding: Tuple[int,
int,
int,
int] = (4,
8,
4,
8)) -> Tuple[bool,
str]:
"""
Layout a button using the button_layouter.
@ -612,7 +662,8 @@ class DocumentLayouter:
"""
return form_layouter(form, self.page, font, field_spacing)
def layout_document(self, elements: List[Union[Paragraph, AbstractImage, Table, Button, Form]]) -> bool:
def layout_document(
self, elements: List[Union[Paragraph, AbstractImage, Table, Button, Form]]) -> bool:
"""
Layout a list of abstract elements (paragraphs, images, tables, buttons, and forms).

View File

@ -13,18 +13,12 @@ with features like:
from __future__ import annotations
from dataclasses import dataclass, asdict
from typing import List, Dict, Tuple, Optional, Union, Generator, Any
from enum import Enum
import json
import multiprocessing
from concurrent.futures import ProcessPoolExecutor, as_completed
import threading
import time
from typing import List, Dict, Tuple, Optional, Any
from pyWebLayout.abstract.block import Block, Paragraph, Heading, HeadingLevel, Table, HList
from pyWebLayout.abstract.inline import Word
from pyWebLayout.concrete.page import Page
from pyWebLayout.concrete.text import Line, Text
from pyWebLayout.concrete.text import Text
from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.style import Font
from pyWebLayout.layout.document_layouter import paragraph_layouter
@ -38,32 +32,33 @@ class RenderingPosition:
"""
chapter_index: int = 0 # Which chapter (based on headings)
block_index: int = 0 # Which block within chapter
word_index: int = 0 # Which word within block (for paragraphs)
# Which word within block (for paragraphs)
word_index: int = 0
table_row: int = 0 # Which row for tables
table_col: int = 0 # Which column for tables
list_item_index: int = 0 # Which item for lists
remaining_pretext: Optional[str] = None # Hyphenated word continuation
page_y_offset: int = 0 # Vertical position on page
def to_dict(self) -> Dict[str, Any]:
"""Serialize position for saving to file/database"""
return asdict(self)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'RenderingPosition':
"""Deserialize position from saved state"""
return cls(**data)
def copy(self) -> 'RenderingPosition':
"""Create a copy of this position"""
return RenderingPosition(**asdict(self))
def __eq__(self, other) -> bool:
"""Check if two positions are equal"""
if not isinstance(other, RenderingPosition):
return False
return asdict(self) == asdict(other)
def __hash__(self) -> int:
"""Make position hashable for use as dict key"""
return hash(tuple(asdict(self).values()))
@ -71,8 +66,13 @@ class RenderingPosition:
class ChapterInfo:
"""Information about a chapter/section in the document"""
def __init__(self, title: str, level: HeadingLevel, position: RenderingPosition, block_index: int):
def __init__(
self,
title: str,
level: HeadingLevel,
position: RenderingPosition,
block_index: int):
self.title = title
self.level = level
self.position = position
@ -84,16 +84,16 @@ class ChapterNavigator:
Handles chapter/section navigation based on HTML heading structure (H1-H6).
Builds a table of contents and provides navigation capabilities.
"""
def __init__(self, blocks: List[Block]):
self.blocks = blocks
self.chapters: List[ChapterInfo] = []
self._build_chapter_map()
def _build_chapter_map(self):
"""Scan blocks for headings and build chapter navigation map"""
current_chapter_index = 0
for block_index, block in enumerate(self.blocks):
if isinstance(block, Heading):
# Create position for this heading
@ -105,23 +105,23 @@ class ChapterNavigator:
table_col=0,
list_item_index=0
)
# Extract heading text
heading_text = self._extract_heading_text(block)
chapter_info = ChapterInfo(
title=heading_text,
level=block.level,
position=position,
block_index=block_index
)
self.chapters.append(chapter_info)
# Only increment chapter index for top-level headings (H1)
if block.level == HeadingLevel.H1:
current_chapter_index += 1
def _extract_heading_text(self, heading: Heading) -> str:
"""Extract text content from a heading block"""
words = []
@ -129,33 +129,35 @@ class ChapterNavigator:
if isinstance(word, Word):
words.append(word.text)
return " ".join(words)
def get_table_of_contents(self) -> List[Tuple[str, HeadingLevel, RenderingPosition]]:
def get_table_of_contents(
self) -> List[Tuple[str, HeadingLevel, RenderingPosition]]:
"""Generate table of contents from heading structure"""
return [(chapter.title, chapter.level, chapter.position) for chapter in self.chapters]
return [(chapter.title, chapter.level, chapter.position)
for chapter in self.chapters]
def get_chapter_position(self, chapter_title: str) -> Optional[RenderingPosition]:
"""Get rendering position for a chapter by title"""
for chapter in self.chapters:
if chapter.title.lower() == chapter_title.lower():
return chapter.position
return None
def get_current_chapter(self, position: RenderingPosition) -> Optional[ChapterInfo]:
"""Determine which chapter contains the current position"""
if not self.chapters:
return None
# Find the chapter that contains this position
for i, chapter in enumerate(self.chapters):
# Check if this is the last chapter or if position is before next chapter
if i == len(self.chapters) - 1:
return chapter
next_chapter = self.chapters[i + 1]
if position.chapter_index < next_chapter.position.chapter_index:
return chapter
return self.chapters[0] if self.chapters else None
@ -164,24 +166,24 @@ class FontScaler:
Handles font scaling operations for ereader font size adjustments.
Applies scaling at layout/render time while preserving original font objects.
"""
@staticmethod
def scale_font(font: Font, scale_factor: float) -> Font:
"""
Create a scaled version of a font for layout calculations.
Args:
font: Original font object
scale_factor: Scaling factor (1.0 = no change, 2.0 = double size, etc.)
Returns:
New Font object with scaled size
"""
if scale_factor == 1.0:
return font
scaled_size = max(1, int(font.font_size * scale_factor))
return Font(
font_path=font._font_path,
font_size=scaled_size,
@ -193,13 +195,14 @@ class FontScaler:
language=font.language,
min_hyphenation_width=font.min_hyphenation_width
)
@staticmethod
def scale_word_spacing(spacing: Tuple[int, int], scale_factor: float) -> Tuple[int, int]:
def scale_word_spacing(spacing: Tuple[int, int],
scale_factor: float) -> Tuple[int, int]:
"""Scale word spacing constraints proportionally"""
if scale_factor == 1.0:
return spacing
min_spacing, max_spacing = spacing
return (
max(1, int(min_spacing * scale_factor)),
@ -212,41 +215,49 @@ class BidirectionalLayouter:
Core layout engine supporting both forward and backward page rendering.
Handles font scaling and maintains position state.
"""
def __init__(self, blocks: List[Block], page_style: PageStyle, page_size: Tuple[int, int] = (800, 600), alignment_override=None):
def __init__(self,
blocks: List[Block],
page_style: PageStyle,
page_size: Tuple[int,
int] = (800,
600),
alignment_override=None):
self.blocks = blocks
self.page_style = page_style
self.page_size = page_size
self.chapter_navigator = ChapterNavigator(blocks)
self.alignment_override = alignment_override
def render_page_forward(self, position: RenderingPosition, font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
def render_page_forward(self, position: RenderingPosition,
font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
"""
Render a page starting from the given position, moving forward through the document.
Args:
position: Starting position in document
font_scale: Font scaling factor
Returns:
Tuple of (rendered_page, next_position)
"""
page = Page(size=self.page_size, style=self.page_style)
current_pos = position.copy()
# Start laying out blocks from the current position
while current_pos.block_index < len(self.blocks) and page.free_space()[1] > 0:
# Additional bounds check to prevent IndexError
if current_pos.block_index >= len(self.blocks):
break
block = self.blocks[current_pos.block_index]
# Apply font scaling to the block
scaled_block = self._scale_block_fonts(block, font_scale)
# Try to fit the block on the current page
success, new_pos = self._layout_block_on_page(scaled_block, page, current_pos, font_scale)
success, new_pos = self._layout_block_on_page(
scaled_block, page, current_pos, font_scale)
if not success:
# Block doesn't fit, we're done with this page
@ -262,45 +273,50 @@ class BidirectionalLayouter:
# We've reached the end of the document
current_pos = new_pos
break
current_pos = new_pos
return page, current_pos
def render_page_backward(self, end_position: RenderingPosition, font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
def render_page_backward(self,
end_position: RenderingPosition,
font_scale: float = 1.0) -> Tuple[Page,
RenderingPosition]:
"""
Render a page that ends at the given position, filling backward.
Critical for "previous page" navigation.
Args:
end_position: Position where page should end
font_scale: Font scaling factor
Returns:
Tuple of (rendered_page, start_position)
"""
# This is a complex operation that requires iterative refinement
# We'll start with an estimated start position and refine it
estimated_start = self._estimate_page_start(end_position, font_scale)
# Render forward from estimated start and see if we reach the target
page, actual_end = self.render_page_forward(estimated_start, font_scale)
# If we overshot or undershot, adjust and try again
# This is a simplified implementation - a full version would be more sophisticated
# This is a simplified implementation - a full version would be more
# sophisticated
if self._position_compare(actual_end, end_position) != 0:
# Adjust estimate and try again (simplified)
estimated_start = self._adjust_start_estimate(estimated_start, end_position, actual_end)
estimated_start = self._adjust_start_estimate(
estimated_start, end_position, actual_end)
page, actual_end = self.render_page_forward(estimated_start, font_scale)
return page, estimated_start
def _scale_block_fonts(self, block: Block, font_scale: float) -> Block:
"""Apply font scaling to all fonts in a block"""
if font_scale == 1.0:
return block
# This is a simplified implementation
# In practice, we'd need to handle each block type appropriately
if isinstance(block, (Paragraph, Heading)):
@ -309,20 +325,27 @@ class BidirectionalLayouter:
scaled_block = Heading(block.level, scaled_block_style)
else:
scaled_block = Paragraph(scaled_block_style)
# words_iter() returns tuples of (position, word)
for position, word in block.words_iter():
if isinstance(word, Word):
scaled_word = Word(word.text, FontScaler.scale_font(word.style, font_scale))
scaled_word = Word(
word.text, FontScaler.scale_font(
word.style, font_scale))
scaled_block.add_word(scaled_word)
return scaled_block
return block
def _layout_block_on_page(self, block: Block, page: Page, position: RenderingPosition, font_scale: float) -> Tuple[bool, RenderingPosition]:
def _layout_block_on_page(self,
block: Block,
page: Page,
position: RenderingPosition,
font_scale: float) -> Tuple[bool,
RenderingPosition]:
"""
Try to layout a block on the page starting from the given position.
Returns:
Tuple of (success, new_position)
"""
@ -339,18 +362,23 @@ class BidirectionalLayouter:
new_pos = position.copy()
new_pos.block_index += 1
return True, new_pos
def _layout_paragraph_on_page(self, paragraph: Paragraph, page: Page, position: RenderingPosition, font_scale: float) -> Tuple[bool, RenderingPosition]:
def _layout_paragraph_on_page(self,
paragraph: Paragraph,
page: Page,
position: RenderingPosition,
font_scale: float) -> Tuple[bool,
RenderingPosition]:
"""
Layout a paragraph on the page using the core paragraph_layouter.
Integrates font scaling and position tracking with the proven layout logic.
Args:
paragraph: The paragraph to layout (already scaled if font_scale != 1.0)
page: The page to layout on
position: Current rendering position
font_scale: Font scaling factor (used for context, paragraph should already be scaled)
Returns:
Tuple of (success, new_position)
"""
@ -365,7 +393,7 @@ class BidirectionalLayouter:
line=None,
source=None
)
# Call the core paragraph layouter with alignment override if set
success, failed_word_index, remaining_pretext = paragraph_layouter(
paragraph,
@ -374,10 +402,10 @@ class BidirectionalLayouter:
pretext=pretext_obj,
alignment_override=self.alignment_override
)
# Create new position based on the result
new_pos = position.copy()
if success:
# Paragraph was fully laid out, move to next block
new_pos.block_index += 1
@ -389,25 +417,35 @@ class BidirectionalLayouter:
if failed_word_index is not None:
# Update position to the word that didn't fit
new_pos.word_index = failed_word_index
# Convert Text object back to string if there's remaining pretext
if remaining_pretext is not None and hasattr(remaining_pretext, 'text'):
new_pos.remaining_pretext = remaining_pretext.text
else:
new_pos.remaining_pretext = None
return False, new_pos
else:
# No specific word failed, but layout wasn't successful
# This shouldn't normally happen, but handle it gracefully
return False, position
def _layout_heading_on_page(self, heading: Heading, page: Page, position: RenderingPosition, font_scale: float) -> Tuple[bool, RenderingPosition]:
def _layout_heading_on_page(self,
heading: Heading,
page: Page,
position: RenderingPosition,
font_scale: float) -> Tuple[bool,
RenderingPosition]:
"""Layout a heading on the page"""
# Similar to paragraph but with heading-specific styling
return self._layout_paragraph_on_page(heading, page, position, font_scale)
def _layout_table_on_page(self, table: Table, page: Page, position: RenderingPosition, font_scale: float) -> Tuple[bool, RenderingPosition]:
def _layout_table_on_page(self,
table: Table,
page: Page,
position: RenderingPosition,
font_scale: float) -> Tuple[bool,
RenderingPosition]:
"""Layout a table on the page with column fitting and row continuation"""
# This is a complex operation that would need full table layout logic
# For now, skip tables
@ -416,8 +454,13 @@ class BidirectionalLayouter:
new_pos.table_row = 0
new_pos.table_col = 0
return True, new_pos
def _layout_list_on_page(self, hlist: HList, page: Page, position: RenderingPosition, font_scale: float) -> Tuple[bool, RenderingPosition]:
def _layout_list_on_page(self,
hlist: HList,
page: Page,
position: RenderingPosition,
font_scale: float) -> Tuple[bool,
RenderingPosition]:
"""Layout a list on the page"""
# This would need list-specific layout logic
# For now, skip lists
@ -425,33 +468,43 @@ class BidirectionalLayouter:
new_pos.block_index += 1
new_pos.list_item_index = 0
return True, new_pos
def _estimate_page_start(self, end_position: RenderingPosition, font_scale: float) -> RenderingPosition:
def _estimate_page_start(
self,
end_position: RenderingPosition,
font_scale: float) -> RenderingPosition:
"""Estimate where a page should start to end at the given position"""
# This is a simplified heuristic - a full implementation would be more sophisticated
# This is a simplified heuristic - a full implementation would be more
# sophisticated
estimated_start = end_position.copy()
# Move back by an estimated number of blocks that would fit on a page
estimated_blocks_per_page = max(1, int(10 / font_scale)) # Rough estimate
estimated_start.block_index = max(0, end_position.block_index - estimated_blocks_per_page)
estimated_start.block_index = max(
0, end_position.block_index - estimated_blocks_per_page)
estimated_start.word_index = 0
return estimated_start
def _adjust_start_estimate(self, current_start: RenderingPosition, target_end: RenderingPosition, actual_end: RenderingPosition) -> RenderingPosition:
def _adjust_start_estimate(
self,
current_start: RenderingPosition,
target_end: RenderingPosition,
actual_end: RenderingPosition) -> RenderingPosition:
"""Adjust start position estimate based on overshoot/undershoot"""
# Simplified adjustment logic
adjusted = current_start.copy()
comparison = self._position_compare(actual_end, target_end)
if comparison > 0: # Overshot
adjusted.block_index = max(0, adjusted.block_index + 1)
elif comparison < 0: # Undershot
adjusted.block_index = max(0, adjusted.block_index - 1)
return adjusted
def _position_compare(self, pos1: RenderingPosition, pos2: RenderingPosition) -> int:
def _position_compare(self, pos1: RenderingPosition,
pos2: RenderingPosition) -> int:
"""Compare two positions (-1: pos1 < pos2, 0: equal, 1: pos1 > pos2)"""
if pos1.chapter_index != pos2.chapter_index:
return 1 if pos1.chapter_index > pos2.chapter_index else -1
@ -470,16 +523,17 @@ def _add_page_methods():
"""Check if a line of given height can fit on the page"""
available_height = self.content_size[1] - self._current_y_offset
return available_height >= line_height
Page.can_fit_line = can_fit_line
if not hasattr(Page, 'available_width'):
@property
def available_width(self) -> int:
"""Get available width for content"""
return self.content_size[0]
Page.available_width = available_width
# Apply the page methods
_add_page_methods()

View File

@ -9,7 +9,6 @@ into a unified, easy-to-use API.
from __future__ import annotations
from typing import List, Dict, Optional, Tuple, Any, Callable
import json
import os
from pathlib import Path
from .ereader_layout import RenderingPosition, ChapterNavigator, ChapterInfo
@ -23,11 +22,11 @@ class BookmarkManager:
"""
Manages bookmarks and reading position persistence for ereader applications.
"""
def __init__(self, document_id: str, bookmarks_dir: str = "bookmarks"):
"""
Initialize bookmark manager.
Args:
document_id: Unique identifier for the document
bookmarks_dir: Directory to store bookmark files
@ -35,13 +34,13 @@ class BookmarkManager:
self.document_id = document_id
self.bookmarks_dir = Path(bookmarks_dir)
self.bookmarks_dir.mkdir(exist_ok=True)
self.bookmarks_file = self.bookmarks_dir / f"{document_id}_bookmarks.json"
self.position_file = self.bookmarks_dir / f"{document_id}_position.json"
self._bookmarks: Dict[str, RenderingPosition] = {}
self._load_bookmarks()
def _load_bookmarks(self):
"""Load bookmarks from file"""
if self.bookmarks_file.exists():
@ -55,7 +54,7 @@ class BookmarkManager:
except Exception as e:
print(f"Failed to load bookmarks: {e}")
self._bookmarks = {}
def _save_bookmarks(self):
"""Save bookmarks to file"""
try:
@ -67,25 +66,25 @@ class BookmarkManager:
json.dump(data, f, indent=2)
except Exception as e:
print(f"Failed to save bookmarks: {e}")
def add_bookmark(self, name: str, position: RenderingPosition):
"""
Add a bookmark at the given position.
Args:
name: Bookmark name
position: Position to bookmark
"""
self._bookmarks[name] = position
self._save_bookmarks()
def remove_bookmark(self, name: str) -> bool:
"""
Remove a bookmark.
Args:
name: Bookmark name to remove
Returns:
True if bookmark was removed, False if not found
"""
@ -94,32 +93,32 @@ class BookmarkManager:
self._save_bookmarks()
return True
return False
def get_bookmark(self, name: str) -> Optional[RenderingPosition]:
"""
Get a bookmark position.
Args:
name: Bookmark name
Returns:
Bookmark position or None if not found
"""
return self._bookmarks.get(name)
def list_bookmarks(self) -> List[Tuple[str, RenderingPosition]]:
"""
Get all bookmarks.
Returns:
List of (name, position) tuples
"""
return list(self._bookmarks.items())
def save_reading_position(self, position: RenderingPosition):
"""
Save the current reading position.
Args:
position: Current reading position
"""
@ -128,11 +127,11 @@ class BookmarkManager:
json.dump(position.to_dict(), f, indent=2)
except Exception as e:
print(f"Failed to save reading position: {e}")
def load_reading_position(self) -> Optional[RenderingPosition]:
"""
Load the last reading position.
Returns:
Last reading position or None if not found
"""
@ -149,7 +148,7 @@ class BookmarkManager:
class EreaderLayoutManager:
"""
High-level ereader layout manager providing a complete interface for ereader applications.
Features:
- Sub-second page rendering with intelligent buffering
- Font scaling support
@ -158,17 +157,17 @@ class EreaderLayoutManager:
- Position persistence
- Progress tracking
"""
def __init__(self,
blocks: List[Block],
page_size: Tuple[int, int],
def __init__(self,
blocks: List[Block],
page_size: Tuple[int, int],
document_id: str = "default",
buffer_size: int = 5,
page_style: Optional[PageStyle] = None,
bookmarks_dir: str = "bookmarks"):
"""
Initialize the ereader layout manager.
Args:
blocks: Document blocks to render
page_size: Page size (width, height) in pixels
@ -180,125 +179,132 @@ class EreaderLayoutManager:
self.blocks = blocks
self.page_size = page_size
self.document_id = document_id
# Initialize page style
if page_style is None:
page_style = PageStyle()
self.page_style = page_style
# Initialize core components
self.renderer = BufferedPageRenderer(blocks, page_style, buffer_size, page_size)
self.chapter_navigator = ChapterNavigator(blocks)
self.bookmark_manager = BookmarkManager(document_id, bookmarks_dir)
# Current state
self.current_position = RenderingPosition()
self.font_scale = 1.0
# Load last reading position if available
saved_position = self.bookmark_manager.load_reading_position()
if saved_position:
self.current_position = saved_position
# Callbacks for UI updates
self.position_changed_callback: Optional[Callable[[RenderingPosition], None]] = None
self.chapter_changed_callback: Optional[Callable[[Optional[ChapterInfo]], None]] = None
def set_position_changed_callback(self, callback: Callable[[RenderingPosition], None]):
self.position_changed_callback: Optional[Callable[[
RenderingPosition], None]] = None
self.chapter_changed_callback: Optional[Callable[[
Optional[ChapterInfo]], None]] = None
def set_position_changed_callback(
self, callback: Callable[[RenderingPosition], None]):
"""Set callback for position changes"""
self.position_changed_callback = callback
def set_chapter_changed_callback(self, callback: Callable[[Optional[ChapterInfo]], None]):
def set_chapter_changed_callback(
self, callback: Callable[[Optional[ChapterInfo]], None]):
"""Set callback for chapter changes"""
self.chapter_changed_callback = callback
def _notify_position_changed(self):
"""Notify UI of position change"""
if self.position_changed_callback:
self.position_changed_callback(self.current_position)
# Check if chapter changed
current_chapter = self.chapter_navigator.get_current_chapter(self.current_position)
current_chapter = self.chapter_navigator.get_current_chapter(
self.current_position)
if self.chapter_changed_callback:
self.chapter_changed_callback(current_chapter)
# Auto-save reading position
self.bookmark_manager.save_reading_position(self.current_position)
def get_current_page(self) -> Page:
"""
Get the page at the current reading position.
Returns:
Rendered page
"""
page, _ = self.renderer.render_page(self.current_position, self.font_scale)
return page
def next_page(self) -> Optional[Page]:
"""
Advance to the next page.
Returns:
Next page or None if at end of document
"""
page, next_position = self.renderer.render_page(self.current_position, self.font_scale)
page, next_position = self.renderer.render_page(
self.current_position, self.font_scale)
# Check if we made progress
if next_position != self.current_position:
self.current_position = next_position
self._notify_position_changed()
return self.get_current_page()
return None # At end of document
def previous_page(self) -> Optional[Page]:
"""
Go to the previous page.
Returns:
Previous page or None if at beginning of document
"""
if self._is_at_beginning():
return None
# Use backward rendering to find the previous page
page, start_position = self.renderer.render_page_backward(self.current_position, self.font_scale)
page, start_position = self.renderer.render_page_backward(
self.current_position, self.font_scale)
if start_position != self.current_position:
self.current_position = start_position
self._notify_position_changed()
return page
return None # At beginning of document
def _is_at_beginning(self) -> bool:
"""Check if we're at the beginning of the document"""
return (self.current_position.chapter_index == 0 and
self.current_position.block_index == 0 and
return (self.current_position.chapter_index == 0 and
self.current_position.block_index == 0 and
self.current_position.word_index == 0)
def jump_to_position(self, position: RenderingPosition) -> Page:
"""
Jump to a specific position in the document.
Args:
position: Position to jump to
Returns:
Page at the new position
"""
self.current_position = position
self._notify_position_changed()
return self.get_current_page()
def jump_to_chapter(self, chapter_title: str) -> Optional[Page]:
"""
Jump to a specific chapter by title.
Args:
chapter_title: Title of the chapter to jump to
Returns:
Page at chapter start or None if chapter not found
"""
@ -306,14 +312,14 @@ class EreaderLayoutManager:
if position:
return self.jump_to_position(position)
return None
def jump_to_chapter_index(self, chapter_index: int) -> Optional[Page]:
"""
Jump to a chapter by index.
Args:
chapter_index: Index of the chapter (0-based)
Returns:
Page at chapter start or None if index invalid
"""
@ -321,23 +327,23 @@ class EreaderLayoutManager:
if 0 <= chapter_index < len(chapters):
return self.jump_to_position(chapters[chapter_index].position)
return None
def set_font_scale(self, scale: float) -> Page:
"""
Change the font scale and re-render current page.
Args:
scale: Font scaling factor (1.0 = normal, 2.0 = double size, etc.)
Returns:
Re-rendered page with new font scale
"""
if scale != self.font_scale:
self.font_scale = scale
# The renderer will handle cache invalidation
return self.get_current_page()
def get_font_scale(self) -> float:
"""Get the current font scale"""
return self.font_scale
@ -397,7 +403,8 @@ class EreaderLayoutManager:
Returns:
Re-rendered page with decreased block spacing
"""
self.page_style.inter_block_spacing = max(0, self.page_style.inter_block_spacing - amount)
self.page_style.inter_block_spacing = max(
0, self.page_style.inter_block_spacing - amount)
self.renderer.page_style = self.page_style # Update renderer's reference
self.renderer.buffer.invalidate_all() # Clear cache to force re-render
return self.get_current_page()
@ -432,31 +439,32 @@ class EreaderLayoutManager:
self.renderer.buffer.invalidate_all() # Clear cache to force re-render
return self.get_current_page()
def get_table_of_contents(self) -> List[Tuple[str, HeadingLevel, RenderingPosition]]:
def get_table_of_contents(
self) -> List[Tuple[str, HeadingLevel, RenderingPosition]]:
"""
Get the table of contents.
Returns:
List of (title, level, position) tuples
"""
return self.chapter_navigator.get_table_of_contents()
def get_current_chapter(self) -> Optional[ChapterInfo]:
"""
Get information about the current chapter.
Returns:
Current chapter info or None if no chapters
"""
return self.chapter_navigator.get_current_chapter(self.current_position)
def add_bookmark(self, name: str) -> bool:
"""
Add a bookmark at the current position.
Args:
name: Bookmark name
Returns:
True if bookmark was added successfully
"""
@ -465,26 +473,26 @@ class EreaderLayoutManager:
return True
except Exception:
return False
def remove_bookmark(self, name: str) -> bool:
"""
Remove a bookmark.
Args:
name: Bookmark name
Returns:
True if bookmark was removed
"""
return self.bookmark_manager.remove_bookmark(name)
def jump_to_bookmark(self, name: str) -> Optional[Page]:
"""
Jump to a bookmark.
Args:
name: Bookmark name
Returns:
Page at bookmark position or None if bookmark not found
"""
@ -492,42 +500,42 @@ class EreaderLayoutManager:
if position:
return self.jump_to_position(position)
return None
def list_bookmarks(self) -> List[Tuple[str, RenderingPosition]]:
"""
Get all bookmarks.
Returns:
List of (name, position) tuples
"""
return self.bookmark_manager.list_bookmarks()
def get_reading_progress(self) -> float:
"""
Get reading progress as a percentage.
Returns:
Progress from 0.0 to 1.0
"""
if not self.blocks:
return 0.0
# Simple progress calculation based on block index
# A more sophisticated version would consider word positions
total_blocks = len(self.blocks)
current_block = min(self.current_position.block_index, total_blocks - 1)
return current_block / max(1, total_blocks - 1)
def get_position_info(self) -> Dict[str, Any]:
"""
Get detailed information about the current position.
Returns:
Dictionary with position details
"""
current_chapter = self.get_current_chapter()
return {
'position': self.current_position.to_dict(),
'chapter': {
@ -539,16 +547,16 @@ class EreaderLayoutManager:
'font_scale': self.font_scale,
'page_size': self.page_size
}
def get_cache_stats(self) -> Dict[str, Any]:
"""
Get cache statistics for debugging/monitoring.
Returns:
Dictionary with cache statistics
"""
return self.renderer.get_cache_stats()
def shutdown(self):
"""
Shutdown the ereader manager and clean up resources.
@ -556,29 +564,29 @@ class EreaderLayoutManager:
"""
# Save current position
self.bookmark_manager.save_reading_position(self.current_position)
# Shutdown renderer and buffer
self.renderer.shutdown()
def __del__(self):
"""Cleanup on destruction"""
self.shutdown()
# Convenience function for quick setup
def create_ereader_manager(blocks: List[Block],
page_size: Tuple[int, int],
document_id: str = "default",
**kwargs) -> EreaderLayoutManager:
def create_ereader_manager(blocks: List[Block],
page_size: Tuple[int, int],
document_id: str = "default",
**kwargs) -> EreaderLayoutManager:
"""
Convenience function to create an ereader manager with sensible defaults.
Args:
blocks: Document blocks to render
page_size: Page size (width, height) in pixels
document_id: Unique identifier for the document
**kwargs: Additional arguments passed to EreaderLayoutManager
Returns:
Configured EreaderLayoutManager instance
"""

View File

@ -8,12 +8,9 @@ multiprocessing to achieve sub-second page navigation performance.
from __future__ import annotations
from typing import Dict, Optional, List, Tuple, Any
from collections import OrderedDict
import multiprocessing
from concurrent.futures import ProcessPoolExecutor, as_completed, Future
from concurrent.futures import ProcessPoolExecutor, Future
import threading
import time
import pickle
from dataclasses import asdict
from .ereader_layout import RenderingPosition, BidirectionalLayouter
from pyWebLayout.concrete.page import Page
@ -21,28 +18,34 @@ from pyWebLayout.abstract.block import Block
from pyWebLayout.style.page_style import PageStyle
def _render_page_worker(args: Tuple[List[Block], PageStyle, RenderingPosition, float, bool]) -> Tuple[RenderingPosition, bytes, RenderingPosition]:
def _render_page_worker(args: Tuple[List[Block],
PageStyle,
RenderingPosition,
float,
bool]) -> Tuple[RenderingPosition,
bytes,
RenderingPosition]:
"""
Worker function for multiprocess page rendering.
Args:
args: Tuple of (blocks, page_style, position, font_scale, is_backward)
Returns:
Tuple of (original_position, pickled_page, next_position)
"""
blocks, page_style, position, font_scale, is_backward = args
layouter = BidirectionalLayouter(blocks, page_style)
if is_backward:
page, next_pos = layouter.render_page_backward(position, font_scale)
else:
page, next_pos = layouter.render_page_forward(position, font_scale)
# Serialize the page for inter-process communication
pickled_page = pickle.dumps(page)
return position, pickled_page, next_pos
@ -51,40 +54,46 @@ class PageBuffer:
Intelligent page caching system with LRU eviction and background rendering.
Maintains separate forward and backward buffers for optimal navigation performance.
"""
def __init__(self, buffer_size: int = 5, max_workers: int = 4):
"""
Initialize the page buffer.
Args:
buffer_size: Number of pages to cache in each direction
max_workers: Maximum number of worker processes for background rendering
"""
self.buffer_size = buffer_size
self.max_workers = max_workers
# LRU caches for forward and backward pages
self.forward_buffer: OrderedDict[RenderingPosition, Page] = OrderedDict()
self.backward_buffer: OrderedDict[RenderingPosition, Page] = OrderedDict()
# Position tracking for next/previous positions
self.position_map: Dict[RenderingPosition, RenderingPosition] = {} # current -> next
self.reverse_position_map: Dict[RenderingPosition, RenderingPosition] = {} # current -> previous
self.position_map: Dict[RenderingPosition,
RenderingPosition] = {} # current -> next
self.reverse_position_map: Dict[RenderingPosition,
RenderingPosition] = {} # current -> previous
# Background rendering
self.executor: Optional[ProcessPoolExecutor] = None
self.pending_renders: Dict[RenderingPosition, Future] = {}
self.render_lock = threading.Lock()
# Document state
self.blocks: Optional[List[Block]] = None
self.page_style: Optional[PageStyle] = None
self.current_font_scale: float = 1.0
def initialize(self, blocks: List[Block], page_style: PageStyle, font_scale: float = 1.0):
def initialize(
self,
blocks: List[Block],
page_style: PageStyle,
font_scale: float = 1.0):
"""
Initialize the buffer with document blocks and page style.
Args:
blocks: Document blocks to render
page_style: Page styling configuration
@ -93,18 +102,18 @@ class PageBuffer:
self.blocks = blocks
self.page_style = page_style
self.current_font_scale = font_scale
# Start the process pool
if self.executor is None:
self.executor = ProcessPoolExecutor(max_workers=self.max_workers)
def get_page(self, position: RenderingPosition) -> Optional[Page]:
"""
Get a cached page if available.
Args:
position: Position to get page for
Returns:
Cached page or None if not available
"""
@ -114,20 +123,25 @@ class PageBuffer:
page = self.forward_buffer.pop(position)
self.forward_buffer[position] = page
return page
# Check backward buffer
if position in self.backward_buffer:
# Move to end (most recently used)
page = self.backward_buffer.pop(position)
self.backward_buffer[position] = page
return page
return None
def cache_page(self, position: RenderingPosition, page: Page, next_position: Optional[RenderingPosition] = None, is_backward: bool = False):
def cache_page(
self,
position: RenderingPosition,
page: Page,
next_position: Optional[RenderingPosition] = None,
is_backward: bool = False):
"""
Cache a rendered page with LRU eviction.
Args:
position: Position of the page
page: Rendered page to cache
@ -135,46 +149,49 @@ class PageBuffer:
is_backward: Whether this is a backward-rendered page
"""
target_buffer = self.backward_buffer if is_backward else self.forward_buffer
# Add to cache
target_buffer[position] = page
# Track position relationships
if next_position:
if is_backward:
self.reverse_position_map[next_position] = position
else:
self.position_map[position] = next_position
# Evict oldest if buffer is full
if len(target_buffer) > self.buffer_size:
oldest_pos, _ = target_buffer.popitem(last=False)
# Clean up position maps
self.position_map.pop(oldest_pos, None)
self.reverse_position_map.pop(oldest_pos, None)
def start_background_rendering(self, current_position: RenderingPosition, direction: str = 'forward'):
def start_background_rendering(
self,
current_position: RenderingPosition,
direction: str = 'forward'):
"""
Start background rendering of upcoming pages.
Args:
current_position: Current reading position
direction: 'forward', 'backward', or 'both'
"""
if not self.blocks or not self.page_style or not self.executor:
return
with self.render_lock:
if direction in ['forward', 'both']:
self._queue_forward_renders(current_position)
if direction in ['backward', 'both']:
self._queue_backward_renders(current_position)
def _queue_forward_renders(self, start_position: RenderingPosition):
"""Queue forward page renders starting from the given position"""
current_pos = start_position
for i in range(self.buffer_size):
# Skip if already cached or being rendered
if current_pos in self.forward_buffer or current_pos in self.pending_renders:
@ -183,19 +200,25 @@ class PageBuffer:
if not current_pos:
break
continue
# Queue render job
args = (self.blocks, self.page_style, current_pos, self.current_font_scale, False)
args = (
self.blocks,
self.page_style,
current_pos,
self.current_font_scale,
False)
future = self.executor.submit(_render_page_worker, args)
self.pending_renders[current_pos] = future
# We don't know the next position yet, so we'll update it when the render completes
# We don't know the next position yet, so we'll update it when the render
# completes
break
def _queue_backward_renders(self, start_position: RenderingPosition):
"""Queue backward page renders ending at the given position"""
current_pos = start_position
for i in range(self.buffer_size):
# Skip if already cached or being rendered
if current_pos in self.backward_buffer or current_pos in self.pending_renders:
@ -204,44 +227,50 @@ class PageBuffer:
if not current_pos:
break
continue
# Queue render job
args = (self.blocks, self.page_style, current_pos, self.current_font_scale, True)
args = (
self.blocks,
self.page_style,
current_pos,
self.current_font_scale,
True)
future = self.executor.submit(_render_page_worker, args)
self.pending_renders[current_pos] = future
# We don't know the previous position yet, so we'll update it when the render completes
# We don't know the previous position yet, so we'll update it when the
# render completes
break
def check_completed_renders(self):
"""Check for completed background renders and cache the results"""
if not self.pending_renders:
return
completed = []
with self.render_lock:
for position, future in self.pending_renders.items():
if future.done():
try:
original_pos, pickled_page, next_pos = future.result()
# Deserialize the page
page = pickle.loads(pickled_page)
# Cache the page
self.cache_page(original_pos, page, next_pos, is_backward=False)
completed.append(position)
except Exception as e:
print(f"Background render failed for position {position}: {e}")
completed.append(position)
# Remove completed renders
for pos in completed:
self.pending_renders.pop(pos, None)
def invalidate_all(self):
"""Clear all cached pages and cancel pending renders"""
with self.render_lock:
@ -249,24 +278,24 @@ class PageBuffer:
for future in self.pending_renders.values():
future.cancel()
self.pending_renders.clear()
# Clear caches
self.forward_buffer.clear()
self.backward_buffer.clear()
self.position_map.clear()
self.reverse_position_map.clear()
def set_font_scale(self, font_scale: float):
"""
Update font scale and invalidate cache.
Args:
font_scale: New font scaling factor
"""
if font_scale != self.current_font_scale:
self.current_font_scale = font_scale
self.invalidate_all()
def get_cache_stats(self) -> Dict[str, Any]:
"""Get cache statistics for debugging/monitoring"""
return {
@ -277,7 +306,7 @@ class PageBuffer:
'reverse_position_mappings': len(self.reverse_position_map),
'current_font_scale': self.current_font_scale
}
def shutdown(self):
"""Shutdown the page buffer and clean up resources"""
if self.executor:
@ -285,14 +314,14 @@ class PageBuffer:
with self.render_lock:
for future in self.pending_renders.values():
future.cancel()
# Shutdown executor
self.executor.shutdown(wait=True)
self.executor = None
# Clear all caches
self.invalidate_all()
def __del__(self):
"""Cleanup on destruction"""
self.shutdown()
@ -302,11 +331,17 @@ class BufferedPageRenderer:
"""
High-level interface for buffered page rendering with automatic background caching.
"""
def __init__(self, blocks: List[Block], page_style: PageStyle, buffer_size: int = 5, page_size: Tuple[int, int] = (800, 600)):
def __init__(self,
blocks: List[Block],
page_style: PageStyle,
buffer_size: int = 5,
page_size: Tuple[int,
int] = (800,
600)):
"""
Initialize the buffered renderer.
Args:
blocks: Document blocks to render
page_style: Page styling configuration
@ -316,18 +351,19 @@ class BufferedPageRenderer:
self.layouter = BidirectionalLayouter(blocks, page_style, page_size)
self.buffer = PageBuffer(buffer_size)
self.buffer.initialize(blocks, page_style)
self.current_position = RenderingPosition()
self.font_scale = 1.0
def render_page(self, position: RenderingPosition, font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
def render_page(self, position: RenderingPosition,
font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
"""
Render a page with intelligent caching.
Args:
position: Position to render from
font_scale: Font scaling factor
Returns:
Tuple of (rendered_page, next_position)
"""
@ -335,40 +371,43 @@ class BufferedPageRenderer:
if font_scale != self.font_scale:
self.font_scale = font_scale
self.buffer.set_font_scale(font_scale)
# Check cache first
cached_page = self.buffer.get_page(position)
if cached_page:
# Get next position from position map
next_pos = self.buffer.position_map.get(position, position)
# Start background rendering for upcoming pages
self.buffer.start_background_rendering(position, 'forward')
return cached_page, next_pos
# Render the page directly
page, next_pos = self.layouter.render_page_forward(position, font_scale)
# Cache the result
self.buffer.cache_page(position, page, next_pos)
# Start background rendering
self.buffer.start_background_rendering(position, 'both')
# Check for completed background renders
self.buffer.check_completed_renders()
return page, next_pos
def render_page_backward(self, end_position: RenderingPosition, font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
def render_page_backward(self,
end_position: RenderingPosition,
font_scale: float = 1.0) -> Tuple[Page,
RenderingPosition]:
"""
Render a page ending at the given position with intelligent caching.
Args:
end_position: Position where page should end
font_scale: Font scaling factor
Returns:
Tuple of (rendered_page, start_position)
"""
@ -376,36 +415,36 @@ class BufferedPageRenderer:
if font_scale != self.font_scale:
self.font_scale = font_scale
self.buffer.set_font_scale(font_scale)
# Check cache first
cached_page = self.buffer.get_page(end_position)
if cached_page:
# Get previous position from reverse position map
prev_pos = self.buffer.reverse_position_map.get(end_position, end_position)
# Start background rendering for previous pages
self.buffer.start_background_rendering(end_position, 'backward')
return cached_page, prev_pos
# Render the page directly
page, start_pos = self.layouter.render_page_backward(end_position, font_scale)
# Cache the result
self.buffer.cache_page(start_pos, page, end_position, is_backward=True)
# Start background rendering
self.buffer.start_background_rendering(end_position, 'both')
# Check for completed background renders
self.buffer.check_completed_renders()
return page, start_pos
def get_cache_stats(self) -> Dict[str, Any]:
"""Get cache statistics"""
return self.buffer.get_cache_stats()
def shutdown(self):
"""Shutdown the renderer and clean up resources"""
self.buffer.shutdown()

View File

@ -4,7 +4,6 @@ Style system for the pyWebLayout library.
This module provides the core styling components used throughout the library.
"""
from enum import Enum
from .fonts import Font, FontWeight, FontStyle, TextDecoration
from .abstract_style import (
AbstractStyle, AbstractStyleRegistry, FontFamily, FontSize

View File

@ -6,6 +6,7 @@ rendering parameters, allowing for flexible interpretation by different
rendering systems and user preferences.
"""
from .alignment import Alignment
from typing import Dict, Optional, Tuple, Union
from dataclasses import dataclass
from enum import Enum
@ -30,7 +31,7 @@ class FontSize(Enum):
LARGE = "large"
X_LARGE = "x-large"
XX_LARGE = "xx-large"
# Allow numeric values as well
@classmethod
def from_value(cls, value: Union[str, int, float]) -> Union['FontSize', int]:
@ -50,7 +51,6 @@ class FontSize(Enum):
# Import Alignment from the centralized location
from .alignment import Alignment
# Use Alignment for text alignment
TextAlign = Alignment
@ -61,25 +61,25 @@ class AbstractStyle:
"""
Abstract representation of text styling that captures semantic intent
rather than concrete rendering parameters.
This allows the same document to be rendered differently based on
user preferences, device capabilities, or accessibility requirements.
Being frozen=True makes this class hashable and immutable, which is
perfect for use as dictionary keys and preventing accidental modification.
"""
# Font properties (semantic)
font_family: FontFamily = FontFamily.SERIF
font_size: Union[FontSize, int] = FontSize.MEDIUM
font_weight: FontWeight = FontWeight.NORMAL
font_style: FontStyle = FontStyle.NORMAL
text_decoration: TextDecoration = TextDecoration.NONE
# Color (as semantic names or RGB)
color: Union[str, Tuple[int, int, int]] = "black"
background_color: Optional[Union[str, Tuple[int, int, int, int]]] = None
# Text properties
text_align: TextAlign = TextAlign.LEFT
line_height: Optional[Union[str, float]] = None # "normal", "1.2", 1.5, etc.
@ -87,13 +87,13 @@ class AbstractStyle:
word_spacing: Optional[Union[str, float]] = None
word_spacing_min: Optional[Union[str, float]] = None # Minimum allowed word spacing
word_spacing_max: Optional[Union[str, float]] = None # Maximum allowed word spacing
# Language and locale
language: str = "en-US"
# Hierarchy properties
parent_style_id: Optional[str] = None
def __post_init__(self):
"""Validate and normalize values after creation"""
# Normalize font_size if it's a string that could be a number
@ -103,11 +103,11 @@ class AbstractStyle:
except ValueError:
# Keep as is if it's a semantic size name
pass
def __hash__(self) -> int:
"""
Custom hash implementation to ensure consistent hashing.
Since this is a frozen dataclass, it should be hashable by default,
but we provide a custom implementation to ensure all fields are
properly considered and to handle the Union types correctly.
@ -130,17 +130,17 @@ class AbstractStyle:
self.language,
self.parent_style_id
)
return hash(hashable_values)
def merge_with(self, other: 'AbstractStyle') -> 'AbstractStyle':
"""
Create a new AbstractStyle by merging this one with another.
The other style's properties take precedence.
Args:
other: AbstractStyle to merge with this one
Returns:
New AbstractStyle with merged values
"""
@ -149,26 +149,26 @@ class AbstractStyle:
field.name: getattr(self, field.name)
for field in self.__dataclass_fields__.values()
}
other_dict = {
field.name: getattr(other, field.name)
for field in other.__dataclass_fields__.values()
if getattr(other, field.name) != field.default
}
# Merge dictionaries (other takes precedence)
merged_dict = current_dict.copy()
merged_dict.update(other_dict)
return AbstractStyle(**merged_dict)
def with_modifications(self, **kwargs) -> 'AbstractStyle':
"""
Create a new AbstractStyle with specified modifications.
Args:
**kwargs: Properties to modify
Returns:
New AbstractStyle with modifications applied
"""
@ -176,7 +176,7 @@ class AbstractStyle:
field.name: getattr(self, field.name)
for field in self.__dataclass_fields__.values()
}
current_dict.update(kwargs)
return AbstractStyle(**current_dict)
@ -184,20 +184,21 @@ class AbstractStyle:
class AbstractStyleRegistry:
"""
Registry for managing abstract document styles.
This registry stores the semantic styling intent and provides
deduplication and inheritance capabilities using hashable AbstractStyle objects.
"""
def __init__(self):
"""Initialize an empty abstract style registry."""
self._styles: Dict[str, AbstractStyle] = {}
self._style_to_id: Dict[AbstractStyle, str] = {} # Reverse mapping using hashable styles
# Reverse mapping using hashable styles
self._style_to_id: Dict[AbstractStyle, str] = {}
self._next_id = 1
# Create and register the default style
self._default_style = self._create_default_style()
def _create_default_style(self) -> AbstractStyle:
"""Create the default document style."""
default_style = AbstractStyle()
@ -205,38 +206,41 @@ class AbstractStyleRegistry:
self._styles[style_id] = default_style
self._style_to_id[default_style] = style_id
return default_style
@property
def default_style(self) -> AbstractStyle:
"""Get the default style for the document."""
return self._default_style
def _generate_style_id(self) -> str:
"""Generate a unique style ID."""
style_id = f"abstract_style_{self._next_id}"
self._next_id += 1
return style_id
def get_style_id(self, style: AbstractStyle) -> Optional[str]:
"""
Get the ID for a given style if it exists in the registry.
Args:
style: AbstractStyle to find
Returns:
Style ID if found, None otherwise
"""
return self._style_to_id.get(style)
def register_style(self, style: AbstractStyle, style_id: Optional[str] = None) -> str:
def register_style(
self,
style: AbstractStyle,
style_id: Optional[str] = None) -> str:
"""
Register a style in the registry.
Args:
style: AbstractStyle to register
style_id: Optional style ID. If None, one will be generated
Returns:
The style ID
"""
@ -244,26 +248,26 @@ class AbstractStyleRegistry:
existing_id = self.get_style_id(style)
if existing_id is not None:
return existing_id
if style_id is None:
style_id = self._generate_style_id()
self._styles[style_id] = style
self._style_to_id[style] = style_id
return style_id
def get_or_create_style(self,
style: Optional[AbstractStyle] = None,
parent_id: Optional[str] = None,
**kwargs) -> Tuple[str, AbstractStyle]:
def get_or_create_style(self,
style: Optional[AbstractStyle] = None,
parent_id: Optional[str] = None,
**kwargs) -> Tuple[str, AbstractStyle]:
"""
Get an existing style or create a new one.
Args:
style: AbstractStyle object. If None, created from kwargs
parent_id: Optional parent style ID
**kwargs: Individual style properties (used if style is None)
Returns:
Tuple of (style_id, AbstractStyle)
"""
@ -274,64 +278,65 @@ class AbstractStyleRegistry:
if parent_id:
filtered_kwargs['parent_style_id'] = parent_id
style = AbstractStyle(**filtered_kwargs)
# Check if we already have this style (using hashable property)
existing_id = self.get_style_id(style)
if existing_id is not None:
return existing_id, style
# Create new style
style_id = self.register_style(style)
return style_id, style
def get_style_by_id(self, style_id: str) -> Optional[AbstractStyle]:
"""Get a style by its ID."""
return self._styles.get(style_id)
def create_derived_style(self, base_style_id: str, **modifications) -> Tuple[str, AbstractStyle]:
def create_derived_style(self, base_style_id: str, **
modifications) -> Tuple[str, AbstractStyle]:
"""
Create a new style derived from a base style.
Args:
base_style_id: ID of the base style
**modifications: Properties to modify
Returns:
Tuple of (new_style_id, new_AbstractStyle)
"""
base_style = self.get_style_by_id(base_style_id)
if base_style is None:
raise ValueError(f"Base style '{base_style_id}' not found")
# Create derived style
derived_style = base_style.with_modifications(**modifications)
return self.get_or_create_style(derived_style)
def resolve_effective_style(self, style_id: str) -> AbstractStyle:
"""
Resolve the effective style including inheritance.
Args:
style_id: Style ID to resolve
Returns:
Effective AbstractStyle with inheritance applied
"""
style = self.get_style_by_id(style_id)
if style is None:
return self._default_style
if style.parent_style_id is None:
return style
# Recursively resolve parent styles
parent_style = self.resolve_effective_style(style.parent_style_id)
return parent_style.merge_with(style)
def get_all_styles(self) -> Dict[str, AbstractStyle]:
"""Get all registered styles."""
return self._styles.copy()
def get_style_count(self) -> int:
"""Get the number of registered styles."""
return len(self._styles)

View File

@ -6,6 +6,7 @@ This module provides alignment-related functionality.
from enum import Enum
class Alignment(Enum):
"""Text and box alignment options"""
# Horizontal alignment
@ -13,10 +14,10 @@ class Alignment(Enum):
RIGHT = "right"
CENTER = "center"
JUSTIFY = "justify"
# Vertical alignment
TOP = "top"
MIDDLE = "middle"
MIDDLE = "middle"
BOTTOM = "bottom"
def __str__(self):

View File

@ -5,12 +5,11 @@ This module converts abstract styles to concrete rendering parameters based on
user preferences, device capabilities, and rendering context.
"""
from typing import Dict, Optional, Tuple, Union, Any
from typing import Dict, Optional, Tuple, Union
from dataclasses import dataclass
from .abstract_style import AbstractStyle, FontFamily, FontSize
from pyWebLayout.style.alignment import Alignment as TextAlign
from .fonts import Font, FontWeight, FontStyle, TextDecoration
import os
@dataclass(frozen=True)
@ -19,24 +18,24 @@ class RenderingContext:
Context information for style resolution.
Contains user preferences and device capabilities.
"""
# User preferences
base_font_size: int = 16 # Base font size in points
font_scale_factor: float = 1.0 # Global font scaling
preferred_serif_font: Optional[str] = None
preferred_sans_serif_font: Optional[str] = None
preferred_monospace_font: Optional[str] = None
# Device/environment info
dpi: int = 96 # Dots per inch
available_width: Optional[int] = None # Available width in pixels
available_height: Optional[int] = None # Available height in pixels
# Accessibility preferences
high_contrast: bool = False
large_text: bool = False
reduce_motion: bool = False
# Language and locale
default_language: str = "en-US"
@ -45,22 +44,22 @@ class RenderingContext:
class ConcreteStyle:
"""
Concrete representation of text styling with actual rendering parameters.
This contains the resolved font files, pixel sizes, actual colors, etc.
that will be used for rendering. This is also hashable for efficient caching.
"""
# Concrete font properties
font_path: Optional[str] = None
font_size: int = 16 # Always in points/pixels
color: Tuple[int, int, int] = (0, 0, 0) # Always RGB
background_color: Optional[Tuple[int, int, int, int]] = None # Always RGBA or None
# Font attributes
weight: FontWeight = FontWeight.NORMAL
style: FontStyle = FontStyle.NORMAL
decoration: TextDecoration = TextDecoration.NONE
# Layout properties
text_align: TextAlign = TextAlign.LEFT
line_height: float = 1.0 # Multiplier
@ -68,14 +67,14 @@ class ConcreteStyle:
word_spacing: float = 0.0 # In pixels
word_spacing_min: float = 0.0 # Minimum word spacing in pixels
word_spacing_max: float = 0.0 # Maximum word spacing in pixels
# Language and locale
language: str = "en-US"
min_hyphenation_width: int = 64 # In pixels
# Reference to source abstract style
abstract_style: Optional[AbstractStyle] = None
def create_font(self) -> Font:
"""Create a Font object from this concrete style."""
return Font(
@ -94,21 +93,21 @@ class ConcreteStyle:
class StyleResolver:
"""
Resolves abstract styles to concrete styles based on rendering context.
This class handles the conversion from semantic styling intent to actual
rendering parameters, applying user preferences and device capabilities.
"""
def __init__(self, context: RenderingContext):
"""
Initialize the style resolver with a rendering context.
Args:
context: RenderingContext with user preferences and device info
"""
self.context = context
self._concrete_cache: Dict[AbstractStyle, ConcreteStyle] = {}
# Font size mapping for semantic sizes
self._semantic_font_sizes = {
FontSize.XX_SMALL: 0.6,
@ -119,7 +118,7 @@ class StyleResolver:
FontSize.X_LARGE: 1.5,
FontSize.XX_LARGE: 2.0,
}
# Color name mapping
self._color_names = {
"black": (0, 0, 0),
@ -141,35 +140,40 @@ class StyleResolver:
"fuchsia": (255, 0, 255),
"purple": (128, 0, 128),
}
def resolve_style(self, abstract_style: AbstractStyle) -> ConcreteStyle:
"""
Resolve an abstract style to a concrete style.
Args:
abstract_style: AbstractStyle to resolve
Returns:
ConcreteStyle with concrete rendering parameters
"""
# Check cache first
if abstract_style in self._concrete_cache:
return self._concrete_cache[abstract_style]
# Resolve each property
font_path = self._resolve_font_path(abstract_style.font_family)
font_size = self._resolve_font_size(abstract_style.font_size)
# Ensure font_size is always an int before using in arithmetic
font_size = int(font_size)
color = self._resolve_color(abstract_style.color)
background_color = self._resolve_background_color(abstract_style.background_color)
background_color = self._resolve_background_color(
abstract_style.background_color)
line_height = self._resolve_line_height(abstract_style.line_height)
letter_spacing = self._resolve_letter_spacing(abstract_style.letter_spacing, font_size)
word_spacing = self._resolve_word_spacing(abstract_style.word_spacing, font_size)
word_spacing_min = self._resolve_word_spacing(abstract_style.word_spacing_min, font_size)
word_spacing_max = self._resolve_word_spacing(abstract_style.word_spacing_max, font_size)
letter_spacing = self._resolve_letter_spacing(
abstract_style.letter_spacing, font_size)
word_spacing = self._resolve_word_spacing(
abstract_style.word_spacing, font_size)
word_spacing_min = self._resolve_word_spacing(
abstract_style.word_spacing_min, font_size)
word_spacing_max = self._resolve_word_spacing(
abstract_style.word_spacing_max, font_size)
min_hyphenation_width = max(int(font_size) * 4, 32) # At least 32 pixels
# Apply default logic for word spacing constraints
if word_spacing_min == 0.0 and word_spacing_max == 0.0:
# If no constraints specified, use base word_spacing as reference
@ -186,7 +190,7 @@ class StyleResolver:
elif word_spacing_max == 0.0:
# Only min specified, use base word_spacing or reasonable multiple
word_spacing_max = max(word_spacing, word_spacing_min * 2)
# Create concrete style
concrete_style = ConcreteStyle(
font_path=font_path,
@ -206,11 +210,11 @@ class StyleResolver:
min_hyphenation_width=min_hyphenation_width,
abstract_style=abstract_style
)
# Cache and return
self._concrete_cache[abstract_style] = concrete_style
return concrete_style
def _resolve_font_path(self, font_family: FontFamily) -> Optional[str]:
"""Resolve font family to actual font file path."""
if font_family == FontFamily.SERIF:
@ -222,7 +226,7 @@ class StyleResolver:
else:
# For cursive and fantasy, fall back to sans-serif
return self.context.preferred_sans_serif_font
def _resolve_font_size(self, font_size: Union[FontSize, int]) -> int:
"""Resolve font size to actual pixel/point size."""
# Ensure we handle FontSize enums properly
@ -240,22 +244,23 @@ class StyleResolver:
except (ValueError, TypeError):
# If conversion fails, use default
base_size = self.context.base_font_size
# Apply global font scaling
final_size = int(base_size * self.context.font_scale_factor)
# Apply accessibility adjustments
if self.context.large_text:
final_size = int(final_size * 1.2)
# Ensure we always return an int, minimum 8pt font
return max(int(final_size), 8)
def _resolve_color(self, color: Union[str, Tuple[int, int, int]]) -> Tuple[int, int, int]:
def _resolve_color(
self, color: Union[str, Tuple[int, int, int]]) -> Tuple[int, int, int]:
"""Resolve color to RGB tuple."""
if isinstance(color, tuple):
return color
if isinstance(color, str):
# Check if it's a named color
if color.lower() in self._color_names:
@ -266,7 +271,7 @@ class StyleResolver:
hex_color = color[1:]
if len(hex_color) == 3:
# Short hex format #RGB -> #RRGGBB
hex_color = ''.join(c*2 for c in hex_color)
hex_color = ''.join(c * 2 for c in hex_color)
if len(hex_color) == 6:
r = int(hex_color[0:2], 16)
g = int(hex_color[2:4], 16)
@ -278,7 +283,7 @@ class StyleResolver:
base_color = (0, 0, 0) # Fallback to black
else:
base_color = (0, 0, 0) # Fallback to black
# Apply high contrast if needed
if self.context.high_contrast:
# Simple high contrast: make dark colors black, light colors white
@ -288,56 +293,65 @@ class StyleResolver:
base_color = (0, 0, 0) # Black
else:
base_color = (255, 255, 255) # White
return base_color
return (0, 0, 0) # Fallback to black
def _resolve_background_color(self, bg_color: Optional[Union[str, Tuple[int, int, int, int]]]) -> Optional[Tuple[int, int, int, int]]:
def _resolve_background_color(self,
bg_color: Optional[Union[str,
Tuple[int,
int,
int,
int]]]) -> Optional[Tuple[int,
int,
int,
int]]:
"""Resolve background color to RGBA tuple or None."""
if bg_color is None:
return None
if isinstance(bg_color, tuple):
if len(bg_color) == 3:
# RGB -> RGBA
return bg_color + (255,)
return bg_color
if isinstance(bg_color, str):
if bg_color.lower() == "transparent":
return None
# Resolve as RGB then add alpha
rgb = self._resolve_color(bg_color)
return rgb + (255,)
return None
def _resolve_line_height(self, line_height: Optional[Union[str, float]]) -> float:
"""Resolve line height to multiplier."""
if line_height is None or line_height == "normal":
return 1.2 # Default line height
if isinstance(line_height, (int, float)):
return float(line_height)
if isinstance(line_height, str):
try:
return float(line_height)
except ValueError:
return 1.2 # Fallback
return 1.2
def _resolve_letter_spacing(self, letter_spacing: Optional[Union[str, float]], font_size: int) -> float:
def _resolve_letter_spacing(
self, letter_spacing: Optional[Union[str, float]], font_size: int) -> float:
"""Resolve letter spacing to pixels."""
if letter_spacing is None or letter_spacing == "normal":
return 0.0
if isinstance(letter_spacing, (int, float)):
return float(letter_spacing)
if isinstance(letter_spacing, str):
if letter_spacing.endswith("em"):
try:
@ -350,17 +364,18 @@ class StyleResolver:
return float(letter_spacing)
except ValueError:
return 0.0
return 0.0
def _resolve_word_spacing(self, word_spacing: Optional[Union[str, float]], font_size: int) -> float:
def _resolve_word_spacing(
self, word_spacing: Optional[Union[str, float]], font_size: int) -> float:
"""Resolve word spacing to pixels."""
if word_spacing is None or word_spacing == "normal":
return 0.0
if isinstance(word_spacing, (int, float)):
return float(word_spacing)
if isinstance(word_spacing, str):
if word_spacing.endswith("em"):
try:
@ -373,13 +388,13 @@ class StyleResolver:
return float(word_spacing)
except ValueError:
return 0.0
return 0.0
def update_context(self, **kwargs):
"""
Update the rendering context and clear cache.
Args:
**kwargs: Context properties to update
"""
@ -389,16 +404,16 @@ class StyleResolver:
for field in self.context.__dataclass_fields__.values()
}
context_dict.update(kwargs)
self.context = RenderingContext(**context_dict)
# Clear cache since context changed
self._concrete_cache.clear()
def clear_cache(self):
"""Clear the concrete style cache."""
self._concrete_cache.clear()
def get_cache_size(self) -> int:
"""Get the number of cached concrete styles."""
return len(self._concrete_cache)
@ -407,60 +422,60 @@ class StyleResolver:
class ConcreteStyleRegistry:
"""
Registry for managing concrete styles with efficient caching.
This registry manages the mapping between abstract and concrete styles,
and provides efficient access to Font objects for rendering.
"""
def __init__(self, resolver: StyleResolver):
"""
Initialize the concrete style registry.
Args:
resolver: StyleResolver for converting abstract to concrete styles
"""
self.resolver = resolver
self._font_cache: Dict[ConcreteStyle, Font] = {}
def get_concrete_style(self, abstract_style: AbstractStyle) -> ConcreteStyle:
"""
Get a concrete style for an abstract style.
Args:
abstract_style: AbstractStyle to resolve
Returns:
ConcreteStyle with rendering parameters
"""
return self.resolver.resolve_style(abstract_style)
def get_font(self, abstract_style: AbstractStyle) -> Font:
"""
Get a Font object for an abstract style.
Args:
abstract_style: AbstractStyle to get font for
Returns:
Font object ready for rendering
"""
concrete_style = self.get_concrete_style(abstract_style)
# Check font cache
if concrete_style in self._font_cache:
return self._font_cache[concrete_style]
# Create and cache font
font = concrete_style.create_font()
self._font_cache[concrete_style] = font
return font
def clear_caches(self):
"""Clear all caches."""
self.resolver.clear_cache()
self._font_cache.clear()
def get_cache_stats(self) -> Dict[str, int]:
"""Get cache statistics."""
return {

View File

@ -1,7 +1,8 @@
# this should contain classes for how different object can be rendered, e.g. bold, italic, regular
# this should contain classes for how different object can be rendered,
# e.g. bold, italic, regular
from PIL import ImageFont
from enum import Enum
from typing import Tuple, Union, Optional
from typing import Tuple, Optional
import os
import logging
@ -31,19 +32,19 @@ class Font:
This class is used by the text renderer to determine how to render text.
"""
def __init__(self,
def __init__(self,
font_path: Optional[str] = None,
font_size: int = 16,
font_size: int = 16,
colour: Tuple[int, int, int] = (0, 0, 0),
weight: FontWeight = FontWeight.NORMAL,
style: FontStyle = FontStyle.NORMAL,
decoration: TextDecoration = TextDecoration.NONE,
background: Optional[Tuple[int, int, int, int]] = None,
language = "en_EN",
language="en_EN",
min_hyphenation_width: Optional[int] = None):
"""
Initialize a Font object with the specified properties.
Args:
font_path: Path to the font file (.ttf, .otf). If None, uses default font.
font_size: Size of the font in points.
@ -67,7 +68,7 @@ class Font:
self._min_hyphenation_width = min_hyphenation_width if min_hyphenation_width is not None else font_size * 4
# Load the font file or use default
self._load_font()
def _get_bundled_font_path(self):
"""Get the path to the bundled font"""
# Get the directory containing this module
@ -75,19 +76,21 @@ class Font:
# Navigate to the assets/fonts directory
assets_dir = os.path.join(os.path.dirname(current_dir), 'assets', 'fonts')
bundled_font_path = os.path.join(assets_dir, 'DejaVuSans.ttf')
logger.debug(f"Font loading: current_dir = {current_dir}")
logger.debug(f"Font loading: assets_dir = {assets_dir}")
logger.debug(f"Font loading: bundled_font_path = {bundled_font_path}")
logger.debug(f"Font loading: bundled font exists = {os.path.exists(bundled_font_path)}")
logger.debug(
f"Font loading: bundled font exists = {
os.path.exists(bundled_font_path)}")
if os.path.exists(bundled_font_path):
logger.info(f"Found bundled font at: {bundled_font_path}")
return bundled_font_path
else:
logger.warning(f"Bundled font not found at: {bundled_font_path}")
return None
def _load_font(self):
"""Load the font using PIL's ImageFont with consistent bundled font"""
try:
@ -95,126 +98,130 @@ class Font:
# Use specified font path
logger.info(f"Loading font from specified path: {self._font_path}")
self._font = ImageFont.truetype(
self._font_path,
self._font_path,
self._font_size
)
logger.info(f"Successfully loaded font from: {self._font_path}")
else:
# Use bundled font for consistency across environments
bundled_font_path = self._get_bundled_font_path()
if bundled_font_path:
logger.info(f"Loading bundled font from: {bundled_font_path}")
self._font = ImageFont.truetype(bundled_font_path, self._font_size)
logger.info(f"Successfully loaded bundled font at size {self._font_size}")
logger.info(
f"Successfully loaded bundled font at size {
self._font_size}")
else:
# Only fall back to PIL's default font if bundled font is not available
logger.warning(f"Bundled font not available, falling back to PIL default font")
# Only fall back to PIL's default font if bundled font is not
# available
logger.warning(
"Bundled font not available, falling back to PIL default font")
self._font = ImageFont.load_default()
except Exception as e:
# Ultimate fallback to default font
logger.error(f"Failed to load font: {e}, falling back to PIL default font")
self._font = ImageFont.load_default()
@property
def font(self):
"""Get the PIL ImageFont object"""
return self._font
@property
def font_size(self):
"""Get the font size"""
return self._font_size
@property
def colour(self):
"""Get the text color"""
return self._colour
@property
def color(self):
"""Alias for colour (American spelling)"""
return self._colour
@property
def background(self):
"""Get the background color"""
return self._background
@property
def weight(self):
"""Get the font weight"""
return self._weight
@property
def style(self):
"""Get the font style"""
return self._style
@property
def decoration(self):
"""Get the text decoration"""
return self._decoration
@property
def min_hyphenation_width(self):
"""Get the minimum width required for hyphenation to be considered"""
return self._min_hyphenation_width
def with_size(self, size: int):
"""Create a new Font object with modified size"""
return Font(
self._font_path,
size,
self._font_path,
size,
self._colour,
self._weight,
self._style,
self._decoration,
self._background
)
def with_colour(self, colour: Tuple[int, int, int]):
"""Create a new Font object with modified colour"""
return Font(
self._font_path,
self._font_size,
self._font_path,
self._font_size,
colour,
self._weight,
self._style,
self._decoration,
self._background
)
def with_weight(self, weight: FontWeight):
"""Create a new Font object with modified weight"""
return Font(
self._font_path,
self._font_size,
self._font_path,
self._font_size,
self._colour,
weight,
self._style,
self._decoration,
self._background
)
def with_style(self, style: FontStyle):
"""Create a new Font object with modified style"""
return Font(
self._font_path,
self._font_size,
self._font_path,
self._font_size,
self._colour,
self._weight,
style,
self._decoration,
self._background
)
def with_decoration(self, decoration: TextDecoration):
"""Create a new Font object with modified decoration"""
return Font(
self._font_path,
self._font_size,
self._font_path,
self._font_size,
self._colour,
self._weight,
self._style,

View File

@ -1,7 +1,6 @@
from typing import Tuple, Optional
from typing import Tuple
from dataclasses import dataclass
from .abstract_style import AbstractStyle, FontFamily, FontSize
from pyWebLayout.style.alignment import Alignment as TextAlign
@dataclass
class PageStyle:

View File

@ -5,40 +5,41 @@ Debug script to test text positioning in the line breaking system
import sys
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
# Add pyWebLayout to path
sys.path.insert(0, str(Path(__file__).parent))
from PIL import Image, ImageDraw
from pyWebLayout.style import Font
from pyWebLayout.concrete.text import Text, Line
from pyWebLayout.style.layout import Alignment
# Add pyWebLayout to path
sys.path.insert(0, str(Path(__file__).parent))
def test_simple_text_rendering():
"""Test basic text rendering to debug positioning issues"""
# Create a simple image
width, height = 300, 200
image = Image.new('RGB', (width, height), 'white')
draw = ImageDraw.Draw(image)
# Draw a border for reference
draw.rectangle([0, 0, width-1, height-1], outline=(200, 200, 200), width=2)
# Create a font
font = Font(font_size=12)
# Test 1: Direct PIL text rendering
print("Test 1: Direct PIL text rendering")
draw.text((10, 30), "Direct PIL text", font=font.font, fill=(0, 0, 0))
# Test 2: Using our Text class
print("Test 2: Using Text class")
text_obj = Text("Text class rendering", font, draw)
text_obj.set_origin([10, 60]) # Set position
print(f"Text origin: {text_obj.origin}")
text_obj.render()
# Test 3: Using Line class
print("Test 3: Using Line class")
line = Line(
@ -49,26 +50,27 @@ def test_simple_text_rendering():
font=font,
halign=Alignment.LEFT
)
# Create a simple word to add to the line
from pyWebLayout.abstract.inline import Word
word = Word("Line class rendering", font)
success, overflow = line.add_word(word)
print(f"Word added successfully: {success}")
print(f"Line origin: {line.origin}")
print(f"Line baseline: {line._baseline}")
print(f"Text objects in line: {len(line.text_objects)}")
if line.text_objects:
for i, text in enumerate(line.text_objects):
print(f" Text {i}: '{text.text}' at origin {text.origin}")
line.render()
# Save the debug image
image.save("debug_text_positioning.png")
print("Debug image saved as debug_text_positioning.png")
if __name__ == "__main__":
test_simple_text_rendering()

View File

@ -15,7 +15,7 @@ import os
import sys
import argparse
from pathlib import Path
from typing import Optional, List
from typing import List
# Add the parent directory to sys.path to import pyWebLayout
sys.path.insert(0, str(Path(__file__).parent.parent))
@ -23,10 +23,8 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
try:
from pyWebLayout.io.readers.epub_reader import read_epub
from pyWebLayout.layout.ereader_layout import BidirectionalLayouter, RenderingPosition
from pyWebLayout.layout.document_layouter import paragraph_layouter
from pyWebLayout.concrete.page import Page
from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.style.fonts import Font
from pyWebLayout.abstract.block import Block
from PIL import Image, ImageDraw
except ImportError as e:
@ -38,10 +36,10 @@ except ImportError as e:
def render_page_to_image(page: Page) -> Image.Image:
"""
Render a Page object to a PIL Image using pyWebLayout's built-in rendering.
Args:
page: The Page object to render
Returns:
PIL Image object
"""
@ -56,7 +54,7 @@ def render_page_to_image(page: Page) -> Image.Image:
draw = ImageDraw.Draw(error_image)
draw.text((20, 20), "Error: Page.render() did not return PIL Image", fill='red')
return error_image
except Exception as e:
# Create error image if rendering fails
error_image = Image.new('RGB', page.size, 'white')
@ -69,26 +67,26 @@ def render_page_to_image(page: Page) -> Image.Image:
def extract_text_from_page(page: Page) -> str:
"""
Extract text content from a Page object for verification purposes.
Args:
page: The Page object to extract text from
Returns:
String containing the page's text content
"""
text_lines = []
text_lines.append(f"=== PAGE CONTENT ===")
text_lines.append("=== PAGE CONTENT ===")
text_lines.append("")
try:
# Recursively extract text from page children
def extract_from_element(element, indent_level=0):
indent = " " * indent_level
# Import abstract block types
from pyWebLayout.abstract.block import Paragraph, Heading, HList, Table, Image as AbstractImage
from pyWebLayout.concrete.text import Line
# Handle Line objects (concrete)
if isinstance(element, Line):
line_text = []
@ -98,7 +96,7 @@ def extract_text_from_page(page: Page) -> str:
line_text.append(str(text_obj.text))
if line_text:
text_lines.append(f"{indent}{' '.join(line_text)}")
# Handle abstract block objects
elif isinstance(element, (Paragraph, Heading)):
# Extract text from paragraph/heading
@ -106,7 +104,7 @@ def extract_text_from_page(page: Page) -> str:
if paragraph_text:
block_type = "HEADING" if isinstance(element, Heading) else "PARAGRAPH"
text_lines.append(f"{indent}{block_type}: {paragraph_text}")
elif isinstance(element, HList):
text_lines.append(f"{indent}LIST:")
# Extract text from list items
@ -115,33 +113,33 @@ def extract_text_from_page(page: Page) -> str:
item_text = extract_text_from_paragraph(item)
if item_text:
text_lines.append(f"{indent} - {item_text}")
except:
except Exception:
text_lines.append(f"{indent} (List content extraction failed)")
elif isinstance(element, Table):
text_lines.append(f"{indent}[TABLE]")
elif isinstance(element, AbstractImage):
alt_text = getattr(element, 'alt_text', '')
src = getattr(element, 'source', 'Unknown')
text_lines.append(f"{indent}[IMAGE: {alt_text or src}]")
# Handle containers with children
elif hasattr(element, '_children') and element._children:
for child in element._children:
extract_from_element(child, indent_level + 1)
# Handle text elements
elif hasattr(element, 'text'):
text = str(element.text).strip()
if text:
text_lines.append(f"{indent}{text}")
# Handle other object types by showing their class name
else:
class_name = element.__class__.__name__
text_lines.append(f"{indent}[{class_name}]")
# Helper function to extract text from paragraph-like objects
def extract_text_from_paragraph(para_obj):
words = []
@ -162,44 +160,44 @@ def extract_text_from_page(page: Page) -> str:
words.append(str(word))
except Exception as e:
return f"(Text extraction error: {str(e)})"
return ' '.join(words) if words else "(No text)"
# Extract text from page children
if hasattr(page, '_children'):
for child in page._children:
extract_from_element(child)
# If no text was extracted, add a note
if len(text_lines) <= 2: # Only header and empty line
text_lines.append("(No text content found)")
except Exception as e:
text_lines.append(f"Error extracting text: {str(e)}")
import traceback
text_lines.append(traceback.format_exc())
return "\n".join(text_lines)
def get_all_blocks_from_book(book) -> List[Block]:
"""
Extract all blocks from all chapters in the book.
Args:
book: The Book object from epub_reader
Returns:
List of all Block objects
"""
all_blocks = []
# Iterate through all chapters
for chapter in book.chapters:
# Get blocks from the chapter
if hasattr(chapter, '_blocks'):
all_blocks.extend(chapter._blocks)
return all_blocks
@ -214,117 +212,116 @@ Examples:
python epub_page_renderer.py book.epub --pages 10 --output-dir my_output --width 600 --height 800
"""
)
parser.add_argument(
'epub_file',
help='Path to the EPUB file to render'
)
parser.add_argument(
'--pages', '-p',
type=int,
default=5,
help='Number of pages to render (default: 5)'
)
parser.add_argument(
'--output-dir', '-o',
default='rendered_pages',
help='Output directory for rendered images (default: rendered_pages)'
)
parser.add_argument(
'--width', '-w',
type=int,
default=800,
help='Page width in pixels (default: 800)'
)
parser.add_argument(
'--height', '-t',
type=int,
default=1000,
help='Page height in pixels (default: 1000)'
)
parser.add_argument(
'--margin', '-m',
type=int,
default=40,
help='Page margin in pixels (default: 40)'
)
parser.add_argument(
'--align', '-a',
choices=['left', 'justify'],
default='left',
help='Text alignment: left or justify (default: left)'
)
args = parser.parse_args()
# Validate arguments
if not os.path.exists(args.epub_file):
print(f"Error: EPUB file '{args.epub_file}' not found")
return 1
if args.pages <= 0:
print("Error: Number of pages must be positive")
return 1
# Create output directory
try:
os.makedirs(args.output_dir, exist_ok=True)
except OSError as e:
print(f"Error creating output directory: {e}")
return 1
print(f"Loading EPUB file: {args.epub_file}")
# Load the EPUB file
try:
book = read_epub(args.epub_file)
print(f"Successfully loaded EPUB: {book.get_title() or 'Unknown Title'}")
# Print book information
author = book.get_metadata('AUTHOR')
if author:
print(f"Author: {author}")
print(f"Chapters: {len(book.chapters) if hasattr(book, 'chapters') else 'Unknown'}")
except Exception as e:
print(f"Error loading EPUB file: {e}")
import traceback
traceback.print_exc()
return 1
# Extract all blocks from the book
print("Extracting content blocks...")
try:
all_blocks = get_all_blocks_from_book(book)
print(f"Extracted {len(all_blocks)} content blocks")
if not all_blocks:
print("No content blocks found in EPUB. The book might be empty.")
return 1
# Apply alignment setting to all paragraphs and headings
from pyWebLayout.abstract.block import Paragraph, Heading
from pyWebLayout.style.alignment import Alignment
alignment = Alignment.JUSTIFY if args.align == 'justify' else Alignment.LEFT
print(f"Applying {args.align} alignment to all text blocks...")
# Note: We'll pass alignment to the layouter which will handle it during rendering
# The alignment is applied at the Line level in paragraph_layouter
except Exception as e:
print(f"Error extracting blocks: {e}")
import traceback
traceback.print_exc()
return 1
# Set up page style and layouter
page_size = (args.width, args.height)
page_style = PageStyle(
@ -335,9 +332,9 @@ Examples:
line_spacing=5,
inter_block_spacing=15
)
print(f"Setting up layouter with page size {page_size} and {args.align} alignment")
try:
layouter = BidirectionalLayouter(
blocks=all_blocks,
@ -350,83 +347,83 @@ Examples:
import traceback
traceback.print_exc()
return 1
# Render pages
print(f"Rendering up to {args.pages} pages...")
try:
pages = []
current_position = RenderingPosition() # Start from beginning
for page_num in range(args.pages):
print(f"Rendering page {page_num + 1}/{args.pages}...")
try:
# Render the page
page, next_position = layouter.render_page_forward(current_position)
pages.append(page)
# Check if we've reached the end of the document
if next_position.block_index >= len(all_blocks):
print(f"Reached end of document after {page_num + 1} pages")
break
# Update position for next page
current_position = next_position
except Exception as e:
print(f"Error rendering page {page_num + 1}: {e}")
import traceback
traceback.print_exc()
break
if not pages:
print("No pages were generated.")
return 1
print(f"Generated {len(pages)} pages")
# Save each page to an image and extract text
for i, page in enumerate(pages):
print(f"Saving page {i + 1}/{len(pages)}...")
try:
# Create image from page using pyWebLayout's built-in rendering
image = render_page_to_image(page)
# Save the image
output_filename = f"page_{i + 1:03d}.png"
output_path = os.path.join(args.output_dir, output_filename)
image.save(output_path, 'PNG')
# Extract and save text content for verification
page_text = extract_text_from_page(page)
text_filename = f"page_{i + 1:03d}.txt"
text_path = os.path.join(args.output_dir, text_filename)
with open(text_path, 'w', encoding='utf-8') as f:
f.write(page_text)
print(f"Saved: {output_path} and {text_path}")
except Exception as e:
print(f"Error saving page {i + 1}: {e}")
import traceback
traceback.print_exc()
continue
print(f"\nCompleted! Rendered {len(pages)} pages to {args.output_dir}")
# Calculate progress through the book
if len(all_blocks) > 0:
progress = (current_position.block_index / len(all_blocks)) * 100
print(f"Progress through book: {progress:.1f}%")
except Exception as e:
print(f"Error during pagination/rendering: {e}")
import traceback
traceback.print_exc()
return 1
return 0

View File

@ -23,14 +23,12 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
try:
from pyWebLayout.io.readers.epub_reader import read_epub
from pyWebLayout.layout.document_layouter import DocumentLayouter, paragraph_layouter, image_layouter, pagebreak_layouter
from pyWebLayout.layout.document_layouter import paragraph_layouter, image_layouter, pagebreak_layouter
from pyWebLayout.concrete.page import Page
from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.style.fonts import Font
from pyWebLayout.style.alignment import Alignment
from pyWebLayout.abstract.block import Block, Paragraph, Heading, HList, Table, Image as AbstractImage, PageBreak
from pyWebLayout.style.concrete_style import RenderingContext, StyleResolver
from PIL import Image, ImageDraw
from pyWebLayout.style.concrete_style import RenderingContext
except ImportError as e:
print(f"Error importing required modules: {e}")
print("Make sure pyWebLayout is properly installed and PIL is available")
@ -40,32 +38,32 @@ except ImportError as e:
def get_all_blocks_from_book(book) -> List[Block]:
"""
Extract all blocks from all chapters in the book.
Args:
book: The Book object from epub_reader
Returns:
List of all Block objects
"""
all_blocks = []
# Iterate through all chapters
for chapter in book.chapters:
# Get blocks from the chapter
if hasattr(chapter, '_blocks'):
all_blocks.extend(chapter._blocks)
return all_blocks
def create_page(page_style: PageStyle, page_size: Tuple[int, int]) -> Page:
"""
Create a new Page with the given style and size.
Args:
page_style: Style configuration for the page
page_size: (width, height) tuple
Returns:
A new Page object
"""
@ -73,23 +71,23 @@ def create_page(page_style: PageStyle, page_size: Tuple[int, int]) -> Page:
size=page_size,
style=page_style
)
return page
def layout_blocks_on_pages(blocks: List[Block], page_style: PageStyle,
def layout_blocks_on_pages(blocks: List[Block], page_style: PageStyle,
page_size: Tuple[int, int], max_pages: int,
alignment_override: Optional[Alignment] = None) -> List[Page]:
"""
Layout blocks across multiple pages using DocumentLayouter.
Args:
blocks: List of abstract blocks to layout
page_style: Style configuration for pages
page_size: (width, height) tuple for pages
max_pages: Maximum number of pages to generate
alignment_override: Optional alignment to override paragraph alignment
Returns:
List of rendered Page objects
"""
@ -97,29 +95,29 @@ def layout_blocks_on_pages(blocks: List[Block], page_style: PageStyle,
current_block_index = 0
continuation_word_index = 0
continuation_pretext = None
# Create rendering context
rendering_context = RenderingContext(base_font_size=16)
_rendering_context = RenderingContext(base_font_size=16)
while current_block_index < len(blocks) and len(pages) < max_pages:
# Create a new page
page = create_page(page_style, page_size)
page_has_content = False
# Try to layout blocks on this page
while current_block_index < len(blocks):
block = blocks[current_block_index]
if isinstance(block, (Paragraph, Heading)):
# Layout paragraph/heading
success, failed_word_index, remaining_pretext = paragraph_layouter(
block,
page,
block,
page,
start_word=continuation_word_index,
pretext=continuation_pretext,
alignment_override=alignment_override
)
if success:
# Block fully laid out, move to next block
page_has_content = True
@ -135,11 +133,11 @@ def layout_blocks_on_pages(blocks: List[Block], page_style: PageStyle,
continuation_pretext = remaining_pretext
# Break to create a new page
break
elif isinstance(block, AbstractImage):
# Layout image
success = image_layouter(block, page)
if success:
page_has_content = True
current_block_index += 1
@ -148,7 +146,7 @@ def layout_blocks_on_pages(blocks: List[Block], page_style: PageStyle,
else:
# Image doesn't fit, try on next page
break
elif isinstance(block, HList):
# Layout list items as paragraphs
try:
@ -162,53 +160,53 @@ def layout_blocks_on_pages(blocks: List[Block], page_style: PageStyle,
pretext=continuation_pretext,
alignment_override=alignment_override
)
if not success:
# Can't fit more on this page
page_has_content = True
break
continuation_word_index = 0
continuation_pretext = None
# Move to next block after processing list
page_has_content = True
current_block_index += 1
except Exception as e:
print(f"Warning: Error processing list: {e}")
current_block_index += 1
elif isinstance(block, PageBreak):
# PageBreak forces a new page
success = pagebreak_layouter(block, page)
# Mark that we've seen this block
current_block_index += 1
continuation_word_index = 0
continuation_pretext = None
# PageBreak always returns False to force new page
# Break to create a new page for subsequent content
break
elif isinstance(block, Table):
# Skip tables for now (not implemented)
print(f"Warning: Skipping table (not yet implemented)")
print("Warning: Skipping table (not yet implemented)")
current_block_index += 1
else:
# Unknown block type, skip
print(f"Warning: Skipping unknown block type: {type(block).__name__}")
current_block_index += 1
# Add page if it has content
if page_has_content:
pages.append(page)
else:
# No content could be added to this page, stop
break
return pages
@ -223,107 +221,107 @@ Examples:
python epub_page_renderer_documentlayouter.py book.epub --pages 10 --output-dir my_output --width 600 --height 800
"""
)
parser.add_argument(
'epub_file',
help='Path to the EPUB file to render'
)
parser.add_argument(
'--pages', '-p',
type=int,
default=5,
help='Number of pages to render (default: 5)'
)
parser.add_argument(
'--output-dir', '-o',
default='rendered_pages',
help='Output directory for rendered images (default: rendered_pages)'
)
parser.add_argument(
'--width', '-w',
type=int,
default=800,
help='Page width in pixels (default: 800)'
)
parser.add_argument(
'--height', '-t',
type=int,
default=1000,
help='Page height in pixels (default: 1000)'
)
parser.add_argument(
'--margin', '-m',
type=int,
default=40,
help='Page margin in pixels (default: 40)'
)
parser.add_argument(
'--align', '-a',
choices=['left', 'justify'],
default='left',
help='Text alignment: left or justify (default: left)'
)
args = parser.parse_args()
# Validate arguments
if not os.path.exists(args.epub_file):
print(f"Error: EPUB file '{args.epub_file}' not found")
return 1
if args.pages <= 0:
print("Error: Number of pages must be positive")
return 1
# Create output directory
try:
os.makedirs(args.output_dir, exist_ok=True)
except OSError as e:
print(f"Error creating output directory: {e}")
return 1
print(f"Loading EPUB file: {args.epub_file}")
# Load the EPUB file
try:
book = read_epub(args.epub_file)
print(f"Successfully loaded EPUB: {book.get_title() or 'Unknown Title'}")
# Print book information
author = book.get_metadata('AUTHOR')
if author:
print(f"Author: {author}")
print(f"Chapters: {len(book.chapters) if hasattr(book, 'chapters') else 'Unknown'}")
except Exception as e:
print(f"Error loading EPUB file: {e}")
import traceback
traceback.print_exc()
return 1
# Extract all blocks from the book
print("Extracting content blocks...")
try:
all_blocks = get_all_blocks_from_book(book)
print(f"Extracted {len(all_blocks)} content blocks")
if not all_blocks:
print("No content blocks found in EPUB. The book might be empty.")
return 1
except Exception as e:
print(f"Error extracting blocks: {e}")
import traceback
traceback.print_exc()
return 1
# Set up page style
page_size = (args.width, args.height)
page_style = PageStyle(
@ -334,58 +332,58 @@ Examples:
line_spacing=5,
inter_block_spacing=15
)
# Set alignment
alignment = Alignment.JUSTIFY if args.align == 'justify' else Alignment.LEFT
print(f"Setting up layouter with page size {page_size} and {args.align} alignment")
# Layout blocks on pages
print(f"Rendering up to {args.pages} pages using DocumentLayouter...")
try:
pages = layout_blocks_on_pages(
all_blocks,
page_style,
page_size,
all_blocks,
page_style,
page_size,
args.pages,
alignment_override=alignment
)
if not pages:
print("No pages were generated.")
return 1
print(f"Generated {len(pages)} pages")
# Save each page to an image
for i, page in enumerate(pages):
print(f"Saving page {i + 1}/{len(pages)}...")
try:
# Render the page
image = page.render()
# Save the image
output_filename = f"page_{i + 1:03d}.png"
output_path = os.path.join(args.output_dir, output_filename)
image.save(output_path, 'PNG')
print(f"Saved: {output_path}")
except Exception as e:
print(f"Error saving page {i + 1}: {e}")
import traceback
traceback.print_exc()
continue
print(f"\nCompleted! Rendered {len(pages)} pages to {args.output_dir}")
except Exception as e:
print(f"Error during pagination/rendering: {e}")
import traceback
traceback.print_exc()
return 1
return 0

View File

@ -5,7 +5,6 @@ Runs test and documentation coverage locally and generates badges.
"""
import subprocess
import sys
import os
@ -15,7 +14,7 @@ def run_command(cmd, description):
print(f"Running: {description}")
print(f"Command: {cmd}")
print(f"{'='*50}")
try:
result = subprocess.run(cmd, shell=True, check=True, capture_output=True, text=True)
print(result.stdout)
@ -34,11 +33,11 @@ def main():
"""Run full coverage analysis locally."""
print("Local Coverage Analysis for pyWebLayout")
print("=" * 60)
# Change to project root if running from scripts directory
if os.path.basename(os.getcwd()) == "scripts":
os.chdir("..")
# Install required packages
print("\n1. Installing required packages...")
packages = [
@ -46,28 +45,35 @@ def main():
"coverage-badge",
"interrogate"
]
for package in packages:
if not run_command(f"pip install {package}", f"Installing {package}"):
print(f"Failed to install {package}, continuing...")
# Run tests with coverage
print("\n2. Running tests with coverage...")
test_cmd = "python -m pytest tests/ -v --cov=pyWebLayout --cov-report=term-missing --cov-report=json --cov-report=html --cov-report=xml"
test_cmd = (
"python -m pytest tests/ -v --cov=pyWebLayout "
"--cov-report=term-missing --cov-report=json --cov-report=html --cov-report=xml"
)
run_command(test_cmd, "Running tests with coverage")
# Generate test coverage badge
print("\n3. Generating test coverage badge...")
run_command("coverage-badge -o coverage.svg -f", "Generating test coverage badge")
# Check documentation coverage
print("\n4. Checking documentation coverage...")
docs_cmd = "interrogate -v --ignore-init-method --ignore-init-module --ignore-magic --ignore-private --ignore-property-decorators --ignore-semiprivate --fail-under=80 --generate-badge coverage-docs.svg pyWebLayout/"
docs_cmd = (
"interrogate -v --ignore-init-method --ignore-init-module --ignore-magic "
"--ignore-private --ignore-property-decorators --ignore-semiprivate "
"--fail-under=80 --generate-badge coverage-docs.svg pyWebLayout/"
)
run_command(docs_cmd, "Checking documentation coverage")
# Generate coverage summary
print("\n5. Generating coverage summary...")
# Write a temporary script to avoid shell quoting issues
summary_script_content = '''import json
import os
@ -75,30 +81,30 @@ import os
if os.path.exists("coverage.json"):
with open("coverage.json", "r") as f:
coverage_data = json.load(f)
total_coverage = round(coverage_data["totals"]["percent_covered"], 1)
covered_lines = coverage_data["totals"]["covered_lines"]
total_lines = coverage_data["totals"]["num_statements"]
with open("coverage-summary.txt", "w") as f:
f.write(f"{total_coverage}%")
print(f"Test Coverage: {total_coverage}%")
print(f"Lines Covered: {covered_lines}/{total_lines}")
else:
print("No coverage data found")
'''
# Write and execute temporary script
with open('temp_coverage_summary.py', 'w') as f:
f.write(summary_script_content)
success = run_command("python temp_coverage_summary.py", "Generating coverage summary")
run_command("python temp_coverage_summary.py", "Generating coverage summary")
# Clean up temporary script
if os.path.exists('temp_coverage_summary.py'):
os.remove('temp_coverage_summary.py')
# List generated files
print("\n6. Generated files:")
files = ["coverage.svg", "coverage-docs.svg", "coverage-summary.txt", "htmlcov/", "coverage.json", "coverage.xml"]
@ -107,7 +113,7 @@ else:
print(f"{file}")
else:
print(f"{file} (not found)")
print("\n" + "="*60)
print("Coverage analysis complete!")
print("To update your README with badges, run:")

View File

@ -14,23 +14,23 @@ def main():
"""Run coverage for Coverage Gutters."""
print("Generating coverage for Coverage Gutters...")
print("Using the same pytest approach as CI...")
try:
# Run tests with coverage and generate all report formats (same as CI)
cmd = [
sys.executable, "-m", "pytest",
"tests/",
sys.executable, "-m", "pytest",
"tests/",
"-v",
"--cov=pyWebLayout",
"--cov=pyWebLayout",
"--cov-report=term-missing",
"--cov-report=json",
"--cov-report=html",
"--cov-report=xml"
]
print(f"Running: {' '.join(cmd)}")
result = subprocess.run(cmd, check=True)
_result = subprocess.run(cmd, check=True)
# Check if coverage.xml was created
if os.path.exists("coverage.xml"):
print("✓ coverage.xml generated successfully!")
@ -42,14 +42,14 @@ def main():
print("2. Run 'Coverage Gutters: Remove Coverage' (to clear cache)")
print("3. Run 'Coverage Gutters: Display Coverage'")
print("4. Or use the Coverage Gutters buttons in the status bar")
# Show file info
size = os.path.getsize("coverage.xml")
print(f"\nGenerated coverage.xml: {size} bytes")
else:
print("✗ coverage.xml was not generated")
except subprocess.CalledProcessError as e:
print(f"Error running tests: {e}")
print("This may indicate test failures or missing dependencies.")

View File

@ -12,19 +12,19 @@ import sys
def update_readme_badges():
"""Update README.md with coverage badges."""
readme_path = "README.md"
if not os.path.exists(readme_path):
print("README.md not found!")
return False
# Read current README
with open(readme_path, 'r') as f:
content = f.read()
# Coverage badges to add/update
test_coverage_badge = "![Test Coverage](./coverage.svg)"
docs_coverage_badge = "![Documentation Coverage](./coverage-docs.svg)"
# Check if badges already exist and update them, otherwise add them at the top
if "![Test Coverage]" in content:
content = re.sub(r'!\[Test Coverage\]\([^)]+\)', test_coverage_badge, content)
@ -34,7 +34,7 @@ def update_readme_badges():
if len(lines) > 0:
lines.insert(1, f"\n{test_coverage_badge}")
content = '\n'.join(lines)
if "![Documentation Coverage]" in content:
content = re.sub(r'!\[Documentation Coverage\]\([^)]+\)', docs_coverage_badge, content)
else:
@ -45,11 +45,11 @@ def update_readme_badges():
lines.insert(i + 1, docs_coverage_badge)
break
content = '\n'.join(lines)
# Write updated README
with open(readme_path, 'w') as f:
f.write(content)
print("README.md updated with coverage badges!")
return True
@ -60,7 +60,7 @@ def show_coverage_summary():
with open("coverage-summary.txt", 'r') as f:
test_coverage = f.read().strip()
print(f"Current Test Coverage: {test_coverage}")
# Try to get documentation coverage from interrogate output
if os.path.exists("coverage.json"):
import json
@ -68,7 +68,9 @@ def show_coverage_summary():
with open("coverage.json", 'r') as f:
coverage_data = json.load(f)
print(f"Detailed Coverage: {coverage_data['totals']['percent_covered']:.1f}%")
print(f"Lines Covered: {coverage_data['totals']['covered_lines']}/{coverage_data['totals']['num_statements']}")
covered = coverage_data['totals']['covered_lines']
total = coverage_data['totals']['num_statements']
print(f"Lines Covered: {covered}/{total}")
except (KeyError, json.JSONDecodeError):
print("Could not parse coverage data")

View File

@ -10,18 +10,18 @@ import json
def main():
"""Main function to fix coverage gutters configuration."""
print("=== Coverage Gutters Fix ===")
print(f"Current working directory: {os.getcwd()}")
# 1. Check if coverage.xml exists
if os.path.exists('coverage.xml'):
print("✓ coverage.xml exists")
# Check file size and basic content
size = os.path.getsize('coverage.xml')
print(f"✓ coverage.xml size: {size} bytes")
# Read first few lines to verify it's valid XML
try:
with open('coverage.xml', 'r') as f:
@ -37,12 +37,12 @@ def main():
print("Running coverage to generate coverage.xml...")
os.system("python -m coverage run --source=pyWebLayout -m unittest tests.test_abstract_inline")
os.system("python -m coverage xml")
# 2. Check VSCode settings
vscode_settings_path = '.vscode/settings.json'
if os.path.exists(vscode_settings_path):
print("✓ VSCode settings.json exists")
with open(vscode_settings_path, 'r') as f:
try:
settings = json.load(f)
@ -57,18 +57,18 @@ def main():
print(f"✗ Error parsing VSCode settings: {e}")
else:
print("✗ VSCode settings.json not found")
# 3. Check if inline.py file exists
inline_file = 'pyWebLayout/abstract/inline.py'
if os.path.exists(inline_file):
print(f"{inline_file} exists")
# Check file size
size = os.path.getsize(inline_file)
print(f"{inline_file} size: {size} bytes")
else:
print(f"{inline_file} does not exist")
# 4. Run a fresh coverage collection specifically for the inline module
print("\n=== Running Fresh Coverage ===")
try:
@ -80,7 +80,7 @@ def main():
print("✓ Fresh coverage data generated")
except Exception as e:
print(f"✗ Error generating coverage: {e}")
# 5. Instructions for manual verification
print("\n=== Manual Verification Steps ===")
print("1. In VSCode, open the Command Palette (Ctrl+Shift+P)")
@ -90,7 +90,7 @@ def main():
print(" - 'Coverage Gutters: Display Coverage' again")
print("4. Check that coverage.xml contains data for pyWebLayout/abstract/inline.py")
print("5. The file should show 100% coverage (all lines covered)")
print("\n=== Troubleshooting ===")
print("If coverage still doesn't show:")
print("1. Restart VSCode")

View File

@ -12,9 +12,9 @@ import threading
import time
from PIL import Image as PILImage
from pyWebLayout.abstract.block import (
Block, BlockType, Paragraph, Heading, HeadingLevel, Quote, CodeBlock,
HList, ListStyle, ListItem, Table, TableRow, TableCell,
HorizontalRule, Image
BlockType, Paragraph, Heading, HeadingLevel, Quote, CodeBlock, HList,
ListStyle, ListItem, Table, TableRow, TableCell, HorizontalRule,
Image
)
from pyWebLayout.abstract.inline import Word, LineBreak
from pyWebLayout.style import Font
@ -29,256 +29,256 @@ except ImportError:
class TestBlockElements(unittest.TestCase):
"""Test cases for basic block elements."""
def test_paragraph_creation(self):
"""Test creating and using paragraphs."""
paragraph = Paragraph()
self.assertEqual(paragraph.block_type, BlockType.PARAGRAPH)
self.assertEqual(paragraph.word_count, 0)
self.assertIsNone(paragraph.parent)
# Add words
font = Font()
word1 = Word("Hello", font)
word2 = Word("World", font)
paragraph.add_word(word1)
paragraph.add_word(word2)
self.assertEqual(paragraph.word_count, 2)
# Test word iteration
words = list(paragraph.words_iter())
self.assertEqual(len(words), 2)
self.assertEqual(words[0][1].text, "Hello")
self.assertEqual(words[1][1].text, "World")
def test_heading_levels(self):
"""Test heading creation with different levels."""
h1 = Heading(HeadingLevel.H1)
h3 = Heading(HeadingLevel.H3)
h6 = Heading(HeadingLevel.H6)
self.assertEqual(h1.level, HeadingLevel.H1)
self.assertEqual(h3.level, HeadingLevel.H3)
self.assertEqual(h6.level, HeadingLevel.H6)
self.assertEqual(h1.block_type, BlockType.HEADING)
# Test level modification
h1.level = HeadingLevel.H2
self.assertEqual(h1.level, HeadingLevel.H2)
def test_quote_nesting(self):
"""Test blockquote with nested content."""
quote = Quote()
# Add nested paragraphs
p1 = Paragraph()
p2 = Paragraph()
quote.add_block(p1)
quote.add_block(p2)
self.assertEqual(p1.parent, quote)
self.assertEqual(p2.parent, quote)
# Test block iteration
blocks = list(quote.blocks())
self.assertEqual(len(blocks), 2)
self.assertEqual(blocks[0], p1)
self.assertEqual(blocks[1], p2)
def test_code_block(self):
"""Test code block functionality."""
code = CodeBlock("python")
self.assertEqual(code.language, "python")
self.assertEqual(code.line_count, 0)
# Add code lines
code.add_line("def hello():")
code.add_line(" print('Hello!')")
self.assertEqual(code.line_count, 2)
# Test line iteration
lines = list(code.lines())
self.assertEqual(len(lines), 2)
self.assertEqual(lines[0][1], "def hello():")
self.assertEqual(lines[1][1], " print('Hello!')")
# Test language modification
code.language = "javascript"
self.assertEqual(code.language, "javascript")
def test_list_creation(self):
"""Test list creation and item management."""
# Unordered list
ul = HList(ListStyle.UNORDERED)
self.assertEqual(ul.style, ListStyle.UNORDERED)
self.assertEqual(ul.item_count, 0)
# Add list items
item1 = ListItem()
item2 = ListItem()
ul.add_item(item1)
ul.add_item(item2)
self.assertEqual(ul.item_count, 2)
self.assertEqual(item1.parent, ul)
self.assertEqual(item2.parent, ul)
# Test item iteration
items = list(ul.items())
self.assertEqual(len(items), 2)
# Test list style change
ul.style = ListStyle.ORDERED
self.assertEqual(ul.style, ListStyle.ORDERED)
def test_definition_list(self):
"""Test definition list with terms."""
dl = HList(ListStyle.DEFINITION)
# Add definition items with terms
dt1 = ListItem(term="Python")
dt2 = ListItem(term="JavaScript")
dl.add_item(dt1)
dl.add_item(dt2)
self.assertEqual(dt1.term, "Python")
self.assertEqual(dt2.term, "JavaScript")
# Test term modification
dt1.term = "Python 3"
self.assertEqual(dt1.term, "Python 3")
def test_table_structure(self):
"""Test table, row, and cell structure."""
table = Table(caption="Test Table")
self.assertEqual(table.caption, "Test Table")
self.assertEqual(table.row_count["total"], 0)
# Create rows and cells
header_row = TableRow()
data_row = TableRow()
# Header cells
h1 = TableCell(is_header=True)
h2 = TableCell(is_header=True)
header_row.add_cell(h1)
header_row.add_cell(h2)
# Data cells
d1 = TableCell(is_header=False)
d2 = TableCell(is_header=False, colspan=2)
data_row.add_cell(d1)
data_row.add_cell(d2)
# Add rows to table
table.add_row(header_row, "header")
table.add_row(data_row, "body")
# Test structure
self.assertEqual(table.row_count["header"], 1)
self.assertEqual(table.row_count["body"], 1)
self.assertEqual(table.row_count["total"], 2)
# Test cell properties
self.assertTrue(h1.is_header)
self.assertFalse(d1.is_header)
self.assertEqual(d2.colspan, 2)
self.assertEqual(d2.rowspan, 1) # Default
# Test row cell count
self.assertEqual(header_row.cell_count, 2)
self.assertEqual(data_row.cell_count, 2)
def test_table_sections(self):
"""Test table header, body, and footer sections."""
table = Table()
# Add rows to different sections
header = TableRow()
body1 = TableRow()
body2 = TableRow()
footer = TableRow()
table.add_row(header, "header")
table.add_row(body1, "body")
table.add_row(body2, "body")
table.add_row(footer, "footer")
# Test section iteration
header_rows = list(table.header_rows())
body_rows = list(table.body_rows())
footer_rows = list(table.footer_rows())
self.assertEqual(len(header_rows), 1)
self.assertEqual(len(body_rows), 2)
self.assertEqual(len(footer_rows), 1)
# Test all_rows iteration
all_rows = list(table.all_rows())
self.assertEqual(len(all_rows), 4)
# Check section labels
sections = [section for section, row in all_rows]
self.assertEqual(sections, ["header", "body", "body", "footer"])
def test_image_loading(self):
"""Test image element properties."""
# Test with basic properties
img = Image("test.jpg", "Test image", 100, 200)
self.assertEqual(img.source, "test.jpg")
self.assertEqual(img.alt_text, "Test image")
self.assertEqual(img.width, 100)
self.assertEqual(img.height, 200)
# Test property modification
img.source = "new.png"
img.alt_text = "New image"
img.width = 150
img.height = 300
self.assertEqual(img.source, "new.png")
self.assertEqual(img.alt_text, "New image")
self.assertEqual(img.width, 150)
self.assertEqual(img.height, 300)
# Test dimensions tuple
self.assertEqual(img.get_dimensions(), (150, 300))
def test_aspect_ratio_calculation(self):
"""Test image aspect ratio calculations."""
# Test with specified dimensions
img = Image("test.jpg", width=400, height=200)
self.assertEqual(img.get_aspect_ratio(), 2.0) # 400/200
# Test with only one dimension
img2 = Image("test.jpg", width=300)
self.assertIsNone(img2.get_aspect_ratio()) # No height specified
# Test scaled dimensions
scaled = img.calculate_scaled_dimensions(max_width=200, max_height=150)
# Should scale down proportionally
self.assertEqual(scaled[0], 200) # Width limited by max_width
self.assertEqual(scaled[1], 100) # Height scaled proportionally
def test_simple_elements(self):
"""Test simple block elements."""
hr = HorizontalRule()
br = LineBreak()
self.assertEqual(hr.block_type, BlockType.HORIZONTAL_RULE)
self.assertEqual(br.block_type, BlockType.LINE_BREAK)
# These elements have no additional properties
self.assertIsNone(hr.parent)
self.assertIsNone(br.parent)
@ -286,29 +286,29 @@ class TestBlockElements(unittest.TestCase):
class TestImagePIL(unittest.TestCase):
"""Test cases for Image class with PIL functionality."""
@classmethod
def setUpClass(cls):
"""Set up temporary directory and test images."""
cls.temp_dir = tempfile.mkdtemp()
cls.sample_image_path = "tests/data/sample_image.jpg"
# Create test images in different formats
cls._create_test_images()
# Start Flask server for URL testing if Flask is available
if FLASK_AVAILABLE:
cls._start_flask_server()
@classmethod
def tearDownClass(cls):
"""Clean up temporary directory and stop Flask server."""
shutil.rmtree(cls.temp_dir, ignore_errors=True)
if FLASK_AVAILABLE and hasattr(cls, 'flask_thread'):
cls.flask_server_running = False
cls.flask_thread.join(timeout=2)
@classmethod
def _create_test_images(cls):
"""Create test images in different formats."""
@ -316,17 +316,17 @@ class TestImagePIL(unittest.TestCase):
if os.path.exists(cls.sample_image_path):
with PILImage.open(cls.sample_image_path) as img:
cls.original_size = img.size
# Save in different formats
cls.jpg_path = os.path.join(cls.temp_dir, "test.jpg")
cls.png_path = os.path.join(cls.temp_dir, "test.png")
cls.bmp_path = os.path.join(cls.temp_dir, "test.bmp")
cls.gif_path = os.path.join(cls.temp_dir, "test.gif")
img.save(cls.jpg_path, "JPEG")
img.save(cls.png_path, "PNG")
img.save(cls.bmp_path, "BMP")
# Convert to RGB for GIF (GIF doesn't support transparency from RGBA)
rgb_img = img.convert("RGB")
rgb_img.save(cls.gif_path, "GIF")
@ -334,17 +334,17 @@ class TestImagePIL(unittest.TestCase):
# Create a simple test image if sample doesn't exist
cls.original_size = (100, 100)
test_img = PILImage.new("RGB", cls.original_size, (255, 0, 0))
cls.jpg_path = os.path.join(cls.temp_dir, "test.jpg")
cls.png_path = os.path.join(cls.temp_dir, "test.png")
cls.bmp_path = os.path.join(cls.temp_dir, "test.bmp")
cls.gif_path = os.path.join(cls.temp_dir, "test.gif")
test_img.save(cls.jpg_path, "JPEG")
test_img.save(cls.png_path, "PNG")
test_img.save(cls.bmp_path, "BMP")
test_img.save(cls.gif_path, "GIF")
@classmethod
def _start_flask_server(cls):
"""Start a Flask server for URL testing."""
@ -365,7 +365,7 @@ class TestImagePIL(unittest.TestCase):
def run_flask():
cls.flask_app.run(host='127.0.0.1', port=cls.flask_port, debug=False,
use_reloader=False, threaded=True)
use_reloader=False, threaded=True)
cls.flask_thread = threading.Thread(target=run_flask, daemon=True)
cls.flask_thread.start()
@ -384,120 +384,120 @@ class TestImagePIL(unittest.TestCase):
pass
time.sleep(wait_interval)
elapsed += wait_interval
def test_image_url_detection(self):
"""Test URL detection functionality."""
img = Image()
# Test URL detection
self.assertTrue(img._is_url("http://example.com/image.jpg"))
self.assertTrue(img._is_url("https://example.com/image.png"))
self.assertTrue(img._is_url("ftp://example.com/image.gif"))
# Test non-URL detection
self.assertFalse(img._is_url("image.jpg"))
self.assertFalse(img._is_url("/path/to/image.png"))
self.assertFalse(img._is_url("../relative/path.gif"))
self.assertFalse(img._is_url(""))
def test_load_local_image_jpg(self):
"""Test loading local JPG image."""
img = Image(self.jpg_path)
file_path, pil_img = img.load_image_data()
self.assertIsNotNone(pil_img)
self.assertEqual(file_path, self.jpg_path)
self.assertEqual(pil_img.size, self.original_size)
self.assertEqual(img.width, self.original_size[0])
self.assertEqual(img.height, self.original_size[1])
def test_load_local_image_png(self):
"""Test loading local PNG image."""
img = Image(self.png_path)
file_path, pil_img = img.load_image_data()
self.assertIsNotNone(pil_img)
self.assertEqual(file_path, self.png_path)
self.assertEqual(pil_img.size, self.original_size)
def test_load_local_image_bmp(self):
"""Test loading local BMP image."""
img = Image(self.bmp_path)
file_path, pil_img = img.load_image_data()
self.assertIsNotNone(pil_img)
self.assertEqual(file_path, self.bmp_path)
self.assertEqual(pil_img.size, self.original_size)
def test_load_local_image_gif(self):
"""Test loading local GIF image."""
img = Image(self.gif_path)
file_path, pil_img = img.load_image_data()
self.assertIsNotNone(pil_img)
self.assertEqual(file_path, self.gif_path)
self.assertEqual(pil_img.size, self.original_size)
def test_load_nonexistent_image(self):
"""Test loading non-existent image."""
img = Image("nonexistent.jpg")
file_path, pil_img = img.load_image_data()
self.assertIsNone(pil_img)
self.assertIsNone(file_path)
def test_load_empty_source(self):
"""Test loading with empty source."""
img = Image("")
file_path, pil_img = img.load_image_data()
self.assertIsNone(pil_img)
self.assertIsNone(file_path)
def test_auto_update_dimensions(self):
"""Test automatic dimension updating."""
img = Image(self.jpg_path, width=50, height=50) # Wrong initial dimensions
# Test with auto-update enabled (default)
file_path, pil_img = img.load_image_data(auto_update_dimensions=True)
self.assertEqual(img.width, self.original_size[0])
self.assertEqual(img.height, self.original_size[1])
def test_no_auto_update_dimensions(self):
"""Test loading without automatic dimension updating."""
original_width, original_height = 50, 50
img = Image(self.jpg_path, width=original_width, height=original_height)
# Test with auto-update disabled
file_path, pil_img = img.load_image_data(auto_update_dimensions=False)
self.assertEqual(img.width, original_width) # Should remain unchanged
self.assertEqual(img.height, original_height) # Should remain unchanged
def test_get_image_info(self):
"""Test getting detailed image information."""
img = Image(self.jpg_path)
info = img.get_image_info()
self.assertIsInstance(info, dict)
self.assertIn('format', info)
self.assertIn('mode', info)
self.assertIn('size', info)
self.assertIn('width', info)
self.assertIn('height', info)
self.assertEqual(info['size'], self.original_size)
self.assertEqual(info['width'], self.original_size[0])
self.assertEqual(info['height'], self.original_size[1])
def test_get_image_info_different_formats(self):
"""Test getting image info for different formats."""
formats_and_paths = [
@ -506,80 +506,80 @@ class TestImagePIL(unittest.TestCase):
('BMP', self.bmp_path),
('GIF', self.gif_path),
]
for expected_format, path in formats_and_paths:
with self.subTest(format=expected_format):
img = Image(path)
info = img.get_image_info()
self.assertEqual(info['format'], expected_format)
self.assertEqual(info['size'], self.original_size)
def test_get_image_info_nonexistent(self):
"""Test getting image info for non-existent image."""
img = Image("nonexistent.jpg")
info = img.get_image_info()
self.assertEqual(info, {})
@unittest.skipUnless(FLASK_AVAILABLE, "Flask not available for URL testing")
def test_load_image_from_url(self):
"""Test loading image from URL."""
url = f"http://127.0.0.1:{self.flask_port}/test.jpg"
img = Image(url)
file_path, pil_img = img.load_image_data()
self.assertIsNotNone(pil_img)
self.assertIsNotNone(file_path)
self.assertTrue(file_path.endswith('.tmp')) # Should be a temp file
self.assertEqual(pil_img.size, self.original_size)
# Check that dimensions were updated
self.assertEqual(img.width, self.original_size[0])
self.assertEqual(img.height, self.original_size[1])
@unittest.skipUnless(FLASK_AVAILABLE, "Flask not available for URL testing")
def test_get_image_info_from_url(self):
"""Test getting image info from URL."""
url = f"http://127.0.0.1:{self.flask_port}/test.jpg"
img = Image(url)
info = img.get_image_info()
self.assertIsInstance(info, dict)
self.assertEqual(info['format'], 'JPEG')
self.assertEqual(info['size'], self.original_size)
def test_load_invalid_url(self):
"""Test loading from invalid URL."""
img = Image("http://nonexistent.domain/image.jpg")
file_path, pil_img = img.load_image_data()
self.assertIsNone(pil_img)
self.assertIsNone(file_path)
def test_multiple_loads_cleanup(self):
"""Test that multiple loads don't leave temp files."""
img = Image(self.jpg_path)
# Load multiple times
for _ in range(3):
file_path, pil_img = img.load_image_data()
self.assertIsNotNone(pil_img)
def test_original_sample_image(self):
"""Test loading the original sample image if it exists."""
if os.path.exists(self.sample_image_path):
img = Image(self.sample_image_path)
file_path, pil_img = img.load_image_data()
self.assertIsNotNone(pil_img)
self.assertEqual(file_path, self.sample_image_path)
# Test that we can get image info
info = img.get_image_info()
self.assertIsInstance(info, dict)

View File

@ -8,13 +8,13 @@ document structure and metadata management.
import unittest
from pyWebLayout.abstract.document import Document, Chapter, Book, MetadataType
from pyWebLayout.abstract.block import Paragraph, Heading, HeadingLevel, BlockType
from pyWebLayout.abstract.inline import Word, FormattedSpan
from pyWebLayout.abstract.inline import Word
from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration
class TestMetadataType(unittest.TestCase):
"""Test cases for MetadataType enum."""
def test_metadata_types(self):
"""Test that all expected metadata types exist."""
expected_types = [
@ -22,135 +22,141 @@ class TestMetadataType(unittest.TestCase):
'PUBLICATION_DATE', 'MODIFIED_DATE', 'PUBLISHER', 'IDENTIFIER',
'COVER_IMAGE', 'CUSTOM'
]
for type_name in expected_types:
self.assertTrue(hasattr(MetadataType, type_name))
# Test custom type has expected value
self.assertEqual(MetadataType.CUSTOM.value, 100)
class TestDocument(unittest.TestCase):
"""Test cases for Document class."""
def setUp(self):
"""Set up test fixtures."""
self.doc = Document("Test Document", "en-US")
self.font = Font()
def test_document_creation(self):
"""Test document creation with basic parameters."""
self.assertEqual(self.doc.get_title(), "Test Document")
self.assertEqual(self.doc.get_metadata(MetadataType.LANGUAGE), "en-US")
self.assertEqual(len(self.doc.blocks), 0)
def test_document_creation_minimal(self):
"""Test document creation with minimal parameters."""
doc = Document()
self.assertIsNone(doc.get_title())
self.assertEqual(doc.get_metadata(MetadataType.LANGUAGE), "en-US")
def test_metadata_management(self):
"""Test setting and getting metadata."""
# Set various metadata types
self.doc.set_metadata(MetadataType.AUTHOR, "John Doe")
self.doc.set_metadata(MetadataType.DESCRIPTION, "A test document")
self.doc.set_metadata(MetadataType.KEYWORDS, ["test", "document"])
# Test retrieval
self.assertEqual(self.doc.get_metadata(MetadataType.AUTHOR), "John Doe")
self.assertEqual(self.doc.get_metadata(MetadataType.DESCRIPTION), "A test document")
self.assertEqual(self.doc.get_metadata(MetadataType.KEYWORDS), ["test", "document"])
self.assertEqual(
self.doc.get_metadata(
MetadataType.DESCRIPTION),
"A test document")
self.assertEqual(
self.doc.get_metadata(
MetadataType.KEYWORDS), [
"test", "document"])
# Test non-existent metadata
self.assertIsNone(self.doc.get_metadata(MetadataType.PUBLISHER))
def test_title_convenience_methods(self):
"""Test title getter and setter convenience methods."""
# Test setting title
self.doc.set_title("New Title")
self.assertEqual(self.doc.get_title(), "New Title")
# Test that it's also in metadata
self.assertEqual(self.doc.get_metadata(MetadataType.TITLE), "New Title")
def test_block_management(self):
"""Test adding and managing blocks."""
# Create some blocks
para1 = Paragraph()
para2 = Paragraph()
heading = Heading(HeadingLevel.H1)
# Add blocks
self.doc.add_block(para1)
self.doc.add_block(heading)
self.doc.add_block(para2)
# Test blocks list
self.assertEqual(len(self.doc.blocks), 3)
self.assertEqual(self.doc.blocks[0], para1)
self.assertEqual(self.doc.blocks[1], heading)
self.assertEqual(self.doc.blocks[2], para2)
def test_anchor_management(self):
"""Test named anchor functionality."""
heading = Heading(HeadingLevel.H1)
para = Paragraph()
# Add anchors
self.doc.add_anchor("intro", heading)
self.doc.add_anchor("content", para)
# Test retrieval
self.assertEqual(self.doc.get_anchor("intro"), heading)
self.assertEqual(self.doc.get_anchor("content"), para)
self.assertIsNone(self.doc.get_anchor("nonexistent"))
def test_resource_management(self):
"""Test document resource management."""
# Add various resources
self.doc.add_resource("image1", {"type": "image", "path": "test.jpg"})
self.doc.add_resource("style1", {"type": "css", "content": "body {}"})
# Test retrieval
image = self.doc.get_resource("image1")
self.assertEqual(image["type"], "image")
self.assertEqual(image["path"], "test.jpg")
style = self.doc.get_resource("style1")
self.assertEqual(style["type"], "css")
# Test non-existent resource
self.assertIsNone(self.doc.get_resource("nonexistent"))
def test_stylesheet_management(self):
"""Test stylesheet addition."""
# Add stylesheets
css1 = {"href": "style.css", "type": "text/css"}
css2 = {"href": "theme.css", "type": "text/css"}
self.doc.add_stylesheet(css1)
self.doc.add_stylesheet(css2)
# Test that stylesheets are stored
self.assertEqual(len(self.doc._stylesheets), 2)
self.assertEqual(self.doc._stylesheets[0], css1)
self.assertEqual(self.doc._stylesheets[1], css2)
def test_script_management(self):
"""Test script addition."""
# Add scripts
script1 = "console.log('Hello');"
script2 = "document.ready(function(){});"
self.doc.add_script(script1)
self.doc.add_script(script2)
# Test that scripts are stored
self.assertEqual(len(self.doc._scripts), 2)
self.assertEqual(self.doc._scripts[0], script1)
self.assertEqual(self.doc._scripts[1], script2)
def test_find_blocks_by_type(self):
"""Test finding blocks by type."""
# Create blocks of different types
@ -158,87 +164,87 @@ class TestDocument(unittest.TestCase):
para2 = Paragraph()
heading1 = Heading(HeadingLevel.H1)
heading2 = Heading(HeadingLevel.H2)
# Add blocks to document
self.doc.add_block(para1)
self.doc.add_block(heading1)
self.doc.add_block(para2)
self.doc.add_block(heading2)
# Test finding paragraphs
paragraphs = self.doc.find_blocks_by_type(BlockType.PARAGRAPH)
self.assertEqual(len(paragraphs), 2)
self.assertIn(para1, paragraphs)
self.assertIn(para2, paragraphs)
# Test finding headings
headings = self.doc.find_blocks_by_type(BlockType.HEADING)
self.assertEqual(len(headings), 2)
self.assertIn(heading1, headings)
self.assertIn(heading2, headings)
def test_find_headings(self):
"""Test finding heading blocks specifically."""
# Create mixed blocks
para = Paragraph()
h1 = Heading(HeadingLevel.H1)
h2 = Heading(HeadingLevel.H2)
# Add words to headings for title extraction
word1 = Word("Chapter", self.font)
word2 = Word("One", self.font)
h1.add_word(word1)
h1.add_word(word2)
word3 = Word("Section", self.font)
h2.add_word(word3)
self.doc.add_block(para)
self.doc.add_block(h1)
self.doc.add_block(h2)
# Test finding headings
headings = self.doc.find_headings()
self.assertEqual(len(headings), 2)
self.assertIn(h1, headings)
self.assertIn(h2, headings)
self.assertNotIn(para, headings)
def test_generate_table_of_contents(self):
"""Test table of contents generation."""
# Create headings with content
h1 = Heading(HeadingLevel.H1)
h2 = Heading(HeadingLevel.H2)
h3 = Heading(HeadingLevel.H3)
# Add words to headings
h1.add_word(Word("Introduction", self.font))
h2.add_word(Word("Getting", self.font))
h2.add_word(Word("Started", self.font))
h3.add_word(Word("Installation", self.font))
self.doc.add_block(h1)
self.doc.add_block(h2)
self.doc.add_block(h3)
# Generate TOC
toc = self.doc.generate_table_of_contents()
# Test TOC structure
self.assertEqual(len(toc), 3)
# Test first entry
level, title, block = toc[0]
self.assertEqual(level, 1) # H1
self.assertEqual(title, "Introduction")
self.assertEqual(block, h1)
# Test second entry
level, title, block = toc[1]
self.assertEqual(level, 2) # H2
self.assertEqual(title, "Getting Started")
self.assertEqual(block, h2)
# Test third entry
level, title, block = toc[2]
self.assertEqual(level, 3) # H3
@ -248,127 +254,127 @@ class TestDocument(unittest.TestCase):
class TestChapter(unittest.TestCase):
"""Test cases for Chapter class."""
def setUp(self):
"""Set up test fixtures."""
self.chapter = Chapter("Test Chapter", 1)
def test_chapter_creation(self):
"""Test chapter creation."""
self.assertEqual(self.chapter.title, "Test Chapter")
self.assertEqual(self.chapter.level, 1)
self.assertEqual(len(self.chapter.blocks), 0)
def test_chapter_creation_minimal(self):
"""Test chapter creation with minimal parameters."""
chapter = Chapter()
self.assertIsNone(chapter.title)
self.assertEqual(chapter.level, 1)
def test_title_property(self):
"""Test title property getter and setter."""
# Test setter
self.chapter.title = "New Chapter Title"
self.assertEqual(self.chapter.title, "New Chapter Title")
# Test setting to None
self.chapter.title = None
self.assertIsNone(self.chapter.title)
def test_level_property(self):
"""Test level property."""
self.assertEqual(self.chapter.level, 1)
# Level should be read-only (no setter test)
# This is by design based on the class definition
def test_block_management(self):
"""Test adding blocks to chapter."""
para1 = Paragraph()
para2 = Paragraph()
heading = Heading(HeadingLevel.H2)
# Add blocks
self.chapter.add_block(para1)
self.chapter.add_block(heading)
self.chapter.add_block(para2)
# Test blocks list
self.assertEqual(len(self.chapter.blocks), 3)
self.assertEqual(self.chapter.blocks[0], para1)
self.assertEqual(self.chapter.blocks[1], heading)
self.assertEqual(self.chapter.blocks[2], para2)
def test_metadata_management(self):
"""Test chapter metadata."""
# Set metadata
self.chapter.set_metadata("author", "Jane Doe")
self.chapter.set_metadata("word_count", 1500)
self.chapter.set_metadata("tags", ["intro", "basics"])
# Test retrieval
self.assertEqual(self.chapter.get_metadata("author"), "Jane Doe")
self.assertEqual(self.chapter.get_metadata("word_count"), 1500)
self.assertEqual(self.chapter.get_metadata("tags"), ["intro", "basics"])
# Test non-existent metadata
self.assertIsNone(self.chapter.get_metadata("nonexistent"))
class TestBook(unittest.TestCase):
"""Test cases for Book class."""
def setUp(self):
"""Set up test fixtures."""
self.book = Book("Test Book", "Author Name", "en-US")
def test_book_creation(self):
"""Test book creation with all parameters."""
self.assertEqual(self.book.get_title(), "Test Book")
self.assertEqual(self.book.get_author(), "Author Name")
self.assertEqual(self.book.get_metadata(MetadataType.LANGUAGE), "en-US")
self.assertEqual(len(self.book.chapters), 0)
def test_book_creation_minimal(self):
"""Test book creation with minimal parameters."""
book = Book()
self.assertIsNone(book.get_title())
self.assertIsNone(book.get_author())
self.assertEqual(book.get_metadata(MetadataType.LANGUAGE), "en-US")
def test_book_creation_partial(self):
"""Test book creation with partial parameters."""
book = Book(title="Just Title")
self.assertEqual(book.get_title(), "Just Title")
self.assertIsNone(book.get_author())
def test_author_convenience_methods(self):
"""Test author getter and setter convenience methods."""
# Test setting author
self.book.set_author("New Author")
self.assertEqual(self.book.get_author(), "New Author")
# Test that it's also in metadata
self.assertEqual(self.book.get_metadata(MetadataType.AUTHOR), "New Author")
def test_chapter_management(self):
"""Test adding and managing chapters."""
# Create chapters
ch1 = Chapter("Introduction", 1)
ch2 = Chapter("Getting Started", 1)
ch3 = Chapter("Advanced Topics", 1)
# Add chapters
self.book.add_chapter(ch1)
self.book.add_chapter(ch2)
self.book.add_chapter(ch3)
# Test chapters list
self.assertEqual(len(self.book.chapters), 3)
self.assertEqual(self.book.chapters[0], ch1)
self.assertEqual(self.book.chapters[1], ch2)
self.assertEqual(self.book.chapters[2], ch3)
def test_create_chapter(self):
"""Test creating chapters through the book."""
# Create chapter with title and level
@ -377,13 +383,13 @@ class TestBook(unittest.TestCase):
self.assertEqual(ch1.level, 1)
self.assertEqual(len(self.book.chapters), 1)
self.assertEqual(self.book.chapters[0], ch1)
# Create chapter with minimal parameters
ch2 = self.book.create_chapter()
self.assertIsNone(ch2.title)
self.assertEqual(ch2.level, 1)
self.assertEqual(len(self.book.chapters), 2)
def test_generate_book_toc(self):
"""Test table of contents generation for book."""
# Create chapters with different levels
@ -392,20 +398,20 @@ class TestBook(unittest.TestCase):
ch3 = Chapter("Basic Concepts", 2)
ch4 = Chapter("Advanced Topics", 1)
ch5 = Chapter("Best Practices", 2)
# Add chapters to book
self.book.add_chapter(ch1)
self.book.add_chapter(ch2)
self.book.add_chapter(ch3)
self.book.add_chapter(ch4)
self.book.add_chapter(ch5)
# Generate TOC
toc = self.book.generate_table_of_contents()
# Test TOC structure
self.assertEqual(len(toc), 5)
# Test entries
expected = [
(1, "Introduction", ch1),
@ -414,38 +420,38 @@ class TestBook(unittest.TestCase):
(1, "Advanced Topics", ch4),
(2, "Best Practices", ch5)
]
for i, (exp_level, exp_title, exp_chapter) in enumerate(expected):
level, title, chapter = toc[i]
self.assertEqual(level, exp_level)
self.assertEqual(title, exp_title)
self.assertEqual(chapter, exp_chapter)
def test_generate_book_toc_with_untitled_chapters(self):
"""Test TOC generation with chapters that have no title."""
# Create chapters, some without titles
ch1 = Chapter("Introduction", 1)
ch2 = Chapter(None, 1) # No title
ch3 = Chapter("Conclusion", 1)
self.book.add_chapter(ch1)
self.book.add_chapter(ch2)
self.book.add_chapter(ch3)
# Generate TOC
toc = self.book.generate_table_of_contents()
# Should only include chapters with titles
self.assertEqual(len(toc), 2)
level, title, chapter = toc[0]
self.assertEqual(title, "Introduction")
self.assertEqual(chapter, ch1)
level, title, chapter = toc[1]
self.assertEqual(title, "Conclusion")
self.assertEqual(chapter, ch3)
def test_book_inherits_document_features(self):
"""Test that Book inherits all Document functionality."""
# Test that book can use all document methods
@ -453,11 +459,14 @@ class TestBook(unittest.TestCase):
para = Paragraph()
self.book.add_block(para)
self.assertEqual(len(self.book.blocks), 1)
# Test metadata
self.book.set_metadata(MetadataType.PUBLISHER, "Test Publisher")
self.assertEqual(self.book.get_metadata(MetadataType.PUBLISHER), "Test Publisher")
self.assertEqual(
self.book.get_metadata(
MetadataType.PUBLISHER),
"Test Publisher")
# Test anchors
heading = Heading(HeadingLevel.H1)
self.book.add_anchor("preface", heading)
@ -466,11 +475,11 @@ class TestBook(unittest.TestCase):
class TestDocumentFontRegistry(unittest.TestCase):
"""Test cases for Document font registry functionality."""
def setUp(self):
"""Set up test fixtures."""
self.doc = Document("Test Document", "en-US")
def test_get_or_create_font_creates_new_font(self):
"""Test that get_or_create_font creates a new font when none exists."""
font = self.doc.get_or_create_font(
@ -478,14 +487,14 @@ class TestDocumentFontRegistry(unittest.TestCase):
colour=(255, 0, 0),
weight=FontWeight.BOLD
)
self.assertEqual(font.font_size, 14)
self.assertEqual(font.colour, (255, 0, 0))
self.assertEqual(font.weight, FontWeight.BOLD)
# Check that font is stored in registry
self.assertEqual(len(self.doc._fonts), 1)
def test_get_or_create_font_reuses_existing_font(self):
"""Test that get_or_create_font reuses existing fonts."""
# Create first font
@ -494,20 +503,20 @@ class TestDocumentFontRegistry(unittest.TestCase):
colour=(255, 0, 0),
weight=FontWeight.BOLD
)
# Create second font with same properties
font2 = self.doc.get_or_create_font(
font_size=14,
colour=(255, 0, 0),
weight=FontWeight.BOLD
)
# Should return the same font object
self.assertIs(font1, font2)
# Should only have one font in registry
self.assertEqual(len(self.doc._fonts), 1)
def test_get_or_create_font_creates_different_fonts(self):
"""Test that different font properties create different fonts."""
# Create first font
@ -516,28 +525,28 @@ class TestDocumentFontRegistry(unittest.TestCase):
colour=(255, 0, 0),
weight=FontWeight.BOLD
)
# Create font with different size
font2 = self.doc.get_or_create_font(
font_size=16,
colour=(255, 0, 0),
weight=FontWeight.BOLD
)
# Create font with different color
font3 = self.doc.get_or_create_font(
font_size=14,
colour=(0, 255, 0),
weight=FontWeight.BOLD
)
# Create font with different weight
font4 = self.doc.get_or_create_font(
font_size=14,
colour=(255, 0, 0),
weight=FontWeight.NORMAL
)
# All should be different objects
self.assertIsNot(font1, font2)
self.assertIsNot(font1, font3)
@ -545,10 +554,10 @@ class TestDocumentFontRegistry(unittest.TestCase):
self.assertIsNot(font2, font3)
self.assertIsNot(font2, font4)
self.assertIsNot(font3, font4)
# Should have four fonts in registry
self.assertEqual(len(self.doc._fonts), 4)
def test_get_or_create_font_with_all_parameters(self):
"""Test get_or_create_font with all parameters."""
font = self.doc.get_or_create_font(
@ -562,7 +571,7 @@ class TestDocumentFontRegistry(unittest.TestCase):
language="fr_FR",
min_hyphenation_width=80
)
self.assertEqual(font._font_path, "path/to/font.ttf")
self.assertEqual(font.font_size, 18)
self.assertEqual(font.colour, (128, 64, 192))
@ -572,11 +581,11 @@ class TestDocumentFontRegistry(unittest.TestCase):
self.assertEqual(font.background, (255, 255, 255, 128))
self.assertEqual(font.language, "fr_FR")
self.assertEqual(font.min_hyphenation_width, 80)
def test_get_or_create_font_with_defaults(self):
"""Test get_or_create_font with default values."""
font = self.doc.get_or_create_font()
# Should create font with default values
self.assertIsNotNone(font)
self.assertEqual(font.font_size, 16) # Default font size
@ -588,12 +597,12 @@ class TestDocumentFontRegistry(unittest.TestCase):
class TestChapterFontRegistry(unittest.TestCase):
"""Test cases for Chapter font registry functionality."""
def setUp(self):
"""Set up test fixtures."""
self.doc = Document("Test Document", "en-US")
self.chapter = Chapter("Test Chapter", 1, parent=self.doc)
def test_chapter_uses_parent_font_registry(self):
"""Test that chapter uses parent document's font registry."""
# Create font through chapter - should delegate to parent
@ -602,52 +611,52 @@ class TestChapterFontRegistry(unittest.TestCase):
colour=(255, 0, 0),
weight=FontWeight.BOLD
)
# Create same font through document - should return same object
font2 = self.doc.get_or_create_font(
font_size=14,
colour=(255, 0, 0),
weight=FontWeight.BOLD
)
# Should be the same font object
self.assertIs(font1, font2)
# Should be stored in document's registry, not chapter's
self.assertEqual(len(self.doc._fonts), 1)
self.assertEqual(len(self.chapter._fonts), 0)
def test_chapter_without_parent_manages_own_fonts(self):
"""Test that chapter without parent manages its own fonts."""
# Create chapter without parent
standalone_chapter = Chapter("Standalone Chapter", 1)
# Create font through chapter
font1 = standalone_chapter.get_or_create_font(
font_size=14,
colour=(255, 0, 0),
weight=FontWeight.BOLD
)
# Create same font again - should reuse
font2 = standalone_chapter.get_or_create_font(
font_size=14,
colour=(255, 0, 0),
weight=FontWeight.BOLD
)
# Should be the same font object
self.assertIs(font1, font2)
# Should be stored in chapter's own registry
self.assertEqual(len(standalone_chapter._fonts), 1)
def test_chapter_parent_assignment(self):
"""Test that chapter parent assignment works correctly."""
# Create chapter with parent
chapter_with_parent = Chapter("Chapter with Parent", 1, parent=self.doc)
self.assertEqual(chapter_with_parent._parent, self.doc)
# Create chapter without parent
chapter_without_parent = Chapter("Chapter without Parent", 1)
self.assertIsNone(chapter_without_parent._parent)
@ -655,11 +664,11 @@ class TestChapterFontRegistry(unittest.TestCase):
class TestBookFontRegistry(unittest.TestCase):
"""Test cases for Book font registry functionality."""
def setUp(self):
"""Set up test fixtures."""
self.book = Book("Test Book", "Author Name", "en-US")
def test_book_inherits_document_font_registry(self):
"""Test that Book inherits Document's font registry functionality."""
# Create font through book
@ -668,17 +677,17 @@ class TestBookFontRegistry(unittest.TestCase):
colour=(255, 0, 0),
weight=FontWeight.BOLD
)
# Create same font again - should reuse
font2 = self.book.get_or_create_font(
font_size=14,
colour=(255, 0, 0),
weight=FontWeight.BOLD
)
# Should be the same font object
self.assertIs(font1, font2)
# Should have one font in registry
self.assertEqual(len(self.book._fonts), 1)

View File

@ -6,7 +6,7 @@ interactive functionality and user interface elements.
"""
import unittest
from unittest.mock import Mock, patch
from unittest.mock import Mock
from pyWebLayout.abstract.functional import (
Link, LinkType, Button, Form, FormField, FormFieldType
)
@ -14,14 +14,14 @@ from pyWebLayout.abstract.functional import (
class TestLinkType(unittest.TestCase):
"""Test cases for LinkType enum."""
def test_link_types(self):
"""Test that all expected link types exist."""
expected_types = ['INTERNAL', 'EXTERNAL', 'API', 'FUNCTION']
for type_name in expected_types:
self.assertTrue(hasattr(LinkType, type_name))
# Test specific values
self.assertEqual(LinkType.INTERNAL.value, 1)
self.assertEqual(LinkType.EXTERNAL.value, 2)
@ -31,21 +31,21 @@ class TestLinkType(unittest.TestCase):
class TestLink(unittest.TestCase):
"""Test cases for Link class."""
def setUp(self):
"""Set up test fixtures."""
self.mock_callback = Mock(return_value="callback_result")
def test_link_creation_minimal(self):
"""Test link creation with minimal parameters."""
link = Link("test-location")
self.assertEqual(link.location, "test-location")
self.assertEqual(link.link_type, LinkType.INTERNAL) # Default
self.assertEqual(link.params, {})
self.assertIsNone(link.title)
self.assertIsNone(link._callback)
def test_link_creation_full(self):
"""Test link creation with all parameters."""
params = {"param1": "value1", "param2": "value2"}
@ -56,29 +56,29 @@ class TestLink(unittest.TestCase):
params=params,
title="Example Link"
)
self.assertEqual(link.location, "https://example.com")
self.assertEqual(link.link_type, LinkType.EXTERNAL)
self.assertEqual(link.params, params)
self.assertEqual(link.title, "Example Link")
self.assertEqual(link._callback, self.mock_callback)
def test_internal_link_execution(self):
"""Test executing internal links."""
link = Link("#section1", LinkType.INTERNAL)
result = link.execute()
# Internal links should return the location
self.assertEqual(result, "#section1")
def test_external_link_execution(self):
"""Test executing external links."""
link = Link("https://example.com", LinkType.EXTERNAL)
result = link.execute()
# External links should return the location
self.assertEqual(result, "https://example.com")
def test_api_link_execution(self):
"""Test executing API links with callback."""
params = {"action": "save", "id": 123}
@ -92,9 +92,10 @@ class TestLink(unittest.TestCase):
result = link.execute()
# Should call callback with location, point (None when not provided), and params
self.mock_callback.assert_called_once_with("/api/save", None, action="save", id=123)
self.mock_callback.assert_called_once_with(
"/api/save", None, action="save", id=123)
self.assertEqual(result, "callback_result")
def test_function_link_execution(self):
"""Test executing function links with callback."""
params = {"data": "test"}
@ -110,23 +111,23 @@ class TestLink(unittest.TestCase):
# Should call callback with location, point (None when not provided), and params
self.mock_callback.assert_called_once_with("save_document", None, data="test")
self.assertEqual(result, "callback_result")
def test_api_link_without_callback(self):
"""Test API link without callback returns location."""
link = Link("/api/endpoint", LinkType.API)
result = link.execute()
# Without callback, should return location
self.assertEqual(result, "/api/endpoint")
def test_function_link_without_callback(self):
"""Test function link without callback returns location."""
link = Link("function_name", LinkType.FUNCTION)
result = link.execute()
# Without callback, should return location
self.assertEqual(result, "function_name")
def test_link_properties(self):
"""Test link property access."""
params = {"key": "value"}
@ -136,7 +137,7 @@ class TestLink(unittest.TestCase):
params=params,
title="Test Title"
)
# Test all property getters
self.assertEqual(link.location, "test")
self.assertEqual(link.link_type, LinkType.API)
@ -146,20 +147,20 @@ class TestLink(unittest.TestCase):
class TestButton(unittest.TestCase):
"""Test cases for Button class."""
def setUp(self):
"""Set up test fixtures."""
self.mock_callback = Mock(return_value="button_clicked")
def test_button_creation_minimal(self):
"""Test button creation with minimal parameters."""
button = Button("Click Me", self.mock_callback)
self.assertEqual(button.label, "Click Me")
self.assertEqual(button._callback, self.mock_callback)
self.assertEqual(button.params, {})
self.assertTrue(button.enabled)
def test_button_creation_full(self):
"""Test button creation with all parameters."""
params = {"action": "submit", "form_id": "test_form"}
@ -169,37 +170,37 @@ class TestButton(unittest.TestCase):
params=params,
enabled=False
)
self.assertEqual(button.label, "Submit")
self.assertEqual(button._callback, self.mock_callback)
self.assertEqual(button.params, params)
self.assertFalse(button.enabled)
def test_button_label_property(self):
"""Test button label getter and setter."""
button = Button("Original", self.mock_callback)
# Test getter
self.assertEqual(button.label, "Original")
# Test setter
button.label = "New Label"
self.assertEqual(button.label, "New Label")
def test_button_enabled_property(self):
"""Test button enabled getter and setter."""
button = Button("Test", self.mock_callback, enabled=True)
# Test initial state
self.assertTrue(button.enabled)
# Test setter
button.enabled = False
self.assertFalse(button.enabled)
button.enabled = True
self.assertTrue(button.enabled)
def test_button_execute_enabled(self):
"""Test executing enabled button."""
params = {"data": "test_data"}
@ -210,40 +211,40 @@ class TestButton(unittest.TestCase):
# Should call callback with point (None when not provided) and params
self.mock_callback.assert_called_once_with(None, data="test_data")
self.assertEqual(result, "button_clicked")
def test_button_execute_disabled(self):
"""Test executing disabled button."""
button = Button("Test", self.mock_callback, enabled=False)
result = button.execute()
# Should not call callback and return None
self.mock_callback.assert_not_called()
self.assertIsNone(result)
def test_button_execute_no_callback(self):
"""Test executing button without callback."""
button = Button("Test", None, enabled=True)
result = button.execute()
# Should return None
self.assertIsNone(result)
class TestFormFieldType(unittest.TestCase):
"""Test cases for FormFieldType enum."""
def test_form_field_types(self):
"""Test that all expected form field types exist."""
expected_types = [
'TEXT', 'PASSWORD', 'CHECKBOX', 'RADIO', 'SELECT', 'TEXTAREA',
'NUMBER', 'DATE', 'TIME', 'EMAIL', 'URL', 'COLOR', 'RANGE', 'HIDDEN'
]
for type_name in expected_types:
self.assertTrue(hasattr(FormFieldType, type_name))
# Test some specific values
self.assertEqual(FormFieldType.TEXT.value, 1)
self.assertEqual(FormFieldType.PASSWORD.value, 2)
@ -252,11 +253,11 @@ class TestFormFieldType(unittest.TestCase):
class TestFormField(unittest.TestCase):
"""Test cases for FormField class."""
def test_form_field_creation_minimal(self):
"""Test form field creation with minimal parameters."""
field = FormField("username", FormFieldType.TEXT)
self.assertEqual(field.name, "username")
self.assertEqual(field.field_type, FormFieldType.TEXT)
self.assertEqual(field.label, "username") # Default to name
@ -264,7 +265,7 @@ class TestFormField(unittest.TestCase):
self.assertFalse(field.required)
self.assertEqual(field.options, [])
self.assertIsNone(field.form)
def test_form_field_creation_full(self):
"""Test form field creation with all parameters."""
options = [("value1", "Label 1"), ("value2", "Label 2")]
@ -276,37 +277,37 @@ class TestFormField(unittest.TestCase):
required=True,
options=options
)
self.assertEqual(field.name, "country")
self.assertEqual(field.field_type, FormFieldType.SELECT)
self.assertEqual(field.label, "Country")
self.assertEqual(field.value, "value1")
self.assertTrue(field.required)
self.assertEqual(field.options, options)
def test_form_field_value_property(self):
"""Test form field value getter and setter."""
field = FormField("test", FormFieldType.TEXT, value="initial")
# Test getter
self.assertEqual(field.value, "initial")
# Test setter
field.value = "new_value"
self.assertEqual(field.value, "new_value")
def test_form_field_form_property(self):
"""Test form field form getter and setter."""
field = FormField("test", FormFieldType.TEXT)
mock_form = Mock()
# Initial state
self.assertIsNone(field.form)
# Test setter
field.form = mock_form
self.assertEqual(field.form, mock_form)
def test_form_field_properties(self):
"""Test all form field property getters."""
options = [("opt1", "Option 1")]
@ -318,7 +319,7 @@ class TestFormField(unittest.TestCase):
required=True,
options=options
)
# Test all getters
self.assertEqual(field.name, "test_field")
self.assertEqual(field.field_type, FormFieldType.CHECKBOX)
@ -330,20 +331,20 @@ class TestFormField(unittest.TestCase):
class TestForm(unittest.TestCase):
"""Test cases for Form class."""
def setUp(self):
"""Set up test fixtures."""
self.mock_callback = Mock(return_value="form_submitted")
def test_form_creation_minimal(self):
"""Test form creation with minimal parameters."""
form = Form("test_form")
self.assertEqual(form.form_id, "test_form")
self.assertIsNone(form.action)
self.assertIsNone(form._callback)
self.assertEqual(len(form._fields), 0)
def test_form_creation_full(self):
"""Test form creation with all parameters."""
form = Form(
@ -351,145 +352,144 @@ class TestForm(unittest.TestCase):
action="/submit",
callback=self.mock_callback
)
self.assertEqual(form.form_id, "contact_form")
self.assertEqual(form.action, "/submit")
self.assertEqual(form._callback, self.mock_callback)
def test_form_field_management(self):
"""Test adding and retrieving form fields."""
form = Form("test_form")
# Create fields
field1 = FormField("username", FormFieldType.TEXT, value="john")
field2 = FormField("password", FormFieldType.PASSWORD, value="secret")
field3 = FormField("email", FormFieldType.EMAIL, value="john@example.com")
# Add fields
form.add_field(field1)
form.add_field(field2)
form.add_field(field3)
# Test that fields are stored correctly
self.assertEqual(len(form._fields), 3)
# Test field retrieval
self.assertEqual(form.get_field("username"), field1)
self.assertEqual(form.get_field("password"), field2)
self.assertEqual(form.get_field("email"), field3)
self.assertIsNone(form.get_field("nonexistent"))
# Test that fields have form reference
self.assertEqual(field1.form, form)
self.assertEqual(field2.form, form)
self.assertEqual(field3.form, form)
def test_form_get_values(self):
"""Test getting form values."""
form = Form("test_form")
# Add fields with values
form.add_field(FormField("name", FormFieldType.TEXT, value="John Doe"))
form.add_field(FormField("age", FormFieldType.NUMBER, value=30))
form.add_field(FormField("subscribe", FormFieldType.CHECKBOX, value=True))
# Get values
values = form.get_values()
expected = {
"name": "John Doe",
"age": 30,
"subscribe": True
}
self.assertEqual(values, expected)
def test_form_get_values_empty(self):
"""Test getting values from empty form."""
form = Form("empty_form")
values = form.get_values()
self.assertEqual(values, {})
def test_form_execute_with_callback(self):
"""Test executing form with callback."""
form = Form("test_form", callback=self.mock_callback)
# Add some fields
form.add_field(FormField("field1", FormFieldType.TEXT, value="value1"))
form.add_field(FormField("field2", FormFieldType.TEXT, value="value2"))
result = form.execute()
# Should call callback with form_id and values
expected_values = {"field1": "value1", "field2": "value2"}
self.mock_callback.assert_called_once_with("test_form", expected_values)
self.assertEqual(result, "form_submitted")
def test_form_execute_without_callback(self):
"""Test executing form without callback."""
form = Form("test_form")
# Add a field
form.add_field(FormField("test", FormFieldType.TEXT, value="test_value"))
result = form.execute()
# Should return the form values
expected = {"test": "test_value"}
self.assertEqual(result, expected)
def test_form_properties(self):
"""Test form property getters."""
form = Form("test_form", action="/submit")
self.assertEqual(form.form_id, "test_form")
self.assertEqual(form.action, "/submit")
class TestFormIntegration(unittest.TestCase):
"""Integration tests for form functionality."""
def test_complete_form_workflow(self):
"""Test a complete form creation and submission workflow."""
# Create form
form = Form("registration_form", action="/register")
# Add various field types
form.add_field(FormField(
"username", FormFieldType.TEXT,
"username", FormFieldType.TEXT,
label="Username", required=True, value="testuser"
))
form.add_field(FormField(
"password", FormFieldType.PASSWORD,
label="Password", required=True, value="secret123"
))
form.add_field(FormField(
"email", FormFieldType.EMAIL,
label="Email", required=True, value="test@example.com"
))
form.add_field(FormField(
"country", FormFieldType.SELECT,
label="Country", value="US",
options=[("US", "United States"), ("CA", "Canada"), ("UK", "United Kingdom")]
))
form.add_field(
FormField(
"country", FormFieldType.SELECT, label="Country", value="US", options=[
("US", "United States"), ("CA", "Canada"), ("UK", "United Kingdom")]))
form.add_field(FormField(
"newsletter", FormFieldType.CHECKBOX,
label="Subscribe to newsletter", value=True
))
# Test form state
self.assertEqual(len(form._fields), 5)
# Test individual field access
username_field = form.get_field("username")
self.assertEqual(username_field.value, "testuser")
self.assertTrue(username_field.required)
# Test getting all values
values = form.get_values()
expected = {
@ -500,26 +500,26 @@ class TestFormIntegration(unittest.TestCase):
"newsletter": True
}
self.assertEqual(values, expected)
# Test form submission
result = form.execute()
self.assertEqual(result, expected)
def test_form_field_modification(self):
"""Test modifying form fields after creation."""
form = Form("test_form")
# Add field
field = FormField("test", FormFieldType.TEXT, value="initial")
form.add_field(field)
# Modify field value
field.value = "modified"
# Test that form reflects the change
values = form.get_values()
self.assertEqual(values["test"], "modified")
# Test getting the modified field
retrieved_field = form.get_field("test")
self.assertEqual(retrieved_field.value, "modified")

File diff suppressed because it is too large Load Diff

View File

@ -34,7 +34,9 @@ class TestChapterFontRegistry(FontRegistryTestMixin, unittest.TestCase):
return Chapter("Test Chapter", level=1)
class TestChapterFontRegistryParentDelegation(FontRegistryParentDelegationTestMixin, unittest.TestCase):
class TestChapterFontRegistryParentDelegation(
FontRegistryParentDelegationTestMixin,
unittest.TestCase):
"""Test FontRegistry parent delegation for Chapter - simplified with mixin."""
def create_parent(self):

View File

@ -3,20 +3,20 @@ Unit tests for LinkedWord and LinkedImage classes.
"""
import unittest
from pyWebLayout.abstract.inline import Word, LinkedWord
from pyWebLayout.abstract.block import Image, LinkedImage
from pyWebLayout.abstract.inline import LinkedWord
from pyWebLayout.abstract.block import LinkedImage
from pyWebLayout.abstract.functional import LinkType
from pyWebLayout.style import Font
class TestLinkedWord(unittest.TestCase):
"""Test cases for LinkedWord class."""
def setUp(self):
"""Set up test fixtures."""
self.font = Font(font_size=16)
self.location = "https://example.com"
def test_linked_word_creation(self):
"""Test creating a LinkedWord."""
linked_word = LinkedWord(
@ -25,12 +25,12 @@ class TestLinkedWord(unittest.TestCase):
location=self.location,
link_type=LinkType.EXTERNAL
)
self.assertEqual(linked_word.text, "example")
self.assertEqual(linked_word.location, self.location)
self.assertEqual(linked_word.link_type, LinkType.EXTERNAL)
self.assertIsNone(linked_word.link_callback)
def test_linked_word_inherits_from_word(self):
"""Test that LinkedWord inherits Word properties."""
linked_word = LinkedWord(
@ -38,21 +38,21 @@ class TestLinkedWord(unittest.TestCase):
style=self.font,
location=self.location
)
# Should have Word properties
self.assertEqual(linked_word.text, "test")
self.assertEqual(linked_word.style, self.font)
self.assertIsNone(linked_word.previous)
self.assertIsNone(linked_word.next)
def test_linked_word_with_callback(self):
"""Test LinkedWord with a callback function."""
callback_called = []
def test_callback(location, **params):
callback_called.append((location, params))
return "navigated"
linked_word = LinkedWord(
text="click",
style=self.font,
@ -61,15 +61,15 @@ class TestLinkedWord(unittest.TestCase):
callback=test_callback,
params={"source": "test"}
)
result = linked_word.execute_link()
linked_word.execute_link()
self.assertEqual(len(callback_called), 1)
self.assertEqual(callback_called[0][0], self.location)
self.assertIn("text", callback_called[0][1])
self.assertEqual(callback_called[0][1]["text"], "click")
self.assertEqual(callback_called[0][1]["source"], "test")
def test_linked_word_execute_external_link(self):
"""Test executing an external link returns the location."""
linked_word = LinkedWord(
@ -78,10 +78,10 @@ class TestLinkedWord(unittest.TestCase):
location=self.location,
link_type=LinkType.EXTERNAL
)
result = linked_word.execute_link()
self.assertEqual(result, self.location)
def test_linked_word_with_title(self):
"""Test LinkedWord with title/tooltip."""
linked_word = LinkedWord(
@ -90,9 +90,9 @@ class TestLinkedWord(unittest.TestCase):
location=self.location,
title="Click to visit example.com"
)
self.assertEqual(linked_word.link_title, "Click to visit example.com")
def test_linked_word_chain(self):
"""Test chaining multiple LinkedWords."""
word1 = LinkedWord(
@ -100,14 +100,14 @@ class TestLinkedWord(unittest.TestCase):
style=self.font,
location=self.location
)
word2 = LinkedWord(
text="here",
style=self.font,
location=self.location,
previous=word1
)
# Check chain
self.assertEqual(word1.next, word2)
self.assertEqual(word2.previous, word1)
@ -115,13 +115,13 @@ class TestLinkedWord(unittest.TestCase):
class TestLinkedImage(unittest.TestCase):
"""Test cases for LinkedImage class."""
def setUp(self):
"""Set up test fixtures."""
self.source = "logo.png"
self.alt_text = "Company Logo"
self.location = "https://example.com/home"
def test_linked_image_creation(self):
"""Test creating a LinkedImage."""
linked_image = LinkedImage(
@ -132,14 +132,14 @@ class TestLinkedImage(unittest.TestCase):
height=50,
link_type=LinkType.EXTERNAL
)
self.assertEqual(linked_image.source, self.source)
self.assertEqual(linked_image.alt_text, self.alt_text)
self.assertEqual(linked_image.location, self.location)
self.assertEqual(linked_image.width, 100)
self.assertEqual(linked_image.height, 50)
self.assertEqual(linked_image.link_type, LinkType.EXTERNAL)
def test_linked_image_inherits_from_image(self):
"""Test that LinkedImage inherits Image properties."""
linked_image = LinkedImage(
@ -147,20 +147,20 @@ class TestLinkedImage(unittest.TestCase):
alt_text=self.alt_text,
location=self.location
)
# Should have Image properties and methods
self.assertEqual(linked_image.source, self.source)
self.assertEqual(linked_image.alt_text, self.alt_text)
self.assertIsNotNone(linked_image.get_dimensions)
def test_linked_image_with_callback(self):
"""Test LinkedImage with a callback function."""
callback_called = []
def image_callback(location, **params):
callback_called.append((location, params))
return "image_clicked"
linked_image = LinkedImage(
source=self.source,
alt_text=self.alt_text,
@ -168,15 +168,15 @@ class TestLinkedImage(unittest.TestCase):
link_type=LinkType.FUNCTION,
callback=image_callback
)
result = linked_image.execute_link()
linked_image.execute_link()
self.assertEqual(len(callback_called), 1)
self.assertEqual(callback_called[0][0], self.location)
self.assertIn("alt_text", callback_called[0][1])
self.assertEqual(callback_called[0][1]["alt_text"], self.alt_text)
self.assertIn("source", callback_called[0][1])
def test_linked_image_execute_internal_link(self):
"""Test executing an internal link returns the location."""
linked_image = LinkedImage(
@ -185,7 +185,7 @@ class TestLinkedImage(unittest.TestCase):
location="#section2",
link_type=LinkType.INTERNAL
)
result = linked_image.execute_link()
self.assertEqual(result, "#section2")

View File

@ -5,22 +5,24 @@ Tests the various alignment handlers (Left, Center, Right, Justify) and their in
"""
import unittest
import numpy as np
from unittest.mock import Mock
from pyWebLayout.concrete.text import Line, Text, LeftAlignmentHandler, CenterRightAlignmentHandler, JustifyAlignmentHandler
from pyWebLayout.concrete.text import (
Line, Text, LeftAlignmentHandler, CenterRightAlignmentHandler, JustifyAlignmentHandler
)
from pyWebLayout.style import Alignment
from pyWebLayout.style import Font
from pyWebLayout.abstract import Word
from PIL import Image, ImageFont, ImageDraw
from PIL import Image, ImageDraw
class TestAlignmentHandlers(unittest.TestCase):
"""Test cases for the alignment handler system"""
def setUp(self):
"""Set up test fixtures"""
self.font = Font()
self.test_words = [Word(text, self.font) for text in ["This", "is", "a", "test", "sentence"]]
self.test_words = [Word(text, self.font)
for text in ["This", "is", "a", "test", "sentence"]]
self.line_width = 300
self.line_height = 30
self.spacing = (5, 20) # min_spacing, max_spacing
@ -29,46 +31,78 @@ class TestAlignmentHandlers(unittest.TestCase):
# Create a real PIL image (canvas) for testing
self.canvas = Image.new('RGB', (800, 600), color='white')
# Create a real ImageDraw object
self.draw = ImageDraw.Draw(self.canvas)
# Create a real Font object
self.style = Font()
def test_left_alignment_handler_assignment(self):
"""Test that Line correctly assigns LeftAlignmentHandler for LEFT alignment"""
left_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.LEFT)
left_line = Line(
self.spacing,
self.origin,
self.size,
self.draw,
font=self.style,
halign=Alignment.LEFT)
self.assertIsInstance(left_line._alignment_handler, LeftAlignmentHandler)
def test_center_alignment_handler_assignment(self):
"""Test that Line correctly assigns CenterRightAlignmentHandler for CENTER alignment"""
center_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.CENTER)
self.assertIsInstance(center_line._alignment_handler, CenterRightAlignmentHandler)
center_line = Line(
self.spacing,
self.origin,
self.size,
self.draw,
font=self.style,
halign=Alignment.CENTER)
self.assertIsInstance(
center_line._alignment_handler,
CenterRightAlignmentHandler)
# Check that it's configured for CENTER alignment
self.assertEqual(center_line._alignment_handler._alignment, Alignment.CENTER)
def test_right_alignment_handler_assignment(self):
"""Test that Line correctly assigns CenterRightAlignmentHandler for RIGHT alignment"""
right_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.RIGHT)
self.assertIsInstance(right_line._alignment_handler, CenterRightAlignmentHandler)
right_line = Line(
self.spacing,
self.origin,
self.size,
self.draw,
font=self.style,
halign=Alignment.RIGHT)
self.assertIsInstance(
right_line._alignment_handler,
CenterRightAlignmentHandler)
# Check that it's configured for RIGHT alignment
self.assertEqual(right_line._alignment_handler._alignment, Alignment.RIGHT)
def test_justify_alignment_handler_assignment(self):
"""Test that Line correctly assigns JustifyAlignmentHandler for JUSTIFY alignment"""
justify_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.JUSTIFY)
justify_line = Line(
self.spacing,
self.origin,
self.size,
self.draw,
font=self.style,
halign=Alignment.JUSTIFY)
self.assertIsInstance(justify_line._alignment_handler, JustifyAlignmentHandler)
def test_left_alignment_word_addition(self):
"""Test adding words to a left-aligned line"""
left_line = Line(self.spacing, self.origin, self.size, self.draw, halign=Alignment.LEFT)
left_line = Line(
self.spacing,
self.origin,
self.size,
self.draw,
halign=Alignment.LEFT)
# Add words until line is full or we run out
words_added = 0
for word in self.test_words:
@ -78,15 +112,21 @@ class TestAlignmentHandlers(unittest.TestCase):
break
else:
words_added += 1
# Should have added at least some words
self.assertGreater(words_added, 0)
self.assertEqual(len(left_line.text_objects), words_added)
def test_center_alignment_word_addition(self):
"""Test adding words to a center-aligned line"""
center_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.CENTER)
center_line = Line(
self.spacing,
self.origin,
self.size,
self.draw,
font=self.style,
halign=Alignment.CENTER)
# Add words until line is full or we run out
words_added = 0
for word in self.test_words:
@ -96,15 +136,21 @@ class TestAlignmentHandlers(unittest.TestCase):
break
else:
words_added += 1
# Should have added at least some words
self.assertGreater(words_added, 0)
self.assertEqual(len(center_line.text_objects), words_added)
def test_right_alignment_word_addition(self):
"""Test adding words to a right-aligned line"""
right_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.RIGHT)
right_line = Line(
self.spacing,
self.origin,
self.size,
self.draw,
font=self.style,
halign=Alignment.RIGHT)
# Add words until line is full or we run out
words_added = 0
for word in self.test_words:
@ -114,15 +160,21 @@ class TestAlignmentHandlers(unittest.TestCase):
break
else:
words_added += 1
# Should have added at least some words
self.assertGreater(words_added, 0)
self.assertEqual(len(right_line.text_objects), words_added)
def test_justify_alignment_word_addition(self):
"""Test adding words to a justified line"""
justify_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.JUSTIFY)
justify_line = Line(
self.spacing,
self.origin,
self.size,
self.draw,
font=self.style,
halign=Alignment.JUSTIFY)
# Add words until line is full or we run out
words_added = 0
for word in self.test_words:
@ -132,16 +184,17 @@ class TestAlignmentHandlers(unittest.TestCase):
break
else:
words_added += 1
# Should have added at least some words
self.assertGreater(words_added, 0)
self.assertEqual(len(justify_line.text_objects), words_added)
def test_handler_spacing_and_position_calculations(self):
"""Test spacing and position calculations for different alignment handlers"""
# Create sample text objects
text_objects = [Text(word, self.style, self.draw) for word in ["Hello", "World"]]
text_objects = [Text(word, self.style, self.draw)
for word in ["Hello", "World"]]
# Test each handler type
handlers = [
("Left", LeftAlignmentHandler()),
@ -149,108 +202,132 @@ class TestAlignmentHandlers(unittest.TestCase):
("Right", CenterRightAlignmentHandler(Alignment.RIGHT)),
("Justify", JustifyAlignmentHandler())
]
for name, handler in handlers:
with self.subTest(handler=name):
spacing_calc, position, overflow = handler.calculate_spacing_and_position(
text_objects, self.line_width, self.spacing[0], self.spacing[1])
# Check that spacing is a valid number
self.assertIsInstance(spacing_calc, (int, float))
self.assertGreaterEqual(spacing_calc, 0)
# Check that position is a valid number
self.assertIsInstance(position, (int, float))
self.assertGreaterEqual(position, 0)
# Check that overflow is a boolean
self.assertIsInstance(overflow, bool)
# Position should be within line width (unless overflow)
if not overflow:
self.assertLessEqual(position, self.line_width)
def test_left_handler_spacing_calculation(self):
"""Test specific spacing calculation for left alignment"""
handler = LeftAlignmentHandler()
text_objects = [Text(word, self.style, self.draw) for word in ["Hello", "World"]]
text_objects = [Text(word, self.style, self.draw)
for word in ["Hello", "World"]]
spacing_calc, position, overflow = handler.calculate_spacing_and_position(
text_objects, self.line_width, self.spacing[0], self.spacing[1])
# Left alignment should have position at 0
self.assertEqual(position, 0)
# Should not overflow with reasonable text
self.assertFalse(overflow)
def test_center_handler_spacing_calculation(self):
"""Test specific spacing calculation for center alignment"""
handler = CenterRightAlignmentHandler(Alignment.CENTER)
text_objects = [Text(word, self.style, self.draw) for word in ["Hello", "World"]]
text_objects = [Text(word, self.style, self.draw)
for word in ["Hello", "World"]]
spacing_calc, position, overflow = handler.calculate_spacing_and_position(
text_objects, self.line_width, self.spacing[0], self.spacing[1])
# Center alignment should have position > 0 (centered) if no overflow
if not overflow:
self.assertGreaterEqual(position, 0)
def test_right_handler_spacing_calculation(self):
"""Test specific spacing calculation for right alignment"""
handler = CenterRightAlignmentHandler(Alignment.RIGHT)
text_objects = [Text(word, self.style, self.draw) for word in ["Hello", "World"]]
text_objects = [Text(word, self.style, self.draw)
for word in ["Hello", "World"]]
spacing_calc, position, overflow = handler.calculate_spacing_and_position(
text_objects, self.line_width, self.spacing[0], self.spacing[1])
# Right alignment should have position >= 0
self.assertGreaterEqual(position, 0)
def test_justify_handler_spacing_calculation(self):
"""Test specific spacing calculation for justify alignment"""
handler = JustifyAlignmentHandler()
text_objects = [Text(word, self.style, self.draw) for word in ["Hello", "World"]]
text_objects = [Text(word, self.style, self.draw)
for word in ["Hello", "World"]]
spacing_calc, position, overflow = handler.calculate_spacing_and_position(
text_objects, self.line_width, self.spacing[0], self.spacing[1])
# Justify alignment should have position at 0
self.assertEqual(position, 0)
# Check spacing is reasonable
self.assertGreaterEqual(spacing_calc, 0)
def test_empty_line_alignment_handlers(self):
"""Test alignment handlers with empty lines"""
alignments = [Alignment.LEFT, Alignment.CENTER, Alignment.RIGHT, Alignment.JUSTIFY]
alignments = [
Alignment.LEFT,
Alignment.CENTER,
Alignment.RIGHT,
Alignment.JUSTIFY]
for alignment in alignments:
with self.subTest(alignment=alignment):
line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=alignment)
line = Line(
self.spacing,
self.origin,
self.size,
self.draw,
font=self.style,
halign=alignment)
# Empty line should still have a handler
self.assertIsNotNone(line._alignment_handler)
# Should be able to render empty line
line.render()
def test_single_word_line_alignment(self):
"""Test alignment handlers with single word lines"""
alignments = [Alignment.LEFT, Alignment.CENTER, Alignment.RIGHT, Alignment.JUSTIFY]
alignments = [
Alignment.LEFT,
Alignment.CENTER,
Alignment.RIGHT,
Alignment.JUSTIFY]
for alignment in alignments:
with self.subTest(alignment=alignment):
line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=alignment)
line = Line(
self.spacing,
self.origin,
self.size,
self.draw,
font=self.style,
halign=alignment)
# Create a test word
test_word = Word("test", self.style)
# Add a single word
result, part = line.add_word(test_word)
self.assertTrue(result) # Should fit
self.assertIsNone(part) # No overflow part
# Should be able to render single word line
line.render()
self.assertEqual(len(line.text_objects), 1)

View File

@ -6,7 +6,7 @@ Tests the Box class which handles basic box model rendering with alignment.
import unittest
import numpy as np
from PIL import Image
from unittest.mock import Mock, patch
from unittest.mock import Mock
from pyWebLayout.concrete.box import Box
from pyWebLayout.style import Alignment
@ -14,17 +14,17 @@ from pyWebLayout.style import Alignment
class TestBox(unittest.TestCase):
"""Test cases for the Box class"""
def setUp(self):
"""Set up test fixtures"""
self.origin = (10, 20)
self.size = (100, 50)
self.callback = Mock()
def test_box_initialization_basic(self):
"""Test basic box initialization"""
box = Box(self.origin, self.size)
np.testing.assert_array_equal(box._origin, np.array([10, 20]))
np.testing.assert_array_equal(box._size, np.array([100, 50]))
np.testing.assert_array_equal(box._end, np.array([110, 70]))
@ -33,66 +33,65 @@ class TestBox(unittest.TestCase):
self.assertIsNone(box._mode)
self.assertEqual(box._halign, Alignment.CENTER)
self.assertEqual(box._valign, Alignment.CENTER)
def test_box_initialization_with_callback(self):
"""Test box initialization with callback"""
box = Box(self.origin, self.size, callback=self.callback)
self.assertEqual(box._callback, self.callback)
def test_box_initialization_with_sheet(self):
"""Test box initialization with image sheet"""
sheet = Image.new('RGBA', (200, 100), (255, 255, 255, 255))
box = Box(self.origin, self.size, sheet=sheet)
self.assertEqual(box._sheet, sheet)
self.assertEqual(box._mode, 'RGBA')
def test_box_initialization_with_mode(self):
"""Test box initialization with explicit mode"""
box = Box(self.origin, self.size, mode='RGB')
self.assertEqual(box._mode, 'RGB')
def test_box_initialization_with_alignment(self):
"""Test box initialization with custom alignment"""
box = Box(self.origin, self.size, halign=Alignment.LEFT, valign=Alignment.TOP)
self.assertEqual(box._halign, Alignment.LEFT)
self.assertEqual(box._valign, Alignment.TOP)
def test_in_shape_point_inside(self):
"""Test in_shape method with point inside box"""
box = Box(self.origin, self.size)
# Test point inside
self.assertTrue(box.in_shape(np.array([50, 40])))
self.assertTrue(box.in_shape(np.array([10, 20]))) # Top-left corner
self.assertTrue(box.in_shape(np.array([109, 69]))) # Just inside bottom-right
def test_in_shape_point_outside(self):
"""Test in_shape method with point outside box"""
box = Box(self.origin, self.size)
# Test points outside
self.assertFalse(box.in_shape(np.array([5, 15]))) # Before origin
self.assertFalse(box.in_shape(np.array([110, 70]))) # At end (exclusive)
self.assertFalse(box.in_shape(np.array([150, 100]))) # Far outside
self.assertFalse(box.in_shape(np.array([110, 70]))) # At end (exclusive)
self.assertFalse(box.in_shape(np.array([150, 100]))) # Far outside
def test_in_shape_multiple_points(self):
"""Test in_shape method with array of points"""
box = Box(self.origin, self.size)
points = np.array([[50, 40], [5, 15], [109, 69], [110, 70]])
result = box.in_shape(points)
np.testing.assert_array_equal(result, [True, False, True, False])
def test_properties_access(self):
"""Test that properties can be accessed correctly"""
box = Box(self.origin, self.size, callback=self.callback)
# Test that origin property works (should be available via inheritance)
np.testing.assert_array_equal(box._origin, np.array([10, 20]))
np.testing.assert_array_equal(box._size, np.array([100, 50]))

View File

@ -5,23 +5,21 @@ Tests the LinkText, ButtonText, and FormFieldText classes.
import unittest
import numpy as np
from PIL import Image, ImageDraw
from unittest.mock import Mock, patch, MagicMock
from unittest.mock import Mock, patch
from pyWebLayout.concrete.functional import (
LinkText, ButtonText, FormFieldText,
LinkText, ButtonText, FormFieldText,
create_link_text, create_button_text, create_form_field_text
)
from pyWebLayout.abstract.functional import (
Link, Button, Form, FormField, LinkType, FormFieldType
Link, Button, FormField, LinkType, FormFieldType
)
from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration
from pyWebLayout.style import Alignment
from pyWebLayout.style import Font, TextDecoration
class TestLinkText(unittest.TestCase):
"""Test cases for the LinkText class"""
def setUp(self):
"""Set up test fixtures"""
self.font = Font(
@ -30,86 +28,89 @@ class TestLinkText(unittest.TestCase):
colour=(0, 0, 0)
)
self.callback = Mock()
# Create different types of links
self.internal_link = Link("chapter1", LinkType.INTERNAL, self.callback)
self.external_link = Link("https://example.com", LinkType.EXTERNAL, self.callback)
self.external_link = Link(
"https://example.com",
LinkType.EXTERNAL,
self.callback)
self.api_link = Link("/api/settings", LinkType.API, self.callback)
self.function_link = Link("toggle_theme", LinkType.FUNCTION, self.callback)
# Create a mock ImageDraw.Draw object
self.mock_draw = Mock()
def test_link_text_initialization_internal(self):
"""Test initialization of internal link text"""
link_text = "Go to Chapter 1"
renderable = LinkText(self.internal_link, link_text, self.font, self.mock_draw)
self.assertEqual(renderable._link, self.internal_link)
self.assertEqual(renderable.text, link_text)
self.assertFalse(renderable._hovered)
self.assertEqual(renderable._callback, self.internal_link.execute)
# Check that the font has underline decoration and blue color
self.assertEqual(renderable.style.decoration, TextDecoration.UNDERLINE)
self.assertEqual(renderable.style.colour, (0, 0, 200))
def test_link_text_initialization_external(self):
"""Test initialization of external link text"""
link_text = "Visit Example"
renderable = LinkText(self.external_link, link_text, self.font, self.mock_draw)
self.assertEqual(renderable._link, self.external_link)
# External links should have darker blue color
self.assertEqual(renderable.style.colour, (0, 0, 180))
def test_link_text_initialization_api(self):
"""Test initialization of API link text"""
link_text = "Settings"
renderable = LinkText(self.api_link, link_text, self.font, self.mock_draw)
self.assertEqual(renderable._link, self.api_link)
# API links should have red color
self.assertEqual(renderable.style.colour, (150, 0, 0))
def test_link_text_initialization_function(self):
"""Test initialization of function link text"""
link_text = "Toggle Theme"
renderable = LinkText(self.function_link, link_text, self.font, self.mock_draw)
self.assertEqual(renderable._link, self.function_link)
# Function links should have green color
self.assertEqual(renderable.style.colour, (0, 120, 0))
def test_link_property(self):
"""Test link property accessor"""
link_text = "Test Link"
renderable = LinkText(self.internal_link, link_text, self.font, self.mock_draw)
self.assertEqual(renderable.link, self.internal_link)
def test_set_hovered(self):
"""Test setting hover state"""
link_text = "Hover Test"
renderable = LinkText(self.internal_link, link_text, self.font, self.mock_draw)
self.assertFalse(renderable._hovered)
renderable.set_hovered(True)
self.assertTrue(renderable._hovered)
renderable.set_hovered(False)
self.assertFalse(renderable._hovered)
def test_render_normal_state(self):
"""Test rendering in normal state"""
link_text = "Test Link"
renderable = LinkText(self.internal_link, link_text, self.font, self.mock_draw)
# Mock the parent Text render method
with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render:
renderable.render()
# Parent render should be called
mock_parent_render.assert_called_once()
# Should not draw highlight when not hovered
@ -120,21 +121,25 @@ class TestLinkText(unittest.TestCase):
link_text = "Test Link"
renderable = LinkText(self.internal_link, link_text, self.font, self.mock_draw)
renderable.set_origin(np.array([10, 20]))
# Mock width property
# Mock width property
renderable._width = 80
# Point inside link
self.assertTrue(renderable.in_object((15, 25)))
# Point outside link
self.assertFalse(renderable.in_object((200, 200)))
def test_factory_function(self):
"""Test the create_link_text factory function"""
link_text = "Factory Test"
renderable = create_link_text(self.internal_link, link_text, self.font, self.mock_draw)
renderable = create_link_text(
self.internal_link,
link_text,
self.font,
self.mock_draw)
self.assertIsInstance(renderable, LinkText)
self.assertEqual(renderable.text, link_text)
self.assertEqual(renderable.link, self.internal_link)
@ -142,7 +147,7 @@ class TestLinkText(unittest.TestCase):
class TestButtonText(unittest.TestCase):
"""Test cases for the ButtonText class"""
def setUp(self):
"""Set up test fixtures"""
self.font = Font(
@ -153,112 +158,114 @@ class TestButtonText(unittest.TestCase):
self.callback = Mock()
self.button = Button("Click Me", self.callback)
self.mock_draw = Mock()
def test_button_text_initialization(self):
"""Test basic button text initialization"""
renderable = ButtonText(self.button, self.font, self.mock_draw)
self.assertEqual(renderable._button, self.button)
self.assertEqual(renderable.text, "Click Me")
self.assertFalse(renderable._pressed)
self.assertFalse(renderable._hovered)
self.assertEqual(renderable._callback, self.button.execute)
self.assertEqual(renderable._padding, (4, 8, 4, 8))
def test_button_text_with_custom_padding(self):
"""Test button text initialization with custom padding"""
custom_padding = (8, 12, 8, 12)
renderable = ButtonText(
self.button, self.font, self.mock_draw,
padding=custom_padding
)
self.assertEqual(renderable._padding, custom_padding)
def test_button_property(self):
"""Test button property accessor"""
renderable = ButtonText(self.button, self.font, self.mock_draw)
self.assertEqual(renderable.button, self.button)
def test_set_pressed(self):
"""Test setting pressed state"""
renderable = ButtonText(self.button, self.font, self.mock_draw)
self.assertFalse(renderable._pressed)
renderable.set_pressed(True)
self.assertTrue(renderable._pressed)
renderable.set_pressed(False)
self.assertFalse(renderable._pressed)
def test_set_hovered(self):
"""Test setting hover state"""
renderable = ButtonText(self.button, self.font, self.mock_draw)
self.assertFalse(renderable._hovered)
renderable.set_hovered(True)
self.assertTrue(renderable._hovered)
renderable.set_hovered(False)
self.assertFalse(renderable._hovered)
def test_size_property(self):
"""Test size property includes padding"""
renderable = ButtonText(self.button, self.font, self.mock_draw)
# The size should be padded size, not just text size
# Since we handle mocks in __init__, use the padded values directly
expected_width = renderable._padded_width
expected_height = renderable._padded_height
np.testing.assert_array_equal(renderable.size, np.array([expected_width, expected_height]))
np.testing.assert_array_equal(
renderable.size, np.array([expected_width, expected_height]))
def test_render_normal_state(self):
"""Test rendering in normal state"""
renderable = ButtonText(self.button, self.font, self.mock_draw)
# Mock the parent Text render method
with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render:
renderable.render()
# Should draw rounded rectangle for button background
self.mock_draw.rounded_rectangle.assert_called_once()
# Parent render should be called for text
mock_parent_render.assert_called_once()
def test_render_disabled_state(self):
"""Test rendering disabled button"""
disabled_button = Button("Disabled", self.callback, enabled=False)
renderable = ButtonText(disabled_button, self.font, self.mock_draw)
# Mock the parent Text render method
with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render:
renderable.render()
# Should still draw button background
self.mock_draw.rounded_rectangle.assert_called_once()
mock_parent_render.assert_called_once()
def test_in_object_with_padding(self):
"""Test in_object method considers padding"""
renderable = ButtonText(self.button, self.font, self.mock_draw)
renderable.set_origin(np.array([10, 20]))
# Point inside button (including padding)
self.assertTrue(renderable.in_object((15, 25)))
# Point outside button
self.assertFalse(renderable.in_object((200, 200)))
def test_factory_function(self):
"""Test the create_button_text factory function"""
custom_padding = (6, 10, 6, 10)
renderable = create_button_text(self.button, self.font, self.mock_draw, custom_padding)
renderable = create_button_text(
self.button, self.font, self.mock_draw, custom_padding)
self.assertIsInstance(renderable, ButtonText)
self.assertEqual(renderable.text, "Click Me")
self.assertEqual(renderable.button, self.button)
@ -267,7 +274,7 @@ class TestButtonText(unittest.TestCase):
class TestFormFieldText(unittest.TestCase):
"""Test cases for the FormFieldText class"""
def setUp(self):
"""Set up test fixtures"""
self.font = Font(
@ -275,96 +282,102 @@ class TestFormFieldText(unittest.TestCase):
font_size=12,
colour=(0, 0, 0)
)
# Create different types of form fields
self.text_field = FormField("username", FormFieldType.TEXT, "Username")
self.password_field = FormField("password", FormFieldType.PASSWORD, "Password")
self.textarea_field = FormField("description", FormFieldType.TEXTAREA, "Description")
self.textarea_field = FormField(
"description", FormFieldType.TEXTAREA, "Description")
self.select_field = FormField("country", FormFieldType.SELECT, "Country")
self.mock_draw = Mock()
def test_form_field_text_initialization(self):
"""Test initialization of form field text"""
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
self.assertEqual(renderable._field, self.text_field)
self.assertEqual(renderable.text, "Username")
self.assertFalse(renderable._focused)
self.assertEqual(renderable._field_height, 24)
def test_form_field_text_with_custom_height(self):
"""Test form field text with custom field height"""
custom_height = 40
renderable = FormFieldText(self.text_field, self.font, self.mock_draw, custom_height)
renderable = FormFieldText(
self.text_field,
self.font,
self.mock_draw,
custom_height)
self.assertEqual(renderable._field_height, custom_height)
def test_field_property(self):
"""Test field property accessor"""
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
self.assertEqual(renderable.field, self.text_field)
def test_set_focused(self):
"""Test setting focus state"""
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
self.assertFalse(renderable._focused)
renderable.set_focused(True)
self.assertTrue(renderable._focused)
renderable.set_focused(False)
self.assertFalse(renderable._focused)
def test_size_includes_field_area(self):
"""Test size property includes field area"""
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
# Size should include label height + gap + field height
expected_height = renderable._style.font_size + 5 + renderable._field_height
expected_width = renderable._field_width # Use the calculated field width
np.testing.assert_array_equal(renderable.size, np.array([expected_width, expected_height]))
np.testing.assert_array_equal(
renderable.size, np.array([expected_width, expected_height]))
def test_render_text_field(self):
"""Test rendering text field"""
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
# Mock the parent Text render method
with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render:
renderable.render()
# Should render label
mock_parent_render.assert_called_once()
# Should draw field background rectangle
self.mock_draw.rectangle.assert_called_once()
def test_render_field_with_value(self):
"""Test rendering field with value"""
self.text_field.value = "john_doe"
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
# Mock the parent Text render method
with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render:
renderable.render()
# Should render label
mock_parent_render.assert_called_once()
# Should draw field background and value text
self.mock_draw.rectangle.assert_called_once()
self.mock_draw.text.assert_called_once()
def test_render_password_field(self):
"""Test rendering password field with masked value"""
self.password_field.value = "secret123"
renderable = FormFieldText(self.password_field, self.font, self.mock_draw)
# Mock the parent Text render method
with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render:
renderable.render()
# Should render label and field
mock_parent_render.assert_called_once()
self.mock_draw.rectangle.assert_called_once()
@ -373,61 +386,62 @@ class TestFormFieldText(unittest.TestCase):
# Check that the text call used masked characters
call_args = self.mock_draw.text.call_args[0]
self.assertEqual(call_args[1], "" * len("secret123"))
def test_render_focused_field(self):
"""Test rendering focused field"""
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
renderable.set_focused(True)
# Mock the parent Text render method
with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render:
renderable.render()
# Should render with focus styling
mock_parent_render.assert_called_once()
self.mock_draw.rectangle.assert_called_once()
def test_handle_click_inside_field(self):
"""Test clicking inside field area"""
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
# Click inside field area (below label)
field_area_y = renderable._style.font_size + 5 + 10 # Within field area
field_area_point = (15, field_area_y)
result = renderable.handle_click(field_area_point)
# Should return True and set focused
self.assertTrue(result)
self.assertTrue(renderable._focused)
def test_handle_click_outside_field(self):
"""Test clicking outside field area"""
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
# Click outside field area
outside_point = (200, 200)
result = renderable.handle_click(outside_point)
# Should return False and not set focused
self.assertFalse(result)
self.assertFalse(renderable._focused)
def test_in_object(self):
"""Test in_object method"""
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
renderable.set_origin(np.array([10, 20]))
# Point inside field (including label and input area)
self.assertTrue(renderable.in_object((15, 25)))
# Point outside field
self.assertFalse(renderable.in_object((200, 200)))
def test_factory_function(self):
"""Test the create_form_field_text factory function"""
custom_height = 30
renderable = create_form_field_text(self.text_field, self.font, self.mock_draw, custom_height)
renderable = create_form_field_text(
self.text_field, self.font, self.mock_draw, custom_height)
self.assertIsInstance(renderable, FormFieldText)
self.assertEqual(renderable.text, "Username")
self.assertEqual(renderable.field, self.text_field)
@ -436,7 +450,7 @@ class TestFormFieldText(unittest.TestCase):
class TestInteractionCallbacks(unittest.TestCase):
"""Test cases for interaction functionality"""
def setUp(self):
"""Set up test fixtures"""
self.font = Font(font_size=12, colour=(0, 0, 0))
@ -455,7 +469,8 @@ class TestInteractionCallbacks(unittest.TestCase):
def test_link_text_interaction(self):
"""Test that LinkText properly handles interaction"""
# Use a FUNCTION link type which calls the callback, not INTERNAL which returns location
# Use a FUNCTION link type which calls the callback, not INTERNAL which
# returns location
link = Link("test_function", LinkType.FUNCTION, self.link_callback)
renderable = LinkText(link, "Test Link", self.font, self.mock_draw)

View File

@ -8,7 +8,7 @@ import os
import tempfile
import numpy as np
from PIL import Image as PILImage, ImageDraw
from unittest.mock import Mock, patch, MagicMock
from unittest.mock import Mock, patch
from pyWebLayout.concrete.image import RenderableImage
from pyWebLayout.abstract.block import Image as AbstractImage
@ -17,67 +17,67 @@ from pyWebLayout.style import Alignment
class TestRenderableImage(unittest.TestCase):
"""Test cases for the RenderableImage class"""
def setUp(self):
"""Set up test fixtures"""
# Create a temporary test image
self.temp_dir = tempfile.mkdtemp()
self.test_image_path = os.path.join(self.temp_dir, "test_image.png")
# Create a simple test image
test_img = PILImage.new('RGB', (100, 80), (255, 0, 0)) # Red image
test_img.save(self.test_image_path)
# Create abstract image objects
self.abstract_image = AbstractImage(self.test_image_path, "Test Image", 100, 80)
self.abstract_image_no_dims = AbstractImage(self.test_image_path, "Test Image")
# Create a canvas and draw object for testing
self.canvas = PILImage.new('RGBA', (400, 300), (255, 255, 255, 255))
self.draw = ImageDraw.Draw(self.canvas)
def tearDown(self):
"""Clean up test fixtures"""
# Clean up temporary files
try:
os.unlink(self.test_image_path)
os.rmdir(self.temp_dir)
except:
except BaseException:
pass
def test_renderable_image_initialization_basic(self):
"""Test basic image initialization"""
renderable = RenderableImage(self.abstract_image, self.canvas)
self.assertEqual(renderable._abstract_image, self.abstract_image)
self.assertEqual(renderable._canvas, self.canvas)
self.assertIsNotNone(renderable._pil_image)
self.assertIsNone(renderable._error_message)
self.assertEqual(renderable._halign, Alignment.CENTER)
self.assertEqual(renderable._valign, Alignment.CENTER)
def test_renderable_image_initialization_with_constraints(self):
"""Test image initialization with size constraints"""
max_width = 50
max_height = 40
renderable = RenderableImage(
self.abstract_image,
self.draw,
max_width=max_width,
max_height=max_height
)
self.assertEqual(renderable._abstract_image, self.abstract_image)
# Size should be constrained
self.assertLessEqual(renderable._size[0], max_width)
self.assertLessEqual(renderable._size[1], max_height)
def test_renderable_image_initialization_with_custom_params(self):
"""Test image initialization with custom parameters"""
custom_origin = (20, 30)
custom_size = (120, 90)
renderable = RenderableImage(
self.abstract_image,
self.draw,
@ -86,30 +86,30 @@ class TestRenderableImage(unittest.TestCase):
halign=Alignment.LEFT,
valign=Alignment.TOP
)
np.testing.assert_array_equal(renderable._origin, np.array(custom_origin))
np.testing.assert_array_equal(renderable._size, np.array(custom_size))
self.assertEqual(renderable._halign, Alignment.LEFT)
self.assertEqual(renderable._valign, Alignment.TOP)
def test_load_image_local_file(self):
"""Test loading image from local file"""
renderable = RenderableImage(self.abstract_image, self.draw)
# Image should be loaded
self.assertIsNotNone(renderable._pil_image)
self.assertIsNone(renderable._error_message)
self.assertEqual(renderable._pil_image.size, (100, 80))
def test_load_image_nonexistent_file(self):
"""Test loading image from nonexistent file"""
bad_abstract = AbstractImage("/nonexistent/path.png", "Bad Image")
renderable = RenderableImage(bad_abstract, self.draw)
# Should have error message, no PIL image
self.assertIsNone(renderable._pil_image)
self.assertIsNotNone(renderable._error_message)
@patch('requests.get')
def test_load_image_url_success(self, mock_get):
"""Test loading image from URL (success)"""
@ -118,14 +118,14 @@ class TestRenderableImage(unittest.TestCase):
mock_response.status_code = 200
mock_response.content = open(self.test_image_path, 'rb').read()
mock_get.return_value = mock_response
url_abstract = AbstractImage("https://example.com/image.png", "URL Image")
renderable = RenderableImage(url_abstract, self.draw)
# Should successfully load image
self.assertIsNotNone(renderable._pil_image)
self.assertIsNone(renderable._error_message)
@patch('requests.get')
def test_load_image_url_failure(self, mock_get):
"""Test loading image from URL (failure)"""
@ -133,14 +133,16 @@ class TestRenderableImage(unittest.TestCase):
mock_response = Mock()
mock_response.status_code = 404
mock_get.return_value = mock_response
url_abstract = AbstractImage("https://example.com/notfound.png", "Bad URL Image")
url_abstract = AbstractImage(
"https://example.com/notfound.png",
"Bad URL Image")
renderable = RenderableImage(url_abstract, self.draw)
# Should have error message
self.assertIsNone(renderable._pil_image)
self.assertIsNotNone(renderable._error_message)
def test_load_image_no_requests_library(self):
"""Test loading URL image when requests library is not available"""
# Mock the import to raise ImportError for requests
@ -148,24 +150,24 @@ class TestRenderableImage(unittest.TestCase):
if name == 'requests':
raise ImportError("No module named 'requests'")
return __import__(name, *args, **kwargs)
with patch('builtins.__import__', side_effect=mock_import):
url_abstract = AbstractImage("https://example.com/image.png", "URL Image")
renderable = RenderableImage(url_abstract, self.draw)
# Should have error message about missing requests
self.assertIsNone(renderable._pil_image)
self.assertIsNotNone(renderable._error_message)
self.assertIn("Requests library not available", renderable._error_message)
def test_resize_image_fit_within_bounds(self):
"""Test image resizing to fit within bounds"""
renderable = RenderableImage(self.abstract_image, self.draw)
# Original image is 100x80, resize to fit in 50x50
renderable._size = np.array([50, 50])
resized = renderable._resize_image()
self.assertIsInstance(resized, PILImage.Image)
# Should maintain aspect ratio and fit within bounds
self.assertLessEqual(resized.width, 50)
@ -174,90 +176,90 @@ class TestRenderableImage(unittest.TestCase):
original_ratio = 100 / 80
new_ratio = resized.width / resized.height
self.assertAlmostEqual(original_ratio, new_ratio, delta=0.1)
def test_resize_image_larger_target(self):
"""Test image resizing when target is larger than original"""
renderable = RenderableImage(self.abstract_image, self.draw)
# Target size larger than original
renderable._size = np.array([200, 160])
resized = renderable._resize_image()
self.assertIsInstance(resized, PILImage.Image)
# Should scale up to fill the space while maintaining aspect ratio
self.assertGreater(resized.width, 100)
self.assertGreater(resized.height, 80)
def test_resize_image_no_image(self):
"""Test resize when no image is loaded"""
bad_abstract = AbstractImage("/nonexistent/path.png", "Bad Image")
renderable = RenderableImage(bad_abstract, self.draw)
resized = renderable._resize_image()
# Should return a placeholder image
self.assertIsInstance(resized, PILImage.Image)
self.assertEqual(resized.mode, 'RGBA')
def test_draw_error_placeholder(self):
"""Test drawing error placeholder"""
bad_abstract = AbstractImage("/nonexistent/path.png", "Bad Image")
renderable = RenderableImage(bad_abstract, self.canvas)
renderable._error_message = "File not found"
# Set origin for the placeholder
renderable.set_origin(np.array([10, 20]))
# Call the error placeholder method
renderable._draw_error_placeholder()
# We can't easily test the actual drawing without complex mocking,
# but we can verify the method doesn't raise an exception
self.assertIsNotNone(renderable._error_message)
def test_draw_error_placeholder_with_text(self):
"""Test drawing error placeholder with error message"""
bad_abstract = AbstractImage("/nonexistent/path.png", "Bad Image")
renderable = RenderableImage(bad_abstract, self.canvas)
renderable._error_message = "File not found"
# Set origin for the placeholder
renderable.set_origin(np.array([10, 20]))
# Call the error placeholder method
renderable._draw_error_placeholder()
# Verify error message is set
self.assertIsNotNone(renderable._error_message)
self.assertIn("File not found", renderable._error_message)
def test_render_successful_image(self):
"""Test rendering successfully loaded image"""
renderable = RenderableImage(self.abstract_image, self.canvas)
renderable.set_origin(np.array([10, 20]))
# Render returns nothing (draws directly into canvas)
result = renderable.render()
# Result should be None as it draws directly
self.assertIsNone(result)
# Verify image was loaded
self.assertIsNotNone(renderable._pil_image)
def test_render_failed_image(self):
"""Test rendering when image failed to load"""
bad_abstract = AbstractImage("/nonexistent/path.png", "Bad Image")
renderable = RenderableImage(bad_abstract, self.canvas)
renderable.set_origin(np.array([10, 20]))
with patch.object(renderable, '_draw_error_placeholder') as mock_draw_error:
result = renderable.render()
# Result should be None as it draws directly
self.assertIsNone(result)
mock_draw_error.assert_called_once()
def test_render_with_left_alignment(self):
"""Test rendering with left alignment"""
renderable = RenderableImage(
@ -267,14 +269,14 @@ class TestRenderableImage(unittest.TestCase):
valign=Alignment.TOP
)
renderable.set_origin(np.array([10, 20]))
result = renderable.render()
# Result should be None as it draws directly
self.assertIsNone(result)
self.assertEqual(renderable._halign, Alignment.LEFT)
self.assertEqual(renderable._valign, Alignment.TOP)
def test_render_with_right_alignment(self):
"""Test rendering with right alignment"""
renderable = RenderableImage(
@ -284,96 +286,97 @@ class TestRenderableImage(unittest.TestCase):
valign=Alignment.BOTTOM
)
renderable.set_origin(np.array([10, 20]))
result = renderable.render()
# Result should be None as it draws directly
self.assertIsNone(result)
self.assertEqual(renderable._halign, Alignment.RIGHT)
self.assertEqual(renderable._valign, Alignment.BOTTOM)
def test_render_rgb_image_conversion(self):
"""Test rendering RGB image (should be converted to RGBA)"""
# Our test image is RGB, so this should test the conversion path
renderable = RenderableImage(self.abstract_image, self.canvas)
renderable.set_origin(np.array([10, 20]))
result = renderable.render()
# Result should be None as it draws directly
self.assertIsNone(result)
self.assertIsNotNone(renderable._pil_image)
def test_in_object(self):
"""Test in_object method"""
renderable = RenderableImage(self.abstract_image, self.draw, origin=(10, 20))
# Point inside image
self.assertTrue(renderable.in_object((15, 25)))
# Point outside image
self.assertFalse(renderable.in_object((200, 200)))
def test_in_object_with_numpy_array(self):
"""Test in_object with numpy array point"""
renderable = RenderableImage(self.abstract_image, self.draw, origin=(10, 20))
# Point inside image as numpy array
point = np.array([15, 25])
self.assertTrue(renderable.in_object(point))
# Point outside image as numpy array
point = np.array([200, 200])
self.assertFalse(renderable.in_object(point))
def test_image_size_calculation_with_abstract_image_dimensions(self):
"""Test that size is calculated from abstract image when available"""
# Abstract image has dimensions 100x80
renderable = RenderableImage(self.abstract_image, self.draw)
# Size should match the calculated scaled dimensions
expected_size = self.abstract_image.calculate_scaled_dimensions()
np.testing.assert_array_equal(renderable._size, np.array(expected_size))
def test_image_size_calculation_with_constraints(self):
"""Test size calculation with max constraints"""
max_width = 60
max_height = 50
renderable = RenderableImage(
self.abstract_image,
self.draw,
max_width=max_width,
max_height=max_height
)
# Size should respect constraints
self.assertLessEqual(renderable._size[0], max_width)
self.assertLessEqual(renderable._size[1], max_height)
def test_image_without_initial_dimensions(self):
"""Test image without initial dimensions in abstract image"""
renderable = RenderableImage(self.abstract_image_no_dims, self.draw)
# Should still work, using default or calculated size
self.assertIsInstance(renderable._size, np.ndarray)
self.assertEqual(len(renderable._size), 2)
def test_set_origin_method(self):
"""Test the set_origin method"""
renderable = RenderableImage(self.abstract_image, self.draw)
new_origin = np.array([50, 60])
renderable.set_origin(new_origin)
np.testing.assert_array_equal(renderable.origin, new_origin)
def test_properties(self):
"""Test the property methods"""
renderable = RenderableImage(self.abstract_image, self.draw, origin=(10, 20), size=(100, 80))
renderable = RenderableImage(
self.abstract_image, self.draw, origin=(
10, 20), size=(
100, 80))
np.testing.assert_array_equal(renderable.origin, np.array([10, 20]))
np.testing.assert_array_equal(renderable.size, np.array([100, 80]))
self.assertEqual(renderable.width, 100)

View File

@ -6,30 +6,29 @@ Tests the Text and Line classes for text rendering functionality.
import unittest
import numpy as np
import os
from PIL import Image, ImageFont, ImageDraw
from unittest.mock import Mock, patch, MagicMock
from PIL import Image, ImageDraw
from unittest.mock import Mock
from pyWebLayout.concrete.text import Text, Line
from pyWebLayout.abstract.inline import Word
from pyWebLayout.style import Font, FontStyle, FontWeight, TextDecoration
from pyWebLayout.style import Alignment
from tests.utils.test_fonts import create_default_test_font, ensure_consistent_font_in_tests
class TestText(unittest.TestCase):
def setUp(self):
# Ensure consistent font usage across tests
ensure_consistent_font_in_tests()
# Create a real PIL image (canvas) for testing
self.canvas = Image.new('RGB', (800, 600), color='white')
# Create a real ImageDraw object
self.draw = ImageDraw.Draw(self.canvas)
# Create a consistent test Font object using bundled font
self.style = create_default_test_font()
def test_init(self):
text_instance = Text(text="Test", style=self.style, draw=self.draw)
self.assertEqual(text_instance.text, "Test")
@ -59,10 +58,10 @@ class TestText(unittest.TestCase):
text_instance = Text(text="Test", style=self.style, draw=self.draw)
# Set a position so we can render without issues
text_instance.set_origin(np.array([10, 50]))
# This should not raise any exceptions with real objects
text_instance.render()
# We can verify the canvas was modified (pixel check)
# After rendering, some pixels should have changed from pure white
# This is a more realistic test than checking mock calls
@ -70,7 +69,7 @@ class TestText(unittest.TestCase):
def test_text_dimensions(self):
"""Test that text dimensions are calculated correctly with real font"""
text_instance = Text(text="Test", style=self.style, draw=self.draw)
# With real objects, we should get actual width measurements
self.assertGreater(text_instance.width, 0)
self.assertIsInstance(text_instance.width, (int, float))
@ -94,10 +93,10 @@ class TestText(unittest.TestCase):
text_instance = Text(text="Hello World!", style=self.style, draw=self.draw)
text_instance.set_origin(np.array([50, 100]))
text_instance.render()
# Optionally save the canvas for visual inspection
self._save_test_image("rendered_text.png")
# Verify that something was drawn (canvas is no longer pure white everywhere)
# Convert to array and check if any pixels changed
pixels = np.array(self.canvas)
@ -120,13 +119,13 @@ class TestLine(unittest.TestCase):
def setUp(self):
# Ensure consistent font usage across tests
ensure_consistent_font_in_tests()
# Create a real PIL image (canvas) for testing
self.canvas = Image.new('RGB', (800, 600), color='white')
# Create a real ImageDraw object
self.draw = ImageDraw.Draw(self.canvas)
# Create a consistent test Font object using bundled font
self.style = create_default_test_font()
@ -135,7 +134,7 @@ class TestLine(unittest.TestCase):
spacing = (5, 15) # min_spacing, max_spacing
origin = np.array([0, 0])
size = np.array([400, 50])
line = Line(
spacing=spacing,
origin=origin,
@ -144,7 +143,7 @@ class TestLine(unittest.TestCase):
font=self.style,
halign=Alignment.LEFT
)
self.assertEqual(line._spacing, spacing)
np.testing.assert_array_equal(line._origin, origin)
np.testing.assert_array_equal(line._size, size)
@ -155,7 +154,7 @@ class TestLine(unittest.TestCase):
spacing = (5, 15)
origin = np.array([0, 0])
size = np.array([400, 50])
line = Line(
spacing=spacing,
origin=origin,
@ -164,10 +163,10 @@ class TestLine(unittest.TestCase):
font=self.style,
halign=Alignment.LEFT
)
# Create a word to add
word = Word(text="Hello", style=self.style)
# This test may need adjustment based on the actual implementation
success, overflow_part = line.add_word(word)
@ -181,7 +180,7 @@ class TestLine(unittest.TestCase):
spacing = (5, 15)
origin = np.array([0, 0])
size = np.array([400, 50])
line = Line(
spacing=spacing,
origin=origin,
@ -190,25 +189,26 @@ class TestLine(unittest.TestCase):
font=self.style,
halign=Alignment.LEFT
)
# Add words until the line is full
words_added = 0
for i in range(100):
word = Word(text="Amsterdam", style=self.style)
success, overflow_part = line.add_word(word)
if overflow_part:
# Word was hyphenated - overflow occurred
self.assertIsNotNone(overflow_part.text)
return
elif not success:
# Line is full, word couldn't be added
self.assertGreater(words_added, 0, "Should have added at least one word before line filled")
self.assertGreater(
words_added, 0, "Should have added at least one word before line filled")
return
else:
# Word was added successfully
words_added += 1
self.fail("Expected line to fill or overflow to occur but reached max iterations")
def test_line_add_word_until_overflow_small(self):
@ -216,7 +216,7 @@ class TestLine(unittest.TestCase):
spacing = (5, 15)
origin = np.array([0, 0])
size = np.array([400, 50])
line = Line(
spacing=spacing,
origin=origin,
@ -225,20 +225,20 @@ class TestLine(unittest.TestCase):
font=self.style,
halign=Alignment.LEFT
)
# Create a word to add
for i in range(100):
word = Word(text="Aslan", style=self.style)
# This test may need adjustment based on the actual implementation
success, overflow_part = line.add_word(word)
# If successful, the word should be added
if success == False:
if not success:
self.assertIsNone(overflow_part)
return
self.fail("Expected line to reach capacity but reached max iterations")
def test_line_add_word_until_overflow_long_brute(self):
@ -246,7 +246,7 @@ class TestLine(unittest.TestCase):
spacing = (5, 15)
origin = np.array([0, 0])
size = np.array([400, 50])
line = Line(
spacing=spacing,
origin=origin,
@ -256,13 +256,14 @@ class TestLine(unittest.TestCase):
halign=Alignment.LEFT,
min_word_length_for_brute_force=6 # Lower threshold to enable hyphenation for shorter words
)
# Use a longer word to trigger brute force hyphenation
words_added = 0
for i in range(100):
word = Word(text="AAAAAAAA", style=self.style) # 8 A's to ensure it's long enough
# 8 A's to ensure it's long enough
word = Word(text="AAAAAAAA", style=self.style)
success, overflow_part = line.add_word(word)
if overflow_part:
# Word was hyphenated - verify overflow part exists
self.assertIsNotNone(overflow_part.text)
@ -270,20 +271,20 @@ class TestLine(unittest.TestCase):
return
elif not success:
# Line is full, word couldn't be added
self.assertGreater(words_added, 0, "Should have added at least one word before line filled")
self.assertGreater(
words_added, 0, "Should have added at least one word before line filled")
return
else:
words_added += 1
self.fail("Expected line to fill or overflow to occur but reached max iterations")
self.fail("Expected line to fill or overflow to occur but reached max iterations")
def test_line_render(self):
"""Test line rendering with real objects"""
spacing = (5, 15)
origin = np.array([50, 100])
size = np.array([400, 50])
line = Line(
spacing=spacing,
origin=origin,
@ -292,7 +293,7 @@ class TestLine(unittest.TestCase):
font=self.style,
halign=Alignment.LEFT
)
# Try to render the line (even if empty)
try:
line.render()

View File

@ -88,8 +88,11 @@ class TestLinkedWordHyphenation(unittest.TestCase):
# Both parts should be LinkText (this is the bug we're testing for)
for text_obj in line._text_objects:
self.assertIsInstance(text_obj, LinkText,
f"Hyphenated LinkedWord part should be LinkText, got {type(text_obj)}")
self.assertIsInstance(
text_obj,
LinkText,
f"Hyphenated LinkedWord part should be LinkText, got {
type(text_obj)}")
self.assertEqual(text_obj.link.location, linked_word.location)
# The overflow should also be LinkText if it's hyphenated

View File

@ -9,10 +9,9 @@ Unit tests for the new Page implementation to verify it meets the requirements:
"""
import unittest
import numpy as np
from PIL import Image, ImageDraw
from PIL import Image
from pyWebLayout.concrete.page import Page
from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.style.fonts import Font
from pyWebLayout.core.base import Renderable, Queriable
@ -23,7 +22,7 @@ class SimpleTestRenderable(Renderable, Queriable):
self._text = text
self.size = size
self._origin = np.array([0, 0])
def render(self):
"""Render returns None - drawing is done via the page's draw object"""
return None
@ -31,7 +30,7 @@ class SimpleTestRenderable(Renderable, Queriable):
class TestPageImplementation(unittest.TestCase):
"""Test cases for the Page class implementation"""
def setUp(self):
"""Set up test fixtures"""
self.basic_style = PageStyle(
@ -42,81 +41,84 @@ class TestPageImplementation(unittest.TestCase):
padding=(15, 15, 15, 15),
background_color=(240, 240, 240)
)
self.page_size = (800, 600)
def test_page_creation_with_style(self):
"""Test creating a page with a PageStyle"""
page = Page(size=self.page_size, style=self.basic_style)
self.assertEqual(page.size, self.page_size)
self.assertEqual(page.style, self.basic_style)
self.assertEqual(page.border_size, 2)
def test_page_creation_without_style(self):
"""Test creating a page without a PageStyle (should use defaults)"""
page = Page(size=self.page_size)
self.assertEqual(page.size, self.page_size)
self.assertIsNotNone(page.style)
def test_page_canvas_and_content_sizes(self):
"""Test that page correctly calculates canvas and content sizes"""
style = PageStyle(
border_width=5,
padding=(10, 20, 30, 40) # top, right, bottom, left
)
page = Page(size=self.page_size, style=style)
# Canvas size should be page size minus borders
expected_canvas_size = (790, 590) # 800-10, 600-10 (border on both sides)
self.assertEqual(page.canvas_size, expected_canvas_size)
# Content size should be canvas minus padding
expected_content_size = (730, 550) # 790-60, 590-40 (padding left+right, top+bottom)
# 790-60, 590-40 (padding left+right, top+bottom)
expected_content_size = (730, 550)
self.assertEqual(page.content_size, expected_content_size)
def test_page_add_remove_children(self):
"""Test adding and removing children from the page"""
page = Page(size=self.page_size)
# Initially no children
self.assertEqual(len(page.children), 0)
# Add children
child1 = SimpleTestRenderable("Child 1")
child2 = SimpleTestRenderable("Child 2")
page.add_child(child1)
self.assertEqual(len(page.children), 1)
self.assertIn(child1, page.children)
page.add_child(child2)
self.assertEqual(len(page.children), 2)
self.assertIn(child2, page.children)
# Test method chaining
child3 = SimpleTestRenderable("Child 3")
result = page.add_child(child3)
self.assertIs(result, page) # Should return self for chaining
self.assertEqual(len(page.children), 3)
self.assertIn(child3, page.children)
# Remove childce youll notice is that responses dont stream character-by-character like other providers. Instead, Claude Code processes your full request before sending back the complete response.
# Remove childce youll notice is that responses dont stream
# character-by-character like other providers. Instead, Claude Code
# processes your full request before sending back the complete response.
removed = page.remove_child(child2)
self.assertTrue(removed)
self.assertEqual(len(page.children), 2)
self.assertNotIn(child2, page.children)
# Try to remove non-existent child
removed = page.remove_child(child2)
self.assertFalse(removed)
# Clear all children
page.clear_children()
self.assertEqual(len(page.children), 0)
def test_page_render(self):
"""Test that page renders and creates a canvas"""
style = PageStyle(
@ -124,24 +126,24 @@ class TestPageImplementation(unittest.TestCase):
border_color=(255, 0, 0),
background_color=(255, 255, 255)
)
page = Page(size=(200, 150), style=style)
# Add a child
child = SimpleTestRenderable("Test child")
page.add_child(child)
# Render the page
image = page.render()
# Check that we got an image
self.assertIsInstance(image, Image.Image)
self.assertEqual(image.size, (200, 150))
self.assertEqual(image.mode, 'RGBA')
# Check that draw object is available
self.assertIsNotNone(page.draw)
def test_page_query_point(self):
"""Test querying points to find children"""
page = Page(size=(400, 300))
@ -167,22 +169,22 @@ class TestPageImplementation(unittest.TestCase):
result = page.query_point((300, 250))
self.assertIsNotNone(result)
self.assertEqual(result.object_type, "empty")
def test_page_in_object(self):
"""Test that page correctly implements in_object"""
page = Page(size=(400, 300))
# Points within page bounds
self.assertTrue(page.in_object((0, 0)))
self.assertTrue(page.in_object((200, 150)))
self.assertTrue(page.in_object((399, 299)))
# Points outside page bounds
self.assertFalse(page.in_object((-1, 0)))
self.assertFalse(page.in_object((0, -1)))
self.assertFalse(page.in_object((400, 299)))
self.assertFalse(page.in_object((399, 300)))
def test_page_with_borders(self):
"""Test page rendering with borders"""
style = PageStyle(
@ -190,33 +192,33 @@ class TestPageImplementation(unittest.TestCase):
border_color=(128, 128, 128),
background_color=(255, 255, 255)
)
page = Page(size=(100, 100), style=style)
image = page.render()
# Check that image was created
self.assertIsInstance(image, Image.Image)
self.assertEqual(image.size, (100, 100))
# The border should be drawn but we can't easily test pixel values
# Just verify the image exists and has the right properties
def test_page_border_size_property(self):
"""Test that border_size property returns correct value"""
# Test with border
style_with_border = PageStyle(border_width=5)
page_with_border = Page(size=self.page_size, style=style_with_border)
self.assertEqual(page_with_border.border_size, 5)
# Test without border
style_no_border = PageStyle(border_width=0)
page_no_border = Page(size=self.page_size, style=style_no_border)
self.assertEqual(page_no_border.border_size, 0)
def test_page_style_properties(self):
"""Test that page correctly exposes style properties"""
page = Page(size=self.page_size, style=self.basic_style)
# Test that style properties are accessible
self.assertEqual(page.style.border_width, 2)
self.assertEqual(page.style.border_color, (255, 0, 0))
@ -224,30 +226,30 @@ class TestPageImplementation(unittest.TestCase):
self.assertEqual(page.style.inter_block_spacing, 20)
self.assertEqual(page.style.padding, (15, 15, 15, 15))
self.assertEqual(page.style.background_color, (240, 240, 240))
def test_page_children_list_operations(self):
"""Test that children list behaves correctly"""
page = Page(size=self.page_size)
# Test that children is initially empty list
self.assertIsInstance(page.children, list)
self.assertEqual(len(page.children), 0)
# Test adding multiple children
children = [
SimpleTestRenderable(f"Child {i}")
for i in range(5)
]
for child in children:
page.add_child(child)
self.assertEqual(len(page.children), 5)
# Test that children are in the correct order
for i, child in enumerate(page.children):
self.assertEqual(child._text, f"Child {i}")
def test_page_can_fit_line_boundary_checking(self):
"""Test that can_fit_line correctly checks bottom boundary"""
# Create page with known dimensions
@ -259,63 +261,64 @@ class TestPageImplementation(unittest.TestCase):
padding=(10, 10, 10, 10)
)
page = Page(size=(800, 600), style=style)
# Initial y_offset should be at border + padding_top = 50
self.assertEqual(page._current_y_offset, 50)
# Test 1: Line that fits comfortably
line_height = 20
max_y = 600 - 40 - 10 # 550
_max_y = 600 - 40 - 10 # 550
self.assertTrue(page.can_fit_line(line_height))
# Would end at 50 + 20 = 70, well within 550
# Test 2: Simulate adding lines to fill the page
# Available height: 550 - 50 = 500 pixels
# With 20-pixel lines, we can fit 25 lines exactly
for i in range(24): # Add 24 lines
self.assertTrue(page.can_fit_line(20), f"Line {i+1} should fit")
self.assertTrue(page.can_fit_line(20), f"Line {i + 1} should fit")
# Simulate adding a line by updating y_offset
page._current_y_offset += 20
# After 24 lines: y_offset = 50 + (24 * 20) = 530
self.assertEqual(page._current_y_offset, 530)
# Test 3: One more 20-pixel line should fit (530 + 20 = 550, exactly at boundary)
# Test 3: One more 20-pixel line should fit (530 + 20 = 550, exactly at
# boundary)
self.assertTrue(page.can_fit_line(20))
page._current_y_offset += 20
self.assertEqual(page._current_y_offset, 550)
# Test 4: Now another line should NOT fit (550 + 20 = 570 > 550)
self.assertFalse(page.can_fit_line(20))
# Test 5: Even a 1-pixel line should not fit (550 + 1 = 551 > 550)
self.assertFalse(page.can_fit_line(1))
# Test 6: Edge case - exactly at boundary, 0-height line should fit
self.assertTrue(page.can_fit_line(0))
def test_page_can_fit_line_with_different_styles(self):
"""Test can_fit_line with different page styles"""
# Test with no border or padding
style_no_border = PageStyle(border_width=0, padding=(0, 0, 0, 0))
page_no_border = Page(size=(100, 100), style=style_no_border)
# With no border/padding, y_offset starts at 0
self.assertEqual(page_no_border._current_y_offset, 0)
# Can fit a 100-pixel line exactly
self.assertTrue(page_no_border.can_fit_line(100))
# Cannot fit a 101-pixel line
self.assertFalse(page_no_border.can_fit_line(101))
# Test with large border and padding
style_large = PageStyle(border_width=20, padding=(15, 15, 15, 15))
page_large = Page(size=(200, 200), style=style_large)
# y_offset starts at border + padding_top = 20 + 15 = 35
self.assertEqual(page_large._current_y_offset, 35)
# Max y = 200 - 20 - 15 = 165
# Available height = 165 - 35 = 130 pixels
self.assertTrue(page_large.can_fit_line(130))

View File

@ -17,8 +17,7 @@ from pyWebLayout.concrete.table import (
TableRenderer
)
from pyWebLayout.abstract.block import (
Table, TableRow, TableCell, Paragraph, Heading, HeadingLevel,
Image as AbstractImage
Table, TableRow, TableCell, Paragraph, Heading, HeadingLevel
)
from pyWebLayout.abstract.inline import Word
from pyWebLayout.style import Font
@ -193,7 +192,11 @@ class TestTableCellRenderer:
assert cell_renderer._style == default_table_style
assert cell_renderer._is_header_section is False
def test_initialization_with_header(self, sample_font, sample_draw, default_table_style):
def test_initialization_with_header(
self,
sample_font,
sample_draw,
default_table_style):
"""Test TableCellRenderer initialization for header cell."""
cell = TableCell(is_header=True)
cell_renderer = TableCellRenderer(
@ -207,7 +210,12 @@ class TestTableCellRenderer:
assert cell_renderer._is_header_section is True
def test_render_empty_cell(self, sample_font, sample_draw, sample_canvas, default_table_style):
def test_render_empty_cell(
self,
sample_font,
sample_draw,
sample_canvas,
default_table_style):
"""Test rendering an empty cell."""
cell = TableCell()
cell_renderer = TableCellRenderer(
@ -223,7 +231,12 @@ class TestTableCellRenderer:
# Render returns None (draws directly on canvas)
assert result is None
def test_render_cell_with_text(self, sample_font, sample_draw, sample_canvas, default_table_style):
def test_render_cell_with_text(
self,
sample_font,
sample_draw,
sample_canvas,
default_table_style):
"""Test rendering a cell with text content."""
cell = TableCell()
paragraph = Paragraph(sample_font)
@ -243,7 +256,12 @@ class TestTableCellRenderer:
result = cell_renderer.render()
assert result is None
def test_render_header_cell(self, sample_font, sample_draw, sample_canvas, default_table_style):
def test_render_header_cell(
self,
sample_font,
sample_draw,
sample_canvas,
default_table_style):
"""Test rendering a header cell with different styling."""
cell = TableCell(is_header=True)
paragraph = Paragraph(sample_font)
@ -263,7 +281,12 @@ class TestTableCellRenderer:
result = cell_renderer.render()
assert result is None
def test_render_cell_with_heading(self, sample_font, sample_draw, sample_canvas, default_table_style):
def test_render_cell_with_heading(
self,
sample_font,
sample_draw,
sample_canvas,
default_table_style):
"""Test rendering a cell with heading content."""
cell = TableCell()
heading = Heading(HeadingLevel.H2, sample_font)
@ -294,11 +317,11 @@ class TestTableCellRenderer:
)
# Point inside cell
assert cell_renderer.in_object((50, 30)) == True
assert cell_renderer.in_object((50, 30))
# Point outside cell
assert cell_renderer.in_object((150, 30)) == False
assert cell_renderer.in_object((50, 100)) == False
assert not cell_renderer.in_object((150, 30))
assert not cell_renderer.in_object((50, 100))
def test_properties_access(self, sample_font, sample_draw, default_table_style):
"""Test accessing cell renderer properties."""
@ -361,7 +384,12 @@ class TestTableRowRenderer:
result = row_renderer.render()
assert result is None
def test_render_row_with_cells(self, sample_font, sample_draw, sample_canvas, default_table_style):
def test_render_row_with_cells(
self,
sample_font,
sample_draw,
sample_canvas,
default_table_style):
"""Test rendering a row with multiple cells."""
row = TableRow()
@ -388,7 +416,12 @@ class TestTableRowRenderer:
# Verify cells were created
assert len(row_renderer._cell_renderers) == 3
def test_render_row_with_colspan(self, sample_font, sample_draw, sample_canvas, default_table_style):
def test_render_row_with_colspan(
self,
sample_font,
sample_draw,
sample_canvas,
default_table_style):
"""Test rendering a row with cells that span multiple columns."""
row = TableRow()
@ -444,7 +477,11 @@ class TestTableRenderer:
assert table_renderer._draw == sample_draw
assert table_renderer._style == default_table_style
def test_dimension_calculation(self, simple_table, sample_draw, default_table_style):
def test_dimension_calculation(
self,
simple_table,
sample_draw,
default_table_style):
"""Test table dimension calculation."""
table_renderer = TableRenderer(
simple_table,
@ -459,7 +496,12 @@ class TestTableRenderer:
assert len(table_renderer._row_heights) == 3 # header, body, footer
assert all(width > 0 for width in table_renderer._column_widths)
def test_render_simple_table(self, simple_table, sample_draw, sample_canvas, default_table_style):
def test_render_simple_table(
self,
simple_table,
sample_draw,
sample_canvas,
default_table_style):
"""Test rendering a complete simple table."""
table_renderer = TableRenderer(
simple_table,
@ -475,7 +517,12 @@ class TestTableRenderer:
# Verify rows were created
assert len(table_renderer._row_renderers) == 2 # 1 header + 1 body
def test_render_table_with_caption(self, simple_table, sample_draw, sample_canvas, default_table_style):
def test_render_table_with_caption(
self,
simple_table,
sample_draw,
sample_canvas,
default_table_style):
"""Test rendering a table with caption."""
simple_table.caption = "Test Table Caption"
@ -535,7 +582,12 @@ class TestTableRenderer:
# Should handle gracefully
assert table_renderer is not None
def test_table_with_footer(self, sample_font, sample_draw, sample_canvas, default_table_style):
def test_table_with_footer(
self,
sample_font,
sample_draw,
sample_canvas,
default_table_style):
"""Test rendering a table with footer rows."""
table = Table()
@ -590,10 +642,10 @@ class TestTableRenderer:
)
# Point inside table
assert table_renderer.in_object((50, 50)) == True
assert table_renderer.in_object((50, 50))
# Point outside table
assert table_renderer.in_object((1000, 1000)) == False
assert not table_renderer.in_object((1000, 1000))
if __name__ == "__main__":

View File

@ -8,7 +8,6 @@ import unittest
import tempfile
import shutil
from pathlib import Path
import json
from pyWebLayout.core.highlight import (
Highlight,

View File

@ -15,7 +15,6 @@ from pyWebLayout.concrete.text import Text, Line
from pyWebLayout.concrete.functional import LinkText
from pyWebLayout.abstract.inline import Word
from pyWebLayout.abstract.functional import Link, LinkType
from pyWebLayout.style import Font, Alignment
from pyWebLayout.style.page_style import PageStyle
from tests.utils.test_fonts import create_default_test_font, ensure_consistent_font_in_tests
@ -369,7 +368,8 @@ class TestPageQueryRange(unittest.TestCase):
start_text = line._text_objects[0]
end_text = line._text_objects[1]
start_point = (int(start_text._origin[0] + 5), int(start_text._origin[1] + 5))
start_point = (
int(start_text._origin[0] + 5), int(start_text._origin[1] + 5))
end_point = (int(end_text._origin[0] + 5), int(end_text._origin[1] + 5))
sel_range = self.page.query_range(start_point, end_point)

View File

@ -9,7 +9,6 @@ import unittest
import tempfile
import os
import shutil
from datetime import datetime
# Import ebooklib for creating test EPUB files
try:
@ -21,8 +20,8 @@ except ImportError:
from pyWebLayout.io.readers.epub_reader import read_epub, EPUBReader
from pyWebLayout.abstract.document import Book
from pyWebLayout.abstract.block import (
Paragraph, Heading, HeadingLevel, Quote, CodeBlock,
HList, ListStyle, Table, HorizontalRule, Image
Paragraph, Heading, Quote, CodeBlock, HList,
ListStyle, Table, Image
)
from pyWebLayout.style import FontWeight, FontStyle, TextDecoration
@ -30,12 +29,12 @@ from pyWebLayout.style import FontWeight, FontStyle, TextDecoration
@unittest.skipUnless(EBOOKLIB_AVAILABLE, "ebooklib not available")
class TestEPUBReader(unittest.TestCase):
"""Test cases for EPUB reader functionality."""
def setUp(self):
"""Set up test environment."""
self.test_dir = tempfile.mkdtemp()
self.epub_files = []
def tearDown(self):
"""Clean up test environment."""
# Clean up test EPUB files
@ -44,21 +43,21 @@ class TestEPUBReader(unittest.TestCase):
os.remove(epub_file)
except OSError:
pass
# Clean up test directory
if os.path.exists(self.test_dir):
shutil.rmtree(self.test_dir, ignore_errors=True)
def create_simple_epub(self, title="Test Book", author="Test Author"):
"""Create a simple EPUB file for testing."""
book = epub.EpubBook()
# Set metadata
book.set_identifier('test-id-123')
book.set_title(title)
book.set_language('en')
book.add_author(author)
# Create a simple chapter
chapter1 = epub.EpubHtml(
title='Chapter 1',
@ -75,31 +74,32 @@ class TestEPUBReader(unittest.TestCase):
</body>
</html>
'''
# Add chapter to book
book.add_item(chapter1)
# Define table of contents
book.toc = (epub.Link("chapter1.xhtml", "Chapter 1", "ch1"),)
# Add navigation files
book.add_item(epub.EpubNcx())
book.add_item(epub.EpubNav())
# Define spine
book.spine = ['nav', chapter1]
# Create temporary file
epub_path = os.path.join(self.test_dir, f'test_simple_{len(self.epub_files)}.epub')
epub_path = os.path.join(self.test_dir,
f'test_simple_{len(self.epub_files)}.epub')
epub.write_epub(epub_path, book, {})
self.epub_files.append(epub_path)
return epub_path
def create_complex_epub(self):
"""Create a more complex EPUB file with multiple chapters and content types."""
book = epub.EpubBook()
# Set metadata
book.set_identifier('complex-test-id-456')
book.set_title('Complex Test Book')
@ -109,7 +109,7 @@ class TestEPUBReader(unittest.TestCase):
book.add_metadata('DC', 'subject', 'Testing')
book.add_metadata('DC', 'date', '2024-01-01')
book.add_metadata('DC', 'publisher', 'Test Publisher')
# Chapter 1: Basic content
chapter1 = epub.EpubHtml(
title='Introduction',
@ -126,7 +126,7 @@ class TestEPUBReader(unittest.TestCase):
</body>
</html>
'''
# Chapter 2: Styled content
chapter2 = epub.EpubHtml(
title='Styled Content',
@ -138,7 +138,7 @@ class TestEPUBReader(unittest.TestCase):
<head><title>Styled Content</title></head>
<body>
<h1>Styled Content</h1>
<p>This chapter contains various <strong>bold text</strong>, <em>italic text</em>,
<p>This chapter contains various <strong>bold text</strong>, <em>italic text</em>,
and <span style="color: red; font-weight: bold;">colored text</span>.</p>
<h2>Subsection</h2>
<p>Text with <u>underline</u> and <s>strikethrough</s>.</p>
@ -147,7 +147,7 @@ class TestEPUBReader(unittest.TestCase):
</body>
</html>
'''
# Chapter 3: Lists and quotes
chapter3 = epub.EpubHtml(
title='Lists and Quotes',
@ -159,30 +159,30 @@ class TestEPUBReader(unittest.TestCase):
<head><title>Lists and Quotes</title></head>
<body>
<h1>Lists and Quotes</h1>
<h2>Unordered List</h2>
<ul>
<li>First item</li>
<li><strong>Bold item</strong></li>
<li>Item with <em>italic text</em></li>
</ul>
<h2>Ordered List</h2>
<ol>
<li>First numbered item</li>
<li>Second numbered item</li>
<li>Third numbered item</li>
</ol>
<h2>Quote</h2>
<blockquote>
<p>This is a <span style="font-style: italic;">quoted paragraph</span>
<p>This is a <span style="font-style: italic;">quoted paragraph</span>
with some styling.</p>
</blockquote>
</body>
</html>
'''
# Chapter 4: Tables and code
chapter4 = epub.EpubHtml(
title='Tables and Code',
@ -194,7 +194,7 @@ class TestEPUBReader(unittest.TestCase):
<head><title>Tables and Code</title></head>
<body>
<h1>Tables and Code</h1>
<h2>Simple Table</h2>
<table>
<thead>
@ -214,25 +214,25 @@ class TestEPUBReader(unittest.TestCase):
</tr>
</tbody>
</table>
<h2>Code Block</h2>
<pre><code>function test() {
console.log("Hello, world!");
return true;
}</code></pre>
<h2>Inline Code</h2>
<p>Use the <code>print()</code> function to output text.</p>
</body>
</html>
'''
# Add chapters to book
book.add_item(chapter1)
book.add_item(chapter2)
book.add_item(chapter3)
book.add_item(chapter4)
# Define table of contents
book.toc = (
epub.Link("chapter1.xhtml", "Introduction", "intro"),
@ -240,31 +240,32 @@ class TestEPUBReader(unittest.TestCase):
epub.Link("chapter3.xhtml", "Lists and Quotes", "lists"),
epub.Link("chapter4.xhtml", "Tables and Code", "tables")
)
# Add navigation files
book.add_item(epub.EpubNcx())
book.add_item(epub.EpubNav())
# Define spine
book.spine = ['nav', chapter1, chapter2, chapter3, chapter4]
# Create temporary file
epub_path = os.path.join(self.test_dir, f'test_complex_{len(self.epub_files)}.epub')
epub_path = os.path.join(self.test_dir,
f'test_complex_{len(self.epub_files)}.epub')
epub.write_epub(epub_path, book, {})
self.epub_files.append(epub_path)
return epub_path
def create_epub_with_nested_content(self):
"""Create an EPUB with nested content structures."""
book = epub.EpubBook()
# Set metadata
book.set_identifier('nested-test-id-789')
book.set_title('Nested Content Test')
book.set_language('en')
book.add_author('Test Author')
# Chapter with nested content
chapter = epub.EpubHtml(
title='Nested Content',
@ -276,17 +277,17 @@ class TestEPUBReader(unittest.TestCase):
<head><title>Nested Content</title></head>
<body>
<h1>Nested Content Examples</h1>
<div>
<h2>Section in Div</h2>
<p>Paragraph inside div.</p>
<section>
<h3>Subsection</h3>
<article>
<h4>Article Header</h4>
<p>Article content with <strong>nested <em>formatting</em></strong>.</p>
<aside>
<p>Sidebar content in aside element.</p>
<ul>
@ -297,268 +298,284 @@ class TestEPUBReader(unittest.TestCase):
</article>
</section>
</div>
<footer>
<p>Footer content with <span style="font-size: 12px; color: gray;">small gray text</span>.</p>
</footer>
</body>
</html>
'''
# Add chapter to book
book.add_item(chapter)
# Define table of contents
book.toc = (epub.Link("nested.xhtml", "Nested Content", "nested"),)
# Add navigation files
book.add_item(epub.EpubNcx())
book.add_item(epub.EpubNav())
# Define spine
book.spine = ['nav', chapter]
# Create temporary file
epub_path = os.path.join(self.test_dir, f'test_nested_{len(self.epub_files)}.epub')
epub_path = os.path.join(self.test_dir,
f'test_nested_{len(self.epub_files)}.epub')
epub.write_epub(epub_path, book, {})
self.epub_files.append(epub_path)
return epub_path
def test_simple_epub_reading(self):
"""Test reading a simple EPUB file."""
epub_path = self.create_simple_epub()
# Read the EPUB
book = read_epub(epub_path)
# Verify it's a Book object
self.assertIsInstance(book, Book)
# Check metadata
self.assertEqual(book.title, "Test Book")
# Check chapters
chapters = list(book.chapters)
self.assertEqual(len(chapters), 1)
# Check chapter content
chapter = chapters[0]
blocks = list(chapter.blocks)
self.assertGreater(len(blocks), 0)
# Should have a heading and paragraphs
has_heading = any(isinstance(block, Heading) for block in blocks)
has_paragraph = any(isinstance(block, Paragraph) for block in blocks)
self.assertTrue(has_heading, "Should contain at least one heading")
self.assertTrue(has_paragraph, "Should contain at least one paragraph")
def test_complex_epub_reading(self):
"""Test reading a complex EPUB file with multiple chapters."""
epub_path = self.create_complex_epub()
# Read the EPUB
book = read_epub(epub_path)
# Verify it's a Book object
self.assertIsInstance(book, Book)
# Check metadata
self.assertEqual(book.title, "Complex Test Book")
# Check chapters
chapters = list(book.chapters)
self.assertEqual(len(chapters), 4)
# Test each chapter has content
for i, chapter in enumerate(chapters):
blocks = list(chapter.blocks)
self.assertGreater(len(blocks), 0, f"Chapter {i+1} should have blocks")
self.assertGreater(len(blocks), 0, f"Chapter {i + 1} should have blocks")
# Each chapter should start with a heading
first_block = blocks[0]
self.assertIsInstance(first_block, Heading, f"Chapter {i+1} should start with heading")
self.assertIsInstance(
first_block, Heading, f"Chapter {
i + 1} should start with heading")
def test_epub_styled_content(self):
"""Test that styled content in EPUB is properly parsed."""
epub_path = self.create_complex_epub()
book = read_epub(epub_path)
chapters = list(book.chapters)
# Check styled content in chapter 2 (index 1)
if len(chapters) > 1:
chapter2_blocks = list(chapters[1].blocks)
# Find paragraphs with styled text
styled_words_found = False
for block in chapter2_blocks:
if isinstance(block, Paragraph):
words = list(block.words_iter())
for _, word in words:
if (word.style.weight == FontWeight.BOLD or
if (word.style.weight == FontWeight.BOLD or
word.style.style == FontStyle.ITALIC or
word.style.colour != (0, 0, 0)): # Non-black color
word.style.colour != (0, 0, 0)): # Non-black color
styled_words_found = True
break
if styled_words_found:
break
self.assertTrue(styled_words_found, "Should find styled words in chapter 2")
def test_epub_lists(self):
"""Test that lists in EPUB are properly parsed."""
epub_path = self.create_complex_epub()
book = read_epub(epub_path)
chapters = list(book.chapters)
# Check lists in chapter 3 (index 2)
if len(chapters) > 2:
chapter3_blocks = list(chapters[2].blocks)
# Find list blocks
unordered_list_found = False
ordered_list_found = False
quote_found = False
for block in chapter3_blocks:
if isinstance(block, HList):
if block.style == ListStyle.UNORDERED:
unordered_list_found = True
# Check list items
items = list(block.items())
self.assertGreater(len(items), 0, "Unordered list should have items")
self.assertGreater(
len(items), 0, "Unordered list should have items")
elif block.style == ListStyle.ORDERED:
ordered_list_found = True
# Check list items
items = list(block.items())
self.assertGreater(len(items), 0, "Ordered list should have items")
self.assertGreater(
len(items), 0, "Ordered list should have items")
elif isinstance(block, Quote):
quote_found = True
self.assertTrue(unordered_list_found, "Should find unordered list in chapter 3")
self.assertTrue(
unordered_list_found,
"Should find unordered list in chapter 3")
self.assertTrue(ordered_list_found, "Should find ordered list in chapter 3")
self.assertTrue(quote_found, "Should find quote in chapter 3")
def test_epub_tables(self):
"""Test that tables in EPUB are properly parsed."""
epub_path = self.create_complex_epub()
book = read_epub(epub_path)
chapters = list(book.chapters)
# Check tables in chapter 4 (index 3)
if len(chapters) > 3:
chapter4_blocks = list(chapters[3].blocks)
# Find table blocks
table_found = False
code_block_found = False
for block in chapter4_blocks:
if isinstance(block, Table):
table_found = True
# Check table has rows
rows = list(block.all_rows())
self.assertGreater(len(rows), 0, "Table should have rows")
elif isinstance(block, CodeBlock):
code_block_found = True
# Check code block has lines
lines = list(block.lines())
self.assertGreater(len(lines), 0, "Code block should have lines")
self.assertTrue(table_found, "Should find table in chapter 4")
self.assertTrue(code_block_found, "Should find code block in chapter 4")
def test_epub_nested_content(self):
"""Test that nested content structures are properly parsed."""
epub_path = self.create_epub_with_nested_content()
book = read_epub(epub_path)
chapters = list(book.chapters)
self.assertEqual(len(chapters), 1)
chapter_blocks = list(chapters[0].blocks)
self.assertGreater(len(chapter_blocks), 0)
# Should have multiple headings (h1, h2, h3, h4)
headings = [block for block in chapter_blocks if isinstance(block, Heading)]
self.assertGreater(len(headings), 2, "Should have multiple headings from nested content")
self.assertGreater(
len(headings),
2,
"Should have multiple headings from nested content")
# Should have paragraphs and lists from nested content
paragraphs = [block for block in chapter_blocks if isinstance(block, Paragraph)]
lists = [block for block in chapter_blocks if isinstance(block, HList)]
self.assertGreater(len(paragraphs), 0, "Should have paragraphs from nested content")
self.assertGreater(
len(paragraphs),
0,
"Should have paragraphs from nested content")
self.assertGreater(len(lists), 0, "Should have lists from nested content")
def test_epub_metadata_extraction(self):
"""Test that EPUB metadata is properly extracted."""
epub_path = self.create_complex_epub()
book = read_epub(epub_path)
# Check basic metadata
self.assertEqual(book.title, "Complex Test Book")
# Check that metadata was set (implementation may vary)
# This tests that the metadata parsing doesn't crash
self.assertIsNotNone(book.title)
def test_epub_reader_class_direct(self):
"""Test EPUBReader class directly."""
epub_path = self.create_simple_epub()
reader = EPUBReader(epub_path)
book = reader.read()
self.assertIsInstance(book, Book)
self.assertEqual(book.title, "Test Book")
def test_invalid_epub_handling(self):
"""Test handling of invalid EPUB files."""
# Create a non-EPUB file
invalid_path = os.path.join(self.test_dir, 'invalid.epub')
with open(invalid_path, 'w') as f:
f.write("This is not an EPUB file")
# Should raise an exception or handle gracefully
with self.assertRaises(Exception):
read_epub(invalid_path)
def test_nonexistent_epub_handling(self):
"""Test handling of nonexistent EPUB files."""
nonexistent_path = os.path.join(self.test_dir, 'nonexistent.epub')
# Should raise an exception
with self.assertRaises(Exception):
read_epub(nonexistent_path)
def test_epub_with_custom_metadata(self):
"""Test EPUB with various metadata fields."""
book = epub.EpubBook()
# Set comprehensive metadata
book.set_identifier('custom-metadata-test')
book.set_title('Custom Metadata Test')
book.set_language('en')
book.add_author('Primary Author')
book.add_author('Secondary Author')
book.add_metadata('DC', 'description', 'A comprehensive test of metadata extraction')
book.add_metadata(
'DC',
'description',
'A comprehensive test of metadata extraction')
book.add_metadata('DC', 'subject', 'Testing')
book.add_metadata('DC', 'subject', 'EPUB')
book.add_metadata('DC', 'date', '2024-06-07')
book.add_metadata('DC', 'publisher', 'Test Publishing House')
book.add_metadata('DC', 'rights', 'Public Domain')
# Simple chapter
chapter = epub.EpubHtml(
title='Metadata Test',
@ -574,22 +591,23 @@ class TestEPUBReader(unittest.TestCase):
</body>
</html>
'''
book.add_item(chapter)
book.toc = (epub.Link("metadata.xhtml", "Metadata Test", "meta"),)
book.add_item(epub.EpubNcx())
book.add_item(epub.EpubNav())
book.spine = ['nav', chapter]
# Write and test
epub_path = os.path.join(self.test_dir, f'test_metadata_{len(self.epub_files)}.epub')
epub_path = os.path.join(self.test_dir,
f'test_metadata_{len(self.epub_files)}.epub')
epub.write_epub(epub_path, book, {})
self.epub_files.append(epub_path)
# Read and verify
parsed_book = read_epub(epub_path)
self.assertEqual(parsed_book.title, "Custom Metadata Test")
# Verify chapters were created
chapters = list(parsed_book.chapters)
self.assertEqual(len(chapters), 1)
@ -597,12 +615,12 @@ class TestEPUBReader(unittest.TestCase):
class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase):
"""Test cases that specifically verify EPUB reader uses html_extraction properly."""
def setUp(self):
"""Set up test environment."""
self.test_dir = tempfile.mkdtemp()
self.epub_files = []
def tearDown(self):
"""Clean up test environment."""
for epub_file in self.epub_files:
@ -610,10 +628,10 @@ class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase):
os.remove(epub_file)
except OSError:
pass
if os.path.exists(self.test_dir):
shutil.rmtree(self.test_dir, ignore_errors=True)
@unittest.skipUnless(EBOOKLIB_AVAILABLE, "ebooklib not available")
def test_html_extraction_integration(self):
"""Test that EPUB reader properly uses html_extraction functionality."""
@ -623,7 +641,7 @@ class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase):
book.set_title('HTML Extraction Test')
book.set_language('en')
book.add_author('Test Author')
# Chapter that exercises html_extraction features
chapter = epub.EpubHtml(
title='HTML Features',
@ -635,22 +653,22 @@ class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase):
<head><title>HTML Features</title></head>
<body>
<h1>HTML Extraction Test</h1>
<!-- Test paragraph with inline formatting -->
<p>This paragraph has <strong>bold</strong>, <em>italic</em>,
<p>This paragraph has <strong>bold</strong>, <em>italic</em>,
<u>underlined</u>, and <span style="color: #ff0000; font-weight: bold;">styled</span> text.</p>
<!-- Test headings -->
<h2>Second Level Heading</h2>
<h3>Third Level Heading</h3>
<!-- Test lists with styled content -->
<ul>
<li>Plain list item</li>
<li><strong>Bold list item</strong></li>
<li>List item with <em>italic text</em></li>
</ul>
<!-- Test table with styled cells -->
<table>
<tr>
@ -662,19 +680,19 @@ class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase):
<td>Normal text</td>
</tr>
</table>
<!-- Test blockquote -->
<blockquote>
<p>This is a quoted paragraph with <strong>bold text</strong>.</p>
</blockquote>
<!-- Test code block -->
<pre><code>def test_function():
return "Hello, World!"</code></pre>
<!-- Test nested formatting -->
<p>Nested formatting: <strong>bold with <em>italic nested</em> inside</strong>.</p>
<!-- Test color variations -->
<p>
<span style="color: red;">Red text</span>,
@ -684,26 +702,26 @@ class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase):
</body>
</html>
'''
book.add_item(chapter)
book.toc = (epub.Link("html_features.xhtml", "HTML Features", "html"),)
book.add_item(epub.EpubNcx())
book.add_item(epub.EpubNav())
book.spine = ['nav', chapter]
# Write EPUB
epub_path = os.path.join(self.test_dir, 'html_extraction_test.epub')
epub.write_epub(epub_path, book, {})
self.epub_files.append(epub_path)
# Read and analyze
parsed_book = read_epub(epub_path)
chapters = list(parsed_book.chapters)
self.assertEqual(len(chapters), 1)
blocks = list(chapters[0].blocks)
self.assertGreater(len(blocks), 5) # Should have multiple blocks
# Test that we get the expected block types
block_types = [type(block).__name__ for block in blocks]
self.assertIn('Heading', block_types, "Should have heading blocks")
@ -712,29 +730,31 @@ class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase):
self.assertIn('Table', block_types, "Should have table blocks")
self.assertIn('Quote', block_types, "Should have quote blocks")
self.assertIn('CodeBlock', block_types, "Should have code blocks")
# Test styled content was preserved
styled_content_found = False
for block in blocks:
if isinstance(block, Paragraph):
words = list(block.words_iter())
for _, word in words:
if (word.style.weight == FontWeight.BOLD or
if (word.style.weight == FontWeight.BOLD or
word.style.style == FontStyle.ITALIC or
word.style.decoration == TextDecoration.UNDERLINE or
word.style.colour != (0, 0, 0)):
word.style.colour != (0, 0, 0)):
styled_content_found = True
break
if styled_content_found:
break
self.assertTrue(styled_content_found, "Should find styled content in parsed blocks")
self.assertTrue(
styled_content_found,
"Should find styled content in parsed blocks")
# Test specific color parsing
red_text_found = False
green_text_found = False
blue_text_found = False
for block in blocks:
if isinstance(block, (Paragraph, Table)):
if isinstance(block, Paragraph):
@ -746,12 +766,11 @@ class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase):
green_text_found = True
elif word.style.colour == (0, 0, 255): # Blue
blue_text_found = True
# At least one color should be found (depending on implementation)
color_found = red_text_found or green_text_found or blue_text_found
self.assertTrue(color_found, "Should find at least one colored text")
def test_epub_with_image(self):
"""Test that images in EPUB are properly parsed."""
book = epub.EpubBook()
@ -759,20 +778,31 @@ class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase):
book.set_title('Image Test Book')
book.set_language('en')
book.add_author('Test Author')
# Create minimal JPEG data for testing
img_data = b'\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x01\x00H\x00H\x00\x00\xff\xdb\x00C\x00\x08\x06\x06\x07\x06\x05\x08\x07\x07\x07\t\t\x08\n\x0c\x14\r\x0c\x0b\x0b\x0c\x19\x12\x13\x0f\x14\x1d\x1a\x1f\x1e\x1d\x1a\x1c\x1c $.\' ",#\x1c\x1c(7),01444\x1f\'9=82<.342\xff\xc0\x00\x11\x08\x00d\x00d\x01\x01\x11\x00\x02\x11\x01\x03\x11\x01\xff\xc4\x00\x14\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x08\xff\xc4\x00\x14\x10\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xda\x00\x0c\x03\x01\x00\x02\x11\x03\x11\x00\x3f\x00\xaa\xff\xd9'
img_data = (
b'\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x01\x00H\x00H\x00\x00'
b'\xff\xdb\x00C\x00\x08\x06\x06\x07\x06\x05\x08\x07\x07\x07\t\t'
b'\x08\n\x0c\x14\r\x0c\x0b\x0b\x0c\x19\x12\x13\x0f\x14\x1d\x1a'
b'\x1f\x1e\x1d\x1a\x1c\x1c $.\' ",#\x1c\x1c(7),01444\x1f\'9=82<.342'
b'\xff\xc0\x00\x11\x08\x00d\x00d\x01\x01\x11\x00\x02\x11\x01\x03'
b'\x11\x01\xff\xc4\x00\x14\x00\x01\x00\x00\x00\x00\x00\x00\x00'
b'\x00\x00\x00\x00\x00\x00\x00\x00\x08\xff\xc4\x00\x14\x10\x01'
b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
b'\x00\xff\xda\x00\x0c\x03\x01\x00\x02\x11\x03\x11\x00\x3f\x00'
b'\xaa\xff\xd9'
)
# Create an EpubImage item
image_item = epub.EpubImage()
image_item.id = 'test_img'
image_item.file_name = 'images/test_image.jpg'
image_item.media_type = 'image/jpeg'
image_item.content = img_data
# Add image to book
book.add_item(image_item)
# Create a chapter that references the image
chapter = epub.EpubHtml(
title='Image Chapter',
@ -788,36 +818,43 @@ class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase):
<p>Text after the image.</p>
</body>
</html>'''
book.add_item(chapter)
book.toc = (epub.Link("image_chapter.xhtml", "Image Chapter", "img_ch"),)
book.add_item(epub.EpubNcx())
book.add_item(epub.EpubNav())
book.spine = ['nav', chapter]
# Write EPUB
epub_path = os.path.join(self.test_dir, f'test_image_{len(self.epub_files)}.epub')
epub_path = os.path.join(self.test_dir,
f'test_image_{len(self.epub_files)}.epub')
epub.write_epub(epub_path, book, {})
self.epub_files.append(epub_path)
# Read and analyze
parsed_book = read_epub(epub_path)
chapters = list(parsed_book.chapters)
self.assertEqual(len(chapters), 1)
blocks = list(chapters[0].blocks)
self.assertGreater(len(blocks), 0)
# Find blocks by type
heading_blocks = [block for block in blocks if isinstance(block, Heading)]
paragraph_blocks = [block for block in blocks if isinstance(block, Paragraph)]
image_blocks = [block for block in blocks if isinstance(block, Image)]
# Verify we have the expected blocks
self.assertEqual(len(heading_blocks), 1, "Should find exactly one heading block")
self.assertGreaterEqual(len(paragraph_blocks), 2, "Should find at least two paragraph blocks")
self.assertEqual(
len(heading_blocks),
1,
"Should find exactly one heading block")
self.assertGreaterEqual(
len(paragraph_blocks),
2,
"Should find at least two paragraph blocks")
self.assertEqual(len(image_blocks), 1, "Should find exactly one image block")
# Verify image properties
image_block = image_blocks[0]
self.assertEqual(image_block.alt_text, "Test image")

View File

@ -14,14 +14,15 @@ from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration
class TestHTMLParagraph(unittest.TestCase):
"""Test cases for basic paragraph parsing."""
def test_simple(self):
text = "<p>This is a paragraph.</p>"
paragraphs = parse_html_string(text)
self.assertEqual(len(paragraphs), 1)
self.assertEqual(len(paragraphs[0]), 4)
for w1, t1 in zip(paragraphs[0].words_iter(), "This is a paragraph.".split(" ")):
for w1, t1 in zip(paragraphs[0].words_iter(),
"This is a paragraph.".split(" ")):
self.assertEqual(w1[1].text, t1)
def test_multiple(self):
@ -31,26 +32,28 @@ class TestHTMLParagraph(unittest.TestCase):
self.assertEqual(len(paragraphs[0]), 4)
self.assertEqual(len(paragraphs[1]), 4)
for w1, t1 in zip(paragraphs[0].words_iter(), "This is a paragraph.".split(" ")):
for w1, t1 in zip(paragraphs[0].words_iter(),
"This is a paragraph.".split(" ")):
self.assertEqual(w1[1].text, t1)
for w1, t1 in zip(paragraphs[1].words_iter(), "This is another paragraph.".split(" ")):
for w1, t1 in zip(paragraphs[1].words_iter(),
"This is another paragraph.".split(" ")):
self.assertEqual(w1[1].text, t1)
class TestHTMLStyledParagraphs(unittest.TestCase):
"""Test cases for paragraphs with inline styling."""
def test_bold_text(self):
"""Test paragraphs with bold text using <strong> and <b> tags."""
text = "<p>This is <strong>bold text</strong> in a paragraph.</p>"
blocks = parse_html_string(text)
self.assertEqual(len(blocks), 1)
self.assertIsInstance(blocks[0], Paragraph)
words = list(blocks[0].words_iter())
self.assertEqual(len(words), 7) # "This is bold text in a paragraph."
# Check that 'bold' and 'text' words have bold font weight
bold_word = words[2][1] # 'bold'
text_word = words[3][1] # 'text'
@ -58,7 +61,7 @@ class TestHTMLStyledParagraphs(unittest.TestCase):
self.assertEqual(bold_word.style.weight, FontWeight.BOLD)
self.assertEqual(text_word.text, "text")
self.assertEqual(text_word.style.weight, FontWeight.BOLD)
# Check that other words are not bold
normal_word = words[0][1] # 'This'
self.assertEqual(normal_word.text, "This")
@ -70,9 +73,9 @@ class TestHTMLStyledParagraphs(unittest.TestCase):
blocks = parse_html_string(text)
self.assertEqual(len(blocks), 1)
self.assertIsInstance(blocks[0], Paragraph)
words = list(blocks[0].words_iter())
# Check that 'italic' and 'text' words have italic font style
italic_word = words[2][1] # 'italic'
text_word = words[3][1] # 'text'
@ -86,7 +89,7 @@ class TestHTMLStyledParagraphs(unittest.TestCase):
text = "<p>This is <u>underlined text</u> here.</p>"
blocks = parse_html_string(text)
self.assertEqual(len(blocks), 1)
words = list(blocks[0].words_iter())
underlined_word = words[2][1] # 'underlined'
self.assertEqual(underlined_word.style.decoration, TextDecoration.UNDERLINE)
@ -96,50 +99,60 @@ class TestHTMLStyledParagraphs(unittest.TestCase):
text = "<p>This is <s>strikethrough text</s> here.</p>"
blocks = parse_html_string(text)
self.assertEqual(len(blocks), 1)
words = list(blocks[0].words_iter())
strike_word = words[2][1] # 'strikethrough'
self.assertEqual(strike_word.style.decoration, TextDecoration.STRIKETHROUGH)
def test_span_with_inline_styles(self):
"""Test paragraphs with span elements containing inline CSS styles."""
text = '<p>This text is normal, but <span style="color: red; font-weight: bold;">this part is red and bold</span>.</p>'
text = (
'<p>This text is normal, but <span style="color: red; font-weight: bold;">'
'this part is red and bold</span>.</p>'
)
blocks = parse_html_string(text)
self.assertEqual(len(blocks), 1)
self.assertIsInstance(blocks[0], Paragraph)
words = list(blocks[0].words_iter())
# Find the styled words
styled_words = []
for _, word in words:
if word.text in ["this", "part", "is", "red", "and", "bold"]:
if word.style.weight == FontWeight.BOLD:
styled_words.append(word)
self.assertGreater(len(styled_words), 0, "Should have bold words in styled span")
self.assertGreater(
len(styled_words),
0,
"Should have bold words in styled span")
# Check that at least one word has the red color
red_words = [w for w in styled_words if w.style.colour == (255, 0, 0)]
self.assertGreater(len(red_words), 0, "Should have red colored words")
def test_mixed_formatting(self):
"""Test paragraphs with multiple formatting elements combined."""
text = "<p>This paragraph contains <strong>bold</strong>, <em>italic</em>, <span style=\"color: blue;\">blue</span>, and <mark>highlighted</mark> text all together.</p>"
text = (
'<p>This paragraph contains <strong>bold</strong>, <em>italic</em>, '
'<span style="color: blue;">blue</span>, and <mark>highlighted</mark> '
'text all together.</p>'
)
blocks = parse_html_string(text)
self.assertEqual(len(blocks), 1)
self.assertIsInstance(blocks[0], Paragraph)
words = list(blocks[0].words_iter())
# Check for bold word
bold_words = [w for _, w in words if w.style.weight == FontWeight.BOLD]
self.assertGreater(len(bold_words), 0, "Should have bold words")
# Check for italic word
italic_words = [w for _, w in words if w.style.style == FontStyle.ITALIC]
self.assertGreater(len(italic_words), 0, "Should have italic words")
# Check for blue colored word
blue_words = [w for _, w in words if w.style.colour == (0, 0, 255)]
self.assertGreater(len(blue_words), 0, "Should have blue colored words")
@ -149,26 +162,29 @@ class TestHTMLStyledParagraphs(unittest.TestCase):
text = "<p>This has <strong>bold with <em>italic inside</em></strong> formatting.</p>"
blocks = parse_html_string(text)
self.assertEqual(len(blocks), 1)
words = list(blocks[0].words_iter())
# Find words that should be both bold and italic
bold_italic_words = [w for _, w in words
if w.style.weight == FontWeight.BOLD and w.style.style == FontStyle.ITALIC]
self.assertGreater(len(bold_italic_words), 0, "Should have words that are both bold and italic")
bold_italic_words = [w for _, w in words if w.style.weight ==
FontWeight.BOLD and w.style.style == FontStyle.ITALIC]
self.assertGreater(
len(bold_italic_words),
0,
"Should have words that are both bold and italic")
def test_color_variations(self):
"""Test different color formats in CSS."""
text = '<p><span style="color: #ff0000;">Hex red</span> and <span style="color: green;">Named green</span>.</p>'
blocks = parse_html_string(text)
self.assertEqual(len(blocks), 1)
words = list(blocks[0].words_iter())
# Check for hex red color
hex_red_words = [w for _, w in words if w.style.colour == (255, 0, 0)]
self.assertGreater(len(hex_red_words), 0, "Should have hex red colored words")
# Check for named green color
green_words = [w for _, w in words if w.style.colour == (0, 255, 0)]
self.assertGreater(len(green_words), 0, "Should have green colored words")
@ -176,7 +192,7 @@ class TestHTMLStyledParagraphs(unittest.TestCase):
class TestHTMLBlockElements(unittest.TestCase):
"""Test cases for block-level HTML elements."""
def test_body_element(self):
"""Test parsing of body element containing other elements."""
text = "<body><p>Paragraph one.</p><p>Paragraph two.</p></body>"
@ -195,17 +211,20 @@ class TestHTMLBlockElements(unittest.TestCase):
def test_headings(self):
"""Test all heading levels h1-h6."""
text = "<h1>Heading 1</h1><h2>Heading 2</h2><h3>Heading 3</h3><h4>Heading 4</h4><h5>Heading 5</h5><h6>Heading 6</h6>"
text = (
"<h1>Heading 1</h1><h2>Heading 2</h2><h3>Heading 3</h3>"
"<h4>Heading 4</h4><h5>Heading 5</h5><h6>Heading 6</h6>"
)
blocks = parse_html_string(text)
self.assertEqual(len(blocks), 6)
expected_levels = [HeadingLevel.H1, HeadingLevel.H2, HeadingLevel.H3,
HeadingLevel.H4, HeadingLevel.H5, HeadingLevel.H6]
expected_levels = [HeadingLevel.H1, HeadingLevel.H2, HeadingLevel.H3,
HeadingLevel.H4, HeadingLevel.H5, HeadingLevel.H6]
for i, block in enumerate(blocks):
self.assertIsInstance(block, Heading)
self.assertEqual(block.level, expected_levels[i])
words = list(block.words_iter())
self.assertEqual(len(words), 2) # "Heading" and number
self.assertEqual(words[0][1].text, "Heading")
@ -216,7 +235,7 @@ class TestHTMLBlockElements(unittest.TestCase):
blocks = parse_html_string(text)
self.assertEqual(len(blocks), 1)
self.assertIsInstance(blocks[0], Quote)
# Check that the quote contains a paragraph
quote_blocks = list(blocks[0].blocks())
self.assertEqual(len(quote_blocks), 1)
@ -228,7 +247,7 @@ class TestHTMLBlockElements(unittest.TestCase):
blocks = parse_html_string(text)
self.assertEqual(len(blocks), 1)
self.assertIsInstance(blocks[0], CodeBlock)
lines = list(blocks[0].lines())
self.assertGreater(len(lines), 0)
@ -239,7 +258,7 @@ class TestHTMLBlockElements(unittest.TestCase):
self.assertEqual(len(blocks), 1)
self.assertIsInstance(blocks[0], HList)
self.assertEqual(blocks[0].style, ListStyle.UNORDERED)
items = list(blocks[0].items())
self.assertEqual(len(items), 3)
@ -257,10 +276,10 @@ class TestHTMLBlockElements(unittest.TestCase):
blocks = parse_html_string(text)
self.assertEqual(len(blocks), 1)
self.assertIsInstance(blocks[0], HList)
items = list(blocks[0].items())
self.assertEqual(len(items), 3)
# Check second item has bold text
second_item_blocks = list(items[1].blocks())
if second_item_blocks:
@ -306,12 +325,12 @@ class TestHTMLBlockElements(unittest.TestCase):
"""
blocks = parse_html_string(text)
self.assertGreater(len(blocks), 2)
# Should have at least a heading, paragraph, and quote
has_heading = any(isinstance(b, Heading) for b in blocks)
has_paragraph = any(isinstance(b, Paragraph) for b in blocks)
has_quote = any(isinstance(b, Quote) for b in blocks)
self.assertTrue(has_heading, "Should contain a heading")
self.assertTrue(has_paragraph, "Should contain a paragraph")
self.assertTrue(has_quote, "Should contain a quote")
@ -322,7 +341,7 @@ class TestHTMLBlockElements(unittest.TestCase):
blocks = parse_html_string(text)
# Empty elements may not create blocks, which is acceptable behavior
self.assertGreaterEqual(len(blocks), 0)
# Test that empty paragraph with some content does create a block
text_with_content = "<p> </p>" # Contains whitespace
blocks_with_content = parse_html_string(text_with_content)
@ -332,7 +351,7 @@ class TestHTMLBlockElements(unittest.TestCase):
class TestHTMLComplexStructures(unittest.TestCase):
"""Test cases for complex HTML structures combining multiple features."""
def test_article_with_mixed_content(self):
"""Test a realistic article structure with mixed content."""
text = """
@ -350,7 +369,7 @@ class TestHTMLComplexStructures(unittest.TestCase):
"""
blocks = parse_html_string(text)
self.assertGreater(len(blocks), 3)
# Verify we have the expected block types
block_types = [type(b).__name__ for b in blocks]
self.assertIn('Heading', block_types)
@ -383,12 +402,12 @@ class TestHTMLComplexStructures(unittest.TestCase):
class TestHTMLFontRegistryIntegration(unittest.TestCase):
"""Test cases for font registry integration with HTML extraction."""
def setUp(self):
"""Set up test fixtures."""
self.doc = Document("Test Document", "en-US")
self.base_font = Font(font_size=16, colour=(0, 0, 0))
def test_font_registry_creates_fonts(self):
"""Test that HTML parsing with document context creates fonts in registry."""
html_content = """
@ -397,21 +416,21 @@ class TestHTMLFontRegistryIntegration(unittest.TestCase):
<h1>Main Header</h1>
</div>
"""
# Initially empty style registry
initial_style_count = self.doc.get_style_registry().get_style_count()
# Parse HTML with document context
blocks = parse_html_string(html_content, self.base_font, document=self.doc)
# Should have created styles for different formatting
final_style_count = self.doc.get_style_registry().get_style_count()
self.assertGreater(final_style_count, initial_style_count,
"Should have created styles in registry")
self.assertGreater(final_style_count, initial_style_count,
"Should have created styles in registry")
# Should have created blocks
self.assertGreater(len(blocks), 0, "Should have created blocks")
def test_font_registry_reuses_fonts(self):
"""Test that parsing same content reuses existing styles."""
html_content = """
@ -420,23 +439,23 @@ class TestHTMLFontRegistryIntegration(unittest.TestCase):
<h1>Main Header</h1>
</div>
"""
# First parse
blocks1 = parse_html_string(html_content, self.base_font, document=self.doc)
first_parse_style_count = self.doc.get_style_registry().get_style_count()
# Second parse with same content
blocks2 = parse_html_string(html_content, self.base_font, document=self.doc)
second_parse_style_count = self.doc.get_style_registry().get_style_count()
# Style count should not increase on second parse
self.assertEqual(first_parse_style_count, second_parse_style_count,
"Should reuse existing styles instead of creating new ones")
"Should reuse existing styles instead of creating new ones")
# Both parses should create same number of blocks
self.assertEqual(len(blocks1), len(blocks2),
"Should create same structure on both parses")
"Should create same structure on both parses")
def test_font_registry_different_styles_create_different_fonts(self):
"""Test that different styles create different style objects."""
# Create styles with different properties
@ -449,65 +468,71 @@ class TestHTMLFontRegistryIntegration(unittest.TestCase):
style_id3, style3 = self.doc.get_or_create_style(
font_size=14, color=(0, 255, 0), font_weight=FontWeight.BOLD
)
# Should be different style IDs
self.assertNotEqual(style_id1, style_id2, "Different sizes should create different styles")
self.assertNotEqual(style_id1, style_id3, "Different colors should create different styles")
self.assertNotEqual(
style_id1,
style_id2,
"Different sizes should create different styles")
self.assertNotEqual(
style_id1,
style_id3,
"Different colors should create different styles")
self.assertNotEqual(style_id2, style_id3, "All styles should be different")
# Should have multiple styles in registry
self.assertGreaterEqual(self.doc.get_style_registry().get_style_count(), 3)
def test_font_registry_integration_with_html_styles(self):
"""Test that HTML parsing uses style registry for styled content."""
html_content = """
<p>Normal text with <strong>bold</strong> and <em>italic</em> and
<p>Normal text with <strong>bold</strong> and <em>italic</em> and
<span style="color: red;">red text</span>.</p>
"""
# Parse content
blocks = parse_html_string(html_content, self.base_font, document=self.doc)
# Extract all words from the paragraph
paragraph = blocks[0]
words = list(paragraph.words_iter())
# Find words with different styles
normal_words = [w for _, w in words if w.style.weight == FontWeight.NORMAL
and w.style.style == FontStyle.NORMAL]
normal_words = [w for _, w in words if w.style.weight == FontWeight.NORMAL
and w.style.style == FontStyle.NORMAL]
bold_words = [w for _, w in words if w.style.weight == FontWeight.BOLD]
italic_words = [w for _, w in words if w.style.style == FontStyle.ITALIC]
red_words = [w for _, w in words if w.style.colour == (255, 0, 0)]
# Should have words with different styles
self.assertGreater(len(normal_words), 0, "Should have normal words")
self.assertGreater(len(bold_words), 0, "Should have bold words")
self.assertGreater(len(italic_words), 0, "Should have italic words")
self.assertGreater(len(red_words), 0, "Should have red words")
# Style registry should contain multiple styles for different formatting
self.assertGreater(self.doc.get_style_registry().get_style_count(), 1,
"Should have multiple styles for different formatting")
self.assertGreater(self.doc.get_style_registry().get_style_count(), 1,
"Should have multiple styles for different formatting")
def test_font_registry_without_document_context(self):
"""Test that parsing without document context works (fallback behavior)."""
html_content = "<p>This is <strong>bold text</strong>.</p>"
# Get initial style count (should include default style)
initial_style_count = self.doc.get_style_registry().get_style_count()
# Parse without document context
blocks = parse_html_string(html_content, self.base_font)
# Should still create blocks successfully
self.assertEqual(len(blocks), 1)
self.assertIsInstance(blocks[0], Paragraph)
# Should not affect document's style registry
final_style_count = self.doc.get_style_registry().get_style_count()
self.assertEqual(final_style_count, initial_style_count,
"Document style registry should remain unchanged")
self.assertEqual(final_style_count, initial_style_count,
"Document style registry should remain unchanged")
def test_complex_html_font_reuse(self):
"""Test style reuse with complex HTML containing repeated styles."""
html_content = """
@ -518,46 +543,46 @@ class TestHTMLFontRegistryIntegration(unittest.TestCase):
<p>Another paragraph with <strong>bold</strong> text.</p>
</div>
"""
# Parse content
blocks = parse_html_string(html_content, self.base_font, document=self.doc)
style_count_after_parse = self.doc.get_style_registry().get_style_count()
# Parse same content again
blocks2 = parse_html_string(html_content, self.base_font, document=self.doc)
style_count_after_second_parse = self.doc.get_style_registry().get_style_count()
# Style count should not increase on second parse
self.assertEqual(style_count_after_parse, style_count_after_second_parse,
"Styles should be reused for repeated formatting")
"Styles should be reused for repeated formatting")
# Both should create same structure
self.assertEqual(len(blocks), len(blocks2))
def test_font_registry_with_nested_styles(self):
"""Test style registry with nested HTML styles."""
html_content = """
<p>Text with <strong>bold and <em>bold italic</em> nested</strong> styles.</p>
"""
# Parse content
blocks = parse_html_string(html_content, self.base_font, document=self.doc)
# Should create styles for different style combinations
paragraph = blocks[0]
words = list(paragraph.words_iter())
# Find words that are both bold and italic
bold_italic_words = [w for _, w in words
if w.style.weight == FontWeight.BOLD
and w.style.style == FontStyle.ITALIC]
self.assertGreater(len(bold_italic_words), 0,
"Should have words with combined bold+italic style")
bold_italic_words = [w for _, w in words
if w.style.weight == FontWeight.BOLD
and w.style.style == FontStyle.ITALIC]
self.assertGreater(len(bold_italic_words), 0,
"Should have words with combined bold+italic style")
# Should have multiple styles in registry for different combinations
self.assertGreater(self.doc.get_style_registry().get_style_count(), 1,
"Should create separate styles for style combinations")
"Should create separate styles for style combinations")
if __name__ == '__main__':

View File

@ -6,7 +6,7 @@ reusing test patterns from test_html_extraction.py that are known to pass.
"""
import unittest
from bs4 import BeautifulSoup, Tag
from bs4 import BeautifulSoup
from pyWebLayout.io.readers.html_extraction import (
create_base_context,
apply_element_styling,
@ -50,11 +50,11 @@ from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration
class TestUtilityFunctions(unittest.TestCase):
"""Test cases for utility functions."""
def test_create_base_context(self):
"""Test creation of base style context."""
context = create_base_context()
self.assertIsInstance(context, StyleContext)
self.assertIsInstance(context.font, Font)
self.assertIsNone(context.background)
@ -62,113 +62,119 @@ class TestUtilityFunctions(unittest.TestCase):
self.assertEqual(context.css_styles, {})
self.assertEqual(context.element_attributes, {})
self.assertEqual(context.parent_elements, [])
def test_parse_inline_styles_from_existing_tests(self):
"""Test parsing CSS inline styles - adapted from test_span_with_inline_styles."""
# From: '<span style="color: red; font-weight: bold;">this part is red and bold</span>'
style_text = "color: red; font-weight: bold;"
styles = parse_inline_styles(style_text)
expected = {
"color": "red",
"font-weight": "bold"
}
self.assertEqual(styles, expected)
def test_parse_inline_styles_color_variations(self):
"""Test parsing different color formats - adapted from test_color_variations."""
# Test hex color parsing
hex_style = "color: #ff0000;"
styles = parse_inline_styles(hex_style)
self.assertEqual(styles.get("color"), "#ff0000")
# Test named color parsing
# Test named color parsing
named_style = "color: green;"
styles = parse_inline_styles(named_style)
self.assertEqual(styles.get("color"), "green")
def test_apply_element_font_styles_bold_elements(self):
"""Test font style application for bold elements - adapted from test_bold_text."""
base_font = Font()
# Test <strong> tag - from "<strong>bold text</strong>"
font = apply_element_font_styles(base_font, "strong", {})
self.assertEqual(font.weight, FontWeight.BOLD)
# Test <b> tag
font = apply_element_font_styles(base_font, "b", {})
self.assertEqual(font.weight, FontWeight.BOLD)
def test_apply_element_font_styles_italic_elements(self):
"""Test font style application for italic elements - adapted from test_italic_text."""
base_font = Font()
# Test <em> tag - from "<em>italic text</em>"
font = apply_element_font_styles(base_font, "em", {})
self.assertEqual(font.style, FontStyle.ITALIC)
# Test <i> tag
font = apply_element_font_styles(base_font, "i", {})
self.assertEqual(font.style, FontStyle.ITALIC)
def test_apply_element_font_styles_decoration_elements(self):
"""Test font decoration - adapted from test_underlined_text and test_strikethrough_text."""
base_font = Font()
# Test <u> tag - from "<u>underlined text</u>"
font = apply_element_font_styles(base_font, "u", {})
self.assertEqual(font.decoration, TextDecoration.UNDERLINE)
# Test <s> tag - from "<s>strikethrough text</s>"
font = apply_element_font_styles(base_font, "s", {})
self.assertEqual(font.decoration, TextDecoration.STRIKETHROUGH)
# Test <del> tag
font = apply_element_font_styles(base_font, "del", {})
self.assertEqual(font.decoration, TextDecoration.STRIKETHROUGH)
def test_apply_element_font_styles_headings(self):
"""Test heading font styles - adapted from test_headings."""
base_font = Font()
# Test heading sizes and weights - from test_headings which tests h1-h6
headings = [("h1", 24), ("h2", 20), ("h3", 18), ("h4", 16), ("h5", 14), ("h6", 12)]
headings = [("h1", 24), ("h2", 20), ("h3", 18),
("h4", 16), ("h5", 14), ("h6", 12)]
for tag, expected_size in headings:
font = apply_element_font_styles(base_font, tag, {})
self.assertEqual(font.font_size, expected_size, f"Size mismatch for {tag}")
self.assertEqual(font.weight, FontWeight.BOLD, f"Weight should be bold for {tag}")
self.assertEqual(
font.weight,
FontWeight.BOLD,
f"Weight should be bold for {tag}")
def test_apply_element_font_styles_color_parsing(self):
"""Test color parsing - adapted from test_color_variations."""
base_font = Font()
# Test named colors - from '<span style="color: green;">Named green</span>'
css_styles = {"color": "green"}
font = apply_element_font_styles(base_font, "span", css_styles)
self.assertEqual(font.colour, (0, 255, 0))
# Test hex colors - from '<span style="color: #ff0000;">Hex red</span>'
css_styles = {"color": "#ff0000"}
font = apply_element_font_styles(base_font, "span", css_styles)
self.assertEqual(font.colour, (255, 0, 0))
def test_apply_element_styling_with_classes_and_styles(self):
"""Test complete element styling - adapted from test_span_with_inline_styles."""
# From: '<span style="color: red; font-weight: bold;">this part is red and bold</span>'
soup = BeautifulSoup('<span class="highlight" style="color: red; font-weight: bold;">text</span>', 'html.parser')
soup = BeautifulSoup(
'<span class="highlight" style="color: red; font-weight: bold;">text</span>',
'html.parser')
element = soup.find('span')
base_context = create_base_context()
styled_context = apply_element_styling(base_context, element)
# Check CSS classes
self.assertIn("highlight", styled_context.css_classes)
# Check CSS styles
self.assertEqual(styled_context.css_styles.get("color"), "red")
self.assertEqual(styled_context.css_styles.get("font-weight"), "bold")
# Check font styling
self.assertEqual(styled_context.font.colour, (255, 0, 0))
self.assertEqual(styled_context.font.weight, FontWeight.BOLD)
@ -176,170 +182,185 @@ class TestUtilityFunctions(unittest.TestCase):
class TestExtractTextContent(unittest.TestCase):
"""Test cases for text content extraction."""
def setUp(self):
"""Set up test fixtures."""
self.base_context = create_base_context()
def test_extract_simple_text(self):
"""Test extracting simple text - adapted from test_simple."""
# From: "<p>This is a paragraph.</p>"
soup = BeautifulSoup('<p>This is a paragraph.</p>', 'html.parser')
element = soup.find('p')
words = extract_text_content(element, self.base_context)
# Should match the expected word count from original test
self.assertEqual(len(words), 4) # "This", "is", "a", "paragraph."
self.assertIsInstance(words[0], Word)
self.assertEqual(words[0].text, "This")
def test_extract_styled_text_bold(self):
"""Test extracting bold styled text - adapted from test_bold_text."""
# From: "<p>This is <strong>bold text</strong> in a paragraph.</p>"
soup = BeautifulSoup('<span>This is <strong>bold text</strong> in a paragraph.</span>', 'html.parser')
soup = BeautifulSoup(
'<span>This is <strong>bold text</strong> in a paragraph.</span>',
'html.parser')
element = soup.find('span')
words = extract_text_content(element, self.base_context)
# Find the bold words
bold_words = [w for w in words if w.style.weight == FontWeight.BOLD]
self.assertGreater(len(bold_words), 0, "Should have bold words")
# Check specific words are bold (from original test expectations)
bold_word_texts = [w.text for w in bold_words]
self.assertIn("bold", bold_word_texts)
self.assertIn("text", bold_word_texts)
def test_extract_nested_formatting(self):
"""Test nested formatting - adapted from test_nested_formatting."""
# From: "<p>This has <strong>bold with <em>italic inside</em></strong> formatting.</p>"
soup = BeautifulSoup('<span>This has <strong>bold with <em>italic inside</em></strong> formatting.</span>', 'html.parser')
soup = BeautifulSoup(
'<span>This has <strong>bold with <em>italic inside</em></strong> formatting.</span>',
'html.parser')
element = soup.find('span')
words = extract_text_content(element, self.base_context)
# Find words that should be both bold and italic
bold_italic_words = [w for w in words
if w.style.weight == FontWeight.BOLD and w.style.style == FontStyle.ITALIC]
self.assertGreater(len(bold_italic_words), 0, "Should have words that are both bold and italic")
bold_italic_words = [w for w in words if w.style.weight ==
FontWeight.BOLD and w.style.style == FontStyle.ITALIC]
self.assertGreater(
len(bold_italic_words),
0,
"Should have words that are both bold and italic")
class TestHandlerFunctions(unittest.TestCase):
"""Test cases for HTML element handler functions using known working patterns."""
def setUp(self):
"""Set up test fixtures."""
self.base_context = create_base_context()
def test_paragraph_handler_simple(self):
"""Test paragraph handler - adapted from test_simple."""
# From: "<p>This is a paragraph.</p>"
soup = BeautifulSoup('<p>This is a paragraph.</p>', 'html.parser')
element = soup.find('p')
result = paragraph_handler(element, self.base_context)
self.assertIsInstance(result, Paragraph)
# Should match original test expectations
self.assertEqual(len(result), 4) # 4 words
words = list(result.words_iter())
expected_texts = ["This", "is", "a", "paragraph."]
for i, expected_text in enumerate(expected_texts):
self.assertEqual(words[i][1].text, expected_text)
def test_heading_handler_all_levels(self):
"""Test heading handler - adapted from test_headings."""
# From: "<h1>Heading 1</h1><h2>Heading 2</h2>..."
expected_levels = [HeadingLevel.H1, HeadingLevel.H2, HeadingLevel.H3,
HeadingLevel.H4, HeadingLevel.H5, HeadingLevel.H6]
# From: "<h1>Heading 1</h1><h2>Heading 2</h2>..."
expected_levels = [HeadingLevel.H1, HeadingLevel.H2, HeadingLevel.H3,
HeadingLevel.H4, HeadingLevel.H5, HeadingLevel.H6]
for i, expected_level in enumerate(expected_levels, 1):
tag = f"h{i}"
soup = BeautifulSoup(f'<{tag}>Heading {i}</{tag}>', 'html.parser')
element = soup.find(tag)
result = heading_handler(element, self.base_context)
self.assertIsInstance(result, Heading)
self.assertEqual(result.level, expected_level)
# Should match original test word expectations
words = list(result.words_iter())
self.assertEqual(len(words), 2) # "Heading" and number
self.assertEqual(words[0][1].text, "Heading")
def test_blockquote_handler(self):
"""Test blockquote handler - adapted from test_blockquote."""
# From: "<blockquote><p>This is a quoted paragraph.</p></blockquote>"
soup = BeautifulSoup('<blockquote><p>This is a quoted paragraph.</p></blockquote>', 'html.parser')
soup = BeautifulSoup(
'<blockquote><p>This is a quoted paragraph.</p></blockquote>',
'html.parser')
element = soup.find('blockquote')
result = blockquote_handler(element, self.base_context)
self.assertIsInstance(result, Quote)
# Check that the quote contains a paragraph (from original test)
quote_blocks = list(result.blocks())
self.assertEqual(len(quote_blocks), 1)
self.assertIsInstance(quote_blocks[0], Paragraph)
def test_preformatted_handler(self):
"""Test preformatted handler - adapted from test_preformatted_code."""
# From: "<pre><code>function hello() {\n console.log('Hello');\n}</code></pre>"
soup = BeautifulSoup('<pre><code>function hello() {\n console.log(\'Hello\');\n}</code></pre>', 'html.parser')
soup = BeautifulSoup(
'<pre><code>function hello() {\n console.log(\'Hello\');\n}</code></pre>',
'html.parser')
element = soup.find('pre')
result = preformatted_handler(element, self.base_context)
self.assertIsInstance(result, CodeBlock)
# Should have lines (from original test expectation)
lines = list(result.lines())
self.assertGreater(len(lines), 0)
def test_unordered_list_handler(self):
"""Test unordered list handler - adapted from test_unordered_list."""
# From: "<ul><li>First item</li><li>Second item</li><li>Third item</li></ul>"
soup = BeautifulSoup('<ul><li>First item</li><li>Second item</li><li>Third item</li></ul>', 'html.parser')
soup = BeautifulSoup(
'<ul><li>First item</li><li>Second item</li><li>Third item</li></ul>',
'html.parser')
element = soup.find('ul')
result = unordered_list_handler(element, self.base_context)
self.assertIsInstance(result, HList)
self.assertEqual(result.style, ListStyle.UNORDERED)
# Should match original test expectations
items = list(result.items())
self.assertEqual(len(items), 3)
def test_ordered_list_handler(self):
"""Test ordered list handler - adapted from test_ordered_list."""
# From: "<ol><li>First item</li><li>Second item</li><li>Third item</li></ol>"
soup = BeautifulSoup('<ol><li>First item</li><li>Second item</li><li>Third item</li></ol>', 'html.parser')
soup = BeautifulSoup(
'<ol><li>First item</li><li>Second item</li><li>Third item</li></ol>',
'html.parser')
element = soup.find('ol')
result = ordered_list_handler(element, self.base_context)
self.assertIsInstance(result, HList)
self.assertEqual(result.style, ListStyle.ORDERED)
# Should match original test expectations
items = list(result.items())
self.assertEqual(len(items), 3) # "First item", "Second item", "Third item"
def test_list_item_handler(self):
"""Test list item handler."""
soup = BeautifulSoup('<li>List item content</li>', 'html.parser')
element = soup.find('li')
result = list_item_handler(element, self.base_context)
self.assertIsInstance(result, ListItem)
blocks = list(result.blocks())
self.assertGreater(len(blocks), 0)
def test_table_handler(self):
"""Test table handler - adapted from test_table_basic."""
# From test_table_basic structure
@ -356,76 +377,80 @@ class TestHandlerFunctions(unittest.TestCase):
</table>
''', 'html.parser')
element = soup.find('table')
result = table_handler(element, self.base_context)
self.assertIsInstance(result, Table)
def test_table_row_handler(self):
"""Test table row handler."""
soup = BeautifulSoup('<tr><td>Cell 1</td><td>Cell 2</td></tr>', 'html.parser')
element = soup.find('tr')
result = table_row_handler(element, self.base_context)
self.assertIsInstance(result, TableRow)
def test_table_cell_handler(self):
"""Test table cell handler."""
soup = BeautifulSoup('<td>Cell content</td>', 'html.parser')
element = soup.find('td')
# Apply styling to get attributes
styled_context = apply_element_styling(self.base_context, element)
result = table_cell_handler(element, styled_context)
self.assertIsInstance(result, TableCell)
self.assertEqual(result.is_header, False)
def test_table_header_cell_handler(self):
"""Test table header cell handler."""
soup = BeautifulSoup('<th>Header content</th>', 'html.parser')
element = soup.find('th')
# Apply styling to get attributes
styled_context = apply_element_styling(self.base_context, element)
result = table_header_cell_handler(element, styled_context)
self.assertIsInstance(result, TableCell)
self.assertEqual(result.is_header, True)
def test_horizontal_rule_handler(self):
"""Test horizontal rule handler."""
soup = BeautifulSoup('<hr>', 'html.parser')
element = soup.find('hr')
result = horizontal_rule_handler(element, self.base_context)
self.assertIsInstance(result, HorizontalRule)
def test_image_handler(self):
"""Test image handler."""
soup = BeautifulSoup('<img src="test.jpg" alt="Test image" width="100" height="50">', 'html.parser')
soup = BeautifulSoup(
'<img src="test.jpg" alt="Test image" width="100" height="50">',
'html.parser')
element = soup.find('img')
# Need to apply styling first to get attributes
styled_context = apply_element_styling(self.base_context, element)
result = image_handler(element, styled_context)
self.assertIsInstance(result, Image)
self.assertEqual(result.source, "test.jpg")
self.assertEqual(result.alt_text, "Test image")
self.assertEqual(result.width, 100)
self.assertEqual(result.height, 50)
def test_div_handler_container(self):
"""Test div handler - adapted from test_div_container."""
# From: "<div><p>First paragraph.</p><p>Second paragraph.</p></div>"
soup = BeautifulSoup('<div><p>First paragraph.</p><p>Second paragraph.</p></div>', 'html.parser')
soup = BeautifulSoup(
'<div><p>First paragraph.</p><p>Second paragraph.</p></div>',
'html.parser')
element = soup.find('div')
result = div_handler(element, self.base_context)
self.assertIsInstance(result, list)
# Should match original test expectations
self.assertEqual(len(result), 2)
@ -435,23 +460,25 @@ class TestHandlerFunctions(unittest.TestCase):
class TestStyledContentHandling(unittest.TestCase):
"""Test styled content handling using patterns from existing tests."""
def setUp(self):
"""Set up test fixtures."""
self.base_context = create_base_context()
def test_paragraph_with_bold_content(self):
"""Test paragraph with bold content - adapted from test_bold_text."""
# From: "<p>This is <strong>bold text</strong> in a paragraph.</p>"
soup = BeautifulSoup('<p>This is <strong>bold text</strong> in a paragraph.</p>', 'html.parser')
soup = BeautifulSoup(
'<p>This is <strong>bold text</strong> in a paragraph.</p>',
'html.parser')
element = soup.find('p')
result = paragraph_handler(element, self.base_context)
self.assertIsInstance(result, Paragraph)
words = list(result.words_iter())
self.assertEqual(len(words), 7) # From original test expectation
# Check that 'bold' and 'text' words have bold font weight (from original test)
bold_word = words[2][1] # 'bold'
text_word = words[3][1] # 'text'
@ -459,31 +486,35 @@ class TestStyledContentHandling(unittest.TestCase):
self.assertEqual(bold_word.style.weight, FontWeight.BOLD)
self.assertEqual(text_word.text, "text")
self.assertEqual(text_word.style.weight, FontWeight.BOLD)
# Check that other words are not bold (from original test)
normal_word = words[0][1] # 'This'
self.assertEqual(normal_word.text, "This")
self.assertNotEqual(normal_word.style.weight, FontWeight.BOLD)
def test_paragraph_with_mixed_formatting(self):
"""Test mixed formatting - adapted from test_mixed_formatting."""
# From: "<p>This paragraph contains <strong>bold</strong>, <em>italic</em>, <span style=\"color: blue;\">blue</span>..."
soup = BeautifulSoup('<p>This paragraph contains <strong>bold</strong>, <em>italic</em>, <span style="color: blue;">blue</span> text.</p>', 'html.parser')
# From: "<p>This paragraph contains <strong>bold</strong>, <em>italic</em>..."
html_str = (
'<p>This paragraph contains <strong>bold</strong>, <em>italic</em>, '
'<span style="color: blue;">blue</span> text.</p>'
)
soup = BeautifulSoup(html_str, 'html.parser')
element = soup.find('p')
result = paragraph_handler(element, self.base_context)
self.assertIsInstance(result, Paragraph)
words = list(result.words_iter())
# Check for bold word (from original test pattern)
bold_words = [w for _, w in words if w.style.weight == FontWeight.BOLD]
self.assertGreater(len(bold_words), 0, "Should have bold words")
# Check for italic word (from original test pattern)
italic_words = [w for _, w in words if w.style.style == FontStyle.ITALIC]
self.assertGreater(len(italic_words), 0, "Should have italic words")
# Check for blue colored word (from original test pattern)
blue_words = [w for _, w in words if w.style.colour == (0, 0, 255)]
self.assertGreater(len(blue_words), 0, "Should have blue colored words")

View File

@ -18,97 +18,115 @@ class TestHTMLFileLoader(unittest.TestCase):
def test_load_html_file(self):
"""Test loading and parsing an HTML file from disk."""
# Path to the test HTML file
html_file_path = os.path.join("tests", "data", "Kimi Räikkönen - Wikipedia.html")
html_file_path = os.path.join(
"tests", "data", "Kimi Räikkönen - Wikipedia.html")
# Verify the test file exists
self.assertTrue(os.path.exists(html_file_path), f"Test HTML file not found: {html_file_path}")
self.assertTrue(
os.path.exists(html_file_path),
f"Test HTML file not found: {html_file_path}")
# Read the HTML file
with open(html_file_path, 'r', encoding='utf-8') as file:
html_content = file.read()
# Verify we got some content
self.assertGreater(len(html_content), 0, "HTML file should not be empty")
# Parse the HTML content using the html_extraction module
try:
blocks = parse_html_string(html_content)
except Exception as e:
self.fail(f"Failed to parse HTML file: {e}")
# Verify we got some blocks
self.assertIsInstance(blocks, list, "parse_html_string should return a list")
self.assertGreater(len(blocks), 0, "Should extract at least one block from the HTML file")
self.assertGreater(
len(blocks),
0,
"Should extract at least one block from the HTML file")
# Verify all returned items are Block instances
for i, block in enumerate(blocks):
self.assertIsInstance(block, Block, f"Item {i} should be a Block instance, got {type(block)}")
self.assertIsInstance(
block,
Block,
f"Item {i} should be a Block instance, got {
type(block)}")
print(f"Successfully loaded and parsed HTML file with {len(blocks)} blocks")
def test_load_html_file_with_custom_font(self):
"""Test loading HTML file with a custom base font."""
html_file_path = os.path.join("tests", "data", "Kimi Räikkönen - Wikipedia.html")
html_file_path = os.path.join(
"tests", "data", "Kimi Räikkönen - Wikipedia.html")
# Skip if file doesn't exist
if not os.path.exists(html_file_path):
self.skipTest(f"Test HTML file not found: {html_file_path}")
# Create a custom font
custom_font = Font(font_size=14, colour=(100, 100, 100))
# Read and parse with custom font
with open(html_file_path, 'r', encoding='utf-8') as file:
html_content = file.read()
blocks = parse_html_string(html_content, base_font=custom_font)
# Verify we got blocks
self.assertGreater(len(blocks), 0, "Should extract blocks with custom font")
print(f"Successfully parsed HTML file with custom font, got {len(blocks)} blocks")
print(
f"Successfully parsed HTML file with custom font, got {
len(blocks)} blocks")
def test_load_html_file_content_types(self):
"""Test that the loaded HTML file contains expected content types."""
html_file_path = os.path.join("tests", "data", "Kimi Räikkönen - Wikipedia.html")
html_file_path = os.path.join(
"tests", "data", "Kimi Räikkönen - Wikipedia.html")
# Skip if file doesn't exist
if not os.path.exists(html_file_path):
self.skipTest(f"Test HTML file not found: {html_file_path}")
with open(html_file_path, 'r', encoding='utf-8') as file:
html_content = file.read()
blocks = parse_html_string(html_content)
# Check that we have different types of blocks
block_type_names = [type(block).__name__ for block in blocks]
unique_types = set(block_type_names)
# A Wikipedia page should contain multiple types of content
self.assertGreater(len(unique_types), 1, "Should have multiple types of blocks in Wikipedia page")
self.assertGreater(
len(unique_types),
1,
"Should have multiple types of blocks in Wikipedia page")
print(f"Found block types: {sorted(unique_types)}")
def test_html_file_size_handling(self):
"""Test that large HTML files can be handled gracefully."""
html_file_path = os.path.join("tests", "data", "Kimi Räikkönen - Wikipedia.html")
html_file_path = os.path.join(
"tests", "data", "Kimi Räikkönen - Wikipedia.html")
# Skip if file doesn't exist
if not os.path.exists(html_file_path):
self.skipTest(f"Test HTML file not found: {html_file_path}")
# Get file size
file_size = os.path.getsize(html_file_path)
print(f"HTML file size: {file_size} bytes")
# Read and parse
with open(html_file_path, 'r', encoding='utf-8') as file:
html_content = file.read()
# This should not raise an exception even for large files
blocks = parse_html_string(html_content)
# Basic verification
self.assertIsInstance(blocks, list)
print(f"Successfully processed {file_size} byte file into {len(blocks)} blocks")

View File

@ -112,13 +112,14 @@ class TestHTMLLinkEndToEnd(unittest.TestCase):
rendered = page.render()
self.assertIsNotNone(rendered)
print(f"\nSettings overlay test:")
print("\nSettings overlay test:")
print(f" Found {len(all_linked_words)} linked words")
print(f" Actions: {actions}")
print(f" Rendered: {rendered.size}")
# The links are successfully created and rendered!
# In a real application, query_point would be used to detect clicks on these links
# In a real application, query_point would be used to detect clicks on
# these links
def test_link_metadata_preserved(self):
"""Test that link metadata (title, type) is preserved through the workflow."""

View File

@ -155,8 +155,11 @@ class TestHTMLLinkInteractivity(unittest.TestCase):
html = '''
<div>
<h2 style="text-align: center; font-size: 18px; font-weight: bold; margin: 10px 0;">Settings</h2>
<p style="padding: 15px; margin: 5px 0; background-color: #dc3545; text-align: center; border-radius: 5px;">
<a href="action:back_to_library" style="text-decoration: none; color: white; font-weight: bold; font-size: 14px;"> Back to Library</a>
<p style="padding: 15px; margin: 5px 0; background-color: #dc3545; text-align: center;
border-radius: 5px;">
<a href="action:back_to_library"
style="text-decoration: none; color: white; font-weight: bold; font-size: 14px;">
Back to Library</a>
</p>
<p style="padding: 10px; margin: 5px 0; background-color: #f8f9fa; border-radius: 5px;">
<span style="font-weight: bold;">Font Size: 100%</span><br>
@ -177,23 +180,26 @@ class TestHTMLLinkInteractivity(unittest.TestCase):
all_linked_words.append(word)
# Verify we found the expected links
self.assertGreater(len(all_linked_words), 0, "Should find LinkedWords in settings HTML")
self.assertGreater(
len(all_linked_words),
0,
"Should find LinkedWords in settings HTML")
# Check for specific link targets
link_targets = {word.location for word in all_linked_words}
self.assertIn("action:back_to_library", link_targets,
"Should find 'Back to Library' link")
"Should find 'Back to Library' link")
self.assertIn("setting:font_decrease", link_targets,
"Should find font decrease link")
"Should find font decrease link")
self.assertIn("setting:font_increase", link_targets,
"Should find font increase link")
"Should find font increase link")
# Verify the link texts
back_to_library_words = [w for w in all_linked_words
if w.location == "action:back_to_library"]
if w.location == "action:back_to_library"]
self.assertGreater(len(back_to_library_words), 0,
"Should have words linked to back_to_library action")
"Should have words linked to back_to_library action")
# Print debug info
print(f"\nFound {len(all_linked_words)} linked words:")

View File

@ -7,173 +7,172 @@ from bs4 import BeautifulSoup
from pyWebLayout.io.readers.html_extraction import (
parse_html_string,
extract_text_content,
create_base_context,
apply_element_styling
create_base_context
)
from pyWebLayout.abstract.inline import LinkedWord
from pyWebLayout.abstract.functional import LinkType
from pyWebLayout.abstract.block import Paragraph
from pyWebLayout.style import Font
class TestHTMLLinkExtraction(unittest.TestCase):
"""Test cases for HTML hyperlink extraction."""
def setUp(self):
"""Set up test fixtures."""
self.base_context = create_base_context()
def test_simple_external_link(self):
"""Test extracting a simple external link."""
html = '<p>Visit <a href="https://example.com">this site</a> for more.</p>'
blocks = parse_html_string(html)
self.assertEqual(len(blocks), 1)
self.assertIsInstance(blocks[0], Paragraph)
paragraph = blocks[0]
words = list(paragraph.words)
# Should have: "Visit", "this", "site", "for", "more."
self.assertEqual(len(words), 5)
# Check that "this" and "site" are LinkedWords
self.assertIsInstance(words[1], LinkedWord)
self.assertIsInstance(words[2], LinkedWord)
# Check link properties
self.assertEqual(words[1].location, "https://example.com")
self.assertEqual(words[1].link_type, LinkType.EXTERNAL)
self.assertEqual(words[2].location, "https://example.com")
self.assertEqual(words[2].link_type, LinkType.EXTERNAL)
def test_internal_link(self):
"""Test extracting an internal anchor link."""
html = '<p>Go to <a href="#section2">section 2</a> below.</p>'
blocks = parse_html_string(html)
paragraph = blocks[0]
words = list(paragraph.words)
# Find LinkedWords
linked_words = [w for w in words if isinstance(w, LinkedWord)]
self.assertEqual(len(linked_words), 2) # "section" and "2"
# Check they're internal links
for word in linked_words:
self.assertEqual(word.link_type, LinkType.INTERNAL)
self.assertEqual(word.location, "#section2")
def test_multi_word_link(self):
"""Test that multi-word links create separate LinkedWords."""
html = '<p><a href="/next">click here for next page</a></p>'
blocks = parse_html_string(html)
paragraph = blocks[0]
words = list(paragraph.words)
# All words should be LinkedWords
self.assertEqual(len(words), 5)
for word in words:
self.assertIsInstance(word, LinkedWord)
self.assertEqual(word.location, "/next")
self.assertEqual(word.link_type, LinkType.INTERNAL)
def test_link_with_title(self):
"""Test extracting link with title attribute."""
html = '<p><a href="https://example.com" title="Visit Example">click</a></p>'
blocks = parse_html_string(html)
paragraph = blocks[0]
words = list(paragraph.words)
self.assertEqual(len(words), 1)
self.assertIsInstance(words[0], LinkedWord)
self.assertEqual(words[0].link_title, "Visit Example")
def test_mixed_linked_and_normal_text(self):
"""Test paragraph with both linked and normal text."""
html = '<p>Some <a href="/page">linked text</a> and normal text.</p>'
blocks = parse_html_string(html)
paragraph = blocks[0]
words = list(paragraph.words)
# "Some" - normal
# "linked" - LinkedWord
# "text" - LinkedWord
# "text" - LinkedWord
# "and" - normal
# "normal" - normal
# "text." - normal
self.assertNotIsInstance(words[0], LinkedWord) # "Some"
self.assertIsInstance(words[1], LinkedWord) # "linked"
self.assertIsInstance(words[2], LinkedWord) # "text"
self.assertNotIsInstance(words[3], LinkedWord) # "and"
def test_link_without_href(self):
"""Test that <a> without href is treated as normal text."""
html = '<p><a>not a link</a></p>'
blocks = parse_html_string(html)
paragraph = blocks[0]
words = list(paragraph.words)
# Should be regular Words, not LinkedWords
for word in words:
self.assertNotIsInstance(word, LinkedWord)
def test_javascript_link(self):
"""Test that javascript: links are detected as API type."""
html = '<p><a href="javascript:alert()">click</a></p>'
blocks = parse_html_string(html)
paragraph = blocks[0]
words = list(paragraph.words)
self.assertIsInstance(words[0], LinkedWord)
self.assertEqual(words[0].link_type, LinkType.API)
def test_nested_formatting_in_link(self):
"""Test link with nested formatting."""
html = '<p><a href="/page">text with <strong>bold</strong> word</a></p>'
blocks = parse_html_string(html)
paragraph = blocks[0]
words = list(paragraph.words)
# All should be LinkedWords regardless of formatting
for word in words:
self.assertIsInstance(word, LinkedWord)
self.assertEqual(word.location, "/page")
def test_multiple_links_in_paragraph(self):
"""Test paragraph with multiple separate links."""
html = '<p><a href="/page1">first</a> and <a href="/page2">second</a> link</p>'
blocks = parse_html_string(html)
paragraph = blocks[0]
words = list(paragraph.words)
# Find LinkedWords and their locations
linked_words = [(w.text, w.location) for w in words if isinstance(w, LinkedWord)]
linked_words = [(w.text, w.location)
for w in words if isinstance(w, LinkedWord)]
# Should have "first" linked to /page1 and "second" linked to /page2
self.assertIn(("first", "/page1"), linked_words)
self.assertIn(("second", "/page2"), linked_words)
def test_extract_text_content_with_links(self):
"""Test extract_text_content directly with link elements."""
html = '<span>Visit <a href="https://example.com">our site</a> today</span>'
soup = BeautifulSoup(html, 'html.parser')
element = soup.find('span')
context = create_base_context()
words = extract_text_content(element, context)
# Should have: "Visit", "our", "site", "today"
self.assertEqual(len(words), 4)
# Check types
self.assertNotIsInstance(words[0], LinkedWord) # "Visit"
self.assertIsInstance(words[1], LinkedWord) # "our"

View File

@ -16,7 +16,7 @@ from pyWebLayout.layout.ereader_layout import (
FontScaler,
BidirectionalLayouter
)
from pyWebLayout.abstract.block import Paragraph, Heading, HeadingLevel, Table, HList
from pyWebLayout.abstract.block import Paragraph, Heading, HeadingLevel
from pyWebLayout.abstract.inline import Word
from pyWebLayout.style import Font
from pyWebLayout.style.page_style import PageStyle
@ -210,7 +210,7 @@ class TestRenderingPosition:
assert pos != "not a position"
assert pos != 42
assert pos != None
assert pos is not None
def test_hashability(self):
"""Test that RenderingPosition is hashable and can be used in sets/dicts."""
@ -594,7 +594,6 @@ class TestBidirectionalLayouter:
# Larger font should estimate fewer blocks
assert est_large.block_index >= est_normal.block_index
def test_scale_block_fonts_paragraph(self, sample_font):
"""Test scaling fonts in a paragraph block."""
layouter = BidirectionalLayouter([], PageStyle())
@ -610,7 +609,12 @@ class TestBidirectionalLayouter:
assert scaled != paragraph
# Check that words were scaled (words is a list, not a method)
words = scaled.words if hasattr(scaled, 'words') and isinstance(scaled.words, list) else list(scaled.words_iter())
words = scaled.words if hasattr(
scaled,
'words') and isinstance(
scaled.words,
list) else list(
scaled.words_iter())
assert len(words) >= 2
def test_scale_block_fonts_heading(self, sample_font):
@ -639,7 +643,8 @@ class TestBidirectionalLayouter:
# Use a simple block (not Paragraph, Heading, Table, or HList)
unknown_block = Block(BlockType.HORIZONTAL_RULE)
success, new_pos = layouter._layout_block_on_page(unknown_block, page, position, 1.0)
success, new_pos = layouter._layout_block_on_page(
unknown_block, page, position, 1.0)
# Should skip and move to next block
assert success is True
@ -682,7 +687,10 @@ class TestBidirectionalLayouter:
assert new_pos.block_index == 1
assert new_pos.list_item_index == 0
def test_render_page_forward_simple(self, sample_blocks_with_headings, sample_page_style):
def test_render_page_forward_simple(
self,
sample_blocks_with_headings,
sample_page_style):
"""Test forward page rendering with simple blocks."""
layouter = BidirectionalLayouter(
sample_blocks_with_headings,
@ -700,7 +708,8 @@ class TestBidirectionalLayouter:
# Position should advance
assert next_pos.block_index >= position.block_index
def test_render_page_forward_with_font_scale(self, sample_blocks_with_headings, sample_page_style):
def test_render_page_forward_with_font_scale(
self, sample_blocks_with_headings, sample_page_style):
"""Test forward rendering with font scaling."""
layouter = BidirectionalLayouter(
sample_blocks_with_headings,
@ -720,7 +729,10 @@ class TestBidirectionalLayouter:
assert page1 is not None
assert page2 is not None
def test_render_page_forward_at_end(self, sample_blocks_with_headings, sample_page_style):
def test_render_page_forward_at_end(
self,
sample_blocks_with_headings,
sample_page_style):
"""Test forward rendering at end of document."""
layouter = BidirectionalLayouter(
sample_blocks_with_headings,
@ -735,7 +747,8 @@ class TestBidirectionalLayouter:
# Should still render a page
assert page is not None
def test_render_page_forward_beyond_end(self, sample_blocks_with_headings, sample_page_style):
def test_render_page_forward_beyond_end(
self, sample_blocks_with_headings, sample_page_style):
"""Test forward rendering beyond document end."""
layouter = BidirectionalLayouter(
sample_blocks_with_headings,
@ -750,7 +763,10 @@ class TestBidirectionalLayouter:
# Should handle gracefully
assert page is not None
def test_render_page_backward_simple(self, sample_blocks_with_headings, sample_page_style):
def test_render_page_backward_simple(
self,
sample_blocks_with_headings,
sample_page_style):
"""Test backward page rendering."""
layouter = BidirectionalLayouter(
sample_blocks_with_headings,
@ -776,7 +792,8 @@ class TestBidirectionalLayouter:
target_end = RenderingPosition(block_index=10)
actual_end = RenderingPosition(block_index=12) # Overshot
adjusted = layouter._adjust_start_estimate(current_start, target_end, actual_end)
adjusted = layouter._adjust_start_estimate(
current_start, target_end, actual_end)
# Should move start forward (increase block_index)
assert adjusted.block_index > current_start.block_index
@ -789,7 +806,8 @@ class TestBidirectionalLayouter:
target_end = RenderingPosition(block_index=10)
actual_end = RenderingPosition(block_index=8) # Undershot
adjusted = layouter._adjust_start_estimate(current_start, target_end, actual_end)
adjusted = layouter._adjust_start_estimate(
current_start, target_end, actual_end)
# Should move start backward (decrease block_index)
assert adjusted.block_index <= current_start.block_index
@ -802,12 +820,14 @@ class TestBidirectionalLayouter:
target_end = RenderingPosition(block_index=10)
actual_end = RenderingPosition(block_index=10) # Exact
adjusted = layouter._adjust_start_estimate(current_start, target_end, actual_end)
adjusted = layouter._adjust_start_estimate(
current_start, target_end, actual_end)
# Should return same or similar position
assert adjusted.block_index >= 0
def test_layout_paragraph_on_page_with_pretext(self, sample_font, sample_page_style):
def test_layout_paragraph_on_page_with_pretext(
self, sample_font, sample_page_style):
"""Test paragraph layout with pretext (hyphenated word continuation)."""
layouter = BidirectionalLayouter([], sample_page_style, page_size=(800, 600))
@ -819,7 +839,8 @@ class TestBidirectionalLayouter:
page = Page(size=(800, 600), style=sample_page_style)
position = RenderingPosition(remaining_pretext="pre-")
success, new_pos = layouter._layout_paragraph_on_page(paragraph, page, position, 1.0)
success, new_pos = layouter._layout_paragraph_on_page(
paragraph, page, position, 1.0)
# Should attempt to layout
assert isinstance(success, bool)
@ -838,7 +859,8 @@ class TestBidirectionalLayouter:
page = Page(size=(800, 600), style=sample_page_style)
position = RenderingPosition()
success, new_pos = layouter._layout_paragraph_on_page(paragraph, page, position, 1.0)
success, new_pos = layouter._layout_paragraph_on_page(
paragraph, page, position, 1.0)
# Should complete successfully
assert isinstance(success, bool)
@ -856,7 +878,8 @@ class TestBidirectionalLayouter:
page = Page(size=(800, 600), style=sample_page_style)
position = RenderingPosition()
success, new_pos = layouter._layout_heading_on_page(heading, page, position, 1.0)
success, new_pos = layouter._layout_heading_on_page(
heading, page, position, 1.0)
# Should attempt to layout like a paragraph
assert isinstance(success, bool)

View File

@ -9,9 +9,7 @@ This module tests:
import pytest
import json
import tempfile
from pathlib import Path
from unittest.mock import Mock, MagicMock, patch
from pyWebLayout.layout.ereader_manager import (
BookmarkManager,
@ -57,7 +55,7 @@ def sample_blocks(sample_font):
# Paragraphs
for i in range(5):
p = Paragraph(sample_font)
p.add_word(Word(f"Paragraph", sample_font))
p.add_word(Word("Paragraph", sample_font))
p.add_word(Word(f"{i}", sample_font))
blocks.append(p)
@ -94,7 +92,7 @@ class TestBookmarkManager:
"""Test that initialization creates bookmarks directory if needed."""
bookmarks_dir = str(tmp_path / "new_bookmarks")
manager = BookmarkManager("test_doc", bookmarks_dir)
BookmarkManager("test_doc", bookmarks_dir)
assert Path(bookmarks_dir).exists()
assert Path(bookmarks_dir).is_dir()
@ -296,7 +294,8 @@ class TestEreaderLayoutManager:
assert manager.font_scale == 1.0
assert isinstance(manager.current_position, RenderingPosition)
def test_initialization_with_custom_page_style(self, sample_blocks, temp_bookmarks_dir):
def test_initialization_with_custom_page_style(
self, sample_blocks, temp_bookmarks_dir):
"""Test initialization with custom page style."""
custom_style = PageStyle()
@ -309,7 +308,8 @@ class TestEreaderLayoutManager:
assert manager.page_style == custom_style
def test_initialization_loads_saved_position(self, sample_blocks, temp_bookmarks_dir):
def test_initialization_loads_saved_position(
self, sample_blocks, temp_bookmarks_dir):
"""Test that initialization loads saved reading position."""
# Save a position first
bookmark_mgr = BookmarkManager("test_doc", temp_bookmarks_dir)
@ -493,7 +493,7 @@ class TestEreaderLayoutManager:
bookmarks_dir=temp_bookmarks_dir
)
page = manager.set_font_scale(1.0)
manager.set_font_scale(1.0)
assert manager.font_scale == 1.0

View File

@ -48,7 +48,10 @@ class TestHTMLLinksInEreader(unittest.TestCase):
if isinstance(word, LinkedWord):
all_linked_words.append(word)
self.assertGreater(len(all_linked_words), 0, "Should create LinkedWords from HTML")
self.assertGreater(
len(all_linked_words),
0,
"Should create LinkedWords from HTML")
print(f"\n Created {len(all_linked_words)} LinkedWords from HTML")
# Step 2: Create EreaderLayoutManager (like the dreader app does)
@ -91,17 +94,19 @@ class TestHTMLLinksInEreader(unittest.TestCase):
print(f" - '{elem['text']}' -> {elem['location']}")
# THIS IS THE KEY ASSERTION
self.assertGreater(len(interactive_elements), 0,
"Settings overlay should have interactive LinkText objects after rendering!")
self.assertGreater(
len(interactive_elements),
0,
"Settings overlay should have interactive LinkText objects after rendering!")
# Verify the expected links are present
locations = {elem['location'] for elem in interactive_elements}
self.assertIn("action:back_to_library", locations,
"Should find 'Back to Library' link")
"Should find 'Back to Library' link")
self.assertIn("setting:font_decrease", locations,
"Should find font decrease link")
"Should find font decrease link")
self.assertIn("setting:font_increase", locations,
"Should find font increase link")
"Should find font increase link")
def test_query_point_detects_links(self):
"""Test that query_point can detect LinkText objects."""
@ -134,8 +139,9 @@ class TestHTMLLinksInEreader(unittest.TestCase):
if found_link:
break
self.assertTrue(found_link,
"Should be able to detect link via query_point somewhere on the page")
self.assertTrue(
found_link,
"Should be able to detect link via query_point somewhere on the page")
if __name__ == '__main__':

View File

@ -5,14 +5,12 @@ This test focuses on verifying that the document layouter properly
integrates word spacing constraints from the style system.
"""
import pytest
from unittest.mock import Mock, MagicMock, patch
from typing import List, Optional
from unittest.mock import Mock, patch
from pyWebLayout.layout.document_layouter import paragraph_layouter, table_layouter, DocumentLayouter
from pyWebLayout.style.abstract_style import AbstractStyle
from pyWebLayout.style.concrete_style import ConcreteStyle, StyleResolver, RenderingContext
from pyWebLayout.abstract.block import Table, TableRow, TableCell
from pyWebLayout.style.concrete_style import StyleResolver, RenderingContext
from pyWebLayout.abstract.block import Table
from pyWebLayout.concrete.table import TableStyle
@ -29,21 +27,21 @@ class TestDocumentLayouter:
self.mock_page.draw = Mock()
self.mock_page.can_fit_line = Mock(return_value=True)
self.mock_page.add_child = Mock()
# Create mock page style with all required numeric properties
self.mock_page.style = Mock()
self.mock_page.style.max_font_size = 72 # Reasonable maximum font size
self.mock_page.style.line_spacing_multiplier = 1.2 # Standard line spacing
# Create mock style resolver
self.mock_style_resolver = Mock()
self.mock_page.style_resolver = self.mock_style_resolver
# Create mock paragraph
self.mock_paragraph = Mock()
self.mock_paragraph.line_height = 20
self.mock_paragraph.style = AbstractStyle()
# Create mock words
self.mock_words = []
for i in range(5):
@ -51,20 +49,22 @@ class TestDocumentLayouter:
word.text = f"word{i}"
self.mock_words.append(word)
self.mock_paragraph.words = self.mock_words
# Create mock concrete style with word spacing constraints
self.mock_concrete_style = Mock()
self.mock_concrete_style.word_spacing_min = 2.0
self.mock_concrete_style.word_spacing_max = 8.0
self.mock_concrete_style.text_align = "left"
# Create mock font that returns proper numeric metrics (not Mock objects)
mock_font = Mock()
# CRITICAL: getmetrics() must return actual numeric values, not Mock objects
# This prevents "TypeError: '>' not supported between instances of 'Mock' and 'Mock'"
mock_font.getmetrics.return_value = (12, 4) # (ascent, descent) as actual integers
# This prevents "TypeError: '>' not supported between instances of 'Mock'
# and 'Mock'"
# (ascent, descent) as actual integers
mock_font.getmetrics.return_value = (12, 4)
mock_font.font = mock_font # For accessing .font property
# Create mock font object that can be used by create_font
mock_font_instance = Mock()
mock_font_instance.font = mock_font
@ -72,7 +72,7 @@ class TestDocumentLayouter:
mock_font_instance.colour = (0, 0, 0)
mock_font_instance.background = (255, 255, 255, 0)
self.mock_concrete_style.create_font = Mock(return_value=mock_font_instance)
# Update mock words to have proper style with font
for word in self.mock_words:
word.style = Mock()
@ -84,34 +84,39 @@ class TestDocumentLayouter:
@patch('pyWebLayout.layout.document_layouter.StyleResolver')
@patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry')
@patch('pyWebLayout.layout.document_layouter.Line')
def test_paragraph_layouter_basic_flow(self, mock_line_class, mock_style_registry_class, mock_style_resolver_class):
def test_paragraph_layouter_basic_flow(
self,
mock_line_class,
mock_style_registry_class,
mock_style_resolver_class):
"""Test basic paragraph layouter functionality."""
# Setup mocks for StyleResolver and ConcreteStyleRegistry
mock_style_resolver = Mock()
mock_style_resolver_class.return_value = mock_style_resolver
mock_style_registry = Mock()
mock_style_registry_class.return_value = mock_style_registry
mock_style_registry.get_concrete_style.return_value = self.mock_concrete_style
mock_line = Mock()
mock_line_class.return_value = mock_line
mock_line.add_word.return_value = (True, None) # All words fit successfully
# Call function
result = paragraph_layouter(self.mock_paragraph, self.mock_page)
# Verify results
success, failed_word_index, remaining_pretext = result
assert success is True
assert failed_word_index is None
assert remaining_pretext is None
# Verify StyleResolver and ConcreteStyleRegistry were created correctly
mock_style_resolver_class.assert_called_once()
mock_style_registry_class.assert_called_once_with(mock_style_resolver)
mock_style_registry.get_concrete_style.assert_called_once_with(self.mock_paragraph.style)
mock_style_registry.get_concrete_style.assert_called_once_with(
self.mock_paragraph.style)
# Verify Line was created with correct spacing constraints
expected_spacing = (2, 8) # From mock_concrete_style
mock_line_class.assert_called_once()
@ -121,36 +126,37 @@ class TestDocumentLayouter:
@patch('pyWebLayout.layout.document_layouter.StyleResolver')
@patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry')
@patch('pyWebLayout.layout.document_layouter.Line')
def test_paragraph_layouter_word_spacing_constraints_extraction(self, mock_line_class, mock_style_registry_class, mock_style_resolver_class):
def test_paragraph_layouter_word_spacing_constraints_extraction(
self, mock_line_class, mock_style_registry_class, mock_style_resolver_class):
"""Test that word spacing constraints are correctly extracted from style."""
# Create concrete style with specific constraints
concrete_style = Mock()
concrete_style.word_spacing_min = 5.5
concrete_style.word_spacing_max = 15.2
concrete_style.text_align = "justify"
# Create a mock font that concrete_style.create_font returns
mock_font = Mock()
mock_font.font = Mock()
mock_font.font.getmetrics.return_value = (12, 4)
mock_font.font_size = 16
concrete_style.create_font = Mock(return_value=mock_font)
# Setup StyleResolver and ConcreteStyleRegistry mocks
mock_style_resolver = Mock()
mock_style_resolver_class.return_value = mock_style_resolver
mock_style_registry = Mock()
mock_style_registry_class.return_value = mock_style_registry
mock_style_registry.get_concrete_style.return_value = concrete_style
mock_line = Mock()
mock_line_class.return_value = mock_line
mock_line.add_word.return_value = (True, None)
# Call function
paragraph_layouter(self.mock_paragraph, self.mock_page)
# Verify spacing constraints were extracted correctly (converted to int)
expected_spacing = (5, 15) # int() conversion of 5.5 and 15.2
call_args = mock_line_class.call_args
@ -159,30 +165,34 @@ class TestDocumentLayouter:
@patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry')
@patch('pyWebLayout.layout.document_layouter.Line')
@patch('pyWebLayout.layout.document_layouter.Text')
def test_paragraph_layouter_line_overflow(self, mock_text_class, mock_line_class, mock_style_registry_class):
def test_paragraph_layouter_line_overflow(
self,
mock_text_class,
mock_line_class,
mock_style_registry_class):
"""Test handling of line overflow when words don't fit."""
# Setup mocks
mock_style_registry = Mock()
mock_style_registry_class.return_value = mock_style_registry
mock_style_registry.get_concrete_style.return_value = self.mock_concrete_style
# Create two mock lines with proper size attribute
mock_line1 = Mock()
mock_line1.size = (400, 20) # (width, height)
mock_line2 = Mock()
mock_line2.size = (400, 20) # (width, height)
mock_line_class.side_effect = [mock_line1, mock_line2]
# Mock Text.from_word to return mock text objects with numeric width
mock_text = Mock()
mock_text.width = 50 # Reasonable word width
mock_text_class.from_word.return_value = mock_text
# First line: first 2 words fit, third doesn't
# Second line: remaining words fit
mock_line1.add_word.side_effect = [
(True, None), # word0 fits
(True, None), # word1 fits
(True, None), # word1 fits
(False, None), # word2 doesn't fit
]
mock_line2.add_word.side_effect = [
@ -190,42 +200,43 @@ class TestDocumentLayouter:
(True, None), # word3 fits
(True, None), # word4 fits
]
# Call function
result = paragraph_layouter(self.mock_paragraph, self.mock_page)
# Verify results
success, failed_word_index, remaining_pretext = result
assert success is True
assert failed_word_index is None
assert remaining_pretext is None
# Verify two lines were created
assert mock_line_class.call_count == 2
assert self.mock_page.add_child.call_count == 2
@patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry')
@patch('pyWebLayout.layout.document_layouter.Line')
def test_paragraph_layouter_page_full(self, mock_line_class, mock_style_registry_class):
def test_paragraph_layouter_page_full(
self, mock_line_class, mock_style_registry_class):
"""Test handling when page runs out of space."""
# Setup mocks
mock_style_registry = Mock()
mock_style_registry_class.return_value = mock_style_registry
mock_style_registry.get_concrete_style.return_value = self.mock_concrete_style
# Page can fit first line but not second
self.mock_page.can_fit_line.side_effect = [True, False]
mock_line = Mock()
mock_line_class.return_value = mock_line
mock_line.add_word.side_effect = [
(True, None), # word0 fits
(False, None), # word1 doesn't fit, need new line
]
# Call function
result = paragraph_layouter(self.mock_paragraph, self.mock_page)
# Verify results indicate page is full
success, failed_word_index, remaining_pretext = result
assert success is False
@ -236,9 +247,9 @@ class TestDocumentLayouter:
"""Test handling of empty paragraph."""
empty_paragraph = Mock()
empty_paragraph.words = []
result = paragraph_layouter(empty_paragraph, self.mock_page)
success, failed_word_index, remaining_pretext = result
assert success is True
assert failed_word_index is None
@ -247,7 +258,7 @@ class TestDocumentLayouter:
def test_paragraph_layouter_invalid_start_word(self):
"""Test handling of invalid start_word index."""
result = paragraph_layouter(self.mock_paragraph, self.mock_page, start_word=10)
success, failed_word_index, remaining_pretext = result
assert success is True
assert failed_word_index is None
@ -259,10 +270,10 @@ class TestDocumentLayouter:
# Setup mock
mock_style_registry = Mock()
mock_style_registry_class.return_value = mock_style_registry
# Create layouter
layouter = DocumentLayouter(self.mock_page)
# Verify initialization
assert layouter.page == self.mock_page
mock_style_registry_class.assert_called_once_with(self.mock_page.style_resolver)
@ -271,12 +282,13 @@ class TestDocumentLayouter:
def test_document_layouter_layout_paragraph(self, mock_paragraph_layouter):
"""Test DocumentLayouter.layout_paragraph method."""
mock_paragraph_layouter.return_value = (True, None, None)
with patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry'):
layouter = DocumentLayouter(self.mock_page)
result = layouter.layout_paragraph(self.mock_paragraph, start_word=2, pretext="test")
result = layouter.layout_paragraph(
self.mock_paragraph, start_word=2, pretext="test")
# Verify the function was called correctly
mock_paragraph_layouter.assert_called_once_with(
self.mock_paragraph, self.mock_page, 2, "test"
@ -286,46 +298,46 @@ class TestDocumentLayouter:
def test_document_layouter_layout_document_success(self):
"""Test DocumentLayouter.layout_document with successful layout."""
from pyWebLayout.abstract import Paragraph
# Create Mock paragraphs that pass isinstance checks
paragraphs = [
Mock(spec=Paragraph),
Mock(spec=Paragraph),
Mock(spec=Paragraph)
]
with patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry'):
layouter = DocumentLayouter(self.mock_page)
# Mock the layout_paragraph method to return success
layouter.layout_paragraph = Mock(return_value=(True, None, None))
result = layouter.layout_document(paragraphs)
assert result is True
assert layouter.layout_paragraph.call_count == 3
def test_document_layouter_layout_document_failure(self):
"""Test DocumentLayouter.layout_document with layout failure."""
from pyWebLayout.abstract import Paragraph
# Create Mock paragraphs that pass isinstance checks
paragraphs = [
Mock(spec=Paragraph),
Mock(spec=Paragraph)
]
with patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry'):
layouter = DocumentLayouter(self.mock_page)
# Mock the layout_paragraph method: first succeeds, second fails
layouter.layout_paragraph = Mock(side_effect=[
(True, None, None), # First paragraph succeeds
(False, 3, None), # Second paragraph fails
])
result = layouter.layout_document(paragraphs)
assert result is False
assert layouter.layout_paragraph.call_count == 2
@ -334,39 +346,40 @@ class TestDocumentLayouter:
# Create real style objects
context = RenderingContext(base_font_size=16)
resolver = StyleResolver(context)
abstract_style = AbstractStyle(
word_spacing=4.0,
word_spacing_min=2.0,
word_spacing_max=10.0
)
concrete_style = resolver.resolve_style(abstract_style)
# Verify constraints are resolved correctly
assert concrete_style.word_spacing_min == 2.0
assert concrete_style.word_spacing_max == 10.0
# This demonstrates the integration works end-to-end
class TestWordSpacingConstraintsInLayout:
"""Specific tests for word spacing constraints in layout context."""
def test_different_spacing_scenarios(self):
"""Test various word spacing constraint scenarios."""
context = RenderingContext(base_font_size=16)
resolver = StyleResolver(context)
test_cases = [
# (word_spacing, word_spacing_min, word_spacing_max, expected_min, expected_max)
(None, None, None, 2.0, 8.0), # Default case
(5.0, None, None, 5.0, 10.0), # Only base specified
(4.0, 2.0, 8.0, 2.0, 8.0), # All specified
(3.0, 1.0, None, 1.0, 3.0), # Min specified, max = max(word_spacing, min*2) = max(3.0, 2.0) = 3.0
(6.0, None, 12.0, 6.0, 12.0), # Max specified, min from base
# Min specified, max = max(word_spacing, min*2) = max(3.0, 2.0) = 3.0
(3.0, 1.0, None, 1.0, 3.0),
(6.0, None, 12.0, 6.0, 12.0), # Max specified, min from base
]
for word_spacing, min_spacing, max_spacing, expected_min, expected_max in test_cases:
style_kwargs = {}
if word_spacing is not None:
@ -375,17 +388,17 @@ class TestWordSpacingConstraintsInLayout:
style_kwargs['word_spacing_min'] = min_spacing
if max_spacing is not None:
style_kwargs['word_spacing_max'] = max_spacing
abstract_style = AbstractStyle(**style_kwargs)
concrete_style = resolver.resolve_style(abstract_style)
assert concrete_style.word_spacing_min == expected_min, f"Failed for case: {style_kwargs}"
assert concrete_style.word_spacing_max == expected_max, f"Failed for case: {style_kwargs}"
class TestMultiPageLayout:
"""Test cases for multi-page document layout scenarios."""
def setup_method(self):
"""Set up test fixtures for multi-page tests."""
# Create multiple mock pages
@ -401,19 +414,19 @@ class TestMultiPageLayout:
page.add_child = Mock()
page.style_resolver = Mock()
self.mock_pages.append(page)
# Create a long paragraph that will span multiple pages
self.long_paragraph = Mock()
self.long_paragraph.line_height = 25
self.long_paragraph.style = AbstractStyle()
# Create many words to ensure page overflow
self.long_paragraph.words = []
for i in range(50): # 50 words should definitely overflow a page
word = Mock()
word.text = f"word_{i:02d}"
self.long_paragraph.words.append(word)
# Create mock concrete style
self.mock_concrete_style = Mock()
self.mock_concrete_style.word_spacing_min = 3.0
@ -421,7 +434,6 @@ class TestMultiPageLayout:
self.mock_concrete_style.text_align = "justify"
self.mock_concrete_style.create_font = Mock()
@patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry')
def test_document_layouter_multi_page_scenario(self, mock_style_registry_class):
"""Test DocumentLayouter handling multiple pages with continuation."""
@ -429,7 +441,7 @@ class TestMultiPageLayout:
mock_style_registry = Mock()
mock_style_registry_class.return_value = mock_style_registry
mock_style_registry.get_concrete_style.return_value = self.mock_concrete_style
# Create a multi-page document layouter
class MultiPageDocumentLayouter(DocumentLayouter):
def __init__(self, pages):
@ -437,7 +449,7 @@ class TestMultiPageLayout:
self.current_page_index = 0
self.page = pages[0]
self.style_registry = Mock()
def get_next_page(self):
"""Get the next available page."""
if self.current_page_index + 1 < len(self.pages):
@ -445,47 +457,47 @@ class TestMultiPageLayout:
self.page = self.pages[self.current_page_index]
return self.page
return None
def layout_document_with_pagination(self, paragraphs):
"""Layout document with automatic pagination."""
for paragraph in paragraphs:
start_word = 0
pretext = None
while start_word < len(paragraph.words):
complete, next_word, remaining_pretext = self.layout_paragraph(
paragraph, start_word, pretext
)
if complete:
# Paragraph finished
break
if next_word is None:
# Error condition
return False, f"Failed to layout paragraph at word {start_word}"
# Try to get next page
next_page = self.get_next_page()
if not next_page:
return False, f"Ran out of pages at word {next_word}"
# Continue with remaining words on next page
start_word = next_word
pretext = remaining_pretext
return True, "All paragraphs laid out successfully"
# Create layouter with multiple pages
layouter = MultiPageDocumentLayouter(self.mock_pages)
# Mock the layout_paragraph method to simulate page filling
original_layout_paragraph = layouter.layout_paragraph
layouter.layout_paragraph
call_count = [0]
def mock_layout_paragraph(paragraph, start_word=0, pretext=None):
call_count[0] += 1
# Simulate different scenarios based on call count
if call_count[0] == 1:
# First page: can fit words 0-19, fails at word 20
@ -498,19 +510,19 @@ class TestMultiPageLayout:
return (True, None, None)
else:
return (False, start_word, None)
layouter.layout_paragraph = mock_layout_paragraph
# Test multi-page layout
success, message = layouter.layout_document_with_pagination([self.long_paragraph])
success, message = layouter.layout_document_with_pagination(
[self.long_paragraph])
# Verify results
assert success is True
assert "successfully" in message
assert call_count[0] == 3 # Should have made 3 layout attempts
assert layouter.current_page_index == 2 # Should end on page 3 (index 2)
def test_realistic_multi_page_scenario(self):
"""Test a realistic scenario with actual content and page constraints."""
# Create realistic paragraph with varied content
@ -522,7 +534,7 @@ class TestMultiPageLayout:
word_spacing_max=8.0,
text_align="justify"
)
# Create words of varying lengths (realistic text)
words = [
"The", "quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog.",
@ -534,13 +546,13 @@ class TestMultiPageLayout:
"system", "to", "handle", "appropriately", "with", "the", "given",
"constraints", "and", "spacing", "requirements."
]
realistic_paragraph.words = []
for word_text in words:
word = Mock()
word.text = word_text
realistic_paragraph.words.append(word)
# Create page with realistic constraints
realistic_page = Mock()
realistic_page.border_size = 30
@ -550,33 +562,34 @@ class TestMultiPageLayout:
realistic_page.draw = Mock()
realistic_page.add_child = Mock()
realistic_page.style_resolver = Mock()
# Simulate page that can fit approximately 20 lines
lines_fitted = [0]
max_lines = 20
def realistic_can_fit_line(line_height):
lines_fitted[0] += 1
return lines_fitted[0] <= max_lines
realistic_page.can_fit_line = realistic_can_fit_line
# Test with real style system
context = RenderingContext(base_font_size=14)
resolver = StyleResolver(context)
concrete_style = resolver.resolve_style(realistic_paragraph.style)
# Verify realistic constraints were calculated
assert concrete_style.word_spacing == 4.0
assert concrete_style.word_spacing_min == 2.0
assert concrete_style.word_spacing_max == 8.0
# This test demonstrates the integration without mocking everything
# In a real scenario, this would interface with actual Line and Text objects
print(f"✓ Realistic scenario test completed")
print("✓ Realistic scenario test completed")
print(f" - Words to layout: {len(realistic_paragraph.words)}")
print(f" - Page width: {realistic_page.available_width}px")
print(f" - Word spacing constraints: {concrete_style.word_spacing_min}-{concrete_style.word_spacing_max}px")
print(
f" - Word spacing constraints: {concrete_style.word_spacing_min}-{concrete_style.word_spacing_max}px")
class TestTableLayouter:
@ -756,24 +769,24 @@ if __name__ == "__main__":
# Run specific tests for debugging
test = TestDocumentLayouter()
test.setup_method()
# Run a simple test
with patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry') as mock_registry:
with patch('pyWebLayout.layout.document_layouter.Line') as mock_line:
mock_style_registry = Mock()
mock_registry.return_value = mock_style_registry
mock_style_registry.get_concrete_style.return_value = test.mock_concrete_style
mock_line_instance = Mock()
mock_line.return_value = mock_line_instance
mock_line_instance.add_word.return_value = (True, None)
result = paragraph_layouter(test.mock_paragraph, test.mock_page)
print(f"Test result: {result}")
# Run multi-page tests
multi_test = TestMultiPageLayout()
multi_test.setup_method()
multi_test.test_realistic_multi_page_scenario()
print("Document layouter tests completed!")

View File

@ -7,20 +7,15 @@ in multi-page layout scenarios.
"""
import pytest
from unittest.mock import Mock, patch
from PIL import Image, ImageDraw
import numpy as np
from typing import List, Optional
import os
import logging
from pyWebLayout.layout.document_layouter import paragraph_layouter, DocumentLayouter
from pyWebLayout.layout.document_layouter import paragraph_layouter
from pyWebLayout.style.abstract_style import AbstractStyle
from pyWebLayout.style.concrete_style import ConcreteStyle, StyleResolver, RenderingContext
from pyWebLayout.style.fonts import Font
from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.concrete.page import Page
from pyWebLayout.concrete.text import Line, Text
from pyWebLayout.concrete.text import Line
from pyWebLayout.abstract.inline import Word
# Enable logging to see font loading messages
@ -34,47 +29,53 @@ def verify_bundled_font_available():
current_dir = os.path.dirname(os.path.abspath(__file__))
# Navigate up to pyWebLayout root, then to assets/fonts
project_root = os.path.dirname(os.path.dirname(current_dir))
bundled_font_path = os.path.join(project_root, 'pyWebLayout', 'assets', 'fonts', 'DejaVuSans.ttf')
bundled_font_path = os.path.join(
project_root,
'pyWebLayout',
'assets',
'fonts',
'DejaVuSans.ttf')
logger.info(f"Integration tests checking for bundled font at: {bundled_font_path}")
if not os.path.exists(bundled_font_path):
pytest.fail(
f"INTEGRATION TEST FAILURE: Bundled font not found at {bundled_font_path}\n"
f"Integration tests require the bundled font to ensure consistent behavior.\n"
f"This likely means the font was not included in the package build."
)
logger.info(f"Bundled font found at: {bundled_font_path}")
return bundled_font_path
class MockWord(Word):
"""A simple mock word that extends the real Word class."""
def __init__(self, text, style=None):
if style is None:
# Integration tests MUST use the bundled font for consistency
style = Font(font_size=16)
# Verify the font loaded properly
if style.font.path is None:
logger.warning("Font loaded without explicit path - may be using PIL default")
logger.warning(
"Font loaded without explicit path - may be using PIL default")
# Initialize the base Word with required parameters
super().__init__(text, style)
self._concrete_texts = []
def add_concete(self, texts):
"""Add concrete text representations."""
if isinstance(texts, list):
self._concrete_texts.extend(texts)
else:
self._concrete_texts.append(texts)
def possible_hyphenation(self):
"""Return possible hyphenation points."""
if len(self.text) <= 6:
return []
# Simple hyphenation: split roughly in the middle
mid = len(self.text) // 2
return [(self.text[:mid] + "-", self.text[mid:])]
@ -82,7 +83,7 @@ class MockWord(Word):
class MockParagraph:
"""A simple paragraph with words and styling."""
def __init__(self, text_content, word_spacing_style=None):
if word_spacing_style is None:
word_spacing_style = AbstractStyle(
@ -90,10 +91,10 @@ class MockParagraph:
word_spacing_min=2.0,
word_spacing_max=8.0
)
self.style = word_spacing_style
self.line_height = 25
# Create words from text content
self.words = []
for word_text in text_content.split():
@ -103,39 +104,40 @@ class MockParagraph:
class TestDocumentLayouterIntegration:
"""Integration tests using real components."""
@classmethod
def setup_class(cls):
"""Verify bundled font is available before running any tests."""
verify_bundled_font_available()
def test_single_page_layout_with_real_components(self):
"""Test layout on a single page using real Line and Text objects."""
# Create a real page that can fit content
page_style = PageStyle(border_width=20, padding=(10, 10, 10, 10))
page = Page(size=(500, 400), style=page_style)
# Create a paragraph with realistic content
paragraph = MockParagraph(
"The quick brown fox jumps over the lazy dog and runs through the forest.",
AbstractStyle(word_spacing=3.0, word_spacing_min=2.0, word_spacing_max=6.0)
)
# Layout the paragraph
success, failed_word_index, remaining_pretext = paragraph_layouter(paragraph, page)
success, failed_word_index, remaining_pretext = paragraph_layouter(
paragraph, page)
# Verify successful layout
assert success is True
assert failed_word_index is None
assert remaining_pretext is None
# Verify lines were added to page
assert len(page.children) > 0
# Verify actual Line objects were created
for child in page.children:
assert isinstance(child, Line)
print(f"✓ Single page test: {len(page.children)} lines created")
def test_multi_page_scenario_with_page_overflow(self):
@ -143,47 +145,52 @@ class TestDocumentLayouterIntegration:
# Create a very small real page that will definitely overflow
small_page_style = PageStyle(border_width=5, padding=(5, 5, 5, 5))
small_page = Page(size=(150, 80), style=small_page_style)
# Create a long paragraph that will definitely overflow
long_text = " ".join([f"verylongword{i:02d}" for i in range(20)]) # 20 long words
long_text = " ".join(
[f"verylongword{i:02d}" for i in range(20)]) # 20 long words
paragraph = MockParagraph(
long_text,
AbstractStyle(word_spacing=4.0, word_spacing_min=2.0, word_spacing_max=8.0)
)
# Layout the paragraph - should fail due to page overflow
success, failed_word_index, remaining_pretext = paragraph_layouter(paragraph, small_page)
success, failed_word_index, remaining_pretext = paragraph_layouter(
paragraph, small_page)
# Either should fail due to overflow OR succeed with limited content
if success:
# If it succeeded, verify it fit some content
assert len(small_page.children) > 0
print(f"✓ Multi-page test: Content fit on small page, {len(small_page.children)} lines created")
print(
f"✓ Multi-page test: Content fit on small page, {len(small_page.children)} lines created")
else:
# If it failed, verify overflow handling
assert failed_word_index is not None # Should indicate where it failed
assert failed_word_index < len(paragraph.words) # Should be within word range
print(f"✓ Multi-page test: Page overflow at word {failed_word_index}, {len(small_page.children)} lines fit")
assert failed_word_index < len(
paragraph.words) # Should be within word range
print(
f"✓ Multi-page test: Page overflow at word {failed_word_index}, {len(small_page.children)} lines fit")
def test_word_spacing_constraints_in_real_lines(self):
"""Test that word spacing constraints are properly used in real Line objects."""
# Create real page
page_style = PageStyle(border_width=20, padding=(10, 10, 10, 10))
page = Page(size=(400, 300), style=page_style)
# Create paragraph with specific spacing constraints
paragraph = MockParagraph(
"Testing word spacing constraints with realistic content.",
AbstractStyle(word_spacing=5.0, word_spacing_min=3.0, word_spacing_max=10.0)
)
# Layout paragraph
success, _, _ = paragraph_layouter(paragraph, page)
assert success is True
# Verify that Line objects were created with correct spacing
assert len(page.children) > 0
for line in page.children:
assert isinstance(line, Line)
# Verify spacing constraints were applied
@ -191,35 +198,34 @@ class TestDocumentLayouterIntegration:
min_spacing, max_spacing = line._spacing
assert min_spacing == 3 # From our constraint
assert max_spacing == 10 # From our constraint
print(f"✓ Word spacing test: {len(page.children)} lines with constraints (3, 10)")
print(
f"✓ Word spacing test: {len(page.children)} lines with constraints (3, 10)")
def test_different_alignment_strategies_with_constraints(self):
"""Test different text alignment strategies with word spacing constraints."""
alignments_to_test = [
("left", AbstractStyle(text_align="left", word_spacing_min=2.0, word_spacing_max=6.0)),
("justify", AbstractStyle(text_align="justify", word_spacing_min=3.0, word_spacing_max=12.0)),
("center", AbstractStyle(text_align="center", word_spacing_min=1.0, word_spacing_max=5.0))
]
("left", AbstractStyle(
text_align="left", word_spacing_min=2.0, word_spacing_max=6.0)), ("justify", AbstractStyle(
text_align="justify", word_spacing_min=3.0, word_spacing_max=12.0)), ("center", AbstractStyle(
text_align="center", word_spacing_min=1.0, word_spacing_max=5.0))]
for alignment_name, style in alignments_to_test:
page_style = PageStyle(border_width=20, padding=(10, 10, 10, 10))
page = Page(size=(350, 200), style=page_style)
paragraph = MockParagraph(
"This sentence will test different alignment strategies with word spacing.",
style
)
"This sentence will test different alignment strategies with word spacing.", style)
success, _, _ = paragraph_layouter(paragraph, page)
assert success is True
assert len(page.children) > 0
# Verify alignment was applied to lines
for line in page.children:
assert isinstance(line, Line)
# Check that the alignment handler was set correctly
assert line._alignment_handler is not None
print(f"{alignment_name} alignment: {len(page.children)} lines created")
def test_realistic_document_with_multiple_pages(self):
@ -227,38 +233,42 @@ class TestDocumentLayouterIntegration:
# Create multiple real pages
page_style = PageStyle(border_width=20, padding=(10, 10, 10, 10))
pages = [Page(size=(400, 300), style=page_style) for _ in range(3)]
# Create a document with multiple paragraphs
paragraphs = [
MockParagraph(
"This is the first paragraph of our document. It contains enough text to potentially span multiple lines and test the word spacing constraints properly.",
"This is the first paragraph of our document. It contains enough text to "
"potentially span multiple lines and test the word spacing constraints properly.",
AbstractStyle(word_spacing=3.0, word_spacing_min=2.0, word_spacing_max=8.0)
),
MockParagraph(
"Here is a second paragraph with different styling. This paragraph uses different word spacing constraints to test the flexibility of the system.",
"Here is a second paragraph with different styling. This paragraph uses "
"different word spacing constraints to test the flexibility of the system.",
AbstractStyle(word_spacing=5.0, word_spacing_min=3.0, word_spacing_max=12.0)
),
MockParagraph(
"The third and final paragraph completes our test document. It should demonstrate that the layouter can handle multiple paragraphs with varying content lengths and styling requirements.",
"The third and final paragraph completes our test document. It should "
"demonstrate that the layouter can handle multiple paragraphs with varying "
"content lengths and styling requirements.",
AbstractStyle(word_spacing=4.0, word_spacing_min=2.5, word_spacing_max=10.0)
)
]
# Layout paragraphs across pages
current_page_index = 0
for para_index, paragraph in enumerate(paragraphs):
start_word = 0
while start_word < len(paragraph.words):
if current_page_index >= len(pages):
break # Out of pages
current_page = pages[current_page_index]
success, failed_word_index, _ = paragraph_layouter(
paragraph, current_page, start_word
)
if success:
# Paragraph completed on this page
break
@ -267,25 +277,25 @@ class TestDocumentLayouterIntegration:
if failed_word_index is not None:
start_word = failed_word_index
current_page_index += 1
# If we're out of pages, stop
if current_page_index >= len(pages):
break
# Verify pages have content
total_lines = sum(len(page.children) for page in pages)
pages_used = sum(1 for page in pages if len(page.children) > 0)
assert total_lines > 0
assert pages_used > 1 # Should use multiple pages
print(f"✓ Multi-document test: {total_lines} lines across {pages_used} pages")
def test_word_spacing_constraint_resolution_integration(self):
"""Test the complete integration from AbstractStyle to Line spacing."""
page_style = PageStyle(border_width=20, padding=(10, 10, 10, 10))
page = Page(size=(400, 600), style=page_style)
_page = Page(size=(400, 600), style=page_style)
# Test different constraint scenarios
test_cases = [
{
@ -295,7 +305,7 @@ class TestDocumentLayouterIntegration:
"expected_max": 12
},
{
"name": "default_constraints",
"name": "default_constraints",
"style": AbstractStyle(word_spacing=6.0),
"expected_min": 6, # Should use word_spacing as min
"expected_max": 12 # Should use word_spacing * 2 as max
@ -307,7 +317,7 @@ class TestDocumentLayouterIntegration:
"expected_max": 8 # Default based on font size (16 * 0.5)
}
]
for case in test_cases:
# Create fresh real page for each test
test_page_style = PageStyle(border_width=20, padding=(10, 10, 10, 10))
@ -316,18 +326,20 @@ class TestDocumentLayouterIntegration:
"Testing constraint resolution with different scenarios.",
case["style"]
)
success, _, _ = paragraph_layouter(paragraph, test_page)
assert success is True
assert len(test_page.children) > 0
# Verify constraints were resolved correctly
line = test_page.children[0]
min_spacing, max_spacing = line._spacing
assert min_spacing == case["expected_min"], f"Min constraint failed for {case['name']}"
assert max_spacing == case["expected_max"], f"Max constraint failed for {case['name']}"
assert min_spacing == case["expected_min"], f"Min constraint failed for {
case['name']}"
assert max_spacing == case["expected_max"], f"Max constraint failed for {
case['name']}"
print(f"{case['name']}: constraints ({min_spacing}, {max_spacing})")
def test_hyphenation_with_word_spacing_constraints(self):
@ -335,15 +347,16 @@ class TestDocumentLayouterIntegration:
# Create a narrow real page to force hyphenation
narrow_page_style = PageStyle(border_width=20, padding=(10, 10, 10, 10))
narrow_page = Page(size=(200, 300), style=narrow_page_style)
# Create paragraph with long words that will need hyphenation
paragraph = MockParagraph(
"supercalifragilisticexpialidocious antidisestablishmentarianism",
AbstractStyle(word_spacing=3.0, word_spacing_min=2.0, word_spacing_max=8.0)
)
success, failed_word_index, remaining_pretext = paragraph_layouter(paragraph, narrow_page)
success, failed_word_index, remaining_pretext = paragraph_layouter(
paragraph, narrow_page)
# Should succeed with hyphenation or handle overflow gracefully
if success:
assert len(narrow_page.children) > 0
@ -357,10 +370,10 @@ class TestDocumentLayouterIntegration:
if __name__ == "__main__":
# Run integration tests
test = TestDocumentLayouterIntegration()
print("Running document layouter integration tests...")
print("=" * 50)
test.test_single_page_layout_with_real_components()
test.test_multi_page_scenario_with_page_overflow()
test.test_word_spacing_constraints_in_real_lines()
@ -368,6 +381,6 @@ if __name__ == "__main__":
test.test_realistic_document_with_multiple_pages()
test.test_word_spacing_constraint_resolution_integration()
test.test_hyphenation_with_word_spacing_constraints()
print("=" * 50)
print("✅ All integration tests completed successfully!")

View File

@ -27,8 +27,12 @@ class FontRegistryTestMixin:
obj = self.create_test_object()
# Create font twice with same properties
font1 = obj.get_or_create_font(font_size=14, colour=(255, 0, 0), weight=FontWeight.BOLD)
font2 = obj.get_or_create_font(font_size=14, colour=(255, 0, 0), weight=FontWeight.BOLD)
font1 = obj.get_or_create_font(
font_size=14, colour=(
255, 0, 0), weight=FontWeight.BOLD)
font2 = obj.get_or_create_font(
font_size=14, colour=(
255, 0, 0), weight=FontWeight.BOLD)
# Should return the same font object (cached)
self.assertIs(font1, font2, "Fonts with identical properties should be cached")
@ -47,15 +51,21 @@ class FontRegistryTestMixin:
font2 = obj.get_or_create_font(font_size=16, **base_params) # Different size
base_params2 = {'font_size': 18, 'weight': FontWeight.NORMAL}
font3 = obj.get_or_create_font(colour=(255, 0, 0), **base_params2) # Different color
font3 = obj.get_or_create_font(
colour=(255, 0, 0), **base_params2) # Different color
base_params3 = {'font_size': 20, 'colour': (100, 100, 100)}
font4 = obj.get_or_create_font(weight=FontWeight.BOLD, **base_params3) # Different weight
font4 = obj.get_or_create_font(
weight=FontWeight.BOLD,
**base_params3) # Different weight
# All should be different objects
self.assertIsNot(font1, font2, "Fonts with different sizes should be distinct")
self.assertIsNot(font1, font3, "Fonts with different colors should be distinct")
self.assertIsNot(font1, font4, "Fonts with different weights should be distinct")
self.assertIsNot(
font1,
font4,
"Fonts with different weights should be distinct")
self.assertIsNot(font2, font3, "Fonts should be distinct")
self.assertIsNot(font2, font4, "Fonts should be distinct")
self.assertIsNot(font3, font4, "Fonts should be distinct")

View File

@ -53,7 +53,10 @@ class MetadataContainerTestMixin:
# Update value
obj.set_metadata("key", "updated")
self.assertEqual(obj.get_metadata("key"), "updated", "Metadata should be updateable")
self.assertEqual(
obj.get_metadata("key"),
"updated",
"Metadata should be updateable")
def test_metadata_isolation(self):
"""Test that metadata is isolated between instances."""

View File

@ -11,35 +11,38 @@ from pyWebLayout.style import Alignment
class TestStyleObjects(unittest.TestCase):
"""Test cases for pyWebLayout style objects."""
def test_font_weight_enum(self):
"""Test FontWeight enum values."""
self.assertEqual(FontWeight.NORMAL.value, "normal")
self.assertEqual(FontWeight.BOLD.value, "bold")
# Test that all expected values exist
weights = [FontWeight.NORMAL, FontWeight.BOLD]
self.assertEqual(len(weights), 2)
def test_font_style_enum(self):
"""Test FontStyle enum values."""
self.assertEqual(FontStyle.NORMAL.value, "normal")
self.assertEqual(FontStyle.ITALIC.value, "italic")
# Test that all expected values exist
styles = [FontStyle.NORMAL, FontStyle.ITALIC]
self.assertEqual(len(styles), 2)
def test_text_decoration_enum(self):
"""Test TextDecoration enum values."""
self.assertEqual(TextDecoration.NONE.value, "none")
self.assertEqual(TextDecoration.UNDERLINE.value, "underline")
self.assertEqual(TextDecoration.STRIKETHROUGH.value, "strikethrough")
# Test that all expected values exist
decorations = [TextDecoration.NONE, TextDecoration.UNDERLINE, TextDecoration.STRIKETHROUGH]
decorations = [
TextDecoration.NONE,
TextDecoration.UNDERLINE,
TextDecoration.STRIKETHROUGH]
self.assertEqual(len(decorations), 3)
def test_alignment_enum(self):
"""Test Alignment enum values."""
self.assertEqual(Alignment.LEFT.value, "left")
@ -49,11 +52,11 @@ class TestStyleObjects(unittest.TestCase):
self.assertEqual(Alignment.BOTTOM.value, "bottom")
self.assertEqual(Alignment.JUSTIFY.value, "justify")
self.assertEqual(Alignment.MIDDLE.value, "middle")
def test_font_initialization_defaults(self):
"""Test Font initialization with default values."""
font = Font()
self.assertIsNone(font._font_path)
self.assertEqual(font.font_size, 16)
self.assertEqual(font.colour, (0, 0, 0))
@ -63,7 +66,7 @@ class TestStyleObjects(unittest.TestCase):
self.assertEqual(font.decoration, TextDecoration.NONE)
self.assertEqual(font.background, (255, 255, 255, 0)) # Transparent
self.assertEqual(font.language, "en_EN")
def test_font_initialization_custom(self):
"""Test Font initialization with custom values."""
font = Font(
@ -76,7 +79,7 @@ class TestStyleObjects(unittest.TestCase):
background=(255, 255, 0, 255),
language="fr_FR"
)
self.assertEqual(font._font_path, "/path/to/font.ttf")
self.assertEqual(font.font_size, 16)
self.assertEqual(font.colour, (255, 0, 0))
@ -85,7 +88,7 @@ class TestStyleObjects(unittest.TestCase):
self.assertEqual(font.decoration, TextDecoration.UNDERLINE)
self.assertEqual(font.background, (255, 255, 0, 255))
self.assertEqual(font.language, "fr_FR")
def test_font_with_methods(self):
"""Test Font immutable modification methods."""
original_font = Font(
@ -95,34 +98,36 @@ class TestStyleObjects(unittest.TestCase):
style=FontStyle.NORMAL,
decoration=TextDecoration.NONE
)
# Test with_size
size_font = original_font.with_size(16)
self.assertEqual(size_font.font_size, 16)
self.assertEqual(original_font.font_size, 12) # Original unchanged
self.assertEqual(size_font.colour, (0, 0, 0)) # Other properties preserved
# Test with_colour
color_font = original_font.with_colour((255, 0, 0))
self.assertEqual(color_font.colour, (255, 0, 0))
self.assertEqual(original_font.colour, (0, 0, 0)) # Original unchanged
self.assertEqual(color_font.font_size, 12) # Other properties preserved
# Test with_weight
weight_font = original_font.with_weight(FontWeight.BOLD)
self.assertEqual(weight_font.weight, FontWeight.BOLD)
self.assertEqual(original_font.weight, FontWeight.NORMAL) # Original unchanged
# Test with_style
style_font = original_font.with_style(FontStyle.ITALIC)
self.assertEqual(style_font.style, FontStyle.ITALIC)
self.assertEqual(original_font.style, FontStyle.NORMAL) # Original unchanged
# Test with_decoration
decoration_font = original_font.with_decoration(TextDecoration.UNDERLINE)
self.assertEqual(decoration_font.decoration, TextDecoration.UNDERLINE)
self.assertEqual(original_font.decoration, TextDecoration.NONE) # Original unchanged
self.assertEqual(
original_font.decoration,
TextDecoration.NONE) # Original unchanged
def test_font_property_access(self):
"""Test Font property access methods."""
font = Font(
@ -132,7 +137,7 @@ class TestStyleObjects(unittest.TestCase):
style=FontStyle.ITALIC,
decoration=TextDecoration.STRIKETHROUGH
)
# Test all property getters
self.assertEqual(font.font_size, 20)
self.assertEqual(font.colour, (128, 128, 128))
@ -140,41 +145,41 @@ class TestStyleObjects(unittest.TestCase):
self.assertEqual(font.weight, FontWeight.BOLD)
self.assertEqual(font.style, FontStyle.ITALIC)
self.assertEqual(font.decoration, TextDecoration.STRIKETHROUGH)
# Test that font object is accessible
self.assertIsNotNone(font.font)
def test_font_immutability(self):
"""Test that Font objects behave immutably."""
font1 = Font(font_size=12, colour=(0, 0, 0))
font2 = font1.with_size(16)
font3 = font2.with_colour((255, 0, 0))
# Each should be different objects
self.assertIsNot(font1, font2)
self.assertIsNot(font2, font3)
self.assertIsNot(font1, font3)
# Original properties should be unchanged
self.assertEqual(font1.font_size, 12)
self.assertEqual(font1.colour, (0, 0, 0))
self.assertEqual(font2.font_size, 16)
self.assertEqual(font2.colour, (0, 0, 0))
self.assertEqual(font3.font_size, 16)
self.assertEqual(font3.colour, (255, 0, 0))
def test_background_handling(self):
"""Test background color handling."""
# Test default transparent background
font1 = Font()
self.assertEqual(font1.background, (255, 255, 255, 0))
# Test explicit background
font2 = Font(background=(255, 0, 0, 128))
self.assertEqual(font2.background, (255, 0, 0, 128))
# Test None background becomes transparent
font3 = Font(background=None)
self.assertEqual(font3.background, (255, 255, 255, 0))

View File

@ -8,12 +8,12 @@ based on user preferences.
import pytest
from pyWebLayout.style.abstract_style import (
AbstractStyle, AbstractStyleRegistry, FontFamily, FontSize, TextAlign
AbstractStyle, AbstractStyleRegistry, FontFamily, FontSize
)
from pyWebLayout.style.concrete_style import (
ConcreteStyle, ConcreteStyleRegistry, RenderingContext, StyleResolver
ConcreteStyleRegistry, RenderingContext, StyleResolver
)
from pyWebLayout.style.fonts import FontWeight, FontStyle, TextDecoration
from pyWebLayout.style.fonts import FontWeight
def test_abstract_style_is_hashable():
@ -25,18 +25,18 @@ def test_abstract_style_is_hashable():
font_weight=FontWeight.BOLD,
color="red"
)
style2 = AbstractStyle(
font_family=FontFamily.SERIF,
font_size=16,
font_weight=FontWeight.BOLD,
color="red"
)
# They should be equal and have the same hash
assert style1 == style2
assert hash(style1) == hash(style2)
# They should work as dictionary keys
style_dict = {style1: "first", style2: "second"}
assert len(style_dict) == 1 # Should be deduplicated
@ -46,15 +46,15 @@ def test_abstract_style_is_hashable():
def test_abstract_style_registry_deduplication():
"""Test that the registry prevents duplicate styles."""
registry = AbstractStyleRegistry()
# Create the same style twice
style1 = AbstractStyle(font_size=18, font_weight=FontWeight.BOLD)
style2 = AbstractStyle(font_size=18, font_weight=FontWeight.BOLD)
# Register both - should get same ID
id1, _ = registry.get_or_create_style(style1)
id2, _ = registry.get_or_create_style(style2)
assert id1 == id2 # Same style should get same ID
assert registry.get_style_count() == 2 # Only default + our style
@ -62,21 +62,21 @@ def test_abstract_style_registry_deduplication():
def test_style_inheritance():
"""Test that style inheritance works properly."""
registry = AbstractStyleRegistry()
# Create base style
base_style = AbstractStyle(font_size=16, color="black")
base_id, _ = registry.get_or_create_style(base_style)
# Create derived style
derived_id, derived_style = registry.create_derived_style(
base_id,
base_id,
font_weight=FontWeight.BOLD,
color="red"
)
# Resolve effective style
effective = registry.resolve_effective_style(derived_id)
assert effective.font_size == 16 # Inherited from base
assert effective.font_weight == FontWeight.BOLD # Overridden
assert effective.color == "red" # Overridden
@ -90,16 +90,17 @@ def test_style_resolver_user_preferences():
font_scale_factor=1.5, # Additional scaling
large_text=True # Accessibility preference
)
resolver = StyleResolver(context)
# Create abstract style with medium size
abstract_style = AbstractStyle(font_size=FontSize.MEDIUM)
# Resolve to concrete style
concrete_style = resolver.resolve_style(abstract_style)
# Font size should be: 20 (base) * 1.0 (medium) * 1.5 (scale) * 1.2 (large_text) = 36
# Font size should be: 20 (base) * 1.0 (medium) * 1.5 (scale) * 1.2
# (large_text) = 36
expected_size = int(20 * 1.0 * 1.5 * 1.2)
assert concrete_style.font_size == expected_size
@ -108,17 +109,17 @@ def test_style_resolver_color_resolution():
"""Test color name resolution."""
context = RenderingContext()
resolver = StyleResolver(context)
# Test named colors
red_style = AbstractStyle(color="red")
concrete_red = resolver.resolve_style(red_style)
assert concrete_red.color == (255, 0, 0)
# Test hex colors
hex_style = AbstractStyle(color="#ff0000")
concrete_hex = resolver.resolve_style(hex_style)
assert concrete_hex.color == (255, 0, 0)
# Test RGB tuple (should pass through)
rgb_style = AbstractStyle(color=(128, 64, 192))
concrete_rgb = resolver.resolve_style(rgb_style)
@ -129,17 +130,17 @@ def test_concrete_style_caching():
"""Test that concrete styles are cached efficiently."""
context = RenderingContext()
registry = ConcreteStyleRegistry(StyleResolver(context))
# Create abstract style
abstract_style = AbstractStyle(font_size=16, color="blue")
# Get font twice - should be cached
font1 = registry.get_font(abstract_style)
font2 = registry.get_font(abstract_style)
# Should be the same object (cached)
assert font1 is font2
# Check cache stats
stats = registry.get_cache_stats()
assert stats["concrete_styles"] == 1
@ -151,17 +152,17 @@ def test_global_font_scaling():
# Create two contexts with different scaling
context_normal = RenderingContext(font_scale_factor=1.0)
context_large = RenderingContext(font_scale_factor=2.0)
resolver_normal = StyleResolver(context_normal)
resolver_large = StyleResolver(context_large)
# Same abstract style
abstract_style = AbstractStyle(font_size=16)
# Resolve with different contexts
concrete_normal = resolver_normal.resolve_style(abstract_style)
concrete_large = resolver_large.resolve_style(abstract_style)
# Large should be 2x the size
assert concrete_large.font_size == concrete_normal.font_size * 2
@ -169,7 +170,7 @@ def test_global_font_scaling():
def test_memory_efficiency():
"""Test that the new system is more memory efficient."""
registry = AbstractStyleRegistry()
# Create many "different" styles that are actually the same
styles = []
for i in range(100):
@ -181,26 +182,26 @@ def test_memory_efficiency():
)
style_id, _ = registry.get_or_create_style(style)
styles.append(style_id)
# All should reference the same style
assert len(set(styles)) == 1 # All IDs are the same
assert registry.get_style_count() == 2 # Only default + our style
# This demonstrates that we don't create duplicate styles
def test_word_style_reference_concept():
"""Demonstrate how words would reference styles instead of storing fonts."""
registry = AbstractStyleRegistry()
# Create paragraph style
para_style = AbstractStyle(font_size=16, color="black")
para_id, _ = registry.get_or_create_style(para_style)
# Create bold word style
bold_style = AbstractStyle(font_size=16, color="black", font_weight=FontWeight.BOLD)
bold_id, _ = registry.get_or_create_style(bold_style)
# Simulate words storing style IDs instead of full Font objects
words_data = [
{"text": "This", "style_id": para_id},
@ -208,19 +209,19 @@ def test_word_style_reference_concept():
{"text": "bold", "style_id": bold_id},
{"text": "text", "style_id": para_id},
]
# To get the actual font for rendering, we resolve through registry
context = RenderingContext()
concrete_registry = ConcreteStyleRegistry(StyleResolver(context))
for word_data in words_data:
abstract_style = registry.get_style_by_id(word_data["style_id"])
font = concrete_registry.get_font(abstract_style)
# Now we have the actual Font object for rendering
assert font is not None
assert hasattr(font, 'font_size')
# Bold word should have bold weight
if word_data["text"] == "bold":
assert font.weight == FontWeight.BOLD

View File

@ -5,14 +5,13 @@ This test shows how to use the new min/max word spacing constraints
in the style system.
"""
import pytest
from pyWebLayout.style.abstract_style import AbstractStyle, AbstractStyleRegistry
from pyWebLayout.style.concrete_style import ConcreteStyle, StyleResolver, RenderingContext
from pyWebLayout.style.concrete_style import StyleResolver, RenderingContext
class TestWordSpacingConstraints:
"""Test cases for word spacing constraints feature."""
def test_abstract_style_with_word_spacing_constraints(self):
"""Test that AbstractStyle accepts word spacing constraint fields."""
style = AbstractStyle(
@ -20,116 +19,116 @@ class TestWordSpacingConstraints:
word_spacing_min=2.0,
word_spacing_max=10.0
)
assert style.word_spacing == 5.0
assert style.word_spacing_min == 2.0
assert style.word_spacing_max == 10.0
def test_concrete_style_resolution_with_constraints(self):
"""Test that word spacing constraints are resolved correctly."""
# Create rendering context
context = RenderingContext(base_font_size=16)
resolver = StyleResolver(context)
# Create abstract style with constraints
abstract_style = AbstractStyle(
word_spacing=5.0,
word_spacing_min=2.0,
word_spacing_max=12.0
)
# Resolve to concrete style
concrete_style = resolver.resolve_style(abstract_style)
# Check that constraints are preserved
assert concrete_style.word_spacing == 5.0
assert concrete_style.word_spacing_min == 2.0
assert concrete_style.word_spacing_max == 12.0
def test_default_constraint_logic(self):
"""Test default constraint logic when not specified."""
context = RenderingContext(base_font_size=16)
resolver = StyleResolver(context)
# Style with only base word spacing
abstract_style = AbstractStyle(word_spacing=6.0)
concrete_style = resolver.resolve_style(abstract_style)
# Should apply default logic: min = base, max = base * 2
assert concrete_style.word_spacing == 6.0
assert concrete_style.word_spacing_min == 6.0
assert concrete_style.word_spacing_max == 12.0
def test_no_word_spacing_defaults(self):
"""Test defaults when no word spacing is specified."""
context = RenderingContext(base_font_size=16)
resolver = StyleResolver(context)
# Style with no word spacing specified
abstract_style = AbstractStyle()
concrete_style = resolver.resolve_style(abstract_style)
# Should apply font-based defaults
assert concrete_style.word_spacing == 0.0
assert concrete_style.word_spacing_min == 2.0 # Minimum default
assert concrete_style.word_spacing_max == 8.0 # 50% of font size (16 * 0.5)
def test_partial_constraints(self):
"""Test behavior when only min or max is specified."""
context = RenderingContext(base_font_size=16)
resolver = StyleResolver(context)
# Only min specified
abstract_style_min = AbstractStyle(
word_spacing=4.0,
word_spacing_min=3.0
)
concrete_style_min = resolver.resolve_style(abstract_style_min)
assert concrete_style_min.word_spacing_min == 3.0
assert concrete_style_min.word_spacing_max == 6.0 # 3.0 * 2
# Only max specified
abstract_style_max = AbstractStyle(
word_spacing=4.0,
word_spacing_max=8.0
)
concrete_style_max = resolver.resolve_style(abstract_style_max)
assert concrete_style_max.word_spacing_min == 4.0 # max(word_spacing, 2.0)
assert concrete_style_max.word_spacing_max == 8.0
def test_style_registry_with_constraints(self):
"""Test that style registry handles word spacing constraints."""
registry = AbstractStyleRegistry()
# Create style with constraints
style_id, style = registry.get_or_create_style(
word_spacing=5.0,
word_spacing_min=3.0,
word_spacing_max=10.0
)
# Verify the style was created correctly
retrieved_style = registry.get_style_by_id(style_id)
assert retrieved_style.word_spacing == 5.0
assert retrieved_style.word_spacing_min == 3.0
assert retrieved_style.word_spacing_max == 10.0
def test_em_units_in_constraints(self):
"""Test that em units work in word spacing constraints."""
context = RenderingContext(base_font_size=16)
resolver = StyleResolver(context)
# Use em units
abstract_style = AbstractStyle(
word_spacing="0.25em",
word_spacing_min="0.1em",
word_spacing_max="0.5em"
)
concrete_style = resolver.resolve_style(abstract_style)
# Should convert em to pixels based on font size (16px)
assert concrete_style.word_spacing == 4.0 # 0.25 * 16
assert concrete_style.word_spacing_min == 1.6 # 0.1 * 16
@ -146,5 +145,5 @@ if __name__ == "__main__":
test.test_partial_constraints()
test.test_style_registry_with_constraints()
test.test_em_units_in_constraints()
print("All word spacing constraint tests passed!")

View File

@ -6,9 +6,8 @@ import unittest
from unittest.mock import Mock
from pyWebLayout.core.callback_registry import CallbackRegistry
from pyWebLayout.core.base import Interactable
from pyWebLayout.concrete.functional import LinkText, ButtonText, FormFieldText
from pyWebLayout.abstract.functional import Link, Button, FormField, LinkType, FormFieldType
from pyWebLayout.concrete.functional import LinkText, ButtonText
from pyWebLayout.abstract.functional import Link, Button, LinkType
from pyWebLayout.style import Font

View File

@ -9,7 +9,6 @@ import unittest
import tempfile
from pathlib import Path
from PIL import Image as PILImage
import numpy as np
from pyWebLayout.abstract.interactive_image import InteractiveImage
@ -123,7 +122,6 @@ class TestInteractiveImage(unittest.TestCase):
def test_create_and_add_to(self):
"""Test the convenience factory method"""
callback_result = []
def callback(point):
return "added!"
@ -171,8 +169,18 @@ class TestInteractiveImage(unittest.TestCase):
def callback2(point):
return "image2"
img1 = InteractiveImage(source=str(self.test_image_path), width=50, height=50, callback=callback1)
img2 = InteractiveImage(source=str(self.test_image_path), width=50, height=50, callback=callback2)
img1 = InteractiveImage(
source=str(
self.test_image_path),
width=50,
height=50,
callback=callback1)
img2 = InteractiveImage(
source=str(
self.test_image_path),
width=50,
height=50,
callback=callback2)
# Set different bounds
img1.set_rendered_bounds(origin=(0, 0), size=(50, 50))

View File

@ -20,18 +20,23 @@ from pyWebLayout.style.fonts import Font, FontWeight, FontStyle, TextDecoration
class TestFontUtilities(unittest.TestCase):
"""Test cases for font utility functions."""
def test_get_bundled_font_path_finds_font(self):
"""Test that get_bundled_font_path finds the bundled font."""
font_path = get_bundled_font_path()
self.assertIsNotNone(font_path, "Bundled font path should not be None")
self.assertTrue(os.path.exists(font_path), f"Font file should exist at {font_path}")
self.assertTrue(font_path.endswith("DejaVuSans.ttf"), "Font path should end with DejaVuSans.ttf")
self.assertTrue(
os.path.exists(font_path),
f"Font file should exist at {font_path}")
self.assertTrue(
font_path.endswith("DejaVuSans.ttf"),
"Font path should end with DejaVuSans.ttf")
def test_verify_bundled_font_available(self):
"""Test that the bundled font can be verified and loaded."""
self.assertTrue(verify_bundled_font_available(), "Bundled font should be available and loadable")
self.assertTrue(verify_bundled_font_available(),
"Bundled font should be available and loadable")
def test_create_test_font_with_defaults(self):
"""Test creating a test font with default parameters."""
font = create_test_font()
@ -41,7 +46,7 @@ class TestFontUtilities(unittest.TestCase):
self.assertEqual(font.weight, FontWeight.NORMAL)
self.assertEqual(font.style, FontStyle.NORMAL)
self.assertEqual(font.decoration, TextDecoration.NONE)
def test_create_test_font_with_custom_parameters(self):
"""Test creating a test font with custom parameters."""
font = create_test_font(
@ -57,50 +62,52 @@ class TestFontUtilities(unittest.TestCase):
self.assertEqual(font.weight, FontWeight.BOLD)
self.assertEqual(font.style, FontStyle.ITALIC)
self.assertEqual(font.decoration, TextDecoration.UNDERLINE)
def test_create_default_test_font(self):
"""Test creating a default test font."""
font = create_default_test_font()
self.assertIsInstance(font, Font)
self.assertEqual(font.font_size, 16)
self.assertEqual(font.colour, (0, 0, 0))
def test_ensure_consistent_font_in_tests_succeeds(self):
"""Test that ensure_consistent_font_in_tests runs without error when font is available."""
# This should not raise any exceptions if the font is properly available
try:
ensure_consistent_font_in_tests()
except RuntimeError:
self.fail("ensure_consistent_font_in_tests() raised RuntimeError when font should be available")
self.fail(
"ensure_consistent_font_in_tests() raised RuntimeError when font should be available")
def test_bundled_font_loads_with_pil(self):
"""Test that the bundled font can be loaded directly with PIL."""
font_path = get_bundled_font_path()
self.assertIsNotNone(font_path)
# Test loading with different sizes
for size in [12, 16, 24, 48]:
with self.subTest(size=size):
pil_font = ImageFont.truetype(font_path, size)
self.assertIsNotNone(pil_font)
def test_font_metrics_consistency(self):
"""Test that font metrics are consistent between different Font objects using the same parameters."""
font1 = create_test_font(font_size=16)
font2 = create_test_font(font_size=16)
# Both fonts should have the same size
self.assertEqual(font1.font_size, font2.font_size)
# Test that text measurements are consistent
# This is a basic check - in real usage, text measurement consistency is what matters most
# This is a basic check - in real usage, text measurement consistency is
# what matters most
self.assertEqual(font1.font_size, font2.font_size)
def test_different_sizes_create_different_fonts(self):
"""Test that different font sizes create fonts with different metrics."""
small_font = create_test_font(font_size=12)
large_font = create_test_font(font_size=24)
self.assertNotEqual(small_font.font_size, large_font.font_size)
self.assertEqual(small_font.font_size, 12)
self.assertEqual(large_font.font_size, 24)
@ -108,24 +115,28 @@ class TestFontUtilities(unittest.TestCase):
class TestFontPathResolution(unittest.TestCase):
"""Test cases for font path resolution from different locations."""
def test_font_path_is_absolute(self):
"""Test that the returned font path is absolute."""
font_path = get_bundled_font_path()
if font_path:
self.assertTrue(os.path.isabs(font_path), "Font path should be absolute")
def test_font_path_points_to_file(self):
"""Test that the font path points to a file, not a directory."""
font_path = get_bundled_font_path()
if font_path:
self.assertTrue(os.path.isfile(font_path), "Font path should point to a file")
self.assertTrue(
os.path.isfile(font_path),
"Font path should point to a file")
def test_font_file_has_correct_extension(self):
"""Test that the font file has the expected .ttf extension."""
font_path = get_bundled_font_path()
if font_path:
self.assertTrue(font_path.lower().endswith('.ttf'), "Font file should have .ttf extension")
self.assertTrue(
font_path.lower().endswith('.ttf'),
"Font file should have .ttf extension")
if __name__ == '__main__':

View File

@ -6,7 +6,6 @@ preventing inconsistencies that can arise from different system fonts.
"""
import os
import sys
from typing import Optional
from PIL import ImageFont
@ -16,25 +15,30 @@ from pyWebLayout.style.fonts import Font, FontWeight, FontStyle, TextDecoration
def get_bundled_font_path() -> Optional[str]:
"""
Get the path to the bundled DejaVuSans.ttf font.
This function works from test directories by finding the font relative to the
test file locations.
Returns:
str: Path to the bundled font file, or None if not found
"""
# Get the directory containing this test utility file
current_dir = os.path.dirname(os.path.abspath(__file__))
# Navigate up to the project root (tests/utils -> tests -> root)
project_root = os.path.dirname(os.path.dirname(current_dir))
# Path to the bundled font
bundled_font_path = os.path.join(project_root, 'pyWebLayout', 'assets', 'fonts', 'DejaVuSans.ttf')
bundled_font_path = os.path.join(
project_root,
'pyWebLayout',
'assets',
'fonts',
'DejaVuSans.ttf')
if os.path.exists(bundled_font_path):
return bundled_font_path
# Alternative: try to find it relative to the pyWebLayout module
try:
import pyWebLayout
@ -44,24 +48,24 @@ def get_bundled_font_path() -> Optional[str]:
return alt_font_path
except ImportError:
pass
return None
def verify_bundled_font_available() -> bool:
"""
Verify that the bundled font is available and can be loaded.
Returns:
bool: True if the bundled font is available and loadable
"""
font_path = get_bundled_font_path()
if not font_path:
return False
try:
# Try to load the font with PIL to verify it's valid
test_font = ImageFont.truetype(font_path, 16)
ImageFont.truetype(font_path, 16)
return True
except Exception:
return False
@ -77,10 +81,10 @@ def create_test_font(font_size: int = 16,
min_hyphenation_width: Optional[int] = None) -> Font:
"""
Create a Font object that uses the bundled font for consistent testing.
This function ensures all tests use the same font file, preventing
cross-system inconsistencies in text measurements and layout.
Args:
font_size: Size of the font in points
colour: RGB color tuple for the text
@ -90,10 +94,10 @@ def create_test_font(font_size: int = 16,
background: RGBA background color for the text
language: Language code for hyphenation and text processing
min_hyphenation_width: Minimum width in pixels for hyphenation
Returns:
Font: A Font object guaranteed to use the bundled font
Raises:
RuntimeError: If the bundled font cannot be found or loaded
"""
@ -103,13 +107,13 @@ def create_test_font(font_size: int = 16,
"Bundled font (DejaVuSans.ttf) not found. "
"Ensure the font exists in pyWebLayout/assets/fonts/"
)
if not verify_bundled_font_available():
raise RuntimeError(
f"Bundled font at {font_path} cannot be loaded. "
"Font file may be corrupted or invalid."
)
return Font(
font_path=font_path,
font_size=font_size,
@ -126,9 +130,9 @@ def create_test_font(font_size: int = 16,
def create_default_test_font() -> Font:
"""
Create a default Font object for testing with the bundled font.
This is equivalent to Font() but guarantees the bundled font is used.
Returns:
Font: A default Font object using the bundled font
"""
@ -138,10 +142,10 @@ def create_default_test_font() -> Font:
def ensure_consistent_font_in_tests():
"""
Ensure that tests are using consistent fonts by checking availability.
This function can be called in test setup to verify the font environment
is properly configured.
Raises:
RuntimeError: If the bundled font is not available
"""