Coverage for pyWebLayout/layout/document_layouter.py: 77%
216 statements
« prev ^ index » next coverage.py v7.11.2, created at 2025-11-12 12:02 +0000
« prev ^ index » next coverage.py v7.11.2, created at 2025-11-12 12:02 +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, 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 if isinstance(paragraph.style, Font):
55 # paragraph.style is already a Font (concrete style)
56 font = paragraph.style
57 # Use default word spacing constraints based on font size
58 # Minimum spacing should be proportional to font size for better readability
59 min_spacing = float(font.font_size) * 0.25 # 25% of font size
60 max_spacing = float(font.font_size) * 0.5 # 50% of font size
61 word_spacing_constraints = (int(min_spacing), int(max_spacing))
62 text_align = Alignment.LEFT # Default alignment
63 else:
64 # paragraph.style is an AbstractStyle, resolve it
65 # Ensure font_size is an int (it could be a FontSize enum)
66 from pyWebLayout.style.abstract_style import FontSize
67 if isinstance(paragraph.style.font_size, FontSize): 67 ↛ 71line 67 didn't jump to line 71 because the condition on line 67 was always true
68 # Use a default base font size, the resolver will handle the semantic size
69 base_font_size = 16
70 else:
71 base_font_size = int(paragraph.style.font_size)
73 rendering_context = RenderingContext(base_font_size=base_font_size)
74 style_resolver = StyleResolver(rendering_context)
75 style_registry = ConcreteStyleRegistry(style_resolver)
76 concrete_style = style_registry.get_concrete_style(paragraph.style)
77 font = concrete_style.create_font()
78 word_spacing_constraints = (
79 int(concrete_style.word_spacing_min),
80 int(concrete_style.word_spacing_max)
81 )
82 text_align = concrete_style.text_align
84 # Apply page-level word spacing override if specified
85 if hasattr( 85 ↛ 91line 85 didn't jump to line 91 because the condition on line 85 was never true
86 page.style,
87 'word_spacing') and isinstance(
88 page.style.word_spacing,
89 int) and page.style.word_spacing > 0:
90 # Add the page-level word spacing to both min and max constraints
91 min_ws, max_ws = word_spacing_constraints
92 word_spacing_constraints = (
93 min_ws + page.style.word_spacing,
94 max_ws + page.style.word_spacing
95 )
97 # Apply alignment override if provided
98 if alignment_override is not None: 98 ↛ 99line 98 didn't jump to line 99 because the condition on line 98 was never true
99 text_align = alignment_override
101 # Cap font size to page maximum if needed
102 if font.font_size > page.style.max_font_size: 102 ↛ 104line 102 didn't jump to line 104 because the condition on line 102 was never true
103 # Use paragraph's font registry to create the capped font
104 if hasattr(paragraph, 'get_or_create_font'):
105 font = paragraph.get_or_create_font(
106 font_path=font._font_path,
107 font_size=page.style.max_font_size,
108 colour=font.colour,
109 weight=font.weight,
110 style=font.style,
111 decoration=font.decoration,
112 background=font.background
113 )
114 else:
115 # Fallback to direct creation (will still use global cache)
116 font = Font(
117 font_path=font._font_path,
118 font_size=page.style.max_font_size,
119 colour=font.colour,
120 weight=font.weight,
121 style=font.style,
122 decoration=font.decoration,
123 background=font.background
124 )
126 # Calculate baseline-to-baseline spacing: font size + additional line spacing
127 # This is the vertical distance between baselines of consecutive lines
128 # Formula: baseline_spacing = font_size + line_spacing (absolute pixels)
129 line_spacing_value = getattr(page.style, 'line_spacing', 5)
130 # Ensure line_spacing is an int (could be Mock in tests)
131 if not isinstance(line_spacing_value, int):
132 line_spacing_value = 5
133 baseline_spacing = font.font_size + line_spacing_value
135 # Get font metrics for boundary checking
136 ascent, descent = font.font.getmetrics()
138 def create_new_line(word: Optional[Union[Word, Text]] = None,
139 is_first_line: bool = False) -> Optional[Line]:
140 """Helper function to create a new line, returns None if page is full."""
141 # Check if this line's baseline and descenders would fit on the page
142 if not page.can_fit_line(baseline_spacing, ascent, descent):
143 return None
145 # For the first line, position it so text starts at the top boundary
146 # For subsequent lines, use current y_offset which tracks
147 # baseline-to-baseline spacing
148 if is_first_line: 148 ↛ 151line 148 didn't jump to line 151 because the condition on line 148 was never true
149 # Position line origin so that baseline (origin + ascent) is close to top
150 # We want minimal space above the text, so origin should be at boundary
151 y_cursor = page._current_y_offset
152 else:
153 y_cursor = page._current_y_offset
154 x_cursor = page.border_size
156 # Create a temporary Text object to calculate word width
157 if word:
158 temp_text = Text.from_word(word, page.draw)
159 temp_text.width
160 else:
161 pass
163 return Line(
164 spacing=word_spacing_constraints,
165 origin=(x_cursor, y_cursor),
166 size=(page.available_width, baseline_spacing),
167 draw=page.draw,
168 font=font,
169 halign=text_align
170 )
172 # Create initial line
173 current_line = create_new_line()
174 if not current_line:
175 return False, start_word, pretext
177 page.add_child(current_line)
178 # Note: add_child already updates _current_y_offset based on child's origin and size
179 # No need to manually increment it here
181 # Track current position in paragraph
182 current_pretext = pretext
184 # Process words starting from start_word
185 for i, word in enumerate(paragraph.words[start_word:], start=start_word):
186 # Check if this is a LinkedWord and needs special handling in concrete layer
187 # Note: The Line.add_word method will create Text objects internally,
188 # but we may want to create LinkText for LinkedWord instances in future
189 # For now, the abstract layer (LinkedWord) carries the link info,
190 # and the concrete layer (LinkText) would be created during rendering
192 success, overflow_text = current_line.add_word(word, current_pretext)
194 if success:
195 # Word fit successfully
196 if overflow_text is not None:
197 # If there's overflow text, we need to start a new line with it
198 current_pretext = overflow_text
199 current_line = create_new_line(overflow_text)
200 if not current_line:
201 # If we can't create a new line, return with the current state
202 return False, i, overflow_text
203 page.add_child(current_line)
204 # Note: add_child already updates _current_y_offset
205 # Continue to the next word
206 continue
207 else:
208 # No overflow, clear pretext
209 current_pretext = None
210 else:
211 # Word didn't fit, need a new line
212 current_line = create_new_line(word)
213 if not current_line:
214 # Page is full, return current position
215 return False, i, overflow_text
217 # Check if the word will fit on the new line before adding it
218 temp_text = Text.from_word(word, page.draw)
219 if temp_text.width > current_line.size[0]:
220 # Word is too wide for the line, we need to hyphenate it
221 if len(word.text) >= 6: 221 ↛ 243line 221 didn't jump to line 243 because the condition on line 221 was always true
222 # Try to hyphenate the word
223 splits = [
224 (Text(
225 pair[0],
226 word.style,
227 page.draw,
228 line=current_line,
229 source=word),
230 Text(
231 pair[1],
232 word.style,
233 page.draw,
234 line=current_line,
235 source=word)) for pair in word.possible_hyphenation()]
236 if len(splits) > 0: 236 ↛ 243line 236 didn't jump to line 243 because the condition on line 236 was always true
237 # Use the first hyphenation point
238 first_part, second_part = splits[0]
239 current_line.add_word(word, first_part)
240 current_pretext = second_part
241 continue
243 page.add_child(current_line)
244 # Note: add_child already updates _current_y_offset
246 # Try to add the word to the new line
247 success, overflow_text = current_line.add_word(word, current_pretext)
249 if not success: 249 ↛ 252line 249 didn't jump to line 252 because the condition on line 249 was never true
250 # Word still doesn't fit even on a new line
251 # This might happen with very long words or narrow pages
252 if overflow_text:
253 # Word was hyphenated, continue with the overflow
254 current_pretext = overflow_text
255 continue
256 else:
257 # Word cannot be broken, skip it or handle as error
258 # For now, we'll return indicating we couldn't process this word
259 return False, i, None
260 else:
261 current_pretext = overflow_text # May be None or hyphenated remainder
263 # All words processed successfully
264 return True, None, None
267def pagebreak_layouter(page_break: PageBreak, page: Page) -> bool:
268 """
269 Handle a page break element.
271 A page break signals that all subsequent content should start on a new page.
272 This function always returns False to indicate that the current page is complete
273 and a new page should be created for subsequent content.
275 Args:
276 page_break: The PageBreak block
277 page: The current page (not used, but kept for consistency)
279 Returns:
280 bool: Always False to force creation of a new page
281 """
282 # Page break always forces a new page
283 return False
286def image_layouter(image: AbstractImage, page: Page, max_width: Optional[int] = None,
287 max_height: Optional[int] = None) -> bool:
288 """
289 Layout an image within a given page.
291 This function places an image on the page, respecting size constraints
292 and available space. Images are centered horizontally by default.
294 Args:
295 image: The abstract Image object to layout
296 page: The page to layout the image on
297 max_width: Maximum width constraint (defaults to page available width)
298 max_height: Maximum height constraint (defaults to remaining page height)
300 Returns:
301 bool: True if image was successfully laid out, False if page ran out of space
302 """
303 # Use page available width if max_width not specified
304 if max_width is None:
305 max_width = page.available_width
307 # Calculate available height on page
308 available_height = page.size[1] - page._current_y_offset - page.border_size
310 # If no space available, image doesn't fit
311 if available_height <= 0: 311 ↛ 312line 311 didn't jump to line 312 because the condition on line 311 was never true
312 return False
314 if max_height is None:
315 max_height = available_height
316 else:
317 max_height = min(max_height, available_height)
319 # Calculate scaled dimensions
320 scaled_width, scaled_height = image.calculate_scaled_dimensions(
321 max_width, max_height)
323 # Check if image fits on current page
324 if scaled_height is None or scaled_height > available_height:
325 return False
327 # Create renderable image
328 x_offset = page.border_size
329 y_offset = page._current_y_offset
331 # Access page.draw to ensure canvas is initialized
332 _ = page.draw
334 renderable_image = RenderableImage(
335 image=image,
336 canvas=page._canvas,
337 max_width=max_width,
338 max_height=max_height,
339 origin=(x_offset, y_offset),
340 size=(scaled_width or max_width, scaled_height or max_height),
341 halign=Alignment.CENTER,
342 valign=Alignment.TOP
343 )
345 # Add to page
346 page.add_child(renderable_image)
348 return True
351def table_layouter(
352 table: Table,
353 page: Page,
354 style: Optional[TableStyle] = None) -> bool:
355 """
356 Layout a table within a given page.
358 This function uses the TableRenderer to render the table at the current
359 page position, advancing the page's y-offset after successful rendering.
361 Args:
362 table: The abstract Table object to layout
363 page: The page to layout the table on
364 style: Optional table styling configuration
366 Returns:
367 bool: True if table was successfully laid out, False if page ran out of space
368 """
369 # Calculate available space
370 available_width = page.available_width
371 x_offset = page.border_size
372 y_offset = page._current_y_offset
374 # Access page.draw to ensure canvas is initialized
375 draw = page.draw
376 canvas = page._canvas
378 # Create table renderer
379 origin = (x_offset, y_offset)
380 renderer = TableRenderer(
381 table=table,
382 origin=origin,
383 available_width=available_width,
384 draw=draw,
385 style=style,
386 canvas=canvas
387 )
389 # Check if table fits on current page
390 table_height = renderer.size[1]
391 available_height = page.size[1] - y_offset - page.border_size
393 if table_height > available_height:
394 return False
396 # Render the table
397 renderer.render()
399 # Update page y-offset
400 page._current_y_offset = y_offset + table_height
402 return True
405def button_layouter(button: Button,
406 page: Page,
407 font: Optional[Font] = None,
408 padding: Tuple[int,
409 int,
410 int,
411 int] = (4,
412 8,
413 4,
414 8)) -> Tuple[bool,
415 str]:
416 """
417 Layout a button within a given page and register it for callback binding.
419 This function creates a ButtonText renderable, positions it on the page,
420 and registers it in the page's callback registry using the button's html_id
421 (if available) or an auto-generated id.
423 Args:
424 button: The abstract Button object to layout
425 page: The page to layout the button on
426 font: Optional font for button text (defaults to page default)
427 padding: Padding around button text (top, right, bottom, left)
429 Returns:
430 Tuple of:
431 - bool: True if button was successfully laid out, False if page ran out of space
432 - str: The id used to register the button in the callback registry
433 """
434 # Use provided font or create a default one
435 if font is None:
436 font = Font(font_size=14, colour=(255, 255, 255))
438 # Calculate available space
439 available_height = page.size[1] - page._current_y_offset - page.border_size
441 # Create ButtonText renderable
442 button_text = ButtonText(button, font, page.draw, padding=padding)
444 # Check if button fits on current page
445 button_height = button_text.size[1]
446 if button_height > available_height:
447 return False, ""
449 # Position the button
450 x_offset = page.border_size
451 y_offset = page._current_y_offset
453 button_text.set_origin(np.array([x_offset, y_offset]))
455 # Register in callback registry
456 html_id = button.html_id
457 registered_id = page.callbacks.register(button_text, html_id=html_id)
459 # Add to page
460 page.add_child(button_text)
462 return True, registered_id
465def form_field_layouter(field: FormField, page: Page, font: Optional[Font] = None,
466 field_height: int = 24) -> Tuple[bool, str]:
467 """
468 Layout a form field within a given page and register it for callback binding.
470 This function creates a FormFieldText renderable, positions it on the page,
471 and registers it in the page's callback registry.
473 Args:
474 field: The abstract FormField object to layout
475 page: The page to layout the field on
476 font: Optional font for field label (defaults to page default)
477 field_height: Height of the input field area
479 Returns:
480 Tuple of:
481 - bool: True if field was successfully laid out, False if page ran out of space
482 - str: The id used to register the field in the callback registry
483 """
484 # Use provided font or create a default one
485 if font is None: 485 ↛ 486line 485 didn't jump to line 486 because the condition on line 485 was never true
486 font = Font(font_size=12, colour=(0, 0, 0))
488 # Calculate available space
489 available_height = page.size[1] - page._current_y_offset - page.border_size
491 # Create FormFieldText renderable
492 field_text = FormFieldText(field, font, page.draw, field_height=field_height)
494 # Check if field fits on current page
495 total_field_height = field_text.size[1]
496 if total_field_height > available_height: 496 ↛ 497line 496 didn't jump to line 497 because the condition on line 496 was never true
497 return False, ""
499 # Position the field
500 x_offset = page.border_size
501 y_offset = page._current_y_offset
503 field_text.set_origin(np.array([x_offset, y_offset]))
505 # Register in callback registry (use field name as html_id fallback)
506 html_id = getattr(field, '_html_id', None) or field.name
507 registered_id = page.callbacks.register(field_text, html_id=html_id)
509 # Add to page
510 page.add_child(field_text)
512 return True, registered_id
515def form_layouter(form: Form, page: Page, font: Optional[Font] = None,
516 field_spacing: int = 10) -> Tuple[bool, List[str]]:
517 """
518 Layout a complete form with all its fields within a given page.
520 This function creates FormFieldText renderables for all fields in the form,
521 positions them vertically, and registers both the form and its fields in
522 the page's callback registry.
524 Args:
525 form: The abstract Form object to layout
526 page: The page to layout the form on
527 font: Optional font for field labels (defaults to page default)
528 field_spacing: Vertical spacing between fields in pixels
530 Returns:
531 Tuple of:
532 - bool: True if form was successfully laid out, False if page ran out of space
533 - List[str]: List of registered ids for all fields (empty if layout failed)
534 """
535 # Use provided font or create a default one
536 if font is None: 536 ↛ 537line 536 didn't jump to line 537 because the condition on line 536 was never true
537 font = Font(font_size=12, colour=(0, 0, 0))
539 # Track registered field ids
540 field_ids = []
542 # Layout each field in the form
543 for field_name, field in form._fields.items():
544 # Add spacing before each field (except the first)
545 if field_ids:
546 page._current_y_offset += field_spacing
548 # Layout the field
549 success, field_id = form_field_layouter(field, page, font)
551 if not success: 551 ↛ 553line 551 didn't jump to line 553 because the condition on line 551 was never true
552 # Couldn't fit this field, return failure
553 return False, []
555 field_ids.append(field_id)
557 # Register the form itself (optional, for form submission)
558 # Note: The form doesn't have a visual representation, but we can track it
559 # for submission callbacks
560 # form_id = page.callbacks.register(form, html_id=form.html_id)
562 return True, field_ids
565class DocumentLayouter:
566 """
567 Document layouter that orchestrates layout of various abstract elements.
569 Delegates to specialized layouters for different content types:
570 - paragraph_layouter for text paragraphs
571 - image_layouter for images
572 - table_layouter for tables
574 This class acts as a coordinator, managing the overall document flow
575 and page context while delegating specific layout tasks to specialized
576 layouter functions.
577 """
579 def __init__(self, page: Page):
580 """
581 Initialize the document layouter with a page.
583 Args:
584 page: The page to layout content on
585 """
586 self.page = page
587 # Create a style resolver if page doesn't have one
588 if hasattr(page, 'style_resolver'):
589 style_resolver = page.style_resolver
590 else:
591 # Create a default rendering context and style resolver
592 from pyWebLayout.style.concrete_style import RenderingContext
593 context = RenderingContext()
594 style_resolver = StyleResolver(context)
595 self.style_registry = ConcreteStyleRegistry(style_resolver)
597 def layout_paragraph(self,
598 paragraph: Paragraph,
599 start_word: int = 0,
600 pretext: Optional[Text] = None) -> Tuple[bool,
601 Optional[int],
602 Optional[Text]]:
603 """
604 Layout a paragraph using the paragraph_layouter.
606 Args:
607 paragraph: The paragraph to layout
608 start_word: Index of the first word to process (for continuation)
609 pretext: Optional pretext from a previous hyphenated word
611 Returns:
612 Tuple of (success, failed_word_index, remaining_pretext)
613 """
614 return paragraph_layouter(paragraph, self.page, start_word, pretext)
616 def layout_image(self, image: AbstractImage, max_width: Optional[int] = None,
617 max_height: Optional[int] = None) -> bool:
618 """
619 Layout an image using the image_layouter.
621 Args:
622 image: The abstract Image object to layout
623 max_width: Maximum width constraint (defaults to page available width)
624 max_height: Maximum height constraint (defaults to remaining page height)
626 Returns:
627 bool: True if image was successfully laid out, False if page ran out of space
628 """
629 return image_layouter(image, self.page, max_width, max_height)
631 def layout_table(self, table: Table, style: Optional[TableStyle] = None) -> bool:
632 """
633 Layout a table using the table_layouter.
635 Args:
636 table: The abstract Table object to layout
637 style: Optional table styling configuration
639 Returns:
640 bool: True if table was successfully laid out, False if page ran out of space
641 """
642 return table_layouter(table, self.page, style)
644 def layout_button(self,
645 button: Button,
646 font: Optional[Font] = None,
647 padding: Tuple[int,
648 int,
649 int,
650 int] = (4,
651 8,
652 4,
653 8)) -> Tuple[bool,
654 str]:
655 """
656 Layout a button using the button_layouter.
658 Args:
659 button: The abstract Button object to layout
660 font: Optional font for button text
661 padding: Padding around button text
663 Returns:
664 Tuple of (success, registered_id)
665 """
666 return button_layouter(button, self.page, font, padding)
668 def layout_form(self, form: Form, font: Optional[Font] = None,
669 field_spacing: int = 10) -> Tuple[bool, List[str]]:
670 """
671 Layout a form using the form_layouter.
673 Args:
674 form: The abstract Form object to layout
675 font: Optional font for field labels
676 field_spacing: Vertical spacing between fields
678 Returns:
679 Tuple of (success, list_of_field_ids)
680 """
681 return form_layouter(form, self.page, font, field_spacing)
683 def layout_document(
684 self, elements: List[Union[Paragraph, AbstractImage, Table, Button, Form]]) -> bool:
685 """
686 Layout a list of abstract elements (paragraphs, images, tables, buttons, and forms).
688 This method delegates to specialized layouters based on element type:
689 - Paragraphs are handled by layout_paragraph
690 - Images are handled by layout_image
691 - Tables are handled by layout_table
692 - Buttons are handled by layout_button
693 - Forms are handled by layout_form
695 Args:
696 elements: List of abstract elements to layout
698 Returns:
699 True if all elements were successfully laid out, False otherwise
700 """
701 for element in elements:
702 if isinstance(element, Paragraph):
703 success, _, _ = self.layout_paragraph(element)
704 if not success:
705 return False
706 elif isinstance(element, AbstractImage):
707 success = self.layout_image(element)
708 if not success: 708 ↛ 709line 708 didn't jump to line 709 because the condition on line 708 was never true
709 return False
710 elif isinstance(element, Table): 710 ↛ 714line 710 didn't jump to line 714 because the condition on line 710 was always true
711 success = self.layout_table(element)
712 if not success:
713 return False
714 elif isinstance(element, Button):
715 success, _ = self.layout_button(element)
716 if not success:
717 return False
718 elif isinstance(element, Form):
719 success, _ = self.layout_form(element)
720 if not success:
721 return False
722 # Future: elif isinstance(element, CodeBlock): use code_layouter
723 return True