From 781a9b6c08888d6ba08cc2236a74c42e9726fdd9 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 8 Nov 2025 23:46:15 +0100 Subject: [PATCH] auto flake and corrections --- examples/01_simple_page_rendering.py | 41 +- examples/02_text_and_layout.py | 27 +- examples/03_page_layouts.py | 26 +- examples/04_table_rendering.py | 30 +- examples/05_html_table_with_images.py | 39 +- pyWebLayout/__init__.py | 7 - pyWebLayout/abstract/__init__.py | 29 +- pyWebLayout/abstract/block.py | 711 ++++++++++-------- pyWebLayout/abstract/document.py | 244 +++--- pyWebLayout/abstract/functional.py | 74 +- pyWebLayout/abstract/inline.py | 165 ++-- pyWebLayout/abstract/interactive_image.py | 9 +- pyWebLayout/concrete/__init__.py | 27 +- pyWebLayout/concrete/box.py | 18 +- pyWebLayout/concrete/functional.py | 178 ++--- pyWebLayout/concrete/image.py | 122 +-- pyWebLayout/concrete/page.py | 42 +- pyWebLayout/concrete/table.py | 95 ++- pyWebLayout/concrete/text.py | 250 +++--- pyWebLayout/core/__init__.py | 30 +- pyWebLayout/core/base.py | 53 +- pyWebLayout/core/callback_registry.py | 4 +- pyWebLayout/core/highlight.py | 5 +- pyWebLayout/core/query.py | 1 - pyWebLayout/io/__init__.py | 1 - pyWebLayout/io/readers/epub_reader.py | 253 ++++--- pyWebLayout/io/readers/html_extraction.py | 48 +- pyWebLayout/layout/document_layouter.py | 147 ++-- pyWebLayout/layout/ereader_layout.py | 256 ++++--- pyWebLayout/layout/ereader_manager.py | 220 +++--- pyWebLayout/layout/page_buffer.py | 235 +++--- pyWebLayout/style/__init__.py | 1 - pyWebLayout/style/abstract_style.py | 127 ++-- pyWebLayout/style/alignment.py | 5 +- pyWebLayout/style/concrete_style.py | 179 +++-- pyWebLayout/style/fonts.py | 89 ++- pyWebLayout/style/page_style.py | 5 +- scripts/debug_text_positioning.py | 32 +- scripts/epub_page_renderer.py | 147 ++-- .../epub_page_renderer_documentlayouter.py | 144 ++-- scripts/run_coverage.py | 48 +- scripts/run_coverage_gutters.py | 20 +- scripts/update_coverage_badges.py | 20 +- scripts/update_coverage_gutters.py | 22 +- tests/abstract/test_abstract_blocks.py | 256 +++---- tests/abstract/test_abstract_document.py | 253 ++++--- tests/abstract/test_abstract_functional.py | 210 +++--- tests/abstract/test_abstract_inline.py | 413 +++++----- tests/abstract/test_document_mixins.py | 4 +- tests/abstract/test_linked_elements.py | 66 +- tests/concrete/test_alignment_handlers.py | 231 ++++-- tests/concrete/test_concrete_box.py | 45 +- tests/concrete/test_concrete_functional.py | 239 +++--- tests/concrete/test_concrete_image.py | 169 +++-- tests/concrete/test_concrete_text.py | 79 +- tests/concrete/test_linkedword_hyphenation.py | 7 +- .../concrete/test_new_page_implementation.py | 137 ++-- tests/concrete/test_table_rendering.py | 88 ++- tests/core/test_highlight.py | 1 - tests/core/test_query_system.py | 4 +- tests/io_tests/test_epub_reader.py | 371 +++++---- tests/io_tests/test_html_extraction.py | 233 +++--- .../test_html_extraction_functions.py | 275 ++++--- tests/io_tests/test_html_file_loader.py | 84 ++- tests/io_tests/test_html_link_end_to_end.py | 5 +- .../io_tests/test_html_link_interactivity.py | 22 +- tests/io_tests/test_html_links.py | 85 ++- tests/layout/test_ereader_layout.py | 57 +- tests/layout/test_ereader_manager.py | 14 +- tests/layout/test_html_links_in_ereader.py | 22 +- tests/layouter/test_document_layouter.py | 239 +++--- .../test_document_layouter_integration.py | 179 +++-- tests/mixins/font_registry_tests.py | 20 +- tests/mixins/metadata_tests.py | 5 +- tests/style/test_html_style.py | 65 +- tests/style/test_new_style_system.py | 81 +- tests/style/test_word_spacing_constraints.py | 53 +- tests/test_callback_registry.py | 5 +- tests/test_interactive_image.py | 16 +- tests/utils/test_font_utilities.py | 57 +- tests/utils/test_fonts.py | 48 +- 81 files changed, 4646 insertions(+), 3718 deletions(-) diff --git a/examples/01_simple_page_rendering.py b/examples/01_simple_page_rendering.py index 3c75d30..b070c7a 100644 --- a/examples/01_simple_page_rendering.py +++ b/examples/01_simple_page_rendering.py @@ -11,6 +11,8 @@ This example demonstrates: This is a foundational example showing the basic Page API. """ +from pyWebLayout.style.page_style import PageStyle +from pyWebLayout.concrete.page import Page import sys from pathlib import Path from PIL import Image, ImageDraw, ImageFont @@ -18,9 +20,6 @@ from PIL import Image, ImageDraw, ImageFont # Add pyWebLayout to path sys.path.insert(0, str(Path(__file__).parent.parent)) -from pyWebLayout.concrete.page import Page -from pyWebLayout.style.page_style import PageStyle - def draw_placeholder_content(page: Page): """Draw some placeholder content directly on the page to visualize the layout.""" @@ -46,13 +45,32 @@ def draw_placeholder_content(page: Page): # Add some text labels try: font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 12) - except: + except BaseException: font = ImageFont.load_default() # Label the areas - draw.text((content_x + 10, content_y + 10), "Content Area", fill=(100, 100, 100), font=font) - draw.text((10, 10), f"Border: {page.border_size}px", fill=(150, 150, 150), font=font) - draw.text((content_x + 10, content_y + 30), f"Size: {content_w}x{content_h}", fill=(100, 100, 100), font=font) + draw.text( + (content_x + 10, + content_y + 10), + "Content Area", + fill=( + 100, + 100, + 100), + font=font) + draw.text( + (10, 10), f"Border: { + page.border_size}px", fill=( + 150, 150, 150), font=font) + draw.text( + (content_x + 10, + content_y + 30), + f"Size: {content_w}x{content_h}", + fill=( + 100, + 100, + 100), + font=font) def create_example_1(): @@ -117,7 +135,7 @@ def create_example_4(): def combine_into_grid(pages, title): """Combine multiple pages into a 2x2 grid with title.""" - print(f"\n Combining pages into grid...") + print("\n Combining pages into grid...") # Render all pages images = [page.render() for page in pages] @@ -141,8 +159,9 @@ def combine_into_grid(pages, title): # Draw title try: - title_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 20) - except: + title_font = ImageFont.truetype( + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 20) + except BaseException: title_font = ImageFont.load_default() # Center the title @@ -187,7 +206,7 @@ def main(): output_path = output_dir / "example_01_page_rendering.png" combined_image.save(output_path) - print(f"\n✓ Example completed!") + print("\n✓ Example completed!") print(f" Output saved to: {output_path}") print(f" Image size: {combined_image.size[0]}x{combined_image.size[1]} pixels") print(f" Created {len(pages)} page examples") diff --git a/examples/02_text_and_layout.py b/examples/02_text_and_layout.py index 91f2e3f..8699175 100644 --- a/examples/02_text_and_layout.py +++ b/examples/02_text_and_layout.py @@ -11,6 +11,10 @@ This example demonstrates text rendering using the pyWebLayout system: This example uses the HTML parsing system to create rich text layouts. """ +from pyWebLayout.style.page_style import PageStyle +from pyWebLayout.concrete.page import Page +from pyWebLayout.style import Font +from pyWebLayout.io.readers.html_extraction import parse_html_string import sys from pathlib import Path from PIL import Image, ImageDraw, ImageFont @@ -18,11 +22,6 @@ from PIL import Image, ImageDraw, ImageFont # Add pyWebLayout to path sys.path.insert(0, str(Path(__file__).parent.parent)) -from pyWebLayout.io.readers.html_extraction import parse_html_string -from pyWebLayout.style import Font -from pyWebLayout.concrete.page import Page -from pyWebLayout.style.page_style import PageStyle - def create_sample_document(): """Create different HTML samples demonstrating various features.""" @@ -37,7 +36,8 @@ def create_sample_document():

This is left-aligned text. It is the default alignment for most text.

Justified Text

-

This paragraph is justified. The text stretches to fill the entire width of the line, creating clean edges on both sides.

+

This paragraph is justified. The text stretches to fill + the entire width of the line, creating clean edges on both sides.

Centered

This text is centered.

@@ -112,7 +112,7 @@ def render_html_to_image(html_content, page_size=(500, 400)): # Add a note that this is HTML-parsed content try: font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 11) - except: + except BaseException: font = ImageFont.load_default() # Draw info about what was parsed @@ -128,7 +128,7 @@ def render_html_to_image(html_content, page_size=(500, 400)): for i, block in enumerate(blocks[:10]): # Show first 10 block_type = type(block).__name__ draw.text((content_x, y_offset), - f" {i+1}. {block_type}", + f" {i + 1}. {block_type}", fill=(60, 60, 60), font=font) y_offset += 18 @@ -150,8 +150,9 @@ def combine_samples(samples): # Add title to image draw = ImageDraw.Draw(img) try: - font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 14) - except: + font = ImageFont.truetype( + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 14) + except BaseException: font = ImageFont.load_default() draw.text((10, 10), title, fill=(50, 50, 150), font=font) @@ -201,11 +202,11 @@ def main(): output_path = output_dir / "example_02_text_and_layout.png" combined_image.save(output_path) - print(f"\n✓ Example completed!") + print("\n✓ Example completed!") print(f" Output saved to: {output_path}") print(f" Image size: {combined_image.size[0]}x{combined_image.size[1]} pixels") - print(f" Note: This example demonstrates HTML parsing") - print(f" Full layout rendering requires the typesetting engine") + print(" Note: This example demonstrates HTML parsing") + print(" Full layout rendering requires the typesetting engine") return combined_image diff --git a/examples/03_page_layouts.py b/examples/03_page_layouts.py index 0474e59..49a5af3 100644 --- a/examples/03_page_layouts.py +++ b/examples/03_page_layouts.py @@ -11,6 +11,8 @@ This example demonstrates different page layout configurations: Shows how the pyWebLayout system handles different page dimensions. """ +from pyWebLayout.style.page_style import PageStyle +from pyWebLayout.concrete.page import Page import sys from pathlib import Path from PIL import Image, ImageDraw, ImageFont @@ -18,9 +20,6 @@ from PIL import Image, ImageDraw, ImageFont # Add pyWebLayout to path sys.path.insert(0, str(Path(__file__).parent.parent)) -from pyWebLayout.concrete.page import Page -from pyWebLayout.style.page_style import PageStyle - def add_page_info(page: Page, title: str): """Add informational text to a page showing its properties.""" @@ -30,9 +29,11 @@ def add_page_info(page: Page, title: str): draw = page.draw try: - font_large = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 14) - font_small = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 11) - except: + font_large = ImageFont.truetype( + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 14) + font_small = ImageFont.truetype( + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 11) + except BaseException: font_large = ImageFont.load_default() font_small = ImageFont.load_default() @@ -164,13 +165,15 @@ def create_layout_showcase(layouts): # Find max dimensions for each row/column max_widths = [] for col in range(cols): - col_images = [images[row * cols + col][1] for row in range(rows) if row * cols + col < len(images)] + col_images = [images[row * cols + col][1] + for row in range(rows) if row * cols + col < len(images)] if col_images: max_widths.append(max(img.size[0] for img in col_images)) max_heights = [] for row in range(rows): - row_images = [images[row * cols + col][1] for col in range(cols) if row * cols + col < len(images)] + row_images = [images[row * cols + col][1] + for col in range(cols) if row * cols + col < len(images)] if row_images: max_heights.append(max(img.size[1] for img in row_images)) @@ -184,8 +187,9 @@ def create_layout_showcase(layouts): # Add title try: - title_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 24) - except: + title_font = ImageFont.truetype( + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 24) + except BaseException: title_font = ImageFont.load_default() title_text = "Page Layout Examples" @@ -231,7 +235,7 @@ def main(): output_path = output_dir / "example_03_page_layouts.png" combined_image.save(output_path) - print(f"\n✓ Example completed!") + print("\n✓ Example completed!") print(f" Output saved to: {output_path}") print(f" Image size: {combined_image.size[0]}x{combined_image.size[1]} pixels") print(f" Created {len(layouts)} layout examples") diff --git a/examples/04_table_rendering.py b/examples/04_table_rendering.py index cdbf830..6fa7b21 100644 --- a/examples/04_table_rendering.py +++ b/examples/04_table_rendering.py @@ -12,6 +12,13 @@ This example demonstrates rendering HTML tables: Shows the HTML-first rendering pipeline. """ +from pyWebLayout.abstract.block import Table +from pyWebLayout.style import Font +from pyWebLayout.io.readers.html_extraction import parse_html_string +from pyWebLayout.layout.document_layouter import DocumentLayouter +from pyWebLayout.style.page_style import PageStyle +from pyWebLayout.concrete.table import TableStyle +from pyWebLayout.concrete.page import Page import sys from pathlib import Path from PIL import Image, ImageDraw @@ -19,14 +26,6 @@ from PIL import Image, ImageDraw # Add pyWebLayout to path sys.path.insert(0, str(Path(__file__).parent.parent)) -from pyWebLayout.concrete.page import Page -from pyWebLayout.concrete.table import TableStyle -from pyWebLayout.style.page_style import PageStyle -from pyWebLayout.layout.document_layouter import DocumentLayouter -from pyWebLayout.io.readers.html_extraction import parse_html_string -from pyWebLayout.style import Font -from pyWebLayout.abstract.block import Table - def create_simple_table_example(): """Create a simple table from HTML.""" @@ -179,7 +178,13 @@ def create_data_table_example(): return html, "Data Table" -def render_table_example(html: str, title: str, style_variant: int = 0, page_size=(500, 400)): +def render_table_example( + html: str, + title: str, + style_variant: int = 0, + page_size=( + 500, + 400)): """Render a table from HTML to an image using DocumentLayouter.""" # Create page with varying backgrounds bg_colors = [ @@ -299,8 +304,9 @@ def combine_examples(examples): # Add main title from PIL import ImageFont try: - main_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 20) - except: + main_font = ImageFont.truetype( + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 20) + except BaseException: main_font = ImageFont.load_default() title_text = "Table Rendering Examples" @@ -346,7 +352,7 @@ def main(): output_path = output_dir / "example_04_table_rendering.png" combined_image.save(output_path) - print(f"\n✓ Example completed!") + print("\n✓ Example completed!") print(f" Output saved to: {output_path}") print(f" Image size: {combined_image.size[0]}x{combined_image.size[1]} pixels") print(f" Created {len(examples)} table examples") diff --git a/examples/05_html_table_with_images.py b/examples/05_html_table_with_images.py index 23ec05f..30e89eb 100644 --- a/examples/05_html_table_with_images.py +++ b/examples/05_html_table_with_images.py @@ -10,6 +10,12 @@ This example demonstrates the complete pipeline: No custom rendering code needed - DocumentLayouter handles everything! """ +from pyWebLayout.style import Font +from pyWebLayout.concrete.table import TableStyle +from pyWebLayout.layout.document_layouter import DocumentLayouter +from pyWebLayout.style.page_style import PageStyle +from pyWebLayout.concrete.page import Page +from pyWebLayout.io.readers.html_extraction import parse_html_string import sys from pathlib import Path from PIL import Image @@ -17,20 +23,13 @@ from PIL import Image # Add pyWebLayout to path sys.path.insert(0, str(Path(__file__).parent.parent)) -from pyWebLayout.io.readers.html_extraction import parse_html_string -from pyWebLayout.concrete.page import Page -from pyWebLayout.style.page_style import PageStyle -from pyWebLayout.layout.document_layouter import DocumentLayouter -from pyWebLayout.concrete.table import TableStyle -from pyWebLayout.style import Font - def create_book_catalog_html(): """Create HTML for a book catalog table with actual tags.""" # Get base path for images - use absolute paths for the img src - data_path = Path(__file__).parent.parent / "tests" / "data" + Path(__file__).parent.parent / "tests" / "data" - html = f""" + html = """ @@ -77,9 +76,9 @@ def create_book_catalog_html(): def create_product_showcase_html(): """Create HTML for a product showcase table with images.""" - data_path = Path(__file__).parent.parent / "tests" / "data" + Path(__file__).parent.parent / "tests" / "data" - html = f""" + html = """
@@ -107,8 +106,8 @@ def create_product_showcase_html(): def render_html_with_layouter(html_string: str, title: str, - table_style: TableStyle, - page_size=(600, 500)): + table_style: TableStyle, + page_size=(600, 500)): """ Render HTML using DocumentLayouter - the proper way! @@ -163,7 +162,7 @@ def render_html_with_layouter(html_string: str, title: str, if not success: print(f" ⚠ Warning: Block {type(block).__name__} didn't fit on page") - print(f" ✓ Layout complete!") + print(" ✓ Layout complete!") # Step 5: Get the rendered canvas # Note: Tables render directly onto page._canvas @@ -257,14 +256,14 @@ def main(): output_path = output_dir / "example_05_html_table_with_images.png" combined.save(output_path) - print(f"\n✓ Example completed!") + print("\n✓ Example completed!") print(f" Output saved to: {output_path}") print(f" Image size: {combined.size[0]}x{combined.size[1]} pixels") - print(f"\nThe complete pipeline:") - print(f" 1. HTML with tags → parse_html_string() → Abstract blocks") - print(f" 2. Abstract blocks → DocumentLayouter → Concrete objects") - print(f" 3. Page.render() → PNG output") - print(f"\n ✓ Using DocumentLayouter - NO custom rendering code!") + print("\nThe complete pipeline:") + print(" 1. HTML with tags → parse_html_string() → Abstract blocks") + print(" 2. Abstract blocks → DocumentLayouter → Concrete objects") + print(" 3. Page.render() → PNG output") + print("\n ✓ Using DocumentLayouter - NO custom rendering code!") return combined diff --git a/pyWebLayout/__init__.py b/pyWebLayout/__init__.py index 90a705c..240043d 100644 --- a/pyWebLayout/__init__.py +++ b/pyWebLayout/__init__.py @@ -11,19 +11,12 @@ save state, and resume rendering. __version__ = '0.1.0' # Core abstractions -from pyWebLayout.core import Renderable, Interactable, Layoutable, Queriable # Style components -from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration # Abstract document model -from pyWebLayout.abstract.document import Document, Book, Chapter, MetadataType # Concrete implementations -from pyWebLayout.concrete.box import Box -from pyWebLayout.concrete.text import Line -from pyWebLayout.concrete.page import Page # Abstract components -from pyWebLayout.abstract.inline import Word diff --git a/pyWebLayout/abstract/__init__.py b/pyWebLayout/abstract/__init__.py index 946e964..adc4694 100644 --- a/pyWebLayout/abstract/__init__.py +++ b/pyWebLayout/abstract/__init__.py @@ -1,7 +1,22 @@ -from .block import Block, BlockType, Paragraph, Heading, HeadingLevel, Quote, CodeBlock -from .block import HList, ListItem, ListStyle, Table, TableRow, TableCell -from .block import HorizontalRule, Image -from .interactive_image import InteractiveImage -from .inline import Word, FormattedSpan, LineBreak -from .document import Document, MetadataType, Chapter, Book -from .functional import Link, LinkType, Button, Form, FormField, FormFieldType +""" +Abstract layer for the pyWebLayout library. + +This package contains abstract representations of document elements that are +independent of rendering specifics. +""" + +from .inline import Word, FormattedSpan +from .block import Paragraph, Heading, Image, HeadingLevel +from .document import Document +from .functional import LinkType + +__all__ = [ + 'Word', + 'FormattedSpan', + 'Paragraph', + 'Heading', + 'Image', + 'HeadingLevel', + 'Document', + 'LinkType', +] diff --git a/pyWebLayout/abstract/block.py b/pyWebLayout/abstract/block.py index e972a9f..2b28aa6 100644 --- a/pyWebLayout/abstract/block.py +++ b/pyWebLayout/abstract/block.py @@ -1,4 +1,4 @@ -from typing import List, Iterator, Tuple, Dict, Optional, Union, Any +from typing import List, Iterator, Tuple, Dict, Optional, Any from enum import Enum import os import tempfile @@ -6,7 +6,6 @@ import urllib.request import urllib.parse from PIL import Image as PILImage from .inline import Word, FormattedSpan -from ..style import Font, FontWeight, FontStyle, TextDecoration from ..core import Hierarchical, Styleable, FontRegistry @@ -69,20 +68,20 @@ class Paragraph(Styleable, FontRegistry, Block): super().__init__(style=style, block_type=BlockType.PARAGRAPH) self._words: List[Word] = [] self._spans: List[FormattedSpan] = [] - + @classmethod def create_and_add_to(cls, container, style=None) -> 'Paragraph': """ Create a new Paragraph and add it to a container, inheriting style from the container if not explicitly provided. - + Args: container: The container to add the paragraph to (must have add_block method and style property) style: Optional style override. If None, inherits from container - + Returns: The newly created Paragraph object - + Raises: AttributeError: If the container doesn't have the required add_block method """ @@ -91,96 +90,98 @@ class Paragraph(Styleable, FontRegistry, Block): style = container.style elif style is None and hasattr(container, 'default_style'): style = container.default_style - + # Create the new paragraph paragraph = cls(style) - + # Add the paragraph to the container if hasattr(container, 'add_block'): container.add_block(paragraph) else: - raise AttributeError(f"Container {type(container).__name__} must have an 'add_block' method") - + raise AttributeError( + f"Container { + type(container).__name__} must have an 'add_block' method") + return paragraph def add_word(self, word: Word): """ Add a word to this paragraph. - + Args: word: The Word object to add """ self._words.append(word) - + def create_word(self, text: str, style=None, background=None) -> Word: """ Create a new word and add it to this paragraph, inheriting paragraph's style if not specified. - + This is a convenience method that uses Word.create_and_add_to() to create words that automatically inherit styling from this paragraph. - + Args: text: The text content of the word style: Optional Font style override. If None, attempts to inherit from paragraph background: Optional background color override - + Returns: The newly created Word object """ return Word.create_and_add_to(text, self, style, background) - + def add_span(self, span: FormattedSpan): """ Add a formatted span to this paragraph. - + Args: span: The FormattedSpan object to add """ self._spans.append(span) - + def create_span(self, style=None, background=None) -> FormattedSpan: """ Create a new formatted span with inherited style. - + Args: style: Optional Font style override. If None, inherits from paragraph background: Optional background color override - + Returns: The newly created FormattedSpan object """ return FormattedSpan.create_and_add_to(self, style, background) - + @property def words(self) -> List[Word]: """Get the list of words in this paragraph""" return self._words - + def words_iter(self) -> Iterator[Tuple[int, Word]]: """ Iterate over the words in this paragraph. - + Yields: Tuples of (index, word) for each word in the paragraph """ for i, word in enumerate(self._words): yield i, word - + def spans(self) -> Iterator[FormattedSpan]: """ Iterate over the formatted spans in this paragraph. - + Yields: Each FormattedSpan in the paragraph """ for span in self._spans: yield span - + @property def word_count(self) -> int: """Get the number of words in this paragraph""" return len(self._words) - + def __len__(self): return self.word_count @@ -202,11 +203,11 @@ class Heading(Paragraph): A heading element (h1, h2, h3, etc.) that contains text with a specific heading level. Headings inherit from Paragraph as they contain words but have additional properties. """ - + def __init__(self, level: HeadingLevel = HeadingLevel.H1, style=None): """ Initialize a heading element. - + Args: level: The heading level (h1-h6) style: Optional default style for words in this heading @@ -214,21 +215,25 @@ class Heading(Paragraph): super().__init__(style) self._block_type = BlockType.HEADING self._level = level - + @classmethod - def create_and_add_to(cls, container, level: HeadingLevel = HeadingLevel.H1, style=None) -> 'Heading': + def create_and_add_to( + cls, + container, + level: HeadingLevel = HeadingLevel.H1, + style=None) -> 'Heading': """ Create a new Heading and add it to a container, inheriting style from the container if not explicitly provided. - + Args: container: The container to add the heading to (must have add_block method and style property) level: The heading level (h1-h6) style: Optional style override. If None, inherits from container - + Returns: The newly created Heading object - + Raises: AttributeError: If the container doesn't have the required add_block method """ @@ -237,23 +242,25 @@ class Heading(Paragraph): style = container.style elif style is None and hasattr(container, 'default_style'): style = container.default_style - + # Create the new heading heading = cls(level, style) - + # Add the heading to the container if hasattr(container, 'add_block'): container.add_block(heading) else: - raise AttributeError(f"Container {type(container).__name__} must have an 'add_block' method") - + raise AttributeError( + f"Container { + type(container).__name__} must have an 'add_block' method") + return heading - + @property def level(self) -> HeadingLevel: """Get the heading level""" return self._level - + @level.setter def level(self, level: HeadingLevel): """Set the heading level""" @@ -264,31 +271,31 @@ class Quote(Block): """ A blockquote element that can contain other block elements. """ - + def __init__(self, style=None): """ Initialize an empty blockquote - + Args: style: Optional default style for child blocks """ super().__init__(BlockType.QUOTE) self._blocks: List[Block] = [] self._style = style - + @classmethod def create_and_add_to(cls, container, style=None) -> 'Quote': """ Create a new Quote and add it to a container, inheriting style from the container if not explicitly provided. - + Args: container: The container to add the quote to (must have add_block method and style property) style: Optional style override. If None, inherits from container - + Returns: The newly created Quote object - + Raises: AttributeError: If the container doesn't have the required add_block method """ @@ -297,67 +304,72 @@ class Quote(Block): style = container.style elif style is None and hasattr(container, 'default_style'): style = container.default_style - + # Create the new quote quote = cls(style) - + # Add the quote to the container if hasattr(container, 'add_block'): container.add_block(quote) else: - raise AttributeError(f"Container {type(container).__name__} must have an 'add_block' method") - + raise AttributeError( + f"Container { + type(container).__name__} must have an 'add_block' method") + return quote - + @property def style(self): """Get the default style for this quote""" return self._style - + @style.setter def style(self, style): """Set the default style for this quote""" self._style = style - + def add_block(self, block: Block): """ Add a block element to this quote. - + Args: block: The Block object to add """ self._blocks.append(block) block.parent = self - + def create_paragraph(self, style=None) -> Paragraph: """ Create a new paragraph and add it to this quote. - + Args: style: Optional style override. If None, inherits from quote - + Returns: The newly created Paragraph object """ return Paragraph.create_and_add_to(self, style) - - def create_heading(self, level: HeadingLevel = HeadingLevel.H1, style=None) -> Heading: + + def create_heading( + self, + level: HeadingLevel = HeadingLevel.H1, + style=None) -> Heading: """ Create a new heading and add it to this quote. - + Args: level: The heading level style: Optional style override. If None, inherits from quote - + Returns: The newly created Heading object """ return Heading.create_and_add_to(self, level, style) - + def blocks(self) -> Iterator[Block]: """ Iterate over the blocks in this quote. - + Yields: Each Block in the quote """ @@ -369,73 +381,75 @@ class CodeBlock(Block): """ A code block element containing pre-formatted text with syntax highlighting. """ - + def __init__(self, language: str = ""): """ Initialize a code block. - + Args: language: The programming language for syntax highlighting """ super().__init__(BlockType.CODE_BLOCK) self._language = language self._lines: List[str] = [] - + @classmethod def create_and_add_to(cls, container, language: str = "") -> 'CodeBlock': """ Create a new CodeBlock and add it to a container. - + Args: container: The container to add the code block to (must have add_block method) language: The programming language for syntax highlighting - + Returns: The newly created CodeBlock object - + Raises: AttributeError: If the container doesn't have the required add_block method """ # Create the new code block code_block = cls(language) - + # Add the code block to the container if hasattr(container, 'add_block'): container.add_block(code_block) else: - raise AttributeError(f"Container {type(container).__name__} must have an 'add_block' method") - + raise AttributeError( + f"Container { + type(container).__name__} must have an 'add_block' method") + return code_block - + @property def language(self) -> str: """Get the programming language""" return self._language - + @language.setter def language(self, language: str): """Set the programming language""" self._language = language - + def add_line(self, line: str): """ Add a line of code to this code block. - + Args: line: The line of code to add """ self._lines.append(line) - + def lines(self) -> Iterator[Tuple[int, str]]: """ Iterate over the lines in this code block. - + Yields: Tuples of (line_number, line_text) for each line """ for i, line in enumerate(self._lines): yield i, line - + @property def line_count(self) -> int: """Get the number of lines in this code block""" @@ -446,18 +460,18 @@ class ListStyle(Enum): """Enumeration of list styles""" UNORDERED = 1 #
@@ -214,25 +214,25 @@ class TestEPUBReader(unittest.TestCase):
- +

