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.abstract.inline import Word
|
||||
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
|
||||
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):
|
||||
@ -227,6 +365,26 @@ class Line(Box):
|
||||
|
||||
self._previous = previous
|
||||
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
|
||||
def text_objects(self) -> List[Text]:
|
||||
@ -286,7 +444,7 @@ class Line(Box):
|
||||
|
||||
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:
|
||||
text: The text content of the word
|
||||
@ -307,65 +465,105 @@ class Line(Box):
|
||||
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
|
||||
# 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
|
||||
|
||||
# Check if word fits in the line with 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)
|
||||
self._text_objects.append(text_obj)
|
||||
self._current_width += spacing_needed + word_width
|
||||
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:
|
||||
# Word doesn't fit - try to hyphenate
|
||||
abstract_word = Word(text, font)
|
||||
if abstract_word.hyphenate():
|
||||
# Get the first hyphenated part
|
||||
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)
|
||||
# Word doesn't fit and no hyphenation recommended
|
||||
if self._text_objects:
|
||||
# Line already has words, can't fit this one at all
|
||||
return text
|
||||
else:
|
||||
# Word cannot be hyphenated
|
||||
if self._text_objects:
|
||||
# Line already has words, can't fit this unhyphenatable word
|
||||
return text
|
||||
# Empty line with word that's too long - force fit
|
||||
return self._force_fit_long_word(text, font, available_width + safety_margin)
|
||||
|
||||
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:
|
||||
# Empty line with unhyphenatable word that's too long
|
||||
# Force-fit as many characters as possible
|
||||
# Can't fit this part, no point trying longer parts
|
||||
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)
|
||||
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:
|
||||
"""
|
||||
Render the line with all its text objects.
|
||||
Render the line with all its text objects using the alignment handler system.
|
||||
|
||||
Returns:
|
||||
A PIL Image containing the rendered line
|
||||
@ -377,30 +575,9 @@ class Line(Box):
|
||||
if not self._text_objects:
|
||||
return canvas
|
||||
|
||||
# Calculate total width of text objects
|
||||
total_text_width = sum(text_obj.width for text_obj in self._text_objects)
|
||||
|
||||
# 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
|
||||
# Use the alignment handler to calculate spacing and position
|
||||
spacing, x_pos = self._alignment_handler.calculate_spacing_and_position(
|
||||
self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
|
||||
|
||||
# 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
|
||||
|
||||
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