picopilot 0.1.4 → 0.2.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/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +111 -16
- package/dist/cli.js.map +1 -1
- package/dist/commands/audio.d.ts +3 -3
- package/dist/commands/audio.d.ts.map +1 -1
- package/dist/commands/audio.js +61 -44
- package/dist/commands/audio.js.map +1 -1
- package/dist/commands/cart-includes.d.ts +11 -0
- package/dist/commands/cart-includes.d.ts.map +1 -0
- package/dist/commands/cart-includes.js +26 -0
- package/dist/commands/cart-includes.js.map +1 -0
- package/dist/commands/doctor.d.ts +22 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +222 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/export.d.ts +3 -4
- package/dist/commands/export.d.ts.map +1 -1
- package/dist/commands/export.js +66 -86
- package/dist/commands/export.js.map +1 -1
- package/dist/commands/gfx.d.ts +2 -2
- package/dist/commands/gfx.d.ts.map +1 -1
- package/dist/commands/gfx.js +3 -2
- package/dist/commands/gfx.js.map +1 -1
- package/dist/commands/label-cart.d.ts +72 -0
- package/dist/commands/label-cart.d.ts.map +1 -0
- package/dist/commands/label-cart.js +228 -0
- package/dist/commands/label-cart.js.map +1 -0
- package/dist/commands/lint.d.ts +2 -2
- package/dist/commands/lint.d.ts.map +1 -1
- package/dist/commands/lint.js +3 -2
- package/dist/commands/lint.js.map +1 -1
- package/dist/commands/minify.d.ts +2 -2
- package/dist/commands/minify.d.ts.map +1 -1
- package/dist/commands/minify.js +3 -2
- package/dist/commands/minify.js.map +1 -1
- package/dist/commands/pico8-env.d.ts +76 -0
- package/dist/commands/pico8-env.d.ts.map +1 -0
- package/dist/commands/pico8-env.js +71 -0
- package/dist/commands/pico8-env.js.map +1 -0
- package/dist/commands/playtest.d.ts +2 -2
- package/dist/commands/playtest.d.ts.map +1 -1
- package/dist/commands/playtest.js +6 -32
- package/dist/commands/playtest.js.map +1 -1
- package/dist/commands/run.d.ts +3 -4
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/run.js +46 -19
- package/dist/commands/run.js.map +1 -1
- package/dist/commands/serve.d.ts +2 -2
- package/dist/commands/serve.d.ts.map +1 -1
- package/dist/commands/serve.js +65 -19
- package/dist/commands/serve.js.map +1 -1
- package/dist/commands/shrinko-env.d.ts +22 -0
- package/dist/commands/shrinko-env.d.ts.map +1 -0
- package/dist/commands/shrinko-env.js +15 -0
- package/dist/commands/shrinko-env.js.map +1 -0
- package/dist/commands/tokens.d.ts +4 -4
- package/dist/commands/tokens.d.ts.map +1 -1
- package/dist/commands/tokens.js +3 -2
- package/dist/commands/tokens.js.map +1 -1
- package/dist/commands/verify.d.ts +2 -2
- package/dist/commands/verify.d.ts.map +1 -1
- package/dist/commands/verify.js +3 -2
- package/dist/commands/verify.js.map +1 -1
- package/dist/engine/child-env.d.ts +14 -0
- package/dist/engine/child-env.d.ts.map +1 -0
- package/dist/engine/child-env.js +21 -0
- package/dist/engine/child-env.js.map +1 -0
- package/dist/engine/gfx/label.d.ts +15 -9
- package/dist/engine/gfx/label.d.ts.map +1 -1
- package/dist/engine/gfx/label.js +130 -38
- package/dist/engine/gfx/label.js.map +1 -1
- package/dist/engine/pico8/adapter.d.ts +23 -3
- package/dist/engine/pico8/adapter.d.ts.map +1 -1
- package/dist/engine/pico8/adapter.js.map +1 -1
- package/dist/engine/pico8/cleanup.d.ts +16 -0
- package/dist/engine/pico8/cleanup.d.ts.map +1 -0
- package/dist/engine/pico8/cleanup.js +74 -0
- package/dist/engine/pico8/cleanup.js.map +1 -0
- package/dist/engine/pico8/display.d.ts +149 -0
- package/dist/engine/pico8/display.d.ts.map +1 -0
- package/dist/engine/pico8/display.js +311 -0
- package/dist/engine/pico8/display.js.map +1 -0
- package/dist/engine/pico8/drive.d.ts.map +1 -1
- package/dist/engine/pico8/drive.js +28 -2
- package/dist/engine/pico8/drive.js.map +1 -1
- package/dist/engine/pico8/harness.d.ts +8 -0
- package/dist/engine/pico8/harness.d.ts.map +1 -1
- package/dist/engine/pico8/harness.js +9 -1
- package/dist/engine/pico8/harness.js.map +1 -1
- package/dist/engine/pico8/index.d.ts +3 -0
- package/dist/engine/pico8/index.d.ts.map +1 -1
- package/dist/engine/pico8/index.js +3 -0
- package/dist/engine/pico8/index.js.map +1 -1
- package/dist/engine/pico8/session-daemon-entry.d.ts +1 -1
- package/dist/engine/pico8/session-daemon-entry.js +1 -1
- package/dist/engine/pico8/session-daemon-main.d.ts +1 -1
- package/dist/engine/pico8/session-daemon-main.js +1 -1
- package/dist/engine/pico8/session-daemon.d.ts +1 -1
- package/dist/engine/pico8/session-daemon.d.ts.map +1 -1
- package/dist/engine/pico8/session-daemon.js +19 -19
- package/dist/engine/pico8/session-daemon.js.map +1 -1
- package/dist/engine/pico8/session.d.ts +1 -1
- package/dist/engine/pico8/session.js +1 -1
- package/dist/engine/pico8/shell.d.ts +56 -3
- package/dist/engine/pico8/shell.d.ts.map +1 -1
- package/dist/engine/pico8/shell.js +188 -24
- package/dist/engine/pico8/shell.js.map +1 -1
- package/dist/engine/pico8/supervisor.d.ts +1 -1
- package/dist/engine/pico8/supervisor.js +1 -1
- package/dist/engine/shrinko/adapter.d.ts +1 -1
- package/dist/engine/shrinko/shell.d.ts +3 -3
- package/dist/engine/shrinko/shell.js +3 -3
- package/package.json +2 -1
- package/src/cli.test.ts +188 -0
- package/src/cli.ts +122 -16
- package/src/commands/audio.test.ts +76 -1
- package/src/commands/audio.ts +79 -57
- package/src/commands/cart-includes.ts +27 -0
- package/src/commands/doctor.test.ts +149 -0
- package/src/commands/doctor.ts +280 -0
- package/src/commands/export.test.ts +327 -3
- package/src/commands/export.ts +81 -99
- package/src/commands/gfx.ts +7 -6
- package/src/commands/label-cart.ts +304 -0
- package/src/commands/lint.test.ts +6 -0
- package/src/commands/lint.ts +7 -6
- package/src/commands/minify.test.ts +6 -0
- package/src/commands/minify.ts +7 -6
- package/src/commands/pico8-env.ts +124 -0
- package/src/commands/playtest.test.ts +3 -0
- package/src/commands/playtest.ts +10 -34
- package/src/commands/run.test.ts +116 -2
- package/src/commands/run.ts +62 -24
- package/src/commands/serve.test.ts +138 -2
- package/src/commands/serve.ts +80 -21
- package/src/commands/shrinko-env.test.ts +68 -0
- package/src/commands/shrinko-env.ts +40 -0
- package/src/commands/tokens.test.ts +19 -0
- package/src/commands/tokens.ts +9 -6
- package/src/commands/verify.test.ts +12 -0
- package/src/commands/verify.ts +7 -4
- package/src/engine/child-env.ts +22 -0
- package/src/engine/gfx/fixtures/pico8-screenshot-384.png +0 -0
- package/src/engine/gfx/label.test.ts +467 -1
- package/src/engine/gfx/label.ts +147 -41
- package/src/engine/pico8/adapter.ts +25 -3
- package/src/engine/pico8/cleanup.test.ts +28 -0
- package/src/engine/pico8/cleanup.ts +76 -0
- package/src/engine/pico8/display.test.ts +210 -0
- package/src/engine/pico8/display.ts +429 -0
- package/src/engine/pico8/drive.test.ts +20 -0
- package/src/engine/pico8/drive.ts +25 -2
- package/src/engine/pico8/fake-xvfb.test-support.ts +55 -0
- package/src/engine/pico8/harness.test.ts +26 -0
- package/src/engine/pico8/harness.ts +18 -2
- package/src/engine/pico8/index.ts +3 -0
- package/src/engine/pico8/session-daemon-entry.ts +1 -1
- package/src/engine/pico8/session-daemon-main.ts +1 -1
- package/src/engine/pico8/session-daemon.test.ts +32 -1
- package/src/engine/pico8/session-daemon.ts +25 -17
- package/src/engine/pico8/session.test.ts +1 -1
- package/src/engine/pico8/session.ts +1 -1
- package/src/engine/pico8/shell.test.ts +449 -10
- package/src/engine/pico8/shell.ts +255 -26
- package/src/engine/pico8/supervisor.ts +1 -1
- package/src/engine/pico8/virtual-display.process.test.ts +206 -0
- package/src/engine/shrinko/adapter.ts +1 -1
- package/src/engine/shrinko/shell.ts +3 -3
- package/src/engine/skills/skills.test.ts +10 -0
- package/src/skills/picopilot-audio/SKILL.md +4 -8
- package/src/skills/picopilot-debug/SKILL.md +18 -1
- package/src/skills/picopilot-overview/SKILL.md +9 -1
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import {overlayEnv} from '../engine/child-env.js';
|
|
2
|
+
import {
|
|
3
|
+
type ChildRunner,
|
|
4
|
+
type ShrinkoAdapter,
|
|
5
|
+
ShellShrinkoAdapter,
|
|
6
|
+
} from '../engine/shrinko/index.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Builds the {@link ShrinkoAdapter} a shrinko-backed command uses, given the
|
|
10
|
+
* incur-resolved env (its `PATH`). A test passes its own factory to swap in a
|
|
11
|
+
* stub adapter (the seam-swap contract).
|
|
12
|
+
*/
|
|
13
|
+
export type ShrinkoAdapterFactory = (env: NodeJS.ProcessEnv) => ShrinkoAdapter;
|
|
14
|
+
|
|
15
|
+
/** Dependencies for {@link createShellShrinkoFactory}, injectable for tests. */
|
|
16
|
+
export interface ShellShrinkoFactoryDeps {
|
|
17
|
+
/** The parent environment shrinko inherits (defaults to `process.env`). */
|
|
18
|
+
readonly parentEnv?: NodeJS.ProcessEnv;
|
|
19
|
+
/** The child runner (defaults to the real `execFile`). */
|
|
20
|
+
readonly run?: ChildRunner;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The default shrinko factory: the shell adapter over a user-installed
|
|
25
|
+
* shrinko8, whose child env is the parent env overlaid with the command's
|
|
26
|
+
* resolved `PATH`. shrinko8 is a Python tool, so a `pip install --user` copy is
|
|
27
|
+
* only found when HOME (user site-packages) reaches the child.
|
|
28
|
+
*/
|
|
29
|
+
export function createShellShrinkoFactory(
|
|
30
|
+
deps: ShellShrinkoFactoryDeps = {},
|
|
31
|
+
): ShrinkoAdapterFactory {
|
|
32
|
+
return (env) =>
|
|
33
|
+
new ShellShrinkoAdapter({
|
|
34
|
+
env: overlayEnv(
|
|
35
|
+
env as Record<string, string | undefined>,
|
|
36
|
+
deps.parentEnv ?? process.env,
|
|
37
|
+
),
|
|
38
|
+
run: deps.run,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -34,6 +34,22 @@ afterEach(() => {
|
|
|
34
34
|
}
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
+
/** The ShrinkoAdapter members `tokens` never calls, for hand-built adapters. */
|
|
38
|
+
const unusedShrinko: Omit<ShrinkoAdapter, 'count'> = {
|
|
39
|
+
async lint() {
|
|
40
|
+
throw new Error('lint not used');
|
|
41
|
+
},
|
|
42
|
+
async minify() {
|
|
43
|
+
throw new Error('minify not used');
|
|
44
|
+
},
|
|
45
|
+
async exportSpritesheet() {
|
|
46
|
+
throw new Error('exportSpritesheet not used');
|
|
47
|
+
},
|
|
48
|
+
async importSpritesheet() {
|
|
49
|
+
throw new Error('importSpritesheet not used');
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
37
53
|
/**
|
|
38
54
|
* A stub {@link ShrinkoAdapter} standing in for BOTH a shrinko-present shell
|
|
39
55
|
* adapter and a hypothetical native-TS adapter. It never spawns anything, so no
|
|
@@ -42,6 +58,7 @@ afterEach(() => {
|
|
|
42
58
|
*/
|
|
43
59
|
function stubAdapter(result: ShrinkoResult<CountReport>): ShrinkoAdapter {
|
|
44
60
|
return {
|
|
61
|
+
...unusedShrinko,
|
|
45
62
|
async count() {
|
|
46
63
|
return result;
|
|
47
64
|
},
|
|
@@ -163,6 +180,7 @@ describe('picopilot tokens: seam-swap (a stub adapter leaves output/exit unchang
|
|
|
163
180
|
const value = present(true);
|
|
164
181
|
// A: returns the value synchronously-resolved.
|
|
165
182
|
const adapterA: ShrinkoAdapter = {
|
|
183
|
+
...unusedShrinko,
|
|
166
184
|
async count() {
|
|
167
185
|
return value;
|
|
168
186
|
},
|
|
@@ -170,6 +188,7 @@ describe('picopilot tokens: seam-swap (a stub adapter leaves output/exit unchang
|
|
|
170
188
|
// B: a DIFFERENT implementation (extra async work, freshly-built struct) that
|
|
171
189
|
// yields the same count. Stands in for a native-TS shrinko behind the seam.
|
|
172
190
|
const adapterB: ShrinkoAdapter = {
|
|
191
|
+
...unusedShrinko,
|
|
173
192
|
async count() {
|
|
174
193
|
await Promise.resolve();
|
|
175
194
|
const v = value.ok ? value.value : undefined;
|
package/src/commands/tokens.ts
CHANGED
|
@@ -5,16 +5,19 @@ import {type Cli, z} from 'incur';
|
|
|
5
5
|
import {
|
|
6
6
|
type ShrinkoAdapter,
|
|
7
7
|
ShrinkoParseError,
|
|
8
|
-
ShellShrinkoAdapter,
|
|
9
8
|
} from '../engine/shrinko/index.js';
|
|
9
|
+
import {
|
|
10
|
+
createShellShrinkoFactory,
|
|
11
|
+
type ShrinkoAdapterFactory,
|
|
12
|
+
} from './shrinko-env.js';
|
|
10
13
|
|
|
11
14
|
/** PICO-8's Lua token budget. A cart over this will not load in PICO-8 (US #3). */
|
|
12
15
|
export const TOKEN_BUDGET = 8192;
|
|
13
16
|
|
|
14
17
|
/**
|
|
15
18
|
* Builds the {@link ShrinkoAdapter} a command uses, given the incur-resolved
|
|
16
|
-
* env. The default is the shell-out
|
|
17
|
-
*
|
|
19
|
+
* env. The default is the shell-out `ShellShrinkoAdapter`, whose child process
|
|
20
|
+
* gets the parent env with this resolved `PATH` overlaid (see `shrinko-env.ts`).
|
|
18
21
|
*
|
|
19
22
|
* A test passes its own factory to {@link registerTokens} to SWAP a stub
|
|
20
23
|
* adapter (the seam-swap contract): the command's public output/exit must be
|
|
@@ -22,10 +25,10 @@ export const TOKEN_BUDGET = 8192;
|
|
|
22
25
|
* seam, because the command only ever talks to the {@link ShrinkoAdapter}
|
|
23
26
|
* interface.
|
|
24
27
|
*/
|
|
25
|
-
export type ShrinkoAdapterFactory
|
|
28
|
+
export type {ShrinkoAdapterFactory} from './shrinko-env.js';
|
|
26
29
|
|
|
27
|
-
const defaultAdapterFactory: ShrinkoAdapterFactory =
|
|
28
|
-
|
|
30
|
+
const defaultAdapterFactory: ShrinkoAdapterFactory =
|
|
31
|
+
createShellShrinkoFactory();
|
|
29
32
|
|
|
30
33
|
/**
|
|
31
34
|
* Registers `picopilot tokens` (US #3), the first shrinko-backed command.
|
|
@@ -52,6 +52,18 @@ afterEach(() => {
|
|
|
52
52
|
*/
|
|
53
53
|
function stubAdapter(result: ShrinkoResult<CountReport>): ShrinkoAdapter {
|
|
54
54
|
return {
|
|
55
|
+
async lint() {
|
|
56
|
+
throw new Error('lint not used');
|
|
57
|
+
},
|
|
58
|
+
async minify() {
|
|
59
|
+
throw new Error('minify not used');
|
|
60
|
+
},
|
|
61
|
+
async exportSpritesheet() {
|
|
62
|
+
throw new Error('exportSpritesheet not used');
|
|
63
|
+
},
|
|
64
|
+
async importSpritesheet() {
|
|
65
|
+
throw new Error('importSpritesheet not used');
|
|
66
|
+
},
|
|
55
67
|
async count() {
|
|
56
68
|
return result;
|
|
57
69
|
},
|
package/src/commands/verify.ts
CHANGED
|
@@ -6,8 +6,11 @@ import {Cart, CartParseError} from '../engine/cart/index.js';
|
|
|
6
6
|
import {
|
|
7
7
|
type ShrinkoAdapter,
|
|
8
8
|
ShrinkoParseError,
|
|
9
|
-
ShellShrinkoAdapter,
|
|
10
9
|
} from '../engine/shrinko/index.js';
|
|
10
|
+
import {
|
|
11
|
+
createShellShrinkoFactory,
|
|
12
|
+
type ShrinkoAdapterFactory,
|
|
13
|
+
} from './shrinko-env.js';
|
|
11
14
|
import {TOKEN_BUDGET} from './tokens.js';
|
|
12
15
|
|
|
13
16
|
/**
|
|
@@ -29,10 +32,10 @@ export const VERIFY_SCOPE =
|
|
|
29
32
|
export const GATE_INCAPABLE_EXIT = 2;
|
|
30
33
|
|
|
31
34
|
/** See {@link ShrinkoAdapterFactory} in `tokens.ts` — verify reuses the same seam. */
|
|
32
|
-
export type ShrinkoAdapterFactory
|
|
35
|
+
export type {ShrinkoAdapterFactory} from './shrinko-env.js';
|
|
33
36
|
|
|
34
|
-
const defaultAdapterFactory: ShrinkoAdapterFactory =
|
|
35
|
-
|
|
37
|
+
const defaultAdapterFactory: ShrinkoAdapterFactory =
|
|
38
|
+
createShellShrinkoFactory();
|
|
36
39
|
|
|
37
40
|
/**
|
|
38
41
|
* The result of the integrity check: the cart parses AND round-trips
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The environment an external tool (PICO-8, shrinko8) is spawned with: the
|
|
3
|
+
* PARENT environment overlaid with the values a command RESOLVED (incur parses
|
|
4
|
+
* only the keys a command declares, e.g. PICO8_PATH / PATH). Handing the child
|
|
5
|
+
* just that parsed subset strips everything else it needs: DISPLAY, HOME,
|
|
6
|
+
* XDG_RUNTIME_DIR and SDL_* for PICO-8 (ADR-0017), HOME / PYTHONUSERBASE for a
|
|
7
|
+
* `pip install --user` shrinko8. An `undefined` resolved value never erases a
|
|
8
|
+
* parent value.
|
|
9
|
+
*
|
|
10
|
+
* `parent` is a parameter (the command layer defaults it to `process.env`; it is
|
|
11
|
+
* never read here) so tests inject a controlled environment.
|
|
12
|
+
*/
|
|
13
|
+
export function overlayEnv(
|
|
14
|
+
resolved: Readonly<Record<string, string | undefined>>,
|
|
15
|
+
parent: NodeJS.ProcessEnv,
|
|
16
|
+
): NodeJS.ProcessEnv {
|
|
17
|
+
const env: NodeJS.ProcessEnv = {...parent};
|
|
18
|
+
for (const [key, value] of Object.entries(resolved)) {
|
|
19
|
+
if (value !== undefined) env[key] = value;
|
|
20
|
+
}
|
|
21
|
+
return env;
|
|
22
|
+
}
|
|
Binary file
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import {readFileSync} from 'node:fs';
|
|
2
|
+
import {deflateSync} from 'node:zlib';
|
|
1
3
|
import {describe, expect, it} from 'vitest';
|
|
2
4
|
import {
|
|
3
5
|
LABEL_SIZE,
|
|
@@ -27,6 +29,151 @@ function solidLabelPng(paletteIndex: number): Uint8Array {
|
|
|
27
29
|
return encodePng({width: LABEL_SIZE, height: LABEL_SIZE, rgb});
|
|
28
30
|
}
|
|
29
31
|
|
|
32
|
+
/**
|
|
33
|
+
* A test-only general PNG encoder (any colour type / bit depth incl. 16, optional
|
|
34
|
+
* PLTE, any of the five scanline filters) so the decoder is exercised on formats
|
|
35
|
+
* our production `encodePng` never emits: palette-indexed (what an image tool
|
|
36
|
+
* writes when it shrinks a PICO-8 screenshot), greyscale, 16-bit, and the
|
|
37
|
+
* previous-row filters. `samples` holds one value per channel per pixel,
|
|
38
|
+
* row-major, already at `bitDepth` range. Written from the PNG spec
|
|
39
|
+
* independently of the decoder: the filter distance is `ceil(bits/8)` per the
|
|
40
|
+
* spec's wording, and the IDAT is zlib-compressed by Node.
|
|
41
|
+
*/
|
|
42
|
+
function encodeTestPng(opts: {
|
|
43
|
+
width: number;
|
|
44
|
+
height: number;
|
|
45
|
+
colourType: 0 | 2 | 3 | 4 | 6;
|
|
46
|
+
bitDepth: 1 | 2 | 4 | 8 | 16;
|
|
47
|
+
samples: ArrayLike<number>;
|
|
48
|
+
palette?: ReadonlyArray<{r: number; g: number; b: number}>;
|
|
49
|
+
/** Scanline filter applied to every row: 0 None, 1 Sub, 2 Up, 3 Average, 4 Paeth. */
|
|
50
|
+
filter?: 0 | 1 | 2 | 3 | 4;
|
|
51
|
+
}): Uint8Array {
|
|
52
|
+
const {width, height, colourType, bitDepth, samples} = opts;
|
|
53
|
+
const channels = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}[colourType];
|
|
54
|
+
const bitsPerPixel = channels * bitDepth;
|
|
55
|
+
const stride = Math.ceil((width * bitsPerPixel) / 8);
|
|
56
|
+
const bpp = Math.ceil(bitsPerPixel / 8);
|
|
57
|
+
const filter = opts.filter ?? 0;
|
|
58
|
+
const raw = new Uint8Array(height * (stride + 1));
|
|
59
|
+
let prev = new Uint8Array(stride);
|
|
60
|
+
for (let y = 0; y < height; y++) {
|
|
61
|
+
const line = new Uint8Array(stride);
|
|
62
|
+
for (let x = 0; x < width * channels; x++) {
|
|
63
|
+
const v = samples[y * width * channels + x]!;
|
|
64
|
+
if (bitDepth === 16) {
|
|
65
|
+
line[x * 2] = v >> 8;
|
|
66
|
+
line[x * 2 + 1] = v & 0xff;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const bit = x * bitDepth;
|
|
70
|
+
line[bit >> 3]! |= v << (8 - bitDepth - (bit & 7));
|
|
71
|
+
}
|
|
72
|
+
const row = y * (stride + 1);
|
|
73
|
+
raw[row] = filter;
|
|
74
|
+
for (let i = 0; i < stride; i++) {
|
|
75
|
+
const a = i >= bpp ? line[i - bpp]! : 0;
|
|
76
|
+
const b = prev[i]!;
|
|
77
|
+
const c = i >= bpp ? prev[i - bpp]! : 0;
|
|
78
|
+
const predictor = [
|
|
79
|
+
0,
|
|
80
|
+
a,
|
|
81
|
+
b,
|
|
82
|
+
(a + b) >> 1,
|
|
83
|
+
(() => {
|
|
84
|
+
const p = a + b - c;
|
|
85
|
+
const pa = Math.abs(p - a);
|
|
86
|
+
const pb = Math.abs(p - b);
|
|
87
|
+
const pc = Math.abs(p - c);
|
|
88
|
+
return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
|
89
|
+
})(),
|
|
90
|
+
][filter]!;
|
|
91
|
+
raw[row + 1 + i] = (line[i]! - predictor) & 0xff;
|
|
92
|
+
}
|
|
93
|
+
prev = line;
|
|
94
|
+
}
|
|
95
|
+
const ihdr = new Uint8Array(13);
|
|
96
|
+
const dv = new DataView(ihdr.buffer);
|
|
97
|
+
dv.setUint32(0, width);
|
|
98
|
+
dv.setUint32(4, height);
|
|
99
|
+
ihdr[8] = bitDepth;
|
|
100
|
+
ihdr[9] = colourType;
|
|
101
|
+
const parts: Uint8Array[] = [
|
|
102
|
+
Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
103
|
+
testChunk('IHDR', ihdr),
|
|
104
|
+
];
|
|
105
|
+
if (opts.palette) {
|
|
106
|
+
const plte = new Uint8Array(opts.palette.length * 3);
|
|
107
|
+
opts.palette.forEach((c, i) => plte.set([c.r, c.g, c.b], i * 3));
|
|
108
|
+
parts.push(testChunk('PLTE', plte));
|
|
109
|
+
}
|
|
110
|
+
parts.push(testChunk('IDAT', deflateSync(raw)));
|
|
111
|
+
parts.push(testChunk('IEND', new Uint8Array(0)));
|
|
112
|
+
return Uint8Array.from(Buffer.concat(parts));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function testChunk(type: string, data: Uint8Array): Uint8Array {
|
|
116
|
+
const out = new Uint8Array(12 + data.length);
|
|
117
|
+
const dv = new DataView(out.buffer);
|
|
118
|
+
dv.setUint32(0, data.length);
|
|
119
|
+
for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i);
|
|
120
|
+
out.set(data, 8);
|
|
121
|
+
let crc = 0xffffffff;
|
|
122
|
+
for (const byte of out.subarray(4, 8 + data.length)) {
|
|
123
|
+
crc ^= byte;
|
|
124
|
+
for (let k = 0; k < 8; k++)
|
|
125
|
+
crc = crc & 1 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1;
|
|
126
|
+
}
|
|
127
|
+
dv.setUint32(8 + data.length, (crc ^ 0xffffffff) >>> 0);
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The {@link LabelError} code `fn` throws (fails the test if it throws anything else or nothing). */
|
|
132
|
+
function labelErrorCode(fn: () => unknown): string {
|
|
133
|
+
try {
|
|
134
|
+
fn();
|
|
135
|
+
} catch (e) {
|
|
136
|
+
expect(e).toBeInstanceOf(LabelError);
|
|
137
|
+
return (e as LabelError).code;
|
|
138
|
+
}
|
|
139
|
+
return expect.unreachable('should have thrown a LabelError');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The palette index at label pixel (x, y) in a `__label__` hex body. */
|
|
143
|
+
function labelAt(hex: string, x: number, y: number): number {
|
|
144
|
+
return parseInt(hex.split('\n')[y]![x]!, 16);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A 128x128 pattern of PICO-8 indices: index = (x + 3y) mod 16 (every colour, varied neighbours). */
|
|
148
|
+
const patternIndex = (x: number, y: number): number => (x + 3 * y) % 16;
|
|
149
|
+
|
|
150
|
+
/** Expected label hex for {@link patternIndex}. */
|
|
151
|
+
function patternHex(): string {
|
|
152
|
+
const rows: string[] = [];
|
|
153
|
+
for (let y = 0; y < LABEL_SIZE; y++) {
|
|
154
|
+
let row = '';
|
|
155
|
+
for (let x = 0; x < LABEL_SIZE; x++) row += patternIndex(x, y).toString(16);
|
|
156
|
+
rows.push(row);
|
|
157
|
+
}
|
|
158
|
+
return `${rows.join('\n')}\n`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** An RGB PNG of {@link patternIndex} upscaled by an integer `scale` (a PICO-8 screenshot shape). */
|
|
162
|
+
function upscaledPatternPng(scale: number): Uint8Array {
|
|
163
|
+
const size = LABEL_SIZE * scale;
|
|
164
|
+
const rgb = new Uint8Array(size * size * 3);
|
|
165
|
+
for (let y = 0; y < size; y++) {
|
|
166
|
+
for (let x = 0; x < size; x++) {
|
|
167
|
+
const c =
|
|
168
|
+
PICO8_PALETTE[
|
|
169
|
+
patternIndex(Math.floor(x / scale), Math.floor(y / scale))
|
|
170
|
+
]!;
|
|
171
|
+
rgb.set([c.r, c.g, c.b], (y * size + x) * 3);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return encodePng({width: size, height: size, rgb});
|
|
175
|
+
}
|
|
176
|
+
|
|
30
177
|
describe('nearestPaletteIndex', () => {
|
|
31
178
|
it('maps each exact palette RGB back to its own index', () => {
|
|
32
179
|
for (let i = 0; i < PICO8_PALETTE.length; i++) {
|
|
@@ -91,7 +238,7 @@ describe('labelHexFromPng', () => {
|
|
|
91
238
|
);
|
|
92
239
|
});
|
|
93
240
|
|
|
94
|
-
it('rejects a wrong-size image (
|
|
241
|
+
it('rejects a wrong-size image (smaller than 128x128)', () => {
|
|
95
242
|
const rgb = new Uint8Array(64 * 64 * 3);
|
|
96
243
|
const png = encodePng({width: 64, height: 64, rgb} as RgbImage);
|
|
97
244
|
try {
|
|
@@ -103,3 +250,322 @@ describe('labelHexFromPng', () => {
|
|
|
103
250
|
}
|
|
104
251
|
});
|
|
105
252
|
});
|
|
253
|
+
|
|
254
|
+
describe('labelHexFromPng: palette-indexed and greyscale PNGs', () => {
|
|
255
|
+
it('decodes a 4-bit palette-indexed PNG (colour type 3, PLTE = PICO-8 palette)', () => {
|
|
256
|
+
const samples = new Uint8Array(LABEL_SIZE * LABEL_SIZE);
|
|
257
|
+
for (let y = 0; y < LABEL_SIZE; y++) {
|
|
258
|
+
for (let x = 0; x < LABEL_SIZE; x++)
|
|
259
|
+
samples[y * LABEL_SIZE + x] = patternIndex(x, y);
|
|
260
|
+
}
|
|
261
|
+
const png = encodeTestPng({
|
|
262
|
+
width: LABEL_SIZE,
|
|
263
|
+
height: LABEL_SIZE,
|
|
264
|
+
colourType: 3,
|
|
265
|
+
bitDepth: 4,
|
|
266
|
+
samples,
|
|
267
|
+
palette: PICO8_PALETTE,
|
|
268
|
+
});
|
|
269
|
+
expect(labelHexFromPng(png)).toBe(patternHex());
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('maps PLTE entries, not raw indices (a shuffled 4-bit palette, Sub-filtered rows)', () => {
|
|
273
|
+
// PLTE slot i holds PICO-8 colour (15 - i): the decoder must look colours
|
|
274
|
+
// up through PLTE rather than treating the stored index as a PICO-8 index.
|
|
275
|
+
const reversed = [...PICO8_PALETTE].reverse();
|
|
276
|
+
const samples = new Uint8Array(LABEL_SIZE * LABEL_SIZE);
|
|
277
|
+
for (let y = 0; y < LABEL_SIZE; y++) {
|
|
278
|
+
for (let x = 0; x < LABEL_SIZE; x++)
|
|
279
|
+
samples[y * LABEL_SIZE + x] = 15 - patternIndex(x, y);
|
|
280
|
+
}
|
|
281
|
+
const png = encodeTestPng({
|
|
282
|
+
width: LABEL_SIZE,
|
|
283
|
+
height: LABEL_SIZE,
|
|
284
|
+
colourType: 3,
|
|
285
|
+
bitDepth: 4,
|
|
286
|
+
samples,
|
|
287
|
+
palette: reversed,
|
|
288
|
+
filter: 1,
|
|
289
|
+
});
|
|
290
|
+
expect(labelHexFromPng(png)).toBe(patternHex());
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it.each([1, 2, 8] as const)(
|
|
294
|
+
'decodes a %i-bit palette-indexed PNG',
|
|
295
|
+
(bitDepth) => {
|
|
296
|
+
const colours = Math.min(16, 1 << bitDepth);
|
|
297
|
+
const samples = new Uint8Array(LABEL_SIZE * LABEL_SIZE);
|
|
298
|
+
for (let y = 0; y < LABEL_SIZE; y++) {
|
|
299
|
+
for (let x = 0; x < LABEL_SIZE; x++)
|
|
300
|
+
samples[y * LABEL_SIZE + x] = (x + y) % colours;
|
|
301
|
+
}
|
|
302
|
+
const png = encodeTestPng({
|
|
303
|
+
width: LABEL_SIZE,
|
|
304
|
+
height: LABEL_SIZE,
|
|
305
|
+
colourType: 3,
|
|
306
|
+
bitDepth,
|
|
307
|
+
samples,
|
|
308
|
+
palette: PICO8_PALETTE.slice(0, colours),
|
|
309
|
+
filter: 1,
|
|
310
|
+
});
|
|
311
|
+
const hex = labelHexFromPng(png);
|
|
312
|
+
for (const [x, y] of [
|
|
313
|
+
[0, 0],
|
|
314
|
+
[1, 0],
|
|
315
|
+
[5, 9],
|
|
316
|
+
[127, 127],
|
|
317
|
+
[126, 3],
|
|
318
|
+
] as const) {
|
|
319
|
+
expect(labelAt(hex, x, y)).toBe((x + y) % colours);
|
|
320
|
+
}
|
|
321
|
+
},
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
it.each([
|
|
325
|
+
[1, 2],
|
|
326
|
+
[1, 3],
|
|
327
|
+
[1, 4],
|
|
328
|
+
[4, 2],
|
|
329
|
+
[4, 3],
|
|
330
|
+
[4, 4],
|
|
331
|
+
] as const)(
|
|
332
|
+
'decodes a %i-bit indexed PNG under previous-row filter %i (Up/Average/Paeth)',
|
|
333
|
+
(bitDepth, filter) => {
|
|
334
|
+
const colours = 1 << Math.min(bitDepth, 4);
|
|
335
|
+
const samples = new Uint8Array(LABEL_SIZE * LABEL_SIZE);
|
|
336
|
+
for (let y = 0; y < LABEL_SIZE; y++) {
|
|
337
|
+
for (let x = 0; x < LABEL_SIZE; x++) {
|
|
338
|
+
samples[y * LABEL_SIZE + x] = patternIndex(x, y) % colours;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
const png = encodeTestPng({
|
|
342
|
+
width: LABEL_SIZE,
|
|
343
|
+
height: LABEL_SIZE,
|
|
344
|
+
colourType: 3,
|
|
345
|
+
bitDepth,
|
|
346
|
+
samples,
|
|
347
|
+
palette: PICO8_PALETTE.slice(0, colours),
|
|
348
|
+
filter,
|
|
349
|
+
});
|
|
350
|
+
const hex = labelHexFromPng(png);
|
|
351
|
+
for (let y = 0; y < LABEL_SIZE; y += 7) {
|
|
352
|
+
for (let x = 0; x < LABEL_SIZE; x += 5) {
|
|
353
|
+
expect(labelAt(hex, x, y)).toBe(patternIndex(x, y) % colours);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
it('decodes a 16-bit RGB PNG (Paeth-filtered) by its high bytes', () => {
|
|
360
|
+
const samples = new Uint16Array(LABEL_SIZE * LABEL_SIZE * 3);
|
|
361
|
+
for (let y = 0; y < LABEL_SIZE; y++) {
|
|
362
|
+
for (let x = 0; x < LABEL_SIZE; x++) {
|
|
363
|
+
const c = PICO8_PALETTE[patternIndex(x, y)]!;
|
|
364
|
+
const d = (y * LABEL_SIZE + x) * 3;
|
|
365
|
+
// High byte = the colour; a noisy low byte must not matter.
|
|
366
|
+
samples[d] = (c.r << 8) | (x & 0xff);
|
|
367
|
+
samples[d + 1] = (c.g << 8) | (y & 0xff);
|
|
368
|
+
samples[d + 2] = (c.b << 8) | 0x7f;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
const png = encodeTestPng({
|
|
372
|
+
width: LABEL_SIZE,
|
|
373
|
+
height: LABEL_SIZE,
|
|
374
|
+
colourType: 2,
|
|
375
|
+
bitDepth: 16,
|
|
376
|
+
samples,
|
|
377
|
+
filter: 4,
|
|
378
|
+
});
|
|
379
|
+
expect(labelHexFromPng(png)).toBe(patternHex());
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('scales 2-bit greyscale to 0..255 (0 -> black 0, 3 -> white 7)', () => {
|
|
383
|
+
const samples = new Uint8Array(LABEL_SIZE * LABEL_SIZE);
|
|
384
|
+
for (let i = 0; i < samples.length; i++) {
|
|
385
|
+
samples[i] = i % LABEL_SIZE < 64 ? 0 : 3;
|
|
386
|
+
}
|
|
387
|
+
const hex = labelHexFromPng(
|
|
388
|
+
encodeTestPng({
|
|
389
|
+
width: LABEL_SIZE,
|
|
390
|
+
height: LABEL_SIZE,
|
|
391
|
+
colourType: 0,
|
|
392
|
+
bitDepth: 2,
|
|
393
|
+
samples,
|
|
394
|
+
filter: 3,
|
|
395
|
+
}),
|
|
396
|
+
);
|
|
397
|
+
expect(labelAt(hex, 5, 60)).toBe(0);
|
|
398
|
+
expect(labelAt(hex, 120, 60)).toBe(7);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('rejects a palette index past the end of PLTE as unsupported-png', () => {
|
|
402
|
+
const samples = new Uint8Array(LABEL_SIZE * LABEL_SIZE).fill(5);
|
|
403
|
+
const png = encodeTestPng({
|
|
404
|
+
width: LABEL_SIZE,
|
|
405
|
+
height: LABEL_SIZE,
|
|
406
|
+
colourType: 3,
|
|
407
|
+
bitDepth: 4,
|
|
408
|
+
samples,
|
|
409
|
+
palette: PICO8_PALETTE.slice(0, 2),
|
|
410
|
+
});
|
|
411
|
+
expect(labelErrorCode(() => labelHexFromPng(png))).toBe('unsupported-png');
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('rejects truncated image data as truncated-png', () => {
|
|
415
|
+
// Image data for only the first 10 rows of a 128-row image.
|
|
416
|
+
const raw = new Uint8Array(10 * (LABEL_SIZE * 3 + 1));
|
|
417
|
+
const ihdr = new Uint8Array(13);
|
|
418
|
+
new DataView(ihdr.buffer).setUint32(0, LABEL_SIZE);
|
|
419
|
+
new DataView(ihdr.buffer).setUint32(4, LABEL_SIZE);
|
|
420
|
+
ihdr[8] = 8;
|
|
421
|
+
ihdr[9] = 2;
|
|
422
|
+
const png = Uint8Array.from(
|
|
423
|
+
Buffer.concat([
|
|
424
|
+
Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
425
|
+
testChunk('IHDR', ihdr),
|
|
426
|
+
testChunk('IDAT', deflateSync(raw)),
|
|
427
|
+
testChunk('IEND', new Uint8Array(0)),
|
|
428
|
+
]),
|
|
429
|
+
);
|
|
430
|
+
expect(labelErrorCode(() => labelHexFromPng(png))).toBe('truncated-png');
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it('rejects an indexed PNG with no PLTE chunk', () => {
|
|
434
|
+
const png = encodeTestPng({
|
|
435
|
+
width: LABEL_SIZE,
|
|
436
|
+
height: LABEL_SIZE,
|
|
437
|
+
colourType: 3,
|
|
438
|
+
bitDepth: 4,
|
|
439
|
+
samples: new Uint8Array(LABEL_SIZE * LABEL_SIZE),
|
|
440
|
+
});
|
|
441
|
+
expect(labelErrorCode(() => labelHexFromPng(png))).toBe('unsupported-png');
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it('decodes greyscale (colour type 0) and grey+alpha (type 4): black -> 0, white -> 7', () => {
|
|
445
|
+
const size = LABEL_SIZE * LABEL_SIZE;
|
|
446
|
+
const grey = new Uint8Array(size);
|
|
447
|
+
for (let i = 0; i < size; i++) grey[i] = i % LABEL_SIZE < 64 ? 0 : 1;
|
|
448
|
+
const g1 = labelHexFromPng(
|
|
449
|
+
encodeTestPng({
|
|
450
|
+
width: LABEL_SIZE,
|
|
451
|
+
height: LABEL_SIZE,
|
|
452
|
+
colourType: 0,
|
|
453
|
+
bitDepth: 1,
|
|
454
|
+
samples: grey,
|
|
455
|
+
}),
|
|
456
|
+
);
|
|
457
|
+
expect(labelAt(g1, 0, 0)).toBe(0);
|
|
458
|
+
expect(labelAt(g1, 127, 0)).toBe(7);
|
|
459
|
+
|
|
460
|
+
const ga = new Uint8Array(size * 2);
|
|
461
|
+
for (let i = 0; i < size; i++) {
|
|
462
|
+
ga[i * 2] = i % LABEL_SIZE < 64 ? 0 : 255;
|
|
463
|
+
ga[i * 2 + 1] = 255;
|
|
464
|
+
}
|
|
465
|
+
const g8 = labelHexFromPng(
|
|
466
|
+
encodeTestPng({
|
|
467
|
+
width: LABEL_SIZE,
|
|
468
|
+
height: LABEL_SIZE,
|
|
469
|
+
colourType: 4,
|
|
470
|
+
bitDepth: 8,
|
|
471
|
+
samples: ga,
|
|
472
|
+
filter: 1,
|
|
473
|
+
}),
|
|
474
|
+
);
|
|
475
|
+
expect(labelAt(g8, 10, 50)).toBe(0);
|
|
476
|
+
expect(labelAt(g8, 100, 50)).toBe(7);
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
describe('labelHexFromPng: integer-multiple screenshots are downscaled', () => {
|
|
481
|
+
it('downscales a 384x384 (3x) image by nearest-neighbour to the exact 128x128 label', () => {
|
|
482
|
+
const hex = labelHexFromPng(upscaledPatternPng(3));
|
|
483
|
+
expect(labelAt(hex, 0, 0)).toBe(patternIndex(0, 0));
|
|
484
|
+
expect(labelAt(hex, 1, 0)).toBe(patternIndex(1, 0));
|
|
485
|
+
expect(labelAt(hex, 0, 1)).toBe(patternIndex(0, 1));
|
|
486
|
+
expect(labelAt(hex, 64, 37)).toBe(patternIndex(64, 37));
|
|
487
|
+
expect(labelAt(hex, 127, 127)).toBe(patternIndex(127, 127));
|
|
488
|
+
expect(hex).toBe(patternHex());
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it('samples the CENTRE of each 3x3 block (only pixel (1,1) carries the colour)', () => {
|
|
492
|
+
const size = LABEL_SIZE * 3;
|
|
493
|
+
const rgb = new Uint8Array(size * size * 3); // all black (index 0) ...
|
|
494
|
+
for (let y = 0; y < LABEL_SIZE; y++) {
|
|
495
|
+
for (let x = 0; x < LABEL_SIZE; x++) {
|
|
496
|
+
// ... except each block's centre, which holds the pattern colour.
|
|
497
|
+
const c = PICO8_PALETTE[patternIndex(x, y)]!;
|
|
498
|
+
rgb.set([c.r, c.g, c.b], ((y * 3 + 1) * size + x * 3 + 1) * 3);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
const png = encodePng({width: size, height: size, rgb});
|
|
502
|
+
expect(labelHexFromPng(png)).toBe(patternHex());
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it.each([2, 4])('downscales a %ix multiple too', (scale) => {
|
|
506
|
+
expect(labelHexFromPng(upscaledPatternPng(scale))).toBe(patternHex());
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it('decodes a REAL PICO-8 0.2.7 `extcmd("screen")` capture (384x384) straight to a label', () => {
|
|
510
|
+
// Captured via `picopilot run` from a cart that draws: cls(1); red (8) top-left
|
|
511
|
+
// quadrant; blue (12) bottom-right quadrant; pset(0,127,7); pset(127,0,10).
|
|
512
|
+
const png = readFileSync(
|
|
513
|
+
new URL('./fixtures/pico8-screenshot-384.png', import.meta.url),
|
|
514
|
+
);
|
|
515
|
+
const hex = labelHexFromPng(png);
|
|
516
|
+
expect(labelAt(hex, 10, 10)).toBe(8);
|
|
517
|
+
expect(labelAt(hex, 63, 63)).toBe(8);
|
|
518
|
+
expect(labelAt(hex, 64, 64)).toBe(12);
|
|
519
|
+
expect(labelAt(hex, 100, 20)).toBe(1);
|
|
520
|
+
expect(labelAt(hex, 20, 100)).toBe(1);
|
|
521
|
+
expect(labelAt(hex, 0, 127)).toBe(7);
|
|
522
|
+
expect(labelAt(hex, 127, 0)).toBe(10);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it('still rejects a 200x200 image (not an integer multiple), naming the accepted sizes', () => {
|
|
526
|
+
const png = encodePng({
|
|
527
|
+
width: 200,
|
|
528
|
+
height: 200,
|
|
529
|
+
rgb: new Uint8Array(200 * 200 * 3),
|
|
530
|
+
});
|
|
531
|
+
try {
|
|
532
|
+
labelHexFromPng(png);
|
|
533
|
+
expect.unreachable('should have thrown');
|
|
534
|
+
} catch (e) {
|
|
535
|
+
expect(e).toBeInstanceOf(LabelError);
|
|
536
|
+
expect((e as LabelError).code).toBe('wrong-size');
|
|
537
|
+
const msg = (e as LabelError).message;
|
|
538
|
+
expect(msg).toContain('200x200');
|
|
539
|
+
expect(msg).toContain('128x128');
|
|
540
|
+
expect(msg).toContain('384x384');
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it('rejects a mis-sized image from its header alone, before decoding pixels', () => {
|
|
545
|
+
// A 4000x4000 header with NO real image data: if the size were checked only
|
|
546
|
+
// after decoding, this would surface as truncated-png instead.
|
|
547
|
+
const ihdr = new Uint8Array(13);
|
|
548
|
+
new DataView(ihdr.buffer).setUint32(0, 4000);
|
|
549
|
+
new DataView(ihdr.buffer).setUint32(4, 4000);
|
|
550
|
+
ihdr[8] = 8;
|
|
551
|
+
ihdr[9] = 2;
|
|
552
|
+
const png = Uint8Array.from(
|
|
553
|
+
Buffer.concat([
|
|
554
|
+
Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
555
|
+
testChunk('IHDR', ihdr),
|
|
556
|
+
testChunk('IDAT', deflateSync(new Uint8Array(1))),
|
|
557
|
+
testChunk('IEND', new Uint8Array(0)),
|
|
558
|
+
]),
|
|
559
|
+
);
|
|
560
|
+
expect(labelErrorCode(() => labelHexFromPng(png))).toBe('wrong-size');
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it('rejects a non-square multiple (256x384)', () => {
|
|
564
|
+
const png = encodePng({
|
|
565
|
+
width: 256,
|
|
566
|
+
height: 384,
|
|
567
|
+
rgb: new Uint8Array(256 * 384 * 3),
|
|
568
|
+
});
|
|
569
|
+
expect(() => labelHexFromPng(png)).toThrowError(/must be/);
|
|
570
|
+
});
|
|
571
|
+
});
|