add_child invalidated the canvas but left _draw bound to it, and the draw property only rebuilt when _draw was None. Callers therefore got a context pointing at a discarded image while page._canvas stayed None. table_layouter reads page._canvas directly, so every image inside a table cell laid out after any other content silently degraded to a grey [Image: WxH] placeholder. The property now rebuilds when either half is missing. On its own that would make layout allocate a full-page RGBA canvas per line, because layout measures text through the page - so measurement moves to page.measurement_draw, a 1x1 scratch context that is never invalidated. Its mode matches the render canvas so that Text's width cache does not hold two entries per word. Children built against the scratch context are re-bound to the live canvas by render_children, which already synchronised _draw and _canvas; that behaviour was incidental and is now load-bearing and documented as such. Regenerating the examples shows table images rendering as images rather than placeholders. The empty header row in the second table of example 05 is unrelated and pre-existing - row height ignores cell padding, so text is clipped as padding grows - recorded as evidence under S6.
741 lines
28 KiB
Python
741 lines
28 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import List, Tuple, Optional, Union
|
|
import numpy as np
|
|
|
|
from pyWebLayout.concrete import Page, Line, Text
|
|
from pyWebLayout.concrete.image import RenderableImage
|
|
from pyWebLayout.concrete.functional import ButtonText, FormFieldText
|
|
from pyWebLayout.concrete.table import TableRenderer, TableStyle
|
|
from pyWebLayout.abstract import Paragraph, Word
|
|
from pyWebLayout.abstract.block import Image as AbstractImage, Heading, PageBreak, Table
|
|
from pyWebLayout.abstract.functional import Button, Form, FormField
|
|
from pyWebLayout.style.concrete_style import ConcreteStyleRegistry, RenderingContext, StyleResolver
|
|
from pyWebLayout.style import Font, Alignment
|
|
|
|
|
|
def paragraph_layouter(paragraph: Paragraph,
|
|
page: Page,
|
|
start_word: int = 0,
|
|
pretext: Optional[Text] = None,
|
|
alignment_override: Optional['Alignment'] = None) -> Tuple[bool,
|
|
Optional[int],
|
|
Optional[Text]]:
|
|
"""
|
|
Layout a paragraph of text within a given page.
|
|
|
|
This function extracts word spacing constraints from the style system
|
|
and uses them to create properly spaced lines of text.
|
|
|
|
Args:
|
|
paragraph: The paragraph to layout
|
|
page: The page to layout the paragraph on
|
|
start_word: Index of the first word to process (for continuation)
|
|
pretext: Optional pretext from a previous hyphenated word
|
|
alignment_override: Optional alignment to override the paragraph's default alignment
|
|
|
|
Returns:
|
|
Tuple of:
|
|
- bool: True if paragraph was completely laid out, False if page ran out of space
|
|
- Optional[int]: Index of first word that didn't fit (if any)
|
|
- Optional[Text]: Remaining pretext if word was hyphenated (if any)
|
|
"""
|
|
if not paragraph.words:
|
|
return True, None, None
|
|
|
|
# Validate inputs
|
|
if start_word >= len(paragraph.words):
|
|
return True, None, None
|
|
|
|
# paragraph.style is already a Font object (concrete), not AbstractStyle
|
|
# We need to get word spacing constraints from the Font's abstract style if available
|
|
# For now, use reasonable defaults based on font size
|
|
|
|
# Alignment for text that does not specify its own. Headings are never
|
|
# justified - stretching a two-word title across the measure is always wrong -
|
|
# so they fall back to flush left.
|
|
default_alignment = getattr(page.style, 'default_alignment', None)
|
|
if not isinstance(default_alignment, Alignment):
|
|
default_alignment = Alignment.JUSTIFY
|
|
if isinstance(paragraph, Heading):
|
|
default_alignment = Alignment.LEFT
|
|
|
|
if isinstance(paragraph.style, Font):
|
|
# paragraph.style is already a Font (concrete style)
|
|
font = paragraph.style
|
|
# Use default word spacing constraints based on font size
|
|
# Minimum spacing should be proportional to font size for better readability
|
|
min_spacing = float(font.font_size) * 0.25 # 25% of font size
|
|
max_spacing = float(font.font_size) * 0.5 # 50% of font size
|
|
word_spacing_constraints = (int(min_spacing), int(max_spacing))
|
|
text_align = default_alignment
|
|
else:
|
|
# paragraph.style is an AbstractStyle, resolve it
|
|
# Ensure font_size is an int (it could be a FontSize enum)
|
|
from pyWebLayout.style.abstract_style import FontSize
|
|
if isinstance(paragraph.style.font_size, FontSize):
|
|
# Use a default base font size, the resolver will handle the semantic size
|
|
base_font_size = 16
|
|
else:
|
|
base_font_size = int(paragraph.style.font_size)
|
|
|
|
rendering_context = RenderingContext(base_font_size=base_font_size)
|
|
style_resolver = StyleResolver(rendering_context)
|
|
style_registry = ConcreteStyleRegistry(style_resolver)
|
|
concrete_style = style_registry.get_concrete_style(paragraph.style)
|
|
font = concrete_style.create_font()
|
|
word_spacing_constraints = (
|
|
int(concrete_style.word_spacing_min),
|
|
int(concrete_style.word_spacing_max)
|
|
)
|
|
# text_align is None when the source did not specify one.
|
|
text_align = concrete_style.text_align or default_alignment
|
|
|
|
# Apply page-level word spacing override if specified
|
|
if hasattr(
|
|
page.style,
|
|
'word_spacing') and isinstance(
|
|
page.style.word_spacing,
|
|
int) and page.style.word_spacing > 0:
|
|
# Add the page-level word spacing to both min and max constraints
|
|
min_ws, max_ws = word_spacing_constraints
|
|
word_spacing_constraints = (
|
|
min_ws + page.style.word_spacing,
|
|
max_ws + page.style.word_spacing
|
|
)
|
|
|
|
# Apply alignment override if provided
|
|
if alignment_override is not None:
|
|
text_align = alignment_override
|
|
|
|
# Cap font size to page maximum if needed
|
|
if font.font_size > page.style.max_font_size:
|
|
# Use paragraph's font registry to create the capped font
|
|
if hasattr(paragraph, 'get_or_create_font'):
|
|
font = paragraph.get_or_create_font(
|
|
font_path=font._font_path,
|
|
font_size=page.style.max_font_size,
|
|
colour=font.colour,
|
|
weight=font.weight,
|
|
style=font.style,
|
|
decoration=font.decoration,
|
|
background=font.background
|
|
)
|
|
else:
|
|
# Fallback to direct creation (will still use global cache)
|
|
font = Font(
|
|
font_path=font._font_path,
|
|
font_size=page.style.max_font_size,
|
|
colour=font.colour,
|
|
weight=font.weight,
|
|
style=font.style,
|
|
decoration=font.decoration,
|
|
background=font.background
|
|
)
|
|
|
|
# Calculate baseline-to-baseline spacing: font size + additional line spacing
|
|
# This is the vertical distance between baselines of consecutive lines
|
|
# Formula: baseline_spacing = font_size + line_spacing (absolute pixels)
|
|
line_spacing_value = getattr(page.style, 'line_spacing', 5)
|
|
# Ensure line_spacing is an int (could be Mock in tests)
|
|
if not isinstance(line_spacing_value, int):
|
|
line_spacing_value = 5
|
|
baseline_spacing = font.font_size + line_spacing_value
|
|
|
|
# Get font metrics for boundary checking
|
|
ascent, descent = font.font.getmetrics()
|
|
|
|
def create_new_line(word: Optional[Union[Word, Text]] = None,
|
|
is_first_line: bool = False) -> Optional[Line]:
|
|
"""Helper function to create a new line, returns None if page is full."""
|
|
# Check if this line's baseline and descenders would fit on the page
|
|
if not page.can_fit_line(baseline_spacing, ascent, descent):
|
|
return None
|
|
|
|
# For the first line, position it so text starts at the top boundary
|
|
# For subsequent lines, use current y_offset which tracks
|
|
# baseline-to-baseline spacing
|
|
if is_first_line:
|
|
# Position line origin so that baseline (origin + ascent) is close to top
|
|
# We want minimal space above the text, so origin should be at boundary
|
|
y_cursor = page._current_y_offset
|
|
else:
|
|
y_cursor = page._current_y_offset
|
|
x_cursor = page.content_origin[0]
|
|
|
|
# Create a temporary Text object to calculate word width
|
|
if word:
|
|
temp_text = Text.from_word(word, page.measurement_draw)
|
|
temp_text.width
|
|
else:
|
|
pass
|
|
|
|
return Line(
|
|
spacing=word_spacing_constraints,
|
|
origin=(x_cursor, y_cursor),
|
|
size=(page.available_width, baseline_spacing),
|
|
draw=page.measurement_draw,
|
|
font=font,
|
|
halign=text_align
|
|
)
|
|
|
|
# Create initial line
|
|
current_line = create_new_line()
|
|
if not current_line:
|
|
return False, start_word, pretext
|
|
|
|
page.add_child(current_line)
|
|
# Note: add_child already updates _current_y_offset based on child's origin and size
|
|
# No need to manually increment it here
|
|
|
|
# Track current position in paragraph
|
|
current_pretext = pretext
|
|
|
|
# Process words starting from start_word
|
|
for i, word in enumerate(paragraph.words[start_word:], start=start_word):
|
|
# Check if this is a LinkedWord and needs special handling in concrete layer
|
|
# Note: The Line.add_word method will create Text objects internally,
|
|
# but we may want to create LinkText for LinkedWord instances in future
|
|
# For now, the abstract layer (LinkedWord) carries the link info,
|
|
# and the concrete layer (LinkText) would be created during rendering
|
|
|
|
success, overflow_text = current_line.add_word(word, current_pretext)
|
|
|
|
if success:
|
|
# Word fit successfully
|
|
if overflow_text is not None:
|
|
# If there's overflow text, we need to start a new line with it
|
|
current_pretext = overflow_text
|
|
current_line = create_new_line(overflow_text)
|
|
if not current_line:
|
|
# If we can't create a new line, return with the current state
|
|
return False, i, overflow_text
|
|
page.add_child(current_line)
|
|
# Note: add_child already updates _current_y_offset
|
|
# Continue to the next word
|
|
continue
|
|
else:
|
|
# No overflow, clear pretext
|
|
current_pretext = None
|
|
else:
|
|
# Word didn't fit, need a new line
|
|
current_line = create_new_line(word)
|
|
if not current_line:
|
|
# Page is full, return current position
|
|
return False, i, overflow_text
|
|
|
|
# Check if the word will fit on the new line before adding it
|
|
temp_text = Text.from_word(word, page.measurement_draw)
|
|
if temp_text.width > current_line.size[0]:
|
|
# Word is too wide for the line, we need to hyphenate it
|
|
if len(word.text) >= 6:
|
|
# Try to hyphenate the word
|
|
splits = [
|
|
(Text(
|
|
pair[0],
|
|
word.style,
|
|
page.measurement_draw,
|
|
line=current_line,
|
|
source=word),
|
|
Text(
|
|
pair[1],
|
|
word.style,
|
|
page.measurement_draw,
|
|
line=current_line,
|
|
source=word)) for pair in word.possible_hyphenation()]
|
|
if len(splits) > 0:
|
|
# Use the first hyphenation point
|
|
first_part, second_part = splits[0]
|
|
current_line.add_word(word, first_part)
|
|
current_pretext = second_part
|
|
continue
|
|
|
|
page.add_child(current_line)
|
|
# Note: add_child already updates _current_y_offset
|
|
|
|
# Try to add the word to the new line
|
|
success, overflow_text = current_line.add_word(word, current_pretext)
|
|
|
|
if not success:
|
|
# Word still doesn't fit even on a new line
|
|
# This might happen with very long words or narrow pages
|
|
if overflow_text:
|
|
# Word was hyphenated, continue with the overflow
|
|
current_pretext = overflow_text
|
|
continue
|
|
else:
|
|
# Word cannot be broken, skip it or handle as error
|
|
# For now, we'll return indicating we couldn't process this word
|
|
return False, i, None
|
|
else:
|
|
current_pretext = overflow_text # May be None or hyphenated remainder
|
|
|
|
# All words processed successfully. The line holding the final word is the
|
|
# end of the paragraph, so it is rendered at its natural width rather than
|
|
# justified to the full column. A paragraph continued on the next page does
|
|
# not reach here, so its lines stay justified - which is correct.
|
|
if current_line is not None:
|
|
current_line.is_paragraph_end = True
|
|
|
|
return True, None, None
|
|
|
|
|
|
def pagebreak_layouter(page_break: PageBreak, page: Page) -> bool:
|
|
"""
|
|
Handle a page break element.
|
|
|
|
A page break signals that all subsequent content should start on a new page.
|
|
This function always returns False to indicate that the current page is complete
|
|
and a new page should be created for subsequent content.
|
|
|
|
Args:
|
|
page_break: The PageBreak block
|
|
page: The current page (not used, but kept for consistency)
|
|
|
|
Returns:
|
|
bool: Always False to force creation of a new page
|
|
"""
|
|
# Page break always forces a new page
|
|
return False
|
|
|
|
|
|
def image_layouter(image: AbstractImage, page: Page, max_width: Optional[int] = None,
|
|
max_height: Optional[int] = None) -> bool:
|
|
"""
|
|
Layout an image within a given page.
|
|
|
|
This function places an image on the page, respecting size constraints
|
|
and available space. Images are centered horizontally by default.
|
|
|
|
Args:
|
|
image: The abstract Image object to layout
|
|
page: The page to layout the image on
|
|
max_width: Maximum width constraint (defaults to page available width)
|
|
max_height: Maximum height constraint (defaults to remaining page height)
|
|
|
|
Returns:
|
|
bool: True if image was successfully laid out, False if page ran out of space
|
|
"""
|
|
# Use page available width if max_width not specified
|
|
if max_width is None:
|
|
max_width = page.available_width
|
|
|
|
# Calculate available height on page
|
|
available_height = page.remaining_height
|
|
|
|
# If no space available, image doesn't fit
|
|
if available_height <= 0:
|
|
return False
|
|
|
|
if max_height is None:
|
|
max_height = available_height
|
|
else:
|
|
max_height = min(max_height, available_height)
|
|
|
|
# Calculate scaled dimensions
|
|
scaled_width, scaled_height = image.calculate_scaled_dimensions(
|
|
max_width, max_height)
|
|
|
|
# Check if image fits on current page
|
|
if scaled_height is None or scaled_height > available_height:
|
|
return False
|
|
|
|
# Create renderable image
|
|
x_offset = page.content_origin[0]
|
|
y_offset = page._current_y_offset
|
|
|
|
# Access page.draw to ensure canvas is initialized
|
|
_ = page.draw
|
|
|
|
renderable_image = RenderableImage(
|
|
image=image,
|
|
canvas=page._canvas,
|
|
max_width=max_width,
|
|
max_height=max_height,
|
|
origin=(x_offset, y_offset),
|
|
size=(scaled_width or max_width, scaled_height or max_height),
|
|
halign=Alignment.CENTER,
|
|
valign=Alignment.TOP
|
|
)
|
|
|
|
# Add to page
|
|
page.add_child(renderable_image)
|
|
|
|
return True
|
|
|
|
|
|
def table_layouter(
|
|
table: Table,
|
|
page: Page,
|
|
style: Optional[TableStyle] = None) -> bool:
|
|
"""
|
|
Layout a table within a given page.
|
|
|
|
This function uses the TableRenderer to render the table at the current
|
|
page position, advancing the page's y-offset after successful rendering.
|
|
|
|
Args:
|
|
table: The abstract Table object to layout
|
|
page: The page to layout the table on
|
|
style: Optional table styling configuration
|
|
|
|
Returns:
|
|
bool: True if table was successfully laid out, False if page ran out of space
|
|
"""
|
|
# Calculate available space
|
|
available_width = page.available_width
|
|
x_offset = page.content_origin[0]
|
|
y_offset = page._current_y_offset
|
|
|
|
# Access page.draw to ensure canvas is initialized
|
|
draw = page.draw
|
|
canvas = page._canvas
|
|
|
|
# Create table renderer
|
|
origin = (x_offset, y_offset)
|
|
renderer = TableRenderer(
|
|
table=table,
|
|
origin=origin,
|
|
available_width=available_width,
|
|
draw=draw,
|
|
style=style,
|
|
canvas=canvas
|
|
)
|
|
|
|
# Check if table fits on current page
|
|
table_height = renderer.size[1]
|
|
available_height = page.remaining_height
|
|
|
|
if table_height > available_height:
|
|
return False
|
|
|
|
# Render the table
|
|
renderer.render()
|
|
|
|
# Update page y-offset
|
|
page._current_y_offset = y_offset + table_height
|
|
|
|
return True
|
|
|
|
|
|
def button_layouter(button: Button,
|
|
page: Page,
|
|
font: Optional[Font] = None,
|
|
padding: Tuple[int,
|
|
int,
|
|
int,
|
|
int] = (4,
|
|
8,
|
|
4,
|
|
8)) -> Tuple[bool,
|
|
str]:
|
|
"""
|
|
Layout a button within a given page and register it for callback binding.
|
|
|
|
This function creates a ButtonText renderable, positions it on the page,
|
|
and registers it in the page's callback registry using the button's html_id
|
|
(if available) or an auto-generated id.
|
|
|
|
Args:
|
|
button: The abstract Button object to layout
|
|
page: The page to layout the button on
|
|
font: Optional font for button text (defaults to page default)
|
|
padding: Padding around button text (top, right, bottom, left)
|
|
|
|
Returns:
|
|
Tuple of:
|
|
- bool: True if button was successfully laid out, False if page ran out of space
|
|
- str: The id used to register the button in the callback registry
|
|
"""
|
|
# Use provided font or create a default one
|
|
if font is None:
|
|
font = Font(font_size=14, colour=(255, 255, 255))
|
|
|
|
# Calculate available space
|
|
available_height = page.remaining_height
|
|
|
|
# Create ButtonText renderable
|
|
button_text = ButtonText(button, font, page.measurement_draw, padding=padding)
|
|
|
|
# Check if button fits on current page
|
|
button_height = button_text.size[1]
|
|
if button_height > available_height:
|
|
return False, ""
|
|
|
|
# Position the button
|
|
x_offset = page.content_origin[0]
|
|
y_offset = page._current_y_offset
|
|
|
|
button_text.set_origin(np.array([x_offset, y_offset]))
|
|
|
|
# Register in callback registry
|
|
html_id = button.html_id
|
|
registered_id = page.callbacks.register(button_text, html_id=html_id)
|
|
|
|
# Add to page
|
|
page.add_child(button_text)
|
|
|
|
return True, registered_id
|
|
|
|
|
|
def form_field_layouter(field: FormField, page: Page, font: Optional[Font] = None,
|
|
field_height: int = 24) -> Tuple[bool, str]:
|
|
"""
|
|
Layout a form field within a given page and register it for callback binding.
|
|
|
|
This function creates a FormFieldText renderable, positions it on the page,
|
|
and registers it in the page's callback registry.
|
|
|
|
Args:
|
|
field: The abstract FormField object to layout
|
|
page: The page to layout the field on
|
|
font: Optional font for field label (defaults to page default)
|
|
field_height: Height of the input field area
|
|
|
|
Returns:
|
|
Tuple of:
|
|
- bool: True if field was successfully laid out, False if page ran out of space
|
|
- str: The id used to register the field in the callback registry
|
|
"""
|
|
# Use provided font or create a default one
|
|
if font is None:
|
|
font = Font(font_size=12, colour=(0, 0, 0))
|
|
|
|
# Calculate available space
|
|
available_height = page.remaining_height
|
|
|
|
# Create FormFieldText renderable
|
|
field_text = FormFieldText(field, font, page.measurement_draw,
|
|
field_height=field_height)
|
|
|
|
# Check if field fits on current page
|
|
total_field_height = field_text.size[1]
|
|
if total_field_height > available_height:
|
|
return False, ""
|
|
|
|
# Position the field
|
|
x_offset = page.content_origin[0]
|
|
y_offset = page._current_y_offset
|
|
|
|
field_text.set_origin(np.array([x_offset, y_offset]))
|
|
|
|
# Register in callback registry (use field name as html_id fallback)
|
|
html_id = getattr(field, '_html_id', None) or field.name
|
|
registered_id = page.callbacks.register(field_text, html_id=html_id)
|
|
|
|
# Add to page
|
|
page.add_child(field_text)
|
|
|
|
return True, registered_id
|
|
|
|
|
|
def form_layouter(form: Form, page: Page, font: Optional[Font] = None,
|
|
field_spacing: int = 10) -> Tuple[bool, List[str]]:
|
|
"""
|
|
Layout a complete form with all its fields within a given page.
|
|
|
|
This function creates FormFieldText renderables for all fields in the form,
|
|
positions them vertically, and registers both the form and its fields in
|
|
the page's callback registry.
|
|
|
|
Args:
|
|
form: The abstract Form object to layout
|
|
page: The page to layout the form on
|
|
font: Optional font for field labels (defaults to page default)
|
|
field_spacing: Vertical spacing between fields in pixels
|
|
|
|
Returns:
|
|
Tuple of:
|
|
- bool: True if form was successfully laid out, False if page ran out of space
|
|
- List[str]: List of registered ids for all fields (empty if layout failed)
|
|
"""
|
|
# Use provided font or create a default one
|
|
if font is None:
|
|
font = Font(font_size=12, colour=(0, 0, 0))
|
|
|
|
# Track registered field ids
|
|
field_ids = []
|
|
|
|
# Layout each field in the form
|
|
for field_name, field in form._fields.items():
|
|
# Add spacing before each field (except the first)
|
|
if field_ids:
|
|
page._current_y_offset += field_spacing
|
|
|
|
# Layout the field
|
|
success, field_id = form_field_layouter(field, page, font)
|
|
|
|
if not success:
|
|
# Couldn't fit this field, return failure
|
|
return False, []
|
|
|
|
field_ids.append(field_id)
|
|
|
|
# Register the form itself (optional, for form submission)
|
|
# Note: The form doesn't have a visual representation, but we can track it
|
|
# for submission callbacks
|
|
# form_id = page.callbacks.register(form, html_id=form.html_id)
|
|
|
|
return True, field_ids
|
|
|
|
|
|
class DocumentLayouter:
|
|
"""
|
|
Document layouter that orchestrates layout of various abstract elements.
|
|
|
|
Delegates to specialized layouters for different content types:
|
|
- paragraph_layouter for text paragraphs
|
|
- image_layouter for images
|
|
- table_layouter for tables
|
|
|
|
This class acts as a coordinator, managing the overall document flow
|
|
and page context while delegating specific layout tasks to specialized
|
|
layouter functions.
|
|
"""
|
|
|
|
def __init__(self, page: Page):
|
|
"""
|
|
Initialize the document layouter with a page.
|
|
|
|
Args:
|
|
page: The page to layout content on
|
|
"""
|
|
self.page = page
|
|
# Create a style resolver if page doesn't have one
|
|
if hasattr(page, 'style_resolver'):
|
|
style_resolver = page.style_resolver
|
|
else:
|
|
# Create a default rendering context and style resolver
|
|
from pyWebLayout.style.concrete_style import RenderingContext
|
|
context = RenderingContext()
|
|
style_resolver = StyleResolver(context)
|
|
self.style_registry = ConcreteStyleRegistry(style_resolver)
|
|
|
|
def layout_paragraph(self,
|
|
paragraph: Paragraph,
|
|
start_word: int = 0,
|
|
pretext: Optional[Text] = None) -> Tuple[bool,
|
|
Optional[int],
|
|
Optional[Text]]:
|
|
"""
|
|
Layout a paragraph using the paragraph_layouter.
|
|
|
|
Args:
|
|
paragraph: The paragraph to layout
|
|
start_word: Index of the first word to process (for continuation)
|
|
pretext: Optional pretext from a previous hyphenated word
|
|
|
|
Returns:
|
|
Tuple of (success, failed_word_index, remaining_pretext)
|
|
"""
|
|
return paragraph_layouter(paragraph, self.page, start_word, pretext)
|
|
|
|
def layout_image(self, image: AbstractImage, max_width: Optional[int] = None,
|
|
max_height: Optional[int] = None) -> bool:
|
|
"""
|
|
Layout an image using the image_layouter.
|
|
|
|
Args:
|
|
image: The abstract Image object to layout
|
|
max_width: Maximum width constraint (defaults to page available width)
|
|
max_height: Maximum height constraint (defaults to remaining page height)
|
|
|
|
Returns:
|
|
bool: True if image was successfully laid out, False if page ran out of space
|
|
"""
|
|
return image_layouter(image, self.page, max_width, max_height)
|
|
|
|
def layout_table(self, table: Table, style: Optional[TableStyle] = None) -> bool:
|
|
"""
|
|
Layout a table using the table_layouter.
|
|
|
|
Args:
|
|
table: The abstract Table object to layout
|
|
style: Optional table styling configuration
|
|
|
|
Returns:
|
|
bool: True if table was successfully laid out, False if page ran out of space
|
|
"""
|
|
return table_layouter(table, self.page, style)
|
|
|
|
def layout_button(self,
|
|
button: Button,
|
|
font: Optional[Font] = None,
|
|
padding: Tuple[int,
|
|
int,
|
|
int,
|
|
int] = (4,
|
|
8,
|
|
4,
|
|
8)) -> Tuple[bool,
|
|
str]:
|
|
"""
|
|
Layout a button using the button_layouter.
|
|
|
|
Args:
|
|
button: The abstract Button object to layout
|
|
font: Optional font for button text
|
|
padding: Padding around button text
|
|
|
|
Returns:
|
|
Tuple of (success, registered_id)
|
|
"""
|
|
return button_layouter(button, self.page, font, padding)
|
|
|
|
def layout_form(self, form: Form, font: Optional[Font] = None,
|
|
field_spacing: int = 10) -> Tuple[bool, List[str]]:
|
|
"""
|
|
Layout a form using the form_layouter.
|
|
|
|
Args:
|
|
form: The abstract Form object to layout
|
|
font: Optional font for field labels
|
|
field_spacing: Vertical spacing between fields
|
|
|
|
Returns:
|
|
Tuple of (success, list_of_field_ids)
|
|
"""
|
|
return form_layouter(form, self.page, font, field_spacing)
|
|
|
|
def layout_document(
|
|
self, elements: List[Union[Paragraph, AbstractImage, Table, Button, Form]]) -> bool:
|
|
"""
|
|
Layout a list of abstract elements (paragraphs, images, tables, buttons, and forms).
|
|
|
|
This method delegates to specialized layouters based on element type:
|
|
- Paragraphs are handled by layout_paragraph
|
|
- Images are handled by layout_image
|
|
- Tables are handled by layout_table
|
|
- Buttons are handled by layout_button
|
|
- Forms are handled by layout_form
|
|
|
|
Args:
|
|
elements: List of abstract elements to layout
|
|
|
|
Returns:
|
|
True if all elements were successfully laid out, False otherwise
|
|
"""
|
|
for element in elements:
|
|
if isinstance(element, Paragraph):
|
|
success, _, _ = self.layout_paragraph(element)
|
|
if not success:
|
|
return False
|
|
elif isinstance(element, AbstractImage):
|
|
success = self.layout_image(element)
|
|
if not success:
|
|
return False
|
|
elif isinstance(element, Table):
|
|
success = self.layout_table(element)
|
|
if not success:
|
|
return False
|
|
elif isinstance(element, Button):
|
|
success, _ = self.layout_button(element)
|
|
if not success:
|
|
return False
|
|
elif isinstance(element, Form):
|
|
success, _ = self.layout_form(element)
|
|
if not success:
|
|
return False
|
|
# Future: elif isinstance(element, CodeBlock): use code_layouter
|
|
return True
|