@hncode/hncode 0.5.6 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/package.json +37 -38
  2. package/src/agent.js +67 -2
  3. package/src/image-row.js +111 -0
  4. package/src/latex.js +1557 -0
  5. package/src/llm.js +19 -4
  6. package/src/mermaid/ansi.js +55 -0
  7. package/src/mermaid/canvas.js +449 -0
  8. package/src/mermaid/class-style.js +57 -0
  9. package/src/mermaid/css-colors.js +153 -0
  10. package/src/mermaid/diagrams/class.js +289 -0
  11. package/src/mermaid/diagrams/er.js +201 -0
  12. package/src/mermaid/diagrams/flowchart.js +488 -0
  13. package/src/mermaid/diagrams/gitgraph.js +205 -0
  14. package/src/mermaid/diagrams/mindmap.js +101 -0
  15. package/src/mermaid/diagrams/pie.js +104 -0
  16. package/src/mermaid/diagrams/sequence.js +282 -0
  17. package/src/mermaid/diagrams/state.js +292 -0
  18. package/src/mermaid/diagrams/timeline.js +99 -0
  19. package/src/mermaid/graph.js +138 -0
  20. package/src/mermaid/index.js +59 -0
  21. package/src/mermaid/labels.js +339 -0
  22. package/src/mermaid/layout-seq.js +225 -0
  23. package/src/mermaid/layout.js +1454 -0
  24. package/src/mermaid/registry.js +54 -0
  25. package/src/mermaid/source-box.js +102 -0
  26. package/src/mermaid/statements.js +235 -0
  27. package/src/mermaid/types.js +1 -0
  28. package/src/mermaid/width.js +29 -0
  29. package/src/screen-diff.js +512 -0
  30. package/src/screen-paint.js +128 -0
  31. package/src/screen.js +277 -0
  32. package/src/subagent-types.js +136 -147
  33. package/src/term-caps.js +10 -4
  34. package/src/tex/font.js +292 -0
  35. package/src/tex/fonts/LICENSE.txt +11 -0
  36. package/src/tex/fonts/STIX2Math.ttf +0 -0
  37. package/src/tex/index.js +293 -0
  38. package/src/tex/kitty.js +186 -0
  39. package/src/tex/layout.js +446 -0
  40. package/src/tex/parse.js +331 -0
  41. package/src/tex/raster.js +216 -0
  42. package/src/tex/render.js +167 -0
  43. package/src/tex/sixel.js +326 -0
  44. package/src/tex/svg.js +237 -0
  45. package/src/tool-result.js +2 -2
  46. package/src/tools/agent-swarm.js +3 -15
  47. package/src/tools/agent.js +454 -464
  48. package/src/tools/ask-user-question.js +195 -206
  49. package/src/tools/bash.js +39 -3
  50. package/src/tools/edit.js +10 -112
  51. package/src/tools/fetch-url.js +130 -120
  52. package/src/tools/memory.js +80 -88
  53. package/src/tools/read.js +27 -23
  54. package/src/tools/todo.js +8 -13
  55. package/src/tools/validate-args.js +78 -0
  56. package/src/tui.js +1346 -502
