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/README.md +8 -3
- package/docs/AUTHORING.md +41 -5
- package/docs/INGEST.md +29 -20
- package/package.json +1 -1
- package/src/assertions/bodies/a06.ts +28 -2
- package/src/assertions/bodies/a19.ts +28 -10
- package/src/compile.ts +4 -1
- package/src/diff.ts +433 -6
- package/src/png.ts +34 -7
- package/src/spine_side.ts +17 -2
- package/src/validate.ts +90 -7
- package/tools/editor_roundtrip.ts +112 -2
- package/tools/plate.ts +100 -27
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|