spine-rigc 0.5.0 → 0.6.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/src/types.ts CHANGED
@@ -573,6 +573,14 @@ export interface CompiledImage {
573
573
  absPath: string;
574
574
  width: number;
575
575
  height: number;
576
+ /**
577
+ * A per-pixel alpha channel, and only that — colour types 4 and 6.
578
+ *
579
+ * ⚠️ Not "this part can be transparent": indexed and greyscale art keeps its
580
+ * transparency in a `tRNS` chunk and reads `false` here. Anything asking
581
+ * whether the art can draw a transparent pixel wants `PngInfo.hasTransparency`
582
+ * ([`src/png.ts`](png.ts)), which is the distinction A19 got wrong (#215).
583
+ */
576
584
  hasAlpha: boolean;
577
585
  isBase: boolean;
578
586
  }
package/src/validate.ts CHANGED
@@ -29,7 +29,7 @@ import {
29
29
  SkeletonJson,
30
30
  TextureAtlas,
31
31
  } from '@esotericsoftware/spine-core';
32
- import { readPngInfo } from './png.ts';
32
+ import { colourTypeName, readPngInfo } from './png.ts';
33
33
  import { CHANNELS_BY_KIND, KEY_TIME_EPSILON, walkTimelines } from './timelines.ts';
34
34
  import type { RigInfo } from './types.ts';
35
35
 
@@ -53,15 +53,31 @@ export interface Failure {
53
53
  * - `spine` — is this valid Spine 4.3 that any runtime will play correctly?
54
54
  * - `spine-html` — the above, plus this project's renderer and archetype policy.
55
55
  *
56
- * ⚠️ `spine-html` is the DEFAULT and must stay the default: it is the behaviour
57
- * every existing caller already has, and a switch that silently loosens a gate
58
- * for callers who did not ask is worse than no switch at all.
56
+ * ⚠️ `validate()` has NO default profile — `ValidateInput.profile` is required,
57
+ * and that is deliberate. A silent default here can only be wrong in one of two
58
+ * directions: loosen it and a caller who did not ask gets a weaker gate than the
59
+ * one they think they ran; tighten it and foreign data is refused by a policy the
60
+ * caller has no stake in. Issue #221 flipped the CLI to `spine` while several
61
+ * internal callers still wanted `spine-html`, at which point one constant could
62
+ * no longer honestly serve both — so the choice is made at every call site now,
63
+ * by the caller who knows which question they are asking.
59
64
  */
60
65
  export type ValidateProfile = 'spine' | 'spine-html';
61
66
 
62
67
  export const VALIDATE_PROFILES: readonly ValidateProfile[] = ['spine', 'spine-html'];
63
68
 
64
- export const DEFAULT_PROFILE: ValidateProfile = 'spine-html';
69
+ /**
70
+ * What the CLI uses when `--profile` is absent — and ONLY the CLI. This is not
71
+ * `validate()`'s default; that function has none (above).
72
+ *
73
+ * `spine` since issue #221. The published package's pitch is "the output imports
74
+ * into the Spine editor", which is exactly the question `spine` asks, and a
75
+ * stranger's first build was being judged instead against one renderer's policy
76
+ * and one project's canvas budget — 14 rules they have no stake in, with the
77
+ * escape hatch documented only in prose. Defaults beat prose. `spine-html` is
78
+ * still one flag away, and every report names the profile that judged it.
79
+ */
80
+ export const CLI_DEFAULT_PROFILE: ValidateProfile = 'spine';
65
81
 
66
82
  /**
67
83
  * What kind of rule each assertion is. Every assertion has an entry, and
@@ -136,8 +152,11 @@ export interface ValidateInput {
136
152
  * stats line says `rig=absent` so a green run cannot be mistaken for a full one.
137
153
  */
138
154
  rig?: RigInfo;
139
- /** Which body of rules to apply. Defaults to `spine-html`; see ValidateProfile. */
140
- profile?: ValidateProfile;
155
+ /**
156
+ * Which body of rules to apply. Required, and deliberately so — there is no
157
+ * default to fall into. See ValidateProfile.
158
+ */
159
+ profile: ValidateProfile;
141
160
  }
142
161
 
143
162
  export interface ValidateReport {
@@ -215,7 +234,7 @@ export function validate(input: ValidateInput): ValidateReport {
215
234
  const skipped: ValidateReport['skipped'] = [];
216
235
  const profileSkipped: ValidateReport['profileSkipped'] = [];
217
236
  const stats: Record<string, number | string> = {};
218
- const profile = input.profile ?? DEFAULT_PROFILE;
237
+ const profile = input.profile;
219
238
  /** True when this profile's rulebook includes the policy layer. */
220
239
  const policy = profile === 'spine-html';
221
240
 
@@ -1297,11 +1316,25 @@ export function validate(input: ValidateInput): ValidateReport {
1297
1316
  }
1298
1317
  }
1299
1318
  });
