diff --git a/test/wpt/README.md b/test/wpt/README.md index a0db61d..2d40468 100644 --- a/test/wpt/README.md +++ b/test/wpt/README.md @@ -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. @@ -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 @@ -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. @@ -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. diff --git a/test/wpt/adapter.mjs b/test/wpt/adapter.mjs index 7fe26b8..8dc2876 100644 --- a/test/wpt/adapter.mjs +++ b/test/wpt/adapter.mjs @@ -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 {} @@ -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) { @@ -105,47 +127,50 @@ 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); } @@ -153,19 +178,21 @@ export function parseDocument(source, name, { readResource } = {}) { 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)) { @@ -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) { diff --git a/test/wpt/adapter.test.mjs b/test/wpt/adapter.test.mjs index 54bcfb5..d8e6680 100644 --- a/test/wpt/adapter.test.mjs +++ b/test/wpt/adapter.test.mjs @@ -60,7 +60,7 @@ test('passes original selectors, ordered duplicate declarations and inline value }); test('fails closed on unsupported document semantics instead of weakening the test', () => { - for (const body of ['', '
', '', '', '', '', '', '', '']) { + for (const body of ['', '
', '', '', '', '', '', '', '']) { assert.throws(() => parseDocument(`${body}`, 'test.html'), Unsupported, body); } assert.throws(() => parseDocument('
', 'test.html'), /Quirks/); diff --git a/test/wpt/bridge.cpp b/test/wpt/bridge.cpp index 7fa9161..44c350d 100644 --- a/test/wpt/bridge.cpp +++ b/test/wpt/bridge.cpp @@ -41,6 +41,12 @@ const std::uint8_t *lookupRuntimeTtfFontForFamily(int id, unsigned long *length) if (length) *length = bytes.size(); return bytes.data(); } +// Browser reftest harnesses draw Ahem without antialiasing (Gecko's +// gfx.font_rendering.ahem_antialias_none), so its 0.8em ascent leaves no +// partial rows at sizes such as 16px. +bool runtimeTtfFontAntialiased(int id) { + return id < 0 || static_cast(id) >= testFonts.size() || testFonts[id].family != "ahem"; +} } namespace gea::framework::app::generated { void drainMicrotasks() {} } using namespace gea::embedded::ui; @@ -55,6 +61,11 @@ EMSCRIPTEN_KEEPALIVE int wpt_init(int width, int height) { sheet.registerUserAgentElementRule("p", "display", "block"); sheet.registerUserAgentElementRule("p", "margin-top", "1em"); sheet.registerUserAgentElementRule("p", "margin-bottom", "1em"); + sheet.registerUserAgentElementRule("aside", "display", "block"); + sheet.registerUserAgentElementRule("article", "display", "block"); + sheet.registerUserAgentElementRule("strong", "font-weight", "bold"); + // Elements HTML does not define are HTMLUnknownElement: inline unless styled. + for (const char *tag : {"flexbox", "grid", "container", "item"}) sheet.registerUserAgentElementRule(tag, "display", "inline"); return 1; } EMSCRIPTEN_KEEPALIVE int wpt_font(const char *family, const std::uint8_t *bytes, int length) { diff --git a/test/wpt/build.mjs b/test/wpt/build.mjs index a6d83af..fc53831 100644 --- a/test/wpt/build.mjs +++ b/test/wpt/build.mjs @@ -16,7 +16,10 @@ for (const [key, pkg] of Object.entries({ GEA_CORE: 'core', GEA_HOST_DIR: 'host' } const manifest = await import(pathToFileURL(path.join(env.GEA_CORE, 'gea_sources.mjs'))); const includes = [...manifest.includeFlags(env), `-I${root}/targets/web/include`]; -const sources = [...manifest.cSources(env), ...manifest.cxxSources(env), +// audio_buffer_runtime.cpp defines compiler-declared AudioBuffer methods against +// an app's generated gea_runtime.h. The rig compiles no app and calls none of them. +const appSensitive = s => !env.GEA_COMPILER_RUNTIME && path.basename(s) === 'audio_buffer_runtime.cpp'; +const sources = [...manifest.cSources(env), ...manifest.cxxSources(env).filter(s => !appSensitive(s)), ...['web_display', 'web_platform', 'web_power', 'web_storage_service', 'web_camera'].map(n => `${root}/targets/web/main/${n}.cpp`), `${root}/test/wpt/bridge.cpp`]; for (const source of sources) fs.accessSync(source); @@ -43,5 +46,5 @@ for (let i = 0; i < sources.length; i++) { } fs.writeFileSync(stamp, identity); run(compiler, ['-O1', ...objects, '--no-entry', '-sALLOW_MEMORY_GROWTH=1', '-sMODULARIZE=1', '-sEXPORT_ES6=1', '-sENVIRONMENT=node', '-sEXPORTED_FUNCTIONS=["_malloc","_free"]', '-sEXPORTED_RUNTIME_METHODS=["ccall","HEAPU8"]', '-o', `${out}/wpt-renderer.mjs`]); -fs.writeFileSync(`${out}/wpt-build.json`, JSON.stringify({ packages: Object.fromEntries(Object.entries(env).filter(([k]) => ['GEA_CORE', 'GEA_HOST_DIR', 'GEA_ENGINE_DIR', 'GEA_ELEMENTS_DIR', 'GEA_GEAOS_PACKAGE_DIR'].includes(k))), compiler: run(compiler, ['--version'], true), flags, profile: 'simulator RGB565, CSS DPR 1, static HTML subset with pinned fonts and ASCII/degree text' }, null, 2)); +fs.writeFileSync(`${out}/wpt-build.json`, JSON.stringify({ packages: Object.fromEntries(Object.entries(env).filter(([k]) => ['GEA_CORE', 'GEA_HOST_DIR', 'GEA_ENGINE_DIR', 'GEA_ELEMENTS_DIR', 'GEA_GEAOS_PACKAGE_DIR'].includes(k))), compiler: run(compiler, ['--version'], true), flags, profile: 'simulator RGB565, CSS DPR 1, static HTML subset with pinned fonts and ASCII/degree/ellipsis text' }, null, 2)); console.log('Built dist/wpt-renderer.mjs using simulator display and shared framework sources.'); diff --git a/test/wpt/fonts.mjs b/test/wpt/fonts.mjs index 1fcd3da..46077b2 100644 --- a/test/wpt/fonts.mjs +++ b/test/wpt/fonts.mjs @@ -3,6 +3,13 @@ import { inflateSync } from 'node:zlib'; export const defaultFont = { family: 'serif', path: 'fonts/GentiumPlus-R.woff' }; export const ahemFont = { family: 'Ahem', path: 'fonts/Ahem.ttf' }; +// Rig-owned fonts live in test/wpt/fonts, outside the pinned upstream corpus, +// each pinned by its SHA-256. The CSS monospace family is DejaVu Sans Mono 2.35, +// unmodified, under the Bitstream Vera license in fonts/LICENSE-DejaVu.txt. +export const monospaceFont = { + family: 'monospace', path: 'DejaVuSansMono.ttf', rig: true, + sha256: '602ec86b8948cfcd956482fe64f94c36c867770149ef2f791d4613f443bcecb3', +}; const padded = n => Math.ceil(n / 4) * 4; function checksum(bytes) { let sum = 0; diff --git a/test/wpt/fonts/DejaVuSansMono.ttf b/test/wpt/fonts/DejaVuSansMono.ttf new file mode 100644 index 0000000..278cd78 Binary files /dev/null and b/test/wpt/fonts/DejaVuSansMono.ttf differ diff --git a/test/wpt/fonts/LICENSE-DejaVu.txt b/test/wpt/fonts/LICENSE-DejaVu.txt new file mode 100644 index 0000000..254e2cc --- /dev/null +++ b/test/wpt/fonts/LICENSE-DejaVu.txt @@ -0,0 +1,99 @@ +Fonts are (c) Bitstream (see below). DejaVu changes are in public domain. +Glyphs imported from Arev fonts are (c) Tavmjong Bah (see below) + +Bitstream Vera Fonts Copyright +------------------------------ + +Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is +a trademark of Bitstream, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of the fonts accompanying this license ("Fonts") and associated +documentation files (the "Font Software"), to reproduce and distribute the +Font Software, including without limitation the rights to use, copy, merge, +publish, distribute, and/or sell copies of the Font Software, and to permit +persons to whom the Font Software is furnished to do so, subject to the +following conditions: + +The above copyright and trademark notices and this permission notice shall +be included in all copies of one or more of the Font Software typefaces. + +The Font Software may be modified, altered, or added to, and in particular +the designs of glyphs or characters in the Fonts may be modified and +additional glyphs or characters may be added to the Fonts, only if the fonts +are renamed to names not containing either the words "Bitstream" or the word +"Vera". + +This License becomes null and void to the extent applicable to Fonts or Font +Software that has been modified and is distributed under the "Bitstream +Vera" names. + +The Font Software may be sold as part of a larger software package but no +copy of one or more of the Font Software typefaces may be sold by itself. + +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, +TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME +FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING +ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF +THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE +FONT SOFTWARE. + +Except as contained in this notice, the names of Gnome, the Gnome +Foundation, and Bitstream Inc., shall not be used in advertising or +otherwise to promote the sale, use or other dealings in this Font Software +without prior written authorization from the Gnome Foundation or Bitstream +Inc., respectively. For further information, contact: fonts at gnome dot +org. + +Arev Fonts Copyright +------------------------------ + +Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of the fonts accompanying this license ("Fonts") and +associated documentation files (the "Font Software"), to reproduce +and distribute the modifications to the Bitstream Vera Font Software, +including without limitation the rights to use, copy, merge, publish, +distribute, and/or sell copies of the Font Software, and to permit +persons to whom the Font Software is furnished to do so, subject to +the following conditions: + +The above copyright and trademark notices and this permission notice +shall be included in all copies of one or more of the Font Software +typefaces. + +The Font Software may be modified, altered, or added to, and in +particular the designs of glyphs or characters in the Fonts may be +modified and additional glyphs or characters may be added to the +Fonts, only if the fonts are renamed to names not containing either +the words "Tavmjong Bah" or the word "Arev". + +This License becomes null and void to the extent applicable to Fonts +or Font Software that has been modified and is distributed under the +"Tavmjong Bah Arev" names. + +The Font Software may be sold as part of a larger software package but +no copy of one or more of the Font Software typefaces may be sold by +itself. + +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL +TAVMJONG BAH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + +Except as contained in this notice, the name of Tavmjong Bah shall not +be used in advertising or otherwise to promote the sale, use or other +dealings in this Font Software without prior written authorization +from Tavmjong Bah. For further information, contact: tavmjong @ free +. fr. + +$Id: LICENSE 2133 2007-11-28 02:46:28Z lechimp $ diff --git a/test/wpt/named-colors.mjs b/test/wpt/named-colors.mjs index 8806433..acc07ef 100644 --- a/test/wpt/named-colors.mjs +++ b/test/wpt/named-colors.mjs @@ -159,7 +159,7 @@ const colorProperties = new Set([ 'color', 'background', 'background-color', 'background-image', 'box-shadow', 'text-shadow', 'outline', 'outline-color', 'text-decoration', 'text-decoration-color', 'column-rule', 'column-rule-color', - 'fill', 'stroke', 'caret-color', 'accent-color', + 'fill', 'stroke', 'caret-color', 'accent-color', 'border-image', 'border-image-source', ]); // Only declaration values in color-bearing properties are eligible. Tokenizing diff --git a/test/wpt/run.mjs b/test/wpt/run.mjs index 6d52160..2c1e1ef 100644 --- a/test/wpt/run.mjs +++ b/test/wpt/run.mjs @@ -37,16 +37,16 @@ const root = fileURLToPath(new URL('./', import.meta.url)); const out = fileURLToPath(new URL('../../dist/', import.meta.url)); const manifest = JSON.parse(fs.readFileSync(`${root}/manifest.json`, 'utf8')); const { values, positionals } = parseArgs({ - options: { limit: { type: 'string', default: 'all' } }, allowPositionals: true, + options: { limit: { type: 'string', default: 'all' }, force: { type: 'boolean', default: false } }, allowPositionals: true, }); -if (positionals.length > 1) throw new Error('Usage: wpt:run [filename substring] [--limit N|all] (default: all)'); +if (positionals.length > 1) throw new Error('Usage: wpt:run [filename substring] [--limit N|all] [--force] (default: all)'); if (values.limit !== 'all' && !/^[1-9]\d*$/.test(values.limit)) throw new Error('--limit must be a positive integer or all'); const limit = values.limit === 'all' ? manifest.tests.length : Number(values.limit); if (!Number.isSafeInteger(limit)) throw new Error('--limit is too large'); const filter = positionals[0]; const selected = manifest.tests.filter(name => name.startsWith('css/') && (!filter || name.includes(filter))).slice(0, limit); if (!selected.length) throw new Error(`No CSS tests match ${filter || 'the selection'}`); -const scope = { selected: selected.length, available: manifest.tests.filter(name => name.startsWith('css/')).length, limit: values.limit, filter: filter || null }; +const scope = { selected: selected.length, available: manifest.tests.filter(name => name.startsWith('css/')).length, limit: values.limit, filter: filter || null, force: values.force }; const build = JSON.parse(fs.readFileSync(`${out}/wpt-build.json`, 'utf8')); function readBytes(name) { if (!Object.hasOwn(manifest.files, name)) throw new Error(`Unpinned resource: ${name}`); @@ -55,11 +55,17 @@ function readBytes(name) { return bytes; } function read(name) { return readBytes(name).toString('utf8'); } +function fontBytes(font) { + if (!font.rig) return readBytes(font.path); + const bytes = fs.readFileSync(path.join(root, 'fonts', font.path)); + if (createHash('sha256').update(bytes).digest('hex') !== font.sha256) throw new Error(`Rig font modified: ${font.path}`); + return bytes; +} // Check every imported source before running anything; missing or altered // fixtures are infrastructure errors, never skips or newly accepted baselines. for (const name of Object.keys(manifest.files)) readBytes(name); function render(document, actions = []) { - const fonts = (document.fonts || []).map(font => ({ family: font.family, bytes: sfntFont(readBytes(font.path)).toString('base64') })); + const fonts = (document.fonts || []).map(font => ({ family: font.family, bytes: sfntFont(fontBytes(font)).toString('base64') })); const r = spawnSync(process.execPath, [`${root}/render.mjs`], { input: JSON.stringify({ document, viewport: manifest.viewport, fonts, interactions: actions }), encoding: 'utf8', timeout: 30000, maxBuffer: 16 * 1024 * 1024, @@ -361,10 +367,12 @@ const results = []; for (const name of selected) { const result = { test: name, status: 'ERROR' }; try { - const doc = parseDocument(read(name), name, { readResource: read }); + const doc = parseDocument(read(name), name, { readResource: read, force: values.force }); if (!doc.references.length) throw new Unsupported('Not a reftest'); // Preflight all references before executing either side. - const references = doc.references.map(ref => ({ ...ref, document: parseDocument(read(ref.path), ref.path, { readResource: read }) })); + const references = doc.references.map(ref => ({ ...ref, document: parseDocument(read(ref.path), ref.path, { readResource: read, force: values.force }) })); + const forced = [...new Set([doc, ...references.map(ref => ref.document)].flatMap(d => d.forced))]; + if (forced.length) result.forced = forced; const failures = failedPrerequisites(prerequisites, [doc, ...references.map(ref => ref.document)]); Object.assign(result, { title: doc.title, specs: doc.specs, properties: doc.properties, normalizations: doc.normalizations, fonts: doc.fonts }); const actual = render(doc, interactions[name]); @@ -394,11 +402,14 @@ for (const name of selected) { result.reason = error.message; } results.push(result); - console.log(`${result.status} ${name}${result.reason ? ` — ${result.reason}` : ''}${result.comparisons ? ` — ${result.comparisons.map(c => `${c.totalPixels} differing pixels`).join(', ')}` : ''}`); + console.log(`${result.status}${result.forced ? ' (forced)' : ''} ${name}${result.reason ? ` — ${result.reason}` : ''}${result.comparisons ? ` — ${result.comparisons.map(c => `${c.totalPixels} differing pixels`).join(', ')}` : ''}`); } -const summary = Object.fromEntries(['PASS', 'FAIL', 'BLOCKED', 'SKIP', 'ERROR'].map(s => [s, results.filter(r => r.status === s).length])); +const statuses = ['PASS', 'FAIL', 'BLOCKED', 'SKIP', 'ERROR']; +const summary = Object.fromEntries(statuses.map(s => [s, results.filter(r => r.status === s).length])); +// Statuses of the results that ran past an adapter guard; already counted in summary. +const forcedSummary = values.force ? Object.fromEntries(statuses.map(s => [s, results.filter(r => r.forced && r.status === s).length])) : null; const preprocessing = { namedColors: 'CSS Color 4 named colors to RGB hex in the test adapter; runtime keyword parsing is not tested' }; -const report = { revision: manifest.revision, scope, viewport: manifest.viewport, build, preprocessing, prerequisites, summary, results }; +const report = { revision: manifest.revision, scope, viewport: manifest.viewport, build, preprocessing, prerequisites, summary, forcedSummary, results }; fs.writeFileSync(`${out}/wpt-results.json`, JSON.stringify(report, null, 2) + '\n'); const escape = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); const upstream = name => `https://github.com/web-platform-tests/wpt/blob/${manifest.revision}/${name}`; @@ -406,11 +417,12 @@ fs.writeFileSync(`${out}/wpt-report.html`, `body{font:16px system-ui;margin:32px;color:#17202a;background:#f5f6f8}h1{margin-bottom:8px}article{background:white;padding:20px;margin:20px 0;border:1px solid #ccd2d8}section{display:flex;gap:12px;overflow:auto}figure{margin:0;min-width:260px;flex:1}img{width:100%;image-rendering:pixelated;border:1px solid #ddd}a{color:#154d91}code{overflow-wrap:anywhere}.FAIL,.ERROR{color:#b12020}.PASS{color:#17602e}.SKIP,.BLOCKED{color:#795300}

Gea · WPT CSS reftests

Simulator RGB565 · ${manifest.viewport.width} × ${manifest.viewport.height} CSS pixels · DPR 1

${scope.selected} selected CSS tests out of ${scope.available} imported. Selection follows corpus order, before rendering; skipped tests count toward the selection.${filter ? ` Filename filter: ${escape(filter)}.` : ''}

-

Upstream revision ${manifest.revision}

${escape(JSON.stringify(summary))}

+

Upstream revision ${manifest.revision}

${escape(JSON.stringify(summary))}

${forcedSummary ? `

Forced run: adapter guards were recorded instead of skipping. Results that ran past a guard: ${escape(JSON.stringify(forcedSummary))}. A forced PASS is not conformance evidence.

` : ''}

Color pre-pass: standard named colors are converted to equivalent RGB hex values in the test adapter. Upstream files and runtime are unchanged. Results do not test runtime color-name parsing; the JSON report records every converted declaration.

HTML subset with explicit pointer actions for registered manual tests and pinned fonts and ASCII/degree text. Default serif uses upstream Gentium Plus. Basic paragraph defaults and block margin collapsing are exercised; font variants, other scripts, and further HTML/display modes remain unfinished. Both test and reference use Gea. These are subset results, not a claim of full CSS conformance. Magenta marks differing pixels. Machine-readable report and build provenance

CSS prerequisites

These independent probes prevent shared test/reference bugs from producing false passes. The Ahem checks exercise native fonts, inheritance, shorthand, whitespace and font-relative sizing. A failed scoped probe blocks matching comparisons only when the test or a reference uses that CSS unit, property, pseudo-element, or HTML element; unequal images still fail. Verified transport control (hexadecimal red)

${prerequisites.map(p => `

${p.status} · ${p.name}${p.cssUnit ? ` · applies to ${escape(p.cssUnit)} dependencies` : ''}${p.cssPseudo ? ` · applies to ${p.cssPseudo === 'hover' ? ':' : '::'}${escape(p.cssPseudo)} dependencies` : ''}${p.cssProperty ? ` · applies to ${escape(p.cssProperty)} declarations` : ''}${p.htmlTag ? ` · applies to <${escape(p.htmlTag)}> dependencies` : ''} · expected ${escape(JSON.stringify(p.expected))}, actual ${escape(JSON.stringify(p.actual))}

`).join('')} -${results.map(r => `

${r.status} · ${escape(path.basename(r.test))}

${r.reason ? `

${escape(r.reason)}

` : ''}${r.interactions ? `

Automated interactions: ${escape(JSON.stringify(r.interactions))}

` : ''}${r.geometry ? `

Actual node geometry

` : ''}${(r.comparisons || []).map(c => `

${escape(c.relation)} · ${c.totalPixels} differing pixels · maximum channel difference ${c.maxDifference} · Upstream reference · Reference geometry

${[['Actual', r.actual], ['Reference', c.reference], ['Difference', c.difference]].map(([label, src]) => `
${label}
${label}
`).join('')}
`).join('')}
`).join('')}`); +${results.map(r => `

${r.status} · ${escape(path.basename(r.test))}

${r.reason ? `

${escape(r.reason)}

` : ''}${r.forced ? `

Forced past: ${escape(r.forced.join('; '))}

` : ''}${r.interactions ? `

Automated interactions: ${escape(JSON.stringify(r.interactions))}

` : ''}${r.geometry ? `

Actual node geometry

` : ''}${(r.comparisons || []).map(c => `

${escape(c.relation)} · ${c.totalPixels} differing pixels · maximum channel difference ${c.maxDifference} · Upstream reference · Reference geometry

${[['Actual', r.actual], ['Reference', c.reference], ['Difference', c.difference]].map(([label, src]) => `
${label}
${label}
`).join('')}
`).join('')}
`).join('')}`); console.log(JSON.stringify(summary)); +if (forcedSummary) console.log(`forced ${JSON.stringify(forcedSummary)}`); console.log(`${out}/wpt-report.html`); process.exitCode = summary.ERROR ? 2 : summary.FAIL || summary.BLOCKED || invalidReferenceFoundation || !summary.PASS ? 1 : 0;