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

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 

10 

11 

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 

27 

28 

29class Block(Hierarchical): 

30 """ 

31 Base class for all block-level elements. 

32 Block elements typically represent visual blocks of content that stack vertically. 

33 

34 Uses Hierarchical mixin for parent-child relationship management. 

35 """ 

36 

37 def __init__(self, block_type: BlockType): 

38 """ 

39 Initialize a block element. 

40 

41 Args: 

42 block_type: The type of block this element represents 

43 """ 

44 super().__init__() 

45 self._block_type = block_type 

46 

47 @property 

48 def block_type(self) -> BlockType: 

49 """Get the type of this block element""" 

50 return self._block_type 

51 

52 

53class Paragraph(Styleable, FontRegistry, ContainerAware, Block): 

54 """ 

55 A paragraph is a block-level element that contains a sequence of words. 

56 

57 Uses Styleable mixin for style property management. 

58 Uses FontRegistry mixin for font caching with parent delegation. 

59 """ 

60 

61 def __init__(self, style=None): 

62 """ 

63 Initialize an empty paragraph 

64 

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] = [] 

71 

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. 

77 

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 

81 

82 Returns: 

83 The newly created Paragraph object 

84 

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) 

91 

92 # Create the new paragraph 

93 paragraph = cls(style) 

94 

95 # Add the paragraph to the container 

96 container.add_block(paragraph) 

97 

98 return paragraph 

99 

100 def add_word(self, word: Word): 

101 """ 

102 Add a word to this paragraph. 

103 

104 Args: 

105 word: The Word object to add 

106 """ 

107 self._words.append(word) 

108 

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. 

112 

113 This is a convenience method that uses Word.create_and_add_to() to create words 

114 that automatically inherit styling from this paragraph. 

115 

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 

120 

121 Returns: 

122 The newly created Word object 

123 """ 

124 return Word.create_and_add_to(text, self, style, background) 

125 

126 def add_span(self, span: FormattedSpan): 

127 """ 

128 Add a formatted span to this paragraph. 

129 

130 Args: 

131 span: The FormattedSpan object to add 

132 """ 

133 self._spans.append(span) 

134 

135 def create_span(self, style=None, background=None) -> FormattedSpan: 

136 """ 

137 Create a new formatted span with inherited style. 

138 

139 Args: 

140 style: Optional Font style override. If None, inherits from paragraph 

141 background: Optional background color override 

142 

143 Returns: 

144 The newly created FormattedSpan object 

145 """ 

146 return FormattedSpan.create_and_add_to(self, style, background) 

147 

148 @property 

149 def words(self) -> List[Word]: 

150 """Get the list of words in this paragraph""" 

151 return self._words 

152 

153 def words_iter(self) -> Iterator[Tuple[int, Word]]: 

154 """ 

155 Iterate over the words in this paragraph. 

156 

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 

162 

163 def spans(self) -> Iterator[FormattedSpan]: 

164 """ 

165 Iterate over the formatted spans in this paragraph. 

166 

167 Yields: 

168 Each FormattedSpan in the paragraph 

169 """ 

170 for span in self._spans: 

171 yield span 

172 

173 @property 

174 def word_count(self) -> int: 

175 """Get the number of words in this paragraph""" 

176 return len(self._words) 

177 

178 def __len__(self): 

179 return self.word_count 

180 

181 # get_or_create_font() is provided by FontRegistry mixin 

182 

183 

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 

192 

193 

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 """ 

199 

200 def __init__(self, level: HeadingLevel = HeadingLevel.H1, style=None): 

201 """ 

202 Initialize a heading element. 

203 

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 

211 

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. 

221 

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 

226 

227 Returns: 

228 The newly created Heading object 

229 

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) 

236 

237 # Create the new heading 

238 heading = cls(level, style) 

239 

240 # Add the heading to the container 

241 container.add_block(heading) 

242 

243 return heading 

244 

245 @property 

246 def level(self) -> HeadingLevel: 

247 """Get the heading level""" 

248 return self._level 

249 

250 @level.setter 

251 def level(self, level: HeadingLevel): 

252 """Set the heading level""" 

253 self._level = level 

254 

255 

256class Quote(BlockContainer, ContainerAware, Block): 

257 """ 

258 A blockquote element that can contain other block elements. 

