auto flake and corrections

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 from enum import Enum
import os import os
import tempfile import tempfile
@ -6,7 +6,6 @@ import urllib.request
import urllib.parse import urllib.parse
from PIL import Image as PILImage from PIL import Image as PILImage
from .inline import Word, FormattedSpan from .inline import Word, FormattedSpan
from ..style import Font, FontWeight, FontStyle, TextDecoration
from ..core import Hierarchical, Styleable, FontRegistry from ..core import Hierarchical, Styleable, FontRegistry
@ -99,7 +98,9 @@ class Paragraph(Styleable, FontRegistry, Block):
if hasattr(container, 'add_block'): if hasattr(container, 'add_block'):
container.add_block(paragraph) container.add_block(paragraph)
else: 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 return paragraph
@ -216,7 +217,11 @@ class Heading(Paragraph):
self._level = level self._level = level
@classmethod @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 Create a new Heading and add it to a container, inheriting style from
the container if not explicitly provided. the container if not explicitly provided.
@ -245,7 +250,9 @@ class Heading(Paragraph):
if hasattr(container, 'add_block'): if hasattr(container, 'add_block'):
container.add_block(heading) container.add_block(heading)
else: 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 return heading
@ -305,7 +312,9 @@ class Quote(Block):
if hasattr(container, 'add_block'): if hasattr(container, 'add_block'):
container.add_block(quote) container.add_block(quote)
else: 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 return quote
@ -341,7 +350,10 @@ class Quote(Block):
""" """
return Paragraph.create_and_add_to(self, style) 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. Create a new heading and add it to this quote.
@ -403,7 +415,9 @@ class CodeBlock(Block):
if hasattr(container, 'add_block'): if hasattr(container, 'add_block'):
container.add_block(code_block) container.add_block(code_block)
else: 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 return code_block
@ -468,7 +482,11 @@ class HList(Block):
self._default_style = default_style self._default_style = default_style
@classmethod @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 Create a new HList and add it to a container, inheriting style from
the container if not explicitly provided. the container if not explicitly provided.
@ -497,7 +515,9 @@ class HList(Block):
if hasattr(container, 'add_block'): if hasattr(container, 'add_block'):
container.add_block(hlist) container.add_block(hlist)
else: 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 return hlist
@ -579,7 +599,11 @@ class ListItem(Block):
self._style = style self._style = style
@classmethod @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 Create a new ListItem and add it to a container, inheriting style from
the container if not explicitly provided. the container if not explicitly provided.
@ -608,7 +632,9 @@ class ListItem(Block):
if hasattr(container, 'add_item'): if hasattr(container, 'add_item'):
container.add_item(item) container.add_item(item)
else: 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 return item
@ -654,7 +680,10 @@ class ListItem(Block):
""" """
return Paragraph.create_and_add_to(self, style) 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. Create a new heading and add it to this list item.
@ -683,7 +712,12 @@ class TableCell(Block):
A table cell element that can contain other block elements. 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. Initialize a table cell.
@ -731,7 +765,9 @@ class TableCell(Block):
if hasattr(container, 'add_cell'): if hasattr(container, 'add_cell'):
container.add_cell(cell) container.add_cell(cell)
else: 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 return cell
@ -797,7 +833,10 @@ class TableCell(Block):
""" """
return Paragraph.create_and_add_to(self, style) 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. Create a new heading and add it to this table cell.
@ -838,7 +877,11 @@ class TableRow(Block):
self._style = style self._style = style
@classmethod @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 Create a new TableRow and add it to a container, inheriting style from
the container if not explicitly provided. the container if not explicitly provided.
@ -865,7 +908,9 @@ class TableRow(Block):
if hasattr(container, 'add_row'): if hasattr(container, 'add_row'):
container.add_row(row, section) container.add_row(row, section)
else: 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 return row
@ -889,7 +934,12 @@ class TableRow(Block):
self._cells.append(cell) self._cells.append(cell)
cell.parent = self 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. Create a new table cell and add it to this row.
@ -941,7 +991,11 @@ class Table(Block):
self._style = style self._style = style
@classmethod @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 Create a new Table and add it to a container, inheriting style from
the container if not explicitly provided. the container if not explicitly provided.
@ -970,7 +1024,9 @@ class Table(Block):
if hasattr(container, 'add_block'): if hasattr(container, 'add_block'):
container.add_block(table) container.add_block(table)
else: 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 return table
@ -1084,7 +1140,12 @@ class Image(Block):
An image element with source, dimensions, and alternative text. 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. Initialize an image element.
@ -1101,8 +1162,13 @@ class Image(Block):
self._height = height self._height = height
@classmethod @classmethod
def create_and_add_to(cls, container, source: str = "", alt_text: str = "", def create_and_add_to(
width: Optional[int] = None, height: Optional[int] = None) -> 'Image': 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. Create a new Image and add it to a container.
@ -1126,7 +1192,9 @@ class Image(Block):
if hasattr(container, 'add_block'): if hasattr(container, 'add_block'):
container.add_block(image) container.add_block(image)
else: 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 return image
@ -1190,8 +1258,10 @@ class Image(Block):
return self._width / self._height return self._width / self._height
return None return None
def calculate_scaled_dimensions(self, max_width: Optional[int] = None, def calculate_scaled_dimensions(self,
max_height: Optional[int] = None) -> Tuple[Optional[int], Optional[int]]: max_width: Optional[int] = None,
max_height: Optional[int] = None) -> Tuple[Optional[int],
Optional[int]]:
""" """
Calculate scaled dimensions that fit within the given constraints. Calculate scaled dimensions that fit within the given constraints.
@ -1255,19 +1325,21 @@ class Image(Block):
temp_file.write(response.read()) temp_file.write(response.read())
return temp_path return temp_path
except: except BaseException:
# Clean up the temporary file if download fails # Clean up the temporary file if download fails
try: try:
os.close(temp_fd) os.close(temp_fd)
except: except BaseException:
pass pass
try: try:
os.unlink(temp_path) os.unlink(temp_path)
except: except BaseException:
pass pass
raise 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. Load image data using PIL, handling both local files and URLs.
@ -1305,12 +1377,12 @@ class Image(Block):
# Return a copy to avoid issues with the context manager # Return a copy to avoid issues with the context manager
return file_path, img.copy() return file_path, img.copy()
except Exception as e: except Exception:
# Clean up temporary file on error # Clean up temporary file on error
if temp_file and os.path.exists(temp_file): if temp_file and os.path.exists(temp_file):
try: try:
os.unlink(temp_file) os.unlink(temp_file)
except: except BaseException:
pass pass
return None, None return None, None
@ -1348,7 +1420,9 @@ class Image(Block):
# If still no format and we have a URL source, try the original URL # 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): 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) img_format = format_map.get(ext)
info = { info = {
@ -1367,7 +1441,7 @@ class Image(Block):
if file_path and self._is_url(self._source): if file_path and self._is_url(self._source):
try: try:
os.unlink(file_path) os.unlink(file_path)
except: except BaseException:
pass pass
return info return info
@ -1448,7 +1522,10 @@ class LinkedImage(Image):
from pyWebLayout.abstract.functional import LinkType from pyWebLayout.abstract.functional import LinkType
# Add image info to context # 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: if context:
full_context.update(context) full_context.update(context)
@ -1489,7 +1566,9 @@ class HorizontalRule(Block):
if hasattr(container, 'add_block'): if hasattr(container, 'add_block'):
container.add_block(hr) container.add_block(hr)
else: 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 return hr
@ -1528,6 +1607,8 @@ class PageBreak(Block):
if hasattr(container, 'add_block'): if hasattr(container, 'add_block'):
container.add_block(page_break) container.add_block(page_break)
else: 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 return page_break

View File

@ -2,8 +2,6 @@ from __future__ import annotations
from typing import List, Dict, Optional, Tuple, Union, Any from typing import List, Dict, Optional, Tuple, Union, Any
from enum import Enum from enum import Enum
from .block import Block, BlockType, Heading, HeadingLevel, Paragraph from .block import Block, BlockType, Heading, HeadingLevel, Paragraph
from .functional import Link, Button, Form
from .inline import Word, FormattedSpan
from ..style import Font, FontWeight, FontStyle, TextDecoration from ..style import Font, FontWeight, FontStyle, TextDecoration
from ..style.abstract_style import AbstractStyle, AbstractStyleRegistry, FontFamily, FontSize from ..style.abstract_style import AbstractStyle, AbstractStyleRegistry, FontFamily, FontSize
from ..style.concrete_style import ConcreteStyleRegistry, RenderingContext, StyleResolver from ..style.concrete_style import ConcreteStyleRegistry, RenderingContext, StyleResolver
@ -34,7 +32,11 @@ class Document(FontRegistry, MetadataContainer):
Uses MetadataContainer mixin for metadata management. Uses MetadataContainer mixin for metadata management.
""" """
def __init__(self, title: Optional[str] = None, language: str = "en-US", default_style=None): def __init__(
self,
title: Optional[str] = None,
language: str = "en-US",
default_style=None):
""" """
Initialize a new document. Initialize a new document.
@ -68,7 +70,8 @@ class Document(FontRegistry, MetadataContainer):
color=default_style.colour, color=default_style.colour,
language=default_style.language language=default_style.language
) )
style_id, default_style = self._abstract_style_registry.get_or_create_style(default_style) style_id, default_style = self._abstract_style_registry.get_or_create_style(
default_style)
self._default_style = default_style self._default_style = default_style
# Set basic metadata # Set basic metadata
@ -116,7 +119,10 @@ class Document(FontRegistry, MetadataContainer):
self.add_block(paragraph) self.add_block(paragraph)
return paragraph return paragraph
def create_heading(self, level: HeadingLevel = HeadingLevel.H1, style=None) -> Heading: def create_heading(
self,
level: HeadingLevel = HeadingLevel.H1,
style=None) -> Heading:
""" """
Create a new heading and add it to this document. Create a new heading and add it to this document.
@ -133,7 +139,11 @@ class Document(FontRegistry, MetadataContainer):
self.add_block(heading) self.add_block(heading)
return heading return heading
def create_chapter(self, title: Optional[str] = None, level: int = 1, style=None) -> 'Chapter': def create_chapter(
self,
title: Optional[str] = None,
level: int = 1,
style=None) -> 'Chapter':
""" """
Create a new chapter with inherited style. Create a new chapter with inherited style.
@ -392,7 +402,12 @@ class Chapter(FontRegistry, MetadataContainer):
Uses MetadataContainer mixin for metadata management. Uses MetadataContainer mixin for metadata management.
""" """
def __init__(self, title: Optional[str] = None, level: int = 1, style=None, parent=None): def __init__(
self,
title: Optional[str] = None,
level: int = 1,
style=None,
parent=None):
""" """
Initialize a new chapter. Initialize a new chapter.
@ -464,7 +479,10 @@ class Chapter(FontRegistry, MetadataContainer):
self.add_block(paragraph) self.add_block(paragraph)
return paragraph return paragraph
def create_heading(self, level: HeadingLevel = HeadingLevel.H1, style=None) -> Heading: def create_heading(
self,
level: HeadingLevel = HeadingLevel.H1,
style=None) -> Heading:
""" """
Create a new heading and add it to this chapter. Create a new heading and add it to this chapter.
@ -522,7 +540,11 @@ class Book(Document):
""" """
self._chapters.append(chapter) self._chapters.append(chapter)
def create_chapter(self, title: Optional[str] = None, level: int = 1, style=None) -> Chapter: def create_chapter(
self,
title: Optional[str] = None,
level: int = 1,
style=None) -> Chapter:
""" """
Create and add a new chapter with inherited style. Create and add a new chapter with inherited style.

View File

@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from enum import Enum from enum import Enum
from typing import Callable, Dict, Any, Optional, Union, List, Tuple from typing import Callable, Dict, Any, Optional, List, Tuple
from pyWebLayout.core.base import Interactable from pyWebLayout.core.base import Interactable

View File

@ -1,5 +1,4 @@
from __future__ import annotations from __future__ import annotations
from pyWebLayout.core.base import Queriable
from pyWebLayout.core import Hierarchical from pyWebLayout.core import Hierarchical
from pyWebLayout.style import Font from pyWebLayout.style import Font
from pyWebLayout.style.abstract_style import AbstractStyle from pyWebLayout.style.abstract_style import AbstractStyle
@ -10,7 +9,6 @@ import pyphen
from pyWebLayout.abstract.functional import LinkType from pyWebLayout.abstract.functional import LinkType
class Word: class Word:
""" """
An abstract representation of a word in a document. Words can be split across An abstract representation of a word in a document. Words can be split across
@ -20,7 +18,13 @@ class Word:
Now uses AbstractStyle objects for memory efficiency and proper style management. Now uses AbstractStyle objects for memory efficiency and proper style management.
""" """
def __init__(self, text: str, style: Union[Font, AbstractStyle], background=None, previous: Union['Word', None] = None): def __init__(self,
text: str,
style: Union[Font,
AbstractStyle],
background=None,
previous: Union['Word',
None] = None):
""" """
Initialize a new Word. Initialize a new Word.
@ -67,7 +71,9 @@ class Word:
if hasattr(container, 'style'): if hasattr(container, 'style'):
style = container.style style = container.style
else: else:
raise AttributeError(f"Container {type(container).__name__} must have a 'style' property") raise AttributeError(
f"Container {
type(container).__name__} must have a 'style' property")
# Inherit background from container if not provided # Inherit background from container if not provided
if background is None and hasattr(container, 'background'): if background is None and hasattr(container, 'background'):
@ -110,7 +116,8 @@ class Word:
else: else:
# Might expect text string (like FormattedSpan.add_word) # Might expect text string (like FormattedSpan.add_word)
# In this case, we can't use the container's add_word as it would create # In this case, we can't use the container's add_word as it would create
# a duplicate Word. We need to add directly to the container's word list. # a duplicate Word. We need to add directly to the container's word
# list.
if hasattr(container, '_words'): if hasattr(container, '_words'):
container._words.append(word) container._words.append(word)
else: else:
@ -120,11 +127,12 @@ class Word:
# No parameters, shouldn't happen with add_word methods # No parameters, shouldn't happen with add_word methods
container.add_word(word) container.add_word(word)
else: else:
raise AttributeError(f"Container {type(container).__name__} must have an 'add_word' method") raise AttributeError(
f"Container {
type(container).__name__} must have an 'add_word' method")
return word return word
def add_concete(self, text: Union[Any, Tuple[Any, Any]]): def add_concete(self, text: Union[Any, Tuple[Any, Any]]):
self.concrete = text self.concrete = text
@ -153,12 +161,10 @@ class Word:
"""Get the next word in sequence""" """Get the next word in sequence"""
return self._next return self._next
def add_next(self, next_word: 'Word'): def add_next(self, next_word: 'Word'):
"""Set the next word in sequence""" """Set the next word in sequence"""
self._next = next_word self._next = next_word
def possible_hyphenation(self, language: str = None) -> bool: def possible_hyphenation(self, language: str = None) -> bool:
""" """
Hyphenate the word and store the parts. Hyphenate the word and store the parts.
@ -172,9 +178,10 @@ class Word:
dic = pyphen.Pyphen(lang=self._style.language) dic = pyphen.Pyphen(lang=self._style.language)
return list(dic.iterate(self._text)) return list(dic.iterate(self._text))
...
...
class FormattedSpan: class FormattedSpan:
""" """
@ -195,7 +202,11 @@ class FormattedSpan:
self._words: List[Word] = [] self._words: List[Word] = []
@classmethod @classmethod
def create_and_add_to(cls, container, style: Optional[Font] = None, background=None) -> 'FormattedSpan': def create_and_add_to(
cls,
container,
style: Optional[Font] = None,
background=None) -> 'FormattedSpan':
""" """
Create a new FormattedSpan and add it to a container, inheriting style from Create a new FormattedSpan and add it to a container, inheriting style from
the container if not explicitly provided. the container if not explicitly provided.
@ -216,7 +227,9 @@ class FormattedSpan:
if hasattr(container, 'style'): if hasattr(container, 'style'):
style = container.style style = container.style
else: else:
raise AttributeError(f"Container {type(container).__name__} must have a 'style' property") raise AttributeError(
f"Container {
type(container).__name__} must have a 'style' property")
# Inherit background from container if not provided # Inherit background from container if not provided
if background is None and hasattr(container, 'background'): if background is None and hasattr(container, 'background'):
@ -229,7 +242,9 @@ class FormattedSpan:
if hasattr(container, 'add_span'): if hasattr(container, 'add_span'):
container.add_span(span) container.add_span(span)
else: else:
raise AttributeError(f"Container {type(container).__name__} must have an 'add_span' method") raise AttributeError(
f"Container {
type(container).__name__} must have an 'add_span' method")
return span return span

View File

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

View File

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

View File

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

View File

@ -1,10 +1,10 @@
from __future__ import annotations from __future__ import annotations
from typing import Optional, Dict, Any, Tuple, List, Union from typing import Optional, Tuple
import numpy as np import numpy as np
from PIL import Image, ImageDraw, ImageFont from PIL import ImageDraw
from pyWebLayout.core.base import Interactable, Queriable from pyWebLayout.core.base import Interactable, Queriable
from pyWebLayout.abstract.functional import Link, Button, Form, FormField, LinkType, FormFieldType from pyWebLayout.abstract.functional import Link, Button, FormField, LinkType, FormFieldType
from pyWebLayout.style import Font, TextDecoration from pyWebLayout.style import Font, TextDecoration
from .text import Text from .text import Text
@ -33,7 +33,8 @@ class LinkText(Text, Interactable, Queriable):
if link.link_type == LinkType.INTERNAL: if link.link_type == LinkType.INTERNAL:
link_font = link_font.with_colour((0, 0, 200)) # Blue for internal links link_font = link_font.with_colour((0, 0, 200)) # Blue for internal links
elif link.link_type == LinkType.EXTERNAL: elif link.link_type == LinkType.EXTERNAL:
link_font = link_font.with_colour((0, 0, 180)) # Darker blue for external links link_font = link_font.with_colour(
(0, 0, 180)) # Darker blue for external links
elif link.link_type == LinkType.API: elif link.link_type == LinkType.API:
link_font = link_font.with_colour((150, 0, 0)) # Red for API links link_font = link_font.with_colour((150, 0, 0)) # Red for API links
elif link.link_type == LinkType.FUNCTION: elif link.link_type == LinkType.FUNCTION:
@ -62,7 +63,6 @@ class LinkText(Text, Interactable, Queriable):
"""Set the hover state for visual feedback""" """Set the hover state for visual feedback"""
self._hovered = hovered self._hovered = hovered
def render(self, next_text: Optional['Text'] = None, spacing: int = 0): def render(self, next_text: Optional['Text'] = None, spacing: int = 0):
""" """
Render the link text with optional hover effects. Render the link text with optional hover effects.
@ -88,13 +88,15 @@ class LinkText(Text, Interactable, Queriable):
size = np.array(size) size = np.array(size)
# Ensure origin is a numpy array # Ensure origin is a numpy array
origin = np.array(self._origin) if not isinstance(self._origin, np.ndarray) else self._origin origin = np.array(
self._origin) if not isinstance(
self._origin,
np.ndarray) else self._origin
self._draw.rectangle([origin, origin + size], self._draw.rectangle([origin, origin + size],
fill=highlight_color) fill=highlight_color)
class ButtonText(Text, Interactable, Queriable): class ButtonText(Text, Interactable, Queriable):
""" """
A Text subclass that can handle Button interactions. A Text subclass that can handle Button interactions.
@ -129,7 +131,9 @@ class ButtonText(Text, Interactable, Queriable):
# Recalculate dimensions to include padding # Recalculate dimensions to include padding
# Use getattr to handle mock objects in tests # Use getattr to handle mock objects in tests
text_width = getattr(self, '_width', 0) if not hasattr(self._width, '__call__') else 0 text_width = getattr(
self, '_width', 0) if not hasattr(
self._width, '__call__') else 0
self._padded_width = text_width + padding[1] + padding[3] self._padded_width = text_width + padding[1] + padding[3]
self._padded_height = self._style.font_size + padding[0] + padding[2] self._padded_height = self._style.font_size + padding[0] + padding[2]
@ -151,7 +155,6 @@ class ButtonText(Text, Interactable, Queriable):
"""Set the hover state""" """Set the hover state"""
self._hovered = hovered self._hovered = hovered
def render(self): def render(self):
""" """
Render the button with background, border, and text. Render the button with background, border, and text.
@ -270,7 +273,9 @@ class FormFieldText(Text, Interactable, Queriable):
# Field width should be at least as wide as the label # Field width should be at least as wide as the label
# Use getattr to handle mock objects in tests # Use getattr to handle mock objects in tests
text_width = getattr(self, '_width', 0) if not hasattr(self._width, '__call__') else 0 text_width = getattr(
self, '_width', 0) if not hasattr(
self._width, '__call__') else 0
self._field_width = max(text_width, 150) self._field_width = max(text_width, 150)
@property @property
@ -371,7 +376,8 @@ class FormFieldText(Text, Interactable, Queriable):
# Factory functions for creating functional text objects # Factory functions for creating functional text objects
def create_link_text(link: Link, text: str, font: Font, draw: ImageDraw.Draw) -> LinkText: def create_link_text(link: Link, text: str, font: Font,
draw: ImageDraw.Draw) -> LinkText:
""" """
Factory function to create a LinkText object. Factory function to create a LinkText object.

View File

@ -1,10 +1,9 @@
import os import os
from typing import Optional, Tuple, Union, Dict, Any from typing import Optional
import numpy as np import numpy as np
from PIL import Image as PILImage, ImageDraw, ImageFont from PIL import Image as PILImage, ImageDraw, ImageFont
from pyWebLayout.core.base import Renderable, Queriable from pyWebLayout.core.base import Renderable, Queriable
from pyWebLayout.abstract.block import Image as AbstractImage from pyWebLayout.abstract.block import Image as AbstractImage
from .box import Box
from pyWebLayout.style import Alignment from pyWebLayout.style import Alignment
@ -80,7 +79,9 @@ class RenderableImage(Renderable, Queriable):
"""Load the image from the source path""" """Load the image from the source path"""
try: try:
# Check if the image has already been loaded into memory # Check if the image has already been loaded into memory
if hasattr(self._abstract_image, '_loaded_image') and self._abstract_image._loaded_image is not None: if hasattr(
self._abstract_image,
'_loaded_image') and self._abstract_image._loaded_image is not None:
self._pil_image = self._abstract_image._loaded_image self._pil_image = self._abstract_image._loaded_image
return return
@ -102,7 +103,8 @@ class RenderableImage(Renderable, Queriable):
self._pil_image = PILImage.open(BytesIO(response.content)) self._pil_image = PILImage.open(BytesIO(response.content))
self._abstract_image._loaded_image = self._pil_image self._abstract_image._loaded_image = self._pil_image
else: else:
self._error_message = f"Failed to load image: HTTP status {response.status_code}" self._error_message = f"Failed to load image: HTTP status {
response.status_code}"
except ImportError: except ImportError:
self._error_message = "Requests library not available for URL loading" self._error_message = "Requests library not available for URL loading"
else: else:
@ -146,8 +148,14 @@ class RenderableImage(Renderable, Queriable):
# Get the underlying image from the draw object to paste onto # Get the underlying image from the draw object to paste onto
self._canvas.paste(
self._canvas.paste(resized_image, (final_x, final_y, final_x + img_width, final_y + img_height)) resized_image,
(final_x,
final_y,
final_x +
img_width,
final_y +
img_height))
else: else:
# Draw error placeholder # Draw error placeholder
self._draw_error_placeholder() self._draw_error_placeholder()
@ -184,7 +192,8 @@ class RenderableImage(Renderable, Queriable):
resized = self._pil_image.resize((new_width, new_height), PILImage.LANCZOS) resized = self._pil_image.resize((new_width, new_height), PILImage.LANCZOS)
else: else:
# Convert to RGBA if needed # Convert to RGBA if needed
resized = self._pil_image.convert('RGBA').resize((new_width, new_height), PILImage.LANCZOS) resized = self._pil_image.convert('RGBA').resize(
(new_width, new_height), PILImage.LANCZOS)
return resized return resized
@ -200,7 +209,8 @@ class RenderableImage(Renderable, Queriable):
self._draw = ImageDraw.Draw(self._canvas) self._draw = ImageDraw.Draw(self._canvas)
# Draw a gray box with a border # Draw a gray box with a border
self._draw.rectangle([(x1, y1), (x2, y2)], fill=(240, 240, 240), outline=(180, 180, 180), width=2) self._draw.rectangle([(x1, y1), (x2, y2)], fill=(
240, 240, 240), outline=(180, 180, 180), width=2)
# Draw an X across the box # Draw an X across the box
self._draw.line([(x1, y1), (x2, y2)], fill=(180, 180, 180), width=2) self._draw.line([(x1, y1), (x2, y2)], fill=(180, 180, 180), width=2)

View File

