auto flake and corrections
This commit is contained in:
parent
1ea870eef5
commit
781a9b6c08
@ -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")
|
||||
|
||||
@ -11,6 +11,10 @@ This example demonstrates text rendering using the pyWebLayout system:
|
||||
This example uses the HTML parsing system to create rich text layouts.
|
||||
"""
|
||||
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.style import Font
|
||||
from pyWebLayout.io.readers.html_extraction import parse_html_string
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
@ -18,11 +22,6 @@ from PIL import Image, ImageDraw, ImageFont
|
||||
# Add pyWebLayout to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from pyWebLayout.io.readers.html_extraction import parse_html_string
|
||||
from pyWebLayout.style import Font
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
|
||||
|
||||
def create_sample_document():
|
||||
"""Create different HTML samples demonstrating various features."""
|
||||
@ -37,7 +36,8 @@ def create_sample_document():
|
||||
<p>This is left-aligned text. It is the default alignment for most text.</p>
|
||||
|
||||
<h2>Justified Text</h2>
|
||||
<p style="text-align: justify;">This paragraph is justified. The text stretches to fill the entire width of the line, creating clean edges on both sides.</p>
|
||||
<p style="text-align: justify;">This paragraph is justified. The text stretches to fill
|
||||
the entire width of the line, creating clean edges on both sides.</p>
|
||||
|
||||
<h2>Centered</h2>
|
||||
<p style="text-align: center;">This text is centered.</p>
|
||||
@ -112,7 +112,7 @@ def render_html_to_image(html_content, page_size=(500, 400)):
|
||||
# Add a note that this is HTML-parsed content
|
||||
try:
|
||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 11)
|
||||
except:
|
||||
except BaseException:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
# Draw info about what was parsed
|
||||
@ -128,7 +128,7 @@ def render_html_to_image(html_content, page_size=(500, 400)):
|
||||
for i, block in enumerate(blocks[:10]): # Show first 10
|
||||
block_type = type(block).__name__
|
||||
draw.text((content_x, y_offset),
|
||||
f" {i+1}. {block_type}",
|
||||
f" {i + 1}. {block_type}",
|
||||
fill=(60, 60, 60), font=font)
|
||||
y_offset += 18
|
||||
|
||||
@ -150,8 +150,9 @@ def combine_samples(samples):
|
||||
# Add title to image
|
||||
draw = ImageDraw.Draw(img)
|
||||
try:
|
||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 14)
|
||||
except:
|
||||
font = ImageFont.truetype(
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 14)
|
||||
except BaseException:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
draw.text((10, 10), title, fill=(50, 50, 150), font=font)
|
||||
@ -201,11 +202,11 @@ def main():
|
||||
output_path = output_dir / "example_02_text_and_layout.png"
|
||||
combined_image.save(output_path)
|
||||
|
||||
print(f"\n✓ Example completed!")
|
||||
print("\n✓ Example completed!")
|
||||
print(f" Output saved to: {output_path}")
|
||||
print(f" Image size: {combined_image.size[0]}x{combined_image.size[1]} pixels")
|
||||
print(f" Note: This example demonstrates HTML parsing")
|
||||
print(f" Full layout rendering requires the typesetting engine")
|
||||
print(" Note: This example demonstrates HTML parsing")
|
||||
print(" Full layout rendering requires the typesetting engine")
|
||||
|
||||
return combined_image
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -10,6 +10,12 @@ This example demonstrates the complete pipeline:
|
||||
No custom rendering code needed - DocumentLayouter handles everything!
|
||||
"""
|
||||
|
||||
from pyWebLayout.style import Font
|
||||
from pyWebLayout.concrete.table import TableStyle
|
||||
from pyWebLayout.layout.document_layouter import DocumentLayouter
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.io.readers.html_extraction import parse_html_string
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
@ -17,20 +23,13 @@ from PIL import Image
|
||||
# Add pyWebLayout to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from pyWebLayout.io.readers.html_extraction import parse_html_string
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from pyWebLayout.layout.document_layouter import DocumentLayouter
|
||||
from pyWebLayout.concrete.table import TableStyle
|
||||
from pyWebLayout.style import Font
|
||||
|
||||
|
||||
def create_book_catalog_html():
|
||||
"""Create HTML for a book catalog table with actual <img> tags."""
|
||||
# Get base path for images - use absolute paths for the img src
|
||||
data_path = Path(__file__).parent.parent / "tests" / "data"
|
||||
Path(__file__).parent.parent / "tests" / "data"
|
||||
|
||||
html = f"""
|
||||
html = """
|
||||
<html>
|
||||
<body>
|
||||
<table>
|
||||
@ -77,9 +76,9 @@ def create_book_catalog_html():
|
||||
|
||||
def create_product_showcase_html():
|
||||
"""Create HTML for a product showcase table with images."""
|
||||
data_path = Path(__file__).parent.parent / "tests" / "data"
|
||||
Path(__file__).parent.parent / "tests" / "data"
|
||||
|
||||
html = f"""
|
||||
html = """
|
||||
<html>
|
||||
<body>
|
||||
<table>
|
||||
@ -107,8 +106,8 @@ def create_product_showcase_html():
|
||||
|
||||
|
||||
def render_html_with_layouter(html_string: str, title: str,
|
||||
table_style: TableStyle,
|
||||
page_size=(600, 500)):
|
||||
table_style: TableStyle,
|
||||
page_size=(600, 500)):
|
||||
"""
|
||||
Render HTML using DocumentLayouter - the proper way!
|
||||
|
||||
@ -163,7 +162,7 @@ def render_html_with_layouter(html_string: str, title: str,
|
||||
if not success:
|
||||
print(f" ⚠ Warning: Block {type(block).__name__} didn't fit on page")
|
||||
|
||||
print(f" ✓ Layout complete!")
|
||||
print(" ✓ Layout complete!")
|
||||
|
||||
# Step 5: Get the rendered canvas
|
||||
# Note: Tables render directly onto page._canvas
|
||||
@ -257,14 +256,14 @@ def main():
|
||||
output_path = output_dir / "example_05_html_table_with_images.png"
|
||||
combined.save(output_path)
|
||||
|
||||
print(f"\n✓ Example completed!")
|
||||
print("\n✓ Example completed!")
|
||||
print(f" Output saved to: {output_path}")
|
||||
print(f" Image size: {combined.size[0]}x{combined.size[1]} pixels")
|
||||
print(f"\nThe complete pipeline:")
|
||||
print(f" 1. HTML with <img> tags → parse_html_string() → Abstract blocks")
|
||||
print(f" 2. Abstract blocks → DocumentLayouter → Concrete objects")
|
||||
print(f" 3. Page.render() → PNG output")
|
||||
print(f"\n ✓ Using DocumentLayouter - NO custom rendering code!")
|
||||
print("\nThe complete pipeline:")
|
||||
print(" 1. HTML with <img> tags → parse_html_string() → Abstract blocks")
|
||||
print(" 2. Abstract blocks → DocumentLayouter → Concrete objects")
|
||||
print(" 3. Page.render() → PNG output")
|
||||
print("\n ✓ Using DocumentLayouter - NO custom rendering code!")
|
||||
|
||||
return combined
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1,7 +1,22 @@
|
||||
from .block import Block, BlockType, Paragraph, Heading, HeadingLevel, Quote, CodeBlock
|
||||
from .block import HList, ListItem, ListStyle, Table, TableRow, TableCell
|
||||
from .block import HorizontalRule, Image
|
||||
from .interactive_image import InteractiveImage
|
||||
from .inline import Word, FormattedSpan, LineBreak
|
||||
from .document import Document, MetadataType, Chapter, Book
|
||||
from .functional import Link, LinkType, Button, Form, FormField, FormFieldType
|
||||
"""
|
||||
Abstract layer for the pyWebLayout library.
|
||||
|
||||
This package contains abstract representations of document elements that are
|
||||
independent of rendering specifics.
|
||||
"""
|
||||
|
||||
from .inline import Word, FormattedSpan
|
||||
from .block import Paragraph, Heading, Image, HeadingLevel
|
||||
from .document import Document
|
||||
from .functional import LinkType
|
||||
|
||||
__all__ = [
|
||||
'Word',
|
||||
'FormattedSpan',
|
||||
'Paragraph',
|
||||
'Heading',
|
||||
'Image',
|
||||
'HeadingLevel',
|
||||
'Document',
|
||||
'LinkType',
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -2,8 +2,6 @@ from __future__ import annotations
|
||||
from typing import List, Dict, Optional, Tuple, Union, Any
|
||||
from enum import Enum
|
||||
from .block import Block, BlockType, Heading, HeadingLevel, Paragraph
|
||||
from .functional import Link, Button, Form
|
||||
from .inline import Word, FormattedSpan
|
||||
from ..style import Font, FontWeight, FontStyle, TextDecoration
|
||||
from ..style.abstract_style import AbstractStyle, AbstractStyleRegistry, FontFamily, FontSize
|
||||
from ..style.concrete_style import ConcreteStyleRegistry, RenderingContext, StyleResolver
|
||||
@ -34,7 +32,11 @@ class Document(FontRegistry, MetadataContainer):
|
||||
Uses MetadataContainer mixin for metadata management.
|
||||
"""
|
||||
|
||||
def __init__(self, title: Optional[str] = None, language: str = "en-US", default_style=None):
|
||||
def __init__(
|
||||
self,
|
||||
title: Optional[str] = None,
|
||||
language: str = "en-US",
|
||||
default_style=None):
|
||||
"""
|
||||
Initialize a new document.
|
||||
|
||||
@ -49,13 +51,13 @@ class Document(FontRegistry, MetadataContainer):
|
||||
self._resources: Dict[str, Any] = {} # External resources like images
|
||||
self._stylesheets: List[Dict[str, Any]] = [] # CSS stylesheets
|
||||
self._scripts: List[str] = [] # JavaScript code
|
||||
|
||||
|
||||
# Style management with new abstract/concrete system
|
||||
self._abstract_style_registry = AbstractStyleRegistry()
|
||||
self._rendering_context = RenderingContext(default_language=language)
|
||||
self._style_resolver = StyleResolver(self._rendering_context)
|
||||
self._concrete_style_registry = ConcreteStyleRegistry(self._style_resolver)
|
||||
|
||||
|
||||
# Set default style
|
||||
if default_style is None:
|
||||
# Create a default abstract style
|
||||
@ -68,45 +70,46 @@ class Document(FontRegistry, MetadataContainer):
|
||||
color=default_style.colour,
|
||||
language=default_style.language
|
||||
)
|
||||
style_id, default_style = self._abstract_style_registry.get_or_create_style(default_style)
|
||||
style_id, default_style = self._abstract_style_registry.get_or_create_style(
|
||||
default_style)
|
||||
self._default_style = default_style
|
||||
|
||||
# Set basic metadata
|
||||
if title:
|
||||
self.set_metadata(MetadataType.TITLE, title)
|
||||
self.set_metadata(MetadataType.LANGUAGE, language)
|
||||
|
||||
|
||||
@property
|
||||
def blocks(self) -> List[Block]:
|
||||
"""Get the top-level blocks in this document"""
|
||||
return self._blocks
|
||||
|
||||
|
||||
@property
|
||||
def default_style(self):
|
||||
"""Get the default style for this document"""
|
||||
return self._default_style
|
||||
|
||||
|
||||
@default_style.setter
|
||||
def default_style(self, style):
|
||||
"""Set the default style for this document"""
|
||||
self._default_style = style
|
||||
|
||||
|
||||
def add_block(self, block: Block):
|
||||
"""
|
||||
Add a block to this document.
|
||||
|
||||
|
||||
Args:
|
||||
block: The block to add
|
||||
"""
|
||||
self._blocks.append(block)
|
||||
|
||||
|
||||
def create_paragraph(self, style=None) -> Paragraph:
|
||||
"""
|
||||
Create a new paragraph and add it to this document.
|
||||
|
||||
|
||||
Args:
|
||||
style: Optional style override. If None, inherits from document
|
||||
|
||||
|
||||
Returns:
|
||||
The newly created Paragraph object
|
||||
"""
|
||||
@ -115,15 +118,18 @@ class Document(FontRegistry, MetadataContainer):
|
||||
paragraph = Paragraph(style)
|
||||
self.add_block(paragraph)
|
||||
return paragraph
|
||||
|
||||
def create_heading(self, level: HeadingLevel = HeadingLevel.H1, style=None) -> Heading:
|
||||
|
||||
def create_heading(
|
||||
self,
|
||||
level: HeadingLevel = HeadingLevel.H1,
|
||||
style=None) -> Heading:
|
||||
"""
|
||||
Create a new heading and add it to this document.
|
||||
|
||||
|
||||
Args:
|
||||
level: The heading level
|
||||
style: Optional style override. If None, inherits from document
|
||||
|
||||
|
||||
Returns:
|
||||
The newly created Heading object
|
||||
"""
|
||||
@ -132,16 +138,20 @@ class Document(FontRegistry, MetadataContainer):
|
||||
heading = Heading(level, style)
|
||||
self.add_block(heading)
|
||||
return heading
|
||||
|
||||
def create_chapter(self, title: Optional[str] = None, level: int = 1, style=None) -> 'Chapter':
|
||||
|
||||
def create_chapter(
|
||||
self,
|
||||
title: Optional[str] = None,
|
||||
level: int = 1,
|
||||
style=None) -> 'Chapter':
|
||||
"""
|
||||
Create a new chapter with inherited style.
|
||||
|
||||
|
||||
Args:
|
||||
title: The chapter title
|
||||
level: The chapter level
|
||||
style: Optional style override. If None, inherits from document
|
||||
|
||||
|
||||
Returns:
|
||||
The newly created Chapter object
|
||||
"""
|
||||
@ -154,148 +164,148 @@ class Document(FontRegistry, MetadataContainer):
|
||||
def add_anchor(self, name: str, target: Block):
|
||||
"""
|
||||
Add a named anchor to this document.
|
||||
|
||||
|
||||
Args:
|
||||
name: The anchor name
|
||||
target: The target block
|
||||
"""
|
||||
self._anchors[name] = target
|
||||
|
||||
|
||||
def get_anchor(self, name: str) -> Optional[Block]:
|
||||
"""
|
||||
Get a named anchor from this document.
|
||||
|
||||
|
||||
Args:
|
||||
name: The anchor name
|
||||
|
||||
|
||||
Returns:
|
||||
The target block, or None if not found
|
||||
"""
|
||||
return self._anchors.get(name)
|
||||
|
||||
|
||||
def add_resource(self, name: str, resource: Any):
|
||||
"""
|
||||
Add a resource to this document.
|
||||
|
||||
|
||||
Args:
|
||||
name: The resource name
|
||||
resource: The resource data
|
||||
"""
|
||||
self._resources[name] = resource
|
||||
|
||||
|
||||
def get_resource(self, name: str) -> Optional[Any]:
|
||||
"""
|
||||
Get a resource from this document.
|
||||
|
||||
|
||||
Args:
|
||||
name: The resource name
|
||||
|
||||
|
||||
Returns:
|
||||
The resource data, or None if not found
|
||||
"""
|
||||
return self._resources.get(name)
|
||||
|
||||
|
||||
def add_stylesheet(self, stylesheet: Dict[str, Any]):
|
||||
"""
|
||||
Add a stylesheet to this document.
|
||||
|
||||
|
||||
Args:
|
||||
stylesheet: The stylesheet data
|
||||
"""
|
||||
self._stylesheets.append(stylesheet)
|
||||
|
||||
|
||||
def add_script(self, script: str):
|
||||
"""
|
||||
Add a script to this document.
|
||||
|
||||
|
||||
Args:
|
||||
script: The script code
|
||||
"""
|
||||
self._scripts.append(script)
|
||||
|
||||
|
||||
def get_title(self) -> Optional[str]:
|
||||
"""
|
||||
Get the document title.
|
||||
|
||||
|
||||
Returns:
|
||||
The document title, or None if not set
|
||||
"""
|
||||
return self.get_metadata(MetadataType.TITLE)
|
||||
|
||||
|
||||
def set_title(self, title: str):
|
||||
"""
|
||||
Set the document title.
|
||||
|
||||
|
||||
Args:
|
||||
title: The document title
|
||||
"""
|
||||
self.set_metadata(MetadataType.TITLE, title)
|
||||
|
||||
|
||||
@property
|
||||
def title(self) -> Optional[str]:
|
||||
"""
|
||||
Get the document title as a property.
|
||||
|
||||
|
||||
Returns:
|
||||
The document title, or None if not set
|
||||
"""
|
||||
return self.get_title()
|
||||
|
||||
|
||||
@title.setter
|
||||
def title(self, title: str):
|
||||
"""
|
||||
Set the document title as a property.
|
||||
|
||||
|
||||
Args:
|
||||
title: The document title
|
||||
"""
|
||||
self.set_title(title)
|
||||
|
||||
|
||||
def find_blocks_by_type(self, block_type: BlockType) -> List[Block]:
|
||||
"""
|
||||
Find all blocks of a specific type.
|
||||
|
||||
|
||||
Args:
|
||||
block_type: The type of blocks to find
|
||||
|
||||
|
||||
Returns:
|
||||
A list of matching blocks
|
||||
"""
|
||||
result = []
|
||||
|
||||
|
||||
def _find_recursive(blocks: List[Block]):
|
||||
for block in blocks:
|
||||
if block.block_type == block_type:
|
||||
result.append(block)
|
||||
|
||||
|
||||
# Check for child blocks based on block type
|
||||
if hasattr(block, '_blocks'):
|
||||
_find_recursive(block._blocks)
|
||||
elif hasattr(block, '_items') and isinstance(block._items, list):
|
||||
_find_recursive(block._items)
|
||||
|
||||
|
||||
_find_recursive(self._blocks)
|
||||
return result
|
||||
|
||||
|
||||
def find_headings(self) -> List[Heading]:
|
||||
"""
|
||||
Find all headings in the document.
|
||||
|
||||
|
||||
Returns:
|
||||
A list of heading blocks
|
||||
"""
|
||||
blocks = self.find_blocks_by_type(BlockType.HEADING)
|
||||
return [block for block in blocks if isinstance(block, Heading)]
|
||||
|
||||
|
||||
def generate_table_of_contents(self) -> List[Tuple[int, str, Block]]:
|
||||
"""
|
||||
Generate a table of contents from headings.
|
||||
|
||||
|
||||
Returns:
|
||||
A list of tuples containing (level, title, heading_block)
|
||||
"""
|
||||
headings = self.find_headings()
|
||||
|
||||
|
||||
toc = []
|
||||
for heading in headings:
|
||||
# Extract text from the heading
|
||||
@ -303,26 +313,26 @@ class Document(FontRegistry, MetadataContainer):
|
||||
for _, word in heading.words_iter():
|
||||
title += word.text + " "
|
||||
title = title.strip()
|
||||
|
||||
|
||||
# Add to TOC
|
||||
level = heading.level.value # Get numeric value from HeadingLevel enum
|
||||
toc.append((level, title, heading))
|
||||
|
||||
|
||||
return toc
|
||||
|
||||
def get_or_create_style(self,
|
||||
font_family: FontFamily = FontFamily.SERIF,
|
||||
font_size: Union[FontSize, int] = FontSize.MEDIUM,
|
||||
font_weight: FontWeight = FontWeight.NORMAL,
|
||||
font_style: FontStyle = FontStyle.NORMAL,
|
||||
text_decoration: TextDecoration = TextDecoration.NONE,
|
||||
color: Union[str, Tuple[int, int, int]] = "black",
|
||||
background_color: Optional[Union[str, Tuple[int, int, int, int]]] = None,
|
||||
language: str = "en-US",
|
||||
**kwargs) -> Tuple[str, AbstractStyle]:
|
||||
|
||||
def get_or_create_style(self,
|
||||
font_family: FontFamily = FontFamily.SERIF,
|
||||
font_size: Union[FontSize, int] = FontSize.MEDIUM,
|
||||
font_weight: FontWeight = FontWeight.NORMAL,
|
||||
font_style: FontStyle = FontStyle.NORMAL,
|
||||
text_decoration: TextDecoration = TextDecoration.NONE,
|
||||
color: Union[str, Tuple[int, int, int]] = "black",
|
||||
background_color: Optional[Union[str, Tuple[int, int, int, int]]] = None,
|
||||
language: str = "en-US",
|
||||
**kwargs) -> Tuple[str, AbstractStyle]:
|
||||
"""
|
||||
Get or create an abstract style with the specified properties.
|
||||
|
||||
|
||||
Args:
|
||||
font_family: Semantic font family
|
||||
font_size: Font size (semantic or numeric)
|
||||
@ -333,7 +343,7 @@ class Document(FontRegistry, MetadataContainer):
|
||||
background_color: Background color
|
||||
language: Language code
|
||||
**kwargs: Additional style properties
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (style_id, AbstractStyle)
|
||||
"""
|
||||
@ -348,34 +358,34 @@ class Document(FontRegistry, MetadataContainer):
|
||||
language=language,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
|
||||
return self._abstract_style_registry.get_or_create_style(abstract_style)
|
||||
|
||||
|
||||
def get_font_for_style(self, abstract_style: AbstractStyle) -> Font:
|
||||
"""
|
||||
Get a Font object for an AbstractStyle (for rendering).
|
||||
|
||||
|
||||
Args:
|
||||
abstract_style: The abstract style to get a font for
|
||||
|
||||
|
||||
Returns:
|
||||
Font object ready for rendering
|
||||
"""
|
||||
return self._concrete_style_registry.get_font(abstract_style)
|
||||
|
||||
|
||||
def update_rendering_context(self, **kwargs):
|
||||
"""
|
||||
Update the rendering context (user preferences, device settings, etc.).
|
||||
|
||||
|
||||
Args:
|
||||
**kwargs: Context properties to update (base_font_size, font_scale_factor, etc.)
|
||||
"""
|
||||
self._style_resolver.update_context(**kwargs)
|
||||
|
||||
|
||||
def get_style_registry(self) -> AbstractStyleRegistry:
|
||||
"""Get the abstract style registry for this document."""
|
||||
return self._abstract_style_registry
|
||||
|
||||
|
||||
def get_concrete_style_registry(self) -> ConcreteStyleRegistry:
|
||||
"""Get the concrete style registry for this document."""
|
||||
return self._concrete_style_registry
|
||||
@ -392,7 +402,12 @@ class Chapter(FontRegistry, MetadataContainer):
|
||||
Uses MetadataContainer mixin for metadata management.
|
||||
"""
|
||||
|
||||
def __init__(self, title: Optional[str] = None, level: int = 1, style=None, parent=None):
|
||||
def __init__(
|
||||
self,
|
||||
title: Optional[str] = None,
|
||||
level: int = 1,
|
||||
style=None,
|
||||
parent=None):
|
||||
"""
|
||||
Initialize a new chapter.
|
||||
|
||||
@ -408,53 +423,53 @@ class Chapter(FontRegistry, MetadataContainer):
|
||||
self._blocks: List[Block] = []
|
||||
self._style = style
|
||||
self._parent = parent
|
||||
|
||||
|
||||
@property
|
||||
def title(self) -> Optional[str]:
|
||||
"""Get the chapter title"""
|
||||
return self._title
|
||||
|
||||
|
||||
@title.setter
|
||||
def title(self, title: str):
|
||||
"""Set the chapter title"""
|
||||
self._title = title
|
||||
|
||||
|
||||
@property
|
||||
def level(self) -> int:
|
||||
"""Get the chapter level"""
|
||||
return self._level
|
||||
|
||||
|
||||
@property
|
||||
def blocks(self) -> List[Block]:
|
||||
"""Get the blocks in this chapter"""
|
||||
return self._blocks
|
||||
|
||||
|
||||
@property
|
||||
def style(self):
|
||||
"""Get the default style for this chapter"""
|
||||
return self._style
|
||||
|
||||
|
||||
@style.setter
|
||||
def style(self, style):
|
||||
"""Set the default style for this chapter"""
|
||||
self._style = style
|
||||
|
||||
|
||||
def add_block(self, block: Block):
|
||||
"""
|
||||
Add a block to this chapter.
|
||||
|
||||
|
||||
Args:
|
||||
block: The block to add
|
||||
"""
|
||||
self._blocks.append(block)
|
||||
|
||||
|
||||
def create_paragraph(self, style=None) -> Paragraph:
|
||||
"""
|
||||
Create a new paragraph and add it to this chapter.
|
||||
|
||||
|
||||
Args:
|
||||
style: Optional style override. If None, inherits from chapter
|
||||
|
||||
|
||||
Returns:
|
||||
The newly created Paragraph object
|
||||
"""
|
||||
@ -463,15 +478,18 @@ class Chapter(FontRegistry, MetadataContainer):
|
||||
paragraph = Paragraph(style)
|
||||
self.add_block(paragraph)
|
||||
return paragraph
|
||||
|
||||
def create_heading(self, level: HeadingLevel = HeadingLevel.H1, style=None) -> Heading:
|
||||
|
||||
def create_heading(
|
||||
self,
|
||||
level: HeadingLevel = HeadingLevel.H1,
|
||||
style=None) -> Heading:
|
||||
"""
|
||||
Create a new heading and add it to this chapter.
|
||||
|
||||
|
||||
Args:
|
||||
level: The heading level
|
||||
style: Optional style override. If None, inherits from chapter
|
||||
|
||||
|
||||
Returns:
|
||||
The newly created Heading object
|
||||
"""
|
||||
@ -490,12 +508,12 @@ class Book(Document):
|
||||
Abstract representation of an ebook.
|
||||
A book is a document that contains chapters.
|
||||
"""
|
||||
|
||||
def __init__(self, title: Optional[str] = None, author: Optional[str] = None,
|
||||
|
||||
def __init__(self, title: Optional[str] = None, author: Optional[str] = None,
|
||||
language: str = "en-US", default_style=None):
|
||||
"""
|
||||
Initialize a new book.
|
||||
|
||||
|
||||
Args:
|
||||
title: The book title
|
||||
author: The book author
|
||||
@ -504,33 +522,37 @@ class Book(Document):
|
||||
"""
|
||||
super().__init__(title, language, default_style)
|
||||
self._chapters: List[Chapter] = []
|
||||
|
||||
|
||||
if author:
|
||||
self.set_metadata(MetadataType.AUTHOR, author)
|
||||
|
||||
|
||||
@property
|
||||
def chapters(self) -> List[Chapter]:
|
||||
"""Get the chapters in this book"""
|
||||
return self._chapters
|
||||
|
||||
|
||||
def add_chapter(self, chapter: Chapter):
|
||||
"""
|
||||
Add a chapter to this book.
|
||||
|
||||
|
||||
Args:
|
||||
chapter: The chapter to add
|
||||
"""
|
||||
self._chapters.append(chapter)
|
||||
|
||||
def create_chapter(self, title: Optional[str] = None, level: int = 1, style=None) -> Chapter:
|
||||
|
||||
def create_chapter(
|
||||
self,
|
||||
title: Optional[str] = None,
|
||||
level: int = 1,
|
||||
style=None) -> Chapter:
|
||||
"""
|
||||
Create and add a new chapter with inherited style.
|
||||
|
||||
|
||||
Args:
|
||||
title: The chapter title
|
||||
level: The chapter level
|
||||
style: Optional style override. If None, inherits from book
|
||||
|
||||
|
||||
Returns:
|
||||
The new chapter
|
||||
"""
|
||||
@ -539,29 +561,29 @@ class Book(Document):
|
||||
chapter = Chapter(title, level, style)
|
||||
self.add_chapter(chapter)
|
||||
return chapter
|
||||
|
||||
|
||||
def get_author(self) -> Optional[str]:
|
||||
"""
|
||||
Get the book author.
|
||||
|
||||
|
||||
Returns:
|
||||
The book author, or None if not set
|
||||
"""
|
||||
return self.get_metadata(MetadataType.AUTHOR)
|
||||
|
||||
|
||||
def set_author(self, author: str):
|
||||
"""
|
||||
Set the book author.
|
||||
|
||||
|
||||
Args:
|
||||
author: The book author
|
||||
"""
|
||||
self.set_metadata(MetadataType.AUTHOR, author)
|
||||
|
||||
|
||||
def generate_table_of_contents(self) -> List[Tuple[int, str, Chapter]]:
|
||||
"""
|
||||
Generate a table of contents from chapters.
|
||||
|
||||
|
||||
Returns:
|
||||
A list of tuples containing (level, title, chapter)
|
||||
"""
|
||||
@ -569,5 +591,5 @@ class Book(Document):
|
||||
for chapter in self._chapters:
|
||||
if chapter.title:
|
||||
toc.append((chapter.level, chapter.title, chapter))
|
||||
|
||||
|
||||
return toc
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
from enum import Enum
|
||||
from typing import Callable, Dict, Any, Optional, Union, List, Tuple
|
||||
from typing import Callable, Dict, Any, Optional, List, Tuple
|
||||
from pyWebLayout.core.base import Interactable
|
||||
|
||||
|
||||
@ -18,7 +18,7 @@ class Link(Interactable):
|
||||
Links can be used for navigation within a document, to external resources,
|
||||
or to trigger API calls for functionality like settings management.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self,
|
||||
location: str,
|
||||
link_type: LinkType = LinkType.INTERNAL,
|
||||
@ -43,22 +43,22 @@ class Link(Interactable):
|
||||
self._params = params or {}
|
||||
self._title = title
|
||||
self._html_id = html_id
|
||||
|
||||
|
||||
@property
|
||||
def location(self) -> str:
|
||||
"""Get the target location of this link"""
|
||||
return self._location
|
||||
|
||||
|
||||
@property
|
||||
def link_type(self) -> LinkType:
|
||||
"""Get the type of this link"""
|
||||
return self._link_type
|
||||
|
||||
|
||||
@property
|
||||
def params(self) -> Dict[str, Any]:
|
||||
"""Get the parameters for this link"""
|
||||
return self._params
|
||||
|
||||
|
||||
@property
|
||||
def title(self) -> Optional[str]:
|
||||
"""Get the title/tooltip for this link"""
|
||||
@ -95,7 +95,7 @@ class Button(Interactable):
|
||||
A button that can be clicked to execute an action.
|
||||
Buttons are similar to function links but are rendered differently.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self,
|
||||
label: str,
|
||||
callback: Callable,
|
||||
@ -117,27 +117,27 @@ class Button(Interactable):
|
||||
self._params = params or {}
|
||||
self._enabled = enabled
|
||||
self._html_id = html_id
|
||||
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
"""Get the button label"""
|
||||
return self._label
|
||||
|
||||
|
||||
@label.setter
|
||||
def label(self, label: str):
|
||||
"""Set the button label"""
|
||||
self._label = label
|
||||
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""Check if the button is enabled"""
|
||||
return self._enabled
|
||||
|
||||
|
||||
@enabled.setter
|
||||
def enabled(self, enabled: bool):
|
||||
"""Enable or disable the button"""
|
||||
self._enabled = enabled
|
||||
|
||||
|
||||
@property
|
||||
def params(self) -> Dict[str, Any]:
|
||||
"""Get the button parameters"""
|
||||
@ -168,7 +168,7 @@ class Form(Interactable):
|
||||
A form that can contain input fields and be submitted.
|
||||
Forms can be used for user input and settings configuration.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self,
|
||||
form_id: str,
|
||||
action: Optional[str] = None,
|
||||
@ -188,12 +188,12 @@ class Form(Interactable):
|
||||
self._action = action
|
||||
self._fields: Dict[str, FormField] = {}
|
||||
self._html_id = html_id
|
||||
|
||||
|
||||
@property
|
||||
def form_id(self) -> str:
|
||||
"""Get the form ID"""
|
||||
return self._form_id
|
||||
|
||||
|
||||
@property
|
||||
def action(self) -> Optional[str]:
|
||||
"""Get the form action"""
|
||||
@ -207,46 +207,46 @@ class Form(Interactable):
|
||||
def add_field(self, field: FormField):
|
||||
"""
|
||||
Add a field to this form.
|
||||
|
||||
|
||||
Args:
|
||||
field: The FormField to add
|
||||
"""
|
||||
self._fields[field.name] = field
|
||||
field.form = self
|
||||
|
||||
|
||||
def get_field(self, name: str) -> Optional[FormField]:
|
||||
"""
|
||||
Get a field by name.
|
||||
|
||||
|
||||
Args:
|
||||
name: The name of the field to get
|
||||
|
||||
|
||||
Returns:
|
||||
The FormField with the specified name, or None if not found
|
||||
"""
|
||||
return self._fields.get(name)
|
||||
|
||||
|
||||
def get_values(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get the current values of all fields in this form.
|
||||
|
||||
|
||||
Returns:
|
||||
A dictionary mapping field names to their current values
|
||||
"""
|
||||
return {name: field.value for name, field in self._fields.items()}
|
||||
|
||||
|
||||
def execute(self) -> Any:
|
||||
"""
|
||||
Submit the form, executing the callback with the form values.
|
||||
|
||||
|
||||
Returns:
|
||||
The result of the callback function, or the form values if no callback is provided.
|
||||
"""
|
||||
values = self.get_values()
|
||||
|
||||
|
||||
if self._callback:
|
||||
return self._callback(self._form_id, values)
|
||||
|
||||
|
||||
return values
|
||||
|
||||
|
||||
@ -272,8 +272,8 @@ class FormField:
|
||||
"""
|
||||
A field in a form that can accept user input.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
|
||||
def __init__(self,
|
||||
name: str,
|
||||
field_type: FormFieldType,
|
||||
label: Optional[str] = None,
|
||||
@ -282,7 +282,7 @@ class FormField:
|
||||
options: Optional[List[Tuple[str, str]]] = None):
|
||||
"""
|
||||
Initialize a form field.
|
||||
|
||||
|
||||
Args:
|
||||
name: The name of this field
|
||||
field_type: The type of this field
|
||||
@ -298,47 +298,47 @@ class FormField:
|
||||
self._required = required
|
||||
self._options = options or []
|
||||
self._form: Optional[Form] = None
|
||||
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Get the field name"""
|
||||
return self._name
|
||||
|
||||
|
||||
@property
|
||||
def field_type(self) -> FormFieldType:
|
||||
"""Get the field type"""
|
||||
return self._field_type
|
||||
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
"""Get the field label"""
|
||||
return self._label
|
||||
|
||||
|
||||
@property
|
||||
def value(self) -> Any:
|
||||
"""Get the current field value"""
|
||||
return self._value
|
||||
|
||||
|
||||
@value.setter
|
||||
def value(self, value: Any):
|
||||
"""Set the field value"""
|
||||
self._value = value
|
||||
|
||||
|
||||
@property
|
||||
def required(self) -> bool:
|
||||
"""Check if the field is required"""
|
||||
return self._required
|
||||
|
||||
|
||||
@property
|
||||
def options(self) -> List[Tuple[str, str]]:
|
||||
"""Get the field options"""
|
||||
return self._options
|
||||
|
||||
|
||||
@property
|
||||
def form(self) -> Optional[Form]:
|
||||
"""Get the form containing this field"""
|
||||
return self._form
|
||||
|
||||
|
||||
@form.setter
|
||||
def form(self, form: Form):
|
||||
"""Set the form containing this field"""
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
from pyWebLayout.core.base import Queriable
|
||||
from pyWebLayout.core import Hierarchical
|
||||
from pyWebLayout.style import Font
|
||||
from pyWebLayout.style.abstract_style import AbstractStyle
|
||||
@ -10,20 +9,25 @@ import pyphen
|
||||
from pyWebLayout.abstract.functional import LinkType
|
||||
|
||||
|
||||
|
||||
class Word:
|
||||
"""
|
||||
An abstract representation of a word in a document. Words can be split across
|
||||
lines or pages during rendering. This class manages the logical representation
|
||||
of a word without any rendering specifics.
|
||||
|
||||
|
||||
Now uses AbstractStyle objects for memory efficiency and proper style management.
|
||||
"""
|
||||
|
||||
def __init__(self, text: str, style: Union[Font, AbstractStyle], background=None, previous: Union['Word', None] = None):
|
||||
def __init__(self,
|
||||
text: str,
|
||||
style: Union[Font,
|
||||
AbstractStyle],
|
||||
background=None,
|
||||
previous: Union['Word',
|
||||
None] = None):
|
||||
"""
|
||||
Initialize a new Word.
|
||||
|
||||
|
||||
Args:
|
||||
text: The text content of the word
|
||||
style: AbstractStyle object or Font object (for backward compatibility)
|
||||
@ -40,25 +44,25 @@ class Word:
|
||||
previous.add_next(self)
|
||||
|
||||
@classmethod
|
||||
def create_and_add_to(cls, text: str, container, style: Optional[Font] = None,
|
||||
background=None) -> 'Word':
|
||||
def create_and_add_to(cls, text: str, container, style: Optional[Font] = None,
|
||||
background=None) -> 'Word':
|
||||
"""
|
||||
Create a new Word and add it to a container, inheriting style and language
|
||||
from the container if not explicitly provided.
|
||||
|
||||
|
||||
This method provides a convenient way to create words that automatically
|
||||
inherit styling from their container (Paragraph, FormattedSpan, etc.)
|
||||
without copying string values - using object references instead.
|
||||
|
||||
|
||||
Args:
|
||||
text: The text content of the word
|
||||
container: The container to add the word to (must have add_word method and style property)
|
||||
style: Optional Font style override. If None, inherits from container
|
||||
background: Optional background color override. If None, inherits from container
|
||||
|
||||
|
||||
Returns:
|
||||
The newly created Word object
|
||||
|
||||
|
||||
Raises:
|
||||
AttributeError: If the container doesn't have the required add_word method or style property
|
||||
"""
|
||||
@ -67,12 +71,14 @@ class Word:
|
||||
if hasattr(container, 'style'):
|
||||
style = container.style
|
||||
else:
|
||||
raise AttributeError(f"Container {type(container).__name__} must have a 'style' property")
|
||||
|
||||
raise AttributeError(
|
||||
f"Container {
|
||||
type(container).__name__} must have a 'style' property")
|
||||
|
||||
# Inherit background from container if not provided
|
||||
if background is None and hasattr(container, 'background'):
|
||||
background = container.background
|
||||
|
||||
|
||||
# Determine the previous word for proper linking
|
||||
previous = None
|
||||
if hasattr(container, '_words') and container._words:
|
||||
@ -86,21 +92,21 @@ class Word:
|
||||
previous = word
|
||||
except (StopIteration, TypeError):
|
||||
previous = None
|
||||
|
||||
|
||||
# Create the new word
|
||||
word = cls(text, style, background, previous)
|
||||
|
||||
|
||||
# Link the previous word to this new one
|
||||
if previous:
|
||||
previous.add_next(word)
|
||||
|
||||
|
||||
# Add the word to the container
|
||||
if hasattr(container, 'add_word'):
|
||||
# Check if add_word expects a Word object or text string
|
||||
import inspect
|
||||
sig = inspect.signature(container.add_word)
|
||||
params = list(sig.parameters.keys())
|
||||
|
||||
|
||||
if len(params) > 0:
|
||||
# Peek at the parameter name to guess the expected type
|
||||
param_name = params[0]
|
||||
@ -110,7 +116,8 @@ class Word:
|
||||
else:
|
||||
# Might expect text string (like FormattedSpan.add_word)
|
||||
# In this case, we can't use the container's add_word as it would create
|
||||
# a duplicate Word. We need to add directly to the container's word list.
|
||||
# a duplicate Word. We need to add directly to the container's word
|
||||
# list.
|
||||
if hasattr(container, '_words'):
|
||||
container._words.append(word)
|
||||
else:
|
||||
@ -120,72 +127,72 @@ class Word:
|
||||
# No parameters, shouldn't happen with add_word methods
|
||||
container.add_word(word)
|
||||
else:
|
||||
raise AttributeError(f"Container {type(container).__name__} must have an 'add_word' method")
|
||||
|
||||
raise AttributeError(
|
||||
f"Container {
|
||||
type(container).__name__} must have an 'add_word' method")
|
||||
|
||||
return word
|
||||
|
||||
|
||||
def add_concete(self, text: Union[Any, Tuple[Any,Any]]):
|
||||
|
||||
def add_concete(self, text: Union[Any, Tuple[Any, Any]]):
|
||||
self.concrete = text
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
"""Get the text content of the word"""
|
||||
return self._text
|
||||
|
||||
|
||||
@property
|
||||
def style(self) -> Font:
|
||||
"""Get the font style of the word"""
|
||||
return self._style
|
||||
|
||||
|
||||
@property
|
||||
def background(self):
|
||||
"""Get the background color of the word"""
|
||||
return self._background
|
||||
|
||||
|
||||
@property
|
||||
def previous(self) -> Union['Word', None]:
|
||||
"""Get the previous word in sequence"""
|
||||
return self._previous
|
||||
|
||||
|
||||
@property
|
||||
def next(self) -> Union['Word', None]:
|
||||
"""Get the next word in sequence"""
|
||||
return self._next
|
||||
|
||||
|
||||
def add_next(self, next_word: 'Word'):
|
||||
"""Set the next word in sequence"""
|
||||
self._next = next_word
|
||||
|
||||
|
||||
|
||||
def possible_hyphenation(self, language: str = None) -> bool:
|
||||
"""
|
||||
Hyphenate the word and store the parts.
|
||||
|
||||
|
||||
Args:
|
||||
language: Language code for hyphenation. If None, uses the style's language.
|
||||
|
||||
|
||||
Returns:
|
||||
bool: True if the word was hyphenated, False otherwise.
|
||||
"""
|
||||
|
||||
dic = pyphen.Pyphen(lang=self._style.language)
|
||||
return list(dic.iterate(self._text))
|
||||
...
|
||||
|
||||
|
||||
...
|
||||
|
||||
|
||||
class FormattedSpan:
|
||||
"""
|
||||
A run of words with consistent formatting.
|
||||
This represents a sequence of words that share the same style attributes.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, style: Font, background=None):
|
||||
"""
|
||||
Initialize a new formatted span.
|
||||
|
||||
|
||||
Args:
|
||||
style: Font style information for all words in this span
|
||||
background: Optional background color override
|
||||
@ -193,21 +200,25 @@ class FormattedSpan:
|
||||
self._style = style
|
||||
self._background = background if background else style.background
|
||||
self._words: List[Word] = []
|
||||
|
||||
|
||||
@classmethod
|
||||
def create_and_add_to(cls, container, style: Optional[Font] = None, background=None) -> 'FormattedSpan':
|
||||
def create_and_add_to(
|
||||
cls,
|
||||
container,
|
||||
style: Optional[Font] = None,
|
||||
background=None) -> 'FormattedSpan':
|
||||
"""
|
||||
Create a new FormattedSpan and add it to a container, inheriting style from
|
||||
the container if not explicitly provided.
|
||||
|
||||
|
||||
Args:
|
||||
container: The container to add the span to (must have add_span method and style property)
|
||||
style: Optional Font style override. If None, inherits from container
|
||||
background: Optional background color override
|
||||
|
||||
|
||||
Returns:
|
||||
The newly created FormattedSpan object
|
||||
|
||||
|
||||
Raises:
|
||||
AttributeError: If the container doesn't have the required add_span method or style property
|
||||
"""
|
||||
@ -216,72 +227,76 @@ class FormattedSpan:
|
||||
if hasattr(container, 'style'):
|
||||
style = container.style
|
||||
else:
|
||||
raise AttributeError(f"Container {type(container).__name__} must have a 'style' property")
|
||||
|
||||
raise AttributeError(
|
||||
f"Container {
|
||||
type(container).__name__} must have a 'style' property")
|
||||
|
||||
# Inherit background from container if not provided
|
||||
if background is None and hasattr(container, 'background'):
|
||||
background = container.background
|
||||
|
||||
|
||||
# Create the new span
|
||||
span = cls(style, background)
|
||||
|
||||
|
||||
# Add the span to the container
|
||||
if hasattr(container, 'add_span'):
|
||||
container.add_span(span)
|
||||
else:
|
||||
raise AttributeError(f"Container {type(container).__name__} must have an 'add_span' method")
|
||||
|
||||
raise AttributeError(
|
||||
f"Container {
|
||||
type(container).__name__} must have an 'add_span' method")
|
||||
|
||||
return span
|
||||
|
||||
|
||||
@property
|
||||
def style(self) -> Font:
|
||||
"""Get the font style of this span"""
|
||||
return self._style
|
||||
|
||||
|
||||
@property
|
||||
def background(self):
|
||||
"""Get the background color of this span"""
|
||||
return self._background
|
||||
|
||||
|
||||
@property
|
||||
def words(self) -> List[Word]:
|
||||
"""Get the list of words in this span"""
|
||||
return self._words
|
||||
|
||||
|
||||
def add_word(self, text: str) -> Word:
|
||||
"""
|
||||
Create and add a new word to this span.
|
||||
|
||||
|
||||
Args:
|
||||
text: The text content of the word
|
||||
|
||||
|
||||
Returns:
|
||||
The newly created Word object
|
||||
"""
|
||||
# Get the previous word if any
|
||||
previous = self._words[-1] if self._words else None
|
||||
|
||||
|
||||
# Create the new word
|
||||
word = Word(text, self._style, self._background, previous)
|
||||
|
||||
|
||||
# Link the previous word to this new one
|
||||
if previous:
|
||||
previous.add_next(word)
|
||||
|
||||
|
||||
# Add the word to our list
|
||||
self._words.append(word)
|
||||
|
||||
|
||||
return word
|
||||
|
||||
|
||||
class LinkedWord(Word):
|
||||
"""
|
||||
A Word that is also a Link - combines text content with hyperlink functionality.
|
||||
|
||||
|
||||
When a word is part of a hyperlink, it becomes clickable and can trigger
|
||||
navigation or callbacks. Multiple words can share the same link destination.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, text: str, style: Union[Font, 'AbstractStyle'],
|
||||
location: str, link_type: Optional['LinkType'] = None,
|
||||
callback: Optional[Callable] = None,
|
||||
@ -290,7 +305,7 @@ class LinkedWord(Word):
|
||||
title: Optional[str] = None):
|
||||
"""
|
||||
Initialize a linked word.
|
||||
|
||||
|
||||
Args:
|
||||
text: The text content of the word
|
||||
style: The font style
|
||||
@ -304,46 +319,46 @@ class LinkedWord(Word):
|
||||
"""
|
||||
# Initialize Word first
|
||||
super().__init__(text, style, background, previous)
|
||||
|
||||
|
||||
# Store link properties
|
||||
self._location = location
|
||||
self._link_type = link_type or LinkType.EXTERNAL
|
||||
self._callback = callback
|
||||
self._params = params or {}
|
||||
self._title = title
|
||||
|
||||
|
||||
@property
|
||||
def location(self) -> str:
|
||||
"""Get the link target location"""
|
||||
return self._location
|
||||
|
||||
|
||||
@property
|
||||
def link_type(self):
|
||||
"""Get the type of link"""
|
||||
return self._link_type
|
||||
|
||||
|
||||
@property
|
||||
def link_callback(self) -> Optional[Callable]:
|
||||
"""Get the link callback (distinct from word callback)"""
|
||||
return self._callback
|
||||
|
||||
|
||||
@property
|
||||
def params(self) -> Dict[str, Any]:
|
||||
"""Get the link parameters"""
|
||||
return self._params
|
||||
|
||||
|
||||
@property
|
||||
def link_title(self) -> Optional[str]:
|
||||
"""Get the link title/tooltip"""
|
||||
return self._title
|
||||
|
||||
|
||||
def execute_link(self, context: Optional[Dict[str, Any]] = None) -> Any:
|
||||
"""
|
||||
Execute the link action.
|
||||
|
||||
|
||||
Args:
|
||||
context: Optional context dict (e.g., {'text': word.text})
|
||||
|
||||
|
||||
Returns:
|
||||
The result of the link execution
|
||||
"""
|
||||
@ -351,7 +366,7 @@ class LinkedWord(Word):
|
||||
full_context = {**self._params, 'text': self._text}
|
||||
if context:
|
||||
full_context.update(context)
|
||||
|
||||
|
||||
if self._link_type in (LinkType.API, LinkType.FUNCTION) and self._callback:
|
||||
return self._callback(self._location, **full_context)
|
||||
else:
|
||||
@ -379,21 +394,21 @@ class LineBreak(Hierarchical):
|
||||
def block_type(self):
|
||||
"""Get the block type for this line break"""
|
||||
return self._block_type
|
||||
|
||||
|
||||
@classmethod
|
||||
def create_and_add_to(cls, container) -> 'LineBreak':
|
||||
"""
|
||||
Create a new LineBreak and add it to a container.
|
||||
|
||||
|
||||
Args:
|
||||
container: The container to add the line break to
|
||||
|
||||
|
||||
Returns:
|
||||
The newly created LineBreak object
|
||||
"""
|
||||
# Create the new line break
|
||||
line_break = cls()
|
||||
|
||||
|
||||
# Add the line break to the container if it has an appropriate method
|
||||
if hasattr(container, 'add_line_break'):
|
||||
container.add_line_break(line_break)
|
||||
@ -405,5 +420,5 @@ class LineBreak(Hierarchical):
|
||||
else:
|
||||
# Set parent relationship manually
|
||||
line_break.parent = container
|
||||
|
||||
|
||||
return line_break
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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',
|
||||
]
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
from __future__ import annotations
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from typing import Tuple, Union, List, Optional, Dict
|
||||
|
||||
from pyWebLayout.core.base import Renderable, Queriable
|
||||
from pyWebLayout.core import Geometric
|
||||
from pyWebLayout.style import Alignment
|
||||
|
||||
|
||||
class Box(Geometric, Renderable, Queriable):
|
||||
"""
|
||||
A box with geometric properties (origin and size).
|
||||
@ -14,12 +14,20 @@ class Box(Geometric, Renderable, Queriable):
|
||||
Uses Geometric mixin for origin and size management.
|
||||
"""
|
||||
|
||||
def __init__(self,origin, size, callback = None, sheet : Image = None, mode: bool = None, halign=Alignment.CENTER, valign = Alignment.CENTER):
|
||||
def __init__(
|
||||
self,
|
||||
origin,
|
||||
size,
|
||||
callback=None,
|
||||
sheet: Image = None,
|
||||
mode: bool = None,
|
||||
halign=Alignment.CENTER,
|
||||
valign=Alignment.CENTER):
|
||||
super().__init__(origin=origin, size=size)
|
||||
self._end = self._origin + self._size
|
||||
self._end = self._origin + self._size
|
||||
self._callback = callback
|
||||
self._sheet : Image = sheet
|
||||
if self._sheet == None:
|
||||
self._sheet: Image = sheet
|
||||
if self._sheet is None:
|
||||
self._mode = mode
|
||||
else:
|
||||
self._mode = sheet.mode
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
from __future__ import annotations
|
||||
from typing import Optional, Dict, Any, Tuple, List, Union
|
||||
from typing import Optional, Tuple
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from PIL import ImageDraw
|
||||
|
||||
from pyWebLayout.core.base import Interactable, Queriable
|
||||
from pyWebLayout.abstract.functional import Link, Button, Form, FormField, LinkType, FormFieldType
|
||||
from pyWebLayout.abstract.functional import Link, Button, FormField, LinkType, FormFieldType
|
||||
from pyWebLayout.style import Font, TextDecoration
|
||||
from .text import Text
|
||||
|
||||
@ -14,12 +14,12 @@ class LinkText(Text, Interactable, Queriable):
|
||||
A Text subclass that can handle Link interactions.
|
||||
Combines text rendering with clickable link functionality.
|
||||
"""
|
||||
|
||||
def __init__(self, link: Link, text: str, font: Font, draw: ImageDraw.Draw,
|
||||
|
||||
def __init__(self, link: Link, text: str, font: Font, draw: ImageDraw.Draw,
|
||||
source=None, line=None):
|
||||
"""
|
||||
Initialize a linkable text object.
|
||||
|
||||
|
||||
Args:
|
||||
link: The abstract Link object to handle interactions
|
||||
text: The text content to render
|
||||
@ -33,35 +33,35 @@ class LinkText(Text, Interactable, Queriable):
|
||||
if link.link_type == LinkType.INTERNAL:
|
||||
link_font = link_font.with_colour((0, 0, 200)) # Blue for internal links
|
||||
elif link.link_type == LinkType.EXTERNAL:
|
||||
link_font = link_font.with_colour((0, 0, 180)) # Darker blue for external links
|
||||
link_font = link_font.with_colour(
|
||||
(0, 0, 180)) # Darker blue for external links
|
||||
elif link.link_type == LinkType.API:
|
||||
link_font = link_font.with_colour((150, 0, 0)) # Red for API links
|
||||
elif link.link_type == LinkType.FUNCTION:
|
||||
link_font = link_font.with_colour((0, 120, 0)) # Green for function links
|
||||
|
||||
|
||||
# Initialize Text with the styled font
|
||||
Text.__init__(self, text, link_font, draw, source, line)
|
||||
|
||||
|
||||
# Initialize Interactable with the link's execute method
|
||||
Interactable.__init__(self, link.execute)
|
||||
|
||||
|
||||
# Store the link object
|
||||
self._link = link
|
||||
self._hovered = False
|
||||
|
||||
|
||||
# Ensure _origin is initialized as numpy array
|
||||
if not hasattr(self, '_origin') or self._origin is None:
|
||||
self._origin = np.array([0, 0])
|
||||
|
||||
|
||||
@property
|
||||
def link(self) -> Link:
|
||||
"""Get the associated Link object"""
|
||||
return self._link
|
||||
|
||||
|
||||
def set_hovered(self, hovered: bool):
|
||||
"""Set the hover state for visual feedback"""
|
||||
self._hovered = hovered
|
||||
|
||||
|
||||
def render(self, next_text: Optional['Text'] = None, spacing: int = 0):
|
||||
"""
|
||||
@ -73,12 +73,12 @@ class LinkText(Text, Interactable, Queriable):
|
||||
"""
|
||||
# Call the parent Text render method with parameters
|
||||
super().render(next_text, spacing)
|
||||
|
||||
|
||||
# Add hover effect if needed
|
||||
if self._hovered:
|
||||
# Draw a subtle highlight background
|
||||
highlight_color = (220, 220, 255, 100) # Light blue with alpha
|
||||
|
||||
|
||||
# Handle mock objects in tests
|
||||
size = self.size
|
||||
if hasattr(size, '__call__'): # It's a Mock
|
||||
@ -86,13 +86,15 @@ class LinkText(Text, Interactable, Queriable):
|
||||
size = np.array([100, 20])
|
||||
else:
|
||||
size = np.array(size)
|
||||
|
||||
|
||||
# Ensure origin is a numpy array
|
||||
origin = np.array(self._origin) if not isinstance(self._origin, np.ndarray) else self._origin
|
||||
|
||||
self._draw.rectangle([origin, origin + size],
|
||||
fill=highlight_color)
|
||||
|
||||
origin = np.array(
|
||||
self._origin) if not isinstance(
|
||||
self._origin,
|
||||
np.ndarray) else self._origin
|
||||
|
||||
self._draw.rectangle([origin, origin + size],
|
||||
fill=highlight_color)
|
||||
|
||||
|
||||
class ButtonText(Text, Interactable, Queriable):
|
||||
@ -100,13 +102,13 @@ class ButtonText(Text, Interactable, Queriable):
|
||||
A Text subclass that can handle Button interactions.
|
||||
Renders text as a clickable button with visual states.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, button: Button, font: Font, draw: ImageDraw.Draw,
|
||||
padding: Tuple[int, int, int, int] = (4, 8, 4, 8),
|
||||
source=None, line=None):
|
||||
"""
|
||||
Initialize a button text object.
|
||||
|
||||
|
||||
Args:
|
||||
button: The abstract Button object to handle interactions
|
||||
font: The base font style
|
||||
@ -117,40 +119,41 @@ class ButtonText(Text, Interactable, Queriable):
|
||||
"""
|
||||
# Initialize Text with the button label
|
||||
Text.__init__(self, button.label, font, draw, source, line)
|
||||
|
||||
|
||||
# Initialize Interactable with the button's execute method
|
||||
Interactable.__init__(self, button.execute)
|
||||
|
||||
|
||||
# Store button properties
|
||||
self._button = button
|
||||
self._padding = padding
|
||||
self._pressed = False
|
||||
self._hovered = False
|
||||
|
||||
|
||||
# Recalculate dimensions to include padding
|
||||
# Use getattr to handle mock objects in tests
|
||||
text_width = getattr(self, '_width', 0) if not hasattr(self._width, '__call__') else 0
|
||||
text_width = getattr(
|
||||
self, '_width', 0) if not hasattr(
|
||||
self._width, '__call__') else 0
|
||||
self._padded_width = text_width + padding[1] + padding[3]
|
||||
self._padded_height = self._style.font_size + padding[0] + padding[2]
|
||||
|
||||
|
||||
@property
|
||||
def button(self) -> Button:
|
||||
"""Get the associated Button object"""
|
||||
return self._button
|
||||
|
||||
|
||||
@property
|
||||
def size(self) -> np.ndarray:
|
||||
"""Get the padded size of the button"""
|
||||
return np.array([self._padded_width, self._padded_height])
|
||||
|
||||
|
||||
def set_pressed(self, pressed: bool):
|
||||
"""Set the pressed state"""
|
||||
self._pressed = pressed
|
||||
|
||||
|
||||
def set_hovered(self, hovered: bool):
|
||||
"""Set the hover state"""
|
||||
self._hovered = hovered
|
||||
|
||||
|
||||
def render(self):
|
||||
"""
|
||||
@ -177,7 +180,7 @@ class ButtonText(Text, Interactable, Queriable):
|
||||
bg_color = (100, 150, 200)
|
||||
border_color = (70, 120, 170)
|
||||
text_color = (255, 255, 255)
|
||||
|
||||
|
||||
# Draw button background with rounded corners
|
||||
# rounded_rectangle expects [x0, y0, x1, y1] format
|
||||
button_rect = [
|
||||
@ -187,8 +190,8 @@ class ButtonText(Text, Interactable, Queriable):
|
||||
int(self._origin[1] + self.size[1])
|
||||
]
|
||||
self._draw.rounded_rectangle(button_rect, fill=bg_color,
|
||||
outline=border_color, width=1, radius=4)
|
||||
|
||||
outline=border_color, width=1, radius=4)
|
||||
|
||||
# Update text color and render text centered within padding
|
||||
self._style = self._style.with_colour(text_color)
|
||||
text_x = self._origin[0] + self._padding[3] # left padding
|
||||
@ -209,28 +212,28 @@ class ButtonText(Text, Interactable, Queriable):
|
||||
# Temporarily set origin for text rendering
|
||||
original_origin = self._origin.copy()
|
||||
self._origin = np.array([text_x, text_y])
|
||||
|
||||
|
||||
# Call parent render method for the text
|
||||
super().render()
|
||||
|
||||
|
||||
# Restore original origin
|
||||
self._origin = original_origin
|
||||
|
||||
|
||||
def in_object(self, point) -> bool:
|
||||
"""
|
||||
Check if a point is within this button.
|
||||
|
||||
|
||||
Args:
|
||||
point: The coordinates to check
|
||||
|
||||
|
||||
Returns:
|
||||
True if the point is within the button bounds (including padding)
|
||||
"""
|
||||
point_array = np.array(point)
|
||||
relative_point = point_array - self._origin
|
||||
|
||||
|
||||
# Check if the point is within the padded button boundaries
|
||||
return (0 <= relative_point[0] < self._padded_width and
|
||||
return (0 <= relative_point[0] < self._padded_width and
|
||||
0 <= relative_point[1] < self._padded_height)
|
||||
|
||||
|
||||
@ -239,12 +242,12 @@ class FormFieldText(Text, Interactable, Queriable):
|
||||
A Text subclass that can handle FormField interactions.
|
||||
Renders form field labels and input areas.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, field: FormField, font: Font, draw: ImageDraw.Draw,
|
||||
field_height: int = 24, source=None, line=None):
|
||||
"""
|
||||
Initialize a form field text object.
|
||||
|
||||
|
||||
Args:
|
||||
field: The abstract FormField object to handle interactions
|
||||
font: The base font style for the label
|
||||
@ -255,68 +258,70 @@ class FormFieldText(Text, Interactable, Queriable):
|
||||
"""
|
||||
# Initialize Text with the field label
|
||||
Text.__init__(self, field.label, font, draw, source, line)
|
||||
|
||||
|
||||
# Initialize Interactable - form fields don't have direct callbacks
|
||||
# but can notify of focus/value changes
|
||||
Interactable.__init__(self, None)
|
||||
|
||||
|
||||
# Store field properties
|
||||
self._field = field
|
||||
self._field_height = field_height
|
||||
self._focused = False
|
||||
|
||||
|
||||
# Calculate total height (label + gap + field)
|
||||
self._total_height = self._style.font_size + 5 + field_height
|
||||
|
||||
|
||||
# Field width should be at least as wide as the label
|
||||
# Use getattr to handle mock objects in tests
|
||||
text_width = getattr(self, '_width', 0) if not hasattr(self._width, '__call__') else 0
|
||||
text_width = getattr(
|
||||
self, '_width', 0) if not hasattr(
|
||||
self._width, '__call__') else 0
|
||||
self._field_width = max(text_width, 150)
|
||||
|
||||
|
||||
@property
|
||||
def field(self) -> FormField:
|
||||
"""Get the associated FormField object"""
|
||||
return self._field
|
||||
|
||||
|
||||
@property
|
||||
def size(self) -> np.ndarray:
|
||||
"""Get the total size including label and field"""
|
||||
return np.array([self._field_width, self._total_height])
|
||||
|
||||
|
||||
def set_focused(self, focused: bool):
|
||||
"""Set the focus state"""
|
||||
self._focused = focused
|
||||
|
||||
|
||||
def render(self):
|
||||
"""
|
||||
Render the form field with label and input area.
|
||||
"""
|
||||
# Render the label
|
||||
super().render()
|
||||
|
||||
|
||||
# Calculate field position (below label with 5px gap)
|
||||
field_x = self._origin[0]
|
||||
field_y = self._origin[1] + self._style.font_size + 5
|
||||
|
||||
|
||||
# Draw field background and border
|
||||
bg_color = (255, 255, 255)
|
||||
border_color = (100, 150, 200) if self._focused else (200, 200, 200)
|
||||
|
||||
field_rect = [(field_x, field_y),
|
||||
(field_x + self._field_width, field_y + self._field_height)]
|
||||
|
||||
field_rect = [(field_x, field_y),
|
||||
(field_x + self._field_width, field_y + self._field_height)]
|
||||
self._draw.rectangle(field_rect, fill=bg_color, outline=border_color, width=1)
|
||||
|
||||
|
||||
# Render field value if present
|
||||
if self._field.value is not None:
|
||||
value_text = str(self._field.value)
|
||||
|
||||
|
||||
# For password fields, mask the text
|
||||
if self._field.field_type == FormFieldType.PASSWORD:
|
||||
value_text = "•" * len(value_text)
|
||||
|
||||
|
||||
# Create a temporary Text object for the value
|
||||
value_font = self._style.with_colour((0, 0, 0))
|
||||
|
||||
|
||||
# Position value text within field (with some padding)
|
||||
# Get font metrics to properly center the baseline
|
||||
ascent, descent = value_font.font.getmetrics()
|
||||
@ -326,61 +331,62 @@ class FormFieldText(Text, Interactable, Queriable):
|
||||
vertical_center = self._field_height / 2
|
||||
value_x = field_x + 5
|
||||
value_y = field_y + vertical_center + (descent / 2)
|
||||
|
||||
|
||||
# Draw the value text
|
||||
self._draw.text((value_x, value_y), value_text,
|
||||
font=value_font.font, fill=value_font.colour, anchor="ls")
|
||||
|
||||
self._draw.text((value_x, value_y), value_text,
|
||||
font=value_font.font, fill=value_font.colour, anchor="ls")
|
||||
|
||||
def handle_click(self, point) -> bool:
|
||||
"""
|
||||
Handle clicks on the form field.
|
||||
|
||||
|
||||
Args:
|
||||
point: The click coordinates relative to this field
|
||||
|
||||
|
||||
Returns:
|
||||
True if the field was clicked and focused
|
||||
"""
|
||||
# Calculate field area
|
||||
field_y = self._style.font_size + 5
|
||||
|
||||
|
||||
# Check if click is within the input field area (not just the label)
|
||||
if (0 <= point[0] <= self._field_width and
|
||||
field_y <= point[1] <= field_y + self._field_height):
|
||||
field_y <= point[1] <= field_y + self._field_height):
|
||||
self.set_focused(True)
|
||||
return True
|
||||
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def in_object(self, point) -> bool:
|
||||
"""
|
||||
Check if a point is within this form field (including label and input area).
|
||||
|
||||
|
||||
Args:
|
||||
point: The coordinates to check
|
||||
|
||||
|
||||
Returns:
|
||||
True if the point is within the field bounds
|
||||
"""
|
||||
point_array = np.array(point)
|
||||
relative_point = point_array - self._origin
|
||||
|
||||
|
||||
# Check if the point is within the total field area
|
||||
return (0 <= relative_point[0] < self._field_width and
|
||||
return (0 <= relative_point[0] < self._field_width and
|
||||
0 <= relative_point[1] < self._total_height)
|
||||
|
||||
|
||||
# Factory functions for creating functional text objects
|
||||
def create_link_text(link: Link, text: str, font: Font, draw: ImageDraw.Draw) -> LinkText:
|
||||
def create_link_text(link: Link, text: str, font: Font,
|
||||
draw: ImageDraw.Draw) -> LinkText:
|
||||
"""
|
||||
Factory function to create a LinkText object.
|
||||
|
||||
|
||||
Args:
|
||||
link: The Link object to associate with the text
|
||||
text: The text content to display
|
||||
font: The base font style
|
||||
draw: The drawing context
|
||||
|
||||
|
||||
Returns:
|
||||
A LinkText object ready for rendering and interaction
|
||||
"""
|
||||
@ -388,16 +394,16 @@ def create_link_text(link: Link, text: str, font: Font, draw: ImageDraw.Draw) ->
|
||||
|
||||
|
||||
def create_button_text(button: Button, font: Font, draw: ImageDraw.Draw,
|
||||
padding: Tuple[int, int, int, int] = (4, 8, 4, 8)) -> ButtonText:
|
||||
padding: Tuple[int, int, int, int] = (4, 8, 4, 8)) -> ButtonText:
|
||||
"""
|
||||
Factory function to create a ButtonText object.
|
||||
|
||||
|
||||
Args:
|
||||
button: The Button object to associate with the text
|
||||
font: The base font style
|
||||
draw: The drawing context
|
||||
padding: Padding around the button text
|
||||
|
||||
|
||||
Returns:
|
||||
A ButtonText object ready for rendering and interaction
|
||||
"""
|
||||
@ -405,16 +411,16 @@ def create_button_text(button: Button, font: Font, draw: ImageDraw.Draw,
|
||||
|
||||
|
||||
def create_form_field_text(field: FormField, font: Font, draw: ImageDraw.Draw,
|
||||
field_height: int = 24) -> FormFieldText:
|
||||
field_height: int = 24) -> FormFieldText:
|
||||
"""
|
||||
Factory function to create a FormFieldText object.
|
||||
|
||||
|
||||
Args:
|
||||
field: The FormField object to associate with the text
|
||||
font: The base font style for the label
|
||||
draw: The drawing context
|
||||
field_height: Height of the input field area
|
||||
|
||||
|
||||
Returns:
|
||||
A FormFieldText object ready for rendering and interaction
|
||||
"""
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
import os
|
||||
from typing import Optional, Tuple, Union, Dict, Any
|
||||
from typing import Optional
|
||||
import numpy as np
|
||||
from PIL import Image as PILImage, ImageDraw, ImageFont
|
||||
from pyWebLayout.core.base import Renderable, Queriable
|
||||
from pyWebLayout.abstract.block import Image as AbstractImage
|
||||
from .box import Box
|
||||
from pyWebLayout.style import Alignment
|
||||
|
||||
|
||||
@ -12,14 +11,14 @@ class RenderableImage(Renderable, Queriable):
|
||||
"""
|
||||
A concrete implementation for rendering Image objects.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, image: AbstractImage, canvas: PILImage.Image,
|
||||
max_width: Optional[int] = None, max_height: Optional[int] = None,
|
||||
origin=None, size=None, callback=None, sheet=None, mode=None,
|
||||
halign=Alignment.CENTER, valign=Alignment.CENTER):
|
||||
"""
|
||||
Initialize a renderable image.
|
||||
|
||||
|
||||
Args:
|
||||
image: The abstract Image object to render
|
||||
draw: The PIL ImageDraw object to draw on
|
||||
@ -40,52 +39,54 @@ class RenderableImage(Renderable, Queriable):
|
||||
self._error_message = None
|
||||
self._halign = halign
|
||||
self._valign = valign
|
||||
|
||||
|
||||
# Set origin as numpy array
|
||||
self._origin = np.array(origin) if origin is not None else np.array([0, 0])
|
||||
|
||||
|
||||
# Try to load the image
|
||||
self._load_image()
|
||||
|
||||
|
||||
# Calculate the size if not provided
|
||||
if size is None:
|
||||
size = image.calculate_scaled_dimensions(max_width, max_height)
|
||||
# Ensure we have valid dimensions, fallback to defaults if None
|
||||
if size[0] is None or size[1] is None:
|
||||
size = (100, 100) # Default size when image dimensions are unavailable
|
||||
|
||||
|
||||
# Set size as numpy array
|
||||
self._size = np.array(size)
|
||||
|
||||
|
||||
@property
|
||||
def origin(self) -> np.ndarray:
|
||||
"""Get the origin of the image"""
|
||||
return self._origin
|
||||
|
||||
|
||||
@property
|
||||
def size(self) -> np.ndarray:
|
||||
"""Get the size of the image"""
|
||||
return self._size
|
||||
|
||||
|
||||
@property
|
||||
def width(self) -> int:
|
||||
"""Get the width of the image"""
|
||||
return self._size[0]
|
||||
|
||||
|
||||
def set_origin(self, origin: np.ndarray):
|
||||
"""Set the origin of this image element"""
|
||||
self._origin = origin
|
||||
|
||||
|
||||
def _load_image(self):
|
||||
"""Load the image from the source path"""
|
||||
try:
|
||||
# Check if the image has already been loaded into memory
|
||||
if hasattr(self._abstract_image, '_loaded_image') and self._abstract_image._loaded_image is not None:
|
||||
if hasattr(
|
||||
self._abstract_image,
|
||||
'_loaded_image') and self._abstract_image._loaded_image is not None:
|
||||
self._pil_image = self._abstract_image._loaded_image
|
||||
return
|
||||
|
||||
|
||||
source = self._abstract_image.source
|
||||
|
||||
|
||||
# Handle different types of sources
|
||||
if os.path.isfile(source):
|
||||
# Local file
|
||||
@ -96,22 +97,23 @@ class RenderableImage(Renderable, Queriable):
|
||||
try:
|
||||
import requests
|
||||
from io import BytesIO
|
||||
|
||||
|
||||
response = requests.get(source, stream=True)
|
||||
if response.status_code == 200:
|
||||
self._pil_image = PILImage.open(BytesIO(response.content))
|
||||
self._abstract_image._loaded_image = self._pil_image
|
||||
else:
|
||||
self._error_message = f"Failed to load image: HTTP status {response.status_code}"
|
||||
self._error_message = f"Failed to load image: HTTP status {
|
||||
response.status_code}"
|
||||
except ImportError:
|
||||
self._error_message = "Requests library not available for URL loading"
|
||||
else:
|
||||
self._error_message = f"Unable to load image from source: {source}"
|
||||
|
||||
|
||||
except Exception as e:
|
||||
self._error_message = f"Error loading image: {str(e)}"
|
||||
self._abstract_image._error = self._error_message
|
||||
|
||||
|
||||
def render(self):
|
||||
"""
|
||||
Render the image directly into the canvas using the provided draw object.
|
||||
@ -119,11 +121,11 @@ class RenderableImage(Renderable, Queriable):
|
||||
if self._pil_image:
|
||||
# Resize the image to fit the box while maintaining aspect ratio
|
||||
resized_image = self._resize_image()
|
||||
|
||||
|
||||
# Calculate position based on alignment
|
||||
img_width, img_height = resized_image.size
|
||||
box_width, box_height = self._size
|
||||
|
||||
|
||||
# Horizontal alignment
|
||||
if self._halign == Alignment.LEFT:
|
||||
x_offset = 0
|
||||
@ -131,7 +133,7 @@ class RenderableImage(Renderable, Queriable):
|
||||
x_offset = box_width - img_width
|
||||
else: # CENTER is default
|
||||
x_offset = (box_width - img_width) // 2
|
||||
|
||||
|
||||
# Vertical alignment
|
||||
if self._valign == Alignment.TOP:
|
||||
y_offset = 0
|
||||
@ -139,55 +141,62 @@ class RenderableImage(Renderable, Queriable):
|
||||
y_offset = box_height - img_height
|
||||
else: # CENTER is default
|
||||
y_offset = (box_height - img_height) // 2
|
||||
|
||||
|
||||
# Calculate final position on canvas
|
||||
final_x = int(self._origin[0] + x_offset)
|
||||
final_y = int(self._origin[1] + y_offset)
|
||||
|
||||
|
||||
# Get the underlying image from the draw object to paste onto
|
||||
|
||||
|
||||
self._canvas.paste(resized_image, (final_x, final_y, final_x + img_width, final_y + img_height))
|
||||
|
||||
self._canvas.paste(
|
||||
resized_image,
|
||||
(final_x,
|
||||
final_y,
|
||||
final_x +
|
||||
img_width,
|
||||
final_y +
|
||||
img_height))
|
||||
else:
|
||||
# Draw error placeholder
|
||||
self._draw_error_placeholder()
|
||||
|
||||
|
||||
def _resize_image(self) -> PILImage.Image:
|
||||
"""
|
||||
Resize the image to fit within the box while maintaining aspect ratio.
|
||||
|
||||
|
||||
Returns:
|
||||
A resized PIL Image
|
||||
"""
|
||||
if not self._pil_image:
|
||||
return PILImage.new('RGBA', tuple(self._size), (200, 200, 200, 100))
|
||||
|
||||
|
||||
# Get the target dimensions
|
||||
target_width, target_height = self._size
|
||||
|
||||
|
||||
# Get the original dimensions
|
||||
orig_width, orig_height = self._pil_image.size
|
||||
|
||||
|
||||
# Calculate the scaling factor to maintain aspect ratio
|
||||
width_ratio = target_width / orig_width
|
||||
height_ratio = target_height / orig_height
|
||||
|
||||
|
||||
# Use the smaller ratio to ensure the image fits within the box
|
||||
ratio = min(width_ratio, height_ratio)
|
||||
|
||||
|
||||
# Calculate new dimensions
|
||||
new_width = int(orig_width * ratio)
|
||||
new_height = int(orig_height * ratio)
|
||||
|
||||
|
||||
# Resize the image
|
||||
if self._pil_image.mode == 'RGBA':
|
||||
resized = self._pil_image.resize((new_width, new_height), PILImage.LANCZOS)
|
||||
else:
|
||||
# Convert to RGBA if needed
|
||||
resized = self._pil_image.convert('RGBA').resize((new_width, new_height), PILImage.LANCZOS)
|
||||
|
||||
resized = self._pil_image.convert('RGBA').resize(
|
||||
(new_width, new_height), PILImage.LANCZOS)
|
||||
|
||||
return resized
|
||||
|
||||
|
||||
def _draw_error_placeholder(self):
|
||||
"""
|
||||
Draw a placeholder for when the image can't be loaded.
|
||||
@ -197,68 +206,69 @@ class RenderableImage(Renderable, Queriable):
|
||||
y1 = int(self._origin[1])
|
||||
x2 = int(self._origin[0] + self._size[0])
|
||||
y2 = int(self._origin[1] + self._size[1])
|
||||
|
||||
|
||||
self._draw = ImageDraw.Draw(self._canvas)
|
||||
# Draw a gray box with a border
|
||||
self._draw.rectangle([(x1, y1), (x2, y2)], fill=(240, 240, 240), outline=(180, 180, 180), width=2)
|
||||
|
||||
self._draw.rectangle([(x1, y1), (x2, y2)], fill=(
|
||||
240, 240, 240), outline=(180, 180, 180), width=2)
|
||||
|
||||
# Draw an X across the box
|
||||
self._draw.line([(x1, y1), (x2, y2)], fill=(180, 180, 180), width=2)
|
||||
self._draw.line([(x1, y2), (x2, y1)], fill=(180, 180, 180), width=2)
|
||||
|
||||
|
||||
# Add error text if available
|
||||
if self._error_message:
|
||||
try:
|
||||
# Try to use a basic font
|
||||
font = ImageFont.load_default()
|
||||
|
||||
|
||||
# Draw the error message, wrapped to fit
|
||||
error_text = "Error: " + self._error_message
|
||||
|
||||
|
||||
# Simple text wrapping - split by words and add lines
|
||||
words = error_text.split()
|
||||
lines = []
|
||||
current_line = ""
|
||||
|
||||
|
||||
for word in words:
|
||||
test_line = current_line + " " + word if current_line else word
|
||||
text_bbox = self._draw.textbbox((0, 0), test_line, font=font)
|
||||
text_width = text_bbox[2] - text_bbox[0]
|
||||
|
||||
|
||||
if text_width <= self._size[0] - 20: # 10px padding on each side
|
||||
current_line = test_line
|
||||
else:
|
||||
lines.append(current_line)
|
||||
current_line = word
|
||||
|
||||
|
||||
if current_line:
|
||||
lines.append(current_line)
|
||||
|
||||
|
||||
# Draw each line
|
||||
y_pos = y1 + 10
|
||||
for line in lines:
|
||||
text_bbox = self._draw.textbbox((0, 0), line, font=font)
|
||||
text_width = text_bbox[2] - text_bbox[0]
|
||||
text_height = text_bbox[3] - text_bbox[1]
|
||||
|
||||
|
||||
# Center the text horizontally
|
||||
x_pos = x1 + (self._size[0] - text_width) // 2
|
||||
|
||||
|
||||
# Draw the text
|
||||
self._draw.text((x_pos, y_pos), line, fill=(80, 80, 80), font=font)
|
||||
|
||||
|
||||
# Move to the next line
|
||||
y_pos += text_height + 2
|
||||
|
||||
|
||||
except Exception:
|
||||
# If text rendering fails, just draw a generic error indicator
|
||||
pass
|
||||
|
||||
|
||||
def in_object(self, point):
|
||||
"""Check if a point is within this image"""
|
||||
point_array = np.array(point)
|
||||
relative_point = point_array - self._origin
|
||||
|
||||
|
||||
# Check if the point is within the image boundaries
|
||||
return (0 <= relative_point[0] < self._size[0] and
|
||||
return (0 <= relative_point[0] < self._size[0] and
|
||||
0 <= relative_point[1] < self._size[1])
|
||||
|
||||
@ -2,12 +2,11 @@ from typing import List, Tuple, Optional
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from pyWebLayout.core.base import Renderable, Layoutable, Queriable
|
||||
from pyWebLayout.core.base import Renderable, Queriable
|
||||
from pyWebLayout.core.query import QueryResult, SelectionRange
|
||||
from pyWebLayout.core.callback_registry import CallbackRegistry
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from pyWebLayout.style import Alignment
|
||||
from .box import Box
|
||||
|
||||
|
||||
class Page(Renderable, Queriable):
|
||||
"""
|
||||
@ -41,29 +40,33 @@ class Page(Renderable, Queriable):
|
||||
"""Get the remaining space on the page"""
|
||||
return (self._size[0], self._size[1] - self._current_y_offset)
|
||||
|
||||
def can_fit_line(self, baseline_spacing: int, ascent: int = 0, descent: int = 0) -> bool:
|
||||
def can_fit_line(
|
||||
self,
|
||||
baseline_spacing: int,
|
||||
ascent: int = 0,
|
||||
descent: int = 0) -> bool:
|
||||
"""
|
||||
Check if a line with the given metrics can fit on the page.
|
||||
|
||||
|
||||
Args:
|
||||
baseline_spacing: Distance from current position to next baseline
|
||||
ascent: Font ascent (height above baseline), defaults to 0 for backward compat
|
||||
descent: Font descent (height below baseline), defaults to 0 for backward compat
|
||||
|
||||
|
||||
Returns:
|
||||
True if the line fits within page boundaries
|
||||
"""
|
||||
# Calculate the maximum Y position allowed (bottom boundary)
|
||||
max_y = self._size[1] - self._style.border_width - self._style.padding_bottom
|
||||
|
||||
|
||||
# If ascent/descent not provided, use simple check (backward compatibility)
|
||||
if ascent == 0 and descent == 0:
|
||||
return (self._current_y_offset + baseline_spacing) <= max_y
|
||||
|
||||
|
||||
# Calculate where the bottom of the text would be
|
||||
# Text bottom = current_y_offset + ascent + descent
|
||||
text_bottom = self._current_y_offset + ascent + descent
|
||||
|
||||
|
||||
# Check if text bottom would exceed the boundary
|
||||
return text_bottom <= max_y
|
||||
|
||||
@ -183,11 +186,15 @@ class Page(Renderable, Queriable):
|
||||
Height in pixels
|
||||
"""
|
||||
if hasattr(child, '_size') and child._size is not None:
|
||||
if isinstance(child._size, (list, tuple, np.ndarray)) and len(child._size) >= 2:
|
||||
if isinstance(
|
||||
child._size, (list, tuple, np.ndarray)) and len(
|
||||
child._size) >= 2:
|
||||
return int(child._size[1])
|
||||
|
||||
if hasattr(child, 'size') and child.size is not None:
|
||||
if isinstance(child.size, (list, tuple, np.ndarray)) and len(child.size) >= 2:
|
||||
if isinstance(
|
||||
child.size, (list, tuple, np.ndarray)) and len(
|
||||
child.size) >= 2:
|
||||
return int(child.size[1])
|
||||
|
||||
if hasattr(child, 'height'):
|
||||
@ -326,7 +333,7 @@ class Page(Renderable, Queriable):
|
||||
if isinstance(child, Queriable) and hasattr(child, 'in_object'):
|
||||
try:
|
||||
return child.in_object(point)
|
||||
except:
|
||||
except BaseException:
|
||||
pass # Fall back to bounds checking
|
||||
|
||||
# Get child position and size for bounds checking
|
||||
@ -353,11 +360,15 @@ class Page(Renderable, Queriable):
|
||||
Tuple of (width, height) or None if size cannot be determined
|
||||
"""
|
||||
if hasattr(child, '_size') and child._size is not None:
|
||||
if isinstance(child._size, (list, tuple, np.ndarray)) and len(child._size) >= 2:
|
||||
if isinstance(
|
||||
child._size, (list, tuple, np.ndarray)) and len(
|
||||
child._size) >= 2:
|
||||
return (int(child._size[0]), int(child._size[1]))
|
||||
|
||||
if hasattr(child, 'size') and child.size is not None:
|
||||
if isinstance(child.size, (list, tuple, np.ndarray)) and len(child.size) >= 2:
|
||||
if isinstance(
|
||||
child.size, (list, tuple, np.ndarray)) and len(
|
||||
child.size) >= 2:
|
||||
return (int(child.size[0]), int(child.size[1]))
|
||||
|
||||
if hasattr(child, 'width') and hasattr(child, 'height'):
|
||||
@ -422,7 +433,8 @@ class Page(Renderable, Queriable):
|
||||
bounds=bounds
|
||||
)
|
||||
|
||||
def query_range(self, start: Tuple[int, int], end: Tuple[int, int]) -> SelectionRange:
|
||||
def query_range(self, start: Tuple[int, int],
|
||||
end: Tuple[int, int]) -> SelectionRange:
|
||||
"""
|
||||
Query all text objects between two points (for text selection).
|
||||
Uses Queriable.in_object() to determine which objects are in range.
|
||||
|
||||
@ -9,15 +9,13 @@ This module provides the concrete rendering classes for tables, including:
|
||||
|
||||
from __future__ import annotations
|
||||
from typing import Tuple, List, Optional, Dict
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pyWebLayout.core.base import Renderable, Queriable
|
||||
from pyWebLayout.core.base import Renderable
|
||||
from pyWebLayout.concrete.box import Box
|
||||
from pyWebLayout.abstract.block import Table, TableRow, TableCell, Paragraph, Heading, Image as AbstractImage
|
||||
from pyWebLayout.abstract.interactive_image import InteractiveImage
|
||||
from pyWebLayout.style import Font, Alignment
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -49,8 +47,15 @@ class TableCellRenderer(Box):
|
||||
Supports paragraphs, headings, images, and links within cells.
|
||||
"""
|
||||
|
||||
def __init__(self, cell: TableCell, origin: Tuple[int, int], size: Tuple[int, int],
|
||||
draw: ImageDraw.Draw, style: TableStyle, is_header_section: bool = False,
|
||||
def __init__(self,
|
||||
cell: TableCell,
|
||||
origin: Tuple[int,
|
||||
int],
|
||||
size: Tuple[int,
|
||||
int],
|
||||
draw: ImageDraw.Draw,
|
||||
style: TableStyle,
|
||||
is_header_section: bool = False,
|
||||
canvas: Optional[Image.Image] = None):
|
||||
"""
|
||||
Initialize a table cell renderer.
|
||||
@ -111,17 +116,20 @@ class TableCellRenderer(Box):
|
||||
# Get font
|
||||
try:
|
||||
if self._is_header_section and self._style.header_text_bold:
|
||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 12)
|
||||
font = ImageFont.truetype(
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 12)
|
||||
else:
|
||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 12)
|
||||
except:
|
||||
font = ImageFont.truetype(
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 12)
|
||||
except BaseException:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
# Render each block in the cell
|
||||
for block in self._cell.blocks():
|
||||
if isinstance(block, AbstractImage):
|
||||
# Render image
|
||||
current_y = self._render_image_in_cell(block, x, current_y, width, height - (current_y - y))
|
||||
current_y = self._render_image_in_cell(
|
||||
block, x, current_y, width, height - (current_y - y))
|
||||
elif isinstance(block, (Paragraph, Heading)):
|
||||
# Extract and render text
|
||||
words = []
|
||||
@ -137,7 +145,8 @@ class TableCellRenderer(Box):
|
||||
if words:
|
||||
text = " ".join(words)
|
||||
if current_y <= y + height - 15:
|
||||
self._draw.text((x + 2, current_y), text, fill=(0, 0, 0), font=font)
|
||||
self._draw.text((x + 2, current_y), text,
|
||||
fill=(0, 0, 0), font=font)
|
||||
current_y += 16
|
||||
|
||||
if current_y > y + height - 10: # Don't overflow cell
|
||||
@ -145,10 +154,18 @@ class TableCellRenderer(Box):
|
||||
|
||||
# If no structured content, try to get any text representation
|
||||
if current_y == y + 2 and hasattr(self._cell, '_text_content'):
|
||||
self._draw.text((x + 2, current_y), self._cell._text_content, fill=(0, 0, 0), font=font)
|
||||
self._draw.text(
|
||||
(x + 2,
|
||||
current_y),
|
||||
self._cell._text_content,
|
||||
fill=(
|
||||
0,
|
||||
0,
|
||||
0),
|
||||
font=font)
|
||||
|
||||
def _render_image_in_cell(self, image_block: AbstractImage, x: int, y: int,
|
||||
max_width: int, max_height: int) -> int:
|
||||
max_width: int, max_height: int) -> int:
|
||||
"""
|
||||
Render an image block inside a table cell.
|
||||
|
||||
@ -181,7 +198,8 @@ class TableCellRenderer(Box):
|
||||
# Use more of the cell space for images
|
||||
img_width, img_height = img.size
|
||||
scale_w = max_width / img_width if img_width > max_width else 1
|
||||
scale_h = (max_height - 10) / img_height if img_height > (max_height - 10) else 1
|
||||
scale_h = (max_height - 10) / \
|
||||
img_height if img_height > (max_height - 10) else 1
|
||||
scale = min(scale_w, scale_h, 1.0) # Don't upscale
|
||||
|
||||
new_width = int(img_width * scale)
|
||||
@ -210,8 +228,9 @@ class TableCellRenderer(Box):
|
||||
# Draw image indicator text
|
||||
from PIL import ImageFont
|
||||
try:
|
||||
small_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 9)
|
||||
except:
|
||||
small_font = ImageFont.truetype(
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 9)
|
||||
except BaseException:
|
||||
small_font = ImageFont.load_default()
|
||||
|
||||
text = f"[Image: {new_width}x{new_height}]"
|
||||
@ -219,7 +238,9 @@ class TableCellRenderer(Box):
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_x = img_x + (new_width - text_width) // 2
|
||||
text_y = y + (new_height - 12) // 2
|
||||
self._draw.text((text_x, text_y), text, fill=(100, 100, 100), font=small_font)
|
||||
self._draw.text(
|
||||
(text_x, text_y), text, fill=(
|
||||
100, 100, 100), font=small_font)
|
||||
|
||||
# Set bounds on InteractiveImage objects for tap detection
|
||||
if isinstance(image_block, InteractiveImage):
|
||||
@ -230,7 +251,7 @@ class TableCellRenderer(Box):
|
||||
|
||||
return y + new_height + 5 # Add some spacing after image
|
||||
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
# If image loading fails, just return current position
|
||||
return y + 20
|
||||
|
||||
@ -240,9 +261,15 @@ class TableRowRenderer(Box):
|
||||
Renders a single table row containing multiple cells.
|
||||
"""
|
||||
|
||||
def __init__(self, row: TableRow, origin: Tuple[int, int],
|
||||
column_widths: List[int], row_height: int,
|
||||
draw: ImageDraw.Draw, style: TableStyle, is_header_section: bool = False,
|
||||
def __init__(self,
|
||||
row: TableRow,
|
||||
origin: Tuple[int,
|
||||
int],
|
||||
column_widths: List[int],
|
||||
row_height: int,
|
||||
draw: ImageDraw.Draw,
|
||||
style: TableStyle,
|
||||
is_header_section: bool = False,
|
||||
canvas: Optional[Image.Image] = None):
|
||||
"""
|
||||
Initialize a table row renderer.
|
||||
@ -309,9 +336,14 @@ class TableRenderer(Box):
|
||||
Handles layout calculation, row/cell placement, and overall table structure.
|
||||
"""
|
||||
|
||||
def __init__(self, table: Table, origin: Tuple[int, int],
|
||||
available_width: int, draw: ImageDraw.Draw,
|
||||
style: Optional[TableStyle] = None, canvas: Optional[Image.Image] = None):
|
||||
def __init__(self,
|
||||
table: Table,
|
||||
origin: Tuple[int,
|
||||
int],
|
||||
available_width: int,
|
||||
draw: ImageDraw.Draw,
|
||||
style: Optional[TableStyle] = None,
|
||||
canvas: Optional[Image.Image] = None):
|
||||
"""
|
||||
Initialize a table renderer.
|
||||
|
||||
@ -331,8 +363,10 @@ class TableRenderer(Box):
|
||||
|
||||
# Calculate table dimensions
|
||||
self._column_widths, self._row_heights = self._calculate_dimensions()
|
||||
total_width = sum(self._column_widths) + self._style.border_width * (len(self._column_widths) + 1)
|
||||
total_height = sum(self._row_heights.values()) + self._style.border_width * (len(self._row_heights) + 1)
|
||||
total_width = sum(self._column_widths) + \
|
||||
self._style.border_width * (len(self._column_widths) + 1)
|
||||
total_height = sum(self._row_heights.values()) + \
|
||||
self._style.border_width * (len(self._row_heights) + 1)
|
||||
|
||||
super().__init__(origin, (total_width, total_height))
|
||||
self._row_renderers: List[TableRowRenderer] = []
|
||||
@ -362,7 +396,8 @@ class TableRenderer(Box):
|
||||
column_widths = [column_width] * num_columns
|
||||
|
||||
# Calculate row heights
|
||||
header_height = 35 if any(1 for section, _ in all_rows if section == "header") else 0
|
||||
header_height = 35 if any(1 for section,
|
||||
_ in all_rows if section == "header") else 0
|
||||
|
||||
# Check if any body rows contain images - if so, use larger height
|
||||
body_height = 30
|
||||
@ -375,7 +410,8 @@ class TableRenderer(Box):
|
||||
body_height = max(body_height, 120)
|
||||
break
|
||||
|
||||
footer_height = 30 if any(1 for section, _ in all_rows if section == "footer") else 0
|
||||
footer_height = 30 if any(1 for section,
|
||||
_ in all_rows if section == "footer") else 0
|
||||
|
||||
row_heights = {
|
||||
"header": header_height,
|
||||
@ -428,8 +464,9 @@ class TableRenderer(Box):
|
||||
from PIL import ImageFont
|
||||
|
||||
try:
|
||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 13)
|
||||
except:
|
||||
font = ImageFont.truetype(
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 13)
|
||||
except BaseException:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
# Center the caption
|
||||
|
||||
@ -2,15 +2,16 @@ from __future__ import annotations
|
||||
from pyWebLayout.core.base import Renderable, Queriable
|
||||
from pyWebLayout.core.query import QueryResult
|
||||
from .box import Box
|
||||
from pyWebLayout.style import Alignment, Font, FontStyle, FontWeight, TextDecoration
|
||||
from pyWebLayout.style import Alignment, Font, TextDecoration
|
||||
from pyWebLayout.abstract import Word
|
||||
from pyWebLayout.abstract.inline import LinkedWord
|
||||
from pyWebLayout.abstract.functional import Link
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from typing import Tuple, Union, List, Optional, Protocol
|
||||
from PIL import ImageDraw
|
||||
from typing import Tuple, List, Optional
|
||||
import numpy as np
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class AlignmentHandler(ABC):
|
||||
"""
|
||||
Abstract base class for text alignment handlers.
|
||||
@ -19,8 +20,8 @@ class AlignmentHandler(ABC):
|
||||
|
||||
@abstractmethod
|
||||
def calculate_spacing_and_position(self, text_objects: List['Text'],
|
||||
available_width: int, min_spacing: int,
|
||||
max_spacing: int) -> Tuple[int, int, bool]:
|
||||
available_width: int, min_spacing: int,
|
||||
max_spacing: int) -> Tuple[int, int, bool]:
|
||||
"""
|
||||
Calculate the spacing between words and starting position for the line.
|
||||
|
||||
@ -33,16 +34,16 @@ class AlignmentHandler(ABC):
|
||||
Returns:
|
||||
Tuple of (spacing_between_words, starting_x_position)
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class LeftAlignmentHandler(AlignmentHandler):
|
||||
"""Handler for left-aligned text."""
|
||||
|
||||
def calculate_spacing_and_position(self,
|
||||
text_objects: List['Text'],
|
||||
available_width: int,
|
||||
min_spacing: int,
|
||||
max_spacing: int) -> Tuple[int, int, bool]:
|
||||
text_objects: List['Text'],
|
||||
available_width: int,
|
||||
min_spacing: int,
|
||||
max_spacing: int) -> Tuple[int, int, bool]:
|
||||
"""
|
||||
Calculate spacing and position for left-aligned text objects.
|
||||
CREngine-inspired: never allow negative spacing, always use minimum spacing for overflow.
|
||||
@ -69,7 +70,8 @@ class LeftAlignmentHandler(AlignmentHandler):
|
||||
# Calculate minimum space needed (text + minimum gaps)
|
||||
min_total_width = text_length + (min_spacing * num_gaps)
|
||||
|
||||
# Check if we have overflow (CREngine pattern: always use min_spacing for overflow)
|
||||
# Check if we have overflow (CREngine pattern: always use min_spacing for
|
||||
# overflow)
|
||||
if min_total_width > available_width:
|
||||
return min_spacing, 0, True # Overflow - but use safe minimum spacing
|
||||
|
||||
@ -87,6 +89,7 @@ class LeftAlignmentHandler(AlignmentHandler):
|
||||
else:
|
||||
return actual_spacing, 0, False # Use calculated spacing
|
||||
|
||||
|
||||
class CenterRightAlignmentHandler(AlignmentHandler):
|
||||
"""Handler for center and right-aligned text."""
|
||||
|
||||
@ -94,8 +97,8 @@ class CenterRightAlignmentHandler(AlignmentHandler):
|
||||
self._alignment = alignment
|
||||
|
||||
def calculate_spacing_and_position(self, text_objects: List['Text'],
|
||||
available_width: int, min_spacing: int,
|
||||
max_spacing: int) -> Tuple[int, int, bool]:
|
||||
available_width: int, min_spacing: int,
|
||||
max_spacing: int) -> Tuple[int, int, bool]:
|
||||
"""Center/right alignment uses minimum spacing with calculated start position."""
|
||||
word_length = sum([word.width for word in text_objects])
|
||||
residual_space = available_width - word_length
|
||||
@ -108,12 +111,12 @@ class CenterRightAlignmentHandler(AlignmentHandler):
|
||||
start_position = available_width - word_length
|
||||
return 0, max(0, start_position), False
|
||||
|
||||
actual_spacing = residual_space // (len(text_objects)-1)
|
||||
ideal_space = (min_spacing + max_spacing)/2
|
||||
if actual_spacing > 0.5*(min_spacing + max_spacing):
|
||||
actual_spacing = 0.5*(min_spacing + max_spacing)
|
||||
actual_spacing = residual_space // (len(text_objects) - 1)
|
||||
ideal_space = (min_spacing + max_spacing) / 2
|
||||
if actual_spacing > 0.5 * (min_spacing + max_spacing):
|
||||
actual_spacing = 0.5 * (min_spacing + max_spacing)
|
||||
|
||||
content_length = word_length + (len(text_objects)-1) * actual_spacing
|
||||
content_length = word_length + (len(text_objects) - 1) * actual_spacing
|
||||
if self._alignment == Alignment.CENTER:
|
||||
start_position = (available_width - content_length) // 2
|
||||
else:
|
||||
@ -124,12 +127,13 @@ class CenterRightAlignmentHandler(AlignmentHandler):
|
||||
|
||||
return ideal_space, max(0, start_position), False
|
||||
|
||||
|
||||
class JustifyAlignmentHandler(AlignmentHandler):
|
||||
"""Handler for justified text with full justification."""
|
||||
|
||||
def calculate_spacing_and_position(self, text_objects: List['Text'],
|
||||
available_width: int, min_spacing: int,
|
||||
max_spacing: int) -> Tuple[int, int, bool]:
|
||||
available_width: int, min_spacing: int,
|
||||
max_spacing: int) -> Tuple[int, int, bool]:
|
||||
"""Justified alignment distributes space to fill the entire line width."""
|
||||
|
||||
word_length = sum([word.width for word in text_objects])
|
||||
@ -137,7 +141,7 @@ class JustifyAlignmentHandler(AlignmentHandler):
|
||||
num_gaps = max(1, len(text_objects) - 1)
|
||||
|
||||
actual_spacing = residual_space // num_gaps
|
||||
ideal_space = (min_spacing + max_spacing)//2
|
||||
ideal_space = (min_spacing + max_spacing) // 2
|
||||
# can we touch the end?
|
||||
if actual_spacing < max_spacing:
|
||||
if actual_spacing < min_spacing:
|
||||
@ -146,13 +150,20 @@ class JustifyAlignmentHandler(AlignmentHandler):
|
||||
return max(min_spacing, actual_spacing), 0, False
|
||||
return ideal_space, 0, False
|
||||
|
||||
|
||||
class Text(Renderable, Queriable):
|
||||
"""
|
||||
Concrete implementation for rendering text.
|
||||
This class handles the visual representation of text fragments.
|
||||
"""
|
||||
|
||||
def __init__(self, text: str, style: Font, draw: ImageDraw.Draw, source: Optional[Word] = None, line: Optional[Line] = None):
|
||||
def __init__(
|
||||
self,
|
||||
text: str,
|
||||
style: Font,
|
||||
draw: ImageDraw.Draw,
|
||||
source: Optional[Word] = None,
|
||||
line: Optional[Line] = None):
|
||||
"""
|
||||
Initialize a Text object.
|
||||
|
||||
@ -181,8 +192,8 @@ class Text(Renderable, Queriable):
|
||||
self._middle_y = ascent - descent / 2
|
||||
|
||||
@classmethod
|
||||
def from_word(cls,word:Word, draw: ImageDraw.Draw):
|
||||
return cls(word.text,word.style, draw)
|
||||
def from_word(cls, word: Word, draw: ImageDraw.Draw):
|
||||
return cls(word.text, word.style, draw)
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
@ -219,7 +230,7 @@ class Text(Renderable, Queriable):
|
||||
"""Get the width of the text"""
|
||||
return np.array((self._width, self._style.font_size))
|
||||
|
||||
def set_origin(self, origin:np.generic):
|
||||
def set_origin(self, origin: np.generic):
|
||||
"""Set the origin (left baseline ("ls")) of this text element"""
|
||||
self._origin = origin
|
||||
|
||||
@ -230,51 +241,51 @@ class Text(Renderable, Queriable):
|
||||
def _apply_decoration(self, next_text: Optional['Text'] = None, spacing: int = 0):
|
||||
"""
|
||||
Apply text decoration (underline or strikethrough).
|
||||
|
||||
|
||||
Args:
|
||||
next_text: The next Text object in the line (if any)
|
||||
spacing: The spacing to the next text object
|
||||
"""
|
||||
if self._style.decoration == TextDecoration.UNDERLINE:
|
||||
# Draw underline at about 90% of the height
|
||||
y_position = self._origin[1] - 0.1*self._style.font_size
|
||||
y_position = self._origin[1] - 0.1 * self._style.font_size
|
||||
line_width = max(1, int(self._style.font_size / 15))
|
||||
|
||||
|
||||
# Determine end x-coordinate
|
||||
end_x = self._origin[0] + self._width
|
||||
|
||||
|
||||
# If next text also has underline decoration, extend to connect them
|
||||
if (next_text is not None and
|
||||
if (next_text is not None and
|
||||
next_text.style.decoration == TextDecoration.UNDERLINE and
|
||||
next_text.style.colour == self._style.colour):
|
||||
next_text.style.colour == self._style.colour):
|
||||
# Extend the underline through the spacing to connect with next word
|
||||
end_x += spacing
|
||||
|
||||
|
||||
self._draw.line([(self._origin[0], y_position), (end_x, y_position)],
|
||||
fill=self._style.colour, width=line_width)
|
||||
fill=self._style.colour, width=line_width)
|
||||
|
||||
elif self._style.decoration == TextDecoration.STRIKETHROUGH:
|
||||
# Draw strikethrough at about 50% of the height
|
||||
y_position = self._origin[1] + self._middle_y
|
||||
line_width = max(1, int(self._style.font_size / 15))
|
||||
|
||||
|
||||
# Determine end x-coordinate
|
||||
end_x = self._origin[0] + self._width
|
||||
|
||||
|
||||
# If next text also has strikethrough decoration, extend to connect them
|
||||
if (next_text is not None and
|
||||
if (next_text is not None and
|
||||
next_text.style.decoration == TextDecoration.STRIKETHROUGH and
|
||||
next_text.style.colour == self._style.colour):
|
||||
next_text.style.colour == self._style.colour):
|
||||
# Extend the strikethrough through the spacing to connect with next word
|
||||
end_x += spacing
|
||||
|
||||
|
||||
self._draw.line([(self._origin[0], y_position), (end_x, y_position)],
|
||||
fill=self._style.colour, width=line_width)
|
||||
fill=self._style.colour, width=line_width)
|
||||
|
||||
def render(self, next_text: Optional['Text'] = None, spacing: int = 0):
|
||||
"""
|
||||
Render the text to an image.
|
||||
|
||||
|
||||
Args:
|
||||
next_text: The next Text object in the line (if any)
|
||||
spacing: The spacing to the next text object
|
||||
@ -285,24 +296,42 @@ class Text(Renderable, Queriable):
|
||||
|
||||
# Draw the text background if specified
|
||||
if self._style.background and self._style.background[3] > 0: # If alpha > 0
|
||||
self._draw.rectangle([self._origin, self._origin+self._size], fill=self._style.background)
|
||||
self._draw.rectangle([self._origin, self._origin +
|
||||
self._size], fill=self._style.background)
|
||||
|
||||
# Draw the text using baseline as anchor point ("ls" = left-baseline)
|
||||
# This ensures the origin represents the baseline, not the top-left
|
||||
self._draw.text((self.origin[0], self._origin[1]), self._text, font=self._style.font, fill=self._style.colour, anchor="ls")
|
||||
self._draw.text(
|
||||
(self.origin[0],
|
||||
self._origin[1]),
|
||||
self._text,
|
||||
font=self._style.font,
|
||||
fill=self._style.colour,
|
||||
anchor="ls")
|
||||
|
||||
# Apply any text decorations with knowledge of next text
|
||||
self._apply_decoration(next_text, spacing)
|
||||
|
||||
|
||||
class Line(Box):
|
||||
"""
|
||||
A line of text consisting of Text objects with consistent spacing.
|
||||
Each Text represents a word or word fragment that can be rendered.
|
||||
"""
|
||||
|
||||
def __init__(self, spacing: Tuple[int, int], origin, size, draw: ImageDraw.Draw,font: Optional[Font] = None,
|
||||
callback=None, sheet=None, mode=None, halign=Alignment.CENTER,
|
||||
valign=Alignment.CENTER, previous = None,
|
||||
def __init__(self,
|
||||
spacing: Tuple[int,
|
||||
int],
|
||||
origin,
|
||||
size,
|
||||
draw: ImageDraw.Draw,
|
||||
font: Optional[Font] = None,
|
||||
callback=None,
|
||||
sheet=None,
|
||||
mode=None,
|
||||
halign=Alignment.CENTER,
|
||||
valign=Alignment.CENTER,
|
||||
previous=None,
|
||||
min_word_length_for_brute_force: int = 8,
|
||||
min_chars_before_hyphen: int = 2,
|
||||
min_chars_after_hyphen: int = 2):
|
||||
@ -329,16 +358,16 @@ class Line(Box):
|
||||
self._spacing = spacing # (min_spacing, max_spacing)
|
||||
self._font = font if font else Font() # Use default font if none provided
|
||||
self._current_width = 0 # Track the current width used
|
||||
self._words : List['Word'] = []
|
||||
self._words: List['Word'] = []
|
||||
self._previous = previous
|
||||
self._next = None
|
||||
ascent, descent = self._font.font.getmetrics()
|
||||
# Store baseline as offset from line origin (top), not absolute position
|
||||
self._baseline = ascent
|
||||
self._draw = draw
|
||||
self._spacing_render = (spacing[0] + spacing[1]) //2
|
||||
self._spacing_render = (spacing[0] + spacing[1]) // 2
|
||||
self._position_render = 0
|
||||
|
||||
|
||||
# Hyphenation configuration parameters
|
||||
self._min_word_length_for_brute_force = min_word_length_for_brute_force
|
||||
self._min_chars_before_hyphen = min_chars_before_hyphen
|
||||
@ -373,7 +402,10 @@ class Line(Box):
|
||||
"""Set the next line in sequence"""
|
||||
self._next = line
|
||||
|
||||
def add_word(self, word: 'Word', part:Optional[Text]=None) -> Tuple[bool, Optional['Text']]:
|
||||
def add_word(self,
|
||||
word: 'Word',
|
||||
part: Optional[Text] = None) -> Tuple[bool,
|
||||
Optional['Text']]:
|
||||
"""
|
||||
Add a word to this line using intelligent word fitting strategies.
|
||||
|
||||
@ -392,7 +424,8 @@ class Line(Box):
|
||||
self._words.append(word)
|
||||
part.add_line(self)
|
||||
|
||||
# Try to add the full word - create LinkText for LinkedWord, regular Text otherwise
|
||||
# Try to add the full word - create LinkText for LinkedWord, regular Text
|
||||
# otherwise
|
||||
if isinstance(word, LinkedWord):
|
||||
# Import here to avoid circular dependency
|
||||
from .functional import LinkText
|
||||
@ -407,14 +440,19 @@ class Line(Box):
|
||||
params=word.params,
|
||||
title=word.link_title
|
||||
)
|
||||
text = LinkText(link, word.text, word.style, self._draw, source=word, line=self)
|
||||
text = LinkText(
|
||||
link,
|
||||
word.text,
|
||||
word.style,
|
||||
self._draw,
|
||||
source=word,
|
||||
line=self)
|
||||
else:
|
||||
text = Text.from_word(word, self._draw)
|
||||
self._text_objects.append(text)
|
||||
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
|
||||
self._text_objects, self._size[0], self._spacing[0], self._spacing[1]
|
||||
)
|
||||
|
||||
self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
|
||||
|
||||
if not overflow:
|
||||
# Word fits! Add it completely
|
||||
self._words.append(word)
|
||||
@ -426,44 +464,53 @@ class Line(Box):
|
||||
|
||||
# Word doesn't fit, remove it and try hyphenation
|
||||
_ = self._text_objects.pop()
|
||||
|
||||
|
||||
# Step 1: Try pyphen hyphenation
|
||||
pyphen_splits = word.possible_hyphenation()
|
||||
valid_splits = []
|
||||
|
||||
|
||||
if pyphen_splits:
|
||||
# Create Text objects for each possible split and check if they fit
|
||||
for pair in pyphen_splits:
|
||||
first_part_text = pair[0] + "-"
|
||||
second_part_text = pair[1]
|
||||
|
||||
|
||||
# Validate minimum character requirements
|
||||
if len(pair[0]) < self._min_chars_before_hyphen:
|
||||
continue
|
||||
if len(pair[1]) < self._min_chars_after_hyphen:
|
||||
continue
|
||||
|
||||
|
||||
# Create Text objects
|
||||
first_text = Text(first_part_text, word.style, self._draw, line=self, source=word)
|
||||
second_text = Text(second_part_text, word.style, self._draw, line=self, source=word)
|
||||
|
||||
first_text = Text(
|
||||
first_part_text,
|
||||
word.style,
|
||||
self._draw,
|
||||
line=self,
|
||||
source=word)
|
||||
second_text = Text(
|
||||
second_part_text,
|
||||
word.style,
|
||||
self._draw,
|
||||
line=self,
|
||||
source=word)
|
||||
|
||||
# Check if first part fits
|
||||
self._text_objects.append(first_text)
|
||||
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
|
||||
self._text_objects, self._size[0], self._spacing[0], self._spacing[1]
|
||||
)
|
||||
self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
|
||||
_ = self._text_objects.pop()
|
||||
|
||||
|
||||
if not overflow:
|
||||
# This split fits! Add it to valid options
|
||||
valid_splits.append((first_text, second_text, spacing, position))
|
||||
|
||||
|
||||
# Step 2: If we have valid pyphen splits, choose the best one
|
||||
if valid_splits:
|
||||
# Select the split with the best (minimum) spacing
|
||||
best_split = min(valid_splits, key=lambda x: x[2])
|
||||
first_text, second_text, spacing, position = best_split
|
||||
|
||||
|
||||
# Apply the split
|
||||
self._text_objects.append(first_text)
|
||||
first_text.line = self
|
||||
@ -472,48 +519,58 @@ class Line(Box):
|
||||
self._position_render = position
|
||||
self._words.append(word)
|
||||
return True, second_text
|
||||
|
||||
|
||||
# Step 3: Try brute force hyphenation (only for long words)
|
||||
if len(word.text) >= self._min_word_length_for_brute_force:
|
||||
# Calculate available space for the word
|
||||
word_length = sum([text.width for text in self._text_objects])
|
||||
spacing_length = self._spacing[0] * max(0, len(self._text_objects) - 1)
|
||||
remaining = self._size[0] - word_length - spacing_length
|
||||
|
||||
|
||||
if remaining > 0:
|
||||
# Create a hyphenated version to measure
|
||||
test_text = Text(word.text + "-", word.style, self._draw)
|
||||
|
||||
|
||||
if test_text.width > 0:
|
||||
# Calculate what fraction of the hyphenated word fits
|
||||
fraction = remaining / test_text.width
|
||||
|
||||
|
||||
# Convert fraction to character position
|
||||
# We need at least min_chars_before_hyphen and leave at least min_chars_after_hyphen
|
||||
# We need at least min_chars_before_hyphen and leave at least
|
||||
# min_chars_after_hyphen
|
||||
max_split_pos = len(word.text) - self._min_chars_after_hyphen
|
||||
min_split_pos = self._min_chars_before_hyphen
|
||||
|
||||
|
||||
# Calculate ideal split position based on available space
|
||||
ideal_split = int(fraction * len(word.text))
|
||||
split_pos = max(min_split_pos, min(ideal_split, max_split_pos))
|
||||
|
||||
|
||||
# Ensure we meet minimum requirements
|
||||
if (split_pos >= self._min_chars_before_hyphen and
|
||||
len(word.text) - split_pos >= self._min_chars_after_hyphen):
|
||||
|
||||
if (split_pos >= self._min_chars_before_hyphen and
|
||||
len(word.text) - split_pos >= self._min_chars_after_hyphen):
|
||||
|
||||
# Create the split
|
||||
first_part_text = word.text[:split_pos] + "-"
|
||||
second_part_text = word.text[split_pos:]
|
||||
|
||||
first_text = Text(first_part_text, word.style, self._draw, line=self, source=word)
|
||||
second_text = Text(second_part_text, word.style, self._draw, line=self, source=word)
|
||||
|
||||
|
||||
first_text = Text(
|
||||
first_part_text,
|
||||
word.style,
|
||||
self._draw,
|
||||
line=self,
|
||||
source=word)
|
||||
second_text = Text(
|
||||
second_part_text,
|
||||
word.style,
|
||||
self._draw,
|
||||
line=self,
|
||||
source=word)
|
||||
|
||||
# Verify the first part actually fits
|
||||
self._text_objects.append(first_text)
|
||||
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
|
||||
self._text_objects, self._size[0], self._spacing[0], self._spacing[1]
|
||||
)
|
||||
|
||||
self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
|
||||
|
||||
if not overflow:
|
||||
# Brute force split works!
|
||||
first_text.line = self
|
||||
@ -526,7 +583,7 @@ class Line(Box):
|
||||
else:
|
||||
# Doesn't fit, remove it
|
||||
_ = self._text_objects.pop()
|
||||
|
||||
|
||||
# Step 4: Word cannot be hyphenated or split, move to next line
|
||||
return False, None
|
||||
|
||||
@ -540,8 +597,7 @@ class Line(Box):
|
||||
# Recalculate spacing and position for current text objects to ensure accuracy
|
||||
if len(self._text_objects) > 0:
|
||||
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
|
||||
self._text_objects, self._size[0], self._spacing[0], self._spacing[1]
|
||||
)
|
||||
self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
|
||||
self._spacing_render = spacing
|
||||
self._position_render = position
|
||||
|
||||
@ -553,13 +609,14 @@ class Line(Box):
|
||||
# Update text draw context to current draw context
|
||||
text._draw = self._draw
|
||||
text.set_origin(np.array([x_cursor, y_cursor]))
|
||||
|
||||
|
||||
# Determine next text object for continuous decoration
|
||||
next_text = self._text_objects[i + 1] if i + 1 < len(self._text_objects) else None
|
||||
|
||||
next_text = self._text_objects[i + 1] if i + \
|
||||
1 < len(self._text_objects) else None
|
||||
|
||||
# Render with next text information for continuous underline/strikethrough
|
||||
text.render(next_text, self._spacing_render)
|
||||
x_cursor += self._spacing_render + text.width # x-spacing + width of text object
|
||||
x_cursor += self._spacing_render + text.width # x-spacing + width of text object
|
||||
|
||||
def query_point(self, point: Tuple[int, int]) -> Optional['QueryResult']:
|
||||
"""
|
||||
@ -583,7 +640,8 @@ class Line(Box):
|
||||
size = text_obj.size
|
||||
|
||||
# Text origin is at baseline (anchor="ls"), so visual top is origin[1] - ascent
|
||||
# Bounds should be (x, visual_top, width, height) for proper highlighting
|
||||
# Bounds should be (x, visual_top, width, height) for proper
|
||||
# highlighting
|
||||
visual_top = int(origin[1] - text_obj._ascent)
|
||||
bounds = (
|
||||
int(origin[0]),
|
||||
@ -602,8 +660,9 @@ class Line(Box):
|
||||
bounds=bounds,
|
||||
text=text_obj._text,
|
||||
is_interactive=True,
|
||||
link_target=text_obj._link.location if hasattr(text_obj, '_link') else None
|
||||
)
|
||||
link_target=text_obj._link.location if hasattr(
|
||||
text_obj,
|
||||
'_link') else None)
|
||||
elif isinstance(text_obj, ButtonText):
|
||||
result = QueryResult(
|
||||
object=text_obj,
|
||||
@ -611,8 +670,9 @@ class Line(Box):
|
||||
bounds=bounds,
|
||||
text=text_obj._text,
|
||||
is_interactive=True,
|
||||
callback=text_obj._callback if hasattr(text_obj, '_callback') else None
|
||||
)
|
||||
callback=text_obj._callback if hasattr(
|
||||
text_obj,
|
||||
'_callback') else None)
|
||||
else:
|
||||
result = QueryResult(
|
||||
object=text_obj,
|
||||
|
||||
@ -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',
|
||||
]
|
||||
|
||||
@ -1,11 +1,9 @@
|
||||
from abc import ABC
|
||||
from typing import Optional, Tuple, List, TYPE_CHECKING, Any, Dict
|
||||
from typing import Optional, Tuple, TYPE_CHECKING, Any, Dict
|
||||
import numpy as np
|
||||
|
||||
from pyWebLayout.style.alignment import Alignment
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pyWebLayout.core.query import QueryResult
|
||||
from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration
|
||||
|
||||
|
||||
@ -14,57 +12,62 @@ class Renderable(ABC):
|
||||
Abstract base class for any object that can be rendered to an image.
|
||||
All renderable objects must implement the render method.
|
||||
"""
|
||||
|
||||
def render(self):
|
||||
"""
|
||||
Render the object to an image.
|
||||
|
||||
|
||||
Returns:
|
||||
PIL.Image: The rendered image
|
||||
"""
|
||||
pass
|
||||
|
||||
@property
|
||||
def origin(self):
|
||||
return self._origin
|
||||
|
||||
|
||||
class Interactable(ABC):
|
||||
"""
|
||||
Abstract base class for any object that can be interacted with.
|
||||
Interactable objects must have a callback that is executed when interacted with.
|
||||
"""
|
||||
|
||||
def __init__(self, callback=None):
|
||||
"""
|
||||
Initialize an interactable object.
|
||||
|
||||
|
||||
Args:
|
||||
callback: The function to call when this object is interacted with
|
||||
"""
|
||||
self._callback = callback
|
||||
|
||||
|
||||
def interact(self, point: np.generic):
|
||||
"""
|
||||
Handle interaction at the given point.
|
||||
|
||||
|
||||
Args:
|
||||
point: The coordinates of the interaction
|
||||
|
||||
|
||||
Returns:
|
||||
The result of calling the callback function with the point
|
||||
"""
|
||||
if self._callback is None:
|
||||
return None
|
||||
return self._callback(point)
|
||||
|
||||
|
||||
|
||||
class Layoutable(ABC):
|
||||
"""
|
||||
Abstract base class for any object that can be laid out.
|
||||
Layoutable objects must implement the layout method which arranges their contents.
|
||||
"""
|
||||
|
||||
def layout(self):
|
||||
"""
|
||||
Layout the object's contents.
|
||||
This method should be called before rendering to properly arrange the object's contents.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class Queriable(ABC):
|
||||
|
||||
@ -181,15 +184,15 @@ class FontRegistry:
|
||||
self._fonts: Dict[str, 'Font'] = {}
|
||||
|
||||
def get_or_create_font(self,
|
||||
font_path: Optional[str] = None,
|
||||
font_size: int = 16,
|
||||
colour: Tuple[int, int, int] = (0, 0, 0),
|
||||
weight: 'FontWeight' = None,
|
||||
style: 'FontStyle' = None,
|
||||
decoration: 'TextDecoration' = None,
|
||||
background: Optional[Tuple[int, int, int, int]] = None,
|
||||
language: str = "en_EN",
|
||||
min_hyphenation_width: Optional[int] = None) -> 'Font':
|
||||
font_path: Optional[str] = None,
|
||||
font_size: int = 16,
|
||||
colour: Tuple[int, int, int] = (0, 0, 0),
|
||||
weight: 'FontWeight' = None,
|
||||
style: 'FontStyle' = None,
|
||||
decoration: 'TextDecoration' = None,
|
||||
background: Optional[Tuple[int, int, int, int]] = None,
|
||||
language: str = "en_EN",
|
||||
min_hyphenation_width: Optional[int] = None) -> 'Font':
|
||||
"""
|
||||
Get or create a font with the specified properties.
|
||||
|
||||
@ -222,7 +225,11 @@ class FontRegistry:
|
||||
decoration = TextDecoration.NONE
|
||||
|
||||
# If we have a parent with font management, delegate to parent
|
||||
if hasattr(self, '_parent') and self._parent and hasattr(self._parent, 'get_or_create_font'):
|
||||
if hasattr(
|
||||
self,
|
||||
'_parent') and self._parent and hasattr(
|
||||
self._parent,
|
||||
'get_or_create_font'):
|
||||
return self._parent.get_or_create_font(
|
||||
font_path=font_path,
|
||||
font_size=font_size,
|
||||
@ -409,8 +416,8 @@ class ContainerAware:
|
||||
"""
|
||||
if not hasattr(container, required_method):
|
||||
raise AttributeError(
|
||||
f"Container {type(container).__name__} must have a '{required_method}' method"
|
||||
)
|
||||
f"Container {
|
||||
type(container).__name__} must have a '{required_method}' method")
|
||||
|
||||
@classmethod
|
||||
def _inherit_style(cls, container, style=None):
|
||||
|
||||
@ -8,7 +8,7 @@ and managing their callbacks. Supports multiple binding strategies:
|
||||
- Type-based batch operations
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional, Callable, Any
|
||||
from typing import Dict, List, Optional, Callable
|
||||
from pyWebLayout.core.base import Interactable
|
||||
|
||||
|
||||
@ -30,7 +30,7 @@ class CallbackRegistry:
|
||||
"""Initialize an empty callback registry."""
|
||||
self._by_reference: Dict[int, Interactable] = {} # id(obj) -> obj
|
||||
self._by_id: Dict[str, Interactable] = {} # HTML id or auto id -> obj
|
||||
self._by_type: Dict[str, List[Interactable]] = {} # type name -> [objs]
|
||||
self._by_type: Dict[str, List[Interactable]] = {} # type name -> [objs]
|
||||
self._auto_counter: int = 0
|
||||
|
||||
def register(self, obj: Interactable, html_id: Optional[str] = None) -> str:
|
||||
|
||||
@ -148,7 +148,8 @@ class HighlightManager:
|
||||
self.highlights.clear()
|
||||
self._save_highlights()
|
||||
|
||||
def get_highlights_for_page(self, page_bounds: Tuple[int, int, int, int]) -> List[Highlight]:
|
||||
def get_highlights_for_page(
|
||||
self, page_bounds: Tuple[int, int, int, int]) -> List[Highlight]:
|
||||
"""
|
||||
Get highlights that appear on a specific page.
|
||||
|
||||
@ -165,7 +166,7 @@ class HighlightManager:
|
||||
# Check if any highlight bounds overlap with page
|
||||
for hx, hy, hw, hh in highlight.bounds:
|
||||
if (hx < page_x + page_w and hx + hw > page_x and
|
||||
hy < page_y + page_h and hy + hh > page_y):
|
||||
hy < page_y + page_h and hy + hh > page_y):
|
||||
page_highlights.append(highlight)
|
||||
break
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -6,4 +6,3 @@ including HTML, EPUB, and other document formats.
|
||||
"""
|
||||
|
||||
# Readers
|
||||
from pyWebLayout.io.readers.epub_reader import EPUBReader
|
||||
|
||||
@ -8,13 +8,12 @@ to pyWebLayout's abstract document model.
|
||||
import os
|
||||
import zipfile
|
||||
import tempfile
|
||||
from typing import Dict, List, Optional, Any, Tuple, Callable
|
||||
from typing import Dict, List, Optional, Any, Callable
|
||||
import xml.etree.ElementTree as ET
|
||||
import re
|
||||
import urllib.parse
|
||||
from PIL import Image as PILImage, ImageOps
|
||||
|
||||
from pyWebLayout.abstract.document import Document, Book, Chapter, MetadataType
|
||||
from pyWebLayout.abstract.document import Book, Chapter, MetadataType
|
||||
from pyWebLayout.abstract.block import PageBreak
|
||||
from pyWebLayout.io.readers.html_extraction import parse_html_string
|
||||
|
||||
@ -33,38 +32,39 @@ def default_eink_processor(img: PILImage.Image) -> PILImage.Image:
|
||||
"""
|
||||
Process image for 4-bit e-ink display using PIL only.
|
||||
Applies histogram equalization and 4-bit quantization.
|
||||
|
||||
|
||||
Args:
|
||||
img: PIL Image to process
|
||||
|
||||
|
||||
Returns:
|
||||
Processed PIL Image in L mode (grayscale) with 4-bit quantization
|
||||
"""
|
||||
# Convert to grayscale if needed
|
||||
if img.mode != 'L':
|
||||
img = img.convert('L')
|
||||
|
||||
|
||||
# Apply histogram equalization for contrast enhancement
|
||||
img = ImageOps.equalize(img)
|
||||
|
||||
|
||||
# Quantize to 4-bit (16 grayscale levels: 0, 17, 34, ..., 255)
|
||||
img = img.point(lambda x: (x // 16) * 17)
|
||||
|
||||
|
||||
return img
|
||||
|
||||
|
||||
class EPUBReader:
|
||||
"""
|
||||
Reader for EPUB documents.
|
||||
|
||||
|
||||
This class extracts content from EPUB files and converts it to
|
||||
pyWebLayout's abstract document model.
|
||||
"""
|
||||
|
||||
def __init__(self, epub_path: str, image_processor: Optional[Callable[[PILImage.Image], PILImage.Image]] = default_eink_processor):
|
||||
|
||||
def __init__(self, epub_path: str, image_processor: Optional[Callable[[
|
||||
PILImage.Image], PILImage.Image]] = default_eink_processor):
|
||||
"""
|
||||
Initialize an EPUB reader.
|
||||
|
||||
|
||||
Args:
|
||||
epub_path: Path to the EPUB file
|
||||
image_processor: Optional function to process images for display optimization.
|
||||
@ -82,11 +82,11 @@ class EPUBReader:
|
||||
self.spine = []
|
||||
self.manifest = {}
|
||||
self.cover_id = None # ID of the cover image in manifest
|
||||
|
||||
|
||||
def read(self) -> Book:
|
||||
"""
|
||||
Read the EPUB file and convert it to a Book.
|
||||
|
||||
|
||||
Returns:
|
||||
Book: The parsed book
|
||||
"""
|
||||
@ -100,45 +100,47 @@ class EPUBReader:
|
||||
|
||||
# Add chapters to the book
|
||||
self._add_chapters()
|
||||
|
||||
|
||||
# Process images for e-ink display optimization
|
||||
self._process_content_images()
|
||||
|
||||
|
||||
return self.book
|
||||
|
||||
|
||||
finally:
|
||||
# Clean up temporary files
|
||||
if self.temp_dir:
|
||||
import shutil
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def _extract_epub(self):
|
||||
"""Extract the EPUB file to a temporary directory."""
|
||||
with zipfile.ZipFile(self.epub_path, 'r') as zip_ref:
|
||||
zip_ref.extractall(self.temp_dir)
|
||||
|
||||
|
||||
# Find the content directory (typically OEBPS or OPS)
|
||||
container_path = os.path.join(self.temp_dir, 'META-INF', 'container.xml')
|
||||
if os.path.exists(container_path):
|
||||
tree = ET.parse(container_path)
|
||||
root = tree.getroot()
|
||||
|
||||
|
||||
# Get the path to the package document (content.opf)
|
||||
for rootfile in root.findall('.//{urn:oasis:names:tc:opendocument:xmlns:container}rootfile'):
|
||||
for rootfile in root.findall(
|
||||
'.//{urn:oasis:names:tc:opendocument:xmlns:container}rootfile'):
|
||||
full_path = rootfile.get('full-path')
|
||||
if full_path:
|
||||
self.content_dir = os.path.dirname(os.path.join(self.temp_dir, full_path))
|
||||
self.content_dir = os.path.dirname(
|
||||
os.path.join(self.temp_dir, full_path))
|
||||
return
|
||||
|
||||
|
||||
# Fallback: look for common content directories
|
||||
for content_dir in ['OEBPS', 'OPS', 'Content']:
|
||||
if os.path.exists(os.path.join(self.temp_dir, content_dir)):
|
||||
self.content_dir = os.path.join(self.temp_dir, content_dir)
|
||||
return
|
||||
|
||||
|
||||
# If no content directory found, use the root
|
||||
self.content_dir = self.temp_dir
|
||||
|
||||
|
||||
def _parse_package_document(self):
|
||||
"""Parse the package document (content.opf)."""
|
||||
# Find the package document
|
||||
@ -150,27 +152,27 @@ class EPUBReader:
|
||||
break
|
||||
if opf_path:
|
||||
break
|
||||
|
||||
|
||||
if not opf_path:
|
||||
raise ValueError("No package document (.opf) found in EPUB")
|
||||
|
||||
|
||||
# Parse the package document
|
||||
tree = ET.parse(opf_path)
|
||||
root = tree.getroot()
|
||||
|
||||
|
||||
# Parse metadata
|
||||
self._parse_metadata(root)
|
||||
|
||||
|
||||
# Parse manifest
|
||||
self._parse_manifest(root)
|
||||
|
||||
|
||||
# Parse spine
|
||||
self._parse_spine(root)
|
||||
|
||||
|
||||
def _parse_metadata(self, root: ET.Element):
|
||||
"""
|
||||
Parse metadata from the package document.
|
||||
|
||||
|
||||
Args:
|
||||
root: Root element of the package document
|
||||
"""
|
||||
@ -178,14 +180,14 @@ class EPUBReader:
|
||||
metadata_elem = root.find('.//{{{0}}}metadata'.format(NAMESPACES['opf']))
|
||||
if metadata_elem is None:
|
||||
return
|
||||
|
||||
|
||||
# Parse DC metadata
|
||||
for elem in metadata_elem:
|
||||
if elem.tag.startswith('{{{0}}}'.format(NAMESPACES['dc'])):
|
||||
# Get the local name (without namespace)
|
||||
name = elem.tag.split('}', 1)[1]
|
||||
value = elem.text
|
||||
|
||||
|
||||
if name == 'title':
|
||||
self.metadata['title'] = value
|
||||
elif name == 'creator':
|
||||
@ -207,20 +209,20 @@ class EPUBReader:
|
||||
else:
|
||||
# Store other metadata
|
||||
self.metadata[name] = value
|
||||
|
||||
|
||||
# Parse meta elements for cover reference
|
||||
for meta in metadata_elem.findall('.//{{{0}}}meta'.format(NAMESPACES['opf'])):
|
||||
name = meta.get('name')
|
||||
content = meta.get('content')
|
||||
|
||||
|
||||
if name == 'cover' and content:
|
||||
# This is a reference to the cover image in the manifest
|
||||
self.cover_id = content
|
||||
|
||||
|
||||
def _parse_manifest(self, root: ET.Element):
|
||||
"""
|
||||
Parse manifest from the package document.
|
||||
|
||||
|
||||
Args:
|
||||
root: Root element of the package document
|
||||
"""
|
||||
@ -228,28 +230,28 @@ class EPUBReader:
|
||||
manifest_elem = root.find('.//{{{0}}}manifest'.format(NAMESPACES['opf']))
|
||||
if manifest_elem is None:
|
||||
return
|
||||
|
||||
|
||||
# Parse items
|
||||
for item in manifest_elem.findall('.//{{{0}}}item'.format(NAMESPACES['opf'])):
|
||||
id = item.get('id')
|
||||
href = item.get('href')
|
||||
media_type = item.get('media-type')
|
||||
|
||||
|
||||
if id and href:
|
||||
# Resolve relative path
|
||||
href = urllib.parse.unquote(href)
|
||||
path = os.path.normpath(os.path.join(self.content_dir, href))
|
||||
|
||||
|
||||
self.manifest[id] = {
|
||||
'href': href,
|
||||
'path': path,
|
||||
'media_type': media_type
|
||||
}
|
||||
|
||||
|
||||
def _parse_spine(self, root: ET.Element):
|
||||
"""
|
||||
Parse spine from the package document.
|
||||
|
||||
|
||||
Args:
|
||||
root: Root element of the package document
|
||||
"""
|
||||
@ -257,21 +259,25 @@ class EPUBReader:
|
||||
spine_elem = root.find('.//{{{0}}}spine'.format(NAMESPACES['opf']))
|
||||
if spine_elem is None:
|
||||
return
|
||||
|
||||
|
||||
# Get the toc attribute (NCX file ID)
|
||||
toc_id = spine_elem.get('toc')
|
||||
if toc_id and toc_id in self.manifest:
|
||||
self.toc_path = self.manifest[toc_id]['path']
|
||||
|
||||
|
||||
# Parse itemrefs
|
||||
for itemref in spine_elem.findall('.//{{{0}}}itemref'.format(NAMESPACES['opf'])):
|
||||
for itemref in spine_elem.findall(
|
||||
'.//{{{0}}}itemref'.format(NAMESPACES['opf'])):
|
||||
idref = itemref.get('idref')
|
||||
if idref and idref in self.manifest:
|
||||
self.spine.append(idref)
|
||||
|
||||
|
||||
def _parse_toc(self):
|
||||
"""Parse the table of contents."""
|
||||
if not hasattr(self, 'toc_path') or not self.toc_path or not os.path.exists(self.toc_path):
|
||||
if not hasattr(
|
||||
self,
|
||||
'toc_path') or not self.toc_path or not os.path.exists(
|
||||
self.toc_path):
|
||||
# Try to find the toc.ncx file
|
||||
for root, dirs, files in os.walk(self.content_dir):
|
||||
for file in files:
|
||||
@ -280,27 +286,30 @@ class EPUBReader:
|
||||
break
|
||||
if hasattr(self, 'toc_path') and self.toc_path:
|
||||
break
|
||||
|
||||
if not hasattr(self, 'toc_path') or not self.toc_path or not os.path.exists(self.toc_path):
|
||||
|
||||
if not hasattr(
|
||||
self,
|
||||
'toc_path') or not self.toc_path or not os.path.exists(
|
||||
self.toc_path):
|
||||
# No TOC found
|
||||
return
|
||||
|
||||
|
||||
# Parse the NCX file
|
||||
tree = ET.parse(self.toc_path)
|
||||
root = tree.getroot()
|
||||
|
||||
|
||||
# Parse navMap
|
||||
nav_map = root.find('.//{{{0}}}navMap'.format(NAMESPACES['ncx']))
|
||||
if nav_map is None:
|
||||
return
|
||||
|
||||
|
||||
# Parse navPoints
|
||||
self._parse_nav_points(nav_map, [])
|
||||
|
||||
|
||||
def _parse_nav_points(self, parent: ET.Element, path: List[Dict[str, Any]]):
|
||||
"""
|
||||
Recursively parse navPoints from the NCX file.
|
||||
|
||||
|
||||
Args:
|
||||
parent: Parent element containing navPoints
|
||||
path: Current path in the TOC hierarchy
|
||||
@ -309,16 +318,17 @@ class EPUBReader:
|
||||
# Get navPoint attributes
|
||||
id = nav_point.get('id')
|
||||
play_order = nav_point.get('playOrder')
|
||||
|
||||
|
||||
# Get navLabel
|
||||
nav_label = nav_point.find('.//{{{0}}}navLabel'.format(NAMESPACES['ncx']))
|
||||
text_elem = nav_label.find('.//{{{0}}}text'.format(NAMESPACES['ncx'])) if nav_label else None
|
||||
text_elem = nav_label.find(
|
||||
'.//{{{0}}}text'.format(NAMESPACES['ncx'])) if nav_label else None
|
||||
label = text_elem.text if text_elem is not None else ""
|
||||
|
||||
|
||||
# Get content
|
||||
content = nav_point.find('.//{{{0}}}content'.format(NAMESPACES['ncx']))
|
||||
src = content.get('src') if content is not None else ""
|
||||
|
||||
|
||||
# Create a TOC entry
|
||||
entry = {
|
||||
'id': id,
|
||||
@ -327,78 +337,83 @@ class EPUBReader:
|
||||
'play_order': play_order,
|
||||
'children': []
|
||||
}
|
||||
|
||||
|
||||
# Add to TOC
|
||||
if path:
|
||||
path[-1]['children'].append(entry)
|
||||
else:
|
||||
self.toc.append(entry)
|
||||
|
||||
|
||||
# Parse child navPoints
|
||||
self._parse_nav_points(nav_point, path + [entry])
|
||||
|
||||
|
||||
def _create_book(self):
|
||||
"""Create a Book object from the parsed metadata."""
|
||||
# Set book metadata
|
||||
if 'title' in self.metadata:
|
||||
self.book.set_title(self.metadata['title'])
|
||||
|
||||
|
||||
if 'creator' in self.metadata:
|
||||
self.book.set_metadata(MetadataType.AUTHOR, self.metadata['creator'])
|
||||
|
||||
|
||||
if 'language' in self.metadata:
|
||||
self.book.set_metadata(MetadataType.LANGUAGE, self.metadata['language'])
|
||||
|
||||
|
||||
if 'description' in self.metadata:
|
||||
self.book.set_metadata(MetadataType.DESCRIPTION, self.metadata['description'])
|
||||
|
||||
self.book.set_metadata(
|
||||
MetadataType.DESCRIPTION,
|
||||
self.metadata['description'])
|
||||
|
||||
if 'subjects' in self.metadata:
|
||||
self.book.set_metadata(MetadataType.KEYWORDS, ', '.join(self.metadata['subjects']))
|
||||
|
||||
self.book.set_metadata(
|
||||
MetadataType.KEYWORDS, ', '.join(
|
||||
self.metadata['subjects']))
|
||||
|
||||
if 'date' in self.metadata:
|
||||
self.book.set_metadata(MetadataType.PUBLICATION_DATE, self.metadata['date'])
|
||||
|
||||
|
||||
if 'identifier' in self.metadata:
|
||||
self.book.set_metadata(MetadataType.IDENTIFIER, self.metadata['identifier'])
|
||||
|
||||
|
||||
if 'publisher' in self.metadata:
|
||||
self.book.set_metadata(MetadataType.PUBLISHER, self.metadata['publisher'])
|
||||
|
||||
|
||||
def _add_cover_chapter(self):
|
||||
"""Add a cover chapter if a cover image is available."""
|
||||
if not self.cover_id or self.cover_id not in self.manifest:
|
||||
return
|
||||
|
||||
|
||||
# Get the cover image path from the manifest
|
||||
cover_item = self.manifest[self.cover_id]
|
||||
cover_path = cover_item['path']
|
||||
|
||||
|
||||
# Check if the file exists
|
||||
if not os.path.exists(cover_path):
|
||||
print(f"Warning: Cover image file not found: {cover_path}")
|
||||
return
|
||||
|
||||
|
||||
# Create a cover chapter
|
||||
cover_chapter = self.book.create_chapter("Cover", 0)
|
||||
|
||||
|
||||
try:
|
||||
# Create an Image block for the cover
|
||||
from pyWebLayout.abstract.block import Image as AbstractImage
|
||||
from PIL import Image as PILImage
|
||||
import io
|
||||
|
||||
|
||||
# Load the image into memory before the temp directory is cleaned up
|
||||
# We need to fully copy the image data to ensure it persists after temp cleanup
|
||||
# We need to fully copy the image data to ensure it persists after temp
|
||||
# cleanup
|
||||
with open(cover_path, 'rb') as f:
|
||||
image_bytes = f.read()
|
||||
|
||||
|
||||
# Create PIL image from bytes in memory
|
||||
pil_image = PILImage.open(io.BytesIO(image_bytes))
|
||||
pil_image.load() # Force loading into memory
|
||||
|
||||
|
||||
# Create a copy to ensure all data is in memory
|
||||
pil_image = pil_image.copy()
|
||||
|
||||
|
||||
# Apply image processing if enabled
|
||||
if self.image_processor:
|
||||
try:
|
||||
@ -406,20 +421,21 @@ class EPUBReader:
|
||||
except Exception as e:
|
||||
print(f"Warning: Image processing failed for cover: {str(e)}")
|
||||
# Continue with unprocessed image
|
||||
|
||||
|
||||
# Create an AbstractImage block with the cover image path
|
||||
cover_image = AbstractImage(source=cover_path, alt_text="Cover Image")
|
||||
|
||||
|
||||
# Set dimensions from the loaded image
|
||||
cover_image._width = pil_image.width
|
||||
cover_image._height = pil_image.height
|
||||
|
||||
# Store the loaded PIL image in the abstract image so it persists after temp cleanup
|
||||
|
||||
# Store the loaded PIL image in the abstract image so it persists after
|
||||
# temp cleanup
|
||||
cover_image._loaded_image = pil_image
|
||||
|
||||
|
||||
# Add the image to the cover chapter
|
||||
cover_chapter.add_block(cover_image)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error creating cover chapter: {str(e)}")
|
||||
import traceback
|
||||
@ -427,16 +443,16 @@ class EPUBReader:
|
||||
# If we can't create the cover image, remove the chapter
|
||||
if hasattr(self.book, 'chapters') and cover_chapter in self.book.chapters:
|
||||
self.book.chapters.remove(cover_chapter)
|
||||
|
||||
|
||||
def _process_chapter_images(self, chapter: Chapter):
|
||||
"""
|
||||
Process images in a single chapter.
|
||||
|
||||
|
||||
Args:
|
||||
chapter: The chapter containing images to process
|
||||
"""
|
||||
from pyWebLayout.abstract.block import Image as AbstractImage
|
||||
|
||||
|
||||
for block in chapter.blocks:
|
||||
if isinstance(block, AbstractImage):
|
||||
# Only process if image has been loaded and processor is enabled
|
||||
@ -444,25 +460,28 @@ class EPUBReader:
|
||||
try:
|
||||
block._loaded_image = self.image_processor(block._loaded_image)
|
||||
except Exception as e:
|
||||
print(f"Warning: Image processing failed for image '{block.alt_text}': {str(e)}")
|
||||
print(
|
||||
f"Warning: Image processing failed for image '{
|
||||
block.alt_text}': {
|
||||
str(e)}")
|
||||
# Continue with unprocessed image
|
||||
|
||||
|
||||
def _process_content_images(self):
|
||||
"""Apply image processing to all images in chapters."""
|
||||
if not self.image_processor:
|
||||
return
|
||||
|
||||
|
||||
for chapter in self.book.chapters:
|
||||
self._process_chapter_images(chapter)
|
||||
|
||||
|
||||
def _add_chapters(self):
|
||||
"""Add chapters to the book based on the spine and TOC."""
|
||||
# Add cover chapter first if available
|
||||
self._add_cover_chapter()
|
||||
|
||||
|
||||
# Create a mapping from src to TOC entry
|
||||
toc_map = {}
|
||||
|
||||
|
||||
def add_to_toc_map(entries):
|
||||
for entry in entries:
|
||||
if entry['src']:
|
||||
@ -470,58 +489,58 @@ class EPUBReader:
|
||||
src_parts = entry['src'].split('#', 1)
|
||||
path = src_parts[0]
|
||||
toc_map[path] = entry
|
||||
|
||||
|
||||
# Process children
|
||||
if entry['children']:
|
||||
add_to_toc_map(entry['children'])
|
||||
|
||||
|
||||
add_to_toc_map(self.toc)
|
||||
|
||||
|
||||
# Process spine items
|
||||
# Start from chapter_index = 1 if cover was added, otherwise 0
|
||||
chapter_index = 1 if (self.cover_id and self.cover_id in self.manifest) else 0
|
||||
for i, idref in enumerate(self.spine):
|
||||
if idref not in self.manifest:
|
||||
continue
|
||||
|
||||
|
||||
item = self.manifest[idref]
|
||||
path = item['path']
|
||||
href = item['href']
|
||||
|
||||
|
||||
# Skip navigation files
|
||||
if (idref == 'nav' or
|
||||
item.get('media_type') == 'application/xhtml+xml' and
|
||||
('nav' in href.lower() or 'toc' in href.lower())):
|
||||
if (idref == 'nav' or
|
||||
item.get('media_type') == 'application/xhtml+xml' and
|
||||
('nav' in href.lower() or 'toc' in href.lower())):
|
||||
continue
|
||||
|
||||
|
||||
# Check if this item is in the TOC
|
||||
chapter_title = None
|
||||
if href in toc_map:
|
||||
chapter_title = toc_map[href]['label']
|
||||
|
||||
|
||||
# Create a chapter
|
||||
chapter_index += 1
|
||||
chapter = self.book.create_chapter(chapter_title, chapter_index)
|
||||
|
||||
|
||||
# Parse the HTML content
|
||||
try:
|
||||
# Read the HTML file
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
html = f.read()
|
||||
|
||||
|
||||
# Parse HTML and add blocks to chapter
|
||||
blocks = parse_html_string(html, document=self.book)
|
||||
|
||||
|
||||
# Copy blocks to the chapter
|
||||
for block in blocks:
|
||||
chapter.add_block(block)
|
||||
|
||||
|
||||
# Add a PageBreak after the chapter to ensure next chapter starts on new page
|
||||
# This helps maintain chapter boundaries during pagination
|
||||
chapter.add_block(PageBreak())
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error parsing chapter {i+1}: {str(e)}")
|
||||
print(f"Error parsing chapter {i + 1}: {str(e)}")
|
||||
# Add an error message block
|
||||
from pyWebLayout.abstract.block import Paragraph
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
@ -529,7 +548,11 @@ class EPUBReader:
|
||||
error_para = Paragraph()
|
||||
# Create a default font style for the error message
|
||||
default_font = Font()
|
||||
error_para.add_word(Word(f"Error loading chapter: {str(e)}", default_font))
|
||||
error_para.add_word(
|
||||
Word(
|
||||
f"Error loading chapter: {
|
||||
str(e)}",
|
||||
default_font))
|
||||
chapter.add_block(error_para)
|
||||
# Still add PageBreak even after error
|
||||
chapter.add_block(PageBreak())
|
||||
@ -538,10 +561,10 @@ class EPUBReader:
|
||||
def read_epub(epub_path: str) -> Book:
|
||||
"""
|
||||
Read an EPUB file and convert it to a Book.
|
||||
|
||||
|
||||
Args:
|
||||
epub_path: Path to the EPUB file
|
||||
|
||||
|
||||
Returns:
|
||||
Book: The parsed book
|
||||
"""
|
||||
|
||||
@ -6,10 +6,9 @@ used by pyWebLayout, including paragraphs, headings, lists, tables, and inline f
|
||||
Each handler function has a robust signature that handles style hints, CSS classes, and attributes.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import List, Dict, Any, Optional, Union, Callable, Tuple, NamedTuple
|
||||
from bs4 import BeautifulSoup, Tag, NavigableString
|
||||
from pyWebLayout.abstract.inline import Word, FormattedSpan
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.abstract.block import (
|
||||
Block,
|
||||
Paragraph,
|
||||
@ -27,8 +26,6 @@ from pyWebLayout.abstract.block import (
|
||||
Image,
|
||||
)
|
||||
from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration
|
||||
from pyWebLayout.style.abstract_style import AbstractStyle, FontFamily, FontSize
|
||||
from pyWebLayout.style import Alignment as TextAlign
|
||||
|
||||
|
||||
class StyleContext(NamedTuple):
|
||||
@ -72,7 +69,9 @@ class StyleContext(NamedTuple):
|
||||
return self._replace(parent_elements=self.parent_elements + [element_name])
|
||||
|
||||
|
||||
def create_base_context(base_font: Optional[Font] = None, document=None) -> StyleContext:
|
||||
def create_base_context(
|
||||
base_font: Optional[Font] = None,
|
||||
document=None) -> StyleContext:
|
||||
"""
|
||||
Create a base style context with default values.
|
||||
|
||||
@ -130,7 +129,8 @@ def apply_element_styling(context: StyleContext, element: Tag) -> StyleContext:
|
||||
new_context = new_context.with_css_styles(css_styles)
|
||||
|
||||
# Apply element-specific default styles
|
||||
font = apply_element_font_styles(new_context.font, tag_name, css_styles, new_context)
|
||||
font = apply_element_font_styles(
|
||||
new_context.font, tag_name, css_styles, new_context)
|
||||
new_context = new_context.with_font(font)
|
||||
|
||||
# Apply background from styles
|
||||
@ -158,9 +158,11 @@ def parse_inline_styles(style_text: str) -> Dict[str, str]:
|
||||
return styles
|
||||
|
||||
|
||||
def apply_element_font_styles(
|
||||
font: Font, tag_name: str, css_styles: Dict[str, str], context: Optional[StyleContext] = None
|
||||
) -> Font:
|
||||
def apply_element_font_styles(font: Font,
|
||||
tag_name: str,
|
||||
css_styles: Dict[str,
|
||||
str],
|
||||
context: Optional[StyleContext] = None) -> Font:
|
||||
"""
|
||||
Apply font styling based on HTML element and CSS styles.
|
||||
Uses document's font registry when available to avoid creating duplicate fonts.
|
||||
@ -273,17 +275,19 @@ def apply_element_font_styles(
|
||||
pass
|
||||
|
||||
# Use document's style registry if available to avoid creating duplicate styles
|
||||
if context and context.document and hasattr(context.document, 'get_or_create_style'):
|
||||
if context and context.document and hasattr(
|
||||
context.document, 'get_or_create_style'):
|
||||
# Create an abstract style first
|
||||
from pyWebLayout.style.abstract_style import FontFamily, FontSize
|
||||
|
||||
|
||||
# Map font properties to abstract style properties
|
||||
font_family = FontFamily.SERIF # Default - could be enhanced to detect from font_path
|
||||
if font_size:
|
||||
font_size_value = font_size if isinstance(font_size, int) else FontSize.MEDIUM
|
||||
font_size_value = font_size if isinstance(
|
||||
font_size, int) else FontSize.MEDIUM
|
||||
else:
|
||||
font_size_value = FontSize.MEDIUM
|
||||
|
||||
|
||||
# Create abstract style and register it
|
||||
style_id, abstract_style = context.document.get_or_create_style(
|
||||
font_family=font_family,
|
||||
@ -294,7 +298,7 @@ def apply_element_font_styles(
|
||||
color=colour,
|
||||
language=language
|
||||
)
|
||||
|
||||
|
||||
# Get the concrete font for this style
|
||||
return context.document.get_font_for_style(abstract_style)
|
||||
elif context and context.document and hasattr(context.document, 'get_or_create_font'):
|
||||
@ -359,7 +363,7 @@ def extract_text_content(element: Tag, context: StyleContext) -> List[Word]:
|
||||
"""
|
||||
from pyWebLayout.abstract.inline import LinkedWord
|
||||
from pyWebLayout.abstract.functional import LinkType
|
||||
|
||||
|
||||
words = []
|
||||
|
||||
for child in element.children:
|
||||
@ -385,14 +389,14 @@ def extract_text_content(element: Tag, context: StyleContext) -> List[Word]:
|
||||
link_type = LinkType.API
|
||||
else:
|
||||
link_type = LinkType.INTERNAL
|
||||
|
||||
|
||||
# Apply link styling
|
||||
child_context = apply_element_styling(context, child)
|
||||
|
||||
|
||||
# Extract text and create LinkedWord for each word
|
||||
link_text = child.get_text(strip=True)
|
||||
title = child.get('title', '')
|
||||
|
||||
|
||||
for word_text in link_text.split():
|
||||
if word_text:
|
||||
linked_word = LinkedWord(
|
||||
@ -409,7 +413,7 @@ def extract_text_content(element: Tag, context: StyleContext) -> List[Word]:
|
||||
child_context = apply_element_styling(context, child)
|
||||
child_words = extract_text_content(child, child_context)
|
||||
words.extend(child_words)
|
||||
|
||||
|
||||
# Process other inline elements
|
||||
elif child.name.lower() in [
|
||||
"span",
|
||||
@ -435,7 +439,8 @@ def extract_text_content(element: Tag, context: StyleContext) -> List[Word]:
|
||||
child_words = extract_text_content(child, child_context)
|
||||
words.extend(child_words)
|
||||
else:
|
||||
# Block element - shouldn't happen in well-formed HTML but handle gracefully
|
||||
# Block element - shouldn't happen in well-formed HTML but handle
|
||||
# gracefully
|
||||
child_context = apply_element_styling(context, child)
|
||||
child_result = process_element(child, child_context)
|
||||
if isinstance(child_result, list):
|
||||
@ -469,7 +474,8 @@ def process_element(
|
||||
|
||||
|
||||
# Handler function signatures:
|
||||
# All handlers receive (element: Tag, context: StyleContext) -> Union[Block, List[Block], None]
|
||||
# All handlers receive (element: Tag, context: StyleContext) ->
|
||||
# Union[Block, List[Block], None]
|
||||
|
||||
|
||||
def paragraph_handler(element: Tag, context: StyleContext) -> Paragraph:
|
||||
|
||||
@ -5,16 +5,22 @@ import numpy as np
|
||||
|
||||
from pyWebLayout.concrete import Page, Line, Text
|
||||
from pyWebLayout.concrete.image import RenderableImage
|
||||
from pyWebLayout.concrete.functional import LinkText, ButtonText, FormFieldText
|
||||
from pyWebLayout.concrete.functional import ButtonText, FormFieldText
|
||||
from pyWebLayout.concrete.table import TableRenderer, TableStyle
|
||||
from pyWebLayout.abstract import Paragraph, Word, Link
|
||||
from pyWebLayout.abstract import Paragraph, Word
|
||||
from pyWebLayout.abstract.block import Image as AbstractImage, PageBreak, Table
|
||||
from pyWebLayout.abstract.functional import Button, Form, FormField
|
||||
from pyWebLayout.abstract.inline import LinkedWord
|
||||
from pyWebLayout.style.concrete_style import ConcreteStyleRegistry, RenderingContext, StyleResolver
|
||||
from pyWebLayout.style import Font, Alignment
|
||||
|
||||
def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pretext: Optional[Text] = None, alignment_override: Optional['Alignment'] = None) -> Tuple[bool, Optional[int], Optional[Text]]:
|
||||
|
||||
def paragraph_layouter(paragraph: Paragraph,
|
||||
page: Page,
|
||||
start_word: int = 0,
|
||||
pretext: Optional[Text] = None,
|
||||
alignment_override: Optional['Alignment'] = None) -> Tuple[bool,
|
||||
Optional[int],
|
||||
Optional[Text]]:
|
||||
"""
|
||||
Layout a paragraph of text within a given page.
|
||||
|
||||
@ -44,7 +50,7 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
|
||||
# paragraph.style is already a Font object (concrete), not AbstractStyle
|
||||
# We need to get word spacing constraints from the Font's abstract style if available
|
||||
# For now, use reasonable defaults based on font size
|
||||
|
||||
|
||||
if isinstance(paragraph.style, Font):
|
||||
# paragraph.style is already a Font (concrete style)
|
||||
font = paragraph.style
|
||||
@ -63,7 +69,7 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
|
||||
base_font_size = 16
|
||||
else:
|
||||
base_font_size = int(paragraph.style.font_size)
|
||||
|
||||
|
||||
rendering_context = RenderingContext(base_font_size=base_font_size)
|
||||
style_resolver = StyleResolver(rendering_context)
|
||||
style_registry = ConcreteStyleRegistry(style_resolver)
|
||||
@ -76,7 +82,11 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
|
||||
text_align = concrete_style.text_align
|
||||
|
||||
# Apply page-level word spacing override if specified
|
||||
if hasattr(page.style, 'word_spacing') and isinstance(page.style.word_spacing, int) and page.style.word_spacing > 0:
|
||||
if hasattr(
|
||||
page.style,
|
||||
'word_spacing') and isinstance(
|
||||
page.style.word_spacing,
|
||||
int) and page.style.word_spacing > 0:
|
||||
# Add the page-level word spacing to both min and max constraints
|
||||
min_ws, max_ws = word_spacing_constraints
|
||||
word_spacing_constraints = (
|
||||
@ -87,7 +97,7 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
|
||||
# Apply alignment override if provided
|
||||
if alignment_override is not None:
|
||||
text_align = alignment_override
|
||||
|
||||
|
||||
# Cap font size to page maximum if needed
|
||||
if font.font_size > page.style.max_font_size:
|
||||
font = Font(
|
||||
@ -99,7 +109,7 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
|
||||
decoration=font.decoration,
|
||||
background=font.background
|
||||
)
|
||||
|
||||
|
||||
# Calculate baseline-to-baseline spacing: font size + additional line spacing
|
||||
# This is the vertical distance between baselines of consecutive lines
|
||||
# Formula: baseline_spacing = font_size + line_spacing (absolute pixels)
|
||||
@ -108,18 +118,20 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
|
||||
if not isinstance(line_spacing_value, int):
|
||||
line_spacing_value = 5
|
||||
baseline_spacing = font.font_size + line_spacing_value
|
||||
|
||||
|
||||
# Get font metrics for boundary checking
|
||||
ascent, descent = font.font.getmetrics()
|
||||
|
||||
def create_new_line(word: Optional[Union[Word, Text]] = None, is_first_line: bool = False) -> Optional[Line]:
|
||||
def create_new_line(word: Optional[Union[Word, Text]] = None,
|
||||
is_first_line: bool = False) -> Optional[Line]:
|
||||
"""Helper function to create a new line, returns None if page is full."""
|
||||
# Check if this line's baseline and descenders would fit on the page
|
||||
if not page.can_fit_line(baseline_spacing, ascent, descent):
|
||||
return None
|
||||
|
||||
# For the first line, position it so text starts at the top boundary
|
||||
# For subsequent lines, use current y_offset which tracks baseline-to-baseline spacing
|
||||
# For subsequent lines, use current y_offset which tracks
|
||||
# baseline-to-baseline spacing
|
||||
if is_first_line:
|
||||
# Position line origin so that baseline (origin + ascent) is close to top
|
||||
# We want minimal space above the text, so origin should be at boundary
|
||||
@ -131,9 +143,9 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
|
||||
# Create a temporary Text object to calculate word width
|
||||
if word:
|
||||
temp_text = Text.from_word(word, page.draw)
|
||||
word_width = temp_text.width
|
||||
temp_text.width
|
||||
else:
|
||||
word_width = 0
|
||||
pass
|
||||
|
||||
return Line(
|
||||
spacing=word_spacing_constraints,
|
||||
@ -163,7 +175,7 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
|
||||
# but we may want to create LinkText for LinkedWord instances in future
|
||||
# For now, the abstract layer (LinkedWord) carries the link info,
|
||||
# and the concrete layer (LinkText) would be created during rendering
|
||||
|
||||
|
||||
success, overflow_text = current_line.add_word(word, current_pretext)
|
||||
|
||||
if success:
|
||||
@ -195,7 +207,19 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
|
||||
# Word is too wide for the line, we need to hyphenate it
|
||||
if len(word.text) >= 6:
|
||||
# Try to hyphenate the word
|
||||
splits = [(Text(pair[0], word.style, page.draw, line=current_line, source=word), Text(pair[1], word.style, page.draw, line=current_line, source=word)) for pair in word.possible_hyphenation()]
|
||||
splits = [
|
||||
(Text(
|
||||
pair[0],
|
||||
word.style,
|
||||
page.draw,
|
||||
line=current_line,
|
||||
source=word),
|
||||
Text(
|
||||
pair[1],
|
||||
word.style,
|
||||
page.draw,
|
||||
line=current_line,
|
||||
source=word)) for pair in word.possible_hyphenation()]
|
||||
if len(splits) > 0:
|
||||
# Use the first hyphenation point
|
||||
first_part, second_part = splits[0]
|
||||
@ -230,15 +254,15 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
|
||||
def pagebreak_layouter(page_break: PageBreak, page: Page) -> bool:
|
||||
"""
|
||||
Handle a page break element.
|
||||
|
||||
|
||||
A page break signals that all subsequent content should start on a new page.
|
||||
This function always returns False to indicate that the current page is complete
|
||||
and a new page should be created for subsequent content.
|
||||
|
||||
|
||||
Args:
|
||||
page_break: The PageBreak block
|
||||
page: The current page (not used, but kept for consistency)
|
||||
|
||||
|
||||
Returns:
|
||||
bool: Always False to force creation of a new page
|
||||
"""
|
||||
@ -246,48 +270,49 @@ def pagebreak_layouter(page_break: PageBreak, page: Page) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def image_layouter(image: AbstractImage, page: Page, max_width: Optional[int] = None,
|
||||
def image_layouter(image: AbstractImage, page: Page, max_width: Optional[int] = None,
|
||||
max_height: Optional[int] = None) -> bool:
|
||||
"""
|
||||
Layout an image within a given page.
|
||||
|
||||
|
||||
This function places an image on the page, respecting size constraints
|
||||
and available space. Images are centered horizontally by default.
|
||||
|
||||
|
||||
Args:
|
||||
image: The abstract Image object to layout
|
||||
page: The page to layout the image on
|
||||
max_width: Maximum width constraint (defaults to page available width)
|
||||
max_height: Maximum height constraint (defaults to remaining page height)
|
||||
|
||||
|
||||
Returns:
|
||||
bool: True if image was successfully laid out, False if page ran out of space
|
||||
"""
|
||||
# Use page available width if max_width not specified
|
||||
if max_width is None:
|
||||
max_width = page.available_width
|
||||
|
||||
|
||||
# Calculate available height on page
|
||||
available_height = page.size[1] - page._current_y_offset - page.border_size
|
||||
if max_height is None:
|
||||
max_height = available_height
|
||||
else:
|
||||
max_height = min(max_height, available_height)
|
||||
|
||||
|
||||
# Calculate scaled dimensions
|
||||
scaled_width, scaled_height = image.calculate_scaled_dimensions(max_width, max_height)
|
||||
|
||||
scaled_width, scaled_height = image.calculate_scaled_dimensions(
|
||||
max_width, max_height)
|
||||
|
||||
# Check if image fits on current page
|
||||
if scaled_height is None or scaled_height > available_height:
|
||||
return False
|
||||
|
||||
|
||||
# Create renderable image
|
||||
x_offset = page.border_size
|
||||
y_offset = page._current_y_offset
|
||||
|
||||
|
||||
# Access page.draw to ensure canvas is initialized
|
||||
_ = page.draw
|
||||
|
||||
|
||||
renderable_image = RenderableImage(
|
||||
image=image,
|
||||
canvas=page._canvas,
|
||||
@ -298,14 +323,17 @@ def image_layouter(image: AbstractImage, page: Page, max_width: Optional[int] =
|
||||
halign=Alignment.CENTER,
|
||||
valign=Alignment.TOP
|
||||
)
|
||||
|
||||
|
||||
# Add to page
|
||||
page.add_child(renderable_image)
|
||||
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def table_layouter(table: Table, page: Page, style: Optional[TableStyle] = None) -> bool:
|
||||
def table_layouter(
|
||||
table: Table,
|
||||
page: Page,
|
||||
style: Optional[TableStyle] = None) -> bool:
|
||||
"""
|
||||
Layout a table within a given page.
|
||||
|
||||
@ -356,8 +384,17 @@ def table_layouter(table: Table, page: Page, style: Optional[TableStyle] = None)
|
||||
return True
|
||||
|
||||
|
||||
def button_layouter(button: Button, page: Page, font: Optional[Font] = None,
|
||||
padding: Tuple[int, int, int, int] = (4, 8, 4, 8)) -> Tuple[bool, str]:
|
||||
def button_layouter(button: Button,
|
||||
page: Page,
|
||||
font: Optional[Font] = None,
|
||||
padding: Tuple[int,
|
||||
int,
|
||||
int,
|
||||
int] = (4,
|
||||
8,
|
||||
4,
|
||||
8)) -> Tuple[bool,
|
||||
str]:
|
||||
"""
|
||||
Layout a button within a given page and register it for callback binding.
|
||||
|
||||
@ -510,17 +547,17 @@ def form_layouter(form: Form, page: Page, font: Optional[Font] = None,
|
||||
class DocumentLayouter:
|
||||
"""
|
||||
Document layouter that orchestrates layout of various abstract elements.
|
||||
|
||||
|
||||
Delegates to specialized layouters for different content types:
|
||||
- paragraph_layouter for text paragraphs
|
||||
- image_layouter for images
|
||||
- table_layouter for tables
|
||||
|
||||
|
||||
This class acts as a coordinator, managing the overall document flow
|
||||
and page context while delegating specific layout tasks to specialized
|
||||
layouter functions.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, page: Page):
|
||||
"""
|
||||
Initialize the document layouter with a page.
|
||||
@ -538,24 +575,28 @@ class DocumentLayouter:
|
||||
context = RenderingContext()
|
||||
style_resolver = StyleResolver(context)
|
||||
self.style_registry = ConcreteStyleRegistry(style_resolver)
|
||||
|
||||
def layout_paragraph(self, paragraph: Paragraph, start_word: int = 0,
|
||||
pretext: Optional[Text] = None) -> Tuple[bool, Optional[int], Optional[Text]]:
|
||||
|
||||
def layout_paragraph(self,
|
||||
paragraph: Paragraph,
|
||||
start_word: int = 0,
|
||||
pretext: Optional[Text] = None) -> Tuple[bool,
|
||||
Optional[int],
|
||||
Optional[Text]]:
|
||||
"""
|
||||
Layout a paragraph using the paragraph_layouter.
|
||||
|
||||
|
||||
Args:
|
||||
paragraph: The paragraph to layout
|
||||
start_word: Index of the first word to process (for continuation)
|
||||
pretext: Optional pretext from a previous hyphenated word
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (success, failed_word_index, remaining_pretext)
|
||||
"""
|
||||
return paragraph_layouter(paragraph, self.page, start_word, pretext)
|
||||
|
||||
|
||||
def layout_image(self, image: AbstractImage, max_width: Optional[int] = None,
|
||||
max_height: Optional[int] = None) -> bool:
|
||||
max_height: Optional[int] = None) -> bool:
|
||||
"""
|
||||
Layout an image using the image_layouter.
|
||||
|
||||
@ -582,8 +623,17 @@ class DocumentLayouter:
|
||||
"""
|
||||
return table_layouter(table, self.page, style)
|
||||
|
||||
def layout_button(self, button: Button, font: Optional[Font] = None,
|
||||
padding: Tuple[int, int, int, int] = (4, 8, 4, 8)) -> Tuple[bool, str]:
|
||||
def layout_button(self,
|
||||
button: Button,
|
||||
font: Optional[Font] = None,
|
||||
padding: Tuple[int,
|
||||
int,
|
||||
int,
|
||||
int] = (4,
|
||||
8,
|
||||
4,
|
||||
8)) -> Tuple[bool,
|
||||
str]:
|
||||
"""
|
||||
Layout a button using the button_layouter.
|
||||
|
||||
@ -612,7 +662,8 @@ class DocumentLayouter:
|
||||
"""
|
||||
return form_layouter(form, self.page, font, field_spacing)
|
||||
|
||||
def layout_document(self, elements: List[Union[Paragraph, AbstractImage, Table, Button, Form]]) -> bool:
|
||||
def layout_document(
|
||||
self, elements: List[Union[Paragraph, AbstractImage, Table, Button, Form]]) -> bool:
|
||||
"""
|
||||
Layout a list of abstract elements (paragraphs, images, tables, buttons, and forms).
|
||||
|
||||
|
||||
@ -13,18 +13,12 @@ with features like:
|
||||
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass, asdict
|
||||
from typing import List, Dict, Tuple, Optional, Union, Generator, Any
|
||||
from enum import Enum
|
||||
import json
|
||||
import multiprocessing
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
import threading
|
||||
import time
|
||||
from typing import List, Dict, Tuple, Optional, Any
|
||||
|
||||
from pyWebLayout.abstract.block import Block, Paragraph, Heading, HeadingLevel, Table, HList
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.concrete.text import Line, Text
|
||||
from pyWebLayout.concrete.text import Text
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from pyWebLayout.style import Font
|
||||
from pyWebLayout.layout.document_layouter import paragraph_layouter
|
||||
@ -38,32 +32,33 @@ class RenderingPosition:
|
||||
"""
|
||||
chapter_index: int = 0 # Which chapter (based on headings)
|
||||
block_index: int = 0 # Which block within chapter
|
||||
word_index: int = 0 # Which word within block (for paragraphs)
|
||||
# Which word within block (for paragraphs)
|
||||
word_index: int = 0
|
||||
table_row: int = 0 # Which row for tables
|
||||
table_col: int = 0 # Which column for tables
|
||||
list_item_index: int = 0 # Which item for lists
|
||||
remaining_pretext: Optional[str] = None # Hyphenated word continuation
|
||||
page_y_offset: int = 0 # Vertical position on page
|
||||
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize position for saving to file/database"""
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'RenderingPosition':
|
||||
"""Deserialize position from saved state"""
|
||||
return cls(**data)
|
||||
|
||||
|
||||
def copy(self) -> 'RenderingPosition':
|
||||
"""Create a copy of this position"""
|
||||
return RenderingPosition(**asdict(self))
|
||||
|
||||
|
||||
def __eq__(self, other) -> bool:
|
||||
"""Check if two positions are equal"""
|
||||
if not isinstance(other, RenderingPosition):
|
||||
return False
|
||||
return asdict(self) == asdict(other)
|
||||
|
||||
|
||||
def __hash__(self) -> int:
|
||||
"""Make position hashable for use as dict key"""
|
||||
return hash(tuple(asdict(self).values()))
|
||||
@ -71,8 +66,13 @@ class RenderingPosition:
|
||||
|
||||
class ChapterInfo:
|
||||
"""Information about a chapter/section in the document"""
|
||||
|
||||
def __init__(self, title: str, level: HeadingLevel, position: RenderingPosition, block_index: int):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
title: str,
|
||||
level: HeadingLevel,
|
||||
position: RenderingPosition,
|
||||
block_index: int):
|
||||
self.title = title
|
||||
self.level = level
|
||||
self.position = position
|
||||
@ -84,16 +84,16 @@ class ChapterNavigator:
|
||||
Handles chapter/section navigation based on HTML heading structure (H1-H6).
|
||||
Builds a table of contents and provides navigation capabilities.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, blocks: List[Block]):
|
||||
self.blocks = blocks
|
||||
self.chapters: List[ChapterInfo] = []
|
||||
self._build_chapter_map()
|
||||
|
||||
|
||||
def _build_chapter_map(self):
|
||||
"""Scan blocks for headings and build chapter navigation map"""
|
||||
current_chapter_index = 0
|
||||
|
||||
|
||||
for block_index, block in enumerate(self.blocks):
|
||||
if isinstance(block, Heading):
|
||||
# Create position for this heading
|
||||
@ -105,23 +105,23 @@ class ChapterNavigator:
|
||||
table_col=0,
|
||||
list_item_index=0
|
||||
)
|
||||
|
||||
|
||||
# Extract heading text
|
||||
heading_text = self._extract_heading_text(block)
|
||||
|
||||
|
||||
chapter_info = ChapterInfo(
|
||||
title=heading_text,
|
||||
level=block.level,
|
||||
position=position,
|
||||
block_index=block_index
|
||||
)
|
||||
|
||||
|
||||
self.chapters.append(chapter_info)
|
||||
|
||||
|
||||
# Only increment chapter index for top-level headings (H1)
|
||||
if block.level == HeadingLevel.H1:
|
||||
current_chapter_index += 1
|
||||
|
||||
|
||||
def _extract_heading_text(self, heading: Heading) -> str:
|
||||
"""Extract text content from a heading block"""
|
||||
words = []
|
||||
@ -129,33 +129,35 @@ class ChapterNavigator:
|
||||
if isinstance(word, Word):
|
||||
words.append(word.text)
|
||||
return " ".join(words)
|
||||
|
||||
def get_table_of_contents(self) -> List[Tuple[str, HeadingLevel, RenderingPosition]]:
|
||||
|
||||
def get_table_of_contents(
|
||||
self) -> List[Tuple[str, HeadingLevel, RenderingPosition]]:
|
||||
"""Generate table of contents from heading structure"""
|
||||
return [(chapter.title, chapter.level, chapter.position) for chapter in self.chapters]
|
||||
|
||||
return [(chapter.title, chapter.level, chapter.position)
|
||||
for chapter in self.chapters]
|
||||
|
||||
def get_chapter_position(self, chapter_title: str) -> Optional[RenderingPosition]:
|
||||
"""Get rendering position for a chapter by title"""
|
||||
for chapter in self.chapters:
|
||||
if chapter.title.lower() == chapter_title.lower():
|
||||
return chapter.position
|
||||
return None
|
||||
|
||||
|
||||
def get_current_chapter(self, position: RenderingPosition) -> Optional[ChapterInfo]:
|
||||
"""Determine which chapter contains the current position"""
|
||||
if not self.chapters:
|
||||
return None
|
||||
|
||||
|
||||
# Find the chapter that contains this position
|
||||
for i, chapter in enumerate(self.chapters):
|
||||
# Check if this is the last chapter or if position is before next chapter
|
||||
if i == len(self.chapters) - 1:
|
||||
return chapter
|
||||
|
||||
|
||||
next_chapter = self.chapters[i + 1]
|
||||
if position.chapter_index < next_chapter.position.chapter_index:
|
||||
return chapter
|
||||
|
||||
|
||||
return self.chapters[0] if self.chapters else None
|
||||
|
||||
|
||||
@ -164,24 +166,24 @@ class FontScaler:
|
||||
Handles font scaling operations for ereader font size adjustments.
|
||||
Applies scaling at layout/render time while preserving original font objects.
|
||||
"""
|
||||
|
||||
|
||||
@staticmethod
|
||||
def scale_font(font: Font, scale_factor: float) -> Font:
|
||||
"""
|
||||
Create a scaled version of a font for layout calculations.
|
||||
|
||||
|
||||
Args:
|
||||
font: Original font object
|
||||
scale_factor: Scaling factor (1.0 = no change, 2.0 = double size, etc.)
|
||||
|
||||
|
||||
Returns:
|
||||
New Font object with scaled size
|
||||
"""
|
||||
if scale_factor == 1.0:
|
||||
return font
|
||||
|
||||
|
||||
scaled_size = max(1, int(font.font_size * scale_factor))
|
||||
|
||||
|
||||
return Font(
|
||||
font_path=font._font_path,
|
||||
font_size=scaled_size,
|
||||
@ -193,13 +195,14 @@ class FontScaler:
|
||||
language=font.language,
|
||||
min_hyphenation_width=font.min_hyphenation_width
|
||||
)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def scale_word_spacing(spacing: Tuple[int, int], scale_factor: float) -> Tuple[int, int]:
|
||||
def scale_word_spacing(spacing: Tuple[int, int],
|
||||
scale_factor: float) -> Tuple[int, int]:
|
||||
"""Scale word spacing constraints proportionally"""
|
||||
if scale_factor == 1.0:
|
||||
return spacing
|
||||
|
||||
|
||||
min_spacing, max_spacing = spacing
|
||||
return (
|
||||
max(1, int(min_spacing * scale_factor)),
|
||||
@ -212,41 +215,49 @@ class BidirectionalLayouter:
|
||||
Core layout engine supporting both forward and backward page rendering.
|
||||
Handles font scaling and maintains position state.
|
||||
"""
|
||||
|
||||
def __init__(self, blocks: List[Block], page_style: PageStyle, page_size: Tuple[int, int] = (800, 600), alignment_override=None):
|
||||
|
||||
def __init__(self,
|
||||
blocks: List[Block],
|
||||
page_style: PageStyle,
|
||||
page_size: Tuple[int,
|
||||
int] = (800,
|
||||
600),
|
||||
alignment_override=None):
|
||||
self.blocks = blocks
|
||||
self.page_style = page_style
|
||||
self.page_size = page_size
|
||||
self.chapter_navigator = ChapterNavigator(blocks)
|
||||
self.alignment_override = alignment_override
|
||||
|
||||
def render_page_forward(self, position: RenderingPosition, font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
|
||||
|
||||
def render_page_forward(self, position: RenderingPosition,
|
||||
font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
|
||||
"""
|
||||
Render a page starting from the given position, moving forward through the document.
|
||||
|
||||
|
||||
Args:
|
||||
position: Starting position in document
|
||||
font_scale: Font scaling factor
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (rendered_page, next_position)
|
||||
"""
|
||||
page = Page(size=self.page_size, style=self.page_style)
|
||||
current_pos = position.copy()
|
||||
|
||||
|
||||
# Start laying out blocks from the current position
|
||||
while current_pos.block_index < len(self.blocks) and page.free_space()[1] > 0:
|
||||
# Additional bounds check to prevent IndexError
|
||||
if current_pos.block_index >= len(self.blocks):
|
||||
break
|
||||
|
||||
|
||||
block = self.blocks[current_pos.block_index]
|
||||
|
||||
|
||||
# Apply font scaling to the block
|
||||
scaled_block = self._scale_block_fonts(block, font_scale)
|
||||
|
||||
|
||||
# Try to fit the block on the current page
|
||||
success, new_pos = self._layout_block_on_page(scaled_block, page, current_pos, font_scale)
|
||||
success, new_pos = self._layout_block_on_page(
|
||||
scaled_block, page, current_pos, font_scale)
|
||||
|
||||
if not success:
|
||||
# Block doesn't fit, we're done with this page
|
||||
@ -262,45 +273,50 @@ class BidirectionalLayouter:
|
||||
# We've reached the end of the document
|
||||
current_pos = new_pos
|
||||
break
|
||||
|
||||
|
||||
current_pos = new_pos
|
||||
|
||||
|
||||
return page, current_pos
|
||||
|
||||
def render_page_backward(self, end_position: RenderingPosition, font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
|
||||
|
||||
def render_page_backward(self,
|
||||
end_position: RenderingPosition,
|
||||
font_scale: float = 1.0) -> Tuple[Page,
|
||||
RenderingPosition]:
|
||||
"""
|
||||
Render a page that ends at the given position, filling backward.
|
||||
Critical for "previous page" navigation.
|
||||
|
||||
|
||||
Args:
|
||||
end_position: Position where page should end
|
||||
font_scale: Font scaling factor
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (rendered_page, start_position)
|
||||
"""
|
||||
# This is a complex operation that requires iterative refinement
|
||||
# We'll start with an estimated start position and refine it
|
||||
|
||||
|
||||
estimated_start = self._estimate_page_start(end_position, font_scale)
|
||||
|
||||
|
||||
# Render forward from estimated start and see if we reach the target
|
||||
page, actual_end = self.render_page_forward(estimated_start, font_scale)
|
||||
|
||||
|
||||
# If we overshot or undershot, adjust and try again
|
||||
# This is a simplified implementation - a full version would be more sophisticated
|
||||
# This is a simplified implementation - a full version would be more
|
||||
# sophisticated
|
||||
if self._position_compare(actual_end, end_position) != 0:
|
||||
# Adjust estimate and try again (simplified)
|
||||
estimated_start = self._adjust_start_estimate(estimated_start, end_position, actual_end)
|
||||
estimated_start = self._adjust_start_estimate(
|
||||
estimated_start, end_position, actual_end)
|
||||
page, actual_end = self.render_page_forward(estimated_start, font_scale)
|
||||
|
||||
|
||||
return page, estimated_start
|
||||
|
||||
|
||||
def _scale_block_fonts(self, block: Block, font_scale: float) -> Block:
|
||||
"""Apply font scaling to all fonts in a block"""
|
||||
if font_scale == 1.0:
|
||||
return block
|
||||
|
||||
|
||||
# This is a simplified implementation
|
||||
# In practice, we'd need to handle each block type appropriately
|
||||
if isinstance(block, (Paragraph, Heading)):
|
||||
@ -309,20 +325,27 @@ class BidirectionalLayouter:
|
||||
scaled_block = Heading(block.level, scaled_block_style)
|
||||
else:
|
||||
scaled_block = Paragraph(scaled_block_style)
|
||||
|
||||
|
||||
# words_iter() returns tuples of (position, word)
|
||||
for position, word in block.words_iter():
|
||||
if isinstance(word, Word):
|
||||
scaled_word = Word(word.text, FontScaler.scale_font(word.style, font_scale))
|
||||
scaled_word = Word(
|
||||
word.text, FontScaler.scale_font(
|
||||
word.style, font_scale))
|
||||
scaled_block.add_word(scaled_word)
|
||||
return scaled_block
|
||||
|
||||
|
||||
return block
|
||||
|
||||
def _layout_block_on_page(self, block: Block, page: Page, position: RenderingPosition, font_scale: float) -> Tuple[bool, RenderingPosition]:
|
||||
|
||||
def _layout_block_on_page(self,
|
||||
block: Block,
|
||||
page: Page,
|
||||
position: RenderingPosition,
|
||||
font_scale: float) -> Tuple[bool,
|
||||
RenderingPosition]:
|
||||
"""
|
||||
Try to layout a block on the page starting from the given position.
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (success, new_position)
|
||||
"""
|
||||
@ -339,18 +362,23 @@ class BidirectionalLayouter:
|
||||
new_pos = position.copy()
|
||||
new_pos.block_index += 1
|
||||
return True, new_pos
|
||||
|
||||
def _layout_paragraph_on_page(self, paragraph: Paragraph, page: Page, position: RenderingPosition, font_scale: float) -> Tuple[bool, RenderingPosition]:
|
||||
|
||||
def _layout_paragraph_on_page(self,
|
||||
paragraph: Paragraph,
|
||||
page: Page,
|
||||
position: RenderingPosition,
|
||||
font_scale: float) -> Tuple[bool,
|
||||
RenderingPosition]:
|
||||
"""
|
||||
Layout a paragraph on the page using the core paragraph_layouter.
|
||||
Integrates font scaling and position tracking with the proven layout logic.
|
||||
|
||||
|
||||
Args:
|
||||
paragraph: The paragraph to layout (already scaled if font_scale != 1.0)
|
||||
page: The page to layout on
|
||||
position: Current rendering position
|
||||
font_scale: Font scaling factor (used for context, paragraph should already be scaled)
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (success, new_position)
|
||||
"""
|
||||
@ -365,7 +393,7 @@ class BidirectionalLayouter:
|
||||
line=None,
|
||||
source=None
|
||||
)
|
||||
|
||||
|
||||
# Call the core paragraph layouter with alignment override if set
|
||||
success, failed_word_index, remaining_pretext = paragraph_layouter(
|
||||
paragraph,
|
||||
@ -374,10 +402,10 @@ class BidirectionalLayouter:
|
||||
pretext=pretext_obj,
|
||||
alignment_override=self.alignment_override
|
||||
)
|
||||
|
||||
|
||||
# Create new position based on the result
|
||||
new_pos = position.copy()
|
||||
|
||||
|
||||
if success:
|
||||
# Paragraph was fully laid out, move to next block
|
||||
new_pos.block_index += 1
|
||||
@ -389,25 +417,35 @@ class BidirectionalLayouter:
|
||||
if failed_word_index is not None:
|
||||
# Update position to the word that didn't fit
|
||||
new_pos.word_index = failed_word_index
|
||||
|
||||
|
||||
# Convert Text object back to string if there's remaining pretext
|
||||
if remaining_pretext is not None and hasattr(remaining_pretext, 'text'):
|
||||
new_pos.remaining_pretext = remaining_pretext.text
|
||||
else:
|
||||
new_pos.remaining_pretext = None
|
||||
|
||||
|
||||
return False, new_pos
|
||||
else:
|
||||
# No specific word failed, but layout wasn't successful
|
||||
# This shouldn't normally happen, but handle it gracefully
|
||||
return False, position
|
||||
|
||||
def _layout_heading_on_page(self, heading: Heading, page: Page, position: RenderingPosition, font_scale: float) -> Tuple[bool, RenderingPosition]:
|
||||
|
||||
def _layout_heading_on_page(self,
|
||||
heading: Heading,
|
||||
page: Page,
|
||||
position: RenderingPosition,
|
||||
font_scale: float) -> Tuple[bool,
|
||||
RenderingPosition]:
|
||||
"""Layout a heading on the page"""
|
||||
# Similar to paragraph but with heading-specific styling
|
||||
return self._layout_paragraph_on_page(heading, page, position, font_scale)
|
||||
|
||||
def _layout_table_on_page(self, table: Table, page: Page, position: RenderingPosition, font_scale: float) -> Tuple[bool, RenderingPosition]:
|
||||
|
||||
def _layout_table_on_page(self,
|
||||
table: Table,
|
||||
page: Page,
|
||||
position: RenderingPosition,
|
||||
font_scale: float) -> Tuple[bool,
|
||||
RenderingPosition]:
|
||||
"""Layout a table on the page with column fitting and row continuation"""
|
||||
# This is a complex operation that would need full table layout logic
|
||||
# For now, skip tables
|
||||
@ -416,8 +454,13 @@ class BidirectionalLayouter:
|
||||
new_pos.table_row = 0
|
||||
new_pos.table_col = 0
|
||||
return True, new_pos
|
||||
|
||||
def _layout_list_on_page(self, hlist: HList, page: Page, position: RenderingPosition, font_scale: float) -> Tuple[bool, RenderingPosition]:
|
||||
|
||||
def _layout_list_on_page(self,
|
||||
hlist: HList,
|
||||
page: Page,
|
||||
position: RenderingPosition,
|
||||
font_scale: float) -> Tuple[bool,
|
||||
RenderingPosition]:
|
||||
"""Layout a list on the page"""
|
||||
# This would need list-specific layout logic
|
||||
# For now, skip lists
|
||||
@ -425,33 +468,43 @@ class BidirectionalLayouter:
|
||||
new_pos.block_index += 1
|
||||
new_pos.list_item_index = 0
|
||||
return True, new_pos
|
||||
|
||||
def _estimate_page_start(self, end_position: RenderingPosition, font_scale: float) -> RenderingPosition:
|
||||
|
||||
def _estimate_page_start(
|
||||
self,
|
||||
end_position: RenderingPosition,
|
||||
font_scale: float) -> RenderingPosition:
|
||||
"""Estimate where a page should start to end at the given position"""
|
||||
# This is a simplified heuristic - a full implementation would be more sophisticated
|
||||
# This is a simplified heuristic - a full implementation would be more
|
||||
# sophisticated
|
||||
estimated_start = end_position.copy()
|
||||
|
||||
|
||||
# Move back by an estimated number of blocks that would fit on a page
|
||||
estimated_blocks_per_page = max(1, int(10 / font_scale)) # Rough estimate
|
||||
estimated_start.block_index = max(0, end_position.block_index - estimated_blocks_per_page)
|
||||
estimated_start.block_index = max(
|
||||
0, end_position.block_index - estimated_blocks_per_page)
|
||||
estimated_start.word_index = 0
|
||||
|
||||
|
||||
return estimated_start
|
||||
|
||||
def _adjust_start_estimate(self, current_start: RenderingPosition, target_end: RenderingPosition, actual_end: RenderingPosition) -> RenderingPosition:
|
||||
|
||||
def _adjust_start_estimate(
|
||||
self,
|
||||
current_start: RenderingPosition,
|
||||
target_end: RenderingPosition,
|
||||
actual_end: RenderingPosition) -> RenderingPosition:
|
||||
"""Adjust start position estimate based on overshoot/undershoot"""
|
||||
# Simplified adjustment logic
|
||||
adjusted = current_start.copy()
|
||||
|
||||
|
||||
comparison = self._position_compare(actual_end, target_end)
|
||||
if comparison > 0: # Overshot
|
||||
adjusted.block_index = max(0, adjusted.block_index + 1)
|
||||
elif comparison < 0: # Undershot
|
||||
adjusted.block_index = max(0, adjusted.block_index - 1)
|
||||
|
||||
|
||||
return adjusted
|
||||
|
||||
def _position_compare(self, pos1: RenderingPosition, pos2: RenderingPosition) -> int:
|
||||
|
||||
def _position_compare(self, pos1: RenderingPosition,
|
||||
pos2: RenderingPosition) -> int:
|
||||
"""Compare two positions (-1: pos1 < pos2, 0: equal, 1: pos1 > pos2)"""
|
||||
if pos1.chapter_index != pos2.chapter_index:
|
||||
return 1 if pos1.chapter_index > pos2.chapter_index else -1
|
||||
@ -470,16 +523,17 @@ def _add_page_methods():
|
||||
"""Check if a line of given height can fit on the page"""
|
||||
available_height = self.content_size[1] - self._current_y_offset
|
||||
return available_height >= line_height
|
||||
|
||||
|
||||
Page.can_fit_line = can_fit_line
|
||||
|
||||
|
||||
if not hasattr(Page, 'available_width'):
|
||||
@property
|
||||
def available_width(self) -> int:
|
||||
"""Get available width for content"""
|
||||
return self.content_size[0]
|
||||
|
||||
|
||||
Page.available_width = available_width
|
||||
|
||||
|
||||
# Apply the page methods
|
||||
_add_page_methods()
|
||||
|
||||
@ -9,7 +9,6 @@ into a unified, easy-to-use API.
|
||||
from __future__ import annotations
|
||||
from typing import List, Dict, Optional, Tuple, Any, Callable
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from .ereader_layout import RenderingPosition, ChapterNavigator, ChapterInfo
|
||||
@ -23,11 +22,11 @@ class BookmarkManager:
|
||||
"""
|
||||
Manages bookmarks and reading position persistence for ereader applications.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, document_id: str, bookmarks_dir: str = "bookmarks"):
|
||||
"""
|
||||
Initialize bookmark manager.
|
||||
|
||||
|
||||
Args:
|
||||
document_id: Unique identifier for the document
|
||||
bookmarks_dir: Directory to store bookmark files
|
||||
@ -35,13 +34,13 @@ class BookmarkManager:
|
||||
self.document_id = document_id
|
||||
self.bookmarks_dir = Path(bookmarks_dir)
|
||||
self.bookmarks_dir.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
self.bookmarks_file = self.bookmarks_dir / f"{document_id}_bookmarks.json"
|
||||
self.position_file = self.bookmarks_dir / f"{document_id}_position.json"
|
||||
|
||||
|
||||
self._bookmarks: Dict[str, RenderingPosition] = {}
|
||||
self._load_bookmarks()
|
||||
|
||||
|
||||
def _load_bookmarks(self):
|
||||
"""Load bookmarks from file"""
|
||||
if self.bookmarks_file.exists():
|
||||
@ -55,7 +54,7 @@ class BookmarkManager:
|
||||
except Exception as e:
|
||||
print(f"Failed to load bookmarks: {e}")
|
||||
self._bookmarks = {}
|
||||
|
||||
|
||||
def _save_bookmarks(self):
|
||||
"""Save bookmarks to file"""
|
||||
try:
|
||||
@ -67,25 +66,25 @@ class BookmarkManager:
|
||||
json.dump(data, f, indent=2)
|
||||
except Exception as e:
|
||||
print(f"Failed to save bookmarks: {e}")
|
||||
|
||||
|
||||
def add_bookmark(self, name: str, position: RenderingPosition):
|
||||
"""
|
||||
Add a bookmark at the given position.
|
||||
|
||||
|
||||
Args:
|
||||
name: Bookmark name
|
||||
position: Position to bookmark
|
||||
"""
|
||||
self._bookmarks[name] = position
|
||||
self._save_bookmarks()
|
||||
|
||||
|
||||
def remove_bookmark(self, name: str) -> bool:
|
||||
"""
|
||||
Remove a bookmark.
|
||||
|
||||
|
||||
Args:
|
||||
name: Bookmark name to remove
|
||||
|
||||
|
||||
Returns:
|
||||
True if bookmark was removed, False if not found
|
||||
"""
|
||||
@ -94,32 +93,32 @@ class BookmarkManager:
|
||||
self._save_bookmarks()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_bookmark(self, name: str) -> Optional[RenderingPosition]:
|
||||
"""
|
||||
Get a bookmark position.
|
||||
|
||||
|
||||
Args:
|
||||
name: Bookmark name
|
||||
|
||||
|
||||
Returns:
|
||||
Bookmark position or None if not found
|
||||
"""
|
||||
return self._bookmarks.get(name)
|
||||
|
||||
|
||||
def list_bookmarks(self) -> List[Tuple[str, RenderingPosition]]:
|
||||
"""
|
||||
Get all bookmarks.
|
||||
|
||||
|
||||
Returns:
|
||||
List of (name, position) tuples
|
||||
"""
|
||||
return list(self._bookmarks.items())
|
||||
|
||||
|
||||
def save_reading_position(self, position: RenderingPosition):
|
||||
"""
|
||||
Save the current reading position.
|
||||
|
||||
|
||||
Args:
|
||||
position: Current reading position
|
||||
"""
|
||||
@ -128,11 +127,11 @@ class BookmarkManager:
|
||||
json.dump(position.to_dict(), f, indent=2)
|
||||
except Exception as e:
|
||||
print(f"Failed to save reading position: {e}")
|
||||
|
||||
|
||||
def load_reading_position(self) -> Optional[RenderingPosition]:
|
||||
"""
|
||||
Load the last reading position.
|
||||
|
||||
|
||||
Returns:
|
||||
Last reading position or None if not found
|
||||
"""
|
||||
@ -149,7 +148,7 @@ class BookmarkManager:
|
||||
class EreaderLayoutManager:
|
||||
"""
|
||||
High-level ereader layout manager providing a complete interface for ereader applications.
|
||||
|
||||
|
||||
Features:
|
||||
- Sub-second page rendering with intelligent buffering
|
||||
- Font scaling support
|
||||
@ -158,17 +157,17 @@ class EreaderLayoutManager:
|
||||
- Position persistence
|
||||
- Progress tracking
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
blocks: List[Block],
|
||||
page_size: Tuple[int, int],
|
||||
|
||||
def __init__(self,
|
||||
blocks: List[Block],
|
||||
page_size: Tuple[int, int],
|
||||
document_id: str = "default",
|
||||
buffer_size: int = 5,
|
||||
page_style: Optional[PageStyle] = None,
|
||||
bookmarks_dir: str = "bookmarks"):
|
||||
"""
|
||||
Initialize the ereader layout manager.
|
||||
|
||||
|
||||
Args:
|
||||
blocks: Document blocks to render
|
||||
page_size: Page size (width, height) in pixels
|
||||
@ -180,125 +179,132 @@ class EreaderLayoutManager:
|
||||
self.blocks = blocks
|
||||
self.page_size = page_size
|
||||
self.document_id = document_id
|
||||
|
||||
|
||||
# Initialize page style
|
||||
if page_style is None:
|
||||
page_style = PageStyle()
|
||||
self.page_style = page_style
|
||||
|
||||
|
||||
# Initialize core components
|
||||
self.renderer = BufferedPageRenderer(blocks, page_style, buffer_size, page_size)
|
||||
self.chapter_navigator = ChapterNavigator(blocks)
|
||||
self.bookmark_manager = BookmarkManager(document_id, bookmarks_dir)
|
||||
|
||||
|
||||
# Current state
|
||||
self.current_position = RenderingPosition()
|
||||
self.font_scale = 1.0
|
||||
|
||||
|
||||
# Load last reading position if available
|
||||
saved_position = self.bookmark_manager.load_reading_position()
|
||||
if saved_position:
|
||||
self.current_position = saved_position
|
||||
|
||||
|
||||
# Callbacks for UI updates
|
||||
self.position_changed_callback: Optional[Callable[[RenderingPosition], None]] = None
|
||||
self.chapter_changed_callback: Optional[Callable[[Optional[ChapterInfo]], None]] = None
|
||||
|
||||
def set_position_changed_callback(self, callback: Callable[[RenderingPosition], None]):
|
||||
self.position_changed_callback: Optional[Callable[[
|
||||
RenderingPosition], None]] = None
|
||||
self.chapter_changed_callback: Optional[Callable[[
|
||||
Optional[ChapterInfo]], None]] = None
|
||||
|
||||
def set_position_changed_callback(
|
||||
self, callback: Callable[[RenderingPosition], None]):
|
||||
"""Set callback for position changes"""
|
||||
self.position_changed_callback = callback
|
||||
|
||||
def set_chapter_changed_callback(self, callback: Callable[[Optional[ChapterInfo]], None]):
|
||||
|
||||
def set_chapter_changed_callback(
|
||||
self, callback: Callable[[Optional[ChapterInfo]], None]):
|
||||
"""Set callback for chapter changes"""
|
||||
self.chapter_changed_callback = callback
|
||||
|
||||
|
||||
def _notify_position_changed(self):
|
||||
"""Notify UI of position change"""
|
||||
if self.position_changed_callback:
|
||||
self.position_changed_callback(self.current_position)
|
||||
|
||||
|
||||
# Check if chapter changed
|
||||
current_chapter = self.chapter_navigator.get_current_chapter(self.current_position)
|
||||
current_chapter = self.chapter_navigator.get_current_chapter(
|
||||
self.current_position)
|
||||
if self.chapter_changed_callback:
|
||||
self.chapter_changed_callback(current_chapter)
|
||||
|
||||
|
||||
# Auto-save reading position
|
||||
self.bookmark_manager.save_reading_position(self.current_position)
|
||||
|
||||
|
||||
def get_current_page(self) -> Page:
|
||||
"""
|
||||
Get the page at the current reading position.
|
||||
|
||||
|
||||
Returns:
|
||||
Rendered page
|
||||
"""
|
||||
page, _ = self.renderer.render_page(self.current_position, self.font_scale)
|
||||
return page
|
||||
|
||||
|
||||
def next_page(self) -> Optional[Page]:
|
||||
"""
|
||||
Advance to the next page.
|
||||
|
||||
|
||||
Returns:
|
||||
Next page or None if at end of document
|
||||
"""
|
||||
page, next_position = self.renderer.render_page(self.current_position, self.font_scale)
|
||||
|
||||
page, next_position = self.renderer.render_page(
|
||||
self.current_position, self.font_scale)
|
||||
|
||||
# Check if we made progress
|
||||
if next_position != self.current_position:
|
||||
self.current_position = next_position
|
||||
self._notify_position_changed()
|
||||
return self.get_current_page()
|
||||
|
||||
|
||||
return None # At end of document
|
||||
|
||||
|
||||
def previous_page(self) -> Optional[Page]:
|
||||
"""
|
||||
Go to the previous page.
|
||||
|
||||
|
||||
Returns:
|
||||
Previous page or None if at beginning of document
|
||||
"""
|
||||
if self._is_at_beginning():
|
||||
return None
|
||||
|
||||
|
||||
# Use backward rendering to find the previous page
|
||||
page, start_position = self.renderer.render_page_backward(self.current_position, self.font_scale)
|
||||
|
||||
page, start_position = self.renderer.render_page_backward(
|
||||
self.current_position, self.font_scale)
|
||||
|
||||
if start_position != self.current_position:
|
||||
self.current_position = start_position
|
||||
self._notify_position_changed()
|
||||
return page
|
||||
|
||||
|
||||
return None # At beginning of document
|
||||
|
||||
|
||||
def _is_at_beginning(self) -> bool:
|
||||
"""Check if we're at the beginning of the document"""
|
||||
return (self.current_position.chapter_index == 0 and
|
||||
self.current_position.block_index == 0 and
|
||||
return (self.current_position.chapter_index == 0 and
|
||||
self.current_position.block_index == 0 and
|
||||
self.current_position.word_index == 0)
|
||||
|
||||
|
||||
def jump_to_position(self, position: RenderingPosition) -> Page:
|
||||
"""
|
||||
Jump to a specific position in the document.
|
||||
|
||||
|
||||
Args:
|
||||
position: Position to jump to
|
||||
|
||||
|
||||
Returns:
|
||||
Page at the new position
|
||||
"""
|
||||
self.current_position = position
|
||||
self._notify_position_changed()
|
||||
return self.get_current_page()
|
||||
|
||||
|
||||
def jump_to_chapter(self, chapter_title: str) -> Optional[Page]:
|
||||
"""
|
||||
Jump to a specific chapter by title.
|
||||
|
||||
|
||||
Args:
|
||||
chapter_title: Title of the chapter to jump to
|
||||
|
||||
|
||||
Returns:
|
||||
Page at chapter start or None if chapter not found
|
||||
"""
|
||||
@ -306,14 +312,14 @@ class EreaderLayoutManager:
|
||||
if position:
|
||||
return self.jump_to_position(position)
|
||||
return None
|
||||
|
||||
|
||||
def jump_to_chapter_index(self, chapter_index: int) -> Optional[Page]:
|
||||
"""
|
||||
Jump to a chapter by index.
|
||||
|
||||
|
||||
Args:
|
||||
chapter_index: Index of the chapter (0-based)
|
||||
|
||||
|
||||
Returns:
|
||||
Page at chapter start or None if index invalid
|
||||
"""
|
||||
@ -321,23 +327,23 @@ class EreaderLayoutManager:
|
||||
if 0 <= chapter_index < len(chapters):
|
||||
return self.jump_to_position(chapters[chapter_index].position)
|
||||
return None
|
||||
|
||||
|
||||
def set_font_scale(self, scale: float) -> Page:
|
||||
"""
|
||||
Change the font scale and re-render current page.
|
||||
|
||||
|
||||
Args:
|
||||
scale: Font scaling factor (1.0 = normal, 2.0 = double size, etc.)
|
||||
|
||||
|
||||
Returns:
|
||||
Re-rendered page with new font scale
|
||||
"""
|
||||
if scale != self.font_scale:
|
||||
self.font_scale = scale
|
||||
# The renderer will handle cache invalidation
|
||||
|
||||
|
||||
return self.get_current_page()
|
||||
|
||||
|
||||
def get_font_scale(self) -> float:
|
||||
"""Get the current font scale"""
|
||||
return self.font_scale
|
||||
@ -397,7 +403,8 @@ class EreaderLayoutManager:
|
||||
Returns:
|
||||
Re-rendered page with decreased block spacing
|
||||
"""
|
||||
self.page_style.inter_block_spacing = max(0, self.page_style.inter_block_spacing - amount)
|
||||
self.page_style.inter_block_spacing = max(
|
||||
0, self.page_style.inter_block_spacing - amount)
|
||||
self.renderer.page_style = self.page_style # Update renderer's reference
|
||||
self.renderer.buffer.invalidate_all() # Clear cache to force re-render
|
||||
return self.get_current_page()
|
||||
@ -432,31 +439,32 @@ class EreaderLayoutManager:
|
||||
self.renderer.buffer.invalidate_all() # Clear cache to force re-render
|
||||
return self.get_current_page()
|
||||
|
||||
def get_table_of_contents(self) -> List[Tuple[str, HeadingLevel, RenderingPosition]]:
|
||||
def get_table_of_contents(
|
||||
self) -> List[Tuple[str, HeadingLevel, RenderingPosition]]:
|
||||
"""
|
||||
Get the table of contents.
|
||||
|
||||
|
||||
Returns:
|
||||
List of (title, level, position) tuples
|
||||
"""
|
||||
return self.chapter_navigator.get_table_of_contents()
|
||||
|
||||
|
||||
def get_current_chapter(self) -> Optional[ChapterInfo]:
|
||||
"""
|
||||
Get information about the current chapter.
|
||||
|
||||
|
||||
Returns:
|
||||
Current chapter info or None if no chapters
|
||||
"""
|
||||
return self.chapter_navigator.get_current_chapter(self.current_position)
|
||||
|
||||
|
||||
def add_bookmark(self, name: str) -> bool:
|
||||
"""
|
||||
Add a bookmark at the current position.
|
||||
|
||||
|
||||
Args:
|
||||
name: Bookmark name
|
||||
|
||||
|
||||
Returns:
|
||||
True if bookmark was added successfully
|
||||
"""
|
||||
@ -465,26 +473,26 @@ class EreaderLayoutManager:
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def remove_bookmark(self, name: str) -> bool:
|
||||
"""
|
||||
Remove a bookmark.
|
||||
|
||||
|
||||
Args:
|
||||
name: Bookmark name
|
||||
|
||||
|
||||
Returns:
|
||||
True if bookmark was removed
|
||||
"""
|
||||
return self.bookmark_manager.remove_bookmark(name)
|
||||
|
||||
|
||||
def jump_to_bookmark(self, name: str) -> Optional[Page]:
|
||||
"""
|
||||
Jump to a bookmark.
|
||||
|
||||
|
||||
Args:
|
||||
name: Bookmark name
|
||||
|
||||
|
||||
Returns:
|
||||
Page at bookmark position or None if bookmark not found
|
||||
"""
|
||||
@ -492,42 +500,42 @@ class EreaderLayoutManager:
|
||||
if position:
|
||||
return self.jump_to_position(position)
|
||||
return None
|
||||
|
||||
|
||||
def list_bookmarks(self) -> List[Tuple[str, RenderingPosition]]:
|
||||
"""
|
||||
Get all bookmarks.
|
||||
|
||||
|
||||
Returns:
|
||||
List of (name, position) tuples
|
||||
"""
|
||||
return self.bookmark_manager.list_bookmarks()
|
||||
|
||||
|
||||
def get_reading_progress(self) -> float:
|
||||
"""
|
||||
Get reading progress as a percentage.
|
||||
|
||||
|
||||
Returns:
|
||||
Progress from 0.0 to 1.0
|
||||
"""
|
||||
if not self.blocks:
|
||||
return 0.0
|
||||
|
||||
|
||||
# Simple progress calculation based on block index
|
||||
# A more sophisticated version would consider word positions
|
||||
total_blocks = len(self.blocks)
|
||||
current_block = min(self.current_position.block_index, total_blocks - 1)
|
||||
|
||||
|
||||
return current_block / max(1, total_blocks - 1)
|
||||
|
||||
|
||||
def get_position_info(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get detailed information about the current position.
|
||||
|
||||
|
||||
Returns:
|
||||
Dictionary with position details
|
||||
"""
|
||||
current_chapter = self.get_current_chapter()
|
||||
|
||||
|
||||
return {
|
||||
'position': self.current_position.to_dict(),
|
||||
'chapter': {
|
||||
@ -539,16 +547,16 @@ class EreaderLayoutManager:
|
||||
'font_scale': self.font_scale,
|
||||
'page_size': self.page_size
|
||||
}
|
||||
|
||||
|
||||
def get_cache_stats(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get cache statistics for debugging/monitoring.
|
||||
|
||||
|
||||
Returns:
|
||||
Dictionary with cache statistics
|
||||
"""
|
||||
return self.renderer.get_cache_stats()
|
||||
|
||||
|
||||
def shutdown(self):
|
||||
"""
|
||||
Shutdown the ereader manager and clean up resources.
|
||||
@ -556,29 +564,29 @@ class EreaderLayoutManager:
|
||||
"""
|
||||
# Save current position
|
||||
self.bookmark_manager.save_reading_position(self.current_position)
|
||||
|
||||
|
||||
# Shutdown renderer and buffer
|
||||
self.renderer.shutdown()
|
||||
|
||||
|
||||
def __del__(self):
|
||||
"""Cleanup on destruction"""
|
||||
self.shutdown()
|
||||
|
||||
|
||||
# Convenience function for quick setup
|
||||
def create_ereader_manager(blocks: List[Block],
|
||||
page_size: Tuple[int, int],
|
||||
document_id: str = "default",
|
||||
**kwargs) -> EreaderLayoutManager:
|
||||
def create_ereader_manager(blocks: List[Block],
|
||||
page_size: Tuple[int, int],
|
||||
document_id: str = "default",
|
||||
**kwargs) -> EreaderLayoutManager:
|
||||
"""
|
||||
Convenience function to create an ereader manager with sensible defaults.
|
||||
|
||||
|
||||
Args:
|
||||
blocks: Document blocks to render
|
||||
page_size: Page size (width, height) in pixels
|
||||
document_id: Unique identifier for the document
|
||||
**kwargs: Additional arguments passed to EreaderLayoutManager
|
||||
|
||||
|
||||
Returns:
|
||||
Configured EreaderLayoutManager instance
|
||||
"""
|
||||
|
||||
@ -8,12 +8,9 @@ multiprocessing to achieve sub-second page navigation performance.
|
||||
from __future__ import annotations
|
||||
from typing import Dict, Optional, List, Tuple, Any
|
||||
from collections import OrderedDict
|
||||
import multiprocessing
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed, Future
|
||||
from concurrent.futures import ProcessPoolExecutor, Future
|
||||
import threading
|
||||
import time
|
||||
import pickle
|
||||
from dataclasses import asdict
|
||||
|
||||
from .ereader_layout import RenderingPosition, BidirectionalLayouter
|
||||
from pyWebLayout.concrete.page import Page
|
||||
@ -21,28 +18,34 @@ from pyWebLayout.abstract.block import Block
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
|
||||
|
||||
def _render_page_worker(args: Tuple[List[Block], PageStyle, RenderingPosition, float, bool]) -> Tuple[RenderingPosition, bytes, RenderingPosition]:
|
||||
def _render_page_worker(args: Tuple[List[Block],
|
||||
PageStyle,
|
||||
RenderingPosition,
|
||||
float,
|
||||
bool]) -> Tuple[RenderingPosition,
|
||||
bytes,
|
||||
RenderingPosition]:
|
||||
"""
|
||||
Worker function for multiprocess page rendering.
|
||||
|
||||
|
||||
Args:
|
||||
args: Tuple of (blocks, page_style, position, font_scale, is_backward)
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (original_position, pickled_page, next_position)
|
||||
"""
|
||||
blocks, page_style, position, font_scale, is_backward = args
|
||||
|
||||
|
||||
layouter = BidirectionalLayouter(blocks, page_style)
|
||||
|
||||
|
||||
if is_backward:
|
||||
page, next_pos = layouter.render_page_backward(position, font_scale)
|
||||
else:
|
||||
page, next_pos = layouter.render_page_forward(position, font_scale)
|
||||
|
||||
|
||||
# Serialize the page for inter-process communication
|
||||
pickled_page = pickle.dumps(page)
|
||||
|
||||
|
||||
return position, pickled_page, next_pos
|
||||
|
||||
|
||||
@ -51,40 +54,46 @@ class PageBuffer:
|
||||
Intelligent page caching system with LRU eviction and background rendering.
|
||||
Maintains separate forward and backward buffers for optimal navigation performance.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, buffer_size: int = 5, max_workers: int = 4):
|
||||
"""
|
||||
Initialize the page buffer.
|
||||
|
||||
|
||||
Args:
|
||||
buffer_size: Number of pages to cache in each direction
|
||||
max_workers: Maximum number of worker processes for background rendering
|
||||
"""
|
||||
self.buffer_size = buffer_size
|
||||
self.max_workers = max_workers
|
||||
|
||||
|
||||
# LRU caches for forward and backward pages
|
||||
self.forward_buffer: OrderedDict[RenderingPosition, Page] = OrderedDict()
|
||||
self.backward_buffer: OrderedDict[RenderingPosition, Page] = OrderedDict()
|
||||
|
||||
|
||||
# Position tracking for next/previous positions
|
||||
self.position_map: Dict[RenderingPosition, RenderingPosition] = {} # current -> next
|
||||
self.reverse_position_map: Dict[RenderingPosition, RenderingPosition] = {} # current -> previous
|
||||
|
||||
self.position_map: Dict[RenderingPosition,
|
||||
RenderingPosition] = {} # current -> next
|
||||
self.reverse_position_map: Dict[RenderingPosition,
|
||||
RenderingPosition] = {} # current -> previous
|
||||
|
||||
# Background rendering
|
||||
self.executor: Optional[ProcessPoolExecutor] = None
|
||||
self.pending_renders: Dict[RenderingPosition, Future] = {}
|
||||
self.render_lock = threading.Lock()
|
||||
|
||||
|
||||
# Document state
|
||||
self.blocks: Optional[List[Block]] = None
|
||||
self.page_style: Optional[PageStyle] = None
|
||||
self.current_font_scale: float = 1.0
|
||||
|
||||
def initialize(self, blocks: List[Block], page_style: PageStyle, font_scale: float = 1.0):
|
||||
|
||||
def initialize(
|
||||
self,
|
||||
blocks: List[Block],
|
||||
page_style: PageStyle,
|
||||
font_scale: float = 1.0):
|
||||
"""
|
||||
Initialize the buffer with document blocks and page style.
|
||||
|
||||
|
||||
Args:
|
||||
blocks: Document blocks to render
|
||||
page_style: Page styling configuration
|
||||
@ -93,18 +102,18 @@ class PageBuffer:
|
||||
self.blocks = blocks
|
||||
self.page_style = page_style
|
||||
self.current_font_scale = font_scale
|
||||
|
||||
|
||||
# Start the process pool
|
||||
if self.executor is None:
|
||||
self.executor = ProcessPoolExecutor(max_workers=self.max_workers)
|
||||
|
||||
|
||||
def get_page(self, position: RenderingPosition) -> Optional[Page]:
|
||||
"""
|
||||
Get a cached page if available.
|
||||
|
||||
|
||||
Args:
|
||||
position: Position to get page for
|
||||
|
||||
|
||||
Returns:
|
||||
Cached page or None if not available
|
||||
"""
|
||||
@ -114,20 +123,25 @@ class PageBuffer:
|
||||
page = self.forward_buffer.pop(position)
|
||||
self.forward_buffer[position] = page
|
||||
return page
|
||||
|
||||
|
||||
# Check backward buffer
|
||||
if position in self.backward_buffer:
|
||||
# Move to end (most recently used)
|
||||
page = self.backward_buffer.pop(position)
|
||||
self.backward_buffer[position] = page
|
||||
return page
|
||||
|
||||
|
||||
return None
|
||||
|
||||
def cache_page(self, position: RenderingPosition, page: Page, next_position: Optional[RenderingPosition] = None, is_backward: bool = False):
|
||||
|
||||
def cache_page(
|
||||
self,
|
||||
position: RenderingPosition,
|
||||
page: Page,
|
||||
next_position: Optional[RenderingPosition] = None,
|
||||
is_backward: bool = False):
|
||||
"""
|
||||
Cache a rendered page with LRU eviction.
|
||||
|
||||
|
||||
Args:
|
||||
position: Position of the page
|
||||
page: Rendered page to cache
|
||||
@ -135,46 +149,49 @@ class PageBuffer:
|
||||
is_backward: Whether this is a backward-rendered page
|
||||
"""
|
||||
target_buffer = self.backward_buffer if is_backward else self.forward_buffer
|
||||
|
||||
|
||||
# Add to cache
|
||||
target_buffer[position] = page
|
||||
|
||||
|
||||
# Track position relationships
|
||||
if next_position:
|
||||
if is_backward:
|
||||
self.reverse_position_map[next_position] = position
|
||||
else:
|
||||
self.position_map[position] = next_position
|
||||
|
||||
|
||||
# Evict oldest if buffer is full
|
||||
if len(target_buffer) > self.buffer_size:
|
||||
oldest_pos, _ = target_buffer.popitem(last=False)
|
||||
# Clean up position maps
|
||||
self.position_map.pop(oldest_pos, None)
|
||||
self.reverse_position_map.pop(oldest_pos, None)
|
||||
|
||||
def start_background_rendering(self, current_position: RenderingPosition, direction: str = 'forward'):
|
||||
|
||||
def start_background_rendering(
|
||||
self,
|
||||
current_position: RenderingPosition,
|
||||
direction: str = 'forward'):
|
||||
"""
|
||||
Start background rendering of upcoming pages.
|
||||
|
||||
|
||||
Args:
|
||||
current_position: Current reading position
|
||||
direction: 'forward', 'backward', or 'both'
|
||||
"""
|
||||
if not self.blocks or not self.page_style or not self.executor:
|
||||
return
|
||||
|
||||
|
||||
with self.render_lock:
|
||||
if direction in ['forward', 'both']:
|
||||
self._queue_forward_renders(current_position)
|
||||
|
||||
|
||||
if direction in ['backward', 'both']:
|
||||
self._queue_backward_renders(current_position)
|
||||
|
||||
|
||||
def _queue_forward_renders(self, start_position: RenderingPosition):
|
||||
"""Queue forward page renders starting from the given position"""
|
||||
current_pos = start_position
|
||||
|
||||
|
||||
for i in range(self.buffer_size):
|
||||
# Skip if already cached or being rendered
|
||||
if current_pos in self.forward_buffer or current_pos in self.pending_renders:
|
||||
@ -183,19 +200,25 @@ class PageBuffer:
|
||||
if not current_pos:
|
||||
break
|
||||
continue
|
||||
|
||||
|
||||
# Queue render job
|
||||
args = (self.blocks, self.page_style, current_pos, self.current_font_scale, False)
|
||||
args = (
|
||||
self.blocks,
|
||||
self.page_style,
|
||||
current_pos,
|
||||
self.current_font_scale,
|
||||
False)
|
||||
future = self.executor.submit(_render_page_worker, args)
|
||||
self.pending_renders[current_pos] = future
|
||||
|
||||
# We don't know the next position yet, so we'll update it when the render completes
|
||||
|
||||
# We don't know the next position yet, so we'll update it when the render
|
||||
# completes
|
||||
break
|
||||
|
||||
|
||||
def _queue_backward_renders(self, start_position: RenderingPosition):
|
||||
"""Queue backward page renders ending at the given position"""
|
||||
current_pos = start_position
|
||||
|
||||
|
||||
for i in range(self.buffer_size):
|
||||
# Skip if already cached or being rendered
|
||||
if current_pos in self.backward_buffer or current_pos in self.pending_renders:
|
||||
@ -204,44 +227,50 @@ class PageBuffer:
|
||||
if not current_pos:
|
||||
break
|
||||
continue
|
||||
|
||||
|
||||
# Queue render job
|
||||
args = (self.blocks, self.page_style, current_pos, self.current_font_scale, True)
|
||||
args = (
|
||||
self.blocks,
|
||||
self.page_style,
|
||||
current_pos,
|
||||
self.current_font_scale,
|
||||
True)
|
||||
future = self.executor.submit(_render_page_worker, args)
|
||||
self.pending_renders[current_pos] = future
|
||||
|
||||
# We don't know the previous position yet, so we'll update it when the render completes
|
||||
|
||||
# We don't know the previous position yet, so we'll update it when the
|
||||
# render completes
|
||||
break
|
||||
|
||||
|
||||
def check_completed_renders(self):
|
||||
"""Check for completed background renders and cache the results"""
|
||||
if not self.pending_renders:
|
||||
return
|
||||
|
||||
|
||||
completed = []
|
||||
|
||||
|
||||
with self.render_lock:
|
||||
for position, future in self.pending_renders.items():
|
||||
if future.done():
|
||||
try:
|
||||
original_pos, pickled_page, next_pos = future.result()
|
||||
|
||||
|
||||
# Deserialize the page
|
||||
page = pickle.loads(pickled_page)
|
||||
|
||||
|
||||
# Cache the page
|
||||
self.cache_page(original_pos, page, next_pos, is_backward=False)
|
||||
|
||||
|
||||
completed.append(position)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f"Background render failed for position {position}: {e}")
|
||||
completed.append(position)
|
||||
|
||||
|
||||
# Remove completed renders
|
||||
for pos in completed:
|
||||
self.pending_renders.pop(pos, None)
|
||||
|
||||
|
||||
def invalidate_all(self):
|
||||
"""Clear all cached pages and cancel pending renders"""
|
||||
with self.render_lock:
|
||||
@ -249,24 +278,24 @@ class PageBuffer:
|
||||
for future in self.pending_renders.values():
|
||||
future.cancel()
|
||||
self.pending_renders.clear()
|
||||
|
||||
|
||||
# Clear caches
|
||||
self.forward_buffer.clear()
|
||||
self.backward_buffer.clear()
|
||||
self.position_map.clear()
|
||||
self.reverse_position_map.clear()
|
||||
|
||||
|
||||
def set_font_scale(self, font_scale: float):
|
||||
"""
|
||||
Update font scale and invalidate cache.
|
||||
|
||||
|
||||
Args:
|
||||
font_scale: New font scaling factor
|
||||
"""
|
||||
if font_scale != self.current_font_scale:
|
||||
self.current_font_scale = font_scale
|
||||
self.invalidate_all()
|
||||
|
||||
|
||||
def get_cache_stats(self) -> Dict[str, Any]:
|
||||
"""Get cache statistics for debugging/monitoring"""
|
||||
return {
|
||||
@ -277,7 +306,7 @@ class PageBuffer:
|
||||
'reverse_position_mappings': len(self.reverse_position_map),
|
||||
'current_font_scale': self.current_font_scale
|
||||
}
|
||||
|
||||
|
||||
def shutdown(self):
|
||||
"""Shutdown the page buffer and clean up resources"""
|
||||
if self.executor:
|
||||
@ -285,14 +314,14 @@ class PageBuffer:
|
||||
with self.render_lock:
|
||||
for future in self.pending_renders.values():
|
||||
future.cancel()
|
||||
|
||||
|
||||
# Shutdown executor
|
||||
self.executor.shutdown(wait=True)
|
||||
self.executor = None
|
||||
|
||||
|
||||
# Clear all caches
|
||||
self.invalidate_all()
|
||||
|
||||
|
||||
def __del__(self):
|
||||
"""Cleanup on destruction"""
|
||||
self.shutdown()
|
||||
@ -302,11 +331,17 @@ class BufferedPageRenderer:
|
||||
"""
|
||||
High-level interface for buffered page rendering with automatic background caching.
|
||||
"""
|
||||
|
||||
def __init__(self, blocks: List[Block], page_style: PageStyle, buffer_size: int = 5, page_size: Tuple[int, int] = (800, 600)):
|
||||
|
||||
def __init__(self,
|
||||
blocks: List[Block],
|
||||
page_style: PageStyle,
|
||||
buffer_size: int = 5,
|
||||
page_size: Tuple[int,
|
||||
int] = (800,
|
||||
600)):
|
||||
"""
|
||||
Initialize the buffered renderer.
|
||||
|
||||
|
||||
Args:
|
||||
blocks: Document blocks to render
|
||||
page_style: Page styling configuration
|
||||
@ -316,18 +351,19 @@ class BufferedPageRenderer:
|
||||
self.layouter = BidirectionalLayouter(blocks, page_style, page_size)
|
||||
self.buffer = PageBuffer(buffer_size)
|
||||
self.buffer.initialize(blocks, page_style)
|
||||
|
||||
|
||||
self.current_position = RenderingPosition()
|
||||
self.font_scale = 1.0
|
||||
|
||||
def render_page(self, position: RenderingPosition, font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
|
||||
|
||||
def render_page(self, position: RenderingPosition,
|
||||
font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
|
||||
"""
|
||||
Render a page with intelligent caching.
|
||||
|
||||
|
||||
Args:
|
||||
position: Position to render from
|
||||
font_scale: Font scaling factor
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (rendered_page, next_position)
|
||||
"""
|
||||
@ -335,40 +371,43 @@ class BufferedPageRenderer:
|
||||
if font_scale != self.font_scale:
|
||||
self.font_scale = font_scale
|
||||
self.buffer.set_font_scale(font_scale)
|
||||
|
||||
|
||||
# Check cache first
|
||||
cached_page = self.buffer.get_page(position)
|
||||
if cached_page:
|
||||
# Get next position from position map
|
||||
next_pos = self.buffer.position_map.get(position, position)
|
||||
|
||||
|
||||
# Start background rendering for upcoming pages
|
||||
self.buffer.start_background_rendering(position, 'forward')
|
||||
|
||||
|
||||
return cached_page, next_pos
|
||||
|
||||
|
||||
# Render the page directly
|
||||
page, next_pos = self.layouter.render_page_forward(position, font_scale)
|
||||
|
||||
|
||||
# Cache the result
|
||||
self.buffer.cache_page(position, page, next_pos)
|
||||
|
||||
|
||||
# Start background rendering
|
||||
self.buffer.start_background_rendering(position, 'both')
|
||||
|
||||
|
||||
# Check for completed background renders
|
||||
self.buffer.check_completed_renders()
|
||||
|
||||
|
||||
return page, next_pos
|
||||
|
||||
def render_page_backward(self, end_position: RenderingPosition, font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
|
||||
|
||||
def render_page_backward(self,
|
||||
end_position: RenderingPosition,
|
||||
font_scale: float = 1.0) -> Tuple[Page,
|
||||
RenderingPosition]:
|
||||
"""
|
||||
Render a page ending at the given position with intelligent caching.
|
||||
|
||||
|
||||
Args:
|
||||
end_position: Position where page should end
|
||||
font_scale: Font scaling factor
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (rendered_page, start_position)
|
||||
"""
|
||||
@ -376,36 +415,36 @@ class BufferedPageRenderer:
|
||||
if font_scale != self.font_scale:
|
||||
self.font_scale = font_scale
|
||||
self.buffer.set_font_scale(font_scale)
|
||||
|
||||
|
||||
# Check cache first
|
||||
cached_page = self.buffer.get_page(end_position)
|
||||
if cached_page:
|
||||
# Get previous position from reverse position map
|
||||
prev_pos = self.buffer.reverse_position_map.get(end_position, end_position)
|
||||
|
||||
|
||||
# Start background rendering for previous pages
|
||||
self.buffer.start_background_rendering(end_position, 'backward')
|
||||
|
||||
|
||||
return cached_page, prev_pos
|
||||
|
||||
|
||||
# Render the page directly
|
||||
page, start_pos = self.layouter.render_page_backward(end_position, font_scale)
|
||||
|
||||
|
||||
# Cache the result
|
||||
self.buffer.cache_page(start_pos, page, end_position, is_backward=True)
|
||||
|
||||
|
||||
# Start background rendering
|
||||
self.buffer.start_background_rendering(end_position, 'both')
|
||||
|
||||
|
||||
# Check for completed background renders
|
||||
self.buffer.check_completed_renders()
|
||||
|
||||
|
||||
return page, start_pos
|
||||
|
||||
|
||||
def get_cache_stats(self) -> Dict[str, Any]:
|
||||
"""Get cache statistics"""
|
||||
return self.buffer.get_cache_stats()
|
||||
|
||||
|
||||
def shutdown(self):
|
||||
"""Shutdown the renderer and clean up resources"""
|
||||
self.buffer.shutdown()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -6,6 +6,7 @@ rendering parameters, allowing for flexible interpretation by different
|
||||
rendering systems and user preferences.
|
||||
"""
|
||||
|
||||
from .alignment import Alignment
|
||||
from typing import Dict, Optional, Tuple, Union
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
@ -30,7 +31,7 @@ class FontSize(Enum):
|
||||
LARGE = "large"
|
||||
X_LARGE = "x-large"
|
||||
XX_LARGE = "xx-large"
|
||||
|
||||
|
||||
# Allow numeric values as well
|
||||
@classmethod
|
||||
def from_value(cls, value: Union[str, int, float]) -> Union['FontSize', int]:
|
||||
@ -50,7 +51,6 @@ class FontSize(Enum):
|
||||
|
||||
|
||||
# Import Alignment from the centralized location
|
||||
from .alignment import Alignment
|
||||
|
||||
# Use Alignment for text alignment
|
||||
TextAlign = Alignment
|
||||
@ -61,25 +61,25 @@ class AbstractStyle:
|
||||
"""
|
||||
Abstract representation of text styling that captures semantic intent
|
||||
rather than concrete rendering parameters.
|
||||
|
||||
|
||||
This allows the same document to be rendered differently based on
|
||||
user preferences, device capabilities, or accessibility requirements.
|
||||
|
||||
|
||||
Being frozen=True makes this class hashable and immutable, which is
|
||||
perfect for use as dictionary keys and preventing accidental modification.
|
||||
"""
|
||||
|
||||
|
||||
# Font properties (semantic)
|
||||
font_family: FontFamily = FontFamily.SERIF
|
||||
font_size: Union[FontSize, int] = FontSize.MEDIUM
|
||||
font_weight: FontWeight = FontWeight.NORMAL
|
||||
font_style: FontStyle = FontStyle.NORMAL
|
||||
text_decoration: TextDecoration = TextDecoration.NONE
|
||||
|
||||
|
||||
# Color (as semantic names or RGB)
|
||||
color: Union[str, Tuple[int, int, int]] = "black"
|
||||
background_color: Optional[Union[str, Tuple[int, int, int, int]]] = None
|
||||
|
||||
|
||||
# Text properties
|
||||
text_align: TextAlign = TextAlign.LEFT
|
||||
line_height: Optional[Union[str, float]] = None # "normal", "1.2", 1.5, etc.
|
||||
@ -87,13 +87,13 @@ class AbstractStyle:
|
||||
word_spacing: Optional[Union[str, float]] = None
|
||||
word_spacing_min: Optional[Union[str, float]] = None # Minimum allowed word spacing
|
||||
word_spacing_max: Optional[Union[str, float]] = None # Maximum allowed word spacing
|
||||
|
||||
|
||||
# Language and locale
|
||||
language: str = "en-US"
|
||||
|
||||
|
||||
# Hierarchy properties
|
||||
parent_style_id: Optional[str] = None
|
||||
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate and normalize values after creation"""
|
||||
# Normalize font_size if it's a string that could be a number
|
||||
@ -103,11 +103,11 @@ class AbstractStyle:
|
||||
except ValueError:
|
||||
# Keep as is if it's a semantic size name
|
||||
pass
|
||||
|
||||
|
||||
def __hash__(self) -> int:
|
||||
"""
|
||||
Custom hash implementation to ensure consistent hashing.
|
||||
|
||||
|
||||
Since this is a frozen dataclass, it should be hashable by default,
|
||||
but we provide a custom implementation to ensure all fields are
|
||||
properly considered and to handle the Union types correctly.
|
||||
@ -130,17 +130,17 @@ class AbstractStyle:
|
||||
self.language,
|
||||
self.parent_style_id
|
||||
)
|
||||
|
||||
|
||||
return hash(hashable_values)
|
||||
|
||||
|
||||
def merge_with(self, other: 'AbstractStyle') -> 'AbstractStyle':
|
||||
"""
|
||||
Create a new AbstractStyle by merging this one with another.
|
||||
The other style's properties take precedence.
|
||||
|
||||
|
||||
Args:
|
||||
other: AbstractStyle to merge with this one
|
||||
|
||||
|
||||
Returns:
|
||||
New AbstractStyle with merged values
|
||||
"""
|
||||
@ -149,26 +149,26 @@ class AbstractStyle:
|
||||
field.name: getattr(self, field.name)
|
||||
for field in self.__dataclass_fields__.values()
|
||||
}
|
||||
|
||||
|
||||
other_dict = {
|
||||
field.name: getattr(other, field.name)
|
||||
for field in other.__dataclass_fields__.values()
|
||||
if getattr(other, field.name) != field.default
|
||||
}
|
||||
|
||||
|
||||
# Merge dictionaries (other takes precedence)
|
||||
merged_dict = current_dict.copy()
|
||||
merged_dict.update(other_dict)
|
||||
|
||||
|
||||
return AbstractStyle(**merged_dict)
|
||||
|
||||
|
||||
def with_modifications(self, **kwargs) -> 'AbstractStyle':
|
||||
"""
|
||||
Create a new AbstractStyle with specified modifications.
|
||||
|
||||
|
||||
Args:
|
||||
**kwargs: Properties to modify
|
||||
|
||||
|
||||
Returns:
|
||||
New AbstractStyle with modifications applied
|
||||
"""
|
||||
@ -176,7 +176,7 @@ class AbstractStyle:
|
||||
field.name: getattr(self, field.name)
|
||||
for field in self.__dataclass_fields__.values()
|
||||
}
|
||||
|
||||
|
||||
current_dict.update(kwargs)
|
||||
return AbstractStyle(**current_dict)
|
||||
|
||||
@ -184,20 +184,21 @@ class AbstractStyle:
|
||||
class AbstractStyleRegistry:
|
||||
"""
|
||||
Registry for managing abstract document styles.
|
||||
|
||||
|
||||
This registry stores the semantic styling intent and provides
|
||||
deduplication and inheritance capabilities using hashable AbstractStyle objects.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize an empty abstract style registry."""
|
||||
self._styles: Dict[str, AbstractStyle] = {}
|
||||
self._style_to_id: Dict[AbstractStyle, str] = {} # Reverse mapping using hashable styles
|
||||
# Reverse mapping using hashable styles
|
||||
self._style_to_id: Dict[AbstractStyle, str] = {}
|
||||
self._next_id = 1
|
||||
|
||||
|
||||
# Create and register the default style
|
||||
self._default_style = self._create_default_style()
|
||||
|
||||
|
||||
def _create_default_style(self) -> AbstractStyle:
|
||||
"""Create the default document style."""
|
||||
default_style = AbstractStyle()
|
||||
@ -205,38 +206,41 @@ class AbstractStyleRegistry:
|
||||
self._styles[style_id] = default_style
|
||||
self._style_to_id[default_style] = style_id
|
||||
return default_style
|
||||
|
||||
|
||||
@property
|
||||
def default_style(self) -> AbstractStyle:
|
||||
"""Get the default style for the document."""
|
||||
return self._default_style
|
||||
|
||||
|
||||
def _generate_style_id(self) -> str:
|
||||
"""Generate a unique style ID."""
|
||||
style_id = f"abstract_style_{self._next_id}"
|
||||
self._next_id += 1
|
||||
return style_id
|
||||
|
||||
|
||||
def get_style_id(self, style: AbstractStyle) -> Optional[str]:
|
||||
"""
|
||||
Get the ID for a given style if it exists in the registry.
|
||||
|
||||
|
||||
Args:
|
||||
style: AbstractStyle to find
|
||||
|
||||
|
||||
Returns:
|
||||
Style ID if found, None otherwise
|
||||
"""
|
||||
return self._style_to_id.get(style)
|
||||
|
||||
def register_style(self, style: AbstractStyle, style_id: Optional[str] = None) -> str:
|
||||
|
||||
def register_style(
|
||||
self,
|
||||
style: AbstractStyle,
|
||||
style_id: Optional[str] = None) -> str:
|
||||
"""
|
||||
Register a style in the registry.
|
||||
|
||||
|
||||
Args:
|
||||
style: AbstractStyle to register
|
||||
style_id: Optional style ID. If None, one will be generated
|
||||
|
||||
|
||||
Returns:
|
||||
The style ID
|
||||
"""
|
||||
@ -244,26 +248,26 @@ class AbstractStyleRegistry:
|
||||
existing_id = self.get_style_id(style)
|
||||
if existing_id is not None:
|
||||
return existing_id
|
||||
|
||||
|
||||
if style_id is None:
|
||||
style_id = self._generate_style_id()
|
||||
|
||||
|
||||
self._styles[style_id] = style
|
||||
self._style_to_id[style] = style_id
|
||||
return style_id
|
||||
|
||||
def get_or_create_style(self,
|
||||
style: Optional[AbstractStyle] = None,
|
||||
parent_id: Optional[str] = None,
|
||||
**kwargs) -> Tuple[str, AbstractStyle]:
|
||||
|
||||
def get_or_create_style(self,
|
||||
style: Optional[AbstractStyle] = None,
|
||||
parent_id: Optional[str] = None,
|
||||
**kwargs) -> Tuple[str, AbstractStyle]:
|
||||
"""
|
||||
Get an existing style or create a new one.
|
||||
|
||||
|
||||
Args:
|
||||
style: AbstractStyle object. If None, created from kwargs
|
||||
parent_id: Optional parent style ID
|
||||
**kwargs: Individual style properties (used if style is None)
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (style_id, AbstractStyle)
|
||||
"""
|
||||
@ -274,64 +278,65 @@ class AbstractStyleRegistry:
|
||||
if parent_id:
|
||||
filtered_kwargs['parent_style_id'] = parent_id
|
||||
style = AbstractStyle(**filtered_kwargs)
|
||||
|
||||
|
||||
# Check if we already have this style (using hashable property)
|
||||
existing_id = self.get_style_id(style)
|
||||
if existing_id is not None:
|
||||
return existing_id, style
|
||||
|
||||
|
||||
# Create new style
|
||||
style_id = self.register_style(style)
|
||||
return style_id, style
|
||||
|
||||
|
||||
def get_style_by_id(self, style_id: str) -> Optional[AbstractStyle]:
|
||||
"""Get a style by its ID."""
|
||||
return self._styles.get(style_id)
|
||||
|
||||
def create_derived_style(self, base_style_id: str, **modifications) -> Tuple[str, AbstractStyle]:
|
||||
|
||||
def create_derived_style(self, base_style_id: str, **
|
||||
modifications) -> Tuple[str, AbstractStyle]:
|
||||
"""
|
||||
Create a new style derived from a base style.
|
||||
|
||||
|
||||
Args:
|
||||
base_style_id: ID of the base style
|
||||
**modifications: Properties to modify
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (new_style_id, new_AbstractStyle)
|
||||
"""
|
||||
base_style = self.get_style_by_id(base_style_id)
|
||||
if base_style is None:
|
||||
raise ValueError(f"Base style '{base_style_id}' not found")
|
||||
|
||||
|
||||
# Create derived style
|
||||
derived_style = base_style.with_modifications(**modifications)
|
||||
return self.get_or_create_style(derived_style)
|
||||
|
||||
|
||||
def resolve_effective_style(self, style_id: str) -> AbstractStyle:
|
||||
"""
|
||||
Resolve the effective style including inheritance.
|
||||
|
||||
|
||||
Args:
|
||||
style_id: Style ID to resolve
|
||||
|
||||
|
||||
Returns:
|
||||
Effective AbstractStyle with inheritance applied
|
||||
"""
|
||||
style = self.get_style_by_id(style_id)
|
||||
if style is None:
|
||||
return self._default_style
|
||||
|
||||
|
||||
if style.parent_style_id is None:
|
||||
return style
|
||||
|
||||
|
||||
# Recursively resolve parent styles
|
||||
parent_style = self.resolve_effective_style(style.parent_style_id)
|
||||
return parent_style.merge_with(style)
|
||||
|
||||
|
||||
def get_all_styles(self) -> Dict[str, AbstractStyle]:
|
||||
"""Get all registered styles."""
|
||||
return self._styles.copy()
|
||||
|
||||
|
||||
def get_style_count(self) -> int:
|
||||
"""Get the number of registered styles."""
|
||||
return len(self._styles)
|
||||
|
||||
@ -6,6 +6,7 @@ This module provides alignment-related functionality.
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class Alignment(Enum):
|
||||
"""Text and box alignment options"""
|
||||
# Horizontal alignment
|
||||
@ -13,10 +14,10 @@ class Alignment(Enum):
|
||||
RIGHT = "right"
|
||||
CENTER = "center"
|
||||
JUSTIFY = "justify"
|
||||
|
||||
|
||||
# Vertical alignment
|
||||
TOP = "top"
|
||||
MIDDLE = "middle"
|
||||
MIDDLE = "middle"
|
||||
BOTTOM = "bottom"
|
||||
|
||||
def __str__(self):
|
||||
|
||||
@ -5,12 +5,11 @@ This module converts abstract styles to concrete rendering parameters based on
|
||||
user preferences, device capabilities, and rendering context.
|
||||
"""
|
||||
|
||||
from typing import Dict, Optional, Tuple, Union, Any
|
||||
from typing import Dict, Optional, Tuple, Union
|
||||
from dataclasses import dataclass
|
||||
from .abstract_style import AbstractStyle, FontFamily, FontSize
|
||||
from pyWebLayout.style.alignment import Alignment as TextAlign
|
||||
from .fonts import Font, FontWeight, FontStyle, TextDecoration
|
||||
import os
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@ -19,24 +18,24 @@ class RenderingContext:
|
||||
Context information for style resolution.
|
||||
Contains user preferences and device capabilities.
|
||||
"""
|
||||
|
||||
|
||||
# User preferences
|
||||
base_font_size: int = 16 # Base font size in points
|
||||
font_scale_factor: float = 1.0 # Global font scaling
|
||||
preferred_serif_font: Optional[str] = None
|
||||
preferred_sans_serif_font: Optional[str] = None
|
||||
preferred_monospace_font: Optional[str] = None
|
||||
|
||||
|
||||
# Device/environment info
|
||||
dpi: int = 96 # Dots per inch
|
||||
available_width: Optional[int] = None # Available width in pixels
|
||||
available_height: Optional[int] = None # Available height in pixels
|
||||
|
||||
|
||||
# Accessibility preferences
|
||||
high_contrast: bool = False
|
||||
large_text: bool = False
|
||||
reduce_motion: bool = False
|
||||
|
||||
|
||||
# Language and locale
|
||||
default_language: str = "en-US"
|
||||
|
||||
@ -45,22 +44,22 @@ class RenderingContext:
|
||||
class ConcreteStyle:
|
||||
"""
|
||||
Concrete representation of text styling with actual rendering parameters.
|
||||
|
||||
|
||||
This contains the resolved font files, pixel sizes, actual colors, etc.
|
||||
that will be used for rendering. This is also hashable for efficient caching.
|
||||
"""
|
||||
|
||||
|
||||
# Concrete font properties
|
||||
font_path: Optional[str] = None
|
||||
font_size: int = 16 # Always in points/pixels
|
||||
color: Tuple[int, int, int] = (0, 0, 0) # Always RGB
|
||||
background_color: Optional[Tuple[int, int, int, int]] = None # Always RGBA or None
|
||||
|
||||
|
||||
# Font attributes
|
||||
weight: FontWeight = FontWeight.NORMAL
|
||||
style: FontStyle = FontStyle.NORMAL
|
||||
decoration: TextDecoration = TextDecoration.NONE
|
||||
|
||||
|
||||
# Layout properties
|
||||
text_align: TextAlign = TextAlign.LEFT
|
||||
line_height: float = 1.0 # Multiplier
|
||||
@ -68,14 +67,14 @@ class ConcreteStyle:
|
||||
word_spacing: float = 0.0 # In pixels
|
||||
word_spacing_min: float = 0.0 # Minimum word spacing in pixels
|
||||
word_spacing_max: float = 0.0 # Maximum word spacing in pixels
|
||||
|
||||
|
||||
# Language and locale
|
||||
language: str = "en-US"
|
||||
min_hyphenation_width: int = 64 # In pixels
|
||||
|
||||
|
||||
# Reference to source abstract style
|
||||
abstract_style: Optional[AbstractStyle] = None
|
||||
|
||||
|
||||
def create_font(self) -> Font:
|
||||
"""Create a Font object from this concrete style."""
|
||||
return Font(
|
||||
@ -94,21 +93,21 @@ class ConcreteStyle:
|
||||
class StyleResolver:
|
||||
"""
|
||||
Resolves abstract styles to concrete styles based on rendering context.
|
||||
|
||||
|
||||
This class handles the conversion from semantic styling intent to actual
|
||||
rendering parameters, applying user preferences and device capabilities.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, context: RenderingContext):
|
||||
"""
|
||||
Initialize the style resolver with a rendering context.
|
||||
|
||||
|
||||
Args:
|
||||
context: RenderingContext with user preferences and device info
|
||||
"""
|
||||
self.context = context
|
||||
self._concrete_cache: Dict[AbstractStyle, ConcreteStyle] = {}
|
||||
|
||||
|
||||
# Font size mapping for semantic sizes
|
||||
self._semantic_font_sizes = {
|
||||
FontSize.XX_SMALL: 0.6,
|
||||
@ -119,7 +118,7 @@ class StyleResolver:
|
||||
FontSize.X_LARGE: 1.5,
|
||||
FontSize.XX_LARGE: 2.0,
|
||||
}
|
||||
|
||||
|
||||
# Color name mapping
|
||||
self._color_names = {
|
||||
"black": (0, 0, 0),
|
||||
@ -141,35 +140,40 @@ class StyleResolver:
|
||||
"fuchsia": (255, 0, 255),
|
||||
"purple": (128, 0, 128),
|
||||
}
|
||||
|
||||
|
||||
def resolve_style(self, abstract_style: AbstractStyle) -> ConcreteStyle:
|
||||
"""
|
||||
Resolve an abstract style to a concrete style.
|
||||
|
||||
|
||||
Args:
|
||||
abstract_style: AbstractStyle to resolve
|
||||
|
||||
|
||||
Returns:
|
||||
ConcreteStyle with concrete rendering parameters
|
||||
"""
|
||||
# Check cache first
|
||||
if abstract_style in self._concrete_cache:
|
||||
return self._concrete_cache[abstract_style]
|
||||
|
||||
|
||||
# Resolve each property
|
||||
font_path = self._resolve_font_path(abstract_style.font_family)
|
||||
font_size = self._resolve_font_size(abstract_style.font_size)
|
||||
# Ensure font_size is always an int before using in arithmetic
|
||||
font_size = int(font_size)
|
||||
color = self._resolve_color(abstract_style.color)
|
||||
background_color = self._resolve_background_color(abstract_style.background_color)
|
||||
background_color = self._resolve_background_color(
|
||||
abstract_style.background_color)
|
||||
line_height = self._resolve_line_height(abstract_style.line_height)
|
||||
letter_spacing = self._resolve_letter_spacing(abstract_style.letter_spacing, font_size)
|
||||
word_spacing = self._resolve_word_spacing(abstract_style.word_spacing, font_size)
|
||||
word_spacing_min = self._resolve_word_spacing(abstract_style.word_spacing_min, font_size)
|
||||
word_spacing_max = self._resolve_word_spacing(abstract_style.word_spacing_max, font_size)
|
||||
letter_spacing = self._resolve_letter_spacing(
|
||||
abstract_style.letter_spacing, font_size)
|
||||
word_spacing = self._resolve_word_spacing(
|
||||
abstract_style.word_spacing, font_size)
|
||||
word_spacing_min = self._resolve_word_spacing(
|
||||
abstract_style.word_spacing_min, font_size)
|
||||
word_spacing_max = self._resolve_word_spacing(
|
||||
abstract_style.word_spacing_max, font_size)
|
||||
min_hyphenation_width = max(int(font_size) * 4, 32) # At least 32 pixels
|
||||
|
||||
|
||||
# Apply default logic for word spacing constraints
|
||||
if word_spacing_min == 0.0 and word_spacing_max == 0.0:
|
||||
# If no constraints specified, use base word_spacing as reference
|
||||
@ -186,7 +190,7 @@ class StyleResolver:
|
||||
elif word_spacing_max == 0.0:
|
||||
# Only min specified, use base word_spacing or reasonable multiple
|
||||
word_spacing_max = max(word_spacing, word_spacing_min * 2)
|
||||
|
||||
|
||||
# Create concrete style
|
||||
concrete_style = ConcreteStyle(
|
||||
font_path=font_path,
|
||||
@ -206,11 +210,11 @@ class StyleResolver:
|
||||
min_hyphenation_width=min_hyphenation_width,
|
||||
abstract_style=abstract_style
|
||||
)
|
||||
|
||||
|
||||
# Cache and return
|
||||
self._concrete_cache[abstract_style] = concrete_style
|
||||
return concrete_style
|
||||
|
||||
|
||||
def _resolve_font_path(self, font_family: FontFamily) -> Optional[str]:
|
||||
"""Resolve font family to actual font file path."""
|
||||
if font_family == FontFamily.SERIF:
|
||||
@ -222,7 +226,7 @@ class StyleResolver:
|
||||
else:
|
||||
# For cursive and fantasy, fall back to sans-serif
|
||||
return self.context.preferred_sans_serif_font
|
||||
|
||||
|
||||
def _resolve_font_size(self, font_size: Union[FontSize, int]) -> int:
|
||||
"""Resolve font size to actual pixel/point size."""
|
||||
# Ensure we handle FontSize enums properly
|
||||
@ -240,22 +244,23 @@ class StyleResolver:
|
||||
except (ValueError, TypeError):
|
||||
# If conversion fails, use default
|
||||
base_size = self.context.base_font_size
|
||||
|
||||
|
||||
# Apply global font scaling
|
||||
final_size = int(base_size * self.context.font_scale_factor)
|
||||
|
||||
|
||||
# Apply accessibility adjustments
|
||||
if self.context.large_text:
|
||||
final_size = int(final_size * 1.2)
|
||||
|
||||
|
||||
# Ensure we always return an int, minimum 8pt font
|
||||
return max(int(final_size), 8)
|
||||
|
||||
def _resolve_color(self, color: Union[str, Tuple[int, int, int]]) -> Tuple[int, int, int]:
|
||||
|
||||
def _resolve_color(
|
||||
self, color: Union[str, Tuple[int, int, int]]) -> Tuple[int, int, int]:
|
||||
"""Resolve color to RGB tuple."""
|
||||
if isinstance(color, tuple):
|
||||
return color
|
||||
|
||||
|
||||
if isinstance(color, str):
|
||||
# Check if it's a named color
|
||||
if color.lower() in self._color_names:
|
||||
@ -266,7 +271,7 @@ class StyleResolver:
|
||||
hex_color = color[1:]
|
||||
if len(hex_color) == 3:
|
||||
# Short hex format #RGB -> #RRGGBB
|
||||
hex_color = ''.join(c*2 for c in hex_color)
|
||||
hex_color = ''.join(c * 2 for c in hex_color)
|
||||
if len(hex_color) == 6:
|
||||
r = int(hex_color[0:2], 16)
|
||||
g = int(hex_color[2:4], 16)
|
||||
@ -278,7 +283,7 @@ class StyleResolver:
|
||||
base_color = (0, 0, 0) # Fallback to black
|
||||
else:
|
||||
base_color = (0, 0, 0) # Fallback to black
|
||||
|
||||
|
||||
# Apply high contrast if needed
|
||||
if self.context.high_contrast:
|
||||
# Simple high contrast: make dark colors black, light colors white
|
||||
@ -288,56 +293,65 @@ class StyleResolver:
|
||||
base_color = (0, 0, 0) # Black
|
||||
else:
|
||||
base_color = (255, 255, 255) # White
|
||||
|
||||
|
||||
return base_color
|
||||
|
||||
|
||||
return (0, 0, 0) # Fallback to black
|
||||
|
||||
def _resolve_background_color(self, bg_color: Optional[Union[str, Tuple[int, int, int, int]]]) -> Optional[Tuple[int, int, int, int]]:
|
||||
|
||||
def _resolve_background_color(self,
|
||||
bg_color: Optional[Union[str,
|
||||
Tuple[int,
|
||||
int,
|
||||
int,
|
||||
int]]]) -> Optional[Tuple[int,
|
||||
int,
|
||||
int,
|
||||
int]]:
|
||||
"""Resolve background color to RGBA tuple or None."""
|
||||
if bg_color is None:
|
||||
return None
|
||||
|
||||
|
||||
if isinstance(bg_color, tuple):
|
||||
if len(bg_color) == 3:
|
||||
# RGB -> RGBA
|
||||
return bg_color + (255,)
|
||||
return bg_color
|
||||
|
||||
|
||||
if isinstance(bg_color, str):
|
||||
if bg_color.lower() == "transparent":
|
||||
return None
|
||||
|
||||
|
||||
# Resolve as RGB then add alpha
|
||||
rgb = self._resolve_color(bg_color)
|
||||
return rgb + (255,)
|
||||
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_line_height(self, line_height: Optional[Union[str, float]]) -> float:
|
||||
"""Resolve line height to multiplier."""
|
||||
if line_height is None or line_height == "normal":
|
||||
return 1.2 # Default line height
|
||||
|
||||
|
||||
if isinstance(line_height, (int, float)):
|
||||
return float(line_height)
|
||||
|
||||
|
||||
if isinstance(line_height, str):
|
||||
try:
|
||||
return float(line_height)
|
||||
except ValueError:
|
||||
return 1.2 # Fallback
|
||||
|
||||
|
||||
return 1.2
|
||||
|
||||
def _resolve_letter_spacing(self, letter_spacing: Optional[Union[str, float]], font_size: int) -> float:
|
||||
|
||||
def _resolve_letter_spacing(
|
||||
self, letter_spacing: Optional[Union[str, float]], font_size: int) -> float:
|
||||
"""Resolve letter spacing to pixels."""
|
||||
if letter_spacing is None or letter_spacing == "normal":
|
||||
return 0.0
|
||||
|
||||
|
||||
if isinstance(letter_spacing, (int, float)):
|
||||
return float(letter_spacing)
|
||||
|
||||
|
||||
if isinstance(letter_spacing, str):
|
||||
if letter_spacing.endswith("em"):
|
||||
try:
|
||||
@ -350,17 +364,18 @@ class StyleResolver:
|
||||
return float(letter_spacing)
|
||||
except ValueError:
|
||||
return 0.0
|
||||
|
||||
|
||||
return 0.0
|
||||
|
||||
def _resolve_word_spacing(self, word_spacing: Optional[Union[str, float]], font_size: int) -> float:
|
||||
|
||||
def _resolve_word_spacing(
|
||||
self, word_spacing: Optional[Union[str, float]], font_size: int) -> float:
|
||||
"""Resolve word spacing to pixels."""
|
||||
if word_spacing is None or word_spacing == "normal":
|
||||
return 0.0
|
||||
|
||||
|
||||
if isinstance(word_spacing, (int, float)):
|
||||
return float(word_spacing)
|
||||
|
||||
|
||||
if isinstance(word_spacing, str):
|
||||
if word_spacing.endswith("em"):
|
||||
try:
|
||||
@ -373,13 +388,13 @@ class StyleResolver:
|
||||
return float(word_spacing)
|
||||
except ValueError:
|
||||
return 0.0
|
||||
|
||||
|
||||
return 0.0
|
||||
|
||||
|
||||
def update_context(self, **kwargs):
|
||||
"""
|
||||
Update the rendering context and clear cache.
|
||||
|
||||
|
||||
Args:
|
||||
**kwargs: Context properties to update
|
||||
"""
|
||||
@ -389,16 +404,16 @@ class StyleResolver:
|
||||
for field in self.context.__dataclass_fields__.values()
|
||||
}
|
||||
context_dict.update(kwargs)
|
||||
|
||||
|
||||
self.context = RenderingContext(**context_dict)
|
||||
|
||||
|
||||
# Clear cache since context changed
|
||||
self._concrete_cache.clear()
|
||||
|
||||
|
||||
def clear_cache(self):
|
||||
"""Clear the concrete style cache."""
|
||||
self._concrete_cache.clear()
|
||||
|
||||
|
||||
def get_cache_size(self) -> int:
|
||||
"""Get the number of cached concrete styles."""
|
||||
return len(self._concrete_cache)
|
||||
@ -407,60 +422,60 @@ class StyleResolver:
|
||||
class ConcreteStyleRegistry:
|
||||
"""
|
||||
Registry for managing concrete styles with efficient caching.
|
||||
|
||||
|
||||
This registry manages the mapping between abstract and concrete styles,
|
||||
and provides efficient access to Font objects for rendering.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, resolver: StyleResolver):
|
||||
"""
|
||||
Initialize the concrete style registry.
|
||||
|
||||
|
||||
Args:
|
||||
resolver: StyleResolver for converting abstract to concrete styles
|
||||
"""
|
||||
self.resolver = resolver
|
||||
self._font_cache: Dict[ConcreteStyle, Font] = {}
|
||||
|
||||
|
||||
def get_concrete_style(self, abstract_style: AbstractStyle) -> ConcreteStyle:
|
||||
"""
|
||||
Get a concrete style for an abstract style.
|
||||
|
||||
|
||||
Args:
|
||||
abstract_style: AbstractStyle to resolve
|
||||
|
||||
|
||||
Returns:
|
||||
ConcreteStyle with rendering parameters
|
||||
"""
|
||||
return self.resolver.resolve_style(abstract_style)
|
||||
|
||||
|
||||
def get_font(self, abstract_style: AbstractStyle) -> Font:
|
||||
"""
|
||||
Get a Font object for an abstract style.
|
||||
|
||||
|
||||
Args:
|
||||
abstract_style: AbstractStyle to get font for
|
||||
|
||||
|
||||
Returns:
|
||||
Font object ready for rendering
|
||||
"""
|
||||
concrete_style = self.get_concrete_style(abstract_style)
|
||||
|
||||
|
||||
# Check font cache
|
||||
if concrete_style in self._font_cache:
|
||||
return self._font_cache[concrete_style]
|
||||
|
||||
|
||||
# Create and cache font
|
||||
font = concrete_style.create_font()
|
||||
self._font_cache[concrete_style] = font
|
||||
|
||||
|
||||
return font
|
||||
|
||||
|
||||
def clear_caches(self):
|
||||
"""Clear all caches."""
|
||||
self.resolver.clear_cache()
|
||||
self._font_cache.clear()
|
||||
|
||||
|
||||
def get_cache_stats(self) -> Dict[str, int]:
|
||||
"""Get cache statistics."""
|
||||
return {
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
# this should contain classes for how different object can be rendered, e.g. bold, italic, regular
|
||||
# this should contain classes for how different object can be rendered,
|
||||
# e.g. bold, italic, regular
|
||||
from PIL import ImageFont
|
||||
from enum import Enum
|
||||
from typing import Tuple, Union, Optional
|
||||
from typing import Tuple, Optional
|
||||
import os
|
||||
import logging
|
||||
|
||||
@ -31,19 +32,19 @@ class Font:
|
||||
This class is used by the text renderer to determine how to render text.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
def __init__(self,
|
||||
font_path: Optional[str] = None,
|
||||
font_size: int = 16,
|
||||
font_size: int = 16,
|
||||
colour: Tuple[int, int, int] = (0, 0, 0),
|
||||
weight: FontWeight = FontWeight.NORMAL,
|
||||
style: FontStyle = FontStyle.NORMAL,
|
||||
decoration: TextDecoration = TextDecoration.NONE,
|
||||
background: Optional[Tuple[int, int, int, int]] = None,
|
||||
language = "en_EN",
|
||||
language="en_EN",
|
||||
min_hyphenation_width: Optional[int] = None):
|
||||
"""
|
||||
Initialize a Font object with the specified properties.
|
||||
|
||||
|
||||
Args:
|
||||
font_path: Path to the font file (.ttf, .otf). If None, uses default font.
|
||||
font_size: Size of the font in points.
|
||||
@ -67,7 +68,7 @@ class Font:
|
||||
self._min_hyphenation_width = min_hyphenation_width if min_hyphenation_width is not None else font_size * 4
|
||||
# Load the font file or use default
|
||||
self._load_font()
|
||||
|
||||
|
||||
def _get_bundled_font_path(self):
|
||||
"""Get the path to the bundled font"""
|
||||
# Get the directory containing this module
|
||||
@ -75,19 +76,21 @@ class Font:
|
||||
# Navigate to the assets/fonts directory
|
||||
assets_dir = os.path.join(os.path.dirname(current_dir), 'assets', 'fonts')
|
||||
bundled_font_path = os.path.join(assets_dir, 'DejaVuSans.ttf')
|
||||
|
||||
|
||||
logger.debug(f"Font loading: current_dir = {current_dir}")
|
||||
logger.debug(f"Font loading: assets_dir = {assets_dir}")
|
||||
logger.debug(f"Font loading: bundled_font_path = {bundled_font_path}")
|
||||
logger.debug(f"Font loading: bundled font exists = {os.path.exists(bundled_font_path)}")
|
||||
|
||||
logger.debug(
|
||||
f"Font loading: bundled font exists = {
|
||||
os.path.exists(bundled_font_path)}")
|
||||
|
||||
if os.path.exists(bundled_font_path):
|
||||
logger.info(f"Found bundled font at: {bundled_font_path}")
|
||||
return bundled_font_path
|
||||
else:
|
||||
logger.warning(f"Bundled font not found at: {bundled_font_path}")
|
||||
return None
|
||||
|
||||
|
||||
def _load_font(self):
|
||||
"""Load the font using PIL's ImageFont with consistent bundled font"""
|
||||
try:
|
||||
@ -95,126 +98,130 @@ class Font:
|
||||
# Use specified font path
|
||||
logger.info(f"Loading font from specified path: {self._font_path}")
|
||||
self._font = ImageFont.truetype(
|
||||
self._font_path,
|
||||
self._font_path,
|
||||
self._font_size
|
||||
)
|
||||
logger.info(f"Successfully loaded font from: {self._font_path}")
|
||||
else:
|
||||
# Use bundled font for consistency across environments
|
||||
bundled_font_path = self._get_bundled_font_path()
|
||||
|
||||
|
||||
if bundled_font_path:
|
||||
logger.info(f"Loading bundled font from: {bundled_font_path}")
|
||||
self._font = ImageFont.truetype(bundled_font_path, self._font_size)
|
||||
logger.info(f"Successfully loaded bundled font at size {self._font_size}")
|
||||
logger.info(
|
||||
f"Successfully loaded bundled font at size {
|
||||
self._font_size}")
|
||||
else:
|
||||
# Only fall back to PIL's default font if bundled font is not available
|
||||
logger.warning(f"Bundled font not available, falling back to PIL default font")
|
||||
# Only fall back to PIL's default font if bundled font is not
|
||||
# available
|
||||
logger.warning(
|
||||
"Bundled font not available, falling back to PIL default font")
|
||||
self._font = ImageFont.load_default()
|
||||
|
||||
|
||||
except Exception as e:
|
||||
# Ultimate fallback to default font
|
||||
logger.error(f"Failed to load font: {e}, falling back to PIL default font")
|
||||
self._font = ImageFont.load_default()
|
||||
|
||||
|
||||
@property
|
||||
def font(self):
|
||||
"""Get the PIL ImageFont object"""
|
||||
return self._font
|
||||
|
||||
|
||||
@property
|
||||
def font_size(self):
|
||||
"""Get the font size"""
|
||||
return self._font_size
|
||||
|
||||
|
||||
@property
|
||||
def colour(self):
|
||||
"""Get the text color"""
|
||||
return self._colour
|
||||
|
||||
|
||||
@property
|
||||
def color(self):
|
||||
"""Alias for colour (American spelling)"""
|
||||
return self._colour
|
||||
|
||||
|
||||
@property
|
||||
def background(self):
|
||||
"""Get the background color"""
|
||||
return self._background
|
||||
|
||||
|
||||
@property
|
||||
def weight(self):
|
||||
"""Get the font weight"""
|
||||
return self._weight
|
||||
|
||||
|
||||
@property
|
||||
def style(self):
|
||||
"""Get the font style"""
|
||||
return self._style
|
||||
|
||||
|
||||
@property
|
||||
def decoration(self):
|
||||
"""Get the text decoration"""
|
||||
return self._decoration
|
||||
|
||||
|
||||
@property
|
||||
def min_hyphenation_width(self):
|
||||
"""Get the minimum width required for hyphenation to be considered"""
|
||||
return self._min_hyphenation_width
|
||||
|
||||
|
||||
def with_size(self, size: int):
|
||||
"""Create a new Font object with modified size"""
|
||||
return Font(
|
||||
self._font_path,
|
||||
size,
|
||||
self._font_path,
|
||||
size,
|
||||
self._colour,
|
||||
self._weight,
|
||||
self._style,
|
||||
self._decoration,
|
||||
self._background
|
||||
)
|
||||
|
||||
|
||||
def with_colour(self, colour: Tuple[int, int, int]):
|
||||
"""Create a new Font object with modified colour"""
|
||||
return Font(
|
||||
self._font_path,
|
||||
self._font_size,
|
||||
self._font_path,
|
||||
self._font_size,
|
||||
colour,
|
||||
self._weight,
|
||||
self._style,
|
||||
self._decoration,
|
||||
self._background
|
||||
)
|
||||
|
||||
|
||||
def with_weight(self, weight: FontWeight):
|
||||
"""Create a new Font object with modified weight"""
|
||||
return Font(
|
||||
self._font_path,
|
||||
self._font_size,
|
||||
self._font_path,
|
||||
self._font_size,
|
||||
self._colour,
|
||||
weight,
|
||||
self._style,
|
||||
self._decoration,
|
||||
self._background
|
||||
)
|
||||
|
||||
|
||||
def with_style(self, style: FontStyle):
|
||||
"""Create a new Font object with modified style"""
|
||||
return Font(
|
||||
self._font_path,
|
||||
self._font_size,
|
||||
self._font_path,
|
||||
self._font_size,
|
||||
self._colour,
|
||||
self._weight,
|
||||
style,
|
||||
self._decoration,
|
||||
self._background
|
||||
)
|
||||
|
||||
|
||||
def with_decoration(self, decoration: TextDecoration):
|
||||
"""Create a new Font object with modified decoration"""
|
||||
return Font(
|
||||
self._font_path,
|
||||
self._font_size,
|
||||
self._font_path,
|
||||
self._font_size,
|
||||
self._colour,
|
||||
self._weight,
|
||||
self._style,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -5,40 +5,41 @@ Debug script to test text positioning in the line breaking system
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
# Add pyWebLayout to path
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from pyWebLayout.style import Font
|
||||
from pyWebLayout.concrete.text import Text, Line
|
||||
from pyWebLayout.style.layout import Alignment
|
||||
|
||||
# Add pyWebLayout to path
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
|
||||
def test_simple_text_rendering():
|
||||
"""Test basic text rendering to debug positioning issues"""
|
||||
|
||||
|
||||
# Create a simple image
|
||||
width, height = 300, 200
|
||||
image = Image.new('RGB', (width, height), 'white')
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
|
||||
# Draw a border for reference
|
||||
draw.rectangle([0, 0, width-1, height-1], outline=(200, 200, 200), width=2)
|
||||
|
||||
|
||||
# Create a font
|
||||
font = Font(font_size=12)
|
||||
|
||||
|
||||
# Test 1: Direct PIL text rendering
|
||||
print("Test 1: Direct PIL text rendering")
|
||||
draw.text((10, 30), "Direct PIL text", font=font.font, fill=(0, 0, 0))
|
||||
|
||||
|
||||
# Test 2: Using our Text class
|
||||
print("Test 2: Using Text class")
|
||||
text_obj = Text("Text class rendering", font, draw)
|
||||
text_obj.set_origin([10, 60]) # Set position
|
||||
print(f"Text origin: {text_obj.origin}")
|
||||
text_obj.render()
|
||||
|
||||
|
||||
# Test 3: Using Line class
|
||||
print("Test 3: Using Line class")
|
||||
line = Line(
|
||||
@ -49,26 +50,27 @@ def test_simple_text_rendering():
|
||||
font=font,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
|
||||
# Create a simple word to add to the line
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
word = Word("Line class rendering", font)
|
||||
|
||||
|
||||
success, overflow = line.add_word(word)
|
||||
print(f"Word added successfully: {success}")
|
||||
print(f"Line origin: {line.origin}")
|
||||
print(f"Line baseline: {line._baseline}")
|
||||
print(f"Text objects in line: {len(line.text_objects)}")
|
||||
|
||||
|
||||
if line.text_objects:
|
||||
for i, text in enumerate(line.text_objects):
|
||||
print(f" Text {i}: '{text.text}' at origin {text.origin}")
|
||||
|
||||
|
||||
line.render()
|
||||
|
||||
|
||||
# Save the debug image
|
||||
image.save("debug_text_positioning.png")
|
||||
print("Debug image saved as debug_text_positioning.png")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_simple_text_rendering()
|
||||
|
||||
@ -15,7 +15,7 @@ import os
|
||||
import sys
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
from typing import List
|
||||
|
||||
# Add the parent directory to sys.path to import pyWebLayout
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
@ -23,10 +23,8 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
try:
|
||||
from pyWebLayout.io.readers.epub_reader import read_epub
|
||||
from pyWebLayout.layout.ereader_layout import BidirectionalLayouter, RenderingPosition
|
||||
from pyWebLayout.layout.document_layouter import paragraph_layouter
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from pyWebLayout.style.fonts import Font
|
||||
from pyWebLayout.abstract.block import Block
|
||||
from PIL import Image, ImageDraw
|
||||
except ImportError as e:
|
||||
@ -38,10 +36,10 @@ except ImportError as e:
|
||||
def render_page_to_image(page: Page) -> Image.Image:
|
||||
"""
|
||||
Render a Page object to a PIL Image using pyWebLayout's built-in rendering.
|
||||
|
||||
|
||||
Args:
|
||||
page: The Page object to render
|
||||
|
||||
|
||||
Returns:
|
||||
PIL Image object
|
||||
"""
|
||||
@ -56,7 +54,7 @@ def render_page_to_image(page: Page) -> Image.Image:
|
||||
draw = ImageDraw.Draw(error_image)
|
||||
draw.text((20, 20), "Error: Page.render() did not return PIL Image", fill='red')
|
||||
return error_image
|
||||
|
||||
|
||||
except Exception as e:
|
||||
# Create error image if rendering fails
|
||||
error_image = Image.new('RGB', page.size, 'white')
|
||||
@ -69,26 +67,26 @@ def render_page_to_image(page: Page) -> Image.Image:
|
||||
def extract_text_from_page(page: Page) -> str:
|
||||
"""
|
||||
Extract text content from a Page object for verification purposes.
|
||||
|
||||
|
||||
Args:
|
||||
page: The Page object to extract text from
|
||||
|
||||
|
||||
Returns:
|
||||
String containing the page's text content
|
||||
"""
|
||||
text_lines = []
|
||||
text_lines.append(f"=== PAGE CONTENT ===")
|
||||
text_lines.append("=== PAGE CONTENT ===")
|
||||
text_lines.append("")
|
||||
|
||||
|
||||
try:
|
||||
# Recursively extract text from page children
|
||||
def extract_from_element(element, indent_level=0):
|
||||
indent = " " * indent_level
|
||||
|
||||
|
||||
# Import abstract block types
|
||||
from pyWebLayout.abstract.block import Paragraph, Heading, HList, Table, Image as AbstractImage
|
||||
from pyWebLayout.concrete.text import Line
|
||||
|
||||
|
||||
# Handle Line objects (concrete)
|
||||
if isinstance(element, Line):
|
||||
line_text = []
|
||||
@ -98,7 +96,7 @@ def extract_text_from_page(page: Page) -> str:
|
||||
line_text.append(str(text_obj.text))
|
||||
if line_text:
|
||||
text_lines.append(f"{indent}{' '.join(line_text)}")
|
||||
|
||||
|
||||
# Handle abstract block objects
|
||||
elif isinstance(element, (Paragraph, Heading)):
|
||||
# Extract text from paragraph/heading
|
||||
@ -106,7 +104,7 @@ def extract_text_from_page(page: Page) -> str:
|
||||
if paragraph_text:
|
||||
block_type = "HEADING" if isinstance(element, Heading) else "PARAGRAPH"
|
||||
text_lines.append(f"{indent}{block_type}: {paragraph_text}")
|
||||
|
||||
|
||||
elif isinstance(element, HList):
|
||||
text_lines.append(f"{indent}LIST:")
|
||||
# Extract text from list items
|
||||
@ -115,33 +113,33 @@ def extract_text_from_page(page: Page) -> str:
|
||||
item_text = extract_text_from_paragraph(item)
|
||||
if item_text:
|
||||
text_lines.append(f"{indent} - {item_text}")
|
||||
except:
|
||||
except Exception:
|
||||
text_lines.append(f"{indent} (List content extraction failed)")
|
||||
|
||||
|
||||
elif isinstance(element, Table):
|
||||
text_lines.append(f"{indent}[TABLE]")
|
||||
|
||||
|
||||
elif isinstance(element, AbstractImage):
|
||||
alt_text = getattr(element, 'alt_text', '')
|
||||
src = getattr(element, 'source', 'Unknown')
|
||||
text_lines.append(f"{indent}[IMAGE: {alt_text or src}]")
|
||||
|
||||
|
||||
# Handle containers with children
|
||||
elif hasattr(element, '_children') and element._children:
|
||||
for child in element._children:
|
||||
extract_from_element(child, indent_level + 1)
|
||||
|
||||
|
||||
# Handle text elements
|
||||
elif hasattr(element, 'text'):
|
||||
text = str(element.text).strip()
|
||||
if text:
|
||||
text_lines.append(f"{indent}{text}")
|
||||
|
||||
|
||||
# Handle other object types by showing their class name
|
||||
else:
|
||||
class_name = element.__class__.__name__
|
||||
text_lines.append(f"{indent}[{class_name}]")
|
||||
|
||||
|
||||
# Helper function to extract text from paragraph-like objects
|
||||
def extract_text_from_paragraph(para_obj):
|
||||
words = []
|
||||
@ -162,44 +160,44 @@ def extract_text_from_page(page: Page) -> str:
|
||||
words.append(str(word))
|
||||
except Exception as e:
|
||||
return f"(Text extraction error: {str(e)})"
|
||||
|
||||
|
||||
return ' '.join(words) if words else "(No text)"
|
||||
|
||||
|
||||
# Extract text from page children
|
||||
if hasattr(page, '_children'):
|
||||
for child in page._children:
|
||||
extract_from_element(child)
|
||||
|
||||
|
||||
# If no text was extracted, add a note
|
||||
if len(text_lines) <= 2: # Only header and empty line
|
||||
text_lines.append("(No text content found)")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
text_lines.append(f"Error extracting text: {str(e)}")
|
||||
import traceback
|
||||
text_lines.append(traceback.format_exc())
|
||||
|
||||
|
||||
return "\n".join(text_lines)
|
||||
|
||||
|
||||
def get_all_blocks_from_book(book) -> List[Block]:
|
||||
"""
|
||||
Extract all blocks from all chapters in the book.
|
||||
|
||||
|
||||
Args:
|
||||
book: The Book object from epub_reader
|
||||
|
||||
|
||||
Returns:
|
||||
List of all Block objects
|
||||
"""
|
||||
all_blocks = []
|
||||
|
||||
|
||||
# Iterate through all chapters
|
||||
for chapter in book.chapters:
|
||||
# Get blocks from the chapter
|
||||
if hasattr(chapter, '_blocks'):
|
||||
all_blocks.extend(chapter._blocks)
|
||||
|
||||
|
||||
return all_blocks
|
||||
|
||||
|
||||
@ -214,117 +212,116 @@ Examples:
|
||||
python epub_page_renderer.py book.epub --pages 10 --output-dir my_output --width 600 --height 800
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
parser.add_argument(
|
||||
'epub_file',
|
||||
help='Path to the EPUB file to render'
|
||||
)
|
||||
|
||||
|
||||
parser.add_argument(
|
||||
'--pages', '-p',
|
||||
type=int,
|
||||
default=5,
|
||||
help='Number of pages to render (default: 5)'
|
||||
)
|
||||
|
||||
|
||||
parser.add_argument(
|
||||
'--output-dir', '-o',
|
||||
default='rendered_pages',
|
||||
help='Output directory for rendered images (default: rendered_pages)'
|
||||
)
|
||||
|
||||
|
||||
parser.add_argument(
|
||||
'--width', '-w',
|
||||
type=int,
|
||||
default=800,
|
||||
help='Page width in pixels (default: 800)'
|
||||
)
|
||||
|
||||
|
||||
parser.add_argument(
|
||||
'--height', '-t',
|
||||
type=int,
|
||||
default=1000,
|
||||
help='Page height in pixels (default: 1000)'
|
||||
)
|
||||
|
||||
|
||||
parser.add_argument(
|
||||
'--margin', '-m',
|
||||
type=int,
|
||||
default=40,
|
||||
help='Page margin in pixels (default: 40)'
|
||||
)
|
||||
|
||||
|
||||
parser.add_argument(
|
||||
'--align', '-a',
|
||||
choices=['left', 'justify'],
|
||||
default='left',
|
||||
help='Text alignment: left or justify (default: left)'
|
||||
)
|
||||
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
# Validate arguments
|
||||
if not os.path.exists(args.epub_file):
|
||||
print(f"Error: EPUB file '{args.epub_file}' not found")
|
||||
return 1
|
||||
|
||||
|
||||
if args.pages <= 0:
|
||||
print("Error: Number of pages must be positive")
|
||||
return 1
|
||||
|
||||
|
||||
# Create output directory
|
||||
try:
|
||||
os.makedirs(args.output_dir, exist_ok=True)
|
||||
except OSError as e:
|
||||
print(f"Error creating output directory: {e}")
|
||||
return 1
|
||||
|
||||
|
||||
print(f"Loading EPUB file: {args.epub_file}")
|
||||
|
||||
|
||||
# Load the EPUB file
|
||||
try:
|
||||
book = read_epub(args.epub_file)
|
||||
print(f"Successfully loaded EPUB: {book.get_title() or 'Unknown Title'}")
|
||||
|
||||
|
||||
# Print book information
|
||||
author = book.get_metadata('AUTHOR')
|
||||
if author:
|
||||
print(f"Author: {author}")
|
||||
|
||||
|
||||
print(f"Chapters: {len(book.chapters) if hasattr(book, 'chapters') else 'Unknown'}")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error loading EPUB file: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
|
||||
|
||||
# Extract all blocks from the book
|
||||
print("Extracting content blocks...")
|
||||
try:
|
||||
all_blocks = get_all_blocks_from_book(book)
|
||||
print(f"Extracted {len(all_blocks)} content blocks")
|
||||
|
||||
|
||||
if not all_blocks:
|
||||
print("No content blocks found in EPUB. The book might be empty.")
|
||||
return 1
|
||||
|
||||
|
||||
# Apply alignment setting to all paragraphs and headings
|
||||
from pyWebLayout.abstract.block import Paragraph, Heading
|
||||
from pyWebLayout.style.alignment import Alignment
|
||||
|
||||
|
||||
alignment = Alignment.JUSTIFY if args.align == 'justify' else Alignment.LEFT
|
||||
print(f"Applying {args.align} alignment to all text blocks...")
|
||||
|
||||
|
||||
# Note: We'll pass alignment to the layouter which will handle it during rendering
|
||||
# The alignment is applied at the Line level in paragraph_layouter
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error extracting blocks: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
|
||||
|
||||
# Set up page style and layouter
|
||||
page_size = (args.width, args.height)
|
||||
page_style = PageStyle(
|
||||
@ -335,9 +332,9 @@ Examples:
|
||||
line_spacing=5,
|
||||
inter_block_spacing=15
|
||||
)
|
||||
|
||||
|
||||
print(f"Setting up layouter with page size {page_size} and {args.align} alignment")
|
||||
|
||||
|
||||
try:
|
||||
layouter = BidirectionalLayouter(
|
||||
blocks=all_blocks,
|
||||
@ -350,83 +347,83 @@ Examples:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
|
||||
|
||||
# Render pages
|
||||
print(f"Rendering up to {args.pages} pages...")
|
||||
|
||||
|
||||
try:
|
||||
pages = []
|
||||
current_position = RenderingPosition() # Start from beginning
|
||||
|
||||
|
||||
for page_num in range(args.pages):
|
||||
print(f"Rendering page {page_num + 1}/{args.pages}...")
|
||||
|
||||
|
||||
try:
|
||||
# Render the page
|
||||
page, next_position = layouter.render_page_forward(current_position)
|
||||
pages.append(page)
|
||||
|
||||
|
||||
# Check if we've reached the end of the document
|
||||
if next_position.block_index >= len(all_blocks):
|
||||
print(f"Reached end of document after {page_num + 1} pages")
|
||||
break
|
||||
|
||||
|
||||
# Update position for next page
|
||||
current_position = next_position
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error rendering page {page_num + 1}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
break
|
||||
|
||||
|
||||
if not pages:
|
||||
print("No pages were generated.")
|
||||
return 1
|
||||
|
||||
|
||||
print(f"Generated {len(pages)} pages")
|
||||
|
||||
|
||||
# Save each page to an image and extract text
|
||||
for i, page in enumerate(pages):
|
||||
print(f"Saving page {i + 1}/{len(pages)}...")
|
||||
|
||||
|
||||
try:
|
||||
# Create image from page using pyWebLayout's built-in rendering
|
||||
image = render_page_to_image(page)
|
||||
|
||||
|
||||
# Save the image
|
||||
output_filename = f"page_{i + 1:03d}.png"
|
||||
output_path = os.path.join(args.output_dir, output_filename)
|
||||
image.save(output_path, 'PNG')
|
||||
|
||||
|
||||
# Extract and save text content for verification
|
||||
page_text = extract_text_from_page(page)
|
||||
text_filename = f"page_{i + 1:03d}.txt"
|
||||
text_path = os.path.join(args.output_dir, text_filename)
|
||||
with open(text_path, 'w', encoding='utf-8') as f:
|
||||
f.write(page_text)
|
||||
|
||||
|
||||
print(f"Saved: {output_path} and {text_path}")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error saving page {i + 1}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
continue
|
||||
|
||||
|
||||
print(f"\nCompleted! Rendered {len(pages)} pages to {args.output_dir}")
|
||||
|
||||
|
||||
# Calculate progress through the book
|
||||
if len(all_blocks) > 0:
|
||||
progress = (current_position.block_index / len(all_blocks)) * 100
|
||||
print(f"Progress through book: {progress:.1f}%")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error during pagination/rendering: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@ -23,14 +23,12 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
try:
|
||||
from pyWebLayout.io.readers.epub_reader import read_epub
|
||||
from pyWebLayout.layout.document_layouter import DocumentLayouter, paragraph_layouter, image_layouter, pagebreak_layouter
|
||||
from pyWebLayout.layout.document_layouter import paragraph_layouter, image_layouter, pagebreak_layouter
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from pyWebLayout.style.fonts import Font
|
||||
from pyWebLayout.style.alignment import Alignment
|
||||
from pyWebLayout.abstract.block import Block, Paragraph, Heading, HList, Table, Image as AbstractImage, PageBreak
|
||||
from pyWebLayout.style.concrete_style import RenderingContext, StyleResolver
|
||||
from PIL import Image, ImageDraw
|
||||
from pyWebLayout.style.concrete_style import RenderingContext
|
||||
except ImportError as e:
|
||||
print(f"Error importing required modules: {e}")
|
||||
print("Make sure pyWebLayout is properly installed and PIL is available")
|
||||
@ -40,32 +38,32 @@ except ImportError as e:
|
||||
def get_all_blocks_from_book(book) -> List[Block]:
|
||||
"""
|
||||
Extract all blocks from all chapters in the book.
|
||||
|
||||
|
||||
Args:
|
||||
book: The Book object from epub_reader
|
||||
|
||||
|
||||
Returns:
|
||||
List of all Block objects
|
||||
"""
|
||||
all_blocks = []
|
||||
|
||||
|
||||
# Iterate through all chapters
|
||||
for chapter in book.chapters:
|
||||
# Get blocks from the chapter
|
||||
if hasattr(chapter, '_blocks'):
|
||||
all_blocks.extend(chapter._blocks)
|
||||
|
||||
|
||||
return all_blocks
|
||||
|
||||
|
||||
def create_page(page_style: PageStyle, page_size: Tuple[int, int]) -> Page:
|
||||
"""
|
||||
Create a new Page with the given style and size.
|
||||
|
||||
|
||||
Args:
|
||||
page_style: Style configuration for the page
|
||||
page_size: (width, height) tuple
|
||||
|
||||
|
||||
Returns:
|
||||
A new Page object
|
||||
"""
|
||||
@ -73,23 +71,23 @@ def create_page(page_style: PageStyle, page_size: Tuple[int, int]) -> Page:
|
||||
size=page_size,
|
||||
style=page_style
|
||||
)
|
||||
|
||||
|
||||
return page
|
||||
|
||||
|
||||
def layout_blocks_on_pages(blocks: List[Block], page_style: PageStyle,
|
||||
def layout_blocks_on_pages(blocks: List[Block], page_style: PageStyle,
|
||||
page_size: Tuple[int, int], max_pages: int,
|
||||
alignment_override: Optional[Alignment] = None) -> List[Page]:
|
||||
"""
|
||||
Layout blocks across multiple pages using DocumentLayouter.
|
||||
|
||||
|
||||
Args:
|
||||
blocks: List of abstract blocks to layout
|
||||
page_style: Style configuration for pages
|
||||
page_size: (width, height) tuple for pages
|
||||
max_pages: Maximum number of pages to generate
|
||||
alignment_override: Optional alignment to override paragraph alignment
|
||||
|
||||
|
||||
Returns:
|
||||
List of rendered Page objects
|
||||
"""
|
||||
@ -97,29 +95,29 @@ def layout_blocks_on_pages(blocks: List[Block], page_style: PageStyle,
|
||||
current_block_index = 0
|
||||
continuation_word_index = 0
|
||||
continuation_pretext = None
|
||||
|
||||
|
||||
# Create rendering context
|
||||
rendering_context = RenderingContext(base_font_size=16)
|
||||
|
||||
_rendering_context = RenderingContext(base_font_size=16)
|
||||
|
||||
while current_block_index < len(blocks) and len(pages) < max_pages:
|
||||
# Create a new page
|
||||
page = create_page(page_style, page_size)
|
||||
page_has_content = False
|
||||
|
||||
|
||||
# Try to layout blocks on this page
|
||||
while current_block_index < len(blocks):
|
||||
block = blocks[current_block_index]
|
||||
|
||||
|
||||
if isinstance(block, (Paragraph, Heading)):
|
||||
# Layout paragraph/heading
|
||||
success, failed_word_index, remaining_pretext = paragraph_layouter(
|
||||
block,
|
||||
page,
|
||||
block,
|
||||
page,
|
||||
start_word=continuation_word_index,
|
||||
pretext=continuation_pretext,
|
||||
alignment_override=alignment_override
|
||||
)
|
||||
|
||||
|
||||
if success:
|
||||
# Block fully laid out, move to next block
|
||||
page_has_content = True
|
||||
@ -135,11 +133,11 @@ def layout_blocks_on_pages(blocks: List[Block], page_style: PageStyle,
|
||||
continuation_pretext = remaining_pretext
|
||||
# Break to create a new page
|
||||
break
|
||||
|
||||
|
||||
elif isinstance(block, AbstractImage):
|
||||
# Layout image
|
||||
success = image_layouter(block, page)
|
||||
|
||||
|
||||
if success:
|
||||
page_has_content = True
|
||||
current_block_index += 1
|
||||
@ -148,7 +146,7 @@ def layout_blocks_on_pages(blocks: List[Block], page_style: PageStyle,
|
||||
else:
|
||||
# Image doesn't fit, try on next page
|
||||
break
|
||||
|
||||
|
||||
elif isinstance(block, HList):
|
||||
# Layout list items as paragraphs
|
||||
try:
|
||||
@ -162,53 +160,53 @@ def layout_blocks_on_pages(blocks: List[Block], page_style: PageStyle,
|
||||
pretext=continuation_pretext,
|
||||
alignment_override=alignment_override
|
||||
)
|
||||
|
||||
|
||||
if not success:
|
||||
# Can't fit more on this page
|
||||
page_has_content = True
|
||||
break
|
||||
|
||||
|
||||
continuation_word_index = 0
|
||||
continuation_pretext = None
|
||||
|
||||
|
||||
# Move to next block after processing list
|
||||
page_has_content = True
|
||||
current_block_index += 1
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f"Warning: Error processing list: {e}")
|
||||
current_block_index += 1
|
||||
|
||||
|
||||
elif isinstance(block, PageBreak):
|
||||
# PageBreak forces a new page
|
||||
success = pagebreak_layouter(block, page)
|
||||
|
||||
|
||||
# Mark that we've seen this block
|
||||
current_block_index += 1
|
||||
continuation_word_index = 0
|
||||
continuation_pretext = None
|
||||
|
||||
|
||||
# PageBreak always returns False to force new page
|
||||
# Break to create a new page for subsequent content
|
||||
break
|
||||
|
||||
|
||||
elif isinstance(block, Table):
|
||||
# Skip tables for now (not implemented)
|
||||
print(f"Warning: Skipping table (not yet implemented)")
|
||||
print("Warning: Skipping table (not yet implemented)")
|
||||
current_block_index += 1
|
||||
|
||||
|
||||
else:
|
||||
# Unknown block type, skip
|
||||
print(f"Warning: Skipping unknown block type: {type(block).__name__}")
|
||||
current_block_index += 1
|
||||
|
||||
|
||||
# Add page if it has content
|
||||
if page_has_content:
|
||||
pages.append(page)
|
||||
else:
|
||||
# No content could be added to this page, stop
|
||||
break
|
||||
|
||||
|
||||
return pages
|
||||
|
||||
|
||||
@ -223,107 +221,107 @@ Examples:
|
||||
python epub_page_renderer_documentlayouter.py book.epub --pages 10 --output-dir my_output --width 600 --height 800
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
parser.add_argument(
|
||||
'epub_file',
|
||||
help='Path to the EPUB file to render'
|
||||
)
|
||||
|
||||
|
||||
parser.add_argument(
|
||||
'--pages', '-p',
|
||||
type=int,
|
||||
default=5,
|
||||
help='Number of pages to render (default: 5)'
|
||||
)
|
||||
|
||||
|
||||
parser.add_argument(
|
||||
'--output-dir', '-o',
|
||||
default='rendered_pages',
|
||||
help='Output directory for rendered images (default: rendered_pages)'
|
||||
)
|
||||
|
||||
|
||||
parser.add_argument(
|
||||
'--width', '-w',
|
||||
type=int,
|
||||
default=800,
|
||||
help='Page width in pixels (default: 800)'
|
||||
)
|
||||
|
||||
|
||||
parser.add_argument(
|
||||
'--height', '-t',
|
||||
type=int,
|
||||
default=1000,
|
||||
help='Page height in pixels (default: 1000)'
|
||||
)
|
||||
|
||||
|
||||
parser.add_argument(
|
||||
'--margin', '-m',
|
||||
type=int,
|
||||
default=40,
|
||||
help='Page margin in pixels (default: 40)'
|
||||
)
|
||||
|
||||
|
||||
parser.add_argument(
|
||||
'--align', '-a',
|
||||
choices=['left', 'justify'],
|
||||
default='left',
|
||||
help='Text alignment: left or justify (default: left)'
|
||||
)
|
||||
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
# Validate arguments
|
||||
if not os.path.exists(args.epub_file):
|
||||
print(f"Error: EPUB file '{args.epub_file}' not found")
|
||||
return 1
|
||||
|
||||
|
||||
if args.pages <= 0:
|
||||
print("Error: Number of pages must be positive")
|
||||
return 1
|
||||
|
||||
|
||||
# Create output directory
|
||||
try:
|
||||
os.makedirs(args.output_dir, exist_ok=True)
|
||||
except OSError as e:
|
||||
print(f"Error creating output directory: {e}")
|
||||
return 1
|
||||
|
||||
|
||||
print(f"Loading EPUB file: {args.epub_file}")
|
||||
|
||||
|
||||
# Load the EPUB file
|
||||
try:
|
||||
book = read_epub(args.epub_file)
|
||||
print(f"Successfully loaded EPUB: {book.get_title() or 'Unknown Title'}")
|
||||
|
||||
|
||||
# Print book information
|
||||
author = book.get_metadata('AUTHOR')
|
||||
if author:
|
||||
print(f"Author: {author}")
|
||||
|
||||
|
||||
print(f"Chapters: {len(book.chapters) if hasattr(book, 'chapters') else 'Unknown'}")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error loading EPUB file: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
|
||||
|
||||
# Extract all blocks from the book
|
||||
print("Extracting content blocks...")
|
||||
try:
|
||||
all_blocks = get_all_blocks_from_book(book)
|
||||
print(f"Extracted {len(all_blocks)} content blocks")
|
||||
|
||||
|
||||
if not all_blocks:
|
||||
print("No content blocks found in EPUB. The book might be empty.")
|
||||
return 1
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error extracting blocks: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
|
||||
|
||||
# Set up page style
|
||||
page_size = (args.width, args.height)
|
||||
page_style = PageStyle(
|
||||
@ -334,58 +332,58 @@ Examples:
|
||||
line_spacing=5,
|
||||
inter_block_spacing=15
|
||||
)
|
||||
|
||||
|
||||
# Set alignment
|
||||
alignment = Alignment.JUSTIFY if args.align == 'justify' else Alignment.LEFT
|
||||
print(f"Setting up layouter with page size {page_size} and {args.align} alignment")
|
||||
|
||||
|
||||
# Layout blocks on pages
|
||||
print(f"Rendering up to {args.pages} pages using DocumentLayouter...")
|
||||
|
||||
|
||||
try:
|
||||
pages = layout_blocks_on_pages(
|
||||
all_blocks,
|
||||
page_style,
|
||||
page_size,
|
||||
all_blocks,
|
||||
page_style,
|
||||
page_size,
|
||||
args.pages,
|
||||
alignment_override=alignment
|
||||
)
|
||||
|
||||
|
||||
if not pages:
|
||||
print("No pages were generated.")
|
||||
return 1
|
||||
|
||||
|
||||
print(f"Generated {len(pages)} pages")
|
||||
|
||||
|
||||
# Save each page to an image
|
||||
for i, page in enumerate(pages):
|
||||
print(f"Saving page {i + 1}/{len(pages)}...")
|
||||
|
||||
|
||||
try:
|
||||
# Render the page
|
||||
image = page.render()
|
||||
|
||||
|
||||
# Save the image
|
||||
output_filename = f"page_{i + 1:03d}.png"
|
||||
output_path = os.path.join(args.output_dir, output_filename)
|
||||
image.save(output_path, 'PNG')
|
||||
|
||||
|
||||
print(f"Saved: {output_path}")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error saving page {i + 1}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
continue
|
||||
|
||||
|
||||
print(f"\nCompleted! Rendered {len(pages)} pages to {args.output_dir}")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error during pagination/rendering: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@ -5,7 +5,6 @@ Runs test and documentation coverage locally and generates badges.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
|
||||
|
||||
@ -15,7 +14,7 @@ def run_command(cmd, description):
|
||||
print(f"Running: {description}")
|
||||
print(f"Command: {cmd}")
|
||||
print(f"{'='*50}")
|
||||
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, shell=True, check=True, capture_output=True, text=True)
|
||||
print(result.stdout)
|
||||
@ -34,11 +33,11 @@ def main():
|
||||
"""Run full coverage analysis locally."""
|
||||
print("Local Coverage Analysis for pyWebLayout")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
# Change to project root if running from scripts directory
|
||||
if os.path.basename(os.getcwd()) == "scripts":
|
||||
os.chdir("..")
|
||||
|
||||
|
||||
# Install required packages
|
||||
print("\n1. Installing required packages...")
|
||||
packages = [
|
||||
@ -46,28 +45,35 @@ def main():
|
||||
"coverage-badge",
|
||||
"interrogate"
|
||||
]
|
||||
|
||||
|
||||
for package in packages:
|
||||
if not run_command(f"pip install {package}", f"Installing {package}"):
|
||||
print(f"Failed to install {package}, continuing...")
|
||||
|
||||
|
||||
# Run tests with coverage
|
||||
print("\n2. Running tests with coverage...")
|
||||
test_cmd = "python -m pytest tests/ -v --cov=pyWebLayout --cov-report=term-missing --cov-report=json --cov-report=html --cov-report=xml"
|
||||
test_cmd = (
|
||||
"python -m pytest tests/ -v --cov=pyWebLayout "
|
||||
"--cov-report=term-missing --cov-report=json --cov-report=html --cov-report=xml"
|
||||
)
|
||||
run_command(test_cmd, "Running tests with coverage")
|
||||
|
||||
|
||||
# Generate test coverage badge
|
||||
print("\n3. Generating test coverage badge...")
|
||||
run_command("coverage-badge -o coverage.svg -f", "Generating test coverage badge")
|
||||
|
||||
|
||||
# Check documentation coverage
|
||||
print("\n4. Checking documentation coverage...")
|
||||
docs_cmd = "interrogate -v --ignore-init-method --ignore-init-module --ignore-magic --ignore-private --ignore-property-decorators --ignore-semiprivate --fail-under=80 --generate-badge coverage-docs.svg pyWebLayout/"
|
||||
docs_cmd = (
|
||||
"interrogate -v --ignore-init-method --ignore-init-module --ignore-magic "
|
||||
"--ignore-private --ignore-property-decorators --ignore-semiprivate "
|
||||
"--fail-under=80 --generate-badge coverage-docs.svg pyWebLayout/"
|
||||
)
|
||||
run_command(docs_cmd, "Checking documentation coverage")
|
||||
|
||||
|
||||
# Generate coverage summary
|
||||
print("\n5. Generating coverage summary...")
|
||||
|
||||
|
||||
# Write a temporary script to avoid shell quoting issues
|
||||
summary_script_content = '''import json
|
||||
import os
|
||||
@ -75,30 +81,30 @@ import os
|
||||
if os.path.exists("coverage.json"):
|
||||
with open("coverage.json", "r") as f:
|
||||
coverage_data = json.load(f)
|
||||
|
||||
|
||||
total_coverage = round(coverage_data["totals"]["percent_covered"], 1)
|
||||
covered_lines = coverage_data["totals"]["covered_lines"]
|
||||
total_lines = coverage_data["totals"]["num_statements"]
|
||||
|
||||
|
||||
with open("coverage-summary.txt", "w") as f:
|
||||
f.write(f"{total_coverage}%")
|
||||
|
||||
|
||||
print(f"Test Coverage: {total_coverage}%")
|
||||
print(f"Lines Covered: {covered_lines}/{total_lines}")
|
||||
else:
|
||||
print("No coverage data found")
|
||||
'''
|
||||
|
||||
|
||||
# Write and execute temporary script
|
||||
with open('temp_coverage_summary.py', 'w') as f:
|
||||
f.write(summary_script_content)
|
||||
|
||||
success = run_command("python temp_coverage_summary.py", "Generating coverage summary")
|
||||
|
||||
|
||||
run_command("python temp_coverage_summary.py", "Generating coverage summary")
|
||||
|
||||
# Clean up temporary script
|
||||
if os.path.exists('temp_coverage_summary.py'):
|
||||
os.remove('temp_coverage_summary.py')
|
||||
|
||||
|
||||
# List generated files
|
||||
print("\n6. Generated files:")
|
||||
files = ["coverage.svg", "coverage-docs.svg", "coverage-summary.txt", "htmlcov/", "coverage.json", "coverage.xml"]
|
||||
@ -107,7 +113,7 @@ else:
|
||||
print(f" ✓ {file}")
|
||||
else:
|
||||
print(f" ✗ {file} (not found)")
|
||||
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("Coverage analysis complete!")
|
||||
print("To update your README with badges, run:")
|
||||
|
||||
@ -14,23 +14,23 @@ def main():
|
||||
"""Run coverage for Coverage Gutters."""
|
||||
print("Generating coverage for Coverage Gutters...")
|
||||
print("Using the same pytest approach as CI...")
|
||||
|
||||
|
||||
try:
|
||||
# Run tests with coverage and generate all report formats (same as CI)
|
||||
cmd = [
|
||||
sys.executable, "-m", "pytest",
|
||||
"tests/",
|
||||
sys.executable, "-m", "pytest",
|
||||
"tests/",
|
||||
"-v",
|
||||
"--cov=pyWebLayout",
|
||||
"--cov=pyWebLayout",
|
||||
"--cov-report=term-missing",
|
||||
"--cov-report=json",
|
||||
"--cov-report=html",
|
||||
"--cov-report=xml"
|
||||
]
|
||||
|
||||
|
||||
print(f"Running: {' '.join(cmd)}")
|
||||
result = subprocess.run(cmd, check=True)
|
||||
|
||||
_result = subprocess.run(cmd, check=True)
|
||||
|
||||
# Check if coverage.xml was created
|
||||
if os.path.exists("coverage.xml"):
|
||||
print("✓ coverage.xml generated successfully!")
|
||||
@ -42,14 +42,14 @@ def main():
|
||||
print("2. Run 'Coverage Gutters: Remove Coverage' (to clear cache)")
|
||||
print("3. Run 'Coverage Gutters: Display Coverage'")
|
||||
print("4. Or use the Coverage Gutters buttons in the status bar")
|
||||
|
||||
|
||||
# Show file info
|
||||
size = os.path.getsize("coverage.xml")
|
||||
print(f"\nGenerated coverage.xml: {size} bytes")
|
||||
|
||||
|
||||
else:
|
||||
print("✗ coverage.xml was not generated")
|
||||
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error running tests: {e}")
|
||||
print("This may indicate test failures or missing dependencies.")
|
||||
|
||||
@ -12,19 +12,19 @@ import sys
|
||||
def update_readme_badges():
|
||||
"""Update README.md with coverage badges."""
|
||||
readme_path = "README.md"
|
||||
|
||||
|
||||
if not os.path.exists(readme_path):
|
||||
print("README.md not found!")
|
||||
return False
|
||||
|
||||
|
||||
# Read current README
|
||||
with open(readme_path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
|
||||
# Coverage badges to add/update
|
||||
test_coverage_badge = ""
|
||||
docs_coverage_badge = ""
|
||||
|
||||
|
||||
# Check if badges already exist and update them, otherwise add them at the top
|
||||
if "![Test Coverage]" in content:
|
||||
content = re.sub(r'!\[Test Coverage\]\([^)]+\)', test_coverage_badge, content)
|
||||
@ -34,7 +34,7 @@ def update_readme_badges():
|
||||
if len(lines) > 0:
|
||||
lines.insert(1, f"\n{test_coverage_badge}")
|
||||
content = '\n'.join(lines)
|
||||
|
||||
|
||||
if "![Documentation Coverage]" in content:
|
||||
content = re.sub(r'!\[Documentation Coverage\]\([^)]+\)', docs_coverage_badge, content)
|
||||
else:
|
||||
@ -45,11 +45,11 @@ def update_readme_badges():
|
||||
lines.insert(i + 1, docs_coverage_badge)
|
||||
break
|
||||
content = '\n'.join(lines)
|
||||
|
||||
|
||||
# Write updated README
|
||||
with open(readme_path, 'w') as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
print("README.md updated with coverage badges!")
|
||||
return True
|
||||
|
||||
@ -60,7 +60,7 @@ def show_coverage_summary():
|
||||
with open("coverage-summary.txt", 'r') as f:
|
||||
test_coverage = f.read().strip()
|
||||
print(f"Current Test Coverage: {test_coverage}")
|
||||
|
||||
|
||||
# Try to get documentation coverage from interrogate output
|
||||
if os.path.exists("coverage.json"):
|
||||
import json
|
||||
@ -68,7 +68,9 @@ def show_coverage_summary():
|
||||
with open("coverage.json", 'r') as f:
|
||||
coverage_data = json.load(f)
|
||||
print(f"Detailed Coverage: {coverage_data['totals']['percent_covered']:.1f}%")
|
||||
print(f"Lines Covered: {coverage_data['totals']['covered_lines']}/{coverage_data['totals']['num_statements']}")
|
||||
covered = coverage_data['totals']['covered_lines']
|
||||
total = coverage_data['totals']['num_statements']
|
||||
print(f"Lines Covered: {covered}/{total}")
|
||||
except (KeyError, json.JSONDecodeError):
|
||||
print("Could not parse coverage data")
|
||||
|
||||
|
||||
@ -10,18 +10,18 @@ import json
|
||||
|
||||
def main():
|
||||
"""Main function to fix coverage gutters configuration."""
|
||||
|
||||
|
||||
print("=== Coverage Gutters Fix ===")
|
||||
print(f"Current working directory: {os.getcwd()}")
|
||||
|
||||
|
||||
# 1. Check if coverage.xml exists
|
||||
if os.path.exists('coverage.xml'):
|
||||
print("✓ coverage.xml exists")
|
||||
|
||||
|
||||
# Check file size and basic content
|
||||
size = os.path.getsize('coverage.xml')
|
||||
print(f"✓ coverage.xml size: {size} bytes")
|
||||
|
||||
|
||||
# Read first few lines to verify it's valid XML
|
||||
try:
|
||||
with open('coverage.xml', 'r') as f:
|
||||
@ -37,12 +37,12 @@ def main():
|
||||
print("Running coverage to generate coverage.xml...")
|
||||
os.system("python -m coverage run --source=pyWebLayout -m unittest tests.test_abstract_inline")
|
||||
os.system("python -m coverage xml")
|
||||
|
||||
|
||||
# 2. Check VSCode settings
|
||||
vscode_settings_path = '.vscode/settings.json'
|
||||
if os.path.exists(vscode_settings_path):
|
||||
print("✓ VSCode settings.json exists")
|
||||
|
||||
|
||||
with open(vscode_settings_path, 'r') as f:
|
||||
try:
|
||||
settings = json.load(f)
|
||||
@ -57,18 +57,18 @@ def main():
|
||||
print(f"✗ Error parsing VSCode settings: {e}")
|
||||
else:
|
||||
print("✗ VSCode settings.json not found")
|
||||
|
||||
|
||||
# 3. Check if inline.py file exists
|
||||
inline_file = 'pyWebLayout/abstract/inline.py'
|
||||
if os.path.exists(inline_file):
|
||||
print(f"✓ {inline_file} exists")
|
||||
|
||||
|
||||
# Check file size
|
||||
size = os.path.getsize(inline_file)
|
||||
print(f"✓ {inline_file} size: {size} bytes")
|
||||
else:
|
||||
print(f"✗ {inline_file} does not exist")
|
||||
|
||||
|
||||
# 4. Run a fresh coverage collection specifically for the inline module
|
||||
print("\n=== Running Fresh Coverage ===")
|
||||
try:
|
||||
@ -80,7 +80,7 @@ def main():
|
||||
print("✓ Fresh coverage data generated")
|
||||
except Exception as e:
|
||||
print(f"✗ Error generating coverage: {e}")
|
||||
|
||||
|
||||
# 5. Instructions for manual verification
|
||||
print("\n=== Manual Verification Steps ===")
|
||||
print("1. In VSCode, open the Command Palette (Ctrl+Shift+P)")
|
||||
@ -90,7 +90,7 @@ def main():
|
||||
print(" - 'Coverage Gutters: Display Coverage' again")
|
||||
print("4. Check that coverage.xml contains data for pyWebLayout/abstract/inline.py")
|
||||
print("5. The file should show 100% coverage (all lines covered)")
|
||||
|
||||
|
||||
print("\n=== Troubleshooting ===")
|
||||
print("If coverage still doesn't show:")
|
||||
print("1. Restart VSCode")
|
||||
|
||||
@ -12,9 +12,9 @@ import threading
|
||||
import time
|
||||
from PIL import Image as PILImage
|
||||
from pyWebLayout.abstract.block import (
|
||||
Block, BlockType, Paragraph, Heading, HeadingLevel, Quote, CodeBlock,
|
||||
HList, ListStyle, ListItem, Table, TableRow, TableCell,
|
||||
HorizontalRule, Image
|
||||
BlockType, Paragraph, Heading, HeadingLevel, Quote, CodeBlock, HList,
|
||||
ListStyle, ListItem, Table, TableRow, TableCell, HorizontalRule,
|
||||
Image
|
||||
)
|
||||
from pyWebLayout.abstract.inline import Word, LineBreak
|
||||
from pyWebLayout.style import Font
|
||||
@ -29,256 +29,256 @@ except ImportError:
|
||||
|
||||
class TestBlockElements(unittest.TestCase):
|
||||
"""Test cases for basic block elements."""
|
||||
|
||||
|
||||
def test_paragraph_creation(self):
|
||||
"""Test creating and using paragraphs."""
|
||||
paragraph = Paragraph()
|
||||
|
||||
|
||||
self.assertEqual(paragraph.block_type, BlockType.PARAGRAPH)
|
||||
self.assertEqual(paragraph.word_count, 0)
|
||||
self.assertIsNone(paragraph.parent)
|
||||
|
||||
|
||||
# Add words
|
||||
font = Font()
|
||||
word1 = Word("Hello", font)
|
||||
word2 = Word("World", font)
|
||||
|
||||
|
||||
paragraph.add_word(word1)
|
||||
paragraph.add_word(word2)
|
||||
|
||||
|
||||
self.assertEqual(paragraph.word_count, 2)
|
||||
|
||||
|
||||
# Test word iteration
|
||||
words = list(paragraph.words_iter())
|
||||
self.assertEqual(len(words), 2)
|
||||
self.assertEqual(words[0][1].text, "Hello")
|
||||
self.assertEqual(words[1][1].text, "World")
|
||||
|
||||
|
||||
def test_heading_levels(self):
|
||||
"""Test heading creation with different levels."""
|
||||
h1 = Heading(HeadingLevel.H1)
|
||||
h3 = Heading(HeadingLevel.H3)
|
||||
h6 = Heading(HeadingLevel.H6)
|
||||
|
||||
|
||||
self.assertEqual(h1.level, HeadingLevel.H1)
|
||||
self.assertEqual(h3.level, HeadingLevel.H3)
|
||||
self.assertEqual(h6.level, HeadingLevel.H6)
|
||||
|
||||
|
||||
self.assertEqual(h1.block_type, BlockType.HEADING)
|
||||
|
||||
|
||||
# Test level modification
|
||||
h1.level = HeadingLevel.H2
|
||||
self.assertEqual(h1.level, HeadingLevel.H2)
|
||||
|
||||
|
||||
def test_quote_nesting(self):
|
||||
"""Test blockquote with nested content."""
|
||||
quote = Quote()
|
||||
|
||||
|
||||
# Add nested paragraphs
|
||||
p1 = Paragraph()
|
||||
p2 = Paragraph()
|
||||
|
||||
|
||||
quote.add_block(p1)
|
||||
quote.add_block(p2)
|
||||
|
||||
|
||||
self.assertEqual(p1.parent, quote)
|
||||
self.assertEqual(p2.parent, quote)
|
||||
|
||||
|
||||
# Test block iteration
|
||||
blocks = list(quote.blocks())
|
||||
self.assertEqual(len(blocks), 2)
|
||||
self.assertEqual(blocks[0], p1)
|
||||
self.assertEqual(blocks[1], p2)
|
||||
|
||||
|
||||
def test_code_block(self):
|
||||
"""Test code block functionality."""
|
||||
code = CodeBlock("python")
|
||||
|
||||
|
||||
self.assertEqual(code.language, "python")
|
||||
self.assertEqual(code.line_count, 0)
|
||||
|
||||
|
||||
# Add code lines
|
||||
code.add_line("def hello():")
|
||||
code.add_line(" print('Hello!')")
|
||||
|
||||
|
||||
self.assertEqual(code.line_count, 2)
|
||||
|
||||
|
||||
# Test line iteration
|
||||
lines = list(code.lines())
|
||||
self.assertEqual(len(lines), 2)
|
||||
self.assertEqual(lines[0][1], "def hello():")
|
||||
self.assertEqual(lines[1][1], " print('Hello!')")
|
||||
|
||||
|
||||
# Test language modification
|
||||
code.language = "javascript"
|
||||
self.assertEqual(code.language, "javascript")
|
||||
|
||||
|
||||
def test_list_creation(self):
|
||||
"""Test list creation and item management."""
|
||||
# Unordered list
|
||||
ul = HList(ListStyle.UNORDERED)
|
||||
self.assertEqual(ul.style, ListStyle.UNORDERED)
|
||||
self.assertEqual(ul.item_count, 0)
|
||||
|
||||
|
||||
# Add list items
|
||||
item1 = ListItem()
|
||||
item2 = ListItem()
|
||||
|
||||
|
||||
ul.add_item(item1)
|
||||
ul.add_item(item2)
|
||||
|
||||
|
||||
self.assertEqual(ul.item_count, 2)
|
||||
self.assertEqual(item1.parent, ul)
|
||||
self.assertEqual(item2.parent, ul)
|
||||
|
||||
|
||||
# Test item iteration
|
||||
items = list(ul.items())
|
||||
self.assertEqual(len(items), 2)
|
||||
|
||||
|
||||
# Test list style change
|
||||
ul.style = ListStyle.ORDERED
|
||||
self.assertEqual(ul.style, ListStyle.ORDERED)
|
||||
|
||||
|
||||
def test_definition_list(self):
|
||||
"""Test definition list with terms."""
|
||||
dl = HList(ListStyle.DEFINITION)
|
||||
|
||||
|
||||
# Add definition items with terms
|
||||
dt1 = ListItem(term="Python")
|
||||
dt2 = ListItem(term="JavaScript")
|
||||
|
||||
|
||||
dl.add_item(dt1)
|
||||
dl.add_item(dt2)
|
||||
|
||||
|
||||
self.assertEqual(dt1.term, "Python")
|
||||
self.assertEqual(dt2.term, "JavaScript")
|
||||
|
||||
|
||||
# Test term modification
|
||||
dt1.term = "Python 3"
|
||||
self.assertEqual(dt1.term, "Python 3")
|
||||
|
||||
|
||||
def test_table_structure(self):
|
||||
"""Test table, row, and cell structure."""
|
||||
table = Table(caption="Test Table")
|
||||
|
||||
|
||||
self.assertEqual(table.caption, "Test Table")
|
||||
self.assertEqual(table.row_count["total"], 0)
|
||||
|
||||
|
||||
# Create rows and cells
|
||||
header_row = TableRow()
|
||||
data_row = TableRow()
|
||||
|
||||
|
||||
# Header cells
|
||||
h1 = TableCell(is_header=True)
|
||||
h2 = TableCell(is_header=True)
|
||||
header_row.add_cell(h1)
|
||||
header_row.add_cell(h2)
|
||||
|
||||
|
||||
# Data cells
|
||||
d1 = TableCell(is_header=False)
|
||||
d2 = TableCell(is_header=False, colspan=2)
|
||||
data_row.add_cell(d1)
|
||||
data_row.add_cell(d2)
|
||||
|
||||
|
||||
# Add rows to table
|
||||
table.add_row(header_row, "header")
|
||||
table.add_row(data_row, "body")
|
||||
|
||||
|
||||
# Test structure
|
||||
self.assertEqual(table.row_count["header"], 1)
|
||||
self.assertEqual(table.row_count["body"], 1)
|
||||
self.assertEqual(table.row_count["total"], 2)
|
||||
|
||||
|
||||
# Test cell properties
|
||||
self.assertTrue(h1.is_header)
|
||||
self.assertFalse(d1.is_header)
|
||||
self.assertEqual(d2.colspan, 2)
|
||||
self.assertEqual(d2.rowspan, 1) # Default
|
||||
|
||||
|
||||
# Test row cell count
|
||||
self.assertEqual(header_row.cell_count, 2)
|
||||
self.assertEqual(data_row.cell_count, 2)
|
||||
|
||||
|
||||
def test_table_sections(self):
|
||||
"""Test table header, body, and footer sections."""
|
||||
table = Table()
|
||||
|
||||
|
||||
# Add rows to different sections
|
||||
header = TableRow()
|
||||
body1 = TableRow()
|
||||
body2 = TableRow()
|
||||
footer = TableRow()
|
||||
|
||||
|
||||
table.add_row(header, "header")
|
||||
table.add_row(body1, "body")
|
||||
table.add_row(body2, "body")
|
||||
table.add_row(footer, "footer")
|
||||
|
||||
|
||||
# Test section iteration
|
||||
header_rows = list(table.header_rows())
|
||||
body_rows = list(table.body_rows())
|
||||
footer_rows = list(table.footer_rows())
|
||||
|
||||
|
||||
self.assertEqual(len(header_rows), 1)
|
||||
self.assertEqual(len(body_rows), 2)
|
||||
self.assertEqual(len(footer_rows), 1)
|
||||
|
||||
|
||||
# Test all_rows iteration
|
||||
all_rows = list(table.all_rows())
|
||||
self.assertEqual(len(all_rows), 4)
|
||||
|
||||
|
||||
# Check section labels
|
||||
sections = [section for section, row in all_rows]
|
||||
self.assertEqual(sections, ["header", "body", "body", "footer"])
|
||||
|
||||
|
||||
def test_image_loading(self):
|
||||
"""Test image element properties."""
|
||||
# Test with basic properties
|
||||
img = Image("test.jpg", "Test image", 100, 200)
|
||||
|
||||
|
||||
self.assertEqual(img.source, "test.jpg")
|
||||
self.assertEqual(img.alt_text, "Test image")
|
||||
self.assertEqual(img.width, 100)
|
||||
self.assertEqual(img.height, 200)
|
||||
|
||||
|
||||
# Test property modification
|
||||
img.source = "new.png"
|
||||
img.alt_text = "New image"
|
||||
img.width = 150
|
||||
img.height = 300
|
||||
|
||||
|
||||
self.assertEqual(img.source, "new.png")
|
||||
self.assertEqual(img.alt_text, "New image")
|
||||
self.assertEqual(img.width, 150)
|
||||
self.assertEqual(img.height, 300)
|
||||
|
||||
|
||||
# Test dimensions tuple
|
||||
self.assertEqual(img.get_dimensions(), (150, 300))
|
||||
|
||||
|
||||
def test_aspect_ratio_calculation(self):
|
||||
"""Test image aspect ratio calculations."""
|
||||
# Test with specified dimensions
|
||||
img = Image("test.jpg", width=400, height=200)
|
||||
self.assertEqual(img.get_aspect_ratio(), 2.0) # 400/200
|
||||
|
||||
|
||||
# Test with only one dimension
|
||||
img2 = Image("test.jpg", width=300)
|
||||
self.assertIsNone(img2.get_aspect_ratio()) # No height specified
|
||||
|
||||
|
||||
# Test scaled dimensions
|
||||
scaled = img.calculate_scaled_dimensions(max_width=200, max_height=150)
|
||||
# Should scale down proportionally
|
||||
self.assertEqual(scaled[0], 200) # Width limited by max_width
|
||||
self.assertEqual(scaled[1], 100) # Height scaled proportionally
|
||||
|
||||
|
||||
def test_simple_elements(self):
|
||||
"""Test simple block elements."""
|
||||
hr = HorizontalRule()
|
||||
br = LineBreak()
|
||||
|
||||
|
||||
self.assertEqual(hr.block_type, BlockType.HORIZONTAL_RULE)
|
||||
self.assertEqual(br.block_type, BlockType.LINE_BREAK)
|
||||
|
||||
|
||||
# These elements have no additional properties
|
||||
self.assertIsNone(hr.parent)
|
||||
self.assertIsNone(br.parent)
|
||||
@ -286,29 +286,29 @@ class TestBlockElements(unittest.TestCase):
|
||||
|
||||
class TestImagePIL(unittest.TestCase):
|
||||
"""Test cases for Image class with PIL functionality."""
|
||||
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Set up temporary directory and test images."""
|
||||
cls.temp_dir = tempfile.mkdtemp()
|
||||
cls.sample_image_path = "tests/data/sample_image.jpg"
|
||||
|
||||
|
||||
# Create test images in different formats
|
||||
cls._create_test_images()
|
||||
|
||||
|
||||
# Start Flask server for URL testing if Flask is available
|
||||
if FLASK_AVAILABLE:
|
||||
cls._start_flask_server()
|
||||
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
"""Clean up temporary directory and stop Flask server."""
|
||||
shutil.rmtree(cls.temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if FLASK_AVAILABLE and hasattr(cls, 'flask_thread'):
|
||||
cls.flask_server_running = False
|
||||
cls.flask_thread.join(timeout=2)
|
||||
|
||||
|
||||
@classmethod
|
||||
def _create_test_images(cls):
|
||||
"""Create test images in different formats."""
|
||||
@ -316,17 +316,17 @@ class TestImagePIL(unittest.TestCase):
|
||||
if os.path.exists(cls.sample_image_path):
|
||||
with PILImage.open(cls.sample_image_path) as img:
|
||||
cls.original_size = img.size
|
||||
|
||||
|
||||
# Save in different formats
|
||||
cls.jpg_path = os.path.join(cls.temp_dir, "test.jpg")
|
||||
cls.png_path = os.path.join(cls.temp_dir, "test.png")
|
||||
cls.bmp_path = os.path.join(cls.temp_dir, "test.bmp")
|
||||
cls.gif_path = os.path.join(cls.temp_dir, "test.gif")
|
||||
|
||||
|
||||
img.save(cls.jpg_path, "JPEG")
|
||||
img.save(cls.png_path, "PNG")
|
||||
img.save(cls.bmp_path, "BMP")
|
||||
|
||||
|
||||
# Convert to RGB for GIF (GIF doesn't support transparency from RGBA)
|
||||
rgb_img = img.convert("RGB")
|
||||
rgb_img.save(cls.gif_path, "GIF")
|
||||
@ -334,17 +334,17 @@ class TestImagePIL(unittest.TestCase):
|
||||
# Create a simple test image if sample doesn't exist
|
||||
cls.original_size = (100, 100)
|
||||
test_img = PILImage.new("RGB", cls.original_size, (255, 0, 0))
|
||||
|
||||
|
||||
cls.jpg_path = os.path.join(cls.temp_dir, "test.jpg")
|
||||
cls.png_path = os.path.join(cls.temp_dir, "test.png")
|
||||
cls.bmp_path = os.path.join(cls.temp_dir, "test.bmp")
|
||||
cls.gif_path = os.path.join(cls.temp_dir, "test.gif")
|
||||
|
||||
|
||||
test_img.save(cls.jpg_path, "JPEG")
|
||||
test_img.save(cls.png_path, "PNG")
|
||||
test_img.save(cls.bmp_path, "BMP")
|
||||
test_img.save(cls.gif_path, "GIF")
|
||||
|
||||
|
||||
@classmethod
|
||||
def _start_flask_server(cls):
|
||||
"""Start a Flask server for URL testing."""
|
||||
@ -365,7 +365,7 @@ class TestImagePIL(unittest.TestCase):
|
||||
|
||||
def run_flask():
|
||||
cls.flask_app.run(host='127.0.0.1', port=cls.flask_port, debug=False,
|
||||
use_reloader=False, threaded=True)
|
||||
use_reloader=False, threaded=True)
|
||||
|
||||
cls.flask_thread = threading.Thread(target=run_flask, daemon=True)
|
||||
cls.flask_thread.start()
|
||||
@ -384,120 +384,120 @@ class TestImagePIL(unittest.TestCase):
|
||||
pass
|
||||
time.sleep(wait_interval)
|
||||
elapsed += wait_interval
|
||||
|
||||
|
||||
def test_image_url_detection(self):
|
||||
"""Test URL detection functionality."""
|
||||
img = Image()
|
||||
|
||||
|
||||
# Test URL detection
|
||||
self.assertTrue(img._is_url("http://example.com/image.jpg"))
|
||||
self.assertTrue(img._is_url("https://example.com/image.png"))
|
||||
self.assertTrue(img._is_url("ftp://example.com/image.gif"))
|
||||
|
||||
|
||||
# Test non-URL detection
|
||||
self.assertFalse(img._is_url("image.jpg"))
|
||||
self.assertFalse(img._is_url("/path/to/image.png"))
|
||||
self.assertFalse(img._is_url("../relative/path.gif"))
|
||||
self.assertFalse(img._is_url(""))
|
||||
|
||||
|
||||
def test_load_local_image_jpg(self):
|
||||
"""Test loading local JPG image."""
|
||||
img = Image(self.jpg_path)
|
||||
|
||||
|
||||
file_path, pil_img = img.load_image_data()
|
||||
|
||||
|
||||
self.assertIsNotNone(pil_img)
|
||||
self.assertEqual(file_path, self.jpg_path)
|
||||
self.assertEqual(pil_img.size, self.original_size)
|
||||
self.assertEqual(img.width, self.original_size[0])
|
||||
self.assertEqual(img.height, self.original_size[1])
|
||||
|
||||
|
||||
def test_load_local_image_png(self):
|
||||
"""Test loading local PNG image."""
|
||||
img = Image(self.png_path)
|
||||
|
||||
|
||||
file_path, pil_img = img.load_image_data()
|
||||
|
||||
|
||||
self.assertIsNotNone(pil_img)
|
||||
self.assertEqual(file_path, self.png_path)
|
||||
self.assertEqual(pil_img.size, self.original_size)
|
||||
|
||||
|
||||
def test_load_local_image_bmp(self):
|
||||
"""Test loading local BMP image."""
|
||||
img = Image(self.bmp_path)
|
||||
|
||||
|
||||
file_path, pil_img = img.load_image_data()
|
||||
|
||||
|
||||
self.assertIsNotNone(pil_img)
|
||||
self.assertEqual(file_path, self.bmp_path)
|
||||
self.assertEqual(pil_img.size, self.original_size)
|
||||
|
||||
|
||||
def test_load_local_image_gif(self):
|
||||
"""Test loading local GIF image."""
|
||||
img = Image(self.gif_path)
|
||||
|
||||
|
||||
file_path, pil_img = img.load_image_data()
|
||||
|
||||
|
||||
self.assertIsNotNone(pil_img)
|
||||
self.assertEqual(file_path, self.gif_path)
|
||||
self.assertEqual(pil_img.size, self.original_size)
|
||||
|
||||
|
||||
def test_load_nonexistent_image(self):
|
||||
"""Test loading non-existent image."""
|
||||
img = Image("nonexistent.jpg")
|
||||
|
||||
|
||||
file_path, pil_img = img.load_image_data()
|
||||
|
||||
|
||||
self.assertIsNone(pil_img)
|
||||
self.assertIsNone(file_path)
|
||||
|
||||
|
||||
def test_load_empty_source(self):
|
||||
"""Test loading with empty source."""
|
||||
img = Image("")
|
||||
|
||||
|
||||
file_path, pil_img = img.load_image_data()
|
||||
|
||||
|
||||
self.assertIsNone(pil_img)
|
||||
self.assertIsNone(file_path)
|
||||
|
||||
|
||||
def test_auto_update_dimensions(self):
|
||||
"""Test automatic dimension updating."""
|
||||
img = Image(self.jpg_path, width=50, height=50) # Wrong initial dimensions
|
||||
|
||||
|
||||
# Test with auto-update enabled (default)
|
||||
file_path, pil_img = img.load_image_data(auto_update_dimensions=True)
|
||||
|
||||
|
||||
self.assertEqual(img.width, self.original_size[0])
|
||||
self.assertEqual(img.height, self.original_size[1])
|
||||
|
||||
|
||||
def test_no_auto_update_dimensions(self):
|
||||
"""Test loading without automatic dimension updating."""
|
||||
original_width, original_height = 50, 50
|
||||
img = Image(self.jpg_path, width=original_width, height=original_height)
|
||||
|
||||
|
||||
# Test with auto-update disabled
|
||||
file_path, pil_img = img.load_image_data(auto_update_dimensions=False)
|
||||
|
||||
|
||||
self.assertEqual(img.width, original_width) # Should remain unchanged
|
||||
self.assertEqual(img.height, original_height) # Should remain unchanged
|
||||
|
||||
|
||||
def test_get_image_info(self):
|
||||
"""Test getting detailed image information."""
|
||||
img = Image(self.jpg_path)
|
||||
|
||||
|
||||
info = img.get_image_info()
|
||||
|
||||
|
||||
self.assertIsInstance(info, dict)
|
||||
self.assertIn('format', info)
|
||||
self.assertIn('mode', info)
|
||||
self.assertIn('size', info)
|
||||
self.assertIn('width', info)
|
||||
self.assertIn('height', info)
|
||||
|
||||
|
||||
self.assertEqual(info['size'], self.original_size)
|
||||
self.assertEqual(info['width'], self.original_size[0])
|
||||
self.assertEqual(info['height'], self.original_size[1])
|
||||
|
||||
|
||||
def test_get_image_info_different_formats(self):
|
||||
"""Test getting image info for different formats."""
|
||||
formats_and_paths = [
|
||||
@ -506,80 +506,80 @@ class TestImagePIL(unittest.TestCase):
|
||||
('BMP', self.bmp_path),
|
||||
('GIF', self.gif_path),
|
||||
]
|
||||
|
||||
|
||||
for expected_format, path in formats_and_paths:
|
||||
with self.subTest(format=expected_format):
|
||||
img = Image(path)
|
||||
info = img.get_image_info()
|
||||
|
||||
|
||||
self.assertEqual(info['format'], expected_format)
|
||||
self.assertEqual(info['size'], self.original_size)
|
||||
|
||||
|
||||
def test_get_image_info_nonexistent(self):
|
||||
"""Test getting image info for non-existent image."""
|
||||
img = Image("nonexistent.jpg")
|
||||
|
||||
|
||||
info = img.get_image_info()
|
||||
|
||||
|
||||
self.assertEqual(info, {})
|
||||
|
||||
|
||||
@unittest.skipUnless(FLASK_AVAILABLE, "Flask not available for URL testing")
|
||||
def test_load_image_from_url(self):
|
||||
"""Test loading image from URL."""
|
||||
url = f"http://127.0.0.1:{self.flask_port}/test.jpg"
|
||||
img = Image(url)
|
||||
|
||||
|
||||
file_path, pil_img = img.load_image_data()
|
||||
|
||||
|
||||
self.assertIsNotNone(pil_img)
|
||||
self.assertIsNotNone(file_path)
|
||||
self.assertTrue(file_path.endswith('.tmp')) # Should be a temp file
|
||||
self.assertEqual(pil_img.size, self.original_size)
|
||||
|
||||
|
||||
# Check that dimensions were updated
|
||||
self.assertEqual(img.width, self.original_size[0])
|
||||
self.assertEqual(img.height, self.original_size[1])
|
||||
|
||||
|
||||
@unittest.skipUnless(FLASK_AVAILABLE, "Flask not available for URL testing")
|
||||
def test_get_image_info_from_url(self):
|
||||
"""Test getting image info from URL."""
|
||||
url = f"http://127.0.0.1:{self.flask_port}/test.jpg"
|
||||
img = Image(url)
|
||||
|
||||
|
||||
info = img.get_image_info()
|
||||
|
||||
|
||||
self.assertIsInstance(info, dict)
|
||||
self.assertEqual(info['format'], 'JPEG')
|
||||
self.assertEqual(info['size'], self.original_size)
|
||||
|
||||
|
||||
def test_load_invalid_url(self):
|
||||
"""Test loading from invalid URL."""
|
||||
img = Image("http://nonexistent.domain/image.jpg")
|
||||
|
||||
|
||||
file_path, pil_img = img.load_image_data()
|
||||
|
||||
|
||||
self.assertIsNone(pil_img)
|
||||
self.assertIsNone(file_path)
|
||||
|
||||
|
||||
def test_multiple_loads_cleanup(self):
|
||||
"""Test that multiple loads don't leave temp files."""
|
||||
img = Image(self.jpg_path)
|
||||
|
||||
|
||||
# Load multiple times
|
||||
for _ in range(3):
|
||||
file_path, pil_img = img.load_image_data()
|
||||
self.assertIsNotNone(pil_img)
|
||||
|
||||
|
||||
def test_original_sample_image(self):
|
||||
"""Test loading the original sample image if it exists."""
|
||||
if os.path.exists(self.sample_image_path):
|
||||
img = Image(self.sample_image_path)
|
||||
|
||||
|
||||
file_path, pil_img = img.load_image_data()
|
||||
|
||||
|
||||
self.assertIsNotNone(pil_img)
|
||||
self.assertEqual(file_path, self.sample_image_path)
|
||||
|
||||
|
||||
# Test that we can get image info
|
||||
info = img.get_image_info()
|
||||
self.assertIsInstance(info, dict)
|
||||
|
||||
@ -8,13 +8,13 @@ document structure and metadata management.
|
||||
import unittest
|
||||
from pyWebLayout.abstract.document import Document, Chapter, Book, MetadataType
|
||||
from pyWebLayout.abstract.block import Paragraph, Heading, HeadingLevel, BlockType
|
||||
from pyWebLayout.abstract.inline import Word, FormattedSpan
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration
|
||||
|
||||
|
||||
class TestMetadataType(unittest.TestCase):
|
||||
"""Test cases for MetadataType enum."""
|
||||
|
||||
|
||||
def test_metadata_types(self):
|
||||
"""Test that all expected metadata types exist."""
|
||||
expected_types = [
|
||||
@ -22,135 +22,141 @@ class TestMetadataType(unittest.TestCase):
|
||||
'PUBLICATION_DATE', 'MODIFIED_DATE', 'PUBLISHER', 'IDENTIFIER',
|
||||
'COVER_IMAGE', 'CUSTOM'
|
||||
]
|
||||
|
||||
|
||||
for type_name in expected_types:
|
||||
self.assertTrue(hasattr(MetadataType, type_name))
|
||||
|
||||
|
||||
# Test custom type has expected value
|
||||
self.assertEqual(MetadataType.CUSTOM.value, 100)
|
||||
|
||||
|
||||
class TestDocument(unittest.TestCase):
|
||||
"""Test cases for Document class."""
|
||||
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
self.doc = Document("Test Document", "en-US")
|
||||
self.font = Font()
|
||||
|
||||
|
||||
def test_document_creation(self):
|
||||
"""Test document creation with basic parameters."""
|
||||
self.assertEqual(self.doc.get_title(), "Test Document")
|
||||
self.assertEqual(self.doc.get_metadata(MetadataType.LANGUAGE), "en-US")
|
||||
self.assertEqual(len(self.doc.blocks), 0)
|
||||
|
||||
|
||||
def test_document_creation_minimal(self):
|
||||
"""Test document creation with minimal parameters."""
|
||||
doc = Document()
|
||||
self.assertIsNone(doc.get_title())
|
||||
self.assertEqual(doc.get_metadata(MetadataType.LANGUAGE), "en-US")
|
||||
|
||||
|
||||
def test_metadata_management(self):
|
||||
"""Test setting and getting metadata."""
|
||||
# Set various metadata types
|
||||
self.doc.set_metadata(MetadataType.AUTHOR, "John Doe")
|
||||
self.doc.set_metadata(MetadataType.DESCRIPTION, "A test document")
|
||||
self.doc.set_metadata(MetadataType.KEYWORDS, ["test", "document"])
|
||||
|
||||
|
||||
# Test retrieval
|
||||
self.assertEqual(self.doc.get_metadata(MetadataType.AUTHOR), "John Doe")
|
||||
self.assertEqual(self.doc.get_metadata(MetadataType.DESCRIPTION), "A test document")
|
||||
self.assertEqual(self.doc.get_metadata(MetadataType.KEYWORDS), ["test", "document"])
|
||||
|
||||
self.assertEqual(
|
||||
self.doc.get_metadata(
|
||||
MetadataType.DESCRIPTION),
|
||||
"A test document")
|
||||
self.assertEqual(
|
||||
self.doc.get_metadata(
|
||||
MetadataType.KEYWORDS), [
|
||||
"test", "document"])
|
||||
|
||||
# Test non-existent metadata
|
||||
self.assertIsNone(self.doc.get_metadata(MetadataType.PUBLISHER))
|
||||
|
||||
|
||||
def test_title_convenience_methods(self):
|
||||
"""Test title getter and setter convenience methods."""
|
||||
# Test setting title
|
||||
self.doc.set_title("New Title")
|
||||
self.assertEqual(self.doc.get_title(), "New Title")
|
||||
|
||||
|
||||
# Test that it's also in metadata
|
||||
self.assertEqual(self.doc.get_metadata(MetadataType.TITLE), "New Title")
|
||||
|
||||
|
||||
def test_block_management(self):
|
||||
"""Test adding and managing blocks."""
|
||||
# Create some blocks
|
||||
para1 = Paragraph()
|
||||
para2 = Paragraph()
|
||||
heading = Heading(HeadingLevel.H1)
|
||||
|
||||
|
||||
# Add blocks
|
||||
self.doc.add_block(para1)
|
||||
self.doc.add_block(heading)
|
||||
self.doc.add_block(para2)
|
||||
|
||||
|
||||
# Test blocks list
|
||||
self.assertEqual(len(self.doc.blocks), 3)
|
||||
self.assertEqual(self.doc.blocks[0], para1)
|
||||
self.assertEqual(self.doc.blocks[1], heading)
|
||||
self.assertEqual(self.doc.blocks[2], para2)
|
||||
|
||||
|
||||
def test_anchor_management(self):
|
||||
"""Test named anchor functionality."""
|
||||
heading = Heading(HeadingLevel.H1)
|
||||
para = Paragraph()
|
||||
|
||||
|
||||
# Add anchors
|
||||
self.doc.add_anchor("intro", heading)
|
||||
self.doc.add_anchor("content", para)
|
||||
|
||||
|
||||
# Test retrieval
|
||||
self.assertEqual(self.doc.get_anchor("intro"), heading)
|
||||
self.assertEqual(self.doc.get_anchor("content"), para)
|
||||
self.assertIsNone(self.doc.get_anchor("nonexistent"))
|
||||
|
||||
|
||||
def test_resource_management(self):
|
||||
"""Test document resource management."""
|
||||
# Add various resources
|
||||
self.doc.add_resource("image1", {"type": "image", "path": "test.jpg"})
|
||||
self.doc.add_resource("style1", {"type": "css", "content": "body {}"})
|
||||
|
||||
|
||||
# Test retrieval
|
||||
image = self.doc.get_resource("image1")
|
||||
self.assertEqual(image["type"], "image")
|
||||
self.assertEqual(image["path"], "test.jpg")
|
||||
|
||||
|
||||
style = self.doc.get_resource("style1")
|
||||
self.assertEqual(style["type"], "css")
|
||||
|
||||
|
||||
# Test non-existent resource
|
||||
self.assertIsNone(self.doc.get_resource("nonexistent"))
|
||||
|
||||
|
||||
def test_stylesheet_management(self):
|
||||
"""Test stylesheet addition."""
|
||||
# Add stylesheets
|
||||
css1 = {"href": "style.css", "type": "text/css"}
|
||||
css2 = {"href": "theme.css", "type": "text/css"}
|
||||
|
||||
|
||||
self.doc.add_stylesheet(css1)
|
||||
self.doc.add_stylesheet(css2)
|
||||
|
||||
|
||||
# Test that stylesheets are stored
|
||||
self.assertEqual(len(self.doc._stylesheets), 2)
|
||||
self.assertEqual(self.doc._stylesheets[0], css1)
|
||||
self.assertEqual(self.doc._stylesheets[1], css2)
|
||||
|
||||
|
||||
def test_script_management(self):
|
||||
"""Test script addition."""
|
||||
# Add scripts
|
||||
script1 = "console.log('Hello');"
|
||||
script2 = "document.ready(function(){});"
|
||||
|
||||
|
||||
self.doc.add_script(script1)
|
||||
self.doc.add_script(script2)
|
||||
|
||||
|
||||
# Test that scripts are stored
|
||||
self.assertEqual(len(self.doc._scripts), 2)
|
||||
self.assertEqual(self.doc._scripts[0], script1)
|
||||
self.assertEqual(self.doc._scripts[1], script2)
|
||||
|
||||
|
||||
def test_find_blocks_by_type(self):
|
||||
"""Test finding blocks by type."""
|
||||
# Create blocks of different types
|
||||
@ -158,87 +164,87 @@ class TestDocument(unittest.TestCase):
|
||||
para2 = Paragraph()
|
||||
heading1 = Heading(HeadingLevel.H1)
|
||||
heading2 = Heading(HeadingLevel.H2)
|
||||
|
||||
|
||||
# Add blocks to document
|
||||
self.doc.add_block(para1)
|
||||
self.doc.add_block(heading1)
|
||||
self.doc.add_block(para2)
|
||||
self.doc.add_block(heading2)
|
||||
|
||||
|
||||
# Test finding paragraphs
|
||||
paragraphs = self.doc.find_blocks_by_type(BlockType.PARAGRAPH)
|
||||
self.assertEqual(len(paragraphs), 2)
|
||||
self.assertIn(para1, paragraphs)
|
||||
self.assertIn(para2, paragraphs)
|
||||
|
||||
|
||||
# Test finding headings
|
||||
headings = self.doc.find_blocks_by_type(BlockType.HEADING)
|
||||
self.assertEqual(len(headings), 2)
|
||||
self.assertIn(heading1, headings)
|
||||
self.assertIn(heading2, headings)
|
||||
|
||||
|
||||
def test_find_headings(self):
|
||||
"""Test finding heading blocks specifically."""
|
||||
# Create mixed blocks
|
||||
para = Paragraph()
|
||||
h1 = Heading(HeadingLevel.H1)
|
||||
h2 = Heading(HeadingLevel.H2)
|
||||
|
||||
|
||||
# Add words to headings for title extraction
|
||||
word1 = Word("Chapter", self.font)
|
||||
word2 = Word("One", self.font)
|
||||
h1.add_word(word1)
|
||||
h1.add_word(word2)
|
||||
|
||||
|
||||
word3 = Word("Section", self.font)
|
||||
h2.add_word(word3)
|
||||
|
||||
|
||||
self.doc.add_block(para)
|
||||
self.doc.add_block(h1)
|
||||
self.doc.add_block(h2)
|
||||
|
||||
|
||||
# Test finding headings
|
||||
headings = self.doc.find_headings()
|
||||
self.assertEqual(len(headings), 2)
|
||||
self.assertIn(h1, headings)
|
||||
self.assertIn(h2, headings)
|
||||
self.assertNotIn(para, headings)
|
||||
|
||||
|
||||
def test_generate_table_of_contents(self):
|
||||
"""Test table of contents generation."""
|
||||
# Create headings with content
|
||||
h1 = Heading(HeadingLevel.H1)
|
||||
h2 = Heading(HeadingLevel.H2)
|
||||
h3 = Heading(HeadingLevel.H3)
|
||||
|
||||
|
||||
# Add words to headings
|
||||
h1.add_word(Word("Introduction", self.font))
|
||||
h2.add_word(Word("Getting", self.font))
|
||||
h2.add_word(Word("Started", self.font))
|
||||
h3.add_word(Word("Installation", self.font))
|
||||
|
||||
|
||||
self.doc.add_block(h1)
|
||||
self.doc.add_block(h2)
|
||||
self.doc.add_block(h3)
|
||||
|
||||
|
||||
# Generate TOC
|
||||
toc = self.doc.generate_table_of_contents()
|
||||
|
||||
|
||||
# Test TOC structure
|
||||
self.assertEqual(len(toc), 3)
|
||||
|
||||
|
||||
# Test first entry
|
||||
level, title, block = toc[0]
|
||||
self.assertEqual(level, 1) # H1
|
||||
self.assertEqual(title, "Introduction")
|
||||
self.assertEqual(block, h1)
|
||||
|
||||
|
||||
# Test second entry
|
||||
level, title, block = toc[1]
|
||||
self.assertEqual(level, 2) # H2
|
||||
self.assertEqual(title, "Getting Started")
|
||||
self.assertEqual(block, h2)
|
||||
|
||||
|
||||
# Test third entry
|
||||
level, title, block = toc[2]
|
||||
self.assertEqual(level, 3) # H3
|
||||
@ -248,127 +254,127 @@ class TestDocument(unittest.TestCase):
|
||||
|
||||
class TestChapter(unittest.TestCase):
|
||||
"""Test cases for Chapter class."""
|
||||
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
self.chapter = Chapter("Test Chapter", 1)
|
||||
|
||||
|
||||
def test_chapter_creation(self):
|
||||
"""Test chapter creation."""
|
||||
self.assertEqual(self.chapter.title, "Test Chapter")
|
||||
self.assertEqual(self.chapter.level, 1)
|
||||
self.assertEqual(len(self.chapter.blocks), 0)
|
||||
|
||||
|
||||
def test_chapter_creation_minimal(self):
|
||||
"""Test chapter creation with minimal parameters."""
|
||||
chapter = Chapter()
|
||||
self.assertIsNone(chapter.title)
|
||||
self.assertEqual(chapter.level, 1)
|
||||
|
||||
|
||||
def test_title_property(self):
|
||||
"""Test title property getter and setter."""
|
||||
# Test setter
|
||||
self.chapter.title = "New Chapter Title"
|
||||
self.assertEqual(self.chapter.title, "New Chapter Title")
|
||||
|
||||
|
||||
# Test setting to None
|
||||
self.chapter.title = None
|
||||
self.assertIsNone(self.chapter.title)
|
||||
|
||||
|
||||
def test_level_property(self):
|
||||
"""Test level property."""
|
||||
self.assertEqual(self.chapter.level, 1)
|
||||
|
||||
|
||||
# Level should be read-only (no setter test)
|
||||
# This is by design based on the class definition
|
||||
|
||||
|
||||
def test_block_management(self):
|
||||
"""Test adding blocks to chapter."""
|
||||
para1 = Paragraph()
|
||||
para2 = Paragraph()
|
||||
heading = Heading(HeadingLevel.H2)
|
||||
|
||||
|
||||
# Add blocks
|
||||
self.chapter.add_block(para1)
|
||||
self.chapter.add_block(heading)
|
||||
self.chapter.add_block(para2)
|
||||
|
||||
|
||||
# Test blocks list
|
||||
self.assertEqual(len(self.chapter.blocks), 3)
|
||||
self.assertEqual(self.chapter.blocks[0], para1)
|
||||
self.assertEqual(self.chapter.blocks[1], heading)
|
||||
self.assertEqual(self.chapter.blocks[2], para2)
|
||||
|
||||
|
||||
def test_metadata_management(self):
|
||||
"""Test chapter metadata."""
|
||||
# Set metadata
|
||||
self.chapter.set_metadata("author", "Jane Doe")
|
||||
self.chapter.set_metadata("word_count", 1500)
|
||||
self.chapter.set_metadata("tags", ["intro", "basics"])
|
||||
|
||||
|
||||
# Test retrieval
|
||||
self.assertEqual(self.chapter.get_metadata("author"), "Jane Doe")
|
||||
self.assertEqual(self.chapter.get_metadata("word_count"), 1500)
|
||||
self.assertEqual(self.chapter.get_metadata("tags"), ["intro", "basics"])
|
||||
|
||||
|
||||
# Test non-existent metadata
|
||||
self.assertIsNone(self.chapter.get_metadata("nonexistent"))
|
||||
|
||||
|
||||
class TestBook(unittest.TestCase):
|
||||
"""Test cases for Book class."""
|
||||
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
self.book = Book("Test Book", "Author Name", "en-US")
|
||||
|
||||
|
||||
def test_book_creation(self):
|
||||
"""Test book creation with all parameters."""
|
||||
self.assertEqual(self.book.get_title(), "Test Book")
|
||||
self.assertEqual(self.book.get_author(), "Author Name")
|
||||
self.assertEqual(self.book.get_metadata(MetadataType.LANGUAGE), "en-US")
|
||||
self.assertEqual(len(self.book.chapters), 0)
|
||||
|
||||
|
||||
def test_book_creation_minimal(self):
|
||||
"""Test book creation with minimal parameters."""
|
||||
book = Book()
|
||||
self.assertIsNone(book.get_title())
|
||||
self.assertIsNone(book.get_author())
|
||||
self.assertEqual(book.get_metadata(MetadataType.LANGUAGE), "en-US")
|
||||
|
||||
|
||||
def test_book_creation_partial(self):
|
||||
"""Test book creation with partial parameters."""
|
||||
book = Book(title="Just Title")
|
||||
self.assertEqual(book.get_title(), "Just Title")
|
||||
self.assertIsNone(book.get_author())
|
||||
|
||||
|
||||
def test_author_convenience_methods(self):
|
||||
"""Test author getter and setter convenience methods."""
|
||||
# Test setting author
|
||||
self.book.set_author("New Author")
|
||||
self.assertEqual(self.book.get_author(), "New Author")
|
||||
|
||||
|
||||
# Test that it's also in metadata
|
||||
self.assertEqual(self.book.get_metadata(MetadataType.AUTHOR), "New Author")
|
||||
|
||||
|
||||
def test_chapter_management(self):
|
||||
"""Test adding and managing chapters."""
|
||||
# Create chapters
|
||||
ch1 = Chapter("Introduction", 1)
|
||||
ch2 = Chapter("Getting Started", 1)
|
||||
ch3 = Chapter("Advanced Topics", 1)
|
||||
|
||||
|
||||
# Add chapters
|
||||
self.book.add_chapter(ch1)
|
||||
self.book.add_chapter(ch2)
|
||||
self.book.add_chapter(ch3)
|
||||
|
||||
|
||||
# Test chapters list
|
||||
self.assertEqual(len(self.book.chapters), 3)
|
||||
self.assertEqual(self.book.chapters[0], ch1)
|
||||
self.assertEqual(self.book.chapters[1], ch2)
|
||||
self.assertEqual(self.book.chapters[2], ch3)
|
||||
|
||||
|
||||
def test_create_chapter(self):
|
||||
"""Test creating chapters through the book."""
|
||||
# Create chapter with title and level
|
||||
@ -377,13 +383,13 @@ class TestBook(unittest.TestCase):
|
||||
self.assertEqual(ch1.level, 1)
|
||||
self.assertEqual(len(self.book.chapters), 1)
|
||||
self.assertEqual(self.book.chapters[0], ch1)
|
||||
|
||||
|
||||
# Create chapter with minimal parameters
|
||||
ch2 = self.book.create_chapter()
|
||||
self.assertIsNone(ch2.title)
|
||||
self.assertEqual(ch2.level, 1)
|
||||
self.assertEqual(len(self.book.chapters), 2)
|
||||
|
||||
|
||||
def test_generate_book_toc(self):
|
||||
"""Test table of contents generation for book."""
|
||||
# Create chapters with different levels
|
||||
@ -392,20 +398,20 @@ class TestBook(unittest.TestCase):
|
||||
ch3 = Chapter("Basic Concepts", 2)
|
||||
ch4 = Chapter("Advanced Topics", 1)
|
||||
ch5 = Chapter("Best Practices", 2)
|
||||
|
||||
|
||||
# Add chapters to book
|
||||
self.book.add_chapter(ch1)
|
||||
self.book.add_chapter(ch2)
|
||||
self.book.add_chapter(ch3)
|
||||
self.book.add_chapter(ch4)
|
||||
self.book.add_chapter(ch5)
|
||||
|
||||
|
||||
# Generate TOC
|
||||
toc = self.book.generate_table_of_contents()
|
||||
|
||||
|
||||
# Test TOC structure
|
||||
self.assertEqual(len(toc), 5)
|
||||
|
||||
|
||||
# Test entries
|
||||
expected = [
|
||||
(1, "Introduction", ch1),
|
||||
@ -414,38 +420,38 @@ class TestBook(unittest.TestCase):
|
||||
(1, "Advanced Topics", ch4),
|
||||
(2, "Best Practices", ch5)
|
||||
]
|
||||
|
||||
|
||||
for i, (exp_level, exp_title, exp_chapter) in enumerate(expected):
|
||||
level, title, chapter = toc[i]
|
||||
self.assertEqual(level, exp_level)
|
||||
self.assertEqual(title, exp_title)
|
||||
self.assertEqual(chapter, exp_chapter)
|
||||
|
||||
|
||||
def test_generate_book_toc_with_untitled_chapters(self):
|
||||
"""Test TOC generation with chapters that have no title."""
|
||||
# Create chapters, some without titles
|
||||
ch1 = Chapter("Introduction", 1)
|
||||
ch2 = Chapter(None, 1) # No title
|
||||
ch3 = Chapter("Conclusion", 1)
|
||||
|
||||
|
||||
self.book.add_chapter(ch1)
|
||||
self.book.add_chapter(ch2)
|
||||
self.book.add_chapter(ch3)
|
||||
|
||||
|
||||
# Generate TOC
|
||||
toc = self.book.generate_table_of_contents()
|
||||
|
||||
|
||||
# Should only include chapters with titles
|
||||
self.assertEqual(len(toc), 2)
|
||||
|
||||
|
||||
level, title, chapter = toc[0]
|
||||
self.assertEqual(title, "Introduction")
|
||||
self.assertEqual(chapter, ch1)
|
||||
|
||||
|
||||
level, title, chapter = toc[1]
|
||||
self.assertEqual(title, "Conclusion")
|
||||
self.assertEqual(chapter, ch3)
|
||||
|
||||
|
||||
def test_book_inherits_document_features(self):
|
||||
"""Test that Book inherits all Document functionality."""
|
||||
# Test that book can use all document methods
|
||||
@ -453,11 +459,14 @@ class TestBook(unittest.TestCase):
|
||||
para = Paragraph()
|
||||
self.book.add_block(para)
|
||||
self.assertEqual(len(self.book.blocks), 1)
|
||||
|
||||
|
||||
# Test metadata
|
||||
self.book.set_metadata(MetadataType.PUBLISHER, "Test Publisher")
|
||||
self.assertEqual(self.book.get_metadata(MetadataType.PUBLISHER), "Test Publisher")
|
||||
|
||||
self.assertEqual(
|
||||
self.book.get_metadata(
|
||||
MetadataType.PUBLISHER),
|
||||
"Test Publisher")
|
||||
|
||||
# Test anchors
|
||||
heading = Heading(HeadingLevel.H1)
|
||||
self.book.add_anchor("preface", heading)
|
||||
@ -466,11 +475,11 @@ class TestBook(unittest.TestCase):
|
||||
|
||||
class TestDocumentFontRegistry(unittest.TestCase):
|
||||
"""Test cases for Document font registry functionality."""
|
||||
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
self.doc = Document("Test Document", "en-US")
|
||||
|
||||
|
||||
def test_get_or_create_font_creates_new_font(self):
|
||||
"""Test that get_or_create_font creates a new font when none exists."""
|
||||
font = self.doc.get_or_create_font(
|
||||
@ -478,14 +487,14 @@ class TestDocumentFontRegistry(unittest.TestCase):
|
||||
colour=(255, 0, 0),
|
||||
weight=FontWeight.BOLD
|
||||
)
|
||||
|
||||
|
||||
self.assertEqual(font.font_size, 14)
|
||||
self.assertEqual(font.colour, (255, 0, 0))
|
||||
self.assertEqual(font.weight, FontWeight.BOLD)
|
||||
|
||||
|
||||
# Check that font is stored in registry
|
||||
self.assertEqual(len(self.doc._fonts), 1)
|
||||
|
||||
|
||||
def test_get_or_create_font_reuses_existing_font(self):
|
||||
"""Test that get_or_create_font reuses existing fonts."""
|
||||
# Create first font
|
||||
@ -494,20 +503,20 @@ class TestDocumentFontRegistry(unittest.TestCase):
|
||||
colour=(255, 0, 0),
|
||||
weight=FontWeight.BOLD
|
||||
)
|
||||
|
||||
|
||||
# Create second font with same properties
|
||||
font2 = self.doc.get_or_create_font(
|
||||
font_size=14,
|
||||
colour=(255, 0, 0),
|
||||
weight=FontWeight.BOLD
|
||||
)
|
||||
|
||||
|
||||
# Should return the same font object
|
||||
self.assertIs(font1, font2)
|
||||
|
||||
|
||||
# Should only have one font in registry
|
||||
self.assertEqual(len(self.doc._fonts), 1)
|
||||
|
||||
|
||||
def test_get_or_create_font_creates_different_fonts(self):
|
||||
"""Test that different font properties create different fonts."""
|
||||
# Create first font
|
||||
@ -516,28 +525,28 @@ class TestDocumentFontRegistry(unittest.TestCase):
|
||||
colour=(255, 0, 0),
|
||||
weight=FontWeight.BOLD
|
||||
)
|
||||
|
||||
|
||||
# Create font with different size
|
||||
font2 = self.doc.get_or_create_font(
|
||||
font_size=16,
|
||||
colour=(255, 0, 0),
|
||||
weight=FontWeight.BOLD
|
||||
)
|
||||
|
||||
|
||||
# Create font with different color
|
||||
font3 = self.doc.get_or_create_font(
|
||||
font_size=14,
|
||||
colour=(0, 255, 0),
|
||||
weight=FontWeight.BOLD
|
||||
)
|
||||
|
||||
|
||||
# Create font with different weight
|
||||
font4 = self.doc.get_or_create_font(
|
||||
font_size=14,
|
||||
colour=(255, 0, 0),
|
||||
weight=FontWeight.NORMAL
|
||||
)
|
||||
|
||||
|
||||
# All should be different objects
|
||||
self.assertIsNot(font1, font2)
|
||||
self.assertIsNot(font1, font3)
|
||||
@ -545,10 +554,10 @@ class TestDocumentFontRegistry(unittest.TestCase):
|
||||
self.assertIsNot(font2, font3)
|
||||
self.assertIsNot(font2, font4)
|
||||
self.assertIsNot(font3, font4)
|
||||
|
||||
|
||||
# Should have four fonts in registry
|
||||
self.assertEqual(len(self.doc._fonts), 4)
|
||||
|
||||
|
||||
def test_get_or_create_font_with_all_parameters(self):
|
||||
"""Test get_or_create_font with all parameters."""
|
||||
font = self.doc.get_or_create_font(
|
||||
@ -562,7 +571,7 @@ class TestDocumentFontRegistry(unittest.TestCase):
|
||||
language="fr_FR",
|
||||
min_hyphenation_width=80
|
||||
)
|
||||
|
||||
|
||||
self.assertEqual(font._font_path, "path/to/font.ttf")
|
||||
self.assertEqual(font.font_size, 18)
|
||||
self.assertEqual(font.colour, (128, 64, 192))
|
||||
@ -572,11 +581,11 @@ class TestDocumentFontRegistry(unittest.TestCase):
|
||||
self.assertEqual(font.background, (255, 255, 255, 128))
|
||||
self.assertEqual(font.language, "fr_FR")
|
||||
self.assertEqual(font.min_hyphenation_width, 80)
|
||||
|
||||
|
||||
def test_get_or_create_font_with_defaults(self):
|
||||
"""Test get_or_create_font with default values."""
|
||||
font = self.doc.get_or_create_font()
|
||||
|
||||
|
||||
# Should create font with default values
|
||||
self.assertIsNotNone(font)
|
||||
self.assertEqual(font.font_size, 16) # Default font size
|
||||
@ -588,12 +597,12 @@ class TestDocumentFontRegistry(unittest.TestCase):
|
||||
|
||||
class TestChapterFontRegistry(unittest.TestCase):
|
||||
"""Test cases for Chapter font registry functionality."""
|
||||
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
self.doc = Document("Test Document", "en-US")
|
||||
self.chapter = Chapter("Test Chapter", 1, parent=self.doc)
|
||||
|
||||
|
||||
def test_chapter_uses_parent_font_registry(self):
|
||||
"""Test that chapter uses parent document's font registry."""
|
||||
# Create font through chapter - should delegate to parent
|
||||
@ -602,52 +611,52 @@ class TestChapterFontRegistry(unittest.TestCase):
|
||||
colour=(255, 0, 0),
|
||||
weight=FontWeight.BOLD
|
||||
)
|
||||
|
||||
|
||||
# Create same font through document - should return same object
|
||||
font2 = self.doc.get_or_create_font(
|
||||
font_size=14,
|
||||
colour=(255, 0, 0),
|
||||
weight=FontWeight.BOLD
|
||||
)
|
||||
|
||||
|
||||
# Should be the same font object
|
||||
self.assertIs(font1, font2)
|
||||
|
||||
|
||||
# Should be stored in document's registry, not chapter's
|
||||
self.assertEqual(len(self.doc._fonts), 1)
|
||||
self.assertEqual(len(self.chapter._fonts), 0)
|
||||
|
||||
|
||||
def test_chapter_without_parent_manages_own_fonts(self):
|
||||
"""Test that chapter without parent manages its own fonts."""
|
||||
# Create chapter without parent
|
||||
standalone_chapter = Chapter("Standalone Chapter", 1)
|
||||
|
||||
|
||||
# Create font through chapter
|
||||
font1 = standalone_chapter.get_or_create_font(
|
||||
font_size=14,
|
||||
colour=(255, 0, 0),
|
||||
weight=FontWeight.BOLD
|
||||
)
|
||||
|
||||
|
||||
# Create same font again - should reuse
|
||||
font2 = standalone_chapter.get_or_create_font(
|
||||
font_size=14,
|
||||
colour=(255, 0, 0),
|
||||
weight=FontWeight.BOLD
|
||||
)
|
||||
|
||||
|
||||
# Should be the same font object
|
||||
self.assertIs(font1, font2)
|
||||
|
||||
|
||||
# Should be stored in chapter's own registry
|
||||
self.assertEqual(len(standalone_chapter._fonts), 1)
|
||||
|
||||
|
||||
def test_chapter_parent_assignment(self):
|
||||
"""Test that chapter parent assignment works correctly."""
|
||||
# Create chapter with parent
|
||||
chapter_with_parent = Chapter("Chapter with Parent", 1, parent=self.doc)
|
||||
self.assertEqual(chapter_with_parent._parent, self.doc)
|
||||
|
||||
|
||||
# Create chapter without parent
|
||||
chapter_without_parent = Chapter("Chapter without Parent", 1)
|
||||
self.assertIsNone(chapter_without_parent._parent)
|
||||
@ -655,11 +664,11 @@ class TestChapterFontRegistry(unittest.TestCase):
|
||||
|
||||
class TestBookFontRegistry(unittest.TestCase):
|
||||
"""Test cases for Book font registry functionality."""
|
||||
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
self.book = Book("Test Book", "Author Name", "en-US")
|
||||
|
||||
|
||||
def test_book_inherits_document_font_registry(self):
|
||||
"""Test that Book inherits Document's font registry functionality."""
|
||||
# Create font through book
|
||||
@ -668,17 +677,17 @@ class TestBookFontRegistry(unittest.TestCase):
|
||||
colour=(255, 0, 0),
|
||||
weight=FontWeight.BOLD
|
||||
)
|
||||
|
||||
|
||||
# Create same font again - should reuse
|
||||
font2 = self.book.get_or_create_font(
|
||||
font_size=14,
|
||||
colour=(255, 0, 0),
|
||||
weight=FontWeight.BOLD
|
||||
)
|
||||
|
||||
|
||||
# Should be the same font object
|
||||
self.assertIs(font1, font2)
|
||||
|
||||
|
||||
# Should have one font in registry
|
||||
self.assertEqual(len(self.book._fonts), 1)
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ interactive functionality and user interface elements.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
from unittest.mock import Mock
|
||||
from pyWebLayout.abstract.functional import (
|
||||
Link, LinkType, Button, Form, FormField, FormFieldType
|
||||
)
|
||||
@ -14,14 +14,14 @@ from pyWebLayout.abstract.functional import (
|
||||
|
||||
class TestLinkType(unittest.TestCase):
|
||||
"""Test cases for LinkType enum."""
|
||||
|
||||
|
||||
def test_link_types(self):
|
||||
"""Test that all expected link types exist."""
|
||||
expected_types = ['INTERNAL', 'EXTERNAL', 'API', 'FUNCTION']
|
||||
|
||||
|
||||
for type_name in expected_types:
|
||||
self.assertTrue(hasattr(LinkType, type_name))
|
||||
|
||||
|
||||
# Test specific values
|
||||
self.assertEqual(LinkType.INTERNAL.value, 1)
|
||||
self.assertEqual(LinkType.EXTERNAL.value, 2)
|
||||
@ -31,21 +31,21 @@ class TestLinkType(unittest.TestCase):
|
||||
|
||||
class TestLink(unittest.TestCase):
|
||||
"""Test cases for Link class."""
|
||||
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
self.mock_callback = Mock(return_value="callback_result")
|
||||
|
||||
|
||||
def test_link_creation_minimal(self):
|
||||
"""Test link creation with minimal parameters."""
|
||||
link = Link("test-location")
|
||||
|
||||
|
||||
self.assertEqual(link.location, "test-location")
|
||||
self.assertEqual(link.link_type, LinkType.INTERNAL) # Default
|
||||
self.assertEqual(link.params, {})
|
||||
self.assertIsNone(link.title)
|
||||
self.assertIsNone(link._callback)
|
||||
|
||||
|
||||
def test_link_creation_full(self):
|
||||
"""Test link creation with all parameters."""
|
||||
params = {"param1": "value1", "param2": "value2"}
|
||||
@ -56,29 +56,29 @@ class TestLink(unittest.TestCase):
|
||||
params=params,
|
||||
title="Example Link"
|
||||
)
|
||||
|
||||
|
||||
self.assertEqual(link.location, "https://example.com")
|
||||
self.assertEqual(link.link_type, LinkType.EXTERNAL)
|
||||
self.assertEqual(link.params, params)
|
||||
self.assertEqual(link.title, "Example Link")
|
||||
self.assertEqual(link._callback, self.mock_callback)
|
||||
|
||||
|
||||
def test_internal_link_execution(self):
|
||||
"""Test executing internal links."""
|
||||
link = Link("#section1", LinkType.INTERNAL)
|
||||
result = link.execute()
|
||||
|
||||
|
||||
# Internal links should return the location
|
||||
self.assertEqual(result, "#section1")
|
||||
|
||||
|
||||
def test_external_link_execution(self):
|
||||
"""Test executing external links."""
|
||||
link = Link("https://example.com", LinkType.EXTERNAL)
|
||||
result = link.execute()
|
||||
|
||||
|
||||
# External links should return the location
|
||||
self.assertEqual(result, "https://example.com")
|
||||
|
||||
|
||||
def test_api_link_execution(self):
|
||||
"""Test executing API links with callback."""
|
||||
params = {"action": "save", "id": 123}
|
||||
@ -92,9 +92,10 @@ class TestLink(unittest.TestCase):
|
||||
result = link.execute()
|
||||
|
||||
# Should call callback with location, point (None when not provided), and params
|
||||
self.mock_callback.assert_called_once_with("/api/save", None, action="save", id=123)
|
||||
self.mock_callback.assert_called_once_with(
|
||||
"/api/save", None, action="save", id=123)
|
||||
self.assertEqual(result, "callback_result")
|
||||
|
||||
|
||||
def test_function_link_execution(self):
|
||||
"""Test executing function links with callback."""
|
||||
params = {"data": "test"}
|
||||
@ -110,23 +111,23 @@ class TestLink(unittest.TestCase):
|
||||
# Should call callback with location, point (None when not provided), and params
|
||||
self.mock_callback.assert_called_once_with("save_document", None, data="test")
|
||||
self.assertEqual(result, "callback_result")
|
||||
|
||||
|
||||
def test_api_link_without_callback(self):
|
||||
"""Test API link without callback returns location."""
|
||||
link = Link("/api/endpoint", LinkType.API)
|
||||
result = link.execute()
|
||||
|
||||
|
||||
# Without callback, should return location
|
||||
self.assertEqual(result, "/api/endpoint")
|
||||
|
||||
|
||||
def test_function_link_without_callback(self):
|
||||
"""Test function link without callback returns location."""
|
||||
link = Link("function_name", LinkType.FUNCTION)
|
||||
result = link.execute()
|
||||
|
||||
|
||||
# Without callback, should return location
|
||||
self.assertEqual(result, "function_name")
|
||||
|
||||
|
||||
def test_link_properties(self):
|
||||
"""Test link property access."""
|
||||
params = {"key": "value"}
|
||||
@ -136,7 +137,7 @@ class TestLink(unittest.TestCase):
|
||||
params=params,
|
||||
title="Test Title"
|
||||
)
|
||||
|
||||
|
||||
# Test all property getters
|
||||
self.assertEqual(link.location, "test")
|
||||
self.assertEqual(link.link_type, LinkType.API)
|
||||
@ -146,20 +147,20 @@ class TestLink(unittest.TestCase):
|
||||
|
||||
class TestButton(unittest.TestCase):
|
||||
"""Test cases for Button class."""
|
||||
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
self.mock_callback = Mock(return_value="button_clicked")
|
||||
|
||||
|
||||
def test_button_creation_minimal(self):
|
||||
"""Test button creation with minimal parameters."""
|
||||
button = Button("Click Me", self.mock_callback)
|
||||
|
||||
|
||||
self.assertEqual(button.label, "Click Me")
|
||||
self.assertEqual(button._callback, self.mock_callback)
|
||||
self.assertEqual(button.params, {})
|
||||
self.assertTrue(button.enabled)
|
||||
|
||||
|
||||
def test_button_creation_full(self):
|
||||
"""Test button creation with all parameters."""
|
||||
params = {"action": "submit", "form_id": "test_form"}
|
||||
@ -169,37 +170,37 @@ class TestButton(unittest.TestCase):
|
||||
params=params,
|
||||
enabled=False
|
||||
)
|
||||
|
||||
|
||||
self.assertEqual(button.label, "Submit")
|
||||
self.assertEqual(button._callback, self.mock_callback)
|
||||
self.assertEqual(button.params, params)
|
||||
self.assertFalse(button.enabled)
|
||||
|
||||
|
||||
def test_button_label_property(self):
|
||||
"""Test button label getter and setter."""
|
||||
button = Button("Original", self.mock_callback)
|
||||
|
||||
|
||||
# Test getter
|
||||
self.assertEqual(button.label, "Original")
|
||||
|
||||
|
||||
# Test setter
|
||||
button.label = "New Label"
|
||||
self.assertEqual(button.label, "New Label")
|
||||
|
||||
|
||||
def test_button_enabled_property(self):
|
||||
"""Test button enabled getter and setter."""
|
||||
button = Button("Test", self.mock_callback, enabled=True)
|
||||
|
||||
|
||||
# Test initial state
|
||||
self.assertTrue(button.enabled)
|
||||
|
||||
|
||||
# Test setter
|
||||
button.enabled = False
|
||||
self.assertFalse(button.enabled)
|
||||
|
||||
|
||||
button.enabled = True
|
||||
self.assertTrue(button.enabled)
|
||||
|
||||
|
||||
def test_button_execute_enabled(self):
|
||||
"""Test executing enabled button."""
|
||||
params = {"data": "test_data"}
|
||||
@ -210,40 +211,40 @@ class TestButton(unittest.TestCase):
|
||||
# Should call callback with point (None when not provided) and params
|
||||
self.mock_callback.assert_called_once_with(None, data="test_data")
|
||||
self.assertEqual(result, "button_clicked")
|
||||
|
||||
|
||||
def test_button_execute_disabled(self):
|
||||
"""Test executing disabled button."""
|
||||
button = Button("Test", self.mock_callback, enabled=False)
|
||||
|
||||
|
||||
result = button.execute()
|
||||
|
||||
|
||||
# Should not call callback and return None
|
||||
self.mock_callback.assert_not_called()
|
||||
self.assertIsNone(result)
|
||||
|
||||
|
||||
def test_button_execute_no_callback(self):
|
||||
"""Test executing button without callback."""
|
||||
button = Button("Test", None, enabled=True)
|
||||
|
||||
|
||||
result = button.execute()
|
||||
|
||||
|
||||
# Should return None
|
||||
self.assertIsNone(result)
|
||||
|
||||
|
||||
class TestFormFieldType(unittest.TestCase):
|
||||
"""Test cases for FormFieldType enum."""
|
||||
|
||||
|
||||
def test_form_field_types(self):
|
||||
"""Test that all expected form field types exist."""
|
||||
expected_types = [
|
||||
'TEXT', 'PASSWORD', 'CHECKBOX', 'RADIO', 'SELECT', 'TEXTAREA',
|
||||
'NUMBER', 'DATE', 'TIME', 'EMAIL', 'URL', 'COLOR', 'RANGE', 'HIDDEN'
|
||||
]
|
||||
|
||||
|
||||
for type_name in expected_types:
|
||||
self.assertTrue(hasattr(FormFieldType, type_name))
|
||||
|
||||
|
||||
# Test some specific values
|
||||
self.assertEqual(FormFieldType.TEXT.value, 1)
|
||||
self.assertEqual(FormFieldType.PASSWORD.value, 2)
|
||||
@ -252,11 +253,11 @@ class TestFormFieldType(unittest.TestCase):
|
||||
|
||||
class TestFormField(unittest.TestCase):
|
||||
"""Test cases for FormField class."""
|
||||
|
||||
|
||||
def test_form_field_creation_minimal(self):
|
||||
"""Test form field creation with minimal parameters."""
|
||||
field = FormField("username", FormFieldType.TEXT)
|
||||
|
||||
|
||||
self.assertEqual(field.name, "username")
|
||||
self.assertEqual(field.field_type, FormFieldType.TEXT)
|
||||
self.assertEqual(field.label, "username") # Default to name
|
||||
@ -264,7 +265,7 @@ class TestFormField(unittest.TestCase):
|
||||
self.assertFalse(field.required)
|
||||
self.assertEqual(field.options, [])
|
||||
self.assertIsNone(field.form)
|
||||
|
||||
|
||||
def test_form_field_creation_full(self):
|
||||
"""Test form field creation with all parameters."""
|
||||
options = [("value1", "Label 1"), ("value2", "Label 2")]
|
||||
@ -276,37 +277,37 @@ class TestFormField(unittest.TestCase):
|
||||
required=True,
|
||||
options=options
|
||||
)
|
||||
|
||||
|
||||
self.assertEqual(field.name, "country")
|
||||
self.assertEqual(field.field_type, FormFieldType.SELECT)
|
||||
self.assertEqual(field.label, "Country")
|
||||
self.assertEqual(field.value, "value1")
|
||||
self.assertTrue(field.required)
|
||||
self.assertEqual(field.options, options)
|
||||
|
||||
|
||||
def test_form_field_value_property(self):
|
||||
"""Test form field value getter and setter."""
|
||||
field = FormField("test", FormFieldType.TEXT, value="initial")
|
||||
|
||||
|
||||
# Test getter
|
||||
self.assertEqual(field.value, "initial")
|
||||
|
||||
|
||||
# Test setter
|
||||
field.value = "new_value"
|
||||
self.assertEqual(field.value, "new_value")
|
||||
|
||||
|
||||
def test_form_field_form_property(self):
|
||||
"""Test form field form getter and setter."""
|
||||
field = FormField("test", FormFieldType.TEXT)
|
||||
mock_form = Mock()
|
||||
|
||||
|
||||
# Initial state
|
||||
self.assertIsNone(field.form)
|
||||
|
||||
|
||||
# Test setter
|
||||
field.form = mock_form
|
||||
self.assertEqual(field.form, mock_form)
|
||||
|
||||
|
||||
def test_form_field_properties(self):
|
||||
"""Test all form field property getters."""
|
||||
options = [("opt1", "Option 1")]
|
||||
@ -318,7 +319,7 @@ class TestFormField(unittest.TestCase):
|
||||
required=True,
|
||||
options=options
|
||||
)
|
||||
|
||||
|
||||
# Test all getters
|
||||
self.assertEqual(field.name, "test_field")
|
||||
self.assertEqual(field.field_type, FormFieldType.CHECKBOX)
|
||||
@ -330,20 +331,20 @@ class TestFormField(unittest.TestCase):
|
||||
|
||||
class TestForm(unittest.TestCase):
|
||||
"""Test cases for Form class."""
|
||||
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
self.mock_callback = Mock(return_value="form_submitted")
|
||||
|
||||
|
||||
def test_form_creation_minimal(self):
|
||||
"""Test form creation with minimal parameters."""
|
||||
form = Form("test_form")
|
||||
|
||||
|
||||
self.assertEqual(form.form_id, "test_form")
|
||||
self.assertIsNone(form.action)
|
||||
self.assertIsNone(form._callback)
|
||||
self.assertEqual(len(form._fields), 0)
|
||||
|
||||
|
||||
def test_form_creation_full(self):
|
||||
"""Test form creation with all parameters."""
|
||||
form = Form(
|
||||
@ -351,145 +352,144 @@ class TestForm(unittest.TestCase):
|
||||
action="/submit",
|
||||
callback=self.mock_callback
|
||||
)
|
||||
|
||||
|
||||
self.assertEqual(form.form_id, "contact_form")
|
||||
self.assertEqual(form.action, "/submit")
|
||||
self.assertEqual(form._callback, self.mock_callback)
|
||||
|
||||
|
||||
def test_form_field_management(self):
|
||||
"""Test adding and retrieving form fields."""
|
||||
form = Form("test_form")
|
||||
|
||||
|
||||
# Create fields
|
||||
field1 = FormField("username", FormFieldType.TEXT, value="john")
|
||||
field2 = FormField("password", FormFieldType.PASSWORD, value="secret")
|
||||
field3 = FormField("email", FormFieldType.EMAIL, value="john@example.com")
|
||||
|
||||
|
||||
# Add fields
|
||||
form.add_field(field1)
|
||||
form.add_field(field2)
|
||||
form.add_field(field3)
|
||||
|
||||
|
||||
# Test that fields are stored correctly
|
||||
self.assertEqual(len(form._fields), 3)
|
||||
|
||||
|
||||
# Test field retrieval
|
||||
self.assertEqual(form.get_field("username"), field1)
|
||||
self.assertEqual(form.get_field("password"), field2)
|
||||
self.assertEqual(form.get_field("email"), field3)
|
||||
self.assertIsNone(form.get_field("nonexistent"))
|
||||
|
||||
|
||||
# Test that fields have form reference
|
||||
self.assertEqual(field1.form, form)
|
||||
self.assertEqual(field2.form, form)
|
||||
self.assertEqual(field3.form, form)
|
||||
|
||||
|
||||
def test_form_get_values(self):
|
||||
"""Test getting form values."""
|
||||
form = Form("test_form")
|
||||
|
||||
|
||||
# Add fields with values
|
||||
form.add_field(FormField("name", FormFieldType.TEXT, value="John Doe"))
|
||||
form.add_field(FormField("age", FormFieldType.NUMBER, value=30))
|
||||
form.add_field(FormField("subscribe", FormFieldType.CHECKBOX, value=True))
|
||||
|
||||
|
||||
# Get values
|
||||
values = form.get_values()
|
||||
|
||||
|
||||
expected = {
|
||||
"name": "John Doe",
|
||||
"age": 30,
|
||||
"subscribe": True
|
||||
}
|
||||
|
||||
|
||||
self.assertEqual(values, expected)
|
||||
|
||||
|
||||
def test_form_get_values_empty(self):
|
||||
"""Test getting values from empty form."""
|
||||
form = Form("empty_form")
|
||||
values = form.get_values()
|
||||
|
||||
|
||||
self.assertEqual(values, {})
|
||||
|
||||
|
||||
def test_form_execute_with_callback(self):
|
||||
"""Test executing form with callback."""
|
||||
form = Form("test_form", callback=self.mock_callback)
|
||||
|
||||
|
||||
# Add some fields
|
||||
form.add_field(FormField("field1", FormFieldType.TEXT, value="value1"))
|
||||
form.add_field(FormField("field2", FormFieldType.TEXT, value="value2"))
|
||||
|
||||
|
||||
result = form.execute()
|
||||
|
||||
|
||||
# Should call callback with form_id and values
|
||||
expected_values = {"field1": "value1", "field2": "value2"}
|
||||
self.mock_callback.assert_called_once_with("test_form", expected_values)
|
||||
self.assertEqual(result, "form_submitted")
|
||||
|
||||
|
||||
def test_form_execute_without_callback(self):
|
||||
"""Test executing form without callback."""
|
||||
form = Form("test_form")
|
||||
|
||||
|
||||
# Add a field
|
||||
form.add_field(FormField("test", FormFieldType.TEXT, value="test_value"))
|
||||
|
||||
|
||||
result = form.execute()
|
||||
|
||||
|
||||
# Should return the form values
|
||||
expected = {"test": "test_value"}
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
|
||||
def test_form_properties(self):
|
||||
"""Test form property getters."""
|
||||
form = Form("test_form", action="/submit")
|
||||
|
||||
|
||||
self.assertEqual(form.form_id, "test_form")
|
||||
self.assertEqual(form.action, "/submit")
|
||||
|
||||
|
||||
class TestFormIntegration(unittest.TestCase):
|
||||
"""Integration tests for form functionality."""
|
||||
|
||||
|
||||
def test_complete_form_workflow(self):
|
||||
"""Test a complete form creation and submission workflow."""
|
||||
# Create form
|
||||
form = Form("registration_form", action="/register")
|
||||
|
||||
|
||||
# Add various field types
|
||||
form.add_field(FormField(
|
||||
"username", FormFieldType.TEXT,
|
||||
"username", FormFieldType.TEXT,
|
||||
label="Username", required=True, value="testuser"
|
||||
))
|
||||
|
||||
|
||||
form.add_field(FormField(
|
||||
"password", FormFieldType.PASSWORD,
|
||||
label="Password", required=True, value="secret123"
|
||||
))
|
||||
|
||||
|
||||
form.add_field(FormField(
|
||||
"email", FormFieldType.EMAIL,
|
||||
label="Email", required=True, value="test@example.com"
|
||||
))
|
||||
|
||||
form.add_field(FormField(
|
||||
"country", FormFieldType.SELECT,
|
||||
label="Country", value="US",
|
||||
options=[("US", "United States"), ("CA", "Canada"), ("UK", "United Kingdom")]
|
||||
))
|
||||
|
||||
|
||||
form.add_field(
|
||||
FormField(
|
||||
"country", FormFieldType.SELECT, label="Country", value="US", options=[
|
||||
("US", "United States"), ("CA", "Canada"), ("UK", "United Kingdom")]))
|
||||
|
||||
form.add_field(FormField(
|
||||
"newsletter", FormFieldType.CHECKBOX,
|
||||
label="Subscribe to newsletter", value=True
|
||||
))
|
||||
|
||||
|
||||
# Test form state
|
||||
self.assertEqual(len(form._fields), 5)
|
||||
|
||||
|
||||
# Test individual field access
|
||||
username_field = form.get_field("username")
|
||||
self.assertEqual(username_field.value, "testuser")
|
||||
self.assertTrue(username_field.required)
|
||||
|
||||
|
||||
# Test getting all values
|
||||
values = form.get_values()
|
||||
expected = {
|
||||
@ -500,26 +500,26 @@ class TestFormIntegration(unittest.TestCase):
|
||||
"newsletter": True
|
||||
}
|
||||
self.assertEqual(values, expected)
|
||||
|
||||
|
||||
# Test form submission
|
||||
result = form.execute()
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
|
||||
def test_form_field_modification(self):
|
||||
"""Test modifying form fields after creation."""
|
||||
form = Form("test_form")
|
||||
|
||||
|
||||
# Add field
|
||||
field = FormField("test", FormFieldType.TEXT, value="initial")
|
||||
form.add_field(field)
|
||||
|
||||
|
||||
# Modify field value
|
||||
field.value = "modified"
|
||||
|
||||
|
||||
# Test that form reflects the change
|
||||
values = form.get_values()
|
||||
self.assertEqual(values["test"], "modified")
|
||||
|
||||
|
||||
# Test getting the modified field
|
||||
retrieved_field = form.get_field("test")
|
||||
self.assertEqual(retrieved_field.value, "modified")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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):
|
||||
|
||||
@ -3,20 +3,20 @@ Unit tests for LinkedWord and LinkedImage classes.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from pyWebLayout.abstract.inline import Word, LinkedWord
|
||||
from pyWebLayout.abstract.block import Image, LinkedImage
|
||||
from pyWebLayout.abstract.inline import LinkedWord
|
||||
from pyWebLayout.abstract.block import LinkedImage
|
||||
from pyWebLayout.abstract.functional import LinkType
|
||||
from pyWebLayout.style import Font
|
||||
|
||||
|
||||
class TestLinkedWord(unittest.TestCase):
|
||||
"""Test cases for LinkedWord class."""
|
||||
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
self.font = Font(font_size=16)
|
||||
self.location = "https://example.com"
|
||||
|
||||
|
||||
def test_linked_word_creation(self):
|
||||
"""Test creating a LinkedWord."""
|
||||
linked_word = LinkedWord(
|
||||
@ -25,12 +25,12 @@ class TestLinkedWord(unittest.TestCase):
|
||||
location=self.location,
|
||||
link_type=LinkType.EXTERNAL
|
||||
)
|
||||
|
||||
|
||||
self.assertEqual(linked_word.text, "example")
|
||||
self.assertEqual(linked_word.location, self.location)
|
||||
self.assertEqual(linked_word.link_type, LinkType.EXTERNAL)
|
||||
self.assertIsNone(linked_word.link_callback)
|
||||
|
||||
|
||||
def test_linked_word_inherits_from_word(self):
|
||||
"""Test that LinkedWord inherits Word properties."""
|
||||
linked_word = LinkedWord(
|
||||
@ -38,21 +38,21 @@ class TestLinkedWord(unittest.TestCase):
|
||||
style=self.font,
|
||||
location=self.location
|
||||
)
|
||||
|
||||
|
||||
# Should have Word properties
|
||||
self.assertEqual(linked_word.text, "test")
|
||||
self.assertEqual(linked_word.style, self.font)
|
||||
self.assertIsNone(linked_word.previous)
|
||||
self.assertIsNone(linked_word.next)
|
||||
|
||||
|
||||
def test_linked_word_with_callback(self):
|
||||
"""Test LinkedWord with a callback function."""
|
||||
callback_called = []
|
||||
|
||||
|
||||
def test_callback(location, **params):
|
||||
callback_called.append((location, params))
|
||||
return "navigated"
|
||||
|
||||
|
||||
linked_word = LinkedWord(
|
||||
text="click",
|
||||
style=self.font,
|
||||
@ -61,15 +61,15 @@ class TestLinkedWord(unittest.TestCase):
|
||||
callback=test_callback,
|
||||
params={"source": "test"}
|
||||
)
|
||||
|
||||
result = linked_word.execute_link()
|
||||
|
||||
|
||||
linked_word.execute_link()
|
||||
|
||||
self.assertEqual(len(callback_called), 1)
|
||||
self.assertEqual(callback_called[0][0], self.location)
|
||||
self.assertIn("text", callback_called[0][1])
|
||||
self.assertEqual(callback_called[0][1]["text"], "click")
|
||||
self.assertEqual(callback_called[0][1]["source"], "test")
|
||||
|
||||
|
||||
def test_linked_word_execute_external_link(self):
|
||||
"""Test executing an external link returns the location."""
|
||||
linked_word = LinkedWord(
|
||||
@ -78,10 +78,10 @@ class TestLinkedWord(unittest.TestCase):
|
||||
location=self.location,
|
||||
link_type=LinkType.EXTERNAL
|
||||
)
|
||||
|
||||
|
||||
result = linked_word.execute_link()
|
||||
self.assertEqual(result, self.location)
|
||||
|
||||
|
||||
def test_linked_word_with_title(self):
|
||||
"""Test LinkedWord with title/tooltip."""
|
||||
linked_word = LinkedWord(
|
||||
@ -90,9 +90,9 @@ class TestLinkedWord(unittest.TestCase):
|
||||
location=self.location,
|
||||
title="Click to visit example.com"
|
||||
)
|
||||
|
||||
|
||||
self.assertEqual(linked_word.link_title, "Click to visit example.com")
|
||||
|
||||
|
||||
def test_linked_word_chain(self):
|
||||
"""Test chaining multiple LinkedWords."""
|
||||
word1 = LinkedWord(
|
||||
@ -100,14 +100,14 @@ class TestLinkedWord(unittest.TestCase):
|
||||
style=self.font,
|
||||
location=self.location
|
||||
)
|
||||
|
||||
|
||||
word2 = LinkedWord(
|
||||
text="here",
|
||||
style=self.font,
|
||||
location=self.location,
|
||||
previous=word1
|
||||
)
|
||||
|
||||
|
||||
# Check chain
|
||||
self.assertEqual(word1.next, word2)
|
||||
self.assertEqual(word2.previous, word1)
|
||||
@ -115,13 +115,13 @@ class TestLinkedWord(unittest.TestCase):
|
||||
|
||||
class TestLinkedImage(unittest.TestCase):
|
||||
"""Test cases for LinkedImage class."""
|
||||
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
self.source = "logo.png"
|
||||
self.alt_text = "Company Logo"
|
||||
self.location = "https://example.com/home"
|
||||
|
||||
|
||||
def test_linked_image_creation(self):
|
||||
"""Test creating a LinkedImage."""
|
||||
linked_image = LinkedImage(
|
||||
@ -132,14 +132,14 @@ class TestLinkedImage(unittest.TestCase):
|
||||
height=50,
|
||||
link_type=LinkType.EXTERNAL
|
||||
)
|
||||
|
||||
|
||||
self.assertEqual(linked_image.source, self.source)
|
||||
self.assertEqual(linked_image.alt_text, self.alt_text)
|
||||
self.assertEqual(linked_image.location, self.location)
|
||||
self.assertEqual(linked_image.width, 100)
|
||||
self.assertEqual(linked_image.height, 50)
|
||||
self.assertEqual(linked_image.link_type, LinkType.EXTERNAL)
|
||||
|
||||
|
||||
def test_linked_image_inherits_from_image(self):
|
||||
"""Test that LinkedImage inherits Image properties."""
|
||||
linked_image = LinkedImage(
|
||||
@ -147,20 +147,20 @@ class TestLinkedImage(unittest.TestCase):
|
||||
alt_text=self.alt_text,
|
||||
location=self.location
|
||||
)
|
||||
|
||||
|
||||
# Should have Image properties and methods
|
||||
self.assertEqual(linked_image.source, self.source)
|
||||
self.assertEqual(linked_image.alt_text, self.alt_text)
|
||||
self.assertIsNotNone(linked_image.get_dimensions)
|
||||
|
||||
|
||||
def test_linked_image_with_callback(self):
|
||||
"""Test LinkedImage with a callback function."""
|
||||
callback_called = []
|
||||
|
||||
|
||||
def image_callback(location, **params):
|
||||
callback_called.append((location, params))
|
||||
return "image_clicked"
|
||||
|
||||
|
||||
linked_image = LinkedImage(
|
||||
source=self.source,
|
||||
alt_text=self.alt_text,
|
||||
@ -168,15 +168,15 @@ class TestLinkedImage(unittest.TestCase):
|
||||
link_type=LinkType.FUNCTION,
|
||||
callback=image_callback
|
||||
)
|
||||
|
||||
result = linked_image.execute_link()
|
||||
|
||||
|
||||
linked_image.execute_link()
|
||||
|
||||
self.assertEqual(len(callback_called), 1)
|
||||
self.assertEqual(callback_called[0][0], self.location)
|
||||
self.assertIn("alt_text", callback_called[0][1])
|
||||
self.assertEqual(callback_called[0][1]["alt_text"], self.alt_text)
|
||||
self.assertIn("source", callback_called[0][1])
|
||||
|
||||
|
||||
def test_linked_image_execute_internal_link(self):
|
||||
"""Test executing an internal link returns the location."""
|
||||
linked_image = LinkedImage(
|
||||
@ -185,7 +185,7 @@ class TestLinkedImage(unittest.TestCase):
|
||||
location="#section2",
|
||||
link_type=LinkType.INTERNAL
|
||||
)
|
||||
|
||||
|
||||
result = linked_image.execute_link()
|
||||
self.assertEqual(result, "#section2")
|
||||
|
||||
|
||||
@ -5,22 +5,24 @@ Tests the various alignment handlers (Left, Center, Right, Justify) and their in
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import numpy as np
|
||||
from unittest.mock import Mock
|
||||
|
||||
from pyWebLayout.concrete.text import Line, Text, LeftAlignmentHandler, CenterRightAlignmentHandler, JustifyAlignmentHandler
|
||||
from pyWebLayout.concrete.text import (
|
||||
Line, Text, LeftAlignmentHandler, CenterRightAlignmentHandler, JustifyAlignmentHandler
|
||||
)
|
||||
from pyWebLayout.style import Alignment
|
||||
from pyWebLayout.style import Font
|
||||
from pyWebLayout.abstract import Word
|
||||
from PIL import Image, ImageFont, ImageDraw
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
|
||||
class TestAlignmentHandlers(unittest.TestCase):
|
||||
"""Test cases for the alignment handler system"""
|
||||
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
self.font = Font()
|
||||
self.test_words = [Word(text, self.font) for text in ["This", "is", "a", "test", "sentence"]]
|
||||
self.test_words = [Word(text, self.font)
|
||||
for text in ["This", "is", "a", "test", "sentence"]]
|
||||
self.line_width = 300
|
||||
self.line_height = 30
|
||||
self.spacing = (5, 20) # min_spacing, max_spacing
|
||||
@ -29,46 +31,78 @@ class TestAlignmentHandlers(unittest.TestCase):
|
||||
|
||||
# Create a real PIL image (canvas) for testing
|
||||
self.canvas = Image.new('RGB', (800, 600), color='white')
|
||||
|
||||
|
||||
# Create a real ImageDraw object
|
||||
self.draw = ImageDraw.Draw(self.canvas)
|
||||
|
||||
|
||||
# Create a real Font object
|
||||
self.style = Font()
|
||||
|
||||
|
||||
def test_left_alignment_handler_assignment(self):
|
||||
"""Test that Line correctly assigns LeftAlignmentHandler for LEFT alignment"""
|
||||
left_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.LEFT)
|
||||
|
||||
left_line = Line(
|
||||
self.spacing,
|
||||
self.origin,
|
||||
self.size,
|
||||
self.draw,
|
||||
font=self.style,
|
||||
halign=Alignment.LEFT)
|
||||
|
||||
self.assertIsInstance(left_line._alignment_handler, LeftAlignmentHandler)
|
||||
|
||||
|
||||
def test_center_alignment_handler_assignment(self):
|
||||
"""Test that Line correctly assigns CenterRightAlignmentHandler for CENTER alignment"""
|
||||
center_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.CENTER)
|
||||
|
||||
self.assertIsInstance(center_line._alignment_handler, CenterRightAlignmentHandler)
|
||||
center_line = Line(
|
||||
self.spacing,
|
||||
self.origin,
|
||||
self.size,
|
||||
self.draw,
|
||||
font=self.style,
|
||||
halign=Alignment.CENTER)
|
||||
|
||||
self.assertIsInstance(
|
||||
center_line._alignment_handler,
|
||||
CenterRightAlignmentHandler)
|
||||
# Check that it's configured for CENTER alignment
|
||||
self.assertEqual(center_line._alignment_handler._alignment, Alignment.CENTER)
|
||||
|
||||
|
||||
def test_right_alignment_handler_assignment(self):
|
||||
"""Test that Line correctly assigns CenterRightAlignmentHandler for RIGHT alignment"""
|
||||
right_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.RIGHT)
|
||||
|
||||
self.assertIsInstance(right_line._alignment_handler, CenterRightAlignmentHandler)
|
||||
right_line = Line(
|
||||
self.spacing,
|
||||
self.origin,
|
||||
self.size,
|
||||
self.draw,
|
||||
font=self.style,
|
||||
halign=Alignment.RIGHT)
|
||||
|
||||
self.assertIsInstance(
|
||||
right_line._alignment_handler,
|
||||
CenterRightAlignmentHandler)
|
||||
# Check that it's configured for RIGHT alignment
|
||||
self.assertEqual(right_line._alignment_handler._alignment, Alignment.RIGHT)
|
||||
|
||||
|
||||
def test_justify_alignment_handler_assignment(self):
|
||||
"""Test that Line correctly assigns JustifyAlignmentHandler for JUSTIFY alignment"""
|
||||
justify_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.JUSTIFY)
|
||||
|
||||
justify_line = Line(
|
||||
self.spacing,
|
||||
self.origin,
|
||||
self.size,
|
||||
self.draw,
|
||||
font=self.style,
|
||||
halign=Alignment.JUSTIFY)
|
||||
|
||||
self.assertIsInstance(justify_line._alignment_handler, JustifyAlignmentHandler)
|
||||
|
||||
|
||||
def test_left_alignment_word_addition(self):
|
||||
"""Test adding words to a left-aligned line"""
|
||||
left_line = Line(self.spacing, self.origin, self.size, self.draw, halign=Alignment.LEFT)
|
||||
|
||||
left_line = Line(
|
||||
self.spacing,
|
||||
self.origin,
|
||||
self.size,
|
||||
self.draw,
|
||||
halign=Alignment.LEFT)
|
||||
|
||||
# Add words until line is full or we run out
|
||||
words_added = 0
|
||||
for word in self.test_words:
|
||||
@ -78,15 +112,21 @@ class TestAlignmentHandlers(unittest.TestCase):
|
||||
break
|
||||
else:
|
||||
words_added += 1
|
||||
|
||||
|
||||
# Should have added at least some words
|
||||
self.assertGreater(words_added, 0)
|
||||
self.assertEqual(len(left_line.text_objects), words_added)
|
||||
|
||||
|
||||
def test_center_alignment_word_addition(self):
|
||||
"""Test adding words to a center-aligned line"""
|
||||
center_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.CENTER)
|
||||
|
||||
center_line = Line(
|
||||
self.spacing,
|
||||
self.origin,
|
||||
self.size,
|
||||
self.draw,
|
||||
font=self.style,
|
||||
halign=Alignment.CENTER)
|
||||
|
||||
# Add words until line is full or we run out
|
||||
words_added = 0
|
||||
for word in self.test_words:
|
||||
@ -96,15 +136,21 @@ class TestAlignmentHandlers(unittest.TestCase):
|
||||
break
|
||||
else:
|
||||
words_added += 1
|
||||
|
||||
|
||||
# Should have added at least some words
|
||||
self.assertGreater(words_added, 0)
|
||||
self.assertEqual(len(center_line.text_objects), words_added)
|
||||
|
||||
|
||||
def test_right_alignment_word_addition(self):
|
||||
"""Test adding words to a right-aligned line"""
|
||||
right_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.RIGHT)
|
||||
|
||||
right_line = Line(
|
||||
self.spacing,
|
||||
self.origin,
|
||||
self.size,
|
||||
self.draw,
|
||||
font=self.style,
|
||||
halign=Alignment.RIGHT)
|
||||
|
||||
# Add words until line is full or we run out
|
||||
words_added = 0
|
||||
for word in self.test_words:
|
||||
@ -114,15 +160,21 @@ class TestAlignmentHandlers(unittest.TestCase):
|
||||
break
|
||||
else:
|
||||
words_added += 1
|
||||
|
||||
|
||||
# Should have added at least some words
|
||||
self.assertGreater(words_added, 0)
|
||||
self.assertEqual(len(right_line.text_objects), words_added)
|
||||
|
||||
|
||||
def test_justify_alignment_word_addition(self):
|
||||
"""Test adding words to a justified line"""
|
||||
justify_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.JUSTIFY)
|
||||
|
||||
justify_line = Line(
|
||||
self.spacing,
|
||||
self.origin,
|
||||
self.size,
|
||||
self.draw,
|
||||
font=self.style,
|
||||
halign=Alignment.JUSTIFY)
|
||||
|
||||
# Add words until line is full or we run out
|
||||
words_added = 0
|
||||
for word in self.test_words:
|
||||
@ -132,16 +184,17 @@ class TestAlignmentHandlers(unittest.TestCase):
|
||||
break
|
||||
else:
|
||||
words_added += 1
|
||||
|
||||
|
||||
# Should have added at least some words
|
||||
self.assertGreater(words_added, 0)
|
||||
self.assertEqual(len(justify_line.text_objects), words_added)
|
||||
|
||||
|
||||
def test_handler_spacing_and_position_calculations(self):
|
||||
"""Test spacing and position calculations for different alignment handlers"""
|
||||
# Create sample text objects
|
||||
text_objects = [Text(word, self.style, self.draw) for word in ["Hello", "World"]]
|
||||
|
||||
text_objects = [Text(word, self.style, self.draw)
|
||||
for word in ["Hello", "World"]]
|
||||
|
||||
# Test each handler type
|
||||
handlers = [
|
||||
("Left", LeftAlignmentHandler()),
|
||||
@ -149,108 +202,132 @@ class TestAlignmentHandlers(unittest.TestCase):
|
||||
("Right", CenterRightAlignmentHandler(Alignment.RIGHT)),
|
||||
("Justify", JustifyAlignmentHandler())
|
||||
]
|
||||
|
||||
|
||||
for name, handler in handlers:
|
||||
with self.subTest(handler=name):
|
||||
spacing_calc, position, overflow = handler.calculate_spacing_and_position(
|
||||
text_objects, self.line_width, self.spacing[0], self.spacing[1])
|
||||
|
||||
|
||||
# Check that spacing is a valid number
|
||||
self.assertIsInstance(spacing_calc, (int, float))
|
||||
self.assertGreaterEqual(spacing_calc, 0)
|
||||
|
||||
|
||||
# Check that position is a valid number
|
||||
self.assertIsInstance(position, (int, float))
|
||||
self.assertGreaterEqual(position, 0)
|
||||
|
||||
|
||||
# Check that overflow is a boolean
|
||||
self.assertIsInstance(overflow, bool)
|
||||
|
||||
|
||||
# Position should be within line width (unless overflow)
|
||||
if not overflow:
|
||||
self.assertLessEqual(position, self.line_width)
|
||||
|
||||
|
||||
def test_left_handler_spacing_calculation(self):
|
||||
"""Test specific spacing calculation for left alignment"""
|
||||
handler = LeftAlignmentHandler()
|
||||
text_objects = [Text(word, self.style, self.draw) for word in ["Hello", "World"]]
|
||||
|
||||
text_objects = [Text(word, self.style, self.draw)
|
||||
for word in ["Hello", "World"]]
|
||||
|
||||
spacing_calc, position, overflow = handler.calculate_spacing_and_position(
|
||||
text_objects, self.line_width, self.spacing[0], self.spacing[1])
|
||||
|
||||
|
||||
# Left alignment should have position at 0
|
||||
self.assertEqual(position, 0)
|
||||
|
||||
|
||||
# Should not overflow with reasonable text
|
||||
self.assertFalse(overflow)
|
||||
|
||||
|
||||
def test_center_handler_spacing_calculation(self):
|
||||
"""Test specific spacing calculation for center alignment"""
|
||||
handler = CenterRightAlignmentHandler(Alignment.CENTER)
|
||||
text_objects = [Text(word, self.style, self.draw) for word in ["Hello", "World"]]
|
||||
|
||||
text_objects = [Text(word, self.style, self.draw)
|
||||
for word in ["Hello", "World"]]
|
||||
|
||||
spacing_calc, position, overflow = handler.calculate_spacing_and_position(
|
||||
text_objects, self.line_width, self.spacing[0], self.spacing[1])
|
||||
|
||||
|
||||
# Center alignment should have position > 0 (centered) if no overflow
|
||||
if not overflow:
|
||||
self.assertGreaterEqual(position, 0)
|
||||
|
||||
|
||||
def test_right_handler_spacing_calculation(self):
|
||||
"""Test specific spacing calculation for right alignment"""
|
||||
handler = CenterRightAlignmentHandler(Alignment.RIGHT)
|
||||
text_objects = [Text(word, self.style, self.draw) for word in ["Hello", "World"]]
|
||||
|
||||
text_objects = [Text(word, self.style, self.draw)
|
||||
for word in ["Hello", "World"]]
|
||||
|
||||
spacing_calc, position, overflow = handler.calculate_spacing_and_position(
|
||||
text_objects, self.line_width, self.spacing[0], self.spacing[1])
|
||||
|
||||
|
||||
# Right alignment should have position >= 0
|
||||
self.assertGreaterEqual(position, 0)
|
||||
|
||||
|
||||
def test_justify_handler_spacing_calculation(self):
|
||||
"""Test specific spacing calculation for justify alignment"""
|
||||
handler = JustifyAlignmentHandler()
|
||||
text_objects = [Text(word, self.style, self.draw) for word in ["Hello", "World"]]
|
||||
|
||||
text_objects = [Text(word, self.style, self.draw)
|
||||
for word in ["Hello", "World"]]
|
||||
|
||||
spacing_calc, position, overflow = handler.calculate_spacing_and_position(
|
||||
text_objects, self.line_width, self.spacing[0], self.spacing[1])
|
||||
|
||||
|
||||
# Justify alignment should have position at 0
|
||||
self.assertEqual(position, 0)
|
||||
|
||||
|
||||
# Check spacing is reasonable
|
||||
self.assertGreaterEqual(spacing_calc, 0)
|
||||
|
||||
|
||||
def test_empty_line_alignment_handlers(self):
|
||||
"""Test alignment handlers with empty lines"""
|
||||
alignments = [Alignment.LEFT, Alignment.CENTER, Alignment.RIGHT, Alignment.JUSTIFY]
|
||||
|
||||
alignments = [
|
||||
Alignment.LEFT,
|
||||
Alignment.CENTER,
|
||||
Alignment.RIGHT,
|
||||
Alignment.JUSTIFY]
|
||||
|
||||
for alignment in alignments:
|
||||
with self.subTest(alignment=alignment):
|
||||
line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=alignment)
|
||||
|
||||
line = Line(
|
||||
self.spacing,
|
||||
self.origin,
|
||||
self.size,
|
||||
self.draw,
|
||||
font=self.style,
|
||||
halign=alignment)
|
||||
|
||||
# Empty line should still have a handler
|
||||
self.assertIsNotNone(line._alignment_handler)
|
||||
|
||||
|
||||
# Should be able to render empty line
|
||||
line.render()
|
||||
|
||||
|
||||
def test_single_word_line_alignment(self):
|
||||
"""Test alignment handlers with single word lines"""
|
||||
alignments = [Alignment.LEFT, Alignment.CENTER, Alignment.RIGHT, Alignment.JUSTIFY]
|
||||
|
||||
alignments = [
|
||||
Alignment.LEFT,
|
||||
Alignment.CENTER,
|
||||
Alignment.RIGHT,
|
||||
Alignment.JUSTIFY]
|
||||
|
||||
for alignment in alignments:
|
||||
with self.subTest(alignment=alignment):
|
||||
line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=alignment)
|
||||
|
||||
line = Line(
|
||||
self.spacing,
|
||||
self.origin,
|
||||
self.size,
|
||||
self.draw,
|
||||
font=self.style,
|
||||
halign=alignment)
|
||||
|
||||
# Create a test word
|
||||
test_word = Word("test", self.style)
|
||||
|
||||
|
||||
# Add a single word
|
||||
result, part = line.add_word(test_word)
|
||||
self.assertTrue(result) # Should fit
|
||||
self.assertIsNone(part) # No overflow part
|
||||
|
||||
|
||||
# Should be able to render single word line
|
||||
line.render()
|
||||
self.assertEqual(len(line.text_objects), 1)
|
||||
|
||||
@ -6,7 +6,7 @@ Tests the Box class which handles basic box model rendering with alignment.
|
||||
import unittest
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from unittest.mock import Mock, patch
|
||||
from unittest.mock import Mock
|
||||
|
||||
from pyWebLayout.concrete.box import Box
|
||||
from pyWebLayout.style import Alignment
|
||||
@ -14,17 +14,17 @@ from pyWebLayout.style import Alignment
|
||||
|
||||
class TestBox(unittest.TestCase):
|
||||
"""Test cases for the Box class"""
|
||||
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
self.origin = (10, 20)
|
||||
self.size = (100, 50)
|
||||
self.callback = Mock()
|
||||
|
||||
|
||||
def test_box_initialization_basic(self):
|
||||
"""Test basic box initialization"""
|
||||
box = Box(self.origin, self.size)
|
||||
|
||||
|
||||
np.testing.assert_array_equal(box._origin, np.array([10, 20]))
|
||||
np.testing.assert_array_equal(box._size, np.array([100, 50]))
|
||||
np.testing.assert_array_equal(box._end, np.array([110, 70]))
|
||||
@ -33,66 +33,65 @@ class TestBox(unittest.TestCase):
|
||||
self.assertIsNone(box._mode)
|
||||
self.assertEqual(box._halign, Alignment.CENTER)
|
||||
self.assertEqual(box._valign, Alignment.CENTER)
|
||||
|
||||
|
||||
def test_box_initialization_with_callback(self):
|
||||
"""Test box initialization with callback"""
|
||||
box = Box(self.origin, self.size, callback=self.callback)
|
||||
|
||||
|
||||
self.assertEqual(box._callback, self.callback)
|
||||
|
||||
|
||||
def test_box_initialization_with_sheet(self):
|
||||
"""Test box initialization with image sheet"""
|
||||
sheet = Image.new('RGBA', (200, 100), (255, 255, 255, 255))
|
||||
box = Box(self.origin, self.size, sheet=sheet)
|
||||
|
||||
|
||||
self.assertEqual(box._sheet, sheet)
|
||||
self.assertEqual(box._mode, 'RGBA')
|
||||
|
||||
|
||||
def test_box_initialization_with_mode(self):
|
||||
"""Test box initialization with explicit mode"""
|
||||
box = Box(self.origin, self.size, mode='RGB')
|
||||
|
||||
|
||||
self.assertEqual(box._mode, 'RGB')
|
||||
|
||||
|
||||
def test_box_initialization_with_alignment(self):
|
||||
"""Test box initialization with custom alignment"""
|
||||
box = Box(self.origin, self.size, halign=Alignment.LEFT, valign=Alignment.TOP)
|
||||
|
||||
|
||||
self.assertEqual(box._halign, Alignment.LEFT)
|
||||
self.assertEqual(box._valign, Alignment.TOP)
|
||||
|
||||
|
||||
def test_in_shape_point_inside(self):
|
||||
"""Test in_shape method with point inside box"""
|
||||
box = Box(self.origin, self.size)
|
||||
|
||||
|
||||
# Test point inside
|
||||
self.assertTrue(box.in_shape(np.array([50, 40])))
|
||||
self.assertTrue(box.in_shape(np.array([10, 20]))) # Top-left corner
|
||||
self.assertTrue(box.in_shape(np.array([109, 69]))) # Just inside bottom-right
|
||||
|
||||
|
||||
def test_in_shape_point_outside(self):
|
||||
"""Test in_shape method with point outside box"""
|
||||
box = Box(self.origin, self.size)
|
||||
|
||||
|
||||
# Test points outside
|
||||
self.assertFalse(box.in_shape(np.array([5, 15]))) # Before origin
|
||||
self.assertFalse(box.in_shape(np.array([110, 70]))) # At end (exclusive)
|
||||
self.assertFalse(box.in_shape(np.array([150, 100]))) # Far outside
|
||||
|
||||
self.assertFalse(box.in_shape(np.array([110, 70]))) # At end (exclusive)
|
||||
self.assertFalse(box.in_shape(np.array([150, 100]))) # Far outside
|
||||
|
||||
def test_in_shape_multiple_points(self):
|
||||
"""Test in_shape method with array of points"""
|
||||
box = Box(self.origin, self.size)
|
||||
|
||||
|
||||
points = np.array([[50, 40], [5, 15], [109, 69], [110, 70]])
|
||||
result = box.in_shape(points)
|
||||
|
||||
|
||||
np.testing.assert_array_equal(result, [True, False, True, False])
|
||||
|
||||
|
||||
def test_properties_access(self):
|
||||
"""Test that properties can be accessed correctly"""
|
||||
box = Box(self.origin, self.size, callback=self.callback)
|
||||
|
||||
|
||||
# Test that origin property works (should be available via inheritance)
|
||||
np.testing.assert_array_equal(box._origin, np.array([10, 20]))
|
||||
np.testing.assert_array_equal(box._size, np.array([100, 50]))
|
||||
|
||||
@ -5,23 +5,21 @@ Tests the LinkText, ButtonText, and FormFieldText classes.
|
||||
|
||||
import unittest
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from pyWebLayout.concrete.functional import (
|
||||
LinkText, ButtonText, FormFieldText,
|
||||
LinkText, ButtonText, FormFieldText,
|
||||
create_link_text, create_button_text, create_form_field_text
|
||||
)
|
||||
from pyWebLayout.abstract.functional import (
|
||||
Link, Button, Form, FormField, LinkType, FormFieldType
|
||||
Link, Button, FormField, LinkType, FormFieldType
|
||||
)
|
||||
from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration
|
||||
from pyWebLayout.style import Alignment
|
||||
from pyWebLayout.style import Font, TextDecoration
|
||||
|
||||
|
||||
class TestLinkText(unittest.TestCase):
|
||||
"""Test cases for the LinkText class"""
|
||||
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
self.font = Font(
|
||||
@ -30,86 +28,89 @@ class TestLinkText(unittest.TestCase):
|
||||
colour=(0, 0, 0)
|
||||
)
|
||||
self.callback = Mock()
|
||||
|
||||
|
||||
# Create different types of links
|
||||
self.internal_link = Link("chapter1", LinkType.INTERNAL, self.callback)
|
||||
self.external_link = Link("https://example.com", LinkType.EXTERNAL, self.callback)
|
||||
self.external_link = Link(
|
||||
"https://example.com",
|
||||
LinkType.EXTERNAL,
|
||||
self.callback)
|
||||
self.api_link = Link("/api/settings", LinkType.API, self.callback)
|
||||
self.function_link = Link("toggle_theme", LinkType.FUNCTION, self.callback)
|
||||
|
||||
|
||||
# Create a mock ImageDraw.Draw object
|
||||
self.mock_draw = Mock()
|
||||
|
||||
|
||||
def test_link_text_initialization_internal(self):
|
||||
"""Test initialization of internal link text"""
|
||||
link_text = "Go to Chapter 1"
|
||||
renderable = LinkText(self.internal_link, link_text, self.font, self.mock_draw)
|
||||
|
||||
|
||||
self.assertEqual(renderable._link, self.internal_link)
|
||||
self.assertEqual(renderable.text, link_text)
|
||||
self.assertFalse(renderable._hovered)
|
||||
self.assertEqual(renderable._callback, self.internal_link.execute)
|
||||
|
||||
|
||||
# Check that the font has underline decoration and blue color
|
||||
self.assertEqual(renderable.style.decoration, TextDecoration.UNDERLINE)
|
||||
self.assertEqual(renderable.style.colour, (0, 0, 200))
|
||||
|
||||
|
||||
def test_link_text_initialization_external(self):
|
||||
"""Test initialization of external link text"""
|
||||
link_text = "Visit Example"
|
||||
renderable = LinkText(self.external_link, link_text, self.font, self.mock_draw)
|
||||
|
||||
|
||||
self.assertEqual(renderable._link, self.external_link)
|
||||
# External links should have darker blue color
|
||||
self.assertEqual(renderable.style.colour, (0, 0, 180))
|
||||
|
||||
|
||||
def test_link_text_initialization_api(self):
|
||||
"""Test initialization of API link text"""
|
||||
link_text = "Settings"
|
||||
renderable = LinkText(self.api_link, link_text, self.font, self.mock_draw)
|
||||
|
||||
|
||||
self.assertEqual(renderable._link, self.api_link)
|
||||
# API links should have red color
|
||||
self.assertEqual(renderable.style.colour, (150, 0, 0))
|
||||
|
||||
|
||||
def test_link_text_initialization_function(self):
|
||||
"""Test initialization of function link text"""
|
||||
link_text = "Toggle Theme"
|
||||
renderable = LinkText(self.function_link, link_text, self.font, self.mock_draw)
|
||||
|
||||
|
||||
self.assertEqual(renderable._link, self.function_link)
|
||||
# Function links should have green color
|
||||
self.assertEqual(renderable.style.colour, (0, 120, 0))
|
||||
|
||||
|
||||
def test_link_property(self):
|
||||
"""Test link property accessor"""
|
||||
link_text = "Test Link"
|
||||
renderable = LinkText(self.internal_link, link_text, self.font, self.mock_draw)
|
||||
|
||||
|
||||
self.assertEqual(renderable.link, self.internal_link)
|
||||
|
||||
|
||||
def test_set_hovered(self):
|
||||
"""Test setting hover state"""
|
||||
link_text = "Hover Test"
|
||||
renderable = LinkText(self.internal_link, link_text, self.font, self.mock_draw)
|
||||
|
||||
|
||||
self.assertFalse(renderable._hovered)
|
||||
|
||||
|
||||
renderable.set_hovered(True)
|
||||
self.assertTrue(renderable._hovered)
|
||||
|
||||
|
||||
renderable.set_hovered(False)
|
||||
self.assertFalse(renderable._hovered)
|
||||
|
||||
|
||||
def test_render_normal_state(self):
|
||||
"""Test rendering in normal state"""
|
||||
link_text = "Test Link"
|
||||
renderable = LinkText(self.internal_link, link_text, self.font, self.mock_draw)
|
||||
|
||||
|
||||
# Mock the parent Text render method
|
||||
with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render:
|
||||
renderable.render()
|
||||
|
||||
|
||||
# Parent render should be called
|
||||
mock_parent_render.assert_called_once()
|
||||
# Should not draw highlight when not hovered
|
||||
@ -120,21 +121,25 @@ class TestLinkText(unittest.TestCase):
|
||||
link_text = "Test Link"
|
||||
renderable = LinkText(self.internal_link, link_text, self.font, self.mock_draw)
|
||||
renderable.set_origin(np.array([10, 20]))
|
||||
|
||||
# Mock width property
|
||||
|
||||
# Mock width property
|
||||
renderable._width = 80
|
||||
|
||||
|
||||
# Point inside link
|
||||
self.assertTrue(renderable.in_object((15, 25)))
|
||||
|
||||
|
||||
# Point outside link
|
||||
self.assertFalse(renderable.in_object((200, 200)))
|
||||
|
||||
|
||||
def test_factory_function(self):
|
||||
"""Test the create_link_text factory function"""
|
||||
link_text = "Factory Test"
|
||||
renderable = create_link_text(self.internal_link, link_text, self.font, self.mock_draw)
|
||||
|
||||
renderable = create_link_text(
|
||||
self.internal_link,
|
||||
link_text,
|
||||
self.font,
|
||||
self.mock_draw)
|
||||
|
||||
self.assertIsInstance(renderable, LinkText)
|
||||
self.assertEqual(renderable.text, link_text)
|
||||
self.assertEqual(renderable.link, self.internal_link)
|
||||
@ -142,7 +147,7 @@ class TestLinkText(unittest.TestCase):
|
||||
|
||||
class TestButtonText(unittest.TestCase):
|
||||
"""Test cases for the ButtonText class"""
|
||||
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
self.font = Font(
|
||||
@ -153,112 +158,114 @@ class TestButtonText(unittest.TestCase):
|
||||
self.callback = Mock()
|
||||
self.button = Button("Click Me", self.callback)
|
||||
self.mock_draw = Mock()
|
||||
|
||||
|
||||
def test_button_text_initialization(self):
|
||||
"""Test basic button text initialization"""
|
||||
renderable = ButtonText(self.button, self.font, self.mock_draw)
|
||||
|
||||
|
||||
self.assertEqual(renderable._button, self.button)
|
||||
self.assertEqual(renderable.text, "Click Me")
|
||||
self.assertFalse(renderable._pressed)
|
||||
self.assertFalse(renderable._hovered)
|
||||
self.assertEqual(renderable._callback, self.button.execute)
|
||||
self.assertEqual(renderable._padding, (4, 8, 4, 8))
|
||||
|
||||
|
||||
def test_button_text_with_custom_padding(self):
|
||||
"""Test button text initialization with custom padding"""
|
||||
custom_padding = (8, 12, 8, 12)
|
||||
|
||||
|
||||
renderable = ButtonText(
|
||||
self.button, self.font, self.mock_draw,
|
||||
padding=custom_padding
|
||||
)
|
||||
|
||||
|
||||
self.assertEqual(renderable._padding, custom_padding)
|
||||
|
||||
|
||||
def test_button_property(self):
|
||||
"""Test button property accessor"""
|
||||
renderable = ButtonText(self.button, self.font, self.mock_draw)
|
||||
|
||||
|
||||
self.assertEqual(renderable.button, self.button)
|
||||
|
||||
|
||||
def test_set_pressed(self):
|
||||
"""Test setting pressed state"""
|
||||
renderable = ButtonText(self.button, self.font, self.mock_draw)
|
||||
|
||||
|
||||
self.assertFalse(renderable._pressed)
|
||||
|
||||
|
||||
renderable.set_pressed(True)
|
||||
self.assertTrue(renderable._pressed)
|
||||
|
||||
|
||||
renderable.set_pressed(False)
|
||||
self.assertFalse(renderable._pressed)
|
||||
|
||||
|
||||
def test_set_hovered(self):
|
||||
"""Test setting hover state"""
|
||||
renderable = ButtonText(self.button, self.font, self.mock_draw)
|
||||
|
||||
|
||||
self.assertFalse(renderable._hovered)
|
||||
|
||||
|
||||
renderable.set_hovered(True)
|
||||
self.assertTrue(renderable._hovered)
|
||||
|
||||
|
||||
renderable.set_hovered(False)
|
||||
self.assertFalse(renderable._hovered)
|
||||
|
||||
|
||||
def test_size_property(self):
|
||||
"""Test size property includes padding"""
|
||||
renderable = ButtonText(self.button, self.font, self.mock_draw)
|
||||
|
||||
|
||||
# The size should be padded size, not just text size
|
||||
# Since we handle mocks in __init__, use the padded values directly
|
||||
expected_width = renderable._padded_width
|
||||
expected_height = renderable._padded_height
|
||||
|
||||
np.testing.assert_array_equal(renderable.size, np.array([expected_width, expected_height]))
|
||||
|
||||
|
||||
np.testing.assert_array_equal(
|
||||
renderable.size, np.array([expected_width, expected_height]))
|
||||
|
||||
def test_render_normal_state(self):
|
||||
"""Test rendering in normal state"""
|
||||
renderable = ButtonText(self.button, self.font, self.mock_draw)
|
||||
|
||||
|
||||
# Mock the parent Text render method
|
||||
with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render:
|
||||
renderable.render()
|
||||
|
||||
|
||||
# Should draw rounded rectangle for button background
|
||||
self.mock_draw.rounded_rectangle.assert_called_once()
|
||||
# Parent render should be called for text
|
||||
mock_parent_render.assert_called_once()
|
||||
|
||||
|
||||
def test_render_disabled_state(self):
|
||||
"""Test rendering disabled button"""
|
||||
disabled_button = Button("Disabled", self.callback, enabled=False)
|
||||
renderable = ButtonText(disabled_button, self.font, self.mock_draw)
|
||||
|
||||
|
||||
# Mock the parent Text render method
|
||||
with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render:
|
||||
renderable.render()
|
||||
|
||||
|
||||
# Should still draw button background
|
||||
self.mock_draw.rounded_rectangle.assert_called_once()
|
||||
mock_parent_render.assert_called_once()
|
||||
|
||||
|
||||
def test_in_object_with_padding(self):
|
||||
"""Test in_object method considers padding"""
|
||||
renderable = ButtonText(self.button, self.font, self.mock_draw)
|
||||
renderable.set_origin(np.array([10, 20]))
|
||||
|
||||
|
||||
# Point inside button (including padding)
|
||||
self.assertTrue(renderable.in_object((15, 25)))
|
||||
|
||||
|
||||
# Point outside button
|
||||
self.assertFalse(renderable.in_object((200, 200)))
|
||||
|
||||
|
||||
def test_factory_function(self):
|
||||
"""Test the create_button_text factory function"""
|
||||
custom_padding = (6, 10, 6, 10)
|
||||
renderable = create_button_text(self.button, self.font, self.mock_draw, custom_padding)
|
||||
|
||||
renderable = create_button_text(
|
||||
self.button, self.font, self.mock_draw, custom_padding)
|
||||
|
||||
self.assertIsInstance(renderable, ButtonText)
|
||||
self.assertEqual(renderable.text, "Click Me")
|
||||
self.assertEqual(renderable.button, self.button)
|
||||
@ -267,7 +274,7 @@ class TestButtonText(unittest.TestCase):
|
||||
|
||||
class TestFormFieldText(unittest.TestCase):
|
||||
"""Test cases for the FormFieldText class"""
|
||||
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
self.font = Font(
|
||||
@ -275,96 +282,102 @@ class TestFormFieldText(unittest.TestCase):
|
||||
font_size=12,
|
||||
colour=(0, 0, 0)
|
||||
)
|
||||
|
||||
|
||||
# Create different types of form fields
|
||||
self.text_field = FormField("username", FormFieldType.TEXT, "Username")
|
||||
self.password_field = FormField("password", FormFieldType.PASSWORD, "Password")
|
||||
self.textarea_field = FormField("description", FormFieldType.TEXTAREA, "Description")
|
||||
self.textarea_field = FormField(
|
||||
"description", FormFieldType.TEXTAREA, "Description")
|
||||
self.select_field = FormField("country", FormFieldType.SELECT, "Country")
|
||||
|
||||
|
||||
self.mock_draw = Mock()
|
||||
|
||||
|
||||
def test_form_field_text_initialization(self):
|
||||
"""Test initialization of form field text"""
|
||||
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
|
||||
|
||||
|
||||
self.assertEqual(renderable._field, self.text_field)
|
||||
self.assertEqual(renderable.text, "Username")
|
||||
self.assertFalse(renderable._focused)
|
||||
self.assertEqual(renderable._field_height, 24)
|
||||
|
||||
|
||||
def test_form_field_text_with_custom_height(self):
|
||||
"""Test form field text with custom field height"""
|
||||
custom_height = 40
|
||||
renderable = FormFieldText(self.text_field, self.font, self.mock_draw, custom_height)
|
||||
|
||||
renderable = FormFieldText(
|
||||
self.text_field,
|
||||
self.font,
|
||||
self.mock_draw,
|
||||
custom_height)
|
||||
|
||||
self.assertEqual(renderable._field_height, custom_height)
|
||||
|
||||
|
||||
def test_field_property(self):
|
||||
"""Test field property accessor"""
|
||||
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
|
||||
|
||||
|
||||
self.assertEqual(renderable.field, self.text_field)
|
||||
|
||||
|
||||
def test_set_focused(self):
|
||||
"""Test setting focus state"""
|
||||
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
|
||||
|
||||
|
||||
self.assertFalse(renderable._focused)
|
||||
|
||||
|
||||
renderable.set_focused(True)
|
||||
self.assertTrue(renderable._focused)
|
||||
|
||||
|
||||
renderable.set_focused(False)
|
||||
self.assertFalse(renderable._focused)
|
||||
|
||||
|
||||
def test_size_includes_field_area(self):
|
||||
"""Test size property includes field area"""
|
||||
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
|
||||
|
||||
|
||||
# Size should include label height + gap + field height
|
||||
expected_height = renderable._style.font_size + 5 + renderable._field_height
|
||||
expected_width = renderable._field_width # Use the calculated field width
|
||||
|
||||
np.testing.assert_array_equal(renderable.size, np.array([expected_width, expected_height]))
|
||||
|
||||
|
||||
np.testing.assert_array_equal(
|
||||
renderable.size, np.array([expected_width, expected_height]))
|
||||
|
||||
def test_render_text_field(self):
|
||||
"""Test rendering text field"""
|
||||
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
|
||||
|
||||
|
||||
# Mock the parent Text render method
|
||||
with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render:
|
||||
renderable.render()
|
||||
|
||||
|
||||
# Should render label
|
||||
mock_parent_render.assert_called_once()
|
||||
# Should draw field background rectangle
|
||||
self.mock_draw.rectangle.assert_called_once()
|
||||
|
||||
|
||||
def test_render_field_with_value(self):
|
||||
"""Test rendering field with value"""
|
||||
self.text_field.value = "john_doe"
|
||||
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
|
||||
|
||||
|
||||
# Mock the parent Text render method
|
||||
with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render:
|
||||
renderable.render()
|
||||
|
||||
|
||||
# Should render label
|
||||
mock_parent_render.assert_called_once()
|
||||
# Should draw field background and value text
|
||||
self.mock_draw.rectangle.assert_called_once()
|
||||
self.mock_draw.text.assert_called_once()
|
||||
|
||||
|
||||
def test_render_password_field(self):
|
||||
"""Test rendering password field with masked value"""
|
||||
self.password_field.value = "secret123"
|
||||
renderable = FormFieldText(self.password_field, self.font, self.mock_draw)
|
||||
|
||||
|
||||
# Mock the parent Text render method
|
||||
with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render:
|
||||
renderable.render()
|
||||
|
||||
|
||||
# Should render label and field
|
||||
mock_parent_render.assert_called_once()
|
||||
self.mock_draw.rectangle.assert_called_once()
|
||||
@ -373,61 +386,62 @@ class TestFormFieldText(unittest.TestCase):
|
||||
# Check that the text call used masked characters
|
||||
call_args = self.mock_draw.text.call_args[0]
|
||||
self.assertEqual(call_args[1], "•" * len("secret123"))
|
||||
|
||||
|
||||
def test_render_focused_field(self):
|
||||
"""Test rendering focused field"""
|
||||
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
|
||||
renderable.set_focused(True)
|
||||
|
||||
|
||||
# Mock the parent Text render method
|
||||
with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render:
|
||||
renderable.render()
|
||||
|
||||
|
||||
# Should render with focus styling
|
||||
mock_parent_render.assert_called_once()
|
||||
self.mock_draw.rectangle.assert_called_once()
|
||||
|
||||
|
||||
def test_handle_click_inside_field(self):
|
||||
"""Test clicking inside field area"""
|
||||
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
|
||||
|
||||
|
||||
# Click inside field area (below label)
|
||||
field_area_y = renderable._style.font_size + 5 + 10 # Within field area
|
||||
field_area_point = (15, field_area_y)
|
||||
result = renderable.handle_click(field_area_point)
|
||||
|
||||
|
||||
# Should return True and set focused
|
||||
self.assertTrue(result)
|
||||
self.assertTrue(renderable._focused)
|
||||
|
||||
|
||||
def test_handle_click_outside_field(self):
|
||||
"""Test clicking outside field area"""
|
||||
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
|
||||
|
||||
|
||||
# Click outside field area
|
||||
outside_point = (200, 200)
|
||||
result = renderable.handle_click(outside_point)
|
||||
|
||||
|
||||
# Should return False and not set focused
|
||||
self.assertFalse(result)
|
||||
self.assertFalse(renderable._focused)
|
||||
|
||||
|
||||
def test_in_object(self):
|
||||
"""Test in_object method"""
|
||||
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
|
||||
renderable.set_origin(np.array([10, 20]))
|
||||
|
||||
|
||||
# Point inside field (including label and input area)
|
||||
self.assertTrue(renderable.in_object((15, 25)))
|
||||
|
||||
|
||||
# Point outside field
|
||||
self.assertFalse(renderable.in_object((200, 200)))
|
||||
|
||||
|
||||
def test_factory_function(self):
|
||||
"""Test the create_form_field_text factory function"""
|
||||
custom_height = 30
|
||||
renderable = create_form_field_text(self.text_field, self.font, self.mock_draw, custom_height)
|
||||
|
||||
renderable = create_form_field_text(
|
||||
self.text_field, self.font, self.mock_draw, custom_height)
|
||||
|
||||
self.assertIsInstance(renderable, FormFieldText)
|
||||
self.assertEqual(renderable.text, "Username")
|
||||
self.assertEqual(renderable.field, self.text_field)
|
||||
@ -436,7 +450,7 @@ class TestFormFieldText(unittest.TestCase):
|
||||
|
||||
class TestInteractionCallbacks(unittest.TestCase):
|
||||
"""Test cases for interaction functionality"""
|
||||
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
self.font = Font(font_size=12, colour=(0, 0, 0))
|
||||
@ -455,7 +469,8 @@ class TestInteractionCallbacks(unittest.TestCase):
|
||||
|
||||
def test_link_text_interaction(self):
|
||||
"""Test that LinkText properly handles interaction"""
|
||||
# Use a FUNCTION link type which calls the callback, not INTERNAL which returns location
|
||||
# Use a FUNCTION link type which calls the callback, not INTERNAL which
|
||||
# returns location
|
||||
link = Link("test_function", LinkType.FUNCTION, self.link_callback)
|
||||
renderable = LinkText(link, "Test Link", self.font, self.mock_draw)
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@ import os
|
||||
import tempfile
|
||||
import numpy as np
|
||||
from PIL import Image as PILImage, ImageDraw
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from pyWebLayout.concrete.image import RenderableImage
|
||||
from pyWebLayout.abstract.block import Image as AbstractImage
|
||||
@ -17,67 +17,67 @@ from pyWebLayout.style import Alignment
|
||||
|
||||
class TestRenderableImage(unittest.TestCase):
|
||||
"""Test cases for the RenderableImage class"""
|
||||
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
# Create a temporary test image
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.test_image_path = os.path.join(self.temp_dir, "test_image.png")
|
||||
|
||||
|
||||
# Create a simple test image
|
||||
test_img = PILImage.new('RGB', (100, 80), (255, 0, 0)) # Red image
|
||||
test_img.save(self.test_image_path)
|
||||
|
||||
|
||||
# Create abstract image objects
|
||||
self.abstract_image = AbstractImage(self.test_image_path, "Test Image", 100, 80)
|
||||
self.abstract_image_no_dims = AbstractImage(self.test_image_path, "Test Image")
|
||||
|
||||
|
||||
# Create a canvas and draw object for testing
|
||||
self.canvas = PILImage.new('RGBA', (400, 300), (255, 255, 255, 255))
|
||||
self.draw = ImageDraw.Draw(self.canvas)
|
||||
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up test fixtures"""
|
||||
# Clean up temporary files
|
||||
try:
|
||||
os.unlink(self.test_image_path)
|
||||
os.rmdir(self.temp_dir)
|
||||
except:
|
||||
except BaseException:
|
||||
pass
|
||||
|
||||
|
||||
def test_renderable_image_initialization_basic(self):
|
||||
"""Test basic image initialization"""
|
||||
renderable = RenderableImage(self.abstract_image, self.canvas)
|
||||
|
||||
|
||||
self.assertEqual(renderable._abstract_image, self.abstract_image)
|
||||
self.assertEqual(renderable._canvas, self.canvas)
|
||||
self.assertIsNotNone(renderable._pil_image)
|
||||
self.assertIsNone(renderable._error_message)
|
||||
self.assertEqual(renderable._halign, Alignment.CENTER)
|
||||
self.assertEqual(renderable._valign, Alignment.CENTER)
|
||||
|
||||
|
||||
def test_renderable_image_initialization_with_constraints(self):
|
||||
"""Test image initialization with size constraints"""
|
||||
max_width = 50
|
||||
max_height = 40
|
||||
|
||||
|
||||
renderable = RenderableImage(
|
||||
self.abstract_image,
|
||||
self.draw,
|
||||
max_width=max_width,
|
||||
max_height=max_height
|
||||
)
|
||||
|
||||
|
||||
self.assertEqual(renderable._abstract_image, self.abstract_image)
|
||||
# Size should be constrained
|
||||
self.assertLessEqual(renderable._size[0], max_width)
|
||||
self.assertLessEqual(renderable._size[1], max_height)
|
||||
|
||||
|
||||
def test_renderable_image_initialization_with_custom_params(self):
|
||||
"""Test image initialization with custom parameters"""
|
||||
custom_origin = (20, 30)
|
||||
custom_size = (120, 90)
|
||||
|
||||
|
||||
renderable = RenderableImage(
|
||||
self.abstract_image,
|
||||
self.draw,
|
||||
@ -86,30 +86,30 @@ class TestRenderableImage(unittest.TestCase):
|
||||
halign=Alignment.LEFT,
|
||||
valign=Alignment.TOP
|
||||
)
|
||||
|
||||
|
||||
np.testing.assert_array_equal(renderable._origin, np.array(custom_origin))
|
||||
np.testing.assert_array_equal(renderable._size, np.array(custom_size))
|
||||
self.assertEqual(renderable._halign, Alignment.LEFT)
|
||||
self.assertEqual(renderable._valign, Alignment.TOP)
|
||||
|
||||
|
||||
def test_load_image_local_file(self):
|
||||
"""Test loading image from local file"""
|
||||
renderable = RenderableImage(self.abstract_image, self.draw)
|
||||
|
||||
|
||||
# Image should be loaded
|
||||
self.assertIsNotNone(renderable._pil_image)
|
||||
self.assertIsNone(renderable._error_message)
|
||||
self.assertEqual(renderable._pil_image.size, (100, 80))
|
||||
|
||||
|
||||
def test_load_image_nonexistent_file(self):
|
||||
"""Test loading image from nonexistent file"""
|
||||
bad_abstract = AbstractImage("/nonexistent/path.png", "Bad Image")
|
||||
renderable = RenderableImage(bad_abstract, self.draw)
|
||||
|
||||
|
||||
# Should have error message, no PIL image
|
||||
self.assertIsNone(renderable._pil_image)
|
||||
self.assertIsNotNone(renderable._error_message)
|
||||
|
||||
|
||||
@patch('requests.get')
|
||||
def test_load_image_url_success(self, mock_get):
|
||||
"""Test loading image from URL (success)"""
|
||||
@ -118,14 +118,14 @@ class TestRenderableImage(unittest.TestCase):
|
||||
mock_response.status_code = 200
|
||||
mock_response.content = open(self.test_image_path, 'rb').read()
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
|
||||
url_abstract = AbstractImage("https://example.com/image.png", "URL Image")
|
||||
renderable = RenderableImage(url_abstract, self.draw)
|
||||
|
||||
|
||||
# Should successfully load image
|
||||
self.assertIsNotNone(renderable._pil_image)
|
||||
self.assertIsNone(renderable._error_message)
|
||||
|
||||
|
||||
@patch('requests.get')
|
||||
def test_load_image_url_failure(self, mock_get):
|
||||
"""Test loading image from URL (failure)"""
|
||||
@ -133,14 +133,16 @@ class TestRenderableImage(unittest.TestCase):
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 404
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
url_abstract = AbstractImage("https://example.com/notfound.png", "Bad URL Image")
|
||||
|
||||
url_abstract = AbstractImage(
|
||||
"https://example.com/notfound.png",
|
||||
"Bad URL Image")
|
||||
renderable = RenderableImage(url_abstract, self.draw)
|
||||
|
||||
|
||||
# Should have error message
|
||||
self.assertIsNone(renderable._pil_image)
|
||||
self.assertIsNotNone(renderable._error_message)
|
||||
|
||||
|
||||
def test_load_image_no_requests_library(self):
|
||||
"""Test loading URL image when requests library is not available"""
|
||||
# Mock the import to raise ImportError for requests
|
||||
@ -148,24 +150,24 @@ class TestRenderableImage(unittest.TestCase):
|
||||
if name == 'requests':
|
||||
raise ImportError("No module named 'requests'")
|
||||
return __import__(name, *args, **kwargs)
|
||||
|
||||
|
||||
with patch('builtins.__import__', side_effect=mock_import):
|
||||
url_abstract = AbstractImage("https://example.com/image.png", "URL Image")
|
||||
renderable = RenderableImage(url_abstract, self.draw)
|
||||
|
||||
|
||||
# Should have error message about missing requests
|
||||
self.assertIsNone(renderable._pil_image)
|
||||
self.assertIsNotNone(renderable._error_message)
|
||||
self.assertIn("Requests library not available", renderable._error_message)
|
||||
|
||||
|
||||
def test_resize_image_fit_within_bounds(self):
|
||||
"""Test image resizing to fit within bounds"""
|
||||
renderable = RenderableImage(self.abstract_image, self.draw)
|
||||
|
||||
|
||||
# Original image is 100x80, resize to fit in 50x50
|
||||
renderable._size = np.array([50, 50])
|
||||
resized = renderable._resize_image()
|
||||
|
||||
|
||||
self.assertIsInstance(resized, PILImage.Image)
|
||||
# Should maintain aspect ratio and fit within bounds
|
||||
self.assertLessEqual(resized.width, 50)
|
||||
@ -174,90 +176,90 @@ class TestRenderableImage(unittest.TestCase):
|
||||
original_ratio = 100 / 80
|
||||
new_ratio = resized.width / resized.height
|
||||
self.assertAlmostEqual(original_ratio, new_ratio, delta=0.1)
|
||||
|
||||
|
||||
def test_resize_image_larger_target(self):
|
||||
"""Test image resizing when target is larger than original"""
|
||||
renderable = RenderableImage(self.abstract_image, self.draw)
|
||||
|
||||
|
||||
# Target size larger than original
|
||||
renderable._size = np.array([200, 160])
|
||||
resized = renderable._resize_image()
|
||||
|
||||
|
||||
self.assertIsInstance(resized, PILImage.Image)
|
||||
# Should scale up to fill the space while maintaining aspect ratio
|
||||
self.assertGreater(resized.width, 100)
|
||||
self.assertGreater(resized.height, 80)
|
||||
|
||||
|
||||
def test_resize_image_no_image(self):
|
||||
"""Test resize when no image is loaded"""
|
||||
bad_abstract = AbstractImage("/nonexistent/path.png", "Bad Image")
|
||||
renderable = RenderableImage(bad_abstract, self.draw)
|
||||
|
||||
|
||||
resized = renderable._resize_image()
|
||||
|
||||
|
||||
# Should return a placeholder image
|
||||
self.assertIsInstance(resized, PILImage.Image)
|
||||
self.assertEqual(resized.mode, 'RGBA')
|
||||
|
||||
|
||||
def test_draw_error_placeholder(self):
|
||||
"""Test drawing error placeholder"""
|
||||
bad_abstract = AbstractImage("/nonexistent/path.png", "Bad Image")
|
||||
renderable = RenderableImage(bad_abstract, self.canvas)
|
||||
renderable._error_message = "File not found"
|
||||
|
||||
|
||||
# Set origin for the placeholder
|
||||
renderable.set_origin(np.array([10, 20]))
|
||||
|
||||
|
||||
# Call the error placeholder method
|
||||
renderable._draw_error_placeholder()
|
||||
|
||||
|
||||
# We can't easily test the actual drawing without complex mocking,
|
||||
# but we can verify the method doesn't raise an exception
|
||||
self.assertIsNotNone(renderable._error_message)
|
||||
|
||||
|
||||
def test_draw_error_placeholder_with_text(self):
|
||||
"""Test drawing error placeholder with error message"""
|
||||
bad_abstract = AbstractImage("/nonexistent/path.png", "Bad Image")
|
||||
renderable = RenderableImage(bad_abstract, self.canvas)
|
||||
renderable._error_message = "File not found"
|
||||
|
||||
|
||||
# Set origin for the placeholder
|
||||
renderable.set_origin(np.array([10, 20]))
|
||||
|
||||
|
||||
# Call the error placeholder method
|
||||
renderable._draw_error_placeholder()
|
||||
|
||||
|
||||
# Verify error message is set
|
||||
self.assertIsNotNone(renderable._error_message)
|
||||
self.assertIn("File not found", renderable._error_message)
|
||||
|
||||
|
||||
def test_render_successful_image(self):
|
||||
"""Test rendering successfully loaded image"""
|
||||
renderable = RenderableImage(self.abstract_image, self.canvas)
|
||||
renderable.set_origin(np.array([10, 20]))
|
||||
|
||||
|
||||
# Render returns nothing (draws directly into canvas)
|
||||
result = renderable.render()
|
||||
|
||||
|
||||
# Result should be None as it draws directly
|
||||
self.assertIsNone(result)
|
||||
|
||||
|
||||
# Verify image was loaded
|
||||
self.assertIsNotNone(renderable._pil_image)
|
||||
|
||||
|
||||
def test_render_failed_image(self):
|
||||
"""Test rendering when image failed to load"""
|
||||
bad_abstract = AbstractImage("/nonexistent/path.png", "Bad Image")
|
||||
renderable = RenderableImage(bad_abstract, self.canvas)
|
||||
renderable.set_origin(np.array([10, 20]))
|
||||
|
||||
|
||||
with patch.object(renderable, '_draw_error_placeholder') as mock_draw_error:
|
||||
result = renderable.render()
|
||||
|
||||
|
||||
# Result should be None as it draws directly
|
||||
self.assertIsNone(result)
|
||||
mock_draw_error.assert_called_once()
|
||||
|
||||
|
||||
def test_render_with_left_alignment(self):
|
||||
"""Test rendering with left alignment"""
|
||||
renderable = RenderableImage(
|
||||
@ -267,14 +269,14 @@ class TestRenderableImage(unittest.TestCase):
|
||||
valign=Alignment.TOP
|
||||
)
|
||||
renderable.set_origin(np.array([10, 20]))
|
||||
|
||||
|
||||
result = renderable.render()
|
||||
|
||||
|
||||
# Result should be None as it draws directly
|
||||
self.assertIsNone(result)
|
||||
self.assertEqual(renderable._halign, Alignment.LEFT)
|
||||
self.assertEqual(renderable._valign, Alignment.TOP)
|
||||
|
||||
|
||||
def test_render_with_right_alignment(self):
|
||||
"""Test rendering with right alignment"""
|
||||
renderable = RenderableImage(
|
||||
@ -284,96 +286,97 @@ class TestRenderableImage(unittest.TestCase):
|
||||
valign=Alignment.BOTTOM
|
||||
)
|
||||
renderable.set_origin(np.array([10, 20]))
|
||||
|
||||
|
||||
result = renderable.render()
|
||||
|
||||
|
||||
# Result should be None as it draws directly
|
||||
self.assertIsNone(result)
|
||||
self.assertEqual(renderable._halign, Alignment.RIGHT)
|
||||
self.assertEqual(renderable._valign, Alignment.BOTTOM)
|
||||
|
||||
|
||||
|
||||
|
||||
def test_render_rgb_image_conversion(self):
|
||||
"""Test rendering RGB image (should be converted to RGBA)"""
|
||||
# Our test image is RGB, so this should test the conversion path
|
||||
renderable = RenderableImage(self.abstract_image, self.canvas)
|
||||
renderable.set_origin(np.array([10, 20]))
|
||||
|
||||
|
||||
result = renderable.render()
|
||||
|
||||
|
||||
# Result should be None as it draws directly
|
||||
self.assertIsNone(result)
|
||||
self.assertIsNotNone(renderable._pil_image)
|
||||
|
||||
|
||||
def test_in_object(self):
|
||||
"""Test in_object method"""
|
||||
renderable = RenderableImage(self.abstract_image, self.draw, origin=(10, 20))
|
||||
|
||||
|
||||
# Point inside image
|
||||
self.assertTrue(renderable.in_object((15, 25)))
|
||||
|
||||
|
||||
# Point outside image
|
||||
self.assertFalse(renderable.in_object((200, 200)))
|
||||
|
||||
|
||||
def test_in_object_with_numpy_array(self):
|
||||
"""Test in_object with numpy array point"""
|
||||
renderable = RenderableImage(self.abstract_image, self.draw, origin=(10, 20))
|
||||
|
||||
|
||||
# Point inside image as numpy array
|
||||
point = np.array([15, 25])
|
||||
self.assertTrue(renderable.in_object(point))
|
||||
|
||||
|
||||
# Point outside image as numpy array
|
||||
point = np.array([200, 200])
|
||||
self.assertFalse(renderable.in_object(point))
|
||||
|
||||
|
||||
def test_image_size_calculation_with_abstract_image_dimensions(self):
|
||||
"""Test that size is calculated from abstract image when available"""
|
||||
# Abstract image has dimensions 100x80
|
||||
renderable = RenderableImage(self.abstract_image, self.draw)
|
||||
|
||||
|
||||
# Size should match the calculated scaled dimensions
|
||||
expected_size = self.abstract_image.calculate_scaled_dimensions()
|
||||
np.testing.assert_array_equal(renderable._size, np.array(expected_size))
|
||||
|
||||
|
||||
def test_image_size_calculation_with_constraints(self):
|
||||
"""Test size calculation with max constraints"""
|
||||
max_width = 60
|
||||
max_height = 50
|
||||
|
||||
|
||||
renderable = RenderableImage(
|
||||
self.abstract_image,
|
||||
self.draw,
|
||||
max_width=max_width,
|
||||
max_height=max_height
|
||||
)
|
||||
|
||||
|
||||
# Size should respect constraints
|
||||
self.assertLessEqual(renderable._size[0], max_width)
|
||||
self.assertLessEqual(renderable._size[1], max_height)
|
||||
|
||||
|
||||
def test_image_without_initial_dimensions(self):
|
||||
"""Test image without initial dimensions in abstract image"""
|
||||
renderable = RenderableImage(self.abstract_image_no_dims, self.draw)
|
||||
|
||||
|
||||
# Should still work, using default or calculated size
|
||||
self.assertIsInstance(renderable._size, np.ndarray)
|
||||
self.assertEqual(len(renderable._size), 2)
|
||||
|
||||
|
||||
def test_set_origin_method(self):
|
||||
"""Test the set_origin method"""
|
||||
renderable = RenderableImage(self.abstract_image, self.draw)
|
||||
|
||||
|
||||
new_origin = np.array([50, 60])
|
||||
renderable.set_origin(new_origin)
|
||||
|
||||
|
||||
np.testing.assert_array_equal(renderable.origin, new_origin)
|
||||
|
||||
|
||||
def test_properties(self):
|
||||
"""Test the property methods"""
|
||||
renderable = RenderableImage(self.abstract_image, self.draw, origin=(10, 20), size=(100, 80))
|
||||
|
||||
renderable = RenderableImage(
|
||||
self.abstract_image, self.draw, origin=(
|
||||
10, 20), size=(
|
||||
100, 80))
|
||||
|
||||
np.testing.assert_array_equal(renderable.origin, np.array([10, 20]))
|
||||
np.testing.assert_array_equal(renderable.size, np.array([100, 80]))
|
||||
self.assertEqual(renderable.width, 100)
|
||||
|
||||
@ -6,30 +6,29 @@ Tests the Text and Line classes for text rendering functionality.
|
||||
import unittest
|
||||
import numpy as np
|
||||
import os
|
||||
from PIL import Image, ImageFont, ImageDraw
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
from PIL import Image, ImageDraw
|
||||
from unittest.mock import Mock
|
||||
|
||||
from pyWebLayout.concrete.text import Text, Line
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.style import Font, FontStyle, FontWeight, TextDecoration
|
||||
from pyWebLayout.style import Alignment
|
||||
from tests.utils.test_fonts import create_default_test_font, ensure_consistent_font_in_tests
|
||||
|
||||
|
||||
class TestText(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# Ensure consistent font usage across tests
|
||||
ensure_consistent_font_in_tests()
|
||||
|
||||
|
||||
# Create a real PIL image (canvas) for testing
|
||||
self.canvas = Image.new('RGB', (800, 600), color='white')
|
||||
|
||||
|
||||
# Create a real ImageDraw object
|
||||
self.draw = ImageDraw.Draw(self.canvas)
|
||||
|
||||
|
||||
# Create a consistent test Font object using bundled font
|
||||
self.style = create_default_test_font()
|
||||
|
||||
|
||||
def test_init(self):
|
||||
text_instance = Text(text="Test", style=self.style, draw=self.draw)
|
||||
self.assertEqual(text_instance.text, "Test")
|
||||
@ -59,10 +58,10 @@ class TestText(unittest.TestCase):
|
||||
text_instance = Text(text="Test", style=self.style, draw=self.draw)
|
||||
# Set a position so we can render without issues
|
||||
text_instance.set_origin(np.array([10, 50]))
|
||||
|
||||
|
||||
# This should not raise any exceptions with real objects
|
||||
text_instance.render()
|
||||
|
||||
|
||||
# We can verify the canvas was modified (pixel check)
|
||||
# After rendering, some pixels should have changed from pure white
|
||||
# This is a more realistic test than checking mock calls
|
||||
@ -70,7 +69,7 @@ class TestText(unittest.TestCase):
|
||||
def test_text_dimensions(self):
|
||||
"""Test that text dimensions are calculated correctly with real font"""
|
||||
text_instance = Text(text="Test", style=self.style, draw=self.draw)
|
||||
|
||||
|
||||
# With real objects, we should get actual width measurements
|
||||
self.assertGreater(text_instance.width, 0)
|
||||
self.assertIsInstance(text_instance.width, (int, float))
|
||||
@ -94,10 +93,10 @@ class TestText(unittest.TestCase):
|
||||
text_instance = Text(text="Hello World!", style=self.style, draw=self.draw)
|
||||
text_instance.set_origin(np.array([50, 100]))
|
||||
text_instance.render()
|
||||
|
||||
|
||||
# Optionally save the canvas for visual inspection
|
||||
self._save_test_image("rendered_text.png")
|
||||
|
||||
|
||||
# Verify that something was drawn (canvas is no longer pure white everywhere)
|
||||
# Convert to array and check if any pixels changed
|
||||
pixels = np.array(self.canvas)
|
||||
@ -120,13 +119,13 @@ class TestLine(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# Ensure consistent font usage across tests
|
||||
ensure_consistent_font_in_tests()
|
||||
|
||||
|
||||
# Create a real PIL image (canvas) for testing
|
||||
self.canvas = Image.new('RGB', (800, 600), color='white')
|
||||
|
||||
|
||||
# Create a real ImageDraw object
|
||||
self.draw = ImageDraw.Draw(self.canvas)
|
||||
|
||||
|
||||
# Create a consistent test Font object using bundled font
|
||||
self.style = create_default_test_font()
|
||||
|
||||
@ -135,7 +134,7 @@ class TestLine(unittest.TestCase):
|
||||
spacing = (5, 15) # min_spacing, max_spacing
|
||||
origin = np.array([0, 0])
|
||||
size = np.array([400, 50])
|
||||
|
||||
|
||||
line = Line(
|
||||
spacing=spacing,
|
||||
origin=origin,
|
||||
@ -144,7 +143,7 @@ class TestLine(unittest.TestCase):
|
||||
font=self.style,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
|
||||
self.assertEqual(line._spacing, spacing)
|
||||
np.testing.assert_array_equal(line._origin, origin)
|
||||
np.testing.assert_array_equal(line._size, size)
|
||||
@ -155,7 +154,7 @@ class TestLine(unittest.TestCase):
|
||||
spacing = (5, 15)
|
||||
origin = np.array([0, 0])
|
||||
size = np.array([400, 50])
|
||||
|
||||
|
||||
line = Line(
|
||||
spacing=spacing,
|
||||
origin=origin,
|
||||
@ -164,10 +163,10 @@ class TestLine(unittest.TestCase):
|
||||
font=self.style,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
|
||||
# Create a word to add
|
||||
word = Word(text="Hello", style=self.style)
|
||||
|
||||
|
||||
# This test may need adjustment based on the actual implementation
|
||||
|
||||
success, overflow_part = line.add_word(word)
|
||||
@ -181,7 +180,7 @@ class TestLine(unittest.TestCase):
|
||||
spacing = (5, 15)
|
||||
origin = np.array([0, 0])
|
||||
size = np.array([400, 50])
|
||||
|
||||
|
||||
line = Line(
|
||||
spacing=spacing,
|
||||
origin=origin,
|
||||
@ -190,25 +189,26 @@ class TestLine(unittest.TestCase):
|
||||
font=self.style,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
|
||||
# Add words until the line is full
|
||||
words_added = 0
|
||||
for i in range(100):
|
||||
word = Word(text="Amsterdam", style=self.style)
|
||||
success, overflow_part = line.add_word(word)
|
||||
|
||||
|
||||
if overflow_part:
|
||||
# Word was hyphenated - overflow occurred
|
||||
self.assertIsNotNone(overflow_part.text)
|
||||
return
|
||||
elif not success:
|
||||
# Line is full, word couldn't be added
|
||||
self.assertGreater(words_added, 0, "Should have added at least one word before line filled")
|
||||
self.assertGreater(
|
||||
words_added, 0, "Should have added at least one word before line filled")
|
||||
return
|
||||
else:
|
||||
# Word was added successfully
|
||||
words_added += 1
|
||||
|
||||
|
||||
self.fail("Expected line to fill or overflow to occur but reached max iterations")
|
||||
|
||||
def test_line_add_word_until_overflow_small(self):
|
||||
@ -216,7 +216,7 @@ class TestLine(unittest.TestCase):
|
||||
spacing = (5, 15)
|
||||
origin = np.array([0, 0])
|
||||
size = np.array([400, 50])
|
||||
|
||||
|
||||
line = Line(
|
||||
spacing=spacing,
|
||||
origin=origin,
|
||||
@ -225,20 +225,20 @@ class TestLine(unittest.TestCase):
|
||||
font=self.style,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
|
||||
# Create a word to add
|
||||
|
||||
for i in range(100):
|
||||
word = Word(text="Aslan", style=self.style)
|
||||
|
||||
|
||||
# This test may need adjustment based on the actual implementation
|
||||
|
||||
success, overflow_part = line.add_word(word)
|
||||
# If successful, the word should be added
|
||||
if success == False:
|
||||
if not success:
|
||||
self.assertIsNone(overflow_part)
|
||||
return
|
||||
|
||||
|
||||
self.fail("Expected line to reach capacity but reached max iterations")
|
||||
|
||||
def test_line_add_word_until_overflow_long_brute(self):
|
||||
@ -246,7 +246,7 @@ class TestLine(unittest.TestCase):
|
||||
spacing = (5, 15)
|
||||
origin = np.array([0, 0])
|
||||
size = np.array([400, 50])
|
||||
|
||||
|
||||
line = Line(
|
||||
spacing=spacing,
|
||||
origin=origin,
|
||||
@ -256,13 +256,14 @@ class TestLine(unittest.TestCase):
|
||||
halign=Alignment.LEFT,
|
||||
min_word_length_for_brute_force=6 # Lower threshold to enable hyphenation for shorter words
|
||||
)
|
||||
|
||||
|
||||
# Use a longer word to trigger brute force hyphenation
|
||||
words_added = 0
|
||||
for i in range(100):
|
||||
word = Word(text="AAAAAAAA", style=self.style) # 8 A's to ensure it's long enough
|
||||
# 8 A's to ensure it's long enough
|
||||
word = Word(text="AAAAAAAA", style=self.style)
|
||||
success, overflow_part = line.add_word(word)
|
||||
|
||||
|
||||
if overflow_part:
|
||||
# Word was hyphenated - verify overflow part exists
|
||||
self.assertIsNotNone(overflow_part.text)
|
||||
@ -270,20 +271,20 @@ class TestLine(unittest.TestCase):
|
||||
return
|
||||
elif not success:
|
||||
# Line is full, word couldn't be added
|
||||
self.assertGreater(words_added, 0, "Should have added at least one word before line filled")
|
||||
self.assertGreater(
|
||||
words_added, 0, "Should have added at least one word before line filled")
|
||||
return
|
||||
else:
|
||||
words_added += 1
|
||||
|
||||
self.fail("Expected line to fill or overflow to occur but reached max iterations")
|
||||
|
||||
self.fail("Expected line to fill or overflow to occur but reached max iterations")
|
||||
|
||||
def test_line_render(self):
|
||||
"""Test line rendering with real objects"""
|
||||
spacing = (5, 15)
|
||||
origin = np.array([50, 100])
|
||||
size = np.array([400, 50])
|
||||
|
||||
|
||||
line = Line(
|
||||
spacing=spacing,
|
||||
origin=origin,
|
||||
@ -292,7 +293,7 @@ class TestLine(unittest.TestCase):
|
||||
font=self.style,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
|
||||
# Try to render the line (even if empty)
|
||||
try:
|
||||
line.render()
|
||||
|
||||
@ -88,8 +88,11 @@ class TestLinkedWordHyphenation(unittest.TestCase):
|
||||
|
||||
# Both parts should be LinkText (this is the bug we're testing for)
|
||||
for text_obj in line._text_objects:
|
||||
self.assertIsInstance(text_obj, LinkText,
|
||||
f"Hyphenated LinkedWord part should be LinkText, got {type(text_obj)}")
|
||||
self.assertIsInstance(
|
||||
text_obj,
|
||||
LinkText,
|
||||
f"Hyphenated LinkedWord part should be LinkText, got {
|
||||
type(text_obj)}")
|
||||
self.assertEqual(text_obj.link.location, linked_word.location)
|
||||
|
||||
# The overflow should also be LinkText if it's hyphenated
|
||||
|
||||
@ -9,10 +9,9 @@ Unit tests for the new Page implementation to verify it meets the requirements:
|
||||
"""
|
||||
import unittest
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
from PIL import Image
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from pyWebLayout.style.fonts import Font
|
||||
from pyWebLayout.core.base import Renderable, Queriable
|
||||
|
||||
|
||||
@ -23,7 +22,7 @@ class SimpleTestRenderable(Renderable, Queriable):
|
||||
self._text = text
|
||||
self.size = size
|
||||
self._origin = np.array([0, 0])
|
||||
|
||||
|
||||
def render(self):
|
||||
"""Render returns None - drawing is done via the page's draw object"""
|
||||
return None
|
||||
@ -31,7 +30,7 @@ class SimpleTestRenderable(Renderable, Queriable):
|
||||
|
||||
class TestPageImplementation(unittest.TestCase):
|
||||
"""Test cases for the Page class implementation"""
|
||||
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
self.basic_style = PageStyle(
|
||||
@ -42,81 +41,84 @@ class TestPageImplementation(unittest.TestCase):
|
||||
padding=(15, 15, 15, 15),
|
||||
background_color=(240, 240, 240)
|
||||
)
|
||||
|
||||
|
||||
self.page_size = (800, 600)
|
||||
|
||||
|
||||
def test_page_creation_with_style(self):
|
||||
"""Test creating a page with a PageStyle"""
|
||||
page = Page(size=self.page_size, style=self.basic_style)
|
||||
|
||||
|
||||
self.assertEqual(page.size, self.page_size)
|
||||
self.assertEqual(page.style, self.basic_style)
|
||||
self.assertEqual(page.border_size, 2)
|
||||
|
||||
|
||||
def test_page_creation_without_style(self):
|
||||
"""Test creating a page without a PageStyle (should use defaults)"""
|
||||
page = Page(size=self.page_size)
|
||||
|
||||
|
||||
self.assertEqual(page.size, self.page_size)
|
||||
self.assertIsNotNone(page.style)
|
||||
|
||||
|
||||
def test_page_canvas_and_content_sizes(self):
|
||||
"""Test that page correctly calculates canvas and content sizes"""
|
||||
style = PageStyle(
|
||||
border_width=5,
|
||||
padding=(10, 20, 30, 40) # top, right, bottom, left
|
||||
)
|
||||
|
||||
|
||||
page = Page(size=self.page_size, style=style)
|
||||
|
||||
|
||||
# Canvas size should be page size minus borders
|
||||
expected_canvas_size = (790, 590) # 800-10, 600-10 (border on both sides)
|
||||
self.assertEqual(page.canvas_size, expected_canvas_size)
|
||||
|
||||
|
||||
# Content size should be canvas minus padding
|
||||
expected_content_size = (730, 550) # 790-60, 590-40 (padding left+right, top+bottom)
|
||||
# 790-60, 590-40 (padding left+right, top+bottom)
|
||||
expected_content_size = (730, 550)
|
||||
self.assertEqual(page.content_size, expected_content_size)
|
||||
|
||||
|
||||
def test_page_add_remove_children(self):
|
||||
"""Test adding and removing children from the page"""
|
||||
page = Page(size=self.page_size)
|
||||
|
||||
|
||||
# Initially no children
|
||||
self.assertEqual(len(page.children), 0)
|
||||
|
||||
|
||||
# Add children
|
||||
child1 = SimpleTestRenderable("Child 1")
|
||||
child2 = SimpleTestRenderable("Child 2")
|
||||
|
||||
|
||||
page.add_child(child1)
|
||||
self.assertEqual(len(page.children), 1)
|
||||
self.assertIn(child1, page.children)
|
||||
|
||||
|
||||
page.add_child(child2)
|
||||
self.assertEqual(len(page.children), 2)
|
||||
self.assertIn(child2, page.children)
|
||||
|
||||
|
||||
# Test method chaining
|
||||
child3 = SimpleTestRenderable("Child 3")
|
||||
result = page.add_child(child3)
|
||||
self.assertIs(result, page) # Should return self for chaining
|
||||
self.assertEqual(len(page.children), 3)
|
||||
self.assertIn(child3, page.children)
|
||||
|
||||
# Remove childce you’ll notice is that responses don’t stream character-by-character like other providers. Instead, Claude Code processes your full request before sending back the complete response.
|
||||
|
||||
# Remove childce you’ll notice is that responses don’t stream
|
||||
# character-by-character like other providers. Instead, Claude Code
|
||||
# processes your full request before sending back the complete response.
|
||||
removed = page.remove_child(child2)
|
||||
self.assertTrue(removed)
|
||||
self.assertEqual(len(page.children), 2)
|
||||
self.assertNotIn(child2, page.children)
|
||||
|
||||
|
||||
# Try to remove non-existent child
|
||||
removed = page.remove_child(child2)
|
||||
self.assertFalse(removed)
|
||||
|
||||
|
||||
# Clear all children
|
||||
page.clear_children()
|
||||
self.assertEqual(len(page.children), 0)
|
||||
|
||||
|
||||
def test_page_render(self):
|
||||
"""Test that page renders and creates a canvas"""
|
||||
style = PageStyle(
|
||||
@ -124,24 +126,24 @@ class TestPageImplementation(unittest.TestCase):
|
||||
border_color=(255, 0, 0),
|
||||
background_color=(255, 255, 255)
|
||||
)
|
||||
|
||||
|
||||
page = Page(size=(200, 150), style=style)
|
||||
|
||||
|
||||
# Add a child
|
||||
child = SimpleTestRenderable("Test child")
|
||||
page.add_child(child)
|
||||
|
||||
|
||||
# Render the page
|
||||
image = page.render()
|
||||
|
||||
|
||||
# Check that we got an image
|
||||
self.assertIsInstance(image, Image.Image)
|
||||
self.assertEqual(image.size, (200, 150))
|
||||
self.assertEqual(image.mode, 'RGBA')
|
||||
|
||||
|
||||
# Check that draw object is available
|
||||
self.assertIsNotNone(page.draw)
|
||||
|
||||
|
||||
def test_page_query_point(self):
|
||||
"""Test querying points to find children"""
|
||||
page = Page(size=(400, 300))
|
||||
@ -167,22 +169,22 @@ class TestPageImplementation(unittest.TestCase):
|
||||
result = page.query_point((300, 250))
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.object_type, "empty")
|
||||
|
||||
|
||||
def test_page_in_object(self):
|
||||
"""Test that page correctly implements in_object"""
|
||||
page = Page(size=(400, 300))
|
||||
|
||||
|
||||
# Points within page bounds
|
||||
self.assertTrue(page.in_object((0, 0)))
|
||||
self.assertTrue(page.in_object((200, 150)))
|
||||
self.assertTrue(page.in_object((399, 299)))
|
||||
|
||||
|
||||
# Points outside page bounds
|
||||
self.assertFalse(page.in_object((-1, 0)))
|
||||
self.assertFalse(page.in_object((0, -1)))
|
||||
self.assertFalse(page.in_object((400, 299)))
|
||||
self.assertFalse(page.in_object((399, 300)))
|
||||
|
||||
|
||||
def test_page_with_borders(self):
|
||||
"""Test page rendering with borders"""
|
||||
style = PageStyle(
|
||||
@ -190,33 +192,33 @@ class TestPageImplementation(unittest.TestCase):
|
||||
border_color=(128, 128, 128),
|
||||
background_color=(255, 255, 255)
|
||||
)
|
||||
|
||||
|
||||
page = Page(size=(100, 100), style=style)
|
||||
image = page.render()
|
||||
|
||||
|
||||
# Check that image was created
|
||||
self.assertIsInstance(image, Image.Image)
|
||||
self.assertEqual(image.size, (100, 100))
|
||||
|
||||
|
||||
# The border should be drawn but we can't easily test pixel values
|
||||
# Just verify the image exists and has the right properties
|
||||
|
||||
|
||||
def test_page_border_size_property(self):
|
||||
"""Test that border_size property returns correct value"""
|
||||
# Test with border
|
||||
style_with_border = PageStyle(border_width=5)
|
||||
page_with_border = Page(size=self.page_size, style=style_with_border)
|
||||
self.assertEqual(page_with_border.border_size, 5)
|
||||
|
||||
|
||||
# Test without border
|
||||
style_no_border = PageStyle(border_width=0)
|
||||
page_no_border = Page(size=self.page_size, style=style_no_border)
|
||||
self.assertEqual(page_no_border.border_size, 0)
|
||||
|
||||
|
||||
def test_page_style_properties(self):
|
||||
"""Test that page correctly exposes style properties"""
|
||||
page = Page(size=self.page_size, style=self.basic_style)
|
||||
|
||||
|
||||
# Test that style properties are accessible
|
||||
self.assertEqual(page.style.border_width, 2)
|
||||
self.assertEqual(page.style.border_color, (255, 0, 0))
|
||||
@ -224,30 +226,30 @@ class TestPageImplementation(unittest.TestCase):
|
||||
self.assertEqual(page.style.inter_block_spacing, 20)
|
||||
self.assertEqual(page.style.padding, (15, 15, 15, 15))
|
||||
self.assertEqual(page.style.background_color, (240, 240, 240))
|
||||
|
||||
|
||||
def test_page_children_list_operations(self):
|
||||
"""Test that children list behaves correctly"""
|
||||
page = Page(size=self.page_size)
|
||||
|
||||
|
||||
# Test that children is initially empty list
|
||||
self.assertIsInstance(page.children, list)
|
||||
self.assertEqual(len(page.children), 0)
|
||||
|
||||
|
||||
# Test adding multiple children
|
||||
children = [
|
||||
SimpleTestRenderable(f"Child {i}")
|
||||
for i in range(5)
|
||||
]
|
||||
|
||||
|
||||
for child in children:
|
||||
page.add_child(child)
|
||||
|
||||
|
||||
self.assertEqual(len(page.children), 5)
|
||||
|
||||
|
||||
# Test that children are in the correct order
|
||||
for i, child in enumerate(page.children):
|
||||
self.assertEqual(child._text, f"Child {i}")
|
||||
|
||||
|
||||
def test_page_can_fit_line_boundary_checking(self):
|
||||
"""Test that can_fit_line correctly checks bottom boundary"""
|
||||
# Create page with known dimensions
|
||||
@ -259,63 +261,64 @@ class TestPageImplementation(unittest.TestCase):
|
||||
padding=(10, 10, 10, 10)
|
||||
)
|
||||
page = Page(size=(800, 600), style=style)
|
||||
|
||||
|
||||
# Initial y_offset should be at border + padding_top = 50
|
||||
self.assertEqual(page._current_y_offset, 50)
|
||||
|
||||
|
||||
# Test 1: Line that fits comfortably
|
||||
line_height = 20
|
||||
max_y = 600 - 40 - 10 # 550
|
||||
_max_y = 600 - 40 - 10 # 550
|
||||
self.assertTrue(page.can_fit_line(line_height))
|
||||
# Would end at 50 + 20 = 70, well within 550
|
||||
|
||||
|
||||
# Test 2: Simulate adding lines to fill the page
|
||||
# Available height: 550 - 50 = 500 pixels
|
||||
# With 20-pixel lines, we can fit 25 lines exactly
|
||||
for i in range(24): # Add 24 lines
|
||||
self.assertTrue(page.can_fit_line(20), f"Line {i+1} should fit")
|
||||
self.assertTrue(page.can_fit_line(20), f"Line {i + 1} should fit")
|
||||
# Simulate adding a line by updating y_offset
|
||||
page._current_y_offset += 20
|
||||
|
||||
|
||||
# After 24 lines: y_offset = 50 + (24 * 20) = 530
|
||||
self.assertEqual(page._current_y_offset, 530)
|
||||
|
||||
# Test 3: One more 20-pixel line should fit (530 + 20 = 550, exactly at boundary)
|
||||
|
||||
# Test 3: One more 20-pixel line should fit (530 + 20 = 550, exactly at
|
||||
# boundary)
|
||||
self.assertTrue(page.can_fit_line(20))
|
||||
page._current_y_offset += 20
|
||||
self.assertEqual(page._current_y_offset, 550)
|
||||
|
||||
|
||||
# Test 4: Now another line should NOT fit (550 + 20 = 570 > 550)
|
||||
self.assertFalse(page.can_fit_line(20))
|
||||
|
||||
|
||||
# Test 5: Even a 1-pixel line should not fit (550 + 1 = 551 > 550)
|
||||
self.assertFalse(page.can_fit_line(1))
|
||||
|
||||
|
||||
# Test 6: Edge case - exactly at boundary, 0-height line should fit
|
||||
self.assertTrue(page.can_fit_line(0))
|
||||
|
||||
|
||||
def test_page_can_fit_line_with_different_styles(self):
|
||||
"""Test can_fit_line with different page styles"""
|
||||
# Test with no border or padding
|
||||
style_no_border = PageStyle(border_width=0, padding=(0, 0, 0, 0))
|
||||
page_no_border = Page(size=(100, 100), style=style_no_border)
|
||||
|
||||
|
||||
# With no border/padding, y_offset starts at 0
|
||||
self.assertEqual(page_no_border._current_y_offset, 0)
|
||||
|
||||
|
||||
# Can fit a 100-pixel line exactly
|
||||
self.assertTrue(page_no_border.can_fit_line(100))
|
||||
|
||||
|
||||
# Cannot fit a 101-pixel line
|
||||
self.assertFalse(page_no_border.can_fit_line(101))
|
||||
|
||||
|
||||
# Test with large border and padding
|
||||
style_large = PageStyle(border_width=20, padding=(15, 15, 15, 15))
|
||||
page_large = Page(size=(200, 200), style=style_large)
|
||||
|
||||
|
||||
# y_offset starts at border + padding_top = 20 + 15 = 35
|
||||
self.assertEqual(page_large._current_y_offset, 35)
|
||||
|
||||
|
||||
# Max y = 200 - 20 - 15 = 165
|
||||
# Available height = 165 - 35 = 130 pixels
|
||||
self.assertTrue(page_large.can_fit_line(130))
|
||||
|
||||
@ -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__":
|
||||
|
||||
@ -8,7 +8,6 @@ import unittest
|
||||
import tempfile
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
from pyWebLayout.core.highlight import (
|
||||
Highlight,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -9,7 +9,6 @@ import unittest
|
||||
import tempfile
|
||||
import os
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
|
||||
# Import ebooklib for creating test EPUB files
|
||||
try:
|
||||
@ -21,8 +20,8 @@ except ImportError:
|
||||
from pyWebLayout.io.readers.epub_reader import read_epub, EPUBReader
|
||||
from pyWebLayout.abstract.document import Book
|
||||
from pyWebLayout.abstract.block import (
|
||||
Paragraph, Heading, HeadingLevel, Quote, CodeBlock,
|
||||
HList, ListStyle, Table, HorizontalRule, Image
|
||||
Paragraph, Heading, Quote, CodeBlock, HList,
|
||||
ListStyle, Table, Image
|
||||
)
|
||||
from pyWebLayout.style import FontWeight, FontStyle, TextDecoration
|
||||
|
||||
@ -30,12 +29,12 @@ from pyWebLayout.style import FontWeight, FontStyle, TextDecoration
|
||||
@unittest.skipUnless(EBOOKLIB_AVAILABLE, "ebooklib not available")
|
||||
class TestEPUBReader(unittest.TestCase):
|
||||
"""Test cases for EPUB reader functionality."""
|
||||
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test environment."""
|
||||
self.test_dir = tempfile.mkdtemp()
|
||||
self.epub_files = []
|
||||
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up test environment."""
|
||||
# Clean up test EPUB files
|
||||
@ -44,21 +43,21 @@ class TestEPUBReader(unittest.TestCase):
|
||||
os.remove(epub_file)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# Clean up test directory
|
||||
if os.path.exists(self.test_dir):
|
||||
shutil.rmtree(self.test_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def create_simple_epub(self, title="Test Book", author="Test Author"):
|
||||
"""Create a simple EPUB file for testing."""
|
||||
book = epub.EpubBook()
|
||||
|
||||
|
||||
# Set metadata
|
||||
book.set_identifier('test-id-123')
|
||||
book.set_title(title)
|
||||
book.set_language('en')
|
||||
book.add_author(author)
|
||||
|
||||
|
||||
# Create a simple chapter
|
||||
chapter1 = epub.EpubHtml(
|
||||
title='Chapter 1',
|
||||
@ -75,31 +74,32 @@ class TestEPUBReader(unittest.TestCase):
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
|
||||
|
||||
# Add chapter to book
|
||||
book.add_item(chapter1)
|
||||
|
||||
|
||||
# Define table of contents
|
||||
book.toc = (epub.Link("chapter1.xhtml", "Chapter 1", "ch1"),)
|
||||
|
||||
|
||||
# Add navigation files
|
||||
book.add_item(epub.EpubNcx())
|
||||
book.add_item(epub.EpubNav())
|
||||
|
||||
|
||||
# Define spine
|
||||
book.spine = ['nav', chapter1]
|
||||
|
||||
|
||||
# Create temporary file
|
||||
epub_path = os.path.join(self.test_dir, f'test_simple_{len(self.epub_files)}.epub')
|
||||
epub_path = os.path.join(self.test_dir,
|
||||
f'test_simple_{len(self.epub_files)}.epub')
|
||||
epub.write_epub(epub_path, book, {})
|
||||
self.epub_files.append(epub_path)
|
||||
|
||||
|
||||
return epub_path
|
||||
|
||||
|
||||
def create_complex_epub(self):
|
||||
"""Create a more complex EPUB file with multiple chapters and content types."""
|
||||
book = epub.EpubBook()
|
||||
|
||||
|
||||
# Set metadata
|
||||
book.set_identifier('complex-test-id-456')
|
||||
book.set_title('Complex Test Book')
|
||||
@ -109,7 +109,7 @@ class TestEPUBReader(unittest.TestCase):
|
||||
book.add_metadata('DC', 'subject', 'Testing')
|
||||
book.add_metadata('DC', 'date', '2024-01-01')
|
||||
book.add_metadata('DC', 'publisher', 'Test Publisher')
|
||||
|
||||
|
||||
# Chapter 1: Basic content
|
||||
chapter1 = epub.EpubHtml(
|
||||
title='Introduction',
|
||||
@ -126,7 +126,7 @@ class TestEPUBReader(unittest.TestCase):
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
|
||||
|
||||
# Chapter 2: Styled content
|
||||
chapter2 = epub.EpubHtml(
|
||||
title='Styled Content',
|
||||
@ -138,7 +138,7 @@ class TestEPUBReader(unittest.TestCase):
|
||||
<head><title>Styled Content</title></head>
|
||||
<body>
|
||||
<h1>Styled Content</h1>
|
||||
<p>This chapter contains various <strong>bold text</strong>, <em>italic text</em>,
|
||||
<p>This chapter contains various <strong>bold text</strong>, <em>italic text</em>,
|
||||
and <span style="color: red; font-weight: bold;">colored text</span>.</p>
|
||||
<h2>Subsection</h2>
|
||||
<p>Text with <u>underline</u> and <s>strikethrough</s>.</p>
|
||||
@ -147,7 +147,7 @@ class TestEPUBReader(unittest.TestCase):
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
|
||||
|
||||
# Chapter 3: Lists and quotes
|
||||
chapter3 = epub.EpubHtml(
|
||||
title='Lists and Quotes',
|
||||
@ -159,30 +159,30 @@ class TestEPUBReader(unittest.TestCase):
|
||||
<head><title>Lists and Quotes</title></head>
|
||||
<body>
|
||||
<h1>Lists and Quotes</h1>
|
||||
|
||||
|
||||
<h2>Unordered List</h2>
|
||||
<ul>
|
||||
<li>First item</li>
|
||||
<li><strong>Bold item</strong></li>
|
||||
<li>Item with <em>italic text</em></li>
|
||||
</ul>
|
||||
|
||||
|
||||
<h2>Ordered List</h2>
|
||||
<ol>
|
||||
<li>First numbered item</li>
|
||||
<li>Second numbered item</li>
|
||||
<li>Third numbered item</li>
|
||||
</ol>
|
||||
|
||||
|
||||
<h2>Quote</h2>
|
||||
<blockquote>
|
||||
<p>This is a <span style="font-style: italic;">quoted paragraph</span>
|
||||
<p>This is a <span style="font-style: italic;">quoted paragraph</span>
|
||||
with some styling.</p>
|
||||
</blockquote>
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
|
||||
|
||||
# Chapter 4: Tables and code
|
||||
chapter4 = epub.EpubHtml(
|
||||
title='Tables and Code',
|
||||
@ -194,7 +194,7 @@ class TestEPUBReader(unittest.TestCase):
|
||||
<head><title>Tables and Code</title></head>
|
||||
<body>
|
||||
<h1>Tables and Code</h1>
|
||||
|
||||
|
||||
<h2>Simple Table</h2>
|
||||
<table>
|
||||
<thead>
|
||||
@ -214,25 +214,25 @@ class TestEPUBReader(unittest.TestCase):
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
<h2>Code Block</h2>
|
||||
<pre><code>function test() {
|
||||
console.log("Hello, world!");
|
||||
return true;
|
||||
}</code></pre>
|
||||
|
||||
|
||||
<h2>Inline Code</h2>
|
||||
<p>Use the <code>print()</code> function to output text.</p>
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
|
||||
|
||||
# Add chapters to book
|
||||
book.add_item(chapter1)
|
||||
book.add_item(chapter2)
|
||||
book.add_item(chapter3)
|
||||
book.add_item(chapter4)
|
||||
|
||||
|
||||
# Define table of contents
|
||||
book.toc = (
|
||||
epub.Link("chapter1.xhtml", "Introduction", "intro"),
|
||||
@ -240,31 +240,32 @@ class TestEPUBReader(unittest.TestCase):
|
||||
epub.Link("chapter3.xhtml", "Lists and Quotes", "lists"),
|
||||
epub.Link("chapter4.xhtml", "Tables and Code", "tables")
|
||||
)
|
||||
|
||||
|
||||
# Add navigation files
|
||||
book.add_item(epub.EpubNcx())
|
||||
book.add_item(epub.EpubNav())
|
||||
|
||||
|
||||
# Define spine
|
||||
book.spine = ['nav', chapter1, chapter2, chapter3, chapter4]
|
||||
|
||||
|
||||
# Create temporary file
|
||||
epub_path = os.path.join(self.test_dir, f'test_complex_{len(self.epub_files)}.epub')
|
||||
epub_path = os.path.join(self.test_dir,
|
||||
f'test_complex_{len(self.epub_files)}.epub')
|
||||
epub.write_epub(epub_path, book, {})
|
||||
self.epub_files.append(epub_path)
|
||||
|
||||
|
||||
return epub_path
|
||||
|
||||
|
||||
def create_epub_with_nested_content(self):
|
||||
"""Create an EPUB with nested content structures."""
|
||||
book = epub.EpubBook()
|
||||
|
||||
|
||||
# Set metadata
|
||||
book.set_identifier('nested-test-id-789')
|
||||
book.set_title('Nested Content Test')
|
||||
book.set_language('en')
|
||||
book.add_author('Test Author')
|
||||
|
||||
|
||||
# Chapter with nested content
|
||||
chapter = epub.EpubHtml(
|
||||
title='Nested Content',
|
||||
@ -276,17 +277,17 @@ class TestEPUBReader(unittest.TestCase):
|
||||
<head><title>Nested Content</title></head>
|
||||
<body>
|
||||
<h1>Nested Content Examples</h1>
|
||||
|
||||
|
||||
<div>
|
||||
<h2>Section in Div</h2>
|
||||
<p>Paragraph inside div.</p>
|
||||
|
||||
|
||||
<section>
|
||||
<h3>Subsection</h3>
|
||||
<article>
|
||||
<h4>Article Header</h4>
|
||||
<p>Article content with <strong>nested <em>formatting</em></strong>.</p>
|
||||
|
||||
|
||||
<aside>
|
||||
<p>Sidebar content in aside element.</p>
|
||||
<ul>
|
||||
@ -297,268 +298,284 @@ class TestEPUBReader(unittest.TestCase):
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
|
||||
<footer>
|
||||
<p>Footer content with <span style="font-size: 12px; color: gray;">small gray text</span>.</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
|
||||
|
||||
# Add chapter to book
|
||||
book.add_item(chapter)
|
||||
|
||||
|
||||
# Define table of contents
|
||||
book.toc = (epub.Link("nested.xhtml", "Nested Content", "nested"),)
|
||||
|
||||
|
||||
# Add navigation files
|
||||
book.add_item(epub.EpubNcx())
|
||||
book.add_item(epub.EpubNav())
|
||||
|
||||
|
||||
# Define spine
|
||||
book.spine = ['nav', chapter]
|
||||
|
||||
|
||||
# Create temporary file
|
||||
epub_path = os.path.join(self.test_dir, f'test_nested_{len(self.epub_files)}.epub')
|
||||
epub_path = os.path.join(self.test_dir,
|
||||
f'test_nested_{len(self.epub_files)}.epub')
|
||||
epub.write_epub(epub_path, book, {})
|
||||
self.epub_files.append(epub_path)
|
||||
|
||||
|
||||
return epub_path
|
||||
|
||||
|
||||
def test_simple_epub_reading(self):
|
||||
"""Test reading a simple EPUB file."""
|
||||
epub_path = self.create_simple_epub()
|
||||
|
||||
|
||||
# Read the EPUB
|
||||
book = read_epub(epub_path)
|
||||
|
||||
|
||||
# Verify it's a Book object
|
||||
self.assertIsInstance(book, Book)
|
||||
|
||||
|
||||
# Check metadata
|
||||
self.assertEqual(book.title, "Test Book")
|
||||
|
||||
|
||||
# Check chapters
|
||||
chapters = list(book.chapters)
|
||||
self.assertEqual(len(chapters), 1)
|
||||
|
||||
|
||||
# Check chapter content
|
||||
chapter = chapters[0]
|
||||
blocks = list(chapter.blocks)
|
||||
self.assertGreater(len(blocks), 0)
|
||||
|
||||
|
||||
# Should have a heading and paragraphs
|
||||
has_heading = any(isinstance(block, Heading) for block in blocks)
|
||||
has_paragraph = any(isinstance(block, Paragraph) for block in blocks)
|
||||
|
||||
|
||||
self.assertTrue(has_heading, "Should contain at least one heading")
|
||||
self.assertTrue(has_paragraph, "Should contain at least one paragraph")
|
||||
|
||||
|
||||
def test_complex_epub_reading(self):
|
||||
"""Test reading a complex EPUB file with multiple chapters."""
|
||||
epub_path = self.create_complex_epub()
|
||||
|
||||
|
||||
# Read the EPUB
|
||||
book = read_epub(epub_path)
|
||||
|
||||
|
||||
# Verify it's a Book object
|
||||
self.assertIsInstance(book, Book)
|
||||
|
||||
|
||||
# Check metadata
|
||||
self.assertEqual(book.title, "Complex Test Book")
|
||||
|
||||
|
||||
# Check chapters
|
||||
chapters = list(book.chapters)
|
||||
self.assertEqual(len(chapters), 4)
|
||||
|
||||
|
||||
# Test each chapter has content
|
||||
for i, chapter in enumerate(chapters):
|
||||
blocks = list(chapter.blocks)
|
||||
self.assertGreater(len(blocks), 0, f"Chapter {i+1} should have blocks")
|
||||
|
||||
self.assertGreater(len(blocks), 0, f"Chapter {i + 1} should have blocks")
|
||||
|
||||
# Each chapter should start with a heading
|
||||
first_block = blocks[0]
|
||||
self.assertIsInstance(first_block, Heading, f"Chapter {i+1} should start with heading")
|
||||
|
||||
self.assertIsInstance(
|
||||
first_block, Heading, f"Chapter {
|
||||
i + 1} should start with heading")
|
||||
|
||||
def test_epub_styled_content(self):
|
||||
"""Test that styled content in EPUB is properly parsed."""
|
||||
epub_path = self.create_complex_epub()
|
||||
book = read_epub(epub_path)
|
||||
|
||||
|
||||
chapters = list(book.chapters)
|
||||
|
||||
|
||||
# Check styled content in chapter 2 (index 1)
|
||||
if len(chapters) > 1:
|
||||
chapter2_blocks = list(chapters[1].blocks)
|
||||
|
||||
|
||||
# Find paragraphs with styled text
|
||||
styled_words_found = False
|
||||
for block in chapter2_blocks:
|
||||
if isinstance(block, Paragraph):
|
||||
words = list(block.words_iter())
|
||||
for _, word in words:
|
||||
if (word.style.weight == FontWeight.BOLD or
|
||||
if (word.style.weight == FontWeight.BOLD or
|
||||
word.style.style == FontStyle.ITALIC or
|
||||
word.style.colour != (0, 0, 0)): # Non-black color
|
||||
word.style.colour != (0, 0, 0)): # Non-black color
|
||||
styled_words_found = True
|
||||
break
|
||||
if styled_words_found:
|
||||
break
|
||||
|
||||
|
||||
self.assertTrue(styled_words_found, "Should find styled words in chapter 2")
|
||||
|
||||
|
||||
def test_epub_lists(self):
|
||||
"""Test that lists in EPUB are properly parsed."""
|
||||
epub_path = self.create_complex_epub()
|
||||
book = read_epub(epub_path)
|
||||
|
||||
|
||||
chapters = list(book.chapters)
|
||||
|
||||
|
||||
# Check lists in chapter 3 (index 2)
|
||||
if len(chapters) > 2:
|
||||
chapter3_blocks = list(chapters[2].blocks)
|
||||
|
||||
|
||||
# Find list blocks
|
||||
unordered_list_found = False
|
||||
ordered_list_found = False
|
||||
quote_found = False
|
||||
|
||||
|
||||
for block in chapter3_blocks:
|
||||
if isinstance(block, HList):
|
||||
if block.style == ListStyle.UNORDERED:
|
||||
unordered_list_found = True
|
||||
|
||||
|
||||
# Check list items
|
||||
items = list(block.items())
|
||||
self.assertGreater(len(items), 0, "Unordered list should have items")
|
||||
|
||||
self.assertGreater(
|
||||
len(items), 0, "Unordered list should have items")
|
||||
|
||||
elif block.style == ListStyle.ORDERED:
|
||||
ordered_list_found = True
|
||||
|
||||
|
||||
# Check list items
|
||||
items = list(block.items())
|
||||
self.assertGreater(len(items), 0, "Ordered list should have items")
|
||||
|
||||
self.assertGreater(
|
||||
len(items), 0, "Ordered list should have items")
|
||||
|
||||
elif isinstance(block, Quote):
|
||||
quote_found = True
|
||||
|
||||
self.assertTrue(unordered_list_found, "Should find unordered list in chapter 3")
|
||||
|
||||
self.assertTrue(
|
||||
unordered_list_found,
|
||||
"Should find unordered list in chapter 3")
|
||||
self.assertTrue(ordered_list_found, "Should find ordered list in chapter 3")
|
||||
self.assertTrue(quote_found, "Should find quote in chapter 3")
|
||||
|
||||
|
||||
def test_epub_tables(self):
|
||||
"""Test that tables in EPUB are properly parsed."""
|
||||
epub_path = self.create_complex_epub()
|
||||
book = read_epub(epub_path)
|
||||
|
||||
|
||||
chapters = list(book.chapters)
|
||||
|
||||
|
||||
# Check tables in chapter 4 (index 3)
|
||||
if len(chapters) > 3:
|
||||
chapter4_blocks = list(chapters[3].blocks)
|
||||
|
||||
|
||||
# Find table blocks
|
||||
table_found = False
|
||||
code_block_found = False
|
||||
|
||||
|
||||
for block in chapter4_blocks:
|
||||
if isinstance(block, Table):
|
||||
table_found = True
|
||||
|
||||
|
||||
# Check table has rows
|
||||
rows = list(block.all_rows())
|
||||
self.assertGreater(len(rows), 0, "Table should have rows")
|
||||
|
||||
|
||||
elif isinstance(block, CodeBlock):
|
||||
code_block_found = True
|
||||
|
||||
|
||||
# Check code block has lines
|
||||
lines = list(block.lines())
|
||||
self.assertGreater(len(lines), 0, "Code block should have lines")
|
||||
|
||||
|
||||
self.assertTrue(table_found, "Should find table in chapter 4")
|
||||
self.assertTrue(code_block_found, "Should find code block in chapter 4")
|
||||
|
||||
|
||||
def test_epub_nested_content(self):
|
||||
"""Test that nested content structures are properly parsed."""
|
||||
epub_path = self.create_epub_with_nested_content()
|
||||
book = read_epub(epub_path)
|
||||
|
||||
|
||||
chapters = list(book.chapters)
|
||||
self.assertEqual(len(chapters), 1)
|
||||
|
||||
|
||||
chapter_blocks = list(chapters[0].blocks)
|
||||
self.assertGreater(len(chapter_blocks), 0)
|
||||
|
||||
|
||||
# Should have multiple headings (h1, h2, h3, h4)
|
||||
headings = [block for block in chapter_blocks if isinstance(block, Heading)]
|
||||
self.assertGreater(len(headings), 2, "Should have multiple headings from nested content")
|
||||
|
||||
self.assertGreater(
|
||||
len(headings),
|
||||
2,
|
||||
"Should have multiple headings from nested content")
|
||||
|
||||
# Should have paragraphs and lists from nested content
|
||||
paragraphs = [block for block in chapter_blocks if isinstance(block, Paragraph)]
|
||||
lists = [block for block in chapter_blocks if isinstance(block, HList)]
|
||||
|
||||
self.assertGreater(len(paragraphs), 0, "Should have paragraphs from nested content")
|
||||
|
||||
self.assertGreater(
|
||||
len(paragraphs),
|
||||
0,
|
||||
"Should have paragraphs from nested content")
|
||||
self.assertGreater(len(lists), 0, "Should have lists from nested content")
|
||||
|
||||
|
||||
def test_epub_metadata_extraction(self):
|
||||
"""Test that EPUB metadata is properly extracted."""
|
||||
epub_path = self.create_complex_epub()
|
||||
book = read_epub(epub_path)
|
||||
|
||||
|
||||
# Check basic metadata
|
||||
self.assertEqual(book.title, "Complex Test Book")
|
||||
|
||||
|
||||
# Check that metadata was set (implementation may vary)
|
||||
# This tests that the metadata parsing doesn't crash
|
||||
self.assertIsNotNone(book.title)
|
||||
|
||||
|
||||
def test_epub_reader_class_direct(self):
|
||||
"""Test EPUBReader class directly."""
|
||||
epub_path = self.create_simple_epub()
|
||||
|
||||
|
||||
reader = EPUBReader(epub_path)
|
||||
book = reader.read()
|
||||
|
||||
|
||||
self.assertIsInstance(book, Book)
|
||||
self.assertEqual(book.title, "Test Book")
|
||||
|
||||
|
||||
def test_invalid_epub_handling(self):
|
||||
"""Test handling of invalid EPUB files."""
|
||||
# Create a non-EPUB file
|
||||
invalid_path = os.path.join(self.test_dir, 'invalid.epub')
|
||||
with open(invalid_path, 'w') as f:
|
||||
f.write("This is not an EPUB file")
|
||||
|
||||
|
||||
# Should raise an exception or handle gracefully
|
||||
with self.assertRaises(Exception):
|
||||
read_epub(invalid_path)
|
||||
|
||||
|
||||
def test_nonexistent_epub_handling(self):
|
||||
"""Test handling of nonexistent EPUB files."""
|
||||
nonexistent_path = os.path.join(self.test_dir, 'nonexistent.epub')
|
||||
|
||||
|
||||
# Should raise an exception
|
||||
with self.assertRaises(Exception):
|
||||
read_epub(nonexistent_path)
|
||||
|
||||
|
||||
def test_epub_with_custom_metadata(self):
|
||||
"""Test EPUB with various metadata fields."""
|
||||
book = epub.EpubBook()
|
||||
|
||||
|
||||
# Set comprehensive metadata
|
||||
book.set_identifier('custom-metadata-test')
|
||||
book.set_title('Custom Metadata Test')
|
||||
book.set_language('en')
|
||||
book.add_author('Primary Author')
|
||||
book.add_author('Secondary Author')
|
||||
book.add_metadata('DC', 'description', 'A comprehensive test of metadata extraction')
|
||||
book.add_metadata(
|
||||
'DC',
|
||||
'description',
|
||||
'A comprehensive test of metadata extraction')
|
||||
book.add_metadata('DC', 'subject', 'Testing')
|
||||
book.add_metadata('DC', 'subject', 'EPUB')
|
||||
book.add_metadata('DC', 'date', '2024-06-07')
|
||||
book.add_metadata('DC', 'publisher', 'Test Publishing House')
|
||||
book.add_metadata('DC', 'rights', 'Public Domain')
|
||||
|
||||
|
||||
# Simple chapter
|
||||
chapter = epub.EpubHtml(
|
||||
title='Metadata Test',
|
||||
@ -574,22 +591,23 @@ class TestEPUBReader(unittest.TestCase):
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
|
||||
|
||||
book.add_item(chapter)
|
||||
book.toc = (epub.Link("metadata.xhtml", "Metadata Test", "meta"),)
|
||||
book.add_item(epub.EpubNcx())
|
||||
book.add_item(epub.EpubNav())
|
||||
book.spine = ['nav', chapter]
|
||||
|
||||
|
||||
# Write and test
|
||||
epub_path = os.path.join(self.test_dir, f'test_metadata_{len(self.epub_files)}.epub')
|
||||
epub_path = os.path.join(self.test_dir,
|
||||
f'test_metadata_{len(self.epub_files)}.epub')
|
||||
epub.write_epub(epub_path, book, {})
|
||||
self.epub_files.append(epub_path)
|
||||
|
||||
|
||||
# Read and verify
|
||||
parsed_book = read_epub(epub_path)
|
||||
self.assertEqual(parsed_book.title, "Custom Metadata Test")
|
||||
|
||||
|
||||
# Verify chapters were created
|
||||
chapters = list(parsed_book.chapters)
|
||||
self.assertEqual(len(chapters), 1)
|
||||
@ -597,12 +615,12 @@ class TestEPUBReader(unittest.TestCase):
|
||||
|
||||
class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase):
|
||||
"""Test cases that specifically verify EPUB reader uses html_extraction properly."""
|
||||
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test environment."""
|
||||
self.test_dir = tempfile.mkdtemp()
|
||||
self.epub_files = []
|
||||
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up test environment."""
|
||||
for epub_file in self.epub_files:
|
||||
@ -610,10 +628,10 @@ class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase):
|
||||
os.remove(epub_file)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
if os.path.exists(self.test_dir):
|
||||
shutil.rmtree(self.test_dir, ignore_errors=True)
|
||||
|
||||
|
||||
@unittest.skipUnless(EBOOKLIB_AVAILABLE, "ebooklib not available")
|
||||
def test_html_extraction_integration(self):
|
||||
"""Test that EPUB reader properly uses html_extraction functionality."""
|
||||
@ -623,7 +641,7 @@ class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase):
|
||||
book.set_title('HTML Extraction Test')
|
||||
book.set_language('en')
|
||||
book.add_author('Test Author')
|
||||
|
||||
|
||||
# Chapter that exercises html_extraction features
|
||||
chapter = epub.EpubHtml(
|
||||
title='HTML Features',
|
||||
@ -635,22 +653,22 @@ class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase):
|
||||
<head><title>HTML Features</title></head>
|
||||
<body>
|
||||
<h1>HTML Extraction Test</h1>
|
||||
|
||||
|
||||
<!-- Test paragraph with inline formatting -->
|
||||
<p>This paragraph has <strong>bold</strong>, <em>italic</em>,
|
||||
<p>This paragraph has <strong>bold</strong>, <em>italic</em>,
|
||||
<u>underlined</u>, and <span style="color: #ff0000; font-weight: bold;">styled</span> text.</p>
|
||||
|
||||
|
||||
<!-- Test headings -->
|
||||
<h2>Second Level Heading</h2>
|
||||
<h3>Third Level Heading</h3>
|
||||
|
||||
|
||||
<!-- Test lists with styled content -->
|
||||
<ul>
|
||||
<li>Plain list item</li>
|
||||
<li><strong>Bold list item</strong></li>
|
||||
<li>List item with <em>italic text</em></li>
|
||||
</ul>
|
||||
|
||||
|
||||
<!-- Test table with styled cells -->
|
||||
<table>
|
||||
<tr>
|
||||
@ -662,19 +680,19 @@ class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase):
|
||||
<td>Normal text</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
<!-- Test blockquote -->
|
||||
<blockquote>
|
||||
<p>This is a quoted paragraph with <strong>bold text</strong>.</p>
|
||||
</blockquote>
|
||||
|
||||
|
||||
<!-- Test code block -->
|
||||
<pre><code>def test_function():
|
||||
return "Hello, World!"</code></pre>
|
||||
|
||||
|
||||
<!-- Test nested formatting -->
|
||||
<p>Nested formatting: <strong>bold with <em>italic nested</em> inside</strong>.</p>
|
||||
|
||||
|
||||
<!-- Test color variations -->
|
||||
<p>
|
||||
<span style="color: red;">Red text</span>,
|
||||
@ -684,26 +702,26 @@ class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase):
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
|
||||
|
||||
book.add_item(chapter)
|
||||
book.toc = (epub.Link("html_features.xhtml", "HTML Features", "html"),)
|
||||
book.add_item(epub.EpubNcx())
|
||||
book.add_item(epub.EpubNav())
|
||||
book.spine = ['nav', chapter]
|
||||
|
||||
|
||||
# Write EPUB
|
||||
epub_path = os.path.join(self.test_dir, 'html_extraction_test.epub')
|
||||
epub.write_epub(epub_path, book, {})
|
||||
self.epub_files.append(epub_path)
|
||||
|
||||
|
||||
# Read and analyze
|
||||
parsed_book = read_epub(epub_path)
|
||||
chapters = list(parsed_book.chapters)
|
||||
self.assertEqual(len(chapters), 1)
|
||||
|
||||
|
||||
blocks = list(chapters[0].blocks)
|
||||
self.assertGreater(len(blocks), 5) # Should have multiple blocks
|
||||
|
||||
|
||||
# Test that we get the expected block types
|
||||
block_types = [type(block).__name__ for block in blocks]
|
||||
self.assertIn('Heading', block_types, "Should have heading blocks")
|
||||
@ -712,29 +730,31 @@ class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase):
|
||||
self.assertIn('Table', block_types, "Should have table blocks")
|
||||
self.assertIn('Quote', block_types, "Should have quote blocks")
|
||||
self.assertIn('CodeBlock', block_types, "Should have code blocks")
|
||||
|
||||
|
||||
# Test styled content was preserved
|
||||
styled_content_found = False
|
||||
for block in blocks:
|
||||
if isinstance(block, Paragraph):
|
||||
words = list(block.words_iter())
|
||||
for _, word in words:
|
||||
if (word.style.weight == FontWeight.BOLD or
|
||||
if (word.style.weight == FontWeight.BOLD or
|
||||
word.style.style == FontStyle.ITALIC or
|
||||
word.style.decoration == TextDecoration.UNDERLINE or
|
||||
word.style.colour != (0, 0, 0)):
|
||||
word.style.colour != (0, 0, 0)):
|
||||
styled_content_found = True
|
||||
break
|
||||
if styled_content_found:
|
||||
break
|
||||
|
||||
self.assertTrue(styled_content_found, "Should find styled content in parsed blocks")
|
||||
|
||||
|
||||
self.assertTrue(
|
||||
styled_content_found,
|
||||
"Should find styled content in parsed blocks")
|
||||
|
||||
# Test specific color parsing
|
||||
red_text_found = False
|
||||
green_text_found = False
|
||||
blue_text_found = False
|
||||
|
||||
|
||||
for block in blocks:
|
||||
if isinstance(block, (Paragraph, Table)):
|
||||
if isinstance(block, Paragraph):
|
||||
@ -746,12 +766,11 @@ class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase):
|
||||
green_text_found = True
|
||||
elif word.style.colour == (0, 0, 255): # Blue
|
||||
blue_text_found = True
|
||||
|
||||
|
||||
# At least one color should be found (depending on implementation)
|
||||
color_found = red_text_found or green_text_found or blue_text_found
|
||||
self.assertTrue(color_found, "Should find at least one colored text")
|
||||
|
||||
|
||||
def test_epub_with_image(self):
|
||||
"""Test that images in EPUB are properly parsed."""
|
||||
book = epub.EpubBook()
|
||||
@ -759,20 +778,31 @@ class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase):
|
||||
book.set_title('Image Test Book')
|
||||
book.set_language('en')
|
||||
book.add_author('Test Author')
|
||||
|
||||
|
||||
# Create minimal JPEG data for testing
|
||||
img_data = b'\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x01\x00H\x00H\x00\x00\xff\xdb\x00C\x00\x08\x06\x06\x07\x06\x05\x08\x07\x07\x07\t\t\x08\n\x0c\x14\r\x0c\x0b\x0b\x0c\x19\x12\x13\x0f\x14\x1d\x1a\x1f\x1e\x1d\x1a\x1c\x1c $.\' ",#\x1c\x1c(7),01444\x1f\'9=82<.342\xff\xc0\x00\x11\x08\x00d\x00d\x01\x01\x11\x00\x02\x11\x01\x03\x11\x01\xff\xc4\x00\x14\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x08\xff\xc4\x00\x14\x10\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xda\x00\x0c\x03\x01\x00\x02\x11\x03\x11\x00\x3f\x00\xaa\xff\xd9'
|
||||
|
||||
img_data = (
|
||||
b'\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x01\x00H\x00H\x00\x00'
|
||||
b'\xff\xdb\x00C\x00\x08\x06\x06\x07\x06\x05\x08\x07\x07\x07\t\t'
|
||||
b'\x08\n\x0c\x14\r\x0c\x0b\x0b\x0c\x19\x12\x13\x0f\x14\x1d\x1a'
|
||||
b'\x1f\x1e\x1d\x1a\x1c\x1c $.\' ",#\x1c\x1c(7),01444\x1f\'9=82<.342'
|
||||
b'\xff\xc0\x00\x11\x08\x00d\x00d\x01\x01\x11\x00\x02\x11\x01\x03'
|
||||
b'\x11\x01\xff\xc4\x00\x14\x00\x01\x00\x00\x00\x00\x00\x00\x00'
|
||||
b'\x00\x00\x00\x00\x00\x00\x00\x00\x08\xff\xc4\x00\x14\x10\x01'
|
||||
b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
|
||||
b'\x00\xff\xda\x00\x0c\x03\x01\x00\x02\x11\x03\x11\x00\x3f\x00'
|
||||
b'\xaa\xff\xd9'
|
||||
)
|
||||
|
||||
# Create an EpubImage item
|
||||
image_item = epub.EpubImage()
|
||||
image_item.id = 'test_img'
|
||||
image_item.file_name = 'images/test_image.jpg'
|
||||
image_item.media_type = 'image/jpeg'
|
||||
image_item.content = img_data
|
||||
|
||||
|
||||
# Add image to book
|
||||
book.add_item(image_item)
|
||||
|
||||
|
||||
# Create a chapter that references the image
|
||||
chapter = epub.EpubHtml(
|
||||
title='Image Chapter',
|
||||
@ -788,36 +818,43 @@ class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase):
|
||||
<p>Text after the image.</p>
|
||||
</body>
|
||||
</html>'''
|
||||
|
||||
|
||||
book.add_item(chapter)
|
||||
book.toc = (epub.Link("image_chapter.xhtml", "Image Chapter", "img_ch"),)
|
||||
book.add_item(epub.EpubNcx())
|
||||
book.add_item(epub.EpubNav())
|
||||
book.spine = ['nav', chapter]
|
||||
|
||||
|
||||
# Write EPUB
|
||||
epub_path = os.path.join(self.test_dir, f'test_image_{len(self.epub_files)}.epub')
|
||||
epub_path = os.path.join(self.test_dir,
|
||||
f'test_image_{len(self.epub_files)}.epub')
|
||||
epub.write_epub(epub_path, book, {})
|
||||
self.epub_files.append(epub_path)
|
||||
|
||||
|
||||
# Read and analyze
|
||||
parsed_book = read_epub(epub_path)
|
||||
chapters = list(parsed_book.chapters)
|
||||
self.assertEqual(len(chapters), 1)
|
||||
|
||||
|
||||
blocks = list(chapters[0].blocks)
|
||||
self.assertGreater(len(blocks), 0)
|
||||
|
||||
|
||||
# Find blocks by type
|
||||
heading_blocks = [block for block in blocks if isinstance(block, Heading)]
|
||||
paragraph_blocks = [block for block in blocks if isinstance(block, Paragraph)]
|
||||
image_blocks = [block for block in blocks if isinstance(block, Image)]
|
||||
|
||||
|
||||
# Verify we have the expected blocks
|
||||
self.assertEqual(len(heading_blocks), 1, "Should find exactly one heading block")
|
||||
self.assertGreaterEqual(len(paragraph_blocks), 2, "Should find at least two paragraph blocks")
|
||||
self.assertEqual(
|
||||
len(heading_blocks),
|
||||
1,
|
||||
"Should find exactly one heading block")
|
||||
self.assertGreaterEqual(
|
||||
len(paragraph_blocks),
|
||||
2,
|
||||
"Should find at least two paragraph blocks")
|
||||
self.assertEqual(len(image_blocks), 1, "Should find exactly one image block")
|
||||
|
||||
|
||||
# Verify image properties
|
||||
image_block = image_blocks[0]
|
||||
self.assertEqual(image_block.alt_text, "Test image")
|
||||
|
||||
@ -14,14 +14,15 @@ from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration
|
||||
|
||||
class TestHTMLParagraph(unittest.TestCase):
|
||||
"""Test cases for basic paragraph parsing."""
|
||||
|
||||
|
||||
def test_simple(self):
|
||||
text = "<p>This is a paragraph.</p>"
|
||||
paragraphs = parse_html_string(text)
|
||||
self.assertEqual(len(paragraphs), 1)
|
||||
self.assertEqual(len(paragraphs[0]), 4)
|
||||
|
||||
for w1, t1 in zip(paragraphs[0].words_iter(), "This is a paragraph.".split(" ")):
|
||||
for w1, t1 in zip(paragraphs[0].words_iter(),
|
||||
"This is a paragraph.".split(" ")):
|
||||
self.assertEqual(w1[1].text, t1)
|
||||
|
||||
def test_multiple(self):
|
||||
@ -31,26 +32,28 @@ class TestHTMLParagraph(unittest.TestCase):
|
||||
self.assertEqual(len(paragraphs[0]), 4)
|
||||
self.assertEqual(len(paragraphs[1]), 4)
|
||||
|
||||
for w1, t1 in zip(paragraphs[0].words_iter(), "This is a paragraph.".split(" ")):
|
||||
for w1, t1 in zip(paragraphs[0].words_iter(),
|
||||
"This is a paragraph.".split(" ")):
|
||||
self.assertEqual(w1[1].text, t1)
|
||||
|
||||
for w1, t1 in zip(paragraphs[1].words_iter(), "This is another paragraph.".split(" ")):
|
||||
for w1, t1 in zip(paragraphs[1].words_iter(),
|
||||
"This is another paragraph.".split(" ")):
|
||||
self.assertEqual(w1[1].text, t1)
|
||||
|
||||
|
||||
class TestHTMLStyledParagraphs(unittest.TestCase):
|
||||
"""Test cases for paragraphs with inline styling."""
|
||||
|
||||
|
||||
def test_bold_text(self):
|
||||
"""Test paragraphs with bold text using <strong> and <b> tags."""
|
||||
text = "<p>This is <strong>bold text</strong> in a paragraph.</p>"
|
||||
blocks = parse_html_string(text)
|
||||
self.assertEqual(len(blocks), 1)
|
||||
self.assertIsInstance(blocks[0], Paragraph)
|
||||
|
||||
|
||||
words = list(blocks[0].words_iter())
|
||||
self.assertEqual(len(words), 7) # "This is bold text in a paragraph."
|
||||
|
||||
|
||||
# Check that 'bold' and 'text' words have bold font weight
|
||||
bold_word = words[2][1] # 'bold'
|
||||
text_word = words[3][1] # 'text'
|
||||
@ -58,7 +61,7 @@ class TestHTMLStyledParagraphs(unittest.TestCase):
|
||||
self.assertEqual(bold_word.style.weight, FontWeight.BOLD)
|
||||
self.assertEqual(text_word.text, "text")
|
||||
self.assertEqual(text_word.style.weight, FontWeight.BOLD)
|
||||
|
||||
|
||||
# Check that other words are not bold
|
||||
normal_word = words[0][1] # 'This'
|
||||
self.assertEqual(normal_word.text, "This")
|
||||
@ -70,9 +73,9 @@ class TestHTMLStyledParagraphs(unittest.TestCase):
|
||||
blocks = parse_html_string(text)
|
||||
self.assertEqual(len(blocks), 1)
|
||||
self.assertIsInstance(blocks[0], Paragraph)
|
||||
|
||||
|
||||
words = list(blocks[0].words_iter())
|
||||
|
||||
|
||||
# Check that 'italic' and 'text' words have italic font style
|
||||
italic_word = words[2][1] # 'italic'
|
||||
text_word = words[3][1] # 'text'
|
||||
@ -86,7 +89,7 @@ class TestHTMLStyledParagraphs(unittest.TestCase):
|
||||
text = "<p>This is <u>underlined text</u> here.</p>"
|
||||
blocks = parse_html_string(text)
|
||||
self.assertEqual(len(blocks), 1)
|
||||
|
||||
|
||||
words = list(blocks[0].words_iter())
|
||||
underlined_word = words[2][1] # 'underlined'
|
||||
self.assertEqual(underlined_word.style.decoration, TextDecoration.UNDERLINE)
|
||||
@ -96,50 +99,60 @@ class TestHTMLStyledParagraphs(unittest.TestCase):
|
||||
text = "<p>This is <s>strikethrough text</s> here.</p>"
|
||||
blocks = parse_html_string(text)
|
||||
self.assertEqual(len(blocks), 1)
|
||||
|
||||
|
||||
words = list(blocks[0].words_iter())
|
||||
strike_word = words[2][1] # 'strikethrough'
|
||||
self.assertEqual(strike_word.style.decoration, TextDecoration.STRIKETHROUGH)
|
||||
|
||||
def test_span_with_inline_styles(self):
|
||||
"""Test paragraphs with span elements containing inline CSS styles."""
|
||||
text = '<p>This text is normal, but <span style="color: red; font-weight: bold;">this part is red and bold</span>.</p>'
|
||||
text = (
|
||||
'<p>This text is normal, but <span style="color: red; font-weight: bold;">'
|
||||
'this part is red and bold</span>.</p>'
|
||||
)
|
||||
blocks = parse_html_string(text)
|
||||
self.assertEqual(len(blocks), 1)
|
||||
self.assertIsInstance(blocks[0], Paragraph)
|
||||
|
||||
|
||||
words = list(blocks[0].words_iter())
|
||||
|
||||
|
||||
# Find the styled words
|
||||
styled_words = []
|
||||
for _, word in words:
|
||||
if word.text in ["this", "part", "is", "red", "and", "bold"]:
|
||||
if word.style.weight == FontWeight.BOLD:
|
||||
styled_words.append(word)
|
||||
|
||||
self.assertGreater(len(styled_words), 0, "Should have bold words in styled span")
|
||||
|
||||
|
||||
self.assertGreater(
|
||||
len(styled_words),
|
||||
0,
|
||||
"Should have bold words in styled span")
|
||||
|
||||
# Check that at least one word has the red color
|
||||
red_words = [w for w in styled_words if w.style.colour == (255, 0, 0)]
|
||||
self.assertGreater(len(red_words), 0, "Should have red colored words")
|
||||
|
||||
def test_mixed_formatting(self):
|
||||
"""Test paragraphs with multiple formatting elements combined."""
|
||||
text = "<p>This paragraph contains <strong>bold</strong>, <em>italic</em>, <span style=\"color: blue;\">blue</span>, and <mark>highlighted</mark> text all together.</p>"
|
||||
text = (
|
||||
'<p>This paragraph contains <strong>bold</strong>, <em>italic</em>, '
|
||||
'<span style="color: blue;">blue</span>, and <mark>highlighted</mark> '
|
||||
'text all together.</p>'
|
||||
)
|
||||
blocks = parse_html_string(text)
|
||||
self.assertEqual(len(blocks), 1)
|
||||
self.assertIsInstance(blocks[0], Paragraph)
|
||||
|
||||
|
||||
words = list(blocks[0].words_iter())
|
||||
|
||||
|
||||
# Check for bold word
|
||||
bold_words = [w for _, w in words if w.style.weight == FontWeight.BOLD]
|
||||
self.assertGreater(len(bold_words), 0, "Should have bold words")
|
||||
|
||||
|
||||
# Check for italic word
|
||||
italic_words = [w for _, w in words if w.style.style == FontStyle.ITALIC]
|
||||
self.assertGreater(len(italic_words), 0, "Should have italic words")
|
||||
|
||||
|
||||
# Check for blue colored word
|
||||
blue_words = [w for _, w in words if w.style.colour == (0, 0, 255)]
|
||||
self.assertGreater(len(blue_words), 0, "Should have blue colored words")
|
||||
@ -149,26 +162,29 @@ class TestHTMLStyledParagraphs(unittest.TestCase):
|
||||
text = "<p>This has <strong>bold with <em>italic inside</em></strong> formatting.</p>"
|
||||
blocks = parse_html_string(text)
|
||||
self.assertEqual(len(blocks), 1)
|
||||
|
||||
|
||||
words = list(blocks[0].words_iter())
|
||||
|
||||
|
||||
# Find words that should be both bold and italic
|
||||
bold_italic_words = [w for _, w in words
|
||||
if w.style.weight == FontWeight.BOLD and w.style.style == FontStyle.ITALIC]
|
||||
self.assertGreater(len(bold_italic_words), 0, "Should have words that are both bold and italic")
|
||||
bold_italic_words = [w for _, w in words if w.style.weight ==
|
||||
FontWeight.BOLD and w.style.style == FontStyle.ITALIC]
|
||||
self.assertGreater(
|
||||
len(bold_italic_words),
|
||||
0,
|
||||
"Should have words that are both bold and italic")
|
||||
|
||||
def test_color_variations(self):
|
||||
"""Test different color formats in CSS."""
|
||||
text = '<p><span style="color: #ff0000;">Hex red</span> and <span style="color: green;">Named green</span>.</p>'
|
||||
blocks = parse_html_string(text)
|
||||
self.assertEqual(len(blocks), 1)
|
||||
|
||||
|
||||
words = list(blocks[0].words_iter())
|
||||
|
||||
|
||||
# Check for hex red color
|
||||
hex_red_words = [w for _, w in words if w.style.colour == (255, 0, 0)]
|
||||
self.assertGreater(len(hex_red_words), 0, "Should have hex red colored words")
|
||||
|
||||
|
||||
# Check for named green color
|
||||
green_words = [w for _, w in words if w.style.colour == (0, 255, 0)]
|
||||
self.assertGreater(len(green_words), 0, "Should have green colored words")
|
||||
@ -176,7 +192,7 @@ class TestHTMLStyledParagraphs(unittest.TestCase):
|
||||
|
||||
class TestHTMLBlockElements(unittest.TestCase):
|
||||
"""Test cases for block-level HTML elements."""
|
||||
|
||||
|
||||
def test_body_element(self):
|
||||
"""Test parsing of body element containing other elements."""
|
||||
text = "<body><p>Paragraph one.</p><p>Paragraph two.</p></body>"
|
||||
@ -195,17 +211,20 @@ class TestHTMLBlockElements(unittest.TestCase):
|
||||
|
||||
def test_headings(self):
|
||||
"""Test all heading levels h1-h6."""
|
||||
text = "<h1>Heading 1</h1><h2>Heading 2</h2><h3>Heading 3</h3><h4>Heading 4</h4><h5>Heading 5</h5><h6>Heading 6</h6>"
|
||||
text = (
|
||||
"<h1>Heading 1</h1><h2>Heading 2</h2><h3>Heading 3</h3>"
|
||||
"<h4>Heading 4</h4><h5>Heading 5</h5><h6>Heading 6</h6>"
|
||||
)
|
||||
blocks = parse_html_string(text)
|
||||
self.assertEqual(len(blocks), 6)
|
||||
|
||||
expected_levels = [HeadingLevel.H1, HeadingLevel.H2, HeadingLevel.H3,
|
||||
HeadingLevel.H4, HeadingLevel.H5, HeadingLevel.H6]
|
||||
|
||||
|
||||
expected_levels = [HeadingLevel.H1, HeadingLevel.H2, HeadingLevel.H3,
|
||||
HeadingLevel.H4, HeadingLevel.H5, HeadingLevel.H6]
|
||||
|
||||
for i, block in enumerate(blocks):
|
||||
self.assertIsInstance(block, Heading)
|
||||
self.assertEqual(block.level, expected_levels[i])
|
||||
|
||||
|
||||
words = list(block.words_iter())
|
||||
self.assertEqual(len(words), 2) # "Heading" and number
|
||||
self.assertEqual(words[0][1].text, "Heading")
|
||||
@ -216,7 +235,7 @@ class TestHTMLBlockElements(unittest.TestCase):
|
||||
blocks = parse_html_string(text)
|
||||
self.assertEqual(len(blocks), 1)
|
||||
self.assertIsInstance(blocks[0], Quote)
|
||||
|
||||
|
||||
# Check that the quote contains a paragraph
|
||||
quote_blocks = list(blocks[0].blocks())
|
||||
self.assertEqual(len(quote_blocks), 1)
|
||||
@ -228,7 +247,7 @@ class TestHTMLBlockElements(unittest.TestCase):
|
||||
blocks = parse_html_string(text)
|
||||
self.assertEqual(len(blocks), 1)
|
||||
self.assertIsInstance(blocks[0], CodeBlock)
|
||||
|
||||
|
||||
lines = list(blocks[0].lines())
|
||||
self.assertGreater(len(lines), 0)
|
||||
|
||||
@ -239,7 +258,7 @@ class TestHTMLBlockElements(unittest.TestCase):
|
||||
self.assertEqual(len(blocks), 1)
|
||||
self.assertIsInstance(blocks[0], HList)
|
||||
self.assertEqual(blocks[0].style, ListStyle.UNORDERED)
|
||||
|
||||
|
||||
items = list(blocks[0].items())
|
||||
self.assertEqual(len(items), 3)
|
||||
|
||||
@ -257,10 +276,10 @@ class TestHTMLBlockElements(unittest.TestCase):
|
||||
blocks = parse_html_string(text)
|
||||
self.assertEqual(len(blocks), 1)
|
||||
self.assertIsInstance(blocks[0], HList)
|
||||
|
||||
|
||||
items = list(blocks[0].items())
|
||||
self.assertEqual(len(items), 3)
|
||||
|
||||
|
||||
# Check second item has bold text
|
||||
second_item_blocks = list(items[1].blocks())
|
||||
if second_item_blocks:
|
||||
@ -306,12 +325,12 @@ class TestHTMLBlockElements(unittest.TestCase):
|
||||
"""
|
||||
blocks = parse_html_string(text)
|
||||
self.assertGreater(len(blocks), 2)
|
||||
|
||||
|
||||
# Should have at least a heading, paragraph, and quote
|
||||
has_heading = any(isinstance(b, Heading) for b in blocks)
|
||||
has_paragraph = any(isinstance(b, Paragraph) for b in blocks)
|
||||
has_quote = any(isinstance(b, Quote) for b in blocks)
|
||||
|
||||
|
||||
self.assertTrue(has_heading, "Should contain a heading")
|
||||
self.assertTrue(has_paragraph, "Should contain a paragraph")
|
||||
self.assertTrue(has_quote, "Should contain a quote")
|
||||
@ -322,7 +341,7 @@ class TestHTMLBlockElements(unittest.TestCase):
|
||||
blocks = parse_html_string(text)
|
||||
# Empty elements may not create blocks, which is acceptable behavior
|
||||
self.assertGreaterEqual(len(blocks), 0)
|
||||
|
||||
|
||||
# Test that empty paragraph with some content does create a block
|
||||
text_with_content = "<p> </p>" # Contains whitespace
|
||||
blocks_with_content = parse_html_string(text_with_content)
|
||||
@ -332,7 +351,7 @@ class TestHTMLBlockElements(unittest.TestCase):
|
||||
|
||||
class TestHTMLComplexStructures(unittest.TestCase):
|
||||
"""Test cases for complex HTML structures combining multiple features."""
|
||||
|
||||
|
||||
def test_article_with_mixed_content(self):
|
||||
"""Test a realistic article structure with mixed content."""
|
||||
text = """
|
||||
@ -350,7 +369,7 @@ class TestHTMLComplexStructures(unittest.TestCase):
|
||||
"""
|
||||
blocks = parse_html_string(text)
|
||||
self.assertGreater(len(blocks), 3)
|
||||
|
||||
|
||||
# Verify we have the expected block types
|
||||
block_types = [type(b).__name__ for b in blocks]
|
||||
self.assertIn('Heading', block_types)
|
||||
@ -383,12 +402,12 @@ class TestHTMLComplexStructures(unittest.TestCase):
|
||||
|
||||
class TestHTMLFontRegistryIntegration(unittest.TestCase):
|
||||
"""Test cases for font registry integration with HTML extraction."""
|
||||
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
self.doc = Document("Test Document", "en-US")
|
||||
self.base_font = Font(font_size=16, colour=(0, 0, 0))
|
||||
|
||||
|
||||
def test_font_registry_creates_fonts(self):
|
||||
"""Test that HTML parsing with document context creates fonts in registry."""
|
||||
html_content = """
|
||||
@ -397,21 +416,21 @@ class TestHTMLFontRegistryIntegration(unittest.TestCase):
|
||||
<h1>Main Header</h1>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
# Initially empty style registry
|
||||
initial_style_count = self.doc.get_style_registry().get_style_count()
|
||||
|
||||
|
||||
# Parse HTML with document context
|
||||
blocks = parse_html_string(html_content, self.base_font, document=self.doc)
|
||||
|
||||
|
||||
# Should have created styles for different formatting
|
||||
final_style_count = self.doc.get_style_registry().get_style_count()
|
||||
self.assertGreater(final_style_count, initial_style_count,
|
||||
"Should have created styles in registry")
|
||||
|
||||
self.assertGreater(final_style_count, initial_style_count,
|
||||
"Should have created styles in registry")
|
||||
|
||||
# Should have created blocks
|
||||
self.assertGreater(len(blocks), 0, "Should have created blocks")
|
||||
|
||||
|
||||
def test_font_registry_reuses_fonts(self):
|
||||
"""Test that parsing same content reuses existing styles."""
|
||||
html_content = """
|
||||
@ -420,23 +439,23 @@ class TestHTMLFontRegistryIntegration(unittest.TestCase):
|
||||
<h1>Main Header</h1>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
# First parse
|
||||
blocks1 = parse_html_string(html_content, self.base_font, document=self.doc)
|
||||
first_parse_style_count = self.doc.get_style_registry().get_style_count()
|
||||
|
||||
|
||||
# Second parse with same content
|
||||
blocks2 = parse_html_string(html_content, self.base_font, document=self.doc)
|
||||
second_parse_style_count = self.doc.get_style_registry().get_style_count()
|
||||
|
||||
|
||||
# Style count should not increase on second parse
|
||||
self.assertEqual(first_parse_style_count, second_parse_style_count,
|
||||
"Should reuse existing styles instead of creating new ones")
|
||||
|
||||
"Should reuse existing styles instead of creating new ones")
|
||||
|
||||
# Both parses should create same number of blocks
|
||||
self.assertEqual(len(blocks1), len(blocks2),
|
||||
"Should create same structure on both parses")
|
||||
|
||||
"Should create same structure on both parses")
|
||||
|
||||
def test_font_registry_different_styles_create_different_fonts(self):
|
||||
"""Test that different styles create different style objects."""
|
||||
# Create styles with different properties
|
||||
@ -449,65 +468,71 @@ class TestHTMLFontRegistryIntegration(unittest.TestCase):
|
||||
style_id3, style3 = self.doc.get_or_create_style(
|
||||
font_size=14, color=(0, 255, 0), font_weight=FontWeight.BOLD
|
||||
)
|
||||
|
||||
|
||||
# Should be different style IDs
|
||||
self.assertNotEqual(style_id1, style_id2, "Different sizes should create different styles")
|
||||
self.assertNotEqual(style_id1, style_id3, "Different colors should create different styles")
|
||||
self.assertNotEqual(
|
||||
style_id1,
|
||||
style_id2,
|
||||
"Different sizes should create different styles")
|
||||
self.assertNotEqual(
|
||||
style_id1,
|
||||
style_id3,
|
||||
"Different colors should create different styles")
|
||||
self.assertNotEqual(style_id2, style_id3, "All styles should be different")
|
||||
|
||||
|
||||
# Should have multiple styles in registry
|
||||
self.assertGreaterEqual(self.doc.get_style_registry().get_style_count(), 3)
|
||||
|
||||
|
||||
def test_font_registry_integration_with_html_styles(self):
|
||||
"""Test that HTML parsing uses style registry for styled content."""
|
||||
html_content = """
|
||||
<p>Normal text with <strong>bold</strong> and <em>italic</em> and
|
||||
<p>Normal text with <strong>bold</strong> and <em>italic</em> and
|
||||
<span style="color: red;">red text</span>.</p>
|
||||
"""
|
||||
|
||||
|
||||
# Parse content
|
||||
blocks = parse_html_string(html_content, self.base_font, document=self.doc)
|
||||
|
||||
|
||||
# Extract all words from the paragraph
|
||||
paragraph = blocks[0]
|
||||
words = list(paragraph.words_iter())
|
||||
|
||||
|
||||
# Find words with different styles
|
||||
normal_words = [w for _, w in words if w.style.weight == FontWeight.NORMAL
|
||||
and w.style.style == FontStyle.NORMAL]
|
||||
normal_words = [w for _, w in words if w.style.weight == FontWeight.NORMAL
|
||||
and w.style.style == FontStyle.NORMAL]
|
||||
bold_words = [w for _, w in words if w.style.weight == FontWeight.BOLD]
|
||||
italic_words = [w for _, w in words if w.style.style == FontStyle.ITALIC]
|
||||
red_words = [w for _, w in words if w.style.colour == (255, 0, 0)]
|
||||
|
||||
|
||||
# Should have words with different styles
|
||||
self.assertGreater(len(normal_words), 0, "Should have normal words")
|
||||
self.assertGreater(len(bold_words), 0, "Should have bold words")
|
||||
self.assertGreater(len(italic_words), 0, "Should have italic words")
|
||||
self.assertGreater(len(red_words), 0, "Should have red words")
|
||||
|
||||
|
||||
# Style registry should contain multiple styles for different formatting
|
||||
self.assertGreater(self.doc.get_style_registry().get_style_count(), 1,
|
||||
"Should have multiple styles for different formatting")
|
||||
|
||||
self.assertGreater(self.doc.get_style_registry().get_style_count(), 1,
|
||||
"Should have multiple styles for different formatting")
|
||||
|
||||
def test_font_registry_without_document_context(self):
|
||||
"""Test that parsing without document context works (fallback behavior)."""
|
||||
html_content = "<p>This is <strong>bold text</strong>.</p>"
|
||||
|
||||
|
||||
# Get initial style count (should include default style)
|
||||
initial_style_count = self.doc.get_style_registry().get_style_count()
|
||||
|
||||
|
||||
# Parse without document context
|
||||
blocks = parse_html_string(html_content, self.base_font)
|
||||
|
||||
|
||||
# Should still create blocks successfully
|
||||
self.assertEqual(len(blocks), 1)
|
||||
self.assertIsInstance(blocks[0], Paragraph)
|
||||
|
||||
|
||||
# Should not affect document's style registry
|
||||
final_style_count = self.doc.get_style_registry().get_style_count()
|
||||
self.assertEqual(final_style_count, initial_style_count,
|
||||
"Document style registry should remain unchanged")
|
||||
|
||||
self.assertEqual(final_style_count, initial_style_count,
|
||||
"Document style registry should remain unchanged")
|
||||
|
||||
def test_complex_html_font_reuse(self):
|
||||
"""Test style reuse with complex HTML containing repeated styles."""
|
||||
html_content = """
|
||||
@ -518,46 +543,46 @@ class TestHTMLFontRegistryIntegration(unittest.TestCase):
|
||||
<p>Another paragraph with <strong>bold</strong> text.</p>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
# Parse content
|
||||
blocks = parse_html_string(html_content, self.base_font, document=self.doc)
|
||||
style_count_after_parse = self.doc.get_style_registry().get_style_count()
|
||||
|
||||
|
||||
# Parse same content again
|
||||
blocks2 = parse_html_string(html_content, self.base_font, document=self.doc)
|
||||
style_count_after_second_parse = self.doc.get_style_registry().get_style_count()
|
||||
|
||||
|
||||
# Style count should not increase on second parse
|
||||
self.assertEqual(style_count_after_parse, style_count_after_second_parse,
|
||||
"Styles should be reused for repeated formatting")
|
||||
|
||||
"Styles should be reused for repeated formatting")
|
||||
|
||||
# Both should create same structure
|
||||
self.assertEqual(len(blocks), len(blocks2))
|
||||
|
||||
|
||||
def test_font_registry_with_nested_styles(self):
|
||||
"""Test style registry with nested HTML styles."""
|
||||
html_content = """
|
||||
<p>Text with <strong>bold and <em>bold italic</em> nested</strong> styles.</p>
|
||||
"""
|
||||
|
||||
|
||||
# Parse content
|
||||
blocks = parse_html_string(html_content, self.base_font, document=self.doc)
|
||||
|
||||
|
||||
# Should create styles for different style combinations
|
||||
paragraph = blocks[0]
|
||||
words = list(paragraph.words_iter())
|
||||
|
||||
|
||||
# Find words that are both bold and italic
|
||||
bold_italic_words = [w for _, w in words
|
||||
if w.style.weight == FontWeight.BOLD
|
||||
and w.style.style == FontStyle.ITALIC]
|
||||
|
||||
self.assertGreater(len(bold_italic_words), 0,
|
||||
"Should have words with combined bold+italic style")
|
||||
|
||||
bold_italic_words = [w for _, w in words
|
||||
if w.style.weight == FontWeight.BOLD
|
||||
and w.style.style == FontStyle.ITALIC]
|
||||
|
||||
self.assertGreater(len(bold_italic_words), 0,
|
||||
"Should have words with combined bold+italic style")
|
||||
|
||||
# Should have multiple styles in registry for different combinations
|
||||
self.assertGreater(self.doc.get_style_registry().get_style_count(), 1,
|
||||
"Should create separate styles for style combinations")
|
||||
"Should create separate styles for style combinations")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@ -6,7 +6,7 @@ reusing test patterns from test_html_extraction.py that are known to pass.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
from bs4 import BeautifulSoup
|
||||
from pyWebLayout.io.readers.html_extraction import (
|
||||
create_base_context,
|
||||
apply_element_styling,
|
||||
@ -50,11 +50,11 @@ from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration
|
||||
|
||||
class TestUtilityFunctions(unittest.TestCase):
|
||||
"""Test cases for utility functions."""
|
||||
|
||||
|
||||
def test_create_base_context(self):
|
||||
"""Test creation of base style context."""
|
||||
context = create_base_context()
|
||||
|
||||
|
||||
self.assertIsInstance(context, StyleContext)
|
||||
self.assertIsInstance(context.font, Font)
|
||||
self.assertIsNone(context.background)
|
||||
@ -62,113 +62,119 @@ class TestUtilityFunctions(unittest.TestCase):
|
||||
self.assertEqual(context.css_styles, {})
|
||||
self.assertEqual(context.element_attributes, {})
|
||||
self.assertEqual(context.parent_elements, [])
|
||||
|
||||
|
||||
def test_parse_inline_styles_from_existing_tests(self):
|
||||
"""Test parsing CSS inline styles - adapted from test_span_with_inline_styles."""
|
||||
# From: '<span style="color: red; font-weight: bold;">this part is red and bold</span>'
|
||||
style_text = "color: red; font-weight: bold;"
|
||||
styles = parse_inline_styles(style_text)
|
||||
|
||||
|
||||
expected = {
|
||||
"color": "red",
|
||||
"font-weight": "bold"
|
||||
}
|
||||
self.assertEqual(styles, expected)
|
||||
|
||||
|
||||
def test_parse_inline_styles_color_variations(self):
|
||||
"""Test parsing different color formats - adapted from test_color_variations."""
|
||||
# Test hex color parsing
|
||||
hex_style = "color: #ff0000;"
|
||||
styles = parse_inline_styles(hex_style)
|
||||
self.assertEqual(styles.get("color"), "#ff0000")
|
||||
|
||||
# Test named color parsing
|
||||
|
||||
# Test named color parsing
|
||||
named_style = "color: green;"
|
||||
styles = parse_inline_styles(named_style)
|
||||
self.assertEqual(styles.get("color"), "green")
|
||||
|
||||
|
||||
def test_apply_element_font_styles_bold_elements(self):
|
||||
"""Test font style application for bold elements - adapted from test_bold_text."""
|
||||
base_font = Font()
|
||||
|
||||
|
||||
# Test <strong> tag - from "<strong>bold text</strong>"
|
||||
font = apply_element_font_styles(base_font, "strong", {})
|
||||
self.assertEqual(font.weight, FontWeight.BOLD)
|
||||
|
||||
|
||||
# Test <b> tag
|
||||
font = apply_element_font_styles(base_font, "b", {})
|
||||
self.assertEqual(font.weight, FontWeight.BOLD)
|
||||
|
||||
|
||||
def test_apply_element_font_styles_italic_elements(self):
|
||||
"""Test font style application for italic elements - adapted from test_italic_text."""
|
||||
base_font = Font()
|
||||
|
||||
|
||||
# Test <em> tag - from "<em>italic text</em>"
|
||||
font = apply_element_font_styles(base_font, "em", {})
|
||||
self.assertEqual(font.style, FontStyle.ITALIC)
|
||||
|
||||
|
||||
# Test <i> tag
|
||||
font = apply_element_font_styles(base_font, "i", {})
|
||||
self.assertEqual(font.style, FontStyle.ITALIC)
|
||||
|
||||
|
||||
def test_apply_element_font_styles_decoration_elements(self):
|
||||
"""Test font decoration - adapted from test_underlined_text and test_strikethrough_text."""
|
||||
base_font = Font()
|
||||
|
||||
|
||||
# Test <u> tag - from "<u>underlined text</u>"
|
||||
font = apply_element_font_styles(base_font, "u", {})
|
||||
self.assertEqual(font.decoration, TextDecoration.UNDERLINE)
|
||||
|
||||
|
||||
# Test <s> tag - from "<s>strikethrough text</s>"
|
||||
font = apply_element_font_styles(base_font, "s", {})
|
||||
self.assertEqual(font.decoration, TextDecoration.STRIKETHROUGH)
|
||||
|
||||
|
||||
# Test <del> tag
|
||||
font = apply_element_font_styles(base_font, "del", {})
|
||||
self.assertEqual(font.decoration, TextDecoration.STRIKETHROUGH)
|
||||
|
||||
|
||||
def test_apply_element_font_styles_headings(self):
|
||||
"""Test heading font styles - adapted from test_headings."""
|
||||
base_font = Font()
|
||||
|
||||
|
||||
# Test heading sizes and weights - from test_headings which tests h1-h6
|
||||
headings = [("h1", 24), ("h2", 20), ("h3", 18), ("h4", 16), ("h5", 14), ("h6", 12)]
|
||||
|
||||
headings = [("h1", 24), ("h2", 20), ("h3", 18),
|
||||
("h4", 16), ("h5", 14), ("h6", 12)]
|
||||
|
||||
for tag, expected_size in headings:
|
||||
font = apply_element_font_styles(base_font, tag, {})
|
||||
self.assertEqual(font.font_size, expected_size, f"Size mismatch for {tag}")
|
||||
self.assertEqual(font.weight, FontWeight.BOLD, f"Weight should be bold for {tag}")
|
||||
|
||||
self.assertEqual(
|
||||
font.weight,
|
||||
FontWeight.BOLD,
|
||||
f"Weight should be bold for {tag}")
|
||||
|
||||
def test_apply_element_font_styles_color_parsing(self):
|
||||
"""Test color parsing - adapted from test_color_variations."""
|
||||
base_font = Font()
|
||||
|
||||
|
||||
# Test named colors - from '<span style="color: green;">Named green</span>'
|
||||
css_styles = {"color": "green"}
|
||||
font = apply_element_font_styles(base_font, "span", css_styles)
|
||||
self.assertEqual(font.colour, (0, 255, 0))
|
||||
|
||||
|
||||
# Test hex colors - from '<span style="color: #ff0000;">Hex red</span>'
|
||||
css_styles = {"color": "#ff0000"}
|
||||
font = apply_element_font_styles(base_font, "span", css_styles)
|
||||
self.assertEqual(font.colour, (255, 0, 0))
|
||||
|
||||
|
||||
def test_apply_element_styling_with_classes_and_styles(self):
|
||||
"""Test complete element styling - adapted from test_span_with_inline_styles."""
|
||||
# From: '<span style="color: red; font-weight: bold;">this part is red and bold</span>'
|
||||
soup = BeautifulSoup('<span class="highlight" style="color: red; font-weight: bold;">text</span>', 'html.parser')
|
||||
soup = BeautifulSoup(
|
||||
'<span class="highlight" style="color: red; font-weight: bold;">text</span>',
|
||||
'html.parser')
|
||||
element = soup.find('span')
|
||||
base_context = create_base_context()
|
||||
|
||||
|
||||
styled_context = apply_element_styling(base_context, element)
|
||||
|
||||
|
||||
# Check CSS classes
|
||||
self.assertIn("highlight", styled_context.css_classes)
|
||||
|
||||
|
||||
# Check CSS styles
|
||||
self.assertEqual(styled_context.css_styles.get("color"), "red")
|
||||
self.assertEqual(styled_context.css_styles.get("font-weight"), "bold")
|
||||
|
||||
|
||||
# Check font styling
|
||||
self.assertEqual(styled_context.font.colour, (255, 0, 0))
|
||||
self.assertEqual(styled_context.font.weight, FontWeight.BOLD)
|
||||
@ -176,170 +182,185 @@ class TestUtilityFunctions(unittest.TestCase):
|
||||
|
||||
class TestExtractTextContent(unittest.TestCase):
|
||||
"""Test cases for text content extraction."""
|
||||
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
self.base_context = create_base_context()
|
||||
|
||||
|
||||
def test_extract_simple_text(self):
|
||||
"""Test extracting simple text - adapted from test_simple."""
|
||||
# From: "<p>This is a paragraph.</p>"
|
||||
soup = BeautifulSoup('<p>This is a paragraph.</p>', 'html.parser')
|
||||
element = soup.find('p')
|
||||
|
||||
|
||||
words = extract_text_content(element, self.base_context)
|
||||
|
||||
|
||||
# Should match the expected word count from original test
|
||||
self.assertEqual(len(words), 4) # "This", "is", "a", "paragraph."
|
||||
self.assertIsInstance(words[0], Word)
|
||||
self.assertEqual(words[0].text, "This")
|
||||
|
||||
|
||||
def test_extract_styled_text_bold(self):
|
||||
"""Test extracting bold styled text - adapted from test_bold_text."""
|
||||
# From: "<p>This is <strong>bold text</strong> in a paragraph.</p>"
|
||||
soup = BeautifulSoup('<span>This is <strong>bold text</strong> in a paragraph.</span>', 'html.parser')
|
||||
soup = BeautifulSoup(
|
||||
'<span>This is <strong>bold text</strong> in a paragraph.</span>',
|
||||
'html.parser')
|
||||
element = soup.find('span')
|
||||
|
||||
|
||||
words = extract_text_content(element, self.base_context)
|
||||
|
||||
|
||||
# Find the bold words
|
||||
bold_words = [w for w in words if w.style.weight == FontWeight.BOLD]
|
||||
self.assertGreater(len(bold_words), 0, "Should have bold words")
|
||||
|
||||
|
||||
# Check specific words are bold (from original test expectations)
|
||||
bold_word_texts = [w.text for w in bold_words]
|
||||
self.assertIn("bold", bold_word_texts)
|
||||
self.assertIn("text", bold_word_texts)
|
||||
|
||||
|
||||
def test_extract_nested_formatting(self):
|
||||
"""Test nested formatting - adapted from test_nested_formatting."""
|
||||
# From: "<p>This has <strong>bold with <em>italic inside</em></strong> formatting.</p>"
|
||||
soup = BeautifulSoup('<span>This has <strong>bold with <em>italic inside</em></strong> formatting.</span>', 'html.parser')
|
||||
soup = BeautifulSoup(
|
||||
'<span>This has <strong>bold with <em>italic inside</em></strong> formatting.</span>',
|
||||
'html.parser')
|
||||
element = soup.find('span')
|
||||
|
||||
|
||||
words = extract_text_content(element, self.base_context)
|
||||
|
||||
|
||||
# Find words that should be both bold and italic
|
||||
bold_italic_words = [w for w in words
|
||||
if w.style.weight == FontWeight.BOLD and w.style.style == FontStyle.ITALIC]
|
||||
self.assertGreater(len(bold_italic_words), 0, "Should have words that are both bold and italic")
|
||||
bold_italic_words = [w for w in words if w.style.weight ==
|
||||
FontWeight.BOLD and w.style.style == FontStyle.ITALIC]
|
||||
self.assertGreater(
|
||||
len(bold_italic_words),
|
||||
0,
|
||||
"Should have words that are both bold and italic")
|
||||
|
||||
|
||||
class TestHandlerFunctions(unittest.TestCase):
|
||||
"""Test cases for HTML element handler functions using known working patterns."""
|
||||
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
self.base_context = create_base_context()
|
||||
|
||||
|
||||
def test_paragraph_handler_simple(self):
|
||||
"""Test paragraph handler - adapted from test_simple."""
|
||||
# From: "<p>This is a paragraph.</p>"
|
||||
soup = BeautifulSoup('<p>This is a paragraph.</p>', 'html.parser')
|
||||
element = soup.find('p')
|
||||
|
||||
|
||||
result = paragraph_handler(element, self.base_context)
|
||||
|
||||
|
||||
self.assertIsInstance(result, Paragraph)
|
||||
# Should match original test expectations
|
||||
self.assertEqual(len(result), 4) # 4 words
|
||||
|
||||
|
||||
words = list(result.words_iter())
|
||||
expected_texts = ["This", "is", "a", "paragraph."]
|
||||
for i, expected_text in enumerate(expected_texts):
|
||||
self.assertEqual(words[i][1].text, expected_text)
|
||||
|
||||
|
||||
def test_heading_handler_all_levels(self):
|
||||
"""Test heading handler - adapted from test_headings."""
|
||||
# From: "<h1>Heading 1</h1><h2>Heading 2</h2>..."
|
||||
expected_levels = [HeadingLevel.H1, HeadingLevel.H2, HeadingLevel.H3,
|
||||
HeadingLevel.H4, HeadingLevel.H5, HeadingLevel.H6]
|
||||
|
||||
# From: "<h1>Heading 1</h1><h2>Heading 2</h2>..."
|
||||
expected_levels = [HeadingLevel.H1, HeadingLevel.H2, HeadingLevel.H3,
|
||||
HeadingLevel.H4, HeadingLevel.H5, HeadingLevel.H6]
|
||||
|
||||
for i, expected_level in enumerate(expected_levels, 1):
|
||||
tag = f"h{i}"
|
||||
soup = BeautifulSoup(f'<{tag}>Heading {i}</{tag}>', 'html.parser')
|
||||
element = soup.find(tag)
|
||||
|
||||
|
||||
result = heading_handler(element, self.base_context)
|
||||
|
||||
|
||||
self.assertIsInstance(result, Heading)
|
||||
self.assertEqual(result.level, expected_level)
|
||||
|
||||
|
||||
# Should match original test word expectations
|
||||
words = list(result.words_iter())
|
||||
self.assertEqual(len(words), 2) # "Heading" and number
|
||||
self.assertEqual(words[0][1].text, "Heading")
|
||||
|
||||
|
||||
def test_blockquote_handler(self):
|
||||
"""Test blockquote handler - adapted from test_blockquote."""
|
||||
# From: "<blockquote><p>This is a quoted paragraph.</p></blockquote>"
|
||||
soup = BeautifulSoup('<blockquote><p>This is a quoted paragraph.</p></blockquote>', 'html.parser')
|
||||
soup = BeautifulSoup(
|
||||
'<blockquote><p>This is a quoted paragraph.</p></blockquote>',
|
||||
'html.parser')
|
||||
element = soup.find('blockquote')
|
||||
|
||||
|
||||
result = blockquote_handler(element, self.base_context)
|
||||
|
||||
|
||||
self.assertIsInstance(result, Quote)
|
||||
|
||||
|
||||
# Check that the quote contains a paragraph (from original test)
|
||||
quote_blocks = list(result.blocks())
|
||||
self.assertEqual(len(quote_blocks), 1)
|
||||
self.assertIsInstance(quote_blocks[0], Paragraph)
|
||||
|
||||
|
||||
def test_preformatted_handler(self):
|
||||
"""Test preformatted handler - adapted from test_preformatted_code."""
|
||||
# From: "<pre><code>function hello() {\n console.log('Hello');\n}</code></pre>"
|
||||
soup = BeautifulSoup('<pre><code>function hello() {\n console.log(\'Hello\');\n}</code></pre>', 'html.parser')
|
||||
soup = BeautifulSoup(
|
||||
'<pre><code>function hello() {\n console.log(\'Hello\');\n}</code></pre>',
|
||||
'html.parser')
|
||||
element = soup.find('pre')
|
||||
|
||||
|
||||
result = preformatted_handler(element, self.base_context)
|
||||
|
||||
|
||||
self.assertIsInstance(result, CodeBlock)
|
||||
|
||||
|
||||
# Should have lines (from original test expectation)
|
||||
lines = list(result.lines())
|
||||
self.assertGreater(len(lines), 0)
|
||||
|
||||
|
||||
def test_unordered_list_handler(self):
|
||||
"""Test unordered list handler - adapted from test_unordered_list."""
|
||||
# From: "<ul><li>First item</li><li>Second item</li><li>Third item</li></ul>"
|
||||
soup = BeautifulSoup('<ul><li>First item</li><li>Second item</li><li>Third item</li></ul>', 'html.parser')
|
||||
soup = BeautifulSoup(
|
||||
'<ul><li>First item</li><li>Second item</li><li>Third item</li></ul>',
|
||||
'html.parser')
|
||||
element = soup.find('ul')
|
||||
|
||||
|
||||
result = unordered_list_handler(element, self.base_context)
|
||||
|
||||
|
||||
self.assertIsInstance(result, HList)
|
||||
self.assertEqual(result.style, ListStyle.UNORDERED)
|
||||
|
||||
|
||||
# Should match original test expectations
|
||||
items = list(result.items())
|
||||
self.assertEqual(len(items), 3)
|
||||
|
||||
|
||||
def test_ordered_list_handler(self):
|
||||
"""Test ordered list handler - adapted from test_ordered_list."""
|
||||
# From: "<ol><li>First item</li><li>Second item</li><li>Third item</li></ol>"
|
||||
soup = BeautifulSoup('<ol><li>First item</li><li>Second item</li><li>Third item</li></ol>', 'html.parser')
|
||||
soup = BeautifulSoup(
|
||||
'<ol><li>First item</li><li>Second item</li><li>Third item</li></ol>',
|
||||
'html.parser')
|
||||
element = soup.find('ol')
|
||||
|
||||
|
||||
result = ordered_list_handler(element, self.base_context)
|
||||
|
||||
|
||||
self.assertIsInstance(result, HList)
|
||||
self.assertEqual(result.style, ListStyle.ORDERED)
|
||||
|
||||
|
||||
# Should match original test expectations
|
||||
items = list(result.items())
|
||||
self.assertEqual(len(items), 3) # "First item", "Second item", "Third item"
|
||||
|
||||
|
||||
def test_list_item_handler(self):
|
||||
"""Test list item handler."""
|
||||
soup = BeautifulSoup('<li>List item content</li>', 'html.parser')
|
||||
element = soup.find('li')
|
||||
|
||||
|
||||
result = list_item_handler(element, self.base_context)
|
||||
|
||||
|
||||
self.assertIsInstance(result, ListItem)
|
||||
blocks = list(result.blocks())
|
||||
self.assertGreater(len(blocks), 0)
|
||||
|
||||
|
||||
def test_table_handler(self):
|
||||
"""Test table handler - adapted from test_table_basic."""
|
||||
# From test_table_basic structure
|
||||
@ -356,76 +377,80 @@ class TestHandlerFunctions(unittest.TestCase):
|
||||
</table>
|
||||
''', 'html.parser')
|
||||
element = soup.find('table')
|
||||
|
||||
|
||||
result = table_handler(element, self.base_context)
|
||||
|
||||
|
||||
self.assertIsInstance(result, Table)
|
||||
|
||||
|
||||
def test_table_row_handler(self):
|
||||
"""Test table row handler."""
|
||||
soup = BeautifulSoup('<tr><td>Cell 1</td><td>Cell 2</td></tr>', 'html.parser')
|
||||
element = soup.find('tr')
|
||||
|
||||
|
||||
result = table_row_handler(element, self.base_context)
|
||||
|
||||
|
||||
self.assertIsInstance(result, TableRow)
|
||||
|
||||
|
||||
def test_table_cell_handler(self):
|
||||
"""Test table cell handler."""
|
||||
soup = BeautifulSoup('<td>Cell content</td>', 'html.parser')
|
||||
element = soup.find('td')
|
||||
|
||||
|
||||
# Apply styling to get attributes
|
||||
styled_context = apply_element_styling(self.base_context, element)
|
||||
result = table_cell_handler(element, styled_context)
|
||||
|
||||
|
||||
self.assertIsInstance(result, TableCell)
|
||||
self.assertEqual(result.is_header, False)
|
||||
|
||||
|
||||
def test_table_header_cell_handler(self):
|
||||
"""Test table header cell handler."""
|
||||
soup = BeautifulSoup('<th>Header content</th>', 'html.parser')
|
||||
element = soup.find('th')
|
||||
|
||||
|
||||
# Apply styling to get attributes
|
||||
styled_context = apply_element_styling(self.base_context, element)
|
||||
result = table_header_cell_handler(element, styled_context)
|
||||
|
||||
|
||||
self.assertIsInstance(result, TableCell)
|
||||
self.assertEqual(result.is_header, True)
|
||||
|
||||
|
||||
def test_horizontal_rule_handler(self):
|
||||
"""Test horizontal rule handler."""
|
||||
soup = BeautifulSoup('<hr>', 'html.parser')
|
||||
element = soup.find('hr')
|
||||
|
||||
|
||||
result = horizontal_rule_handler(element, self.base_context)
|
||||
|
||||
|
||||
self.assertIsInstance(result, HorizontalRule)
|
||||
|
||||
|
||||
def test_image_handler(self):
|
||||
"""Test image handler."""
|
||||
soup = BeautifulSoup('<img src="test.jpg" alt="Test image" width="100" height="50">', 'html.parser')
|
||||
soup = BeautifulSoup(
|
||||
'<img src="test.jpg" alt="Test image" width="100" height="50">',
|
||||
'html.parser')
|
||||
element = soup.find('img')
|
||||
|
||||
|
||||
# Need to apply styling first to get attributes
|
||||
styled_context = apply_element_styling(self.base_context, element)
|
||||
result = image_handler(element, styled_context)
|
||||
|
||||
|
||||
self.assertIsInstance(result, Image)
|
||||
self.assertEqual(result.source, "test.jpg")
|
||||
self.assertEqual(result.alt_text, "Test image")
|
||||
self.assertEqual(result.width, 100)
|
||||
self.assertEqual(result.height, 50)
|
||||
|
||||
|
||||
def test_div_handler_container(self):
|
||||
"""Test div handler - adapted from test_div_container."""
|
||||
# From: "<div><p>First paragraph.</p><p>Second paragraph.</p></div>"
|
||||
soup = BeautifulSoup('<div><p>First paragraph.</p><p>Second paragraph.</p></div>', 'html.parser')
|
||||
soup = BeautifulSoup(
|
||||
'<div><p>First paragraph.</p><p>Second paragraph.</p></div>',
|
||||
'html.parser')
|
||||
element = soup.find('div')
|
||||
|
||||
|
||||
result = div_handler(element, self.base_context)
|
||||
|
||||
|
||||
self.assertIsInstance(result, list)
|
||||
# Should match original test expectations
|
||||
self.assertEqual(len(result), 2)
|
||||
@ -435,23 +460,25 @@ class TestHandlerFunctions(unittest.TestCase):
|
||||
|
||||
class TestStyledContentHandling(unittest.TestCase):
|
||||
"""Test styled content handling using patterns from existing tests."""
|
||||
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
self.base_context = create_base_context()
|
||||
|
||||
|
||||
def test_paragraph_with_bold_content(self):
|
||||
"""Test paragraph with bold content - adapted from test_bold_text."""
|
||||
# From: "<p>This is <strong>bold text</strong> in a paragraph.</p>"
|
||||
soup = BeautifulSoup('<p>This is <strong>bold text</strong> in a paragraph.</p>', 'html.parser')
|
||||
soup = BeautifulSoup(
|
||||
'<p>This is <strong>bold text</strong> in a paragraph.</p>',
|
||||
'html.parser')
|
||||
element = soup.find('p')
|
||||
|
||||
|
||||
result = paragraph_handler(element, self.base_context)
|
||||
|
||||
|
||||
self.assertIsInstance(result, Paragraph)
|
||||
words = list(result.words_iter())
|
||||
self.assertEqual(len(words), 7) # From original test expectation
|
||||
|
||||
|
||||
# Check that 'bold' and 'text' words have bold font weight (from original test)
|
||||
bold_word = words[2][1] # 'bold'
|
||||
text_word = words[3][1] # 'text'
|
||||
@ -459,31 +486,35 @@ class TestStyledContentHandling(unittest.TestCase):
|
||||
self.assertEqual(bold_word.style.weight, FontWeight.BOLD)
|
||||
self.assertEqual(text_word.text, "text")
|
||||
self.assertEqual(text_word.style.weight, FontWeight.BOLD)
|
||||
|
||||
|
||||
# Check that other words are not bold (from original test)
|
||||
normal_word = words[0][1] # 'This'
|
||||
self.assertEqual(normal_word.text, "This")
|
||||
self.assertNotEqual(normal_word.style.weight, FontWeight.BOLD)
|
||||
|
||||
|
||||
def test_paragraph_with_mixed_formatting(self):
|
||||
"""Test mixed formatting - adapted from test_mixed_formatting."""
|
||||
# From: "<p>This paragraph contains <strong>bold</strong>, <em>italic</em>, <span style=\"color: blue;\">blue</span>..."
|
||||
soup = BeautifulSoup('<p>This paragraph contains <strong>bold</strong>, <em>italic</em>, <span style="color: blue;">blue</span> text.</p>', 'html.parser')
|
||||
# From: "<p>This paragraph contains <strong>bold</strong>, <em>italic</em>..."
|
||||
html_str = (
|
||||
'<p>This paragraph contains <strong>bold</strong>, <em>italic</em>, '
|
||||
'<span style="color: blue;">blue</span> text.</p>'
|
||||
)
|
||||
soup = BeautifulSoup(html_str, 'html.parser')
|
||||
element = soup.find('p')
|
||||
|
||||
|
||||
result = paragraph_handler(element, self.base_context)
|
||||
|
||||
|
||||
self.assertIsInstance(result, Paragraph)
|
||||
words = list(result.words_iter())
|
||||
|
||||
|
||||
# Check for bold word (from original test pattern)
|
||||
bold_words = [w for _, w in words if w.style.weight == FontWeight.BOLD]
|
||||
self.assertGreater(len(bold_words), 0, "Should have bold words")
|
||||
|
||||
|
||||
# Check for italic word (from original test pattern)
|
||||
italic_words = [w for _, w in words if w.style.style == FontStyle.ITALIC]
|
||||
self.assertGreater(len(italic_words), 0, "Should have italic words")
|
||||
|
||||
|
||||
# Check for blue colored word (from original test pattern)
|
||||
blue_words = [w for _, w in words if w.style.colour == (0, 0, 255)]
|
||||
self.assertGreater(len(blue_words), 0, "Should have blue colored words")
|
||||
|
||||
@ -18,97 +18,115 @@ class TestHTMLFileLoader(unittest.TestCase):
|
||||
def test_load_html_file(self):
|
||||
"""Test loading and parsing an HTML file from disk."""
|
||||
# Path to the test HTML file
|
||||
html_file_path = os.path.join("tests", "data", "Kimi Räikkönen - Wikipedia.html")
|
||||
|
||||
html_file_path = os.path.join(
|
||||
"tests", "data", "Kimi Räikkönen - Wikipedia.html")
|
||||
|
||||
# Verify the test file exists
|
||||
self.assertTrue(os.path.exists(html_file_path), f"Test HTML file not found: {html_file_path}")
|
||||
|
||||
self.assertTrue(
|
||||
os.path.exists(html_file_path),
|
||||
f"Test HTML file not found: {html_file_path}")
|
||||
|
||||
# Read the HTML file
|
||||
with open(html_file_path, 'r', encoding='utf-8') as file:
|
||||
html_content = file.read()
|
||||
|
||||
|
||||
# Verify we got some content
|
||||
self.assertGreater(len(html_content), 0, "HTML file should not be empty")
|
||||
|
||||
|
||||
# Parse the HTML content using the html_extraction module
|
||||
try:
|
||||
blocks = parse_html_string(html_content)
|
||||
except Exception as e:
|
||||
self.fail(f"Failed to parse HTML file: {e}")
|
||||
|
||||
|
||||
# Verify we got some blocks
|
||||
self.assertIsInstance(blocks, list, "parse_html_string should return a list")
|
||||
self.assertGreater(len(blocks), 0, "Should extract at least one block from the HTML file")
|
||||
|
||||
self.assertGreater(
|
||||
len(blocks),
|
||||
0,
|
||||
"Should extract at least one block from the HTML file")
|
||||
|
||||
# Verify all returned items are Block instances
|
||||
for i, block in enumerate(blocks):
|
||||
self.assertIsInstance(block, Block, f"Item {i} should be a Block instance, got {type(block)}")
|
||||
|
||||
self.assertIsInstance(
|
||||
block,
|
||||
Block,
|
||||
f"Item {i} should be a Block instance, got {
|
||||
type(block)}")
|
||||
|
||||
print(f"Successfully loaded and parsed HTML file with {len(blocks)} blocks")
|
||||
|
||||
def test_load_html_file_with_custom_font(self):
|
||||
"""Test loading HTML file with a custom base font."""
|
||||
html_file_path = os.path.join("tests", "data", "Kimi Räikkönen - Wikipedia.html")
|
||||
|
||||
html_file_path = os.path.join(
|
||||
"tests", "data", "Kimi Räikkönen - Wikipedia.html")
|
||||
|
||||
# Skip if file doesn't exist
|
||||
if not os.path.exists(html_file_path):
|
||||
self.skipTest(f"Test HTML file not found: {html_file_path}")
|
||||
|
||||
|
||||
# Create a custom font
|
||||
custom_font = Font(font_size=14, colour=(100, 100, 100))
|
||||
|
||||
|
||||
# Read and parse with custom font
|
||||
with open(html_file_path, 'r', encoding='utf-8') as file:
|
||||
html_content = file.read()
|
||||
|
||||
|
||||
blocks = parse_html_string(html_content, base_font=custom_font)
|
||||
|
||||
|
||||
# Verify we got blocks
|
||||
self.assertGreater(len(blocks), 0, "Should extract blocks with custom font")
|
||||
|
||||
print(f"Successfully parsed HTML file with custom font, got {len(blocks)} blocks")
|
||||
|
||||
print(
|
||||
f"Successfully parsed HTML file with custom font, got {
|
||||
len(blocks)} blocks")
|
||||
|
||||
def test_load_html_file_content_types(self):
|
||||
"""Test that the loaded HTML file contains expected content types."""
|
||||
html_file_path = os.path.join("tests", "data", "Kimi Räikkönen - Wikipedia.html")
|
||||
|
||||
html_file_path = os.path.join(
|
||||
"tests", "data", "Kimi Räikkönen - Wikipedia.html")
|
||||
|
||||
# Skip if file doesn't exist
|
||||
if not os.path.exists(html_file_path):
|
||||
self.skipTest(f"Test HTML file not found: {html_file_path}")
|
||||
|
||||
|
||||
with open(html_file_path, 'r', encoding='utf-8') as file:
|
||||
html_content = file.read()
|
||||
|
||||
|
||||
blocks = parse_html_string(html_content)
|
||||
|
||||
|
||||
# Check that we have different types of blocks
|
||||
block_type_names = [type(block).__name__ for block in blocks]
|
||||
unique_types = set(block_type_names)
|
||||
|
||||
|
||||
# A Wikipedia page should contain multiple types of content
|
||||
self.assertGreater(len(unique_types), 1, "Should have multiple types of blocks in Wikipedia page")
|
||||
|
||||
self.assertGreater(
|
||||
len(unique_types),
|
||||
1,
|
||||
"Should have multiple types of blocks in Wikipedia page")
|
||||
|
||||
print(f"Found block types: {sorted(unique_types)}")
|
||||
|
||||
def test_html_file_size_handling(self):
|
||||
"""Test that large HTML files can be handled gracefully."""
|
||||
html_file_path = os.path.join("tests", "data", "Kimi Räikkönen - Wikipedia.html")
|
||||
|
||||
html_file_path = os.path.join(
|
||||
"tests", "data", "Kimi Räikkönen - Wikipedia.html")
|
||||
|
||||
# Skip if file doesn't exist
|
||||
if not os.path.exists(html_file_path):
|
||||
self.skipTest(f"Test HTML file not found: {html_file_path}")
|
||||
|
||||
|
||||
# Get file size
|
||||
file_size = os.path.getsize(html_file_path)
|
||||
print(f"HTML file size: {file_size} bytes")
|
||||
|
||||
|
||||
# Read and parse
|
||||
with open(html_file_path, 'r', encoding='utf-8') as file:
|
||||
html_content = file.read()
|
||||
|
||||
|
||||
# This should not raise an exception even for large files
|
||||
blocks = parse_html_string(html_content)
|
||||
|
||||
|
||||
# Basic verification
|
||||
self.assertIsInstance(blocks, list)
|
||||
print(f"Successfully processed {file_size} byte file into {len(blocks)} blocks")
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -155,8 +155,11 @@ class TestHTMLLinkInteractivity(unittest.TestCase):
|
||||
html = '''
|
||||
<div>
|
||||
<h2 style="text-align: center; font-size: 18px; font-weight: bold; margin: 10px 0;">Settings</h2>
|
||||
<p style="padding: 15px; margin: 5px 0; background-color: #dc3545; text-align: center; border-radius: 5px;">
|
||||
<a href="action:back_to_library" style="text-decoration: none; color: white; font-weight: bold; font-size: 14px;">◄ Back to Library</a>
|
||||
<p style="padding: 15px; margin: 5px 0; background-color: #dc3545; text-align: center;
|
||||
border-radius: 5px;">
|
||||
<a href="action:back_to_library"
|
||||
style="text-decoration: none; color: white; font-weight: bold; font-size: 14px;">
|
||||
◄ Back to Library</a>
|
||||
</p>
|
||||
<p style="padding: 10px; margin: 5px 0; background-color: #f8f9fa; border-radius: 5px;">
|
||||
<span style="font-weight: bold;">Font Size: 100%</span><br>
|
||||
@ -177,23 +180,26 @@ class TestHTMLLinkInteractivity(unittest.TestCase):
|
||||
all_linked_words.append(word)
|
||||
|
||||
# Verify we found the expected links
|
||||
self.assertGreater(len(all_linked_words), 0, "Should find LinkedWords in settings HTML")
|
||||
self.assertGreater(
|
||||
len(all_linked_words),
|
||||
0,
|
||||
"Should find LinkedWords in settings HTML")
|
||||
|
||||
# Check for specific link targets
|
||||
link_targets = {word.location for word in all_linked_words}
|
||||
|
||||
self.assertIn("action:back_to_library", link_targets,
|
||||
"Should find 'Back to Library' link")
|
||||
"Should find 'Back to Library' link")
|
||||
self.assertIn("setting:font_decrease", link_targets,
|
||||
"Should find font decrease link")
|
||||
"Should find font decrease link")
|
||||
self.assertIn("setting:font_increase", link_targets,
|
||||
"Should find font increase link")
|
||||
"Should find font increase link")
|
||||
|
||||
# Verify the link texts
|
||||
back_to_library_words = [w for w in all_linked_words
|
||||
if w.location == "action:back_to_library"]
|
||||
if w.location == "action:back_to_library"]
|
||||
self.assertGreater(len(back_to_library_words), 0,
|
||||
"Should have words linked to back_to_library action")
|
||||
"Should have words linked to back_to_library action")
|
||||
|
||||
# Print debug info
|
||||
print(f"\nFound {len(all_linked_words)} linked words:")
|
||||
|
||||
@ -7,173 +7,172 @@ from bs4 import BeautifulSoup
|
||||
from pyWebLayout.io.readers.html_extraction import (
|
||||
parse_html_string,
|
||||
extract_text_content,
|
||||
create_base_context,
|
||||
apply_element_styling
|
||||
create_base_context
|
||||
)
|
||||
from pyWebLayout.abstract.inline import LinkedWord
|
||||
from pyWebLayout.abstract.functional import LinkType
|
||||
from pyWebLayout.abstract.block import Paragraph
|
||||
from pyWebLayout.style import Font
|
||||
|
||||
|
||||
class TestHTMLLinkExtraction(unittest.TestCase):
|
||||
"""Test cases for HTML hyperlink extraction."""
|
||||
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
self.base_context = create_base_context()
|
||||
|
||||
|
||||
def test_simple_external_link(self):
|
||||
"""Test extracting a simple external link."""
|
||||
html = '<p>Visit <a href="https://example.com">this site</a> for more.</p>'
|
||||
blocks = parse_html_string(html)
|
||||
|
||||
|
||||
self.assertEqual(len(blocks), 1)
|
||||
self.assertIsInstance(blocks[0], Paragraph)
|
||||
|
||||
|
||||
paragraph = blocks[0]
|
||||
words = list(paragraph.words)
|
||||
|
||||
|
||||
# Should have: "Visit", "this", "site", "for", "more."
|
||||
self.assertEqual(len(words), 5)
|
||||
|
||||
|
||||
# Check that "this" and "site" are LinkedWords
|
||||
self.assertIsInstance(words[1], LinkedWord)
|
||||
self.assertIsInstance(words[2], LinkedWord)
|
||||
|
||||
|
||||
# Check link properties
|
||||
self.assertEqual(words[1].location, "https://example.com")
|
||||
self.assertEqual(words[1].link_type, LinkType.EXTERNAL)
|
||||
self.assertEqual(words[2].location, "https://example.com")
|
||||
self.assertEqual(words[2].link_type, LinkType.EXTERNAL)
|
||||
|
||||
|
||||
def test_internal_link(self):
|
||||
"""Test extracting an internal anchor link."""
|
||||
html = '<p>Go to <a href="#section2">section 2</a> below.</p>'
|
||||
blocks = parse_html_string(html)
|
||||
|
||||
|
||||
paragraph = blocks[0]
|
||||
words = list(paragraph.words)
|
||||
|
||||
|
||||
# Find LinkedWords
|
||||
linked_words = [w for w in words if isinstance(w, LinkedWord)]
|
||||
self.assertEqual(len(linked_words), 2) # "section" and "2"
|
||||
|
||||
|
||||
# Check they're internal links
|
||||
for word in linked_words:
|
||||
self.assertEqual(word.link_type, LinkType.INTERNAL)
|
||||
self.assertEqual(word.location, "#section2")
|
||||
|
||||
|
||||
def test_multi_word_link(self):
|
||||
"""Test that multi-word links create separate LinkedWords."""
|
||||
html = '<p><a href="/next">click here for next page</a></p>'
|
||||
blocks = parse_html_string(html)
|
||||
|
||||
|
||||
paragraph = blocks[0]
|
||||
words = list(paragraph.words)
|
||||
|
||||
|
||||
# All words should be LinkedWords
|
||||
self.assertEqual(len(words), 5)
|
||||
for word in words:
|
||||
self.assertIsInstance(word, LinkedWord)
|
||||
self.assertEqual(word.location, "/next")
|
||||
self.assertEqual(word.link_type, LinkType.INTERNAL)
|
||||
|
||||
|
||||
def test_link_with_title(self):
|
||||
"""Test extracting link with title attribute."""
|
||||
html = '<p><a href="https://example.com" title="Visit Example">click</a></p>'
|
||||
blocks = parse_html_string(html)
|
||||
|
||||
|
||||
paragraph = blocks[0]
|
||||
words = list(paragraph.words)
|
||||
|
||||
|
||||
self.assertEqual(len(words), 1)
|
||||
self.assertIsInstance(words[0], LinkedWord)
|
||||
self.assertEqual(words[0].link_title, "Visit Example")
|
||||
|
||||
|
||||
def test_mixed_linked_and_normal_text(self):
|
||||
"""Test paragraph with both linked and normal text."""
|
||||
html = '<p>Some <a href="/page">linked text</a> and normal text.</p>'
|
||||
blocks = parse_html_string(html)
|
||||
|
||||
|
||||
paragraph = blocks[0]
|
||||
words = list(paragraph.words)
|
||||
|
||||
|
||||
# "Some" - normal
|
||||
# "linked" - LinkedWord
|
||||
# "text" - LinkedWord
|
||||
# "text" - LinkedWord
|
||||
# "and" - normal
|
||||
# "normal" - normal
|
||||
# "text." - normal
|
||||
|
||||
|
||||
self.assertNotIsInstance(words[0], LinkedWord) # "Some"
|
||||
self.assertIsInstance(words[1], LinkedWord) # "linked"
|
||||
self.assertIsInstance(words[2], LinkedWord) # "text"
|
||||
self.assertNotIsInstance(words[3], LinkedWord) # "and"
|
||||
|
||||
|
||||
def test_link_without_href(self):
|
||||
"""Test that <a> without href is treated as normal text."""
|
||||
html = '<p><a>not a link</a></p>'
|
||||
blocks = parse_html_string(html)
|
||||
|
||||
|
||||
paragraph = blocks[0]
|
||||
words = list(paragraph.words)
|
||||
|
||||
|
||||
# Should be regular Words, not LinkedWords
|
||||
for word in words:
|
||||
self.assertNotIsInstance(word, LinkedWord)
|
||||
|
||||
|
||||
def test_javascript_link(self):
|
||||
"""Test that javascript: links are detected as API type."""
|
||||
html = '<p><a href="javascript:alert()">click</a></p>'
|
||||
blocks = parse_html_string(html)
|
||||
|
||||
|
||||
paragraph = blocks[0]
|
||||
words = list(paragraph.words)
|
||||
|
||||
|
||||
self.assertIsInstance(words[0], LinkedWord)
|
||||
self.assertEqual(words[0].link_type, LinkType.API)
|
||||
|
||||
|
||||
def test_nested_formatting_in_link(self):
|
||||
"""Test link with nested formatting."""
|
||||
html = '<p><a href="/page">text with <strong>bold</strong> word</a></p>'
|
||||
blocks = parse_html_string(html)
|
||||
|
||||
|
||||
paragraph = blocks[0]
|
||||
words = list(paragraph.words)
|
||||
|
||||
|
||||
# All should be LinkedWords regardless of formatting
|
||||
for word in words:
|
||||
self.assertIsInstance(word, LinkedWord)
|
||||
self.assertEqual(word.location, "/page")
|
||||
|
||||
|
||||
def test_multiple_links_in_paragraph(self):
|
||||
"""Test paragraph with multiple separate links."""
|
||||
html = '<p><a href="/page1">first</a> and <a href="/page2">second</a> link</p>'
|
||||
blocks = parse_html_string(html)
|
||||
|
||||
|
||||
paragraph = blocks[0]
|
||||
words = list(paragraph.words)
|
||||
|
||||
|
||||
# Find LinkedWords and their locations
|
||||
linked_words = [(w.text, w.location) for w in words if isinstance(w, LinkedWord)]
|
||||
|
||||
linked_words = [(w.text, w.location)
|
||||
for w in words if isinstance(w, LinkedWord)]
|
||||
|
||||
# Should have "first" linked to /page1 and "second" linked to /page2
|
||||
self.assertIn(("first", "/page1"), linked_words)
|
||||
self.assertIn(("second", "/page2"), linked_words)
|
||||
|
||||
|
||||
def test_extract_text_content_with_links(self):
|
||||
"""Test extract_text_content directly with link elements."""
|
||||
html = '<span>Visit <a href="https://example.com">our site</a> today</span>'
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
element = soup.find('span')
|
||||
|
||||
|
||||
context = create_base_context()
|
||||
words = extract_text_content(element, context)
|
||||
|
||||
|
||||
# Should have: "Visit", "our", "site", "today"
|
||||
self.assertEqual(len(words), 4)
|
||||
|
||||
|
||||
# Check types
|
||||
self.assertNotIsInstance(words[0], LinkedWord) # "Visit"
|
||||
self.assertIsInstance(words[1], LinkedWord) # "our"
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -48,7 +48,10 @@ class TestHTMLLinksInEreader(unittest.TestCase):
|
||||
if isinstance(word, LinkedWord):
|
||||
all_linked_words.append(word)
|
||||
|
||||
self.assertGreater(len(all_linked_words), 0, "Should create LinkedWords from HTML")
|
||||
self.assertGreater(
|
||||
len(all_linked_words),
|
||||
0,
|
||||
"Should create LinkedWords from HTML")
|
||||
print(f"\n Created {len(all_linked_words)} LinkedWords from HTML")
|
||||
|
||||
# Step 2: Create EreaderLayoutManager (like the dreader app does)
|
||||
@ -91,17 +94,19 @@ class TestHTMLLinksInEreader(unittest.TestCase):
|
||||
print(f" - '{elem['text']}' -> {elem['location']}")
|
||||
|
||||
# THIS IS THE KEY ASSERTION
|
||||
self.assertGreater(len(interactive_elements), 0,
|
||||
"Settings overlay should have interactive LinkText objects after rendering!")
|
||||
self.assertGreater(
|
||||
len(interactive_elements),
|
||||
0,
|
||||
"Settings overlay should have interactive LinkText objects after rendering!")
|
||||
|
||||
# Verify the expected links are present
|
||||
locations = {elem['location'] for elem in interactive_elements}
|
||||
self.assertIn("action:back_to_library", locations,
|
||||
"Should find 'Back to Library' link")
|
||||
"Should find 'Back to Library' link")
|
||||
self.assertIn("setting:font_decrease", locations,
|
||||
"Should find font decrease link")
|
||||
"Should find font decrease link")
|
||||
self.assertIn("setting:font_increase", locations,
|
||||
"Should find font increase link")
|
||||
"Should find font increase link")
|
||||
|
||||
def test_query_point_detects_links(self):
|
||||
"""Test that query_point can detect LinkText objects."""
|
||||
@ -134,8 +139,9 @@ class TestHTMLLinksInEreader(unittest.TestCase):
|
||||
if found_link:
|
||||
break
|
||||
|
||||
self.assertTrue(found_link,
|
||||
"Should be able to detect link via query_point somewhere on the page")
|
||||
self.assertTrue(
|
||||
found_link,
|
||||
"Should be able to detect link via query_point somewhere on the page")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@ -5,14 +5,12 @@ This test focuses on verifying that the document layouter properly
|
||||
integrates word spacing constraints from the style system.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, MagicMock, patch
|
||||
from typing import List, Optional
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from pyWebLayout.layout.document_layouter import paragraph_layouter, table_layouter, DocumentLayouter
|
||||
from pyWebLayout.style.abstract_style import AbstractStyle
|
||||
from pyWebLayout.style.concrete_style import ConcreteStyle, StyleResolver, RenderingContext
|
||||
from pyWebLayout.abstract.block import Table, TableRow, TableCell
|
||||
from pyWebLayout.style.concrete_style import StyleResolver, RenderingContext
|
||||
from pyWebLayout.abstract.block import Table
|
||||
from pyWebLayout.concrete.table import TableStyle
|
||||
|
||||
|
||||
@ -29,21 +27,21 @@ class TestDocumentLayouter:
|
||||
self.mock_page.draw = Mock()
|
||||
self.mock_page.can_fit_line = Mock(return_value=True)
|
||||
self.mock_page.add_child = Mock()
|
||||
|
||||
|
||||
# Create mock page style with all required numeric properties
|
||||
self.mock_page.style = Mock()
|
||||
self.mock_page.style.max_font_size = 72 # Reasonable maximum font size
|
||||
self.mock_page.style.line_spacing_multiplier = 1.2 # Standard line spacing
|
||||
|
||||
|
||||
# Create mock style resolver
|
||||
self.mock_style_resolver = Mock()
|
||||
self.mock_page.style_resolver = self.mock_style_resolver
|
||||
|
||||
|
||||
# Create mock paragraph
|
||||
self.mock_paragraph = Mock()
|
||||
self.mock_paragraph.line_height = 20
|
||||
self.mock_paragraph.style = AbstractStyle()
|
||||
|
||||
|
||||
# Create mock words
|
||||
self.mock_words = []
|
||||
for i in range(5):
|
||||
@ -51,20 +49,22 @@ class TestDocumentLayouter:
|
||||
word.text = f"word{i}"
|
||||
self.mock_words.append(word)
|
||||
self.mock_paragraph.words = self.mock_words
|
||||
|
||||
|
||||
# Create mock concrete style with word spacing constraints
|
||||
self.mock_concrete_style = Mock()
|
||||
self.mock_concrete_style.word_spacing_min = 2.0
|
||||
self.mock_concrete_style.word_spacing_max = 8.0
|
||||
self.mock_concrete_style.text_align = "left"
|
||||
|
||||
|
||||
# Create mock font that returns proper numeric metrics (not Mock objects)
|
||||
mock_font = Mock()
|
||||
# CRITICAL: getmetrics() must return actual numeric values, not Mock objects
|
||||
# This prevents "TypeError: '>' not supported between instances of 'Mock' and 'Mock'"
|
||||
mock_font.getmetrics.return_value = (12, 4) # (ascent, descent) as actual integers
|
||||
# This prevents "TypeError: '>' not supported between instances of 'Mock'
|
||||
# and 'Mock'"
|
||||
# (ascent, descent) as actual integers
|
||||
mock_font.getmetrics.return_value = (12, 4)
|
||||
mock_font.font = mock_font # For accessing .font property
|
||||
|
||||
|
||||
# Create mock font object that can be used by create_font
|
||||
mock_font_instance = Mock()
|
||||
mock_font_instance.font = mock_font
|
||||
@ -72,7 +72,7 @@ class TestDocumentLayouter:
|
||||
mock_font_instance.colour = (0, 0, 0)
|
||||
mock_font_instance.background = (255, 255, 255, 0)
|
||||
self.mock_concrete_style.create_font = Mock(return_value=mock_font_instance)
|
||||
|
||||
|
||||
# Update mock words to have proper style with font
|
||||
for word in self.mock_words:
|
||||
word.style = Mock()
|
||||
@ -84,34 +84,39 @@ class TestDocumentLayouter:
|
||||
@patch('pyWebLayout.layout.document_layouter.StyleResolver')
|
||||
@patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry')
|
||||
@patch('pyWebLayout.layout.document_layouter.Line')
|
||||
def test_paragraph_layouter_basic_flow(self, mock_line_class, mock_style_registry_class, mock_style_resolver_class):
|
||||
def test_paragraph_layouter_basic_flow(
|
||||
self,
|
||||
mock_line_class,
|
||||
mock_style_registry_class,
|
||||
mock_style_resolver_class):
|
||||
"""Test basic paragraph layouter functionality."""
|
||||
# Setup mocks for StyleResolver and ConcreteStyleRegistry
|
||||
mock_style_resolver = Mock()
|
||||
mock_style_resolver_class.return_value = mock_style_resolver
|
||||
|
||||
|
||||
mock_style_registry = Mock()
|
||||
mock_style_registry_class.return_value = mock_style_registry
|
||||
mock_style_registry.get_concrete_style.return_value = self.mock_concrete_style
|
||||
|
||||
|
||||
mock_line = Mock()
|
||||
mock_line_class.return_value = mock_line
|
||||
mock_line.add_word.return_value = (True, None) # All words fit successfully
|
||||
|
||||
|
||||
# Call function
|
||||
result = paragraph_layouter(self.mock_paragraph, self.mock_page)
|
||||
|
||||
|
||||
# Verify results
|
||||
success, failed_word_index, remaining_pretext = result
|
||||
assert success is True
|
||||
assert failed_word_index is None
|
||||
assert remaining_pretext is None
|
||||
|
||||
|
||||
# Verify StyleResolver and ConcreteStyleRegistry were created correctly
|
||||
mock_style_resolver_class.assert_called_once()
|
||||
mock_style_registry_class.assert_called_once_with(mock_style_resolver)
|
||||
mock_style_registry.get_concrete_style.assert_called_once_with(self.mock_paragraph.style)
|
||||
|
||||
mock_style_registry.get_concrete_style.assert_called_once_with(
|
||||
self.mock_paragraph.style)
|
||||
|
||||
# Verify Line was created with correct spacing constraints
|
||||
expected_spacing = (2, 8) # From mock_concrete_style
|
||||
mock_line_class.assert_called_once()
|
||||
@ -121,36 +126,37 @@ class TestDocumentLayouter:
|
||||
@patch('pyWebLayout.layout.document_layouter.StyleResolver')
|
||||
@patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry')
|
||||
@patch('pyWebLayout.layout.document_layouter.Line')
|
||||
def test_paragraph_layouter_word_spacing_constraints_extraction(self, mock_line_class, mock_style_registry_class, mock_style_resolver_class):
|
||||
def test_paragraph_layouter_word_spacing_constraints_extraction(
|
||||
self, mock_line_class, mock_style_registry_class, mock_style_resolver_class):
|
||||
"""Test that word spacing constraints are correctly extracted from style."""
|
||||
# Create concrete style with specific constraints
|
||||
concrete_style = Mock()
|
||||
concrete_style.word_spacing_min = 5.5
|
||||
concrete_style.word_spacing_max = 15.2
|
||||
concrete_style.text_align = "justify"
|
||||
|
||||
|
||||
# Create a mock font that concrete_style.create_font returns
|
||||
mock_font = Mock()
|
||||
mock_font.font = Mock()
|
||||
mock_font.font.getmetrics.return_value = (12, 4)
|
||||
mock_font.font_size = 16
|
||||
concrete_style.create_font = Mock(return_value=mock_font)
|
||||
|
||||
|
||||
# Setup StyleResolver and ConcreteStyleRegistry mocks
|
||||
mock_style_resolver = Mock()
|
||||
mock_style_resolver_class.return_value = mock_style_resolver
|
||||
|
||||
|
||||
mock_style_registry = Mock()
|
||||
mock_style_registry_class.return_value = mock_style_registry
|
||||
mock_style_registry.get_concrete_style.return_value = concrete_style
|
||||
|
||||
|
||||
mock_line = Mock()
|
||||
mock_line_class.return_value = mock_line
|
||||
mock_line.add_word.return_value = (True, None)
|
||||
|
||||
|
||||
# Call function
|
||||
paragraph_layouter(self.mock_paragraph, self.mock_page)
|
||||
|
||||
|
||||
# Verify spacing constraints were extracted correctly (converted to int)
|
||||
expected_spacing = (5, 15) # int() conversion of 5.5 and 15.2
|
||||
call_args = mock_line_class.call_args
|
||||
@ -159,30 +165,34 @@ class TestDocumentLayouter:
|
||||
@patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry')
|
||||
@patch('pyWebLayout.layout.document_layouter.Line')
|
||||
@patch('pyWebLayout.layout.document_layouter.Text')
|
||||
def test_paragraph_layouter_line_overflow(self, mock_text_class, mock_line_class, mock_style_registry_class):
|
||||
def test_paragraph_layouter_line_overflow(
|
||||
self,
|
||||
mock_text_class,
|
||||
mock_line_class,
|
||||
mock_style_registry_class):
|
||||
"""Test handling of line overflow when words don't fit."""
|
||||
# Setup mocks
|
||||
mock_style_registry = Mock()
|
||||
mock_style_registry_class.return_value = mock_style_registry
|
||||
mock_style_registry.get_concrete_style.return_value = self.mock_concrete_style
|
||||
|
||||
|
||||
# Create two mock lines with proper size attribute
|
||||
mock_line1 = Mock()
|
||||
mock_line1.size = (400, 20) # (width, height)
|
||||
mock_line2 = Mock()
|
||||
mock_line2.size = (400, 20) # (width, height)
|
||||
mock_line_class.side_effect = [mock_line1, mock_line2]
|
||||
|
||||
|
||||
# Mock Text.from_word to return mock text objects with numeric width
|
||||
mock_text = Mock()
|
||||
mock_text.width = 50 # Reasonable word width
|
||||
mock_text_class.from_word.return_value = mock_text
|
||||
|
||||
|
||||
# First line: first 2 words fit, third doesn't
|
||||
# Second line: remaining words fit
|
||||
mock_line1.add_word.side_effect = [
|
||||
(True, None), # word0 fits
|
||||
(True, None), # word1 fits
|
||||
(True, None), # word1 fits
|
||||
(False, None), # word2 doesn't fit
|
||||
]
|
||||
mock_line2.add_word.side_effect = [
|
||||
@ -190,42 +200,43 @@ class TestDocumentLayouter:
|
||||
(True, None), # word3 fits
|
||||
(True, None), # word4 fits
|
||||
]
|
||||
|
||||
|
||||
# Call function
|
||||
result = paragraph_layouter(self.mock_paragraph, self.mock_page)
|
||||
|
||||
|
||||
# Verify results
|
||||
success, failed_word_index, remaining_pretext = result
|
||||
assert success is True
|
||||
assert failed_word_index is None
|
||||
assert remaining_pretext is None
|
||||
|
||||
|
||||
# Verify two lines were created
|
||||
assert mock_line_class.call_count == 2
|
||||
assert self.mock_page.add_child.call_count == 2
|
||||
|
||||
@patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry')
|
||||
@patch('pyWebLayout.layout.document_layouter.Line')
|
||||
def test_paragraph_layouter_page_full(self, mock_line_class, mock_style_registry_class):
|
||||
def test_paragraph_layouter_page_full(
|
||||
self, mock_line_class, mock_style_registry_class):
|
||||
"""Test handling when page runs out of space."""
|
||||
# Setup mocks
|
||||
mock_style_registry = Mock()
|
||||
mock_style_registry_class.return_value = mock_style_registry
|
||||
mock_style_registry.get_concrete_style.return_value = self.mock_concrete_style
|
||||
|
||||
|
||||
# Page can fit first line but not second
|
||||
self.mock_page.can_fit_line.side_effect = [True, False]
|
||||
|
||||
|
||||
mock_line = Mock()
|
||||
mock_line_class.return_value = mock_line
|
||||
mock_line.add_word.side_effect = [
|
||||
(True, None), # word0 fits
|
||||
(False, None), # word1 doesn't fit, need new line
|
||||
]
|
||||
|
||||
|
||||
# Call function
|
||||
result = paragraph_layouter(self.mock_paragraph, self.mock_page)
|
||||
|
||||
|
||||
# Verify results indicate page is full
|
||||
success, failed_word_index, remaining_pretext = result
|
||||
assert success is False
|
||||
@ -236,9 +247,9 @@ class TestDocumentLayouter:
|
||||
"""Test handling of empty paragraph."""
|
||||
empty_paragraph = Mock()
|
||||
empty_paragraph.words = []
|
||||
|
||||
|
||||
result = paragraph_layouter(empty_paragraph, self.mock_page)
|
||||
|
||||
|
||||
success, failed_word_index, remaining_pretext = result
|
||||
assert success is True
|
||||
assert failed_word_index is None
|
||||
@ -247,7 +258,7 @@ class TestDocumentLayouter:
|
||||
def test_paragraph_layouter_invalid_start_word(self):
|
||||
"""Test handling of invalid start_word index."""
|
||||
result = paragraph_layouter(self.mock_paragraph, self.mock_page, start_word=10)
|
||||
|
||||
|
||||
success, failed_word_index, remaining_pretext = result
|
||||
assert success is True
|
||||
assert failed_word_index is None
|
||||
@ -259,10 +270,10 @@ class TestDocumentLayouter:
|
||||
# Setup mock
|
||||
mock_style_registry = Mock()
|
||||
mock_style_registry_class.return_value = mock_style_registry
|
||||
|
||||
|
||||
# Create layouter
|
||||
layouter = DocumentLayouter(self.mock_page)
|
||||
|
||||
|
||||
# Verify initialization
|
||||
assert layouter.page == self.mock_page
|
||||
mock_style_registry_class.assert_called_once_with(self.mock_page.style_resolver)
|
||||
@ -271,12 +282,13 @@ class TestDocumentLayouter:
|
||||
def test_document_layouter_layout_paragraph(self, mock_paragraph_layouter):
|
||||
"""Test DocumentLayouter.layout_paragraph method."""
|
||||
mock_paragraph_layouter.return_value = (True, None, None)
|
||||
|
||||
|
||||
with patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry'):
|
||||
layouter = DocumentLayouter(self.mock_page)
|
||||
|
||||
result = layouter.layout_paragraph(self.mock_paragraph, start_word=2, pretext="test")
|
||||
|
||||
|
||||
result = layouter.layout_paragraph(
|
||||
self.mock_paragraph, start_word=2, pretext="test")
|
||||
|
||||
# Verify the function was called correctly
|
||||
mock_paragraph_layouter.assert_called_once_with(
|
||||
self.mock_paragraph, self.mock_page, 2, "test"
|
||||
@ -286,46 +298,46 @@ class TestDocumentLayouter:
|
||||
def test_document_layouter_layout_document_success(self):
|
||||
"""Test DocumentLayouter.layout_document with successful layout."""
|
||||
from pyWebLayout.abstract import Paragraph
|
||||
|
||||
|
||||
# Create Mock paragraphs that pass isinstance checks
|
||||
paragraphs = [
|
||||
Mock(spec=Paragraph),
|
||||
Mock(spec=Paragraph),
|
||||
Mock(spec=Paragraph)
|
||||
]
|
||||
|
||||
|
||||
with patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry'):
|
||||
layouter = DocumentLayouter(self.mock_page)
|
||||
|
||||
|
||||
# Mock the layout_paragraph method to return success
|
||||
layouter.layout_paragraph = Mock(return_value=(True, None, None))
|
||||
|
||||
|
||||
result = layouter.layout_document(paragraphs)
|
||||
|
||||
|
||||
assert result is True
|
||||
assert layouter.layout_paragraph.call_count == 3
|
||||
|
||||
def test_document_layouter_layout_document_failure(self):
|
||||
"""Test DocumentLayouter.layout_document with layout failure."""
|
||||
from pyWebLayout.abstract import Paragraph
|
||||
|
||||
|
||||
# Create Mock paragraphs that pass isinstance checks
|
||||
paragraphs = [
|
||||
Mock(spec=Paragraph),
|
||||
Mock(spec=Paragraph)
|
||||
]
|
||||
|
||||
|
||||
with patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry'):
|
||||
layouter = DocumentLayouter(self.mock_page)
|
||||
|
||||
|
||||
# Mock the layout_paragraph method: first succeeds, second fails
|
||||
layouter.layout_paragraph = Mock(side_effect=[
|
||||
(True, None, None), # First paragraph succeeds
|
||||
(False, 3, None), # Second paragraph fails
|
||||
])
|
||||
|
||||
|
||||
result = layouter.layout_document(paragraphs)
|
||||
|
||||
|
||||
assert result is False
|
||||
assert layouter.layout_paragraph.call_count == 2
|
||||
|
||||
@ -334,39 +346,40 @@ class TestDocumentLayouter:
|
||||
# Create real style objects
|
||||
context = RenderingContext(base_font_size=16)
|
||||
resolver = StyleResolver(context)
|
||||
|
||||
|
||||
abstract_style = AbstractStyle(
|
||||
word_spacing=4.0,
|
||||
word_spacing_min=2.0,
|
||||
word_spacing_max=10.0
|
||||
)
|
||||
|
||||
|
||||
concrete_style = resolver.resolve_style(abstract_style)
|
||||
|
||||
|
||||
# Verify constraints are resolved correctly
|
||||
assert concrete_style.word_spacing_min == 2.0
|
||||
assert concrete_style.word_spacing_max == 10.0
|
||||
|
||||
|
||||
# This demonstrates the integration works end-to-end
|
||||
|
||||
|
||||
class TestWordSpacingConstraintsInLayout:
|
||||
"""Specific tests for word spacing constraints in layout context."""
|
||||
|
||||
|
||||
def test_different_spacing_scenarios(self):
|
||||
"""Test various word spacing constraint scenarios."""
|
||||
context = RenderingContext(base_font_size=16)
|
||||
resolver = StyleResolver(context)
|
||||
|
||||
|
||||
test_cases = [
|
||||
# (word_spacing, word_spacing_min, word_spacing_max, expected_min, expected_max)
|
||||
(None, None, None, 2.0, 8.0), # Default case
|
||||
(5.0, None, None, 5.0, 10.0), # Only base specified
|
||||
(4.0, 2.0, 8.0, 2.0, 8.0), # All specified
|
||||
(3.0, 1.0, None, 1.0, 3.0), # Min specified, max = max(word_spacing, min*2) = max(3.0, 2.0) = 3.0
|
||||
(6.0, None, 12.0, 6.0, 12.0), # Max specified, min from base
|
||||
# Min specified, max = max(word_spacing, min*2) = max(3.0, 2.0) = 3.0
|
||||
(3.0, 1.0, None, 1.0, 3.0),
|
||||
(6.0, None, 12.0, 6.0, 12.0), # Max specified, min from base
|
||||
]
|
||||
|
||||
|
||||
for word_spacing, min_spacing, max_spacing, expected_min, expected_max in test_cases:
|
||||
style_kwargs = {}
|
||||
if word_spacing is not None:
|
||||
@ -375,17 +388,17 @@ class TestWordSpacingConstraintsInLayout:
|
||||
style_kwargs['word_spacing_min'] = min_spacing
|
||||
if max_spacing is not None:
|
||||
style_kwargs['word_spacing_max'] = max_spacing
|
||||
|
||||
|
||||
abstract_style = AbstractStyle(**style_kwargs)
|
||||
concrete_style = resolver.resolve_style(abstract_style)
|
||||
|
||||
|
||||
assert concrete_style.word_spacing_min == expected_min, f"Failed for case: {style_kwargs}"
|
||||
assert concrete_style.word_spacing_max == expected_max, f"Failed for case: {style_kwargs}"
|
||||
|
||||
|
||||
class TestMultiPageLayout:
|
||||
"""Test cases for multi-page document layout scenarios."""
|
||||
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test fixtures for multi-page tests."""
|
||||
# Create multiple mock pages
|
||||
@ -401,19 +414,19 @@ class TestMultiPageLayout:
|
||||
page.add_child = Mock()
|
||||
page.style_resolver = Mock()
|
||||
self.mock_pages.append(page)
|
||||
|
||||
|
||||
# Create a long paragraph that will span multiple pages
|
||||
self.long_paragraph = Mock()
|
||||
self.long_paragraph.line_height = 25
|
||||
self.long_paragraph.style = AbstractStyle()
|
||||
|
||||
|
||||
# Create many words to ensure page overflow
|
||||
self.long_paragraph.words = []
|
||||
for i in range(50): # 50 words should definitely overflow a page
|
||||
word = Mock()
|
||||
word.text = f"word_{i:02d}"
|
||||
self.long_paragraph.words.append(word)
|
||||
|
||||
|
||||
# Create mock concrete style
|
||||
self.mock_concrete_style = Mock()
|
||||
self.mock_concrete_style.word_spacing_min = 3.0
|
||||
@ -421,7 +434,6 @@ class TestMultiPageLayout:
|
||||
self.mock_concrete_style.text_align = "justify"
|
||||
self.mock_concrete_style.create_font = Mock()
|
||||
|
||||
|
||||
@patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry')
|
||||
def test_document_layouter_multi_page_scenario(self, mock_style_registry_class):
|
||||
"""Test DocumentLayouter handling multiple pages with continuation."""
|
||||
@ -429,7 +441,7 @@ class TestMultiPageLayout:
|
||||
mock_style_registry = Mock()
|
||||
mock_style_registry_class.return_value = mock_style_registry
|
||||
mock_style_registry.get_concrete_style.return_value = self.mock_concrete_style
|
||||
|
||||
|
||||
# Create a multi-page document layouter
|
||||
class MultiPageDocumentLayouter(DocumentLayouter):
|
||||
def __init__(self, pages):
|
||||
@ -437,7 +449,7 @@ class TestMultiPageLayout:
|
||||
self.current_page_index = 0
|
||||
self.page = pages[0]
|
||||
self.style_registry = Mock()
|
||||
|
||||
|
||||
def get_next_page(self):
|
||||
"""Get the next available page."""
|
||||
if self.current_page_index + 1 < len(self.pages):
|
||||
@ -445,47 +457,47 @@ class TestMultiPageLayout:
|
||||
self.page = self.pages[self.current_page_index]
|
||||
return self.page
|
||||
return None
|
||||
|
||||
|
||||
def layout_document_with_pagination(self, paragraphs):
|
||||
"""Layout document with automatic pagination."""
|
||||
for paragraph in paragraphs:
|
||||
start_word = 0
|
||||
pretext = None
|
||||
|
||||
|
||||
while start_word < len(paragraph.words):
|
||||
complete, next_word, remaining_pretext = self.layout_paragraph(
|
||||
paragraph, start_word, pretext
|
||||
)
|
||||
|
||||
|
||||
if complete:
|
||||
# Paragraph finished
|
||||
break
|
||||
|
||||
|
||||
if next_word is None:
|
||||
# Error condition
|
||||
return False, f"Failed to layout paragraph at word {start_word}"
|
||||
|
||||
|
||||
# Try to get next page
|
||||
next_page = self.get_next_page()
|
||||
if not next_page:
|
||||
return False, f"Ran out of pages at word {next_word}"
|
||||
|
||||
|
||||
# Continue with remaining words on next page
|
||||
start_word = next_word
|
||||
pretext = remaining_pretext
|
||||
|
||||
|
||||
return True, "All paragraphs laid out successfully"
|
||||
|
||||
|
||||
# Create layouter with multiple pages
|
||||
layouter = MultiPageDocumentLayouter(self.mock_pages)
|
||||
|
||||
|
||||
# Mock the layout_paragraph method to simulate page filling
|
||||
original_layout_paragraph = layouter.layout_paragraph
|
||||
layouter.layout_paragraph
|
||||
call_count = [0]
|
||||
|
||||
|
||||
def mock_layout_paragraph(paragraph, start_word=0, pretext=None):
|
||||
call_count[0] += 1
|
||||
|
||||
|
||||
# Simulate different scenarios based on call count
|
||||
if call_count[0] == 1:
|
||||
# First page: can fit words 0-19, fails at word 20
|
||||
@ -498,19 +510,19 @@ class TestMultiPageLayout:
|
||||
return (True, None, None)
|
||||
else:
|
||||
return (False, start_word, None)
|
||||
|
||||
|
||||
layouter.layout_paragraph = mock_layout_paragraph
|
||||
|
||||
|
||||
# Test multi-page layout
|
||||
success, message = layouter.layout_document_with_pagination([self.long_paragraph])
|
||||
|
||||
success, message = layouter.layout_document_with_pagination(
|
||||
[self.long_paragraph])
|
||||
|
||||
# Verify results
|
||||
assert success is True
|
||||
assert "successfully" in message
|
||||
assert call_count[0] == 3 # Should have made 3 layout attempts
|
||||
assert layouter.current_page_index == 2 # Should end on page 3 (index 2)
|
||||
|
||||
|
||||
def test_realistic_multi_page_scenario(self):
|
||||
"""Test a realistic scenario with actual content and page constraints."""
|
||||
# Create realistic paragraph with varied content
|
||||
@ -522,7 +534,7 @@ class TestMultiPageLayout:
|
||||
word_spacing_max=8.0,
|
||||
text_align="justify"
|
||||
)
|
||||
|
||||
|
||||
# Create words of varying lengths (realistic text)
|
||||
words = [
|
||||
"The", "quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog.",
|
||||
@ -534,13 +546,13 @@ class TestMultiPageLayout:
|
||||
"system", "to", "handle", "appropriately", "with", "the", "given",
|
||||
"constraints", "and", "spacing", "requirements."
|
||||
]
|
||||
|
||||
|
||||
realistic_paragraph.words = []
|
||||
for word_text in words:
|
||||
word = Mock()
|
||||
word.text = word_text
|
||||
realistic_paragraph.words.append(word)
|
||||
|
||||
|
||||
# Create page with realistic constraints
|
||||
realistic_page = Mock()
|
||||
realistic_page.border_size = 30
|
||||
@ -550,33 +562,34 @@ class TestMultiPageLayout:
|
||||
realistic_page.draw = Mock()
|
||||
realistic_page.add_child = Mock()
|
||||
realistic_page.style_resolver = Mock()
|
||||
|
||||
|
||||
# Simulate page that can fit approximately 20 lines
|
||||
lines_fitted = [0]
|
||||
max_lines = 20
|
||||
|
||||
|
||||
def realistic_can_fit_line(line_height):
|
||||
lines_fitted[0] += 1
|
||||
return lines_fitted[0] <= max_lines
|
||||
|
||||
|
||||
realistic_page.can_fit_line = realistic_can_fit_line
|
||||
|
||||
|
||||
# Test with real style system
|
||||
context = RenderingContext(base_font_size=14)
|
||||
resolver = StyleResolver(context)
|
||||
concrete_style = resolver.resolve_style(realistic_paragraph.style)
|
||||
|
||||
|
||||
# Verify realistic constraints were calculated
|
||||
assert concrete_style.word_spacing == 4.0
|
||||
assert concrete_style.word_spacing_min == 2.0
|
||||
assert concrete_style.word_spacing_max == 8.0
|
||||
|
||||
|
||||
# This test demonstrates the integration without mocking everything
|
||||
# In a real scenario, this would interface with actual Line and Text objects
|
||||
print(f"✓ Realistic scenario test completed")
|
||||
print("✓ Realistic scenario test completed")
|
||||
print(f" - Words to layout: {len(realistic_paragraph.words)}")
|
||||
print(f" - Page width: {realistic_page.available_width}px")
|
||||
print(f" - Word spacing constraints: {concrete_style.word_spacing_min}-{concrete_style.word_spacing_max}px")
|
||||
print(
|
||||
f" - Word spacing constraints: {concrete_style.word_spacing_min}-{concrete_style.word_spacing_max}px")
|
||||
|
||||
|
||||
class TestTableLayouter:
|
||||
@ -756,24 +769,24 @@ if __name__ == "__main__":
|
||||
# Run specific tests for debugging
|
||||
test = TestDocumentLayouter()
|
||||
test.setup_method()
|
||||
|
||||
|
||||
# Run a simple test
|
||||
with patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry') as mock_registry:
|
||||
with patch('pyWebLayout.layout.document_layouter.Line') as mock_line:
|
||||
mock_style_registry = Mock()
|
||||
mock_registry.return_value = mock_style_registry
|
||||
mock_style_registry.get_concrete_style.return_value = test.mock_concrete_style
|
||||
|
||||
|
||||
mock_line_instance = Mock()
|
||||
mock_line.return_value = mock_line_instance
|
||||
mock_line_instance.add_word.return_value = (True, None)
|
||||
|
||||
|
||||
result = paragraph_layouter(test.mock_paragraph, test.mock_page)
|
||||
print(f"Test result: {result}")
|
||||
|
||||
|
||||
# Run multi-page tests
|
||||
multi_test = TestMultiPageLayout()
|
||||
multi_test.setup_method()
|
||||
multi_test.test_realistic_multi_page_scenario()
|
||||
|
||||
|
||||
print("Document layouter tests completed!")
|
||||
|
||||
@ -7,20 +7,15 @@ in multi-page layout scenarios.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch
|
||||
from PIL import Image, ImageDraw
|
||||
import numpy as np
|
||||
from typing import List, Optional
|
||||
import os
|
||||
import logging
|
||||
|
||||
from pyWebLayout.layout.document_layouter import paragraph_layouter, DocumentLayouter
|
||||
from pyWebLayout.layout.document_layouter import paragraph_layouter
|
||||
from pyWebLayout.style.abstract_style import AbstractStyle
|
||||
from pyWebLayout.style.concrete_style import ConcreteStyle, StyleResolver, RenderingContext
|
||||
from pyWebLayout.style.fonts import Font
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.concrete.text import Line, Text
|
||||
from pyWebLayout.concrete.text import Line
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
|
||||
# Enable logging to see font loading messages
|
||||
@ -34,47 +29,53 @@ def verify_bundled_font_available():
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
# Navigate up to pyWebLayout root, then to assets/fonts
|
||||
project_root = os.path.dirname(os.path.dirname(current_dir))
|
||||
bundled_font_path = os.path.join(project_root, 'pyWebLayout', 'assets', 'fonts', 'DejaVuSans.ttf')
|
||||
|
||||
bundled_font_path = os.path.join(
|
||||
project_root,
|
||||
'pyWebLayout',
|
||||
'assets',
|
||||
'fonts',
|
||||
'DejaVuSans.ttf')
|
||||
|
||||
logger.info(f"Integration tests checking for bundled font at: {bundled_font_path}")
|
||||
|
||||
|
||||
if not os.path.exists(bundled_font_path):
|
||||
pytest.fail(
|
||||
f"INTEGRATION TEST FAILURE: Bundled font not found at {bundled_font_path}\n"
|
||||
f"Integration tests require the bundled font to ensure consistent behavior.\n"
|
||||
f"This likely means the font was not included in the package build."
|
||||
)
|
||||
|
||||
|
||||
logger.info(f"Bundled font found at: {bundled_font_path}")
|
||||
return bundled_font_path
|
||||
|
||||
|
||||
class MockWord(Word):
|
||||
"""A simple mock word that extends the real Word class."""
|
||||
|
||||
|
||||
def __init__(self, text, style=None):
|
||||
if style is None:
|
||||
# Integration tests MUST use the bundled font for consistency
|
||||
style = Font(font_size=16)
|
||||
# Verify the font loaded properly
|
||||
if style.font.path is None:
|
||||
logger.warning("Font loaded without explicit path - may be using PIL default")
|
||||
logger.warning(
|
||||
"Font loaded without explicit path - may be using PIL default")
|
||||
# Initialize the base Word with required parameters
|
||||
super().__init__(text, style)
|
||||
self._concrete_texts = []
|
||||
|
||||
|
||||
def add_concete(self, texts):
|
||||
"""Add concrete text representations."""
|
||||
if isinstance(texts, list):
|
||||
self._concrete_texts.extend(texts)
|
||||
else:
|
||||
self._concrete_texts.append(texts)
|
||||
|
||||
|
||||
def possible_hyphenation(self):
|
||||
"""Return possible hyphenation points."""
|
||||
if len(self.text) <= 6:
|
||||
return []
|
||||
|
||||
|
||||
# Simple hyphenation: split roughly in the middle
|
||||
mid = len(self.text) // 2
|
||||
return [(self.text[:mid] + "-", self.text[mid:])]
|
||||
@ -82,7 +83,7 @@ class MockWord(Word):
|
||||
|
||||
class MockParagraph:
|
||||
"""A simple paragraph with words and styling."""
|
||||
|
||||
|
||||
def __init__(self, text_content, word_spacing_style=None):
|
||||
if word_spacing_style is None:
|
||||
word_spacing_style = AbstractStyle(
|
||||
@ -90,10 +91,10 @@ class MockParagraph:
|
||||
word_spacing_min=2.0,
|
||||
word_spacing_max=8.0
|
||||
)
|
||||
|
||||
|
||||
self.style = word_spacing_style
|
||||
self.line_height = 25
|
||||
|
||||
|
||||
# Create words from text content
|
||||
self.words = []
|
||||
for word_text in text_content.split():
|
||||
@ -103,39 +104,40 @@ class MockParagraph:
|
||||
|
||||
class TestDocumentLayouterIntegration:
|
||||
"""Integration tests using real components."""
|
||||
|
||||
|
||||
@classmethod
|
||||
def setup_class(cls):
|
||||
"""Verify bundled font is available before running any tests."""
|
||||
verify_bundled_font_available()
|
||||
|
||||
|
||||
def test_single_page_layout_with_real_components(self):
|
||||
"""Test layout on a single page using real Line and Text objects."""
|
||||
# Create a real page that can fit content
|
||||
page_style = PageStyle(border_width=20, padding=(10, 10, 10, 10))
|
||||
page = Page(size=(500, 400), style=page_style)
|
||||
|
||||
|
||||
# Create a paragraph with realistic content
|
||||
paragraph = MockParagraph(
|
||||
"The quick brown fox jumps over the lazy dog and runs through the forest.",
|
||||
AbstractStyle(word_spacing=3.0, word_spacing_min=2.0, word_spacing_max=6.0)
|
||||
)
|
||||
|
||||
|
||||
# Layout the paragraph
|
||||
success, failed_word_index, remaining_pretext = paragraph_layouter(paragraph, page)
|
||||
|
||||
success, failed_word_index, remaining_pretext = paragraph_layouter(
|
||||
paragraph, page)
|
||||
|
||||
# Verify successful layout
|
||||
assert success is True
|
||||
assert failed_word_index is None
|
||||
assert remaining_pretext is None
|
||||
|
||||
|
||||
# Verify lines were added to page
|
||||
assert len(page.children) > 0
|
||||
|
||||
|
||||
# Verify actual Line objects were created
|
||||
for child in page.children:
|
||||
assert isinstance(child, Line)
|
||||
|
||||
|
||||
print(f"✓ Single page test: {len(page.children)} lines created")
|
||||
|
||||
def test_multi_page_scenario_with_page_overflow(self):
|
||||
@ -143,47 +145,52 @@ class TestDocumentLayouterIntegration:
|
||||
# Create a very small real page that will definitely overflow
|
||||
small_page_style = PageStyle(border_width=5, padding=(5, 5, 5, 5))
|
||||
small_page = Page(size=(150, 80), style=small_page_style)
|
||||
|
||||
|
||||
# Create a long paragraph that will definitely overflow
|
||||
long_text = " ".join([f"verylongword{i:02d}" for i in range(20)]) # 20 long words
|
||||
long_text = " ".join(
|
||||
[f"verylongword{i:02d}" for i in range(20)]) # 20 long words
|
||||
paragraph = MockParagraph(
|
||||
long_text,
|
||||
AbstractStyle(word_spacing=4.0, word_spacing_min=2.0, word_spacing_max=8.0)
|
||||
)
|
||||
|
||||
|
||||
# Layout the paragraph - should fail due to page overflow
|
||||
success, failed_word_index, remaining_pretext = paragraph_layouter(paragraph, small_page)
|
||||
|
||||
success, failed_word_index, remaining_pretext = paragraph_layouter(
|
||||
paragraph, small_page)
|
||||
|
||||
# Either should fail due to overflow OR succeed with limited content
|
||||
if success:
|
||||
# If it succeeded, verify it fit some content
|
||||
assert len(small_page.children) > 0
|
||||
print(f"✓ Multi-page test: Content fit on small page, {len(small_page.children)} lines created")
|
||||
print(
|
||||
f"✓ Multi-page test: Content fit on small page, {len(small_page.children)} lines created")
|
||||
else:
|
||||
# If it failed, verify overflow handling
|
||||
assert failed_word_index is not None # Should indicate where it failed
|
||||
assert failed_word_index < len(paragraph.words) # Should be within word range
|
||||
print(f"✓ Multi-page test: Page overflow at word {failed_word_index}, {len(small_page.children)} lines fit")
|
||||
assert failed_word_index < len(
|
||||
paragraph.words) # Should be within word range
|
||||
print(
|
||||
f"✓ Multi-page test: Page overflow at word {failed_word_index}, {len(small_page.children)} lines fit")
|
||||
|
||||
def test_word_spacing_constraints_in_real_lines(self):
|
||||
"""Test that word spacing constraints are properly used in real Line objects."""
|
||||
# Create real page
|
||||
page_style = PageStyle(border_width=20, padding=(10, 10, 10, 10))
|
||||
page = Page(size=(400, 300), style=page_style)
|
||||
|
||||
|
||||
# Create paragraph with specific spacing constraints
|
||||
paragraph = MockParagraph(
|
||||
"Testing word spacing constraints with realistic content.",
|
||||
AbstractStyle(word_spacing=5.0, word_spacing_min=3.0, word_spacing_max=10.0)
|
||||
)
|
||||
|
||||
|
||||
# Layout paragraph
|
||||
success, _, _ = paragraph_layouter(paragraph, page)
|
||||
assert success is True
|
||||
|
||||
|
||||
# Verify that Line objects were created with correct spacing
|
||||
assert len(page.children) > 0
|
||||
|
||||
|
||||
for line in page.children:
|
||||
assert isinstance(line, Line)
|
||||
# Verify spacing constraints were applied
|
||||
@ -191,35 +198,34 @@ class TestDocumentLayouterIntegration:
|
||||
min_spacing, max_spacing = line._spacing
|
||||
assert min_spacing == 3 # From our constraint
|
||||
assert max_spacing == 10 # From our constraint
|
||||
|
||||
print(f"✓ Word spacing test: {len(page.children)} lines with constraints (3, 10)")
|
||||
|
||||
print(
|
||||
f"✓ Word spacing test: {len(page.children)} lines with constraints (3, 10)")
|
||||
|
||||
def test_different_alignment_strategies_with_constraints(self):
|
||||
"""Test different text alignment strategies with word spacing constraints."""
|
||||
alignments_to_test = [
|
||||
("left", AbstractStyle(text_align="left", word_spacing_min=2.0, word_spacing_max=6.0)),
|
||||
("justify", AbstractStyle(text_align="justify", word_spacing_min=3.0, word_spacing_max=12.0)),
|
||||
("center", AbstractStyle(text_align="center", word_spacing_min=1.0, word_spacing_max=5.0))
|
||||
]
|
||||
|
||||
("left", AbstractStyle(
|
||||
text_align="left", word_spacing_min=2.0, word_spacing_max=6.0)), ("justify", AbstractStyle(
|
||||
text_align="justify", word_spacing_min=3.0, word_spacing_max=12.0)), ("center", AbstractStyle(
|
||||
text_align="center", word_spacing_min=1.0, word_spacing_max=5.0))]
|
||||
|
||||
for alignment_name, style in alignments_to_test:
|
||||
page_style = PageStyle(border_width=20, padding=(10, 10, 10, 10))
|
||||
page = Page(size=(350, 200), style=page_style)
|
||||
paragraph = MockParagraph(
|
||||
"This sentence will test different alignment strategies with word spacing.",
|
||||
style
|
||||
)
|
||||
|
||||
"This sentence will test different alignment strategies with word spacing.", style)
|
||||
|
||||
success, _, _ = paragraph_layouter(paragraph, page)
|
||||
assert success is True
|
||||
assert len(page.children) > 0
|
||||
|
||||
|
||||
# Verify alignment was applied to lines
|
||||
for line in page.children:
|
||||
assert isinstance(line, Line)
|
||||
# Check that the alignment handler was set correctly
|
||||
assert line._alignment_handler is not None
|
||||
|
||||
|
||||
print(f"✓ {alignment_name} alignment: {len(page.children)} lines created")
|
||||
|
||||
def test_realistic_document_with_multiple_pages(self):
|
||||
@ -227,38 +233,42 @@ class TestDocumentLayouterIntegration:
|
||||
# Create multiple real pages
|
||||
page_style = PageStyle(border_width=20, padding=(10, 10, 10, 10))
|
||||
pages = [Page(size=(400, 300), style=page_style) for _ in range(3)]
|
||||
|
||||
|
||||
# Create a document with multiple paragraphs
|
||||
paragraphs = [
|
||||
MockParagraph(
|
||||
"This is the first paragraph of our document. It contains enough text to potentially span multiple lines and test the word spacing constraints properly.",
|
||||
"This is the first paragraph of our document. It contains enough text to "
|
||||
"potentially span multiple lines and test the word spacing constraints properly.",
|
||||
AbstractStyle(word_spacing=3.0, word_spacing_min=2.0, word_spacing_max=8.0)
|
||||
),
|
||||
MockParagraph(
|
||||
"Here is a second paragraph with different styling. This paragraph uses different word spacing constraints to test the flexibility of the system.",
|
||||
"Here is a second paragraph with different styling. This paragraph uses "
|
||||
"different word spacing constraints to test the flexibility of the system.",
|
||||
AbstractStyle(word_spacing=5.0, word_spacing_min=3.0, word_spacing_max=12.0)
|
||||
),
|
||||
MockParagraph(
|
||||
"The third and final paragraph completes our test document. It should demonstrate that the layouter can handle multiple paragraphs with varying content lengths and styling requirements.",
|
||||
"The third and final paragraph completes our test document. It should "
|
||||
"demonstrate that the layouter can handle multiple paragraphs with varying "
|
||||
"content lengths and styling requirements.",
|
||||
AbstractStyle(word_spacing=4.0, word_spacing_min=2.5, word_spacing_max=10.0)
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
# Layout paragraphs across pages
|
||||
current_page_index = 0
|
||||
|
||||
|
||||
for para_index, paragraph in enumerate(paragraphs):
|
||||
start_word = 0
|
||||
|
||||
|
||||
while start_word < len(paragraph.words):
|
||||
if current_page_index >= len(pages):
|
||||
break # Out of pages
|
||||
|
||||
|
||||
current_page = pages[current_page_index]
|
||||
success, failed_word_index, _ = paragraph_layouter(
|
||||
paragraph, current_page, start_word
|
||||
)
|
||||
|
||||
|
||||
if success:
|
||||
# Paragraph completed on this page
|
||||
break
|
||||
@ -267,25 +277,25 @@ class TestDocumentLayouterIntegration:
|
||||
if failed_word_index is not None:
|
||||
start_word = failed_word_index
|
||||
current_page_index += 1
|
||||
|
||||
|
||||
# If we're out of pages, stop
|
||||
if current_page_index >= len(pages):
|
||||
break
|
||||
|
||||
|
||||
# Verify pages have content
|
||||
total_lines = sum(len(page.children) for page in pages)
|
||||
pages_used = sum(1 for page in pages if len(page.children) > 0)
|
||||
|
||||
|
||||
assert total_lines > 0
|
||||
assert pages_used > 1 # Should use multiple pages
|
||||
|
||||
|
||||
print(f"✓ Multi-document test: {total_lines} lines across {pages_used} pages")
|
||||
|
||||
def test_word_spacing_constraint_resolution_integration(self):
|
||||
"""Test the complete integration from AbstractStyle to Line spacing."""
|
||||
page_style = PageStyle(border_width=20, padding=(10, 10, 10, 10))
|
||||
page = Page(size=(400, 600), style=page_style)
|
||||
|
||||
_page = Page(size=(400, 600), style=page_style)
|
||||
|
||||
# Test different constraint scenarios
|
||||
test_cases = [
|
||||
{
|
||||
@ -295,7 +305,7 @@ class TestDocumentLayouterIntegration:
|
||||
"expected_max": 12
|
||||
},
|
||||
{
|
||||
"name": "default_constraints",
|
||||
"name": "default_constraints",
|
||||
"style": AbstractStyle(word_spacing=6.0),
|
||||
"expected_min": 6, # Should use word_spacing as min
|
||||
"expected_max": 12 # Should use word_spacing * 2 as max
|
||||
@ -307,7 +317,7 @@ class TestDocumentLayouterIntegration:
|
||||
"expected_max": 8 # Default based on font size (16 * 0.5)
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
for case in test_cases:
|
||||
# Create fresh real page for each test
|
||||
test_page_style = PageStyle(border_width=20, padding=(10, 10, 10, 10))
|
||||
@ -316,18 +326,20 @@ class TestDocumentLayouterIntegration:
|
||||
"Testing constraint resolution with different scenarios.",
|
||||
case["style"]
|
||||
)
|
||||
|
||||
|
||||
success, _, _ = paragraph_layouter(paragraph, test_page)
|
||||
assert success is True
|
||||
assert len(test_page.children) > 0
|
||||
|
||||
|
||||
# Verify constraints were resolved correctly
|
||||
line = test_page.children[0]
|
||||
min_spacing, max_spacing = line._spacing
|
||||
|
||||
assert min_spacing == case["expected_min"], f"Min constraint failed for {case['name']}"
|
||||
assert max_spacing == case["expected_max"], f"Max constraint failed for {case['name']}"
|
||||
|
||||
|
||||
assert min_spacing == case["expected_min"], f"Min constraint failed for {
|
||||
case['name']}"
|
||||
assert max_spacing == case["expected_max"], f"Max constraint failed for {
|
||||
case['name']}"
|
||||
|
||||
print(f"✓ {case['name']}: constraints ({min_spacing}, {max_spacing})")
|
||||
|
||||
def test_hyphenation_with_word_spacing_constraints(self):
|
||||
@ -335,15 +347,16 @@ class TestDocumentLayouterIntegration:
|
||||
# Create a narrow real page to force hyphenation
|
||||
narrow_page_style = PageStyle(border_width=20, padding=(10, 10, 10, 10))
|
||||
narrow_page = Page(size=(200, 300), style=narrow_page_style)
|
||||
|
||||
|
||||
# Create paragraph with long words that will need hyphenation
|
||||
paragraph = MockParagraph(
|
||||
"supercalifragilisticexpialidocious antidisestablishmentarianism",
|
||||
AbstractStyle(word_spacing=3.0, word_spacing_min=2.0, word_spacing_max=8.0)
|
||||
)
|
||||
|
||||
success, failed_word_index, remaining_pretext = paragraph_layouter(paragraph, narrow_page)
|
||||
|
||||
|
||||
success, failed_word_index, remaining_pretext = paragraph_layouter(
|
||||
paragraph, narrow_page)
|
||||
|
||||
# Should succeed with hyphenation or handle overflow gracefully
|
||||
if success:
|
||||
assert len(narrow_page.children) > 0
|
||||
@ -357,10 +370,10 @@ class TestDocumentLayouterIntegration:
|
||||
if __name__ == "__main__":
|
||||
# Run integration tests
|
||||
test = TestDocumentLayouterIntegration()
|
||||
|
||||
|
||||
print("Running document layouter integration tests...")
|
||||
print("=" * 50)
|
||||
|
||||
|
||||
test.test_single_page_layout_with_real_components()
|
||||
test.test_multi_page_scenario_with_page_overflow()
|
||||
test.test_word_spacing_constraints_in_real_lines()
|
||||
@ -368,6 +381,6 @@ if __name__ == "__main__":
|
||||
test.test_realistic_document_with_multiple_pages()
|
||||
test.test_word_spacing_constraint_resolution_integration()
|
||||
test.test_hyphenation_with_word_spacing_constraints()
|
||||
|
||||
|
||||
print("=" * 50)
|
||||
print("✅ All integration tests completed successfully!")
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -11,35 +11,38 @@ from pyWebLayout.style import Alignment
|
||||
|
||||
class TestStyleObjects(unittest.TestCase):
|
||||
"""Test cases for pyWebLayout style objects."""
|
||||
|
||||
|
||||
def test_font_weight_enum(self):
|
||||
"""Test FontWeight enum values."""
|
||||
self.assertEqual(FontWeight.NORMAL.value, "normal")
|
||||
self.assertEqual(FontWeight.BOLD.value, "bold")
|
||||
|
||||
|
||||
# Test that all expected values exist
|
||||
weights = [FontWeight.NORMAL, FontWeight.BOLD]
|
||||
self.assertEqual(len(weights), 2)
|
||||
|
||||
|
||||
def test_font_style_enum(self):
|
||||
"""Test FontStyle enum values."""
|
||||
self.assertEqual(FontStyle.NORMAL.value, "normal")
|
||||
self.assertEqual(FontStyle.ITALIC.value, "italic")
|
||||
|
||||
|
||||
# Test that all expected values exist
|
||||
styles = [FontStyle.NORMAL, FontStyle.ITALIC]
|
||||
self.assertEqual(len(styles), 2)
|
||||
|
||||
|
||||
def test_text_decoration_enum(self):
|
||||
"""Test TextDecoration enum values."""
|
||||
self.assertEqual(TextDecoration.NONE.value, "none")
|
||||
self.assertEqual(TextDecoration.UNDERLINE.value, "underline")
|
||||
self.assertEqual(TextDecoration.STRIKETHROUGH.value, "strikethrough")
|
||||
|
||||
|
||||
# Test that all expected values exist
|
||||
decorations = [TextDecoration.NONE, TextDecoration.UNDERLINE, TextDecoration.STRIKETHROUGH]
|
||||
decorations = [
|
||||
TextDecoration.NONE,
|
||||
TextDecoration.UNDERLINE,
|
||||
TextDecoration.STRIKETHROUGH]
|
||||
self.assertEqual(len(decorations), 3)
|
||||
|
||||
|
||||
def test_alignment_enum(self):
|
||||
"""Test Alignment enum values."""
|
||||
self.assertEqual(Alignment.LEFT.value, "left")
|
||||
@ -49,11 +52,11 @@ class TestStyleObjects(unittest.TestCase):
|
||||
self.assertEqual(Alignment.BOTTOM.value, "bottom")
|
||||
self.assertEqual(Alignment.JUSTIFY.value, "justify")
|
||||
self.assertEqual(Alignment.MIDDLE.value, "middle")
|
||||
|
||||
|
||||
def test_font_initialization_defaults(self):
|
||||
"""Test Font initialization with default values."""
|
||||
font = Font()
|
||||
|
||||
|
||||
self.assertIsNone(font._font_path)
|
||||
self.assertEqual(font.font_size, 16)
|
||||
self.assertEqual(font.colour, (0, 0, 0))
|
||||
@ -63,7 +66,7 @@ class TestStyleObjects(unittest.TestCase):
|
||||
self.assertEqual(font.decoration, TextDecoration.NONE)
|
||||
self.assertEqual(font.background, (255, 255, 255, 0)) # Transparent
|
||||
self.assertEqual(font.language, "en_EN")
|
||||
|
||||
|
||||
def test_font_initialization_custom(self):
|
||||
"""Test Font initialization with custom values."""
|
||||
font = Font(
|
||||
@ -76,7 +79,7 @@ class TestStyleObjects(unittest.TestCase):
|
||||
background=(255, 255, 0, 255),
|
||||
language="fr_FR"
|
||||
)
|
||||
|
||||
|
||||
self.assertEqual(font._font_path, "/path/to/font.ttf")
|
||||
self.assertEqual(font.font_size, 16)
|
||||
self.assertEqual(font.colour, (255, 0, 0))
|
||||
@ -85,7 +88,7 @@ class TestStyleObjects(unittest.TestCase):
|
||||
self.assertEqual(font.decoration, TextDecoration.UNDERLINE)
|
||||
self.assertEqual(font.background, (255, 255, 0, 255))
|
||||
self.assertEqual(font.language, "fr_FR")
|
||||
|
||||
|
||||
def test_font_with_methods(self):
|
||||
"""Test Font immutable modification methods."""
|
||||
original_font = Font(
|
||||
@ -95,34 +98,36 @@ class TestStyleObjects(unittest.TestCase):
|
||||
style=FontStyle.NORMAL,
|
||||
decoration=TextDecoration.NONE
|
||||
)
|
||||
|
||||
|
||||
# Test with_size
|
||||
size_font = original_font.with_size(16)
|
||||
self.assertEqual(size_font.font_size, 16)
|
||||
self.assertEqual(original_font.font_size, 12) # Original unchanged
|
||||
self.assertEqual(size_font.colour, (0, 0, 0)) # Other properties preserved
|
||||
|
||||
|
||||
# Test with_colour
|
||||
color_font = original_font.with_colour((255, 0, 0))
|
||||
self.assertEqual(color_font.colour, (255, 0, 0))
|
||||
self.assertEqual(original_font.colour, (0, 0, 0)) # Original unchanged
|
||||
self.assertEqual(color_font.font_size, 12) # Other properties preserved
|
||||
|
||||
|
||||
# Test with_weight
|
||||
weight_font = original_font.with_weight(FontWeight.BOLD)
|
||||
self.assertEqual(weight_font.weight, FontWeight.BOLD)
|
||||
self.assertEqual(original_font.weight, FontWeight.NORMAL) # Original unchanged
|
||||
|
||||
|
||||
# Test with_style
|
||||
style_font = original_font.with_style(FontStyle.ITALIC)
|
||||
self.assertEqual(style_font.style, FontStyle.ITALIC)
|
||||
self.assertEqual(original_font.style, FontStyle.NORMAL) # Original unchanged
|
||||
|
||||
|
||||
# Test with_decoration
|
||||
decoration_font = original_font.with_decoration(TextDecoration.UNDERLINE)
|
||||
self.assertEqual(decoration_font.decoration, TextDecoration.UNDERLINE)
|
||||
self.assertEqual(original_font.decoration, TextDecoration.NONE) # Original unchanged
|
||||
|
||||
self.assertEqual(
|
||||
original_font.decoration,
|
||||
TextDecoration.NONE) # Original unchanged
|
||||
|
||||
def test_font_property_access(self):
|
||||
"""Test Font property access methods."""
|
||||
font = Font(
|
||||
@ -132,7 +137,7 @@ class TestStyleObjects(unittest.TestCase):
|
||||
style=FontStyle.ITALIC,
|
||||
decoration=TextDecoration.STRIKETHROUGH
|
||||
)
|
||||
|
||||
|
||||
# Test all property getters
|
||||
self.assertEqual(font.font_size, 20)
|
||||
self.assertEqual(font.colour, (128, 128, 128))
|
||||
@ -140,41 +145,41 @@ class TestStyleObjects(unittest.TestCase):
|
||||
self.assertEqual(font.weight, FontWeight.BOLD)
|
||||
self.assertEqual(font.style, FontStyle.ITALIC)
|
||||
self.assertEqual(font.decoration, TextDecoration.STRIKETHROUGH)
|
||||
|
||||
|
||||
# Test that font object is accessible
|
||||
self.assertIsNotNone(font.font)
|
||||
|
||||
|
||||
def test_font_immutability(self):
|
||||
"""Test that Font objects behave immutably."""
|
||||
font1 = Font(font_size=12, colour=(0, 0, 0))
|
||||
font2 = font1.with_size(16)
|
||||
font3 = font2.with_colour((255, 0, 0))
|
||||
|
||||
|
||||
# Each should be different objects
|
||||
self.assertIsNot(font1, font2)
|
||||
self.assertIsNot(font2, font3)
|
||||
self.assertIsNot(font1, font3)
|
||||
|
||||
|
||||
# Original properties should be unchanged
|
||||
self.assertEqual(font1.font_size, 12)
|
||||
self.assertEqual(font1.colour, (0, 0, 0))
|
||||
|
||||
|
||||
self.assertEqual(font2.font_size, 16)
|
||||
self.assertEqual(font2.colour, (0, 0, 0))
|
||||
|
||||
|
||||
self.assertEqual(font3.font_size, 16)
|
||||
self.assertEqual(font3.colour, (255, 0, 0))
|
||||
|
||||
|
||||
def test_background_handling(self):
|
||||
"""Test background color handling."""
|
||||
# Test default transparent background
|
||||
font1 = Font()
|
||||
self.assertEqual(font1.background, (255, 255, 255, 0))
|
||||
|
||||
|
||||
# Test explicit background
|
||||
font2 = Font(background=(255, 0, 0, 128))
|
||||
self.assertEqual(font2.background, (255, 0, 0, 128))
|
||||
|
||||
|
||||
# Test None background becomes transparent
|
||||
font3 = Font(background=None)
|
||||
self.assertEqual(font3.background, (255, 255, 255, 0))
|
||||
|
||||
@ -8,12 +8,12 @@ based on user preferences.
|
||||
|
||||
import pytest
|
||||
from pyWebLayout.style.abstract_style import (
|
||||
AbstractStyle, AbstractStyleRegistry, FontFamily, FontSize, TextAlign
|
||||
AbstractStyle, AbstractStyleRegistry, FontFamily, FontSize
|
||||
)
|
||||
from pyWebLayout.style.concrete_style import (
|
||||
ConcreteStyle, ConcreteStyleRegistry, RenderingContext, StyleResolver
|
||||
ConcreteStyleRegistry, RenderingContext, StyleResolver
|
||||
)
|
||||
from pyWebLayout.style.fonts import FontWeight, FontStyle, TextDecoration
|
||||
from pyWebLayout.style.fonts import FontWeight
|
||||
|
||||
|
||||
def test_abstract_style_is_hashable():
|
||||
@ -25,18 +25,18 @@ def test_abstract_style_is_hashable():
|
||||
font_weight=FontWeight.BOLD,
|
||||
color="red"
|
||||
)
|
||||
|
||||
|
||||
style2 = AbstractStyle(
|
||||
font_family=FontFamily.SERIF,
|
||||
font_size=16,
|
||||
font_weight=FontWeight.BOLD,
|
||||
color="red"
|
||||
)
|
||||
|
||||
|
||||
# They should be equal and have the same hash
|
||||
assert style1 == style2
|
||||
assert hash(style1) == hash(style2)
|
||||
|
||||
|
||||
# They should work as dictionary keys
|
||||
style_dict = {style1: "first", style2: "second"}
|
||||
assert len(style_dict) == 1 # Should be deduplicated
|
||||
@ -46,15 +46,15 @@ def test_abstract_style_is_hashable():
|
||||
def test_abstract_style_registry_deduplication():
|
||||
"""Test that the registry prevents duplicate styles."""
|
||||
registry = AbstractStyleRegistry()
|
||||
|
||||
|
||||
# Create the same style twice
|
||||
style1 = AbstractStyle(font_size=18, font_weight=FontWeight.BOLD)
|
||||
style2 = AbstractStyle(font_size=18, font_weight=FontWeight.BOLD)
|
||||
|
||||
|
||||
# Register both - should get same ID
|
||||
id1, _ = registry.get_or_create_style(style1)
|
||||
id2, _ = registry.get_or_create_style(style2)
|
||||
|
||||
|
||||
assert id1 == id2 # Same style should get same ID
|
||||
assert registry.get_style_count() == 2 # Only default + our style
|
||||
|
||||
@ -62,21 +62,21 @@ def test_abstract_style_registry_deduplication():
|
||||
def test_style_inheritance():
|
||||
"""Test that style inheritance works properly."""
|
||||
registry = AbstractStyleRegistry()
|
||||
|
||||
|
||||
# Create base style
|
||||
base_style = AbstractStyle(font_size=16, color="black")
|
||||
base_id, _ = registry.get_or_create_style(base_style)
|
||||
|
||||
|
||||
# Create derived style
|
||||
derived_id, derived_style = registry.create_derived_style(
|
||||
base_id,
|
||||
base_id,
|
||||
font_weight=FontWeight.BOLD,
|
||||
color="red"
|
||||
)
|
||||
|
||||
|
||||
# Resolve effective style
|
||||
effective = registry.resolve_effective_style(derived_id)
|
||||
|
||||
|
||||
assert effective.font_size == 16 # Inherited from base
|
||||
assert effective.font_weight == FontWeight.BOLD # Overridden
|
||||
assert effective.color == "red" # Overridden
|
||||
@ -90,16 +90,17 @@ def test_style_resolver_user_preferences():
|
||||
font_scale_factor=1.5, # Additional scaling
|
||||
large_text=True # Accessibility preference
|
||||
)
|
||||
|
||||
|
||||
resolver = StyleResolver(context)
|
||||
|
||||
|
||||
# Create abstract style with medium size
|
||||
abstract_style = AbstractStyle(font_size=FontSize.MEDIUM)
|
||||
|
||||
|
||||
# Resolve to concrete style
|
||||
concrete_style = resolver.resolve_style(abstract_style)
|
||||
|
||||
# Font size should be: 20 (base) * 1.0 (medium) * 1.5 (scale) * 1.2 (large_text) = 36
|
||||
|
||||
# Font size should be: 20 (base) * 1.0 (medium) * 1.5 (scale) * 1.2
|
||||
# (large_text) = 36
|
||||
expected_size = int(20 * 1.0 * 1.5 * 1.2)
|
||||
assert concrete_style.font_size == expected_size
|
||||
|
||||
@ -108,17 +109,17 @@ def test_style_resolver_color_resolution():
|
||||
"""Test color name resolution."""
|
||||
context = RenderingContext()
|
||||
resolver = StyleResolver(context)
|
||||
|
||||
|
||||
# Test named colors
|
||||
red_style = AbstractStyle(color="red")
|
||||
concrete_red = resolver.resolve_style(red_style)
|
||||
assert concrete_red.color == (255, 0, 0)
|
||||
|
||||
|
||||
# Test hex colors
|
||||
hex_style = AbstractStyle(color="#ff0000")
|
||||
concrete_hex = resolver.resolve_style(hex_style)
|
||||
assert concrete_hex.color == (255, 0, 0)
|
||||
|
||||
|
||||
# Test RGB tuple (should pass through)
|
||||
rgb_style = AbstractStyle(color=(128, 64, 192))
|
||||
concrete_rgb = resolver.resolve_style(rgb_style)
|
||||
@ -129,17 +130,17 @@ def test_concrete_style_caching():
|
||||
"""Test that concrete styles are cached efficiently."""
|
||||
context = RenderingContext()
|
||||
registry = ConcreteStyleRegistry(StyleResolver(context))
|
||||
|
||||
|
||||
# Create abstract style
|
||||
abstract_style = AbstractStyle(font_size=16, color="blue")
|
||||
|
||||
|
||||
# Get font twice - should be cached
|
||||
font1 = registry.get_font(abstract_style)
|
||||
font2 = registry.get_font(abstract_style)
|
||||
|
||||
|
||||
# Should be the same object (cached)
|
||||
assert font1 is font2
|
||||
|
||||
|
||||
# Check cache stats
|
||||
stats = registry.get_cache_stats()
|
||||
assert stats["concrete_styles"] == 1
|
||||
@ -151,17 +152,17 @@ def test_global_font_scaling():
|
||||
# Create two contexts with different scaling
|
||||
context_normal = RenderingContext(font_scale_factor=1.0)
|
||||
context_large = RenderingContext(font_scale_factor=2.0)
|
||||
|
||||
|
||||
resolver_normal = StyleResolver(context_normal)
|
||||
resolver_large = StyleResolver(context_large)
|
||||
|
||||
|
||||
# Same abstract style
|
||||
abstract_style = AbstractStyle(font_size=16)
|
||||
|
||||
|
||||
# Resolve with different contexts
|
||||
concrete_normal = resolver_normal.resolve_style(abstract_style)
|
||||
concrete_large = resolver_large.resolve_style(abstract_style)
|
||||
|
||||
|
||||
# Large should be 2x the size
|
||||
assert concrete_large.font_size == concrete_normal.font_size * 2
|
||||
|
||||
@ -169,7 +170,7 @@ def test_global_font_scaling():
|
||||
def test_memory_efficiency():
|
||||
"""Test that the new system is more memory efficient."""
|
||||
registry = AbstractStyleRegistry()
|
||||
|
||||
|
||||
# Create many "different" styles that are actually the same
|
||||
styles = []
|
||||
for i in range(100):
|
||||
@ -181,26 +182,26 @@ def test_memory_efficiency():
|
||||
)
|
||||
style_id, _ = registry.get_or_create_style(style)
|
||||
styles.append(style_id)
|
||||
|
||||
|
||||
# All should reference the same style
|
||||
assert len(set(styles)) == 1 # All IDs are the same
|
||||
assert registry.get_style_count() == 2 # Only default + our style
|
||||
|
||||
|
||||
# This demonstrates that we don't create duplicate styles
|
||||
|
||||
|
||||
def test_word_style_reference_concept():
|
||||
"""Demonstrate how words would reference styles instead of storing fonts."""
|
||||
registry = AbstractStyleRegistry()
|
||||
|
||||
|
||||
# Create paragraph style
|
||||
para_style = AbstractStyle(font_size=16, color="black")
|
||||
para_id, _ = registry.get_or_create_style(para_style)
|
||||
|
||||
|
||||
# Create bold word style
|
||||
bold_style = AbstractStyle(font_size=16, color="black", font_weight=FontWeight.BOLD)
|
||||
bold_id, _ = registry.get_or_create_style(bold_style)
|
||||
|
||||
|
||||
# Simulate words storing style IDs instead of full Font objects
|
||||
words_data = [
|
||||
{"text": "This", "style_id": para_id},
|
||||
@ -208,19 +209,19 @@ def test_word_style_reference_concept():
|
||||
{"text": "bold", "style_id": bold_id},
|
||||
{"text": "text", "style_id": para_id},
|
||||
]
|
||||
|
||||
|
||||
# To get the actual font for rendering, we resolve through registry
|
||||
context = RenderingContext()
|
||||
concrete_registry = ConcreteStyleRegistry(StyleResolver(context))
|
||||
|
||||
|
||||
for word_data in words_data:
|
||||
abstract_style = registry.get_style_by_id(word_data["style_id"])
|
||||
font = concrete_registry.get_font(abstract_style)
|
||||
|
||||
|
||||
# Now we have the actual Font object for rendering
|
||||
assert font is not None
|
||||
assert hasattr(font, 'font_size')
|
||||
|
||||
|
||||
# Bold word should have bold weight
|
||||
if word_data["text"] == "bold":
|
||||
assert font.weight == FontWeight.BOLD
|
||||
|
||||
@ -5,14 +5,13 @@ This test shows how to use the new min/max word spacing constraints
|
||||
in the style system.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pyWebLayout.style.abstract_style import AbstractStyle, AbstractStyleRegistry
|
||||
from pyWebLayout.style.concrete_style import ConcreteStyle, StyleResolver, RenderingContext
|
||||
from pyWebLayout.style.concrete_style import StyleResolver, RenderingContext
|
||||
|
||||
|
||||
class TestWordSpacingConstraints:
|
||||
"""Test cases for word spacing constraints feature."""
|
||||
|
||||
|
||||
def test_abstract_style_with_word_spacing_constraints(self):
|
||||
"""Test that AbstractStyle accepts word spacing constraint fields."""
|
||||
style = AbstractStyle(
|
||||
@ -20,116 +19,116 @@ class TestWordSpacingConstraints:
|
||||
word_spacing_min=2.0,
|
||||
word_spacing_max=10.0
|
||||
)
|
||||
|
||||
|
||||
assert style.word_spacing == 5.0
|
||||
assert style.word_spacing_min == 2.0
|
||||
assert style.word_spacing_max == 10.0
|
||||
|
||||
|
||||
def test_concrete_style_resolution_with_constraints(self):
|
||||
"""Test that word spacing constraints are resolved correctly."""
|
||||
# Create rendering context
|
||||
context = RenderingContext(base_font_size=16)
|
||||
resolver = StyleResolver(context)
|
||||
|
||||
|
||||
# Create abstract style with constraints
|
||||
abstract_style = AbstractStyle(
|
||||
word_spacing=5.0,
|
||||
word_spacing_min=2.0,
|
||||
word_spacing_max=12.0
|
||||
)
|
||||
|
||||
|
||||
# Resolve to concrete style
|
||||
concrete_style = resolver.resolve_style(abstract_style)
|
||||
|
||||
|
||||
# Check that constraints are preserved
|
||||
assert concrete_style.word_spacing == 5.0
|
||||
assert concrete_style.word_spacing_min == 2.0
|
||||
assert concrete_style.word_spacing_max == 12.0
|
||||
|
||||
|
||||
def test_default_constraint_logic(self):
|
||||
"""Test default constraint logic when not specified."""
|
||||
context = RenderingContext(base_font_size=16)
|
||||
resolver = StyleResolver(context)
|
||||
|
||||
|
||||
# Style with only base word spacing
|
||||
abstract_style = AbstractStyle(word_spacing=6.0)
|
||||
concrete_style = resolver.resolve_style(abstract_style)
|
||||
|
||||
|
||||
# Should apply default logic: min = base, max = base * 2
|
||||
assert concrete_style.word_spacing == 6.0
|
||||
assert concrete_style.word_spacing_min == 6.0
|
||||
assert concrete_style.word_spacing_max == 12.0
|
||||
|
||||
|
||||
def test_no_word_spacing_defaults(self):
|
||||
"""Test defaults when no word spacing is specified."""
|
||||
context = RenderingContext(base_font_size=16)
|
||||
resolver = StyleResolver(context)
|
||||
|
||||
|
||||
# Style with no word spacing specified
|
||||
abstract_style = AbstractStyle()
|
||||
concrete_style = resolver.resolve_style(abstract_style)
|
||||
|
||||
|
||||
# Should apply font-based defaults
|
||||
assert concrete_style.word_spacing == 0.0
|
||||
assert concrete_style.word_spacing_min == 2.0 # Minimum default
|
||||
assert concrete_style.word_spacing_max == 8.0 # 50% of font size (16 * 0.5)
|
||||
|
||||
|
||||
def test_partial_constraints(self):
|
||||
"""Test behavior when only min or max is specified."""
|
||||
context = RenderingContext(base_font_size=16)
|
||||
resolver = StyleResolver(context)
|
||||
|
||||
|
||||
# Only min specified
|
||||
abstract_style_min = AbstractStyle(
|
||||
word_spacing=4.0,
|
||||
word_spacing_min=3.0
|
||||
)
|
||||
concrete_style_min = resolver.resolve_style(abstract_style_min)
|
||||
|
||||
|
||||
assert concrete_style_min.word_spacing_min == 3.0
|
||||
assert concrete_style_min.word_spacing_max == 6.0 # 3.0 * 2
|
||||
|
||||
|
||||
# Only max specified
|
||||
abstract_style_max = AbstractStyle(
|
||||
word_spacing=4.0,
|
||||
word_spacing_max=8.0
|
||||
)
|
||||
concrete_style_max = resolver.resolve_style(abstract_style_max)
|
||||
|
||||
|
||||
assert concrete_style_max.word_spacing_min == 4.0 # max(word_spacing, 2.0)
|
||||
assert concrete_style_max.word_spacing_max == 8.0
|
||||
|
||||
|
||||
def test_style_registry_with_constraints(self):
|
||||
"""Test that style registry handles word spacing constraints."""
|
||||
registry = AbstractStyleRegistry()
|
||||
|
||||
|
||||
# Create style with constraints
|
||||
style_id, style = registry.get_or_create_style(
|
||||
word_spacing=5.0,
|
||||
word_spacing_min=3.0,
|
||||
word_spacing_max=10.0
|
||||
)
|
||||
|
||||
|
||||
# Verify the style was created correctly
|
||||
retrieved_style = registry.get_style_by_id(style_id)
|
||||
assert retrieved_style.word_spacing == 5.0
|
||||
assert retrieved_style.word_spacing_min == 3.0
|
||||
assert retrieved_style.word_spacing_max == 10.0
|
||||
|
||||
|
||||
def test_em_units_in_constraints(self):
|
||||
"""Test that em units work in word spacing constraints."""
|
||||
context = RenderingContext(base_font_size=16)
|
||||
resolver = StyleResolver(context)
|
||||
|
||||
|
||||
# Use em units
|
||||
abstract_style = AbstractStyle(
|
||||
word_spacing="0.25em",
|
||||
word_spacing_min="0.1em",
|
||||
word_spacing_max="0.5em"
|
||||
)
|
||||
|
||||
|
||||
concrete_style = resolver.resolve_style(abstract_style)
|
||||
|
||||
|
||||
# Should convert em to pixels based on font size (16px)
|
||||
assert concrete_style.word_spacing == 4.0 # 0.25 * 16
|
||||
assert concrete_style.word_spacing_min == 1.6 # 0.1 * 16
|
||||
@ -146,5 +145,5 @@ if __name__ == "__main__":
|
||||
test.test_partial_constraints()
|
||||
test.test_style_registry_with_constraints()
|
||||
test.test_em_units_in_constraints()
|
||||
|
||||
|
||||
print("All word spacing constraint tests passed!")
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -20,18 +20,23 @@ from pyWebLayout.style.fonts import Font, FontWeight, FontStyle, TextDecoration
|
||||
|
||||
class TestFontUtilities(unittest.TestCase):
|
||||
"""Test cases for font utility functions."""
|
||||
|
||||
|
||||
def test_get_bundled_font_path_finds_font(self):
|
||||
"""Test that get_bundled_font_path finds the bundled font."""
|
||||
font_path = get_bundled_font_path()
|
||||
self.assertIsNotNone(font_path, "Bundled font path should not be None")
|
||||
self.assertTrue(os.path.exists(font_path), f"Font file should exist at {font_path}")
|
||||
self.assertTrue(font_path.endswith("DejaVuSans.ttf"), "Font path should end with DejaVuSans.ttf")
|
||||
|
||||
self.assertTrue(
|
||||
os.path.exists(font_path),
|
||||
f"Font file should exist at {font_path}")
|
||||
self.assertTrue(
|
||||
font_path.endswith("DejaVuSans.ttf"),
|
||||
"Font path should end with DejaVuSans.ttf")
|
||||
|
||||
def test_verify_bundled_font_available(self):
|
||||
"""Test that the bundled font can be verified and loaded."""
|
||||
self.assertTrue(verify_bundled_font_available(), "Bundled font should be available and loadable")
|
||||
|
||||
self.assertTrue(verify_bundled_font_available(),
|
||||
"Bundled font should be available and loadable")
|
||||
|
||||
def test_create_test_font_with_defaults(self):
|
||||
"""Test creating a test font with default parameters."""
|
||||
font = create_test_font()
|
||||
@ -41,7 +46,7 @@ class TestFontUtilities(unittest.TestCase):
|
||||
self.assertEqual(font.weight, FontWeight.NORMAL)
|
||||
self.assertEqual(font.style, FontStyle.NORMAL)
|
||||
self.assertEqual(font.decoration, TextDecoration.NONE)
|
||||
|
||||
|
||||
def test_create_test_font_with_custom_parameters(self):
|
||||
"""Test creating a test font with custom parameters."""
|
||||
font = create_test_font(
|
||||
@ -57,50 +62,52 @@ class TestFontUtilities(unittest.TestCase):
|
||||
self.assertEqual(font.weight, FontWeight.BOLD)
|
||||
self.assertEqual(font.style, FontStyle.ITALIC)
|
||||
self.assertEqual(font.decoration, TextDecoration.UNDERLINE)
|
||||
|
||||
|
||||
def test_create_default_test_font(self):
|
||||
"""Test creating a default test font."""
|
||||
font = create_default_test_font()
|
||||
self.assertIsInstance(font, Font)
|
||||
self.assertEqual(font.font_size, 16)
|
||||
self.assertEqual(font.colour, (0, 0, 0))
|
||||
|
||||
|
||||
def test_ensure_consistent_font_in_tests_succeeds(self):
|
||||
"""Test that ensure_consistent_font_in_tests runs without error when font is available."""
|
||||
# This should not raise any exceptions if the font is properly available
|
||||
try:
|
||||
ensure_consistent_font_in_tests()
|
||||
except RuntimeError:
|
||||
self.fail("ensure_consistent_font_in_tests() raised RuntimeError when font should be available")
|
||||
|
||||
self.fail(
|
||||
"ensure_consistent_font_in_tests() raised RuntimeError when font should be available")
|
||||
|
||||
def test_bundled_font_loads_with_pil(self):
|
||||
"""Test that the bundled font can be loaded directly with PIL."""
|
||||
font_path = get_bundled_font_path()
|
||||
self.assertIsNotNone(font_path)
|
||||
|
||||
|
||||
# Test loading with different sizes
|
||||
for size in [12, 16, 24, 48]:
|
||||
with self.subTest(size=size):
|
||||
pil_font = ImageFont.truetype(font_path, size)
|
||||
self.assertIsNotNone(pil_font)
|
||||
|
||||
|
||||
def test_font_metrics_consistency(self):
|
||||
"""Test that font metrics are consistent between different Font objects using the same parameters."""
|
||||
font1 = create_test_font(font_size=16)
|
||||
font2 = create_test_font(font_size=16)
|
||||
|
||||
|
||||
# Both fonts should have the same size
|
||||
self.assertEqual(font1.font_size, font2.font_size)
|
||||
|
||||
|
||||
# Test that text measurements are consistent
|
||||
# This is a basic check - in real usage, text measurement consistency is what matters most
|
||||
# This is a basic check - in real usage, text measurement consistency is
|
||||
# what matters most
|
||||
self.assertEqual(font1.font_size, font2.font_size)
|
||||
|
||||
|
||||
def test_different_sizes_create_different_fonts(self):
|
||||
"""Test that different font sizes create fonts with different metrics."""
|
||||
small_font = create_test_font(font_size=12)
|
||||
large_font = create_test_font(font_size=24)
|
||||
|
||||
|
||||
self.assertNotEqual(small_font.font_size, large_font.font_size)
|
||||
self.assertEqual(small_font.font_size, 12)
|
||||
self.assertEqual(large_font.font_size, 24)
|
||||
@ -108,24 +115,28 @@ class TestFontUtilities(unittest.TestCase):
|
||||
|
||||
class TestFontPathResolution(unittest.TestCase):
|
||||
"""Test cases for font path resolution from different locations."""
|
||||
|
||||
|
||||
def test_font_path_is_absolute(self):
|
||||
"""Test that the returned font path is absolute."""
|
||||
font_path = get_bundled_font_path()
|
||||
if font_path:
|
||||
self.assertTrue(os.path.isabs(font_path), "Font path should be absolute")
|
||||
|
||||
|
||||
def test_font_path_points_to_file(self):
|
||||
"""Test that the font path points to a file, not a directory."""
|
||||
font_path = get_bundled_font_path()
|
||||
if font_path:
|
||||
self.assertTrue(os.path.isfile(font_path), "Font path should point to a file")
|
||||
|
||||
self.assertTrue(
|
||||
os.path.isfile(font_path),
|
||||
"Font path should point to a file")
|
||||
|
||||
def test_font_file_has_correct_extension(self):
|
||||
"""Test that the font file has the expected .ttf extension."""
|
||||
font_path = get_bundled_font_path()
|
||||
if font_path:
|
||||
self.assertTrue(font_path.lower().endswith('.ttf'), "Font file should have .ttf extension")
|
||||
self.assertTrue(
|
||||
font_path.lower().endswith('.ttf'),
|
||||
"Font file should have .ttf extension")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@ -6,7 +6,6 @@ preventing inconsistencies that can arise from different system fonts.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import Optional
|
||||
from PIL import ImageFont
|
||||
|
||||
@ -16,25 +15,30 @@ from pyWebLayout.style.fonts import Font, FontWeight, FontStyle, TextDecoration
|
||||
def get_bundled_font_path() -> Optional[str]:
|
||||
"""
|
||||
Get the path to the bundled DejaVuSans.ttf font.
|
||||
|
||||
|
||||
This function works from test directories by finding the font relative to the
|
||||
test file locations.
|
||||
|
||||
|
||||
Returns:
|
||||
str: Path to the bundled font file, or None if not found
|
||||
"""
|
||||
# Get the directory containing this test utility file
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
# Navigate up to the project root (tests/utils -> tests -> root)
|
||||
project_root = os.path.dirname(os.path.dirname(current_dir))
|
||||
|
||||
|
||||
# Path to the bundled font
|
||||
bundled_font_path = os.path.join(project_root, 'pyWebLayout', 'assets', 'fonts', 'DejaVuSans.ttf')
|
||||
|
||||
bundled_font_path = os.path.join(
|
||||
project_root,
|
||||
'pyWebLayout',
|
||||
'assets',
|
||||
'fonts',
|
||||
'DejaVuSans.ttf')
|
||||
|
||||
if os.path.exists(bundled_font_path):
|
||||
return bundled_font_path
|
||||
|
||||
|
||||
# Alternative: try to find it relative to the pyWebLayout module
|
||||
try:
|
||||
import pyWebLayout
|
||||
@ -44,24 +48,24 @@ def get_bundled_font_path() -> Optional[str]:
|
||||
return alt_font_path
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def verify_bundled_font_available() -> bool:
|
||||
"""
|
||||
Verify that the bundled font is available and can be loaded.
|
||||
|
||||
|
||||
Returns:
|
||||
bool: True if the bundled font is available and loadable
|
||||
"""
|
||||
font_path = get_bundled_font_path()
|
||||
if not font_path:
|
||||
return False
|
||||
|
||||
|
||||
try:
|
||||
# Try to load the font with PIL to verify it's valid
|
||||
test_font = ImageFont.truetype(font_path, 16)
|
||||
ImageFont.truetype(font_path, 16)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
@ -77,10 +81,10 @@ def create_test_font(font_size: int = 16,
|
||||
min_hyphenation_width: Optional[int] = None) -> Font:
|
||||
"""
|
||||
Create a Font object that uses the bundled font for consistent testing.
|
||||
|
||||
|
||||
This function ensures all tests use the same font file, preventing
|
||||
cross-system inconsistencies in text measurements and layout.
|
||||
|
||||
|
||||
Args:
|
||||
font_size: Size of the font in points
|
||||
colour: RGB color tuple for the text
|
||||
@ -90,10 +94,10 @@ def create_test_font(font_size: int = 16,
|
||||
background: RGBA background color for the text
|
||||
language: Language code for hyphenation and text processing
|
||||
min_hyphenation_width: Minimum width in pixels for hyphenation
|
||||
|
||||
|
||||
Returns:
|
||||
Font: A Font object guaranteed to use the bundled font
|
||||
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the bundled font cannot be found or loaded
|
||||
"""
|
||||
@ -103,13 +107,13 @@ def create_test_font(font_size: int = 16,
|
||||
"Bundled font (DejaVuSans.ttf) not found. "
|
||||
"Ensure the font exists in pyWebLayout/assets/fonts/"
|
||||
)
|
||||
|
||||
|
||||
if not verify_bundled_font_available():
|
||||
raise RuntimeError(
|
||||
f"Bundled font at {font_path} cannot be loaded. "
|
||||
"Font file may be corrupted or invalid."
|
||||
)
|
||||
|
||||
|
||||
return Font(
|
||||
font_path=font_path,
|
||||
font_size=font_size,
|
||||
@ -126,9 +130,9 @@ def create_test_font(font_size: int = 16,
|
||||
def create_default_test_font() -> Font:
|
||||
"""
|
||||
Create a default Font object for testing with the bundled font.
|
||||
|
||||
|
||||
This is equivalent to Font() but guarantees the bundled font is used.
|
||||
|
||||
|
||||
Returns:
|
||||
Font: A default Font object using the bundled font
|
||||
"""
|
||||
@ -138,10 +142,10 @@ def create_default_test_font() -> Font:
|
||||
def ensure_consistent_font_in_tests():
|
||||
"""
|
||||
Ensure that tests are using consistent fonts by checking availability.
|
||||
|
||||
|
||||
This function can be called in test setup to verify the font environment
|
||||
is properly configured.
|
||||
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the bundled font is not available
|
||||
"""
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user