259 """ 

260 

261 def __init__(self, style=None): 

262 """ 

263 Initialize an empty blockquote 

264 

265 Args: 

266 style: Optional default style for child blocks 

267 """ 

268 super().__init__(BlockType.QUOTE) 

269 self._style = style 

270 

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. 

276 

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 

280 

281 Returns: 

282 The newly created Quote object 

283 

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) 

290 

291 # Create the new quote 

292 quote = cls(style) 

293 

294 # Add the quote to the container 

295 container.add_block(quote) 

296 

297 return quote 

298 

299 @property 

300 def style(self): 

301 """Get the default style for this quote""" 

302 return self._style 

303 

304 @style.setter 

305 def style(self, style): 

306 """Set the default style for this quote""" 

307 self._style = style 

308 

309 

310class CodeBlock(Block): 

311 """ 

312 A code block element containing pre-formatted text with syntax highlighting. 

313 """ 

314 

315 def __init__(self, language: str = ""): 

316 """ 

317 Initialize a code block. 

318 

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] = [] 

325 

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. 

330 

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 

334 

335 Returns: 

336 The newly created CodeBlock object 

337 

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) 

343 

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 ) 

351 

352 return code_block 

353 

354 @property 

355 def language(self) -> str: 

356 """Get the programming language""" 

357 return self._language 

358 

359 @language.setter 

360 def language(self, language: str): 

361 """Set the programming language""" 

362 self._language = language 

363 

364 def add_line(self, line: str): 

365 """ 

366 Add a line of code to this code block. 

367 

368 Args: 

369 line: The line of code to add 

370 """ 

371 self._lines.append(line) 

372 

373 def lines(self) -> Iterator[Tuple[int, str]]: 

374 """ 

375 Iterate over the lines in this code block. 

376 

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 

382 

383 @property 

384 def line_count(self) -> int: 

385 """Get the number of lines in this code block""" 

386 return len(self._lines) 

387 

388 

389class ListStyle(Enum): 

390 """Enumeration of list styles""" 

391 UNORDERED = 1 # <ul> 

392 ORDERED = 2 # <ol> 

393 DEFINITION = 3 # <dl> 

394 

395 

396class HList(ContainerAware, Block): 

397 """ 

398 An HTML list element (ul, ol, dl). 

399 """ 

400 

401 def __init__(self, style: ListStyle = ListStyle.UNORDERED, default_style=None): 

402 """ 

403 Initialize a list. 

404 

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 

413 

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. 

423 

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 

428 

429 Returns: 

430 The newly created HList object 

431 

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) 

438 

439 # Create the new list 

440 hlist = cls(style, default_style) 

441 

442 # Add the list to the container 

443 container.add_block(hlist) 

444 

445 return hlist 

446 

447 @property 

448 def style(self) -> ListStyle: 

449 """Get the list style""" 

450 return self._style 

451 

452 @style.setter 

453 def style(self, style: ListStyle): 

454 """Set the list style""" 

455 self._style = style 

456 

457 @property 

458 def default_style(self): 

459 """Get the default style for list items""" 

460 return self._default_style 

461 

462 @default_style.setter 

463 def default_style(self, style): 

464 """Set the default style for list items""" 

465 self._default_style = style 

466 

467 def add_item(self, item: 'ListItem'): 

468 """ 

469 Add an item to this list. 

470 

471 Args: 

472 item: The ListItem to add 

473 """ 

474 self._items.append(item) 

475 item.parent = self 

476 

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. 

480 

481 Args: 

482 term: Optional term for definition lists 

483 style: Optional style override. If None, inherits from list 

484 

485 Returns: 

486 The newly created ListItem object 

487 """ 

488 return ListItem.create_and_add_to(self, term, style) 

489 

490 def items(self) -> Iterator['ListItem']: 

491 """ 

492 Iterate over the items in this list. 

493 

494 Yields: 

495 Each ListItem in the list 

496 """ 

497 for item in self._items: 

498 yield item 

499 

500 @property 

501 def item_count(self) -> int: 

502 """Get the number of items in this list""" 

503 return len(self._items) 

504 

505 

506class ListItem(BlockContainer, ContainerAware, Block): 

507 """ 

508 A list item element that can contain other block elements. 

509 """ 

510 

511 def __init__(self, term: Optional[str] = None, style=None): 

512 """ 

