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',
|
||||
]
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
from typing import List, Iterator, Tuple, Dict, Optional, Union, Any
|
||||
from typing import List, Iterator, Tuple, Dict, Optional, Any
|
||||
from enum import Enum
|
||||
import os
|
||||
import tempfile
|
||||
@ -6,7 +6,6 @@ import urllib.request
|
||||
import urllib.parse
|
||||
from PIL import Image as PILImage
|
||||
from .inline import Word, FormattedSpan
|
||||
from ..style import Font, FontWeight, FontStyle, TextDecoration
|
||||
from ..core import Hierarchical, Styleable, FontRegistry
|
||||
|
||||
|
||||
@ -99,7 +98,9 @@ class Paragraph(Styleable, FontRegistry, Block):
|
||||
if hasattr(container, 'add_block'):
|
||||
container.add_block(paragraph)
|
||||
else:
|
||||
raise AttributeError(f"Container {type(container).__name__} must have an 'add_block' method")
|
||||
raise AttributeError(
|
||||
f"Container {
|
||||
type(container).__name__} must have an 'add_block' method")
|
||||
|
||||
return paragraph
|
||||
|
||||
@ -216,7 +217,11 @@ class Heading(Paragraph):
|
||||
self._level = level
|
||||
|
||||
@classmethod
|
||||
def create_and_add_to(cls, container, level: HeadingLevel = HeadingLevel.H1, style=None) -> 'Heading':
|
||||
def create_and_add_to(
|
||||
cls,
|
||||
container,
|
||||
level: HeadingLevel = HeadingLevel.H1,
|
||||
style=None) -> 'Heading':
|
||||
"""
|
||||
Create a new Heading and add it to a container, inheriting style from
|
||||
the container if not explicitly provided.
|
||||
@ -245,7 +250,9 @@ class Heading(Paragraph):
|
||||
if hasattr(container, 'add_block'):
|
||||
container.add_block(heading)
|
||||
else:
|
||||
raise AttributeError(f"Container {type(container).__name__} must have an 'add_block' method")
|
||||
raise AttributeError(
|
||||
f"Container {
|
||||
type(container).__name__} must have an 'add_block' method")
|
||||
|
||||
return heading
|
||||
|
||||
@ -305,7 +312,9 @@ class Quote(Block):
|
||||
if hasattr(container, 'add_block'):
|
||||
container.add_block(quote)
|
||||
else:
|
||||
raise AttributeError(f"Container {type(container).__name__} must have an 'add_block' method")
|
||||
raise AttributeError(
|
||||
f"Container {
|
||||
type(container).__name__} must have an 'add_block' method")
|
||||
|
||||
return quote
|
||||
|
||||
@ -341,7 +350,10 @@ class Quote(Block):
|
||||
"""
|
||||
return Paragraph.create_and_add_to(self, style)
|
||||
|
||||
def create_heading(self, level: HeadingLevel = HeadingLevel.H1, style=None) -> Heading:
|
||||
def create_heading(
|
||||
self,
|
||||
level: HeadingLevel = HeadingLevel.H1,
|
||||
style=None) -> Heading:
|
||||
"""
|
||||
Create a new heading and add it to this quote.
|
||||
|
||||
@ -403,7 +415,9 @@ class CodeBlock(Block):
|
||||
if hasattr(container, 'add_block'):
|
||||
container.add_block(code_block)
|
||||
else:
|
||||
raise AttributeError(f"Container {type(container).__name__} must have an 'add_block' method")
|
||||
raise AttributeError(
|
||||
f"Container {
|
||||
type(container).__name__} must have an 'add_block' method")
|
||||
|
||||
return code_block
|
||||
|
||||
@ -446,7 +460,7 @@ class ListStyle(Enum):
|
||||
"""Enumeration of list styles"""
|
||||
UNORDERED = 1 # <ul>
|
||||
ORDERED = 2 # <ol>
|
||||
DEFINITION = 3 # <dl>
|
||||
DEFINITION = 3 # <dl>
|
||||
|
||||
|
||||
class HList(Block):
|
||||
@ -468,7 +482,11 @@ class HList(Block):
|
||||
self._default_style = default_style
|
||||
|
||||
@classmethod
|
||||
def create_and_add_to(cls, container, style: ListStyle = ListStyle.UNORDERED, default_style=None) -> 'HList':
|
||||
def create_and_add_to(
|
||||
cls,
|
||||
container,
|
||||
style: ListStyle = ListStyle.UNORDERED,
|
||||
default_style=None) -> 'HList':
|
||||
"""
|
||||
Create a new HList and add it to a container, inheriting style from
|
||||
the container if not explicitly provided.
|
||||
@ -497,7 +515,9 @@ class HList(Block):
|
||||
if hasattr(container, 'add_block'):
|
||||
container.add_block(hlist)
|
||||
else:
|
||||
raise AttributeError(f"Container {type(container).__name__} must have an 'add_block' method")
|
||||
raise AttributeError(
|
||||
f"Container {
|
||||
type(container).__name__} must have an 'add_block' method")
|
||||
|
||||
return hlist
|
||||
|
||||
@ -579,7 +599,11 @@ class ListItem(Block):
|
||||
self._style = style
|
||||
|
||||
@classmethod
|
||||
def create_and_add_to(cls, container, term: Optional[str] = None, style=None) -> 'ListItem':
|
||||
def create_and_add_to(
|
||||
cls,
|
||||
container,
|
||||
term: Optional[str] = None,
|
||||
style=None) -> 'ListItem':
|
||||
"""
|
||||
Create a new ListItem and add it to a container, inheriting style from
|
||||
the container if not explicitly provided.
|
||||
@ -608,7 +632,9 @@ class ListItem(Block):
|
||||
if hasattr(container, 'add_item'):
|
||||
container.add_item(item)
|
||||
else:
|
||||
raise AttributeError(f"Container {type(container).__name__} must have an 'add_item' method")
|
||||
raise AttributeError(
|
||||
f"Container {
|
||||
type(container).__name__} must have an 'add_item' method")
|
||||
|
||||
return item
|
||||
|
||||
@ -654,7 +680,10 @@ class ListItem(Block):
|
||||
"""
|
||||
return Paragraph.create_and_add_to(self, style)
|
||||
|
||||
def create_heading(self, level: HeadingLevel = HeadingLevel.H1, style=None) -> Heading:
|
||||
def create_heading(
|
||||
self,
|
||||
level: HeadingLevel = HeadingLevel.H1,
|
||||
style=None) -> Heading:
|
||||
"""
|
||||
Create a new heading and add it to this list item.
|
||||
|
||||
@ -683,7 +712,12 @@ class TableCell(Block):
|
||||
A table cell element that can contain other block elements.
|
||||
"""
|
||||
|
||||
def __init__(self, is_header: bool = False, colspan: int = 1, rowspan: int = 1, style=None):
|
||||
def __init__(
|
||||
self,
|
||||
is_header: bool = False,
|
||||
colspan: int = 1,
|
||||
rowspan: int = 1,
|
||||
style=None):
|
||||
"""
|
||||
Initialize a table cell.
|
||||
|
||||
@ -702,7 +736,7 @@ class TableCell(Block):
|
||||
|
||||
@classmethod
|
||||
def create_and_add_to(cls, container, is_header: bool = False, colspan: int = 1,
|
||||
rowspan: int = 1, style=None) -> 'TableCell':
|
||||
rowspan: int = 1, style=None) -> 'TableCell':
|
||||
"""
|
||||
Create a new TableCell and add it to a container, inheriting style from
|
||||
the container if not explicitly provided.
|
||||
@ -731,7 +765,9 @@ class TableCell(Block):
|
||||
if hasattr(container, 'add_cell'):
|
||||
container.add_cell(cell)
|
||||
else:
|
||||
raise AttributeError(f"Container {type(container).__name__} must have an 'add_cell' method")
|
||||
raise AttributeError(
|
||||
f"Container {
|
||||
type(container).__name__} must have an 'add_cell' method")
|
||||
|
||||
return cell
|
||||
|
||||
@ -797,7 +833,10 @@ class TableCell(Block):
|
||||
"""
|
||||
return Paragraph.create_and_add_to(self, style)
|
||||
|
||||
def create_heading(self, level: HeadingLevel = HeadingLevel.H1, style=None) -> Heading:
|
||||
def create_heading(
|
||||
self,
|
||||
level: HeadingLevel = HeadingLevel.H1,
|
||||
style=None) -> Heading:
|
||||
"""
|
||||
Create a new heading and add it to this table cell.
|
||||
|
||||
@ -838,7 +877,11 @@ class TableRow(Block):
|
||||
self._style = style
|
||||
|
||||
@classmethod
|
||||
def create_and_add_to(cls, container, section: str = "body", style=None) -> 'TableRow':
|
||||
def create_and_add_to(
|
||||
cls,
|
||||
container,
|
||||
section: str = "body",
|
||||
style=None) -> 'TableRow':
|
||||
"""
|
||||
Create a new TableRow and add it to a container, inheriting style from
|
||||
the container if not explicitly provided.
|
||||
@ -865,7 +908,9 @@ class TableRow(Block):
|
||||
if hasattr(container, 'add_row'):
|
||||
container.add_row(row, section)
|
||||
else:
|
||||
raise AttributeError(f"Container {type(container).__name__} must have an 'add_row' method")
|
||||
raise AttributeError(
|
||||
f"Container {
|
||||
type(container).__name__} must have an 'add_row' method")
|
||||
|
||||
return row
|
||||
|
||||
@ -889,7 +934,12 @@ class TableRow(Block):
|
||||
self._cells.append(cell)
|
||||
cell.parent = self
|
||||
|
||||
def create_cell(self, is_header: bool = False, colspan: int = 1, rowspan: int = 1, style=None) -> TableCell:
|
||||
def create_cell(
|
||||
self,
|
||||
is_header: bool = False,
|
||||
colspan: int = 1,
|
||||
rowspan: int = 1,
|
||||
style=None) -> TableCell:
|
||||
"""
|
||||
Create a new table cell and add it to this row.
|
||||
|
||||
@ -941,7 +991,11 @@ class Table(Block):
|
||||
self._style = style
|
||||
|
||||
@classmethod
|
||||
def create_and_add_to(cls, container, caption: Optional[str] = None, style=None) -> 'Table':
|
||||
def create_and_add_to(
|
||||
cls,
|
||||
container,
|
||||
caption: Optional[str] = None,
|
||||
style=None) -> 'Table':
|
||||
"""
|
||||
Create a new Table and add it to a container, inheriting style from
|
||||
the container if not explicitly provided.
|
||||
@ -970,7 +1024,9 @@ class Table(Block):
|
||||
if hasattr(container, 'add_block'):
|
||||
container.add_block(table)
|
||||
else:
|
||||
raise AttributeError(f"Container {type(container).__name__} must have an 'add_block' method")
|
||||
raise AttributeError(
|
||||
f"Container {
|
||||
type(container).__name__} must have an 'add_block' method")
|
||||
|
||||
return table
|
||||
|
||||
@ -1084,7 +1140,12 @@ class Image(Block):
|
||||
An image element with source, dimensions, and alternative text.
|
||||
"""
|
||||
|
||||
def __init__(self, source: str = "", alt_text: str = "", width: Optional[int] = None, height: Optional[int] = None):
|
||||
def __init__(
|
||||
self,
|
||||
source: str = "",
|
||||
alt_text: str = "",
|
||||
width: Optional[int] = None,
|
||||
height: Optional[int] = None):
|
||||
"""
|
||||
Initialize an image element.
|
||||
|
||||
@ -1101,8 +1162,13 @@ class Image(Block):
|
||||
self._height = height
|
||||
|
||||
@classmethod
|
||||
def create_and_add_to(cls, container, source: str = "", alt_text: str = "",
|
||||
width: Optional[int] = None, height: Optional[int] = None) -> 'Image':
|
||||
def create_and_add_to(
|
||||
cls,
|
||||
container,
|
||||
source: str = "",
|
||||
alt_text: str = "",
|
||||
width: Optional[int] = None,
|
||||
height: Optional[int] = None) -> 'Image':
|
||||
"""
|
||||
Create a new Image and add it to a container.
|
||||
|
||||
@ -1126,7 +1192,9 @@ class Image(Block):
|
||||
if hasattr(container, 'add_block'):
|
||||
container.add_block(image)
|
||||
else:
|
||||
raise AttributeError(f"Container {type(container).__name__} must have an 'add_block' method")
|
||||
raise AttributeError(
|
||||
f"Container {
|
||||
type(container).__name__} must have an 'add_block' method")
|
||||
|
||||
return image
|
||||
|
||||
@ -1190,8 +1258,10 @@ class Image(Block):
|
||||
return self._width / self._height
|
||||
return None
|
||||
|
||||
def calculate_scaled_dimensions(self, max_width: Optional[int] = None,
|
||||
max_height: Optional[int] = None) -> Tuple[Optional[int], Optional[int]]:
|
||||
def calculate_scaled_dimensions(self,
|
||||
max_width: Optional[int] = None,
|
||||
max_height: Optional[int] = None) -> Tuple[Optional[int],
|
||||
Optional[int]]:
|
||||
"""
|
||||
Calculate scaled dimensions that fit within the given constraints.
|
||||
|
||||
@ -1255,19 +1325,21 @@ class Image(Block):
|
||||
temp_file.write(response.read())
|
||||
|
||||
return temp_path
|
||||
except:
|
||||
except BaseException:
|
||||
# Clean up the temporary file if download fails
|
||||
try:
|
||||
os.close(temp_fd)
|
||||
except:
|
||||
except BaseException:
|
||||
pass
|
||||
try:
|
||||
os.unlink(temp_path)
|
||||
except:
|
||||
except BaseException:
|
||||
pass
|
||||
raise
|
||||
|
||||
def load_image_data(self, auto_update_dimensions: bool = True) -> Tuple[Optional[str], Optional[PILImage.Image]]:
|
||||
def load_image_data(self,
|
||||
auto_update_dimensions: bool = True) -> Tuple[Optional[str],
|
||||
Optional[PILImage.Image]]:
|
||||
"""
|
||||
Load image data using PIL, handling both local files and URLs.
|
||||
|
||||
@ -1305,12 +1377,12 @@ class Image(Block):
|
||||
# Return a copy to avoid issues with the context manager
|
||||
return file_path, img.copy()
|
||||
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
# Clean up temporary file on error
|
||||
if temp_file and os.path.exists(temp_file):
|
||||
try:
|
||||
os.unlink(temp_file)
|
||||
except:
|
||||
except BaseException:
|
||||
pass
|
||||
return None, None
|
||||
|
||||
@ -1348,7 +1420,9 @@ class Image(Block):
|
||||
|
||||
# If still no format and we have a URL source, try the original URL
|
||||
if img_format is None and self._is_url(self._source):
|
||||
ext = os.path.splitext(urllib.parse.urlparse(self._source).path)[1].lower()
|
||||
ext = os.path.splitext(
|
||||
urllib.parse.urlparse(
|
||||
self._source).path)[1].lower()
|
||||
img_format = format_map.get(ext)
|
||||
|
||||
info = {
|
||||
@ -1367,7 +1441,7 @@ class Image(Block):
|
||||
if file_path and self._is_url(self._source):
|
||||
try:
|
||||
os.unlink(file_path)
|
||||
except:
|
||||
except BaseException:
|
||||
pass
|
||||
|
||||
return info
|
||||
@ -1380,7 +1454,7 @@ class LinkedImage(Image):
|
||||
|
||||
def __init__(self, source: str, alt_text: str, location: str,
|
||||
width: Optional[int] = None, height: Optional[int] = None,
|
||||
link_type = None,
|
||||
link_type=None,
|
||||
callback: Optional[Any] = None,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
title: Optional[str] = None):
|
||||
@ -1448,7 +1522,10 @@ class LinkedImage(Image):
|
||||
from pyWebLayout.abstract.functional import LinkType
|
||||
|
||||
# Add image info to context
|
||||
full_context = {**self._params, 'alt_text': self._alt_text, 'source': self._source}
|
||||
full_context = {
|
||||
**self._params,
|
||||
'alt_text': self._alt_text,
|
||||
'source': self._source}
|
||||
if context:
|
||||
full_context.update(context)
|
||||
|
||||
@ -1489,7 +1566,9 @@ class HorizontalRule(Block):
|
||||
if hasattr(container, 'add_block'):
|
||||
container.add_block(hr)
|
||||
else:
|
||||
raise AttributeError(f"Container {type(container).__name__} must have an 'add_block' method")
|
||||
raise AttributeError(
|
||||
f"Container {
|
||||
type(container).__name__} must have an 'add_block' method")
|
||||
|
||||
return hr
|
||||
|
||||
@ -1528,6 +1607,8 @@ class PageBreak(Block):
|
||||
if hasattr(container, 'add_block'):
|
||||
container.add_block(page_break)
|
||||
else:
|
||||
raise AttributeError(f"Container {type(container).__name__} must have an 'add_block' method")
|
||||
raise AttributeError(
|
||||
f"Container {
|
||||
type(container).__name__} must have an 'add_block' method")
|
||||
|
||||
return page_break
|
||||
|
||||
@ -2,8 +2,6 @@ from __future__ import annotations
|
||||
from typing import List, Dict, Optional, Tuple, Union, Any
|
||||
from enum import Enum
|
||||
from .block import Block, BlockType, Heading, HeadingLevel, Paragraph
|
||||
from .functional import Link, Button, Form
|
||||
from .inline import Word, FormattedSpan
|
||||
from ..style import Font, FontWeight, FontStyle, TextDecoration
|
||||
from ..style.abstract_style import AbstractStyle, AbstractStyleRegistry, FontFamily, FontSize
|
||||
from ..style.concrete_style import ConcreteStyleRegistry, RenderingContext, StyleResolver
|
||||
@ -34,7 +32,11 @@ class Document(FontRegistry, MetadataContainer):
|
||||
Uses MetadataContainer mixin for metadata management.
|
||||
"""
|
||||
|
||||
def __init__(self, title: Optional[str] = None, language: str = "en-US", default_style=None):
|
||||
def __init__(
|
||||
self,
|
||||
title: Optional[str] = None,
|
||||
language: str = "en-US",
|
||||
default_style=None):
|
||||
"""
|
||||
Initialize a new document.
|
||||
|
||||
@ -68,7 +70,8 @@ class Document(FontRegistry, MetadataContainer):
|
||||
color=default_style.colour,
|
||||
language=default_style.language
|
||||
)
|
||||
style_id, default_style = self._abstract_style_registry.get_or_create_style(default_style)
|
||||
style_id, default_style = self._abstract_style_registry.get_or_create_style(
|
||||
default_style)
|
||||
self._default_style = default_style
|
||||
|
||||
# Set basic metadata
|
||||
@ -116,7 +119,10 @@ class Document(FontRegistry, MetadataContainer):
|
||||
self.add_block(paragraph)
|
||||
return paragraph
|
||||
|
||||
def create_heading(self, level: HeadingLevel = HeadingLevel.H1, style=None) -> Heading:
|
||||
def create_heading(
|
||||
self,
|
||||
level: HeadingLevel = HeadingLevel.H1,
|
||||
style=None) -> Heading:
|
||||
"""
|
||||
Create a new heading and add it to this document.
|
||||
|
||||
@ -133,7 +139,11 @@ class Document(FontRegistry, MetadataContainer):
|
||||
self.add_block(heading)
|
||||
return heading
|
||||
|
||||
def create_chapter(self, title: Optional[str] = None, level: int = 1, style=None) -> 'Chapter':
|
||||
def create_chapter(
|
||||
self,
|
||||
title: Optional[str] = None,
|
||||
level: int = 1,
|
||||
style=None) -> 'Chapter':
|
||||
"""
|
||||
Create a new chapter with inherited style.
|
||||
|
||||
@ -311,15 +321,15 @@ class Document(FontRegistry, MetadataContainer):
|
||||
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]:
|
||||
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.
|
||||
|
||||
@ -392,7 +402,12 @@ class Chapter(FontRegistry, MetadataContainer):
|
||||
Uses MetadataContainer mixin for metadata management.
|
||||
"""
|
||||
|
||||
def __init__(self, title: Optional[str] = None, level: int = 1, style=None, parent=None):
|
||||
def __init__(
|
||||
self,
|
||||
title: Optional[str] = None,
|
||||
level: int = 1,
|
||||
style=None,
|
||||
parent=None):
|
||||
"""
|
||||
Initialize a new chapter.
|
||||
|
||||
@ -464,7 +479,10 @@ class Chapter(FontRegistry, MetadataContainer):
|
||||
self.add_block(paragraph)
|
||||
return paragraph
|
||||
|
||||
def create_heading(self, level: HeadingLevel = HeadingLevel.H1, style=None) -> Heading:
|
||||
def create_heading(
|
||||
self,
|
||||
level: HeadingLevel = HeadingLevel.H1,
|
||||
style=None) -> Heading:
|
||||
"""
|
||||
Create a new heading and add it to this chapter.
|
||||
|
||||
@ -522,7 +540,11 @@ class Book(Document):
|
||||
"""
|
||||
self._chapters.append(chapter)
|
||||
|
||||
def create_chapter(self, title: Optional[str] = None, level: int = 1, style=None) -> Chapter:
|
||||
def create_chapter(
|
||||
self,
|
||||
title: Optional[str] = None,
|
||||
level: int = 1,
|
||||
style=None) -> Chapter:
|
||||
"""
|
||||
Create and add a new chapter with inherited style.
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
from __future__ import annotations
|
||||
from pyWebLayout.core.base import Queriable
|
||||
from pyWebLayout.core import Hierarchical
|
||||
from pyWebLayout.style import Font
|
||||
from pyWebLayout.style.abstract_style import AbstractStyle
|
||||
@ -10,7 +9,6 @@ import pyphen
|
||||
from pyWebLayout.abstract.functional import LinkType
|
||||
|
||||
|
||||
|
||||
class Word:
|
||||
"""
|
||||
An abstract representation of a word in a document. Words can be split across
|
||||
@ -20,7 +18,13 @@ class Word:
|
||||
Now uses AbstractStyle objects for memory efficiency and proper style management.
|
||||
"""
|
||||
|
||||
def __init__(self, text: str, style: Union[Font, AbstractStyle], background=None, previous: Union['Word', None] = None):
|
||||
def __init__(self,
|
||||
text: str,
|
||||
style: Union[Font,
|
||||
AbstractStyle],
|
||||
background=None,
|
||||
previous: Union['Word',
|
||||
None] = None):
|
||||
"""
|
||||
Initialize a new Word.
|
||||
|
||||
@ -41,7 +45,7 @@ class Word:
|
||||
|
||||
@classmethod
|
||||
def create_and_add_to(cls, text: str, container, style: Optional[Font] = None,
|
||||
background=None) -> 'Word':
|
||||
background=None) -> 'Word':
|
||||
"""
|
||||
Create a new Word and add it to a container, inheriting style and language
|
||||
from the container if not explicitly provided.
|
||||
@ -67,7 +71,9 @@ class Word:
|
||||
if hasattr(container, 'style'):
|
||||
style = container.style
|
||||
else:
|
||||
raise AttributeError(f"Container {type(container).__name__} must have a 'style' property")
|
||||
raise AttributeError(
|
||||
f"Container {
|
||||
type(container).__name__} must have a 'style' property")
|
||||
|
||||
# Inherit background from container if not provided
|
||||
if background is None and hasattr(container, 'background'):
|
||||
@ -110,7 +116,8 @@ class Word:
|
||||
else:
|
||||
# Might expect text string (like FormattedSpan.add_word)
|
||||
# In this case, we can't use the container's add_word as it would create
|
||||
# a duplicate Word. We need to add directly to the container's word list.
|
||||
# a duplicate Word. We need to add directly to the container's word
|
||||
# list.
|
||||
if hasattr(container, '_words'):
|
||||
container._words.append(word)
|
||||
else:
|
||||
@ -120,12 +127,13 @@ 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
|
||||
@ -153,12 +161,10 @@ class Word:
|
||||
"""Get the next word in sequence"""
|
||||
return self._next
|
||||
|
||||
|
||||
def add_next(self, next_word: 'Word'):
|
||||
"""Set the next word in sequence"""
|
||||
self._next = next_word
|
||||
|
||||
|
||||
def possible_hyphenation(self, language: str = None) -> bool:
|
||||
"""
|
||||
Hyphenate the word and store the parts.
|
||||
@ -172,9 +178,10 @@ class Word:
|
||||
|
||||
dic = pyphen.Pyphen(lang=self._style.language)
|
||||
return list(dic.iterate(self._text))
|
||||
...
|
||||
|
||||
|
||||
...
|
||||
|
||||
|
||||
class FormattedSpan:
|
||||
"""
|
||||
@ -195,7 +202,11 @@ class FormattedSpan:
|
||||
self._words: List[Word] = []
|
||||
|
||||
@classmethod
|
||||
def create_and_add_to(cls, container, style: Optional[Font] = None, background=None) -> 'FormattedSpan':
|
||||
def create_and_add_to(
|
||||
cls,
|
||||
container,
|
||||
style: Optional[Font] = None,
|
||||
background=None) -> 'FormattedSpan':
|
||||
"""
|
||||
Create a new FormattedSpan and add it to a container, inheriting style from
|
||||
the container if not explicitly provided.
|
||||
@ -216,7 +227,9 @@ class FormattedSpan:
|
||||
if hasattr(container, 'style'):
|
||||
style = container.style
|
||||
else:
|
||||
raise AttributeError(f"Container {type(container).__name__} must have a 'style' property")
|
||||
raise AttributeError(
|
||||
f"Container {
|
||||
type(container).__name__} must have a 'style' property")
|
||||
|
||||
# Inherit background from container if not provided
|
||||
if background is None and hasattr(container, 'background'):
|
||||
@ -229,7 +242,9 @@ class FormattedSpan:
|
||||
if hasattr(container, 'add_span'):
|
||||
container.add_span(span)
|
||||
else:
|
||||
raise AttributeError(f"Container {type(container).__name__} must have an 'add_span' method")
|
||||
raise AttributeError(
|
||||
f"Container {
|
||||
type(container).__name__} must have an 'add_span' method")
|
||||
|
||||
return span
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -33,7 +33,8 @@ class LinkText(Text, Interactable, Queriable):
|
||||
if link.link_type == LinkType.INTERNAL:
|
||||
link_font = link_font.with_colour((0, 0, 200)) # Blue for internal links
|
||||
elif link.link_type == LinkType.EXTERNAL:
|
||||
link_font = link_font.with_colour((0, 0, 180)) # Darker blue for external links
|
||||
link_font = link_font.with_colour(
|
||||
(0, 0, 180)) # Darker blue for external links
|
||||
elif link.link_type == LinkType.API:
|
||||
link_font = link_font.with_colour((150, 0, 0)) # Red for API links
|
||||
elif link.link_type == LinkType.FUNCTION:
|
||||
@ -62,7 +63,6 @@ class LinkText(Text, Interactable, Queriable):
|
||||
"""Set the hover state for visual feedback"""
|
||||
self._hovered = hovered
|
||||
|
||||
|
||||
def render(self, next_text: Optional['Text'] = None, spacing: int = 0):
|
||||
"""
|
||||
Render the link text with optional hover effects.
|
||||
@ -88,11 +88,13 @@ class LinkText(Text, Interactable, Queriable):
|
||||
size = np.array(size)
|
||||
|
||||
# Ensure origin is a numpy array
|
||||
origin = np.array(self._origin) if not isinstance(self._origin, np.ndarray) else self._origin
|
||||
origin = np.array(
|
||||
self._origin) if not isinstance(
|
||||
self._origin,
|
||||
np.ndarray) else self._origin
|
||||
|
||||
self._draw.rectangle([origin, origin + size],
|
||||
fill=highlight_color)
|
||||
|
||||
fill=highlight_color)
|
||||
|
||||
|
||||
class ButtonText(Text, Interactable, Queriable):
|
||||
@ -129,7 +131,9 @@ class ButtonText(Text, Interactable, Queriable):
|
||||
|
||||
# Recalculate dimensions to include padding
|
||||
# Use getattr to handle mock objects in tests
|
||||
text_width = getattr(self, '_width', 0) if not hasattr(self._width, '__call__') else 0
|
||||
text_width = getattr(
|
||||
self, '_width', 0) if not hasattr(
|
||||
self._width, '__call__') else 0
|
||||
self._padded_width = text_width + padding[1] + padding[3]
|
||||
self._padded_height = self._style.font_size + padding[0] + padding[2]
|
||||
|
||||
@ -151,7 +155,6 @@ class ButtonText(Text, Interactable, Queriable):
|
||||
"""Set the hover state"""
|
||||
self._hovered = hovered
|
||||
|
||||
|
||||
def render(self):
|
||||
"""
|
||||
Render the button with background, border, and text.
|
||||
@ -187,7 +190,7 @@ 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)
|
||||
@ -270,7 +273,9 @@ class FormFieldText(Text, Interactable, Queriable):
|
||||
|
||||
# Field width should be at least as wide as the label
|
||||
# Use getattr to handle mock objects in tests
|
||||
text_width = getattr(self, '_width', 0) if not hasattr(self._width, '__call__') else 0
|
||||
text_width = getattr(
|
||||
self, '_width', 0) if not hasattr(
|
||||
self._width, '__call__') else 0
|
||||
self._field_width = max(text_width, 150)
|
||||
|
||||
@property
|
||||
@ -303,7 +308,7 @@ class FormFieldText(Text, Interactable, Queriable):
|
||||
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_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
|
||||
@ -329,7 +334,7 @@ class FormFieldText(Text, Interactable, Queriable):
|
||||
|
||||
# Draw the value text
|
||||
self._draw.text((value_x, value_y), value_text,
|
||||
font=value_font.font, fill=value_font.colour, anchor="ls")
|
||||
font=value_font.font, fill=value_font.colour, anchor="ls")
|
||||
|
||||
def handle_click(self, point) -> bool:
|
||||
"""
|
||||
@ -346,7 +351,7 @@ class FormFieldText(Text, Interactable, Queriable):
|
||||
|
||||
# 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
|
||||
|
||||
@ -371,7 +376,8 @@ class FormFieldText(Text, Interactable, Queriable):
|
||||
|
||||
|
||||
# Factory functions for creating functional text objects
|
||||
def create_link_text(link: Link, text: str, font: Font, draw: ImageDraw.Draw) -> LinkText:
|
||||
def create_link_text(link: Link, text: str, font: Font,
|
||||
draw: ImageDraw.Draw) -> LinkText:
|
||||
"""
|
||||
Factory function to create a LinkText object.
|
||||
|
||||
@ -388,7 +394,7 @@ 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.
|
||||
|
||||
@ -405,7 +411,7 @@ 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.
|
||||
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
import os
|
||||
from typing import Optional, Tuple, Union, Dict, Any
|
||||
from typing import Optional
|
||||
import numpy as np
|
||||
from PIL import Image as PILImage, ImageDraw, ImageFont
|
||||
from pyWebLayout.core.base import Renderable, Queriable
|
||||
from pyWebLayout.abstract.block import Image as AbstractImage
|
||||
from .box import Box
|
||||
from pyWebLayout.style import Alignment
|
||||
|
||||
|
||||
@ -80,7 +79,9 @@ class RenderableImage(Renderable, Queriable):
|
||||
"""Load the image from the source path"""
|
||||
try:
|
||||
# Check if the image has already been loaded into memory
|
||||
if hasattr(self._abstract_image, '_loaded_image') and self._abstract_image._loaded_image is not None:
|
||||
if hasattr(
|
||||
self._abstract_image,
|
||||
'_loaded_image') and self._abstract_image._loaded_image is not None:
|
||||
self._pil_image = self._abstract_image._loaded_image
|
||||
return
|
||||
|
||||
@ -102,7 +103,8 @@ class RenderableImage(Renderable, Queriable):
|
||||
self._pil_image = PILImage.open(BytesIO(response.content))
|
||||
self._abstract_image._loaded_image = self._pil_image
|
||||
else:
|
||||
self._error_message = f"Failed to load image: HTTP status {response.status_code}"
|
||||
self._error_message = f"Failed to load image: HTTP status {
|
||||
response.status_code}"
|
||||
except ImportError:
|
||||
self._error_message = "Requests library not available for URL loading"
|
||||
else:
|
||||
@ -146,8 +148,14 @@ class RenderableImage(Renderable, Queriable):
|
||||
|
||||
# Get the underlying image from the draw object to paste onto
|
||||
|
||||
|
||||
self._canvas.paste(resized_image, (final_x, final_y, final_x + img_width, final_y + img_height))
|
||||
self._canvas.paste(
|
||||
resized_image,
|
||||
(final_x,
|
||||
final_y,
|
||||
final_x +
|
||||
img_width,
|
||||
final_y +
|
||||
img_height))
|
||||
else:
|
||||
# Draw error placeholder
|
||||
self._draw_error_placeholder()
|
||||
@ -184,7 +192,8 @@ class RenderableImage(Renderable, Queriable):
|
||||
resized = self._pil_image.resize((new_width, new_height), PILImage.LANCZOS)
|
||||
else:
|
||||
# Convert to RGBA if needed
|
||||
resized = self._pil_image.convert('RGBA').resize((new_width, new_height), PILImage.LANCZOS)
|
||||
resized = self._pil_image.convert('RGBA').resize(
|
||||
(new_width, new_height), PILImage.LANCZOS)
|
||||
|
||||
return resized
|
||||
|
||||
@ -200,7 +209,8 @@ class RenderableImage(Renderable, Queriable):
|
||||
|
||||
self._draw = ImageDraw.Draw(self._canvas)
|
||||
# Draw a gray box with a border
|
||||
self._draw.rectangle([(x1, y1), (x2, y2)], fill=(240, 240, 240), outline=(180, 180, 180), width=2)
|
||||
self._draw.rectangle([(x1, y1), (x2, y2)], fill=(
|
||||
240, 240, 240), outline=(180, 180, 180), width=2)
|
||||
|
||||
# Draw an X across the box
|
||||
self._draw.line([(x1, y1), (x2, y2)], fill=(180, 180, 180), width=2)
|
||||
|
||||
@ -2,12 +2,11 @@ from typing import List, Tuple, Optional
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from pyWebLayout.core.base import Renderable, Layoutable, Queriable
|
||||
from pyWebLayout.core.base import Renderable, Queriable
|
||||
from pyWebLayout.core.query import QueryResult, SelectionRange
|
||||
from pyWebLayout.core.callback_registry import CallbackRegistry
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from pyWebLayout.style import Alignment
|
||||
from .box import Box
|
||||
|
||||
|
||||
class Page(Renderable, Queriable):
|
||||
"""
|
||||
@ -41,7 +40,11 @@ class Page(Renderable, Queriable):
|
||||
"""Get the remaining space on the page"""
|
||||
return (self._size[0], self._size[1] - self._current_y_offset)
|
||||
|
||||
def can_fit_line(self, baseline_spacing: int, ascent: int = 0, descent: int = 0) -> bool:
|
||||
def can_fit_line(
|
||||
self,
|
||||
baseline_spacing: int,
|
||||
ascent: int = 0,
|
||||
descent: int = 0) -> bool:
|
||||
"""
|
||||
Check if a line with the given metrics can fit on the page.
|
||||
|
||||
@ -183,11 +186,15 @@ class Page(Renderable, Queriable):
|
||||
Height in pixels
|
||||
"""
|
||||
if hasattr(child, '_size') and child._size is not None:
|
||||
if isinstance(child._size, (list, tuple, np.ndarray)) and len(child._size) >= 2:
|
||||
if isinstance(
|
||||
child._size, (list, tuple, np.ndarray)) and len(
|
||||
child._size) >= 2:
|
||||
return int(child._size[1])
|
||||
|
||||
if hasattr(child, 'size') and child.size is not None:
|
||||
if isinstance(child.size, (list, tuple, np.ndarray)) and len(child.size) >= 2:
|
||||
if isinstance(
|
||||
child.size, (list, tuple, np.ndarray)) and len(
|
||||
child.size) >= 2:
|
||||
return int(child.size[1])
|
||||
|
||||
if hasattr(child, 'height'):
|
||||
@ -326,7 +333,7 @@ class Page(Renderable, Queriable):
|
||||
if isinstance(child, Queriable) and hasattr(child, 'in_object'):
|
||||
try:
|
||||
return child.in_object(point)
|
||||
except:
|
||||
except BaseException:
|
||||
pass # Fall back to bounds checking
|
||||
|
||||
# Get child position and size for bounds checking
|
||||
@ -353,11 +360,15 @@ class Page(Renderable, Queriable):
|
||||
Tuple of (width, height) or None if size cannot be determined
|
||||
"""
|
||||
if hasattr(child, '_size') and child._size is not None:
|
||||
if isinstance(child._size, (list, tuple, np.ndarray)) and len(child._size) >= 2:
|
||||
if isinstance(
|
||||
child._size, (list, tuple, np.ndarray)) and len(
|
||||
child._size) >= 2:
|
||||
return (int(child._size[0]), int(child._size[1]))
|
||||
|
||||
if hasattr(child, 'size') and child.size is not None:
|
||||
if isinstance(child.size, (list, tuple, np.ndarray)) and len(child.size) >= 2:
|
||||
if isinstance(
|
||||
child.size, (list, tuple, np.ndarray)) and len(
|
||||
child.size) >= 2:
|
||||
return (int(child.size[0]), int(child.size[1]))
|
||||
|
||||
if hasattr(child, 'width') and hasattr(child, 'height'):
|
||||
@ -422,7 +433,8 @@ class Page(Renderable, Queriable):
|
||||
bounds=bounds
|
||||
)
|
||||
|
||||
def query_range(self, start: Tuple[int, int], end: Tuple[int, int]) -> SelectionRange:
|
||||
def query_range(self, start: Tuple[int, int],
|
||||
end: Tuple[int, int]) -> SelectionRange:
|
||||
"""
|
||||
Query all text objects between two points (for text selection).
|
||||
Uses Queriable.in_object() to determine which objects are in range.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -237,7 +248,7 @@ class Text(Renderable, Queriable):
|
||||
"""
|
||||
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
|
||||
@ -246,12 +257,12 @@ class Text(Renderable, Queriable):
|
||||
# If next text also has underline decoration, extend to connect them
|
||||
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
|
||||
@ -264,12 +275,12 @@ class Text(Renderable, Queriable):
|
||||
# If next text also has strikethrough decoration, extend to connect them
|
||||
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):
|
||||
"""
|
||||
@ -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,14 +358,14 @@ 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
|
||||
@ -373,7 +402,10 @@ class Line(Box):
|
||||
"""Set the next line in sequence"""
|
||||
self._next = line
|
||||
|
||||
def add_word(self, word: 'Word', part:Optional[Text]=None) -> Tuple[bool, Optional['Text']]:
|
||||
def add_word(self,
|
||||
word: 'Word',
|
||||
part: Optional[Text] = None) -> Tuple[bool,
|
||||
Optional['Text']]:
|
||||
"""
|
||||
Add a word to this line using intelligent word fitting strategies.
|
||||
|
||||
@ -392,7 +424,8 @@ class Line(Box):
|
||||
self._words.append(word)
|
||||
part.add_line(self)
|
||||
|
||||
# Try to add the full word - create LinkText for LinkedWord, regular Text otherwise
|
||||
# Try to add the full word - create LinkText for LinkedWord, regular Text
|
||||
# otherwise
|
||||
if isinstance(word, LinkedWord):
|
||||
# Import here to avoid circular dependency
|
||||
from .functional import LinkText
|
||||
@ -407,13 +440,18 @@ class Line(Box):
|
||||
params=word.params,
|
||||
title=word.link_title
|
||||
)
|
||||
text = LinkText(link, word.text, word.style, self._draw, source=word, line=self)
|
||||
text = LinkText(
|
||||
link,
|
||||
word.text,
|
||||
word.style,
|
||||
self._draw,
|
||||
source=word,
|
||||
line=self)
|
||||
else:
|
||||
text = Text.from_word(word, self._draw)
|
||||
self._text_objects.append(text)
|
||||
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
|
||||
self._text_objects, self._size[0], self._spacing[0], self._spacing[1]
|
||||
)
|
||||
self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
|
||||
|
||||
if not overflow:
|
||||
# Word fits! Add it completely
|
||||
@ -444,14 +482,23 @@ class Line(Box):
|
||||
continue
|
||||
|
||||
# Create Text objects
|
||||
first_text = Text(first_part_text, word.style, self._draw, line=self, source=word)
|
||||
second_text = Text(second_part_text, word.style, self._draw, line=self, source=word)
|
||||
first_text = Text(
|
||||
first_part_text,
|
||||
word.style,
|
||||
self._draw,
|
||||
line=self,
|
||||
source=word)
|
||||
second_text = Text(
|
||||
second_part_text,
|
||||
word.style,
|
||||
self._draw,
|
||||
line=self,
|
||||
source=word)
|
||||
|
||||
# Check if first part fits
|
||||
self._text_objects.append(first_text)
|
||||
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
|
||||
self._text_objects, self._size[0], self._spacing[0], self._spacing[1]
|
||||
)
|
||||
self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
|
||||
_ = self._text_objects.pop()
|
||||
|
||||
if not overflow:
|
||||
@ -489,7 +536,8 @@ class Line(Box):
|
||||
fraction = remaining / test_text.width
|
||||
|
||||
# Convert fraction to character position
|
||||
# We need at least min_chars_before_hyphen and leave at least min_chars_after_hyphen
|
||||
# We need at least min_chars_before_hyphen and leave at least
|
||||
# min_chars_after_hyphen
|
||||
max_split_pos = len(word.text) - self._min_chars_after_hyphen
|
||||
min_split_pos = self._min_chars_before_hyphen
|
||||
|
||||
@ -499,20 +547,29 @@ class Line(Box):
|
||||
|
||||
# Ensure we meet minimum requirements
|
||||
if (split_pos >= self._min_chars_before_hyphen and
|
||||
len(word.text) - split_pos >= self._min_chars_after_hyphen):
|
||||
len(word.text) - split_pos >= self._min_chars_after_hyphen):
|
||||
|
||||
# Create the split
|
||||
first_part_text = word.text[:split_pos] + "-"
|
||||
second_part_text = word.text[split_pos:]
|
||||
|
||||
first_text = Text(first_part_text, word.style, self._draw, line=self, source=word)
|
||||
second_text = Text(second_part_text, word.style, self._draw, line=self, source=word)
|
||||
first_text = Text(
|
||||
first_part_text,
|
||||
word.style,
|
||||
self._draw,
|
||||
line=self,
|
||||
source=word)
|
||||
second_text = Text(
|
||||
second_part_text,
|
||||
word.style,
|
||||
self._draw,
|
||||
line=self,
|
||||
source=word)
|
||||
|
||||
# Verify the first part actually fits
|
||||
self._text_objects.append(first_text)
|
||||
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
|
||||
self._text_objects, self._size[0], self._spacing[0], self._spacing[1]
|
||||
)
|
||||
self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
|
||||
|
||||
if not overflow:
|
||||
# Brute force split works!
|
||||
@ -540,8 +597,7 @@ class Line(Box):
|
||||
# Recalculate spacing and position for current text objects to ensure accuracy
|
||||
if len(self._text_objects) > 0:
|
||||
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
|
||||
self._text_objects, self._size[0], self._spacing[0], self._spacing[1]
|
||||
)
|
||||
self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
|
||||
self._spacing_render = spacing
|
||||
self._position_render = position
|
||||
|
||||
@ -555,11 +611,12 @@ class Line(Box):
|
||||
text.set_origin(np.array([x_cursor, y_cursor]))
|
||||
|
||||
# Determine next text object for continuous decoration
|
||||
next_text = self._text_objects[i + 1] if i + 1 < len(self._text_objects) else None
|
||||
next_text = self._text_objects[i + 1] if i + \
|
||||
1 < len(self._text_objects) else None
|
||||
|
||||
# Render with next text information for continuous underline/strikethrough
|
||||
text.render(next_text, self._spacing_render)
|
||||
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,6 +12,7 @@ class Renderable(ABC):
|
||||
Abstract base class for any object that can be rendered to an image.
|
||||
All renderable objects must implement the render method.
|
||||
"""
|
||||
|
||||
def render(self):
|
||||
"""
|
||||
Render the object to an image.
|
||||
@ -21,16 +20,18 @@ class Renderable(ABC):
|
||||
Returns:
|
||||
PIL.Image: The rendered image
|
||||
"""
|
||||
pass
|
||||
|
||||
@property
|
||||
def origin(self):
|
||||
return self._origin
|
||||
|
||||
|
||||
class Interactable(ABC):
|
||||
"""
|
||||
Abstract base class for any object that can be interacted with.
|
||||
Interactable objects must have a callback that is executed when interacted with.
|
||||
"""
|
||||
|
||||
def __init__(self, callback=None):
|
||||
"""
|
||||
Initialize an interactable object.
|
||||
@ -54,17 +55,19 @@ class Interactable(ABC):
|
||||
return None
|
||||
return self._callback(point)
|
||||
|
||||
|
||||
class Layoutable(ABC):
|
||||
"""
|
||||
Abstract base class for any object that can be laid out.
|
||||
Layoutable objects must implement the layout method which arranges their contents.
|
||||
"""
|
||||
|
||||
def layout(self):
|
||||
"""
|
||||
Layout the object's contents.
|
||||
This method should be called before rendering to properly arrange the object's contents.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class Queriable(ABC):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -61,7 +60,8 @@ class EPUBReader:
|
||||
pyWebLayout's abstract document model.
|
||||
"""
|
||||
|
||||
def __init__(self, epub_path: str, image_processor: Optional[Callable[[PILImage.Image], PILImage.Image]] = default_eink_processor):
|
||||
def __init__(self, epub_path: str, image_processor: Optional[Callable[[
|
||||
PILImage.Image], PILImage.Image]] = default_eink_processor):
|
||||
"""
|
||||
Initialize an EPUB reader.
|
||||
|
||||
@ -124,10 +124,12 @@ class EPUBReader:
|
||||
root = tree.getroot()
|
||||
|
||||
# Get the path to the package document (content.opf)
|
||||
for rootfile in root.findall('.//{urn:oasis:names:tc:opendocument:xmlns:container}rootfile'):
|
||||
for rootfile in root.findall(
|
||||
'.//{urn:oasis:names:tc:opendocument:xmlns:container}rootfile'):
|
||||
full_path = rootfile.get('full-path')
|
||||
if full_path:
|
||||
self.content_dir = os.path.dirname(os.path.join(self.temp_dir, full_path))
|
||||
self.content_dir = os.path.dirname(
|
||||
os.path.join(self.temp_dir, full_path))
|
||||
return
|
||||
|
||||
# Fallback: look for common content directories
|
||||
@ -264,14 +266,18 @@ class EPUBReader:
|
||||
self.toc_path = self.manifest[toc_id]['path']
|
||||
|
||||
# Parse itemrefs
|
||||
for itemref in spine_elem.findall('.//{{{0}}}itemref'.format(NAMESPACES['opf'])):
|
||||
for itemref in spine_elem.findall(
|
||||
'.//{{{0}}}itemref'.format(NAMESPACES['opf'])):
|
||||
idref = itemref.get('idref')
|
||||
if idref and idref in self.manifest:
|
||||
self.spine.append(idref)
|
||||
|
||||
def _parse_toc(self):
|
||||
"""Parse the table of contents."""
|
||||
if not hasattr(self, 'toc_path') or not self.toc_path or not os.path.exists(self.toc_path):
|
||||
if not hasattr(
|
||||
self,
|
||||
'toc_path') or not self.toc_path or not os.path.exists(
|
||||
self.toc_path):
|
||||
# Try to find the toc.ncx file
|
||||
for root, dirs, files in os.walk(self.content_dir):
|
||||
for file in files:
|
||||
@ -281,7 +287,10 @@ class EPUBReader:
|
||||
if hasattr(self, 'toc_path') and self.toc_path:
|
||||
break
|
||||
|
||||
if not hasattr(self, 'toc_path') or not self.toc_path or not os.path.exists(self.toc_path):
|
||||
if not hasattr(
|
||||
self,
|
||||
'toc_path') or not self.toc_path or not os.path.exists(
|
||||
self.toc_path):
|
||||
# No TOC found
|
||||
return
|
||||
|
||||
@ -312,7 +321,8 @@ class EPUBReader:
|
||||
|
||||
# Get navLabel
|
||||
nav_label = nav_point.find('.//{{{0}}}navLabel'.format(NAMESPACES['ncx']))
|
||||
text_elem = nav_label.find('.//{{{0}}}text'.format(NAMESPACES['ncx'])) if nav_label else None
|
||||
text_elem = nav_label.find(
|
||||
'.//{{{0}}}text'.format(NAMESPACES['ncx'])) if nav_label else None
|
||||
label = text_elem.text if text_elem is not None else ""
|
||||
|
||||
# Get content
|
||||
@ -350,10 +360,14 @@ class EPUBReader:
|
||||
self.book.set_metadata(MetadataType.LANGUAGE, self.metadata['language'])
|
||||
|
||||
if 'description' in self.metadata:
|
||||
self.book.set_metadata(MetadataType.DESCRIPTION, self.metadata['description'])
|
||||
self.book.set_metadata(
|
||||
MetadataType.DESCRIPTION,
|
||||
self.metadata['description'])
|
||||
|
||||
if 'subjects' in self.metadata:
|
||||
self.book.set_metadata(MetadataType.KEYWORDS, ', '.join(self.metadata['subjects']))
|
||||
self.book.set_metadata(
|
||||
MetadataType.KEYWORDS, ', '.join(
|
||||
self.metadata['subjects']))
|
||||
|
||||
if 'date' in self.metadata:
|
||||
self.book.set_metadata(MetadataType.PUBLICATION_DATE, self.metadata['date'])
|
||||
@ -388,7 +402,8 @@ class EPUBReader:
|
||||
import io
|
||||
|
||||
# Load the image into memory before the temp directory is cleaned up
|
||||
# We need to fully copy the image data to ensure it persists after temp cleanup
|
||||
# We need to fully copy the image data to ensure it persists after temp
|
||||
# cleanup
|
||||
with open(cover_path, 'rb') as f:
|
||||
image_bytes = f.read()
|
||||
|
||||
@ -414,7 +429,8 @@ class EPUBReader:
|
||||
cover_image._width = pil_image.width
|
||||
cover_image._height = pil_image.height
|
||||
|
||||
# Store the loaded PIL image in the abstract image so it persists after temp cleanup
|
||||
# Store the loaded PIL image in the abstract image so it persists after
|
||||
# temp cleanup
|
||||
cover_image._loaded_image = pil_image
|
||||
|
||||
# Add the image to the cover chapter
|
||||
@ -444,7 +460,10 @@ class EPUBReader:
|
||||
try:
|
||||
block._loaded_image = self.image_processor(block._loaded_image)
|
||||
except Exception as e:
|
||||
print(f"Warning: Image processing failed for image '{block.alt_text}': {str(e)}")
|
||||
print(
|
||||
f"Warning: Image processing failed for image '{
|
||||
block.alt_text}': {
|
||||
str(e)}")
|
||||
# Continue with unprocessed image
|
||||
|
||||
def _process_content_images(self):
|
||||
@ -491,7 +510,7 @@ class EPUBReader:
|
||||
# Skip navigation files
|
||||
if (idref == 'nav' or
|
||||
item.get('media_type') == 'application/xhtml+xml' and
|
||||
('nav' in href.lower() or 'toc' in href.lower())):
|
||||
('nav' in href.lower() or 'toc' in href.lower())):
|
||||
continue
|
||||
|
||||
# Check if this item is in the TOC
|
||||
@ -521,7 +540,7 @@ class EPUBReader:
|
||||
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())
|
||||
|
||||
@ -6,10 +6,9 @@ used by pyWebLayout, including paragraphs, headings, lists, tables, and inline f
|
||||
Each handler function has a robust signature that handles style hints, CSS classes, and attributes.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import List, Dict, Any, Optional, Union, Callable, Tuple, NamedTuple
|
||||
from bs4 import BeautifulSoup, Tag, NavigableString
|
||||
from pyWebLayout.abstract.inline import Word, FormattedSpan
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.abstract.block import (
|
||||
Block,
|
||||
Paragraph,
|
||||
@ -27,8 +26,6 @@ from pyWebLayout.abstract.block import (
|
||||
Image,
|
||||
)
|
||||
from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration
|
||||
from pyWebLayout.style.abstract_style import AbstractStyle, FontFamily, FontSize
|
||||
from pyWebLayout.style import Alignment as TextAlign
|
||||
|
||||
|
||||
class StyleContext(NamedTuple):
|
||||
@ -72,7 +69,9 @@ class StyleContext(NamedTuple):
|
||||
return self._replace(parent_elements=self.parent_elements + [element_name])
|
||||
|
||||
|
||||
def create_base_context(base_font: Optional[Font] = None, document=None) -> StyleContext:
|
||||
def create_base_context(
|
||||
base_font: Optional[Font] = None,
|
||||
document=None) -> StyleContext:
|
||||
"""
|
||||
Create a base style context with default values.
|
||||
|
||||
@ -130,7 +129,8 @@ def apply_element_styling(context: StyleContext, element: Tag) -> StyleContext:
|
||||
new_context = new_context.with_css_styles(css_styles)
|
||||
|
||||
# Apply element-specific default styles
|
||||
font = apply_element_font_styles(new_context.font, tag_name, css_styles, new_context)
|
||||
font = apply_element_font_styles(
|
||||
new_context.font, tag_name, css_styles, new_context)
|
||||
new_context = new_context.with_font(font)
|
||||
|
||||
# Apply background from styles
|
||||
@ -158,9 +158,11 @@ def parse_inline_styles(style_text: str) -> Dict[str, str]:
|
||||
return styles
|
||||
|
||||
|
||||
def apply_element_font_styles(
|
||||
font: Font, tag_name: str, css_styles: Dict[str, str], context: Optional[StyleContext] = None
|
||||
) -> Font:
|
||||
def apply_element_font_styles(font: Font,
|
||||
tag_name: str,
|
||||
css_styles: Dict[str,
|
||||
str],
|
||||
context: Optional[StyleContext] = None) -> Font:
|
||||
"""
|
||||
Apply font styling based on HTML element and CSS styles.
|
||||
Uses document's font registry when available to avoid creating duplicate fonts.
|
||||
@ -273,14 +275,16 @@ def apply_element_font_styles(
|
||||
pass
|
||||
|
||||
# Use document's style registry if available to avoid creating duplicate styles
|
||||
if context and context.document and hasattr(context.document, 'get_or_create_style'):
|
||||
if context and context.document and hasattr(
|
||||
context.document, 'get_or_create_style'):
|
||||
# Create an abstract style first
|
||||
from pyWebLayout.style.abstract_style import FontFamily, FontSize
|
||||
|
||||
# Map font properties to abstract style properties
|
||||
font_family = FontFamily.SERIF # Default - could be enhanced to detect from font_path
|
||||
if font_size:
|
||||
font_size_value = font_size if isinstance(font_size, int) else FontSize.MEDIUM
|
||||
font_size_value = font_size if isinstance(
|
||||
font_size, int) else FontSize.MEDIUM
|
||||
else:
|
||||
font_size_value = FontSize.MEDIUM
|
||||
|
||||
@ -435,7 +439,8 @@ def extract_text_content(element: Tag, context: StyleContext) -> List[Word]:
|
||||
child_words = extract_text_content(child, child_context)
|
||||
words.extend(child_words)
|
||||
else:
|
||||
# Block element - shouldn't happen in well-formed HTML but handle gracefully
|
||||
# Block element - shouldn't happen in well-formed HTML but handle
|
||||
# gracefully
|
||||
child_context = apply_element_styling(context, child)
|
||||
child_result = process_element(child, child_context)
|
||||
if isinstance(child_result, list):
|
||||
@ -469,7 +474,8 @@ def process_element(
|
||||
|
||||
|
||||
# Handler function signatures:
|
||||
# All handlers receive (element: Tag, context: StyleContext) -> Union[Block, List[Block], None]
|
||||
# All handlers receive (element: Tag, context: StyleContext) ->
|
||||
# Union[Block, List[Block], None]
|
||||
|
||||
|
||||
def paragraph_handler(element: Tag, context: StyleContext) -> Paragraph:
|
||||
|
||||
@ -5,16 +5,22 @@ import numpy as np
|
||||
|
||||
from pyWebLayout.concrete import Page, Line, Text
|
||||
from pyWebLayout.concrete.image import RenderableImage
|
||||
from pyWebLayout.concrete.functional import LinkText, ButtonText, FormFieldText
|
||||
from pyWebLayout.concrete.functional import ButtonText, FormFieldText
|
||||
from pyWebLayout.concrete.table import TableRenderer, TableStyle
|
||||
from pyWebLayout.abstract import Paragraph, Word, Link
|
||||
from pyWebLayout.abstract import Paragraph, Word
|
||||
from pyWebLayout.abstract.block import Image as AbstractImage, PageBreak, Table
|
||||
from pyWebLayout.abstract.functional import Button, Form, FormField
|
||||
from pyWebLayout.abstract.inline import LinkedWord
|
||||
from pyWebLayout.style.concrete_style import ConcreteStyleRegistry, RenderingContext, StyleResolver
|
||||
from pyWebLayout.style import Font, Alignment
|
||||
|
||||
def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pretext: Optional[Text] = None, alignment_override: Optional['Alignment'] = None) -> Tuple[bool, Optional[int], Optional[Text]]:
|
||||
|
||||
def paragraph_layouter(paragraph: Paragraph,
|
||||
page: Page,
|
||||
start_word: int = 0,
|
||||
pretext: Optional[Text] = None,
|
||||
alignment_override: Optional['Alignment'] = None) -> Tuple[bool,
|
||||
Optional[int],
|
||||
Optional[Text]]:
|
||||
"""
|
||||
Layout a paragraph of text within a given page.
|
||||
|
||||
@ -76,7 +82,11 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
|
||||
text_align = concrete_style.text_align
|
||||
|
||||
# Apply page-level word spacing override if specified
|
||||
if hasattr(page.style, 'word_spacing') and isinstance(page.style.word_spacing, int) and page.style.word_spacing > 0:
|
||||
if hasattr(
|
||||
page.style,
|
||||
'word_spacing') and isinstance(
|
||||
page.style.word_spacing,
|
||||
int) and page.style.word_spacing > 0:
|
||||
# Add the page-level word spacing to both min and max constraints
|
||||
min_ws, max_ws = word_spacing_constraints
|
||||
word_spacing_constraints = (
|
||||
@ -112,14 +122,16 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
|
||||
# Get font metrics for boundary checking
|
||||
ascent, descent = font.font.getmetrics()
|
||||
|
||||
def create_new_line(word: Optional[Union[Word, Text]] = None, is_first_line: bool = False) -> Optional[Line]:
|
||||
def create_new_line(word: Optional[Union[Word, Text]] = None,
|
||||
is_first_line: bool = False) -> Optional[Line]:
|
||||
"""Helper function to create a new line, returns None if page is full."""
|
||||
# Check if this line's baseline and descenders would fit on the page
|
||||
if not page.can_fit_line(baseline_spacing, ascent, descent):
|
||||
return None
|
||||
|
||||
# For the first line, position it so text starts at the top boundary
|
||||
# For subsequent lines, use current y_offset which tracks baseline-to-baseline spacing
|
||||
# For subsequent lines, use current y_offset which tracks
|
||||
# baseline-to-baseline spacing
|
||||
if is_first_line:
|
||||
# Position line origin so that baseline (origin + ascent) is close to top
|
||||
# We want minimal space above the text, so origin should be at boundary
|
||||
@ -131,9 +143,9 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
|
||||
# Create a temporary Text object to calculate word width
|
||||
if word:
|
||||
temp_text = Text.from_word(word, page.draw)
|
||||
word_width = temp_text.width
|
||||
temp_text.width
|
||||
else:
|
||||
word_width = 0
|
||||
pass
|
||||
|
||||
return Line(
|
||||
spacing=word_spacing_constraints,
|
||||
@ -195,7 +207,19 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
|
||||
# Word is too wide for the line, we need to hyphenate it
|
||||
if len(word.text) >= 6:
|
||||
# Try to hyphenate the word
|
||||
splits = [(Text(pair[0], word.style, page.draw, line=current_line, source=word), Text(pair[1], word.style, page.draw, line=current_line, source=word)) for pair in word.possible_hyphenation()]
|
||||
splits = [
|
||||
(Text(
|
||||
pair[0],
|
||||
word.style,
|
||||
page.draw,
|
||||
line=current_line,
|
||||
source=word),
|
||||
Text(
|
||||
pair[1],
|
||||
word.style,
|
||||
page.draw,
|
||||
line=current_line,
|
||||
source=word)) for pair in word.possible_hyphenation()]
|
||||
if len(splits) > 0:
|
||||
# Use the first hyphenation point
|
||||
first_part, second_part = splits[0]
|
||||
@ -275,7 +299,8 @@ def image_layouter(image: AbstractImage, page: Page, max_width: Optional[int] =
|
||||
max_height = min(max_height, available_height)
|
||||
|
||||
# Calculate scaled dimensions
|
||||
scaled_width, scaled_height = image.calculate_scaled_dimensions(max_width, max_height)
|
||||
scaled_width, scaled_height = image.calculate_scaled_dimensions(
|
||||
max_width, max_height)
|
||||
|
||||
# Check if image fits on current page
|
||||
if scaled_height is None or scaled_height > available_height:
|
||||
@ -305,7 +330,10 @@ def image_layouter(image: AbstractImage, page: Page, max_width: Optional[int] =
|
||||
return True
|
||||
|
||||
|
||||
def table_layouter(table: Table, page: Page, style: Optional[TableStyle] = None) -> bool:
|
||||
def table_layouter(
|
||||
table: Table,
|
||||
page: Page,
|
||||
style: Optional[TableStyle] = None) -> bool:
|
||||
"""
|
||||
Layout a table within a given page.
|
||||
|
||||
@ -356,8 +384,17 @@ def table_layouter(table: Table, page: Page, style: Optional[TableStyle] = None)
|
||||
return True
|
||||
|
||||
|
||||
def button_layouter(button: Button, page: Page, font: Optional[Font] = None,
|
||||
padding: Tuple[int, int, int, int] = (4, 8, 4, 8)) -> Tuple[bool, str]:
|
||||
def button_layouter(button: Button,
|
||||
page: Page,
|
||||
font: Optional[Font] = None,
|
||||
padding: Tuple[int,
|
||||
int,
|
||||
int,
|
||||
int] = (4,
|
||||
8,
|
||||
4,
|
||||
8)) -> Tuple[bool,
|
||||
str]:
|
||||
"""
|
||||
Layout a button within a given page and register it for callback binding.
|
||||
|
||||
@ -539,8 +576,12 @@ class DocumentLayouter:
|
||||
style_resolver = StyleResolver(context)
|
||||
self.style_registry = ConcreteStyleRegistry(style_resolver)
|
||||
|
||||
def layout_paragraph(self, paragraph: Paragraph, start_word: int = 0,
|
||||
pretext: Optional[Text] = None) -> Tuple[bool, Optional[int], Optional[Text]]:
|
||||
def layout_paragraph(self,
|
||||
paragraph: Paragraph,
|
||||
start_word: int = 0,
|
||||
pretext: Optional[Text] = None) -> Tuple[bool,
|
||||
Optional[int],
|
||||
Optional[Text]]:
|
||||
"""
|
||||
Layout a paragraph using the paragraph_layouter.
|
||||
|
||||
@ -555,7 +596,7 @@ class DocumentLayouter:
|
||||
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,7 +32,8 @@ class RenderingPosition:
|
||||
"""
|
||||
chapter_index: int = 0 # Which chapter (based on headings)
|
||||
block_index: int = 0 # Which block within chapter
|
||||
word_index: int = 0 # Which word within block (for paragraphs)
|
||||
# Which word within block (for paragraphs)
|
||||
word_index: int = 0
|
||||
table_row: int = 0 # Which row for tables
|
||||
table_col: int = 0 # Which column for tables
|
||||
list_item_index: int = 0 # Which item for lists
|
||||
@ -72,7 +67,12 @@ class RenderingPosition:
|
||||
class ChapterInfo:
|
||||
"""Information about a chapter/section in the document"""
|
||||
|
||||
def __init__(self, title: str, level: HeadingLevel, position: RenderingPosition, block_index: int):
|
||||
def __init__(
|
||||
self,
|
||||
title: str,
|
||||
level: HeadingLevel,
|
||||
position: RenderingPosition,
|
||||
block_index: int):
|
||||
self.title = title
|
||||
self.level = level
|
||||
self.position = position
|
||||
@ -130,9 +130,11 @@ class ChapterNavigator:
|
||||
words.append(word.text)
|
||||
return " ".join(words)
|
||||
|
||||
def get_table_of_contents(self) -> List[Tuple[str, HeadingLevel, RenderingPosition]]:
|
||||
def get_table_of_contents(
|
||||
self) -> List[Tuple[str, HeadingLevel, RenderingPosition]]:
|
||||
"""Generate table of contents from heading structure"""
|
||||
return [(chapter.title, chapter.level, chapter.position) for chapter in self.chapters]
|
||||
return [(chapter.title, chapter.level, chapter.position)
|
||||
for chapter in self.chapters]
|
||||
|
||||
def get_chapter_position(self, chapter_title: str) -> Optional[RenderingPosition]:
|
||||
"""Get rendering position for a chapter by title"""
|
||||
@ -195,7 +197,8 @@ class FontScaler:
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def scale_word_spacing(spacing: Tuple[int, int], scale_factor: float) -> Tuple[int, int]:
|
||||
def scale_word_spacing(spacing: Tuple[int, int],
|
||||
scale_factor: float) -> Tuple[int, int]:
|
||||
"""Scale word spacing constraints proportionally"""
|
||||
if scale_factor == 1.0:
|
||||
return spacing
|
||||
@ -213,14 +216,21 @@ class BidirectionalLayouter:
|
||||
Handles font scaling and maintains position state.
|
||||
"""
|
||||
|
||||
def __init__(self, blocks: List[Block], page_style: PageStyle, page_size: Tuple[int, int] = (800, 600), alignment_override=None):
|
||||
def __init__(self,
|
||||
blocks: List[Block],
|
||||
page_style: PageStyle,
|
||||
page_size: Tuple[int,
|
||||
int] = (800,
|
||||
600),
|
||||
alignment_override=None):
|
||||
self.blocks = blocks
|
||||
self.page_style = page_style
|
||||
self.page_size = page_size
|
||||
self.chapter_navigator = ChapterNavigator(blocks)
|
||||
self.alignment_override = alignment_override
|
||||
|
||||
def render_page_forward(self, position: RenderingPosition, font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
|
||||
def render_page_forward(self, position: RenderingPosition,
|
||||
font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
|
||||
"""
|
||||
Render a page starting from the given position, moving forward through the document.
|
||||
|
||||
@ -246,7 +256,8 @@ class BidirectionalLayouter:
|
||||
scaled_block = self._scale_block_fonts(block, font_scale)
|
||||
|
||||
# Try to fit the block on the current page
|
||||
success, new_pos = self._layout_block_on_page(scaled_block, page, current_pos, font_scale)
|
||||
success, new_pos = self._layout_block_on_page(
|
||||
scaled_block, page, current_pos, font_scale)
|
||||
|
||||
if not success:
|
||||
# Block doesn't fit, we're done with this page
|
||||
@ -267,7 +278,10 @@ class BidirectionalLayouter:
|
||||
|
||||
return page, current_pos
|
||||
|
||||
def render_page_backward(self, end_position: RenderingPosition, font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
|
||||
def render_page_backward(self,
|
||||
end_position: RenderingPosition,
|
||||
font_scale: float = 1.0) -> Tuple[Page,
|
||||
RenderingPosition]:
|
||||
"""
|
||||
Render a page that ends at the given position, filling backward.
|
||||
Critical for "previous page" navigation.
|
||||
@ -288,10 +302,12 @@ class BidirectionalLayouter:
|
||||
page, actual_end = self.render_page_forward(estimated_start, font_scale)
|
||||
|
||||
# If we overshot or undershot, adjust and try again
|
||||
# This is a simplified implementation - a full version would be more sophisticated
|
||||
# This is a simplified implementation - a full version would be more
|
||||
# sophisticated
|
||||
if self._position_compare(actual_end, end_position) != 0:
|
||||
# Adjust estimate and try again (simplified)
|
||||
estimated_start = self._adjust_start_estimate(estimated_start, end_position, actual_end)
|
||||
estimated_start = self._adjust_start_estimate(
|
||||
estimated_start, end_position, actual_end)
|
||||
page, actual_end = self.render_page_forward(estimated_start, font_scale)
|
||||
|
||||
return page, estimated_start
|
||||
@ -313,13 +329,20 @@ class BidirectionalLayouter:
|
||||
# words_iter() returns tuples of (position, word)
|
||||
for position, word in block.words_iter():
|
||||
if isinstance(word, Word):
|
||||
scaled_word = Word(word.text, FontScaler.scale_font(word.style, font_scale))
|
||||
scaled_word = Word(
|
||||
word.text, FontScaler.scale_font(
|
||||
word.style, font_scale))
|
||||
scaled_block.add_word(scaled_word)
|
||||
return scaled_block
|
||||
|
||||
return block
|
||||
|
||||
def _layout_block_on_page(self, block: Block, page: Page, position: RenderingPosition, font_scale: float) -> Tuple[bool, RenderingPosition]:
|
||||
def _layout_block_on_page(self,
|
||||
block: Block,
|
||||
page: Page,
|
||||
position: RenderingPosition,
|
||||
font_scale: float) -> Tuple[bool,
|
||||
RenderingPosition]:
|
||||
"""
|
||||
Try to layout a block on the page starting from the given position.
|
||||
|
||||
@ -340,7 +363,12 @@ class BidirectionalLayouter:
|
||||
new_pos.block_index += 1
|
||||
return True, new_pos
|
||||
|
||||
def _layout_paragraph_on_page(self, paragraph: Paragraph, page: Page, position: RenderingPosition, font_scale: float) -> Tuple[bool, RenderingPosition]:
|
||||
def _layout_paragraph_on_page(self,
|
||||
paragraph: Paragraph,
|
||||
page: Page,
|
||||
position: RenderingPosition,
|
||||
font_scale: float) -> Tuple[bool,
|
||||
RenderingPosition]:
|
||||
"""
|
||||
Layout a paragraph on the page using the core paragraph_layouter.
|
||||
Integrates font scaling and position tracking with the proven layout logic.
|
||||
@ -402,12 +430,22 @@ class BidirectionalLayouter:
|
||||
# This shouldn't normally happen, but handle it gracefully
|
||||
return False, position
|
||||
|
||||
def _layout_heading_on_page(self, heading: Heading, page: Page, position: RenderingPosition, font_scale: float) -> Tuple[bool, RenderingPosition]:
|
||||
def _layout_heading_on_page(self,
|
||||
heading: Heading,
|
||||
page: Page,
|
||||
position: RenderingPosition,
|
||||
font_scale: float) -> Tuple[bool,
|
||||
RenderingPosition]:
|
||||
"""Layout a heading on the page"""
|
||||
# Similar to paragraph but with heading-specific styling
|
||||
return self._layout_paragraph_on_page(heading, page, position, font_scale)
|
||||
|
||||
def _layout_table_on_page(self, table: Table, page: Page, position: RenderingPosition, font_scale: float) -> Tuple[bool, RenderingPosition]:
|
||||
def _layout_table_on_page(self,
|
||||
table: Table,
|
||||
page: Page,
|
||||
position: RenderingPosition,
|
||||
font_scale: float) -> Tuple[bool,
|
||||
RenderingPosition]:
|
||||
"""Layout a table on the page with column fitting and row continuation"""
|
||||
# This is a complex operation that would need full table layout logic
|
||||
# For now, skip tables
|
||||
@ -417,7 +455,12 @@ class BidirectionalLayouter:
|
||||
new_pos.table_col = 0
|
||||
return True, new_pos
|
||||
|
||||
def _layout_list_on_page(self, hlist: HList, page: Page, position: RenderingPosition, font_scale: float) -> Tuple[bool, RenderingPosition]:
|
||||
def _layout_list_on_page(self,
|
||||
hlist: HList,
|
||||
page: Page,
|
||||
position: RenderingPosition,
|
||||
font_scale: float) -> Tuple[bool,
|
||||
RenderingPosition]:
|
||||
"""Layout a list on the page"""
|
||||
# This would need list-specific layout logic
|
||||
# For now, skip lists
|
||||
@ -426,19 +469,28 @@ class BidirectionalLayouter:
|
||||
new_pos.list_item_index = 0
|
||||
return True, new_pos
|
||||
|
||||
def _estimate_page_start(self, end_position: RenderingPosition, font_scale: float) -> RenderingPosition:
|
||||
def _estimate_page_start(
|
||||
self,
|
||||
end_position: RenderingPosition,
|
||||
font_scale: float) -> RenderingPosition:
|
||||
"""Estimate where a page should start to end at the given position"""
|
||||
# This is a simplified heuristic - a full implementation would be more sophisticated
|
||||
# This is a simplified heuristic - a full implementation would be more
|
||||
# sophisticated
|
||||
estimated_start = end_position.copy()
|
||||
|
||||
# Move back by an estimated number of blocks that would fit on a page
|
||||
estimated_blocks_per_page = max(1, int(10 / font_scale)) # Rough estimate
|
||||
estimated_start.block_index = max(0, end_position.block_index - estimated_blocks_per_page)
|
||||
estimated_start.block_index = max(
|
||||
0, end_position.block_index - estimated_blocks_per_page)
|
||||
estimated_start.word_index = 0
|
||||
|
||||
return estimated_start
|
||||
|
||||
def _adjust_start_estimate(self, current_start: RenderingPosition, target_end: RenderingPosition, actual_end: RenderingPosition) -> RenderingPosition:
|
||||
def _adjust_start_estimate(
|
||||
self,
|
||||
current_start: RenderingPosition,
|
||||
target_end: RenderingPosition,
|
||||
actual_end: RenderingPosition) -> RenderingPosition:
|
||||
"""Adjust start position estimate based on overshoot/undershoot"""
|
||||
# Simplified adjustment logic
|
||||
adjusted = current_start.copy()
|
||||
@ -451,7 +503,8 @@ class BidirectionalLayouter:
|
||||
|
||||
return adjusted
|
||||
|
||||
def _position_compare(self, pos1: RenderingPosition, pos2: RenderingPosition) -> int:
|
||||
def _position_compare(self, pos1: RenderingPosition,
|
||||
pos2: RenderingPosition) -> int:
|
||||
"""Compare two positions (-1: pos1 < pos2, 0: equal, 1: pos1 > pos2)"""
|
||||
if pos1.chapter_index != pos2.chapter_index:
|
||||
return 1 if pos1.chapter_index > pos2.chapter_index else -1
|
||||
@ -481,5 +534,6 @@ def _add_page_methods():
|
||||
|
||||
Page.available_width = available_width
|
||||
|
||||
|
||||
# Apply the page methods
|
||||
_add_page_methods()
|
||||
|
||||
@ -9,7 +9,6 @@ into a unified, easy-to-use API.
|
||||
from __future__ import annotations
|
||||
from typing import List, Dict, Optional, Tuple, Any, Callable
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from .ereader_layout import RenderingPosition, ChapterNavigator, ChapterInfo
|
||||
@ -201,14 +200,18 @@ class EreaderLayoutManager:
|
||||
self.current_position = saved_position
|
||||
|
||||
# Callbacks for UI updates
|
||||
self.position_changed_callback: Optional[Callable[[RenderingPosition], None]] = None
|
||||
self.chapter_changed_callback: Optional[Callable[[Optional[ChapterInfo]], None]] = None
|
||||
self.position_changed_callback: Optional[Callable[[
|
||||
RenderingPosition], None]] = None
|
||||
self.chapter_changed_callback: Optional[Callable[[
|
||||
Optional[ChapterInfo]], None]] = None
|
||||
|
||||
def set_position_changed_callback(self, callback: Callable[[RenderingPosition], None]):
|
||||
def set_position_changed_callback(
|
||||
self, callback: Callable[[RenderingPosition], None]):
|
||||
"""Set callback for position changes"""
|
||||
self.position_changed_callback = callback
|
||||
|
||||
def set_chapter_changed_callback(self, callback: Callable[[Optional[ChapterInfo]], None]):
|
||||
def set_chapter_changed_callback(
|
||||
self, callback: Callable[[Optional[ChapterInfo]], None]):
|
||||
"""Set callback for chapter changes"""
|
||||
self.chapter_changed_callback = callback
|
||||
|
||||
@ -218,7 +221,8 @@ class EreaderLayoutManager:
|
||||
self.position_changed_callback(self.current_position)
|
||||
|
||||
# Check if chapter changed
|
||||
current_chapter = self.chapter_navigator.get_current_chapter(self.current_position)
|
||||
current_chapter = self.chapter_navigator.get_current_chapter(
|
||||
self.current_position)
|
||||
if self.chapter_changed_callback:
|
||||
self.chapter_changed_callback(current_chapter)
|
||||
|
||||
@ -242,7 +246,8 @@ class EreaderLayoutManager:
|
||||
Returns:
|
||||
Next page or None if at end of document
|
||||
"""
|
||||
page, next_position = self.renderer.render_page(self.current_position, self.font_scale)
|
||||
page, next_position = self.renderer.render_page(
|
||||
self.current_position, self.font_scale)
|
||||
|
||||
# Check if we made progress
|
||||
if next_position != self.current_position:
|
||||
@ -263,7 +268,8 @@ class EreaderLayoutManager:
|
||||
return None
|
||||
|
||||
# Use backward rendering to find the previous page
|
||||
page, start_position = self.renderer.render_page_backward(self.current_position, self.font_scale)
|
||||
page, start_position = self.renderer.render_page_backward(
|
||||
self.current_position, self.font_scale)
|
||||
|
||||
if start_position != self.current_position:
|
||||
self.current_position = start_position
|
||||
@ -397,7 +403,8 @@ class EreaderLayoutManager:
|
||||
Returns:
|
||||
Re-rendered page with decreased block spacing
|
||||
"""
|
||||
self.page_style.inter_block_spacing = max(0, self.page_style.inter_block_spacing - amount)
|
||||
self.page_style.inter_block_spacing = max(
|
||||
0, self.page_style.inter_block_spacing - amount)
|
||||
self.renderer.page_style = self.page_style # Update renderer's reference
|
||||
self.renderer.buffer.invalidate_all() # Clear cache to force re-render
|
||||
return self.get_current_page()
|
||||
@ -432,7 +439,8 @@ class EreaderLayoutManager:
|
||||
self.renderer.buffer.invalidate_all() # Clear cache to force re-render
|
||||
return self.get_current_page()
|
||||
|
||||
def get_table_of_contents(self) -> List[Tuple[str, HeadingLevel, RenderingPosition]]:
|
||||
def get_table_of_contents(
|
||||
self) -> List[Tuple[str, HeadingLevel, RenderingPosition]]:
|
||||
"""
|
||||
Get the table of contents.
|
||||
|
||||
@ -567,9 +575,9 @@ class EreaderLayoutManager:
|
||||
|
||||
# Convenience function for quick setup
|
||||
def create_ereader_manager(blocks: List[Block],
|
||||
page_size: Tuple[int, int],
|
||||
document_id: str = "default",
|
||||
**kwargs) -> EreaderLayoutManager:
|
||||
page_size: Tuple[int, int],
|
||||
document_id: str = "default",
|
||||
**kwargs) -> EreaderLayoutManager:
|
||||
"""
|
||||
Convenience function to create an ereader manager with sensible defaults.
|
||||
|
||||
|
||||
@ -8,12 +8,9 @@ multiprocessing to achieve sub-second page navigation performance.
|
||||
from __future__ import annotations
|
||||
from typing import Dict, Optional, List, Tuple, Any
|
||||
from collections import OrderedDict
|
||||
import multiprocessing
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed, Future
|
||||
from concurrent.futures import ProcessPoolExecutor, Future
|
||||
import threading
|
||||
import time
|
||||
import pickle
|
||||
from dataclasses import asdict
|
||||
|
||||
from .ereader_layout import RenderingPosition, BidirectionalLayouter
|
||||
from pyWebLayout.concrete.page import Page
|
||||
@ -21,7 +18,13 @@ from pyWebLayout.abstract.block import Block
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
|
||||
|
||||
def _render_page_worker(args: Tuple[List[Block], PageStyle, RenderingPosition, float, bool]) -> Tuple[RenderingPosition, bytes, RenderingPosition]:
|
||||
def _render_page_worker(args: Tuple[List[Block],
|
||||
PageStyle,
|
||||
RenderingPosition,
|
||||
float,
|
||||
bool]) -> Tuple[RenderingPosition,
|
||||
bytes,
|
||||
RenderingPosition]:
|
||||
"""
|
||||
Worker function for multiprocess page rendering.
|
||||
|
||||
@ -68,8 +71,10 @@ class PageBuffer:
|
||||
self.backward_buffer: OrderedDict[RenderingPosition, Page] = OrderedDict()
|
||||
|
||||
# Position tracking for next/previous positions
|
||||
self.position_map: Dict[RenderingPosition, RenderingPosition] = {} # current -> next
|
||||
self.reverse_position_map: Dict[RenderingPosition, RenderingPosition] = {} # current -> previous
|
||||
self.position_map: Dict[RenderingPosition,
|
||||
RenderingPosition] = {} # current -> next
|
||||
self.reverse_position_map: Dict[RenderingPosition,
|
||||
RenderingPosition] = {} # current -> previous
|
||||
|
||||
# Background rendering
|
||||
self.executor: Optional[ProcessPoolExecutor] = None
|
||||
@ -81,7 +86,11 @@ class PageBuffer:
|
||||
self.page_style: Optional[PageStyle] = None
|
||||
self.current_font_scale: float = 1.0
|
||||
|
||||
def initialize(self, blocks: List[Block], page_style: PageStyle, font_scale: float = 1.0):
|
||||
def initialize(
|
||||
self,
|
||||
blocks: List[Block],
|
||||
page_style: PageStyle,
|
||||
font_scale: float = 1.0):
|
||||
"""
|
||||
Initialize the buffer with document blocks and page style.
|
||||
|
||||
@ -124,7 +133,12 @@ class PageBuffer:
|
||||
|
||||
return None
|
||||
|
||||
def cache_page(self, position: RenderingPosition, page: Page, next_position: Optional[RenderingPosition] = None, is_backward: bool = False):
|
||||
def cache_page(
|
||||
self,
|
||||
position: RenderingPosition,
|
||||
page: Page,
|
||||
next_position: Optional[RenderingPosition] = None,
|
||||
is_backward: bool = False):
|
||||
"""
|
||||
Cache a rendered page with LRU eviction.
|
||||
|
||||
@ -153,7 +167,10 @@ class PageBuffer:
|
||||
self.position_map.pop(oldest_pos, None)
|
||||
self.reverse_position_map.pop(oldest_pos, None)
|
||||
|
||||
def start_background_rendering(self, current_position: RenderingPosition, direction: str = 'forward'):
|
||||
def start_background_rendering(
|
||||
self,
|
||||
current_position: RenderingPosition,
|
||||
direction: str = 'forward'):
|
||||
"""
|
||||
Start background rendering of upcoming pages.
|
||||
|
||||
@ -185,11 +202,17 @@ class PageBuffer:
|
||||
continue
|
||||
|
||||
# Queue render job
|
||||
args = (self.blocks, self.page_style, current_pos, self.current_font_scale, False)
|
||||
args = (
|
||||
self.blocks,
|
||||
self.page_style,
|
||||
current_pos,
|
||||
self.current_font_scale,
|
||||
False)
|
||||
future = self.executor.submit(_render_page_worker, args)
|
||||
self.pending_renders[current_pos] = future
|
||||
|
||||
# We don't know the next position yet, so we'll update it when the render completes
|
||||
# We don't know the next position yet, so we'll update it when the render
|
||||
# completes
|
||||
break
|
||||
|
||||
def _queue_backward_renders(self, start_position: RenderingPosition):
|
||||
@ -206,11 +229,17 @@ class PageBuffer:
|
||||
continue
|
||||
|
||||
# Queue render job
|
||||
args = (self.blocks, self.page_style, current_pos, self.current_font_scale, True)
|
||||
args = (
|
||||
self.blocks,
|
||||
self.page_style,
|
||||
current_pos,
|
||||
self.current_font_scale,
|
||||
True)
|
||||
future = self.executor.submit(_render_page_worker, args)
|
||||
self.pending_renders[current_pos] = future
|
||||
|
||||
# We don't know the previous position yet, so we'll update it when the render completes
|
||||
# We don't know the previous position yet, so we'll update it when the
|
||||
# render completes
|
||||
break
|
||||
|
||||
def check_completed_renders(self):
|
||||
@ -303,7 +332,13 @@ class BufferedPageRenderer:
|
||||
High-level interface for buffered page rendering with automatic background caching.
|
||||
"""
|
||||
|
||||
def __init__(self, blocks: List[Block], page_style: PageStyle, buffer_size: int = 5, page_size: Tuple[int, int] = (800, 600)):
|
||||
def __init__(self,
|
||||
blocks: List[Block],
|
||||
page_style: PageStyle,
|
||||
buffer_size: int = 5,
|
||||
page_size: Tuple[int,
|
||||
int] = (800,
|
||||
600)):
|
||||
"""
|
||||
Initialize the buffered renderer.
|
||||
|
||||
@ -320,7 +355,8 @@ class BufferedPageRenderer:
|
||||
self.current_position = RenderingPosition()
|
||||
self.font_scale = 1.0
|
||||
|
||||
def render_page(self, position: RenderingPosition, font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
|
||||
def render_page(self, position: RenderingPosition,
|
||||
font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
|
||||
"""
|
||||
Render a page with intelligent caching.
|
||||
|
||||
@ -361,7 +397,10 @@ class BufferedPageRenderer:
|
||||
|
||||
return page, next_pos
|
||||
|
||||
def render_page_backward(self, end_position: RenderingPosition, font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
|
||||
def render_page_backward(self,
|
||||
end_position: RenderingPosition,
|
||||
font_scale: float = 1.0) -> Tuple[Page,
|
||||
RenderingPosition]:
|
||||
"""
|
||||
Render a page ending at the given position with intelligent caching.
|
||||
|
||||
|
||||
@ -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
|
||||
@ -50,7 +51,6 @@ class FontSize(Enum):
|
||||
|
||||
|
||||
# Import Alignment from the centralized location
|
||||
from .alignment import Alignment
|
||||
|
||||
# Use Alignment for text alignment
|
||||
TextAlign = Alignment
|
||||
@ -192,7 +192,8 @@ class AbstractStyleRegistry:
|
||||
def __init__(self):
|
||||
"""Initialize an empty abstract style registry."""
|
||||
self._styles: Dict[str, AbstractStyle] = {}
|
||||
self._style_to_id: Dict[AbstractStyle, str] = {} # Reverse mapping using hashable styles
|
||||
# Reverse mapping using hashable styles
|
||||
self._style_to_id: Dict[AbstractStyle, str] = {}
|
||||
self._next_id = 1
|
||||
|
||||
# Create and register the default style
|
||||
@ -229,7 +230,10 @@ class AbstractStyleRegistry:
|
||||
"""
|
||||
return self._style_to_id.get(style)
|
||||
|
||||
def register_style(self, style: AbstractStyle, style_id: Optional[str] = None) -> str:
|
||||
def register_style(
|
||||
self,
|
||||
style: AbstractStyle,
|
||||
style_id: Optional[str] = None) -> str:
|
||||
"""
|
||||
Register a style in the registry.
|
||||
|
||||
@ -253,9 +257,9 @@ class AbstractStyleRegistry:
|
||||
return style_id
|
||||
|
||||
def get_or_create_style(self,
|
||||
style: Optional[AbstractStyle] = None,
|
||||
parent_id: Optional[str] = None,
|
||||
**kwargs) -> Tuple[str, AbstractStyle]:
|
||||
style: Optional[AbstractStyle] = None,
|
||||
parent_id: Optional[str] = None,
|
||||
**kwargs) -> Tuple[str, AbstractStyle]:
|
||||
"""
|
||||
Get an existing style or create a new one.
|
||||
|
||||
@ -288,7 +292,8 @@ class AbstractStyleRegistry:
|
||||
"""Get a style by its ID."""
|
||||
return self._styles.get(style_id)
|
||||
|
||||
def create_derived_style(self, base_style_id: str, **modifications) -> Tuple[str, AbstractStyle]:
|
||||
def create_derived_style(self, base_style_id: str, **
|
||||
modifications) -> Tuple[str, AbstractStyle]:
|
||||
"""
|
||||
Create a new style derived from a base style.
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ This module provides alignment-related functionality.
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class Alignment(Enum):
|
||||
"""Text and box alignment options"""
|
||||
# Horizontal alignment
|
||||
|
||||
@ -5,12 +5,11 @@ This module converts abstract styles to concrete rendering parameters based on
|
||||
user preferences, device capabilities, and rendering context.
|
||||
"""
|
||||
|
||||
from typing import Dict, Optional, Tuple, Union, Any
|
||||
from typing import Dict, Optional, Tuple, Union
|
||||
from dataclasses import dataclass
|
||||
from .abstract_style import AbstractStyle, FontFamily, FontSize
|
||||
from pyWebLayout.style.alignment import Alignment as TextAlign
|
||||
from .fonts import Font, FontWeight, FontStyle, TextDecoration
|
||||
import os
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@ -162,12 +161,17 @@ class StyleResolver:
|
||||
# Ensure font_size is always an int before using in arithmetic
|
||||
font_size = int(font_size)
|
||||
color = self._resolve_color(abstract_style.color)
|
||||
background_color = self._resolve_background_color(abstract_style.background_color)
|
||||
background_color = self._resolve_background_color(
|
||||
abstract_style.background_color)
|
||||
line_height = self._resolve_line_height(abstract_style.line_height)
|
||||
letter_spacing = self._resolve_letter_spacing(abstract_style.letter_spacing, font_size)
|
||||
word_spacing = self._resolve_word_spacing(abstract_style.word_spacing, font_size)
|
||||
word_spacing_min = self._resolve_word_spacing(abstract_style.word_spacing_min, font_size)
|
||||
word_spacing_max = self._resolve_word_spacing(abstract_style.word_spacing_max, font_size)
|
||||
letter_spacing = self._resolve_letter_spacing(
|
||||
abstract_style.letter_spacing, font_size)
|
||||
word_spacing = self._resolve_word_spacing(
|
||||
abstract_style.word_spacing, font_size)
|
||||
word_spacing_min = self._resolve_word_spacing(
|
||||
abstract_style.word_spacing_min, font_size)
|
||||
word_spacing_max = self._resolve_word_spacing(
|
||||
abstract_style.word_spacing_max, font_size)
|
||||
min_hyphenation_width = max(int(font_size) * 4, 32) # At least 32 pixels
|
||||
|
||||
# Apply default logic for word spacing constraints
|
||||
@ -251,7 +255,8 @@ class StyleResolver:
|
||||
# Ensure we always return an int, minimum 8pt font
|
||||
return max(int(final_size), 8)
|
||||
|
||||
def _resolve_color(self, color: Union[str, Tuple[int, int, int]]) -> Tuple[int, int, int]:
|
||||
def _resolve_color(
|
||||
self, color: Union[str, Tuple[int, int, int]]) -> Tuple[int, int, int]:
|
||||
"""Resolve color to RGB tuple."""
|
||||
if isinstance(color, tuple):
|
||||
return color
|
||||
@ -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)
|
||||
@ -293,7 +298,15 @@ class StyleResolver:
|
||||
|
||||
return (0, 0, 0) # Fallback to black
|
||||
|
||||
def _resolve_background_color(self, bg_color: Optional[Union[str, Tuple[int, int, int, int]]]) -> Optional[Tuple[int, int, int, int]]:
|
||||
def _resolve_background_color(self,
|
||||
bg_color: Optional[Union[str,
|
||||
Tuple[int,
|
||||
int,
|
||||
int,
|
||||
int]]]) -> Optional[Tuple[int,
|
||||
int,
|
||||
int,
|
||||
int]]:
|
||||
"""Resolve background color to RGBA tuple or None."""
|
||||
if bg_color is None:
|
||||
return None
|
||||
@ -330,7 +343,8 @@ class StyleResolver:
|
||||
|
||||
return 1.2
|
||||
|
||||
def _resolve_letter_spacing(self, letter_spacing: Optional[Union[str, float]], font_size: int) -> float:
|
||||
def _resolve_letter_spacing(
|
||||
self, letter_spacing: Optional[Union[str, float]], font_size: int) -> float:
|
||||
"""Resolve letter spacing to pixels."""
|
||||
if letter_spacing is None or letter_spacing == "normal":
|
||||
return 0.0
|
||||
@ -353,7 +367,8 @@ class StyleResolver:
|
||||
|
||||
return 0.0
|
||||
|
||||
def _resolve_word_spacing(self, word_spacing: Optional[Union[str, float]], font_size: int) -> float:
|
||||
def _resolve_word_spacing(
|
||||
self, word_spacing: Optional[Union[str, float]], font_size: int) -> float:
|
||||
"""Resolve word spacing to pixels."""
|
||||
if word_spacing is None or word_spacing == "normal":
|
||||
return 0.0
|
||||
|
||||
@ -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
|
||||
|
||||
@ -39,7 +40,7 @@ class Font:
|
||||
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.
|
||||
@ -79,7 +80,9 @@ class Font:
|
||||
logger.debug(f"Font loading: current_dir = {current_dir}")
|
||||
logger.debug(f"Font loading: assets_dir = {assets_dir}")
|
||||
logger.debug(f"Font loading: bundled_font_path = {bundled_font_path}")
|
||||
logger.debug(f"Font loading: bundled font exists = {os.path.exists(bundled_font_path)}")
|
||||
logger.debug(
|
||||
f"Font loading: bundled font exists = {
|
||||
os.path.exists(bundled_font_path)}")
|
||||
|
||||
if os.path.exists(bundled_font_path):
|
||||
logger.info(f"Found bundled font at: {bundled_font_path}")
|
||||
@ -106,10 +109,14 @@ class Font:
|
||||
if bundled_font_path:
|
||||
logger.info(f"Loading bundled font from: {bundled_font_path}")
|
||||
self._font = ImageFont.truetype(bundled_font_path, self._font_size)
|
||||
logger.info(f"Successfully loaded bundled font at size {self._font_size}")
|
||||
logger.info(
|
||||
f"Successfully loaded bundled font at size {
|
||||
self._font_size}")
|
||||
else:
|
||||
# Only fall back to PIL's default font if bundled font is not available
|
||||
logger.warning(f"Bundled font not available, falling back to PIL default font")
|
||||
# Only fall back to PIL's default font if bundled font is not
|
||||
# available
|
||||
logger.warning(
|
||||
"Bundled font not available, falling back to PIL default font")
|
||||
self._font = ImageFont.load_default()
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@ -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,15 +5,16 @@ Debug script to test text positioning in the line breaking system
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
# Add pyWebLayout to path
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from pyWebLayout.style import Font
|
||||
from pyWebLayout.concrete.text import Text, Line
|
||||
from pyWebLayout.style.layout import Alignment
|
||||
|
||||
# Add pyWebLayout to path
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
|
||||
def test_simple_text_rendering():
|
||||
"""Test basic text rendering to debug positioning issues"""
|
||||
|
||||
@ -70,5 +71,6 @@ def test_simple_text_rendering():
|
||||
image.save("debug_text_positioning.png")
|
||||
print("Debug image saved as debug_text_positioning.png")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_simple_text_rendering()
|
||||
|
||||
@ -15,7 +15,7 @@ import os
|
||||
import sys
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
from typing import List
|
||||
|
||||
# Add the parent directory to sys.path to import pyWebLayout
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
@ -23,10 +23,8 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
try:
|
||||
from pyWebLayout.io.readers.epub_reader import read_epub
|
||||
from pyWebLayout.layout.ereader_layout import BidirectionalLayouter, RenderingPosition
|
||||
from pyWebLayout.layout.document_layouter import paragraph_layouter
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from pyWebLayout.style.fonts import Font
|
||||
from pyWebLayout.abstract.block import Block
|
||||
from PIL import Image, ImageDraw
|
||||
except ImportError as e:
|
||||
@ -77,7 +75,7 @@ def extract_text_from_page(page: Page) -> str:
|
||||
String containing the page's text content
|
||||
"""
|
||||
text_lines = []
|
||||
text_lines.append(f"=== PAGE CONTENT ===")
|
||||
text_lines.append("=== PAGE CONTENT ===")
|
||||
text_lines.append("")
|
||||
|
||||
try:
|
||||
@ -115,7 +113,7 @@ def extract_text_from_page(page: Page) -> str:
|
||||
item_text = extract_text_from_paragraph(item)
|
||||
if item_text:
|
||||
text_lines.append(f"{indent} - {item_text}")
|
||||
except:
|
||||
except Exception:
|
||||
text_lines.append(f"{indent} (List content extraction failed)")
|
||||
|
||||
elif isinstance(element, Table):
|
||||
@ -310,7 +308,6 @@ Examples:
|
||||
return 1
|
||||
|
||||
# Apply alignment setting to all paragraphs and headings
|
||||
from pyWebLayout.abstract.block import Paragraph, Heading
|
||||
from pyWebLayout.style.alignment import Alignment
|
||||
|
||||
alignment = Alignment.JUSTIFY if args.align == 'justify' else Alignment.LEFT
|
||||
|
||||
@ -23,14 +23,12 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
try:
|
||||
from pyWebLayout.io.readers.epub_reader import read_epub
|
||||
from pyWebLayout.layout.document_layouter import DocumentLayouter, paragraph_layouter, image_layouter, pagebreak_layouter
|
||||
from pyWebLayout.layout.document_layouter import paragraph_layouter, image_layouter, pagebreak_layouter
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from pyWebLayout.style.fonts import Font
|
||||
from pyWebLayout.style.alignment import Alignment
|
||||
from pyWebLayout.abstract.block import Block, Paragraph, Heading, HList, Table, Image as AbstractImage, PageBreak
|
||||
from pyWebLayout.style.concrete_style import RenderingContext, StyleResolver
|
||||
from PIL import Image, ImageDraw
|
||||
from pyWebLayout.style.concrete_style import RenderingContext
|
||||
except ImportError as e:
|
||||
print(f"Error importing required modules: {e}")
|
||||
print("Make sure pyWebLayout is properly installed and PIL is available")
|
||||
@ -99,7 +97,7 @@ def layout_blocks_on_pages(blocks: List[Block], page_style: PageStyle,
|
||||
continuation_pretext = None
|
||||
|
||||
# Create rendering context
|
||||
rendering_context = RenderingContext(base_font_size=16)
|
||||
_rendering_context = RenderingContext(base_font_size=16)
|
||||
|
||||
while current_block_index < len(blocks) and len(pages) < max_pages:
|
||||
# Create a new page
|
||||
@ -194,7 +192,7 @@ def layout_blocks_on_pages(blocks: List[Block], page_style: PageStyle,
|
||||
|
||||
elif isinstance(block, Table):
|
||||
# Skip tables for now (not implemented)
|
||||
print(f"Warning: Skipping table (not yet implemented)")
|
||||
print("Warning: Skipping table (not yet implemented)")
|
||||
current_block_index += 1
|
||||
|
||||
else:
|
||||
|
||||
@ -5,7 +5,6 @@ Runs test and documentation coverage locally and generates badges.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
|
||||
|
||||
@ -53,7 +52,10 @@ def main():
|
||||
|
||||
# Run tests with coverage
|
||||
print("\n2. Running tests with coverage...")
|
||||
test_cmd = "python -m pytest tests/ -v --cov=pyWebLayout --cov-report=term-missing --cov-report=json --cov-report=html --cov-report=xml"
|
||||
test_cmd = (
|
||||
"python -m pytest tests/ -v --cov=pyWebLayout "
|
||||
"--cov-report=term-missing --cov-report=json --cov-report=html --cov-report=xml"
|
||||
)
|
||||
run_command(test_cmd, "Running tests with coverage")
|
||||
|
||||
# Generate test coverage badge
|
||||
@ -62,7 +64,11 @@ def main():
|
||||
|
||||
# Check documentation coverage
|
||||
print("\n4. Checking documentation coverage...")
|
||||
docs_cmd = "interrogate -v --ignore-init-method --ignore-init-module --ignore-magic --ignore-private --ignore-property-decorators --ignore-semiprivate --fail-under=80 --generate-badge coverage-docs.svg pyWebLayout/"
|
||||
docs_cmd = (
|
||||
"interrogate -v --ignore-init-method --ignore-init-module --ignore-magic "
|
||||
"--ignore-private --ignore-property-decorators --ignore-semiprivate "
|
||||
"--fail-under=80 --generate-badge coverage-docs.svg pyWebLayout/"
|
||||
)
|
||||
run_command(docs_cmd, "Checking documentation coverage")
|
||||
|
||||
# Generate coverage summary
|
||||
@ -93,7 +99,7 @@ else:
|
||||
with open('temp_coverage_summary.py', 'w') as f:
|
||||
f.write(summary_script_content)
|
||||
|
||||
success = run_command("python temp_coverage_summary.py", "Generating coverage summary")
|
||||
run_command("python temp_coverage_summary.py", "Generating coverage summary")
|
||||
|
||||
# Clean up temporary script
|
||||
if os.path.exists('temp_coverage_summary.py'):
|
||||
|
||||
@ -29,7 +29,7 @@ def main():
|
||||
]
|
||||
|
||||
print(f"Running: {' '.join(cmd)}")
|
||||
result = subprocess.run(cmd, check=True)
|
||||
_result = subprocess.run(cmd, check=True)
|
||||
|
||||
# Check if coverage.xml was created
|
||||
if os.path.exists("coverage.xml"):
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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
|
||||
@ -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()
|
||||
|
||||
@ -8,7 +8,7 @@ document structure and metadata management.
|
||||
import unittest
|
||||
from pyWebLayout.abstract.document import Document, Chapter, Book, MetadataType
|
||||
from pyWebLayout.abstract.block import Paragraph, Heading, HeadingLevel, BlockType
|
||||
from pyWebLayout.abstract.inline import Word, FormattedSpan
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration
|
||||
|
||||
|
||||
@ -59,8 +59,14 @@ class TestDocument(unittest.TestCase):
|
||||
|
||||
# Test retrieval
|
||||
self.assertEqual(self.doc.get_metadata(MetadataType.AUTHOR), "John Doe")
|
||||
self.assertEqual(self.doc.get_metadata(MetadataType.DESCRIPTION), "A test document")
|
||||
self.assertEqual(self.doc.get_metadata(MetadataType.KEYWORDS), ["test", "document"])
|
||||
self.assertEqual(
|
||||
self.doc.get_metadata(
|
||||
MetadataType.DESCRIPTION),
|
||||
"A test document")
|
||||
self.assertEqual(
|
||||
self.doc.get_metadata(
|
||||
MetadataType.KEYWORDS), [
|
||||
"test", "document"])
|
||||
|
||||
# Test non-existent metadata
|
||||
self.assertIsNone(self.doc.get_metadata(MetadataType.PUBLISHER))
|
||||
@ -456,7 +462,10 @@ class TestBook(unittest.TestCase):
|
||||
|
||||
# Test metadata
|
||||
self.book.set_metadata(MetadataType.PUBLISHER, "Test Publisher")
|
||||
self.assertEqual(self.book.get_metadata(MetadataType.PUBLISHER), "Test Publisher")
|
||||
self.assertEqual(
|
||||
self.book.get_metadata(
|
||||
MetadataType.PUBLISHER),
|
||||
"Test Publisher")
|
||||
|
||||
# Test anchors
|
||||
heading = Heading(HeadingLevel.H1)
|
||||
|
||||
@ -6,7 +6,7 @@ interactive functionality and user interface elements.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
from unittest.mock import Mock
|
||||
from pyWebLayout.abstract.functional import (
|
||||
Link, LinkType, Button, Form, FormField, FormFieldType
|
||||
)
|
||||
@ -92,7 +92,8 @@ class TestLink(unittest.TestCase):
|
||||
result = link.execute()
|
||||
|
||||
# Should call callback with location, point (None when not provided), and params
|
||||
self.mock_callback.assert_called_once_with("/api/save", None, action="save", id=123)
|
||||
self.mock_callback.assert_called_once_with(
|
||||
"/api/save", None, action="save", id=123)
|
||||
self.assertEqual(result, "callback_result")
|
||||
|
||||
def test_function_link_execution(self):
|
||||
@ -471,11 +472,10 @@ class TestFormIntegration(unittest.TestCase):
|
||||
label="Email", required=True, value="test@example.com"
|
||||
))
|
||||
|
||||
form.add_field(FormField(
|
||||
"country", FormFieldType.SELECT,
|
||||
label="Country", value="US",
|
||||
options=[("US", "United States"), ("CA", "Canada"), ("UK", "United Kingdom")]
|
||||
))
|
||||
form.add_field(
|
||||
FormField(
|
||||
"country", FormFieldType.SELECT, label="Country", value="US", options=[
|
||||
("US", "United States"), ("CA", "Canada"), ("UK", "United Kingdom")]))
|
||||
|
||||
form.add_field(FormField(
|
||||
"newsletter", FormFieldType.CHECKBOX,
|
||||
|
||||
@ -6,7 +6,7 @@ and formatting within documents.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
from unittest.mock import Mock
|
||||
from pyWebLayout.abstract.inline import Word, FormattedSpan, LineBreak
|
||||
from pyWebLayout.style import Font
|
||||
|
||||
@ -28,8 +28,7 @@ class TestWord(unittest.TestCase):
|
||||
self.assertEqual(word.style, self.font)
|
||||
self.assertIsNone(word.previous)
|
||||
self.assertIsNone(word.next)
|
||||
self.assertEqual(len(word.possible_hyphenation()),0)
|
||||
|
||||
self.assertEqual(len(word.possible_hyphenation()), 0)
|
||||
|
||||
def test_word_hyphenation(self):
|
||||
"""Test word creation with minimal parameters."""
|
||||
@ -39,7 +38,7 @@ class TestWord(unittest.TestCase):
|
||||
self.assertEqual(word.style, self.font)
|
||||
self.assertIsNone(word.previous)
|
||||
self.assertIsNone(word.next)
|
||||
self.assertEqual(len(word.possible_hyphenation()),3)
|
||||
self.assertEqual(len(word.possible_hyphenation()), 3)
|
||||
|
||||
def test_word_creation_with_previous(self):
|
||||
"""Test word creation with previous word reference."""
|
||||
@ -71,7 +70,6 @@ class TestWord(unittest.TestCase):
|
||||
self.assertEqual(word2.previous, word1)
|
||||
self.assertIsNone(word2.next)
|
||||
|
||||
|
||||
def test_add_next_word(self):
|
||||
"""Test linking words with add_next method."""
|
||||
word1 = Word("first", self.font)
|
||||
@ -98,7 +96,6 @@ class TestWord(unittest.TestCase):
|
||||
word2 = Word("second", self.font, previous=word1)
|
||||
word3 = Word("third", self.font, previous=word2)
|
||||
|
||||
|
||||
# Test complete chain
|
||||
self.assertIsNone(word1.previous)
|
||||
self.assertEqual(word1.next, word2)
|
||||
@ -109,7 +106,6 @@ class TestWord(unittest.TestCase):
|
||||
self.assertEqual(word3.previous, word2)
|
||||
self.assertIsNone(word3.next)
|
||||
|
||||
|
||||
def test_word_create_and_add_to_with_style_override(self):
|
||||
"""Test Word.create_and_add_to with explicit style parameter."""
|
||||
# Create alternate font
|
||||
@ -269,7 +265,7 @@ class TestWord(unittest.TestCase):
|
||||
self.assertEqual(len(container.added_words), 1)
|
||||
self.assertEqual(container.added_words[0], word)
|
||||
|
||||
def test_word_create_and_add_to_parameter_inspection_word_object(self):
|
||||
def test_word_create_and_add_to_parameter_inspection_word_object_param(self):
|
||||
"""Test Word.create_and_add_to with add_word method that has 'word_object' parameter."""
|
||||
class WordObjectContainer:
|
||||
def __init__(self, font):
|
||||
@ -285,7 +281,8 @@ class TestWord(unittest.TestCase):
|
||||
self.assertEqual(len(container.added_words), 1)
|
||||
self.assertEqual(container.added_words[0], word)
|
||||
|
||||
def test_word_create_and_add_to_parameter_inspection_text_fallback_with_words_list(self):
|
||||
def test_word_create_and_add_to_parameter_inspection_text_fallback_with_words_list(
|
||||
self):
|
||||
"""Test Word.create_and_add_to with add_word that expects text but container has _words list."""
|
||||
class TextExpectingContainer:
|
||||
def __init__(self, font):
|
||||
@ -307,7 +304,8 @@ class TestWord(unittest.TestCase):
|
||||
# add_word should not have been called since it expects text
|
||||
self.assertEqual(len(container.add_word_calls), 0)
|
||||
|
||||
def test_word_create_and_add_to_parameter_inspection_fallback_without_words_list(self):
|
||||
def test_word_create_and_add_to_parameter_inspection_fallback_without_words_list(
|
||||
self):
|
||||
"""Test Word.create_and_add_to fallback when container doesn't have _words list."""
|
||||
class TextExpectingContainer:
|
||||
def __init__(self, font):
|
||||
@ -337,11 +335,14 @@ class TestWord(unittest.TestCase):
|
||||
|
||||
container = NoParamsContainer(self.font)
|
||||
|
||||
# The current implementation will fail when calling add_word(word) with a no-parameter method
|
||||
# The current implementation will fail when calling add_word(word) with a
|
||||
# no-parameter method
|
||||
with self.assertRaises(TypeError) as context:
|
||||
Word.create_and_add_to("test", container)
|
||||
|
||||
self.assertIn("takes 1 positional argument but 2 were given", str(context.exception))
|
||||
self.assertIn(
|
||||
"takes 1 positional argument but 2 were given", str(
|
||||
context.exception))
|
||||
|
||||
def test_word_create_and_add_to_linking_behavior_with_existing_words(self):
|
||||
"""Test Word.create_and_add_to properly links with existing words in container."""
|
||||
@ -550,13 +551,13 @@ class TestFormattedSpan(unittest.TestCase):
|
||||
if i == 0:
|
||||
self.assertIsNone(word.previous)
|
||||
else:
|
||||
self.assertEqual(word.previous, words[i-1])
|
||||
self.assertEqual(word.previous, words[i - 1])
|
||||
|
||||
# Test next link
|
||||
if i == 4:
|
||||
self.assertIsNone(word.next)
|
||||
else:
|
||||
self.assertEqual(word.next, words[i+1])
|
||||
self.assertEqual(word.next, words[i + 1])
|
||||
|
||||
def test_empty_span_operations(self):
|
||||
"""Test operations on empty formatted span."""
|
||||
@ -691,9 +692,9 @@ class TestWordFormattedSpanIntegration(unittest.TestCase):
|
||||
# Test linking
|
||||
for i in range(5):
|
||||
if i > 0:
|
||||
self.assertEqual(words[i].previous, words[i-1])
|
||||
self.assertEqual(words[i].previous, words[i - 1])
|
||||
if i < 4:
|
||||
self.assertEqual(words[i].next, words[i+1])
|
||||
self.assertEqual(words[i].next, words[i + 1])
|
||||
|
||||
def test_multiple_spans_same_style(self):
|
||||
"""Test creating multiple spans with the same style."""
|
||||
|
||||
@ -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,8 +3,8 @@ Unit tests for LinkedWord and LinkedImage classes.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from pyWebLayout.abstract.inline import Word, LinkedWord
|
||||
from pyWebLayout.abstract.block import Image, LinkedImage
|
||||
from pyWebLayout.abstract.inline import LinkedWord
|
||||
from pyWebLayout.abstract.block import LinkedImage
|
||||
from pyWebLayout.abstract.functional import LinkType
|
||||
from pyWebLayout.style import Font
|
||||
|
||||
@ -62,7 +62,7 @@ class TestLinkedWord(unittest.TestCase):
|
||||
params={"source": "test"}
|
||||
)
|
||||
|
||||
result = linked_word.execute_link()
|
||||
linked_word.execute_link()
|
||||
|
||||
self.assertEqual(len(callback_called), 1)
|
||||
self.assertEqual(callback_called[0][0], self.location)
|
||||
@ -169,7 +169,7 @@ class TestLinkedImage(unittest.TestCase):
|
||||
callback=image_callback
|
||||
)
|
||||
|
||||
result = linked_image.execute_link()
|
||||
linked_image.execute_link()
|
||||
|
||||
self.assertEqual(len(callback_called), 1)
|
||||
self.assertEqual(callback_called[0][0], self.location)
|
||||
|
||||
@ -5,14 +5,15 @@ Tests the various alignment handlers (Left, Center, Right, Justify) and their in
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import numpy as np
|
||||
from unittest.mock import Mock
|
||||
|
||||
from pyWebLayout.concrete.text import Line, Text, LeftAlignmentHandler, CenterRightAlignmentHandler, JustifyAlignmentHandler
|
||||
from pyWebLayout.concrete.text import (
|
||||
Line, Text, LeftAlignmentHandler, CenterRightAlignmentHandler, JustifyAlignmentHandler
|
||||
)
|
||||
from pyWebLayout.style import Alignment
|
||||
from pyWebLayout.style import Font
|
||||
from pyWebLayout.abstract import Word
|
||||
from PIL import Image, ImageFont, ImageDraw
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
|
||||
class TestAlignmentHandlers(unittest.TestCase):
|
||||
"""Test cases for the alignment handler system"""
|
||||
@ -20,7 +21,8 @@ class TestAlignmentHandlers(unittest.TestCase):
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
self.font = Font()
|
||||
self.test_words = [Word(text, self.font) for text in ["This", "is", "a", "test", "sentence"]]
|
||||
self.test_words = [Word(text, self.font)
|
||||
for text in ["This", "is", "a", "test", "sentence"]]
|
||||
self.line_width = 300
|
||||
self.line_height = 30
|
||||
self.spacing = (5, 20) # min_spacing, max_spacing
|
||||
@ -36,38 +38,70 @@ class TestAlignmentHandlers(unittest.TestCase):
|
||||
# Create a real Font object
|
||||
self.style = Font()
|
||||
|
||||
|
||||
def test_left_alignment_handler_assignment(self):
|
||||
"""Test that Line correctly assigns LeftAlignmentHandler for LEFT alignment"""
|
||||
left_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.LEFT)
|
||||
left_line = Line(
|
||||
self.spacing,
|
||||
self.origin,
|
||||
self.size,
|
||||
self.draw,
|
||||
font=self.style,
|
||||
halign=Alignment.LEFT)
|
||||
|
||||
self.assertIsInstance(left_line._alignment_handler, LeftAlignmentHandler)
|
||||
|
||||
def test_center_alignment_handler_assignment(self):
|
||||
"""Test that Line correctly assigns CenterRightAlignmentHandler for CENTER alignment"""
|
||||
center_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.CENTER)
|
||||
center_line = Line(
|
||||
self.spacing,
|
||||
self.origin,
|
||||
self.size,
|
||||
self.draw,
|
||||
font=self.style,
|
||||
halign=Alignment.CENTER)
|
||||
|
||||
self.assertIsInstance(center_line._alignment_handler, CenterRightAlignmentHandler)
|
||||
self.assertIsInstance(
|
||||
center_line._alignment_handler,
|
||||
CenterRightAlignmentHandler)
|
||||
# Check that it's configured for CENTER alignment
|
||||
self.assertEqual(center_line._alignment_handler._alignment, Alignment.CENTER)
|
||||
|
||||
def test_right_alignment_handler_assignment(self):
|
||||
"""Test that Line correctly assigns CenterRightAlignmentHandler for RIGHT alignment"""
|
||||
right_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.RIGHT)
|
||||
right_line = Line(
|
||||
self.spacing,
|
||||
self.origin,
|
||||
self.size,
|
||||
self.draw,
|
||||
font=self.style,
|
||||
halign=Alignment.RIGHT)
|
||||
|
||||
self.assertIsInstance(right_line._alignment_handler, CenterRightAlignmentHandler)
|
||||
self.assertIsInstance(
|
||||
right_line._alignment_handler,
|
||||
CenterRightAlignmentHandler)
|
||||
# Check that it's configured for RIGHT alignment
|
||||
self.assertEqual(right_line._alignment_handler._alignment, Alignment.RIGHT)
|
||||
|
||||
def test_justify_alignment_handler_assignment(self):
|
||||
"""Test that Line correctly assigns JustifyAlignmentHandler for JUSTIFY alignment"""
|
||||
justify_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.JUSTIFY)
|
||||
justify_line = Line(
|
||||
self.spacing,
|
||||
self.origin,
|
||||
self.size,
|
||||
self.draw,
|
||||
font=self.style,
|
||||
halign=Alignment.JUSTIFY)
|
||||
|
||||
self.assertIsInstance(justify_line._alignment_handler, JustifyAlignmentHandler)
|
||||
|
||||
def test_left_alignment_word_addition(self):
|
||||
"""Test adding words to a left-aligned line"""
|
||||
left_line = Line(self.spacing, self.origin, self.size, self.draw, halign=Alignment.LEFT)
|
||||
left_line = Line(
|
||||
self.spacing,
|
||||
self.origin,
|
||||
self.size,
|
||||
self.draw,
|
||||
halign=Alignment.LEFT)
|
||||
|
||||
# Add words until line is full or we run out
|
||||
words_added = 0
|
||||
@ -85,7 +119,13 @@ class TestAlignmentHandlers(unittest.TestCase):
|
||||
|
||||
def test_center_alignment_word_addition(self):
|
||||
"""Test adding words to a center-aligned line"""
|
||||
center_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.CENTER)
|
||||
center_line = Line(
|
||||
self.spacing,
|
||||
self.origin,
|
||||
self.size,
|
||||
self.draw,
|
||||
font=self.style,
|
||||
halign=Alignment.CENTER)
|
||||
|
||||
# Add words until line is full or we run out
|
||||
words_added = 0
|
||||
@ -103,7 +143,13 @@ class TestAlignmentHandlers(unittest.TestCase):
|
||||
|
||||
def test_right_alignment_word_addition(self):
|
||||
"""Test adding words to a right-aligned line"""
|
||||
right_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.RIGHT)
|
||||
right_line = Line(
|
||||
self.spacing,
|
||||
self.origin,
|
||||
self.size,
|
||||
self.draw,
|
||||
font=self.style,
|
||||
halign=Alignment.RIGHT)
|
||||
|
||||
# Add words until line is full or we run out
|
||||
words_added = 0
|
||||
@ -121,7 +167,13 @@ class TestAlignmentHandlers(unittest.TestCase):
|
||||
|
||||
def test_justify_alignment_word_addition(self):
|
||||
"""Test adding words to a justified line"""
|
||||
justify_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.JUSTIFY)
|
||||
justify_line = Line(
|
||||
self.spacing,
|
||||
self.origin,
|
||||
self.size,
|
||||
self.draw,
|
||||
font=self.style,
|
||||
halign=Alignment.JUSTIFY)
|
||||
|
||||
# Add words until line is full or we run out
|
||||
words_added = 0
|
||||
@ -140,7 +192,8 @@ class TestAlignmentHandlers(unittest.TestCase):
|
||||
def test_handler_spacing_and_position_calculations(self):
|
||||
"""Test spacing and position calculations for different alignment handlers"""
|
||||
# Create sample text objects
|
||||
text_objects = [Text(word, self.style, self.draw) for word in ["Hello", "World"]]
|
||||
text_objects = [Text(word, self.style, self.draw)
|
||||
for word in ["Hello", "World"]]
|
||||
|
||||
# Test each handler type
|
||||
handlers = [
|
||||
@ -173,7 +226,8 @@ class TestAlignmentHandlers(unittest.TestCase):
|
||||
def test_left_handler_spacing_calculation(self):
|
||||
"""Test specific spacing calculation for left alignment"""
|
||||
handler = LeftAlignmentHandler()
|
||||
text_objects = [Text(word, self.style, self.draw) for word in ["Hello", "World"]]
|
||||
text_objects = [Text(word, self.style, self.draw)
|
||||
for word in ["Hello", "World"]]
|
||||
|
||||
spacing_calc, position, overflow = handler.calculate_spacing_and_position(
|
||||
text_objects, self.line_width, self.spacing[0], self.spacing[1])
|
||||
@ -187,7 +241,8 @@ class TestAlignmentHandlers(unittest.TestCase):
|
||||
def test_center_handler_spacing_calculation(self):
|
||||
"""Test specific spacing calculation for center alignment"""
|
||||
handler = CenterRightAlignmentHandler(Alignment.CENTER)
|
||||
text_objects = [Text(word, self.style, self.draw) for word in ["Hello", "World"]]
|
||||
text_objects = [Text(word, self.style, self.draw)
|
||||
for word in ["Hello", "World"]]
|
||||
|
||||
spacing_calc, position, overflow = handler.calculate_spacing_and_position(
|
||||
text_objects, self.line_width, self.spacing[0], self.spacing[1])
|
||||
@ -199,7 +254,8 @@ class TestAlignmentHandlers(unittest.TestCase):
|
||||
def test_right_handler_spacing_calculation(self):
|
||||
"""Test specific spacing calculation for right alignment"""
|
||||
handler = CenterRightAlignmentHandler(Alignment.RIGHT)
|
||||
text_objects = [Text(word, self.style, self.draw) for word in ["Hello", "World"]]
|
||||
text_objects = [Text(word, self.style, self.draw)
|
||||
for word in ["Hello", "World"]]
|
||||
|
||||
spacing_calc, position, overflow = handler.calculate_spacing_and_position(
|
||||
text_objects, self.line_width, self.spacing[0], self.spacing[1])
|
||||
@ -210,7 +266,8 @@ class TestAlignmentHandlers(unittest.TestCase):
|
||||
def test_justify_handler_spacing_calculation(self):
|
||||
"""Test specific spacing calculation for justify alignment"""
|
||||
handler = JustifyAlignmentHandler()
|
||||
text_objects = [Text(word, self.style, self.draw) for word in ["Hello", "World"]]
|
||||
text_objects = [Text(word, self.style, self.draw)
|
||||
for word in ["Hello", "World"]]
|
||||
|
||||
spacing_calc, position, overflow = handler.calculate_spacing_and_position(
|
||||
text_objects, self.line_width, self.spacing[0], self.spacing[1])
|
||||
@ -223,11 +280,21 @@ class TestAlignmentHandlers(unittest.TestCase):
|
||||
|
||||
def test_empty_line_alignment_handlers(self):
|
||||
"""Test alignment handlers with empty lines"""
|
||||
alignments = [Alignment.LEFT, Alignment.CENTER, Alignment.RIGHT, Alignment.JUSTIFY]
|
||||
alignments = [
|
||||
Alignment.LEFT,
|
||||
Alignment.CENTER,
|
||||
Alignment.RIGHT,
|
||||
Alignment.JUSTIFY]
|
||||
|
||||
for alignment in alignments:
|
||||
with self.subTest(alignment=alignment):
|
||||
line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=alignment)
|
||||
line = Line(
|
||||
self.spacing,
|
||||
self.origin,
|
||||
self.size,
|
||||
self.draw,
|
||||
font=self.style,
|
||||
halign=alignment)
|
||||
|
||||
# Empty line should still have a handler
|
||||
self.assertIsNotNone(line._alignment_handler)
|
||||
@ -237,11 +304,21 @@ class TestAlignmentHandlers(unittest.TestCase):
|
||||
|
||||
def test_single_word_line_alignment(self):
|
||||
"""Test alignment handlers with single word lines"""
|
||||
alignments = [Alignment.LEFT, Alignment.CENTER, Alignment.RIGHT, Alignment.JUSTIFY]
|
||||
alignments = [
|
||||
Alignment.LEFT,
|
||||
Alignment.CENTER,
|
||||
Alignment.RIGHT,
|
||||
Alignment.JUSTIFY]
|
||||
|
||||
for alignment in alignments:
|
||||
with self.subTest(alignment=alignment):
|
||||
line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=alignment)
|
||||
line = Line(
|
||||
self.spacing,
|
||||
self.origin,
|
||||
self.size,
|
||||
self.draw,
|
||||
font=self.style,
|
||||
halign=alignment)
|
||||
|
||||
# Create a test word
|
||||
test_word = Word("test", self.style)
|
||||
|
||||
@ -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
|
||||
@ -76,8 +76,8 @@ class TestBox(unittest.TestCase):
|
||||
|
||||
# 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"""
|
||||
@ -88,7 +88,6 @@ class TestBox(unittest.TestCase):
|
||||
|
||||
np.testing.assert_array_equal(result, [True, False, True, False])
|
||||
|
||||
|
||||
def test_properties_access(self):
|
||||
"""Test that properties can be accessed correctly"""
|
||||
box = Box(self.origin, self.size, callback=self.callback)
|
||||
|
||||
@ -5,18 +5,16 @@ Tests the LinkText, ButtonText, and FormFieldText classes.
|
||||
|
||||
import unittest
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from pyWebLayout.concrete.functional import (
|
||||
LinkText, ButtonText, FormFieldText,
|
||||
create_link_text, create_button_text, create_form_field_text
|
||||
)
|
||||
from pyWebLayout.abstract.functional import (
|
||||
Link, Button, Form, FormField, LinkType, FormFieldType
|
||||
Link, Button, FormField, LinkType, FormFieldType
|
||||
)
|
||||
from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration
|
||||
from pyWebLayout.style import Alignment
|
||||
from pyWebLayout.style import Font, TextDecoration
|
||||
|
||||
|
||||
class TestLinkText(unittest.TestCase):
|
||||
@ -33,7 +31,10 @@ class TestLinkText(unittest.TestCase):
|
||||
|
||||
# Create different types of links
|
||||
self.internal_link = Link("chapter1", LinkType.INTERNAL, self.callback)
|
||||
self.external_link = Link("https://example.com", LinkType.EXTERNAL, self.callback)
|
||||
self.external_link = Link(
|
||||
"https://example.com",
|
||||
LinkType.EXTERNAL,
|
||||
self.callback)
|
||||
self.api_link = Link("/api/settings", LinkType.API, self.callback)
|
||||
self.function_link = Link("toggle_theme", LinkType.FUNCTION, self.callback)
|
||||
|
||||
@ -133,7 +134,11 @@ class TestLinkText(unittest.TestCase):
|
||||
def test_factory_function(self):
|
||||
"""Test the create_link_text factory function"""
|
||||
link_text = "Factory Test"
|
||||
renderable = create_link_text(self.internal_link, link_text, self.font, self.mock_draw)
|
||||
renderable = create_link_text(
|
||||
self.internal_link,
|
||||
link_text,
|
||||
self.font,
|
||||
self.mock_draw)
|
||||
|
||||
self.assertIsInstance(renderable, LinkText)
|
||||
self.assertEqual(renderable.text, link_text)
|
||||
@ -215,7 +220,8 @@ class TestButtonText(unittest.TestCase):
|
||||
expected_width = renderable._padded_width
|
||||
expected_height = renderable._padded_height
|
||||
|
||||
np.testing.assert_array_equal(renderable.size, np.array([expected_width, expected_height]))
|
||||
np.testing.assert_array_equal(
|
||||
renderable.size, np.array([expected_width, expected_height]))
|
||||
|
||||
def test_render_normal_state(self):
|
||||
"""Test rendering in normal state"""
|
||||
@ -257,7 +263,8 @@ class TestButtonText(unittest.TestCase):
|
||||
def test_factory_function(self):
|
||||
"""Test the create_button_text factory function"""
|
||||
custom_padding = (6, 10, 6, 10)
|
||||
renderable = create_button_text(self.button, self.font, self.mock_draw, custom_padding)
|
||||
renderable = create_button_text(
|
||||
self.button, self.font, self.mock_draw, custom_padding)
|
||||
|
||||
self.assertIsInstance(renderable, ButtonText)
|
||||
self.assertEqual(renderable.text, "Click Me")
|
||||
@ -279,7 +286,8 @@ class TestFormFieldText(unittest.TestCase):
|
||||
# Create different types of form fields
|
||||
self.text_field = FormField("username", FormFieldType.TEXT, "Username")
|
||||
self.password_field = FormField("password", FormFieldType.PASSWORD, "Password")
|
||||
self.textarea_field = FormField("description", FormFieldType.TEXTAREA, "Description")
|
||||
self.textarea_field = FormField(
|
||||
"description", FormFieldType.TEXTAREA, "Description")
|
||||
self.select_field = FormField("country", FormFieldType.SELECT, "Country")
|
||||
|
||||
self.mock_draw = Mock()
|
||||
@ -296,7 +304,11 @@ class TestFormFieldText(unittest.TestCase):
|
||||
def test_form_field_text_with_custom_height(self):
|
||||
"""Test form field text with custom field height"""
|
||||
custom_height = 40
|
||||
renderable = FormFieldText(self.text_field, self.font, self.mock_draw, custom_height)
|
||||
renderable = FormFieldText(
|
||||
self.text_field,
|
||||
self.font,
|
||||
self.mock_draw,
|
||||
custom_height)
|
||||
|
||||
self.assertEqual(renderable._field_height, custom_height)
|
||||
|
||||
@ -326,7 +338,8 @@ class TestFormFieldText(unittest.TestCase):
|
||||
expected_height = renderable._style.font_size + 5 + renderable._field_height
|
||||
expected_width = renderable._field_width # Use the calculated field width
|
||||
|
||||
np.testing.assert_array_equal(renderable.size, np.array([expected_width, expected_height]))
|
||||
np.testing.assert_array_equal(
|
||||
renderable.size, np.array([expected_width, expected_height]))
|
||||
|
||||
def test_render_text_field(self):
|
||||
"""Test rendering text field"""
|
||||
@ -426,7 +439,8 @@ class TestFormFieldText(unittest.TestCase):
|
||||
def test_factory_function(self):
|
||||
"""Test the create_form_field_text factory function"""
|
||||
custom_height = 30
|
||||
renderable = create_form_field_text(self.text_field, self.font, self.mock_draw, custom_height)
|
||||
renderable = create_form_field_text(
|
||||
self.text_field, self.font, self.mock_draw, custom_height)
|
||||
|
||||
self.assertIsInstance(renderable, FormFieldText)
|
||||
self.assertEqual(renderable.text, "Username")
|
||||
@ -455,7 +469,8 @@ class TestInteractionCallbacks(unittest.TestCase):
|
||||
|
||||
def test_link_text_interaction(self):
|
||||
"""Test that LinkText properly handles interaction"""
|
||||
# Use a FUNCTION link type which calls the callback, not INTERNAL which returns location
|
||||
# Use a FUNCTION link type which calls the callback, not INTERNAL which
|
||||
# returns location
|
||||
link = Link("test_function", LinkType.FUNCTION, self.link_callback)
|
||||
renderable = LinkText(link, "Test Link", self.font, self.mock_draw)
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@ import os
|
||||
import tempfile
|
||||
import numpy as np
|
||||
from PIL import Image as PILImage, ImageDraw
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from pyWebLayout.concrete.image import RenderableImage
|
||||
from pyWebLayout.abstract.block import Image as AbstractImage
|
||||
@ -42,7 +42,7 @@ class TestRenderableImage(unittest.TestCase):
|
||||
try:
|
||||
os.unlink(self.test_image_path)
|
||||
os.rmdir(self.temp_dir)
|
||||
except:
|
||||
except BaseException:
|
||||
pass
|
||||
|
||||
def test_renderable_image_initialization_basic(self):
|
||||
@ -134,7 +134,9 @@ class TestRenderableImage(unittest.TestCase):
|
||||
mock_response.status_code = 404
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
url_abstract = AbstractImage("https://example.com/notfound.png", "Bad URL Image")
|
||||
url_abstract = AbstractImage(
|
||||
"https://example.com/notfound.png",
|
||||
"Bad URL Image")
|
||||
renderable = RenderableImage(url_abstract, self.draw)
|
||||
|
||||
# Should have error message
|
||||
@ -292,8 +294,6 @@ class TestRenderableImage(unittest.TestCase):
|
||||
self.assertEqual(renderable._halign, Alignment.RIGHT)
|
||||
self.assertEqual(renderable._valign, Alignment.BOTTOM)
|
||||
|
||||
|
||||
|
||||
def test_render_rgb_image_conversion(self):
|
||||
"""Test rendering RGB image (should be converted to RGBA)"""
|
||||
# Our test image is RGB, so this should test the conversion path
|
||||
@ -372,7 +372,10 @@ class TestRenderableImage(unittest.TestCase):
|
||||
|
||||
def test_properties(self):
|
||||
"""Test the property methods"""
|
||||
renderable = RenderableImage(self.abstract_image, self.draw, origin=(10, 20), size=(100, 80))
|
||||
renderable = RenderableImage(
|
||||
self.abstract_image, self.draw, origin=(
|
||||
10, 20), size=(
|
||||
100, 80))
|
||||
|
||||
np.testing.assert_array_equal(renderable.origin, np.array([10, 20]))
|
||||
np.testing.assert_array_equal(renderable.size, np.array([100, 80]))
|
||||
|
||||
@ -6,15 +6,15 @@ Tests the Text and Line classes for text rendering functionality.
|
||||
import unittest
|
||||
import numpy as np
|
||||
import os
|
||||
from PIL import Image, ImageFont, ImageDraw
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
from PIL import Image, ImageDraw
|
||||
from unittest.mock import Mock
|
||||
|
||||
from pyWebLayout.concrete.text import Text, Line
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.style import Font, FontStyle, FontWeight, TextDecoration
|
||||
from pyWebLayout.style import Alignment
|
||||
from tests.utils.test_fonts import create_default_test_font, ensure_consistent_font_in_tests
|
||||
|
||||
|
||||
class TestText(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# Ensure consistent font usage across tests
|
||||
@ -29,7 +29,6 @@ class TestText(unittest.TestCase):
|
||||
# Create a consistent test Font object using bundled font
|
||||
self.style = create_default_test_font()
|
||||
|
||||
|
||||
def test_init(self):
|
||||
text_instance = Text(text="Test", style=self.style, draw=self.draw)
|
||||
self.assertEqual(text_instance.text, "Test")
|
||||
@ -203,7 +202,8 @@ class TestLine(unittest.TestCase):
|
||||
return
|
||||
elif not success:
|
||||
# Line is full, word couldn't be added
|
||||
self.assertGreater(words_added, 0, "Should have added at least one word before line filled")
|
||||
self.assertGreater(
|
||||
words_added, 0, "Should have added at least one word before line filled")
|
||||
return
|
||||
else:
|
||||
# Word was added successfully
|
||||
@ -235,7 +235,7 @@ class TestLine(unittest.TestCase):
|
||||
|
||||
success, overflow_part = line.add_word(word)
|
||||
# If successful, the word should be added
|
||||
if success == False:
|
||||
if not success:
|
||||
self.assertIsNone(overflow_part)
|
||||
return
|
||||
|
||||
@ -260,7 +260,8 @@ class TestLine(unittest.TestCase):
|
||||
# Use a longer word to trigger brute force hyphenation
|
||||
words_added = 0
|
||||
for i in range(100):
|
||||
word = Word(text="AAAAAAAA", style=self.style) # 8 A's to ensure it's long enough
|
||||
# 8 A's to ensure it's long enough
|
||||
word = Word(text="AAAAAAAA", style=self.style)
|
||||
success, overflow_part = line.add_word(word)
|
||||
|
||||
if overflow_part:
|
||||
@ -270,14 +271,14 @@ class TestLine(unittest.TestCase):
|
||||
return
|
||||
elif not success:
|
||||
# Line is full, word couldn't be added
|
||||
self.assertGreater(words_added, 0, "Should have added at least one word before line filled")
|
||||
self.assertGreater(
|
||||
words_added, 0, "Should have added at least one word before line filled")
|
||||
return
|
||||
else:
|
||||
words_added += 1
|
||||
|
||||
self.fail("Expected line to fill or overflow to occur but reached max iterations")
|
||||
|
||||
|
||||
def test_line_render(self):
|
||||
"""Test line rendering with real objects"""
|
||||
spacing = (5, 15)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -74,7 +73,8 @@ class TestPageImplementation(unittest.TestCase):
|
||||
self.assertEqual(page.canvas_size, expected_canvas_size)
|
||||
|
||||
# Content size should be canvas minus padding
|
||||
expected_content_size = (730, 550) # 790-60, 590-40 (padding left+right, top+bottom)
|
||||
# 790-60, 590-40 (padding left+right, top+bottom)
|
||||
expected_content_size = (730, 550)
|
||||
self.assertEqual(page.content_size, expected_content_size)
|
||||
|
||||
def test_page_add_remove_children(self):
|
||||
@ -103,7 +103,9 @@ class TestPageImplementation(unittest.TestCase):
|
||||
self.assertEqual(len(page.children), 3)
|
||||
self.assertIn(child3, page.children)
|
||||
|
||||
# Remove childce 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)
|
||||
@ -265,7 +267,7 @@ class TestPageImplementation(unittest.TestCase):
|
||||
|
||||
# Test 1: Line that fits comfortably
|
||||
line_height = 20
|
||||
max_y = 600 - 40 - 10 # 550
|
||||
_max_y = 600 - 40 - 10 # 550
|
||||
self.assertTrue(page.can_fit_line(line_height))
|
||||
# Would end at 50 + 20 = 70, well within 550
|
||||
|
||||
@ -273,14 +275,15 @@ class TestPageImplementation(unittest.TestCase):
|
||||
# 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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -90,7 +89,8 @@ class TestEPUBReader(unittest.TestCase):
|
||||
book.spine = ['nav', chapter1]
|
||||
|
||||
# Create temporary file
|
||||
epub_path = os.path.join(self.test_dir, f'test_simple_{len(self.epub_files)}.epub')
|
||||
epub_path = os.path.join(self.test_dir,
|
||||
f'test_simple_{len(self.epub_files)}.epub')
|
||||
epub.write_epub(epub_path, book, {})
|
||||
self.epub_files.append(epub_path)
|
||||
|
||||
@ -249,7 +249,8 @@ class TestEPUBReader(unittest.TestCase):
|
||||
book.spine = ['nav', chapter1, chapter2, chapter3, chapter4]
|
||||
|
||||
# Create temporary file
|
||||
epub_path = os.path.join(self.test_dir, f'test_complex_{len(self.epub_files)}.epub')
|
||||
epub_path = os.path.join(self.test_dir,
|
||||
f'test_complex_{len(self.epub_files)}.epub')
|
||||
epub.write_epub(epub_path, book, {})
|
||||
self.epub_files.append(epub_path)
|
||||
|
||||
@ -319,7 +320,8 @@ class TestEPUBReader(unittest.TestCase):
|
||||
book.spine = ['nav', chapter]
|
||||
|
||||
# Create temporary file
|
||||
epub_path = os.path.join(self.test_dir, f'test_nested_{len(self.epub_files)}.epub')
|
||||
epub_path = os.path.join(self.test_dir,
|
||||
f'test_nested_{len(self.epub_files)}.epub')
|
||||
epub.write_epub(epub_path, book, {})
|
||||
self.epub_files.append(epub_path)
|
||||
|
||||
@ -374,11 +376,13 @@ class TestEPUBReader(unittest.TestCase):
|
||||
# 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."""
|
||||
@ -399,7 +403,7 @@ class TestEPUBReader(unittest.TestCase):
|
||||
for _, word in words:
|
||||
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:
|
||||
@ -430,19 +434,23 @@ class TestEPUBReader(unittest.TestCase):
|
||||
|
||||
# Check list items
|
||||
items = list(block.items())
|
||||
self.assertGreater(len(items), 0, "Unordered list should have items")
|
||||
self.assertGreater(
|
||||
len(items), 0, "Unordered list should have items")
|
||||
|
||||
elif block.style == ListStyle.ORDERED:
|
||||
ordered_list_found = True
|
||||
|
||||
# Check list items
|
||||
items = list(block.items())
|
||||
self.assertGreater(len(items), 0, "Ordered list should have items")
|
||||
self.assertGreater(
|
||||
len(items), 0, "Ordered list should have items")
|
||||
|
||||
elif isinstance(block, Quote):
|
||||
quote_found = True
|
||||
|
||||
self.assertTrue(unordered_list_found, "Should find unordered list in chapter 3")
|
||||
self.assertTrue(
|
||||
unordered_list_found,
|
||||
"Should find unordered list in chapter 3")
|
||||
self.assertTrue(ordered_list_found, "Should find ordered list in chapter 3")
|
||||
self.assertTrue(quote_found, "Should find quote in chapter 3")
|
||||
|
||||
@ -492,13 +500,19 @@ class TestEPUBReader(unittest.TestCase):
|
||||
|
||||
# Should have multiple headings (h1, h2, h3, h4)
|
||||
headings = [block for block in chapter_blocks if isinstance(block, Heading)]
|
||||
self.assertGreater(len(headings), 2, "Should have multiple headings from nested content")
|
||||
self.assertGreater(
|
||||
len(headings),
|
||||
2,
|
||||
"Should have multiple headings from nested content")
|
||||
|
||||
# Should have paragraphs and lists from nested content
|
||||
paragraphs = [block for block in chapter_blocks if isinstance(block, Paragraph)]
|
||||
lists = [block for block in chapter_blocks if isinstance(block, HList)]
|
||||
|
||||
self.assertGreater(len(paragraphs), 0, "Should have paragraphs from nested content")
|
||||
self.assertGreater(
|
||||
len(paragraphs),
|
||||
0,
|
||||
"Should have paragraphs from nested content")
|
||||
self.assertGreater(len(lists), 0, "Should have lists from nested content")
|
||||
|
||||
def test_epub_metadata_extraction(self):
|
||||
@ -552,7 +566,10 @@ class TestEPUBReader(unittest.TestCase):
|
||||
book.set_language('en')
|
||||
book.add_author('Primary Author')
|
||||
book.add_author('Secondary Author')
|
||||
book.add_metadata('DC', 'description', 'A comprehensive test of metadata extraction')
|
||||
book.add_metadata(
|
||||
'DC',
|
||||
'description',
|
||||
'A comprehensive test of metadata extraction')
|
||||
book.add_metadata('DC', 'subject', 'Testing')
|
||||
book.add_metadata('DC', 'subject', 'EPUB')
|
||||
book.add_metadata('DC', 'date', '2024-06-07')
|
||||
@ -582,7 +599,8 @@ class TestEPUBReader(unittest.TestCase):
|
||||
book.spine = ['nav', chapter]
|
||||
|
||||
# Write and test
|
||||
epub_path = os.path.join(self.test_dir, f'test_metadata_{len(self.epub_files)}.epub')
|
||||
epub_path = os.path.join(self.test_dir,
|
||||
f'test_metadata_{len(self.epub_files)}.epub')
|
||||
epub.write_epub(epub_path, book, {})
|
||||
self.epub_files.append(epub_path)
|
||||
|
||||
@ -722,13 +740,15 @@ class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase):
|
||||
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
|
||||
@ -751,7 +771,6 @@ class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase):
|
||||
color_found = red_text_found or green_text_found or blue_text_found
|
||||
self.assertTrue(color_found, "Should find at least one colored text")
|
||||
|
||||
|
||||
def test_epub_with_image(self):
|
||||
"""Test that images in EPUB are properly parsed."""
|
||||
book = epub.EpubBook()
|
||||
@ -761,7 +780,18 @@ class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase):
|
||||
book.add_author('Test Author')
|
||||
|
||||
# Create minimal JPEG data for testing
|
||||
img_data = b'\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x01\x00H\x00H\x00\x00\xff\xdb\x00C\x00\x08\x06\x06\x07\x06\x05\x08\x07\x07\x07\t\t\x08\n\x0c\x14\r\x0c\x0b\x0b\x0c\x19\x12\x13\x0f\x14\x1d\x1a\x1f\x1e\x1d\x1a\x1c\x1c $.\' ",#\x1c\x1c(7),01444\x1f\'9=82<.342\xff\xc0\x00\x11\x08\x00d\x00d\x01\x01\x11\x00\x02\x11\x01\x03\x11\x01\xff\xc4\x00\x14\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x08\xff\xc4\x00\x14\x10\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xda\x00\x0c\x03\x01\x00\x02\x11\x03\x11\x00\x3f\x00\xaa\xff\xd9'
|
||||
img_data = (
|
||||
b'\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x01\x00H\x00H\x00\x00'
|
||||
b'\xff\xdb\x00C\x00\x08\x06\x06\x07\x06\x05\x08\x07\x07\x07\t\t'
|
||||
b'\x08\n\x0c\x14\r\x0c\x0b\x0b\x0c\x19\x12\x13\x0f\x14\x1d\x1a'
|
||||
b'\x1f\x1e\x1d\x1a\x1c\x1c $.\' ",#\x1c\x1c(7),01444\x1f\'9=82<.342'
|
||||
b'\xff\xc0\x00\x11\x08\x00d\x00d\x01\x01\x11\x00\x02\x11\x01\x03'
|
||||
b'\x11\x01\xff\xc4\x00\x14\x00\x01\x00\x00\x00\x00\x00\x00\x00'
|
||||
b'\x00\x00\x00\x00\x00\x00\x00\x00\x08\xff\xc4\x00\x14\x10\x01'
|
||||
b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
|
||||
b'\x00\xff\xda\x00\x0c\x03\x01\x00\x02\x11\x03\x11\x00\x3f\x00'
|
||||
b'\xaa\xff\xd9'
|
||||
)
|
||||
|
||||
# Create an EpubImage item
|
||||
image_item = epub.EpubImage()
|
||||
@ -796,7 +826,8 @@ class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase):
|
||||
book.spine = ['nav', chapter]
|
||||
|
||||
# Write EPUB
|
||||
epub_path = os.path.join(self.test_dir, f'test_image_{len(self.epub_files)}.epub')
|
||||
epub_path = os.path.join(self.test_dir,
|
||||
f'test_image_{len(self.epub_files)}.epub')
|
||||
epub.write_epub(epub_path, book, {})
|
||||
self.epub_files.append(epub_path)
|
||||
|
||||
@ -814,8 +845,14 @@ class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase):
|
||||
image_blocks = [block for block in blocks if isinstance(block, Image)]
|
||||
|
||||
# Verify we have the expected blocks
|
||||
self.assertEqual(len(heading_blocks), 1, "Should find exactly one heading block")
|
||||
self.assertGreaterEqual(len(paragraph_blocks), 2, "Should find at least two paragraph blocks")
|
||||
self.assertEqual(
|
||||
len(heading_blocks),
|
||||
1,
|
||||
"Should find exactly one heading block")
|
||||
self.assertGreaterEqual(
|
||||
len(paragraph_blocks),
|
||||
2,
|
||||
"Should find at least two paragraph blocks")
|
||||
self.assertEqual(len(image_blocks), 1, "Should find exactly one image block")
|
||||
|
||||
# Verify image properties
|
||||
|
||||
@ -21,7 +21,8 @@ class TestHTMLParagraph(unittest.TestCase):
|
||||
self.assertEqual(len(paragraphs), 1)
|
||||
self.assertEqual(len(paragraphs[0]), 4)
|
||||
|
||||
for w1, t1 in zip(paragraphs[0].words_iter(), "This is a paragraph.".split(" ")):
|
||||
for w1, t1 in zip(paragraphs[0].words_iter(),
|
||||
"This is a paragraph.".split(" ")):
|
||||
self.assertEqual(w1[1].text, t1)
|
||||
|
||||
def test_multiple(self):
|
||||
@ -31,10 +32,12 @@ class TestHTMLParagraph(unittest.TestCase):
|
||||
self.assertEqual(len(paragraphs[0]), 4)
|
||||
self.assertEqual(len(paragraphs[1]), 4)
|
||||
|
||||
for w1, t1 in zip(paragraphs[0].words_iter(), "This is a paragraph.".split(" ")):
|
||||
for w1, t1 in zip(paragraphs[0].words_iter(),
|
||||
"This is a paragraph.".split(" ")):
|
||||
self.assertEqual(w1[1].text, t1)
|
||||
|
||||
for w1, t1 in zip(paragraphs[1].words_iter(), "This is another paragraph.".split(" ")):
|
||||
for w1, t1 in zip(paragraphs[1].words_iter(),
|
||||
"This is another paragraph.".split(" ")):
|
||||
self.assertEqual(w1[1].text, t1)
|
||||
|
||||
|
||||
@ -103,7 +106,10 @@ class TestHTMLStyledParagraphs(unittest.TestCase):
|
||||
|
||||
def test_span_with_inline_styles(self):
|
||||
"""Test paragraphs with span elements containing inline CSS styles."""
|
||||
text = '<p>This text is normal, but <span style="color: red; font-weight: bold;">this part is red and bold</span>.</p>'
|
||||
text = (
|
||||
'<p>This text is normal, but <span style="color: red; font-weight: bold;">'
|
||||
'this part is red and bold</span>.</p>'
|
||||
)
|
||||
blocks = parse_html_string(text)
|
||||
self.assertEqual(len(blocks), 1)
|
||||
self.assertIsInstance(blocks[0], Paragraph)
|
||||
@ -117,7 +123,10 @@ class TestHTMLStyledParagraphs(unittest.TestCase):
|
||||
if word.style.weight == FontWeight.BOLD:
|
||||
styled_words.append(word)
|
||||
|
||||
self.assertGreater(len(styled_words), 0, "Should have bold words in styled span")
|
||||
self.assertGreater(
|
||||
len(styled_words),
|
||||
0,
|
||||
"Should have bold words in styled span")
|
||||
|
||||
# Check that at least one word has the red color
|
||||
red_words = [w for w in styled_words if w.style.colour == (255, 0, 0)]
|
||||
@ -125,7 +134,11 @@ class TestHTMLStyledParagraphs(unittest.TestCase):
|
||||
|
||||
def test_mixed_formatting(self):
|
||||
"""Test paragraphs with multiple formatting elements combined."""
|
||||
text = "<p>This paragraph contains <strong>bold</strong>, <em>italic</em>, <span style=\"color: blue;\">blue</span>, and <mark>highlighted</mark> text all together.</p>"
|
||||
text = (
|
||||
'<p>This paragraph contains <strong>bold</strong>, <em>italic</em>, '
|
||||
'<span style="color: blue;">blue</span>, and <mark>highlighted</mark> '
|
||||
'text all together.</p>'
|
||||
)
|
||||
blocks = parse_html_string(text)
|
||||
self.assertEqual(len(blocks), 1)
|
||||
self.assertIsInstance(blocks[0], Paragraph)
|
||||
@ -153,9 +166,12 @@ class TestHTMLStyledParagraphs(unittest.TestCase):
|
||||
words = list(blocks[0].words_iter())
|
||||
|
||||
# Find words that should be both bold and italic
|
||||
bold_italic_words = [w for _, w in words
|
||||
if w.style.weight == FontWeight.BOLD and w.style.style == FontStyle.ITALIC]
|
||||
self.assertGreater(len(bold_italic_words), 0, "Should have words that are both bold and italic")
|
||||
bold_italic_words = [w for _, w in words if w.style.weight ==
|
||||
FontWeight.BOLD and w.style.style == FontStyle.ITALIC]
|
||||
self.assertGreater(
|
||||
len(bold_italic_words),
|
||||
0,
|
||||
"Should have words that are both bold and italic")
|
||||
|
||||
def test_color_variations(self):
|
||||
"""Test different color formats in CSS."""
|
||||
@ -195,12 +211,15 @@ 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]
|
||||
HeadingLevel.H4, HeadingLevel.H5, HeadingLevel.H6]
|
||||
|
||||
for i, block in enumerate(blocks):
|
||||
self.assertIsInstance(block, Heading)
|
||||
@ -407,7 +426,7 @@ class TestHTMLFontRegistryIntegration(unittest.TestCase):
|
||||
# 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")
|
||||
"Should have created styles in registry")
|
||||
|
||||
# Should have created blocks
|
||||
self.assertGreater(len(blocks), 0, "Should have created blocks")
|
||||
@ -431,11 +450,11 @@ class TestHTMLFontRegistryIntegration(unittest.TestCase):
|
||||
|
||||
# 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."""
|
||||
@ -451,8 +470,14 @@ class TestHTMLFontRegistryIntegration(unittest.TestCase):
|
||||
)
|
||||
|
||||
# Should be different style IDs
|
||||
self.assertNotEqual(style_id1, style_id2, "Different sizes should create different styles")
|
||||
self.assertNotEqual(style_id1, style_id3, "Different colors should create different styles")
|
||||
self.assertNotEqual(
|
||||
style_id1,
|
||||
style_id2,
|
||||
"Different sizes should create different styles")
|
||||
self.assertNotEqual(
|
||||
style_id1,
|
||||
style_id3,
|
||||
"Different colors should create different styles")
|
||||
self.assertNotEqual(style_id2, style_id3, "All styles should be different")
|
||||
|
||||
# Should have multiple styles in registry
|
||||
@ -474,7 +499,7 @@ class TestHTMLFontRegistryIntegration(unittest.TestCase):
|
||||
|
||||
# Find words with different styles
|
||||
normal_words = [w for _, w in words if w.style.weight == FontWeight.NORMAL
|
||||
and w.style.style == FontStyle.NORMAL]
|
||||
and w.style.style == FontStyle.NORMAL]
|
||||
bold_words = [w for _, w in words if w.style.weight == FontWeight.BOLD]
|
||||
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)]
|
||||
@ -487,7 +512,7 @@ class TestHTMLFontRegistryIntegration(unittest.TestCase):
|
||||
|
||||
# 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")
|
||||
"Should have multiple styles for different formatting")
|
||||
|
||||
def test_font_registry_without_document_context(self):
|
||||
"""Test that parsing without document context works (fallback behavior)."""
|
||||
@ -506,7 +531,7 @@ class TestHTMLFontRegistryIntegration(unittest.TestCase):
|
||||
# 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")
|
||||
"Document style registry should remain unchanged")
|
||||
|
||||
def test_complex_html_font_reuse(self):
|
||||
"""Test style reuse with complex HTML containing repeated styles."""
|
||||
@ -529,7 +554,7 @@ class TestHTMLFontRegistryIntegration(unittest.TestCase):
|
||||
|
||||
# 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))
|
||||
@ -549,15 +574,15 @@ class TestHTMLFontRegistryIntegration(unittest.TestCase):
|
||||
|
||||
# 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]
|
||||
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 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,
|
||||
@ -132,12 +132,16 @@ class TestUtilityFunctions(unittest.TestCase):
|
||||
base_font = Font()
|
||||
|
||||
# Test heading sizes and weights - from test_headings which tests h1-h6
|
||||
headings = [("h1", 24), ("h2", 20), ("h3", 18), ("h4", 16), ("h5", 14), ("h6", 12)]
|
||||
headings = [("h1", 24), ("h2", 20), ("h3", 18),
|
||||
("h4", 16), ("h5", 14), ("h6", 12)]
|
||||
|
||||
for tag, expected_size in headings:
|
||||
font = apply_element_font_styles(base_font, tag, {})
|
||||
self.assertEqual(font.font_size, expected_size, f"Size mismatch for {tag}")
|
||||
self.assertEqual(font.weight, FontWeight.BOLD, f"Weight should be bold for {tag}")
|
||||
self.assertEqual(
|
||||
font.weight,
|
||||
FontWeight.BOLD,
|
||||
f"Weight should be bold for {tag}")
|
||||
|
||||
def test_apply_element_font_styles_color_parsing(self):
|
||||
"""Test color parsing - adapted from test_color_variations."""
|
||||
@ -156,7 +160,9 @@ class TestUtilityFunctions(unittest.TestCase):
|
||||
def test_apply_element_styling_with_classes_and_styles(self):
|
||||
"""Test complete element styling - adapted from test_span_with_inline_styles."""
|
||||
# From: '<span style="color: red; font-weight: bold;">this part is red and bold</span>'
|
||||
soup = BeautifulSoup('<span class="highlight" style="color: red; font-weight: bold;">text</span>', 'html.parser')
|
||||
soup = BeautifulSoup(
|
||||
'<span class="highlight" style="color: red; font-weight: bold;">text</span>',
|
||||
'html.parser')
|
||||
element = soup.find('span')
|
||||
base_context = create_base_context()
|
||||
|
||||
@ -197,7 +203,9 @@ class TestExtractTextContent(unittest.TestCase):
|
||||
def test_extract_styled_text_bold(self):
|
||||
"""Test extracting bold styled text - adapted from test_bold_text."""
|
||||
# From: "<p>This is <strong>bold text</strong> in a paragraph.</p>"
|
||||
soup = BeautifulSoup('<span>This is <strong>bold text</strong> in a paragraph.</span>', 'html.parser')
|
||||
soup = BeautifulSoup(
|
||||
'<span>This is <strong>bold text</strong> in a paragraph.</span>',
|
||||
'html.parser')
|
||||
element = soup.find('span')
|
||||
|
||||
words = extract_text_content(element, self.base_context)
|
||||
@ -214,15 +222,20 @@ class TestExtractTextContent(unittest.TestCase):
|
||||
def test_extract_nested_formatting(self):
|
||||
"""Test nested formatting - adapted from test_nested_formatting."""
|
||||
# From: "<p>This has <strong>bold with <em>italic inside</em></strong> formatting.</p>"
|
||||
soup = BeautifulSoup('<span>This has <strong>bold with <em>italic inside</em></strong> formatting.</span>', 'html.parser')
|
||||
soup = BeautifulSoup(
|
||||
'<span>This has <strong>bold with <em>italic inside</em></strong> formatting.</span>',
|
||||
'html.parser')
|
||||
element = soup.find('span')
|
||||
|
||||
words = extract_text_content(element, self.base_context)
|
||||
|
||||
# Find words that should be both bold and italic
|
||||
bold_italic_words = [w for w in words
|
||||
if w.style.weight == FontWeight.BOLD and w.style.style == FontStyle.ITALIC]
|
||||
self.assertGreater(len(bold_italic_words), 0, "Should have words that are both bold and italic")
|
||||
bold_italic_words = [w for w in words if w.style.weight ==
|
||||
FontWeight.BOLD and w.style.style == FontStyle.ITALIC]
|
||||
self.assertGreater(
|
||||
len(bold_italic_words),
|
||||
0,
|
||||
"Should have words that are both bold and italic")
|
||||
|
||||
|
||||
class TestHandlerFunctions(unittest.TestCase):
|
||||
@ -253,7 +266,7 @@ class TestHandlerFunctions(unittest.TestCase):
|
||||
"""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]
|
||||
HeadingLevel.H4, HeadingLevel.H5, HeadingLevel.H6]
|
||||
|
||||
for i, expected_level in enumerate(expected_levels, 1):
|
||||
tag = f"h{i}"
|
||||
@ -273,7 +286,9 @@ class TestHandlerFunctions(unittest.TestCase):
|
||||
def test_blockquote_handler(self):
|
||||
"""Test blockquote handler - adapted from test_blockquote."""
|
||||
# From: "<blockquote><p>This is a quoted paragraph.</p></blockquote>"
|
||||
soup = BeautifulSoup('<blockquote><p>This is a quoted paragraph.</p></blockquote>', 'html.parser')
|
||||
soup = BeautifulSoup(
|
||||
'<blockquote><p>This is a quoted paragraph.</p></blockquote>',
|
||||
'html.parser')
|
||||
element = soup.find('blockquote')
|
||||
|
||||
result = blockquote_handler(element, self.base_context)
|
||||
@ -288,7 +303,9 @@ class TestHandlerFunctions(unittest.TestCase):
|
||||
def test_preformatted_handler(self):
|
||||
"""Test preformatted handler - adapted from test_preformatted_code."""
|
||||
# From: "<pre><code>function hello() {\n console.log('Hello');\n}</code></pre>"
|
||||
soup = BeautifulSoup('<pre><code>function hello() {\n console.log(\'Hello\');\n}</code></pre>', 'html.parser')
|
||||
soup = BeautifulSoup(
|
||||
'<pre><code>function hello() {\n console.log(\'Hello\');\n}</code></pre>',
|
||||
'html.parser')
|
||||
element = soup.find('pre')
|
||||
|
||||
result = preformatted_handler(element, self.base_context)
|
||||
@ -302,7 +319,9 @@ class TestHandlerFunctions(unittest.TestCase):
|
||||
def test_unordered_list_handler(self):
|
||||
"""Test unordered list handler - adapted from test_unordered_list."""
|
||||
# From: "<ul><li>First item</li><li>Second item</li><li>Third item</li></ul>"
|
||||
soup = BeautifulSoup('<ul><li>First item</li><li>Second item</li><li>Third item</li></ul>', 'html.parser')
|
||||
soup = BeautifulSoup(
|
||||
'<ul><li>First item</li><li>Second item</li><li>Third item</li></ul>',
|
||||
'html.parser')
|
||||
element = soup.find('ul')
|
||||
|
||||
result = unordered_list_handler(element, self.base_context)
|
||||
@ -317,7 +336,9 @@ class TestHandlerFunctions(unittest.TestCase):
|
||||
def test_ordered_list_handler(self):
|
||||
"""Test ordered list handler - adapted from test_ordered_list."""
|
||||
# From: "<ol><li>First item</li><li>Second item</li><li>Third item</li></ol>"
|
||||
soup = BeautifulSoup('<ol><li>First item</li><li>Second item</li><li>Third item</li></ol>', 'html.parser')
|
||||
soup = BeautifulSoup(
|
||||
'<ol><li>First item</li><li>Second item</li><li>Third item</li></ol>',
|
||||
'html.parser')
|
||||
element = soup.find('ol')
|
||||
|
||||
result = ordered_list_handler(element, self.base_context)
|
||||
@ -405,7 +426,9 @@ class TestHandlerFunctions(unittest.TestCase):
|
||||
|
||||
def test_image_handler(self):
|
||||
"""Test image handler."""
|
||||
soup = BeautifulSoup('<img src="test.jpg" alt="Test image" width="100" height="50">', 'html.parser')
|
||||
soup = BeautifulSoup(
|
||||
'<img src="test.jpg" alt="Test image" width="100" height="50">',
|
||||
'html.parser')
|
||||
element = soup.find('img')
|
||||
|
||||
# Need to apply styling first to get attributes
|
||||
@ -421,7 +444,9 @@ class TestHandlerFunctions(unittest.TestCase):
|
||||
def test_div_handler_container(self):
|
||||
"""Test div handler - adapted from test_div_container."""
|
||||
# From: "<div><p>First paragraph.</p><p>Second paragraph.</p></div>"
|
||||
soup = BeautifulSoup('<div><p>First paragraph.</p><p>Second paragraph.</p></div>', 'html.parser')
|
||||
soup = BeautifulSoup(
|
||||
'<div><p>First paragraph.</p><p>Second paragraph.</p></div>',
|
||||
'html.parser')
|
||||
element = soup.find('div')
|
||||
|
||||
result = div_handler(element, self.base_context)
|
||||
@ -443,7 +468,9 @@ class TestStyledContentHandling(unittest.TestCase):
|
||||
def test_paragraph_with_bold_content(self):
|
||||
"""Test paragraph with bold content - adapted from test_bold_text."""
|
||||
# From: "<p>This is <strong>bold text</strong> in a paragraph.</p>"
|
||||
soup = BeautifulSoup('<p>This is <strong>bold text</strong> in a paragraph.</p>', 'html.parser')
|
||||
soup = BeautifulSoup(
|
||||
'<p>This is <strong>bold text</strong> in a paragraph.</p>',
|
||||
'html.parser')
|
||||
element = soup.find('p')
|
||||
|
||||
result = paragraph_handler(element, self.base_context)
|
||||
@ -467,8 +494,12 @@ class TestStyledContentHandling(unittest.TestCase):
|
||||
|
||||
def test_paragraph_with_mixed_formatting(self):
|
||||
"""Test mixed formatting - adapted from test_mixed_formatting."""
|
||||
# From: "<p>This paragraph contains <strong>bold</strong>, <em>italic</em>, <span style=\"color: blue;\">blue</span>..."
|
||||
soup = BeautifulSoup('<p>This paragraph contains <strong>bold</strong>, <em>italic</em>, <span style="color: blue;">blue</span> text.</p>', 'html.parser')
|
||||
# From: "<p>This paragraph contains <strong>bold</strong>, <em>italic</em>..."
|
||||
html_str = (
|
||||
'<p>This paragraph contains <strong>bold</strong>, <em>italic</em>, '
|
||||
'<span style="color: blue;">blue</span> text.</p>'
|
||||
)
|
||||
soup = BeautifulSoup(html_str, 'html.parser')
|
||||
element = soup.find('p')
|
||||
|
||||
result = paragraph_handler(element, self.base_context)
|
||||
|
||||
@ -18,10 +18,13 @@ class TestHTMLFileLoader(unittest.TestCase):
|
||||
def test_load_html_file(self):
|
||||
"""Test loading and parsing an HTML file from disk."""
|
||||
# Path to the test HTML file
|
||||
html_file_path = os.path.join("tests", "data", "Kimi Räikkönen - Wikipedia.html")
|
||||
html_file_path = os.path.join(
|
||||
"tests", "data", "Kimi Räikkönen - Wikipedia.html")
|
||||
|
||||
# Verify the test file exists
|
||||
self.assertTrue(os.path.exists(html_file_path), f"Test HTML file not found: {html_file_path}")
|
||||
self.assertTrue(
|
||||
os.path.exists(html_file_path),
|
||||
f"Test HTML file not found: {html_file_path}")
|
||||
|
||||
# Read the HTML file
|
||||
with open(html_file_path, 'r', encoding='utf-8') as file:
|
||||
@ -38,17 +41,25 @@ class TestHTMLFileLoader(unittest.TestCase):
|
||||
|
||||
# Verify we got some blocks
|
||||
self.assertIsInstance(blocks, list, "parse_html_string should return a list")
|
||||
self.assertGreater(len(blocks), 0, "Should extract at least one block from the HTML file")
|
||||
self.assertGreater(
|
||||
len(blocks),
|
||||
0,
|
||||
"Should extract at least one block from the HTML file")
|
||||
|
||||
# Verify all returned items are Block instances
|
||||
for i, block in enumerate(blocks):
|
||||
self.assertIsInstance(block, Block, f"Item {i} should be a Block instance, got {type(block)}")
|
||||
self.assertIsInstance(
|
||||
block,
|
||||
Block,
|
||||
f"Item {i} should be a Block instance, got {
|
||||
type(block)}")
|
||||
|
||||
print(f"Successfully loaded and parsed HTML file with {len(blocks)} blocks")
|
||||
|
||||
def test_load_html_file_with_custom_font(self):
|
||||
"""Test loading HTML file with a custom base font."""
|
||||
html_file_path = os.path.join("tests", "data", "Kimi Räikkönen - Wikipedia.html")
|
||||
html_file_path = os.path.join(
|
||||
"tests", "data", "Kimi Räikkönen - Wikipedia.html")
|
||||
|
||||
# Skip if file doesn't exist
|
||||
if not os.path.exists(html_file_path):
|
||||
@ -66,11 +77,14 @@ class TestHTMLFileLoader(unittest.TestCase):
|
||||
# Verify we got blocks
|
||||
self.assertGreater(len(blocks), 0, "Should extract blocks with custom font")
|
||||
|
||||
print(f"Successfully parsed HTML file with custom font, got {len(blocks)} blocks")
|
||||
print(
|
||||
f"Successfully parsed HTML file with custom font, got {
|
||||
len(blocks)} blocks")
|
||||
|
||||
def test_load_html_file_content_types(self):
|
||||
"""Test that the loaded HTML file contains expected content types."""
|
||||
html_file_path = os.path.join("tests", "data", "Kimi Räikkönen - Wikipedia.html")
|
||||
html_file_path = os.path.join(
|
||||
"tests", "data", "Kimi Räikkönen - Wikipedia.html")
|
||||
|
||||
# Skip if file doesn't exist
|
||||
if not os.path.exists(html_file_path):
|
||||
@ -86,13 +100,17 @@ class TestHTMLFileLoader(unittest.TestCase):
|
||||
unique_types = set(block_type_names)
|
||||
|
||||
# A Wikipedia page should contain multiple types of content
|
||||
self.assertGreater(len(unique_types), 1, "Should have multiple types of blocks in Wikipedia page")
|
||||
self.assertGreater(
|
||||
len(unique_types),
|
||||
1,
|
||||
"Should have multiple types of blocks in Wikipedia page")
|
||||
|
||||
print(f"Found block types: {sorted(unique_types)}")
|
||||
|
||||
def test_html_file_size_handling(self):
|
||||
"""Test that large HTML files can be handled gracefully."""
|
||||
html_file_path = os.path.join("tests", "data", "Kimi Räikkönen - Wikipedia.html")
|
||||
html_file_path = os.path.join(
|
||||
"tests", "data", "Kimi Räikkönen - Wikipedia.html")
|
||||
|
||||
# Skip if file doesn't exist
|
||||
if not os.path.exists(html_file_path):
|
||||
|
||||
@ -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,13 +7,11 @@ from bs4 import BeautifulSoup
|
||||
from pyWebLayout.io.readers.html_extraction import (
|
||||
parse_html_string,
|
||||
extract_text_content,
|
||||
create_base_context,
|
||||
apply_element_styling
|
||||
create_base_context
|
||||
)
|
||||
from pyWebLayout.abstract.inline import LinkedWord
|
||||
from pyWebLayout.abstract.functional import LinkType
|
||||
from pyWebLayout.abstract.block import Paragraph
|
||||
from pyWebLayout.style import Font
|
||||
|
||||
|
||||
class TestHTMLLinkExtraction(unittest.TestCase):
|
||||
@ -156,7 +154,8 @@ class TestHTMLLinkExtraction(unittest.TestCase):
|
||||
words = list(paragraph.words)
|
||||
|
||||
# Find LinkedWords and their locations
|
||||
linked_words = [(w.text, w.location) for w in words if isinstance(w, LinkedWord)]
|
||||
linked_words = [(w.text, w.location)
|
||||
for w in words if isinstance(w, LinkedWord)]
|
||||
|
||||
# Should have "first" linked to /page1 and "second" linked to /page2
|
||||
self.assertIn(("first", "/page1"), linked_words)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -61,8 +59,10 @@ class TestDocumentLayouter:
|
||||
# Create mock font that returns proper numeric metrics (not Mock objects)
|
||||
mock_font = Mock()
|
||||
# CRITICAL: getmetrics() must return actual numeric values, not Mock objects
|
||||
# This prevents "TypeError: '>' not supported between instances of 'Mock' and 'Mock'"
|
||||
mock_font.getmetrics.return_value = (12, 4) # (ascent, descent) as actual integers
|
||||
# This prevents "TypeError: '>' not supported between instances of 'Mock'
|
||||
# and 'Mock'"
|
||||
# (ascent, descent) as actual integers
|
||||
mock_font.getmetrics.return_value = (12, 4)
|
||||
mock_font.font = mock_font # For accessing .font property
|
||||
|
||||
# Create mock font object that can be used by create_font
|
||||
@ -84,7 +84,11 @@ class TestDocumentLayouter:
|
||||
@patch('pyWebLayout.layout.document_layouter.StyleResolver')
|
||||
@patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry')
|
||||
@patch('pyWebLayout.layout.document_layouter.Line')
|
||||
def test_paragraph_layouter_basic_flow(self, mock_line_class, mock_style_registry_class, mock_style_resolver_class):
|
||||
def test_paragraph_layouter_basic_flow(
|
||||
self,
|
||||
mock_line_class,
|
||||
mock_style_registry_class,
|
||||
mock_style_resolver_class):
|
||||
"""Test basic paragraph layouter functionality."""
|
||||
# Setup mocks for StyleResolver and ConcreteStyleRegistry
|
||||
mock_style_resolver = Mock()
|
||||
@ -110,7 +114,8 @@ class TestDocumentLayouter:
|
||||
# Verify StyleResolver and ConcreteStyleRegistry were created correctly
|
||||
mock_style_resolver_class.assert_called_once()
|
||||
mock_style_registry_class.assert_called_once_with(mock_style_resolver)
|
||||
mock_style_registry.get_concrete_style.assert_called_once_with(self.mock_paragraph.style)
|
||||
mock_style_registry.get_concrete_style.assert_called_once_with(
|
||||
self.mock_paragraph.style)
|
||||
|
||||
# Verify Line was created with correct spacing constraints
|
||||
expected_spacing = (2, 8) # From mock_concrete_style
|
||||
@ -121,7 +126,8 @@ class TestDocumentLayouter:
|
||||
@patch('pyWebLayout.layout.document_layouter.StyleResolver')
|
||||
@patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry')
|
||||
@patch('pyWebLayout.layout.document_layouter.Line')
|
||||
def test_paragraph_layouter_word_spacing_constraints_extraction(self, mock_line_class, mock_style_registry_class, mock_style_resolver_class):
|
||||
def test_paragraph_layouter_word_spacing_constraints_extraction(
|
||||
self, mock_line_class, mock_style_registry_class, mock_style_resolver_class):
|
||||
"""Test that word spacing constraints are correctly extracted from style."""
|
||||
# Create concrete style with specific constraints
|
||||
concrete_style = Mock()
|
||||
@ -159,7 +165,11 @@ class TestDocumentLayouter:
|
||||
@patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry')
|
||||
@patch('pyWebLayout.layout.document_layouter.Line')
|
||||
@patch('pyWebLayout.layout.document_layouter.Text')
|
||||
def test_paragraph_layouter_line_overflow(self, mock_text_class, mock_line_class, mock_style_registry_class):
|
||||
def test_paragraph_layouter_line_overflow(
|
||||
self,
|
||||
mock_text_class,
|
||||
mock_line_class,
|
||||
mock_style_registry_class):
|
||||
"""Test handling of line overflow when words don't fit."""
|
||||
# Setup mocks
|
||||
mock_style_registry = Mock()
|
||||
@ -206,7 +216,8 @@ class TestDocumentLayouter:
|
||||
|
||||
@patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry')
|
||||
@patch('pyWebLayout.layout.document_layouter.Line')
|
||||
def test_paragraph_layouter_page_full(self, mock_line_class, mock_style_registry_class):
|
||||
def test_paragraph_layouter_page_full(
|
||||
self, mock_line_class, mock_style_registry_class):
|
||||
"""Test handling when page runs out of space."""
|
||||
# Setup mocks
|
||||
mock_style_registry = Mock()
|
||||
@ -275,7 +286,8 @@ class TestDocumentLayouter:
|
||||
with patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry'):
|
||||
layouter = DocumentLayouter(self.mock_page)
|
||||
|
||||
result = layouter.layout_paragraph(self.mock_paragraph, start_word=2, pretext="test")
|
||||
result = layouter.layout_paragraph(
|
||||
self.mock_paragraph, start_word=2, pretext="test")
|
||||
|
||||
# Verify the function was called correctly
|
||||
mock_paragraph_layouter.assert_called_once_with(
|
||||
@ -363,8 +375,9 @@ class TestWordSpacingConstraintsInLayout:
|
||||
(None, None, None, 2.0, 8.0), # Default case
|
||||
(5.0, None, None, 5.0, 10.0), # Only base specified
|
||||
(4.0, 2.0, 8.0, 2.0, 8.0), # All specified
|
||||
(3.0, 1.0, None, 1.0, 3.0), # Min specified, max = max(word_spacing, min*2) = max(3.0, 2.0) = 3.0
|
||||
(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:
|
||||
@ -421,7 +434,6 @@ class TestMultiPageLayout:
|
||||
self.mock_concrete_style.text_align = "justify"
|
||||
self.mock_concrete_style.create_font = Mock()
|
||||
|
||||
|
||||
@patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry')
|
||||
def test_document_layouter_multi_page_scenario(self, mock_style_registry_class):
|
||||
"""Test DocumentLayouter handling multiple pages with continuation."""
|
||||
@ -480,7 +492,7 @@ class TestMultiPageLayout:
|
||||
layouter = MultiPageDocumentLayouter(self.mock_pages)
|
||||
|
||||
# Mock the layout_paragraph method to simulate page filling
|
||||
original_layout_paragraph = layouter.layout_paragraph
|
||||
layouter.layout_paragraph
|
||||
call_count = [0]
|
||||
|
||||
def mock_layout_paragraph(paragraph, start_word=0, pretext=None):
|
||||
@ -502,7 +514,8 @@ class TestMultiPageLayout:
|
||||
layouter.layout_paragraph = mock_layout_paragraph
|
||||
|
||||
# Test multi-page layout
|
||||
success, message = layouter.layout_document_with_pagination([self.long_paragraph])
|
||||
success, message = layouter.layout_document_with_pagination(
|
||||
[self.long_paragraph])
|
||||
|
||||
# Verify results
|
||||
assert success is True
|
||||
@ -510,7 +523,6 @@ class TestMultiPageLayout:
|
||||
assert call_count[0] == 3 # Should have made 3 layout attempts
|
||||
assert layouter.current_page_index == 2 # Should end on page 3 (index 2)
|
||||
|
||||
|
||||
def test_realistic_multi_page_scenario(self):
|
||||
"""Test a realistic scenario with actual content and page constraints."""
|
||||
# Create realistic paragraph with varied content
|
||||
@ -573,10 +585,11 @@ class TestMultiPageLayout:
|
||||
|
||||
# This test demonstrates the integration without mocking everything
|
||||
# In a real scenario, this would interface with actual Line and Text objects
|
||||
print(f"✓ Realistic scenario test completed")
|
||||
print("✓ Realistic scenario test completed")
|
||||
print(f" - Words to layout: {len(realistic_paragraph.words)}")
|
||||
print(f" - Page width: {realistic_page.available_width}px")
|
||||
print(f" - Word spacing constraints: {concrete_style.word_spacing_min}-{concrete_style.word_spacing_max}px")
|
||||
print(
|
||||
f" - Word spacing constraints: {concrete_style.word_spacing_min}-{concrete_style.word_spacing_max}px")
|
||||
|
||||
|
||||
class TestTableLayouter:
|
||||
|
||||
@ -7,20 +7,15 @@ in multi-page layout scenarios.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch
|
||||
from PIL import Image, ImageDraw
|
||||
import numpy as np
|
||||
from typing import List, Optional
|
||||
import os
|
||||
import logging
|
||||
|
||||
from pyWebLayout.layout.document_layouter import paragraph_layouter, DocumentLayouter
|
||||
from pyWebLayout.layout.document_layouter import paragraph_layouter
|
||||
from pyWebLayout.style.abstract_style import AbstractStyle
|
||||
from pyWebLayout.style.concrete_style import ConcreteStyle, StyleResolver, RenderingContext
|
||||
from pyWebLayout.style.fonts import Font
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.concrete.text import Line, Text
|
||||
from pyWebLayout.concrete.text import Line
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
|
||||
# Enable logging to see font loading messages
|
||||
@ -34,7 +29,12 @@ def verify_bundled_font_available():
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
# Navigate up to pyWebLayout root, then to assets/fonts
|
||||
project_root = os.path.dirname(os.path.dirname(current_dir))
|
||||
bundled_font_path = os.path.join(project_root, 'pyWebLayout', 'assets', 'fonts', 'DejaVuSans.ttf')
|
||||
bundled_font_path = os.path.join(
|
||||
project_root,
|
||||
'pyWebLayout',
|
||||
'assets',
|
||||
'fonts',
|
||||
'DejaVuSans.ttf')
|
||||
|
||||
logger.info(f"Integration tests checking for bundled font at: {bundled_font_path}")
|
||||
|
||||
@ -58,7 +58,8 @@ class MockWord(Word):
|
||||
style = Font(font_size=16)
|
||||
# Verify the font loaded properly
|
||||
if style.font.path is None:
|
||||
logger.warning("Font loaded without explicit path - may be using PIL default")
|
||||
logger.warning(
|
||||
"Font loaded without explicit path - may be using PIL default")
|
||||
# Initialize the base Word with required parameters
|
||||
super().__init__(text, style)
|
||||
self._concrete_texts = []
|
||||
@ -122,7 +123,8 @@ class TestDocumentLayouterIntegration:
|
||||
)
|
||||
|
||||
# Layout the paragraph
|
||||
success, failed_word_index, remaining_pretext = paragraph_layouter(paragraph, page)
|
||||
success, failed_word_index, remaining_pretext = paragraph_layouter(
|
||||
paragraph, page)
|
||||
|
||||
# Verify successful layout
|
||||
assert success is True
|
||||
@ -145,25 +147,30 @@ class TestDocumentLayouterIntegration:
|
||||
small_page = Page(size=(150, 80), style=small_page_style)
|
||||
|
||||
# Create a long paragraph that will definitely overflow
|
||||
long_text = " ".join([f"verylongword{i:02d}" for i in range(20)]) # 20 long words
|
||||
long_text = " ".join(
|
||||
[f"verylongword{i:02d}" for i in range(20)]) # 20 long words
|
||||
paragraph = MockParagraph(
|
||||
long_text,
|
||||
AbstractStyle(word_spacing=4.0, word_spacing_min=2.0, word_spacing_max=8.0)
|
||||
)
|
||||
|
||||
# Layout the paragraph - should fail due to page overflow
|
||||
success, failed_word_index, remaining_pretext = paragraph_layouter(paragraph, small_page)
|
||||
success, failed_word_index, remaining_pretext = paragraph_layouter(
|
||||
paragraph, small_page)
|
||||
|
||||
# Either should fail due to overflow OR succeed with limited content
|
||||
if success:
|
||||
# If it succeeded, verify it fit some content
|
||||
assert len(small_page.children) > 0
|
||||
print(f"✓ Multi-page test: Content fit on small page, {len(small_page.children)} lines created")
|
||||
print(
|
||||
f"✓ Multi-page test: Content fit on small page, {len(small_page.children)} lines created")
|
||||
else:
|
||||
# If it failed, verify overflow handling
|
||||
assert failed_word_index is not None # Should indicate where it failed
|
||||
assert failed_word_index < len(paragraph.words) # Should be within word range
|
||||
print(f"✓ Multi-page test: Page overflow at word {failed_word_index}, {len(small_page.children)} lines fit")
|
||||
assert failed_word_index < len(
|
||||
paragraph.words) # Should be within word range
|
||||
print(
|
||||
f"✓ Multi-page test: Page overflow at word {failed_word_index}, {len(small_page.children)} lines fit")
|
||||
|
||||
def test_word_spacing_constraints_in_real_lines(self):
|
||||
"""Test that word spacing constraints are properly used in real Line objects."""
|
||||
@ -192,23 +199,22 @@ class TestDocumentLayouterIntegration:
|
||||
assert min_spacing == 3 # From our constraint
|
||||
assert max_spacing == 10 # From our constraint
|
||||
|
||||
print(f"✓ Word spacing test: {len(page.children)} lines with constraints (3, 10)")
|
||||
print(
|
||||
f"✓ Word spacing test: {len(page.children)} lines with constraints (3, 10)")
|
||||
|
||||
def test_different_alignment_strategies_with_constraints(self):
|
||||
"""Test different text alignment strategies with word spacing constraints."""
|
||||
alignments_to_test = [
|
||||
("left", AbstractStyle(text_align="left", word_spacing_min=2.0, word_spacing_max=6.0)),
|
||||
("justify", AbstractStyle(text_align="justify", word_spacing_min=3.0, word_spacing_max=12.0)),
|
||||
("center", AbstractStyle(text_align="center", word_spacing_min=1.0, word_spacing_max=5.0))
|
||||
]
|
||||
("left", AbstractStyle(
|
||||
text_align="left", word_spacing_min=2.0, word_spacing_max=6.0)), ("justify", AbstractStyle(
|
||||
text_align="justify", word_spacing_min=3.0, word_spacing_max=12.0)), ("center", AbstractStyle(
|
||||
text_align="center", word_spacing_min=1.0, word_spacing_max=5.0))]
|
||||
|
||||
for alignment_name, style in alignments_to_test:
|
||||
page_style = PageStyle(border_width=20, padding=(10, 10, 10, 10))
|
||||
page = Page(size=(350, 200), style=page_style)
|
||||
paragraph = MockParagraph(
|
||||
"This sentence will test different alignment strategies with word spacing.",
|
||||
style
|
||||
)
|
||||
"This sentence will test different alignment strategies with word spacing.", style)
|
||||
|
||||
success, _, _ = paragraph_layouter(paragraph, page)
|
||||
assert success is True
|
||||
@ -231,15 +237,19 @@ class TestDocumentLayouterIntegration:
|
||||
# Create a document with multiple paragraphs
|
||||
paragraphs = [
|
||||
MockParagraph(
|
||||
"This is the first paragraph of our document. It contains enough text to potentially span multiple lines and test the word spacing constraints properly.",
|
||||
"This is the first paragraph of our document. It contains enough text to "
|
||||
"potentially span multiple lines and test the word spacing constraints properly.",
|
||||
AbstractStyle(word_spacing=3.0, word_spacing_min=2.0, word_spacing_max=8.0)
|
||||
),
|
||||
MockParagraph(
|
||||
"Here is a second paragraph with different styling. This paragraph uses different word spacing constraints to test the flexibility of the system.",
|
||||
"Here is a second paragraph with different styling. This paragraph uses "
|
||||
"different word spacing constraints to test the flexibility of the system.",
|
||||
AbstractStyle(word_spacing=5.0, word_spacing_min=3.0, word_spacing_max=12.0)
|
||||
),
|
||||
MockParagraph(
|
||||
"The third and final paragraph completes our test document. It should demonstrate that the layouter can handle multiple paragraphs with varying content lengths and styling requirements.",
|
||||
"The third and final paragraph completes our test document. It should "
|
||||
"demonstrate that the layouter can handle multiple paragraphs with varying "
|
||||
"content lengths and styling requirements.",
|
||||
AbstractStyle(word_spacing=4.0, word_spacing_min=2.5, word_spacing_max=10.0)
|
||||
)
|
||||
]
|
||||
@ -284,7 +294,7 @@ class TestDocumentLayouterIntegration:
|
||||
def test_word_spacing_constraint_resolution_integration(self):
|
||||
"""Test the complete integration from AbstractStyle to Line spacing."""
|
||||
page_style = PageStyle(border_width=20, padding=(10, 10, 10, 10))
|
||||
page = Page(size=(400, 600), style=page_style)
|
||||
_page = Page(size=(400, 600), style=page_style)
|
||||
|
||||
# Test different constraint scenarios
|
||||
test_cases = [
|
||||
@ -325,8 +335,10 @@ class TestDocumentLayouterIntegration:
|
||||
line = test_page.children[0]
|
||||
min_spacing, max_spacing = line._spacing
|
||||
|
||||
assert min_spacing == case["expected_min"], f"Min constraint failed for {case['name']}"
|
||||
assert max_spacing == case["expected_max"], f"Max constraint failed for {case['name']}"
|
||||
assert min_spacing == case["expected_min"], f"Min constraint failed for {
|
||||
case['name']}"
|
||||
assert max_spacing == case["expected_max"], f"Max constraint failed for {
|
||||
case['name']}"
|
||||
|
||||
print(f"✓ {case['name']}: constraints ({min_spacing}, {max_spacing})")
|
||||
|
||||
@ -342,7 +354,8 @@ class TestDocumentLayouterIntegration:
|
||||
AbstractStyle(word_spacing=3.0, word_spacing_min=2.0, word_spacing_max=8.0)
|
||||
)
|
||||
|
||||
success, failed_word_index, remaining_pretext = paragraph_layouter(paragraph, narrow_page)
|
||||
success, failed_word_index, remaining_pretext = paragraph_layouter(
|
||||
paragraph, narrow_page)
|
||||
|
||||
# Should succeed with hyphenation or handle overflow gracefully
|
||||
if success:
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -37,7 +37,10 @@ class TestStyleObjects(unittest.TestCase):
|
||||
self.assertEqual(TextDecoration.STRIKETHROUGH.value, "strikethrough")
|
||||
|
||||
# Test that all expected values exist
|
||||
decorations = [TextDecoration.NONE, TextDecoration.UNDERLINE, TextDecoration.STRIKETHROUGH]
|
||||
decorations = [
|
||||
TextDecoration.NONE,
|
||||
TextDecoration.UNDERLINE,
|
||||
TextDecoration.STRIKETHROUGH]
|
||||
self.assertEqual(len(decorations), 3)
|
||||
|
||||
def test_alignment_enum(self):
|
||||
@ -121,7 +124,9 @@ class TestStyleObjects(unittest.TestCase):
|
||||
# Test with_decoration
|
||||
decoration_font = original_font.with_decoration(TextDecoration.UNDERLINE)
|
||||
self.assertEqual(decoration_font.decoration, TextDecoration.UNDERLINE)
|
||||
self.assertEqual(original_font.decoration, TextDecoration.NONE) # Original unchanged
|
||||
self.assertEqual(
|
||||
original_font.decoration,
|
||||
TextDecoration.NONE) # Original unchanged
|
||||
|
||||
def test_font_property_access(self):
|
||||
"""Test Font property access methods."""
|
||||
|
||||
@ -8,12 +8,12 @@ based on user preferences.
|
||||
|
||||
import pytest
|
||||
from pyWebLayout.style.abstract_style import (
|
||||
AbstractStyle, AbstractStyleRegistry, FontFamily, FontSize, TextAlign
|
||||
AbstractStyle, AbstractStyleRegistry, FontFamily, FontSize
|
||||
)
|
||||
from pyWebLayout.style.concrete_style import (
|
||||
ConcreteStyle, ConcreteStyleRegistry, RenderingContext, StyleResolver
|
||||
ConcreteStyleRegistry, RenderingContext, StyleResolver
|
||||
)
|
||||
from pyWebLayout.style.fonts import FontWeight, FontStyle, TextDecoration
|
||||
from pyWebLayout.style.fonts import FontWeight
|
||||
|
||||
|
||||
def test_abstract_style_is_hashable():
|
||||
@ -99,7 +99,8 @@ def test_style_resolver_user_preferences():
|
||||
# Resolve to concrete style
|
||||
concrete_style = resolver.resolve_style(abstract_style)
|
||||
|
||||
# Font size should be: 20 (base) * 1.0 (medium) * 1.5 (scale) * 1.2 (large_text) = 36
|
||||
# Font size should be: 20 (base) * 1.0 (medium) * 1.5 (scale) * 1.2
|
||||
# (large_text) = 36
|
||||
expected_size = int(20 * 1.0 * 1.5 * 1.2)
|
||||
assert concrete_style.font_size == expected_size
|
||||
|
||||
|
||||
@ -5,9 +5,8 @@ This test shows how to use the new min/max word spacing constraints
|
||||
in the style system.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pyWebLayout.style.abstract_style import AbstractStyle, AbstractStyleRegistry
|
||||
from pyWebLayout.style.concrete_style import ConcreteStyle, StyleResolver, RenderingContext
|
||||
from pyWebLayout.style.concrete_style import StyleResolver, RenderingContext
|
||||
|
||||
|
||||
class TestWordSpacingConstraints:
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -25,12 +25,17 @@ class TestFontUtilities(unittest.TestCase):
|
||||
"""Test that get_bundled_font_path finds the bundled font."""
|
||||
font_path = get_bundled_font_path()
|
||||
self.assertIsNotNone(font_path, "Bundled font path should not be None")
|
||||
self.assertTrue(os.path.exists(font_path), f"Font file should exist at {font_path}")
|
||||
self.assertTrue(font_path.endswith("DejaVuSans.ttf"), "Font path should end with DejaVuSans.ttf")
|
||||
self.assertTrue(
|
||||
os.path.exists(font_path),
|
||||
f"Font file should exist at {font_path}")
|
||||
self.assertTrue(
|
||||
font_path.endswith("DejaVuSans.ttf"),
|
||||
"Font path should end with DejaVuSans.ttf")
|
||||
|
||||
def test_verify_bundled_font_available(self):
|
||||
"""Test that the bundled font can be verified and loaded."""
|
||||
self.assertTrue(verify_bundled_font_available(), "Bundled font should be available and loadable")
|
||||
self.assertTrue(verify_bundled_font_available(),
|
||||
"Bundled font should be available and loadable")
|
||||
|
||||
def test_create_test_font_with_defaults(self):
|
||||
"""Test creating a test font with default parameters."""
|
||||
@ -71,7 +76,8 @@ class TestFontUtilities(unittest.TestCase):
|
||||
try:
|
||||
ensure_consistent_font_in_tests()
|
||||
except RuntimeError:
|
||||
self.fail("ensure_consistent_font_in_tests() raised RuntimeError when font should be available")
|
||||
self.fail(
|
||||
"ensure_consistent_font_in_tests() raised RuntimeError when font should be available")
|
||||
|
||||
def test_bundled_font_loads_with_pil(self):
|
||||
"""Test that the bundled font can be loaded directly with PIL."""
|
||||
@ -93,7 +99,8 @@ class TestFontUtilities(unittest.TestCase):
|
||||
self.assertEqual(font1.font_size, font2.font_size)
|
||||
|
||||
# Test that text measurements are consistent
|
||||
# This is a basic check - in real usage, text measurement consistency is what matters most
|
||||
# This is a basic check - in real usage, text measurement consistency is
|
||||
# what matters most
|
||||
self.assertEqual(font1.font_size, font2.font_size)
|
||||
|
||||
def test_different_sizes_create_different_fonts(self):
|
||||
@ -119,13 +126,17 @@ class TestFontPathResolution(unittest.TestCase):
|
||||
"""Test that the font path points to a file, not a directory."""
|
||||
font_path = get_bundled_font_path()
|
||||
if font_path:
|
||||
self.assertTrue(os.path.isfile(font_path), "Font path should point to a file")
|
||||
self.assertTrue(
|
||||
os.path.isfile(font_path),
|
||||
"Font path should point to a file")
|
||||
|
||||
def test_font_file_has_correct_extension(self):
|
||||
"""Test that the font file has the expected .ttf extension."""
|
||||
font_path = get_bundled_font_path()
|
||||
if font_path:
|
||||
self.assertTrue(font_path.lower().endswith('.ttf'), "Font file should have .ttf extension")
|
||||
self.assertTrue(
|
||||
font_path.lower().endswith('.ttf'),
|
||||
"Font file should have .ttf extension")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@ -6,7 +6,6 @@ preventing inconsistencies that can arise from different system fonts.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import Optional
|
||||
from PIL import ImageFont
|
||||
|
||||
@ -30,7 +29,12 @@ def get_bundled_font_path() -> Optional[str]:
|
||||
project_root = os.path.dirname(os.path.dirname(current_dir))
|
||||
|
||||
# Path to the bundled font
|
||||
bundled_font_path = os.path.join(project_root, 'pyWebLayout', 'assets', 'fonts', 'DejaVuSans.ttf')
|
||||
bundled_font_path = os.path.join(
|
||||
project_root,
|
||||
'pyWebLayout',
|
||||
'assets',
|
||||
'fonts',
|
||||
'DejaVuSans.ttf')
|
||||
|
||||
if os.path.exists(bundled_font_path):
|
||||
return bundled_font_path
|
||||
@ -61,7 +65,7 @@ def verify_bundled_font_available() -> bool:
|
||||
|
||||
try:
|
||||
# Try to load the font with PIL to verify it's valid
|
||||
test_font = ImageFont.truetype(font_path, 16)
|
||||
ImageFont.truetype(font_path, 16)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user