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. 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 import sys
from pathlib import Path from pathlib import Path
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
@ -18,9 +20,6 @@ from PIL import Image, ImageDraw, ImageFont
# Add pyWebLayout to path # Add pyWebLayout to path
sys.path.insert(0, str(Path(__file__).parent.parent)) 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): def draw_placeholder_content(page: Page):
"""Draw some placeholder content directly on the page to visualize the layout.""" """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 # Add some text labels
try: try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 12) font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 12)
except: except BaseException:
font = ImageFont.load_default() font = ImageFont.load_default()
# Label the areas # Label the areas
draw.text((content_x + 10, content_y + 10), "Content Area", fill=(100, 100, 100), font=font) draw.text(
draw.text((10, 10), f"Border: {page.border_size}px", fill=(150, 150, 150), font=font) (content_x + 10,
draw.text((content_x + 10, content_y + 30), f"Size: {content_w}x{content_h}", fill=(100, 100, 100), font=font) 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(): def create_example_1():
@ -117,7 +135,7 @@ def create_example_4():
def combine_into_grid(pages, title): def combine_into_grid(pages, title):
"""Combine multiple pages into a 2x2 grid with 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 # Render all pages
images = [page.render() for page in pages] images = [page.render() for page in pages]
@ -141,8 +159,9 @@ def combine_into_grid(pages, title):
# Draw title # Draw title
try: try:
title_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 20) title_font = ImageFont.truetype(
except: "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 20)
except BaseException:
title_font = ImageFont.load_default() title_font = ImageFont.load_default()
# Center the title # Center the title
@ -187,7 +206,7 @@ def main():
output_path = output_dir / "example_01_page_rendering.png" output_path = output_dir / "example_01_page_rendering.png"
combined_image.save(output_path) combined_image.save(output_path)
print(f"\n✓ Example completed!") print("\n✓ Example completed!")
print(f" Output saved to: {output_path}") print(f" Output saved to: {output_path}")
print(f" Image size: {combined_image.size[0]}x{combined_image.size[1]} pixels") print(f" Image size: {combined_image.size[0]}x{combined_image.size[1]} pixels")
print(f" Created {len(pages)} page examples") 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. 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 import sys
from pathlib import Path from pathlib import Path
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
@ -18,11 +22,6 @@ from PIL import Image, ImageDraw, ImageFont
# Add pyWebLayout to path # Add pyWebLayout to path
sys.path.insert(0, str(Path(__file__).parent.parent)) 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(): def create_sample_document():
"""Create different HTML samples demonstrating various features.""" """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> <p>This is left-aligned text. It is the default alignment for most text.</p>
<h2>Justified Text</h2> <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> <h2>Centered</h2>
<p style="text-align: center;">This text is centered.</p> <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 # Add a note that this is HTML-parsed content
try: try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 11) font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 11)
except: except BaseException:
font = ImageFont.load_default() font = ImageFont.load_default()
# Draw info about what was parsed # 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 for i, block in enumerate(blocks[:10]): # Show first 10
block_type = type(block).__name__ block_type = type(block).__name__
draw.text((content_x, y_offset), draw.text((content_x, y_offset),
f" {i+1}. {block_type}", f" {i + 1}. {block_type}",
fill=(60, 60, 60), font=font) fill=(60, 60, 60), font=font)
y_offset += 18 y_offset += 18
@ -150,8 +150,9 @@ def combine_samples(samples):
# Add title to image # Add title to image
draw = ImageDraw.Draw(img) draw = ImageDraw.Draw(img)
try: try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 14) font = ImageFont.truetype(
except: "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 14)
except BaseException:
font = ImageFont.load_default() font = ImageFont.load_default()
draw.text((10, 10), title, fill=(50, 50, 150), font=font) 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" output_path = output_dir / "example_02_text_and_layout.png"
combined_image.save(output_path) combined_image.save(output_path)
print(f"\n✓ Example completed!") print("\n✓ Example completed!")
print(f" Output saved to: {output_path}") print(f" Output saved to: {output_path}")
print(f" Image size: {combined_image.size[0]}x{combined_image.size[1]} pixels") print(f" Image size: {combined_image.size[0]}x{combined_image.size[1]} pixels")
print(f" Note: This example demonstrates HTML parsing") print(" Note: This example demonstrates HTML parsing")
print(f" Full layout rendering requires the typesetting engine") print(" Full layout rendering requires the typesetting engine")
return combined_image 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. Shows how the pyWebLayout system handles different page dimensions.
""" """
from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.concrete.page import Page
import sys import sys
from pathlib import Path from pathlib import Path
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
@ -18,9 +20,6 @@ from PIL import Image, ImageDraw, ImageFont
# Add pyWebLayout to path # Add pyWebLayout to path
sys.path.insert(0, str(Path(__file__).parent.parent)) 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): def add_page_info(page: Page, title: str):
"""Add informational text to a page showing its properties.""" """Add informational text to a page showing its properties."""
@ -30,9 +29,11 @@ def add_page_info(page: Page, title: str):
draw = page.draw draw = page.draw
try: try:
font_large = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 14) font_large = ImageFont.truetype(
font_small = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 11) "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 14)
except: font_small = ImageFont.truetype(
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 11)
except BaseException:
font_large = ImageFont.load_default() font_large = ImageFont.load_default()
font_small = ImageFont.load_default() font_small = ImageFont.load_default()
@ -164,13 +165,15 @@ def create_layout_showcase(layouts):
# Find max dimensions for each row/column # Find max dimensions for each row/column
max_widths = [] max_widths = []
for col in range(cols): 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: if col_images:
max_widths.append(max(img.size[0] for img in col_images)) max_widths.append(max(img.size[0] for img in col_images))
max_heights = [] max_heights = []
for row in range(rows): 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: if row_images:
max_heights.append(max(img.size[1] for img in 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 # Add title
try: try:
title_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 24) title_font = ImageFont.truetype(
except: "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 24)
except BaseException:
title_font = ImageFont.load_default() title_font = ImageFont.load_default()
title_text = "Page Layout Examples" title_text = "Page Layout Examples"
@ -231,7 +235,7 @@ def main():
output_path = output_dir / "example_03_page_layouts.png" output_path = output_dir / "example_03_page_layouts.png"
combined_image.save(output_path) combined_image.save(output_path)
print(f"\n✓ Example completed!") print("\n✓ Example completed!")
print(f" Output saved to: {output_path}") print(f" Output saved to: {output_path}")
print(f" Image size: {combined_image.size[0]}x{combined_image.size[1]} pixels") print(f" Image size: {combined_image.size[0]}x{combined_image.size[1]} pixels")
print(f" Created {len(layouts)} layout examples") 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. 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 import sys
from pathlib import Path from pathlib import Path
from PIL import Image, ImageDraw from PIL import Image, ImageDraw
@ -19,14 +26,6 @@ from PIL import Image, ImageDraw
# Add pyWebLayout to path # Add pyWebLayout to path
sys.path.insert(0, str(Path(__file__).parent.parent)) 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(): def create_simple_table_example():
"""Create a simple table from HTML.""" """Create a simple table from HTML."""
@ -179,7 +178,13 @@ def create_data_table_example():
return html, "Data Table" 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.""" """Render a table from HTML to an image using DocumentLayouter."""
# Create page with varying backgrounds # Create page with varying backgrounds
bg_colors = [ bg_colors = [
@ -299,8 +304,9 @@ def combine_examples(examples):
# Add main title # Add main title
from PIL import ImageFont from PIL import ImageFont
try: try:
main_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 20) main_font = ImageFont.truetype(
except: "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 20)
except BaseException:
main_font = ImageFont.load_default() main_font = ImageFont.load_default()
title_text = "Table Rendering Examples" title_text = "Table Rendering Examples"
@ -346,7 +352,7 @@ def main():
output_path = output_dir / "example_04_table_rendering.png" output_path = output_dir / "example_04_table_rendering.png"
combined_image.save(output_path) combined_image.save(output_path)
print(f"\n✓ Example completed!") print("\n✓ Example completed!")
print(f" Output saved to: {output_path}") print(f" Output saved to: {output_path}")
print(f" Image size: {combined_image.size[0]}x{combined_image.size[1]} pixels") print(f" Image size: {combined_image.size[0]}x{combined_image.size[1]} pixels")
print(f" Created {len(examples)} table examples") 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! 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 import sys
from pathlib import Path from pathlib import Path
from PIL import Image from PIL import Image
@ -17,20 +23,13 @@ from PIL import Image
# Add pyWebLayout to path # Add pyWebLayout to path
sys.path.insert(0, str(Path(__file__).parent.parent)) 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(): def create_book_catalog_html():
"""Create HTML for a book catalog table with actual <img> tags.""" """Create HTML for a book catalog table with actual <img> tags."""
# Get base path for images - use absolute paths for the img src # 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> <html>
<body> <body>
<table> <table>
@ -77,9 +76,9 @@ def create_book_catalog_html():
def create_product_showcase_html(): def create_product_showcase_html():
"""Create HTML for a product showcase table with images.""" """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> <html>
<body> <body>
<table> <table>
@ -107,8 +106,8 @@ def create_product_showcase_html():
def render_html_with_layouter(html_string: str, title: str, def render_html_with_layouter(html_string: str, title: str,
table_style: TableStyle, table_style: TableStyle,
page_size=(600, 500)): page_size=(600, 500)):
""" """
Render HTML using DocumentLayouter - the proper way! Render HTML using DocumentLayouter - the proper way!
@ -163,7 +162,7 @@ def render_html_with_layouter(html_string: str, title: str,
if not success: if not success:
print(f" ⚠ Warning: Block {type(block).__name__} didn't fit on page") 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 # Step 5: Get the rendered canvas
# Note: Tables render directly onto page._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" output_path = output_dir / "example_05_html_table_with_images.png"
combined.save(output_path) combined.save(output_path)
print(f"\n✓ Example completed!") print("\n✓ Example completed!")
print(f" Output saved to: {output_path}") print(f" Output saved to: {output_path}")
print(f" Image size: {combined.size[0]}x{combined.size[1]} pixels") print(f" Image size: {combined.size[0]}x{combined.size[1]} pixels")
print(f"\nThe complete pipeline:") print("\nThe complete pipeline:")
print(f" 1. HTML with <img> tags → parse_html_string() → Abstract blocks") print(" 1. HTML with <img> tags → parse_html_string() → Abstract blocks")
print(f" 2. Abstract blocks → DocumentLayouter → Concrete objects") print(" 2. Abstract blocks → DocumentLayouter → Concrete objects")
print(f" 3. Page.render() → PNG output") print(" 3. Page.render() → PNG output")
print(f"\n ✓ Using DocumentLayouter - NO custom rendering code!") print("\n ✓ Using DocumentLayouter - NO custom rendering code!")
return combined return combined

View File

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

View File

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

View File

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

View File

@ -9,7 +9,7 @@ proper bounding box detection.
from typing import Optional, Callable, Tuple from typing import Optional, Callable, Tuple
import numpy as np import numpy as np
from .block import Image, BlockType from .block import Image
from ..core.base import Interactable, Queriable 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) callback: Function to call when image is tapped (receives point coordinates)
""" """
# Initialize Image # 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 # Initialize Interactable
Interactable.__init__(self, callback=callback) 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 .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 .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 from __future__ import annotations
import numpy as np import numpy as np
from PIL import Image from PIL import Image
from typing import Tuple, Union, List, Optional, Dict
from pyWebLayout.core.base import Renderable, Queriable from pyWebLayout.core.base import Renderable, Queriable
from pyWebLayout.core import Geometric from pyWebLayout.core import Geometric
from pyWebLayout.style import Alignment from pyWebLayout.style import Alignment
class Box(Geometric, Renderable, Queriable): class Box(Geometric, Renderable, Queriable):
""" """
A box with geometric properties (origin and size). 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. 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) super().__init__(origin=origin, size=size)
self._end = self._origin + self._size self._end = self._origin + self._size
self._callback = callback self._callback = callback
self._sheet : Image = sheet self._sheet: Image = sheet
if self._sheet == None: if self._sheet is None:
self._mode = mode self._mode = mode
else: else:
self._mode = sheet.mode self._mode = sheet.mode

View File

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

View File

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

View File

@ -2,12 +2,11 @@ from typing import List, Tuple, Optional
import numpy as np import numpy as np
from PIL import Image, ImageDraw 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.query import QueryResult, SelectionRange
from pyWebLayout.core.callback_registry import CallbackRegistry from pyWebLayout.core.callback_registry import CallbackRegistry
from pyWebLayout.style.page_style import PageStyle from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.style import Alignment
from .box import Box
class Page(Renderable, Queriable): class Page(Renderable, Queriable):
""" """
@ -41,29 +40,33 @@ class Page(Renderable, Queriable):
"""Get the remaining space on the page""" """Get the remaining space on the page"""
return (self._size[0], self._size[1] - self._current_y_offset) 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. Check if a line with the given metrics can fit on the page.
Args: Args:
baseline_spacing: Distance from current position to next baseline baseline_spacing: Distance from current position to next baseline
ascent: Font ascent (height above baseline), defaults to 0 for backward compat ascent: Font ascent (height above baseline), defaults to 0 for backward compat
descent: Font descent (height below baseline), defaults to 0 for backward compat descent: Font descent (height below baseline), defaults to 0 for backward compat
Returns: Returns:
True if the line fits within page boundaries True if the line fits within page boundaries
""" """
# Calculate the maximum Y position allowed (bottom boundary) # Calculate the maximum Y position allowed (bottom boundary)
max_y = self._size[1] - self._style.border_width - self._style.padding_bottom 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/descent not provided, use simple check (backward compatibility)
if ascent == 0 and descent == 0: if ascent == 0 and descent == 0:
return (self._current_y_offset + baseline_spacing) <= max_y return (self._current_y_offset + baseline_spacing) <= max_y
# Calculate where the bottom of the text would be # Calculate where the bottom of the text would be
# Text bottom = current_y_offset + ascent + descent # Text bottom = current_y_offset + ascent + descent
text_bottom = self._current_y_offset + ascent + descent text_bottom = self._current_y_offset + ascent + descent
# Check if text bottom would exceed the boundary # Check if text bottom would exceed the boundary
return text_bottom <= max_y return text_bottom <= max_y
@ -183,11 +186,15 @@ class Page(Renderable, Queriable):
Height in pixels Height in pixels
""" """
if hasattr(child, '_size') and child._size is not None: 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]) return int(child._size[1])
if hasattr(child, 'size') and child.size is not None: 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]) return int(child.size[1])
if hasattr(child, 'height'): if hasattr(child, 'height'):
@ -326,7 +333,7 @@ class Page(Renderable, Queriable):
if isinstance(child, Queriable) and hasattr(child, 'in_object'): if isinstance(child, Queriable) and hasattr(child, 'in_object'):
try: try:
return child.in_object(point) return child.in_object(point)
except: except BaseException:
pass # Fall back to bounds checking pass # Fall back to bounds checking
# Get child position and size for 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 Tuple of (width, height) or None if size cannot be determined
""" """
if hasattr(child, '_size') and child._size is not None: 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])) return (int(child._size[0]), int(child._size[1]))
if hasattr(child, 'size') and child.size is not None: 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])) return (int(child.size[0]), int(child.size[1]))
if hasattr(child, 'width') and hasattr(child, 'height'): if hasattr(child, 'width') and hasattr(child, 'height'):
@ -422,7 +433,8 @@ class Page(Renderable, Queriable):
bounds=bounds 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). Query all text objects between two points (for text selection).
Uses Queriable.in_object() to determine which objects are in range. 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 __future__ import annotations
from typing import Tuple, List, Optional, Dict from typing import Tuple, List, Optional, Dict
import numpy as np
from PIL import Image, ImageDraw from PIL import Image, ImageDraw
from dataclasses import dataclass 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.concrete.box import Box
from pyWebLayout.abstract.block import Table, TableRow, TableCell, Paragraph, Heading, Image as AbstractImage from pyWebLayout.abstract.block import Table, TableRow, TableCell, Paragraph, Heading, Image as AbstractImage
from pyWebLayout.abstract.interactive_image import InteractiveImage from pyWebLayout.abstract.interactive_image import InteractiveImage
from pyWebLayout.style import Font, Alignment
@dataclass @dataclass
@ -49,8 +47,15 @@ class TableCellRenderer(Box):
Supports paragraphs, headings, images, and links within cells. Supports paragraphs, headings, images, and links within cells.
""" """
def __init__(self, cell: TableCell, origin: Tuple[int, int], size: Tuple[int, int], def __init__(self,
draw: ImageDraw.Draw, style: TableStyle, is_header_section: bool = False, 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): canvas: Optional[Image.Image] = None):
""" """
Initialize a table cell renderer. Initialize a table cell renderer.
@ -111,17 +116,20 @@ class TableCellRenderer(Box):
# Get font # Get font
try: try:
if self._is_header_section and self._style.header_text_bold: 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: else:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 12) font = ImageFont.truetype(
except: "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 12)
except BaseException:
font = ImageFont.load_default() font = ImageFont.load_default()
# Render each block in the cell # Render each block in the cell
for block in self._cell.blocks(): for block in self._cell.blocks():
if isinstance(block, AbstractImage): if isinstance(block, AbstractImage):
# Render image # 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)): elif isinstance(block, (Paragraph, Heading)):
# Extract and render text # Extract and render text
words = [] words = []
@ -137,7 +145,8 @@ class TableCellRenderer(Box):
if words: if words:
text = " ".join(words) text = " ".join(words)
if current_y <= y + height - 15: 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 current_y += 16
if current_y > y + height - 10: # Don't overflow cell 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 no structured content, try to get any text representation
if current_y == y + 2 and hasattr(self._cell, '_text_content'): 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, 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. Render an image block inside a table cell.
@ -181,7 +198,8 @@ class TableCellRenderer(Box):
# Use more of the cell space for images # Use more of the cell space for images
img_width, img_height = img.size img_width, img_height = img.size
scale_w = max_width / img_width if img_width > max_width else 1 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 scale = min(scale_w, scale_h, 1.0) # Don't upscale
new_width = int(img_width * scale) new_width = int(img_width * scale)
@ -210,8 +228,9 @@ class TableCellRenderer(Box):
# Draw image indicator text # Draw image indicator text
from PIL import ImageFont from PIL import ImageFont
try: try:
small_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 9) small_font = ImageFont.truetype(
except: "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 9)
except BaseException:
small_font = ImageFont.load_default() small_font = ImageFont.load_default()
text = f"[Image: {new_width}x{new_height}]" text = f"[Image: {new_width}x{new_height}]"
@ -219,7 +238,9 @@ class TableCellRenderer(Box):
text_width = bbox[2] - bbox[0] text_width = bbox[2] - bbox[0]
text_x = img_x + (new_width - text_width) // 2 text_x = img_x + (new_width - text_width) // 2
text_y = y + (new_height - 12) // 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 # Set bounds on InteractiveImage objects for tap detection
if isinstance(image_block, InteractiveImage): if isinstance(image_block, InteractiveImage):
@ -230,7 +251,7 @@ class TableCellRenderer(Box):
return y + new_height + 5 # Add some spacing after image return y + new_height + 5 # Add some spacing after image
except Exception as e: except Exception:
# If image loading fails, just return current position # If image loading fails, just return current position
return y + 20 return y + 20
@ -240,9 +261,15 @@ class TableRowRenderer(Box):
Renders a single table row containing multiple cells. Renders a single table row containing multiple cells.
""" """
def __init__(self, row: TableRow, origin: Tuple[int, int], def __init__(self,
column_widths: List[int], row_height: int, row: TableRow,
draw: ImageDraw.Draw, style: TableStyle, is_header_section: bool = False, 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): canvas: Optional[Image.Image] = None):
""" """
Initialize a table row renderer. Initialize a table row renderer.
@ -309,9 +336,14 @@ class TableRenderer(Box):
Handles layout calculation, row/cell placement, and overall table structure. Handles layout calculation, row/cell placement, and overall table structure.
""" """
def __init__(self, table: Table, origin: Tuple[int, int], def __init__(self,
available_width: int, draw: ImageDraw.Draw, table: Table,
style: Optional[TableStyle] = None, canvas: Optional[Image.Image] = None): origin: Tuple[int,
int],
available_width: int,
draw: ImageDraw.Draw,
style: Optional[TableStyle] = None,
canvas: Optional[Image.Image] = None):
""" """
Initialize a table renderer. Initialize a table renderer.
@ -331,8 +363,10 @@ class TableRenderer(Box):
# Calculate table dimensions # Calculate table dimensions
self._column_widths, self._row_heights = self._calculate_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_width = sum(self._column_widths) + \
total_height = sum(self._row_heights.values()) + self._style.border_width * (len(self._row_heights) + 1) 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)) super().__init__(origin, (total_width, total_height))
self._row_renderers: List[TableRowRenderer] = [] self._row_renderers: List[TableRowRenderer] = []
@ -362,7 +396,8 @@ class TableRenderer(Box):
column_widths = [column_width] * num_columns column_widths = [column_width] * num_columns
# Calculate row heights # 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 # Check if any body rows contain images - if so, use larger height
body_height = 30 body_height = 30
@ -375,7 +410,8 @@ class TableRenderer(Box):
body_height = max(body_height, 120) body_height = max(body_height, 120)
break 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 = { row_heights = {
"header": header_height, "header": header_height,
@ -428,8 +464,9 @@ class TableRenderer(Box):
from PIL import ImageFont from PIL import ImageFont
try: try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 13) font = ImageFont.truetype(
except: "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 13)
except BaseException:
font = ImageFont.load_default() font = ImageFont.load_default()
# Center the caption # Center the caption

View File

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

View File

@ -148,7 +148,8 @@ class HighlightManager:
self.highlights.clear() self.highlights.clear()
self._save_highlights() 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. Get highlights that appear on a specific page.
@ -165,7 +166,7 @@ class HighlightManager:
# Check if any highlight bounds overlap with page # Check if any highlight bounds overlap with page
for hx, hy, hw, hh in highlight.bounds: for hx, hy, hw, hh in highlight.bounds:
if (hx < page_x + page_w and hx + hw > page_x and 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) page_highlights.append(highlight)
break break

View File

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

View File

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

View File

@ -8,13 +8,12 @@ to pyWebLayout's abstract document model.
import os import os
import zipfile import zipfile
import tempfile 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 xml.etree.ElementTree as ET
import re
import urllib.parse import urllib.parse
from PIL import Image as PILImage, ImageOps 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.abstract.block import PageBreak
from pyWebLayout.io.readers.html_extraction import parse_html_string 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. Process image for 4-bit e-ink display using PIL only.
Applies histogram equalization and 4-bit quantization. Applies histogram equalization and 4-bit quantization.
Args: Args:
img: PIL Image to process img: PIL Image to process
Returns: Returns:
Processed PIL Image in L mode (grayscale) with 4-bit quantization Processed PIL Image in L mode (grayscale) with 4-bit quantization
""" """
# Convert to grayscale if needed # Convert to grayscale if needed
if img.mode != 'L': if img.mode != 'L':
img = img.convert('L') img = img.convert('L')
# Apply histogram equalization for contrast enhancement # Apply histogram equalization for contrast enhancement
img = ImageOps.equalize(img) img = ImageOps.equalize(img)
# Quantize to 4-bit (16 grayscale levels: 0, 17, 34, ..., 255) # Quantize to 4-bit (16 grayscale levels: 0, 17, 34, ..., 255)
img = img.point(lambda x: (x // 16) * 17) img = img.point(lambda x: (x // 16) * 17)
return img return img
class EPUBReader: class EPUBReader:
""" """
Reader for EPUB documents. Reader for EPUB documents.
This class extracts content from EPUB files and converts it to This class extracts content from EPUB files and converts it to
pyWebLayout's abstract document model. 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. Initialize an EPUB reader.
Args: Args:
epub_path: Path to the EPUB file epub_path: Path to the EPUB file
image_processor: Optional function to process images for display optimization. image_processor: Optional function to process images for display optimization.
@ -82,11 +82,11 @@ class EPUBReader:
self.spine = [] self.spine = []
self.manifest = {} self.manifest = {}
self.cover_id = None # ID of the cover image in manifest self.cover_id = None # ID of the cover image in manifest
def read(self) -> Book: def read(self) -> Book:
""" """
Read the EPUB file and convert it to a Book. Read the EPUB file and convert it to a Book.
Returns: Returns:
Book: The parsed book Book: The parsed book
""" """
@ -100,45 +100,47 @@ class EPUBReader:
# Add chapters to the book # Add chapters to the book
self._add_chapters() self._add_chapters()
# Process images for e-ink display optimization # Process images for e-ink display optimization
self._process_content_images() self._process_content_images()
return self.book return self.book
finally: finally:
# Clean up temporary files # Clean up temporary files
if self.temp_dir: if self.temp_dir:
import shutil import shutil
shutil.rmtree(self.temp_dir, ignore_errors=True) shutil.rmtree(self.temp_dir, ignore_errors=True)
def _extract_epub(self): def _extract_epub(self):
"""Extract the EPUB file to a temporary directory.""" """Extract the EPUB file to a temporary directory."""
with zipfile.ZipFile(self.epub_path, 'r') as zip_ref: with zipfile.ZipFile(self.epub_path, 'r') as zip_ref:
zip_ref.extractall(self.temp_dir) zip_ref.extractall(self.temp_dir)
# Find the content directory (typically OEBPS or OPS) # Find the content directory (typically OEBPS or OPS)
container_path = os.path.join(self.temp_dir, 'META-INF', 'container.xml') container_path = os.path.join(self.temp_dir, 'META-INF', 'container.xml')
if os.path.exists(container_path): if os.path.exists(container_path):
tree = ET.parse(container_path) tree = ET.parse(container_path)
root = tree.getroot() root = tree.getroot()
# Get the path to the package document (content.opf) # 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') full_path = rootfile.get('full-path')
if 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 return
# Fallback: look for common content directories # Fallback: look for common content directories
for content_dir in ['OEBPS', 'OPS', 'Content']: for content_dir in ['OEBPS', 'OPS', 'Content']:
if os.path.exists(os.path.join(self.temp_dir, content_dir)): if os.path.exists(os.path.join(self.temp_dir, content_dir)):
self.content_dir = os.path.join(self.temp_dir, content_dir) self.content_dir = os.path.join(self.temp_dir, content_dir)
return return
# If no content directory found, use the root # If no content directory found, use the root
self.content_dir = self.temp_dir self.content_dir = self.temp_dir
def _parse_package_document(self): def _parse_package_document(self):
"""Parse the package document (content.opf).""" """Parse the package document (content.opf)."""
# Find the package document # Find the package document
@ -150,27 +152,27 @@ class EPUBReader:
break break
if opf_path: if opf_path:
break break
if not opf_path: if not opf_path:
raise ValueError("No package document (.opf) found in EPUB") raise ValueError("No package document (.opf) found in EPUB")
# Parse the package document # Parse the package document
tree = ET.parse(opf_path) tree = ET.parse(opf_path)
root = tree.getroot() root = tree.getroot()
# Parse metadata # Parse metadata
self._parse_metadata(root) self._parse_metadata(root)
# Parse manifest # Parse manifest
self._parse_manifest(root) self._parse_manifest(root)
# Parse spine # Parse spine
self._parse_spine(root) self._parse_spine(root)
def _parse_metadata(self, root: ET.Element): def _parse_metadata(self, root: ET.Element):
""" """
Parse metadata from the package document. Parse metadata from the package document.
Args: Args:
root: Root element of the package document root: Root element of the package document
""" """
@ -178,14 +180,14 @@ class EPUBReader:
metadata_elem = root.find('.//{{{0}}}metadata'.format(NAMESPACES['opf'])) metadata_elem = root.find('.//{{{0}}}metadata'.format(NAMESPACES['opf']))
if metadata_elem is None: if metadata_elem is None:
return return
# Parse DC metadata # Parse DC metadata
for elem in metadata_elem: for elem in metadata_elem:
if elem.tag.startswith('{{{0}}}'.format(NAMESPACES['dc'])): if elem.tag.startswith('{{{0}}}'.format(NAMESPACES['dc'])):
# Get the local name (without namespace) # Get the local name (without namespace)
name = elem.tag.split('}', 1)[1] name = elem.tag.split('}', 1)[1]
value = elem.text value = elem.text
if name == 'title': if name == 'title':
self.metadata['title'] = value self.metadata['title'] = value
elif name == 'creator': elif name == 'creator':
@ -207,20 +209,20 @@ class EPUBReader:
else: else:
# Store other metadata # Store other metadata
self.metadata[name] = value self.metadata[name] = value
# Parse meta elements for cover reference # Parse meta elements for cover reference
for meta in metadata_elem.findall('.//{{{0}}}meta'.format(NAMESPACES['opf'])): for meta in metadata_elem.findall('.//{{{0}}}meta'.format(NAMESPACES['opf'])):
name = meta.get('name') name = meta.get('name')
content = meta.get('content') content = meta.get('content')
if name == 'cover' and content: if name == 'cover' and content:
# This is a reference to the cover image in the manifest # This is a reference to the cover image in the manifest
self.cover_id = content self.cover_id = content
def _parse_manifest(self, root: ET.Element): def _parse_manifest(self, root: ET.Element):
""" """
Parse manifest from the package document. Parse manifest from the package document.
Args: Args:
root: Root element of the package document root: Root element of the package document
""" """
@ -228,28 +230,28 @@ class EPUBReader:
manifest_elem = root.find('.//{{{0}}}manifest'.format(NAMESPACES['opf'])) manifest_elem = root.find('.//{{{0}}}manifest'.format(NAMESPACES['opf']))
if manifest_elem is None: if manifest_elem is None:
return return
# Parse items # Parse items
for item in manifest_elem.findall('.//{{{0}}}item'.format(NAMESPACES['opf'])): for item in manifest_elem.findall('.//{{{0}}}item'.format(NAMESPACES['opf'])):
id = item.get('id') id = item.get('id')
href = item.get('href') href = item.get('href')
media_type = item.get('media-type') media_type = item.get('media-type')
if id and href: if id and href:
# Resolve relative path # Resolve relative path
href = urllib.parse.unquote(href) href = urllib.parse.unquote(href)
path = os.path.normpath(os.path.join(self.content_dir, href)) path = os.path.normpath(os.path.join(self.content_dir, href))
self.manifest[id] = { self.manifest[id] = {
'href': href, 'href': href,
'path': path, 'path': path,
'media_type': media_type 'media_type': media_type
} }
def _parse_spine(self, root: ET.Element): def _parse_spine(self, root: ET.Element):
""" """
Parse spine from the package document. Parse spine from the package document.
Args: Args:
root: Root element of the package document root: Root element of the package document
""" """
@ -257,21 +259,25 @@ class EPUBReader:
spine_elem = root.find('.//{{{0}}}spine'.format(NAMESPACES['opf'])) spine_elem = root.find('.//{{{0}}}spine'.format(NAMESPACES['opf']))
if spine_elem is None: if spine_elem is None:
return return
# Get the toc attribute (NCX file ID) # Get the toc attribute (NCX file ID)
toc_id = spine_elem.get('toc') toc_id = spine_elem.get('toc')
if toc_id and toc_id in self.manifest: if toc_id and toc_id in self.manifest:
self.toc_path = self.manifest[toc_id]['path'] self.toc_path = self.manifest[toc_id]['path']
# Parse itemrefs # 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') idref = itemref.get('idref')
if idref and idref in self.manifest: if idref and idref in self.manifest:
self.spine.append(idref) self.spine.append(idref)
def _parse_toc(self): def _parse_toc(self):
"""Parse the table of contents.""" """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 # Try to find the toc.ncx file
for root, dirs, files in os.walk(self.content_dir): for root, dirs, files in os.walk(self.content_dir):
for file in files: for file in files:
@ -280,27 +286,30 @@ class EPUBReader:
break break
if hasattr(self, 'toc_path') and self.toc_path: if hasattr(self, 'toc_path') and self.toc_path:
break 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 # No TOC found
return return
# Parse the NCX file # Parse the NCX file
tree = ET.parse(self.toc_path) tree = ET.parse(self.toc_path)
root = tree.getroot() root = tree.getroot()
# Parse navMap # Parse navMap
nav_map = root.find('.//{{{0}}}navMap'.format(NAMESPACES['ncx'])) nav_map = root.find('.//{{{0}}}navMap'.format(NAMESPACES['ncx']))
if nav_map is None: if nav_map is None:
return return
# Parse navPoints # Parse navPoints
self._parse_nav_points(nav_map, []) self._parse_nav_points(nav_map, [])
def _parse_nav_points(self, parent: ET.Element, path: List[Dict[str, Any]]): def _parse_nav_points(self, parent: ET.Element, path: List[Dict[str, Any]]):
""" """
Recursively parse navPoints from the NCX file. Recursively parse navPoints from the NCX file.
Args: Args:
parent: Parent element containing navPoints parent: Parent element containing navPoints
path: Current path in the TOC hierarchy path: Current path in the TOC hierarchy
@ -309,16 +318,17 @@ class EPUBReader:
# Get navPoint attributes # Get navPoint attributes
id = nav_point.get('id') id = nav_point.get('id')
play_order = nav_point.get('playOrder') play_order = nav_point.get('playOrder')
# Get navLabel # Get navLabel
nav_label = nav_point.find('.//{{{0}}}navLabel'.format(NAMESPACES['ncx'])) 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 "" label = text_elem.text if text_elem is not None else ""
# Get content # Get content
content = nav_point.find('.//{{{0}}}content'.format(NAMESPACES['ncx'])) content = nav_point.find('.//{{{0}}}content'.format(NAMESPACES['ncx']))
src = content.get('src') if content is not None else "" src = content.get('src') if content is not None else ""
# Create a TOC entry # Create a TOC entry
entry = { entry = {
'id': id, 'id': id,
@ -327,78 +337,83 @@ class EPUBReader:
'play_order': play_order, 'play_order': play_order,
'children': [] 'children': []
} }
# Add to TOC # Add to TOC
if path: if path:
path[-1]['children'].append(entry) path[-1]['children'].append(entry)
else: else:
self.toc.append(entry) self.toc.append(entry)
# Parse child navPoints # Parse child navPoints
self._parse_nav_points(nav_point, path + [entry]) self._parse_nav_points(nav_point, path + [entry])
def _create_book(self): def _create_book(self):
"""Create a Book object from the parsed metadata.""" """Create a Book object from the parsed metadata."""
# Set book metadata # Set book metadata
if 'title' in self.metadata: if 'title' in self.metadata:
self.book.set_title(self.metadata['title']) self.book.set_title(self.metadata['title'])
if 'creator' in self.metadata: if 'creator' in self.metadata:
self.book.set_metadata(MetadataType.AUTHOR, self.metadata['creator']) self.book.set_metadata(MetadataType.AUTHOR, self.metadata['creator'])
if 'language' in self.metadata: if 'language' in self.metadata:
self.book.set_metadata(MetadataType.LANGUAGE, self.metadata['language']) self.book.set_metadata(MetadataType.LANGUAGE, self.metadata['language'])
if 'description' in self.metadata: 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: 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: if 'date' in self.metadata:
self.book.set_metadata(MetadataType.PUBLICATION_DATE, self.metadata['date']) self.book.set_metadata(MetadataType.PUBLICATION_DATE, self.metadata['date'])
if 'identifier' in self.metadata: if 'identifier' in self.metadata:
self.book.set_metadata(MetadataType.IDENTIFIER, self.metadata['identifier']) self.book.set_metadata(MetadataType.IDENTIFIER, self.metadata['identifier'])
if 'publisher' in self.metadata: if 'publisher' in self.metadata:
self.book.set_metadata(MetadataType.PUBLISHER, self.metadata['publisher']) self.book.set_metadata(MetadataType.PUBLISHER, self.metadata['publisher'])
def _add_cover_chapter(self): def _add_cover_chapter(self):
"""Add a cover chapter if a cover image is available.""" """Add a cover chapter if a cover image is available."""
if not self.cover_id or self.cover_id not in self.manifest: if not self.cover_id or self.cover_id not in self.manifest:
return return
# Get the cover image path from the manifest # Get the cover image path from the manifest
cover_item = self.manifest[self.cover_id] cover_item = self.manifest[self.cover_id]
cover_path = cover_item['path'] cover_path = cover_item['path']
# Check if the file exists # Check if the file exists
if not os.path.exists(cover_path): if not os.path.exists(cover_path):
print(f"Warning: Cover image file not found: {cover_path}") print(f"Warning: Cover image file not found: {cover_path}")
return return
# Create a cover chapter # Create a cover chapter
cover_chapter = self.book.create_chapter("Cover", 0) cover_chapter = self.book.create_chapter("Cover", 0)
try: try:
# Create an Image block for the cover # Create an Image block for the cover
from pyWebLayout.abstract.block import Image as AbstractImage from pyWebLayout.abstract.block import Image as AbstractImage
from PIL import Image as PILImage from PIL import Image as PILImage
import io import io
# Load the image into memory before the temp directory is cleaned up # 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: with open(cover_path, 'rb') as f:
image_bytes = f.read() image_bytes = f.read()
# Create PIL image from bytes in memory # Create PIL image from bytes in memory
pil_image = PILImage.open(io.BytesIO(image_bytes)) pil_image = PILImage.open(io.BytesIO(image_bytes))
pil_image.load() # Force loading into memory pil_image.load() # Force loading into memory
# Create a copy to ensure all data is in memory # Create a copy to ensure all data is in memory
pil_image = pil_image.copy() pil_image = pil_image.copy()
# Apply image processing if enabled # Apply image processing if enabled
if self.image_processor: if self.image_processor:
try: try:
@ -406,20 +421,21 @@ class EPUBReader:
except Exception as e: except Exception as e:
print(f"Warning: Image processing failed for cover: {str(e)}") print(f"Warning: Image processing failed for cover: {str(e)}")
# Continue with unprocessed image # Continue with unprocessed image
# Create an AbstractImage block with the cover image path # Create an AbstractImage block with the cover image path
cover_image = AbstractImage(source=cover_path, alt_text="Cover Image") cover_image = AbstractImage(source=cover_path, alt_text="Cover Image")
# Set dimensions from the loaded image # Set dimensions from the loaded image
cover_image._width = pil_image.width cover_image._width = pil_image.width
cover_image._height = pil_image.height 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 cover_image._loaded_image = pil_image
# Add the image to the cover chapter # Add the image to the cover chapter
cover_chapter.add_block(cover_image) cover_chapter.add_block(cover_image)
except Exception as e: except Exception as e:
print(f"Error creating cover chapter: {str(e)}") print(f"Error creating cover chapter: {str(e)}")
import traceback import traceback
@ -427,16 +443,16 @@ class EPUBReader:
# If we can't create the cover image, remove the chapter # If we can't create the cover image, remove the chapter
if hasattr(self.book, 'chapters') and cover_chapter in self.book.chapters: if hasattr(self.book, 'chapters') and cover_chapter in self.book.chapters:
self.book.chapters.remove(cover_chapter) self.book.chapters.remove(cover_chapter)
def _process_chapter_images(self, chapter: Chapter): def _process_chapter_images(self, chapter: Chapter):
""" """
Process images in a single chapter. Process images in a single chapter.
Args: Args:
chapter: The chapter containing images to process chapter: The chapter containing images to process
""" """
from pyWebLayout.abstract.block import Image as AbstractImage from pyWebLayout.abstract.block import Image as AbstractImage
for block in chapter.blocks: for block in chapter.blocks:
if isinstance(block, AbstractImage): if isinstance(block, AbstractImage):
# Only process if image has been loaded and processor is enabled # Only process if image has been loaded and processor is enabled
@ -444,25 +460,28 @@ class EPUBReader:
try: try:
block._loaded_image = self.image_processor(block._loaded_image) block._loaded_image = self.image_processor(block._loaded_image)
except Exception as e: 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 # Continue with unprocessed image
def _process_content_images(self): def _process_content_images(self):
"""Apply image processing to all images in chapters.""" """Apply image processing to all images in chapters."""
if not self.image_processor: if not self.image_processor:
return return
for chapter in self.book.chapters: for chapter in self.book.chapters:
self._process_chapter_images(chapter) self._process_chapter_images(chapter)
def _add_chapters(self): def _add_chapters(self):
"""Add chapters to the book based on the spine and TOC.""" """Add chapters to the book based on the spine and TOC."""
# Add cover chapter first if available # Add cover chapter first if available
self._add_cover_chapter() self._add_cover_chapter()
# Create a mapping from src to TOC entry # Create a mapping from src to TOC entry
toc_map = {} toc_map = {}
def add_to_toc_map(entries): def add_to_toc_map(entries):
for entry in entries: for entry in entries:
if entry['src']: if entry['src']:
@ -470,58 +489,58 @@ class EPUBReader:
src_parts = entry['src'].split('#', 1) src_parts = entry['src'].split('#', 1)
path = src_parts[0] path = src_parts[0]
toc_map[path] = entry toc_map[path] = entry
# Process children # Process children
if entry['children']: if entry['children']:
add_to_toc_map(entry['children']) add_to_toc_map(entry['children'])
add_to_toc_map(self.toc) add_to_toc_map(self.toc)
# Process spine items # Process spine items
# Start from chapter_index = 1 if cover was added, otherwise 0 # 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 chapter_index = 1 if (self.cover_id and self.cover_id in self.manifest) else 0
for i, idref in enumerate(self.spine): for i, idref in enumerate(self.spine):
if idref not in self.manifest: if idref not in self.manifest:
continue continue
item = self.manifest[idref] item = self.manifest[idref]
path = item['path'] path = item['path']
href = item['href'] href = item['href']
# Skip navigation files # Skip navigation files
if (idref == 'nav' or if (idref == 'nav' or
item.get('media_type') == 'application/xhtml+xml' and item.get('media_type') == 'application/xhtml+xml' and
('nav' in href.lower() or 'toc' in href.lower())): ('nav' in href.lower() or 'toc' in href.lower())):
continue continue
# Check if this item is in the TOC # Check if this item is in the TOC
chapter_title = None chapter_title = None
if href in toc_map: if href in toc_map:
chapter_title = toc_map[href]['label'] chapter_title = toc_map[href]['label']
# Create a chapter # Create a chapter
chapter_index += 1 chapter_index += 1
chapter = self.book.create_chapter(chapter_title, chapter_index) chapter = self.book.create_chapter(chapter_title, chapter_index)
# Parse the HTML content # Parse the HTML content
try: try:
# Read the HTML file # Read the HTML file
with open(path, 'r', encoding='utf-8') as f: with open(path, 'r', encoding='utf-8') as f:
html = f.read() html = f.read()
# Parse HTML and add blocks to chapter # Parse HTML and add blocks to chapter
blocks = parse_html_string(html, document=self.book) blocks = parse_html_string(html, document=self.book)
# Copy blocks to the chapter # Copy blocks to the chapter
for block in blocks: for block in blocks:
chapter.add_block(block) chapter.add_block(block)
# Add a PageBreak after the chapter to ensure next chapter starts on new page # Add a PageBreak after the chapter to ensure next chapter starts on new page
# This helps maintain chapter boundaries during pagination # This helps maintain chapter boundaries during pagination
chapter.add_block(PageBreak()) chapter.add_block(PageBreak())
except Exception as e: 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 # Add an error message block
from pyWebLayout.abstract.block import Paragraph from pyWebLayout.abstract.block import Paragraph
from pyWebLayout.abstract.inline import Word from pyWebLayout.abstract.inline import Word
@ -529,7 +548,11 @@ class EPUBReader:
error_para = Paragraph() error_para = Paragraph()
# Create a default font style for the error message # Create a default font style for the error message
default_font = Font() 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) chapter.add_block(error_para)
# Still add PageBreak even after error # Still add PageBreak even after error
chapter.add_block(PageBreak()) chapter.add_block(PageBreak())
@ -538,10 +561,10 @@ class EPUBReader:
def read_epub(epub_path: str) -> Book: def read_epub(epub_path: str) -> Book:
""" """
Read an EPUB file and convert it to a Book. Read an EPUB file and convert it to a Book.
Args: Args:
epub_path: Path to the EPUB file epub_path: Path to the EPUB file
Returns: Returns:
Book: The parsed book 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. 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 typing import List, Dict, Any, Optional, Union, Callable, Tuple, NamedTuple
from bs4 import BeautifulSoup, Tag, NavigableString from bs4 import BeautifulSoup, Tag, NavigableString
from pyWebLayout.abstract.inline import Word, FormattedSpan from pyWebLayout.abstract.inline import Word
from pyWebLayout.abstract.block import ( from pyWebLayout.abstract.block import (
Block, Block,
Paragraph, Paragraph,
@ -27,8 +26,6 @@ from pyWebLayout.abstract.block import (
Image, Image,
) )
from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration 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): class StyleContext(NamedTuple):
@ -72,7 +69,9 @@ class StyleContext(NamedTuple):
return self._replace(parent_elements=self.parent_elements + [element_name]) 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. 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) new_context = new_context.with_css_styles(css_styles)
# Apply element-specific default 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) new_context = new_context.with_font(font)
# Apply background from styles # Apply background from styles
@ -158,9 +158,11 @@ def parse_inline_styles(style_text: str) -> Dict[str, str]:
return styles return styles
def apply_element_font_styles( def apply_element_font_styles(font: Font,
font: Font, tag_name: str, css_styles: Dict[str, str], context: Optional[StyleContext] = None tag_name: str,
) -> Font: css_styles: Dict[str,
str],
context: Optional[StyleContext] = None) -> Font:
""" """
Apply font styling based on HTML element and CSS styles. Apply font styling based on HTML element and CSS styles.
Uses document's font registry when available to avoid creating duplicate fonts. Uses document's font registry when available to avoid creating duplicate fonts.
@ -273,17 +275,19 @@ def apply_element_font_styles(
pass pass
# Use document's style registry if available to avoid creating duplicate styles # 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 # Create an abstract style first
from pyWebLayout.style.abstract_style import FontFamily, FontSize from pyWebLayout.style.abstract_style import FontFamily, FontSize
# Map font properties to abstract style properties # Map font properties to abstract style properties
font_family = FontFamily.SERIF # Default - could be enhanced to detect from font_path font_family = FontFamily.SERIF # Default - could be enhanced to detect from font_path
if font_size: 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: else:
font_size_value = FontSize.MEDIUM font_size_value = FontSize.MEDIUM
# Create abstract style and register it # Create abstract style and register it
style_id, abstract_style = context.document.get_or_create_style( style_id, abstract_style = context.document.get_or_create_style(
font_family=font_family, font_family=font_family,
@ -294,7 +298,7 @@ def apply_element_font_styles(
color=colour, color=colour,
language=language language=language
) )
# Get the concrete font for this style # Get the concrete font for this style
return context.document.get_font_for_style(abstract_style) return context.document.get_font_for_style(abstract_style)
elif context and context.document and hasattr(context.document, 'get_or_create_font'): 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.inline import LinkedWord
from pyWebLayout.abstract.functional import LinkType from pyWebLayout.abstract.functional import LinkType
words = [] words = []
for child in element.children: for child in element.children:
@ -385,14 +389,14 @@ def extract_text_content(element: Tag, context: StyleContext) -> List[Word]:
link_type = LinkType.API link_type = LinkType.API
else: else:
link_type = LinkType.INTERNAL link_type = LinkType.INTERNAL
# Apply link styling # Apply link styling
child_context = apply_element_styling(context, child) child_context = apply_element_styling(context, child)
# Extract text and create LinkedWord for each word # Extract text and create LinkedWord for each word
link_text = child.get_text(strip=True) link_text = child.get_text(strip=True)
title = child.get('title', '') title = child.get('title', '')
for word_text in link_text.split(): for word_text in link_text.split():
if word_text: if word_text:
linked_word = LinkedWord( 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_context = apply_element_styling(context, child)
child_words = extract_text_content(child, child_context) child_words = extract_text_content(child, child_context)
words.extend(child_words) words.extend(child_words)
# Process other inline elements # Process other inline elements
elif child.name.lower() in [ elif child.name.lower() in [
"span", "span",
@ -435,7 +439,8 @@ def extract_text_content(element: Tag, context: StyleContext) -> List[Word]:
child_words = extract_text_content(child, child_context) child_words = extract_text_content(child, child_context)
words.extend(child_words) words.extend(child_words)
else: 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_context = apply_element_styling(context, child)
child_result = process_element(child, child_context) child_result = process_element(child, child_context)
if isinstance(child_result, list): if isinstance(child_result, list):
@ -469,7 +474,8 @@ def process_element(
# Handler function signatures: # 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: 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 import Page, Line, Text
from pyWebLayout.concrete.image import RenderableImage 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.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.block import Image as AbstractImage, PageBreak, Table
from pyWebLayout.abstract.functional import Button, Form, FormField 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.concrete_style import ConcreteStyleRegistry, RenderingContext, StyleResolver
from pyWebLayout.style import Font, Alignment 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. 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 # 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 # We need to get word spacing constraints from the Font's abstract style if available
# For now, use reasonable defaults based on font size # For now, use reasonable defaults based on font size
if isinstance(paragraph.style, Font): if isinstance(paragraph.style, Font):
# paragraph.style is already a Font (concrete style) # paragraph.style is already a Font (concrete style)
font = paragraph.style font = paragraph.style
@ -63,7 +69,7 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
base_font_size = 16 base_font_size = 16
else: else:
base_font_size = int(paragraph.style.font_size) base_font_size = int(paragraph.style.font_size)
rendering_context = RenderingContext(base_font_size=base_font_size) rendering_context = RenderingContext(base_font_size=base_font_size)
style_resolver = StyleResolver(rendering_context) style_resolver = StyleResolver(rendering_context)
style_registry = ConcreteStyleRegistry(style_resolver) 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 text_align = concrete_style.text_align
# Apply page-level word spacing override if specified # 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 # Add the page-level word spacing to both min and max constraints
min_ws, max_ws = word_spacing_constraints min_ws, max_ws = word_spacing_constraints
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 # Apply alignment override if provided
if alignment_override is not None: if alignment_override is not None:
text_align = alignment_override text_align = alignment_override
# Cap font size to page maximum if needed # Cap font size to page maximum if needed
if font.font_size > page.style.max_font_size: if font.font_size > page.style.max_font_size:
font = Font( font = Font(
@ -99,7 +109,7 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
decoration=font.decoration, decoration=font.decoration,
background=font.background background=font.background
) )
# Calculate baseline-to-baseline spacing: font size + additional line spacing # Calculate baseline-to-baseline spacing: font size + additional line spacing
# This is the vertical distance between baselines of consecutive lines # This is the vertical distance between baselines of consecutive lines
# Formula: baseline_spacing = font_size + line_spacing (absolute pixels) # 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): if not isinstance(line_spacing_value, int):
line_spacing_value = 5 line_spacing_value = 5
baseline_spacing = font.font_size + line_spacing_value baseline_spacing = font.font_size + line_spacing_value
# Get font metrics for boundary checking # Get font metrics for boundary checking
ascent, descent = font.font.getmetrics() 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.""" """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 # Check if this line's baseline and descenders would fit on the page
if not page.can_fit_line(baseline_spacing, ascent, descent): if not page.can_fit_line(baseline_spacing, ascent, descent):
return None return None
# For the first line, position it so text starts at the top boundary # 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: if is_first_line:
# Position line origin so that baseline (origin + ascent) is close to top # 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 # 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 # Create a temporary Text object to calculate word width
if word: if word:
temp_text = Text.from_word(word, page.draw) temp_text = Text.from_word(word, page.draw)
word_width = temp_text.width temp_text.width
else: else:
word_width = 0 pass
return Line( return Line(
spacing=word_spacing_constraints, 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 # but we may want to create LinkText for LinkedWord instances in future
# For now, the abstract layer (LinkedWord) carries the link info, # For now, the abstract layer (LinkedWord) carries the link info,
# and the concrete layer (LinkText) would be created during rendering # and the concrete layer (LinkText) would be created during rendering
success, overflow_text = current_line.add_word(word, current_pretext) success, overflow_text = current_line.add_word(word, current_pretext)
if success: 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 # Word is too wide for the line, we need to hyphenate it
if len(word.text) >= 6: if len(word.text) >= 6:
# Try to hyphenate the word # 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: if len(splits) > 0:
# Use the first hyphenation point # Use the first hyphenation point
first_part, second_part = splits[0] 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: def pagebreak_layouter(page_break: PageBreak, page: Page) -> bool:
""" """
Handle a page break element. Handle a page break element.
A page break signals that all subsequent content should start on a new page. 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 This function always returns False to indicate that the current page is complete
and a new page should be created for subsequent content. and a new page should be created for subsequent content.
Args: Args:
page_break: The PageBreak block page_break: The PageBreak block
page: The current page (not used, but kept for consistency) page: The current page (not used, but kept for consistency)
Returns: Returns:
bool: Always False to force creation of a new page 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 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: max_height: Optional[int] = None) -> bool:
""" """
Layout an image within a given page. Layout an image within a given page.
This function places an image on the page, respecting size constraints This function places an image on the page, respecting size constraints
and available space. Images are centered horizontally by default. and available space. Images are centered horizontally by default.
Args: Args:
image: The abstract Image object to layout image: The abstract Image object to layout
page: The page to layout the image on page: The page to layout the image on
max_width: Maximum width constraint (defaults to page available width) max_width: Maximum width constraint (defaults to page available width)
max_height: Maximum height constraint (defaults to remaining page height) max_height: Maximum height constraint (defaults to remaining page height)
Returns: Returns:
bool: True if image was successfully laid out, False if page ran out of space bool: True if image was successfully laid out, False if page ran out of space
""" """
# Use page available width if max_width not specified # Use page available width if max_width not specified
if max_width is None: if max_width is None:
max_width = page.available_width max_width = page.available_width
# Calculate available height on page # Calculate available height on page
available_height = page.size[1] - page._current_y_offset - page.border_size available_height = page.size[1] - page._current_y_offset - page.border_size
if max_height is None: if max_height is None:
max_height = available_height max_height = available_height
else: else:
max_height = min(max_height, available_height) max_height = min(max_height, available_height)
# Calculate scaled dimensions # 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 # Check if image fits on current page
if scaled_height is None or scaled_height > available_height: if scaled_height is None or scaled_height > available_height:
return False return False
# Create renderable image # Create renderable image
x_offset = page.border_size x_offset = page.border_size
y_offset = page._current_y_offset y_offset = page._current_y_offset
# Access page.draw to ensure canvas is initialized # Access page.draw to ensure canvas is initialized
_ = page.draw _ = page.draw
renderable_image = RenderableImage( renderable_image = RenderableImage(
image=image, image=image,
canvas=page._canvas, canvas=page._canvas,
@ -298,14 +323,17 @@ def image_layouter(image: AbstractImage, page: Page, max_width: Optional[int] =
halign=Alignment.CENTER, halign=Alignment.CENTER,
valign=Alignment.TOP valign=Alignment.TOP
) )
# Add to page # Add to page
page.add_child(renderable_image) page.add_child(renderable_image)
return True 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. Layout a table within a given page.
@ -356,8 +384,17 @@ def table_layouter(table: Table, page: Page, style: Optional[TableStyle] = None)
return True return True
def button_layouter(button: Button, page: Page, font: Optional[Font] = None, def button_layouter(button: Button,
padding: Tuple[int, int, int, int] = (4, 8, 4, 8)) -> Tuple[bool, str]: 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. 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: class DocumentLayouter:
""" """
Document layouter that orchestrates layout of various abstract elements. Document layouter that orchestrates layout of various abstract elements.
Delegates to specialized layouters for different content types: Delegates to specialized layouters for different content types:
- paragraph_layouter for text paragraphs - paragraph_layouter for text paragraphs
- image_layouter for images - image_layouter for images
- table_layouter for tables - table_layouter for tables
This class acts as a coordinator, managing the overall document flow This class acts as a coordinator, managing the overall document flow
and page context while delegating specific layout tasks to specialized and page context while delegating specific layout tasks to specialized
layouter functions. layouter functions.
""" """
def __init__(self, page: Page): def __init__(self, page: Page):
""" """
Initialize the document layouter with a page. Initialize the document layouter with a page.
@ -538,24 +575,28 @@ class DocumentLayouter:
context = RenderingContext() context = RenderingContext()
style_resolver = StyleResolver(context) style_resolver = StyleResolver(context)
self.style_registry = ConcreteStyleRegistry(style_resolver) self.style_registry = ConcreteStyleRegistry(style_resolver)
def layout_paragraph(self, paragraph: Paragraph, start_word: int = 0, def layout_paragraph(self,
pretext: Optional[Text] = None) -> Tuple[bool, Optional[int], Optional[Text]]: paragraph: Paragraph,
start_word: int = 0,
pretext: Optional[Text] = None) -> Tuple[bool,
Optional[int],
Optional[Text]]:
""" """
Layout a paragraph using the paragraph_layouter. Layout a paragraph using the paragraph_layouter.
Args: Args:
paragraph: The paragraph to layout paragraph: The paragraph to layout
start_word: Index of the first word to process (for continuation) start_word: Index of the first word to process (for continuation)
pretext: Optional pretext from a previous hyphenated word pretext: Optional pretext from a previous hyphenated word
Returns: Returns:
Tuple of (success, failed_word_index, remaining_pretext) Tuple of (success, failed_word_index, remaining_pretext)
""" """
return paragraph_layouter(paragraph, self.page, start_word, pretext) return paragraph_layouter(paragraph, self.page, start_word, pretext)
def layout_image(self, image: AbstractImage, max_width: Optional[int] = None, 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. Layout an image using the image_layouter.
@ -582,8 +623,17 @@ class DocumentLayouter:
""" """
return table_layouter(table, self.page, style) return table_layouter(table, self.page, style)
def layout_button(self, button: Button, font: Optional[Font] = None, def layout_button(self,
padding: Tuple[int, int, int, int] = (4, 8, 4, 8)) -> Tuple[bool, str]: 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. Layout a button using the button_layouter.
@ -612,7 +662,8 @@ class DocumentLayouter:
""" """
return form_layouter(form, self.page, font, field_spacing) 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). 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 __future__ import annotations
from dataclasses import dataclass, asdict from dataclasses import dataclass, asdict
from typing import List, Dict, Tuple, Optional, Union, Generator, Any from typing import List, Dict, Tuple, Optional, Any
from enum import Enum
import json
import multiprocessing
from concurrent.futures import ProcessPoolExecutor, as_completed
import threading
import time
from pyWebLayout.abstract.block import Block, Paragraph, Heading, HeadingLevel, Table, HList from pyWebLayout.abstract.block import Block, Paragraph, Heading, HeadingLevel, Table, HList
from pyWebLayout.abstract.inline import Word from pyWebLayout.abstract.inline import Word
from pyWebLayout.concrete.page import Page 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.page_style import PageStyle
from pyWebLayout.style import Font from pyWebLayout.style import Font
from pyWebLayout.layout.document_layouter import paragraph_layouter from pyWebLayout.layout.document_layouter import paragraph_layouter
@ -38,32 +32,33 @@ class RenderingPosition:
""" """
chapter_index: int = 0 # Which chapter (based on headings) chapter_index: int = 0 # Which chapter (based on headings)
block_index: int = 0 # Which block within chapter 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_row: int = 0 # Which row for tables
table_col: int = 0 # Which column for tables table_col: int = 0 # Which column for tables
list_item_index: int = 0 # Which item for lists list_item_index: int = 0 # Which item for lists
remaining_pretext: Optional[str] = None # Hyphenated word continuation remaining_pretext: Optional[str] = None # Hyphenated word continuation
page_y_offset: int = 0 # Vertical position on page page_y_offset: int = 0 # Vertical position on page
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
"""Serialize position for saving to file/database""" """Serialize position for saving to file/database"""
return asdict(self) return asdict(self)
@classmethod @classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'RenderingPosition': def from_dict(cls, data: Dict[str, Any]) -> 'RenderingPosition':
"""Deserialize position from saved state""" """Deserialize position from saved state"""
return cls(**data) return cls(**data)
def copy(self) -> 'RenderingPosition': def copy(self) -> 'RenderingPosition':
"""Create a copy of this position""" """Create a copy of this position"""
return RenderingPosition(**asdict(self)) return RenderingPosition(**asdict(self))
def __eq__(self, other) -> bool: def __eq__(self, other) -> bool:
"""Check if two positions are equal""" """Check if two positions are equal"""
if not isinstance(other, RenderingPosition): if not isinstance(other, RenderingPosition):
return False return False
return asdict(self) == asdict(other) return asdict(self) == asdict(other)
def __hash__(self) -> int: def __hash__(self) -> int:
"""Make position hashable for use as dict key""" """Make position hashable for use as dict key"""
return hash(tuple(asdict(self).values())) return hash(tuple(asdict(self).values()))
@ -71,8 +66,13 @@ class RenderingPosition:
class ChapterInfo: class ChapterInfo:
"""Information about a chapter/section in the document""" """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.title = title
self.level = level self.level = level
self.position = position self.position = position
@ -84,16 +84,16 @@ class ChapterNavigator:
Handles chapter/section navigation based on HTML heading structure (H1-H6). Handles chapter/section navigation based on HTML heading structure (H1-H6).
Builds a table of contents and provides navigation capabilities. Builds a table of contents and provides navigation capabilities.
""" """
def __init__(self, blocks: List[Block]): def __init__(self, blocks: List[Block]):
self.blocks = blocks self.blocks = blocks
self.chapters: List[ChapterInfo] = [] self.chapters: List[ChapterInfo] = []
self._build_chapter_map() self._build_chapter_map()
def _build_chapter_map(self): def _build_chapter_map(self):
"""Scan blocks for headings and build chapter navigation map""" """Scan blocks for headings and build chapter navigation map"""
current_chapter_index = 0 current_chapter_index = 0
for block_index, block in enumerate(self.blocks): for block_index, block in enumerate(self.blocks):
if isinstance(block, Heading): if isinstance(block, Heading):
# Create position for this heading # Create position for this heading
@ -105,23 +105,23 @@ class ChapterNavigator:
table_col=0, table_col=0,
list_item_index=0 list_item_index=0
) )
# Extract heading text # Extract heading text
heading_text = self._extract_heading_text(block) heading_text = self._extract_heading_text(block)
chapter_info = ChapterInfo( chapter_info = ChapterInfo(
title=heading_text, title=heading_text,
level=block.level, level=block.level,
position=position, position=position,
block_index=block_index block_index=block_index
) )
self.chapters.append(chapter_info) self.chapters.append(chapter_info)
# Only increment chapter index for top-level headings (H1) # Only increment chapter index for top-level headings (H1)
if block.level == HeadingLevel.H1: if block.level == HeadingLevel.H1:
current_chapter_index += 1 current_chapter_index += 1
def _extract_heading_text(self, heading: Heading) -> str: def _extract_heading_text(self, heading: Heading) -> str:
"""Extract text content from a heading block""" """Extract text content from a heading block"""
words = [] words = []
@ -129,33 +129,35 @@ class ChapterNavigator:
if isinstance(word, Word): if isinstance(word, Word):
words.append(word.text) words.append(word.text)
return " ".join(words) 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""" """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]: def get_chapter_position(self, chapter_title: str) -> Optional[RenderingPosition]:
"""Get rendering position for a chapter by title""" """Get rendering position for a chapter by title"""
for chapter in self.chapters: for chapter in self.chapters:
if chapter.title.lower() == chapter_title.lower(): if chapter.title.lower() == chapter_title.lower():
return chapter.position return chapter.position
return None return None
def get_current_chapter(self, position: RenderingPosition) -> Optional[ChapterInfo]: def get_current_chapter(self, position: RenderingPosition) -> Optional[ChapterInfo]:
"""Determine which chapter contains the current position""" """Determine which chapter contains the current position"""
if not self.chapters: if not self.chapters:
return None return None
# Find the chapter that contains this position # Find the chapter that contains this position
for i, chapter in enumerate(self.chapters): for i, chapter in enumerate(self.chapters):
# Check if this is the last chapter or if position is before next chapter # Check if this is the last chapter or if position is before next chapter
if i == len(self.chapters) - 1: if i == len(self.chapters) - 1:
return chapter return chapter
next_chapter = self.chapters[i + 1] next_chapter = self.chapters[i + 1]
if position.chapter_index < next_chapter.position.chapter_index: if position.chapter_index < next_chapter.position.chapter_index:
return chapter return chapter
return self.chapters[0] if self.chapters else None return self.chapters[0] if self.chapters else None
@ -164,24 +166,24 @@ class FontScaler:
Handles font scaling operations for ereader font size adjustments. Handles font scaling operations for ereader font size adjustments.
Applies scaling at layout/render time while preserving original font objects. Applies scaling at layout/render time while preserving original font objects.
""" """
@staticmethod @staticmethod
def scale_font(font: Font, scale_factor: float) -> Font: def scale_font(font: Font, scale_factor: float) -> Font:
""" """
Create a scaled version of a font for layout calculations. Create a scaled version of a font for layout calculations.
Args: Args:
font: Original font object font: Original font object
scale_factor: Scaling factor (1.0 = no change, 2.0 = double size, etc.) scale_factor: Scaling factor (1.0 = no change, 2.0 = double size, etc.)
Returns: Returns:
New Font object with scaled size New Font object with scaled size
""" """
if scale_factor == 1.0: if scale_factor == 1.0:
return font return font
scaled_size = max(1, int(font.font_size * scale_factor)) scaled_size = max(1, int(font.font_size * scale_factor))
return Font( return Font(
font_path=font._font_path, font_path=font._font_path,
font_size=scaled_size, font_size=scaled_size,
@ -193,13 +195,14 @@ class FontScaler:
language=font.language, language=font.language,
min_hyphenation_width=font.min_hyphenation_width min_hyphenation_width=font.min_hyphenation_width
) )
@staticmethod @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""" """Scale word spacing constraints proportionally"""
if scale_factor == 1.0: if scale_factor == 1.0:
return spacing return spacing
min_spacing, max_spacing = spacing min_spacing, max_spacing = spacing
return ( return (
max(1, int(min_spacing * scale_factor)), max(1, int(min_spacing * scale_factor)),
@ -212,41 +215,49 @@ class BidirectionalLayouter:
Core layout engine supporting both forward and backward page rendering. Core layout engine supporting both forward and backward page rendering.
Handles font scaling and maintains position state. 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.blocks = blocks
self.page_style = page_style self.page_style = page_style
self.page_size = page_size self.page_size = page_size
self.chapter_navigator = ChapterNavigator(blocks) self.chapter_navigator = ChapterNavigator(blocks)
self.alignment_override = alignment_override 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. Render a page starting from the given position, moving forward through the document.
Args: Args:
position: Starting position in document position: Starting position in document
font_scale: Font scaling factor font_scale: Font scaling factor
Returns: Returns:
Tuple of (rendered_page, next_position) Tuple of (rendered_page, next_position)
""" """
page = Page(size=self.page_size, style=self.page_style) page = Page(size=self.page_size, style=self.page_style)
current_pos = position.copy() current_pos = position.copy()
# Start laying out blocks from the current position # Start laying out blocks from the current position
while current_pos.block_index < len(self.blocks) and page.free_space()[1] > 0: while current_pos.block_index < len(self.blocks) and page.free_space()[1] > 0:
# Additional bounds check to prevent IndexError # Additional bounds check to prevent IndexError
if current_pos.block_index >= len(self.blocks): if current_pos.block_index >= len(self.blocks):
break break
block = self.blocks[current_pos.block_index] block = self.blocks[current_pos.block_index]
# Apply font scaling to the block # Apply font scaling to the block
scaled_block = self._scale_block_fonts(block, font_scale) scaled_block = self._scale_block_fonts(block, font_scale)
# Try to fit the block on the current page # 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: if not success:
# Block doesn't fit, we're done with this page # Block doesn't fit, we're done with this page
@ -262,45 +273,50 @@ class BidirectionalLayouter:
# We've reached the end of the document # We've reached the end of the document
current_pos = new_pos current_pos = new_pos
break break
current_pos = new_pos current_pos = new_pos
return page, current_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. Render a page that ends at the given position, filling backward.
Critical for "previous page" navigation. Critical for "previous page" navigation.
Args: Args:
end_position: Position where page should end end_position: Position where page should end
font_scale: Font scaling factor font_scale: Font scaling factor
Returns: Returns:
Tuple of (rendered_page, start_position) Tuple of (rendered_page, start_position)
""" """
# This is a complex operation that requires iterative refinement # This is a complex operation that requires iterative refinement
# We'll start with an estimated start position and refine it # We'll start with an estimated start position and refine it
estimated_start = self._estimate_page_start(end_position, font_scale) estimated_start = self._estimate_page_start(end_position, font_scale)
# Render forward from estimated start and see if we reach the target # Render forward from estimated start and see if we reach the target
page, actual_end = self.render_page_forward(estimated_start, font_scale) page, actual_end = self.render_page_forward(estimated_start, font_scale)
# If we overshot or undershot, adjust and try again # 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: if self._position_compare(actual_end, end_position) != 0:
# Adjust estimate and try again (simplified) # 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) page, actual_end = self.render_page_forward(estimated_start, font_scale)
return page, estimated_start return page, estimated_start
def _scale_block_fonts(self, block: Block, font_scale: float) -> Block: def _scale_block_fonts(self, block: Block, font_scale: float) -> Block:
"""Apply font scaling to all fonts in a block""" """Apply font scaling to all fonts in a block"""
if font_scale == 1.0: if font_scale == 1.0:
return block return block
# This is a simplified implementation # This is a simplified implementation
# In practice, we'd need to handle each block type appropriately # In practice, we'd need to handle each block type appropriately
if isinstance(block, (Paragraph, Heading)): if isinstance(block, (Paragraph, Heading)):
@ -309,20 +325,27 @@ class BidirectionalLayouter:
scaled_block = Heading(block.level, scaled_block_style) scaled_block = Heading(block.level, scaled_block_style)
else: else:
scaled_block = Paragraph(scaled_block_style) scaled_block = Paragraph(scaled_block_style)
# words_iter() returns tuples of (position, word) # words_iter() returns tuples of (position, word)
for position, word in block.words_iter(): for position, word in block.words_iter():
if isinstance(word, Word): 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) scaled_block.add_word(scaled_word)
return scaled_block return scaled_block
return 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. Try to layout a block on the page starting from the given position.
Returns: Returns:
Tuple of (success, new_position) Tuple of (success, new_position)
""" """
@ -339,18 +362,23 @@ class BidirectionalLayouter:
new_pos = position.copy() new_pos = position.copy()
new_pos.block_index += 1 new_pos.block_index += 1
return True, new_pos 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. Layout a paragraph on the page using the core paragraph_layouter.
Integrates font scaling and position tracking with the proven layout logic. Integrates font scaling and position tracking with the proven layout logic.
Args: Args:
paragraph: The paragraph to layout (already scaled if font_scale != 1.0) paragraph: The paragraph to layout (already scaled if font_scale != 1.0)
page: The page to layout on page: The page to layout on
position: Current rendering position position: Current rendering position
font_scale: Font scaling factor (used for context, paragraph should already be scaled) font_scale: Font scaling factor (used for context, paragraph should already be scaled)
Returns: Returns:
Tuple of (success, new_position) Tuple of (success, new_position)
""" """
@ -365,7 +393,7 @@ class BidirectionalLayouter:
line=None, line=None,
source=None source=None
) )
# Call the core paragraph layouter with alignment override if set # Call the core paragraph layouter with alignment override if set
success, failed_word_index, remaining_pretext = paragraph_layouter( success, failed_word_index, remaining_pretext = paragraph_layouter(
paragraph, paragraph,
@ -374,10 +402,10 @@ class BidirectionalLayouter:
pretext=pretext_obj, pretext=pretext_obj,
alignment_override=self.alignment_override alignment_override=self.alignment_override
) )
# Create new position based on the result # Create new position based on the result
new_pos = position.copy() new_pos = position.copy()
if success: if success:
# Paragraph was fully laid out, move to next block # Paragraph was fully laid out, move to next block
new_pos.block_index += 1 new_pos.block_index += 1
@ -389,25 +417,35 @@ class BidirectionalLayouter:
if failed_word_index is not None: if failed_word_index is not None:
# Update position to the word that didn't fit # Update position to the word that didn't fit
new_pos.word_index = failed_word_index new_pos.word_index = failed_word_index
# Convert Text object back to string if there's remaining pretext # Convert Text object back to string if there's remaining pretext
if remaining_pretext is not None and hasattr(remaining_pretext, 'text'): if remaining_pretext is not None and hasattr(remaining_pretext, 'text'):
new_pos.remaining_pretext = remaining_pretext.text new_pos.remaining_pretext = remaining_pretext.text
else: else:
new_pos.remaining_pretext = None new_pos.remaining_pretext = None
return False, new_pos return False, new_pos
else: else:
# No specific word failed, but layout wasn't successful # No specific word failed, but layout wasn't successful
# This shouldn't normally happen, but handle it gracefully # This shouldn't normally happen, but handle it gracefully
return False, position 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""" """Layout a heading on the page"""
# Similar to paragraph but with heading-specific styling # Similar to paragraph but with heading-specific styling
return self._layout_paragraph_on_page(heading, page, position, font_scale) 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""" """Layout a table on the page with column fitting and row continuation"""
# This is a complex operation that would need full table layout logic # This is a complex operation that would need full table layout logic
# For now, skip tables # For now, skip tables
@ -416,8 +454,13 @@ class BidirectionalLayouter:
new_pos.table_row = 0 new_pos.table_row = 0
new_pos.table_col = 0 new_pos.table_col = 0
return True, new_pos 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""" """Layout a list on the page"""
# This would need list-specific layout logic # This would need list-specific layout logic
# For now, skip lists # For now, skip lists
@ -425,33 +468,43 @@ class BidirectionalLayouter:
new_pos.block_index += 1 new_pos.block_index += 1
new_pos.list_item_index = 0 new_pos.list_item_index = 0
return True, new_pos 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""" """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() estimated_start = end_position.copy()
# Move back by an estimated number of blocks that would fit on a page # 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_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 estimated_start.word_index = 0
return estimated_start 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""" """Adjust start position estimate based on overshoot/undershoot"""
# Simplified adjustment logic # Simplified adjustment logic
adjusted = current_start.copy() adjusted = current_start.copy()
comparison = self._position_compare(actual_end, target_end) comparison = self._position_compare(actual_end, target_end)
if comparison > 0: # Overshot if comparison > 0: # Overshot
adjusted.block_index = max(0, adjusted.block_index + 1) adjusted.block_index = max(0, adjusted.block_index + 1)
elif comparison < 0: # Undershot elif comparison < 0: # Undershot
adjusted.block_index = max(0, adjusted.block_index - 1) adjusted.block_index = max(0, adjusted.block_index - 1)
return adjusted 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)""" """Compare two positions (-1: pos1 < pos2, 0: equal, 1: pos1 > pos2)"""
if pos1.chapter_index != pos2.chapter_index: if pos1.chapter_index != pos2.chapter_index:
return 1 if pos1.chapter_index > pos2.chapter_index else -1 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""" """Check if a line of given height can fit on the page"""
available_height = self.content_size[1] - self._current_y_offset available_height = self.content_size[1] - self._current_y_offset
return available_height >= line_height return available_height >= line_height
Page.can_fit_line = can_fit_line Page.can_fit_line = can_fit_line
if not hasattr(Page, 'available_width'): if not hasattr(Page, 'available_width'):
@property @property
def available_width(self) -> int: def available_width(self) -> int:
"""Get available width for content""" """Get available width for content"""
return self.content_size[0] return self.content_size[0]
Page.available_width = available_width Page.available_width = available_width
# Apply the page methods # Apply the page methods
_add_page_methods() _add_page_methods()

View File

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

View File

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

View File

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

View File

@ -6,6 +6,7 @@ This module provides alignment-related functionality.
from enum import Enum from enum import Enum
class Alignment(Enum): class Alignment(Enum):
"""Text and box alignment options""" """Text and box alignment options"""
# Horizontal alignment # Horizontal alignment
@ -13,10 +14,10 @@ class Alignment(Enum):
RIGHT = "right" RIGHT = "right"
CENTER = "center" CENTER = "center"
JUSTIFY = "justify" JUSTIFY = "justify"
# Vertical alignment # Vertical alignment
TOP = "top" TOP = "top"
MIDDLE = "middle" MIDDLE = "middle"
BOTTOM = "bottom" BOTTOM = "bottom"
def __str__(self): 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. 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 dataclasses import dataclass
from .abstract_style import AbstractStyle, FontFamily, FontSize from .abstract_style import AbstractStyle, FontFamily, FontSize
from pyWebLayout.style.alignment import Alignment as TextAlign from pyWebLayout.style.alignment import Alignment as TextAlign
from .fonts import Font, FontWeight, FontStyle, TextDecoration from .fonts import Font, FontWeight, FontStyle, TextDecoration
import os
@dataclass(frozen=True) @dataclass(frozen=True)
@ -19,24 +18,24 @@ class RenderingContext:
Context information for style resolution. Context information for style resolution.
Contains user preferences and device capabilities. Contains user preferences and device capabilities.
""" """
# User preferences # User preferences
base_font_size: int = 16 # Base font size in points base_font_size: int = 16 # Base font size in points
font_scale_factor: float = 1.0 # Global font scaling font_scale_factor: float = 1.0 # Global font scaling
preferred_serif_font: Optional[str] = None preferred_serif_font: Optional[str] = None
preferred_sans_serif_font: Optional[str] = None preferred_sans_serif_font: Optional[str] = None
preferred_monospace_font: Optional[str] = None preferred_monospace_font: Optional[str] = None
# Device/environment info # Device/environment info
dpi: int = 96 # Dots per inch dpi: int = 96 # Dots per inch
available_width: Optional[int] = None # Available width in pixels available_width: Optional[int] = None # Available width in pixels
available_height: Optional[int] = None # Available height in pixels available_height: Optional[int] = None # Available height in pixels
# Accessibility preferences # Accessibility preferences
high_contrast: bool = False high_contrast: bool = False
large_text: bool = False large_text: bool = False
reduce_motion: bool = False reduce_motion: bool = False
# Language and locale # Language and locale
default_language: str = "en-US" default_language: str = "en-US"
@ -45,22 +44,22 @@ class RenderingContext:
class ConcreteStyle: class ConcreteStyle:
""" """
Concrete representation of text styling with actual rendering parameters. Concrete representation of text styling with actual rendering parameters.
This contains the resolved font files, pixel sizes, actual colors, etc. This contains the resolved font files, pixel sizes, actual colors, etc.
that will be used for rendering. This is also hashable for efficient caching. that will be used for rendering. This is also hashable for efficient caching.
""" """
# Concrete font properties # Concrete font properties
font_path: Optional[str] = None font_path: Optional[str] = None
font_size: int = 16 # Always in points/pixels font_size: int = 16 # Always in points/pixels
color: Tuple[int, int, int] = (0, 0, 0) # Always RGB color: Tuple[int, int, int] = (0, 0, 0) # Always RGB
background_color: Optional[Tuple[int, int, int, int]] = None # Always RGBA or None background_color: Optional[Tuple[int, int, int, int]] = None # Always RGBA or None
# Font attributes # Font attributes
weight: FontWeight = FontWeight.NORMAL weight: FontWeight = FontWeight.NORMAL
style: FontStyle = FontStyle.NORMAL style: FontStyle = FontStyle.NORMAL
decoration: TextDecoration = TextDecoration.NONE decoration: TextDecoration = TextDecoration.NONE
# Layout properties # Layout properties
text_align: TextAlign = TextAlign.LEFT text_align: TextAlign = TextAlign.LEFT
line_height: float = 1.0 # Multiplier line_height: float = 1.0 # Multiplier
@ -68,14 +67,14 @@ class ConcreteStyle:
word_spacing: float = 0.0 # In pixels word_spacing: float = 0.0 # In pixels
word_spacing_min: float = 0.0 # Minimum word spacing in pixels word_spacing_min: float = 0.0 # Minimum word spacing in pixels
word_spacing_max: float = 0.0 # Maximum word spacing in pixels word_spacing_max: float = 0.0 # Maximum word spacing in pixels
# Language and locale # Language and locale
language: str = "en-US" language: str = "en-US"
min_hyphenation_width: int = 64 # In pixels min_hyphenation_width: int = 64 # In pixels
# Reference to source abstract style # Reference to source abstract style
abstract_style: Optional[AbstractStyle] = None abstract_style: Optional[AbstractStyle] = None
def create_font(self) -> Font: def create_font(self) -> Font:
"""Create a Font object from this concrete style.""" """Create a Font object from this concrete style."""
return Font( return Font(
@ -94,21 +93,21 @@ class ConcreteStyle:
class StyleResolver: class StyleResolver:
""" """
Resolves abstract styles to concrete styles based on rendering context. Resolves abstract styles to concrete styles based on rendering context.
This class handles the conversion from semantic styling intent to actual This class handles the conversion from semantic styling intent to actual
rendering parameters, applying user preferences and device capabilities. rendering parameters, applying user preferences and device capabilities.
""" """
def __init__(self, context: RenderingContext): def __init__(self, context: RenderingContext):
""" """
Initialize the style resolver with a rendering context. Initialize the style resolver with a rendering context.
Args: Args:
context: RenderingContext with user preferences and device info context: RenderingContext with user preferences and device info
""" """
self.context = context self.context = context
self._concrete_cache: Dict[AbstractStyle, ConcreteStyle] = {} self._concrete_cache: Dict[AbstractStyle, ConcreteStyle] = {}
# Font size mapping for semantic sizes # Font size mapping for semantic sizes
self._semantic_font_sizes = { self._semantic_font_sizes = {
FontSize.XX_SMALL: 0.6, FontSize.XX_SMALL: 0.6,
@ -119,7 +118,7 @@ class StyleResolver:
FontSize.X_LARGE: 1.5, FontSize.X_LARGE: 1.5,
FontSize.XX_LARGE: 2.0, FontSize.XX_LARGE: 2.0,
} }
# Color name mapping # Color name mapping
self._color_names = { self._color_names = {
"black": (0, 0, 0), "black": (0, 0, 0),
@ -141,35 +140,40 @@ class StyleResolver:
"fuchsia": (255, 0, 255), "fuchsia": (255, 0, 255),
"purple": (128, 0, 128), "purple": (128, 0, 128),
} }
def resolve_style(self, abstract_style: AbstractStyle) -> ConcreteStyle: def resolve_style(self, abstract_style: AbstractStyle) -> ConcreteStyle:
""" """
Resolve an abstract style to a concrete style. Resolve an abstract style to a concrete style.
Args: Args:
abstract_style: AbstractStyle to resolve abstract_style: AbstractStyle to resolve
Returns: Returns:
ConcreteStyle with concrete rendering parameters ConcreteStyle with concrete rendering parameters
""" """
# Check cache first # Check cache first
if abstract_style in self._concrete_cache: if abstract_style in self._concrete_cache:
return self._concrete_cache[abstract_style] return self._concrete_cache[abstract_style]
# Resolve each property # Resolve each property
font_path = self._resolve_font_path(abstract_style.font_family) font_path = self._resolve_font_path(abstract_style.font_family)
font_size = self._resolve_font_size(abstract_style.font_size) font_size = self._resolve_font_size(abstract_style.font_size)
# Ensure font_size is always an int before using in arithmetic # Ensure font_size is always an int before using in arithmetic
font_size = int(font_size) font_size = int(font_size)
color = self._resolve_color(abstract_style.color) 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) line_height = self._resolve_line_height(abstract_style.line_height)
letter_spacing = self._resolve_letter_spacing(abstract_style.letter_spacing, font_size) letter_spacing = self._resolve_letter_spacing(
word_spacing = self._resolve_word_spacing(abstract_style.word_spacing, font_size) abstract_style.letter_spacing, font_size)
word_spacing_min = self._resolve_word_spacing(abstract_style.word_spacing_min, font_size) word_spacing = self._resolve_word_spacing(
word_spacing_max = self._resolve_word_spacing(abstract_style.word_spacing_max, font_size) 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 min_hyphenation_width = max(int(font_size) * 4, 32) # At least 32 pixels
# Apply default logic for word spacing constraints # Apply default logic for word spacing constraints
if word_spacing_min == 0.0 and word_spacing_max == 0.0: if word_spacing_min == 0.0 and word_spacing_max == 0.0:
# If no constraints specified, use base word_spacing as reference # If no constraints specified, use base word_spacing as reference
@ -186,7 +190,7 @@ class StyleResolver:
elif word_spacing_max == 0.0: elif word_spacing_max == 0.0:
# Only min specified, use base word_spacing or reasonable multiple # Only min specified, use base word_spacing or reasonable multiple
word_spacing_max = max(word_spacing, word_spacing_min * 2) word_spacing_max = max(word_spacing, word_spacing_min * 2)
# Create concrete style # Create concrete style
concrete_style = ConcreteStyle( concrete_style = ConcreteStyle(
font_path=font_path, font_path=font_path,
@ -206,11 +210,11 @@ class StyleResolver:
min_hyphenation_width=min_hyphenation_width, min_hyphenation_width=min_hyphenation_width,
abstract_style=abstract_style abstract_style=abstract_style
) )
# Cache and return # Cache and return
self._concrete_cache[abstract_style] = concrete_style self._concrete_cache[abstract_style] = concrete_style
return concrete_style return concrete_style
def _resolve_font_path(self, font_family: FontFamily) -> Optional[str]: def _resolve_font_path(self, font_family: FontFamily) -> Optional[str]:
"""Resolve font family to actual font file path.""" """Resolve font family to actual font file path."""
if font_family == FontFamily.SERIF: if font_family == FontFamily.SERIF:
@ -222,7 +226,7 @@ class StyleResolver:
else: else:
# For cursive and fantasy, fall back to sans-serif # For cursive and fantasy, fall back to sans-serif
return self.context.preferred_sans_serif_font return self.context.preferred_sans_serif_font
def _resolve_font_size(self, font_size: Union[FontSize, int]) -> int: def _resolve_font_size(self, font_size: Union[FontSize, int]) -> int:
"""Resolve font size to actual pixel/point size.""" """Resolve font size to actual pixel/point size."""
# Ensure we handle FontSize enums properly # Ensure we handle FontSize enums properly
@ -240,22 +244,23 @@ class StyleResolver:
except (ValueError, TypeError): except (ValueError, TypeError):
# If conversion fails, use default # If conversion fails, use default
base_size = self.context.base_font_size base_size = self.context.base_font_size
# Apply global font scaling # Apply global font scaling
final_size = int(base_size * self.context.font_scale_factor) final_size = int(base_size * self.context.font_scale_factor)
# Apply accessibility adjustments # Apply accessibility adjustments
if self.context.large_text: if self.context.large_text:
final_size = int(final_size * 1.2) final_size = int(final_size * 1.2)
# Ensure we always return an int, minimum 8pt font # Ensure we always return an int, minimum 8pt font
return max(int(final_size), 8) 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.""" """Resolve color to RGB tuple."""
if isinstance(color, tuple): if isinstance(color, tuple):
return color return color
if isinstance(color, str): if isinstance(color, str):
# Check if it's a named color # Check if it's a named color
if color.lower() in self._color_names: if color.lower() in self._color_names:
@ -266,7 +271,7 @@ class StyleResolver:
hex_color = color[1:] hex_color = color[1:]
if len(hex_color) == 3: if len(hex_color) == 3:
# Short hex format #RGB -> #RRGGBB # 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: if len(hex_color) == 6:
r = int(hex_color[0:2], 16) r = int(hex_color[0:2], 16)
g = int(hex_color[2:4], 16) g = int(hex_color[2:4], 16)
@ -278,7 +283,7 @@ class StyleResolver:
base_color = (0, 0, 0) # Fallback to black base_color = (0, 0, 0) # Fallback to black
else: else:
base_color = (0, 0, 0) # Fallback to black base_color = (0, 0, 0) # Fallback to black
# Apply high contrast if needed # Apply high contrast if needed
if self.context.high_contrast: if self.context.high_contrast:
# Simple high contrast: make dark colors black, light colors white # Simple high contrast: make dark colors black, light colors white
@ -288,56 +293,65 @@ class StyleResolver:
base_color = (0, 0, 0) # Black base_color = (0, 0, 0) # Black
else: else:
base_color = (255, 255, 255) # White base_color = (255, 255, 255) # White
return base_color return base_color
return (0, 0, 0) # Fallback to black 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.""" """Resolve background color to RGBA tuple or None."""
if bg_color is None: if bg_color is None:
return None return None
if isinstance(bg_color, tuple): if isinstance(bg_color, tuple):
if len(bg_color) == 3: if len(bg_color) == 3:
# RGB -> RGBA # RGB -> RGBA
return bg_color + (255,) return bg_color + (255,)
return bg_color return bg_color
if isinstance(bg_color, str): if isinstance(bg_color, str):
if bg_color.lower() == "transparent": if bg_color.lower() == "transparent":
return None return None
# Resolve as RGB then add alpha # Resolve as RGB then add alpha
rgb = self._resolve_color(bg_color) rgb = self._resolve_color(bg_color)
return rgb + (255,) return rgb + (255,)
return None return None
def _resolve_line_height(self, line_height: Optional[Union[str, float]]) -> float: def _resolve_line_height(self, line_height: Optional[Union[str, float]]) -> float:
"""Resolve line height to multiplier.""" """Resolve line height to multiplier."""
if line_height is None or line_height == "normal": if line_height is None or line_height == "normal":
return 1.2 # Default line height return 1.2 # Default line height
if isinstance(line_height, (int, float)): if isinstance(line_height, (int, float)):
return float(line_height) return float(line_height)
if isinstance(line_height, str): if isinstance(line_height, str):
try: try:
return float(line_height) return float(line_height)
except ValueError: except ValueError:
return 1.2 # Fallback return 1.2 # Fallback
return 1.2 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.""" """Resolve letter spacing to pixels."""
if letter_spacing is None or letter_spacing == "normal": if letter_spacing is None or letter_spacing == "normal":
return 0.0 return 0.0
if isinstance(letter_spacing, (int, float)): if isinstance(letter_spacing, (int, float)):
return float(letter_spacing) return float(letter_spacing)
if isinstance(letter_spacing, str): if isinstance(letter_spacing, str):
if letter_spacing.endswith("em"): if letter_spacing.endswith("em"):
try: try:
@ -350,17 +364,18 @@ class StyleResolver:
return float(letter_spacing) return float(letter_spacing)
except ValueError: except ValueError:
return 0.0 return 0.0
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.""" """Resolve word spacing to pixels."""
if word_spacing is None or word_spacing == "normal": if word_spacing is None or word_spacing == "normal":
return 0.0 return 0.0
if isinstance(word_spacing, (int, float)): if isinstance(word_spacing, (int, float)):
return float(word_spacing) return float(word_spacing)
if isinstance(word_spacing, str): if isinstance(word_spacing, str):
if word_spacing.endswith("em"): if word_spacing.endswith("em"):
try: try:
@ -373,13 +388,13 @@ class StyleResolver:
return float(word_spacing) return float(word_spacing)
except ValueError: except ValueError:
return 0.0 return 0.0
return 0.0 return 0.0
def update_context(self, **kwargs): def update_context(self, **kwargs):
""" """
Update the rendering context and clear cache. Update the rendering context and clear cache.
Args: Args:
**kwargs: Context properties to update **kwargs: Context properties to update
""" """
@ -389,16 +404,16 @@ class StyleResolver:
for field in self.context.__dataclass_fields__.values() for field in self.context.__dataclass_fields__.values()
} }
context_dict.update(kwargs) context_dict.update(kwargs)
self.context = RenderingContext(**context_dict) self.context = RenderingContext(**context_dict)
# Clear cache since context changed # Clear cache since context changed
self._concrete_cache.clear() self._concrete_cache.clear()
def clear_cache(self): def clear_cache(self):
"""Clear the concrete style cache.""" """Clear the concrete style cache."""
self._concrete_cache.clear() self._concrete_cache.clear()
def get_cache_size(self) -> int: def get_cache_size(self) -> int:
"""Get the number of cached concrete styles.""" """Get the number of cached concrete styles."""
return len(self._concrete_cache) return len(self._concrete_cache)
@ -407,60 +422,60 @@ class StyleResolver:
class ConcreteStyleRegistry: class ConcreteStyleRegistry:
""" """
Registry for managing concrete styles with efficient caching. Registry for managing concrete styles with efficient caching.
This registry manages the mapping between abstract and concrete styles, This registry manages the mapping between abstract and concrete styles,
and provides efficient access to Font objects for rendering. and provides efficient access to Font objects for rendering.
""" """
def __init__(self, resolver: StyleResolver): def __init__(self, resolver: StyleResolver):
""" """
Initialize the concrete style registry. Initialize the concrete style registry.
Args: Args:
resolver: StyleResolver for converting abstract to concrete styles resolver: StyleResolver for converting abstract to concrete styles
""" """
self.resolver = resolver self.resolver = resolver
self._font_cache: Dict[ConcreteStyle, Font] = {} self._font_cache: Dict[ConcreteStyle, Font] = {}
def get_concrete_style(self, abstract_style: AbstractStyle) -> ConcreteStyle: def get_concrete_style(self, abstract_style: AbstractStyle) -> ConcreteStyle:
""" """
Get a concrete style for an abstract style. Get a concrete style for an abstract style.
Args: Args:
abstract_style: AbstractStyle to resolve abstract_style: AbstractStyle to resolve
Returns: Returns:
ConcreteStyle with rendering parameters ConcreteStyle with rendering parameters
""" """
return self.resolver.resolve_style(abstract_style) return self.resolver.resolve_style(abstract_style)
def get_font(self, abstract_style: AbstractStyle) -> Font: def get_font(self, abstract_style: AbstractStyle) -> Font:
""" """
Get a Font object for an abstract style. Get a Font object for an abstract style.
Args: Args:
abstract_style: AbstractStyle to get font for abstract_style: AbstractStyle to get font for
Returns: Returns:
Font object ready for rendering Font object ready for rendering
""" """
concrete_style = self.get_concrete_style(abstract_style) concrete_style = self.get_concrete_style(abstract_style)
# Check font cache # Check font cache
if concrete_style in self._font_cache: if concrete_style in self._font_cache:
return self._font_cache[concrete_style] return self._font_cache[concrete_style]
# Create and cache font # Create and cache font
font = concrete_style.create_font() font = concrete_style.create_font()
self._font_cache[concrete_style] = font self._font_cache[concrete_style] = font
return font return font
def clear_caches(self): def clear_caches(self):
"""Clear all caches.""" """Clear all caches."""
self.resolver.clear_cache() self.resolver.clear_cache()
self._font_cache.clear() self._font_cache.clear()
def get_cache_stats(self) -> Dict[str, int]: def get_cache_stats(self) -> Dict[str, int]:
"""Get cache statistics.""" """Get cache statistics."""
return { 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 PIL import ImageFont
from enum import Enum from enum import Enum
from typing import Tuple, Union, Optional from typing import Tuple, Optional
import os import os
import logging import logging
@ -31,19 +32,19 @@ class Font:
This class is used by the text renderer to determine how to render text. 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_path: Optional[str] = None,
font_size: int = 16, font_size: int = 16,
colour: Tuple[int, int, int] = (0, 0, 0), colour: Tuple[int, int, int] = (0, 0, 0),
weight: FontWeight = FontWeight.NORMAL, weight: FontWeight = FontWeight.NORMAL,
style: FontStyle = FontStyle.NORMAL, style: FontStyle = FontStyle.NORMAL,
decoration: TextDecoration = TextDecoration.NONE, decoration: TextDecoration = TextDecoration.NONE,
background: Optional[Tuple[int, int, int, int]] = None, background: Optional[Tuple[int, int, int, int]] = None,
language = "en_EN", language="en_EN",
min_hyphenation_width: Optional[int] = None): min_hyphenation_width: Optional[int] = None):
""" """
Initialize a Font object with the specified properties. Initialize a Font object with the specified properties.
Args: Args:
font_path: Path to the font file (.ttf, .otf). If None, uses default font. font_path: Path to the font file (.ttf, .otf). If None, uses default font.
font_size: Size of the font in points. 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 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 # Load the font file or use default
self._load_font() self._load_font()
def _get_bundled_font_path(self): def _get_bundled_font_path(self):
"""Get the path to the bundled font""" """Get the path to the bundled font"""
# Get the directory containing this module # Get the directory containing this module
@ -75,19 +76,21 @@ class Font:
# Navigate to the assets/fonts directory # Navigate to the assets/fonts directory
assets_dir = os.path.join(os.path.dirname(current_dir), 'assets', 'fonts') assets_dir = os.path.join(os.path.dirname(current_dir), 'assets', 'fonts')
bundled_font_path = os.path.join(assets_dir, 'DejaVuSans.ttf') bundled_font_path = os.path.join(assets_dir, 'DejaVuSans.ttf')
logger.debug(f"Font loading: current_dir = {current_dir}") logger.debug(f"Font loading: current_dir = {current_dir}")
logger.debug(f"Font loading: assets_dir = {assets_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_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): if os.path.exists(bundled_font_path):
logger.info(f"Found bundled font at: {bundled_font_path}") logger.info(f"Found bundled font at: {bundled_font_path}")
return bundled_font_path return bundled_font_path
else: else:
logger.warning(f"Bundled font not found at: {bundled_font_path}") logger.warning(f"Bundled font not found at: {bundled_font_path}")
return None return None
def _load_font(self): def _load_font(self):
"""Load the font using PIL's ImageFont with consistent bundled font""" """Load the font using PIL's ImageFont with consistent bundled font"""
try: try:
@ -95,126 +98,130 @@ class Font:
# Use specified font path # Use specified font path
logger.info(f"Loading font from specified path: {self._font_path}") logger.info(f"Loading font from specified path: {self._font_path}")
self._font = ImageFont.truetype( self._font = ImageFont.truetype(
self._font_path, self._font_path,
self._font_size self._font_size
) )
logger.info(f"Successfully loaded font from: {self._font_path}") logger.info(f"Successfully loaded font from: {self._font_path}")
else: else:
# Use bundled font for consistency across environments # Use bundled font for consistency across environments
bundled_font_path = self._get_bundled_font_path() bundled_font_path = self._get_bundled_font_path()
if bundled_font_path: if bundled_font_path:
logger.info(f"Loading bundled font from: {bundled_font_path}") logger.info(f"Loading bundled font from: {bundled_font_path}")
self._font = ImageFont.truetype(bundled_font_path, self._font_size) 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: else:
# Only fall back to PIL's default font if bundled font is not available # Only fall back to PIL's default font if bundled font is not
logger.warning(f"Bundled font not available, falling back to PIL default font") # available
logger.warning(
"Bundled font not available, falling back to PIL default font")
self._font = ImageFont.load_default() self._font = ImageFont.load_default()
except Exception as e: except Exception as e:
# Ultimate fallback to default font # Ultimate fallback to default font
logger.error(f"Failed to load font: {e}, falling back to PIL default font") logger.error(f"Failed to load font: {e}, falling back to PIL default font")
self._font = ImageFont.load_default() self._font = ImageFont.load_default()
@property @property
def font(self): def font(self):
"""Get the PIL ImageFont object""" """Get the PIL ImageFont object"""
return self._font return self._font
@property @property
def font_size(self): def font_size(self):
"""Get the font size""" """Get the font size"""
return self._font_size return self._font_size
@property @property
def colour(self): def colour(self):
"""Get the text color""" """Get the text color"""
return self._colour return self._colour
@property @property
def color(self): def color(self):
"""Alias for colour (American spelling)""" """Alias for colour (American spelling)"""
return self._colour return self._colour
@property @property
def background(self): def background(self):
"""Get the background color""" """Get the background color"""
return self._background return self._background
@property @property
def weight(self): def weight(self):
"""Get the font weight""" """Get the font weight"""
return self._weight return self._weight
@property @property
def style(self): def style(self):
"""Get the font style""" """Get the font style"""
return self._style return self._style
@property @property
def decoration(self): def decoration(self):
"""Get the text decoration""" """Get the text decoration"""
return self._decoration return self._decoration
@property @property
def min_hyphenation_width(self): def min_hyphenation_width(self):
"""Get the minimum width required for hyphenation to be considered""" """Get the minimum width required for hyphenation to be considered"""
return self._min_hyphenation_width return self._min_hyphenation_width
def with_size(self, size: int): def with_size(self, size: int):
"""Create a new Font object with modified size""" """Create a new Font object with modified size"""
return Font( return Font(
self._font_path, self._font_path,
size, size,
self._colour, self._colour,
self._weight, self._weight,
self._style, self._style,
self._decoration, self._decoration,
self._background self._background
) )
def with_colour(self, colour: Tuple[int, int, int]): def with_colour(self, colour: Tuple[int, int, int]):
"""Create a new Font object with modified colour""" """Create a new Font object with modified colour"""
return Font( return Font(
self._font_path, self._font_path,
self._font_size, self._font_size,
colour, colour,
self._weight, self._weight,
self._style, self._style,
self._decoration, self._decoration,
self._background self._background
) )
def with_weight(self, weight: FontWeight): def with_weight(self, weight: FontWeight):
"""Create a new Font object with modified weight""" """Create a new Font object with modified weight"""
return Font( return Font(
self._font_path, self._font_path,
self._font_size, self._font_size,
self._colour, self._colour,
weight, weight,
self._style, self._style,
self._decoration, self._decoration,
self._background self._background
) )
def with_style(self, style: FontStyle): def with_style(self, style: FontStyle):
"""Create a new Font object with modified style""" """Create a new Font object with modified style"""
return Font( return Font(
self._font_path, self._font_path,
self._font_size, self._font_size,
self._colour, self._colour,
self._weight, self._weight,
style, style,
self._decoration, self._decoration,
self._background self._background
) )
def with_decoration(self, decoration: TextDecoration): def with_decoration(self, decoration: TextDecoration):
"""Create a new Font object with modified decoration""" """Create a new Font object with modified decoration"""
return Font( return Font(
self._font_path, self._font_path,
self._font_size, self._font_size,
self._colour, self._colour,
self._weight, self._weight,
self._style, self._style,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,20 +3,20 @@ Unit tests for LinkedWord and LinkedImage classes.
""" """
import unittest import unittest
from pyWebLayout.abstract.inline import Word, LinkedWord from pyWebLayout.abstract.inline import LinkedWord
from pyWebLayout.abstract.block import Image, LinkedImage from pyWebLayout.abstract.block import LinkedImage
from pyWebLayout.abstract.functional import LinkType from pyWebLayout.abstract.functional import LinkType
from pyWebLayout.style import Font from pyWebLayout.style import Font
class TestLinkedWord(unittest.TestCase): class TestLinkedWord(unittest.TestCase):
"""Test cases for LinkedWord class.""" """Test cases for LinkedWord class."""
def setUp(self): def setUp(self):
"""Set up test fixtures.""" """Set up test fixtures."""
self.font = Font(font_size=16) self.font = Font(font_size=16)
self.location = "https://example.com" self.location = "https://example.com"
def test_linked_word_creation(self): def test_linked_word_creation(self):
"""Test creating a LinkedWord.""" """Test creating a LinkedWord."""
linked_word = LinkedWord( linked_word = LinkedWord(
@ -25,12 +25,12 @@ class TestLinkedWord(unittest.TestCase):
location=self.location, location=self.location,
link_type=LinkType.EXTERNAL link_type=LinkType.EXTERNAL
) )
self.assertEqual(linked_word.text, "example") self.assertEqual(linked_word.text, "example")
self.assertEqual(linked_word.location, self.location) self.assertEqual(linked_word.location, self.location)
self.assertEqual(linked_word.link_type, LinkType.EXTERNAL) self.assertEqual(linked_word.link_type, LinkType.EXTERNAL)
self.assertIsNone(linked_word.link_callback) self.assertIsNone(linked_word.link_callback)
def test_linked_word_inherits_from_word(self): def test_linked_word_inherits_from_word(self):
"""Test that LinkedWord inherits Word properties.""" """Test that LinkedWord inherits Word properties."""
linked_word = LinkedWord( linked_word = LinkedWord(
@ -38,21 +38,21 @@ class TestLinkedWord(unittest.TestCase):
style=self.font, style=self.font,
location=self.location location=self.location
) )
# Should have Word properties # Should have Word properties
self.assertEqual(linked_word.text, "test") self.assertEqual(linked_word.text, "test")
self.assertEqual(linked_word.style, self.font) self.assertEqual(linked_word.style, self.font)
self.assertIsNone(linked_word.previous) self.assertIsNone(linked_word.previous)
self.assertIsNone(linked_word.next) self.assertIsNone(linked_word.next)
def test_linked_word_with_callback(self): def test_linked_word_with_callback(self):
"""Test LinkedWord with a callback function.""" """Test LinkedWord with a callback function."""
callback_called = [] callback_called = []
def test_callback(location, **params): def test_callback(location, **params):
callback_called.append((location, params)) callback_called.append((location, params))
return "navigated" return "navigated"
linked_word = LinkedWord( linked_word = LinkedWord(
text="click", text="click",
style=self.font, style=self.font,
@ -61,15 +61,15 @@ class TestLinkedWord(unittest.TestCase):
callback=test_callback, callback=test_callback,
params={"source": "test"} params={"source": "test"}
) )
result = linked_word.execute_link() linked_word.execute_link()
self.assertEqual(len(callback_called), 1) self.assertEqual(len(callback_called), 1)
self.assertEqual(callback_called[0][0], self.location) self.assertEqual(callback_called[0][0], self.location)
self.assertIn("text", callback_called[0][1]) self.assertIn("text", callback_called[0][1])
self.assertEqual(callback_called[0][1]["text"], "click") self.assertEqual(callback_called[0][1]["text"], "click")
self.assertEqual(callback_called[0][1]["source"], "test") self.assertEqual(callback_called[0][1]["source"], "test")
def test_linked_word_execute_external_link(self): def test_linked_word_execute_external_link(self):
"""Test executing an external link returns the location.""" """Test executing an external link returns the location."""
linked_word = LinkedWord( linked_word = LinkedWord(
@ -78,10 +78,10 @@ class TestLinkedWord(unittest.TestCase):
location=self.location, location=self.location,
link_type=LinkType.EXTERNAL link_type=LinkType.EXTERNAL
) )
result = linked_word.execute_link() result = linked_word.execute_link()
self.assertEqual(result, self.location) self.assertEqual(result, self.location)
def test_linked_word_with_title(self): def test_linked_word_with_title(self):
"""Test LinkedWord with title/tooltip.""" """Test LinkedWord with title/tooltip."""
linked_word = LinkedWord( linked_word = LinkedWord(
@ -90,9 +90,9 @@ class TestLinkedWord(unittest.TestCase):
location=self.location, location=self.location,
title="Click to visit example.com" title="Click to visit example.com"
) )
self.assertEqual(linked_word.link_title, "Click to visit example.com") self.assertEqual(linked_word.link_title, "Click to visit example.com")
def test_linked_word_chain(self): def test_linked_word_chain(self):
"""Test chaining multiple LinkedWords.""" """Test chaining multiple LinkedWords."""
word1 = LinkedWord( word1 = LinkedWord(
@ -100,14 +100,14 @@ class TestLinkedWord(unittest.TestCase):
style=self.font, style=self.font,
location=self.location location=self.location
) )
word2 = LinkedWord( word2 = LinkedWord(
text="here", text="here",
style=self.font, style=self.font,
location=self.location, location=self.location,
previous=word1 previous=word1
) )
# Check chain # Check chain
self.assertEqual(word1.next, word2) self.assertEqual(word1.next, word2)
self.assertEqual(word2.previous, word1) self.assertEqual(word2.previous, word1)
@ -115,13 +115,13 @@ class TestLinkedWord(unittest.TestCase):
class TestLinkedImage(unittest.TestCase): class TestLinkedImage(unittest.TestCase):
"""Test cases for LinkedImage class.""" """Test cases for LinkedImage class."""
def setUp(self): def setUp(self):
"""Set up test fixtures.""" """Set up test fixtures."""
self.source = "logo.png" self.source = "logo.png"
self.alt_text = "Company Logo" self.alt_text = "Company Logo"
self.location = "https://example.com/home" self.location = "https://example.com/home"
def test_linked_image_creation(self): def test_linked_image_creation(self):
"""Test creating a LinkedImage.""" """Test creating a LinkedImage."""
linked_image = LinkedImage( linked_image = LinkedImage(
@ -132,14 +132,14 @@ class TestLinkedImage(unittest.TestCase):
height=50, height=50,
link_type=LinkType.EXTERNAL link_type=LinkType.EXTERNAL
) )
self.assertEqual(linked_image.source, self.source) self.assertEqual(linked_image.source, self.source)
self.assertEqual(linked_image.alt_text, self.alt_text) self.assertEqual(linked_image.alt_text, self.alt_text)
self.assertEqual(linked_image.location, self.location) self.assertEqual(linked_image.location, self.location)
self.assertEqual(linked_image.width, 100) self.assertEqual(linked_image.width, 100)
self.assertEqual(linked_image.height, 50) self.assertEqual(linked_image.height, 50)
self.assertEqual(linked_image.link_type, LinkType.EXTERNAL) self.assertEqual(linked_image.link_type, LinkType.EXTERNAL)
def test_linked_image_inherits_from_image(self): def test_linked_image_inherits_from_image(self):
"""Test that LinkedImage inherits Image properties.""" """Test that LinkedImage inherits Image properties."""
linked_image = LinkedImage( linked_image = LinkedImage(
@ -147,20 +147,20 @@ class TestLinkedImage(unittest.TestCase):
alt_text=self.alt_text, alt_text=self.alt_text,
location=self.location location=self.location
) )
# Should have Image properties and methods # Should have Image properties and methods
self.assertEqual(linked_image.source, self.source) self.assertEqual(linked_image.source, self.source)
self.assertEqual(linked_image.alt_text, self.alt_text) self.assertEqual(linked_image.alt_text, self.alt_text)
self.assertIsNotNone(linked_image.get_dimensions) self.assertIsNotNone(linked_image.get_dimensions)
def test_linked_image_with_callback(self): def test_linked_image_with_callback(self):
"""Test LinkedImage with a callback function.""" """Test LinkedImage with a callback function."""
callback_called = [] callback_called = []
def image_callback(location, **params): def image_callback(location, **params):
callback_called.append((location, params)) callback_called.append((location, params))
return "image_clicked" return "image_clicked"
linked_image = LinkedImage( linked_image = LinkedImage(
source=self.source, source=self.source,
alt_text=self.alt_text, alt_text=self.alt_text,
@ -168,15 +168,15 @@ class TestLinkedImage(unittest.TestCase):
link_type=LinkType.FUNCTION, link_type=LinkType.FUNCTION,
callback=image_callback callback=image_callback
) )
result = linked_image.execute_link() linked_image.execute_link()
self.assertEqual(len(callback_called), 1) self.assertEqual(len(callback_called), 1)
self.assertEqual(callback_called[0][0], self.location) self.assertEqual(callback_called[0][0], self.location)
self.assertIn("alt_text", callback_called[0][1]) self.assertIn("alt_text", callback_called[0][1])
self.assertEqual(callback_called[0][1]["alt_text"], self.alt_text) self.assertEqual(callback_called[0][1]["alt_text"], self.alt_text)
self.assertIn("source", callback_called[0][1]) self.assertIn("source", callback_called[0][1])
def test_linked_image_execute_internal_link(self): def test_linked_image_execute_internal_link(self):
"""Test executing an internal link returns the location.""" """Test executing an internal link returns the location."""
linked_image = LinkedImage( linked_image = LinkedImage(
@ -185,7 +185,7 @@ class TestLinkedImage(unittest.TestCase):
location="#section2", location="#section2",
link_type=LinkType.INTERNAL link_type=LinkType.INTERNAL
) )
result = linked_image.execute_link() result = linked_image.execute_link()
self.assertEqual(result, "#section2") 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 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 Alignment
from pyWebLayout.style import Font from pyWebLayout.style import Font
from pyWebLayout.abstract import Word from pyWebLayout.abstract import Word
from PIL import Image, ImageFont, ImageDraw from PIL import Image, ImageDraw
class TestAlignmentHandlers(unittest.TestCase): class TestAlignmentHandlers(unittest.TestCase):
"""Test cases for the alignment handler system""" """Test cases for the alignment handler system"""
def setUp(self): def setUp(self):
"""Set up test fixtures""" """Set up test fixtures"""
self.font = Font() 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_width = 300
self.line_height = 30 self.line_height = 30
self.spacing = (5, 20) # min_spacing, max_spacing self.spacing = (5, 20) # min_spacing, max_spacing
@ -29,46 +31,78 @@ class TestAlignmentHandlers(unittest.TestCase):
# Create a real PIL image (canvas) for testing # Create a real PIL image (canvas) for testing
self.canvas = Image.new('RGB', (800, 600), color='white') self.canvas = Image.new('RGB', (800, 600), color='white')
# Create a real ImageDraw object # Create a real ImageDraw object
self.draw = ImageDraw.Draw(self.canvas) self.draw = ImageDraw.Draw(self.canvas)
# Create a real Font object # Create a real Font object
self.style = Font() self.style = Font()
def test_left_alignment_handler_assignment(self): def test_left_alignment_handler_assignment(self):
"""Test that Line correctly assigns LeftAlignmentHandler for LEFT alignment""" """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) self.assertIsInstance(left_line._alignment_handler, LeftAlignmentHandler)
def test_center_alignment_handler_assignment(self): def test_center_alignment_handler_assignment(self):
"""Test that Line correctly assigns CenterRightAlignmentHandler for CENTER alignment""" """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) center_line = Line(
self.spacing,
self.assertIsInstance(center_line._alignment_handler, CenterRightAlignmentHandler) 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 # Check that it's configured for CENTER alignment
self.assertEqual(center_line._alignment_handler._alignment, Alignment.CENTER) self.assertEqual(center_line._alignment_handler._alignment, Alignment.CENTER)
def test_right_alignment_handler_assignment(self): def test_right_alignment_handler_assignment(self):
"""Test that Line correctly assigns CenterRightAlignmentHandler for RIGHT alignment""" """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) right_line = Line(
self.spacing,
self.assertIsInstance(right_line._alignment_handler, CenterRightAlignmentHandler) 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 # Check that it's configured for RIGHT alignment
self.assertEqual(right_line._alignment_handler._alignment, Alignment.RIGHT) self.assertEqual(right_line._alignment_handler._alignment, Alignment.RIGHT)
def test_justify_alignment_handler_assignment(self): def test_justify_alignment_handler_assignment(self):
"""Test that Line correctly assigns JustifyAlignmentHandler for JUSTIFY alignment""" """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) self.assertIsInstance(justify_line._alignment_handler, JustifyAlignmentHandler)
def test_left_alignment_word_addition(self): def test_left_alignment_word_addition(self):
"""Test adding words to a left-aligned line""" """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 # Add words until line is full or we run out
words_added = 0 words_added = 0
for word in self.test_words: for word in self.test_words:
@ -78,15 +112,21 @@ class TestAlignmentHandlers(unittest.TestCase):
break break
else: else:
words_added += 1 words_added += 1
# Should have added at least some words # Should have added at least some words
self.assertGreater(words_added, 0) self.assertGreater(words_added, 0)
self.assertEqual(len(left_line.text_objects), words_added) self.assertEqual(len(left_line.text_objects), words_added)
def test_center_alignment_word_addition(self): def test_center_alignment_word_addition(self):
"""Test adding words to a center-aligned line""" """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 # Add words until line is full or we run out
words_added = 0 words_added = 0
for word in self.test_words: for word in self.test_words:
@ -96,15 +136,21 @@ class TestAlignmentHandlers(unittest.TestCase):
break break
else: else:
words_added += 1 words_added += 1
# Should have added at least some words # Should have added at least some words
self.assertGreater(words_added, 0) self.assertGreater(words_added, 0)
self.assertEqual(len(center_line.text_objects), words_added) self.assertEqual(len(center_line.text_objects), words_added)
def test_right_alignment_word_addition(self): def test_right_alignment_word_addition(self):
"""Test adding words to a right-aligned line""" """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 # Add words until line is full or we run out
words_added = 0 words_added = 0
for word in self.test_words: for word in self.test_words:
@ -114,15 +160,21 @@ class TestAlignmentHandlers(unittest.TestCase):
break break
else: else:
words_added += 1 words_added += 1
# Should have added at least some words # Should have added at least some words
self.assertGreater(words_added, 0) self.assertGreater(words_added, 0)
self.assertEqual(len(right_line.text_objects), words_added) self.assertEqual(len(right_line.text_objects), words_added)
def test_justify_alignment_word_addition(self): def test_justify_alignment_word_addition(self):
"""Test adding words to a justified line""" """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 # Add words until line is full or we run out
words_added = 0 words_added = 0
for word in self.test_words: for word in self.test_words:
@ -132,16 +184,17 @@ class TestAlignmentHandlers(unittest.TestCase):
break break
else: else:
words_added += 1 words_added += 1
# Should have added at least some words # Should have added at least some words
self.assertGreater(words_added, 0) self.assertGreater(words_added, 0)
self.assertEqual(len(justify_line.text_objects), words_added) self.assertEqual(len(justify_line.text_objects), words_added)
def test_handler_spacing_and_position_calculations(self): def test_handler_spacing_and_position_calculations(self):
"""Test spacing and position calculations for different alignment handlers""" """Test spacing and position calculations for different alignment handlers"""
# Create sample text objects # 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 # Test each handler type
handlers = [ handlers = [
("Left", LeftAlignmentHandler()), ("Left", LeftAlignmentHandler()),
@ -149,108 +202,132 @@ class TestAlignmentHandlers(unittest.TestCase):
("Right", CenterRightAlignmentHandler(Alignment.RIGHT)), ("Right", CenterRightAlignmentHandler(Alignment.RIGHT)),
("Justify", JustifyAlignmentHandler()) ("Justify", JustifyAlignmentHandler())
] ]
for name, handler in handlers: for name, handler in handlers:
with self.subTest(handler=name): with self.subTest(handler=name):
spacing_calc, position, overflow = handler.calculate_spacing_and_position( spacing_calc, position, overflow = handler.calculate_spacing_and_position(
text_objects, self.line_width, self.spacing[0], self.spacing[1]) text_objects, self.line_width, self.spacing[0], self.spacing[1])
# Check that spacing is a valid number # Check that spacing is a valid number
self.assertIsInstance(spacing_calc, (int, float)) self.assertIsInstance(spacing_calc, (int, float))
self.assertGreaterEqual(spacing_calc, 0) self.assertGreaterEqual(spacing_calc, 0)
# Check that position is a valid number # Check that position is a valid number
self.assertIsInstance(position, (int, float)) self.assertIsInstance(position, (int, float))
self.assertGreaterEqual(position, 0) self.assertGreaterEqual(position, 0)
# Check that overflow is a boolean # Check that overflow is a boolean
self.assertIsInstance(overflow, bool) self.assertIsInstance(overflow, bool)
# Position should be within line width (unless overflow) # Position should be within line width (unless overflow)
if not overflow: if not overflow:
self.assertLessEqual(position, self.line_width) self.assertLessEqual(position, self.line_width)
def test_left_handler_spacing_calculation(self): def test_left_handler_spacing_calculation(self):
"""Test specific spacing calculation for left alignment""" """Test specific spacing calculation for left alignment"""
handler = LeftAlignmentHandler() 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( spacing_calc, position, overflow = handler.calculate_spacing_and_position(
text_objects, self.line_width, self.spacing[0], self.spacing[1]) text_objects, self.line_width, self.spacing[0], self.spacing[1])
# Left alignment should have position at 0 # Left alignment should have position at 0
self.assertEqual(position, 0) self.assertEqual(position, 0)
# Should not overflow with reasonable text # Should not overflow with reasonable text
self.assertFalse(overflow) self.assertFalse(overflow)
def test_center_handler_spacing_calculation(self): def test_center_handler_spacing_calculation(self):
"""Test specific spacing calculation for center alignment""" """Test specific spacing calculation for center alignment"""
handler = CenterRightAlignmentHandler(Alignment.CENTER) 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( spacing_calc, position, overflow = handler.calculate_spacing_and_position(
text_objects, self.line_width, self.spacing[0], self.spacing[1]) text_objects, self.line_width, self.spacing[0], self.spacing[1])
# Center alignment should have position > 0 (centered) if no overflow # Center alignment should have position > 0 (centered) if no overflow
if not overflow: if not overflow:
self.assertGreaterEqual(position, 0) self.assertGreaterEqual(position, 0)
def test_right_handler_spacing_calculation(self): def test_right_handler_spacing_calculation(self):
"""Test specific spacing calculation for right alignment""" """Test specific spacing calculation for right alignment"""
handler = CenterRightAlignmentHandler(Alignment.RIGHT) 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( spacing_calc, position, overflow = handler.calculate_spacing_and_position(
text_objects, self.line_width, self.spacing[0], self.spacing[1]) text_objects, self.line_width, self.spacing[0], self.spacing[1])
# Right alignment should have position >= 0 # Right alignment should have position >= 0
self.assertGreaterEqual(position, 0) self.assertGreaterEqual(position, 0)
def test_justify_handler_spacing_calculation(self): def test_justify_handler_spacing_calculation(self):
"""Test specific spacing calculation for justify alignment""" """Test specific spacing calculation for justify alignment"""
handler = JustifyAlignmentHandler() 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( spacing_calc, position, overflow = handler.calculate_spacing_and_position(
text_objects, self.line_width, self.spacing[0], self.spacing[1]) text_objects, self.line_width, self.spacing[0], self.spacing[1])
# Justify alignment should have position at 0 # Justify alignment should have position at 0
self.assertEqual(position, 0) self.assertEqual(position, 0)
# Check spacing is reasonable # Check spacing is reasonable
self.assertGreaterEqual(spacing_calc, 0) self.assertGreaterEqual(spacing_calc, 0)
def test_empty_line_alignment_handlers(self): def test_empty_line_alignment_handlers(self):
"""Test alignment handlers with empty lines""" """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: for alignment in alignments:
with self.subTest(alignment=alignment): 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 # Empty line should still have a handler
self.assertIsNotNone(line._alignment_handler) self.assertIsNotNone(line._alignment_handler)
# Should be able to render empty line # Should be able to render empty line
line.render() line.render()
def test_single_word_line_alignment(self): def test_single_word_line_alignment(self):
"""Test alignment handlers with single word lines""" """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: for alignment in alignments:
with self.subTest(alignment=alignment): 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 # Create a test word
test_word = Word("test", self.style) test_word = Word("test", self.style)
# Add a single word # Add a single word
result, part = line.add_word(test_word) result, part = line.add_word(test_word)
self.assertTrue(result) # Should fit self.assertTrue(result) # Should fit
self.assertIsNone(part) # No overflow part self.assertIsNone(part) # No overflow part
# Should be able to render single word line # Should be able to render single word line
line.render() line.render()
self.assertEqual(len(line.text_objects), 1) 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 unittest
import numpy as np import numpy as np
from PIL import Image from PIL import Image
from unittest.mock import Mock, patch from unittest.mock import Mock
from pyWebLayout.concrete.box import Box from pyWebLayout.concrete.box import Box
from pyWebLayout.style import Alignment from pyWebLayout.style import Alignment
@ -14,17 +14,17 @@ from pyWebLayout.style import Alignment
class TestBox(unittest.TestCase): class TestBox(unittest.TestCase):
"""Test cases for the Box class""" """Test cases for the Box class"""
def setUp(self): def setUp(self):
"""Set up test fixtures""" """Set up test fixtures"""
self.origin = (10, 20) self.origin = (10, 20)
self.size = (100, 50) self.size = (100, 50)
self.callback = Mock() self.callback = Mock()
def test_box_initialization_basic(self): def test_box_initialization_basic(self):
"""Test basic box initialization""" """Test basic box initialization"""
box = Box(self.origin, self.size) box = Box(self.origin, self.size)
np.testing.assert_array_equal(box._origin, np.array([10, 20])) 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._size, np.array([100, 50]))
np.testing.assert_array_equal(box._end, np.array([110, 70])) np.testing.assert_array_equal(box._end, np.array([110, 70]))
@ -33,66 +33,65 @@ class TestBox(unittest.TestCase):
self.assertIsNone(box._mode) self.assertIsNone(box._mode)
self.assertEqual(box._halign, Alignment.CENTER) self.assertEqual(box._halign, Alignment.CENTER)
self.assertEqual(box._valign, Alignment.CENTER) self.assertEqual(box._valign, Alignment.CENTER)
def test_box_initialization_with_callback(self): def test_box_initialization_with_callback(self):
"""Test box initialization with callback""" """Test box initialization with callback"""
box = Box(self.origin, self.size, callback=self.callback) box = Box(self.origin, self.size, callback=self.callback)
self.assertEqual(box._callback, self.callback) self.assertEqual(box._callback, self.callback)
def test_box_initialization_with_sheet(self): def test_box_initialization_with_sheet(self):
"""Test box initialization with image sheet""" """Test box initialization with image sheet"""
sheet = Image.new('RGBA', (200, 100), (255, 255, 255, 255)) sheet = Image.new('RGBA', (200, 100), (255, 255, 255, 255))
box = Box(self.origin, self.size, sheet=sheet) box = Box(self.origin, self.size, sheet=sheet)
self.assertEqual(box._sheet, sheet) self.assertEqual(box._sheet, sheet)
self.assertEqual(box._mode, 'RGBA') self.assertEqual(box._mode, 'RGBA')
def test_box_initialization_with_mode(self): def test_box_initialization_with_mode(self):
"""Test box initialization with explicit mode""" """Test box initialization with explicit mode"""
box = Box(self.origin, self.size, mode='RGB') box = Box(self.origin, self.size, mode='RGB')
self.assertEqual(box._mode, 'RGB') self.assertEqual(box._mode, 'RGB')
def test_box_initialization_with_alignment(self): def test_box_initialization_with_alignment(self):
"""Test box initialization with custom alignment""" """Test box initialization with custom alignment"""
box = Box(self.origin, self.size, halign=Alignment.LEFT, valign=Alignment.TOP) box = Box(self.origin, self.size, halign=Alignment.LEFT, valign=Alignment.TOP)
self.assertEqual(box._halign, Alignment.LEFT) self.assertEqual(box._halign, Alignment.LEFT)
self.assertEqual(box._valign, Alignment.TOP) self.assertEqual(box._valign, Alignment.TOP)
def test_in_shape_point_inside(self): def test_in_shape_point_inside(self):
"""Test in_shape method with point inside box""" """Test in_shape method with point inside box"""
box = Box(self.origin, self.size) box = Box(self.origin, self.size)
# Test point inside # Test point inside
self.assertTrue(box.in_shape(np.array([50, 40]))) 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([10, 20]))) # Top-left corner
self.assertTrue(box.in_shape(np.array([109, 69]))) # Just inside bottom-right self.assertTrue(box.in_shape(np.array([109, 69]))) # Just inside bottom-right
def test_in_shape_point_outside(self): def test_in_shape_point_outside(self):
"""Test in_shape method with point outside box""" """Test in_shape method with point outside box"""
box = Box(self.origin, self.size) box = Box(self.origin, self.size)
# Test points outside # Test points outside
self.assertFalse(box.in_shape(np.array([5, 15]))) # Before origin 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([110, 70]))) # At end (exclusive)
self.assertFalse(box.in_shape(np.array([150, 100]))) # Far outside self.assertFalse(box.in_shape(np.array([150, 100]))) # Far outside
def test_in_shape_multiple_points(self): def test_in_shape_multiple_points(self):
"""Test in_shape method with array of points""" """Test in_shape method with array of points"""
box = Box(self.origin, self.size) box = Box(self.origin, self.size)
points = np.array([[50, 40], [5, 15], [109, 69], [110, 70]]) points = np.array([[50, 40], [5, 15], [109, 69], [110, 70]])
result = box.in_shape(points) result = box.in_shape(points)
np.testing.assert_array_equal(result, [True, False, True, False]) np.testing.assert_array_equal(result, [True, False, True, False])
def test_properties_access(self): def test_properties_access(self):
"""Test that properties can be accessed correctly""" """Test that properties can be accessed correctly"""
box = Box(self.origin, self.size, callback=self.callback) box = Box(self.origin, self.size, callback=self.callback)
# Test that origin property works (should be available via inheritance) # 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._origin, np.array([10, 20]))
np.testing.assert_array_equal(box._size, np.array([100, 50])) 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 unittest
import numpy as np import numpy as np
from PIL import Image, ImageDraw from unittest.mock import Mock, patch
from unittest.mock import Mock, patch, MagicMock
from pyWebLayout.concrete.functional import ( from pyWebLayout.concrete.functional import (
LinkText, ButtonText, FormFieldText, LinkText, ButtonText, FormFieldText,
create_link_text, create_button_text, create_form_field_text create_link_text, create_button_text, create_form_field_text
) )
from pyWebLayout.abstract.functional import ( 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 Font, TextDecoration
from pyWebLayout.style import Alignment
class TestLinkText(unittest.TestCase): class TestLinkText(unittest.TestCase):
"""Test cases for the LinkText class""" """Test cases for the LinkText class"""
def setUp(self): def setUp(self):
"""Set up test fixtures""" """Set up test fixtures"""
self.font = Font( self.font = Font(
@ -30,86 +28,89 @@ class TestLinkText(unittest.TestCase):
colour=(0, 0, 0) colour=(0, 0, 0)
) )
self.callback = Mock() self.callback = Mock()
# Create different types of links # Create different types of links
self.internal_link = Link("chapter1", LinkType.INTERNAL, self.callback) 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.api_link = Link("/api/settings", LinkType.API, self.callback)
self.function_link = Link("toggle_theme", LinkType.FUNCTION, self.callback) self.function_link = Link("toggle_theme", LinkType.FUNCTION, self.callback)
# Create a mock ImageDraw.Draw object # Create a mock ImageDraw.Draw object
self.mock_draw = Mock() self.mock_draw = Mock()
def test_link_text_initialization_internal(self): def test_link_text_initialization_internal(self):
"""Test initialization of internal link text""" """Test initialization of internal link text"""
link_text = "Go to Chapter 1" link_text = "Go to Chapter 1"
renderable = LinkText(self.internal_link, link_text, self.font, self.mock_draw) renderable = LinkText(self.internal_link, link_text, self.font, self.mock_draw)
self.assertEqual(renderable._link, self.internal_link) self.assertEqual(renderable._link, self.internal_link)
self.assertEqual(renderable.text, link_text) self.assertEqual(renderable.text, link_text)
self.assertFalse(renderable._hovered) self.assertFalse(renderable._hovered)
self.assertEqual(renderable._callback, self.internal_link.execute) self.assertEqual(renderable._callback, self.internal_link.execute)
# Check that the font has underline decoration and blue color # Check that the font has underline decoration and blue color
self.assertEqual(renderable.style.decoration, TextDecoration.UNDERLINE) self.assertEqual(renderable.style.decoration, TextDecoration.UNDERLINE)
self.assertEqual(renderable.style.colour, (0, 0, 200)) self.assertEqual(renderable.style.colour, (0, 0, 200))
def test_link_text_initialization_external(self): def test_link_text_initialization_external(self):
"""Test initialization of external link text""" """Test initialization of external link text"""
link_text = "Visit Example" link_text = "Visit Example"
renderable = LinkText(self.external_link, link_text, self.font, self.mock_draw) renderable = LinkText(self.external_link, link_text, self.font, self.mock_draw)
self.assertEqual(renderable._link, self.external_link) self.assertEqual(renderable._link, self.external_link)
# External links should have darker blue color # External links should have darker blue color
self.assertEqual(renderable.style.colour, (0, 0, 180)) self.assertEqual(renderable.style.colour, (0, 0, 180))
def test_link_text_initialization_api(self): def test_link_text_initialization_api(self):
"""Test initialization of API link text""" """Test initialization of API link text"""
link_text = "Settings" link_text = "Settings"
renderable = LinkText(self.api_link, link_text, self.font, self.mock_draw) renderable = LinkText(self.api_link, link_text, self.font, self.mock_draw)
self.assertEqual(renderable._link, self.api_link) self.assertEqual(renderable._link, self.api_link)
# API links should have red color # API links should have red color
self.assertEqual(renderable.style.colour, (150, 0, 0)) self.assertEqual(renderable.style.colour, (150, 0, 0))
def test_link_text_initialization_function(self): def test_link_text_initialization_function(self):
"""Test initialization of function link text""" """Test initialization of function link text"""
link_text = "Toggle Theme" link_text = "Toggle Theme"
renderable = LinkText(self.function_link, link_text, self.font, self.mock_draw) renderable = LinkText(self.function_link, link_text, self.font, self.mock_draw)
self.assertEqual(renderable._link, self.function_link) self.assertEqual(renderable._link, self.function_link)
# Function links should have green color # Function links should have green color
self.assertEqual(renderable.style.colour, (0, 120, 0)) self.assertEqual(renderable.style.colour, (0, 120, 0))
def test_link_property(self): def test_link_property(self):
"""Test link property accessor""" """Test link property accessor"""
link_text = "Test Link" link_text = "Test Link"
renderable = LinkText(self.internal_link, link_text, self.font, self.mock_draw) renderable = LinkText(self.internal_link, link_text, self.font, self.mock_draw)
self.assertEqual(renderable.link, self.internal_link) self.assertEqual(renderable.link, self.internal_link)
def test_set_hovered(self): def test_set_hovered(self):
"""Test setting hover state""" """Test setting hover state"""
link_text = "Hover Test" link_text = "Hover Test"
renderable = LinkText(self.internal_link, link_text, self.font, self.mock_draw) renderable = LinkText(self.internal_link, link_text, self.font, self.mock_draw)
self.assertFalse(renderable._hovered) self.assertFalse(renderable._hovered)
renderable.set_hovered(True) renderable.set_hovered(True)
self.assertTrue(renderable._hovered) self.assertTrue(renderable._hovered)
renderable.set_hovered(False) renderable.set_hovered(False)
self.assertFalse(renderable._hovered) self.assertFalse(renderable._hovered)
def test_render_normal_state(self): def test_render_normal_state(self):
"""Test rendering in normal state""" """Test rendering in normal state"""
link_text = "Test Link" link_text = "Test Link"
renderable = LinkText(self.internal_link, link_text, self.font, self.mock_draw) renderable = LinkText(self.internal_link, link_text, self.font, self.mock_draw)
# Mock the parent Text render method # Mock the parent Text render method
with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render: with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render:
renderable.render() renderable.render()
# Parent render should be called # Parent render should be called
mock_parent_render.assert_called_once() mock_parent_render.assert_called_once()
# Should not draw highlight when not hovered # Should not draw highlight when not hovered
@ -120,21 +121,25 @@ class TestLinkText(unittest.TestCase):
link_text = "Test Link" link_text = "Test Link"
renderable = LinkText(self.internal_link, link_text, self.font, self.mock_draw) renderable = LinkText(self.internal_link, link_text, self.font, self.mock_draw)
renderable.set_origin(np.array([10, 20])) renderable.set_origin(np.array([10, 20]))
# Mock width property # Mock width property
renderable._width = 80 renderable._width = 80
# Point inside link # Point inside link
self.assertTrue(renderable.in_object((15, 25))) self.assertTrue(renderable.in_object((15, 25)))
# Point outside link # Point outside link
self.assertFalse(renderable.in_object((200, 200))) self.assertFalse(renderable.in_object((200, 200)))
def test_factory_function(self): def test_factory_function(self):
"""Test the create_link_text factory function""" """Test the create_link_text factory function"""
link_text = "Factory Test" 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.assertIsInstance(renderable, LinkText)
self.assertEqual(renderable.text, link_text) self.assertEqual(renderable.text, link_text)
self.assertEqual(renderable.link, self.internal_link) self.assertEqual(renderable.link, self.internal_link)
@ -142,7 +147,7 @@ class TestLinkText(unittest.TestCase):
class TestButtonText(unittest.TestCase): class TestButtonText(unittest.TestCase):
"""Test cases for the ButtonText class""" """Test cases for the ButtonText class"""
def setUp(self): def setUp(self):
"""Set up test fixtures""" """Set up test fixtures"""
self.font = Font( self.font = Font(
@ -153,112 +158,114 @@ class TestButtonText(unittest.TestCase):
self.callback = Mock() self.callback = Mock()
self.button = Button("Click Me", self.callback) self.button = Button("Click Me", self.callback)
self.mock_draw = Mock() self.mock_draw = Mock()
def test_button_text_initialization(self): def test_button_text_initialization(self):
"""Test basic button text initialization""" """Test basic button text initialization"""
renderable = ButtonText(self.button, self.font, self.mock_draw) renderable = ButtonText(self.button, self.font, self.mock_draw)
self.assertEqual(renderable._button, self.button) self.assertEqual(renderable._button, self.button)
self.assertEqual(renderable.text, "Click Me") self.assertEqual(renderable.text, "Click Me")
self.assertFalse(renderable._pressed) self.assertFalse(renderable._pressed)
self.assertFalse(renderable._hovered) self.assertFalse(renderable._hovered)
self.assertEqual(renderable._callback, self.button.execute) self.assertEqual(renderable._callback, self.button.execute)
self.assertEqual(renderable._padding, (4, 8, 4, 8)) self.assertEqual(renderable._padding, (4, 8, 4, 8))
def test_button_text_with_custom_padding(self): def test_button_text_with_custom_padding(self):
"""Test button text initialization with custom padding""" """Test button text initialization with custom padding"""
custom_padding = (8, 12, 8, 12) custom_padding = (8, 12, 8, 12)
renderable = ButtonText( renderable = ButtonText(
self.button, self.font, self.mock_draw, self.button, self.font, self.mock_draw,
padding=custom_padding padding=custom_padding
) )
self.assertEqual(renderable._padding, custom_padding) self.assertEqual(renderable._padding, custom_padding)
def test_button_property(self): def test_button_property(self):
"""Test button property accessor""" """Test button property accessor"""
renderable = ButtonText(self.button, self.font, self.mock_draw) renderable = ButtonText(self.button, self.font, self.mock_draw)
self.assertEqual(renderable.button, self.button) self.assertEqual(renderable.button, self.button)
def test_set_pressed(self): def test_set_pressed(self):
"""Test setting pressed state""" """Test setting pressed state"""
renderable = ButtonText(self.button, self.font, self.mock_draw) renderable = ButtonText(self.button, self.font, self.mock_draw)
self.assertFalse(renderable._pressed) self.assertFalse(renderable._pressed)
renderable.set_pressed(True) renderable.set_pressed(True)
self.assertTrue(renderable._pressed) self.assertTrue(renderable._pressed)
renderable.set_pressed(False) renderable.set_pressed(False)
self.assertFalse(renderable._pressed) self.assertFalse(renderable._pressed)
def test_set_hovered(self): def test_set_hovered(self):
"""Test setting hover state""" """Test setting hover state"""
renderable = ButtonText(self.button, self.font, self.mock_draw) renderable = ButtonText(self.button, self.font, self.mock_draw)
self.assertFalse(renderable._hovered) self.assertFalse(renderable._hovered)
renderable.set_hovered(True) renderable.set_hovered(True)
self.assertTrue(renderable._hovered) self.assertTrue(renderable._hovered)
renderable.set_hovered(False) renderable.set_hovered(False)
self.assertFalse(renderable._hovered) self.assertFalse(renderable._hovered)
def test_size_property(self): def test_size_property(self):
"""Test size property includes padding""" """Test size property includes padding"""
renderable = ButtonText(self.button, self.font, self.mock_draw) renderable = ButtonText(self.button, self.font, self.mock_draw)
# The size should be padded size, not just text size # The size should be padded size, not just text size
# Since we handle mocks in __init__, use the padded values directly # Since we handle mocks in __init__, use the padded values directly
expected_width = renderable._padded_width expected_width = renderable._padded_width
expected_height = renderable._padded_height 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): def test_render_normal_state(self):
"""Test rendering in normal state""" """Test rendering in normal state"""
renderable = ButtonText(self.button, self.font, self.mock_draw) renderable = ButtonText(self.button, self.font, self.mock_draw)
# Mock the parent Text render method # Mock the parent Text render method
with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render: with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render:
renderable.render() renderable.render()
# Should draw rounded rectangle for button background # Should draw rounded rectangle for button background
self.mock_draw.rounded_rectangle.assert_called_once() self.mock_draw.rounded_rectangle.assert_called_once()
# Parent render should be called for text # Parent render should be called for text
mock_parent_render.assert_called_once() mock_parent_render.assert_called_once()
def test_render_disabled_state(self): def test_render_disabled_state(self):
"""Test rendering disabled button""" """Test rendering disabled button"""
disabled_button = Button("Disabled", self.callback, enabled=False) disabled_button = Button("Disabled", self.callback, enabled=False)
renderable = ButtonText(disabled_button, self.font, self.mock_draw) renderable = ButtonText(disabled_button, self.font, self.mock_draw)
# Mock the parent Text render method # Mock the parent Text render method
with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render: with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render:
renderable.render() renderable.render()
# Should still draw button background # Should still draw button background
self.mock_draw.rounded_rectangle.assert_called_once() self.mock_draw.rounded_rectangle.assert_called_once()
mock_parent_render.assert_called_once() mock_parent_render.assert_called_once()
def test_in_object_with_padding(self): def test_in_object_with_padding(self):
"""Test in_object method considers padding""" """Test in_object method considers padding"""
renderable = ButtonText(self.button, self.font, self.mock_draw) renderable = ButtonText(self.button, self.font, self.mock_draw)
renderable.set_origin(np.array([10, 20])) renderable.set_origin(np.array([10, 20]))
# Point inside button (including padding) # Point inside button (including padding)
self.assertTrue(renderable.in_object((15, 25))) self.assertTrue(renderable.in_object((15, 25)))
# Point outside button # Point outside button
self.assertFalse(renderable.in_object((200, 200))) self.assertFalse(renderable.in_object((200, 200)))
def test_factory_function(self): def test_factory_function(self):
"""Test the create_button_text factory function""" """Test the create_button_text factory function"""
custom_padding = (6, 10, 6, 10) 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.assertIsInstance(renderable, ButtonText)
self.assertEqual(renderable.text, "Click Me") self.assertEqual(renderable.text, "Click Me")
self.assertEqual(renderable.button, self.button) self.assertEqual(renderable.button, self.button)
@ -267,7 +274,7 @@ class TestButtonText(unittest.TestCase):
class TestFormFieldText(unittest.TestCase): class TestFormFieldText(unittest.TestCase):
"""Test cases for the FormFieldText class""" """Test cases for the FormFieldText class"""
def setUp(self): def setUp(self):
"""Set up test fixtures""" """Set up test fixtures"""
self.font = Font( self.font = Font(
@ -275,96 +282,102 @@ class TestFormFieldText(unittest.TestCase):
font_size=12, font_size=12,
colour=(0, 0, 0) colour=(0, 0, 0)
) )
# Create different types of form fields # Create different types of form fields
self.text_field = FormField("username", FormFieldType.TEXT, "Username") self.text_field = FormField("username", FormFieldType.TEXT, "Username")
self.password_field = FormField("password", FormFieldType.PASSWORD, "Password") 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.select_field = FormField("country", FormFieldType.SELECT, "Country")
self.mock_draw = Mock() self.mock_draw = Mock()
def test_form_field_text_initialization(self): def test_form_field_text_initialization(self):
"""Test initialization of form field text""" """Test initialization of form field text"""
renderable = FormFieldText(self.text_field, self.font, self.mock_draw) renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
self.assertEqual(renderable._field, self.text_field) self.assertEqual(renderable._field, self.text_field)
self.assertEqual(renderable.text, "Username") self.assertEqual(renderable.text, "Username")
self.assertFalse(renderable._focused) self.assertFalse(renderable._focused)
self.assertEqual(renderable._field_height, 24) self.assertEqual(renderable._field_height, 24)
def test_form_field_text_with_custom_height(self): def test_form_field_text_with_custom_height(self):
"""Test form field text with custom field height""" """Test form field text with custom field height"""
custom_height = 40 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) self.assertEqual(renderable._field_height, custom_height)
def test_field_property(self): def test_field_property(self):
"""Test field property accessor""" """Test field property accessor"""
renderable = FormFieldText(self.text_field, self.font, self.mock_draw) renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
self.assertEqual(renderable.field, self.text_field) self.assertEqual(renderable.field, self.text_field)
def test_set_focused(self): def test_set_focused(self):
"""Test setting focus state""" """Test setting focus state"""
renderable = FormFieldText(self.text_field, self.font, self.mock_draw) renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
self.assertFalse(renderable._focused) self.assertFalse(renderable._focused)
renderable.set_focused(True) renderable.set_focused(True)
self.assertTrue(renderable._focused) self.assertTrue(renderable._focused)
renderable.set_focused(False) renderable.set_focused(False)
self.assertFalse(renderable._focused) self.assertFalse(renderable._focused)
def test_size_includes_field_area(self): def test_size_includes_field_area(self):
"""Test size property includes field area""" """Test size property includes field area"""
renderable = FormFieldText(self.text_field, self.font, self.mock_draw) renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
# Size should include label height + gap + field height # Size should include label height + gap + field height
expected_height = renderable._style.font_size + 5 + renderable._field_height expected_height = renderable._style.font_size + 5 + renderable._field_height
expected_width = renderable._field_width # Use the calculated field width 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): def test_render_text_field(self):
"""Test rendering text field""" """Test rendering text field"""
renderable = FormFieldText(self.text_field, self.font, self.mock_draw) renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
# Mock the parent Text render method # Mock the parent Text render method
with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render: with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render:
renderable.render() renderable.render()
# Should render label # Should render label
mock_parent_render.assert_called_once() mock_parent_render.assert_called_once()
# Should draw field background rectangle # Should draw field background rectangle
self.mock_draw.rectangle.assert_called_once() self.mock_draw.rectangle.assert_called_once()
def test_render_field_with_value(self): def test_render_field_with_value(self):
"""Test rendering field with value""" """Test rendering field with value"""
self.text_field.value = "john_doe" self.text_field.value = "john_doe"
renderable = FormFieldText(self.text_field, self.font, self.mock_draw) renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
# Mock the parent Text render method # Mock the parent Text render method
with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render: with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render:
renderable.render() renderable.render()
# Should render label # Should render label
mock_parent_render.assert_called_once() mock_parent_render.assert_called_once()
# Should draw field background and value text # Should draw field background and value text
self.mock_draw.rectangle.assert_called_once() self.mock_draw.rectangle.assert_called_once()
self.mock_draw.text.assert_called_once() self.mock_draw.text.assert_called_once()
def test_render_password_field(self): def test_render_password_field(self):
"""Test rendering password field with masked value""" """Test rendering password field with masked value"""
self.password_field.value = "secret123" self.password_field.value = "secret123"
renderable = FormFieldText(self.password_field, self.font, self.mock_draw) renderable = FormFieldText(self.password_field, self.font, self.mock_draw)
# Mock the parent Text render method # Mock the parent Text render method
with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render: with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render:
renderable.render() renderable.render()
# Should render label and field # Should render label and field
mock_parent_render.assert_called_once() mock_parent_render.assert_called_once()
self.mock_draw.rectangle.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 # Check that the text call used masked characters
call_args = self.mock_draw.text.call_args[0] call_args = self.mock_draw.text.call_args[0]
self.assertEqual(call_args[1], "" * len("secret123")) self.assertEqual(call_args[1], "" * len("secret123"))
def test_render_focused_field(self): def test_render_focused_field(self):
"""Test rendering focused field""" """Test rendering focused field"""
renderable = FormFieldText(self.text_field, self.font, self.mock_draw) renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
renderable.set_focused(True) renderable.set_focused(True)
# Mock the parent Text render method # Mock the parent Text render method
with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render: with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render:
renderable.render() renderable.render()
# Should render with focus styling # Should render with focus styling
mock_parent_render.assert_called_once() mock_parent_render.assert_called_once()
self.mock_draw.rectangle.assert_called_once() self.mock_draw.rectangle.assert_called_once()
def test_handle_click_inside_field(self): def test_handle_click_inside_field(self):
"""Test clicking inside field area""" """Test clicking inside field area"""
renderable = FormFieldText(self.text_field, self.font, self.mock_draw) renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
# Click inside field area (below label) # Click inside field area (below label)
field_area_y = renderable._style.font_size + 5 + 10 # Within field area field_area_y = renderable._style.font_size + 5 + 10 # Within field area
field_area_point = (15, field_area_y) field_area_point = (15, field_area_y)
result = renderable.handle_click(field_area_point) result = renderable.handle_click(field_area_point)
# Should return True and set focused # Should return True and set focused
self.assertTrue(result) self.assertTrue(result)
self.assertTrue(renderable._focused) self.assertTrue(renderable._focused)
def test_handle_click_outside_field(self): def test_handle_click_outside_field(self):
"""Test clicking outside field area""" """Test clicking outside field area"""
renderable = FormFieldText(self.text_field, self.font, self.mock_draw) renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
# Click outside field area # Click outside field area
outside_point = (200, 200) outside_point = (200, 200)
result = renderable.handle_click(outside_point) result = renderable.handle_click(outside_point)
# Should return False and not set focused # Should return False and not set focused
self.assertFalse(result) self.assertFalse(result)
self.assertFalse(renderable._focused) self.assertFalse(renderable._focused)
def test_in_object(self): def test_in_object(self):
"""Test in_object method""" """Test in_object method"""
renderable = FormFieldText(self.text_field, self.font, self.mock_draw) renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
renderable.set_origin(np.array([10, 20])) renderable.set_origin(np.array([10, 20]))
# Point inside field (including label and input area) # Point inside field (including label and input area)
self.assertTrue(renderable.in_object((15, 25))) self.assertTrue(renderable.in_object((15, 25)))
# Point outside field # Point outside field
self.assertFalse(renderable.in_object((200, 200))) self.assertFalse(renderable.in_object((200, 200)))
def test_factory_function(self): def test_factory_function(self):
"""Test the create_form_field_text factory function""" """Test the create_form_field_text factory function"""
custom_height = 30 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.assertIsInstance(renderable, FormFieldText)
self.assertEqual(renderable.text, "Username") self.assertEqual(renderable.text, "Username")
self.assertEqual(renderable.field, self.text_field) self.assertEqual(renderable.field, self.text_field)
@ -436,7 +450,7 @@ class TestFormFieldText(unittest.TestCase):
class TestInteractionCallbacks(unittest.TestCase): class TestInteractionCallbacks(unittest.TestCase):
"""Test cases for interaction functionality""" """Test cases for interaction functionality"""
def setUp(self): def setUp(self):
"""Set up test fixtures""" """Set up test fixtures"""
self.font = Font(font_size=12, colour=(0, 0, 0)) self.font = Font(font_size=12, colour=(0, 0, 0))
@ -455,7 +469,8 @@ class TestInteractionCallbacks(unittest.TestCase):
def test_link_text_interaction(self): def test_link_text_interaction(self):
"""Test that LinkText properly handles interaction""" """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) link = Link("test_function", LinkType.FUNCTION, self.link_callback)
renderable = LinkText(link, "Test Link", self.font, self.mock_draw) renderable = LinkText(link, "Test Link", self.font, self.mock_draw)

View File

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

View File

@ -6,30 +6,29 @@ Tests the Text and Line classes for text rendering functionality.
import unittest import unittest
import numpy as np import numpy as np
import os import os
from PIL import Image, ImageFont, ImageDraw from PIL import Image, ImageDraw
from unittest.mock import Mock, patch, MagicMock from unittest.mock import Mock
from pyWebLayout.concrete.text import Text, Line from pyWebLayout.concrete.text import Text, Line
from pyWebLayout.abstract.inline import Word from pyWebLayout.abstract.inline import Word
from pyWebLayout.style import Font, FontStyle, FontWeight, TextDecoration
from pyWebLayout.style import Alignment from pyWebLayout.style import Alignment
from tests.utils.test_fonts import create_default_test_font, ensure_consistent_font_in_tests from tests.utils.test_fonts import create_default_test_font, ensure_consistent_font_in_tests
class TestText(unittest.TestCase): class TestText(unittest.TestCase):
def setUp(self): def setUp(self):
# Ensure consistent font usage across tests # Ensure consistent font usage across tests
ensure_consistent_font_in_tests() ensure_consistent_font_in_tests()
# Create a real PIL image (canvas) for testing # Create a real PIL image (canvas) for testing
self.canvas = Image.new('RGB', (800, 600), color='white') self.canvas = Image.new('RGB', (800, 600), color='white')
# Create a real ImageDraw object # Create a real ImageDraw object
self.draw = ImageDraw.Draw(self.canvas) self.draw = ImageDraw.Draw(self.canvas)
# Create a consistent test Font object using bundled font # Create a consistent test Font object using bundled font
self.style = create_default_test_font() self.style = create_default_test_font()
def test_init(self): def test_init(self):
text_instance = Text(text="Test", style=self.style, draw=self.draw) text_instance = Text(text="Test", style=self.style, draw=self.draw)
self.assertEqual(text_instance.text, "Test") 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) text_instance = Text(text="Test", style=self.style, draw=self.draw)
# Set a position so we can render without issues # Set a position so we can render without issues
text_instance.set_origin(np.array([10, 50])) text_instance.set_origin(np.array([10, 50]))
# This should not raise any exceptions with real objects # This should not raise any exceptions with real objects
text_instance.render() text_instance.render()
# We can verify the canvas was modified (pixel check) # We can verify the canvas was modified (pixel check)
# After rendering, some pixels should have changed from pure white # After rendering, some pixels should have changed from pure white
# This is a more realistic test than checking mock calls # This is a more realistic test than checking mock calls
@ -70,7 +69,7 @@ class TestText(unittest.TestCase):
def test_text_dimensions(self): def test_text_dimensions(self):
"""Test that text dimensions are calculated correctly with real font""" """Test that text dimensions are calculated correctly with real font"""
text_instance = Text(text="Test", style=self.style, draw=self.draw) text_instance = Text(text="Test", style=self.style, draw=self.draw)
# With real objects, we should get actual width measurements # With real objects, we should get actual width measurements
self.assertGreater(text_instance.width, 0) self.assertGreater(text_instance.width, 0)
self.assertIsInstance(text_instance.width, (int, float)) 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 = Text(text="Hello World!", style=self.style, draw=self.draw)
text_instance.set_origin(np.array([50, 100])) text_instance.set_origin(np.array([50, 100]))
text_instance.render() text_instance.render()
# Optionally save the canvas for visual inspection # Optionally save the canvas for visual inspection
self._save_test_image("rendered_text.png") self._save_test_image("rendered_text.png")
# Verify that something was drawn (canvas is no longer pure white everywhere) # Verify that something was drawn (canvas is no longer pure white everywhere)
# Convert to array and check if any pixels changed # Convert to array and check if any pixels changed
pixels = np.array(self.canvas) pixels = np.array(self.canvas)
@ -120,13 +119,13 @@ class TestLine(unittest.TestCase):
def setUp(self): def setUp(self):
# Ensure consistent font usage across tests # Ensure consistent font usage across tests
ensure_consistent_font_in_tests() ensure_consistent_font_in_tests()
# Create a real PIL image (canvas) for testing # Create a real PIL image (canvas) for testing
self.canvas = Image.new('RGB', (800, 600), color='white') self.canvas = Image.new('RGB', (800, 600), color='white')
# Create a real ImageDraw object # Create a real ImageDraw object
self.draw = ImageDraw.Draw(self.canvas) self.draw = ImageDraw.Draw(self.canvas)
# Create a consistent test Font object using bundled font # Create a consistent test Font object using bundled font
self.style = create_default_test_font() self.style = create_default_test_font()
@ -135,7 +134,7 @@ class TestLine(unittest.TestCase):
spacing = (5, 15) # min_spacing, max_spacing spacing = (5, 15) # min_spacing, max_spacing
origin = np.array([0, 0]) origin = np.array([0, 0])
size = np.array([400, 50]) size = np.array([400, 50])
line = Line( line = Line(
spacing=spacing, spacing=spacing,
origin=origin, origin=origin,
@ -144,7 +143,7 @@ class TestLine(unittest.TestCase):
font=self.style, font=self.style,
halign=Alignment.LEFT halign=Alignment.LEFT
) )
self.assertEqual(line._spacing, spacing) self.assertEqual(line._spacing, spacing)
np.testing.assert_array_equal(line._origin, origin) np.testing.assert_array_equal(line._origin, origin)
np.testing.assert_array_equal(line._size, size) np.testing.assert_array_equal(line._size, size)
@ -155,7 +154,7 @@ class TestLine(unittest.TestCase):
spacing = (5, 15) spacing = (5, 15)
origin = np.array([0, 0]) origin = np.array([0, 0])
size = np.array([400, 50]) size = np.array([400, 50])
line = Line( line = Line(
spacing=spacing, spacing=spacing,
origin=origin, origin=origin,
@ -164,10 +163,10 @@ class TestLine(unittest.TestCase):
font=self.style, font=self.style,
halign=Alignment.LEFT halign=Alignment.LEFT
) )
# Create a word to add # Create a word to add
word = Word(text="Hello", style=self.style) word = Word(text="Hello", style=self.style)
# This test may need adjustment based on the actual implementation # This test may need adjustment based on the actual implementation
success, overflow_part = line.add_word(word) success, overflow_part = line.add_word(word)
@ -181,7 +180,7 @@ class TestLine(unittest.TestCase):
spacing = (5, 15) spacing = (5, 15)
origin = np.array([0, 0]) origin = np.array([0, 0])
size = np.array([400, 50]) size = np.array([400, 50])
line = Line( line = Line(
spacing=spacing, spacing=spacing,
origin=origin, origin=origin,
@ -190,25 +189,26 @@ class TestLine(unittest.TestCase):
font=self.style, font=self.style,
halign=Alignment.LEFT halign=Alignment.LEFT
) )
# Add words until the line is full # Add words until the line is full
words_added = 0 words_added = 0
for i in range(100): for i in range(100):
word = Word(text="Amsterdam", style=self.style) word = Word(text="Amsterdam", style=self.style)
success, overflow_part = line.add_word(word) success, overflow_part = line.add_word(word)
if overflow_part: if overflow_part:
# Word was hyphenated - overflow occurred # Word was hyphenated - overflow occurred
self.assertIsNotNone(overflow_part.text) self.assertIsNotNone(overflow_part.text)
return return
elif not success: elif not success:
# Line is full, word couldn't be added # 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 return
else: else:
# Word was added successfully # Word was added successfully
words_added += 1 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_add_word_until_overflow_small(self): def test_line_add_word_until_overflow_small(self):
@ -216,7 +216,7 @@ class TestLine(unittest.TestCase):
spacing = (5, 15) spacing = (5, 15)
origin = np.array([0, 0]) origin = np.array([0, 0])
size = np.array([400, 50]) size = np.array([400, 50])
line = Line( line = Line(
spacing=spacing, spacing=spacing,
origin=origin, origin=origin,
@ -225,20 +225,20 @@ class TestLine(unittest.TestCase):
font=self.style, font=self.style,
halign=Alignment.LEFT halign=Alignment.LEFT
) )
# Create a word to add # Create a word to add
for i in range(100): for i in range(100):
word = Word(text="Aslan", style=self.style) word = Word(text="Aslan", style=self.style)
# This test may need adjustment based on the actual implementation # This test may need adjustment based on the actual implementation
success, overflow_part = line.add_word(word) success, overflow_part = line.add_word(word)
# If successful, the word should be added # If successful, the word should be added
if success == False: if not success:
self.assertIsNone(overflow_part) self.assertIsNone(overflow_part)
return return
self.fail("Expected line to reach capacity but reached max iterations") self.fail("Expected line to reach capacity but reached max iterations")
def test_line_add_word_until_overflow_long_brute(self): def test_line_add_word_until_overflow_long_brute(self):
@ -246,7 +246,7 @@ class TestLine(unittest.TestCase):
spacing = (5, 15) spacing = (5, 15)
origin = np.array([0, 0]) origin = np.array([0, 0])
size = np.array([400, 50]) size = np.array([400, 50])
line = Line( line = Line(
spacing=spacing, spacing=spacing,
origin=origin, origin=origin,
@ -256,13 +256,14 @@ class TestLine(unittest.TestCase):
halign=Alignment.LEFT, halign=Alignment.LEFT,
min_word_length_for_brute_force=6 # Lower threshold to enable hyphenation for shorter words min_word_length_for_brute_force=6 # Lower threshold to enable hyphenation for shorter words
) )
# Use a longer word to trigger brute force hyphenation # Use a longer word to trigger brute force hyphenation
words_added = 0 words_added = 0
for i in range(100): 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) success, overflow_part = line.add_word(word)
if overflow_part: if overflow_part:
# Word was hyphenated - verify overflow part exists # Word was hyphenated - verify overflow part exists
self.assertIsNotNone(overflow_part.text) self.assertIsNotNone(overflow_part.text)
@ -270,20 +271,20 @@ class TestLine(unittest.TestCase):
return return
elif not success: elif not success:
# Line is full, word couldn't be added # 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 return
else: else:
words_added += 1 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): def test_line_render(self):
"""Test line rendering with real objects""" """Test line rendering with real objects"""
spacing = (5, 15) spacing = (5, 15)
origin = np.array([50, 100]) origin = np.array([50, 100])
size = np.array([400, 50]) size = np.array([400, 50])
line = Line( line = Line(
spacing=spacing, spacing=spacing,
origin=origin, origin=origin,
@ -292,7 +293,7 @@ class TestLine(unittest.TestCase):
font=self.style, font=self.style,
halign=Alignment.LEFT halign=Alignment.LEFT
) )
# Try to render the line (even if empty) # Try to render the line (even if empty)
try: try:
line.render() 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) # Both parts should be LinkText (this is the bug we're testing for)
for text_obj in line._text_objects: for text_obj in line._text_objects:
self.assertIsInstance(text_obj, LinkText, self.assertIsInstance(
f"Hyphenated LinkedWord part should be LinkText, got {type(text_obj)}") text_obj,
LinkText,
f"Hyphenated LinkedWord part should be LinkText, got {
type(text_obj)}")
self.assertEqual(text_obj.link.location, linked_word.location) self.assertEqual(text_obj.link.location, linked_word.location)
# The overflow should also be LinkText if it's hyphenated # 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 unittest
import numpy as np import numpy as np
from PIL import Image, ImageDraw from PIL import Image
from pyWebLayout.concrete.page import Page from pyWebLayout.concrete.page import Page
from pyWebLayout.style.page_style import PageStyle from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.style.fonts import Font
from pyWebLayout.core.base import Renderable, Queriable from pyWebLayout.core.base import Renderable, Queriable
@ -23,7 +22,7 @@ class SimpleTestRenderable(Renderable, Queriable):
self._text = text self._text = text
self.size = size self.size = size
self._origin = np.array([0, 0]) self._origin = np.array([0, 0])
def render(self): def render(self):
"""Render returns None - drawing is done via the page's draw object""" """Render returns None - drawing is done via the page's draw object"""
return None return None
@ -31,7 +30,7 @@ class SimpleTestRenderable(Renderable, Queriable):
class TestPageImplementation(unittest.TestCase): class TestPageImplementation(unittest.TestCase):
"""Test cases for the Page class implementation""" """Test cases for the Page class implementation"""
def setUp(self): def setUp(self):
"""Set up test fixtures""" """Set up test fixtures"""
self.basic_style = PageStyle( self.basic_style = PageStyle(
@ -42,81 +41,84 @@ class TestPageImplementation(unittest.TestCase):
padding=(15, 15, 15, 15), padding=(15, 15, 15, 15),
background_color=(240, 240, 240) background_color=(240, 240, 240)
) )
self.page_size = (800, 600) self.page_size = (800, 600)
def test_page_creation_with_style(self): def test_page_creation_with_style(self):
"""Test creating a page with a PageStyle""" """Test creating a page with a PageStyle"""
page = Page(size=self.page_size, style=self.basic_style) page = Page(size=self.page_size, style=self.basic_style)
self.assertEqual(page.size, self.page_size) self.assertEqual(page.size, self.page_size)
self.assertEqual(page.style, self.basic_style) self.assertEqual(page.style, self.basic_style)
self.assertEqual(page.border_size, 2) self.assertEqual(page.border_size, 2)
def test_page_creation_without_style(self): def test_page_creation_without_style(self):
"""Test creating a page without a PageStyle (should use defaults)""" """Test creating a page without a PageStyle (should use defaults)"""
page = Page(size=self.page_size) page = Page(size=self.page_size)
self.assertEqual(page.size, self.page_size) self.assertEqual(page.size, self.page_size)
self.assertIsNotNone(page.style) self.assertIsNotNone(page.style)
def test_page_canvas_and_content_sizes(self): def test_page_canvas_and_content_sizes(self):
"""Test that page correctly calculates canvas and content sizes""" """Test that page correctly calculates canvas and content sizes"""
style = PageStyle( style = PageStyle(
border_width=5, border_width=5,
padding=(10, 20, 30, 40) # top, right, bottom, left padding=(10, 20, 30, 40) # top, right, bottom, left
) )
page = Page(size=self.page_size, style=style) page = Page(size=self.page_size, style=style)
# Canvas size should be page size minus borders # Canvas size should be page size minus borders
expected_canvas_size = (790, 590) # 800-10, 600-10 (border on both sides) expected_canvas_size = (790, 590) # 800-10, 600-10 (border on both sides)
self.assertEqual(page.canvas_size, expected_canvas_size) self.assertEqual(page.canvas_size, expected_canvas_size)
# Content size should be canvas minus padding # 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) self.assertEqual(page.content_size, expected_content_size)
def test_page_add_remove_children(self): def test_page_add_remove_children(self):
"""Test adding and removing children from the page""" """Test adding and removing children from the page"""
page = Page(size=self.page_size) page = Page(size=self.page_size)
# Initially no children # Initially no children
self.assertEqual(len(page.children), 0) self.assertEqual(len(page.children), 0)
# Add children # Add children
child1 = SimpleTestRenderable("Child 1") child1 = SimpleTestRenderable("Child 1")
child2 = SimpleTestRenderable("Child 2") child2 = SimpleTestRenderable("Child 2")
page.add_child(child1) page.add_child(child1)
self.assertEqual(len(page.children), 1) self.assertEqual(len(page.children), 1)
self.assertIn(child1, page.children) self.assertIn(child1, page.children)
page.add_child(child2) page.add_child(child2)
self.assertEqual(len(page.children), 2) self.assertEqual(len(page.children), 2)
self.assertIn(child2, page.children) self.assertIn(child2, page.children)
# Test method chaining # Test method chaining
child3 = SimpleTestRenderable("Child 3") child3 = SimpleTestRenderable("Child 3")
result = page.add_child(child3) result = page.add_child(child3)
self.assertIs(result, page) # Should return self for chaining self.assertIs(result, page) # Should return self for chaining
self.assertEqual(len(page.children), 3) self.assertEqual(len(page.children), 3)
self.assertIn(child3, page.children) 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) removed = page.remove_child(child2)
self.assertTrue(removed) self.assertTrue(removed)
self.assertEqual(len(page.children), 2) self.assertEqual(len(page.children), 2)
self.assertNotIn(child2, page.children) self.assertNotIn(child2, page.children)
# Try to remove non-existent child # Try to remove non-existent child
removed = page.remove_child(child2) removed = page.remove_child(child2)
self.assertFalse(removed) self.assertFalse(removed)
# Clear all children # Clear all children
page.clear_children() page.clear_children()
self.assertEqual(len(page.children), 0) self.assertEqual(len(page.children), 0)
def test_page_render(self): def test_page_render(self):
"""Test that page renders and creates a canvas""" """Test that page renders and creates a canvas"""
style = PageStyle( style = PageStyle(
@ -124,24 +126,24 @@ class TestPageImplementation(unittest.TestCase):
border_color=(255, 0, 0), border_color=(255, 0, 0),
background_color=(255, 255, 255) background_color=(255, 255, 255)
) )
page = Page(size=(200, 150), style=style) page = Page(size=(200, 150), style=style)
# Add a child # Add a child
child = SimpleTestRenderable("Test child") child = SimpleTestRenderable("Test child")
page.add_child(child) page.add_child(child)
# Render the page # Render the page
image = page.render() image = page.render()
# Check that we got an image # Check that we got an image
self.assertIsInstance(image, Image.Image) self.assertIsInstance(image, Image.Image)
self.assertEqual(image.size, (200, 150)) self.assertEqual(image.size, (200, 150))
self.assertEqual(image.mode, 'RGBA') self.assertEqual(image.mode, 'RGBA')
# Check that draw object is available # Check that draw object is available
self.assertIsNotNone(page.draw) self.assertIsNotNone(page.draw)
def test_page_query_point(self): def test_page_query_point(self):
"""Test querying points to find children""" """Test querying points to find children"""
page = Page(size=(400, 300)) page = Page(size=(400, 300))
@ -167,22 +169,22 @@ class TestPageImplementation(unittest.TestCase):
result = page.query_point((300, 250)) result = page.query_point((300, 250))
self.assertIsNotNone(result) self.assertIsNotNone(result)
self.assertEqual(result.object_type, "empty") self.assertEqual(result.object_type, "empty")
def test_page_in_object(self): def test_page_in_object(self):
"""Test that page correctly implements in_object""" """Test that page correctly implements in_object"""
page = Page(size=(400, 300)) page = Page(size=(400, 300))
# Points within page bounds # Points within page bounds
self.assertTrue(page.in_object((0, 0))) self.assertTrue(page.in_object((0, 0)))
self.assertTrue(page.in_object((200, 150))) self.assertTrue(page.in_object((200, 150)))
self.assertTrue(page.in_object((399, 299))) self.assertTrue(page.in_object((399, 299)))
# Points outside page bounds # Points outside page bounds
self.assertFalse(page.in_object((-1, 0))) self.assertFalse(page.in_object((-1, 0)))
self.assertFalse(page.in_object((0, -1))) self.assertFalse(page.in_object((0, -1)))
self.assertFalse(page.in_object((400, 299))) self.assertFalse(page.in_object((400, 299)))
self.assertFalse(page.in_object((399, 300))) self.assertFalse(page.in_object((399, 300)))
def test_page_with_borders(self): def test_page_with_borders(self):
"""Test page rendering with borders""" """Test page rendering with borders"""
style = PageStyle( style = PageStyle(
@ -190,33 +192,33 @@ class TestPageImplementation(unittest.TestCase):
border_color=(128, 128, 128), border_color=(128, 128, 128),
background_color=(255, 255, 255) background_color=(255, 255, 255)
) )
page = Page(size=(100, 100), style=style) page = Page(size=(100, 100), style=style)
image = page.render() image = page.render()
# Check that image was created # Check that image was created
self.assertIsInstance(image, Image.Image) self.assertIsInstance(image, Image.Image)
self.assertEqual(image.size, (100, 100)) self.assertEqual(image.size, (100, 100))
# The border should be drawn but we can't easily test pixel values # The border should be drawn but we can't easily test pixel values
# Just verify the image exists and has the right properties # Just verify the image exists and has the right properties
def test_page_border_size_property(self): def test_page_border_size_property(self):
"""Test that border_size property returns correct value""" """Test that border_size property returns correct value"""
# Test with border # Test with border
style_with_border = PageStyle(border_width=5) style_with_border = PageStyle(border_width=5)
page_with_border = Page(size=self.page_size, style=style_with_border) page_with_border = Page(size=self.page_size, style=style_with_border)
self.assertEqual(page_with_border.border_size, 5) self.assertEqual(page_with_border.border_size, 5)
# Test without border # Test without border
style_no_border = PageStyle(border_width=0) style_no_border = PageStyle(border_width=0)
page_no_border = Page(size=self.page_size, style=style_no_border) page_no_border = Page(size=self.page_size, style=style_no_border)
self.assertEqual(page_no_border.border_size, 0) self.assertEqual(page_no_border.border_size, 0)
def test_page_style_properties(self): def test_page_style_properties(self):
"""Test that page correctly exposes style properties""" """Test that page correctly exposes style properties"""
page = Page(size=self.page_size, style=self.basic_style) page = Page(size=self.page_size, style=self.basic_style)
# Test that style properties are accessible # Test that style properties are accessible
self.assertEqual(page.style.border_width, 2) self.assertEqual(page.style.border_width, 2)
self.assertEqual(page.style.border_color, (255, 0, 0)) 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.inter_block_spacing, 20)
self.assertEqual(page.style.padding, (15, 15, 15, 15)) self.assertEqual(page.style.padding, (15, 15, 15, 15))
self.assertEqual(page.style.background_color, (240, 240, 240)) self.assertEqual(page.style.background_color, (240, 240, 240))
def test_page_children_list_operations(self): def test_page_children_list_operations(self):
"""Test that children list behaves correctly""" """Test that children list behaves correctly"""
page = Page(size=self.page_size) page = Page(size=self.page_size)
# Test that children is initially empty list # Test that children is initially empty list
self.assertIsInstance(page.children, list) self.assertIsInstance(page.children, list)
self.assertEqual(len(page.children), 0) self.assertEqual(len(page.children), 0)
# Test adding multiple children # Test adding multiple children
children = [ children = [
SimpleTestRenderable(f"Child {i}") SimpleTestRenderable(f"Child {i}")
for i in range(5) for i in range(5)
] ]
for child in children: for child in children:
page.add_child(child) page.add_child(child)
self.assertEqual(len(page.children), 5) self.assertEqual(len(page.children), 5)
# Test that children are in the correct order # Test that children are in the correct order
for i, child in enumerate(page.children): for i, child in enumerate(page.children):
self.assertEqual(child._text, f"Child {i}") self.assertEqual(child._text, f"Child {i}")
def test_page_can_fit_line_boundary_checking(self): def test_page_can_fit_line_boundary_checking(self):
"""Test that can_fit_line correctly checks bottom boundary""" """Test that can_fit_line correctly checks bottom boundary"""
# Create page with known dimensions # Create page with known dimensions
@ -259,63 +261,64 @@ class TestPageImplementation(unittest.TestCase):
padding=(10, 10, 10, 10) padding=(10, 10, 10, 10)
) )
page = Page(size=(800, 600), style=style) page = Page(size=(800, 600), style=style)
# Initial y_offset should be at border + padding_top = 50 # Initial y_offset should be at border + padding_top = 50
self.assertEqual(page._current_y_offset, 50) self.assertEqual(page._current_y_offset, 50)
# Test 1: Line that fits comfortably # Test 1: Line that fits comfortably
line_height = 20 line_height = 20
max_y = 600 - 40 - 10 # 550 _max_y = 600 - 40 - 10 # 550
self.assertTrue(page.can_fit_line(line_height)) self.assertTrue(page.can_fit_line(line_height))
# Would end at 50 + 20 = 70, well within 550 # Would end at 50 + 20 = 70, well within 550
# Test 2: Simulate adding lines to fill the page # Test 2: Simulate adding lines to fill the page
# Available height: 550 - 50 = 500 pixels # Available height: 550 - 50 = 500 pixels
# With 20-pixel lines, we can fit 25 lines exactly # With 20-pixel lines, we can fit 25 lines exactly
for i in range(24): # Add 24 lines 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 # Simulate adding a line by updating y_offset
page._current_y_offset += 20 page._current_y_offset += 20
# After 24 lines: y_offset = 50 + (24 * 20) = 530 # After 24 lines: y_offset = 50 + (24 * 20) = 530
self.assertEqual(page._current_y_offset, 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)) self.assertTrue(page.can_fit_line(20))
page._current_y_offset += 20 page._current_y_offset += 20
self.assertEqual(page._current_y_offset, 550) self.assertEqual(page._current_y_offset, 550)
# Test 4: Now another line should NOT fit (550 + 20 = 570 > 550) # Test 4: Now another line should NOT fit (550 + 20 = 570 > 550)
self.assertFalse(page.can_fit_line(20)) self.assertFalse(page.can_fit_line(20))
# Test 5: Even a 1-pixel line should not fit (550 + 1 = 551 > 550) # Test 5: Even a 1-pixel line should not fit (550 + 1 = 551 > 550)
self.assertFalse(page.can_fit_line(1)) self.assertFalse(page.can_fit_line(1))
# Test 6: Edge case - exactly at boundary, 0-height line should fit # Test 6: Edge case - exactly at boundary, 0-height line should fit
self.assertTrue(page.can_fit_line(0)) self.assertTrue(page.can_fit_line(0))
def test_page_can_fit_line_with_different_styles(self): def test_page_can_fit_line_with_different_styles(self):
"""Test can_fit_line with different page styles""" """Test can_fit_line with different page styles"""
# Test with no border or padding # Test with no border or padding
style_no_border = PageStyle(border_width=0, padding=(0, 0, 0, 0)) style_no_border = PageStyle(border_width=0, padding=(0, 0, 0, 0))
page_no_border = Page(size=(100, 100), style=style_no_border) page_no_border = Page(size=(100, 100), style=style_no_border)
# With no border/padding, y_offset starts at 0 # With no border/padding, y_offset starts at 0
self.assertEqual(page_no_border._current_y_offset, 0) self.assertEqual(page_no_border._current_y_offset, 0)
# Can fit a 100-pixel line exactly # Can fit a 100-pixel line exactly
self.assertTrue(page_no_border.can_fit_line(100)) self.assertTrue(page_no_border.can_fit_line(100))
# Cannot fit a 101-pixel line # Cannot fit a 101-pixel line
self.assertFalse(page_no_border.can_fit_line(101)) self.assertFalse(page_no_border.can_fit_line(101))
# Test with large border and padding # Test with large border and padding
style_large = PageStyle(border_width=20, padding=(15, 15, 15, 15)) style_large = PageStyle(border_width=20, padding=(15, 15, 15, 15))
page_large = Page(size=(200, 200), style=style_large) page_large = Page(size=(200, 200), style=style_large)
# y_offset starts at border + padding_top = 20 + 15 = 35 # y_offset starts at border + padding_top = 20 + 15 = 35
self.assertEqual(page_large._current_y_offset, 35) self.assertEqual(page_large._current_y_offset, 35)
# Max y = 200 - 20 - 15 = 165 # Max y = 200 - 20 - 15 = 165
# Available height = 165 - 35 = 130 pixels # Available height = 165 - 35 = 130 pixels
self.assertTrue(page_large.can_fit_line(130)) self.assertTrue(page_large.can_fit_line(130))

View File

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

View File

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

View File

@ -15,7 +15,6 @@ from pyWebLayout.concrete.text import Text, Line
from pyWebLayout.concrete.functional import LinkText from pyWebLayout.concrete.functional import LinkText
from pyWebLayout.abstract.inline import Word from pyWebLayout.abstract.inline import Word
from pyWebLayout.abstract.functional import Link, LinkType from pyWebLayout.abstract.functional import Link, LinkType
from pyWebLayout.style import Font, Alignment
from pyWebLayout.style.page_style import PageStyle from pyWebLayout.style.page_style import PageStyle
from tests.utils.test_fonts import create_default_test_font, ensure_consistent_font_in_tests 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] start_text = line._text_objects[0]
end_text = line._text_objects[1] 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)) end_point = (int(end_text._origin[0] + 5), int(end_text._origin[1] + 5))
sel_range = self.page.query_range(start_point, end_point) sel_range = self.page.query_range(start_point, end_point)

View File

@ -9,7 +9,6 @@ import unittest
import tempfile import tempfile
import os import os
import shutil import shutil
from datetime import datetime
# Import ebooklib for creating test EPUB files # Import ebooklib for creating test EPUB files
try: try:
@ -21,8 +20,8 @@ except ImportError:
from pyWebLayout.io.readers.epub_reader import read_epub, EPUBReader from pyWebLayout.io.readers.epub_reader import read_epub, EPUBReader
from pyWebLayout.abstract.document import Book from pyWebLayout.abstract.document import Book
from pyWebLayout.abstract.block import ( from pyWebLayout.abstract.block import (
Paragraph, Heading, HeadingLevel, Quote, CodeBlock, Paragraph, Heading, Quote, CodeBlock, HList,
HList, ListStyle, Table, HorizontalRule, Image ListStyle, Table, Image
) )
from pyWebLayout.style import FontWeight, FontStyle, TextDecoration 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") @unittest.skipUnless(EBOOKLIB_AVAILABLE, "ebooklib not available")
class TestEPUBReader(unittest.TestCase): class TestEPUBReader(unittest.TestCase):
"""Test cases for EPUB reader functionality.""" """Test cases for EPUB reader functionality."""
def setUp(self): def setUp(self):
"""Set up test environment.""" """Set up test environment."""
self.test_dir = tempfile.mkdtemp() self.test_dir = tempfile.mkdtemp()
self.epub_files = [] self.epub_files = []
def tearDown(self): def tearDown(self):
"""Clean up test environment.""" """Clean up test environment."""
# Clean up test EPUB files # Clean up test EPUB files
@ -44,21 +43,21 @@ class TestEPUBReader(unittest.TestCase):
os.remove(epub_file) os.remove(epub_file)
except OSError: except OSError:
pass pass
# Clean up test directory # Clean up test directory
if os.path.exists(self.test_dir): if os.path.exists(self.test_dir):
shutil.rmtree(self.test_dir, ignore_errors=True) shutil.rmtree(self.test_dir, ignore_errors=True)
def create_simple_epub(self, title="Test Book", author="Test Author"): def create_simple_epub(self, title="Test Book", author="Test Author"):
"""Create a simple EPUB file for testing.""" """Create a simple EPUB file for testing."""
book = epub.EpubBook() book = epub.EpubBook()
# Set metadata # Set metadata
book.set_identifier('test-id-123') book.set_identifier('test-id-123')
book.set_title(title) book.set_title(title)
book.set_language('en') book.set_language('en')
book.add_author(author) book.add_author(author)
# Create a simple chapter # Create a simple chapter
chapter1 = epub.EpubHtml( chapter1 = epub.EpubHtml(
title='Chapter 1', title='Chapter 1',
@ -75,31 +74,32 @@ class TestEPUBReader(unittest.TestCase):
</body> </body>
</html> </html>
''' '''
# Add chapter to book # Add chapter to book
book.add_item(chapter1) book.add_item(chapter1)
# Define table of contents # Define table of contents
book.toc = (epub.Link("chapter1.xhtml", "Chapter 1", "ch1"),) book.toc = (epub.Link("chapter1.xhtml", "Chapter 1", "ch1"),)
# Add navigation files # Add navigation files
book.add_item(epub.EpubNcx()) book.add_item(epub.EpubNcx())
book.add_item(epub.EpubNav()) book.add_item(epub.EpubNav())
# Define spine # Define spine
book.spine = ['nav', chapter1] book.spine = ['nav', chapter1]
# Create temporary file # 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, {}) epub.write_epub(epub_path, book, {})
self.epub_files.append(epub_path) self.epub_files.append(epub_path)
return epub_path return epub_path
def create_complex_epub(self): def create_complex_epub(self):
"""Create a more complex EPUB file with multiple chapters and content types.""" """Create a more complex EPUB file with multiple chapters and content types."""
book = epub.EpubBook() book = epub.EpubBook()
# Set metadata # Set metadata
book.set_identifier('complex-test-id-456') book.set_identifier('complex-test-id-456')
book.set_title('Complex Test Book') book.set_title('Complex Test Book')
@ -109,7 +109,7 @@ class TestEPUBReader(unittest.TestCase):
book.add_metadata('DC', 'subject', 'Testing') book.add_metadata('DC', 'subject', 'Testing')
book.add_metadata('DC', 'date', '2024-01-01') book.add_metadata('DC', 'date', '2024-01-01')
book.add_metadata('DC', 'publisher', 'Test Publisher') book.add_metadata('DC', 'publisher', 'Test Publisher')
# Chapter 1: Basic content # Chapter 1: Basic content
chapter1 = epub.EpubHtml( chapter1 = epub.EpubHtml(
title='Introduction', title='Introduction',
@ -126,7 +126,7 @@ class TestEPUBReader(unittest.TestCase):
</body> </body>
</html> </html>
''' '''
# Chapter 2: Styled content # Chapter 2: Styled content
chapter2 = epub.EpubHtml( chapter2 = epub.EpubHtml(
title='Styled Content', title='Styled Content',
@ -138,7 +138,7 @@ class TestEPUBReader(unittest.TestCase):
<head><title>Styled Content</title></head> <head><title>Styled Content</title></head>
<body> <body>
<h1>Styled Content</h1> <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> and <span style="color: red; font-weight: bold;">colored text</span>.</p>
<h2>Subsection</h2> <h2>Subsection</h2>
<p>Text with <u>underline</u> and <s>strikethrough</s>.</p> <p>Text with <u>underline</u> and <s>strikethrough</s>.</p>
@ -147,7 +147,7 @@ class TestEPUBReader(unittest.TestCase):
</body> </body>
</html> </html>
''' '''
# Chapter 3: Lists and quotes # Chapter 3: Lists and quotes
chapter3 = epub.EpubHtml( chapter3 = epub.EpubHtml(
title='Lists and Quotes', title='Lists and Quotes',
@ -159,30 +159,30 @@ class TestEPUBReader(unittest.TestCase):
<head><title>Lists and Quotes</title></head> <head><title>Lists and Quotes</title></head>
<body> <body>
<h1>Lists and Quotes</h1> <h1>Lists and Quotes</h1>
<h2>Unordered List</h2> <h2>Unordered List</h2>
<ul> <ul>
<li>First item</li> <li>First item</li>
<li><strong>Bold item</strong></li> <li><strong>Bold item</strong></li>
<li>Item with <em>italic text</em></li> <li>Item with <em>italic text</em></li>
</ul> </ul>
<h2>Ordered List</h2> <h2>Ordered List</h2>
<ol> <ol>
<li>First numbered item</li> <li>First numbered item</li>
<li>Second numbered item</li> <li>Second numbered item</li>
<li>Third numbered item</li> <li>Third numbered item</li>
</ol> </ol>
<h2>Quote</h2> <h2>Quote</h2>
<blockquote> <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> with some styling.</p>
</blockquote> </blockquote>
</body> </body>
</html> </html>
''' '''
# Chapter 4: Tables and code # Chapter 4: Tables and code
chapter4 = epub.EpubHtml( chapter4 = epub.EpubHtml(
title='Tables and Code', title='Tables and Code',
@ -194,7 +194,7 @@ class TestEPUBReader(unittest.TestCase):
<head><title>Tables and Code</title></head> <head><title>Tables and Code</title></head>
<body> <body>
<h1>Tables and Code</h1> <h1>Tables and Code</h1>
<h2>Simple Table</h2> <h2>Simple Table</h2>
<table> <table>
<thead> <thead>
@ -214,25 +214,25 @@ class TestEPUBReader(unittest.TestCase):
</tr> </tr>
</tbody> </tbody>
</table> </table>
<h2>Code Block</h2> <h2>Code Block</h2>
<pre><code>function test() { <pre><code>function test() {
console.log("Hello, world!"); console.log("Hello, world!");
return true; return true;
}</code></pre> }</code></pre>
<h2>Inline Code</h2> <h2>Inline Code</h2>
<p>Use the <code>print()</code> function to output text.</p> <p>Use the <code>print()</code> function to output text.</p>
</body> </body>
</html> </html>
''' '''
# Add chapters to book # Add chapters to book
book.add_item(chapter1) book.add_item(chapter1)
book.add_item(chapter2) book.add_item(chapter2)
book.add_item(chapter3) book.add_item(chapter3)
book.add_item(chapter4) book.add_item(chapter4)
# Define table of contents # Define table of contents
book.toc = ( book.toc = (
epub.Link("chapter1.xhtml", "Introduction", "intro"), epub.Link("chapter1.xhtml", "Introduction", "intro"),
@ -240,31 +240,32 @@ class TestEPUBReader(unittest.TestCase):
epub.Link("chapter3.xhtml", "Lists and Quotes", "lists"), epub.Link("chapter3.xhtml", "Lists and Quotes", "lists"),
epub.Link("chapter4.xhtml", "Tables and Code", "tables") epub.Link("chapter4.xhtml", "Tables and Code", "tables")
) )
# Add navigation files # Add navigation files
book.add_item(epub.EpubNcx()) book.add_item(epub.EpubNcx())
book.add_item(epub.EpubNav()) book.add_item(epub.EpubNav())
# Define spine # Define spine
book.spine = ['nav', chapter1, chapter2, chapter3, chapter4] book.spine = ['nav', chapter1, chapter2, chapter3, chapter4]
# Create temporary file # 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, {}) epub.write_epub(epub_path, book, {})
self.epub_files.append(epub_path) self.epub_files.append(epub_path)
return epub_path return epub_path
def create_epub_with_nested_content(self): def create_epub_with_nested_content(self):
"""Create an EPUB with nested content structures.""" """Create an EPUB with nested content structures."""
book = epub.EpubBook() book = epub.EpubBook()
# Set metadata # Set metadata
book.set_identifier('nested-test-id-789') book.set_identifier('nested-test-id-789')
book.set_title('Nested Content Test') book.set_title('Nested Content Test')
book.set_language('en') book.set_language('en')
book.add_author('Test Author') book.add_author('Test Author')
# Chapter with nested content # Chapter with nested content
chapter = epub.EpubHtml( chapter = epub.EpubHtml(
title='Nested Content', title='Nested Content',
@ -276,17 +277,17 @@ class TestEPUBReader(unittest.TestCase):
<head><title>Nested Content</title></head> <head><title>Nested Content</title></head>
<body> <body>
<h1>Nested Content Examples</h1> <h1>Nested Content Examples</h1>
<div> <div>
<h2>Section in Div</h2> <h2>Section in Div</h2>
<p>Paragraph inside div.</p> <p>Paragraph inside div.</p>
<section> <section>
<h3>Subsection</h3> <h3>Subsection</h3>
<article> <article>
<h4>Article Header</h4> <h4>Article Header</h4>
<p>Article content with <strong>nested <em>formatting</em></strong>.</p> <p>Article content with <strong>nested <em>formatting</em></strong>.</p>
<aside> <aside>
<p>Sidebar content in aside element.</p> <p>Sidebar content in aside element.</p>
<ul> <ul>
@ -297,268 +298,284 @@ class TestEPUBReader(unittest.TestCase):
</article> </article>
</section> </section>
</div> </div>
<footer> <footer>
<p>Footer content with <span style="font-size: 12px; color: gray;">small gray text</span>.</p> <p>Footer content with <span style="font-size: 12px; color: gray;">small gray text</span>.</p>
</footer> </footer>
</body> </body>
</html> </html>
''' '''
# Add chapter to book # Add chapter to book
book.add_item(chapter) book.add_item(chapter)
# Define table of contents # Define table of contents
book.toc = (epub.Link("nested.xhtml", "Nested Content", "nested"),) book.toc = (epub.Link("nested.xhtml", "Nested Content", "nested"),)
# Add navigation files # Add navigation files
book.add_item(epub.EpubNcx()) book.add_item(epub.EpubNcx())
book.add_item(epub.EpubNav()) book.add_item(epub.EpubNav())
# Define spine # Define spine
book.spine = ['nav', chapter] book.spine = ['nav', chapter]
# Create temporary file # 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, {}) epub.write_epub(epub_path, book, {})
self.epub_files.append(epub_path) self.epub_files.append(epub_path)
return epub_path return epub_path
def test_simple_epub_reading(self): def test_simple_epub_reading(self):
"""Test reading a simple EPUB file.""" """Test reading a simple EPUB file."""
epub_path = self.create_simple_epub() epub_path = self.create_simple_epub()
# Read the EPUB # Read the EPUB
book = read_epub(epub_path) book = read_epub(epub_path)
# Verify it's a Book object # Verify it's a Book object
self.assertIsInstance(book, Book) self.assertIsInstance(book, Book)
# Check metadata # Check metadata
self.assertEqual(book.title, "Test Book") self.assertEqual(book.title, "Test Book")
# Check chapters # Check chapters
chapters = list(book.chapters) chapters = list(book.chapters)
self.assertEqual(len(chapters), 1) self.assertEqual(len(chapters), 1)
# Check chapter content # Check chapter content
chapter = chapters[0] chapter = chapters[0]
blocks = list(chapter.blocks) blocks = list(chapter.blocks)
self.assertGreater(len(blocks), 0) self.assertGreater(len(blocks), 0)
# Should have a heading and paragraphs # Should have a heading and paragraphs
has_heading = any(isinstance(block, Heading) for block in blocks) has_heading = any(isinstance(block, Heading) for block in blocks)
has_paragraph = any(isinstance(block, Paragraph) 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_heading, "Should contain at least one heading")
self.assertTrue(has_paragraph, "Should contain at least one paragraph") self.assertTrue(has_paragraph, "Should contain at least one paragraph")
def test_complex_epub_reading(self): def test_complex_epub_reading(self):
"""Test reading a complex EPUB file with multiple chapters.""" """Test reading a complex EPUB file with multiple chapters."""
epub_path = self.create_complex_epub() epub_path = self.create_complex_epub()
# Read the EPUB # Read the EPUB
book = read_epub(epub_path) book = read_epub(epub_path)
# Verify it's a Book object # Verify it's a Book object
self.assertIsInstance(book, Book) self.assertIsInstance(book, Book)
# Check metadata # Check metadata
self.assertEqual(book.title, "Complex Test Book") self.assertEqual(book.title, "Complex Test Book")
# Check chapters # Check chapters
chapters = list(book.chapters) chapters = list(book.chapters)
self.assertEqual(len(chapters), 4) self.assertEqual(len(chapters), 4)
# Test each chapter has content # Test each chapter has content
for i, chapter in enumerate(chapters): for i, chapter in enumerate(chapters):
blocks = list(chapter.blocks) 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 # Each chapter should start with a heading
first_block = blocks[0] 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): def test_epub_styled_content(self):
"""Test that styled content in EPUB is properly parsed.""" """Test that styled content in EPUB is properly parsed."""
epub_path = self.create_complex_epub() epub_path = self.create_complex_epub()
book = read_epub(epub_path) book = read_epub(epub_path)
chapters = list(book.chapters) chapters = list(book.chapters)
# Check styled content in chapter 2 (index 1) # Check styled content in chapter 2 (index 1)
if len(chapters) > 1: if len(chapters) > 1:
chapter2_blocks = list(chapters[1].blocks) chapter2_blocks = list(chapters[1].blocks)
# Find paragraphs with styled text # Find paragraphs with styled text
styled_words_found = False styled_words_found = False
for block in chapter2_blocks: for block in chapter2_blocks:
if isinstance(block, Paragraph): if isinstance(block, Paragraph):
words = list(block.words_iter()) words = list(block.words_iter())
for _, word in words: 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.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 styled_words_found = True
break break
if styled_words_found: if styled_words_found:
break break
self.assertTrue(styled_words_found, "Should find styled words in chapter 2") self.assertTrue(styled_words_found, "Should find styled words in chapter 2")
def test_epub_lists(self): def test_epub_lists(self):
"""Test that lists in EPUB are properly parsed.""" """Test that lists in EPUB are properly parsed."""
epub_path = self.create_complex_epub() epub_path = self.create_complex_epub()
book = read_epub(epub_path) book = read_epub(epub_path)
chapters = list(book.chapters) chapters = list(book.chapters)
# Check lists in chapter 3 (index 2) # Check lists in chapter 3 (index 2)
if len(chapters) > 2: if len(chapters) > 2:
chapter3_blocks = list(chapters[2].blocks) chapter3_blocks = list(chapters[2].blocks)
# Find list blocks # Find list blocks
unordered_list_found = False unordered_list_found = False
ordered_list_found = False ordered_list_found = False
quote_found = False quote_found = False
for block in chapter3_blocks: for block in chapter3_blocks:
if isinstance(block, HList): if isinstance(block, HList):
if block.style == ListStyle.UNORDERED: if block.style == ListStyle.UNORDERED:
unordered_list_found = True unordered_list_found = True
# Check list items # Check list items
items = list(block.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: elif block.style == ListStyle.ORDERED:
ordered_list_found = True ordered_list_found = True
# Check list items # Check list items
items = list(block.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): elif isinstance(block, Quote):
quote_found = True 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(ordered_list_found, "Should find ordered list in chapter 3")
self.assertTrue(quote_found, "Should find quote in chapter 3") self.assertTrue(quote_found, "Should find quote in chapter 3")
def test_epub_tables(self): def test_epub_tables(self):
"""Test that tables in EPUB are properly parsed.""" """Test that tables in EPUB are properly parsed."""
epub_path = self.create_complex_epub() epub_path = self.create_complex_epub()
book = read_epub(epub_path) book = read_epub(epub_path)
chapters = list(book.chapters) chapters = list(book.chapters)
# Check tables in chapter 4 (index 3) # Check tables in chapter 4 (index 3)
if len(chapters) > 3: if len(chapters) > 3:
chapter4_blocks = list(chapters[3].blocks) chapter4_blocks = list(chapters[3].blocks)
# Find table blocks # Find table blocks
table_found = False table_found = False
code_block_found = False code_block_found = False
for block in chapter4_blocks: for block in chapter4_blocks:
if isinstance(block, Table): if isinstance(block, Table):
table_found = True table_found = True
# Check table has rows # Check table has rows
rows = list(block.all_rows()) rows = list(block.all_rows())
self.assertGreater(len(rows), 0, "Table should have rows") self.assertGreater(len(rows), 0, "Table should have rows")
elif isinstance(block, CodeBlock): elif isinstance(block, CodeBlock):
code_block_found = True code_block_found = True
# Check code block has lines # Check code block has lines
lines = list(block.lines()) lines = list(block.lines())
self.assertGreater(len(lines), 0, "Code block should have lines") self.assertGreater(len(lines), 0, "Code block should have lines")
self.assertTrue(table_found, "Should find table in chapter 4") self.assertTrue(table_found, "Should find table in chapter 4")
self.assertTrue(code_block_found, "Should find code block in chapter 4") self.assertTrue(code_block_found, "Should find code block in chapter 4")
def test_epub_nested_content(self): def test_epub_nested_content(self):
"""Test that nested content structures are properly parsed.""" """Test that nested content structures are properly parsed."""
epub_path = self.create_epub_with_nested_content() epub_path = self.create_epub_with_nested_content()
book = read_epub(epub_path) book = read_epub(epub_path)
chapters = list(book.chapters) chapters = list(book.chapters)
self.assertEqual(len(chapters), 1) self.assertEqual(len(chapters), 1)
chapter_blocks = list(chapters[0].blocks) chapter_blocks = list(chapters[0].blocks)
self.assertGreater(len(chapter_blocks), 0) self.assertGreater(len(chapter_blocks), 0)
# Should have multiple headings (h1, h2, h3, h4) # Should have multiple headings (h1, h2, h3, h4)
headings = [block for block in chapter_blocks if isinstance(block, Heading)] 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 # Should have paragraphs and lists from nested content
paragraphs = [block for block in chapter_blocks if isinstance(block, Paragraph)] paragraphs = [block for block in chapter_blocks if isinstance(block, Paragraph)]
lists = [block for block in chapter_blocks if isinstance(block, HList)] 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") self.assertGreater(len(lists), 0, "Should have lists from nested content")
def test_epub_metadata_extraction(self): def test_epub_metadata_extraction(self):
"""Test that EPUB metadata is properly extracted.""" """Test that EPUB metadata is properly extracted."""
epub_path = self.create_complex_epub() epub_path = self.create_complex_epub()
book = read_epub(epub_path) book = read_epub(epub_path)
# Check basic metadata # Check basic metadata
self.assertEqual(book.title, "Complex Test Book") self.assertEqual(book.title, "Complex Test Book")
# Check that metadata was set (implementation may vary) # Check that metadata was set (implementation may vary)
# This tests that the metadata parsing doesn't crash # This tests that the metadata parsing doesn't crash
self.assertIsNotNone(book.title) self.assertIsNotNone(book.title)
def test_epub_reader_class_direct(self): def test_epub_reader_class_direct(self):
"""Test EPUBReader class directly.""" """Test EPUBReader class directly."""
epub_path = self.create_simple_epub() epub_path = self.create_simple_epub()
reader = EPUBReader(epub_path) reader = EPUBReader(epub_path)
book = reader.read() book = reader.read()
self.assertIsInstance(book, Book) self.assertIsInstance(book, Book)
self.assertEqual(book.title, "Test Book") self.assertEqual(book.title, "Test Book")
def test_invalid_epub_handling(self): def test_invalid_epub_handling(self):
"""Test handling of invalid EPUB files.""" """Test handling of invalid EPUB files."""
# Create a non-EPUB file # Create a non-EPUB file
invalid_path = os.path.join(self.test_dir, 'invalid.epub') invalid_path = os.path.join(self.test_dir, 'invalid.epub')
with open(invalid_path, 'w') as f: with open(invalid_path, 'w') as f:
f.write("This is not an EPUB file") f.write("This is not an EPUB file")
# Should raise an exception or handle gracefully # Should raise an exception or handle gracefully
with self.assertRaises(Exception): with self.assertRaises(Exception):
read_epub(invalid_path) read_epub(invalid_path)
def test_nonexistent_epub_handling(self): def test_nonexistent_epub_handling(self):
"""Test handling of nonexistent EPUB files.""" """Test handling of nonexistent EPUB files."""
nonexistent_path = os.path.join(self.test_dir, 'nonexistent.epub') nonexistent_path = os.path.join(self.test_dir, 'nonexistent.epub')
# Should raise an exception # Should raise an exception
with self.assertRaises(Exception): with self.assertRaises(Exception):
read_epub(nonexistent_path) read_epub(nonexistent_path)
def test_epub_with_custom_metadata(self): def test_epub_with_custom_metadata(self):
"""Test EPUB with various metadata fields.""" """Test EPUB with various metadata fields."""
book = epub.EpubBook() book = epub.EpubBook()
# Set comprehensive metadata # Set comprehensive metadata
book.set_identifier('custom-metadata-test') book.set_identifier('custom-metadata-test')
book.set_title('Custom Metadata Test') book.set_title('Custom Metadata Test')
book.set_language('en') book.set_language('en')
book.add_author('Primary Author') book.add_author('Primary Author')
book.add_author('Secondary 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', 'Testing')
book.add_metadata('DC', 'subject', 'EPUB') book.add_metadata('DC', 'subject', 'EPUB')
book.add_metadata('DC', 'date', '2024-06-07') book.add_metadata('DC', 'date', '2024-06-07')
book.add_metadata('DC', 'publisher', 'Test Publishing House') book.add_metadata('DC', 'publisher', 'Test Publishing House')
book.add_metadata('DC', 'rights', 'Public Domain') book.add_metadata('DC', 'rights', 'Public Domain')
# Simple chapter # Simple chapter
chapter = epub.EpubHtml( chapter = epub.EpubHtml(
title='Metadata Test', title='Metadata Test',
@ -574,22 +591,23 @@ class TestEPUBReader(unittest.TestCase):
</body> </body>
</html> </html>
''' '''
book.add_item(chapter) book.add_item(chapter)
book.toc = (epub.Link("metadata.xhtml", "Metadata Test", "meta"),) book.toc = (epub.Link("metadata.xhtml", "Metadata Test", "meta"),)
book.add_item(epub.EpubNcx()) book.add_item(epub.EpubNcx())
book.add_item(epub.EpubNav()) book.add_item(epub.EpubNav())
book.spine = ['nav', chapter] book.spine = ['nav', chapter]
# Write and test # 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, {}) epub.write_epub(epub_path, book, {})
self.epub_files.append(epub_path) self.epub_files.append(epub_path)
# Read and verify # Read and verify
parsed_book = read_epub(epub_path) parsed_book = read_epub(epub_path)
self.assertEqual(parsed_book.title, "Custom Metadata Test") self.assertEqual(parsed_book.title, "Custom Metadata Test")
# Verify chapters were created # Verify chapters were created
chapters = list(parsed_book.chapters) chapters = list(parsed_book.chapters)
self.assertEqual(len(chapters), 1) self.assertEqual(len(chapters), 1)
@ -597,12 +615,12 @@ class TestEPUBReader(unittest.TestCase):
class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase): class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase):
"""Test cases that specifically verify EPUB reader uses html_extraction properly.""" """Test cases that specifically verify EPUB reader uses html_extraction properly."""
def setUp(self): def setUp(self):
"""Set up test environment.""" """Set up test environment."""
self.test_dir = tempfile.mkdtemp() self.test_dir = tempfile.mkdtemp()
self.epub_files = [] self.epub_files = []
def tearDown(self): def tearDown(self):
"""Clean up test environment.""" """Clean up test environment."""
for epub_file in self.epub_files: for epub_file in self.epub_files:
@ -610,10 +628,10 @@ class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase):
os.remove(epub_file) os.remove(epub_file)
except OSError: except OSError:
pass pass
if os.path.exists(self.test_dir): if os.path.exists(self.test_dir):
shutil.rmtree(self.test_dir, ignore_errors=True) shutil.rmtree(self.test_dir, ignore_errors=True)
@unittest.skipUnless(EBOOKLIB_AVAILABLE, "ebooklib not available") @unittest.skipUnless(EBOOKLIB_AVAILABLE, "ebooklib not available")
def test_html_extraction_integration(self): def test_html_extraction_integration(self):
"""Test that EPUB reader properly uses html_extraction functionality.""" """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_title('HTML Extraction Test')
book.set_language('en') book.set_language('en')
book.add_author('Test Author') book.add_author('Test Author')
# Chapter that exercises html_extraction features # Chapter that exercises html_extraction features
chapter = epub.EpubHtml( chapter = epub.EpubHtml(
title='HTML Features', title='HTML Features',
@ -635,22 +653,22 @@ class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase):
<head><title>HTML Features</title></head> <head><title>HTML Features</title></head>
<body> <body>
<h1>HTML Extraction Test</h1> <h1>HTML Extraction Test</h1>
<!-- Test paragraph with inline formatting --> <!-- 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> <u>underlined</u>, and <span style="color: #ff0000; font-weight: bold;">styled</span> text.</p>
<!-- Test headings --> <!-- Test headings -->
<h2>Second Level Heading</h2> <h2>Second Level Heading</h2>
<h3>Third Level Heading</h3> <h3>Third Level Heading</h3>
<!-- Test lists with styled content --> <!-- Test lists with styled content -->
<ul> <ul>
<li>Plain list item</li> <li>Plain list item</li>
<li><strong>Bold list item</strong></li> <li><strong>Bold list item</strong></li>
<li>List item with <em>italic text</em></li> <li>List item with <em>italic text</em></li>
</ul> </ul>
<!-- Test table with styled cells --> <!-- Test table with styled cells -->
<table> <table>
<tr> <tr>
@ -662,19 +680,19 @@ class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase):
<td>Normal text</td> <td>Normal text</td>
</tr> </tr>
</table> </table>
<!-- Test blockquote --> <!-- Test blockquote -->
<blockquote> <blockquote>
<p>This is a quoted paragraph with <strong>bold text</strong>.</p> <p>This is a quoted paragraph with <strong>bold text</strong>.</p>
</blockquote> </blockquote>
<!-- Test code block --> <!-- Test code block -->
<pre><code>def test_function(): <pre><code>def test_function():
return "Hello, World!"</code></pre> return "Hello, World!"</code></pre>
<!-- Test nested formatting --> <!-- Test nested formatting -->
<p>Nested formatting: <strong>bold with <em>italic nested</em> inside</strong>.</p> <p>Nested formatting: <strong>bold with <em>italic nested</em> inside</strong>.</p>
<!-- Test color variations --> <!-- Test color variations -->
<p> <p>
<span style="color: red;">Red text</span>, <span style="color: red;">Red text</span>,
@ -684,26 +702,26 @@ class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase):
</body> </body>
</html> </html>
''' '''
book.add_item(chapter) book.add_item(chapter)
book.toc = (epub.Link("html_features.xhtml", "HTML Features", "html"),) book.toc = (epub.Link("html_features.xhtml", "HTML Features", "html"),)
book.add_item(epub.EpubNcx()) book.add_item(epub.EpubNcx())
book.add_item(epub.EpubNav()) book.add_item(epub.EpubNav())
book.spine = ['nav', chapter] book.spine = ['nav', chapter]
# Write EPUB # Write EPUB
epub_path = os.path.join(self.test_dir, 'html_extraction_test.epub') epub_path = os.path.join(self.test_dir, 'html_extraction_test.epub')
epub.write_epub(epub_path, book, {}) epub.write_epub(epub_path, book, {})
self.epub_files.append(epub_path) self.epub_files.append(epub_path)
# Read and analyze # Read and analyze
parsed_book = read_epub(epub_path) parsed_book = read_epub(epub_path)
chapters = list(parsed_book.chapters) chapters = list(parsed_book.chapters)
self.assertEqual(len(chapters), 1) self.assertEqual(len(chapters), 1)
blocks = list(chapters[0].blocks) blocks = list(chapters[0].blocks)
self.assertGreater(len(blocks), 5) # Should have multiple blocks self.assertGreater(len(blocks), 5) # Should have multiple blocks
# Test that we get the expected block types # Test that we get the expected block types
block_types = [type(block).__name__ for block in blocks] block_types = [type(block).__name__ for block in blocks]
self.assertIn('Heading', block_types, "Should have heading 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('Table', block_types, "Should have table blocks")
self.assertIn('Quote', block_types, "Should have quote blocks") self.assertIn('Quote', block_types, "Should have quote blocks")
self.assertIn('CodeBlock', block_types, "Should have code blocks") self.assertIn('CodeBlock', block_types, "Should have code blocks")
# Test styled content was preserved # Test styled content was preserved
styled_content_found = False styled_content_found = False
for block in blocks: for block in blocks:
if isinstance(block, Paragraph): if isinstance(block, Paragraph):
words = list(block.words_iter()) words = list(block.words_iter())
for _, word in words: 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.style == FontStyle.ITALIC or
word.style.decoration == TextDecoration.UNDERLINE or word.style.decoration == TextDecoration.UNDERLINE or
word.style.colour != (0, 0, 0)): word.style.colour != (0, 0, 0)):
styled_content_found = True styled_content_found = True
break break
if styled_content_found: if styled_content_found:
break 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 # Test specific color parsing
red_text_found = False red_text_found = False
green_text_found = False green_text_found = False
blue_text_found = False blue_text_found = False
for block in blocks: for block in blocks:
if isinstance(block, (Paragraph, Table)): if isinstance(block, (Paragraph, Table)):
if isinstance(block, Paragraph): if isinstance(block, Paragraph):
@ -746,12 +766,11 @@ class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase):
green_text_found = True green_text_found = True
elif word.style.colour == (0, 0, 255): # Blue elif word.style.colour == (0, 0, 255): # Blue
blue_text_found = True blue_text_found = True
# At least one color should be found (depending on implementation) # At least one color should be found (depending on implementation)
color_found = red_text_found or green_text_found or blue_text_found color_found = red_text_found or green_text_found or blue_text_found
self.assertTrue(color_found, "Should find at least one colored text") self.assertTrue(color_found, "Should find at least one colored text")
def test_epub_with_image(self): def test_epub_with_image(self):
"""Test that images in EPUB are properly parsed.""" """Test that images in EPUB are properly parsed."""
book = epub.EpubBook() book = epub.EpubBook()
@ -759,20 +778,31 @@ class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase):
book.set_title('Image Test Book') book.set_title('Image Test Book')
book.set_language('en') book.set_language('en')
book.add_author('Test Author') book.add_author('Test Author')
# Create minimal JPEG data for testing # 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 # Create an EpubImage item
image_item = epub.EpubImage() image_item = epub.EpubImage()
image_item.id = 'test_img' image_item.id = 'test_img'
image_item.file_name = 'images/test_image.jpg' image_item.file_name = 'images/test_image.jpg'
image_item.media_type = 'image/jpeg' image_item.media_type = 'image/jpeg'
image_item.content = img_data image_item.content = img_data
# Add image to book # Add image to book
book.add_item(image_item) book.add_item(image_item)
# Create a chapter that references the image # Create a chapter that references the image
chapter = epub.EpubHtml( chapter = epub.EpubHtml(
title='Image Chapter', title='Image Chapter',
@ -788,36 +818,43 @@ class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase):
<p>Text after the image.</p> <p>Text after the image.</p>
</body> </body>
</html>''' </html>'''
book.add_item(chapter) book.add_item(chapter)
book.toc = (epub.Link("image_chapter.xhtml", "Image Chapter", "img_ch"),) book.toc = (epub.Link("image_chapter.xhtml", "Image Chapter", "img_ch"),)
book.add_item(epub.EpubNcx()) book.add_item(epub.EpubNcx())
book.add_item(epub.EpubNav()) book.add_item(epub.EpubNav())
book.spine = ['nav', chapter] book.spine = ['nav', chapter]
# Write EPUB # 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, {}) epub.write_epub(epub_path, book, {})
self.epub_files.append(epub_path) self.epub_files.append(epub_path)
# Read and analyze # Read and analyze
parsed_book = read_epub(epub_path) parsed_book = read_epub(epub_path)
chapters = list(parsed_book.chapters) chapters = list(parsed_book.chapters)
self.assertEqual(len(chapters), 1) self.assertEqual(len(chapters), 1)
blocks = list(chapters[0].blocks) blocks = list(chapters[0].blocks)
self.assertGreater(len(blocks), 0) self.assertGreater(len(blocks), 0)
# Find blocks by type # Find blocks by type
heading_blocks = [block for block in blocks if isinstance(block, Heading)] heading_blocks = [block for block in blocks if isinstance(block, Heading)]
paragraph_blocks = [block for block in blocks if isinstance(block, Paragraph)] paragraph_blocks = [block for block in blocks if isinstance(block, Paragraph)]
image_blocks = [block for block in blocks if isinstance(block, Image)] image_blocks = [block for block in blocks if isinstance(block, Image)]
# Verify we have the expected blocks # Verify we have the expected blocks
self.assertEqual(len(heading_blocks), 1, "Should find exactly one heading block") self.assertEqual(
self.assertGreaterEqual(len(paragraph_blocks), 2, "Should find at least two paragraph blocks") 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") self.assertEqual(len(image_blocks), 1, "Should find exactly one image block")
# Verify image properties # Verify image properties
image_block = image_blocks[0] image_block = image_blocks[0]
self.assertEqual(image_block.alt_text, "Test image") 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): class TestHTMLParagraph(unittest.TestCase):
"""Test cases for basic paragraph parsing.""" """Test cases for basic paragraph parsing."""
def test_simple(self): def test_simple(self):
text = "<p>This is a paragraph.</p>" text = "<p>This is a paragraph.</p>"
paragraphs = parse_html_string(text) paragraphs = parse_html_string(text)
self.assertEqual(len(paragraphs), 1) self.assertEqual(len(paragraphs), 1)
self.assertEqual(len(paragraphs[0]), 4) 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) self.assertEqual(w1[1].text, t1)
def test_multiple(self): def test_multiple(self):
@ -31,26 +32,28 @@ class TestHTMLParagraph(unittest.TestCase):
self.assertEqual(len(paragraphs[0]), 4) self.assertEqual(len(paragraphs[0]), 4)
self.assertEqual(len(paragraphs[1]), 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) 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) self.assertEqual(w1[1].text, t1)
class TestHTMLStyledParagraphs(unittest.TestCase): class TestHTMLStyledParagraphs(unittest.TestCase):
"""Test cases for paragraphs with inline styling.""" """Test cases for paragraphs with inline styling."""
def test_bold_text(self): def test_bold_text(self):
"""Test paragraphs with bold text using <strong> and <b> tags.""" """Test paragraphs with bold text using <strong> and <b> tags."""
text = "<p>This is <strong>bold text</strong> in a paragraph.</p>" text = "<p>This is <strong>bold text</strong> in a paragraph.</p>"
blocks = parse_html_string(text) blocks = parse_html_string(text)
self.assertEqual(len(blocks), 1) self.assertEqual(len(blocks), 1)
self.assertIsInstance(blocks[0], Paragraph) self.assertIsInstance(blocks[0], Paragraph)
words = list(blocks[0].words_iter()) words = list(blocks[0].words_iter())
self.assertEqual(len(words), 7) # "This is bold text in a paragraph." self.assertEqual(len(words), 7) # "This is bold text in a paragraph."
# Check that 'bold' and 'text' words have bold font weight # Check that 'bold' and 'text' words have bold font weight
bold_word = words[2][1] # 'bold' bold_word = words[2][1] # 'bold'
text_word = words[3][1] # 'text' text_word = words[3][1] # 'text'
@ -58,7 +61,7 @@ class TestHTMLStyledParagraphs(unittest.TestCase):
self.assertEqual(bold_word.style.weight, FontWeight.BOLD) self.assertEqual(bold_word.style.weight, FontWeight.BOLD)
self.assertEqual(text_word.text, "text") self.assertEqual(text_word.text, "text")
self.assertEqual(text_word.style.weight, FontWeight.BOLD) self.assertEqual(text_word.style.weight, FontWeight.BOLD)
# Check that other words are not bold # Check that other words are not bold
normal_word = words[0][1] # 'This' normal_word = words[0][1] # 'This'
self.assertEqual(normal_word.text, "This") self.assertEqual(normal_word.text, "This")
@ -70,9 +73,9 @@ class TestHTMLStyledParagraphs(unittest.TestCase):
blocks = parse_html_string(text) blocks = parse_html_string(text)
self.assertEqual(len(blocks), 1) self.assertEqual(len(blocks), 1)
self.assertIsInstance(blocks[0], Paragraph) self.assertIsInstance(blocks[0], Paragraph)
words = list(blocks[0].words_iter()) words = list(blocks[0].words_iter())
# Check that 'italic' and 'text' words have italic font style # Check that 'italic' and 'text' words have italic font style
italic_word = words[2][1] # 'italic' italic_word = words[2][1] # 'italic'
text_word = words[3][1] # 'text' text_word = words[3][1] # 'text'
@ -86,7 +89,7 @@ class TestHTMLStyledParagraphs(unittest.TestCase):
text = "<p>This is <u>underlined text</u> here.</p>" text = "<p>This is <u>underlined text</u> here.</p>"
blocks = parse_html_string(text) blocks = parse_html_string(text)
self.assertEqual(len(blocks), 1) self.assertEqual(len(blocks), 1)
words = list(blocks[0].words_iter()) words = list(blocks[0].words_iter())
underlined_word = words[2][1] # 'underlined' underlined_word = words[2][1] # 'underlined'
self.assertEqual(underlined_word.style.decoration, TextDecoration.UNDERLINE) 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>" text = "<p>This is <s>strikethrough text</s> here.</p>"
blocks = parse_html_string(text) blocks = parse_html_string(text)
self.assertEqual(len(blocks), 1) self.assertEqual(len(blocks), 1)
words = list(blocks[0].words_iter()) words = list(blocks[0].words_iter())
strike_word = words[2][1] # 'strikethrough' strike_word = words[2][1] # 'strikethrough'
self.assertEqual(strike_word.style.decoration, TextDecoration.STRIKETHROUGH) self.assertEqual(strike_word.style.decoration, TextDecoration.STRIKETHROUGH)
def test_span_with_inline_styles(self): def test_span_with_inline_styles(self):
"""Test paragraphs with span elements containing inline CSS styles.""" """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) blocks = parse_html_string(text)
self.assertEqual(len(blocks), 1) self.assertEqual(len(blocks), 1)
self.assertIsInstance(blocks[0], Paragraph) self.assertIsInstance(blocks[0], Paragraph)
words = list(blocks[0].words_iter()) words = list(blocks[0].words_iter())
# Find the styled words # Find the styled words
styled_words = [] styled_words = []
for _, word in words: for _, word in words:
if word.text in ["this", "part", "is", "red", "and", "bold"]: if word.text in ["this", "part", "is", "red", "and", "bold"]:
if word.style.weight == FontWeight.BOLD: if word.style.weight == FontWeight.BOLD:
styled_words.append(word) 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 # 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)] 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") self.assertGreater(len(red_words), 0, "Should have red colored words")
def test_mixed_formatting(self): def test_mixed_formatting(self):
"""Test paragraphs with multiple formatting elements combined.""" """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) blocks = parse_html_string(text)
self.assertEqual(len(blocks), 1) self.assertEqual(len(blocks), 1)
self.assertIsInstance(blocks[0], Paragraph) self.assertIsInstance(blocks[0], Paragraph)
words = list(blocks[0].words_iter()) words = list(blocks[0].words_iter())
# Check for bold word # Check for bold word
bold_words = [w for _, w in words if w.style.weight == FontWeight.BOLD] bold_words = [w for _, w in words if w.style.weight == FontWeight.BOLD]
self.assertGreater(len(bold_words), 0, "Should have bold words") self.assertGreater(len(bold_words), 0, "Should have bold words")
# Check for italic word # Check for italic word
italic_words = [w for _, w in words if w.style.style == FontStyle.ITALIC] italic_words = [w for _, w in words if w.style.style == FontStyle.ITALIC]
self.assertGreater(len(italic_words), 0, "Should have italic words") self.assertGreater(len(italic_words), 0, "Should have italic words")
# Check for blue colored word # Check for blue colored word
blue_words = [w for _, w in words if w.style.colour == (0, 0, 255)] 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") 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>" text = "<p>This has <strong>bold with <em>italic inside</em></strong> formatting.</p>"
blocks = parse_html_string(text) blocks = parse_html_string(text)
self.assertEqual(len(blocks), 1) self.assertEqual(len(blocks), 1)
words = list(blocks[0].words_iter()) words = list(blocks[0].words_iter())
# Find words that should be both bold and italic # Find words that should be both bold and italic
bold_italic_words = [w for _, w in words bold_italic_words = [w for _, w in words if w.style.weight ==
if w.style.weight == FontWeight.BOLD and w.style.style == FontStyle.ITALIC] FontWeight.BOLD and w.style.style == FontStyle.ITALIC]
self.assertGreater(len(bold_italic_words), 0, "Should have words that are both bold and italic") self.assertGreater(
len(bold_italic_words),
0,
"Should have words that are both bold and italic")
def test_color_variations(self): def test_color_variations(self):
"""Test different color formats in CSS.""" """Test different color formats in CSS."""
text = '<p><span style="color: #ff0000;">Hex red</span> and <span style="color: green;">Named green</span>.</p>' text = '<p><span style="color: #ff0000;">Hex red</span> and <span style="color: green;">Named green</span>.</p>'
blocks = parse_html_string(text) blocks = parse_html_string(text)
self.assertEqual(len(blocks), 1) self.assertEqual(len(blocks), 1)
words = list(blocks[0].words_iter()) words = list(blocks[0].words_iter())
# Check for hex red color # Check for hex red color
hex_red_words = [w for _, w in words if w.style.colour == (255, 0, 0)] 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") self.assertGreater(len(hex_red_words), 0, "Should have hex red colored words")
# Check for named green color # Check for named green color
green_words = [w for _, w in words if w.style.colour == (0, 255, 0)] 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") self.assertGreater(len(green_words), 0, "Should have green colored words")
@ -176,7 +192,7 @@ class TestHTMLStyledParagraphs(unittest.TestCase):
class TestHTMLBlockElements(unittest.TestCase): class TestHTMLBlockElements(unittest.TestCase):
"""Test cases for block-level HTML elements.""" """Test cases for block-level HTML elements."""
def test_body_element(self): def test_body_element(self):
"""Test parsing of body element containing other elements.""" """Test parsing of body element containing other elements."""
text = "<body><p>Paragraph one.</p><p>Paragraph two.</p></body>" text = "<body><p>Paragraph one.</p><p>Paragraph two.</p></body>"
@ -195,17 +211,20 @@ class TestHTMLBlockElements(unittest.TestCase):
def test_headings(self): def test_headings(self):
"""Test all heading levels h1-h6.""" """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) blocks = parse_html_string(text)
self.assertEqual(len(blocks), 6) self.assertEqual(len(blocks), 6)
expected_levels = [HeadingLevel.H1, HeadingLevel.H2, HeadingLevel.H3, expected_levels = [HeadingLevel.H1, HeadingLevel.H2, HeadingLevel.H3,
HeadingLevel.H4, HeadingLevel.H5, HeadingLevel.H6] HeadingLevel.H4, HeadingLevel.H5, HeadingLevel.H6]
for i, block in enumerate(blocks): for i, block in enumerate(blocks):
self.assertIsInstance(block, Heading) self.assertIsInstance(block, Heading)
self.assertEqual(block.level, expected_levels[i]) self.assertEqual(block.level, expected_levels[i])
words = list(block.words_iter()) words = list(block.words_iter())
self.assertEqual(len(words), 2) # "Heading" and number self.assertEqual(len(words), 2) # "Heading" and number
self.assertEqual(words[0][1].text, "Heading") self.assertEqual(words[0][1].text, "Heading")
@ -216,7 +235,7 @@ class TestHTMLBlockElements(unittest.TestCase):
blocks = parse_html_string(text) blocks = parse_html_string(text)
self.assertEqual(len(blocks), 1) self.assertEqual(len(blocks), 1)
self.assertIsInstance(blocks[0], Quote) self.assertIsInstance(blocks[0], Quote)
# Check that the quote contains a paragraph # Check that the quote contains a paragraph
quote_blocks = list(blocks[0].blocks()) quote_blocks = list(blocks[0].blocks())
self.assertEqual(len(quote_blocks), 1) self.assertEqual(len(quote_blocks), 1)
@ -228,7 +247,7 @@ class TestHTMLBlockElements(unittest.TestCase):
blocks = parse_html_string(text) blocks = parse_html_string(text)
self.assertEqual(len(blocks), 1) self.assertEqual(len(blocks), 1)
self.assertIsInstance(blocks[0], CodeBlock) self.assertIsInstance(blocks[0], CodeBlock)
lines = list(blocks[0].lines()) lines = list(blocks[0].lines())
self.assertGreater(len(lines), 0) self.assertGreater(len(lines), 0)
@ -239,7 +258,7 @@ class TestHTMLBlockElements(unittest.TestCase):
self.assertEqual(len(blocks), 1) self.assertEqual(len(blocks), 1)
self.assertIsInstance(blocks[0], HList) self.assertIsInstance(blocks[0], HList)
self.assertEqual(blocks[0].style, ListStyle.UNORDERED) self.assertEqual(blocks[0].style, ListStyle.UNORDERED)
items = list(blocks[0].items()) items = list(blocks[0].items())
self.assertEqual(len(items), 3) self.assertEqual(len(items), 3)
@ -257,10 +276,10 @@ class TestHTMLBlockElements(unittest.TestCase):
blocks = parse_html_string(text) blocks = parse_html_string(text)
self.assertEqual(len(blocks), 1) self.assertEqual(len(blocks), 1)
self.assertIsInstance(blocks[0], HList) self.assertIsInstance(blocks[0], HList)
items = list(blocks[0].items()) items = list(blocks[0].items())
self.assertEqual(len(items), 3) self.assertEqual(len(items), 3)
# Check second item has bold text # Check second item has bold text
second_item_blocks = list(items[1].blocks()) second_item_blocks = list(items[1].blocks())
if second_item_blocks: if second_item_blocks:
@ -306,12 +325,12 @@ class TestHTMLBlockElements(unittest.TestCase):
""" """
blocks = parse_html_string(text) blocks = parse_html_string(text)
self.assertGreater(len(blocks), 2) self.assertGreater(len(blocks), 2)
# Should have at least a heading, paragraph, and quote # Should have at least a heading, paragraph, and quote
has_heading = any(isinstance(b, Heading) for b in blocks) has_heading = any(isinstance(b, Heading) for b in blocks)
has_paragraph = any(isinstance(b, Paragraph) for b in blocks) has_paragraph = any(isinstance(b, Paragraph) for b in blocks)
has_quote = any(isinstance(b, Quote) 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_heading, "Should contain a heading")
self.assertTrue(has_paragraph, "Should contain a paragraph") self.assertTrue(has_paragraph, "Should contain a paragraph")
self.assertTrue(has_quote, "Should contain a quote") self.assertTrue(has_quote, "Should contain a quote")
@ -322,7 +341,7 @@ class TestHTMLBlockElements(unittest.TestCase):
blocks = parse_html_string(text) blocks = parse_html_string(text)
# Empty elements may not create blocks, which is acceptable behavior # Empty elements may not create blocks, which is acceptable behavior
self.assertGreaterEqual(len(blocks), 0) self.assertGreaterEqual(len(blocks), 0)
# Test that empty paragraph with some content does create a block # Test that empty paragraph with some content does create a block
text_with_content = "<p> </p>" # Contains whitespace text_with_content = "<p> </p>" # Contains whitespace
blocks_with_content = parse_html_string(text_with_content) blocks_with_content = parse_html_string(text_with_content)
@ -332,7 +351,7 @@ class TestHTMLBlockElements(unittest.TestCase):
class TestHTMLComplexStructures(unittest.TestCase): class TestHTMLComplexStructures(unittest.TestCase):
"""Test cases for complex HTML structures combining multiple features.""" """Test cases for complex HTML structures combining multiple features."""
def test_article_with_mixed_content(self): def test_article_with_mixed_content(self):
"""Test a realistic article structure with mixed content.""" """Test a realistic article structure with mixed content."""
text = """ text = """
@ -350,7 +369,7 @@ class TestHTMLComplexStructures(unittest.TestCase):
""" """
blocks = parse_html_string(text) blocks = parse_html_string(text)
self.assertGreater(len(blocks), 3) self.assertGreater(len(blocks), 3)
# Verify we have the expected block types # Verify we have the expected block types
block_types = [type(b).__name__ for b in blocks] block_types = [type(b).__name__ for b in blocks]
self.assertIn('Heading', block_types) self.assertIn('Heading', block_types)
@ -383,12 +402,12 @@ class TestHTMLComplexStructures(unittest.TestCase):
class TestHTMLFontRegistryIntegration(unittest.TestCase): class TestHTMLFontRegistryIntegration(unittest.TestCase):
"""Test cases for font registry integration with HTML extraction.""" """Test cases for font registry integration with HTML extraction."""
def setUp(self): def setUp(self):
"""Set up test fixtures.""" """Set up test fixtures."""
self.doc = Document("Test Document", "en-US") self.doc = Document("Test Document", "en-US")
self.base_font = Font(font_size=16, colour=(0, 0, 0)) self.base_font = Font(font_size=16, colour=(0, 0, 0))
def test_font_registry_creates_fonts(self): def test_font_registry_creates_fonts(self):
"""Test that HTML parsing with document context creates fonts in registry.""" """Test that HTML parsing with document context creates fonts in registry."""
html_content = """ html_content = """
@ -397,21 +416,21 @@ class TestHTMLFontRegistryIntegration(unittest.TestCase):
<h1>Main Header</h1> <h1>Main Header</h1>
</div> </div>
""" """
# Initially empty style registry # Initially empty style registry
initial_style_count = self.doc.get_style_registry().get_style_count() initial_style_count = self.doc.get_style_registry().get_style_count()
# Parse HTML with document context # Parse HTML with document context
blocks = parse_html_string(html_content, self.base_font, document=self.doc) blocks = parse_html_string(html_content, self.base_font, document=self.doc)
# Should have created styles for different formatting # Should have created styles for different formatting
final_style_count = self.doc.get_style_registry().get_style_count() final_style_count = self.doc.get_style_registry().get_style_count()
self.assertGreater(final_style_count, initial_style_count, self.assertGreater(final_style_count, initial_style_count,
"Should have created styles in registry") "Should have created styles in registry")
# Should have created blocks # Should have created blocks
self.assertGreater(len(blocks), 0, "Should have created blocks") self.assertGreater(len(blocks), 0, "Should have created blocks")
def test_font_registry_reuses_fonts(self): def test_font_registry_reuses_fonts(self):
"""Test that parsing same content reuses existing styles.""" """Test that parsing same content reuses existing styles."""
html_content = """ html_content = """
@ -420,23 +439,23 @@ class TestHTMLFontRegistryIntegration(unittest.TestCase):
<h1>Main Header</h1> <h1>Main Header</h1>
</div> </div>
""" """
# First parse # First parse
blocks1 = parse_html_string(html_content, self.base_font, document=self.doc) blocks1 = parse_html_string(html_content, self.base_font, document=self.doc)
first_parse_style_count = self.doc.get_style_registry().get_style_count() first_parse_style_count = self.doc.get_style_registry().get_style_count()
# Second parse with same content # Second parse with same content
blocks2 = parse_html_string(html_content, self.base_font, document=self.doc) blocks2 = parse_html_string(html_content, self.base_font, document=self.doc)
second_parse_style_count = self.doc.get_style_registry().get_style_count() second_parse_style_count = self.doc.get_style_registry().get_style_count()
# Style count should not increase on second parse # Style count should not increase on second parse
self.assertEqual(first_parse_style_count, second_parse_style_count, 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 # Both parses should create same number of blocks
self.assertEqual(len(blocks1), len(blocks2), 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): def test_font_registry_different_styles_create_different_fonts(self):
"""Test that different styles create different style objects.""" """Test that different styles create different style objects."""
# Create styles with different properties # Create styles with different properties
@ -449,65 +468,71 @@ class TestHTMLFontRegistryIntegration(unittest.TestCase):
style_id3, style3 = self.doc.get_or_create_style( style_id3, style3 = self.doc.get_or_create_style(
font_size=14, color=(0, 255, 0), font_weight=FontWeight.BOLD font_size=14, color=(0, 255, 0), font_weight=FontWeight.BOLD
) )
# Should be different style IDs # Should be different style IDs
self.assertNotEqual(style_id1, style_id2, "Different sizes should create different styles") self.assertNotEqual(
self.assertNotEqual(style_id1, style_id3, "Different colors should create different styles") 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") self.assertNotEqual(style_id2, style_id3, "All styles should be different")
# Should have multiple styles in registry # Should have multiple styles in registry
self.assertGreaterEqual(self.doc.get_style_registry().get_style_count(), 3) self.assertGreaterEqual(self.doc.get_style_registry().get_style_count(), 3)
def test_font_registry_integration_with_html_styles(self): def test_font_registry_integration_with_html_styles(self):
"""Test that HTML parsing uses style registry for styled content.""" """Test that HTML parsing uses style registry for styled content."""
html_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> <span style="color: red;">red text</span>.</p>
""" """
# Parse content # Parse content
blocks = parse_html_string(html_content, self.base_font, document=self.doc) blocks = parse_html_string(html_content, self.base_font, document=self.doc)
# Extract all words from the paragraph # Extract all words from the paragraph
paragraph = blocks[0] paragraph = blocks[0]
words = list(paragraph.words_iter()) words = list(paragraph.words_iter())
# Find words with different styles # Find words with different styles
normal_words = [w for _, w in words if w.style.weight == FontWeight.NORMAL normal_words = [w for _, w in words if w.style.weight == FontWeight.NORMAL
and w.style.style == FontStyle.NORMAL] and w.style.style == FontStyle.NORMAL]
bold_words = [w for _, w in words if w.style.weight == FontWeight.BOLD] 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] 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)] red_words = [w for _, w in words if w.style.colour == (255, 0, 0)]
# Should have words with different styles # Should have words with different styles
self.assertGreater(len(normal_words), 0, "Should have normal words") self.assertGreater(len(normal_words), 0, "Should have normal words")
self.assertGreater(len(bold_words), 0, "Should have bold words") self.assertGreater(len(bold_words), 0, "Should have bold words")
self.assertGreater(len(italic_words), 0, "Should have italic words") self.assertGreater(len(italic_words), 0, "Should have italic words")
self.assertGreater(len(red_words), 0, "Should have red words") self.assertGreater(len(red_words), 0, "Should have red words")
# Style registry should contain multiple styles for different formatting # Style registry should contain multiple styles for different formatting
self.assertGreater(self.doc.get_style_registry().get_style_count(), 1, self.assertGreater(self.doc.get_style_registry().get_style_count(), 1,
"Should have multiple styles for different formatting") "Should have multiple styles for different formatting")
def test_font_registry_without_document_context(self): def test_font_registry_without_document_context(self):
"""Test that parsing without document context works (fallback behavior).""" """Test that parsing without document context works (fallback behavior)."""
html_content = "<p>This is <strong>bold text</strong>.</p>" html_content = "<p>This is <strong>bold text</strong>.</p>"
# Get initial style count (should include default style) # Get initial style count (should include default style)
initial_style_count = self.doc.get_style_registry().get_style_count() initial_style_count = self.doc.get_style_registry().get_style_count()
# Parse without document context # Parse without document context
blocks = parse_html_string(html_content, self.base_font) blocks = parse_html_string(html_content, self.base_font)
# Should still create blocks successfully # Should still create blocks successfully
self.assertEqual(len(blocks), 1) self.assertEqual(len(blocks), 1)
self.assertIsInstance(blocks[0], Paragraph) self.assertIsInstance(blocks[0], Paragraph)
# Should not affect document's style registry # Should not affect document's style registry
final_style_count = self.doc.get_style_registry().get_style_count() final_style_count = self.doc.get_style_registry().get_style_count()
self.assertEqual(final_style_count, initial_style_count, self.assertEqual(final_style_count, initial_style_count,
"Document style registry should remain unchanged") "Document style registry should remain unchanged")
def test_complex_html_font_reuse(self): def test_complex_html_font_reuse(self):
"""Test style reuse with complex HTML containing repeated styles.""" """Test style reuse with complex HTML containing repeated styles."""
html_content = """ html_content = """
@ -518,46 +543,46 @@ class TestHTMLFontRegistryIntegration(unittest.TestCase):
<p>Another paragraph with <strong>bold</strong> text.</p> <p>Another paragraph with <strong>bold</strong> text.</p>
</div> </div>
""" """
# Parse content # Parse content
blocks = parse_html_string(html_content, self.base_font, document=self.doc) blocks = parse_html_string(html_content, self.base_font, document=self.doc)
style_count_after_parse = self.doc.get_style_registry().get_style_count() style_count_after_parse = self.doc.get_style_registry().get_style_count()
# Parse same content again # Parse same content again
blocks2 = parse_html_string(html_content, self.base_font, document=self.doc) 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_after_second_parse = self.doc.get_style_registry().get_style_count()
# Style count should not increase on second parse # Style count should not increase on second parse
self.assertEqual(style_count_after_parse, style_count_after_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 # Both should create same structure
self.assertEqual(len(blocks), len(blocks2)) self.assertEqual(len(blocks), len(blocks2))
def test_font_registry_with_nested_styles(self): def test_font_registry_with_nested_styles(self):
"""Test style registry with nested HTML styles.""" """Test style registry with nested HTML styles."""
html_content = """ html_content = """
<p>Text with <strong>bold and <em>bold italic</em> nested</strong> styles.</p> <p>Text with <strong>bold and <em>bold italic</em> nested</strong> styles.</p>
""" """
# Parse content # Parse content
blocks = parse_html_string(html_content, self.base_font, document=self.doc) blocks = parse_html_string(html_content, self.base_font, document=self.doc)
# Should create styles for different style combinations # Should create styles for different style combinations
paragraph = blocks[0] paragraph = blocks[0]
words = list(paragraph.words_iter()) words = list(paragraph.words_iter())
# Find words that are both bold and italic # Find words that are both bold and italic
bold_italic_words = [w for _, w in words bold_italic_words = [w for _, w in words
if w.style.weight == FontWeight.BOLD if w.style.weight == FontWeight.BOLD
and w.style.style == FontStyle.ITALIC] and w.style.style == FontStyle.ITALIC]
self.assertGreater(len(bold_italic_words), 0, self.assertGreater(len(bold_italic_words), 0,
"Should have words with combined bold+italic style") "Should have words with combined bold+italic style")
# Should have multiple styles in registry for different combinations # Should have multiple styles in registry for different combinations
self.assertGreater(self.doc.get_style_registry().get_style_count(), 1, 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__': if __name__ == '__main__':

View File

@ -6,7 +6,7 @@ reusing test patterns from test_html_extraction.py that are known to pass.
""" """
import unittest import unittest
from bs4 import BeautifulSoup, Tag from bs4 import BeautifulSoup
from pyWebLayout.io.readers.html_extraction import ( from pyWebLayout.io.readers.html_extraction import (
create_base_context, create_base_context,
apply_element_styling, apply_element_styling,
@ -50,11 +50,11 @@ from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration
class TestUtilityFunctions(unittest.TestCase): class TestUtilityFunctions(unittest.TestCase):
"""Test cases for utility functions.""" """Test cases for utility functions."""
def test_create_base_context(self): def test_create_base_context(self):
"""Test creation of base style context.""" """Test creation of base style context."""
context = create_base_context() context = create_base_context()
self.assertIsInstance(context, StyleContext) self.assertIsInstance(context, StyleContext)
self.assertIsInstance(context.font, Font) self.assertIsInstance(context.font, Font)
self.assertIsNone(context.background) self.assertIsNone(context.background)
@ -62,113 +62,119 @@ class TestUtilityFunctions(unittest.TestCase):
self.assertEqual(context.css_styles, {}) self.assertEqual(context.css_styles, {})
self.assertEqual(context.element_attributes, {}) self.assertEqual(context.element_attributes, {})
self.assertEqual(context.parent_elements, []) self.assertEqual(context.parent_elements, [])
def test_parse_inline_styles_from_existing_tests(self): def test_parse_inline_styles_from_existing_tests(self):
"""Test parsing CSS inline styles - adapted from test_span_with_inline_styles.""" """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>' # From: '<span style="color: red; font-weight: bold;">this part is red and bold</span>'
style_text = "color: red; font-weight: bold;" style_text = "color: red; font-weight: bold;"
styles = parse_inline_styles(style_text) styles = parse_inline_styles(style_text)
expected = { expected = {
"color": "red", "color": "red",
"font-weight": "bold" "font-weight": "bold"
} }
self.assertEqual(styles, expected) self.assertEqual(styles, expected)
def test_parse_inline_styles_color_variations(self): def test_parse_inline_styles_color_variations(self):
"""Test parsing different color formats - adapted from test_color_variations.""" """Test parsing different color formats - adapted from test_color_variations."""
# Test hex color parsing # Test hex color parsing
hex_style = "color: #ff0000;" hex_style = "color: #ff0000;"
styles = parse_inline_styles(hex_style) styles = parse_inline_styles(hex_style)
self.assertEqual(styles.get("color"), "#ff0000") self.assertEqual(styles.get("color"), "#ff0000")
# Test named color parsing # Test named color parsing
named_style = "color: green;" named_style = "color: green;"
styles = parse_inline_styles(named_style) styles = parse_inline_styles(named_style)
self.assertEqual(styles.get("color"), "green") self.assertEqual(styles.get("color"), "green")
def test_apply_element_font_styles_bold_elements(self): def test_apply_element_font_styles_bold_elements(self):
"""Test font style application for bold elements - adapted from test_bold_text.""" """Test font style application for bold elements - adapted from test_bold_text."""
base_font = Font() base_font = Font()
# Test <strong> tag - from "<strong>bold text</strong>" # Test <strong> tag - from "<strong>bold text</strong>"
font = apply_element_font_styles(base_font, "strong", {}) font = apply_element_font_styles(base_font, "strong", {})
self.assertEqual(font.weight, FontWeight.BOLD) self.assertEqual(font.weight, FontWeight.BOLD)
# Test <b> tag # Test <b> tag
font = apply_element_font_styles(base_font, "b", {}) font = apply_element_font_styles(base_font, "b", {})
self.assertEqual(font.weight, FontWeight.BOLD) self.assertEqual(font.weight, FontWeight.BOLD)
def test_apply_element_font_styles_italic_elements(self): def test_apply_element_font_styles_italic_elements(self):
"""Test font style application for italic elements - adapted from test_italic_text.""" """Test font style application for italic elements - adapted from test_italic_text."""
base_font = Font() base_font = Font()
# Test <em> tag - from "<em>italic text</em>" # Test <em> tag - from "<em>italic text</em>"
font = apply_element_font_styles(base_font, "em", {}) font = apply_element_font_styles(base_font, "em", {})
self.assertEqual(font.style, FontStyle.ITALIC) self.assertEqual(font.style, FontStyle.ITALIC)
# Test <i> tag # Test <i> tag
font = apply_element_font_styles(base_font, "i", {}) font = apply_element_font_styles(base_font, "i", {})
self.assertEqual(font.style, FontStyle.ITALIC) self.assertEqual(font.style, FontStyle.ITALIC)
def test_apply_element_font_styles_decoration_elements(self): def test_apply_element_font_styles_decoration_elements(self):
"""Test font decoration - adapted from test_underlined_text and test_strikethrough_text.""" """Test font decoration - adapted from test_underlined_text and test_strikethrough_text."""
base_font = Font() base_font = Font()
# Test <u> tag - from "<u>underlined text</u>" # Test <u> tag - from "<u>underlined text</u>"
font = apply_element_font_styles(base_font, "u", {}) font = apply_element_font_styles(base_font, "u", {})
self.assertEqual(font.decoration, TextDecoration.UNDERLINE) self.assertEqual(font.decoration, TextDecoration.UNDERLINE)
# Test <s> tag - from "<s>strikethrough text</s>" # Test <s> tag - from "<s>strikethrough text</s>"
font = apply_element_font_styles(base_font, "s", {}) font = apply_element_font_styles(base_font, "s", {})
self.assertEqual(font.decoration, TextDecoration.STRIKETHROUGH) self.assertEqual(font.decoration, TextDecoration.STRIKETHROUGH)
# Test <del> tag # Test <del> tag
font = apply_element_font_styles(base_font, "del", {}) font = apply_element_font_styles(base_font, "del", {})
self.assertEqual(font.decoration, TextDecoration.STRIKETHROUGH) self.assertEqual(font.decoration, TextDecoration.STRIKETHROUGH)
def test_apply_element_font_styles_headings(self): def test_apply_element_font_styles_headings(self):
"""Test heading font styles - adapted from test_headings.""" """Test heading font styles - adapted from test_headings."""
base_font = Font() base_font = Font()
# Test heading sizes and weights - from test_headings which tests h1-h6 # 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: for tag, expected_size in headings:
font = apply_element_font_styles(base_font, tag, {}) font = apply_element_font_styles(base_font, tag, {})
self.assertEqual(font.font_size, expected_size, f"Size mismatch for {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): def test_apply_element_font_styles_color_parsing(self):
"""Test color parsing - adapted from test_color_variations.""" """Test color parsing - adapted from test_color_variations."""
base_font = Font() base_font = Font()
# Test named colors - from '<span style="color: green;">Named green</span>' # Test named colors - from '<span style="color: green;">Named green</span>'
css_styles = {"color": "green"} css_styles = {"color": "green"}
font = apply_element_font_styles(base_font, "span", css_styles) font = apply_element_font_styles(base_font, "span", css_styles)
self.assertEqual(font.colour, (0, 255, 0)) self.assertEqual(font.colour, (0, 255, 0))
# Test hex colors - from '<span style="color: #ff0000;">Hex red</span>' # Test hex colors - from '<span style="color: #ff0000;">Hex red</span>'
css_styles = {"color": "#ff0000"} css_styles = {"color": "#ff0000"}
font = apply_element_font_styles(base_font, "span", css_styles) font = apply_element_font_styles(base_font, "span", css_styles)
self.assertEqual(font.colour, (255, 0, 0)) self.assertEqual(font.colour, (255, 0, 0))
def test_apply_element_styling_with_classes_and_styles(self): def test_apply_element_styling_with_classes_and_styles(self):
"""Test complete element styling - adapted from test_span_with_inline_styles.""" """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>' # 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') element = soup.find('span')
base_context = create_base_context() base_context = create_base_context()
styled_context = apply_element_styling(base_context, element) styled_context = apply_element_styling(base_context, element)
# Check CSS classes # Check CSS classes
self.assertIn("highlight", styled_context.css_classes) self.assertIn("highlight", styled_context.css_classes)
# Check CSS styles # Check CSS styles
self.assertEqual(styled_context.css_styles.get("color"), "red") self.assertEqual(styled_context.css_styles.get("color"), "red")
self.assertEqual(styled_context.css_styles.get("font-weight"), "bold") self.assertEqual(styled_context.css_styles.get("font-weight"), "bold")
# Check font styling # Check font styling
self.assertEqual(styled_context.font.colour, (255, 0, 0)) self.assertEqual(styled_context.font.colour, (255, 0, 0))
self.assertEqual(styled_context.font.weight, FontWeight.BOLD) self.assertEqual(styled_context.font.weight, FontWeight.BOLD)
@ -176,170 +182,185 @@ class TestUtilityFunctions(unittest.TestCase):
class TestExtractTextContent(unittest.TestCase): class TestExtractTextContent(unittest.TestCase):
"""Test cases for text content extraction.""" """Test cases for text content extraction."""
def setUp(self): def setUp(self):
"""Set up test fixtures.""" """Set up test fixtures."""
self.base_context = create_base_context() self.base_context = create_base_context()
def test_extract_simple_text(self): def test_extract_simple_text(self):
"""Test extracting simple text - adapted from test_simple.""" """Test extracting simple text - adapted from test_simple."""
# From: "<p>This is a paragraph.</p>" # From: "<p>This is a paragraph.</p>"
soup = BeautifulSoup('<p>This is a paragraph.</p>', 'html.parser') soup = BeautifulSoup('<p>This is a paragraph.</p>', 'html.parser')
element = soup.find('p') element = soup.find('p')
words = extract_text_content(element, self.base_context) words = extract_text_content(element, self.base_context)
# Should match the expected word count from original test # Should match the expected word count from original test
self.assertEqual(len(words), 4) # "This", "is", "a", "paragraph." self.assertEqual(len(words), 4) # "This", "is", "a", "paragraph."
self.assertIsInstance(words[0], Word) self.assertIsInstance(words[0], Word)
self.assertEqual(words[0].text, "This") self.assertEqual(words[0].text, "This")
def test_extract_styled_text_bold(self): def test_extract_styled_text_bold(self):
"""Test extracting bold styled text - adapted from test_bold_text.""" """Test extracting bold styled text - adapted from test_bold_text."""
# From: "<p>This is <strong>bold text</strong> in a paragraph.</p>" # 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') element = soup.find('span')
words = extract_text_content(element, self.base_context) words = extract_text_content(element, self.base_context)
# Find the bold words # Find the bold words
bold_words = [w for w in words if w.style.weight == FontWeight.BOLD] bold_words = [w for w in words if w.style.weight == FontWeight.BOLD]
self.assertGreater(len(bold_words), 0, "Should have bold words") self.assertGreater(len(bold_words), 0, "Should have bold words")
# Check specific words are bold (from original test expectations) # Check specific words are bold (from original test expectations)
bold_word_texts = [w.text for w in bold_words] bold_word_texts = [w.text for w in bold_words]
self.assertIn("bold", bold_word_texts) self.assertIn("bold", bold_word_texts)
self.assertIn("text", bold_word_texts) self.assertIn("text", bold_word_texts)
def test_extract_nested_formatting(self): def test_extract_nested_formatting(self):
"""Test nested formatting - adapted from test_nested_formatting.""" """Test nested formatting - adapted from test_nested_formatting."""
# From: "<p>This has <strong>bold with <em>italic inside</em></strong> formatting.</p>" # 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') element = soup.find('span')
words = extract_text_content(element, self.base_context) words = extract_text_content(element, self.base_context)
# Find words that should be both bold and italic # Find words that should be both bold and italic
bold_italic_words = [w for w in words bold_italic_words = [w for w in words if w.style.weight ==
if w.style.weight == FontWeight.BOLD and w.style.style == FontStyle.ITALIC] FontWeight.BOLD and w.style.style == FontStyle.ITALIC]
self.assertGreater(len(bold_italic_words), 0, "Should have words that are both bold and italic") self.assertGreater(
len(bold_italic_words),
0,
"Should have words that are both bold and italic")
class TestHandlerFunctions(unittest.TestCase): class TestHandlerFunctions(unittest.TestCase):
"""Test cases for HTML element handler functions using known working patterns.""" """Test cases for HTML element handler functions using known working patterns."""
def setUp(self): def setUp(self):
"""Set up test fixtures.""" """Set up test fixtures."""
self.base_context = create_base_context() self.base_context = create_base_context()
def test_paragraph_handler_simple(self): def test_paragraph_handler_simple(self):
"""Test paragraph handler - adapted from test_simple.""" """Test paragraph handler - adapted from test_simple."""
# From: "<p>This is a paragraph.</p>" # From: "<p>This is a paragraph.</p>"
soup = BeautifulSoup('<p>This is a paragraph.</p>', 'html.parser') soup = BeautifulSoup('<p>This is a paragraph.</p>', 'html.parser')
element = soup.find('p') element = soup.find('p')
result = paragraph_handler(element, self.base_context) result = paragraph_handler(element, self.base_context)
self.assertIsInstance(result, Paragraph) self.assertIsInstance(result, Paragraph)
# Should match original test expectations # Should match original test expectations
self.assertEqual(len(result), 4) # 4 words self.assertEqual(len(result), 4) # 4 words
words = list(result.words_iter()) words = list(result.words_iter())
expected_texts = ["This", "is", "a", "paragraph."] expected_texts = ["This", "is", "a", "paragraph."]
for i, expected_text in enumerate(expected_texts): for i, expected_text in enumerate(expected_texts):
self.assertEqual(words[i][1].text, expected_text) self.assertEqual(words[i][1].text, expected_text)
def test_heading_handler_all_levels(self): def test_heading_handler_all_levels(self):
"""Test heading handler - adapted from test_headings.""" """Test heading handler - adapted from test_headings."""
# From: "<h1>Heading 1</h1><h2>Heading 2</h2>..." # From: "<h1>Heading 1</h1><h2>Heading 2</h2>..."
expected_levels = [HeadingLevel.H1, HeadingLevel.H2, HeadingLevel.H3, expected_levels = [HeadingLevel.H1, HeadingLevel.H2, HeadingLevel.H3,
HeadingLevel.H4, HeadingLevel.H5, HeadingLevel.H6] HeadingLevel.H4, HeadingLevel.H5, HeadingLevel.H6]
for i, expected_level in enumerate(expected_levels, 1): for i, expected_level in enumerate(expected_levels, 1):
tag = f"h{i}" tag = f"h{i}"
soup = BeautifulSoup(f'<{tag}>Heading {i}</{tag}>', 'html.parser') soup = BeautifulSoup(f'<{tag}>Heading {i}</{tag}>', 'html.parser')
element = soup.find(tag) element = soup.find(tag)
result = heading_handler(element, self.base_context) result = heading_handler(element, self.base_context)
self.assertIsInstance(result, Heading) self.assertIsInstance(result, Heading)
self.assertEqual(result.level, expected_level) self.assertEqual(result.level, expected_level)
# Should match original test word expectations # Should match original test word expectations
words = list(result.words_iter()) words = list(result.words_iter())
self.assertEqual(len(words), 2) # "Heading" and number self.assertEqual(len(words), 2) # "Heading" and number
self.assertEqual(words[0][1].text, "Heading") self.assertEqual(words[0][1].text, "Heading")
def test_blockquote_handler(self): def test_blockquote_handler(self):
"""Test blockquote handler - adapted from test_blockquote.""" """Test blockquote handler - adapted from test_blockquote."""
# From: "<blockquote><p>This is a quoted paragraph.</p></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') element = soup.find('blockquote')
result = blockquote_handler(element, self.base_context) result = blockquote_handler(element, self.base_context)
self.assertIsInstance(result, Quote) self.assertIsInstance(result, Quote)
# Check that the quote contains a paragraph (from original test) # Check that the quote contains a paragraph (from original test)
quote_blocks = list(result.blocks()) quote_blocks = list(result.blocks())
self.assertEqual(len(quote_blocks), 1) self.assertEqual(len(quote_blocks), 1)
self.assertIsInstance(quote_blocks[0], Paragraph) self.assertIsInstance(quote_blocks[0], Paragraph)
def test_preformatted_handler(self): def test_preformatted_handler(self):
"""Test preformatted handler - adapted from test_preformatted_code.""" """Test preformatted handler - adapted from test_preformatted_code."""
# From: "<pre><code>function hello() {\n console.log('Hello');\n}</code></pre>" # 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') element = soup.find('pre')
result = preformatted_handler(element, self.base_context) result = preformatted_handler(element, self.base_context)
self.assertIsInstance(result, CodeBlock) self.assertIsInstance(result, CodeBlock)
# Should have lines (from original test expectation) # Should have lines (from original test expectation)
lines = list(result.lines()) lines = list(result.lines())
self.assertGreater(len(lines), 0) self.assertGreater(len(lines), 0)
def test_unordered_list_handler(self): def test_unordered_list_handler(self):
"""Test unordered list handler - adapted from test_unordered_list.""" """Test unordered list handler - adapted from test_unordered_list."""
# From: "<ul><li>First item</li><li>Second item</li><li>Third item</li></ul>" # 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') element = soup.find('ul')
result = unordered_list_handler(element, self.base_context) result = unordered_list_handler(element, self.base_context)
self.assertIsInstance(result, HList) self.assertIsInstance(result, HList)
self.assertEqual(result.style, ListStyle.UNORDERED) self.assertEqual(result.style, ListStyle.UNORDERED)
# Should match original test expectations # Should match original test expectations
items = list(result.items()) items = list(result.items())
self.assertEqual(len(items), 3) self.assertEqual(len(items), 3)
def test_ordered_list_handler(self): def test_ordered_list_handler(self):
"""Test ordered list handler - adapted from test_ordered_list.""" """Test ordered list handler - adapted from test_ordered_list."""
# From: "<ol><li>First item</li><li>Second item</li><li>Third item</li></ol>" # 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') element = soup.find('ol')
result = ordered_list_handler(element, self.base_context) result = ordered_list_handler(element, self.base_context)
self.assertIsInstance(result, HList) self.assertIsInstance(result, HList)
self.assertEqual(result.style, ListStyle.ORDERED) self.assertEqual(result.style, ListStyle.ORDERED)
# Should match original test expectations # Should match original test expectations
items = list(result.items()) items = list(result.items())
self.assertEqual(len(items), 3) # "First item", "Second item", "Third item" self.assertEqual(len(items), 3) # "First item", "Second item", "Third item"
def test_list_item_handler(self): def test_list_item_handler(self):
"""Test list item handler.""" """Test list item handler."""
soup = BeautifulSoup('<li>List item content</li>', 'html.parser') soup = BeautifulSoup('<li>List item content</li>', 'html.parser')
element = soup.find('li') element = soup.find('li')
result = list_item_handler(element, self.base_context) result = list_item_handler(element, self.base_context)
self.assertIsInstance(result, ListItem) self.assertIsInstance(result, ListItem)
blocks = list(result.blocks()) blocks = list(result.blocks())
self.assertGreater(len(blocks), 0) self.assertGreater(len(blocks), 0)
def test_table_handler(self): def test_table_handler(self):
"""Test table handler - adapted from test_table_basic.""" """Test table handler - adapted from test_table_basic."""
# From test_table_basic structure # From test_table_basic structure
@ -356,76 +377,80 @@ class TestHandlerFunctions(unittest.TestCase):
</table> </table>
''', 'html.parser') ''', 'html.parser')
element = soup.find('table') element = soup.find('table')
result = table_handler(element, self.base_context) result = table_handler(element, self.base_context)
self.assertIsInstance(result, Table) self.assertIsInstance(result, Table)
def test_table_row_handler(self): def test_table_row_handler(self):
"""Test table row handler.""" """Test table row handler."""
soup = BeautifulSoup('<tr><td>Cell 1</td><td>Cell 2</td></tr>', 'html.parser') soup = BeautifulSoup('<tr><td>Cell 1</td><td>Cell 2</td></tr>', 'html.parser')
element = soup.find('tr') element = soup.find('tr')
result = table_row_handler(element, self.base_context) result = table_row_handler(element, self.base_context)
self.assertIsInstance(result, TableRow) self.assertIsInstance(result, TableRow)
def test_table_cell_handler(self): def test_table_cell_handler(self):
"""Test table cell handler.""" """Test table cell handler."""
soup = BeautifulSoup('<td>Cell content</td>', 'html.parser') soup = BeautifulSoup('<td>Cell content</td>', 'html.parser')
element = soup.find('td') element = soup.find('td')
# Apply styling to get attributes # Apply styling to get attributes
styled_context = apply_element_styling(self.base_context, element) styled_context = apply_element_styling(self.base_context, element)
result = table_cell_handler(element, styled_context) result = table_cell_handler(element, styled_context)
self.assertIsInstance(result, TableCell) self.assertIsInstance(result, TableCell)
self.assertEqual(result.is_header, False) self.assertEqual(result.is_header, False)
def test_table_header_cell_handler(self): def test_table_header_cell_handler(self):
"""Test table header cell handler.""" """Test table header cell handler."""
soup = BeautifulSoup('<th>Header content</th>', 'html.parser') soup = BeautifulSoup('<th>Header content</th>', 'html.parser')
element = soup.find('th') element = soup.find('th')
# Apply styling to get attributes # Apply styling to get attributes
styled_context = apply_element_styling(self.base_context, element) styled_context = apply_element_styling(self.base_context, element)
result = table_header_cell_handler(element, styled_context) result = table_header_cell_handler(element, styled_context)
self.assertIsInstance(result, TableCell) self.assertIsInstance(result, TableCell)
self.assertEqual(result.is_header, True) self.assertEqual(result.is_header, True)
def test_horizontal_rule_handler(self): def test_horizontal_rule_handler(self):
"""Test horizontal rule handler.""" """Test horizontal rule handler."""
soup = BeautifulSoup('<hr>', 'html.parser') soup = BeautifulSoup('<hr>', 'html.parser')
element = soup.find('hr') element = soup.find('hr')
result = horizontal_rule_handler(element, self.base_context) result = horizontal_rule_handler(element, self.base_context)
self.assertIsInstance(result, HorizontalRule) self.assertIsInstance(result, HorizontalRule)
def test_image_handler(self): def test_image_handler(self):
"""Test image handler.""" """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') element = soup.find('img')
# Need to apply styling first to get attributes # Need to apply styling first to get attributes
styled_context = apply_element_styling(self.base_context, element) styled_context = apply_element_styling(self.base_context, element)
result = image_handler(element, styled_context) result = image_handler(element, styled_context)
self.assertIsInstance(result, Image) self.assertIsInstance(result, Image)
self.assertEqual(result.source, "test.jpg") self.assertEqual(result.source, "test.jpg")
self.assertEqual(result.alt_text, "Test image") self.assertEqual(result.alt_text, "Test image")
self.assertEqual(result.width, 100) self.assertEqual(result.width, 100)
self.assertEqual(result.height, 50) self.assertEqual(result.height, 50)
def test_div_handler_container(self): def test_div_handler_container(self):
"""Test div handler - adapted from test_div_container.""" """Test div handler - adapted from test_div_container."""
# From: "<div><p>First paragraph.</p><p>Second paragraph.</p></div>" # 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') element = soup.find('div')
result = div_handler(element, self.base_context) result = div_handler(element, self.base_context)
self.assertIsInstance(result, list) self.assertIsInstance(result, list)
# Should match original test expectations # Should match original test expectations
self.assertEqual(len(result), 2) self.assertEqual(len(result), 2)
@ -435,23 +460,25 @@ class TestHandlerFunctions(unittest.TestCase):
class TestStyledContentHandling(unittest.TestCase): class TestStyledContentHandling(unittest.TestCase):
"""Test styled content handling using patterns from existing tests.""" """Test styled content handling using patterns from existing tests."""
def setUp(self): def setUp(self):
"""Set up test fixtures.""" """Set up test fixtures."""
self.base_context = create_base_context() self.base_context = create_base_context()
def test_paragraph_with_bold_content(self): def test_paragraph_with_bold_content(self):
"""Test paragraph with bold content - adapted from test_bold_text.""" """Test paragraph with bold content - adapted from test_bold_text."""
# From: "<p>This is <strong>bold text</strong> in a paragraph.</p>" # 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') element = soup.find('p')
result = paragraph_handler(element, self.base_context) result = paragraph_handler(element, self.base_context)
self.assertIsInstance(result, Paragraph) self.assertIsInstance(result, Paragraph)
words = list(result.words_iter()) words = list(result.words_iter())
self.assertEqual(len(words), 7) # From original test expectation self.assertEqual(len(words), 7) # From original test expectation
# Check that 'bold' and 'text' words have bold font weight (from original test) # Check that 'bold' and 'text' words have bold font weight (from original test)
bold_word = words[2][1] # 'bold' bold_word = words[2][1] # 'bold'
text_word = words[3][1] # 'text' text_word = words[3][1] # 'text'
@ -459,31 +486,35 @@ class TestStyledContentHandling(unittest.TestCase):
self.assertEqual(bold_word.style.weight, FontWeight.BOLD) self.assertEqual(bold_word.style.weight, FontWeight.BOLD)
self.assertEqual(text_word.text, "text") self.assertEqual(text_word.text, "text")
self.assertEqual(text_word.style.weight, FontWeight.BOLD) self.assertEqual(text_word.style.weight, FontWeight.BOLD)
# Check that other words are not bold (from original test) # Check that other words are not bold (from original test)
normal_word = words[0][1] # 'This' normal_word = words[0][1] # 'This'
self.assertEqual(normal_word.text, "This") self.assertEqual(normal_word.text, "This")
self.assertNotEqual(normal_word.style.weight, FontWeight.BOLD) self.assertNotEqual(normal_word.style.weight, FontWeight.BOLD)
def test_paragraph_with_mixed_formatting(self): def test_paragraph_with_mixed_formatting(self):
"""Test mixed formatting - adapted from test_mixed_formatting.""" """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>..." # From: "<p>This paragraph contains <strong>bold</strong>, <em>italic</em>..."
soup = BeautifulSoup('<p>This paragraph contains <strong>bold</strong>, <em>italic</em>, <span style="color: blue;">blue</span> text.</p>', 'html.parser') 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') element = soup.find('p')
result = paragraph_handler(element, self.base_context) result = paragraph_handler(element, self.base_context)
self.assertIsInstance(result, Paragraph) self.assertIsInstance(result, Paragraph)
words = list(result.words_iter()) words = list(result.words_iter())
# Check for bold word (from original test pattern) # Check for bold word (from original test pattern)
bold_words = [w for _, w in words if w.style.weight == FontWeight.BOLD] bold_words = [w for _, w in words if w.style.weight == FontWeight.BOLD]
self.assertGreater(len(bold_words), 0, "Should have bold words") self.assertGreater(len(bold_words), 0, "Should have bold words")
# Check for italic word (from original test pattern) # Check for italic word (from original test pattern)
italic_words = [w for _, w in words if w.style.style == FontStyle.ITALIC] italic_words = [w for _, w in words if w.style.style == FontStyle.ITALIC]
self.assertGreater(len(italic_words), 0, "Should have italic words") self.assertGreater(len(italic_words), 0, "Should have italic words")
# Check for blue colored word (from original test pattern) # Check for blue colored word (from original test pattern)
blue_words = [w for _, w in words if w.style.colour == (0, 0, 255)] 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") 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): def test_load_html_file(self):
"""Test loading and parsing an HTML file from disk.""" """Test loading and parsing an HTML file from disk."""
# Path to the test HTML file # 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 # 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 # Read the HTML file
with open(html_file_path, 'r', encoding='utf-8') as file: with open(html_file_path, 'r', encoding='utf-8') as file:
html_content = file.read() html_content = file.read()
# Verify we got some content # Verify we got some content
self.assertGreater(len(html_content), 0, "HTML file should not be empty") self.assertGreater(len(html_content), 0, "HTML file should not be empty")
# Parse the HTML content using the html_extraction module # Parse the HTML content using the html_extraction module
try: try:
blocks = parse_html_string(html_content) blocks = parse_html_string(html_content)
except Exception as e: except Exception as e:
self.fail(f"Failed to parse HTML file: {e}") self.fail(f"Failed to parse HTML file: {e}")
# Verify we got some blocks # Verify we got some blocks
self.assertIsInstance(blocks, list, "parse_html_string should return a list") 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 # Verify all returned items are Block instances
for i, block in enumerate(blocks): 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") print(f"Successfully loaded and parsed HTML file with {len(blocks)} blocks")
def test_load_html_file_with_custom_font(self): def test_load_html_file_with_custom_font(self):
"""Test loading HTML file with a custom base font.""" """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 # Skip if file doesn't exist
if not os.path.exists(html_file_path): if not os.path.exists(html_file_path):
self.skipTest(f"Test HTML file not found: {html_file_path}") self.skipTest(f"Test HTML file not found: {html_file_path}")
# Create a custom font # Create a custom font
custom_font = Font(font_size=14, colour=(100, 100, 100)) custom_font = Font(font_size=14, colour=(100, 100, 100))
# Read and parse with custom font # Read and parse with custom font
with open(html_file_path, 'r', encoding='utf-8') as file: with open(html_file_path, 'r', encoding='utf-8') as file:
html_content = file.read() html_content = file.read()
blocks = parse_html_string(html_content, base_font=custom_font) blocks = parse_html_string(html_content, base_font=custom_font)
# Verify we got blocks # Verify we got blocks
self.assertGreater(len(blocks), 0, "Should extract blocks with custom font") 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): def test_load_html_file_content_types(self):
"""Test that the loaded HTML file contains expected content types.""" """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 # Skip if file doesn't exist
if not os.path.exists(html_file_path): if not os.path.exists(html_file_path):
self.skipTest(f"Test HTML file not found: {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: with open(html_file_path, 'r', encoding='utf-8') as file:
html_content = file.read() html_content = file.read()
blocks = parse_html_string(html_content) blocks = parse_html_string(html_content)
# Check that we have different types of blocks # Check that we have different types of blocks
block_type_names = [type(block).__name__ for block in blocks] block_type_names = [type(block).__name__ for block in blocks]
unique_types = set(block_type_names) unique_types = set(block_type_names)
# A Wikipedia page should contain multiple types of content # 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)}") print(f"Found block types: {sorted(unique_types)}")
def test_html_file_size_handling(self): def test_html_file_size_handling(self):
"""Test that large HTML files can be handled gracefully.""" """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 # Skip if file doesn't exist
if not os.path.exists(html_file_path): if not os.path.exists(html_file_path):
self.skipTest(f"Test HTML file not found: {html_file_path}") self.skipTest(f"Test HTML file not found: {html_file_path}")
# Get file size # Get file size
file_size = os.path.getsize(html_file_path) file_size = os.path.getsize(html_file_path)
print(f"HTML file size: {file_size} bytes") print(f"HTML file size: {file_size} bytes")
# Read and parse # Read and parse
with open(html_file_path, 'r', encoding='utf-8') as file: with open(html_file_path, 'r', encoding='utf-8') as file:
html_content = file.read() html_content = file.read()
# This should not raise an exception even for large files # This should not raise an exception even for large files
blocks = parse_html_string(html_content) blocks = parse_html_string(html_content)
# Basic verification # Basic verification
self.assertIsInstance(blocks, list) self.assertIsInstance(blocks, list)
print(f"Successfully processed {file_size} byte file into {len(blocks)} blocks") 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() rendered = page.render()
self.assertIsNotNone(rendered) self.assertIsNotNone(rendered)
print(f"\nSettings overlay test:") print("\nSettings overlay test:")
print(f" Found {len(all_linked_words)} linked words") print(f" Found {len(all_linked_words)} linked words")
print(f" Actions: {actions}") print(f" Actions: {actions}")
print(f" Rendered: {rendered.size}") print(f" Rendered: {rendered.size}")
# The links are successfully created and rendered! # 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): def test_link_metadata_preserved(self):
"""Test that link metadata (title, type) is preserved through the workflow.""" """Test that link metadata (title, type) is preserved through the workflow."""

View File

@ -155,8 +155,11 @@ class TestHTMLLinkInteractivity(unittest.TestCase):
html = ''' html = '''
<div> <div>
<h2 style="text-align: center; font-size: 18px; font-weight: bold; margin: 10px 0;">Settings</h2> <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;"> <p style="padding: 15px; margin: 5px 0; background-color: #dc3545; text-align: center;
<a href="action:back_to_library" style="text-decoration: none; color: white; font-weight: bold; font-size: 14px;"> Back to Library</a> 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>
<p style="padding: 10px; margin: 5px 0; background-color: #f8f9fa; border-radius: 5px;"> <p style="padding: 10px; margin: 5px 0; background-color: #f8f9fa; border-radius: 5px;">
<span style="font-weight: bold;">Font Size: 100%</span><br> <span style="font-weight: bold;">Font Size: 100%</span><br>
@ -177,23 +180,26 @@ class TestHTMLLinkInteractivity(unittest.TestCase):
all_linked_words.append(word) all_linked_words.append(word)
# Verify we found the expected links # 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 # Check for specific link targets
link_targets = {word.location for word in all_linked_words} link_targets = {word.location for word in all_linked_words}
self.assertIn("action:back_to_library", link_targets, 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, self.assertIn("setting:font_decrease", link_targets,
"Should find font decrease link") "Should find font decrease link")
self.assertIn("setting:font_increase", link_targets, self.assertIn("setting:font_increase", link_targets,
"Should find font increase link") "Should find font increase link")
# Verify the link texts # Verify the link texts
back_to_library_words = [w for w in all_linked_words 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, 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 debug info
print(f"\nFound {len(all_linked_words)} linked words:") 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 ( from pyWebLayout.io.readers.html_extraction import (
parse_html_string, parse_html_string,
extract_text_content, extract_text_content,
create_base_context, create_base_context
apply_element_styling
) )
from pyWebLayout.abstract.inline import LinkedWord from pyWebLayout.abstract.inline import LinkedWord
from pyWebLayout.abstract.functional import LinkType from pyWebLayout.abstract.functional import LinkType
from pyWebLayout.abstract.block import Paragraph from pyWebLayout.abstract.block import Paragraph
from pyWebLayout.style import Font
class TestHTMLLinkExtraction(unittest.TestCase): class TestHTMLLinkExtraction(unittest.TestCase):
"""Test cases for HTML hyperlink extraction.""" """Test cases for HTML hyperlink extraction."""
def setUp(self): def setUp(self):
"""Set up test fixtures.""" """Set up test fixtures."""
self.base_context = create_base_context() self.base_context = create_base_context()
def test_simple_external_link(self): def test_simple_external_link(self):
"""Test extracting a simple external link.""" """Test extracting a simple external link."""
html = '<p>Visit <a href="https://example.com">this site</a> for more.</p>' html = '<p>Visit <a href="https://example.com">this site</a> for more.</p>'
blocks = parse_html_string(html) blocks = parse_html_string(html)
self.assertEqual(len(blocks), 1) self.assertEqual(len(blocks), 1)
self.assertIsInstance(blocks[0], Paragraph) self.assertIsInstance(blocks[0], Paragraph)
paragraph = blocks[0] paragraph = blocks[0]
words = list(paragraph.words) words = list(paragraph.words)
# Should have: "Visit", "this", "site", "for", "more." # Should have: "Visit", "this", "site", "for", "more."
self.assertEqual(len(words), 5) self.assertEqual(len(words), 5)
# Check that "this" and "site" are LinkedWords # Check that "this" and "site" are LinkedWords
self.assertIsInstance(words[1], LinkedWord) self.assertIsInstance(words[1], LinkedWord)
self.assertIsInstance(words[2], LinkedWord) self.assertIsInstance(words[2], LinkedWord)
# Check link properties # Check link properties
self.assertEqual(words[1].location, "https://example.com") self.assertEqual(words[1].location, "https://example.com")
self.assertEqual(words[1].link_type, LinkType.EXTERNAL) self.assertEqual(words[1].link_type, LinkType.EXTERNAL)
self.assertEqual(words[2].location, "https://example.com") self.assertEqual(words[2].location, "https://example.com")
self.assertEqual(words[2].link_type, LinkType.EXTERNAL) self.assertEqual(words[2].link_type, LinkType.EXTERNAL)
def test_internal_link(self): def test_internal_link(self):
"""Test extracting an internal anchor link.""" """Test extracting an internal anchor link."""
html = '<p>Go to <a href="#section2">section 2</a> below.</p>' html = '<p>Go to <a href="#section2">section 2</a> below.</p>'
blocks = parse_html_string(html) blocks = parse_html_string(html)
paragraph = blocks[0] paragraph = blocks[0]
words = list(paragraph.words) words = list(paragraph.words)
# Find LinkedWords # Find LinkedWords
linked_words = [w for w in words if isinstance(w, LinkedWord)] linked_words = [w for w in words if isinstance(w, LinkedWord)]
self.assertEqual(len(linked_words), 2) # "section" and "2" self.assertEqual(len(linked_words), 2) # "section" and "2"
# Check they're internal links # Check they're internal links
for word in linked_words: for word in linked_words:
self.assertEqual(word.link_type, LinkType.INTERNAL) self.assertEqual(word.link_type, LinkType.INTERNAL)
self.assertEqual(word.location, "#section2") self.assertEqual(word.location, "#section2")
def test_multi_word_link(self): def test_multi_word_link(self):
"""Test that multi-word links create separate LinkedWords.""" """Test that multi-word links create separate LinkedWords."""
html = '<p><a href="/next">click here for next page</a></p>' html = '<p><a href="/next">click here for next page</a></p>'
blocks = parse_html_string(html) blocks = parse_html_string(html)
paragraph = blocks[0] paragraph = blocks[0]
words = list(paragraph.words) words = list(paragraph.words)
# All words should be LinkedWords # All words should be LinkedWords
self.assertEqual(len(words), 5) self.assertEqual(len(words), 5)
for word in words: for word in words:
self.assertIsInstance(word, LinkedWord) self.assertIsInstance(word, LinkedWord)
self.assertEqual(word.location, "/next") self.assertEqual(word.location, "/next")
self.assertEqual(word.link_type, LinkType.INTERNAL) self.assertEqual(word.link_type, LinkType.INTERNAL)
def test_link_with_title(self): def test_link_with_title(self):
"""Test extracting link with title attribute.""" """Test extracting link with title attribute."""
html = '<p><a href="https://example.com" title="Visit Example">click</a></p>' html = '<p><a href="https://example.com" title="Visit Example">click</a></p>'
blocks = parse_html_string(html) blocks = parse_html_string(html)
paragraph = blocks[0] paragraph = blocks[0]
words = list(paragraph.words) words = list(paragraph.words)
self.assertEqual(len(words), 1) self.assertEqual(len(words), 1)
self.assertIsInstance(words[0], LinkedWord) self.assertIsInstance(words[0], LinkedWord)
self.assertEqual(words[0].link_title, "Visit Example") self.assertEqual(words[0].link_title, "Visit Example")
def test_mixed_linked_and_normal_text(self): def test_mixed_linked_and_normal_text(self):
"""Test paragraph with both linked and normal text.""" """Test paragraph with both linked and normal text."""
html = '<p>Some <a href="/page">linked text</a> and normal text.</p>' html = '<p>Some <a href="/page">linked text</a> and normal text.</p>'
blocks = parse_html_string(html) blocks = parse_html_string(html)
paragraph = blocks[0] paragraph = blocks[0]
words = list(paragraph.words) words = list(paragraph.words)
# "Some" - normal # "Some" - normal
# "linked" - LinkedWord # "linked" - LinkedWord
# "text" - LinkedWord # "text" - LinkedWord
# "and" - normal # "and" - normal
# "normal" - normal # "normal" - normal
# "text." - normal # "text." - normal
self.assertNotIsInstance(words[0], LinkedWord) # "Some" self.assertNotIsInstance(words[0], LinkedWord) # "Some"
self.assertIsInstance(words[1], LinkedWord) # "linked" self.assertIsInstance(words[1], LinkedWord) # "linked"
self.assertIsInstance(words[2], LinkedWord) # "text" self.assertIsInstance(words[2], LinkedWord) # "text"
self.assertNotIsInstance(words[3], LinkedWord) # "and" self.assertNotIsInstance(words[3], LinkedWord) # "and"
def test_link_without_href(self): def test_link_without_href(self):
"""Test that <a> without href is treated as normal text.""" """Test that <a> without href is treated as normal text."""
html = '<p><a>not a link</a></p>' html = '<p><a>not a link</a></p>'
blocks = parse_html_string(html) blocks = parse_html_string(html)
paragraph = blocks[0] paragraph = blocks[0]
words = list(paragraph.words) words = list(paragraph.words)
# Should be regular Words, not LinkedWords # Should be regular Words, not LinkedWords
for word in words: for word in words:
self.assertNotIsInstance(word, LinkedWord) self.assertNotIsInstance(word, LinkedWord)
def test_javascript_link(self): def test_javascript_link(self):
"""Test that javascript: links are detected as API type.""" """Test that javascript: links are detected as API type."""
html = '<p><a href="javascript:alert()">click</a></p>' html = '<p><a href="javascript:alert()">click</a></p>'
blocks = parse_html_string(html) blocks = parse_html_string(html)
paragraph = blocks[0] paragraph = blocks[0]
words = list(paragraph.words) words = list(paragraph.words)
self.assertIsInstance(words[0], LinkedWord) self.assertIsInstance(words[0], LinkedWord)
self.assertEqual(words[0].link_type, LinkType.API) self.assertEqual(words[0].link_type, LinkType.API)
def test_nested_formatting_in_link(self): def test_nested_formatting_in_link(self):
"""Test link with nested formatting.""" """Test link with nested formatting."""
html = '<p><a href="/page">text with <strong>bold</strong> word</a></p>' html = '<p><a href="/page">text with <strong>bold</strong> word</a></p>'
blocks = parse_html_string(html) blocks = parse_html_string(html)
paragraph = blocks[0] paragraph = blocks[0]
words = list(paragraph.words) words = list(paragraph.words)
# All should be LinkedWords regardless of formatting # All should be LinkedWords regardless of formatting
for word in words: for word in words:
self.assertIsInstance(word, LinkedWord) self.assertIsInstance(word, LinkedWord)
self.assertEqual(word.location, "/page") self.assertEqual(word.location, "/page")
def test_multiple_links_in_paragraph(self): def test_multiple_links_in_paragraph(self):
"""Test paragraph with multiple separate links.""" """Test paragraph with multiple separate links."""
html = '<p><a href="/page1">first</a> and <a href="/page2">second</a> link</p>' html = '<p><a href="/page1">first</a> and <a href="/page2">second</a> link</p>'
blocks = parse_html_string(html) blocks = parse_html_string(html)
paragraph = blocks[0] paragraph = blocks[0]
words = list(paragraph.words) words = list(paragraph.words)
# Find LinkedWords and their locations # 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 # Should have "first" linked to /page1 and "second" linked to /page2
self.assertIn(("first", "/page1"), linked_words) self.assertIn(("first", "/page1"), linked_words)
self.assertIn(("second", "/page2"), linked_words) self.assertIn(("second", "/page2"), linked_words)
def test_extract_text_content_with_links(self): def test_extract_text_content_with_links(self):
"""Test extract_text_content directly with link elements.""" """Test extract_text_content directly with link elements."""
html = '<span>Visit <a href="https://example.com">our site</a> today</span>' html = '<span>Visit <a href="https://example.com">our site</a> today</span>'
soup = BeautifulSoup(html, 'html.parser') soup = BeautifulSoup(html, 'html.parser')
element = soup.find('span') element = soup.find('span')
context = create_base_context() context = create_base_context()
words = extract_text_content(element, context) words = extract_text_content(element, context)
# Should have: "Visit", "our", "site", "today" # Should have: "Visit", "our", "site", "today"
self.assertEqual(len(words), 4) self.assertEqual(len(words), 4)
# Check types # Check types
self.assertNotIsInstance(words[0], LinkedWord) # "Visit" self.assertNotIsInstance(words[0], LinkedWord) # "Visit"
self.assertIsInstance(words[1], LinkedWord) # "our" self.assertIsInstance(words[1], LinkedWord) # "our"

View File

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

View File

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

View File

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

View File

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

View File

@ -27,8 +27,12 @@ class FontRegistryTestMixin:
obj = self.create_test_object() obj = self.create_test_object()
# Create font twice with same properties # Create font twice with same properties
font1 = obj.get_or_create_font(font_size=14, colour=(255, 0, 0), weight=FontWeight.BOLD) font1 = obj.get_or_create_font(
font2 = obj.get_or_create_font(font_size=14, colour=(255, 0, 0), weight=FontWeight.BOLD) 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) # Should return the same font object (cached)
self.assertIs(font1, font2, "Fonts with identical properties should be 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 font2 = obj.get_or_create_font(font_size=16, **base_params) # Different size
base_params2 = {'font_size': 18, 'weight': FontWeight.NORMAL} 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)} 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 # All should be different objects
self.assertIsNot(font1, font2, "Fonts with different sizes should be distinct") 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, 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, font3, "Fonts should be distinct")
self.assertIsNot(font2, font4, "Fonts should be distinct") self.assertIsNot(font2, font4, "Fonts should be distinct")
self.assertIsNot(font3, font4, "Fonts should be distinct") self.assertIsNot(font3, font4, "Fonts should be distinct")

View File

@ -53,7 +53,10 @@ class MetadataContainerTestMixin:
# Update value # Update value
obj.set_metadata("key", "updated") 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): def test_metadata_isolation(self):
"""Test that metadata is isolated between instances.""" """Test that metadata is isolated between instances."""