513 Initialize a list item. 

514 

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 

522 

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. 

532 

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 

537 

538 Returns: 

539 The newly created ListItem object 

540 

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) 

547 

548 # Create the new list item 

549 item = cls(term, style) 

550 

551 # Add the list item to the container 

552 container.add_item(item) 

553 

554 return item 

555 

556 @property 

557 def term(self) -> Optional[str]: 

558 """Get the definition term (for definition lists)""" 

559 return self._term 

560 

561 @term.setter 

562 def term(self, term: str): 

563 """Set the definition term""" 

564 self._term = term 

565 

566 @property 

567 def style(self): 

568 """Get the default style for this list item""" 

569 return self._style 

570 

571 @style.setter 

572 def style(self, style): 

573 """Set the default style for this list item""" 

574 self._style = style 

575 

576 

577class TableCell(BlockContainer, ContainerAware, Block): 

578 """ 

579 A table cell element that can contain other block elements. 

580 """ 

581 

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. 

590 

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 

602 

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. 

609 

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 

616 

617 Returns: 

618 The newly created TableCell object 

619 

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) 

626 

627 # Create the new table cell 

628 cell = cls(is_header, colspan, rowspan, style) 

629 

630 # Add the cell to the container 

631 container.add_cell(cell) 

632 

633 return cell 

634 

635 @property 

636 def is_header(self) -> bool: 

637 """Check if this is a header cell""" 

638 return self._is_header 

639 

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 

644 

645 @property 

646 def colspan(self) -> int: 

647 """Get the column span""" 

648 return self._colspan 

649 

650 @colspan.setter 

651 def colspan(self, colspan: int): 

652 """Set the column span""" 

653 self._colspan = max(1, colspan) # Ensure minimum of 1 

654 

655 @property 

656 def rowspan(self) -> int: 

657 """Get the row span""" 

658 return self._rowspan 

659 

660 @rowspan.setter 

661 def rowspan(self, rowspan: int): 

662 """Set the row span""" 

663 self._rowspan = max(1, rowspan) # Ensure minimum of 1 

664 

665 @property 

666 def style(self): 

667 """Get the default style for this table cell""" 

668 return self._style 

669 

670 @style.setter 

671 def style(self, style): 

672 """Set the default style for this table cell""" 

673 self._style = style 

674 

675 

676class TableRow(ContainerAware, Block): 

677 """ 

678 A table row element containing table cells. 

679 """ 

680 

681 def __init__(self, style=None): 

682 """ 

683 Initialize an empty table row 

684 

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 

691 

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. 

701 

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 

706 

707 Returns: 

708 The newly created TableRow object 

709 

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) 

716 

717 # Create the new table row 

718 row = cls(style) 

719 

720 # Add the row to the container 

721 container.add_row(row, section) 

722 

723 return row 

724 

725 @property 

726 def style(self): 

727 """Get the default style for this table row""" 

728 return self._style 

729 

730 @style.setter 

731 def style(self, style): 

732 """Set the default style for this table row""" 

733 self._style = style 

734 

735 def add_cell(self, cell: TableCell): 

736 """ 

737 Add a cell to this row. 

738 

739 Args: 

740 cell: The TableCell to add 

741 """ 

742 self._cells.append(cell) 

743 cell.parent = self 

744 

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. 

753 

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 

759 

760 Returns: 

761 The newly created TableCell object 

762 """ 

763 return TableCell.create_and_add_to(self, is_header, colspan, rowspan, style) 

764 

765 def cells(self) -> Iterator[TableCell]: 

766 """ 

767 Iterate over the cells in this row. 

768 

769 Yields: 

770 Each TableCell in the row 

771 """ 

772 for cell in self._cells: 

773 yield cell 

774 

775 @property 

776 def cell_count(self) -> int: 

777 """Get the number of cells in this row""" 

778 return len(self._cells) 

779 

780 

781class Table(ContainerAware, Block): 

782 """ 

783 A table element containing rows and cells. 

784 """ 

785 

786 def __init__(self, caption: Optional[str] = None, style=None): 

