Coverage for pyWebLayout/io/readers/html_extraction.py: 89%

400 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-08 20:34 +0000

1""" 

2HTML extraction module for converting HTML elements to pyWebLayout abstract elements. 

3 

4This module provides handler functions for converting HTML elements into the abstract document structure 

5used by pyWebLayout, including paragraphs, headings, lists, tables, and inline formatting. 

6Each handler function has a robust signature that handles style hints, CSS classes, and attributes. 

7""" 

8 

9from typing import List, Dict, Any, Optional, Union, Callable, Tuple, NamedTuple 

10from bs4 import BeautifulSoup, Tag, NavigableString 

11from bs4.element import CData, Comment, Doctype, ProcessingInstruction 

12from pyWebLayout.abstract.inline import Word 

13from pyWebLayout.abstract.block import ( 

14 Block, 

15 Paragraph, 

16 Heading, 

17 HeadingLevel, 

18 Quote, 

19 CodeBlock, 

20 HList, 

21 ListItem, 

22 ListStyle, 

23 Table, 

24 TableRow, 

25 TableCell, 

26 HorizontalRule, 

27 Image, 

28) 

29from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration 

30 

31 

32class StyleContext(NamedTuple): 

33 """ 

34 Immutable style context passed to handler functions. 

35 Contains all styling information including inherited styles, CSS hints, and element attributes. 

36 """ 

37 

38 font: Font 

39 background: Optional[Tuple[int, int, int, int]] 

40 css_classes: set 

41 css_styles: Dict[str, str] 

42 element_attributes: Dict[str, Any] 

43 parent_elements: List[str] # Stack of parent element names 

44 document: Optional[Any] # Reference to document for font registry 

45 base_path: Optional[str] = None # Base path for resolving relative URLs 

46 

47 def with_font(self, font: Font) -> "StyleContext": 

48 """Create new context with modified font.""" 

49 return self._replace(font=font) 

50 

51 def with_background( 

52 self, background: Optional[Tuple[int, int, int, int]] 

53 ) -> "StyleContext": 

54 """Create new context with modified background.""" 

55 return self._replace(background=background) 

56 

57 def with_css_classes(self, css_classes: set) -> "StyleContext": 

58 """Create new context with modified CSS classes.""" 

59 return self._replace(css_classes=css_classes) 

60 

61 def with_css_styles(self, css_styles: Dict[str, str]) -> "StyleContext": 

62 """Create new context with modified CSS styles.""" 

63 return self._replace(css_styles=css_styles) 

64 

65 def with_attributes(self, attributes: Dict[str, Any]) -> "StyleContext": 

66 """Create new context with modified element attributes.""" 

67 return self._replace(element_attributes=attributes) 

68 

69 def push_element(self, element_name: str) -> "StyleContext": 

70 """Create new context with element pushed onto parent stack.""" 

71 return self._replace(parent_elements=self.parent_elements + [element_name]) 

72 

73 

74def create_base_context( 

75 base_font: Optional[Font] = None, 

76 document=None, 

77 base_path: Optional[str] = None) -> StyleContext: 

78 """ 

79 Create a base style context with default values. 

80 

81 Args: 

82 base_font: Base font to use, defaults to system default 

83 document: Document instance for font registry 

84 base_path: Base directory path for resolving relative URLs 

85 

86 Returns: 

87 StyleContext with default values 

88 """ 

89 # Use document's font registry if available, otherwise create default font 

90 if base_font is None: 

91 if document and hasattr(document, 'get_or_create_font'): 

92 base_font = document.get_or_create_font() 

93 else: 

94 base_font = Font() 

95 

96 return StyleContext( 

97 font=base_font, 

98 background=None, 

99 css_classes=set(), 

100 css_styles={}, 

101 element_attributes={}, 

102 parent_elements=[], 

103 document=document, 

104 base_path=base_path, 

105 ) 

106 

107 

108def apply_element_styling(context: StyleContext, element: Tag) -> StyleContext: 

109 """ 

110 Apply element-specific styling to context based on HTML element and attributes. 

111 

112 Args: 

113 context: Current style context 

114 element: BeautifulSoup Tag object 

115 

116 Returns: 

117 New StyleContext with applied styling 

118 """ 

119 tag_name = element.name.lower() 

120 attributes = dict(element.attrs) if element.attrs else {} 

121 

122 # Start with current context 

123 new_context = context.with_attributes(attributes).push_element(tag_name) 

124 

125 # Apply CSS classes 

126 css_classes = new_context.css_classes.copy() 

