@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.
@@ -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
+ }