787 """ 

788 Initialize a table. 

789 

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 

800 

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. 

810 

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 

815 

816 Returns: 

817 The newly created Table object 

818 

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) 

825 

826 # Create the new table 

827 table = cls(caption, style) 

828 

829 # Add the table to the container 

830 container.add_block(table) 

831 

832 return table 

833 

834 @property 

835 def caption(self) -> Optional[str]: 

836 """Get the table caption""" 

837 return self._caption 

838 

839 @caption.setter 

840 def caption(self, caption: Optional[str]): 

841 """Set the table caption""" 

842 self._caption = caption 

843 

844 @property 

845 def style(self): 

846 """Get the default style for this table""" 

847 return self._style 

848 

849 @style.setter 

850 def style(self, style): 

851 """Set the default style for this table""" 

852 self._style = style 

853 

854 def add_row(self, row: TableRow, section: str = "body"): 

855 """ 

856 Add a row to this table. 

857 

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 

863 

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) 

870 

871 def create_row(self, section: str = "body", style=None) -> TableRow: 

872 """ 

873 Create a new table row and add it to this table. 

874 

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 

878 

879 Returns: 

880 The newly created TableRow object 

881 """ 

882 return TableRow.create_and_add_to(self, section, style) 

883 

884 def header_rows(self) -> Iterator[TableRow]: 

885 """ 

886 Iterate over the header rows in this table. 

887 

888 Yields: 

889 Each TableRow in the header section 

890 """ 

891 for row in self._header_rows: 

892 yield row 

893 

894 def body_rows(self) -> Iterator[TableRow]: 

895 """ 

896 Iterate over the body rows in this table. 

897 

898 Yields: 

899 Each TableRow in the body section 

900 """ 

901 for row in self._rows: 

902 yield row 

903 

904 def footer_rows(self) -> Iterator[TableRow]: 

905 """ 

906 Iterate over the footer rows in this table. 

907 

908 Yields: 

909 Each TableRow in the footer section 

910 """ 

911 for row in self._footer_rows: 

912 yield row 

913 

914 def all_rows(self) -> Iterator[Tuple[str, TableRow]]: 

915 """ 

916 Iterate over all rows in this table with their section labels. 

917 

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) 

927 

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 } 

937 

938 

939class Image(Block): 

940 """ 

941 An image element with source, dimensions, and alternative text. 

942 """ 

943 

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. 

952 

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 

964 

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. 

975 

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 

982 

983 Returns: 

984 The newly created Image object 

985 

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) 

991 

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 ) 

999 

1000 return image 

1001 

1002 @property 

1003 def source(self) -> str: 

1004 """Get the image source""" 

1005 return self._source 

1006 

1007 @source.setter 

1008 def source(self, source: str): 

1009 """Set the image source""" 

1010 self._source = source 

1011 

1012 @property 

1013 def alt_text(self) -> str: 

1014 """Get the alternative text""" 

1015 return self._alt_text 

1016 

1017 @alt_text.setter 

1018 def alt_text(self, alt_text: str): 

1019 """Set the alternative text""" 

1020 self._alt_text = alt_text 

1021 

1022 @property 

1023 def width(self) -> Optional[int]: 

1024 """Get the image width""" 

1025 return self._width 

1026 

1027 @width.setter 

1028 def width(self, width: Optional[int]): 

1029 """Set the image width""" 

1030 self._width = width 

1031 

1032 @property 

1033 def height(self) -> Optional[int]: 

1034 """Get the image height""" 

1035 return self._height 

1036 

1037 @height.setter 

1038 def height(self, height: Optional[int]): 

1039 """Set the image height""" 

1040 self._height = height 

1041 

1042 def get_dimensions(self) -> Tuple[Optional[int], Optional[int]]: 

1043 """ 

1044 Get the image dimensions as a tuple. 

1045 

1046 Returns: 

1047 Tuple of (width, height) 

1048 """ 

1049 return (self._width, self._height) 

1050 

1051 def get_aspect_ratio(self) -> Optional[float]: 

1052 """ 

1053 Calculate the aspect ratio of the image. 

1054 

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 

1061 

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. 

1068 

1069 Args: 

1070 max_width: Maximum allowed width 

1071 max_height: Maximum allowed height 

1072 

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) 

1078 

1079 width, height = self._width, self._height 

1080 

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 

1085 

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 

1089 

1090 return (width, height) 

1091 

1092 def _is_url(self, source: str) -> bool: 

1093 """ 

1094 Check if the source is a URL. 

1095 

1096 Args: 

1097 source: The source string to check 

1098 

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) 

1104 

1105 def _download_to_temp(self, url: str) -> str: 

1106 """ 

