@namiml/cli 3.4.12-dev.202610051759 → 3.4.12-dev.202610060208
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/placements/page.js +34 -0
- package/dist/render/capabilities.js +63 -0
- package/dist/render/graphics.js +203 -0
- package/dist/render/image.js +362 -0
- package/dist/render/layout.js +544 -0
- package/dist/render/page-model.js +610 -0
- package/dist/render/paint.js +625 -0
- package/dist/render/style.js +413 -0
- package/dist/render/surface.js +377 -0
- package/dist/render/text.js +145 -0
- package/dist/screens/LaunchScreen.js +85 -14
- package/dist/screens/PageRender.js +273 -0
- package/dist/screens/Shell.js +24 -5
- package/dist/screens/useTerminalImages.js +129 -0
- package/dist/utils/terminal.js +17 -1
- package/dist/version.js +1 -1
- package/package.json +4 -2
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { getPaywallDataFromLabel } from "@namiml/sdk-core";
|
|
2
|
+
import { currentPageName } from "../render/composite.js";
|
|
3
|
+
import { pageModel } from "../render/page-model.js";
|
|
4
|
+
/**
|
|
5
|
+
* The render model for a placement, from core's own lookup.
|
|
6
|
+
*
|
|
7
|
+
* The twin of `compositeForPlacement` in `src/placements/launch.ts`: the same
|
|
8
|
+
* `getPaywallDataFromLabel(label, "label")` the launch itself performs, so the
|
|
9
|
+
* frame draws the page the launch served. It lives in its own file because
|
|
10
|
+
* NAM-4542's `launch.ts` is closed to changes; the two must resolve the same
|
|
11
|
+
* page, so change them together.
|
|
12
|
+
*
|
|
13
|
+
* A placement only resolves a paywall when that paywall was in the set the SDK
|
|
14
|
+
* fetched for the configured device's form factor, which is why an empty page
|
|
15
|
+
* is usually a device mismatch — `unresolved` carries that distinction on to
|
|
16
|
+
* the screen exactly as the outline's does.
|
|
17
|
+
*/
|
|
18
|
+
const NO_PAGE = {
|
|
19
|
+
pageName: "",
|
|
20
|
+
root: { kind: "box", style: {}, children: [] },
|
|
21
|
+
unresolved: true,
|
|
22
|
+
};
|
|
23
|
+
export function renderPageForPlacement(label) {
|
|
24
|
+
try {
|
|
25
|
+
const { paywall } = getPaywallDataFromLabel(label, "label");
|
|
26
|
+
if (!paywall)
|
|
27
|
+
return NO_PAGE;
|
|
28
|
+
return pageModel(paywall, currentPageName(paywall));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// A placement the SDK cannot resolve is an empty frame, never a crash.
|
|
32
|
+
return NO_PAGE;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { colorDisabled } from "../utils/terminal.js";
|
|
2
|
+
/**
|
|
3
|
+
* What this terminal can do, from the environment alone — no probing, no escape
|
|
4
|
+
* sequences written to a terminal that may not answer.
|
|
5
|
+
*
|
|
6
|
+
* `NO_COLOR` wins over everything, which is the rule `src/utils/terminal.ts`
|
|
7
|
+
* already sets for animation: a user who asked for a quiet terminal gets no
|
|
8
|
+
* colour and no images, even inside kitty. A non-TTY gets the same treatment,
|
|
9
|
+
* because a redirected stdout is a file or a pipe, not a screen.
|
|
10
|
+
*/
|
|
11
|
+
export function detectCaps(env = process.env, isTTY = false) {
|
|
12
|
+
if (colorDisabled(env) || !isTTY)
|
|
13
|
+
return { trueColor: false, imageTier: "placeholder" };
|
|
14
|
+
return { trueColor: hasTrueColor(env), imageTier: imageProtocol(env) === "kitty" ? "kitty" : "glyphs" };
|
|
15
|
+
}
|
|
16
|
+
/** `COLORTERM` is the only portable 24-bit signal; the two values terminals actually set. */
|
|
17
|
+
function hasTrueColor(env) {
|
|
18
|
+
const colorterm = env.COLORTERM?.toLowerCase();
|
|
19
|
+
return colorterm === "truecolor" || colorterm === "24bit";
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Which block glyphs to draw images with.
|
|
23
|
+
*
|
|
24
|
+
* Sextants (Unicode 13, 2020) give the most detail and are the default, but a
|
|
25
|
+
* font without them draws tofu, which is worse than a coarser image — and there
|
|
26
|
+
* is no way to ask a terminal what its font covers. `NAMI_IMAGE_BLOCKS` is the
|
|
27
|
+
* escape hatch: set it to `quadrant` (four subpixels, in fonts for decades) or
|
|
28
|
+
* `half` (two, universal).
|
|
29
|
+
*/
|
|
30
|
+
export function blockMode(env = process.env) {
|
|
31
|
+
const asked = env.NAMI_IMAGE_BLOCKS?.trim().toLowerCase();
|
|
32
|
+
if (asked === "quadrant" || asked === "half" || asked === "sextant")
|
|
33
|
+
return asked;
|
|
34
|
+
return "sextant";
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Whether this terminal will take an image as pixels rather than as glyphs.
|
|
38
|
+
*
|
|
39
|
+
* Read from the environment rather than asked. The protocol has a query — an
|
|
40
|
+
* escape the terminal answers `OK` to — but asking means putting stdin in raw
|
|
41
|
+
* mode and waiting for a reply that may never come, at the moment the CLI is
|
|
42
|
+
* starting up, and getting that wrong hangs the program on a terminal that
|
|
43
|
+
* simply does not answer. The set of terminals that speak kitty graphics is
|
|
44
|
+
* short and identifies itself plainly, so this is a lookup until there is a
|
|
45
|
+
* second protocol to tell apart.
|
|
46
|
+
*
|
|
47
|
+
* `NAMI_IMAGE_PROTOCOL=none` forces the glyph renderers, which is the escape
|
|
48
|
+
* hatch if a terminal claims the protocol and draws nothing.
|
|
49
|
+
*/
|
|
50
|
+
export function imageProtocol(env = process.env) {
|
|
51
|
+
const asked = env.NAMI_IMAGE_PROTOCOL?.trim().toLowerCase();
|
|
52
|
+
if (asked === "none")
|
|
53
|
+
return "none";
|
|
54
|
+
if (asked === "kitty")
|
|
55
|
+
return "kitty";
|
|
56
|
+
// Ghostty (`xterm-ghostty`) and kitty itself. Verified against Ghostty: it
|
|
57
|
+
// answers the protocol's query with OK and draws a chunked RGBA transmission.
|
|
58
|
+
if (env.TERM === "xterm-ghostty" || env.TERM_PROGRAM === "ghostty")
|
|
59
|
+
return "kitty";
|
|
60
|
+
if (env.TERM === "xterm-kitty" || env.KITTY_WINDOW_ID)
|
|
61
|
+
return "kitty";
|
|
62
|
+
return "none";
|
|
63
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Images as pixels rather than glyphs, for terminals that can take them.
|
|
3
|
+
*
|
|
4
|
+
* A character cell can hold at most two colours, so a block-glyph image is
|
|
5
|
+
* capped at two colours per cell however finely the glyph subdivides it — six
|
|
6
|
+
* subpixels (`sextantRows`) is about the ceiling. A terminal that speaks a
|
|
7
|
+
* graphics protocol draws the real thing instead, at its own pixel resolution.
|
|
8
|
+
*
|
|
9
|
+
* This module is the protocol half and is **pure**: it builds escape sequences
|
|
10
|
+
* and does the arithmetic, and never writes to a stream or reads a clock. The
|
|
11
|
+
* writing, and the ordering problem that comes with it, live in
|
|
12
|
+
* `src/screens/useTerminalImages.ts`.
|
|
13
|
+
*
|
|
14
|
+
* Only the kitty protocol is implemented. It is what Ghostty and kitty speak;
|
|
15
|
+
* iTerm2 and sixel are the same shape of problem (transmit, place, delete) and
|
|
16
|
+
* belong behind the same three functions when their turn comes.
|
|
17
|
+
*
|
|
18
|
+
* ## Why RGBA and not PNG
|
|
19
|
+
*
|
|
20
|
+
* `f=100` means PNG and `f=32` means raw RGBA. We already decode both PNG and
|
|
21
|
+
* JPEG to RGBA for the glyph renderers, so sending RGBA needs no encoder and
|
|
22
|
+
* cannot mismatch the file's actual format — sending a JPEG under `f=100` is
|
|
23
|
+
* exactly the bug that made the first spike draw nothing, silently, because the
|
|
24
|
+
* error reply was suppressed. Raw pixels cost more bytes, so `rgbaFor` scales
|
|
25
|
+
* to the cells the image will occupy before transmitting rather than sending
|
|
26
|
+
* a 4000-pixel asset to fill 30 columns.
|
|
27
|
+
*/
|
|
28
|
+
/** Bytes of base64 per escape. The protocol's own limit is 4096. */
|
|
29
|
+
export const CHUNK = 4096;
|
|
30
|
+
/**
|
|
31
|
+
* Roughly how many pixels a cell is. Used only to decide how much resolution to
|
|
32
|
+
* transmit — the terminal does the real scaling from `cols`/`rows`, so being a
|
|
33
|
+
* little off costs sharpness or bytes, never correctness.
|
|
34
|
+
*/
|
|
35
|
+
export const CELL_PIXELS = { width: 9, height: 18 };
|
|
36
|
+
/**
|
|
37
|
+
* Stacking levels, and the one number in this file that is not arbitrary.
|
|
38
|
+
*
|
|
39
|
+
* The protocol draws an image below the terminal's text at any negative `z`,
|
|
40
|
+
* but only below a cell that carries its **own background colour** when `z` is
|
|
41
|
+
* under −1073741824. That distinction is the difference between a paywall's
|
|
42
|
+
* buttons keeping their fills and disappearing into the page's background
|
|
43
|
+
* photograph, so every image here sits under that line. The page's background
|
|
44
|
+
* goes one lower again, so a logo drawn over it stays over it.
|
|
45
|
+
*/
|
|
46
|
+
export const UNDER_FILLS = -1073741825;
|
|
47
|
+
export const BACKGROUND_Z = UNDER_FILLS - 1000;
|
|
48
|
+
/**
|
|
49
|
+
* The cells an image should be drawn into, and the part of it to draw, so that
|
|
50
|
+
* it keeps its own shape.
|
|
51
|
+
*
|
|
52
|
+
* The protocol scales an image into exactly the cells it is given, so placing a
|
|
53
|
+
* wide logo in a square box stretches it — which is what `imageBox` already
|
|
54
|
+
* protects the glyph renderers from. `fit` shrinks the cells to the image's
|
|
55
|
+
* shape and centres them; `fill` keeps the cells and crops the image instead,
|
|
56
|
+
* which is what covering a box means.
|
|
57
|
+
*/
|
|
58
|
+
export function fitToBox(pixels, box, mode,
|
|
59
|
+
/** Where the picture sits in a box wider than it is: 0 left, 0.5 centred, 1 right. */
|
|
60
|
+
align = {}, cellAspect = 2) {
|
|
61
|
+
if (mode === "fill") {
|
|
62
|
+
// Pixels the box is worth, in the image's own aspect terms.
|
|
63
|
+
const want = box.w / (box.h * cellAspect);
|
|
64
|
+
const have = pixels.width / pixels.height;
|
|
65
|
+
const w = have > want ? Math.max(1, Math.round(pixels.height * want)) : pixels.width;
|
|
66
|
+
const h = have > want ? pixels.height : Math.max(1, Math.round(pixels.width / want));
|
|
67
|
+
return {
|
|
68
|
+
x: box.x,
|
|
69
|
+
y: box.y,
|
|
70
|
+
cols: box.w,
|
|
71
|
+
rows: box.h,
|
|
72
|
+
crop: {
|
|
73
|
+
x: Math.floor((pixels.width - w) / 2),
|
|
74
|
+
y: Math.floor((pixels.height - h) / 2),
|
|
75
|
+
w: Math.min(w, pixels.width),
|
|
76
|
+
h: Math.min(h, pixels.height),
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
const scale = Math.min(1, Math.min(box.w / pixels.width, (box.h * cellAspect) / pixels.height));
|
|
81
|
+
const cols = Math.max(1, Math.min(box.w, Math.round(pixels.width * scale)));
|
|
82
|
+
const rows = Math.max(1, Math.min(box.h, Math.round((pixels.height * scale) / cellAspect)));
|
|
83
|
+
return {
|
|
84
|
+
x: box.x + Math.floor((box.w - cols) * (align.x ?? 0.5)),
|
|
85
|
+
y: box.y + Math.floor((box.h - rows) * (align.y ?? 0.5)),
|
|
86
|
+
cols,
|
|
87
|
+
rows,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The escapes that send an image's pixels to the terminal under `id`.
|
|
92
|
+
*
|
|
93
|
+
* Transmission is separate from placement on purpose: the pixels go once, and
|
|
94
|
+
* then every redraw and every scroll step is a single short `place`. Sending
|
|
95
|
+
* 200 KB on each frame would be visible.
|
|
96
|
+
*/
|
|
97
|
+
export function transmit(id, rgba, width, height, chunk = CHUNK) {
|
|
98
|
+
const data = Buffer.from(rgba.buffer, rgba.byteOffset, rgba.byteLength).toString("base64");
|
|
99
|
+
const head = `f=32,t=d,a=t,i=${id},s=${Math.max(1, Math.floor(width))},v=${Math.max(1, Math.floor(height))},q=2`;
|
|
100
|
+
if (data.length <= chunk)
|
|
101
|
+
return [`\u001b_G${head},m=0;${data}\u001b\\`];
|
|
102
|
+
const out = [`\u001b_G${head},m=1;${data.slice(0, chunk)}\u001b\\`];
|
|
103
|
+
let at = chunk;
|
|
104
|
+
while (at < data.length - chunk) {
|
|
105
|
+
out.push(`\u001b_Gm=1,q=2;${data.slice(at, at + chunk)}\u001b\\`);
|
|
106
|
+
at += chunk;
|
|
107
|
+
}
|
|
108
|
+
out.push(`\u001b_Gm=0,q=2;${data.slice(at)}\u001b\\`);
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
/** The escape that draws a transmitted image at the cursor. */
|
|
112
|
+
export function place(id, { cols, rows, source, z }) {
|
|
113
|
+
const parts = [`a=p`, `i=${id}`, `p=1`, `c=${Math.max(1, Math.floor(cols))}`, `r=${Math.max(1, Math.floor(rows))}`];
|
|
114
|
+
if (source)
|
|
115
|
+
parts.push(`x=${source.x}`, `y=${source.y}`, `w=${source.w}`, `h=${source.h}`);
|
|
116
|
+
if (z !== undefined)
|
|
117
|
+
parts.push(`z=${z}`);
|
|
118
|
+
// C=1 keeps the cursor where it was, so the caller's own positioning holds.
|
|
119
|
+
parts.push("C=1", "q=2");
|
|
120
|
+
return `\u001b_G${parts.join(",")}\u001b\\`;
|
|
121
|
+
}
|
|
122
|
+
/** The escape that forgets an image and every placement of it. */
|
|
123
|
+
export function forget(id) {
|
|
124
|
+
return `\u001b_Ga=d,d=I,i=${id}\u001b\\`;
|
|
125
|
+
}
|
|
126
|
+
/** Move the cursor to a row and column of the app's own output, and back after. */
|
|
127
|
+
export function at(col, rowsUp, body) {
|
|
128
|
+
const up = rowsUp > 0 ? `\u001b[${rowsUp}A` : "";
|
|
129
|
+
const right = col > 0 ? `\u001b[${col}C` : "";
|
|
130
|
+
return `\u001b7${up}\r${right}${body}\u001b8`;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* An image box clipped to a scrolling window, as cells to draw and the part of
|
|
134
|
+
* the image to draw there.
|
|
135
|
+
*
|
|
136
|
+
* The terminal does not know about our window: it draws where it is told, so a
|
|
137
|
+
* frame that clips its content has to clip the placement too, or the image
|
|
138
|
+
* spills past the border. `null` means the box is entirely outside the window.
|
|
139
|
+
*
|
|
140
|
+
* `top` is the box's first row in the page's own coordinates, `offset` the rows
|
|
141
|
+
* scrolled off, `windowRows` the rows on screen.
|
|
142
|
+
*/
|
|
143
|
+
export function clipToWindow(box, offset, windowRows, pixelHeight, pixelWidth) {
|
|
144
|
+
const first = Math.max(box.y, offset);
|
|
145
|
+
const last = Math.min(box.y + box.rows, offset + windowRows);
|
|
146
|
+
const rows = last - first;
|
|
147
|
+
if (rows <= 0 || box.rows <= 0)
|
|
148
|
+
return null;
|
|
149
|
+
const hidden = first - box.y;
|
|
150
|
+
const y = Math.round((hidden / box.rows) * pixelHeight);
|
|
151
|
+
const h = Math.max(1, Math.round((rows / box.rows) * pixelHeight));
|
|
152
|
+
return {
|
|
153
|
+
row: first - offset,
|
|
154
|
+
rows,
|
|
155
|
+
source: { x: 0, y, w: pixelWidth, h: Math.min(h, pixelHeight - y) },
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* The image scaled to the pixels its cells are worth, as RGBA, composited over
|
|
160
|
+
* `backdrop` if one is given.
|
|
161
|
+
*
|
|
162
|
+
* Nearest neighbour on purpose: the terminal resamples again when it fits the
|
|
163
|
+
* pixels to the cells, so a careful filter here would be thrown away. What
|
|
164
|
+
* matters is not sending a 4000-pixel asset to fill thirty columns.
|
|
165
|
+
*
|
|
166
|
+
* **The compositing is not optional.** A terminal is free to ignore the alpha
|
|
167
|
+
* channel it was sent, and one that does draws the bare colour underneath it —
|
|
168
|
+
* which for a white logo on a transparent ground is a white rectangle with the
|
|
169
|
+
* mark invisible inside it. Flattening here makes the pixels say what they
|
|
170
|
+
* should look like rather than trusting the terminal to work it out, and it is
|
|
171
|
+
* the same thing `halfBlockRows` and friends do with their own `backdrop`.
|
|
172
|
+
*/
|
|
173
|
+
export function rgbaFor(pixels, cols, rows, backdrop,
|
|
174
|
+
/** Take only this part of the image, for a `fill` that crops rather than squashes. */
|
|
175
|
+
crop) {
|
|
176
|
+
const take = crop ?? { x: 0, y: 0, w: pixels.width, h: pixels.height };
|
|
177
|
+
const width = Math.max(1, Math.min(take.w, Math.floor(cols * CELL_PIXELS.width)));
|
|
178
|
+
const height = Math.max(1, Math.min(take.h, Math.floor(rows * CELL_PIXELS.height)));
|
|
179
|
+
const data = new Uint8Array(width * height * 4);
|
|
180
|
+
for (let y = 0; y < height; y += 1) {
|
|
181
|
+
const sy = Math.min(pixels.height - 1, take.y + Math.floor((y * take.h) / height));
|
|
182
|
+
for (let x = 0; x < width; x += 1) {
|
|
183
|
+
const sx = Math.min(pixels.width - 1, take.x + Math.floor((x * take.w) / width));
|
|
184
|
+
const from = (sy * pixels.width + sx) * 4;
|
|
185
|
+
const to = (y * width + x) * 4;
|
|
186
|
+
const alpha = pixels.data[from + 3];
|
|
187
|
+
if (backdrop && alpha < 255) {
|
|
188
|
+
const weight = alpha / 255;
|
|
189
|
+
data[to] = Math.round(pixels.data[from] * weight + backdrop.r * (1 - weight));
|
|
190
|
+
data[to + 1] = Math.round(pixels.data[from + 1] * weight + backdrop.g * (1 - weight));
|
|
191
|
+
data[to + 2] = Math.round(pixels.data[from + 2] * weight + backdrop.b * (1 - weight));
|
|
192
|
+
data[to + 3] = 255;
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
data[to] = pixels.data[from];
|
|
196
|
+
data[to + 1] = pixels.data[from + 1];
|
|
197
|
+
data[to + 2] = pixels.data[from + 2];
|
|
198
|
+
data[to + 3] = alpha;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return { data, width, height };
|
|
203
|
+
}
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { decode as decodeJpeg } from "jpeg-js";
|
|
5
|
+
import { PNG } from "pngjs";
|
|
6
|
+
import { configDir } from "../paths.js";
|
|
7
|
+
/** Upper half block: its foreground is the upper pixel, its background the lower one. */
|
|
8
|
+
const UPPER_HALF = "▀";
|
|
9
|
+
/** Lower half block, for a cell whose upper pixel is absent or transparent. */
|
|
10
|
+
const LOWER_HALF = "▄";
|
|
11
|
+
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
12
|
+
const JPEG_SIGNATURE = [0xff, 0xd8, 0xff];
|
|
13
|
+
/**
|
|
14
|
+
* The fallback every terminal can draw: `▀` with the **foreground** carrying the
|
|
15
|
+
* upper pixel and the **background** the lower one, so each cell shows two
|
|
16
|
+
* vertical pixels.
|
|
17
|
+
*
|
|
18
|
+
* The image is nearest-neighbour sampled to fill the `cols` × `rows` box, except
|
|
19
|
+
* that it is **never upscaled**: the grid is `min(cols, width)` wide and
|
|
20
|
+
* `min(rows * 2, height)` pixel-rows tall, so a 12×12 logo in a 40×12 box comes
|
|
21
|
+
* back as 12 columns of 6 rows rather than smeared across 40. The caller places
|
|
22
|
+
* the smaller result inside its box.
|
|
23
|
+
*
|
|
24
|
+
* An odd pixel-row count leaves the bottom cell without a lower pixel; its `bg`
|
|
25
|
+
* is empty so the terminal's own background shows through. A fully transparent
|
|
26
|
+
* pixel counts as absent the same way, which is what turns a cell into `▄` or a
|
|
27
|
+
* blank.
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* The cell box an image occupies inside `cols x rows`, keeping its shape.
|
|
31
|
+
*
|
|
32
|
+
* A half block splits a cell into two, so a half-block pixel is about square
|
|
33
|
+
* while a whole cell is about twice as tall as it is wide. The source's aspect
|
|
34
|
+
* is therefore matched against `cols x (rows * 2)`. Without this a square logo
|
|
35
|
+
* authored `width: 100%, height: 32` was stretched across the full width and
|
|
36
|
+
* squashed into two rows — a wide, short smear of something that is not wide or
|
|
37
|
+
* short. `fit` (the mode 17 of the 19 authored images use) contains the image
|
|
38
|
+
* inside the box; `fill` covers the box and crops the overflow. Neither ever
|
|
39
|
+
* upscales past the source's own resolution.
|
|
40
|
+
*/
|
|
41
|
+
export function imageBox(pixels, cols, rows, mode) {
|
|
42
|
+
if (cols <= 0 || rows <= 0 || pixels.width <= 0 || pixels.height <= 0)
|
|
43
|
+
return { cols: 0, rows: 0 };
|
|
44
|
+
const halfRows = rows * 2;
|
|
45
|
+
const byWidth = cols / pixels.width;
|
|
46
|
+
const byHeight = halfRows / pixels.height;
|
|
47
|
+
// `fill` covers the box, `fit` stays inside it. Never magnify the source.
|
|
48
|
+
const scale = Math.min(1, mode === "fill" ? Math.max(byWidth, byHeight) : Math.min(byWidth, byHeight));
|
|
49
|
+
return {
|
|
50
|
+
cols: Math.max(1, Math.min(cols, Math.round(pixels.width * scale))),
|
|
51
|
+
rows: Math.max(1, Math.min(rows, Math.ceil((pixels.height * scale) / 2))),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
export function halfBlockRows(pixels, cols, rows,
|
|
55
|
+
/**
|
|
56
|
+
* What shows through a partly transparent pixel — the fill the image is being
|
|
57
|
+
* drawn over. Without it a logo's anti-aliased edge, which is mostly
|
|
58
|
+
* transparent white, paints as solid white and the whole mark looks blown out.
|
|
59
|
+
*/
|
|
60
|
+
backdrop,
|
|
61
|
+
/** `fit` (contain, the default) or `fill` (cover). */
|
|
62
|
+
mode) {
|
|
63
|
+
if (cols <= 0 || rows <= 0 || pixels.width <= 0 || pixels.height <= 0)
|
|
64
|
+
return [];
|
|
65
|
+
const box = imageBox(pixels, cols, rows, mode);
|
|
66
|
+
const pixelCols = Math.max(1, box.cols);
|
|
67
|
+
const pixelRows = Math.max(1, Math.min(box.rows * 2, pixels.height));
|
|
68
|
+
const out = [];
|
|
69
|
+
for (let row = 0; row * 2 < pixelRows; row += 1) {
|
|
70
|
+
const cells = [];
|
|
71
|
+
for (let col = 0; col < pixelCols; col += 1) {
|
|
72
|
+
cells.push(cell(sample(pixels, col, row * 2, pixelCols, pixelRows, backdrop), sample(pixels, col, row * 2 + 1, pixelCols, pixelRows, backdrop)));
|
|
73
|
+
}
|
|
74
|
+
out.push(cells);
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The one place a format is decoded, so a format we cannot read degrades to the
|
|
80
|
+
* `placeholder` tier instead of throwing. PNG through `pngjs`, JPEG through
|
|
81
|
+
* `jpeg-js`, anything else (and anything malformed) → null.
|
|
82
|
+
*/
|
|
83
|
+
export function decodeImage(bytes) {
|
|
84
|
+
try {
|
|
85
|
+
if (startsWith(bytes, PNG_SIGNATURE)) {
|
|
86
|
+
const png = PNG.sync.read(Buffer.from(bytes));
|
|
87
|
+
return { data: new Uint8Array(png.data), width: png.width, height: png.height };
|
|
88
|
+
}
|
|
89
|
+
if (startsWith(bytes, JPEG_SIGNATURE)) {
|
|
90
|
+
const jpg = decodeJpeg(bytes, { useTArray: true, formatAsRGBA: true });
|
|
91
|
+
return { data: new Uint8Array(jpg.data), width: jpg.width, height: jpg.height };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The image bytes for a URL, cached on disk under `configDir()/image-cache/<sha256 of url>`.
|
|
101
|
+
*
|
|
102
|
+
* **Never throws and never rejects**: an unreachable host, a 404, a corrupt cache
|
|
103
|
+
* or an unwritable cache directory all come back as `null` (or, when only the
|
|
104
|
+
* cache write failed, as the bytes anyway). A paywall image that cannot be
|
|
105
|
+
* fetched must never take the page render down with it.
|
|
106
|
+
*
|
|
107
|
+
* `fetch` is injected so tests never touch the network.
|
|
108
|
+
*/
|
|
109
|
+
export async function fetchImage(url, deps = {}) {
|
|
110
|
+
const dir = deps.cacheDir ?? join(configDir(), "image-cache");
|
|
111
|
+
const file = join(dir, createHash("sha256").update(url).digest("hex"));
|
|
112
|
+
const cached = await readCached(file);
|
|
113
|
+
if (cached)
|
|
114
|
+
return cached;
|
|
115
|
+
let bytes;
|
|
116
|
+
try {
|
|
117
|
+
const get = deps.fetch ?? globalThis.fetch;
|
|
118
|
+
const response = await get(url);
|
|
119
|
+
if (!response.ok)
|
|
120
|
+
return null;
|
|
121
|
+
bytes = new Uint8Array(await response.arrayBuffer());
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
await writeCached(dir, file, bytes);
|
|
127
|
+
return bytes;
|
|
128
|
+
}
|
|
129
|
+
async function readCached(file) {
|
|
130
|
+
try {
|
|
131
|
+
const buffer = await readFile(file);
|
|
132
|
+
return buffer.length > 0 ? new Uint8Array(buffer) : null;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/** Atomic, `0600` in a `0700` directory — the same shape as the token and config stores. */
|
|
139
|
+
async function writeCached(dir, file, bytes) {
|
|
140
|
+
try {
|
|
141
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
142
|
+
const temp = `${file}.tmp`;
|
|
143
|
+
await writeFile(temp, bytes, { mode: 0o600 });
|
|
144
|
+
await rename(temp, file);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
// A cache we cannot write is not a reason to lose an image we already have.
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
function startsWith(bytes, signature) {
|
|
151
|
+
if (bytes.length < signature.length)
|
|
152
|
+
return false;
|
|
153
|
+
return signature.every((byte, i) => bytes[i] === byte);
|
|
154
|
+
}
|
|
155
|
+
/** The colour of one source pixel, or null when it is outside the image or fully transparent. */
|
|
156
|
+
/**
|
|
157
|
+
* The average of every source pixel a cell covers, composited over `backdrop`.
|
|
158
|
+
*
|
|
159
|
+
* Two things this fixes, both of which made logos look blown out. Taking one
|
|
160
|
+
* pixel per cell threw away almost the whole image when downscaling — an 830px
|
|
161
|
+
* mark into 40 columns sampled one pixel in several hundred, so a bright one
|
|
162
|
+
* spiked and thin strokes vanished. And treating alpha as binary painted every
|
|
163
|
+
* anti-aliased edge at full opacity, which is a white halo around a white mark.
|
|
164
|
+
* Averaging in premultiplied space, then compositing the accumulated alpha, is
|
|
165
|
+
* what an image resizer does.
|
|
166
|
+
*/
|
|
167
|
+
function sample(pixels, col, pixelRow, cols, pixelRows, backdrop) {
|
|
168
|
+
const rgb = sampleRgb(pixels, col, pixelRow, cols, pixelRows, backdrop);
|
|
169
|
+
return rgb ? hex(rgb.r, rgb.g, rgb.b) : null;
|
|
170
|
+
}
|
|
171
|
+
/** The same average, kept as a colour — what the quadrant renderer needs. */
|
|
172
|
+
function sampleRgb(pixels, col, pixelRow, cols, pixelRows, backdrop) {
|
|
173
|
+
if (pixelRow >= pixelRows)
|
|
174
|
+
return null;
|
|
175
|
+
const x0 = Math.floor((col * pixels.width) / cols);
|
|
176
|
+
const x1 = Math.max(x0 + 1, Math.floor(((col + 1) * pixels.width) / cols));
|
|
177
|
+
const y0 = Math.floor((pixelRow * pixels.height) / pixelRows);
|
|
178
|
+
const y1 = Math.max(y0 + 1, Math.floor(((pixelRow + 1) * pixels.height) / pixelRows));
|
|
179
|
+
let r = 0;
|
|
180
|
+
let g = 0;
|
|
181
|
+
let b = 0;
|
|
182
|
+
let alpha = 0;
|
|
183
|
+
let count = 0;
|
|
184
|
+
for (let y = y0; y < Math.min(y1, pixels.height); y += 1) {
|
|
185
|
+
for (let x = x0; x < Math.min(x1, pixels.width); x += 1) {
|
|
186
|
+
const at = (y * pixels.width + x) * 4;
|
|
187
|
+
if (at + 3 >= pixels.data.length)
|
|
188
|
+
continue;
|
|
189
|
+
const a = pixels.data[at + 3] / 255;
|
|
190
|
+
// Premultiplied, so a transparent pixel's colour cannot drag the average.
|
|
191
|
+
r += pixels.data[at] * a;
|
|
192
|
+
g += pixels.data[at + 1] * a;
|
|
193
|
+
b += pixels.data[at + 2] * a;
|
|
194
|
+
alpha += a;
|
|
195
|
+
count += 1;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (count === 0)
|
|
199
|
+
return null;
|
|
200
|
+
const coverage = alpha / count;
|
|
201
|
+
// Below this the cell is background, not a faint smear of the mark.
|
|
202
|
+
if (coverage < 0.02)
|
|
203
|
+
return null;
|
|
204
|
+
// Un-premultiply to the mark's own colour, then composite it over what shows
|
|
205
|
+
// through. With no backdrop the colour stands alone, as it did before.
|
|
206
|
+
const own = { r: r / alpha, g: g / alpha, b: b / alpha };
|
|
207
|
+
if (!backdrop || coverage >= 0.999)
|
|
208
|
+
return own;
|
|
209
|
+
return {
|
|
210
|
+
r: own.r * coverage + backdrop.r * (1 - coverage),
|
|
211
|
+
g: own.g * coverage + backdrop.g * (1 - coverage),
|
|
212
|
+
b: own.b * coverage + backdrop.b * (1 - coverage),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
function cell(upper, lower) {
|
|
216
|
+
if (upper && lower)
|
|
217
|
+
return { ch: UPPER_HALF, fg: upper, bg: lower };
|
|
218
|
+
if (upper)
|
|
219
|
+
return { ch: UPPER_HALF, fg: upper, bg: "" };
|
|
220
|
+
if (lower)
|
|
221
|
+
return { ch: LOWER_HALF, fg: lower, bg: "" };
|
|
222
|
+
return { ch: " ", fg: "", bg: "" };
|
|
223
|
+
}
|
|
224
|
+
function hex(r, g, b) {
|
|
225
|
+
return `#${channel(Math.round(r))}${channel(Math.round(g))}${channel(Math.round(b))}`;
|
|
226
|
+
}
|
|
227
|
+
const channel = (value) => Math.max(0, Math.min(255, value)).toString(16).padStart(2, "0");
|
|
228
|
+
/**
|
|
229
|
+
* The sixteen quadrant glyphs, indexed by a bitmask of which quarters take the
|
|
230
|
+
* foreground: 1 top-left, 2 top-right, 4 bottom-left, 8 bottom-right.
|
|
231
|
+
*/
|
|
232
|
+
const QUADRANTS = [" ", "\u2598", "\u259d", "\u2580", "\u2596", "\u258c", "\u259e", "\u259b", "\u2597", "\u259a", "\u2590", "\u259c", "\u2584", "\u2599", "\u259f", "\u2588"];
|
|
233
|
+
/**
|
|
234
|
+
* An image as quadrant cells: **four** subpixels per cell instead of a half
|
|
235
|
+
* block's two, so horizontal detail doubles at the same size on screen.
|
|
236
|
+
*
|
|
237
|
+
* A cell still carries exactly two colours, which is what keeps this
|
|
238
|
+
* compositable — the cells drop into the same grid as everything else, and text
|
|
239
|
+
* can sit over them. The two colours are chosen per cell by splitting its four
|
|
240
|
+
* subpixels around their mean brightness and averaging each side, so an edge
|
|
241
|
+
* running through a cell keeps both the colours that meet there rather than
|
|
242
|
+
* being flattened to their average.
|
|
243
|
+
*
|
|
244
|
+
* `ink-picture` reaches higher resolution with braille (eight dots), but its
|
|
245
|
+
* dots are monochrome and its renderers emit pre-coloured strings rather than
|
|
246
|
+
* cells, so neither its colour nor its output would survive this compositor.
|
|
247
|
+
*/
|
|
248
|
+
export function quadrantRows(pixels, cols, rows, backdrop, mode) {
|
|
249
|
+
if (cols <= 0 || rows <= 0 || pixels.width <= 0 || pixels.height <= 0)
|
|
250
|
+
return [];
|
|
251
|
+
const box = imageBox(pixels, cols, rows, mode);
|
|
252
|
+
const gridCols = Math.max(1, box.cols) * 2;
|
|
253
|
+
const gridRows = Math.max(1, box.rows) * 2;
|
|
254
|
+
const out = [];
|
|
255
|
+
for (let row = 0; row < box.rows; row += 1) {
|
|
256
|
+
const cells = [];
|
|
257
|
+
for (let col = 0; col < box.cols; col += 1) {
|
|
258
|
+
const quarters = [
|
|
259
|
+
sampleRgb(pixels, col * 2, row * 2, gridCols, gridRows, backdrop),
|
|
260
|
+
sampleRgb(pixels, col * 2 + 1, row * 2, gridCols, gridRows, backdrop),
|
|
261
|
+
sampleRgb(pixels, col * 2, row * 2 + 1, gridCols, gridRows, backdrop),
|
|
262
|
+
sampleRgb(pixels, col * 2 + 1, row * 2 + 1, gridCols, gridRows, backdrop),
|
|
263
|
+
];
|
|
264
|
+
cells.push(quadrantCell(quarters));
|
|
265
|
+
}
|
|
266
|
+
out.push(cells);
|
|
267
|
+
}
|
|
268
|
+
return out;
|
|
269
|
+
}
|
|
270
|
+
/** Split a cell's four quarters into a foreground and a background colour. */
|
|
271
|
+
function quadrantCell(quarters) {
|
|
272
|
+
return twoColourCell(quarters, (mask) => QUADRANTS[mask]);
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* One cell from any number of subpixels: pick the two colours that best carry
|
|
276
|
+
* what is in it, then ask `glyph` for the shape.
|
|
277
|
+
*
|
|
278
|
+
* The split is around the subpixels' mean brightness, so an edge running
|
|
279
|
+
* through a cell keeps both of the colours that meet there instead of being
|
|
280
|
+
* flattened to their average.
|
|
281
|
+
*/
|
|
282
|
+
function twoColourCell(parts, glyph) {
|
|
283
|
+
const present = parts.filter((part) => part !== null);
|
|
284
|
+
if (present.length === 0)
|
|
285
|
+
return { ch: " ", fg: "", bg: "" };
|
|
286
|
+
const luma = (c) => (c.r * 299 + c.g * 587 + c.b * 114) / 1000;
|
|
287
|
+
const mean = present.reduce((sum, c) => sum + luma(c), 0) / present.length;
|
|
288
|
+
const light = present.filter((c) => luma(c) >= mean);
|
|
289
|
+
const dark = present.filter((c) => luma(c) < mean);
|
|
290
|
+
// One colour in the cell: paint it flat rather than inventing an edge.
|
|
291
|
+
if (light.length === 0 || dark.length === 0)
|
|
292
|
+
return { ch: " ", fg: "", bg: hexOf(average(present)) };
|
|
293
|
+
const fg = average(light);
|
|
294
|
+
const bg = average(dark);
|
|
295
|
+
let mask = 0;
|
|
296
|
+
parts.forEach((part, index) => {
|
|
297
|
+
// A subpixel with nothing in it takes the background, so a transparent
|
|
298
|
+
// corner does not pull the glyph toward the mark.
|
|
299
|
+
if (part && luma(part) >= mean)
|
|
300
|
+
mask |= 1 << index;
|
|
301
|
+
});
|
|
302
|
+
return { ch: glyph(mask), fg: hexOf(fg), bg: hexOf(bg) };
|
|
303
|
+
}
|
|
304
|
+
function average(colors) {
|
|
305
|
+
const total = colors.reduce((sum, c) => ({ r: sum.r + c.r, g: sum.g + c.g, b: sum.b + c.b }), { r: 0, g: 0, b: 0 });
|
|
306
|
+
return { r: total.r / colors.length, g: total.g / colors.length, b: total.b / colors.length };
|
|
307
|
+
}
|
|
308
|
+
const hexOf = (c) => hex(c.r, c.g, c.b);
|
|
309
|
+
/**
|
|
310
|
+
* A sextant glyph for a bitmask of six subpixels: bit 0 upper-left, 1
|
|
311
|
+
* upper-right, 2 middle-left, 3 middle-right, 4 lower-left, 5 lower-right.
|
|
312
|
+
*
|
|
313
|
+
* Unicode 13's Symbols for Legacy Computing allocates U+1FB00..U+1FB3B for
|
|
314
|
+
* sixty of the sixty-four combinations. It leaves out the four that already had
|
|
315
|
+
* glyphs: empty, full, the left column and the right column. Those are the
|
|
316
|
+
* three special cases below, and they are where this mapping goes wrong if it
|
|
317
|
+
* goes wrong at all.
|
|
318
|
+
*/
|
|
319
|
+
export function sextantGlyph(mask) {
|
|
320
|
+
const value = mask & 0b111111;
|
|
321
|
+
if (value === 0)
|
|
322
|
+
return " ";
|
|
323
|
+
if (value === 0b111111)
|
|
324
|
+
return "\u2588";
|
|
325
|
+
if (value === 0b010101)
|
|
326
|
+
return "\u258c"; // left column
|
|
327
|
+
if (value === 0b101010)
|
|
328
|
+
return "\u2590"; // right column
|
|
329
|
+
const skipped = (value > 0b010101 ? 1 : 0) + (value > 0b101010 ? 1 : 0);
|
|
330
|
+
return String.fromCodePoint(0x1fb00 + value - 1 - skipped);
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* An image as sextant cells: **six** subpixels per cell, two across and three
|
|
334
|
+
* down, against a quadrant's four and a half block's two.
|
|
335
|
+
*
|
|
336
|
+
* Same two-colour contract as the others, so these cells composite like any
|
|
337
|
+
* other. The catch is font coverage: sextants arrived in Unicode 13 (2020) and
|
|
338
|
+
* a font without them draws tofu, which is worse than a coarser image. The
|
|
339
|
+
* renderer is therefore chosen by `blockMode()` rather than assumed.
|
|
340
|
+
*/
|
|
341
|
+
export function sextantRows(pixels, cols, rows, backdrop, mode) {
|
|
342
|
+
if (cols <= 0 || rows <= 0 || pixels.width <= 0 || pixels.height <= 0)
|
|
343
|
+
return [];
|
|
344
|
+
const box = imageBox(pixels, cols, rows, mode);
|
|
345
|
+
const gridCols = Math.max(1, box.cols) * 2;
|
|
346
|
+
const gridRows = Math.max(1, box.rows) * 3;
|
|
347
|
+
const out = [];
|
|
348
|
+
for (let row = 0; row < box.rows; row += 1) {
|
|
349
|
+
const cells = [];
|
|
350
|
+
for (let col = 0; col < box.cols; col += 1) {
|
|
351
|
+
const parts = [];
|
|
352
|
+
for (let dy = 0; dy < 3; dy += 1) {
|
|
353
|
+
for (let dx = 0; dx < 2; dx += 1) {
|
|
354
|
+
parts.push(sampleRgb(pixels, col * 2 + dx, row * 3 + dy, gridCols, gridRows, backdrop));
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
cells.push(twoColourCell(parts, sextantGlyph));
|
|
358
|
+
}
|
|
359
|
+
out.push(cells);
|
|
360
|
+
}
|
|
361
|
+
return out;
|
|
362
|
+
}
|