Code Block

function test() {
     console.log("Hello, world!");
     return true;
 }
- +

Inline Code

Use the print() function to output text.

''' - + # Add chapters to book book.add_item(chapter1) book.add_item(chapter2) book.add_item(chapter3) book.add_item(chapter4) - + # Define table of contents book.toc = ( epub.Link("chapter1.xhtml", "Introduction", "intro"), @@ -240,31 +240,32 @@ class TestEPUBReader(unittest.TestCase): epub.Link("chapter3.xhtml", "Lists and Quotes", "lists"), epub.Link("chapter4.xhtml", "Tables and Code", "tables") ) - + # Add navigation files book.add_item(epub.EpubNcx()) book.add_item(epub.EpubNav()) - + # Define spine book.spine = ['nav', chapter1, chapter2, chapter3, chapter4] - + # Create temporary file - epub_path = os.path.join(self.test_dir, f'test_complex_{len(self.epub_files)}.epub') + epub_path = os.path.join(self.test_dir, + f'test_complex_{len(self.epub_files)}.epub') epub.write_epub(epub_path, book, {}) self.epub_files.append(epub_path) - + return epub_path - + def create_epub_with_nested_content(self): """Create an EPUB with nested content structures.""" book = epub.EpubBook() - + # Set metadata book.set_identifier('nested-test-id-789') book.set_title('Nested Content Test') book.set_language('en') book.add_author('Test Author') - + # Chapter with nested content chapter = epub.EpubHtml( title='Nested Content', @@ -276,17 +277,17 @@ class TestEPUBReader(unittest.TestCase): Nested Content

Nested Content Examples

- +

Section in Div

Paragraph inside div.

- +

Subsection

Article Header

Article content with nested formatting.

- +
- + ''' - + # Add chapter to book book.add_item(chapter) - + # Define table of contents book.toc = (epub.Link("nested.xhtml", "Nested Content", "nested"),) - + # Add navigation files book.add_item(epub.EpubNcx()) book.add_item(epub.EpubNav()) - + # Define spine book.spine = ['nav', chapter] - + # Create temporary file - epub_path = os.path.join(self.test_dir, f'test_nested_{len(self.epub_files)}.epub') + epub_path = os.path.join(self.test_dir, + f'test_nested_{len(self.epub_files)}.epub') epub.write_epub(epub_path, book, {}) self.epub_files.append(epub_path) - + return epub_path - + def test_simple_epub_reading(self): """Test reading a simple EPUB file.""" epub_path = self.create_simple_epub() - + # Read the EPUB book = read_epub(epub_path) - + # Verify it's a Book object self.assertIsInstance(book, Book) - + # Check metadata self.assertEqual(book.title, "Test Book") - + # Check chapters chapters = list(book.chapters) self.assertEqual(len(chapters), 1) - + # Check chapter content chapter = chapters[0] blocks = list(chapter.blocks) self.assertGreater(len(blocks), 0) - + # Should have a heading and paragraphs has_heading = any(isinstance(block, Heading) for block in blocks) has_paragraph = any(isinstance(block, Paragraph) for block in blocks) - + self.assertTrue(has_heading, "Should contain at least one heading") self.assertTrue(has_paragraph, "Should contain at least one paragraph") - + def test_complex_epub_reading(self): """Test reading a complex EPUB file with multiple chapters.""" epub_path = self.create_complex_epub() - + # Read the EPUB book = read_epub(epub_path) - + # Verify it's a Book object self.assertIsInstance(book, Book) - + # Check metadata self.assertEqual(book.title, "Complex Test Book") - + # Check chapters chapters = list(book.chapters) self.assertEqual(len(chapters), 4) - + # Test each chapter has content for i, chapter in enumerate(chapters): blocks = list(chapter.blocks) - self.assertGreater(len(blocks), 0, f"Chapter {i+1} should have blocks") - + self.assertGreater(len(blocks), 0, f"Chapter {i + 1} should have blocks") + # Each chapter should start with a heading first_block = blocks[0] - self.assertIsInstance(first_block, Heading, f"Chapter {i+1} should start with heading") - + self.assertIsInstance( + first_block, Heading, f"Chapter { + i + 1} should start with heading") + def test_epub_styled_content(self): """Test that styled content in EPUB is properly parsed.""" epub_path = self.create_complex_epub() book = read_epub(epub_path) - + chapters = list(book.chapters) - + # Check styled content in chapter 2 (index 1) if len(chapters) > 1: chapter2_blocks = list(chapters[1].blocks) - + # Find paragraphs with styled text styled_words_found = False for block in chapter2_blocks: if isinstance(block, Paragraph): words = list(block.words_iter()) for _, word in words: - if (word.style.weight == FontWeight.BOLD or + if (word.style.weight == FontWeight.BOLD or word.style.style == FontStyle.ITALIC or - word.style.colour != (0, 0, 0)): # Non-black color + word.style.colour != (0, 0, 0)): # Non-black color styled_words_found = True break if styled_words_found: break - + self.assertTrue(styled_words_found, "Should find styled words in chapter 2") - + def test_epub_lists(self): """Test that lists in EPUB are properly parsed.""" epub_path = self.create_complex_epub() book = read_epub(epub_path) - + chapters = list(book.chapters) - + # Check lists in chapter 3 (index 2) if len(chapters) > 2: chapter3_blocks = list(chapters[2].blocks) - + # Find list blocks unordered_list_found = False ordered_list_found = False quote_found = False - + for block in chapter3_blocks: if isinstance(block, HList): if block.style == ListStyle.UNORDERED: unordered_list_found = True - + # Check list items items = list(block.items()) - self.assertGreater(len(items), 0, "Unordered list should have items") - + self.assertGreater( + len(items), 0, "Unordered list should have items") + elif block.style == ListStyle.ORDERED: ordered_list_found = True - + # Check list items items = list(block.items()) - self.assertGreater(len(items), 0, "Ordered list should have items") - + self.assertGreater( + len(items), 0, "Ordered list should have items") + elif isinstance(block, Quote): quote_found = True - - self.assertTrue(unordered_list_found, "Should find unordered list in chapter 3") + + self.assertTrue( + unordered_list_found, + "Should find unordered list in chapter 3") self.assertTrue(ordered_list_found, "Should find ordered list in chapter 3") self.assertTrue(quote_found, "Should find quote in chapter 3") - + def test_epub_tables(self): """Test that tables in EPUB are properly parsed.""" epub_path = self.create_complex_epub() book = read_epub(epub_path) - + chapters = list(book.chapters) - + # Check tables in chapter 4 (index 3) if len(chapters) > 3: chapter4_blocks = list(chapters[3].blocks) - + # Find table blocks table_found = False code_block_found = False - + for block in chapter4_blocks: if isinstance(block, Table): table_found = True - + # Check table has rows rows = list(block.all_rows()) self.assertGreater(len(rows), 0, "Table should have rows") - + elif isinstance(block, CodeBlock): code_block_found = True - + # Check code block has lines lines = list(block.lines()) self.assertGreater(len(lines), 0, "Code block should have lines") - + self.assertTrue(table_found, "Should find table in chapter 4") self.assertTrue(code_block_found, "Should find code block in chapter 4") - + def test_epub_nested_content(self): """Test that nested content structures are properly parsed.""" epub_path = self.create_epub_with_nested_content() book = read_epub(epub_path) - + chapters = list(book.chapters) self.assertEqual(len(chapters), 1) - + chapter_blocks = list(chapters[0].blocks) self.assertGreater(len(chapter_blocks), 0) - + # Should have multiple headings (h1, h2, h3, h4) headings = [block for block in chapter_blocks if isinstance(block, Heading)] - self.assertGreater(len(headings), 2, "Should have multiple headings from nested content") - + self.assertGreater( + len(headings), + 2, + "Should have multiple headings from nested content") + # Should have paragraphs and lists from nested content paragraphs = [block for block in chapter_blocks if isinstance(block, Paragraph)] lists = [block for block in chapter_blocks if isinstance(block, HList)] - - self.assertGreater(len(paragraphs), 0, "Should have paragraphs from nested content") + + self.assertGreater( + len(paragraphs), + 0, + "Should have paragraphs from nested content") self.assertGreater(len(lists), 0, "Should have lists from nested content") - + def test_epub_metadata_extraction(self): """Test that EPUB metadata is properly extracted.""" epub_path = self.create_complex_epub() book = read_epub(epub_path) - + # Check basic metadata self.assertEqual(book.title, "Complex Test Book") - + # Check that metadata was set (implementation may vary) # This tests that the metadata parsing doesn't crash self.assertIsNotNone(book.title) - + def test_epub_reader_class_direct(self): """Test EPUBReader class directly.""" epub_path = self.create_simple_epub() - + reader = EPUBReader(epub_path) book = reader.read() - + self.assertIsInstance(book, Book) self.assertEqual(book.title, "Test Book") - + def test_invalid_epub_handling(self): """Test handling of invalid EPUB files.""" # Create a non-EPUB file invalid_path = os.path.join(self.test_dir, 'invalid.epub') with open(invalid_path, 'w') as f: f.write("This is not an EPUB file") - + # Should raise an exception or handle gracefully with self.assertRaises(Exception): read_epub(invalid_path) - + def test_nonexistent_epub_handling(self): """Test handling of nonexistent EPUB files.""" nonexistent_path = os.path.join(self.test_dir, 'nonexistent.epub') - + # Should raise an exception with self.assertRaises(Exception): read_epub(nonexistent_path) - + def test_epub_with_custom_metadata(self): """Test EPUB with various metadata fields.""" book = epub.EpubBook() - + # Set comprehensive metadata book.set_identifier('custom-metadata-test') book.set_title('Custom Metadata Test') book.set_language('en') book.add_author('Primary Author') book.add_author('Secondary Author') - book.add_metadata('DC', 'description', 'A comprehensive test of metadata extraction') + book.add_metadata( + 'DC', + 'description', + 'A comprehensive test of metadata extraction') book.add_metadata('DC', 'subject', 'Testing') book.add_metadata('DC', 'subject', 'EPUB') book.add_metadata('DC', 'date', '2024-06-07') book.add_metadata('DC', 'publisher', 'Test Publishing House') book.add_metadata('DC', 'rights', 'Public Domain') - + # Simple chapter chapter = epub.EpubHtml( title='Metadata Test', @@ -574,22 +591,23 @@ class TestEPUBReader(unittest.TestCase): ''' - + book.add_item(chapter) book.toc = (epub.Link("metadata.xhtml", "Metadata Test", "meta"),) book.add_item(epub.EpubNcx()) book.add_item(epub.EpubNav()) book.spine = ['nav', chapter] - + # Write and test - epub_path = os.path.join(self.test_dir, f'test_metadata_{len(self.epub_files)}.epub') + epub_path = os.path.join(self.test_dir, + f'test_metadata_{len(self.epub_files)}.epub') epub.write_epub(epub_path, book, {}) self.epub_files.append(epub_path) - + # Read and verify parsed_book = read_epub(epub_path) self.assertEqual(parsed_book.title, "Custom Metadata Test") - + # Verify chapters were created chapters = list(parsed_book.chapters) self.assertEqual(len(chapters), 1) @@ -597,12 +615,12 @@ class TestEPUBReader(unittest.TestCase): class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase): """Test cases that specifically verify EPUB reader uses html_extraction properly.""" - + def setUp(self): """Set up test environment.""" self.test_dir = tempfile.mkdtemp() self.epub_files = [] - + def tearDown(self): """Clean up test environment.""" for epub_file in self.epub_files: @@ -610,10 +628,10 @@ class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase): os.remove(epub_file) except OSError: pass - + if os.path.exists(self.test_dir): shutil.rmtree(self.test_dir, ignore_errors=True) - + @unittest.skipUnless(EBOOKLIB_AVAILABLE, "ebooklib not available") def test_html_extraction_integration(self): """Test that EPUB reader properly uses html_extraction functionality.""" @@ -623,7 +641,7 @@ class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase): book.set_title('HTML Extraction Test') book.set_language('en') book.add_author('Test Author') - + # Chapter that exercises html_extraction features chapter = epub.EpubHtml( title='HTML Features', @@ -635,22 +653,22 @@ class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase): HTML Features