127 if "class" in attributes: 

128 classes = ( 

129 attributes["class"].split() 

130 if isinstance(attributes["class"], str) 

131 else attributes["class"] 

132 ) 

133 css_classes.update(classes) 

134 new_context = new_context.with_css_classes(css_classes) 

135 

136 # Apply inline styles 

137 css_styles = new_context.css_styles.copy() 

138 if "style" in attributes: 

139 inline_styles = parse_inline_styles(attributes["style"]) 

140 css_styles.update(inline_styles) 

141 new_context = new_context.with_css_styles(css_styles) 

142 

143 # Apply element-specific default styles 

144 font = apply_element_font_styles( 

145 new_context.font, tag_name, css_styles, new_context) 

146 new_context = new_context.with_font(font) 

147 

148 # Apply background from styles 

149 background = apply_background_styles(new_context.background, css_styles) 

150 new_context = new_context.with_background(background) 

151 

152 return new_context 

153 

154 

155def parse_inline_styles(style_text: str) -> Dict[str, str]: 

156 """ 

157 Parse CSS inline styles into dictionary. 

158 

159 Args: 

160 style_text: CSS style text (e.g., "color: red; font-weight: bold;") 

161 

162 Returns: 

163 Dictionary of CSS property-value pairs 

164 """ 

165 styles = {} 

166 for declaration in style_text.split(";"): 

167 if ":" in declaration: 

168 prop, value = declaration.split(":", 1) 

169 styles[prop.strip().lower()] = value.strip() 

170 return styles 

171 

172 

173def apply_element_font_styles(font: Font, 

174 tag_name: str, 

175 css_styles: Dict[str, 

176 str], 

177 context: Optional[StyleContext] = None) -> Font: 

178 """ 

179 Apply font styling based on HTML element and CSS styles. 

180 Uses document's font registry when available to avoid creating duplicate fonts. 

181 

182 Args: 

183 font: Current font 

184 tag_name: HTML tag name 

185 css_styles: CSS styles dictionary 

186 context: Style context with document reference for font registry 

187 

188 Returns: 

189 Font object with applied styling (either existing or newly created) 

190 """ 

191 # Default element styles 

192 element_font_styles = { 

193 "b": {"weight": FontWeight.BOLD}, 

194 "strong": {"weight": FontWeight.BOLD}, 

195 "i": {"style": FontStyle.ITALIC}, 

196 "em": {"style": FontStyle.ITALIC}, 

197 "u": {"decoration": TextDecoration.UNDERLINE}, 

198 "s": {"decoration": TextDecoration.STRIKETHROUGH}, 

199 "del": {"decoration": TextDecoration.STRIKETHROUGH}, 

200 "h1": {"size": 24, "weight": FontWeight.BOLD}, 

201 "h2": {"size": 20, "weight": FontWeight.BOLD}, 

202 "h3": {"size": 18, "weight": FontWeight.BOLD}, 

203 "h4": {"size": 16, "weight": FontWeight.BOLD}, 

204 "h5": {"size": 14, "weight": FontWeight.BOLD}, 

205 "h6": {"size": 12, "weight": FontWeight.BOLD}, 

206 } 

207 

208 # Start with current font properties 

209 font_size = font.font_size 

210 colour = font.colour 

211 weight = font.weight 

212 style = font.style 

213 decoration = font.decoration 

214 background = font.background 

215 language = font.language 

216 font_path = font._font_path 

217 

218 # Apply element default styles 

219 if tag_name in element_font_styles: 

220 elem_styles = element_font_styles[tag_name] 

221 if "size" in elem_styles: 

222 font_size = elem_styles["size"] 

223 if "weight" in elem_styles: 

224 weight = elem_styles["weight"] 

225 if "style" in elem_styles: 

226 style = elem_styles["style"] 

227 if "decoration" in elem_styles: 

228 decoration = elem_styles["decoration"] 

229 

230 # Apply CSS styles (override element defaults) 

231 if "font-size" in css_styles: 

232 # Parse font-size (simplified - could be enhanced) 

233 size_value = css_styles["font-size"].lower() 

234 if size_value.endswith("px"): 

235 try: 

236 font_size = int(float(size_value[:-2])) 

237 except ValueError: 

238 pass 

239 elif size_value.endswith("pt"): 239 ↛ 240line 239 didn't jump to line 240 because the condition on line 239 was never true

240 try: 

241 font_size = int(float(size_value[:-2])) 

242 except ValueError: 

243 pass 

244 

245 if "font-weight" in css_styles: 

