@zenuml/core 3.46.0 → 3.46.1
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.
- package/.claude/skills/dia-scoring/SKILL.md +139 -0
- package/.claude/skills/dia-scoring/agents/openai.yaml +7 -0
- package/.claude/skills/dia-scoring/references/selectors-and-keys.md +253 -0
- package/CLAUDE.md +1 -1
- package/bun.lock +25 -11
- package/cy/canonical-history.html +908 -0
- package/cy/compare-case.html +357 -0
- package/cy/compare-cases.js +824 -0
- package/cy/compare.html +35 -0
- package/cy/diff-algorithm.js +199 -0
- package/cy/element-report.html +705 -0
- package/cy/icons-test.html +29 -0
- package/cy/legacy-vs-html.html +291 -0
- package/cy/native-diff-ext/background.js +60 -0
- package/cy/native-diff-ext/bridge.js +26 -0
- package/cy/native-diff-ext/content.js +194 -0
- package/cy/parity-test.html +122 -0
- package/cy/return-in-nested-if.html +29 -0
- package/cy/svg-preview.html +56 -0
- package/cy/svg-test.html +21 -0
- package/cy/theme-default-test.html +28 -0
- package/dist/stats.html +1 -1
- package/dist/zenuml.esm.mjs +16352 -15223
- package/dist/zenuml.js +701 -575
- package/docs/superpowers/plans/2026-03-23-svg-parity-features.md +283 -0
- package/index.html +568 -73
- package/package.json +15 -4
- package/scripts/analyze-compare-case/collect-data.mjs +991 -0
- package/scripts/analyze-compare-case/config.mjs +102 -0
- package/scripts/analyze-compare-case/geometry.mjs +101 -0
- package/scripts/analyze-compare-case/native-diff.mjs +224 -0
- package/scripts/analyze-compare-case/output.mjs +74 -0
- package/scripts/analyze-compare-case/panel-diff.mjs +114 -0
- package/scripts/analyze-compare-case/report.mjs +157 -0
- package/scripts/analyze-compare-case/residual-scopes.mjs +325 -0
- package/scripts/analyze-compare-case/scoring.mjs +816 -0
- package/scripts/analyze-compare-case.mjs +149 -0
- package/scripts/snapshot-dual.js +34 -34
- package/skills/dia-scoring/SKILL.md +129 -0
- package/skills/dia-scoring/agents/openai.yaml +7 -0
- package/skills/dia-scoring/references/selectors-and-keys.md +253 -0
- package/test-setup.ts +8 -0
- package/types/index.d.ts +56 -0
- package/vite.config.ts +4 -0
- package/dist/10029-icon-service-Function-Apps-ObflOLuF.js +0 -5
- package/dist/Res_AWS-Identity-Access-Management_IAM-Access-Analyzer_48-BPq60XMY.js +0 -11
- package/dist/Res_AWS-Lambda_Lambda-Function_48-Co38UB_2.js +0 -12
- package/dist/Res_Amazon-EC2_Instance_48-CRaqbNUl.js +0 -12
- package/dist/Res_Amazon-Simple-Notification-Service_Topic_48-q13mxUeM.js +0 -11
- package/dist/Res_Amazon-Simple-Queue-Service_Queue_48-D2-8gbFw.js +0 -11
- package/dist/Robustness_Diagram_Boundary-nYnmTPs8.js +0 -10
- package/dist/Robustness_Diagram_Control-DLNLoMxd.js +0 -11
- package/dist/Robustness_Diagram_Entity-Be3kcbIE.js +0 -11
- package/dist/actor-BMj_HFpo.js +0 -11
- package/dist/database-BKHQQWQK.js +0 -8
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* What this file does:
|
|
3
|
+
* Owns the analyzer's command-line contract and default runtime settings.
|
|
4
|
+
*
|
|
5
|
+
* High-level flow:
|
|
6
|
+
* - Defines the default case, base URL, diff tolerances, and viewport.
|
|
7
|
+
* - Parses CLI flags into a normalized options object used by the entrypoint.
|
|
8
|
+
* - Keeps argument handling separate so it can be tested without Playwright.
|
|
9
|
+
*
|
|
10
|
+
* Example input:
|
|
11
|
+
* `["--case", "async-2a", "--user-data-dir", "/Users/pengxiao/Library/Application Support/Google/Chrome", "--profile-directory", "Profile 8", "--json"]`
|
|
12
|
+
*
|
|
13
|
+
* Example output:
|
|
14
|
+
* `{ caseName: "async-2a", userDataDir: "/Users/.../Chrome", profileDirectory: "Profile 8", jsonOnly: true, ... }`
|
|
15
|
+
*/
|
|
16
|
+
export const DEFAULTS = {
|
|
17
|
+
caseName: "async-2a",
|
|
18
|
+
baseUrl: "http://localhost:8080",
|
|
19
|
+
lumaThreshold: 240,
|
|
20
|
+
channelTolerance: 12,
|
|
21
|
+
positionTolerance: 0,
|
|
22
|
+
viewport: { width: 1600, height: 2200 },
|
|
23
|
+
userDataDir: process.env.PLAYWRIGHT_USER_DATA_DIR || null,
|
|
24
|
+
profileDirectory: process.env.PLAYWRIGHT_PROFILE_DIRECTORY || null,
|
|
25
|
+
browserChannel: process.env.PLAYWRIGHT_CHANNEL || null,
|
|
26
|
+
headless: process.env.PLAYWRIGHT_HEADLESS === "1",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function parseArgs(argv) {
|
|
30
|
+
const args = { ...DEFAULTS, jsonOnly: false, summaryOnly: false, outputDir: null };
|
|
31
|
+
for (let i = 0; i < argv.length; i++) {
|
|
32
|
+
const arg = argv[i];
|
|
33
|
+
const next = argv[i + 1];
|
|
34
|
+
if ((arg === "--case" || arg === "-c") && next) {
|
|
35
|
+
args.caseName = next;
|
|
36
|
+
i++;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if ((arg === "--base-url" || arg === "-b") && next) {
|
|
40
|
+
args.baseUrl = next;
|
|
41
|
+
i++;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (arg === "--user-data-dir" && next) {
|
|
45
|
+
args.userDataDir = next;
|
|
46
|
+
i++;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (arg === "--profile-directory" && next) {
|
|
50
|
+
args.profileDirectory = next;
|
|
51
|
+
i++;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (arg === "--channel" && next) {
|
|
55
|
+
args.browserChannel = next;
|
|
56
|
+
i++;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (arg === "--json") {
|
|
60
|
+
args.jsonOnly = true;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (arg === "--summary-only") {
|
|
64
|
+
args.summaryOnly = true;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (arg === "--output-dir" && next) {
|
|
68
|
+
args.outputDir = next;
|
|
69
|
+
i++;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (arg === "--luma" && next) {
|
|
73
|
+
args.lumaThreshold = Number(next);
|
|
74
|
+
i++;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (arg === "--ctol" && next) {
|
|
78
|
+
args.channelTolerance = Number(next);
|
|
79
|
+
i++;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (arg === "--ptol" && next) {
|
|
83
|
+
args.positionTolerance = Number(next);
|
|
84
|
+
i++;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (arg === "--headed") {
|
|
88
|
+
args.headless = false;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (arg === "--headless") {
|
|
92
|
+
args.headless = true;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (!arg.startsWith("-") && args.caseName === DEFAULTS.caseName) {
|
|
96
|
+
args.caseName = arg;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
100
|
+
}
|
|
101
|
+
return args;
|
|
102
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* What this file does:
|
|
3
|
+
* Provides the shared math helpers used across the analyzer.
|
|
4
|
+
*
|
|
5
|
+
* High-level flow:
|
|
6
|
+
* - Normalizes offsets and rounding for stable report output.
|
|
7
|
+
* - Computes rectangle relationships such as union, overlap, and centers.
|
|
8
|
+
* - Centralizes these primitives so extraction, scoring, and residual attribution
|
|
9
|
+
* all use the same geometry rules.
|
|
10
|
+
*
|
|
11
|
+
* Example input:
|
|
12
|
+
* `unionRect([{ x: 10, y: 20, w: 5, h: 5 }, { x: 14, y: 18, w: 6, h: 10 }])`
|
|
13
|
+
*
|
|
14
|
+
* Example output:
|
|
15
|
+
* `{ x: 10, y: 18, w: 10, h: 12 }`
|
|
16
|
+
*/
|
|
17
|
+
export function round(value, digits = 2) {
|
|
18
|
+
const factor = 10 ** digits;
|
|
19
|
+
return Math.round(value * factor) / factor;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function normalizeOffset(value) {
|
|
23
|
+
const rounded = round(value);
|
|
24
|
+
return Math.abs(rounded) < 0.05 ? 0 : rounded;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function clamp(value, min, max) {
|
|
28
|
+
return Math.max(min, Math.min(max, value));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function segmentGraphemes(text) {
|
|
32
|
+
if (typeof Intl !== "undefined" && typeof Intl.Segmenter === "function") {
|
|
33
|
+
return Array.from(new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(text)).map(
|
|
34
|
+
(part) => ({ grapheme: part.segment, index: part.index }),
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const chars = Array.from(text);
|
|
39
|
+
let offset = 0;
|
|
40
|
+
return chars.map((grapheme) => {
|
|
41
|
+
const part = { grapheme, index: offset };
|
|
42
|
+
offset += grapheme.length;
|
|
43
|
+
return part;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function keyForLabel(label) {
|
|
48
|
+
return `${label.kind}\u0000${label.pairText ?? label.text}\u0000${label.textOrder}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function rectRight(rect) {
|
|
52
|
+
return rect.x + rect.w;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function rectBottom(rect) {
|
|
56
|
+
return rect.y + rect.h;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function area(rect) {
|
|
60
|
+
return Math.max(0, rect.w) * Math.max(0, rect.h);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function unionRect(rects) {
|
|
64
|
+
if (!rects || rects.length === 0) {
|
|
65
|
+
return { x: 0, y: 0, w: 0, h: 0 };
|
|
66
|
+
}
|
|
67
|
+
const left = Math.min(...rects.map((rect) => rect.x));
|
|
68
|
+
const top = Math.min(...rects.map((rect) => rect.y));
|
|
69
|
+
const right = Math.max(...rects.map((rect) => rect.x + rect.w));
|
|
70
|
+
const bottom = Math.max(...rects.map((rect) => rect.y + rect.h));
|
|
71
|
+
return { x: left, y: top, w: right - left, h: bottom - top };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function arrowEndpointsFromBox(box) {
|
|
75
|
+
return {
|
|
76
|
+
left_x: box.x,
|
|
77
|
+
right_x: box.x + box.w,
|
|
78
|
+
width: box.w,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function intersectionArea(a, b) {
|
|
83
|
+
const left = Math.max(a.x, b.x);
|
|
84
|
+
const top = Math.max(a.y, b.y);
|
|
85
|
+
const right = Math.min(rectRight(a), rectRight(b));
|
|
86
|
+
const bottom = Math.min(rectBottom(a), rectBottom(b));
|
|
87
|
+
return Math.max(0, right - left) * Math.max(0, bottom - top);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function rectCenter(rect) {
|
|
91
|
+
return {
|
|
92
|
+
x: rect.x + rect.w / 2,
|
|
93
|
+
y: rect.y + rect.h / 2,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function iou(a, b) {
|
|
98
|
+
const inter = intersectionArea(a, b);
|
|
99
|
+
const union = area(a) + area(b) - inter;
|
|
100
|
+
return union <= 0 ? 0 : inter / union;
|
|
101
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* What this file does:
|
|
3
|
+
* Builds the analyzer's local pixel diff from native HTML and SVG screenshots.
|
|
4
|
+
*
|
|
5
|
+
* High-level flow:
|
|
6
|
+
* - Flattens captured PNGs onto white so alpha does not skew comparison.
|
|
7
|
+
* - Compares HTML and SVG pixels with configurable channel and position tolerance.
|
|
8
|
+
* - Classifies pixels into match, html-only, svg-only, and color-diff buckets.
|
|
9
|
+
* - Exposes slot-level diff summaries used later by letter and icon scoring.
|
|
10
|
+
*
|
|
11
|
+
* Note:
|
|
12
|
+
* This module only produces the analyzer-side diff. It is not the live diff-panel
|
|
13
|
+
* source of truth used by the current dia-scoring policy.
|
|
14
|
+
*
|
|
15
|
+
* Example input:
|
|
16
|
+
* Two flattened images of the HTML and SVG roots plus options like
|
|
17
|
+
* `{ lumaThreshold: 240, channelTolerance: 12, positionTolerance: 0 }`
|
|
18
|
+
*
|
|
19
|
+
* Example output:
|
|
20
|
+
* `{ width, height, diffData, classData, stats: { matched, htmlOnly, svgOnly, colorDiff, pixelPct } }`
|
|
21
|
+
*/
|
|
22
|
+
import { PNG } from "pngjs";
|
|
23
|
+
|
|
24
|
+
import { clamp, round } from "./geometry.mjs";
|
|
25
|
+
|
|
26
|
+
function rgbaToLuma(r, g, b) {
|
|
27
|
+
return 0.3 * r + 0.59 * g + 0.11 * b;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function flattenToWhite(png) {
|
|
31
|
+
const data = new Uint8ClampedArray(png.width * png.height * 4);
|
|
32
|
+
for (let i = 0; i < png.data.length; i += 4) {
|
|
33
|
+
const alpha = png.data[i + 3] / 255;
|
|
34
|
+
data[i] = Math.round(png.data[i] * alpha + 255 * (1 - alpha));
|
|
35
|
+
data[i + 1] = Math.round(png.data[i + 1] * alpha + 255 * (1 - alpha));
|
|
36
|
+
data[i + 2] = Math.round(png.data[i + 2] * alpha + 255 * (1 - alpha));
|
|
37
|
+
data[i + 3] = 255;
|
|
38
|
+
}
|
|
39
|
+
return { width: png.width, height: png.height, data };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function padImage(image, width, height) {
|
|
43
|
+
const data = new Uint8ClampedArray(width * height * 4);
|
|
44
|
+
data.fill(255);
|
|
45
|
+
for (let y = 0; y < image.height; y++) {
|
|
46
|
+
for (let x = 0; x < image.width; x++) {
|
|
47
|
+
const srcIndex = (y * image.width + x) * 4;
|
|
48
|
+
const dstIndex = (y * width + x) * 4;
|
|
49
|
+
data[dstIndex] = image.data[srcIndex];
|
|
50
|
+
data[dstIndex + 1] = image.data[srcIndex + 1];
|
|
51
|
+
data[dstIndex + 2] = image.data[srcIndex + 2];
|
|
52
|
+
data[dstIndex + 3] = 255;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return data;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function pixelsClose(a, b, tolerance) {
|
|
59
|
+
return (
|
|
60
|
+
Math.abs(a[0] - b[0]) <= tolerance &&
|
|
61
|
+
Math.abs(a[1] - b[1]) <= tolerance &&
|
|
62
|
+
Math.abs(a[2] - b[2]) <= tolerance
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getPixel(data, width, x, y) {
|
|
67
|
+
const index = (y * width + x) * 4;
|
|
68
|
+
return [data[index], data[index + 1], data[index + 2]];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function hasNearbyMatch(srcData, srcWidth, dstData, dstWidth, x, y, width, height, options) {
|
|
72
|
+
const pixel = getPixel(srcData, srcWidth, x, y);
|
|
73
|
+
for (let dy = -options.positionTolerance; dy <= options.positionTolerance; dy++) {
|
|
74
|
+
for (let dx = -options.positionTolerance; dx <= options.positionTolerance; dx++) {
|
|
75
|
+
const nx = x + dx;
|
|
76
|
+
const ny = y + dy;
|
|
77
|
+
if (nx < 0 || nx >= width || ny < 0 || ny >= height) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const other = getPixel(dstData, dstWidth, nx, ny);
|
|
81
|
+
if (rgbaToLuma(other[0], other[1], other[2]) < options.lumaThreshold && pixelsClose(pixel, other, options.channelTolerance)) {
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function computeNativeDiff(htmlImage, svgImage, options) {
|
|
90
|
+
const width = Math.max(htmlImage.width, svgImage.width);
|
|
91
|
+
const height = Math.max(htmlImage.height, svgImage.height);
|
|
92
|
+
const htmlData = padImage(htmlImage, width, height);
|
|
93
|
+
const svgData = padImage(svgImage, width, height);
|
|
94
|
+
const diffData = new Uint8ClampedArray(width * height * 4);
|
|
95
|
+
const classData = new Uint8Array(width * height);
|
|
96
|
+
|
|
97
|
+
let total = 0;
|
|
98
|
+
let matched = 0;
|
|
99
|
+
let htmlOnly = 0;
|
|
100
|
+
let svgOnly = 0;
|
|
101
|
+
let colorDiff = 0;
|
|
102
|
+
|
|
103
|
+
for (let y = 0; y < height; y++) {
|
|
104
|
+
for (let x = 0; x < width; x++) {
|
|
105
|
+
const index = (y * width + x) * 4;
|
|
106
|
+
const a = [htmlData[index], htmlData[index + 1], htmlData[index + 2]];
|
|
107
|
+
const b = [svgData[index], svgData[index + 1], svgData[index + 2]];
|
|
108
|
+
const isHtmlContent = rgbaToLuma(a[0], a[1], a[2]) < options.lumaThreshold;
|
|
109
|
+
const isSvgContent = rgbaToLuma(b[0], b[1], b[2]) < options.lumaThreshold;
|
|
110
|
+
|
|
111
|
+
if (!isHtmlContent && !isSvgContent) {
|
|
112
|
+
diffData[index] = 240;
|
|
113
|
+
diffData[index + 1] = 240;
|
|
114
|
+
diffData[index + 2] = 240;
|
|
115
|
+
diffData[index + 3] = 255;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
total++;
|
|
120
|
+
const matchHtml = hasNearbyMatch(htmlData, width, svgData, width, x, y, width, height, options);
|
|
121
|
+
const matchSvg = hasNearbyMatch(svgData, width, htmlData, width, x, y, width, height, options);
|
|
122
|
+
|
|
123
|
+
if (isHtmlContent && isSvgContent) {
|
|
124
|
+
if (matchHtml || matchSvg) {
|
|
125
|
+
matched++;
|
|
126
|
+
classData[y * width + x] = 1;
|
|
127
|
+
diffData[index] = 0;
|
|
128
|
+
diffData[index + 1] = 100;
|
|
129
|
+
diffData[index + 2] = 0;
|
|
130
|
+
} else {
|
|
131
|
+
colorDiff++;
|
|
132
|
+
classData[y * width + x] = 4;
|
|
133
|
+
diffData[index] = 255;
|
|
134
|
+
diffData[index + 1] = 0;
|
|
135
|
+
diffData[index + 2] = 255;
|
|
136
|
+
}
|
|
137
|
+
} else if (isHtmlContent) {
|
|
138
|
+
if (matchHtml) {
|
|
139
|
+
matched++;
|
|
140
|
+
classData[y * width + x] = 1;
|
|
141
|
+
diffData[index] = 0;
|
|
142
|
+
diffData[index + 1] = 100;
|
|
143
|
+
diffData[index + 2] = 0;
|
|
144
|
+
} else {
|
|
145
|
+
htmlOnly++;
|
|
146
|
+
classData[y * width + x] = 2;
|
|
147
|
+
diffData[index] = 255;
|
|
148
|
+
diffData[index + 1] = 0;
|
|
149
|
+
diffData[index + 2] = 0;
|
|
150
|
+
}
|
|
151
|
+
} else if (matchSvg) {
|
|
152
|
+
matched++;
|
|
153
|
+
classData[y * width + x] = 1;
|
|
154
|
+
diffData[index] = 0;
|
|
155
|
+
diffData[index + 1] = 100;
|
|
156
|
+
diffData[index + 2] = 0;
|
|
157
|
+
} else {
|
|
158
|
+
svgOnly++;
|
|
159
|
+
classData[y * width + x] = 3;
|
|
160
|
+
diffData[index] = 0;
|
|
161
|
+
diffData[index + 1] = 0;
|
|
162
|
+
diffData[index + 2] = 255;
|
|
163
|
+
}
|
|
164
|
+
diffData[index + 3] = 255;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
width,
|
|
170
|
+
height,
|
|
171
|
+
diffData,
|
|
172
|
+
classData,
|
|
173
|
+
stats: {
|
|
174
|
+
matched,
|
|
175
|
+
total,
|
|
176
|
+
htmlOnly,
|
|
177
|
+
svgOnly,
|
|
178
|
+
colorDiff,
|
|
179
|
+
pixelPct: total > 0 ? round((matched / total) * 100, 2) : 100,
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function buildPngBuffer(width, height, data) {
|
|
185
|
+
const png = new PNG({ width, height });
|
|
186
|
+
png.data = Buffer.from(data);
|
|
187
|
+
return PNG.sync.write(png);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function analyzeDiffSlot(diffImage, slot) {
|
|
191
|
+
const x1 = clamp(Math.floor(slot.x), 0, diffImage.width);
|
|
192
|
+
const y1 = clamp(Math.floor(slot.y), 0, diffImage.height);
|
|
193
|
+
const x2 = clamp(Math.ceil(slot.x + slot.w), 0, diffImage.width);
|
|
194
|
+
const y2 = clamp(Math.ceil(slot.y + slot.h), 0, diffImage.height);
|
|
195
|
+
|
|
196
|
+
let redCount = 0;
|
|
197
|
+
let blueCount = 0;
|
|
198
|
+
let redSumX = 0;
|
|
199
|
+
let redSumY = 0;
|
|
200
|
+
let blueSumX = 0;
|
|
201
|
+
let blueSumY = 0;
|
|
202
|
+
|
|
203
|
+
for (let y = y1; y < y2; y++) {
|
|
204
|
+
for (let x = x1; x < x2; x++) {
|
|
205
|
+
const cls = diffImage.classData[y * diffImage.width + x];
|
|
206
|
+
if (cls === 2) {
|
|
207
|
+
redCount++;
|
|
208
|
+
redSumX += x + 0.5;
|
|
209
|
+
redSumY += y + 0.5;
|
|
210
|
+
} else if (cls === 3) {
|
|
211
|
+
blueCount++;
|
|
212
|
+
blueSumX += x + 0.5;
|
|
213
|
+
blueSumY += y + 0.5;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
redCount,
|
|
220
|
+
blueCount,
|
|
221
|
+
redCentroid: redCount > 0 ? { x: redSumX / redCount, y: redSumY / redCount } : null,
|
|
222
|
+
blueCentroid: blueCount > 0 ? { x: blueSumX / blueCount, y: blueSumY / blueCount } : null,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* What this file does:
|
|
3
|
+
* Handles the analyzer's side effects after the report has been built.
|
|
4
|
+
*
|
|
5
|
+
* High-level flow:
|
|
6
|
+
* - Optionally writes screenshot and diff artifacts plus report.json to disk.
|
|
7
|
+
* - Renders report output in one of three modes: summary-only, JSON-only, or both.
|
|
8
|
+
* - Keeps file IO and stdout formatting out of the entrypoint and scoring logic.
|
|
9
|
+
*
|
|
10
|
+
* Example input:
|
|
11
|
+
* A finished report object, screenshot buffers, diff data, and CLI mode flags.
|
|
12
|
+
*
|
|
13
|
+
* Example output:
|
|
14
|
+
* Either files on disk like `html.png`, `svg.png`, `diff.png`, `report.json`,
|
|
15
|
+
* or text written to stdout in JSON-only, summary-only, or mixed mode.
|
|
16
|
+
*/
|
|
17
|
+
import fs from "node:fs/promises";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
|
|
20
|
+
import { buildPngBuffer } from "./native-diff.mjs";
|
|
21
|
+
|
|
22
|
+
export async function maybeWriteArtifacts(outputDir, htmlBuffer, svgBuffer, diffImage, report) {
|
|
23
|
+
if (!outputDir) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
28
|
+
const paths = {
|
|
29
|
+
html: path.join(outputDir, "html.png"),
|
|
30
|
+
svg: path.join(outputDir, "svg.png"),
|
|
31
|
+
diff: path.join(outputDir, "diff.png"),
|
|
32
|
+
report: path.join(outputDir, "report.json"),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
await Promise.all([
|
|
36
|
+
fs.writeFile(paths.html, htmlBuffer),
|
|
37
|
+
fs.writeFile(paths.svg, svgBuffer),
|
|
38
|
+
fs.writeFile(paths.diff, buildPngBuffer(diffImage.width, diffImage.height, diffImage.diffData)),
|
|
39
|
+
fs.writeFile(paths.report, JSON.stringify(report, null, 2)),
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
return paths;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function writeReportOutput(stdout, report, options) {
|
|
46
|
+
const summaryBlock = [
|
|
47
|
+
report.summary,
|
|
48
|
+
report.number_summary,
|
|
49
|
+
report.arrow_summary,
|
|
50
|
+
report.participant_label_summary,
|
|
51
|
+
report.participant_stereotype_summary,
|
|
52
|
+
report.participant_icon_summary,
|
|
53
|
+
report.participant_box_summary,
|
|
54
|
+
report.participant_color_summary,
|
|
55
|
+
report.comment_summary,
|
|
56
|
+
report.participant_group_summary,
|
|
57
|
+
report.occurrence_summary,
|
|
58
|
+
report.fragment_divider_summary,
|
|
59
|
+
report.residual_scope_summary,
|
|
60
|
+
].flat().join("\n");
|
|
61
|
+
|
|
62
|
+
if (options.summaryOnly) {
|
|
63
|
+
stdout.write(`${summaryBlock}\n`);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!options.jsonOnly) {
|
|
68
|
+
stdout.write(`${JSON.stringify(report, null, 2)}\n\n`);
|
|
69
|
+
stdout.write(`${summaryBlock}\n`);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
74
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* What this file does:
|
|
3
|
+
* Uses the page's live `#diff-panel canvas` as the analyzer's pixel-diff source.
|
|
4
|
+
*
|
|
5
|
+
* High-level flow:
|
|
6
|
+
* - Renders the compare-case diff into the page's diff panel from captured screenshots.
|
|
7
|
+
* - Reads the panel canvas pixels back into Node.
|
|
8
|
+
* - Classifies panel colors into match, html-only, svg-only, and color-diff classes.
|
|
9
|
+
* - Returns a `diffImage` object compatible with the analyzer's scoring modules.
|
|
10
|
+
*
|
|
11
|
+
* Example input:
|
|
12
|
+
* A Playwright `page` plus HTML/SVG PNG buffers captured from the compare-case roots.
|
|
13
|
+
*
|
|
14
|
+
* Example output:
|
|
15
|
+
* `{ width, height, diffData, classData, stats, badgeText, panelStats }`
|
|
16
|
+
*/
|
|
17
|
+
import { round } from "./geometry.mjs";
|
|
18
|
+
|
|
19
|
+
function classifyPanelPixel(r, g, b) {
|
|
20
|
+
const isBackground = r >= 230 && g >= 230 && b >= 230;
|
|
21
|
+
if (isBackground) {
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
24
|
+
if (r >= 220 && g <= 120 && b <= 160 && r > b) {
|
|
25
|
+
return 2;
|
|
26
|
+
}
|
|
27
|
+
if (b >= 220 && r <= 120 && g <= 120 && b > r) {
|
|
28
|
+
return 3;
|
|
29
|
+
}
|
|
30
|
+
if (r >= 200 && b >= 200 && g <= 140) {
|
|
31
|
+
return 4;
|
|
32
|
+
}
|
|
33
|
+
if (g >= 50 && g >= r && g >= b) {
|
|
34
|
+
return 1;
|
|
35
|
+
}
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function buildDiffImageFromPanel(width, height, rgbaData) {
|
|
40
|
+
const diffData = new Uint8ClampedArray(rgbaData);
|
|
41
|
+
const classData = new Uint8Array(width * height);
|
|
42
|
+
|
|
43
|
+
let matched = 0;
|
|
44
|
+
let htmlOnly = 0;
|
|
45
|
+
let svgOnly = 0;
|
|
46
|
+
let colorDiff = 0;
|
|
47
|
+
|
|
48
|
+
for (let i = 0; i < classData.length; i++) {
|
|
49
|
+
const offset = i * 4;
|
|
50
|
+
const cls = classifyPanelPixel(
|
|
51
|
+
diffData[offset],
|
|
52
|
+
diffData[offset + 1],
|
|
53
|
+
diffData[offset + 2],
|
|
54
|
+
);
|
|
55
|
+
classData[i] = cls;
|
|
56
|
+
if (cls === 1) matched++;
|
|
57
|
+
else if (cls === 2) htmlOnly++;
|
|
58
|
+
else if (cls === 3) svgOnly++;
|
|
59
|
+
else if (cls === 4) colorDiff++;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const total = matched + htmlOnly + svgOnly + colorDiff;
|
|
63
|
+
const posMatched = matched + colorDiff;
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
width,
|
|
67
|
+
height,
|
|
68
|
+
diffData,
|
|
69
|
+
classData,
|
|
70
|
+
stats: {
|
|
71
|
+
matched,
|
|
72
|
+
posMatched,
|
|
73
|
+
total,
|
|
74
|
+
htmlOnly,
|
|
75
|
+
svgOnly,
|
|
76
|
+
colorDiff,
|
|
77
|
+
pixelPct: total > 0 ? round((matched / total) * 100, 2) : 100,
|
|
78
|
+
posPct: total > 0 ? round((posMatched / total) * 100, 2) : 100,
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function renderAndReadDiffPanel(page, htmlBuffer, svgBuffer) {
|
|
84
|
+
const htmlDataUrl = `data:image/png;base64,${htmlBuffer.toString("base64")}`;
|
|
85
|
+
const svgDataUrl = `data:image/png;base64,${svgBuffer.toString("base64")}`;
|
|
86
|
+
|
|
87
|
+
const panel = await page.evaluate(async ({ htmlDataUrl, svgDataUrl }) => {
|
|
88
|
+
if (typeof window.diffFromImages !== "function") {
|
|
89
|
+
throw new Error("window.diffFromImages is not available on compare-case.html");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const panelStats = await window.diffFromImages(htmlDataUrl, svgDataUrl);
|
|
93
|
+
const canvas = document.querySelector("#diff-panel canvas");
|
|
94
|
+
if (!canvas) {
|
|
95
|
+
throw new Error("#diff-panel canvas was not rendered");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const ctx = canvas.getContext("2d");
|
|
99
|
+
const image = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
100
|
+
return {
|
|
101
|
+
width: canvas.width,
|
|
102
|
+
height: canvas.height,
|
|
103
|
+
data: Array.from(image.data),
|
|
104
|
+
badgeText: document.getElementById("match-badge")?.textContent?.trim() || "",
|
|
105
|
+
panelStats,
|
|
106
|
+
};
|
|
107
|
+
}, { htmlDataUrl, svgDataUrl });
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
...buildDiffImageFromPanel(panel.width, panel.height, panel.data),
|
|
111
|
+
badgeText: panel.badgeText,
|
|
112
|
+
panelStats: panel.panelStats,
|
|
113
|
+
};
|
|
114
|
+
}
|