spine-rigc 2.0.1 → 2.0.3

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/src/validate.ts CHANGED
@@ -3466,10 +3466,70 @@ const NOT_A_VALUE_IN_THE_FILE: Record<string, string> = {
3466
3466
  index: 'the array position this walk already iterates by name',
3467
3467
  timelineIds: 'derived from the timelines',
3468
3468
  timelineSlots: 'derived from the skin',
3469
- properties: 'derived from the constraint',
3470
3469
  propertyIds: 'carries an attachment id, which is a counter (see above)',
3471
3470
  };
3472
3471
 
3472
+ /**
3473
+ * The keys above that ARE a value the file carries on these owners, by the
3474
+ * owner's class name (issue #1084).
3475
+ *
3476
+ * The skip list is keyed by field name alone, and four of its names are also
3477
+ * the name of a field the parser reads off the file on a different class. Each
3478
+ * owner below was found by walking the parsed form of the twelve editor exports
3479
+ * and the seven gallery builds for every class carrying a key the list names,
3480
+ * and each field was planted and posed before it was let back in:
3481
+ *
3482
+ * - `a`, `b` — a `Color`'s alpha and blue channels, which share their names with
3483
+ * two entries of a bone's world matrix. Every slot colour, dark colour, bone
3484
+ * colour and attachment colour lost half its channels to that: a slot's alpha
3485
+ * `ff` → `80` moved no value at all, and posed, it changes every slot row.
3486
+ * - `local` — a slider's `local` flag, which shares its name with a bone pose's
3487
+ * derived `local`. Flipped on `gallery/look`'s `yaw`, it moves the posed bones.
3488
+ * - `offsets` — a transform constraint's six offsets (`rotation`, `x`, `y`,
3489
+ * `scaleX`, `scaleY`, `shearY` in the file), which share their name with a
3490
+ * sequence's region offsets. A `y` offset moved on `6-arcs-pro`'s `tail`
3491
+ * moved no value and moves the posed bones.
3492
+ *
3493
+ * A fifth entry, `properties` (*derived from the constraint*), is gone rather
3494
+ * than excepted: the one class measured carrying it is the transform constraint,
3495
+ * where it is the file's from/to map, so the reason was not true of anything.
3496
+ *
3497
+ * ⚠️ An exception rather than a rewrite of the list: the list's design — a
3498
+ * field the parser starts reading is compared the day it starts — is right,
3499
+ * and what was wrong was that a name stood for every class carrying it.
3500
+ */
3501
+ const VALUE_ON_THESE_OWNERS: Record<string, readonly string[]> = {
3502
+ a: ['Color'],
3503
+ b: ['Color'],
3504
+ local: ['SliderData'],
3505
+ offsets: ['TransformConstraintData'],
3506
+ };
3507
+
3508
+ /** Whether the walk leaves `key` out on an object of class `owner`. */
3509
+ function notAValue(owner: string, key: string): boolean {
3510
+ return key in NOT_A_VALUE_IN_THE_FILE && !(VALUE_ON_THESE_OWNERS[key] ?? []).includes(owner);
3511
+ }
3512
+
3513
+ /**
3514
+ * The class an object was parsed into when that class is one of several sharing
3515
+ * a shape — it extends another class — or `null`.
3516
+ *
3517
+ * Reflection reads fields, and two classes with the same fields are the same
3518
+ * object to it: a slider reading `rotate` holds a `FromRotate` and one reading
3519
+ * `x` a `FromX`, both `{ offset, to }`, so moving a slider to another property
3520
+ * moved no value (issue #1084). Measured over the parsed corpus, the only
3521
+ * unnamed objects the walk expands whose class extends another are the
3522
+ * transform and slider property classes (`From*`, `To*`); a pose, a colour and a
3523
+ * sequence are classes of their own, so this adds nothing under them.
3524
+ */
3525
+ function sharedShapeKind(obj: object): string | null {
3526
+ const proto = Object.getPrototypeOf(obj) as object | null;
3527
+ if (proto === null || proto === Object.prototype) return null;
3528
+ const parent = Object.getPrototypeOf(proto) as object | null;
3529
+ if (parent === null || parent === Object.prototype) return null;
3530
+ return (obj.constructor as { name?: string } | undefined)?.name ?? null;
3531
+ }
3532
+
3473
3533
  /** How deep a chain of unnamed objects may go before the walk says so and stops. */
3474
3534
  const VALUE_WALK_DEPTH = 10;
3475
3535
 