1107 Download an image from a URL to a temporary file. 

1108 

1109 Args: 

1110 url: The URL to download from 

1111 

1112 Returns: 

1113 Path to the temporary file 

1114 

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') 

1120 

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()) 

1127 

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 

1140 

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. 

1146 

1147 Args: 

1148 auto_update_dimensions: If True, automatically update width and height from the loaded image 

1149 

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 

1156 

1157 file_path = None 

1158 temp_file = None 

1159 

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 

1168 

1169 # Open with PIL 

1170 with PILImage.open(file_path) as img: 

1171 # Load the image data 

1172 img.load() 

1173 

1174 # Update dimensions if requested 

1175 if auto_update_dimensions: 

1176 self._width, self._height = img.size 

1177 

1178 # Return a copy to avoid issues with the context manager 

1179 return file_path, img.copy() 

1180 

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 

1189 

1190 def get_image_info(self) -> Dict[str, Any]: 

1191 """ 

1192 Get detailed information about the image using PIL. 

1193 

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) 

1199 

1200 if img is None: 

1201 return {} 

1202 

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 } 

1216 

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) 

1221 

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) 

1228 

1229 info = { 

1230 'format': img_format, 

1231 'mode': img.mode, 

1232 'size': img.size, 

1233 'width': img.width, 

1234 'height': img.height, 

1235 } 

1236 

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 

1240 

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 

1247 

1248 return info 

1249 

1250 

1251class LinkedImage(Image): 

1252 """ 

1253 An Image that is also a Link - clickable images that navigate or trigger callbacks. 

1254 """ 

1255 

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. 

1264 

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) 

1278 

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 

1287 

1288 @property 

1289 def location(self) -> str: 

1290 """Get the link target location""" 

1291 return self._location 

1292 

1293 @property 

1294 def link_type(self): 

1295 """Get the type of link""" 

1296 return self._link_type 

1297 

1298 @property 

1299 def link_callback(self) -> Optional[Any]: 

1300 """Get the link callback""" 

1301 return self._callback 

1302 

1303 @property 

1304 def params(self) -> Dict[str, Any]: 

1305 """Get the link parameters""" 

1306 return self._params 

1307 

1308 @property 

1309 def link_title(self) -> Optional[str]: 

1310 """Get the link title/tooltip""" 

1311 return self._link_title 

1312 

1313 def execute_link(self, context: Optional[Dict[str, Any]] = None) -> Any: 

1314 """ 

1315 Execute the link action. 

1316 

1317 Args: 

1318 context: Optional context dict (e.g., {'alt_text': image.alt_text}) 

1319 

1320 Returns: 

1321 The result of the link execution 

1322 """ 

1323 from pyWebLayout.abstract.functional import LinkType 

1324 

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) 

1332 

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 

1338 

1339 

1340class HorizontalRule(Block): 

1341 """ 

1342 A horizontal rule element (hr tag). 

1343 """ 

1344 

1345 def __init__(self): 

1346 """Initialize a horizontal rule element.""" 

1347 super().__init__(BlockType.HORIZONTAL_RULE) 

1348 

1349 @classmethod 

1350 def create_and_add_to(cls, container) -> 'HorizontalRule': 

1351 """ 

1352 Create a new HorizontalRule and add it to a container. 

1353 

1354 Args: 

1355 container: The container to add the horizontal rule to (must have add_block method) 

1356 

1357 Returns: 

1358 The newly created HorizontalRule object 

1359 

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() 

1365 

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 ) 

1373 

1374 return hr 

1375 

1376 

1377class PageBreak(Block): 

1378 """ 

1379 A page break element that forces content to start on a new page. 

1380 

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 """ 

1385 

1386 def __init__(self): 

1387 """Initialize a page break element.""" 

1388 super().__init__(BlockType.PAGE_BREAK) 

1389 

1390 @classmethod 

1391 def create_and_add_to(cls, container) -> 'PageBreak': 

1392 """ 

1393 Create a new PageBreak and add it to a container. 

1394 

1395 Args: 

1396 container: The container to add the page break to (must have add_block method) 

1397 

1398 Returns: 

1399 The newly created PageBreak object 

1400 

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() 

1406 

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 ) 

1414 

1415 return page_break