246 weight_value = css_styles["font-weight"].lower() 

247 if weight_value in ["bold", "700", "800", "900"]: 247 ↛ 249line 247 didn't jump to line 249 because the condition on line 247 was always true

248 weight = FontWeight.BOLD 

249 elif weight_value in ["normal", "400"]: 

250 weight = FontWeight.NORMAL 

251 

252 if "font-style" in css_styles: 

253 style_value = css_styles["font-style"].lower() 

254 if style_value == "italic": 

255 style = FontStyle.ITALIC 

256 elif style_value == "normal": 256 ↛ 259line 256 didn't jump to line 259 because the condition on line 256 was always true

257 style = FontStyle.NORMAL 

258 

259 if "text-decoration" in css_styles: 

260 decoration_value = css_styles["text-decoration"].lower() 

261 if "underline" in decoration_value: 

262 decoration = TextDecoration.UNDERLINE 

263 elif "line-through" in decoration_value: 263 ↛ 264line 263 didn't jump to line 264 because the condition on line 263 was never true

264 decoration = TextDecoration.STRIKETHROUGH 

265 elif "none" in decoration_value: 265 ↛ 268line 265 didn't jump to line 268 because the condition on line 265 was always true

266 decoration = TextDecoration.NONE 

267 

268 if "color" in css_styles: 

269 # Parse color (simplified - could be enhanced for hex, rgb, etc.) 

270 color_value = css_styles["color"].lower() 

271 color_map = { 

272 "black": (0, 0, 0), 

273 "white": (255, 255, 255), 

274 "red": (255, 0, 0), 

275 "green": (0, 255, 0), 

276 "blue": (0, 0, 255), 

277 } 

278 if color_value in color_map: 

279 colour = color_map[color_value] 

280 elif color_value.startswith("#") and len(color_value) == 7: 

281 try: 

282 r = int(color_value[1:3], 16) 

283 g = int(color_value[3:5], 16) 

284 b = int(color_value[5:7], 16) 

285 colour = (r, g, b) 

286 except ValueError: 

287 pass 

288 

289 # Use document's style registry if available to avoid creating duplicate styles 

290 if context and context.document and hasattr( 

291 context.document, 'get_or_create_style'): 

292 # Create an abstract style first 

293 from pyWebLayout.style.abstract_style import FontFamily, FontSize 

294 

295 # Map font properties to abstract style properties 

296 font_family = FontFamily.SERIF # Default - could be enhanced to detect from font_path 

297 if font_size: 297 ↛ 301line 297 didn't jump to line 301 because the condition on line 297 was always true

298 font_size_value = font_size if isinstance( 

299 font_size, int) else FontSize.MEDIUM 

300 else: 

301 font_size_value = FontSize.MEDIUM 

302 

303 # Create abstract style and register it 

304 style_id, abstract_style = context.document.get_or_create_style( 

305 font_family=font_family, 

306 font_size=font_size_value, 

307 font_weight=weight, 

308 font_style=style, 

309 text_decoration=decoration, 

310 color=colour, 

311 language=language 

312 ) 

313 

314 # Get the concrete font for this style 

315 return context.document.get_font_for_style(abstract_style) 

316 elif context and context.document and hasattr(context.document, 'get_or_create_font'): 316 ↛ 318line 316 didn't jump to line 318 because the condition on line 316 was never true

317 # Fallback to old font registry system 

318 return context.document.get_or_create_font( 

319 font_path=font_path, 

320 font_size=font_size, 

321 colour=colour, 

322 weight=weight, 

323 style=style, 

324 decoration=decoration, 

325 background=background, 

326 language=language, 

327 min_hyphenation_width=font.min_hyphenation_width 

328 ) 

329 else: 

330 # Fallback to creating new font if no document context 

331 return Font( 

332 font_path=font_path, 

333 font_size=font_size, 

334 colour=colour, 

335 weight=weight, 

336 style=style, 

337 decoration=decoration, 

338 background=background, 

339 language=language, 

340 ) 

341 

342 

343def apply_background_styles( 

344 current_background: Optional[Tuple[int, int, int, int]], css_styles: Dict[str, str] 

345) -> Optional[Tuple[int, int, int, int]]: 

346 """ 

347 Apply background styling from CSS. 

348 

349 Args: 

350 current_background: Current background color (RGBA) 

351 css_styles: CSS styles dictionary 

352 

353 Returns: 

354 New background color or None 

355 """ 

356 if "background-color" in css_styles: 

357 bg_value = css_styles["background-color"].lower() 

