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/preview.ts ADDED
@@ -0,0 +1,243 @@
1
+ /**
2
+ * The single-file preview — a compiled artifact playing in Esoteric Software's
3
+ * own web player, with nothing beside it.
4
+ *
5
+ * ⭐ Why this exists at all. `validate` answers "is this valid Spine", `check`
6
+ * answers "does it match these frames", and neither of them can answer the
7
+ * question a first user actually has: *does it look right?* A rig whose head sits
8
+ * visibly off its torso compiles green, loads in `spine-core` and steps
9
+ * numerically clean — the offsets are the ones the spec asked for. The only
10
+ * remedy for that class of error is looking, and until this command the package
11
+ * offered no way to look (issue #216).
12
+ *
13
+ * ## Why the official player, and not our own renderer
14
+ *
15
+ * There is a rasteriser in [`render.ts`](render.ts) already, and `rigc render`
16
+ * uses it. This is deliberately the other thing: `SpinePlayer` is the runtime
17
+ * Esoteric ships, so a rig that plays here has been played by the reference
18
+ * implementation rather than by ours. Every picture our own code draws is, at
19
+ * some level, rigc checking its own work — this one is not, which is why issue
20
+ * #151 settled on it as the surface a human votes on.
21
+ *
22
+ * ## Why one file, and what `rawDataURIs` is doing
23
+ *
24
+ * `SpinePlayer` normally fetches its skeleton, atlas and pages over HTTP, which
25
+ * would make a preview a directory plus a web server — a thing to set up rather
26
+ * than a thing to open. Its `rawDataURIs` option maps each of those paths to a
27
+ * `data:` URI instead, and the player's downloader takes the mapping in
28
+ * preference to the network. So the whole artifact — skeleton JSON, atlas text
29
+ * and every page's PNG bytes — is embedded, and the result is one `.html` file
30
+ * that plays when double-clicked and can be attached to a message.
31
+ *
32
+ * ⚠️ The keys are the paths the player asks for, and they must match exactly.
33
+ * `config.atlas` has no directory part, so the player's own texture resolution
34
+ * (`parentPath + pageName`) asks for each page under **the name the atlas spells**
35
+ * — `../parts/torso.png` and all. That is why the atlas text is embedded verbatim
36
+ * rather than rewritten to flat names: the file that plays is the file that was
37
+ * built, which is the entire value of the interop proof.
38
+ *
39
+ * ## The player is referenced, never vendored
40
+ *
41
+ * The `<script>` and `<link>` point at unpkg. Nothing Esoteric owns is copied
42
+ * into this repository or into the published package — see [NOTICE.md](../NOTICE.md)
43
+ * — and the generated page belongs to whoever ran the command. The cost is that
44
+ * the file needs the network the first time it is opened, which the page says out
45
+ * loud when the script does not arrive.
46
+ */
47
+ import { SPINE_VERSION } from './compile.ts';
48
+ import { BACKGROUND } from './render.ts';
49
+
50
+ /**
51
+ * The Spine Web Player line the generated page loads.
52
+ *
53
+ * Derived from the runtime line the compiler emits rather than written down
54
+ * twice: a page playing 4.2 data in a 4.3 player (or the reverse) is a failure
55
+ * mode nobody would look for, and this makes the two impossible to edit apart.
56
+ *
57
+ * The **patch** is deliberately a wildcard where `SPINE_VERSION` is exact. Data
58
+ * compatibility is a property of the minor line, and pinning a patch would break
59
+ * every generated page on the day spine-core ships a version the player did not.
60
+ */
61
+ export const PLAYER_LINE = `${SPINE_VERSION.split('.').slice(0, 2).join('.')}.*`;
62
+ export const PLAYER_SCRIPT_URL = `https://unpkg.com/@esotericsoftware/spine-player@${PLAYER_LINE}/dist/iife/spine-player.js`;
63
+ export const PLAYER_STYLE_URL = `https://unpkg.com/@esotericsoftware/spine-player@${PLAYER_LINE}/dist/spine-player.css`;
64
+
65
+ /** The name the embedded skeleton and atlas are asked for under, inside the page. */
66
+ export const SKELETON_KEY = 'skeleton.json';
67
+ export const ATLAS_KEY = 'skeleton.atlas';
68
+
69
+ /** One atlas page, named exactly as the atlas spells it, with its bytes. */
70
+ export interface PreviewPage {
71
+ /** The page name from the atlas text — a path, quite possibly a relative one. */
72
+ name: string;
73
+ bytes: Uint8Array;
74
+ }
75
+
76
+ export interface PreviewInput {
77
+ skeletonText: string;
78
+ atlasText: string;
79
+ pages: PreviewPage[];
80
+ /** The animation to autoplay and loop, or `null` for a skeleton with none. */
81
+ animation: string | null;
82
+ /** Every animation the page offers in its picker. */
83
+ animations: string[];
84
+ /** What the page calls itself — the skeleton's path, for the tab and the header. */
85
+ label: string;
86
+ /** rigc's own version, for the generated-by line. */
87
+ version: string;
88
+ }
89
+
90
+ /**
91
+ * A `data:` URI the player's downloader will take the fast path on.
92
+ *
93
+ * ⚠️ Base64 for the text assets too, not only for the PNGs. The downloader
94
+ * decides a value is a data URI rather than an alias by asking whether it
95
+ * contains a `.` — and a percent-encoded skeleton JSON is full of them, so the
96
+ * un-encoded form would be handed to `XMLHttpRequest` as a URL instead. Base64's
97
+ * alphabet has no `.` in it, so this always lands on the branch that decodes.
98
+ */
99
+ export function dataUri(mime: string, body: string | Uint8Array): string {
100
+ const base64 = (typeof body === 'string' ? Buffer.from(body, 'utf8') : Buffer.from(body)).toString('base64');
101
+ return `data:${mime};base64,${base64}`;
102
+ }
103
+
104
+ /** `#rrggbb` for the player's background, so a preview and `rigc render` agree. */
105
+ function backgroundHex(): string {
106
+ return `#${BACKGROUND.slice(0, 3)
107
+ .map((c) => c.toString(16).padStart(2, '0'))
108
+ .join('')}`;
109
+ }
110
+
111
+ /** The five characters that can end an element or an attribute early. */
112
+ export function escapeHtml(text: string): string {
113
+ return text
114
+ .replace(/&/g, '&amp;')
115
+ .replace(/</g, '&lt;')
116
+ .replace(/>/g, '&gt;')
117
+ .replace(/"/g, '&quot;')
118
+ .replace(/'/g, '&#39;');
119
+ }
120
+
121
+ /**
122
+ * JSON for embedding inside a `<script>` element.
123
+ *
124
+ * The escape is on `<` alone and it is enough: an HTML parser ends a script at
125
+ * `</script`, and it cannot see one if no `<` survives. `<` is the same
126
+ * string to a JSON reader, so nothing about the value changes. Page names come
127
+ * out of a file somebody else wrote, which is exactly why this is not optional.
128
+ */
129
+ function embeddedJson(value: unknown): string {
130
+ return JSON.stringify(value).replace(/</g, '\\u003c');
131
+ }
132
+
133
+ /**
134
+ * The whole page, as text.
135
+ *
136
+ * There is no template file and no build step: this is a `.ts` string, so the
137
+ * published package carries it the way it carries every other module, and the
138
+ * page it produces has no dependency of its own except the player URL above.
139
+ */
140
+ export function buildPreview(input: PreviewInput): string {
141
+ const rawDataURIs: Record<string, string> = {
142
+ [SKELETON_KEY]: dataUri('application/json', input.skeletonText),
143
+ [ATLAS_KEY]: dataUri('text/plain', input.atlasText),
144
+ };
145
+ for (const page of input.pages) rawDataURIs[page.name] = dataUri('image/png', page.bytes);
146
+
147
+ // `animation` is left off a skeleton with none rather than set to null: the
148
+ // player checks for the key's presence, and the setup pose held still is the
149
+ // honest picture of a rig that has no animation to play.
150
+ const config: Record<string, unknown> = {
151
+ skeleton: SKELETON_KEY,
152
+ atlas: ATLAS_KEY,
153
+ rawDataURIs,
154
+ animations: input.animations,
155
+ showControls: true,
156
+ alpha: false,
157
+ backgroundColor: backgroundHex(),
158
+ };
159
+ if (input.animation !== null) config.animation = input.animation;
160
+
161
+ const label = escapeHtml(input.label);
162
+ const played =
163
+ input.animation === null
164
+ ? 'no animation — the setup pose'
165
+ : `${escapeHtml(input.animation)}${input.animations.length > 1 ? ` (of ${input.animations.length}; pick another in the controls)` : ''}`;
166
+
167
+ return `<!doctype html>
168
+ <html lang="en">
169
+ <head>
170
+ <meta charset="utf-8">
171
+ <meta name="viewport" content="width=device-width, initial-scale=1">
172
+ <title>rigc preview — ${label}</title>
173
+ <!--
174
+ Generated by rigc ${escapeHtml(input.version)} — https://github.com/firejune/rigc
175
+
176
+ The skeleton, the atlas and every atlas page are embedded in this file as data
177
+ URIs, so it plays on its own with no server and no sibling files.
178
+
179
+ It plays them in the Spine Web Player, which is NOT embedded: the script and
180
+ stylesheet below are loaded from unpkg. The Spine Runtimes are Copyright (c)
181
+ 2013-2025 Esoteric Software LLC and are licensed under the Spine Runtimes
182
+ License Agreement — https://esotericsoftware.com/spine-runtimes-license — which
183
+ requires each user of a product integrating them to hold a Spine Editor
184
+ license. Nothing owned by Esoteric Software is redistributed by rigc.
185
+ -->
186
+ <link rel="stylesheet" href="${PLAYER_STYLE_URL}">
187
+ <style>
188
+ :root { color-scheme: light dark; }
189
+ html, body { margin: 0; height: 100%; }
190
+ body {
191
+ display: flex; flex-direction: column;
192
+ background: ${backgroundHex()};
193
+ color: #1a1a1a;
194
+ font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
195
+ }
196
+ header { padding: 10px 14px; border-bottom: 1px solid rgba(0, 0, 0, 0.15); }
197
+ header b { font-weight: 600; }
198
+ header span { opacity: 0.65; }
199
+ #rigc-player { flex: 1 1 auto; min-height: 0; }
200
+ #rigc-status { margin: 0; padding: 10px 14px; border-top: 1px solid rgba(0, 0, 0, 0.15); white-space: pre-wrap; }
201
+ #rigc-status[data-state="error"] { background: #7d1d1d; color: #fff; }
202
+ </style>
203
+ </head>
204
+ <body>
205
+ <header><b>${label}</b> <span>— ${played}</span></header>
206
+ <div id="rigc-player"></div>
207
+ <p id="rigc-status">loading the Spine Web Player…</p>
208
+ <script src="${PLAYER_SCRIPT_URL}"></script>
209
+ <script>
210
+ (function () {
211
+ var status = document.getElementById('rigc-status');
212
+ var state = { status: 'loading', message: null, player: null };
213
+ window.rigcPreview = state;
214
+ function say(kind, message) {
215
+ state.status = kind;
216
+ state.message = message;
217
+ status.textContent = message;
218
+ status.setAttribute('data-state', kind);
219
+ }
220
+ if (typeof window.spine === 'undefined' || typeof window.spine.SpinePlayer !== 'function') {
221
+ say('error', 'The Spine Web Player did not load from ${PLAYER_SCRIPT_URL} — this page needs a network connection the first time it is opened.');
222
+ return;
223
+ }
224
+ var config = ${embeddedJson(config)};
225
+ config.success = function (player) {
226
+ state.player = player;
227
+ say('ready', 'playing in Spine Web Player ${PLAYER_LINE} — everything it is drawing is embedded in this file.');
228
+ };
229
+ config.error = function (player, message) {
230
+ state.player = player;
231
+ say('error', String(message));
232
+ };
233
+ try {
234
+ new window.spine.SpinePlayer('rigc-player', config);
235
+ } catch (err) {
236
+ say('error', String(err && err.message ? err.message : err));
237
+ }
238
+ })();
239
+ </script>
240
+ </body>
241
+ </html>
242
+ `;
243
+ }
package/src/render.ts CHANGED
@@ -144,6 +144,11 @@ export const SHEET_COLUMNS = 8;
144
144
  export const SHEET_FILE = 'contact.png';
145
145
  /** One pixel of rule between tiles, and one around the outside. */
146
146
  export const SHEET_GAP = 1;
147
+ /** Default long side of one contact-sheet tile, in pixels. */
148
+ export const SHEET_TILE = 128;
149
+ /** The rule between tiles, and the frame number drawn in each. */
150
+ export const SHEET_RULE: RGBA = [176, 176, 176, 255];
151
+ export const SHEET_LABEL: RGBA = [96, 96, 96, 255];
147
152
 
148
153
  /** One rendered frame directory: which animation, at what rate, and what is on disk. */
149
154
  export interface FrameSet {
@@ -285,6 +290,19 @@ export function loadPosable(skeletonPath: string, atlasPath: string, atlasDir: s
285
290
  return posableFromText(readFileSync(skeletonPath, 'utf8'), readFileSync(atlasPath, 'utf8'), atlasDir);
286
291
  }
287
292
 
293
+ /**
294
+ * The page names an atlas declares, in the order it declares them.
295
+ *
296
+ * Through `TextureAtlas` rather than by reading the lines: a page name and a
297
+ * region name are both unindented in the atlas format, so anything that told them
298
+ * apart here would be a second opinion about the file's syntax — and the one
299
+ * caller that needs this list (`rigc preview`, embedding each page) has to agree
300
+ * exactly with the player that will ask for them by name.
301
+ */
302
+ export function atlasPageNames(atlasText: string): string[] {
303
+ return new TextureAtlas(atlasText).pages.map((page) => page.name);
304
+ }
305
+
288
306
  /** Same, for artifacts held in memory rather than on disk. */
289
307
  export function posableFromText(skeletonText: string, atlasText: string, atlasDir: string): Posable {
290
308
  const atlas = new TextureAtlas(atlasText);
@@ -911,6 +929,46 @@ export function renderFrame(frame: Frame, pages: Map<string, Plate>, viewport: V
911
929
  return plate;
912
930
  }
913
931
 
932
+ /**
933
+ * Every frame of one animation as one labelled grid, row major.
934
+ *
935
+ * Not decoration: rung 3's subject is *spacing* — how far a thing travels
936
+ * between two consecutive frames — and that is a comparison across frames. A
937
+ * reader flipping through 65 separate files is comparing against memory.
938
+ *
939
+ * ⭐ It lives here rather than beside either caller because the layout is a
940
+ * CONTRACT: `bench/render_reference.ts` writes the grid, `rigc render` writes the
941
+ * same grid for a user's own build, and `src/check.ts` reads a sheet's tiles back
942
+ * out of it (issue #36). Three programs reading one geometry is one definition or
943
+ * it is a bug waiting for the day two of them are edited apart.
944
+ */
945
+ export function contactSheet(frames: Frame[], pages: Map<string, Plate>, viewport: Viewport, tile: number): Plate {
946
+ const tileScale = tile / Math.max(viewport.width, viewport.height);
947
+ const tileW = Math.max(1, Math.round(viewport.width * tileScale));
948
+ const tileH = Math.max(1, Math.round(viewport.height * tileScale));
949
+ const columns = Math.min(SHEET_COLUMNS, frames.length);
950
+ const rows = Math.ceil(frames.length / columns);
951
+ const sheet = new Plate(columns * (tileW + SHEET_GAP) + SHEET_GAP, rows * (tileH + SHEET_GAP) + SHEET_GAP);
952
+ fill(sheet, SHEET_RULE);
953
+ const base = projector(viewport);
954
+ frames.forEach((frame, i) => {
955
+ const col = i % columns;
956
+ const row = Math.floor(i / columns);
957
+ const ox = col * (tileW + SHEET_GAP) + SHEET_GAP;
958
+ const oy = row * (tileH + SHEET_GAP) + SHEET_GAP;
959
+ const plate = new Plate(tileW, tileH);
960
+ fill(plate, BACKGROUND);
961
+ const project = (wx: number, wy: number): [number, number] => {
962
+ const [px, py] = base(wx, wy);
963
+ return [px * tileScale, py * tileScale];
964
+ };
965
+ for (const piece of frame.pieces) blitPiece(plate, pageFor(pages, piece), piece, project);
966
+ plate.text(String(i), 2, 2, 1, SHEET_LABEL);
967
+ for (let y = 0; y < tileH; y++) for (let x = 0; x < tileW; x++) sheet.set(ox + x, oy + y, plate.get(x, y));
968
+ });
969
+ return sheet;
970
+ }
971
+
914
972
  /** Where one thing landed in a frame, in frame pixels. */
915
973
  export interface Footprint {
916
974
  /** Alpha-weighted count of covered pixels. 0 means nothing was drawn. */
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
  });