Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 95 additions & 5 deletions test/wpt/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,16 +152,18 @@ unrelated failed unit probe does not block that comparison.

## Current adapter scope

* Standards-mode HTML, `html`/`body`/`div`/`span`/`section`, IDs/classes, inline
declarations, embedded and linked stylesheets. ASCII/degree text and all
* Standards-mode HTML, `html`/`body`/`div`/`span`/`section`/`strong`/`aside`/`article`,
the unknown elements `flexbox`/`grid`/`container`/`item`, IDs/classes, inline
declarations, embedded and linked stylesheets. ASCII, degree and ellipsis (U+2026) text and all
source whitespace are transported to the native layout engine. Comments do
not interrupt a contiguous CSS text sequence.
* 800 × 600 CSS pixels, DPR 1: the WPT viewport, not the physical board viewport.
Pixel storage and rasterization use the simulator's embedded RGB565 path.
* HTML body margins and root text color are installed at Gea's default-style
priority, below author styles. The display list paints onto a white viewport.
Paragraph display and `1em` block margins are registered as UA element rules
by the test bridge. The shared cascade keeps them below author rules,
Paragraph display and `1em` block margins, bold `strong`, and inline display
for the unknown elements (HTML makes them `HTMLUnknownElement`) are registered
as UA element rules by the test bridge. The shared cascade keeps them below author rules,
including universal selectors; the engine does not install HTML defaults.
Selector lists are split for the native one-selector-per-rule API. Simple
element/class selectors use their corresponding native registration APIs.
Expand All @@ -179,10 +181,15 @@ unrelated failed unit probe does not block that comparison.
a fresh tree and an initial layout/paint. This first version has no animation
or mutation lifecycle and does not exercise the TSX compiler/app loader.
* Scripts, further HTML defaults and display modes, text outside
the native ASCII/degree repertoire, XHTML, image assets,
the ASCII/degree/ellipsis repertoire, XHTML, image assets,
at-rules other than basic TrueType/WOFF1 `@font-face`, `!important`, quirks mode, compound HTML/body selectors and unsupported
HTML are explicit skips.
These are adapter limitations, not evidence that the engine cannot support them.
* `display` declarations are transported for `block`, `flex`, `grid`, `none`,
`flow-root`, `list-item`, `inline`, `inline-block`, `inline-flex` and
`inline-grid`; any other value is
a skip. Whatever the engine does not implement for these values (such as
list markers) shows up as a failure, not a skip.
* Apart from that disclosed color normalization, CSS properties and values are
sent unchanged to the engine. The imported corpus includes
interactions with features such as floats, flex-flow and order. A failure
Expand Down Expand Up @@ -386,6 +393,10 @@ The default serif font is pinned upstream Gentium Plus. Its WOFF1 wrapper is
decoded losslessly into SFNT tables, with bounds and checksums validated.
Independent tests compare every decoded table with its source and verify the
complete SFNT checksum. Pinned Ahem is also available as an installed test font.
Documents that name the generic `monospace` family also load DejaVu Sans Mono
2.35 from `test/wpt/fonts`, the rig's own font directory (upstream WPT pins no
monospace font). It is unmodified, pinned by SHA-256 in `fonts.mjs`, and
distributed under the Bitstream Vera license in `fonts/LICENSE-DejaVu.txt`.
Font variants, general font matching/shaping, paragraph defaults, additional
display modes and other character repertoires still need work.

Expand Down Expand Up @@ -2521,3 +2532,82 @@ The native text-coverage parity, text-background integration, first-line, and
transformed-rounded-rect suites pass. The adapter/font suite has 20 passing
checks. Work stops here at the user's request; remaining failures are preserved
for a later session.

## Ahem without antialiasing and right-to-left static positions (2026-10-21)