358 if bg_value == "transparent": 358 ↛ 359line 358 didn't jump to line 359 because the condition on line 358 was never true

359 return None 

360 # Add color parsing logic here if needed 

361 

362 return current_background 

363 

364 

365def extract_text_content(element: Tag, context: StyleContext) -> List[Word]: 

366 """ 

367 Extract text content from an element, handling inline formatting and links. 

368 

369 Args: 

370 element: BeautifulSoup Tag object 

371 context: Current style context 

372 

373 Returns: 

374 List of Word objects (including LinkedWord for hyperlinks) 

375 """ 

376 return extract_words_from_nodes(list(element.children), context) 

377 

378 

379def extract_words_from_nodes(nodes: List, context: StyleContext) -> List[Word]: 

380 """ 

381 Extract words from a sequence of sibling nodes. 

382 

383 Separated from extract_text_content so that a container holding a mix of 

384 inline and block children can hand over just the inline runs, without 

385 building a synthetic element to wrap them in. 

386 

387 Args: 

388 nodes: BeautifulSoup nodes (Tags and NavigableStrings) in document order 

389 context: Current style context 

390 

391 Returns: 

392 List of Word objects (including LinkedWord for hyperlinks) 

393 """ 

394 from pyWebLayout.abstract.inline import LinkedWord 

395 from pyWebLayout.abstract.functional import LinkType 

396 

397 words = [] 

398 

399 for child in nodes: 

400 # Comments and processing instructions are NavigableString subclasses; 

401 # their text is markup, not content. 

402 if isinstance(child, (Comment, Doctype, CData, ProcessingInstruction)): 402 ↛ 403line 402 didn't jump to line 403 because the condition on line 402 was never true

403 continue 

404 

405 if isinstance(child, NavigableString): 

406 # Plain text - split into words. Argument-less str.split() already 

407 # discards surrounding whitespace and never yields an empty string, so 

408 # it needs neither a preceding strip() nor a per-word emptiness test. 

409 font = context.font 

410 background = context.background 

411 words.extend([Word(word_text, font, background) 

412 for word_text in str(child).split()]) 

413 elif isinstance(child, Tag): 413 ↛ 399line 413 didn't jump to line 399 because the condition on line 413 was always true

414 # Special handling for <a> tags (hyperlinks) 

415 if child.name.lower() == "a": 

416 href = child.get('href', '') 

417 if href: 

418 # Determine link type based on href 

419 if href.startswith(('http://', 'https://')): 

420 link_type = LinkType.EXTERNAL 

421 elif href.startswith('#'): 

422 link_type = LinkType.INTERNAL 

423 elif href.startswith('javascript:') or href.startswith('api:'): 

424 link_type = LinkType.API 

425 else: 

426 link_type = LinkType.INTERNAL 

427 

428 # Apply link styling 

429 child_context = apply_element_styling(context, child) 

430 

431 # Extract text and create LinkedWord for each word 

432 link_text = child.get_text(strip=True) 

433 title = child.get('title', '') 

434 

435 for word_text in link_text.split(): 

436 if word_text: 436 ↛ 435line 436 didn't jump to line 435 because the condition on line 436 was always true

437 linked_word = LinkedWord( 

438 text=word_text, 

439 style=child_context.font, 

440 location=href, 

441 link_type=link_type, 

442 background=child_context.background, 

443 title=title if title else None 

444 ) 

445 words.append(linked_word) 

446 else: 

447 # <a> without href - treat as normal text 

448 child_context = apply_element_styling(context, child) 

449 child_words = extract_text_content(child, child_context) 

450 words.extend(child_words) 

451 

452 # Process other inline elements 

453 elif child.name.lower() in [ 

454 "span", 

455 "strong", 

456 "b", 

457 "em", 

458 "i", 

459 "u", 

460 "s", 

461 "del", 

462 "ins", 

463 "mark", 

464 "small", 

465 "sub", 

466 "sup", 

467 "code", 

468 "q", 

469 "cite", 

470 "abbr", 

471 "time", 

472 ]: 

473 child_context = apply_element_styling(context, child) 

474 child_words = extract_text_content(child, child_context) 

475 words.extend(child_words) 

476 else: 

477 # Block element - shouldn't happen in well-formed HTML but handle 

478 # gracefully 

479 child_context = apply_element_styling(context, child) 

480 child_result = process_element(child, child_context) 

481 if isinstance(child_result, list): 481 ↛ 482line 481 didn't jump to line 482 because the condition on line 481 was never true

482 for block in child_result: 

483 if isinstance(block, Paragraph): 

