diff --git a/examples/01_simple_page_rendering.py b/examples/01_simple_page_rendering.py
index 3c75d30..b070c7a 100644
--- a/examples/01_simple_page_rendering.py
+++ b/examples/01_simple_page_rendering.py
@@ -11,6 +11,8 @@ This example demonstrates:
This is a foundational example showing the basic Page API.
"""
+from pyWebLayout.style.page_style import PageStyle
+from pyWebLayout.concrete.page import Page
import sys
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
@@ -18,9 +20,6 @@ from PIL import Image, ImageDraw, ImageFont
# Add pyWebLayout to path
sys.path.insert(0, str(Path(__file__).parent.parent))
-from pyWebLayout.concrete.page import Page
-from pyWebLayout.style.page_style import PageStyle
-
def draw_placeholder_content(page: Page):
"""Draw some placeholder content directly on the page to visualize the layout."""
@@ -46,13 +45,32 @@ def draw_placeholder_content(page: Page):
# Add some text labels
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 12)
- except:
+ except BaseException:
font = ImageFont.load_default()
# Label the areas
- draw.text((content_x + 10, content_y + 10), "Content Area", fill=(100, 100, 100), font=font)
- draw.text((10, 10), f"Border: {page.border_size}px", fill=(150, 150, 150), font=font)
- draw.text((content_x + 10, content_y + 30), f"Size: {content_w}x{content_h}", fill=(100, 100, 100), font=font)
+ draw.text(
+ (content_x + 10,
+ content_y + 10),
+ "Content Area",
+ fill=(
+ 100,
+ 100,
+ 100),
+ font=font)
+ draw.text(
+ (10, 10), f"Border: {
+ page.border_size}px", fill=(
+ 150, 150, 150), font=font)
+ draw.text(
+ (content_x + 10,
+ content_y + 30),
+ f"Size: {content_w}x{content_h}",
+ fill=(
+ 100,
+ 100,
+ 100),
+ font=font)
def create_example_1():
@@ -117,7 +135,7 @@ def create_example_4():
def combine_into_grid(pages, title):
"""Combine multiple pages into a 2x2 grid with title."""
- print(f"\n Combining pages into grid...")
+ print("\n Combining pages into grid...")
# Render all pages
images = [page.render() for page in pages]
@@ -141,8 +159,9 @@ def combine_into_grid(pages, title):
# Draw title
try:
- title_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 20)
- except:
+ title_font = ImageFont.truetype(
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 20)
+ except BaseException:
title_font = ImageFont.load_default()
# Center the title
@@ -187,7 +206,7 @@ def main():
output_path = output_dir / "example_01_page_rendering.png"
combined_image.save(output_path)
- print(f"\n✓ Example completed!")
+ print("\n✓ Example completed!")
print(f" Output saved to: {output_path}")
print(f" Image size: {combined_image.size[0]}x{combined_image.size[1]} pixels")
print(f" Created {len(pages)} page examples")
diff --git a/examples/02_text_and_layout.py b/examples/02_text_and_layout.py
index 91f2e3f..8699175 100644
--- a/examples/02_text_and_layout.py
+++ b/examples/02_text_and_layout.py
@@ -11,6 +11,10 @@ This example demonstrates text rendering using the pyWebLayout system:
This example uses the HTML parsing system to create rich text layouts.
"""
+from pyWebLayout.style.page_style import PageStyle
+from pyWebLayout.concrete.page import Page
+from pyWebLayout.style import Font
+from pyWebLayout.io.readers.html_extraction import parse_html_string
import sys
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
@@ -18,11 +22,6 @@ from PIL import Image, ImageDraw, ImageFont
# Add pyWebLayout to path
sys.path.insert(0, str(Path(__file__).parent.parent))
-from pyWebLayout.io.readers.html_extraction import parse_html_string
-from pyWebLayout.style import Font
-from pyWebLayout.concrete.page import Page
-from pyWebLayout.style.page_style import PageStyle
-
def create_sample_document():
"""Create different HTML samples demonstrating various features."""
@@ -37,7 +36,8 @@ def create_sample_document():
This is left-aligned text. It is the default alignment for most text.
Justified Text
- This paragraph is justified. The text stretches to fill the entire width of the line, creating clean edges on both sides.
+ This paragraph is justified. The text stretches to fill
+ the entire width of the line, creating clean edges on both sides.
Centered
This text is centered.
@@ -112,7 +112,7 @@ def render_html_to_image(html_content, page_size=(500, 400)):
# Add a note that this is HTML-parsed content
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 11)
- except:
+ except BaseException:
font = ImageFont.load_default()
# Draw info about what was parsed
@@ -128,7 +128,7 @@ def render_html_to_image(html_content, page_size=(500, 400)):
for i, block in enumerate(blocks[:10]): # Show first 10
block_type = type(block).__name__
draw.text((content_x, y_offset),
- f" {i+1}. {block_type}",
+ f" {i + 1}. {block_type}",
fill=(60, 60, 60), font=font)
y_offset += 18
@@ -150,8 +150,9 @@ def combine_samples(samples):
# Add title to image
draw = ImageDraw.Draw(img)
try:
- font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 14)
- except:
+ font = ImageFont.truetype(
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 14)
+ except BaseException:
font = ImageFont.load_default()
draw.text((10, 10), title, fill=(50, 50, 150), font=font)
@@ -201,11 +202,11 @@ def main():
output_path = output_dir / "example_02_text_and_layout.png"
combined_image.save(output_path)
- print(f"\n✓ Example completed!")
+ print("\n✓ Example completed!")
print(f" Output saved to: {output_path}")
print(f" Image size: {combined_image.size[0]}x{combined_image.size[1]} pixels")
- print(f" Note: This example demonstrates HTML parsing")
- print(f" Full layout rendering requires the typesetting engine")
+ print(" Note: This example demonstrates HTML parsing")
+ print(" Full layout rendering requires the typesetting engine")
return combined_image
diff --git a/examples/03_page_layouts.py b/examples/03_page_layouts.py
index 0474e59..49a5af3 100644
--- a/examples/03_page_layouts.py
+++ b/examples/03_page_layouts.py
@@ -11,6 +11,8 @@ This example demonstrates different page layout configurations:
Shows how the pyWebLayout system handles different page dimensions.
"""
+from pyWebLayout.style.page_style import PageStyle
+from pyWebLayout.concrete.page import Page
import sys
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
@@ -18,9 +20,6 @@ from PIL import Image, ImageDraw, ImageFont
# Add pyWebLayout to path
sys.path.insert(0, str(Path(__file__).parent.parent))
-from pyWebLayout.concrete.page import Page
-from pyWebLayout.style.page_style import PageStyle
-
def add_page_info(page: Page, title: str):
"""Add informational text to a page showing its properties."""
@@ -30,9 +29,11 @@ def add_page_info(page: Page, title: str):
draw = page.draw
try:
- font_large = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 14)
- font_small = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 11)
- except:
+ font_large = ImageFont.truetype(
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 14)
+ font_small = ImageFont.truetype(
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 11)
+ except BaseException:
font_large = ImageFont.load_default()
font_small = ImageFont.load_default()
@@ -164,13 +165,15 @@ def create_layout_showcase(layouts):
# Find max dimensions for each row/column
max_widths = []
for col in range(cols):
- col_images = [images[row * cols + col][1] for row in range(rows) if row * cols + col < len(images)]
+ col_images = [images[row * cols + col][1]
+ for row in range(rows) if row * cols + col < len(images)]
if col_images:
max_widths.append(max(img.size[0] for img in col_images))
max_heights = []
for row in range(rows):
- row_images = [images[row * cols + col][1] for col in range(cols) if row * cols + col < len(images)]
+ row_images = [images[row * cols + col][1]
+ for col in range(cols) if row * cols + col < len(images)]
if row_images:
max_heights.append(max(img.size[1] for img in row_images))
@@ -184,8 +187,9 @@ def create_layout_showcase(layouts):
# Add title
try:
- title_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 24)
- except:
+ title_font = ImageFont.truetype(
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 24)
+ except BaseException:
title_font = ImageFont.load_default()
title_text = "Page Layout Examples"
@@ -231,7 +235,7 @@ def main():
output_path = output_dir / "example_03_page_layouts.png"
combined_image.save(output_path)
- print(f"\n✓ Example completed!")
+ print("\n✓ Example completed!")
print(f" Output saved to: {output_path}")
print(f" Image size: {combined_image.size[0]}x{combined_image.size[1]} pixels")
print(f" Created {len(layouts)} layout examples")
diff --git a/examples/04_table_rendering.py b/examples/04_table_rendering.py
index cdbf830..6fa7b21 100644
--- a/examples/04_table_rendering.py
+++ b/examples/04_table_rendering.py
@@ -12,6 +12,13 @@ This example demonstrates rendering HTML tables:
Shows the HTML-first rendering pipeline.
"""
+from pyWebLayout.abstract.block import Table
+from pyWebLayout.style import Font
+from pyWebLayout.io.readers.html_extraction import parse_html_string
+from pyWebLayout.layout.document_layouter import DocumentLayouter
+from pyWebLayout.style.page_style import PageStyle
+from pyWebLayout.concrete.table import TableStyle
+from pyWebLayout.concrete.page import Page
import sys
from pathlib import Path
from PIL import Image, ImageDraw
@@ -19,14 +26,6 @@ from PIL import Image, ImageDraw
# Add pyWebLayout to path
sys.path.insert(0, str(Path(__file__).parent.parent))
-from pyWebLayout.concrete.page import Page
-from pyWebLayout.concrete.table import TableStyle
-from pyWebLayout.style.page_style import PageStyle
-from pyWebLayout.layout.document_layouter import DocumentLayouter
-from pyWebLayout.io.readers.html_extraction import parse_html_string
-from pyWebLayout.style import Font
-from pyWebLayout.abstract.block import Table
-
def create_simple_table_example():
"""Create a simple table from HTML."""
@@ -179,7 +178,13 @@ def create_data_table_example():
return html, "Data Table"
-def render_table_example(html: str, title: str, style_variant: int = 0, page_size=(500, 400)):
+def render_table_example(
+ html: str,
+ title: str,
+ style_variant: int = 0,
+ page_size=(
+ 500,
+ 400)):
"""Render a table from HTML to an image using DocumentLayouter."""
# Create page with varying backgrounds
bg_colors = [
@@ -299,8 +304,9 @@ def combine_examples(examples):
# Add main title
from PIL import ImageFont
try:
- main_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 20)
- except:
+ main_font = ImageFont.truetype(
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 20)
+ except BaseException:
main_font = ImageFont.load_default()
title_text = "Table Rendering Examples"
@@ -346,7 +352,7 @@ def main():
output_path = output_dir / "example_04_table_rendering.png"
combined_image.save(output_path)
- print(f"\n✓ Example completed!")
+ print("\n✓ Example completed!")
print(f" Output saved to: {output_path}")
print(f" Image size: {combined_image.size[0]}x{combined_image.size[1]} pixels")
print(f" Created {len(examples)} table examples")
diff --git a/examples/05_html_table_with_images.py b/examples/05_html_table_with_images.py
index 23ec05f..30e89eb 100644
--- a/examples/05_html_table_with_images.py
+++ b/examples/05_html_table_with_images.py
@@ -10,6 +10,12 @@ This example demonstrates the complete pipeline:
No custom rendering code needed - DocumentLayouter handles everything!
"""
+from pyWebLayout.style import Font
+from pyWebLayout.concrete.table import TableStyle
+from pyWebLayout.layout.document_layouter import DocumentLayouter
+from pyWebLayout.style.page_style import PageStyle
+from pyWebLayout.concrete.page import Page
+from pyWebLayout.io.readers.html_extraction import parse_html_string
import sys
from pathlib import Path
from PIL import Image
@@ -17,20 +23,13 @@ from PIL import Image
# Add pyWebLayout to path
sys.path.insert(0, str(Path(__file__).parent.parent))
-from pyWebLayout.io.readers.html_extraction import parse_html_string
-from pyWebLayout.concrete.page import Page
-from pyWebLayout.style.page_style import PageStyle
-from pyWebLayout.layout.document_layouter import DocumentLayouter
-from pyWebLayout.concrete.table import TableStyle
-from pyWebLayout.style import Font
-
def create_book_catalog_html():
"""Create HTML for a book catalog table with actual
tags."""
# Get base path for images - use absolute paths for the img src
- data_path = Path(__file__).parent.parent / "tests" / "data"
+ Path(__file__).parent.parent / "tests" / "data"
- html = f"""
+ html = """
@@ -77,9 +76,9 @@ def create_book_catalog_html():
def create_product_showcase_html():
"""Create HTML for a product showcase table with images."""
- data_path = Path(__file__).parent.parent / "tests" / "data"
+ Path(__file__).parent.parent / "tests" / "data"
- html = f"""
+ html = """
@@ -107,8 +106,8 @@ def create_product_showcase_html():
def render_html_with_layouter(html_string: str, title: str,
- table_style: TableStyle,
- page_size=(600, 500)):
+ table_style: TableStyle,
+ page_size=(600, 500)):
"""
Render HTML using DocumentLayouter - the proper way!
@@ -163,7 +162,7 @@ def render_html_with_layouter(html_string: str, title: str,
if not success:
print(f" ⚠ Warning: Block {type(block).__name__} didn't fit on page")
- print(f" ✓ Layout complete!")
+ print(" ✓ Layout complete!")
# Step 5: Get the rendered canvas
# Note: Tables render directly onto page._canvas
@@ -257,14 +256,14 @@ def main():
output_path = output_dir / "example_05_html_table_with_images.png"
combined.save(output_path)
- print(f"\n✓ Example completed!")
+ print("\n✓ Example completed!")
print(f" Output saved to: {output_path}")
print(f" Image size: {combined.size[0]}x{combined.size[1]} pixels")
- print(f"\nThe complete pipeline:")
- print(f" 1. HTML with
tags → parse_html_string() → Abstract blocks")
- print(f" 2. Abstract blocks → DocumentLayouter → Concrete objects")
- print(f" 3. Page.render() → PNG output")
- print(f"\n ✓ Using DocumentLayouter - NO custom rendering code!")
+ print("\nThe complete pipeline:")
+ print(" 1. HTML with
tags → parse_html_string() → Abstract blocks")
+ print(" 2. Abstract blocks → DocumentLayouter → Concrete objects")
+ print(" 3. Page.render() → PNG output")
+ print("\n ✓ Using DocumentLayouter - NO custom rendering code!")
return combined
diff --git a/pyWebLayout/__init__.py b/pyWebLayout/__init__.py
index 90a705c..240043d 100644
--- a/pyWebLayout/__init__.py
+++ b/pyWebLayout/__init__.py
@@ -11,19 +11,12 @@ save state, and resume rendering.
__version__ = '0.1.0'
# Core abstractions
-from pyWebLayout.core import Renderable, Interactable, Layoutable, Queriable
# Style components
-from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration
# Abstract document model
-from pyWebLayout.abstract.document import Document, Book, Chapter, MetadataType
# Concrete implementations
-from pyWebLayout.concrete.box import Box
-from pyWebLayout.concrete.text import Line
-from pyWebLayout.concrete.page import Page
# Abstract components
-from pyWebLayout.abstract.inline import Word
diff --git a/pyWebLayout/abstract/__init__.py b/pyWebLayout/abstract/__init__.py
index 946e964..adc4694 100644
--- a/pyWebLayout/abstract/__init__.py
+++ b/pyWebLayout/abstract/__init__.py
@@ -1,7 +1,22 @@
-from .block import Block, BlockType, Paragraph, Heading, HeadingLevel, Quote, CodeBlock
-from .block import HList, ListItem, ListStyle, Table, TableRow, TableCell
-from .block import HorizontalRule, Image
-from .interactive_image import InteractiveImage
-from .inline import Word, FormattedSpan, LineBreak
-from .document import Document, MetadataType, Chapter, Book
-from .functional import Link, LinkType, Button, Form, FormField, FormFieldType
+"""
+Abstract layer for the pyWebLayout library.
+
+This package contains abstract representations of document elements that are
+independent of rendering specifics.
+"""
+
+from .inline import Word, FormattedSpan
+from .block import Paragraph, Heading, Image, HeadingLevel
+from .document import Document
+from .functional import LinkType
+
+__all__ = [
+ 'Word',
+ 'FormattedSpan',
+ 'Paragraph',
+ 'Heading',
+ 'Image',
+ 'HeadingLevel',
+ 'Document',
+ 'LinkType',
+]
diff --git a/pyWebLayout/abstract/block.py b/pyWebLayout/abstract/block.py
index e972a9f..2b28aa6 100644
--- a/pyWebLayout/abstract/block.py
+++ b/pyWebLayout/abstract/block.py
@@ -1,4 +1,4 @@
-from typing import List, Iterator, Tuple, Dict, Optional, Union, Any
+from typing import List, Iterator, Tuple, Dict, Optional, Any
from enum import Enum
import os
import tempfile
@@ -6,7 +6,6 @@ import urllib.request
import urllib.parse
from PIL import Image as PILImage
from .inline import Word, FormattedSpan
-from ..style import Font, FontWeight, FontStyle, TextDecoration
from ..core import Hierarchical, Styleable, FontRegistry
@@ -69,20 +68,20 @@ class Paragraph(Styleable, FontRegistry, Block):
super().__init__(style=style, block_type=BlockType.PARAGRAPH)
self._words: List[Word] = []
self._spans: List[FormattedSpan] = []
-
+
@classmethod
def create_and_add_to(cls, container, style=None) -> 'Paragraph':
"""
Create a new Paragraph and add it to a container, inheriting style from
the container if not explicitly provided.
-
+
Args:
container: The container to add the paragraph to (must have add_block method and style property)
style: Optional style override. If None, inherits from container
-
+
Returns:
The newly created Paragraph object
-
+
Raises:
AttributeError: If the container doesn't have the required add_block method
"""
@@ -91,96 +90,98 @@ class Paragraph(Styleable, FontRegistry, Block):
style = container.style
elif style is None and hasattr(container, 'default_style'):
style = container.default_style
-
+
# Create the new paragraph
paragraph = cls(style)
-
+
# Add the paragraph to the container
if hasattr(container, 'add_block'):
container.add_block(paragraph)
else:
- raise AttributeError(f"Container {type(container).__name__} must have an 'add_block' method")
-
+ raise AttributeError(
+ f"Container {
+ type(container).__name__} must have an 'add_block' method")
+
return paragraph
def add_word(self, word: Word):
"""
Add a word to this paragraph.
-
+
Args:
word: The Word object to add
"""
self._words.append(word)
-
+
def create_word(self, text: str, style=None, background=None) -> Word:
"""
Create a new word and add it to this paragraph, inheriting paragraph's style if not specified.
-
+
This is a convenience method that uses Word.create_and_add_to() to create words
that automatically inherit styling from this paragraph.
-
+
Args:
text: The text content of the word
style: Optional Font style override. If None, attempts to inherit from paragraph
background: Optional background color override
-
+
Returns:
The newly created Word object
"""
return Word.create_and_add_to(text, self, style, background)
-
+
def add_span(self, span: FormattedSpan):
"""
Add a formatted span to this paragraph.
-
+
Args:
span: The FormattedSpan object to add
"""
self._spans.append(span)
-
+
def create_span(self, style=None, background=None) -> FormattedSpan:
"""
Create a new formatted span with inherited style.
-
+
Args:
style: Optional Font style override. If None, inherits from paragraph
background: Optional background color override
-
+
Returns:
The newly created FormattedSpan object
"""
return FormattedSpan.create_and_add_to(self, style, background)
-
+
@property
def words(self) -> List[Word]:
"""Get the list of words in this paragraph"""
return self._words
-
+
def words_iter(self) -> Iterator[Tuple[int, Word]]:
"""
Iterate over the words in this paragraph.
-
+
Yields:
Tuples of (index, word) for each word in the paragraph
"""
for i, word in enumerate(self._words):
yield i, word
-
+
def spans(self) -> Iterator[FormattedSpan]:
"""
Iterate over the formatted spans in this paragraph.
-
+
Yields:
Each FormattedSpan in the paragraph
"""
for span in self._spans:
yield span
-
+
@property
def word_count(self) -> int:
"""Get the number of words in this paragraph"""
return len(self._words)
-
+
def __len__(self):
return self.word_count
@@ -202,11 +203,11 @@ class Heading(Paragraph):
A heading element (h1, h2, h3, etc.) that contains text with a specific heading level.
Headings inherit from Paragraph as they contain words but have additional properties.
"""
-
+
def __init__(self, level: HeadingLevel = HeadingLevel.H1, style=None):
"""
Initialize a heading element.
-
+
Args:
level: The heading level (h1-h6)
style: Optional default style for words in this heading
@@ -214,21 +215,25 @@ class Heading(Paragraph):
super().__init__(style)
self._block_type = BlockType.HEADING
self._level = level
-
+
@classmethod
- def create_and_add_to(cls, container, level: HeadingLevel = HeadingLevel.H1, style=None) -> 'Heading':
+ def create_and_add_to(
+ cls,
+ container,
+ level: HeadingLevel = HeadingLevel.H1,
+ style=None) -> 'Heading':
"""
Create a new Heading and add it to a container, inheriting style from
the container if not explicitly provided.
-
+
Args:
container: The container to add the heading to (must have add_block method and style property)
level: The heading level (h1-h6)
style: Optional style override. If None, inherits from container
-
+
Returns:
The newly created Heading object
-
+
Raises:
AttributeError: If the container doesn't have the required add_block method
"""
@@ -237,23 +242,25 @@ class Heading(Paragraph):
style = container.style
elif style is None and hasattr(container, 'default_style'):
style = container.default_style
-
+
# Create the new heading
heading = cls(level, style)
-
+
# Add the heading to the container
if hasattr(container, 'add_block'):
container.add_block(heading)
else:
- raise AttributeError(f"Container {type(container).__name__} must have an 'add_block' method")
-
+ raise AttributeError(
+ f"Container {
+ type(container).__name__} must have an 'add_block' method")
+
return heading
-
+
@property
def level(self) -> HeadingLevel:
"""Get the heading level"""
return self._level
-
+
@level.setter
def level(self, level: HeadingLevel):
"""Set the heading level"""
@@ -264,31 +271,31 @@ class Quote(Block):
"""
A blockquote element that can contain other block elements.
"""
-
+
def __init__(self, style=None):
"""
Initialize an empty blockquote
-
+
Args:
style: Optional default style for child blocks
"""
super().__init__(BlockType.QUOTE)
self._blocks: List[Block] = []
self._style = style
-
+
@classmethod
def create_and_add_to(cls, container, style=None) -> 'Quote':
"""
Create a new Quote and add it to a container, inheriting style from
the container if not explicitly provided.
-
+
Args:
container: The container to add the quote to (must have add_block method and style property)
style: Optional style override. If None, inherits from container
-
+
Returns:
The newly created Quote object
-
+
Raises:
AttributeError: If the container doesn't have the required add_block method
"""
@@ -297,67 +304,72 @@ class Quote(Block):
style = container.style
elif style is None and hasattr(container, 'default_style'):
style = container.default_style
-
+
# Create the new quote
quote = cls(style)
-
+
# Add the quote to the container
if hasattr(container, 'add_block'):
container.add_block(quote)
else:
- raise AttributeError(f"Container {type(container).__name__} must have an 'add_block' method")
-
+ raise AttributeError(
+ f"Container {
+ type(container).__name__} must have an 'add_block' method")
+
return quote
-
+
@property
def style(self):
"""Get the default style for this quote"""
return self._style
-
+
@style.setter
def style(self, style):
"""Set the default style for this quote"""
self._style = style
-
+
def add_block(self, block: Block):
"""
Add a block element to this quote.
-
+
Args:
block: The Block object to add
"""
self._blocks.append(block)
block.parent = self
-
+
def create_paragraph(self, style=None) -> Paragraph:
"""
Create a new paragraph and add it to this quote.
-
+
Args:
style: Optional style override. If None, inherits from quote
-
+
Returns:
The newly created Paragraph object
"""
return Paragraph.create_and_add_to(self, style)
-
- 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 quote.
-
+
Args:
level: The heading level
style: Optional style override. If None, inherits from quote
-
+
Returns:
The newly created Heading object
"""
return Heading.create_and_add_to(self, level, style)
-
+
def blocks(self) -> Iterator[Block]:
"""
Iterate over the blocks in this quote.
-
+
Yields:
Each Block in the quote
"""
@@ -369,73 +381,75 @@ class CodeBlock(Block):
"""
A code block element containing pre-formatted text with syntax highlighting.
"""
-
+
def __init__(self, language: str = ""):
"""
Initialize a code block.
-
+
Args:
language: The programming language for syntax highlighting
"""
super().__init__(BlockType.CODE_BLOCK)
self._language = language
self._lines: List[str] = []
-
+
@classmethod
def create_and_add_to(cls, container, language: str = "") -> 'CodeBlock':
"""
Create a new CodeBlock and add it to a container.
-
+
Args:
container: The container to add the code block to (must have add_block method)
language: The programming language for syntax highlighting
-
+
Returns:
The newly created CodeBlock object
-
+
Raises:
AttributeError: If the container doesn't have the required add_block method
"""
# Create the new code block
code_block = cls(language)
-
+
# Add the code block to the container
if hasattr(container, 'add_block'):
container.add_block(code_block)
else:
- raise AttributeError(f"Container {type(container).__name__} must have an 'add_block' method")
-
+ raise AttributeError(
+ f"Container {
+ type(container).__name__} must have an 'add_block' method")
+
return code_block
-
+
@property
def language(self) -> str:
"""Get the programming language"""
return self._language
-
+
@language.setter
def language(self, language: str):
"""Set the programming language"""
self._language = language
-
+
def add_line(self, line: str):
"""
Add a line of code to this code block.
-
+
Args:
line: The line of code to add
"""
self._lines.append(line)
-
+
def lines(self) -> Iterator[Tuple[int, str]]:
"""
Iterate over the lines in this code block.
-
+
Yields:
Tuples of (line_number, line_text) for each line
"""
for i, line in enumerate(self._lines):
yield i, line
-
+
@property
def line_count(self) -> int:
"""Get the number of lines in this code block"""
@@ -446,18 +460,18 @@ class ListStyle(Enum):
"""Enumeration of list styles"""
UNORDERED = 1 #
ORDERED = 2 #
- DEFINITION = 3 #
+ DEFINITION = 3 #
class HList(Block):
"""
An HTML list element (ul, ol, dl).
"""
-
+
def __init__(self, style: ListStyle = ListStyle.UNORDERED, default_style=None):
"""
Initialize a list.
-
+
Args:
style: The style of list (unordered, ordered, definition)
default_style: Optional default style for child items
@@ -466,21 +480,25 @@ class HList(Block):
self._style = style
self._items: List[ListItem] = []
self._default_style = default_style
-
+
@classmethod
- def create_and_add_to(cls, container, style: ListStyle = ListStyle.UNORDERED, default_style=None) -> 'HList':
+ def create_and_add_to(
+ cls,
+ container,
+ style: ListStyle = ListStyle.UNORDERED,
+ default_style=None) -> 'HList':
"""
Create a new HList and add it to a container, inheriting style from
the container if not explicitly provided.
-
+
Args:
container: The container to add the list to (must have add_block method)
style: The style of list (unordered, ordered, definition)
default_style: Optional default style for child items. If None, inherits from container
-
+
Returns:
The newly created HList object
-
+
Raises:
AttributeError: If the container doesn't have the required add_block method
"""
@@ -489,71 +507,73 @@ class HList(Block):
default_style = container.style
elif default_style is None and hasattr(container, 'default_style'):
default_style = container.default_style
-
+
# Create the new list
hlist = cls(style, default_style)
-
+
# Add the list to the container
if hasattr(container, 'add_block'):
container.add_block(hlist)
else:
- raise AttributeError(f"Container {type(container).__name__} must have an 'add_block' method")
-
+ raise AttributeError(
+ f"Container {
+ type(container).__name__} must have an 'add_block' method")
+
return hlist
-
+
@property
def style(self) -> ListStyle:
"""Get the list style"""
return self._style
-
+
@style.setter
def style(self, style: ListStyle):
"""Set the list style"""
self._style = style
-
+
@property
def default_style(self):
"""Get the default style for list items"""
return self._default_style
-
+
@default_style.setter
def default_style(self, style):
"""Set the default style for list items"""
self._default_style = style
-
+
def add_item(self, item: 'ListItem'):
"""
Add an item to this list.
-
+
Args:
item: The ListItem to add
"""
self._items.append(item)
item.parent = self
-
+
def create_item(self, term: Optional[str] = None, style=None) -> 'ListItem':
"""
Create a new list item and add it to this list.
-
+
Args:
term: Optional term for definition lists
style: Optional style override. If None, inherits from list
-
+
Returns:
The newly created ListItem object
"""
return ListItem.create_and_add_to(self, term, style)
-
+
def items(self) -> Iterator['ListItem']:
"""
Iterate over the items in this list.
-
+
Yields:
Each ListItem in the list
"""
for item in self._items:
yield item
-
+
@property
def item_count(self) -> int:
"""Get the number of items in this list"""
@@ -564,11 +584,11 @@ class ListItem(Block):
"""
A list item element that can contain other block elements.
"""
-
+
def __init__(self, term: Optional[str] = None, style=None):
"""
Initialize a list item.
-
+
Args:
term: Optional term for definition lists (dt element)
style: Optional default style for child blocks
@@ -577,21 +597,25 @@ class ListItem(Block):
self._blocks: List[Block] = []
self._term = term
self._style = style
-
+
@classmethod
- def create_and_add_to(cls, container, term: Optional[str] = None, style=None) -> 'ListItem':
+ def create_and_add_to(
+ cls,
+ container,
+ term: Optional[str] = None,
+ style=None) -> 'ListItem':
"""
Create a new ListItem and add it to a container, inheriting style from
the container if not explicitly provided.
-
+
Args:
container: The container to add the list item to (must have add_item method)
term: Optional term for definition lists (dt element)
style: Optional style override. If None, inherits from container
-
+
Returns:
The newly created ListItem object
-
+
Raises:
AttributeError: If the container doesn't have the required add_item method
"""
@@ -600,77 +624,82 @@ class ListItem(Block):
style = container.default_style
elif style is None and hasattr(container, 'style'):
style = container.style
-
+
# Create the new list item
item = cls(term, style)
-
+
# Add the list item to the container
if hasattr(container, 'add_item'):
container.add_item(item)
else:
- raise AttributeError(f"Container {type(container).__name__} must have an 'add_item' method")
-
+ raise AttributeError(
+ f"Container {
+ type(container).__name__} must have an 'add_item' method")
+
return item
-
+
@property
def term(self) -> Optional[str]:
"""Get the definition term (for definition lists)"""
return self._term
-
+
@term.setter
def term(self, term: str):
"""Set the definition term"""
self._term = term
-
+
@property
def style(self):
"""Get the default style for this list item"""
return self._style
-
+
@style.setter
def style(self, style):
"""Set the default style for this list item"""
self._style = style
-
+
def add_block(self, block: Block):
"""
Add a block element to this list item.
-
+
Args:
block: The Block object to add
"""
self._blocks.append(block)
block.parent = self
-
+
def create_paragraph(self, style=None) -> Paragraph:
"""
Create a new paragraph and add it to this list item.
-
+
Args:
style: Optional style override. If None, inherits from list item
-
+
Returns:
The newly created Paragraph object
"""
return Paragraph.create_and_add_to(self, style)
-
- 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 list item.
-
+
Args:
level: The heading level
style: Optional style override. If None, inherits from list item
-
+
Returns:
The newly created Heading object
"""
return Heading.create_and_add_to(self, level, style)
-
+
def blocks(self) -> Iterator[Block]:
"""
Iterate over the blocks in this list item.
-
+
Yields:
Each Block in the list item
"""
@@ -682,11 +711,16 @@ class TableCell(Block):
"""
A table cell element that can contain other block elements.
"""
-
- def __init__(self, is_header: bool = False, colspan: int = 1, rowspan: int = 1, style=None):
+
+ def __init__(
+ self,
+ is_header: bool = False,
+ colspan: int = 1,
+ rowspan: int = 1,
+ style=None):
"""
Initialize a table cell.
-
+
Args:
is_header: Whether this cell is a header cell (th) or data cell (td)
colspan: Number of columns this cell spans
@@ -699,121 +733,126 @@ class TableCell(Block):
self._rowspan = rowspan
self._blocks: List[Block] = []
self._style = style
-
+
@classmethod
- def create_and_add_to(cls, container, is_header: bool = False, colspan: int = 1,
- rowspan: int = 1, style=None) -> 'TableCell':
+ def create_and_add_to(cls, container, is_header: bool = False, colspan: int = 1,
+ rowspan: int = 1, style=None) -> 'TableCell':
"""
Create a new TableCell and add it to a container, inheriting style from
the container if not explicitly provided.
-
+
Args:
container: The container to add the cell to (must have add_cell method)
is_header: Whether this cell is a header cell (th) or data cell (td)
colspan: Number of columns this cell spans
rowspan: Number of rows this cell spans
style: Optional style override. If None, inherits from container
-
+
Returns:
The newly created TableCell object
-
+
Raises:
AttributeError: If the container doesn't have the required add_cell method
"""
# Inherit style from container if not provided
if style is None and hasattr(container, 'style'):
style = container.style
-
+
# Create the new table cell
cell = cls(is_header, colspan, rowspan, style)
-
+
# Add the cell to the container
if hasattr(container, 'add_cell'):
container.add_cell(cell)
else:
- raise AttributeError(f"Container {type(container).__name__} must have an 'add_cell' method")
-
+ raise AttributeError(
+ f"Container {
+ type(container).__name__} must have an 'add_cell' method")
+
return cell
-
+
@property
def is_header(self) -> bool:
"""Check if this is a header cell"""
return self._is_header
-
+
@is_header.setter
def is_header(self, is_header: bool):
"""Set whether this is a header cell"""
self._is_header = is_header
-
+
@property
def colspan(self) -> int:
"""Get the column span"""
return self._colspan
-
+
@colspan.setter
def colspan(self, colspan: int):
"""Set the column span"""
self._colspan = max(1, colspan) # Ensure minimum of 1
-
+
@property
def rowspan(self) -> int:
"""Get the row span"""
return self._rowspan
-
+
@rowspan.setter
def rowspan(self, rowspan: int):
"""Set the row span"""
self._rowspan = max(1, rowspan) # Ensure minimum of 1
-
+
@property
def style(self):
"""Get the default style for this table cell"""
return self._style
-
+
@style.setter
def style(self, style):
"""Set the default style for this table cell"""
self._style = style
-
+
def add_block(self, block: Block):
"""
Add a block element to this cell.
-
+
Args:
block: The Block object to add
"""
self._blocks.append(block)
block.parent = self
-
+
def create_paragraph(self, style=None) -> Paragraph:
"""
Create a new paragraph and add it to this table cell.
-
+
Args:
style: Optional style override. If None, inherits from cell
-
+
Returns:
The newly created Paragraph object
"""
return Paragraph.create_and_add_to(self, style)
-
- 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 table cell.
-
+
Args:
level: The heading level
style: Optional style override. If None, inherits from cell
-
+
Returns:
The newly created Heading object
"""
return Heading.create_and_add_to(self, level, style)
-
+
def blocks(self) -> Iterator[Block]:
"""
Iterate over the blocks in this cell.
-
+
Yields:
Each Block in the cell
"""
@@ -825,95 +864,106 @@ class TableRow(Block):
"""
A table row element containing table cells.
"""
-
+
def __init__(self, style=None):
"""
Initialize an empty table row
-
+
Args:
style: Optional default style for child cells
"""
super().__init__(BlockType.TABLE_ROW)
self._cells: List[TableCell] = []
self._style = style
-
+
@classmethod
- def create_and_add_to(cls, container, section: str = "body", style=None) -> 'TableRow':
+ def create_and_add_to(
+ cls,
+ container,
+ section: str = "body",
+ style=None) -> 'TableRow':
"""
Create a new TableRow and add it to a container, inheriting style from
the container if not explicitly provided.
-
+
Args:
container: The container to add the row to (must have add_row method)
section: The section to add the row to ("header", "body", or "footer")
style: Optional style override. If None, inherits from container
-
+
Returns:
The newly created TableRow object
-
+
Raises:
AttributeError: If the container doesn't have the required add_row method
"""
# Inherit style from container if not provided
if style is None and hasattr(container, 'style'):
style = container.style
-
+
# Create the new table row
row = cls(style)
-
+
# Add the row to the container
if hasattr(container, 'add_row'):
container.add_row(row, section)
else:
- raise AttributeError(f"Container {type(container).__name__} must have an 'add_row' method")
-
+ raise AttributeError(
+ f"Container {
+ type(container).__name__} must have an 'add_row' method")
+
return row
-
+
@property
def style(self):
"""Get the default style for this table row"""
return self._style
-
+
@style.setter
def style(self, style):
"""Set the default style for this table row"""
self._style = style
-
+
def add_cell(self, cell: TableCell):
"""
Add a cell to this row.
-
+
Args:
cell: The TableCell to add
"""
self._cells.append(cell)
cell.parent = self
-
- def create_cell(self, is_header: bool = False, colspan: int = 1, rowspan: int = 1, style=None) -> TableCell:
+
+ def create_cell(
+ self,
+ is_header: bool = False,
+ colspan: int = 1,
+ rowspan: int = 1,
+ style=None) -> TableCell:
"""
Create a new table cell and add it to this row.
-
+
Args:
is_header: Whether this cell is a header cell
colspan: Number of columns this cell spans
rowspan: Number of rows this cell spans
style: Optional style override. If None, inherits from row
-
+
Returns:
The newly created TableCell object
"""
return TableCell.create_and_add_to(self, is_header, colspan, rowspan, style)
-
+
def cells(self) -> Iterator[TableCell]:
"""
Iterate over the cells in this row.
-
+
Yields:
Each TableCell in the row
"""
for cell in self._cells:
yield cell
-
+
@property
def cell_count(self) -> int:
"""Get the number of cells in this row"""
@@ -924,11 +974,11 @@ class Table(Block):
"""
A table element containing rows and cells.
"""
-
+
def __init__(self, caption: Optional[str] = None, style=None):
"""
Initialize a table.
-
+
Args:
caption: Optional caption for the table
style: Optional default style for child rows
@@ -939,21 +989,25 @@ class Table(Block):
self._header_rows: List[TableRow] = []
self._footer_rows: List[TableRow] = []
self._style = style
-
+
@classmethod
- def create_and_add_to(cls, container, caption: Optional[str] = None, style=None) -> 'Table':
+ def create_and_add_to(
+ cls,
+ container,
+ caption: Optional[str] = None,
+ style=None) -> 'Table':
"""
Create a new Table and add it to a container, inheriting style from
the container if not explicitly provided.
-
+
Args:
container: The container to add the table to (must have add_block method)
caption: Optional caption for the table
style: Optional style override. If None, inherits from container
-
+
Returns:
The newly created Table object
-
+
Raises:
AttributeError: If the container doesn't have the required add_block method
"""
@@ -962,102 +1016,104 @@ class Table(Block):
style = container.style
elif style is None and hasattr(container, 'default_style'):
style = container.default_style
-
+
# Create the new table
table = cls(caption, style)
-
+
# Add the table to the container
if hasattr(container, 'add_block'):
container.add_block(table)
else:
- raise AttributeError(f"Container {type(container).__name__} must have an 'add_block' method")
-
+ raise AttributeError(
+ f"Container {
+ type(container).__name__} must have an 'add_block' method")
+
return table
-
+
@property
def caption(self) -> Optional[str]:
"""Get the table caption"""
return self._caption
-
+
@caption.setter
def caption(self, caption: Optional[str]):
"""Set the table caption"""
self._caption = caption
-
+
@property
def style(self):
"""Get the default style for this table"""
return self._style
-
+
@style.setter
def style(self, style):
"""Set the default style for this table"""
self._style = style
-
+
def add_row(self, row: TableRow, section: str = "body"):
"""
Add a row to this table.
-
+
Args:
row: The TableRow to add
section: The section to add the row to ("header", "body", or "footer")
"""
row.parent = self
-
+
if section.lower() == "header":
self._header_rows.append(row)
elif section.lower() == "footer":
self._footer_rows.append(row)
else: # Default to body
self._rows.append(row)
-
+
def create_row(self, section: str = "body", style=None) -> TableRow:
"""
Create a new table row and add it to this table.
-
+
Args:
section: The section to add the row to ("header", "body", or "footer")
style: Optional style override. If None, inherits from table
-
+
Returns:
The newly created TableRow object
"""
return TableRow.create_and_add_to(self, section, style)
-
+
def header_rows(self) -> Iterator[TableRow]:
"""
Iterate over the header rows in this table.
-
+
Yields:
Each TableRow in the header section
"""
for row in self._header_rows:
yield row
-
+
def body_rows(self) -> Iterator[TableRow]:
"""
Iterate over the body rows in this table.
-
+
Yields:
Each TableRow in the body section
"""
for row in self._rows:
yield row
-
+
def footer_rows(self) -> Iterator[TableRow]:
"""
Iterate over the footer rows in this table.
-
+
Yields:
Each TableRow in the footer section
"""
for row in self._footer_rows:
yield row
-
+
def all_rows(self) -> Iterator[Tuple[str, TableRow]]:
"""
Iterate over all rows in this table with their section labels.
-
+
Yields:
Tuples of (section, row) for each row in the table
"""
@@ -1067,7 +1123,7 @@ class Table(Block):
yield ("body", row)
for row in self._footer_rows:
yield ("footer", row)
-
+
@property
def row_count(self) -> Dict[str, int]:
"""Get the row counts by section"""
@@ -1083,11 +1139,16 @@ class Image(Block):
"""
An image element with source, dimensions, and alternative text.
"""
-
- def __init__(self, source: str = "", alt_text: str = "", width: Optional[int] = None, height: Optional[int] = None):
+
+ def __init__(
+ self,
+ source: str = "",
+ alt_text: str = "",
+ width: Optional[int] = None,
+ height: Optional[int] = None):
"""
Initialize an image element.
-
+
Args:
source: The image source URL or path
alt_text: Alternative text for accessibility
@@ -1099,191 +1160,202 @@ class Image(Block):
self._alt_text = alt_text
self._width = width
self._height = height
-
+
@classmethod
- def create_and_add_to(cls, container, source: str = "", alt_text: str = "",
- width: Optional[int] = None, height: Optional[int] = None) -> 'Image':
+ def create_and_add_to(
+ cls,
+ container,
+ source: str = "",
+ alt_text: str = "",
+ width: Optional[int] = None,
+ height: Optional[int] = None) -> 'Image':
"""
Create a new Image and add it to a container.
-
+
Args:
container: The container to add the image to (must have add_block method)
source: The image source URL or path
alt_text: Alternative text for accessibility
width: Optional image width in pixels
height: Optional image height in pixels
-
+
Returns:
The newly created Image object
-
+
Raises:
AttributeError: If the container doesn't have the required add_block method
"""
# Create the new image
image = cls(source, alt_text, width, height)
-
+
# Add the image to the container
if hasattr(container, 'add_block'):
container.add_block(image)
else:
- raise AttributeError(f"Container {type(container).__name__} must have an 'add_block' method")
-
+ raise AttributeError(
+ f"Container {
+ type(container).__name__} must have an 'add_block' method")
+
return image
-
+
@property
def source(self) -> str:
"""Get the image source"""
return self._source
-
+
@source.setter
def source(self, source: str):
"""Set the image source"""
self._source = source
-
+
@property
def alt_text(self) -> str:
"""Get the alternative text"""
return self._alt_text
-
+
@alt_text.setter
def alt_text(self, alt_text: str):
"""Set the alternative text"""
self._alt_text = alt_text
-
+
@property
def width(self) -> Optional[int]:
"""Get the image width"""
return self._width
-
+
@width.setter
def width(self, width: Optional[int]):
"""Set the image width"""
self._width = width
-
+
@property
def height(self) -> Optional[int]:
"""Get the image height"""
return self._height
-
+
@height.setter
def height(self, height: Optional[int]):
"""Set the image height"""
self._height = height
-
+
def get_dimensions(self) -> Tuple[Optional[int], Optional[int]]:
"""
Get the image dimensions as a tuple.
-
+
Returns:
Tuple of (width, height)
"""
return (self._width, self._height)
-
+
def get_aspect_ratio(self) -> Optional[float]:
"""
Calculate the aspect ratio of the image.
-
+
Returns:
The aspect ratio (width/height) or None if either dimension is missing
"""
if self._width is not None and self._height is not None and self._height > 0:
return self._width / self._height
return None
-
- def calculate_scaled_dimensions(self, max_width: Optional[int] = None,
- max_height: Optional[int] = None) -> Tuple[Optional[int], Optional[int]]:
+
+ def calculate_scaled_dimensions(self,
+ max_width: Optional[int] = None,
+ max_height: Optional[int] = None) -> Tuple[Optional[int],
+ Optional[int]]:
"""
Calculate scaled dimensions that fit within the given constraints.
-
+
Args:
max_width: Maximum allowed width
max_height: Maximum allowed height
-
+
Returns:
Tuple of (scaled_width, scaled_height)
"""
if self._width is None or self._height is None:
return (self._width, self._height)
-
+
width, height = self._width, self._height
-
+
# Scale down if needed
if max_width is not None and width > max_width:
height = int(height * max_width / width)
width = max_width
-
+
if max_height is not None and height > max_height:
width = int(width * max_height / height)
height = max_height
-
+
return (width, height)
-
+
def _is_url(self, source: str) -> bool:
"""
Check if the source is a URL.
-
+
Args:
source: The source string to check
-
+
Returns:
True if the source appears to be a URL, False otherwise
"""
parsed = urllib.parse.urlparse(source)
return bool(parsed.scheme and parsed.netloc)
-
+
def _download_to_temp(self, url: str) -> str:
"""
Download an image from a URL to a temporary file.
-
+
Args:
url: The URL to download from
-
+
Returns:
Path to the temporary file
-
+
Raises:
urllib.error.URLError: If the download fails
"""
# Create a temporary file
temp_fd, temp_path = tempfile.mkstemp(suffix='.tmp')
-
+
try:
# Download the image
with urllib.request.urlopen(url) as response:
# Write the response data to the temporary file
with os.fdopen(temp_fd, 'wb') as temp_file:
temp_file.write(response.read())
-
+
return temp_path
- except:
+ except BaseException:
# Clean up the temporary file if download fails
try:
os.close(temp_fd)
- except:
+ except BaseException:
pass
try:
os.unlink(temp_path)
- except:
+ except BaseException:
pass
raise
-
- def load_image_data(self, auto_update_dimensions: bool = True) -> Tuple[Optional[str], Optional[PILImage.Image]]:
+
+ def load_image_data(self,
+ auto_update_dimensions: bool = True) -> Tuple[Optional[str],
+ Optional[PILImage.Image]]:
"""
Load image data using PIL, handling both local files and URLs.
-
+
Args:
auto_update_dimensions: If True, automatically update width and height from the loaded image
-
+
Returns:
Tuple of (file_path, PIL_Image_object). For URLs, file_path is the temporary file path.
Returns (None, None) if loading fails.
"""
if not self._source:
return None, None
-
+
file_path = None
temp_file = None
-
+
try:
if self._is_url(self._source):
# Download to temporary file
@@ -1292,65 +1364,67 @@ class Image(Block):
else:
# Use local file path
file_path = self._source
-
+
# Open with PIL
with PILImage.open(file_path) as img:
# Load the image data
img.load()
-
+
# Update dimensions if requested
if auto_update_dimensions:
self._width, self._height = img.size
-
+
# Return a copy to avoid issues with the context manager
return file_path, img.copy()
-
- except Exception as e:
+
+ except Exception:
# Clean up temporary file on error
if temp_file and os.path.exists(temp_file):
try:
os.unlink(temp_file)
- except:
+ except BaseException:
pass
return None, None
-
+
def get_image_info(self) -> Dict[str, Any]:
"""
Get detailed information about the image using PIL.
-
+
Returns:
Dictionary containing image information including format, mode, size, etc.
Returns empty dict if image cannot be loaded.
"""
file_path, img = self.load_image_data(auto_update_dimensions=False)
-
+
if img is None:
return {}
-
+
# Try to determine format from the image, file extension, or source
img_format = img.format
if img_format is None:
# Try to determine format from file extension
format_map = {
'.jpg': 'JPEG',
- '.jpeg': 'JPEG',
+ '.jpeg': 'JPEG',
'.png': 'PNG',
'.gif': 'GIF',
'.bmp': 'BMP',
'.tiff': 'TIFF',
'.tif': 'TIFF'
}
-
+
# First try the actual file path if available
if file_path:
ext = os.path.splitext(file_path)[1].lower()
img_format = format_map.get(ext)
-
+
# If still no format and we have a URL source, try the original URL
if img_format is None and self._is_url(self._source):
- ext = os.path.splitext(urllib.parse.urlparse(self._source).path)[1].lower()
+ ext = os.path.splitext(
+ urllib.parse.urlparse(
+ self._source).path)[1].lower()
img_format = format_map.get(ext)
-
+
info = {
'format': img_format,
'mode': img.mode,
@@ -1358,18 +1432,18 @@ class Image(Block):
'width': img.width,
'height': img.height,
}
-
+
# Add additional info if available
if hasattr(img, 'info'):
info['info'] = img.info
-
+
# Clean up temporary file if it was created
if file_path and self._is_url(self._source):
try:
os.unlink(file_path)
- except:
+ except BaseException:
pass
-
+
return info
@@ -1377,16 +1451,16 @@ class LinkedImage(Image):
"""
An Image that is also a Link - clickable images that navigate or trigger callbacks.
"""
-
+
def __init__(self, source: str, alt_text: str, location: str,
width: Optional[int] = None, height: Optional[int] = None,
- link_type = None,
+ link_type=None,
callback: Optional[Any] = None,
params: Optional[Dict[str, Any]] = None,
title: Optional[str] = None):
"""
Initialize a linked image.
-
+
Args:
source: The image source URL or path
alt_text: Alternative text for accessibility
@@ -1400,7 +1474,7 @@ class LinkedImage(Image):
"""
# Initialize Image
super().__init__(source, alt_text, width, height)
-
+
# Store link properties
# Import here to avoid circular imports at module level
from pyWebLayout.abstract.functional import LinkType
@@ -1409,49 +1483,52 @@ class LinkedImage(Image):
self._callback = callback
self._params = params or {}
self._link_title = title
-
+
@property
def location(self) -> str:
"""Get the link target location"""
return self._location
-
+
@property
def link_type(self):
"""Get the type of link"""
return self._link_type
-
+
@property
def link_callback(self) -> Optional[Any]:
"""Get the link callback"""
return self._callback
-
+
@property
def params(self) -> Dict[str, Any]:
"""Get the link parameters"""
return self._params
-
+
@property
def link_title(self) -> Optional[str]:
"""Get the link title/tooltip"""
return self._link_title
-
+
def execute_link(self, context: Optional[Dict[str, Any]] = None) -> Any:
"""
Execute the link action.
-
+
Args:
context: Optional context dict (e.g., {'alt_text': image.alt_text})
-
+
Returns:
The result of the link execution
"""
from pyWebLayout.abstract.functional import LinkType
-
+
# Add image info to context
- full_context = {**self._params, 'alt_text': self._alt_text, 'source': self._source}
+ full_context = {
+ **self._params,
+ 'alt_text': self._alt_text,
+ 'source': self._source}
if context:
full_context.update(context)
-
+
if self._link_type in (LinkType.API, LinkType.FUNCTION) and self._callback:
return self._callback(self._location, **full_context)
else:
@@ -1463,71 +1540,75 @@ class HorizontalRule(Block):
"""
A horizontal rule element (hr tag).
"""
-
+
def __init__(self):
"""Initialize a horizontal rule element."""
super().__init__(BlockType.HORIZONTAL_RULE)
-
+
@classmethod
def create_and_add_to(cls, container) -> 'HorizontalRule':
"""
Create a new HorizontalRule and add it to a container.
-
+
Args:
container: The container to add the horizontal rule to (must have add_block method)
-
+
Returns:
The newly created HorizontalRule object
-
+
Raises:
AttributeError: If the container doesn't have the required add_block method
"""
# Create the new horizontal rule
hr = cls()
-
+
# Add the horizontal rule to the container
if hasattr(container, 'add_block'):
container.add_block(hr)
else:
- raise AttributeError(f"Container {type(container).__name__} must have an 'add_block' method")
-
+ raise AttributeError(
+ f"Container {
+ type(container).__name__} must have an 'add_block' method")
+
return hr
class PageBreak(Block):
"""
A page break element that forces content to start on a new page.
-
+
When encountered during layout, this block signals that all subsequent
content should be placed on a new page, even if the current page has
available space.
"""
-
+
def __init__(self):
"""Initialize a page break element."""
super().__init__(BlockType.PAGE_BREAK)
-
+
@classmethod
def create_and_add_to(cls, container) -> 'PageBreak':
"""
Create a new PageBreak and add it to a container.
-
+
Args:
container: The container to add the page break to (must have add_block method)
-
+
Returns:
The newly created PageBreak object
-
+
Raises:
AttributeError: If the container doesn't have the required add_block method
"""
# Create the new page break
page_break = cls()
-
+
# Add the page break to the container
if hasattr(container, 'add_block'):
container.add_block(page_break)
else:
- raise AttributeError(f"Container {type(container).__name__} must have an 'add_block' method")
-
+ raise AttributeError(
+ f"Container {
+ type(container).__name__} must have an 'add_block' method")
+
return page_break
diff --git a/pyWebLayout/abstract/document.py b/pyWebLayout/abstract/document.py
index ff52e2c..c5dfd5f 100644
--- a/pyWebLayout/abstract/document.py
+++ b/pyWebLayout/abstract/document.py
@@ -2,8 +2,6 @@ from __future__ import annotations
from typing import List, Dict, Optional, Tuple, Union, Any
from enum import Enum
from .block import Block, BlockType, Heading, HeadingLevel, Paragraph
-from .functional import Link, Button, Form
-from .inline import Word, FormattedSpan
from ..style import Font, FontWeight, FontStyle, TextDecoration
from ..style.abstract_style import AbstractStyle, AbstractStyleRegistry, FontFamily, FontSize
from ..style.concrete_style import ConcreteStyleRegistry, RenderingContext, StyleResolver
@@ -34,7 +32,11 @@ class Document(FontRegistry, MetadataContainer):
Uses MetadataContainer mixin for metadata management.
"""
- def __init__(self, title: Optional[str] = None, language: str = "en-US", default_style=None):
+ def __init__(
+ self,
+ title: Optional[str] = None,
+ language: str = "en-US",
+ default_style=None):
"""
Initialize a new document.
@@ -49,13 +51,13 @@ class Document(FontRegistry, MetadataContainer):
self._resources: Dict[str, Any] = {} # External resources like images
self._stylesheets: List[Dict[str, Any]] = [] # CSS stylesheets
self._scripts: List[str] = [] # JavaScript code
-
+
# Style management with new abstract/concrete system
self._abstract_style_registry = AbstractStyleRegistry()
self._rendering_context = RenderingContext(default_language=language)
self._style_resolver = StyleResolver(self._rendering_context)
self._concrete_style_registry = ConcreteStyleRegistry(self._style_resolver)
-
+
# Set default style
if default_style is None:
# Create a default abstract style
@@ -68,45 +70,46 @@ class Document(FontRegistry, MetadataContainer):
color=default_style.colour,
language=default_style.language
)
- style_id, default_style = self._abstract_style_registry.get_or_create_style(default_style)
+ style_id, default_style = self._abstract_style_registry.get_or_create_style(
+ default_style)
self._default_style = default_style
# Set basic metadata
if title:
self.set_metadata(MetadataType.TITLE, title)
self.set_metadata(MetadataType.LANGUAGE, language)
-
+
@property
def blocks(self) -> List[Block]:
"""Get the top-level blocks in this document"""
return self._blocks
-
+
@property
def default_style(self):
"""Get the default style for this document"""
return self._default_style
-
+
@default_style.setter
def default_style(self, style):
"""Set the default style for this document"""
self._default_style = style
-
+
def add_block(self, block: Block):
"""
Add a block to this document.
-
+
Args:
block: The block to add
"""
self._blocks.append(block)
-
+
def create_paragraph(self, style=None) -> Paragraph:
"""
Create a new paragraph and add it to this document.
-
+
Args:
style: Optional style override. If None, inherits from document
-
+
Returns:
The newly created Paragraph object
"""
@@ -115,15 +118,18 @@ class Document(FontRegistry, MetadataContainer):
paragraph = Paragraph(style)
self.add_block(paragraph)
return paragraph
-
- def create_heading(self, level: HeadingLevel = HeadingLevel.H1, style=None) -> Heading:
+
+ def create_heading(
+ self,
+ level: HeadingLevel = HeadingLevel.H1,
+ style=None) -> Heading:
"""
Create a new heading and add it to this document.
-
+
Args:
level: The heading level
style: Optional style override. If None, inherits from document
-
+
Returns:
The newly created Heading object
"""
@@ -132,16 +138,20 @@ class Document(FontRegistry, MetadataContainer):
heading = Heading(level, style)
self.add_block(heading)
return heading
-
- def create_chapter(self, title: Optional[str] = None, level: int = 1, style=None) -> 'Chapter':
+
+ def create_chapter(
+ self,
+ title: Optional[str] = None,
+ level: int = 1,
+ style=None) -> 'Chapter':
"""
Create a new chapter with inherited style.
-
+
Args:
title: The chapter title
level: The chapter level
style: Optional style override. If None, inherits from document
-
+
Returns:
The newly created Chapter object
"""
@@ -154,148 +164,148 @@ class Document(FontRegistry, MetadataContainer):
def add_anchor(self, name: str, target: Block):
"""
Add a named anchor to this document.
-
+
Args:
name: The anchor name
target: The target block
"""
self._anchors[name] = target
-
+
def get_anchor(self, name: str) -> Optional[Block]:
"""
Get a named anchor from this document.
-
+
Args:
name: The anchor name
-
+
Returns:
The target block, or None if not found
"""
return self._anchors.get(name)
-
+
def add_resource(self, name: str, resource: Any):
"""
Add a resource to this document.
-
+
Args:
name: The resource name
resource: The resource data
"""
self._resources[name] = resource
-
+
def get_resource(self, name: str) -> Optional[Any]:
"""
Get a resource from this document.
-
+
Args:
name: The resource name
-
+
Returns:
The resource data, or None if not found
"""
return self._resources.get(name)
-
+
def add_stylesheet(self, stylesheet: Dict[str, Any]):
"""
Add a stylesheet to this document.
-
+
Args:
stylesheet: The stylesheet data
"""
self._stylesheets.append(stylesheet)
-
+
def add_script(self, script: str):
"""
Add a script to this document.
-
+
Args:
script: The script code
"""
self._scripts.append(script)
-
+
def get_title(self) -> Optional[str]:
"""
Get the document title.
-
+
Returns:
The document title, or None if not set
"""
return self.get_metadata(MetadataType.TITLE)
-
+
def set_title(self, title: str):
"""
Set the document title.
-
+
Args:
title: The document title
"""
self.set_metadata(MetadataType.TITLE, title)
-
+
@property
def title(self) -> Optional[str]:
"""
Get the document title as a property.
-
+
Returns:
The document title, or None if not set
"""
return self.get_title()
-
+
@title.setter
def title(self, title: str):
"""
Set the document title as a property.
-
+
Args:
title: The document title
"""
self.set_title(title)
-
+
def find_blocks_by_type(self, block_type: BlockType) -> List[Block]:
"""
Find all blocks of a specific type.
-
+
Args:
block_type: The type of blocks to find
-
+
Returns:
A list of matching blocks
"""
result = []
-
+
def _find_recursive(blocks: List[Block]):
for block in blocks:
if block.block_type == block_type:
result.append(block)
-
+
# Check for child blocks based on block type
if hasattr(block, '_blocks'):
_find_recursive(block._blocks)
elif hasattr(block, '_items') and isinstance(block._items, list):
_find_recursive(block._items)
-
+
_find_recursive(self._blocks)
return result
-
+
def find_headings(self) -> List[Heading]:
"""
Find all headings in the document.
-
+
Returns:
A list of heading blocks
"""
blocks = self.find_blocks_by_type(BlockType.HEADING)
return [block for block in blocks if isinstance(block, Heading)]
-
+
def generate_table_of_contents(self) -> List[Tuple[int, str, Block]]:
"""
Generate a table of contents from headings.
-
+
Returns:
A list of tuples containing (level, title, heading_block)
"""
headings = self.find_headings()
-
+
toc = []
for heading in headings:
# Extract text from the heading
@@ -303,26 +313,26 @@ class Document(FontRegistry, MetadataContainer):
for _, word in heading.words_iter():
title += word.text + " "
title = title.strip()
-
+
# Add to TOC
level = heading.level.value # Get numeric value from HeadingLevel enum
toc.append((level, title, heading))
-
+
return toc
-
- def get_or_create_style(self,
- font_family: FontFamily = FontFamily.SERIF,
- font_size: Union[FontSize, int] = FontSize.MEDIUM,
- font_weight: FontWeight = FontWeight.NORMAL,
- font_style: FontStyle = FontStyle.NORMAL,
- text_decoration: TextDecoration = TextDecoration.NONE,
- color: Union[str, Tuple[int, int, int]] = "black",
- background_color: Optional[Union[str, Tuple[int, int, int, int]]] = None,
- language: str = "en-US",
- **kwargs) -> Tuple[str, AbstractStyle]:
+
+ def get_or_create_style(self,
+ font_family: FontFamily = FontFamily.SERIF,
+ font_size: Union[FontSize, int] = FontSize.MEDIUM,
+ font_weight: FontWeight = FontWeight.NORMAL,
+ font_style: FontStyle = FontStyle.NORMAL,
+ text_decoration: TextDecoration = TextDecoration.NONE,
+ color: Union[str, Tuple[int, int, int]] = "black",
+ background_color: Optional[Union[str, Tuple[int, int, int, int]]] = None,
+ language: str = "en-US",
+ **kwargs) -> Tuple[str, AbstractStyle]:
"""
Get or create an abstract style with the specified properties.
-
+
Args:
font_family: Semantic font family
font_size: Font size (semantic or numeric)
@@ -333,7 +343,7 @@ class Document(FontRegistry, MetadataContainer):
background_color: Background color
language: Language code
**kwargs: Additional style properties
-
+
Returns:
Tuple of (style_id, AbstractStyle)
"""
@@ -348,34 +358,34 @@ class Document(FontRegistry, MetadataContainer):
language=language,
**kwargs
)
-
+
return self._abstract_style_registry.get_or_create_style(abstract_style)
-
+
def get_font_for_style(self, abstract_style: AbstractStyle) -> Font:
"""
Get a Font object for an AbstractStyle (for rendering).
-
+
Args:
abstract_style: The abstract style to get a font for
-
+
Returns:
Font object ready for rendering
"""
return self._concrete_style_registry.get_font(abstract_style)
-
+
def update_rendering_context(self, **kwargs):
"""
Update the rendering context (user preferences, device settings, etc.).
-
+
Args:
**kwargs: Context properties to update (base_font_size, font_scale_factor, etc.)
"""
self._style_resolver.update_context(**kwargs)
-
+
def get_style_registry(self) -> AbstractStyleRegistry:
"""Get the abstract style registry for this document."""
return self._abstract_style_registry
-
+
def get_concrete_style_registry(self) -> ConcreteStyleRegistry:
"""Get the concrete style registry for this document."""
return self._concrete_style_registry
@@ -392,7 +402,12 @@ class Chapter(FontRegistry, MetadataContainer):
Uses MetadataContainer mixin for metadata management.
"""
- def __init__(self, title: Optional[str] = None, level: int = 1, style=None, parent=None):
+ def __init__(
+ self,
+ title: Optional[str] = None,
+ level: int = 1,
+ style=None,
+ parent=None):
"""
Initialize a new chapter.
@@ -408,53 +423,53 @@ class Chapter(FontRegistry, MetadataContainer):
self._blocks: List[Block] = []
self._style = style
self._parent = parent
-
+
@property
def title(self) -> Optional[str]:
"""Get the chapter title"""
return self._title
-
+
@title.setter
def title(self, title: str):
"""Set the chapter title"""
self._title = title
-
+
@property
def level(self) -> int:
"""Get the chapter level"""
return self._level
-
+
@property
def blocks(self) -> List[Block]:
"""Get the blocks in this chapter"""
return self._blocks
-
+
@property
def style(self):
"""Get the default style for this chapter"""
return self._style
-
+
@style.setter
def style(self, style):
"""Set the default style for this chapter"""
self._style = style
-
+
def add_block(self, block: Block):
"""
Add a block to this chapter.
-
+
Args:
block: The block to add
"""
self._blocks.append(block)
-
+
def create_paragraph(self, style=None) -> Paragraph:
"""
Create a new paragraph and add it to this chapter.
-
+
Args:
style: Optional style override. If None, inherits from chapter
-
+
Returns:
The newly created Paragraph object
"""
@@ -463,15 +478,18 @@ class Chapter(FontRegistry, MetadataContainer):
paragraph = Paragraph(style)
self.add_block(paragraph)
return paragraph
-
- def create_heading(self, level: HeadingLevel = HeadingLevel.H1, style=None) -> Heading:
+
+ def create_heading(
+ self,
+ level: HeadingLevel = HeadingLevel.H1,
+ style=None) -> Heading:
"""
Create a new heading and add it to this chapter.
-
+
Args:
level: The heading level
style: Optional style override. If None, inherits from chapter
-
+
Returns:
The newly created Heading object
"""
@@ -490,12 +508,12 @@ class Book(Document):
Abstract representation of an ebook.
A book is a document that contains chapters.
"""
-
- def __init__(self, title: Optional[str] = None, author: Optional[str] = None,
+
+ def __init__(self, title: Optional[str] = None, author: Optional[str] = None,
language: str = "en-US", default_style=None):
"""
Initialize a new book.
-
+
Args:
title: The book title
author: The book author
@@ -504,33 +522,37 @@ class Book(Document):
"""
super().__init__(title, language, default_style)
self._chapters: List[Chapter] = []
-
+
if author:
self.set_metadata(MetadataType.AUTHOR, author)
-
+
@property
def chapters(self) -> List[Chapter]:
"""Get the chapters in this book"""
return self._chapters
-
+
def add_chapter(self, chapter: Chapter):
"""
Add a chapter to this book.
-
+
Args:
chapter: The chapter to add
"""
self._chapters.append(chapter)
-
- def create_chapter(self, title: Optional[str] = None, level: int = 1, style=None) -> Chapter:
+
+ def create_chapter(
+ self,
+ title: Optional[str] = None,
+ level: int = 1,
+ style=None) -> Chapter:
"""
Create and add a new chapter with inherited style.
-
+
Args:
title: The chapter title
level: The chapter level
style: Optional style override. If None, inherits from book
-
+
Returns:
The new chapter
"""
@@ -539,29 +561,29 @@ class Book(Document):
chapter = Chapter(title, level, style)
self.add_chapter(chapter)
return chapter
-
+
def get_author(self) -> Optional[str]:
"""
Get the book author.
-
+
Returns:
The book author, or None if not set
"""
return self.get_metadata(MetadataType.AUTHOR)
-
+
def set_author(self, author: str):
"""
Set the book author.
-
+
Args:
author: The book author
"""
self.set_metadata(MetadataType.AUTHOR, author)
-
+
def generate_table_of_contents(self) -> List[Tuple[int, str, Chapter]]:
"""
Generate a table of contents from chapters.
-
+
Returns:
A list of tuples containing (level, title, chapter)
"""
@@ -569,5 +591,5 @@ class Book(Document):
for chapter in self._chapters:
if chapter.title:
toc.append((chapter.level, chapter.title, chapter))
-
+
return toc
diff --git a/pyWebLayout/abstract/functional.py b/pyWebLayout/abstract/functional.py
index 84d3994..5200136 100644
--- a/pyWebLayout/abstract/functional.py
+++ b/pyWebLayout/abstract/functional.py
@@ -1,6 +1,6 @@
from __future__ import annotations
from enum import Enum
-from typing import Callable, Dict, Any, Optional, Union, List, Tuple
+from typing import Callable, Dict, Any, Optional, List, Tuple
from pyWebLayout.core.base import Interactable
@@ -18,7 +18,7 @@ class Link(Interactable):
Links can be used for navigation within a document, to external resources,
or to trigger API calls for functionality like settings management.
"""
-
+
def __init__(self,
location: str,
link_type: LinkType = LinkType.INTERNAL,
@@ -43,22 +43,22 @@ class Link(Interactable):
self._params = params or {}
self._title = title
self._html_id = html_id
-
+
@property
def location(self) -> str:
"""Get the target location of this link"""
return self._location
-
+
@property
def link_type(self) -> LinkType:
"""Get the type of this link"""
return self._link_type
-
+
@property
def params(self) -> Dict[str, Any]:
"""Get the parameters for this link"""
return self._params
-
+
@property
def title(self) -> Optional[str]:
"""Get the title/tooltip for this link"""
@@ -95,7 +95,7 @@ class Button(Interactable):
A button that can be clicked to execute an action.
Buttons are similar to function links but are rendered differently.
"""
-
+
def __init__(self,
label: str,
callback: Callable,
@@ -117,27 +117,27 @@ class Button(Interactable):
self._params = params or {}
self._enabled = enabled
self._html_id = html_id
-
+
@property
def label(self) -> str:
"""Get the button label"""
return self._label
-
+
@label.setter
def label(self, label: str):
"""Set the button label"""
self._label = label
-
+
@property
def enabled(self) -> bool:
"""Check if the button is enabled"""
return self._enabled
-
+
@enabled.setter
def enabled(self, enabled: bool):
"""Enable or disable the button"""
self._enabled = enabled
-
+
@property
def params(self) -> Dict[str, Any]:
"""Get the button parameters"""
@@ -168,7 +168,7 @@ class Form(Interactable):
A form that can contain input fields and be submitted.
Forms can be used for user input and settings configuration.
"""
-
+
def __init__(self,
form_id: str,
action: Optional[str] = None,
@@ -188,12 +188,12 @@ class Form(Interactable):
self._action = action
self._fields: Dict[str, FormField] = {}
self._html_id = html_id
-
+
@property
def form_id(self) -> str:
"""Get the form ID"""
return self._form_id
-
+
@property
def action(self) -> Optional[str]:
"""Get the form action"""
@@ -207,46 +207,46 @@ class Form(Interactable):
def add_field(self, field: FormField):
"""
Add a field to this form.
-
+
Args:
field: The FormField to add
"""
self._fields[field.name] = field
field.form = self
-
+
def get_field(self, name: str) -> Optional[FormField]:
"""
Get a field by name.
-
+
Args:
name: The name of the field to get
-
+
Returns:
The FormField with the specified name, or None if not found
"""
return self._fields.get(name)
-
+
def get_values(self) -> Dict[str, Any]:
"""
Get the current values of all fields in this form.
-
+
Returns:
A dictionary mapping field names to their current values
"""
return {name: field.value for name, field in self._fields.items()}
-
+
def execute(self) -> Any:
"""
Submit the form, executing the callback with the form values.
-
+
Returns:
The result of the callback function, or the form values if no callback is provided.
"""
values = self.get_values()
-
+
if self._callback:
return self._callback(self._form_id, values)
-
+
return values
@@ -272,8 +272,8 @@ class FormField:
"""
A field in a form that can accept user input.
"""
-
- def __init__(self,
+
+ def __init__(self,
name: str,
field_type: FormFieldType,
label: Optional[str] = None,
@@ -282,7 +282,7 @@ class FormField:
options: Optional[List[Tuple[str, str]]] = None):
"""
Initialize a form field.
-
+
Args:
name: The name of this field
field_type: The type of this field
@@ -298,47 +298,47 @@ class FormField:
self._required = required
self._options = options or []
self._form: Optional[Form] = None
-
+
@property
def name(self) -> str:
"""Get the field name"""
return self._name
-
+
@property
def field_type(self) -> FormFieldType:
"""Get the field type"""
return self._field_type
-
+
@property
def label(self) -> str:
"""Get the field label"""
return self._label
-
+
@property
def value(self) -> Any:
"""Get the current field value"""
return self._value
-
+
@value.setter
def value(self, value: Any):
"""Set the field value"""
self._value = value
-
+
@property
def required(self) -> bool:
"""Check if the field is required"""
return self._required
-
+
@property
def options(self) -> List[Tuple[str, str]]:
"""Get the field options"""
return self._options
-
+
@property
def form(self) -> Optional[Form]:
"""Get the form containing this field"""
return self._form
-
+
@form.setter
def form(self, form: Form):
"""Set the form containing this field"""
diff --git a/pyWebLayout/abstract/inline.py b/pyWebLayout/abstract/inline.py
index f2fdc5d..2cae0a9 100644
--- a/pyWebLayout/abstract/inline.py
+++ b/pyWebLayout/abstract/inline.py
@@ -1,5 +1,4 @@
from __future__ import annotations
-from pyWebLayout.core.base import Queriable
from pyWebLayout.core import Hierarchical
from pyWebLayout.style import Font
from pyWebLayout.style.abstract_style import AbstractStyle
@@ -10,20 +9,25 @@ import pyphen
from pyWebLayout.abstract.functional import LinkType
-
class Word:
"""
An abstract representation of a word in a document. Words can be split across
lines or pages during rendering. This class manages the logical representation
of a word without any rendering specifics.
-
+
Now uses AbstractStyle objects for memory efficiency and proper style management.
"""
- def __init__(self, text: str, style: Union[Font, AbstractStyle], background=None, previous: Union['Word', None] = None):
+ def __init__(self,
+ text: str,
+ style: Union[Font,
+ AbstractStyle],
+ background=None,
+ previous: Union['Word',
+ None] = None):
"""
Initialize a new Word.
-
+
Args:
text: The text content of the word
style: AbstractStyle object or Font object (for backward compatibility)
@@ -40,25 +44,25 @@ class Word:
previous.add_next(self)
@classmethod
- def create_and_add_to(cls, text: str, container, style: Optional[Font] = None,
- background=None) -> 'Word':
+ def create_and_add_to(cls, text: str, container, style: Optional[Font] = None,
+ background=None) -> 'Word':
"""
Create a new Word and add it to a container, inheriting style and language
from the container if not explicitly provided.
-
+
This method provides a convenient way to create words that automatically
inherit styling from their container (Paragraph, FormattedSpan, etc.)
without copying string values - using object references instead.
-
+
Args:
text: The text content of the word
container: The container to add the word to (must have add_word method and style property)
style: Optional Font style override. If None, inherits from container
background: Optional background color override. If None, inherits from container
-
+
Returns:
The newly created Word object
-
+
Raises:
AttributeError: If the container doesn't have the required add_word method or style property
"""
@@ -67,12 +71,14 @@ class Word:
if hasattr(container, 'style'):
style = container.style
else:
- raise AttributeError(f"Container {type(container).__name__} must have a 'style' property")
-
+ raise AttributeError(
+ f"Container {
+ type(container).__name__} must have a 'style' property")
+
# Inherit background from container if not provided
if background is None and hasattr(container, 'background'):
background = container.background
-
+
# Determine the previous word for proper linking
previous = None
if hasattr(container, '_words') and container._words:
@@ -86,21 +92,21 @@ class Word:
previous = word
except (StopIteration, TypeError):
previous = None
-
+
# Create the new word
word = cls(text, style, background, previous)
-
+
# Link the previous word to this new one
if previous:
previous.add_next(word)
-
+
# Add the word to the container
if hasattr(container, 'add_word'):
# Check if add_word expects a Word object or text string
import inspect
sig = inspect.signature(container.add_word)
params = list(sig.parameters.keys())
-
+
if len(params) > 0:
# Peek at the parameter name to guess the expected type
param_name = params[0]
@@ -110,7 +116,8 @@ class Word:
else:
# Might expect text string (like FormattedSpan.add_word)
# In this case, we can't use the container's add_word as it would create
- # a duplicate Word. We need to add directly to the container's word list.
+ # a duplicate Word. We need to add directly to the container's word
+ # list.
if hasattr(container, '_words'):
container._words.append(word)
else:
@@ -120,72 +127,72 @@ class Word:
# No parameters, shouldn't happen with add_word methods
container.add_word(word)
else:
- raise AttributeError(f"Container {type(container).__name__} must have an 'add_word' method")
-
+ raise AttributeError(
+ f"Container {
+ type(container).__name__} must have an 'add_word' method")
+
return word
-
-
- def add_concete(self, text: Union[Any, Tuple[Any,Any]]):
+
+ def add_concete(self, text: Union[Any, Tuple[Any, Any]]):
self.concrete = text
@property
def text(self) -> str:
"""Get the text content of the word"""
return self._text
-
+
@property
def style(self) -> Font:
"""Get the font style of the word"""
return self._style
-
+
@property
def background(self):
"""Get the background color of the word"""
return self._background
-
+
@property
def previous(self) -> Union['Word', None]:
"""Get the previous word in sequence"""
return self._previous
-
+
@property
def next(self) -> Union['Word', None]:
"""Get the next word in sequence"""
return self._next
-
def add_next(self, next_word: 'Word'):
"""Set the next word in sequence"""
self._next = next_word
-
-
+
def possible_hyphenation(self, language: str = None) -> bool:
"""
Hyphenate the word and store the parts.
-
+
Args:
language: Language code for hyphenation. If None, uses the style's language.
-
+
Returns:
bool: True if the word was hyphenated, False otherwise.
"""
dic = pyphen.Pyphen(lang=self._style.language)
return list(dic.iterate(self._text))
-...
+...
+
class FormattedSpan:
"""
A run of words with consistent formatting.
This represents a sequence of words that share the same style attributes.
"""
-
+
def __init__(self, style: Font, background=None):
"""
Initialize a new formatted span.
-
+
Args:
style: Font style information for all words in this span
background: Optional background color override
@@ -193,21 +200,25 @@ class FormattedSpan:
self._style = style
self._background = background if background else style.background
self._words: List[Word] = []
-
+
@classmethod
- def create_and_add_to(cls, container, style: Optional[Font] = None, background=None) -> 'FormattedSpan':
+ def create_and_add_to(
+ cls,
+ container,
+ style: Optional[Font] = None,
+ background=None) -> 'FormattedSpan':
"""
Create a new FormattedSpan and add it to a container, inheriting style from
the container if not explicitly provided.
-
+
Args:
container: The container to add the span to (must have add_span method and style property)
style: Optional Font style override. If None, inherits from container
background: Optional background color override
-
+
Returns:
The newly created FormattedSpan object
-
+
Raises:
AttributeError: If the container doesn't have the required add_span method or style property
"""
@@ -216,72 +227,76 @@ class FormattedSpan:
if hasattr(container, 'style'):
style = container.style
else:
- raise AttributeError(f"Container {type(container).__name__} must have a 'style' property")
-
+ raise AttributeError(
+ f"Container {
+ type(container).__name__} must have a 'style' property")
+
# Inherit background from container if not provided
if background is None and hasattr(container, 'background'):
background = container.background
-
+
# Create the new span
span = cls(style, background)
-
+
# Add the span to the container
if hasattr(container, 'add_span'):
container.add_span(span)
else:
- raise AttributeError(f"Container {type(container).__name__} must have an 'add_span' method")
-
+ raise AttributeError(
+ f"Container {
+ type(container).__name__} must have an 'add_span' method")
+
return span
-
+
@property
def style(self) -> Font:
"""Get the font style of this span"""
return self._style
-
+
@property
def background(self):
"""Get the background color of this span"""
return self._background
-
+
@property
def words(self) -> List[Word]:
"""Get the list of words in this span"""
return self._words
-
+
def add_word(self, text: str) -> Word:
"""
Create and add a new word to this span.
-
+
Args:
text: The text content of the word
-
+
Returns:
The newly created Word object
"""
# Get the previous word if any
previous = self._words[-1] if self._words else None
-
+
# Create the new word
word = Word(text, self._style, self._background, previous)
-
+
# Link the previous word to this new one
if previous:
previous.add_next(word)
-
+
# Add the word to our list
self._words.append(word)
-
+
return word
class LinkedWord(Word):
"""
A Word that is also a Link - combines text content with hyperlink functionality.
-
+
When a word is part of a hyperlink, it becomes clickable and can trigger
navigation or callbacks. Multiple words can share the same link destination.
"""
-
+
def __init__(self, text: str, style: Union[Font, 'AbstractStyle'],
location: str, link_type: Optional['LinkType'] = None,
callback: Optional[Callable] = None,
@@ -290,7 +305,7 @@ class LinkedWord(Word):
title: Optional[str] = None):
"""
Initialize a linked word.
-
+
Args:
text: The text content of the word
style: The font style
@@ -304,46 +319,46 @@ class LinkedWord(Word):
"""
# Initialize Word first
super().__init__(text, style, background, previous)
-
+
# Store link properties
self._location = location
self._link_type = link_type or LinkType.EXTERNAL
self._callback = callback
self._params = params or {}
self._title = title
-
+
@property
def location(self) -> str:
"""Get the link target location"""
return self._location
-
+
@property
def link_type(self):
"""Get the type of link"""
return self._link_type
-
+
@property
def link_callback(self) -> Optional[Callable]:
"""Get the link callback (distinct from word callback)"""
return self._callback
-
+
@property
def params(self) -> Dict[str, Any]:
"""Get the link parameters"""
return self._params
-
+
@property
def link_title(self) -> Optional[str]:
"""Get the link title/tooltip"""
return self._title
-
+
def execute_link(self, context: Optional[Dict[str, Any]] = None) -> Any:
"""
Execute the link action.
-
+
Args:
context: Optional context dict (e.g., {'text': word.text})
-
+
Returns:
The result of the link execution
"""
@@ -351,7 +366,7 @@ class LinkedWord(Word):
full_context = {**self._params, 'text': self._text}
if context:
full_context.update(context)
-
+
if self._link_type in (LinkType.API, LinkType.FUNCTION) and self._callback:
return self._callback(self._location, **full_context)
else:
@@ -379,21 +394,21 @@ class LineBreak(Hierarchical):
def block_type(self):
"""Get the block type for this line break"""
return self._block_type
-
+
@classmethod
def create_and_add_to(cls, container) -> 'LineBreak':
"""
Create a new LineBreak and add it to a container.
-
+
Args:
container: The container to add the line break to
-
+
Returns:
The newly created LineBreak object
"""
# Create the new line break
line_break = cls()
-
+
# Add the line break to the container if it has an appropriate method
if hasattr(container, 'add_line_break'):
container.add_line_break(line_break)
@@ -405,5 +420,5 @@ class LineBreak(Hierarchical):
else:
# Set parent relationship manually
line_break.parent = container
-
+
return line_break
diff --git a/pyWebLayout/abstract/interactive_image.py b/pyWebLayout/abstract/interactive_image.py
index 453ac0b..f9b2511 100644
--- a/pyWebLayout/abstract/interactive_image.py
+++ b/pyWebLayout/abstract/interactive_image.py
@@ -9,7 +9,7 @@ proper bounding box detection.
from typing import Optional, Callable, Tuple
import numpy as np
-from .block import Image, BlockType
+from .block import Image
from ..core.base import Interactable, Queriable
@@ -54,7 +54,12 @@ class InteractiveImage(Image, Interactable, Queriable):
callback: Function to call when image is tapped (receives point coordinates)
"""
# Initialize Image
- Image.__init__(self, source=source, alt_text=alt_text, width=width, height=height)
+ Image.__init__(
+ self,
+ source=source,
+ alt_text=alt_text,
+ width=width,
+ height=height)
# Initialize Interactable
Interactable.__init__(self, callback=callback)
diff --git a/pyWebLayout/concrete/__init__.py b/pyWebLayout/concrete/__init__.py
index 3501b05..0c91b87 100644
--- a/pyWebLayout/concrete/__init__.py
+++ b/pyWebLayout/concrete/__init__.py
@@ -1,6 +1,25 @@
-from .box import Box
-from .page import Page
+"""
+Concrete layer for the pyWebLayout library.
+
+This package contains concrete implementations that can be directly rendered.
+"""
+
from .text import Text, Line
-from .functional import LinkText, ButtonText, FormFieldText, create_link_text, create_button_text, create_form_field_text
+from .box import Box
from .image import RenderableImage
-from .table import TableRenderer, TableRowRenderer, TableCellRenderer, TableStyle
+from .page import Page
+from pyWebLayout.abstract.block import Table, TableRow as Row, TableCell as Cell
+from .functional import LinkText, ButtonText
+
+__all__ = [
+ 'Text',
+ 'Line',
+ 'Box',
+ 'RenderableImage',
+ 'Page',
+ 'Table',
+ 'Row',
+ 'Cell',
+ 'LinkText',
+ 'ButtonText',
+]
diff --git a/pyWebLayout/concrete/box.py b/pyWebLayout/concrete/box.py
index 88cb74f..8d37864 100644
--- a/pyWebLayout/concrete/box.py
+++ b/pyWebLayout/concrete/box.py
@@ -1,12 +1,12 @@
from __future__ import annotations
import numpy as np
from PIL import Image
-from typing import Tuple, Union, List, Optional, Dict
from pyWebLayout.core.base import Renderable, Queriable
from pyWebLayout.core import Geometric
from pyWebLayout.style import Alignment
+
class Box(Geometric, Renderable, Queriable):
"""
A box with geometric properties (origin and size).
@@ -14,12 +14,20 @@ class Box(Geometric, Renderable, Queriable):
Uses Geometric mixin for origin and size management.
"""
- def __init__(self,origin, size, callback = None, sheet : Image = None, mode: bool = None, halign=Alignment.CENTER, valign = Alignment.CENTER):
+ def __init__(
+ self,
+ origin,
+ size,
+ callback=None,
+ sheet: Image = None,
+ mode: bool = None,
+ halign=Alignment.CENTER,
+ valign=Alignment.CENTER):
super().__init__(origin=origin, size=size)
- self._end = self._origin + self._size
+ self._end = self._origin + self._size
self._callback = callback
- self._sheet : Image = sheet
- if self._sheet == None:
+ self._sheet: Image = sheet
+ if self._sheet is None:
self._mode = mode
else:
self._mode = sheet.mode
diff --git a/pyWebLayout/concrete/functional.py b/pyWebLayout/concrete/functional.py
index 5e6d9e6..3e3d917 100644
--- a/pyWebLayout/concrete/functional.py
+++ b/pyWebLayout/concrete/functional.py
@@ -1,10 +1,10 @@
from __future__ import annotations
-from typing import Optional, Dict, Any, Tuple, List, Union
+from typing import Optional, Tuple
import numpy as np
-from PIL import Image, ImageDraw, ImageFont
+from PIL import ImageDraw
from pyWebLayout.core.base import Interactable, Queriable
-from pyWebLayout.abstract.functional import Link, Button, Form, FormField, LinkType, FormFieldType
+from pyWebLayout.abstract.functional import Link, Button, FormField, LinkType, FormFieldType
from pyWebLayout.style import Font, TextDecoration
from .text import Text
@@ -14,12 +14,12 @@ class LinkText(Text, Interactable, Queriable):
A Text subclass that can handle Link interactions.
Combines text rendering with clickable link functionality.
"""
-
- def __init__(self, link: Link, text: str, font: Font, draw: ImageDraw.Draw,
+
+ def __init__(self, link: Link, text: str, font: Font, draw: ImageDraw.Draw,
source=None, line=None):
"""
Initialize a linkable text object.
-
+
Args:
link: The abstract Link object to handle interactions
text: The text content to render
@@ -33,35 +33,35 @@ class LinkText(Text, Interactable, Queriable):
if link.link_type == LinkType.INTERNAL:
link_font = link_font.with_colour((0, 0, 200)) # Blue for internal links
elif link.link_type == LinkType.EXTERNAL:
- link_font = link_font.with_colour((0, 0, 180)) # Darker blue for external links
+ link_font = link_font.with_colour(
+ (0, 0, 180)) # Darker blue for external links
elif link.link_type == LinkType.API:
link_font = link_font.with_colour((150, 0, 0)) # Red for API links
elif link.link_type == LinkType.FUNCTION:
link_font = link_font.with_colour((0, 120, 0)) # Green for function links
-
+
# Initialize Text with the styled font
Text.__init__(self, text, link_font, draw, source, line)
-
+
# Initialize Interactable with the link's execute method
Interactable.__init__(self, link.execute)
-
+
# Store the link object
self._link = link
self._hovered = False
-
+
# Ensure _origin is initialized as numpy array
if not hasattr(self, '_origin') or self._origin is None:
self._origin = np.array([0, 0])
-
+
@property
def link(self) -> Link:
"""Get the associated Link object"""
return self._link
-
+
def set_hovered(self, hovered: bool):
"""Set the hover state for visual feedback"""
self._hovered = hovered
-
def render(self, next_text: Optional['Text'] = None, spacing: int = 0):
"""
@@ -73,12 +73,12 @@ class LinkText(Text, Interactable, Queriable):
"""
# Call the parent Text render method with parameters
super().render(next_text, spacing)
-
+
# Add hover effect if needed
if self._hovered:
# Draw a subtle highlight background
highlight_color = (220, 220, 255, 100) # Light blue with alpha
-
+
# Handle mock objects in tests
size = self.size
if hasattr(size, '__call__'): # It's a Mock
@@ -86,13 +86,15 @@ class LinkText(Text, Interactable, Queriable):
size = np.array([100, 20])
else:
size = np.array(size)
-
+
# Ensure origin is a numpy array
- origin = np.array(self._origin) if not isinstance(self._origin, np.ndarray) else self._origin
-
- self._draw.rectangle([origin, origin + size],
- fill=highlight_color)
-
+ origin = np.array(
+ self._origin) if not isinstance(
+ self._origin,
+ np.ndarray) else self._origin
+
+ self._draw.rectangle([origin, origin + size],
+ fill=highlight_color)
class ButtonText(Text, Interactable, Queriable):
@@ -100,13 +102,13 @@ class ButtonText(Text, Interactable, Queriable):
A Text subclass that can handle Button interactions.
Renders text as a clickable button with visual states.
"""
-
+
def __init__(self, button: Button, font: Font, draw: ImageDraw.Draw,
padding: Tuple[int, int, int, int] = (4, 8, 4, 8),
source=None, line=None):
"""
Initialize a button text object.
-
+
Args:
button: The abstract Button object to handle interactions
font: The base font style
@@ -117,40 +119,41 @@ class ButtonText(Text, Interactable, Queriable):
"""
# Initialize Text with the button label
Text.__init__(self, button.label, font, draw, source, line)
-
+
# Initialize Interactable with the button's execute method
Interactable.__init__(self, button.execute)
-
+
# Store button properties
self._button = button
self._padding = padding
self._pressed = False
self._hovered = False
-
+
# Recalculate dimensions to include padding
# Use getattr to handle mock objects in tests
- text_width = getattr(self, '_width', 0) if not hasattr(self._width, '__call__') else 0
+ text_width = getattr(
+ self, '_width', 0) if not hasattr(
+ self._width, '__call__') else 0
self._padded_width = text_width + padding[1] + padding[3]
self._padded_height = self._style.font_size + padding[0] + padding[2]
-
+
@property
def button(self) -> Button:
"""Get the associated Button object"""
return self._button
-
+
@property
def size(self) -> np.ndarray:
"""Get the padded size of the button"""
return np.array([self._padded_width, self._padded_height])
-
+
def set_pressed(self, pressed: bool):
"""Set the pressed state"""
self._pressed = pressed
-
+
def set_hovered(self, hovered: bool):
"""Set the hover state"""
self._hovered = hovered
-
def render(self):
"""
@@ -177,7 +180,7 @@ class ButtonText(Text, Interactable, Queriable):
bg_color = (100, 150, 200)
border_color = (70, 120, 170)
text_color = (255, 255, 255)
-
+
# Draw button background with rounded corners
# rounded_rectangle expects [x0, y0, x1, y1] format
button_rect = [
@@ -187,8 +190,8 @@ class ButtonText(Text, Interactable, Queriable):
int(self._origin[1] + self.size[1])
]
self._draw.rounded_rectangle(button_rect, fill=bg_color,
- outline=border_color, width=1, radius=4)
-
+ outline=border_color, width=1, radius=4)
+
# Update text color and render text centered within padding
self._style = self._style.with_colour(text_color)
text_x = self._origin[0] + self._padding[3] # left padding
@@ -209,28 +212,28 @@ class ButtonText(Text, Interactable, Queriable):
# Temporarily set origin for text rendering
original_origin = self._origin.copy()
self._origin = np.array([text_x, text_y])
-
+
# Call parent render method for the text
super().render()
-
+
# Restore original origin
self._origin = original_origin
-
+
def in_object(self, point) -> bool:
"""
Check if a point is within this button.
-
+
Args:
point: The coordinates to check
-
+
Returns:
True if the point is within the button bounds (including padding)
"""
point_array = np.array(point)
relative_point = point_array - self._origin
-
+
# Check if the point is within the padded button boundaries
- return (0 <= relative_point[0] < self._padded_width and
+ return (0 <= relative_point[0] < self._padded_width and
0 <= relative_point[1] < self._padded_height)
@@ -239,12 +242,12 @@ class FormFieldText(Text, Interactable, Queriable):
A Text subclass that can handle FormField interactions.
Renders form field labels and input areas.
"""
-
+
def __init__(self, field: FormField, font: Font, draw: ImageDraw.Draw,
field_height: int = 24, source=None, line=None):
"""
Initialize a form field text object.
-
+
Args:
field: The abstract FormField object to handle interactions
font: The base font style for the label
@@ -255,68 +258,70 @@ class FormFieldText(Text, Interactable, Queriable):
"""
# Initialize Text with the field label
Text.__init__(self, field.label, font, draw, source, line)
-
+
# Initialize Interactable - form fields don't have direct callbacks
# but can notify of focus/value changes
Interactable.__init__(self, None)
-
+
# Store field properties
self._field = field
self._field_height = field_height
self._focused = False
-
+
# Calculate total height (label + gap + field)
self._total_height = self._style.font_size + 5 + field_height
-
+
# Field width should be at least as wide as the label
# Use getattr to handle mock objects in tests
- text_width = getattr(self, '_width', 0) if not hasattr(self._width, '__call__') else 0
+ text_width = getattr(
+ self, '_width', 0) if not hasattr(
+ self._width, '__call__') else 0
self._field_width = max(text_width, 150)
-
+
@property
def field(self) -> FormField:
"""Get the associated FormField object"""
return self._field
-
+
@property
def size(self) -> np.ndarray:
"""Get the total size including label and field"""
return np.array([self._field_width, self._total_height])
-
+
def set_focused(self, focused: bool):
"""Set the focus state"""
self._focused = focused
-
+
def render(self):
"""
Render the form field with label and input area.
"""
# Render the label
super().render()
-
+
# Calculate field position (below label with 5px gap)
field_x = self._origin[0]
field_y = self._origin[1] + self._style.font_size + 5
-
+
# Draw field background and border
bg_color = (255, 255, 255)
border_color = (100, 150, 200) if self._focused else (200, 200, 200)
-
- field_rect = [(field_x, field_y),
- (field_x + self._field_width, field_y + self._field_height)]
+
+ field_rect = [(field_x, field_y),
+ (field_x + self._field_width, field_y + self._field_height)]
self._draw.rectangle(field_rect, fill=bg_color, outline=border_color, width=1)
-
+
# Render field value if present
if self._field.value is not None:
value_text = str(self._field.value)
-
+
# For password fields, mask the text
if self._field.field_type == FormFieldType.PASSWORD:
value_text = "•" * len(value_text)
-
+
# Create a temporary Text object for the value
value_font = self._style.with_colour((0, 0, 0))
-
+
# Position value text within field (with some padding)
# Get font metrics to properly center the baseline
ascent, descent = value_font.font.getmetrics()
@@ -326,61 +331,62 @@ class FormFieldText(Text, Interactable, Queriable):
vertical_center = self._field_height / 2
value_x = field_x + 5
value_y = field_y + vertical_center + (descent / 2)
-
+
# Draw the value text
- self._draw.text((value_x, value_y), value_text,
- font=value_font.font, fill=value_font.colour, anchor="ls")
-
+ self._draw.text((value_x, value_y), value_text,
+ font=value_font.font, fill=value_font.colour, anchor="ls")
+
def handle_click(self, point) -> bool:
"""
Handle clicks on the form field.
-
+
Args:
point: The click coordinates relative to this field
-
+
Returns:
True if the field was clicked and focused
"""
# Calculate field area
field_y = self._style.font_size + 5
-
+
# Check if click is within the input field area (not just the label)
if (0 <= point[0] <= self._field_width and
- field_y <= point[1] <= field_y + self._field_height):
+ field_y <= point[1] <= field_y + self._field_height):
self.set_focused(True)
return True
-
+
return False
-
+
def in_object(self, point) -> bool:
"""
Check if a point is within this form field (including label and input area).
-
+
Args:
point: The coordinates to check
-
+
Returns:
True if the point is within the field bounds
"""
point_array = np.array(point)
relative_point = point_array - self._origin
-
+
# Check if the point is within the total field area
- return (0 <= relative_point[0] < self._field_width and
+ return (0 <= relative_point[0] < self._field_width and
0 <= relative_point[1] < self._total_height)
# Factory functions for creating functional text objects
-def create_link_text(link: Link, text: str, font: Font, draw: ImageDraw.Draw) -> LinkText:
+def create_link_text(link: Link, text: str, font: Font,
+ draw: ImageDraw.Draw) -> LinkText:
"""
Factory function to create a LinkText object.
-
+
Args:
link: The Link object to associate with the text
text: The text content to display
font: The base font style
draw: The drawing context
-
+
Returns:
A LinkText object ready for rendering and interaction
"""
@@ -388,16 +394,16 @@ def create_link_text(link: Link, text: str, font: Font, draw: ImageDraw.Draw) ->
def create_button_text(button: Button, font: Font, draw: ImageDraw.Draw,
- padding: Tuple[int, int, int, int] = (4, 8, 4, 8)) -> ButtonText:
+ padding: Tuple[int, int, int, int] = (4, 8, 4, 8)) -> ButtonText:
"""
Factory function to create a ButtonText object.
-
+
Args:
button: The Button object to associate with the text
font: The base font style
draw: The drawing context
padding: Padding around the button text
-
+
Returns:
A ButtonText object ready for rendering and interaction
"""
@@ -405,16 +411,16 @@ def create_button_text(button: Button, font: Font, draw: ImageDraw.Draw,
def create_form_field_text(field: FormField, font: Font, draw: ImageDraw.Draw,
- field_height: int = 24) -> FormFieldText:
+ field_height: int = 24) -> FormFieldText:
"""
Factory function to create a FormFieldText object.
-
+
Args:
field: The FormField object to associate with the text
font: The base font style for the label
draw: The drawing context
field_height: Height of the input field area
-
+
Returns:
A FormFieldText object ready for rendering and interaction
"""
diff --git a/pyWebLayout/concrete/image.py b/pyWebLayout/concrete/image.py
index 898a0e1..743416e 100644
--- a/pyWebLayout/concrete/image.py
+++ b/pyWebLayout/concrete/image.py
@@ -1,10 +1,9 @@
import os
-from typing import Optional, Tuple, Union, Dict, Any
+from typing import Optional
import numpy as np
from PIL import Image as PILImage, ImageDraw, ImageFont
from pyWebLayout.core.base import Renderable, Queriable
from pyWebLayout.abstract.block import Image as AbstractImage
-from .box import Box
from pyWebLayout.style import Alignment
@@ -12,14 +11,14 @@ class RenderableImage(Renderable, Queriable):
"""
A concrete implementation for rendering Image objects.
"""
-
+
def __init__(self, image: AbstractImage, canvas: PILImage.Image,
max_width: Optional[int] = None, max_height: Optional[int] = None,
origin=None, size=None, callback=None, sheet=None, mode=None,
halign=Alignment.CENTER, valign=Alignment.CENTER):
"""
Initialize a renderable image.
-
+
Args:
image: The abstract Image object to render
draw: The PIL ImageDraw object to draw on
@@ -40,52 +39,54 @@ class RenderableImage(Renderable, Queriable):
self._error_message = None
self._halign = halign
self._valign = valign
-
+
# Set origin as numpy array
self._origin = np.array(origin) if origin is not None else np.array([0, 0])
-
+
# Try to load the image
self._load_image()
-
+
# Calculate the size if not provided
if size is None:
size = image.calculate_scaled_dimensions(max_width, max_height)
# Ensure we have valid dimensions, fallback to defaults if None
if size[0] is None or size[1] is None:
size = (100, 100) # Default size when image dimensions are unavailable
-
+
# Set size as numpy array
self._size = np.array(size)
-
+
@property
def origin(self) -> np.ndarray:
"""Get the origin of the image"""
return self._origin
-
+
@property
def size(self) -> np.ndarray:
"""Get the size of the image"""
return self._size
-
+
@property
def width(self) -> int:
"""Get the width of the image"""
return self._size[0]
-
+
def set_origin(self, origin: np.ndarray):
"""Set the origin of this image element"""
self._origin = origin
-
+
def _load_image(self):
"""Load the image from the source path"""
try:
# Check if the image has already been loaded into memory
- if hasattr(self._abstract_image, '_loaded_image') and self._abstract_image._loaded_image is not None:
+ if hasattr(
+ self._abstract_image,
+ '_loaded_image') and self._abstract_image._loaded_image is not None:
self._pil_image = self._abstract_image._loaded_image
return
-
+
source = self._abstract_image.source
-
+
# Handle different types of sources
if os.path.isfile(source):
# Local file
@@ -96,22 +97,23 @@ class RenderableImage(Renderable, Queriable):
try:
import requests
from io import BytesIO
-
+
response = requests.get(source, stream=True)
if response.status_code == 200:
self._pil_image = PILImage.open(BytesIO(response.content))
self._abstract_image._loaded_image = self._pil_image
else:
- self._error_message = f"Failed to load image: HTTP status {response.status_code}"
+ self._error_message = f"Failed to load image: HTTP status {
+ response.status_code}"
except ImportError:
self._error_message = "Requests library not available for URL loading"
else:
self._error_message = f"Unable to load image from source: {source}"
-
+
except Exception as e:
self._error_message = f"Error loading image: {str(e)}"
self._abstract_image._error = self._error_message
-
+
def render(self):
"""
Render the image directly into the canvas using the provided draw object.
@@ -119,11 +121,11 @@ class RenderableImage(Renderable, Queriable):
if self._pil_image:
# Resize the image to fit the box while maintaining aspect ratio
resized_image = self._resize_image()
-
+
# Calculate position based on alignment
img_width, img_height = resized_image.size
box_width, box_height = self._size
-
+
# Horizontal alignment
if self._halign == Alignment.LEFT:
x_offset = 0
@@ -131,7 +133,7 @@ class RenderableImage(Renderable, Queriable):
x_offset = box_width - img_width
else: # CENTER is default
x_offset = (box_width - img_width) // 2
-
+
# Vertical alignment
if self._valign == Alignment.TOP:
y_offset = 0
@@ -139,55 +141,62 @@ class RenderableImage(Renderable, Queriable):
y_offset = box_height - img_height
else: # CENTER is default
y_offset = (box_height - img_height) // 2
-
+
# Calculate final position on canvas
final_x = int(self._origin[0] + x_offset)
final_y = int(self._origin[1] + y_offset)
-
+
# Get the underlying image from the draw object to paste onto
-
-
- self._canvas.paste(resized_image, (final_x, final_y, final_x + img_width, final_y + img_height))
+
+ self._canvas.paste(
+ resized_image,
+ (final_x,
+ final_y,
+ final_x +
+ img_width,
+ final_y +
+ img_height))
else:
# Draw error placeholder
self._draw_error_placeholder()
-
+
def _resize_image(self) -> PILImage.Image:
"""
Resize the image to fit within the box while maintaining aspect ratio.
-
+
Returns:
A resized PIL Image
"""
if not self._pil_image:
return PILImage.new('RGBA', tuple(self._size), (200, 200, 200, 100))
-
+
# Get the target dimensions
target_width, target_height = self._size
-
+
# Get the original dimensions
orig_width, orig_height = self._pil_image.size
-
+
# Calculate the scaling factor to maintain aspect ratio
width_ratio = target_width / orig_width
height_ratio = target_height / orig_height
-
+
# Use the smaller ratio to ensure the image fits within the box
ratio = min(width_ratio, height_ratio)
-
+
# Calculate new dimensions
new_width = int(orig_width * ratio)
new_height = int(orig_height * ratio)
-
+
# Resize the image
if self._pil_image.mode == 'RGBA':
resized = self._pil_image.resize((new_width, new_height), PILImage.LANCZOS)
else:
# Convert to RGBA if needed
- resized = self._pil_image.convert('RGBA').resize((new_width, new_height), PILImage.LANCZOS)
-
+ resized = self._pil_image.convert('RGBA').resize(
+ (new_width, new_height), PILImage.LANCZOS)
+
return resized
-
+
def _draw_error_placeholder(self):
"""
Draw a placeholder for when the image can't be loaded.
@@ -197,68 +206,69 @@ class RenderableImage(Renderable, Queriable):
y1 = int(self._origin[1])
x2 = int(self._origin[0] + self._size[0])
y2 = int(self._origin[1] + self._size[1])
-
+
self._draw = ImageDraw.Draw(self._canvas)
# Draw a gray box with a border
- self._draw.rectangle([(x1, y1), (x2, y2)], fill=(240, 240, 240), outline=(180, 180, 180), width=2)
-
+ self._draw.rectangle([(x1, y1), (x2, y2)], fill=(
+ 240, 240, 240), outline=(180, 180, 180), width=2)
+
# Draw an X across the box
self._draw.line([(x1, y1), (x2, y2)], fill=(180, 180, 180), width=2)
self._draw.line([(x1, y2), (x2, y1)], fill=(180, 180, 180), width=2)
-
+
# Add error text if available
if self._error_message:
try:
# Try to use a basic font
font = ImageFont.load_default()
-
+
# Draw the error message, wrapped to fit
error_text = "Error: " + self._error_message
-
+
# Simple text wrapping - split by words and add lines
words = error_text.split()
lines = []
current_line = ""
-
+
for word in words:
test_line = current_line + " " + word if current_line else word
text_bbox = self._draw.textbbox((0, 0), test_line, font=font)
text_width = text_bbox[2] - text_bbox[0]
-
+
if text_width <= self._size[0] - 20: # 10px padding on each side
current_line = test_line
else:
lines.append(current_line)
current_line = word
-
+
if current_line:
lines.append(current_line)
-
+
# Draw each line
y_pos = y1 + 10
for line in lines:
text_bbox = self._draw.textbbox((0, 0), line, font=font)
text_width = text_bbox[2] - text_bbox[0]
text_height = text_bbox[3] - text_bbox[1]
-
+
# Center the text horizontally
x_pos = x1 + (self._size[0] - text_width) // 2
-
+
# Draw the text
self._draw.text((x_pos, y_pos), line, fill=(80, 80, 80), font=font)
-
+
# Move to the next line
y_pos += text_height + 2
-
+
except Exception:
# If text rendering fails, just draw a generic error indicator
pass
-
+
def in_object(self, point):
"""Check if a point is within this image"""
point_array = np.array(point)
relative_point = point_array - self._origin
-
+
# Check if the point is within the image boundaries
- return (0 <= relative_point[0] < self._size[0] and
+ return (0 <= relative_point[0] < self._size[0] and
0 <= relative_point[1] < self._size[1])
diff --git a/pyWebLayout/concrete/page.py b/pyWebLayout/concrete/page.py
index 297893b..348559f 100644
--- a/pyWebLayout/concrete/page.py
+++ b/pyWebLayout/concrete/page.py
@@ -2,12 +2,11 @@ from typing import List, Tuple, Optional
import numpy as np
from PIL import Image, ImageDraw
-from pyWebLayout.core.base import Renderable, Layoutable, Queriable
+from pyWebLayout.core.base import Renderable, Queriable
from pyWebLayout.core.query import QueryResult, SelectionRange
from pyWebLayout.core.callback_registry import CallbackRegistry
from pyWebLayout.style.page_style import PageStyle
-from pyWebLayout.style import Alignment
-from .box import Box
+
class Page(Renderable, Queriable):
"""
@@ -41,29 +40,33 @@ class Page(Renderable, Queriable):
"""Get the remaining space on the page"""
return (self._size[0], self._size[1] - self._current_y_offset)
- def can_fit_line(self, baseline_spacing: int, ascent: int = 0, descent: int = 0) -> bool:
+ def can_fit_line(
+ self,
+ baseline_spacing: int,
+ ascent: int = 0,
+ descent: int = 0) -> bool:
"""
Check if a line with the given metrics can fit on the page.
-
+
Args:
baseline_spacing: Distance from current position to next baseline
ascent: Font ascent (height above baseline), defaults to 0 for backward compat
descent: Font descent (height below baseline), defaults to 0 for backward compat
-
+
Returns:
True if the line fits within page boundaries
"""
# Calculate the maximum Y position allowed (bottom boundary)
max_y = self._size[1] - self._style.border_width - self._style.padding_bottom
-
+
# If ascent/descent not provided, use simple check (backward compatibility)
if ascent == 0 and descent == 0:
return (self._current_y_offset + baseline_spacing) <= max_y
-
+
# Calculate where the bottom of the text would be
# Text bottom = current_y_offset + ascent + descent
text_bottom = self._current_y_offset + ascent + descent
-
+
# Check if text bottom would exceed the boundary
return text_bottom <= max_y
@@ -183,11 +186,15 @@ class Page(Renderable, Queriable):
Height in pixels
"""
if hasattr(child, '_size') and child._size is not None:
- if isinstance(child._size, (list, tuple, np.ndarray)) and len(child._size) >= 2:
+ if isinstance(
+ child._size, (list, tuple, np.ndarray)) and len(
+ child._size) >= 2:
return int(child._size[1])
if hasattr(child, 'size') and child.size is not None:
- if isinstance(child.size, (list, tuple, np.ndarray)) and len(child.size) >= 2:
+ if isinstance(
+ child.size, (list, tuple, np.ndarray)) and len(
+ child.size) >= 2:
return int(child.size[1])
if hasattr(child, 'height'):
@@ -326,7 +333,7 @@ class Page(Renderable, Queriable):
if isinstance(child, Queriable) and hasattr(child, 'in_object'):
try:
return child.in_object(point)
- except:
+ except BaseException:
pass # Fall back to bounds checking
# Get child position and size for bounds checking
@@ -353,11 +360,15 @@ class Page(Renderable, Queriable):
Tuple of (width, height) or None if size cannot be determined
"""
if hasattr(child, '_size') and child._size is not None:
- if isinstance(child._size, (list, tuple, np.ndarray)) and len(child._size) >= 2:
+ if isinstance(
+ child._size, (list, tuple, np.ndarray)) and len(
+ child._size) >= 2:
return (int(child._size[0]), int(child._size[1]))
if hasattr(child, 'size') and child.size is not None:
- if isinstance(child.size, (list, tuple, np.ndarray)) and len(child.size) >= 2:
+ if isinstance(
+ child.size, (list, tuple, np.ndarray)) and len(
+ child.size) >= 2:
return (int(child.size[0]), int(child.size[1]))
if hasattr(child, 'width') and hasattr(child, 'height'):
@@ -422,7 +433,8 @@ class Page(Renderable, Queriable):
bounds=bounds
)
- def query_range(self, start: Tuple[int, int], end: Tuple[int, int]) -> SelectionRange:
+ def query_range(self, start: Tuple[int, int],
+ end: Tuple[int, int]) -> SelectionRange:
"""
Query all text objects between two points (for text selection).
Uses Queriable.in_object() to determine which objects are in range.
diff --git a/pyWebLayout/concrete/table.py b/pyWebLayout/concrete/table.py
index 596d3d1..a750e06 100644
--- a/pyWebLayout/concrete/table.py
+++ b/pyWebLayout/concrete/table.py
@@ -9,15 +9,13 @@ This module provides the concrete rendering classes for tables, including:
from __future__ import annotations
from typing import Tuple, List, Optional, Dict
-import numpy as np
from PIL import Image, ImageDraw
from dataclasses import dataclass
-from pyWebLayout.core.base import Renderable, Queriable
+from pyWebLayout.core.base import Renderable
from pyWebLayout.concrete.box import Box
from pyWebLayout.abstract.block import Table, TableRow, TableCell, Paragraph, Heading, Image as AbstractImage
from pyWebLayout.abstract.interactive_image import InteractiveImage
-from pyWebLayout.style import Font, Alignment
@dataclass
@@ -49,8 +47,15 @@ class TableCellRenderer(Box):
Supports paragraphs, headings, images, and links within cells.
"""
- def __init__(self, cell: TableCell, origin: Tuple[int, int], size: Tuple[int, int],
- draw: ImageDraw.Draw, style: TableStyle, is_header_section: bool = False,
+ def __init__(self,
+ cell: TableCell,
+ origin: Tuple[int,
+ int],
+ size: Tuple[int,
+ int],
+ draw: ImageDraw.Draw,
+ style: TableStyle,
+ is_header_section: bool = False,
canvas: Optional[Image.Image] = None):
"""
Initialize a table cell renderer.
@@ -111,17 +116,20 @@ class TableCellRenderer(Box):
# Get font
try:
if self._is_header_section and self._style.header_text_bold:
- font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 12)
+ font = ImageFont.truetype(
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 12)
else:
- font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 12)
- except:
+ font = ImageFont.truetype(
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 12)
+ except BaseException:
font = ImageFont.load_default()
# Render each block in the cell
for block in self._cell.blocks():
if isinstance(block, AbstractImage):
# Render image
- current_y = self._render_image_in_cell(block, x, current_y, width, height - (current_y - y))
+ current_y = self._render_image_in_cell(
+ block, x, current_y, width, height - (current_y - y))
elif isinstance(block, (Paragraph, Heading)):
# Extract and render text
words = []
@@ -137,7 +145,8 @@ class TableCellRenderer(Box):
if words:
text = " ".join(words)
if current_y <= y + height - 15:
- self._draw.text((x + 2, current_y), text, fill=(0, 0, 0), font=font)
+ self._draw.text((x + 2, current_y), text,
+ fill=(0, 0, 0), font=font)
current_y += 16
if current_y > y + height - 10: # Don't overflow cell
@@ -145,10 +154,18 @@ class TableCellRenderer(Box):
# If no structured content, try to get any text representation
if current_y == y + 2 and hasattr(self._cell, '_text_content'):
- self._draw.text((x + 2, current_y), self._cell._text_content, fill=(0, 0, 0), font=font)
+ self._draw.text(
+ (x + 2,
+ current_y),
+ self._cell._text_content,
+ fill=(
+ 0,
+ 0,
+ 0),
+ font=font)
def _render_image_in_cell(self, image_block: AbstractImage, x: int, y: int,
- max_width: int, max_height: int) -> int:
+ max_width: int, max_height: int) -> int:
"""
Render an image block inside a table cell.
@@ -181,7 +198,8 @@ class TableCellRenderer(Box):
# Use more of the cell space for images
img_width, img_height = img.size
scale_w = max_width / img_width if img_width > max_width else 1
- scale_h = (max_height - 10) / img_height if img_height > (max_height - 10) else 1
+ scale_h = (max_height - 10) / \
+ img_height if img_height > (max_height - 10) else 1
scale = min(scale_w, scale_h, 1.0) # Don't upscale
new_width = int(img_width * scale)
@@ -210,8 +228,9 @@ class TableCellRenderer(Box):
# Draw image indicator text
from PIL import ImageFont
try:
- small_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 9)
- except:
+ small_font = ImageFont.truetype(
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 9)
+ except BaseException:
small_font = ImageFont.load_default()
text = f"[Image: {new_width}x{new_height}]"
@@ -219,7 +238,9 @@ class TableCellRenderer(Box):
text_width = bbox[2] - bbox[0]
text_x = img_x + (new_width - text_width) // 2
text_y = y + (new_height - 12) // 2
- self._draw.text((text_x, text_y), text, fill=(100, 100, 100), font=small_font)
+ self._draw.text(
+ (text_x, text_y), text, fill=(
+ 100, 100, 100), font=small_font)
# Set bounds on InteractiveImage objects for tap detection
if isinstance(image_block, InteractiveImage):
@@ -230,7 +251,7 @@ class TableCellRenderer(Box):
return y + new_height + 5 # Add some spacing after image
- except Exception as e:
+ except Exception:
# If image loading fails, just return current position
return y + 20
@@ -240,9 +261,15 @@ class TableRowRenderer(Box):
Renders a single table row containing multiple cells.
"""
- def __init__(self, row: TableRow, origin: Tuple[int, int],
- column_widths: List[int], row_height: int,
- draw: ImageDraw.Draw, style: TableStyle, is_header_section: bool = False,
+ def __init__(self,
+ row: TableRow,
+ origin: Tuple[int,
+ int],
+ column_widths: List[int],
+ row_height: int,
+ draw: ImageDraw.Draw,
+ style: TableStyle,
+ is_header_section: bool = False,
canvas: Optional[Image.Image] = None):
"""
Initialize a table row renderer.
@@ -309,9 +336,14 @@ class TableRenderer(Box):
Handles layout calculation, row/cell placement, and overall table structure.
"""
- def __init__(self, table: Table, origin: Tuple[int, int],
- available_width: int, draw: ImageDraw.Draw,
- style: Optional[TableStyle] = None, canvas: Optional[Image.Image] = None):
+ def __init__(self,
+ table: Table,
+ origin: Tuple[int,
+ int],
+ available_width: int,
+ draw: ImageDraw.Draw,
+ style: Optional[TableStyle] = None,
+ canvas: Optional[Image.Image] = None):
"""
Initialize a table renderer.
@@ -331,8 +363,10 @@ class TableRenderer(Box):
# Calculate table dimensions
self._column_widths, self._row_heights = self._calculate_dimensions()
- total_width = sum(self._column_widths) + self._style.border_width * (len(self._column_widths) + 1)
- total_height = sum(self._row_heights.values()) + self._style.border_width * (len(self._row_heights) + 1)
+ total_width = sum(self._column_widths) + \
+ self._style.border_width * (len(self._column_widths) + 1)
+ total_height = sum(self._row_heights.values()) + \
+ self._style.border_width * (len(self._row_heights) + 1)
super().__init__(origin, (total_width, total_height))
self._row_renderers: List[TableRowRenderer] = []
@@ -362,7 +396,8 @@ class TableRenderer(Box):
column_widths = [column_width] * num_columns
# Calculate row heights
- header_height = 35 if any(1 for section, _ in all_rows if section == "header") else 0
+ header_height = 35 if any(1 for section,
+ _ in all_rows if section == "header") else 0
# Check if any body rows contain images - if so, use larger height
body_height = 30
@@ -375,7 +410,8 @@ class TableRenderer(Box):
body_height = max(body_height, 120)
break
- footer_height = 30 if any(1 for section, _ in all_rows if section == "footer") else 0
+ footer_height = 30 if any(1 for section,
+ _ in all_rows if section == "footer") else 0
row_heights = {
"header": header_height,
@@ -428,8 +464,9 @@ class TableRenderer(Box):
from PIL import ImageFont
try:
- font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 13)
- except:
+ font = ImageFont.truetype(
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 13)
+ except BaseException:
font = ImageFont.load_default()
# Center the caption
diff --git a/pyWebLayout/concrete/text.py b/pyWebLayout/concrete/text.py
index e860fdd..343fa20 100644
--- a/pyWebLayout/concrete/text.py
+++ b/pyWebLayout/concrete/text.py
@@ -2,15 +2,16 @@ from __future__ import annotations
from pyWebLayout.core.base import Renderable, Queriable
from pyWebLayout.core.query import QueryResult
from .box import Box
-from pyWebLayout.style import Alignment, Font, FontStyle, FontWeight, TextDecoration
+from pyWebLayout.style import Alignment, Font, TextDecoration
from pyWebLayout.abstract import Word
from pyWebLayout.abstract.inline import LinkedWord
from pyWebLayout.abstract.functional import Link
-from PIL import Image, ImageDraw, ImageFont
-from typing import Tuple, Union, List, Optional, Protocol
+from PIL import ImageDraw
+from typing import Tuple, List, Optional
import numpy as np
from abc import ABC, abstractmethod
+
class AlignmentHandler(ABC):
"""
Abstract base class for text alignment handlers.
@@ -19,8 +20,8 @@ class AlignmentHandler(ABC):
@abstractmethod
def calculate_spacing_and_position(self, text_objects: List['Text'],
- available_width: int, min_spacing: int,
- max_spacing: int) -> Tuple[int, int, bool]:
+ available_width: int, min_spacing: int,
+ max_spacing: int) -> Tuple[int, int, bool]:
"""
Calculate the spacing between words and starting position for the line.
@@ -33,16 +34,16 @@ class AlignmentHandler(ABC):
Returns:
Tuple of (spacing_between_words, starting_x_position)
"""
- pass
+
class LeftAlignmentHandler(AlignmentHandler):
"""Handler for left-aligned text."""
def calculate_spacing_and_position(self,
- text_objects: List['Text'],
- available_width: int,
- min_spacing: int,
- max_spacing: int) -> Tuple[int, int, bool]:
+ text_objects: List['Text'],
+ available_width: int,
+ min_spacing: int,
+ max_spacing: int) -> Tuple[int, int, bool]:
"""
Calculate spacing and position for left-aligned text objects.
CREngine-inspired: never allow negative spacing, always use minimum spacing for overflow.
@@ -69,7 +70,8 @@ class LeftAlignmentHandler(AlignmentHandler):
# Calculate minimum space needed (text + minimum gaps)
min_total_width = text_length + (min_spacing * num_gaps)
- # Check if we have overflow (CREngine pattern: always use min_spacing for overflow)
+ # Check if we have overflow (CREngine pattern: always use min_spacing for
+ # overflow)
if min_total_width > available_width:
return min_spacing, 0, True # Overflow - but use safe minimum spacing
@@ -87,6 +89,7 @@ class LeftAlignmentHandler(AlignmentHandler):
else:
return actual_spacing, 0, False # Use calculated spacing
+
class CenterRightAlignmentHandler(AlignmentHandler):
"""Handler for center and right-aligned text."""
@@ -94,8 +97,8 @@ class CenterRightAlignmentHandler(AlignmentHandler):
self._alignment = alignment
def calculate_spacing_and_position(self, text_objects: List['Text'],
- available_width: int, min_spacing: int,
- max_spacing: int) -> Tuple[int, int, bool]:
+ available_width: int, min_spacing: int,
+ max_spacing: int) -> Tuple[int, int, bool]:
"""Center/right alignment uses minimum spacing with calculated start position."""
word_length = sum([word.width for word in text_objects])
residual_space = available_width - word_length
@@ -108,12 +111,12 @@ class CenterRightAlignmentHandler(AlignmentHandler):
start_position = available_width - word_length
return 0, max(0, start_position), False
- actual_spacing = residual_space // (len(text_objects)-1)
- ideal_space = (min_spacing + max_spacing)/2
- if actual_spacing > 0.5*(min_spacing + max_spacing):
- actual_spacing = 0.5*(min_spacing + max_spacing)
+ actual_spacing = residual_space // (len(text_objects) - 1)
+ ideal_space = (min_spacing + max_spacing) / 2
+ if actual_spacing > 0.5 * (min_spacing + max_spacing):
+ actual_spacing = 0.5 * (min_spacing + max_spacing)
- content_length = word_length + (len(text_objects)-1) * actual_spacing
+ content_length = word_length + (len(text_objects) - 1) * actual_spacing
if self._alignment == Alignment.CENTER:
start_position = (available_width - content_length) // 2
else:
@@ -124,12 +127,13 @@ class CenterRightAlignmentHandler(AlignmentHandler):
return ideal_space, max(0, start_position), False
+
class JustifyAlignmentHandler(AlignmentHandler):
"""Handler for justified text with full justification."""
def calculate_spacing_and_position(self, text_objects: List['Text'],
- available_width: int, min_spacing: int,
- max_spacing: int) -> Tuple[int, int, bool]:
+ available_width: int, min_spacing: int,
+ max_spacing: int) -> Tuple[int, int, bool]:
"""Justified alignment distributes space to fill the entire line width."""
word_length = sum([word.width for word in text_objects])
@@ -137,7 +141,7 @@ class JustifyAlignmentHandler(AlignmentHandler):
num_gaps = max(1, len(text_objects) - 1)
actual_spacing = residual_space // num_gaps
- ideal_space = (min_spacing + max_spacing)//2
+ ideal_space = (min_spacing + max_spacing) // 2
# can we touch the end?
if actual_spacing < max_spacing:
if actual_spacing < min_spacing:
@@ -146,13 +150,20 @@ class JustifyAlignmentHandler(AlignmentHandler):
return max(min_spacing, actual_spacing), 0, False
return ideal_space, 0, False
+
class Text(Renderable, Queriable):
"""
Concrete implementation for rendering text.
This class handles the visual representation of text fragments.
"""
- def __init__(self, text: str, style: Font, draw: ImageDraw.Draw, source: Optional[Word] = None, line: Optional[Line] = None):
+ def __init__(
+ self,
+ text: str,
+ style: Font,
+ draw: ImageDraw.Draw,
+ source: Optional[Word] = None,
+ line: Optional[Line] = None):
"""
Initialize a Text object.
@@ -181,8 +192,8 @@ class Text(Renderable, Queriable):
self._middle_y = ascent - descent / 2
@classmethod
- def from_word(cls,word:Word, draw: ImageDraw.Draw):
- return cls(word.text,word.style, draw)
+ def from_word(cls, word: Word, draw: ImageDraw.Draw):
+ return cls(word.text, word.style, draw)
@property
def text(self) -> str:
@@ -219,7 +230,7 @@ class Text(Renderable, Queriable):
"""Get the width of the text"""
return np.array((self._width, self._style.font_size))
- def set_origin(self, origin:np.generic):
+ def set_origin(self, origin: np.generic):
"""Set the origin (left baseline ("ls")) of this text element"""
self._origin = origin
@@ -230,51 +241,51 @@ class Text(Renderable, Queriable):
def _apply_decoration(self, next_text: Optional['Text'] = None, spacing: int = 0):
"""
Apply text decoration (underline or strikethrough).
-
+
Args:
next_text: The next Text object in the line (if any)
spacing: The spacing to the next text object
"""
if self._style.decoration == TextDecoration.UNDERLINE:
# Draw underline at about 90% of the height
- y_position = self._origin[1] - 0.1*self._style.font_size
+ y_position = self._origin[1] - 0.1 * self._style.font_size
line_width = max(1, int(self._style.font_size / 15))
-
+
# Determine end x-coordinate
end_x = self._origin[0] + self._width
-
+
# If next text also has underline decoration, extend to connect them
- if (next_text is not None and
+ if (next_text is not None and
next_text.style.decoration == TextDecoration.UNDERLINE and
- next_text.style.colour == self._style.colour):
+ next_text.style.colour == self._style.colour):
# Extend the underline through the spacing to connect with next word
end_x += spacing
-
+
self._draw.line([(self._origin[0], y_position), (end_x, y_position)],
- fill=self._style.colour, width=line_width)
+ fill=self._style.colour, width=line_width)
elif self._style.decoration == TextDecoration.STRIKETHROUGH:
# Draw strikethrough at about 50% of the height
y_position = self._origin[1] + self._middle_y
line_width = max(1, int(self._style.font_size / 15))
-
+
# Determine end x-coordinate
end_x = self._origin[0] + self._width
-
+
# If next text also has strikethrough decoration, extend to connect them
- if (next_text is not None and
+ if (next_text is not None and
next_text.style.decoration == TextDecoration.STRIKETHROUGH and
- next_text.style.colour == self._style.colour):
+ next_text.style.colour == self._style.colour):
# Extend the strikethrough through the spacing to connect with next word
end_x += spacing
-
+
self._draw.line([(self._origin[0], y_position), (end_x, y_position)],
- fill=self._style.colour, width=line_width)
+ fill=self._style.colour, width=line_width)
def render(self, next_text: Optional['Text'] = None, spacing: int = 0):
"""
Render the text to an image.
-
+
Args:
next_text: The next Text object in the line (if any)
spacing: The spacing to the next text object
@@ -285,24 +296,42 @@ class Text(Renderable, Queriable):
# Draw the text background if specified
if self._style.background and self._style.background[3] > 0: # If alpha > 0
- self._draw.rectangle([self._origin, self._origin+self._size], fill=self._style.background)
+ self._draw.rectangle([self._origin, self._origin +
+ self._size], fill=self._style.background)
# Draw the text using baseline as anchor point ("ls" = left-baseline)
# This ensures the origin represents the baseline, not the top-left
- self._draw.text((self.origin[0], self._origin[1]), self._text, font=self._style.font, fill=self._style.colour, anchor="ls")
+ self._draw.text(
+ (self.origin[0],
+ self._origin[1]),
+ self._text,
+ font=self._style.font,
+ fill=self._style.colour,
+ anchor="ls")
# Apply any text decorations with knowledge of next text
self._apply_decoration(next_text, spacing)
+
class Line(Box):
"""
A line of text consisting of Text objects with consistent spacing.
Each Text represents a word or word fragment that can be rendered.
"""
- def __init__(self, spacing: Tuple[int, int], origin, size, draw: ImageDraw.Draw,font: Optional[Font] = None,
- callback=None, sheet=None, mode=None, halign=Alignment.CENTER,
- valign=Alignment.CENTER, previous = None,
+ def __init__(self,
+ spacing: Tuple[int,
+ int],
+ origin,
+ size,
+ draw: ImageDraw.Draw,
+ font: Optional[Font] = None,
+ callback=None,
+ sheet=None,
+ mode=None,
+ halign=Alignment.CENTER,
+ valign=Alignment.CENTER,
+ previous=None,
min_word_length_for_brute_force: int = 8,
min_chars_before_hyphen: int = 2,
min_chars_after_hyphen: int = 2):
@@ -329,16 +358,16 @@ class Line(Box):
self._spacing = spacing # (min_spacing, max_spacing)
self._font = font if font else Font() # Use default font if none provided
self._current_width = 0 # Track the current width used
- self._words : List['Word'] = []
+ self._words: List['Word'] = []
self._previous = previous
self._next = None
ascent, descent = self._font.font.getmetrics()
# Store baseline as offset from line origin (top), not absolute position
self._baseline = ascent
self._draw = draw
- self._spacing_render = (spacing[0] + spacing[1]) //2
+ self._spacing_render = (spacing[0] + spacing[1]) // 2
self._position_render = 0
-
+
# Hyphenation configuration parameters
self._min_word_length_for_brute_force = min_word_length_for_brute_force
self._min_chars_before_hyphen = min_chars_before_hyphen
@@ -373,7 +402,10 @@ class Line(Box):
"""Set the next line in sequence"""
self._next = line
- def add_word(self, word: 'Word', part:Optional[Text]=None) -> Tuple[bool, Optional['Text']]:
+ def add_word(self,
+ word: 'Word',
+ part: Optional[Text] = None) -> Tuple[bool,
+ Optional['Text']]:
"""
Add a word to this line using intelligent word fitting strategies.
@@ -392,7 +424,8 @@ class Line(Box):
self._words.append(word)
part.add_line(self)
- # Try to add the full word - create LinkText for LinkedWord, regular Text otherwise
+ # Try to add the full word - create LinkText for LinkedWord, regular Text
+ # otherwise
if isinstance(word, LinkedWord):
# Import here to avoid circular dependency
from .functional import LinkText
@@ -407,14 +440,19 @@ class Line(Box):
params=word.params,
title=word.link_title
)
- text = LinkText(link, word.text, word.style, self._draw, source=word, line=self)
+ text = LinkText(
+ link,
+ word.text,
+ word.style,
+ self._draw,
+ source=word,
+ line=self)
else:
text = Text.from_word(word, self._draw)
self._text_objects.append(text)
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
- self._text_objects, self._size[0], self._spacing[0], self._spacing[1]
- )
-
+ self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
+
if not overflow:
# Word fits! Add it completely
self._words.append(word)
@@ -426,44 +464,53 @@ class Line(Box):
# Word doesn't fit, remove it and try hyphenation
_ = self._text_objects.pop()
-
+
# Step 1: Try pyphen hyphenation
pyphen_splits = word.possible_hyphenation()
valid_splits = []
-
+
if pyphen_splits:
# Create Text objects for each possible split and check if they fit
for pair in pyphen_splits:
first_part_text = pair[0] + "-"
second_part_text = pair[1]
-
+
# Validate minimum character requirements
if len(pair[0]) < self._min_chars_before_hyphen:
continue
if len(pair[1]) < self._min_chars_after_hyphen:
continue
-
+
# Create Text objects
- first_text = Text(first_part_text, word.style, self._draw, line=self, source=word)
- second_text = Text(second_part_text, word.style, self._draw, line=self, source=word)
-
+ first_text = Text(
+ first_part_text,
+ word.style,
+ self._draw,
+ line=self,
+ source=word)
+ second_text = Text(
+ second_part_text,
+ word.style,
+ self._draw,
+ line=self,
+ source=word)
+
# Check if first part fits
self._text_objects.append(first_text)
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
- self._text_objects, self._size[0], self._spacing[0], self._spacing[1]
- )
+ self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
_ = self._text_objects.pop()
-
+
if not overflow:
# This split fits! Add it to valid options
valid_splits.append((first_text, second_text, spacing, position))
-
+
# Step 2: If we have valid pyphen splits, choose the best one
if valid_splits:
# Select the split with the best (minimum) spacing
best_split = min(valid_splits, key=lambda x: x[2])
first_text, second_text, spacing, position = best_split
-
+
# Apply the split
self._text_objects.append(first_text)
first_text.line = self
@@ -472,48 +519,58 @@ class Line(Box):
self._position_render = position
self._words.append(word)
return True, second_text
-
+
# Step 3: Try brute force hyphenation (only for long words)
if len(word.text) >= self._min_word_length_for_brute_force:
# Calculate available space for the word
word_length = sum([text.width for text in self._text_objects])
spacing_length = self._spacing[0] * max(0, len(self._text_objects) - 1)
remaining = self._size[0] - word_length - spacing_length
-
+
if remaining > 0:
# Create a hyphenated version to measure
test_text = Text(word.text + "-", word.style, self._draw)
-
+
if test_text.width > 0:
# Calculate what fraction of the hyphenated word fits
fraction = remaining / test_text.width
-
+
# Convert fraction to character position
- # We need at least min_chars_before_hyphen and leave at least min_chars_after_hyphen
+ # We need at least min_chars_before_hyphen and leave at least
+ # min_chars_after_hyphen
max_split_pos = len(word.text) - self._min_chars_after_hyphen
min_split_pos = self._min_chars_before_hyphen
-
+
# Calculate ideal split position based on available space
ideal_split = int(fraction * len(word.text))
split_pos = max(min_split_pos, min(ideal_split, max_split_pos))
-
+
# Ensure we meet minimum requirements
- if (split_pos >= self._min_chars_before_hyphen and
- len(word.text) - split_pos >= self._min_chars_after_hyphen):
-
+ if (split_pos >= self._min_chars_before_hyphen and
+ len(word.text) - split_pos >= self._min_chars_after_hyphen):
+
# Create the split
first_part_text = word.text[:split_pos] + "-"
second_part_text = word.text[split_pos:]
-
- first_text = Text(first_part_text, word.style, self._draw, line=self, source=word)
- second_text = Text(second_part_text, word.style, self._draw, line=self, source=word)
-
+
+ first_text = Text(
+ first_part_text,
+ word.style,
+ self._draw,
+ line=self,
+ source=word)
+ second_text = Text(
+ second_part_text,
+ word.style,
+ self._draw,
+ line=self,
+ source=word)
+
# Verify the first part actually fits
self._text_objects.append(first_text)
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
- self._text_objects, self._size[0], self._spacing[0], self._spacing[1]
- )
-
+ self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
+
if not overflow:
# Brute force split works!
first_text.line = self
@@ -526,7 +583,7 @@ class Line(Box):
else:
# Doesn't fit, remove it
_ = self._text_objects.pop()
-
+
# Step 4: Word cannot be hyphenated or split, move to next line
return False, None
@@ -540,8 +597,7 @@ class Line(Box):
# Recalculate spacing and position for current text objects to ensure accuracy
if len(self._text_objects) > 0:
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
- self._text_objects, self._size[0], self._spacing[0], self._spacing[1]
- )
+ self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
self._spacing_render = spacing
self._position_render = position
@@ -553,13 +609,14 @@ class Line(Box):
# Update text draw context to current draw context
text._draw = self._draw
text.set_origin(np.array([x_cursor, y_cursor]))
-
+
# Determine next text object for continuous decoration
- next_text = self._text_objects[i + 1] if i + 1 < len(self._text_objects) else None
-
+ next_text = self._text_objects[i + 1] if i + \
+ 1 < len(self._text_objects) else None
+
# Render with next text information for continuous underline/strikethrough
text.render(next_text, self._spacing_render)
- x_cursor += self._spacing_render + text.width # x-spacing + width of text object
+ x_cursor += self._spacing_render + text.width # x-spacing + width of text object
def query_point(self, point: Tuple[int, int]) -> Optional['QueryResult']:
"""
@@ -583,7 +640,8 @@ class Line(Box):
size = text_obj.size
# Text origin is at baseline (anchor="ls"), so visual top is origin[1] - ascent
- # Bounds should be (x, visual_top, width, height) for proper highlighting
+ # Bounds should be (x, visual_top, width, height) for proper
+ # highlighting
visual_top = int(origin[1] - text_obj._ascent)
bounds = (
int(origin[0]),
@@ -602,8 +660,9 @@ class Line(Box):
bounds=bounds,
text=text_obj._text,
is_interactive=True,
- link_target=text_obj._link.location if hasattr(text_obj, '_link') else None
- )
+ link_target=text_obj._link.location if hasattr(
+ text_obj,
+ '_link') else None)
elif isinstance(text_obj, ButtonText):
result = QueryResult(
object=text_obj,
@@ -611,8 +670,9 @@ class Line(Box):
bounds=bounds,
text=text_obj._text,
is_interactive=True,
- callback=text_obj._callback if hasattr(text_obj, '_callback') else None
- )
+ callback=text_obj._callback if hasattr(
+ text_obj,
+ '_callback') else None)
else:
result = QueryResult(
object=text_obj,
diff --git a/pyWebLayout/core/__init__.py b/pyWebLayout/core/__init__.py
index 2421de8..ce1efc5 100644
--- a/pyWebLayout/core/__init__.py
+++ b/pyWebLayout/core/__init__.py
@@ -5,8 +5,30 @@ This package contains the core abstractions and base classes that form the found
of the pyWebLayout rendering system.
"""
-from pyWebLayout.core.base import (
- Renderable, Interactable, Layoutable, Queriable,
- Hierarchical, Geometric, Styleable, FontRegistry,
- MetadataContainer, BlockContainer, ContainerAware
+from .base import (
+ Renderable,
+ Interactable,
+ Layoutable,
+ Queriable,
+ Hierarchical,
+ Geometric,
+ Styleable,
+ FontRegistry,
+ MetadataContainer,
+ BlockContainer,
+ ContainerAware,
)
+
+__all__ = [
+ 'Renderable',
+ 'Interactable',
+ 'Layoutable',
+ 'Queriable',
+ 'Hierarchical',
+ 'Geometric',
+ 'Styleable',
+ 'FontRegistry',
+ 'MetadataContainer',
+ 'BlockContainer',
+ 'ContainerAware',
+]
diff --git a/pyWebLayout/core/base.py b/pyWebLayout/core/base.py
index c4da9d9..47218e0 100644
--- a/pyWebLayout/core/base.py
+++ b/pyWebLayout/core/base.py
@@ -1,11 +1,9 @@
from abc import ABC
-from typing import Optional, Tuple, List, TYPE_CHECKING, Any, Dict
+from typing import Optional, Tuple, TYPE_CHECKING, Any, Dict
import numpy as np
-from pyWebLayout.style.alignment import Alignment
if TYPE_CHECKING:
- from pyWebLayout.core.query import QueryResult
from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration
@@ -14,57 +12,62 @@ class Renderable(ABC):
Abstract base class for any object that can be rendered to an image.
All renderable objects must implement the render method.
"""
+
def render(self):
"""
Render the object to an image.
-
+
Returns:
PIL.Image: The rendered image
"""
- pass
+
@property
def origin(self):
return self._origin
+
class Interactable(ABC):
"""
Abstract base class for any object that can be interacted with.
Interactable objects must have a callback that is executed when interacted with.
"""
+
def __init__(self, callback=None):
"""
Initialize an interactable object.
-
+
Args:
callback: The function to call when this object is interacted with
"""
self._callback = callback
-
+
def interact(self, point: np.generic):
"""
Handle interaction at the given point.
-
+
Args:
point: The coordinates of the interaction
-
+
Returns:
The result of calling the callback function with the point
"""
if self._callback is None:
return None
return self._callback(point)
-
+
+
class Layoutable(ABC):
"""
Abstract base class for any object that can be laid out.
Layoutable objects must implement the layout method which arranges their contents.
"""
+
def layout(self):
"""
Layout the object's contents.
This method should be called before rendering to properly arrange the object's contents.
"""
- pass
+
class Queriable(ABC):
@@ -181,15 +184,15 @@ class FontRegistry:
self._fonts: Dict[str, 'Font'] = {}
def get_or_create_font(self,
- font_path: Optional[str] = None,
- font_size: int = 16,
- colour: Tuple[int, int, int] = (0, 0, 0),
- weight: 'FontWeight' = None,
- style: 'FontStyle' = None,
- decoration: 'TextDecoration' = None,
- background: Optional[Tuple[int, int, int, int]] = None,
- language: str = "en_EN",
- min_hyphenation_width: Optional[int] = None) -> 'Font':
+ font_path: Optional[str] = None,
+ font_size: int = 16,
+ colour: Tuple[int, int, int] = (0, 0, 0),
+ weight: 'FontWeight' = None,
+ style: 'FontStyle' = None,
+ decoration: 'TextDecoration' = None,
+ background: Optional[Tuple[int, int, int, int]] = None,
+ language: str = "en_EN",
+ min_hyphenation_width: Optional[int] = None) -> 'Font':
"""
Get or create a font with the specified properties.
@@ -222,7 +225,11 @@ class FontRegistry:
decoration = TextDecoration.NONE
# If we have a parent with font management, delegate to parent
- if hasattr(self, '_parent') and self._parent and hasattr(self._parent, 'get_or_create_font'):
+ if hasattr(
+ self,
+ '_parent') and self._parent and hasattr(
+ self._parent,
+ 'get_or_create_font'):
return self._parent.get_or_create_font(
font_path=font_path,
font_size=font_size,
@@ -409,8 +416,8 @@ class ContainerAware:
"""
if not hasattr(container, required_method):
raise AttributeError(
- f"Container {type(container).__name__} must have a '{required_method}' method"
- )
+ f"Container {
+ type(container).__name__} must have a '{required_method}' method")
@classmethod
def _inherit_style(cls, container, style=None):
diff --git a/pyWebLayout/core/callback_registry.py b/pyWebLayout/core/callback_registry.py
index ecadab7..612cee3 100644
--- a/pyWebLayout/core/callback_registry.py
+++ b/pyWebLayout/core/callback_registry.py
@@ -8,7 +8,7 @@ and managing their callbacks. Supports multiple binding strategies:
- Type-based batch operations
"""
-from typing import Dict, List, Optional, Callable, Any
+from typing import Dict, List, Optional, Callable
from pyWebLayout.core.base import Interactable
@@ -30,7 +30,7 @@ class CallbackRegistry:
"""Initialize an empty callback registry."""
self._by_reference: Dict[int, Interactable] = {} # id(obj) -> obj
self._by_id: Dict[str, Interactable] = {} # HTML id or auto id -> obj
- self._by_type: Dict[str, List[Interactable]] = {} # type name -> [objs]
+ self._by_type: Dict[str, List[Interactable]] = {} # type name -> [objs]
self._auto_counter: int = 0
def register(self, obj: Interactable, html_id: Optional[str] = None) -> str:
diff --git a/pyWebLayout/core/highlight.py b/pyWebLayout/core/highlight.py
index d05dda2..20a428b 100644
--- a/pyWebLayout/core/highlight.py
+++ b/pyWebLayout/core/highlight.py
@@ -148,7 +148,8 @@ class HighlightManager:
self.highlights.clear()
self._save_highlights()
- def get_highlights_for_page(self, page_bounds: Tuple[int, int, int, int]) -> List[Highlight]:
+ def get_highlights_for_page(
+ self, page_bounds: Tuple[int, int, int, int]) -> List[Highlight]:
"""
Get highlights that appear on a specific page.
@@ -165,7 +166,7 @@ class HighlightManager:
# Check if any highlight bounds overlap with page
for hx, hy, hw, hh in highlight.bounds:
if (hx < page_x + page_w and hx + hw > page_x and
- hy < page_y + page_h and hy + hh > page_y):
+ hy < page_y + page_h and hy + hh > page_y):
page_highlights.append(highlight)
break
diff --git a/pyWebLayout/core/query.py b/pyWebLayout/core/query.py
index ccf7a4d..eaa4e9f 100644
--- a/pyWebLayout/core/query.py
+++ b/pyWebLayout/core/query.py
@@ -9,7 +9,6 @@ and text selection.
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional, Tuple, List, Any, TYPE_CHECKING
-import numpy as np
if TYPE_CHECKING:
from pyWebLayout.core.base import Queriable
diff --git a/pyWebLayout/io/__init__.py b/pyWebLayout/io/__init__.py
index d7d47f7..0efe1ac 100644
--- a/pyWebLayout/io/__init__.py
+++ b/pyWebLayout/io/__init__.py
@@ -6,4 +6,3 @@ including HTML, EPUB, and other document formats.
"""
# Readers
-from pyWebLayout.io.readers.epub_reader import EPUBReader
diff --git a/pyWebLayout/io/readers/epub_reader.py b/pyWebLayout/io/readers/epub_reader.py
index 239aa51..a8f4330 100644
--- a/pyWebLayout/io/readers/epub_reader.py
+++ b/pyWebLayout/io/readers/epub_reader.py
@@ -8,13 +8,12 @@ to pyWebLayout's abstract document model.
import os
import zipfile
import tempfile
-from typing import Dict, List, Optional, Any, Tuple, Callable
+from typing import Dict, List, Optional, Any, Callable
import xml.etree.ElementTree as ET
-import re
import urllib.parse
from PIL import Image as PILImage, ImageOps
-from pyWebLayout.abstract.document import Document, Book, Chapter, MetadataType
+from pyWebLayout.abstract.document import Book, Chapter, MetadataType
from pyWebLayout.abstract.block import PageBreak
from pyWebLayout.io.readers.html_extraction import parse_html_string
@@ -33,38 +32,39 @@ def default_eink_processor(img: PILImage.Image) -> PILImage.Image:
"""
Process image for 4-bit e-ink display using PIL only.
Applies histogram equalization and 4-bit quantization.
-
+
Args:
img: PIL Image to process
-
+
Returns:
Processed PIL Image in L mode (grayscale) with 4-bit quantization
"""
# Convert to grayscale if needed
if img.mode != 'L':
img = img.convert('L')
-
+
# Apply histogram equalization for contrast enhancement
img = ImageOps.equalize(img)
-
+
# Quantize to 4-bit (16 grayscale levels: 0, 17, 34, ..., 255)
img = img.point(lambda x: (x // 16) * 17)
-
+
return img
class EPUBReader:
"""
Reader for EPUB documents.
-
+
This class extracts content from EPUB files and converts it to
pyWebLayout's abstract document model.
"""
-
- def __init__(self, epub_path: str, image_processor: Optional[Callable[[PILImage.Image], PILImage.Image]] = default_eink_processor):
+
+ def __init__(self, epub_path: str, image_processor: Optional[Callable[[
+ PILImage.Image], PILImage.Image]] = default_eink_processor):
"""
Initialize an EPUB reader.
-
+
Args:
epub_path: Path to the EPUB file
image_processor: Optional function to process images for display optimization.
@@ -82,11 +82,11 @@ class EPUBReader:
self.spine = []
self.manifest = {}
self.cover_id = None # ID of the cover image in manifest
-
+
def read(self) -> Book:
"""
Read the EPUB file and convert it to a Book.
-
+
Returns:
Book: The parsed book
"""
@@ -100,45 +100,47 @@ class EPUBReader:
# Add chapters to the book
self._add_chapters()
-
+
# Process images for e-ink display optimization
self._process_content_images()
-
+
return self.book
-
+
finally:
# Clean up temporary files
if self.temp_dir:
import shutil
shutil.rmtree(self.temp_dir, ignore_errors=True)
-
+
def _extract_epub(self):
"""Extract the EPUB file to a temporary directory."""
with zipfile.ZipFile(self.epub_path, 'r') as zip_ref:
zip_ref.extractall(self.temp_dir)
-
+
# Find the content directory (typically OEBPS or OPS)
container_path = os.path.join(self.temp_dir, 'META-INF', 'container.xml')
if os.path.exists(container_path):
tree = ET.parse(container_path)
root = tree.getroot()
-
+
# Get the path to the package document (content.opf)
- for rootfile in root.findall('.//{urn:oasis:names:tc:opendocument:xmlns:container}rootfile'):
+ for rootfile in root.findall(
+ './/{urn:oasis:names:tc:opendocument:xmlns:container}rootfile'):
full_path = rootfile.get('full-path')
if full_path:
- self.content_dir = os.path.dirname(os.path.join(self.temp_dir, full_path))
+ self.content_dir = os.path.dirname(
+ os.path.join(self.temp_dir, full_path))
return
-
+
# Fallback: look for common content directories
for content_dir in ['OEBPS', 'OPS', 'Content']:
if os.path.exists(os.path.join(self.temp_dir, content_dir)):
self.content_dir = os.path.join(self.temp_dir, content_dir)
return
-
+
# If no content directory found, use the root
self.content_dir = self.temp_dir
-
+
def _parse_package_document(self):
"""Parse the package document (content.opf)."""
# Find the package document
@@ -150,27 +152,27 @@ class EPUBReader:
break
if opf_path:
break
-
+
if not opf_path:
raise ValueError("No package document (.opf) found in EPUB")
-
+
# Parse the package document
tree = ET.parse(opf_path)
root = tree.getroot()
-
+
# Parse metadata
self._parse_metadata(root)
-
+
# Parse manifest
self._parse_manifest(root)
-
+
# Parse spine
self._parse_spine(root)
-
+
def _parse_metadata(self, root: ET.Element):
"""
Parse metadata from the package document.
-
+
Args:
root: Root element of the package document
"""
@@ -178,14 +180,14 @@ class EPUBReader:
metadata_elem = root.find('.//{{{0}}}metadata'.format(NAMESPACES['opf']))
if metadata_elem is None:
return
-
+
# Parse DC metadata
for elem in metadata_elem:
if elem.tag.startswith('{{{0}}}'.format(NAMESPACES['dc'])):
# Get the local name (without namespace)
name = elem.tag.split('}', 1)[1]
value = elem.text
-
+
if name == 'title':
self.metadata['title'] = value
elif name == 'creator':
@@ -207,20 +209,20 @@ class EPUBReader:
else:
# Store other metadata
self.metadata[name] = value
-
+
# Parse meta elements for cover reference
for meta in metadata_elem.findall('.//{{{0}}}meta'.format(NAMESPACES['opf'])):
name = meta.get('name')
content = meta.get('content')
-
+
if name == 'cover' and content:
# This is a reference to the cover image in the manifest
self.cover_id = content
-
+
def _parse_manifest(self, root: ET.Element):
"""
Parse manifest from the package document.
-
+
Args:
root: Root element of the package document
"""
@@ -228,28 +230,28 @@ class EPUBReader:
manifest_elem = root.find('.//{{{0}}}manifest'.format(NAMESPACES['opf']))
if manifest_elem is None:
return
-
+
# Parse items
for item in manifest_elem.findall('.//{{{0}}}item'.format(NAMESPACES['opf'])):
id = item.get('id')
href = item.get('href')
media_type = item.get('media-type')
-
+
if id and href:
# Resolve relative path
href = urllib.parse.unquote(href)
path = os.path.normpath(os.path.join(self.content_dir, href))
-
+
self.manifest[id] = {
'href': href,
'path': path,
'media_type': media_type
}
-
+
def _parse_spine(self, root: ET.Element):
"""
Parse spine from the package document.
-
+
Args:
root: Root element of the package document
"""
@@ -257,21 +259,25 @@ class EPUBReader:
spine_elem = root.find('.//{{{0}}}spine'.format(NAMESPACES['opf']))
if spine_elem is None:
return
-
+
# Get the toc attribute (NCX file ID)
toc_id = spine_elem.get('toc')
if toc_id and toc_id in self.manifest:
self.toc_path = self.manifest[toc_id]['path']
-
+
# Parse itemrefs
- for itemref in spine_elem.findall('.//{{{0}}}itemref'.format(NAMESPACES['opf'])):
+ for itemref in spine_elem.findall(
+ './/{{{0}}}itemref'.format(NAMESPACES['opf'])):
idref = itemref.get('idref')
if idref and idref in self.manifest:
self.spine.append(idref)
-
+
def _parse_toc(self):
"""Parse the table of contents."""
- if not hasattr(self, 'toc_path') or not self.toc_path or not os.path.exists(self.toc_path):
+ if not hasattr(
+ self,
+ 'toc_path') or not self.toc_path or not os.path.exists(
+ self.toc_path):
# Try to find the toc.ncx file
for root, dirs, files in os.walk(self.content_dir):
for file in files:
@@ -280,27 +286,30 @@ class EPUBReader:
break
if hasattr(self, 'toc_path') and self.toc_path:
break
-
- if not hasattr(self, 'toc_path') or not self.toc_path or not os.path.exists(self.toc_path):
+
+ if not hasattr(
+ self,
+ 'toc_path') or not self.toc_path or not os.path.exists(
+ self.toc_path):
# No TOC found
return
-
+
# Parse the NCX file
tree = ET.parse(self.toc_path)
root = tree.getroot()
-
+
# Parse navMap
nav_map = root.find('.//{{{0}}}navMap'.format(NAMESPACES['ncx']))
if nav_map is None:
return
-
+
# Parse navPoints
self._parse_nav_points(nav_map, [])
-
+
def _parse_nav_points(self, parent: ET.Element, path: List[Dict[str, Any]]):
"""
Recursively parse navPoints from the NCX file.
-
+
Args:
parent: Parent element containing navPoints
path: Current path in the TOC hierarchy
@@ -309,16 +318,17 @@ class EPUBReader:
# Get navPoint attributes
id = nav_point.get('id')
play_order = nav_point.get('playOrder')
-
+
# Get navLabel
nav_label = nav_point.find('.//{{{0}}}navLabel'.format(NAMESPACES['ncx']))
- text_elem = nav_label.find('.//{{{0}}}text'.format(NAMESPACES['ncx'])) if nav_label else None
+ text_elem = nav_label.find(
+ './/{{{0}}}text'.format(NAMESPACES['ncx'])) if nav_label else None
label = text_elem.text if text_elem is not None else ""
-
+
# Get content
content = nav_point.find('.//{{{0}}}content'.format(NAMESPACES['ncx']))
src = content.get('src') if content is not None else ""
-
+
# Create a TOC entry
entry = {
'id': id,
@@ -327,78 +337,83 @@ class EPUBReader:
'play_order': play_order,
'children': []
}
-
+
# Add to TOC
if path:
path[-1]['children'].append(entry)
else:
self.toc.append(entry)
-
+
# Parse child navPoints
self._parse_nav_points(nav_point, path + [entry])
-
+
def _create_book(self):
"""Create a Book object from the parsed metadata."""
# Set book metadata
if 'title' in self.metadata:
self.book.set_title(self.metadata['title'])
-
+
if 'creator' in self.metadata:
self.book.set_metadata(MetadataType.AUTHOR, self.metadata['creator'])
-
+
if 'language' in self.metadata:
self.book.set_metadata(MetadataType.LANGUAGE, self.metadata['language'])
-
+
if 'description' in self.metadata:
- self.book.set_metadata(MetadataType.DESCRIPTION, self.metadata['description'])
-
+ self.book.set_metadata(
+ MetadataType.DESCRIPTION,
+ self.metadata['description'])
+
if 'subjects' in self.metadata:
- self.book.set_metadata(MetadataType.KEYWORDS, ', '.join(self.metadata['subjects']))
-
+ self.book.set_metadata(
+ MetadataType.KEYWORDS, ', '.join(
+ self.metadata['subjects']))
+
if 'date' in self.metadata:
self.book.set_metadata(MetadataType.PUBLICATION_DATE, self.metadata['date'])
-
+
if 'identifier' in self.metadata:
self.book.set_metadata(MetadataType.IDENTIFIER, self.metadata['identifier'])
-
+
if 'publisher' in self.metadata:
self.book.set_metadata(MetadataType.PUBLISHER, self.metadata['publisher'])
-
+
def _add_cover_chapter(self):
"""Add a cover chapter if a cover image is available."""
if not self.cover_id or self.cover_id not in self.manifest:
return
-
+
# Get the cover image path from the manifest
cover_item = self.manifest[self.cover_id]
cover_path = cover_item['path']
-
+
# Check if the file exists
if not os.path.exists(cover_path):
print(f"Warning: Cover image file not found: {cover_path}")
return
-
+
# Create a cover chapter
cover_chapter = self.book.create_chapter("Cover", 0)
-
+
try:
# Create an Image block for the cover
from pyWebLayout.abstract.block import Image as AbstractImage
from PIL import Image as PILImage
import io
-
+
# Load the image into memory before the temp directory is cleaned up
- # We need to fully copy the image data to ensure it persists after temp cleanup
+ # We need to fully copy the image data to ensure it persists after temp
+ # cleanup
with open(cover_path, 'rb') as f:
image_bytes = f.read()
-
+
# Create PIL image from bytes in memory
pil_image = PILImage.open(io.BytesIO(image_bytes))
pil_image.load() # Force loading into memory
-
+
# Create a copy to ensure all data is in memory
pil_image = pil_image.copy()
-
+
# Apply image processing if enabled
if self.image_processor:
try:
@@ -406,20 +421,21 @@ class EPUBReader:
except Exception as e:
print(f"Warning: Image processing failed for cover: {str(e)}")
# Continue with unprocessed image
-
+
# Create an AbstractImage block with the cover image path
cover_image = AbstractImage(source=cover_path, alt_text="Cover Image")
-
+
# Set dimensions from the loaded image
cover_image._width = pil_image.width
cover_image._height = pil_image.height
-
- # Store the loaded PIL image in the abstract image so it persists after temp cleanup
+
+ # Store the loaded PIL image in the abstract image so it persists after
+ # temp cleanup
cover_image._loaded_image = pil_image
-
+
# Add the image to the cover chapter
cover_chapter.add_block(cover_image)
-
+
except Exception as e:
print(f"Error creating cover chapter: {str(e)}")
import traceback
@@ -427,16 +443,16 @@ class EPUBReader:
# If we can't create the cover image, remove the chapter
if hasattr(self.book, 'chapters') and cover_chapter in self.book.chapters:
self.book.chapters.remove(cover_chapter)
-
+
def _process_chapter_images(self, chapter: Chapter):
"""
Process images in a single chapter.
-
+
Args:
chapter: The chapter containing images to process
"""
from pyWebLayout.abstract.block import Image as AbstractImage
-
+
for block in chapter.blocks:
if isinstance(block, AbstractImage):
# Only process if image has been loaded and processor is enabled
@@ -444,25 +460,28 @@ class EPUBReader:
try:
block._loaded_image = self.image_processor(block._loaded_image)
except Exception as e:
- print(f"Warning: Image processing failed for image '{block.alt_text}': {str(e)}")
+ print(
+ f"Warning: Image processing failed for image '{
+ block.alt_text}': {
+ str(e)}")
# Continue with unprocessed image
-
+
def _process_content_images(self):
"""Apply image processing to all images in chapters."""
if not self.image_processor:
return
-
+
for chapter in self.book.chapters:
self._process_chapter_images(chapter)
-
+
def _add_chapters(self):
"""Add chapters to the book based on the spine and TOC."""
# Add cover chapter first if available
self._add_cover_chapter()
-
+
# Create a mapping from src to TOC entry
toc_map = {}
-
+
def add_to_toc_map(entries):
for entry in entries:
if entry['src']:
@@ -470,58 +489,58 @@ class EPUBReader:
src_parts = entry['src'].split('#', 1)
path = src_parts[0]
toc_map[path] = entry
-
+
# Process children
if entry['children']:
add_to_toc_map(entry['children'])
-
+
add_to_toc_map(self.toc)
-
+
# Process spine items
# Start from chapter_index = 1 if cover was added, otherwise 0
chapter_index = 1 if (self.cover_id and self.cover_id in self.manifest) else 0
for i, idref in enumerate(self.spine):
if idref not in self.manifest:
continue
-
+
item = self.manifest[idref]
path = item['path']
href = item['href']
-
+
# Skip navigation files
- if (idref == 'nav' or
- item.get('media_type') == 'application/xhtml+xml' and
- ('nav' in href.lower() or 'toc' in href.lower())):
+ if (idref == 'nav' or
+ item.get('media_type') == 'application/xhtml+xml' and
+ ('nav' in href.lower() or 'toc' in href.lower())):
continue
-
+
# Check if this item is in the TOC
chapter_title = None
if href in toc_map:
chapter_title = toc_map[href]['label']
-
+
# Create a chapter
chapter_index += 1
chapter = self.book.create_chapter(chapter_title, chapter_index)
-
+
# Parse the HTML content
try:
# Read the HTML file
with open(path, 'r', encoding='utf-8') as f:
html = f.read()
-
+
# Parse HTML and add blocks to chapter
blocks = parse_html_string(html, document=self.book)
-
+
# Copy blocks to the chapter
for block in blocks:
chapter.add_block(block)
-
+
# Add a PageBreak after the chapter to ensure next chapter starts on new page
# This helps maintain chapter boundaries during pagination
chapter.add_block(PageBreak())
-
+
except Exception as e:
- print(f"Error parsing chapter {i+1}: {str(e)}")
+ print(f"Error parsing chapter {i + 1}: {str(e)}")
# Add an error message block
from pyWebLayout.abstract.block import Paragraph
from pyWebLayout.abstract.inline import Word
@@ -529,7 +548,11 @@ class EPUBReader:
error_para = Paragraph()
# Create a default font style for the error message
default_font = Font()
- error_para.add_word(Word(f"Error loading chapter: {str(e)}", default_font))
+ error_para.add_word(
+ Word(
+ f"Error loading chapter: {
+ str(e)}",
+ default_font))
chapter.add_block(error_para)
# Still add PageBreak even after error
chapter.add_block(PageBreak())
@@ -538,10 +561,10 @@ class EPUBReader:
def read_epub(epub_path: str) -> Book:
"""
Read an EPUB file and convert it to a Book.
-
+
Args:
epub_path: Path to the EPUB file
-
+
Returns:
Book: The parsed book
"""
diff --git a/pyWebLayout/io/readers/html_extraction.py b/pyWebLayout/io/readers/html_extraction.py
index 51ed4d6..152be3b 100644
--- a/pyWebLayout/io/readers/html_extraction.py
+++ b/pyWebLayout/io/readers/html_extraction.py
@@ -6,10 +6,9 @@ used by pyWebLayout, including paragraphs, headings, lists, tables, and inline f
Each handler function has a robust signature that handles style hints, CSS classes, and attributes.
"""
-import re
from typing import List, Dict, Any, Optional, Union, Callable, Tuple, NamedTuple
from bs4 import BeautifulSoup, Tag, NavigableString
-from pyWebLayout.abstract.inline import Word, FormattedSpan
+from pyWebLayout.abstract.inline import Word
from pyWebLayout.abstract.block import (
Block,
Paragraph,
@@ -27,8 +26,6 @@ from pyWebLayout.abstract.block import (
Image,
)
from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration
-from pyWebLayout.style.abstract_style import AbstractStyle, FontFamily, FontSize
-from pyWebLayout.style import Alignment as TextAlign
class StyleContext(NamedTuple):
@@ -72,7 +69,9 @@ class StyleContext(NamedTuple):
return self._replace(parent_elements=self.parent_elements + [element_name])
-def create_base_context(base_font: Optional[Font] = None, document=None) -> StyleContext:
+def create_base_context(
+ base_font: Optional[Font] = None,
+ document=None) -> StyleContext:
"""
Create a base style context with default values.
@@ -130,7 +129,8 @@ def apply_element_styling(context: StyleContext, element: Tag) -> StyleContext:
new_context = new_context.with_css_styles(css_styles)
# Apply element-specific default styles
- font = apply_element_font_styles(new_context.font, tag_name, css_styles, new_context)
+ font = apply_element_font_styles(
+ new_context.font, tag_name, css_styles, new_context)
new_context = new_context.with_font(font)
# Apply background from styles
@@ -158,9 +158,11 @@ def parse_inline_styles(style_text: str) -> Dict[str, str]:
return styles
-def apply_element_font_styles(
- font: Font, tag_name: str, css_styles: Dict[str, str], context: Optional[StyleContext] = None
-) -> Font:
+def apply_element_font_styles(font: Font,
+ tag_name: str,
+ css_styles: Dict[str,
+ str],
+ context: Optional[StyleContext] = None) -> Font:
"""
Apply font styling based on HTML element and CSS styles.
Uses document's font registry when available to avoid creating duplicate fonts.
@@ -273,17 +275,19 @@ def apply_element_font_styles(
pass
# Use document's style registry if available to avoid creating duplicate styles
- if context and context.document and hasattr(context.document, 'get_or_create_style'):
+ if context and context.document and hasattr(
+ context.document, 'get_or_create_style'):
# Create an abstract style first
from pyWebLayout.style.abstract_style import FontFamily, FontSize
-
+
# Map font properties to abstract style properties
font_family = FontFamily.SERIF # Default - could be enhanced to detect from font_path
if font_size:
- font_size_value = font_size if isinstance(font_size, int) else FontSize.MEDIUM
+ font_size_value = font_size if isinstance(
+ font_size, int) else FontSize.MEDIUM
else:
font_size_value = FontSize.MEDIUM
-
+
# Create abstract style and register it
style_id, abstract_style = context.document.get_or_create_style(
font_family=font_family,
@@ -294,7 +298,7 @@ def apply_element_font_styles(
color=colour,
language=language
)
-
+
# Get the concrete font for this style
return context.document.get_font_for_style(abstract_style)
elif context and context.document and hasattr(context.document, 'get_or_create_font'):
@@ -359,7 +363,7 @@ def extract_text_content(element: Tag, context: StyleContext) -> List[Word]:
"""
from pyWebLayout.abstract.inline import LinkedWord
from pyWebLayout.abstract.functional import LinkType
-
+
words = []
for child in element.children:
@@ -385,14 +389,14 @@ def extract_text_content(element: Tag, context: StyleContext) -> List[Word]:
link_type = LinkType.API
else:
link_type = LinkType.INTERNAL
-
+
# Apply link styling
child_context = apply_element_styling(context, child)
-
+
# Extract text and create LinkedWord for each word
link_text = child.get_text(strip=True)
title = child.get('title', '')
-
+
for word_text in link_text.split():
if word_text:
linked_word = LinkedWord(
@@ -409,7 +413,7 @@ def extract_text_content(element: Tag, context: StyleContext) -> List[Word]:
child_context = apply_element_styling(context, child)
child_words = extract_text_content(child, child_context)
words.extend(child_words)
-
+
# Process other inline elements
elif child.name.lower() in [
"span",
@@ -435,7 +439,8 @@ def extract_text_content(element: Tag, context: StyleContext) -> List[Word]:
child_words = extract_text_content(child, child_context)
words.extend(child_words)
else:
- # Block element - shouldn't happen in well-formed HTML but handle gracefully
+ # Block element - shouldn't happen in well-formed HTML but handle
+ # gracefully
child_context = apply_element_styling(context, child)
child_result = process_element(child, child_context)
if isinstance(child_result, list):
@@ -469,7 +474,8 @@ def process_element(
# Handler function signatures:
-# All handlers receive (element: Tag, context: StyleContext) -> Union[Block, List[Block], None]
+# All handlers receive (element: Tag, context: StyleContext) ->
+# Union[Block, List[Block], None]
def paragraph_handler(element: Tag, context: StyleContext) -> Paragraph:
diff --git a/pyWebLayout/layout/document_layouter.py b/pyWebLayout/layout/document_layouter.py
index bd4d2b8..c8396db 100644
--- a/pyWebLayout/layout/document_layouter.py
+++ b/pyWebLayout/layout/document_layouter.py
@@ -5,16 +5,22 @@ import numpy as np
from pyWebLayout.concrete import Page, Line, Text
from pyWebLayout.concrete.image import RenderableImage
-from pyWebLayout.concrete.functional import LinkText, ButtonText, FormFieldText
+from pyWebLayout.concrete.functional import ButtonText, FormFieldText
from pyWebLayout.concrete.table import TableRenderer, TableStyle
-from pyWebLayout.abstract import Paragraph, Word, Link
+from pyWebLayout.abstract import Paragraph, Word
from pyWebLayout.abstract.block import Image as AbstractImage, PageBreak, Table
from pyWebLayout.abstract.functional import Button, Form, FormField
-from pyWebLayout.abstract.inline import LinkedWord
from pyWebLayout.style.concrete_style import ConcreteStyleRegistry, RenderingContext, StyleResolver
from pyWebLayout.style import Font, Alignment
-def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pretext: Optional[Text] = None, alignment_override: Optional['Alignment'] = None) -> Tuple[bool, Optional[int], Optional[Text]]:
+
+def paragraph_layouter(paragraph: Paragraph,
+ page: Page,
+ start_word: int = 0,
+ pretext: Optional[Text] = None,
+ alignment_override: Optional['Alignment'] = None) -> Tuple[bool,
+ Optional[int],
+ Optional[Text]]:
"""
Layout a paragraph of text within a given page.
@@ -44,7 +50,7 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
# paragraph.style is already a Font object (concrete), not AbstractStyle
# We need to get word spacing constraints from the Font's abstract style if available
# For now, use reasonable defaults based on font size
-
+
if isinstance(paragraph.style, Font):
# paragraph.style is already a Font (concrete style)
font = paragraph.style
@@ -63,7 +69,7 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
base_font_size = 16
else:
base_font_size = int(paragraph.style.font_size)
-
+
rendering_context = RenderingContext(base_font_size=base_font_size)
style_resolver = StyleResolver(rendering_context)
style_registry = ConcreteStyleRegistry(style_resolver)
@@ -76,7 +82,11 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
text_align = concrete_style.text_align
# Apply page-level word spacing override if specified
- if hasattr(page.style, 'word_spacing') and isinstance(page.style.word_spacing, int) and page.style.word_spacing > 0:
+ if hasattr(
+ page.style,
+ 'word_spacing') and isinstance(
+ page.style.word_spacing,
+ int) and page.style.word_spacing > 0:
# Add the page-level word spacing to both min and max constraints
min_ws, max_ws = word_spacing_constraints
word_spacing_constraints = (
@@ -87,7 +97,7 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
# Apply alignment override if provided
if alignment_override is not None:
text_align = alignment_override
-
+
# Cap font size to page maximum if needed
if font.font_size > page.style.max_font_size:
font = Font(
@@ -99,7 +109,7 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
decoration=font.decoration,
background=font.background
)
-
+
# Calculate baseline-to-baseline spacing: font size + additional line spacing
# This is the vertical distance between baselines of consecutive lines
# Formula: baseline_spacing = font_size + line_spacing (absolute pixels)
@@ -108,18 +118,20 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
if not isinstance(line_spacing_value, int):
line_spacing_value = 5
baseline_spacing = font.font_size + line_spacing_value
-
+
# Get font metrics for boundary checking
ascent, descent = font.font.getmetrics()
- def create_new_line(word: Optional[Union[Word, Text]] = None, is_first_line: bool = False) -> Optional[Line]:
+ def create_new_line(word: Optional[Union[Word, Text]] = None,
+ is_first_line: bool = False) -> Optional[Line]:
"""Helper function to create a new line, returns None if page is full."""
# Check if this line's baseline and descenders would fit on the page
if not page.can_fit_line(baseline_spacing, ascent, descent):
return None
# For the first line, position it so text starts at the top boundary
- # For subsequent lines, use current y_offset which tracks baseline-to-baseline spacing
+ # For subsequent lines, use current y_offset which tracks
+ # baseline-to-baseline spacing
if is_first_line:
# Position line origin so that baseline (origin + ascent) is close to top
# We want minimal space above the text, so origin should be at boundary
@@ -131,9 +143,9 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
# Create a temporary Text object to calculate word width
if word:
temp_text = Text.from_word(word, page.draw)
- word_width = temp_text.width
+ temp_text.width
else:
- word_width = 0
+ pass
return Line(
spacing=word_spacing_constraints,
@@ -163,7 +175,7 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
# but we may want to create LinkText for LinkedWord instances in future
# For now, the abstract layer (LinkedWord) carries the link info,
# and the concrete layer (LinkText) would be created during rendering
-
+
success, overflow_text = current_line.add_word(word, current_pretext)
if success:
@@ -195,7 +207,19 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
# Word is too wide for the line, we need to hyphenate it
if len(word.text) >= 6:
# Try to hyphenate the word
- splits = [(Text(pair[0], word.style, page.draw, line=current_line, source=word), Text(pair[1], word.style, page.draw, line=current_line, source=word)) for pair in word.possible_hyphenation()]
+ splits = [
+ (Text(
+ pair[0],
+ word.style,
+ page.draw,
+ line=current_line,
+ source=word),
+ Text(
+ pair[1],
+ word.style,
+ page.draw,
+ line=current_line,
+ source=word)) for pair in word.possible_hyphenation()]
if len(splits) > 0:
# Use the first hyphenation point
first_part, second_part = splits[0]
@@ -230,15 +254,15 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
def pagebreak_layouter(page_break: PageBreak, page: Page) -> bool:
"""
Handle a page break element.
-
+
A page break signals that all subsequent content should start on a new page.
This function always returns False to indicate that the current page is complete
and a new page should be created for subsequent content.
-
+
Args:
page_break: The PageBreak block
page: The current page (not used, but kept for consistency)
-
+
Returns:
bool: Always False to force creation of a new page
"""
@@ -246,48 +270,49 @@ def pagebreak_layouter(page_break: PageBreak, page: Page) -> bool:
return False
-def image_layouter(image: AbstractImage, page: Page, max_width: Optional[int] = None,
+def image_layouter(image: AbstractImage, page: Page, max_width: Optional[int] = None,
max_height: Optional[int] = None) -> bool:
"""
Layout an image within a given page.
-
+
This function places an image on the page, respecting size constraints
and available space. Images are centered horizontally by default.
-
+
Args:
image: The abstract Image object to layout
page: The page to layout the image on
max_width: Maximum width constraint (defaults to page available width)
max_height: Maximum height constraint (defaults to remaining page height)
-
+
Returns:
bool: True if image was successfully laid out, False if page ran out of space
"""
# Use page available width if max_width not specified
if max_width is None:
max_width = page.available_width
-
+
# Calculate available height on page
available_height = page.size[1] - page._current_y_offset - page.border_size
if max_height is None:
max_height = available_height
else:
max_height = min(max_height, available_height)
-
+
# Calculate scaled dimensions
- scaled_width, scaled_height = image.calculate_scaled_dimensions(max_width, max_height)
-
+ scaled_width, scaled_height = image.calculate_scaled_dimensions(
+ max_width, max_height)
+
# Check if image fits on current page
if scaled_height is None or scaled_height > available_height:
return False
-
+
# Create renderable image
x_offset = page.border_size
y_offset = page._current_y_offset
-
+
# Access page.draw to ensure canvas is initialized
_ = page.draw
-
+
renderable_image = RenderableImage(
image=image,
canvas=page._canvas,
@@ -298,14 +323,17 @@ def image_layouter(image: AbstractImage, page: Page, max_width: Optional[int] =
halign=Alignment.CENTER,
valign=Alignment.TOP
)
-
+
# Add to page
page.add_child(renderable_image)
-
+
return True
-def table_layouter(table: Table, page: Page, style: Optional[TableStyle] = None) -> bool:
+def table_layouter(
+ table: Table,
+ page: Page,
+ style: Optional[TableStyle] = None) -> bool:
"""
Layout a table within a given page.
@@ -356,8 +384,17 @@ def table_layouter(table: Table, page: Page, style: Optional[TableStyle] = None)
return True
-def button_layouter(button: Button, page: Page, font: Optional[Font] = None,
- padding: Tuple[int, int, int, int] = (4, 8, 4, 8)) -> Tuple[bool, str]:
+def button_layouter(button: Button,
+ page: Page,
+ font: Optional[Font] = None,
+ padding: Tuple[int,
+ int,
+ int,
+ int] = (4,
+ 8,
+ 4,
+ 8)) -> Tuple[bool,
+ str]:
"""
Layout a button within a given page and register it for callback binding.
@@ -510,17 +547,17 @@ def form_layouter(form: Form, page: Page, font: Optional[Font] = None,
class DocumentLayouter:
"""
Document layouter that orchestrates layout of various abstract elements.
-
+
Delegates to specialized layouters for different content types:
- paragraph_layouter for text paragraphs
- image_layouter for images
- table_layouter for tables
-
+
This class acts as a coordinator, managing the overall document flow
and page context while delegating specific layout tasks to specialized
layouter functions.
"""
-
+
def __init__(self, page: Page):
"""
Initialize the document layouter with a page.
@@ -538,24 +575,28 @@ class DocumentLayouter:
context = RenderingContext()
style_resolver = StyleResolver(context)
self.style_registry = ConcreteStyleRegistry(style_resolver)
-
- def layout_paragraph(self, paragraph: Paragraph, start_word: int = 0,
- pretext: Optional[Text] = None) -> Tuple[bool, Optional[int], Optional[Text]]:
+
+ def layout_paragraph(self,
+ paragraph: Paragraph,
+ start_word: int = 0,
+ pretext: Optional[Text] = None) -> Tuple[bool,
+ Optional[int],
+ Optional[Text]]:
"""
Layout a paragraph using the paragraph_layouter.
-
+
Args:
paragraph: The paragraph to layout
start_word: Index of the first word to process (for continuation)
pretext: Optional pretext from a previous hyphenated word
-
+
Returns:
Tuple of (success, failed_word_index, remaining_pretext)
"""
return paragraph_layouter(paragraph, self.page, start_word, pretext)
-
+
def layout_image(self, image: AbstractImage, max_width: Optional[int] = None,
- max_height: Optional[int] = None) -> bool:
+ max_height: Optional[int] = None) -> bool:
"""
Layout an image using the image_layouter.
@@ -582,8 +623,17 @@ class DocumentLayouter:
"""
return table_layouter(table, self.page, style)
- def layout_button(self, button: Button, font: Optional[Font] = None,
- padding: Tuple[int, int, int, int] = (4, 8, 4, 8)) -> Tuple[bool, str]:
+ def layout_button(self,
+ button: Button,
+ font: Optional[Font] = None,
+ padding: Tuple[int,
+ int,
+ int,
+ int] = (4,
+ 8,
+ 4,
+ 8)) -> Tuple[bool,
+ str]:
"""
Layout a button using the button_layouter.
@@ -612,7 +662,8 @@ class DocumentLayouter:
"""
return form_layouter(form, self.page, font, field_spacing)
- def layout_document(self, elements: List[Union[Paragraph, AbstractImage, Table, Button, Form]]) -> bool:
+ def layout_document(
+ self, elements: List[Union[Paragraph, AbstractImage, Table, Button, Form]]) -> bool:
"""
Layout a list of abstract elements (paragraphs, images, tables, buttons, and forms).
diff --git a/pyWebLayout/layout/ereader_layout.py b/pyWebLayout/layout/ereader_layout.py
index 5b0eb1f..502fb7b 100644
--- a/pyWebLayout/layout/ereader_layout.py
+++ b/pyWebLayout/layout/ereader_layout.py
@@ -13,18 +13,12 @@ with features like:
from __future__ import annotations
from dataclasses import dataclass, asdict
-from typing import List, Dict, Tuple, Optional, Union, Generator, Any
-from enum import Enum
-import json
-import multiprocessing
-from concurrent.futures import ProcessPoolExecutor, as_completed
-import threading
-import time
+from typing import List, Dict, Tuple, Optional, Any
from pyWebLayout.abstract.block import Block, Paragraph, Heading, HeadingLevel, Table, HList
from pyWebLayout.abstract.inline import Word
from pyWebLayout.concrete.page import Page
-from pyWebLayout.concrete.text import Line, Text
+from pyWebLayout.concrete.text import Text
from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.style import Font
from pyWebLayout.layout.document_layouter import paragraph_layouter
@@ -38,32 +32,33 @@ class RenderingPosition:
"""
chapter_index: int = 0 # Which chapter (based on headings)
block_index: int = 0 # Which block within chapter
- word_index: int = 0 # Which word within block (for paragraphs)
+ # Which word within block (for paragraphs)
+ word_index: int = 0
table_row: int = 0 # Which row for tables
table_col: int = 0 # Which column for tables
list_item_index: int = 0 # Which item for lists
remaining_pretext: Optional[str] = None # Hyphenated word continuation
page_y_offset: int = 0 # Vertical position on page
-
+
def to_dict(self) -> Dict[str, Any]:
"""Serialize position for saving to file/database"""
return asdict(self)
-
+
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'RenderingPosition':
"""Deserialize position from saved state"""
return cls(**data)
-
+
def copy(self) -> 'RenderingPosition':
"""Create a copy of this position"""
return RenderingPosition(**asdict(self))
-
+
def __eq__(self, other) -> bool:
"""Check if two positions are equal"""
if not isinstance(other, RenderingPosition):
return False
return asdict(self) == asdict(other)
-
+
def __hash__(self) -> int:
"""Make position hashable for use as dict key"""
return hash(tuple(asdict(self).values()))
@@ -71,8 +66,13 @@ class RenderingPosition:
class ChapterInfo:
"""Information about a chapter/section in the document"""
-
- def __init__(self, title: str, level: HeadingLevel, position: RenderingPosition, block_index: int):
+
+ def __init__(
+ self,
+ title: str,
+ level: HeadingLevel,
+ position: RenderingPosition,
+ block_index: int):
self.title = title
self.level = level
self.position = position
@@ -84,16 +84,16 @@ class ChapterNavigator:
Handles chapter/section navigation based on HTML heading structure (H1-H6).
Builds a table of contents and provides navigation capabilities.
"""
-
+
def __init__(self, blocks: List[Block]):
self.blocks = blocks
self.chapters: List[ChapterInfo] = []
self._build_chapter_map()
-
+
def _build_chapter_map(self):
"""Scan blocks for headings and build chapter navigation map"""
current_chapter_index = 0
-
+
for block_index, block in enumerate(self.blocks):
if isinstance(block, Heading):
# Create position for this heading
@@ -105,23 +105,23 @@ class ChapterNavigator:
table_col=0,
list_item_index=0
)
-
+
# Extract heading text
heading_text = self._extract_heading_text(block)
-
+
chapter_info = ChapterInfo(
title=heading_text,
level=block.level,
position=position,
block_index=block_index
)
-
+
self.chapters.append(chapter_info)
-
+
# Only increment chapter index for top-level headings (H1)
if block.level == HeadingLevel.H1:
current_chapter_index += 1
-
+
def _extract_heading_text(self, heading: Heading) -> str:
"""Extract text content from a heading block"""
words = []
@@ -129,33 +129,35 @@ class ChapterNavigator:
if isinstance(word, Word):
words.append(word.text)
return " ".join(words)
-
- def get_table_of_contents(self) -> List[Tuple[str, HeadingLevel, RenderingPosition]]:
+
+ def get_table_of_contents(
+ self) -> List[Tuple[str, HeadingLevel, RenderingPosition]]:
"""Generate table of contents from heading structure"""
- return [(chapter.title, chapter.level, chapter.position) for chapter in self.chapters]
-
+ return [(chapter.title, chapter.level, chapter.position)
+ for chapter in self.chapters]
+
def get_chapter_position(self, chapter_title: str) -> Optional[RenderingPosition]:
"""Get rendering position for a chapter by title"""
for chapter in self.chapters:
if chapter.title.lower() == chapter_title.lower():
return chapter.position
return None
-
+
def get_current_chapter(self, position: RenderingPosition) -> Optional[ChapterInfo]:
"""Determine which chapter contains the current position"""
if not self.chapters:
return None
-
+
# Find the chapter that contains this position
for i, chapter in enumerate(self.chapters):
# Check if this is the last chapter or if position is before next chapter
if i == len(self.chapters) - 1:
return chapter
-
+
next_chapter = self.chapters[i + 1]
if position.chapter_index < next_chapter.position.chapter_index:
return chapter
-
+
return self.chapters[0] if self.chapters else None
@@ -164,24 +166,24 @@ class FontScaler:
Handles font scaling operations for ereader font size adjustments.
Applies scaling at layout/render time while preserving original font objects.
"""
-
+
@staticmethod
def scale_font(font: Font, scale_factor: float) -> Font:
"""
Create a scaled version of a font for layout calculations.
-
+
Args:
font: Original font object
scale_factor: Scaling factor (1.0 = no change, 2.0 = double size, etc.)
-
+
Returns:
New Font object with scaled size
"""
if scale_factor == 1.0:
return font
-
+
scaled_size = max(1, int(font.font_size * scale_factor))
-
+
return Font(
font_path=font._font_path,
font_size=scaled_size,
@@ -193,13 +195,14 @@ class FontScaler:
language=font.language,
min_hyphenation_width=font.min_hyphenation_width
)
-
+
@staticmethod
- def scale_word_spacing(spacing: Tuple[int, int], scale_factor: float) -> Tuple[int, int]:
+ def scale_word_spacing(spacing: Tuple[int, int],
+ scale_factor: float) -> Tuple[int, int]:
"""Scale word spacing constraints proportionally"""
if scale_factor == 1.0:
return spacing
-
+
min_spacing, max_spacing = spacing
return (
max(1, int(min_spacing * scale_factor)),
@@ -212,41 +215,49 @@ class BidirectionalLayouter:
Core layout engine supporting both forward and backward page rendering.
Handles font scaling and maintains position state.
"""
-
- def __init__(self, blocks: List[Block], page_style: PageStyle, page_size: Tuple[int, int] = (800, 600), alignment_override=None):
+
+ def __init__(self,
+ blocks: List[Block],
+ page_style: PageStyle,
+ page_size: Tuple[int,
+ int] = (800,
+ 600),
+ alignment_override=None):
self.blocks = blocks
self.page_style = page_style
self.page_size = page_size
self.chapter_navigator = ChapterNavigator(blocks)
self.alignment_override = alignment_override
-
- def render_page_forward(self, position: RenderingPosition, font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
+
+ def render_page_forward(self, position: RenderingPosition,
+ font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
"""
Render a page starting from the given position, moving forward through the document.
-
+
Args:
position: Starting position in document
font_scale: Font scaling factor
-
+
Returns:
Tuple of (rendered_page, next_position)
"""
page = Page(size=self.page_size, style=self.page_style)
current_pos = position.copy()
-
+
# Start laying out blocks from the current position
while current_pos.block_index < len(self.blocks) and page.free_space()[1] > 0:
# Additional bounds check to prevent IndexError
if current_pos.block_index >= len(self.blocks):
break
-
+
block = self.blocks[current_pos.block_index]
-
+
# Apply font scaling to the block
scaled_block = self._scale_block_fonts(block, font_scale)
-
+
# Try to fit the block on the current page
- success, new_pos = self._layout_block_on_page(scaled_block, page, current_pos, font_scale)
+ success, new_pos = self._layout_block_on_page(
+ scaled_block, page, current_pos, font_scale)
if not success:
# Block doesn't fit, we're done with this page
@@ -262,45 +273,50 @@ class BidirectionalLayouter:
# We've reached the end of the document
current_pos = new_pos
break
-
+
current_pos = new_pos
-
+
return page, current_pos
-
- def render_page_backward(self, end_position: RenderingPosition, font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
+
+ def render_page_backward(self,
+ end_position: RenderingPosition,
+ font_scale: float = 1.0) -> Tuple[Page,
+ RenderingPosition]:
"""
Render a page that ends at the given position, filling backward.
Critical for "previous page" navigation.
-
+
Args:
end_position: Position where page should end
font_scale: Font scaling factor
-
+
Returns:
Tuple of (rendered_page, start_position)
"""
# This is a complex operation that requires iterative refinement
# We'll start with an estimated start position and refine it
-
+
estimated_start = self._estimate_page_start(end_position, font_scale)
-
+
# Render forward from estimated start and see if we reach the target
page, actual_end = self.render_page_forward(estimated_start, font_scale)
-
+
# If we overshot or undershot, adjust and try again
- # This is a simplified implementation - a full version would be more sophisticated
+ # This is a simplified implementation - a full version would be more
+ # sophisticated
if self._position_compare(actual_end, end_position) != 0:
# Adjust estimate and try again (simplified)
- estimated_start = self._adjust_start_estimate(estimated_start, end_position, actual_end)
+ estimated_start = self._adjust_start_estimate(
+ estimated_start, end_position, actual_end)
page, actual_end = self.render_page_forward(estimated_start, font_scale)
-
+
return page, estimated_start
-
+
def _scale_block_fonts(self, block: Block, font_scale: float) -> Block:
"""Apply font scaling to all fonts in a block"""
if font_scale == 1.0:
return block
-
+
# This is a simplified implementation
# In practice, we'd need to handle each block type appropriately
if isinstance(block, (Paragraph, Heading)):
@@ -309,20 +325,27 @@ class BidirectionalLayouter:
scaled_block = Heading(block.level, scaled_block_style)
else:
scaled_block = Paragraph(scaled_block_style)
-
+
# words_iter() returns tuples of (position, word)
for position, word in block.words_iter():
if isinstance(word, Word):
- scaled_word = Word(word.text, FontScaler.scale_font(word.style, font_scale))
+ scaled_word = Word(
+ word.text, FontScaler.scale_font(
+ word.style, font_scale))
scaled_block.add_word(scaled_word)
return scaled_block
-
+
return block
-
- def _layout_block_on_page(self, block: Block, page: Page, position: RenderingPosition, font_scale: float) -> Tuple[bool, RenderingPosition]:
+
+ def _layout_block_on_page(self,
+ block: Block,
+ page: Page,
+ position: RenderingPosition,
+ font_scale: float) -> Tuple[bool,
+ RenderingPosition]:
"""
Try to layout a block on the page starting from the given position.
-
+
Returns:
Tuple of (success, new_position)
"""
@@ -339,18 +362,23 @@ class BidirectionalLayouter:
new_pos = position.copy()
new_pos.block_index += 1
return True, new_pos
-
- def _layout_paragraph_on_page(self, paragraph: Paragraph, page: Page, position: RenderingPosition, font_scale: float) -> Tuple[bool, RenderingPosition]:
+
+ def _layout_paragraph_on_page(self,
+ paragraph: Paragraph,
+ page: Page,
+ position: RenderingPosition,
+ font_scale: float) -> Tuple[bool,
+ RenderingPosition]:
"""
Layout a paragraph on the page using the core paragraph_layouter.
Integrates font scaling and position tracking with the proven layout logic.
-
+
Args:
paragraph: The paragraph to layout (already scaled if font_scale != 1.0)
page: The page to layout on
position: Current rendering position
font_scale: Font scaling factor (used for context, paragraph should already be scaled)
-
+
Returns:
Tuple of (success, new_position)
"""
@@ -365,7 +393,7 @@ class BidirectionalLayouter:
line=None,
source=None
)
-
+
# Call the core paragraph layouter with alignment override if set
success, failed_word_index, remaining_pretext = paragraph_layouter(
paragraph,
@@ -374,10 +402,10 @@ class BidirectionalLayouter:
pretext=pretext_obj,
alignment_override=self.alignment_override
)
-
+
# Create new position based on the result
new_pos = position.copy()
-
+
if success:
# Paragraph was fully laid out, move to next block
new_pos.block_index += 1
@@ -389,25 +417,35 @@ class BidirectionalLayouter:
if failed_word_index is not None:
# Update position to the word that didn't fit
new_pos.word_index = failed_word_index
-
+
# Convert Text object back to string if there's remaining pretext
if remaining_pretext is not None and hasattr(remaining_pretext, 'text'):
new_pos.remaining_pretext = remaining_pretext.text
else:
new_pos.remaining_pretext = None
-
+
return False, new_pos
else:
# No specific word failed, but layout wasn't successful
# This shouldn't normally happen, but handle it gracefully
return False, position
-
- def _layout_heading_on_page(self, heading: Heading, page: Page, position: RenderingPosition, font_scale: float) -> Tuple[bool, RenderingPosition]:
+
+ def _layout_heading_on_page(self,
+ heading: Heading,
+ page: Page,
+ position: RenderingPosition,
+ font_scale: float) -> Tuple[bool,
+ RenderingPosition]:
"""Layout a heading on the page"""
# Similar to paragraph but with heading-specific styling
return self._layout_paragraph_on_page(heading, page, position, font_scale)
-
- def _layout_table_on_page(self, table: Table, page: Page, position: RenderingPosition, font_scale: float) -> Tuple[bool, RenderingPosition]:
+
+ def _layout_table_on_page(self,
+ table: Table,
+ page: Page,
+ position: RenderingPosition,
+ font_scale: float) -> Tuple[bool,
+ RenderingPosition]:
"""Layout a table on the page with column fitting and row continuation"""
# This is a complex operation that would need full table layout logic
# For now, skip tables
@@ -416,8 +454,13 @@ class BidirectionalLayouter:
new_pos.table_row = 0
new_pos.table_col = 0
return True, new_pos
-
- def _layout_list_on_page(self, hlist: HList, page: Page, position: RenderingPosition, font_scale: float) -> Tuple[bool, RenderingPosition]:
+
+ def _layout_list_on_page(self,
+ hlist: HList,
+ page: Page,
+ position: RenderingPosition,
+ font_scale: float) -> Tuple[bool,
+ RenderingPosition]:
"""Layout a list on the page"""
# This would need list-specific layout logic
# For now, skip lists
@@ -425,33 +468,43 @@ class BidirectionalLayouter:
new_pos.block_index += 1
new_pos.list_item_index = 0
return True, new_pos
-
- def _estimate_page_start(self, end_position: RenderingPosition, font_scale: float) -> RenderingPosition:
+
+ def _estimate_page_start(
+ self,
+ end_position: RenderingPosition,
+ font_scale: float) -> RenderingPosition:
"""Estimate where a page should start to end at the given position"""
- # This is a simplified heuristic - a full implementation would be more sophisticated
+ # This is a simplified heuristic - a full implementation would be more
+ # sophisticated
estimated_start = end_position.copy()
-
+
# Move back by an estimated number of blocks that would fit on a page
estimated_blocks_per_page = max(1, int(10 / font_scale)) # Rough estimate
- estimated_start.block_index = max(0, end_position.block_index - estimated_blocks_per_page)
+ estimated_start.block_index = max(
+ 0, end_position.block_index - estimated_blocks_per_page)
estimated_start.word_index = 0
-
+
return estimated_start
-
- def _adjust_start_estimate(self, current_start: RenderingPosition, target_end: RenderingPosition, actual_end: RenderingPosition) -> RenderingPosition:
+
+ def _adjust_start_estimate(
+ self,
+ current_start: RenderingPosition,
+ target_end: RenderingPosition,
+ actual_end: RenderingPosition) -> RenderingPosition:
"""Adjust start position estimate based on overshoot/undershoot"""
# Simplified adjustment logic
adjusted = current_start.copy()
-
+
comparison = self._position_compare(actual_end, target_end)
if comparison > 0: # Overshot
adjusted.block_index = max(0, adjusted.block_index + 1)
elif comparison < 0: # Undershot
adjusted.block_index = max(0, adjusted.block_index - 1)
-
+
return adjusted
-
- def _position_compare(self, pos1: RenderingPosition, pos2: RenderingPosition) -> int:
+
+ def _position_compare(self, pos1: RenderingPosition,
+ pos2: RenderingPosition) -> int:
"""Compare two positions (-1: pos1 < pos2, 0: equal, 1: pos1 > pos2)"""
if pos1.chapter_index != pos2.chapter_index:
return 1 if pos1.chapter_index > pos2.chapter_index else -1
@@ -470,16 +523,17 @@ def _add_page_methods():
"""Check if a line of given height can fit on the page"""
available_height = self.content_size[1] - self._current_y_offset
return available_height >= line_height
-
+
Page.can_fit_line = can_fit_line
-
+
if not hasattr(Page, 'available_width'):
@property
def available_width(self) -> int:
"""Get available width for content"""
return self.content_size[0]
-
+
Page.available_width = available_width
+
# Apply the page methods
_add_page_methods()
diff --git a/pyWebLayout/layout/ereader_manager.py b/pyWebLayout/layout/ereader_manager.py
index 0517293..5548f91 100644
--- a/pyWebLayout/layout/ereader_manager.py
+++ b/pyWebLayout/layout/ereader_manager.py
@@ -9,7 +9,6 @@ into a unified, easy-to-use API.
from __future__ import annotations
from typing import List, Dict, Optional, Tuple, Any, Callable
import json
-import os
from pathlib import Path
from .ereader_layout import RenderingPosition, ChapterNavigator, ChapterInfo
@@ -23,11 +22,11 @@ class BookmarkManager:
"""
Manages bookmarks and reading position persistence for ereader applications.
"""
-
+
def __init__(self, document_id: str, bookmarks_dir: str = "bookmarks"):
"""
Initialize bookmark manager.
-
+
Args:
document_id: Unique identifier for the document
bookmarks_dir: Directory to store bookmark files
@@ -35,13 +34,13 @@ class BookmarkManager:
self.document_id = document_id
self.bookmarks_dir = Path(bookmarks_dir)
self.bookmarks_dir.mkdir(exist_ok=True)
-
+
self.bookmarks_file = self.bookmarks_dir / f"{document_id}_bookmarks.json"
self.position_file = self.bookmarks_dir / f"{document_id}_position.json"
-
+
self._bookmarks: Dict[str, RenderingPosition] = {}
self._load_bookmarks()
-
+
def _load_bookmarks(self):
"""Load bookmarks from file"""
if self.bookmarks_file.exists():
@@ -55,7 +54,7 @@ class BookmarkManager:
except Exception as e:
print(f"Failed to load bookmarks: {e}")
self._bookmarks = {}
-
+
def _save_bookmarks(self):
"""Save bookmarks to file"""
try:
@@ -67,25 +66,25 @@ class BookmarkManager:
json.dump(data, f, indent=2)
except Exception as e:
print(f"Failed to save bookmarks: {e}")
-
+
def add_bookmark(self, name: str, position: RenderingPosition):
"""
Add a bookmark at the given position.
-
+
Args:
name: Bookmark name
position: Position to bookmark
"""
self._bookmarks[name] = position
self._save_bookmarks()
-
+
def remove_bookmark(self, name: str) -> bool:
"""
Remove a bookmark.
-
+
Args:
name: Bookmark name to remove
-
+
Returns:
True if bookmark was removed, False if not found
"""
@@ -94,32 +93,32 @@ class BookmarkManager:
self._save_bookmarks()
return True
return False
-
+
def get_bookmark(self, name: str) -> Optional[RenderingPosition]:
"""
Get a bookmark position.
-
+
Args:
name: Bookmark name
-
+
Returns:
Bookmark position or None if not found
"""
return self._bookmarks.get(name)
-
+
def list_bookmarks(self) -> List[Tuple[str, RenderingPosition]]:
"""
Get all bookmarks.
-
+
Returns:
List of (name, position) tuples
"""
return list(self._bookmarks.items())
-
+
def save_reading_position(self, position: RenderingPosition):
"""
Save the current reading position.
-
+
Args:
position: Current reading position
"""
@@ -128,11 +127,11 @@ class BookmarkManager:
json.dump(position.to_dict(), f, indent=2)
except Exception as e:
print(f"Failed to save reading position: {e}")
-
+
def load_reading_position(self) -> Optional[RenderingPosition]:
"""
Load the last reading position.
-
+
Returns:
Last reading position or None if not found
"""
@@ -149,7 +148,7 @@ class BookmarkManager:
class EreaderLayoutManager:
"""
High-level ereader layout manager providing a complete interface for ereader applications.
-
+
Features:
- Sub-second page rendering with intelligent buffering
- Font scaling support
@@ -158,17 +157,17 @@ class EreaderLayoutManager:
- Position persistence
- Progress tracking
"""
-
- def __init__(self,
- blocks: List[Block],
- page_size: Tuple[int, int],
+
+ def __init__(self,
+ blocks: List[Block],
+ page_size: Tuple[int, int],
document_id: str = "default",
buffer_size: int = 5,
page_style: Optional[PageStyle] = None,
bookmarks_dir: str = "bookmarks"):
"""
Initialize the ereader layout manager.
-
+
Args:
blocks: Document blocks to render
page_size: Page size (width, height) in pixels
@@ -180,125 +179,132 @@ class EreaderLayoutManager:
self.blocks = blocks
self.page_size = page_size
self.document_id = document_id
-
+
# Initialize page style
if page_style is None:
page_style = PageStyle()
self.page_style = page_style
-
+
# Initialize core components
self.renderer = BufferedPageRenderer(blocks, page_style, buffer_size, page_size)
self.chapter_navigator = ChapterNavigator(blocks)
self.bookmark_manager = BookmarkManager(document_id, bookmarks_dir)
-
+
# Current state
self.current_position = RenderingPosition()
self.font_scale = 1.0
-
+
# Load last reading position if available
saved_position = self.bookmark_manager.load_reading_position()
if saved_position:
self.current_position = saved_position
-
+
# Callbacks for UI updates
- self.position_changed_callback: Optional[Callable[[RenderingPosition], None]] = None
- self.chapter_changed_callback: Optional[Callable[[Optional[ChapterInfo]], None]] = None
-
- def set_position_changed_callback(self, callback: Callable[[RenderingPosition], None]):
+ self.position_changed_callback: Optional[Callable[[
+ RenderingPosition], None]] = None
+ self.chapter_changed_callback: Optional[Callable[[
+ Optional[ChapterInfo]], None]] = None
+
+ def set_position_changed_callback(
+ self, callback: Callable[[RenderingPosition], None]):
"""Set callback for position changes"""
self.position_changed_callback = callback
-
- def set_chapter_changed_callback(self, callback: Callable[[Optional[ChapterInfo]], None]):
+
+ def set_chapter_changed_callback(
+ self, callback: Callable[[Optional[ChapterInfo]], None]):
"""Set callback for chapter changes"""
self.chapter_changed_callback = callback
-
+
def _notify_position_changed(self):
"""Notify UI of position change"""
if self.position_changed_callback:
self.position_changed_callback(self.current_position)
-
+
# Check if chapter changed
- current_chapter = self.chapter_navigator.get_current_chapter(self.current_position)
+ current_chapter = self.chapter_navigator.get_current_chapter(
+ self.current_position)
if self.chapter_changed_callback:
self.chapter_changed_callback(current_chapter)
-
+
# Auto-save reading position
self.bookmark_manager.save_reading_position(self.current_position)
-
+
def get_current_page(self) -> Page:
"""
Get the page at the current reading position.
-
+
Returns:
Rendered page
"""
page, _ = self.renderer.render_page(self.current_position, self.font_scale)
return page
-
+
def next_page(self) -> Optional[Page]:
"""
Advance to the next page.
-
+
Returns:
Next page or None if at end of document
"""
- page, next_position = self.renderer.render_page(self.current_position, self.font_scale)
-
+ page, next_position = self.renderer.render_page(
+ self.current_position, self.font_scale)
+
# Check if we made progress
if next_position != self.current_position:
self.current_position = next_position
self._notify_position_changed()
return self.get_current_page()
-
+
return None # At end of document
-
+
def previous_page(self) -> Optional[Page]:
"""
Go to the previous page.
-
+
Returns:
Previous page or None if at beginning of document
"""
if self._is_at_beginning():
return None
-
+
# Use backward rendering to find the previous page
- page, start_position = self.renderer.render_page_backward(self.current_position, self.font_scale)
-
+ page, start_position = self.renderer.render_page_backward(
+ self.current_position, self.font_scale)
+
if start_position != self.current_position:
self.current_position = start_position
self._notify_position_changed()
return page
-
+
return None # At beginning of document
-
+
def _is_at_beginning(self) -> bool:
"""Check if we're at the beginning of the document"""
- return (self.current_position.chapter_index == 0 and
- self.current_position.block_index == 0 and
+ return (self.current_position.chapter_index == 0 and
+ self.current_position.block_index == 0 and
self.current_position.word_index == 0)
-
+
def jump_to_position(self, position: RenderingPosition) -> Page:
"""
Jump to a specific position in the document.
-
+
Args:
position: Position to jump to
-
+
Returns:
Page at the new position
"""
self.current_position = position
self._notify_position_changed()
return self.get_current_page()
-
+
def jump_to_chapter(self, chapter_title: str) -> Optional[Page]:
"""
Jump to a specific chapter by title.
-
+
Args:
chapter_title: Title of the chapter to jump to
-
+
Returns:
Page at chapter start or None if chapter not found
"""
@@ -306,14 +312,14 @@ class EreaderLayoutManager:
if position:
return self.jump_to_position(position)
return None
-
+
def jump_to_chapter_index(self, chapter_index: int) -> Optional[Page]:
"""
Jump to a chapter by index.
-
+
Args:
chapter_index: Index of the chapter (0-based)
-
+
Returns:
Page at chapter start or None if index invalid
"""
@@ -321,23 +327,23 @@ class EreaderLayoutManager:
if 0 <= chapter_index < len(chapters):
return self.jump_to_position(chapters[chapter_index].position)
return None
-
+
def set_font_scale(self, scale: float) -> Page:
"""
Change the font scale and re-render current page.
-
+
Args:
scale: Font scaling factor (1.0 = normal, 2.0 = double size, etc.)
-
+
Returns:
Re-rendered page with new font scale
"""
if scale != self.font_scale:
self.font_scale = scale
# The renderer will handle cache invalidation
-
+
return self.get_current_page()
-
+
def get_font_scale(self) -> float:
"""Get the current font scale"""
return self.font_scale
@@ -397,7 +403,8 @@ class EreaderLayoutManager:
Returns:
Re-rendered page with decreased block spacing
"""
- self.page_style.inter_block_spacing = max(0, self.page_style.inter_block_spacing - amount)
+ self.page_style.inter_block_spacing = max(
+ 0, self.page_style.inter_block_spacing - amount)
self.renderer.page_style = self.page_style # Update renderer's reference
self.renderer.buffer.invalidate_all() # Clear cache to force re-render
return self.get_current_page()
@@ -432,31 +439,32 @@ class EreaderLayoutManager:
self.renderer.buffer.invalidate_all() # Clear cache to force re-render
return self.get_current_page()
- def get_table_of_contents(self) -> List[Tuple[str, HeadingLevel, RenderingPosition]]:
+ def get_table_of_contents(
+ self) -> List[Tuple[str, HeadingLevel, RenderingPosition]]:
"""
Get the table of contents.
-
+
Returns:
List of (title, level, position) tuples
"""
return self.chapter_navigator.get_table_of_contents()
-
+
def get_current_chapter(self) -> Optional[ChapterInfo]:
"""
Get information about the current chapter.
-
+
Returns:
Current chapter info or None if no chapters
"""
return self.chapter_navigator.get_current_chapter(self.current_position)
-
+
def add_bookmark(self, name: str) -> bool:
"""
Add a bookmark at the current position.
-
+
Args:
name: Bookmark name
-
+
Returns:
True if bookmark was added successfully
"""
@@ -465,26 +473,26 @@ class EreaderLayoutManager:
return True
except Exception:
return False
-
+
def remove_bookmark(self, name: str) -> bool:
"""
Remove a bookmark.
-
+
Args:
name: Bookmark name
-
+
Returns:
True if bookmark was removed
"""
return self.bookmark_manager.remove_bookmark(name)
-
+
def jump_to_bookmark(self, name: str) -> Optional[Page]:
"""
Jump to a bookmark.
-
+
Args:
name: Bookmark name
-
+
Returns:
Page at bookmark position or None if bookmark not found
"""
@@ -492,42 +500,42 @@ class EreaderLayoutManager:
if position:
return self.jump_to_position(position)
return None
-
+
def list_bookmarks(self) -> List[Tuple[str, RenderingPosition]]:
"""
Get all bookmarks.
-
+
Returns:
List of (name, position) tuples
"""
return self.bookmark_manager.list_bookmarks()
-
+
def get_reading_progress(self) -> float:
"""
Get reading progress as a percentage.
-
+
Returns:
Progress from 0.0 to 1.0
"""
if not self.blocks:
return 0.0
-
+
# Simple progress calculation based on block index
# A more sophisticated version would consider word positions
total_blocks = len(self.blocks)
current_block = min(self.current_position.block_index, total_blocks - 1)
-
+
return current_block / max(1, total_blocks - 1)
-
+
def get_position_info(self) -> Dict[str, Any]:
"""
Get detailed information about the current position.
-
+
Returns:
Dictionary with position details
"""
current_chapter = self.get_current_chapter()
-
+
return {
'position': self.current_position.to_dict(),
'chapter': {
@@ -539,16 +547,16 @@ class EreaderLayoutManager:
'font_scale': self.font_scale,
'page_size': self.page_size
}
-
+
def get_cache_stats(self) -> Dict[str, Any]:
"""
Get cache statistics for debugging/monitoring.
-
+
Returns:
Dictionary with cache statistics
"""
return self.renderer.get_cache_stats()
-
+
def shutdown(self):
"""
Shutdown the ereader manager and clean up resources.
@@ -556,29 +564,29 @@ class EreaderLayoutManager:
"""
# Save current position
self.bookmark_manager.save_reading_position(self.current_position)
-
+
# Shutdown renderer and buffer
self.renderer.shutdown()
-
+
def __del__(self):
"""Cleanup on destruction"""
self.shutdown()
# Convenience function for quick setup
-def create_ereader_manager(blocks: List[Block],
- page_size: Tuple[int, int],
- document_id: str = "default",
- **kwargs) -> EreaderLayoutManager:
+def create_ereader_manager(blocks: List[Block],
+ page_size: Tuple[int, int],
+ document_id: str = "default",
+ **kwargs) -> EreaderLayoutManager:
"""
Convenience function to create an ereader manager with sensible defaults.
-
+
Args:
blocks: Document blocks to render
page_size: Page size (width, height) in pixels
document_id: Unique identifier for the document
**kwargs: Additional arguments passed to EreaderLayoutManager
-
+
Returns:
Configured EreaderLayoutManager instance
"""
diff --git a/pyWebLayout/layout/page_buffer.py b/pyWebLayout/layout/page_buffer.py
index 46cd11f..9cb9ac7 100644
--- a/pyWebLayout/layout/page_buffer.py
+++ b/pyWebLayout/layout/page_buffer.py
@@ -8,12 +8,9 @@ multiprocessing to achieve sub-second page navigation performance.
from __future__ import annotations
from typing import Dict, Optional, List, Tuple, Any
from collections import OrderedDict
-import multiprocessing
-from concurrent.futures import ProcessPoolExecutor, as_completed, Future
+from concurrent.futures import ProcessPoolExecutor, Future
import threading
-import time
import pickle
-from dataclasses import asdict
from .ereader_layout import RenderingPosition, BidirectionalLayouter
from pyWebLayout.concrete.page import Page
@@ -21,28 +18,34 @@ from pyWebLayout.abstract.block import Block
from pyWebLayout.style.page_style import PageStyle
-def _render_page_worker(args: Tuple[List[Block], PageStyle, RenderingPosition, float, bool]) -> Tuple[RenderingPosition, bytes, RenderingPosition]:
+def _render_page_worker(args: Tuple[List[Block],
+ PageStyle,
+ RenderingPosition,
+ float,
+ bool]) -> Tuple[RenderingPosition,
+ bytes,
+ RenderingPosition]:
"""
Worker function for multiprocess page rendering.
-
+
Args:
args: Tuple of (blocks, page_style, position, font_scale, is_backward)
-
+
Returns:
Tuple of (original_position, pickled_page, next_position)
"""
blocks, page_style, position, font_scale, is_backward = args
-
+
layouter = BidirectionalLayouter(blocks, page_style)
-
+
if is_backward:
page, next_pos = layouter.render_page_backward(position, font_scale)
else:
page, next_pos = layouter.render_page_forward(position, font_scale)
-
+
# Serialize the page for inter-process communication
pickled_page = pickle.dumps(page)
-
+
return position, pickled_page, next_pos
@@ -51,40 +54,46 @@ class PageBuffer:
Intelligent page caching system with LRU eviction and background rendering.
Maintains separate forward and backward buffers for optimal navigation performance.
"""
-
+
def __init__(self, buffer_size: int = 5, max_workers: int = 4):
"""
Initialize the page buffer.
-
+
Args:
buffer_size: Number of pages to cache in each direction
max_workers: Maximum number of worker processes for background rendering
"""
self.buffer_size = buffer_size
self.max_workers = max_workers
-
+
# LRU caches for forward and backward pages
self.forward_buffer: OrderedDict[RenderingPosition, Page] = OrderedDict()
self.backward_buffer: OrderedDict[RenderingPosition, Page] = OrderedDict()
-
+
# Position tracking for next/previous positions
- self.position_map: Dict[RenderingPosition, RenderingPosition] = {} # current -> next
- self.reverse_position_map: Dict[RenderingPosition, RenderingPosition] = {} # current -> previous
-
+ self.position_map: Dict[RenderingPosition,
+ RenderingPosition] = {} # current -> next
+ self.reverse_position_map: Dict[RenderingPosition,
+ RenderingPosition] = {} # current -> previous
+
# Background rendering
self.executor: Optional[ProcessPoolExecutor] = None
self.pending_renders: Dict[RenderingPosition, Future] = {}
self.render_lock = threading.Lock()
-
+
# Document state
self.blocks: Optional[List[Block]] = None
self.page_style: Optional[PageStyle] = None
self.current_font_scale: float = 1.0
-
- def initialize(self, blocks: List[Block], page_style: PageStyle, font_scale: float = 1.0):
+
+ def initialize(
+ self,
+ blocks: List[Block],
+ page_style: PageStyle,
+ font_scale: float = 1.0):
"""
Initialize the buffer with document blocks and page style.
-
+
Args:
blocks: Document blocks to render
page_style: Page styling configuration
@@ -93,18 +102,18 @@ class PageBuffer:
self.blocks = blocks
self.page_style = page_style
self.current_font_scale = font_scale
-
+
# Start the process pool
if self.executor is None:
self.executor = ProcessPoolExecutor(max_workers=self.max_workers)
-
+
def get_page(self, position: RenderingPosition) -> Optional[Page]:
"""
Get a cached page if available.
-
+
Args:
position: Position to get page for
-
+
Returns:
Cached page or None if not available
"""
@@ -114,20 +123,25 @@ class PageBuffer:
page = self.forward_buffer.pop(position)
self.forward_buffer[position] = page
return page
-
+
# Check backward buffer
if position in self.backward_buffer:
# Move to end (most recently used)
page = self.backward_buffer.pop(position)
self.backward_buffer[position] = page
return page
-
+
return None
-
- def cache_page(self, position: RenderingPosition, page: Page, next_position: Optional[RenderingPosition] = None, is_backward: bool = False):
+
+ def cache_page(
+ self,
+ position: RenderingPosition,
+ page: Page,
+ next_position: Optional[RenderingPosition] = None,
+ is_backward: bool = False):
"""
Cache a rendered page with LRU eviction.
-
+
Args:
position: Position of the page
page: Rendered page to cache
@@ -135,46 +149,49 @@ class PageBuffer:
is_backward: Whether this is a backward-rendered page
"""
target_buffer = self.backward_buffer if is_backward else self.forward_buffer
-
+
# Add to cache
target_buffer[position] = page
-
+
# Track position relationships
if next_position:
if is_backward:
self.reverse_position_map[next_position] = position
else:
self.position_map[position] = next_position
-
+
# Evict oldest if buffer is full
if len(target_buffer) > self.buffer_size:
oldest_pos, _ = target_buffer.popitem(last=False)
# Clean up position maps
self.position_map.pop(oldest_pos, None)
self.reverse_position_map.pop(oldest_pos, None)
-
- def start_background_rendering(self, current_position: RenderingPosition, direction: str = 'forward'):
+
+ def start_background_rendering(
+ self,
+ current_position: RenderingPosition,
+ direction: str = 'forward'):
"""
Start background rendering of upcoming pages.
-
+
Args:
current_position: Current reading position
direction: 'forward', 'backward', or 'both'
"""
if not self.blocks or not self.page_style or not self.executor:
return
-
+
with self.render_lock:
if direction in ['forward', 'both']:
self._queue_forward_renders(current_position)
-
+
if direction in ['backward', 'both']:
self._queue_backward_renders(current_position)
-
+
def _queue_forward_renders(self, start_position: RenderingPosition):
"""Queue forward page renders starting from the given position"""
current_pos = start_position
-
+
for i in range(self.buffer_size):
# Skip if already cached or being rendered
if current_pos in self.forward_buffer or current_pos in self.pending_renders:
@@ -183,19 +200,25 @@ class PageBuffer:
if not current_pos:
break
continue
-
+
# Queue render job
- args = (self.blocks, self.page_style, current_pos, self.current_font_scale, False)
+ args = (
+ self.blocks,
+ self.page_style,
+ current_pos,
+ self.current_font_scale,
+ False)
future = self.executor.submit(_render_page_worker, args)
self.pending_renders[current_pos] = future
-
- # We don't know the next position yet, so we'll update it when the render completes
+
+ # We don't know the next position yet, so we'll update it when the render
+ # completes
break
-
+
def _queue_backward_renders(self, start_position: RenderingPosition):
"""Queue backward page renders ending at the given position"""
current_pos = start_position
-
+
for i in range(self.buffer_size):
# Skip if already cached or being rendered
if current_pos in self.backward_buffer or current_pos in self.pending_renders:
@@ -204,44 +227,50 @@ class PageBuffer:
if not current_pos:
break
continue
-
+
# Queue render job
- args = (self.blocks, self.page_style, current_pos, self.current_font_scale, True)
+ args = (
+ self.blocks,
+ self.page_style,
+ current_pos,
+ self.current_font_scale,
+ True)
future = self.executor.submit(_render_page_worker, args)
self.pending_renders[current_pos] = future
-
- # We don't know the previous position yet, so we'll update it when the render completes
+
+ # We don't know the previous position yet, so we'll update it when the
+ # render completes
break
-
+
def check_completed_renders(self):
"""Check for completed background renders and cache the results"""
if not self.pending_renders:
return
-
+
completed = []
-
+
with self.render_lock:
for position, future in self.pending_renders.items():
if future.done():
try:
original_pos, pickled_page, next_pos = future.result()
-
+
# Deserialize the page
page = pickle.loads(pickled_page)
-
+
# Cache the page
self.cache_page(original_pos, page, next_pos, is_backward=False)
-
+
completed.append(position)
-
+
except Exception as e:
print(f"Background render failed for position {position}: {e}")
completed.append(position)
-
+
# Remove completed renders
for pos in completed:
self.pending_renders.pop(pos, None)
-
+
def invalidate_all(self):
"""Clear all cached pages and cancel pending renders"""
with self.render_lock:
@@ -249,24 +278,24 @@ class PageBuffer:
for future in self.pending_renders.values():
future.cancel()
self.pending_renders.clear()
-
+
# Clear caches
self.forward_buffer.clear()
self.backward_buffer.clear()
self.position_map.clear()
self.reverse_position_map.clear()
-
+
def set_font_scale(self, font_scale: float):
"""
Update font scale and invalidate cache.
-
+
Args:
font_scale: New font scaling factor
"""
if font_scale != self.current_font_scale:
self.current_font_scale = font_scale
self.invalidate_all()
-
+
def get_cache_stats(self) -> Dict[str, Any]:
"""Get cache statistics for debugging/monitoring"""
return {
@@ -277,7 +306,7 @@ class PageBuffer:
'reverse_position_mappings': len(self.reverse_position_map),
'current_font_scale': self.current_font_scale
}
-
+
def shutdown(self):
"""Shutdown the page buffer and clean up resources"""
if self.executor:
@@ -285,14 +314,14 @@ class PageBuffer:
with self.render_lock:
for future in self.pending_renders.values():
future.cancel()
-
+
# Shutdown executor
self.executor.shutdown(wait=True)
self.executor = None
-
+
# Clear all caches
self.invalidate_all()
-
+
def __del__(self):
"""Cleanup on destruction"""
self.shutdown()
@@ -302,11 +331,17 @@ class BufferedPageRenderer:
"""
High-level interface for buffered page rendering with automatic background caching.
"""
-
- def __init__(self, blocks: List[Block], page_style: PageStyle, buffer_size: int = 5, page_size: Tuple[int, int] = (800, 600)):
+
+ def __init__(self,
+ blocks: List[Block],
+ page_style: PageStyle,
+ buffer_size: int = 5,
+ page_size: Tuple[int,
+ int] = (800,
+ 600)):
"""
Initialize the buffered renderer.
-
+
Args:
blocks: Document blocks to render
page_style: Page styling configuration
@@ -316,18 +351,19 @@ class BufferedPageRenderer:
self.layouter = BidirectionalLayouter(blocks, page_style, page_size)
self.buffer = PageBuffer(buffer_size)
self.buffer.initialize(blocks, page_style)
-
+
self.current_position = RenderingPosition()
self.font_scale = 1.0
-
- def render_page(self, position: RenderingPosition, font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
+
+ def render_page(self, position: RenderingPosition,
+ font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
"""
Render a page with intelligent caching.
-
+
Args:
position: Position to render from
font_scale: Font scaling factor
-
+
Returns:
Tuple of (rendered_page, next_position)
"""
@@ -335,40 +371,43 @@ class BufferedPageRenderer:
if font_scale != self.font_scale:
self.font_scale = font_scale
self.buffer.set_font_scale(font_scale)
-
+
# Check cache first
cached_page = self.buffer.get_page(position)
if cached_page:
# Get next position from position map
next_pos = self.buffer.position_map.get(position, position)
-
+
# Start background rendering for upcoming pages
self.buffer.start_background_rendering(position, 'forward')
-
+
return cached_page, next_pos
-
+
# Render the page directly
page, next_pos = self.layouter.render_page_forward(position, font_scale)
-
+
# Cache the result
self.buffer.cache_page(position, page, next_pos)
-
+
# Start background rendering
self.buffer.start_background_rendering(position, 'both')
-
+
# Check for completed background renders
self.buffer.check_completed_renders()
-
+
return page, next_pos
-
- def render_page_backward(self, end_position: RenderingPosition, font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
+
+ def render_page_backward(self,
+ end_position: RenderingPosition,
+ font_scale: float = 1.0) -> Tuple[Page,
+ RenderingPosition]:
"""
Render a page ending at the given position with intelligent caching.
-
+
Args:
end_position: Position where page should end
font_scale: Font scaling factor
-
+
Returns:
Tuple of (rendered_page, start_position)
"""
@@ -376,36 +415,36 @@ class BufferedPageRenderer:
if font_scale != self.font_scale:
self.font_scale = font_scale
self.buffer.set_font_scale(font_scale)
-
+
# Check cache first
cached_page = self.buffer.get_page(end_position)
if cached_page:
# Get previous position from reverse position map
prev_pos = self.buffer.reverse_position_map.get(end_position, end_position)
-
+
# Start background rendering for previous pages
self.buffer.start_background_rendering(end_position, 'backward')
-
+
return cached_page, prev_pos
-
+
# Render the page directly
page, start_pos = self.layouter.render_page_backward(end_position, font_scale)
-
+
# Cache the result
self.buffer.cache_page(start_pos, page, end_position, is_backward=True)
-
+
# Start background rendering
self.buffer.start_background_rendering(end_position, 'both')
-
+
# Check for completed background renders
self.buffer.check_completed_renders()
-
+
return page, start_pos
-
+
def get_cache_stats(self) -> Dict[str, Any]:
"""Get cache statistics"""
return self.buffer.get_cache_stats()
-
+
def shutdown(self):
"""Shutdown the renderer and clean up resources"""
self.buffer.shutdown()
diff --git a/pyWebLayout/style/__init__.py b/pyWebLayout/style/__init__.py
index 53690c2..f701ba6 100644
--- a/pyWebLayout/style/__init__.py
+++ b/pyWebLayout/style/__init__.py
@@ -4,7 +4,6 @@ Style system for the pyWebLayout library.
This module provides the core styling components used throughout the library.
"""
-from enum import Enum
from .fonts import Font, FontWeight, FontStyle, TextDecoration
from .abstract_style import (
AbstractStyle, AbstractStyleRegistry, FontFamily, FontSize
diff --git a/pyWebLayout/style/abstract_style.py b/pyWebLayout/style/abstract_style.py
index e4d1079..bc0dac2 100644
--- a/pyWebLayout/style/abstract_style.py
+++ b/pyWebLayout/style/abstract_style.py
@@ -6,6 +6,7 @@ rendering parameters, allowing for flexible interpretation by different
rendering systems and user preferences.
"""
+from .alignment import Alignment
from typing import Dict, Optional, Tuple, Union
from dataclasses import dataclass
from enum import Enum
@@ -30,7 +31,7 @@ class FontSize(Enum):
LARGE = "large"
X_LARGE = "x-large"
XX_LARGE = "xx-large"
-
+
# Allow numeric values as well
@classmethod
def from_value(cls, value: Union[str, int, float]) -> Union['FontSize', int]:
@@ -50,7 +51,6 @@ class FontSize(Enum):
# Import Alignment from the centralized location
-from .alignment import Alignment
# Use Alignment for text alignment
TextAlign = Alignment
@@ -61,25 +61,25 @@ class AbstractStyle:
"""
Abstract representation of text styling that captures semantic intent
rather than concrete rendering parameters.
-
+
This allows the same document to be rendered differently based on
user preferences, device capabilities, or accessibility requirements.
-
+
Being frozen=True makes this class hashable and immutable, which is
perfect for use as dictionary keys and preventing accidental modification.
"""
-
+
# Font properties (semantic)
font_family: FontFamily = FontFamily.SERIF
font_size: Union[FontSize, int] = FontSize.MEDIUM
font_weight: FontWeight = FontWeight.NORMAL
font_style: FontStyle = FontStyle.NORMAL
text_decoration: TextDecoration = TextDecoration.NONE
-
+
# Color (as semantic names or RGB)
color: Union[str, Tuple[int, int, int]] = "black"
background_color: Optional[Union[str, Tuple[int, int, int, int]]] = None
-
+
# Text properties
text_align: TextAlign = TextAlign.LEFT
line_height: Optional[Union[str, float]] = None # "normal", "1.2", 1.5, etc.
@@ -87,13 +87,13 @@ class AbstractStyle:
word_spacing: Optional[Union[str, float]] = None
word_spacing_min: Optional[Union[str, float]] = None # Minimum allowed word spacing
word_spacing_max: Optional[Union[str, float]] = None # Maximum allowed word spacing
-
+
# Language and locale
language: str = "en-US"
-
+
# Hierarchy properties
parent_style_id: Optional[str] = None
-
+
def __post_init__(self):
"""Validate and normalize values after creation"""
# Normalize font_size if it's a string that could be a number
@@ -103,11 +103,11 @@ class AbstractStyle:
except ValueError:
# Keep as is if it's a semantic size name
pass
-
+
def __hash__(self) -> int:
"""
Custom hash implementation to ensure consistent hashing.
-
+
Since this is a frozen dataclass, it should be hashable by default,
but we provide a custom implementation to ensure all fields are
properly considered and to handle the Union types correctly.
@@ -130,17 +130,17 @@ class AbstractStyle:
self.language,
self.parent_style_id
)
-
+
return hash(hashable_values)
-
+
def merge_with(self, other: 'AbstractStyle') -> 'AbstractStyle':
"""
Create a new AbstractStyle by merging this one with another.
The other style's properties take precedence.
-
+
Args:
other: AbstractStyle to merge with this one
-
+
Returns:
New AbstractStyle with merged values
"""
@@ -149,26 +149,26 @@ class AbstractStyle:
field.name: getattr(self, field.name)
for field in self.__dataclass_fields__.values()
}
-
+
other_dict = {
field.name: getattr(other, field.name)
for field in other.__dataclass_fields__.values()
if getattr(other, field.name) != field.default
}
-
+
# Merge dictionaries (other takes precedence)
merged_dict = current_dict.copy()
merged_dict.update(other_dict)
-
+
return AbstractStyle(**merged_dict)
-
+
def with_modifications(self, **kwargs) -> 'AbstractStyle':
"""
Create a new AbstractStyle with specified modifications.
-
+
Args:
**kwargs: Properties to modify
-
+
Returns:
New AbstractStyle with modifications applied
"""
@@ -176,7 +176,7 @@ class AbstractStyle:
field.name: getattr(self, field.name)
for field in self.__dataclass_fields__.values()
}
-
+
current_dict.update(kwargs)
return AbstractStyle(**current_dict)
@@ -184,20 +184,21 @@ class AbstractStyle:
class AbstractStyleRegistry:
"""
Registry for managing abstract document styles.
-
+
This registry stores the semantic styling intent and provides
deduplication and inheritance capabilities using hashable AbstractStyle objects.
"""
-
+
def __init__(self):
"""Initialize an empty abstract style registry."""
self._styles: Dict[str, AbstractStyle] = {}
- self._style_to_id: Dict[AbstractStyle, str] = {} # Reverse mapping using hashable styles
+ # Reverse mapping using hashable styles
+ self._style_to_id: Dict[AbstractStyle, str] = {}
self._next_id = 1
-
+
# Create and register the default style
self._default_style = self._create_default_style()
-
+
def _create_default_style(self) -> AbstractStyle:
"""Create the default document style."""
default_style = AbstractStyle()
@@ -205,38 +206,41 @@ class AbstractStyleRegistry:
self._styles[style_id] = default_style
self._style_to_id[default_style] = style_id
return default_style
-
+
@property
def default_style(self) -> AbstractStyle:
"""Get the default style for the document."""
return self._default_style
-
+
def _generate_style_id(self) -> str:
"""Generate a unique style ID."""
style_id = f"abstract_style_{self._next_id}"
self._next_id += 1
return style_id
-
+
def get_style_id(self, style: AbstractStyle) -> Optional[str]:
"""
Get the ID for a given style if it exists in the registry.
-
+
Args:
style: AbstractStyle to find
-
+
Returns:
Style ID if found, None otherwise
"""
return self._style_to_id.get(style)
-
- def register_style(self, style: AbstractStyle, style_id: Optional[str] = None) -> str:
+
+ def register_style(
+ self,
+ style: AbstractStyle,
+ style_id: Optional[str] = None) -> str:
"""
Register a style in the registry.
-
+
Args:
style: AbstractStyle to register
style_id: Optional style ID. If None, one will be generated
-
+
Returns:
The style ID
"""
@@ -244,26 +248,26 @@ class AbstractStyleRegistry:
existing_id = self.get_style_id(style)
if existing_id is not None:
return existing_id
-
+
if style_id is None:
style_id = self._generate_style_id()
-
+
self._styles[style_id] = style
self._style_to_id[style] = style_id
return style_id
-
- def get_or_create_style(self,
- style: Optional[AbstractStyle] = None,
- parent_id: Optional[str] = None,
- **kwargs) -> Tuple[str, AbstractStyle]:
+
+ def get_or_create_style(self,
+ style: Optional[AbstractStyle] = None,
+ parent_id: Optional[str] = None,
+ **kwargs) -> Tuple[str, AbstractStyle]:
"""
Get an existing style or create a new one.
-
+
Args:
style: AbstractStyle object. If None, created from kwargs
parent_id: Optional parent style ID
**kwargs: Individual style properties (used if style is None)
-
+
Returns:
Tuple of (style_id, AbstractStyle)
"""
@@ -274,64 +278,65 @@ class AbstractStyleRegistry:
if parent_id:
filtered_kwargs['parent_style_id'] = parent_id
style = AbstractStyle(**filtered_kwargs)
-
+
# Check if we already have this style (using hashable property)
existing_id = self.get_style_id(style)
if existing_id is not None:
return existing_id, style
-
+
# Create new style
style_id = self.register_style(style)
return style_id, style
-
+
def get_style_by_id(self, style_id: str) -> Optional[AbstractStyle]:
"""Get a style by its ID."""
return self._styles.get(style_id)
-
- def create_derived_style(self, base_style_id: str, **modifications) -> Tuple[str, AbstractStyle]:
+
+ def create_derived_style(self, base_style_id: str, **
+ modifications) -> Tuple[str, AbstractStyle]:
"""
Create a new style derived from a base style.
-
+
Args:
base_style_id: ID of the base style
**modifications: Properties to modify
-
+
Returns:
Tuple of (new_style_id, new_AbstractStyle)
"""
base_style = self.get_style_by_id(base_style_id)
if base_style is None:
raise ValueError(f"Base style '{base_style_id}' not found")
-
+
# Create derived style
derived_style = base_style.with_modifications(**modifications)
return self.get_or_create_style(derived_style)
-
+
def resolve_effective_style(self, style_id: str) -> AbstractStyle:
"""
Resolve the effective style including inheritance.
-
+
Args:
style_id: Style ID to resolve
-
+
Returns:
Effective AbstractStyle with inheritance applied
"""
style = self.get_style_by_id(style_id)
if style is None:
return self._default_style
-
+
if style.parent_style_id is None:
return style
-
+
# Recursively resolve parent styles
parent_style = self.resolve_effective_style(style.parent_style_id)
return parent_style.merge_with(style)
-
+
def get_all_styles(self) -> Dict[str, AbstractStyle]:
"""Get all registered styles."""
return self._styles.copy()
-
+
def get_style_count(self) -> int:
"""Get the number of registered styles."""
return len(self._styles)
diff --git a/pyWebLayout/style/alignment.py b/pyWebLayout/style/alignment.py
index bac8dd7..a25975b 100644
--- a/pyWebLayout/style/alignment.py
+++ b/pyWebLayout/style/alignment.py
@@ -6,6 +6,7 @@ This module provides alignment-related functionality.
from enum import Enum
+
class Alignment(Enum):
"""Text and box alignment options"""
# Horizontal alignment
@@ -13,10 +14,10 @@ class Alignment(Enum):
RIGHT = "right"
CENTER = "center"
JUSTIFY = "justify"
-
+
# Vertical alignment
TOP = "top"
- MIDDLE = "middle"
+ MIDDLE = "middle"
BOTTOM = "bottom"
def __str__(self):
diff --git a/pyWebLayout/style/concrete_style.py b/pyWebLayout/style/concrete_style.py
index b308013..44796ba 100644
--- a/pyWebLayout/style/concrete_style.py
+++ b/pyWebLayout/style/concrete_style.py
@@ -5,12 +5,11 @@ This module converts abstract styles to concrete rendering parameters based on
user preferences, device capabilities, and rendering context.
"""
-from typing import Dict, Optional, Tuple, Union, Any
+from typing import Dict, Optional, Tuple, Union
from dataclasses import dataclass
from .abstract_style import AbstractStyle, FontFamily, FontSize
from pyWebLayout.style.alignment import Alignment as TextAlign
from .fonts import Font, FontWeight, FontStyle, TextDecoration
-import os
@dataclass(frozen=True)
@@ -19,24 +18,24 @@ class RenderingContext:
Context information for style resolution.
Contains user preferences and device capabilities.
"""
-
+
# User preferences
base_font_size: int = 16 # Base font size in points
font_scale_factor: float = 1.0 # Global font scaling
preferred_serif_font: Optional[str] = None
preferred_sans_serif_font: Optional[str] = None
preferred_monospace_font: Optional[str] = None
-
+
# Device/environment info
dpi: int = 96 # Dots per inch
available_width: Optional[int] = None # Available width in pixels
available_height: Optional[int] = None # Available height in pixels
-
+
# Accessibility preferences
high_contrast: bool = False
large_text: bool = False
reduce_motion: bool = False
-
+
# Language and locale
default_language: str = "en-US"
@@ -45,22 +44,22 @@ class RenderingContext:
class ConcreteStyle:
"""
Concrete representation of text styling with actual rendering parameters.
-
+
This contains the resolved font files, pixel sizes, actual colors, etc.
that will be used for rendering. This is also hashable for efficient caching.
"""
-
+
# Concrete font properties
font_path: Optional[str] = None
font_size: int = 16 # Always in points/pixels
color: Tuple[int, int, int] = (0, 0, 0) # Always RGB
background_color: Optional[Tuple[int, int, int, int]] = None # Always RGBA or None
-
+
# Font attributes
weight: FontWeight = FontWeight.NORMAL
style: FontStyle = FontStyle.NORMAL
decoration: TextDecoration = TextDecoration.NONE
-
+
# Layout properties
text_align: TextAlign = TextAlign.LEFT
line_height: float = 1.0 # Multiplier
@@ -68,14 +67,14 @@ class ConcreteStyle:
word_spacing: float = 0.0 # In pixels
word_spacing_min: float = 0.0 # Minimum word spacing in pixels
word_spacing_max: float = 0.0 # Maximum word spacing in pixels
-
+
# Language and locale
language: str = "en-US"
min_hyphenation_width: int = 64 # In pixels
-
+
# Reference to source abstract style
abstract_style: Optional[AbstractStyle] = None
-
+
def create_font(self) -> Font:
"""Create a Font object from this concrete style."""
return Font(
@@ -94,21 +93,21 @@ class ConcreteStyle:
class StyleResolver:
"""
Resolves abstract styles to concrete styles based on rendering context.
-
+
This class handles the conversion from semantic styling intent to actual
rendering parameters, applying user preferences and device capabilities.
"""
-
+
def __init__(self, context: RenderingContext):
"""
Initialize the style resolver with a rendering context.
-
+
Args:
context: RenderingContext with user preferences and device info
"""
self.context = context
self._concrete_cache: Dict[AbstractStyle, ConcreteStyle] = {}
-
+
# Font size mapping for semantic sizes
self._semantic_font_sizes = {
FontSize.XX_SMALL: 0.6,
@@ -119,7 +118,7 @@ class StyleResolver:
FontSize.X_LARGE: 1.5,
FontSize.XX_LARGE: 2.0,
}
-
+
# Color name mapping
self._color_names = {
"black": (0, 0, 0),
@@ -141,35 +140,40 @@ class StyleResolver:
"fuchsia": (255, 0, 255),
"purple": (128, 0, 128),
}
-
+
def resolve_style(self, abstract_style: AbstractStyle) -> ConcreteStyle:
"""
Resolve an abstract style to a concrete style.
-
+
Args:
abstract_style: AbstractStyle to resolve
-
+
Returns:
ConcreteStyle with concrete rendering parameters
"""
# Check cache first
if abstract_style in self._concrete_cache:
return self._concrete_cache[abstract_style]
-
+
# Resolve each property
font_path = self._resolve_font_path(abstract_style.font_family)
font_size = self._resolve_font_size(abstract_style.font_size)
# Ensure font_size is always an int before using in arithmetic
font_size = int(font_size)
color = self._resolve_color(abstract_style.color)
- background_color = self._resolve_background_color(abstract_style.background_color)
+ background_color = self._resolve_background_color(
+ abstract_style.background_color)
line_height = self._resolve_line_height(abstract_style.line_height)
- letter_spacing = self._resolve_letter_spacing(abstract_style.letter_spacing, font_size)
- word_spacing = self._resolve_word_spacing(abstract_style.word_spacing, font_size)
- word_spacing_min = self._resolve_word_spacing(abstract_style.word_spacing_min, font_size)
- word_spacing_max = self._resolve_word_spacing(abstract_style.word_spacing_max, font_size)
+ letter_spacing = self._resolve_letter_spacing(
+ abstract_style.letter_spacing, font_size)
+ word_spacing = self._resolve_word_spacing(
+ abstract_style.word_spacing, font_size)
+ word_spacing_min = self._resolve_word_spacing(
+ abstract_style.word_spacing_min, font_size)
+ word_spacing_max = self._resolve_word_spacing(
+ abstract_style.word_spacing_max, font_size)
min_hyphenation_width = max(int(font_size) * 4, 32) # At least 32 pixels
-
+
# Apply default logic for word spacing constraints
if word_spacing_min == 0.0 and word_spacing_max == 0.0:
# If no constraints specified, use base word_spacing as reference
@@ -186,7 +190,7 @@ class StyleResolver:
elif word_spacing_max == 0.0:
# Only min specified, use base word_spacing or reasonable multiple
word_spacing_max = max(word_spacing, word_spacing_min * 2)
-
+
# Create concrete style
concrete_style = ConcreteStyle(
font_path=font_path,
@@ -206,11 +210,11 @@ class StyleResolver:
min_hyphenation_width=min_hyphenation_width,
abstract_style=abstract_style
)
-
+
# Cache and return
self._concrete_cache[abstract_style] = concrete_style
return concrete_style
-
+
def _resolve_font_path(self, font_family: FontFamily) -> Optional[str]:
"""Resolve font family to actual font file path."""
if font_family == FontFamily.SERIF:
@@ -222,7 +226,7 @@ class StyleResolver:
else:
# For cursive and fantasy, fall back to sans-serif
return self.context.preferred_sans_serif_font
-
+
def _resolve_font_size(self, font_size: Union[FontSize, int]) -> int:
"""Resolve font size to actual pixel/point size."""
# Ensure we handle FontSize enums properly
@@ -240,22 +244,23 @@ class StyleResolver:
except (ValueError, TypeError):
# If conversion fails, use default
base_size = self.context.base_font_size
-
+
# Apply global font scaling
final_size = int(base_size * self.context.font_scale_factor)
-
+
# Apply accessibility adjustments
if self.context.large_text:
final_size = int(final_size * 1.2)
-
+
# Ensure we always return an int, minimum 8pt font
return max(int(final_size), 8)
-
- def _resolve_color(self, color: Union[str, Tuple[int, int, int]]) -> Tuple[int, int, int]:
+
+ def _resolve_color(
+ self, color: Union[str, Tuple[int, int, int]]) -> Tuple[int, int, int]:
"""Resolve color to RGB tuple."""
if isinstance(color, tuple):
return color
-
+
if isinstance(color, str):
# Check if it's a named color
if color.lower() in self._color_names:
@@ -266,7 +271,7 @@ class StyleResolver:
hex_color = color[1:]
if len(hex_color) == 3:
# Short hex format #RGB -> #RRGGBB
- hex_color = ''.join(c*2 for c in hex_color)
+ hex_color = ''.join(c * 2 for c in hex_color)
if len(hex_color) == 6:
r = int(hex_color[0:2], 16)
g = int(hex_color[2:4], 16)
@@ -278,7 +283,7 @@ class StyleResolver:
base_color = (0, 0, 0) # Fallback to black
else:
base_color = (0, 0, 0) # Fallback to black
-
+
# Apply high contrast if needed
if self.context.high_contrast:
# Simple high contrast: make dark colors black, light colors white
@@ -288,56 +293,65 @@ class StyleResolver:
base_color = (0, 0, 0) # Black
else:
base_color = (255, 255, 255) # White
-
+
return base_color
-
+
return (0, 0, 0) # Fallback to black
-
- def _resolve_background_color(self, bg_color: Optional[Union[str, Tuple[int, int, int, int]]]) -> Optional[Tuple[int, int, int, int]]:
+
+ def _resolve_background_color(self,
+ bg_color: Optional[Union[str,
+ Tuple[int,
+ int,
+ int,
+ int]]]) -> Optional[Tuple[int,
+ int,
+ int,
+ int]]:
"""Resolve background color to RGBA tuple or None."""
if bg_color is None:
return None
-
+
if isinstance(bg_color, tuple):
if len(bg_color) == 3:
# RGB -> RGBA
return bg_color + (255,)
return bg_color
-
+
if isinstance(bg_color, str):
if bg_color.lower() == "transparent":
return None
-
+
# Resolve as RGB then add alpha
rgb = self._resolve_color(bg_color)
return rgb + (255,)
-
+
return None
-
+
def _resolve_line_height(self, line_height: Optional[Union[str, float]]) -> float:
"""Resolve line height to multiplier."""
if line_height is None or line_height == "normal":
return 1.2 # Default line height
-
+
if isinstance(line_height, (int, float)):
return float(line_height)
-
+
if isinstance(line_height, str):
try:
return float(line_height)
except ValueError:
return 1.2 # Fallback
-
+
return 1.2
-
- def _resolve_letter_spacing(self, letter_spacing: Optional[Union[str, float]], font_size: int) -> float:
+
+ def _resolve_letter_spacing(
+ self, letter_spacing: Optional[Union[str, float]], font_size: int) -> float:
"""Resolve letter spacing to pixels."""
if letter_spacing is None or letter_spacing == "normal":
return 0.0
-
+
if isinstance(letter_spacing, (int, float)):
return float(letter_spacing)
-
+
if isinstance(letter_spacing, str):
if letter_spacing.endswith("em"):
try:
@@ -350,17 +364,18 @@ class StyleResolver:
return float(letter_spacing)
except ValueError:
return 0.0
-
+
return 0.0
-
- def _resolve_word_spacing(self, word_spacing: Optional[Union[str, float]], font_size: int) -> float:
+
+ def _resolve_word_spacing(
+ self, word_spacing: Optional[Union[str, float]], font_size: int) -> float:
"""Resolve word spacing to pixels."""
if word_spacing is None or word_spacing == "normal":
return 0.0
-
+
if isinstance(word_spacing, (int, float)):
return float(word_spacing)
-
+
if isinstance(word_spacing, str):
if word_spacing.endswith("em"):
try:
@@ -373,13 +388,13 @@ class StyleResolver:
return float(word_spacing)
except ValueError:
return 0.0
-
+
return 0.0
-
+
def update_context(self, **kwargs):
"""
Update the rendering context and clear cache.
-
+
Args:
**kwargs: Context properties to update
"""
@@ -389,16 +404,16 @@ class StyleResolver:
for field in self.context.__dataclass_fields__.values()
}
context_dict.update(kwargs)
-
+
self.context = RenderingContext(**context_dict)
-
+
# Clear cache since context changed
self._concrete_cache.clear()
-
+
def clear_cache(self):
"""Clear the concrete style cache."""
self._concrete_cache.clear()
-
+
def get_cache_size(self) -> int:
"""Get the number of cached concrete styles."""
return len(self._concrete_cache)
@@ -407,60 +422,60 @@ class StyleResolver:
class ConcreteStyleRegistry:
"""
Registry for managing concrete styles with efficient caching.
-
+
This registry manages the mapping between abstract and concrete styles,
and provides efficient access to Font objects for rendering.
"""
-
+
def __init__(self, resolver: StyleResolver):
"""
Initialize the concrete style registry.
-
+
Args:
resolver: StyleResolver for converting abstract to concrete styles
"""
self.resolver = resolver
self._font_cache: Dict[ConcreteStyle, Font] = {}
-
+
def get_concrete_style(self, abstract_style: AbstractStyle) -> ConcreteStyle:
"""
Get a concrete style for an abstract style.
-
+
Args:
abstract_style: AbstractStyle to resolve
-
+
Returns:
ConcreteStyle with rendering parameters
"""
return self.resolver.resolve_style(abstract_style)
-
+
def get_font(self, abstract_style: AbstractStyle) -> Font:
"""
Get a Font object for an abstract style.
-
+
Args:
abstract_style: AbstractStyle to get font for
-
+
Returns:
Font object ready for rendering
"""
concrete_style = self.get_concrete_style(abstract_style)
-
+
# Check font cache
if concrete_style in self._font_cache:
return self._font_cache[concrete_style]
-
+
# Create and cache font
font = concrete_style.create_font()
self._font_cache[concrete_style] = font
-
+
return font
-
+
def clear_caches(self):
"""Clear all caches."""
self.resolver.clear_cache()
self._font_cache.clear()
-
+
def get_cache_stats(self) -> Dict[str, int]:
"""Get cache statistics."""
return {
diff --git a/pyWebLayout/style/fonts.py b/pyWebLayout/style/fonts.py
index 0374923..66189bb 100644
--- a/pyWebLayout/style/fonts.py
+++ b/pyWebLayout/style/fonts.py
@@ -1,7 +1,8 @@
-# this should contain classes for how different object can be rendered, e.g. bold, italic, regular
+# this should contain classes for how different object can be rendered,
+# e.g. bold, italic, regular
from PIL import ImageFont
from enum import Enum
-from typing import Tuple, Union, Optional
+from typing import Tuple, Optional
import os
import logging
@@ -31,19 +32,19 @@ class Font:
This class is used by the text renderer to determine how to render text.
"""
- def __init__(self,
+ def __init__(self,
font_path: Optional[str] = None,
- font_size: int = 16,
+ font_size: int = 16,
colour: Tuple[int, int, int] = (0, 0, 0),
weight: FontWeight = FontWeight.NORMAL,
style: FontStyle = FontStyle.NORMAL,
decoration: TextDecoration = TextDecoration.NONE,
background: Optional[Tuple[int, int, int, int]] = None,
- language = "en_EN",
+ language="en_EN",
min_hyphenation_width: Optional[int] = None):
"""
Initialize a Font object with the specified properties.
-
+
Args:
font_path: Path to the font file (.ttf, .otf). If None, uses default font.
font_size: Size of the font in points.
@@ -67,7 +68,7 @@ class Font:
self._min_hyphenation_width = min_hyphenation_width if min_hyphenation_width is not None else font_size * 4
# Load the font file or use default
self._load_font()
-
+
def _get_bundled_font_path(self):
"""Get the path to the bundled font"""
# Get the directory containing this module
@@ -75,19 +76,21 @@ class Font:
# Navigate to the assets/fonts directory
assets_dir = os.path.join(os.path.dirname(current_dir), 'assets', 'fonts')
bundled_font_path = os.path.join(assets_dir, 'DejaVuSans.ttf')
-
+
logger.debug(f"Font loading: current_dir = {current_dir}")
logger.debug(f"Font loading: assets_dir = {assets_dir}")
logger.debug(f"Font loading: bundled_font_path = {bundled_font_path}")
- logger.debug(f"Font loading: bundled font exists = {os.path.exists(bundled_font_path)}")
-
+ logger.debug(
+ f"Font loading: bundled font exists = {
+ os.path.exists(bundled_font_path)}")
+
if os.path.exists(bundled_font_path):
logger.info(f"Found bundled font at: {bundled_font_path}")
return bundled_font_path
else:
logger.warning(f"Bundled font not found at: {bundled_font_path}")
return None
-
+
def _load_font(self):
"""Load the font using PIL's ImageFont with consistent bundled font"""
try:
@@ -95,126 +98,130 @@ class Font:
# Use specified font path
logger.info(f"Loading font from specified path: {self._font_path}")
self._font = ImageFont.truetype(
- self._font_path,
+ self._font_path,
self._font_size
)
logger.info(f"Successfully loaded font from: {self._font_path}")
else:
# Use bundled font for consistency across environments
bundled_font_path = self._get_bundled_font_path()
-
+
if bundled_font_path:
logger.info(f"Loading bundled font from: {bundled_font_path}")
self._font = ImageFont.truetype(bundled_font_path, self._font_size)
- logger.info(f"Successfully loaded bundled font at size {self._font_size}")
+ logger.info(
+ f"Successfully loaded bundled font at size {
+ self._font_size}")
else:
- # Only fall back to PIL's default font if bundled font is not available
- logger.warning(f"Bundled font not available, falling back to PIL default font")
+ # Only fall back to PIL's default font if bundled font is not
+ # available
+ logger.warning(
+ "Bundled font not available, falling back to PIL default font")
self._font = ImageFont.load_default()
-
+
except Exception as e:
# Ultimate fallback to default font
logger.error(f"Failed to load font: {e}, falling back to PIL default font")
self._font = ImageFont.load_default()
-
+
@property
def font(self):
"""Get the PIL ImageFont object"""
return self._font
-
+
@property
def font_size(self):
"""Get the font size"""
return self._font_size
-
+
@property
def colour(self):
"""Get the text color"""
return self._colour
-
+
@property
def color(self):
"""Alias for colour (American spelling)"""
return self._colour
-
+
@property
def background(self):
"""Get the background color"""
return self._background
-
+
@property
def weight(self):
"""Get the font weight"""
return self._weight
-
+
@property
def style(self):
"""Get the font style"""
return self._style
-
+
@property
def decoration(self):
"""Get the text decoration"""
return self._decoration
-
+
@property
def min_hyphenation_width(self):
"""Get the minimum width required for hyphenation to be considered"""
return self._min_hyphenation_width
-
+
def with_size(self, size: int):
"""Create a new Font object with modified size"""
return Font(
- self._font_path,
- size,
+ self._font_path,
+ size,
self._colour,
self._weight,
self._style,
self._decoration,
self._background
)
-
+
def with_colour(self, colour: Tuple[int, int, int]):
"""Create a new Font object with modified colour"""
return Font(
- self._font_path,
- self._font_size,
+ self._font_path,
+ self._font_size,
colour,
self._weight,
self._style,
self._decoration,
self._background
)
-
+
def with_weight(self, weight: FontWeight):
"""Create a new Font object with modified weight"""
return Font(
- self._font_path,
- self._font_size,
+ self._font_path,
+ self._font_size,
self._colour,
weight,
self._style,
self._decoration,
self._background
)
-
+
def with_style(self, style: FontStyle):
"""Create a new Font object with modified style"""
return Font(
- self._font_path,
- self._font_size,
+ self._font_path,
+ self._font_size,
self._colour,
self._weight,
style,
self._decoration,
self._background
)
-
+
def with_decoration(self, decoration: TextDecoration):
"""Create a new Font object with modified decoration"""
return Font(
- self._font_path,
- self._font_size,
+ self._font_path,
+ self._font_size,
self._colour,
self._weight,
self._style,
diff --git a/pyWebLayout/style/page_style.py b/pyWebLayout/style/page_style.py
index 9fb0cd3..47d8938 100644
--- a/pyWebLayout/style/page_style.py
+++ b/pyWebLayout/style/page_style.py
@@ -1,7 +1,6 @@
-from typing import Tuple, Optional
+from typing import Tuple
from dataclasses import dataclass
-from .abstract_style import AbstractStyle, FontFamily, FontSize
-from pyWebLayout.style.alignment import Alignment as TextAlign
+
@dataclass
class PageStyle:
diff --git a/scripts/debug_text_positioning.py b/scripts/debug_text_positioning.py
index 3389f74..ae9bfa2 100644
--- a/scripts/debug_text_positioning.py
+++ b/scripts/debug_text_positioning.py
@@ -5,40 +5,41 @@ Debug script to test text positioning in the line breaking system
import sys
from pathlib import Path
-from PIL import Image, ImageDraw, ImageFont
-
-# Add pyWebLayout to path
-sys.path.insert(0, str(Path(__file__).parent))
+from PIL import Image, ImageDraw
from pyWebLayout.style import Font
from pyWebLayout.concrete.text import Text, Line
from pyWebLayout.style.layout import Alignment
+# Add pyWebLayout to path
+sys.path.insert(0, str(Path(__file__).parent))
+
+
def test_simple_text_rendering():
"""Test basic text rendering to debug positioning issues"""
-
+
# Create a simple image
width, height = 300, 200
image = Image.new('RGB', (width, height), 'white')
draw = ImageDraw.Draw(image)
-
+
# Draw a border for reference
draw.rectangle([0, 0, width-1, height-1], outline=(200, 200, 200), width=2)
-
+
# Create a font
font = Font(font_size=12)
-
+
# Test 1: Direct PIL text rendering
print("Test 1: Direct PIL text rendering")
draw.text((10, 30), "Direct PIL text", font=font.font, fill=(0, 0, 0))
-
+
# Test 2: Using our Text class
print("Test 2: Using Text class")
text_obj = Text("Text class rendering", font, draw)
text_obj.set_origin([10, 60]) # Set position
print(f"Text origin: {text_obj.origin}")
text_obj.render()
-
+
# Test 3: Using Line class
print("Test 3: Using Line class")
line = Line(
@@ -49,26 +50,27 @@ def test_simple_text_rendering():
font=font,
halign=Alignment.LEFT
)
-
+
# Create a simple word to add to the line
from pyWebLayout.abstract.inline import Word
word = Word("Line class rendering", font)
-
+
success, overflow = line.add_word(word)
print(f"Word added successfully: {success}")
print(f"Line origin: {line.origin}")
print(f"Line baseline: {line._baseline}")
print(f"Text objects in line: {len(line.text_objects)}")
-
+
if line.text_objects:
for i, text in enumerate(line.text_objects):
print(f" Text {i}: '{text.text}' at origin {text.origin}")
-
+
line.render()
-
+
# Save the debug image
image.save("debug_text_positioning.png")
print("Debug image saved as debug_text_positioning.png")
+
if __name__ == "__main__":
test_simple_text_rendering()
diff --git a/scripts/epub_page_renderer.py b/scripts/epub_page_renderer.py
index c344637..fddeac8 100755
--- a/scripts/epub_page_renderer.py
+++ b/scripts/epub_page_renderer.py
@@ -15,7 +15,7 @@ import os
import sys
import argparse
from pathlib import Path
-from typing import Optional, List
+from typing import List
# Add the parent directory to sys.path to import pyWebLayout
sys.path.insert(0, str(Path(__file__).parent.parent))
@@ -23,10 +23,8 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
try:
from pyWebLayout.io.readers.epub_reader import read_epub
from pyWebLayout.layout.ereader_layout import BidirectionalLayouter, RenderingPosition
- from pyWebLayout.layout.document_layouter import paragraph_layouter
from pyWebLayout.concrete.page import Page
from pyWebLayout.style.page_style import PageStyle
- from pyWebLayout.style.fonts import Font
from pyWebLayout.abstract.block import Block
from PIL import Image, ImageDraw
except ImportError as e:
@@ -38,10 +36,10 @@ except ImportError as e:
def render_page_to_image(page: Page) -> Image.Image:
"""
Render a Page object to a PIL Image using pyWebLayout's built-in rendering.
-
+
Args:
page: The Page object to render
-
+
Returns:
PIL Image object
"""
@@ -56,7 +54,7 @@ def render_page_to_image(page: Page) -> Image.Image:
draw = ImageDraw.Draw(error_image)
draw.text((20, 20), "Error: Page.render() did not return PIL Image", fill='red')
return error_image
-
+
except Exception as e:
# Create error image if rendering fails
error_image = Image.new('RGB', page.size, 'white')
@@ -69,26 +67,26 @@ def render_page_to_image(page: Page) -> Image.Image:
def extract_text_from_page(page: Page) -> str:
"""
Extract text content from a Page object for verification purposes.
-
+
Args:
page: The Page object to extract text from
-
+
Returns:
String containing the page's text content
"""
text_lines = []
- text_lines.append(f"=== PAGE CONTENT ===")
+ text_lines.append("=== PAGE CONTENT ===")
text_lines.append("")
-
+
try:
# Recursively extract text from page children
def extract_from_element(element, indent_level=0):
indent = " " * indent_level
-
+
# Import abstract block types
from pyWebLayout.abstract.block import Paragraph, Heading, HList, Table, Image as AbstractImage
from pyWebLayout.concrete.text import Line
-
+
# Handle Line objects (concrete)
if isinstance(element, Line):
line_text = []
@@ -98,7 +96,7 @@ def extract_text_from_page(page: Page) -> str:
line_text.append(str(text_obj.text))
if line_text:
text_lines.append(f"{indent}{' '.join(line_text)}")
-
+
# Handle abstract block objects
elif isinstance(element, (Paragraph, Heading)):
# Extract text from paragraph/heading
@@ -106,7 +104,7 @@ def extract_text_from_page(page: Page) -> str:
if paragraph_text:
block_type = "HEADING" if isinstance(element, Heading) else "PARAGRAPH"
text_lines.append(f"{indent}{block_type}: {paragraph_text}")
-
+
elif isinstance(element, HList):
text_lines.append(f"{indent}LIST:")
# Extract text from list items
@@ -115,33 +113,33 @@ def extract_text_from_page(page: Page) -> str:
item_text = extract_text_from_paragraph(item)
if item_text:
text_lines.append(f"{indent} - {item_text}")
- except:
+ except Exception:
text_lines.append(f"{indent} (List content extraction failed)")
-
+
elif isinstance(element, Table):
text_lines.append(f"{indent}[TABLE]")
-
+
elif isinstance(element, AbstractImage):
alt_text = getattr(element, 'alt_text', '')
src = getattr(element, 'source', 'Unknown')
text_lines.append(f"{indent}[IMAGE: {alt_text or src}]")
-
+
# Handle containers with children
elif hasattr(element, '_children') and element._children:
for child in element._children:
extract_from_element(child, indent_level + 1)
-
+
# Handle text elements
elif hasattr(element, 'text'):
text = str(element.text).strip()
if text:
text_lines.append(f"{indent}{text}")
-
+
# Handle other object types by showing their class name
else:
class_name = element.__class__.__name__
text_lines.append(f"{indent}[{class_name}]")
-
+
# Helper function to extract text from paragraph-like objects
def extract_text_from_paragraph(para_obj):
words = []
@@ -162,44 +160,44 @@ def extract_text_from_page(page: Page) -> str:
words.append(str(word))
except Exception as e:
return f"(Text extraction error: {str(e)})"
-
+
return ' '.join(words) if words else "(No text)"
-
+
# Extract text from page children
if hasattr(page, '_children'):
for child in page._children:
extract_from_element(child)
-
+
# If no text was extracted, add a note
if len(text_lines) <= 2: # Only header and empty line
text_lines.append("(No text content found)")
-
+
except Exception as e:
text_lines.append(f"Error extracting text: {str(e)}")
import traceback
text_lines.append(traceback.format_exc())
-
+
return "\n".join(text_lines)
def get_all_blocks_from_book(book) -> List[Block]:
"""
Extract all blocks from all chapters in the book.
-
+
Args:
book: The Book object from epub_reader
-
+
Returns:
List of all Block objects
"""
all_blocks = []
-
+
# Iterate through all chapters
for chapter in book.chapters:
# Get blocks from the chapter
if hasattr(chapter, '_blocks'):
all_blocks.extend(chapter._blocks)
-
+
return all_blocks
@@ -214,117 +212,116 @@ Examples:
python epub_page_renderer.py book.epub --pages 10 --output-dir my_output --width 600 --height 800
"""
)
-
+
parser.add_argument(
'epub_file',
help='Path to the EPUB file to render'
)
-
+
parser.add_argument(
'--pages', '-p',
type=int,
default=5,
help='Number of pages to render (default: 5)'
)
-
+
parser.add_argument(
'--output-dir', '-o',
default='rendered_pages',
help='Output directory for rendered images (default: rendered_pages)'
)
-
+
parser.add_argument(
'--width', '-w',
type=int,
default=800,
help='Page width in pixels (default: 800)'
)
-
+
parser.add_argument(
'--height', '-t',
type=int,
default=1000,
help='Page height in pixels (default: 1000)'
)
-
+
parser.add_argument(
'--margin', '-m',
type=int,
default=40,
help='Page margin in pixels (default: 40)'
)
-
+
parser.add_argument(
'--align', '-a',
choices=['left', 'justify'],
default='left',
help='Text alignment: left or justify (default: left)'
)
-
+
args = parser.parse_args()
-
+
# Validate arguments
if not os.path.exists(args.epub_file):
print(f"Error: EPUB file '{args.epub_file}' not found")
return 1
-
+
if args.pages <= 0:
print("Error: Number of pages must be positive")
return 1
-
+
# Create output directory
try:
os.makedirs(args.output_dir, exist_ok=True)
except OSError as e:
print(f"Error creating output directory: {e}")
return 1
-
+
print(f"Loading EPUB file: {args.epub_file}")
-
+
# Load the EPUB file
try:
book = read_epub(args.epub_file)
print(f"Successfully loaded EPUB: {book.get_title() or 'Unknown Title'}")
-
+
# Print book information
author = book.get_metadata('AUTHOR')
if author:
print(f"Author: {author}")
-
+
print(f"Chapters: {len(book.chapters) if hasattr(book, 'chapters') else 'Unknown'}")
-
+
except Exception as e:
print(f"Error loading EPUB file: {e}")
import traceback
traceback.print_exc()
return 1
-
+
# Extract all blocks from the book
print("Extracting content blocks...")
try:
all_blocks = get_all_blocks_from_book(book)
print(f"Extracted {len(all_blocks)} content blocks")
-
+
if not all_blocks:
print("No content blocks found in EPUB. The book might be empty.")
return 1
-
+
# Apply alignment setting to all paragraphs and headings
- from pyWebLayout.abstract.block import Paragraph, Heading
from pyWebLayout.style.alignment import Alignment
-
+
alignment = Alignment.JUSTIFY if args.align == 'justify' else Alignment.LEFT
print(f"Applying {args.align} alignment to all text blocks...")
-
+
# Note: We'll pass alignment to the layouter which will handle it during rendering
# The alignment is applied at the Line level in paragraph_layouter
-
+
except Exception as e:
print(f"Error extracting blocks: {e}")
import traceback
traceback.print_exc()
return 1
-
+
# Set up page style and layouter
page_size = (args.width, args.height)
page_style = PageStyle(
@@ -335,9 +332,9 @@ Examples:
line_spacing=5,
inter_block_spacing=15
)
-
+
print(f"Setting up layouter with page size {page_size} and {args.align} alignment")
-
+
try:
layouter = BidirectionalLayouter(
blocks=all_blocks,
@@ -350,83 +347,83 @@ Examples:
import traceback
traceback.print_exc()
return 1
-
+
# Render pages
print(f"Rendering up to {args.pages} pages...")
-
+
try:
pages = []
current_position = RenderingPosition() # Start from beginning
-
+
for page_num in range(args.pages):
print(f"Rendering page {page_num + 1}/{args.pages}...")
-
+
try:
# Render the page
page, next_position = layouter.render_page_forward(current_position)
pages.append(page)
-
+
# Check if we've reached the end of the document
if next_position.block_index >= len(all_blocks):
print(f"Reached end of document after {page_num + 1} pages")
break
-
+
# Update position for next page
current_position = next_position
-
+
except Exception as e:
print(f"Error rendering page {page_num + 1}: {e}")
import traceback
traceback.print_exc()
break
-
+
if not pages:
print("No pages were generated.")
return 1
-
+
print(f"Generated {len(pages)} pages")
-
+
# Save each page to an image and extract text
for i, page in enumerate(pages):
print(f"Saving page {i + 1}/{len(pages)}...")
-
+
try:
# Create image from page using pyWebLayout's built-in rendering
image = render_page_to_image(page)
-
+
# Save the image
output_filename = f"page_{i + 1:03d}.png"
output_path = os.path.join(args.output_dir, output_filename)
image.save(output_path, 'PNG')
-
+
# Extract and save text content for verification
page_text = extract_text_from_page(page)
text_filename = f"page_{i + 1:03d}.txt"
text_path = os.path.join(args.output_dir, text_filename)
with open(text_path, 'w', encoding='utf-8') as f:
f.write(page_text)
-
+
print(f"Saved: {output_path} and {text_path}")
-
+
except Exception as e:
print(f"Error saving page {i + 1}: {e}")
import traceback
traceback.print_exc()
continue
-
+
print(f"\nCompleted! Rendered {len(pages)} pages to {args.output_dir}")
-
+
# Calculate progress through the book
if len(all_blocks) > 0:
progress = (current_position.block_index / len(all_blocks)) * 100
print(f"Progress through book: {progress:.1f}%")
-
+
except Exception as e:
print(f"Error during pagination/rendering: {e}")
import traceback
traceback.print_exc()
return 1
-
+
return 0
diff --git a/scripts/epub_page_renderer_documentlayouter.py b/scripts/epub_page_renderer_documentlayouter.py
index 9d9439e..b240372 100644
--- a/scripts/epub_page_renderer_documentlayouter.py
+++ b/scripts/epub_page_renderer_documentlayouter.py
@@ -23,14 +23,12 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
try:
from pyWebLayout.io.readers.epub_reader import read_epub
- from pyWebLayout.layout.document_layouter import DocumentLayouter, paragraph_layouter, image_layouter, pagebreak_layouter
+ from pyWebLayout.layout.document_layouter import paragraph_layouter, image_layouter, pagebreak_layouter
from pyWebLayout.concrete.page import Page
from pyWebLayout.style.page_style import PageStyle
- from pyWebLayout.style.fonts import Font
from pyWebLayout.style.alignment import Alignment
from pyWebLayout.abstract.block import Block, Paragraph, Heading, HList, Table, Image as AbstractImage, PageBreak
- from pyWebLayout.style.concrete_style import RenderingContext, StyleResolver
- from PIL import Image, ImageDraw
+ from pyWebLayout.style.concrete_style import RenderingContext
except ImportError as e:
print(f"Error importing required modules: {e}")
print("Make sure pyWebLayout is properly installed and PIL is available")
@@ -40,32 +38,32 @@ except ImportError as e:
def get_all_blocks_from_book(book) -> List[Block]:
"""
Extract all blocks from all chapters in the book.
-
+
Args:
book: The Book object from epub_reader
-
+
Returns:
List of all Block objects
"""
all_blocks = []
-
+
# Iterate through all chapters
for chapter in book.chapters:
# Get blocks from the chapter
if hasattr(chapter, '_blocks'):
all_blocks.extend(chapter._blocks)
-
+
return all_blocks
def create_page(page_style: PageStyle, page_size: Tuple[int, int]) -> Page:
"""
Create a new Page with the given style and size.
-
+
Args:
page_style: Style configuration for the page
page_size: (width, height) tuple
-
+
Returns:
A new Page object
"""
@@ -73,23 +71,23 @@ def create_page(page_style: PageStyle, page_size: Tuple[int, int]) -> Page:
size=page_size,
style=page_style
)
-
+
return page
-def layout_blocks_on_pages(blocks: List[Block], page_style: PageStyle,
+def layout_blocks_on_pages(blocks: List[Block], page_style: PageStyle,
page_size: Tuple[int, int], max_pages: int,
alignment_override: Optional[Alignment] = None) -> List[Page]:
"""
Layout blocks across multiple pages using DocumentLayouter.
-
+
Args:
blocks: List of abstract blocks to layout
page_style: Style configuration for pages
page_size: (width, height) tuple for pages
max_pages: Maximum number of pages to generate
alignment_override: Optional alignment to override paragraph alignment
-
+
Returns:
List of rendered Page objects
"""
@@ -97,29 +95,29 @@ def layout_blocks_on_pages(blocks: List[Block], page_style: PageStyle,
current_block_index = 0
continuation_word_index = 0
continuation_pretext = None
-
+
# Create rendering context
- rendering_context = RenderingContext(base_font_size=16)
-
+ _rendering_context = RenderingContext(base_font_size=16)
+
while current_block_index < len(blocks) and len(pages) < max_pages:
# Create a new page
page = create_page(page_style, page_size)
page_has_content = False
-
+
# Try to layout blocks on this page
while current_block_index < len(blocks):
block = blocks[current_block_index]
-
+
if isinstance(block, (Paragraph, Heading)):
# Layout paragraph/heading
success, failed_word_index, remaining_pretext = paragraph_layouter(
- block,
- page,
+ block,
+ page,
start_word=continuation_word_index,
pretext=continuation_pretext,
alignment_override=alignment_override
)
-
+
if success:
# Block fully laid out, move to next block
page_has_content = True
@@ -135,11 +133,11 @@ def layout_blocks_on_pages(blocks: List[Block], page_style: PageStyle,
continuation_pretext = remaining_pretext
# Break to create a new page
break
-
+
elif isinstance(block, AbstractImage):
# Layout image
success = image_layouter(block, page)
-
+
if success:
page_has_content = True
current_block_index += 1
@@ -148,7 +146,7 @@ def layout_blocks_on_pages(blocks: List[Block], page_style: PageStyle,
else:
# Image doesn't fit, try on next page
break
-
+
elif isinstance(block, HList):
# Layout list items as paragraphs
try:
@@ -162,53 +160,53 @@ def layout_blocks_on_pages(blocks: List[Block], page_style: PageStyle,
pretext=continuation_pretext,
alignment_override=alignment_override
)
-
+
if not success:
# Can't fit more on this page
page_has_content = True
break
-
+
continuation_word_index = 0
continuation_pretext = None
-
+
# Move to next block after processing list
page_has_content = True
current_block_index += 1
-
+
except Exception as e:
print(f"Warning: Error processing list: {e}")
current_block_index += 1
-
+
elif isinstance(block, PageBreak):
# PageBreak forces a new page
success = pagebreak_layouter(block, page)
-
+
# Mark that we've seen this block
current_block_index += 1
continuation_word_index = 0
continuation_pretext = None
-
+
# PageBreak always returns False to force new page
# Break to create a new page for subsequent content
break
-
+
elif isinstance(block, Table):
# Skip tables for now (not implemented)
- print(f"Warning: Skipping table (not yet implemented)")
+ print("Warning: Skipping table (not yet implemented)")
current_block_index += 1
-
+
else:
# Unknown block type, skip
print(f"Warning: Skipping unknown block type: {type(block).__name__}")
current_block_index += 1
-
+
# Add page if it has content
if page_has_content:
pages.append(page)
else:
# No content could be added to this page, stop
break
-
+
return pages
@@ -223,107 +221,107 @@ Examples:
python epub_page_renderer_documentlayouter.py book.epub --pages 10 --output-dir my_output --width 600 --height 800
"""
)
-
+
parser.add_argument(
'epub_file',
help='Path to the EPUB file to render'
)
-
+
parser.add_argument(
'--pages', '-p',
type=int,
default=5,
help='Number of pages to render (default: 5)'
)
-
+
parser.add_argument(
'--output-dir', '-o',
default='rendered_pages',
help='Output directory for rendered images (default: rendered_pages)'
)
-
+
parser.add_argument(
'--width', '-w',
type=int,
default=800,
help='Page width in pixels (default: 800)'
)
-
+
parser.add_argument(
'--height', '-t',
type=int,
default=1000,
help='Page height in pixels (default: 1000)'
)
-
+
parser.add_argument(
'--margin', '-m',
type=int,
default=40,
help='Page margin in pixels (default: 40)'
)
-
+
parser.add_argument(
'--align', '-a',
choices=['left', 'justify'],
default='left',
help='Text alignment: left or justify (default: left)'
)
-
+
args = parser.parse_args()
-
+
# Validate arguments
if not os.path.exists(args.epub_file):
print(f"Error: EPUB file '{args.epub_file}' not found")
return 1
-
+
if args.pages <= 0:
print("Error: Number of pages must be positive")
return 1
-
+
# Create output directory
try:
os.makedirs(args.output_dir, exist_ok=True)
except OSError as e:
print(f"Error creating output directory: {e}")
return 1
-
+
print(f"Loading EPUB file: {args.epub_file}")
-
+
# Load the EPUB file
try:
book = read_epub(args.epub_file)
print(f"Successfully loaded EPUB: {book.get_title() or 'Unknown Title'}")
-
+
# Print book information
author = book.get_metadata('AUTHOR')
if author:
print(f"Author: {author}")
-
+
print(f"Chapters: {len(book.chapters) if hasattr(book, 'chapters') else 'Unknown'}")
-
+
except Exception as e:
print(f"Error loading EPUB file: {e}")
import traceback
traceback.print_exc()
return 1
-
+
# Extract all blocks from the book
print("Extracting content blocks...")
try:
all_blocks = get_all_blocks_from_book(book)
print(f"Extracted {len(all_blocks)} content blocks")
-
+
if not all_blocks:
print("No content blocks found in EPUB. The book might be empty.")
return 1
-
+
except Exception as e:
print(f"Error extracting blocks: {e}")
import traceback
traceback.print_exc()
return 1
-
+
# Set up page style
page_size = (args.width, args.height)
page_style = PageStyle(
@@ -334,58 +332,58 @@ Examples:
line_spacing=5,
inter_block_spacing=15
)
-
+
# Set alignment
alignment = Alignment.JUSTIFY if args.align == 'justify' else Alignment.LEFT
print(f"Setting up layouter with page size {page_size} and {args.align} alignment")
-
+
# Layout blocks on pages
print(f"Rendering up to {args.pages} pages using DocumentLayouter...")
-
+
try:
pages = layout_blocks_on_pages(
- all_blocks,
- page_style,
- page_size,
+ all_blocks,
+ page_style,
+ page_size,
args.pages,
alignment_override=alignment
)
-
+
if not pages:
print("No pages were generated.")
return 1
-
+
print(f"Generated {len(pages)} pages")
-
+
# Save each page to an image
for i, page in enumerate(pages):
print(f"Saving page {i + 1}/{len(pages)}...")
-
+
try:
# Render the page
image = page.render()
-
+
# Save the image
output_filename = f"page_{i + 1:03d}.png"
output_path = os.path.join(args.output_dir, output_filename)
image.save(output_path, 'PNG')
-
+
print(f"Saved: {output_path}")
-
+
except Exception as e:
print(f"Error saving page {i + 1}: {e}")
import traceback
traceback.print_exc()
continue
-
+
print(f"\nCompleted! Rendered {len(pages)} pages to {args.output_dir}")
-
+
except Exception as e:
print(f"Error during pagination/rendering: {e}")
import traceback
traceback.print_exc()
return 1
-
+
return 0
diff --git a/scripts/run_coverage.py b/scripts/run_coverage.py
index ac5a5c1..8922951 100644
--- a/scripts/run_coverage.py
+++ b/scripts/run_coverage.py
@@ -5,7 +5,6 @@ Runs test and documentation coverage locally and generates badges.
"""
import subprocess
-import sys
import os
@@ -15,7 +14,7 @@ def run_command(cmd, description):
print(f"Running: {description}")
print(f"Command: {cmd}")
print(f"{'='*50}")
-
+
try:
result = subprocess.run(cmd, shell=True, check=True, capture_output=True, text=True)
print(result.stdout)
@@ -34,11 +33,11 @@ def main():
"""Run full coverage analysis locally."""
print("Local Coverage Analysis for pyWebLayout")
print("=" * 60)
-
+
# Change to project root if running from scripts directory
if os.path.basename(os.getcwd()) == "scripts":
os.chdir("..")
-
+
# Install required packages
print("\n1. Installing required packages...")
packages = [
@@ -46,28 +45,35 @@ def main():
"coverage-badge",
"interrogate"
]
-
+
for package in packages:
if not run_command(f"pip install {package}", f"Installing {package}"):
print(f"Failed to install {package}, continuing...")
-
+
# Run tests with coverage
print("\n2. Running tests with coverage...")
- test_cmd = "python -m pytest tests/ -v --cov=pyWebLayout --cov-report=term-missing --cov-report=json --cov-report=html --cov-report=xml"
+ test_cmd = (
+ "python -m pytest tests/ -v --cov=pyWebLayout "
+ "--cov-report=term-missing --cov-report=json --cov-report=html --cov-report=xml"
+ )
run_command(test_cmd, "Running tests with coverage")
-
+
# Generate test coverage badge
print("\n3. Generating test coverage badge...")
run_command("coverage-badge -o coverage.svg -f", "Generating test coverage badge")
-
+
# Check documentation coverage
print("\n4. Checking documentation coverage...")
- docs_cmd = "interrogate -v --ignore-init-method --ignore-init-module --ignore-magic --ignore-private --ignore-property-decorators --ignore-semiprivate --fail-under=80 --generate-badge coverage-docs.svg pyWebLayout/"
+ docs_cmd = (
+ "interrogate -v --ignore-init-method --ignore-init-module --ignore-magic "
+ "--ignore-private --ignore-property-decorators --ignore-semiprivate "
+ "--fail-under=80 --generate-badge coverage-docs.svg pyWebLayout/"
+ )
run_command(docs_cmd, "Checking documentation coverage")
-
+
# Generate coverage summary
print("\n5. Generating coverage summary...")
-
+
# Write a temporary script to avoid shell quoting issues
summary_script_content = '''import json
import os
@@ -75,30 +81,30 @@ import os
if os.path.exists("coverage.json"):
with open("coverage.json", "r") as f:
coverage_data = json.load(f)
-
+
total_coverage = round(coverage_data["totals"]["percent_covered"], 1)
covered_lines = coverage_data["totals"]["covered_lines"]
total_lines = coverage_data["totals"]["num_statements"]
-
+
with open("coverage-summary.txt", "w") as f:
f.write(f"{total_coverage}%")
-
+
print(f"Test Coverage: {total_coverage}%")
print(f"Lines Covered: {covered_lines}/{total_lines}")
else:
print("No coverage data found")
'''
-
+
# Write and execute temporary script
with open('temp_coverage_summary.py', 'w') as f:
f.write(summary_script_content)
-
- success = run_command("python temp_coverage_summary.py", "Generating coverage summary")
-
+
+ run_command("python temp_coverage_summary.py", "Generating coverage summary")
+
# Clean up temporary script
if os.path.exists('temp_coverage_summary.py'):
os.remove('temp_coverage_summary.py')
-
+
# List generated files
print("\n6. Generated files:")
files = ["coverage.svg", "coverage-docs.svg", "coverage-summary.txt", "htmlcov/", "coverage.json", "coverage.xml"]
@@ -107,7 +113,7 @@ else:
print(f" ✓ {file}")
else:
print(f" ✗ {file} (not found)")
-
+
print("\n" + "="*60)
print("Coverage analysis complete!")
print("To update your README with badges, run:")
diff --git a/scripts/run_coverage_gutters.py b/scripts/run_coverage_gutters.py
index 4f9b115..7c8fd2e 100644
--- a/scripts/run_coverage_gutters.py
+++ b/scripts/run_coverage_gutters.py
@@ -14,23 +14,23 @@ def main():
"""Run coverage for Coverage Gutters."""
print("Generating coverage for Coverage Gutters...")
print("Using the same pytest approach as CI...")
-
+
try:
# Run tests with coverage and generate all report formats (same as CI)
cmd = [
- sys.executable, "-m", "pytest",
- "tests/",
+ sys.executable, "-m", "pytest",
+ "tests/",
"-v",
- "--cov=pyWebLayout",
+ "--cov=pyWebLayout",
"--cov-report=term-missing",
"--cov-report=json",
"--cov-report=html",
"--cov-report=xml"
]
-
+
print(f"Running: {' '.join(cmd)}")
- result = subprocess.run(cmd, check=True)
-
+ _result = subprocess.run(cmd, check=True)
+
# Check if coverage.xml was created
if os.path.exists("coverage.xml"):
print("✓ coverage.xml generated successfully!")
@@ -42,14 +42,14 @@ def main():
print("2. Run 'Coverage Gutters: Remove Coverage' (to clear cache)")
print("3. Run 'Coverage Gutters: Display Coverage'")
print("4. Or use the Coverage Gutters buttons in the status bar")
-
+
# Show file info
size = os.path.getsize("coverage.xml")
print(f"\nGenerated coverage.xml: {size} bytes")
-
+
else:
print("✗ coverage.xml was not generated")
-
+
except subprocess.CalledProcessError as e:
print(f"Error running tests: {e}")
print("This may indicate test failures or missing dependencies.")
diff --git a/scripts/update_coverage_badges.py b/scripts/update_coverage_badges.py
index 1763061..71514d4 100644
--- a/scripts/update_coverage_badges.py
+++ b/scripts/update_coverage_badges.py
@@ -12,19 +12,19 @@ import sys
def update_readme_badges():
"""Update README.md with coverage badges."""
readme_path = "README.md"
-
+
if not os.path.exists(readme_path):
print("README.md not found!")
return False
-
+
# Read current README
with open(readme_path, 'r') as f:
content = f.read()
-
+
# Coverage badges to add/update
test_coverage_badge = ""
docs_coverage_badge = ""
-
+
# Check if badges already exist and update them, otherwise add them at the top
if "![Test Coverage]" in content:
content = re.sub(r'!\[Test Coverage\]\([^)]+\)', test_coverage_badge, content)
@@ -34,7 +34,7 @@ def update_readme_badges():
if len(lines) > 0:
lines.insert(1, f"\n{test_coverage_badge}")
content = '\n'.join(lines)
-
+
if "![Documentation Coverage]" in content:
content = re.sub(r'!\[Documentation Coverage\]\([^)]+\)', docs_coverage_badge, content)
else:
@@ -45,11 +45,11 @@ def update_readme_badges():
lines.insert(i + 1, docs_coverage_badge)
break
content = '\n'.join(lines)
-
+
# Write updated README
with open(readme_path, 'w') as f:
f.write(content)
-
+
print("README.md updated with coverage badges!")
return True
@@ -60,7 +60,7 @@ def show_coverage_summary():
with open("coverage-summary.txt", 'r') as f:
test_coverage = f.read().strip()
print(f"Current Test Coverage: {test_coverage}")
-
+
# Try to get documentation coverage from interrogate output
if os.path.exists("coverage.json"):
import json
@@ -68,7 +68,9 @@ def show_coverage_summary():
with open("coverage.json", 'r') as f:
coverage_data = json.load(f)
print(f"Detailed Coverage: {coverage_data['totals']['percent_covered']:.1f}%")
- print(f"Lines Covered: {coverage_data['totals']['covered_lines']}/{coverage_data['totals']['num_statements']}")
+ covered = coverage_data['totals']['covered_lines']
+ total = coverage_data['totals']['num_statements']
+ print(f"Lines Covered: {covered}/{total}")
except (KeyError, json.JSONDecodeError):
print("Could not parse coverage data")
diff --git a/scripts/update_coverage_gutters.py b/scripts/update_coverage_gutters.py
index 31370b1..069a3e1 100644
--- a/scripts/update_coverage_gutters.py
+++ b/scripts/update_coverage_gutters.py
@@ -10,18 +10,18 @@ import json
def main():
"""Main function to fix coverage gutters configuration."""
-
+
print("=== Coverage Gutters Fix ===")
print(f"Current working directory: {os.getcwd()}")
-
+
# 1. Check if coverage.xml exists
if os.path.exists('coverage.xml'):
print("✓ coverage.xml exists")
-
+
# Check file size and basic content
size = os.path.getsize('coverage.xml')
print(f"✓ coverage.xml size: {size} bytes")
-
+
# Read first few lines to verify it's valid XML
try:
with open('coverage.xml', 'r') as f:
@@ -37,12 +37,12 @@ def main():
print("Running coverage to generate coverage.xml...")
os.system("python -m coverage run --source=pyWebLayout -m unittest tests.test_abstract_inline")
os.system("python -m coverage xml")
-
+
# 2. Check VSCode settings
vscode_settings_path = '.vscode/settings.json'
if os.path.exists(vscode_settings_path):
print("✓ VSCode settings.json exists")
-
+
with open(vscode_settings_path, 'r') as f:
try:
settings = json.load(f)
@@ -57,18 +57,18 @@ def main():
print(f"✗ Error parsing VSCode settings: {e}")
else:
print("✗ VSCode settings.json not found")
-
+
# 3. Check if inline.py file exists
inline_file = 'pyWebLayout/abstract/inline.py'
if os.path.exists(inline_file):
print(f"✓ {inline_file} exists")
-
+
# Check file size
size = os.path.getsize(inline_file)
print(f"✓ {inline_file} size: {size} bytes")
else:
print(f"✗ {inline_file} does not exist")
-
+
# 4. Run a fresh coverage collection specifically for the inline module
print("\n=== Running Fresh Coverage ===")
try:
@@ -80,7 +80,7 @@ def main():
print("✓ Fresh coverage data generated")
except Exception as e:
print(f"✗ Error generating coverage: {e}")
-
+
# 5. Instructions for manual verification
print("\n=== Manual Verification Steps ===")
print("1. In VSCode, open the Command Palette (Ctrl+Shift+P)")
@@ -90,7 +90,7 @@ def main():
print(" - 'Coverage Gutters: Display Coverage' again")
print("4. Check that coverage.xml contains data for pyWebLayout/abstract/inline.py")
print("5. The file should show 100% coverage (all lines covered)")
-
+
print("\n=== Troubleshooting ===")
print("If coverage still doesn't show:")
print("1. Restart VSCode")
diff --git a/tests/abstract/test_abstract_blocks.py b/tests/abstract/test_abstract_blocks.py
index 3a9ed78..c4770ed 100644
--- a/tests/abstract/test_abstract_blocks.py
+++ b/tests/abstract/test_abstract_blocks.py
@@ -12,9 +12,9 @@ import threading
import time
from PIL import Image as PILImage
from pyWebLayout.abstract.block import (
- Block, BlockType, Paragraph, Heading, HeadingLevel, Quote, CodeBlock,
- HList, ListStyle, ListItem, Table, TableRow, TableCell,
- HorizontalRule, Image
+ BlockType, Paragraph, Heading, HeadingLevel, Quote, CodeBlock, HList,
+ ListStyle, ListItem, Table, TableRow, TableCell, HorizontalRule,
+ Image
)
from pyWebLayout.abstract.inline import Word, LineBreak
from pyWebLayout.style import Font
@@ -29,256 +29,256 @@ except ImportError:
class TestBlockElements(unittest.TestCase):
"""Test cases for basic block elements."""
-
+
def test_paragraph_creation(self):
"""Test creating and using paragraphs."""
paragraph = Paragraph()
-
+
self.assertEqual(paragraph.block_type, BlockType.PARAGRAPH)
self.assertEqual(paragraph.word_count, 0)
self.assertIsNone(paragraph.parent)
-
+
# Add words
font = Font()
word1 = Word("Hello", font)
word2 = Word("World", font)
-
+
paragraph.add_word(word1)
paragraph.add_word(word2)
-
+
self.assertEqual(paragraph.word_count, 2)
-
+
# Test word iteration
words = list(paragraph.words_iter())
self.assertEqual(len(words), 2)
self.assertEqual(words[0][1].text, "Hello")
self.assertEqual(words[1][1].text, "World")
-
+
def test_heading_levels(self):
"""Test heading creation with different levels."""
h1 = Heading(HeadingLevel.H1)
h3 = Heading(HeadingLevel.H3)
h6 = Heading(HeadingLevel.H6)
-
+
self.assertEqual(h1.level, HeadingLevel.H1)
self.assertEqual(h3.level, HeadingLevel.H3)
self.assertEqual(h6.level, HeadingLevel.H6)
-
+
self.assertEqual(h1.block_type, BlockType.HEADING)
-
+
# Test level modification
h1.level = HeadingLevel.H2
self.assertEqual(h1.level, HeadingLevel.H2)
-
+
def test_quote_nesting(self):
"""Test blockquote with nested content."""
quote = Quote()
-
+
# Add nested paragraphs
p1 = Paragraph()
p2 = Paragraph()
-
+
quote.add_block(p1)
quote.add_block(p2)
-
+
self.assertEqual(p1.parent, quote)
self.assertEqual(p2.parent, quote)
-
+
# Test block iteration
blocks = list(quote.blocks())
self.assertEqual(len(blocks), 2)
self.assertEqual(blocks[0], p1)
self.assertEqual(blocks[1], p2)
-
+
def test_code_block(self):
"""Test code block functionality."""
code = CodeBlock("python")
-
+
self.assertEqual(code.language, "python")
self.assertEqual(code.line_count, 0)
-
+
# Add code lines
code.add_line("def hello():")
code.add_line(" print('Hello!')")
-
+
self.assertEqual(code.line_count, 2)
-
+
# Test line iteration
lines = list(code.lines())
self.assertEqual(len(lines), 2)
self.assertEqual(lines[0][1], "def hello():")
self.assertEqual(lines[1][1], " print('Hello!')")
-
+
# Test language modification
code.language = "javascript"
self.assertEqual(code.language, "javascript")
-
+
def test_list_creation(self):
"""Test list creation and item management."""
# Unordered list
ul = HList(ListStyle.UNORDERED)
self.assertEqual(ul.style, ListStyle.UNORDERED)
self.assertEqual(ul.item_count, 0)
-
+
# Add list items
item1 = ListItem()
item2 = ListItem()
-
+
ul.add_item(item1)
ul.add_item(item2)
-
+
self.assertEqual(ul.item_count, 2)
self.assertEqual(item1.parent, ul)
self.assertEqual(item2.parent, ul)
-
+
# Test item iteration
items = list(ul.items())
self.assertEqual(len(items), 2)
-
+
# Test list style change
ul.style = ListStyle.ORDERED
self.assertEqual(ul.style, ListStyle.ORDERED)
-
+
def test_definition_list(self):
"""Test definition list with terms."""
dl = HList(ListStyle.DEFINITION)
-
+
# Add definition items with terms
dt1 = ListItem(term="Python")
dt2 = ListItem(term="JavaScript")
-
+
dl.add_item(dt1)
dl.add_item(dt2)
-
+
self.assertEqual(dt1.term, "Python")
self.assertEqual(dt2.term, "JavaScript")
-
+
# Test term modification
dt1.term = "Python 3"
self.assertEqual(dt1.term, "Python 3")
-
+
def test_table_structure(self):
"""Test table, row, and cell structure."""
table = Table(caption="Test Table")
-
+
self.assertEqual(table.caption, "Test Table")
self.assertEqual(table.row_count["total"], 0)
-
+
# Create rows and cells
header_row = TableRow()
data_row = TableRow()
-
+
# Header cells
h1 = TableCell(is_header=True)
h2 = TableCell(is_header=True)
header_row.add_cell(h1)
header_row.add_cell(h2)
-
+
# Data cells
d1 = TableCell(is_header=False)
d2 = TableCell(is_header=False, colspan=2)
data_row.add_cell(d1)
data_row.add_cell(d2)
-
+
# Add rows to table
table.add_row(header_row, "header")
table.add_row(data_row, "body")
-
+
# Test structure
self.assertEqual(table.row_count["header"], 1)
self.assertEqual(table.row_count["body"], 1)
self.assertEqual(table.row_count["total"], 2)
-
+
# Test cell properties
self.assertTrue(h1.is_header)
self.assertFalse(d1.is_header)
self.assertEqual(d2.colspan, 2)
self.assertEqual(d2.rowspan, 1) # Default
-
+
# Test row cell count
self.assertEqual(header_row.cell_count, 2)
self.assertEqual(data_row.cell_count, 2)
-
+
def test_table_sections(self):
"""Test table header, body, and footer sections."""
table = Table()
-
+
# Add rows to different sections
header = TableRow()
body1 = TableRow()
body2 = TableRow()
footer = TableRow()
-
+
table.add_row(header, "header")
table.add_row(body1, "body")
table.add_row(body2, "body")
table.add_row(footer, "footer")
-
+
# Test section iteration
header_rows = list(table.header_rows())
body_rows = list(table.body_rows())
footer_rows = list(table.footer_rows())
-
+
self.assertEqual(len(header_rows), 1)
self.assertEqual(len(body_rows), 2)
self.assertEqual(len(footer_rows), 1)
-
+
# Test all_rows iteration
all_rows = list(table.all_rows())
self.assertEqual(len(all_rows), 4)
-
+
# Check section labels
sections = [section for section, row in all_rows]
self.assertEqual(sections, ["header", "body", "body", "footer"])
-
+
def test_image_loading(self):
"""Test image element properties."""
# Test with basic properties
img = Image("test.jpg", "Test image", 100, 200)
-
+
self.assertEqual(img.source, "test.jpg")
self.assertEqual(img.alt_text, "Test image")
self.assertEqual(img.width, 100)
self.assertEqual(img.height, 200)
-
+
# Test property modification
img.source = "new.png"
img.alt_text = "New image"
img.width = 150
img.height = 300
-
+
self.assertEqual(img.source, "new.png")
self.assertEqual(img.alt_text, "New image")
self.assertEqual(img.width, 150)
self.assertEqual(img.height, 300)
-
+
# Test dimensions tuple
self.assertEqual(img.get_dimensions(), (150, 300))
-
+
def test_aspect_ratio_calculation(self):
"""Test image aspect ratio calculations."""
# Test with specified dimensions
img = Image("test.jpg", width=400, height=200)
self.assertEqual(img.get_aspect_ratio(), 2.0) # 400/200
-
+
# Test with only one dimension
img2 = Image("test.jpg", width=300)
self.assertIsNone(img2.get_aspect_ratio()) # No height specified
-
+
# Test scaled dimensions
scaled = img.calculate_scaled_dimensions(max_width=200, max_height=150)
# Should scale down proportionally
self.assertEqual(scaled[0], 200) # Width limited by max_width
self.assertEqual(scaled[1], 100) # Height scaled proportionally
-
+
def test_simple_elements(self):
"""Test simple block elements."""
hr = HorizontalRule()
br = LineBreak()
-
+
self.assertEqual(hr.block_type, BlockType.HORIZONTAL_RULE)
self.assertEqual(br.block_type, BlockType.LINE_BREAK)
-
+
# These elements have no additional properties
self.assertIsNone(hr.parent)
self.assertIsNone(br.parent)
@@ -286,29 +286,29 @@ class TestBlockElements(unittest.TestCase):
class TestImagePIL(unittest.TestCase):
"""Test cases for Image class with PIL functionality."""
-
+
@classmethod
def setUpClass(cls):
"""Set up temporary directory and test images."""
cls.temp_dir = tempfile.mkdtemp()
cls.sample_image_path = "tests/data/sample_image.jpg"
-
+
# Create test images in different formats
cls._create_test_images()
-
+
# Start Flask server for URL testing if Flask is available
if FLASK_AVAILABLE:
cls._start_flask_server()
-
+
@classmethod
def tearDownClass(cls):
"""Clean up temporary directory and stop Flask server."""
shutil.rmtree(cls.temp_dir, ignore_errors=True)
-
+
if FLASK_AVAILABLE and hasattr(cls, 'flask_thread'):
cls.flask_server_running = False
cls.flask_thread.join(timeout=2)
-
+
@classmethod
def _create_test_images(cls):
"""Create test images in different formats."""
@@ -316,17 +316,17 @@ class TestImagePIL(unittest.TestCase):
if os.path.exists(cls.sample_image_path):
with PILImage.open(cls.sample_image_path) as img:
cls.original_size = img.size
-
+
# Save in different formats
cls.jpg_path = os.path.join(cls.temp_dir, "test.jpg")
cls.png_path = os.path.join(cls.temp_dir, "test.png")
cls.bmp_path = os.path.join(cls.temp_dir, "test.bmp")
cls.gif_path = os.path.join(cls.temp_dir, "test.gif")
-
+
img.save(cls.jpg_path, "JPEG")
img.save(cls.png_path, "PNG")
img.save(cls.bmp_path, "BMP")
-
+
# Convert to RGB for GIF (GIF doesn't support transparency from RGBA)
rgb_img = img.convert("RGB")
rgb_img.save(cls.gif_path, "GIF")
@@ -334,17 +334,17 @@ class TestImagePIL(unittest.TestCase):
# Create a simple test image if sample doesn't exist
cls.original_size = (100, 100)
test_img = PILImage.new("RGB", cls.original_size, (255, 0, 0))
-
+
cls.jpg_path = os.path.join(cls.temp_dir, "test.jpg")
cls.png_path = os.path.join(cls.temp_dir, "test.png")
cls.bmp_path = os.path.join(cls.temp_dir, "test.bmp")
cls.gif_path = os.path.join(cls.temp_dir, "test.gif")
-
+
test_img.save(cls.jpg_path, "JPEG")
test_img.save(cls.png_path, "PNG")
test_img.save(cls.bmp_path, "BMP")
test_img.save(cls.gif_path, "GIF")
-
+
@classmethod
def _start_flask_server(cls):
"""Start a Flask server for URL testing."""
@@ -365,7 +365,7 @@ class TestImagePIL(unittest.TestCase):
def run_flask():
cls.flask_app.run(host='127.0.0.1', port=cls.flask_port, debug=False,
- use_reloader=False, threaded=True)
+ use_reloader=False, threaded=True)
cls.flask_thread = threading.Thread(target=run_flask, daemon=True)
cls.flask_thread.start()
@@ -384,120 +384,120 @@ class TestImagePIL(unittest.TestCase):
pass
time.sleep(wait_interval)
elapsed += wait_interval
-
+
def test_image_url_detection(self):
"""Test URL detection functionality."""
img = Image()
-
+
# Test URL detection
self.assertTrue(img._is_url("http://example.com/image.jpg"))
self.assertTrue(img._is_url("https://example.com/image.png"))
self.assertTrue(img._is_url("ftp://example.com/image.gif"))
-
+
# Test non-URL detection
self.assertFalse(img._is_url("image.jpg"))
self.assertFalse(img._is_url("/path/to/image.png"))
self.assertFalse(img._is_url("../relative/path.gif"))
self.assertFalse(img._is_url(""))
-
+
def test_load_local_image_jpg(self):
"""Test loading local JPG image."""
img = Image(self.jpg_path)
-
+
file_path, pil_img = img.load_image_data()
-
+
self.assertIsNotNone(pil_img)
self.assertEqual(file_path, self.jpg_path)
self.assertEqual(pil_img.size, self.original_size)
self.assertEqual(img.width, self.original_size[0])
self.assertEqual(img.height, self.original_size[1])
-
+
def test_load_local_image_png(self):
"""Test loading local PNG image."""
img = Image(self.png_path)
-
+
file_path, pil_img = img.load_image_data()
-
+
self.assertIsNotNone(pil_img)
self.assertEqual(file_path, self.png_path)
self.assertEqual(pil_img.size, self.original_size)
-
+
def test_load_local_image_bmp(self):
"""Test loading local BMP image."""
img = Image(self.bmp_path)
-
+
file_path, pil_img = img.load_image_data()
-
+
self.assertIsNotNone(pil_img)
self.assertEqual(file_path, self.bmp_path)
self.assertEqual(pil_img.size, self.original_size)
-
+
def test_load_local_image_gif(self):
"""Test loading local GIF image."""
img = Image(self.gif_path)
-
+
file_path, pil_img = img.load_image_data()
-
+
self.assertIsNotNone(pil_img)
self.assertEqual(file_path, self.gif_path)
self.assertEqual(pil_img.size, self.original_size)
-
+
def test_load_nonexistent_image(self):
"""Test loading non-existent image."""
img = Image("nonexistent.jpg")
-
+
file_path, pil_img = img.load_image_data()
-
+
self.assertIsNone(pil_img)
self.assertIsNone(file_path)
-
+
def test_load_empty_source(self):
"""Test loading with empty source."""
img = Image("")
-
+
file_path, pil_img = img.load_image_data()
-
+
self.assertIsNone(pil_img)
self.assertIsNone(file_path)
-
+
def test_auto_update_dimensions(self):
"""Test automatic dimension updating."""
img = Image(self.jpg_path, width=50, height=50) # Wrong initial dimensions
-
+
# Test with auto-update enabled (default)
file_path, pil_img = img.load_image_data(auto_update_dimensions=True)
-
+
self.assertEqual(img.width, self.original_size[0])
self.assertEqual(img.height, self.original_size[1])
-
+
def test_no_auto_update_dimensions(self):
"""Test loading without automatic dimension updating."""
original_width, original_height = 50, 50
img = Image(self.jpg_path, width=original_width, height=original_height)
-
+
# Test with auto-update disabled
file_path, pil_img = img.load_image_data(auto_update_dimensions=False)
-
+
self.assertEqual(img.width, original_width) # Should remain unchanged
self.assertEqual(img.height, original_height) # Should remain unchanged
-
+
def test_get_image_info(self):
"""Test getting detailed image information."""
img = Image(self.jpg_path)
-
+
info = img.get_image_info()
-
+
self.assertIsInstance(info, dict)
self.assertIn('format', info)
self.assertIn('mode', info)
self.assertIn('size', info)
self.assertIn('width', info)
self.assertIn('height', info)
-
+
self.assertEqual(info['size'], self.original_size)
self.assertEqual(info['width'], self.original_size[0])
self.assertEqual(info['height'], self.original_size[1])
-
+
def test_get_image_info_different_formats(self):
"""Test getting image info for different formats."""
formats_and_paths = [
@@ -506,80 +506,80 @@ class TestImagePIL(unittest.TestCase):
('BMP', self.bmp_path),
('GIF', self.gif_path),
]
-
+
for expected_format, path in formats_and_paths:
with self.subTest(format=expected_format):
img = Image(path)
info = img.get_image_info()
-
+
self.assertEqual(info['format'], expected_format)
self.assertEqual(info['size'], self.original_size)
-
+
def test_get_image_info_nonexistent(self):
"""Test getting image info for non-existent image."""
img = Image("nonexistent.jpg")
-
+
info = img.get_image_info()
-
+
self.assertEqual(info, {})
-
+
@unittest.skipUnless(FLASK_AVAILABLE, "Flask not available for URL testing")
def test_load_image_from_url(self):
"""Test loading image from URL."""
url = f"http://127.0.0.1:{self.flask_port}/test.jpg"
img = Image(url)
-
+
file_path, pil_img = img.load_image_data()
-
+
self.assertIsNotNone(pil_img)
self.assertIsNotNone(file_path)
self.assertTrue(file_path.endswith('.tmp')) # Should be a temp file
self.assertEqual(pil_img.size, self.original_size)
-
+
# Check that dimensions were updated
self.assertEqual(img.width, self.original_size[0])
self.assertEqual(img.height, self.original_size[1])
-
+
@unittest.skipUnless(FLASK_AVAILABLE, "Flask not available for URL testing")
def test_get_image_info_from_url(self):
"""Test getting image info from URL."""
url = f"http://127.0.0.1:{self.flask_port}/test.jpg"
img = Image(url)
-
+
info = img.get_image_info()
-
+
self.assertIsInstance(info, dict)
self.assertEqual(info['format'], 'JPEG')
self.assertEqual(info['size'], self.original_size)
-
+
def test_load_invalid_url(self):
"""Test loading from invalid URL."""
img = Image("http://nonexistent.domain/image.jpg")
-
+
file_path, pil_img = img.load_image_data()
-
+
self.assertIsNone(pil_img)
self.assertIsNone(file_path)
-
+
def test_multiple_loads_cleanup(self):
"""Test that multiple loads don't leave temp files."""
img = Image(self.jpg_path)
-
+
# Load multiple times
for _ in range(3):
file_path, pil_img = img.load_image_data()
self.assertIsNotNone(pil_img)
-
+
def test_original_sample_image(self):
"""Test loading the original sample image if it exists."""
if os.path.exists(self.sample_image_path):
img = Image(self.sample_image_path)
-
+
file_path, pil_img = img.load_image_data()
-
+
self.assertIsNotNone(pil_img)
self.assertEqual(file_path, self.sample_image_path)
-
+
# Test that we can get image info
info = img.get_image_info()
self.assertIsInstance(info, dict)
diff --git a/tests/abstract/test_abstract_document.py b/tests/abstract/test_abstract_document.py
index fadcb04..87afd06 100644
--- a/tests/abstract/test_abstract_document.py
+++ b/tests/abstract/test_abstract_document.py
@@ -8,13 +8,13 @@ document structure and metadata management.
import unittest
from pyWebLayout.abstract.document import Document, Chapter, Book, MetadataType
from pyWebLayout.abstract.block import Paragraph, Heading, HeadingLevel, BlockType
-from pyWebLayout.abstract.inline import Word, FormattedSpan
+from pyWebLayout.abstract.inline import Word
from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration
class TestMetadataType(unittest.TestCase):
"""Test cases for MetadataType enum."""
-
+
def test_metadata_types(self):
"""Test that all expected metadata types exist."""
expected_types = [
@@ -22,135 +22,141 @@ class TestMetadataType(unittest.TestCase):
'PUBLICATION_DATE', 'MODIFIED_DATE', 'PUBLISHER', 'IDENTIFIER',
'COVER_IMAGE', 'CUSTOM'
]
-
+
for type_name in expected_types:
self.assertTrue(hasattr(MetadataType, type_name))
-
+
# Test custom type has expected value
self.assertEqual(MetadataType.CUSTOM.value, 100)
class TestDocument(unittest.TestCase):
"""Test cases for Document class."""
-
+
def setUp(self):
"""Set up test fixtures."""
self.doc = Document("Test Document", "en-US")
self.font = Font()
-
+
def test_document_creation(self):
"""Test document creation with basic parameters."""
self.assertEqual(self.doc.get_title(), "Test Document")
self.assertEqual(self.doc.get_metadata(MetadataType.LANGUAGE), "en-US")
self.assertEqual(len(self.doc.blocks), 0)
-
+
def test_document_creation_minimal(self):
"""Test document creation with minimal parameters."""
doc = Document()
self.assertIsNone(doc.get_title())
self.assertEqual(doc.get_metadata(MetadataType.LANGUAGE), "en-US")
-
+
def test_metadata_management(self):
"""Test setting and getting metadata."""
# Set various metadata types
self.doc.set_metadata(MetadataType.AUTHOR, "John Doe")
self.doc.set_metadata(MetadataType.DESCRIPTION, "A test document")
self.doc.set_metadata(MetadataType.KEYWORDS, ["test", "document"])
-
+
# Test retrieval
self.assertEqual(self.doc.get_metadata(MetadataType.AUTHOR), "John Doe")
- self.assertEqual(self.doc.get_metadata(MetadataType.DESCRIPTION), "A test document")
- self.assertEqual(self.doc.get_metadata(MetadataType.KEYWORDS), ["test", "document"])
-
+ self.assertEqual(
+ self.doc.get_metadata(
+ MetadataType.DESCRIPTION),
+ "A test document")
+ self.assertEqual(
+ self.doc.get_metadata(
+ MetadataType.KEYWORDS), [
+ "test", "document"])
+
# Test non-existent metadata
self.assertIsNone(self.doc.get_metadata(MetadataType.PUBLISHER))
-
+
def test_title_convenience_methods(self):
"""Test title getter and setter convenience methods."""
# Test setting title
self.doc.set_title("New Title")
self.assertEqual(self.doc.get_title(), "New Title")
-
+
# Test that it's also in metadata
self.assertEqual(self.doc.get_metadata(MetadataType.TITLE), "New Title")
-
+
def test_block_management(self):
"""Test adding and managing blocks."""
# Create some blocks
para1 = Paragraph()
para2 = Paragraph()
heading = Heading(HeadingLevel.H1)
-
+
# Add blocks
self.doc.add_block(para1)
self.doc.add_block(heading)
self.doc.add_block(para2)
-
+
# Test blocks list
self.assertEqual(len(self.doc.blocks), 3)
self.assertEqual(self.doc.blocks[0], para1)
self.assertEqual(self.doc.blocks[1], heading)
self.assertEqual(self.doc.blocks[2], para2)
-
+
def test_anchor_management(self):
"""Test named anchor functionality."""
heading = Heading(HeadingLevel.H1)
para = Paragraph()
-
+
# Add anchors
self.doc.add_anchor("intro", heading)
self.doc.add_anchor("content", para)
-
+
# Test retrieval
self.assertEqual(self.doc.get_anchor("intro"), heading)
self.assertEqual(self.doc.get_anchor("content"), para)
self.assertIsNone(self.doc.get_anchor("nonexistent"))
-
+
def test_resource_management(self):
"""Test document resource management."""
# Add various resources
self.doc.add_resource("image1", {"type": "image", "path": "test.jpg"})
self.doc.add_resource("style1", {"type": "css", "content": "body {}"})
-
+
# Test retrieval
image = self.doc.get_resource("image1")
self.assertEqual(image["type"], "image")
self.assertEqual(image["path"], "test.jpg")
-
+
style = self.doc.get_resource("style1")
self.assertEqual(style["type"], "css")
-
+
# Test non-existent resource
self.assertIsNone(self.doc.get_resource("nonexistent"))
-
+
def test_stylesheet_management(self):
"""Test stylesheet addition."""
# Add stylesheets
css1 = {"href": "style.css", "type": "text/css"}
css2 = {"href": "theme.css", "type": "text/css"}
-
+
self.doc.add_stylesheet(css1)
self.doc.add_stylesheet(css2)
-
+
# Test that stylesheets are stored
self.assertEqual(len(self.doc._stylesheets), 2)
self.assertEqual(self.doc._stylesheets[0], css1)
self.assertEqual(self.doc._stylesheets[1], css2)
-
+
def test_script_management(self):
"""Test script addition."""
# Add scripts
script1 = "console.log('Hello');"
script2 = "document.ready(function(){});"
-
+
self.doc.add_script(script1)
self.doc.add_script(script2)
-
+
# Test that scripts are stored
self.assertEqual(len(self.doc._scripts), 2)
self.assertEqual(self.doc._scripts[0], script1)
self.assertEqual(self.doc._scripts[1], script2)
-
+
def test_find_blocks_by_type(self):
"""Test finding blocks by type."""
# Create blocks of different types
@@ -158,87 +164,87 @@ class TestDocument(unittest.TestCase):
para2 = Paragraph()
heading1 = Heading(HeadingLevel.H1)
heading2 = Heading(HeadingLevel.H2)
-
+
# Add blocks to document
self.doc.add_block(para1)
self.doc.add_block(heading1)
self.doc.add_block(para2)
self.doc.add_block(heading2)
-
+
# Test finding paragraphs
paragraphs = self.doc.find_blocks_by_type(BlockType.PARAGRAPH)
self.assertEqual(len(paragraphs), 2)
self.assertIn(para1, paragraphs)
self.assertIn(para2, paragraphs)
-
+
# Test finding headings
headings = self.doc.find_blocks_by_type(BlockType.HEADING)
self.assertEqual(len(headings), 2)
self.assertIn(heading1, headings)
self.assertIn(heading2, headings)
-
+
def test_find_headings(self):
"""Test finding heading blocks specifically."""
# Create mixed blocks
para = Paragraph()
h1 = Heading(HeadingLevel.H1)
h2 = Heading(HeadingLevel.H2)
-
+
# Add words to headings for title extraction
word1 = Word("Chapter", self.font)
word2 = Word("One", self.font)
h1.add_word(word1)
h1.add_word(word2)
-
+
word3 = Word("Section", self.font)
h2.add_word(word3)
-
+
self.doc.add_block(para)
self.doc.add_block(h1)
self.doc.add_block(h2)
-
+
# Test finding headings
headings = self.doc.find_headings()
self.assertEqual(len(headings), 2)
self.assertIn(h1, headings)
self.assertIn(h2, headings)
self.assertNotIn(para, headings)
-
+
def test_generate_table_of_contents(self):
"""Test table of contents generation."""
# Create headings with content
h1 = Heading(HeadingLevel.H1)
h2 = Heading(HeadingLevel.H2)
h3 = Heading(HeadingLevel.H3)
-
+
# Add words to headings
h1.add_word(Word("Introduction", self.font))
h2.add_word(Word("Getting", self.font))
h2.add_word(Word("Started", self.font))
h3.add_word(Word("Installation", self.font))
-
+
self.doc.add_block(h1)
self.doc.add_block(h2)
self.doc.add_block(h3)
-
+
# Generate TOC
toc = self.doc.generate_table_of_contents()
-
+
# Test TOC structure
self.assertEqual(len(toc), 3)
-
+
# Test first entry
level, title, block = toc[0]
self.assertEqual(level, 1) # H1
self.assertEqual(title, "Introduction")
self.assertEqual(block, h1)
-
+
# Test second entry
level, title, block = toc[1]
self.assertEqual(level, 2) # H2
self.assertEqual(title, "Getting Started")
self.assertEqual(block, h2)
-
+
# Test third entry
level, title, block = toc[2]
self.assertEqual(level, 3) # H3
@@ -248,127 +254,127 @@ class TestDocument(unittest.TestCase):
class TestChapter(unittest.TestCase):
"""Test cases for Chapter class."""
-
+
def setUp(self):
"""Set up test fixtures."""
self.chapter = Chapter("Test Chapter", 1)
-
+
def test_chapter_creation(self):
"""Test chapter creation."""
self.assertEqual(self.chapter.title, "Test Chapter")
self.assertEqual(self.chapter.level, 1)
self.assertEqual(len(self.chapter.blocks), 0)
-
+
def test_chapter_creation_minimal(self):
"""Test chapter creation with minimal parameters."""
chapter = Chapter()
self.assertIsNone(chapter.title)
self.assertEqual(chapter.level, 1)
-
+
def test_title_property(self):
"""Test title property getter and setter."""
# Test setter
self.chapter.title = "New Chapter Title"
self.assertEqual(self.chapter.title, "New Chapter Title")
-
+
# Test setting to None
self.chapter.title = None
self.assertIsNone(self.chapter.title)
-
+
def test_level_property(self):
"""Test level property."""
self.assertEqual(self.chapter.level, 1)
-
+
# Level should be read-only (no setter test)
# This is by design based on the class definition
-
+
def test_block_management(self):
"""Test adding blocks to chapter."""
para1 = Paragraph()
para2 = Paragraph()
heading = Heading(HeadingLevel.H2)
-
+
# Add blocks
self.chapter.add_block(para1)
self.chapter.add_block(heading)
self.chapter.add_block(para2)
-
+
# Test blocks list
self.assertEqual(len(self.chapter.blocks), 3)
self.assertEqual(self.chapter.blocks[0], para1)
self.assertEqual(self.chapter.blocks[1], heading)
self.assertEqual(self.chapter.blocks[2], para2)
-
+
def test_metadata_management(self):
"""Test chapter metadata."""
# Set metadata
self.chapter.set_metadata("author", "Jane Doe")
self.chapter.set_metadata("word_count", 1500)
self.chapter.set_metadata("tags", ["intro", "basics"])
-
+
# Test retrieval
self.assertEqual(self.chapter.get_metadata("author"), "Jane Doe")
self.assertEqual(self.chapter.get_metadata("word_count"), 1500)
self.assertEqual(self.chapter.get_metadata("tags"), ["intro", "basics"])
-
+
# Test non-existent metadata
self.assertIsNone(self.chapter.get_metadata("nonexistent"))
class TestBook(unittest.TestCase):
"""Test cases for Book class."""
-
+
def setUp(self):
"""Set up test fixtures."""
self.book = Book("Test Book", "Author Name", "en-US")
-
+
def test_book_creation(self):
"""Test book creation with all parameters."""
self.assertEqual(self.book.get_title(), "Test Book")
self.assertEqual(self.book.get_author(), "Author Name")
self.assertEqual(self.book.get_metadata(MetadataType.LANGUAGE), "en-US")
self.assertEqual(len(self.book.chapters), 0)
-
+
def test_book_creation_minimal(self):
"""Test book creation with minimal parameters."""
book = Book()
self.assertIsNone(book.get_title())
self.assertIsNone(book.get_author())
self.assertEqual(book.get_metadata(MetadataType.LANGUAGE), "en-US")
-
+
def test_book_creation_partial(self):
"""Test book creation with partial parameters."""
book = Book(title="Just Title")
self.assertEqual(book.get_title(), "Just Title")
self.assertIsNone(book.get_author())
-
+
def test_author_convenience_methods(self):
"""Test author getter and setter convenience methods."""
# Test setting author
self.book.set_author("New Author")
self.assertEqual(self.book.get_author(), "New Author")
-
+
# Test that it's also in metadata
self.assertEqual(self.book.get_metadata(MetadataType.AUTHOR), "New Author")
-
+
def test_chapter_management(self):
"""Test adding and managing chapters."""
# Create chapters
ch1 = Chapter("Introduction", 1)
ch2 = Chapter("Getting Started", 1)
ch3 = Chapter("Advanced Topics", 1)
-
+
# Add chapters
self.book.add_chapter(ch1)
self.book.add_chapter(ch2)
self.book.add_chapter(ch3)
-
+
# Test chapters list
self.assertEqual(len(self.book.chapters), 3)
self.assertEqual(self.book.chapters[0], ch1)
self.assertEqual(self.book.chapters[1], ch2)
self.assertEqual(self.book.chapters[2], ch3)
-
+
def test_create_chapter(self):
"""Test creating chapters through the book."""
# Create chapter with title and level
@@ -377,13 +383,13 @@ class TestBook(unittest.TestCase):
self.assertEqual(ch1.level, 1)
self.assertEqual(len(self.book.chapters), 1)
self.assertEqual(self.book.chapters[0], ch1)
-
+
# Create chapter with minimal parameters
ch2 = self.book.create_chapter()
self.assertIsNone(ch2.title)
self.assertEqual(ch2.level, 1)
self.assertEqual(len(self.book.chapters), 2)
-
+
def test_generate_book_toc(self):
"""Test table of contents generation for book."""
# Create chapters with different levels
@@ -392,20 +398,20 @@ class TestBook(unittest.TestCase):
ch3 = Chapter("Basic Concepts", 2)
ch4 = Chapter("Advanced Topics", 1)
ch5 = Chapter("Best Practices", 2)
-
+
# Add chapters to book
self.book.add_chapter(ch1)
self.book.add_chapter(ch2)
self.book.add_chapter(ch3)
self.book.add_chapter(ch4)
self.book.add_chapter(ch5)
-
+
# Generate TOC
toc = self.book.generate_table_of_contents()
-
+
# Test TOC structure
self.assertEqual(len(toc), 5)
-
+
# Test entries
expected = [
(1, "Introduction", ch1),
@@ -414,38 +420,38 @@ class TestBook(unittest.TestCase):
(1, "Advanced Topics", ch4),
(2, "Best Practices", ch5)
]
-
+
for i, (exp_level, exp_title, exp_chapter) in enumerate(expected):
level, title, chapter = toc[i]
self.assertEqual(level, exp_level)
self.assertEqual(title, exp_title)
self.assertEqual(chapter, exp_chapter)
-
+
def test_generate_book_toc_with_untitled_chapters(self):
"""Test TOC generation with chapters that have no title."""
# Create chapters, some without titles
ch1 = Chapter("Introduction", 1)
ch2 = Chapter(None, 1) # No title
ch3 = Chapter("Conclusion", 1)
-
+
self.book.add_chapter(ch1)
self.book.add_chapter(ch2)
self.book.add_chapter(ch3)
-
+
# Generate TOC
toc = self.book.generate_table_of_contents()
-
+
# Should only include chapters with titles
self.assertEqual(len(toc), 2)
-
+
level, title, chapter = toc[0]
self.assertEqual(title, "Introduction")
self.assertEqual(chapter, ch1)
-
+
level, title, chapter = toc[1]
self.assertEqual(title, "Conclusion")
self.assertEqual(chapter, ch3)
-
+
def test_book_inherits_document_features(self):
"""Test that Book inherits all Document functionality."""
# Test that book can use all document methods
@@ -453,11 +459,14 @@ class TestBook(unittest.TestCase):
para = Paragraph()
self.book.add_block(para)
self.assertEqual(len(self.book.blocks), 1)
-
+
# Test metadata
self.book.set_metadata(MetadataType.PUBLISHER, "Test Publisher")
- self.assertEqual(self.book.get_metadata(MetadataType.PUBLISHER), "Test Publisher")
-
+ self.assertEqual(
+ self.book.get_metadata(
+ MetadataType.PUBLISHER),
+ "Test Publisher")
+
# Test anchors
heading = Heading(HeadingLevel.H1)
self.book.add_anchor("preface", heading)
@@ -466,11 +475,11 @@ class TestBook(unittest.TestCase):
class TestDocumentFontRegistry(unittest.TestCase):
"""Test cases for Document font registry functionality."""
-
+
def setUp(self):
"""Set up test fixtures."""
self.doc = Document("Test Document", "en-US")
-
+
def test_get_or_create_font_creates_new_font(self):
"""Test that get_or_create_font creates a new font when none exists."""
font = self.doc.get_or_create_font(
@@ -478,14 +487,14 @@ class TestDocumentFontRegistry(unittest.TestCase):
colour=(255, 0, 0),
weight=FontWeight.BOLD
)
-
+
self.assertEqual(font.font_size, 14)
self.assertEqual(font.colour, (255, 0, 0))
self.assertEqual(font.weight, FontWeight.BOLD)
-
+
# Check that font is stored in registry
self.assertEqual(len(self.doc._fonts), 1)
-
+
def test_get_or_create_font_reuses_existing_font(self):
"""Test that get_or_create_font reuses existing fonts."""
# Create first font
@@ -494,20 +503,20 @@ class TestDocumentFontRegistry(unittest.TestCase):
colour=(255, 0, 0),
weight=FontWeight.BOLD
)
-
+
# Create second font with same properties
font2 = self.doc.get_or_create_font(
font_size=14,
colour=(255, 0, 0),
weight=FontWeight.BOLD
)
-
+
# Should return the same font object
self.assertIs(font1, font2)
-
+
# Should only have one font in registry
self.assertEqual(len(self.doc._fonts), 1)
-
+
def test_get_or_create_font_creates_different_fonts(self):
"""Test that different font properties create different fonts."""
# Create first font
@@ -516,28 +525,28 @@ class TestDocumentFontRegistry(unittest.TestCase):
colour=(255, 0, 0),
weight=FontWeight.BOLD
)
-
+
# Create font with different size
font2 = self.doc.get_or_create_font(
font_size=16,
colour=(255, 0, 0),
weight=FontWeight.BOLD
)
-
+
# Create font with different color
font3 = self.doc.get_or_create_font(
font_size=14,
colour=(0, 255, 0),
weight=FontWeight.BOLD
)
-
+
# Create font with different weight
font4 = self.doc.get_or_create_font(
font_size=14,
colour=(255, 0, 0),
weight=FontWeight.NORMAL
)
-
+
# All should be different objects
self.assertIsNot(font1, font2)
self.assertIsNot(font1, font3)
@@ -545,10 +554,10 @@ class TestDocumentFontRegistry(unittest.TestCase):
self.assertIsNot(font2, font3)
self.assertIsNot(font2, font4)
self.assertIsNot(font3, font4)
-
+
# Should have four fonts in registry
self.assertEqual(len(self.doc._fonts), 4)
-
+
def test_get_or_create_font_with_all_parameters(self):
"""Test get_or_create_font with all parameters."""
font = self.doc.get_or_create_font(
@@ -562,7 +571,7 @@ class TestDocumentFontRegistry(unittest.TestCase):
language="fr_FR",
min_hyphenation_width=80
)
-
+
self.assertEqual(font._font_path, "path/to/font.ttf")
self.assertEqual(font.font_size, 18)
self.assertEqual(font.colour, (128, 64, 192))
@@ -572,11 +581,11 @@ class TestDocumentFontRegistry(unittest.TestCase):
self.assertEqual(font.background, (255, 255, 255, 128))
self.assertEqual(font.language, "fr_FR")
self.assertEqual(font.min_hyphenation_width, 80)
-
+
def test_get_or_create_font_with_defaults(self):
"""Test get_or_create_font with default values."""
font = self.doc.get_or_create_font()
-
+
# Should create font with default values
self.assertIsNotNone(font)
self.assertEqual(font.font_size, 16) # Default font size
@@ -588,12 +597,12 @@ class TestDocumentFontRegistry(unittest.TestCase):
class TestChapterFontRegistry(unittest.TestCase):
"""Test cases for Chapter font registry functionality."""
-
+
def setUp(self):
"""Set up test fixtures."""
self.doc = Document("Test Document", "en-US")
self.chapter = Chapter("Test Chapter", 1, parent=self.doc)
-
+
def test_chapter_uses_parent_font_registry(self):
"""Test that chapter uses parent document's font registry."""
# Create font through chapter - should delegate to parent
@@ -602,52 +611,52 @@ class TestChapterFontRegistry(unittest.TestCase):
colour=(255, 0, 0),
weight=FontWeight.BOLD
)
-
+
# Create same font through document - should return same object
font2 = self.doc.get_or_create_font(
font_size=14,
colour=(255, 0, 0),
weight=FontWeight.BOLD
)
-
+
# Should be the same font object
self.assertIs(font1, font2)
-
+
# Should be stored in document's registry, not chapter's
self.assertEqual(len(self.doc._fonts), 1)
self.assertEqual(len(self.chapter._fonts), 0)
-
+
def test_chapter_without_parent_manages_own_fonts(self):
"""Test that chapter without parent manages its own fonts."""
# Create chapter without parent
standalone_chapter = Chapter("Standalone Chapter", 1)
-
+
# Create font through chapter
font1 = standalone_chapter.get_or_create_font(
font_size=14,
colour=(255, 0, 0),
weight=FontWeight.BOLD
)
-
+
# Create same font again - should reuse
font2 = standalone_chapter.get_or_create_font(
font_size=14,
colour=(255, 0, 0),
weight=FontWeight.BOLD
)
-
+
# Should be the same font object
self.assertIs(font1, font2)
-
+
# Should be stored in chapter's own registry
self.assertEqual(len(standalone_chapter._fonts), 1)
-
+
def test_chapter_parent_assignment(self):
"""Test that chapter parent assignment works correctly."""
# Create chapter with parent
chapter_with_parent = Chapter("Chapter with Parent", 1, parent=self.doc)
self.assertEqual(chapter_with_parent._parent, self.doc)
-
+
# Create chapter without parent
chapter_without_parent = Chapter("Chapter without Parent", 1)
self.assertIsNone(chapter_without_parent._parent)
@@ -655,11 +664,11 @@ class TestChapterFontRegistry(unittest.TestCase):
class TestBookFontRegistry(unittest.TestCase):
"""Test cases for Book font registry functionality."""
-
+
def setUp(self):
"""Set up test fixtures."""
self.book = Book("Test Book", "Author Name", "en-US")
-
+
def test_book_inherits_document_font_registry(self):
"""Test that Book inherits Document's font registry functionality."""
# Create font through book
@@ -668,17 +677,17 @@ class TestBookFontRegistry(unittest.TestCase):
colour=(255, 0, 0),
weight=FontWeight.BOLD
)
-
+
# Create same font again - should reuse
font2 = self.book.get_or_create_font(
font_size=14,
colour=(255, 0, 0),
weight=FontWeight.BOLD
)
-
+
# Should be the same font object
self.assertIs(font1, font2)
-
+
# Should have one font in registry
self.assertEqual(len(self.book._fonts), 1)
diff --git a/tests/abstract/test_abstract_functional.py b/tests/abstract/test_abstract_functional.py
index 12cf039..af6b719 100644
--- a/tests/abstract/test_abstract_functional.py
+++ b/tests/abstract/test_abstract_functional.py
@@ -6,7 +6,7 @@ interactive functionality and user interface elements.
"""
import unittest
-from unittest.mock import Mock, patch
+from unittest.mock import Mock
from pyWebLayout.abstract.functional import (
Link, LinkType, Button, Form, FormField, FormFieldType
)
@@ -14,14 +14,14 @@ from pyWebLayout.abstract.functional import (
class TestLinkType(unittest.TestCase):
"""Test cases for LinkType enum."""
-
+
def test_link_types(self):
"""Test that all expected link types exist."""
expected_types = ['INTERNAL', 'EXTERNAL', 'API', 'FUNCTION']
-
+
for type_name in expected_types:
self.assertTrue(hasattr(LinkType, type_name))
-
+
# Test specific values
self.assertEqual(LinkType.INTERNAL.value, 1)
self.assertEqual(LinkType.EXTERNAL.value, 2)
@@ -31,21 +31,21 @@ class TestLinkType(unittest.TestCase):
class TestLink(unittest.TestCase):
"""Test cases for Link class."""
-
+
def setUp(self):
"""Set up test fixtures."""
self.mock_callback = Mock(return_value="callback_result")
-
+
def test_link_creation_minimal(self):
"""Test link creation with minimal parameters."""
link = Link("test-location")
-
+
self.assertEqual(link.location, "test-location")
self.assertEqual(link.link_type, LinkType.INTERNAL) # Default
self.assertEqual(link.params, {})
self.assertIsNone(link.title)
self.assertIsNone(link._callback)
-
+
def test_link_creation_full(self):
"""Test link creation with all parameters."""
params = {"param1": "value1", "param2": "value2"}
@@ -56,29 +56,29 @@ class TestLink(unittest.TestCase):
params=params,
title="Example Link"
)
-
+
self.assertEqual(link.location, "https://example.com")
self.assertEqual(link.link_type, LinkType.EXTERNAL)
self.assertEqual(link.params, params)
self.assertEqual(link.title, "Example Link")
self.assertEqual(link._callback, self.mock_callback)
-
+
def test_internal_link_execution(self):
"""Test executing internal links."""
link = Link("#section1", LinkType.INTERNAL)
result = link.execute()
-
+
# Internal links should return the location
self.assertEqual(result, "#section1")
-
+
def test_external_link_execution(self):
"""Test executing external links."""
link = Link("https://example.com", LinkType.EXTERNAL)
result = link.execute()
-
+
# External links should return the location
self.assertEqual(result, "https://example.com")
-
+
def test_api_link_execution(self):
"""Test executing API links with callback."""
params = {"action": "save", "id": 123}
@@ -92,9 +92,10 @@ class TestLink(unittest.TestCase):
result = link.execute()
# Should call callback with location, point (None when not provided), and params
- self.mock_callback.assert_called_once_with("/api/save", None, action="save", id=123)
+ self.mock_callback.assert_called_once_with(
+ "/api/save", None, action="save", id=123)
self.assertEqual(result, "callback_result")
-
+
def test_function_link_execution(self):
"""Test executing function links with callback."""
params = {"data": "test"}
@@ -110,23 +111,23 @@ class TestLink(unittest.TestCase):
# Should call callback with location, point (None when not provided), and params
self.mock_callback.assert_called_once_with("save_document", None, data="test")
self.assertEqual(result, "callback_result")
-
+
def test_api_link_without_callback(self):
"""Test API link without callback returns location."""
link = Link("/api/endpoint", LinkType.API)
result = link.execute()
-
+
# Without callback, should return location
self.assertEqual(result, "/api/endpoint")
-
+
def test_function_link_without_callback(self):
"""Test function link without callback returns location."""
link = Link("function_name", LinkType.FUNCTION)
result = link.execute()
-
+
# Without callback, should return location
self.assertEqual(result, "function_name")
-
+
def test_link_properties(self):
"""Test link property access."""
params = {"key": "value"}
@@ -136,7 +137,7 @@ class TestLink(unittest.TestCase):
params=params,
title="Test Title"
)
-
+
# Test all property getters
self.assertEqual(link.location, "test")
self.assertEqual(link.link_type, LinkType.API)
@@ -146,20 +147,20 @@ class TestLink(unittest.TestCase):
class TestButton(unittest.TestCase):
"""Test cases for Button class."""
-
+
def setUp(self):
"""Set up test fixtures."""
self.mock_callback = Mock(return_value="button_clicked")
-
+
def test_button_creation_minimal(self):
"""Test button creation with minimal parameters."""
button = Button("Click Me", self.mock_callback)
-
+
self.assertEqual(button.label, "Click Me")
self.assertEqual(button._callback, self.mock_callback)
self.assertEqual(button.params, {})
self.assertTrue(button.enabled)
-
+
def test_button_creation_full(self):
"""Test button creation with all parameters."""
params = {"action": "submit", "form_id": "test_form"}
@@ -169,37 +170,37 @@ class TestButton(unittest.TestCase):
params=params,
enabled=False
)
-
+
self.assertEqual(button.label, "Submit")
self.assertEqual(button._callback, self.mock_callback)
self.assertEqual(button.params, params)
self.assertFalse(button.enabled)
-
+
def test_button_label_property(self):
"""Test button label getter and setter."""
button = Button("Original", self.mock_callback)
-
+
# Test getter
self.assertEqual(button.label, "Original")
-
+
# Test setter
button.label = "New Label"
self.assertEqual(button.label, "New Label")
-
+
def test_button_enabled_property(self):
"""Test button enabled getter and setter."""
button = Button("Test", self.mock_callback, enabled=True)
-
+
# Test initial state
self.assertTrue(button.enabled)
-
+
# Test setter
button.enabled = False
self.assertFalse(button.enabled)
-
+
button.enabled = True
self.assertTrue(button.enabled)
-
+
def test_button_execute_enabled(self):
"""Test executing enabled button."""
params = {"data": "test_data"}
@@ -210,40 +211,40 @@ class TestButton(unittest.TestCase):
# Should call callback with point (None when not provided) and params
self.mock_callback.assert_called_once_with(None, data="test_data")
self.assertEqual(result, "button_clicked")
-
+
def test_button_execute_disabled(self):
"""Test executing disabled button."""
button = Button("Test", self.mock_callback, enabled=False)
-
+
result = button.execute()
-
+
# Should not call callback and return None
self.mock_callback.assert_not_called()
self.assertIsNone(result)
-
+
def test_button_execute_no_callback(self):
"""Test executing button without callback."""
button = Button("Test", None, enabled=True)
-
+
result = button.execute()
-
+
# Should return None
self.assertIsNone(result)
class TestFormFieldType(unittest.TestCase):
"""Test cases for FormFieldType enum."""
-
+
def test_form_field_types(self):
"""Test that all expected form field types exist."""
expected_types = [
'TEXT', 'PASSWORD', 'CHECKBOX', 'RADIO', 'SELECT', 'TEXTAREA',
'NUMBER', 'DATE', 'TIME', 'EMAIL', 'URL', 'COLOR', 'RANGE', 'HIDDEN'
]
-
+
for type_name in expected_types:
self.assertTrue(hasattr(FormFieldType, type_name))
-
+
# Test some specific values
self.assertEqual(FormFieldType.TEXT.value, 1)
self.assertEqual(FormFieldType.PASSWORD.value, 2)
@@ -252,11 +253,11 @@ class TestFormFieldType(unittest.TestCase):
class TestFormField(unittest.TestCase):
"""Test cases for FormField class."""
-
+
def test_form_field_creation_minimal(self):
"""Test form field creation with minimal parameters."""
field = FormField("username", FormFieldType.TEXT)
-
+
self.assertEqual(field.name, "username")
self.assertEqual(field.field_type, FormFieldType.TEXT)
self.assertEqual(field.label, "username") # Default to name
@@ -264,7 +265,7 @@ class TestFormField(unittest.TestCase):
self.assertFalse(field.required)
self.assertEqual(field.options, [])
self.assertIsNone(field.form)
-
+
def test_form_field_creation_full(self):
"""Test form field creation with all parameters."""
options = [("value1", "Label 1"), ("value2", "Label 2")]
@@ -276,37 +277,37 @@ class TestFormField(unittest.TestCase):
required=True,
options=options
)
-
+
self.assertEqual(field.name, "country")
self.assertEqual(field.field_type, FormFieldType.SELECT)
self.assertEqual(field.label, "Country")
self.assertEqual(field.value, "value1")
self.assertTrue(field.required)
self.assertEqual(field.options, options)
-
+
def test_form_field_value_property(self):
"""Test form field value getter and setter."""
field = FormField("test", FormFieldType.TEXT, value="initial")
-
+
# Test getter
self.assertEqual(field.value, "initial")
-
+
# Test setter
field.value = "new_value"
self.assertEqual(field.value, "new_value")
-
+
def test_form_field_form_property(self):
"""Test form field form getter and setter."""
field = FormField("test", FormFieldType.TEXT)
mock_form = Mock()
-
+
# Initial state
self.assertIsNone(field.form)
-
+
# Test setter
field.form = mock_form
self.assertEqual(field.form, mock_form)
-
+
def test_form_field_properties(self):
"""Test all form field property getters."""
options = [("opt1", "Option 1")]
@@ -318,7 +319,7 @@ class TestFormField(unittest.TestCase):
required=True,
options=options
)
-
+
# Test all getters
self.assertEqual(field.name, "test_field")
self.assertEqual(field.field_type, FormFieldType.CHECKBOX)
@@ -330,20 +331,20 @@ class TestFormField(unittest.TestCase):
class TestForm(unittest.TestCase):
"""Test cases for Form class."""
-
+
def setUp(self):
"""Set up test fixtures."""
self.mock_callback = Mock(return_value="form_submitted")
-
+
def test_form_creation_minimal(self):
"""Test form creation with minimal parameters."""
form = Form("test_form")
-
+
self.assertEqual(form.form_id, "test_form")
self.assertIsNone(form.action)
self.assertIsNone(form._callback)
self.assertEqual(len(form._fields), 0)
-
+
def test_form_creation_full(self):
"""Test form creation with all parameters."""
form = Form(
@@ -351,145 +352,144 @@ class TestForm(unittest.TestCase):
action="/submit",
callback=self.mock_callback
)
-
+
self.assertEqual(form.form_id, "contact_form")
self.assertEqual(form.action, "/submit")
self.assertEqual(form._callback, self.mock_callback)
-
+
def test_form_field_management(self):
"""Test adding and retrieving form fields."""
form = Form("test_form")
-
+
# Create fields
field1 = FormField("username", FormFieldType.TEXT, value="john")
field2 = FormField("password", FormFieldType.PASSWORD, value="secret")
field3 = FormField("email", FormFieldType.EMAIL, value="john@example.com")
-
+
# Add fields
form.add_field(field1)
form.add_field(field2)
form.add_field(field3)
-
+
# Test that fields are stored correctly
self.assertEqual(len(form._fields), 3)
-
+
# Test field retrieval
self.assertEqual(form.get_field("username"), field1)
self.assertEqual(form.get_field("password"), field2)
self.assertEqual(form.get_field("email"), field3)
self.assertIsNone(form.get_field("nonexistent"))
-
+
# Test that fields have form reference
self.assertEqual(field1.form, form)
self.assertEqual(field2.form, form)
self.assertEqual(field3.form, form)
-
+
def test_form_get_values(self):
"""Test getting form values."""
form = Form("test_form")
-
+
# Add fields with values
form.add_field(FormField("name", FormFieldType.TEXT, value="John Doe"))
form.add_field(FormField("age", FormFieldType.NUMBER, value=30))
form.add_field(FormField("subscribe", FormFieldType.CHECKBOX, value=True))
-
+
# Get values
values = form.get_values()
-
+
expected = {
"name": "John Doe",
"age": 30,
"subscribe": True
}
-
+
self.assertEqual(values, expected)
-
+
def test_form_get_values_empty(self):
"""Test getting values from empty form."""
form = Form("empty_form")
values = form.get_values()
-
+
self.assertEqual(values, {})
-
+
def test_form_execute_with_callback(self):
"""Test executing form with callback."""
form = Form("test_form", callback=self.mock_callback)
-
+
# Add some fields
form.add_field(FormField("field1", FormFieldType.TEXT, value="value1"))
form.add_field(FormField("field2", FormFieldType.TEXT, value="value2"))
-
+
result = form.execute()
-
+
# Should call callback with form_id and values
expected_values = {"field1": "value1", "field2": "value2"}
self.mock_callback.assert_called_once_with("test_form", expected_values)
self.assertEqual(result, "form_submitted")
-
+
def test_form_execute_without_callback(self):
"""Test executing form without callback."""
form = Form("test_form")
-
+
# Add a field
form.add_field(FormField("test", FormFieldType.TEXT, value="test_value"))
-
+
result = form.execute()
-
+
# Should return the form values
expected = {"test": "test_value"}
self.assertEqual(result, expected)
-
+
def test_form_properties(self):
"""Test form property getters."""
form = Form("test_form", action="/submit")
-
+
self.assertEqual(form.form_id, "test_form")
self.assertEqual(form.action, "/submit")
class TestFormIntegration(unittest.TestCase):
"""Integration tests for form functionality."""
-
+
def test_complete_form_workflow(self):
"""Test a complete form creation and submission workflow."""
# Create form
form = Form("registration_form", action="/register")
-
+
# Add various field types
form.add_field(FormField(
- "username", FormFieldType.TEXT,
+ "username", FormFieldType.TEXT,
label="Username", required=True, value="testuser"
))
-
+
form.add_field(FormField(
"password", FormFieldType.PASSWORD,
label="Password", required=True, value="secret123"
))
-
+
form.add_field(FormField(
"email", FormFieldType.EMAIL,
label="Email", required=True, value="test@example.com"
))
-
- form.add_field(FormField(
- "country", FormFieldType.SELECT,
- label="Country", value="US",
- options=[("US", "United States"), ("CA", "Canada"), ("UK", "United Kingdom")]
- ))
-
+
+ form.add_field(
+ FormField(
+ "country", FormFieldType.SELECT, label="Country", value="US", options=[
+ ("US", "United States"), ("CA", "Canada"), ("UK", "United Kingdom")]))
+
form.add_field(FormField(
"newsletter", FormFieldType.CHECKBOX,
label="Subscribe to newsletter", value=True
))
-
+
# Test form state
self.assertEqual(len(form._fields), 5)
-
+
# Test individual field access
username_field = form.get_field("username")
self.assertEqual(username_field.value, "testuser")
self.assertTrue(username_field.required)
-
+
# Test getting all values
values = form.get_values()
expected = {
@@ -500,26 +500,26 @@ class TestFormIntegration(unittest.TestCase):
"newsletter": True
}
self.assertEqual(values, expected)
-
+
# Test form submission
result = form.execute()
self.assertEqual(result, expected)
-
+
def test_form_field_modification(self):
"""Test modifying form fields after creation."""
form = Form("test_form")
-
+
# Add field
field = FormField("test", FormFieldType.TEXT, value="initial")
form.add_field(field)
-
+
# Modify field value
field.value = "modified"
-
+
# Test that form reflects the change
values = form.get_values()
self.assertEqual(values["test"], "modified")
-
+
# Test getting the modified field
retrieved_field = form.get_field("test")
self.assertEqual(retrieved_field.value, "modified")
diff --git a/tests/abstract/test_abstract_inline.py b/tests/abstract/test_abstract_inline.py
index 690f1db..23a1f0e 100644
--- a/tests/abstract/test_abstract_inline.py
+++ b/tests/abstract/test_abstract_inline.py
@@ -6,64 +6,63 @@ and formatting within documents.
"""
import unittest
-from unittest.mock import Mock, patch, MagicMock
+from unittest.mock import Mock
from pyWebLayout.abstract.inline import Word, FormattedSpan, LineBreak
from pyWebLayout.style import Font
class TestWord(unittest.TestCase):
"""Test cases for Word class."""
-
+
def setUp(self):
"""Set up test fixtures."""
self.font = Font()
# Note: Font background is a tuple (255, 255, 255, 0) by default
# Note: Font language is set via constructor parameter (language - with typo)
-
+
def test_word_creation_minimal(self):
"""Test word creation with minimal parameters."""
word = Word("hello", self.font)
-
+
self.assertEqual(word.text, "hello")
self.assertEqual(word.style, self.font)
self.assertIsNone(word.previous)
self.assertIsNone(word.next)
- self.assertEqual(len(word.possible_hyphenation()),0)
-
+ self.assertEqual(len(word.possible_hyphenation()), 0)
def test_word_hyphenation(self):
"""Test word creation with minimal parameters."""
word = Word("amsterdam", self.font)
-
+
self.assertEqual(word.text, "amsterdam")
self.assertEqual(word.style, self.font)
self.assertIsNone(word.previous)
self.assertIsNone(word.next)
- self.assertEqual(len(word.possible_hyphenation()),3)
-
+ self.assertEqual(len(word.possible_hyphenation()), 3)
+
def test_word_creation_with_previous(self):
"""Test word creation with previous word reference."""
word1 = Word("first", self.font)
word2 = Word("second", self.font, previous=word1)
-
+
self.assertEqual(word2.previous, word1)
self.assertIsNone(word1.previous)
self.assertEqual(word1.next, word2)
self.assertIsNone(word2.next)
-
+
def test_word_creation_with_background_override(self):
"""Test word creation with background color override."""
word = Word("test", self.font, background="yellow")
-
+
self.assertEqual(word.background, "yellow")
# Original font background should be unchanged - it's a tuple
self.assertEqual(word.style.background, (255, 255, 255, 0))
-
+
def test_word_properties(self):
"""Test word property getters."""
word1 = Word("first", self.font)
word2 = Word("second", self.font, background="blue", previous=word1)
-
+
# Test all properties
self.assertEqual(word2.text, "second")
self.assertEqual(word2.style, self.font)
@@ -71,64 +70,61 @@ class TestWord(unittest.TestCase):
self.assertEqual(word2.previous, word1)
self.assertIsNone(word2.next)
-
def test_add_next_word(self):
"""Test linking words with add_next method."""
word1 = Word("first", self.font)
word2 = Word("second", self.font)
word3 = Word("third", self.font)
-
+
# Link words
word1.add_next(word2)
word2.add_next(word3)
-
+
# Test forward links
self.assertEqual(word1.next, word2)
self.assertEqual(word2.next, word3)
self.assertIsNone(word3.next)
-
+
# Backward links should remain as set in constructor
self.assertIsNone(word1.previous)
self.assertIsNone(word2.previous)
self.assertIsNone(word3.previous)
-
+
def test_word_chain(self):
"""Test creating a chain of linked words."""
word1 = Word("first", self.font)
word2 = Word("second", self.font, previous=word1)
word3 = Word("third", self.font, previous=word2)
-
-
+
# Test complete chain
self.assertIsNone(word1.previous)
self.assertEqual(word1.next, word2)
-
+
self.assertEqual(word2.previous, word1)
self.assertEqual(word2.next, word3)
-
+
self.assertEqual(word3.previous, word2)
self.assertIsNone(word3.next)
-
-
+
def test_word_create_and_add_to_with_style_override(self):
"""Test Word.create_and_add_to with explicit style parameter."""
# Create alternate font
alt_font = Font()
-
+
# Create mock container
mock_container = Mock()
mock_container.style = self.font
mock_container.add_word = Mock()
# Ensure _words doesn't interfere
del mock_container._words
-
+
# Create word with style override
word = Word.create_and_add_to("test", mock_container, style=alt_font)
-
+
# Test that word uses the override style, not container style
self.assertEqual(word.style, alt_font)
self.assertNotEqual(word.style, self.font)
-
+
def test_word_create_and_add_to_with_background_override(self):
"""Test Word.create_and_add_to with explicit background parameter."""
# Create mock container
@@ -138,13 +134,13 @@ class TestWord(unittest.TestCase):
mock_container.add_word = Mock()
# Ensure _words doesn't interfere
del mock_container._words
-
+
# Create word with background override
word = Word.create_and_add_to("test", mock_container, background="override_bg")
-
+
# Test that word uses the override background
self.assertEqual(word.background, "override_bg")
-
+
def test_word_create_and_add_to_inherit_container_background(self):
"""Test Word.create_and_add_to inheriting background from container."""
# Create mock container with background
@@ -154,31 +150,31 @@ class TestWord(unittest.TestCase):
mock_container.add_word = Mock()
# Ensure _words doesn't interfere
del mock_container._words
-
+
# Create word without background override
word = Word.create_and_add_to("test", mock_container)
-
+
# Test that word inherits container background
self.assertEqual(word.background, "container_bg")
-
+
def test_word_create_and_add_to_with_words_list_previous(self):
"""Test Word.create_and_add_to linking with previous word from _words list."""
# Create mock container with _words list
mock_container = Mock()
mock_container.style = self.font
mock_container.add_word = Mock()
-
+
# Create existing word and add to container's _words list
existing_word = Word("previous", self.font)
mock_container._words = [existing_word]
-
+
# Create new word
word = Word.create_and_add_to("current", mock_container)
-
+
# Test that words are linked
self.assertEqual(word.previous, existing_word)
self.assertEqual(existing_word.next, word)
-
+
def test_word_create_and_add_to_with_words_method_previous(self):
"""Test Word.create_and_add_to linking with previous word from words() method."""
# Create a simple container that implements words() method
@@ -186,52 +182,52 @@ class TestWord(unittest.TestCase):
def __init__(self, font):
self.style = font
self.existing_word = Word("previous", font)
-
+
def words(self):
yield ("span1", self.existing_word)
-
+
def add_word(self, word):
pass # Simple implementation
-
+
container = SimpleContainer(self.font)
-
+
# Create new word
word = Word.create_and_add_to("current", container)
-
+
# Test that words are linked
self.assertEqual(word.previous, container.existing_word)
self.assertEqual(container.existing_word.next, word)
-
+
def test_word_create_and_add_to_no_style_error(self):
"""Test Word.create_and_add_to raises error when container has no style."""
# Create container without style
class BadContainer:
def add_word(self, word):
pass
-
+
container = BadContainer()
-
+
# Test that AttributeError is raised
with self.assertRaises(AttributeError) as context:
Word.create_and_add_to("test", container)
-
+
self.assertIn("must have a 'style' property", str(context.exception))
-
+
def test_word_create_and_add_to_no_add_word_error(self):
"""Test Word.create_and_add_to raises error when container has no add_word method."""
# Create container without add_word
class BadContainer:
def __init__(self, font):
self.style = font
-
+
container = BadContainer(self.font)
-
+
# Test that AttributeError is raised
with self.assertRaises(AttributeError) as context:
Word.create_and_add_to("test", container)
-
+
self.assertIn("must have an 'add_word' method", str(context.exception))
-
+
def test_word_create_and_add_to_parameter_inspection_word_object(self):
"""Test Word.create_and_add_to with add_word method that expects Word object."""
# Create container with add_word method that has 'word' parameter name
@@ -239,110 +235,115 @@ class TestWord(unittest.TestCase):
def __init__(self, font):
self.style = font
self.added_words = []
-
+
def add_word(self, word): # Parameter named 'word' indicates it expects Word object
self.added_words.append(word)
-
+
container = WordObjectContainer(self.font)
-
+
# Create and add word
word = Word.create_and_add_to("test", container)
-
+
# Test that the Word object was passed to add_word
self.assertEqual(len(container.added_words), 1)
self.assertEqual(container.added_words[0], word)
self.assertIsInstance(container.added_words[0], Word)
-
+
def test_word_create_and_add_to_parameter_inspection_word_obj(self):
"""Test Word.create_and_add_to with add_word method that has 'word_obj' parameter."""
class WordObjContainer:
def __init__(self, font):
self.style = font
self.added_words = []
-
+
def add_word(self, word_obj): # Parameter named 'word_obj' indicates it expects Word object
self.added_words.append(word_obj)
-
+
container = WordObjContainer(self.font)
word = Word.create_and_add_to("test", container)
-
+
self.assertEqual(len(container.added_words), 1)
self.assertEqual(container.added_words[0], word)
-
- def test_word_create_and_add_to_parameter_inspection_word_object(self):
+
+ def test_word_create_and_add_to_parameter_inspection_word_object_param(self):
"""Test Word.create_and_add_to with add_word method that has 'word_object' parameter."""
class WordObjectContainer:
def __init__(self, font):
self.style = font
self.added_words = []
-
+
def add_word(self, word_object): # Parameter named 'word_object' indicates it expects Word object
self.added_words.append(word_object)
-
+
container = WordObjectContainer(self.font)
word = Word.create_and_add_to("test", container)
-
+
self.assertEqual(len(container.added_words), 1)
self.assertEqual(container.added_words[0], word)
-
- def test_word_create_and_add_to_parameter_inspection_text_fallback_with_words_list(self):
+
+ def test_word_create_and_add_to_parameter_inspection_text_fallback_with_words_list(
+ self):
"""Test Word.create_and_add_to with add_word that expects text but container has _words list."""
class TextExpectingContainer:
def __init__(self, font):
self.style = font
self._words = [] # Has _words list
self.add_word_calls = []
-
+
def add_word(self, text): # Parameter named 'text' suggests it expects string
# This simulates FormattedSpan.add_word behavior
self.add_word_calls.append(text)
# In real FormattedSpan, this would create a Word internally
-
+
container = TextExpectingContainer(self.font)
word = Word.create_and_add_to("test", container)
-
+
# Word should be added to _words list directly, not via add_word
self.assertEqual(len(container._words), 1)
self.assertEqual(container._words[0], word)
# add_word should not have been called since it expects text
self.assertEqual(len(container.add_word_calls), 0)
-
- def test_word_create_and_add_to_parameter_inspection_fallback_without_words_list(self):
+
+ def test_word_create_and_add_to_parameter_inspection_fallback_without_words_list(
+ self):
"""Test Word.create_and_add_to fallback when container doesn't have _words list."""
class TextExpectingContainer:
def __init__(self, font):
self.style = font
# No _words list
self.added_words = []
-
+
def add_word(self, text): # Parameter suggests text but we'll pass Word as fallback
self.added_words.append(text)
-
+
container = TextExpectingContainer(self.font)
word = Word.create_and_add_to("test", container)
-
+
# Should fallback to calling add_word with Word object
self.assertEqual(len(container.added_words), 1)
self.assertEqual(container.added_words[0], word)
-
+
def test_word_create_and_add_to_no_parameters_edge_case(self):
"""Test Word.create_and_add_to with add_word method that has no parameters."""
class NoParamsContainer:
def __init__(self, font):
self.style = font
self.add_word_called = False
-
+
def add_word(self): # No parameters - edge case
self.add_word_called = True
-
+
container = NoParamsContainer(self.font)
-
- # The current implementation will fail when calling add_word(word) with a no-parameter method
+
+ # The current implementation will fail when calling add_word(word) with a
+ # no-parameter method
with self.assertRaises(TypeError) as context:
Word.create_and_add_to("test", container)
-
- self.assertIn("takes 1 positional argument but 2 were given", str(context.exception))
-
+
+ self.assertIn(
+ "takes 1 positional argument but 2 were given", str(
+ context.exception))
+
def test_word_create_and_add_to_linking_behavior_with_existing_words(self):
"""Test Word.create_and_add_to properly links with existing words in container."""
# Create container with existing words
@@ -353,22 +354,22 @@ class TestWord(unittest.TestCase):
# Add an existing word
existing_word = Word("existing", font)
self._words.append(existing_word)
-
+
def add_word(self, word):
self._words.append(word)
-
+
container = ContainerWithWords(self.font)
existing_word = container._words[0]
-
+
# Create new word
new_word = Word.create_and_add_to("new", container)
-
+
# Test linking
self.assertEqual(new_word.previous, existing_word)
self.assertEqual(existing_word.next, new_word)
self.assertEqual(len(container._words), 2)
self.assertEqual(container._words[1], new_word)
-
+
def test_word_create_and_add_to_linking_behavior_with_words_method(self):
"""Test Word.create_and_add_to properly links with words from container.words() method."""
class ContainerWithWordsMethod:
@@ -378,199 +379,199 @@ class TestWord(unittest.TestCase):
self.existing_word2 = Word("second", font)
self.existing_word1.add_next(self.existing_word2)
self.added_words = []
-
+
def words(self):
yield ("span1", self.existing_word1)
yield ("span2", self.existing_word2)
-
+
def add_word(self, word):
self.added_words.append(word)
-
+
container = ContainerWithWordsMethod(self.font)
-
+
# Create new word
new_word = Word.create_and_add_to("third", container)
-
+
# Should link to the last word returned by words() method
self.assertEqual(new_word.previous, container.existing_word2)
self.assertEqual(container.existing_word2.next, new_word)
-
+
def test_word_create_and_add_to_linking_behavior_empty_words_method(self):
"""Test Word.create_and_add_to with empty words() method."""
class EmptyWordsContainer:
def __init__(self, font):
self.style = font
-
+
def words(self):
# Empty iterator
return iter([])
-
+
def add_word(self, word):
pass
-
+
container = EmptyWordsContainer(self.font)
-
+
# Create word
word = Word.create_and_add_to("test", container)
-
+
# Should have no previous word
self.assertIsNone(word.previous)
-
+
def test_word_create_and_add_to_linking_behavior_words_method_exception(self):
"""Test Word.create_and_add_to with words() method that raises exception."""
class ExceptionWordsContainer:
def __init__(self, font):
self.style = font
-
+
def words(self):
raise TypeError("Error in words method")
-
+
def add_word(self, word):
pass
-
+
container = ExceptionWordsContainer(self.font)
-
+
# Create word - should handle exception gracefully
word = Word.create_and_add_to("test", container)
-
+
# Should have no previous word due to exception
self.assertIsNone(word.previous)
class TestFormattedSpan(unittest.TestCase):
"""Test cases for FormattedSpan class."""
-
+
def setUp(self):
"""Set up test fixtures."""
self.font = Font()
# Font background is a tuple, not a string
-
+
def test_formatted_span_creation_minimal(self):
"""Test formatted span creation with minimal parameters."""
span = FormattedSpan(self.font)
-
+
self.assertEqual(span.style, self.font)
self.assertEqual(span.background, self.font.background)
self.assertEqual(len(span.words), 0)
-
+
def test_formatted_span_creation_with_background(self):
"""Test formatted span creation with background override."""
span = FormattedSpan(self.font, background="yellow")
-
+
self.assertEqual(span.style, self.font)
self.assertEqual(span.background, "yellow")
self.assertNotEqual(span.background, self.font.background)
-
+
def test_formatted_span_properties(self):
"""Test formatted span property getters."""
span = FormattedSpan(self.font, background="blue")
-
+
self.assertEqual(span.style, self.font)
self.assertEqual(span.background, "blue")
self.assertIsInstance(span.words, list)
self.assertEqual(len(span.words), 0)
-
+
def test_add_single_word(self):
"""Test adding a single word to formatted span."""
span = FormattedSpan(self.font)
-
+
word = span.add_word("hello")
-
+
# Test returned word
self.assertIsInstance(word, Word)
self.assertEqual(word.text, "hello")
self.assertEqual(word.style, self.font)
self.assertEqual(word.background, self.font.background)
self.assertIsNone(word.previous)
-
+
# Test span state
self.assertEqual(len(span.words), 1)
self.assertEqual(span.words[0], word)
-
+
def test_add_multiple_words(self):
"""Test adding multiple words to formatted span."""
span = FormattedSpan(self.font)
-
+
word1 = span.add_word("first")
word2 = span.add_word("second")
word3 = span.add_word("third")
-
+
# Test span contains all words
self.assertEqual(len(span.words), 3)
self.assertEqual(span.words[0], word1)
self.assertEqual(span.words[1], word2)
self.assertEqual(span.words[2], word3)
-
+
# Test word linking
self.assertIsNone(word1.previous)
self.assertEqual(word1.next, word2)
-
+
self.assertEqual(word2.previous, word1)
self.assertEqual(word2.next, word3)
-
+
self.assertEqual(word3.previous, word2)
self.assertIsNone(word3.next)
-
+
def test_add_word_with_background_override(self):
"""Test that added words inherit span background."""
span = FormattedSpan(self.font, background="red")
-
+
word = span.add_word("test")
-
+
# Word should inherit span's background
self.assertEqual(word.background, "red")
self.assertEqual(word.style, self.font)
-
+
def test_word_style_consistency(self):
"""Test that all words in span have consistent style."""
span = FormattedSpan(self.font, background="green")
-
+
words = []
for text in ["this", "is", "a", "test"]:
words.append(span.add_word(text))
-
+
# All words should have same style and background
for word in words:
self.assertEqual(word.style, self.font)
self.assertEqual(word.background, "green")
-
+
def test_word_chain_integrity(self):
"""Test that word chain is properly maintained."""
span = FormattedSpan(self.font)
-
+
words = []
for i in range(5):
words.append(span.add_word(f"word{i}"))
-
+
# Test complete chain
for i in range(5):
word = words[i]
-
+
# Test previous link
if i == 0:
self.assertIsNone(word.previous)
else:
- self.assertEqual(word.previous, words[i-1])
-
+ self.assertEqual(word.previous, words[i - 1])
+
# Test next link
if i == 4:
self.assertIsNone(word.next)
else:
- self.assertEqual(word.next, words[i+1])
-
+ self.assertEqual(word.next, words[i + 1])
+
def test_empty_span_operations(self):
"""Test operations on empty formatted span."""
span = FormattedSpan(self.font)
-
+
# Test empty state
self.assertEqual(len(span.words), 0)
self.assertEqual(span.words, [])
-
+
# Add first word
first_word = span.add_word("first")
self.assertIsNone(first_word.previous)
self.assertIsNone(first_word.next)
-
+
def test_formatted_span_create_and_add_to_with_container_style(self):
"""Test FormattedSpan.create_and_add_to with container that has style property."""
# Create mock container with style and add_span method
@@ -579,35 +580,35 @@ class TestFormattedSpan(unittest.TestCase):
mock_container.add_span = Mock()
# Remove background so it inherits from font
del mock_container.background
-
+
# Create and add span
span = FormattedSpan.create_and_add_to(mock_container)
-
+
# Test that span was created with correct properties
self.assertIsInstance(span, FormattedSpan)
self.assertEqual(span.style, self.font)
self.assertEqual(span.background, self.font.background)
-
+
# Test that add_span was called
mock_container.add_span.assert_called_once_with(span)
-
+
def test_formatted_span_create_and_add_to_with_style_override(self):
"""Test FormattedSpan.create_and_add_to with explicit style parameter."""
# Create alternate font
alt_font = Font()
-
+
# Create mock container
mock_container = Mock()
mock_container.style = self.font
mock_container.add_span = Mock()
-
+
# Create span with style override
span = FormattedSpan.create_and_add_to(mock_container, style=alt_font)
-
+
# Test that span uses the override style, not container style
self.assertEqual(span.style, alt_font)
self.assertNotEqual(span.style, self.font)
-
+
def test_formatted_span_create_and_add_to_with_background_override(self):
"""Test FormattedSpan.create_and_add_to with explicit background parameter."""
# Create mock container
@@ -615,13 +616,13 @@ class TestFormattedSpan(unittest.TestCase):
mock_container.style = self.font
mock_container.background = "container_bg"
mock_container.add_span = Mock()
-
+
# Create span with background override
span = FormattedSpan.create_and_add_to(mock_container, background="override_bg")
-
+
# Test that span uses the override background
self.assertEqual(span.background, "override_bg")
-
+
def test_formatted_span_create_and_add_to_inherit_container_background(self):
"""Test FormattedSpan.create_and_add_to inheriting background from container."""
# Create mock container with background
@@ -629,124 +630,124 @@ class TestFormattedSpan(unittest.TestCase):
mock_container.style = self.font
mock_container.background = "container_bg"
mock_container.add_span = Mock()
-
+
# Create span without background override
span = FormattedSpan.create_and_add_to(mock_container)
-
+
# Test that span inherits container background
self.assertEqual(span.background, "container_bg")
-
+
def test_formatted_span_create_and_add_to_no_style_error(self):
"""Test FormattedSpan.create_and_add_to raises error when container has no style."""
# Create mock container without style
mock_container = Mock()
del mock_container.style
mock_container.add_span = Mock()
-
+
# Test that AttributeError is raised
with self.assertRaises(AttributeError) as context:
FormattedSpan.create_and_add_to(mock_container)
-
+
self.assertIn("must have a 'style' property", str(context.exception))
-
+
def test_formatted_span_create_and_add_to_no_add_span_error(self):
"""Test FormattedSpan.create_and_add_to raises error when container has no add_span method."""
# Create mock container without add_span
mock_container = Mock()
mock_container.style = self.font
del mock_container.add_span
-
+
# Test that AttributeError is raised
with self.assertRaises(AttributeError) as context:
FormattedSpan.create_and_add_to(mock_container)
-
+
self.assertIn("must have an 'add_span' method", str(context.exception))
class TestWordFormattedSpanIntegration(unittest.TestCase):
"""Integration tests between Word and FormattedSpan classes."""
-
+
def setUp(self):
"""Set up test fixtures."""
self.font = Font()
# Font background is a tuple, not a string
-
+
def test_sentence_creation(self):
"""Test creating a complete sentence with formatted span."""
span = FormattedSpan(self.font)
-
+
sentence_words = ["The", "quick", "brown", "fox", "jumps"]
words = []
-
+
for word_text in sentence_words:
words.append(span.add_word(word_text))
-
+
# Test sentence structure
self.assertEqual(len(span.words), 5)
-
+
# Test word content
for i, expected_text in enumerate(sentence_words):
self.assertEqual(words[i].text, expected_text)
-
+
# Test linking
for i in range(5):
if i > 0:
- self.assertEqual(words[i].previous, words[i-1])
+ self.assertEqual(words[i].previous, words[i - 1])
if i < 4:
- self.assertEqual(words[i].next, words[i+1])
+ self.assertEqual(words[i].next, words[i + 1])
def test_multiple_spans_same_style(self):
"""Test creating multiple spans with the same style."""
font = Font()
-
+
span1 = FormattedSpan(font)
span2 = FormattedSpan(font)
-
+
# Add words to both spans
span1_words = [span1.add_word("First"), span1.add_word("span")]
span2_words = [span2.add_word("Second"), span2.add_word("span")]
-
+
# Test that spans are independent
self.assertEqual(len(span1.words), 2)
self.assertEqual(len(span2.words), 2)
-
+
# Test that words in different spans are not linked
self.assertIsNone(span1_words[1].next)
self.assertIsNone(span2_words[0].previous)
-
+
# But words within spans are linked
self.assertEqual(span1_words[0].next, span1_words[1])
self.assertEqual(span2_words[1].previous, span2_words[0])
-
+
def test_span_style_inheritance(self):
"""Test that words properly inherit span styling."""
font = Font()
# Font background is a tuple (255, 255, 255, 0)
-
+
# Test with span background override
span = FormattedSpan(font, background="lightgreen")
-
+
word1 = span.add_word("styled")
word2 = span.add_word("text")
-
+
# Both words should have span's background, not font's
self.assertEqual(word1.background, "lightgreen")
self.assertEqual(word2.background, "lightgreen")
-
+
# But they should have font's other properties
self.assertEqual(word1.style, font)
self.assertEqual(word2.style, font)
-
+
def test_word_modification_after_creation(self):
"""Test modifying words after they've been added to span."""
span = FormattedSpan(self.font)
-
+
word = span.add_word("original")
-
+
# Verify initial state
self.assertEqual(word.text, "original")
self.assertEqual(len(span.words), 1)
-
+
# Words are immutable by design (no setter for text property)
# But we can test that the reference is maintained
self.assertEqual(span.words[0], word)
@@ -755,70 +756,70 @@ class TestWordFormattedSpanIntegration(unittest.TestCase):
class TestLineBreak(unittest.TestCase):
"""Test cases for LineBreak class."""
-
+
def test_line_break_creation(self):
"""Test line break creation."""
line_break = LineBreak()
-
+
# Test initial state
self.assertIsNotNone(line_break.block_type)
self.assertIsNone(line_break.parent)
-
+
def test_line_break_block_type(self):
"""Test line break block type property."""
line_break = LineBreak()
-
+
# Import BlockType to verify the correct type
from pyWebLayout.abstract.block import BlockType
self.assertEqual(line_break.block_type, BlockType.LINE_BREAK)
-
+
def test_line_break_parent_property(self):
"""Test line break parent property getter and setter."""
line_break = LineBreak()
-
+
# Test initial state
self.assertIsNone(line_break.parent)
-
+
# Test setter
mock_parent = Mock()
line_break.parent = mock_parent
self.assertEqual(line_break.parent, mock_parent)
-
+
# Test setting to None
line_break.parent = None
self.assertIsNone(line_break.parent)
-
+
def test_line_break_create_and_add_to_with_add_line_break(self):
"""Test LineBreak.create_and_add_to with container that has add_line_break method."""
# Create mock container with add_line_break method
mock_container = Mock()
mock_container.add_line_break = Mock()
-
+
# Create and add line break
line_break = LineBreak.create_and_add_to(mock_container)
-
+
# Test that line break was created
self.assertIsInstance(line_break, LineBreak)
-
+
# Test that add_line_break was called
mock_container.add_line_break.assert_called_once_with(line_break)
-
+
def test_line_break_create_and_add_to_with_add_element(self):
"""Test LineBreak.create_and_add_to with container that has add_element method."""
# Create mock container without add_line_break but with add_element
mock_container = Mock()
del mock_container.add_line_break # Ensure no add_line_break method
mock_container.add_element = Mock()
-
+
# Create and add line break
line_break = LineBreak.create_and_add_to(mock_container)
-
+
# Test that line break was created
self.assertIsInstance(line_break, LineBreak)
-
+
# Test that add_element was called
mock_container.add_element.assert_called_once_with(line_break)
-
+
def test_line_break_create_and_add_to_with_add_word(self):
"""Test LineBreak.create_and_add_to with container that has add_word method."""
# Create mock container with only add_word method
@@ -826,16 +827,16 @@ class TestLineBreak(unittest.TestCase):
del mock_container.add_line_break # Ensure no add_line_break method
del mock_container.add_element # Ensure no add_element method
mock_container.add_word = Mock()
-
+
# Create and add line break
line_break = LineBreak.create_and_add_to(mock_container)
-
+
# Test that line break was created
self.assertIsInstance(line_break, LineBreak)
-
+
# Test that add_word was called
mock_container.add_word.assert_called_once_with(line_break)
-
+
def test_line_break_create_and_add_to_fallback(self):
"""Test LineBreak.create_and_add_to fallback behavior when no add methods available."""
# Create mock container without any add methods
@@ -843,13 +844,13 @@ class TestLineBreak(unittest.TestCase):
del mock_container.add_line_break
del mock_container.add_element
del mock_container.add_word
-
+
# Create and add line break
line_break = LineBreak.create_and_add_to(mock_container)
-
+
# Test that line break was created
self.assertIsInstance(line_break, LineBreak)
-
+
# Test that parent was set manually
self.assertEqual(line_break.parent, mock_container)
diff --git a/tests/abstract/test_document_mixins.py b/tests/abstract/test_document_mixins.py
index 90a0623..1a38a9e 100644
--- a/tests/abstract/test_document_mixins.py
+++ b/tests/abstract/test_document_mixins.py
@@ -34,7 +34,9 @@ class TestChapterFontRegistry(FontRegistryTestMixin, unittest.TestCase):
return Chapter("Test Chapter", level=1)
-class TestChapterFontRegistryParentDelegation(FontRegistryParentDelegationTestMixin, unittest.TestCase):
+class TestChapterFontRegistryParentDelegation(
+ FontRegistryParentDelegationTestMixin,
+ unittest.TestCase):
"""Test FontRegistry parent delegation for Chapter - simplified with mixin."""
def create_parent(self):
diff --git a/tests/abstract/test_linked_elements.py b/tests/abstract/test_linked_elements.py
index b843165..3b42b68 100644
--- a/tests/abstract/test_linked_elements.py
+++ b/tests/abstract/test_linked_elements.py
@@ -3,20 +3,20 @@ Unit tests for LinkedWord and LinkedImage classes.
"""
import unittest
-from pyWebLayout.abstract.inline import Word, LinkedWord
-from pyWebLayout.abstract.block import Image, LinkedImage
+from pyWebLayout.abstract.inline import LinkedWord
+from pyWebLayout.abstract.block import LinkedImage
from pyWebLayout.abstract.functional import LinkType
from pyWebLayout.style import Font
class TestLinkedWord(unittest.TestCase):
"""Test cases for LinkedWord class."""
-
+
def setUp(self):
"""Set up test fixtures."""
self.font = Font(font_size=16)
self.location = "https://example.com"
-
+
def test_linked_word_creation(self):
"""Test creating a LinkedWord."""
linked_word = LinkedWord(
@@ -25,12 +25,12 @@ class TestLinkedWord(unittest.TestCase):
location=self.location,
link_type=LinkType.EXTERNAL
)
-
+
self.assertEqual(linked_word.text, "example")
self.assertEqual(linked_word.location, self.location)
self.assertEqual(linked_word.link_type, LinkType.EXTERNAL)
self.assertIsNone(linked_word.link_callback)
-
+
def test_linked_word_inherits_from_word(self):
"""Test that LinkedWord inherits Word properties."""
linked_word = LinkedWord(
@@ -38,21 +38,21 @@ class TestLinkedWord(unittest.TestCase):
style=self.font,
location=self.location
)
-
+
# Should have Word properties
self.assertEqual(linked_word.text, "test")
self.assertEqual(linked_word.style, self.font)
self.assertIsNone(linked_word.previous)
self.assertIsNone(linked_word.next)
-
+
def test_linked_word_with_callback(self):
"""Test LinkedWord with a callback function."""
callback_called = []
-
+
def test_callback(location, **params):
callback_called.append((location, params))
return "navigated"
-
+
linked_word = LinkedWord(
text="click",
style=self.font,
@@ -61,15 +61,15 @@ class TestLinkedWord(unittest.TestCase):
callback=test_callback,
params={"source": "test"}
)
-
- result = linked_word.execute_link()
-
+
+ linked_word.execute_link()
+
self.assertEqual(len(callback_called), 1)
self.assertEqual(callback_called[0][0], self.location)
self.assertIn("text", callback_called[0][1])
self.assertEqual(callback_called[0][1]["text"], "click")
self.assertEqual(callback_called[0][1]["source"], "test")
-
+
def test_linked_word_execute_external_link(self):
"""Test executing an external link returns the location."""
linked_word = LinkedWord(
@@ -78,10 +78,10 @@ class TestLinkedWord(unittest.TestCase):
location=self.location,
link_type=LinkType.EXTERNAL
)
-
+
result = linked_word.execute_link()
self.assertEqual(result, self.location)
-
+
def test_linked_word_with_title(self):
"""Test LinkedWord with title/tooltip."""
linked_word = LinkedWord(
@@ -90,9 +90,9 @@ class TestLinkedWord(unittest.TestCase):
location=self.location,
title="Click to visit example.com"
)
-
+
self.assertEqual(linked_word.link_title, "Click to visit example.com")
-
+
def test_linked_word_chain(self):
"""Test chaining multiple LinkedWords."""
word1 = LinkedWord(
@@ -100,14 +100,14 @@ class TestLinkedWord(unittest.TestCase):
style=self.font,
location=self.location
)
-
+
word2 = LinkedWord(
text="here",
style=self.font,
location=self.location,
previous=word1
)
-
+
# Check chain
self.assertEqual(word1.next, word2)
self.assertEqual(word2.previous, word1)
@@ -115,13 +115,13 @@ class TestLinkedWord(unittest.TestCase):
class TestLinkedImage(unittest.TestCase):
"""Test cases for LinkedImage class."""
-
+
def setUp(self):
"""Set up test fixtures."""
self.source = "logo.png"
self.alt_text = "Company Logo"
self.location = "https://example.com/home"
-
+
def test_linked_image_creation(self):
"""Test creating a LinkedImage."""
linked_image = LinkedImage(
@@ -132,14 +132,14 @@ class TestLinkedImage(unittest.TestCase):
height=50,
link_type=LinkType.EXTERNAL
)
-
+
self.assertEqual(linked_image.source, self.source)
self.assertEqual(linked_image.alt_text, self.alt_text)
self.assertEqual(linked_image.location, self.location)
self.assertEqual(linked_image.width, 100)
self.assertEqual(linked_image.height, 50)
self.assertEqual(linked_image.link_type, LinkType.EXTERNAL)
-
+
def test_linked_image_inherits_from_image(self):
"""Test that LinkedImage inherits Image properties."""
linked_image = LinkedImage(
@@ -147,20 +147,20 @@ class TestLinkedImage(unittest.TestCase):
alt_text=self.alt_text,
location=self.location
)
-
+
# Should have Image properties and methods
self.assertEqual(linked_image.source, self.source)
self.assertEqual(linked_image.alt_text, self.alt_text)
self.assertIsNotNone(linked_image.get_dimensions)
-
+
def test_linked_image_with_callback(self):
"""Test LinkedImage with a callback function."""
callback_called = []
-
+
def image_callback(location, **params):
callback_called.append((location, params))
return "image_clicked"
-
+
linked_image = LinkedImage(
source=self.source,
alt_text=self.alt_text,
@@ -168,15 +168,15 @@ class TestLinkedImage(unittest.TestCase):
link_type=LinkType.FUNCTION,
callback=image_callback
)
-
- result = linked_image.execute_link()
-
+
+ linked_image.execute_link()
+
self.assertEqual(len(callback_called), 1)
self.assertEqual(callback_called[0][0], self.location)
self.assertIn("alt_text", callback_called[0][1])
self.assertEqual(callback_called[0][1]["alt_text"], self.alt_text)
self.assertIn("source", callback_called[0][1])
-
+
def test_linked_image_execute_internal_link(self):
"""Test executing an internal link returns the location."""
linked_image = LinkedImage(
@@ -185,7 +185,7 @@ class TestLinkedImage(unittest.TestCase):
location="#section2",
link_type=LinkType.INTERNAL
)
-
+
result = linked_image.execute_link()
self.assertEqual(result, "#section2")
diff --git a/tests/concrete/test_alignment_handlers.py b/tests/concrete/test_alignment_handlers.py
index 75be145..c11770a 100644
--- a/tests/concrete/test_alignment_handlers.py
+++ b/tests/concrete/test_alignment_handlers.py
@@ -5,22 +5,24 @@ Tests the various alignment handlers (Left, Center, Right, Justify) and their in
"""
import unittest
-import numpy as np
-from unittest.mock import Mock
-from pyWebLayout.concrete.text import Line, Text, LeftAlignmentHandler, CenterRightAlignmentHandler, JustifyAlignmentHandler
+from pyWebLayout.concrete.text import (
+ Line, Text, LeftAlignmentHandler, CenterRightAlignmentHandler, JustifyAlignmentHandler
+)
from pyWebLayout.style import Alignment
from pyWebLayout.style import Font
from pyWebLayout.abstract import Word
-from PIL import Image, ImageFont, ImageDraw
+from PIL import Image, ImageDraw
+
class TestAlignmentHandlers(unittest.TestCase):
"""Test cases for the alignment handler system"""
-
+
def setUp(self):
"""Set up test fixtures"""
self.font = Font()
- self.test_words = [Word(text, self.font) for text in ["This", "is", "a", "test", "sentence"]]
+ self.test_words = [Word(text, self.font)
+ for text in ["This", "is", "a", "test", "sentence"]]
self.line_width = 300
self.line_height = 30
self.spacing = (5, 20) # min_spacing, max_spacing
@@ -29,46 +31,78 @@ class TestAlignmentHandlers(unittest.TestCase):
# Create a real PIL image (canvas) for testing
self.canvas = Image.new('RGB', (800, 600), color='white')
-
+
# Create a real ImageDraw object
self.draw = ImageDraw.Draw(self.canvas)
-
+
# Create a real Font object
self.style = Font()
-
def test_left_alignment_handler_assignment(self):
"""Test that Line correctly assigns LeftAlignmentHandler for LEFT alignment"""
- left_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.LEFT)
-
+ left_line = Line(
+ self.spacing,
+ self.origin,
+ self.size,
+ self.draw,
+ font=self.style,
+ halign=Alignment.LEFT)
+
self.assertIsInstance(left_line._alignment_handler, LeftAlignmentHandler)
-
+
def test_center_alignment_handler_assignment(self):
"""Test that Line correctly assigns CenterRightAlignmentHandler for CENTER alignment"""
- center_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.CENTER)
-
- self.assertIsInstance(center_line._alignment_handler, CenterRightAlignmentHandler)
+ center_line = Line(
+ self.spacing,
+ self.origin,
+ self.size,
+ self.draw,
+ font=self.style,
+ halign=Alignment.CENTER)
+
+ self.assertIsInstance(
+ center_line._alignment_handler,
+ CenterRightAlignmentHandler)
# Check that it's configured for CENTER alignment
self.assertEqual(center_line._alignment_handler._alignment, Alignment.CENTER)
-
+
def test_right_alignment_handler_assignment(self):
"""Test that Line correctly assigns CenterRightAlignmentHandler for RIGHT alignment"""
- right_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.RIGHT)
-
- self.assertIsInstance(right_line._alignment_handler, CenterRightAlignmentHandler)
+ right_line = Line(
+ self.spacing,
+ self.origin,
+ self.size,
+ self.draw,
+ font=self.style,
+ halign=Alignment.RIGHT)
+
+ self.assertIsInstance(
+ right_line._alignment_handler,
+ CenterRightAlignmentHandler)
# Check that it's configured for RIGHT alignment
self.assertEqual(right_line._alignment_handler._alignment, Alignment.RIGHT)
-
+
def test_justify_alignment_handler_assignment(self):
"""Test that Line correctly assigns JustifyAlignmentHandler for JUSTIFY alignment"""
- justify_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.JUSTIFY)
-
+ justify_line = Line(
+ self.spacing,
+ self.origin,
+ self.size,
+ self.draw,
+ font=self.style,
+ halign=Alignment.JUSTIFY)
+
self.assertIsInstance(justify_line._alignment_handler, JustifyAlignmentHandler)
-
+
def test_left_alignment_word_addition(self):
"""Test adding words to a left-aligned line"""
- left_line = Line(self.spacing, self.origin, self.size, self.draw, halign=Alignment.LEFT)
-
+ left_line = Line(
+ self.spacing,
+ self.origin,
+ self.size,
+ self.draw,
+ halign=Alignment.LEFT)
+
# Add words until line is full or we run out
words_added = 0
for word in self.test_words:
@@ -78,15 +112,21 @@ class TestAlignmentHandlers(unittest.TestCase):
break
else:
words_added += 1
-
+
# Should have added at least some words
self.assertGreater(words_added, 0)
self.assertEqual(len(left_line.text_objects), words_added)
-
+
def test_center_alignment_word_addition(self):
"""Test adding words to a center-aligned line"""
- center_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.CENTER)
-
+ center_line = Line(
+ self.spacing,
+ self.origin,
+ self.size,
+ self.draw,
+ font=self.style,
+ halign=Alignment.CENTER)
+
# Add words until line is full or we run out
words_added = 0
for word in self.test_words:
@@ -96,15 +136,21 @@ class TestAlignmentHandlers(unittest.TestCase):
break
else:
words_added += 1
-
+
# Should have added at least some words
self.assertGreater(words_added, 0)
self.assertEqual(len(center_line.text_objects), words_added)
-
+
def test_right_alignment_word_addition(self):
"""Test adding words to a right-aligned line"""
- right_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.RIGHT)
-
+ right_line = Line(
+ self.spacing,
+ self.origin,
+ self.size,
+ self.draw,
+ font=self.style,
+ halign=Alignment.RIGHT)
+
# Add words until line is full or we run out
words_added = 0
for word in self.test_words:
@@ -114,15 +160,21 @@ class TestAlignmentHandlers(unittest.TestCase):
break
else:
words_added += 1
-
+
# Should have added at least some words
self.assertGreater(words_added, 0)
self.assertEqual(len(right_line.text_objects), words_added)
-
+
def test_justify_alignment_word_addition(self):
"""Test adding words to a justified line"""
- justify_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.JUSTIFY)
-
+ justify_line = Line(
+ self.spacing,
+ self.origin,
+ self.size,
+ self.draw,
+ font=self.style,
+ halign=Alignment.JUSTIFY)
+
# Add words until line is full or we run out
words_added = 0
for word in self.test_words:
@@ -132,16 +184,17 @@ class TestAlignmentHandlers(unittest.TestCase):
break
else:
words_added += 1
-
+
# Should have added at least some words
self.assertGreater(words_added, 0)
self.assertEqual(len(justify_line.text_objects), words_added)
-
+
def test_handler_spacing_and_position_calculations(self):
"""Test spacing and position calculations for different alignment handlers"""
# Create sample text objects
- text_objects = [Text(word, self.style, self.draw) for word in ["Hello", "World"]]
-
+ text_objects = [Text(word, self.style, self.draw)
+ for word in ["Hello", "World"]]
+
# Test each handler type
handlers = [
("Left", LeftAlignmentHandler()),
@@ -149,108 +202,132 @@ class TestAlignmentHandlers(unittest.TestCase):
("Right", CenterRightAlignmentHandler(Alignment.RIGHT)),
("Justify", JustifyAlignmentHandler())
]
-
+
for name, handler in handlers:
with self.subTest(handler=name):
spacing_calc, position, overflow = handler.calculate_spacing_and_position(
text_objects, self.line_width, self.spacing[0], self.spacing[1])
-
+
# Check that spacing is a valid number
self.assertIsInstance(spacing_calc, (int, float))
self.assertGreaterEqual(spacing_calc, 0)
-
+
# Check that position is a valid number
self.assertIsInstance(position, (int, float))
self.assertGreaterEqual(position, 0)
-
+
# Check that overflow is a boolean
self.assertIsInstance(overflow, bool)
-
+
# Position should be within line width (unless overflow)
if not overflow:
self.assertLessEqual(position, self.line_width)
-
+
def test_left_handler_spacing_calculation(self):
"""Test specific spacing calculation for left alignment"""
handler = LeftAlignmentHandler()
- text_objects = [Text(word, self.style, self.draw) for word in ["Hello", "World"]]
-
+ text_objects = [Text(word, self.style, self.draw)
+ for word in ["Hello", "World"]]
+
spacing_calc, position, overflow = handler.calculate_spacing_and_position(
text_objects, self.line_width, self.spacing[0], self.spacing[1])
-
+
# Left alignment should have position at 0
self.assertEqual(position, 0)
-
+
# Should not overflow with reasonable text
self.assertFalse(overflow)
-
+
def test_center_handler_spacing_calculation(self):
"""Test specific spacing calculation for center alignment"""
handler = CenterRightAlignmentHandler(Alignment.CENTER)
- text_objects = [Text(word, self.style, self.draw) for word in ["Hello", "World"]]
-
+ text_objects = [Text(word, self.style, self.draw)
+ for word in ["Hello", "World"]]
+
spacing_calc, position, overflow = handler.calculate_spacing_and_position(
text_objects, self.line_width, self.spacing[0], self.spacing[1])
-
+
# Center alignment should have position > 0 (centered) if no overflow
if not overflow:
self.assertGreaterEqual(position, 0)
-
+
def test_right_handler_spacing_calculation(self):
"""Test specific spacing calculation for right alignment"""
handler = CenterRightAlignmentHandler(Alignment.RIGHT)
- text_objects = [Text(word, self.style, self.draw) for word in ["Hello", "World"]]
-
+ text_objects = [Text(word, self.style, self.draw)
+ for word in ["Hello", "World"]]
+
spacing_calc, position, overflow = handler.calculate_spacing_and_position(
text_objects, self.line_width, self.spacing[0], self.spacing[1])
-
+
# Right alignment should have position >= 0
self.assertGreaterEqual(position, 0)
-
+
def test_justify_handler_spacing_calculation(self):
"""Test specific spacing calculation for justify alignment"""
handler = JustifyAlignmentHandler()
- text_objects = [Text(word, self.style, self.draw) for word in ["Hello", "World"]]
-
+ text_objects = [Text(word, self.style, self.draw)
+ for word in ["Hello", "World"]]
+
spacing_calc, position, overflow = handler.calculate_spacing_and_position(
text_objects, self.line_width, self.spacing[0], self.spacing[1])
-
+
# Justify alignment should have position at 0
self.assertEqual(position, 0)
-
+
# Check spacing is reasonable
self.assertGreaterEqual(spacing_calc, 0)
-
+
def test_empty_line_alignment_handlers(self):
"""Test alignment handlers with empty lines"""
- alignments = [Alignment.LEFT, Alignment.CENTER, Alignment.RIGHT, Alignment.JUSTIFY]
-
+ alignments = [
+ Alignment.LEFT,
+ Alignment.CENTER,
+ Alignment.RIGHT,
+ Alignment.JUSTIFY]
+
for alignment in alignments:
with self.subTest(alignment=alignment):
- line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=alignment)
-
+ line = Line(
+ self.spacing,
+ self.origin,
+ self.size,
+ self.draw,
+ font=self.style,
+ halign=alignment)
+
# Empty line should still have a handler
self.assertIsNotNone(line._alignment_handler)
-
+
# Should be able to render empty line
line.render()
-
+
def test_single_word_line_alignment(self):
"""Test alignment handlers with single word lines"""
- alignments = [Alignment.LEFT, Alignment.CENTER, Alignment.RIGHT, Alignment.JUSTIFY]
-
+ alignments = [
+ Alignment.LEFT,
+ Alignment.CENTER,
+ Alignment.RIGHT,
+ Alignment.JUSTIFY]
+
for alignment in alignments:
with self.subTest(alignment=alignment):
- line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=alignment)
-
+ line = Line(
+ self.spacing,
+ self.origin,
+ self.size,
+ self.draw,
+ font=self.style,
+ halign=alignment)
+
# Create a test word
test_word = Word("test", self.style)
-
+
# Add a single word
result, part = line.add_word(test_word)
self.assertTrue(result) # Should fit
self.assertIsNone(part) # No overflow part
-
+
# Should be able to render single word line
line.render()
self.assertEqual(len(line.text_objects), 1)
diff --git a/tests/concrete/test_concrete_box.py b/tests/concrete/test_concrete_box.py
index f8d8c8b..a3dcf05 100644
--- a/tests/concrete/test_concrete_box.py
+++ b/tests/concrete/test_concrete_box.py
@@ -6,7 +6,7 @@ Tests the Box class which handles basic box model rendering with alignment.
import unittest
import numpy as np
from PIL import Image
-from unittest.mock import Mock, patch
+from unittest.mock import Mock
from pyWebLayout.concrete.box import Box
from pyWebLayout.style import Alignment
@@ -14,17 +14,17 @@ from pyWebLayout.style import Alignment
class TestBox(unittest.TestCase):
"""Test cases for the Box class"""
-
+
def setUp(self):
"""Set up test fixtures"""
self.origin = (10, 20)
self.size = (100, 50)
self.callback = Mock()
-
+
def test_box_initialization_basic(self):
"""Test basic box initialization"""
box = Box(self.origin, self.size)
-
+
np.testing.assert_array_equal(box._origin, np.array([10, 20]))
np.testing.assert_array_equal(box._size, np.array([100, 50]))
np.testing.assert_array_equal(box._end, np.array([110, 70]))
@@ -33,66 +33,65 @@ class TestBox(unittest.TestCase):
self.assertIsNone(box._mode)
self.assertEqual(box._halign, Alignment.CENTER)
self.assertEqual(box._valign, Alignment.CENTER)
-
+
def test_box_initialization_with_callback(self):
"""Test box initialization with callback"""
box = Box(self.origin, self.size, callback=self.callback)
-
+
self.assertEqual(box._callback, self.callback)
-
+
def test_box_initialization_with_sheet(self):
"""Test box initialization with image sheet"""
sheet = Image.new('RGBA', (200, 100), (255, 255, 255, 255))
box = Box(self.origin, self.size, sheet=sheet)
-
+
self.assertEqual(box._sheet, sheet)
self.assertEqual(box._mode, 'RGBA')
-
+
def test_box_initialization_with_mode(self):
"""Test box initialization with explicit mode"""
box = Box(self.origin, self.size, mode='RGB')
-
+
self.assertEqual(box._mode, 'RGB')
-
+
def test_box_initialization_with_alignment(self):
"""Test box initialization with custom alignment"""
box = Box(self.origin, self.size, halign=Alignment.LEFT, valign=Alignment.TOP)
-
+
self.assertEqual(box._halign, Alignment.LEFT)
self.assertEqual(box._valign, Alignment.TOP)
-
+
def test_in_shape_point_inside(self):
"""Test in_shape method with point inside box"""
box = Box(self.origin, self.size)
-
+
# Test point inside
self.assertTrue(box.in_shape(np.array([50, 40])))
self.assertTrue(box.in_shape(np.array([10, 20]))) # Top-left corner
self.assertTrue(box.in_shape(np.array([109, 69]))) # Just inside bottom-right
-
+
def test_in_shape_point_outside(self):
"""Test in_shape method with point outside box"""
box = Box(self.origin, self.size)
-
+
# Test points outside
self.assertFalse(box.in_shape(np.array([5, 15]))) # Before origin
- self.assertFalse(box.in_shape(np.array([110, 70]))) # At end (exclusive)
- self.assertFalse(box.in_shape(np.array([150, 100]))) # Far outside
-
+ self.assertFalse(box.in_shape(np.array([110, 70]))) # At end (exclusive)
+ self.assertFalse(box.in_shape(np.array([150, 100]))) # Far outside
+
def test_in_shape_multiple_points(self):
"""Test in_shape method with array of points"""
box = Box(self.origin, self.size)
-
+
points = np.array([[50, 40], [5, 15], [109, 69], [110, 70]])
result = box.in_shape(points)
-
+
np.testing.assert_array_equal(result, [True, False, True, False])
-
def test_properties_access(self):
"""Test that properties can be accessed correctly"""
box = Box(self.origin, self.size, callback=self.callback)
-
+
# Test that origin property works (should be available via inheritance)
np.testing.assert_array_equal(box._origin, np.array([10, 20]))
np.testing.assert_array_equal(box._size, np.array([100, 50]))
diff --git a/tests/concrete/test_concrete_functional.py b/tests/concrete/test_concrete_functional.py
index 585c82d..0ef779b 100644
--- a/tests/concrete/test_concrete_functional.py
+++ b/tests/concrete/test_concrete_functional.py
@@ -5,23 +5,21 @@ Tests the LinkText, ButtonText, and FormFieldText classes.
import unittest
import numpy as np
-from PIL import Image, ImageDraw
-from unittest.mock import Mock, patch, MagicMock
+from unittest.mock import Mock, patch
from pyWebLayout.concrete.functional import (
- LinkText, ButtonText, FormFieldText,
+ LinkText, ButtonText, FormFieldText,
create_link_text, create_button_text, create_form_field_text
)
from pyWebLayout.abstract.functional import (
- Link, Button, Form, FormField, LinkType, FormFieldType
+ Link, Button, FormField, LinkType, FormFieldType
)
-from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration
-from pyWebLayout.style import Alignment
+from pyWebLayout.style import Font, TextDecoration
class TestLinkText(unittest.TestCase):
"""Test cases for the LinkText class"""
-
+
def setUp(self):
"""Set up test fixtures"""
self.font = Font(
@@ -30,86 +28,89 @@ class TestLinkText(unittest.TestCase):
colour=(0, 0, 0)
)
self.callback = Mock()
-
+
# Create different types of links
self.internal_link = Link("chapter1", LinkType.INTERNAL, self.callback)
- self.external_link = Link("https://example.com", LinkType.EXTERNAL, self.callback)
+ self.external_link = Link(
+ "https://example.com",
+ LinkType.EXTERNAL,
+ self.callback)
self.api_link = Link("/api/settings", LinkType.API, self.callback)
self.function_link = Link("toggle_theme", LinkType.FUNCTION, self.callback)
-
+
# Create a mock ImageDraw.Draw object
self.mock_draw = Mock()
-
+
def test_link_text_initialization_internal(self):
"""Test initialization of internal link text"""
link_text = "Go to Chapter 1"
renderable = LinkText(self.internal_link, link_text, self.font, self.mock_draw)
-
+
self.assertEqual(renderable._link, self.internal_link)
self.assertEqual(renderable.text, link_text)
self.assertFalse(renderable._hovered)
self.assertEqual(renderable._callback, self.internal_link.execute)
-
+
# Check that the font has underline decoration and blue color
self.assertEqual(renderable.style.decoration, TextDecoration.UNDERLINE)
self.assertEqual(renderable.style.colour, (0, 0, 200))
-
+
def test_link_text_initialization_external(self):
"""Test initialization of external link text"""
link_text = "Visit Example"
renderable = LinkText(self.external_link, link_text, self.font, self.mock_draw)
-
+
self.assertEqual(renderable._link, self.external_link)
# External links should have darker blue color
self.assertEqual(renderable.style.colour, (0, 0, 180))
-
+
def test_link_text_initialization_api(self):
"""Test initialization of API link text"""
link_text = "Settings"
renderable = LinkText(self.api_link, link_text, self.font, self.mock_draw)
-
+
self.assertEqual(renderable._link, self.api_link)
# API links should have red color
self.assertEqual(renderable.style.colour, (150, 0, 0))
-
+
def test_link_text_initialization_function(self):
"""Test initialization of function link text"""
link_text = "Toggle Theme"
renderable = LinkText(self.function_link, link_text, self.font, self.mock_draw)
-
+
self.assertEqual(renderable._link, self.function_link)
# Function links should have green color
self.assertEqual(renderable.style.colour, (0, 120, 0))
-
+
def test_link_property(self):
"""Test link property accessor"""
link_text = "Test Link"
renderable = LinkText(self.internal_link, link_text, self.font, self.mock_draw)
-
+
self.assertEqual(renderable.link, self.internal_link)
-
+
def test_set_hovered(self):
"""Test setting hover state"""
link_text = "Hover Test"
renderable = LinkText(self.internal_link, link_text, self.font, self.mock_draw)
-
+
self.assertFalse(renderable._hovered)
-
+
renderable.set_hovered(True)
self.assertTrue(renderable._hovered)
-
+
renderable.set_hovered(False)
self.assertFalse(renderable._hovered)
-
+
def test_render_normal_state(self):
"""Test rendering in normal state"""
link_text = "Test Link"
renderable = LinkText(self.internal_link, link_text, self.font, self.mock_draw)
-
+
# Mock the parent Text render method
with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render:
renderable.render()
-
+
# Parent render should be called
mock_parent_render.assert_called_once()
# Should not draw highlight when not hovered
@@ -120,21 +121,25 @@ class TestLinkText(unittest.TestCase):
link_text = "Test Link"
renderable = LinkText(self.internal_link, link_text, self.font, self.mock_draw)
renderable.set_origin(np.array([10, 20]))
-
- # Mock width property
+
+ # Mock width property
renderable._width = 80
-
+
# Point inside link
self.assertTrue(renderable.in_object((15, 25)))
-
+
# Point outside link
self.assertFalse(renderable.in_object((200, 200)))
-
+
def test_factory_function(self):
"""Test the create_link_text factory function"""
link_text = "Factory Test"
- renderable = create_link_text(self.internal_link, link_text, self.font, self.mock_draw)
-
+ renderable = create_link_text(
+ self.internal_link,
+ link_text,
+ self.font,
+ self.mock_draw)
+
self.assertIsInstance(renderable, LinkText)
self.assertEqual(renderable.text, link_text)
self.assertEqual(renderable.link, self.internal_link)
@@ -142,7 +147,7 @@ class TestLinkText(unittest.TestCase):
class TestButtonText(unittest.TestCase):
"""Test cases for the ButtonText class"""
-
+
def setUp(self):
"""Set up test fixtures"""
self.font = Font(
@@ -153,112 +158,114 @@ class TestButtonText(unittest.TestCase):
self.callback = Mock()
self.button = Button("Click Me", self.callback)
self.mock_draw = Mock()
-
+
def test_button_text_initialization(self):
"""Test basic button text initialization"""
renderable = ButtonText(self.button, self.font, self.mock_draw)
-
+
self.assertEqual(renderable._button, self.button)
self.assertEqual(renderable.text, "Click Me")
self.assertFalse(renderable._pressed)
self.assertFalse(renderable._hovered)
self.assertEqual(renderable._callback, self.button.execute)
self.assertEqual(renderable._padding, (4, 8, 4, 8))
-
+
def test_button_text_with_custom_padding(self):
"""Test button text initialization with custom padding"""
custom_padding = (8, 12, 8, 12)
-
+
renderable = ButtonText(
self.button, self.font, self.mock_draw,
padding=custom_padding
)
-
+
self.assertEqual(renderable._padding, custom_padding)
-
+
def test_button_property(self):
"""Test button property accessor"""
renderable = ButtonText(self.button, self.font, self.mock_draw)
-
+
self.assertEqual(renderable.button, self.button)
-
+
def test_set_pressed(self):
"""Test setting pressed state"""
renderable = ButtonText(self.button, self.font, self.mock_draw)
-
+
self.assertFalse(renderable._pressed)
-
+
renderable.set_pressed(True)
self.assertTrue(renderable._pressed)
-
+
renderable.set_pressed(False)
self.assertFalse(renderable._pressed)
-
+
def test_set_hovered(self):
"""Test setting hover state"""
renderable = ButtonText(self.button, self.font, self.mock_draw)
-
+
self.assertFalse(renderable._hovered)
-
+
renderable.set_hovered(True)
self.assertTrue(renderable._hovered)
-
+
renderable.set_hovered(False)
self.assertFalse(renderable._hovered)
-
+
def test_size_property(self):
"""Test size property includes padding"""
renderable = ButtonText(self.button, self.font, self.mock_draw)
-
+
# The size should be padded size, not just text size
# Since we handle mocks in __init__, use the padded values directly
expected_width = renderable._padded_width
expected_height = renderable._padded_height
-
- np.testing.assert_array_equal(renderable.size, np.array([expected_width, expected_height]))
-
+
+ np.testing.assert_array_equal(
+ renderable.size, np.array([expected_width, expected_height]))
+
def test_render_normal_state(self):
"""Test rendering in normal state"""
renderable = ButtonText(self.button, self.font, self.mock_draw)
-
+
# Mock the parent Text render method
with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render:
renderable.render()
-
+
# Should draw rounded rectangle for button background
self.mock_draw.rounded_rectangle.assert_called_once()
# Parent render should be called for text
mock_parent_render.assert_called_once()
-
+
def test_render_disabled_state(self):
"""Test rendering disabled button"""
disabled_button = Button("Disabled", self.callback, enabled=False)
renderable = ButtonText(disabled_button, self.font, self.mock_draw)
-
+
# Mock the parent Text render method
with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render:
renderable.render()
-
+
# Should still draw button background
self.mock_draw.rounded_rectangle.assert_called_once()
mock_parent_render.assert_called_once()
-
+
def test_in_object_with_padding(self):
"""Test in_object method considers padding"""
renderable = ButtonText(self.button, self.font, self.mock_draw)
renderable.set_origin(np.array([10, 20]))
-
+
# Point inside button (including padding)
self.assertTrue(renderable.in_object((15, 25)))
-
+
# Point outside button
self.assertFalse(renderable.in_object((200, 200)))
-
+
def test_factory_function(self):
"""Test the create_button_text factory function"""
custom_padding = (6, 10, 6, 10)
- renderable = create_button_text(self.button, self.font, self.mock_draw, custom_padding)
-
+ renderable = create_button_text(
+ self.button, self.font, self.mock_draw, custom_padding)
+
self.assertIsInstance(renderable, ButtonText)
self.assertEqual(renderable.text, "Click Me")
self.assertEqual(renderable.button, self.button)
@@ -267,7 +274,7 @@ class TestButtonText(unittest.TestCase):
class TestFormFieldText(unittest.TestCase):
"""Test cases for the FormFieldText class"""
-
+
def setUp(self):
"""Set up test fixtures"""
self.font = Font(
@@ -275,96 +282,102 @@ class TestFormFieldText(unittest.TestCase):
font_size=12,
colour=(0, 0, 0)
)
-
+
# Create different types of form fields
self.text_field = FormField("username", FormFieldType.TEXT, "Username")
self.password_field = FormField("password", FormFieldType.PASSWORD, "Password")
- self.textarea_field = FormField("description", FormFieldType.TEXTAREA, "Description")
+ self.textarea_field = FormField(
+ "description", FormFieldType.TEXTAREA, "Description")
self.select_field = FormField("country", FormFieldType.SELECT, "Country")
-
+
self.mock_draw = Mock()
-
+
def test_form_field_text_initialization(self):
"""Test initialization of form field text"""
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
-
+
self.assertEqual(renderable._field, self.text_field)
self.assertEqual(renderable.text, "Username")
self.assertFalse(renderable._focused)
self.assertEqual(renderable._field_height, 24)
-
+
def test_form_field_text_with_custom_height(self):
"""Test form field text with custom field height"""
custom_height = 40
- renderable = FormFieldText(self.text_field, self.font, self.mock_draw, custom_height)
-
+ renderable = FormFieldText(
+ self.text_field,
+ self.font,
+ self.mock_draw,
+ custom_height)
+
self.assertEqual(renderable._field_height, custom_height)
-
+
def test_field_property(self):
"""Test field property accessor"""
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
-
+
self.assertEqual(renderable.field, self.text_field)
-
+
def test_set_focused(self):
"""Test setting focus state"""
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
-
+
self.assertFalse(renderable._focused)
-
+
renderable.set_focused(True)
self.assertTrue(renderable._focused)
-
+
renderable.set_focused(False)
self.assertFalse(renderable._focused)
-
+
def test_size_includes_field_area(self):
"""Test size property includes field area"""
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
-
+
# Size should include label height + gap + field height
expected_height = renderable._style.font_size + 5 + renderable._field_height
expected_width = renderable._field_width # Use the calculated field width
-
- np.testing.assert_array_equal(renderable.size, np.array([expected_width, expected_height]))
-
+
+ np.testing.assert_array_equal(
+ renderable.size, np.array([expected_width, expected_height]))
+
def test_render_text_field(self):
"""Test rendering text field"""
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
-
+
# Mock the parent Text render method
with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render:
renderable.render()
-
+
# Should render label
mock_parent_render.assert_called_once()
# Should draw field background rectangle
self.mock_draw.rectangle.assert_called_once()
-
+
def test_render_field_with_value(self):
"""Test rendering field with value"""
self.text_field.value = "john_doe"
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
-
+
# Mock the parent Text render method
with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render:
renderable.render()
-
+
# Should render label
mock_parent_render.assert_called_once()
# Should draw field background and value text
self.mock_draw.rectangle.assert_called_once()
self.mock_draw.text.assert_called_once()
-
+
def test_render_password_field(self):
"""Test rendering password field with masked value"""
self.password_field.value = "secret123"
renderable = FormFieldText(self.password_field, self.font, self.mock_draw)
-
+
# Mock the parent Text render method
with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render:
renderable.render()
-
+
# Should render label and field
mock_parent_render.assert_called_once()
self.mock_draw.rectangle.assert_called_once()
@@ -373,61 +386,62 @@ class TestFormFieldText(unittest.TestCase):
# Check that the text call used masked characters
call_args = self.mock_draw.text.call_args[0]
self.assertEqual(call_args[1], "•" * len("secret123"))
-
+
def test_render_focused_field(self):
"""Test rendering focused field"""
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
renderable.set_focused(True)
-
+
# Mock the parent Text render method
with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render:
renderable.render()
-
+
# Should render with focus styling
mock_parent_render.assert_called_once()
self.mock_draw.rectangle.assert_called_once()
-
+
def test_handle_click_inside_field(self):
"""Test clicking inside field area"""
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
-
+
# Click inside field area (below label)
field_area_y = renderable._style.font_size + 5 + 10 # Within field area
field_area_point = (15, field_area_y)
result = renderable.handle_click(field_area_point)
-
+
# Should return True and set focused
self.assertTrue(result)
self.assertTrue(renderable._focused)
-
+
def test_handle_click_outside_field(self):
"""Test clicking outside field area"""
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
-
+
# Click outside field area
outside_point = (200, 200)
result = renderable.handle_click(outside_point)
-
+
# Should return False and not set focused
self.assertFalse(result)
self.assertFalse(renderable._focused)
-
+
def test_in_object(self):
"""Test in_object method"""
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
renderable.set_origin(np.array([10, 20]))
-
+
# Point inside field (including label and input area)
self.assertTrue(renderable.in_object((15, 25)))
-
+
# Point outside field
self.assertFalse(renderable.in_object((200, 200)))
-
+
def test_factory_function(self):
"""Test the create_form_field_text factory function"""
custom_height = 30
- renderable = create_form_field_text(self.text_field, self.font, self.mock_draw, custom_height)
-
+ renderable = create_form_field_text(
+ self.text_field, self.font, self.mock_draw, custom_height)
+
self.assertIsInstance(renderable, FormFieldText)
self.assertEqual(renderable.text, "Username")
self.assertEqual(renderable.field, self.text_field)
@@ -436,7 +450,7 @@ class TestFormFieldText(unittest.TestCase):
class TestInteractionCallbacks(unittest.TestCase):
"""Test cases for interaction functionality"""
-
+
def setUp(self):
"""Set up test fixtures"""
self.font = Font(font_size=12, colour=(0, 0, 0))
@@ -455,7 +469,8 @@ class TestInteractionCallbacks(unittest.TestCase):
def test_link_text_interaction(self):
"""Test that LinkText properly handles interaction"""
- # Use a FUNCTION link type which calls the callback, not INTERNAL which returns location
+ # Use a FUNCTION link type which calls the callback, not INTERNAL which
+ # returns location
link = Link("test_function", LinkType.FUNCTION, self.link_callback)
renderable = LinkText(link, "Test Link", self.font, self.mock_draw)
diff --git a/tests/concrete/test_concrete_image.py b/tests/concrete/test_concrete_image.py
index 94cfd27..871f2b9 100644
--- a/tests/concrete/test_concrete_image.py
+++ b/tests/concrete/test_concrete_image.py
@@ -8,7 +8,7 @@ import os
import tempfile
import numpy as np
from PIL import Image as PILImage, ImageDraw
-from unittest.mock import Mock, patch, MagicMock
+from unittest.mock import Mock, patch
from pyWebLayout.concrete.image import RenderableImage
from pyWebLayout.abstract.block import Image as AbstractImage
@@ -17,67 +17,67 @@ from pyWebLayout.style import Alignment
class TestRenderableImage(unittest.TestCase):
"""Test cases for the RenderableImage class"""
-
+
def setUp(self):
"""Set up test fixtures"""
# Create a temporary test image
self.temp_dir = tempfile.mkdtemp()
self.test_image_path = os.path.join(self.temp_dir, "test_image.png")
-
+
# Create a simple test image
test_img = PILImage.new('RGB', (100, 80), (255, 0, 0)) # Red image
test_img.save(self.test_image_path)
-
+
# Create abstract image objects
self.abstract_image = AbstractImage(self.test_image_path, "Test Image", 100, 80)
self.abstract_image_no_dims = AbstractImage(self.test_image_path, "Test Image")
-
+
# Create a canvas and draw object for testing
self.canvas = PILImage.new('RGBA', (400, 300), (255, 255, 255, 255))
self.draw = ImageDraw.Draw(self.canvas)
-
+
def tearDown(self):
"""Clean up test fixtures"""
# Clean up temporary files
try:
os.unlink(self.test_image_path)
os.rmdir(self.temp_dir)
- except:
+ except BaseException:
pass
-
+
def test_renderable_image_initialization_basic(self):
"""Test basic image initialization"""
renderable = RenderableImage(self.abstract_image, self.canvas)
-
+
self.assertEqual(renderable._abstract_image, self.abstract_image)
self.assertEqual(renderable._canvas, self.canvas)
self.assertIsNotNone(renderable._pil_image)
self.assertIsNone(renderable._error_message)
self.assertEqual(renderable._halign, Alignment.CENTER)
self.assertEqual(renderable._valign, Alignment.CENTER)
-
+
def test_renderable_image_initialization_with_constraints(self):
"""Test image initialization with size constraints"""
max_width = 50
max_height = 40
-
+
renderable = RenderableImage(
self.abstract_image,
self.draw,
max_width=max_width,
max_height=max_height
)
-
+
self.assertEqual(renderable._abstract_image, self.abstract_image)
# Size should be constrained
self.assertLessEqual(renderable._size[0], max_width)
self.assertLessEqual(renderable._size[1], max_height)
-
+
def test_renderable_image_initialization_with_custom_params(self):
"""Test image initialization with custom parameters"""
custom_origin = (20, 30)
custom_size = (120, 90)
-
+
renderable = RenderableImage(
self.abstract_image,
self.draw,
@@ -86,30 +86,30 @@ class TestRenderableImage(unittest.TestCase):
halign=Alignment.LEFT,
valign=Alignment.TOP
)
-
+
np.testing.assert_array_equal(renderable._origin, np.array(custom_origin))
np.testing.assert_array_equal(renderable._size, np.array(custom_size))
self.assertEqual(renderable._halign, Alignment.LEFT)
self.assertEqual(renderable._valign, Alignment.TOP)
-
+
def test_load_image_local_file(self):
"""Test loading image from local file"""
renderable = RenderableImage(self.abstract_image, self.draw)
-
+
# Image should be loaded
self.assertIsNotNone(renderable._pil_image)
self.assertIsNone(renderable._error_message)
self.assertEqual(renderable._pil_image.size, (100, 80))
-
+
def test_load_image_nonexistent_file(self):
"""Test loading image from nonexistent file"""
bad_abstract = AbstractImage("/nonexistent/path.png", "Bad Image")
renderable = RenderableImage(bad_abstract, self.draw)
-
+
# Should have error message, no PIL image
self.assertIsNone(renderable._pil_image)
self.assertIsNotNone(renderable._error_message)
-
+
@patch('requests.get')
def test_load_image_url_success(self, mock_get):
"""Test loading image from URL (success)"""
@@ -118,14 +118,14 @@ class TestRenderableImage(unittest.TestCase):
mock_response.status_code = 200
mock_response.content = open(self.test_image_path, 'rb').read()
mock_get.return_value = mock_response
-
+
url_abstract = AbstractImage("https://example.com/image.png", "URL Image")
renderable = RenderableImage(url_abstract, self.draw)
-
+
# Should successfully load image
self.assertIsNotNone(renderable._pil_image)
self.assertIsNone(renderable._error_message)
-
+
@patch('requests.get')
def test_load_image_url_failure(self, mock_get):
"""Test loading image from URL (failure)"""
@@ -133,14 +133,16 @@ class TestRenderableImage(unittest.TestCase):
mock_response = Mock()
mock_response.status_code = 404
mock_get.return_value = mock_response
-
- url_abstract = AbstractImage("https://example.com/notfound.png", "Bad URL Image")
+
+ url_abstract = AbstractImage(
+ "https://example.com/notfound.png",
+ "Bad URL Image")
renderable = RenderableImage(url_abstract, self.draw)
-
+
# Should have error message
self.assertIsNone(renderable._pil_image)
self.assertIsNotNone(renderable._error_message)
-
+
def test_load_image_no_requests_library(self):
"""Test loading URL image when requests library is not available"""
# Mock the import to raise ImportError for requests
@@ -148,24 +150,24 @@ class TestRenderableImage(unittest.TestCase):
if name == 'requests':
raise ImportError("No module named 'requests'")
return __import__(name, *args, **kwargs)
-
+
with patch('builtins.__import__', side_effect=mock_import):
url_abstract = AbstractImage("https://example.com/image.png", "URL Image")
renderable = RenderableImage(url_abstract, self.draw)
-
+
# Should have error message about missing requests
self.assertIsNone(renderable._pil_image)
self.assertIsNotNone(renderable._error_message)
self.assertIn("Requests library not available", renderable._error_message)
-
+
def test_resize_image_fit_within_bounds(self):
"""Test image resizing to fit within bounds"""
renderable = RenderableImage(self.abstract_image, self.draw)
-
+
# Original image is 100x80, resize to fit in 50x50
renderable._size = np.array([50, 50])
resized = renderable._resize_image()
-
+
self.assertIsInstance(resized, PILImage.Image)
# Should maintain aspect ratio and fit within bounds
self.assertLessEqual(resized.width, 50)
@@ -174,90 +176,90 @@ class TestRenderableImage(unittest.TestCase):
original_ratio = 100 / 80
new_ratio = resized.width / resized.height
self.assertAlmostEqual(original_ratio, new_ratio, delta=0.1)
-
+
def test_resize_image_larger_target(self):
"""Test image resizing when target is larger than original"""
renderable = RenderableImage(self.abstract_image, self.draw)
-
+
# Target size larger than original
renderable._size = np.array([200, 160])
resized = renderable._resize_image()
-
+
self.assertIsInstance(resized, PILImage.Image)
# Should scale up to fill the space while maintaining aspect ratio
self.assertGreater(resized.width, 100)
self.assertGreater(resized.height, 80)
-
+
def test_resize_image_no_image(self):
"""Test resize when no image is loaded"""
bad_abstract = AbstractImage("/nonexistent/path.png", "Bad Image")
renderable = RenderableImage(bad_abstract, self.draw)
-
+
resized = renderable._resize_image()
-
+
# Should return a placeholder image
self.assertIsInstance(resized, PILImage.Image)
self.assertEqual(resized.mode, 'RGBA')
-
+
def test_draw_error_placeholder(self):
"""Test drawing error placeholder"""
bad_abstract = AbstractImage("/nonexistent/path.png", "Bad Image")
renderable = RenderableImage(bad_abstract, self.canvas)
renderable._error_message = "File not found"
-
+
# Set origin for the placeholder
renderable.set_origin(np.array([10, 20]))
-
+
# Call the error placeholder method
renderable._draw_error_placeholder()
-
+
# We can't easily test the actual drawing without complex mocking,
# but we can verify the method doesn't raise an exception
self.assertIsNotNone(renderable._error_message)
-
+
def test_draw_error_placeholder_with_text(self):
"""Test drawing error placeholder with error message"""
bad_abstract = AbstractImage("/nonexistent/path.png", "Bad Image")
renderable = RenderableImage(bad_abstract, self.canvas)
renderable._error_message = "File not found"
-
+
# Set origin for the placeholder
renderable.set_origin(np.array([10, 20]))
-
+
# Call the error placeholder method
renderable._draw_error_placeholder()
-
+
# Verify error message is set
self.assertIsNotNone(renderable._error_message)
self.assertIn("File not found", renderable._error_message)
-
+
def test_render_successful_image(self):
"""Test rendering successfully loaded image"""
renderable = RenderableImage(self.abstract_image, self.canvas)
renderable.set_origin(np.array([10, 20]))
-
+
# Render returns nothing (draws directly into canvas)
result = renderable.render()
-
+
# Result should be None as it draws directly
self.assertIsNone(result)
-
+
# Verify image was loaded
self.assertIsNotNone(renderable._pil_image)
-
+
def test_render_failed_image(self):
"""Test rendering when image failed to load"""
bad_abstract = AbstractImage("/nonexistent/path.png", "Bad Image")
renderable = RenderableImage(bad_abstract, self.canvas)
renderable.set_origin(np.array([10, 20]))
-
+
with patch.object(renderable, '_draw_error_placeholder') as mock_draw_error:
result = renderable.render()
-
+
# Result should be None as it draws directly
self.assertIsNone(result)
mock_draw_error.assert_called_once()
-
+
def test_render_with_left_alignment(self):
"""Test rendering with left alignment"""
renderable = RenderableImage(
@@ -267,14 +269,14 @@ class TestRenderableImage(unittest.TestCase):
valign=Alignment.TOP
)
renderable.set_origin(np.array([10, 20]))
-
+
result = renderable.render()
-
+
# Result should be None as it draws directly
self.assertIsNone(result)
self.assertEqual(renderable._halign, Alignment.LEFT)
self.assertEqual(renderable._valign, Alignment.TOP)
-
+
def test_render_with_right_alignment(self):
"""Test rendering with right alignment"""
renderable = RenderableImage(
@@ -284,96 +286,97 @@ class TestRenderableImage(unittest.TestCase):
valign=Alignment.BOTTOM
)
renderable.set_origin(np.array([10, 20]))
-
+
result = renderable.render()
-
+
# Result should be None as it draws directly
self.assertIsNone(result)
self.assertEqual(renderable._halign, Alignment.RIGHT)
self.assertEqual(renderable._valign, Alignment.BOTTOM)
-
-
-
+
def test_render_rgb_image_conversion(self):
"""Test rendering RGB image (should be converted to RGBA)"""
# Our test image is RGB, so this should test the conversion path
renderable = RenderableImage(self.abstract_image, self.canvas)
renderable.set_origin(np.array([10, 20]))
-
+
result = renderable.render()
-
+
# Result should be None as it draws directly
self.assertIsNone(result)
self.assertIsNotNone(renderable._pil_image)
-
+
def test_in_object(self):
"""Test in_object method"""
renderable = RenderableImage(self.abstract_image, self.draw, origin=(10, 20))
-
+
# Point inside image
self.assertTrue(renderable.in_object((15, 25)))
-
+
# Point outside image
self.assertFalse(renderable.in_object((200, 200)))
-
+
def test_in_object_with_numpy_array(self):
"""Test in_object with numpy array point"""
renderable = RenderableImage(self.abstract_image, self.draw, origin=(10, 20))
-
+
# Point inside image as numpy array
point = np.array([15, 25])
self.assertTrue(renderable.in_object(point))
-
+
# Point outside image as numpy array
point = np.array([200, 200])
self.assertFalse(renderable.in_object(point))
-
+
def test_image_size_calculation_with_abstract_image_dimensions(self):
"""Test that size is calculated from abstract image when available"""
# Abstract image has dimensions 100x80
renderable = RenderableImage(self.abstract_image, self.draw)
-
+
# Size should match the calculated scaled dimensions
expected_size = self.abstract_image.calculate_scaled_dimensions()
np.testing.assert_array_equal(renderable._size, np.array(expected_size))
-
+
def test_image_size_calculation_with_constraints(self):
"""Test size calculation with max constraints"""
max_width = 60
max_height = 50
-
+
renderable = RenderableImage(
self.abstract_image,
self.draw,
max_width=max_width,
max_height=max_height
)
-
+
# Size should respect constraints
self.assertLessEqual(renderable._size[0], max_width)
self.assertLessEqual(renderable._size[1], max_height)
-
+
def test_image_without_initial_dimensions(self):
"""Test image without initial dimensions in abstract image"""
renderable = RenderableImage(self.abstract_image_no_dims, self.draw)
-
+
# Should still work, using default or calculated size
self.assertIsInstance(renderable._size, np.ndarray)
self.assertEqual(len(renderable._size), 2)
-
+
def test_set_origin_method(self):
"""Test the set_origin method"""
renderable = RenderableImage(self.abstract_image, self.draw)
-
+
new_origin = np.array([50, 60])
renderable.set_origin(new_origin)
-
+
np.testing.assert_array_equal(renderable.origin, new_origin)
-
+
def test_properties(self):
"""Test the property methods"""
- renderable = RenderableImage(self.abstract_image, self.draw, origin=(10, 20), size=(100, 80))
-
+ renderable = RenderableImage(
+ self.abstract_image, self.draw, origin=(
+ 10, 20), size=(
+ 100, 80))
+
np.testing.assert_array_equal(renderable.origin, np.array([10, 20]))
np.testing.assert_array_equal(renderable.size, np.array([100, 80]))
self.assertEqual(renderable.width, 100)
diff --git a/tests/concrete/test_concrete_text.py b/tests/concrete/test_concrete_text.py
index dbc82af..1a3c18a 100644
--- a/tests/concrete/test_concrete_text.py
+++ b/tests/concrete/test_concrete_text.py
@@ -6,30 +6,29 @@ Tests the Text and Line classes for text rendering functionality.
import unittest
import numpy as np
import os
-from PIL import Image, ImageFont, ImageDraw
-from unittest.mock import Mock, patch, MagicMock
+from PIL import Image, ImageDraw
+from unittest.mock import Mock
from pyWebLayout.concrete.text import Text, Line
from pyWebLayout.abstract.inline import Word
-from pyWebLayout.style import Font, FontStyle, FontWeight, TextDecoration
from pyWebLayout.style import Alignment
from tests.utils.test_fonts import create_default_test_font, ensure_consistent_font_in_tests
+
class TestText(unittest.TestCase):
def setUp(self):
# Ensure consistent font usage across tests
ensure_consistent_font_in_tests()
-
+
# Create a real PIL image (canvas) for testing
self.canvas = Image.new('RGB', (800, 600), color='white')
-
+
# Create a real ImageDraw object
self.draw = ImageDraw.Draw(self.canvas)
-
+
# Create a consistent test Font object using bundled font
self.style = create_default_test_font()
-
def test_init(self):
text_instance = Text(text="Test", style=self.style, draw=self.draw)
self.assertEqual(text_instance.text, "Test")
@@ -59,10 +58,10 @@ class TestText(unittest.TestCase):
text_instance = Text(text="Test", style=self.style, draw=self.draw)
# Set a position so we can render without issues
text_instance.set_origin(np.array([10, 50]))
-
+
# This should not raise any exceptions with real objects
text_instance.render()
-
+
# We can verify the canvas was modified (pixel check)
# After rendering, some pixels should have changed from pure white
# This is a more realistic test than checking mock calls
@@ -70,7 +69,7 @@ class TestText(unittest.TestCase):
def test_text_dimensions(self):
"""Test that text dimensions are calculated correctly with real font"""
text_instance = Text(text="Test", style=self.style, draw=self.draw)
-
+
# With real objects, we should get actual width measurements
self.assertGreater(text_instance.width, 0)
self.assertIsInstance(text_instance.width, (int, float))
@@ -94,10 +93,10 @@ class TestText(unittest.TestCase):
text_instance = Text(text="Hello World!", style=self.style, draw=self.draw)
text_instance.set_origin(np.array([50, 100]))
text_instance.render()
-
+
# Optionally save the canvas for visual inspection
self._save_test_image("rendered_text.png")
-
+
# Verify that something was drawn (canvas is no longer pure white everywhere)
# Convert to array and check if any pixels changed
pixels = np.array(self.canvas)
@@ -120,13 +119,13 @@ class TestLine(unittest.TestCase):
def setUp(self):
# Ensure consistent font usage across tests
ensure_consistent_font_in_tests()
-
+
# Create a real PIL image (canvas) for testing
self.canvas = Image.new('RGB', (800, 600), color='white')
-
+
# Create a real ImageDraw object
self.draw = ImageDraw.Draw(self.canvas)
-
+
# Create a consistent test Font object using bundled font
self.style = create_default_test_font()
@@ -135,7 +134,7 @@ class TestLine(unittest.TestCase):
spacing = (5, 15) # min_spacing, max_spacing
origin = np.array([0, 0])
size = np.array([400, 50])
-
+
line = Line(
spacing=spacing,
origin=origin,
@@ -144,7 +143,7 @@ class TestLine(unittest.TestCase):
font=self.style,
halign=Alignment.LEFT
)
-
+
self.assertEqual(line._spacing, spacing)
np.testing.assert_array_equal(line._origin, origin)
np.testing.assert_array_equal(line._size, size)
@@ -155,7 +154,7 @@ class TestLine(unittest.TestCase):
spacing = (5, 15)
origin = np.array([0, 0])
size = np.array([400, 50])
-
+
line = Line(
spacing=spacing,
origin=origin,
@@ -164,10 +163,10 @@ class TestLine(unittest.TestCase):
font=self.style,
halign=Alignment.LEFT
)
-
+
# Create a word to add
word = Word(text="Hello", style=self.style)
-
+
# This test may need adjustment based on the actual implementation
success, overflow_part = line.add_word(word)
@@ -181,7 +180,7 @@ class TestLine(unittest.TestCase):
spacing = (5, 15)
origin = np.array([0, 0])
size = np.array([400, 50])
-
+
line = Line(
spacing=spacing,
origin=origin,
@@ -190,25 +189,26 @@ class TestLine(unittest.TestCase):
font=self.style,
halign=Alignment.LEFT
)
-
+
# Add words until the line is full
words_added = 0
for i in range(100):
word = Word(text="Amsterdam", style=self.style)
success, overflow_part = line.add_word(word)
-
+
if overflow_part:
# Word was hyphenated - overflow occurred
self.assertIsNotNone(overflow_part.text)
return
elif not success:
# Line is full, word couldn't be added
- self.assertGreater(words_added, 0, "Should have added at least one word before line filled")
+ self.assertGreater(
+ words_added, 0, "Should have added at least one word before line filled")
return
else:
# Word was added successfully
words_added += 1
-
+
self.fail("Expected line to fill or overflow to occur but reached max iterations")
def test_line_add_word_until_overflow_small(self):
@@ -216,7 +216,7 @@ class TestLine(unittest.TestCase):
spacing = (5, 15)
origin = np.array([0, 0])
size = np.array([400, 50])
-
+
line = Line(
spacing=spacing,
origin=origin,
@@ -225,20 +225,20 @@ class TestLine(unittest.TestCase):
font=self.style,
halign=Alignment.LEFT
)
-
+
# Create a word to add
for i in range(100):
word = Word(text="Aslan", style=self.style)
-
+
# This test may need adjustment based on the actual implementation
success, overflow_part = line.add_word(word)
# If successful, the word should be added
- if success == False:
+ if not success:
self.assertIsNone(overflow_part)
return
-
+
self.fail("Expected line to reach capacity but reached max iterations")
def test_line_add_word_until_overflow_long_brute(self):
@@ -246,7 +246,7 @@ class TestLine(unittest.TestCase):
spacing = (5, 15)
origin = np.array([0, 0])
size = np.array([400, 50])
-
+
line = Line(
spacing=spacing,
origin=origin,
@@ -256,13 +256,14 @@ class TestLine(unittest.TestCase):
halign=Alignment.LEFT,
min_word_length_for_brute_force=6 # Lower threshold to enable hyphenation for shorter words
)
-
+
# Use a longer word to trigger brute force hyphenation
words_added = 0
for i in range(100):
- word = Word(text="AAAAAAAA", style=self.style) # 8 A's to ensure it's long enough
+ # 8 A's to ensure it's long enough
+ word = Word(text="AAAAAAAA", style=self.style)
success, overflow_part = line.add_word(word)
-
+
if overflow_part:
# Word was hyphenated - verify overflow part exists
self.assertIsNotNone(overflow_part.text)
@@ -270,20 +271,20 @@ class TestLine(unittest.TestCase):
return
elif not success:
# Line is full, word couldn't be added
- self.assertGreater(words_added, 0, "Should have added at least one word before line filled")
+ self.assertGreater(
+ words_added, 0, "Should have added at least one word before line filled")
return
else:
words_added += 1
-
- self.fail("Expected line to fill or overflow to occur but reached max iterations")
+ self.fail("Expected line to fill or overflow to occur but reached max iterations")
def test_line_render(self):
"""Test line rendering with real objects"""
spacing = (5, 15)
origin = np.array([50, 100])
size = np.array([400, 50])
-
+
line = Line(
spacing=spacing,
origin=origin,
@@ -292,7 +293,7 @@ class TestLine(unittest.TestCase):
font=self.style,
halign=Alignment.LEFT
)
-
+
# Try to render the line (even if empty)
try:
line.render()
diff --git a/tests/concrete/test_linkedword_hyphenation.py b/tests/concrete/test_linkedword_hyphenation.py
index 3586fbf..394a971 100644
--- a/tests/concrete/test_linkedword_hyphenation.py
+++ b/tests/concrete/test_linkedword_hyphenation.py
@@ -88,8 +88,11 @@ class TestLinkedWordHyphenation(unittest.TestCase):
# Both parts should be LinkText (this is the bug we're testing for)
for text_obj in line._text_objects:
- self.assertIsInstance(text_obj, LinkText,
- f"Hyphenated LinkedWord part should be LinkText, got {type(text_obj)}")
+ self.assertIsInstance(
+ text_obj,
+ LinkText,
+ f"Hyphenated LinkedWord part should be LinkText, got {
+ type(text_obj)}")
self.assertEqual(text_obj.link.location, linked_word.location)
# The overflow should also be LinkText if it's hyphenated
diff --git a/tests/concrete/test_new_page_implementation.py b/tests/concrete/test_new_page_implementation.py
index e446825..7d48887 100644
--- a/tests/concrete/test_new_page_implementation.py
+++ b/tests/concrete/test_new_page_implementation.py
@@ -9,10 +9,9 @@ Unit tests for the new Page implementation to verify it meets the requirements:
"""
import unittest
import numpy as np
-from PIL import Image, ImageDraw
+from PIL import Image
from pyWebLayout.concrete.page import Page
from pyWebLayout.style.page_style import PageStyle
-from pyWebLayout.style.fonts import Font
from pyWebLayout.core.base import Renderable, Queriable
@@ -23,7 +22,7 @@ class SimpleTestRenderable(Renderable, Queriable):
self._text = text
self.size = size
self._origin = np.array([0, 0])
-
+
def render(self):
"""Render returns None - drawing is done via the page's draw object"""
return None
@@ -31,7 +30,7 @@ class SimpleTestRenderable(Renderable, Queriable):
class TestPageImplementation(unittest.TestCase):
"""Test cases for the Page class implementation"""
-
+
def setUp(self):
"""Set up test fixtures"""
self.basic_style = PageStyle(
@@ -42,81 +41,84 @@ class TestPageImplementation(unittest.TestCase):
padding=(15, 15, 15, 15),
background_color=(240, 240, 240)
)
-
+
self.page_size = (800, 600)
-
+
def test_page_creation_with_style(self):
"""Test creating a page with a PageStyle"""
page = Page(size=self.page_size, style=self.basic_style)
-
+
self.assertEqual(page.size, self.page_size)
self.assertEqual(page.style, self.basic_style)
self.assertEqual(page.border_size, 2)
-
+
def test_page_creation_without_style(self):
"""Test creating a page without a PageStyle (should use defaults)"""
page = Page(size=self.page_size)
-
+
self.assertEqual(page.size, self.page_size)
self.assertIsNotNone(page.style)
-
+
def test_page_canvas_and_content_sizes(self):
"""Test that page correctly calculates canvas and content sizes"""
style = PageStyle(
border_width=5,
padding=(10, 20, 30, 40) # top, right, bottom, left
)
-
+
page = Page(size=self.page_size, style=style)
-
+
# Canvas size should be page size minus borders
expected_canvas_size = (790, 590) # 800-10, 600-10 (border on both sides)
self.assertEqual(page.canvas_size, expected_canvas_size)
-
+
# Content size should be canvas minus padding
- expected_content_size = (730, 550) # 790-60, 590-40 (padding left+right, top+bottom)
+ # 790-60, 590-40 (padding left+right, top+bottom)
+ expected_content_size = (730, 550)
self.assertEqual(page.content_size, expected_content_size)
-
+
def test_page_add_remove_children(self):
"""Test adding and removing children from the page"""
page = Page(size=self.page_size)
-
+
# Initially no children
self.assertEqual(len(page.children), 0)
-
+
# Add children
child1 = SimpleTestRenderable("Child 1")
child2 = SimpleTestRenderable("Child 2")
-
+
page.add_child(child1)
self.assertEqual(len(page.children), 1)
self.assertIn(child1, page.children)
-
+
page.add_child(child2)
self.assertEqual(len(page.children), 2)
self.assertIn(child2, page.children)
-
+
# Test method chaining
child3 = SimpleTestRenderable("Child 3")
result = page.add_child(child3)
self.assertIs(result, page) # Should return self for chaining
self.assertEqual(len(page.children), 3)
self.assertIn(child3, page.children)
-
- # Remove childce you’ll notice is that responses don’t stream character-by-character like other providers. Instead, Claude Code processes your full request before sending back the complete response.
+
+ # Remove childce you’ll notice is that responses don’t stream
+ # character-by-character like other providers. Instead, Claude Code
+ # processes your full request before sending back the complete response.
removed = page.remove_child(child2)
self.assertTrue(removed)
self.assertEqual(len(page.children), 2)
self.assertNotIn(child2, page.children)
-
+
# Try to remove non-existent child
removed = page.remove_child(child2)
self.assertFalse(removed)
-
+
# Clear all children
page.clear_children()
self.assertEqual(len(page.children), 0)
-
+
def test_page_render(self):
"""Test that page renders and creates a canvas"""
style = PageStyle(
@@ -124,24 +126,24 @@ class TestPageImplementation(unittest.TestCase):
border_color=(255, 0, 0),
background_color=(255, 255, 255)
)
-
+
page = Page(size=(200, 150), style=style)
-
+
# Add a child
child = SimpleTestRenderable("Test child")
page.add_child(child)
-
+
# Render the page
image = page.render()
-
+
# Check that we got an image
self.assertIsInstance(image, Image.Image)
self.assertEqual(image.size, (200, 150))
self.assertEqual(image.mode, 'RGBA')
-
+
# Check that draw object is available
self.assertIsNotNone(page.draw)
-
+
def test_page_query_point(self):
"""Test querying points to find children"""
page = Page(size=(400, 300))
@@ -167,22 +169,22 @@ class TestPageImplementation(unittest.TestCase):
result = page.query_point((300, 250))
self.assertIsNotNone(result)
self.assertEqual(result.object_type, "empty")
-
+
def test_page_in_object(self):
"""Test that page correctly implements in_object"""
page = Page(size=(400, 300))
-
+
# Points within page bounds
self.assertTrue(page.in_object((0, 0)))
self.assertTrue(page.in_object((200, 150)))
self.assertTrue(page.in_object((399, 299)))
-
+
# Points outside page bounds
self.assertFalse(page.in_object((-1, 0)))
self.assertFalse(page.in_object((0, -1)))
self.assertFalse(page.in_object((400, 299)))
self.assertFalse(page.in_object((399, 300)))
-
+
def test_page_with_borders(self):
"""Test page rendering with borders"""
style = PageStyle(
@@ -190,33 +192,33 @@ class TestPageImplementation(unittest.TestCase):
border_color=(128, 128, 128),
background_color=(255, 255, 255)
)
-
+
page = Page(size=(100, 100), style=style)
image = page.render()
-
+
# Check that image was created
self.assertIsInstance(image, Image.Image)
self.assertEqual(image.size, (100, 100))
-
+
# The border should be drawn but we can't easily test pixel values
# Just verify the image exists and has the right properties
-
+
def test_page_border_size_property(self):
"""Test that border_size property returns correct value"""
# Test with border
style_with_border = PageStyle(border_width=5)
page_with_border = Page(size=self.page_size, style=style_with_border)
self.assertEqual(page_with_border.border_size, 5)
-
+
# Test without border
style_no_border = PageStyle(border_width=0)
page_no_border = Page(size=self.page_size, style=style_no_border)
self.assertEqual(page_no_border.border_size, 0)
-
+
def test_page_style_properties(self):
"""Test that page correctly exposes style properties"""
page = Page(size=self.page_size, style=self.basic_style)
-
+
# Test that style properties are accessible
self.assertEqual(page.style.border_width, 2)
self.assertEqual(page.style.border_color, (255, 0, 0))
@@ -224,30 +226,30 @@ class TestPageImplementation(unittest.TestCase):
self.assertEqual(page.style.inter_block_spacing, 20)
self.assertEqual(page.style.padding, (15, 15, 15, 15))
self.assertEqual(page.style.background_color, (240, 240, 240))
-
+
def test_page_children_list_operations(self):
"""Test that children list behaves correctly"""
page = Page(size=self.page_size)
-
+
# Test that children is initially empty list
self.assertIsInstance(page.children, list)
self.assertEqual(len(page.children), 0)
-
+
# Test adding multiple children
children = [
SimpleTestRenderable(f"Child {i}")
for i in range(5)
]
-
+
for child in children:
page.add_child(child)
-
+
self.assertEqual(len(page.children), 5)
-
+
# Test that children are in the correct order
for i, child in enumerate(page.children):
self.assertEqual(child._text, f"Child {i}")
-
+
def test_page_can_fit_line_boundary_checking(self):
"""Test that can_fit_line correctly checks bottom boundary"""
# Create page with known dimensions
@@ -259,63 +261,64 @@ class TestPageImplementation(unittest.TestCase):
padding=(10, 10, 10, 10)
)
page = Page(size=(800, 600), style=style)
-
+
# Initial y_offset should be at border + padding_top = 50
self.assertEqual(page._current_y_offset, 50)
-
+
# Test 1: Line that fits comfortably
line_height = 20
- max_y = 600 - 40 - 10 # 550
+ _max_y = 600 - 40 - 10 # 550
self.assertTrue(page.can_fit_line(line_height))
# Would end at 50 + 20 = 70, well within 550
-
+
# Test 2: Simulate adding lines to fill the page
# Available height: 550 - 50 = 500 pixels
# With 20-pixel lines, we can fit 25 lines exactly
for i in range(24): # Add 24 lines
- self.assertTrue(page.can_fit_line(20), f"Line {i+1} should fit")
+ self.assertTrue(page.can_fit_line(20), f"Line {i + 1} should fit")
# Simulate adding a line by updating y_offset
page._current_y_offset += 20
-
+
# After 24 lines: y_offset = 50 + (24 * 20) = 530
self.assertEqual(page._current_y_offset, 530)
-
- # Test 3: One more 20-pixel line should fit (530 + 20 = 550, exactly at boundary)
+
+ # Test 3: One more 20-pixel line should fit (530 + 20 = 550, exactly at
+ # boundary)
self.assertTrue(page.can_fit_line(20))
page._current_y_offset += 20
self.assertEqual(page._current_y_offset, 550)
-
+
# Test 4: Now another line should NOT fit (550 + 20 = 570 > 550)
self.assertFalse(page.can_fit_line(20))
-
+
# Test 5: Even a 1-pixel line should not fit (550 + 1 = 551 > 550)
self.assertFalse(page.can_fit_line(1))
-
+
# Test 6: Edge case - exactly at boundary, 0-height line should fit
self.assertTrue(page.can_fit_line(0))
-
+
def test_page_can_fit_line_with_different_styles(self):
"""Test can_fit_line with different page styles"""
# Test with no border or padding
style_no_border = PageStyle(border_width=0, padding=(0, 0, 0, 0))
page_no_border = Page(size=(100, 100), style=style_no_border)
-
+
# With no border/padding, y_offset starts at 0
self.assertEqual(page_no_border._current_y_offset, 0)
-
+
# Can fit a 100-pixel line exactly
self.assertTrue(page_no_border.can_fit_line(100))
-
+
# Cannot fit a 101-pixel line
self.assertFalse(page_no_border.can_fit_line(101))
-
+
# Test with large border and padding
style_large = PageStyle(border_width=20, padding=(15, 15, 15, 15))
page_large = Page(size=(200, 200), style=style_large)
-
+
# y_offset starts at border + padding_top = 20 + 15 = 35
self.assertEqual(page_large._current_y_offset, 35)
-
+
# Max y = 200 - 20 - 15 = 165
# Available height = 165 - 35 = 130 pixels
self.assertTrue(page_large.can_fit_line(130))
diff --git a/tests/concrete/test_table_rendering.py b/tests/concrete/test_table_rendering.py
index 6fb1c50..6faaa63 100644
--- a/tests/concrete/test_table_rendering.py
+++ b/tests/concrete/test_table_rendering.py
@@ -17,8 +17,7 @@ from pyWebLayout.concrete.table import (
TableRenderer
)
from pyWebLayout.abstract.block import (
- Table, TableRow, TableCell, Paragraph, Heading, HeadingLevel,
- Image as AbstractImage
+ Table, TableRow, TableCell, Paragraph, Heading, HeadingLevel
)
from pyWebLayout.abstract.inline import Word
from pyWebLayout.style import Font
@@ -193,7 +192,11 @@ class TestTableCellRenderer:
assert cell_renderer._style == default_table_style
assert cell_renderer._is_header_section is False
- def test_initialization_with_header(self, sample_font, sample_draw, default_table_style):
+ def test_initialization_with_header(
+ self,
+ sample_font,
+ sample_draw,
+ default_table_style):
"""Test TableCellRenderer initialization for header cell."""
cell = TableCell(is_header=True)
cell_renderer = TableCellRenderer(
@@ -207,7 +210,12 @@ class TestTableCellRenderer:
assert cell_renderer._is_header_section is True
- def test_render_empty_cell(self, sample_font, sample_draw, sample_canvas, default_table_style):
+ def test_render_empty_cell(
+ self,
+ sample_font,
+ sample_draw,
+ sample_canvas,
+ default_table_style):
"""Test rendering an empty cell."""
cell = TableCell()
cell_renderer = TableCellRenderer(
@@ -223,7 +231,12 @@ class TestTableCellRenderer:
# Render returns None (draws directly on canvas)
assert result is None
- def test_render_cell_with_text(self, sample_font, sample_draw, sample_canvas, default_table_style):
+ def test_render_cell_with_text(
+ self,
+ sample_font,
+ sample_draw,
+ sample_canvas,
+ default_table_style):
"""Test rendering a cell with text content."""
cell = TableCell()
paragraph = Paragraph(sample_font)
@@ -243,7 +256,12 @@ class TestTableCellRenderer:
result = cell_renderer.render()
assert result is None
- def test_render_header_cell(self, sample_font, sample_draw, sample_canvas, default_table_style):
+ def test_render_header_cell(
+ self,
+ sample_font,
+ sample_draw,
+ sample_canvas,
+ default_table_style):
"""Test rendering a header cell with different styling."""
cell = TableCell(is_header=True)
paragraph = Paragraph(sample_font)
@@ -263,7 +281,12 @@ class TestTableCellRenderer:
result = cell_renderer.render()
assert result is None
- def test_render_cell_with_heading(self, sample_font, sample_draw, sample_canvas, default_table_style):
+ def test_render_cell_with_heading(
+ self,
+ sample_font,
+ sample_draw,
+ sample_canvas,
+ default_table_style):
"""Test rendering a cell with heading content."""
cell = TableCell()
heading = Heading(HeadingLevel.H2, sample_font)
@@ -294,11 +317,11 @@ class TestTableCellRenderer:
)
# Point inside cell
- assert cell_renderer.in_object((50, 30)) == True
+ assert cell_renderer.in_object((50, 30))
# Point outside cell
- assert cell_renderer.in_object((150, 30)) == False
- assert cell_renderer.in_object((50, 100)) == False
+ assert not cell_renderer.in_object((150, 30))
+ assert not cell_renderer.in_object((50, 100))
def test_properties_access(self, sample_font, sample_draw, default_table_style):
"""Test accessing cell renderer properties."""
@@ -361,7 +384,12 @@ class TestTableRowRenderer:
result = row_renderer.render()
assert result is None
- def test_render_row_with_cells(self, sample_font, sample_draw, sample_canvas, default_table_style):
+ def test_render_row_with_cells(
+ self,
+ sample_font,
+ sample_draw,
+ sample_canvas,
+ default_table_style):
"""Test rendering a row with multiple cells."""
row = TableRow()
@@ -388,7 +416,12 @@ class TestTableRowRenderer:
# Verify cells were created
assert len(row_renderer._cell_renderers) == 3
- def test_render_row_with_colspan(self, sample_font, sample_draw, sample_canvas, default_table_style):
+ def test_render_row_with_colspan(
+ self,
+ sample_font,
+ sample_draw,
+ sample_canvas,
+ default_table_style):
"""Test rendering a row with cells that span multiple columns."""
row = TableRow()
@@ -444,7 +477,11 @@ class TestTableRenderer:
assert table_renderer._draw == sample_draw
assert table_renderer._style == default_table_style
- def test_dimension_calculation(self, simple_table, sample_draw, default_table_style):
+ def test_dimension_calculation(
+ self,
+ simple_table,
+ sample_draw,
+ default_table_style):
"""Test table dimension calculation."""
table_renderer = TableRenderer(
simple_table,
@@ -459,7 +496,12 @@ class TestTableRenderer:
assert len(table_renderer._row_heights) == 3 # header, body, footer
assert all(width > 0 for width in table_renderer._column_widths)
- def test_render_simple_table(self, simple_table, sample_draw, sample_canvas, default_table_style):
+ def test_render_simple_table(
+ self,
+ simple_table,
+ sample_draw,
+ sample_canvas,
+ default_table_style):
"""Test rendering a complete simple table."""
table_renderer = TableRenderer(
simple_table,
@@ -475,7 +517,12 @@ class TestTableRenderer:
# Verify rows were created
assert len(table_renderer._row_renderers) == 2 # 1 header + 1 body
- def test_render_table_with_caption(self, simple_table, sample_draw, sample_canvas, default_table_style):
+ def test_render_table_with_caption(
+ self,
+ simple_table,
+ sample_draw,
+ sample_canvas,
+ default_table_style):
"""Test rendering a table with caption."""
simple_table.caption = "Test Table Caption"
@@ -535,7 +582,12 @@ class TestTableRenderer:
# Should handle gracefully
assert table_renderer is not None
- def test_table_with_footer(self, sample_font, sample_draw, sample_canvas, default_table_style):
+ def test_table_with_footer(
+ self,
+ sample_font,
+ sample_draw,
+ sample_canvas,
+ default_table_style):
"""Test rendering a table with footer rows."""
table = Table()
@@ -590,10 +642,10 @@ class TestTableRenderer:
)
# Point inside table
- assert table_renderer.in_object((50, 50)) == True
+ assert table_renderer.in_object((50, 50))
# Point outside table
- assert table_renderer.in_object((1000, 1000)) == False
+ assert not table_renderer.in_object((1000, 1000))
if __name__ == "__main__":
diff --git a/tests/core/test_highlight.py b/tests/core/test_highlight.py
index be09dea..e5a05f9 100644
--- a/tests/core/test_highlight.py
+++ b/tests/core/test_highlight.py
@@ -8,7 +8,6 @@ import unittest
import tempfile
import shutil
from pathlib import Path
-import json
from pyWebLayout.core.highlight import (
Highlight,
diff --git a/tests/core/test_query_system.py b/tests/core/test_query_system.py
index 01e9f86..3a30832 100644
--- a/tests/core/test_query_system.py
+++ b/tests/core/test_query_system.py
@@ -15,7 +15,6 @@ from pyWebLayout.concrete.text import Text, Line
from pyWebLayout.concrete.functional import LinkText
from pyWebLayout.abstract.inline import Word
from pyWebLayout.abstract.functional import Link, LinkType
-from pyWebLayout.style import Font, Alignment
from pyWebLayout.style.page_style import PageStyle
from tests.utils.test_fonts import create_default_test_font, ensure_consistent_font_in_tests
@@ -369,7 +368,8 @@ class TestPageQueryRange(unittest.TestCase):
start_text = line._text_objects[0]
end_text = line._text_objects[1]
- start_point = (int(start_text._origin[0] + 5), int(start_text._origin[1] + 5))
+ start_point = (
+ int(start_text._origin[0] + 5), int(start_text._origin[1] + 5))
end_point = (int(end_text._origin[0] + 5), int(end_text._origin[1] + 5))
sel_range = self.page.query_range(start_point, end_point)
diff --git a/tests/io_tests/test_epub_reader.py b/tests/io_tests/test_epub_reader.py
index 16bb8fc..e67625f 100644
--- a/tests/io_tests/test_epub_reader.py
+++ b/tests/io_tests/test_epub_reader.py
@@ -9,7 +9,6 @@ import unittest
import tempfile
import os
import shutil
-from datetime import datetime
# Import ebooklib for creating test EPUB files
try:
@@ -21,8 +20,8 @@ except ImportError:
from pyWebLayout.io.readers.epub_reader import read_epub, EPUBReader
from pyWebLayout.abstract.document import Book
from pyWebLayout.abstract.block import (
- Paragraph, Heading, HeadingLevel, Quote, CodeBlock,
- HList, ListStyle, Table, HorizontalRule, Image
+ Paragraph, Heading, Quote, CodeBlock, HList,
+ ListStyle, Table, Image
)
from pyWebLayout.style import FontWeight, FontStyle, TextDecoration
@@ -30,12 +29,12 @@ from pyWebLayout.style import FontWeight, FontStyle, TextDecoration
@unittest.skipUnless(EBOOKLIB_AVAILABLE, "ebooklib not available")
class TestEPUBReader(unittest.TestCase):
"""Test cases for EPUB reader functionality."""
-
+
def setUp(self):
"""Set up test environment."""
self.test_dir = tempfile.mkdtemp()
self.epub_files = []
-
+
def tearDown(self):
"""Clean up test environment."""
# Clean up test EPUB files
@@ -44,21 +43,21 @@ class TestEPUBReader(unittest.TestCase):
os.remove(epub_file)
except OSError:
pass
-
+
# Clean up test directory
if os.path.exists(self.test_dir):
shutil.rmtree(self.test_dir, ignore_errors=True)
-
+
def create_simple_epub(self, title="Test Book", author="Test Author"):
"""Create a simple EPUB file for testing."""
book = epub.EpubBook()
-
+
# Set metadata
book.set_identifier('test-id-123')
book.set_title(title)
book.set_language('en')
book.add_author(author)
-
+
# Create a simple chapter
chapter1 = epub.EpubHtml(
title='Chapter 1',
@@ -75,31 +74,32 @@ class TestEPUBReader(unittest.TestCase):
'''
-
+
# Add chapter to book
book.add_item(chapter1)
-
+
# Define table of contents
book.toc = (epub.Link("chapter1.xhtml", "Chapter 1", "ch1"),)
-
+
# Add navigation files
book.add_item(epub.EpubNcx())
book.add_item(epub.EpubNav())
-
+
# Define spine
book.spine = ['nav', chapter1]
-
+
# Create temporary file
- epub_path = os.path.join(self.test_dir, f'test_simple_{len(self.epub_files)}.epub')
+ epub_path = os.path.join(self.test_dir,
+ f'test_simple_{len(self.epub_files)}.epub')
epub.write_epub(epub_path, book, {})
self.epub_files.append(epub_path)
-
+
return epub_path
-
+
def create_complex_epub(self):
"""Create a more complex EPUB file with multiple chapters and content types."""
book = epub.EpubBook()
-
+
# Set metadata
book.set_identifier('complex-test-id-456')
book.set_title('Complex Test Book')
@@ -109,7 +109,7 @@ class TestEPUBReader(unittest.TestCase):
book.add_metadata('DC', 'subject', 'Testing')
book.add_metadata('DC', 'date', '2024-01-01')
book.add_metadata('DC', 'publisher', 'Test Publisher')
-
+
# Chapter 1: Basic content
chapter1 = epub.EpubHtml(
title='Introduction',
@@ -126,7 +126,7 @@ class TestEPUBReader(unittest.TestCase):
'''
-
+
# Chapter 2: Styled content
chapter2 = epub.EpubHtml(
title='Styled Content',
@@ -138,7 +138,7 @@ class TestEPUBReader(unittest.TestCase):
Styled Content
Styled Content
- This chapter contains various bold text, italic text,
+
This chapter contains various bold text, italic text,
and colored text.
Subsection
Text with underline and strikethrough.
@@ -147,7 +147,7 @@ class TestEPUBReader(unittest.TestCase):