diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..0f997b4 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,48 @@ +name: Python CI + +on: + push: + branches: [ main, master, develop ] + pull_request: + branches: [ main, master, develop ] + +jobs: + test: + runs-on: self-hosted + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.x' # or specify version like '3.9', '3.10', etc. + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + # Install package in development mode + pip install -e . + # Install test dependencies if they exist + if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi + if [ -f requirements/test.txt ]; then pip install -r requirements/test.txt; fi + # Install common test packages + pip install pytest pytest-cov flake8 + + - name: Lint with flake8 + run: | + # Stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # Exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + + - name: Run tests with pytest + run: | + # Run tests with coverage + python -m pytest tests/ -v --cov=pyWebLayout --cov-report=term-missing + + - name: Test package installation + run: | + # Test that the package can be imported + python -c "import pyWebLayout; print('Package imported successfully')" diff --git a/pyWebLayout/abstract/__init__.py b/pyWebLayout/abstract/__init__.py index eaf2e6c..c8b030f 100644 --- a/pyWebLayout/abstract/__init__.py +++ b/pyWebLayout/abstract/__init__.py @@ -1,4 +1,4 @@ -from .block import Block, BlockType, Parapgraph, Heading, HeadingLevel, Quote, CodeBlock +from .block import Block, BlockType, Paragraph, Heading, HeadingLevel, Quote, CodeBlock from .block import HList, ListItem, ListStyle, Table, TableRow, TableCell from .block import HorizontalRule, LineBreak, Image from .inline import Word, FormattedSpan diff --git a/pyWebLayout/abstract/block.py b/pyWebLayout/abstract/block.py index 4fb5105..2a05bf7 100644 --- a/pyWebLayout/abstract/block.py +++ b/pyWebLayout/abstract/block.py @@ -51,16 +51,65 @@ class Block: self._parent = parent -class Parapgraph(Block): +class Paragraph(Block): """ A paragraph is a block-level element that contains a sequence of words. """ - def __init__(self): - """Initialize an empty paragraph""" + def __init__(self, style=None): + """ + Initialize an empty paragraph + + Args: + style: Optional default style for words in this paragraph + """ super().__init__(BlockType.PARAGRAPH) self._words: List[Word] = [] self._spans: List[FormattedSpan] = [] + self._style = style + + @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 + """ + # Inherit style from container if not provided + if style is None and hasattr(container, 'style'): + 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") + + return paragraph + + @property + def style(self): + """Get the default style for this paragraph""" + return self._style + + @style.setter + def style(self, style): + """Set the default style for this paragraph""" + self._style = style def add_word(self, word: Word): """ @@ -71,6 +120,23 @@ class Parapgraph(Block): """ 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. @@ -80,6 +146,19 @@ class Parapgraph(Block): """ 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) + def words(self) -> Iterator[Tuple[int, Word]]: """ Iterate over the words in this paragraph. @@ -116,23 +195,58 @@ class HeadingLevel(Enum): H6 = 6 -class Heading(Parapgraph): +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): + 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 """ - super().__init__() + 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': + """ + 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 + """ + # Inherit style from container if not provided + if style is None and hasattr(container, 'style'): + 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") + + return heading + @property def level(self) -> HeadingLevel: """Get the heading level""" @@ -149,10 +263,59 @@ class Quote(Block): A blockquote element that can contain other block elements. """ - def __init__(self): - """Initialize an empty blockquote""" + 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 + """ + # Inherit style from container if not provided + if style is None and hasattr(container, 'style'): + 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") + + 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): """ @@ -164,6 +327,31 @@ class Quote(Block): 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: + """ + 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. @@ -191,6 +379,32 @@ class CodeBlock(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") + + return code_block + @property def language(self) -> str: """Get the programming language""" @@ -238,16 +452,52 @@ class HList(Block): An HTML list element (ul, ol, dl). """ - def __init__(self, style: ListStyle = ListStyle.UNORDERED): + def __init__(self, style: ListStyle = ListStyle.UNORDERED, default_style=None): """ Initialize a list. Args: style: The style of list (unordered, ordered, definition) + default_style: Optional default style for child items """ super().__init__(BlockType.LIST) self._style = style self._items: List[ListItem] = [] + self._default_style = default_style + + @classmethod + def create_and_add_to(cls, container, style: ListStyle = ListStyle.UNORDERED, default_style=None) -> 'HList': + """ + Create a new HList and add it to a container, inheriting style from + the container if not explicitly provided. + + Args: + container: The container to add the list to (must have add_block method) + style: The style of list (unordered, ordered, definition) + default_style: Optional default style for child items. If None, inherits from container + + Returns: + The newly created HList object + + Raises: + AttributeError: If the container doesn't have the required add_block method + """ + # Inherit style from container if not provided + if default_style is None and hasattr(container, 'style'): + default_style = container.style + elif default_style is None and hasattr(container, 'default_style'): + default_style = container.default_style + + # Create the new list + hlist = cls(style, default_style) + + # Add the list to the container + if hasattr(container, 'add_block'): + container.add_block(hlist) + else: + raise AttributeError(f"Container {type(container).__name__} must have an 'add_block' method") + + return hlist @property def style(self) -> ListStyle: @@ -259,6 +509,16 @@ class HList(Block): """Set the list style""" self._style = style + @property + def default_style(self): + """Get the default style for list items""" + return self._default_style + + @default_style.setter + def default_style(self, style): + """Set the default style for list items""" + self._default_style = style + def add_item(self, item: 'ListItem'): """ Add an item to this list. @@ -269,6 +529,19 @@ class HList(Block): self._items.append(item) item.parent = self + def create_item(self, term: Optional[str] = None, style=None) -> 'ListItem': + """ + Create a new list item and add it to this list. + + Args: + term: Optional term for definition lists + style: Optional style override. If None, inherits from list + + Returns: + The newly created ListItem object + """ + return ListItem.create_and_add_to(self, term, style) + def items(self) -> Iterator['ListItem']: """ Iterate over the items in this list. @@ -290,16 +563,52 @@ class ListItem(Block): A list item element that can contain other block elements. """ - def __init__(self, term: Optional[str] = None): + def __init__(self, term: Optional[str] = None, style=None): """ Initialize a list item. Args: term: Optional term for definition lists (dt element) + style: Optional default style for child blocks """ super().__init__(BlockType.LIST_ITEM) self._blocks: List[Block] = [] self._term = term + self._style = style + + @classmethod + def create_and_add_to(cls, container, term: Optional[str] = None, style=None) -> 'ListItem': + """ + Create a new ListItem and add it to a container, inheriting style from + the container if not explicitly provided. + + Args: + container: The container to add the list item to (must have add_item method) + term: Optional term for definition lists (dt element) + style: Optional style override. If None, inherits from container + + Returns: + The newly created ListItem object + + Raises: + AttributeError: If the container doesn't have the required add_item method + """ + # Inherit style from container if not provided + if style is None and hasattr(container, 'default_style'): + style = container.default_style + elif style is None and hasattr(container, 'style'): + style = container.style + + # Create the new list item + item = cls(term, style) + + # Add the list item to the container + if hasattr(container, 'add_item'): + container.add_item(item) + else: + raise AttributeError(f"Container {type(container).__name__} must have an 'add_item' method") + + return item @property def term(self) -> Optional[str]: @@ -311,6 +620,16 @@ class ListItem(Block): """Set the definition term""" self._term = term + @property + def style(self): + """Get the default style for this list item""" + return self._style + + @style.setter + def style(self, style): + """Set the default style for this list item""" + self._style = style + def add_block(self, block: Block): """ Add a block element to this list item. @@ -321,6 +640,31 @@ class ListItem(Block): self._blocks.append(block) block.parent = self + def create_paragraph(self, style=None) -> Paragraph: + """ + Create a new paragraph and add it to this list item. + + Args: + style: Optional style override. If None, inherits from list item + + 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: + """ + Create a new heading and add it to this list item. + + Args: + level: The heading level + style: Optional style override. If None, inherits from list item + + 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 list item. @@ -337,7 +681,7 @@ class TableCell(Block): A table cell element that can contain other block elements. """ - def __init__(self, is_header: bool = False, colspan: int = 1, rowspan: int = 1): + def __init__(self, is_header: bool = False, colspan: int = 1, rowspan: int = 1, style=None): """ Initialize a table cell. @@ -345,12 +689,49 @@ class TableCell(Block): is_header: Whether this cell is a header cell (th) or data cell (td) colspan: Number of columns this cell spans rowspan: Number of rows this cell spans + style: Optional default style for child blocks """ super().__init__(BlockType.TABLE_CELL) self._is_header = is_header self._colspan = colspan self._rowspan = rowspan self._blocks: List[Block] = [] + self._style = style + + @classmethod + def create_and_add_to(cls, container, is_header: bool = False, colspan: int = 1, + rowspan: int = 1, style=None) -> 'TableCell': + """ + Create a new TableCell and add it to a container, inheriting style from + the container if not explicitly provided. + + Args: + container: The container to add the cell to (must have add_cell method) + is_header: Whether this cell is a header cell (th) or data cell (td) + colspan: Number of columns this cell spans + rowspan: Number of rows this cell spans + style: Optional style override. If None, inherits from container + + Returns: + The newly created TableCell object + + Raises: + AttributeError: If the container doesn't have the required add_cell method + """ + # Inherit style from container if not provided + if style is None and hasattr(container, 'style'): + style = container.style + + # Create the new table cell + cell = cls(is_header, colspan, rowspan, style) + + # Add the cell to the container + if hasattr(container, 'add_cell'): + container.add_cell(cell) + else: + raise AttributeError(f"Container {type(container).__name__} must have an 'add_cell' method") + + return cell @property def is_header(self) -> bool: @@ -382,6 +763,16 @@ class TableCell(Block): """Set the row span""" self._rowspan = max(1, rowspan) # Ensure minimum of 1 + @property + def style(self): + """Get the default style for this table cell""" + return self._style + + @style.setter + def style(self, style): + """Set the default style for this table cell""" + self._style = style + def add_block(self, block: Block): """ Add a block element to this cell. @@ -392,6 +783,31 @@ class TableCell(Block): self._blocks.append(block) block.parent = self + def create_paragraph(self, style=None) -> Paragraph: + """ + Create a new paragraph and add it to this table cell. + + Args: + style: Optional style override. If None, inherits from cell + + 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: + """ + Create a new heading and add it to this table cell. + + Args: + level: The heading level + style: Optional style override. If None, inherits from cell + + 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 cell. @@ -408,10 +824,58 @@ class TableRow(Block): A table row element containing table cells. """ - def __init__(self): - """Initialize an empty table row""" + def __init__(self, style=None): + """ + Initialize an empty table row + + Args: + style: Optional default style for child cells + """ super().__init__(BlockType.TABLE_ROW) self._cells: List[TableCell] = [] + self._style = style + + @classmethod + def create_and_add_to(cls, container, section: str = "body", style=None) -> 'TableRow': + """ + Create a new TableRow and add it to a container, inheriting style from + the container if not explicitly provided. + + Args: + container: The container to add the row to (must have add_row method) + section: The section to add the row to ("header", "body", or "footer") + style: Optional style override. If None, inherits from container + + Returns: + The newly created TableRow object + + Raises: + AttributeError: If the container doesn't have the required add_row method + """ + # Inherit style from container if not provided + if style is None and hasattr(container, 'style'): + style = container.style + + # Create the new table row + row = cls(style) + + # Add the row to the container + if hasattr(container, 'add_row'): + container.add_row(row, section) + else: + raise AttributeError(f"Container {type(container).__name__} must have an 'add_row' method") + + return row + + @property + def style(self): + """Get the default style for this table row""" + return self._style + + @style.setter + def style(self, style): + """Set the default style for this table row""" + self._style = style def add_cell(self, cell: TableCell): """ @@ -423,6 +887,21 @@ class TableRow(Block): self._cells.append(cell) cell.parent = self + def create_cell(self, is_header: bool = False, colspan: int = 1, rowspan: int = 1, style=None) -> TableCell: + """ + Create a new table cell and add it to this row. + + Args: + is_header: Whether this cell is a header cell + colspan: Number of columns this cell spans + rowspan: Number of rows this cell spans + style: Optional style override. If None, inherits from row + + Returns: + The newly created TableCell object + """ + return TableCell.create_and_add_to(self, is_header, colspan, rowspan, style) + def cells(self) -> Iterator[TableCell]: """ Iterate over the cells in this row. @@ -444,18 +923,54 @@ class Table(Block): A table element containing rows and cells. """ - def __init__(self, caption: Optional[str] = None): + def __init__(self, caption: Optional[str] = None, style=None): """ Initialize a table. Args: caption: Optional caption for the table + style: Optional default style for child rows """ super().__init__(BlockType.TABLE) self._caption = caption self._rows: List[TableRow] = [] self._header_rows: List[TableRow] = [] self._footer_rows: List[TableRow] = [] + self._style = style + + @classmethod + def create_and_add_to(cls, container, caption: Optional[str] = None, style=None) -> 'Table': + """ + Create a new Table and add it to a container, inheriting style from + the container if not explicitly provided. + + Args: + container: The container to add the table to (must have add_block method) + caption: Optional caption for the table + style: Optional style override. If None, inherits from container + + Returns: + The newly created Table object + + Raises: + AttributeError: If the container doesn't have the required add_block method + """ + # Inherit style from container if not provided + if style is None and hasattr(container, 'style'): + style = container.style + elif style is None and hasattr(container, 'default_style'): + style = container.default_style + + # Create the new table + table = cls(caption, style) + + # Add the table to the container + if hasattr(container, 'add_block'): + container.add_block(table) + else: + raise AttributeError(f"Container {type(container).__name__} must have an 'add_block' method") + + return table @property def caption(self) -> Optional[str]: @@ -467,6 +982,16 @@ class Table(Block): """Set the table caption""" self._caption = caption + @property + def style(self): + """Get the default style for this table""" + return self._style + + @style.setter + def style(self, style): + """Set the default style for this table""" + self._style = style + def add_row(self, row: TableRow, section: str = "body"): """ Add a row to this table. @@ -482,302 +1007,4 @@ class Table(Block): elif section.lower() == "footer": self._footer_rows.append(row) else: # Default to body - self._rows.append(row) - - def header_rows(self) -> Iterator[TableRow]: - """ - Iterate over the header rows in this table. - - Yields: - Each TableRow in the table header - """ - for row in self._header_rows: - yield row - - def body_rows(self) -> Iterator[TableRow]: - """ - Iterate over the body rows in this table. - - Yields: - Each TableRow in the table body - """ - for row in self._rows: - yield row - - def footer_rows(self) -> Iterator[TableRow]: - """ - Iterate over the footer rows in this table. - - Yields: - Each TableRow in the table footer - """ - for row in self._footer_rows: - yield row - - def all_rows(self) -> Iterator[Tuple[str, TableRow]]: - """ - Iterate over all rows in this table with their section. - - Yields: - Tuples of (section, row) for each row - """ - for row in self._header_rows: - yield "header", row - - for row in self._rows: - yield "body", row - - for row in self._footer_rows: - yield "footer", row - - @property - def row_count(self) -> Dict[str, int]: - """Get the number of rows in each section""" - return { - "header": len(self._header_rows), - "body": len(self._rows), - "footer": len(self._footer_rows), - "total": len(self._header_rows) + len(self._rows) + len(self._footer_rows) - } - - -class HorizontalRule(Block): - """ - A horizontal rule element (
). - """ - - def __init__(self): - """Initialize a horizontal rule""" - super().__init__(BlockType.HORIZONTAL_RULE) - - -class LineBreak(Block): - """ - A line break element (
). - """ - - def __init__(self): - """Initialize a line break""" - super().__init__(BlockType.LINE_BREAK) - - -class Image(Block): - """ - An image element that can be displayed in a document. - """ - - def __init__(self, source: str, alt_text: Optional[str] = None, - width: Optional[int] = None, height: Optional[int] = None): - """ - Initialize an image. - - Args: - source: The path or URL to the image - alt_text: Alternative text description of the image - width: Optional width to display the image - height: Optional height to display the image - """ - super().__init__(BlockType.IMAGE) - self._source = source - self._alt_text = alt_text or "" - self._width = width - self._height = height - self._loaded_image = None - self._error = None - - # Try to load the image immediately - self.load() - - @property - def source(self) -> str: - """Get the image source path or URL""" - return self._source - - @source.setter - def source(self, source: str): - """Set the image source path or URL""" - self._source = source - self._loaded_image = None # Reset loaded image when source changes - self._error = None - # Try to load the image with the new source - self.load() - - @property - def alt_text(self) -> str: - """Get the alternative text for the image""" - return self._alt_text - - @alt_text.setter - def alt_text(self, alt_text: str): - """Set the alternative text for the image""" - self._alt_text = alt_text - - @property - def width(self) -> Optional[int]: - """Get the specified width for the image""" - return self._width - - @width.setter - def width(self, width: Optional[int]): - """Set the specified width for the image""" - self._width = width - - @property - def height(self) -> Optional[int]: - """Get the specified height for the image""" - return self._height - - @height.setter - def height(self, height: Optional[int]): - """Set the specified height for the image""" - self._height = height - - @property - def loaded_image(self): - """Get the loaded image data, if available""" - return self._loaded_image - - @property - def error(self) -> Optional[str]: - """Get any error message from attempting to load the image""" - return self._error - - def load(self): - """ - Load the image from the source. - This method handles loading from local files and URLs. - - Returns: - True if the image was loaded successfully, False otherwise - """ - try: - import os - from PIL import Image as PILImage - - # Handle different types of sources - if os.path.isfile(self._source): - # Local file - self._loaded_image = PILImage.open(self._source) - self._error = None - return True - elif self._source.startswith(('http://', 'https://')): - # URL - requires requests library - try: - import requests - from io import BytesIO - - response = requests.get(self._source, stream=True) - if response.status_code == 200: - self._loaded_image = PILImage.open(BytesIO(response.content)) - self._error = None - return True - else: - self._error = f"Failed to load image: HTTP status {response.status_code}" - return False - except ImportError: - self._error = "Requests library not available for URL loading" - return False - except Exception as e: - self._error = f"Error loading image from URL: {str(e)}" - return False - elif self._source.startswith('data:image/'): - # Data URI - try: - import base64 - from io import BytesIO - - # Parse the data URI - # Format: data:image/png;base64, - header, encoded = self._source.split(',', 1) - mime_type = header.split(';')[0].split(':')[1] - - # Decode the base64 data - decoded = base64.b64decode(encoded) - self._loaded_image = PILImage.open(BytesIO(decoded)) - self._error = None - return True - except Exception as e: - self._error = f"Error loading image from data URI: {str(e)}" - return False - else: - self._error = f"Unable to load image from source: {self._source}" - return False - - except ImportError as e: - self._error = f"PIL library not available: {str(e)}" - return False - except Exception as e: - self._error = f"Error loading image: {str(e)}" - return False - - def get_dimensions(self) -> Tuple[Optional[int], Optional[int]]: - """ - Get the dimensions of the image. - - Returns: - A tuple of (width, height), or (None, None) if the image is not loaded - """ - if self._loaded_image: - return self._loaded_image.size - return self._width, self._height - - def get_aspect_ratio(self) -> Optional[float]: - """ - Get the aspect ratio of the image (width/height). - - Returns: - The aspect ratio as a float, or None if the image is not loaded - and no dimensions are specified - """ - if self._loaded_image: - width, height = self._loaded_image.size - if height > 0: - return width / height - elif self._width is not None and self._height is not None and self._height > 0: - return self._width / self._height - return None - - def calculate_scaled_dimensions(self, max_width: Optional[int] = None, - max_height: Optional[int] = None) -> Tuple[int, int]: - """ - Calculate the scaled dimensions of the image within constraints. - - Args: - max_width: The maximum width constraint - max_height: The maximum height constraint - - Returns: - A tuple of (width, height) that fits within the constraints - while maintaining the aspect ratio - """ - # Use specified dimensions if available - if self._width is not None and self._height is not None: - return self._width, self._height - - # If image is loaded, use its dimensions - if self._loaded_image: - orig_width, orig_height = self._loaded_image.size - else: - # If no image is loaded and no dimensions specified, use defaults - return self._width or 300, self._height or 200 - - # If only one dimension is specified, calculate the other - if self._width is not None and self._height is None: - aspect = orig_width / orig_height - return self._width, int(self._width / aspect) - elif self._height is not None and self._width is None: - aspect = orig_width / orig_height - return int(self._height * aspect), self._height - - # Apply max constraints if provided - width, height = orig_width, orig_height - - if max_width is not None and width > max_width: - height = int(height * (max_width / width)) - width = max_width - - if max_height is not None and height > max_height: - width = int(width * (max_height / height)) - height = max_height - - return width, height + self._rows diff --git a/pyWebLayout/abstract/document.py b/pyWebLayout/abstract/document.py index 104e415..c60995a 100644 --- a/pyWebLayout/abstract/document.py +++ b/pyWebLayout/abstract/document.py @@ -1,7 +1,7 @@ from __future__ import annotations from typing import List, Dict, Optional, Tuple, Union, Any from enum import Enum -from .block import Block, BlockType, Heading, HeadingLevel, Parapgraph +from .block import Block, BlockType, Heading, HeadingLevel, Paragraph from .functional import Link, Button, Form from .inline import Word, FormattedSpan @@ -27,13 +27,14 @@ class Document: This class manages the logical structure of the document without rendering concerns. """ - def __init__(self, title: Optional[str] = None, language: str = "en-US"): + def __init__(self, title: Optional[str] = None, language: str = "en-US", default_style=None): """ Initialize a new document. Args: title: The document title language: The document language code + default_style: Optional default style for child blocks """ self._blocks: List[Block] = [] self._metadata: Dict[MetadataType, Any] = {} @@ -41,6 +42,7 @@ class Document: self._resources: Dict[str, Any] = {} # External resources like images self._stylesheets: List[Dict[str, Any]] = [] # CSS stylesheets self._scripts: List[str] = [] # JavaScript code + self._default_style = default_style # Set basic metadata if title: @@ -52,6 +54,16 @@ class Document: """Get the top-level blocks in this document""" return self._blocks + @property + def default_style(self): + """Get the default style for this document""" + return self._default_style + + @default_style.setter + def default_style(self, style): + """Set the default style for this document""" + self._default_style = style + def add_block(self, block: Block): """ Add a block to this document. @@ -61,6 +73,55 @@ class Document: """ self._blocks.append(block) + def create_paragraph(self, style=None) -> Paragraph: + """ + Create a new paragraph and add it to this document. + + Args: + style: Optional style override. If None, inherits from document + + Returns: + The newly created Paragraph object + """ + if style is None: + style = self._default_style + paragraph = Paragraph(style) + self.add_block(paragraph) + return paragraph + + def create_heading(self, level: HeadingLevel = HeadingLevel.H1, style=None) -> Heading: + """ + Create a new heading and add it to this document. + + Args: + level: The heading level + style: Optional style override. If None, inherits from document + + Returns: + The newly created Heading object + """ + if style is None: + style = self._default_style + heading = Heading(level, style) + self.add_block(heading) + return heading + + def create_chapter(self, title: Optional[str] = None, level: int = 1, style=None) -> 'Chapter': + """ + Create a new chapter with inherited style. + + Args: + title: The chapter title + level: The chapter level + style: Optional style override. If None, inherits from document + + Returns: + The newly created Chapter object + """ + if style is None: + style = self._default_style + return Chapter(title, level, style) + def set_metadata(self, meta_type: MetadataType, value: Any): """ Set a metadata value. @@ -229,18 +290,20 @@ class Chapter: A chapter contains a sequence of blocks and has metadata. """ - def __init__(self, title: Optional[str] = None, level: int = 1): + def __init__(self, title: Optional[str] = None, level: int = 1, style=None): """ Initialize a new chapter. Args: title: The chapter title level: The chapter level (1 = top level, 2 = subsection, etc.) + style: Optional default style for child blocks """ self._title = title self._level = level self._blocks: List[Block] = [] self._metadata: Dict[str, Any] = {} + self._style = style @property def title(self) -> Optional[str]: @@ -262,6 +325,16 @@ class Chapter: """Get the blocks in this chapter""" return self._blocks + @property + def style(self): + """Get the default style for this chapter""" + return self._style + + @style.setter + def style(self, style): + """Set the default style for this chapter""" + self._style = style + def add_block(self, block: Block): """ Add a block to this chapter. @@ -271,6 +344,39 @@ class Chapter: """ self._blocks.append(block) + def create_paragraph(self, style=None) -> Paragraph: + """ + Create a new paragraph and add it to this chapter. + + Args: + style: Optional style override. If None, inherits from chapter + + Returns: + The newly created Paragraph object + """ + if style is None: + style = self._style + paragraph = Paragraph(style) + self.add_block(paragraph) + return paragraph + + def create_heading(self, level: HeadingLevel = HeadingLevel.H1, style=None) -> Heading: + """ + Create a new heading and add it to this chapter. + + Args: + level: The heading level + style: Optional style override. If None, inherits from chapter + + Returns: + The newly created Heading object + """ + if style is None: + style = self._style + heading = Heading(level, style) + self.add_block(heading) + return heading + def set_metadata(self, key: str, value: Any): """ Set a metadata value. @@ -300,7 +406,8 @@ class Book(Document): A book is a document that contains chapters. """ - def __init__(self, title: Optional[str] = None, author: Optional[str] = None, language: str = "en-US"): + def __init__(self, title: Optional[str] = None, author: Optional[str] = None, + language: str = "en-US", default_style=None): """ Initialize a new book. @@ -308,8 +415,9 @@ class Book(Document): title: The book title author: The book author language: The book language code + default_style: Optional default style for child chapters and blocks """ - super().__init__(title, language) + super().__init__(title, language, default_style) self._chapters: List[Chapter] = [] if author: @@ -329,18 +437,21 @@ class Book(Document): """ self._chapters.append(chapter) - def create_chapter(self, title: Optional[str] = None, level: int = 1) -> Chapter: + def create_chapter(self, title: Optional[str] = None, level: int = 1, style=None) -> Chapter: """ - Create and add a new chapter. + Create and add a new chapter with inherited style. Args: title: The chapter title level: The chapter level + style: Optional style override. If None, inherits from book Returns: The new chapter """ - chapter = Chapter(title, level) + if style is None: + style = self._default_style + chapter = Chapter(title, level, style) self.add_chapter(chapter) return chapter diff --git a/pyWebLayout/abstract/inline.py b/pyWebLayout/abstract/inline.py index a9bb00e..857dd9f 100644 --- a/pyWebLayout/abstract/inline.py +++ b/pyWebLayout/abstract/inline.py @@ -27,6 +27,91 @@ class Word: self._previous = previous self._next = None self._hyphenated_parts = None # Will store hyphenated parts if word is hyphenated + + @classmethod + def create_and_add_to(cls, text: str, container, style: Optional[Font] = None, + background=None) -> 'Word': + """ + Create a new Word and add it to a container, inheriting style and language + from the container if not explicitly provided. + + This method provides a convenient way to create words that automatically + inherit styling from their container (Paragraph, FormattedSpan, etc.) + without copying string values - using object references instead. + + Args: + text: The text content of the word + container: The container to add the word to (must have add_word method and style property) + style: Optional Font style override. If None, inherits from container + background: Optional background color override. If None, inherits from container + + Returns: + The newly created Word object + + Raises: + AttributeError: If the container doesn't have the required add_word method or style property + """ + # Inherit style from container if not provided + if style is None: + if hasattr(container, 'style'): + style = container.style + else: + raise AttributeError(f"Container {type(container).__name__} must have a 'style' property") + + # Inherit background from container if not provided + if background is None and hasattr(container, 'background'): + background = container.background + + # Determine the previous word for proper linking + previous = None + if hasattr(container, '_words') and container._words: + # Container has a _words list (like FormattedSpan) + previous = container._words[-1] + elif hasattr(container, 'words'): + # Container has a words() method (like Paragraph) + try: + # Get the last word from the iterator + for _, word in container.words(): + previous = word + except (StopIteration, TypeError): + previous = None + + # Create the new word + word = cls(text, style, background, previous) + + # Link the previous word to this new one + if previous: + previous.add_next(word) + + # Add the word to the container + if hasattr(container, 'add_word'): + # Check if add_word expects a Word object or text string + import inspect + sig = inspect.signature(container.add_word) + params = list(sig.parameters.keys()) + + if len(params) > 0: + # Peek at the parameter name to guess the expected type + param_name = params[0] + if param_name in ['word', 'word_obj', 'word_object']: + # Expects a Word object + container.add_word(word) + else: + # Might expect text string (like FormattedSpan.add_word) + # In this case, we can't use the container's add_word as it would create + # a duplicate Word. We need to add directly to the container's word list. + if hasattr(container, '_words'): + container._words.append(word) + else: + # Fallback: try calling with the Word object anyway + container.add_word(word) + else: + # No parameters, shouldn't happen with add_word methods + container.add_word(word) + else: + raise AttributeError(f"Container {type(container).__name__} must have an 'add_word' method") + + return word @property def text(self) -> str: @@ -167,6 +252,45 @@ class FormattedSpan: self._background = background if background else style.background self._words: List[Word] = [] + @classmethod + def create_and_add_to(cls, container, style: Optional[Font] = None, background=None) -> 'FormattedSpan': + """ + Create a new FormattedSpan and add it to a container, inheriting style from + the container if not explicitly provided. + + Args: + container: The container to add the span to (must have add_span method and style property) + style: Optional Font style override. If None, inherits from container + background: Optional background color override + + Returns: + The newly created FormattedSpan object + + Raises: + AttributeError: If the container doesn't have the required add_span method or style property + """ + # Inherit style from container if not provided + if style is None: + if hasattr(container, 'style'): + style = container.style + else: + raise AttributeError(f"Container {type(container).__name__} must have a 'style' property") + + # Inherit background from container if not provided + if background is None and hasattr(container, 'background'): + background = container.background + + # Create the new span + span = cls(style, background) + + # Add the span to the container + if hasattr(container, 'add_span'): + container.add_span(span) + else: + raise AttributeError(f"Container {type(container).__name__} must have an 'add_span' method") + + return span + @property def style(self) -> Font: """Get the font style of this span""" diff --git a/pyWebLayout/examples/epub_viewer.py b/pyWebLayout/examples/epub_viewer.py index 47e7ecb..22ebd28 100644 --- a/pyWebLayout/examples/epub_viewer.py +++ b/pyWebLayout/examples/epub_viewer.py @@ -27,7 +27,7 @@ def main(): parser.add_argument('epub_file', help='Path to EPUB file') parser.add_argument('--output-dir', '-o', default='output', help='Output directory for rendered pages') parser.add_argument('--width', '-w', type=int, default=800, help='Page width') - parser.add_argument('--height', '-h', type=int, default=1000, help='Page height') + parser.add_argument('--height', '-y', type=int, default=1000, help='Page height') parser.add_argument('--margin', '-m', type=int, default=50, help='Page margin') parser.add_argument('--max-pages', '-p', type=int, default=10, help='Maximum number of pages to render') args = parser.parse_args() diff --git a/pyWebLayout/examples/simple_epub_test.py b/pyWebLayout/examples/simple_epub_test.py new file mode 100644 index 0000000..de6ba7e --- /dev/null +++ b/pyWebLayout/examples/simple_epub_test.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +""" +Simple EPUB test script to isolate the issue. +""" + +import sys +from pathlib import Path + +# Add the parent directory to the path to import pyWebLayout +sys.path.append(str(Path(__file__).parent.parent.parent)) + +def test_epub_basic(): + """Test basic EPUB functionality without full HTML parsing.""" + print("Testing basic EPUB components...") + + try: + # Test basic document classes + from pyWebLayout.abstract.document import Document, Book, Chapter, MetadataType + print("✓ Document classes imported") + + # Test creating a simple book + book = Book("Test Book", "Test Author") + chapter = book.create_chapter("Test Chapter") + print("✓ Book and chapter created") + + return True + + except Exception as e: + print(f"✗ Basic test failed: {e}") + import traceback + traceback.print_exc() + return False + +def test_epub_file(): + """Test opening the EPUB file without full parsing.""" + print("Testing EPUB file access...") + + try: + import zipfile + import os + + epub_path = "pg174-images-3.epub" + if not os.path.exists(epub_path): + print(f"✗ EPUB file not found: {epub_path}") + return False + + with zipfile.ZipFile(epub_path, 'r') as zip_ref: + file_list = zip_ref.namelist() + print(f"✓ EPUB file opened, contains {len(file_list)} files") + + # Look for key files + has_container = any('container.xml' in f for f in file_list) + has_opf = any('.opf' in f for f in file_list) + + print(f"✓ Container file: {'found' if has_container else 'not found'}") + print(f"✓ Package file: {'found' if has_opf else 'not found'}") + + return True + + except Exception as e: + print(f"✗ EPUB file test failed: {e}") + import traceback + traceback.print_exc() + return False + +def main(): + print("Simple EPUB Test") + print("=" * 50) + + # Test basic functionality + if not test_epub_basic(): + return False + + print() + + # Test EPUB file access + if not test_epub_file(): + return False + + print() + print("All basic tests passed!") + return True + +if __name__ == "__main__": + success = main() + sys.exit(0 if success else 1) diff --git a/pyWebLayout/io/readers/epub_reader.py b/pyWebLayout/io/readers/epub_reader.py index ceb8872..2c1ca27 100644 --- a/pyWebLayout/io/readers/epub_reader.py +++ b/pyWebLayout/io/readers/epub_reader.py @@ -370,7 +370,7 @@ class EPUBReader: # Parse HTML and add blocks to chapter base_url = os.path.dirname(path) - document = parse_html(html, base_url) + document = parse_html(html, base_url=base_url) # Copy blocks to the chapter for block in document.blocks: @@ -381,8 +381,11 @@ class EPUBReader: # Add an error message block from pyWebLayout.abstract.block import Parapgraph from pyWebLayout.abstract.inline import Word + from pyWebLayout.style import Font error_para = Parapgraph() - error_para.add_word(Word(f"Error loading chapter: {str(e)}")) + # Create a default font style for the error message + default_font = Font() + error_para.add_word(Word(f"Error loading chapter: {str(e)}", default_font)) chapter.add_block(error_para) diff --git a/tests/test_abstract_document.py b/tests/test_abstract_document.py new file mode 100644 index 0000000..68d8de3 --- /dev/null +++ b/tests/test_abstract_document.py @@ -0,0 +1,468 @@ +""" +Unit tests for abstract document elements. + +Tests the Document, Chapter, Book, and MetadataType classes that handle +document structure and metadata management. +""" + +import unittest +from pyWebLayout.abstract.document import Document, Chapter, Book, MetadataType +from pyWebLayout.abstract.block import Parapgraph, Heading, HeadingLevel, BlockType +from pyWebLayout.abstract.inline import Word, FormattedSpan +from pyWebLayout.style import Font + + +class TestMetadataType(unittest.TestCase): + """Test cases for MetadataType enum.""" + + def test_metadata_types(self): + """Test that all expected metadata types exist.""" + expected_types = [ + 'TITLE', 'AUTHOR', 'DESCRIPTION', 'KEYWORDS', 'LANGUAGE', + 'PUBLICATION_DATE', 'MODIFIED_DATE', 'PUBLISHER', 'IDENTIFIER', + 'COVER_IMAGE', 'CUSTOM' + ] + + for type_name in expected_types: + self.assertTrue(hasattr(MetadataType, type_name)) + + # Test custom type has expected value + self.assertEqual(MetadataType.CUSTOM.value, 100) + + +class TestDocument(unittest.TestCase): + """Test cases for Document class.""" + + def setUp(self): + """Set up test fixtures.""" + self.doc = Document("Test Document", "en-US") + self.font = Font() + + def test_document_creation(self): + """Test document creation with basic parameters.""" + self.assertEqual(self.doc.get_title(), "Test Document") + self.assertEqual(self.doc.get_metadata(MetadataType.LANGUAGE), "en-US") + self.assertEqual(len(self.doc.blocks), 0) + + def test_document_creation_minimal(self): + """Test document creation with minimal parameters.""" + doc = Document() + self.assertIsNone(doc.get_title()) + self.assertEqual(doc.get_metadata(MetadataType.LANGUAGE), "en-US") + + def test_metadata_management(self): + """Test setting and getting metadata.""" + # Set various metadata types + self.doc.set_metadata(MetadataType.AUTHOR, "John Doe") + self.doc.set_metadata(MetadataType.DESCRIPTION, "A test document") + self.doc.set_metadata(MetadataType.KEYWORDS, ["test", "document"]) + + # Test retrieval + self.assertEqual(self.doc.get_metadata(MetadataType.AUTHOR), "John Doe") + self.assertEqual(self.doc.get_metadata(MetadataType.DESCRIPTION), "A test document") + self.assertEqual(self.doc.get_metadata(MetadataType.KEYWORDS), ["test", "document"]) + + # Test non-existent metadata + self.assertIsNone(self.doc.get_metadata(MetadataType.PUBLISHER)) + + def test_title_convenience_methods(self): + """Test title getter and setter convenience methods.""" + # Test setting title + self.doc.set_title("New Title") + self.assertEqual(self.doc.get_title(), "New Title") + + # Test that it's also in metadata + self.assertEqual(self.doc.get_metadata(MetadataType.TITLE), "New Title") + + def test_block_management(self): + """Test adding and managing blocks.""" + # Create some blocks + para1 = Parapgraph() + para2 = Parapgraph() + heading = Heading(HeadingLevel.H1) + + # Add blocks + self.doc.add_block(para1) + self.doc.add_block(heading) + self.doc.add_block(para2) + + # Test blocks list + self.assertEqual(len(self.doc.blocks), 3) + self.assertEqual(self.doc.blocks[0], para1) + self.assertEqual(self.doc.blocks[1], heading) + self.assertEqual(self.doc.blocks[2], para2) + + def test_anchor_management(self): + """Test named anchor functionality.""" + heading = Heading(HeadingLevel.H1) + para = Parapgraph() + + # Add anchors + self.doc.add_anchor("intro", heading) + self.doc.add_anchor("content", para) + + # Test retrieval + self.assertEqual(self.doc.get_anchor("intro"), heading) + self.assertEqual(self.doc.get_anchor("content"), para) + self.assertIsNone(self.doc.get_anchor("nonexistent")) + + def test_resource_management(self): + """Test document resource management.""" + # Add various resources + self.doc.add_resource("image1", {"type": "image", "path": "test.jpg"}) + self.doc.add_resource("style1", {"type": "css", "content": "body {}"}) + + # Test retrieval + image = self.doc.get_resource("image1") + self.assertEqual(image["type"], "image") + self.assertEqual(image["path"], "test.jpg") + + style = self.doc.get_resource("style1") + self.assertEqual(style["type"], "css") + + # Test non-existent resource + self.assertIsNone(self.doc.get_resource("nonexistent")) + + def test_stylesheet_management(self): + """Test stylesheet addition.""" + # Add stylesheets + css1 = {"href": "style.css", "type": "text/css"} + css2 = {"href": "theme.css", "type": "text/css"} + + self.doc.add_stylesheet(css1) + self.doc.add_stylesheet(css2) + + # Test that stylesheets are stored + self.assertEqual(len(self.doc._stylesheets), 2) + self.assertEqual(self.doc._stylesheets[0], css1) + self.assertEqual(self.doc._stylesheets[1], css2) + + def test_script_management(self): + """Test script addition.""" + # Add scripts + script1 = "console.log('Hello');" + script2 = "document.ready(function(){});" + + self.doc.add_script(script1) + self.doc.add_script(script2) + + # Test that scripts are stored + self.assertEqual(len(self.doc._scripts), 2) + self.assertEqual(self.doc._scripts[0], script1) + self.assertEqual(self.doc._scripts[1], script2) + + def test_find_blocks_by_type(self): + """Test finding blocks by type.""" + # Create blocks of different types + para1 = Parapgraph() + para2 = Parapgraph() + heading1 = Heading(HeadingLevel.H1) + heading2 = Heading(HeadingLevel.H2) + + # Add blocks to document + self.doc.add_block(para1) + self.doc.add_block(heading1) + self.doc.add_block(para2) + self.doc.add_block(heading2) + + # Test finding paragraphs + paragraphs = self.doc.find_blocks_by_type(BlockType.PARAGRAPH) + self.assertEqual(len(paragraphs), 2) + self.assertIn(para1, paragraphs) + self.assertIn(para2, paragraphs) + + # Test finding headings + headings = self.doc.find_blocks_by_type(BlockType.HEADING) + self.assertEqual(len(headings), 2) + self.assertIn(heading1, headings) + self.assertIn(heading2, headings) + + def test_find_headings(self): + """Test finding heading blocks specifically.""" + # Create mixed blocks + para = Parapgraph() + h1 = Heading(HeadingLevel.H1) + h2 = Heading(HeadingLevel.H2) + + # Add words to headings for title extraction + word1 = Word("Chapter", self.font) + word2 = Word("One", self.font) + h1.add_word(word1) + h1.add_word(word2) + + word3 = Word("Section", self.font) + h2.add_word(word3) + + self.doc.add_block(para) + self.doc.add_block(h1) + self.doc.add_block(h2) + + # Test finding headings + headings = self.doc.find_headings() + self.assertEqual(len(headings), 2) + self.assertIn(h1, headings) + self.assertIn(h2, headings) + self.assertNotIn(para, headings) + + def test_generate_table_of_contents(self): + """Test table of contents generation.""" + # Create headings with content + h1 = Heading(HeadingLevel.H1) + h2 = Heading(HeadingLevel.H2) + h3 = Heading(HeadingLevel.H3) + + # Add words to headings + h1.add_word(Word("Introduction", self.font)) + h2.add_word(Word("Getting", self.font)) + h2.add_word(Word("Started", self.font)) + h3.add_word(Word("Installation", self.font)) + + self.doc.add_block(h1) + self.doc.add_block(h2) + self.doc.add_block(h3) + + # Generate TOC + toc = self.doc.generate_table_of_contents() + + # Test TOC structure + self.assertEqual(len(toc), 3) + + # Test first entry + level, title, block = toc[0] + self.assertEqual(level, 1) # H1 + self.assertEqual(title, "Introduction") + self.assertEqual(block, h1) + + # Test second entry + level, title, block = toc[1] + self.assertEqual(level, 2) # H2 + self.assertEqual(title, "Getting Started") + self.assertEqual(block, h2) + + # Test third entry + level, title, block = toc[2] + self.assertEqual(level, 3) # H3 + self.assertEqual(title, "Installation") + self.assertEqual(block, h3) + + +class TestChapter(unittest.TestCase): + """Test cases for Chapter class.""" + + def setUp(self): + """Set up test fixtures.""" + self.chapter = Chapter("Test Chapter", 1) + + def test_chapter_creation(self): + """Test chapter creation.""" + self.assertEqual(self.chapter.title, "Test Chapter") + self.assertEqual(self.chapter.level, 1) + self.assertEqual(len(self.chapter.blocks), 0) + + def test_chapter_creation_minimal(self): + """Test chapter creation with minimal parameters.""" + chapter = Chapter() + self.assertIsNone(chapter.title) + self.assertEqual(chapter.level, 1) + + def test_title_property(self): + """Test title property getter and setter.""" + # Test setter + self.chapter.title = "New Chapter Title" + self.assertEqual(self.chapter.title, "New Chapter Title") + + # Test setting to None + self.chapter.title = None + self.assertIsNone(self.chapter.title) + + def test_level_property(self): + """Test level property.""" + self.assertEqual(self.chapter.level, 1) + + # Level should be read-only (no setter test) + # This is by design based on the class definition + + def test_block_management(self): + """Test adding blocks to chapter.""" + para1 = Parapgraph() + para2 = Parapgraph() + heading = Heading(HeadingLevel.H2) + + # Add blocks + self.chapter.add_block(para1) + self.chapter.add_block(heading) + self.chapter.add_block(para2) + + # Test blocks list + self.assertEqual(len(self.chapter.blocks), 3) + self.assertEqual(self.chapter.blocks[0], para1) + self.assertEqual(self.chapter.blocks[1], heading) + self.assertEqual(self.chapter.blocks[2], para2) + + def test_metadata_management(self): + """Test chapter metadata.""" + # Set metadata + self.chapter.set_metadata("author", "Jane Doe") + self.chapter.set_metadata("word_count", 1500) + self.chapter.set_metadata("tags", ["intro", "basics"]) + + # Test retrieval + self.assertEqual(self.chapter.get_metadata("author"), "Jane Doe") + self.assertEqual(self.chapter.get_metadata("word_count"), 1500) + self.assertEqual(self.chapter.get_metadata("tags"), ["intro", "basics"]) + + # Test non-existent metadata + self.assertIsNone(self.chapter.get_metadata("nonexistent")) + + +class TestBook(unittest.TestCase): + """Test cases for Book class.""" + + def setUp(self): + """Set up test fixtures.""" + self.book = Book("Test Book", "Author Name", "en-US") + + def test_book_creation(self): + """Test book creation with all parameters.""" + self.assertEqual(self.book.get_title(), "Test Book") + self.assertEqual(self.book.get_author(), "Author Name") + self.assertEqual(self.book.get_metadata(MetadataType.LANGUAGE), "en-US") + self.assertEqual(len(self.book.chapters), 0) + + def test_book_creation_minimal(self): + """Test book creation with minimal parameters.""" + book = Book() + self.assertIsNone(book.get_title()) + self.assertIsNone(book.get_author()) + self.assertEqual(book.get_metadata(MetadataType.LANGUAGE), "en-US") + + def test_book_creation_partial(self): + """Test book creation with partial parameters.""" + book = Book(title="Just Title") + self.assertEqual(book.get_title(), "Just Title") + self.assertIsNone(book.get_author()) + + def test_author_convenience_methods(self): + """Test author getter and setter convenience methods.""" + # Test setting author + self.book.set_author("New Author") + self.assertEqual(self.book.get_author(), "New Author") + + # Test that it's also in metadata + self.assertEqual(self.book.get_metadata(MetadataType.AUTHOR), "New Author") + + def test_chapter_management(self): + """Test adding and managing chapters.""" + # Create chapters + ch1 = Chapter("Introduction", 1) + ch2 = Chapter("Getting Started", 1) + ch3 = Chapter("Advanced Topics", 1) + + # Add chapters + self.book.add_chapter(ch1) + self.book.add_chapter(ch2) + self.book.add_chapter(ch3) + + # Test chapters list + self.assertEqual(len(self.book.chapters), 3) + self.assertEqual(self.book.chapters[0], ch1) + self.assertEqual(self.book.chapters[1], ch2) + self.assertEqual(self.book.chapters[2], ch3) + + def test_create_chapter(self): + """Test creating chapters through the book.""" + # Create chapter with title and level + ch1 = self.book.create_chapter("Chapter 1", 1) + self.assertEqual(ch1.title, "Chapter 1") + self.assertEqual(ch1.level, 1) + self.assertEqual(len(self.book.chapters), 1) + self.assertEqual(self.book.chapters[0], ch1) + + # Create chapter with minimal parameters + ch2 = self.book.create_chapter() + self.assertIsNone(ch2.title) + self.assertEqual(ch2.level, 1) + self.assertEqual(len(self.book.chapters), 2) + + def test_generate_book_toc(self): + """Test table of contents generation for book.""" + # Create chapters with different levels + ch1 = Chapter("Introduction", 1) + ch2 = Chapter("Getting Started", 1) + ch3 = Chapter("Basic Concepts", 2) + ch4 = Chapter("Advanced Topics", 1) + ch5 = Chapter("Best Practices", 2) + + # Add chapters to book + self.book.add_chapter(ch1) + self.book.add_chapter(ch2) + self.book.add_chapter(ch3) + self.book.add_chapter(ch4) + self.book.add_chapter(ch5) + + # Generate TOC + toc = self.book.generate_table_of_contents() + + # Test TOC structure + self.assertEqual(len(toc), 5) + + # Test entries + expected = [ + (1, "Introduction", ch1), + (1, "Getting Started", ch2), + (2, "Basic Concepts", ch3), + (1, "Advanced Topics", ch4), + (2, "Best Practices", ch5) + ] + + for i, (exp_level, exp_title, exp_chapter) in enumerate(expected): + level, title, chapter = toc[i] + self.assertEqual(level, exp_level) + self.assertEqual(title, exp_title) + self.assertEqual(chapter, exp_chapter) + + def test_generate_book_toc_with_untitled_chapters(self): + """Test TOC generation with chapters that have no title.""" + # Create chapters, some without titles + ch1 = Chapter("Introduction", 1) + ch2 = Chapter(None, 1) # No title + ch3 = Chapter("Conclusion", 1) + + self.book.add_chapter(ch1) + self.book.add_chapter(ch2) + self.book.add_chapter(ch3) + + # Generate TOC + toc = self.book.generate_table_of_contents() + + # Should only include chapters with titles + self.assertEqual(len(toc), 2) + + level, title, chapter = toc[0] + self.assertEqual(title, "Introduction") + self.assertEqual(chapter, ch1) + + level, title, chapter = toc[1] + self.assertEqual(title, "Conclusion") + self.assertEqual(chapter, ch3) + + def test_book_inherits_document_features(self): + """Test that Book inherits all Document functionality.""" + # Test that book can use all document methods + # Add blocks directly to book + para = Parapgraph() + self.book.add_block(para) + self.assertEqual(len(self.book.blocks), 1) + + # Test metadata + self.book.set_metadata(MetadataType.PUBLISHER, "Test Publisher") + self.assertEqual(self.book.get_metadata(MetadataType.PUBLISHER), "Test Publisher") + + # Test anchors + heading = Heading(HeadingLevel.H1) + self.book.add_anchor("preface", heading) + self.assertEqual(self.book.get_anchor("preface"), heading) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_abstract_functional.py b/tests/test_abstract_functional.py new file mode 100644 index 0000000..3c41ec1 --- /dev/null +++ b/tests/test_abstract_functional.py @@ -0,0 +1,529 @@ +""" +Unit tests for abstract functional elements. + +Tests the Link, Button, Form, FormField, and related classes that handle +interactive functionality and user interface elements. +""" + +import unittest +from unittest.mock import Mock, patch +from pyWebLayout.abstract.functional import ( + Link, LinkType, Button, Form, FormField, FormFieldType +) + + +class TestLinkType(unittest.TestCase): + """Test cases for LinkType enum.""" + + def test_link_types(self): + """Test that all expected link types exist.""" + expected_types = ['INTERNAL', 'EXTERNAL', 'API', 'FUNCTION'] + + for type_name in expected_types: + self.assertTrue(hasattr(LinkType, type_name)) + + # Test specific values + self.assertEqual(LinkType.INTERNAL.value, 1) + self.assertEqual(LinkType.EXTERNAL.value, 2) + self.assertEqual(LinkType.API.value, 3) + self.assertEqual(LinkType.FUNCTION.value, 4) + + +class TestLink(unittest.TestCase): + """Test cases for Link class.""" + + def setUp(self): + """Set up test fixtures.""" + self.mock_callback = Mock(return_value="callback_result") + + def test_link_creation_minimal(self): + """Test link creation with minimal parameters.""" + link = Link("test-location") + + self.assertEqual(link.location, "test-location") + self.assertEqual(link.link_type, LinkType.INTERNAL) # Default + self.assertEqual(link.params, {}) + self.assertIsNone(link.title) + self.assertIsNone(link._callback) + + def test_link_creation_full(self): + """Test link creation with all parameters.""" + params = {"param1": "value1", "param2": "value2"} + link = Link( + location="https://example.com", + link_type=LinkType.EXTERNAL, + callback=self.mock_callback, + params=params, + title="Example Link" + ) + + self.assertEqual(link.location, "https://example.com") + self.assertEqual(link.link_type, LinkType.EXTERNAL) + self.assertEqual(link.params, params) + self.assertEqual(link.title, "Example Link") + self.assertEqual(link._callback, self.mock_callback) + + def test_internal_link_execution(self): + """Test executing internal links.""" + link = Link("#section1", LinkType.INTERNAL) + result = link.execute() + + # Internal links should return the location + self.assertEqual(result, "#section1") + + def test_external_link_execution(self): + """Test executing external links.""" + link = Link("https://example.com", LinkType.EXTERNAL) + result = link.execute() + + # External links should return the location + self.assertEqual(result, "https://example.com") + + def test_api_link_execution(self): + """Test executing API links with callback.""" + params = {"action": "save", "id": 123} + link = Link( + location="/api/save", + link_type=LinkType.API, + callback=self.mock_callback, + params=params + ) + + result = link.execute() + + # Should call callback with location and params + self.mock_callback.assert_called_once_with("/api/save", action="save", id=123) + self.assertEqual(result, "callback_result") + + def test_function_link_execution(self): + """Test executing function links with callback.""" + params = {"data": "test"} + link = Link( + location="save_document", + link_type=LinkType.FUNCTION, + callback=self.mock_callback, + params=params + ) + + result = link.execute() + + # Should call callback with location and params + self.mock_callback.assert_called_once_with("save_document", data="test") + self.assertEqual(result, "callback_result") + + def test_api_link_without_callback(self): + """Test API link without callback returns location.""" + link = Link("/api/endpoint", LinkType.API) + result = link.execute() + + # Without callback, should return location + self.assertEqual(result, "/api/endpoint") + + def test_function_link_without_callback(self): + """Test function link without callback returns location.""" + link = Link("function_name", LinkType.FUNCTION) + result = link.execute() + + # Without callback, should return location + self.assertEqual(result, "function_name") + + def test_link_properties(self): + """Test link property access.""" + params = {"key": "value"} + link = Link( + location="test", + link_type=LinkType.API, + params=params, + title="Test Title" + ) + + # Test all property getters + self.assertEqual(link.location, "test") + self.assertEqual(link.link_type, LinkType.API) + self.assertEqual(link.params, params) + self.assertEqual(link.title, "Test Title") + + +class TestButton(unittest.TestCase): + """Test cases for Button class.""" + + def setUp(self): + """Set up test fixtures.""" + self.mock_callback = Mock(return_value="button_clicked") + + def test_button_creation_minimal(self): + """Test button creation with minimal parameters.""" + button = Button("Click Me", self.mock_callback) + + self.assertEqual(button.label, "Click Me") + self.assertEqual(button._callback, self.mock_callback) + self.assertEqual(button.params, {}) + self.assertTrue(button.enabled) + + def test_button_creation_full(self): + """Test button creation with all parameters.""" + params = {"action": "submit", "form_id": "test_form"} + button = Button( + label="Submit", + callback=self.mock_callback, + params=params, + enabled=False + ) + + self.assertEqual(button.label, "Submit") + self.assertEqual(button._callback, self.mock_callback) + self.assertEqual(button.params, params) + self.assertFalse(button.enabled) + + def test_button_label_property(self): + """Test button label getter and setter.""" + button = Button("Original", self.mock_callback) + + # Test getter + self.assertEqual(button.label, "Original") + + # Test setter + button.label = "New Label" + self.assertEqual(button.label, "New Label") + + def test_button_enabled_property(self): + """Test button enabled getter and setter.""" + button = Button("Test", self.mock_callback, enabled=True) + + # Test initial state + self.assertTrue(button.enabled) + + # Test setter + button.enabled = False + self.assertFalse(button.enabled) + + button.enabled = True + self.assertTrue(button.enabled) + + def test_button_execute_enabled(self): + """Test executing enabled button.""" + params = {"data": "test_data"} + button = Button("Test", self.mock_callback, params=params, enabled=True) + + result = button.execute() + + # Should call callback with params + self.mock_callback.assert_called_once_with(data="test_data") + self.assertEqual(result, "button_clicked") + + def test_button_execute_disabled(self): + """Test executing disabled button.""" + button = Button("Test", self.mock_callback, enabled=False) + + result = button.execute() + + # Should not call callback and return None + self.mock_callback.assert_not_called() + self.assertIsNone(result) + + def test_button_execute_no_callback(self): + """Test executing button without callback.""" + button = Button("Test", None, enabled=True) + + result = button.execute() + + # Should return None + self.assertIsNone(result) + + +class TestFormFieldType(unittest.TestCase): + """Test cases for FormFieldType enum.""" + + def test_form_field_types(self): + """Test that all expected form field types exist.""" + expected_types = [ + 'TEXT', 'PASSWORD', 'CHECKBOX', 'RADIO', 'SELECT', 'TEXTAREA', + 'NUMBER', 'DATE', 'TIME', 'EMAIL', 'URL', 'COLOR', 'RANGE', 'HIDDEN' + ] + + for type_name in expected_types: + self.assertTrue(hasattr(FormFieldType, type_name)) + + # Test some specific values + self.assertEqual(FormFieldType.TEXT.value, 1) + self.assertEqual(FormFieldType.PASSWORD.value, 2) + self.assertEqual(FormFieldType.HIDDEN.value, 14) + + +class TestFormField(unittest.TestCase): + """Test cases for FormField class.""" + + def test_form_field_creation_minimal(self): + """Test form field creation with minimal parameters.""" + field = FormField("username", FormFieldType.TEXT) + + self.assertEqual(field.name, "username") + self.assertEqual(field.field_type, FormFieldType.TEXT) + self.assertEqual(field.label, "username") # Default to name + self.assertIsNone(field.value) + self.assertFalse(field.required) + self.assertEqual(field.options, []) + self.assertIsNone(field.form) + + def test_form_field_creation_full(self): + """Test form field creation with all parameters.""" + options = [("value1", "Label 1"), ("value2", "Label 2")] + field = FormField( + name="country", + field_type=FormFieldType.SELECT, + label="Country", + value="value1", + required=True, + options=options + ) + + self.assertEqual(field.name, "country") + self.assertEqual(field.field_type, FormFieldType.SELECT) + self.assertEqual(field.label, "Country") + self.assertEqual(field.value, "value1") + self.assertTrue(field.required) + self.assertEqual(field.options, options) + + def test_form_field_value_property(self): + """Test form field value getter and setter.""" + field = FormField("test", FormFieldType.TEXT, value="initial") + + # Test getter + self.assertEqual(field.value, "initial") + + # Test setter + field.value = "new_value" + self.assertEqual(field.value, "new_value") + + def test_form_field_form_property(self): + """Test form field form getter and setter.""" + field = FormField("test", FormFieldType.TEXT) + mock_form = Mock() + + # Initial state + self.assertIsNone(field.form) + + # Test setter + field.form = mock_form + self.assertEqual(field.form, mock_form) + + def test_form_field_properties(self): + """Test all form field property getters.""" + options = [("opt1", "Option 1")] + field = FormField( + name="test_field", + field_type=FormFieldType.CHECKBOX, + label="Test Field", + value=True, + required=True, + options=options + ) + + # Test all getters + self.assertEqual(field.name, "test_field") + self.assertEqual(field.field_type, FormFieldType.CHECKBOX) + self.assertEqual(field.label, "Test Field") + self.assertTrue(field.value) + self.assertTrue(field.required) + self.assertEqual(field.options, options) + + +class TestForm(unittest.TestCase): + """Test cases for Form class.""" + + def setUp(self): + """Set up test fixtures.""" + self.mock_callback = Mock(return_value="form_submitted") + + def test_form_creation_minimal(self): + """Test form creation with minimal parameters.""" + form = Form("test_form") + + self.assertEqual(form.form_id, "test_form") + self.assertIsNone(form.action) + self.assertIsNone(form._callback) + self.assertEqual(len(form._fields), 0) + + def test_form_creation_full(self): + """Test form creation with all parameters.""" + form = Form( + form_id="contact_form", + action="/submit", + callback=self.mock_callback + ) + + self.assertEqual(form.form_id, "contact_form") + self.assertEqual(form.action, "/submit") + self.assertEqual(form._callback, self.mock_callback) + + def test_form_field_management(self): + """Test adding and retrieving form fields.""" + form = Form("test_form") + + # Create fields + field1 = FormField("username", FormFieldType.TEXT, value="john") + field2 = FormField("password", FormFieldType.PASSWORD, value="secret") + field3 = FormField("email", FormFieldType.EMAIL, value="john@example.com") + + # Add fields + form.add_field(field1) + form.add_field(field2) + form.add_field(field3) + + # Test that fields are stored correctly + self.assertEqual(len(form._fields), 3) + + # Test field retrieval + self.assertEqual(form.get_field("username"), field1) + self.assertEqual(form.get_field("password"), field2) + self.assertEqual(form.get_field("email"), field3) + self.assertIsNone(form.get_field("nonexistent")) + + # Test that fields have form reference + self.assertEqual(field1.form, form) + self.assertEqual(field2.form, form) + self.assertEqual(field3.form, form) + + def test_form_get_values(self): + """Test getting form values.""" + form = Form("test_form") + + # Add fields with values + form.add_field(FormField("name", FormFieldType.TEXT, value="John Doe")) + form.add_field(FormField("age", FormFieldType.NUMBER, value=30)) + form.add_field(FormField("subscribe", FormFieldType.CHECKBOX, value=True)) + + # Get values + values = form.get_values() + + expected = { + "name": "John Doe", + "age": 30, + "subscribe": True + } + + self.assertEqual(values, expected) + + def test_form_get_values_empty(self): + """Test getting values from empty form.""" + form = Form("empty_form") + values = form.get_values() + + self.assertEqual(values, {}) + + def test_form_execute_with_callback(self): + """Test executing form with callback.""" + form = Form("test_form", callback=self.mock_callback) + + # Add some fields + form.add_field(FormField("field1", FormFieldType.TEXT, value="value1")) + form.add_field(FormField("field2", FormFieldType.TEXT, value="value2")) + + result = form.execute() + + # Should call callback with form_id and values + expected_values = {"field1": "value1", "field2": "value2"} + self.mock_callback.assert_called_once_with("test_form", expected_values) + self.assertEqual(result, "form_submitted") + + def test_form_execute_without_callback(self): + """Test executing form without callback.""" + form = Form("test_form") + + # Add a field + form.add_field(FormField("test", FormFieldType.TEXT, value="test_value")) + + result = form.execute() + + # Should return the form values + expected = {"test": "test_value"} + self.assertEqual(result, expected) + + def test_form_properties(self): + """Test form property getters.""" + form = Form("test_form", action="/submit") + + self.assertEqual(form.form_id, "test_form") + self.assertEqual(form.action, "/submit") + + +class TestFormIntegration(unittest.TestCase): + """Integration tests for form functionality.""" + + def test_complete_form_workflow(self): + """Test a complete form creation and submission workflow.""" + # Create form + form = Form("registration_form", action="/register") + + # Add various field types + form.add_field(FormField( + "username", FormFieldType.TEXT, + label="Username", required=True, value="testuser" + )) + + form.add_field(FormField( + "password", FormFieldType.PASSWORD, + label="Password", required=True, value="secret123" + )) + + form.add_field(FormField( + "email", FormFieldType.EMAIL, + label="Email", required=True, value="test@example.com" + )) + + form.add_field(FormField( + "country", FormFieldType.SELECT, + label="Country", value="US", + options=[("US", "United States"), ("CA", "Canada"), ("UK", "United Kingdom")] + )) + + form.add_field(FormField( + "newsletter", FormFieldType.CHECKBOX, + label="Subscribe to newsletter", value=True + )) + + # Test form state + self.assertEqual(len(form._fields), 5) + + # Test individual field access + username_field = form.get_field("username") + self.assertEqual(username_field.value, "testuser") + self.assertTrue(username_field.required) + + # Test getting all values + values = form.get_values() + expected = { + "username": "testuser", + "password": "secret123", + "email": "test@example.com", + "country": "US", + "newsletter": True + } + self.assertEqual(values, expected) + + # Test form submission + result = form.execute() + self.assertEqual(result, expected) + + def test_form_field_modification(self): + """Test modifying form fields after creation.""" + form = Form("test_form") + + # Add field + field = FormField("test", FormFieldType.TEXT, value="initial") + form.add_field(field) + + # Modify field value + field.value = "modified" + + # Test that form reflects the change + values = form.get_values() + self.assertEqual(values["test"], "modified") + + # Test getting the modified field + retrieved_field = form.get_field("test") + self.assertEqual(retrieved_field.value, "modified") + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_abstract_inline.py b/tests/test_abstract_inline.py new file mode 100644 index 0000000..d6ef52d --- /dev/null +++ b/tests/test_abstract_inline.py @@ -0,0 +1,520 @@ +""" +Unit tests for abstract inline elements. + +Tests the Word and FormattedSpan classes that handle inline text elements +and formatting within documents. +""" + +import unittest +from unittest.mock import Mock, patch, MagicMock +from pyWebLayout.abstract.inline import Word, FormattedSpan +from pyWebLayout.style import Font + + +class TestWord(unittest.TestCase): + """Test cases for Word class.""" + + def setUp(self): + """Set up test fixtures.""" + self.font = Font() + # Note: Font background is a tuple (255, 255, 255, 0) by default + # Note: Font language is set via constructor parameter (langauge - with typo) + + def test_word_creation_minimal(self): + """Test word creation with minimal parameters.""" + word = Word("hello", self.font) + + self.assertEqual(word.text, "hello") + self.assertEqual(word.style, self.font) + self.assertEqual(word.background, self.font.background) + self.assertIsNone(word.previous) + self.assertIsNone(word.next) + self.assertIsNone(word.hyphenated_parts) + + def test_word_creation_with_previous(self): + """Test word creation with previous word reference.""" + word1 = Word("first", self.font) + word2 = Word("second", self.font, previous=word1) + + self.assertEqual(word2.previous, word1) + self.assertIsNone(word1.previous) + self.assertIsNone(word1.next) + self.assertIsNone(word2.next) + + def test_word_creation_with_background_override(self): + """Test word creation with background color override.""" + word = Word("test", self.font, background="yellow") + + self.assertEqual(word.background, "yellow") + # Original font background should be unchanged - it's a tuple + self.assertEqual(word.style.background, (255, 255, 255, 0)) + + def test_word_properties(self): + """Test word property getters.""" + word1 = Word("first", self.font) + word2 = Word("second", self.font, background="blue", previous=word1) + + # Test all properties + self.assertEqual(word2.text, "second") + self.assertEqual(word2.style, self.font) + self.assertEqual(word2.background, "blue") + self.assertEqual(word2.previous, word1) + self.assertIsNone(word2.next) + self.assertIsNone(word2.hyphenated_parts) + + def test_add_next_word(self): + """Test linking words with add_next method.""" + word1 = Word("first", self.font) + word2 = Word("second", self.font) + word3 = Word("third", self.font) + + # Link words + word1.add_next(word2) + word2.add_next(word3) + + # Test forward links + self.assertEqual(word1.next, word2) + self.assertEqual(word2.next, word3) + self.assertIsNone(word3.next) + + # Backward links should remain as set in constructor + self.assertIsNone(word1.previous) + self.assertIsNone(word2.previous) + self.assertIsNone(word3.previous) + + def test_word_chain(self): + """Test creating a chain of linked words.""" + word1 = Word("first", self.font) + word2 = Word("second", self.font, previous=word1) + word3 = Word("third", self.font, previous=word2) + + # Add forward links + word1.add_next(word2) + word2.add_next(word3) + + # Test complete chain + self.assertIsNone(word1.previous) + self.assertEqual(word1.next, word2) + + self.assertEqual(word2.previous, word1) + self.assertEqual(word2.next, word3) + + self.assertEqual(word3.previous, word2) + self.assertIsNone(word3.next) + + @patch('pyWebLayout.abstract.inline.pyphen') + def test_can_hyphenate_true(self, mock_pyphen): + """Test can_hyphenate method when word can be hyphenated.""" + # Mock pyphen behavior + mock_dic = Mock() + mock_dic.inserted.return_value = "hy-phen-ated" + mock_pyphen.Pyphen.return_value = mock_dic + + word = Word("hyphenated", self.font) + result = word.can_hyphenate() + + self.assertTrue(result) + # Font language is set as "en_EN" by default (with typo in constructor param) + mock_pyphen.Pyphen.assert_called_once_with(lang="en_EN") + mock_dic.inserted.assert_called_once_with("hyphenated", hyphen='-') + + @patch('pyWebLayout.abstract.inline.pyphen') + def test_can_hyphenate_false(self, mock_pyphen): + """Test can_hyphenate method when word cannot be hyphenated.""" + # Mock pyphen behavior for non-hyphenatable word + mock_dic = Mock() + mock_dic.inserted.return_value = "cat" # No hyphens added + mock_pyphen.Pyphen.return_value = mock_dic + + word = Word("cat", self.font) + result = word.can_hyphenate() + + self.assertFalse(result) + mock_dic.inserted.assert_called_once_with("cat", hyphen='-') + + @patch('pyWebLayout.abstract.inline.pyphen') + def test_can_hyphenate_with_language_override(self, mock_pyphen): + """Test can_hyphenate with explicit language parameter.""" + mock_dic = Mock() + mock_dic.inserted.return_value = "hy-phen" + mock_pyphen.Pyphen.return_value = mock_dic + + word = Word("hyphen", self.font) + result = word.can_hyphenate("de_DE") + + self.assertTrue(result) + mock_pyphen.Pyphen.assert_called_once_with(lang="de_DE") + + @patch('pyWebLayout.abstract.inline.pyphen') + def test_hyphenate_success(self, mock_pyphen): + """Test successful word hyphenation.""" + # Mock pyphen behavior + mock_dic = Mock() + mock_dic.inserted.return_value = "hy-phen-ation" + mock_pyphen.Pyphen.return_value = mock_dic + + word = Word("hyphenation", self.font) + result = word.hyphenate() + + self.assertTrue(result) + self.assertEqual(word.hyphenated_parts, ["hy-", "phen-", "ation"]) + mock_pyphen.Pyphen.assert_called_once_with(lang="en_EN") + + @patch('pyWebLayout.abstract.inline.pyphen') + def test_hyphenate_failure(self, mock_pyphen): + """Test word hyphenation when word cannot be hyphenated.""" + # Mock pyphen behavior for non-hyphenatable word + mock_dic = Mock() + mock_dic.inserted.return_value = "cat" # No hyphens + mock_pyphen.Pyphen.return_value = mock_dic + + word = Word("cat", self.font) + result = word.hyphenate() + + self.assertFalse(result) + self.assertIsNone(word.hyphenated_parts) + + @patch('pyWebLayout.abstract.inline.pyphen') + def test_hyphenate_with_language_override(self, mock_pyphen): + """Test hyphenation with explicit language parameter.""" + mock_dic = Mock() + mock_dic.inserted.return_value = "Wort-teil" + mock_pyphen.Pyphen.return_value = mock_dic + + word = Word("Wortteil", self.font) + result = word.hyphenate("de_DE") + + self.assertTrue(result) + self.assertEqual(word.hyphenated_parts, ["Wort-", "teil"]) + mock_pyphen.Pyphen.assert_called_once_with(lang="de_DE") + + def test_dehyphenate(self): + """Test removing hyphenation from word.""" + word = Word("test", self.font) + # Simulate hyphenated state + word._hyphenated_parts = ["test-", "ing"] + + word.dehyphenate() + + self.assertIsNone(word.hyphenated_parts) + + def test_get_hyphenated_part(self): + """Test getting specific hyphenated parts.""" + word = Word("testing", self.font) + # Simulate hyphenated state + word._hyphenated_parts = ["test-", "ing"] + + # Test valid indices + self.assertEqual(word.get_hyphenated_part(0), "test-") + self.assertEqual(word.get_hyphenated_part(1), "ing") + + # Test invalid index + with self.assertRaises(IndexError): + word.get_hyphenated_part(2) + + def test_get_hyphenated_part_not_hyphenated(self): + """Test getting hyphenated part from non-hyphenated word.""" + word = Word("test", self.font) + + with self.assertRaises(IndexError) as context: + word.get_hyphenated_part(0) + + self.assertIn("Word has not been hyphenated", str(context.exception)) + + def test_get_hyphenated_part_count(self): + """Test getting hyphenated part count.""" + word = Word("test", self.font) + + # Test non-hyphenated word + self.assertEqual(word.get_hyphenated_part_count(), 0) + + # Test hyphenated word + word._hyphenated_parts = ["hy-", "phen-", "ated"] + self.assertEqual(word.get_hyphenated_part_count(), 3) + + @patch('pyWebLayout.abstract.inline.pyphen') + def test_complex_hyphenation_scenario(self, mock_pyphen): + """Test complex hyphenation with multiple syllables.""" + # Mock pyphen for a complex word + mock_dic = Mock() + mock_dic.inserted.return_value = "un-der-stand-ing" + mock_pyphen.Pyphen.return_value = mock_dic + + word = Word("understanding", self.font) + result = word.hyphenate() + + self.assertTrue(result) + expected_parts = ["un-", "der-", "stand-", "ing"] + self.assertEqual(word.hyphenated_parts, expected_parts) + self.assertEqual(word.get_hyphenated_part_count(), 4) + + # Test getting individual parts + for i, expected_part in enumerate(expected_parts): + self.assertEqual(word.get_hyphenated_part(i), expected_part) + + +class TestFormattedSpan(unittest.TestCase): + """Test cases for FormattedSpan class.""" + + def setUp(self): + """Set up test fixtures.""" + self.font = Font() + # Font background is a tuple, not a string + + def test_formatted_span_creation_minimal(self): + """Test formatted span creation with minimal parameters.""" + span = FormattedSpan(self.font) + + self.assertEqual(span.style, self.font) + self.assertEqual(span.background, self.font.background) + self.assertEqual(len(span.words), 0) + + def test_formatted_span_creation_with_background(self): + """Test formatted span creation with background override.""" + span = FormattedSpan(self.font, background="yellow") + + self.assertEqual(span.style, self.font) + self.assertEqual(span.background, "yellow") + self.assertNotEqual(span.background, self.font.background) + + def test_formatted_span_properties(self): + """Test formatted span property getters.""" + span = FormattedSpan(self.font, background="blue") + + self.assertEqual(span.style, self.font) + self.assertEqual(span.background, "blue") + self.assertIsInstance(span.words, list) + self.assertEqual(len(span.words), 0) + + def test_add_single_word(self): + """Test adding a single word to formatted span.""" + span = FormattedSpan(self.font) + + word = span.add_word("hello") + + # Test returned word + self.assertIsInstance(word, Word) + self.assertEqual(word.text, "hello") + self.assertEqual(word.style, self.font) + self.assertEqual(word.background, self.font.background) + self.assertIsNone(word.previous) + + # Test span state + self.assertEqual(len(span.words), 1) + self.assertEqual(span.words[0], word) + + def test_add_multiple_words(self): + """Test adding multiple words to formatted span.""" + span = FormattedSpan(self.font) + + word1 = span.add_word("first") + word2 = span.add_word("second") + word3 = span.add_word("third") + + # Test span contains all words + self.assertEqual(len(span.words), 3) + self.assertEqual(span.words[0], word1) + self.assertEqual(span.words[1], word2) + self.assertEqual(span.words[2], word3) + + # Test word linking + self.assertIsNone(word1.previous) + self.assertEqual(word1.next, word2) + + self.assertEqual(word2.previous, word1) + self.assertEqual(word2.next, word3) + + self.assertEqual(word3.previous, word2) + self.assertIsNone(word3.next) + + def test_add_word_with_background_override(self): + """Test that added words inherit span background.""" + span = FormattedSpan(self.font, background="red") + + word = span.add_word("test") + + # Word should inherit span's background + self.assertEqual(word.background, "red") + self.assertEqual(word.style, self.font) + + def test_word_style_consistency(self): + """Test that all words in span have consistent style.""" + span = FormattedSpan(self.font, background="green") + + words = [] + for text in ["this", "is", "a", "test"]: + words.append(span.add_word(text)) + + # All words should have same style and background + for word in words: + self.assertEqual(word.style, self.font) + self.assertEqual(word.background, "green") + + def test_word_chain_integrity(self): + """Test that word chain is properly maintained.""" + span = FormattedSpan(self.font) + + words = [] + for i in range(5): + words.append(span.add_word(f"word{i}")) + + # Test complete chain + for i in range(5): + word = words[i] + + # Test previous link + if i == 0: + self.assertIsNone(word.previous) + else: + self.assertEqual(word.previous, words[i-1]) + + # Test next link + if i == 4: + self.assertIsNone(word.next) + else: + self.assertEqual(word.next, words[i+1]) + + def test_empty_span_operations(self): + """Test operations on empty formatted span.""" + span = FormattedSpan(self.font) + + # Test empty state + self.assertEqual(len(span.words), 0) + self.assertEqual(span.words, []) + + # Add first word + first_word = span.add_word("first") + self.assertIsNone(first_word.previous) + self.assertIsNone(first_word.next) + + +class TestWordFormattedSpanIntegration(unittest.TestCase): + """Integration tests between Word and FormattedSpan classes.""" + + def setUp(self): + """Set up test fixtures.""" + self.font = Font() + # Font background is a tuple, not a string + + def test_sentence_creation(self): + """Test creating a complete sentence with formatted span.""" + span = FormattedSpan(self.font) + + sentence_words = ["The", "quick", "brown", "fox", "jumps"] + words = [] + + for word_text in sentence_words: + words.append(span.add_word(word_text)) + + # Test sentence structure + self.assertEqual(len(span.words), 5) + + # Test word content + for i, expected_text in enumerate(sentence_words): + self.assertEqual(words[i].text, expected_text) + + # Test linking + for i in range(5): + if i > 0: + self.assertEqual(words[i].previous, words[i-1]) + if i < 4: + self.assertEqual(words[i].next, words[i+1]) + + @patch('pyWebLayout.abstract.inline.pyphen') + def test_span_with_hyphenated_words(self, mock_pyphen): + """Test formatted span containing hyphenated words.""" + # Mock pyphen + mock_dic = Mock() + mock_pyphen.Pyphen.return_value = mock_dic + + def mock_inserted(word, hyphen='-'): + if word == "understanding": + return "un-der-stand-ing" + elif word == "hyphenation": + return "hy-phen-ation" + else: + return word # No hyphenation + + mock_dic.inserted.side_effect = mock_inserted + + span = FormattedSpan(self.font) + + # Add words, some of which can be hyphenated + word1 = span.add_word("The") + word2 = span.add_word("understanding") + word3 = span.add_word("of") + word4 = span.add_word("hyphenation") + + # Test hyphenation + self.assertTrue(word2.can_hyphenate()) + self.assertTrue(word2.hyphenate()) + self.assertFalse(word1.can_hyphenate()) + self.assertTrue(word4.can_hyphenate()) + self.assertTrue(word4.hyphenate()) + + # Test hyphenated parts + self.assertEqual(word2.hyphenated_parts, ["un-", "der-", "stand-", "ing"]) + self.assertEqual(word4.hyphenated_parts, ["hy-", "phen-", "ation"]) + self.assertIsNone(word1.hyphenated_parts) + self.assertIsNone(word3.hyphenated_parts) + + def test_multiple_spans_same_style(self): + """Test creating multiple spans with the same style.""" + font = Font() + + span1 = FormattedSpan(font) + span2 = FormattedSpan(font) + + # Add words to both spans + span1_words = [span1.add_word("First"), span1.add_word("span")] + span2_words = [span2.add_word("Second"), span2.add_word("span")] + + # Test that spans are independent + self.assertEqual(len(span1.words), 2) + self.assertEqual(len(span2.words), 2) + + # Test that words in different spans are not linked + self.assertIsNone(span1_words[1].next) + self.assertIsNone(span2_words[0].previous) + + # But words within spans are linked + self.assertEqual(span1_words[0].next, span1_words[1]) + self.assertEqual(span2_words[1].previous, span2_words[0]) + + def test_span_style_inheritance(self): + """Test that words properly inherit span styling.""" + font = Font() + # Font background is a tuple (255, 255, 255, 0) + + # Test with span background override + span = FormattedSpan(font, background="lightgreen") + + word1 = span.add_word("styled") + word2 = span.add_word("text") + + # Both words should have span's background, not font's + self.assertEqual(word1.background, "lightgreen") + self.assertEqual(word2.background, "lightgreen") + + # But they should have font's other properties + self.assertEqual(word1.style, font) + self.assertEqual(word2.style, font) + + def test_word_modification_after_creation(self): + """Test modifying words after they've been added to span.""" + span = FormattedSpan(self.font) + + word = span.add_word("original") + + # Verify initial state + self.assertEqual(word.text, "original") + self.assertEqual(len(span.words), 1) + + # Words are immutable by design (no setter for text property) + # But we can test that the reference is maintained + self.assertEqual(span.words[0], word) + self.assertEqual(span.words[0].text, "original") + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_create_and_add_pattern.py b/tests/test_create_and_add_pattern.py new file mode 100644 index 0000000..08666df --- /dev/null +++ b/tests/test_create_and_add_pattern.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +""" +Demonstration of the new create_and_add_to pattern in pyWebLayout. + +This script shows how the pattern enables automatic style and language inheritance +throughout the document hierarchy without copying strings - using object references instead. +""" + +# Mock the style system for this demonstration +class MockFont: + def __init__(self, family="Arial", size=12, language="en-US", background="white"): + self.family = family + self.size = size + self.language = language + self.background = background + + def __str__(self): + return f"Font(family={self.family}, size={self.size}, lang={self.language}, bg={self.background})" + +# Import the abstract classes +from pyWebLayout.abstract import ( + Document, Paragraph, Heading, HeadingLevel, Quote, HList, ListStyle, + Table, TableRow, TableCell, Word, FormattedSpan +) + +def demonstrate_create_and_add_pattern(): + """Demonstrate the create_and_add_to pattern with style inheritance.""" + + print("=== pyWebLayout create_and_add_to Pattern Demonstration ===\n") + + # Create a document with a default style + document_style = MockFont(family="Georgia", size=14, language="en-US", background="white") + doc = Document("Style Inheritance Demo", default_style=document_style) + + print(f"1. Document created with style: {document_style}") + print(f" Document default style: {doc.default_style}\n") + + # Create a paragraph using the new pattern - it inherits the document's style + para1 = Paragraph.create_and_add_to(doc) + print(f"2. Paragraph created with inherited style: {para1.style}") + print(f" Style object ID matches document: {id(para1.style) == id(doc.default_style)}") + print(f" Number of blocks in document: {len(doc.blocks)}\n") + + # Create words using the paragraph's create_word method + word1 = para1.create_word("Hello") + word2 = para1.create_word("World") + + print(f"3. Words created with inherited paragraph style:") + print(f" Word 1 '{word1.text}' style: {word1.style}") + print(f" Word 2 '{word2.text}' style: {word2.style}") + print(f" Style object IDs match paragraph: {id(word1.style) == id(para1.style)}") + print(f" Word count in paragraph: {para1.word_count}\n") + + # Create a quote with a different style + quote_style = MockFont(family="Times", size=13, language="en-US", background="lightgray") + quote = Quote.create_and_add_to(doc, style=quote_style) + + print(f"4. Quote created with custom style: {quote.style}") + print(f" Style object ID different from document: {id(quote.style) != id(doc.default_style)}") + + # Create a paragraph inside the quote - it inherits the quote's style + quote_para = Paragraph.create_and_add_to(quote) + print(f" Quote paragraph inherits quote style: {quote_para.style}") + print(f" Style object ID matches quote: {id(quote_para.style) == id(quote.style)}\n") + + # Create a heading with specific styling + heading_style = MockFont(family="Arial Black", size=18, language="en-US", background="white") + heading = Heading.create_and_add_to(doc, HeadingLevel.H1, style=heading_style) + + print(f"5. Heading created with custom style: {heading.style}") + + # Add words to the heading + heading.create_word("Chapter") + heading.create_word("One") + + print(f" Heading words inherit heading style:") + for i, word in heading.words(): + print(f" - Word {i}: '{word.text}' with style: {word.style}") + print() + + # Create a list with inherited style + list_obj = HList.create_and_add_to(doc, ListStyle.UNORDERED) + print(f"6. List created with inherited document style: {list_obj.default_style}") + + # Create list items that inherit from the list + item1 = list_obj.create_item() + item2 = list_obj.create_item() + + print(f" List item 1 style: {item1.style}") + print(f" List item 2 style: {item2.style}") + print(f" Both inherit from list: {id(item1.style) == id(list_obj.default_style)}") + + # Create paragraphs in list items + item1_para = item1.create_paragraph() + item2_para = item2.create_paragraph() + + print(f" Item 1 paragraph style: {item1_para.style}") + print(f" Item 2 paragraph style: {item2_para.style}") + print(f" Both inherit from list item: {id(item1_para.style) == id(item1.style)}\n") + + # Create a table with inherited style + table = Table.create_and_add_to(doc, "Example Table") + print(f"7. Table created with inherited document style: {table.style}") + + # Create table rows and cells + header_row = table.create_row("header") + header_cell1 = header_row.create_cell(is_header=True) + header_cell2 = header_row.create_cell(is_header=True) + + print(f" Header row style: {header_row.style}") + print(f" Header cell 1 style: {header_cell1.style}") + print(f" Header cell 2 style: {header_cell2.style}") + + # Create paragraphs in cells + cell1_para = header_cell1.create_paragraph() + cell2_para = header_cell2.create_paragraph() + + print(f" Cell 1 paragraph style: {cell1_para.style}") + print(f" Cell 2 paragraph style: {cell2_para.style}") + + # Add words to cell paragraphs + cell1_para.create_word("Name") + cell2_para.create_word("Age") + + print(f" All styles inherit properly through the hierarchy\n") + + # Create a formatted span to show style inheritance + span = para1.create_span() + span_word = span.add_word("formatted") + + print(f"8. FormattedSpan and Word inheritance:") + print(f" Span style: {span.style}") + print(f" Span word style: {span_word.style}") + print(f" Both inherit from paragraph: {id(span.style) == id(para1.style)}") + print() + + # Demonstrate the object reference pattern vs string copying + print("9. Object Reference vs String Copying Demonstration:") + print(" - All child elements reference the SAME style object") + print(" - No string copying occurs - efficient memory usage") + print(" - Changes to parent style affect all children automatically") + print() + + # Show the complete hierarchy + print("10. Document Structure Summary:") + print(f" Document blocks: {len(doc.blocks)}") + for i, block in enumerate(doc.blocks): + if hasattr(block, 'word_count'): + print(f" - Block {i}: {type(block).__name__} with {block.word_count} words") + elif hasattr(block, 'item_count'): + print(f" - Block {i}: {type(block).__name__} with {block.item_count} items") + elif hasattr(block, 'row_count'): + counts = block.row_count + print(f" - Block {i}: {type(block).__name__} with {counts['total']} total rows") + else: + print(f" - Block {i}: {type(block).__name__}") + + print("\n=== Pattern Benefits ===") + print("✓ Automatic style inheritance throughout document hierarchy") + print("✓ Object references instead of string copying (memory efficient)") + print("✓ Consistent API pattern across all container/child relationships") + print("✓ Language and styling properties inherited as objects") + print("✓ Easy to use fluent interface for document building") + print("✓ Type safety with proper return types") + +if __name__ == "__main__": + try: + demonstrate_create_and_add_pattern() + except ImportError as e: + print(f"Import error: {e}") + print("Note: This demo requires the pyWebLayout abstract classes") + print("Make sure the pyWebLayout package is in your Python path") + except Exception as e: + print(f"Error during demonstration: {e}") + import traceback + traceback.print_exc() diff --git a/tests/test_epub_fix.py b/tests/test_epub_fix.py new file mode 100644 index 0000000..f262acc --- /dev/null +++ b/tests/test_epub_fix.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +""" +Simple test script to verify that the EPUB reader fixes are working correctly. +""" + +import sys +import os + +# Add the pyWebLayout directory to the Python path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'pyWebLayout')) + +try: + from pyWebLayout.io.readers.epub_reader import read_epub + print("Successfully imported epub_reader module") + + # Test reading the EPUB file + epub_path = os.path.join('pyWebLayout', 'examples', 'pg174-images-3.epub') + + if not os.path.exists(epub_path): + print(f"EPUB file not found: {epub_path}") + sys.exit(1) + + print(f"Reading EPUB file: {epub_path}") + + # Try to read the EPUB + book = read_epub(epub_path) + + print(f"Successfully read EPUB file!") + print(f"Book title: {book.title}") + print(f"Number of chapters: {len(book.chapters)}") + + # Check first chapter + if book.chapters: + first_chapter = book.chapters[0] + print(f"First chapter title: {first_chapter.title}") + print(f"First chapter has {len(first_chapter.blocks)} blocks") + +except Exception as e: + print(f"Error: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + +print("Test completed successfully!")