HTML Extraction Test

- + -

This paragraph has bold, italic, +

This paragraph has bold, italic, underlined, and styled text.

- +

Second Level Heading

Third Level Heading

- + - + @@ -662,19 +680,19 @@ class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase):
Normal text
- +

This is a quoted paragraph with bold text.

- +
def test_function():
     return "Hello, World!"
- +

Nested formatting: bold with italic nested inside.

- +

Red text, @@ -684,26 +702,26 @@ class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase): ''' - + book.add_item(chapter) book.toc = (epub.Link("html_features.xhtml", "HTML Features", "html"),) book.add_item(epub.EpubNcx()) book.add_item(epub.EpubNav()) book.spine = ['nav', chapter] - + # Write EPUB epub_path = os.path.join(self.test_dir, 'html_extraction_test.epub') epub.write_epub(epub_path, book, {}) self.epub_files.append(epub_path) - + # Read and analyze parsed_book = read_epub(epub_path) chapters = list(parsed_book.chapters) self.assertEqual(len(chapters), 1) - + blocks = list(chapters[0].blocks) self.assertGreater(len(blocks), 5) # Should have multiple blocks - + # Test that we get the expected block types block_types = [type(block).__name__ for block in blocks] self.assertIn('Heading', block_types, "Should have heading blocks") @@ -712,29 +730,31 @@ class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase): self.assertIn('Table', block_types, "Should have table blocks") self.assertIn('Quote', block_types, "Should have quote blocks") self.assertIn('CodeBlock', block_types, "Should have code blocks") - + # Test styled content was preserved styled_content_found = False for block in blocks: if isinstance(block, Paragraph): words = list(block.words_iter()) for _, word in words: - if (word.style.weight == FontWeight.BOLD or + if (word.style.weight == FontWeight.BOLD or word.style.style == FontStyle.ITALIC or word.style.decoration == TextDecoration.UNDERLINE or - word.style.colour != (0, 0, 0)): + word.style.colour != (0, 0, 0)): styled_content_found = True break if styled_content_found: break - - self.assertTrue(styled_content_found, "Should find styled content in parsed blocks") - + + self.assertTrue( + styled_content_found, + "Should find styled content in parsed blocks") + # Test specific color parsing red_text_found = False green_text_found = False blue_text_found = False - + for block in blocks: if isinstance(block, (Paragraph, Table)): if isinstance(block, Paragraph): @@ -746,12 +766,11 @@ class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase): green_text_found = True elif word.style.colour == (0, 0, 255): # Blue blue_text_found = True - + # At least one color should be found (depending on implementation) color_found = red_text_found or green_text_found or blue_text_found self.assertTrue(color_found, "Should find at least one colored text") - def test_epub_with_image(self): """Test that images in EPUB are properly parsed.""" book = epub.EpubBook() @@ -759,20 +778,31 @@ class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase): book.set_title('Image Test Book') book.set_language('en') book.add_author('Test Author') - + # Create minimal JPEG data for testing - img_data = b'\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x01\x00H\x00H\x00\x00\xff\xdb\x00C\x00\x08\x06\x06\x07\x06\x05\x08\x07\x07\x07\t\t\x08\n\x0c\x14\r\x0c\x0b\x0b\x0c\x19\x12\x13\x0f\x14\x1d\x1a\x1f\x1e\x1d\x1a\x1c\x1c $.\' ",#\x1c\x1c(7),01444\x1f\'9=82<.342\xff\xc0\x00\x11\x08\x00d\x00d\x01\x01\x11\x00\x02\x11\x01\x03\x11\x01\xff\xc4\x00\x14\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x08\xff\xc4\x00\x14\x10\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xda\x00\x0c\x03\x01\x00\x02\x11\x03\x11\x00\x3f\x00\xaa\xff\xd9' - + img_data = ( + b'\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x01\x00H\x00H\x00\x00' + b'\xff\xdb\x00C\x00\x08\x06\x06\x07\x06\x05\x08\x07\x07\x07\t\t' + b'\x08\n\x0c\x14\r\x0c\x0b\x0b\x0c\x19\x12\x13\x0f\x14\x1d\x1a' + b'\x1f\x1e\x1d\x1a\x1c\x1c $.\' ",#\x1c\x1c(7),01444\x1f\'9=82<.342' + b'\xff\xc0\x00\x11\x08\x00d\x00d\x01\x01\x11\x00\x02\x11\x01\x03' + b'\x11\x01\xff\xc4\x00\x14\x00\x01\x00\x00\x00\x00\x00\x00\x00' + b'\x00\x00\x00\x00\x00\x00\x00\x00\x08\xff\xc4\x00\x14\x10\x01' + b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + b'\x00\xff\xda\x00\x0c\x03\x01\x00\x02\x11\x03\x11\x00\x3f\x00' + b'\xaa\xff\xd9' + ) + # Create an EpubImage item image_item = epub.EpubImage() image_item.id = 'test_img' image_item.file_name = 'images/test_image.jpg' image_item.media_type = 'image/jpeg' image_item.content = img_data - + # Add image to book book.add_item(image_item) - + # Create a chapter that references the image chapter = epub.EpubHtml( title='Image Chapter', @@ -788,36 +818,43 @@ class TestEPUBIntegrationWithHTMLExtraction(unittest.TestCase):

Text after the image.

''' - + book.add_item(chapter) book.toc = (epub.Link("image_chapter.xhtml", "Image Chapter", "img_ch"),) book.add_item(epub.EpubNcx()) book.add_item(epub.EpubNav()) book.spine = ['nav', chapter] - + # Write EPUB - epub_path = os.path.join(self.test_dir, f'test_image_{len(self.epub_files)}.epub') + epub_path = os.path.join(self.test_dir, + f'test_image_{len(self.epub_files)}.epub') epub.write_epub(epub_path, book, {}) self.epub_files.append(epub_path) - + # Read and analyze parsed_book = read_epub(epub_path) chapters = list(parsed_book.chapters) self.assertEqual(len(chapters), 1) - + blocks = list(chapters[0].blocks) self.assertGreater(len(blocks), 0) - + # Find blocks by type heading_blocks = [block for block in blocks if isinstance(block, Heading)] paragraph_blocks = [block for block in blocks if isinstance(block, Paragraph)] image_blocks = [block for block in blocks if isinstance(block, Image)] - + # Verify we have the expected blocks - self.assertEqual(len(heading_blocks), 1, "Should find exactly one heading block") - self.assertGreaterEqual(len(paragraph_blocks), 2, "Should find at least two paragraph blocks") + self.assertEqual( + len(heading_blocks), + 1, + "Should find exactly one heading block") + self.assertGreaterEqual( + len(paragraph_blocks), + 2, + "Should find at least two paragraph blocks") self.assertEqual(len(image_blocks), 1, "Should find exactly one image block") - + # Verify image properties image_block = image_blocks[0] self.assertEqual(image_block.alt_text, "Test image") diff --git a/tests/io_tests/test_html_extraction.py b/tests/io_tests/test_html_extraction.py index 03151e1..0fe7320 100644 --- a/tests/io_tests/test_html_extraction.py +++ b/tests/io_tests/test_html_extraction.py @@ -14,14 +14,15 @@ from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration class TestHTMLParagraph(unittest.TestCase): """Test cases for basic paragraph parsing.""" - + def test_simple(self): text = "

This is a paragraph.

" paragraphs = parse_html_string(text) self.assertEqual(len(paragraphs), 1) self.assertEqual(len(paragraphs[0]), 4) - for w1, t1 in zip(paragraphs[0].words_iter(), "This is a paragraph.".split(" ")): + for w1, t1 in zip(paragraphs[0].words_iter(), + "This is a paragraph.".split(" ")): self.assertEqual(w1[1].text, t1) def test_multiple(self): @@ -31,26 +32,28 @@ class TestHTMLParagraph(unittest.TestCase): self.assertEqual(len(paragraphs[0]), 4) self.assertEqual(len(paragraphs[1]), 4) - for w1, t1 in zip(paragraphs[0].words_iter(), "This is a paragraph.".split(" ")): + for w1, t1 in zip(paragraphs[0].words_iter(), + "This is a paragraph.".split(" ")): self.assertEqual(w1[1].text, t1) - for w1, t1 in zip(paragraphs[1].words_iter(), "This is another paragraph.".split(" ")): + for w1, t1 in zip(paragraphs[1].words_iter(), + "This is another paragraph.".split(" ")): self.assertEqual(w1[1].text, t1) class TestHTMLStyledParagraphs(unittest.TestCase): """Test cases for paragraphs with inline styling.""" - + def test_bold_text(self): """Test paragraphs with bold text using and tags.""" text = "

This is bold text in a paragraph.

" blocks = parse_html_string(text) self.assertEqual(len(blocks), 1) self.assertIsInstance(blocks[0], Paragraph) - + words = list(blocks[0].words_iter()) self.assertEqual(len(words), 7) # "This is bold text in a paragraph." - + # Check that 'bold' and 'text' words have bold font weight bold_word = words[2][1] # 'bold' text_word = words[3][1] # 'text' @@ -58,7 +61,7 @@ class TestHTMLStyledParagraphs(unittest.TestCase): self.assertEqual(bold_word.style.weight, FontWeight.BOLD) self.assertEqual(text_word.text, "text") self.assertEqual(text_word.style.weight, FontWeight.BOLD) - + # Check that other words are not bold normal_word = words[0][1] # 'This' self.assertEqual(normal_word.text, "This") @@ -70,9 +73,9 @@ class TestHTMLStyledParagraphs(unittest.TestCase): blocks = parse_html_string(text) self.assertEqual(len(blocks), 1) self.assertIsInstance(blocks[0], Paragraph) - + words = list(blocks[0].words_iter()) - + # Check that 'italic' and 'text' words have italic font style italic_word = words[2][1] # 'italic' text_word = words[3][1] # 'text' @@ -86,7 +89,7 @@ class TestHTMLStyledParagraphs(unittest.TestCase): text = "

This is underlined text here.

" blocks = parse_html_string(text) self.assertEqual(len(blocks), 1) - + words = list(blocks[0].words_iter()) underlined_word = words[2][1] # 'underlined' self.assertEqual(underlined_word.style.decoration, TextDecoration.UNDERLINE) @@ -96,50 +99,60 @@ class TestHTMLStyledParagraphs(unittest.TestCase): text = "

This is strikethrough text here.

" blocks = parse_html_string(text) self.assertEqual(len(blocks), 1) - + words = list(blocks[0].words_iter()) strike_word = words[2][1] # 'strikethrough' self.assertEqual(strike_word.style.decoration, TextDecoration.STRIKETHROUGH) def test_span_with_inline_styles(self): """Test paragraphs with span elements containing inline CSS styles.""" - text = '

This text is normal, but this part is red and bold.

' + text = ( + '

This text is normal, but ' + 'this part is red and bold.

' + ) blocks = parse_html_string(text) self.assertEqual(len(blocks), 1) self.assertIsInstance(blocks[0], Paragraph) - + words = list(blocks[0].words_iter()) - + # Find the styled words styled_words = [] for _, word in words: if word.text in ["this", "part", "is", "red", "and", "bold"]: if word.style.weight == FontWeight.BOLD: styled_words.append(word) - - self.assertGreater(len(styled_words), 0, "Should have bold words in styled span") - + + self.assertGreater( + len(styled_words), + 0, + "Should have bold words in styled span") + # Check that at least one word has the red color red_words = [w for w in styled_words if w.style.colour == (255, 0, 0)] self.assertGreater(len(red_words), 0, "Should have red colored words") def test_mixed_formatting(self): """Test paragraphs with multiple formatting elements combined.""" - text = "

This paragraph contains bold, italic, blue, and highlighted text all together.

" + text = ( + '

This paragraph contains bold, italic, ' + 'blue, and highlighted ' + 'text all together.

' + ) blocks = parse_html_string(text) self.assertEqual(len(blocks), 1) self.assertIsInstance(blocks[0], Paragraph) - + words = list(blocks[0].words_iter()) - + # Check for bold word bold_words = [w for _, w in words if w.style.weight == FontWeight.BOLD] self.assertGreater(len(bold_words), 0, "Should have bold words") - + # Check for italic word italic_words = [w for _, w in words if w.style.style == FontStyle.ITALIC] self.assertGreater(len(italic_words), 0, "Should have italic words") - + # Check for blue colored word blue_words = [w for _, w in words if w.style.colour == (0, 0, 255)] self.assertGreater(len(blue_words), 0, "Should have blue colored words") @@ -149,26 +162,29 @@ class TestHTMLStyledParagraphs(unittest.TestCase): text = "

This has bold with italic inside formatting.

" blocks = parse_html_string(text) self.assertEqual(len(blocks), 1) - + words = list(blocks[0].words_iter()) - + # Find words that should be both bold and italic - bold_italic_words = [w for _, w in words - if w.style.weight == FontWeight.BOLD and w.style.style == FontStyle.ITALIC] - self.assertGreater(len(bold_italic_words), 0, "Should have words that are both bold and italic") + bold_italic_words = [w for _, w in words if w.style.weight == + FontWeight.BOLD and w.style.style == FontStyle.ITALIC] + self.assertGreater( + len(bold_italic_words), + 0, + "Should have words that are both bold and italic") def test_color_variations(self): """Test different color formats in CSS.""" text = '

Hex red and Named green.

' blocks = parse_html_string(text) self.assertEqual(len(blocks), 1) - + words = list(blocks[0].words_iter()) - + # Check for hex red color hex_red_words = [w for _, w in words if w.style.colour == (255, 0, 0)] self.assertGreater(len(hex_red_words), 0, "Should have hex red colored words") - + # Check for named green color green_words = [w for _, w in words if w.style.colour == (0, 255, 0)] self.assertGreater(len(green_words), 0, "Should have green colored words") @@ -176,7 +192,7 @@ class TestHTMLStyledParagraphs(unittest.TestCase): class TestHTMLBlockElements(unittest.TestCase): """Test cases for block-level HTML elements.""" - + def test_body_element(self): """Test parsing of body element containing other elements.""" text = "

Paragraph one.

Paragraph two.

" @@ -195,17 +211,20 @@ class TestHTMLBlockElements(unittest.TestCase): def test_headings(self): """Test all heading levels h1-h6.""" - text = "

Heading 1

Heading 2

Heading 3

Heading 4

Heading 5
Heading 6
" + text = ( + "

Heading 1

Heading 2

Heading 3

" + "

Heading 4

Heading 5
Heading 6
" + ) blocks = parse_html_string(text) self.assertEqual(len(blocks), 6) - - expected_levels = [HeadingLevel.H1, HeadingLevel.H2, HeadingLevel.H3, - HeadingLevel.H4, HeadingLevel.H5, HeadingLevel.H6] - + + expected_levels = [HeadingLevel.H1, HeadingLevel.H2, HeadingLevel.H3, + HeadingLevel.H4, HeadingLevel.H5, HeadingLevel.H6] + for i, block in enumerate(blocks): self.assertIsInstance(block, Heading) self.assertEqual(block.level, expected_levels[i]) - + words = list(block.words_iter()) self.assertEqual(len(words), 2) # "Heading" and number self.assertEqual(words[0][1].text, "Heading") @@ -216,7 +235,7 @@ class TestHTMLBlockElements(unittest.TestCase): blocks = parse_html_string(text) self.assertEqual(len(blocks), 1) self.assertIsInstance(blocks[0], Quote) - + # Check that the quote contains a paragraph quote_blocks = list(blocks[0].blocks()) self.assertEqual(len(quote_blocks), 1) @@ -228,7 +247,7 @@ class TestHTMLBlockElements(unittest.TestCase): blocks = parse_html_string(text) self.assertEqual(len(blocks), 1) self.assertIsInstance(blocks[0], CodeBlock) - + lines = list(blocks[0].lines()) self.assertGreater(len(lines), 0) @@ -239,7 +258,7 @@ class TestHTMLBlockElements(unittest.TestCase): self.assertEqual(len(blocks), 1) self.assertIsInstance(blocks[0], HList) self.assertEqual(blocks[0].style, ListStyle.UNORDERED) - + items = list(blocks[0].items()) self.assertEqual(len(items), 3) @@ -257,10 +276,10 @@ class TestHTMLBlockElements(unittest.TestCase): blocks = parse_html_string(text) self.assertEqual(len(blocks), 1) self.assertIsInstance(blocks[0], HList) - + items = list(blocks[0].items()) self.assertEqual(len(items), 3) - + # Check second item has bold text second_item_blocks = list(items[1].blocks()) if second_item_blocks: @@ -306,12 +325,12 @@ class TestHTMLBlockElements(unittest.TestCase): """ blocks = parse_html_string(text) self.assertGreater(len(blocks), 2) - + # Should have at least a heading, paragraph, and quote has_heading = any(isinstance(b, Heading) for b in blocks) has_paragraph = any(isinstance(b, Paragraph) for b in blocks) has_quote = any(isinstance(b, Quote) for b in blocks) - + self.assertTrue(has_heading, "Should contain a heading") self.assertTrue(has_paragraph, "Should contain a paragraph") self.assertTrue(has_quote, "Should contain a quote") @@ -322,7 +341,7 @@ class TestHTMLBlockElements(unittest.TestCase): blocks = parse_html_string(text) # Empty elements may not create blocks, which is acceptable behavior self.assertGreaterEqual(len(blocks), 0) - + # Test that empty paragraph with some content does create a block text_with_content = "

" # Contains whitespace blocks_with_content = parse_html_string(text_with_content) @@ -332,7 +351,7 @@ class TestHTMLBlockElements(unittest.TestCase): class TestHTMLComplexStructures(unittest.TestCase): """Test cases for complex HTML structures combining multiple features.""" - + def test_article_with_mixed_content(self): """Test a realistic article structure with mixed content.""" text = """ @@ -350,7 +369,7 @@ class TestHTMLComplexStructures(unittest.TestCase): """ blocks = parse_html_string(text) self.assertGreater(len(blocks), 3) - + # Verify we have the expected block types block_types = [type(b).__name__ for b in blocks] self.assertIn('Heading', block_types) @@ -383,12 +402,12 @@ class TestHTMLComplexStructures(unittest.TestCase): class TestHTMLFontRegistryIntegration(unittest.TestCase): """Test cases for font registry integration with HTML extraction.""" - + def setUp(self): """Set up test fixtures.""" self.doc = Document("Test Document", "en-US") self.base_font = Font(font_size=16, colour=(0, 0, 0)) - + def test_font_registry_creates_fonts(self): """Test that HTML parsing with document context creates fonts in registry.""" html_content = """ @@ -397,21 +416,21 @@ class TestHTMLFontRegistryIntegration(unittest.TestCase):

