From a0f16c9a046010be0c1f91e686373b32b463e5ac Mon Sep 17 00:00:00 2001 From: Aaro Alhainen Date: Sat, 11 Jul 2026 13:24:14 +0300 Subject: [PATCH 01/12] Fix percentage font-size resolution when element has a parent The computation was misindented into the no-parent branch of computed_style, so any element with a parent got None back and the property was silently dropped by the cascade. --- tests/test_computed_style.py | 33 +++++++++++++++++++++++++++++++++ web/css/utils.py | 6 +++--- 2 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 tests/test_computed_style.py diff --git a/tests/test_computed_style.py b/tests/test_computed_style.py new file mode 100644 index 0000000..84d9f1f --- /dev/null +++ b/tests/test_computed_style.py @@ -0,0 +1,33 @@ +import unittest +from web.css.utils import computed_style +from test_tag_selector import MockElement + + +class TestComputedStyle(unittest.TestCase): + def test_font_size_px_passthrough(self): + element = MockElement("div") + self.assertEqual(computed_style(element, "font-size", "12px"), "12px") + + def test_font_size_percentage_with_parent(self): + parent = MockElement("div") + parent.style = {"font-size": "20px"} + element = MockElement("span", parent=parent) + self.assertEqual(computed_style(element, "font-size", "50%"), "10.0px") + + def test_font_size_percentage_without_parent(self): + element = MockElement("div") + element.parentNode = None + # Falls back to the inherited default of 16px. + self.assertEqual(computed_style(element, "font-size", "50%"), "8.0px") + + def test_font_size_unsupported_unit_returns_none(self): + element = MockElement("div") + self.assertIsNone(computed_style(element, "font-size", "1.2em")) + + def test_other_property_returned_verbatim(self): + element = MockElement("div") + self.assertEqual(computed_style(element, "color", "red"), "red") + + +if __name__ == '__main__': + unittest.main() diff --git a/web/css/utils.py b/web/css/utils.py index b728089..92c06a3 100644 --- a/web/css/utils.py +++ b/web/css/utils.py @@ -25,9 +25,9 @@ def computed_style(node: Element, property: str, value: str) -> Optional[str]: parent_font_size = node.parentNode.style["font-size"] else: parent_font_size = INHERITED_PROPERTIES["font-size"] - node_pct = float(value[:-1]) / 100 - parent_px = float(parent_font_size[:-2]) - return str(node_pct * parent_px) + "px" + node_pct = float(value[:-1]) / 100 + parent_px = float(parent_font_size[:-2]) + return str(node_pct * parent_px) + "px" else: return None else: From 1d7a705e3eab8b5c6dddf1f4d0d4c3f0283a5e18 Mon Sep 17 00:00:00 2001 From: Aaro Alhainen Date: Sat, 11 Jul 2026 13:24:37 +0300 Subject: [PATCH 02/12] Raise NotImplementedError instead of the NotImplemented constant raise NotImplemented is a TypeError at runtime since NotImplemented is not an exception class. --- tests/test_parser_utils.py | 21 +++++++++++++++++++++ web/html/parser/utils.py | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 tests/test_parser_utils.py diff --git a/tests/test_parser_utils.py b/tests/test_parser_utils.py new file mode 100644 index 0000000..6399ca9 --- /dev/null +++ b/tests/test_parser_utils.py @@ -0,0 +1,21 @@ +import unittest +from web.html.parser.utils import tag_is_special + + +class TestTagIsSpecial(unittest.TestCase): + def test_special_html_tag(self): + self.assertTrue(tag_is_special("div")) + + def test_non_special_html_tag(self): + self.assertFalse(tag_is_special("span")) + + def test_special_svg_tag(self): + self.assertTrue(tag_is_special("desc", "svg")) + + def test_mathml_namespace_raises_not_implemented(self): + with self.assertRaises(NotImplementedError): + tag_is_special("mi", "Mathml") + + +if __name__ == '__main__': + unittest.main() diff --git a/web/html/parser/utils.py b/web/html/parser/utils.py index d915731..ce48fea 100644 --- a/web/html/parser/utils.py +++ b/web/html/parser/utils.py @@ -133,6 +133,6 @@ def tag_is_special(tagName: str, nameSpace: str = "html") -> bool: "title" ] elif nameSpace == "Mathml": - raise NotImplemented + raise NotImplementedError return False From 508fc132eded723ce26cefdaaa3504268d16411b Mon Sep 17 00:00:00 2001 From: Aaro Alhainen Date: Sat, 11 Jul 2026 13:25:10 +0300 Subject: [PATCH 03/12] Fix EventInit defaults that were tuples due to trailing commas bubbles and cancelable defaulted to the truthy tuple (False,) instead of False. --- tests/test_event.py | 33 +++++++++++++++++++++++++++++++++ web/dom/events/Event.py | 4 ++-- 2 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 tests/test_event.py diff --git a/tests/test_event.py b/tests/test_event.py new file mode 100644 index 0000000..c3e1bc1 --- /dev/null +++ b/tests/test_event.py @@ -0,0 +1,33 @@ +import unittest +from web.dom.events.Event import Event, EventInit + + +class TestEventInit(unittest.TestCase): + def test_defaults_are_booleans(self): + init = EventInit() + self.assertIs(init.bubbles, False) + self.assertIs(init.cancelable, False) + self.assertIs(init.composed, False) + + def test_explicit_values_propagate(self): + init = EventInit(bubbles=True, cancelable=True) + self.assertIs(init.bubbles, True) + self.assertIs(init.cancelable, True) + self.assertIs(init.composed, False) + + +class TestEvent(unittest.TestCase): + def test_default_event_flags(self): + event = Event("click") + self.assertIs(event.bubbles, False) + self.assertIs(event.cancelable, False) + self.assertIs(event.composed, False) + + def test_event_flags_from_init_dict(self): + event = Event("click", EventInit(bubbles=True)) + self.assertIs(event.bubbles, True) + self.assertIs(event.cancelable, False) + + +if __name__ == '__main__': + unittest.main() diff --git a/web/dom/events/Event.py b/web/dom/events/Event.py index dc217c4..81bc9fc 100644 --- a/web/dom/events/Event.py +++ b/web/dom/events/Event.py @@ -7,8 +7,8 @@ @dataclass class EventInit: - bubbles: bool = False, - cancelable: bool = False, + bubbles: bool = False + cancelable: bool = False composed: bool = False From 143abc9a99367f798a3f97cd05ece6768f08831c Mon Sep 17 00:00:00 2001 From: Aaro Alhainen Date: Sat, 11 Jul 2026 13:25:57 +0300 Subject: [PATCH 04/12] Make DomException a proper Exception and raise it from Node mutations DomException subclassed BaseException and was never raised anywhere. It now subclasses Exception, carries its message through super().__init__, and Node.removeChild/appendChildBeforeElement raise it with name NotFoundError (per the DOM spec) instead of leaking a bare ValueError from the list operations. --- tests/test_dom_exception.py | 49 ++++++++++++++++++++++++++++++ web/dom/Node.py | 11 +++++-- web/dom/exceptions/DomException.py | 3 +- 3 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 tests/test_dom_exception.py diff --git a/tests/test_dom_exception.py b/tests/test_dom_exception.py new file mode 100644 index 0000000..d486b5b --- /dev/null +++ b/tests/test_dom_exception.py @@ -0,0 +1,49 @@ +import unittest +from web.dom.exceptions.DomException import DomException +from test_tag_selector import MockNode + + +class TestDomException(unittest.TestCase): + def test_is_exception_subclass(self): + self.assertTrue(issubclass(DomException, Exception)) + + def test_catchable_as_exception(self): + try: + raise DomException("boom") + except Exception as e: + self.assertIsInstance(e, DomException) + + def test_str_carries_message(self): + self.assertEqual(str(DomException("boom", name="NotFoundError")), "boom") + + def test_code_maps_name_to_error_code(self): + self.assertEqual(DomException("", name="NotFoundError").code().value, 8) + self.assertEqual(DomException("", name="TotallyUnknownError").code().value, 0) + + +class TestNodeMutationErrors(unittest.TestCase): + def test_remove_child_raises_not_found(self): + parent = MockNode() + stranger = MockNode() + with self.assertRaises(DomException) as ctx: + parent.removeChild(stranger) + self.assertEqual(ctx.exception.name, "NotFoundError") + + def test_append_child_before_element_raises_not_found(self): + parent = MockNode() + child = MockNode() + stranger = MockNode() + with self.assertRaises(DomException) as ctx: + parent.appendChildBeforeElement(child, stranger) + self.assertEqual(ctx.exception.name, "NotFoundError") + + def test_remove_child_removes_existing_child(self): + parent = MockNode() + child = MockNode() + parent.appendChild(child) + parent.removeChild(child) + self.assertEqual(parent.children, []) + + +if __name__ == '__main__': + unittest.main() diff --git a/web/dom/Node.py b/web/dom/Node.py index 319b4c8..5e52f4c 100644 --- a/web/dom/Node.py +++ b/web/dom/Node.py @@ -1,6 +1,7 @@ from typing import List, Union from web.dom.Document import Document from web.dom.events.EventTarget import EventTarget +from web.dom.exceptions.DomException import DomException import uuid @@ -41,11 +42,17 @@ def appendChild(self, node: 'Node') -> None: self.__children.append(node) def appendChildBeforeElement(self, node: 'Node', insertBefore: 'Node') -> None: - index = self.__children.index(insertBefore) + try: + index = self.__children.index(insertBefore) + except ValueError: + raise DomException("Node to insert before is not a child of this node", name="NotFoundError") self.__children.insert(index, node) def removeChild(self, node: 'Node') -> None: - self.__children.remove(node) + try: + self.__children.remove(node) + except ValueError: + raise DomException("Node to be removed is not a child of this node", name="NotFoundError") @property def children(self) -> List['Node']: diff --git a/web/dom/exceptions/DomException.py b/web/dom/exceptions/DomException.py index a56e6c2..59547d5 100644 --- a/web/dom/exceptions/DomException.py +++ b/web/dom/exceptions/DomException.py @@ -1,7 +1,7 @@ from enum import Enum -class DomException(BaseException): +class DomException(Exception): class __ErrorCode(Enum): NONE = 0 # Error is not recognized. INDEX_SIZE_ERR = 1 @@ -31,6 +31,7 @@ class __ErrorCode(Enum): DATA_CLONE_ERR = 25 def __init__(self, message: str = "", name: str = "Error"): + super().__init__(message) self.name = name self.message = message From 26a953bb71f8e293ffa5a8307a4111ac14549bfd Mon Sep 17 00:00:00 2001 From: Aaro Alhainen Date: Sat, 11 Jul 2026 13:26:33 +0300 Subject: [PATCH 05/12] Implement absolute font-size keyword to pixel conversion convert_absolute_size_to_pixels was a stub that returned 16 for every keyword; small/large/etc. now map to the standard scale on a 16px medium base. --- browser/styling/font/utils.py | 17 ++++++++++++++--- tests/test_font_utils.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 tests/test_font_utils.py diff --git a/browser/styling/font/utils.py b/browser/styling/font/utils.py index 498fcde..44ad4c9 100644 --- a/browser/styling/font/utils.py +++ b/browser/styling/font/utils.py @@ -1,8 +1,19 @@ -from typing import Literal +from typing import Dict, Literal CSS_FONTS_SIZE = ["xx-small", "x-small", "small", "medium", "large", "x-large", "xx-large", "xxx-large"] +# Absolute-size keywords mapped to pixels on the default 16px medium scale. +ABSOLUTE_FONT_SIZES: Dict[str, int] = { + "xx-small": 9, + "x-small": 10, + "small": 13, + "medium": 16, + "large": 18, + "x-large": 24, + "xx-large": 32, + "xxx-large": 48, +} + def convert_absolute_size_to_pixels(font_size: Literal["xx-small", "x-small", "small", "medium", "large", "x-large", "xx-large", "xxx-large"]) -> int: - # TODO: Handle font size conversion. - return 16 \ No newline at end of file + return ABSOLUTE_FONT_SIZES.get(font_size, 16) diff --git a/tests/test_font_utils.py b/tests/test_font_utils.py new file mode 100644 index 0000000..1d77581 --- /dev/null +++ b/tests/test_font_utils.py @@ -0,0 +1,28 @@ +import unittest +from browser.styling.font.utils import CSS_FONTS_SIZE, convert_absolute_size_to_pixels + + +class TestConvertAbsoluteSizeToPixels(unittest.TestCase): + def test_keyword_mappings(self): + expected = { + "xx-small": 9, + "x-small": 10, + "small": 13, + "medium": 16, + "large": 18, + "x-large": 24, + "xx-large": 32, + "xxx-large": 48, + } + for keyword, pixels in expected.items(): + self.assertEqual(convert_absolute_size_to_pixels(keyword), pixels) + + def test_every_known_keyword_has_a_mapping(self): + # Guards against CSS_FONTS_SIZE drifting apart from the mapping. + sizes = [convert_absolute_size_to_pixels(keyword) for keyword in CSS_FONTS_SIZE] + self.assertEqual(sizes, sorted(sizes)) + self.assertEqual(len(set(sizes)), len(CSS_FONTS_SIZE)) + + +if __name__ == '__main__': + unittest.main() From f75ff5e6a3d882abdb59df8593ac6d212283cfcf Mon Sep 17 00:00:00 2001 From: Aaro Alhainen Date: Sat, 11 Jul 2026 13:27:50 +0300 Subject: [PATCH 06/12] Fix rem/em width and height math in Layout.calculate_size The width branches re-derived the font size from the raw style string and assigned a str in the rem/em arms, so int * str string-repeated into self.width and later int() calls raised ValueError. The height branches crashed on any non-px font-size value. Both now use self.font_size, which is already resolved to int pixels in __init__. rem still uses the element font size as its base like create_margin/ create_padding do; the proper root-based rem distinction is tracked in TODO section 3. --- browser/layouts/Layout.py | 37 ++++--------------------- tests/test_layout_units.py | 55 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 32 deletions(-) create mode 100644 tests/test_layout_units.py diff --git a/browser/layouts/Layout.py b/browser/layouts/Layout.py index c466131..61466df 100644 --- a/browser/layouts/Layout.py +++ b/browser/layouts/Layout.py @@ -150,11 +150,9 @@ def calculate_size(self) -> None: self.height = int(attr_height.replace("px", "")) elif attr_height.endswith("rem"): #TODO: Fix rem and em calculations. - font_size = int(self.node.style["font-size"].replace("px", "")) - self.height = float(attr_height.replace("rem", "")) * font_size + self.height = int(float(attr_height.replace("rem", "")) * self.font_size) elif attr_height.endswith("em"): - font_size = int(self.node.style["font-size"].replace("px", "")) - self.height = float(attr_height.replace("em", "")) * font_size + self.height = int(float(attr_height.replace("em", "")) * self.font_size) self.calculated_height = self.height @@ -181,35 +179,10 @@ def calculate_size(self) -> None: if attr_width.endswith("px"): self.width = int(float(attr_width.replace("px", ""))) elif attr_width.endswith("rem"): - font_size_str: str = self.node.style["font-size"] - if font_size_str.endswith("px"): - font_size = int(font_size_str.replace("px", "")) - elif font_size_str.endswith("%"): - parent_font_size = int(self.parent.node.style["font-size"].replace("px", "")) - font_size = ((parent_font_size / 100) * int(font_size_str.replace("%", ""))) - elif font_size_str.endswith("rem"): - #TODO: Fix rem and em calculations. - parent_font_size = int(self.parent.node.style["font-size"].replace("px", "")) - font_size = str(parent_font_size * float(font_size_str.replace("rem", ""))) - elif font_size_str.endswith("em"): - parent_font_size = int(self.parent.node.style["font-size"].replace("px", "")) - font_size = str(parent_font_size * float(font_size_str.replace("em", ""))) - self.width = int(float(attr_width.replace("rem", ""))) * font_size + #TODO: Fix rem and em calculations. + self.width = int(float(attr_width.replace("rem", "")) * self.font_size) elif attr_width.endswith("em"): - font_size_str: str = self.node.style["font-size"] - if font_size_str.endswith("px"): - font_size = int(font_size_str.replace("px", "")) - elif font_size_str.endswith("%"): - parent_font_size = int(self.parent.node.style["font-size"].replace("px", "")) - font_size = ((parent_font_size / 100) * int(font_size_str.replace("%", ""))) - elif font_size_str.endswith("rem"): - #TODO: Fix rem and em calculations. - parent_font_size = int(self.parent.node.style["font-size"].replace("px", "")) - font_size = str(parent_font_size * float(font_size_str.replace("rem", ""))) - elif font_size_str.endswith("em"): - parent_font_size = int(self.parent.node.style["font-size"].replace("px", "")) - font_size = str(parent_font_size * float(font_size_str.replace("em", ""))) - self.width = int(float(attr_width.replace("em", ""))) * font_size + self.width = int(float(attr_width.replace("em", "")) * self.font_size) elif attr_width.endswith("%"): self.should_recalculate_size = True parent_width = self.parent.width diff --git a/tests/test_layout_units.py b/tests/test_layout_units.py new file mode 100644 index 0000000..bb55338 --- /dev/null +++ b/tests/test_layout_units.py @@ -0,0 +1,55 @@ +import unittest + +try: + from browser.elements.elements import Border + from browser.layouts.Layout import Layout, Margin, Padding +except ImportError: + raise unittest.SkipTest("tkinter/PIL not available") + +from test_tag_selector import MockElement + + +def make_layout(style: dict) -> Layout: + """Builds a bare Layout without running __init__ (which needs a full layout tree).""" + layout = object.__new__(Layout) + element = MockElement("div") + element.style = style + layout.node = element + layout.parent = None + layout.children = [] + layout.x = 0 + layout.y = 0 + layout.width = 0 + layout.height = 0 + layout.internal_padding = 0 + layout.should_recalculate_size = False + layout.margin = Margin() + layout.border = Border() + layout.padding = Padding() + layout.font_size = 16 + layout.float = "none" + layout.calculated_height = 0 + return layout + + +class TestEmRemSizes(unittest.TestCase): + def test_width_and_height_in_em_and_rem(self): + layout = make_layout({"width": "2em", "height": "2rem", "font-size": "16px"}) + layout.calculate_size() + self.assertEqual(layout.width, 32) + self.assertEqual(layout.height, 32) + + def test_em_size_stays_numeric_with_relative_font_size(self): + # Regression: the old code re-parsed the raw font-size string and + # produced str * int repetition (or ValueError) for non-px values. + layout = make_layout({"width": "2em", "height": "1.5em", "font-size": "150%"}) + layout.font_size = 24 # resolved 150% of 16px + layout.calculate_size() + self.assertIsInstance(layout.width, int) + self.assertIsInstance(layout.height, int) + self.assertEqual(layout.width, 48) + self.assertEqual(layout.height, 36) + + +if __name__ == '__main__': + unittest.main() From de1debfe56e25af1fbbdd90d0f459aa3ca9ab35f Mon Sep 17 00:00:00 2001 From: Aaro Alhainen Date: Sat, 11 Jul 2026 13:28:52 +0300 Subject: [PATCH 07/12] Set internal_padding once from the left border width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every border branch in create_border wrote internal_padding, so the value ended up as whichever side was processed last — e.g. a lone border-top-width horizontally indented the first word of text. It is now derived once from the parsed left border, matching its sole use as TextLayout's x inset. The em branch of calclulate_border_width also now uses the resolved self.font_size instead of re-parsing the raw style string, which crashed on non-px font-size values. Left padding is intentionally still not included; tracked as follow-up. --- browser/layouts/Layout.py | 11 +++-------- tests/test_layout_units.py | 23 +++++++++++++++++++++++ 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/browser/layouts/Layout.py b/browser/layouts/Layout.py index 61466df..4c2bac2 100644 --- a/browser/layouts/Layout.py +++ b/browser/layouts/Layout.py @@ -242,8 +242,7 @@ def layout_mode(self, node: Node) -> Literal["inline", "block"]: def create_border(self) -> None: def calclulate_border_width(width: str) -> int: if width.endswith("em"): - font_size = int(self.node.style["font-size"].replace("px", "")) - return int(float(width.replace("em", "")) * font_size) + return int(float(width.replace("em", "")) * self.font_size) elif width.endswith("%"): return int(self.parent.width * (int(width.replace("%", "")) / 100)) elif width.endswith("px"): @@ -263,7 +262,6 @@ def calclulate_border_width(width: str) -> int: border_width = calclulate_border_width(width) if color: - self.internal_padding = border_width for side in ["top", "right", "bottom", "left"]: self.border.set_border(side, BorderProperties(width=border_width, color=transform_color(color))) elif style.get("border-width", None) and style.get("border-color", None): @@ -278,24 +276,20 @@ def calclulate_border_width(width: str) -> int: for index, side in enumerate(["top", "right", "bottom", "left"]): width = widths[index] border_width = calclulate_border_width(width) - self.internal_padding = border_width self.border.set_border(side, BorderProperties(width=border_width, color=transform_color(color))) elif len(widths) == 2: for side in ["top", "bottom"]: width = widths[0] border_width = calclulate_border_width(width) - self.internal_padding = border_width self.border.set_border(side, BorderProperties(width=border_width, color=transform_color(color))) for side in ["right", "left"]: width = widths[1] border_width = calclulate_border_width(width) - self.internal_padding = border_width self.border.set_border(side, BorderProperties(width=border_width, color=transform_color(color))) elif len(widths) == 1: for side in ["top", "right", "bottom", "left"]: width = widths[0] border_width = calclulate_border_width(width) - self.internal_padding = border_width self.border.set_border(side, BorderProperties(width=border_width, color=transform_color(color))) else: @@ -304,9 +298,10 @@ def calclulate_border_width(width: str) -> int: color = style.get(f"border-{side}-color", None) if width and color: border_width = calclulate_border_width(width) - self.internal_padding = border_width self.border.set_border(side, BorderProperties(width=border_width, color=transform_color(color))) + self.internal_padding = self.border.get_border("left").width + def create_margin(self) -> None: def calculate_margin_width(width: str) -> int: if width.endswith("rem"): diff --git a/tests/test_layout_units.py b/tests/test_layout_units.py index bb55338..61d93a0 100644 --- a/tests/test_layout_units.py +++ b/tests/test_layout_units.py @@ -51,5 +51,28 @@ def test_em_size_stays_numeric_with_relative_font_size(self): self.assertEqual(layout.height, 36) +class TestCreateBorderInternalPadding(unittest.TestCase): + def test_top_border_only_does_not_indent_text(self): + layout = make_layout({"border-top-width": "4px", "border-top-color": "red", "font-size": "16px"}) + layout.create_border() + self.assertEqual(layout.internal_padding, 0) + + def test_four_value_border_width_uses_left_side(self): + layout = make_layout({"border-width": "1px 2px 3px 4px", "border-color": "red", "font-size": "16px"}) + layout.create_border() + self.assertEqual(layout.internal_padding, 4) + self.assertEqual(layout.border.get_border("top").width, 1) + self.assertEqual(layout.border.get_border("right").width, 2) + self.assertEqual(layout.border.get_border("bottom").width, 3) + self.assertEqual(layout.border.get_border("left").width, 4) + + def test_border_shorthand_sets_all_sides(self): + layout = make_layout({"border": "2px solid red", "font-size": "16px"}) + layout.create_border() + self.assertEqual(layout.internal_padding, 2) + for side in ["top", "right", "bottom", "left"]: + self.assertEqual(layout.border.get_border(side).width, 2) + + if __name__ == '__main__': unittest.main() From cfd7920c6ed4996b9e0a2d4238d168b35cd29aee Mon Sep 17 00:00:00 2001 From: Aaro Alhainen Date: Sat, 11 Jul 2026 13:29:39 +0300 Subject: [PATCH 08/12] Restore DrawBorder.calculate_offset corner mitre logic The body was commented out so the method always returned (0, 0), leaving corner gaps whenever per-side borders had differing widths. Each border line is again extended by half the width of its perpendicular neighbours. --- browser/elements/elements.py | 32 +++++++------------------------- tests/test_layout_units.py | 19 ++++++++++++++++++- 2 files changed, 25 insertions(+), 26 deletions(-) diff --git a/browser/elements/elements.py b/browser/elements/elements.py index 17e696a..37d9ec9 100644 --- a/browser/elements/elements.py +++ b/browser/elements/elements.py @@ -139,31 +139,13 @@ def __init__(self, x1, y1, x2, y2, border: Border = Border()): self.border = border def calculate_offset(self, side: Literal["top", "left", "bottom", "right"]) -> Tuple[int, int]: - offset: Tuple[int, int] = (0, 0) - """ - if side == "top": - if self.border.get_border("left"): - offset = (int(self.border.get_border("left").width/2), offset[1]) - if self.border.get_border("right"): - offset = (offset[0], int(self.border.get_border("right").width/2)) - elif side == "left": - if self.border.get_border("top"): - offset = (offset[0], int(self.border.get_border("top").width/2)) - if self.border.get_border("bottom"): - offset = (int(self.border.get_border("bottom").width/2), offset[1]) - elif side == "bottom": - if self.border.get_border("left"): - offset = (int(self.border.get_border("left").width/2), offset[1]) - if self.border.get_border("right"): - offset = (offset[0], int(self.border.get_border("right").width/2)) - elif side == "right": - if self.border.get_border("top"): - offset = (offset[0], int(self.border.get_border("top").width/2)) - if self.border.get_border("bottom"): - offset = (int(self.border.get_border("bottom").width/2), offset[1]) - -""" - return offset + # Extend each border line by half the width of the perpendicular + # borders so lines of differing widths meet at the corners. + if side in ("top", "bottom"): + return (self.border.get_border("left").width // 2, + self.border.get_border("right").width // 2) + return (self.border.get_border("bottom").width // 2, + self.border.get_border("top").width // 2) def execute(self, scroll: int, canvas: Canvas, supported_emojis: List[str]): widths = [border.width for border in self.border.get_borders().values()] diff --git a/tests/test_layout_units.py b/tests/test_layout_units.py index 61d93a0..75f2ec2 100644 --- a/tests/test_layout_units.py +++ b/tests/test_layout_units.py @@ -1,8 +1,9 @@ import unittest try: - from browser.elements.elements import Border + from browser.elements.elements import Border, BorderProperties, DrawBorder from browser.layouts.Layout import Layout, Margin, Padding + from browser.styling.color.utils import transform_color except ImportError: raise unittest.SkipTest("tkinter/PIL not available") @@ -74,5 +75,21 @@ def test_border_shorthand_sets_all_sides(self): self.assertEqual(layout.border.get_border(side).width, 2) +class TestDrawBorderOffset(unittest.TestCase): + def test_offsets_extend_into_perpendicular_borders(self): + border = Border() + border.set_border("left", BorderProperties(color=transform_color("red"), width=4)) + border.set_border("right", BorderProperties(color=transform_color("red"), width=2)) + draw_border = DrawBorder(0, 0, 100, 100, border) + self.assertEqual(draw_border.calculate_offset("top"), (2, 1)) + self.assertEqual(draw_border.calculate_offset("bottom"), (2, 1)) + self.assertEqual(draw_border.calculate_offset("left"), (0, 0)) + + def test_zero_width_borders_give_zero_offset(self): + draw_border = DrawBorder(0, 0, 100, 100, Border()) + for side in ["top", "right", "bottom", "left"]: + self.assertEqual(draw_border.calculate_offset(side), (0, 0)) + + if __name__ == '__main__': unittest.main() From 449fc1f59fc5923578617680f73b4c3a17115d84 Mon Sep 17 00:00:00 2001 From: Aaro Alhainen Date: Sat, 11 Jul 2026 13:30:05 +0300 Subject: [PATCH 09/12] Use window height instead of width in scroll calculations scroll_down's max-scroll and the scrollbar thumb updates in scroll_down, scroll_up and scrollbar_scroll all read get_window_size()[0] (width) where the viewport height belongs, letting the page scroll past its end on landscape windows. --- browser/Browser.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/browser/Browser.py b/browser/Browser.py index fcee609..bcce1a7 100644 --- a/browser/Browser.py +++ b/browser/Browser.py @@ -291,13 +291,13 @@ def handle_scroll(self, direction: tkinter.Event): def scroll_down(self, delta: int): delta = delta * -1 - max_y = (self.document.content_height - BrowserState.get_window_size()[0]) + 15 + max_y = (self.document.content_height - BrowserState.get_window_size()[1]) + 15 scroll = min(self.scroll + (delta * SCROLL_STEP), max_y) if scroll <= 0: self.scroll = 0 else: self.scroll = scroll - self.scrollbar.set((self.scroll/self.document.content_height), ((self.scroll + BrowserState.get_window_size()[0])/self.document.content_height)) + self.scrollbar.set((self.scroll/self.document.content_height), ((self.scroll + BrowserState.get_window_size()[1])/self.document.content_height)) self.draw() def scroll_up(self, delta): @@ -310,7 +310,7 @@ def scroll_up(self, delta): self.scroll = 0 else: self.scroll -= (delta * SCROLL_STEP) - self.scrollbar.set((self.scroll/self.document.content_height), ((self.scroll + BrowserState.get_window_size()[0])/self.document.content_height)) + self.scrollbar.set((self.scroll/self.document.content_height), ((self.scroll + BrowserState.get_window_size()[1])/self.document.content_height)) self.draw() def scrollbar_scroll(self, action: Literal["moveto"], position: str): @@ -320,7 +320,7 @@ def scrollbar_scroll(self, action: Literal["moveto"], position: str): if not 0 <= position_float <= max_position: return self.scroll = self.document.content_height * position_float - self.scrollbar.set((self.scroll/self.document.content_height), ((self.scroll + BrowserState.get_window_size()[0])/self.document.content_height)) + self.scrollbar.set((self.scroll/self.document.content_height), ((self.scroll + BrowserState.get_window_size()[1])/self.document.content_height)) self.draw() def is_emoji(self, unicode) -> bool: From 70b7dae604101b77899c516d37267f50b9d5fd47 Mon Sep 17 00:00:00 2001 From: Aaro Alhainen Date: Sat, 11 Jul 2026 13:30:48 +0300 Subject: [PATCH 10/12] Remove debug file writes and stray prints from page loading raster() no longer dumps rules.txt and document.html into the working directory on every page load; drop leftover debug prints in ImageLayout and transform_color. --- browser/Browser.py | 5 ----- browser/layouts/ImageLayout.py | 1 - browser/styling/color/utils.py | 1 - 3 files changed, 7 deletions(-) diff --git a/browser/Browser.py b/browser/Browser.py index bcce1a7..7e52bf1 100644 --- a/browser/Browser.py +++ b/browser/Browser.py @@ -259,12 +259,7 @@ def raster(self, dom: DocumentType): child = cast(CharacterData, child) rules.extend(CSSParser(child.data).parse()) - with open("rules.txt", "w") as f: - for rule in rules: - f.write(str(rule.__dict__) + "\n") style(dom, sorted(rules, key=cascade_priority)) - with open("document.html", "w") as f: - f.write(str(dom)) self.document = DocumentLayout(dom) [inspector.update_dom(dom) for inspector in BrowserState.get_inspectors()] self.document.height = BrowserState.get_window_size()[1] diff --git a/browser/layouts/ImageLayout.py b/browser/layouts/ImageLayout.py index 835b068..9a3885e 100644 --- a/browser/layouts/ImageLayout.py +++ b/browser/layouts/ImageLayout.py @@ -99,7 +99,6 @@ def calculate_height(self) -> int: attr_height = attr_height[:-2] return int(attr_height) elif style_height == "auto": - print("width", self.width) if self.width == None: return 100 style_height = str(self.width) diff --git a/browser/styling/color/utils.py b/browser/styling/color/utils.py index c16db96..090cad7 100644 --- a/browser/styling/color/utils.py +++ b/browser/styling/color/utils.py @@ -182,7 +182,6 @@ def transform_color(color: str) -> ValidColor: return ValidColor("color", "") if type(color) == tuple and len(color) == 4: - print("Error?: Color is already a tuple") return ValidColor("rgba_color", color) if color == "initial": From 498db549f047e04a7f4924a37fbf546875cd56c3 Mon Sep 17 00:00:00 2001 From: Aaro Alhainen Date: Sat, 11 Jul 2026 13:35:02 +0300 Subject: [PATCH 11/12] Log page-load failures instead of silently swallowing them check_key wrapped load_webpage in a bare except: pass, so any network, parse or layout error on Enter produced no feedback at all. Catch Exception, log it, and keep the 'break' binding behavior. Also annotate the log helper so it passes strict mypy. --- browser/Browser.py | 7 ++++--- browser/utils/logging.py | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/browser/Browser.py b/browser/Browser.py index 7e52bf1..95689bc 100644 --- a/browser/Browser.py +++ b/browser/Browser.py @@ -89,14 +89,15 @@ def __init__(self) -> None: with open("./browser/styling/defaults/browser.css") as file: self.default_style_sheet = CSSParser(file.read()).parse() - def check_key(self, event): + def check_key(self, event: tkinter.Event) -> Optional[str]: # Ignore the 'Return' key if event.keysym == "Return": try: self.load_webpage() - except: - pass + except Exception as e: + logging.log("Failed to load page:", e) return "break" + return None def init_emojis(self) -> List[str]: from os import listdir diff --git a/browser/utils/logging.py b/browser/utils/logging.py index 5014b6d..90bc2af 100644 --- a/browser/utils/logging.py +++ b/browser/utils/logging.py @@ -1,4 +1,4 @@ -def log(*args, **kwargs): - print(*args, **kwargs) +def log(*args: object) -> None: + print(*args) From 3be2e720ee56b93218c687f4ae44e2d2d9f179c2 Mon Sep 17 00:00:00 2001 From: Aaro Alhainen Date: Sat, 11 Jul 2026 14:06:04 +0300 Subject: [PATCH 12/12] Add TODO roadmap with section 1 quick fixes completed --- TODO.md | 93 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 TODO.md diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..b1bb799 --- /dev/null +++ b/TODO.md @@ -0,0 +1,93 @@ +# TODO + +Concrete, code-level roadmap for theBrowser, ordered by priority: rendering correctness first (make real pages and Acid1 render better), long-term fronts (JS) last. High-level status lives in [README.md](README.md); this list tracks the actual gaps found in the code. + +## 1. Quick fixes / known bugs + +High impact, small effort — mostly straight-up bugs. + +- [x] `computed_style` percentage font-size with a parent falls through and returns nothing — the `%` branch only handles the no-parent path (`web/css/utils.py:19-34`) +- [x] `raise NotImplemented` uses the non-exception constant, so it never actually raises (`web/html/parser/utils.py:136`) +- [x] rem/em handling assigns a `str` to `font_size` and then multiplies with it (`browser/layouts/Layout.py:151-212`, `browser/layouts/ImageLayout.py:74,112`) +- [x] `convert_absolute_size_to_pixels` is a stub that always returns 16 — keyword font sizes (`small`/`medium`/`large`/...) are ignored (`browser/styling/font/utils.py:7`) +- [x] `scroll_down` uses window *width* instead of height for max scroll (`browser/Browser.py:294`) +- [x] Border and padding both write `internal_padding` and clobber each other (`browser/layouts/Layout.py:293,308`) +- [x] `DrawBorder.calculate_offset` body is commented out (always returns `(0, 0)`), so per-side borders with differing widths misalign at corners (`browser/elements/elements.py:143-165`) +- [x] `raster()` writes debug files `rules.txt` and `document.html` to CWD on every page load (`browser/Browser.py:262-267`); stray debug prints (`browser/layouts/ImageLayout.py:102`, `browser/styling/color/utils.py:185`) +- [x] Bare `except: pass` hides all page-load errors (`browser/Browser.py:98`) +- [x] `EventInit` trailing commas turn `bubbles`/`cancelable` into tuples (`web/dom/events/Event.py:10-11`) +- [x] `DomException` subclasses `BaseException` — should be `Exception`, and should actually be raised from DOM mutation code (`web/dom/exceptions/DomException.py`) + +## 2. CSS correctness + +The biggest rendering-correctness lever: cascade and selector matching. + +- [ ] Correct specificity — `(id, class, tag)` tuple instead of hardcoded `priority = 1` (`web/css/TagSelector.py:6`, `web/css/DescendantSelector.py:9`, `cascade_priority` in `web/css/utils.py`) +- [ ] Honor `!important` — it's parsed but the flag is discarded (`web/css/CSSParser.py:66-69`) +- [ ] Shorthand property expansion: `margin`, `padding`, `border`, `font`, `background` +- [ ] More selectors: universal `*`, child `>`, sibling `+`/`~`, attribute `[attr]`, basic pseudo-classes +- [ ] Broaden inherited properties beyond the current 4 (`web/css/utils.py:7-12`) and unit resolution beyond font-size px/% (proper `em`/`rem`/`pt`) +- [ ] `@media` blocks are currently swallowed and their rules discarded — parse and evaluate (`web/css/CSSParser.py:122-136`) +- [ ] Colors: `#RRGGBBAA` hex, `hsl()`/`hsla()`, `currentColor`; `initial` shouldn't hardcode black (`browser/styling/color/utils.py:161,189`) + +## 3. Layout + +Acid1 lives or dies here. + +- [ ] Proper float support: `clear`, a shared float context instead of copy-pasted left/right branches across BlockLayout/InlineLayout/TableLayout/dl (`browser/layouts/Layout.py:121`) +- [ ] Margins: `auto` centering, margin collapsing, negative margins (`browser/layouts/Layout.py:337-383`) +- [ ] `min-width`/`max-width`/`min-height`/`max-height` (commented out in `browser/layouts/InputLayout.py:48-53`), `calc()` (currently dropped, `browser/layouts/Layout.py:163`), `max-content` (`browser/layouts/Layout.py:222`) +- [ ] Units: `vh`, proper `em` vs `rem` distinction, `ch`/`ex`/`cm`/`mm`/`in` +- [ ] `display: inline-block`; `position: relative/absolute/fixed` and `z-index` — nothing reads `position` today +- [ ] Table: colspan/rowspan, `thead`/`tfoot`/`caption`, `border-collapse`, a real width algorithm instead of the current heuristic (`browser/layouts/table/TableLayout.py:196-218,261-271`) +- [ ] `dl`: distinguish `dt` vs `dd` (indentation) (`browser/layouts/dl/`) +- [ ] Remove BlockLayout hacks: default `height = 10` and the body re-layout loop (`browser/layouts/BlockLayout.py:13,45-48`) + +## 4. Painting / rendering + +- [ ] `border-style` (only solid supported) and `border-radius` (`browser/layouts/Layout.py:289`) +- [ ] `text-decoration` (underline/line-through), `text-align`, `white-space` handling +- [ ] `font-family` — currently ignored, tkinter default is always used (`browser/layouts/utils.py`) +- [ ] Re-enable emoji image rendering — the code path is commented out, emojis render as plain text (`browser/Browser.py:335-350`, `browser/elements/elements.py:42-54`) +- [ ] `background-image`; proper rgba rendering instead of the per-rect PIL image workaround (`browser/elements/elements.py:106,232`) + +## 5. HTML parsing (spec completeness) + +- [ ] Adoption-agency algorithm, reconstruct-active-formatting-elements, generate-implied-end-tags (`web/html/parser/HTMLDocumentParser.py:201,534-694,743`) +- [ ] Table insertion modes — InTable/InTableText/InCaption/InColumnGroup/InTableBody/InRow/InCell/InSelect are empty stubs (`web/html/parser/HTMLDocumentParser.py:875-900`) +- [ ] Frameset / after-body / after-after modes (`web/html/parser/HTMLDocumentParser.py:939-949`) +- [ ] Missing tokenizer states: DOCTYPE public/system identifiers, CDATA sections, PLAINTEXT, script-data-double-escaped, after-attribute-name, comment-end-bang (`web/html/parser/HTMLTokenizerRefactored.py:277,719-731,776,1012,1041-1086`) +- [ ] Named character reference state-switch TODO (`web/html/parser/HTMLTokenizerRefactored.py:1122`) and numeric noncharacter fix-up (~1211) +- [ ] Quirks-mode detection (`web/html/parser/HTMLDocumentParser.py:311`) and charset handling (`372,568`) +- [ ] Delete dead code: old `web/html/parser/HTMLTokenizer.py` (imported nowhere — parser uses `HTMLTokenizerRefactored`) and the `libs/JSlib/JavaScript.py` stub + +## 6. Networking & forms + +- [ ] Real POST form submission — `submit_form` always builds a GET query string, ignoring `method`/`enctype` (`browser/Browser.py:197-214`) +- [ ] Serialize `select`/`textarea`/checkbox semantics in form data; radio buttons don't deselect their group siblings (`browser/Browser.py:177-182`) +- [ ] `data:` URLs; handle `request()` returning `None` for unsupported schemes without crashing (`browser/utils/networking.py:37`) +- [ ] Replace hand-rolled `resolve_url` with `urllib.parse.urljoin` (`browser/utils/networking.py:16-35`) +- [ ] Cookies, explicit redirect handling, request timeouts; `REQUEST_CACHE` never invalidates and ignores Cache-Control (`browser/utils/networking.py:10,40,54`) + +## 7. Browser chrome + +- [ ] History with back/forward buttons +- [ ] Tabs +- [ ] Bookmarks +- [ ] Text input: caret positioning, selection, clipboard, tab between fields; `textarea` and `select` support +- [ ] Inspector: computed-styles panel; network view appends without clearing so rows duplicate (`browser/Inspector.py:100-102`); scrollbar created twice and wired to the wrong tree (`browser/Inspector.py:88-93`) + +## 8. JavaScript (long-term) + +- [ ] Lexer/parser for JS source — none exists, the AST can't be built from text +- [ ] Interpreter scope handling — `__enter_scope`/`__exit_scope` raise `NotImplementedError`, so `run()` always throws (`web/js/Interpreter.py:26-29`) +- [ ] Fix `ASTNode.execute` signature mismatches and implement the missing `execute` methods (`web/js/ASTNode.py`) +- [ ] DOM events: listener storage, `dispatchEvent`, propagation, `preventDefault`/`stopPropagation` — `EventTarget.add_event_listener` raises today (`web/dom/events/EventTarget.py:14`) +- [ ] Wire `