@invarn/cibuild 2.3.8 → 2.4.0
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/dist/cli.cjs +54 -37
- package/dist/src/cli.js +8 -0
- package/dist/src/commands/fidelity-trim.d.ts +13 -0
- package/dist/src/commands/fidelity-trim.d.ts.map +1 -0
- package/dist/src/commands/fidelity-trim.js +21 -0
- package/dist/src/commands/fidelity-trim.test.d.ts +8 -0
- package/dist/src/commands/fidelity-trim.test.d.ts.map +1 -0
- package/dist/src/commands/fidelity-trim.test.js +79 -0
- package/dist/src/yaml/steps/render-post-processor.d.ts +52 -32
- package/dist/src/yaml/steps/render-post-processor.d.ts.map +1 -1
- package/dist/src/yaml/steps/render-post-processor.js +104 -205
- package/dist/src/yaml/steps/render-post-processor.test.d.ts +7 -10
- package/dist/src/yaml/steps/render-post-processor.test.d.ts.map +1 -1
- package/dist/src/yaml/steps/render-post-processor.test.js +80 -183
- package/dist/src/yaml/steps/trim-align-corpus.test.d.ts +28 -0
- package/dist/src/yaml/steps/trim-align-corpus.test.d.ts.map +1 -0
- package/dist/src/yaml/steps/trim-align-corpus.test.js +383 -0
- package/dist/src/yaml/steps/trim-align.d.ts +85 -0
- package/dist/src/yaml/steps/trim-align.d.ts.map +1 -0
- package/dist/src/yaml/steps/trim-align.js +348 -0
- package/dist/src/yaml/steps/trim-align.test.d.ts +11 -0
- package/dist/src/yaml/steps/trim-align.test.d.ts.map +1 -0
- package/dist/src/yaml/steps/trim-align.test.js +258 -0
- package/dist/src/yaml/steps/ui-fidelity-preview-android.d.ts +1 -1
- package/dist/src/yaml/steps/ui-fidelity-preview-android.d.ts.map +1 -1
- package/dist/src/yaml/steps/ui-fidelity-preview-android.js +9 -0
- package/dist/src/yaml/steps/ui-fidelity-preview-android.test.js +4 -3
- package/dist/src/yaml/steps/ui-fidelity-preview.d.ts.map +1 -1
- package/dist/src/yaml/steps/ui-fidelity-preview.js +9 -0
- package/dist/src/yaml/steps/ui-fidelity-preview.test.js +13 -3
- package/dist/src/yaml/steps/ui-fidelity-render-android.d.ts.map +1 -1
- package/dist/src/yaml/steps/ui-fidelity-render-android.js +7 -16
- package/dist/src/yaml/steps/ui-fidelity-render-android.test.js +9 -9
- package/dist/src/yaml/steps/ui-fidelity-render.js +1 -1
- package/dist/src/yaml/steps/ui-fidelity-render.test.js +25 -20
- package/package.json +3 -1
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure-JS trim/align helper — the CoreGraphics port (fidelity-linux 06).
|
|
3
|
+
*
|
|
4
|
+
* This is a PORT of the embedded Swift helper that used to live in
|
|
5
|
+
* render-post-processor.ts (`TRIM_HELPER_SWIFT_SOURCE`), not a redesign: the
|
|
6
|
+
* bounding-box semantics (alpha pass, corner-derived uniform background,
|
|
7
|
+
* tolerance 4, never-crop-to-nothing fallback), the fit-pad geometry
|
|
8
|
+
* (aspect-preserving scale, centered, transparent letterbox) and the TRIMDIMS
|
|
9
|
+
* contract are byte-for-byte the Swift behavior. CoreGraphics/ImageIO do not
|
|
10
|
+
* exist on Linux, so the pixel work moved into the ci binary itself (pngjs is
|
|
11
|
+
* pure JS and bundles via bundle.mjs) — iOS and Android now run the SAME bytes,
|
|
12
|
+
* and output pixels can only change with our lockfile, never with a runner OS
|
|
13
|
+
* upgrade.
|
|
14
|
+
*
|
|
15
|
+
* Port-fidelity notes, load-bearing for the corpus gate:
|
|
16
|
+
* - The Swift helper ANALYZED pixels through a premultiplied-alpha CGContext
|
|
17
|
+
* but CROPPED the original (straight-alpha) image. We mirror both: the
|
|
18
|
+
* bounding box is computed over premultiplied values, the trimmed output is
|
|
19
|
+
* a raw copy of the source pixels.
|
|
20
|
+
* - The Swift aligned output was drawn through a premultiplied context and
|
|
21
|
+
* unpremultiplied on PNG encode, which is lossy on semi-transparent pixels;
|
|
22
|
+
* we reproduce that roundtrip. Where CoreGraphics RESAMPLED (trimmed size ≠
|
|
23
|
+
* reference size), its proprietary `.high` interpolation is not bit-exactly
|
|
24
|
+
* reproducible — we use bilinear filtering in premultiplied space. The
|
|
25
|
+
* trimmed image and TRIMDIMS never depend on resampling and are the
|
|
26
|
+
* pixel-identical surface the corpus gate enforces.
|
|
27
|
+
*/
|
|
28
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
29
|
+
import { PNG } from 'pngjs';
|
|
30
|
+
/** Mirror of the Swift helper's exit codes. */
|
|
31
|
+
export const TRIM_EXIT_USAGE = 64;
|
|
32
|
+
export const TRIM_EXIT_UNREADABLE = 65;
|
|
33
|
+
export const TRIM_EXIT_NO_CONTENT = 66;
|
|
34
|
+
export const TRIM_EXIT_WRITE_FAILED = 68;
|
|
35
|
+
// Tight tolerance: exact solid fills match, near-colored content does not.
|
|
36
|
+
// (Same constant, same meaning as the Swift helper.)
|
|
37
|
+
const TOLERANCE = 4;
|
|
38
|
+
/**
|
|
39
|
+
* Premultiply one straight-alpha channel value, matching the rounding
|
|
40
|
+
* CoreGraphics/vImage applies when drawing into a premultiplied context:
|
|
41
|
+
* (v * a + 127) / 255, truncated.
|
|
42
|
+
*/
|
|
43
|
+
function premultiply(value, alpha) {
|
|
44
|
+
return ((value * alpha + 127) / 255) | 0;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Un-premultiply one channel value, matching ImageIO's PNG encode of a
|
|
48
|
+
* premultiplied bitmap. Transparent pixels collapse to zero (which is also
|
|
49
|
+
* why the Swift aligned output zeroes the RGB of fully-transparent pixels).
|
|
50
|
+
*/
|
|
51
|
+
function unpremultiply(value, alpha) {
|
|
52
|
+
if (alpha === 0)
|
|
53
|
+
return 0;
|
|
54
|
+
const v = Math.round((value * 255) / alpha);
|
|
55
|
+
return v > 255 ? 255 : v;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Bounding box of content: pixels that are neither transparent nor part of a
|
|
59
|
+
* uniform solid background. Two passes, because the renderer forces the view
|
|
60
|
+
* into a fixed device frame whose margins are transparent: (1) bound the
|
|
61
|
+
* non-transparent pixels, then (2) detect a uniform opaque background from the
|
|
62
|
+
* CORNERS OF THAT OPAQUE BOX — not the raw frame, whose corners are the
|
|
63
|
+
* transparent device margin — and bound everything that is not that
|
|
64
|
+
* background. A wrapper that fills the canvas with .background(.white) is thus
|
|
65
|
+
* trimmed to its real content. When the opaque corners disagree, only
|
|
66
|
+
* transparency is trimmed. Top-left image coordinates.
|
|
67
|
+
*
|
|
68
|
+
* `data` is straight-alpha RGBA8 (as pngjs decodes); the color comparisons run
|
|
69
|
+
* on premultiplied values to mirror the Swift helper's analysis context.
|
|
70
|
+
*/
|
|
71
|
+
export function contentBoundingBox(data, width, height) {
|
|
72
|
+
if (width === 0 || height === 0)
|
|
73
|
+
return null;
|
|
74
|
+
const bytesPerRow = width * 4;
|
|
75
|
+
// Pass 1: bounding box of all non-transparent pixels.
|
|
76
|
+
let aMinX = width;
|
|
77
|
+
let aMinY = height;
|
|
78
|
+
let aMaxX = -1;
|
|
79
|
+
let aMaxY = -1;
|
|
80
|
+
for (let y = 0; y < height; y++) {
|
|
81
|
+
const rowBase = y * bytesPerRow;
|
|
82
|
+
for (let x = 0; x < width; x++) {
|
|
83
|
+
if (data[rowBase + x * 4 + 3] > 0) {
|
|
84
|
+
if (x < aMinX)
|
|
85
|
+
aMinX = x;
|
|
86
|
+
if (x > aMaxX)
|
|
87
|
+
aMaxX = x;
|
|
88
|
+
if (y < aMinY)
|
|
89
|
+
aMinY = y;
|
|
90
|
+
if (y > aMaxY)
|
|
91
|
+
aMaxY = y;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (aMaxX < 0)
|
|
96
|
+
return null;
|
|
97
|
+
const opaqueBox = {
|
|
98
|
+
x: aMinX,
|
|
99
|
+
y: aMinY,
|
|
100
|
+
width: aMaxX - aMinX + 1,
|
|
101
|
+
height: aMaxY - aMinY + 1,
|
|
102
|
+
};
|
|
103
|
+
// Premultiplied RGBA of one pixel — the analysis view of the image.
|
|
104
|
+
function sample(x, y) {
|
|
105
|
+
const i = y * bytesPerRow + x * 4;
|
|
106
|
+
const a = data[i + 3];
|
|
107
|
+
return [
|
|
108
|
+
premultiply(data[i], a),
|
|
109
|
+
premultiply(data[i + 1], a),
|
|
110
|
+
premultiply(data[i + 2], a),
|
|
111
|
+
a,
|
|
112
|
+
];
|
|
113
|
+
}
|
|
114
|
+
// Detect a uniform opaque background from the corners of the opaque box.
|
|
115
|
+
// As in the Swift helper, every corner is compared against the FIRST corner
|
|
116
|
+
// (background only ever holds corners[0] or null).
|
|
117
|
+
const corners = [
|
|
118
|
+
sample(aMinX, aMinY),
|
|
119
|
+
sample(aMaxX, aMinY),
|
|
120
|
+
sample(aMinX, aMaxY),
|
|
121
|
+
sample(aMaxX, aMaxY),
|
|
122
|
+
];
|
|
123
|
+
let background = corners[0];
|
|
124
|
+
for (const c of corners) {
|
|
125
|
+
if (background !== null) {
|
|
126
|
+
if (c[3] === 0 ||
|
|
127
|
+
Math.abs(c[0] - background[0]) > TOLERANCE ||
|
|
128
|
+
Math.abs(c[1] - background[1]) > TOLERANCE ||
|
|
129
|
+
Math.abs(c[2] - background[2]) > TOLERANCE ||
|
|
130
|
+
Math.abs(c[3] - background[3]) > TOLERANCE) {
|
|
131
|
+
background = null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// No uniform background: the opaque box is the content.
|
|
136
|
+
if (background === null)
|
|
137
|
+
return opaqueBox;
|
|
138
|
+
const bg = background;
|
|
139
|
+
// Pass 2: within the opaque box, bound everything that is not the background.
|
|
140
|
+
let minX = aMaxX + 1;
|
|
141
|
+
let minY = aMaxY + 1;
|
|
142
|
+
let maxX = aMinX - 1;
|
|
143
|
+
let maxY = aMinY - 1;
|
|
144
|
+
for (let y = aMinY; y <= aMaxY; y++) {
|
|
145
|
+
const rowBase = y * bytesPerRow;
|
|
146
|
+
for (let x = aMinX; x <= aMaxX; x++) {
|
|
147
|
+
const idx = rowBase + x * 4;
|
|
148
|
+
const a = data[idx + 3];
|
|
149
|
+
if (a === 0)
|
|
150
|
+
continue;
|
|
151
|
+
const r = premultiply(data[idx], a);
|
|
152
|
+
const g = premultiply(data[idx + 1], a);
|
|
153
|
+
const b = premultiply(data[idx + 2], a);
|
|
154
|
+
if (Math.abs(r - bg[0]) <= TOLERANCE &&
|
|
155
|
+
Math.abs(g - bg[1]) <= TOLERANCE &&
|
|
156
|
+
Math.abs(b - bg[2]) <= TOLERANCE &&
|
|
157
|
+
Math.abs(a - bg[3]) <= TOLERANCE) {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (x < minX)
|
|
161
|
+
minX = x;
|
|
162
|
+
if (x > maxX)
|
|
163
|
+
maxX = x;
|
|
164
|
+
if (y < minY)
|
|
165
|
+
minY = y;
|
|
166
|
+
if (y > maxY)
|
|
167
|
+
maxY = y;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// All background, no distinct content: keep the opaque box, never crop to nothing.
|
|
171
|
+
if (maxX < minX)
|
|
172
|
+
return opaqueBox;
|
|
173
|
+
return { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 };
|
|
174
|
+
}
|
|
175
|
+
/** Raw pixel copy of a region — the Swift `image.cropping(to:)` equivalent. */
|
|
176
|
+
export function cropImage(src, box) {
|
|
177
|
+
const out = new PNG({ width: box.width, height: box.height });
|
|
178
|
+
for (let y = 0; y < box.height; y++) {
|
|
179
|
+
const srcStart = ((box.y + y) * src.width + box.x) * 4;
|
|
180
|
+
src.data.copy(out.data, y * box.width * 4, srcStart, srcStart + box.width * 4);
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Draw `src` centered on a transparent reference-sized canvas, scaled to fit
|
|
186
|
+
* while PRESERVING its aspect ratio. Unlike a stretch-to-fit (which distorts
|
|
187
|
+
* proportions when the aspect ratios differ), this keeps the rendered shape
|
|
188
|
+
* honest: an aspect mismatch shows as transparent letterbox/pillarbox padding
|
|
189
|
+
* rather than a squished image, while the canvas stays exactly reference-sized
|
|
190
|
+
* so the pair still overlays 1:1.
|
|
191
|
+
*
|
|
192
|
+
* Compositing happens in premultiplied space with an unpremultiply on the way
|
|
193
|
+
* out, mirroring the Swift context roundtrip. Resampling is bilinear (see the
|
|
194
|
+
* module docblock for why CG's `.high` filter is not reproducible).
|
|
195
|
+
*/
|
|
196
|
+
export function fitPad(src, width, height) {
|
|
197
|
+
if (width <= 0 || height <= 0)
|
|
198
|
+
return null;
|
|
199
|
+
const out = new PNG({ width, height }); // zero-initialized: transparent canvas
|
|
200
|
+
const scale = Math.min(width / src.width, height / src.height);
|
|
201
|
+
const drawW = src.width * scale;
|
|
202
|
+
const drawH = src.height * scale;
|
|
203
|
+
const originX = (width - drawW) / 2;
|
|
204
|
+
const originY = (height - drawH) / 2;
|
|
205
|
+
// Premultiplied source, sampled by the filter below.
|
|
206
|
+
const pre = Buffer.alloc(src.width * src.height * 4);
|
|
207
|
+
for (let i = 0; i < src.width * src.height * 4; i += 4) {
|
|
208
|
+
const a = src.data[i + 3];
|
|
209
|
+
pre[i] = premultiply(src.data[i], a);
|
|
210
|
+
pre[i + 1] = premultiply(src.data[i + 1], a);
|
|
211
|
+
pre[i + 2] = premultiply(src.data[i + 2], a);
|
|
212
|
+
pre[i + 3] = a;
|
|
213
|
+
}
|
|
214
|
+
const identity = scale === 1 && Number.isInteger(originX) && Number.isInteger(originY);
|
|
215
|
+
for (let dy = 0; dy < height; dy++) {
|
|
216
|
+
for (let dx = 0; dx < width; dx++) {
|
|
217
|
+
const cx = dx + 0.5;
|
|
218
|
+
const cy = dy + 0.5;
|
|
219
|
+
if (cx < originX || cx > originX + drawW || cy < originY || cy > originY + drawH) {
|
|
220
|
+
continue; // transparent padding
|
|
221
|
+
}
|
|
222
|
+
const di = (dy * width + dx) * 4;
|
|
223
|
+
if (identity) {
|
|
224
|
+
// 1:1 placement — no filtering, but still the premultiply roundtrip
|
|
225
|
+
// the Swift context applied.
|
|
226
|
+
const si = ((dy - originY) * src.width + (dx - originX)) * 4;
|
|
227
|
+
const a = pre[si + 3];
|
|
228
|
+
out.data[di] = unpremultiply(pre[si], a);
|
|
229
|
+
out.data[di + 1] = unpremultiply(pre[si + 1], a);
|
|
230
|
+
out.data[di + 2] = unpremultiply(pre[si + 2], a);
|
|
231
|
+
out.data[di + 3] = a;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
// Bilinear sample at the source-space position of this dest pixel center,
|
|
235
|
+
// clamped to the source edges.
|
|
236
|
+
const sx = (cx - originX) / scale - 0.5;
|
|
237
|
+
const sy = (cy - originY) / scale - 0.5;
|
|
238
|
+
const x0 = Math.floor(sx);
|
|
239
|
+
const y0 = Math.floor(sy);
|
|
240
|
+
const fx = sx - x0;
|
|
241
|
+
const fy = sy - y0;
|
|
242
|
+
const clampX = (v) => (v < 0 ? 0 : v >= src.width ? src.width - 1 : v);
|
|
243
|
+
const clampY = (v) => (v < 0 ? 0 : v >= src.height ? src.height - 1 : v);
|
|
244
|
+
const i00 = (clampY(y0) * src.width + clampX(x0)) * 4;
|
|
245
|
+
const i10 = (clampY(y0) * src.width + clampX(x0 + 1)) * 4;
|
|
246
|
+
const i01 = (clampY(y0 + 1) * src.width + clampX(x0)) * 4;
|
|
247
|
+
const i11 = (clampY(y0 + 1) * src.width + clampX(x0 + 1)) * 4;
|
|
248
|
+
const w00 = (1 - fx) * (1 - fy);
|
|
249
|
+
const w10 = fx * (1 - fy);
|
|
250
|
+
const w01 = (1 - fx) * fy;
|
|
251
|
+
const w11 = fx * fy;
|
|
252
|
+
const blend = (offset) => pre[i00 + offset] * w00 +
|
|
253
|
+
pre[i10 + offset] * w10 +
|
|
254
|
+
pre[i01 + offset] * w01 +
|
|
255
|
+
pre[i11 + offset] * w11;
|
|
256
|
+
const a = Math.round(blend(3));
|
|
257
|
+
out.data[di] = unpremultiply(Math.round(blend(0)), a);
|
|
258
|
+
out.data[di + 1] = unpremultiply(Math.round(blend(1)), a);
|
|
259
|
+
out.data[di + 2] = unpremultiply(Math.round(blend(2)), a);
|
|
260
|
+
out.data[di + 3] = a;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return out;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* The helper entry point, argv-compatible with the retired Swift helper:
|
|
267
|
+
* `[in.png, out.png]` or `[in.png, out.png, aligned.png, reference.png]`.
|
|
268
|
+
* Crops the input to its content bounding box, optionally writes a
|
|
269
|
+
* reference-sized aligned copy (best-effort), and reports a TRIMDIMS line.
|
|
270
|
+
* Same exit codes, same stdout/stderr shape as the Swift helper.
|
|
271
|
+
*/
|
|
272
|
+
export function runTrimAlign(argv) {
|
|
273
|
+
if (argv.length < 2) {
|
|
274
|
+
return {
|
|
275
|
+
status: TRIM_EXIT_USAGE,
|
|
276
|
+
stdout: '',
|
|
277
|
+
stderr: 'usage: fidelity-trim <in.png> <out.png> [<aligned.png> <reference.png>]',
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
const [inputPath, outputPath] = argv;
|
|
281
|
+
let image;
|
|
282
|
+
try {
|
|
283
|
+
image = PNG.sync.read(readFileSync(inputPath));
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
return {
|
|
287
|
+
status: TRIM_EXIT_UNREADABLE,
|
|
288
|
+
stdout: '',
|
|
289
|
+
stderr: 'could not read image at ' + inputPath,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
const box = contentBoundingBox(image.data, image.width, image.height);
|
|
293
|
+
if (box === null) {
|
|
294
|
+
return {
|
|
295
|
+
status: TRIM_EXIT_NO_CONTENT,
|
|
296
|
+
stdout: '',
|
|
297
|
+
stderr: 'no content to trim (image is uniform)',
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
const cropped = cropImage(image, box);
|
|
301
|
+
try {
|
|
302
|
+
writeFileSync(outputPath, PNG.sync.write(cropped));
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
return {
|
|
306
|
+
status: TRIM_EXIT_WRITE_FAILED,
|
|
307
|
+
stdout: '',
|
|
308
|
+
stderr: 'could not write trimmed PNG to ' + outputPath,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
// Optional aligned output: the trimmed image fit onto a reference-sized
|
|
312
|
+
// canvas, preserving aspect ratio (transparent letterbox/pillarbox padding
|
|
313
|
+
// when aspect ratios differ). A reference-sized, undistorted overlay pair.
|
|
314
|
+
// Best-effort; dimensions are reported either way.
|
|
315
|
+
let referenceWidth = 0;
|
|
316
|
+
let referenceHeight = 0;
|
|
317
|
+
if (argv.length >= 4) {
|
|
318
|
+
const alignedPath = argv[2];
|
|
319
|
+
const referencePath = argv[3];
|
|
320
|
+
let reference = null;
|
|
321
|
+
try {
|
|
322
|
+
reference = PNG.sync.read(readFileSync(referencePath));
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
reference = null;
|
|
326
|
+
}
|
|
327
|
+
if (reference !== null) {
|
|
328
|
+
referenceWidth = reference.width;
|
|
329
|
+
referenceHeight = reference.height;
|
|
330
|
+
const aligned = fitPad(cropped, referenceWidth, referenceHeight);
|
|
331
|
+
if (aligned !== null) {
|
|
332
|
+
try {
|
|
333
|
+
writeFileSync(alignedPath, PNG.sync.write(aligned));
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
// best effort, as in the Swift helper
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
// Report: rendered (trimmed) px, reference px (0 0 when unavailable), then
|
|
342
|
+
// the pre-trim source px so the caller can log how much margin was removed.
|
|
343
|
+
const stdout = 'TRIMDIMS ' +
|
|
344
|
+
[box.width, box.height, referenceWidth, referenceHeight, image.width, image.height].join(' ') +
|
|
345
|
+
'\n';
|
|
346
|
+
return { status: 0, stdout, stderr: '' };
|
|
347
|
+
}
|
|
348
|
+
//# sourceMappingURL=trim-align.js.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the pure-JS trim/align helper (the CoreGraphics port).
|
|
3
|
+
*
|
|
4
|
+
* These exercise the pixel logic directly with pngjs-crafted images — no
|
|
5
|
+
* `swift`, no real toolchain — so the bounding-box/crop/pad semantics that
|
|
6
|
+
* previously needed CIBUILD_UI_FIDELITY_REAL_SWIFT=1 now run in every CI pass.
|
|
7
|
+
* The Swift-vs-JS equivalence itself is guarded by the corpus gate in
|
|
8
|
+
* trim-align-corpus.test.ts (real toolchain, gated).
|
|
9
|
+
*/
|
|
10
|
+
export {};
|
|
11
|
+
//# sourceMappingURL=trim-align.test.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"trim-align.test.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/trim-align.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG"}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the pure-JS trim/align helper (the CoreGraphics port).
|
|
3
|
+
*
|
|
4
|
+
* These exercise the pixel logic directly with pngjs-crafted images — no
|
|
5
|
+
* `swift`, no real toolchain — so the bounding-box/crop/pad semantics that
|
|
6
|
+
* previously needed CIBUILD_UI_FIDELITY_REAL_SWIFT=1 now run in every CI pass.
|
|
7
|
+
* The Swift-vs-JS equivalence itself is guarded by the corpus gate in
|
|
8
|
+
* trim-align-corpus.test.ts (real toolchain, gated).
|
|
9
|
+
*/
|
|
10
|
+
import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { PNG } from 'pngjs';
|
|
14
|
+
import { describe, test, expect, afterAll } from '@jest/globals';
|
|
15
|
+
import { runTrimAlign } from './trim-align.js';
|
|
16
|
+
const RED = { r: 255, g: 0, b: 0, a: 255 };
|
|
17
|
+
const BLUE = { r: 0, g: 0, b: 255, a: 255 };
|
|
18
|
+
const WHITE = { r: 255, g: 255, b: 255, a: 255 };
|
|
19
|
+
const PINK = { r: 255, g: 240, b: 238, a: 255 };
|
|
20
|
+
/** Paint opaque rects onto a transparent canvas, top-left coordinates. */
|
|
21
|
+
function craftPng(w, h, rects) {
|
|
22
|
+
const png = new PNG({ width: w, height: h });
|
|
23
|
+
for (const rect of rects) {
|
|
24
|
+
for (let y = rect.y; y < rect.y + rect.h; y++) {
|
|
25
|
+
for (let x = rect.x; x < rect.x + rect.w; x++) {
|
|
26
|
+
const i = (y * w + x) * 4;
|
|
27
|
+
png.data[i] = rect.r;
|
|
28
|
+
png.data[i + 1] = rect.g;
|
|
29
|
+
png.data[i + 2] = rect.b;
|
|
30
|
+
png.data[i + 3] = rect.a;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return PNG.sync.write(png);
|
|
35
|
+
}
|
|
36
|
+
function pngSize(filePath) {
|
|
37
|
+
const buf = readFileSync(filePath);
|
|
38
|
+
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
|
39
|
+
}
|
|
40
|
+
function pixelAt(filePath, x, y) {
|
|
41
|
+
const png = PNG.sync.read(readFileSync(filePath));
|
|
42
|
+
const i = (y * png.width + x) * 4;
|
|
43
|
+
return [png.data[i], png.data[i + 1], png.data[i + 2], png.data[i + 3]];
|
|
44
|
+
}
|
|
45
|
+
const dirs = [];
|
|
46
|
+
function scene() {
|
|
47
|
+
const dir = mkdtempSync(join(tmpdir(), 'trim-align-js-'));
|
|
48
|
+
dirs.push(dir);
|
|
49
|
+
return dir;
|
|
50
|
+
}
|
|
51
|
+
afterAll(() => {
|
|
52
|
+
for (const dir of dirs) {
|
|
53
|
+
try {
|
|
54
|
+
rmSync(dir, { recursive: true, force: true });
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// best effort
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
// ---- trim ----------------------------------------------------------------------
|
|
62
|
+
describe('runTrimAlign — trim', () => {
|
|
63
|
+
test('trims a transparent frame down to its single content rectangle', () => {
|
|
64
|
+
const dir = scene();
|
|
65
|
+
const input = join(dir, 'in.png');
|
|
66
|
+
writeFileSync(input, craftPng(200, 200, [{ x: 30, y: 50, w: 60, h: 40, ...RED }]));
|
|
67
|
+
const out = join(dir, 'out.png');
|
|
68
|
+
const result = runTrimAlign([input, out]);
|
|
69
|
+
expect(result.stderr).toBe('');
|
|
70
|
+
expect(result.status).toBe(0);
|
|
71
|
+
expect(pngSize(out)).toEqual({ width: 60, height: 40 });
|
|
72
|
+
// The crop is a raw pixel copy of the content region.
|
|
73
|
+
expect(pixelAt(out, 0, 0)).toEqual([255, 0, 0, 255]);
|
|
74
|
+
expect(pixelAt(out, 59, 39)).toEqual([255, 0, 0, 255]);
|
|
75
|
+
// TRIMDIMS: rendered (trimmed) px, reference px (0 0 without one), source px.
|
|
76
|
+
expect(result.stdout.trim()).toBe('TRIMDIMS 60 40 0 0 200 200');
|
|
77
|
+
});
|
|
78
|
+
test('crops an opaque uniform-background wrapper to its content card', () => {
|
|
79
|
+
const dir = scene();
|
|
80
|
+
// 420x320 transparent canvas, 375x200 white wrapper, 343x168 pink card.
|
|
81
|
+
const input = join(dir, 'in.png');
|
|
82
|
+
writeFileSync(input, craftPng(420, 320, [
|
|
83
|
+
{ x: 20, y: 40, w: 375, h: 200, ...WHITE },
|
|
84
|
+
{ x: 36, y: 56, w: 343, h: 168, ...PINK },
|
|
85
|
+
]));
|
|
86
|
+
const out = join(dir, 'out.png');
|
|
87
|
+
const result = runTrimAlign([input, out]);
|
|
88
|
+
expect(result.status).toBe(0);
|
|
89
|
+
// Lands on the content card, NOT the white wrapper (375x200) or the raw
|
|
90
|
+
// canvas (420x320).
|
|
91
|
+
expect(pngSize(out)).toEqual({ width: 343, height: 168 });
|
|
92
|
+
});
|
|
93
|
+
test('trims only transparency when the opaque corners disagree (fallback)', () => {
|
|
94
|
+
const dir = scene();
|
|
95
|
+
// Two differently-colored halves: the opaque bounding box is 80x80 but its
|
|
96
|
+
// corners are not one uniform color, so no background is detected and only
|
|
97
|
+
// the transparent margin is removed.
|
|
98
|
+
const input = join(dir, 'in.png');
|
|
99
|
+
writeFileSync(input, craftPng(120, 120, [
|
|
100
|
+
{ x: 20, y: 20, w: 40, h: 80, ...RED },
|
|
101
|
+
{ x: 60, y: 20, w: 40, h: 80, ...BLUE },
|
|
102
|
+
]));
|
|
103
|
+
const out = join(dir, 'out.png');
|
|
104
|
+
const result = runTrimAlign([input, out]);
|
|
105
|
+
expect(result.status).toBe(0);
|
|
106
|
+
expect(pngSize(out)).toEqual({ width: 80, height: 80 });
|
|
107
|
+
});
|
|
108
|
+
test('keeps a fully-opaque uniform image whole (never crops to nothing)', () => {
|
|
109
|
+
const dir = scene();
|
|
110
|
+
const input = join(dir, 'in.png');
|
|
111
|
+
writeFileSync(input, craftPng(64, 48, [{ x: 0, y: 0, w: 64, h: 48, ...WHITE }]));
|
|
112
|
+
const out = join(dir, 'out.png');
|
|
113
|
+
const result = runTrimAlign([input, out]);
|
|
114
|
+
// All four corners agree (uniform background) and pass 2 finds no distinct
|
|
115
|
+
// content — the opaque box (the whole image) is kept, exit 0.
|
|
116
|
+
expect(result.status).toBe(0);
|
|
117
|
+
expect(pngSize(out)).toEqual({ width: 64, height: 48 });
|
|
118
|
+
expect(result.stdout.trim()).toBe('TRIMDIMS 64 48 0 0 64 48');
|
|
119
|
+
});
|
|
120
|
+
test('is a no-op crop when content touches every edge', () => {
|
|
121
|
+
const dir = scene();
|
|
122
|
+
// Content fills the canvas with differing corner colors: nothing to trim.
|
|
123
|
+
const input = join(dir, 'in.png');
|
|
124
|
+
writeFileSync(input, craftPng(50, 30, [
|
|
125
|
+
{ x: 0, y: 0, w: 25, h: 30, ...RED },
|
|
126
|
+
{ x: 25, y: 0, w: 25, h: 30, ...BLUE },
|
|
127
|
+
]));
|
|
128
|
+
const out = join(dir, 'out.png');
|
|
129
|
+
const result = runTrimAlign([input, out]);
|
|
130
|
+
expect(result.status).toBe(0);
|
|
131
|
+
expect(pngSize(out)).toEqual({ width: 50, height: 30 });
|
|
132
|
+
// Raw copy: pixels survive untouched.
|
|
133
|
+
expect(pixelAt(out, 0, 0)).toEqual([255, 0, 0, 255]);
|
|
134
|
+
expect(pixelAt(out, 49, 29)).toEqual([0, 0, 255, 255]);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
// ---- align ---------------------------------------------------------------------
|
|
138
|
+
describe('runTrimAlign — aligned output', () => {
|
|
139
|
+
test('writes a reference-sized aligned image and reports all three dimensions', () => {
|
|
140
|
+
const dir = scene();
|
|
141
|
+
const input = join(dir, 'in.png');
|
|
142
|
+
writeFileSync(input, craftPng(200, 200, [{ x: 30, y: 50, w: 60, h: 40, ...RED }]));
|
|
143
|
+
const reference = join(dir, 'ref.png');
|
|
144
|
+
writeFileSync(reference, craftPng(100, 50, [{ x: 0, y: 0, w: 100, h: 50, ...BLUE }]));
|
|
145
|
+
const out = join(dir, 'out.png');
|
|
146
|
+
const aligned = join(dir, 'aligned.png');
|
|
147
|
+
const result = runTrimAlign([input, out, aligned, reference]);
|
|
148
|
+
expect(result.status).toBe(0);
|
|
149
|
+
// The trimmed render is the content; the aligned copy is fit onto a
|
|
150
|
+
// reference-sized canvas (preserving aspect) for a 1:1 overlay.
|
|
151
|
+
expect(pngSize(out)).toEqual({ width: 60, height: 40 });
|
|
152
|
+
expect(pngSize(aligned)).toEqual({ width: 100, height: 50 });
|
|
153
|
+
expect(result.stdout.trim()).toBe('TRIMDIMS 60 40 100 50 200 200');
|
|
154
|
+
});
|
|
155
|
+
test('fits a wide render into a square reference without distortion (transparent letterbox)', () => {
|
|
156
|
+
const dir = scene();
|
|
157
|
+
// Content fills its canvas: a 2:1 render (80x40), all opaque red.
|
|
158
|
+
const input = join(dir, 'in.png');
|
|
159
|
+
writeFileSync(input, craftPng(80, 40, [{ x: 0, y: 0, w: 80, h: 40, ...RED }]));
|
|
160
|
+
// Square 1:1 reference (50x50) — a deliberately different aspect ratio.
|
|
161
|
+
const reference = join(dir, 'ref.png');
|
|
162
|
+
writeFileSync(reference, craftPng(50, 50, [{ x: 0, y: 0, w: 50, h: 50, ...BLUE }]));
|
|
163
|
+
const out = join(dir, 'out.png');
|
|
164
|
+
const aligned = join(dir, 'aligned.png');
|
|
165
|
+
runTrimAlign([input, out, aligned, reference]);
|
|
166
|
+
// Aligned stays exactly reference-sized (overlay pair holds).
|
|
167
|
+
expect(pngSize(aligned)).toEqual({ width: 50, height: 50 });
|
|
168
|
+
// Aspect preserved: the 2:1 content fits by width (50x25) and is centered,
|
|
169
|
+
// so both letterbox bands are transparent while the center is opaque
|
|
170
|
+
// content. A stretch-to-fit would make every one of these opaque.
|
|
171
|
+
expect(pixelAt(aligned, 25, 3)[3]).toBe(0); // top band
|
|
172
|
+
expect(pixelAt(aligned, 25, 46)[3]).toBe(0); // bottom band
|
|
173
|
+
expect(pixelAt(aligned, 25, 25)[3]).toBe(255); // content (center)
|
|
174
|
+
});
|
|
175
|
+
test('upscales into a larger reference and downscales into a smaller one', () => {
|
|
176
|
+
const dir = scene();
|
|
177
|
+
const input = join(dir, 'in.png');
|
|
178
|
+
writeFileSync(input, craftPng(40, 40, [{ x: 0, y: 0, w: 40, h: 40, ...RED }]));
|
|
179
|
+
const out = join(dir, 'out.png');
|
|
180
|
+
// Reference smaller than the render: content is downscaled to fit.
|
|
181
|
+
const smallRef = join(dir, 'ref-small.png');
|
|
182
|
+
writeFileSync(smallRef, craftPng(20, 20, [{ x: 0, y: 0, w: 20, h: 20, ...BLUE }]));
|
|
183
|
+
const alignedSmall = join(dir, 'aligned-small.png');
|
|
184
|
+
let result = runTrimAlign([input, out, alignedSmall, smallRef]);
|
|
185
|
+
expect(result.status).toBe(0);
|
|
186
|
+
expect(pngSize(alignedSmall)).toEqual({ width: 20, height: 20 });
|
|
187
|
+
expect(pixelAt(alignedSmall, 10, 10)).toEqual([255, 0, 0, 255]);
|
|
188
|
+
// Reference larger than the render: content is upscaled to fit.
|
|
189
|
+
const bigRef = join(dir, 'ref-big.png');
|
|
190
|
+
writeFileSync(bigRef, craftPng(80, 80, [{ x: 0, y: 0, w: 80, h: 80, ...BLUE }]));
|
|
191
|
+
const alignedBig = join(dir, 'aligned-big.png');
|
|
192
|
+
result = runTrimAlign([input, out, alignedBig, bigRef]);
|
|
193
|
+
expect(result.status).toBe(0);
|
|
194
|
+
expect(pngSize(alignedBig)).toEqual({ width: 80, height: 80 });
|
|
195
|
+
expect(pixelAt(alignedBig, 40, 40)).toEqual([255, 0, 0, 255]);
|
|
196
|
+
});
|
|
197
|
+
test('an identity-size reference yields a pixel-identical aligned copy for opaque content', () => {
|
|
198
|
+
const dir = scene();
|
|
199
|
+
const input = join(dir, 'in.png');
|
|
200
|
+
writeFileSync(input, craftPng(30, 20, [
|
|
201
|
+
{ x: 0, y: 0, w: 15, h: 20, ...RED },
|
|
202
|
+
{ x: 15, y: 0, w: 15, h: 20, ...BLUE },
|
|
203
|
+
]));
|
|
204
|
+
const reference = join(dir, 'ref.png');
|
|
205
|
+
writeFileSync(reference, craftPng(30, 20, [{ x: 0, y: 0, w: 30, h: 20, ...WHITE }]));
|
|
206
|
+
const out = join(dir, 'out.png');
|
|
207
|
+
const aligned = join(dir, 'aligned.png');
|
|
208
|
+
const result = runTrimAlign([input, out, aligned, reference]);
|
|
209
|
+
expect(result.status).toBe(0);
|
|
210
|
+
const outPng = PNG.sync.read(readFileSync(out));
|
|
211
|
+
const alignedPng = PNG.sync.read(readFileSync(aligned));
|
|
212
|
+
expect(alignedPng.width).toBe(outPng.width);
|
|
213
|
+
expect(alignedPng.height).toBe(outPng.height);
|
|
214
|
+
expect(Buffer.compare(alignedPng.data, outPng.data)).toBe(0);
|
|
215
|
+
});
|
|
216
|
+
test('a missing reference is best-effort: no aligned image, zero reference dims', () => {
|
|
217
|
+
const dir = scene();
|
|
218
|
+
const input = join(dir, 'in.png');
|
|
219
|
+
writeFileSync(input, craftPng(100, 100, [{ x: 10, y: 10, w: 50, h: 30, ...RED }]));
|
|
220
|
+
const out = join(dir, 'out.png');
|
|
221
|
+
const aligned = join(dir, 'aligned.png');
|
|
222
|
+
const result = runTrimAlign([input, out, aligned, join(dir, 'nope.png')]);
|
|
223
|
+
expect(result.status).toBe(0);
|
|
224
|
+
expect(existsSync(aligned)).toBe(false);
|
|
225
|
+
expect(result.stdout.trim()).toBe('TRIMDIMS 50 30 0 0 100 100');
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
// ---- failure contract ------------------------------------------------------------
|
|
229
|
+
describe('runTrimAlign — failure contract (Swift helper exit codes)', () => {
|
|
230
|
+
test('usage error without two paths', () => {
|
|
231
|
+
const result = runTrimAlign(['only-one.png']);
|
|
232
|
+
expect(result.status).toBe(64);
|
|
233
|
+
expect(result.stderr).toContain('usage:');
|
|
234
|
+
});
|
|
235
|
+
test('unreadable input exits 65', () => {
|
|
236
|
+
const dir = scene();
|
|
237
|
+
const result = runTrimAlign([join(dir, 'missing.png'), join(dir, 'out.png')]);
|
|
238
|
+
expect(result.status).toBe(65);
|
|
239
|
+
expect(result.stderr).toContain('could not read image at');
|
|
240
|
+
});
|
|
241
|
+
test('a fully-transparent image has no content and exits 66', () => {
|
|
242
|
+
const dir = scene();
|
|
243
|
+
const input = join(dir, 'in.png');
|
|
244
|
+
writeFileSync(input, craftPng(40, 40, []));
|
|
245
|
+
const result = runTrimAlign([input, join(dir, 'out.png')]);
|
|
246
|
+
expect(result.status).toBe(66);
|
|
247
|
+
expect(result.stderr).toContain('no content to trim');
|
|
248
|
+
});
|
|
249
|
+
test('an unwritable output path exits 68', () => {
|
|
250
|
+
const dir = scene();
|
|
251
|
+
const input = join(dir, 'in.png');
|
|
252
|
+
writeFileSync(input, craftPng(40, 40, [{ x: 0, y: 0, w: 10, h: 10, ...RED }]));
|
|
253
|
+
const result = runTrimAlign([input, join(dir, 'no-such-dir', 'out.png')]);
|
|
254
|
+
expect(result.status).toBe(68);
|
|
255
|
+
expect(result.stderr).toContain('could not write trimmed PNG');
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
//# sourceMappingURL=trim-align.test.js.map
|
|
@@ -44,7 +44,7 @@ export interface UiFidelityPreviewAndroidOptions {
|
|
|
44
44
|
outDir?: string;
|
|
45
45
|
/**
|
|
46
46
|
* Extra entries prepended to PATH for the render child process, so callers
|
|
47
|
-
* (and tests) can supply a specific java/gradle/
|
|
47
|
+
* (and tests) can supply a specific java/gradle/ci/tar toolchain. Optional.
|
|
48
48
|
*/
|
|
49
49
|
toolchainPath?: string;
|
|
50
50
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ui-fidelity-preview-android.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/ui-fidelity-preview-android.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;
|
|
1
|
+
{"version":3,"file":"ui-fidelity-preview-android.d.ts","sourceRoot":"","sources":["../../../../src/yaml/steps/ui-fidelity-preview-android.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAwBH,OAAO,KAAK,EAEV,uBAAuB,EACvB,uBAAuB,EAGxB,MAAM,0BAA0B,CAAC;AAElC,MAAM,WAAW,+BAA+B;IAC9C;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,gEAAgE;IAChE,OAAO,EAAE,uBAAuB,EAAE,CAAC;IACnC;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB,kEAAkE;IAClE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,2EAA2E;IAC3E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAQD;;;;;GAKG;AACH,eAAO,MAAM,yBAAyB,8BAA8B,CAAC;AAqErE;;;;;;GAMG;AACH,wBAAgB,8BAA8B,CAC5C,OAAO,EAAE,+BAA+B,GACvC,uBAAuB,CA8KzB"}
|
|
@@ -25,6 +25,7 @@ import { basename, join, resolve } from 'node:path';
|
|
|
25
25
|
import { parseScale, DEFAULT_SCALE } from './ui-fidelity-render.js';
|
|
26
26
|
import { DEFAULT_GRADLE_TASK, PACKAGE_ARCHIVE_BASENAME, generateAndroidRenderScript, isValidGradleTask, } from './ui-fidelity-render-android.js';
|
|
27
27
|
import { buildPreviewScreenResult } from './ui-fidelity-preview.js';
|
|
28
|
+
import { resolveCliJsPath } from './render-post-processor.js';
|
|
28
29
|
/**
|
|
29
30
|
* Build error returned when the local Android toolchain is absent, so the caller
|
|
30
31
|
* can fall back to a remote run instead of a confusing build failure. Generic
|
|
@@ -221,6 +222,14 @@ export function renderUiFidelityPreviewAndroid(options) {
|
|
|
221
222
|
if (!process.env.ANDROID_SDK_ROOT)
|
|
222
223
|
env.ANDROID_SDK_ROOT = androidSdk;
|
|
223
224
|
}
|
|
225
|
+
// Hand the script this installation's CLI entrypoint so its trim stage can
|
|
226
|
+
// spawn `fidelity-trim` without a ci on PATH (library callers like the
|
|
227
|
+
// Invarn CLI). Never override an explicitly-set entrypoint.
|
|
228
|
+
if (!env.CIBUILD_CLI_JS) {
|
|
229
|
+
const cliJs = resolveCliJsPath();
|
|
230
|
+
if (cliJs !== null)
|
|
231
|
+
env.CIBUILD_CLI_JS = cliJs;
|
|
232
|
+
}
|
|
224
233
|
const run = spawnSync('node', ['-e', script], {
|
|
225
234
|
cwd: workDir,
|
|
226
235
|
env,
|