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/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
|
|
5
|
-
* it
|
|
6
|
-
* puts first
|
|
7
|
-
*
|
|
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
|
-
/**
|
|
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 (
|
|
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
|
|
111
|
+
hasAlpha,
|
|
112
|
+
hasTrns,
|
|
113
|
+
hasTransparency: hasAlpha || hasTrns,
|
|
49
114
|
};
|
|
50
115
|
}
|