484 for _, word in block.words_iter(): 

485 words.append(word) 

486 elif isinstance(child_result, Paragraph): 486 ↛ 487line 486 didn't jump to line 487 because the condition on line 486 was never true

487 for _, word in child_result.words_iter(): 

488 words.append(word) 

489 

490 return words 

491 

492 

493# Tags that flow within a line of text rather than forming a block of their own. 

494# They carry no handler of their own: extract_words_from_nodes consumes them, 

495# applying their styling to the words they contain. 

496INLINE_TAGS = frozenset({ 

497 "a", "span", "strong", "b", "em", "i", "u", "s", "del", "ins", "mark", 

498 "small", "sub", "sup", "code", "q", "cite", "abbr", "time", 

499}) 

500 

501 

502def is_inline(node) -> bool: 

503 """ 

504 Whether a node belongs to a run of text rather than standing as its own block. 

505 

506 Args: 

507 node: A BeautifulSoup Tag or NavigableString 

508 

509 Returns: 

510 True for text and inline tags, False for block-level tags 

511 """ 

512 if isinstance(node, Tag): 

513 return node.name.lower() in INLINE_TAGS 

514 if isinstance(node, (Comment, Doctype, CData, ProcessingInstruction)): 

515 return False 

516 return isinstance(node, NavigableString) 

517 

518 

519def process_block_children(element: Tag, context: StyleContext) -> List[Block]: 

520 """ 

521 Process a container's children into a list of blocks. 

522 

523 Containers may hold a mix of inline and block content. Consecutive inline 

524 children are gathered into a run and become one Paragraph; a block child ends 

525 the current run and is processed by its own handler. This is the single entry 

526 point for every container that is not itself a paragraph - div, li, td, th, 

527 blockquote and the semantic containers. 

528 

529 Without this, inline tags reach process_element, whose handler for them is 

530 ignore_handler, and their text is silently dropped. 

531 

532 Args: 

533 element: The container element 

534 context: Current style context 

535 

536 Returns: 

537 Blocks in document order 

538 """ 

539 blocks: List[Block] = [] 

540 run: List = [] 

541 

542 def flush_run(): 

543 """Turn the pending inline run into a paragraph, if it holds any words.""" 

544 if not run: 

545 return 

546 words = extract_words_from_nodes(run, context) 

547 run.clear() 

548 if words: 

549 paragraph = Paragraph(context.font) 

550 for word in words: 

551 paragraph.add_word(word) 

552 blocks.append(paragraph) 

553 

554 for child in element.children: 

555 # <br> ends the current line of text and starts a new one. 

556 if isinstance(child, Tag) and child.name.lower() == "br": 

557 flush_run() 

558 continue 

559 

560 if is_inline(child): 

561 run.append(child) 

562 continue 

563 

564 if not isinstance(child, Tag): 

565 continue # comments and similar 

566 

567 flush_run() 

568 child_context = apply_element_styling(context, child) 

569 result = process_element(child, child_context) 

570 if result: 

571 if isinstance(result, list): 

572 blocks.extend(result) 

573 else: 

574 blocks.append(result) 

575 

576 flush_run() 

577 return blocks 

578 

579 

580def process_element( 

581 element: Tag, context: StyleContext 

582) -> Union[Block, List[Block], None]: 

583 """ 

584 Process a single HTML element using appropriate handler. 

585 

586 Args: 

587 element: BeautifulSoup Tag object 

588 context: Current style context 

589 

590 Returns: 

591 Block object(s) or None if element should be ignored 

592 """ 

593 tag_name = element.name.lower() 

594 handler = HANDLERS.get(tag_name, generic_handler) 

595 return handler(element, context) 

596 

597 

598# Handler function signatures: 

599# All handlers receive (element: Tag, context: StyleContext) -> 

600# Union[Block, List[Block], None] 

601 

602 

603def paragraph_handler(element: Tag, context: StyleContext) -> Union[Paragraph, List[Block], Image]: 

604 """ 

605 Handle <p> elements. 

606 

607 Special handling for paragraphs containing images: 

608 - If the paragraph contains only an image (common in EPUBs), return the image block 

609 - If the paragraph contains images mixed with text, split into separate blocks 

610 - Otherwise, return a normal paragraph with text content 

611 """ 

612 # Check if paragraph contains any img tags (including nested ones) 

613 img_tags = element.find_all('img') 

614 

615 if img_tags: 

616 # Paragraph contains images - need special handling 

617 blocks = [] 

618 

619 # Check if this is an image-only paragraph (very common in EPUBs) 