1300
- // An overlay part must carry an alpha channel or it cannot be an overlay: it
1301
- // would paint an opaque rectangle over the untouched base, and an overlay
1302
- // formation's whole claim is that the still frame has no seam. The base
1319
+ // An overlay part must be able to draw a transparent pixel or it cannot be an
1320
+ // overlay: it would paint a solid rectangle over the untouched base, and an
1321
+ // overlay formation's whole claim is that the still frame has no seam. The base
1303
1322
  // plate itself is the one page allowed to be opaque, and it identifies
1304
1323
  // itself structurally — it is the region that covers the whole stage.
1324
+ //
1325
+ // ⭐ Transparency is not the same thing as an alpha CHANNEL, and this assertion
1326
+ // used to conflate them (#215). Colour types 4 and 6 store alpha per pixel;
1327
+ // types 0, 2 and 3 store it in a `tRNS` chunk instead, and indexed+tRNS is the
1328
+ // ordinary output of ImageMagick, Photoshop's PNG-8 export, GIMP's indexed
1329
+ // mode, aseprite and pngquant. Judging on the colour type alone refused art
1330
+ // that was never broken — seven of one author's nine hand-drawn parts, on their
1331
+ // first build — and told them, untruthfully, that the file held no transparency
1332
+ // at all. That audit is also why the CLI's default is now `spine`, which does
1333
+ // not run this rule at all (#221): a stranger reaches this refusal only by
1334
+ // asking for `spine-html`. It still has to be both TRUE and actionable when it
1335
+ // fires, and it now has to name the profile it belongs to as well as the one
1336
+ // that does not ask — the reader opted in, and the message is where they find
1337
+ // out what they opted into.
1305
1338
  check('A19_OVERLAY_PNGS_HAVE_ALPHA', () => {
1306
1339
  if (!atlas) return;
1307
1340
  const stageW = skeletonData?.width ?? 0;
@@ -1313,15 +1346,27 @@ export function validate(input: ValidateInput): ValidateReport {
1313
1346
  if (region) basePages.add(region.page.name);
1314
1347
  }
1315
1348
  }
1349
+ // The escape hatch is only worth naming when it is reachable: with no stage
1350
+ // size to measure against, `basePages` is empty and no image can qualify, so
1351
+ // pointing at it would send the reader after a door that is not there.
1352
+ const exemption =
1353
+ stageW && stageH
1354
+ ? `Only the one image big enough to cover the whole stage (${stageW}x${stageH}) may be opaque.`
1355
+ : 'The one image that covers the whole stage may be opaque, but this skeleton declares no stage size, so ' +
1356
+ 'nothing here qualifies.';
1316
1357
  for (const page of atlas.pages) {
1317
1358
  const abs = resolve(input.atlasDir, page.name);
1318
1359
  if (!existsSync(abs)) continue;
1319
1360
  const info = readPngInfo(abs);
1320
- if (info.hasAlpha) continue;
1361
+ if (info.hasTransparency) continue;
1321
1362
  if (basePages.has(page.name)) continue; // full-stage base plate: opaque is correct
1322
1363
  fail(
1323
1364
  'A19_OVERLAY_PNGS_HAVE_ALPHA',
1324
- `overlay page "${page.name}" has colour type ${info.colourType}, which carries no alpha channel`,
1365
+ `part image "${page.name}" cannot be transparent anywhere: it is colour type ${info.colourType} ` +
1366
+ `(${colourTypeName(info.colourType)}) with no tRNS chunk, so it would paint a solid rectangle over ` +
1367
+ 'whatever is drawn behind it. Re-export it with transparency — as RGBA, or as an indexed or greyscale ' +
1368
+ `PNG that keeps its tRNS chunk. ${exemption} This is renderer policy, and it belongs to --profile ` +
1369
+ 'spine-html: the default --profile spine does not run this check.',
1325
1370
  );
1326
1371
  }
1327
1372
  });
package/tools/plate.ts CHANGED
@@ -169,7 +169,19 @@ function crc32(buf: Uint8Array): number {
169
169
  return (c ^ 0xffffffff) >>> 0;
170
170
  }
171
171
 
