This commit is contained in:
parent
899182152a
commit
4e65fe3e67
214
html_browser.py
214
html_browser.py
@ -25,8 +25,11 @@ from pyWebLayout.concrete import (
|
||||
from pyWebLayout.abstract.functional import (
|
||||
Link, Button, Form, FormField, LinkType, FormFieldType
|
||||
)
|
||||
from pyWebLayout.abstract.block import Paragraph
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.style.fonts import Font, FontWeight, FontStyle, TextDecoration
|
||||
from pyWebLayout.style.layout import Alignment
|
||||
from pyWebLayout.typesetting.paragraph_layout import ParagraphLayout, ParagraphLayoutResult
|
||||
|
||||
|
||||
class HTMLParser:
|
||||
@ -39,7 +42,7 @@ class HTMLParser:
|
||||
def parse_html_string(self, html_content: str, base_url: str = "") -> Page:
|
||||
"""Parse HTML string and return a Page object"""
|
||||
# Create the main page
|
||||
page = Page(size=(800, 1600), background_color=(255, 255, 255))
|
||||
page = Page(size=(800, 10000), background_color=(255, 255, 255))
|
||||
self.current_container = page
|
||||
self.base_url = base_url
|
||||
|
||||
@ -76,7 +79,7 @@ class HTMLParser:
|
||||
return self.parse_html_string(html_content, base_url)
|
||||
except Exception as e:
|
||||
# Create error page
|
||||
page = Page(size=(800, 1600), background_color=(255, 255, 255))
|
||||
page = Page(size=(800, 10000), background_color=(255, 255, 255))
|
||||
error_text = Text(f"Error loading file: {str(e)}", Font(font_size=16, colour=(255, 0, 0)))
|
||||
page.add_child(error_text)
|
||||
return page
|
||||
@ -86,22 +89,211 @@ class HTMLParser:
|
||||
# Simple token-based parsing
|
||||
tokens = self._tokenize_html(content)
|
||||
|
||||
# Group tokens into paragraphs and other elements
|
||||
self._process_tokens_into_elements(tokens, container)
|
||||
|
||||
def _process_tokens_into_elements(self, tokens: List[Dict], container: Container):
|
||||
"""Process tokens and create appropriate elements (paragraphs, images, etc.)"""
|
||||
i = 0
|
||||
current_paragraph_content = []
|
||||
|
||||
while i < len(tokens):
|
||||
token = tokens[i]
|
||||
|
||||
if token['type'] == 'text':
|
||||
if token['content'].strip(): # Only add non-empty text
|
||||
text_obj = Text(token['content'].strip(), self.font_stack[-1])
|
||||
container.add_child(text_obj)
|
||||
current_paragraph_content.append((token['content'].strip(), self.font_stack[-1]))
|
||||
|
||||
elif token['type'] == 'tag':
|
||||
# Handle the tag and potentially parse content between opening and closing tags
|
||||
i = self._handle_tag_with_content(token, tokens, i, container)
|
||||
continue
|
||||
tag_name = token['name']
|
||||
is_closing = token['closing']
|
||||
|
||||
# Handle block-level elements that should end the current paragraph
|
||||
if tag_name in ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'div', 'br', 'img'] and not is_closing:
|
||||
# Finalize any pending paragraph content
|
||||
if current_paragraph_content:
|
||||
self._create_and_add_paragraph(current_paragraph_content, container)
|
||||
current_paragraph_content = []
|
||||
|
||||
# Handle the block element
|
||||
if tag_name == 'p':
|
||||
# Start a new paragraph
|
||||
i = self._handle_paragraph_tag(token, tokens, i, container)
|
||||
continue
|
||||
elif tag_name in ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']:
|
||||
# Handle header
|
||||
i = self._handle_header_tag(token, tokens, i, container)
|
||||
continue
|
||||
elif tag_name == 'br':
|
||||
# Add line break
|
||||
spacer = Box((0, 0), (1, 10))
|
||||
container.add_child(spacer)
|
||||
elif tag_name == 'img':
|
||||
# Handle image
|
||||
self._handle_tag(token, container)
|
||||
elif tag_name == 'div':
|
||||
# Continue processing div content
|
||||
pass
|
||||
|
||||
# Handle inline elements or continue processing
|
||||
elif tag_name in ['b', 'strong', 'i', 'em', 'u', 'a']:
|
||||
i = self._handle_inline_tag_with_content(token, tokens, i, current_paragraph_content)
|
||||
continue
|
||||
else:
|
||||
# Handle other tags normally
|
||||
self._handle_tag(token, container)
|
||||
|
||||
i += 1
|
||||
|
||||
# Finalize any remaining paragraph content
|
||||
if current_paragraph_content:
|
||||
self._create_and_add_paragraph(current_paragraph_content, container)
|
||||
|
||||
def _create_and_add_paragraph(self, content_list: List[Tuple[str, Font]], container: Container):
|
||||
"""Create a paragraph from content and add it to the container using proper layout"""
|
||||
if not content_list:
|
||||
return
|
||||
|
||||
# Create a paragraph object
|
||||
paragraph = Paragraph(style=content_list[0][1]) # Use first font as paragraph style
|
||||
|
||||
# Add words to the paragraph
|
||||
for text_content, font in content_list:
|
||||
words = text_content.split()
|
||||
for word_text in words:
|
||||
if word_text.strip():
|
||||
word = Word(word_text.strip(), font)
|
||||
paragraph.add_word(word)
|
||||
|
||||
# Use paragraph layout to break into lines
|
||||
layout = ParagraphLayout(
|
||||
line_width=750, # Page width minus margins
|
||||
line_height=20,
|
||||
word_spacing=(3, 8),
|
||||
line_spacing=3,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
# Layout the paragraph into lines
|
||||
lines = layout.layout_paragraph(paragraph)
|
||||
|
||||
# Add each line to the container
|
||||
for line in lines:
|
||||
container.add_child(line)
|
||||
|
||||
# Add some space after the paragraph
|
||||
spacer = Box((0, 0), (1, 5))
|
||||
container.add_child(spacer)
|
||||
|
||||
def _handle_paragraph_tag(self, token, tokens, current_index, container):
|
||||
"""Handle paragraph tags with proper text flow"""
|
||||
content_start = current_index + 1
|
||||
content_end = self._find_matching_closing_tag(tokens, current_index, 'p')
|
||||
|
||||
# Collect content within the paragraph
|
||||
paragraph_content = []
|
||||
|
||||
i = content_start
|
||||
while i < content_end:
|
||||
content_token = tokens[i]
|
||||
if content_token['type'] == 'text':
|
||||
if content_token['content'].strip():
|
||||
paragraph_content.append((content_token['content'].strip(), self.font_stack[-1]))
|
||||
elif content_token['type'] == 'tag' and not content_token['closing']:
|
||||
# Handle inline formatting within paragraph
|
||||
if content_token['name'] in ['b', 'strong', 'i', 'em', 'u', 'a']:
|
||||
i = self._handle_inline_tag_with_content(content_token, tokens, i, paragraph_content)
|
||||
continue
|
||||
i += 1
|
||||
|
||||
# Create and add the paragraph
|
||||
if paragraph_content:
|
||||
self._create_and_add_paragraph(paragraph_content, container)
|
||||
|
||||
return content_end + 1 if content_end < len(tokens) else len(tokens)
|
||||
|
||||
def _handle_header_tag(self, token, tokens, current_index, container):
|
||||
"""Handle header tags with proper styling"""
|
||||
tag_name = token['name']
|
||||
|
||||
# Push header font onto stack
|
||||
size_map = {'h1': 24, 'h2': 20, 'h3': 18, 'h4': 16, 'h5': 14, 'h6': 12}
|
||||
font = self.font_stack[-1].with_size(size_map[tag_name]).with_weight(FontWeight.BOLD)
|
||||
self.font_stack.append(font)
|
||||
|
||||
content_start = current_index + 1
|
||||
content_end = self._find_matching_closing_tag(tokens, current_index, tag_name)
|
||||
|
||||
# Collect header content
|
||||
header_content = []
|
||||
|
||||
i = content_start
|
||||
while i < content_end:
|
||||
content_token = tokens[i]
|
||||
if content_token['type'] == 'text':
|
||||
if content_token['content'].strip():
|
||||
header_content.append((content_token['content'].strip(), self.font_stack[-1]))
|
||||
elif content_token['type'] == 'tag' and not content_token['closing']:
|
||||
# Handle inline formatting within header
|
||||
if content_token['name'] in ['b', 'strong', 'i', 'em', 'u']:
|
||||
i = self._handle_inline_tag_with_content(content_token, tokens, i, header_content)
|
||||
continue
|
||||
i += 1
|
||||
|
||||
# Pop the header font
|
||||
if len(self.font_stack) > 1:
|
||||
self.font_stack.pop()
|
||||
|
||||
# Create and add the header paragraph with extra spacing
|
||||
if header_content:
|
||||
self._create_and_add_paragraph(header_content, container)
|
||||
# Add extra space after headers
|
||||
spacer = Box((0, 0), (1, 10))
|
||||
container.add_child(spacer)
|
||||
|
||||
return content_end + 1 if content_end < len(tokens) else len(tokens)
|
||||
|
||||
def _handle_inline_tag_with_content(self, token, tokens, current_index, paragraph_content):
|
||||
"""Handle inline formatting tags and collect their content"""
|
||||
tag_name = token['name']
|
||||
|
||||
# Push formatted font onto stack
|
||||
if tag_name in ['b', 'strong']:
|
||||
font = self.font_stack[-1].with_weight(FontWeight.BOLD)
|
||||
self.font_stack.append(font)
|
||||
elif tag_name in ['i', 'em']:
|
||||
font = self.font_stack[-1].with_style(FontStyle.ITALIC)
|
||||
self.font_stack.append(font)
|
||||
elif tag_name == 'u':
|
||||
font = self.font_stack[-1].with_decoration(TextDecoration.UNDERLINE)
|
||||
self.font_stack.append(font)
|
||||
elif tag_name == 'a':
|
||||
font = self.font_stack[-1].with_colour((0, 0, 255)).with_decoration(TextDecoration.UNDERLINE)
|
||||
self.font_stack.append(font)
|
||||
|
||||
content_start = current_index + 1
|
||||
content_end = self._find_matching_closing_tag(tokens, current_index, tag_name)
|
||||
|
||||
# Collect content with the formatting applied
|
||||
i = content_start
|
||||
while i < content_end:
|
||||
content_token = tokens[i]
|
||||
if content_token['type'] == 'text':
|
||||
if content_token['content'].strip():
|
||||
paragraph_content.append((content_token['content'].strip(), self.font_stack[-1]))
|
||||
elif content_token['type'] == 'tag' and not content_token['closing']:
|
||||
# Handle nested inline formatting
|
||||
if content_token['name'] in ['b', 'strong', 'i', 'em', 'u']:
|
||||
i = self._handle_inline_tag_with_content(content_token, tokens, i, paragraph_content)
|
||||
continue
|
||||
i += 1
|
||||
|
||||
# Pop the formatting font
|
||||
if len(self.font_stack) > 1:
|
||||
self.font_stack.pop()
|
||||
|
||||
return content_end + 1 if content_end < len(tokens) else len(tokens)
|
||||
|
||||
def _handle_tag_with_content(self, token, tokens, current_index, container):
|
||||
"""Handle tags and their content, returning the new index position"""
|
||||
tag_name = token['name']
|
||||
@ -517,11 +709,15 @@ class BrowserWindow:
|
||||
if hasattr(container, '_children'):
|
||||
for child in container._children:
|
||||
if hasattr(child, '_origin'):
|
||||
child_offset = (offset[0] + child._origin[0], offset[1] + child._origin[1])
|
||||
# Convert numpy arrays to tuples for consistent coordinate handling
|
||||
child_origin = tuple(child._origin) if hasattr(child._origin, '__iter__') else child._origin
|
||||
child_size = tuple(child._size) if hasattr(child._size, '__iter__') else child._size
|
||||
|
||||
child_offset = (offset[0] + child_origin[0], offset[1] + child_origin[1])
|
||||
|
||||
# Check if element is clickable
|
||||
if isinstance(child, (RenderableLink, RenderableButton)):
|
||||
elements.append((child, child_offset, child._size))
|
||||
elements.append((child, child_offset, child_size))
|
||||
|
||||
# Recursively check children
|
||||
if hasattr(child, '_children'):
|
||||
|
||||
514
pyWebLayout/typesetting/paragraph_layout.py
Normal file
514
pyWebLayout/typesetting/paragraph_layout.py
Normal file
@ -0,0 +1,514 @@
|
||||
"""
|
||||
Paragraph layout system for pyWebLayout.
|
||||
|
||||
This module provides functionality for breaking paragraphs into lines and managing
|
||||
text flow within paragraphs, including word wrapping, hyphenation, pagination,
|
||||
and state management for resumable rendering.
|
||||
"""
|
||||
|
||||
from typing import List, Tuple, Optional, Union, Dict, Any
|
||||
import json
|
||||
from dataclasses import dataclass, asdict
|
||||
from pyWebLayout.abstract.block import Paragraph
|
||||
from pyWebLayout.abstract.inline import Word, FormattedSpan
|
||||
from pyWebLayout.concrete.text import Line, RenderableWord
|
||||
from pyWebLayout.style import Font
|
||||
from pyWebLayout.style.layout import Alignment
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParagraphRenderingState:
|
||||
"""
|
||||
State information for paragraph rendering that can be saved and restored.
|
||||
|
||||
This allows for resumable rendering when paragraphs span multiple pages
|
||||
or when rendering needs to be interrupted and resumed later.
|
||||
"""
|
||||
paragraph_id: str # Unique identifier for the paragraph
|
||||
current_word_index: int = 0 # Index of the current word being processed
|
||||
current_char_index: int = 0 # Character index within the current word (for partial words)
|
||||
rendered_lines: int = 0 # Number of lines already rendered
|
||||
total_lines_estimated: int = 0 # Estimated total lines needed
|
||||
completed: bool = False # Whether paragraph rendering is complete
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert state to dictionary for serialization."""
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'ParagraphRenderingState':
|
||||
"""Create state from dictionary."""
|
||||
return cls(**data)
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Convert state to JSON string."""
|
||||
return json.dumps(self.to_dict())
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_str: str) -> 'ParagraphRenderingState':
|
||||
"""Create state from JSON string."""
|
||||
return cls.from_dict(json.loads(json_str))
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParagraphLayoutResult:
|
||||
"""
|
||||
Result of paragraph layout operation.
|
||||
|
||||
Contains the rendered lines and information about remaining content.
|
||||
"""
|
||||
lines: List[Line]
|
||||
remaining_paragraph: Optional[Paragraph] = None
|
||||
state: Optional[ParagraphRenderingState] = None
|
||||
total_height: int = 0
|
||||
is_complete: bool = True
|
||||
|
||||
|
||||
class ParagraphLayout:
|
||||
"""
|
||||
Handles the layout of paragraph content into lines.
|
||||
|
||||
This class takes a paragraph containing words and formatted spans and
|
||||
breaks it down into a series of lines that fit within specified constraints.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
line_width: int,
|
||||
line_height: int,
|
||||
word_spacing: Tuple[int, int] = (3, 8), # min, max spacing
|
||||
line_spacing: int = 2, # spacing between lines
|
||||
halign: Alignment = Alignment.LEFT,
|
||||
valign: Alignment = Alignment.CENTER
|
||||
):
|
||||
"""
|
||||
Initialize a paragraph layout manager.
|
||||
|
||||
Args:
|
||||
line_width: Maximum width for each line
|
||||
line_height: Height of each line
|
||||
word_spacing: Tuple of (min_spacing, max_spacing) between words
|
||||
line_spacing: Vertical spacing between lines
|
||||
halign: Horizontal alignment of text within lines
|
||||
valign: Vertical alignment of text within lines
|
||||
"""
|
||||
self.line_width = line_width
|
||||
self.line_height = line_height
|
||||
self.word_spacing = word_spacing
|
||||
self.line_spacing = line_spacing
|
||||
self.halign = halign
|
||||
self.valign = valign
|
||||
|
||||
def layout_paragraph(self, paragraph: Paragraph) -> List[Line]:
|
||||
"""
|
||||
Layout a paragraph into a series of lines.
|
||||
|
||||
Args:
|
||||
paragraph: The paragraph to layout
|
||||
|
||||
Returns:
|
||||
List of Line objects containing the paragraph's content
|
||||
"""
|
||||
lines = []
|
||||
|
||||
# Get all words from the paragraph (including from spans)
|
||||
all_words = self._collect_words_from_paragraph(paragraph)
|
||||
|
||||
if not all_words:
|
||||
return lines
|
||||
|
||||
# Create lines and distribute words
|
||||
current_line = None
|
||||
previous_line = None
|
||||
|
||||
for word_text, word_font in all_words:
|
||||
# Create a new line if we don't have one
|
||||
if current_line is None:
|
||||
current_line = Line(
|
||||
spacing=self.word_spacing,
|
||||
origin=(0, len(lines) * (self.line_height + self.line_spacing)),
|
||||
size=(self.line_width, self.line_height),
|
||||
font=word_font,
|
||||
halign=self.halign,
|
||||
valign=self.valign,
|
||||
previous=previous_line
|
||||
)
|
||||
|
||||
# Link the previous line to this one
|
||||
if previous_line:
|
||||
previous_line.set_next(current_line)
|
||||
|
||||
# Try to add the word to the current line
|
||||
overflow = current_line.add_word(word_text, word_font)
|
||||
|
||||
if overflow is None:
|
||||
# Word fit completely, continue with current line
|
||||
continue
|
||||
elif overflow == word_text:
|
||||
# Entire word didn't fit, need a new line
|
||||
if current_line.renderable_words:
|
||||
# Current line has content, finalize it and start a new one
|
||||
lines.append(current_line)
|
||||
previous_line = current_line
|
||||
current_line = None
|
||||
# Retry with the same word on the new line
|
||||
continue
|
||||
else:
|
||||
# Empty line and word still doesn't fit - this is handled by force-fitting
|
||||
# The add_word method should have handled this case
|
||||
continue
|
||||
else:
|
||||
# Part of the word fit, remainder is in overflow
|
||||
# Finalize current line and continue with overflow
|
||||
lines.append(current_line)
|
||||
previous_line = current_line
|
||||
current_line = None
|
||||
|
||||
# Continue with the overflow text
|
||||
word_text = overflow
|
||||
# Retry with the overflow on a new line
|
||||
continue
|
||||
|
||||
# Add the final line if it has content
|
||||
if current_line and current_line.renderable_words:
|
||||
lines.append(current_line)
|
||||
|
||||
return lines
|
||||
|
||||
def _collect_words_from_paragraph(self, paragraph: Paragraph) -> List[Tuple[str, Font]]:
|
||||
"""
|
||||
Collect all words from a paragraph, including from formatted spans.
|
||||
|
||||
Args:
|
||||
paragraph: The paragraph to collect words from
|
||||
|
||||
Returns:
|
||||
List of tuples (word_text, font) for each word in the paragraph
|
||||
"""
|
||||
all_words = []
|
||||
|
||||
# Get words directly from the paragraph
|
||||
for _, word in paragraph.words():
|
||||
all_words.append((word.text, word.style))
|
||||
|
||||
# Get words from formatted spans
|
||||
for span in paragraph.spans():
|
||||
for word in span.words:
|
||||
all_words.append((word.text, word.style))
|
||||
|
||||
return all_words
|
||||
|
||||
def calculate_paragraph_height(self, paragraph: Paragraph) -> int:
|
||||
"""
|
||||
Calculate the total height needed to render a paragraph.
|
||||
|
||||
Args:
|
||||
paragraph: The paragraph to calculate height for
|
||||
|
||||
Returns:
|
||||
Total height in pixels needed for the paragraph
|
||||
"""
|
||||
lines = self.layout_paragraph(paragraph)
|
||||
if not lines:
|
||||
return 0
|
||||
|
||||
# Height is number of lines * line height + spacing between lines
|
||||
total_height = len(lines) * self.line_height
|
||||
if len(lines) > 1:
|
||||
total_height += (len(lines) - 1) * self.line_spacing
|
||||
|
||||
return total_height
|
||||
|
||||
def get_line_at_position(self, paragraph: Paragraph, y_position: int) -> Optional[Tuple[int, Line]]:
|
||||
"""
|
||||
Get the line at a specific Y position within the paragraph.
|
||||
|
||||
Args:
|
||||
paragraph: The paragraph to query
|
||||
y_position: Y position relative to the paragraph's top
|
||||
|
||||
Returns:
|
||||
Tuple of (line_index, Line) or None if position is outside the paragraph
|
||||
"""
|
||||
lines = self.layout_paragraph(paragraph)
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
line_y = i * (self.line_height + self.line_spacing)
|
||||
if line_y <= y_position < line_y + self.line_height:
|
||||
return (i, line)
|
||||
|
||||
return None
|
||||
|
||||
def fit_paragraph_in_height(self, paragraph: Paragraph, max_height: int) -> Tuple[List[Line], Optional[Paragraph]]:
|
||||
"""
|
||||
Fit as many lines of a paragraph as possible within a given height.
|
||||
|
||||
Args:
|
||||
paragraph: The paragraph to fit
|
||||
max_height: Maximum height available
|
||||
|
||||
Returns:
|
||||
Tuple of (lines_that_fit, remaining_paragraph_or_None)
|
||||
"""
|
||||
lines = self.layout_paragraph(paragraph)
|
||||
|
||||
# Calculate how many lines fit
|
||||
lines_that_fit = []
|
||||
current_height = 0
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
line_height_needed = self.line_height
|
||||
if i > 0: # Add line spacing for all lines except the first
|
||||
line_height_needed += self.line_spacing
|
||||
|
||||
if current_height + line_height_needed <= max_height:
|
||||
lines_that_fit.append(line)
|
||||
current_height += line_height_needed
|
||||
else:
|
||||
break
|
||||
|
||||
# If all lines fit, return them with no remainder
|
||||
if len(lines_that_fit) == len(lines):
|
||||
return (lines_that_fit, None)
|
||||
|
||||
# If some lines didn't fit, create a remainder paragraph
|
||||
# This is a simplified approach - in a full implementation,
|
||||
# you'd need to track which words were rendered and create
|
||||
# a new paragraph with the remaining words
|
||||
remaining_lines = lines[len(lines_that_fit):]
|
||||
|
||||
# For now, return the fitted lines and indicate there's more content
|
||||
# A full implementation would reconstruct a paragraph from remaining words
|
||||
return (lines_that_fit, paragraph if remaining_lines else None)
|
||||
|
||||
def layout_paragraph_with_pagination(
|
||||
self,
|
||||
paragraph: Paragraph,
|
||||
max_height: int,
|
||||
state: Optional[ParagraphRenderingState] = None
|
||||
) -> ParagraphLayoutResult:
|
||||
"""
|
||||
Layout a paragraph with pagination support and state management.
|
||||
|
||||
Args:
|
||||
paragraph: The paragraph to layout
|
||||
max_height: Maximum height available for rendering
|
||||
state: Optional existing state to resume from
|
||||
|
||||
Returns:
|
||||
ParagraphLayoutResult containing lines, state, and completion info
|
||||
"""
|
||||
# Generate a unique ID for the paragraph if not already set
|
||||
paragraph_id = str(id(paragraph))
|
||||
|
||||
# Initialize or use existing state
|
||||
if state is None:
|
||||
state = ParagraphRenderingState(paragraph_id=paragraph_id)
|
||||
|
||||
# Get all words from the paragraph
|
||||
all_words = self._collect_words_from_paragraph(paragraph)
|
||||
|
||||
if not all_words:
|
||||
state.completed = True
|
||||
return ParagraphLayoutResult(
|
||||
lines=[],
|
||||
state=state,
|
||||
is_complete=True,
|
||||
total_height=0
|
||||
)
|
||||
|
||||
# Start from the current position in the state
|
||||
remaining_words = all_words[state.current_word_index:]
|
||||
|
||||
# Handle partial word if needed
|
||||
if state.current_char_index > 0 and remaining_words:
|
||||
word_text, word_font = remaining_words[0]
|
||||
partial_word = word_text[state.current_char_index:]
|
||||
remaining_words[0] = (partial_word, word_font)
|
||||
|
||||
lines = []
|
||||
current_line = None
|
||||
previous_line = None
|
||||
current_height = 0
|
||||
word_index = state.current_word_index
|
||||
|
||||
for word_text, word_font in remaining_words:
|
||||
# Create a new line if we don't have one
|
||||
if current_line is None:
|
||||
line_y = len(lines) * (self.line_height + self.line_spacing)
|
||||
current_line = Line(
|
||||
spacing=self.word_spacing,
|
||||
origin=(0, line_y),
|
||||
size=(self.line_width, self.line_height),
|
||||
font=word_font,
|
||||
halign=self.halign,
|
||||
valign=self.valign,
|
||||
previous=previous_line
|
||||
)
|
||||
|
||||
if previous_line:
|
||||
previous_line.set_next(current_line)
|
||||
|
||||
# Check if adding this line would exceed max height
|
||||
line_height_needed = self.line_height
|
||||
if lines: # Add line spacing for all lines except the first
|
||||
line_height_needed += self.line_spacing
|
||||
|
||||
if current_height + line_height_needed > max_height and lines:
|
||||
# Can't fit another line, break here
|
||||
state.current_word_index = word_index
|
||||
state.current_char_index = 0
|
||||
state.rendered_lines = len(lines)
|
||||
state.completed = False
|
||||
|
||||
return ParagraphLayoutResult(
|
||||
lines=lines,
|
||||
state=state,
|
||||
is_complete=False,
|
||||
total_height=current_height,
|
||||
remaining_paragraph=self._create_remaining_paragraph(paragraph, all_words, word_index)
|
||||
)
|
||||
|
||||
# Try to add the word to the current line
|
||||
overflow = current_line.add_word(word_text, word_font)
|
||||
|
||||
if overflow is None:
|
||||
# Word fit completely
|
||||
word_index += 1
|
||||
continue
|
||||
elif overflow == word_text:
|
||||
# Entire word didn't fit, need a new line
|
||||
if current_line.renderable_words:
|
||||
# Finalize current line and start a new one
|
||||
lines.append(current_line)
|
||||
current_height += line_height_needed
|
||||
previous_line = current_line
|
||||
current_line = None
|
||||
# Don't increment word_index, retry with same word
|
||||
continue
|
||||
else:
|
||||
# Empty line and word still doesn't fit - this should be handled by force-fitting
|
||||
word_index += 1
|
||||
continue
|
||||
else:
|
||||
# Part of the word fit, remainder is in overflow
|
||||
lines.append(current_line)
|
||||
current_height += line_height_needed
|
||||
previous_line = current_line
|
||||
current_line = None
|
||||
|
||||
# Update state to track partial word
|
||||
state.current_word_index = word_index
|
||||
state.current_char_index = len(word_text) - len(overflow)
|
||||
state.rendered_lines = len(lines)
|
||||
state.completed = False
|
||||
|
||||
return ParagraphLayoutResult(
|
||||
lines=lines,
|
||||
state=state,
|
||||
is_complete=False,
|
||||
total_height=current_height,
|
||||
remaining_paragraph=self._create_remaining_paragraph(paragraph, all_words, word_index, len(word_text) - len(overflow))
|
||||
)
|
||||
|
||||
# Add the final line if it has content
|
||||
if current_line and current_line.renderable_words:
|
||||
line_height_needed = self.line_height
|
||||
if lines:
|
||||
line_height_needed += self.line_spacing
|
||||
|
||||
# Check if we can fit the final line
|
||||
if current_height + line_height_needed <= max_height:
|
||||
lines.append(current_line)
|
||||
current_height += line_height_needed
|
||||
state.completed = True
|
||||
else:
|
||||
# Can't fit the final line
|
||||
state.current_word_index = word_index
|
||||
state.current_char_index = 0
|
||||
state.rendered_lines = len(lines)
|
||||
state.completed = False
|
||||
|
||||
return ParagraphLayoutResult(
|
||||
lines=lines,
|
||||
state=state,
|
||||
is_complete=False,
|
||||
total_height=current_height,
|
||||
remaining_paragraph=self._create_remaining_paragraph(paragraph, all_words, word_index)
|
||||
)
|
||||
|
||||
# All content fit
|
||||
state.completed = True
|
||||
state.rendered_lines = len(lines)
|
||||
|
||||
return ParagraphLayoutResult(
|
||||
lines=lines,
|
||||
state=state,
|
||||
is_complete=True,
|
||||
total_height=current_height
|
||||
)
|
||||
|
||||
def _create_remaining_paragraph(
|
||||
self,
|
||||
original: Paragraph,
|
||||
all_words: List[Tuple[str, Font]],
|
||||
start_word_index: int,
|
||||
start_char_index: int = 0
|
||||
) -> Paragraph:
|
||||
"""
|
||||
Create a new paragraph containing the remaining unrendered content.
|
||||
|
||||
Args:
|
||||
original: The original paragraph
|
||||
all_words: All words from the original paragraph
|
||||
start_word_index: Index of the first unrendered word
|
||||
start_char_index: Character index within the first unrendered word
|
||||
|
||||
Returns:
|
||||
New paragraph with remaining content
|
||||
"""
|
||||
# Create a new paragraph with the same style
|
||||
remaining_paragraph = Paragraph(style=original.style)
|
||||
|
||||
# Add remaining words
|
||||
remaining_words = all_words[start_word_index:]
|
||||
|
||||
for i, (word_text, word_font) in enumerate(remaining_words):
|
||||
# Handle partial word for the first remaining word
|
||||
if i == 0 and start_char_index > 0:
|
||||
word_text = word_text[start_char_index:]
|
||||
|
||||
if word_text: # Only add non-empty words
|
||||
word = Word(word_text, word_font)
|
||||
remaining_paragraph.add_word(word)
|
||||
|
||||
return remaining_paragraph
|
||||
|
||||
|
||||
class ParagraphRenderer:
|
||||
"""
|
||||
Renders paragraphs using the layout system.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def render_paragraph(
|
||||
paragraph: Paragraph,
|
||||
layout: ParagraphLayout,
|
||||
max_height: Optional[int] = None
|
||||
) -> Tuple[List[Line], Optional[Paragraph]]:
|
||||
"""
|
||||
Render a paragraph into lines, optionally constrained by height.
|
||||
|
||||
Args:
|
||||
paragraph: The paragraph to render
|
||||
layout: The layout manager to use
|
||||
max_height: Optional maximum height constraint
|
||||
|
||||
Returns:
|
||||
Tuple of (rendered_lines, remaining_paragraph_or_None)
|
||||
"""
|
||||
if max_height is None:
|
||||
lines = layout.layout_paragraph(paragraph)
|
||||
return (lines, None)
|
||||
else:
|
||||
return layout.fit_paragraph_in_height(paragraph, max_height)
|
||||
228
tests/test_multiline_rendering.py
Normal file
228
tests/test_multiline_rendering.py
Normal file
@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script to verify multi-line text rendering and line wrapping functionality.
|
||||
"""
|
||||
|
||||
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
|
||||
import os
|
||||
|
||||
def create_multiline_test(sentence, target_lines, line_width, line_height, font_size=14):
|
||||
"""
|
||||
Test rendering a sentence across multiple lines
|
||||
|
||||
Args:
|
||||
sentence: The sentence to render
|
||||
target_lines: Expected number of lines
|
||||
line_width: Width of each line in pixels
|
||||
line_height: Height of each line in pixels
|
||||
font_size: Font size to use
|
||||
|
||||
Returns:
|
||||
tuple: (actual_lines_used, lines_list, combined_image)
|
||||
"""
|
||||
font_style = Font(
|
||||
font_path=None,
|
||||
font_size=font_size,
|
||||
colour=(0, 0, 0, 255)
|
||||
)
|
||||
|
||||
# Split sentence into words
|
||||
words = sentence.split()
|
||||
|
||||
# Create lines and distribute words
|
||||
lines = []
|
||||
current_line = None
|
||||
words_remaining = words.copy()
|
||||
|
||||
while words_remaining:
|
||||
# Create a new line
|
||||
current_line = Line(
|
||||
spacing=(3, 8), # min, max spacing
|
||||
origin=(0, len(lines) * 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]
|
||||
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)
|
||||
else:
|
||||
# Word didn't fit, try next line
|
||||
break
|
||||
|
||||
# If no words were added to this line, we have a problem
|
||||
if not words_added_to_line:
|
||||
print(f"ERROR: Word '{words_remaining[0]}' is too long for line width {line_width}")
|
||||
break
|
||||
|
||||
# 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 subtle line 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)
|
||||
|
||||
return len(lines), lines, combined_image
|
||||
|
||||
def test_sentence_wrapping():
|
||||
"""Test various sentences with different expected line counts"""
|
||||
|
||||
test_cases = [
|
||||
{
|
||||
"sentence": "This is a simple test sentence that should wrap to exactly two lines.",
|
||||
"expected_lines": 2,
|
||||
"line_width": 200,
|
||||
"description": "Two-line sentence"
|
||||
},
|
||||
{
|
||||
"sentence": "This is a much longer sentence that contains many more words and should definitely wrap across three lines when rendered with the specified width constraints.",
|
||||
"expected_lines": 3,
|
||||
"line_width": 180,
|
||||
"description": "Three-line sentence"
|
||||
},
|
||||
{
|
||||
"sentence": "Here we have an even longer sentence with significantly more content that will require four lines to properly display all the text when using the constrained width setting.",
|
||||
"expected_lines": 4,
|
||||
"line_width": 160,
|
||||
"description": "Four-line sentence"
|
||||
},
|
||||
{
|
||||
"sentence": "Short sentence.",
|
||||
"expected_lines": 1,
|
||||
"line_width": 300,
|
||||
"description": "Single line sentence"
|
||||
},
|
||||
{
|
||||
"sentence": "This sentence has some really long words like supercalifragilisticexpialidocious that might need hyphenation.",
|
||||
"expected_lines": 3,
|
||||
"line_width": 150,
|
||||
"description": "Sentence with long words"
|
||||
}
|
||||
]
|
||||
|
||||
print("Testing multi-line sentence rendering...\n")
|
||||
|
||||
results = []
|
||||
|
||||
for i, test_case in enumerate(test_cases):
|
||||
sentence = test_case["sentence"]
|
||||
expected_lines = test_case["expected_lines"]
|
||||
line_width = test_case["line_width"]
|
||||
description = test_case["description"]
|
||||
|
||||
print(f"Test {i+1}: {description}")
|
||||
print(f" Sentence: \"{sentence}\"")
|
||||
print(f" Expected lines: {expected_lines}")
|
||||
print(f" Line width: {line_width}px")
|
||||
|
||||
# Run the test
|
||||
actual_lines, lines, combined_image = create_multiline_test(
|
||||
sentence, expected_lines, line_width, 25, font_size=12
|
||||
)
|
||||
|
||||
print(f" Actual lines: {actual_lines}")
|
||||
|
||||
# Show word distribution
|
||||
for j, line in enumerate(lines):
|
||||
words_in_line = [word.word.text for word in line.renderable_words]
|
||||
print(f" Line {j+1}: {' '.join(words_in_line)}")
|
||||
|
||||
# Save the result
|
||||
output_filename = f"test_multiline_{i+1}_{description.lower().replace(' ', '_').replace('-', '_')}.png"
|
||||
combined_image.save(output_filename)
|
||||
print(f" Saved as: {output_filename}")
|
||||
|
||||
# Check if it matches expectations
|
||||
if actual_lines == expected_lines:
|
||||
print(f" ✓ SUCCESS: Got expected {expected_lines} lines")
|
||||
else:
|
||||
print(f" ✗ MISMATCH: Expected {expected_lines} lines, got {actual_lines}")
|
||||
|
||||
results.append({
|
||||
"test": description,
|
||||
"expected": expected_lines,
|
||||
"actual": actual_lines,
|
||||
"success": actual_lines == expected_lines,
|
||||
"filename": output_filename
|
||||
})
|
||||
|
||||
print()
|
||||
|
||||
# Summary
|
||||
print("="*60)
|
||||
print("SUMMARY")
|
||||
print("="*60)
|
||||
|
||||
successful_tests = sum(1 for r in results if r["success"])
|
||||
total_tests = len(results)
|
||||
|
||||
print(f"Tests passed: {successful_tests}/{total_tests}")
|
||||
print()
|
||||
|
||||
for result in results:
|
||||
status = "✓ PASS" if result["success"] else "✗ FAIL"
|
||||
print(f"{status} {result['test']}: {result['actual']}/{result['expected']} lines ({result['filename']})")
|
||||
|
||||
return results
|
||||
|
||||
def test_fixed_width_scenarios():
|
||||
"""Test specific width scenarios to verify line utilization"""
|
||||
print("\n" + "="*60)
|
||||
print("TESTING FIXED WIDTH SCENARIOS")
|
||||
print("="*60)
|
||||
|
||||
# Test with progressively narrower widths
|
||||
sentence = "The quick brown fox jumps over the lazy dog near the riverbank."
|
||||
widths = [300, 200, 150, 100, 80]
|
||||
|
||||
for width in widths:
|
||||
print(f"\nTesting width: {width}px")
|
||||
actual_lines, lines, combined_image = create_multiline_test(
|
||||
sentence, None, width, 20, font_size=12
|
||||
)
|
||||
|
||||
# Calculate utilization
|
||||
for j, line in enumerate(lines):
|
||||
words_in_line = [word.word.text for word in line.renderable_words]
|
||||
line_text = ' '.join(words_in_line)
|
||||
utilization = (line._current_width / width) * 100
|
||||
print(f" Line {j+1}: \"{line_text}\" (width: {line._current_width}/{width}px, {utilization:.1f}% utilization)")
|
||||
|
||||
output_filename = f"test_width_{width}px.png"
|
||||
combined_image.save(output_filename)
|
||||
print(f" Saved as: {output_filename}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Running multi-line text rendering verification tests...\n")
|
||||
|
||||
# Test sentence wrapping
|
||||
results = test_sentence_wrapping()
|
||||
|
||||
# Test fixed width scenarios
|
||||
test_fixed_width_scenarios()
|
||||
|
||||
print(f"\nAll tests completed. Check the generated PNG files for visual verification.")
|
||||
print("Look for:")
|
||||
print("- Proper line wrapping at expected breakpoints")
|
||||
print("- Good utilization of available line width")
|
||||
print("- No text cropping at line boundaries")
|
||||
print("- Proper word spacing and alignment")
|
||||
Loading…
x
Reference in New Issue
Block a user