Coverage for pyWebLayout/abstract/block.py: 79%
489 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 typing import List, Iterator, Tuple, Dict, Optional, Any
2from enum import Enum
3import os
4import tempfile
5import urllib.request
6import urllib.parse
7from PIL import Image as PILImage
8from .inline import Word, FormattedSpan
9from ..core import Hierarchical, Styleable, FontRegistry, ContainerAware, BlockContainer
12class BlockType(Enum):
13 """Enumeration of different block types for classification purposes"""
14 PARAGRAPH = 1
15 HEADING = 2
16 QUOTE = 3
17 CODE_BLOCK = 4
18 LIST = 5
19 LIST_ITEM = 6
20 TABLE = 7
21 TABLE_ROW = 8
22 TABLE_CELL = 9
23 HORIZONTAL_RULE = 10
24 LINE_BREAK = 11
25 IMAGE = 12
26 PAGE_BREAK = 13
29class Block(Hierarchical):
30 """
31 Base class for all block-level elements.
32 Block elements typically represent visual blocks of content that stack vertically.
34 Uses Hierarchical mixin for parent-child relationship management.
35 """
37 def __init__(self, block_type: BlockType):
38 """
39 Initialize a block element.
41 Args:
42 block_type: The type of block this element represents
43 """
44 super().__init__()
45 self._block_type = block_type
47 @property
48 def block_type(self) -> BlockType:
49 """Get the type of this block element"""
50 return self._block_type
53class Paragraph(Styleable, FontRegistry, ContainerAware, Block):
54 """
55 A paragraph is a block-level element that contains a sequence of words.
57 Uses Styleable mixin for style property management.
58 Uses FontRegistry mixin for font caching with parent delegation.
59 """
61 def __init__(self, style=None):
62 """
63 Initialize an empty paragraph
65 Args:
66 style: Optional default style for words in this paragraph
67 """
68 super().__init__(style=style, block_type=BlockType.PARAGRAPH)
69 self._words: List[Word] = []
70 self._spans: List[FormattedSpan] = []
72 @classmethod
73 def create_and_add_to(cls, container, style=None) -> 'Paragraph':
74 """
75 Create a new Paragraph and add it to a container, inheriting style from
76 the container if not explicitly provided.
78 Args:
79 container: The container to add the paragraph to (must have add_block method and style property)
80 style: Optional style override. If None, inherits from container
82 Returns:
83 The newly created Paragraph object
85 Raises:
86 AttributeError: If the container doesn't have the required add_block method
87 """
88 # Validate container and inherit style using ContainerAware utilities
89 cls._validate_container(container)
90 style = cls._inherit_style(container, style)
92 # Create the new paragraph
93 paragraph = cls(style)
95 # Add the paragraph to the container
96 container.add_block(paragraph)
98 return paragraph
100 def add_word(self, word: Word):
101 """
102 Add a word to this paragraph.
104 Args:
105 word: The Word object to add
106 """
107 self._words.append(word)
109 def create_word(self, text: str, style=None, background=None) -> Word:
110 """
111 Create a new word and add it to this paragraph, inheriting paragraph's style if not specified.
113 This is a convenience method that uses Word.create_and_add_to() to create words
114 that automatically inherit styling from this paragraph.
116 Args:
117 text: The text content of the word
118 style: Optional Font style override. If None, attempts to inherit from paragraph
119 background: Optional background color override
121 Returns:
122 The newly created Word object
123 """
124 return Word.create_and_add_to(text, self, style, background)
126 def add_span(self, span: FormattedSpan):
127 """
128 Add a formatted span to this paragraph.
130 Args:
131 span: The FormattedSpan object to add
132 """
133 self._spans.append(span)
135 def create_span(self, style=None, background=None) -> FormattedSpan:
136 """
137 Create a new formatted span with inherited style.
139 Args:
140 style: Optional Font style override. If None, inherits from paragraph
141 background: Optional background color override
143 Returns:
144 The newly created FormattedSpan object
145 """
146 return FormattedSpan.create_and_add_to(self, style, background)
148 @property
149 def words(self) -> List[Word]:
150 """Get the list of words in this paragraph"""
151 return self._words
153 def words_iter(self) -> Iterator[Tuple[int, Word]]:
154 """
155 Iterate over the words in this paragraph.
157 Yields:
158 Tuples of (index, word) for each word in the paragraph
159 """
160 for i, word in enumerate(self._words):
161 yield i, word
163 def spans(self) -> Iterator[FormattedSpan]:
164 """
165 Iterate over the formatted spans in this paragraph.
167 Yields:
168 Each FormattedSpan in the paragraph
169 """
170 for span in self._spans:
171 yield span
173 @property
174 def word_count(self) -> int:
175 """Get the number of words in this paragraph"""
176 return len(self._words)
178 def __len__(self):
179 return self.word_count
181 # get_or_create_font() is provided by FontRegistry mixin
184class HeadingLevel(Enum):
185 """Enumeration representing HTML heading levels (h1-h6)"""
186 H1 = 1
187 H2 = 2
188 H3 = 3
189 H4 = 4
190 H5 = 5
191 H6 = 6
194class Heading(Paragraph):
195 """
196 A heading element (h1, h2, h3, etc.) that contains text with a specific heading level.
197 Headings inherit from Paragraph as they contain words but have additional properties.
198 """
200 def __init__(self, level: HeadingLevel = HeadingLevel.H1, style=None):
201 """
202 Initialize a heading element.
204 Args:
205 level: The heading level (h1-h6)
206 style: Optional default style for words in this heading
207 """
208 super().__init__(style)
209 self._block_type = BlockType.HEADING
210 self._level = level
212 @classmethod
213 def create_and_add_to(
214 cls,
215 container,
216 level: HeadingLevel = HeadingLevel.H1,
217 style=None) -> 'Heading':
218 """
219 Create a new Heading and add it to a container, inheriting style from
220 the container if not explicitly provided.
222 Args:
223 container: The container to add the heading to (must have add_block method and style property)
224 level: The heading level (h1-h6)
225 style: Optional style override. If None, inherits from container
227 Returns:
228 The newly created Heading object
230 Raises:
231 AttributeError: If the container doesn't have the required add_block method
232 """
233 # Validate container and inherit style using ContainerAware utilities
234 cls._validate_container(container)
235 style = cls._inherit_style(container, style)
237 # Create the new heading
238 heading = cls(level, style)
240 # Add the heading to the container
241 container.add_block(heading)
243 return heading
245 @property
246 def level(self) -> HeadingLevel:
247 """Get the heading level"""
248 return self._level
250 @level.setter
251 def level(self, level: HeadingLevel):
252 """Set the heading level"""
253 self._level = level
256class Quote(BlockContainer, ContainerAware, Block):
257 """
258 A blockquote element that can contain other block elements.
259 """
261 def __init__(self, style=None):
262 """
263 Initialize an empty blockquote
265 Args:
266 style: Optional default style for child blocks
267 """
268 super().__init__(BlockType.QUOTE)
269 self._style = style
271 @classmethod
272 def create_and_add_to(cls, container, style=None) -> 'Quote':
273 """
274 Create a new Quote and add it to a container, inheriting style from
275 the container if not explicitly provided.
277 Args:
278 container: The container to add the quote to (must have add_block method and style property)
279 style: Optional style override. If None, inherits from container
281 Returns:
282 The newly created Quote object
284 Raises:
285 AttributeError: If the container doesn't have the required add_block method
286 """
287 # Validate container and inherit style using ContainerAware utilities
288 cls._validate_container(container)
289 style = cls._inherit_style(container, style)
291 # Create the new quote
292 quote = cls(style)
294 # Add the quote to the container
295 container.add_block(quote)
297 return quote
299 @property
300 def style(self):
301 """Get the default style for this quote"""
302 return self._style
304 @style.setter
305 def style(self, style):
306 """Set the default style for this quote"""
307 self._style = style
310class CodeBlock(Block):
311 """
312 A code block element containing pre-formatted text with syntax highlighting.
313 """
315 def __init__(self, language: str = ""):
316 """
317 Initialize a code block.
319 Args:
320 language: The programming language for syntax highlighting
321 """
322 super().__init__(BlockType.CODE_BLOCK)
323 self._language = language
324 self._lines: List[str] = []
326 @classmethod
327 def create_and_add_to(cls, container, language: str = "") -> 'CodeBlock':
328 """
329 Create a new CodeBlock and add it to a container.
331 Args:
332 container: The container to add the code block to (must have add_block method)
333 language: The programming language for syntax highlighting
335 Returns:
336 The newly created CodeBlock object
338 Raises:
339 AttributeError: If the container doesn't have the required add_block method
340 """
341 # Create the new code block
342 code_block = cls(language)
344 # Add the code block to the container
345 if hasattr(container, 'add_block'):
346 container.add_block(code_block)
347 else:
348 raise AttributeError(
349 f"Container {type(container).__name__} must have an 'add_block' method"
350 )
352 return code_block
354 @property
355 def language(self) -> str:
356 """Get the programming language"""
357 return self._language
359 @language.setter
360 def language(self, language: str):
361 """Set the programming language"""
362 self._language = language
364 def add_line(self, line: str):
365 """
366 Add a line of code to this code block.
368 Args:
369 line: The line of code to add
370 """
371 self._lines.append(line)
373 def lines(self) -> Iterator[Tuple[int, str]]:
374 """
375 Iterate over the lines in this code block.
377 Yields:
378 Tuples of (line_number, line_text) for each line
379 """
380 for i, line in enumerate(self._lines):
381 yield i, line
383 @property
384 def line_count(self) -> int:
385 """Get the number of lines in this code block"""
386 return len(self._lines)
389class ListStyle(Enum):
390 """Enumeration of list styles"""
391 UNORDERED = 1 # <ul>
392 ORDERED = 2 # <ol>
393 DEFINITION = 3 # <dl>
396class HList(ContainerAware, Block):
397 """
398 An HTML list element (ul, ol, dl).
399 """
401 def __init__(self, style: ListStyle = ListStyle.UNORDERED, default_style=None):
402 """
403 Initialize a list.
405 Args:
406 style: The style of list (unordered, ordered, definition)
407 default_style: Optional default style for child items
408 """
409 super().__init__(BlockType.LIST)
410 self._style = style
411 self._items: List[ListItem] = []
412 self._default_style = default_style
414 @classmethod
415 def create_and_add_to(
416 cls,
417 container,
418 style: ListStyle = ListStyle.UNORDERED,
419 default_style=None) -> 'HList':
420 """
421 Create a new HList and add it to a container, inheriting style from
422 the container if not explicitly provided.
424 Args:
425 container: The container to add the list to (must have add_block method)
426 style: The style of list (unordered, ordered, definition)
427 default_style: Optional default style for child items. If None, inherits from container
429 Returns:
430 The newly created HList object
432 Raises:
433 AttributeError: If the container doesn't have the required add_block method
434 """
435 # Validate container and inherit style using ContainerAware utilities
436 cls._validate_container(container)
437 default_style = cls._inherit_style(container, default_style)
439 # Create the new list
440 hlist = cls(style, default_style)
442 # Add the list to the container
443 container.add_block(hlist)
445 return hlist
447 @property
448 def style(self) -> ListStyle:
449 """Get the list style"""
450 return self._style
452 @style.setter
453 def style(self, style: ListStyle):
454 """Set the list style"""
455 self._style = style
457 @property
458 def default_style(self):
459 """Get the default style for list items"""
460 return self._default_style
462 @default_style.setter
463 def default_style(self, style):
464 """Set the default style for list items"""
465 self._default_style = style
467 def add_item(self, item: 'ListItem'):
468 """
469 Add an item to this list.
471 Args:
472 item: The ListItem to add
473 """
474 self._items.append(item)
475 item.parent = self
477 def create_item(self, term: Optional[str] = None, style=None) -> 'ListItem':
478 """
479 Create a new list item and add it to this list.
481 Args:
482 term: Optional term for definition lists
483 style: Optional style override. If None, inherits from list
485 Returns:
486 The newly created ListItem object
487 """
488 return ListItem.create_and_add_to(self, term, style)
490 def items(self) -> Iterator['ListItem']:
491 """
492 Iterate over the items in this list.
494 Yields:
495 Each ListItem in the list
496 """
497 for item in self._items:
498 yield item
500 @property
501 def item_count(self) -> int:
502 """Get the number of items in this list"""
503 return len(self._items)
506class ListItem(BlockContainer, ContainerAware, Block):
507 """
508 A list item element that can contain other block elements.
509 """
511 def __init__(self, term: Optional[str] = None, style=None):
512 """
513 Initialize a list item.
515 Args:
516 term: Optional term for definition lists (dt element)
517 style: Optional default style for child blocks
518 """
519 super().__init__(BlockType.LIST_ITEM)
520 self._term = term
521 self._style = style
523 @classmethod
524 def create_and_add_to(
525 cls,
526 container,
527 term: Optional[str] = None,
528 style=None) -> 'ListItem':
529 """
530 Create a new ListItem and add it to a container, inheriting style from
531 the container if not explicitly provided.
533 Args:
534 container: The container to add the list item to (must have add_item method)
535 term: Optional term for definition lists (dt element)
536 style: Optional style override. If None, inherits from container
538 Returns:
539 The newly created ListItem object
541 Raises:
542 AttributeError: If the container doesn't have the required add_item method
543 """
544 # Validate container and inherit style using ContainerAware utilities
545 cls._validate_container(container, required_method='add_item')
546 style = cls._inherit_style(container, style)
548 # Create the new list item
549 item = cls(term, style)
551 # Add the list item to the container
552 container.add_item(item)
554 return item
556 @property
557 def term(self) -> Optional[str]:
558 """Get the definition term (for definition lists)"""
559 return self._term
561 @term.setter
562 def term(self, term: str):
563 """Set the definition term"""
564 self._term = term
566 @property
567 def style(self):
568 """Get the default style for this list item"""
569 return self._style
571 @style.setter
572 def style(self, style):
573 """Set the default style for this list item"""
574 self._style = style
577class TableCell(BlockContainer, ContainerAware, Block):
578 """
579 A table cell element that can contain other block elements.
580 """
582 def __init__(
583 self,
584 is_header: bool = False,
585 colspan: int = 1,
586 rowspan: int = 1,
587 style=None):
588 """
589 Initialize a table cell.
591 Args:
592 is_header: Whether this cell is a header cell (th) or data cell (td)
593 colspan: Number of columns this cell spans
594 rowspan: Number of rows this cell spans
595 style: Optional default style for child blocks
596 """
597 super().__init__(BlockType.TABLE_CELL)
598 self._is_header = is_header
599 self._colspan = colspan
600 self._rowspan = rowspan
601 self._style = style
603 @classmethod
604 def create_and_add_to(cls, container, is_header: bool = False, colspan: int = 1,
605 rowspan: int = 1, style=None) -> 'TableCell':
606 """
607 Create a new TableCell and add it to a container, inheriting style from
608 the container if not explicitly provided.
610 Args:
611 container: The container to add the cell to (must have add_cell method)
612 is_header: Whether this cell is a header cell (th) or data cell (td)
613 colspan: Number of columns this cell spans
614 rowspan: Number of rows this cell spans
615 style: Optional style override. If None, inherits from container
617 Returns:
618 The newly created TableCell object
620 Raises:
621 AttributeError: If the container doesn't have the required add_cell method
622 """
623 # Validate container and inherit style using ContainerAware utilities
624 cls._validate_container(container, required_method='add_cell')
625 style = cls._inherit_style(container, style)
627 # Create the new table cell
628 cell = cls(is_header, colspan, rowspan, style)
630 # Add the cell to the container
631 container.add_cell(cell)
633 return cell
635 @property
636 def is_header(self) -> bool:
637 """Check if this is a header cell"""
638 return self._is_header
640 @is_header.setter
641 def is_header(self, is_header: bool):
642 """Set whether this is a header cell"""
643 self._is_header = is_header
645 @property
646 def colspan(self) -> int:
647 """Get the column span"""
648 return self._colspan
650 @colspan.setter
651 def colspan(self, colspan: int):
652 """Set the column span"""
653 self._colspan = max(1, colspan) # Ensure minimum of 1
655 @property
656 def rowspan(self) -> int:
657 """Get the row span"""
658 return self._rowspan
660 @rowspan.setter
661 def rowspan(self, rowspan: int):
662 """Set the row span"""
663 self._rowspan = max(1, rowspan) # Ensure minimum of 1
665 @property
666 def style(self):
667 """Get the default style for this table cell"""
668 return self._style
670 @style.setter
671 def style(self, style):
672 """Set the default style for this table cell"""
673 self._style = style
676class TableRow(ContainerAware, Block):
677 """
678 A table row element containing table cells.
679 """
681 def __init__(self, style=None):
682 """
683 Initialize an empty table row
685 Args:
686 style: Optional default style for child cells
687 """
688 super().__init__(BlockType.TABLE_ROW)
689 self._cells: List[TableCell] = []
690 self._style = style
692 @classmethod
693 def create_and_add_to(
694 cls,
695 container,
696 section: str = "body",
697 style=None) -> 'TableRow':
698 """
699 Create a new TableRow and add it to a container, inheriting style from
700 the container if not explicitly provided.
702 Args:
703 container: The container to add the row to (must have add_row method)
704 section: The section to add the row to ("header", "body", or "footer")
705 style: Optional style override. If None, inherits from container
707 Returns:
708 The newly created TableRow object
710 Raises:
711 AttributeError: If the container doesn't have the required add_row method
712 """
713 # Validate container and inherit style using ContainerAware utilities
714 cls._validate_container(container, required_method='add_row')
715 style = cls._inherit_style(container, style)
717 # Create the new table row
718 row = cls(style)
720 # Add the row to the container
721 container.add_row(row, section)
723 return row
725 @property
726 def style(self):
727 """Get the default style for this table row"""
728 return self._style
730 @style.setter
731 def style(self, style):
732 """Set the default style for this table row"""
733 self._style = style
735 def add_cell(self, cell: TableCell):
736 """
737 Add a cell to this row.
739 Args:
740 cell: The TableCell to add
741 """
742 self._cells.append(cell)
743 cell.parent = self
745 def create_cell(
746 self,
747 is_header: bool = False,
748 colspan: int = 1,
749 rowspan: int = 1,
750 style=None) -> TableCell:
751 """
752 Create a new table cell and add it to this row.
754 Args:
755 is_header: Whether this cell is a header cell
756 colspan: Number of columns this cell spans
757 rowspan: Number of rows this cell spans
758 style: Optional style override. If None, inherits from row
760 Returns:
761 The newly created TableCell object
762 """
763 return TableCell.create_and_add_to(self, is_header, colspan, rowspan, style)
765 def cells(self) -> Iterator[TableCell]:
766 """
767 Iterate over the cells in this row.
769 Yields:
770 Each TableCell in the row
771 """
772 for cell in self._cells:
773 yield cell
775 @property
776 def cell_count(self) -> int:
777 """Get the number of cells in this row"""
778 return len(self._cells)
781class Table(ContainerAware, Block):
782 """
783 A table element containing rows and cells.
784 """
786 def __init__(self, caption: Optional[str] = None, style=None):
787 """
788 Initialize a table.
790 Args:
791 caption: Optional caption for the table
792 style: Optional default style for child rows
793 """
794 super().__init__(BlockType.TABLE)
795 self._caption = caption
796 self._rows: List[TableRow] = []
797 self._header_rows: List[TableRow] = []
798 self._footer_rows: List[TableRow] = []
799 self._style = style
801 @classmethod
802 def create_and_add_to(
803 cls,
804 container,
805 caption: Optional[str] = None,
806 style=None) -> 'Table':
807 """
808 Create a new Table and add it to a container, inheriting style from
809 the container if not explicitly provided.
811 Args:
812 container: The container to add the table to (must have add_block method)
813 caption: Optional caption for the table
814 style: Optional style override. If None, inherits from container
816 Returns:
817 The newly created Table object
819 Raises:
820 AttributeError: If the container doesn't have the required add_block method
821 """
822 # Validate container and inherit style using ContainerAware utilities
823 cls._validate_container(container)
824 style = cls._inherit_style(container, style)
826 # Create the new table
827 table = cls(caption, style)
829 # Add the table to the container
830 container.add_block(table)
832 return table
834 @property
835 def caption(self) -> Optional[str]:
836 """Get the table caption"""
837 return self._caption
839 @caption.setter
840 def caption(self, caption: Optional[str]):
841 """Set the table caption"""
842 self._caption = caption
844 @property
845 def style(self):
846 """Get the default style for this table"""
847 return self._style
849 @style.setter
850 def style(self, style):
851 """Set the default style for this table"""
852 self._style = style
854 def add_row(self, row: TableRow, section: str = "body"):
855 """
856 Add a row to this table.
858 Args:
859 row: The TableRow to add
860 section: The section to add the row to ("header", "body", or "footer")
861 """
862 row.parent = self
864 if section.lower() == "header":
865 self._header_rows.append(row)
866 elif section.lower() == "footer":
867 self._footer_rows.append(row)
868 else: # Default to body
869 self._rows.append(row)
871 def create_row(self, section: str = "body", style=None) -> TableRow:
872 """
873 Create a new table row and add it to this table.
875 Args:
876 section: The section to add the row to ("header", "body", or "footer")
877 style: Optional style override. If None, inherits from table
879 Returns:
880 The newly created TableRow object
881 """
882 return TableRow.create_and_add_to(self, section, style)
884 def header_rows(self) -> Iterator[TableRow]:
885 """
886 Iterate over the header rows in this table.
888 Yields:
889 Each TableRow in the header section
890 """
891 for row in self._header_rows:
892 yield row
894 def body_rows(self) -> Iterator[TableRow]:
895 """
896 Iterate over the body rows in this table.
898 Yields:
899 Each TableRow in the body section
900 """
901 for row in self._rows:
902 yield row
904 def footer_rows(self) -> Iterator[TableRow]:
905 """
906 Iterate over the footer rows in this table.
908 Yields:
909 Each TableRow in the footer section
910 """
911 for row in self._footer_rows:
912 yield row
914 def all_rows(self) -> Iterator[Tuple[str, TableRow]]:
915 """
916 Iterate over all rows in this table with their section labels.
918 Yields:
919 Tuples of (section, row) for each row in the table
920 """
921 for row in self._header_rows:
922 yield ("header", row)
923 for row in self._rows:
924 yield ("body", row)
925 for row in self._footer_rows:
926 yield ("footer", row)
928 @property
929 def row_count(self) -> Dict[str, int]:
930 """Get the row counts by section"""
931 return {
932 "header": len(self._header_rows),
933 "body": len(self._rows),
934 "footer": len(self._footer_rows),
935 "total": len(self._header_rows) + len(self._rows) + len(self._footer_rows)
936 }
939class Image(Block):
940 """
941 An image element with source, dimensions, and alternative text.
942 """
944 def __init__(
945 self,
946 source: str = "",
947 alt_text: str = "",
948 width: Optional[int] = None,
949 height: Optional[int] = None):
950 """
951 Initialize an image element.
953 Args:
954 source: The image source URL or path
955 alt_text: Alternative text for accessibility
956 width: Optional image width in pixels
957 height: Optional image height in pixels
958 """
959 super().__init__(BlockType.IMAGE)
960 self._source = source
961 self._alt_text = alt_text
962 self._width = width
963 self._height = height
965 @classmethod
966 def create_and_add_to(
967 cls,
968 container,
969 source: str = "",
970 alt_text: str = "",
971 width: Optional[int] = None,
972 height: Optional[int] = None) -> 'Image':
973 """
974 Create a new Image and add it to a container.
976 Args:
977 container: The container to add the image to (must have add_block method)
978 source: The image source URL or path
979 alt_text: Alternative text for accessibility
980 width: Optional image width in pixels
981 height: Optional image height in pixels
983 Returns:
984 The newly created Image object
986 Raises:
987 AttributeError: If the container doesn't have the required add_block method
988 """
989 # Create the new image
990 image = cls(source, alt_text, width, height)
992 # Add the image to the container
993 if hasattr(container, 'add_block'):
994 container.add_block(image)
995 else:
996 raise AttributeError(
997 f"Container {type(container).__name__} must have an 'add_block' method"
998 )
1000 return image
1002 @property
1003 def source(self) -> str:
1004 """Get the image source"""
1005 return self._source
1007 @source.setter
1008 def source(self, source: str):
1009 """Set the image source"""
1010 self._source = source
1012 @property
1013 def alt_text(self) -> str:
1014 """Get the alternative text"""
1015 return self._alt_text
1017 @alt_text.setter
1018 def alt_text(self, alt_text: str):
1019 """Set the alternative text"""
1020 self._alt_text = alt_text
1022 @property
1023 def width(self) -> Optional[int]:
1024 """Get the image width"""
1025 return self._width
1027 @width.setter
1028 def width(self, width: Optional[int]):
1029 """Set the image width"""
1030 self._width = width
1032 @property
1033 def height(self) -> Optional[int]:
1034 """Get the image height"""
1035 return self._height
1037 @height.setter
1038 def height(self, height: Optional[int]):
1039 """Set the image height"""
1040 self._height = height
1042 def get_dimensions(self) -> Tuple[Optional[int], Optional[int]]:
1043 """
1044 Get the image dimensions as a tuple.
1046 Returns:
1047 Tuple of (width, height)
1048 """
1049 return (self._width, self._height)
1051 def get_aspect_ratio(self) -> Optional[float]:
1052 """
1053 Calculate the aspect ratio of the image.
1055 Returns:
1056 The aspect ratio (width/height) or None if either dimension is missing
1057 """
1058 if self._width is not None and self._height is not None and self._height > 0:
1059 return self._width / self._height
1060 return None
1062 def calculate_scaled_dimensions(self,
1063 max_width: Optional[int] = None,
1064 max_height: Optional[int] = None) -> Tuple[Optional[int],
1065 Optional[int]]:
1066 """
1067 Calculate scaled dimensions that fit within the given constraints.
1069 Args:
1070 max_width: Maximum allowed width
1071 max_height: Maximum allowed height
1073 Returns:
1074 Tuple of (scaled_width, scaled_height)
1075 """
1076 if self._width is None or self._height is None:
1077 return (self._width, self._height)
1079 width, height = self._width, self._height
1081 # Scale down if needed
1082 if max_width is not None and width > max_width:
1083 height = int(height * max_width / width)
1084 width = max_width
1086 if max_height is not None and height > max_height: 1086 ↛ 1087line 1086 didn't jump to line 1087 because the condition on line 1086 was never true
1087 width = int(width * max_height / height)
1088 height = max_height
1090 return (width, height)
1092 def _is_url(self, source: str) -> bool:
1093 """
1094 Check if the source is a URL.
1096 Args:
1097 source: The source string to check
1099 Returns:
1100 True if the source appears to be a URL, False otherwise
1101 """
1102 parsed = urllib.parse.urlparse(source)
1103 return bool(parsed.scheme and parsed.netloc)
1105 def _download_to_temp(self, url: str) -> str:
1106 """
1107 Download an image from a URL to a temporary file.
1109 Args:
1110 url: The URL to download from
1112 Returns:
1113 Path to the temporary file
1115 Raises:
1116 urllib.error.URLError: If the download fails
1117 """
1118 # Create a temporary file
1119 temp_fd, temp_path = tempfile.mkstemp(suffix='.tmp')
1121 try:
1122 # Download the image
1123 with urllib.request.urlopen(url) as response:
1124 # Write the response data to the temporary file
1125 with os.fdopen(temp_fd, 'wb') as temp_file:
1126 temp_file.write(response.read())
1128 return temp_path
1129 except BaseException:
1130 # Clean up the temporary file if download fails
1131 try:
1132 os.close(temp_fd)
1133 except BaseException:
1134 pass
1135 try:
1136 os.unlink(temp_path)
1137 except BaseException:
1138 pass
1139 raise
1141 def load_image_data(self,
1142 auto_update_dimensions: bool = True) -> Tuple[Optional[str],
1143 Optional[PILImage.Image]]:
1144 """
1145 Load image data using PIL, handling both local files and URLs.
1147 Args:
1148 auto_update_dimensions: If True, automatically update width and height from the loaded image
1150 Returns:
1151 Tuple of (file_path, PIL_Image_object). For URLs, file_path is the temporary file path.
1152 Returns (None, None) if loading fails.
1153 """
1154 if not self._source:
1155 return None, None
1157 file_path = None
1158 temp_file = None
1160 try:
1161 if self._is_url(self._source):
1162 # Download to temporary file
1163 temp_file = self._download_to_temp(self._source)
1164 file_path = temp_file
1165 else:
1166 # Use local file path
1167 file_path = self._source
1169 # Open with PIL
1170 with PILImage.open(file_path) as img:
1171 # Load the image data
1172 img.load()
1174 # Update dimensions if requested
1175 if auto_update_dimensions:
1176 self._width, self._height = img.size
1178 # Return a copy to avoid issues with the context manager
1179 return file_path, img.copy()
1181 except Exception:
1182 # Clean up temporary file on error
1183 if temp_file and os.path.exists(temp_file): 1183 ↛ 1184line 1183 didn't jump to line 1184 because the condition on line 1183 was never true
1184 try:
1185 os.unlink(temp_file)
1186 except BaseException:
1187 pass
1188 return None, None
1190 def get_image_info(self) -> Dict[str, Any]:
1191 """
1192 Get detailed information about the image using PIL.
1194 Returns:
1195 Dictionary containing image information including format, mode, size, etc.
1196 Returns empty dict if image cannot be loaded.
1197 """
1198 file_path, img = self.load_image_data(auto_update_dimensions=False)
1200 if img is None:
1201 return {}
1203 # Try to determine format from the image, file extension, or source
1204 img_format = img.format
1205 if img_format is None: 1205 ↛ 1229line 1205 didn't jump to line 1229 because the condition on line 1205 was always true
1206 # Try to determine format from file extension
1207 format_map = {
1208 '.jpg': 'JPEG',
1209 '.jpeg': 'JPEG',
1210 '.png': 'PNG',
1211 '.gif': 'GIF',
1212 '.bmp': 'BMP',
1213 '.tiff': 'TIFF',
1214 '.tif': 'TIFF'
1215 }
1217 # First try the actual file path if available
1218 if file_path: 1218 ↛ 1223line 1218 didn't jump to line 1223 because the condition on line 1218 was always true
1219 ext = os.path.splitext(file_path)[1].lower()
1220 img_format = format_map.get(ext)
1222 # If still no format and we have a URL source, try the original URL
1223 if img_format is None and self._is_url(self._source):
1224 ext = os.path.splitext(
1225 urllib.parse.urlparse(
1226 self._source).path)[1].lower()
1227 img_format = format_map.get(ext)
1229 info = {
1230 'format': img_format,
1231 'mode': img.mode,
1232 'size': img.size,
1233 'width': img.width,
1234 'height': img.height,
1235 }
1237 # Add additional info if available
1238 if hasattr(img, 'info'): 1238 ↛ 1242line 1238 didn't jump to line 1242 because the condition on line 1238 was always true
1239 info['info'] = img.info
1241 # Clean up temporary file if it was created
1242 if file_path and self._is_url(self._source):
1243 try:
1244 os.unlink(file_path)
1245 except BaseException:
1246 pass
1248 return info
1251class LinkedImage(Image):
1252 """
1253 An Image that is also a Link - clickable images that navigate or trigger callbacks.
1254 """
1256 def __init__(self, source: str, alt_text: str, location: str,
1257 width: Optional[int] = None, height: Optional[int] = None,
1258 link_type=None,
1259 callback: Optional[Any] = None,
1260 params: Optional[Dict[str, Any]] = None,
1261 title: Optional[str] = None):
1262 """
1263 Initialize a linked image.
1265 Args:
1266 source: The image source URL or path
1267 alt_text: Alternative text for accessibility
1268 location: The link target (URL, bookmark, etc.)
1269 width: Optional image width in pixels
1270 height: Optional image height in pixels
1271 link_type: Type of link (INTERNAL, EXTERNAL, etc.)
1272 callback: Optional callback for link activation
1273 params: Parameters for the link
1274 title: Tooltip/title for the link
1275 """
1276 # Initialize Image
1277 super().__init__(source, alt_text, width, height)
1279 # Store link properties
1280 # Import here to avoid circular imports at module level
1281 from pyWebLayout.abstract.functional import LinkType
1282 self._location = location
1283 self._link_type = link_type or LinkType.EXTERNAL
1284 self._callback = callback
1285 self._params = params or {}
1286 self._link_title = title
1288 @property
1289 def location(self) -> str:
1290 """Get the link target location"""
1291 return self._location
1293 @property
1294 def link_type(self):
1295 """Get the type of link"""
1296 return self._link_type
1298 @property
1299 def link_callback(self) -> Optional[Any]:
1300 """Get the link callback"""
1301 return self._callback
1303 @property
1304 def params(self) -> Dict[str, Any]:
1305 """Get the link parameters"""
1306 return self._params
1308 @property
1309 def link_title(self) -> Optional[str]:
1310 """Get the link title/tooltip"""
1311 return self._link_title
1313 def execute_link(self, context: Optional[Dict[str, Any]] = None) -> Any:
1314 """
1315 Execute the link action.
1317 Args:
1318 context: Optional context dict (e.g., {'alt_text': image.alt_text})
1320 Returns:
1321 The result of the link execution
1322 """
1323 from pyWebLayout.abstract.functional import LinkType
1325 # Add image info to context
1326 full_context = {
1327 **self._params,
1328 'alt_text': self._alt_text,
1329 'source': self._source}
1330 if context: 1330 ↛ 1331line 1330 didn't jump to line 1331 because the condition on line 1330 was never true
1331 full_context.update(context)
1333 if self._link_type in (LinkType.API, LinkType.FUNCTION) and self._callback:
1334 return self._callback(self._location, **full_context)
1335 else:
1336 # For INTERNAL and EXTERNAL links, return the location
1337 return self._location
1340class HorizontalRule(Block):
1341 """
1342 A horizontal rule element (hr tag).
1343 """
1345 def __init__(self):
1346 """Initialize a horizontal rule element."""
1347 super().__init__(BlockType.HORIZONTAL_RULE)
1349 @classmethod
1350 def create_and_add_to(cls, container) -> 'HorizontalRule':
1351 """
1352 Create a new HorizontalRule and add it to a container.
1354 Args:
1355 container: The container to add the horizontal rule to (must have add_block method)
1357 Returns:
1358 The newly created HorizontalRule object
1360 Raises:
1361 AttributeError: If the container doesn't have the required add_block method
1362 """
1363 # Create the new horizontal rule
1364 hr = cls()
1366 # Add the horizontal rule to the container
1367 if hasattr(container, 'add_block'):
1368 container.add_block(hr)
1369 else:
1370 raise AttributeError(
1371 f"Container {type(container).__name__} must have an 'add_block' method"
1372 )
1374 return hr
1377class PageBreak(Block):
1378 """
1379 A page break element that forces content to start on a new page.
1381 When encountered during layout, this block signals that all subsequent
1382 content should be placed on a new page, even if the current page has
1383 available space.
1384 """
1386 def __init__(self):
1387 """Initialize a page break element."""
1388 super().__init__(BlockType.PAGE_BREAK)
1390 @classmethod
1391 def create_and_add_to(cls, container) -> 'PageBreak':
1392 """
1393 Create a new PageBreak and add it to a container.
1395 Args:
1396 container: The container to add the page break to (must have add_block method)
1398 Returns:
1399 The newly created PageBreak object
1401 Raises:
1402 AttributeError: If the container doesn't have the required add_block method
1403 """
1404 # Create the new page break
1405 page_break = cls()
1407 # Add the page break to the container
1408 if hasattr(container, 'add_block'):
1409 container.add_block(page_break)
1410 else:
1411 raise AttributeError(
1412 f"Container {type(container).__name__} must have an 'add_block' method"
1413 )
1415 return page_break