@ -2,12 +2,11 @@ from typing import List, Tuple, Optional
import numpy as np import numpy as np
from PIL import Image, ImageDraw from PIL import Image, ImageDraw
from pyWebLayout.core.base import Renderable, Layoutable, Queriable from pyWebLayout.core.base import Renderable, Queriable
from pyWebLayout.core.query import QueryResult, SelectionRange from pyWebLayout.core.query import QueryResult, SelectionRange
from pyWebLayout.core.callback_registry import CallbackRegistry from pyWebLayout.core.callback_registry import CallbackRegistry
from pyWebLayout.style.page_style import PageStyle from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.style import Alignment
from .box import Box
class Page(Renderable, Queriable): class Page(Renderable, Queriable):
""" """
@ -41,7 +40,11 @@ class Page(Renderable, Queriable):
"""Get the remaining space on the page""" """Get the remaining space on the page"""
return (self._size[0], self._size[1] - self._current_y_offset) return (self._size[0], self._size[1] - self._current_y_offset)
def can_fit_line(self, baseline_spacing: int, ascent: int = 0, descent: int = 0) -> bool: def can_fit_line(
self,
baseline_spacing: int,
ascent: int = 0,
descent: int = 0) -> bool:
""" """
Check if a line with the given metrics can fit on the page. Check if a line with the given metrics can fit on the page.
@ -183,11 +186,15 @@ class Page(Renderable, Queriable):
Height in pixels Height in pixels
""" """
if hasattr(child, '_size') and child._size is not None: if hasattr(child, '_size') and child._size is not None:
if isinstance(child._size, (list, tuple, np.ndarray)) and len(child._size) >= 2: if isinstance(
child._size, (list, tuple, np.ndarray)) and len(
child._size) >= 2:
return int(child._size[1]) return int(child._size[1])
if hasattr(child, 'size') and child.size is not None: if hasattr(child, 'size') and child.size is not None:
if isinstance(child.size, (list, tuple, np.ndarray)) and len(child.size) >= 2: if isinstance(
child.size, (list, tuple, np.ndarray)) and len(
child.size) >= 2:
return int(child.size[1]) return int(child.size[1])
if hasattr(child, 'height'): if hasattr(child, 'height'):
@ -326,7 +333,7 @@ class Page(Renderable, Queriable):
if isinstance(child, Queriable) and hasattr(child, 'in_object'): if isinstance(child, Queriable) and hasattr(child, 'in_object'):
try: try:
return child.in_object(point) return child.in_object(point)
except: except BaseException:
pass # Fall back to bounds checking pass # Fall back to bounds checking
# Get child position and size for bounds checking # Get child position and size for bounds checking
@ -353,11 +360,15 @@ class Page(Renderable, Queriable):
Tuple of (width, height) or None if size cannot be determined Tuple of (width, height) or None if size cannot be determined
""" """
if hasattr(child, '_size') and child._size is not None: if hasattr(child, '_size') and child._size is not None:
if isinstance(child._size, (list, tuple, np.ndarray)) and len(child._size) >= 2: if isinstance(
child._size, (list, tuple, np.ndarray)) and len(
child._size) >= 2:
return (int(child._size[0]), int(child._size[1])) return (int(child._size[0]), int(child._size[1]))
if hasattr(child, 'size') and child.size is not None: if hasattr(child, 'size') and child.size is not None:
if isinstance(child.size, (list, tuple, np.ndarray)) and len(child.size) >= 2: if isinstance(
child.size, (list, tuple, np.ndarray)) and len(
child.size) >= 2:
return (int(child.size[0]), int(child.size[1])) return (int(child.size[0]), int(child.size[1]))
if hasattr(child, 'width') and hasattr(child, 'height'): if hasattr(child, 'width') and hasattr(child, 'height'):
@ -422,7 +433,8 @@ class Page(Renderable, Queriable):
bounds=bounds bounds=bounds
) )
def query_range(self, start: Tuple[int, int], end: Tuple[int, int]) -> SelectionRange: def query_range(self, start: Tuple[int, int],
end: Tuple[int, int]) -> SelectionRange:
""" """
Query all text objects between two points (for text selection). Query all text objects between two points (for text selection).
Uses Queriable.in_object() to determine which objects are in range. Uses Queriable.in_object() to determine which objects are in range.

View File

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

View File

