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
« 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.
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"""
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
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 """
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
47 def with_font(self, font: Font) -> "StyleContext":
48 """Create new context with modified font."""
49 return self._replace(font=font)
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)
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)
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)
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)
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])
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.
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
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()
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 )
108def apply_element_styling(context: StyleContext, element: Tag) -> StyleContext:
109 """
110 Apply element-specific styling to context based on HTML element and attributes.
112 Args:
113 context: Current style context
114 element: BeautifulSoup Tag object
116 Returns:
117 New StyleContext with applied styling
118 """
119 tag_name = element.name.lower()
120 attributes = dict(element.attrs) if element.attrs else {}
122 # Start with current context
123 new_context = context.with_attributes(attributes).push_element(tag_name)
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)
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)
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)
148 # Apply background from styles
149 background = apply_background_styles(new_context.background, css_styles)
150 new_context = new_context.with_background(background)
152 return new_context
155def parse_inline_styles(style_text: str) -> Dict[str, str]:
156 """
157 Parse CSS inline styles into dictionary.
159 Args:
160 style_text: CSS style text (e.g., "color: red; font-weight: bold;")
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
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.
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
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 }
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
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"]
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
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
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
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
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
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
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
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 )
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 )
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.
349 Args:
350 current_background: Current background color (RGBA)
351 css_styles: CSS styles dictionary
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
362 return current_background
365def extract_text_content(element: Tag, context: StyleContext) -> List[Word]:
366 """
367 Extract text content from an element, handling inline formatting and links.
369 Args:
370 element: BeautifulSoup Tag object
371 context: Current style context
373 Returns:
374 List of Word objects (including LinkedWord for hyperlinks)
375 """
376 return extract_words_from_nodes(list(element.children), context)
379def extract_words_from_nodes(nodes: List, context: StyleContext) -> List[Word]:
380 """
381 Extract words from a sequence of sibling nodes.
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.
387 Args:
388 nodes: BeautifulSoup nodes (Tags and NavigableStrings) in document order
389 context: Current style context
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
397 words = []
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
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
428 # Apply link styling
429 child_context = apply_element_styling(context, child)
431 # Extract text and create LinkedWord for each word
432 link_text = child.get_text(strip=True)
433 title = child.get('title', '')
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)
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)
490 return words
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})
502def is_inline(node) -> bool:
503 """
504 Whether a node belongs to a run of text rather than standing as its own block.
506 Args:
507 node: A BeautifulSoup Tag or NavigableString
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)
519def process_block_children(element: Tag, context: StyleContext) -> List[Block]:
520 """
521 Process a container's children into a list of blocks.
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.
529 Without this, inline tags reach process_element, whose handler for them is
530 ignore_handler, and their text is silently dropped.
532 Args:
533 element: The container element
534 context: Current style context
536 Returns:
537 Blocks in document order
538 """
539 blocks: List[Block] = []
540 run: List = []
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)
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
560 if is_inline(child):
561 run.append(child)
562 continue
564 if not isinstance(child, Tag):
565 continue # comments and similar
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)
576 flush_run()
577 return blocks
580def process_element(
581 element: Tag, context: StyleContext
582) -> Union[Block, List[Block], None]:
583 """
584 Process a single HTML element using appropriate handler.
586 Args:
587 element: BeautifulSoup Tag object
588 context: Current style context
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)
598# Handler function signatures:
599# All handlers receive (element: Tag, context: StyleContext) ->
600# Union[Block, List[Block], None]
603def paragraph_handler(element: Tag, context: StyleContext) -> Union[Paragraph, List[Block], Image]:
604 """
605 Handle <p> elements.
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')
615 if img_tags:
616 # Paragraph contains images - need special handling
617 blocks = []
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)
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)
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)
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
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
659 return blocks if blocks else Paragraph(context.font)
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
669def div_handler(element: Tag, context: StyleContext) -> List[Block]:
670 """Handle <div> elements - treat as generic container."""
671 return process_block_children(element, context)
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 }
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
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
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)
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)
711 return code_block
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
720 # Inline code - handled during text extraction
721 return None
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
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
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
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)
763 table = Table(caption, context.font)
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
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)
783 return table
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
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)
804 for block in process_block_children(element, context):
805 cell.add_block(block)
807 return cell
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)
816 for block in process_block_children(element, context):
817 cell.add_block(block)
819 return cell
822def horizontal_rule_handler(element: Tag, context: StyleContext) -> HorizontalRule:
823 """Handle <hr> elements."""
824 return HorizontalRule()
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
833def image_handler(element: Tag, context: StyleContext) -> Image:
834 """Handle <img> elements."""
835 import os
836 import urllib.parse
838 src = context.element_attributes.get("src", "")
839 alt_text = context.element_attributes.get("alt", "")
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))
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
858 return Image(source=src, alt_text=alt_text, width=width, height=height)
861def ignore_handler(element: Tag, context: StyleContext) -> None:
862 """Handle elements that should be ignored."""
863 return None
866def generic_handler(element: Tag, context: StyleContext) -> List[Block]:
867 """Handle unknown elements as generic containers."""
868 return div_handler(element, context)
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}
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.
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)
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)
953 blocks = []
955 # Process the body if it exists, otherwise process all top-level elements
956 root_element = soup.find("body") or soup
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)
968 return blocks