This commit is contained in:
parent
9baafe85bf
commit
8d892bfe28
233
ARCHITECTURE.md
Normal file
233
ARCHITECTURE.md
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
# pyWebLayout Architecture: Abstract vs Concrete
|
||||||
|
|
||||||
|
This document explains the fundamental architectural separation between **Abstract** and **Concrete** layers in the pyWebLayout library.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The pyWebLayout library follows a clear separation between two distinct layers:
|
||||||
|
|
||||||
|
- **Abstract Layer**: Represents the logical structure and content of documents (HTML/EPUB text)
|
||||||
|
- **Concrete Layer**: Handles the spatial rendering and visual representation of content
|
||||||
|
|
||||||
|
This separation provides flexibility, testability, and clean separation of concerns.
|
||||||
|
|
||||||
|
## Abstract Layer (`pyWebLayout/abstract/`)
|
||||||
|
|
||||||
|
The Abstract layer deals with the **logical structure** of documents without concerning itself with how content will be visually rendered.
|
||||||
|
|
||||||
|
### Key Components
|
||||||
|
|
||||||
|
#### `abstract/block.py`
|
||||||
|
- `Block`: Base class for all block-level content
|
||||||
|
- `Paragraph`: Represents a logical paragraph containing words
|
||||||
|
- `Heading`: Represents headings with semantic levels (H1-H6)
|
||||||
|
- `HList`: Represents ordered/unordered lists
|
||||||
|
- `Image`: Represents image references
|
||||||
|
|
||||||
|
#### `abstract/inline.py`
|
||||||
|
- `Word`: Represents individual words with text content and styling information
|
||||||
|
- Contains methods for hyphenation and text manipulation
|
||||||
|
- Does **not** handle rendering or spatial layout
|
||||||
|
|
||||||
|
#### `abstract/document.py`
|
||||||
|
- `Document`: Container for the overall document structure
|
||||||
|
- `Chapter`: Logical grouping of blocks (for books/long documents)
|
||||||
|
|
||||||
|
### Characteristics of Abstract Classes
|
||||||
|
|
||||||
|
1. **Content-focused**: Store text, structure, and semantic meaning
|
||||||
|
2. **Layout-agnostic**: No knowledge of fonts, pixels, or rendering
|
||||||
|
3. **Reusable**: Same content can be rendered in different formats/sizes
|
||||||
|
4. **Serializable**: Can be saved/loaded without rendering context
|
||||||
|
|
||||||
|
### Example: Abstract Word
|
||||||
|
|
||||||
|
```python
|
||||||
|
# An Abstract Word knows its text content and semantic properties
|
||||||
|
word = Word("supercalifragilisticexpialidocious", font_style)
|
||||||
|
word.hyphenate() # Logical operation - finds break points
|
||||||
|
parts = word.get_hyphenated_parts() # Returns ["super-", "cali-", "fragi-", ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Concrete Layer (`pyWebLayout/concrete/`)
|
||||||
|
|
||||||
|
The Concrete layer handles the **spatial representation** and actual rendering of content.
|
||||||
|
|
||||||
|
### Key Components
|
||||||
|
|
||||||
|
#### `concrete/text.py`
|
||||||
|
- `Text`: Renders a specific text fragment with precise positioning
|
||||||
|
- `Line`: Manages a line of `Text` objects with spacing and alignment
|
||||||
|
- Handles actual pixel measurements, font rendering, and positioning
|
||||||
|
|
||||||
|
#### `concrete/page.py`
|
||||||
|
- `Page`: Top-level container for rendered content
|
||||||
|
- `Container`: Layout manager for organizing renderable objects
|
||||||
|
- Handles spatial layout, pagination, and visual composition
|
||||||
|
|
||||||
|
#### `concrete/box.py`
|
||||||
|
- `Box`: Base class for all spatially-aware renderable objects
|
||||||
|
- Provides positioning, sizing, and rendering capabilities
|
||||||
|
|
||||||
|
### Characteristics of Concrete Classes
|
||||||
|
|
||||||
|
1. **Rendering-focused**: Handle pixels, fonts, images, and visual output
|
||||||
|
2. **Spatially-aware**: Know exact positions, sizes, and layout constraints
|
||||||
|
3. **Implementation-specific**: Tied to specific rendering technologies (PIL, etc.)
|
||||||
|
4. **Non-portable**: Rendering results are tied to specific display contexts
|
||||||
|
|
||||||
|
### Example: Concrete Text
|
||||||
|
|
||||||
|
```python
|
||||||
|
# A Concrete Text object handles actual rendering
|
||||||
|
text = Text("super-", font) # Specific text fragment
|
||||||
|
text._calculate_dimensions() # Computes exact pixel size
|
||||||
|
image = text.render() # Produces actual visual output
|
||||||
|
```
|
||||||
|
|
||||||
|
## The Transformation Process
|
||||||
|
|
||||||
|
The architecture involves a clear transformation from Abstract to Concrete:
|
||||||
|
|
||||||
|
```
|
||||||
|
Abstract Document
|
||||||
|
↓
|
||||||
|
[Parser Layer]
|
||||||
|
↓
|
||||||
|
Abstract Blocks (Paragraph, Heading, etc.)
|
||||||
|
↓
|
||||||
|
[Layout Engine]
|
||||||
|
↓
|
||||||
|
Concrete Objects (Text, Line, Page)
|
||||||
|
↓
|
||||||
|
[Rendering Engine]
|
||||||
|
↓
|
||||||
|
Visual Output (Images, PDF, etc.)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example Transformation
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 1. Abstract content
|
||||||
|
paragraph = Paragraph()
|
||||||
|
paragraph.add_word(Word("This", font))
|
||||||
|
paragraph.add_word(Word("is", font))
|
||||||
|
paragraph.add_word(Word("a", font))
|
||||||
|
paragraph.add_word(Word("test", font))
|
||||||
|
|
||||||
|
# 2. Layout transformation
|
||||||
|
layout = ParagraphLayout(line_width=200, line_height=20)
|
||||||
|
lines = layout.layout_paragraph(paragraph) # Returns List[Line]
|
||||||
|
|
||||||
|
# 3. Each Line contains concrete Text objects
|
||||||
|
for line in lines:
|
||||||
|
for text_obj in line.text_objects: # List[Text]
|
||||||
|
print(f"Text: '{text_obj.text}' at position {text_obj._origin}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Architectural Principles
|
||||||
|
|
||||||
|
### 1. **Single Responsibility**
|
||||||
|
- Abstract classes: Handle content and structure
|
||||||
|
- Concrete classes: Handle rendering and layout
|
||||||
|
|
||||||
|
### 2. **Separation of Concerns**
|
||||||
|
- Text parsing/processing ≠ Text rendering
|
||||||
|
- Document structure ≠ Page layout
|
||||||
|
- Content semantics ≠ Visual presentation
|
||||||
|
|
||||||
|
### 3. **Immutable Abstract Content**
|
||||||
|
- Abstract content remains unchanged during rendering
|
||||||
|
- Multiple concrete representations can be generated from same abstract content
|
||||||
|
- Enables pagination, different formats, responsive layouts
|
||||||
|
|
||||||
|
### 4. **One-to-Many Relationships**
|
||||||
|
- One Abstract Word → Multiple Concrete Text objects (hyphenation)
|
||||||
|
- One Abstract Paragraph → Multiple Concrete Lines
|
||||||
|
- One Abstract Document → Multiple Concrete Pages
|
||||||
|
|
||||||
|
## Common Anti-Patterns to Avoid
|
||||||
|
|
||||||
|
### ❌ **Mixing Concerns**
|
||||||
|
```python
|
||||||
|
# WRONG: Abstract class knowing about pixels
|
||||||
|
class Word:
|
||||||
|
def __init__(self, text):
|
||||||
|
self.text = text
|
||||||
|
self.rendered_width = None # ❌ Concrete concern in abstract class
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ **renderable_words Concept**
|
||||||
|
```python
|
||||||
|
# WRONG: Confusing abstract and concrete
|
||||||
|
line.renderable_words # ❌ This suggests Words are renderable
|
||||||
|
# Words are abstract - only Text objects render
|
||||||
|
```
|
||||||
|
|
||||||
|
### ✅ **Correct Separation**
|
||||||
|
```python
|
||||||
|
# CORRECT: Clear separation
|
||||||
|
abstract_word = Word("test") # Abstract content
|
||||||
|
concrete_text = Text("test", font) # Concrete rendering
|
||||||
|
line.text_objects.append(concrete_text) # Concrete objects in concrete container
|
||||||
|
```
|
||||||
|
|
||||||
|
## Benefits of This Architecture
|
||||||
|
|
||||||
|
### 1. **Flexibility**
|
||||||
|
- Same content can be rendered at different sizes
|
||||||
|
- Multiple output formats from single source
|
||||||
|
- Easy to implement responsive design
|
||||||
|
|
||||||
|
### 2. **Testability**
|
||||||
|
- Abstract logic can be tested without rendering
|
||||||
|
- Layout algorithms can be tested independently
|
||||||
|
- Visual rendering can be mocked
|
||||||
|
|
||||||
|
### 3. **Performance**
|
||||||
|
- Abstract content can be cached and reused
|
||||||
|
- Layout can be computed once for multiple renderings
|
||||||
|
- Incremental updates possible
|
||||||
|
|
||||||
|
### 4. **Maintainability**
|
||||||
|
- Clear boundaries between text processing and rendering
|
||||||
|
- Changes to rendering don't affect content parsing
|
||||||
|
- Easy to swap rendering backends
|
||||||
|
|
||||||
|
## File Organization
|
||||||
|
|
||||||
|
```
|
||||||
|
pyWebLayout/
|
||||||
|
├── abstract/ # Content and structure
|
||||||
|
│ ├── block.py # Document blocks (Paragraph, Heading, etc.)
|
||||||
|
│ ├── inline.py # Inline content (Word, etc.)
|
||||||
|
│ ├── document.py # Document structure
|
||||||
|
│ └── functional.py # Links, buttons, etc.
|
||||||
|
│
|
||||||
|
├── concrete/ # Rendering and layout
|
||||||
|
│ ├── text.py # Text and Line rendering
|
||||||
|
│ ├── page.py # Page layout and containers
|
||||||
|
│ ├── box.py # Base rendering classes
|
||||||
|
│ ├── image.py # Image rendering
|
||||||
|
│ └── functional.py # Interactive elements
|
||||||
|
│
|
||||||
|
├── typesetting/ # Layout algorithms
|
||||||
|
│ ├── paragraph_layout.py # Abstract → Concrete transformation
|
||||||
|
│ ├── flow.py # Text flow management
|
||||||
|
│ └── pagination.py # Page breaking logic
|
||||||
|
│
|
||||||
|
└── style/ # Styling and formatting
|
||||||
|
├── fonts.py # Font management
|
||||||
|
├── layout.py # Layout constants
|
||||||
|
└── alignment.py # Alignment enums
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The Abstract/Concrete separation is fundamental to pyWebLayout's design. It ensures clean separation between content processing and visual rendering, enabling flexible, maintainable, and testable document processing pipelines.
|
||||||
|
|
||||||
|
**Remember**:
|
||||||
|
- **Abstract** = What to display (content, structure, semantics)
|
||||||
|
- **Concrete** = How to display it (pixels, fonts, positioning, rendering)
|
||||||
|
|
||||||
|
This architecture enables the library to handle complex document layouts while maintaining clear, understandable code organization.
|
||||||
@ -5,8 +5,146 @@ from pyWebLayout.style.layout import Alignment
|
|||||||
from pyWebLayout.style import Font, FontStyle, FontWeight, TextDecoration
|
from pyWebLayout.style import Font, FontStyle, FontWeight, TextDecoration
|
||||||
from pyWebLayout.abstract.inline import Word
|
from pyWebLayout.abstract.inline import Word
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
from typing import Tuple, Union, List, Optional
|
from typing import Tuple, Union, List, Optional, Protocol
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
|
||||||
|
class AlignmentHandler(ABC):
|
||||||
|
"""
|
||||||
|
Abstract base class for text alignment handlers.
|
||||||
|
Each handler implements a specific alignment strategy.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def calculate_spacing_and_position(self, text_objects: List['Text'],
|
||||||
|
available_width: int, min_spacing: int,
|
||||||
|
max_spacing: int) -> Tuple[int, int]:
|
||||||
|
"""
|
||||||
|
Calculate the spacing between words and starting position for the line.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text_objects: List of Text objects in the line
|
||||||
|
available_width: Total width available for the line
|
||||||
|
min_spacing: Minimum spacing between words
|
||||||
|
max_spacing: Maximum spacing between words
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (spacing_between_words, starting_x_position)
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def should_try_hyphenation(self, text_objects: List['Text'], word_width: int,
|
||||||
|
available_width: int, spacing: int) -> bool:
|
||||||
|
"""
|
||||||
|
Determine if hyphenation should be attempted for better spacing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text_objects: Current text objects in the line
|
||||||
|
word_width: Width of the word trying to be added
|
||||||
|
available_width: Available width remaining
|
||||||
|
spacing: Current minimum spacing being used
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if hyphenation should be attempted
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class LeftAlignmentHandler(AlignmentHandler):
|
||||||
|
"""Handler for left-aligned text."""
|
||||||
|
|
||||||
|
def calculate_spacing_and_position(self, text_objects: List['Text'],
|
||||||
|
available_width: int, min_spacing: int,
|
||||||
|
max_spacing: int) -> Tuple[int, int]:
|
||||||
|
"""Left alignment uses minimum spacing and starts at position 0."""
|
||||||
|
return min_spacing, 0
|
||||||
|
|
||||||
|
def should_try_hyphenation(self, text_objects: List['Text'], word_width: int,
|
||||||
|
available_width: int, spacing: int) -> bool:
|
||||||
|
"""For left alignment, hyphenate only if the word doesn't fit."""
|
||||||
|
return word_width > available_width
|
||||||
|
|
||||||
|
|
||||||
|
class CenterRightAlignmentHandler(AlignmentHandler):
|
||||||
|
"""Handler for center and right-aligned text."""
|
||||||
|
|
||||||
|
def __init__(self, alignment: Alignment):
|
||||||
|
self._alignment = alignment
|
||||||
|
|
||||||
|
def calculate_spacing_and_position(self, text_objects: List['Text'],
|
||||||
|
available_width: int, min_spacing: int,
|
||||||
|
max_spacing: int) -> Tuple[int, int]:
|
||||||
|
"""Center/right alignment uses minimum spacing with calculated start position."""
|
||||||
|
if not text_objects:
|
||||||
|
return min_spacing, 0
|
||||||
|
|
||||||
|
total_text_width = sum(text_obj.width for text_obj in text_objects)
|
||||||
|
num_spaces = len(text_objects) - 1
|
||||||
|
spacing = min_spacing
|
||||||
|
|
||||||
|
if self._alignment == Alignment.RIGHT:
|
||||||
|
x_pos = available_width - (total_text_width + spacing * num_spaces)
|
||||||
|
else: # CENTER
|
||||||
|
x_pos = (available_width - (total_text_width + spacing * num_spaces)) // 2
|
||||||
|
|
||||||
|
return spacing, max(0, x_pos)
|
||||||
|
|
||||||
|
def should_try_hyphenation(self, text_objects: List['Text'], word_width: int,
|
||||||
|
available_width: int, spacing: int) -> bool:
|
||||||
|
"""For center/right alignment, hyphenate only if the word doesn't fit."""
|
||||||
|
return word_width > available_width
|
||||||
|
|
||||||
|
|
||||||
|
class JustifyAlignmentHandler(AlignmentHandler):
|
||||||
|
"""Handler for justified text with optimal spacing."""
|
||||||
|
|
||||||
|
def calculate_spacing_and_position(self, text_objects: List['Text'],
|
||||||
|
available_width: int, min_spacing: int,
|
||||||
|
max_spacing: int) -> Tuple[int, int]:
|
||||||
|
"""Justified alignment distributes space evenly between words."""
|
||||||
|
if not text_objects or len(text_objects) == 1:
|
||||||
|
# Single word or empty line - use left alignment
|
||||||
|
return min_spacing, 0
|
||||||
|
|
||||||
|
total_text_width = sum(text_obj.width for text_obj in text_objects)
|
||||||
|
num_spaces = len(text_objects) - 1
|
||||||
|
available_space = available_width - total_text_width
|
||||||
|
|
||||||
|
if num_spaces > 0:
|
||||||
|
spacing = available_space // num_spaces
|
||||||
|
# Ensure spacing is within acceptable bounds
|
||||||
|
spacing = max(min_spacing, min(max_spacing, spacing))
|
||||||
|
else:
|
||||||
|
spacing = min_spacing
|
||||||
|
|
||||||
|
return spacing, 0
|
||||||
|
|
||||||
|
def should_try_hyphenation(self, text_objects: List['Text'], word_width: int,
|
||||||
|
available_width: int, spacing: int) -> bool:
|
||||||
|
"""
|
||||||
|
For justified text, consider hyphenation if it would improve spacing quality.
|
||||||
|
This includes cases where the word fits but would create poor spacing.
|
||||||
|
"""
|
||||||
|
if word_width > available_width:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Calculate what the spacing would be with this word added
|
||||||
|
if not text_objects:
|
||||||
|
return False
|
||||||
|
|
||||||
|
total_text_width = sum(text_obj.width for text_obj in text_objects) + word_width
|
||||||
|
num_spaces = len(text_objects) # Will be len(text_objects) after adding the word
|
||||||
|
available_space = available_width - total_text_width
|
||||||
|
|
||||||
|
if num_spaces > 0:
|
||||||
|
projected_spacing = available_space // num_spaces
|
||||||
|
# If spacing would be too large, consider hyphenation for better distribution
|
||||||
|
max_acceptable_spacing = spacing * 2 # Allow up to 2x normal spacing
|
||||||
|
return projected_spacing > max_acceptable_spacing
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class Text(Renderable, Queriable):
|
class Text(Renderable, Queriable):
|
||||||
@ -228,6 +366,26 @@ class Line(Box):
|
|||||||
self._previous = previous
|
self._previous = previous
|
||||||
self._next = None
|
self._next = None
|
||||||
|
|
||||||
|
# Create the appropriate alignment handler
|
||||||
|
self._alignment_handler = self._create_alignment_handler(halign)
|
||||||
|
|
||||||
|
def _create_alignment_handler(self, alignment: Alignment) -> AlignmentHandler:
|
||||||
|
"""
|
||||||
|
Create the appropriate alignment handler based on the alignment type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
alignment: The alignment type
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The appropriate alignment handler instance
|
||||||
|
"""
|
||||||
|
if alignment == Alignment.LEFT:
|
||||||
|
return LeftAlignmentHandler()
|
||||||
|
elif alignment == Alignment.JUSTIFY:
|
||||||
|
return JustifyAlignmentHandler()
|
||||||
|
else: # CENTER or RIGHT
|
||||||
|
return CenterRightAlignmentHandler(alignment)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def text_objects(self) -> List[Text]:
|
def text_objects(self) -> List[Text]:
|
||||||
"""Get the list of Text objects in this line"""
|
"""Get the list of Text objects in this line"""
|
||||||
@ -286,7 +444,7 @@ class Line(Box):
|
|||||||
|
|
||||||
def add_word(self, text: str, font: Optional[Font] = None) -> Union[None, str]:
|
def add_word(self, text: str, font: Optional[Font] = None) -> Union[None, str]:
|
||||||
"""
|
"""
|
||||||
Add a word to this line as a Text object.
|
Add a word to this line as a Text object using intelligent hyphenation decisions.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: The text content of the word
|
text: The text content of the word
|
||||||
@ -307,65 +465,105 @@ class Line(Box):
|
|||||||
spacing_needed = min_spacing if self._text_objects else 0
|
spacing_needed = min_spacing if self._text_objects else 0
|
||||||
|
|
||||||
# Add a small margin to prevent edge cases where words appear to fit but get cropped
|
# Add a small margin to prevent edge cases where words appear to fit but get cropped
|
||||||
# This addresses the issue of lines appearing too short
|
|
||||||
safety_margin = max(1, int(font.font_size * 0.05)) # 5% of font size as safety margin
|
safety_margin = max(1, int(font.font_size * 0.05)) # 5% of font size as safety margin
|
||||||
|
|
||||||
# Check if word fits in the line with safety margin
|
# Check if word fits in the line with safety margin
|
||||||
available_width = self._size[0] - self._current_width - spacing_needed - safety_margin
|
available_width = self._size[0] - self._current_width - spacing_needed - safety_margin
|
||||||
if word_width <= available_width:
|
|
||||||
# Word fits - add it to the line
|
# Use the alignment handler to decide if hyphenation should be attempted
|
||||||
|
should_hyphenate = self._alignment_handler.should_try_hyphenation(
|
||||||
|
self._text_objects, word_width, available_width, min_spacing)
|
||||||
|
|
||||||
|
if word_width <= available_width and not should_hyphenate:
|
||||||
|
# Word fits and alignment handler doesn't suggest hyphenation - add it to the line
|
||||||
text_obj.add_to_line(self)
|
text_obj.add_to_line(self)
|
||||||
self._text_objects.append(text_obj)
|
self._text_objects.append(text_obj)
|
||||||
self._current_width += spacing_needed + word_width
|
self._current_width += spacing_needed + word_width
|
||||||
return None
|
return None
|
||||||
|
elif should_hyphenate:
|
||||||
|
# Try hyphenation for better spacing
|
||||||
|
return self._try_hyphenation_or_fit(text, font, available_width, spacing_needed, safety_margin)
|
||||||
else:
|
else:
|
||||||
# Word doesn't fit - try to hyphenate
|
# Word doesn't fit and no hyphenation recommended
|
||||||
abstract_word = Word(text, font)
|
if self._text_objects:
|
||||||
if abstract_word.hyphenate():
|
# Line already has words, can't fit this one at all
|
||||||
# Get the first hyphenated part
|
return text
|
||||||
first_part_text = abstract_word.get_hyphenated_part(0)
|
|
||||||
first_part_obj = Text(first_part_text, font)
|
|
||||||
|
|
||||||
# Check if first part fits (with safety margin)
|
|
||||||
if first_part_obj.width <= available_width:
|
|
||||||
# First part fits - add it to the line
|
|
||||||
first_part_obj.add_to_line(self)
|
|
||||||
self._text_objects.append(first_part_obj)
|
|
||||||
self._current_width += spacing_needed + first_part_obj.width
|
|
||||||
|
|
||||||
# Return the remaining part(s)
|
|
||||||
if abstract_word.get_hyphenated_part_count() > 1:
|
|
||||||
return abstract_word.get_hyphenated_part(1)
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
# Even the first hyphenated part doesn't fit
|
|
||||||
if self._text_objects:
|
|
||||||
# Line already has words, can't fit this one at all
|
|
||||||
return text
|
|
||||||
else:
|
|
||||||
# Empty line - must fit something or infinite loop
|
|
||||||
first_part_text = abstract_word.get_hyphenated_part(0)
|
|
||||||
# If the first part is nearly as long as the original word, this is likely a test
|
|
||||||
if len(first_part_text.rstrip('-')) >= len(text) * 0.8: # 80% of original length
|
|
||||||
# This is likely a mocked test scenario - return original word unchanged
|
|
||||||
return text
|
|
||||||
else:
|
|
||||||
# Real scenario with proper hyphenation - try force fitting
|
|
||||||
return self._force_fit_long_word(text, font, available_width + safety_margin)
|
|
||||||
else:
|
else:
|
||||||
# Word cannot be hyphenated
|
# Empty line with word that's too long - force fit
|
||||||
if self._text_objects:
|
return self._force_fit_long_word(text, font, available_width + safety_margin)
|
||||||
# Line already has words, can't fit this unhyphenatable word
|
|
||||||
return text
|
def _try_hyphenation_or_fit(self, text: str, font: Font, available_width: int,
|
||||||
|
spacing_needed: int, safety_margin: int) -> Union[None, str]:
|
||||||
|
"""
|
||||||
|
Try different hyphenation options and choose the best one for spacing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: The text to hyphenate
|
||||||
|
font: The font to use
|
||||||
|
available_width: Available width for the word
|
||||||
|
spacing_needed: Spacing needed before the word
|
||||||
|
safety_margin: Safety margin for fitting
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None if the word fits, or remaining text if it doesn't fit
|
||||||
|
"""
|
||||||
|
abstract_word = Word(text, font)
|
||||||
|
|
||||||
|
if abstract_word.hyphenate():
|
||||||
|
# Try different hyphenation breakpoints to find the best spacing
|
||||||
|
best_option = None
|
||||||
|
best_spacing_quality = float('inf') # Lower is better
|
||||||
|
|
||||||
|
for i in range(abstract_word.get_hyphenated_part_count()):
|
||||||
|
part_text = abstract_word.get_hyphenated_part(i)
|
||||||
|
part_obj = Text(part_text, font)
|
||||||
|
|
||||||
|
if part_obj.width <= available_width:
|
||||||
|
# Calculate spacing quality with this hyphenation
|
||||||
|
temp_text_objects = self._text_objects + [part_obj]
|
||||||
|
spacing, _ = self._alignment_handler.calculate_spacing_and_position(
|
||||||
|
temp_text_objects, self._size[0], self._spacing[0], self._spacing[1])
|
||||||
|
|
||||||
|
# Quality metric: prefer spacing closer to minimum, avoid extremes
|
||||||
|
spacing_quality = abs(spacing - self._spacing[0])
|
||||||
|
|
||||||
|
if spacing_quality < best_spacing_quality:
|
||||||
|
best_spacing_quality = spacing_quality
|
||||||
|
best_option = (i, part_obj, part_text)
|
||||||
else:
|
else:
|
||||||
# Empty line with unhyphenatable word that's too long
|
# Can't fit this part, no point trying longer parts
|
||||||
# Force-fit as many characters as possible
|
break
|
||||||
|
|
||||||
|
if best_option:
|
||||||
|
# Use the best hyphenation option
|
||||||
|
i, part_obj, part_text = best_option
|
||||||
|
part_obj.add_to_line(self)
|
||||||
|
self._text_objects.append(part_obj)
|
||||||
|
self._current_width += spacing_needed + part_obj.width
|
||||||
|
|
||||||
|
# Return remaining part(s) if any
|
||||||
|
if i + 1 < abstract_word.get_hyphenated_part_count():
|
||||||
|
return abstract_word.get_hyphenated_part(i + 1)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
# No hyphenation part fits
|
||||||
|
if self._text_objects:
|
||||||
|
return text # Line already has words, can't fit this one
|
||||||
|
else:
|
||||||
|
# Empty line - must fit something
|
||||||
return self._force_fit_long_word(text, font, available_width + safety_margin)
|
return self._force_fit_long_word(text, font, available_width + safety_margin)
|
||||||
|
else:
|
||||||
|
# Word cannot be hyphenated
|
||||||
|
if self._text_objects:
|
||||||
|
return text # Line already has words, can't fit this unhyphenatable word
|
||||||
|
else:
|
||||||
|
# Empty line with unhyphenatable word that's too long
|
||||||
|
return self._force_fit_long_word(text, font, available_width + safety_margin)
|
||||||
|
|
||||||
def render(self) -> Image.Image:
|
def render(self) -> Image.Image:
|
||||||
"""
|
"""
|
||||||
Render the line with all its text objects.
|
Render the line with all its text objects using the alignment handler system.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A PIL Image containing the rendered line
|
A PIL Image containing the rendered line
|
||||||
@ -377,30 +575,9 @@ class Line(Box):
|
|||||||
if not self._text_objects:
|
if not self._text_objects:
|
||||||
return canvas
|
return canvas
|
||||||
|
|
||||||
# Calculate total width of text objects
|
# Use the alignment handler to calculate spacing and position
|
||||||
total_text_width = sum(text_obj.width for text_obj in self._text_objects)
|
spacing, x_pos = self._alignment_handler.calculate_spacing_and_position(
|
||||||
|
self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
|
||||||
# Calculate spacing based on alignment and available space
|
|
||||||
available_space = self._size[0] - total_text_width
|
|
||||||
num_spaces = len(self._text_objects) - 1
|
|
||||||
|
|
||||||
if num_spaces > 0:
|
|
||||||
if self._halign == Alignment.JUSTIFY:
|
|
||||||
# For justified text, distribute space evenly between words
|
|
||||||
spacing = available_space // num_spaces
|
|
||||||
else:
|
|
||||||
# Use minimum spacing for other alignments
|
|
||||||
spacing = self._spacing[0]
|
|
||||||
else:
|
|
||||||
spacing = 0
|
|
||||||
|
|
||||||
# Calculate starting x position based on alignment
|
|
||||||
if self._halign == Alignment.LEFT:
|
|
||||||
x_pos = 0
|
|
||||||
elif self._halign == Alignment.RIGHT:
|
|
||||||
x_pos = self._size[0] - (total_text_width + spacing * num_spaces)
|
|
||||||
else: # CENTER
|
|
||||||
x_pos = (self._size[0] - (total_text_width + spacing * num_spaces)) // 2
|
|
||||||
|
|
||||||
# Vertical alignment - center text vertically in the line
|
# Vertical alignment - center text vertically in the line
|
||||||
y_pos = (self._size[1] - max(text_obj.height for text_obj in self._text_objects)) // 2
|
y_pos = (self._size[1] - max(text_obj.height for text_obj in self._text_objects)) // 2
|
||||||
|
|||||||
38
simple_test.py
Normal file
38
simple_test.py
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Simple test to check if the refactored text alignment system works.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pyWebLayout.concrete.text import Line, Text, AlignmentHandler, LeftAlignmentHandler
|
||||||
|
from pyWebLayout.style.layout import Alignment
|
||||||
|
from pyWebLayout.style import Font
|
||||||
|
print("✓ All imports successful")
|
||||||
|
|
||||||
|
# Create a simple font
|
||||||
|
font = Font()
|
||||||
|
print("✓ Font created")
|
||||||
|
|
||||||
|
# Create a line with left alignment
|
||||||
|
line = Line((5, 20), (0, 0), (200, 30), font, halign=Alignment.LEFT)
|
||||||
|
print("✓ Line created with left alignment")
|
||||||
|
print(f" Handler type: {type(line._alignment_handler).__name__}")
|
||||||
|
|
||||||
|
# Try adding a word
|
||||||
|
result = line.add_word("Hello")
|
||||||
|
print(f"✓ Added word 'Hello', result: {result}")
|
||||||
|
print(f" Line now has {len(line.text_objects)} text objects")
|
||||||
|
|
||||||
|
# Test different alignments
|
||||||
|
alignments = [Alignment.LEFT, Alignment.CENTER, Alignment.RIGHT, Alignment.JUSTIFY]
|
||||||
|
for align in alignments:
|
||||||
|
test_line = Line((5, 20), (0, 0), (200, 30), font, halign=align)
|
||||||
|
handler_name = type(test_line._alignment_handler).__name__
|
||||||
|
print(f"✓ {align.name} alignment uses {handler_name}")
|
||||||
|
|
||||||
|
print("✓ All tests passed!")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Error: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
118
test_alignment_handlers.py
Normal file
118
test_alignment_handlers.py
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test script to verify the alignment handler system is working correctly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pyWebLayout.concrete.text import Line, Text, LeftAlignmentHandler, CenterRightAlignmentHandler, JustifyAlignmentHandler
|
||||||
|
from pyWebLayout.style.layout import Alignment
|
||||||
|
from pyWebLayout.style import Font
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
def test_alignment_handlers():
|
||||||
|
"""Test all three alignment handlers."""
|
||||||
|
print("Testing Alignment Handler System")
|
||||||
|
print("=" * 40)
|
||||||
|
|
||||||
|
# Create a basic font for testing
|
||||||
|
font = Font()
|
||||||
|
|
||||||
|
# Test data
|
||||||
|
test_words = ["This", "is", "a", "test", "sentence"]
|
||||||
|
line_width = 300
|
||||||
|
line_height = 30
|
||||||
|
spacing = (5, 20) # min_spacing, max_spacing
|
||||||
|
|
||||||
|
# Test Left Alignment
|
||||||
|
print("\n1. Testing Left Alignment:")
|
||||||
|
left_line = Line(spacing, (0, 0), (line_width, line_height), font, halign=Alignment.LEFT)
|
||||||
|
|
||||||
|
for word in test_words:
|
||||||
|
result = left_line.add_word(word)
|
||||||
|
if result:
|
||||||
|
print(f" Word '{word}' didn't fit, remainder: '{result}'")
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
print(f" Added word: '{word}'")
|
||||||
|
|
||||||
|
print(f" Final line has {len(left_line.text_objects)} words")
|
||||||
|
print(f" Handler type: {type(left_line._alignment_handler).__name__}")
|
||||||
|
|
||||||
|
# Test Center Alignment
|
||||||
|
print("\n2. Testing Center Alignment:")
|
||||||
|
center_line = Line(spacing, (0, 0), (line_width, line_height), font, halign=Alignment.CENTER)
|
||||||
|
|
||||||
|
for word in test_words:
|
||||||
|
result = center_line.add_word(word)
|
||||||
|
if result:
|
||||||
|
print(f" Word '{word}' didn't fit, remainder: '{result}'")
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
print(f" Added word: '{word}'")
|
||||||
|
|
||||||
|
print(f" Final line has {len(center_line.text_objects)} words")
|
||||||
|
print(f" Handler type: {type(center_line._alignment_handler).__name__}")
|
||||||
|
|
||||||
|
# Test Right Alignment
|
||||||
|
print("\n3. Testing Right Alignment:")
|
||||||
|
right_line = Line(spacing, (0, 0), (line_width, line_height), font, halign=Alignment.RIGHT)
|
||||||
|
|
||||||
|
for word in test_words:
|
||||||
|
result = right_line.add_word(word)
|
||||||
|
if result:
|
||||||
|
print(f" Word '{word}' didn't fit, remainder: '{result}'")
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
print(f" Added word: '{word}'")
|
||||||
|
|
||||||
|
print(f" Final line has {len(right_line.text_objects)} words")
|
||||||
|
print(f" Handler type: {type(right_line._alignment_handler).__name__}")
|
||||||
|
|
||||||
|
# Test Justify Alignment
|
||||||
|
print("\n4. Testing Justify Alignment:")
|
||||||
|
justify_line = Line(spacing, (0, 0), (line_width, line_height), font, halign=Alignment.JUSTIFY)
|
||||||
|
|
||||||
|
for word in test_words:
|
||||||
|
result = justify_line.add_word(word)
|
||||||
|
if result:
|
||||||
|
print(f" Word '{word}' didn't fit, remainder: '{result}'")
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
print(f" Added word: '{word}'")
|
||||||
|
|
||||||
|
print(f" Final line has {len(justify_line.text_objects)} words")
|
||||||
|
print(f" Handler type: {type(justify_line._alignment_handler).__name__}")
|
||||||
|
|
||||||
|
# Test spacing and position calculations
|
||||||
|
print("\n5. Testing Handler Calculations:")
|
||||||
|
|
||||||
|
# Create sample text objects
|
||||||
|
text_objects = [Text(word, font) for word in ["Hello", "World"]]
|
||||||
|
|
||||||
|
handlers = [
|
||||||
|
("Left", LeftAlignmentHandler()),
|
||||||
|
("Center", CenterRightAlignmentHandler(Alignment.CENTER)),
|
||||||
|
("Right", CenterRightAlignmentHandler(Alignment.RIGHT)),
|
||||||
|
("Justify", JustifyAlignmentHandler())
|
||||||
|
]
|
||||||
|
|
||||||
|
for name, handler in handlers:
|
||||||
|
spacing_calc, position = handler.calculate_spacing_and_position(
|
||||||
|
text_objects, line_width, spacing[0], spacing[1])
|
||||||
|
print(f" {name}: spacing={spacing_calc}, position={position}")
|
||||||
|
|
||||||
|
print("\n6. Testing Hyphenation Decisions:")
|
||||||
|
|
||||||
|
# Test hyphenation decisions for different alignments
|
||||||
|
test_word_width = 50
|
||||||
|
available_width = 40 # Word doesn't fit
|
||||||
|
|
||||||
|
for name, handler in handlers:
|
||||||
|
should_hyphenate = handler.should_try_hyphenation(
|
||||||
|
text_objects, test_word_width, available_width, spacing[0])
|
||||||
|
print(f" {name}: should_hyphenate={should_hyphenate}")
|
||||||
|
|
||||||
|
print("\nAlignment Handler Test Completed Successfully!")
|
||||||
|
return True
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_alignment_handlers()
|
||||||
@ -1,162 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Test script specifically for verifying the long word fix.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw
|
|
||||||
from pyWebLayout.concrete.text import Text, Line
|
|
||||||
from pyWebLayout.style import Font, FontStyle, FontWeight
|
|
||||||
from pyWebLayout.style.layout import Alignment
|
|
||||||
|
|
||||||
def test_supercalifragilisticexpialidocious():
|
|
||||||
"""Test the specific long word that was causing issues"""
|
|
||||||
|
|
||||||
print("Testing long word handling...")
|
|
||||||
|
|
||||||
font_style = Font(
|
|
||||||
font_path=None,
|
|
||||||
font_size=12,
|
|
||||||
colour=(0, 0, 0, 255)
|
|
||||||
)
|
|
||||||
|
|
||||||
# The problematic sentence
|
|
||||||
sentence = "This sentence has some really long words like supercalifragilisticexpialidocious that might need hyphenation."
|
|
||||||
|
|
||||||
# Test with the same constraints that were failing
|
|
||||||
line_width = 150
|
|
||||||
line_height = 25
|
|
||||||
|
|
||||||
words = sentence.split()
|
|
||||||
|
|
||||||
# Create lines and track all the text
|
|
||||||
lines = []
|
|
||||||
words_remaining = words.copy()
|
|
||||||
all_rendered_text = []
|
|
||||||
|
|
||||||
print(f"Original sentence: {sentence}")
|
|
||||||
print(f"Line width: {line_width}px")
|
|
||||||
print()
|
|
||||||
|
|
||||||
line_number = 1
|
|
||||||
while words_remaining:
|
|
||||||
print(f"Creating line {line_number}...")
|
|
||||||
|
|
||||||
# Create a new line
|
|
||||||
current_line = Line(
|
|
||||||
spacing=(3, 8),
|
|
||||||
origin=(0, (line_number-1) * line_height),
|
|
||||||
size=(line_width, line_height),
|
|
||||||
font=font_style,
|
|
||||||
halign=Alignment.LEFT
|
|
||||||
)
|
|
||||||
|
|
||||||
lines.append(current_line)
|
|
||||||
|
|
||||||
# Add words to current line until it's full
|
|
||||||
words_added_to_line = []
|
|
||||||
while words_remaining:
|
|
||||||
word = words_remaining[0]
|
|
||||||
print(f" Trying to add word: '{word}'")
|
|
||||||
|
|
||||||
result = current_line.add_word(word)
|
|
||||||
|
|
||||||
if result is None:
|
|
||||||
# Word fit in the line
|
|
||||||
words_added_to_line.append(word)
|
|
||||||
words_remaining.pop(0)
|
|
||||||
print(f" ✓ Added '{word}' to line {line_number}")
|
|
||||||
else:
|
|
||||||
# Word didn't fit, or only part of it fit
|
|
||||||
if result == word:
|
|
||||||
# Whole word didn't fit
|
|
||||||
print(f" ✗ Word '{word}' didn't fit, moving to next line")
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
# Part of word fit, remainder is in result
|
|
||||||
words_added_to_line.append(word) # The original word
|
|
||||||
words_remaining[0] = result # Replace with remainder
|
|
||||||
print(f" ⚡ Part of '{word}' fit, remainder: '{result}'")
|
|
||||||
break
|
|
||||||
|
|
||||||
# Show what's on this line
|
|
||||||
line_words = [word.text for word in current_line.text_objects]
|
|
||||||
line_text = ' '.join(line_words)
|
|
||||||
all_rendered_text.extend(line_words)
|
|
||||||
print(f" Line {line_number} contains: \"{line_text}\"")
|
|
||||||
print(f" Line {line_number} width usage: {current_line._current_width}/{line_width}px")
|
|
||||||
print()
|
|
||||||
|
|
||||||
# If no words were added to this line, we have a problem
|
|
||||||
if not line_words:
|
|
||||||
print(f"ERROR: No words could be added to line {line_number}")
|
|
||||||
break
|
|
||||||
|
|
||||||
line_number += 1
|
|
||||||
|
|
||||||
# Safety check to prevent infinite loops
|
|
||||||
if line_number > 10:
|
|
||||||
print("Safety break: too many lines")
|
|
||||||
break
|
|
||||||
|
|
||||||
# Check if all words were rendered
|
|
||||||
original_words = sentence.split()
|
|
||||||
rendered_text_combined = ' '.join(all_rendered_text)
|
|
||||||
|
|
||||||
print("="*60)
|
|
||||||
print("VERIFICATION")
|
|
||||||
print("="*60)
|
|
||||||
print(f"Original text: {sentence}")
|
|
||||||
print(f"Rendered text: {rendered_text_combined}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Check for the problematic word
|
|
||||||
long_word = "supercalifragilisticexpialidocious"
|
|
||||||
if long_word in rendered_text_combined:
|
|
||||||
print(f"✓ SUCCESS: Long word '{long_word}' was rendered!")
|
|
||||||
elif "supercalifragilisticexpialidocious" in rendered_text_combined:
|
|
||||||
print(f"✓ SUCCESS: Long word was rendered (possibly hyphenated)!")
|
|
||||||
else:
|
|
||||||
# Check if parts of the word are there
|
|
||||||
found_parts = []
|
|
||||||
for rendered_word in all_rendered_text:
|
|
||||||
if long_word.startswith(rendered_word.replace('-', '')):
|
|
||||||
found_parts.append(rendered_word)
|
|
||||||
elif rendered_word.replace('-', '') in long_word:
|
|
||||||
found_parts.append(rendered_word)
|
|
||||||
|
|
||||||
if found_parts:
|
|
||||||
print(f"✓ PARTIAL SUCCESS: Found parts of long word: {found_parts}")
|
|
||||||
else:
|
|
||||||
print(f"✗ FAILURE: Long word '{long_word}' was not rendered at all!")
|
|
||||||
|
|
||||||
print(f"Total lines used: {len(lines)}")
|
|
||||||
|
|
||||||
# Create combined image showing all lines
|
|
||||||
total_height = len(lines) * line_height
|
|
||||||
combined_image = Image.new('RGBA', (line_width, total_height), (255, 255, 255, 255))
|
|
||||||
|
|
||||||
for i, line in enumerate(lines):
|
|
||||||
line_img = line.render()
|
|
||||||
y_pos = i * line_height
|
|
||||||
combined_image.paste(line_img, (0, y_pos), line_img)
|
|
||||||
|
|
||||||
# Add a border for visualization
|
|
||||||
draw = ImageDraw.Draw(combined_image)
|
|
||||||
draw.rectangle([(0, y_pos), (line_width-1, y_pos + line_height-1)], outline=(200, 200, 200), width=1)
|
|
||||||
|
|
||||||
# Save the result
|
|
||||||
output_filename = "test_long_word_fix.png"
|
|
||||||
combined_image.save(output_filename)
|
|
||||||
print(f"Result saved as: {output_filename}")
|
|
||||||
|
|
||||||
return len(lines), all_rendered_text
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
print("Testing long word fix for 'supercalifragilisticexpialidocious'...\n")
|
|
||||||
|
|
||||||
lines_used, rendered_words = test_supercalifragilisticexpialidocious()
|
|
||||||
|
|
||||||
print(f"\nTest completed!")
|
|
||||||
print(f"- Lines used: {lines_used}")
|
|
||||||
print(f"- Total words rendered: {len(rendered_words)}")
|
|
||||||
print(f"- Check test_long_word_fix.png for visual verification")
|
|
||||||
Loading…
x
Reference in New Issue
Block a user