Coverage for pyWebLayout/layout/document_layouter.py: 77%
219 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-08 20:34 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-08 20:34 +0000
1from __future__ import annotations
3from typing import List, Tuple, Optional, Union
4import numpy as np
6from pyWebLayout.concrete import Page, Line, Text
7from pyWebLayout.concrete.image import RenderableImage
8from pyWebLayout.concrete.functional import ButtonText, FormFieldText
9from pyWebLayout.concrete.table import TableRenderer, TableStyle
10from pyWebLayout.abstract import Paragraph, Word
11from pyWebLayout.abstract.block import Image as AbstractImage, Heading, PageBreak, Table
12from pyWebLayout.abstract.functional import Button, Form, FormField
13from pyWebLayout.style.concrete_style import ConcreteStyleRegistry, RenderingContext, StyleResolver
14from pyWebLayout.style import Font, Alignment
17def paragraph_layouter(paragraph: Paragraph,
18 page: Page,
19 start_word: int = 0,
20 pretext: Optional[Text] = None,
21 alignment_override: Optional['Alignment'] = None) -> Tuple[bool,
22 Optional[int],
23 Optional[Text]]:
24 """
25 Layout a paragraph of text within a given page.
27 This function extracts word spacing constraints from the style system
28 and uses them to create properly spaced lines of text.
30 Args:
31 paragraph: The paragraph to layout
32 page: The page to layout the paragraph on
33 start_word: Index of the first word to process (for continuation)
34 pretext: Optional pretext from a previous hyphenated word
35 alignment_override: Optional alignment to override the paragraph's default alignment
37 Returns:
38 Tuple of:
39 - bool: True if paragraph was completely laid out, False if page ran out of space
40 - Optional[int]: Index of first word that didn't fit (if any)
41 - Optional[Text]: Remaining pretext if word was hyphenated (if any)
42 """
43 if not paragraph.words:
44 return True, None, None
46 # Validate inputs
47 if start_word >= len(paragraph.words):
48 return True, None, None
50 # paragraph.style is already a Font object (concrete), not AbstractStyle
51 # We need to get word spacing constraints from the Font's abstract style if available
52 # For now, use reasonable defaults based on font size
54 # Alignment for text that does not specify its own. Headings are never
55 # justified - stretching a two-word title across the measure is always wrong -
56 # so they fall back to flush left.
57 default_alignment = getattr(page.style, 'default_alignment', None)
58 if not isinstance(default_alignment, Alignment):
59 default_alignment = Alignment.JUSTIFY
60 if isinstance(paragraph, Heading):
61 default_alignment = Alignment.LEFT
63 if isinstance(paragraph.style, Font):
64 # paragraph.style is already a Font (concrete style)
65 font = paragraph.style
66 # Use default word spacing constraints based on font size
67 # Minimum spacing should be proportional to font size for better readability
68 min_spacing = float(font.font_size) * 0.25 # 25% of font size
69 max_spacing = float(font.font_size) * 0.5 # 50% of font size
70 word_spacing_constraints = (int(min_spacing), int(max_spacing))
71 text_align = default_alignment
72 else:
73 # paragraph.style is an AbstractStyle, resolve it
74 # Ensure font_size is an int (it could be a FontSize enum)
75 from pyWebLayout.style.abstract_style import FontSize
76 if isinstance(paragraph.style.font_size, FontSize): 76 ↛ 80line 76 didn't jump to line 80 because the condition on line 76 was always true
77 # Use a default base font size, the resolver will handle the semantic size
78 base_font_size = 16
79 else:
80 base_font_size = int(paragraph.style.font_size)
82 rendering_context = RenderingContext(base_font_size=base_font_size)
83 style_resolver = StyleResolver(rendering_context)
84 style_registry = ConcreteStyleRegistry(style_resolver)
85 concrete_style = style_registry.get_concrete_style(paragraph.style)
86 font = concrete_style.create_font()
87 word_spacing_constraints = (
88 int(concrete_style.word_spacing_min),
89 int(concrete_style.word_spacing_max)
90 )
91 # text_align is None when the source did not specify one.
92 text_align = concrete_style.text_align or default_alignment
94 # Apply page-level word spacing override if specified
95 if hasattr( 95 ↛ 101line 95 didn't jump to line 101 because the condition on line 95 was never true
96 page.style,
97 'word_spacing') and isinstance(
98 page.style.word_spacing,
99 int) and page.style.word_spacing > 0:
100 # Add the page-level word spacing to both min and max constraints
101 min_ws, max_ws = word_spacing_constraints
102 word_spacing_constraints = (
103 min_ws + page.style.word_spacing,
104 max_ws + page.style.word_spacing
105 )
107 # Apply alignment override if provided
108 if alignment_override is not None:
109 text_align = alignment_override
111 # Cap font size to page maximum if needed
112 if font.font_size > page.style.max_font_size: 112 ↛ 114line 112 didn't jump to line 114 because the condition on line 112 was never true
113 # Use paragraph's font registry to create the capped font
114 if hasattr(paragraph, 'get_or_create_font'):
115 font = paragraph.get_or_create_font(
116 font_path=font._font_path,
117 font_size=page.style.max_font_size,
118 colour=font.colour,
119 weight=font.weight,
120 style=font.style,
121 decoration=font.decoration,
122 background=font.background
123 )
124 else:
125 # Fallback to direct creation (will still use global cache)
126 font = Font(
127 font_path=font._font_path,
128 font_size=page.style.max_font_size,
129 colour=font.colour,
130 weight=font.weight,
131 style=font.style,
132 decoration=font.decoration,
133 background=font.background
134 )
136 # Calculate baseline-to-baseline spacing: font size + additional line spacing
137 # This is the vertical distance between baselines of consecutive lines
138 # Formula: baseline_spacing = font_size + line_spacing (absolute pixels)
139 line_spacing_value = getattr(page.style, 'line_spacing', 5)
140 # Ensure line_spacing is an int (could be Mock in tests)
141 if not isinstance(line_spacing_value, int):
142 line_spacing_value = 5
143 baseline_spacing = font.font_size + line_spacing_value
145 # Get font metrics for boundary checking
146 ascent, descent = font.font.getmetrics()
148 def create_new_line(word: Optional[Union[Word, Text]] = None,
149 is_first_line: bool = False) -> Optional[Line]:
150 """Helper function to create a new line, returns None if page is full."""
151 # Check if this line's baseline and descenders would fit on the page
152 if not page.can_fit_line(baseline_spacing, ascent, descent):
153 return None
155 # For the first line, position it so text starts at the top boundary
156 # For subsequent lines, use current y_offset which tracks
157 # baseline-to-baseline spacing
158 if is_first_line: 158 ↛ 161line 158 didn't jump to line 161 because the condition on line 158 was never true
159 # Position line origin so that baseline (origin + ascent) is close to top
160 # We want minimal space above the text, so origin should be at boundary
161 y_cursor = page._current_y_offset
162 else:
163 y_cursor = page._current_y_offset
164 x_cursor = page.content_origin[0]
166 # `word` is accepted for call-site readability only: the line that is about
167 # to be created measures it when it is added, so measuring it here as well
168 # only paid for a Text object that was immediately discarded.
170 return Line(
171 spacing=word_spacing_constraints,
172 origin=(x_cursor, y_cursor),
173 size=(page.available_width, baseline_spacing),
174 draw=page.measurement_draw,
175 font=font,
176 halign=text_align
177 )
179 # Create initial line
180 current_line = create_new_line()
181 if not current_line:
182 return False, start_word, pretext
184 page.add_child(current_line)
185 # Note: add_child already updates _current_y_offset based on child's origin and size
186 # No need to manually increment it here
188 # Track current position in paragraph
189 current_pretext = pretext
191 # Process words starting from start_word
192 for i, word in enumerate(paragraph.words[start_word:], start=start_word):
193 # Check if this is a LinkedWord and needs special handling in concrete layer
194 # Note: The Line.add_word method will create Text objects internally,
195 # but we may want to create LinkText for LinkedWord instances in future
196 # For now, the abstract layer (LinkedWord) carries the link info,
197 # and the concrete layer (LinkText) would be created during rendering
199 success, overflow_text = current_line.add_word(word, current_pretext)
201 if success:
202 # Word fit successfully
203 if overflow_text is not None:
204 # If there's overflow text, we need to start a new line with it
205 current_pretext = overflow_text
206 current_line = create_new_line(overflow_text)
207 if not current_line:
208 # If we can't create a new line, return with the current state
209 return False, i, overflow_text
210 page.add_child(current_line)
211 # Note: add_child already updates _current_y_offset
212 # Continue to the next word
213 continue
214 else:
215 # No overflow, clear pretext
216 current_pretext = None
217 else:
218 # Word didn't fit, need a new line
219 current_line = create_new_line(word)
220 if not current_line:
221 # Page is full, return current position
222 return False, i, overflow_text
224 # Check if the word will fit on the new line before adding it
225 temp_text = Text.from_word(word, page.measurement_draw)
226 if temp_text.width > current_line.size[0]:
227 # Word is too wide for the line, we need to hyphenate it
228 if len(word.text) >= 6: 228 ↛ 250line 228 didn't jump to line 250 because the condition on line 228 was always true
229 # Try to hyphenate the word
230 splits = [
231 (Text(
232 pair[0],
233 word.style,
234 page.measurement_draw,
235 line=current_line,
236 source=word),
237 Text(
238 pair[1],
239 word.style,
240 page.measurement_draw,
241 line=current_line,
242 source=word)) for pair in word.possible_hyphenation()]
243 if len(splits) > 0: 243 ↛ 250line 243 didn't jump to line 250 because the condition on line 243 was always true
244 # Use the first hyphenation point
245 first_part, second_part = splits[0]
246 current_line.add_word(word, first_part)
247 current_pretext = second_part
248 continue
250 page.add_child(current_line)
251 # Note: add_child already updates _current_y_offset
253 # Try to add the word to the new line
254 success, overflow_text = current_line.add_word(word, current_pretext)
256 if not success: 256 ↛ 259line 256 didn't jump to line 259 because the condition on line 256 was never true
257 # Word still doesn't fit even on a new line
258 # This might happen with very long words or narrow pages
259 if overflow_text:
260 # Word was hyphenated, continue with the overflow
261 current_pretext = overflow_text
262 continue
263 else:
264 # Word cannot be broken, skip it or handle as error
265 # For now, we'll return indicating we couldn't process this word
266 return False, i, None
267 else:
268 current_pretext = overflow_text # May be None or hyphenated remainder
270 # All words processed successfully. The line holding the final word is the
271 # end of the paragraph, so it is rendered at its natural width rather than
272 # justified to the full column. A paragraph continued on the next page does
273 # not reach here, so its lines stay justified - which is correct.
274 if current_line is not None: 274 ↛ 277line 274 didn't jump to line 277 because the condition on line 274 was always true
275 current_line.is_paragraph_end = True
277 return True, None, None
280def pagebreak_layouter(page_break: PageBreak, page: Page) -> bool:
281 """
282 Handle a page break element.
284 A page break signals that all subsequent content should start on a new page.
285 This function always returns False to indicate that the current page is complete
286 and a new page should be created for subsequent content.
288 Args:
289 page_break: The PageBreak block
290 page: The current page (not used, but kept for consistency)
292 Returns:
293 bool: Always False to force creation of a new page
294 """
295 # Page break always forces a new page
296 return False
299def image_layouter(image: AbstractImage, page: Page, max_width: Optional[int] = None,
300 max_height: Optional[int] = None) -> bool:
301 """
302 Layout an image within a given page.
304 This function places an image on the page, respecting size constraints
305 and available space. Images are centered horizontally by default.
307 Args:
308 image: The abstract Image object to layout
309 page: The page to layout the image on
310 max_width: Maximum width constraint (defaults to page available width)
311 max_height: Maximum height constraint (defaults to remaining page height)
313 Returns:
314 bool: True if image was successfully laid out, False if page ran out of space
315 """
316 # Use page available width if max_width not specified
317 if max_width is None:
318 max_width = page.available_width
320 # Calculate available height on page
321 available_height = page.remaining_height
323 # If no space available, image doesn't fit
324 if available_height <= 0: 324 ↛ 325line 324 didn't jump to line 325 because the condition on line 324 was never true
325 return False
327 if max_height is None:
328 max_height = available_height
329 else:
330 max_height = min(max_height, available_height)
332 # Calculate scaled dimensions
333 scaled_width, scaled_height = image.calculate_scaled_dimensions(
334 max_width, max_height)
336 # Check if image fits on current page
337 if scaled_height is None or scaled_height > available_height:
338 return False
340 # Create renderable image
341 x_offset = page.content_origin[0]
342 y_offset = page._current_y_offset
344 # Access page.draw to ensure canvas is initialized
345 _ = page.draw
347 renderable_image = RenderableImage(
348 image=image,
349 canvas=page._canvas,
350 max_width=max_width,
351 max_height=max_height,
352 origin=(x_offset, y_offset),
353 size=(scaled_width or max_width, scaled_height or max_height),
354 halign=Alignment.CENTER,
355 valign=Alignment.TOP
356 )
358 # Add to page
359 page.add_child(renderable_image)
361 return True
364def table_layouter(
365 table: Table,
366 page: Page,
367 style: Optional[TableStyle] = None) -> bool:
368 """
369 Layout a table within a given page.
371 This function uses the TableRenderer to render the table at the current
372 page position, advancing the page's y-offset after successful rendering.
374 Args:
375 table: The abstract Table object to layout
376 page: The page to layout the table on
377 style: Optional table styling configuration
379 Returns:
380 bool: True if table was successfully laid out, False if page ran out of space
381 """
382 # Calculate available space
383 available_width = page.available_width
384 x_offset = page.content_origin[0]
385 y_offset = page._current_y_offset
387 # Access page.draw to ensure canvas is initialized
388 draw = page.draw
389 canvas = page._canvas
391 # Create table renderer
392 origin = (x_offset, y_offset)
393 renderer = TableRenderer(
394 table=table,
395 origin=origin,
396 available_width=available_width,
397 draw=draw,
398 style=style,
399 canvas=canvas
400 )
402 # Check if table fits on current page
403 table_height = renderer.size[1]
404 available_height = page.remaining_height
406 if table_height > available_height:
407 return False
409 # Render the table
410 renderer.render()
412 # Update page y-offset
413 page._current_y_offset = y_offset + table_height
415 return True
418def button_layouter(button: Button,
419 page: Page,
420 font: Optional[Font] = None,
421 padding: Tuple[int,
422 int,
423 int,
424 int] = (4,
425 8,
426 4,
427 8)) -> Tuple[bool,
428 str]:
429 """
430 Layout a button within a given page and register it for callback binding.
432 This function creates a ButtonText renderable, positions it on the page,
433 and registers it in the page's callback registry using the button's html_id
434 (if available) or an auto-generated id.
436 Args:
437 button: The abstract Button object to layout
438 page: The page to layout the button on
439 font: Optional font for button text (defaults to page default)
440 padding: Padding around button text (top, right, bottom, left)
442 Returns:
443 Tuple of:
444 - bool: True if button was successfully laid out, False if page ran out of space
445 - str: The id used to register the button in the callback registry
446 """
447 # Use provided font or create a default one
448 if font is None:
449 font = Font(font_size=14, colour=(255, 255, 255))
451 # Calculate available space
452 available_height = page.remaining_height
454 # Create ButtonText renderable
455 button_text = ButtonText(button, font, page.measurement_draw, padding=padding)
457 # Check if button fits on current page
458 button_height = button_text.size[1]
459 if button_height > available_height:
460 return False, ""
462 # Position the button
463 x_offset = page.content_origin[0]
464 y_offset = page._current_y_offset
466 button_text.set_origin(np.array([x_offset, y_offset]))
468 # Register in callback registry
469 html_id = button.html_id
470 registered_id = page.callbacks.register(button_text, html_id=html_id)
472 # Add to page
473 page.add_child(button_text)
475 return True, registered_id
478def form_field_layouter(field: FormField, page: Page, font: Optional[Font] = None,
479 field_height: int = 24) -> Tuple[bool, str]:
480 """
481 Layout a form field within a given page and register it for callback binding.
483 This function creates a FormFieldText renderable, positions it on the page,
484 and registers it in the page's callback registry.
486 Args:
487 field: The abstract FormField object to layout
488 page: The page to layout the field on
489 font: Optional font for field label (defaults to page default)
490 field_height: Height of the input field area
492 Returns:
493 Tuple of:
494 - bool: True if field was successfully laid out, False if page ran out of space
495 - str: The id used to register the field in the callback registry
496 """
497 # Use provided font or create a default one
498 if font is None: 498 ↛ 499line 498 didn't jump to line 499 because the condition on line 498 was never true
499 font = Font(font_size=12, colour=(0, 0, 0))
501 # Calculate available space
502 available_height = page.remaining_height
504 # Create FormFieldText renderable
505 field_text = FormFieldText(field, font, page.measurement_draw,
506 field_height=field_height)
508 # Check if field fits on current page
509 total_field_height = field_text.size[1]
510 if total_field_height > available_height: 510 ↛ 511line 510 didn't jump to line 511 because the condition on line 510 was never true
511 return False, ""
513 # Position the field
514 x_offset = page.content_origin[0]
515 y_offset = page._current_y_offset
517 field_text.set_origin(np.array([x_offset, y_offset]))
519 # Register in callback registry (use field name as html_id fallback)
520 html_id = getattr(field, '_html_id', None) or field.name
521 registered_id = page.callbacks.register(field_text, html_id=html_id)
523 # Add to page
524 page.add_child(field_text)
526 return True, registered_id
529def form_layouter(form: Form, page: Page, font: Optional[Font] = None,
530 field_spacing: int = 10) -> Tuple[bool, List[str]]:
531 """
532 Layout a complete form with all its fields within a given page.
534 This function creates FormFieldText renderables for all fields in the form,
535 positions them vertically, and registers both the form and its fields in
536 the page's callback registry.
538 Args:
539 form: The abstract Form object to layout
540 page: The page to layout the form on
541 font: Optional font for field labels (defaults to page default)
542 field_spacing: Vertical spacing between fields in pixels
544 Returns:
545 Tuple of:
546 - bool: True if form was successfully laid out, False if page ran out of space
547 - List[str]: List of registered ids for all fields (empty if layout failed)
548 """
549 # Use provided font or create a default one
550 if font is None: 550 ↛ 551line 550 didn't jump to line 551 because the condition on line 550 was never true
551 font = Font(font_size=12, colour=(0, 0, 0))
553 # Track registered field ids
554 field_ids = []
556 # Layout each field in the form
557 for field_name, field in form._fields.items():
558 # Add spacing before each field (except the first)
559 if field_ids:
560 page._current_y_offset += field_spacing
562 # Layout the field
563 success, field_id = form_field_layouter(field, page, font)
565 if not success: 565 ↛ 567line 565 didn't jump to line 567 because the condition on line 565 was never true
566 # Couldn't fit this field, return failure
567 return False, []
569 field_ids.append(field_id)
571 # Register the form itself (optional, for form submission)
572 # Note: The form doesn't have a visual representation, but we can track it
573 # for submission callbacks
574 # form_id = page.callbacks.register(form, html_id=form.html_id)
576 return True, field_ids
579class DocumentLayouter:
580 """
581 Document layouter that orchestrates layout of various abstract elements.
583 Delegates to specialized layouters for different content types:
584 - paragraph_layouter for text paragraphs
585 - image_layouter for images
586 - table_layouter for tables
588 This class acts as a coordinator, managing the overall document flow
589 and page context while delegating specific layout tasks to specialized
590 layouter functions.
591 """
593 def __init__(self, page: Page):
594 """
595 Initialize the document layouter with a page.
597 Args:
598 page: The page to layout content on
599 """
600 self.page = page
601 # Create a style resolver if page doesn't have one
602 if hasattr(page, 'style_resolver'):
603 style_resolver = page.style_resolver
604 else:
605 # Create a default rendering context and style resolver
606 from pyWebLayout.style.concrete_style import RenderingContext
607 context = RenderingContext()
608 style_resolver = StyleResolver(context)
609 self.style_registry = ConcreteStyleRegistry(style_resolver)
611 def layout_paragraph(self,
612 paragraph: Paragraph,
613 start_word: int = 0,
614 pretext: Optional[Text] = None) -> Tuple[bool,
615 Optional[int],
616 Optional[Text]]:
617 """
618 Layout a paragraph using the paragraph_layouter.
620 Args:
621 paragraph: The paragraph to layout
622 start_word: Index of the first word to process (for continuation)
623 pretext: Optional pretext from a previous hyphenated word
625 Returns:
626 Tuple of (success, failed_word_index, remaining_pretext)
627 """
628 return paragraph_layouter(paragraph, self.page, start_word, pretext)
630 def layout_image(self, image: AbstractImage, max_width: Optional[int] = None,
631 max_height: Optional[int] = None) -> bool:
632 """
633 Layout an image using the image_layouter.
635 Args:
636 image: The abstract Image object to layout
637 max_width: Maximum width constraint (defaults to page available width)
638 max_height: Maximum height constraint (defaults to remaining page height)
640 Returns:
641 bool: True if image was successfully laid out, False if page ran out of space
642 """
643 return image_layouter(image, self.page, max_width, max_height)
645 def layout_table(self, table: Table, style: Optional[TableStyle] = None) -> bool:
646 """
647 Layout a table using the table_layouter.
649 Args:
650 table: The abstract Table object to layout
651 style: Optional table styling configuration
653 Returns:
654 bool: True if table was successfully laid out, False if page ran out of space
655 """
656 return table_layouter(table, self.page, style)
658 def layout_button(self,
659 button: Button,
660 font: Optional[Font] = None,
661 padding: Tuple[int,
662 int,
663 int,
664 int] = (4,
665 8,
666 4,
667 8)) -> Tuple[bool,
668 str]:
669 """
670 Layout a button using the button_layouter.
672 Args:
673 button: The abstract Button object to layout
674 font: Optional font for button text
675 padding: Padding around button text
677 Returns:
678 Tuple of (success, registered_id)
679 """
680 return button_layouter(button, self.page, font, padding)
682 def layout_form(self, form: Form, font: Optional[Font] = None,
683 field_spacing: int = 10) -> Tuple[bool, List[str]]:
684 """
685 Layout a form using the form_layouter.
687 Args:
688 form: The abstract Form object to layout
689 font: Optional font for field labels
690 field_spacing: Vertical spacing between fields
692 Returns:
693 Tuple of (success, list_of_field_ids)
694 """
695 return form_layouter(form, self.page, font, field_spacing)
697 def layout_document(
698 self, elements: List[Union[Paragraph, AbstractImage, Table, Button, Form]]) -> bool:
699 """
700 Layout a list of abstract elements (paragraphs, images, tables, buttons, and forms).
702 This method delegates to specialized layouters based on element type:
703 - Paragraphs are handled by layout_paragraph
704 - Images are handled by layout_image
705 - Tables are handled by layout_table
706 - Buttons are handled by layout_button
707 - Forms are handled by layout_form
709 Args:
710 elements: List of abstract elements to layout
712 Returns:
713 True if all elements were successfully laid out, False otherwise
714 """
715 for element in elements:
716 if isinstance(element, Paragraph):
717 success, _, _ = self.layout_paragraph(element)
718 if not success:
719 return False
720 elif isinstance(element, AbstractImage):
721 success = self.layout_image(element)
722 if not success: 722 ↛ 723line 722 didn't jump to line 723 because the condition on line 722 was never true
723 return False
724 elif isinstance(element, Table): 724 ↛ 728line 724 didn't jump to line 728 because the condition on line 724 was always true
725 success = self.layout_table(element)
726 if not success:
727 return False
728 elif isinstance(element, Button):
729 success, _ = self.layout_button(element)
730 if not success:
731 return False
732 elif isinstance(element, Form):
733 success, _ = self.layout_form(element)
734 if not success:
735 return False
736 # Future: elif isinstance(element, CodeBlock): use code_layouter
737 return True