Coverage for pyWebLayout/concrete/table.py: 70%
303 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
1"""
2Concrete table rendering implementation for pyWebLayout.
4This module provides the concrete rendering classes for tables, including:
5- TableRenderer: Main table rendering with borders and spacing
6- TableRowRenderer: Individual row rendering
7- TableCellRenderer: Cell rendering with support for nested content (text, images, links)
8"""
10from __future__ import annotations
11from typing import Tuple, List, Optional, Dict
12from PIL import Image, ImageDraw
13from dataclasses import dataclass
15from pyWebLayout.core.base import Renderable
16from pyWebLayout.concrete.box import Box
17from pyWebLayout.abstract.block import Table, TableRow, TableCell, Paragraph, Heading, Image as AbstractImage
18from pyWebLayout.abstract.interactive_image import InteractiveImage
21@dataclass
22class TableStyle:
23 """Styling configuration for table rendering."""
25 # Border configuration
26 border_width: int = 1
27 border_color: Tuple[int, int, int] = (0, 0, 0)
29 # Cell padding
30 cell_padding: Tuple[int, int, int, int] = (5, 5, 5, 5) # top, right, bottom, left
32 # Header styling
33 header_bg_color: Tuple[int, int, int] = (240, 240, 240)
34 header_text_bold: bool = True
36 # Cell background
37 cell_bg_color: Tuple[int, int, int] = (255, 255, 255)
38 alternate_row_color: Optional[Tuple[int, int, int]] = (250, 250, 250)
40 # Spacing
41 cell_spacing: int = 0 # Space between cells (for separated borders model)
44class TableCellRenderer(Box):
45 """
46 Renders a single table cell with its content.
47 Supports paragraphs, headings, images, and links within cells.
48 """
50 def __init__(self,
51 cell: TableCell,
52 origin: Tuple[int,
53 int],
54 size: Tuple[int,
55 int],
56 draw: ImageDraw.Draw,
57 style: TableStyle,
58 is_header_section: bool = False,
59 canvas: Optional[Image.Image] = None):
60 """
61 Initialize a table cell renderer.
63 Args:
64 cell: The abstract TableCell to render
65 origin: Top-left position of the cell
66 size: Width and height of the cell
67 draw: PIL ImageDraw object for rendering
68 style: Table styling configuration
69 is_header_section: Whether this cell is in the header section
70 canvas: Optional PIL Image for pasting images (required for image rendering)
71 """
72 super().__init__(origin, size)
73 self._cell = cell
74 self._draw = draw
75 self._style = style
76 self._is_header_section = is_header_section or cell.is_header
77 self._canvas = canvas
78 self._children: List[Renderable] = []
80 def render(self) -> Image.Image:
81 """Render the table cell."""
82 # Determine background color
83 if self._is_header_section:
84 bg_color = self._style.header_bg_color
85 else:
86 bg_color = self._style.cell_bg_color
88 # Draw cell background
89 x, y = self._origin
90 w, h = self._size
91 self._draw.rectangle(
92 [x, y, x + w, y + h],
93 fill=bg_color,
94 outline=self._style.border_color,
95 width=self._style.border_width
96 )
98 # Calculate content area (inside padding)
99 padding = self._style.cell_padding
100 content_x = x + padding[3] # left padding
101 content_y = y + padding[0] # top padding
102 content_width = w - (padding[1] + padding[3]) # minus left and right padding
103 content_height = h - (padding[0] + padding[2]) # minus top and bottom padding
105 # Render cell content (text)
106 self._render_cell_content(content_x, content_y, content_width, content_height)
108 return None # Cell rendering is done directly on the page
110 def _render_cell_content(self, x: int, y: int, width: int, height: int):
111 """Render the content inside the cell (text and images) with line wrapping."""
112 from pyWebLayout.concrete.text import Line, Text
113 from pyWebLayout.style.fonts import Font
114 from pyWebLayout.style import FontWeight, Alignment
116 current_y = y + 2
117 available_height = height - 4 # Account for top/bottom padding
119 # Create font for the cell
120 font_size = 12
121 font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
122 if self._is_header_section and self._style.header_text_bold:
123 font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
125 font = Font(
126 font_path=font_path,
127 font_size=font_size,
128 weight=FontWeight.BOLD if self._is_header_section and self._style.header_text_bold else FontWeight.NORMAL
129 )
131 # Word spacing constraints (min, max)
132 min_spacing = int(font_size * 0.25)
133 max_spacing = int(font_size * 0.5)
134 word_spacing = (min_spacing, max_spacing)
136 # Line height (baseline spacing)
137 line_height = font_size + 4
138 ascent, descent = font.font.getmetrics()
140 # Render each block in the cell
141 for block in self._cell.blocks():
142 if isinstance(block, AbstractImage): 142 ↛ 144line 142 didn't jump to line 144 because the condition on line 142 was never true
143 # Render image
144 current_y = self._render_image_in_cell(
145 block, x, current_y, width, height - (current_y - y))
146 elif isinstance(block, (Paragraph, Heading)): 146 ↛ 225line 146 didn't jump to line 225 because the condition on line 146 was always true
147 # Get words from the block
148 from pyWebLayout.abstract.inline import Word as AbstractWord
150 word_items = block.words() if callable(block.words) else block.words
151 words = list(word_items)
153 if not words: 153 ↛ 154line 153 didn't jump to line 154 because the condition on line 153 was never true
154 continue
156 # Create new Word objects with the table cell's font
157 # The words from the paragraph may have AbstractStyle, but we need Font objects
158 wrapped_words = []
159 for word_item in words:
160 # Handle word tuples (index, word_obj)
161 if isinstance(word_item, tuple) and len(word_item) >= 2: 161 ↛ 162line 161 didn't jump to line 162 because the condition on line 161 was never true
162 word_obj = word_item[1]
163 else:
164 word_obj = word_item
166 # Extract text from the word
167 word_text = word_obj.text if hasattr(word_obj, 'text') else str(word_obj)
169 # Create a new Word with the cell's Font
170 new_word = AbstractWord(word_text, font)
171 wrapped_words.append(new_word)
173 # Layout words using Line objects with wrapping
174 word_index = 0
175 pretext = None
177 while word_index < len(wrapped_words):
178 # Check if we have space for another line
179 if current_y + ascent + descent > y + available_height: 179 ↛ 180line 179 didn't jump to line 180 because the condition on line 179 was never true
180 break # No more space in cell
182 # Create a new line
183 line = Line(
184 spacing=word_spacing,
185 origin=(x + 2, current_y),
186 size=(width - 4, line_height),
187 draw=self._draw,
188 font=font,
189 halign=Alignment.LEFT
190 )
192 # Add words to this line until it's full
193 line_has_content = False
194 while word_index < len(wrapped_words):
195 word = wrapped_words[word_index]
197 # Try to add word to line
198 success, overflow = line.add_word(word, pretext)
199 pretext = None # Clear pretext after use
201 if success: 201 ↛ 214line 201 didn't jump to line 214 because the condition on line 201 was always true
202 line_has_content = True
203 if overflow: 203 ↛ 207line 203 didn't jump to line 207 because the condition on line 203 was never true
204 # Word was hyphenated, carry over to next line
205 # DON'T increment word_index - we need to add the overflow
206 # to the next line with the same word
207 pretext = overflow
208 break # Move to next line
209 else:
210 # Word fit completely, move to next word
211 word_index += 1
212 else:
213 # Word doesn't fit on this line
214 if not line_has_content:
215 # Even first word doesn't fit, force it anyway and advance
216 # This prevents infinite loops with words that truly can't fit
217 word_index += 1
218 break
220 # Render the line if it has content
221 if line_has_content or len(line.text_objects) > 0: 221 ↛ 177line 221 didn't jump to line 177 because the condition on line 221 was always true
222 line.render()
223 current_y += line_height
225 if current_y > y + height - 10: # Don't overflow cell 225 ↛ 226line 225 didn't jump to line 226 because the condition on line 225 was never true
226 break
228 # If no structured content, try to get any text representation
229 if current_y == y + 2 and hasattr(self._cell, '_text_content'): 229 ↛ 231line 229 didn't jump to line 231 because the condition on line 229 was never true
230 # Use simple text rendering for fallback case
231 from PIL import ImageFont
232 try:
233 pil_font = ImageFont.truetype(font_path, font_size)
234 except BaseException:
235 pil_font = ImageFont.load_default()
237 self._draw.text(
238 (x + 2, current_y),
239 self._cell._text_content,
240 fill=(0, 0, 0),
241 font=pil_font
242 )
244 def _render_image_in_cell(self, image_block: AbstractImage, x: int, y: int,
245 max_width: int, max_height: int) -> int:
246 """
247 Render an image block inside a table cell.
249 Returns:
250 The new Y position after the image
251 """
252 try:
253 # Get the image path from the block
254 image_path = None
255 if hasattr(image_block, 'source'):
256 image_path = image_block.source
257 elif hasattr(image_block, '_source'):
258 image_path = image_block._source
259 elif hasattr(image_block, 'path'):
260 image_path = image_block.path
261 elif hasattr(image_block, 'src'):
262 image_path = image_block.src
263 elif hasattr(image_block, '_path'):
264 image_path = image_block._path
265 elif hasattr(image_block, '_src'):
266 image_path = image_block._src
268 if not image_path:
269 return y + 20 # Skip if no image path
271 # Load and resize image to fit in cell
272 img = Image.open(image_path)
274 # Calculate scaling to fit within max dimensions
275 # Use more of the cell space for images
276 img_width, img_height = img.size
277 scale_w = max_width / img_width if img_width > max_width else 1
278 scale_h = (max_height - 10) / \
279 img_height if img_height > (max_height - 10) else 1
280 scale = min(scale_w, scale_h, 1.0) # Don't upscale
282 new_width = int(img_width * scale)
283 new_height = int(img_height * scale)
285 if scale < 1.0:
286 img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
288 # Center image horizontally in cell
289 img_x = x + (max_width - new_width) // 2
291 # Paste the image onto the canvas if available
292 if self._canvas is not None:
293 if img.mode == 'RGBA':
294 self._canvas.paste(img, (img_x, y), img)
295 else:
296 self._canvas.paste(img, (img_x, y))
297 else:
298 # Fallback: draw a placeholder if no canvas provided
299 self._draw.rectangle(
300 [img_x, y, img_x + new_width, y + new_height],
301 fill=(200, 200, 200),
302 outline=(150, 150, 150)
303 )
305 # Draw image indicator text
306 from PIL import ImageFont
307 try:
308 small_font = ImageFont.truetype(
309 "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 9)
310 except BaseException:
311 small_font = ImageFont.load_default()
313 text = f"[Image: {new_width}x{new_height}]"
314 bbox = self._draw.textbbox((0, 0), text, font=small_font)
315 text_width = bbox[2] - bbox[0]
316 text_x = img_x + (new_width - text_width) // 2
317 text_y = y + (new_height - 12) // 2
318 self._draw.text(
319 (text_x, text_y), text, fill=(
320 100, 100, 100), font=small_font)
322 # Set bounds on InteractiveImage objects for tap detection
323 if isinstance(image_block, InteractiveImage):
324 image_block.set_rendered_bounds(
325 origin=(img_x, y),
326 size=(new_width, new_height)
327 )
329 return y + new_height + 5 # Add some spacing after image
331 except Exception:
332 # If image loading fails, just return current position
333 return y + 20
336class TableRowRenderer(Box):
337 """
338 Renders a single table row containing multiple cells.
339 """
341 def __init__(self,
342 row: TableRow,
343 origin: Tuple[int,
344 int],
345 column_widths: List[int],
346 row_height: int,
347 draw: ImageDraw.Draw,
348 style: TableStyle,
349 is_header_section: bool = False,
350 canvas: Optional[Image.Image] = None):
351 """
352 Initialize a table row renderer.
354 Args:
355 row: The abstract TableRow to render
356 origin: Top-left position of the row
357 column_widths: List of widths for each column
358 row_height: Height of this row
359 draw: PIL ImageDraw object for rendering
360 style: Table styling configuration
361 is_header_section: Whether this row is in the header section
362 canvas: Optional PIL Image for pasting images
363 """
364 width = sum(column_widths) + style.border_width * (len(column_widths) + 1)
365 super().__init__(origin, (width, row_height))
366 self._row = row
367 self._column_widths = column_widths
368 self._row_height = row_height
369 self._draw = draw
370 self._style = style
371 self._is_header_section = is_header_section
372 self._canvas = canvas
373 self._cell_renderers: List[TableCellRenderer] = []
375 def render(self) -> Image.Image:
376 """Render the table row by rendering each cell."""
377 x, y = self._origin
378 current_x = x
380 # Render each cell
381 cells = list(self._row.cells())
382 for i, cell in enumerate(cells):
383 if i < len(self._column_widths): 383 ↛ 382line 383 didn't jump to line 382 because the condition on line 383 was always true
384 cell_width = self._column_widths[i]
386 # Handle colspan
387 if cell.colspan > 1 and i + cell.colspan <= len(self._column_widths):
388 # Sum up widths for spanned columns
389 cell_width = sum(self._column_widths[i:i + cell.colspan])
390 cell_width += self._style.border_width * (cell.colspan - 1)
392 # Create and render cell
393 cell_renderer = TableCellRenderer(
394 cell,
395 (current_x, y),
396 (cell_width, self._row_height),
397 self._draw,
398 self._style,
399 self._is_header_section,
400 self._canvas
401 )
402 cell_renderer.render()
403 self._cell_renderers.append(cell_renderer)
405 current_x += cell_width + self._style.border_width
407 return None # Row rendering is done directly on the page
410class TableRenderer(Box):
411 """
412 Main table renderer that orchestrates the rendering of an entire table.
413 Handles layout calculation, row/cell placement, and overall table structure.
414 """
416 def __init__(self,
417 table: Table,
418 origin: Tuple[int,
419 int],
420 available_width: int,
421 draw: ImageDraw.Draw,
422 style: Optional[TableStyle] = None,
423 canvas: Optional[Image.Image] = None):
424 """
425 Initialize a table renderer.
427 Args:
428 table: The abstract Table to render
429 origin: Top-left position where the table should be rendered
430 available_width: Maximum width available for the table
431 draw: PIL ImageDraw object for rendering
432 style: Optional table styling configuration
433 canvas: Optional PIL Image for pasting images
434 """
435 self._table = table
436 self._draw = draw
437 self._style = style or TableStyle()
438 self._available_width = available_width
439 self._canvas = canvas
441 # Calculate table dimensions
442 self._column_widths, self._row_heights = self._calculate_dimensions()
443 total_width = sum(self._column_widths) + \
444 self._style.border_width * (len(self._column_widths) + 1)
445 total_height = sum(self._row_heights.values()) + \
446 self._style.border_width * (len(self._row_heights) + 1)
448 super().__init__(origin, (total_width, total_height))
449 self._row_renderers: List[TableRowRenderer] = []
451 def _calculate_dimensions(self) -> Tuple[List[int], Dict[str, int]]:
452 """
453 Calculate column widths and row heights for the table.
455 Uses the table optimizer for intelligent column width distribution.
457 Returns:
458 Tuple of (column_widths, row_heights_dict)
459 """
460 from pyWebLayout.layout.table_optimizer import optimize_table_layout
462 all_rows = list(self._table.all_rows())
464 if not all_rows:
465 return ([100], {"header": 30, "body": 30, "footer": 30})
467 # Use optimizer for column widths!
468 column_widths = optimize_table_layout(
469 self._table,
470 self._available_width,
471 sample_size=5,
472 style=self._style
473 )
475 if not column_widths: 475 ↛ 477line 475 didn't jump to line 477 because the condition on line 475 was never true
476 # Fallback if table is empty
477 column_widths = [100]
479 # Calculate row heights dynamically based on optimized column widths
480 header_height = self._calculate_row_height_for_section(
481 all_rows, "header", column_widths) if any(
482 1 for section, _ in all_rows if section == "header") else 0
484 body_height = self._calculate_row_height_for_section(
485 all_rows, "body", column_widths)
487 footer_height = self._calculate_row_height_for_section(
488 all_rows, "footer", column_widths) if any(
489 1 for section, _ in all_rows if section == "footer") else 0
491 row_heights = {
492 "header": header_height,
493 "body": body_height,
494 "footer": footer_height
495 }
497 return (column_widths, row_heights)
499 def _calculate_row_height_for_section(
500 self,
501 all_rows: List,
502 section: str,
503 column_widths: List[int]) -> int:
504 """
505 Calculate the maximum required height for rows in a specific section.
507 Args:
508 all_rows: List of all rows in the table
509 section: Section name ('header', 'body', or 'footer')
510 column_widths: List of column widths
512 Returns:
513 Maximum height needed for rows in this section
514 """
515 from pyWebLayout.concrete.text import Text
516 from pyWebLayout.style.fonts import Font
517 from pyWebLayout.abstract.inline import Word as AbstractWord
519 # Font configuration
520 font_size = 12
521 line_height = font_size + 4
522 padding = self._style.cell_padding
523 vertical_padding = padding[0] + padding[2] # top + bottom
524 horizontal_padding = padding[1] + padding[3] # left + right
526 max_height = 40 # Minimum height
528 for row_section, row in all_rows:
529 if row_section != section:
530 continue
532 row_max_height = 40 # Minimum for this row
534 for cell_idx, cell in enumerate(row.cells()):
535 if cell_idx >= len(column_widths): 535 ↛ 536line 535 didn't jump to line 536 because the condition on line 535 was never true
536 continue
538 # Get cell width (accounting for colspan)
539 cell_width = column_widths[cell_idx]
540 if cell.colspan > 1 and cell_idx + \ 540 ↛ 542line 540 didn't jump to line 542 because the condition on line 540 was never true
541 cell.colspan <= len(column_widths):
542 cell_width = sum(
543 column_widths[cell_idx:cell_idx + cell.colspan])
544 cell_width += self._style.border_width * (cell.colspan - 1)
546 # Calculate content width (minus padding)
547 content_width = cell_width - horizontal_padding - 4 # Extra margin
549 cell_height = vertical_padding + 4 # Base height with padding
551 # Analyze each block in the cell
552 for block in cell.blocks():
553 if isinstance(block, AbstractImage): 553 ↛ 555line 553 didn't jump to line 555 because the condition on line 553 was never true
554 # Images need more space
555 cell_height = max(cell_height, 120)
556 elif isinstance(block, (Paragraph, Heading)): 556 ↛ 552line 556 didn't jump to line 552 because the condition on line 556 was always true
557 # Calculate text wrapping height
558 word_items = block.words() if callable(
559 block.words) else block.words
560 words = list(word_items)
562 if not words: 562 ↛ 563line 562 didn't jump to line 563 because the condition on line 562 was never true
563 continue
565 # Simulate text wrapping to count lines
566 lines_needed = self._estimate_wrapped_lines(
567 words, content_width, font_size)
568 text_height = lines_needed * line_height
569 cell_height = max(
570 cell_height, text_height + vertical_padding + 4)
572 row_max_height = max(row_max_height, cell_height)
574 max_height = max(max_height, row_max_height)
576 return max_height
578 def _estimate_wrapped_lines(
579 self,
580 words: List,
581 available_width: int,
582 font_size: int) -> int:
583 """
584 Estimate how many lines are needed to render the given words.
586 Args:
587 words: List of word objects
588 available_width: Available width for text
589 font_size: Font size in pixels
591 Returns:
592 Number of lines needed
593 """
594 from pyWebLayout.concrete.text import Text
595 from pyWebLayout.style.fonts import Font
597 # Create a temporary font for measurement
598 font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
599 font = Font(font_path=font_path, font_size=font_size)
601 # Word spacing (approximate)
602 word_spacing = int(font_size * 0.25)
604 lines = 1
605 current_line_width = 0
607 for word_item in words:
608 # Handle word tuples (index, word_obj)
609 if isinstance(word_item, tuple) and len(word_item) >= 2: 609 ↛ 610line 609 didn't jump to line 610 because the condition on line 609 was never true
610 word_obj = word_item[1]
611 else:
612 word_obj = word_item
614 # Extract text from the word
615 word_text = word_obj.text if hasattr(
616 word_obj, 'text') else str(word_obj)
618 # Measure word width
619 word_width = font.font.getlength(word_text)
621 # Check if word fits on current line
622 if current_line_width > 0: # Not first word on line
623 needed_width = current_line_width + word_spacing + word_width
624 if needed_width > available_width: 624 ↛ 626line 624 didn't jump to line 626 because the condition on line 624 was never true
625 # Need new line
626 lines += 1
627 current_line_width = word_width
628 else:
629 current_line_width = needed_width
630 else:
631 # First word on line
632 if word_width > available_width: 632 ↛ 634line 632 didn't jump to line 634 because the condition on line 632 was never true
633 # Word needs to be hyphenated, assume it takes 1 line
634 lines += 1
635 current_line_width = 0
636 else:
637 current_line_width = word_width
639 return lines
641 def render(self) -> Image.Image:
642 """Render the complete table."""
643 x, y = self._origin
644 current_y = y
646 # Render caption if present
647 if self._table.caption:
648 current_y = self._render_caption(x, current_y)
649 current_y += 10 # Space after caption
651 # Render header rows
652 for section, row in self._table.all_rows():
653 if section == "header":
654 row_height = self._row_heights["header"]
655 elif section == "footer":
656 row_height = self._row_heights["footer"]
657 else:
658 row_height = self._row_heights["body"]
660 is_header = (section == "header")
662 row_renderer = TableRowRenderer(
663 row,
664 (x, current_y),
665 self._column_widths,
666 row_height,
667 self._draw,
668 self._style,
669 is_header,
670 self._canvas
671 )
672 row_renderer.render()
673 self._row_renderers.append(row_renderer)
675 current_y += row_height + self._style.border_width
677 return None # Table rendering is done directly on the page
679 def _render_caption(self, x: int, y: int) -> int:
680 """Render the table caption and return the new Y position."""
681 from PIL import ImageFont
683 try:
684 font = ImageFont.truetype(
685 "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 13)
686 except BaseException:
687 font = ImageFont.load_default()
689 # Center the caption
690 bbox = self._draw.textbbox((0, 0), self._table.caption, font=font)
691 text_width = bbox[2] - bbox[0]
692 caption_x = x + (self._size[0] - text_width) // 2
694 self._draw.text((caption_x, y), self._table.caption, fill=(0, 0, 0), font=font)
696 return y + 20 # Caption height
698 @property
699 def height(self) -> int:
700 """Get the total height of the rendered table."""
701 return int(self._size[1])
703 @property
704 def width(self) -> int:
705 """Get the total width of the rendered table."""
706 return int(self._size[0])