Update coverage badges [skip ci]

This commit is contained in:
Gitea Action
2026-08-08 20:35:15 +00:00
commit 735face593
312 changed files with 91102 additions and 0 deletions
View File
File diff suppressed because it is too large Load Diff
+687
View File
@@ -0,0 +1,687 @@
"""
Unit tests for HTML extraction functionality.
Tests the HTML parsing and conversion to pyWebLayout abstract elements,
including styled content within paragraphs and block-level elements.
"""
import unittest
from pyWebLayout.io.readers.html_extraction import parse_html_string
from pyWebLayout.abstract.block import Paragraph, Heading, HeadingLevel, Quote, CodeBlock, HList, ListStyle, Table, Image
from pyWebLayout.abstract.document import Document
from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration
class TestHTMLParagraph(unittest.TestCase):
"""Test cases for basic paragraph parsing."""
def test_simple(self):
text = "<p>This is a paragraph.</p>"
paragraphs = parse_html_string(text)
self.assertEqual(len(paragraphs), 1)
self.assertEqual(len(paragraphs[0]), 4)
for w1, t1 in zip(paragraphs[0].words_iter(),
"This is a paragraph.".split(" ")):
self.assertEqual(w1[1].text, t1)
def test_multiple(self):
text = "<p>This is a paragraph.</p><p>This is another paragraph.</p>"
paragraphs = parse_html_string(text)
self.assertEqual(len(paragraphs), 2)
self.assertEqual(len(paragraphs[0]), 4)
self.assertEqual(len(paragraphs[1]), 4)
for w1, t1 in zip(paragraphs[0].words_iter(),
"This is a paragraph.".split(" ")):
self.assertEqual(w1[1].text, t1)
for w1, t1 in zip(paragraphs[1].words_iter(),
"This is another paragraph.".split(" ")):
self.assertEqual(w1[1].text, t1)
class TestHTMLStyledParagraphs(unittest.TestCase):
"""Test cases for paragraphs with inline styling."""
def test_bold_text(self):
"""Test paragraphs with bold text using <strong> and <b> tags."""
text = "<p>This is <strong>bold text</strong> in a paragraph.</p>"
blocks = parse_html_string(text)
self.assertEqual(len(blocks), 1)
self.assertIsInstance(blocks[0], Paragraph)
words = list(blocks[0].words_iter())
self.assertEqual(len(words), 7) # "This is bold text in a paragraph."
# Check that 'bold' and 'text' words have bold font weight
bold_word = words[2][1] # 'bold'
text_word = words[3][1] # 'text'
self.assertEqual(bold_word.text, "bold")
self.assertEqual(bold_word.style.weight, FontWeight.BOLD)
self.assertEqual(text_word.text, "text")
self.assertEqual(text_word.style.weight, FontWeight.BOLD)
# Check that other words are not bold
normal_word = words[0][1] # 'This'
self.assertEqual(normal_word.text, "This")
self.assertNotEqual(normal_word.style.weight, FontWeight.BOLD)
def test_italic_text(self):
"""Test paragraphs with italic text using <em> and <i> tags."""
text = "<p>This is <em>italic text</em> in a paragraph.</p>"
blocks = parse_html_string(text)
self.assertEqual(len(blocks), 1)
self.assertIsInstance(blocks[0], Paragraph)
words = list(blocks[0].words_iter())
# Check that 'italic' and 'text' words have italic font style
italic_word = words[2][1] # 'italic'
text_word = words[3][1] # 'text'
self.assertEqual(italic_word.text, "italic")
self.assertEqual(italic_word.style.style, FontStyle.ITALIC)
self.assertEqual(text_word.text, "text")
self.assertEqual(text_word.style.style, FontStyle.ITALIC)
def test_underlined_text(self):
"""Test paragraphs with underlined text using <u> tag."""
text = "<p>This is <u>underlined text</u> here.</p>"
blocks = parse_html_string(text)
self.assertEqual(len(blocks), 1)
words = list(blocks[0].words_iter())
underlined_word = words[2][1] # 'underlined'
self.assertEqual(underlined_word.style.decoration, TextDecoration.UNDERLINE)
def test_strikethrough_text(self):
"""Test paragraphs with strikethrough text using <s> and <del> tags."""
text = "<p>This is <s>strikethrough text</s> here.</p>"
blocks = parse_html_string(text)
self.assertEqual(len(blocks), 1)
words = list(blocks[0].words_iter())
strike_word = words[2][1] # 'strikethrough'
self.assertEqual(strike_word.style.decoration, TextDecoration.STRIKETHROUGH)
def test_span_with_inline_styles(self):
"""Test paragraphs with span elements containing inline CSS styles."""
text = (
'<p>This text is normal, but <span style="color: red; font-weight: bold;">'
'this part is red and bold</span>.</p>'
)
blocks = parse_html_string(text)
self.assertEqual(len(blocks), 1)
self.assertIsInstance(blocks[0], Paragraph)
words = list(blocks[0].words_iter())
# Find the styled words
styled_words = []
for _, word in words:
if word.text in ["this", "part", "is", "red", "and", "bold"]:
if word.style.weight == FontWeight.BOLD:
styled_words.append(word)
self.assertGreater(
len(styled_words),
0,
"Should have bold words in styled span")
# Check that at least one word has the red color
red_words = [w for w in styled_words if w.style.colour == (255, 0, 0)]
self.assertGreater(len(red_words), 0, "Should have red colored words")
def test_mixed_formatting(self):
"""Test paragraphs with multiple formatting elements combined."""
text = (
'<p>This paragraph contains <strong>bold</strong>, <em>italic</em>, '
'<span style="color: blue;">blue</span>, and <mark>highlighted</mark> '
'text all together.</p>'
)
blocks = parse_html_string(text)
self.assertEqual(len(blocks), 1)
self.assertIsInstance(blocks[0], Paragraph)
words = list(blocks[0].words_iter())
# Check for bold word
bold_words = [w for _, w in words if w.style.weight == FontWeight.BOLD]
self.assertGreater(len(bold_words), 0, "Should have bold words")
# Check for italic word
italic_words = [w for _, w in words if w.style.style == FontStyle.ITALIC]
self.assertGreater(len(italic_words), 0, "Should have italic words")
# Check for blue colored word
blue_words = [w for _, w in words if w.style.colour == (0, 0, 255)]
self.assertGreater(len(blue_words), 0, "Should have blue colored words")
def test_nested_formatting(self):
"""Test nested formatting elements."""
text = "<p>This has <strong>bold with <em>italic inside</em></strong> formatting.</p>"
blocks = parse_html_string(text)
self.assertEqual(len(blocks), 1)
words = list(blocks[0].words_iter())
# Find words that should be both bold and italic
bold_italic_words = [w for _, w in words if w.style.weight ==
FontWeight.BOLD and w.style.style == FontStyle.ITALIC]
self.assertGreater(
len(bold_italic_words),
0,
"Should have words that are both bold and italic")
def test_color_variations(self):
"""Test different color formats in CSS."""
text = '<p><span style="color: #ff0000;">Hex red</span> and <span style="color: green;">Named green</span>.</p>'
blocks = parse_html_string(text)
self.assertEqual(len(blocks), 1)
words = list(blocks[0].words_iter())
# Check for hex red color
hex_red_words = [w for _, w in words if w.style.colour == (255, 0, 0)]
self.assertGreater(len(hex_red_words), 0, "Should have hex red colored words")
# Check for named green color
green_words = [w for _, w in words if w.style.colour == (0, 255, 0)]
self.assertGreater(len(green_words), 0, "Should have green colored words")
class TestHTMLBlockElements(unittest.TestCase):
"""Test cases for block-level HTML elements."""
def test_body_element(self):
"""Test parsing of body element containing other elements."""
text = "<body><p>Paragraph one.</p><p>Paragraph two.</p></body>"
blocks = parse_html_string(text)
self.assertEqual(len(blocks), 2)
self.assertIsInstance(blocks[0], Paragraph)
self.assertIsInstance(blocks[1], Paragraph)
def test_div_container(self):
"""Test div elements as generic containers."""
text = "<div><p>First paragraph.</p><p>Second paragraph.</p></div>"
blocks = parse_html_string(text)
self.assertEqual(len(blocks), 2)
self.assertIsInstance(blocks[0], Paragraph)
self.assertIsInstance(blocks[1], Paragraph)
def test_headings(self):
"""Test all heading levels h1-h6."""
text = (
"<h1>Heading 1</h1><h2>Heading 2</h2><h3>Heading 3</h3>"
"<h4>Heading 4</h4><h5>Heading 5</h5><h6>Heading 6</h6>"
)
blocks = parse_html_string(text)
self.assertEqual(len(blocks), 6)
expected_levels = [HeadingLevel.H1, HeadingLevel.H2, HeadingLevel.H3,
HeadingLevel.H4, HeadingLevel.H5, HeadingLevel.H6]
for i, block in enumerate(blocks):
self.assertIsInstance(block, Heading)
self.assertEqual(block.level, expected_levels[i])
words = list(block.words_iter())
self.assertEqual(len(words), 2) # "Heading" and number
self.assertEqual(words[0][1].text, "Heading")
def test_blockquote(self):
"""Test blockquote elements."""
text = "<blockquote><p>This is a quoted paragraph.</p></blockquote>"
blocks = parse_html_string(text)
self.assertEqual(len(blocks), 1)
self.assertIsInstance(blocks[0], Quote)
# Check that the quote contains a paragraph
quote_blocks = list(blocks[0].blocks())
self.assertEqual(len(quote_blocks), 1)
self.assertIsInstance(quote_blocks[0], Paragraph)
def test_preformatted_code(self):
"""Test preformatted code blocks."""
text = "<pre><code>function hello() {\n console.log('Hello');\n}</code></pre>"
blocks = parse_html_string(text)
self.assertEqual(len(blocks), 1)
self.assertIsInstance(blocks[0], CodeBlock)
lines = list(blocks[0].lines())
self.assertGreater(len(lines), 0)
def test_unordered_list(self):
"""Test unordered lists."""
text = "<ul><li>First item</li><li>Second item</li><li>Third item</li></ul>"
blocks = parse_html_string(text)
self.assertEqual(len(blocks), 1)
self.assertIsInstance(blocks[0], HList)
self.assertEqual(blocks[0].style, ListStyle.UNORDERED)
items = list(blocks[0].items())
self.assertEqual(len(items), 3)
def test_ordered_list(self):
"""Test ordered lists."""
text = "<ol><li>First item</li><li>Second item</li><li>Third item</li></ol>"
blocks = parse_html_string(text)
self.assertEqual(len(blocks), 1)
self.assertIsInstance(blocks[0], HList)
self.assertEqual(blocks[0].style, ListStyle.ORDERED)
def test_list_with_styled_content(self):
"""Test lists containing styled content."""
text = "<ul><li>Normal item</li><li><strong>Bold item</strong></li><li>Item with <em>italic</em> text</li></ul>"
blocks = parse_html_string(text)
self.assertEqual(len(blocks), 1)
self.assertIsInstance(blocks[0], HList)
items = list(blocks[0].items())
self.assertEqual(len(items), 3)
# Check second item has bold text
second_item_blocks = list(items[1].blocks())
if second_item_blocks:
words = list(second_item_blocks[0].words_iter())
bold_words = [w for _, w in words if w.style.weight == FontWeight.BOLD]
self.assertGreater(len(bold_words), 0)
def test_table_basic(self):
"""Test basic table structure."""
text = """
<table>
<tr>
<th>Header 1</th>
<th>Header 2</th>
</tr>
<tr>
<td>Cell 1</td>
<td>Cell 2</td>
</tr>
</table>
"""
blocks = parse_html_string(text)
self.assertEqual(len(blocks), 1)
self.assertIsInstance(blocks[0], Table)
def test_semantic_elements(self):
"""Test semantic HTML5 elements treated as containers."""
text = "<section><article><p>Article content</p></article></section>"
blocks = parse_html_string(text)
self.assertEqual(len(blocks), 1)
self.assertIsInstance(blocks[0], Paragraph)
def test_nested_block_elements(self):
"""Test nested block elements."""
text = """
<div>
<h2>Section Title</h2>
<p>Some introductory text.</p>
<blockquote>
<p>A quoted paragraph.</p>
</blockquote>
</div>
"""
blocks = parse_html_string(text)
self.assertGreater(len(blocks), 2)
# Should have at least a heading, paragraph, and quote
has_heading = any(isinstance(b, Heading) for b in blocks)
has_paragraph = any(isinstance(b, Paragraph) for b in blocks)
has_quote = any(isinstance(b, Quote) for b in blocks)
self.assertTrue(has_heading, "Should contain a heading")
self.assertTrue(has_paragraph, "Should contain a paragraph")
self.assertTrue(has_quote, "Should contain a quote")
def test_empty_elements(self):
"""Test handling of empty elements."""
text = "<p></p><div></div><span></span>"
blocks = parse_html_string(text)
# Empty elements may not create blocks, which is acceptable behavior
self.assertGreaterEqual(len(blocks), 0)
# Test that empty paragraph with some content does create a block
text_with_content = "<p> </p>" # Contains whitespace
blocks_with_content = parse_html_string(text_with_content)
# This should create at least one block since there's whitespace content
self.assertGreaterEqual(len(blocks_with_content), 0)
class TestHTMLComplexStructures(unittest.TestCase):
"""Test cases for complex HTML structures combining multiple features."""
def test_article_with_mixed_content(self):
"""Test a realistic article structure with mixed content."""
text = """
<article>
<h1>Article Title</h1>
<p>This is the <strong>introduction</strong> paragraph with <em>some emphasis</em>.</p>
<blockquote>
<p>This is a <span style="color: blue;">quoted section</span> with styling.</p>
</blockquote>
<ul>
<li>First <strong>important</strong> point</li>
<li>Second point with <code>inline code</code></li>
</ul>
</article>
"""
blocks = parse_html_string(text)
self.assertGreater(len(blocks), 3)
# Verify we have the expected block types
block_types = [type(b).__name__ for b in blocks]
self.assertIn('Heading', block_types)
self.assertIn('Paragraph', block_types)
self.assertIn('Quote', block_types)
self.assertIn('HList', block_types)
def test_styled_table_content(self):
"""Test table with styled cell content."""
text = """
<table>
<thead>
<tr>
<th><strong>Product</strong></th>
<th><em>Price</em></th>
</tr>
</thead>
<tbody>
<tr>
<td>Item with <span style="color: red;">red text</span></td>
<td><strong>$19.99</strong></td>
</tr>
</tbody>
</table>
"""
blocks = parse_html_string(text)
self.assertEqual(len(blocks), 1)
self.assertIsInstance(blocks[0], Table)
class TestHTMLFontRegistryIntegration(unittest.TestCase):
"""Test cases for font registry integration with HTML extraction."""
def setUp(self):
"""Set up test fixtures."""
self.doc = Document("Test Document", "en-US")
self.base_font = Font(font_size=16, colour=(0, 0, 0))
def test_font_registry_creates_fonts(self):
"""Test that HTML parsing with document context creates fonts in registry."""
html_content = """
<div>
<p>This is <strong>bold text</strong> and <em>italic text</em>.</p>
<h1>Main Header</h1>
</div>
"""
# Initially empty style registry
initial_style_count = self.doc.get_style_registry().get_style_count()
# Parse HTML with document context
blocks = parse_html_string(html_content, self.base_font, document=self.doc)
# Should have created styles for different formatting
final_style_count = self.doc.get_style_registry().get_style_count()
self.assertGreater(final_style_count, initial_style_count,
"Should have created styles in registry")
# Should have created blocks
self.assertGreater(len(blocks), 0, "Should have created blocks")
def test_font_registry_reuses_fonts(self):
"""Test that parsing same content reuses existing styles."""
html_content = """
<div>
<p>This is <strong>bold text</strong> and <em>italic text</em>.</p>
<h1>Main Header</h1>
</div>
"""
# First parse
blocks1 = parse_html_string(html_content, self.base_font, document=self.doc)
first_parse_style_count = self.doc.get_style_registry().get_style_count()
# Second parse with same content
blocks2 = parse_html_string(html_content, self.base_font, document=self.doc)
second_parse_style_count = self.doc.get_style_registry().get_style_count()
# Style count should not increase on second parse
self.assertEqual(first_parse_style_count, second_parse_style_count,
"Should reuse existing styles instead of creating new ones")
# Both parses should create same number of blocks
self.assertEqual(len(blocks1), len(blocks2),
"Should create same structure on both parses")
def test_font_registry_different_styles_create_different_fonts(self):
"""Test that different styles create different style objects."""
# Create styles with different properties
style_id1, style1 = self.doc.get_or_create_style(
font_size=14, color=(255, 0, 0), font_weight=FontWeight.BOLD
)
style_id2, style2 = self.doc.get_or_create_style(
font_size=16, color=(255, 0, 0), font_weight=FontWeight.BOLD
)
style_id3, style3 = self.doc.get_or_create_style(
font_size=14, color=(0, 255, 0), font_weight=FontWeight.BOLD
)
# Should be different style IDs
self.assertNotEqual(
style_id1,
style_id2,
"Different sizes should create different styles")
self.assertNotEqual(
style_id1,
style_id3,
"Different colors should create different styles")
self.assertNotEqual(style_id2, style_id3, "All styles should be different")
# Should have multiple styles in registry
self.assertGreaterEqual(self.doc.get_style_registry().get_style_count(), 3)
def test_font_registry_integration_with_html_styles(self):
"""Test that HTML parsing uses style registry for styled content."""
html_content = """
<p>Normal text with <strong>bold</strong> and <em>italic</em> and
<span style="color: red;">red text</span>.</p>
"""
# Parse content
blocks = parse_html_string(html_content, self.base_font, document=self.doc)
# Extract all words from the paragraph
paragraph = blocks[0]
words = list(paragraph.words_iter())
# Find words with different styles
normal_words = [w for _, w in words if w.style.weight == FontWeight.NORMAL
and w.style.style == FontStyle.NORMAL]
bold_words = [w for _, w in words if w.style.weight == FontWeight.BOLD]
italic_words = [w for _, w in words if w.style.style == FontStyle.ITALIC]
red_words = [w for _, w in words if w.style.colour == (255, 0, 0)]
# Should have words with different styles
self.assertGreater(len(normal_words), 0, "Should have normal words")
self.assertGreater(len(bold_words), 0, "Should have bold words")
self.assertGreater(len(italic_words), 0, "Should have italic words")
self.assertGreater(len(red_words), 0, "Should have red words")
# Style registry should contain multiple styles for different formatting
self.assertGreater(self.doc.get_style_registry().get_style_count(), 1,
"Should have multiple styles for different formatting")
def test_font_registry_without_document_context(self):
"""Test that parsing without document context works (fallback behavior)."""
html_content = "<p>This is <strong>bold text</strong>.</p>"
# Get initial style count (should include default style)
initial_style_count = self.doc.get_style_registry().get_style_count()
# Parse without document context
blocks = parse_html_string(html_content, self.base_font)
# Should still create blocks successfully
self.assertEqual(len(blocks), 1)
self.assertIsInstance(blocks[0], Paragraph)
# Should not affect document's style registry
final_style_count = self.doc.get_style_registry().get_style_count()
self.assertEqual(final_style_count, initial_style_count,
"Document style registry should remain unchanged")
def test_complex_html_font_reuse(self):
"""Test style reuse with complex HTML containing repeated styles."""
html_content = """
<div>
<h1>First Header</h1>
<p>Paragraph with <strong>bold</strong> text.</p>
<h1>Second Header</h1>
<p>Another paragraph with <strong>bold</strong> text.</p>
</div>
"""
# Parse content
blocks = parse_html_string(html_content, self.base_font, document=self.doc)
style_count_after_parse = self.doc.get_style_registry().get_style_count()
# Parse same content again
blocks2 = parse_html_string(html_content, self.base_font, document=self.doc)
style_count_after_second_parse = self.doc.get_style_registry().get_style_count()
# Style count should not increase on second parse
self.assertEqual(style_count_after_parse, style_count_after_second_parse,
"Styles should be reused for repeated formatting")
# Both should create same structure
self.assertEqual(len(blocks), len(blocks2))
def test_font_registry_with_nested_styles(self):
"""Test style registry with nested HTML styles."""
html_content = """
<p>Text with <strong>bold and <em>bold italic</em> nested</strong> styles.</p>
"""
# Parse content
blocks = parse_html_string(html_content, self.base_font, document=self.doc)
# Should create styles for different style combinations
paragraph = blocks[0]
words = list(paragraph.words_iter())
# Find words that are both bold and italic
bold_italic_words = [w for _, w in words
if w.style.weight == FontWeight.BOLD
and w.style.style == FontStyle.ITALIC]
self.assertGreater(len(bold_italic_words), 0,
"Should have words with combined bold+italic style")
# Should have multiple styles in registry for different combinations
self.assertGreater(self.doc.get_style_registry().get_style_count(), 1,
"Should create separate styles for style combinations")
class TestHTMLImagesInParagraphs(unittest.TestCase):
"""Test cases for handling images inside paragraph tags."""
def setUp(self):
"""Set up test fixtures."""
self.base_font = Font(font_size=14)
def test_image_only_paragraph(self):
"""Test paragraph containing only an image (common in EPUBs)."""
html = '<p><img src="cover.jpg" alt="Book Cover"/></p>'
blocks = parse_html_string(html, base_font=self.base_font)
# Should parse as an Image block, not a Paragraph
self.assertGreater(len(blocks), 0, "Should parse at least one block")
# Check that we have an Image block
image_blocks = [b for b in blocks if isinstance(b, Image)]
self.assertGreater(len(image_blocks), 0, "Should have at least one Image block")
# Verify image properties
img = image_blocks[0]
self.assertEqual(img.source, "cover.jpg")
self.assertEqual(img.alt_text, "Book Cover")
def test_paragraph_with_multiple_images(self):
"""Test paragraph with multiple images."""
html = '<p><img src="img1.jpg" alt="First"/><img src="img2.jpg" alt="Second"/></p>'
blocks = parse_html_string(html, base_font=self.base_font)
# Should have multiple Image blocks
image_blocks = [b for b in blocks if isinstance(b, Image)]
self.assertEqual(len(image_blocks), 2, "Should have two Image blocks")
# Verify both images were parsed
sources = [img.source for img in image_blocks]
self.assertIn("img1.jpg", sources)
self.assertIn("img2.jpg", sources)
def test_paragraph_with_text_and_image(self):
"""Test paragraph with mixed text and image content."""
html = '<p>Some text before <img src="inline.jpg" alt="Inline"/> and after</p>'
blocks = parse_html_string(html, base_font=self.base_font)
# Should have both paragraph and image blocks
paragraphs = [b for b in blocks if isinstance(b, Paragraph)]
images = [b for b in blocks if isinstance(b, Image)]
self.assertGreater(len(paragraphs), 0, "Should have a Paragraph block for text")
self.assertGreater(len(images), 0, "Should have an Image block")
# Verify image was parsed
self.assertEqual(images[0].source, "inline.jpg")
# Verify text was extracted (should have words like "Some", "text", etc.)
if paragraphs:
words = list(paragraphs[0].words_iter())
self.assertGreater(len(words), 0, "Paragraph should have words")
def test_regular_paragraph_still_works(self):
"""Test that regular paragraphs without images still work correctly."""
html = '<p>Just regular text without any images.</p>'
blocks = parse_html_string(html, base_font=self.base_font)
# Should be exactly one Paragraph block
self.assertEqual(len(blocks), 1, "Should have exactly one block")
self.assertIsInstance(blocks[0], Paragraph, "Should be a Paragraph block")
# Should not have any Image blocks
image_blocks = [b for b in blocks if isinstance(b, Image)]
self.assertEqual(len(image_blocks), 0, "Should have no Image blocks")
def test_image_with_width_and_height(self):
"""Test image parsing with width and height attributes."""
html = '<p><img src="sized.jpg" alt="Sized Image" width="400" height="300"/></p>'
blocks = parse_html_string(html, base_font=self.base_font)
# Should have an Image block
image_blocks = [b for b in blocks if isinstance(b, Image)]
self.assertEqual(len(image_blocks), 1, "Should have one Image block")
# Verify dimensions were parsed
img = image_blocks[0]
self.assertEqual(img.width, 400)
self.assertEqual(img.height, 300)
def test_nested_paragraph_with_image_in_span(self):
"""Test image inside nested inline elements."""
html = '<p><span><img src="nested.jpg" alt="Nested"/></span></p>'
blocks = parse_html_string(html, base_font=self.base_font)
# Should still extract the image
image_blocks = [b for b in blocks if isinstance(b, Image)]
self.assertGreater(len(image_blocks), 0, "Should find image even when nested")
# Verify image was parsed correctly
self.assertEqual(image_blocks[0].source, "nested.jpg")
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,524 @@
"""
Unit tests for individual HTML extraction functions.
Tests the specific handler functions and utility functions in html_extraction module,
reusing test patterns from test_html_extraction.py that are known to pass.
"""
import unittest
from bs4 import BeautifulSoup
from pyWebLayout.io.readers.html_extraction import (
create_base_context,
apply_element_styling,
parse_inline_styles,
apply_element_font_styles,
extract_text_content,
paragraph_handler,
div_handler,
heading_handler,
blockquote_handler,
preformatted_handler,
unordered_list_handler,
ordered_list_handler,
list_item_handler,
table_handler,
table_row_handler,
table_cell_handler,
table_header_cell_handler,
horizontal_rule_handler,
image_handler,
StyleContext,
)
from pyWebLayout.abstract.block import (
Paragraph,
Heading,
HeadingLevel,
Quote,
CodeBlock,
HList,
ListItem,
ListStyle,
Table,
TableRow,
TableCell,
HorizontalRule,
Image,
)
from pyWebLayout.abstract.inline import Word
from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration
class TestUtilityFunctions(unittest.TestCase):
"""Test cases for utility functions."""
def test_create_base_context(self):
"""Test creation of base style context."""
context = create_base_context()
self.assertIsInstance(context, StyleContext)
self.assertIsInstance(context.font, Font)
self.assertIsNone(context.background)
self.assertEqual(context.css_classes, set())
self.assertEqual(context.css_styles, {})
self.assertEqual(context.element_attributes, {})
self.assertEqual(context.parent_elements, [])
def test_parse_inline_styles_from_existing_tests(self):
"""Test parsing CSS inline styles - adapted from test_span_with_inline_styles."""
# From: '<span style="color: red; font-weight: bold;">this part is red and bold</span>'
style_text = "color: red; font-weight: bold;"
styles = parse_inline_styles(style_text)
expected = {
"color": "red",
"font-weight": "bold"
}
self.assertEqual(styles, expected)
def test_parse_inline_styles_color_variations(self):
"""Test parsing different color formats - adapted from test_color_variations."""
# Test hex color parsing
hex_style = "color: #ff0000;"
styles = parse_inline_styles(hex_style)
self.assertEqual(styles.get("color"), "#ff0000")
# Test named color parsing
named_style = "color: green;"
styles = parse_inline_styles(named_style)
self.assertEqual(styles.get("color"), "green")
def test_apply_element_font_styles_bold_elements(self):
"""Test font style application for bold elements - adapted from test_bold_text."""
base_font = Font()
# Test <strong> tag - from "<strong>bold text</strong>"
font = apply_element_font_styles(base_font, "strong", {})
self.assertEqual(font.weight, FontWeight.BOLD)
# Test <b> tag
font = apply_element_font_styles(base_font, "b", {})
self.assertEqual(font.weight, FontWeight.BOLD)
def test_apply_element_font_styles_italic_elements(self):
"""Test font style application for italic elements - adapted from test_italic_text."""
base_font = Font()
# Test <em> tag - from "<em>italic text</em>"
font = apply_element_font_styles(base_font, "em", {})
self.assertEqual(font.style, FontStyle.ITALIC)
# Test <i> tag
font = apply_element_font_styles(base_font, "i", {})
self.assertEqual(font.style, FontStyle.ITALIC)
def test_apply_element_font_styles_decoration_elements(self):
"""Test font decoration - adapted from test_underlined_text and test_strikethrough_text."""
base_font = Font()
# Test <u> tag - from "<u>underlined text</u>"
font = apply_element_font_styles(base_font, "u", {})
self.assertEqual(font.decoration, TextDecoration.UNDERLINE)
# Test <s> tag - from "<s>strikethrough text</s>"
font = apply_element_font_styles(base_font, "s", {})
self.assertEqual(font.decoration, TextDecoration.STRIKETHROUGH)
# Test <del> tag
font = apply_element_font_styles(base_font, "del", {})
self.assertEqual(font.decoration, TextDecoration.STRIKETHROUGH)
def test_apply_element_font_styles_headings(self):
"""Test heading font styles - adapted from test_headings."""
base_font = Font()
# Test heading sizes and weights - from test_headings which tests h1-h6
headings = [("h1", 24), ("h2", 20), ("h3", 18),
("h4", 16), ("h5", 14), ("h6", 12)]
for tag, expected_size in headings:
font = apply_element_font_styles(base_font, tag, {})
self.assertEqual(font.font_size, expected_size, f"Size mismatch for {tag}")
self.assertEqual(
font.weight,
FontWeight.BOLD,
f"Weight should be bold for {tag}")
def test_apply_element_font_styles_color_parsing(self):
"""Test color parsing - adapted from test_color_variations."""
base_font = Font()
# Test named colors - from '<span style="color: green;">Named green</span>'
css_styles = {"color": "green"}
font = apply_element_font_styles(base_font, "span", css_styles)
self.assertEqual(font.colour, (0, 255, 0))
# Test hex colors - from '<span style="color: #ff0000;">Hex red</span>'
css_styles = {"color": "#ff0000"}
font = apply_element_font_styles(base_font, "span", css_styles)
self.assertEqual(font.colour, (255, 0, 0))
def test_apply_element_styling_with_classes_and_styles(self):
"""Test complete element styling - adapted from test_span_with_inline_styles."""
# From: '<span style="color: red; font-weight: bold;">this part is red and bold</span>'
soup = BeautifulSoup(
'<span class="highlight" style="color: red; font-weight: bold;">text</span>',
'html.parser')
element = soup.find('span')
base_context = create_base_context()
styled_context = apply_element_styling(base_context, element)
# Check CSS classes
self.assertIn("highlight", styled_context.css_classes)
# Check CSS styles
self.assertEqual(styled_context.css_styles.get("color"), "red")
self.assertEqual(styled_context.css_styles.get("font-weight"), "bold")
# Check font styling
self.assertEqual(styled_context.font.colour, (255, 0, 0))
self.assertEqual(styled_context.font.weight, FontWeight.BOLD)
class TestExtractTextContent(unittest.TestCase):
"""Test cases for text content extraction."""
def setUp(self):
"""Set up test fixtures."""
self.base_context = create_base_context()
def test_extract_simple_text(self):
"""Test extracting simple text - adapted from test_simple."""
# From: "<p>This is a paragraph.</p>"
soup = BeautifulSoup('<p>This is a paragraph.</p>', 'html.parser')
element = soup.find('p')
words = extract_text_content(element, self.base_context)
# Should match the expected word count from original test
self.assertEqual(len(words), 4) # "This", "is", "a", "paragraph."
self.assertIsInstance(words[0], Word)
self.assertEqual(words[0].text, "This")
def test_extract_styled_text_bold(self):
"""Test extracting bold styled text - adapted from test_bold_text."""
# From: "<p>This is <strong>bold text</strong> in a paragraph.</p>"
soup = BeautifulSoup(
'<span>This is <strong>bold text</strong> in a paragraph.</span>',
'html.parser')
element = soup.find('span')
words = extract_text_content(element, self.base_context)
# Find the bold words
bold_words = [w for w in words if w.style.weight == FontWeight.BOLD]
self.assertGreater(len(bold_words), 0, "Should have bold words")
# Check specific words are bold (from original test expectations)
bold_word_texts = [w.text for w in bold_words]
self.assertIn("bold", bold_word_texts)
self.assertIn("text", bold_word_texts)
def test_extract_nested_formatting(self):
"""Test nested formatting - adapted from test_nested_formatting."""
# From: "<p>This has <strong>bold with <em>italic inside</em></strong> formatting.</p>"
soup = BeautifulSoup(
'<span>This has <strong>bold with <em>italic inside</em></strong> formatting.</span>',
'html.parser')
element = soup.find('span')
words = extract_text_content(element, self.base_context)
# Find words that should be both bold and italic
bold_italic_words = [w for w in words if w.style.weight ==
FontWeight.BOLD and w.style.style == FontStyle.ITALIC]
self.assertGreater(
len(bold_italic_words),
0,
"Should have words that are both bold and italic")
class TestHandlerFunctions(unittest.TestCase):
"""Test cases for HTML element handler functions using known working patterns."""
def setUp(self):
"""Set up test fixtures."""
self.base_context = create_base_context()
def test_paragraph_handler_simple(self):
"""Test paragraph handler - adapted from test_simple."""
# From: "<p>This is a paragraph.</p>"
soup = BeautifulSoup('<p>This is a paragraph.</p>', 'html.parser')
element = soup.find('p')
result = paragraph_handler(element, self.base_context)
self.assertIsInstance(result, Paragraph)
# Should match original test expectations
self.assertEqual(len(result), 4) # 4 words
words = list(result.words_iter())
expected_texts = ["This", "is", "a", "paragraph."]
for i, expected_text in enumerate(expected_texts):
self.assertEqual(words[i][1].text, expected_text)
def test_heading_handler_all_levels(self):
"""Test heading handler - adapted from test_headings."""
# From: "<h1>Heading 1</h1><h2>Heading 2</h2>..."
expected_levels = [HeadingLevel.H1, HeadingLevel.H2, HeadingLevel.H3,
HeadingLevel.H4, HeadingLevel.H5, HeadingLevel.H6]
for i, expected_level in enumerate(expected_levels, 1):
tag = f"h{i}"
soup = BeautifulSoup(f'<{tag}>Heading {i}</{tag}>', 'html.parser')
element = soup.find(tag)
result = heading_handler(element, self.base_context)
self.assertIsInstance(result, Heading)
self.assertEqual(result.level, expected_level)
# Should match original test word expectations
words = list(result.words_iter())
self.assertEqual(len(words), 2) # "Heading" and number
self.assertEqual(words[0][1].text, "Heading")
def test_blockquote_handler(self):
"""Test blockquote handler - adapted from test_blockquote."""
# From: "<blockquote><p>This is a quoted paragraph.</p></blockquote>"
soup = BeautifulSoup(
'<blockquote><p>This is a quoted paragraph.</p></blockquote>',
'html.parser')
element = soup.find('blockquote')
result = blockquote_handler(element, self.base_context)
self.assertIsInstance(result, Quote)
# Check that the quote contains a paragraph (from original test)
quote_blocks = list(result.blocks())
self.assertEqual(len(quote_blocks), 1)
self.assertIsInstance(quote_blocks[0], Paragraph)
def test_preformatted_handler(self):
"""Test preformatted handler - adapted from test_preformatted_code."""
# From: "<pre><code>function hello() {\n console.log('Hello');\n}</code></pre>"
soup = BeautifulSoup(
'<pre><code>function hello() {\n console.log(\'Hello\');\n}</code></pre>',
'html.parser')
element = soup.find('pre')
result = preformatted_handler(element, self.base_context)
self.assertIsInstance(result, CodeBlock)
# Should have lines (from original test expectation)
lines = list(result.lines())
self.assertGreater(len(lines), 0)
def test_unordered_list_handler(self):
"""Test unordered list handler - adapted from test_unordered_list."""
# From: "<ul><li>First item</li><li>Second item</li><li>Third item</li></ul>"
soup = BeautifulSoup(
'<ul><li>First item</li><li>Second item</li><li>Third item</li></ul>',
'html.parser')
element = soup.find('ul')
result = unordered_list_handler(element, self.base_context)
self.assertIsInstance(result, HList)
self.assertEqual(result.style, ListStyle.UNORDERED)
# Should match original test expectations
items = list(result.items())
self.assertEqual(len(items), 3)
def test_ordered_list_handler(self):
"""Test ordered list handler - adapted from test_ordered_list."""
# From: "<ol><li>First item</li><li>Second item</li><li>Third item</li></ol>"
soup = BeautifulSoup(
'<ol><li>First item</li><li>Second item</li><li>Third item</li></ol>',
'html.parser')
element = soup.find('ol')
result = ordered_list_handler(element, self.base_context)
self.assertIsInstance(result, HList)
self.assertEqual(result.style, ListStyle.ORDERED)
# Should match original test expectations
items = list(result.items())
self.assertEqual(len(items), 3) # "First item", "Second item", "Third item"
def test_list_item_handler(self):
"""Test list item handler."""
soup = BeautifulSoup('<li>List item content</li>', 'html.parser')
element = soup.find('li')
result = list_item_handler(element, self.base_context)
self.assertIsInstance(result, ListItem)
blocks = list(result.blocks())
self.assertGreater(len(blocks), 0)
def test_table_handler(self):
"""Test table handler - adapted from test_table_basic."""
# From test_table_basic structure
soup = BeautifulSoup('''
<table>
<tr>
<th>Header 1</th>
<th>Header 2</th>
</tr>
<tr>
<td>Cell 1</td>
<td>Cell 2</td>
</tr>
</table>
''', 'html.parser')
element = soup.find('table')
result = table_handler(element, self.base_context)
self.assertIsInstance(result, Table)
def test_table_row_handler(self):
"""Test table row handler."""
soup = BeautifulSoup('<tr><td>Cell 1</td><td>Cell 2</td></tr>', 'html.parser')
element = soup.find('tr')
result = table_row_handler(element, self.base_context)
self.assertIsInstance(result, TableRow)
def test_table_cell_handler(self):
"""Test table cell handler."""
soup = BeautifulSoup('<td>Cell content</td>', 'html.parser')
element = soup.find('td')
# Apply styling to get attributes
styled_context = apply_element_styling(self.base_context, element)
result = table_cell_handler(element, styled_context)
self.assertIsInstance(result, TableCell)
self.assertEqual(result.is_header, False)
def test_table_header_cell_handler(self):
"""Test table header cell handler."""
soup = BeautifulSoup('<th>Header content</th>', 'html.parser')
element = soup.find('th')
# Apply styling to get attributes
styled_context = apply_element_styling(self.base_context, element)
result = table_header_cell_handler(element, styled_context)
self.assertIsInstance(result, TableCell)
self.assertEqual(result.is_header, True)
def test_horizontal_rule_handler(self):
"""Test horizontal rule handler."""
soup = BeautifulSoup('<hr>', 'html.parser')
element = soup.find('hr')
result = horizontal_rule_handler(element, self.base_context)
self.assertIsInstance(result, HorizontalRule)
def test_image_handler(self):
"""Test image handler."""
soup = BeautifulSoup(
'<img src="test.jpg" alt="Test image" width="100" height="50">',
'html.parser')
element = soup.find('img')
# Need to apply styling first to get attributes
styled_context = apply_element_styling(self.base_context, element)
result = image_handler(element, styled_context)
self.assertIsInstance(result, Image)
self.assertEqual(result.source, "test.jpg")
self.assertEqual(result.alt_text, "Test image")
self.assertEqual(result.width, 100)
self.assertEqual(result.height, 50)
def test_div_handler_container(self):
"""Test div handler - adapted from test_div_container."""
# From: "<div><p>First paragraph.</p><p>Second paragraph.</p></div>"
soup = BeautifulSoup(
'<div><p>First paragraph.</p><p>Second paragraph.</p></div>',
'html.parser')
element = soup.find('div')
result = div_handler(element, self.base_context)
self.assertIsInstance(result, list)
# Should match original test expectations
self.assertEqual(len(result), 2)
self.assertIsInstance(result[0], Paragraph)
self.assertIsInstance(result[1], Paragraph)
class TestStyledContentHandling(unittest.TestCase):
"""Test styled content handling using patterns from existing tests."""
def setUp(self):
"""Set up test fixtures."""
self.base_context = create_base_context()
def test_paragraph_with_bold_content(self):
"""Test paragraph with bold content - adapted from test_bold_text."""
# From: "<p>This is <strong>bold text</strong> in a paragraph.</p>"
soup = BeautifulSoup(
'<p>This is <strong>bold text</strong> in a paragraph.</p>',
'html.parser')
element = soup.find('p')
result = paragraph_handler(element, self.base_context)
self.assertIsInstance(result, Paragraph)
words = list(result.words_iter())
self.assertEqual(len(words), 7) # From original test expectation
# Check that 'bold' and 'text' words have bold font weight (from original test)
bold_word = words[2][1] # 'bold'
text_word = words[3][1] # 'text'
self.assertEqual(bold_word.text, "bold")
self.assertEqual(bold_word.style.weight, FontWeight.BOLD)
self.assertEqual(text_word.text, "text")
self.assertEqual(text_word.style.weight, FontWeight.BOLD)
# Check that other words are not bold (from original test)
normal_word = words[0][1] # 'This'
self.assertEqual(normal_word.text, "This")
self.assertNotEqual(normal_word.style.weight, FontWeight.BOLD)
def test_paragraph_with_mixed_formatting(self):
"""Test mixed formatting - adapted from test_mixed_formatting."""
# From: "<p>This paragraph contains <strong>bold</strong>, <em>italic</em>..."
html_str = (
'<p>This paragraph contains <strong>bold</strong>, <em>italic</em>, '
'<span style="color: blue;">blue</span> text.</p>'
)
soup = BeautifulSoup(html_str, 'html.parser')
element = soup.find('p')
result = paragraph_handler(element, self.base_context)
self.assertIsInstance(result, Paragraph)
words = list(result.words_iter())
# Check for bold word (from original test pattern)
bold_words = [w for _, w in words if w.style.weight == FontWeight.BOLD]
self.assertGreater(len(bold_words), 0, "Should have bold words")
# Check for italic word (from original test pattern)
italic_words = [w for _, w in words if w.style.style == FontStyle.ITALIC]
self.assertGreater(len(italic_words), 0, "Should have italic words")
# Check for blue colored word (from original test pattern)
blue_words = [w for _, w in words if w.style.colour == (0, 0, 255)]
self.assertGreater(len(blue_words), 0, "Should have blue colored words")
if __name__ == '__main__':
unittest.main()
+136
View File
@@ -0,0 +1,136 @@
"""
Test module for loading HTML files using the html_extraction module.
This test verifies that HTML files can be loaded from disk and processed
using the html_extraction.parse_html_string function.
"""
import os
import unittest
from pyWebLayout.io.readers.html_extraction import parse_html_string
from pyWebLayout.abstract.block import Block
from pyWebLayout.style import Font
class TestHTMLFileLoader(unittest.TestCase):
"""Test class for HTML file loading functionality."""
def test_load_html_file(self):
"""Test loading and parsing an HTML file from disk."""
# Path to the test HTML file
html_file_path = os.path.join(
"tests", "data", "Kimi Räikkönen - Wikipedia.html")
# Verify the test file exists
self.assertTrue(
os.path.exists(html_file_path),
f"Test HTML file not found: {html_file_path}")
# Read the HTML file
with open(html_file_path, 'r', encoding='utf-8') as file:
html_content = file.read()
# Verify we got some content
self.assertGreater(len(html_content), 0, "HTML file should not be empty")
# Parse the HTML content using the html_extraction module
try:
blocks = parse_html_string(html_content)
except Exception as e:
self.fail(f"Failed to parse HTML file: {e}")
# Verify we got some blocks
self.assertIsInstance(blocks, list, "parse_html_string should return a list")
self.assertGreater(
len(blocks),
0,
"Should extract at least one block from the HTML file")
# Verify all returned items are Block instances
for i, block in enumerate(blocks):
self.assertIsInstance(
block,
Block,
f"Item {i} should be a Block instance, got {type(block)}"
)
print(f"Successfully loaded and parsed HTML file with {len(blocks)} blocks")
def test_load_html_file_with_custom_font(self):
"""Test loading HTML file with a custom base font."""
html_file_path = os.path.join(
"tests", "data", "Kimi Räikkönen - Wikipedia.html")
# Skip if file doesn't exist
if not os.path.exists(html_file_path):
self.skipTest(f"Test HTML file not found: {html_file_path}")
# Create a custom font
custom_font = Font(font_size=14, colour=(100, 100, 100))
# Read and parse with custom font
with open(html_file_path, 'r', encoding='utf-8') as file:
html_content = file.read()
blocks = parse_html_string(html_content, base_font=custom_font)
# Verify we got blocks
self.assertGreater(len(blocks), 0, "Should extract blocks with custom font")
print(
f"Successfully parsed HTML file with custom font, got {len(blocks)} blocks"
)
def test_load_html_file_content_types(self):
"""Test that the loaded HTML file contains expected content types."""
html_file_path = os.path.join(
"tests", "data", "Kimi Räikkönen - Wikipedia.html")
# Skip if file doesn't exist
if not os.path.exists(html_file_path):
self.skipTest(f"Test HTML file not found: {html_file_path}")
with open(html_file_path, 'r', encoding='utf-8') as file:
html_content = file.read()
blocks = parse_html_string(html_content)
# Check that we have different types of blocks
block_type_names = [type(block).__name__ for block in blocks]
unique_types = set(block_type_names)
# A Wikipedia page should contain multiple types of content
self.assertGreater(
len(unique_types),
1,
"Should have multiple types of blocks in Wikipedia page")
print(f"Found block types: {sorted(unique_types)}")
def test_html_file_size_handling(self):
"""Test that large HTML files can be handled gracefully."""
html_file_path = os.path.join(
"tests", "data", "Kimi Räikkönen - Wikipedia.html")
# Skip if file doesn't exist
if not os.path.exists(html_file_path):
self.skipTest(f"Test HTML file not found: {html_file_path}")
# Get file size
file_size = os.path.getsize(html_file_path)
print(f"HTML file size: {file_size} bytes")
# Read and parse
with open(html_file_path, 'r', encoding='utf-8') as file:
html_content = file.read()
# This should not raise an exception even for large files
blocks = parse_html_string(html_content)
# Basic verification
self.assertIsInstance(blocks, list)
print(f"Successfully processed {file_size} byte file into {len(blocks)} blocks")
if __name__ == '__main__':
unittest.main()
+164
View File
@@ -0,0 +1,164 @@
"""
End-to-end test for HTML link interactivity with query_point.
This test verifies the complete flow:
1. HTML with links -> parse_html_string -> LinkedWord objects
2. LinkedWord objects -> DocumentLayouter -> Rendered page
3. Rendered page -> query_point -> Interactive link detection
"""
import unittest
from pyWebLayout.io.readers.html_extraction import parse_html_string
from pyWebLayout.concrete.page import Page
from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.layout.document_layouter import DocumentLayouter
from pyWebLayout.abstract.inline import LinkedWord
class TestHTMLLinkEndToEnd(unittest.TestCase):
"""Test complete HTML link workflow from parsing to interaction."""
def test_complete_link_workflow(self):
"""Test the complete workflow: HTML -> parsing -> layout -> query."""
# Step 1: Create HTML with a link
html = '<p>Click <a href="action:test_action">this link</a> to test.</p>'
# Step 2: Parse HTML to blocks
blocks = parse_html_string(html)
self.assertEqual(len(blocks), 1)
# Step 3: Verify LinkedWord was created
paragraph = blocks[0]
linked_words = [w for w in paragraph.words if isinstance(w, LinkedWord)]
self.assertEqual(len(linked_words), 2) # "this" and "link"
# Verify link properties
for word in linked_words:
self.assertEqual(word.location, "action:test_action")
self.assertIn(word.text, ["this", "link"])
# Step 4: Layout on a page
page_style = PageStyle()
page = Page((400, 200), page_style)
layouter = DocumentLayouter(page)
layouter.layout_document([paragraph])
# Step 5: Render the page
rendered = page.render()
self.assertIsNotNone(rendered)
# Step 6: Test query_point functionality
# The query system should be able to detect interactive elements
# Note: Exact coordinates depend on layout, so we test the capability
# rather than specific pixel positions
# Try to query various points on the page
# The page should respond to queries (even if we don't hit the exact link)
result = page.query_point((100, 50))
# Result might be None if we didn't hit any text, but the API should work
# The key is that if we DID hit a link, it would be detected
print(f"Query result: {result}")
if result and result.is_interactive:
# If we hit an interactive element, it should have link info
self.assertIsNotNone(result.link_target)
print(f"Found interactive link: {result.link_target}")
def test_settings_overlay_complete_workflow(self):
"""Test the complete workflow with actual settings overlay HTML."""
# This is the exact HTML pattern used for settings overlays
html = '''
<div>
<h2>Settings</h2>
<p>
<a href="action:back_to_library">Back to Library</a>
</p>
<p>
Font Size:
<a href="setting:font_decrease">[-]</a>
<a href="setting:font_increase">[+]</a>
</p>
</div>
'''
# Parse HTML
blocks = parse_html_string(html)
# Collect all LinkedWords
all_linked_words = []
for block in blocks:
if hasattr(block, 'words'):
for word in block.words:
if isinstance(word, LinkedWord):
all_linked_words.append(word)
# Verify we found the expected links
self.assertGreater(len(all_linked_words), 0)
# Check for specific actions
actions = {word.location for word in all_linked_words}
self.assertIn("action:back_to_library", actions)
self.assertIn("setting:font_decrease", actions)
self.assertIn("setting:font_increase", actions)
# Layout and render
page_style = PageStyle(padding=(10, 10, 10, 10))
page = Page((400, 300), page_style)
layouter = DocumentLayouter(page)
for block in blocks:
layouter.layout_document([block])
rendered = page.render()
self.assertIsNotNone(rendered)
print("\nSettings overlay test:")
print(f" Found {len(all_linked_words)} linked words")
print(f" Actions: {actions}")
print(f" Rendered: {rendered.size}")
# The links are successfully created and rendered!
# In a real application, query_point would be used to detect clicks on
# these links
def test_link_metadata_preserved(self):
"""Test that link metadata (title, type) is preserved through the workflow."""
html = '''
<p>
<a href="https://example.com" title="Example Site">External</a>
<a href="#section2" title="Go to Section 2">Internal</a>
<a href="javascript:alert()" title="Alert">API</a>
</p>
'''
blocks = parse_html_string(html)
paragraph = blocks[0]
linked_words = [w for w in paragraph.words if isinstance(w, LinkedWord)]
# Should have 3 LinkedWords (one for each link)
self.assertEqual(len(linked_words), 3)
# Check that link metadata is preserved
links_by_text = {w.text: w for w in linked_words}
# External link
external = links_by_text.get("External")
self.assertIsNotNone(external)
self.assertEqual(external.location, "https://example.com")
self.assertEqual(external.link_title, "Example Site")
# Internal link
internal = links_by_text.get("Internal")
self.assertIsNotNone(internal)
self.assertEqual(internal.location, "#section2")
self.assertEqual(internal.link_title, "Go to Section 2")
# API link
api = links_by_text.get("API")
self.assertIsNotNone(api)
self.assertTrue(api.location.startswith("javascript:"))
self.assertEqual(api.link_title, "Alert")
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,211 @@
"""
Test HTML link interactivity when rendering pages.
This test verifies that HTML links parsed via parse_html_string() are
properly interactive and can be queried/detected when rendered on a page.
"""
import unittest
from pyWebLayout.io.readers.html_extraction import parse_html_string
from pyWebLayout.concrete.page import Page
from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.abstract.inline import LinkedWord
class TestHTMLLinkInteractivity(unittest.TestCase):
"""Test that HTML links are interactive when rendered."""
def test_simple_link_parsing(self):
"""Test that parse_html_string creates LinkedWord objects for links."""
html = '''
<div>
<p>Click <a href="action:back_to_library">here</a> to go back.</p>
<p>Or visit <a href="setting:font_increase">this setting</a>.</p>
</div>
'''
blocks = parse_html_string(html)
# Should have 2 paragraphs
self.assertEqual(len(blocks), 2)
# First paragraph - check for LinkedWord
para1_words = list(blocks[0].words)
linked_words_1 = [w for w in para1_words if isinstance(w, LinkedWord)]
# "here" should be a LinkedWord
self.assertEqual(len(linked_words_1), 1)
self.assertEqual(linked_words_1[0].text, "here")
self.assertEqual(linked_words_1[0].location, "action:back_to_library")
# Second paragraph
para2_words = list(blocks[1].words)
linked_words_2 = [w for w in para2_words if isinstance(w, LinkedWord)]
# "this" and "setting" should be LinkedWords
self.assertEqual(len(linked_words_2), 2)
link_texts = [w.text for w in linked_words_2]
self.assertIn("this", link_texts)
self.assertIn("setting", link_texts)
def test_link_rendering_on_page(self):
"""Test that links are properly rendered and detectable on a page."""
from pyWebLayout.layout.document_layouter import DocumentLayouter
html = '''
<div>
<h1>Settings</h1>
<p>Font Size: <a href="setting:font_decrease">-</a> | <a href="setting:font_increase">+</a></p>
<p><a href="action:back_to_library">Back to Library</a></p>
</div>
'''
blocks = parse_html_string(html)
# Create a page and use DocumentLayouter to properly layout blocks
page_style = PageStyle()
page = Page((600, 800), page_style)
layouter = DocumentLayouter(page)
# Layout all blocks
for block in blocks:
layouter.layout_document([block])
# Render the page
rendered_image = page.render()
# Verify the page rendered
self.assertIsNotNone(rendered_image)
self.assertEqual(rendered_image.size, (600, 800))
# Now verify we can detect the links via query_point
# We need to find the positions of the linked words
found_links = []
# Scan the blocks for LinkedWords
for block in blocks:
if hasattr(block, 'words'):
for word in block.words:
if isinstance(word, LinkedWord):
found_links.append({
'text': word.text,
'location': word.location,
'link_type': word.link_type
})
# Verify we found the expected links
# -, +, Back, to, Library (5 LinkedWords total)
self.assertGreater(len(found_links), 0, "Should find at least some LinkedWords")
link_locations = [link['location'] for link in found_links]
self.assertIn("setting:font_decrease", link_locations)
self.assertIn("setting:font_increase", link_locations)
self.assertIn("action:back_to_library", link_locations)
def test_link_query_point_detection(self):
"""Test that query_point can detect links on a rendered page."""
from pyWebLayout.layout.document_layouter import DocumentLayouter
html = '''
<p>Click <a href="action:test">here</a> to test.</p>
'''
blocks = parse_html_string(html)
# Create a page
page_style = PageStyle()
page = Page((400, 200), page_style)
layouter = DocumentLayouter(page)
# Layout the block
for block in blocks:
layouter.layout_document([block])
# Get the rendered canvas
_ = page.draw # Ensure canvas exists
# Find the LinkedWord in the blocks
link_word = None
for block in blocks:
if hasattr(block, 'words'):
for word in block.words:
if isinstance(word, LinkedWord) and word.location == "action:test":
link_word = word
break
# Verify we found the link
self.assertIsNotNone(link_word)
self.assertEqual(link_word.text, "here")
self.assertEqual(link_word.location, "action:test")
# Test that query_point can detect the link
# Try querying a point in the middle of the page where text should be
# Note: The exact coordinates depend on the layout, but we can test the API
result = page.query_point((100, 50))
if result:
# If we hit something, verify we can access its properties
self.assertIsNotNone(result.object_type)
# Link detection would show is_interactive=True for links
if result.is_interactive:
self.assertIsNotNone(result.link_target)
def test_settings_overlay_button_html(self):
"""Test the specific HTML pattern used for settings overlay buttons."""
# This is the pattern used in the settings overlay
html = '''
<div>
<h2 style="text-align: center; font-size: 18px; font-weight: bold; margin: 10px 0;">Settings</h2>
<p style="padding: 15px; margin: 5px 0; background-color: #dc3545; text-align: center;
border-radius: 5px;">
<a href="action:back_to_library"
style="text-decoration: none; color: white; font-weight: bold; font-size: 14px;">
◄ Back to Library</a>
</p>
<p style="padding: 10px; margin: 5px 0; background-color: #f8f9fa; border-radius: 5px;">
<span style="font-weight: bold;">Font Size: 100%</span><br>
<a href="setting:font_decrease" style="text-decoration: none; color: #007bff; margin: 0 10px;">[-]</a>
<a href="setting:font_increase" style="text-decoration: none; color: #007bff; margin: 0 10px;">[+]</a>
</p>
</div>
'''
blocks = parse_html_string(html)
# Find all LinkedWords
all_linked_words = []
for block in blocks:
if hasattr(block, 'words'):
for word in block.words:
if isinstance(word, LinkedWord):
all_linked_words.append(word)
# Verify we found the expected links
self.assertGreater(
len(all_linked_words),
0,
"Should find LinkedWords in settings HTML")
# Check for specific link targets
link_targets = {word.location for word in all_linked_words}
self.assertIn("action:back_to_library", link_targets,
"Should find 'Back to Library' link")
self.assertIn("setting:font_decrease", link_targets,
"Should find font decrease link")
self.assertIn("setting:font_increase", link_targets,
"Should find font increase link")
# Verify the link texts
back_to_library_words = [w for w in all_linked_words
if w.location == "action:back_to_library"]
self.assertGreater(len(back_to_library_words), 0,
"Should have words linked to back_to_library action")
# Print debug info
print(f"\nFound {len(all_linked_words)} linked words:")
for word in all_linked_words:
print(f" - '{word.text}' -> {word.location}")
if __name__ == '__main__':
unittest.main()
+184
View File
@@ -0,0 +1,184 @@
"""
Unit tests for HTML link extraction.
"""
import unittest
from bs4 import BeautifulSoup
from pyWebLayout.io.readers.html_extraction import (
parse_html_string,
extract_text_content,
create_base_context
)
from pyWebLayout.abstract.inline import LinkedWord
from pyWebLayout.abstract.functional import LinkType
from pyWebLayout.abstract.block import Paragraph
class TestHTMLLinkExtraction(unittest.TestCase):
"""Test cases for HTML hyperlink extraction."""
def setUp(self):
"""Set up test fixtures."""
self.base_context = create_base_context()
def test_simple_external_link(self):
"""Test extracting a simple external link."""
html = '<p>Visit <a href="https://example.com">this site</a> for more.</p>'
blocks = parse_html_string(html)
self.assertEqual(len(blocks), 1)
self.assertIsInstance(blocks[0], Paragraph)
paragraph = blocks[0]
words = list(paragraph.words)
# Should have: "Visit", "this", "site", "for", "more."
self.assertEqual(len(words), 5)
# Check that "this" and "site" are LinkedWords
self.assertIsInstance(words[1], LinkedWord)
self.assertIsInstance(words[2], LinkedWord)
# Check link properties
self.assertEqual(words[1].location, "https://example.com")
self.assertEqual(words[1].link_type, LinkType.EXTERNAL)
self.assertEqual(words[2].location, "https://example.com")
self.assertEqual(words[2].link_type, LinkType.EXTERNAL)
def test_internal_link(self):
"""Test extracting an internal anchor link."""
html = '<p>Go to <a href="#section2">section 2</a> below.</p>'
blocks = parse_html_string(html)
paragraph = blocks[0]
words = list(paragraph.words)
# Find LinkedWords
linked_words = [w for w in words if isinstance(w, LinkedWord)]
self.assertEqual(len(linked_words), 2) # "section" and "2"
# Check they're internal links
for word in linked_words:
self.assertEqual(word.link_type, LinkType.INTERNAL)
self.assertEqual(word.location, "#section2")
def test_multi_word_link(self):
"""Test that multi-word links create separate LinkedWords."""
html = '<p><a href="/next">click here for next page</a></p>'
blocks = parse_html_string(html)
paragraph = blocks[0]
words = list(paragraph.words)
# All words should be LinkedWords
self.assertEqual(len(words), 5)
for word in words:
self.assertIsInstance(word, LinkedWord)
self.assertEqual(word.location, "/next")
self.assertEqual(word.link_type, LinkType.INTERNAL)
def test_link_with_title(self):
"""Test extracting link with title attribute."""
html = '<p><a href="https://example.com" title="Visit Example">click</a></p>'
blocks = parse_html_string(html)
paragraph = blocks[0]
words = list(paragraph.words)
self.assertEqual(len(words), 1)
self.assertIsInstance(words[0], LinkedWord)
self.assertEqual(words[0].link_title, "Visit Example")
def test_mixed_linked_and_normal_text(self):
"""Test paragraph with both linked and normal text."""
html = '<p>Some <a href="/page">linked text</a> and normal text.</p>'
blocks = parse_html_string(html)
paragraph = blocks[0]
words = list(paragraph.words)
# "Some" - normal
# "linked" - LinkedWord
# "text" - LinkedWord
# "and" - normal
# "normal" - normal
# "text." - normal
self.assertNotIsInstance(words[0], LinkedWord) # "Some"
self.assertIsInstance(words[1], LinkedWord) # "linked"
self.assertIsInstance(words[2], LinkedWord) # "text"
self.assertNotIsInstance(words[3], LinkedWord) # "and"
def test_link_without_href(self):
"""Test that <a> without href is treated as normal text."""
html = '<p><a>not a link</a></p>'
blocks = parse_html_string(html)
paragraph = blocks[0]
words = list(paragraph.words)
# Should be regular Words, not LinkedWords
for word in words:
self.assertNotIsInstance(word, LinkedWord)
def test_javascript_link(self):
"""Test that javascript: links are detected as API type."""
html = '<p><a href="javascript:alert()">click</a></p>'
blocks = parse_html_string(html)
paragraph = blocks[0]
words = list(paragraph.words)
self.assertIsInstance(words[0], LinkedWord)
self.assertEqual(words[0].link_type, LinkType.API)
def test_nested_formatting_in_link(self):
"""Test link with nested formatting."""
html = '<p><a href="/page">text with <strong>bold</strong> word</a></p>'
blocks = parse_html_string(html)
paragraph = blocks[0]
words = list(paragraph.words)
# All should be LinkedWords regardless of formatting
for word in words:
self.assertIsInstance(word, LinkedWord)
self.assertEqual(word.location, "/page")
def test_multiple_links_in_paragraph(self):
"""Test paragraph with multiple separate links."""
html = '<p><a href="/page1">first</a> and <a href="/page2">second</a> link</p>'
blocks = parse_html_string(html)
paragraph = blocks[0]
words = list(paragraph.words)
# Find LinkedWords and their locations
linked_words = [(w.text, w.location)
for w in words if isinstance(w, LinkedWord)]
# Should have "first" linked to /page1 and "second" linked to /page2
self.assertIn(("first", "/page1"), linked_words)
self.assertIn(("second", "/page2"), linked_words)
def test_extract_text_content_with_links(self):
"""Test extract_text_content directly with link elements."""
html = '<span>Visit <a href="https://example.com">our site</a> today</span>'
soup = BeautifulSoup(html, 'html.parser')
element = soup.find('span')
context = create_base_context()
words = extract_text_content(element, context)
# Should have: "Visit", "our", "site", "today"
self.assertEqual(len(words), 4)
# Check types
self.assertNotIsInstance(words[0], LinkedWord) # "Visit"
self.assertIsInstance(words[1], LinkedWord) # "our"
self.assertIsInstance(words[2], LinkedWord) # "site"
self.assertNotIsInstance(words[3], LinkedWord) # "today"
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,169 @@
"""
Regression tests for inline content inside block containers (spec S1).
Inline tags are registered to ignore_handler because they are meant to be
consumed by extract_text_content. Only <p> and <h1>-<h6> ever called it, so
every other container - div, li, td, th, blockquote - iterated its children as
blocks, and inline tags returned None. Their text was silently discarded, and
bare text nodes each became a separate paragraph.
"""
import pytest
from pyWebLayout.abstract.block import (
HList,
Paragraph,
Quote,
Table,
)
from pyWebLayout.abstract.inline import LinkedWord, Word
from pyWebLayout.io.readers.html_extraction import parse_html_string
def words_of(block):
return [w.text for w in getattr(block, 'words', [])]
def all_words(blocks):
out = []
for block in blocks:
out.extend(words_of(block))
return out
def cell_blocks(table):
for _, row in table.all_rows():
for cell in row.cells():
yield list(cell.blocks())
EXPECTED = ["hello", "world", "again"]
class TestInlineContentIsKept:
"""The same markup must survive in every container."""
def test_paragraph_control(self):
"""<p> already worked - this is the reference behaviour."""
blocks = parse_html_string("<p>hello <b>world</b> again</p>")
assert all_words(blocks) == EXPECTED
def test_div(self):
blocks = parse_html_string("<div>hello <b>world</b> again</div>")
assert all_words(blocks) == EXPECTED
def test_list_item(self):
blocks = parse_html_string("<ul><li>hello <b>world</b> again</li></ul>")
hlist = next(b for b in blocks if isinstance(b, HList))
item = list(hlist.items())[0]
assert all_words(item.blocks()) == EXPECTED
def test_table_cell(self):
blocks = parse_html_string(
"<table><tr><td>hello <b>world</b> again</td></tr></table>")
table = next(b for b in blocks if isinstance(b, Table))
assert all_words(next(cell_blocks(table))) == EXPECTED
def test_table_header_cell(self):
blocks = parse_html_string(
"<table><tr><th>hello <b>world</b> again</th></tr></table>")
table = next(b for b in blocks if isinstance(b, Table))
assert all_words(next(cell_blocks(table))) == EXPECTED
def test_blockquote(self):
blocks = parse_html_string("<blockquote>hello <b>world</b> again</blockquote>")
quote = next(b for b in blocks if isinstance(b, Quote))
assert all_words(quote.blocks()) == EXPECTED
class TestInlineRunsCoalesce:
"""A run of inline content is one paragraph, not one per text node."""
def test_div_yields_a_single_paragraph(self):
blocks = parse_html_string("<div>a <b>b</b> c</div>")
paragraphs = [b for b in blocks if isinstance(b, Paragraph)]
assert len(paragraphs) == 1, f"expected one paragraph, got {len(blocks)} blocks"
assert words_of(paragraphs[0]) == ["a", "b", "c"]
def test_cell_yields_a_single_paragraph(self):
blocks = parse_html_string("<table><tr><td>a <b>b</b> c</td></tr></table>")
table = next(b for b in blocks if isinstance(b, Table))
cell = next(cell_blocks(table))
assert len(cell) == 1
assert words_of(cell[0]) == ["a", "b", "c"]
def test_block_child_splits_the_run(self):
"""Inline runs either side of a block child stay separate, in order."""
blocks = parse_html_string(
"<table><tr><td>before<p>middle</p>after</td></tr></table>")
table = next(b for b in blocks if isinstance(b, Table))
cell = next(cell_blocks(table))
assert [words_of(b) for b in cell] == [["before"], ["middle"], ["after"]]
def test_line_break_splits_the_run(self):
blocks = parse_html_string("<div>first<br>second</div>")
paragraphs = [b for b in blocks if isinstance(b, Paragraph)]
assert [words_of(p) for p in paragraphs] == [["first"], ["second"]]
def test_whitespace_between_blocks_makes_no_paragraph(self):
blocks = parse_html_string("<div>\n <p>one</p>\n <p>two</p>\n</div>")
assert [words_of(b) for b in blocks] == [["one"], ["two"]]
class TestLinksSurvive:
"""<a href> must produce LinkedWord wherever it appears."""
def test_link_in_cell(self):
blocks = parse_html_string(
'<table><tr><td><a href="http://x">link</a> text</td></tr></table>')
table = next(b for b in blocks if isinstance(b, Table))
cell = next(cell_blocks(table))
found = [w for b in cell for w in getattr(b, 'words', [])]
assert [w.text for w in found] == ["link", "text"]
linked = [w for w in found if isinstance(w, LinkedWord)]
assert len(linked) == 1
assert linked[0].location == "http://x"
def test_link_in_div(self):
blocks = parse_html_string('<div>see <a href="#s2">Section 2</a> now</div>')
found = [w for b in blocks for w in getattr(b, 'words', [])]
assert [w.text for w in found] == ["see", "Section", "2", "now"]
assert all(isinstance(w, LinkedWord) for w in found[1:3])
def test_link_in_list_item(self):
blocks = parse_html_string('<ul><li><a href="u">click</a> here</li></ul>')
hlist = next(b for b in blocks if isinstance(b, HList))
item = hlist._items[0]
found = [w for b in item.blocks() for w in getattr(b, 'words', [])]
assert [w.text for w in found] == ["click", "here"]
assert isinstance(found[0], LinkedWord)
class TestNestedContainers:
def test_div_in_div(self):
blocks = parse_html_string("<div>outer <div>inner</div> tail</div>")
assert [words_of(b) for b in blocks] == [["outer"], ["inner"], ["tail"]]
def test_block_children_still_pass_through(self):
blocks = parse_html_string("<div><h1>Title</h1><p>Body</p></div>")
assert len(blocks) == 2
assert words_of(blocks[0]) == ["Title"]
assert words_of(blocks[1]) == ["Body"]
def test_cell_containing_a_list(self):
blocks = parse_html_string(
"<table><tr><td>intro<ul><li>item</li></ul></td></tr></table>")
table = next(b for b in blocks if isinstance(b, Table))
cell = next(cell_blocks(table))
assert isinstance(cell[0], Paragraph)
assert words_of(cell[0]) == ["intro"]
assert isinstance(cell[1], HList)
class TestComments:
def test_comment_text_is_not_content(self):
blocks = parse_html_string("<div>real<!-- hidden note -->text</div>")
assert all_words(blocks) == ["real", "text"]