View File

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

View File

@ -8,12 +8,12 @@ based on user preferences.
import pytest import pytest
from pyWebLayout.style.abstract_style import ( from pyWebLayout.style.abstract_style import (
AbstractStyle, AbstractStyleRegistry, FontFamily, FontSize, TextAlign AbstractStyle, AbstractStyleRegistry, FontFamily, FontSize
) )
from pyWebLayout.style.concrete_style import ( 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(): def test_abstract_style_is_hashable():
@ -25,18 +25,18 @@ def test_abstract_style_is_hashable():
font_weight=FontWeight.BOLD, font_weight=FontWeight.BOLD,
color="red" color="red"
) )
style2 = AbstractStyle( style2 = AbstractStyle(
font_family=FontFamily.SERIF, font_family=FontFamily.SERIF,
font_size=16, font_size=16,
font_weight=FontWeight.BOLD, font_weight=FontWeight.BOLD,
color="red" color="red"
) )
# They should be equal and have the same hash # They should be equal and have the same hash
assert style1 == style2 assert style1 == style2
assert hash(style1) == hash(style2) assert hash(style1) == hash(style2)
# They should work as dictionary keys # They should work as dictionary keys
style_dict = {style1: "first", style2: "second"} style_dict = {style1: "first", style2: "second"}
assert len(style_dict) == 1 # Should be deduplicated assert len(style_dict) == 1 # Should be deduplicated
@ -46,15 +46,15 @@ def test_abstract_style_is_hashable():
def test_abstract_style_registry_deduplication(): def test_abstract_style_registry_deduplication():
"""Test that the registry prevents duplicate styles.""" """Test that the registry prevents duplicate styles."""
registry = AbstractStyleRegistry() registry = AbstractStyleRegistry()
# Create the same style twice # Create the same style twice
style1 = AbstractStyle(font_size=18, font_weight=FontWeight.BOLD) style1 = AbstractStyle(font_size=18, font_weight=FontWeight.BOLD)
style2 = AbstractStyle(font_size=18, font_weight=FontWeight.BOLD) style2 = AbstractStyle(font_size=18, font_weight=FontWeight.BOLD)
# Register both - should get same ID # Register both - should get same ID
id1, _ = registry.get_or_create_style(style1) id1, _ = registry.get_or_create_style(style1)
id2, _ = registry.get_or_create_style(style2) id2, _ = registry.get_or_create_style(style2)
assert id1 == id2 # Same style should get same ID assert id1 == id2 # Same style should get same ID
assert registry.get_style_count() == 2 # Only default + our style assert registry.get_style_count() == 2 # Only default + our style
@ -62,21 +62,21 @@ def test_abstract_style_registry_deduplication():
def test_style_inheritance(): def test_style_inheritance():
"""Test that style inheritance works properly.""" """Test that style inheritance works properly."""
registry = AbstractStyleRegistry() registry = AbstractStyleRegistry()
# Create base style # Create base style
base_style = AbstractStyle(font_size=16, color="black") base_style = AbstractStyle(font_size=16, color="black")
base_id, _ = registry.get_or_create_style(base_style) base_id, _ = registry.get_or_create_style(base_style)
# Create derived style # Create derived style
derived_id, derived_style = registry.create_derived_style( derived_id, derived_style = registry.create_derived_style(
base_id, base_id,
font_weight=FontWeight.BOLD, font_weight=FontWeight.BOLD,
color="red" color="red"
) )
# Resolve effective style # Resolve effective style
effective = registry.resolve_effective_style(derived_id) effective = registry.resolve_effective_style(derived_id)
assert effective.font_size == 16 # Inherited from base assert effective.font_size == 16 # Inherited from base
assert effective.font_weight == FontWeight.BOLD # Overridden assert effective.font_weight == FontWeight.BOLD # Overridden
assert effective.color == "red" # Overridden assert effective.color == "red" # Overridden
@ -90,16 +90,17 @@ def test_style_resolver_user_preferences():
font_scale_factor=1.5, # Additional scaling font_scale_factor=1.5, # Additional scaling
large_text=True # Accessibility preference large_text=True # Accessibility preference
) )
resolver = StyleResolver(context) resolver = StyleResolver(context)
# Create abstract style with medium size # Create abstract style with medium size
abstract_style = AbstractStyle(font_size=FontSize.MEDIUM) abstract_style = AbstractStyle(font_size=FontSize.MEDIUM)
# Resolve to concrete style # Resolve to concrete style
concrete_style = resolver.resolve_style(abstract_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) expected_size = int(20 * 1.0 * 1.5 * 1.2)
assert concrete_style.font_size == expected_size assert concrete_style.font_size == expected_size
@ -108,17 +109,17 @@ def test_style_resolver_color_resolution():
"""Test color name resolution.""" """Test color name resolution."""
context = RenderingContext() context = RenderingContext()
resolver = StyleResolver(context) resolver = StyleResolver(context)
# Test named colors # Test named colors
red_style = AbstractStyle(color="red") red_style = AbstractStyle(color="red")
concrete_red = resolver.resolve_style(red_style) concrete_red = resolver.resolve_style(red_style)
assert concrete_red.color == (255, 0, 0) assert concrete_red.color == (255, 0, 0)
# Test hex colors # Test hex colors
hex_style = AbstractStyle(color="#ff0000") hex_style = AbstractStyle(color="#ff0000")
concrete_hex = resolver.resolve_style(hex_style) concrete_hex = resolver.resolve_style(hex_style)
assert concrete_hex.color == (255, 0, 0) assert concrete_hex.color == (255, 0, 0)
# Test RGB tuple (should pass through) # Test RGB tuple (should pass through)
rgb_style = AbstractStyle(color=(128, 64, 192)) rgb_style = AbstractStyle(color=(128, 64, 192))
concrete_rgb = resolver.resolve_style(rgb_style) concrete_rgb = resolver.resolve_style(rgb_style)
@ -129,17 +130,17 @@ def test_concrete_style_caching():
"""Test that concrete styles are cached efficiently.""" """Test that concrete styles are cached efficiently."""
context = RenderingContext() context = RenderingContext()
registry = ConcreteStyleRegistry(StyleResolver(context)) registry = ConcreteStyleRegistry(StyleResolver(context))
# Create abstract style # Create abstract style
abstract_style = AbstractStyle(font_size=16, color="blue") abstract_style = AbstractStyle(font_size=16, color="blue")
# Get font twice - should be cached # Get font twice - should be cached
font1 = registry.get_font(abstract_style) font1 = registry.get_font(abstract_style)
font2 = registry.get_font(abstract_style) font2 = registry.get_font(abstract_style)
# Should be the same object (cached) # Should be the same object (cached)
assert font1 is font2 assert font1 is font2
# Check cache stats # Check cache stats
stats = registry.get_cache_stats() stats = registry.get_cache_stats()
assert stats["concrete_styles"] == 1 assert stats["concrete_styles"] == 1
@ -151,17 +152,17 @@ def test_global_font_scaling():
# Create two contexts with different scaling # Create two contexts with different scaling
context_normal = RenderingContext(font_scale_factor=1.0) context_normal = RenderingContext(font_scale_factor=1.0)
context_large = RenderingContext(font_scale_factor=2.0) context_large = RenderingContext(font_scale_factor=2.0)
resolver_normal = StyleResolver(context_normal) resolver_normal = StyleResolver(context_normal)
resolver_large = StyleResolver(context_large) resolver_large = StyleResolver(context_large)
# Same abstract style # Same abstract style
abstract_style = AbstractStyle(font_size=16) abstract_style = AbstractStyle(font_size=16)
# Resolve with different contexts # Resolve with different contexts
concrete_normal = resolver_normal.resolve_style(abstract_style) concrete_normal = resolver_normal.resolve_style(abstract_style)
concrete_large = resolver_large.resolve_style(abstract_style) concrete_large = resolver_large.resolve_style(abstract_style)
# Large should be 2x the size # Large should be 2x the size
assert concrete_large.font_size == concrete_normal.font_size * 2 assert concrete_large.font_size == concrete_normal.font_size * 2
@ -169,7 +170,7 @@ def test_global_font_scaling():
def test_memory_efficiency(): def test_memory_efficiency():
"""Test that the new system is more memory efficient.""" """Test that the new system is more memory efficient."""
registry = AbstractStyleRegistry() registry = AbstractStyleRegistry()
# Create many "different" styles that are actually the same # Create many "different" styles that are actually the same
styles = [] styles = []
for i in range(100): for i in range(100):
@ -181,26 +182,26 @@ def test_memory_efficiency():
) )
style_id, _ = registry.get_or_create_style(style) style_id, _ = registry.get_or_create_style(style)
styles.append(style_id) styles.append(style_id)
# All should reference the same style # All should reference the same style
assert len(set(styles)) == 1 # All IDs are the same assert len(set(styles)) == 1 # All IDs are the same
assert registry.get_style_count() == 2 # Only default + our style assert registry.get_style_count() == 2 # Only default + our style
# This demonstrates that we don't create duplicate styles # This demonstrates that we don't create duplicate styles
def test_word_style_reference_concept(): def test_word_style_reference_concept():
"""Demonstrate how words would reference styles instead of storing fonts.""" """Demonstrate how words would reference styles instead of storing fonts."""
registry = AbstractStyleRegistry() registry = AbstractStyleRegistry()
# Create paragraph style # Create paragraph style
para_style = AbstractStyle(font_size=16, color="black") para_style = AbstractStyle(font_size=16, color="black")
para_id, _ = registry.get_or_create_style(para_style) para_id, _ = registry.get_or_create_style(para_style)
# Create bold word style # Create bold word style
bold_style = AbstractStyle(font_size=16, color="black", font_weight=FontWeight.BOLD) bold_style = AbstractStyle(font_size=16, color="black", font_weight=FontWeight.BOLD)
bold_id, _ = registry.get_or_create_style(bold_style) bold_id, _ = registry.get_or_create_style(bold_style)
# Simulate words storing style IDs instead of full Font objects # Simulate words storing style IDs instead of full Font objects
words_data = [ words_data = [
{"text": "This", "style_id": para_id}, {"text": "This", "style_id": para_id},
@ -208,19 +209,19 @@ def test_word_style_reference_concept():
{"text": "bold", "style_id": bold_id}, {"text": "bold", "style_id": bold_id},
{"text": "text", "style_id": para_id}, {"text": "text", "style_id": para_id},
] ]
# To get the actual font for rendering, we resolve through registry # To get the actual font for rendering, we resolve through registry
context = RenderingContext() context = RenderingContext()
concrete_registry = ConcreteStyleRegistry(StyleResolver(context)) concrete_registry = ConcreteStyleRegistry(StyleResolver(context))
for word_data in words_data: for word_data in words_data:
abstract_style = registry.get_style_by_id(word_data["style_id"]) abstract_style = registry.get_style_by_id(word_data["style_id"])
font = concrete_registry.get_font(abstract_style) font = concrete_registry.get_font(abstract_style)
# Now we have the actual Font object for rendering # Now we have the actual Font object for rendering
assert font is not None assert font is not None
assert hasattr(font, 'font_size') assert hasattr(font, 'font_size')
# Bold word should have bold weight # Bold word should have bold weight
if word_data["text"] == "bold": if word_data["text"] == "bold":
assert font.weight == FontWeight.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. in the style system.
""" """
import pytest
from pyWebLayout.style.abstract_style import AbstractStyle, AbstractStyleRegistry 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: class TestWordSpacingConstraints:
"""Test cases for word spacing constraints feature.""" """Test cases for word spacing constraints feature."""
def test_abstract_style_with_word_spacing_constraints(self): def test_abstract_style_with_word_spacing_constraints(self):
"""Test that AbstractStyle accepts word spacing constraint fields.""" """Test that AbstractStyle accepts word spacing constraint fields."""
style = AbstractStyle( style = AbstractStyle(
@ -20,116 +19,116 @@ class TestWordSpacingConstraints:
word_spacing_min=2.0, word_spacing_min=2.0,
word_spacing_max=10.0 word_spacing_max=10.0
) )
assert style.word_spacing == 5.0 assert style.word_spacing == 5.0
assert style.word_spacing_min == 2.0 assert style.word_spacing_min == 2.0
assert style.word_spacing_max == 10.0 assert style.word_spacing_max == 10.0
def test_concrete_style_resolution_with_constraints(self): def test_concrete_style_resolution_with_constraints(self):
"""Test that word spacing constraints are resolved correctly.""" """Test that word spacing constraints are resolved correctly."""
# Create rendering context # Create rendering context
context = RenderingContext(base_font_size=16) context = RenderingContext(base_font_size=16)
resolver = StyleResolver(context) resolver = StyleResolver(context)
# Create abstract style with constraints # Create abstract style with constraints
abstract_style = AbstractStyle( abstract_style = AbstractStyle(
word_spacing=5.0, word_spacing=5.0,
word_spacing_min=2.0, word_spacing_min=2.0,
word_spacing_max=12.0 word_spacing_max=12.0
) )
# Resolve to concrete style # Resolve to concrete style
concrete_style = resolver.resolve_style(abstract_style) concrete_style = resolver.resolve_style(abstract_style)
# Check that constraints are preserved # Check that constraints are preserved
assert concrete_style.word_spacing == 5.0 assert concrete_style.word_spacing == 5.0
assert concrete_style.word_spacing_min == 2.0 assert concrete_style.word_spacing_min == 2.0
assert concrete_style.word_spacing_max == 12.0 assert concrete_style.word_spacing_max == 12.0
def test_default_constraint_logic(self): def test_default_constraint_logic(self):
"""Test default constraint logic when not specified.""" """Test default constraint logic when not specified."""
context = RenderingContext(base_font_size=16) context = RenderingContext(base_font_size=16)
resolver = StyleResolver(context) resolver = StyleResolver(context)
# Style with only base word spacing # Style with only base word spacing
abstract_style = AbstractStyle(word_spacing=6.0) abstract_style = AbstractStyle(word_spacing=6.0)
concrete_style = resolver.resolve_style(abstract_style) concrete_style = resolver.resolve_style(abstract_style)
# Should apply default logic: min = base, max = base * 2 # Should apply default logic: min = base, max = base * 2
assert concrete_style.word_spacing == 6.0 assert concrete_style.word_spacing == 6.0
assert concrete_style.word_spacing_min == 6.0 assert concrete_style.word_spacing_min == 6.0
assert concrete_style.word_spacing_max == 12.0 assert concrete_style.word_spacing_max == 12.0
def test_no_word_spacing_defaults(self): def test_no_word_spacing_defaults(self):
"""Test defaults when no word spacing is specified.""" """Test defaults when no word spacing is specified."""
context = RenderingContext(base_font_size=16) context = RenderingContext(base_font_size=16)
resolver = StyleResolver(context) resolver = StyleResolver(context)
# Style with no word spacing specified # Style with no word spacing specified
abstract_style = AbstractStyle() abstract_style = AbstractStyle()
concrete_style = resolver.resolve_style(abstract_style) concrete_style = resolver.resolve_style(abstract_style)
# Should apply font-based defaults # Should apply font-based defaults
assert concrete_style.word_spacing == 0.0 assert concrete_style.word_spacing == 0.0
assert concrete_style.word_spacing_min == 2.0 # Minimum default 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) assert concrete_style.word_spacing_max == 8.0 # 50% of font size (16 * 0.5)
def test_partial_constraints(self): def test_partial_constraints(self):
"""Test behavior when only min or max is specified.""" """Test behavior when only min or max is specified."""
context = RenderingContext(base_font_size=16) context = RenderingContext(base_font_size=16)
resolver = StyleResolver(context) resolver = StyleResolver(context)
# Only min specified # Only min specified
abstract_style_min = AbstractStyle( abstract_style_min = AbstractStyle(
word_spacing=4.0, word_spacing=4.0,
word_spacing_min=3.0 word_spacing_min=3.0
) )
concrete_style_min = resolver.resolve_style(abstract_style_min) concrete_style_min = resolver.resolve_style(abstract_style_min)
assert concrete_style_min.word_spacing_min == 3.0 assert concrete_style_min.word_spacing_min == 3.0
assert concrete_style_min.word_spacing_max == 6.0 # 3.0 * 2 assert concrete_style_min.word_spacing_max == 6.0 # 3.0 * 2
# Only max specified # Only max specified
abstract_style_max = AbstractStyle( abstract_style_max = AbstractStyle(
word_spacing=4.0, word_spacing=4.0,
word_spacing_max=8.0 word_spacing_max=8.0
) )
concrete_style_max = resolver.resolve_style(abstract_style_max) 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_min == 4.0 # max(word_spacing, 2.0)
assert concrete_style_max.word_spacing_max == 8.0 assert concrete_style_max.word_spacing_max == 8.0
def test_style_registry_with_constraints(self): def test_style_registry_with_constraints(self):
"""Test that style registry handles word spacing constraints.""" """Test that style registry handles word spacing constraints."""
registry = AbstractStyleRegistry() registry = AbstractStyleRegistry()
# Create style with constraints # Create style with constraints
style_id, style = registry.get_or_create_style( style_id, style = registry.get_or_create_style(
word_spacing=5.0, word_spacing=5.0,
word_spacing_min=3.0, word_spacing_min=3.0,
word_spacing_max=10.0 word_spacing_max=10.0
) )
# Verify the style was created correctly # Verify the style was created correctly
retrieved_style = registry.get_style_by_id(style_id) retrieved_style = registry.get_style_by_id(style_id)
assert retrieved_style.word_spacing == 5.0 assert retrieved_style.word_spacing == 5.0
assert retrieved_style.word_spacing_min == 3.0 assert retrieved_style.word_spacing_min == 3.0
assert retrieved_style.word_spacing_max == 10.0 assert retrieved_style.word_spacing_max == 10.0
def test_em_units_in_constraints(self): def test_em_units_in_constraints(self):
"""Test that em units work in word spacing constraints.""" """Test that em units work in word spacing constraints."""
context = RenderingContext(base_font_size=16) context = RenderingContext(base_font_size=16)
resolver = StyleResolver(context) resolver = StyleResolver(context)
# Use em units # Use em units
abstract_style = AbstractStyle( abstract_style = AbstractStyle(
word_spacing="0.25em", word_spacing="0.25em",
word_spacing_min="0.1em", word_spacing_min="0.1em",
word_spacing_max="0.5em" word_spacing_max="0.5em"
) )
concrete_style = resolver.resolve_style(abstract_style) concrete_style = resolver.resolve_style(abstract_style)
# Should convert em to pixels based on font size (16px) # 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 == 4.0 # 0.25 * 16
assert concrete_style.word_spacing_min == 1.6 # 0.1 * 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_partial_constraints()
test.test_style_registry_with_constraints() test.test_style_registry_with_constraints()
test.test_em_units_in_constraints() test.test_em_units_in_constraints()
print("All word spacing constraint tests passed!") print("All word spacing constraint tests passed!")

