These past few days, I’ve been working on advancing the implementation of HTML and CSS in my custom browser, Omoikane (source).
At first, I displayed the same HTML as Firefox and investigated where any rendering discrepancies remained.
When I began the comparison, I discovered not only simple color differences but also fundamental shortcomings, such as inline elements not having their bounding boxes correctly calculated, web fonts not being manageable from JavaScript, and HTML fragments not being parsed according to the context.
As I fixed each issue in turn, the problems spread to rendering, layout, the CSSOM, the DOM, and even the HTML parser.
This time, I’ll summarize the 15 issues that were resolved.
Initial rendering comparison with Firefox
The trigger was opening the same test fixture in both Omoikane and Firefox and comparing the DOM bounding boxes with screenshots.
The first issues discovered in this comparison were:
- The multi‑line display of
<textarea>and the line‑break positions do not match. - The background of inline
<span>elements is not rendered, andgetClientRects()also becomes empty. - Arabic text appears as missing glyphs instead of connected ligatures.
In PR #680, we fixed issues #673, #674, and #675 together. (PR #680)
For inline elements, we now retain per‑line element boundaries and use the same rectangle for background rendering as well as for getClientRects() and getBoundingClientRect().
For <textarea>, we normalize line‑break characters to LF, handle word‑ and grapheme‑level wrapping, line height, clipping at padding edges, and caret and selection positions based on UTF‑16 code units.
For Arabic text, we not only added more font fallbacks but also fixed issues such as missed data‑URL loads, a processing step that was converting numbers inside URLs and breaking the Base64 encoding, and a problem where the font family was being stripped from font shorthand declarations.
We captured 25 test cases before and after the fix, as well as with Firefox, and confirmed that the re‑captured images matched within each browser.
In this PR we also fixed issue #703, which caused the test HTTP server on macOS to become WouldBlock and resulted in a double panic during cleanup after CSP testing.
If the test infrastructure is unstable before performing rendering comparisons, it becomes impossible to tell whether a discrepancy stems from an implementation difference or a test failure, so addressing this was also necessary.
Treating inline‑block as a single box
When comparing fixtures that use web fonts, the width of the strings matched, but the height of inline-block was off by 10 px.
#measure {
display: inline-block;
font: 40px/50px DynamicFace, monospace;
border: 2px solid;
padding: 8px;
}
In Firefox, the border‑box height is 70 px.
line-height 50px
+ padding 8px × 2
+ border 2px × 2
= 70px
In Omoikane it was 60 px.
The cause was that inline-block was being expanded into a fragment in the same way as a normal inline element, without creating an isolated formatting context inside it. As a result, instead of the explicitly specified 50 px line box, a height equivalent to about 40 px of glyph metrics was used.
In Issue #709, we treated inline-block as an atomic inline‑level box placed within its parent line, and changed child elements to be laid out using an independent LayoutBox. (issue #709)
With this change, internal line boxes, block children, shrink‑to‑fit, baseline alignment, vertical-align, overflow handling, transforms, and hit‑testing can all be processed on a per‑element basis.
After the fix, the DOMRect values for both Firefox and Omoikane became (24, 24, 300, 70).
Enabling web‑font handling from JavaScript
Omoikane has long been able to use CSS @font-face for rendering, but it lacked an API for loading fonts from JavaScript.
From the page’s perspective, the following operations were not possible:
const face = new FontFace("Example", "url(example.woff2)");
await face.load();
document.fonts.add(face);
await document.fonts.ready;
In Issue #687, we implemented FontFace, FontFaceSet, document.fonts, and a task source for loading fonts. (issue #687)
This includes loading from URLs and BufferSource, load(), loaded, status, registration and removal, check(), ready, and even loading‑related events.
Another problem was also discovered here.
The existing CSSOM was scanning the roughly 1 MB font data URL character by character in JavaScript. As a result, it hit the loop limit and could not enumerate @font-face.
Because declaration splitting used a simple ; delimiter, semicolons inside data URLs or quoted strings were also treated as declaration separators.
In Issue #708, we moved the CSS rule scanner that holds the source to Rust, and implemented CSSFontFaceRule and live updates of descriptors. (issue #708)
When a descriptor is changed from CSSFontFaceRule.style, the change is reflected in the original stylesheet and FontFace, and when src is modified or a rule is removed, the old face is detached.
These two changes were bundled into PR #712. (PR #712)
All 75 fixed Web Platform Tests now run successfully, and the element geometry matches Firefox exactly. The remaining discrepancy is only in the glyph outlines, which will be addressed separately later.
Adding layers to the CSS cascade
@layer could be parsed syntactically, but the style resolver treated the rules inside it as ordinary rules.
For example, when a high‑specificity selector in an earlier layer conflicts with a lower‑specificity selector in a later layer, the latter layer should normally take precedence.
However, because the layer order was not considered, Omoikane sometimes let the preceding rule win solely based on specificity.
In Issue #706, we registered named, anonymous, nested, imported, and unlayered rules in the order they are declared and added layer ordering to the cascade. (issue #706)
Normally, rules in later layers are stronger, but in !important the order is reversed. revert-layer, layer‑aware @import, and manipulation of layer rules via the CSSOM are also included.
In PR #718, across the 81 tests—including the newly added Web Platform Tests—there were zero regressions, and the fixed fixtures compared with Firefox matched in both geometry and every pixel. (PR #718)
Prevent submission of invalid forms
The Constraint Validation API was not yet implemented on the form side.
As a result, even when a required input was empty, requestSubmit() would fire the submit event and proceed to navigation.
In Issue #691, we added the following APIs to input, textarea, select, and similar elements. (issue #691)
validityvalidationMessagewillValidatecheckValidity()reportValidity()setCustomValidity()
required evaluates the type, pattern, min, max, step, character count, and custom error, and also handles conditions such as disabled, readonly, fieldset, datalist, and other cases that are excluded from validation.
For an invalid control, a cancelable, non‑bubbling invalid event is fired, and if it is not handled, focus is moved to the first control. We also implemented the differences between novalidate and formnovalidate, as well as between submit() and requestSubmit().
In PR #719 (PR #719), all 15 newly added Web Platform Test cases passed, and there were no regressions in the overall test suite.
Parsing HTML fragments according to context
The implementation of innerHTML always wrapped the supplied HTML in a temporary body before passing it to the document parser.
For ordinary elements this works as expected, but within table, tbody, tr, and select the tree‑construction mode that starts is different. The namespace also changes for SVG and MathML.
Furthermore, Range.createContextualFragment() itself was not yet implemented.
In Issue #690 (issue #690) and Issue #720 (issue #720), we added an HTML fragment parser that starts from the context element and made it shareable between innerHTML and Range.createContextualFragment().
In addition to HTML, we also added a strict fragment parser for XML documents.
With this implementation in place, the question of when to execute scripts inside a fragment arises.
Scripts created with innerHTML remain inert, but inline scripts created with document.createElement("script") or Range.createContextualFragment() need to be executed when they are attached to the document.
In Issue #721 (issue #721), we track the state of scripts created via the API on a per‑native‑node basis and ensure they are executed only once within the realm of the target Document.
This prevents double execution during moves or reinsertions, and aligns the handling of type, CSP, sandbox, iframe realm, and exception logging with the existing script execution path.
In the consolidated PR #722 (PR #722), 28 HTML tree‑builder tests, 97 fixed Web Platform Tests, and a total of 2,482 tests passed.
Rendering ellipsis with text‑overflow
text-overflow was not parsed as a property; it simply clipped the string that overflowed the available width.
In Issue #697 (issue #697), we added the single‑value properties clip and ellipsis.
The ellipsis cannot be added simply by appending it to the end of the string. First, space must be reserved for the ellipsis itself, and the string must be truncated at a position that fits within the remaining width.
When doing this, the cut must not occur in the middle of a grapheme cluster; processing may span multiple inline fragments, and the side on which the ellipsis is placed is swapped between LTR and RTL layouts.
Layout, hit‑testing, and scroll geometry retain the original fragment, and only during painting do we assemble a separate visual line for the overflow.
In PR #723 (PR #723), we confirmed that the border boxes match Firefox across six test conditions and that the ellipsis appears on the correct side for both LTR and RTL layouts.
Characters appear smaller despite using the same font
Even after these fixes, when using the same TTF, the same font-size, and the same line-height, Firefox and Omoikane still showed differences in character outlines and baselines.
With a fixed 20 px font, Omoikane’s glyph ink measured 17 px in height and was positioned 10 px from the top of the line box, whereas Firefox’s glyph ink measured 19 px in height and was positioned 6 px from the top.
Since the element’s bounding rectangle and line position are correct, the issue lies not with layout but with the glyph rasterization step.
In Omoikane, during text shaping the CSS font size was mapped to the font’s unitsPerEm.
Conversely, when passing the size to the ab_glyph used for rasterization, the CSS pixel value was supplied directly as a scale factor. Because the scale of ab_glyph is based on ascent - descent, it does not match the scaling used during shaping, which is based on the em square.
In Issue #677 (issue #677), we converted the CSS em size to the full‑height scale of ab_glyph, aligning the scaling factors for shaping and rasterization.
Additionally, we attach the ascent and descent of the selected font face to the text fragment and use half‑leading to determine the baseline.
After the fix, the glyph ink measures 18 px in height and is positioned 7 px from the top of the line box, bringing it closer to Firefox’s 19 px height and 6 px offset.
The number of differing pixels dropped from 8,439 to 7,332, and the normalized mean absolute error decreased from about 0.0070 to about 0.0035.
The remaining difference of roughly one pixel stems from the fundamental differences between ab_glyph and the rasterizer used by Firefox. Rather than treating them as equivalent within a wide tolerance, we recorded separately which discrepancies are due to implementation bugs and which are inherent to the rasterizer differences.
The method of creating box‑shadows was different
Finally, the issue concerns box‑shadows and rounded corners.
Omoikane’s outer shadow first carves out the interior of the border box and then blurs that outline.
CSS’s outer shadow, however, blurs the entire opaque border box first and then clips the resulting shadow to the inside of the border edge. The order is reversed.
In addition, because the blur value from box-shadow was used as the radius for each of the three box‑blur passes, the shadow became wider than the specification’s sigma = blur / 2.
These two effects combined caused the shadow to be faint immediately outside the element and to spread excessively outward.
In Issue #676 (issue #676), we changed the process to first blur the entire opaque shape and then, at the end, exclude the interior of the rounded corners with coverage.
For small blurs we apply a direct Gaussian blur, while for larger blurs we use a three‑pass box approximation, which keeps the processing linear with respect to the number of pixels.
Rounded corners were also evaluated only as a binary inside/outside test centered on pixel boundaries, resulting in a stair‑stepped edge. We now use the same sub‑pixel coverage for solid colors, gradients, images, borders, and shadows.
With the original 8 px shadow, the differences from Firefox changed as follows.
| Metric | Before fix | After fix |
|---|---|---|
| Pixels with difference | 5,168 | 4,024 |
| Maximum channel difference | 74/255 | 5/255 |
| Normalized MAE | 0.0218553 | 0.00110876 |
Although the number of differing pixels is still relatively high, the maximum difference and the average error have become considerably smaller. The remaining discrepancy is mainly due to anti‑aliasing differences on the rounded‑corner edges.
We compared twelve conditions—including blur values of 0, 1, 2, 4, 8, and 16 px, positive and negative spread, offset, and rounded‑corner shadows—and found that the geometry of the elements matched Firefox in all cases.
Closed 15 issues
The issues closed this time are the following 15:
- #673 Multi‑line rendering of textarea
- #674 Inline backgrounds and DOMRect
- #675 Arabic font fallback and shaping
- #703 WouldBlock on macOS test HTTP server
- #709 Independent layout of inline‑block
- #687 CSS Font Loading API
- #708 CSSOM for @font-face
- #706 CSS @layer
- #691 Constraint Validation API
- #690 Range.createContextualFragment
- #720 HTML fragment parser
- #721 Dynamic inline script
- #697 text-overflow
- #677 Character rasterization and baseline
- #676 box-shadow and rounded‑corner borders
Each item is an independent feature, but in practice they are quite interrelated.
By comparing web fonts we discovered the height of inline-block, and once the geometry was aligned, the differences in character rasterization became visible. Fixing the inline rectangle also allowed us to correctly handle which fragments are retained for text-overflow.
Implementing the HTML fragment parser brings up a DOM‑side issue of when scripts inside the fragment should be executed.
Fixing one visible discrepancy revealed another discrepancy beneath it, and so on.
Summary
This time we advanced the HTML and CSS implementation of Omoikane as a whole.
We added the Font Loading API, the Constraint Validation API, Range.createContextualFragment(), @layer, text-overflow, and so on.
At the same time, we corrected inline elements, textarea, Arabic text, inline-block, character rasterization, box‑shadow, and rounded‑corner borders by comparing them with Firefox.
We not only have the APIs implemented, but we also run the actual Web Platform Tests and verify the DOM measurements and rendering results side‑by‑side with Firefox.
The rendering still does not match Firefox pixel‑for‑pixel. Differences remain, such as character anti‑aliasing, because a different rasterizer is used.
Nevertheless, we have progressed from merely parsing HTML and producing a plausible visual output to handling Web API state changes, live updates of the CSSOM, context‑dependent HTML parsing, and even sub‑pixel coverage in rendering.
We will continue to work in this manner, fixing each discrepancy as we encounter it.