172
- function chunk(type: string, body: Uint8Array): Uint8Array {
172
+ /** The eight bytes every PNG opens with. */
173
+ export const PNG_SIGNATURE = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
174
+
175
+ /**
176
+ * One PNG chunk: length, type, body, CRC.
177
+ *
178
+ * Exported because this writer only ever emits colour type 6, and the selftest
179
+ * has to produce the colour types it does NOT — indexed and greyscale, with and
180
+ * without a `tRNS` chunk — to have anything for `A19` to judge. Building those by
181
+ * hand needs the CRC table, and a second copy of it in the test would be a second
182
+ * thing that can be wrong.
183
+ */
184
+ export function pngChunk(type: string, body: Uint8Array): Uint8Array {
173
185
  const out = new Uint8Array(body.length + 12);
174
186
  const view = new DataView(out.buffer);
175
187
  view.setUint32(0, body.length);
@@ -197,10 +209,10 @@ export function encodePng(width: number, height: number, rgba: Uint8Array): Uint
197
209
  ihdr[11] = 0;
198
210
  ihdr[12] = 0;
199
211
  const parts = [
200
- new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
201
- chunk('IHDR', ihdr),
202
- chunk('IDAT', new Uint8Array(deflateSync(raw, { level: 9 }))),
203
- chunk('IEND', new Uint8Array(0)),
212
+ PNG_SIGNATURE,
213
+ pngChunk('IHDR', ihdr),
214
+ pngChunk('IDAT', new Uint8Array(deflateSync(raw, { level: 9 }))),
215
+ pngChunk('IEND', new Uint8Array(0)),
204
216
  ];
205
217
  const total = parts.reduce((n, p) => n + p.length, 0);
206
218
  const out = new Uint8Array(total);
@@ -216,48 +228,127 @@ export function encodePng(width: number, height: number, rgba: Uint8Array): Uint
216
228
  // PNG decoding
217
229
  // ---------------------------------------------------------------------------
218
230
 
231
+ /** Samples per pixel for each PNG colour type. Anything else is not a colour type. */
232
+ const CHANNELS: Record<number, number> = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
233
+
234
+ /** The bit depths the PNG spec allows for each colour type. */
235
+ const BIT_DEPTHS: Record<number, number[]> = {
236
+ 0: [1, 2, 4, 8, 16],
237
+ 2: [8, 16],
238
+ 3: [1, 2, 4, 8],
239
+ 4: [8, 16],
240
+ 6: [8, 16],
241
+ };
242
+
243
+ /**
244
+ * Read one sample out of an unfiltered scanline, at the file's own bit depth.
245
+ *
246
+ * Returns the RAW value — 0..1 for a 1-bit file, 0..65535 for a 16-bit one — not
247
+ * a scaled byte. Two callers need it that way: a palette index IS the raw value,
248
+ * and a `tRNS` colour is declared at the file's depth and has to be compared
249
+ * against samples at that depth, not against something already rounded to 8 bits.
250
+ */
251
+ function sampleAt(line: Uint8Array, index: number, bitDepth: number): number {
252
+ if (bitDepth === 8) return line[index];
253
+ if (bitDepth === 16) return (line[index * 2] << 8) | line[index * 2 + 1];
254
+ const perByte = 8 / bitDepth;
255
+ const byte = line[Math.floor(index / perByte)];
256
+ // Sub-byte samples are packed most-significant first.
257
+ const shift = 8 - bitDepth * ((index % perByte) + 1);
258
+ return (byte >> shift) & ((1 << bitDepth) - 1);
259
+ }
260
+
261
+ /** A raw sample scaled to 0..255, the range a `Plate` stores. */
262
+ function toByte(sample: number, bitDepth: number): number {
263
+ if (bitDepth === 8) return sample;
264
+ if (bitDepth === 16) return sample >> 8;
265
+ return Math.round((sample * 255) / ((1 << bitDepth) - 1));
266
+ }
267
+
219
268
  /**
220
- * Read an 8-bit RGB/RGBA PNG back into a `Plate`.
269
+ * Read a PNG back into a `Plate`, expanding whatever it is to straight RGBA.
221
270
  *
222
271
  * `src/png.ts` parses 26 bytes because that is all the COMPILER needs, and it
223
272
  * stays that way: the compiler never re-measures art. This
224
273
  * decoder is for the measuring tools, which is where art measurement belongs -
225
274
  * the same division that puts `mesh.center` in the manifest as a measured number
226
275
  * rather than as something the compiler derives at build time.
276
+ *
277
+ * ⭐ **Every colour type the gate accepts is decodable here** (issue #226). It
278
+ * used to read colour types 2 and 6 only, which was the compiler's own old blind
279
+ * spot rebuilt one step later: `A19` learned in #215 that indexed and greyscale
280
+ * art carrying a `tRNS` chunk is ordinary transparent art — ImageMagick, PNG-8
281
+ * export, GIMP indexed, aseprite and pngquant all write it — so such a part
282
+ * builds and validates green, and would then have been refused by the one command
283
+ * whose whole job is to show the author what they built. A wall removed at the
284
+ * gate and rebuilt at the picture is not a wall removed.
285
+ *
286
+ * So the expansion happens here, at decode: a palette index becomes its `PLTE`
287
+ * entry with its `tRNS` alpha, a greyscale sample becomes three equal channels,
288
+ * and a `tRNS` colour on a type 0 or 2 file becomes alpha 0 on the pixels that
289
+ * match it. Every caller above this line keeps seeing exactly one thing, RGBA.
290
+ *
291
+ * Interlaced files are still refused: Adam7 is a second sample layout rather than
292
+ * a second sample format, and no tool in the corpus writes one.
227
293
  */
228
294
  export function decodePng(buf: Uint8Array): Plate {
229
295
  const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
230
296
  let at = 8;
231
297
  let width = 0;
232
298
  let height = 0;
299
+ let bitDepth = 8;
233
300
  let colourType = 6;
301
+ let palette: Uint8Array | null = null;
302
+ let trns: Uint8Array | null = null;
234
303
  const idat: Uint8Array[] = [];
235
- while (at < buf.length) {
304
+ while (at + 8 <= buf.length) {
236
305
  const len = view.getUint32(at);
237
306
  const type = String.fromCharCode(buf[at + 4], buf[at + 5], buf[at + 6], buf[at + 7]);
238
307
  const body = buf.subarray(at + 8, at + 8 + len);
239
308
  if (type === 'IHDR') {
240
309
  width = view.getUint32(at + 8);
241
310
  height = view.getUint32(at + 12);
242
- if (body[8] !== 8) throw new Error(`unsupported bit depth ${body[8]}`);
311
+ bitDepth = body[8];
243
312
  colourType = body[9];
244
313
  if (body[12] !== 0) throw new Error('interlaced PNG');
245
- } else if (type === 'IDAT') idat.push(body);
314
+ } else if (type === 'PLTE') palette = body.slice();
315
+ else if (type === 'tRNS') trns = body.slice();
316
+ else if (type === 'IDAT') idat.push(body);
246
317
  at += 12 + len;
247
318
  }
248
- const bpp = colourType === 6 ? 4 : colourType === 2 ? 3 : 0;
249
- if (!bpp) throw new Error(`unsupported colour type ${colourType}`);
319
+ const channels = CHANNELS[colourType];
320
+ if (channels === undefined) throw new Error(`unsupported colour type ${colourType}`);
321
+ if (!BIT_DEPTHS[colourType].includes(bitDepth)) {
322
+ throw new Error(`bit depth ${bitDepth} is not one PNG allows for colour type ${colourType}`);
323
+ }
324
+ if (colourType === 3 && palette === null) throw new Error('indexed PNG with no PLTE chunk');
325
+ // Const copies so the null checks above narrow inside the per-pixel loop below,
326
+ // where `palette` and `trns` are still the chunk loop's mutable bindings.
327
+ const pal = palette ?? new Uint8Array(0);
328
+ const alphaTable = trns;
329
+
250
330
  const raw = new Uint8Array(inflateSync(Buffer.concat(idat.map((c) => Buffer.from(c)))));
251
- const stride = width * bpp + 1;
331
+ // The filter operates on BYTES, and its "left neighbour" is one whole pixel
332
+ // back — rounded up to a byte, so sub-byte depths compare against the byte
333
+ // immediately to the left.
334
+ const filterUnit = Math.max(1, Math.ceil((channels * bitDepth) / 8));
335
+ const rowBytes = Math.ceil((width * channels * bitDepth) / 8);
336
+ const stride = rowBytes + 1;
252
337
  const plate = new Plate(width, height);
253
- let prev = new Uint8Array(width * bpp);
338
+ let prev = new Uint8Array(rowBytes);
339
+ // On a type 0 or 2 file a `tRNS` chunk names ONE invisible colour, one
340
+ // big-endian 16-bit sample per channel whatever the file's own depth. (For an
341
+ // indexed file the same chunk is something else entirely: one alpha byte per
342
+ // palette entry, read directly below.)
343
+ const trnsSample = (i: number): number =>
344
+ alphaTable === null ? -1 : (alphaTable[i * 2] << 8) | alphaTable[i * 2 + 1];
254
345
  for (let y = 0; y < height; y++) {
255
346
  const filter = raw[y * stride];
256
347
  const line = raw.slice(y * stride + 1, (y + 1) * stride);
257
- for (let x = 0; x < width * bpp; x++) {
258
- const a = x >= bpp ? line[x - bpp] : 0;
348
+ for (let x = 0; x < rowBytes; x++) {
349
+ const a = x >= filterUnit ? line[x - filterUnit] : 0;
259
350
  const b = prev[x];
260
- const c = x >= bpp ? prev[x - bpp] : 0;
351
+ const c = x >= filterUnit ? prev[x - filterUnit] : 0;
261
352
  if (filter === 1) line[x] = (line[x] + a) & 255;
262
353
  else if (filter === 2) line[x] = (line[x] + b) & 255;
263
354
  else if (filter === 3) line[x] = (line[x] + ((a + b) >> 1)) & 255;
@@ -271,16 +362,68 @@ export function decodePng(buf: Uint8Array): Plate {
271
362
  }
272
363
  for (let x = 0; x < width; x++) {
273
364
  const o = (y * width + x) * 4;
274
- plate.data[o] = line[x * bpp];
275
- plate.data[o + 1] = line[x * bpp + 1];
276
- plate.data[o + 2] = line[x * bpp + 2];
277
- plate.data[o + 3] = bpp === 4 ? line[x * bpp + 3] : 255;
365
+ const first = x * channels;
366
+ if (colourType === 3) {
367
+ const index = sampleAt(line, first, bitDepth);
368
+ plate.data[o] = pal[index * 3];
369
+ plate.data[o + 1] = pal[index * 3 + 1];
370
+ plate.data[o + 2] = pal[index * 3 + 2];
371
+ // A tRNS shorter than the palette leaves the entries past its end opaque,
372
+ // which is what the spec says and what pngquant relies on.
373
+ plate.data[o + 3] = alphaTable !== null && index < alphaTable.length ? alphaTable[index] : 255;
374
+ continue;
375
+ }
376
+ if (colourType === 0 || colourType === 4) {
377
+ const grey = sampleAt(line, first, bitDepth);
378
+ const g = toByte(grey, bitDepth);
379
+ plate.data[o] = g;
380
+ plate.data[o + 1] = g;
381
+ plate.data[o + 2] = g;
382
+ plate.data[o + 3] =
383
+ colourType === 4
384
+ ? toByte(sampleAt(line, first + 1, bitDepth), bitDepth)
385
+ : alphaTable !== null && alphaTable.length >= 2 && trnsSample(0) === grey
386
+ ? 0
387
+ : 255;
388
+ continue;
389
+ }
390
+ const r = sampleAt(line, first, bitDepth);
391
+ const g = sampleAt(line, first + 1, bitDepth);
392
+ const b = sampleAt(line, first + 2, bitDepth);
393
+ plate.data[o] = toByte(r, bitDepth);
394
+ plate.data[o + 1] = toByte(g, bitDepth);
395
+ plate.data[o + 2] = toByte(b, bitDepth);
396
+ plate.data[o + 3] =
397
+ colourType === 6
398
+ ? toByte(sampleAt(line, first + 3, bitDepth), bitDepth)
399
+ : alphaTable !== null &&
400
+ alphaTable.length >= 6 &&
401
+ trnsSample(0) === r &&
402
+ trnsSample(1) === g &&
403
+ trnsSample(2) === b
404
+ ? 0
405
+ : 255;
278
406
  }
279
407
  prev = line;
280
408
  }
281
409
  return plate;
282
410
  }
283
411
 
412
+ /**
413
+ * Decode the PNG at `path`, naming the file if it cannot be decoded.
414
+ *
415
+ * The bare message says what is wrong with the bytes and nothing about which
416
+ * bytes; a rig has as many pages as it has parts, and "unsupported colour type 5"
417
+ * with no path is a search rather than a fix.
418
+ */
284
419
  export function readPlate(path: string): Plate {
285
- return decodePng(readFileSync(path));
420
+ // Only the DECODE is wrapped. A missing file already says which one, and
421
+ // rewording an ENOENT as a decoding problem would mislabel the commonest
422
+ // failure of all.
423
+ const buf = readFileSync(path);
424
+ try {
425
+ return decodePng(buf);
426
+ } catch (err) {
427
+ throw new Error(`cannot decode PNG ${path}: ${(err as Error).message}`);
428
+ }
286
429
  }