View File

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

View File

@ -9,7 +9,6 @@ import unittest
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from PIL import Image as PILImage from PIL import Image as PILImage
import numpy as np
from pyWebLayout.abstract.interactive_image import InteractiveImage from pyWebLayout.abstract.interactive_image import InteractiveImage
@ -123,7 +122,6 @@ class TestInteractiveImage(unittest.TestCase):
def test_create_and_add_to(self): def test_create_and_add_to(self):
"""Test the convenience factory method""" """Test the convenience factory method"""
callback_result = []
def callback(point): def callback(point):
return "added!" return "added!"
@ -171,8 +169,18 @@ class TestInteractiveImage(unittest.TestCase):
def callback2(point): def callback2(point):
return "image2" return "image2"
img1 = InteractiveImage(source=str(self.test_image_path), width=50, height=50, callback=callback1) img1 = InteractiveImage(
img2 = InteractiveImage(source=str(self.test_image_path), width=50, height=50, callback=callback2) 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 # Set different bounds
img1.set_rendered_bounds(origin=(0, 0), size=(50, 50)) 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): class TestFontUtilities(unittest.TestCase):
"""Test cases for font utility functions.""" """Test cases for font utility functions."""
def test_get_bundled_font_path_finds_font(self): def test_get_bundled_font_path_finds_font(self):
"""Test that get_bundled_font_path finds the bundled font.""" """Test that get_bundled_font_path finds the bundled font."""
font_path = get_bundled_font_path() font_path = get_bundled_font_path()
self.assertIsNotNone(font_path, "Bundled font path should not be None") 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(
self.assertTrue(font_path.endswith("DejaVuSans.ttf"), "Font path should end with DejaVuSans.ttf") 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): def test_verify_bundled_font_available(self):
"""Test that the bundled font can be verified and loaded.""" """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): def test_create_test_font_with_defaults(self):
"""Test creating a test font with default parameters.""" """Test creating a test font with default parameters."""
font = create_test_font() font = create_test_font()
@ -41,7 +46,7 @@ class TestFontUtilities(unittest.TestCase):
self.assertEqual(font.weight, FontWeight.NORMAL) self.assertEqual(font.weight, FontWeight.NORMAL)
self.assertEqual(font.style, FontStyle.NORMAL) self.assertEqual(font.style, FontStyle.NORMAL)
self.assertEqual(font.decoration, TextDecoration.NONE) self.assertEqual(font.decoration, TextDecoration.NONE)
def test_create_test_font_with_custom_parameters(self): def test_create_test_font_with_custom_parameters(self):
"""Test creating a test font with custom parameters.""" """Test creating a test font with custom parameters."""
font = create_test_font( font = create_test_font(
@ -57,50 +62,52 @@ class TestFontUtilities(unittest.TestCase):
self.assertEqual(font.weight, FontWeight.BOLD) self.assertEqual(font.weight, FontWeight.BOLD)
self.assertEqual(font.style, FontStyle.ITALIC) self.assertEqual(font.style, FontStyle.ITALIC)
self.assertEqual(font.decoration, TextDecoration.UNDERLINE) self.assertEqual(font.decoration, TextDecoration.UNDERLINE)
def test_create_default_test_font(self): def test_create_default_test_font(self):
"""Test creating a default test font.""" """Test creating a default test font."""
font = create_default_test_font() font = create_default_test_font()
self.assertIsInstance(font, Font) self.assertIsInstance(font, Font)
self.assertEqual(font.font_size, 16) self.assertEqual(font.font_size, 16)
self.assertEqual(font.colour, (0, 0, 0)) self.assertEqual(font.colour, (0, 0, 0))
def test_ensure_consistent_font_in_tests_succeeds(self): def test_ensure_consistent_font_in_tests_succeeds(self):
"""Test that ensure_consistent_font_in_tests runs without error when font is available.""" """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 # This should not raise any exceptions if the font is properly available
try: try:
ensure_consistent_font_in_tests() ensure_consistent_font_in_tests()
except RuntimeError: 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): def test_bundled_font_loads_with_pil(self):
"""Test that the bundled font can be loaded directly with PIL.""" """Test that the bundled font can be loaded directly with PIL."""
font_path = get_bundled_font_path() font_path = get_bundled_font_path()
self.assertIsNotNone(font_path) self.assertIsNotNone(font_path)
# Test loading with different sizes # Test loading with different sizes
for size in [12, 16, 24, 48]: for size in [12, 16, 24, 48]:
with self.subTest(size=size): with self.subTest(size=size):
pil_font = ImageFont.truetype(font_path, size) pil_font = ImageFont.truetype(font_path, size)
self.assertIsNotNone(pil_font) self.assertIsNotNone(pil_font)
def test_font_metrics_consistency(self): def test_font_metrics_consistency(self):
"""Test that font metrics are consistent between different Font objects using the same parameters.""" """Test that font metrics are consistent between different Font objects using the same parameters."""
font1 = create_test_font(font_size=16) font1 = create_test_font(font_size=16)
font2 = create_test_font(font_size=16) font2 = create_test_font(font_size=16)
# Both fonts should have the same size # Both fonts should have the same size
self.assertEqual(font1.font_size, font2.font_size) self.assertEqual(font1.font_size, font2.font_size)
# Test that text measurements are consistent # 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) self.assertEqual(font1.font_size, font2.font_size)
def test_different_sizes_create_different_fonts(self): def test_different_sizes_create_different_fonts(self):
"""Test that different font sizes create fonts with different metrics.""" """Test that different font sizes create fonts with different metrics."""
small_font = create_test_font(font_size=12) small_font = create_test_font(font_size=12)
large_font = create_test_font(font_size=24) large_font = create_test_font(font_size=24)
self.assertNotEqual(small_font.font_size, large_font.font_size) self.assertNotEqual(small_font.font_size, large_font.font_size)
self.assertEqual(small_font.font_size, 12) self.assertEqual(small_font.font_size, 12)
self.assertEqual(large_font.font_size, 24) self.assertEqual(large_font.font_size, 24)
@ -108,24 +115,28 @@ class TestFontUtilities(unittest.TestCase):
class TestFontPathResolution(unittest.TestCase): class TestFontPathResolution(unittest.TestCase):
"""Test cases for font path resolution from different locations.""" """Test cases for font path resolution from different locations."""
def test_font_path_is_absolute(self): def test_font_path_is_absolute(self):
"""Test that the returned font path is absolute.""" """Test that the returned font path is absolute."""
font_path = get_bundled_font_path() font_path = get_bundled_font_path()
if font_path: if font_path:
self.assertTrue(os.path.isabs(font_path), "Font path should be absolute") self.assertTrue(os.path.isabs(font_path), "Font path should be absolute")
def test_font_path_points_to_file(self): def test_font_path_points_to_file(self):
"""Test that the font path points to a file, not a directory.""" """Test that the font path points to a file, not a directory."""
font_path = get_bundled_font_path() font_path = get_bundled_font_path()
if 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): def test_font_file_has_correct_extension(self):
"""Test that the font file has the expected .ttf extension.""" """Test that the font file has the expected .ttf extension."""
font_path = get_bundled_font_path() font_path = get_bundled_font_path()
if 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__': if __name__ == '__main__':

View File

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