@@ -0,0 +1,167 @@
1
+ /**
2
+ * A laid-out formula → an RGBA image.
3
+ *
4
+ * The box tree from `layout.js` is in font units, so the first decision is the scale: the caller asks for a
5
+ * pixel size, and everything below is that scale applied once. Walking the tree means every `glyph` item
6
+ * gets its outline rasterised at `size * scale` and composited at its own offset.
7
+ *
8
+ * Colour is a parameter rather than a style: the caller composites the formula onto whatever background its
9
+ * terminal already has, so the raster must not assume one.
10
+ */
11
+ import { rasterize, composite } from './raster.js';
12
+
13
+ /**
14
+ * Render a laid-out box to pixels.
15
+ *
16
+ * @param {{width:number,height:number,depth:number,items:object[]}} box Output of `layout()`.
17
+ * @param {object} font A `TrueTypeFont`.
18
+ * @param {object} opts
19
+ * @param {number} opts.px Target pixel size of one em.
20
+ * @param {{r:number,g:number,b:number}} [opts.colour] Ink colour. Defaults to black.
21
+ * @param {number} [opts.padding] Blank pixels around the formula.
22
+ * @returns {{rgba: Uint8ClampedArray, width: number, height: number}}
23
+ */
24
+ export function renderBox(box, font, opts) {
25
+ const px = Math.max(4, Math.round(opts.px || 24));
26
+ const colour = opts.colour || { r: 0, g: 0, b: 0 };
27
+ // A MARGIN IS PART OF THE IMAGE, not decoration. A bracket's arms are two pixels thick and sit exactly on
28
+ // the content's bounding edge, so a small margin left the last arm flush with the bottom of the bitmap —
29
+ // it read as clipped even though every row was drawn. Antialiased glyph edges reach a pixel past their
30
+ // outline, so the margin has to be wider than the stroke.
31
+ const pad = Math.max(4, Math.round(opts.padding ?? 8));
32
+
33
+ // Font units per pixel. The layout works in a 1000-unit em regardless of the font's own em size.
34
+ const emUnits = 1000;
35
+ const scale = px / emUnits;
36
+
37
+ const width = Math.max(1, Math.ceil(box.width * scale) + pad * 2);
38
+ const height = Math.max(1, Math.ceil((box.height + box.depth) * scale) + pad * 2);
39
+ const baseline = Math.ceil(box.height * scale) + pad;
40
+
41
+ const rgba = new Uint8ClampedArray(width * height * 4);
42
+
43
+ // Each glyph is cached by (gid, pixel size): a formula repeats letters, and rasterising is the expensive
44
+ // part.
45
+ const cache = new Map();
46
+
47
+ const paint = (item, originX, originY) => {
48
+ if (item.kind === 'glyph') {
49
+ const sizePx = Math.max(1, Math.round(item.size * scale)); // glyph size in px
50
+ const key = item.gid + ':' + sizePx;
51
+ let hit = cache.get(key);
52
+ if (!hit) {
53
+ const outline = font.outline(item.gid);
54
+ const k = sizePx / font.unitsPerEm;
55
+ // Rasterise into a patch sized from the glyph's own metrics, with the baseline at `asc`.
56
+ const asc = Math.ceil(Math.max(item.height, 1) * k) + 1;
57
+ const desc = Math.ceil(Math.max(item.depth, 1) * k) + 1;
58
+ const w = Math.max(1, Math.ceil(item.width * k) + 2);
59
+ const h = Math.max(1, asc + desc);
60
+ const cover = rasterize(outline, k, 1, asc, w, h);
61
+ hit = { cover, w, h, baseline: asc };
62
+ cache.set(key, hit);
63
+ }
64
+ composite(rgba, hit.cover, hit.w, hit.h, width, colour, {
65
+ x: originX - 1,
66
+ y: originY - hit.baseline,
67
+ });
68
+ return;
69
+ }
70
+
71
+ if (item.kind === 'rule') {
72
+ // A bar: one or two pixels thick, drawn as plain pixels rather than as a glyph.
73
+ const x0 = Math.max(0, originX);
74
+ const y0 = Math.max(0, originY);
75
+ const wpx = Math.max(1, Math.round(item.width * scale));
76
+ const px1 = Math.max(1, Math.round(2));
77
+ for (let y = y0; y < Math.min(height, y0 + px1); y++) {
78
+ for (let x = x0; x < Math.min(width, x0 + wpx); x++) {
79
+ const i = (y * width + x) * 4;
80
+ rgba[i] = colour.r;
81
+ rgba[i + 1] = colour.g;
82
+ rgba[i + 2] = colour.b;
83
+ rgba[i + 3] = 255;
84
+ }
85
+ }
86
+ return;
87
+ }
88
+
89
+ if (item.kind === 'bracket') {
90
+ // A BRACKET IS A VERTICAL STROKE WITH TWO ARMS POINTING INWARD.
91
+ //
92
+ // The stroke position depends on the side: a left bracket draws its stroke at the left edge and its
93
+ // arms to the right; a right bracket mirrors that. Drawing both at `x0` produced two left brackets,
94
+ // which is what made a matrix look like `[ … [` instead of `[ … ]`.
95
+ const x0 = originX;
96
+ const top = originY - item.height * scale;
97
+ const bottom = originY + item.depth * scale;
98
+ const wpx = Math.max(1, Math.round(item.width * scale));
99
+ const arm = Math.max(2, Math.round(wpx * 0.8));
100
+ const thick = Math.max(1, Math.round(2));
101
+ const draw = (x, y) => {
102
+ if (x < 0 || y < 0 || x >= width || y >= height) return;
103
+ const i = (y * width + x) * 4;
104
+ rgba[i] = colour.r;
105
+ rgba[i + 1] = colour.g;
106
+ rgba[i + 2] = colour.b;
107
+ rgba[i + 3] = 255;
108
+ };
109
+
110
+ // The stroke sits at the outer edge of the bracket's own box.
111
+ const strokeX = item.side === 'left' ? Math.round(x0) : Math.round(x0) + wpx - thick;
112
+ const top0 = Math.max(0, Math.round(top));
113
+ const bot0 = Math.min(height - 1, Math.round(bottom));
114
+ for (let y = top0; y <= bot0; y++) {
115
+ for (let t = 0; t < thick; t++) draw(strokeX + t, y);
116
+ }
117
+
118
+ // Arms start at the stroke and reach inward.
119
+ const armStart = item.side === 'left' ? strokeX : strokeX - arm + thick;
120
+ for (let a = 0; a < arm; a++) {
121
+ for (let t = 0; t < thick; t++) {
122
+ draw(armStart + a, top0 + t);
123
+ draw(armStart + a, bot0 - t);
124
+ }
125
+ }
126
+ return;
127
+ }
128
+
129
+ if (item.kind === 'brace') {
130
+ // A left brace as a column of `{`-shaped pixels would take a real glyph; the env renderer uses a
131
+ // bracket for `cases` too, so this only runs if a caller asks for a brace explicitly.
132
+ return;
133
+ }
134
+ };
135
+
136
+ const walk = (node, ox, oy) => {
137
+ // The node's x/y are measured in the parent's space, in font units.
138
+ const x = ox + Math.round((node.x || 0) * scale);
139
+ const y = oy - Math.round((node.y || 0) * scale);
140
+ if (node.kind) paint(node, x, y);
141
+ if (node.items) for (const child of node.items) walk(child, x, y);
142
+ };
143
+
144
+ walk(box, pad, baseline); // the tree is in FONT UNITS; `paint` and `walk` scale as they go
145
+
146
+ // CROP FROM EITHER EDGE, if a caller asked for it.
147
+ //
148
+ // A picture that straddles the top of the window must be drawn from its first VISIBLE row, and a sixel
149
+ // payload can only be cropped on a six-pixel band boundary. Cropping the ENCODED payload therefore leaves
150
+ // between 0 and 5 pixels of the picture above the window — up to a quarter of a cell — so the formula
151
+ // visibly slides as the view scrolls and snaps back when the leftover happens to be zero. Cropping the
152
+ // PIXELS instead makes the first drawn row the first visible row, with no leftover to hide.
153
+ //
154
+ // The BOTTOM is the same problem at the panel's other edge: the transcript ends where the composer begins,
155
+ // and a picture clipped to one visible row still drew its remaining rows over the input box. `maxHeight` is
156
+ // the pixels that may remain; anything past it is dropped here rather than encoded and drawn.
157
+ const skip = Math.max(0, Math.round(opts.skipTop || 0));
158
+ const limit = opts.maxHeight > 0 ? Math.max(1, Math.round(opts.maxHeight)) : Infinity;
159
+ if (skip > 0 || limit < height) {
160
+ const kept = Math.max(1, Math.min(height - skip, limit));
161
+ const cropped = new Uint8ClampedArray(width * kept * 4);
162
+ cropped.set(rgba.subarray(skip * width * 4, (skip + kept) * width * 4));
163
+ return { rgba: cropped, width, height: kept };
164
+ }
165
+
166
+ return { rgba, width, height };
167
+ }
@@ -0,0 +1,326 @@
1
+ /**
2
+ * RGBA → a Sixel image sequence.
3
+ *
4
+ * Sixel is a raster format where a row of six pixels is one character: 63 (`?`) is blank and successive
5
+ * bits set pixels top-down, so `@` lights the top pixel of the band and `B` the top two. The encoder walks
6
+ * the image in bands of six rows, quantises colours to a palette, and emits one `#n` colour select followed
7
+ * by the run-encoded pixels for that colour.
8
+ *
9
+ * THE THREE SEQUENCES, and getting them wrong is how a sixel arrives stretched, shifted or blank:
10
+ *
11
+ * DCS `ESC P P1;P2;P3; q` — P2 is the BACKGROUND SELECT, the only one most terminals implement
12
+ * raster `" Pan;Pad;Ph;Pv` — exactly four parameters: aspect, aspect, width, height
13
+ * finalize `ESC \` — returns the terminal to normal operation
14
+ *
15
+ * The raster attribute carries NO background parameter. Putting one there (as this encoder used to) makes a
16
+ * decoder read the height as the wrong field, and the image comes out wider than it is: measured, an 8-pixel
17
+ * wide picture decoded as 10.
18
+ *
19
+ * A BAND WITH NOTHING IN IT STILL ADVANCES. Sixel draws relative to wherever the cursor sits, so a band is
20
+ * positioned by the `-` characters before it, not by its own content. Skipping the `-` for an empty band
21
+ * pulls everything below it UP by six rows, which is what put a formula's descenders inside the line above.
22
+ *
23
+ * The palette is built with a median cut over the image's own colours rather than a fixed 256-colour cube.
24
+ * A formula is a handful of greys plus antialiased edges, and a cube would spend its entries on colours that
25
+ * never appear.
26
+ */
27
+
28
+ /** Bands are six pixel rows tall by definition. */
29
+ const BAND = 6;
30
+
31
+ /** DCS: pixel aspect 0 (default), background select, no horizontal grid. */
32
+ const introducer = (transparent) => `\x1bP0;${transparent ? 1 : 0};0;q`;
33
+ /** String Terminator. */
34
+ const FINALIZER = '\x1b\\';
35
+
36
+ /**
37
+ * Build a palette from the distinct opaque colours in the image, at most `max` of them.
38
+ * Median cut: repeatedly split the box with the widest channel spread at its median.
39
+ */
40
+ function buildPalette(rgba, background, max = 255) {
41
+ // Flatten alpha onto the background so the antialiased edges become SHADES rather than the ink colour at
42
+ // a lower alpha. Sixel has no partial alpha; without this every edge painted solid.
43
+ const bgColour = background || { r: 0, g: 0, b: 0 };
44
+ const shade = (i) => {
45
+ const a = rgba[i + 3] / 255;
46
+ return {
47
+ r: Math.round(rgba[i] * a + bgColour.r * (1 - a)),
48
+ g: Math.round(rgba[i + 1] * a + bgColour.g * (1 - a)),
49
+ b: Math.round(rgba[i + 2] * a + bgColour.b * (1 - a)),
50
+ };
51
+ };
52
+
53
+ const seen = new Map();
54
+ for (let i = 0; i < rgba.length; i += 4) {
55
+ if (rgba[i + 3] === 0) continue; // fully transparent: not part of the picture at all
56
+ const s = shade(i);
57
+ const key = (s.r << 16) | (s.g << 8) | s.b;
58
+ seen.set(key, (seen.get(key) || 0) + 1);
59
+ }
60
+ if (seen.size === 0) return [{ r: 0, g: 0, b: 0 }];
61
+
62
+ // DROP THE BACKGROUND SHADE BEFORE ANY EARLY RETURN.
63
+ //
64
+ // A shade identical to the background is indistinguishable from the unpainted area, so selecting it for
65
+ // ink paints nothing. It has to go before the "few colours, use them all" shortcut below, or the darkest
66
+ // shade keeps the background's colour and the formula loses its dark side.
67
+ if (bgColour) {
68
+ const bgKey = (bgColour.r << 16) | (bgColour.g << 8) | bgColour.b;
69
+ if (seen.size > 1) seen.delete(bgKey);
70
+ }
71
+
72
+ if (seen.size <= max) {
73
+ return [...seen.keys()].map((k) => ({ r: (k >> 16) & 255, g: (k >> 8) & 255, b: k & 255 }));
74
+ }
75
+
76
+ let boxes = [[...seen.entries()].map(([k, n]) => ({ r: (k >> 16) & 255, g: (k >> 8) & 255, b: k & 255, n }))];
77
+
78
+ while (boxes.length < max) {
79
+ // Split the box with the largest weighted extent.
80
+ let bestIdx = -1;
81
+ let bestSpread = 0;
82
+ for (let b = 0; b < boxes.length; b++) {
83
+ const box = boxes[b];
84
+ if (box.length < 2) continue;
85
+ const spread = ['r', 'g', 'b'].reduce((m, ch) => {
86
+ let lo = 255;
87
+ let hi = 0;
88
+ for (const c of box) { if (c[ch] < lo) lo = c[ch]; if (c[ch] > hi) hi = c[ch]; }
89
+ return Math.max(m, hi - lo);
90
+ }, 0);
91
+ if (spread > bestSpread) { bestSpread = spread; bestIdx = b; }
92
+ }
93
+ if (bestIdx < 0) break;
94
+
95
+ const box = boxes[bestIdx];
96
+ // Split on the channel with the widest range.
97
+ let channel = 'r';
98
+ let widest = 0;
99
+ for (const ch of ['r', 'g', 'b']) {
100
+ let lo = 255;
101
+ let hi = 0;
102
+ for (const c of box) { if (c[ch] < lo) lo = c[ch]; if (c[ch] > hi) hi = c[ch]; }
103
+ if (hi - lo > widest) { widest = hi - lo; channel = ch; }
104
+ }
105
+ box.sort((a, b) => a[channel] - b[channel]);
106
+ let cut = 0;
107
+ let total = 0;
108
+ for (const c of box) total += c.n;
109
+ let acc = 0;
110
+ for (let i = 0; i < box.length; i++) {
111
+ acc += box[i].n;
112
+ if (acc >= total / 2) { cut = Math.max(1, Math.min(box.length - 1, i + 1)); break; }
113
+ }
114
+ const left = box.slice(0, cut);
115
+ const right = box.slice(cut);
116
+ if (!left.length || !right.length) { boxes.splice(bestIdx, 1); continue; }
117
+ boxes.splice(bestIdx, 1, left, right);
118
+ }
119
+
120
+ // Each box becomes its weighted mean.
121
+ return boxes.map((box) => {
122
+ let r = 0, g = 0, b = 0, n = 0;
123
+ for (const c of box) { r += c.r * c.n; g += c.g * c.n; b += c.b * c.n; n += c.n; }
124
+ return { r: Math.round(r / n), g: Math.round(g / n), b: Math.round(b / n) };
125
+ });
126
+ }
127
+
128
+ /** Nearest palette entry, by squared distance. */
129
+ function nearest(palette, r, g, b) {
130
+ let best = 0;
131
+ let bestD = Infinity;
132
+ for (let i = 0; i < palette.length; i++) {
133
+ const p = palette[i];
134
+ const d = (p.r - r) * (p.r - r) + (p.g - g) * (p.g - g) + (p.b - b) * (p.b - b);
135
+ if (d < bestD) { bestD = d; best = i; }
136
+ }
137
+ return best;
138
+ }
139
+
140
+ /** A sixel data character for one column's six-bit mask. */
141
+ const sixelChar = (bits) => String.fromCharCode(63 + bits);
142
+
143
+ /**
144
+ * Write the band-major body of a sequence from a per-pixel colour register.
145
+ *
146
+ * Every band ends with `-`, an empty one included: the separator advances the cursor by six rows and is the
147
+ * only thing that says where the next band starts. Skipping it for an empty band pulls everything below it
148
+ * UP by six rows, which is what put a formula's descenders inside the line above.
149
+ *
150
+ * @param {Int16Array} indices One entry per pixel: a colour register, or -1 for "not painted".
151
+ * @param {number} w
152
+ * @param {number} h
153
+ * @returns {string}
154
+ */
155
+ function encodeBands(indices, w, h) {
156
+ let out = '';
157
+ let written = 0;
158
+ for (let by = 0; by < h; by += BAND) {
159
+ const rows = Math.min(BAND, h - by);
160
+
161
+ // Which colours appear in this band, and the six-bit column masks that paint them.
162
+ const masks = new Map();
163
+ for (let x = 0; x < w; x++) {
164
+ for (let r = 0; r < rows; r++) {
165
+ const idx = indices[(by + r) * w + x];
166
+ if (idx < 0) continue;
167
+ let m = masks.get(idx);
168
+ if (!m) { m = new Uint8Array(w); masks.set(idx, m); }
169
+ m[x] |= 1 << r;
170
+ }
171
+ }
172
+
173
+ if (written > 0) out += '-';
174
+ written++;
175
+ if (masks.size === 0) continue;
176
+
177
+ let first = true;
178
+ for (const [idx, mask] of masks) {
179
+ const chars = new Array(w);
180
+ for (let x = 0; x < w; x++) chars[x] = sixelChar(mask[x]);
181
+ // Within a band a second colour restarts at column 0: that is what `$` does.
182
+ if (!first) out += '$';
183
+ first = false;
184
+ out += '#' + idx + encodeRun(chars);
185
+ }
186
+ }
187
+ return out;
188
+ }
189
+
190
+ /**
191
+ * Encode a single-colour coverage mask: one ink register, one bit per pixel.
192
+ *
193
+ * This is the form a typeset formula takes. The raster is antialiased COVERAGE, and Sixel has no partial
194
+ * alpha, so a pixel either reaches `threshold` and is painted in full `ink` or is not painted at all. A hard
195
+ * mask is what keeps a hairline stroke — a fraction bar, a radical overline — continuous, where encoding the
196
+ * antialiased ramp as greys dissolves it into speckle. dsh-tui settles on the same rule (src/ink/
197
+ * sixel-codec.ts, `sixelCoveragePaintsThreshold`: a 25% bar).
198
+ *
199
+ * @param {Uint8ClampedArray|Uint8Array} rgba
200
+ * @param {number} w
201
+ * @param {number} h
202
+ * @param {{transparent: boolean, background: {r:number,g:number,b:number}, threshold: number,
203
+ * ink: {r:number,g:number,b:number}}} opts
204
+ * @returns {string}
205
+ */
206
+ function encodeMask(rgba, w, h, opts) {
207
+ const { transparent, background: bg, threshold, ink } = opts;
208
+ const indices = new Int16Array(w * h).fill(-1);
209
+ for (let y = 0; y < h; y++) {
210
+ for (let x = 0; x < w; x++) {
211
+ const i = (y * w + x) * 4;
212
+ // An opaque background paints every pixel; a transparent one paints only the covered ones.
213
+ if (transparent && rgba[i + 3] < threshold) continue;
214
+ indices[y * w + x] = transparent ? 0 : 1;
215
+ }
216
+ }
217
+
218
+ let out = introducer(transparent);
219
+ out += `"1;1;${w};${h}`;
220
+ if (!transparent) {
221
+ out += '#0;2;' + Math.round(bg.r * 100 / 255) + ';' + Math.round(bg.g * 100 / 255) + ';'
222
+ + Math.round(bg.b * 100 / 255);
223
+ }
224
+ out += '#' + (transparent ? 0 : 1) + ';2;'
225
+ + Math.round(ink.r * 100 / 255) + ';' + Math.round(ink.g * 100 / 255) + ';'
226
+ + Math.round(ink.b * 100 / 255);
227
+ return out + encodeBands(indices, w, h) + FINALIZER;
228
+ }
229
+
230
+ /**
231
+ * Encode an RGBA image as a Sixel sequence.
232
+ *
233
+ * @param {Uint8ClampedArray|Uint8Array} rgba Pixels, width*height*4.
234
+ * @param {number} width Image width.
235
+ * @param {number} height Image height.
236
+ * @param {{transparent?: boolean, background?: {r:number,g:number,b:number}}} [opts]
237
+ * `transparent` (the default) leaves uncovered pixels alone so the terminal's own background shows through;
238
+ * pass `false` to paint the whole box in `background`.
239
+ * @returns {string} The sequence, ready to write to the terminal.
240
+ */
241
+ export function encodeSixel(rgba, width, height, opts = {}) {
242
+ const w = Math.max(0, width | 0);
243
+ const h = Math.max(0, height | 0);
244
+ if (!w || !h) return introducer(true) + FINALIZER;
245
+
246
+ const bg = opts.background || { r: 0, g: 0, b: 0 };
247
+ const transparent = opts.transparent !== false;
248
+
249
+ // LINE ART IS A ONE-BIT MASK, not a gradient.
250
+ //
251
+ // A formula is a single ink colour drawn with antialiasing, and Sixel has no partial alpha. Encoding the
252
+ // antialiased ramp as dozens of grey registers costs a large payload, and the terminal's own fitting then
253
+ // turns those middle greys into speckle. dsh-tui reaches the same conclusion (src/ink/sixel-codec.ts,
254
+ // `sixelCoveragePaintsThreshold`): with `lineArt` the coverage is promoted at one threshold, so a hairline
255
+ // stroke — a fraction bar, a radical overline, a superscript — stays continuous instead of fading out.
256
+ const lineArt = opts.lineArt === true;
257
+ const ink = opts.ink || null;
258
+ if (lineArt || ink) {
259
+ return encodeMask(rgba, w, h, {
260
+ transparent,
261
+ background: bg,
262
+ threshold: lineArt ? 64 : 0, // 25% coverage, the same bar dsh-tui uses
263
+ ink: ink || { r: 220, g: 220, b: 220 },
264
+ });
265
+ }
266
+
267
+ const palette = buildPalette(rgba, transparent ? null : bg);
268
+
269
+ // Map every pixel through the SAME flattening the palette was built with. Comparing the raw ink colour
270
+ // against background-blended shades put nearly every pixel on one of two entries, and the formula lost its
271
+ // edges. `-1` means "not painted", which is how a transparent pixel is expressed.
272
+ const indices = new Int16Array(w * h).fill(-1);
273
+ for (let y = 0; y < h; y++) {
274
+ for (let x = 0; x < w; x++) {
275
+ const i = (y * w + x) * 4;
276
+ const a = rgba[i + 3] / 255;
277
+ if (a <= 0.02 && transparent) continue;
278
+ const r = Math.round(rgba[i] * a + bg.r * (1 - a));
279
+ const g = Math.round(rgba[i + 1] * a + bg.g * (1 - a));
280
+ const b = Math.round(rgba[i + 2] * a + bg.b * (1 - a));
281
+ indices[y * w + x] = nearest(palette, r, g, b);
282
+ }
283
+ }
284
+
285
+ let out = introducer(transparent);
286
+ // Raster attributes: aspect ratio, then the drawing area in pixels, and NOTHING else.
287
+ out += `"1;1;${w};${h}`;
288
+
289
+ // Colour registers. An opaque image owns register 0 for the background; a transparent one uses all of
290
+ // them for ink, because nothing is painted behind the picture.
291
+ const firstRegister = transparent ? 0 : 1;
292
+ if (!transparent) {
293
+ out += '#0;2;' + Math.round(bg.r * 100 / 255) + ';' + Math.round(bg.g * 100 / 255) + ';'
294
+ + Math.round(bg.b * 100 / 255);
295
+ }
296
+ for (let i = 0; i < palette.length; i++) {
297
+ const p = palette[i];
298
+ // Sixel colour components are percentages, 0..100, not bytes.
299
+ out += '#' + (i + firstRegister) + ';2;'
300
+ + Math.round(p.r * 100 / 255) + ';'
301
+ + Math.round(p.g * 100 / 255) + ';'
302
+ + Math.round(p.b * 100 / 255);
303
+ }
304
+
305
+ // Register numbers: an opaque image reserves register 0 for its background, so the ink registers start at
306
+ // 1. Shifting the indices once keeps the band writer unaware of that.
307
+ if (firstRegister !== 0) {
308
+ for (let i = 0; i < indices.length; i++) if (indices[i] >= 0) indices[i] += firstRegister;
309
+ }
310
+ return out + encodeBands(indices, w, h) + FINALIZER;
311
+ }
312
+
313
+ /** Run-encode one colour's row of sixel characters. */
314
+ function encodeRun(chars) {
315
+ let out = '';
316
+ let run = 1;
317
+ for (let x = 1; x <= chars.length; x++) {
318
+ if (x < chars.length && chars[x] === chars[x - 1]) { run++; continue; }
319
+ const ch = chars[x - 1];
320
+ out += run >= 4 ? '!' + run + ch : ch.repeat(run);
321
+ run = 1;
322
+ }
323
+ return out;
324
+ }
325
+
326
+ export { introducer, FINALIZER, BAND };