@hncode/hncode 0.5.5 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +37 -38
- package/src/agent.js +67 -2
- package/src/image-row.js +111 -0
- package/src/latex.js +1557 -0
- package/src/llm.js +19 -4
- package/src/mermaid/ansi.js +55 -0
- package/src/mermaid/canvas.js +449 -0
- package/src/mermaid/class-style.js +57 -0
- package/src/mermaid/css-colors.js +153 -0
- package/src/mermaid/diagrams/class.js +289 -0
- package/src/mermaid/diagrams/er.js +201 -0
- package/src/mermaid/diagrams/flowchart.js +488 -0
- package/src/mermaid/diagrams/gitgraph.js +205 -0
- package/src/mermaid/diagrams/mindmap.js +101 -0
- package/src/mermaid/diagrams/pie.js +104 -0
- package/src/mermaid/diagrams/sequence.js +282 -0
- package/src/mermaid/diagrams/state.js +292 -0
- package/src/mermaid/diagrams/timeline.js +99 -0
- package/src/mermaid/graph.js +138 -0
- package/src/mermaid/index.js +59 -0
- package/src/mermaid/labels.js +339 -0
- package/src/mermaid/layout-seq.js +225 -0
- package/src/mermaid/layout.js +1454 -0
- package/src/mermaid/registry.js +54 -0
- package/src/mermaid/source-box.js +102 -0
- package/src/mermaid/statements.js +235 -0
- package/src/mermaid/types.js +1 -0
- package/src/mermaid/width.js +29 -0
- package/src/screen-diff.js +512 -0
- package/src/screen-paint.js +128 -0
- package/src/screen.js +277 -0
- package/src/subagent-types.js +136 -147
- package/src/term-caps.js +10 -4
- package/src/tex/font.js +292 -0
- package/src/tex/fonts/LICENSE.txt +11 -0
- package/src/tex/fonts/STIX2Math.ttf +0 -0
- package/src/tex/index.js +293 -0
- package/src/tex/kitty.js +186 -0
- package/src/tex/layout.js +446 -0
- package/src/tex/parse.js +331 -0
- package/src/tex/raster.js +216 -0
- package/src/tex/render.js +167 -0
- package/src/tex/sixel.js +326 -0
- package/src/tex/svg.js +237 -0
- package/src/tool-result.js +2 -2
- package/src/tools/agent-swarm.js +3 -15
- package/src/tools/agent.js +454 -464
- package/src/tools/ask-user-question.js +195 -206
- package/src/tools/bash.js +39 -3
- package/src/tools/edit.js +10 -112
- package/src/tools/fetch-url.js +130 -120
- package/src/tools/memory.js +80 -88
- package/src/tools/read.js +27 -23
- package/src/tools/todo.js +8 -13
- package/src/tools/validate-args.js +78 -0
- package/src/tui.js +1346 -496
|
@@ -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
|
+
}
|
package/src/tex/sixel.js
ADDED
|
@@ -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 };
|