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/NOTICE.md +21 -0
- package/README.md +336 -223
- package/bin/rigc.cjs +40 -0
- package/cli.ts +558 -49
- package/docs/AUTHORING.md +538 -23
- package/docs/PROMPTING.md +106 -0
- package/docs/SPEC_COVERAGE.md +16 -14
- package/package.json +4 -2
- package/src/compile.ts +189 -142
- package/src/emit.ts +88 -0
- package/src/json-position.ts +253 -0
- package/src/png.ts +72 -7
- package/src/preview.ts +243 -0
- package/src/render.ts +58 -0
- package/src/types.ts +8 -0
- package/src/validate.ts +58 -13
- package/tools/plate.ts +164 -21
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
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
311
|
+
bitDepth = body[8];
|
|
243
312
|
colourType = body[9];
|
|
244
313
|
if (body[12] !== 0) throw new Error('interlaced PNG');
|
|
245
|
-
} else if (type === '
|
|
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
|
|
249
|
-
if (
|
|
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
|
-
|
|
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(
|
|
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 <
|
|
258
|
-
const a = x >=
|
|
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 >=
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
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
|
}
|