@oim-media/spec 1.27.8 → 1.27.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/BINARY_SPEC.md +190 -186
  2. package/CANONICAL_SPEC.md +409 -409
  3. package/LICENSE +55 -55
  4. package/NOTICE +6 -6
  5. package/README.md +458 -444
  6. package/action-cases.json +13 -13
  7. package/artistic-text-cases.json +690 -690
  8. package/canonical-cases.json +6 -0
  9. package/keyframe-cases.json +32 -32
  10. package/keys.json +22 -22
  11. package/marks-cases.json +34 -34
  12. package/package.json +2 -1
  13. package/pattern-cases.json +552 -552
  14. package/schema/oim.schema.json +2920 -2920
  15. package/schema/oim2.schema.json +2763 -0
  16. package/schema/ovm.schema.json +2701 -2701
  17. package/text-effect-cases.json +835 -835
  18. package/tools/parity-core.mjs +122 -122
  19. package/tools/parity.mjs +86 -86
  20. package/vectors/README.md +105 -103
  21. package/vectors/accessibility.expected.json +67 -67
  22. package/vectors/artistic-text.expected.json +84 -84
  23. package/vectors/asymmetric-borders.expected.json +29 -29
  24. package/vectors/background-pattern-order.expected.json +95 -95
  25. package/vectors/basic-text.expected.json +29 -29
  26. package/vectors/color-blending-opacity.expected.json +47 -47
  27. package/vectors/crop.expected.json +32 -32
  28. package/vectors/embedded-font-woff.expected.json +57 -57
  29. package/vectors/extreme-aspect-ratios.expected.json +29 -29
  30. package/vectors/filter-effects.expected.json +33 -33
  31. package/vectors/flip.expected.json +47 -47
  32. package/vectors/frame-shapes.expected.json +73 -73
  33. package/vectors/full-monty-composite.expected.json +96 -96
  34. package/vectors/golden/exceptions.json +3 -3
  35. package/vectors/golden/mask-window.web.png +0 -0
  36. package/vectors/golden/zone-group.web.png +0 -0
  37. package/vectors/groups-and-parallax.expected.json +90 -90
  38. package/vectors/image-fill.expected.json +54 -54
  39. package/vectors/interactive-chained-actions.expected.json +77 -77
  40. package/vectors/interactive-hover.expected.json +36 -36
  41. package/vectors/interactive.expected.json +121 -121
  42. package/vectors/layer-blur.expected.json +48 -48
  43. package/vectors/layer-grain.expected.json +52 -52
  44. package/vectors/layer-ordering-zindex.expected.json +62 -62
  45. package/vectors/legacy-text-chunk.expected.json +29 -29
  46. package/vectors/mask-window.expected.json +45 -0
  47. package/vectors/mask-window.oim.png +0 -0
  48. package/vectors/media-filters.expected.json +50 -50
  49. package/vectors/multi-container-layout.expected.json +45 -45
  50. package/vectors/multilang-cjk-rtl.expected.json +30 -30
  51. package/vectors/nested-groups.expected.json +68 -68
  52. package/vectors/palettes.expected.json +57 -57
  53. package/vectors/parallax-infinite.expected.json +39 -39
  54. package/vectors/pattern-color-roles.expected.json +68 -68
  55. package/vectors/reedit-english.expected.json +45 -45
  56. package/vectors/reveal-and-play.expected.json +107 -107
  57. package/vectors/shadow-effects.expected.json +34 -34
  58. package/vectors/shape-shadows.expected.json +62 -62
  59. package/vectors/strokes-and-borders.expected.json +31 -31
  60. package/vectors/text-runs-precalculated.expected.json +46 -46
  61. package/vectors/transparent-background.expected.json +30 -30
  62. package/vectors/typography-styling.expected.json +34 -34
  63. package/vectors/zone-group.expected.json +46 -0
  64. package/vectors/zone-group.oim.png +0 -0
