diff --git a/html_browser.py b/html_browser.py
index 80baf2a..7517d52 100644
--- a/html_browser.py
+++ b/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,21 +89,210 @@ 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"""
@@ -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'):
diff --git a/pyWebLayout/typesetting/paragraph_layout.py b/pyWebLayout/typesetting/paragraph_layout.py
new file mode 100644
index 0000000..6929f1f
--- /dev/null
+++ b/pyWebLayout/typesetting/paragraph_layout.py
@@ -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)
diff --git a/tests/test_multiline_rendering.py b/tests/test_multiline_rendering.py
new file mode 100644
index 0000000..1ec702e
--- /dev/null
+++ b/tests/test_multiline_rendering.py
@@ -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")