Main Header

""" - + # Initially empty style registry initial_style_count = self.doc.get_style_registry().get_style_count() - + # Parse HTML with document context blocks = parse_html_string(html_content, self.base_font, document=self.doc) - + # Should have created styles for different formatting final_style_count = self.doc.get_style_registry().get_style_count() - self.assertGreater(final_style_count, initial_style_count, - "Should have created styles in registry") - + self.assertGreater(final_style_count, initial_style_count, + "Should have created styles in registry") + # Should have created blocks self.assertGreater(len(blocks), 0, "Should have created blocks") - + def test_font_registry_reuses_fonts(self): """Test that parsing same content reuses existing styles.""" html_content = """ @@ -420,23 +439,23 @@ class TestHTMLFontRegistryIntegration(unittest.TestCase):

Main Header

""" - + # First parse blocks1 = parse_html_string(html_content, self.base_font, document=self.doc) first_parse_style_count = self.doc.get_style_registry().get_style_count() - + # Second parse with same content blocks2 = parse_html_string(html_content, self.base_font, document=self.doc) second_parse_style_count = self.doc.get_style_registry().get_style_count() - + # Style count should not increase on second parse self.assertEqual(first_parse_style_count, second_parse_style_count, - "Should reuse existing styles instead of creating new ones") - + "Should reuse existing styles instead of creating new ones") + # Both parses should create same number of blocks self.assertEqual(len(blocks1), len(blocks2), - "Should create same structure on both parses") - + "Should create same structure on both parses") + def test_font_registry_different_styles_create_different_fonts(self): """Test that different styles create different style objects.""" # Create styles with different properties @@ -449,65 +468,71 @@ class TestHTMLFontRegistryIntegration(unittest.TestCase): style_id3, style3 = self.doc.get_or_create_style( font_size=14, color=(0, 255, 0), font_weight=FontWeight.BOLD ) - + # Should be different style IDs - self.assertNotEqual(style_id1, style_id2, "Different sizes should create different styles") - self.assertNotEqual(style_id1, style_id3, "Different colors should create different styles") + self.assertNotEqual( + style_id1, + style_id2, + "Different sizes should create different styles") + self.assertNotEqual( + style_id1, + style_id3, + "Different colors should create different styles") self.assertNotEqual(style_id2, style_id3, "All styles should be different") - + # Should have multiple styles in registry self.assertGreaterEqual(self.doc.get_style_registry().get_style_count(), 3) - + def test_font_registry_integration_with_html_styles(self): """Test that HTML parsing uses style registry for styled content.""" html_content = """ -

Normal text with bold and italic and +

Normal text with bold and italic and red text.

""" - + # Parse content blocks = parse_html_string(html_content, self.base_font, document=self.doc) - + # Extract all words from the paragraph paragraph = blocks[0] words = list(paragraph.words_iter()) - + # Find words with different styles - normal_words = [w for _, w in words if w.style.weight == FontWeight.NORMAL - and w.style.style == FontStyle.NORMAL] + normal_words = [w for _, w in words if w.style.weight == FontWeight.NORMAL + and w.style.style == FontStyle.NORMAL] bold_words = [w for _, w in words if w.style.weight == FontWeight.BOLD] italic_words = [w for _, w in words if w.style.style == FontStyle.ITALIC] red_words = [w for _, w in words if w.style.colour == (255, 0, 0)] - + # Should have words with different styles self.assertGreater(len(normal_words), 0, "Should have normal words") self.assertGreater(len(bold_words), 0, "Should have bold words") self.assertGreater(len(italic_words), 0, "Should have italic words") self.assertGreater(len(red_words), 0, "Should have red words") - + # Style registry should contain multiple styles for different formatting - self.assertGreater(self.doc.get_style_registry().get_style_count(), 1, - "Should have multiple styles for different formatting") - + self.assertGreater(self.doc.get_style_registry().get_style_count(), 1, + "Should have multiple styles for different formatting") + def test_font_registry_without_document_context(self): """Test that parsing without document context works (fallback behavior).""" html_content = "

This is bold text.

" - + # Get initial style count (should include default style) initial_style_count = self.doc.get_style_registry().get_style_count() - + # Parse without document context blocks = parse_html_string(html_content, self.base_font) - + # Should still create blocks successfully self.assertEqual(len(blocks), 1) self.assertIsInstance(blocks[0], Paragraph) - + # Should not affect document's style registry final_style_count = self.doc.get_style_registry().get_style_count() - self.assertEqual(final_style_count, initial_style_count, - "Document style registry should remain unchanged") - + self.assertEqual(final_style_count, initial_style_count, + "Document style registry should remain unchanged") + def test_complex_html_font_reuse(self): """Test style reuse with complex HTML containing repeated styles.""" html_content = """ @@ -518,46 +543,46 @@ class TestHTMLFontRegistryIntegration(unittest.TestCase):

Another paragraph with bold text.

""" - + # Parse content blocks = parse_html_string(html_content, self.base_font, document=self.doc) style_count_after_parse = self.doc.get_style_registry().get_style_count() - + # Parse same content again blocks2 = parse_html_string(html_content, self.base_font, document=self.doc) style_count_after_second_parse = self.doc.get_style_registry().get_style_count() - + # Style count should not increase on second parse self.assertEqual(style_count_after_parse, style_count_after_second_parse, - "Styles should be reused for repeated formatting") - + "Styles should be reused for repeated formatting") + # Both should create same structure self.assertEqual(len(blocks), len(blocks2)) - + def test_font_registry_with_nested_styles(self): """Test style registry with nested HTML styles.""" html_content = """

Text with bold and bold italic nested styles.

