@xamukavila/pxpipe 0.8.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 (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +312 -0
  3. package/bin/cli.js +7 -0
  4. package/dist/core/applicability.d.ts +31 -0
  5. package/dist/core/applicability.js +96 -0
  6. package/dist/core/atlas-gray.d.ts +26 -0
  7. package/dist/core/atlas-gray.js +64 -0
  8. package/dist/core/atlas.d.ts +34 -0
  9. package/dist/core/atlas.js +71 -0
  10. package/dist/core/baseline.d.ts +80 -0
  11. package/dist/core/baseline.js +101 -0
  12. package/dist/core/caveman.d.ts +38 -0
  13. package/dist/core/caveman.js +183 -0
  14. package/dist/core/export.d.ts +128 -0
  15. package/dist/core/export.js +390 -0
  16. package/dist/core/factsheet.d.ts +78 -0
  17. package/dist/core/factsheet.js +216 -0
  18. package/dist/core/gpt-model-profiles.d.ts +60 -0
  19. package/dist/core/gpt-model-profiles.js +161 -0
  20. package/dist/core/history.d.ts +141 -0
  21. package/dist/core/history.js +553 -0
  22. package/dist/core/index.d.ts +8 -0
  23. package/dist/core/index.js +8 -0
  24. package/dist/core/library.d.ts +74 -0
  25. package/dist/core/library.js +133 -0
  26. package/dist/core/measurement.d.ts +22 -0
  27. package/dist/core/measurement.js +213 -0
  28. package/dist/core/openai-history.d.ts +124 -0
  29. package/dist/core/openai-history.js +494 -0
  30. package/dist/core/openai-savings.d.ts +44 -0
  31. package/dist/core/openai-savings.js +75 -0
  32. package/dist/core/openai.d.ts +24 -0
  33. package/dist/core/openai.js +839 -0
  34. package/dist/core/png.d.ts +11 -0
  35. package/dist/core/png.js +132 -0
  36. package/dist/core/proxy.d.ts +81 -0
  37. package/dist/core/proxy.js +730 -0
  38. package/dist/core/render.d.ts +188 -0
  39. package/dist/core/render.js +785 -0
  40. package/dist/core/schema-strip.d.ts +29 -0
  41. package/dist/core/schema-strip.js +160 -0
  42. package/dist/core/tracker.d.ts +154 -0
  43. package/dist/core/tracker.js +216 -0
  44. package/dist/core/transform.d.ts +362 -0
  45. package/dist/core/transform.js +1828 -0
  46. package/dist/core/types.d.ts +77 -0
  47. package/dist/core/types.js +8 -0
  48. package/dist/dashboard/fragments.d.ts +36 -0
  49. package/dist/dashboard/fragments.js +938 -0
  50. package/dist/dashboard/types.d.ts +154 -0
  51. package/dist/dashboard/types.js +3 -0
  52. package/dist/dashboard/vendor.d.ts +3 -0
  53. package/dist/dashboard/vendor.js +6 -0
  54. package/dist/dashboard.d.ts +245 -0
  55. package/dist/dashboard.js +1140 -0
  56. package/dist/export-collect.d.ts +36 -0
  57. package/dist/export-collect.js +59 -0
  58. package/dist/node.d.ts +9 -0
  59. package/dist/node.js +9038 -0
  60. package/dist/sessions.d.ts +172 -0
  61. package/dist/sessions.js +510 -0
  62. package/dist/stats.d.ts +74 -0
  63. package/dist/stats.js +248 -0
  64. package/dist/worker.d.ts +53 -0
  65. package/dist/worker.js +102 -0
  66. package/package.json +96 -0
@@ -0,0 +1,785 @@
1
+ /**
2
+ * Text → PNG renderer. Blits atlas glyphs into a grayscale framebuffer, then PNG-encodes.
3
+ * Iterates by codepoint so East Asian Wide chars (2-cell advance) and surrogate pairs handled correctly.
4
+ * Pages capped at ~1932×1932 px: Fable/Opus 4.8 >20-image requests are held to ≤2000 px/side
5
+ * (REJECTED if exceeded, not silently downscaled); ≤4784 token limit binds first at 1932 px.
6
+ */
7
+ import { ATLAS_CELL_W, ATLAS_CELL_H, ATLAS_PIXELS, ATLAS_OFFSETS, ATLAS_WIDE_FLAGS, atlasRank, } from './atlas.js';
8
+ import { ATLAS_GRAY_CELL_W, ATLAS_GRAY_CELL_H, ATLAS_GRAY_PIXELS, ATLAS_GRAY_OFFSETS, ATLAS_GRAY_WIDE_FLAGS, atlasGrayRank, } from './atlas-gray.js';
9
+ import { encodeGrayPng, encodeRgbPng } from './png.js';
10
+ /** Page-height ceiling. Measured (2026-07-01, count_tokens sweep, claude-sonnet-4-5 — see
11
+ * /tmp/pxexp/LEVER1-findings.md): the API downscales any image to fit BOTH long-edge ≤1568
12
+ * AND ~1.15 MP (≈1,143,750 px), then bills ≈ px/750 (≈1525 tok cap, applied per-image).
13
+ * The old 1932×1932 page was billed at cap but resampled 0.555× → 5×8 glyphs reached the
14
+ * encoder at ~2.8×4.4 px. New page shape 1568×728 = 1,141,504 px fits both bounds →
15
+ * WYSIWYG for the vision encoder (also satisfies ≤2000 px/side for >20-image requests). */
16
+ export const MAX_HEIGHT_PX = 728;
17
+ /** Char budget for the static slab (system + tools + CLAUDE.md). Matches physical page
18
+ * capacity at 312 cols × 90 rows so image-count estimates track real pagination. */
19
+ export const READABLE_CHARS_PER_IMAGE = 28080;
20
+ /** Char budget for dense content (tool output, collapsed history). 312 cols × 90 rows = 28080
21
+ * chars fills the 1568×728 page. NOTE: verbatim recall of imaged text is unreliable at any size. */
22
+ export const DENSE_CONTENT_CHARS_PER_IMAGE = 28080;
23
+ export const DENSE_CONTENT_COLS = 312;
24
+ /** Bare 5×8 cell (no padding). A/B showed 5×8 beats 7×10 on dense JSON (4/5 vs 3/5 reads, 42% fewer tokens).
25
+ * Revert to {cellWBonus:2, cellHBonus:2} if misread rates rise. */
26
+ export const DENSE_RENDER_STYLE = { cellWBonus: 0, cellHBonus: 0, aa: true };
27
+ /** Default columns for the static slab. 312 × 5 px + 8 px pad = 1568 px — exactly the API's
28
+ * long-edge bound (313 cols = 1573 px would trigger a 0.997× resample, blurring every glyph). */
29
+ const DEFAULT_COLS = 312;
30
+ /** Horizontal padding (left + right each), px. Exported for transform.ts token-cost math. */
31
+ export const PAD_X = 4;
32
+ /** Vertical padding (top + bottom each), px. Exported for transform.ts token-cost math. */
33
+ export const PAD_Y = 4;
34
+ /** Production ships bare 5×8 atlas cell (reflow+grayscale+inimage instruction band
35
+ * brought 5×8 to 98.95% OCR accuracy on Opus 4.7, matching or beating padded cells).
36
+ * RenderStyle.cellWBonus/cellHBonus override per-eval only. */
37
+ export const DEFAULT_CELL_W_BONUS = 0;
38
+ export const DEFAULT_CELL_H_BONUS = 0;
39
+ /** Effective cell pixel dimensions. transform.ts derives image-budget math from these. */
40
+ export const CELL_W = ATLAS_CELL_W + DEFAULT_CELL_W_BONUS;
41
+ export const CELL_H = ATLAS_CELL_H + DEFAULT_CELL_H_BONUS;
42
+ // --- column-aware wrapping -------------------------------------------------
43
+ /** Visual width of a codepoint in cells (1 = Latin, 2 = East Asian Wide).
44
+ * Missing codepoints advance 1 cell so wrap math stays stable. */
45
+ function cellsFor(codepoint, markerScale = 1) {
46
+ // Enlarged ↵ occupies markerScale cells of wrap budget instead of 1.
47
+ if (codepoint === NL_SENTINEL_CP && markerScale > 1)
48
+ return markerScale;
49
+ const rank = atlasRank(codepoint);
50
+ if (rank < 0)
51
+ return 1;
52
+ return ATLAS_WIDE_FLAGS[rank] === 1 ? 2 : 1;
53
+ }
54
+ const TAB_WIDTH = 4; // standard 4-space tab stops (logs, code, tool output are all 4-oriented)
55
+ /** Strip trailing whitespace per line and collapse 4+ consecutive \n to 3.
56
+ * Does NOT touch mid-line spaces or leading indent — structure is preserved. */
57
+ export function minifyForRender(text) {
58
+ return text
59
+ .split('\n')
60
+ .map((line) => line.replace(/[ \t]+$/, ''))
61
+ .join('\n')
62
+ .replace(/\n{4,}/g, '\n\n\n'); // 4+ \n → 3 \n (max 2 blank lines)
63
+ }
64
+ // --- R3 reflow -------------------------------------------------------------
65
+ //
66
+ // Marks each original hard newline with U+21B5 ↵ so the model can distinguish
67
+ // real newlines from soft-wraps. Lossless at the transform level (see dereflow):
68
+ // the only mutation is the minifyForRender pass. Wrap-the-line measured ~99%
69
+ // OCR fidelity vs ~78% for naive dense packing.
70
+ /** U+21B5 ↵ sentinel for original hard newlines in reflowed text. In full-bmp atlas via Unifont. */
71
+ export const NL_SENTINEL = '↵';
72
+ const NL_SENTINEL_CP = 0x21b5; // precomputed for hot-path comparisons
73
+ /** Look-alike (U+23CE ⏎) for a ↵ that was ALREADY in the source content — distinct from
74
+ * the U+21B5 ↵ we insert for newlines. reflow() bails when its input already contains the
75
+ * sentinel; that's vanishingly rare for normal content but common when the content is about
76
+ * pxpipe itself (rendered dumps, OCR, this very transcript). {@link neutralizeSentinel}
77
+ * swaps pre-existing sentinels for this glyph in RENDER-PREP only, so reflow can pack
78
+ * newlines instead of bailing to a raw, unpacked render. Originals are preserved verbatim
79
+ * elsewhere (recordRecoverable / cache-stable history), and reflow()'s own round-trip
80
+ * contract — and its tests — are left untouched. */
81
+ export const NL_SENTINEL_LITERAL = '⏎'; // U+23CE
82
+ export function neutralizeSentinel(text) {
83
+ return text.indexOf(NL_SENTINEL) >= 0
84
+ ? text.split(NL_SENTINEL).join(NL_SENTINEL_LITERAL)
85
+ : text;
86
+ }
87
+ /** colorCycle palette: four dark ink-on-white hues for per-character boundary cues. */
88
+ const GLYPH_PALETTE = [
89
+ [20, 20, 20], // near-black
90
+ [20, 40, 160], // dark blue
91
+ [150, 20, 20], // dark red
92
+ [20, 110, 40], // dark green
93
+ ];
94
+ /** colorByRole palette, indexed by slot-1. Only the boundary TAGS are tinted;
95
+ * body content stays black. [<user> tags, <assistant> tags]. */
96
+ export const ROLE_PALETTE = [
97
+ [20, 120, 50], // 1: <user> / </user> — green
98
+ [30, 70, 180], // 2: <assistant> / </assistant> — blue
99
+ ];
100
+ const ROLE_SLOT_USER = 1;
101
+ const ROLE_SLOT_ASSISTANT = 2;
102
+ /**
103
+ * Slot markers for the parallel "slot string" — the structure-through mechanism
104
+ * that replaces the old parse-back. A slot string is a width-preserving copy of
105
+ * the rendered text: every structural role-tag character is swapped for one of
106
+ * these control codes, and every other codepoint is copied verbatim. Because the
107
+ * markers are width-1 (exactly like the ASCII tag chars they stand in for), the
108
+ * existing reflow / wrapLines / paging transforms mutate the slot string in lock-
109
+ * step with the text — only whitespace/newlines move, and those are slot 0 in
110
+ * both. The renderer then reads role attribution BY POSITION instead of trying to
111
+ * re-find "<user>" in flattened text (which miscolors a body that literally quotes
112
+ * a tag). The structure is known at serialize time and carried, never guessed.
113
+ */
114
+ export const SLOT_MARK_USER = String.fromCharCode(1);
115
+ export const SLOT_MARK_ASSISTANT = String.fromCharCode(2);
116
+ const SLOT_MARK_RE = new RegExp('[' + SLOT_MARK_USER + SLOT_MARK_ASSISTANT + ']', 'g');
117
+ /** Map a slot-string codepoint to its color slot (0 = body / black ink). */
118
+ function slotForMarkCp(cp) {
119
+ if (cp === 0x0001)
120
+ return ROLE_SLOT_USER;
121
+ if (cp === 0x0002)
122
+ return ROLE_SLOT_ASSISTANT;
123
+ return 0;
124
+ }
125
+ /** Replacement for a literal slot-marker found in body content. Must (a) map to slot 0
126
+ * like body (it's not 0x01/0x02), (b) share the markers' width class so wrapLines splits
127
+ * the slot identically to the text, and (c) NOT be ' '/'\t' — minifyForRender strips
128
+ * trailing spaces/tabs, which would shorten the slot line (where a body \x01 sat) but not
129
+ * the text line (\x01 isn't stripped), desyncing the two and smearing role hues. A C0
130
+ * control char satisfies all three. */
131
+ const SLOT_NEUTRAL = '\u0003'; // U+0003 ETX — non-marker C0 control
132
+ /** Width-preserving slot-0 copy of body text: identical codepoints (so wrap math is
133
+ * unchanged) but with any literal slot-marker control char neutralized. These are rare in
134
+ * real content but DO occur (e.g. binary-ish tool output that gets collapsed into history),
135
+ * so the replacement is width- and strip-equivalent to the marker — never a space, which
136
+ * the minifier would strip and misalign. Guarantees body can't forge a role hue. */
137
+ export function slotCopyBody(body) {
138
+ if (body.indexOf(SLOT_MARK_USER) < 0 && body.indexOf(SLOT_MARK_ASSISTANT) < 0)
139
+ return body;
140
+ return body.replace(SLOT_MARK_RE, SLOT_NEUTRAL);
141
+ }
142
+ /** Build the slot-string segment for one role-wrapped turn, mirroring the text
143
+ * form `<${tag}>\n${body}\n</${tag}>`: marker chars over the open/close tags,
144
+ * a verbatim slot-0 copy of the body. `mark` is the role's slot marker. */
145
+ export function roleSlotSegment(tag, body, mark, attr = '') {
146
+ // `attr` (e.g. ` t="37"`) is part of the OPEN tag, so the marker run must cover it too —
147
+ // the renderer colors by codepoint position, so a short marker run would un-tint the
148
+ // attribute and break alignment. Width is identical to the text tag by construction.
149
+ const open = `<${tag}${attr}>`;
150
+ const close = `</${tag}>`;
151
+ return `${mark.repeat(open.length)}\n${slotCopyBody(body)}\n${mark.repeat(close.length)}`;
152
+ }
153
+ /** Minify + tab-expand + join lines with ↵ sentinel. Returns null if text already
154
+ * contains ↵ (caller falls back to non-reflow path; vanishingly rare in practice). */
155
+ export function reflow(text) {
156
+ if (text.indexOf(NL_SENTINEL) >= 0)
157
+ return null;
158
+ return minifyForRender(text)
159
+ .split('\n')
160
+ .map(expandTabsInLine)
161
+ .join(NL_SENTINEL);
162
+ }
163
+ /** Inverse of reflow: ↵ → '\n'. dereflow(reflow(text)) === minifyForRender(text) with tabs expanded. */
164
+ export function dereflow(reflowed) {
165
+ return reflowed.split(NL_SENTINEL).join('\n');
166
+ }
167
+ /** Expand \t to U+2192 → + padding to the next TAB_WIDTH stop. Visible marker lets the
168
+ * model distinguish indent-spaces from intentional-spaces. Wide CJK chars count as 2 cols.
169
+ * U+0009 is absent from the atlas (control codepoint), so without this every tab was a drop. */
170
+ export function expandTabsInLine(line) {
171
+ if (line.indexOf('\t') < 0)
172
+ return line; // fast path
173
+ let out = '';
174
+ let col = 0;
175
+ for (const ch of line) {
176
+ if (ch === '\t') {
177
+ const span = TAB_WIDTH - (col % TAB_WIDTH);
178
+ out += '→'; // visible tab marker (1 col)
179
+ if (span > 1)
180
+ out += ' '.repeat(span - 1); // pad to next stop
181
+ col += span;
182
+ }
183
+ else {
184
+ out += ch;
185
+ col += cellsFor(ch.codePointAt(0));
186
+ }
187
+ }
188
+ return out;
189
+ }
190
+ /** Visual width of a line in cells. Wide CJK = 2; enlarged ↵ = markerScale. */
191
+ export function measureLineCols(line, markerScale = 1) {
192
+ let w = 0;
193
+ for (const ch of line)
194
+ w += cellsFor(ch.codePointAt(0), markerScale);
195
+ return w;
196
+ }
197
+ /** Always renders at full canvas width. Signature kept for transform.ts compatibility; returns cols unchanged. */
198
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
199
+ export function shrinkColsToContent(text, cols, markerScale = 1) {
200
+ // Real content-width measurement — delegates to measureContentCols (was historically a
201
+ // no-op stub). The proxy's cost gate AND renderer both call this, so sizing the canvas to
202
+ // the widest line here makes the proxy match the SDK/export path (renderTextToImages),
203
+ // which already uses measureContentCols. Never narrows below the widest line ⇒ row count
204
+ // (and thus image/paging count) is unchanged; only the canvas WIDTH (pixel cost) drops.
205
+ return measureContentCols(text, cols, markerScale);
206
+ }
207
+ /**
208
+ * Real content-width measurement (the capability `shrinkColsToContent` historically
209
+ * stubbed out): the display width, in cols, of the widest line in `text`, capped at
210
+ * `maxCols`. Lets a renderer size a narrow canvas to short-line content (e.g. code)
211
+ * instead of padding every page to full width. Pure function of (text, maxCols) →
212
+ * deterministic width → cache-prefix-safe. Tabs are expanded so the measured width
213
+ * matches what the renderer actually lays out.
214
+ */
215
+ export function measureContentCols(text, maxCols, markerScale = 1) {
216
+ const cap = Math.max(1, maxCols | 0);
217
+ let widest = 1;
218
+ let start = 0;
219
+ for (let i = 0; i <= text.length; i++) {
220
+ if (i === text.length || text[i] === '\n') {
221
+ const w = measureLineCols(expandTabsInLine(text.slice(start, i)), markerScale);
222
+ if (w > widest)
223
+ widest = w;
224
+ if (widest >= cap)
225
+ return cap;
226
+ start = i + 1;
227
+ }
228
+ }
229
+ return Math.min(cap, widest);
230
+ }
231
+ export function wrapLines(text, cols, markerScale = 1) {
232
+ const out = [];
233
+ const minified = minifyForRender(text);
234
+ for (const rawWithTabs of minified.split('\n')) {
235
+ const raw = expandTabsInLine(rawWithTabs);
236
+ if (raw.length === 0) {
237
+ out.push('');
238
+ continue;
239
+ }
240
+ let cur = '';
241
+ let curCols = 0;
242
+ // Codepoint iteration handles surrogate pairs as one unit.
243
+ // ↵ is treated as an inline glyph — it never forces a row break.
244
+ for (const ch of raw) {
245
+ const cp = ch.codePointAt(0);
246
+ const w = cellsFor(cp, markerScale);
247
+ if (curCols + w > cols) {
248
+ out.push(cur);
249
+ cur = ch;
250
+ curCols = w;
251
+ }
252
+ else {
253
+ cur += ch;
254
+ curCols += w;
255
+ }
256
+ }
257
+ if (cur.length > 0)
258
+ out.push(cur);
259
+ }
260
+ return out;
261
+ }
262
+ function splitWrappedLinesIntoReadablePages(lines, maxLines, maxChars = READABLE_CHARS_PER_IMAGE) {
263
+ const pages = [];
264
+ let cur = [];
265
+ let curChars = 0;
266
+ const lineLimit = Math.max(1, maxLines | 0);
267
+ const charLimit = Math.max(1, maxChars | 0);
268
+ for (const line of lines) {
269
+ const lineChars = line.length + (cur.length > 0 ? 1 : 0);
270
+ if (cur.length > 0 &&
271
+ (cur.length >= lineLimit || curChars + lineChars > charLimit)) {
272
+ pages.push(cur);
273
+ cur = [];
274
+ curChars = 0;
275
+ }
276
+ cur.push(line);
277
+ curChars += line.length + (cur.length > 1 ? 1 : 0);
278
+ }
279
+ if (cur.length > 0)
280
+ pages.push(cur);
281
+ return pages.length > 0 ? pages : [[]];
282
+ }
283
+ function readableLinesPerColumn(cols) {
284
+ return Math.max(1, Math.floor(READABLE_CHARS_PER_IMAGE / Math.max(1, cols)));
285
+ }
286
+ /**
287
+ * Blit a 1-bit glyph at pixel (x, y). Returns cells advanced (1 or 2), or 0 if absent
288
+ * from atlas — caller must still advance 1 cell to keep wrap math stable.
289
+ */
290
+ function blitGlyph(fb, fbW, x, y, codepoint, markerMask = null) {
291
+ const rank = atlasRank(codepoint);
292
+ if (rank < 0)
293
+ return 0;
294
+ const wide = ATLAS_WIDE_FLAGS[rank] === 1;
295
+ const srcW = wide ? 2 * ATLAS_CELL_W : ATLAS_CELL_W;
296
+ // ATLAS_OFFSETS is a bit offset (MSB-first packing). Pixel (gx,gy): byte = bitIdx>>>3, bit = 7-(bitIdx&7).
297
+ const srcOff = ATLAS_OFFSETS[rank];
298
+ for (let gy = 0; gy < ATLAS_CELL_H; gy++) {
299
+ const dstRow = (y + gy) * fbW + x;
300
+ const bitRowStart = srcOff + gy * srcW;
301
+ for (let gx = 0; gx < srcW; gx++) {
302
+ const bitIdx = bitRowStart + gx;
303
+ const byte = ATLAS_PIXELS[bitIdx >>> 3];
304
+ const bit = (byte >>> (7 - (bitIdx & 7))) & 1;
305
+ if (bit) {
306
+ fb[dstRow + gx] = 255; // glyphs never overlap in grid layout; set unconditionally
307
+ if (markerMask)
308
+ markerMask[dstRow + gx] = 1;
309
+ }
310
+ }
311
+ }
312
+ return wide ? 2 : 1;
313
+ }
314
+ /**
315
+ * Blit a grayscale atlas glyph at pixel (x, y) using max-blending. EVAL-ONLY (style.aa).
316
+ * Returns cells advanced (1 or 2), or 0 if absent from the gray atlas.
317
+ */
318
+ function blitGlyphGray(fb, fbW, x, y, codepoint) {
319
+ const rank = atlasGrayRank(codepoint);
320
+ if (rank < 0)
321
+ return 0;
322
+ const wide = ATLAS_GRAY_WIDE_FLAGS[rank] === 1;
323
+ const srcW = wide ? 2 * ATLAS_GRAY_CELL_W : ATLAS_GRAY_CELL_W;
324
+ // ATLAS_GRAY_OFFSETS is a byte offset (1 byte/pixel, unlike the bit-packed 1-bit atlas).
325
+ const srcOff = ATLAS_GRAY_OFFSETS[rank];
326
+ for (let gy = 0; gy < ATLAS_GRAY_CELL_H; gy++) {
327
+ const dstRow = (y + gy) * fbW + x;
328
+ const srcRow = srcOff + gy * srcW;
329
+ for (let gx = 0; gx < srcW; gx++) {
330
+ const coverage = ATLAS_GRAY_PIXELS[srcRow + gx];
331
+ if (coverage > 0) {
332
+ const idx = dstRow + gx;
333
+ if (coverage > fb[idx])
334
+ fb[idx] = coverage;
335
+ }
336
+ }
337
+ }
338
+ return wide ? 2 : 1;
339
+ }
340
+ /** Blit a glyph scaled horizontally by scaleX (height unchanged). Used for enlarged ↵:
341
+ * horizontal-only avoids corrupting the row below. Returns cells advanced (scaleX or 2*scaleX). */
342
+ function blitGlyphScaled(fb, markerMask, fbW, fbH, x, y, codepoint, scaleX) {
343
+ const rank = atlasRank(codepoint);
344
+ if (rank < 0)
345
+ return 0;
346
+ const wide = ATLAS_WIDE_FLAGS[rank] === 1;
347
+ const srcW = wide ? 2 * ATLAS_CELL_W : ATLAS_CELL_W;
348
+ const srcOff = ATLAS_OFFSETS[rank];
349
+ for (let gy = 0; gy < ATLAS_CELL_H; gy++) {
350
+ const py = y + gy;
351
+ if (py >= fbH)
352
+ break;
353
+ const bitRowStart = srcOff + gy * srcW;
354
+ for (let gx = 0; gx < srcW; gx++) {
355
+ const bitIdx = bitRowStart + gx;
356
+ const byte = ATLAS_PIXELS[bitIdx >>> 3];
357
+ if (((byte >>> (7 - (bitIdx & 7))) & 1) === 0)
358
+ continue;
359
+ for (let sx = 0; sx < scaleX; sx++) {
360
+ const px = x + gx * scaleX + sx;
361
+ if (px >= fbW)
362
+ break;
363
+ const idx = py * fbW + px;
364
+ fb[idx] = 255;
365
+ if (markerMask)
366
+ markerMask[idx] = 1;
367
+ }
368
+ }
369
+ }
370
+ return wide ? 2 * scaleX : scaleX;
371
+ }
372
+ const GRID_INK = 25; // pre-invert → 230 post-invert; distinct from gutter divider (64 → 191)
373
+ /** Draw faint grid rules onto background pixels only (glyph ink wins). Zero pixel-cost to image size. */
374
+ function drawGrid(fb, fbW, fbH, rows, gridCols, cellH, cellW, glyphH = ATLAS_CELL_H) {
375
+ for (let row = 0; row < rows; row++) {
376
+ const y = PAD_Y + row * cellH + (glyphH - 1);
377
+ if (y >= fbH)
378
+ break;
379
+ const rowStart = y * fbW;
380
+ for (let x = 0; x < fbW; x++) {
381
+ if (fb[rowStart + x] === 0)
382
+ fb[rowStart + x] = GRID_INK;
383
+ }
384
+ }
385
+ if (gridCols > 0) {
386
+ for (let col = gridCols;; col += gridCols) {
387
+ const x = PAD_X + col * cellW;
388
+ if (x >= fbW - PAD_X)
389
+ break;
390
+ for (let y = 0; y < fbH; y++) {
391
+ const idx = y * fbW + x;
392
+ if (fb[idx] === 0)
393
+ fb[idx] = GRID_INK;
394
+ }
395
+ }
396
+ }
397
+ }
398
+ /** Render text to a single PNG (≤ MAX_HEIGHT_PX tall). Wide glyphs occupy 2 consecutive cells. */
399
+ export async function renderChunkToPng(text, cols = DEFAULT_COLS, style = {}, maxHeightPx = MAX_HEIGHT_PX, slotText) {
400
+ const useAA = style.aa === true;
401
+ const atlasH = useAA ? ATLAS_GRAY_CELL_H : ATLAS_CELL_H;
402
+ const atlasW = useAA ? ATLAS_GRAY_CELL_W : ATLAS_CELL_W;
403
+ const markerScale = Math.max(1, Math.floor(style.markerScale ?? 1));
404
+ const cellH = atlasH + Math.max(0, Math.floor(style.cellHBonus ?? DEFAULT_CELL_H_BONUS));
405
+ const cellW = Math.max(1, atlasW + Math.floor(style.cellWBonus ?? DEFAULT_CELL_W_BONUS));
406
+ const lines = wrapLines(text, cols, markerScale);
407
+ // Slot string carries role attribution by position. It is width-identical to
408
+ // `text`, so wrapLines splits it into the exact same rows — fitSlotLines[r] aligns
409
+ // codepoint-for-codepoint with fitLines[r]. Only built when coloring is on.
410
+ const slotLines = style.colorByRole === true && slotText !== undefined
411
+ ? wrapLines(slotText, cols, markerScale)
412
+ : null;
413
+ const maxLines = Math.max(1, Math.floor((maxHeightPx - 2 * PAD_Y) / cellH));
414
+ const fitLines = lines.slice(0, maxLines);
415
+ const fitSlotLines = slotLines ? slotLines.slice(0, maxLines) : null;
416
+ // charsRendered = input codepoints covered by this image (for..of counts by codepoint, not code unit).
417
+ let charsRendered;
418
+ if (fitLines.length === lines.length) {
419
+ let n = 0;
420
+ for (const _ of text)
421
+ n++;
422
+ charsRendered = n;
423
+ }
424
+ else {
425
+ // Partial: sum kept-line codepoints + 1 separator per adjacent pair.
426
+ let n = 0;
427
+ for (let i = 0; i < fitLines.length; i++) {
428
+ for (const _ of fitLines[i])
429
+ n++;
430
+ }
431
+ n += Math.max(0, fitLines.length - 1);
432
+ charsRendered = n;
433
+ }
434
+ // Widen by overhang when cellW < atlasW so the last glyph stays inside the framebuffer.
435
+ const width = 2 * PAD_X + cols * cellW + Math.max(0, atlasW - cellW);
436
+ const height = 2 * PAD_Y + fitLines.length * cellH;
437
+ // Black canvas — inverted to black-on-white after blitting (matches Python proxy convention).
438
+ const fb = new Uint8Array(width * height);
439
+ // markerMask: 1 where ↵ glyph was inked; used to recolor those pixels red.
440
+ const markerMask = style.markerRed ? new Uint8Array(width * height) : null;
441
+ // colorMask: stores colorSlot per inked pixel (0 = background) for colorCycle / colorByRole RGB output.
442
+ const useColorCycle = style.colorCycle === true;
443
+ const useColorByRole = style.colorByRole === true;
444
+ const colorMask = (useColorCycle || useColorByRole) ? new Uint8Array(width * height) : null;
445
+ let droppedChars = 0;
446
+ const droppedCodepoints = new Map();
447
+ let glyphIndex = 0; // every cell including spaces/missing
448
+ for (let row = 0; row < fitLines.length; row++) {
449
+ const line = fitLines[row];
450
+ const baseY = PAD_Y + row * cellH;
451
+ let col = 0;
452
+ // Per-row slot codepoints, aligned 1:1 with `line` (same wrap, width-preserved).
453
+ const slotRow = fitSlotLines ? Array.from(fitSlotLines[row] ?? '') : null;
454
+ let charIdx = 0;
455
+ for (const ch of line) {
456
+ if (col >= cols)
457
+ break; // shouldn't happen — wrap prevents this
458
+ const codepoint = ch.codePointAt(0);
459
+ const baseX = PAD_X + col * cellW;
460
+ const isMarker = codepoint === NL_SENTINEL_CP;
461
+ const colorSlot = useColorByRole
462
+ ? (slotRow ? slotForMarkCp(slotRow[charIdx]?.codePointAt(0)) : 0) // 0 = body (black); only tags carry a role hue
463
+ : (glyphIndex % GLYPH_PALETTE.length) + 1; // 0 reserved for background in colorMask
464
+ let advance;
465
+ if (isMarker && markerScale > 1) {
466
+ advance = blitGlyphScaled(fb, markerMask, width, height, baseX, baseY, codepoint, markerScale);
467
+ if (colorMask) {
468
+ for (let gy = 0; gy < atlasH; gy++) {
469
+ const py = baseY + gy;
470
+ if (py >= height)
471
+ break;
472
+ for (let gx = 0; gx < advance * cellW; gx++) {
473
+ const px = baseX + gx;
474
+ if (px >= width)
475
+ break;
476
+ const idx = py * width + px;
477
+ if (fb[idx] > 0)
478
+ colorMask[idx] = colorSlot;
479
+ }
480
+ }
481
+ }
482
+ }
483
+ else if (useAA) {
484
+ advance = blitGlyphGray(fb, width, baseX, baseY, codepoint);
485
+ if (colorMask && advance > 0) {
486
+ const srcW = advance * atlasW;
487
+ for (let gy = 0; gy < atlasH; gy++) {
488
+ const py = baseY + gy;
489
+ if (py >= height)
490
+ break;
491
+ for (let gx = 0; gx < srcW; gx++) {
492
+ const px = baseX + gx;
493
+ if (px >= width)
494
+ break;
495
+ const idx = py * width + px;
496
+ if (fb[idx] > 0)
497
+ colorMask[idx] = colorSlot;
498
+ }
499
+ }
500
+ }
501
+ }
502
+ else {
503
+ advance = blitGlyph(fb, width, baseX, baseY, codepoint, isMarker ? markerMask : null);
504
+ if (colorMask && advance > 0) {
505
+ const srcW = advance * atlasW;
506
+ for (let gy = 0; gy < atlasH; gy++) {
507
+ const py = baseY + gy;
508
+ if (py >= height)
509
+ break;
510
+ for (let gx = 0; gx < srcW; gx++) {
511
+ const px = baseX + gx;
512
+ if (px >= width)
513
+ break;
514
+ const idx = py * width + px;
515
+ if (fb[idx] > 0)
516
+ colorMask[idx] = colorSlot;
517
+ }
518
+ }
519
+ }
520
+ }
521
+ glyphIndex++;
522
+ charIdx++;
523
+ if (advance === 0) {
524
+ droppedChars++;
525
+ droppedCodepoints.set(codepoint, (droppedCodepoints.get(codepoint) ?? 0) + 1);
526
+ col += 1; // missing glyph still occupies 1 cell for wrap stability
527
+ }
528
+ else {
529
+ col += advance;
530
+ }
531
+ }
532
+ }
533
+ if (style.grid) {
534
+ drawGrid(fb, width, height, fitLines.length, Math.max(0, Math.floor(style.gridCols ?? 0)), cellH, cellW, atlasH);
535
+ }
536
+ // Invert to black-on-white (matches Python proxy).
537
+ for (let i = 0; i < fb.length; i++)
538
+ fb[i] = 255 - fb[i];
539
+ let png;
540
+ if (colorMask) {
541
+ // colorCycle / colorByRole: AA-blend each inked pixel onto white in its palette color. markerRed ignored.
542
+ const palette = useColorByRole ? ROLE_PALETTE : GLYPH_PALETTE;
543
+ const rgb = new Uint8Array(width * height * 3);
544
+ for (let i = 0; i < fb.length; i++) {
545
+ const g = fb[i]; // post-invert: 0 = ink, 255 = background
546
+ const slot = colorMask[i];
547
+ if (slot > 0) {
548
+ const coverage = 255 - g; // pre-invert coverage
549
+ const [pr, pg, pb] = palette[(slot - 1) % palette.length];
550
+ // Alpha-blend: channel = 255 - coverage*(255-palette)/255
551
+ rgb[i * 3] = Math.round(255 - coverage * (255 - pr) / 255);
552
+ rgb[i * 3 + 1] = Math.round(255 - coverage * (255 - pg) / 255);
553
+ rgb[i * 3 + 2] = Math.round(255 - coverage * (255 - pb) / 255);
554
+ }
555
+ else {
556
+ rgb[i * 3] = g;
557
+ rgb[i * 3 + 1] = g;
558
+ rgb[i * 3 + 2] = g;
559
+ }
560
+ }
561
+ png = await encodeRgbPng(rgb, width, height);
562
+ }
563
+ else if (markerMask) {
564
+ // markerRed: ↵ pixels → red, everything else stays greyscale.
565
+ const rgb = new Uint8Array(width * height * 3);
566
+ for (let i = 0; i < fb.length; i++) {
567
+ const g = fb[i];
568
+ if (markerMask[i] === 1 && g < 128) {
569
+ rgb[i * 3] = 220; // R
570
+ rgb[i * 3 + 1] = 0; // G
571
+ rgb[i * 3 + 2] = 0; // B
572
+ }
573
+ else {
574
+ rgb[i * 3] = g;
575
+ rgb[i * 3 + 1] = g;
576
+ rgb[i * 3 + 2] = g;
577
+ }
578
+ }
579
+ png = await encodeRgbPng(rgb, width, height);
580
+ }
581
+ else {
582
+ png = await encodeGrayPng(fb, width, height);
583
+ }
584
+ return { png, width, height, charsRendered, droppedChars, droppedCodepoints };
585
+ }
586
+ /** Reflow-aware variant of renderTextToPngs. Falls back to non-reflow on sentinel collision. */
587
+ export async function renderTextToPngsReflow(text, cols = DEFAULT_COLS, style = {}) {
588
+ const packed = reflow(text);
589
+ return renderTextToPngs(packed ?? text, cols, style);
590
+ }
591
+ /** Split text into N PNGs each ≤ MAX_HEIGHT_PX tall, respecting per-image char budget. */
592
+ export async function renderTextToPngsWithCharLimit(text, cols = DEFAULT_COLS, maxCharsPerImage = READABLE_CHARS_PER_IMAGE, style = {}, maxHeightPx = MAX_HEIGHT_PX, slotText) {
593
+ const markerScale = Math.max(1, Math.floor(style.markerScale ?? 1));
594
+ const cellH = ATLAS_CELL_H + Math.max(0, Math.floor(style.cellHBonus ?? DEFAULT_CELL_H_BONUS));
595
+ const lines = wrapLines(text, cols, markerScale);
596
+ // Width-identical slot rows align 1:1 with `lines`; pages are contiguous slices,
597
+ // so the same index range gives each page its slot half. Only built when coloring.
598
+ const slotLines = style.colorByRole === true && slotText !== undefined
599
+ ? wrapLines(slotText, cols, markerScale)
600
+ : null;
601
+ const hardLinesPerImg = Math.max(1, Math.floor((maxHeightPx - 2 * PAD_Y) / cellH));
602
+ // Dense pages (DENSE_CONTENT_CHARS_PER_IMAGE) fill the full 1932 px height;
603
+ // the slab budget (READABLE_CHARS_PER_IMAGE) keeps its shorter row cap.
604
+ const linesPerImg = Math.min(hardLinesPerImg, Math.max(1, Math.floor(maxCharsPerImage / cols)));
605
+ const images = [];
606
+ let slotCursor = 0;
607
+ for (const page of splitWrappedLinesIntoReadablePages(lines, linesPerImg, maxCharsPerImage)) {
608
+ const chunk = page.join('\n');
609
+ const slotChunk = slotLines
610
+ ? slotLines.slice(slotCursor, slotCursor + page.length).join('\n')
611
+ : undefined;
612
+ slotCursor += page.length;
613
+ images.push(await renderChunkToPng(chunk, cols, style, maxHeightPx, slotChunk));
614
+ }
615
+ return images;
616
+ }
617
+ export async function renderTextToPngs(text, cols = DEFAULT_COLS, style = {}, maxHeightPx = MAX_HEIGHT_PX, slotText) {
618
+ return renderTextToPngsWithCharLimit(text, cols, READABLE_CHARS_PER_IMAGE, style, maxHeightPx, slotText);
619
+ }
620
+ // --- R2 multi-column rendering --------------------------------------------
621
+ //
622
+ // Packs N columns side-by-side (column-major) so one image covers numCols×linesPerImg
623
+ // wrapped lines. Reduces image count by ~numCols for short-line content.
624
+ // OCR column ordering is the risk — gated behind an opt-in flag pending empirical eval.
625
+ const GUTTER_CELLS = 4;
626
+ // Width hard-capped at the API's long-edge bound: anything wider is resampled server-side
627
+ // (measured 2026-07-01: fit-within 1568-edge AND ~1.15 MP, then ≈px/750 billing).
628
+ const MAX_WIDTH_PX = 1568;
629
+ const GUTTER_DIVIDER_INK = 64; // pre-invert → 191 post-invert: light gray column separator
630
+ const GUTTER_DIVIDER_INSET_PX = 2; // keep divider clear of padding rows
631
+ /** Pixel width of a multi-col canvas. */
632
+ export function multiColWidth(cols, numCols) {
633
+ const n = Math.max(1, numCols | 0);
634
+ return 2 * PAD_X + n * cols * CELL_W + (n - 1) * GUTTER_CELLS * CELL_W;
635
+ }
636
+ /** Largest numCols fitting within MAX_WIDTH_PX. Used to clamp over-large CLI flags. */
637
+ export function maxFittingCols(cols) {
638
+ let n = 1;
639
+ while (multiColWidth(cols, n + 1) <= MAX_WIDTH_PX)
640
+ n++;
641
+ return n;
642
+ }
643
+ async function renderMultiColChunkFromLines(lines, cols, numCols, charsCovered, linesPerCol) {
644
+ const width = multiColWidth(cols, numCols);
645
+ // Column 0 is always the tallest in column-major packing.
646
+ const rowsPerCol = Math.max(1, linesPerCol | 0);
647
+ const usedRows = Math.min(lines.length, rowsPerCol);
648
+ const height = 2 * PAD_Y + usedRows * CELL_H;
649
+ const fb = new Uint8Array(width * height);
650
+ let droppedChars = 0;
651
+ const droppedCodepoints = new Map();
652
+ const colStride = cols * CELL_W + GUTTER_CELLS * CELL_W; // pixel stride per column including gutter
653
+ for (let c = 0; c < numCols; c++) {
654
+ const colBaseX = PAD_X + c * colStride;
655
+ const colStart = c * rowsPerCol;
656
+ if (colStart >= lines.length)
657
+ break;
658
+ const colEnd = Math.min(colStart + rowsPerCol, lines.length);
659
+ for (let r = 0; r < colEnd - colStart; r++) {
660
+ const line = lines[colStart + r];
661
+ const baseY = PAD_Y + r * CELL_H;
662
+ let col = 0;
663
+ for (const ch of line) {
664
+ if (col >= cols)
665
+ break;
666
+ const codepoint = ch.codePointAt(0);
667
+ const baseX = colBaseX + col * CELL_W;
668
+ const advance = blitGlyph(fb, width, baseX, baseY, codepoint);
669
+ if (advance === 0) {
670
+ droppedChars++;
671
+ droppedCodepoints.set(codepoint, (droppedCodepoints.get(codepoint) ?? 0) + 1);
672
+ col += 1;
673
+ }
674
+ else {
675
+ col += advance;
676
+ }
677
+ }
678
+ }
679
+ }
680
+ // Draw faint vertical divider in each gutter before the invert pass (DEFLATE cost ≈ 3-5 bytes).
681
+ if (numCols >= 2) {
682
+ const gutterPxPerSide = GUTTER_CELLS * CELL_W;
683
+ const yStart = GUTTER_DIVIDER_INSET_PX;
684
+ const yEnd = height - GUTTER_DIVIDER_INSET_PX;
685
+ for (let c = 0; c < numCols - 1; c++) {
686
+ const colEndX = PAD_X + c * colStride + cols * CELL_W;
687
+ const dividerX = colEndX + Math.floor(gutterPxPerSide / 2);
688
+ for (let y = yStart; y < yEnd; y++) {
689
+ const idx = y * width + dividerX;
690
+ if (fb[idx] === 0)
691
+ fb[idx] = GUTTER_DIVIDER_INK; // background pixels only (defensive)
692
+ }
693
+ }
694
+ }
695
+ for (let i = 0; i < fb.length; i++)
696
+ fb[i] = 255 - fb[i]; // invert to black-on-white;
697
+ const png = await encodeGrayPng(fb, width, height);
698
+ return {
699
+ png,
700
+ width,
701
+ height,
702
+ charsRendered: charsCovered,
703
+ droppedChars,
704
+ droppedCodepoints,
705
+ };
706
+ }
707
+ /** Split text into N multi-column PNGs. numCols <= 1 delegates to renderTextToPngs
708
+ * for byte-identical output (determinism/cache_control preserved when flag is off). */
709
+ export async function renderTextToPngsMultiCol(text, cols = DEFAULT_COLS, numCols = 2) {
710
+ if (numCols <= 1)
711
+ return renderTextToPngs(text, cols);
712
+ if (multiColWidth(cols, numCols) > MAX_WIDTH_PX) {
713
+ // Clamp to widest fitting count rather than throw (bad CLI flag recovery).
714
+ numCols = maxFittingCols(cols);
715
+ if (numCols <= 1)
716
+ return renderTextToPngs(text, cols);
717
+ }
718
+ const lines = wrapLines(text, cols);
719
+ const hardLinesPerImg = Math.max(1, Math.floor((MAX_HEIGHT_PX - 2 * PAD_Y) / CELL_H));
720
+ const linesPerImg = Math.min(hardLinesPerImg, readableLinesPerColumn(cols));
721
+ const linesPerImage = linesPerImg * numCols;
722
+ // Total source codepoints; assigned to the last image to ensure counts sum exactly.
723
+ let totalChars = 0;
724
+ for (const _ of text)
725
+ totalChars++;
726
+ const images = [];
727
+ let coveredChars = 0;
728
+ const pages = splitWrappedLinesIntoReadablePages(lines, linesPerImage, READABLE_CHARS_PER_IMAGE * Math.max(1, numCols | 0));
729
+ for (let i = 0; i < pages.length; i++) {
730
+ const slice = pages[i];
731
+ const isLast = i === pages.length - 1;
732
+ let chars;
733
+ if (isLast) {
734
+ chars = Math.max(0, totalChars - coveredChars);
735
+ }
736
+ else {
737
+ let n = 0;
738
+ for (const ln of slice)
739
+ for (const _ of ln)
740
+ n++;
741
+ n += Math.max(0, slice.length - 1);
742
+ chars = n;
743
+ }
744
+ coveredChars += chars;
745
+ images.push(await renderMultiColChunkFromLines(slice, cols, numCols, chars, linesPerImg));
746
+ }
747
+ return images;
748
+ }
749
+ /** Reflow-aware variant of renderTextToPngsMultiCol. Falls back to non-reflow on sentinel collision. */
750
+ export async function renderTextToPngsReflowMultiCol(text, cols = DEFAULT_COLS, numCols = 2) {
751
+ const packed = reflow(text);
752
+ return renderTextToPngsMultiCol(packed ?? text, cols, numCols);
753
+ }
754
+ /**
755
+ * The single dense-page rendering decision shared by the public SDK primitive
756
+ * `renderTextToImages` (library.ts → `pxpipe export`) AND the proxy's `textToImageBlocks`
757
+ * (transform.ts): optionally reflow, measure the content width, pack as many side-by-side
758
+ * columns as actually fit the width cap (or honor an explicit count, collapsing the wasted
759
+ * divider column when the canvas shrinks), then render. Both callers route through HERE so
760
+ * export PNGs and proxy image blocks are produced by the exact same code and cannot drift —
761
+ * `shrinkColsToContent` is `measureContentCols`, so the proxy's old inline path was already
762
+ * identical at the default 384 cols; this makes it identical at every cols. Returns the raw
763
+ * rendered pages; each caller packages them (PNG files vs. base64 Anthropic ImageBlocks).
764
+ *
765
+ * History (history.ts) deliberately does NOT use this: it reflows a parallel role-slot string
766
+ * in lockstep with the text and renders with `colorByRole`, which code export has no concept
767
+ * of — wiring slots through this public surface would bloat it for one internal caller. It
768
+ * still shares the underlying `reflow()` + `renderTextToPngsWithCharLimit` primitives.
769
+ */
770
+ export async function renderDensePages(text, opts = {}) {
771
+ const source = opts.reflow ? reflow(text) ?? text : text;
772
+ const maxCols = Math.max(1, (opts.cols ?? DENSE_CONTENT_COLS) | 0);
773
+ const cols = opts.shrink === false ? maxCols : measureContentCols(source, maxCols);
774
+ const requestedCols = opts.multiCol === undefined || opts.multiCol === 'auto'
775
+ ? Math.max(1, maxFittingCols(cols))
776
+ : Math.max(1, opts.multiCol | 0);
777
+ const numCols = cols < maxCols ? 1 : requestedCols;
778
+ const style = opts.style ?? DENSE_RENDER_STYLE;
779
+ const maxChars = opts.maxCharsPerImage ?? DENSE_CONTENT_CHARS_PER_IMAGE;
780
+ const maxHeightPx = opts.maxHeightPx ?? MAX_HEIGHT_PX;
781
+ return numCols > 1
782
+ ? renderTextToPngsMultiCol(source, cols, numCols)
783
+ : renderTextToPngsWithCharLimit(source, cols, maxChars, style, maxHeightPx);
784
+ }
785
+ //# sourceMappingURL=render.js.map