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/src/emit.ts ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Writing the compiled atlas to disk with its pages copied alongside it — the
3
+ * step behind `build --copy-images` (issue #217).
4
+ *
5
+ * `compile()` never touches the filesystem for output; it only reads inputs and
6
+ * assembles text (see its own header). A page's name in that text is
7
+ * `relative(outDir, absPath)` — the PNG's path *as seen from the atlas file* — so
8
+ * it points at wherever the source art already lives, which is very often
9
+ * outside `outDir` (`../parts/torso.png`). That is the right default for a build
10
+ * that sits beside the project that owns the art, and it is exactly what makes
11
+ * `--out` non-self-contained the moment the directory is zipped, committed or
12
+ * handed to someone who does not have that project.
13
+ *
14
+ * `copyAtlasImages` is the opt-in other half: copy every referenced page PNG
15
+ * into the output directory and hand back the atlas text with page names
16
+ * rewritten to match. Nothing here runs unless `cli.ts` calls it, so the default
17
+ * emit (`buildAtlasText` in [`src/compile.ts`](compile.ts)) is untouched.
18
+ */
19
+ import { copyFileSync, mkdirSync } from 'node:fs';
20
+ import { basename, extname, join } from 'node:path';
21
+ import { buildAtlasText } from './compile.ts';
22
+ import type { CompiledImage } from './types.ts';
23
+
24
+ /** One page's source and where `--copy-images` put it. */
25
+ export interface CopiedPage {
26
+ region: string;
27
+ /** Absolute path the page was read from. */
28
+ from: string;
29
+ /** Filename inside the output directory — also the atlas's new page name. */
30
+ to: string;
31
+ }
32
+
33
+ export interface CopyImagesResult {
34
+ /** The atlas text, with every page name rewritten to its `to` filename. */
35
+ atlasText: string;
36
+ pages: CopiedPage[];
37
+ }
38
+
39
+ /**
40
+ * Copy every image `compile()` referenced into `outDir` and return the atlas
41
+ * text rewritten to match.
42
+ *
43
+ * ## Naming and collisions
44
+ *
45
+ * A page's filename in `outDir` is its source basename, unchanged. That is safe
46
+ * because rigc already enforces a stronger invariant upstream: a region name IS
47
+ * the PNG basename (`compile.ts`'s `addImage`), and two images sharing a region
48
+ * are refused at compile time — so within one compile, no two entries of
49
+ * `images` can legitimately carry the same basename already.
50
+ *
51
+ * The check below exists anyway. "The caller already prevents this" stops being
52
+ * true the moment the caller changes, and a case-insensitive filesystem (the
53
+ * macOS default) can still collide two basenames the region check saw as
54
+ * distinct (`Torso.png` vs `torso.png` are different regions, one destination
55
+ * file). On a genuine collision, the second and later claimants of a name get
56
+ * `-2`, `-3`, … inserted before the extension, assigned in the order `images`
57
+ * lists them — the same order the atlas itself is written in, so the mapping is
58
+ * identical on every run of the same compile. The rewritten atlas is the record
59
+ * of it: reading a page name back out says exactly which file it is, with no
60
+ * side table to fall out of sync.
61
+ */
62
+ export function copyAtlasImages(images: CompiledImage[], outDir: string): CopyImagesResult {
63
+ mkdirSync(outDir, { recursive: true });
64
+
65
+ const claimedBy = new Map<string, string>(); // destination filename -> absPath holding it
66
+ const pages: CopiedPage[] = [];
67
+
68
+ for (const img of images) {
69
+ const original = basename(img.absPath);
70
+ let name = original;
71
+ if (claimedBy.has(name) && claimedBy.get(name) !== img.absPath) {
72
+ const ext = extname(original);
73
+ const stem = original.slice(0, original.length - ext.length);
74
+ let n = 2;
75
+ do {
76
+ name = `${stem}-${n}${ext}`;
77
+ n++;
78
+ } while (claimedBy.has(name) && claimedBy.get(name) !== img.absPath);
79
+ }
80
+ claimedBy.set(name, img.absPath);
81
+ pages.push({ region: img.region, from: img.absPath, to: name });
82
+ }
83
+
84
+ for (const page of pages) copyFileSync(page.from, join(outDir, page.to));
85
+
86
+ const rewritten: CompiledImage[] = images.map((img, i) => ({ ...img, page: pages[i].to }));
87
+ return { atlasText: buildAtlasText(rewritten), pages };
88
+ }
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Best-effort line/column for a `JSON.parse` failure.
3
+ *
4
+ * Bun's `JSON.parse` (JavaScriptCore) names the fault — `JSON Parse error:
5
+ * Property name must be a string literal` — and never a position, so a caller
6
+ * only ever learns which file was bad, not where inside it. This module fills
7
+ * that gap without a dependency: it re-walks the text through a minimal,
8
+ * hand-written JSON grammar and stops at the first place that grammar
9
+ * disagrees with the text.
10
+ *
11
+ * ⚠️ This is not a JSON parser used to parse anything — `JSON.parse` still owns
12
+ * every successful parse and every error MESSAGE. `locateJsonError` only ever
13
+ * runs after `JSON.parse` has already thrown, purely to attribute a position to
14
+ * the failure it already found. That is why it does not have to be a complete,
15
+ * spec-perfect grammar: for the ordinary mistakes (an unquoted key, a trailing
16
+ * comma, a missing colon, an unterminated string) the two parsers diverge at
17
+ * the same character, so the position is exact. Wherever this scanner is
18
+ * looser or stricter than `JSON.parse` in some corner, the position can be
19
+ * early rather than exactly the runtime's own stopping point — which is why
20
+ * it is documented, and named, as best-effort rather than exact.
21
+ */
22
+
23
+ export interface JsonPosition {
24
+ /** 1-based, like every editor and every compiler diagnostic. */
25
+ line: number;
26
+ column: number;
27
+ }
28
+
29
+ /** Thrown internally the moment the grammar walk disagrees with the text. */
30
+ class ScanStopped {
31
+ constructor(readonly position: JsonPosition) {}
32
+ }
33
+
34
+ class Cursor {
35
+ private i = 0;
36
+ private line = 1;
37
+ private column = 1;
38
+
39
+ constructor(private readonly text: string) {}
40
+
41
+ get done(): boolean {
42
+ return this.i >= this.text.length;
43
+ }
44
+
45
+ peek(): string | undefined {
46
+ return this.text[this.i];
47
+ }
48
+
49
+ startsWith(s: string): boolean {
50
+ return this.text.startsWith(s, this.i);
51
+ }
52
+
53
+ advance(): void {
54
+ if (this.text[this.i] === '\n') {
55
+ this.line++;
56
+ this.column = 1;
57
+ } else {
58
+ this.column++;
59
+ }
60
+ this.i++;
61
+ }
62
+
63
+ position(): JsonPosition {
64
+ return { line: this.line, column: this.column };
65
+ }
66
+ }
67
+
68
+ function isDigit(c: string | undefined): boolean {
69
+ return c !== undefined && c >= '0' && c <= '9';
70
+ }
71
+
72
+ function isHexDigit(c: string | undefined): boolean {
73
+ return c !== undefined && ((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'));
74
+ }
75
+
76
+ function isWhitespace(c: string | undefined): boolean {
77
+ return c === ' ' || c === '\t' || c === '\n' || c === '\r';
78
+ }
79
+
80
+ function stop(cur: Cursor): never {
81
+ throw new ScanStopped(cur.position());
82
+ }
83
+
84
+ function skipWhitespace(cur: Cursor): void {
85
+ while (!cur.done && isWhitespace(cur.peek())) cur.advance();
86
+ }
87
+
88
+ function skipLiteral(cur: Cursor, length: number): void {
89
+ for (let k = 0; k < length; k++) cur.advance();
90
+ }
91
+
92
+ const STRING_ESCAPES = new Set(['"', '\\', '/', 'b', 'f', 'n', 'r', 't']);
93
+
94
+ function scanString(cur: Cursor): void {
95
+ cur.advance(); // opening quote
96
+ for (;;) {
97
+ if (cur.done) stop(cur);
98
+ const c = cur.peek()!;
99
+ if (c === '"') {
100
+ cur.advance();
101
+ return;
102
+ }
103
+ if (c === '\\') {
104
+ cur.advance();
105
+ const esc = cur.peek();
106
+ if (esc !== undefined && STRING_ESCAPES.has(esc)) {
107
+ cur.advance();
108
+ continue;
109
+ }
110
+ if (esc === 'u') {
111
+ cur.advance();
112
+ for (let k = 0; k < 4; k++) {
113
+ if (!isHexDigit(cur.peek())) stop(cur);
114
+ cur.advance();
115
+ }
116
+ continue;
117
+ }
118
+ stop(cur); // unknown escape
119
+ }
120
+ if (c.charCodeAt(0) < 0x20) stop(cur); // raw control character in a string
121
+ cur.advance();
122
+ }
123
+ }
124
+
125
+ function scanNumber(cur: Cursor): void {
126
+ if (cur.peek() === '-') cur.advance();
127
+ if (cur.peek() === '0') {
128
+ cur.advance();
129
+ } else if (isDigit(cur.peek())) {
130
+ while (isDigit(cur.peek())) cur.advance();
131
+ } else {
132
+ stop(cur);
133
+ }
134
+ if (cur.peek() === '.') {
135
+ cur.advance();
136
+ if (!isDigit(cur.peek())) stop(cur);
137
+ while (isDigit(cur.peek())) cur.advance();
138
+ }
139
+ if (cur.peek() === 'e' || cur.peek() === 'E') {
140
+ cur.advance();
141
+ if (cur.peek() === '+' || cur.peek() === '-') cur.advance();
142
+ if (!isDigit(cur.peek())) stop(cur);
143
+ while (isDigit(cur.peek())) cur.advance();
144
+ }
145
+ }
146
+
147
+ function scanValue(cur: Cursor): void {
148
+ skipWhitespace(cur);
149
+ if (cur.done) stop(cur);
150
+ const c = cur.peek();
151
+ if (c === '{') return scanObject(cur);
152
+ if (c === '[') return scanArray(cur);
153
+ if (c === '"') return scanString(cur);
154
+ if (c === '-' || isDigit(c)) return scanNumber(cur);
155
+ if (cur.startsWith('true')) return skipLiteral(cur, 4);
156
+ if (cur.startsWith('false')) return skipLiteral(cur, 5);
157
+ if (cur.startsWith('null')) return skipLiteral(cur, 4);
158
+ stop(cur);
159
+ }
160
+
161
+ function scanObject(cur: Cursor): void {
162
+ cur.advance(); // '{'
163
+ skipWhitespace(cur);
164
+ if (cur.peek() === '}') {
165
+ cur.advance();
166
+ return;
167
+ }
168
+ for (;;) {
169
+ skipWhitespace(cur);
170
+ // The classic unquoted-key mistake stops here — this is
171
+ // "Property name must be a string literal" in JavaScriptCore's own words.
172
+ if (cur.peek() !== '"') stop(cur);
173
+ scanString(cur);
174
+ skipWhitespace(cur);
175
+ if (cur.peek() !== ':') stop(cur);
176
+ cur.advance();
177
+ scanValue(cur);
178
+ skipWhitespace(cur);
179
+ const c = cur.peek();
180
+ if (c === ',') {
181
+ cur.advance();
182
+ continue;
183
+ }
184
+ if (c === '}') {
185
+ cur.advance();
186
+ return;
187
+ }
188
+ stop(cur);
189
+ }
190
+ }
191
+
192
+ function scanArray(cur: Cursor): void {
193
+ cur.advance(); // '['
194
+ skipWhitespace(cur);
195
+ if (cur.peek() === ']') {
196
+ cur.advance();
197
+ return;
198
+ }
199
+ for (;;) {
200
+ scanValue(cur);
201
+ skipWhitespace(cur);
202
+ const c = cur.peek();
203
+ if (c === ',') {
204
+ cur.advance();
205
+ skipWhitespace(cur);
206
+ continue;
207
+ }
208
+ if (c === ']') {
209
+ cur.advance();
210
+ return;
211
+ }
212
+ stop(cur);
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Where, in `text`, a strict JSON parse first disagrees with it. Best-effort:
218
+ * see the module doc for what that means and why.
219
+ */
220
+ export function locateJsonError(text: string): JsonPosition {
221
+ const cur = new Cursor(text);
222
+ try {
223
+ scanValue(cur);
224
+ skipWhitespace(cur);
225
+ if (!cur.done) stop(cur); // trailing content after the one top-level value
226
+ } catch (err) {
227
+ if (err instanceof ScanStopped) return err.position;
228
+ throw err;
229
+ }
230
+ // The grammar walk above found nothing wrong, even though `JSON.parse` did —
231
+ // this scanner is looser than the runtime parser in whatever way this file's
232
+ // fault is. Rather than claim a location it did not actually find, it points
233
+ // at the end of the file.
234
+ return cur.position();
235
+ }
236
+
237
+ /**
238
+ * `JSON.parse`, with a `(line N, column N)` suffix appended to the message
239
+ * whenever it throws a `SyntaxError` — the runtime's own message, positioned.
240
+ * Any other error (a non-JSON exception `JSON.parse` never actually throws,
241
+ * kept here only for type safety) passes through unchanged.
242
+ */
243
+ export function parseJsonWithPosition(text: string): unknown {
244
+ try {
245
+ return JSON.parse(text);
246
+ } catch (err) {
247
+ if (err instanceof SyntaxError) {
248
+ const { line, column } = locateJsonError(text);
249
+ throw new SyntaxError(`${err.message} (line ${line}, column ${column})`);
250
+ }
251
+ throw err;
252
+ }
253
+ }
package/src/png.ts CHANGED
@@ -1,10 +1,18 @@
1
1
  /**
2
2
  * PNG header reader.
3
3
  *
4
- * The only thing rigc needs from a part PNG is its true pixel size and whether
5
- * it carries an alpha channel, and both live in the IHDR chunk that every PNG
6
- * puts first. Parsing 26 bytes keeps the compiler dependency-free: no image
7
- * library, and nothing on the module path but rigc itself.
4
+ * The only things rigc needs from a part PNG are its true pixel size and whether
5
+ * it can draw a transparent pixel. The size is in the IHDR chunk that every PNG
6
+ * puts first; transparency is in the IHDR's colour type OR in a `tRNS` chunk a
7
+ * little further in, so the reader walks the file's small leading chunks and
8
+ * stops at the pixel data. That keeps the compiler dependency-free — no image
9
+ * library, and nothing on the module path but rigc itself — while still reading
10
+ * every place the answer can be written down.
11
+ *
12
+ * ⚠️ It reads a header, not an image: "can this file draw a transparent pixel",
13
+ * never "does it". Whether the art actually has a transparent margin is a
14
+ * question about pixels, and the tools that measure pixels decode the whole file
15
+ * ([`tools/plate.ts`](../tools/plate.ts)).
8
16
  *
9
17
  * Measuring instead of trusting is the whole point: an atlas `size:` that
10
18
  * disagrees with the file loads clean and collapses the UVs silently.
@@ -13,21 +21,72 @@ import { readFileSync } from 'node:fs';
13
21
 
14
22
  const SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
15
23
 
16
- /** PNG colour types that carry a per-pixel alpha channel. */
24
+ /**
25
+ * PNG colour types that carry a per-pixel alpha channel.
26
+ *
27
+ * ⭐ False here does NOT mean opaque, and reading it that way is what #215 was:
28
+ * types 0, 2 and 3 can all carry a `tRNS` chunk instead — a palette alpha table
29
+ * for indexed art, one invisible colour for the other two — and indexed+tRNS is
30
+ * the ordinary output of ImageMagick, Photoshop's PNG-8 export, GIMP's indexed
31
+ * mode, aseprite and pngquant. `hasTransparency` is the field to judge art by;
32
+ * this one answers the narrower question of where the alpha is stored.
33
+ */
17
34
  const COLOUR_TYPE_HAS_ALPHA: Record<number, boolean> = {
18
35
  0: false, // greyscale
19
36
  2: false, // truecolour
20
- 3: false, // indexed (may have tRNS, but not a straight alpha channel)
37
+ 3: false, // indexed (transparency, if any, is in tRNS)
21
38
  4: true, // greyscale + alpha
22
39
  6: true, // truecolour + alpha
23
40
  };
24
41
 
42
+ /** The spec's name for each colour type, for messages that have to name one. */
43
+ const COLOUR_TYPE_NAMES: Record<number, string> = {
44
+ 0: 'greyscale',
45
+ 2: 'truecolour',
46
+ 3: 'indexed',
47
+ 4: 'greyscale + alpha',
48
+ 6: 'truecolour + alpha',
49
+ };
50
+
51
+ /** How to say a colour type out loud. Unknown types print as themselves. */
52
+ export function colourTypeName(colourType: number): string {
53
+ return COLOUR_TYPE_NAMES[colourType] ?? 'unrecognised';
54
+ }
55
+
25
56
  export interface PngInfo {
26
57
  width: number;
27
58
  height: number;
28
59
  bitDepth: number;
29
60
  colourType: number;
61
+ /** A per-pixel alpha channel in the pixel data: colour types 4 and 6, and only those. */
30
62
  hasAlpha: boolean;
63
+ /** A `tRNS` chunk: a palette alpha table (type 3), or one invisible colour (types 0 and 2). */
64
+ hasTrns: boolean;
65
+ /** Either of the above — the file is able to draw a transparent pixel. */
66
+ hasTransparency: boolean;
67
+ }
68
+
69
+ /**
70
+ * Walk the chunk list looking for `tRNS`, stopping where it can no longer appear.
71
+ *
72
+ * The spec orders `tRNS` after `PLTE` and before the first `IDAT`, so this reads
73
+ * only the file's small leading chunks and never touches the compressed bulk. A
74
+ * length that would run past the end of the file ends the walk rather than
75
+ * throwing: a truncated PNG is A17 and A06's business, and answering "no tRNS"
76
+ * about a file nobody can open is the same answer either way.
77
+ */
78
+ function scanForTrns(buf: Buffer): boolean {
79
+ let at = 8; // past the signature; the first chunk is IHDR
80
+ while (at + 8 <= buf.length) {
81
+ const length = buf.readUInt32BE(at);
82
+ const type = buf.toString('latin1', at + 4, at + 8);
83
+ if (type === 'tRNS') return true;
84
+ if (type === 'IDAT' || type === 'IEND') return false;
85
+ const next = at + 12 + length; // 4 length + 4 type + body + 4 CRC
86
+ if (next <= at || next > buf.length) return false;
87
+ at = next;
88
+ }
89
+ return false;
31
90
  }
32
91
 
33
92
  export function readPngInfo(path: string): PngInfo {
@@ -40,11 +99,17 @@ export function readPngInfo(path: string): PngInfo {
40
99
  throw new Error(`PNG does not start with IHDR: ${path}`);
41
100
  }
42
101
  const colourType = buf.readUInt8(25);
102
+ const hasAlpha = COLOUR_TYPE_HAS_ALPHA[colourType] ?? false;
103
+ // A file with an alpha channel cannot also carry tRNS, so the scan is skipped
104
+ // for the types that already answered — which is every PNG rigc itself writes.
105
+ const hasTrns = hasAlpha ? false : scanForTrns(buf);
43
106
  return {
44
107
  width: buf.readUInt32BE(16),
45
108
  height: buf.readUInt32BE(20),
46
109
  bitDepth: buf.readUInt8(24),
47
110
  colourType,
48
- hasAlpha: COLOUR_TYPE_HAS_ALPHA[colourType] ?? false,
111
+ hasAlpha,
112
+ hasTrns,
113
+ hasTransparency: hasAlpha || hasTrns,
49
114
  };
50
115
  }