This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user