@@ -3501,7 +3561,24 @@ function pushValue(value: unknown, path: string, out: SkeletonValue[], depth: nu
3501
3561
  if (typeof value === 'function') return;
3502
3562
  if (ArrayBuffer.isView(value) || Array.isArray(value)) {
3503
3563
  const list = value as ArrayLike<unknown>;
3504
- for (let i = 0; i < list.length; i++) pushValue(list[i], `${path}/${i}`, out, depth + 1);
3564
+ // A list whose every entry is an unnamed object of its own shared-shape
3565
+ // class — a transform's `properties` and each one's `to` — is keyed by those
3566
+ // classes rather than by position: the file writes them as an object keyed
3567
+ // by property name, any writer may key it in its own order, and the
3568
+ // parser's list follows that order. By position, the same map written
3569
+ // backwards would read as every property moved (issue #1084).
3570
+ const kinds: string[] = [];
3571
+ for (let i = 0; i < list.length; i++) {
3572
+ const entry = list[i];
3573
+ const kind =
3574
+ typeof entry === 'object' && entry !== null && typeof (entry as Record<string, unknown>).name !== 'string'
3575
+ ? sharedShapeKind(entry)
3576
+ : null;
3577
+ if (kind === null || kinds.includes(kind)) break;
3578
+ kinds.push(kind);
3579
+ }
3580
+ const byKind = list.length > 0 && kinds.length === list.length;
3581
+ for (let i = 0; i < list.length; i++) pushValue(list[i], `${path}/${byKind ? kinds[i] : i}`, out, depth + 1);
3505
3582
  return;
3506
3583
  }
3507
3584
  if (typeof value !== 'object') return;
@@ -3514,8 +3591,12 @@ function pushValue(value: unknown, path: string, out: SkeletonValue[], depth: nu
3514
3591
  out.push({ path, value: '(deeper than the walk goes)' });
3515
3592
  return;
3516
3593
  }
3594
+ const owner = (obj.constructor as { name?: string } | undefined)?.name ?? '';
3595
+ // The class, where it is the one thing the fields cannot say — see `sharedShapeKind`.
3596
+ const kind = depth > 0 ? sharedShapeKind(obj) : null;
3597
+ if (kind !== null) out.push({ path: `${path}/kind`, value: kind });
3517
3598
  for (const key of Object.keys(obj).sort()) {
3518
- if (key in NOT_A_VALUE_IN_THE_FILE) continue;
3599
+ if (notAValue(owner, key)) continue;
3519
3600
  pushValue(obj[key], `${path}/${key}`, out, depth + 1);
3520
3601
  }
3521
3602
  }
@@ -3570,7 +3651,9 @@ function timelineKey(timeline: Timeline, data: ReturnType<SkeletonJson['readSkel
3570
3651
  * and setup attachment, every attachment in every skin (a region's offsets,
3571
3652
  * rotation, scale and size; a mesh's vertices, weights, `regionUVs`,
3572
3653
  * triangles, hull and edges; a bounding box's, path's and clipping shape's
3573
- * vertices), every constraint's pose and flags, every event's payload, and for
3654
+ * vertices), every constraint's pose, flags and modes — a transform's offsets
3655
+ * and its property map, each entry by its class, and a slider's property by its
3656
+ * class (issue #1084) — every event's payload, and for
3574
3657
  * every timeline every frame — time and values, from the runtime's own
3575
3658
  * `getFrameEntries()` — its curve type and Bezier samples, and its deform
3576
3659
  * vertices, attachment names, draw orders and event payloads.
@@ -3605,7 +3688,7 @@ export function skeletonValues(skeletonText: string, atlasText: string): Skeleto
3605
3688
  const at = `bones/${bone.name}`;
3606
3689
  const rec = bone as unknown as Record<string, unknown>;
3607
3690
  for (const key of Object.keys(rec).sort()) {
3608
- if (key in NOT_A_VALUE_IN_THE_FILE || key === 'name') continue;
3691
+ if (notAValue('BoneData', key) || key === 'name') continue;
3609
3692
  pushValue(rec[key], `${at}/${key === 'setupPose' ? 'setup' : key}`, out, 1);
3610
3693
  }
3611
3694
  }
@@ -3613,7 +3696,7 @@ export function skeletonValues(skeletonText: string, atlasText: string): Skeleto
3613
3696
  const at = `slots/${slot.name}`;
3614
3697
  const rec = slot as unknown as Record<string, unknown>;
3615
3698
  for (const key of Object.keys(rec).sort()) {
3616
- if (key in NOT_A_VALUE_IN_THE_FILE || key === 'name') continue;
3699
+ if (notAValue('SlotData', key) || key === 'name') continue;
3617
3700
  pushValue(rec[key], `${at}/${key === 'setupPose' ? 'setup' : key}`, out, 1);
3618
3701
  }
3619
3702
  }