@@ -1,122 +1,122 @@
1
- // Copyright (c) 2026 Xue Studios LLC
2
- // SPDX-License-Identifier: Apache-2.0
3
- // Visual conformance metric (CANONICAL_SPEC.md, "Visual conformance"). Zero dependencies.
4
- import zlib from 'node:zlib';
5
-
6
- export const CHANNEL_TOLERANCE = 40;
7
- // Calibrated 2026-11-07: passing text noise reaches 0.265 %, a unitless letterSpacing bug 0.509 %.
8
- export const MAX_AREA = 0.004;
9
- export const MAX_BLOB = 24 * 24;
10
-
11
- const SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
12
-
13
- const paeth = (a, b, c) => {
14
- const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
15
- return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
16
- };
17
-
18
- /** Decodes an 8-bit, non-interlaced RGB or RGBA PNG to RGBA. */
19
- export const decodePng = (buf) => {
20
- if (!buf.subarray(0, 8).equals(SIGNATURE)) throw new Error('unsupported PNG: bad signature');
21
- let pos = 8, width = 0, height = 0, type = 0;
22
- const idat = [];
23
- while (pos < buf.length) {
24
- const len = buf.readUInt32BE(pos), kind = buf.toString('latin1', pos + 4, pos + 8);
25
- const data = buf.subarray(pos + 8, pos + 8 + len);
26
- if (kind === 'IHDR') {
27
- width = data.readUInt32BE(0); height = data.readUInt32BE(4); type = data[9];
28
- if (data[8] !== 8 || (type !== 2 && type !== 6) || data[12] !== 0)
29
- throw new Error(`unsupported PNG: bit depth ${data[8]}, colour type ${type}, interlace ${data[12]}`);
30
- } else if (kind === 'IDAT') idat.push(data);
31
- else if (kind === 'IEND') break;
32
- pos += 12 + len;
33
- }
34
- const raw = zlib.inflateSync(Buffer.concat(idat));
35
- const bpp = type === 6 ? 4 : 3, stride = width * bpp;
36
- const out = Buffer.alloc(stride * height);
37
- for (let y = 0; y < height; y++) {
38
- const f = raw[y * (stride + 1)], src = y * (stride + 1) + 1, row = y * stride;
39
- for (let i = 0; i < stride; i++) {
40
- const a = i >= bpp ? out[row + i - bpp] : 0, b = y ? out[row - stride + i] : 0;
41
- const c = i >= bpp && y ? out[row - stride + i - bpp] : 0;
42
- const pred = f === 0 ? 0 : f === 1 ? a : f === 2 ? b : f === 3 ? (a + b) >> 1 : f === 4 ? paeth(a, b, c) : NaN;
43
- if (Number.isNaN(pred)) throw new Error(`unsupported PNG: filter ${f}`);
44
- out[row + i] = (raw[src + i] + pred) & 255;
45
- }
46
- }
47
- const rgba = new Uint8Array(width * height * 4);
48
- for (let p = 0; p < width * height; p++) {
49
- rgba[p * 4] = out[p * bpp]; rgba[p * 4 + 1] = out[p * bpp + 1]; rgba[p * 4 + 2] = out[p * bpp + 2];
50
- rgba[p * 4 + 3] = bpp === 4 ? out[p * bpp + 3] : 255;
51
- }
52
- return { width, height, rgba };
53
- };
54
-
55
- const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
56
- let c = n;
57
- for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
58
- return c >>> 0;
59
- });
60
- const crc32 = (buf) => {
61
- let c = 0xffffffff;
62
- for (const byte of buf) c = CRC_TABLE[(c ^ byte) & 255] ^ (c >>> 8);
63
- return (c ^ 0xffffffff) >>> 0;
64
- };
65
- const chunk = (kind, data) => {
66
- const head = Buffer.alloc(8); head.writeUInt32BE(data.length); head.write(kind, 4, 'latin1');
67
- const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])));
68
- return Buffer.concat([head, data, crc]);
69
- };
70
-
71
- /** Encodes RGBA as an 8-bit RGBA PNG (filter 0). */
72
- export const encodePng = ({ width, height, rgba }) => {
73
- const ihdr = Buffer.alloc(13);
74
- ihdr.writeUInt32BE(width); ihdr.writeUInt32BE(height, 4); ihdr[8] = 8; ihdr[9] = 6;
75
- const raw = Buffer.alloc((width * 4 + 1) * height);
76
- for (let y = 0; y < height; y++) raw.set(rgba.subarray(y * width * 4, (y + 1) * width * 4), y * (width * 4 + 1) + 1);
77
- return Buffer.concat([SIGNATURE, chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]);
78
- };
79
-
80
- // Composite over white: a transparent render is a blank page, not black.
81
- const onWhite = (rgba, i) => {
82
- const a = rgba[i + 3] / 255;
83
- return [0, 1, 2].map((k) => rgba[i + k] * a + 255 * (1 - a));
84
- };
85
-
86
- /** Compares a render against a golden under the visual conformance metric. */
87
- export const compare = (golden, render) => {
88
- const { width: w, height: h } = golden;
89
- if (w !== render.width || h !== render.height)
90
- return { ok: false, reason: `size ${render.width}x${render.height}, expected ${w}x${h}`, area: 1, largestBlob: 0, mask: new Uint8Array(0) };
91
- const diff = new Uint8Array(w * h);
92
- for (let p = 0; p < w * h; p++) {
93
- const g = onWhite(golden.rgba, p * 4), r = onWhite(render.rgba, p * 4);
94
- diff[p] = g.some((v, k) => Math.abs(Math.round(v) - Math.round(r[k])) > CHANNEL_TOLERANCE) ? 1 : 0;
95
- }
96
- // 3x3 erosion: antialiasing and hinting leave 1-2 px lines; a defect is an area.
97
- const mask = new Uint8Array(w * h);
98
- let count = 0;
99
- for (let y = 1; y < h - 1; y++)
100
- for (let x = 1; x < w - 1; x++) {
101
- let all = 1;
102
- for (let dy = -1; dy <= 1 && all; dy++) for (let dx = -1; dx <= 1 && all; dx++) all = diff[(y + dy) * w + x + dx];
103
- if (all) { mask[y * w + x] = 1; count++; }
104
- }
105
- let largestBlob = 0;
106
- const seen = new Uint8Array(w * h), stack = [];
107
- for (let p = 0; p < w * h; p++) {
108
- if (!mask[p] || seen[p]) continue;
109
- let size = 0;
110
- stack.push(p); seen[p] = 1;
111
- while (stack.length) {
112
- const q = stack.pop(), x = q % w;
113
- size++;
114
- for (const n of [q - w, q + w, x > 0 ? q - 1 : -1, x < w - 1 ? q + 1 : -1])
115
- if (n >= 0 && n < w * h && mask[n] && !seen[n]) { seen[n] = 1; stack.push(n); }
116
- }
117
- largestBlob = Math.max(largestBlob, size);
118
- }
119
- const area = count / (w * h);
120
- const ok = area <= MAX_AREA && largestBlob <= MAX_BLOB;
121
- return { ok, reason: ok ? undefined : `area ${(area * 100).toFixed(3)}%, largest blob ${largestBlob}px`, area, largestBlob, mask };
122
- };
1
+ // Copyright (c) 2026 Xue Studios LLC
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ // Visual conformance metric (CANONICAL_SPEC.md, "Visual conformance"). Zero dependencies.
4
+ import zlib from 'node:zlib';
5
+
6
+ export const CHANNEL_TOLERANCE = 40;
7
+ // Calibrated 2026-11-07: passing text noise reaches 0.265 %, a unitless letterSpacing bug 0.509 %.
8
+ export const MAX_AREA = 0.004;
9
+ export const MAX_BLOB = 24 * 24;
10
+
11
+ const SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
12
+
13
+ const paeth = (a, b, c) => {
14
+ const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
15
+ return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
16
+ };
17
+
18
+ /** Decodes an 8-bit, non-interlaced RGB or RGBA PNG to RGBA. */
19
+ export const decodePng = (buf) => {
20
+ if (!buf.subarray(0, 8).equals(SIGNATURE)) throw new Error('unsupported PNG: bad signature');
21
+ let pos = 8, width = 0, height = 0, type = 0;
22
+ const idat = [];
23
+ while (pos < buf.length) {
24
+ const len = buf.readUInt32BE(pos), kind = buf.toString('latin1', pos + 4, pos + 8);
25
+ const data = buf.subarray(pos + 8, pos + 8 + len);
26
+ if (kind === 'IHDR') {
27
+ width = data.readUInt32BE(0); height = data.readUInt32BE(4); type = data[9];
28
+ if (data[8] !== 8 || (type !== 2 && type !== 6) || data[12] !== 0)
29
+ throw new Error(`unsupported PNG: bit depth ${data[8]}, colour type ${type}, interlace ${data[12]}`);
30
+ } else if (kind === 'IDAT') idat.push(data);
31
+ else if (kind === 'IEND') break;
32
+ pos += 12 + len;
33
+ }
34
+ const raw = zlib.inflateSync(Buffer.concat(idat));
35
+ const bpp = type === 6 ? 4 : 3, stride = width * bpp;
36
+ const out = Buffer.alloc(stride * height);
37
+ for (let y = 0; y < height; y++) {
38
+ const f = raw[y * (stride + 1)], src = y * (stride + 1) + 1, row = y * stride;
39
+ for (let i = 0; i < stride; i++) {
40
+ const a = i >= bpp ? out[row + i - bpp] : 0, b = y ? out[row - stride + i] : 0;
41
+ const c = i >= bpp && y ? out[row - stride + i - bpp] : 0;
42
+ const pred = f === 0 ? 0 : f === 1 ? a : f === 2 ? b : f === 3 ? (a + b) >> 1 : f === 4 ? paeth(a, b, c) : NaN;
43
+ if (Number.isNaN(pred)) throw new Error(`unsupported PNG: filter ${f}`);
44
+ out[row + i] = (raw[src + i] + pred) & 255;
45
+ }
46
+ }
47
+ const rgba = new Uint8Array(width * height * 4);
48
+ for (let p = 0; p < width * height; p++) {
49
+ rgba[p * 4] = out[p * bpp]; rgba[p * 4 + 1] = out[p * bpp + 1]; rgba[p * 4 + 2] = out[p * bpp + 2];
50
+ rgba[p * 4 + 3] = bpp === 4 ? out[p * bpp + 3] : 255;
51
+ }
52
+ return { width, height, rgba };
53
+ };
54
+
55
+ const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
56
+ let c = n;
57
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
58
+ return c >>> 0;
59
+ });
60
+ const crc32 = (buf) => {
61
+ let c = 0xffffffff;
62
+ for (const byte of buf) c = CRC_TABLE[(c ^ byte) & 255] ^ (c >>> 8);
63
+ return (c ^ 0xffffffff) >>> 0;
64
+ };
65
+ const chunk = (kind, data) => {
66
+ const head = Buffer.alloc(8); head.writeUInt32BE(data.length); head.write(kind, 4, 'latin1');
67
+ const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])));
68
+ return Buffer.concat([head, data, crc]);
69
+ };
70
+
71
+ /** Encodes RGBA as an 8-bit RGBA PNG (filter 0). */
72
+ export const encodePng = ({ width, height, rgba }) => {
73
+ const ihdr = Buffer.alloc(13);
74
+ ihdr.writeUInt32BE(width); ihdr.writeUInt32BE(height, 4); ihdr[8] = 8; ihdr[9] = 6;
75
+ const raw = Buffer.alloc((width * 4 + 1) * height);
76
+ for (let y = 0; y < height; y++) raw.set(rgba.subarray(y * width * 4, (y + 1) * width * 4), y * (width * 4 + 1) + 1);
77
+ return Buffer.concat([SIGNATURE, chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]);
78
+ };
79
+
80
+ // Composite over white: a transparent render is a blank page, not black.
81
+ const onWhite = (rgba, i) => {
82
+ const a = rgba[i + 3] / 255;
83
+ return [0, 1, 2].map((k) => rgba[i + k] * a + 255 * (1 - a));
84
+ };
85
+
86
+ /** Compares a render against a golden under the visual conformance metric. */
87
+ export const compare = (golden, render) => {
88
+ const { width: w, height: h } = golden;
89
+ if (w !== render.width || h !== render.height)
90
+ return { ok: false, reason: `size ${render.width}x${render.height}, expected ${w}x${h}`, area: 1, largestBlob: 0, mask: new Uint8Array(0) };
91
+ const diff = new Uint8Array(w * h);
92
+ for (let p = 0; p < w * h; p++) {
93
+ const g = onWhite(golden.rgba, p * 4), r = onWhite(render.rgba, p * 4);
94
+ diff[p] = g.some((v, k) => Math.abs(Math.round(v) - Math.round(r[k])) > CHANNEL_TOLERANCE) ? 1 : 0;
95
+ }
96
+ // 3x3 erosion: antialiasing and hinting leave 1-2 px lines; a defect is an area.
97
+ const mask = new Uint8Array(w * h);
98
+ let count = 0;
99
+ for (let y = 1; y < h - 1; y++)
100
+ for (let x = 1; x < w - 1; x++) {
101
+ let all = 1;
102
+ for (let dy = -1; dy <= 1 && all; dy++) for (let dx = -1; dx <= 1 && all; dx++) all = diff[(y + dy) * w + x + dx];
103
+ if (all) { mask[y * w + x] = 1; count++; }
104
+ }
105
+ let largestBlob = 0;
106
+ const seen = new Uint8Array(w * h), stack = [];
107
+ for (let p = 0; p < w * h; p++) {
108
+ if (!mask[p] || seen[p]) continue;
109
+ let size = 0;
110
+ stack.push(p); seen[p] = 1;
111
+ while (stack.length) {
112
+ const q = stack.pop(), x = q % w;
113
+ size++;
114
+ for (const n of [q - w, q + w, x > 0 ? q - 1 : -1, x < w - 1 ? q + 1 : -1])
115
+ if (n >= 0 && n < w * h && mask[n] && !seen[n]) { seen[n] = 1; stack.push(n); }
116
+ }
117
+ largestBlob = Math.max(largestBlob, size);
118
+ }
119
+ const area = count / (w * h);
120
+ const ok = area <= MAX_AREA && largestBlob <= MAX_BLOB;
121
+ return { ok, reason: ok ? undefined : `area ${(area * 100).toFixed(3)}%, largest blob ${largestBlob}px`, area, largestBlob, mask };
122
+ };
package/tools/parity.mjs CHANGED
@@ -1,86 +1,86 @@
1
- #!/usr/bin/env node
2
- // Copyright (c) 2026 Xue Studios LLC
3
- // SPDX-License-Identifier: Apache-2.0
4
- // Visual conformance: compares a renderer's PNGs against the web goldens.
5
- // Usage: node parity.mjs <renders-dir> --renderer <name> [--out <diff-dir>]
6
- import fs from 'node:fs';
7
- import path from 'node:path';
8
- import { fileURLToPath } from 'node:url';
9
- import { decodePng, encodePng, compare } from './parity-core.mjs';
10
-
11
- const HERE = path.dirname(fileURLToPath(import.meta.url));
12
-
13
- const names = (dir, suffix) =>
14
- fs.readdirSync(dir).filter((f) => f.endsWith(suffix)).map((f) => f.slice(0, -suffix.length)).sort();
15
-
16
- const diffImage = (golden, mask) => {
17
- const rgba = new Uint8Array(golden.rgba.length);
18
- for (let p = 0; p < mask.length; p++) {
19
- const i = p * 4;
20
- if (mask[p]) rgba.set([255, 0, 0, 255], i);
21
- else rgba.set([golden.rgba[i] * 0.3, golden.rgba[i + 1] * 0.3, golden.rgba[i + 2] * 0.3, 255], i);
22
- }
23
- return { width: golden.width, height: golden.height, rgba };
24
- };
25
-
26
- export const runParity = ({
27
- rendersDir,
28
- renderer,
29
- outDir,
30
- goldenDir = path.join(HERE, '..', 'vectors', 'golden'),
31
- vectorsDir = path.join(HERE, '..', 'vectors'),
32
- }) => {
33
- const failures = [], lines = [];
34
- const vectors = names(vectorsDir, '.oim.png');
35
- const goldens = names(goldenDir, '.web.png');
36
- const exceptionsFile = path.join(goldenDir, 'exceptions.json');
37
- const exceptions = (fs.existsSync(exceptionsFile) ? JSON.parse(fs.readFileSync(exceptionsFile, 'utf8')) : {})[renderer] ?? {};
38
-
39
- for (const v of vectors) if (!goldens.includes(v)) failures.push(`${v}: no golden for this vector, regenerate the goldens`);
40
- for (const g of goldens) if (!vectors.includes(g)) failures.push(`${g}: orphan golden, no vector`);
41
- for (const e of Object.keys(exceptions)) if (!vectors.includes(e)) failures.push(`${e}: unknown vector in exceptions`);
42
- if (outDir) fs.mkdirSync(outDir, { recursive: true });
43
-
44
- for (const name of goldens.filter((g) => vectors.includes(g))) {
45
- const renderPath = path.join(rendersDir, `${name}.png`);
46
- const excepted = name in exceptions;
47
- // `broken`: no comparison happened (missing, unreadable or wrong-size render). An exception never covers it.
48
- let verdict, detail = '', broken = false;
49
- if (!fs.existsSync(renderPath)) {
50
- verdict = 'FAIL'; detail = 'missing render'; broken = true;
51
- } else {
52
- let golden, r;
53
- try {
54
- golden = decodePng(fs.readFileSync(path.join(goldenDir, `${name}.web.png`)));
55
- r = compare(golden, decodePng(fs.readFileSync(renderPath)));
56
- } catch (err) {
57
- r = { ok: false, reason: err.message, area: 1, largestBlob: 0 };
58
- }
59
- broken = !r.ok && !r.mask?.length;
60
- detail = r.reason ?? `area=${(r.area * 100).toFixed(3)}% blob=${r.largestBlob}px`;
61
- verdict = r.ok ? 'PASS' : 'FAIL';
62
- if (!r.ok && outDir && r.mask?.length) fs.writeFileSync(path.join(outDir, `${name}.diff.png`), encodePng(diffImage(golden, r.mask)));
63
- }
64
- if (excepted && verdict === 'PASS') failures.push(`${name}: passes, remove the exception for ${renderer}`);
65
- else if (excepted && !broken) verdict = 'EXCEPTED';
66
- else if (verdict === 'FAIL') failures.push(`${name}: ${detail}`);
67
- lines.push(`${name.padEnd(32)} ${verdict.padEnd(8)} ${detail}`);
68
- }
69
- if (!lines.length) failures.push('no vectors compared: check the vectors and goldens directories');
70
- return { failures, lines };
71
- };
72
-
73
- if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
74
- const args = process.argv.slice(2);
75
- const flag = (n) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : undefined; };
76
- const rendersDir = args[0], renderer = flag('--renderer');
77
- if (!rendersDir || !renderer) {
78
- console.error('usage: parity.mjs <renders-dir> --renderer <name> [--out <diff-dir>]');
79
- process.exit(2);
80
- }
81
- const { failures, lines } = runParity({ rendersDir, renderer, outDir: flag('--out') });
82
- lines.forEach((l) => console.log(l));
83
- failures.forEach((f) => console.error(`✖ ${f}`));
84
- console.log(failures.length ? `\n${failures.length} failure(s)` : '\nvisual parity: OK');
85
- process.exit(failures.length ? 1 : 0);
86
- }
1
+ #!/usr/bin/env node
2
+ // Copyright (c) 2026 Xue Studios LLC
3
+ // SPDX-License-Identifier: Apache-2.0
4
+ // Visual conformance: compares a renderer's PNGs against the web goldens.
5
+ // Usage: node parity.mjs <renders-dir> --renderer <name> [--out <diff-dir>]
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { decodePng, encodePng, compare } from './parity-core.mjs';
10
+
11
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
12
+
13
+ const names = (dir, suffix) =>
14
+ fs.readdirSync(dir).filter((f) => f.endsWith(suffix)).map((f) => f.slice(0, -suffix.length)).sort();
15
+
16
+ const diffImage = (golden, mask) => {
17
+ const rgba = new Uint8Array(golden.rgba.length);
18
+ for (let p = 0; p < mask.length; p++) {
19
+ const i = p * 4;
20
+ if (mask[p]) rgba.set([255, 0, 0, 255], i);
21
+ else rgba.set([golden.rgba[i] * 0.3, golden.rgba[i + 1] * 0.3, golden.rgba[i + 2] * 0.3, 255], i);
22
+ }
23
+ return { width: golden.width, height: golden.height, rgba };
24
+ };
25
+
26
+ export const runParity = ({
27
+ rendersDir,
28
+ renderer,
29
+ outDir,
30
+ goldenDir = path.join(HERE, '..', 'vectors', 'golden'),
31
+ vectorsDir = path.join(HERE, '..', 'vectors'),
32
+ }) => {
33
+ const failures = [], lines = [];
34
+ const vectors = names(vectorsDir, '.oim.png');
35
+ const goldens = names(goldenDir, '.web.png');
36
+ const exceptionsFile = path.join(goldenDir, 'exceptions.json');
37
+ const exceptions = (fs.existsSync(exceptionsFile) ? JSON.parse(fs.readFileSync(exceptionsFile, 'utf8')) : {})[renderer] ?? {};
38
+
39
+ for (const v of vectors) if (!goldens.includes(v)) failures.push(`${v}: no golden for this vector, regenerate the goldens`);
40
+ for (const g of goldens) if (!vectors.includes(g)) failures.push(`${g}: orphan golden, no vector`);
41
+ for (const e of Object.keys(exceptions)) if (!vectors.includes(e)) failures.push(`${e}: unknown vector in exceptions`);
42
+ if (outDir) fs.mkdirSync(outDir, { recursive: true });
43
+
44
+ for (const name of goldens.filter((g) => vectors.includes(g))) {
45
+ const renderPath = path.join(rendersDir, `${name}.png`);
46
+ const excepted = name in exceptions;
47
+ // `broken`: no comparison happened (missing, unreadable or wrong-size render). An exception never covers it.
48
+ let verdict, detail = '', broken = false;
49
+ if (!fs.existsSync(renderPath)) {
50
+ verdict = 'FAIL'; detail = 'missing render'; broken = true;
51
+ } else {
52
+ let golden, r;
53
+ try {
54
+ golden = decodePng(fs.readFileSync(path.join(goldenDir, `${name}.web.png`)));
55
+ r = compare(golden, decodePng(fs.readFileSync(renderPath)));
56
+ } catch (err) {
57
+ r = { ok: false, reason: err.message, area: 1, largestBlob: 0 };
58
+ }
59
+ broken = !r.ok && !r.mask?.length;
60
+ detail = r.reason ?? `area=${(r.area * 100).toFixed(3)}% blob=${r.largestBlob}px`;
61
+ verdict = r.ok ? 'PASS' : 'FAIL';
62
+ if (!r.ok && outDir && r.mask?.length) fs.writeFileSync(path.join(outDir, `${name}.diff.png`), encodePng(diffImage(golden, r.mask)));
63
+ }
64
+ if (excepted && verdict === 'PASS') failures.push(`${name}: passes, remove the exception for ${renderer}`);
65
+ else if (excepted && !broken) verdict = 'EXCEPTED';
66
+ else if (verdict === 'FAIL') failures.push(`${name}: ${detail}`);
67
+ lines.push(`${name.padEnd(32)} ${verdict.padEnd(8)} ${detail}`);
68
+ }
69
+ if (!lines.length) failures.push('no vectors compared: check the vectors and goldens directories');
70
+ return { failures, lines };
71
+ };
72
+
73
+ if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
74
+ const args = process.argv.slice(2);
75
+ const flag = (n) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : undefined; };
76
+ const rendersDir = args[0], renderer = flag('--renderer');
77
+ if (!rendersDir || !renderer) {
78
+ console.error('usage: parity.mjs <renders-dir> --renderer <name> [--out <diff-dir>]');
79
+ process.exit(2);
80
+ }
81
+ const { failures, lines } = runParity({ rendersDir, renderer, outDir: flag('--out') });
82
+ lines.forEach((l) => console.log(l));
83
+ failures.forEach((f) => console.error(`✖ ${f}`));
84
+ console.log(failures.length ? `\n${failures.length} failure(s)` : '\nvisual parity: OK');
85
+ process.exit(failures.length ? 1 : 0);
86
+ }