From ae5913be2e2706d6939a03661d68376ddac68645 Mon Sep 17 00:00:00 2001 From: Benedikt Willi Date: Mon, 12 Jan 2026 11:41:18 +0100 Subject: [PATCH] Implement CSS parsing, selector matching, and style resolution - Added a comprehensive CSS parser with support for tag, class, and ID selectors. - Implemented property declaration parsing and inline style handling. - Introduced a Selector class for specificity calculation and matching against HTML elements. - Created a CSSRule class to represent individual CSS rules. - Developed a StyleResolver class to compute final styles for elements, considering cascade and inheritance. - Added integration tests for CSS parsing and style application in HTML documents. - Updated HTML parser to retain More" root = parse_html(html) body = root.children[0] - joined = " ".join(collect_text(body)) - assert "Text" in joined - assert "More" in joined - assert "color" not in joined + # Find style element + style_elem = None + for child in body.children: + if hasattr(child, "tag") and child.tag == "style": + style_elem = child + break + + assert style_elem is not None + # Style content should be in the element + joined = " ".join(collect_text(style_elem)) + assert "color" in joined def test_parse_decodes_entities(self): html = "<div> & "test"" diff --git a/tests/test_layout.py b/tests/test_layout.py index 2447535..a9d84ef 100644 --- a/tests/test_layout.py +++ b/tests/test_layout.py @@ -145,6 +145,23 @@ class TestDocumentLayout: assert len(lines) > 1 # Should wrap to multiple lines + def test_document_layout_skips_style_tags(self): + """Style tags should not be rendered as text.""" + body = Element("body") + p = Element("p") + p.children = [Text("Visible text")] + style = Element("style") + style.children = [Text("body { color: red; }")] + body.children = [p, style] + + layout = DocumentLayout(body) + lines = layout.layout(800) + + assert len(lines) == 1 + assert lines[0].text == "Visible text" + # CSS should not appear in rendered text + assert not any("color" in line.text for line in lines) + def test_document_layout_char_positions(self): body = Element("body") p = Element("p") diff --git a/tests/test_styling_integration.py b/tests/test_styling_integration.py new file mode 100644 index 0000000..1bf2231 --- /dev/null +++ b/tests/test_styling_integration.py @@ -0,0 +1,248 @@ +"""Integration tests for CSS styling system.""" + +import pytest +from src.parser.html import parse_html_with_styles, Element +from src.layout.document import DocumentLayout + + +class TestStyleIntegration: + """Test end-to-end CSS parsing and layout integration.""" + + def test_parse_with_style_tag(self): + html = """ + + + + + +

Hello World

+ + + """ + root = parse_html_with_styles(html) + + # Find the p element + p_elem = None + for child in root.children: + if hasattr(child, "tag") and child.tag == "body": + for grandchild in child.children: + if hasattr(grandchild, "tag") and grandchild.tag == "p": + p_elem = grandchild + break + + assert p_elem is not None + assert hasattr(p_elem, "computed_style") + assert p_elem.computed_style.get("color") == "red" + assert p_elem.computed_style.get("font-size") == "18px" + + def test_inline_style_override(self): + html = """ + + +

Styled paragraph

+ + + """ + root = parse_html_with_styles(html) + + # Find the p element + for child in root.children: + if hasattr(child, "tag") and child.tag == "body": + for grandchild in child.children: + if hasattr(grandchild, "tag") and grandchild.tag == "p": + p_elem = grandchild + assert p_elem.computed_style.get("color") == "blue" + assert p_elem.computed_style.get("font-size") == "20px" + return + + pytest.fail("P element not found") + + def test_cascade_priority(self): + html = """ + + + + + +

Tag only

+

With class

+

With ID

+

With inline

+ + + """ + root = parse_html_with_styles(html) + + # Find body + body = None + for child in root.children: + if hasattr(child, "tag") and child.tag == "body": + body = child + break + + assert body is not None + paragraphs = [c for c in body.children if hasattr(c, "tag") and c.tag == "p"] + assert len(paragraphs) == 4 + + # Check cascade + assert paragraphs[0].computed_style.get("color") == "red" # Tag only + assert paragraphs[1].computed_style.get("color") == "green" # Class wins + assert paragraphs[2].computed_style.get("color") == "blue" # ID wins + assert paragraphs[3].computed_style.get("color") == "purple" # Inline wins + + def test_inheritance(self): + html = """ + + + + + +
+

Nested paragraph

+
+ + + """ + root = parse_html_with_styles(html) + + # Find the nested p element + for child in root.children: + if hasattr(child, "tag") and child.tag == "body": + for grandchild in child.children: + if hasattr(grandchild, "tag") and grandchild.tag == "div": + for ggchild in grandchild.children: + if hasattr(ggchild, "tag") and ggchild.tag == "p": + # Should inherit color from body + assert ggchild.computed_style.get("color") == "blue" + # Font-size may be set by default.css + assert ggchild.computed_style.get("font-size") != "" + return + + pytest.fail("Nested p element not found") + + def test_layout_uses_styles(self): + html = """ + + + + + +

Title

+

Paragraph

+ + + """ + root = parse_html_with_styles(html) + + # Create layout + layout = DocumentLayout(root) + lines = layout.layout(800) + # H1 should use custom font size + assert lines[0].font_size == 40 + + # P should use custom font size + assert lines[1].font_size == 20 + + def test_multiple_classes(self): + html = """ + + + + + +

Multiple classes

+ + + """ + root = parse_html_with_styles(html) + + # Find the p element + for child in root.children: + if hasattr(child, "tag") and child.tag == "body": + for grandchild in child.children: + if hasattr(grandchild, "tag") and grandchild.tag == "p": + # Should match both classes + assert grandchild.computed_style.get("font-size") == "24px" + assert grandchild.computed_style.get("color") == "red" + return + + pytest.fail("P element not found") + + def test_default_styles_applied(self): + html = """ + + +

Heading

+

Paragraph

+ Link + + + """ + root = parse_html_with_styles(html) + + # Find elements + body = None + for child in root.children: + if hasattr(child, "tag") and child.tag == "body": + body = child + break + + assert body is not None + + h1 = next((c for c in body.children if hasattr(c, "tag") and c.tag == "h1"), None) + p = next((c for c in body.children if hasattr(c, "tag") and c.tag == "p"), None) + a = next((c for c in body.children if hasattr(c, "tag") and c.tag == "a"), None) + + # Check default styles from default.css + assert h1 is not None + # Font-size from default.css is 2.5rem + assert h1.computed_style.get("font-size") == "2.5rem" + assert h1.computed_style.get("font-weight") == "600" + + assert p is not None + assert p.computed_style.get("display") == "block" + + assert a is not None + # Link color from default.css + assert a.computed_style.get("color") == "#0066cc" + assert a.computed_style.get("text-decoration") == "none" + + def test_no_styles_when_disabled(self): + html = """ + + + + + +

Test

+ + + """ + root = parse_html_with_styles(html, apply_styles=False) + + # Find the p element + for child in root.children: + if hasattr(child, "tag") and child.tag == "body": + for grandchild in child.children: + if hasattr(grandchild, "tag") and grandchild.tag == "p": + # Should not have computed_style when disabled + assert not hasattr(grandchild, "computed_style") + return + + pytest.fail("P element not found")