@@ -3681,7 +3764,7 @@ export function skeletonValues(skeletonText: string, atlasText: string): Skeleto
3681
3764
  }
3682
3765
  const rec = timeline as unknown as Record<string, unknown>;
3683
3766
  for (const field of Object.keys(rec).sort()) {
3684
- if (field in NOT_A_VALUE_IN_THE_FILE || field === 'frames') continue;
3767
+ if (notAValue(timeline.constructor?.name ?? '', field) || field === 'frames') continue;
3685
3768
  // The owner is in the path already; comparing the index as well would
3686
3769
  // report one reordering twice, in a measure that is not about order.
3687
3770
  if (field === 'boneIndex' || field === 'slotIndex' || field === 'constraintIndex') continue;
@@ -153,6 +153,41 @@ function bundleName(plist: string): string | null {
153
153
  }
154
154
  }
155
155
 
156
+ /**
157
+ * The editor naming its own version, as MEASURED on a licensed 4.3 editor on
158
+ * macOS 2026-10-02 (issue #1077). `--version` printed nine lines on stdout and
159
+ * none on stderr, in this shape:
160
+ *
161
+ * the launcher's banner `Spine Launcher <x.y.z> (<platform>)`
162
+ * the copyright line
163
+ * the operating system and its version
164
+ * `Starting: Spine <x.y.z> Professional`
165
+ * `Spine <x.y.z> Professional` <- this line
166
+ * `Licensed to:`
167
+ * the licensee's name
168
+ * the licensee's e-mail address
169
+ * `Complete.`
170
+ *
171
+ * 🚨 The tool used to print the last three of those, which are the licensee's
172
+ * name, e-mail and `Complete.` — personal data in a log that gets pasted into
173
+ * PR bodies and cards, and no version at all (issue #1040 found it). So the
174
+ * report keeps ONE line, the one the editor starts with its own name and a
175
+ * version, and nothing else from that output.
176
+ *
177
+ * ⚠️ Anchored at the start of the line and to a version straight after the
178
+ * word, which is what separates it from its neighbours: the launcher's banner
179
+ * has `Launcher` there (and is a different version), and the `Starting:` line
180
+ * does not start with `Spine`. A line that merely contains a version somewhere —
181
+ * the operating system's — is not the editor's.
182
+ */
183
+ const EDITOR_VERSION_LINE = /^Spine[ \t]+\d+\.\d+\.\d+\b[^\n]*$/m;
184
+
185
+ /** What step 0 prints for the editor's version: its own line, or a sentence saying it was not there. */
186
+ function editorVersionLine(output: string): string {
187
+ const line = EDITOR_VERSION_LINE.exec(output);
188
+ return line === null ? 'editor version: not found in --version output' : line[0].trim();
189
+ }
190
+
156
191
  /** The trial naming itself in its own `--version` output, or null. */
