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