620 # Get text content without the img tags 

621 text_content = element.get_text(strip=True) 

622 

623 if not text_content or len(text_content.strip()) == 0: 

624 # Image-only paragraph - return just the image(s) 

625 for img_tag in img_tags: 

626 child_context = apply_element_styling(context, img_tag) 

627 img_block = image_handler(img_tag, child_context) 

628 if img_block: 628 ↛ 625line 628 didn't jump to line 625 because the condition on line 628 was always true

629 blocks.append(img_block) 

630 

631 # Return single image or list of images 

632 if len(blocks) == 1: 

633 return blocks[0] 

634 return blocks if blocks else Paragraph(context.font) 

635 

636 # Mixed content - paragraph has both text and images 

637 # Process children in order to preserve structure 

638 for child in element.children: 

639 if isinstance(child, Tag): 

640 if child.name == 'img': 640 ↛ 649line 640 didn't jump to line 649 because the condition on line 640 was always true

641 # Add the image as a separate block 

642 child_context = apply_element_styling(context, child) 

643 img_block = image_handler(child, child_context) 

644 if img_block: 644 ↛ 638line 644 didn't jump to line 638 because the condition on line 644 was always true

645 blocks.append(img_block) 

646 else: 

647 # Process other inline elements as part of text 

648 # This will be handled by extract_text_content below 

649 pass 

650 

651 # Also add a paragraph with the text content 

652 paragraph = Paragraph(context.font) 

653 words = extract_text_content(element, context) 

654 if words: 654 ↛ 659line 654 didn't jump to line 659 because the condition on line 654 was always true

655 for word in words: 

656 paragraph.add_word(word) 

657 blocks.insert(0, paragraph) # Text comes before images 

658 

659 return blocks if blocks else Paragraph(context.font) 

660 

661 # No images - normal paragraph handling 

662 paragraph = Paragraph(context.font) 

663 words = extract_text_content(element, context) 

664 for word in words: 

665 paragraph.add_word(word) 

666 return paragraph 

667 

668 

669def div_handler(element: Tag, context: StyleContext) -> List[Block]: 

670 """Handle <div> elements - treat as generic container.""" 

671 return process_block_children(element, context) 

672 

673 

674def heading_handler(element: Tag, context: StyleContext) -> Heading: 

675 """Handle <h1>-<h6> elements.""" 

676 level_map = { 

677 "h1": HeadingLevel.H1, 

678 "h2": HeadingLevel.H2, 

679 "h3": HeadingLevel.H3, 

680 "h4": HeadingLevel.H4, 

681 "h5": HeadingLevel.H5, 

682 "h6": HeadingLevel.H6, 

683 } 

684 

685 level = level_map.get(element.name.lower(), HeadingLevel.H1) 

686 heading = Heading(level, context.font) 

687 words = extract_text_content(element, context) 

688 for word in words: 

689 heading.add_word(word) 

690 return heading 

691 

692 

693def blockquote_handler(element: Tag, context: StyleContext) -> Quote: 

694 """Handle <blockquote> elements.""" 

695 quote = Quote(context.font) 

696 for block in process_block_children(element, context): 

697 quote.add_block(block) 

698 return quote 

699 

700 

701def preformatted_handler(element: Tag, context: StyleContext) -> CodeBlock: 

702 """Handle <pre> elements.""" 

703 language = context.element_attributes.get("data-language", "") 

704 code_block = CodeBlock(language) 

705 

706 # Preserve whitespace and line breaks in preformatted text 

707 text = element.get_text(separator="\n", strip=False) 

708 for line in text.split("\n"): 

709 code_block.add_line(line) 

710 

711 return code_block 

712 

713 

714def code_handler(element: Tag, context: StyleContext) -> Union[CodeBlock, None]: 

715 """Handle <code> elements.""" 

716 # If parent is <pre>, this is handled by preformatted_handler 

717 if context.parent_elements and context.parent_elements[-1] == "pre": 

718 return None # Will be handled by parent 

719 

720 # Inline code - handled during text extraction 

721 return None 

722 

723 

724def unordered_list_handler(element: Tag, context: StyleContext) -> HList: 

725 """Handle <ul> elements.""" 

726 hlist = HList(ListStyle.UNORDERED, context.font) 

727 for child in element.children: 

728 if isinstance(child, Tag) and child.name.lower() == "li": 

729 child_context = apply_element_styling(context, child) 

730 item = process_element(child, child_context) 

731 if item: 731 ↛ 727line 731 didn't jump to line 727 because the condition on line 731 was always true