157
192
  function trialSignalFromVersion(output: string): string | null {
158
193
  const banner = TRIAL_BANNER.exec(output);
@@ -273,6 +308,61 @@ interface Ran {
273
308
  timedOut: boolean;
274
309
  }
275
310
 
311
+ /**
312
+ * The indices of the lines in one stream of editor output that carry the
313
+ * licence holder, and nothing else (issue #1082).
314
+ *
315
+ * Measured on a licensed 4.3 editor on macOS 2026-10-02, through this tool, on
316
+ * four failing calls. Every one exited 1, wrote everything to stdout and nothing
317
+ * to stderr, and printed one of two shapes:
318
+ *
319
+ * an input path that does not exist (import, and export) — refused by the
320
+ * LAUNCHER before the editor starts, and no licence line at all:
321
+ *
322
+ * the launcher's banner, the copyright line, the operating system
323
+ * (blank) `Parameter: --input <path>` (blank)
324
+ * `ERROR: Input path does not exist:`
325
+ * the path
326
+ *
327
+ * a file the editor cannot read (a skeleton JSON naming a parent bone it does
328
+ * not declare; a `.spine` that is not a project) — the editor starts, names
329
+ * itself and its licence holder, and then the failure:
330
+ *
331
+ * the launcher's banner, the copyright line, the operating system
332
+ * `Starting: Spine <x.y.z> Professional`
333
+ * `Spine <x.y.z> Professional`
334
+ * `Licensed to: <name> <<e-mail>>` <- ONE line, withheld
335
+ * import: `Project import: …`, `ERROR: Unable to import skeleton.`, then
336
+ * export: `ERROR: Unable to export.`, then
337
+ * `[error] …` lines, a stack, and `Cause: …` lines
338
+ *
339
+ * 🚨 So on a failure the block is one line, not the three `--version` prints
340
+ * (`Licensed to:` alone, then the name, then the e-mail — issue #1077), and
341
+ * the lines straight after it are the editor's own error: a rule that took
342
+ * "the header and the two lines after it" would have quoted the licence holder
343
+ * not at all and the reason for the failure not at all either.
344
+ *
345
+ * ⚠️ Both shapes are held, each by what bounds it. A header with a value on the
346
+ * same line is that one line. A bare header takes the two lines after it only
347
+ * when the second of them is an e-mail (`@`), which is the `--version` shape;
348
+ * anything else after a bare header is the editor talking and is quoted.
349
+ */
350
+ function licenceLines(lines: readonly string[]): Set<number> {
351
+ const withheld = new Set<number>();
352
+ for (let i = 0; i < lines.length; i++) {
353
+ const header = /^[ \t]*Licensed to:(.*)$/.exec(lines[i]);
354
+ if (header === null) continue;
355
+ withheld.add(i);
356
+ if (header[1].trim() !== '') continue;
357
+ if (i + 2 < lines.length && lines[i + 2].includes('@')) {
358
+ withheld.add(i + 1);
359
+ withheld.add(i + 2);
360
+ i += 2;
361
+ }
362
+ }
363
+ return withheld;
364
+ }
365
+
276
366
  /**
277
367
  * Everything the editor printed on a step that did not do what it was for.
278
368
  *
@@ -301,6 +391,11 @@ interface Ran {
301
391
  * softening the verdict. What it prints is the editor's own words, quoted and
302
392
  * attributed to the stream they came off, and never rewritten — a harness that
303
393
  * summarised them would be the same defect with a smaller radius.
394
+ *
395
+ * 🚨 With one exception, which is not the editor's words but the licence
396
+ * holder's (issue #1082): the licence line, which `licenceLines` names and
397
+ * which is replaced by one line saying it was withheld. Every other line is
398
+ * quoted as printed, in order.
304
399
  */
305
400
  function editorSaid(ran: Ran): string[] {
306
401
  const lines: string[] = [];
@@ -311,7 +406,21 @@ function editorSaid(ran: Ran): string[] {
311
406
  const body = text.replace(/\s+$/, '');
312
407
  if (body === '') continue;
313
408
  lines.push(` the editor's ${stream}:`);
314
- for (const line of body.split('\n')) lines.push(` | ${line}`);
409
+ const printed = body.split('\n');
410
+ const withheld = licenceLines(printed);
411
+ for (let i = 0; i < printed.length; i++) {
412
+ if (!withheld.has(i)) {
413
+ lines.push(` | ${printed[i]}`);
414
+ continue;
415
+ }
416
+ // One note per run of withheld lines, in the place they were printed, so
417
+ // the quotation still reads in the editor's order and the omission is
418
+ // stated rather than left to look like an editor that said less.
419
+ let span = 1;
420
+ while (withheld.has(i + span)) span++;
421
+ lines.push(` ~ ${span} line(s) withheld here: the editor's licence block names the licence holder (issue #1082)`);
422
+ i += span - 1;
423
+ }
315
424
  }
316
425
  // Silence is a finding too, and it has to be stated rather than left to look
317
426
  // like a harness that forgot to print. The card above is what an unstated one
@@ -981,7 +1090,8 @@ function main(): void {
981
1090
 
982
1091
  const ver = run(opts.editor, ['--version'], 60);
983
1092
  emit(` editor ${opts.editor}`);
984
- for (const line of ver.stdout.trim().split('\n').slice(-3)) emit(` ${line}`);
1093
+ // One line, never the output's tail: the tail is the licensee's (issue #1077).
1094
+ emit(` ${editorVersionLine(`${ver.stdout}\n${ver.stderr}`)}`);
985
1095
  // The second signal, and the only one that works on a platform whose trial
986
1096
  // path this repository has never seen: the binary's own banner. It costs no
987
1097
  // extra call — `--version` above is one the tool already made.
package/tools/plate.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  import { deflateSync, inflateSync } from 'node:zlib';
16
16
  import { readFileSync, writeFileSync } from 'node:fs';
17
17
  import { drawText, textWidth } from './font5x7.ts';
18
- import { assertPng } from '../src/png.ts';
18
+ import { assertPng, NotAPngError } from '../src/png.ts';
19
19
 
20
20
  export type RGBA = [number, number, number, number];
21
21
 
@@ -266,33 +266,27 @@ function toByte(sample: number, bitDepth: number): number {
266
266
  return Math.round((sample * 255) / ((1 << bitDepth) - 1));
267
267
  }
268
268
 
269
+ /** What `decodePng` has once the stream is inflated and before a scanline is unfiltered. */
270
+ interface InflatedPng {
271
+ width: number;
272
+ height: number;
273
+ bitDepth: number;
274
+ colourType: number;
275
+ channels: number;
276
+ pal: Uint8Array;
277
+ alphaTable: Uint8Array | null;
278
+ raw: Uint8Array;
279
+ }
280
+
269
281
  /**
270
- * Read a PNG back into a `Plate`, expanding whatever it is to straight RGBA.
271
- *
272
- * `src/png.ts` parses 26 bytes because that is all the COMPILER needs, and it
273
- * stays that way: the compiler never re-measures art. This
274
- * decoder is for the measuring tools, which is where art measurement belongs -
275
- * the same division that puts `mesh.center` in the manifest as a measured number
276
- * rather than as something the compiler derives at build time.
277
- *
278
- * ⭐ **Every colour type the gate accepts is decodable here** (issue #226). It
279
- * used to read colour types 2 and 6 only, which was the compiler's own old blind
280
- * spot rebuilt one step later: `A19` learned in #215 that indexed and greyscale
281
- * art carrying a `tRNS` chunk is ordinary transparent art — ImageMagick, PNG-8
282
- * export, GIMP indexed, aseprite and pngquant all write it — so such a part
283
- * builds and validates green, and would then have been refused by the one command
284
- * whose whole job is to show the author what they built. A wall removed at the
285
- * gate and rebuilt at the picture is not a wall removed.
286
- *
287
- * So the expansion happens here, at decode: a palette index becomes its `PLTE`
288
- * entry with its `tRNS` alpha, a greyscale sample becomes three equal channels,
289
- * and a `tRNS` colour on a type 0 or 2 file becomes alpha 0 on the pixels that
290
- * match it. Every caller above this line keeps seeing exactly one thing, RGBA.
291
- *
292
- * Interlaced files are still refused: Adam7 is a second sample layout rather than
293
- * a second sample format, and no tool in the corpus writes one.
282
+ * The half of `decodePng` that can fail: the chunk walk, the header's own
283
+ * checks, the inflate of every `IDAT` concatenated, and whether what came out
284
+ * is as long as the header says the image is (issue #1074). Everything after
285
+ * it — unfiltering, expanding to RGBA — reads bytes that are there and cannot
286
+ * refuse, so `imageDataProblem` runs exactly this and nothing else, and is the
287
+ * decoder's own answer rather than a second opinion beside it.
294
288
  */
295
- export function decodePng(buf: Uint8Array): Plate {
289
+ function inflatePng(buf: Uint8Array): InflatedPng {
296
290
  const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
297
291
  let at = 8;
298
292
  let width = 0;
@@ -329,6 +323,47 @@ export function decodePng(buf: Uint8Array): Plate {
329
323
  const alphaTable = trns;
330
324
 
331
325
  const raw = new Uint8Array(inflateSync(Buffer.concat(idat.map((c) => Buffer.from(c)))));
326
+ // A stream that inflates clean and short is as undecodable as one that does
327
+ // not inflate: the rows past its end would be read as zeros, a picture the
328
+ // file never held. Longer is accepted — the spec's decoders ignore the excess.
329
+ const needed = height * (Math.ceil((width * channels * bitDepth) / 8) + 1);
330
+ if (raw.length < needed) {
331
+ throw new Error(
332
+ `its image data inflates to ${raw.length} byte(s), where a ${width}x${height} image of colour type ` +
333
+ `${colourType} at bit depth ${bitDepth} needs ${needed}`,
334
+ );
335
+ }
336
+ return { width, height, bitDepth, colourType, channels, pal, alphaTable, raw };
337
+ }
338
+
339
+ /**
340
+ * Read a PNG back into a `Plate`, expanding whatever it is to straight RGBA.
341
+ *
342
+ * `src/png.ts` parses 26 bytes because that is all the COMPILER needs, and it
343
+ * stays that way: the compiler never re-measures art. This
344
+ * decoder is for the measuring tools, which is where art measurement belongs -
345
+ * the same division that puts `mesh.center` in the manifest as a measured number
346
+ * rather than as something the compiler derives at build time.
347
+ *
348
+ * ⭐ **Every colour type the gate accepts is decodable here** (issue #226). It
349
+ * used to read colour types 2 and 6 only, which was the compiler's own old blind
350
+ * spot rebuilt one step later: `A19` learned in #215 that indexed and greyscale
351
+ * art carrying a `tRNS` chunk is ordinary transparent art — ImageMagick, PNG-8
352
+ * export, GIMP indexed, aseprite and pngquant all write it — so such a part
353
+ * builds and validates green, and would then have been refused by the one command
354
+ * whose whole job is to show the author what they built. A wall removed at the
355
+ * gate and rebuilt at the picture is not a wall removed.
356
+ *
357
+ * So the expansion happens here, at decode: a palette index becomes its `PLTE`
358
+ * entry with its `tRNS` alpha, a greyscale sample becomes three equal channels,
359
+ * and a `tRNS` colour on a type 0 or 2 file becomes alpha 0 on the pixels that
360
+ * match it. Every caller above this line keeps seeing exactly one thing, RGBA.
361
+ *
362
+ * Interlaced files are still refused: Adam7 is a second sample layout rather than
363
+ * a second sample format, and no tool in the corpus writes one.
364
+ */
365
+ export function decodePng(buf: Uint8Array): Plate {
366
+ const { width, height, bitDepth, colourType, channels, pal, alphaTable, raw } = inflatePng(buf);
332
367
  // The filter operates on BYTES, and its "left neighbour" is one whole pixel
333
368
  // back — rounded up to a byte, so sub-byte depths compare against the byte
334
369
  // immediately to the left.
@@ -430,6 +465,44 @@ export function readPlate(path: string): Plate {
430
465
  try {
431
466
  return decodePng(buf);
432
467
  } catch (err) {
433
- throw new Error(`cannot decode PNG ${path}: ${(err as Error).message}`);
468
+ // The decoder's refusal as the same sentence the gate prints (issue
469
+ // #1074), and as the class every command already refuses by name: it was
470
+ // a bare Error, which `render` and `build` (through a mesh part's trace)
471
+ // let through as a stack.
472
+ throw new NotAPngError(path, undecodableSentence(path, (err as Error).message));
473
+ }
474
+ }
475
+
476
+ /** What a PNG whose header reads and whose image data does not decode is, as one sentence. */
477
+ function undecodableSentence(path: string, decoder: string): string {
478
+ return (
479
+ `${path} is a PNG whose header and chunks read and whose image data cannot be decoded: the decoder stops ` +
480
+ `with "${decoder}", so no texel of it can be read. Re-export the file, or re-copy it whole`
481
+ );
482
+ }
483
+
484
+ /**
485
+ * Why the image data of a PNG that `pngProblem` accepts cannot be decoded, as
486
+ * one sentence — or `null` when it can (issue #1074).
487
+ *
488
+ * 🔒 **It is the decoder, not a check beside it**: it runs `inflatePng`, the
489
+ * half of `decodePng` that can refuse, and reports that refusal in the
490
+ * decoder's own words. `pngProblem` reads chunk headers and never inflates, so
491
+ * a page with an intact chunk walk over a broken compressed stream passed it,
492
+ * passed `A06`, and was found only by whichever reader decoded first — `A19`
493
+ * as `threw: cannot decode PNG …`, `render` and a mesh part's trace as a stack,
494
+ * and on a page `A19` does not scan (the base plate, or any page under the
495
+ * default profile) by nobody: such a build was written green.
496
+ *
497
+ * ⚠️ Inflate only, deliberately: measured over the 19 recipes' pages it is
498
+ * 68.9 ms against `validate()`'s 286.2 ms under `spine`, where a whole decode
499
+ * (`decodePng`, which unfilters and expands every texel) is 459.6 ms.
500
+ */
501
+ export function imageDataProblem(buf: Uint8Array, path: string): string | null {
502
+ try {
503
+ inflatePng(buf);
504
+ return null;
505
+ } catch (err) {
506
+ return undecodableSentence(path, (err as Error).message);
434
507
  }
435
508
  }