Compare commits

...

3 Commits

Author SHA1 Message Date
4c99282aef setup cfg file
Some checks failed
Python CI / test (push) Failing after 46s
2025-11-08 23:46:28 +01:00
781a9b6c08 auto flake and corrections 2025-11-08 23:46:15 +01:00
1ea870eef5 yet more tests 2025-11-08 19:52:19 +01:00
82 changed files with 4929 additions and 3708 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -21,3 +21,17 @@ install_requires =
[options.packages.find]
include = pyWebLayout*
[flake8]
exclude =
venv,
build,
dist,
.git,
__pycache__,
.pytest_cache,
*.egg-info
max-line-length = 120
extend-ignore = E203
per-file-ignores =
*:F841

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,140 @@
"""
Test that LinkedWord objects remain as LinkText even when hyphenated.
This is a regression test for the bug where hyphenated LinkedWords
were being converted to regular Text objects instead of LinkText.
"""
import unittest
from PIL import Image, ImageDraw
from pyWebLayout.concrete.text import Line
from pyWebLayout.concrete.functional import LinkText
from pyWebLayout.abstract.inline import LinkedWord
from pyWebLayout.abstract.functional import LinkType
from pyWebLayout.style import Font, Alignment
class TestLinkedWordHyphenation(unittest.TestCase):
"""Test that LinkedWords become LinkText objects even when hyphenated."""
def setUp(self):
"""Set up test canvas and drawing context."""
self.canvas = Image.new('RGB', (800, 600), color='white')
self.draw = ImageDraw.Draw(self.canvas)
self.font = Font(font_size=12)
def test_short_linkedword_no_hyphenation(self):
"""Test that a short LinkedWord that fits becomes a LinkText."""
# Create a line with enough space
line = Line(
spacing=(5, 15),
origin=(0, 0),
size=(200, 30),
draw=self.draw,
halign=Alignment.LEFT
)
# Create a LinkedWord that will fit without hyphenation
linked_word = LinkedWord(
text="click",
style=self.font,
location="action:test",
link_type=LinkType.API
)
# Add the word to the line
success, overflow = line.add_word(linked_word)
# Verify it was added successfully
self.assertTrue(success)
self.assertIsNone(overflow)
# Verify it became a LinkText object
self.assertEqual(len(line._text_objects), 1)
self.assertIsInstance(line._text_objects[0], LinkText)
self.assertEqual(line._text_objects[0].link.location, "action:test")
def test_long_linkedword_with_hyphenation(self):
"""Test that a long LinkedWord that needs hyphenation preserves LinkText."""
# Create a narrow line to force hyphenation
line = Line(
spacing=(5, 15),
origin=(0, 0),
size=(80, 30),
draw=self.draw,
halign=Alignment.LEFT
)
# Create a long LinkedWord that will need hyphenation
linked_word = LinkedWord(
text="https://example.com/very-long-url",
style=self.font,
location="https://example.com/very-long-url",
link_type=LinkType.EXTERNAL
)
# Add the word to the line
success, overflow = line.add_word(linked_word)
# The word should either:
# 1. Fit completely and be a LinkText
# 2. Be hyphenated, and BOTH parts should be LinkText
if overflow is not None:
# Word was hyphenated
# The first part should be in the line
self.assertTrue(success)
self.assertGreater(len(line._text_objects), 0)
# Both parts should be LinkText (this is the bug we're testing for)
for text_obj in line._text_objects:
self.assertIsInstance(
text_obj,
LinkText,
f"Hyphenated LinkedWord part should be LinkText, got {
type(text_obj)}")
self.assertEqual(text_obj.link.location, linked_word.location)
# The overflow should also be LinkText if it's hyphenated
if isinstance(overflow, LinkText):
self.assertEqual(overflow.link.location, linked_word.location)
else:
# Word fit without hyphenation
self.assertTrue(success)
self.assertEqual(len(line._text_objects), 1)
self.assertIsInstance(line._text_objects[0], LinkText)
def test_linkedword_title_preserved_after_hyphenation(self):
"""Test that link metadata (title) is preserved when hyphenated."""
# Create a narrow line
line = Line(
spacing=(5, 15),
origin=(0, 0),
size=(60, 30),
draw=self.draw,
halign=Alignment.LEFT
)
# Create a LinkedWord with title that will likely be hyphenated
linked_word = LinkedWord(
text="documentation",
style=self.font,
location="https://docs.example.com",
link_type=LinkType.EXTERNAL,
title="View Documentation"
)
# Add the word
success, overflow = line.add_word(linked_word)
# Verify metadata is preserved
if overflow is not None:
# If hyphenated, both parts should have link metadata
for text_obj in line._text_objects:
if isinstance(text_obj, LinkText):
self.assertEqual(text_obj.link.location, "https://docs.example.com")
self.assertEqual(text_obj.link.title, "View Documentation")
if __name__ == '__main__':
unittest.main()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,148 @@
"""
End-to-end test for HTML links in EreaderLayoutManager.
This test mimics exactly what the dreader application does:
1. Load HTML with links via parse_html_string
2. Create an EreaderLayoutManager
3. Render a page
4. Query for interactive elements
This should reveal if links are actually interactive after full rendering.
"""
import unittest
from pyWebLayout.io.readers.html_extraction import parse_html_string
from pyWebLayout.layout.ereader_manager import EreaderLayoutManager
from pyWebLayout.abstract.inline import LinkedWord
from pyWebLayout.concrete.functional import LinkText
class TestHTMLLinksInEreader(unittest.TestCase):
"""Test HTML link interactivity in the full ereader pipeline."""
def test_settings_overlay_links_are_interactive(self):
"""Test that settings overlay HTML creates interactive links."""
# This is realistic settings overlay HTML
html = '''
<div>
<h2>Settings</h2>
<p>
<a href="action:back_to_library">Back to Library</a>
</p>
<p>
Font Size:
<a href="setting:font_decrease">[-]</a>
<a href="setting:font_increase">[+]</a>
</p>
</div>
'''
# Step 1: Parse HTML to blocks
blocks = parse_html_string(html)
# Verify LinkedWords were created
all_linked_words = []
for block in blocks:
if hasattr(block, 'words'):
for word in block.words:
if isinstance(word, LinkedWord):
all_linked_words.append(word)
self.assertGreater(
len(all_linked_words),
0,
"Should create LinkedWords from HTML")
print(f"\n Created {len(all_linked_words)} LinkedWords from HTML")
# Step 2: Create EreaderLayoutManager (like the dreader app does)
page_size = (400, 600)
manager = EreaderLayoutManager(
blocks=blocks,
page_size=page_size,
document_id="test_settings"
)
# Step 3: Get the rendered page
page = manager.get_current_page()
self.assertIsNotNone(page)
# Step 4: Render to image
rendered_image = page.render()
self.assertIsNotNone(rendered_image)
print(f" Rendered page: {rendered_image.size}")
# Step 5: Find all interactive elements by scanning the page
# This is the CRITICAL test - are there LinkText objects in the page?
interactive_elements = []
# Scan through all children of the page
if hasattr(page, '_children'):
for child in page._children:
# Check if child is a Line
if hasattr(child, '_text_objects'):
for text_obj in child._text_objects:
if isinstance(text_obj, LinkText):
interactive_elements.append({
'type': 'LinkText',
'text': text_obj._text,
'location': text_obj.link.location,
'is_interactive': hasattr(text_obj, 'execute')
})
print(f" Found {len(interactive_elements)} LinkText objects in rendered page")
for elem in interactive_elements:
print(f" - '{elem['text']}' -> {elem['location']}")
# THIS IS THE KEY ASSERTION
self.assertGreater(
len(interactive_elements),
0,
"Settings overlay should have interactive LinkText objects after rendering!")
# Verify the expected links are present
locations = {elem['location'] for elem in interactive_elements}
self.assertIn("action:back_to_library", locations,
"Should find 'Back to Library' link")
self.assertIn("setting:font_decrease", locations,
"Should find font decrease link")
self.assertIn("setting:font_increase", locations,
"Should find font increase link")
def test_query_point_detects_links(self):
"""Test that query_point can detect LinkText objects."""
html = '<p><a href="action:test">Click here</a> to test.</p>'
blocks = parse_html_string(html)
manager = EreaderLayoutManager(
blocks=blocks,
page_size=(400, 200),
document_id="test_query"
)
page = manager.get_current_page()
page.render()
# Try to query various points on the page
# We don't know exact coordinates, so scan a grid
found_link = False
for y in range(20, 100, 10):
for x in range(20, 380, 20):
result = page.query_point((x, y))
if result and result.is_interactive:
print(f"\n Found interactive element at ({x}, {y})")
print(f" Type: {result.object_type}")
print(f" Link target: {result.link_target}")
print(f" Text: {result.text}")
found_link = True
self.assertEqual(result.link_target, "action:test")
break
if found_link:
break
self.assertTrue(
found_link,
"Should be able to detect link via query_point somewhere on the page")
if __name__ == '__main__':
unittest.main()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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