""" - + # Parse content blocks = parse_html_string(html_content, self.base_font, document=self.doc) - + # Should create styles for different style combinations paragraph = blocks[0] words = list(paragraph.words_iter()) - + # Find words that are both bold and italic - bold_italic_words = [w for _, w in words - if w.style.weight == FontWeight.BOLD - and w.style.style == FontStyle.ITALIC] - - self.assertGreater(len(bold_italic_words), 0, - "Should have words with combined bold+italic style") - + bold_italic_words = [w for _, w in words + if w.style.weight == FontWeight.BOLD + and w.style.style == FontStyle.ITALIC] + + self.assertGreater(len(bold_italic_words), 0, + "Should have words with combined bold+italic style") + # Should have multiple styles in registry for different combinations self.assertGreater(self.doc.get_style_registry().get_style_count(), 1, - "Should create separate styles for style combinations") + "Should create separate styles for style combinations") if __name__ == '__main__': diff --git a/tests/io_tests/test_html_extraction_functions.py b/tests/io_tests/test_html_extraction_functions.py index 34e4363..d545586 100644 --- a/tests/io_tests/test_html_extraction_functions.py +++ b/tests/io_tests/test_html_extraction_functions.py @@ -6,7 +6,7 @@ reusing test patterns from test_html_extraction.py that are known to pass. """ import unittest -from bs4 import BeautifulSoup, Tag +from bs4 import BeautifulSoup from pyWebLayout.io.readers.html_extraction import ( create_base_context, apply_element_styling, @@ -50,11 +50,11 @@ from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration class TestUtilityFunctions(unittest.TestCase): """Test cases for utility functions.""" - + def test_create_base_context(self): """Test creation of base style context.""" context = create_base_context() - + self.assertIsInstance(context, StyleContext) self.assertIsInstance(context.font, Font) self.assertIsNone(context.background) @@ -62,113 +62,119 @@ class TestUtilityFunctions(unittest.TestCase): self.assertEqual(context.css_styles, {}) self.assertEqual(context.element_attributes, {}) self.assertEqual(context.parent_elements, []) - + def test_parse_inline_styles_from_existing_tests(self): """Test parsing CSS inline styles - adapted from test_span_with_inline_styles.""" # From: 'this part is red and bold' style_text = "color: red; font-weight: bold;" styles = parse_inline_styles(style_text) - + expected = { "color": "red", "font-weight": "bold" } self.assertEqual(styles, expected) - + def test_parse_inline_styles_color_variations(self): """Test parsing different color formats - adapted from test_color_variations.""" # Test hex color parsing hex_style = "color: #ff0000;" styles = parse_inline_styles(hex_style) self.assertEqual(styles.get("color"), "#ff0000") - - # Test named color parsing + + # Test named color parsing named_style = "color: green;" styles = parse_inline_styles(named_style) self.assertEqual(styles.get("color"), "green") - + def test_apply_element_font_styles_bold_elements(self): """Test font style application for bold elements - adapted from test_bold_text.""" base_font = Font() - + # Test tag - from "bold text" font = apply_element_font_styles(base_font, "strong", {}) self.assertEqual(font.weight, FontWeight.BOLD) - + # Test tag font = apply_element_font_styles(base_font, "b", {}) self.assertEqual(font.weight, FontWeight.BOLD) - + def test_apply_element_font_styles_italic_elements(self): """Test font style application for italic elements - adapted from test_italic_text.""" base_font = Font() - + # Test tag - from "italic text" font = apply_element_font_styles(base_font, "em", {}) self.assertEqual(font.style, FontStyle.ITALIC) - + # Test tag font = apply_element_font_styles(base_font, "i", {}) self.assertEqual(font.style, FontStyle.ITALIC) - + def test_apply_element_font_styles_decoration_elements(self): """Test font decoration - adapted from test_underlined_text and test_strikethrough_text.""" base_font = Font() - + # Test tag - from "underlined text" font = apply_element_font_styles(base_font, "u", {}) self.assertEqual(font.decoration, TextDecoration.UNDERLINE) - + # Test tag - from "strikethrough text" font = apply_element_font_styles(base_font, "s", {}) self.assertEqual(font.decoration, TextDecoration.STRIKETHROUGH) - + # Test tag font = apply_element_font_styles(base_font, "del", {}) self.assertEqual(font.decoration, TextDecoration.STRIKETHROUGH) - + def test_apply_element_font_styles_headings(self): """Test heading font styles - adapted from test_headings.""" base_font = Font() - + # Test heading sizes and weights - from test_headings which tests h1-h6 - headings = [("h1", 24), ("h2", 20), ("h3", 18), ("h4", 16), ("h5", 14), ("h6", 12)] - + headings = [("h1", 24), ("h2", 20), ("h3", 18), + ("h4", 16), ("h5", 14), ("h6", 12)] + for tag, expected_size in headings: font = apply_element_font_styles(base_font, tag, {}) self.assertEqual(font.font_size, expected_size, f"Size mismatch for {tag}") - self.assertEqual(font.weight, FontWeight.BOLD, f"Weight should be bold for {tag}") - + self.assertEqual( + font.weight, + FontWeight.BOLD, + f"Weight should be bold for {tag}") + def test_apply_element_font_styles_color_parsing(self): """Test color parsing - adapted from test_color_variations.""" base_font = Font() - + # Test named colors - from 'Named green' css_styles = {"color": "green"} font = apply_element_font_styles(base_font, "span", css_styles) self.assertEqual(font.colour, (0, 255, 0)) - + # Test hex colors - from 'Hex red' css_styles = {"color": "#ff0000"} font = apply_element_font_styles(base_font, "span", css_styles) self.assertEqual(font.colour, (255, 0, 0)) - + def test_apply_element_styling_with_classes_and_styles(self): """Test complete element styling - adapted from test_span_with_inline_styles.""" # From: 'this part is red and bold' - soup = BeautifulSoup('text', 'html.parser') + soup = BeautifulSoup( + 'text', + 'html.parser') element = soup.find('span') base_context = create_base_context() - + styled_context = apply_element_styling(base_context, element) - + # Check CSS classes self.assertIn("highlight", styled_context.css_classes) - + # Check CSS styles self.assertEqual(styled_context.css_styles.get("color"), "red") self.assertEqual(styled_context.css_styles.get("font-weight"), "bold") - + # Check font styling self.assertEqual(styled_context.font.colour, (255, 0, 0)) self.assertEqual(styled_context.font.weight, FontWeight.BOLD) @@ -176,170 +182,185 @@ class TestUtilityFunctions(unittest.TestCase): class TestExtractTextContent(unittest.TestCase): """Test cases for text content extraction.""" - + def setUp(self): """Set up test fixtures.""" self.base_context = create_base_context() - + def test_extract_simple_text(self): """Test extracting simple text - adapted from test_simple.""" # From: "

This is a paragraph.

" soup = BeautifulSoup('

This is a paragraph.

', 'html.parser') element = soup.find('p') - + words = extract_text_content(element, self.base_context) - + # Should match the expected word count from original test self.assertEqual(len(words), 4) # "This", "is", "a", "paragraph." self.assertIsInstance(words[0], Word) self.assertEqual(words[0].text, "This") - + def test_extract_styled_text_bold(self): """Test extracting bold styled text - adapted from test_bold_text.""" # From: "

This is bold text in a paragraph.

" - soup = BeautifulSoup('This is bold text in a paragraph.', 'html.parser') + soup = BeautifulSoup( + 'This is bold text in a paragraph.', + 'html.parser') element = soup.find('span') - + words = extract_text_content(element, self.base_context) - + # Find the bold words bold_words = [w for w in words if w.style.weight == FontWeight.BOLD] self.assertGreater(len(bold_words), 0, "Should have bold words") - + # Check specific words are bold (from original test expectations) bold_word_texts = [w.text for w in bold_words] self.assertIn("bold", bold_word_texts) self.assertIn("text", bold_word_texts) - + def test_extract_nested_formatting(self): """Test nested formatting - adapted from test_nested_formatting.""" # From: "

This has bold with italic inside formatting.

" - soup = BeautifulSoup('This has bold with italic inside formatting.', 'html.parser') + soup = BeautifulSoup( + 'This has bold with italic inside formatting.', + 'html.parser') element = soup.find('span') - + words = extract_text_content(element, self.base_context) - + # Find words that should be both bold and italic - bold_italic_words = [w for w in words - if w.style.weight == FontWeight.BOLD and w.style.style == FontStyle.ITALIC] - self.assertGreater(len(bold_italic_words), 0, "Should have words that are both bold and italic") + bold_italic_words = [w for w in words if w.style.weight == + FontWeight.BOLD and w.style.style == FontStyle.ITALIC] + self.assertGreater( + len(bold_italic_words), + 0, + "Should have words that are both bold and italic") class TestHandlerFunctions(unittest.TestCase): """Test cases for HTML element handler functions using known working patterns.""" - + def setUp(self): """Set up test fixtures.""" self.base_context = create_base_context() - + def test_paragraph_handler_simple(self): """Test paragraph handler - adapted from test_simple.""" # From: "

This is a paragraph.

" soup = BeautifulSoup('

This is a paragraph.

', 'html.parser') element = soup.find('p') - + result = paragraph_handler(element, self.base_context) - + self.assertIsInstance(result, Paragraph) # Should match original test expectations self.assertEqual(len(result), 4) # 4 words - + words = list(result.words_iter()) expected_texts = ["This", "is", "a", "paragraph."] for i, expected_text in enumerate(expected_texts): self.assertEqual(words[i][1].text, expected_text) - + def test_heading_handler_all_levels(self): """Test heading handler - adapted from test_headings.""" - # From: "

Heading 1

Heading 2

..." - expected_levels = [HeadingLevel.H1, HeadingLevel.H2, HeadingLevel.H3, - HeadingLevel.H4, HeadingLevel.H5, HeadingLevel.H6] - + # From: "

Heading 1

Heading 2

..." + expected_levels = [HeadingLevel.H1, HeadingLevel.H2, HeadingLevel.H3, + HeadingLevel.H4, HeadingLevel.H5, HeadingLevel.H6] + for i, expected_level in enumerate(expected_levels, 1): tag = f"h{i}" soup = BeautifulSoup(f'<{tag}>Heading {i}', 'html.parser') element = soup.find(tag) - + result = heading_handler(element, self.base_context) - + self.assertIsInstance(result, Heading) self.assertEqual(result.level, expected_level) - + # Should match original test word expectations words = list(result.words_iter()) self.assertEqual(len(words), 2) # "Heading" and number self.assertEqual(words[0][1].text, "Heading") - + def test_blockquote_handler(self): """Test blockquote handler - adapted from test_blockquote.""" # From: "

This is a quoted paragraph.

" - soup = BeautifulSoup('

This is a quoted paragraph.

', 'html.parser') + soup = BeautifulSoup( + '

This is a quoted paragraph.

', + 'html.parser') element = soup.find('blockquote') - + result = blockquote_handler(element, self.base_context) - + self.assertIsInstance(result, Quote) - + # Check that the quote contains a paragraph (from original test) quote_blocks = list(result.blocks()) self.assertEqual(len(quote_blocks), 1) self.assertIsInstance(quote_blocks[0], Paragraph) - + def test_preformatted_handler(self): """Test preformatted handler - adapted from test_preformatted_code.""" # From: "
function hello() {\n  console.log('Hello');\n}
" - soup = BeautifulSoup('
function hello() {\n  console.log(\'Hello\');\n}
', 'html.parser') + soup = BeautifulSoup( + '
function hello() {\n  console.log(\'Hello\');\n}
', + 'html.parser') element = soup.find('pre') - + result = preformatted_handler(element, self.base_context) - + self.assertIsInstance(result, CodeBlock) - + # Should have lines (from original test expectation) lines = list(result.lines()) self.assertGreater(len(lines), 0) - + def test_unordered_list_handler(self): """Test unordered list handler - adapted from test_unordered_list.""" # From: "
  • First item
  • Second item
  • Third item
" - soup = BeautifulSoup('
  • First item
  • Second item
  • Third item
', 'html.parser') + soup = BeautifulSoup( + '
  • First item
  • Second item
  • Third item
', + 'html.parser') element = soup.find('ul') - + result = unordered_list_handler(element, self.base_context) - + self.assertIsInstance(result, HList) self.assertEqual(result.style, ListStyle.UNORDERED) - + # Should match original test expectations items = list(result.items()) self.assertEqual(len(items), 3) - + def test_ordered_list_handler(self): """Test ordered list handler - adapted from test_ordered_list.""" # From: "
  1. First item
  2. Second item
  3. Third item
" - soup = BeautifulSoup('
  1. First item
  2. Second item
  3. Third item
', 'html.parser') + soup = BeautifulSoup( + '
  1. First item
  2. Second item
  3. Third item
', + 'html.parser') element = soup.find('ol') - + result = ordered_list_handler(element, self.base_context) - + self.assertIsInstance(result, HList) self.assertEqual(result.style, ListStyle.ORDERED) - + # Should match original test expectations items = list(result.items()) self.assertEqual(len(items), 3) # "First item", "Second item", "Third item" - + def test_list_item_handler(self): """Test list item handler.""" soup = BeautifulSoup('
  • List item content
  • ', 'html.parser') element = soup.find('li') - + result = list_item_handler(element, self.base_context) - + self.assertIsInstance(result, ListItem) blocks = list(result.blocks()) self.assertGreater(len(blocks), 0) - + def test_table_handler(self): """Test table handler - adapted from test_table_basic.""" # From test_table_basic structure @@ -356,76 +377,80 @@ class TestHandlerFunctions(unittest.TestCase): ''', 'html.parser') element = soup.find('table') - + result = table_handler(element, self.base_context) - + self.assertIsInstance(result, Table) - + def test_table_row_handler(self): """Test table row handler.""" soup = BeautifulSoup('Cell 1Cell 2', 'html.parser') element = soup.find('tr') - + result = table_row_handler(element, self.base_context) - + self.assertIsInstance(result, TableRow) - + def test_table_cell_handler(self): """Test table cell handler.""" soup = BeautifulSoup('Cell content', 'html.parser') element = soup.find('td') - + # Apply styling to get attributes styled_context = apply_element_styling(self.base_context, element) result = table_cell_handler(element, styled_context) - + self.assertIsInstance(result, TableCell) self.assertEqual(result.is_header, False) - + def test_table_header_cell_handler(self): """Test table header cell handler.""" soup = BeautifulSoup('Header content', 'html.parser') element = soup.find('th') - + # Apply styling to get attributes styled_context = apply_element_styling(self.base_context, element) result = table_header_cell_handler(element, styled_context) - + self.assertIsInstance(result, TableCell) self.assertEqual(result.is_header, True) - + def test_horizontal_rule_handler(self): """Test horizontal rule handler.""" soup = BeautifulSoup('
    ', 'html.parser') element = soup.find('hr') - + result = horizontal_rule_handler(element, self.base_context) - + self.assertIsInstance(result, HorizontalRule) - + def test_image_handler(self): """Test image handler.""" - soup = BeautifulSoup('Test image', 'html.parser') + soup = BeautifulSoup( + 'Test image', + 'html.parser') element = soup.find('img') - + # Need to apply styling first to get attributes styled_context = apply_element_styling(self.base_context, element) result = image_handler(element, styled_context) - + self.assertIsInstance(result, Image) self.assertEqual(result.source, "test.jpg") self.assertEqual(result.alt_text, "Test image") self.assertEqual(result.width, 100) self.assertEqual(result.height, 50) - + def test_div_handler_container(self): """Test div handler - adapted from test_div_container.""" # From: "

    First paragraph.

    Second paragraph.

    " - soup = BeautifulSoup('

    First paragraph.

    Second paragraph.

    ', 'html.parser') + soup = BeautifulSoup( + '

    First paragraph.

    Second paragraph.

    ', + 'html.parser') element = soup.find('div') - + result = div_handler(element, self.base_context) - + self.assertIsInstance(result, list) # Should match original test expectations self.assertEqual(len(result), 2) @@ -435,23 +460,25 @@ class TestHandlerFunctions(unittest.TestCase): class TestStyledContentHandling(unittest.TestCase): """Test styled content handling using patterns from existing tests.""" - + def setUp(self): """Set up test fixtures.""" self.base_context = create_base_context() - + def test_paragraph_with_bold_content(self): """Test paragraph with bold content - adapted from test_bold_text.""" # From: "

    This is bold text in a paragraph.

    " - soup = BeautifulSoup('

    This is bold text in a paragraph.

    ', 'html.parser') + soup = BeautifulSoup( + '

    This is bold text in a paragraph.

    ', + 'html.parser') element = soup.find('p') - + result = paragraph_handler(element, self.base_context) - + self.assertIsInstance(result, Paragraph) words = list(result.words_iter()) self.assertEqual(len(words), 7) # From original test expectation - + # Check that 'bold' and 'text' words have bold font weight (from original test) bold_word = words[2][1] # 'bold' text_word = words[3][1] # 'text' @@ -459,31 +486,35 @@ class TestStyledContentHandling(unittest.TestCase): self.assertEqual(bold_word.style.weight, FontWeight.BOLD) self.assertEqual(text_word.text, "text") self.assertEqual(text_word.style.weight, FontWeight.BOLD) - + # Check that other words are not bold (from original test) normal_word = words[0][1] # 'This' self.assertEqual(normal_word.text, "This") self.assertNotEqual(normal_word.style.weight, FontWeight.BOLD) - + def test_paragraph_with_mixed_formatting(self): """Test mixed formatting - adapted from test_mixed_formatting.""" - # From: "

    This paragraph contains bold, italic, blue..." - soup = BeautifulSoup('

    This paragraph contains bold, italic, blue text.

    ', 'html.parser') + # From: "

    This paragraph contains bold, italic..." + html_str = ( + '

    This paragraph contains bold, italic, ' + 'blue text.

    ' + ) + soup = BeautifulSoup(html_str, 'html.parser') element = soup.find('p') - + result = paragraph_handler(element, self.base_context) - + self.assertIsInstance(result, Paragraph) words = list(result.words_iter()) - + # Check for bold word (from original test pattern) bold_words = [w for _, w in words if w.style.weight == FontWeight.BOLD] self.assertGreater(len(bold_words), 0, "Should have bold words") - + # Check for italic word (from original test pattern) italic_words = [w for _, w in words if w.style.style == FontStyle.ITALIC] self.assertGreater(len(italic_words), 0, "Should have italic words") - + # Check for blue colored word (from original test pattern) blue_words = [w for _, w in words if w.style.colour == (0, 0, 255)] self.assertGreater(len(blue_words), 0, "Should have blue colored words") diff --git a/tests/io_tests/test_html_file_loader.py b/tests/io_tests/test_html_file_loader.py index 7c89a4d..2e3615d 100644 --- a/tests/io_tests/test_html_file_loader.py +++ b/tests/io_tests/test_html_file_loader.py @@ -18,97 +18,115 @@ class TestHTMLFileLoader(unittest.TestCase): def test_load_html_file(self): """Test loading and parsing an HTML file from disk.""" # Path to the test HTML file - html_file_path = os.path.join("tests", "data", "Kimi Räikkönen - Wikipedia.html") - + html_file_path = os.path.join( + "tests", "data", "Kimi Räikkönen - Wikipedia.html") + # Verify the test file exists - self.assertTrue(os.path.exists(html_file_path), f"Test HTML file not found: {html_file_path}") - + self.assertTrue( + os.path.exists(html_file_path), + f"Test HTML file not found: {html_file_path}") + # Read the HTML file with open(html_file_path, 'r', encoding='utf-8') as file: html_content = file.read() - + # Verify we got some content self.assertGreater(len(html_content), 0, "HTML file should not be empty") - + # Parse the HTML content using the html_extraction module try: blocks = parse_html_string(html_content) except Exception as e: self.fail(f"Failed to parse HTML file: {e}") - + # Verify we got some blocks self.assertIsInstance(blocks, list, "parse_html_string should return a list") - self.assertGreater(len(blocks), 0, "Should extract at least one block from the HTML file") - + self.assertGreater( + len(blocks), + 0, + "Should extract at least one block from the HTML file") + # Verify all returned items are Block instances for i, block in enumerate(blocks): - self.assertIsInstance(block, Block, f"Item {i} should be a Block instance, got {type(block)}") - + self.assertIsInstance( + block, + Block, + f"Item {i} should be a Block instance, got { + type(block)}") + print(f"Successfully loaded and parsed HTML file with {len(blocks)} blocks") def test_load_html_file_with_custom_font(self): """Test loading HTML file with a custom base font.""" - html_file_path = os.path.join("tests", "data", "Kimi Räikkönen - Wikipedia.html") - + html_file_path = os.path.join( + "tests", "data", "Kimi Räikkönen - Wikipedia.html") + # Skip if file doesn't exist if not os.path.exists(html_file_path): self.skipTest(f"Test HTML file not found: {html_file_path}") - + # Create a custom font custom_font = Font(font_size=14, colour=(100, 100, 100)) - + # Read and parse with custom font with open(html_file_path, 'r', encoding='utf-8') as file: html_content = file.read() - + blocks = parse_html_string(html_content, base_font=custom_font) - + # Verify we got blocks self.assertGreater(len(blocks), 0, "Should extract blocks with custom font") - - print(f"Successfully parsed HTML file with custom font, got {len(blocks)} blocks") + + print( + f"Successfully parsed HTML file with custom font, got { + len(blocks)} blocks") def test_load_html_file_content_types(self): """Test that the loaded HTML file contains expected content types.""" - html_file_path = os.path.join("tests", "data", "Kimi Räikkönen - Wikipedia.html") - + html_file_path = os.path.join( + "tests", "data", "Kimi Räikkönen - Wikipedia.html") + # Skip if file doesn't exist if not os.path.exists(html_file_path): self.skipTest(f"Test HTML file not found: {html_file_path}") - + with open(html_file_path, 'r', encoding='utf-8') as file: html_content = file.read() - + blocks = parse_html_string(html_content) - + # Check that we have different types of blocks block_type_names = [type(block).__name__ for block in blocks] unique_types = set(block_type_names) - + # A Wikipedia page should contain multiple types of content - self.assertGreater(len(unique_types), 1, "Should have multiple types of blocks in Wikipedia page") - + self.assertGreater( + len(unique_types), + 1, + "Should have multiple types of blocks in Wikipedia page") + print(f"Found block types: {sorted(unique_types)}") def test_html_file_size_handling(self): """Test that large HTML files can be handled gracefully.""" - html_file_path = os.path.join("tests", "data", "Kimi Räikkönen - Wikipedia.html") - + html_file_path = os.path.join( + "tests", "data", "Kimi Räikkönen - Wikipedia.html") + # Skip if file doesn't exist if not os.path.exists(html_file_path): self.skipTest(f"Test HTML file not found: {html_file_path}") - + # Get file size file_size = os.path.getsize(html_file_path) print(f"HTML file size: {file_size} bytes") - + # Read and parse with open(html_file_path, 'r', encoding='utf-8') as file: html_content = file.read() - + # This should not raise an exception even for large files blocks = parse_html_string(html_content) - + # Basic verification self.assertIsInstance(blocks, list) print(f"Successfully processed {file_size} byte file into {len(blocks)} blocks") diff --git a/tests/io_tests/test_html_link_end_to_end.py b/tests/io_tests/test_html_link_end_to_end.py index 91bf079..a5fba0b 100644 --- a/tests/io_tests/test_html_link_end_to_end.py +++ b/tests/io_tests/test_html_link_end_to_end.py @@ -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.""" diff --git a/tests/io_tests/test_html_link_interactivity.py b/tests/io_tests/test_html_link_interactivity.py index f41f52d..7229863 100644 --- a/tests/io_tests/test_html_link_interactivity.py +++ b/tests/io_tests/test_html_link_interactivity.py @@ -155,8 +155,11 @@ class TestHTMLLinkInteractivity(unittest.TestCase): html = '''

    Settings

    -

    - ◄ Back to Library +

    + + ◄ Back to Library

    Font Size: 100%
    @@ -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:") diff --git a/tests/io_tests/test_html_links.py b/tests/io_tests/test_html_links.py index 21386e8..ef94daa 100644 --- a/tests/io_tests/test_html_links.py +++ b/tests/io_tests/test_html_links.py @@ -7,173 +7,172 @@ from bs4 import BeautifulSoup from pyWebLayout.io.readers.html_extraction import ( parse_html_string, extract_text_content, - create_base_context, - apply_element_styling + create_base_context ) from pyWebLayout.abstract.inline import LinkedWord from pyWebLayout.abstract.functional import LinkType from pyWebLayout.abstract.block import Paragraph -from pyWebLayout.style import Font class TestHTMLLinkExtraction(unittest.TestCase): """Test cases for HTML hyperlink extraction.""" - + def setUp(self): """Set up test fixtures.""" self.base_context = create_base_context() - + def test_simple_external_link(self): """Test extracting a simple external link.""" html = '

    Visit this site for more.

    ' blocks = parse_html_string(html) - + self.assertEqual(len(blocks), 1) self.assertIsInstance(blocks[0], Paragraph) - + paragraph = blocks[0] words = list(paragraph.words) - + # Should have: "Visit", "this", "site", "for", "more." self.assertEqual(len(words), 5) - + # Check that "this" and "site" are LinkedWords self.assertIsInstance(words[1], LinkedWord) self.assertIsInstance(words[2], LinkedWord) - + # Check link properties self.assertEqual(words[1].location, "https://example.com") self.assertEqual(words[1].link_type, LinkType.EXTERNAL) self.assertEqual(words[2].location, "https://example.com") self.assertEqual(words[2].link_type, LinkType.EXTERNAL) - + def test_internal_link(self): """Test extracting an internal anchor link.""" html = '

    Go to section 2 below.

    ' blocks = parse_html_string(html) - + paragraph = blocks[0] words = list(paragraph.words) - + # Find LinkedWords linked_words = [w for w in words if isinstance(w, LinkedWord)] self.assertEqual(len(linked_words), 2) # "section" and "2" - + # Check they're internal links for word in linked_words: self.assertEqual(word.link_type, LinkType.INTERNAL) self.assertEqual(word.location, "#section2") - + def test_multi_word_link(self): """Test that multi-word links create separate LinkedWords.""" html = '

    click here for next page

    ' blocks = parse_html_string(html) - + paragraph = blocks[0] words = list(paragraph.words) - + # All words should be LinkedWords self.assertEqual(len(words), 5) for word in words: self.assertIsInstance(word, LinkedWord) self.assertEqual(word.location, "/next") self.assertEqual(word.link_type, LinkType.INTERNAL) - + def test_link_with_title(self): """Test extracting link with title attribute.""" html = '

    click

    ' blocks = parse_html_string(html) - + paragraph = blocks[0] words = list(paragraph.words) - + self.assertEqual(len(words), 1) self.assertIsInstance(words[0], LinkedWord) self.assertEqual(words[0].link_title, "Visit Example") - + def test_mixed_linked_and_normal_text(self): """Test paragraph with both linked and normal text.""" html = '

    Some linked text and normal text.

    ' blocks = parse_html_string(html) - + paragraph = blocks[0] words = list(paragraph.words) - + # "Some" - normal # "linked" - LinkedWord - # "text" - LinkedWord + # "text" - LinkedWord # "and" - normal # "normal" - normal # "text." - normal - + self.assertNotIsInstance(words[0], LinkedWord) # "Some" self.assertIsInstance(words[1], LinkedWord) # "linked" self.assertIsInstance(words[2], LinkedWord) # "text" self.assertNotIsInstance(words[3], LinkedWord) # "and" - + def test_link_without_href(self): """Test that without href is treated as normal text.""" html = '

    not a link

    ' blocks = parse_html_string(html) - + paragraph = blocks[0] words = list(paragraph.words) - + # Should be regular Words, not LinkedWords for word in words: self.assertNotIsInstance(word, LinkedWord) - + def test_javascript_link(self): """Test that javascript: links are detected as API type.""" html = '

    click

    ' blocks = parse_html_string(html) - + paragraph = blocks[0] words = list(paragraph.words) - + self.assertIsInstance(words[0], LinkedWord) self.assertEqual(words[0].link_type, LinkType.API) - + def test_nested_formatting_in_link(self): """Test link with nested formatting.""" html = '

    text with bold word

    ' blocks = parse_html_string(html) - + paragraph = blocks[0] words = list(paragraph.words) - + # All should be LinkedWords regardless of formatting for word in words: self.assertIsInstance(word, LinkedWord) self.assertEqual(word.location, "/page") - + def test_multiple_links_in_paragraph(self): """Test paragraph with multiple separate links.""" html = '

    first and second link

    ' blocks = parse_html_string(html) - + paragraph = blocks[0] words = list(paragraph.words) - + # Find LinkedWords and their locations - linked_words = [(w.text, w.location) for w in words if isinstance(w, LinkedWord)] - + linked_words = [(w.text, w.location) + for w in words if isinstance(w, LinkedWord)] + # Should have "first" linked to /page1 and "second" linked to /page2 self.assertIn(("first", "/page1"), linked_words) self.assertIn(("second", "/page2"), linked_words) - + def test_extract_text_content_with_links(self): """Test extract_text_content directly with link elements.""" html = 'Visit our site today' soup = BeautifulSoup(html, 'html.parser') element = soup.find('span') - + context = create_base_context() words = extract_text_content(element, context) - + # Should have: "Visit", "our", "site", "today" self.assertEqual(len(words), 4) - + # Check types self.assertNotIsInstance(words[0], LinkedWord) # "Visit" self.assertIsInstance(words[1], LinkedWord) # "our" diff --git a/tests/layout/test_ereader_layout.py b/tests/layout/test_ereader_layout.py index 8c9a7c8..1486e4f 100644 --- a/tests/layout/test_ereader_layout.py +++ b/tests/layout/test_ereader_layout.py @@ -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) diff --git a/tests/layout/test_ereader_manager.py b/tests/layout/test_ereader_manager.py index 13ef8f9..f3c3631 100644 --- a/tests/layout/test_ereader_manager.py +++ b/tests/layout/test_ereader_manager.py @@ -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 diff --git a/tests/layout/test_html_links_in_ereader.py b/tests/layout/test_html_links_in_ereader.py index 7d220e8..f7e62d4 100644 --- a/tests/layout/test_html_links_in_ereader.py +++ b/tests/layout/test_html_links_in_ereader.py @@ -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__': diff --git a/tests/layouter/test_document_layouter.py b/tests/layouter/test_document_layouter.py index 2182653..73bde30 100644 --- a/tests/layouter/test_document_layouter.py +++ b/tests/layouter/test_document_layouter.py @@ -5,14 +5,12 @@ This test focuses on verifying that the document layouter properly integrates word spacing constraints from the style system. """ -import pytest -from unittest.mock import Mock, MagicMock, patch -from typing import List, Optional +from unittest.mock import Mock, patch from pyWebLayout.layout.document_layouter import paragraph_layouter, table_layouter, DocumentLayouter from pyWebLayout.style.abstract_style import AbstractStyle -from pyWebLayout.style.concrete_style import ConcreteStyle, StyleResolver, RenderingContext -from pyWebLayout.abstract.block import Table, TableRow, TableCell +from pyWebLayout.style.concrete_style import StyleResolver, RenderingContext +from pyWebLayout.abstract.block import Table from pyWebLayout.concrete.table import TableStyle @@ -29,21 +27,21 @@ class TestDocumentLayouter: self.mock_page.draw = Mock() self.mock_page.can_fit_line = Mock(return_value=True) self.mock_page.add_child = Mock() - + # Create mock page style with all required numeric properties self.mock_page.style = Mock() self.mock_page.style.max_font_size = 72 # Reasonable maximum font size self.mock_page.style.line_spacing_multiplier = 1.2 # Standard line spacing - + # Create mock style resolver self.mock_style_resolver = Mock() self.mock_page.style_resolver = self.mock_style_resolver - + # Create mock paragraph self.mock_paragraph = Mock() self.mock_paragraph.line_height = 20 self.mock_paragraph.style = AbstractStyle() - + # Create mock words self.mock_words = [] for i in range(5): @@ -51,20 +49,22 @@ class TestDocumentLayouter: word.text = f"word{i}" self.mock_words.append(word) self.mock_paragraph.words = self.mock_words - + # Create mock concrete style with word spacing constraints self.mock_concrete_style = Mock() self.mock_concrete_style.word_spacing_min = 2.0 self.mock_concrete_style.word_spacing_max = 8.0 self.mock_concrete_style.text_align = "left" - + # Create mock font that returns proper numeric metrics (not Mock objects) mock_font = Mock() # CRITICAL: getmetrics() must return actual numeric values, not Mock objects - # This prevents "TypeError: '>' not supported between instances of 'Mock' and 'Mock'" - mock_font.getmetrics.return_value = (12, 4) # (ascent, descent) as actual integers + # This prevents "TypeError: '>' not supported between instances of 'Mock' + # and 'Mock'" + # (ascent, descent) as actual integers + mock_font.getmetrics.return_value = (12, 4) mock_font.font = mock_font # For accessing .font property - + # Create mock font object that can be used by create_font mock_font_instance = Mock() mock_font_instance.font = mock_font @@ -72,7 +72,7 @@ class TestDocumentLayouter: mock_font_instance.colour = (0, 0, 0) mock_font_instance.background = (255, 255, 255, 0) self.mock_concrete_style.create_font = Mock(return_value=mock_font_instance) - + # Update mock words to have proper style with font for word in self.mock_words: word.style = Mock() @@ -84,34 +84,39 @@ class TestDocumentLayouter: @patch('pyWebLayout.layout.document_layouter.StyleResolver') @patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry') @patch('pyWebLayout.layout.document_layouter.Line') - def test_paragraph_layouter_basic_flow(self, mock_line_class, mock_style_registry_class, mock_style_resolver_class): + def test_paragraph_layouter_basic_flow( + self, + mock_line_class, + mock_style_registry_class, + mock_style_resolver_class): """Test basic paragraph layouter functionality.""" # Setup mocks for StyleResolver and ConcreteStyleRegistry mock_style_resolver = Mock() mock_style_resolver_class.return_value = mock_style_resolver - + mock_style_registry = Mock() mock_style_registry_class.return_value = mock_style_registry mock_style_registry.get_concrete_style.return_value = self.mock_concrete_style - + mock_line = Mock() mock_line_class.return_value = mock_line mock_line.add_word.return_value = (True, None) # All words fit successfully - + # Call function result = paragraph_layouter(self.mock_paragraph, self.mock_page) - + # Verify results success, failed_word_index, remaining_pretext = result assert success is True assert failed_word_index is None assert remaining_pretext is None - + # Verify StyleResolver and ConcreteStyleRegistry were created correctly mock_style_resolver_class.assert_called_once() mock_style_registry_class.assert_called_once_with(mock_style_resolver) - mock_style_registry.get_concrete_style.assert_called_once_with(self.mock_paragraph.style) - + mock_style_registry.get_concrete_style.assert_called_once_with( + self.mock_paragraph.style) + # Verify Line was created with correct spacing constraints expected_spacing = (2, 8) # From mock_concrete_style mock_line_class.assert_called_once() @@ -121,36 +126,37 @@ class TestDocumentLayouter: @patch('pyWebLayout.layout.document_layouter.StyleResolver') @patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry') @patch('pyWebLayout.layout.document_layouter.Line') - def test_paragraph_layouter_word_spacing_constraints_extraction(self, mock_line_class, mock_style_registry_class, mock_style_resolver_class): + def test_paragraph_layouter_word_spacing_constraints_extraction( + self, mock_line_class, mock_style_registry_class, mock_style_resolver_class): """Test that word spacing constraints are correctly extracted from style.""" # Create concrete style with specific constraints concrete_style = Mock() concrete_style.word_spacing_min = 5.5 concrete_style.word_spacing_max = 15.2 concrete_style.text_align = "justify" - + # Create a mock font that concrete_style.create_font returns mock_font = Mock() mock_font.font = Mock() mock_font.font.getmetrics.return_value = (12, 4) mock_font.font_size = 16 concrete_style.create_font = Mock(return_value=mock_font) - + # Setup StyleResolver and ConcreteStyleRegistry mocks mock_style_resolver = Mock() mock_style_resolver_class.return_value = mock_style_resolver - + mock_style_registry = Mock() mock_style_registry_class.return_value = mock_style_registry mock_style_registry.get_concrete_style.return_value = concrete_style - + mock_line = Mock() mock_line_class.return_value = mock_line mock_line.add_word.return_value = (True, None) - + # Call function paragraph_layouter(self.mock_paragraph, self.mock_page) - + # Verify spacing constraints were extracted correctly (converted to int) expected_spacing = (5, 15) # int() conversion of 5.5 and 15.2 call_args = mock_line_class.call_args @@ -159,30 +165,34 @@ class TestDocumentLayouter: @patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry') @patch('pyWebLayout.layout.document_layouter.Line') @patch('pyWebLayout.layout.document_layouter.Text') - def test_paragraph_layouter_line_overflow(self, mock_text_class, mock_line_class, mock_style_registry_class): + def test_paragraph_layouter_line_overflow( + self, + mock_text_class, + mock_line_class, + mock_style_registry_class): """Test handling of line overflow when words don't fit.""" # Setup mocks mock_style_registry = Mock() mock_style_registry_class.return_value = mock_style_registry mock_style_registry.get_concrete_style.return_value = self.mock_concrete_style - + # Create two mock lines with proper size attribute mock_line1 = Mock() mock_line1.size = (400, 20) # (width, height) mock_line2 = Mock() mock_line2.size = (400, 20) # (width, height) mock_line_class.side_effect = [mock_line1, mock_line2] - + # Mock Text.from_word to return mock text objects with numeric width mock_text = Mock() mock_text.width = 50 # Reasonable word width mock_text_class.from_word.return_value = mock_text - + # First line: first 2 words fit, third doesn't # Second line: remaining words fit mock_line1.add_word.side_effect = [ (True, None), # word0 fits - (True, None), # word1 fits + (True, None), # word1 fits (False, None), # word2 doesn't fit ] mock_line2.add_word.side_effect = [ @@ -190,42 +200,43 @@ class TestDocumentLayouter: (True, None), # word3 fits (True, None), # word4 fits ] - + # Call function result = paragraph_layouter(self.mock_paragraph, self.mock_page) - + # Verify results success, failed_word_index, remaining_pretext = result assert success is True assert failed_word_index is None assert remaining_pretext is None - + # Verify two lines were created assert mock_line_class.call_count == 2 assert self.mock_page.add_child.call_count == 2 @patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry') @patch('pyWebLayout.layout.document_layouter.Line') - def test_paragraph_layouter_page_full(self, mock_line_class, mock_style_registry_class): + def test_paragraph_layouter_page_full( + self, mock_line_class, mock_style_registry_class): """Test handling when page runs out of space.""" # Setup mocks mock_style_registry = Mock() mock_style_registry_class.return_value = mock_style_registry mock_style_registry.get_concrete_style.return_value = self.mock_concrete_style - + # Page can fit first line but not second self.mock_page.can_fit_line.side_effect = [True, False] - + mock_line = Mock() mock_line_class.return_value = mock_line mock_line.add_word.side_effect = [ (True, None), # word0 fits (False, None), # word1 doesn't fit, need new line ] - + # Call function result = paragraph_layouter(self.mock_paragraph, self.mock_page) - + # Verify results indicate page is full success, failed_word_index, remaining_pretext = result assert success is False @@ -236,9 +247,9 @@ class TestDocumentLayouter: """Test handling of empty paragraph.""" empty_paragraph = Mock() empty_paragraph.words = [] - + result = paragraph_layouter(empty_paragraph, self.mock_page) - + success, failed_word_index, remaining_pretext = result assert success is True assert failed_word_index is None @@ -247,7 +258,7 @@ class TestDocumentLayouter: def test_paragraph_layouter_invalid_start_word(self): """Test handling of invalid start_word index.""" result = paragraph_layouter(self.mock_paragraph, self.mock_page, start_word=10) - + success, failed_word_index, remaining_pretext = result assert success is True assert failed_word_index is None @@ -259,10 +270,10 @@ class TestDocumentLayouter: # Setup mock mock_style_registry = Mock() mock_style_registry_class.return_value = mock_style_registry - + # Create layouter layouter = DocumentLayouter(self.mock_page) - + # Verify initialization assert layouter.page == self.mock_page mock_style_registry_class.assert_called_once_with(self.mock_page.style_resolver) @@ -271,12 +282,13 @@ class TestDocumentLayouter: def test_document_layouter_layout_paragraph(self, mock_paragraph_layouter): """Test DocumentLayouter.layout_paragraph method.""" mock_paragraph_layouter.return_value = (True, None, None) - + with patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry'): layouter = DocumentLayouter(self.mock_page) - - result = layouter.layout_paragraph(self.mock_paragraph, start_word=2, pretext="test") - + + result = layouter.layout_paragraph( + self.mock_paragraph, start_word=2, pretext="test") + # Verify the function was called correctly mock_paragraph_layouter.assert_called_once_with( self.mock_paragraph, self.mock_page, 2, "test" @@ -286,46 +298,46 @@ class TestDocumentLayouter: def test_document_layouter_layout_document_success(self): """Test DocumentLayouter.layout_document with successful layout.""" from pyWebLayout.abstract import Paragraph - + # Create Mock paragraphs that pass isinstance checks paragraphs = [ Mock(spec=Paragraph), Mock(spec=Paragraph), Mock(spec=Paragraph) ] - + with patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry'): layouter = DocumentLayouter(self.mock_page) - + # Mock the layout_paragraph method to return success layouter.layout_paragraph = Mock(return_value=(True, None, None)) - + result = layouter.layout_document(paragraphs) - + assert result is True assert layouter.layout_paragraph.call_count == 3 def test_document_layouter_layout_document_failure(self): """Test DocumentLayouter.layout_document with layout failure.""" from pyWebLayout.abstract import Paragraph - + # Create Mock paragraphs that pass isinstance checks paragraphs = [ Mock(spec=Paragraph), Mock(spec=Paragraph) ] - + with patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry'): layouter = DocumentLayouter(self.mock_page) - + # Mock the layout_paragraph method: first succeeds, second fails layouter.layout_paragraph = Mock(side_effect=[ (True, None, None), # First paragraph succeeds (False, 3, None), # Second paragraph fails ]) - + result = layouter.layout_document(paragraphs) - + assert result is False assert layouter.layout_paragraph.call_count == 2 @@ -334,39 +346,40 @@ class TestDocumentLayouter: # Create real style objects context = RenderingContext(base_font_size=16) resolver = StyleResolver(context) - + abstract_style = AbstractStyle( word_spacing=4.0, word_spacing_min=2.0, word_spacing_max=10.0 ) - + concrete_style = resolver.resolve_style(abstract_style) - + # Verify constraints are resolved correctly assert concrete_style.word_spacing_min == 2.0 assert concrete_style.word_spacing_max == 10.0 - + # This demonstrates the integration works end-to-end class TestWordSpacingConstraintsInLayout: """Specific tests for word spacing constraints in layout context.""" - + def test_different_spacing_scenarios(self): """Test various word spacing constraint scenarios.""" context = RenderingContext(base_font_size=16) resolver = StyleResolver(context) - + test_cases = [ # (word_spacing, word_spacing_min, word_spacing_max, expected_min, expected_max) (None, None, None, 2.0, 8.0), # Default case (5.0, None, None, 5.0, 10.0), # Only base specified (4.0, 2.0, 8.0, 2.0, 8.0), # All specified - (3.0, 1.0, None, 1.0, 3.0), # Min specified, max = max(word_spacing, min*2) = max(3.0, 2.0) = 3.0 - (6.0, None, 12.0, 6.0, 12.0), # Max specified, min from base + # Min specified, max = max(word_spacing, min*2) = max(3.0, 2.0) = 3.0 + (3.0, 1.0, None, 1.0, 3.0), + (6.0, None, 12.0, 6.0, 12.0), # Max specified, min from base ] - + for word_spacing, min_spacing, max_spacing, expected_min, expected_max in test_cases: style_kwargs = {} if word_spacing is not None: @@ -375,17 +388,17 @@ class TestWordSpacingConstraintsInLayout: style_kwargs['word_spacing_min'] = min_spacing if max_spacing is not None: style_kwargs['word_spacing_max'] = max_spacing - + abstract_style = AbstractStyle(**style_kwargs) concrete_style = resolver.resolve_style(abstract_style) - + assert concrete_style.word_spacing_min == expected_min, f"Failed for case: {style_kwargs}" assert concrete_style.word_spacing_max == expected_max, f"Failed for case: {style_kwargs}" class TestMultiPageLayout: """Test cases for multi-page document layout scenarios.""" - + def setup_method(self): """Set up test fixtures for multi-page tests.""" # Create multiple mock pages @@ -401,19 +414,19 @@ class TestMultiPageLayout: page.add_child = Mock() page.style_resolver = Mock() self.mock_pages.append(page) - + # Create a long paragraph that will span multiple pages self.long_paragraph = Mock() self.long_paragraph.line_height = 25 self.long_paragraph.style = AbstractStyle() - + # Create many words to ensure page overflow self.long_paragraph.words = [] for i in range(50): # 50 words should definitely overflow a page word = Mock() word.text = f"word_{i:02d}" self.long_paragraph.words.append(word) - + # Create mock concrete style self.mock_concrete_style = Mock() self.mock_concrete_style.word_spacing_min = 3.0 @@ -421,7 +434,6 @@ class TestMultiPageLayout: self.mock_concrete_style.text_align = "justify" self.mock_concrete_style.create_font = Mock() - @patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry') def test_document_layouter_multi_page_scenario(self, mock_style_registry_class): """Test DocumentLayouter handling multiple pages with continuation.""" @@ -429,7 +441,7 @@ class TestMultiPageLayout: mock_style_registry = Mock() mock_style_registry_class.return_value = mock_style_registry mock_style_registry.get_concrete_style.return_value = self.mock_concrete_style - + # Create a multi-page document layouter class MultiPageDocumentLayouter(DocumentLayouter): def __init__(self, pages): @@ -437,7 +449,7 @@ class TestMultiPageLayout: self.current_page_index = 0 self.page = pages[0] self.style_registry = Mock() - + def get_next_page(self): """Get the next available page.""" if self.current_page_index + 1 < len(self.pages): @@ -445,47 +457,47 @@ class TestMultiPageLayout: self.page = self.pages[self.current_page_index] return self.page return None - + def layout_document_with_pagination(self, paragraphs): """Layout document with automatic pagination.""" for paragraph in paragraphs: start_word = 0 pretext = None - + while start_word < len(paragraph.words): complete, next_word, remaining_pretext = self.layout_paragraph( paragraph, start_word, pretext ) - + if complete: # Paragraph finished break - + if next_word is None: # Error condition return False, f"Failed to layout paragraph at word {start_word}" - + # Try to get next page next_page = self.get_next_page() if not next_page: return False, f"Ran out of pages at word {next_word}" - + # Continue with remaining words on next page start_word = next_word pretext = remaining_pretext - + return True, "All paragraphs laid out successfully" - + # Create layouter with multiple pages layouter = MultiPageDocumentLayouter(self.mock_pages) - + # Mock the layout_paragraph method to simulate page filling - original_layout_paragraph = layouter.layout_paragraph + layouter.layout_paragraph call_count = [0] - + def mock_layout_paragraph(paragraph, start_word=0, pretext=None): call_count[0] += 1 - + # Simulate different scenarios based on call count if call_count[0] == 1: # First page: can fit words 0-19, fails at word 20 @@ -498,19 +510,19 @@ class TestMultiPageLayout: return (True, None, None) else: return (False, start_word, None) - + layouter.layout_paragraph = mock_layout_paragraph - + # Test multi-page layout - success, message = layouter.layout_document_with_pagination([self.long_paragraph]) - + success, message = layouter.layout_document_with_pagination( + [self.long_paragraph]) + # Verify results assert success is True assert "successfully" in message assert call_count[0] == 3 # Should have made 3 layout attempts assert layouter.current_page_index == 2 # Should end on page 3 (index 2) - def test_realistic_multi_page_scenario(self): """Test a realistic scenario with actual content and page constraints.""" # Create realistic paragraph with varied content @@ -522,7 +534,7 @@ class TestMultiPageLayout: word_spacing_max=8.0, text_align="justify" ) - + # Create words of varying lengths (realistic text) words = [ "The", "quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog.", @@ -534,13 +546,13 @@ class TestMultiPageLayout: "system", "to", "handle", "appropriately", "with", "the", "given", "constraints", "and", "spacing", "requirements." ] - + realistic_paragraph.words = [] for word_text in words: word = Mock() word.text = word_text realistic_paragraph.words.append(word) - + # Create page with realistic constraints realistic_page = Mock() realistic_page.border_size = 30 @@ -550,33 +562,34 @@ class TestMultiPageLayout: realistic_page.draw = Mock() realistic_page.add_child = Mock() realistic_page.style_resolver = Mock() - + # Simulate page that can fit approximately 20 lines lines_fitted = [0] max_lines = 20 - + def realistic_can_fit_line(line_height): lines_fitted[0] += 1 return lines_fitted[0] <= max_lines - + realistic_page.can_fit_line = realistic_can_fit_line - + # Test with real style system context = RenderingContext(base_font_size=14) resolver = StyleResolver(context) concrete_style = resolver.resolve_style(realistic_paragraph.style) - + # Verify realistic constraints were calculated assert concrete_style.word_spacing == 4.0 assert concrete_style.word_spacing_min == 2.0 assert concrete_style.word_spacing_max == 8.0 - + # This test demonstrates the integration without mocking everything # In a real scenario, this would interface with actual Line and Text objects - print(f"✓ Realistic scenario test completed") + print("✓ Realistic scenario test completed") print(f" - Words to layout: {len(realistic_paragraph.words)}") print(f" - Page width: {realistic_page.available_width}px") - print(f" - Word spacing constraints: {concrete_style.word_spacing_min}-{concrete_style.word_spacing_max}px") + print( + f" - Word spacing constraints: {concrete_style.word_spacing_min}-{concrete_style.word_spacing_max}px") class TestTableLayouter: @@ -756,24 +769,24 @@ if __name__ == "__main__": # Run specific tests for debugging test = TestDocumentLayouter() test.setup_method() - + # Run a simple test with patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry') as mock_registry: with patch('pyWebLayout.layout.document_layouter.Line') as mock_line: mock_style_registry = Mock() mock_registry.return_value = mock_style_registry mock_style_registry.get_concrete_style.return_value = test.mock_concrete_style - + mock_line_instance = Mock() mock_line.return_value = mock_line_instance mock_line_instance.add_word.return_value = (True, None) - + result = paragraph_layouter(test.mock_paragraph, test.mock_page) print(f"Test result: {result}") - + # Run multi-page tests multi_test = TestMultiPageLayout() multi_test.setup_method() multi_test.test_realistic_multi_page_scenario() - + print("Document layouter tests completed!") diff --git a/tests/layouter/test_document_layouter_integration.py b/tests/layouter/test_document_layouter_integration.py index b41c1f2..fa9ee96 100644 --- a/tests/layouter/test_document_layouter_integration.py +++ b/tests/layouter/test_document_layouter_integration.py @@ -7,20 +7,15 @@ in multi-page layout scenarios. """ import pytest -from unittest.mock import Mock, patch -from PIL import Image, ImageDraw -import numpy as np -from typing import List, Optional import os import logging -from pyWebLayout.layout.document_layouter import paragraph_layouter, DocumentLayouter +from pyWebLayout.layout.document_layouter import paragraph_layouter from pyWebLayout.style.abstract_style import AbstractStyle -from pyWebLayout.style.concrete_style import ConcreteStyle, StyleResolver, RenderingContext from pyWebLayout.style.fonts import Font from pyWebLayout.style.page_style import PageStyle from pyWebLayout.concrete.page import Page -from pyWebLayout.concrete.text import Line, Text +from pyWebLayout.concrete.text import Line from pyWebLayout.abstract.inline import Word # Enable logging to see font loading messages @@ -34,47 +29,53 @@ def verify_bundled_font_available(): current_dir = os.path.dirname(os.path.abspath(__file__)) # Navigate up to pyWebLayout root, then to assets/fonts project_root = os.path.dirname(os.path.dirname(current_dir)) - bundled_font_path = os.path.join(project_root, 'pyWebLayout', 'assets', 'fonts', 'DejaVuSans.ttf') - + bundled_font_path = os.path.join( + project_root, + 'pyWebLayout', + 'assets', + 'fonts', + 'DejaVuSans.ttf') + logger.info(f"Integration tests checking for bundled font at: {bundled_font_path}") - + if not os.path.exists(bundled_font_path): pytest.fail( f"INTEGRATION TEST FAILURE: Bundled font not found at {bundled_font_path}\n" f"Integration tests require the bundled font to ensure consistent behavior.\n" f"This likely means the font was not included in the package build." ) - + logger.info(f"Bundled font found at: {bundled_font_path}") return bundled_font_path class MockWord(Word): """A simple mock word that extends the real Word class.""" - + def __init__(self, text, style=None): if style is None: # Integration tests MUST use the bundled font for consistency style = Font(font_size=16) # Verify the font loaded properly if style.font.path is None: - logger.warning("Font loaded without explicit path - may be using PIL default") + logger.warning( + "Font loaded without explicit path - may be using PIL default") # Initialize the base Word with required parameters super().__init__(text, style) self._concrete_texts = [] - + def add_concete(self, texts): """Add concrete text representations.""" if isinstance(texts, list): self._concrete_texts.extend(texts) else: self._concrete_texts.append(texts) - + def possible_hyphenation(self): """Return possible hyphenation points.""" if len(self.text) <= 6: return [] - + # Simple hyphenation: split roughly in the middle mid = len(self.text) // 2 return [(self.text[:mid] + "-", self.text[mid:])] @@ -82,7 +83,7 @@ class MockWord(Word): class MockParagraph: """A simple paragraph with words and styling.""" - + def __init__(self, text_content, word_spacing_style=None): if word_spacing_style is None: word_spacing_style = AbstractStyle( @@ -90,10 +91,10 @@ class MockParagraph: word_spacing_min=2.0, word_spacing_max=8.0 ) - + self.style = word_spacing_style self.line_height = 25 - + # Create words from text content self.words = [] for word_text in text_content.split(): @@ -103,39 +104,40 @@ class MockParagraph: class TestDocumentLayouterIntegration: """Integration tests using real components.""" - + @classmethod def setup_class(cls): """Verify bundled font is available before running any tests.""" verify_bundled_font_available() - + def test_single_page_layout_with_real_components(self): """Test layout on a single page using real Line and Text objects.""" # Create a real page that can fit content page_style = PageStyle(border_width=20, padding=(10, 10, 10, 10)) page = Page(size=(500, 400), style=page_style) - + # Create a paragraph with realistic content paragraph = MockParagraph( "The quick brown fox jumps over the lazy dog and runs through the forest.", AbstractStyle(word_spacing=3.0, word_spacing_min=2.0, word_spacing_max=6.0) ) - + # Layout the paragraph - success, failed_word_index, remaining_pretext = paragraph_layouter(paragraph, page) - + success, failed_word_index, remaining_pretext = paragraph_layouter( + paragraph, page) + # Verify successful layout assert success is True assert failed_word_index is None assert remaining_pretext is None - + # Verify lines were added to page assert len(page.children) > 0 - + # Verify actual Line objects were created for child in page.children: assert isinstance(child, Line) - + print(f"✓ Single page test: {len(page.children)} lines created") def test_multi_page_scenario_with_page_overflow(self): @@ -143,47 +145,52 @@ class TestDocumentLayouterIntegration: # Create a very small real page that will definitely overflow small_page_style = PageStyle(border_width=5, padding=(5, 5, 5, 5)) small_page = Page(size=(150, 80), style=small_page_style) - + # Create a long paragraph that will definitely overflow - long_text = " ".join([f"verylongword{i:02d}" for i in range(20)]) # 20 long words + long_text = " ".join( + [f"verylongword{i:02d}" for i in range(20)]) # 20 long words paragraph = MockParagraph( long_text, AbstractStyle(word_spacing=4.0, word_spacing_min=2.0, word_spacing_max=8.0) ) - + # Layout the paragraph - should fail due to page overflow - success, failed_word_index, remaining_pretext = paragraph_layouter(paragraph, small_page) - + success, failed_word_index, remaining_pretext = paragraph_layouter( + paragraph, small_page) + # Either should fail due to overflow OR succeed with limited content if success: # If it succeeded, verify it fit some content assert len(small_page.children) > 0 - print(f"✓ Multi-page test: Content fit on small page, {len(small_page.children)} lines created") + print( + f"✓ Multi-page test: Content fit on small page, {len(small_page.children)} lines created") else: # If it failed, verify overflow handling assert failed_word_index is not None # Should indicate where it failed - assert failed_word_index < len(paragraph.words) # Should be within word range - print(f"✓ Multi-page test: Page overflow at word {failed_word_index}, {len(small_page.children)} lines fit") + assert failed_word_index < len( + paragraph.words) # Should be within word range + print( + f"✓ Multi-page test: Page overflow at word {failed_word_index}, {len(small_page.children)} lines fit") def test_word_spacing_constraints_in_real_lines(self): """Test that word spacing constraints are properly used in real Line objects.""" # Create real page page_style = PageStyle(border_width=20, padding=(10, 10, 10, 10)) page = Page(size=(400, 300), style=page_style) - + # Create paragraph with specific spacing constraints paragraph = MockParagraph( "Testing word spacing constraints with realistic content.", AbstractStyle(word_spacing=5.0, word_spacing_min=3.0, word_spacing_max=10.0) ) - + # Layout paragraph success, _, _ = paragraph_layouter(paragraph, page) assert success is True - + # Verify that Line objects were created with correct spacing assert len(page.children) > 0 - + for line in page.children: assert isinstance(line, Line) # Verify spacing constraints were applied @@ -191,35 +198,34 @@ class TestDocumentLayouterIntegration: min_spacing, max_spacing = line._spacing assert min_spacing == 3 # From our constraint assert max_spacing == 10 # From our constraint - - print(f"✓ Word spacing test: {len(page.children)} lines with constraints (3, 10)") + + print( + f"✓ Word spacing test: {len(page.children)} lines with constraints (3, 10)") def test_different_alignment_strategies_with_constraints(self): """Test different text alignment strategies with word spacing constraints.""" alignments_to_test = [ - ("left", AbstractStyle(text_align="left", word_spacing_min=2.0, word_spacing_max=6.0)), - ("justify", AbstractStyle(text_align="justify", word_spacing_min=3.0, word_spacing_max=12.0)), - ("center", AbstractStyle(text_align="center", word_spacing_min=1.0, word_spacing_max=5.0)) - ] - + ("left", AbstractStyle( + text_align="left", word_spacing_min=2.0, word_spacing_max=6.0)), ("justify", AbstractStyle( + text_align="justify", word_spacing_min=3.0, word_spacing_max=12.0)), ("center", AbstractStyle( + text_align="center", word_spacing_min=1.0, word_spacing_max=5.0))] + for alignment_name, style in alignments_to_test: page_style = PageStyle(border_width=20, padding=(10, 10, 10, 10)) page = Page(size=(350, 200), style=page_style) paragraph = MockParagraph( - "This sentence will test different alignment strategies with word spacing.", - style - ) - + "This sentence will test different alignment strategies with word spacing.", style) + success, _, _ = paragraph_layouter(paragraph, page) assert success is True assert len(page.children) > 0 - + # Verify alignment was applied to lines for line in page.children: assert isinstance(line, Line) # Check that the alignment handler was set correctly assert line._alignment_handler is not None - + print(f"✓ {alignment_name} alignment: {len(page.children)} lines created") def test_realistic_document_with_multiple_pages(self): @@ -227,38 +233,42 @@ class TestDocumentLayouterIntegration: # Create multiple real pages page_style = PageStyle(border_width=20, padding=(10, 10, 10, 10)) pages = [Page(size=(400, 300), style=page_style) for _ in range(3)] - + # Create a document with multiple paragraphs paragraphs = [ MockParagraph( - "This is the first paragraph of our document. It contains enough text to potentially span multiple lines and test the word spacing constraints properly.", + "This is the first paragraph of our document. It contains enough text to " + "potentially span multiple lines and test the word spacing constraints properly.", AbstractStyle(word_spacing=3.0, word_spacing_min=2.0, word_spacing_max=8.0) ), MockParagraph( - "Here is a second paragraph with different styling. This paragraph uses different word spacing constraints to test the flexibility of the system.", + "Here is a second paragraph with different styling. This paragraph uses " + "different word spacing constraints to test the flexibility of the system.", AbstractStyle(word_spacing=5.0, word_spacing_min=3.0, word_spacing_max=12.0) ), MockParagraph( - "The third and final paragraph completes our test document. It should demonstrate that the layouter can handle multiple paragraphs with varying content lengths and styling requirements.", + "The third and final paragraph completes our test document. It should " + "demonstrate that the layouter can handle multiple paragraphs with varying " + "content lengths and styling requirements.", AbstractStyle(word_spacing=4.0, word_spacing_min=2.5, word_spacing_max=10.0) ) ] - + # Layout paragraphs across pages current_page_index = 0 - + for para_index, paragraph in enumerate(paragraphs): start_word = 0 - + while start_word < len(paragraph.words): if current_page_index >= len(pages): break # Out of pages - + current_page = pages[current_page_index] success, failed_word_index, _ = paragraph_layouter( paragraph, current_page, start_word ) - + if success: # Paragraph completed on this page break @@ -267,25 +277,25 @@ class TestDocumentLayouterIntegration: if failed_word_index is not None: start_word = failed_word_index current_page_index += 1 - + # If we're out of pages, stop if current_page_index >= len(pages): break - + # Verify pages have content total_lines = sum(len(page.children) for page in pages) pages_used = sum(1 for page in pages if len(page.children) > 0) - + assert total_lines > 0 assert pages_used > 1 # Should use multiple pages - + print(f"✓ Multi-document test: {total_lines} lines across {pages_used} pages") def test_word_spacing_constraint_resolution_integration(self): """Test the complete integration from AbstractStyle to Line spacing.""" page_style = PageStyle(border_width=20, padding=(10, 10, 10, 10)) - page = Page(size=(400, 600), style=page_style) - + _page = Page(size=(400, 600), style=page_style) + # Test different constraint scenarios test_cases = [ { @@ -295,7 +305,7 @@ class TestDocumentLayouterIntegration: "expected_max": 12 }, { - "name": "default_constraints", + "name": "default_constraints", "style": AbstractStyle(word_spacing=6.0), "expected_min": 6, # Should use word_spacing as min "expected_max": 12 # Should use word_spacing * 2 as max @@ -307,7 +317,7 @@ class TestDocumentLayouterIntegration: "expected_max": 8 # Default based on font size (16 * 0.5) } ] - + for case in test_cases: # Create fresh real page for each test test_page_style = PageStyle(border_width=20, padding=(10, 10, 10, 10)) @@ -316,18 +326,20 @@ class TestDocumentLayouterIntegration: "Testing constraint resolution with different scenarios.", case["style"] ) - + success, _, _ = paragraph_layouter(paragraph, test_page) assert success is True assert len(test_page.children) > 0 - + # Verify constraints were resolved correctly line = test_page.children[0] min_spacing, max_spacing = line._spacing - - assert min_spacing == case["expected_min"], f"Min constraint failed for {case['name']}" - assert max_spacing == case["expected_max"], f"Max constraint failed for {case['name']}" - + + assert min_spacing == case["expected_min"], f"Min constraint failed for { + case['name']}" + assert max_spacing == case["expected_max"], f"Max constraint failed for { + case['name']}" + print(f"✓ {case['name']}: constraints ({min_spacing}, {max_spacing})") def test_hyphenation_with_word_spacing_constraints(self): @@ -335,15 +347,16 @@ class TestDocumentLayouterIntegration: # Create a narrow real page to force hyphenation narrow_page_style = PageStyle(border_width=20, padding=(10, 10, 10, 10)) narrow_page = Page(size=(200, 300), style=narrow_page_style) - + # Create paragraph with long words that will need hyphenation paragraph = MockParagraph( "supercalifragilisticexpialidocious antidisestablishmentarianism", AbstractStyle(word_spacing=3.0, word_spacing_min=2.0, word_spacing_max=8.0) ) - - success, failed_word_index, remaining_pretext = paragraph_layouter(paragraph, narrow_page) - + + success, failed_word_index, remaining_pretext = paragraph_layouter( + paragraph, narrow_page) + # Should succeed with hyphenation or handle overflow gracefully if success: assert len(narrow_page.children) > 0 @@ -357,10 +370,10 @@ class TestDocumentLayouterIntegration: if __name__ == "__main__": # Run integration tests test = TestDocumentLayouterIntegration() - + print("Running document layouter integration tests...") print("=" * 50) - + test.test_single_page_layout_with_real_components() test.test_multi_page_scenario_with_page_overflow() test.test_word_spacing_constraints_in_real_lines() @@ -368,6 +381,6 @@ if __name__ == "__main__": test.test_realistic_document_with_multiple_pages() test.test_word_spacing_constraint_resolution_integration() test.test_hyphenation_with_word_spacing_constraints() - + print("=" * 50) print("✅ All integration tests completed successfully!") diff --git a/tests/mixins/font_registry_tests.py b/tests/mixins/font_registry_tests.py index 2ed4b8c..d2c5bee 100644 --- a/tests/mixins/font_registry_tests.py +++ b/tests/mixins/font_registry_tests.py @@ -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") diff --git a/tests/mixins/metadata_tests.py b/tests/mixins/metadata_tests.py index 0eecfb5..683e5cc 100644 --- a/tests/mixins/metadata_tests.py +++ b/tests/mixins/metadata_tests.py @@ -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.""" diff --git a/tests/style/test_html_style.py b/tests/style/test_html_style.py index 3d0aa35..2e6c103 100644 --- a/tests/style/test_html_style.py +++ b/tests/style/test_html_style.py @@ -11,35 +11,38 @@ from pyWebLayout.style import Alignment class TestStyleObjects(unittest.TestCase): """Test cases for pyWebLayout style objects.""" - + def test_font_weight_enum(self): """Test FontWeight enum values.""" self.assertEqual(FontWeight.NORMAL.value, "normal") self.assertEqual(FontWeight.BOLD.value, "bold") - + # Test that all expected values exist weights = [FontWeight.NORMAL, FontWeight.BOLD] self.assertEqual(len(weights), 2) - + def test_font_style_enum(self): """Test FontStyle enum values.""" self.assertEqual(FontStyle.NORMAL.value, "normal") self.assertEqual(FontStyle.ITALIC.value, "italic") - + # Test that all expected values exist styles = [FontStyle.NORMAL, FontStyle.ITALIC] self.assertEqual(len(styles), 2) - + def test_text_decoration_enum(self): """Test TextDecoration enum values.""" self.assertEqual(TextDecoration.NONE.value, "none") self.assertEqual(TextDecoration.UNDERLINE.value, "underline") self.assertEqual(TextDecoration.STRIKETHROUGH.value, "strikethrough") - + # Test that all expected values exist - decorations = [TextDecoration.NONE, TextDecoration.UNDERLINE, TextDecoration.STRIKETHROUGH] + decorations = [ + TextDecoration.NONE, + TextDecoration.UNDERLINE, + TextDecoration.STRIKETHROUGH] self.assertEqual(len(decorations), 3) - + def test_alignment_enum(self): """Test Alignment enum values.""" self.assertEqual(Alignment.LEFT.value, "left") @@ -49,11 +52,11 @@ class TestStyleObjects(unittest.TestCase): self.assertEqual(Alignment.BOTTOM.value, "bottom") self.assertEqual(Alignment.JUSTIFY.value, "justify") self.assertEqual(Alignment.MIDDLE.value, "middle") - + def test_font_initialization_defaults(self): """Test Font initialization with default values.""" font = Font() - + self.assertIsNone(font._font_path) self.assertEqual(font.font_size, 16) self.assertEqual(font.colour, (0, 0, 0)) @@ -63,7 +66,7 @@ class TestStyleObjects(unittest.TestCase): self.assertEqual(font.decoration, TextDecoration.NONE) self.assertEqual(font.background, (255, 255, 255, 0)) # Transparent self.assertEqual(font.language, "en_EN") - + def test_font_initialization_custom(self): """Test Font initialization with custom values.""" font = Font( @@ -76,7 +79,7 @@ class TestStyleObjects(unittest.TestCase): background=(255, 255, 0, 255), language="fr_FR" ) - + self.assertEqual(font._font_path, "/path/to/font.ttf") self.assertEqual(font.font_size, 16) self.assertEqual(font.colour, (255, 0, 0)) @@ -85,7 +88,7 @@ class TestStyleObjects(unittest.TestCase): self.assertEqual(font.decoration, TextDecoration.UNDERLINE) self.assertEqual(font.background, (255, 255, 0, 255)) self.assertEqual(font.language, "fr_FR") - + def test_font_with_methods(self): """Test Font immutable modification methods.""" original_font = Font( @@ -95,34 +98,36 @@ class TestStyleObjects(unittest.TestCase): style=FontStyle.NORMAL, decoration=TextDecoration.NONE ) - + # Test with_size size_font = original_font.with_size(16) self.assertEqual(size_font.font_size, 16) self.assertEqual(original_font.font_size, 12) # Original unchanged self.assertEqual(size_font.colour, (0, 0, 0)) # Other properties preserved - + # Test with_colour color_font = original_font.with_colour((255, 0, 0)) self.assertEqual(color_font.colour, (255, 0, 0)) self.assertEqual(original_font.colour, (0, 0, 0)) # Original unchanged self.assertEqual(color_font.font_size, 12) # Other properties preserved - + # Test with_weight weight_font = original_font.with_weight(FontWeight.BOLD) self.assertEqual(weight_font.weight, FontWeight.BOLD) self.assertEqual(original_font.weight, FontWeight.NORMAL) # Original unchanged - + # Test with_style style_font = original_font.with_style(FontStyle.ITALIC) self.assertEqual(style_font.style, FontStyle.ITALIC) self.assertEqual(original_font.style, FontStyle.NORMAL) # Original unchanged - + # Test with_decoration decoration_font = original_font.with_decoration(TextDecoration.UNDERLINE) self.assertEqual(decoration_font.decoration, TextDecoration.UNDERLINE) - self.assertEqual(original_font.decoration, TextDecoration.NONE) # Original unchanged - + self.assertEqual( + original_font.decoration, + TextDecoration.NONE) # Original unchanged + def test_font_property_access(self): """Test Font property access methods.""" font = Font( @@ -132,7 +137,7 @@ class TestStyleObjects(unittest.TestCase): style=FontStyle.ITALIC, decoration=TextDecoration.STRIKETHROUGH ) - + # Test all property getters self.assertEqual(font.font_size, 20) self.assertEqual(font.colour, (128, 128, 128)) @@ -140,41 +145,41 @@ class TestStyleObjects(unittest.TestCase): self.assertEqual(font.weight, FontWeight.BOLD) self.assertEqual(font.style, FontStyle.ITALIC) self.assertEqual(font.decoration, TextDecoration.STRIKETHROUGH) - + # Test that font object is accessible self.assertIsNotNone(font.font) - + def test_font_immutability(self): """Test that Font objects behave immutably.""" font1 = Font(font_size=12, colour=(0, 0, 0)) font2 = font1.with_size(16) font3 = font2.with_colour((255, 0, 0)) - + # Each should be different objects self.assertIsNot(font1, font2) self.assertIsNot(font2, font3) self.assertIsNot(font1, font3) - + # Original properties should be unchanged self.assertEqual(font1.font_size, 12) self.assertEqual(font1.colour, (0, 0, 0)) - + self.assertEqual(font2.font_size, 16) self.assertEqual(font2.colour, (0, 0, 0)) - + self.assertEqual(font3.font_size, 16) self.assertEqual(font3.colour, (255, 0, 0)) - + def test_background_handling(self): """Test background color handling.""" # Test default transparent background font1 = Font() self.assertEqual(font1.background, (255, 255, 255, 0)) - + # Test explicit background font2 = Font(background=(255, 0, 0, 128)) self.assertEqual(font2.background, (255, 0, 0, 128)) - + # Test None background becomes transparent font3 = Font(background=None) self.assertEqual(font3.background, (255, 255, 255, 0)) diff --git a/tests/style/test_new_style_system.py b/tests/style/test_new_style_system.py index 9dcbba5..7dc66aa 100644 --- a/tests/style/test_new_style_system.py +++ b/tests/style/test_new_style_system.py @@ -8,12 +8,12 @@ based on user preferences. import pytest from pyWebLayout.style.abstract_style import ( - AbstractStyle, AbstractStyleRegistry, FontFamily, FontSize, TextAlign + AbstractStyle, AbstractStyleRegistry, FontFamily, FontSize ) from pyWebLayout.style.concrete_style import ( - ConcreteStyle, ConcreteStyleRegistry, RenderingContext, StyleResolver + ConcreteStyleRegistry, RenderingContext, StyleResolver ) -from pyWebLayout.style.fonts import FontWeight, FontStyle, TextDecoration +from pyWebLayout.style.fonts import FontWeight def test_abstract_style_is_hashable(): @@ -25,18 +25,18 @@ def test_abstract_style_is_hashable(): font_weight=FontWeight.BOLD, color="red" ) - + style2 = AbstractStyle( font_family=FontFamily.SERIF, font_size=16, font_weight=FontWeight.BOLD, color="red" ) - + # They should be equal and have the same hash assert style1 == style2 assert hash(style1) == hash(style2) - + # They should work as dictionary keys style_dict = {style1: "first", style2: "second"} assert len(style_dict) == 1 # Should be deduplicated @@ -46,15 +46,15 @@ def test_abstract_style_is_hashable(): def test_abstract_style_registry_deduplication(): """Test that the registry prevents duplicate styles.""" registry = AbstractStyleRegistry() - + # Create the same style twice style1 = AbstractStyle(font_size=18, font_weight=FontWeight.BOLD) style2 = AbstractStyle(font_size=18, font_weight=FontWeight.BOLD) - + # Register both - should get same ID id1, _ = registry.get_or_create_style(style1) id2, _ = registry.get_or_create_style(style2) - + assert id1 == id2 # Same style should get same ID assert registry.get_style_count() == 2 # Only default + our style @@ -62,21 +62,21 @@ def test_abstract_style_registry_deduplication(): def test_style_inheritance(): """Test that style inheritance works properly.""" registry = AbstractStyleRegistry() - + # Create base style base_style = AbstractStyle(font_size=16, color="black") base_id, _ = registry.get_or_create_style(base_style) - + # Create derived style derived_id, derived_style = registry.create_derived_style( - base_id, + base_id, font_weight=FontWeight.BOLD, color="red" ) - + # Resolve effective style effective = registry.resolve_effective_style(derived_id) - + assert effective.font_size == 16 # Inherited from base assert effective.font_weight == FontWeight.BOLD # Overridden assert effective.color == "red" # Overridden @@ -90,16 +90,17 @@ def test_style_resolver_user_preferences(): font_scale_factor=1.5, # Additional scaling large_text=True # Accessibility preference ) - + resolver = StyleResolver(context) - + # Create abstract style with medium size abstract_style = AbstractStyle(font_size=FontSize.MEDIUM) - + # Resolve to concrete style concrete_style = resolver.resolve_style(abstract_style) - - # Font size should be: 20 (base) * 1.0 (medium) * 1.5 (scale) * 1.2 (large_text) = 36 + + # Font size should be: 20 (base) * 1.0 (medium) * 1.5 (scale) * 1.2 + # (large_text) = 36 expected_size = int(20 * 1.0 * 1.5 * 1.2) assert concrete_style.font_size == expected_size @@ -108,17 +109,17 @@ def test_style_resolver_color_resolution(): """Test color name resolution.""" context = RenderingContext() resolver = StyleResolver(context) - + # Test named colors red_style = AbstractStyle(color="red") concrete_red = resolver.resolve_style(red_style) assert concrete_red.color == (255, 0, 0) - + # Test hex colors hex_style = AbstractStyle(color="#ff0000") concrete_hex = resolver.resolve_style(hex_style) assert concrete_hex.color == (255, 0, 0) - + # Test RGB tuple (should pass through) rgb_style = AbstractStyle(color=(128, 64, 192)) concrete_rgb = resolver.resolve_style(rgb_style) @@ -129,17 +130,17 @@ def test_concrete_style_caching(): """Test that concrete styles are cached efficiently.""" context = RenderingContext() registry = ConcreteStyleRegistry(StyleResolver(context)) - + # Create abstract style abstract_style = AbstractStyle(font_size=16, color="blue") - + # Get font twice - should be cached font1 = registry.get_font(abstract_style) font2 = registry.get_font(abstract_style) - + # Should be the same object (cached) assert font1 is font2 - + # Check cache stats stats = registry.get_cache_stats() assert stats["concrete_styles"] == 1 @@ -151,17 +152,17 @@ def test_global_font_scaling(): # Create two contexts with different scaling context_normal = RenderingContext(font_scale_factor=1.0) context_large = RenderingContext(font_scale_factor=2.0) - + resolver_normal = StyleResolver(context_normal) resolver_large = StyleResolver(context_large) - + # Same abstract style abstract_style = AbstractStyle(font_size=16) - + # Resolve with different contexts concrete_normal = resolver_normal.resolve_style(abstract_style) concrete_large = resolver_large.resolve_style(abstract_style) - + # Large should be 2x the size assert concrete_large.font_size == concrete_normal.font_size * 2 @@ -169,7 +170,7 @@ def test_global_font_scaling(): def test_memory_efficiency(): """Test that the new system is more memory efficient.""" registry = AbstractStyleRegistry() - + # Create many "different" styles that are actually the same styles = [] for i in range(100): @@ -181,26 +182,26 @@ def test_memory_efficiency(): ) style_id, _ = registry.get_or_create_style(style) styles.append(style_id) - + # All should reference the same style assert len(set(styles)) == 1 # All IDs are the same assert registry.get_style_count() == 2 # Only default + our style - + # This demonstrates that we don't create duplicate styles def test_word_style_reference_concept(): """Demonstrate how words would reference styles instead of storing fonts.""" registry = AbstractStyleRegistry() - + # Create paragraph style para_style = AbstractStyle(font_size=16, color="black") para_id, _ = registry.get_or_create_style(para_style) - + # Create bold word style bold_style = AbstractStyle(font_size=16, color="black", font_weight=FontWeight.BOLD) bold_id, _ = registry.get_or_create_style(bold_style) - + # Simulate words storing style IDs instead of full Font objects words_data = [ {"text": "This", "style_id": para_id}, @@ -208,19 +209,19 @@ def test_word_style_reference_concept(): {"text": "bold", "style_id": bold_id}, {"text": "text", "style_id": para_id}, ] - + # To get the actual font for rendering, we resolve through registry context = RenderingContext() concrete_registry = ConcreteStyleRegistry(StyleResolver(context)) - + for word_data in words_data: abstract_style = registry.get_style_by_id(word_data["style_id"]) font = concrete_registry.get_font(abstract_style) - + # Now we have the actual Font object for rendering assert font is not None assert hasattr(font, 'font_size') - + # Bold word should have bold weight if word_data["text"] == "bold": assert font.weight == FontWeight.BOLD diff --git a/tests/style/test_word_spacing_constraints.py b/tests/style/test_word_spacing_constraints.py index 0bed9f5..3986f79 100644 --- a/tests/style/test_word_spacing_constraints.py +++ b/tests/style/test_word_spacing_constraints.py @@ -5,14 +5,13 @@ This test shows how to use the new min/max word spacing constraints in the style system. """ -import pytest from pyWebLayout.style.abstract_style import AbstractStyle, AbstractStyleRegistry -from pyWebLayout.style.concrete_style import ConcreteStyle, StyleResolver, RenderingContext +from pyWebLayout.style.concrete_style import StyleResolver, RenderingContext class TestWordSpacingConstraints: """Test cases for word spacing constraints feature.""" - + def test_abstract_style_with_word_spacing_constraints(self): """Test that AbstractStyle accepts word spacing constraint fields.""" style = AbstractStyle( @@ -20,116 +19,116 @@ class TestWordSpacingConstraints: word_spacing_min=2.0, word_spacing_max=10.0 ) - + assert style.word_spacing == 5.0 assert style.word_spacing_min == 2.0 assert style.word_spacing_max == 10.0 - + def test_concrete_style_resolution_with_constraints(self): """Test that word spacing constraints are resolved correctly.""" # Create rendering context context = RenderingContext(base_font_size=16) resolver = StyleResolver(context) - + # Create abstract style with constraints abstract_style = AbstractStyle( word_spacing=5.0, word_spacing_min=2.0, word_spacing_max=12.0 ) - + # Resolve to concrete style concrete_style = resolver.resolve_style(abstract_style) - + # Check that constraints are preserved assert concrete_style.word_spacing == 5.0 assert concrete_style.word_spacing_min == 2.0 assert concrete_style.word_spacing_max == 12.0 - + def test_default_constraint_logic(self): """Test default constraint logic when not specified.""" context = RenderingContext(base_font_size=16) resolver = StyleResolver(context) - + # Style with only base word spacing abstract_style = AbstractStyle(word_spacing=6.0) concrete_style = resolver.resolve_style(abstract_style) - + # Should apply default logic: min = base, max = base * 2 assert concrete_style.word_spacing == 6.0 assert concrete_style.word_spacing_min == 6.0 assert concrete_style.word_spacing_max == 12.0 - + def test_no_word_spacing_defaults(self): """Test defaults when no word spacing is specified.""" context = RenderingContext(base_font_size=16) resolver = StyleResolver(context) - + # Style with no word spacing specified abstract_style = AbstractStyle() concrete_style = resolver.resolve_style(abstract_style) - + # Should apply font-based defaults assert concrete_style.word_spacing == 0.0 assert concrete_style.word_spacing_min == 2.0 # Minimum default assert concrete_style.word_spacing_max == 8.0 # 50% of font size (16 * 0.5) - + def test_partial_constraints(self): """Test behavior when only min or max is specified.""" context = RenderingContext(base_font_size=16) resolver = StyleResolver(context) - + # Only min specified abstract_style_min = AbstractStyle( word_spacing=4.0, word_spacing_min=3.0 ) concrete_style_min = resolver.resolve_style(abstract_style_min) - + assert concrete_style_min.word_spacing_min == 3.0 assert concrete_style_min.word_spacing_max == 6.0 # 3.0 * 2 - + # Only max specified abstract_style_max = AbstractStyle( word_spacing=4.0, word_spacing_max=8.0 ) concrete_style_max = resolver.resolve_style(abstract_style_max) - + assert concrete_style_max.word_spacing_min == 4.0 # max(word_spacing, 2.0) assert concrete_style_max.word_spacing_max == 8.0 - + def test_style_registry_with_constraints(self): """Test that style registry handles word spacing constraints.""" registry = AbstractStyleRegistry() - + # Create style with constraints style_id, style = registry.get_or_create_style( word_spacing=5.0, word_spacing_min=3.0, word_spacing_max=10.0 ) - + # Verify the style was created correctly retrieved_style = registry.get_style_by_id(style_id) assert retrieved_style.word_spacing == 5.0 assert retrieved_style.word_spacing_min == 3.0 assert retrieved_style.word_spacing_max == 10.0 - + def test_em_units_in_constraints(self): """Test that em units work in word spacing constraints.""" context = RenderingContext(base_font_size=16) resolver = StyleResolver(context) - + # Use em units abstract_style = AbstractStyle( word_spacing="0.25em", word_spacing_min="0.1em", word_spacing_max="0.5em" ) - + concrete_style = resolver.resolve_style(abstract_style) - + # Should convert em to pixels based on font size (16px) assert concrete_style.word_spacing == 4.0 # 0.25 * 16 assert concrete_style.word_spacing_min == 1.6 # 0.1 * 16 @@ -146,5 +145,5 @@ if __name__ == "__main__": test.test_partial_constraints() test.test_style_registry_with_constraints() test.test_em_units_in_constraints() - + print("All word spacing constraint tests passed!") diff --git a/tests/test_callback_registry.py b/tests/test_callback_registry.py index c4aa1cc..5a64b5e 100644 --- a/tests/test_callback_registry.py +++ b/tests/test_callback_registry.py @@ -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 diff --git a/tests/test_interactive_image.py b/tests/test_interactive_image.py index c59135d..9386477 100644 --- a/tests/test_interactive_image.py +++ b/tests/test_interactive_image.py @@ -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)) diff --git a/tests/utils/test_font_utilities.py b/tests/utils/test_font_utilities.py index 0c4850b..a18d78e 100644 --- a/tests/utils/test_font_utilities.py +++ b/tests/utils/test_font_utilities.py @@ -20,18 +20,23 @@ from pyWebLayout.style.fonts import Font, FontWeight, FontStyle, TextDecoration class TestFontUtilities(unittest.TestCase): """Test cases for font utility functions.""" - + def test_get_bundled_font_path_finds_font(self): """Test that get_bundled_font_path finds the bundled font.""" font_path = get_bundled_font_path() self.assertIsNotNone(font_path, "Bundled font path should not be None") - self.assertTrue(os.path.exists(font_path), f"Font file should exist at {font_path}") - self.assertTrue(font_path.endswith("DejaVuSans.ttf"), "Font path should end with DejaVuSans.ttf") - + self.assertTrue( + os.path.exists(font_path), + f"Font file should exist at {font_path}") + self.assertTrue( + font_path.endswith("DejaVuSans.ttf"), + "Font path should end with DejaVuSans.ttf") + def test_verify_bundled_font_available(self): """Test that the bundled font can be verified and loaded.""" - self.assertTrue(verify_bundled_font_available(), "Bundled font should be available and loadable") - + self.assertTrue(verify_bundled_font_available(), + "Bundled font should be available and loadable") + def test_create_test_font_with_defaults(self): """Test creating a test font with default parameters.""" font = create_test_font() @@ -41,7 +46,7 @@ class TestFontUtilities(unittest.TestCase): self.assertEqual(font.weight, FontWeight.NORMAL) self.assertEqual(font.style, FontStyle.NORMAL) self.assertEqual(font.decoration, TextDecoration.NONE) - + def test_create_test_font_with_custom_parameters(self): """Test creating a test font with custom parameters.""" font = create_test_font( @@ -57,50 +62,52 @@ class TestFontUtilities(unittest.TestCase): self.assertEqual(font.weight, FontWeight.BOLD) self.assertEqual(font.style, FontStyle.ITALIC) self.assertEqual(font.decoration, TextDecoration.UNDERLINE) - + def test_create_default_test_font(self): """Test creating a default test font.""" font = create_default_test_font() self.assertIsInstance(font, Font) self.assertEqual(font.font_size, 16) self.assertEqual(font.colour, (0, 0, 0)) - + def test_ensure_consistent_font_in_tests_succeeds(self): """Test that ensure_consistent_font_in_tests runs without error when font is available.""" # This should not raise any exceptions if the font is properly available try: ensure_consistent_font_in_tests() except RuntimeError: - self.fail("ensure_consistent_font_in_tests() raised RuntimeError when font should be available") - + self.fail( + "ensure_consistent_font_in_tests() raised RuntimeError when font should be available") + def test_bundled_font_loads_with_pil(self): """Test that the bundled font can be loaded directly with PIL.""" font_path = get_bundled_font_path() self.assertIsNotNone(font_path) - + # Test loading with different sizes for size in [12, 16, 24, 48]: with self.subTest(size=size): pil_font = ImageFont.truetype(font_path, size) self.assertIsNotNone(pil_font) - + def test_font_metrics_consistency(self): """Test that font metrics are consistent between different Font objects using the same parameters.""" font1 = create_test_font(font_size=16) font2 = create_test_font(font_size=16) - + # Both fonts should have the same size self.assertEqual(font1.font_size, font2.font_size) - + # Test that text measurements are consistent - # This is a basic check - in real usage, text measurement consistency is what matters most + # This is a basic check - in real usage, text measurement consistency is + # what matters most self.assertEqual(font1.font_size, font2.font_size) - + def test_different_sizes_create_different_fonts(self): """Test that different font sizes create fonts with different metrics.""" small_font = create_test_font(font_size=12) large_font = create_test_font(font_size=24) - + self.assertNotEqual(small_font.font_size, large_font.font_size) self.assertEqual(small_font.font_size, 12) self.assertEqual(large_font.font_size, 24) @@ -108,24 +115,28 @@ class TestFontUtilities(unittest.TestCase): class TestFontPathResolution(unittest.TestCase): """Test cases for font path resolution from different locations.""" - + def test_font_path_is_absolute(self): """Test that the returned font path is absolute.""" font_path = get_bundled_font_path() if font_path: self.assertTrue(os.path.isabs(font_path), "Font path should be absolute") - + def test_font_path_points_to_file(self): """Test that the font path points to a file, not a directory.""" font_path = get_bundled_font_path() if font_path: - self.assertTrue(os.path.isfile(font_path), "Font path should point to a file") - + self.assertTrue( + os.path.isfile(font_path), + "Font path should point to a file") + def test_font_file_has_correct_extension(self): """Test that the font file has the expected .ttf extension.""" font_path = get_bundled_font_path() if font_path: - self.assertTrue(font_path.lower().endswith('.ttf'), "Font file should have .ttf extension") + self.assertTrue( + font_path.lower().endswith('.ttf'), + "Font file should have .ttf extension") if __name__ == '__main__': diff --git a/tests/utils/test_fonts.py b/tests/utils/test_fonts.py index 8171a1b..d68a9b6 100644 --- a/tests/utils/test_fonts.py +++ b/tests/utils/test_fonts.py @@ -6,7 +6,6 @@ preventing inconsistencies that can arise from different system fonts. """ import os -import sys from typing import Optional from PIL import ImageFont @@ -16,25 +15,30 @@ from pyWebLayout.style.fonts import Font, FontWeight, FontStyle, TextDecoration def get_bundled_font_path() -> Optional[str]: """ Get the path to the bundled DejaVuSans.ttf font. - + This function works from test directories by finding the font relative to the test file locations. - + Returns: str: Path to the bundled font file, or None if not found """ # Get the directory containing this test utility file current_dir = os.path.dirname(os.path.abspath(__file__)) - + # Navigate up to the project root (tests/utils -> tests -> root) project_root = os.path.dirname(os.path.dirname(current_dir)) - + # Path to the bundled font - bundled_font_path = os.path.join(project_root, 'pyWebLayout', 'assets', 'fonts', 'DejaVuSans.ttf') - + bundled_font_path = os.path.join( + project_root, + 'pyWebLayout', + 'assets', + 'fonts', + 'DejaVuSans.ttf') + if os.path.exists(bundled_font_path): return bundled_font_path - + # Alternative: try to find it relative to the pyWebLayout module try: import pyWebLayout @@ -44,24 +48,24 @@ def get_bundled_font_path() -> Optional[str]: return alt_font_path except ImportError: pass - + return None def verify_bundled_font_available() -> bool: """ Verify that the bundled font is available and can be loaded. - + Returns: bool: True if the bundled font is available and loadable """ font_path = get_bundled_font_path() if not font_path: return False - + try: # Try to load the font with PIL to verify it's valid - test_font = ImageFont.truetype(font_path, 16) + ImageFont.truetype(font_path, 16) return True except Exception: return False @@ -77,10 +81,10 @@ def create_test_font(font_size: int = 16, min_hyphenation_width: Optional[int] = None) -> Font: """ Create a Font object that uses the bundled font for consistent testing. - + This function ensures all tests use the same font file, preventing cross-system inconsistencies in text measurements and layout. - + Args: font_size: Size of the font in points colour: RGB color tuple for the text @@ -90,10 +94,10 @@ def create_test_font(font_size: int = 16, background: RGBA background color for the text language: Language code for hyphenation and text processing min_hyphenation_width: Minimum width in pixels for hyphenation - + Returns: Font: A Font object guaranteed to use the bundled font - + Raises: RuntimeError: If the bundled font cannot be found or loaded """ @@ -103,13 +107,13 @@ def create_test_font(font_size: int = 16, "Bundled font (DejaVuSans.ttf) not found. " "Ensure the font exists in pyWebLayout/assets/fonts/" ) - + if not verify_bundled_font_available(): raise RuntimeError( f"Bundled font at {font_path} cannot be loaded. " "Font file may be corrupted or invalid." ) - + return Font( font_path=font_path, font_size=font_size, @@ -126,9 +130,9 @@ def create_test_font(font_size: int = 16, def create_default_test_font() -> Font: """ Create a default Font object for testing with the bundled font. - + This is equivalent to Font() but guarantees the bundled font is used. - + Returns: Font: A default Font object using the bundled font """ @@ -138,10 +142,10 @@ def create_default_test_font() -> Font: def ensure_consistent_font_in_tests(): """ Ensure that tests are using consistent fonts by checking availability. - + This function can be called in test setup to verify the font environment is properly configured. - + Raises: RuntimeError: If the bundled font is not available """