732 hlist.add_item(item) 

733 return hlist 

734 

735 

736def ordered_list_handler(element: Tag, context: StyleContext) -> HList: 

737 """Handle <ol> elements.""" 

738 hlist = HList(ListStyle.ORDERED, context.font) 

739 for child in element.children: 

740 if isinstance(child, Tag) and child.name.lower() == "li": 

741 child_context = apply_element_styling(context, child) 

742 item = process_element(child, child_context) 

743 if item: 743 ↛ 739line 743 didn't jump to line 739 because the condition on line 743 was always true

744 hlist.add_item(item) 

745 return hlist 

746 

747 

748def list_item_handler(element: Tag, context: StyleContext) -> ListItem: 

749 """Handle <li> elements.""" 

750 list_item = ListItem(None, context.font) 

751 for block in process_block_children(element, context): 

752 list_item.add_block(block) 

753 return list_item 

754 

755 

756def table_handler(element: Tag, context: StyleContext) -> Table: 

757 """Handle <table> elements.""" 

758 caption = None 

759 caption_elem = element.find("caption") 

760 if caption_elem: 760 ↛ 761line 760 didn't jump to line 761 because the condition on line 760 was never true

761 caption = caption_elem.get_text(strip=True) 

762 

763 table = Table(caption, context.font) 

764 

765 # Process table rows 

766 for child in element.children: 

767 if isinstance(child, Tag): 

768 if child.name.lower() == "tr": 

769 child_context = apply_element_styling(context, child) 

770 row = process_element(child, child_context) 

771 if row: 771 ↛ 766line 771 didn't jump to line 766 because the condition on line 771 was always true

772 table.add_row(row) 

773 elif child.name.lower() in ["thead", "tbody", "tfoot"]: 773 ↛ 766line 773 didn't jump to line 766 because the condition on line 773 was always true

774 section = "header" if child.name.lower() == "thead" else "body" 

775 section = "footer" if child.name.lower() == "tfoot" else section 

776 

777 for row_elem in child.find_all("tr"): 

778 child_context = apply_element_styling(context, row_elem) 

779 row = process_element(row_elem, child_context) 

780 if row: 780 ↛ 777line 780 didn't jump to line 777 because the condition on line 780 was always true

781 table.add_row(row, section) 

782 

783 return table 

784 

785 

786def table_row_handler(element: Tag, context: StyleContext) -> TableRow: 

787 """Handle <tr> elements.""" 

788 row = TableRow(context.font) 

789 for child in element.children: 

790 if isinstance(child, Tag) and child.name.lower() in ["td", "th"]: 

791 child_context = apply_element_styling(context, child) 

792 cell = process_element(child, child_context) 

793 if cell: 793 ↛ 789line 793 didn't jump to line 789 because the condition on line 793 was always true

794 row.add_cell(cell) 

795 return row 

796 

797 

798def table_cell_handler(element: Tag, context: StyleContext) -> TableCell: 

799 """Handle <td> elements.""" 

800 colspan = int(context.element_attributes.get("colspan", 1)) 

801 rowspan = int(context.element_attributes.get("rowspan", 1)) 

802 cell = TableCell(False, colspan, rowspan, context.font) 

803 

804 for block in process_block_children(element, context): 

805 cell.add_block(block) 

806 

807 return cell 

808 

809 

810def table_header_cell_handler(element: Tag, context: StyleContext) -> TableCell: 

811 """Handle <th> elements.""" 

812 colspan = int(context.element_attributes.get("colspan", 1)) 

813 rowspan = int(context.element_attributes.get("rowspan", 1)) 

814 cell = TableCell(True, colspan, rowspan, context.font) 

815 

816 for block in process_block_children(element, context): 

817 cell.add_block(block) 

818 

819 return cell 

820 

821 

822def horizontal_rule_handler(element: Tag, context: StyleContext) -> HorizontalRule: 

823 """Handle <hr> elements.""" 

824 return HorizontalRule() 

825 

826 

827def line_break_handler(element: Tag, context: StyleContext) -> None: 

828 """Handle <br> elements.""" 

829 # Line breaks are typically handled at the paragraph level 

830 return None 

831 

832 

833def image_handler(element: Tag, context: StyleContext) -> Image: 

834 """Handle <img> elements.""" 

835 import os 

836 import urllib.parse 

837 

838 src = context.element_attributes.get("src", "") 

839 alt_text = context.element_attributes.get("alt", "") 

840 

841 # Resolve relative paths if base_path is provided 

