spine-rigc 0.4.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/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
  }