@ -2,15 +2,16 @@ from __future__ import annotations
from pyWebLayout.core.base import Renderable, Queriable from pyWebLayout.core.base import Renderable, Queriable
from pyWebLayout.core.query import QueryResult from pyWebLayout.core.query import QueryResult
from .box import Box from .box import Box
from pyWebLayout.style import Alignment, Font, FontStyle, FontWeight, TextDecoration from pyWebLayout.style import Alignment, Font, TextDecoration
from pyWebLayout.abstract import Word from pyWebLayout.abstract import Word
from pyWebLayout.abstract.inline import LinkedWord from pyWebLayout.abstract.inline import LinkedWord
from pyWebLayout.abstract.functional import Link from pyWebLayout.abstract.functional import Link
from PIL import Image, ImageDraw, ImageFont from PIL import ImageDraw
from typing import Tuple, Union, List, Optional, Protocol from typing import Tuple, List, Optional
import numpy as np import numpy as np
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
class AlignmentHandler(ABC): class AlignmentHandler(ABC):
""" """
Abstract base class for text alignment handlers. Abstract base class for text alignment handlers.
@ -33,7 +34,7 @@ class AlignmentHandler(ABC):
Returns: Returns:
Tuple of (spacing_between_words, starting_x_position) Tuple of (spacing_between_words, starting_x_position)
""" """
pass
class LeftAlignmentHandler(AlignmentHandler): class LeftAlignmentHandler(AlignmentHandler):
"""Handler for left-aligned text.""" """Handler for left-aligned text."""
@ -69,7 +70,8 @@ class LeftAlignmentHandler(AlignmentHandler):
# Calculate minimum space needed (text + minimum gaps) # Calculate minimum space needed (text + minimum gaps)
min_total_width = text_length + (min_spacing * num_gaps) min_total_width = text_length + (min_spacing * num_gaps)
# Check if we have overflow (CREngine pattern: always use min_spacing for overflow) # Check if we have overflow (CREngine pattern: always use min_spacing for
# overflow)
if min_total_width > available_width: if min_total_width > available_width:
return min_spacing, 0, True # Overflow - but use safe minimum spacing return min_spacing, 0, True # Overflow - but use safe minimum spacing
@ -87,6 +89,7 @@ class LeftAlignmentHandler(AlignmentHandler):
else: else:
return actual_spacing, 0, False # Use calculated spacing return actual_spacing, 0, False # Use calculated spacing
class CenterRightAlignmentHandler(AlignmentHandler): class CenterRightAlignmentHandler(AlignmentHandler):
"""Handler for center and right-aligned text.""" """Handler for center and right-aligned text."""
@ -124,6 +127,7 @@ class CenterRightAlignmentHandler(AlignmentHandler):
return ideal_space, max(0, start_position), False return ideal_space, max(0, start_position), False
class JustifyAlignmentHandler(AlignmentHandler): class JustifyAlignmentHandler(AlignmentHandler):
"""Handler for justified text with full justification.""" """Handler for justified text with full justification."""
@ -146,13 +150,20 @@ class JustifyAlignmentHandler(AlignmentHandler):
return max(min_spacing, actual_spacing), 0, False return max(min_spacing, actual_spacing), 0, False
return ideal_space, 0, False return ideal_space, 0, False
class Text(Renderable, Queriable): class Text(Renderable, Queriable):
""" """
Concrete implementation for rendering text. Concrete implementation for rendering text.
This class handles the visual representation of text fragments. This class handles the visual representation of text fragments.
""" """
def __init__(self, text: str, style: Font, draw: ImageDraw.Draw, source: Optional[Word] = None, line: Optional[Line] = None): def __init__(
self,
text: str,
style: Font,
draw: ImageDraw.Draw,
source: Optional[Word] = None,
line: Optional[Line] = None):
""" """
Initialize a Text object. Initialize a Text object.
@ -285,24 +296,42 @@ class Text(Renderable, Queriable):
# Draw the text background if specified # Draw the text background if specified
if self._style.background and self._style.background[3] > 0: # If alpha > 0 if self._style.background and self._style.background[3] > 0: # If alpha > 0
self._draw.rectangle([self._origin, self._origin+self._size], fill=self._style.background) self._draw.rectangle([self._origin, self._origin +
self._size], fill=self._style.background)
# Draw the text using baseline as anchor point ("ls" = left-baseline) # Draw the text using baseline as anchor point ("ls" = left-baseline)
# This ensures the origin represents the baseline, not the top-left # This ensures the origin represents the baseline, not the top-left
self._draw.text((self.origin[0], self._origin[1]), self._text, font=self._style.font, fill=self._style.colour, anchor="ls") self._draw.text(
(self.origin[0],
self._origin[1]),
self._text,
font=self._style.font,
fill=self._style.colour,
anchor="ls")
# Apply any text decorations with knowledge of next text # Apply any text decorations with knowledge of next text
self._apply_decoration(next_text, spacing) self._apply_decoration(next_text, spacing)
class Line(Box): class Line(Box):
""" """
A line of text consisting of Text objects with consistent spacing. A line of text consisting of Text objects with consistent spacing.
Each Text represents a word or word fragment that can be rendered. Each Text represents a word or word fragment that can be rendered.
""" """
def __init__(self, spacing: Tuple[int, int], origin, size, draw: ImageDraw.Draw,font: Optional[Font] = None, def __init__(self,
callback=None, sheet=None, mode=None, halign=Alignment.CENTER, spacing: Tuple[int,
valign=Alignment.CENTER, previous = None, int],
origin,
size,
draw: ImageDraw.Draw,
font: Optional[Font] = None,
callback=None,
sheet=None,
mode=None,
halign=Alignment.CENTER,
valign=Alignment.CENTER,
previous=None,
min_word_length_for_brute_force: int = 8, min_word_length_for_brute_force: int = 8,
min_chars_before_hyphen: int = 2, min_chars_before_hyphen: int = 2,
min_chars_after_hyphen: int = 2): min_chars_after_hyphen: int = 2):
@ -373,7 +402,10 @@ class Line(Box):
"""Set the next line in sequence""" """Set the next line in sequence"""
self._next = line self._next = line
def add_word(self, word: 'Word', part:Optional[Text]=None) -> Tuple[bool, Optional['Text']]: def add_word(self,
word: 'Word',
part: Optional[Text] = None) -> Tuple[bool,
Optional['Text']]:
""" """
Add a word to this line using intelligent word fitting strategies. Add a word to this line using intelligent word fitting strategies.
@ -392,7 +424,8 @@ class Line(Box):
self._words.append(word) self._words.append(word)
part.add_line(self) part.add_line(self)
# Try to add the full word - create LinkText for LinkedWord, regular Text otherwise # Try to add the full word - create LinkText for LinkedWord, regular Text
# otherwise
if isinstance(word, LinkedWord): if isinstance(word, LinkedWord):
# Import here to avoid circular dependency # Import here to avoid circular dependency
from .functional import LinkText from .functional import LinkText
@ -407,13 +440,18 @@ class Line(Box):
params=word.params, params=word.params,
title=word.link_title title=word.link_title
) )
text = LinkText(link, word.text, word.style, self._draw, source=word, line=self) text = LinkText(
link,
word.text,
word.style,
self._draw,
source=word,
line=self)
else: else:
text = Text.from_word(word, self._draw) text = Text.from_word(word, self._draw)
self._text_objects.append(text) self._text_objects.append(text)
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position( spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
self._text_objects, self._size[0], self._spacing[0], self._spacing[1] self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
)
if not overflow: if not overflow:
# Word fits! Add it completely # Word fits! Add it completely
@ -444,14 +482,23 @@ class Line(Box):
continue continue
# Create Text objects # Create Text objects
first_text = Text(first_part_text, word.style, self._draw, line=self, source=word) first_text = Text(
second_text = Text(second_part_text, word.style, self._draw, line=self, source=word) first_part_text,
word.style,
self._draw,
line=self,
source=word)
second_text = Text(
second_part_text,
word.style,
self._draw,
line=self,
source=word)
# Check if first part fits # Check if first part fits
self._text_objects.append(first_text) self._text_objects.append(first_text)
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position( spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
self._text_objects, self._size[0], self._spacing[0], self._spacing[1] self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
)
_ = self._text_objects.pop() _ = self._text_objects.pop()
if not overflow: if not overflow:
@ -489,7 +536,8 @@ class Line(Box):
fraction = remaining / test_text.width fraction = remaining / test_text.width
# Convert fraction to character position # Convert fraction to character position
# We need at least min_chars_before_hyphen and leave at least min_chars_after_hyphen # We need at least min_chars_before_hyphen and leave at least
# min_chars_after_hyphen
max_split_pos = len(word.text) - self._min_chars_after_hyphen max_split_pos = len(word.text) - self._min_chars_after_hyphen
min_split_pos = self._min_chars_before_hyphen min_split_pos = self._min_chars_before_hyphen
@ -505,14 +553,23 @@ class Line(Box):
first_part_text = word.text[:split_pos] + "-" first_part_text = word.text[:split_pos] + "-"
second_part_text = word.text[split_pos:] second_part_text = word.text[split_pos:]
first_text = Text(first_part_text, word.style, self._draw, line=self, source=word) first_text = Text(
second_text = Text(second_part_text, word.style, self._draw, line=self, source=word) first_part_text,
word.style,
self._draw,
line=self,
source=word)
second_text = Text(
second_part_text,
word.style,
self._draw,
line=self,
source=word)
# Verify the first part actually fits # Verify the first part actually fits
self._text_objects.append(first_text) self._text_objects.append(first_text)
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position( spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
self._text_objects, self._size[0], self._spacing[0], self._spacing[1] self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
)
if not overflow: if not overflow:
# Brute force split works! # Brute force split works!
@ -540,8 +597,7 @@ class Line(Box):
# Recalculate spacing and position for current text objects to ensure accuracy # Recalculate spacing and position for current text objects to ensure accuracy
if len(self._text_objects) > 0: if len(self._text_objects) > 0:
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position( spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
self._text_objects, self._size[0], self._spacing[0], self._spacing[1] self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
)
self._spacing_render = spacing self._spacing_render = spacing
self._position_render = position self._position_render = position
@ -555,7 +611,8 @@ class Line(Box):
text.set_origin(np.array([x_cursor, y_cursor])) text.set_origin(np.array([x_cursor, y_cursor]))
# Determine next text object for continuous decoration # Determine next text object for continuous decoration
next_text = self._text_objects[i + 1] if i + 1 < len(self._text_objects) else None next_text = self._text_objects[i + 1] if i + \
1 < len(self._text_objects) else None
# Render with next text information for continuous underline/strikethrough # Render with next text information for continuous underline/strikethrough
text.render(next_text, self._spacing_render) text.render(next_text, self._spacing_render)
@ -583,7 +640,8 @@ class Line(Box):
size = text_obj.size size = text_obj.size
# Text origin is at baseline (anchor="ls"), so visual top is origin[1] - ascent # Text origin is at baseline (anchor="ls"), so visual top is origin[1] - ascent
# Bounds should be (x, visual_top, width, height) for proper highlighting # Bounds should be (x, visual_top, width, height) for proper
# highlighting
visual_top = int(origin[1] - text_obj._ascent) visual_top = int(origin[1] - text_obj._ascent)
bounds = ( bounds = (
int(origin[0]), int(origin[0]),
@ -602,8 +660,9 @@ class Line(Box):
bounds=bounds, bounds=bounds,
text=text_obj._text, text=text_obj._text,
is_interactive=True, is_interactive=True,
link_target=text_obj._link.location if hasattr(text_obj, '_link') else None link_target=text_obj._link.location if hasattr(
) text_obj,
'_link') else None)
elif isinstance(text_obj, ButtonText): elif isinstance(text_obj, ButtonText):
result = QueryResult( result = QueryResult(
object=text_obj, object=text_obj,
@ -611,8 +670,9 @@ class Line(Box):
bounds=bounds, bounds=bounds,
text=text_obj._text, text=text_obj._text,
is_interactive=True, is_interactive=True,
callback=text_obj._callback if hasattr(text_obj, '_callback') else None callback=text_obj._callback if hasattr(
) text_obj,
'_callback') else None)
else: else:
result = QueryResult( result = QueryResult(
object=text_obj, object=text_obj,

View File

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

View File

@ -1,11 +1,9 @@
from abc import ABC from abc import ABC
from typing import Optional, Tuple, List, TYPE_CHECKING, Any, Dict from typing import Optional, Tuple, TYPE_CHECKING, Any, Dict
import numpy as np import numpy as np
from pyWebLayout.style.alignment import Alignment
if TYPE_CHECKING: if TYPE_CHECKING:
from pyWebLayout.core.query import QueryResult
from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration
@ -14,6 +12,7 @@ class Renderable(ABC):
Abstract base class for any object that can be rendered to an image. Abstract base class for any object that can be rendered to an image.
All renderable objects must implement the render method. All renderable objects must implement the render method.
""" """
def render(self): def render(self):
""" """
Render the object to an image. Render the object to an image.
@ -21,16 +20,18 @@ class Renderable(ABC):
Returns: Returns:
PIL.Image: The rendered image PIL.Image: The rendered image
""" """
pass
@property @property
def origin(self): def origin(self):
return self._origin return self._origin
class Interactable(ABC): class Interactable(ABC):
""" """
Abstract base class for any object that can be interacted with. Abstract base class for any object that can be interacted with.
Interactable objects must have a callback that is executed when interacted with. Interactable objects must have a callback that is executed when interacted with.
""" """
def __init__(self, callback=None): def __init__(self, callback=None):
""" """
Initialize an interactable object. Initialize an interactable object.
@ -54,17 +55,19 @@ class Interactable(ABC):
return None return None
return self._callback(point) return self._callback(point)
class Layoutable(ABC): class Layoutable(ABC):
""" """
Abstract base class for any object that can be laid out. Abstract base class for any object that can be laid out.
Layoutable objects must implement the layout method which arranges their contents. Layoutable objects must implement the layout method which arranges their contents.
""" """
def layout(self): def layout(self):
""" """
Layout the object's contents. Layout the object's contents.
This method should be called before rendering to properly arrange the object's contents. This method should be called before rendering to properly arrange the object's contents.
""" """
pass
class Queriable(ABC): class Queriable(ABC):
@ -222,7 +225,11 @@ class FontRegistry:
decoration = TextDecoration.NONE decoration = TextDecoration.NONE
# If we have a parent with font management, delegate to parent # If we have a parent with font management, delegate to parent
if hasattr(self, '_parent') and self._parent and hasattr(self._parent, 'get_or_create_font'): if hasattr(
self,
'_parent') and self._parent and hasattr(
self._parent,
'get_or_create_font'):
return self._parent.get_or_create_font( return self._parent.get_or_create_font(
font_path=font_path, font_path=font_path,
font_size=font_size, font_size=font_size,
@ -409,8 +416,8 @@ class ContainerAware:
""" """
if not hasattr(container, required_method): if not hasattr(container, required_method):
raise AttributeError( raise AttributeError(
f"Container {type(container).__name__} must have a '{required_method}' method" f"Container {
) type(container).__name__} must have a '{required_method}' method")
@classmethod @classmethod
def _inherit_style(cls, container, style=None): def _inherit_style(cls, container, style=None):

View File

@ -8,7 +8,7 @@ and managing their callbacks. Supports multiple binding strategies:
- Type-based batch operations - Type-based batch operations
""" """
from typing import Dict, List, Optional, Callable, Any from typing import Dict, List, Optional, Callable
from pyWebLayout.core.base import Interactable from pyWebLayout.core.base import Interactable

View File

@ -148,7 +148,8 @@ class HighlightManager:
self.highlights.clear() self.highlights.clear()
self._save_highlights() self._save_highlights()
def get_highlights_for_page(self, page_bounds: Tuple[int, int, int, int]) -> List[Highlight]: def get_highlights_for_page(
self, page_bounds: Tuple[int, int, int, int]) -> List[Highlight]:
""" """
Get highlights that appear on a specific page. Get highlights that appear on a specific page.

View File

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

View File

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

View File

@ -8,13 +8,12 @@ to pyWebLayout's abstract document model.
import os import os
import zipfile import zipfile
import tempfile import tempfile
from typing import Dict, List, Optional, Any, Tuple, Callable from typing import Dict, List, Optional, Any, Callable
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
import re
import urllib.parse import urllib.parse
from PIL import Image as PILImage, ImageOps from PIL import Image as PILImage, ImageOps
from pyWebLayout.abstract.document import Document, Book, Chapter, MetadataType from pyWebLayout.abstract.document import Book, Chapter, MetadataType
from pyWebLayout.abstract.block import PageBreak from pyWebLayout.abstract.block import PageBreak
from pyWebLayout.io.readers.html_extraction import parse_html_string from pyWebLayout.io.readers.html_extraction import parse_html_string
@ -61,7 +60,8 @@ class EPUBReader:
pyWebLayout's abstract document model. pyWebLayout's abstract document model.
""" """
def __init__(self, epub_path: str, image_processor: Optional[Callable[[PILImage.Image], PILImage.Image]] = default_eink_processor): def __init__(self, epub_path: str, image_processor: Optional[Callable[[
PILImage.Image], PILImage.Image]] = default_eink_processor):
""" """
Initialize an EPUB reader. Initialize an EPUB reader.
@ -124,10 +124,12 @@ class EPUBReader:
root = tree.getroot() root = tree.getroot()
# Get the path to the package document (content.opf) # Get the path to the package document (content.opf)
for rootfile in root.findall('.//{urn:oasis:names:tc:opendocument:xmlns:container}rootfile'): for rootfile in root.findall(
'.//{urn:oasis:names:tc:opendocument:xmlns:container}rootfile'):
full_path = rootfile.get('full-path') full_path = rootfile.get('full-path')
if full_path: if full_path:
self.content_dir = os.path.dirname(os.path.join(self.temp_dir, full_path)) self.content_dir = os.path.dirname(
os.path.join(self.temp_dir, full_path))
return return
# Fallback: look for common content directories # Fallback: look for common content directories
@ -264,14 +266,18 @@ class EPUBReader:
self.toc_path = self.manifest[toc_id]['path'] self.toc_path = self.manifest[toc_id]['path']
# Parse itemrefs # Parse itemrefs
for itemref in spine_elem.findall('.//{{{0}}}itemref'.format(NAMESPACES['opf'])): for itemref in spine_elem.findall(
'.//{{{0}}}itemref'.format(NAMESPACES['opf'])):
idref = itemref.get('idref') idref = itemref.get('idref')
if idref and idref in self.manifest: if idref and idref in self.manifest:
self.spine.append(idref) self.spine.append(idref)
def _parse_toc(self): def _parse_toc(self):
"""Parse the table of contents.""" """Parse the table of contents."""
if not hasattr(self, 'toc_path') or not self.toc_path or not os.path.exists(self.toc_path): if not hasattr(
self,
'toc_path') or not self.toc_path or not os.path.exists(
self.toc_path):
# Try to find the toc.ncx file # Try to find the toc.ncx file
for root, dirs, files in os.walk(self.content_dir): for root, dirs, files in os.walk(self.content_dir):
for file in files: for file in files:
@ -281,7 +287,10 @@ class EPUBReader:
if hasattr(self, 'toc_path') and self.toc_path: if hasattr(self, 'toc_path') and self.toc_path:
break break
if not hasattr(self, 'toc_path') or not self.toc_path or not os.path.exists(self.toc_path): if not hasattr(
self,
'toc_path') or not self.toc_path or not os.path.exists(
self.toc_path):
# No TOC found # No TOC found
return return
@ -312,7 +321,8 @@ class EPUBReader:
# Get navLabel # Get navLabel
nav_label = nav_point.find('.//{{{0}}}navLabel'.format(NAMESPACES['ncx'])) nav_label = nav_point.find('.//{{{0}}}navLabel'.format(NAMESPACES['ncx']))
text_elem = nav_label.find('.//{{{0}}}text'.format(NAMESPACES['ncx'])) if nav_label else None text_elem = nav_label.find(
'.//{{{0}}}text'.format(NAMESPACES['ncx'])) if nav_label else None
label = text_elem.text if text_elem is not None else "" label = text_elem.text if text_elem is not None else ""
# Get content # Get content
@ -350,10 +360,14 @@ class EPUBReader:
self.book.set_metadata(MetadataType.LANGUAGE, self.metadata['language']) self.book.set_metadata(MetadataType.LANGUAGE, self.metadata['language'])
if 'description' in self.metadata: if 'description' in self.metadata:
self.book.set_metadata(MetadataType.DESCRIPTION, self.metadata['description']) self.book.set_metadata(
MetadataType.DESCRIPTION,
self.metadata['description'])
if 'subjects' in self.metadata: if 'subjects' in self.metadata:
self.book.set_metadata(MetadataType.KEYWORDS, ', '.join(self.metadata['subjects'])) self.book.set_metadata(
MetadataType.KEYWORDS, ', '.join(
self.metadata['subjects']))
if 'date' in self.metadata: if 'date' in self.metadata:
self.book.set_metadata(MetadataType.PUBLICATION_DATE, self.metadata['date']) self.book.set_metadata(MetadataType.PUBLICATION_DATE, self.metadata['date'])
@ -388,7 +402,8 @@ class EPUBReader:
import io import io
# Load the image into memory before the temp directory is cleaned up # Load the image into memory before the temp directory is cleaned up
# We need to fully copy the image data to ensure it persists after temp cleanup # We need to fully copy the image data to ensure it persists after temp
# cleanup
with open(cover_path, 'rb') as f: with open(cover_path, 'rb') as f:
image_bytes = f.read() image_bytes = f.read()
@ -414,7 +429,8 @@ class EPUBReader:
cover_image._width = pil_image.width cover_image._width = pil_image.width
cover_image._height = pil_image.height cover_image._height = pil_image.height
# Store the loaded PIL image in the abstract image so it persists after temp cleanup # Store the loaded PIL image in the abstract image so it persists after
# temp cleanup
cover_image._loaded_image = pil_image cover_image._loaded_image = pil_image
# Add the image to the cover chapter # Add the image to the cover chapter
@ -444,7 +460,10 @@ class EPUBReader:
try: try:
block._loaded_image = self.image_processor(block._loaded_image) block._loaded_image = self.image_processor(block._loaded_image)
except Exception as e: except Exception as e:
print(f"Warning: Image processing failed for image '{block.alt_text}': {str(e)}") print(
f"Warning: Image processing failed for image '{
block.alt_text}': {
str(e)}")
# Continue with unprocessed image # Continue with unprocessed image
def _process_content_images(self): def _process_content_images(self):
@ -529,7 +548,11 @@ class EPUBReader:
error_para = Paragraph() error_para = Paragraph()
# Create a default font style for the error message # Create a default font style for the error message
default_font = Font() default_font = Font()
error_para.add_word(Word(f"Error loading chapter: {str(e)}", default_font)) error_para.add_word(
Word(
f"Error loading chapter: {
str(e)}",
default_font))
chapter.add_block(error_para) chapter.add_block(error_para)
# Still add PageBreak even after error # Still add PageBreak even after error
chapter.add_block(PageBreak()) chapter.add_block(PageBreak())

View File

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

View File

@ -5,16 +5,22 @@ import numpy as np
from pyWebLayout.concrete import Page, Line, Text from pyWebLayout.concrete import Page, Line, Text
from pyWebLayout.concrete.image import RenderableImage from pyWebLayout.concrete.image import RenderableImage
from pyWebLayout.concrete.functional import LinkText, ButtonText, FormFieldText from pyWebLayout.concrete.functional import ButtonText, FormFieldText
from pyWebLayout.concrete.table import TableRenderer, TableStyle from pyWebLayout.concrete.table import TableRenderer, TableStyle
from pyWebLayout.abstract import Paragraph, Word, Link from pyWebLayout.abstract import Paragraph, Word
from pyWebLayout.abstract.block import Image as AbstractImage, PageBreak, Table from pyWebLayout.abstract.block import Image as AbstractImage, PageBreak, Table
from pyWebLayout.abstract.functional import Button, Form, FormField from pyWebLayout.abstract.functional import Button, Form, FormField
from pyWebLayout.abstract.inline import LinkedWord
from pyWebLayout.style.concrete_style import ConcreteStyleRegistry, RenderingContext, StyleResolver from pyWebLayout.style.concrete_style import ConcreteStyleRegistry, RenderingContext, StyleResolver
from pyWebLayout.style import Font, Alignment from pyWebLayout.style import Font, Alignment
def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pretext: Optional[Text] = None, alignment_override: Optional['Alignment'] = None) -> Tuple[bool, Optional[int], Optional[Text]]:
def paragraph_layouter(paragraph: Paragraph,
page: Page,
start_word: int = 0,
pretext: Optional[Text] = None,
alignment_override: Optional['Alignment'] = None) -> Tuple[bool,
Optional[int],
Optional[Text]]:
""" """
Layout a paragraph of text within a given page. Layout a paragraph of text within a given page.
@ -76,7 +82,11 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
text_align = concrete_style.text_align text_align = concrete_style.text_align
# Apply page-level word spacing override if specified # Apply page-level word spacing override if specified
if hasattr(page.style, 'word_spacing') and isinstance(page.style.word_spacing, int) and page.style.word_spacing > 0: if hasattr(
page.style,
'word_spacing') and isinstance(
page.style.word_spacing,
int) and page.style.word_spacing > 0:
# Add the page-level word spacing to both min and max constraints # Add the page-level word spacing to both min and max constraints
min_ws, max_ws = word_spacing_constraints min_ws, max_ws = word_spacing_constraints
word_spacing_constraints = ( word_spacing_constraints = (
@ -112,14 +122,16 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
# Get font metrics for boundary checking # Get font metrics for boundary checking
ascent, descent = font.font.getmetrics() ascent, descent = font.font.getmetrics()
def create_new_line(word: Optional[Union[Word, Text]] = None, is_first_line: bool = False) -> Optional[Line]: def create_new_line(word: Optional[Union[Word, Text]] = None,
is_first_line: bool = False) -> Optional[Line]:
"""Helper function to create a new line, returns None if page is full.""" """Helper function to create a new line, returns None if page is full."""
# Check if this line's baseline and descenders would fit on the page # Check if this line's baseline and descenders would fit on the page
if not page.can_fit_line(baseline_spacing, ascent, descent): if not page.can_fit_line(baseline_spacing, ascent, descent):
return None return None
# For the first line, position it so text starts at the top boundary # For the first line, position it so text starts at the top boundary
# For subsequent lines, use current y_offset which tracks baseline-to-baseline spacing # For subsequent lines, use current y_offset which tracks
# baseline-to-baseline spacing
if is_first_line: if is_first_line:
# Position line origin so that baseline (origin + ascent) is close to top # Position line origin so that baseline (origin + ascent) is close to top
# We want minimal space above the text, so origin should be at boundary # We want minimal space above the text, so origin should be at boundary
@ -131,9 +143,9 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
# Create a temporary Text object to calculate word width # Create a temporary Text object to calculate word width
if word: if word:
temp_text = Text.from_word(word, page.draw) temp_text = Text.from_word(word, page.draw)
word_width = temp_text.width temp_text.width
else: else:
word_width = 0 pass
return Line( return Line(
spacing=word_spacing_constraints, spacing=word_spacing_constraints,
@ -195,7 +207,19 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
# Word is too wide for the line, we need to hyphenate it # Word is too wide for the line, we need to hyphenate it
if len(word.text) >= 6: if len(word.text) >= 6:
# Try to hyphenate the word # Try to hyphenate the word
splits = [(Text(pair[0], word.style, page.draw, line=current_line, source=word), Text(pair[1], word.style, page.draw, line=current_line, source=word)) for pair in word.possible_hyphenation()] splits = [
(Text(
pair[0],
word.style,
page.draw,
line=current_line,
source=word),
Text(
pair[1],
word.style,
page.draw,
line=current_line,
source=word)) for pair in word.possible_hyphenation()]
if len(splits) > 0: if len(splits) > 0:
# Use the first hyphenation point # Use the first hyphenation point
first_part, second_part = splits[0] first_part, second_part = splits[0]
@ -275,7 +299,8 @@ def image_layouter(image: AbstractImage, page: Page, max_width: Optional[int] =
max_height = min(max_height, available_height) max_height = min(max_height, available_height)
# Calculate scaled dimensions # Calculate scaled dimensions
scaled_width, scaled_height = image.calculate_scaled_dimensions(max_width, max_height) scaled_width, scaled_height = image.calculate_scaled_dimensions(
max_width, max_height)
# Check if image fits on current page # Check if image fits on current page
if scaled_height is None or scaled_height > available_height: if scaled_height is None or scaled_height > available_height:
@ -305,7 +330,10 @@ def image_layouter(image: AbstractImage, page: Page, max_width: Optional[int] =
return True return True
def table_layouter(table: Table, page: Page, style: Optional[TableStyle] = None) -> bool: def table_layouter(
table: Table,
page: Page,
style: Optional[TableStyle] = None) -> bool:
""" """
Layout a table within a given page. Layout a table within a given page.
@ -356,8 +384,17 @@ def table_layouter(table: Table, page: Page, style: Optional[TableStyle] = None)
return True return True
def button_layouter(button: Button, page: Page, font: Optional[Font] = None, def button_layouter(button: Button,
padding: Tuple[int, int, int, int] = (4, 8, 4, 8)) -> Tuple[bool, str]: page: Page,
font: Optional[Font] = None,
padding: Tuple[int,
int,
int,
int] = (4,
8,
4,
8)) -> Tuple[bool,
str]:
""" """
Layout a button within a given page and register it for callback binding. Layout a button within a given page and register it for callback binding.
@ -539,8 +576,12 @@ class DocumentLayouter:
style_resolver = StyleResolver(context) style_resolver = StyleResolver(context)
self.style_registry = ConcreteStyleRegistry(style_resolver) self.style_registry = ConcreteStyleRegistry(style_resolver)
def layout_paragraph(self, paragraph: Paragraph, start_word: int = 0, def layout_paragraph(self,
pretext: Optional[Text] = None) -> Tuple[bool, Optional[int], Optional[Text]]: paragraph: Paragraph,
start_word: int = 0,
pretext: Optional[Text] = None) -> Tuple[bool,
Optional[int],
Optional[Text]]:
""" """
Layout a paragraph using the paragraph_layouter. Layout a paragraph using the paragraph_layouter.
@ -582,8 +623,17 @@ class DocumentLayouter:
""" """
return table_layouter(table, self.page, style) return table_layouter(table, self.page, style)
def layout_button(self, button: Button, font: Optional[Font] = None, def layout_button(self,
padding: Tuple[int, int, int, int] = (4, 8, 4, 8)) -> Tuple[bool, str]: button: Button,
font: Optional[Font] = None,
padding: Tuple[int,
int,
int,
int] = (4,
8,
4,
8)) -> Tuple[bool,
str]:
""" """
Layout a button using the button_layouter. Layout a button using the button_layouter.
@ -612,7 +662,8 @@ class DocumentLayouter:
""" """
return form_layouter(form, self.page, font, field_spacing) return form_layouter(form, self.page, font, field_spacing)
def layout_document(self, elements: List[Union[Paragraph, AbstractImage, Table, Button, Form]]) -> bool: def layout_document(
self, elements: List[Union[Paragraph, AbstractImage, Table, Button, Form]]) -> bool:
""" """
Layout a list of abstract elements (paragraphs, images, tables, buttons, and forms). Layout a list of abstract elements (paragraphs, images, tables, buttons, and forms).

View File

@ -13,18 +13,12 @@ with features like:
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, asdict from dataclasses import dataclass, asdict
from typing import List, Dict, Tuple, Optional, Union, Generator, Any from typing import List, Dict, Tuple, Optional, Any
from enum import Enum
import json
import multiprocessing
from concurrent.futures import ProcessPoolExecutor, as_completed
import threading
import time
from pyWebLayout.abstract.block import Block, Paragraph, Heading, HeadingLevel, Table, HList from pyWebLayout.abstract.block import Block, Paragraph, Heading, HeadingLevel, Table, HList
from pyWebLayout.abstract.inline import Word from pyWebLayout.abstract.inline import Word
from pyWebLayout.concrete.page import Page from pyWebLayout.concrete.page import Page
from pyWebLayout.concrete.text import Line, Text from pyWebLayout.concrete.text import Text
from pyWebLayout.style.page_style import PageStyle from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.style import Font from pyWebLayout.style import Font
from pyWebLayout.layout.document_layouter import paragraph_layouter from pyWebLayout.layout.document_layouter import paragraph_layouter
@ -38,7 +32,8 @@ class RenderingPosition:
""" """
chapter_index: int = 0 # Which chapter (based on headings) chapter_index: int = 0 # Which chapter (based on headings)
block_index: int = 0 # Which block within chapter block_index: int = 0 # Which block within chapter
word_index: int = 0 # Which word within block (for paragraphs) # Which word within block (for paragraphs)
word_index: int = 0
table_row: int = 0 # Which row for tables table_row: int = 0 # Which row for tables
table_col: int = 0 # Which column for tables table_col: int = 0 # Which column for tables
list_item_index: int = 0 # Which item for lists list_item_index: int = 0 # Which item for lists
@ -72,7 +67,12 @@ class RenderingPosition:
class ChapterInfo: class ChapterInfo:
"""Information about a chapter/section in the document""" """Information about a chapter/section in the document"""
def __init__(self, title: str, level: HeadingLevel, position: RenderingPosition, block_index: int): def __init__(
self,
title: str,
level: HeadingLevel,
position: RenderingPosition,
block_index: int):
self.title = title self.title = title
self.level = level self.level = level
self.position = position self.position = position
@ -130,9 +130,11 @@ class ChapterNavigator:
words.append(word.text) words.append(word.text)
return " ".join(words) return " ".join(words)
def get_table_of_contents(self) -> List[Tuple[str, HeadingLevel, RenderingPosition]]: def get_table_of_contents(
self) -> List[Tuple[str, HeadingLevel, RenderingPosition]]:
"""Generate table of contents from heading structure""" """Generate table of contents from heading structure"""
return [(chapter.title, chapter.level, chapter.position) for chapter in self.chapters] return [(chapter.title, chapter.level, chapter.position)
for chapter in self.chapters]
def get_chapter_position(self, chapter_title: str) -> Optional[RenderingPosition]: def get_chapter_position(self, chapter_title: str) -> Optional[RenderingPosition]:
"""Get rendering position for a chapter by title""" """Get rendering position for a chapter by title"""
@ -195,7 +197,8 @@ class FontScaler:
) )
@staticmethod @staticmethod
def scale_word_spacing(spacing: Tuple[int, int], scale_factor: float) -> Tuple[int, int]: def scale_word_spacing(spacing: Tuple[int, int],
scale_factor: float) -> Tuple[int, int]:
"""Scale word spacing constraints proportionally""" """Scale word spacing constraints proportionally"""
if scale_factor == 1.0: if scale_factor == 1.0:
return spacing return spacing
@ -213,14 +216,21 @@ class BidirectionalLayouter:
Handles font scaling and maintains position state. Handles font scaling and maintains position state.
""" """
def __init__(self, blocks: List[Block], page_style: PageStyle, page_size: Tuple[int, int] = (800, 600), alignment_override=None): def __init__(self,
blocks: List[Block],
page_style: PageStyle,
page_size: Tuple[int,
int] = (800,
600),
alignment_override=None):
self.blocks = blocks self.blocks = blocks
self.page_style = page_style self.page_style = page_style
self.page_size = page_size self.page_size = page_size
self.chapter_navigator = ChapterNavigator(blocks) self.chapter_navigator = ChapterNavigator(blocks)
self.alignment_override = alignment_override self.alignment_override = alignment_override
def render_page_forward(self, position: RenderingPosition, font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]: def render_page_forward(self, position: RenderingPosition,
font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
""" """
Render a page starting from the given position, moving forward through the document. Render a page starting from the given position, moving forward through the document.
@ -246,7 +256,8 @@ class BidirectionalLayouter:
scaled_block = self._scale_block_fonts(block, font_scale) scaled_block = self._scale_block_fonts(block, font_scale)
# Try to fit the block on the current page # Try to fit the block on the current page
success, new_pos = self._layout_block_on_page(scaled_block, page, current_pos, font_scale) success, new_pos = self._layout_block_on_page(
scaled_block, page, current_pos, font_scale)
if not success: if not success:
# Block doesn't fit, we're done with this page # Block doesn't fit, we're done with this page
@ -267,7 +278,10 @@ class BidirectionalLayouter:
return page, current_pos return page, current_pos
def render_page_backward(self, end_position: RenderingPosition, font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]: def render_page_backward(self,
end_position: RenderingPosition,
font_scale: float = 1.0) -> Tuple[Page,
RenderingPosition]:
""" """
Render a page that ends at the given position, filling backward. Render a page that ends at the given position, filling backward.
Critical for "previous page" navigation. Critical for "previous page" navigation.
@ -288,10 +302,12 @@ class BidirectionalLayouter:
page, actual_end = self.render_page_forward(estimated_start, font_scale) page, actual_end = self.render_page_forward(estimated_start, font_scale)
# If we overshot or undershot, adjust and try again # If we overshot or undershot, adjust and try again
# This is a simplified implementation - a full version would be more sophisticated # This is a simplified implementation - a full version would be more
# sophisticated
if self._position_compare(actual_end, end_position) != 0: if self._position_compare(actual_end, end_position) != 0:
# Adjust estimate and try again (simplified) # Adjust estimate and try again (simplified)
estimated_start = self._adjust_start_estimate(estimated_start, end_position, actual_end) estimated_start = self._adjust_start_estimate(
estimated_start, end_position, actual_end)
page, actual_end = self.render_page_forward(estimated_start, font_scale) page, actual_end = self.render_page_forward(estimated_start, font_scale)
return page, estimated_start return page, estimated_start
@ -313,13 +329,20 @@ class BidirectionalLayouter:
# words_iter() returns tuples of (position, word) # words_iter() returns tuples of (position, word)
for position, word in block.words_iter(): for position, word in block.words_iter():
if isinstance(word, Word): if isinstance(word, Word):
scaled_word = Word(word.text, FontScaler.scale_font(word.style, font_scale)) scaled_word = Word(
word.text, FontScaler.scale_font(
word.style, font_scale))
scaled_block.add_word(scaled_word) scaled_block.add_word(scaled_word)
return scaled_block return scaled_block
return block return block
def _layout_block_on_page(self, block: Block, page: Page, position: RenderingPosition, font_scale: float) -> Tuple[bool, RenderingPosition]: def _layout_block_on_page(self,
block: Block,
page: Page,
position: RenderingPosition,
font_scale: float) -> Tuple[bool,
RenderingPosition]:
""" """
Try to layout a block on the page starting from the given position. Try to layout a block on the page starting from the given position.
@ -340,7 +363,12 @@ class BidirectionalLayouter:
new_pos.block_index += 1 new_pos.block_index += 1
return True, new_pos return True, new_pos
def _layout_paragraph_on_page(self, paragraph: Paragraph, page: Page, position: RenderingPosition, font_scale: float) -> Tuple[bool, RenderingPosition]: def _layout_paragraph_on_page(self,
paragraph: Paragraph,
page: Page,
position: RenderingPosition,
font_scale: float) -> Tuple[bool,
RenderingPosition]:
""" """
Layout a paragraph on the page using the core paragraph_layouter. Layout a paragraph on the page using the core paragraph_layouter.
Integrates font scaling and position tracking with the proven layout logic. Integrates font scaling and position tracking with the proven layout logic.
@ -402,12 +430,22 @@ class BidirectionalLayouter:
# This shouldn't normally happen, but handle it gracefully # This shouldn't normally happen, but handle it gracefully
return False, position return False, position
def _layout_heading_on_page(self, heading: Heading, page: Page, position: RenderingPosition, font_scale: float) -> Tuple[bool, RenderingPosition]: def _layout_heading_on_page(self,
heading: Heading,
page: Page,
position: RenderingPosition,
font_scale: float) -> Tuple[bool,
RenderingPosition]:
"""Layout a heading on the page""" """Layout a heading on the page"""
# Similar to paragraph but with heading-specific styling # Similar to paragraph but with heading-specific styling
return self._layout_paragraph_on_page(heading, page, position, font_scale) return self._layout_paragraph_on_page(heading, page, position, font_scale)
def _layout_table_on_page(self, table: Table, page: Page, position: RenderingPosition, font_scale: float) -> Tuple[bool, RenderingPosition]: def _layout_table_on_page(self,
table: Table,
page: Page,
position: RenderingPosition,
font_scale: float) -> Tuple[bool,
RenderingPosition]:
"""Layout a table on the page with column fitting and row continuation""" """Layout a table on the page with column fitting and row continuation"""
# This is a complex operation that would need full table layout logic # This is a complex operation that would need full table layout logic
# For now, skip tables # For now, skip tables
@ -417,7 +455,12 @@ class BidirectionalLayouter:
new_pos.table_col = 0 new_pos.table_col = 0
return True, new_pos return True, new_pos
def _layout_list_on_page(self, hlist: HList, page: Page, position: RenderingPosition, font_scale: float) -> Tuple[bool, RenderingPosition]: def _layout_list_on_page(self,
hlist: HList,
page: Page,
position: RenderingPosition,
font_scale: float) -> Tuple[bool,
RenderingPosition]:
"""Layout a list on the page""" """Layout a list on the page"""
# This would need list-specific layout logic # This would need list-specific layout logic
# For now, skip lists # For now, skip lists
@ -426,19 +469,28 @@ class BidirectionalLayouter:
new_pos.list_item_index = 0 new_pos.list_item_index = 0
return True, new_pos return True, new_pos
def _estimate_page_start(self, end_position: RenderingPosition, font_scale: float) -> RenderingPosition: def _estimate_page_start(
self,
end_position: RenderingPosition,
font_scale: float) -> RenderingPosition:
"""Estimate where a page should start to end at the given position""" """Estimate where a page should start to end at the given position"""
# This is a simplified heuristic - a full implementation would be more sophisticated # This is a simplified heuristic - a full implementation would be more
# sophisticated
estimated_start = end_position.copy() estimated_start = end_position.copy()
# Move back by an estimated number of blocks that would fit on a page # Move back by an estimated number of blocks that would fit on a page
estimated_blocks_per_page = max(1, int(10 / font_scale)) # Rough estimate estimated_blocks_per_page = max(1, int(10 / font_scale)) # Rough estimate
estimated_start.block_index = max(0, end_position.block_index - estimated_blocks_per_page) estimated_start.block_index = max(
0, end_position.block_index - estimated_blocks_per_page)
estimated_start.word_index = 0 estimated_start.word_index = 0
return estimated_start return estimated_start
def _adjust_start_estimate(self, current_start: RenderingPosition, target_end: RenderingPosition, actual_end: RenderingPosition) -> RenderingPosition: def _adjust_start_estimate(
self,
current_start: RenderingPosition,
target_end: RenderingPosition,
actual_end: RenderingPosition) -> RenderingPosition:
"""Adjust start position estimate based on overshoot/undershoot""" """Adjust start position estimate based on overshoot/undershoot"""
# Simplified adjustment logic # Simplified adjustment logic
adjusted = current_start.copy() adjusted = current_start.copy()
@ -451,7 +503,8 @@ class BidirectionalLayouter:
return adjusted return adjusted
def _position_compare(self, pos1: RenderingPosition, pos2: RenderingPosition) -> int: def _position_compare(self, pos1: RenderingPosition,
pos2: RenderingPosition) -> int:
"""Compare two positions (-1: pos1 < pos2, 0: equal, 1: pos1 > pos2)""" """Compare two positions (-1: pos1 < pos2, 0: equal, 1: pos1 > pos2)"""
if pos1.chapter_index != pos2.chapter_index: if pos1.chapter_index != pos2.chapter_index:
return 1 if pos1.chapter_index > pos2.chapter_index else -1 return 1 if pos1.chapter_index > pos2.chapter_index else -1
@ -481,5 +534,6 @@ def _add_page_methods():
Page.available_width = available_width Page.available_width = available_width
# Apply the page methods # Apply the page methods
_add_page_methods() _add_page_methods()

View File

@ -9,7 +9,6 @@ into a unified, easy-to-use API.
from __future__ import annotations from __future__ import annotations
from typing import List, Dict, Optional, Tuple, Any, Callable from typing import List, Dict, Optional, Tuple, Any, Callable
import json import json
import os
from pathlib import Path from pathlib import Path
from .ereader_layout import RenderingPosition, ChapterNavigator, ChapterInfo from .ereader_layout import RenderingPosition, ChapterNavigator, ChapterInfo
@ -201,14 +200,18 @@ class EreaderLayoutManager:
self.current_position = saved_position self.current_position = saved_position
# Callbacks for UI updates # Callbacks for UI updates
self.position_changed_callback: Optional[Callable[[RenderingPosition], None]] = None self.position_changed_callback: Optional[Callable[[
self.chapter_changed_callback: Optional[Callable[[Optional[ChapterInfo]], None]] = None RenderingPosition], None]] = None
self.chapter_changed_callback: Optional[Callable[[
Optional[ChapterInfo]], None]] = None
def set_position_changed_callback(self, callback: Callable[[RenderingPosition], None]): def set_position_changed_callback(
self, callback: Callable[[RenderingPosition], None]):
"""Set callback for position changes""" """Set callback for position changes"""
self.position_changed_callback = callback self.position_changed_callback = callback
def set_chapter_changed_callback(self, callback: Callable[[Optional[ChapterInfo]], None]): def set_chapter_changed_callback(
self, callback: Callable[[Optional[ChapterInfo]], None]):
"""Set callback for chapter changes""" """Set callback for chapter changes"""
self.chapter_changed_callback = callback self.chapter_changed_callback = callback
@ -218,7 +221,8 @@ class EreaderLayoutManager:
self.position_changed_callback(self.current_position) self.position_changed_callback(self.current_position)
# Check if chapter changed # Check if chapter changed
current_chapter = self.chapter_navigator.get_current_chapter(self.current_position) current_chapter = self.chapter_navigator.get_current_chapter(
self.current_position)
if self.chapter_changed_callback: if self.chapter_changed_callback:
self.chapter_changed_callback(current_chapter) self.chapter_changed_callback(current_chapter)
@ -242,7 +246,8 @@ class EreaderLayoutManager:
Returns: Returns:
Next page or None if at end of document Next page or None if at end of document
""" """
page, next_position = self.renderer.render_page(self.current_position, self.font_scale) page, next_position = self.renderer.render_page(
self.current_position, self.font_scale)
# Check if we made progress # Check if we made progress
if next_position != self.current_position: if next_position != self.current_position:
@ -263,7 +268,8 @@ class EreaderLayoutManager:
return None return None
# Use backward rendering to find the previous page # Use backward rendering to find the previous page
page, start_position = self.renderer.render_page_backward(self.current_position, self.font_scale) page, start_position = self.renderer.render_page_backward(
self.current_position, self.font_scale)
if start_position != self.current_position: if start_position != self.current_position:
self.current_position = start_position self.current_position = start_position
@ -397,7 +403,8 @@ class EreaderLayoutManager:
Returns: Returns:
Re-rendered page with decreased block spacing Re-rendered page with decreased block spacing
""" """
self.page_style.inter_block_spacing = max(0, self.page_style.inter_block_spacing - amount) self.page_style.inter_block_spacing = max(
0, self.page_style.inter_block_spacing - amount)
self.renderer.page_style = self.page_style # Update renderer's reference self.renderer.page_style = self.page_style # Update renderer's reference
self.renderer.buffer.invalidate_all() # Clear cache to force re-render self.renderer.buffer.invalidate_all() # Clear cache to force re-render
return self.get_current_page() return self.get_current_page()
@ -432,7 +439,8 @@ class EreaderLayoutManager:
self.renderer.buffer.invalidate_all() # Clear cache to force re-render self.renderer.buffer.invalidate_all() # Clear cache to force re-render
return self.get_current_page() return self.get_current_page()
def get_table_of_contents(self) -> List[Tuple[str, HeadingLevel, RenderingPosition]]: def get_table_of_contents(
self) -> List[Tuple[str, HeadingLevel, RenderingPosition]]:
""" """
Get the table of contents. Get the table of contents.

View File

@ -8,12 +8,9 @@ multiprocessing to achieve sub-second page navigation performance.
from __future__ import annotations from __future__ import annotations
from typing import Dict, Optional, List, Tuple, Any from typing import Dict, Optional, List, Tuple, Any
from collections import OrderedDict from collections import OrderedDict
import multiprocessing from concurrent.futures import ProcessPoolExecutor, Future
from concurrent.futures import ProcessPoolExecutor, as_completed, Future
import threading import threading
import time
import pickle import pickle
from dataclasses import asdict
from .ereader_layout import RenderingPosition, BidirectionalLayouter from .ereader_layout import RenderingPosition, BidirectionalLayouter
from pyWebLayout.concrete.page import Page from pyWebLayout.concrete.page import Page
@ -21,7 +18,13 @@ from pyWebLayout.abstract.block import Block
from pyWebLayout.style.page_style import PageStyle from pyWebLayout.style.page_style import PageStyle
def _render_page_worker(args: Tuple[List[Block], PageStyle, RenderingPosition, float, bool]) -> Tuple[RenderingPosition, bytes, RenderingPosition]: def _render_page_worker(args: Tuple[List[Block],
PageStyle,
RenderingPosition,
float,
bool]) -> Tuple[RenderingPosition,
bytes,
RenderingPosition]:
""" """
Worker function for multiprocess page rendering. Worker function for multiprocess page rendering.
@ -68,8 +71,10 @@ class PageBuffer:
self.backward_buffer: OrderedDict[RenderingPosition, Page] = OrderedDict() self.backward_buffer: OrderedDict[RenderingPosition, Page] = OrderedDict()
# Position tracking for next/previous positions # Position tracking for next/previous positions
self.position_map: Dict[RenderingPosition, RenderingPosition] = {} # current -> next self.position_map: Dict[RenderingPosition,
self.reverse_position_map: Dict[RenderingPosition, RenderingPosition] = {} # current -> previous RenderingPosition] = {} # current -> next
self.reverse_position_map: Dict[RenderingPosition,
RenderingPosition] = {} # current -> previous
# Background rendering # Background rendering
self.executor: Optional[ProcessPoolExecutor] = None self.executor: Optional[ProcessPoolExecutor] = None
@ -81,7 +86,11 @@ class PageBuffer:
self.page_style: Optional[PageStyle] = None self.page_style: Optional[PageStyle] = None
self.current_font_scale: float = 1.0 self.current_font_scale: float = 1.0
def initialize(self, blocks: List[Block], page_style: PageStyle, font_scale: float = 1.0): def initialize(
self,
blocks: List[Block],
page_style: PageStyle,
font_scale: float = 1.0):
""" """
Initialize the buffer with document blocks and page style. Initialize the buffer with document blocks and page style.
@ -124,7 +133,12 @@ class PageBuffer:
return None return None
def cache_page(self, position: RenderingPosition, page: Page, next_position: Optional[RenderingPosition] = None, is_backward: bool = False): def cache_page(
self,
position: RenderingPosition,
page: Page,
next_position: Optional[RenderingPosition] = None,
is_backward: bool = False):
""" """
Cache a rendered page with LRU eviction. Cache a rendered page with LRU eviction.
@ -153,7 +167,10 @@ class PageBuffer:
self.position_map.pop(oldest_pos, None) self.position_map.pop(oldest_pos, None)
self.reverse_position_map.pop(oldest_pos, None) self.reverse_position_map.pop(oldest_pos, None)
def start_background_rendering(self, current_position: RenderingPosition, direction: str = 'forward'): def start_background_rendering(
self,
current_position: RenderingPosition,
direction: str = 'forward'):
""" """
Start background rendering of upcoming pages. Start background rendering of upcoming pages.
@ -185,11 +202,17 @@ class PageBuffer:
continue continue
# Queue render job # Queue render job
args = (self.blocks, self.page_style, current_pos, self.current_font_scale, False) args = (
self.blocks,
self.page_style,
current_pos,
self.current_font_scale,
False)
future = self.executor.submit(_render_page_worker, args) future = self.executor.submit(_render_page_worker, args)
self.pending_renders[current_pos] = future self.pending_renders[current_pos] = future
# We don't know the next position yet, so we'll update it when the render completes # We don't know the next position yet, so we'll update it when the render
# completes
break break
def _queue_backward_renders(self, start_position: RenderingPosition): def _queue_backward_renders(self, start_position: RenderingPosition):
@ -206,11 +229,17 @@ class PageBuffer:
continue continue
# Queue render job # Queue render job
args = (self.blocks, self.page_style, current_pos, self.current_font_scale, True) args = (
self.blocks,
self.page_style,
current_pos,
self.current_font_scale,
True)
future = self.executor.submit(_render_page_worker, args) future = self.executor.submit(_render_page_worker, args)
self.pending_renders[current_pos] = future self.pending_renders[current_pos] = future
# We don't know the previous position yet, so we'll update it when the render completes # We don't know the previous position yet, so we'll update it when the
# render completes
break break
def check_completed_renders(self): def check_completed_renders(self):
@ -303,7 +332,13 @@ class BufferedPageRenderer:
High-level interface for buffered page rendering with automatic background caching. High-level interface for buffered page rendering with automatic background caching.
""" """
def __init__(self, blocks: List[Block], page_style: PageStyle, buffer_size: int = 5, page_size: Tuple[int, int] = (800, 600)): def __init__(self,
blocks: List[Block],
page_style: PageStyle,
buffer_size: int = 5,
page_size: Tuple[int,
int] = (800,
600)):
""" """
Initialize the buffered renderer. Initialize the buffered renderer.
@ -320,7 +355,8 @@ class BufferedPageRenderer:
self.current_position = RenderingPosition() self.current_position = RenderingPosition()
self.font_scale = 1.0 self.font_scale = 1.0
def render_page(self, position: RenderingPosition, font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]: def render_page(self, position: RenderingPosition,
font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
""" """
Render a page with intelligent caching. Render a page with intelligent caching.
@ -361,7 +397,10 @@ class BufferedPageRenderer:
return page, next_pos return page, next_pos
def render_page_backward(self, end_position: RenderingPosition, font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]: def render_page_backward(self,
end_position: RenderingPosition,
font_scale: float = 1.0) -> Tuple[Page,
RenderingPosition]:
""" """
Render a page ending at the given position with intelligent caching. Render a page ending at the given position with intelligent caching.

View File

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

View File

@ -6,6 +6,7 @@ rendering parameters, allowing for flexible interpretation by different
rendering systems and user preferences. rendering systems and user preferences.
""" """
from .alignment import Alignment
from typing import Dict, Optional, Tuple, Union from typing import Dict, Optional, Tuple, Union
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
@ -50,7 +51,6 @@ class FontSize(Enum):
# Import Alignment from the centralized location # Import Alignment from the centralized location
from .alignment import Alignment
# Use Alignment for text alignment # Use Alignment for text alignment
TextAlign = Alignment TextAlign = Alignment
@ -192,7 +192,8 @@ class AbstractStyleRegistry:
def __init__(self): def __init__(self):
"""Initialize an empty abstract style registry.""" """Initialize an empty abstract style registry."""
self._styles: Dict[str, AbstractStyle] = {} self._styles: Dict[str, AbstractStyle] = {}
self._style_to_id: Dict[AbstractStyle, str] = {} # Reverse mapping using hashable styles # Reverse mapping using hashable styles
self._style_to_id: Dict[AbstractStyle, str] = {}
self._next_id = 1 self._next_id = 1
# Create and register the default style # Create and register the default style
@ -229,7 +230,10 @@ class AbstractStyleRegistry:
""" """
return self._style_to_id.get(style) return self._style_to_id.get(style)
def register_style(self, style: AbstractStyle, style_id: Optional[str] = None) -> str: def register_style(
self,
style: AbstractStyle,
style_id: Optional[str] = None) -> str:
""" """
Register a style in the registry. Register a style in the registry.
@ -288,7 +292,8 @@ class AbstractStyleRegistry:
"""Get a style by its ID.""" """Get a style by its ID."""
return self._styles.get(style_id) return self._styles.get(style_id)
def create_derived_style(self, base_style_id: str, **modifications) -> Tuple[str, AbstractStyle]: def create_derived_style(self, base_style_id: str, **
modifications) -> Tuple[str, AbstractStyle]:
""" """
Create a new style derived from a base style. Create a new style derived from a base style.

View File

@ -6,6 +6,7 @@ This module provides alignment-related functionality.
from enum import Enum from enum import Enum
class Alignment(Enum): class Alignment(Enum):
"""Text and box alignment options""" """Text and box alignment options"""
# Horizontal alignment # Horizontal alignment

View File

@ -5,12 +5,11 @@ This module converts abstract styles to concrete rendering parameters based on
user preferences, device capabilities, and rendering context. user preferences, device capabilities, and rendering context.
""" """
from typing import Dict, Optional, Tuple, Union, Any from typing import Dict, Optional, Tuple, Union
from dataclasses import dataclass from dataclasses import dataclass
from .abstract_style import AbstractStyle, FontFamily, FontSize from .abstract_style import AbstractStyle, FontFamily, FontSize
from pyWebLayout.style.alignment import Alignment as TextAlign from pyWebLayout.style.alignment import Alignment as TextAlign
from .fonts import Font, FontWeight, FontStyle, TextDecoration from .fonts import Font, FontWeight, FontStyle, TextDecoration
import os
@dataclass(frozen=True) @dataclass(frozen=True)
@ -162,12 +161,17 @@ class StyleResolver:
# Ensure font_size is always an int before using in arithmetic # Ensure font_size is always an int before using in arithmetic
font_size = int(font_size) font_size = int(font_size)
color = self._resolve_color(abstract_style.color) color = self._resolve_color(abstract_style.color)
background_color = self._resolve_background_color(abstract_style.background_color) background_color = self._resolve_background_color(
abstract_style.background_color)
line_height = self._resolve_line_height(abstract_style.line_height) line_height = self._resolve_line_height(abstract_style.line_height)
letter_spacing = self._resolve_letter_spacing(abstract_style.letter_spacing, font_size) letter_spacing = self._resolve_letter_spacing(
word_spacing = self._resolve_word_spacing(abstract_style.word_spacing, font_size) abstract_style.letter_spacing, font_size)
word_spacing_min = self._resolve_word_spacing(abstract_style.word_spacing_min, font_size) word_spacing = self._resolve_word_spacing(
word_spacing_max = self._resolve_word_spacing(abstract_style.word_spacing_max, font_size) abstract_style.word_spacing, font_size)
word_spacing_min = self._resolve_word_spacing(
abstract_style.word_spacing_min, font_size)
word_spacing_max = self._resolve_word_spacing(
abstract_style.word_spacing_max, font_size)
min_hyphenation_width = max(int(font_size) * 4, 32) # At least 32 pixels min_hyphenation_width = max(int(font_size) * 4, 32) # At least 32 pixels
# Apply default logic for word spacing constraints # Apply default logic for word spacing constraints
@ -251,7 +255,8 @@ class StyleResolver:
# Ensure we always return an int, minimum 8pt font # Ensure we always return an int, minimum 8pt font
return max(int(final_size), 8) return max(int(final_size), 8)
def _resolve_color(self, color: Union[str, Tuple[int, int, int]]) -> Tuple[int, int, int]: def _resolve_color(
self, color: Union[str, Tuple[int, int, int]]) -> Tuple[int, int, int]:
"""Resolve color to RGB tuple.""" """Resolve color to RGB tuple."""
if isinstance(color, tuple): if isinstance(color, tuple):
return color return color
@ -293,7 +298,15 @@ class StyleResolver:
return (0, 0, 0) # Fallback to black return (0, 0, 0) # Fallback to black
def _resolve_background_color(self, bg_color: Optional[Union[str, Tuple[int, int, int, int]]]) -> Optional[Tuple[int, int, int, int]]: def _resolve_background_color(self,
bg_color: Optional[Union[str,
Tuple[int,
int,
int,
int]]]) -> Optional[Tuple[int,
int,
int,
int]]:
"""Resolve background color to RGBA tuple or None.""" """Resolve background color to RGBA tuple or None."""
if bg_color is None: if bg_color is None:
return None return None
@ -330,7 +343,8 @@ class StyleResolver:
return 1.2 return 1.2
def _resolve_letter_spacing(self, letter_spacing: Optional[Union[str, float]], font_size: int) -> float: def _resolve_letter_spacing(
self, letter_spacing: Optional[Union[str, float]], font_size: int) -> float:
"""Resolve letter spacing to pixels.""" """Resolve letter spacing to pixels."""
if letter_spacing is None or letter_spacing == "normal": if letter_spacing is None or letter_spacing == "normal":
return 0.0 return 0.0
@ -353,7 +367,8 @@ class StyleResolver:
return 0.0 return 0.0
def _resolve_word_spacing(self, word_spacing: Optional[Union[str, float]], font_size: int) -> float: def _resolve_word_spacing(
self, word_spacing: Optional[Union[str, float]], font_size: int) -> float:
"""Resolve word spacing to pixels.""" """Resolve word spacing to pixels."""
if word_spacing is None or word_spacing == "normal": if word_spacing is None or word_spacing == "normal":
return 0.0 return 0.0

View File

@ -1,7 +1,8 @@
# this should contain classes for how different object can be rendered, e.g. bold, italic, regular # this should contain classes for how different object can be rendered,
# e.g. bold, italic, regular
from PIL import ImageFont from PIL import ImageFont
from enum import Enum from enum import Enum
from typing import Tuple, Union, Optional from typing import Tuple, Optional
import os import os
import logging import logging
@ -79,7 +80,9 @@ class Font:
logger.debug(f"Font loading: current_dir = {current_dir}") logger.debug(f"Font loading: current_dir = {current_dir}")
logger.debug(f"Font loading: assets_dir = {assets_dir}") logger.debug(f"Font loading: assets_dir = {assets_dir}")
logger.debug(f"Font loading: bundled_font_path = {bundled_font_path}") logger.debug(f"Font loading: bundled_font_path = {bundled_font_path}")
logger.debug(f"Font loading: bundled font exists = {os.path.exists(bundled_font_path)}") logger.debug(
f"Font loading: bundled font exists = {
os.path.exists(bundled_font_path)}")
if os.path.exists(bundled_font_path): if os.path.exists(bundled_font_path):
logger.info(f"Found bundled font at: {bundled_font_path}") logger.info(f"Found bundled font at: {bundled_font_path}")
@ -106,10 +109,14 @@ class Font:
if bundled_font_path: if bundled_font_path:
logger.info(f"Loading bundled font from: {bundled_font_path}") logger.info(f"Loading bundled font from: {bundled_font_path}")
self._font = ImageFont.truetype(bundled_font_path, self._font_size) self._font = ImageFont.truetype(bundled_font_path, self._font_size)
logger.info(f"Successfully loaded bundled font at size {self._font_size}") logger.info(
f"Successfully loaded bundled font at size {
self._font_size}")
else: else:
# Only fall back to PIL's default font if bundled font is not available # Only fall back to PIL's default font if bundled font is not
logger.warning(f"Bundled font not available, falling back to PIL default font") # available
logger.warning(
"Bundled font not available, falling back to PIL default font")
self._font = ImageFont.load_default() self._font = ImageFont.load_default()
except Exception as e: except Exception as e:

View File

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

View File

@ -5,15 +5,16 @@ Debug script to test text positioning in the line breaking system
import sys import sys
from pathlib import Path from pathlib import Path
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw
# Add pyWebLayout to path
sys.path.insert(0, str(Path(__file__).parent))
from pyWebLayout.style import Font from pyWebLayout.style import Font
from pyWebLayout.concrete.text import Text, Line from pyWebLayout.concrete.text import Text, Line
from pyWebLayout.style.layout import Alignment from pyWebLayout.style.layout import Alignment
# Add pyWebLayout to path
sys.path.insert(0, str(Path(__file__).parent))
def test_simple_text_rendering(): def test_simple_text_rendering():
"""Test basic text rendering to debug positioning issues""" """Test basic text rendering to debug positioning issues"""
@ -70,5 +71,6 @@ def test_simple_text_rendering():
image.save("debug_text_positioning.png") image.save("debug_text_positioning.png")
print("Debug image saved as debug_text_positioning.png") print("Debug image saved as debug_text_positioning.png")
if __name__ == "__main__": if __name__ == "__main__":
test_simple_text_rendering() test_simple_text_rendering()

View File

@ -15,7 +15,7 @@ import os
import sys import sys
import argparse import argparse
from pathlib import Path from pathlib import Path
from typing import Optional, List from typing import List
# Add the parent directory to sys.path to import pyWebLayout # Add the parent directory to sys.path to import pyWebLayout
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))
@ -23,10 +23,8 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
try: try:
from pyWebLayout.io.readers.epub_reader import read_epub from pyWebLayout.io.readers.epub_reader import read_epub
from pyWebLayout.layout.ereader_layout import BidirectionalLayouter, RenderingPosition from pyWebLayout.layout.ereader_layout import BidirectionalLayouter, RenderingPosition
from pyWebLayout.layout.document_layouter import paragraph_layouter
from pyWebLayout.concrete.page import Page from pyWebLayout.concrete.page import Page
from pyWebLayout.style.page_style import PageStyle from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.style.fonts import Font
from pyWebLayout.abstract.block import Block from pyWebLayout.abstract.block import Block
from PIL import Image, ImageDraw from PIL import Image, ImageDraw
except ImportError as e: except ImportError as e:
@ -77,7 +75,7 @@ def extract_text_from_page(page: Page) -> str:
String containing the page's text content String containing the page's text content
""" """
text_lines = [] text_lines = []
text_lines.append(f"=== PAGE CONTENT ===") text_lines.append("=== PAGE CONTENT ===")
text_lines.append("") text_lines.append("")
try: try:
@ -115,7 +113,7 @@ def extract_text_from_page(page: Page) -> str:
item_text = extract_text_from_paragraph(item) item_text = extract_text_from_paragraph(item)
if item_text: if item_text:
text_lines.append(f"{indent} - {item_text}") text_lines.append(f"{indent} - {item_text}")
except: except Exception:
text_lines.append(f"{indent} (List content extraction failed)") text_lines.append(f"{indent} (List content extraction failed)")
elif isinstance(element, Table): elif isinstance(element, Table):
@ -310,7 +308,6 @@ Examples:
return 1 return 1
# Apply alignment setting to all paragraphs and headings # Apply alignment setting to all paragraphs and headings
from pyWebLayout.abstract.block import Paragraph, Heading
from pyWebLayout.style.alignment import Alignment from pyWebLayout.style.alignment import Alignment
alignment = Alignment.JUSTIFY if args.align == 'justify' else Alignment.LEFT alignment = Alignment.JUSTIFY if args.align == 'justify' else Alignment.LEFT

View File

@ -23,14 +23,12 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
try: try:
from pyWebLayout.io.readers.epub_reader import read_epub from pyWebLayout.io.readers.epub_reader import read_epub
from pyWebLayout.layout.document_layouter import DocumentLayouter, paragraph_layouter, image_layouter, pagebreak_layouter from pyWebLayout.layout.document_layouter import paragraph_layouter, image_layouter, pagebreak_layouter
from pyWebLayout.concrete.page import Page from pyWebLayout.concrete.page import Page
from pyWebLayout.style.page_style import PageStyle from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.style.fonts import Font
from pyWebLayout.style.alignment import Alignment from pyWebLayout.style.alignment import Alignment
from pyWebLayout.abstract.block import Block, Paragraph, Heading, HList, Table, Image as AbstractImage, PageBreak from pyWebLayout.abstract.block import Block, Paragraph, Heading, HList, Table, Image as AbstractImage, PageBreak
from pyWebLayout.style.concrete_style import RenderingContext, StyleResolver from pyWebLayout.style.concrete_style import RenderingContext
from PIL import Image, ImageDraw
except ImportError as e: except ImportError as e:
print(f"Error importing required modules: {e}") print(f"Error importing required modules: {e}")
print("Make sure pyWebLayout is properly installed and PIL is available") print("Make sure pyWebLayout is properly installed and PIL is available")
@ -99,7 +97,7 @@ def layout_blocks_on_pages(blocks: List[Block], page_style: PageStyle,
continuation_pretext = None continuation_pretext = None
# Create rendering context # Create rendering context
rendering_context = RenderingContext(base_font_size=16) _rendering_context = RenderingContext(base_font_size=16)
while current_block_index < len(blocks) and len(pages) < max_pages: while current_block_index < len(blocks) and len(pages) < max_pages:
# Create a new page # Create a new page
@ -194,7 +192,7 @@ def layout_blocks_on_pages(blocks: List[Block], page_style: PageStyle,
elif isinstance(block, Table): elif isinstance(block, Table):
# Skip tables for now (not implemented) # Skip tables for now (not implemented)
print(f"Warning: Skipping table (not yet implemented)") print("Warning: Skipping table (not yet implemented)")
current_block_index += 1 current_block_index += 1
else: else:

View File

@ -5,7 +5,6 @@ Runs test and documentation coverage locally and generates badges.
""" """
import subprocess import subprocess
import sys
import os import os
@ -53,7 +52,10 @@ def main():
# Run tests with coverage # Run tests with coverage
print("\n2. Running tests with coverage...") print("\n2. Running tests with coverage...")
test_cmd = "python -m pytest tests/ -v --cov=pyWebLayout --cov-report=term-missing --cov-report=json --cov-report=html --cov-report=xml" test_cmd = (
"python -m pytest tests/ -v --cov=pyWebLayout "
"--cov-report=term-missing --cov-report=json --cov-report=html --cov-report=xml"
)
run_command(test_cmd, "Running tests with coverage") run_command(test_cmd, "Running tests with coverage")
# Generate test coverage badge # Generate test coverage badge
@ -62,7 +64,11 @@ def main():
# Check documentation coverage # Check documentation coverage
print("\n4. Checking documentation coverage...") print("\n4. Checking documentation coverage...")
docs_cmd = "interrogate -v --ignore-init-method --ignore-init-module --ignore-magic --ignore-private --ignore-property-decorators --ignore-semiprivate --fail-under=80 --generate-badge coverage-docs.svg pyWebLayout/" docs_cmd = (
"interrogate -v --ignore-init-method --ignore-init-module --ignore-magic "
"--ignore-private --ignore-property-decorators --ignore-semiprivate "
"--fail-under=80 --generate-badge coverage-docs.svg pyWebLayout/"
)
run_command(docs_cmd, "Checking documentation coverage") run_command(docs_cmd, "Checking documentation coverage")
# Generate coverage summary # Generate coverage summary
@ -93,7 +99,7 @@ else:
with open('temp_coverage_summary.py', 'w') as f: with open('temp_coverage_summary.py', 'w') as f:
f.write(summary_script_content) f.write(summary_script_content)
success = run_command("python temp_coverage_summary.py", "Generating coverage summary") run_command("python temp_coverage_summary.py", "Generating coverage summary")
# Clean up temporary script # Clean up temporary script
if os.path.exists('temp_coverage_summary.py'): if os.path.exists('temp_coverage_summary.py'):

View File

@ -29,7 +29,7 @@ def main():
] ]
print(f"Running: {' '.join(cmd)}") print(f"Running: {' '.join(cmd)}")
result = subprocess.run(cmd, check=True) _result = subprocess.run(cmd, check=True)
# Check if coverage.xml was created # Check if coverage.xml was created
if os.path.exists("coverage.xml"): if os.path.exists("coverage.xml"):

View File

@ -68,7 +68,9 @@ def show_coverage_summary():
with open("coverage.json", 'r') as f: with open("coverage.json", 'r') as f:
coverage_data = json.load(f) coverage_data = json.load(f)
print(f"Detailed Coverage: {coverage_data['totals']['percent_covered']:.1f}%") print(f"Detailed Coverage: {coverage_data['totals']['percent_covered']:.1f}%")
print(f"Lines Covered: {coverage_data['totals']['covered_lines']}/{coverage_data['totals']['num_statements']}") covered = coverage_data['totals']['covered_lines']
total = coverage_data['totals']['num_statements']
print(f"Lines Covered: {covered}/{total}")
except (KeyError, json.JSONDecodeError): except (KeyError, json.JSONDecodeError):
print("Could not parse coverage data") print("Could not parse coverage data")

View File

@ -12,9 +12,9 @@ import threading
import time import time
from PIL import Image as PILImage from PIL import Image as PILImage
from pyWebLayout.abstract.block import ( from pyWebLayout.abstract.block import (
Block, BlockType, Paragraph, Heading, HeadingLevel, Quote, CodeBlock, BlockType, Paragraph, Heading, HeadingLevel, Quote, CodeBlock, HList,
HList, ListStyle, ListItem, Table, TableRow, TableCell, ListStyle, ListItem, Table, TableRow, TableCell, HorizontalRule,
HorizontalRule, Image Image
) )
from pyWebLayout.abstract.inline import Word, LineBreak from pyWebLayout.abstract.inline import Word, LineBreak
from pyWebLayout.style import Font from pyWebLayout.style import Font

View File

@ -8,7 +8,7 @@ document structure and metadata management.
import unittest import unittest
from pyWebLayout.abstract.document import Document, Chapter, Book, MetadataType from pyWebLayout.abstract.document import Document, Chapter, Book, MetadataType
from pyWebLayout.abstract.block import Paragraph, Heading, HeadingLevel, BlockType from pyWebLayout.abstract.block import Paragraph, Heading, HeadingLevel, BlockType
from pyWebLayout.abstract.inline import Word, FormattedSpan from pyWebLayout.abstract.inline import Word
from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration
@ -59,8 +59,14 @@ class TestDocument(unittest.TestCase):
# Test retrieval # Test retrieval
self.assertEqual(self.doc.get_metadata(MetadataType.AUTHOR), "John Doe") self.assertEqual(self.doc.get_metadata(MetadataType.AUTHOR), "John Doe")
self.assertEqual(self.doc.get_metadata(MetadataType.DESCRIPTION), "A test document") self.assertEqual(
self.assertEqual(self.doc.get_metadata(MetadataType.KEYWORDS), ["test", "document"]) self.doc.get_metadata(
MetadataType.DESCRIPTION),
"A test document")
self.assertEqual(
self.doc.get_metadata(
MetadataType.KEYWORDS), [
"test", "document"])
# Test non-existent metadata # Test non-existent metadata
self.assertIsNone(self.doc.get_metadata(MetadataType.PUBLISHER)) self.assertIsNone(self.doc.get_metadata(MetadataType.PUBLISHER))
@ -456,7 +462,10 @@ class TestBook(unittest.TestCase):
# Test metadata # Test metadata
self.book.set_metadata(MetadataType.PUBLISHER, "Test Publisher") self.book.set_metadata(MetadataType.PUBLISHER, "Test Publisher")
self.assertEqual(self.book.get_metadata(MetadataType.PUBLISHER), "Test Publisher") self.assertEqual(
self.book.get_metadata(
MetadataType.PUBLISHER),
"Test Publisher")
# Test anchors # Test anchors
heading = Heading(HeadingLevel.H1) heading = Heading(HeadingLevel.H1)

View File

@ -6,7 +6,7 @@ interactive functionality and user interface elements.
""" """
import unittest import unittest
from unittest.mock import Mock, patch from unittest.mock import Mock
from pyWebLayout.abstract.functional import ( from pyWebLayout.abstract.functional import (
Link, LinkType, Button, Form, FormField, FormFieldType Link, LinkType, Button, Form, FormField, FormFieldType
) )
@ -92,7 +92,8 @@ class TestLink(unittest.TestCase):
result = link.execute() result = link.execute()
# Should call callback with location, point (None when not provided), and params # Should call callback with location, point (None when not provided), and params
self.mock_callback.assert_called_once_with("/api/save", None, action="save", id=123) self.mock_callback.assert_called_once_with(
"/api/save", None, action="save", id=123)
self.assertEqual(result, "callback_result") self.assertEqual(result, "callback_result")
def test_function_link_execution(self): def test_function_link_execution(self):
@ -471,11 +472,10 @@ class TestFormIntegration(unittest.TestCase):
label="Email", required=True, value="test@example.com" label="Email", required=True, value="test@example.com"
)) ))
form.add_field(FormField( form.add_field(
"country", FormFieldType.SELECT, FormField(
label="Country", value="US", "country", FormFieldType.SELECT, label="Country", value="US", options=[
options=[("US", "United States"), ("CA", "Canada"), ("UK", "United Kingdom")] ("US", "United States"), ("CA", "Canada"), ("UK", "United Kingdom")]))
))
form.add_field(FormField( form.add_field(FormField(
"newsletter", FormFieldType.CHECKBOX, "newsletter", FormFieldType.CHECKBOX,

View File

@ -6,7 +6,7 @@ and formatting within documents.
""" """
import unittest import unittest
from unittest.mock import Mock, patch, MagicMock from unittest.mock import Mock
from pyWebLayout.abstract.inline import Word, FormattedSpan, LineBreak from pyWebLayout.abstract.inline import Word, FormattedSpan, LineBreak
from pyWebLayout.style import Font from pyWebLayout.style import Font
@ -30,7 +30,6 @@ class TestWord(unittest.TestCase):
self.assertIsNone(word.next) self.assertIsNone(word.next)
self.assertEqual(len(word.possible_hyphenation()), 0) self.assertEqual(len(word.possible_hyphenation()), 0)
def test_word_hyphenation(self): def test_word_hyphenation(self):
"""Test word creation with minimal parameters.""" """Test word creation with minimal parameters."""
word = Word("amsterdam", self.font) word = Word("amsterdam", self.font)
@ -71,7 +70,6 @@ class TestWord(unittest.TestCase):
self.assertEqual(word2.previous, word1) self.assertEqual(word2.previous, word1)
self.assertIsNone(word2.next) self.assertIsNone(word2.next)
def test_add_next_word(self): def test_add_next_word(self):
"""Test linking words with add_next method.""" """Test linking words with add_next method."""
word1 = Word("first", self.font) word1 = Word("first", self.font)
@ -98,7 +96,6 @@ class TestWord(unittest.TestCase):
word2 = Word("second", self.font, previous=word1) word2 = Word("second", self.font, previous=word1)
word3 = Word("third", self.font, previous=word2) word3 = Word("third", self.font, previous=word2)
# Test complete chain # Test complete chain
self.assertIsNone(word1.previous) self.assertIsNone(word1.previous)
self.assertEqual(word1.next, word2) self.assertEqual(word1.next, word2)
@ -109,7 +106,6 @@ class TestWord(unittest.TestCase):
self.assertEqual(word3.previous, word2) self.assertEqual(word3.previous, word2)
self.assertIsNone(word3.next) self.assertIsNone(word3.next)
def test_word_create_and_add_to_with_style_override(self): def test_word_create_and_add_to_with_style_override(self):
"""Test Word.create_and_add_to with explicit style parameter.""" """Test Word.create_and_add_to with explicit style parameter."""
# Create alternate font # Create alternate font
@ -269,7 +265,7 @@ class TestWord(unittest.TestCase):
self.assertEqual(len(container.added_words), 1) self.assertEqual(len(container.added_words), 1)
self.assertEqual(container.added_words[0], word) 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.""" """Test Word.create_and_add_to with add_word method that has 'word_object' parameter."""
class WordObjectContainer: class WordObjectContainer:
def __init__(self, font): def __init__(self, font):
@ -285,7 +281,8 @@ class TestWord(unittest.TestCase):
self.assertEqual(len(container.added_words), 1) self.assertEqual(len(container.added_words), 1)
self.assertEqual(container.added_words[0], word) 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.""" """Test Word.create_and_add_to with add_word that expects text but container has _words list."""
class TextExpectingContainer: class TextExpectingContainer:
def __init__(self, font): def __init__(self, font):
@ -307,7 +304,8 @@ class TestWord(unittest.TestCase):
# add_word should not have been called since it expects text # add_word should not have been called since it expects text
self.assertEqual(len(container.add_word_calls), 0) 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.""" """Test Word.create_and_add_to fallback when container doesn't have _words list."""
class TextExpectingContainer: class TextExpectingContainer:
def __init__(self, font): def __init__(self, font):
@ -337,11 +335,14 @@ class TestWord(unittest.TestCase):
container = NoParamsContainer(self.font) 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: with self.assertRaises(TypeError) as context:
Word.create_and_add_to("test", container) 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): 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.""" """Test Word.create_and_add_to properly links with existing words in container."""

View File

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

View File

@ -3,8 +3,8 @@ Unit tests for LinkedWord and LinkedImage classes.
""" """
import unittest import unittest
from pyWebLayout.abstract.inline import Word, LinkedWord from pyWebLayout.abstract.inline import LinkedWord
from pyWebLayout.abstract.block import Image, LinkedImage from pyWebLayout.abstract.block import LinkedImage
from pyWebLayout.abstract.functional import LinkType from pyWebLayout.abstract.functional import LinkType
from pyWebLayout.style import Font from pyWebLayout.style import Font
@ -62,7 +62,7 @@ class TestLinkedWord(unittest.TestCase):
params={"source": "test"} params={"source": "test"}
) )
result = linked_word.execute_link() linked_word.execute_link()
self.assertEqual(len(callback_called), 1) self.assertEqual(len(callback_called), 1)
self.assertEqual(callback_called[0][0], self.location) self.assertEqual(callback_called[0][0], self.location)
@ -169,7 +169,7 @@ class TestLinkedImage(unittest.TestCase):
callback=image_callback callback=image_callback
) )
result = linked_image.execute_link() linked_image.execute_link()
self.assertEqual(len(callback_called), 1) self.assertEqual(len(callback_called), 1)
self.assertEqual(callback_called[0][0], self.location) self.assertEqual(callback_called[0][0], self.location)

View File

@ -5,14 +5,15 @@ Tests the various alignment handlers (Left, Center, Right, Justify) and their in
""" """
import unittest import unittest
import numpy as np
from unittest.mock import Mock
from pyWebLayout.concrete.text import Line, Text, LeftAlignmentHandler, CenterRightAlignmentHandler, JustifyAlignmentHandler from pyWebLayout.concrete.text import (
Line, Text, LeftAlignmentHandler, CenterRightAlignmentHandler, JustifyAlignmentHandler
)
from pyWebLayout.style import Alignment from pyWebLayout.style import Alignment
from pyWebLayout.style import Font from pyWebLayout.style import Font
from pyWebLayout.abstract import Word from pyWebLayout.abstract import Word
from PIL import Image, ImageFont, ImageDraw from PIL import Image, ImageDraw
class TestAlignmentHandlers(unittest.TestCase): class TestAlignmentHandlers(unittest.TestCase):
"""Test cases for the alignment handler system""" """Test cases for the alignment handler system"""
@ -20,7 +21,8 @@ class TestAlignmentHandlers(unittest.TestCase):
def setUp(self): def setUp(self):
"""Set up test fixtures""" """Set up test fixtures"""
self.font = Font() self.font = Font()
self.test_words = [Word(text, self.font) for text in ["This", "is", "a", "test", "sentence"]] self.test_words = [Word(text, self.font)
for text in ["This", "is", "a", "test", "sentence"]]
self.line_width = 300 self.line_width = 300
self.line_height = 30 self.line_height = 30
self.spacing = (5, 20) # min_spacing, max_spacing self.spacing = (5, 20) # min_spacing, max_spacing
@ -36,38 +38,70 @@ class TestAlignmentHandlers(unittest.TestCase):
# Create a real Font object # Create a real Font object
self.style = Font() self.style = Font()
def test_left_alignment_handler_assignment(self): def test_left_alignment_handler_assignment(self):
"""Test that Line correctly assigns LeftAlignmentHandler for LEFT alignment""" """Test that Line correctly assigns LeftAlignmentHandler for LEFT alignment"""
left_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.LEFT) left_line = Line(
self.spacing,
self.origin,
self.size,
self.draw,
font=self.style,
halign=Alignment.LEFT)
self.assertIsInstance(left_line._alignment_handler, LeftAlignmentHandler) self.assertIsInstance(left_line._alignment_handler, LeftAlignmentHandler)
def test_center_alignment_handler_assignment(self): def test_center_alignment_handler_assignment(self):
"""Test that Line correctly assigns CenterRightAlignmentHandler for CENTER alignment""" """Test that Line correctly assigns CenterRightAlignmentHandler for CENTER alignment"""
center_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.CENTER) center_line = Line(
self.spacing,
self.origin,
self.size,
self.draw,
font=self.style,
halign=Alignment.CENTER)
self.assertIsInstance(center_line._alignment_handler, CenterRightAlignmentHandler) self.assertIsInstance(
center_line._alignment_handler,
CenterRightAlignmentHandler)
# Check that it's configured for CENTER alignment # Check that it's configured for CENTER alignment
self.assertEqual(center_line._alignment_handler._alignment, Alignment.CENTER) self.assertEqual(center_line._alignment_handler._alignment, Alignment.CENTER)
def test_right_alignment_handler_assignment(self): def test_right_alignment_handler_assignment(self):
"""Test that Line correctly assigns CenterRightAlignmentHandler for RIGHT alignment""" """Test that Line correctly assigns CenterRightAlignmentHandler for RIGHT alignment"""
right_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.RIGHT) right_line = Line(
self.spacing,
self.origin,
self.size,
self.draw,
font=self.style,
halign=Alignment.RIGHT)
self.assertIsInstance(right_line._alignment_handler, CenterRightAlignmentHandler) self.assertIsInstance(
right_line._alignment_handler,
CenterRightAlignmentHandler)
# Check that it's configured for RIGHT alignment # Check that it's configured for RIGHT alignment
self.assertEqual(right_line._alignment_handler._alignment, Alignment.RIGHT) self.assertEqual(right_line._alignment_handler._alignment, Alignment.RIGHT)
def test_justify_alignment_handler_assignment(self): def test_justify_alignment_handler_assignment(self):
"""Test that Line correctly assigns JustifyAlignmentHandler for JUSTIFY alignment""" """Test that Line correctly assigns JustifyAlignmentHandler for JUSTIFY alignment"""
justify_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.JUSTIFY) justify_line = Line(
self.spacing,
self.origin,
self.size,
self.draw,
font=self.style,
halign=Alignment.JUSTIFY)
self.assertIsInstance(justify_line._alignment_handler, JustifyAlignmentHandler) self.assertIsInstance(justify_line._alignment_handler, JustifyAlignmentHandler)
def test_left_alignment_word_addition(self): def test_left_alignment_word_addition(self):
"""Test adding words to a left-aligned line""" """Test adding words to a left-aligned line"""
left_line = Line(self.spacing, self.origin, self.size, self.draw, halign=Alignment.LEFT) left_line = Line(
self.spacing,
self.origin,
self.size,
self.draw,
halign=Alignment.LEFT)
# Add words until line is full or we run out # Add words until line is full or we run out
words_added = 0 words_added = 0
@ -85,7 +119,13 @@ class TestAlignmentHandlers(unittest.TestCase):
def test_center_alignment_word_addition(self): def test_center_alignment_word_addition(self):
"""Test adding words to a center-aligned line""" """Test adding words to a center-aligned line"""
center_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.CENTER) center_line = Line(
self.spacing,
self.origin,
self.size,
self.draw,
font=self.style,
halign=Alignment.CENTER)
# Add words until line is full or we run out # Add words until line is full or we run out
words_added = 0 words_added = 0
@ -103,7 +143,13 @@ class TestAlignmentHandlers(unittest.TestCase):
def test_right_alignment_word_addition(self): def test_right_alignment_word_addition(self):
"""Test adding words to a right-aligned line""" """Test adding words to a right-aligned line"""
right_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.RIGHT) right_line = Line(
self.spacing,
self.origin,
self.size,
self.draw,
font=self.style,
halign=Alignment.RIGHT)
# Add words until line is full or we run out # Add words until line is full or we run out
words_added = 0 words_added = 0
@ -121,7 +167,13 @@ class TestAlignmentHandlers(unittest.TestCase):
def test_justify_alignment_word_addition(self): def test_justify_alignment_word_addition(self):
"""Test adding words to a justified line""" """Test adding words to a justified line"""
justify_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.JUSTIFY) justify_line = Line(
self.spacing,
self.origin,
self.size,
self.draw,
font=self.style,
halign=Alignment.JUSTIFY)
# Add words until line is full or we run out # Add words until line is full or we run out
words_added = 0 words_added = 0
@ -140,7 +192,8 @@ class TestAlignmentHandlers(unittest.TestCase):
def test_handler_spacing_and_position_calculations(self): def test_handler_spacing_and_position_calculations(self):
"""Test spacing and position calculations for different alignment handlers""" """Test spacing and position calculations for different alignment handlers"""
# Create sample text objects # Create sample text objects
text_objects = [Text(word, self.style, self.draw) for word in ["Hello", "World"]] text_objects = [Text(word, self.style, self.draw)
for word in ["Hello", "World"]]
# Test each handler type # Test each handler type
handlers = [ handlers = [
@ -173,7 +226,8 @@ class TestAlignmentHandlers(unittest.TestCase):
def test_left_handler_spacing_calculation(self): def test_left_handler_spacing_calculation(self):
"""Test specific spacing calculation for left alignment""" """Test specific spacing calculation for left alignment"""
handler = LeftAlignmentHandler() handler = LeftAlignmentHandler()
text_objects = [Text(word, self.style, self.draw) for word in ["Hello", "World"]] text_objects = [Text(word, self.style, self.draw)
for word in ["Hello", "World"]]
spacing_calc, position, overflow = handler.calculate_spacing_and_position( spacing_calc, position, overflow = handler.calculate_spacing_and_position(
text_objects, self.line_width, self.spacing[0], self.spacing[1]) text_objects, self.line_width, self.spacing[0], self.spacing[1])
@ -187,7 +241,8 @@ class TestAlignmentHandlers(unittest.TestCase):
def test_center_handler_spacing_calculation(self): def test_center_handler_spacing_calculation(self):
"""Test specific spacing calculation for center alignment""" """Test specific spacing calculation for center alignment"""
handler = CenterRightAlignmentHandler(Alignment.CENTER) handler = CenterRightAlignmentHandler(Alignment.CENTER)
text_objects = [Text(word, self.style, self.draw) for word in ["Hello", "World"]] text_objects = [Text(word, self.style, self.draw)
for word in ["Hello", "World"]]
spacing_calc, position, overflow = handler.calculate_spacing_and_position( spacing_calc, position, overflow = handler.calculate_spacing_and_position(
text_objects, self.line_width, self.spacing[0], self.spacing[1]) text_objects, self.line_width, self.spacing[0], self.spacing[1])
@ -199,7 +254,8 @@ class TestAlignmentHandlers(unittest.TestCase):
def test_right_handler_spacing_calculation(self): def test_right_handler_spacing_calculation(self):
"""Test specific spacing calculation for right alignment""" """Test specific spacing calculation for right alignment"""
handler = CenterRightAlignmentHandler(Alignment.RIGHT) handler = CenterRightAlignmentHandler(Alignment.RIGHT)
text_objects = [Text(word, self.style, self.draw) for word in ["Hello", "World"]] text_objects = [Text(word, self.style, self.draw)
for word in ["Hello", "World"]]
spacing_calc, position, overflow = handler.calculate_spacing_and_position( spacing_calc, position, overflow = handler.calculate_spacing_and_position(
text_objects, self.line_width, self.spacing[0], self.spacing[1]) text_objects, self.line_width, self.spacing[0], self.spacing[1])
@ -210,7 +266,8 @@ class TestAlignmentHandlers(unittest.TestCase):
def test_justify_handler_spacing_calculation(self): def test_justify_handler_spacing_calculation(self):
"""Test specific spacing calculation for justify alignment""" """Test specific spacing calculation for justify alignment"""
handler = JustifyAlignmentHandler() handler = JustifyAlignmentHandler()
text_objects = [Text(word, self.style, self.draw) for word in ["Hello", "World"]] text_objects = [Text(word, self.style, self.draw)
for word in ["Hello", "World"]]
spacing_calc, position, overflow = handler.calculate_spacing_and_position( spacing_calc, position, overflow = handler.calculate_spacing_and_position(
text_objects, self.line_width, self.spacing[0], self.spacing[1]) text_objects, self.line_width, self.spacing[0], self.spacing[1])
@ -223,11 +280,21 @@ class TestAlignmentHandlers(unittest.TestCase):
def test_empty_line_alignment_handlers(self): def test_empty_line_alignment_handlers(self):
"""Test alignment handlers with empty lines""" """Test alignment handlers with empty lines"""
alignments = [Alignment.LEFT, Alignment.CENTER, Alignment.RIGHT, Alignment.JUSTIFY] alignments = [
Alignment.LEFT,
Alignment.CENTER,
Alignment.RIGHT,
Alignment.JUSTIFY]
for alignment in alignments: for alignment in alignments:
with self.subTest(alignment=alignment): with self.subTest(alignment=alignment):
line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=alignment) line = Line(
self.spacing,
self.origin,
self.size,
self.draw,
font=self.style,
halign=alignment)
# Empty line should still have a handler # Empty line should still have a handler
self.assertIsNotNone(line._alignment_handler) self.assertIsNotNone(line._alignment_handler)
@ -237,11 +304,21 @@ class TestAlignmentHandlers(unittest.TestCase):
def test_single_word_line_alignment(self): def test_single_word_line_alignment(self):
"""Test alignment handlers with single word lines""" """Test alignment handlers with single word lines"""
alignments = [Alignment.LEFT, Alignment.CENTER, Alignment.RIGHT, Alignment.JUSTIFY] alignments = [
Alignment.LEFT,
Alignment.CENTER,
Alignment.RIGHT,
Alignment.JUSTIFY]
for alignment in alignments: for alignment in alignments:
with self.subTest(alignment=alignment): with self.subTest(alignment=alignment):
line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=alignment) line = Line(
self.spacing,
self.origin,
self.size,
self.draw,
font=self.style,
halign=alignment)
# Create a test word # Create a test word
test_word = Word("test", self.style) test_word = Word("test", self.style)

View File

@ -6,7 +6,7 @@ Tests the Box class which handles basic box model rendering with alignment.
import unittest import unittest
import numpy as np import numpy as np
from PIL import Image from PIL import Image
from unittest.mock import Mock, patch from unittest.mock import Mock
from pyWebLayout.concrete.box import Box from pyWebLayout.concrete.box import Box
from pyWebLayout.style import Alignment from pyWebLayout.style import Alignment
@ -88,7 +88,6 @@ class TestBox(unittest.TestCase):
np.testing.assert_array_equal(result, [True, False, True, False]) np.testing.assert_array_equal(result, [True, False, True, False])
def test_properties_access(self): def test_properties_access(self):
"""Test that properties can be accessed correctly""" """Test that properties can be accessed correctly"""
box = Box(self.origin, self.size, callback=self.callback) box = Box(self.origin, self.size, callback=self.callback)

View File

@ -5,18 +5,16 @@ Tests the LinkText, ButtonText, and FormFieldText classes.
import unittest import unittest
import numpy as np import numpy as np
from PIL import Image, ImageDraw from unittest.mock import Mock, patch
from unittest.mock import Mock, patch, MagicMock
from pyWebLayout.concrete.functional import ( from pyWebLayout.concrete.functional import (
LinkText, ButtonText, FormFieldText, LinkText, ButtonText, FormFieldText,
create_link_text, create_button_text, create_form_field_text create_link_text, create_button_text, create_form_field_text
) )
from pyWebLayout.abstract.functional import ( from pyWebLayout.abstract.functional import (
Link, Button, Form, FormField, LinkType, FormFieldType Link, Button, FormField, LinkType, FormFieldType
) )
from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration from pyWebLayout.style import Font, TextDecoration
from pyWebLayout.style import Alignment
class TestLinkText(unittest.TestCase): class TestLinkText(unittest.TestCase):
@ -33,7 +31,10 @@ class TestLinkText(unittest.TestCase):
# Create different types of links # Create different types of links
self.internal_link = Link("chapter1", LinkType.INTERNAL, self.callback) self.internal_link = Link("chapter1", LinkType.INTERNAL, self.callback)
self.external_link = Link("https://example.com", LinkType.EXTERNAL, self.callback) self.external_link = Link(
"https://example.com",
LinkType.EXTERNAL,
self.callback)
self.api_link = Link("/api/settings", LinkType.API, self.callback) self.api_link = Link("/api/settings", LinkType.API, self.callback)
self.function_link = Link("toggle_theme", LinkType.FUNCTION, self.callback) self.function_link = Link("toggle_theme", LinkType.FUNCTION, self.callback)
@ -133,7 +134,11 @@ class TestLinkText(unittest.TestCase):
def test_factory_function(self): def test_factory_function(self):
"""Test the create_link_text factory function""" """Test the create_link_text factory function"""
link_text = "Factory Test" link_text = "Factory Test"
renderable = create_link_text(self.internal_link, link_text, self.font, self.mock_draw) renderable = create_link_text(
self.internal_link,
link_text,
self.font,
self.mock_draw)
self.assertIsInstance(renderable, LinkText) self.assertIsInstance(renderable, LinkText)
self.assertEqual(renderable.text, link_text) self.assertEqual(renderable.text, link_text)
@ -215,7 +220,8 @@ class TestButtonText(unittest.TestCase):
expected_width = renderable._padded_width expected_width = renderable._padded_width
expected_height = renderable._padded_height expected_height = renderable._padded_height
np.testing.assert_array_equal(renderable.size, np.array([expected_width, expected_height])) np.testing.assert_array_equal(
renderable.size, np.array([expected_width, expected_height]))
def test_render_normal_state(self): def test_render_normal_state(self):
"""Test rendering in normal state""" """Test rendering in normal state"""
@ -257,7 +263,8 @@ class TestButtonText(unittest.TestCase):
def test_factory_function(self): def test_factory_function(self):
"""Test the create_button_text factory function""" """Test the create_button_text factory function"""
custom_padding = (6, 10, 6, 10) custom_padding = (6, 10, 6, 10)
renderable = create_button_text(self.button, self.font, self.mock_draw, custom_padding) renderable = create_button_text(
self.button, self.font, self.mock_draw, custom_padding)
self.assertIsInstance(renderable, ButtonText) self.assertIsInstance(renderable, ButtonText)
self.assertEqual(renderable.text, "Click Me") self.assertEqual(renderable.text, "Click Me")
@ -279,7 +286,8 @@ class TestFormFieldText(unittest.TestCase):
# Create different types of form fields # Create different types of form fields
self.text_field = FormField("username", FormFieldType.TEXT, "Username") self.text_field = FormField("username", FormFieldType.TEXT, "Username")
self.password_field = FormField("password", FormFieldType.PASSWORD, "Password") self.password_field = FormField("password", FormFieldType.PASSWORD, "Password")
self.textarea_field = FormField("description", FormFieldType.TEXTAREA, "Description") self.textarea_field = FormField(
"description", FormFieldType.TEXTAREA, "Description")
self.select_field = FormField("country", FormFieldType.SELECT, "Country") self.select_field = FormField("country", FormFieldType.SELECT, "Country")
self.mock_draw = Mock() self.mock_draw = Mock()
@ -296,7 +304,11 @@ class TestFormFieldText(unittest.TestCase):
def test_form_field_text_with_custom_height(self): def test_form_field_text_with_custom_height(self):
"""Test form field text with custom field height""" """Test form field text with custom field height"""
custom_height = 40 custom_height = 40
renderable = FormFieldText(self.text_field, self.font, self.mock_draw, custom_height) renderable = FormFieldText(
self.text_field,
self.font,
self.mock_draw,
custom_height)
self.assertEqual(renderable._field_height, custom_height) self.assertEqual(renderable._field_height, custom_height)
@ -326,7 +338,8 @@ class TestFormFieldText(unittest.TestCase):
expected_height = renderable._style.font_size + 5 + renderable._field_height expected_height = renderable._style.font_size + 5 + renderable._field_height
expected_width = renderable._field_width # Use the calculated field width expected_width = renderable._field_width # Use the calculated field width
np.testing.assert_array_equal(renderable.size, np.array([expected_width, expected_height])) np.testing.assert_array_equal(
renderable.size, np.array([expected_width, expected_height]))
def test_render_text_field(self): def test_render_text_field(self):
"""Test rendering text field""" """Test rendering text field"""
@ -426,7 +439,8 @@ class TestFormFieldText(unittest.TestCase):
def test_factory_function(self): def test_factory_function(self):
"""Test the create_form_field_text factory function""" """Test the create_form_field_text factory function"""
custom_height = 30 custom_height = 30
renderable = create_form_field_text(self.text_field, self.font, self.mock_draw, custom_height) renderable = create_form_field_text(
self.text_field, self.font, self.mock_draw, custom_height)
self.assertIsInstance(renderable, FormFieldText) self.assertIsInstance(renderable, FormFieldText)
self.assertEqual(renderable.text, "Username") self.assertEqual(renderable.text, "Username")
@ -455,7 +469,8 @@ class TestInteractionCallbacks(unittest.TestCase):
def test_link_text_interaction(self): def test_link_text_interaction(self):
"""Test that LinkText properly handles interaction""" """Test that LinkText properly handles interaction"""
# Use a FUNCTION link type which calls the callback, not INTERNAL which returns location # Use a FUNCTION link type which calls the callback, not INTERNAL which
# returns location
link = Link("test_function", LinkType.FUNCTION, self.link_callback) link = Link("test_function", LinkType.FUNCTION, self.link_callback)
renderable = LinkText(link, "Test Link", self.font, self.mock_draw) renderable = LinkText(link, "Test Link", self.font, self.mock_draw)

View File

@ -8,7 +8,7 @@ import os
import tempfile import tempfile
import numpy as np import numpy as np
from PIL import Image as PILImage, ImageDraw from PIL import Image as PILImage, ImageDraw
from unittest.mock import Mock, patch, MagicMock from unittest.mock import Mock, patch
from pyWebLayout.concrete.image import RenderableImage from pyWebLayout.concrete.image import RenderableImage
from pyWebLayout.abstract.block import Image as AbstractImage from pyWebLayout.abstract.block import Image as AbstractImage
@ -42,7 +42,7 @@ class TestRenderableImage(unittest.TestCase):
try: try:
os.unlink(self.test_image_path) os.unlink(self.test_image_path)
os.rmdir(self.temp_dir) os.rmdir(self.temp_dir)
except: except BaseException:
pass pass
def test_renderable_image_initialization_basic(self): def test_renderable_image_initialization_basic(self):
@ -134,7 +134,9 @@ class TestRenderableImage(unittest.TestCase):
mock_response.status_code = 404 mock_response.status_code = 404
mock_get.return_value = mock_response mock_get.return_value = mock_response
url_abstract = AbstractImage("https://example.com/notfound.png", "Bad URL Image") url_abstract = AbstractImage(
"https://example.com/notfound.png",
"Bad URL Image")
renderable = RenderableImage(url_abstract, self.draw) renderable = RenderableImage(url_abstract, self.draw)
# Should have error message # Should have error message
@ -292,8 +294,6 @@ class TestRenderableImage(unittest.TestCase):
self.assertEqual(renderable._halign, Alignment.RIGHT) self.assertEqual(renderable._halign, Alignment.RIGHT)
self.assertEqual(renderable._valign, Alignment.BOTTOM) self.assertEqual(renderable._valign, Alignment.BOTTOM)
def test_render_rgb_image_conversion(self): def test_render_rgb_image_conversion(self):
"""Test rendering RGB image (should be converted to RGBA)""" """Test rendering RGB image (should be converted to RGBA)"""
# Our test image is RGB, so this should test the conversion path # Our test image is RGB, so this should test the conversion path
@ -372,7 +372,10 @@ class TestRenderableImage(unittest.TestCase):
def test_properties(self): def test_properties(self):
"""Test the property methods""" """Test the property methods"""
renderable = RenderableImage(self.abstract_image, self.draw, origin=(10, 20), size=(100, 80)) renderable = RenderableImage(
self.abstract_image, self.draw, origin=(
10, 20), size=(
100, 80))
np.testing.assert_array_equal(renderable.origin, np.array([10, 20])) np.testing.assert_array_equal(renderable.origin, np.array([10, 20]))
np.testing.assert_array_equal(renderable.size, np.array([100, 80])) np.testing.assert_array_equal(renderable.size, np.array([100, 80]))

View File

@ -6,15 +6,15 @@ Tests the Text and Line classes for text rendering functionality.
import unittest import unittest
import numpy as np import numpy as np
import os import os
from PIL import Image, ImageFont, ImageDraw from PIL import Image, ImageDraw
from unittest.mock import Mock, patch, MagicMock from unittest.mock import Mock
from pyWebLayout.concrete.text import Text, Line from pyWebLayout.concrete.text import Text, Line
from pyWebLayout.abstract.inline import Word from pyWebLayout.abstract.inline import Word
from pyWebLayout.style import Font, FontStyle, FontWeight, TextDecoration
from pyWebLayout.style import Alignment from pyWebLayout.style import Alignment
from tests.utils.test_fonts import create_default_test_font, ensure_consistent_font_in_tests from tests.utils.test_fonts import create_default_test_font, ensure_consistent_font_in_tests
class TestText(unittest.TestCase): class TestText(unittest.TestCase):
def setUp(self): def setUp(self):
# Ensure consistent font usage across tests # Ensure consistent font usage across tests
@ -29,7 +29,6 @@ class TestText(unittest.TestCase):
# Create a consistent test Font object using bundled font # Create a consistent test Font object using bundled font
self.style = create_default_test_font() self.style = create_default_test_font()
def test_init(self): def test_init(self):
text_instance = Text(text="Test", style=self.style, draw=self.draw) text_instance = Text(text="Test", style=self.style, draw=self.draw)
self.assertEqual(text_instance.text, "Test") self.assertEqual(text_instance.text, "Test")
@ -203,7 +202,8 @@ class TestLine(unittest.TestCase):
return return
elif not success: elif not success:
# Line is full, word couldn't be added # Line is full, word couldn't be added
self.assertGreater(words_added, 0, "Should have added at least one word before line filled") self.assertGreater(
words_added, 0, "Should have added at least one word before line filled")
return return
else: else:
# Word was added successfully # Word was added successfully
@ -235,7 +235,7 @@ class TestLine(unittest.TestCase):
success, overflow_part = line.add_word(word) success, overflow_part = line.add_word(word)
# If successful, the word should be added # If successful, the word should be added
if success == False: if not success:
self.assertIsNone(overflow_part) self.assertIsNone(overflow_part)
return return
@ -260,7 +260,8 @@ class TestLine(unittest.TestCase):
# Use a longer word to trigger brute force hyphenation # Use a longer word to trigger brute force hyphenation
words_added = 0 words_added = 0
for i in range(100): for i in range(100):
word = Word(text="AAAAAAAA", style=self.style) # 8 A's to ensure it's long enough # 8 A's to ensure it's long enough
word = Word(text="AAAAAAAA", style=self.style)
success, overflow_part = line.add_word(word) success, overflow_part = line.add_word(word)
if overflow_part: if overflow_part:
@ -270,14 +271,14 @@ class TestLine(unittest.TestCase):
return return
elif not success: elif not success:
# Line is full, word couldn't be added # Line is full, word couldn't be added
self.assertGreater(words_added, 0, "Should have added at least one word before line filled") self.assertGreater(
words_added, 0, "Should have added at least one word before line filled")
return return
else: else:
words_added += 1 words_added += 1
self.fail("Expected line to fill or overflow to occur but reached max iterations") self.fail("Expected line to fill or overflow to occur but reached max iterations")
def test_line_render(self): def test_line_render(self):
"""Test line rendering with real objects""" """Test line rendering with real objects"""
spacing = (5, 15) spacing = (5, 15)

View File

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

View File

@ -9,10 +9,9 @@ Unit tests for the new Page implementation to verify it meets the requirements:
""" """
import unittest import unittest
import numpy as np import numpy as np
from PIL import Image, ImageDraw from PIL import Image
from pyWebLayout.concrete.page import Page from pyWebLayout.concrete.page import Page
from pyWebLayout.style.page_style import PageStyle from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.style.fonts import Font
from pyWebLayout.core.base import Renderable, Queriable from pyWebLayout.core.base import Renderable, Queriable
@ -74,7 +73,8 @@ class TestPageImplementation(unittest.TestCase):
self.assertEqual(page.canvas_size, expected_canvas_size) self.assertEqual(page.canvas_size, expected_canvas_size)
# Content size should be canvas minus padding # Content size should be canvas minus padding
expected_content_size = (730, 550) # 790-60, 590-40 (padding left+right, top+bottom) # 790-60, 590-40 (padding left+right, top+bottom)
expected_content_size = (730, 550)
self.assertEqual(page.content_size, expected_content_size) self.assertEqual(page.content_size, expected_content_size)
def test_page_add_remove_children(self): def test_page_add_remove_children(self):
@ -103,7 +103,9 @@ class TestPageImplementation(unittest.TestCase):
self.assertEqual(len(page.children), 3) self.assertEqual(len(page.children), 3)
self.assertIn(child3, page.children) self.assertIn(child3, page.children)
# Remove childce youll notice is that responses dont stream character-by-character like other providers. Instead, Claude Code processes your full request before sending back the complete response. # Remove childce youll notice is that responses dont stream
# character-by-character like other providers. Instead, Claude Code
# processes your full request before sending back the complete response.
removed = page.remove_child(child2) removed = page.remove_child(child2)
self.assertTrue(removed) self.assertTrue(removed)
self.assertEqual(len(page.children), 2) self.assertEqual(len(page.children), 2)
@ -265,7 +267,7 @@ class TestPageImplementation(unittest.TestCase):
# Test 1: Line that fits comfortably # Test 1: Line that fits comfortably
line_height = 20 line_height = 20
max_y = 600 - 40 - 10 # 550 _max_y = 600 - 40 - 10 # 550
self.assertTrue(page.can_fit_line(line_height)) self.assertTrue(page.can_fit_line(line_height))
# Would end at 50 + 20 = 70, well within 550 # Would end at 50 + 20 = 70, well within 550
@ -280,7 +282,8 @@ class TestPageImplementation(unittest.TestCase):
# After 24 lines: y_offset = 50 + (24 * 20) = 530 # After 24 lines: y_offset = 50 + (24 * 20) = 530
self.assertEqual(page._current_y_offset, 530) self.assertEqual(page._current_y_offset, 530)
# Test 3: One more 20-pixel line should fit (530 + 20 = 550, exactly at boundary) # Test 3: One more 20-pixel line should fit (530 + 20 = 550, exactly at
# boundary)
self.assertTrue(page.can_fit_line(20)) self.assertTrue(page.can_fit_line(20))
page._current_y_offset += 20 page._current_y_offset += 20
self.assertEqual(page._current_y_offset, 550) self.assertEqual(page._current_y_offset, 550)

View File

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

View File

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

View File

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

View File

@ -9,7 +9,6 @@ import unittest
import tempfile import tempfile
import os import os
import shutil import shutil
from datetime import datetime
# Import ebooklib for creating test EPUB files # Import ebooklib for creating test EPUB files
try: try:
@ -21,8 +20,8 @@ except ImportError:
from pyWebLayout.io.readers.epub_reader import read_epub, EPUBReader from pyWebLayout.io.readers.epub_reader import read_epub, EPUBReader
from pyWebLayout.abstract.document import Book from pyWebLayout.abstract.document import Book
from pyWebLayout.abstract.block import ( from pyWebLayout.abstract.block import (
Paragraph, Heading, HeadingLevel, Quote, CodeBlock, Paragraph, Heading, Quote, CodeBlock, HList,
HList, ListStyle, Table, HorizontalRule, Image ListStyle, Table, Image
) )
from pyWebLayout.style import FontWeight, FontStyle, TextDecoration from pyWebLayout.style import FontWeight, FontStyle, TextDecoration
@ -90,7 +89,8 @@ class TestEPUBReader(unittest.TestCase):
book.spine = ['nav', chapter1] book.spine = ['nav', chapter1]
# Create temporary file # Create temporary file
epub_path = os.path.join(self.test_dir, f'test_simple_{len(self.epub_files)}.epub') epub_path = os.path.join(self.test_dir,
f'test_simple_{len(self.epub_files)}.epub')
epub.write_epub(epub_path, book, {}) epub.write_epub(epub_path, book, {})
self.epub_files.append(epub_path) self.epub_files.append(epub_path)
@ -249,7 +249,8 @@ class TestEPUBReader(unittest.TestCase):
book.spine = ['nav', chapter1, chapter2, chapter3, chapter4] book.spine = ['nav', chapter1, chapter2, chapter3, chapter4]
# Create temporary file # Create temporary file
epub_path = os.path.join(self.test_dir, f'test_complex_{len(self.epub_files)}.epub') epub_path = os.path.join(self.test_dir,
f'test_complex_{len(self.epub_files)}.epub')
epub.write_epub(epub_path, book, {}) epub.write_epub(epub_path, book, {})
self.epub_files.append(epub_path) self.epub_files.append(epub_path)
@ -319,7 +320,8 @@ class TestEPUBReader(unittest.TestCase):
book.spine = ['nav', chapter] book.spine = ['nav', chapter]
# Create temporary file # Create temporary file
epub_path = os.path.join(self.test_dir, f'test_nested_{len(self.epub_files)}.epub') epub_path = os.path.join(self.test_dir,
f'test_nested_{len(self.epub_files)}.epub')
epub.write_epub(epub_path, book, {}) epub.write_epub(epub_path, book, {})
self.epub_files.append(epub_path) self.epub_files.append(epub_path)
@ -378,7 +380,9 @@ class TestEPUBReader(unittest.TestCase):
# Each chapter should start with a heading # Each chapter should start with a heading
first_block = blocks[0] first_block = blocks[0]
self.assertIsInstance(first_block, Heading, f"Chapter {i+1} should start with heading") self.assertIsInstance(
first_block, Heading, f"Chapter {
i + 1} should start with heading")
def test_epub_styled_content(self): def test_epub_styled_content(self):
"""Test that styled content in EPUB is properly parsed.""" """Test that styled content in EPUB is properly parsed."""
@ -430,19 +434,23 @@ class TestEPUBReader(unittest.TestCase):
# Check list items # Check list items
items = list(block.items()) items = list(block.items())
self.assertGreater(len(items), 0, "Unordered list should have items") self.assertGreater(
len(items), 0, "Unordered list should have items")
elif block.style == ListStyle.ORDERED: elif block.style == ListStyle.ORDERED:
ordered_list_found = True ordered_list_found = True
# Check list items # Check list items
items = list(block.items()) items = list(block.items())
self.assertGreater(len(items), 0, "Ordered list should have items") self.assertGreater(
len(items), 0, "Ordered list should have items")
elif isinstance(block, Quote): elif isinstance(block, Quote):
quote_found = True quote_found = True
self.assertTrue(unordered_list_found, "Should find unordered list in chapter 3") self.assertTrue(
unordered_list_found,
"Should find unordered list in chapter 3")
self.assertTrue(ordered_list_found, "Should find ordered list in chapter 3") self.assertTrue(ordered_list_found, "Should find ordered list in chapter 3")
self.assertTrue(quote_found, "Should find quote in chapter 3") self.assertTrue(quote_found, "Should find quote in chapter 3")
@ -492,13 +500,19 @@ class TestEPUBReader(unittest.TestCase):
# Should have multiple headings (h1, h2, h3, h4) # Should have multiple headings (h1, h2, h3, h4)
headings = [block for block in chapter_blocks if isinstance(block, Heading)] headings = [block for block in chapter_blocks if isinstance(block, Heading)]
self.assertGreater(len(headings), 2, "Should have multiple headings from nested content") self.assertGreater(
len(headings),
2,
"Should have multiple headings from nested content")
# Should have paragraphs and lists from nested content # Should have paragraphs and lists from nested content
paragraphs = [block for block in chapter_blocks if isinstance(block, Paragraph)] paragraphs = [block for block in chapter_blocks if isinstance(block, Paragraph)]
lists = [block for block in chapter_blocks if isinstance(block, HList)] lists = [block for block in chapter_blocks if isinstance(block, HList)]
self.assertGreater(len(paragraphs), 0, "Should have paragraphs from nested content") self.assertGreater(
len(paragraphs),
0,
"Should have paragraphs from nested content")
self.assertGreater(len(lists), 0, "Should have lists from nested content") self.assertGreater(len(lists), 0, "Should have lists from nested content")
def test_epub_metadata_extraction(self): def test_epub_metadata_extraction(self):
@ -552,7 +566,10 @@ class TestEPUBReader(unittest.TestCase):
book.set_language('en') book.set_language('en')
book.add_author('Primary Author') book.add_author('Primary Author')
book.add_author('Secondary Author') book.add_author('Secondary Author')
book.add_metadata('DC', 'description', 'A comprehensive test of metadata extraction') book.add_metadata(
'DC',
'description',
'A comprehensive test of metadata extraction')
book.add_metadata('DC', 'subject', 'Testing') book.add_metadata('DC', 'subject', 'Testing')
book.add_metadata('DC', 'subject', 'EPUB') book.add_metadata('DC', 'subject', 'EPUB')
book.add_metadata('DC', 'date', '2024-06-07') book.add_metadata('DC', 'date', '2024-06-07')
@ -582,7 +599,8 @@ class TestEPUBReader(unittest.TestCase):
book.spine = ['nav', chapter] book.spine = ['nav', chapter]
# Write and test # Write and test
epub_path = os.path.join(self.test_dir, f'test_metadata_{len(self.epub_files)}.epub') epub_path = os.path.join(self.test_dir,
f'test_metadata_{len(self.epub_files)}.epub')
epub.write_epub(epub_path, book, {}) epub.write_epub(epub_path, book, {})
self.epub_files.append(epub_path) self.epub_files.append(epub_path)
@ -728,7 +746,9 @@ class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase):
if styled_content_found: if styled_content_found:
break break
self.assertTrue(styled_content_found, "Should find styled content in parsed blocks") self.assertTrue(
styled_content_found,
"Should find styled content in parsed blocks")
# Test specific color parsing # Test specific color parsing
red_text_found = False red_text_found = False
@ -751,7 +771,6 @@ class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase):
color_found = red_text_found or green_text_found or blue_text_found color_found = red_text_found or green_text_found or blue_text_found
self.assertTrue(color_found, "Should find at least one colored text") self.assertTrue(color_found, "Should find at least one colored text")
def test_epub_with_image(self): def test_epub_with_image(self):
"""Test that images in EPUB are properly parsed.""" """Test that images in EPUB are properly parsed."""
book = epub.EpubBook() book = epub.EpubBook()
@ -761,7 +780,18 @@ class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase):
book.add_author('Test Author') book.add_author('Test Author')
# Create minimal JPEG data for testing # Create minimal JPEG data for testing
img_data = b'\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x01\x00H\x00H\x00\x00\xff\xdb\x00C\x00\x08\x06\x06\x07\x06\x05\x08\x07\x07\x07\t\t\x08\n\x0c\x14\r\x0c\x0b\x0b\x0c\x19\x12\x13\x0f\x14\x1d\x1a\x1f\x1e\x1d\x1a\x1c\x1c $.\' ",#\x1c\x1c(7),01444\x1f\'9=82<.342\xff\xc0\x00\x11\x08\x00d\x00d\x01\x01\x11\x00\x02\x11\x01\x03\x11\x01\xff\xc4\x00\x14\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x08\xff\xc4\x00\x14\x10\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xda\x00\x0c\x03\x01\x00\x02\x11\x03\x11\x00\x3f\x00\xaa\xff\xd9' img_data = (
b'\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x01\x00H\x00H\x00\x00'
b'\xff\xdb\x00C\x00\x08\x06\x06\x07\x06\x05\x08\x07\x07\x07\t\t'
b'\x08\n\x0c\x14\r\x0c\x0b\x0b\x0c\x19\x12\x13\x0f\x14\x1d\x1a'
b'\x1f\x1e\x1d\x1a\x1c\x1c $.\' ",#\x1c\x1c(7),01444\x1f\'9=82<.342'
b'\xff\xc0\x00\x11\x08\x00d\x00d\x01\x01\x11\x00\x02\x11\x01\x03'
b'\x11\x01\xff\xc4\x00\x14\x00\x01\x00\x00\x00\x00\x00\x00\x00'
b'\x00\x00\x00\x00\x00\x00\x00\x00\x08\xff\xc4\x00\x14\x10\x01'
b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
b'\x00\xff\xda\x00\x0c\x03\x01\x00\x02\x11\x03\x11\x00\x3f\x00'
b'\xaa\xff\xd9'
)
# Create an EpubImage item # Create an EpubImage item
image_item = epub.EpubImage() image_item = epub.EpubImage()
@ -796,7 +826,8 @@ class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase):
book.spine = ['nav', chapter] book.spine = ['nav', chapter]
# Write EPUB # Write EPUB
epub_path = os.path.join(self.test_dir, f'test_image_{len(self.epub_files)}.epub') epub_path = os.path.join(self.test_dir,
f'test_image_{len(self.epub_files)}.epub')
epub.write_epub(epub_path, book, {}) epub.write_epub(epub_path, book, {})
self.epub_files.append(epub_path) self.epub_files.append(epub_path)
@ -814,8 +845,14 @@ class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase):
image_blocks = [block for block in blocks if isinstance(block, Image)] image_blocks = [block for block in blocks if isinstance(block, Image)]
# Verify we have the expected blocks # Verify we have the expected blocks
self.assertEqual(len(heading_blocks), 1, "Should find exactly one heading block") self.assertEqual(
self.assertGreaterEqual(len(paragraph_blocks), 2, "Should find at least two paragraph blocks") len(heading_blocks),
1,
"Should find exactly one heading block")
self.assertGreaterEqual(
len(paragraph_blocks),
2,
"Should find at least two paragraph blocks")
self.assertEqual(len(image_blocks), 1, "Should find exactly one image block") self.assertEqual(len(image_blocks), 1, "Should find exactly one image block")
# Verify image properties # Verify image properties

View File

@ -21,7 +21,8 @@ class TestHTMLParagraph(unittest.TestCase):
self.assertEqual(len(paragraphs), 1) self.assertEqual(len(paragraphs), 1)
self.assertEqual(len(paragraphs[0]), 4) self.assertEqual(len(paragraphs[0]), 4)
for w1, t1 in zip(paragraphs[0].words_iter(), "This is a paragraph.".split(" ")): for w1, t1 in zip(paragraphs[0].words_iter(),
"This is a paragraph.".split(" ")):
self.assertEqual(w1[1].text, t1) self.assertEqual(w1[1].text, t1)
def test_multiple(self): def test_multiple(self):
@ -31,10 +32,12 @@ class TestHTMLParagraph(unittest.TestCase):
self.assertEqual(len(paragraphs[0]), 4) self.assertEqual(len(paragraphs[0]), 4)
self.assertEqual(len(paragraphs[1]), 4) self.assertEqual(len(paragraphs[1]), 4)
for w1, t1 in zip(paragraphs[0].words_iter(), "This is a paragraph.".split(" ")): for w1, t1 in zip(paragraphs[0].words_iter(),
"This is a paragraph.".split(" ")):
self.assertEqual(w1[1].text, t1) self.assertEqual(w1[1].text, t1)
for w1, t1 in zip(paragraphs[1].words_iter(), "This is another paragraph.".split(" ")): for w1, t1 in zip(paragraphs[1].words_iter(),
"This is another paragraph.".split(" ")):
self.assertEqual(w1[1].text, t1) self.assertEqual(w1[1].text, t1)
@ -103,7 +106,10 @@ class TestHTMLStyledParagraphs(unittest.TestCase):
def test_span_with_inline_styles(self): def test_span_with_inline_styles(self):
"""Test paragraphs with span elements containing inline CSS styles.""" """Test paragraphs with span elements containing inline CSS styles."""
text = '<p>This text is normal, but <span style="color: red; font-weight: bold;">this part is red and bold</span>.</p>' text = (
'<p>This text is normal, but <span style="color: red; font-weight: bold;">'
'this part is red and bold</span>.</p>'
)
blocks = parse_html_string(text) blocks = parse_html_string(text)
self.assertEqual(len(blocks), 1) self.assertEqual(len(blocks), 1)
self.assertIsInstance(blocks[0], Paragraph) self.assertIsInstance(blocks[0], Paragraph)
@ -117,7 +123,10 @@ class TestHTMLStyledParagraphs(unittest.TestCase):
if word.style.weight == FontWeight.BOLD: if word.style.weight == FontWeight.BOLD:
styled_words.append(word) styled_words.append(word)
self.assertGreater(len(styled_words), 0, "Should have bold words in styled span") self.assertGreater(
len(styled_words),
0,
"Should have bold words in styled span")
# Check that at least one word has the red color # Check that at least one word has the red color
red_words = [w for w in styled_words if w.style.colour == (255, 0, 0)] red_words = [w for w in styled_words if w.style.colour == (255, 0, 0)]
@ -125,7 +134,11 @@ class TestHTMLStyledParagraphs(unittest.TestCase):
def test_mixed_formatting(self): def test_mixed_formatting(self):
"""Test paragraphs with multiple formatting elements combined.""" """Test paragraphs with multiple formatting elements combined."""
text = "<p>This paragraph contains <strong>bold</strong>, <em>italic</em>, <span style=\"color: blue;\">blue</span>, and <mark>highlighted</mark> text all together.</p>" text = (
'<p>This paragraph contains <strong>bold</strong>, <em>italic</em>, '
'<span style="color: blue;">blue</span>, and <mark>highlighted</mark> '
'text all together.</p>'
)
blocks = parse_html_string(text) blocks = parse_html_string(text)
self.assertEqual(len(blocks), 1) self.assertEqual(len(blocks), 1)
self.assertIsInstance(blocks[0], Paragraph) self.assertIsInstance(blocks[0], Paragraph)
@ -153,9 +166,12 @@ class TestHTMLStyledParagraphs(unittest.TestCase):
words = list(blocks[0].words_iter()) words = list(blocks[0].words_iter())
# Find words that should be both bold and italic # Find words that should be both bold and italic
bold_italic_words = [w for _, w in words bold_italic_words = [w for _, w in words if w.style.weight ==
if w.style.weight == FontWeight.BOLD and w.style.style == FontStyle.ITALIC] FontWeight.BOLD and w.style.style == FontStyle.ITALIC]
self.assertGreater(len(bold_italic_words), 0, "Should have words that are both bold and italic") self.assertGreater(
len(bold_italic_words),
0,
"Should have words that are both bold and italic")
def test_color_variations(self): def test_color_variations(self):
"""Test different color formats in CSS.""" """Test different color formats in CSS."""
@ -195,7 +211,10 @@ class TestHTMLBlockElements(unittest.TestCase):
def test_headings(self): def test_headings(self):
"""Test all heading levels h1-h6.""" """Test all heading levels h1-h6."""
text = "<h1>Heading 1</h1><h2>Heading 2</h2><h3>Heading 3</h3><h4>Heading 4</h4><h5>Heading 5</h5><h6>Heading 6</h6>" text = (
"<h1>Heading 1</h1><h2>Heading 2</h2><h3>Heading 3</h3>"
"<h4>Heading 4</h4><h5>Heading 5</h5><h6>Heading 6</h6>"
)
blocks = parse_html_string(text) blocks = parse_html_string(text)
self.assertEqual(len(blocks), 6) self.assertEqual(len(blocks), 6)
@ -451,8 +470,14 @@ class TestHTMLFontRegistryIntegration(unittest.TestCase):
) )
# Should be different style IDs # Should be different style IDs
self.assertNotEqual(style_id1, style_id2, "Different sizes should create different styles") self.assertNotEqual(
self.assertNotEqual(style_id1, style_id3, "Different colors should create different styles") style_id1,
style_id2,
"Different sizes should create different styles")
self.assertNotEqual(
style_id1,
style_id3,
"Different colors should create different styles")
self.assertNotEqual(style_id2, style_id3, "All styles should be different") self.assertNotEqual(style_id2, style_id3, "All styles should be different")
# Should have multiple styles in registry # Should have multiple styles in registry

View File

@ -6,7 +6,7 @@ reusing test patterns from test_html_extraction.py that are known to pass.
""" """
import unittest import unittest
from bs4 import BeautifulSoup, Tag from bs4 import BeautifulSoup
from pyWebLayout.io.readers.html_extraction import ( from pyWebLayout.io.readers.html_extraction import (
create_base_context, create_base_context,
apply_element_styling, apply_element_styling,
@ -132,12 +132,16 @@ class TestUtilityFunctions(unittest.TestCase):
base_font = Font() base_font = Font()
# Test heading sizes and weights - from test_headings which tests h1-h6 # Test heading sizes and weights - from test_headings which tests h1-h6
headings = [("h1", 24), ("h2", 20), ("h3", 18), ("h4", 16), ("h5", 14), ("h6", 12)] headings = [("h1", 24), ("h2", 20), ("h3", 18),
("h4", 16), ("h5", 14), ("h6", 12)]
for tag, expected_size in headings: for tag, expected_size in headings:
font = apply_element_font_styles(base_font, tag, {}) font = apply_element_font_styles(base_font, tag, {})
self.assertEqual(font.font_size, expected_size, f"Size mismatch for {tag}") self.assertEqual(font.font_size, expected_size, f"Size mismatch for {tag}")
self.assertEqual(font.weight, FontWeight.BOLD, f"Weight should be bold for {tag}") self.assertEqual(
font.weight,
FontWeight.BOLD,
f"Weight should be bold for {tag}")
def test_apply_element_font_styles_color_parsing(self): def test_apply_element_font_styles_color_parsing(self):
"""Test color parsing - adapted from test_color_variations.""" """Test color parsing - adapted from test_color_variations."""
@ -156,7 +160,9 @@ class TestUtilityFunctions(unittest.TestCase):
def test_apply_element_styling_with_classes_and_styles(self): def test_apply_element_styling_with_classes_and_styles(self):
"""Test complete element styling - adapted from test_span_with_inline_styles.""" """Test complete element styling - adapted from test_span_with_inline_styles."""
# From: '<span style="color: red; font-weight: bold;">this part is red and bold</span>' # From: '<span style="color: red; font-weight: bold;">this part is red and bold</span>'
soup = BeautifulSoup('<span class="highlight" style="color: red; font-weight: bold;">text</span>', 'html.parser') soup = BeautifulSoup(
'<span class="highlight" style="color: red; font-weight: bold;">text</span>',
'html.parser')
element = soup.find('span') element = soup.find('span')
base_context = create_base_context() base_context = create_base_context()
@ -197,7 +203,9 @@ class TestExtractTextContent(unittest.TestCase):
def test_extract_styled_text_bold(self): def test_extract_styled_text_bold(self):
"""Test extracting bold styled text - adapted from test_bold_text.""" """Test extracting bold styled text - adapted from test_bold_text."""
# From: "<p>This is <strong>bold text</strong> in a paragraph.</p>" # From: "<p>This is <strong>bold text</strong> in a paragraph.</p>"
soup = BeautifulSoup('<span>This is <strong>bold text</strong> in a paragraph.</span>', 'html.parser') soup = BeautifulSoup(
'<span>This is <strong>bold text</strong> in a paragraph.</span>',
'html.parser')
element = soup.find('span') element = soup.find('span')
words = extract_text_content(element, self.base_context) words = extract_text_content(element, self.base_context)
@ -214,15 +222,20 @@ class TestExtractTextContent(unittest.TestCase):
def test_extract_nested_formatting(self): def test_extract_nested_formatting(self):
"""Test nested formatting - adapted from test_nested_formatting.""" """Test nested formatting - adapted from test_nested_formatting."""
# From: "<p>This has <strong>bold with <em>italic inside</em></strong> formatting.</p>" # From: "<p>This has <strong>bold with <em>italic inside</em></strong> formatting.</p>"
soup = BeautifulSoup('<span>This has <strong>bold with <em>italic inside</em></strong> formatting.</span>', 'html.parser') soup = BeautifulSoup(
'<span>This has <strong>bold with <em>italic inside</em></strong> formatting.</span>',
'html.parser')
element = soup.find('span') element = soup.find('span')
words = extract_text_content(element, self.base_context) words = extract_text_content(element, self.base_context)
# Find words that should be both bold and italic # Find words that should be both bold and italic
bold_italic_words = [w for w in words bold_italic_words = [w for w in words if w.style.weight ==
if w.style.weight == FontWeight.BOLD and w.style.style == FontStyle.ITALIC] FontWeight.BOLD and w.style.style == FontStyle.ITALIC]
self.assertGreater(len(bold_italic_words), 0, "Should have words that are both bold and italic") self.assertGreater(
len(bold_italic_words),
0,
"Should have words that are both bold and italic")
class TestHandlerFunctions(unittest.TestCase): class TestHandlerFunctions(unittest.TestCase):
@ -273,7 +286,9 @@ class TestHandlerFunctions(unittest.TestCase):
def test_blockquote_handler(self): def test_blockquote_handler(self):
"""Test blockquote handler - adapted from test_blockquote.""" """Test blockquote handler - adapted from test_blockquote."""
# From: "<blockquote><p>This is a quoted paragraph.</p></blockquote>" # From: "<blockquote><p>This is a quoted paragraph.</p></blockquote>"
soup = BeautifulSoup('<blockquote><p>This is a quoted paragraph.</p></blockquote>', 'html.parser') soup = BeautifulSoup(
'<blockquote><p>This is a quoted paragraph.</p></blockquote>',
'html.parser')
element = soup.find('blockquote') element = soup.find('blockquote')
result = blockquote_handler(element, self.base_context) result = blockquote_handler(element, self.base_context)
@ -288,7 +303,9 @@ class TestHandlerFunctions(unittest.TestCase):
def test_preformatted_handler(self): def test_preformatted_handler(self):
"""Test preformatted handler - adapted from test_preformatted_code.""" """Test preformatted handler - adapted from test_preformatted_code."""
# From: "<pre><code>function hello() {\n console.log('Hello');\n}</code></pre>" # From: "<pre><code>function hello() {\n console.log('Hello');\n}</code></pre>"
soup = BeautifulSoup('<pre><code>function hello() {\n console.log(\'Hello\');\n}</code></pre>', 'html.parser') soup = BeautifulSoup(
'<pre><code>function hello() {\n console.log(\'Hello\');\n}</code></pre>',
'html.parser')
element = soup.find('pre') element = soup.find('pre')
result = preformatted_handler(element, self.base_context) result = preformatted_handler(element, self.base_context)
@ -302,7 +319,9 @@ class TestHandlerFunctions(unittest.TestCase):
def test_unordered_list_handler(self): def test_unordered_list_handler(self):
"""Test unordered list handler - adapted from test_unordered_list.""" """Test unordered list handler - adapted from test_unordered_list."""
# From: "<ul><li>First item</li><li>Second item</li><li>Third item</li></ul>" # From: "<ul><li>First item</li><li>Second item</li><li>Third item</li></ul>"
soup = BeautifulSoup('<ul><li>First item</li><li>Second item</li><li>Third item</li></ul>', 'html.parser') soup = BeautifulSoup(
'<ul><li>First item</li><li>Second item</li><li>Third item</li></ul>',
'html.parser')
element = soup.find('ul') element = soup.find('ul')
result = unordered_list_handler(element, self.base_context) result = unordered_list_handler(element, self.base_context)
@ -317,7 +336,9 @@ class TestHandlerFunctions(unittest.TestCase):
def test_ordered_list_handler(self): def test_ordered_list_handler(self):
"""Test ordered list handler - adapted from test_ordered_list.""" """Test ordered list handler - adapted from test_ordered_list."""
# From: "<ol><li>First item</li><li>Second item</li><li>Third item</li></ol>" # From: "<ol><li>First item</li><li>Second item</li><li>Third item</li></ol>"
soup = BeautifulSoup('<ol><li>First item</li><li>Second item</li><li>Third item</li></ol>', 'html.parser') soup = BeautifulSoup(
'<ol><li>First item</li><li>Second item</li><li>Third item</li></ol>',
'html.parser')
element = soup.find('ol') element = soup.find('ol')
result = ordered_list_handler(element, self.base_context) result = ordered_list_handler(element, self.base_context)
@ -405,7 +426,9 @@ class TestHandlerFunctions(unittest.TestCase):
def test_image_handler(self): def test_image_handler(self):
"""Test image handler.""" """Test image handler."""
soup = BeautifulSoup('<img src="test.jpg" alt="Test image" width="100" height="50">', 'html.parser') soup = BeautifulSoup(
'<img src="test.jpg" alt="Test image" width="100" height="50">',
'html.parser')
element = soup.find('img') element = soup.find('img')
# Need to apply styling first to get attributes # Need to apply styling first to get attributes
@ -421,7 +444,9 @@ class TestHandlerFunctions(unittest.TestCase):
def test_div_handler_container(self): def test_div_handler_container(self):
"""Test div handler - adapted from test_div_container.""" """Test div handler - adapted from test_div_container."""
# From: "<div><p>First paragraph.</p><p>Second paragraph.</p></div>" # From: "<div><p>First paragraph.</p><p>Second paragraph.</p></div>"
soup = BeautifulSoup('<div><p>First paragraph.</p><p>Second paragraph.</p></div>', 'html.parser') soup = BeautifulSoup(
'<div><p>First paragraph.</p><p>Second paragraph.</p></div>',
'html.parser')
element = soup.find('div') element = soup.find('div')
result = div_handler(element, self.base_context) result = div_handler(element, self.base_context)
@ -443,7 +468,9 @@ class TestStyledContentHandling(unittest.TestCase):
def test_paragraph_with_bold_content(self): def test_paragraph_with_bold_content(self):
"""Test paragraph with bold content - adapted from test_bold_text.""" """Test paragraph with bold content - adapted from test_bold_text."""
# From: "<p>This is <strong>bold text</strong> in a paragraph.</p>" # From: "<p>This is <strong>bold text</strong> in a paragraph.</p>"
soup = BeautifulSoup('<p>This is <strong>bold text</strong> in a paragraph.</p>', 'html.parser') soup = BeautifulSoup(
'<p>This is <strong>bold text</strong> in a paragraph.</p>',
'html.parser')
element = soup.find('p') element = soup.find('p')
result = paragraph_handler(element, self.base_context) result = paragraph_handler(element, self.base_context)
@ -467,8 +494,12 @@ class TestStyledContentHandling(unittest.TestCase):
def test_paragraph_with_mixed_formatting(self): def test_paragraph_with_mixed_formatting(self):
"""Test mixed formatting - adapted from test_mixed_formatting.""" """Test mixed formatting - adapted from test_mixed_formatting."""
# From: "<p>This paragraph contains <strong>bold</strong>, <em>italic</em>, <span style=\"color: blue;\">blue</span>..." # From: "<p>This paragraph contains <strong>bold</strong>, <em>italic</em>..."
soup = BeautifulSoup('<p>This paragraph contains <strong>bold</strong>, <em>italic</em>, <span style="color: blue;">blue</span> text.</p>', 'html.parser') html_str = (
'<p>This paragraph contains <strong>bold</strong>, <em>italic</em>, '
'<span style="color: blue;">blue</span> text.</p>'
)
soup = BeautifulSoup(html_str, 'html.parser')
element = soup.find('p') element = soup.find('p')
result = paragraph_handler(element, self.base_context) result = paragraph_handler(element, self.base_context)

View File

@ -18,10 +18,13 @@ class TestHTMLFileLoader(unittest.TestCase):
def test_load_html_file(self): def test_load_html_file(self):
"""Test loading and parsing an HTML file from disk.""" """Test loading and parsing an HTML file from disk."""
# Path to the test HTML file # Path to the test HTML file
html_file_path = os.path.join("tests", "data", "Kimi Räikkönen - Wikipedia.html") html_file_path = os.path.join(
"tests", "data", "Kimi Räikkönen - Wikipedia.html")
# Verify the test file exists # Verify the test file exists
self.assertTrue(os.path.exists(html_file_path), f"Test HTML file not found: {html_file_path}") self.assertTrue(
os.path.exists(html_file_path),
f"Test HTML file not found: {html_file_path}")
# Read the HTML file # Read the HTML file
with open(html_file_path, 'r', encoding='utf-8') as file: with open(html_file_path, 'r', encoding='utf-8') as file:
@ -38,17 +41,25 @@ class TestHTMLFileLoader(unittest.TestCase):
# Verify we got some blocks # Verify we got some blocks
self.assertIsInstance(blocks, list, "parse_html_string should return a list") self.assertIsInstance(blocks, list, "parse_html_string should return a list")
self.assertGreater(len(blocks), 0, "Should extract at least one block from the HTML file") self.assertGreater(
len(blocks),
0,
"Should extract at least one block from the HTML file")
# Verify all returned items are Block instances # Verify all returned items are Block instances
for i, block in enumerate(blocks): for i, block in enumerate(blocks):
self.assertIsInstance(block, Block, f"Item {i} should be a Block instance, got {type(block)}") self.assertIsInstance(
block,
Block,
f"Item {i} should be a Block instance, got {
type(block)}")
print(f"Successfully loaded and parsed HTML file with {len(blocks)} blocks") print(f"Successfully loaded and parsed HTML file with {len(blocks)} blocks")
def test_load_html_file_with_custom_font(self): def test_load_html_file_with_custom_font(self):
"""Test loading HTML file with a custom base font.""" """Test loading HTML file with a custom base font."""
html_file_path = os.path.join("tests", "data", "Kimi Räikkönen - Wikipedia.html") html_file_path = os.path.join(
"tests", "data", "Kimi Räikkönen - Wikipedia.html")
# Skip if file doesn't exist # Skip if file doesn't exist
if not os.path.exists(html_file_path): if not os.path.exists(html_file_path):
@ -66,11 +77,14 @@ class TestHTMLFileLoader(unittest.TestCase):
# Verify we got blocks # Verify we got blocks
self.assertGreater(len(blocks), 0, "Should extract blocks with custom font") self.assertGreater(len(blocks), 0, "Should extract blocks with custom font")
print(f"Successfully parsed HTML file with custom font, got {len(blocks)} blocks") print(
f"Successfully parsed HTML file with custom font, got {
len(blocks)} blocks")
def test_load_html_file_content_types(self): def test_load_html_file_content_types(self):
"""Test that the loaded HTML file contains expected content types.""" """Test that the loaded HTML file contains expected content types."""
html_file_path = os.path.join("tests", "data", "Kimi Räikkönen - Wikipedia.html") html_file_path = os.path.join(
"tests", "data", "Kimi Räikkönen - Wikipedia.html")
# Skip if file doesn't exist # Skip if file doesn't exist
if not os.path.exists(html_file_path): if not os.path.exists(html_file_path):
@ -86,13 +100,17 @@ class TestHTMLFileLoader(unittest.TestCase):
unique_types = set(block_type_names) unique_types = set(block_type_names)
# A Wikipedia page should contain multiple types of content # A Wikipedia page should contain multiple types of content
self.assertGreater(len(unique_types), 1, "Should have multiple types of blocks in Wikipedia page") self.assertGreater(
len(unique_types),
1,
"Should have multiple types of blocks in Wikipedia page")
print(f"Found block types: {sorted(unique_types)}") print(f"Found block types: {sorted(unique_types)}")
def test_html_file_size_handling(self): def test_html_file_size_handling(self):
"""Test that large HTML files can be handled gracefully.""" """Test that large HTML files can be handled gracefully."""
html_file_path = os.path.join("tests", "data", "Kimi Räikkönen - Wikipedia.html") html_file_path = os.path.join(
"tests", "data", "Kimi Räikkönen - Wikipedia.html")
# Skip if file doesn't exist # Skip if file doesn't exist
if not os.path.exists(html_file_path): if not os.path.exists(html_file_path):

View File

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

View File

@ -155,8 +155,11 @@ class TestHTMLLinkInteractivity(unittest.TestCase):
html = ''' html = '''
<div> <div>
<h2 style="text-align: center; font-size: 18px; font-weight: bold; margin: 10px 0;">Settings</h2> <h2 style="text-align: center; font-size: 18px; font-weight: bold; margin: 10px 0;">Settings</h2>
<p style="padding: 15px; margin: 5px 0; background-color: #dc3545; text-align: center; border-radius: 5px;"> <p style="padding: 15px; margin: 5px 0; background-color: #dc3545; text-align: center;
<a href="action:back_to_library" style="text-decoration: none; color: white; font-weight: bold; font-size: 14px;"> Back to Library</a> border-radius: 5px;">
<a href="action:back_to_library"
style="text-decoration: none; color: white; font-weight: bold; font-size: 14px;">
Back to Library</a>
</p> </p>
<p style="padding: 10px; margin: 5px 0; background-color: #f8f9fa; border-radius: 5px;"> <p style="padding: 10px; margin: 5px 0; background-color: #f8f9fa; border-radius: 5px;">
<span style="font-weight: bold;">Font Size: 100%</span><br> <span style="font-weight: bold;">Font Size: 100%</span><br>
@ -177,7 +180,10 @@ class TestHTMLLinkInteractivity(unittest.TestCase):
all_linked_words.append(word) all_linked_words.append(word)
# Verify we found the expected links # Verify we found the expected links
self.assertGreater(len(all_linked_words), 0, "Should find LinkedWords in settings HTML") self.assertGreater(
len(all_linked_words),
0,
"Should find LinkedWords in settings HTML")
# Check for specific link targets # Check for specific link targets
link_targets = {word.location for word in all_linked_words} link_targets = {word.location for word in all_linked_words}

View File

@ -7,13 +7,11 @@ from bs4 import BeautifulSoup
from pyWebLayout.io.readers.html_extraction import ( from pyWebLayout.io.readers.html_extraction import (
parse_html_string, parse_html_string,
extract_text_content, extract_text_content,
create_base_context, create_base_context
apply_element_styling
) )
from pyWebLayout.abstract.inline import LinkedWord from pyWebLayout.abstract.inline import LinkedWord
from pyWebLayout.abstract.functional import LinkType from pyWebLayout.abstract.functional import LinkType
from pyWebLayout.abstract.block import Paragraph from pyWebLayout.abstract.block import Paragraph
from pyWebLayout.style import Font
class TestHTMLLinkExtraction(unittest.TestCase): class TestHTMLLinkExtraction(unittest.TestCase):
@ -156,7 +154,8 @@ class TestHTMLLinkExtraction(unittest.TestCase):
words = list(paragraph.words) words = list(paragraph.words)
# Find LinkedWords and their locations # Find LinkedWords and their locations
linked_words = [(w.text, w.location) for w in words if isinstance(w, LinkedWord)] linked_words = [(w.text, w.location)
for w in words if isinstance(w, LinkedWord)]
# Should have "first" linked to /page1 and "second" linked to /page2 # Should have "first" linked to /page1 and "second" linked to /page2
self.assertIn(("first", "/page1"), linked_words) self.assertIn(("first", "/page1"), linked_words)

View File

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

View File

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

View File

@ -48,7 +48,10 @@ class TestHTMLLinksInEreader(unittest.TestCase):
if isinstance(word, LinkedWord): if isinstance(word, LinkedWord):
all_linked_words.append(word) all_linked_words.append(word)
self.assertGreater(len(all_linked_words), 0, "Should create LinkedWords from HTML") self.assertGreater(
len(all_linked_words),
0,
"Should create LinkedWords from HTML")
print(f"\n Created {len(all_linked_words)} LinkedWords from HTML") print(f"\n Created {len(all_linked_words)} LinkedWords from HTML")
# Step 2: Create EreaderLayoutManager (like the dreader app does) # Step 2: Create EreaderLayoutManager (like the dreader app does)
@ -91,7 +94,9 @@ class TestHTMLLinksInEreader(unittest.TestCase):
print(f" - '{elem['text']}' -> {elem['location']}") print(f" - '{elem['text']}' -> {elem['location']}")
# THIS IS THE KEY ASSERTION # THIS IS THE KEY ASSERTION
self.assertGreater(len(interactive_elements), 0, self.assertGreater(
len(interactive_elements),
0,
"Settings overlay should have interactive LinkText objects after rendering!") "Settings overlay should have interactive LinkText objects after rendering!")
# Verify the expected links are present # Verify the expected links are present
@ -134,7 +139,8 @@ class TestHTMLLinksInEreader(unittest.TestCase):
if found_link: if found_link:
break break
self.assertTrue(found_link, self.assertTrue(
found_link,
"Should be able to detect link via query_point somewhere on the page") "Should be able to detect link via query_point somewhere on the page")

View File

@ -5,14 +5,12 @@ This test focuses on verifying that the document layouter properly
integrates word spacing constraints from the style system. integrates word spacing constraints from the style system.
""" """
import pytest from unittest.mock import Mock, patch
from unittest.mock import Mock, MagicMock, patch
from typing import List, Optional
from pyWebLayout.layout.document_layouter import paragraph_layouter, table_layouter, DocumentLayouter from pyWebLayout.layout.document_layouter import paragraph_layouter, table_layouter, DocumentLayouter
from pyWebLayout.style.abstract_style import AbstractStyle from pyWebLayout.style.abstract_style import AbstractStyle
from pyWebLayout.style.concrete_style import ConcreteStyle, StyleResolver, RenderingContext from pyWebLayout.style.concrete_style import StyleResolver, RenderingContext
from pyWebLayout.abstract.block import Table, TableRow, TableCell from pyWebLayout.abstract.block import Table
from pyWebLayout.concrete.table import TableStyle from pyWebLayout.concrete.table import TableStyle
@ -61,8 +59,10 @@ class TestDocumentLayouter:
# Create mock font that returns proper numeric metrics (not Mock objects) # Create mock font that returns proper numeric metrics (not Mock objects)
mock_font = Mock() mock_font = Mock()
# CRITICAL: getmetrics() must return actual numeric values, not Mock objects # CRITICAL: getmetrics() must return actual numeric values, not Mock objects
# This prevents "TypeError: '>' not supported between instances of 'Mock' and 'Mock'" # This prevents "TypeError: '>' not supported between instances of 'Mock'
mock_font.getmetrics.return_value = (12, 4) # (ascent, descent) as actual integers # and 'Mock'"
# (ascent, descent) as actual integers
mock_font.getmetrics.return_value = (12, 4)
mock_font.font = mock_font # For accessing .font property mock_font.font = mock_font # For accessing .font property
# Create mock font object that can be used by create_font # Create mock font object that can be used by create_font
@ -84,7 +84,11 @@ class TestDocumentLayouter:
@patch('pyWebLayout.layout.document_layouter.StyleResolver') @patch('pyWebLayout.layout.document_layouter.StyleResolver')
@patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry') @patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry')
@patch('pyWebLayout.layout.document_layouter.Line') @patch('pyWebLayout.layout.document_layouter.Line')
def test_paragraph_layouter_basic_flow(self, mock_line_class, mock_style_registry_class, mock_style_resolver_class): def test_paragraph_layouter_basic_flow(
self,
mock_line_class,
mock_style_registry_class,
mock_style_resolver_class):
"""Test basic paragraph layouter functionality.""" """Test basic paragraph layouter functionality."""
# Setup mocks for StyleResolver and ConcreteStyleRegistry # Setup mocks for StyleResolver and ConcreteStyleRegistry
mock_style_resolver = Mock() mock_style_resolver = Mock()
@ -110,7 +114,8 @@ class TestDocumentLayouter:
# Verify StyleResolver and ConcreteStyleRegistry were created correctly # Verify StyleResolver and ConcreteStyleRegistry were created correctly
mock_style_resolver_class.assert_called_once() mock_style_resolver_class.assert_called_once()
mock_style_registry_class.assert_called_once_with(mock_style_resolver) mock_style_registry_class.assert_called_once_with(mock_style_resolver)
mock_style_registry.get_concrete_style.assert_called_once_with(self.mock_paragraph.style) mock_style_registry.get_concrete_style.assert_called_once_with(
self.mock_paragraph.style)
# Verify Line was created with correct spacing constraints # Verify Line was created with correct spacing constraints
expected_spacing = (2, 8) # From mock_concrete_style expected_spacing = (2, 8) # From mock_concrete_style
@ -121,7 +126,8 @@ class TestDocumentLayouter:
@patch('pyWebLayout.layout.document_layouter.StyleResolver') @patch('pyWebLayout.layout.document_layouter.StyleResolver')
@patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry') @patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry')
@patch('pyWebLayout.layout.document_layouter.Line') @patch('pyWebLayout.layout.document_layouter.Line')
def test_paragraph_layouter_word_spacing_constraints_extraction(self, mock_line_class, mock_style_registry_class, mock_style_resolver_class): def test_paragraph_layouter_word_spacing_constraints_extraction(
self, mock_line_class, mock_style_registry_class, mock_style_resolver_class):
"""Test that word spacing constraints are correctly extracted from style.""" """Test that word spacing constraints are correctly extracted from style."""
# Create concrete style with specific constraints # Create concrete style with specific constraints
concrete_style = Mock() concrete_style = Mock()
@ -159,7 +165,11 @@ class TestDocumentLayouter:
@patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry') @patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry')
@patch('pyWebLayout.layout.document_layouter.Line') @patch('pyWebLayout.layout.document_layouter.Line')
@patch('pyWebLayout.layout.document_layouter.Text') @patch('pyWebLayout.layout.document_layouter.Text')
def test_paragraph_layouter_line_overflow(self, mock_text_class, mock_line_class, mock_style_registry_class): def test_paragraph_layouter_line_overflow(
self,
mock_text_class,
mock_line_class,
mock_style_registry_class):
"""Test handling of line overflow when words don't fit.""" """Test handling of line overflow when words don't fit."""
# Setup mocks # Setup mocks
mock_style_registry = Mock() mock_style_registry = Mock()
@ -206,7 +216,8 @@ class TestDocumentLayouter:
@patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry') @patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry')
@patch('pyWebLayout.layout.document_layouter.Line') @patch('pyWebLayout.layout.document_layouter.Line')
def test_paragraph_layouter_page_full(self, mock_line_class, mock_style_registry_class): def test_paragraph_layouter_page_full(
self, mock_line_class, mock_style_registry_class):
"""Test handling when page runs out of space.""" """Test handling when page runs out of space."""
# Setup mocks # Setup mocks
mock_style_registry = Mock() mock_style_registry = Mock()
@ -275,7 +286,8 @@ class TestDocumentLayouter:
with patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry'): with patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry'):
layouter = DocumentLayouter(self.mock_page) layouter = DocumentLayouter(self.mock_page)
result = layouter.layout_paragraph(self.mock_paragraph, start_word=2, pretext="test") result = layouter.layout_paragraph(
self.mock_paragraph, start_word=2, pretext="test")
# Verify the function was called correctly # Verify the function was called correctly
mock_paragraph_layouter.assert_called_once_with( mock_paragraph_layouter.assert_called_once_with(
@ -363,7 +375,8 @@ class TestWordSpacingConstraintsInLayout:
(None, None, None, 2.0, 8.0), # Default case (None, None, None, 2.0, 8.0), # Default case
(5.0, None, None, 5.0, 10.0), # Only base specified (5.0, None, None, 5.0, 10.0), # Only base specified
(4.0, 2.0, 8.0, 2.0, 8.0), # All specified (4.0, 2.0, 8.0, 2.0, 8.0), # All specified
(3.0, 1.0, None, 1.0, 3.0), # Min specified, max = max(word_spacing, min*2) = max(3.0, 2.0) = 3.0 # Min specified, max = max(word_spacing, min*2) = max(3.0, 2.0) = 3.0
(3.0, 1.0, None, 1.0, 3.0),
(6.0, None, 12.0, 6.0, 12.0), # Max specified, min from base (6.0, None, 12.0, 6.0, 12.0), # Max specified, min from base
] ]
@ -421,7 +434,6 @@ class TestMultiPageLayout:
self.mock_concrete_style.text_align = "justify" self.mock_concrete_style.text_align = "justify"
self.mock_concrete_style.create_font = Mock() self.mock_concrete_style.create_font = Mock()
@patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry') @patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry')
def test_document_layouter_multi_page_scenario(self, mock_style_registry_class): def test_document_layouter_multi_page_scenario(self, mock_style_registry_class):
"""Test DocumentLayouter handling multiple pages with continuation.""" """Test DocumentLayouter handling multiple pages with continuation."""
@ -480,7 +492,7 @@ class TestMultiPageLayout:
layouter = MultiPageDocumentLayouter(self.mock_pages) layouter = MultiPageDocumentLayouter(self.mock_pages)
# Mock the layout_paragraph method to simulate page filling # Mock the layout_paragraph method to simulate page filling
original_layout_paragraph = layouter.layout_paragraph layouter.layout_paragraph
call_count = [0] call_count = [0]
def mock_layout_paragraph(paragraph, start_word=0, pretext=None): def mock_layout_paragraph(paragraph, start_word=0, pretext=None):
@ -502,7 +514,8 @@ class TestMultiPageLayout:
layouter.layout_paragraph = mock_layout_paragraph layouter.layout_paragraph = mock_layout_paragraph
# Test multi-page layout # Test multi-page layout
success, message = layouter.layout_document_with_pagination([self.long_paragraph]) success, message = layouter.layout_document_with_pagination(
[self.long_paragraph])
# Verify results # Verify results
assert success is True assert success is True
@ -510,7 +523,6 @@ class TestMultiPageLayout:
assert call_count[0] == 3 # Should have made 3 layout attempts assert call_count[0] == 3 # Should have made 3 layout attempts
assert layouter.current_page_index == 2 # Should end on page 3 (index 2) assert layouter.current_page_index == 2 # Should end on page 3 (index 2)
def test_realistic_multi_page_scenario(self): def test_realistic_multi_page_scenario(self):
"""Test a realistic scenario with actual content and page constraints.""" """Test a realistic scenario with actual content and page constraints."""
# Create realistic paragraph with varied content # Create realistic paragraph with varied content
@ -573,10 +585,11 @@ class TestMultiPageLayout:
# This test demonstrates the integration without mocking everything # This test demonstrates the integration without mocking everything
# In a real scenario, this would interface with actual Line and Text objects # In a real scenario, this would interface with actual Line and Text objects
print(f"✓ Realistic scenario test completed") print("✓ Realistic scenario test completed")
print(f" - Words to layout: {len(realistic_paragraph.words)}") print(f" - Words to layout: {len(realistic_paragraph.words)}")
print(f" - Page width: {realistic_page.available_width}px") print(f" - Page width: {realistic_page.available_width}px")
print(f" - Word spacing constraints: {concrete_style.word_spacing_min}-{concrete_style.word_spacing_max}px") print(
f" - Word spacing constraints: {concrete_style.word_spacing_min}-{concrete_style.word_spacing_max}px")
class TestTableLayouter: class TestTableLayouter:

View File

@ -7,20 +7,15 @@ in multi-page layout scenarios.
""" """
import pytest import pytest
from unittest.mock import Mock, patch
from PIL import Image, ImageDraw
import numpy as np
from typing import List, Optional
import os import os
import logging import logging
from pyWebLayout.layout.document_layouter import paragraph_layouter, DocumentLayouter from pyWebLayout.layout.document_layouter import paragraph_layouter
from pyWebLayout.style.abstract_style import AbstractStyle from pyWebLayout.style.abstract_style import AbstractStyle
from pyWebLayout.style.concrete_style import ConcreteStyle, StyleResolver, RenderingContext
from pyWebLayout.style.fonts import Font from pyWebLayout.style.fonts import Font
from pyWebLayout.style.page_style import PageStyle from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.concrete.page import Page from pyWebLayout.concrete.page import Page
from pyWebLayout.concrete.text import Line, Text from pyWebLayout.concrete.text import Line
from pyWebLayout.abstract.inline import Word from pyWebLayout.abstract.inline import Word
# Enable logging to see font loading messages # Enable logging to see font loading messages
@ -34,7 +29,12 @@ def verify_bundled_font_available():
current_dir = os.path.dirname(os.path.abspath(__file__)) current_dir = os.path.dirname(os.path.abspath(__file__))
# Navigate up to pyWebLayout root, then to assets/fonts # Navigate up to pyWebLayout root, then to assets/fonts
project_root = os.path.dirname(os.path.dirname(current_dir)) project_root = os.path.dirname(os.path.dirname(current_dir))
bundled_font_path = os.path.join(project_root, 'pyWebLayout', 'assets', 'fonts', 'DejaVuSans.ttf') bundled_font_path = os.path.join(
project_root,
'pyWebLayout',
'assets',
'fonts',
'DejaVuSans.ttf')
logger.info(f"Integration tests checking for bundled font at: {bundled_font_path}") logger.info(f"Integration tests checking for bundled font at: {bundled_font_path}")
@ -58,7 +58,8 @@ class MockWord(Word):
style = Font(font_size=16) style = Font(font_size=16)
# Verify the font loaded properly # Verify the font loaded properly
if style.font.path is None: if style.font.path is None:
logger.warning("Font loaded without explicit path - may be using PIL default") logger.warning(
"Font loaded without explicit path - may be using PIL default")
# Initialize the base Word with required parameters # Initialize the base Word with required parameters
super().__init__(text, style) super().__init__(text, style)
self._concrete_texts = [] self._concrete_texts = []
@ -122,7 +123,8 @@ class TestDocumentLayouterIntegration:
) )
# Layout the paragraph # Layout the paragraph
success, failed_word_index, remaining_pretext = paragraph_layouter(paragraph, page) success, failed_word_index, remaining_pretext = paragraph_layouter(
paragraph, page)
# Verify successful layout # Verify successful layout
assert success is True assert success is True
@ -145,25 +147,30 @@ class TestDocumentLayouterIntegration:
small_page = Page(size=(150, 80), style=small_page_style) small_page = Page(size=(150, 80), style=small_page_style)
# Create a long paragraph that will definitely overflow # Create a long paragraph that will definitely overflow
long_text = " ".join([f"verylongword{i:02d}" for i in range(20)]) # 20 long words long_text = " ".join(
[f"verylongword{i:02d}" for i in range(20)]) # 20 long words
paragraph = MockParagraph( paragraph = MockParagraph(
long_text, long_text,
AbstractStyle(word_spacing=4.0, word_spacing_min=2.0, word_spacing_max=8.0) AbstractStyle(word_spacing=4.0, word_spacing_min=2.0, word_spacing_max=8.0)
) )
# Layout the paragraph - should fail due to page overflow # Layout the paragraph - should fail due to page overflow
success, failed_word_index, remaining_pretext = paragraph_layouter(paragraph, small_page) success, failed_word_index, remaining_pretext = paragraph_layouter(
paragraph, small_page)
# Either should fail due to overflow OR succeed with limited content # Either should fail due to overflow OR succeed with limited content
if success: if success:
# If it succeeded, verify it fit some content # If it succeeded, verify it fit some content
assert len(small_page.children) > 0 assert len(small_page.children) > 0
print(f"✓ Multi-page test: Content fit on small page, {len(small_page.children)} lines created") print(
f"✓ Multi-page test: Content fit on small page, {len(small_page.children)} lines created")
else: else:
# If it failed, verify overflow handling # If it failed, verify overflow handling
assert failed_word_index is not None # Should indicate where it failed assert failed_word_index is not None # Should indicate where it failed
assert failed_word_index < len(paragraph.words) # Should be within word range assert failed_word_index < len(
print(f"✓ Multi-page test: Page overflow at word {failed_word_index}, {len(small_page.children)} lines fit") paragraph.words) # Should be within word range
print(
f"✓ Multi-page test: Page overflow at word {failed_word_index}, {len(small_page.children)} lines fit")
def test_word_spacing_constraints_in_real_lines(self): def test_word_spacing_constraints_in_real_lines(self):
"""Test that word spacing constraints are properly used in real Line objects.""" """Test that word spacing constraints are properly used in real Line objects."""
@ -192,23 +199,22 @@ class TestDocumentLayouterIntegration:
assert min_spacing == 3 # From our constraint assert min_spacing == 3 # From our constraint
assert max_spacing == 10 # From our constraint assert max_spacing == 10 # From our constraint
print(f"✓ Word spacing test: {len(page.children)} lines with constraints (3, 10)") print(
f"✓ Word spacing test: {len(page.children)} lines with constraints (3, 10)")
def test_different_alignment_strategies_with_constraints(self): def test_different_alignment_strategies_with_constraints(self):
"""Test different text alignment strategies with word spacing constraints.""" """Test different text alignment strategies with word spacing constraints."""
alignments_to_test = [ alignments_to_test = [
("left", AbstractStyle(text_align="left", word_spacing_min=2.0, word_spacing_max=6.0)), ("left", AbstractStyle(
("justify", AbstractStyle(text_align="justify", word_spacing_min=3.0, word_spacing_max=12.0)), text_align="left", word_spacing_min=2.0, word_spacing_max=6.0)), ("justify", AbstractStyle(
("center", AbstractStyle(text_align="center", word_spacing_min=1.0, word_spacing_max=5.0)) text_align="justify", word_spacing_min=3.0, word_spacing_max=12.0)), ("center", AbstractStyle(
] text_align="center", word_spacing_min=1.0, word_spacing_max=5.0))]
for alignment_name, style in alignments_to_test: for alignment_name, style in alignments_to_test:
page_style = PageStyle(border_width=20, padding=(10, 10, 10, 10)) page_style = PageStyle(border_width=20, padding=(10, 10, 10, 10))
page = Page(size=(350, 200), style=page_style) page = Page(size=(350, 200), style=page_style)
paragraph = MockParagraph( paragraph = MockParagraph(
"This sentence will test different alignment strategies with word spacing.", "This sentence will test different alignment strategies with word spacing.", style)
style
)
success, _, _ = paragraph_layouter(paragraph, page) success, _, _ = paragraph_layouter(paragraph, page)
assert success is True assert success is True
@ -231,15 +237,19 @@ class TestDocumentLayouterIntegration:
# Create a document with multiple paragraphs # Create a document with multiple paragraphs
paragraphs = [ paragraphs = [
MockParagraph( MockParagraph(
"This is the first paragraph of our document. It contains enough text to potentially span multiple lines and test the word spacing constraints properly.", "This is the first paragraph of our document. It contains enough text to "
"potentially span multiple lines and test the word spacing constraints properly.",
AbstractStyle(word_spacing=3.0, word_spacing_min=2.0, word_spacing_max=8.0) AbstractStyle(word_spacing=3.0, word_spacing_min=2.0, word_spacing_max=8.0)
), ),
MockParagraph( MockParagraph(
"Here is a second paragraph with different styling. This paragraph uses different word spacing constraints to test the flexibility of the system.", "Here is a second paragraph with different styling. This paragraph uses "
"different word spacing constraints to test the flexibility of the system.",
AbstractStyle(word_spacing=5.0, word_spacing_min=3.0, word_spacing_max=12.0) AbstractStyle(word_spacing=5.0, word_spacing_min=3.0, word_spacing_max=12.0)
), ),
MockParagraph( MockParagraph(
"The third and final paragraph completes our test document. It should demonstrate that the layouter can handle multiple paragraphs with varying content lengths and styling requirements.", "The third and final paragraph completes our test document. It should "
"demonstrate that the layouter can handle multiple paragraphs with varying "
"content lengths and styling requirements.",
AbstractStyle(word_spacing=4.0, word_spacing_min=2.5, word_spacing_max=10.0) AbstractStyle(word_spacing=4.0, word_spacing_min=2.5, word_spacing_max=10.0)
) )
] ]
@ -284,7 +294,7 @@ class TestDocumentLayouterIntegration:
def test_word_spacing_constraint_resolution_integration(self): def test_word_spacing_constraint_resolution_integration(self):
"""Test the complete integration from AbstractStyle to Line spacing.""" """Test the complete integration from AbstractStyle to Line spacing."""
page_style = PageStyle(border_width=20, padding=(10, 10, 10, 10)) page_style = PageStyle(border_width=20, padding=(10, 10, 10, 10))
page = Page(size=(400, 600), style=page_style) _page = Page(size=(400, 600), style=page_style)
# Test different constraint scenarios # Test different constraint scenarios
test_cases = [ test_cases = [
@ -325,8 +335,10 @@ class TestDocumentLayouterIntegration:
line = test_page.children[0] line = test_page.children[0]
min_spacing, max_spacing = line._spacing min_spacing, max_spacing = line._spacing
assert min_spacing == case["expected_min"], f"Min constraint failed for {case['name']}" assert min_spacing == case["expected_min"], f"Min constraint failed for {
assert max_spacing == case["expected_max"], f"Max constraint failed for {case['name']}" case['name']}"
assert max_spacing == case["expected_max"], f"Max constraint failed for {
case['name']}"
print(f"{case['name']}: constraints ({min_spacing}, {max_spacing})") print(f"{case['name']}: constraints ({min_spacing}, {max_spacing})")
@ -342,7 +354,8 @@ class TestDocumentLayouterIntegration:
AbstractStyle(word_spacing=3.0, word_spacing_min=2.0, word_spacing_max=8.0) AbstractStyle(word_spacing=3.0, word_spacing_min=2.0, word_spacing_max=8.0)
) )
success, failed_word_index, remaining_pretext = paragraph_layouter(paragraph, narrow_page) success, failed_word_index, remaining_pretext = paragraph_layouter(
paragraph, narrow_page)
# Should succeed with hyphenation or handle overflow gracefully # Should succeed with hyphenation or handle overflow gracefully
if success: if success:

View File

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

View File

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

View File

@ -37,7 +37,10 @@ class TestStyleObjects(unittest.TestCase):
self.assertEqual(TextDecoration.STRIKETHROUGH.value, "strikethrough") self.assertEqual(TextDecoration.STRIKETHROUGH.value, "strikethrough")
# Test that all expected values exist # Test that all expected values exist
decorations = [TextDecoration.NONE, TextDecoration.UNDERLINE, TextDecoration.STRIKETHROUGH] decorations = [
TextDecoration.NONE,
TextDecoration.UNDERLINE,
TextDecoration.STRIKETHROUGH]
self.assertEqual(len(decorations), 3) self.assertEqual(len(decorations), 3)
def test_alignment_enum(self): def test_alignment_enum(self):
@ -121,7 +124,9 @@ class TestStyleObjects(unittest.TestCase):
# Test with_decoration # Test with_decoration
decoration_font = original_font.with_decoration(TextDecoration.UNDERLINE) decoration_font = original_font.with_decoration(TextDecoration.UNDERLINE)
self.assertEqual(decoration_font.decoration, TextDecoration.UNDERLINE) self.assertEqual(decoration_font.decoration, TextDecoration.UNDERLINE)
self.assertEqual(original_font.decoration, TextDecoration.NONE) # Original unchanged self.assertEqual(
original_font.decoration,
TextDecoration.NONE) # Original unchanged
def test_font_property_access(self): def test_font_property_access(self):
"""Test Font property access methods.""" """Test Font property access methods."""

View File

@ -8,12 +8,12 @@ based on user preferences.
import pytest import pytest
from pyWebLayout.style.abstract_style import ( from pyWebLayout.style.abstract_style import (
AbstractStyle, AbstractStyleRegistry, FontFamily, FontSize, TextAlign AbstractStyle, AbstractStyleRegistry, FontFamily, FontSize
) )
from pyWebLayout.style.concrete_style import ( from pyWebLayout.style.concrete_style import (
ConcreteStyle, ConcreteStyleRegistry, RenderingContext, StyleResolver ConcreteStyleRegistry, RenderingContext, StyleResolver
) )
from pyWebLayout.style.fonts import FontWeight, FontStyle, TextDecoration from pyWebLayout.style.fonts import FontWeight
def test_abstract_style_is_hashable(): def test_abstract_style_is_hashable():
@ -99,7 +99,8 @@ def test_style_resolver_user_preferences():
# Resolve to concrete style # Resolve to concrete style
concrete_style = resolver.resolve_style(abstract_style) concrete_style = resolver.resolve_style(abstract_style)
# Font size should be: 20 (base) * 1.0 (medium) * 1.5 (scale) * 1.2 (large_text) = 36 # Font size should be: 20 (base) * 1.0 (medium) * 1.5 (scale) * 1.2
# (large_text) = 36
expected_size = int(20 * 1.0 * 1.5 * 1.2) expected_size = int(20 * 1.0 * 1.5 * 1.2)
assert concrete_style.font_size == expected_size assert concrete_style.font_size == expected_size

View File

@ -5,9 +5,8 @@ This test shows how to use the new min/max word spacing constraints
in the style system. in the style system.
""" """
import pytest
from pyWebLayout.style.abstract_style import AbstractStyle, AbstractStyleRegistry from pyWebLayout.style.abstract_style import AbstractStyle, AbstractStyleRegistry
from pyWebLayout.style.concrete_style import ConcreteStyle, StyleResolver, RenderingContext from pyWebLayout.style.concrete_style import StyleResolver, RenderingContext
class TestWordSpacingConstraints: class TestWordSpacingConstraints:

View File

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

View File

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

View File

@ -25,12 +25,17 @@ class TestFontUtilities(unittest.TestCase):
"""Test that get_bundled_font_path finds the bundled font.""" """Test that get_bundled_font_path finds the bundled font."""
font_path = get_bundled_font_path() font_path = get_bundled_font_path()
self.assertIsNotNone(font_path, "Bundled font path should not be None") self.assertIsNotNone(font_path, "Bundled font path should not be None")
self.assertTrue(os.path.exists(font_path), f"Font file should exist at {font_path}") self.assertTrue(
self.assertTrue(font_path.endswith("DejaVuSans.ttf"), "Font path should end with DejaVuSans.ttf") os.path.exists(font_path),
f"Font file should exist at {font_path}")
self.assertTrue(
font_path.endswith("DejaVuSans.ttf"),
"Font path should end with DejaVuSans.ttf")
def test_verify_bundled_font_available(self): def test_verify_bundled_font_available(self):
"""Test that the bundled font can be verified and loaded.""" """Test that the bundled font can be verified and loaded."""
self.assertTrue(verify_bundled_font_available(), "Bundled font should be available and loadable") self.assertTrue(verify_bundled_font_available(),
"Bundled font should be available and loadable")
def test_create_test_font_with_defaults(self): def test_create_test_font_with_defaults(self):
"""Test creating a test font with default parameters.""" """Test creating a test font with default parameters."""
@ -71,7 +76,8 @@ class TestFontUtilities(unittest.TestCase):
try: try:
ensure_consistent_font_in_tests() ensure_consistent_font_in_tests()
except RuntimeError: except RuntimeError:
self.fail("ensure_consistent_font_in_tests() raised RuntimeError when font should be available") self.fail(
"ensure_consistent_font_in_tests() raised RuntimeError when font should be available")
def test_bundled_font_loads_with_pil(self): def test_bundled_font_loads_with_pil(self):
"""Test that the bundled font can be loaded directly with PIL.""" """Test that the bundled font can be loaded directly with PIL."""
@ -93,7 +99,8 @@ class TestFontUtilities(unittest.TestCase):
self.assertEqual(font1.font_size, font2.font_size) self.assertEqual(font1.font_size, font2.font_size)
# Test that text measurements are consistent # Test that text measurements are consistent
# This is a basic check - in real usage, text measurement consistency is what matters most # This is a basic check - in real usage, text measurement consistency is
# what matters most
self.assertEqual(font1.font_size, font2.font_size) self.assertEqual(font1.font_size, font2.font_size)
def test_different_sizes_create_different_fonts(self): def test_different_sizes_create_different_fonts(self):
@ -119,13 +126,17 @@ class TestFontPathResolution(unittest.TestCase):
"""Test that the font path points to a file, not a directory.""" """Test that the font path points to a file, not a directory."""
font_path = get_bundled_font_path() font_path = get_bundled_font_path()
if font_path: if font_path:
self.assertTrue(os.path.isfile(font_path), "Font path should point to a file") self.assertTrue(
os.path.isfile(font_path),
"Font path should point to a file")
def test_font_file_has_correct_extension(self): def test_font_file_has_correct_extension(self):
"""Test that the font file has the expected .ttf extension.""" """Test that the font file has the expected .ttf extension."""
font_path = get_bundled_font_path() font_path = get_bundled_font_path()
if font_path: if font_path:
self.assertTrue(font_path.lower().endswith('.ttf'), "Font file should have .ttf extension") self.assertTrue(
font_path.lower().endswith('.ttf'),
"Font file should have .ttf extension")
if __name__ == '__main__': if __name__ == '__main__':

View File

@ -6,7 +6,6 @@ preventing inconsistencies that can arise from different system fonts.
""" """
import os import os
import sys
from typing import Optional from typing import Optional
from PIL import ImageFont from PIL import ImageFont
@ -30,7 +29,12 @@ def get_bundled_font_path() -> Optional[str]:
project_root = os.path.dirname(os.path.dirname(current_dir)) project_root = os.path.dirname(os.path.dirname(current_dir))
# Path to the bundled font # Path to the bundled font
bundled_font_path = os.path.join(project_root, 'pyWebLayout', 'assets', 'fonts', 'DejaVuSans.ttf') bundled_font_path = os.path.join(
project_root,
'pyWebLayout',
'assets',
'fonts',
'DejaVuSans.ttf')
if os.path.exists(bundled_font_path): if os.path.exists(bundled_font_path):
return bundled_font_path return bundled_font_path
@ -61,7 +65,7 @@ def verify_bundled_font_available() -> bool:
try: try:
# Try to load the font with PIL to verify it's valid # Try to load the font with PIL to verify it's valid
test_font = ImageFont.truetype(font_path, 16) ImageFont.truetype(font_path, 16)
return True return True
except Exception: except Exception:
return False return False