842 if context.base_path and src and not src.startswith(('http://', 'https://', '/')): 

843 # Parse the src to handle URL-encoded characters 

844 src_decoded = urllib.parse.unquote(src) 

845 # Resolve relative path to absolute path 

846 src = os.path.normpath(os.path.join(context.base_path, src_decoded)) 

847 

848 # Parse dimensions if provided 

849 width = height = None 

850 try: 

851 if "width" in context.element_attributes: 

852 width = int(context.element_attributes["width"]) 

853 if "height" in context.element_attributes: 

854 height = int(context.element_attributes["height"]) 

855 except ValueError: 

856 pass 

857 

858 return Image(source=src, alt_text=alt_text, width=width, height=height) 

859 

860 

861def ignore_handler(element: Tag, context: StyleContext) -> None: 

862 """Handle elements that should be ignored.""" 

863 return None 

864 

865 

866def generic_handler(element: Tag, context: StyleContext) -> List[Block]: 

867 """Handle unknown elements as generic containers.""" 

868 return div_handler(element, context) 

869 

870 

871# Handler registry - maps HTML tag names to handler functions 

872HANDLERS: Dict[str, Callable[[Tag, StyleContext], Union[Block, List[Block], None]]] = { 

873 # Block elements 

874 "p": paragraph_handler, 

875 "div": div_handler, 

876 "h1": heading_handler, 

877 "h2": heading_handler, 

878 "h3": heading_handler, 

879 "h4": heading_handler, 

880 "h5": heading_handler, 

881 "h6": heading_handler, 

882 "blockquote": blockquote_handler, 

883 "pre": preformatted_handler, 

884 "code": code_handler, 

885 "ul": unordered_list_handler, 

886 "ol": ordered_list_handler, 

887 "li": list_item_handler, 

888 "table": table_handler, 

889 "tr": table_row_handler, 

890 "td": table_cell_handler, 

891 "th": table_header_cell_handler, 

892 "hr": horizontal_rule_handler, 

893 "br": line_break_handler, 

894 # Semantic elements (treated as containers) 

895 "section": div_handler, 

896 "article": div_handler, 

897 "aside": div_handler, 

898 "nav": div_handler, 

899 "header": div_handler, 

900 "footer": div_handler, 

901 "main": div_handler, 

902 "figure": div_handler, 

903 "figcaption": paragraph_handler, 

904 # Media elements 

905 "img": image_handler, 

906 # Inline elements (handled during text extraction) 

907 "span": ignore_handler, 

908 "a": ignore_handler, 

909 "strong": ignore_handler, 

910 "b": ignore_handler, 

911 "em": ignore_handler, 

912 "i": ignore_handler, 

913 "u": ignore_handler, 

914 "s": ignore_handler, 

915 "del": ignore_handler, 

916 "ins": ignore_handler, 

917 "mark": ignore_handler, 

918 "small": ignore_handler, 

919 "sub": ignore_handler, 

920 "sup": ignore_handler, 

921 "q": ignore_handler, 

922 "cite": ignore_handler, 

923 "abbr": ignore_handler, 

924 "time": ignore_handler, 

925 # Ignored elements 

926 "script": ignore_handler, 

927 "style": ignore_handler, 

928 "meta": ignore_handler, 

929 "link": ignore_handler, 

930 "head": ignore_handler, 

931 "title": ignore_handler, 

932} 

933 

934 

935def parse_html_string( 

936 html_string: str, base_font: Optional[Font] = None, document=None, base_path: Optional[str] = None 

937) -> List[Block]: 

938 """ 

939 Parse HTML string and return list of Block objects. 

940 

941 Args: 

942 html_string: HTML content to parse 

943 base_font: Base font for styling, defaults to system default 

944 document: Document instance for font registry to avoid duplicate fonts 

945 base_path: Base directory path for resolving relative URLs (e.g., image sources) 

946 

947 Returns: 

948 List of Block objects representing the document structure 

949 """ 

950 soup = BeautifulSoup(html_string, "html.parser") 

951 context = create_base_context(base_font, document, base_path) 

952 

953 blocks = [] 

954 

955 # Process the body if it exists, otherwise process all top-level elements 

956 root_element = soup.find("body") or soup 

957 

958 for element in root_element.children: 

959 if isinstance(element, Tag): 

960 element_context = apply_element_styling(context, element) 

961 result = process_element(element, element_context) 

962 if result: 

963 if isinstance(result, list): 

964 blocks.extend(result) 

965 else: 

966 blocks.append(result) 

967 

968 return blocks