The bridge now asks the engine to rasterize Ahem without antialiasing, as
browser reftest harnesses do (Gecko's `gfx.font_rendering.ahem_antialias_none`).
Ahem's ascent is 0.8em, so at sizes that are not multiples of 5px its square
glyphs start between pixel rows: at 16px the antialiased glyph covered 80% of
its top row and 20% of the row below its line box, enough to show a red span
beneath it or tint an adjacent border. The engine still owns all metrics and
coverage; `generated::runtimeTtfFontAntialiased` (weak, true by default) only
thresholds the family's coverage at 50% when each glyph is rasterized. Other
fonts, including the Gentium Plus default whose `gasp` table also requests no
grayscale at 9-16ppem, keep antialiasing.

Three shared-engine corrections accompany it:

- A block-level absolute box placed after content on its line takes its static
position on the next line at its block's inline-start edge, the right edge in
a right-to-left block (CSS 2.2 10.3.7).
- An inline box whose content fits one line no longer applies `text-align`
inside itself; the line that holds it aligns it as a whole. A `direction: rtl`
span in a left-to-right line had moved its content across the line.
- A contentless inline box on a line (nothing in flow, nothing painted, no
authored size) takes its font's content area, so a positioned one gives its
absolute children the right containing block. It still adds no height to its
line, and on a zero-height line (CSS 2.2 9.4.2) it stays at the line's top. As
a flex or grid item it is blockified and stays empty.

The complete forced run moves from **869 PASS, 231 FAIL** to **874 PASS, 226 FAIL**
with no regressions: `htb-ltr-ltr`, `htb-ltr-rtl.tentative`,
`htb-rtl-ltr.tentative`, `htb-rtl-rtl` and
`nested-inline-abspos-child-with-siblings` pass. Pixel counts change in 14
failing vertical writing-mode cases and two forced line-clamp cases (seams
removed); their statuses do not. The 24 vertical `css-position` failures
need vertical inline layout, writing-mode-aware logical properties and vertical
multicolumn fragmentation, which the engine does not implement.

## Vertical writing-mode flows and flow-relative properties (2026-10-21)

The engine now lays out vertical inline content. A `vertical-lr` or
`vertical-rl` block container with inline content or columns, whose subtree
holds only elements and text in that one writing mode, becomes a vertical flow.
Its subtree is rotated into the logical frame (boxes, sizes, sides, insets and
auto margins), laid out by the horizontal block, inline and multicolumn code,
and transposed back. Its text draws as columns of glyphs turned clockwise.
Absolute boxes whose containing block lies in the flow are placed in the logical
frame. Columns stack across the block axis. Flex and grid containers, line
clamps, replaced content and mixed writing modes keep the previous path.

Flow-relative properties in stylesheet rules (`inset-inline-start`,
`margin-block-end`, `inline-size`, ...) now map by each element's own writing
mode and direction ([CSS Logical 4](https://drafts.csswg.org/css-logical-1/#box)),
not always as horizontal-tb ltr. Inline styles and the compiler's static
mapping still use horizontal-tb ltr.

Horizontal corrections the same tests needed:

- An absolute box's static position moves with a relatively positioned inline
ancestor.
- A split inline's containing block rectangle covers its child inlines'
fragments, and in right-to-left text uses the block's direction.
- Collapsible spaces that end a line hang past it instead of wrapping the run
before them.
- A word that continues across inline items is not split between lines.
- `text-align: start` and `end` resolve with the block's direction.
- An orthogonal inline-block takes a synthesized baseline.
- An absolute vertical box with both vertical insets lays out its lines at that
height.
- Bitmap-font text that wraps takes the bottom of its first line box as its
baseline, not its last, so the items beside its first line stay on it.

A relayout scoped to a subtree inside a vertical flow starts from the flow's
root, the only place the flow can be laid out from.

The complete forced run moves from **874 PASS, 226 FAIL** to **900 PASS, 200 FAIL**
with no regressions. All 24 vertical `css-position` tests pass, as do
`align-self-static-position-004` and `margin-trim/flex-column-orthogonal-item`.
Vertical text draws no decorations, emphasis marks or ellipses. There is no bidi
reordering, and margins do not collapse through a vertical flow root.
100 changes: 64 additions & 36 deletions test/wpt/adapter.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { parse } from 'parse5';
import postcss from 'postcss';
import valueParser from 'postcss-value-parser';
import { normalizeNamedColors } from './named-colors.mjs';
import { defaultFont, ahemFont } from './fonts.mjs';
import { defaultFont, ahemFont, monospaceFont } from './fonts.mjs';

export class Unsupported extends Error {}

Expand Down Expand Up @@ -48,24 +48,46 @@ export function localReference(from, href) {

// This transport adapter normalizes named colors to equivalent RGB hex values.
// Layout decisions and style resolution remain in Gea; upstream files stay intact.
export function parseDocument(source, name, { readResource } = {}) {
if (!name.endsWith('.html')) throw new Unsupported('Only HTML documents are supported; XHTML needs an XML adapter');
// `force` renders past every guard instead of skipping: each guard records its
// reason in `forced`, and the construct is transported as-is, or dropped when it
// cannot be (an unloadable font or stylesheet, an at-rule, an attribute). A forced
// result is not conformance evidence: test and reference can share the defect.
export function parseDocument(source, name, { readResource, force = false } = {}) {
const forced = [];
function unsupported(reason) {
if (!force) throw new Unsupported(reason);
if (!forced.includes(reason)) forced.push(reason);
}
// Drops one construct in force mode when a guard inside it throws.
function guarded(transport) {
try {
transport();
} catch (error) {
if (!force || !(error instanceof Unsupported)) throw error;
unsupported(error.message);
}
}
if (!name.endsWith('.html')) unsupported('Only HTML documents are supported; XHTML needs an XML adapter');
const dom = parse(source);
if (dom.mode !== 'no-quirks') throw new Unsupported('Quirks-mode document');
if (dom.mode !== 'no-quirks') unsupported('Quirks-mode document');
const rules = [], references = [], specs = [], normalizations = [], fonts = [], properties = new Set();
let title = name, fuzzy = null, count = 0;
let title = name, fuzzy = null, count = 0, monospace = false;
function declarations(nodes) {
return nodes.filter(n => n.type !== 'comment').map(n => {
if (n.type !== 'decl') throw new Unsupported('Nested CSS or at-rule');
if (/url\s*\(/i.test(n.value)) throw new Unsupported('CSS resource/font loading');
if (n.important) throw new Unsupported('!important needs priority-preserving declaration transport');
if (n.prop.toLowerCase() === 'display' && !/^(block|flex|grid|none)$/i.test(n.value)) throw new Unsupported('Only block/flex/grid/none display declarations are supported by this adapter');
if (n.prop.toLowerCase() === 'content') throw new Unsupported('Generated text content');
if (/^(animation|transition)(-|$)/i.test(n.prop)) throw new Unsupported('Animation/transition timing is not implemented by this static adapter');
return nodes.filter(n => n.type !== 'comment').flatMap(n => {
if (n.type !== 'decl') {
unsupported('Nested CSS or at-rule');
return [];
}
if (/url\s*\(/i.test(n.value)) unsupported('CSS resource/font loading');
if (n.important) unsupported('!important needs priority-preserving declaration transport');
if (n.prop.toLowerCase() === 'display' && !/^(block|flex|grid|none|flow-root|list-item|inline|inline-block|inline-flex|inline-grid)$/i.test(n.value)) unsupported('Only block/flex/grid/none/flow-root/list-item/inline/inline-block/inline-flex/inline-grid display declarations are supported by this adapter');
if (n.prop.toLowerCase() === 'content') unsupported('Generated text content');
if (/^(animation|transition)(-|$)/i.test(n.prop)) unsupported('Animation/transition timing is not implemented by this static adapter');
properties.add(n.prop);
if (/^font(-family)?$/i.test(n.prop) && /\bmonospace\b/i.test(n.value)) monospace = true;
const value = normalizeNamedColors(n.prop, n.value);
if (value !== n.value) normalizations.push({ property: n.prop, from: n.value, to: value });
return [n.prop, value];
return [[n.prop, value]];
});
}
function fontFace(node, from) {
Expand Down Expand Up @@ -105,67 +127,72 @@ export function parseDocument(source, name, { readResource } = {}) {
function stylesheet(text, from = name) {
for (const n of postcss.parse(text).nodes) {
if (n.type === 'comment') continue;
if (n.type === 'atrule' && n.name.toLowerCase() === 'font-face') { fontFace(n, from); continue; }
if (n.type !== 'rule') throw new Unsupported(`CSS ${n.type}: ${n.name || ''}`);
if (n.type === 'atrule' && n.name.toLowerCase() === 'font-face') { guarded(() => fontFace(n, from)); continue; }
if (n.type !== 'rule') {
unsupported(`CSS ${n.type}: ${n.name || ''}`);
continue;
}
const values = declarations(n.nodes);
for (const selector of postcss.list.comma(n.selector)) {
if (!/^(html|body)$/i.test(selector) && /(?:^|[\s>+~])(?:html|body)(?=[\s.#:[>+~]|$)/i.test(selector)) throw new Unsupported('Compound HTML/body selectors use native application-root aliases');
if (!/^(html|body)$/i.test(selector) && /(?:^|[\s>+~])(?:html|body)(?=[\s.#:[>+~]|$)/i.test(selector)) unsupported('Compound HTML/body selectors use native application-root aliases');
rules.push({ selector, declarations: values });
}
}
}
function inspect(n) {
const a = Object.fromEntries((n.attrs || []).map(a => [a.name, a.value]));
if (n.tagName === 'script') throw new Unsupported('JavaScript/testharness document');
if (n.tagName === 'base') throw new Unsupported('HTML base URL');
if (n.tagName === 'script') unsupported('JavaScript/testharness document');
if (n.tagName === 'base') unsupported('HTML base URL');
if (n.tagName === 'style') {
if (a.media && a.media !== 'all') throw new Unsupported('Style media condition');
if (a.type && a.type.toLowerCase() !== 'text/css') throw new Unsupported(`Style MIME type ${a.type}`);
if (a.media && a.media !== 'all') unsupported('Style media condition');
if (a.type && a.type.toLowerCase() !== 'text/css') unsupported(`Style MIME type ${a.type}`);
stylesheet(n.childNodes.map(n => n.value || '').join(''));
}
if (n.tagName === 'title') title = n.childNodes.map(n => n.value || '').join('').trim();
if (n.tagName === 'link') {
if (['match', 'mismatch'].includes(a.rel)) references.push({ relation: a.rel, path: localReference(name, a.href) });
if (a.rel === 'help') specs.push(a.href);
if (a.rel === 'stylesheet') {
if (a.rel === 'stylesheet') guarded(() => {
if (!readResource) throw new Unsupported('External stylesheet loading');
if (a.media && a.media !== 'all') throw new Unsupported('Stylesheet media condition');
if (a.type && a.type.toLowerCase() !== 'text/css') throw new Unsupported(`Stylesheet MIME type ${a.type}`);
if (a.media && a.media !== 'all') unsupported('Stylesheet media condition');
if (a.type && a.type.toLowerCase() !== 'text/css') unsupported(`Stylesheet MIME type ${a.type}`);
if (Object.hasOwn(a, 'disabled')) throw new Unsupported('Disabled stylesheet');
// Resolve from the document URL and apply in document order. The caller
// supplies only hash-verified vendored resources; no network during runs.
const source = localReference(name, a.href);
stylesheet(readResource(source), source);
}
});
}
if (n.tagName === 'meta' && a.name === 'fuzzy') {
if (fuzzy !== null) throw new Unsupported('Multiple/per-reference fuzzy metadata');
fuzzy = parseFuzzy(a.content);
if (fuzzy !== null) unsupported('Multiple/per-reference fuzzy metadata');
else guarded(() => { fuzzy = parseFuzzy(a.content); });
}
if (n.tagName === 'meta' && ['viewport', 'flags'].includes(a.name) && a.content) throw new Unsupported(`Test metadata: ${a.name}=${a.content}`);
if (n.tagName === 'meta' && ['viewport', 'flags'].includes(a.name) && a.content) unsupported(`Test metadata: ${a.name}=${a.content}`);
for (const [key, value] of Object.entries(a)) {
if (key.startsWith('on')) throw new Unsupported(`Event handler ${key}`);
if (key === 'class' && /(?:^|\s)(?:reftest-wait|test-wait)(?:\s|$)/.test(value)) throw new Unsupported('Script-controlled screenshot readiness');
if (key.startsWith('on')) unsupported(`Event handler ${key}`);
if (key === 'class' && /(?:^|\s)(?:reftest-wait|test-wait)(?:\s|$)/.test(value)) unsupported('Script-controlled screenshot readiness');
}
for (const child of n.childNodes || []) inspect(child);
}
inspect(dom);
function element(n) {
if (n.nodeName === '#comment') return null;
if (n.nodeName === '#text') {
if (/[^\t\r\n\f\x20-\x7e\u00b0]/u.test(n.value)) throw new Unsupported('Text codepoint outside native runtime font repertoire');
if (++count > 450) throw new Unsupported('Document exceeds adapter node limit');
if (/[^\t\r\n\f\x20-\x7e\u00b0\u2026]/u.test(n.value)) unsupported('Text codepoint outside native runtime font repertoire');
if (++count > 450) unsupported('Document exceeds adapter node limit');
return { tag: '#text', text: n.value, attributes: [], inline: [], children: [] };
}
if (n.tagName === 'head') return null;
if (!['html', 'body', 'div', 'span', 'section', 'p', 'br'].includes(n.tagName)) throw new Unsupported(`HTML element <${n.tagName}>`);
if (n.namespaceURI !== 'http://www.w3.org/1999/xhtml') throw new Unsupported('Non-HTML namespace');
if (++count > 450) throw new Unsupported('Document exceeds adapter node limit');
// These never generate boxes; inspect() already applied what they carry.
if (force && ['script', 'style', 'template', 'title', 'meta', 'link', 'base', 'noscript'].includes(n.tagName)) return null;
if (!['html', 'body', 'div', 'span', 'section', 'p', 'br', 'strong', 'aside', 'article', 'flexbox', 'grid', 'container', 'item'].includes(n.tagName)) unsupported(`HTML element <${n.tagName}>`);
if (n.namespaceURI !== 'http://www.w3.org/1999/xhtml') unsupported('Non-HTML namespace');
if (++count > 450) unsupported('Document exceeds adapter node limit');
const attributes = [], inline = [];
for (const a of n.attrs || []) {
if (a.name === 'style') inline.push(...declarations(postcss.parse(a.value).nodes));
else if (['id', 'class'].includes(a.name) || a.name.startsWith('data-')) attributes.push([a.name, a.value]);
else if (a.name !== 'xmlns') throw new Unsupported(`HTML attribute ${a.name}`);
else if (a.name !== 'xmlns') unsupported(`HTML attribute ${a.name}`);
}
const children = [];
for (const child of (n.childNodes || []).map(element).filter(Boolean)) {
Expand All @@ -179,7 +206,8 @@ export function parseDocument(source, name, { readResource } = {}) {
const tree = element(dom.childNodes.find(n => n.tagName === 'html'));
if (!fonts.some(font => font.family.toLowerCase() === defaultFont.family)) fonts.unshift(defaultFont);
if (!fonts.some(font => font.family.toLowerCase() === 'ahem')) fonts.push(ahemFont);
return { title, tree, rules, fonts, references, specs, fuzzy, normalizations, properties: [...properties].sort() };
if (monospace && !fonts.some(font => font.family.toLowerCase() === 'monospace')) fonts.push(monospaceFont);
return { title, tree, rules, fonts, references, specs, fuzzy, normalizations, properties: [...properties].sort(), forced };
}

export function parseFuzzy(value) {
Expand Down
Loading
Loading