@bli-cockpit/cli 0.2.271 → 0.2.273

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,287 @@
1
+ /**
2
+ * One PDF page's text items to readable text, tables kept (BLI-23347).
3
+ *
4
+ * Pure: positioned text items in, a string out. No pdf library, no I/O, so the heuristics are tested on
5
+ * hand-made items and measured on real PDFs without a parser in the way.
6
+ *
7
+ * What it does, in order:
8
+ * 1. Items are grouped into ROWS by their baseline (y), left to right inside a row.
9
+ * 2. A page of two justified text columns (a paper) is split at its gutter, so the left column is read
10
+ * before the right one; rows that cross the gutter (title, abstract, figure captions) stay whole.
11
+ * 3. Inside a row, items that sit close together join into one CELL; a gap of about a em starts the next.
12
+ * 4. Consecutive rows with two or more cells whose cells line up in the same vertical channels become a
13
+ * Markdown table (first row is the header). Rows that do not line up stay plain lines, cells apart by
14
+ * two spaces, so nothing is invented and nothing is dropped.
15
+ *
16
+ * Measured against `pdftotext -layout` on public PDFs: docs/architecture/pdf-transcripts.md.
17
+ */
18
+ /** A gap wider than this many font sizes starts a new cell. */
19
+ const CELL_GAP_EM = 0.5;
20
+ /** A gap wider than this many font sizes between two items still needs a space when joining them. */
21
+ const WORD_GAP_EM = 0.15;
22
+ /** A vertical jump of more than this many font sizes between rows is a paragraph break (or the end of a table). */
23
+ const BREAK_EM = 1.9;
24
+ /** Rows of one table sit at most this many font sizes apart (booktabs rules add space around the header). */
25
+ const TABLE_ROW_EM = 3;
26
+ function median(values) {
27
+ const sorted = [...values].sort((a, b) => a - b);
28
+ return sorted.length === 0 ? 0 : sorted[Math.floor(sorted.length / 2)];
29
+ }
30
+ function toRows(items) {
31
+ const live = items.filter((item) => item.str.trim() !== "").sort((a, b) => b.y - a.y || a.x - b.x);
32
+ const rows = [];
33
+ for (const item of live) {
34
+ const last = rows[rows.length - 1];
35
+ if (last && Math.abs(last.y - item.y) <= Math.max(1.5, 0.5 * Math.min(last.size, item.size || last.size))) {
36
+ last.items.push(item);
37
+ last.size = Math.max(last.size, item.size);
38
+ }
39
+ else {
40
+ rows.push({ y: item.y, size: item.size || 10, items: [item], cells: [] });
41
+ }
42
+ }
43
+ const merged = attachScripts(rows);
44
+ for (const row of merged) {
45
+ row.items.sort((a, b) => a.x - b.x);
46
+ row.cells = cellsOf(row);
47
+ }
48
+ return merged;
49
+ }
50
+ /**
51
+ * A row of one or two tiny, smaller items right above or below a normal row is a superscript or subscript
52
+ * (`n·d²`), not a line of its own: it joins that row as `^2` or `_i`. Without this a table whose cells hold
53
+ * exponents is cut in pieces.
54
+ */
55
+ function attachScripts(rows) {
56
+ const out = [];
57
+ for (let at = 0; at < rows.length; at += 1) {
58
+ const row = rows[at];
59
+ const chars = row.items.reduce((sum, item) => sum + item.str.trim().length, 0);
60
+ const near = [rows[at - 1], rows[at + 1]]
61
+ .filter((other) => other !== undefined && row.size <= 0.85 * other.size && Math.abs(other.y - row.y) <= 0.85 * other.size)
62
+ .sort((a, b) => Math.abs(a.y - row.y) - Math.abs(b.y - row.y))[0];
63
+ if (!near || chars > 6 || row.items.length > 2) {
64
+ out.push(row);
65
+ continue;
66
+ }
67
+ for (const item of row.items)
68
+ near.items.push({ ...item, str: `${item.y > near.y ? "^" : "_"}${item.str.trim()}` });
69
+ }
70
+ return out.filter((row, index) => out.indexOf(row) === index);
71
+ }
72
+ function cellsOf(row) {
73
+ const cells = [];
74
+ let end = Number.NEGATIVE_INFINITY;
75
+ for (const item of row.items) {
76
+ const gap = item.x - end;
77
+ const last = cells[cells.length - 1];
78
+ const text = item.str.replace(/\s+/g, " ");
79
+ if (!last || gap > CELL_GAP_EM * row.size) {
80
+ cells.push({ text: text.trim(), x0: item.x, x1: item.x + item.width });
81
+ }
82
+ else {
83
+ const script = text.startsWith("^") || text.startsWith("_");
84
+ const spaced = !script && gap > WORD_GAP_EM * row.size && !last.text.endsWith(" ") && !text.startsWith(" ");
85
+ last.text = (last.text + (spaced ? " " : "") + text).replace(/\s+/g, " ");
86
+ last.x1 = item.x + item.width;
87
+ }
88
+ end = item.x + item.width;
89
+ }
90
+ for (const cell of cells)
91
+ cell.text = cell.text.replace(/\s*(?:\.\s?){4,}\s*/g, " … ").trim();
92
+ return cells.filter((cell) => cell.text !== "");
93
+ }
94
+ function percentile(values, fraction) {
95
+ const sorted = [...values].sort((a, b) => a - b);
96
+ return sorted.length === 0 ? 0 : sorted[Math.min(sorted.length - 1, Math.floor(fraction * sorted.length))];
97
+ }
98
+ const spans = (item, x) => item.x < x - 2 && item.x + item.width > x + 2;
99
+ /**
100
+ * The x of a gutter that splits two justified text columns, or null. Rows that cross it (title, abstract,
101
+ * a wide figure or table) stay whole; the caller keeps them. Decided on items, not rows: the two columns'
102
+ * baselines rarely line up, so a "row" holds one side of the page at a time.
103
+ */
104
+ export function findGutter(rows) {
105
+ const items = rows.flatMap((row) => row.items);
106
+ if (items.length < 40)
107
+ return null;
108
+ const left = percentile(items.map((item) => item.x), 0.05);
109
+ const right = percentile(items.map((item) => item.x + item.width), 0.95);
110
+ const width = right - left;
111
+ if (width < 200)
112
+ return null;
113
+ const crossing = (x) => items.filter((item) => spans(item, x)).length;
114
+ let best = left + 0.4 * width;
115
+ for (let x = best; x <= left + 0.6 * width; x += 2)
116
+ if (crossing(x) < crossing(best))
117
+ best = x;
118
+ const floor = crossing(best);
119
+ if (floor > 0.04 * items.length)
120
+ return null;
121
+ let lo = best;
122
+ let hi = best;
123
+ while (crossing(lo - 1) <= floor && lo > left + 0.3 * width)
124
+ lo -= 1;
125
+ while (crossing(hi + 1) <= floor && hi < left + 0.7 * width)
126
+ hi += 1;
127
+ if (hi - lo < 6)
128
+ return null;
129
+ const gutter = (lo + hi) / 2;
130
+ // Both sides carry text, and the lines of each side run most of the way across their column (justified text).
131
+ const leftRows = rows.map((row) => row.items.filter((item) => item.x + item.width <= gutter)).filter((side) => side.length > 0);
132
+ const rightRows = rows.map((row) => row.items.filter((item) => item.x >= gutter)).filter((side) => side.length > 0);
133
+ if (leftRows.length < 8 || rightRows.length < 8)
134
+ return null;
135
+ const leftReach = median(leftRows.map((side) => Math.max(...side.map((item) => item.x + item.width)) - left));
136
+ const rightReach = median(rightRows.map((side) => Math.max(...side.map((item) => item.x + item.width)) - gutter));
137
+ if (leftReach < 0.8 * (lo - left) || rightReach < 0.8 * (right - hi))
138
+ return null;
139
+ return gutter;
140
+ }
141
+ function crosses(row, gutter) {
142
+ return row.items.some((item) => spans(item, gutter));
143
+ }
144
+ /** A section of rows read in order: whole rows, or left column then right column. */
145
+ function sections(rows, gutter) {
146
+ if (gutter === null)
147
+ return [rows];
148
+ const out = [];
149
+ let run = [];
150
+ const flush = () => {
151
+ if (run.length === 0)
152
+ return;
153
+ const leftItems = run.flatMap((row) => row.items.filter((item) => item.x + item.width / 2 < gutter));
154
+ const rightItems = run.flatMap((row) => row.items.filter((item) => item.x + item.width / 2 >= gutter));
155
+ out.push(toRows(leftItems), toRows(rightItems));
156
+ run = [];
157
+ };
158
+ for (const row of rows) {
159
+ if (crosses(row, gutter)) {
160
+ flush();
161
+ out.push([row]);
162
+ }
163
+ else {
164
+ run.push(row);
165
+ }
166
+ }
167
+ flush();
168
+ return out.filter((section) => section.length > 0);
169
+ }
170
+ /** Column channels of a block of columnar rows: the x ranges where cells of the rows with the most cells sit. */
171
+ function columnsOf(block) {
172
+ const most = median(block.map((row) => row.cells.length));
173
+ const spans = block
174
+ .filter((row) => row.cells.length >= Math.max(2, most))
175
+ .flatMap((row) => row.cells.map((cell) => [cell.x0, cell.x1]))
176
+ .sort((a, b) => a[0] - b[0]);
177
+ const pad = 0.3 * median(block.map((row) => row.size));
178
+ const columns = [];
179
+ for (const [x0, x1] of spans) {
180
+ const last = columns[columns.length - 1];
181
+ if (last && x0 <= last[1] + pad)
182
+ last[1] = Math.max(last[1], x1);
183
+ else
184
+ columns.push([x0, x1]);
185
+ }
186
+ return columns;
187
+ }
188
+ function columnIndex(columns, cell) {
189
+ const center = (cell.x0 + cell.x1) / 2;
190
+ let best = 0;
191
+ let bestDistance = Number.POSITIVE_INFINITY;
192
+ columns.forEach(([x0, x1], index) => {
193
+ const distance = center >= x0 && center <= x1 ? 0 : Math.min(Math.abs(center - x0), Math.abs(center - x1));
194
+ if (distance < bestDistance) {
195
+ best = index;
196
+ bestDistance = distance;
197
+ }
198
+ });
199
+ return best;
200
+ }
201
+ function markdownTable(block, columns) {
202
+ const lines = block.map((row) => {
203
+ const cells = Array.from({ length: columns.length }, () => "");
204
+ for (const cell of row.cells) {
205
+ const at = columnIndex(columns, cell);
206
+ cells[at] = cells[at] ? `${cells[at]} ${cell.text}` : cell.text;
207
+ }
208
+ return `| ${cells.map((text) => text.replace(/\|/g, "\\|")).join(" | ")} |`;
209
+ });
210
+ lines.splice(1, 0, `| ${columns.map(() => "---").join(" | ")} |`);
211
+ return lines.join("\n");
212
+ }
213
+ const plainLine = (row) => row.cells.map((cell) => cell.text).join(row.cells.length > 1 ? " " : " ");
214
+ /**
215
+ * A short one-cell row under a columnar row, with another columnar row after it, is the wrapped second line
216
+ * of one of that row's cells (a table header that wrapped): it joins that cell.
217
+ */
218
+ function foldContinuations(rows) {
219
+ const out = [];
220
+ for (let at = 0; at < rows.length; at += 1) {
221
+ const row = rows[at];
222
+ const above = out[out.length - 1];
223
+ const below = rows[at + 1];
224
+ if (above && above.cells.length >= 2 && below && below.cells.length >= 2 && row.cells.length === 1 && row.cells[0].text.length <= 24 && above.y - row.y <= TABLE_ROW_EM * row.size) {
225
+ const cell = row.cells[0];
226
+ const center = (cell.x0 + cell.x1) / 2;
227
+ const target = above.cells.find((candidate) => center >= candidate.x0 - 2 && center <= candidate.x1 + 2);
228
+ if (target) {
229
+ target.text = `${target.text} ${cell.text}`;
230
+ target.x0 = Math.min(target.x0, cell.x0);
231
+ target.x1 = Math.max(target.x1, cell.x1);
232
+ continue;
233
+ }
234
+ }
235
+ out.push(row);
236
+ }
237
+ return out;
238
+ }
239
+ function renderSection(input) {
240
+ const rows = foldContinuations(input);
241
+ const out = [];
242
+ let previous;
243
+ let at = 0;
244
+ while (at < rows.length) {
245
+ const row = rows[at];
246
+ if (previous && previous.y - row.y > BREAK_EM * Math.max(previous.size, row.size))
247
+ out.push("");
248
+ if (row.cells.length >= 2) {
249
+ // A block: consecutive columnar rows with no paragraph-sized jump between them.
250
+ let end = at + 1;
251
+ while (end < rows.length && rows[end].cells.length >= 2 && rows[end - 1].y - rows[end].y <= TABLE_ROW_EM * rows[end].size)
252
+ end += 1;
253
+ const block = rows.slice(at, end);
254
+ const columns = block.length >= 2 ? columnsOf(block) : [];
255
+ const fullRows = block.filter((member) => member.cells.length === columns.length).length;
256
+ if (columns.length >= 2 && fullRows >= 2) {
257
+ out.push(markdownTable(block, columns));
258
+ }
259
+ else {
260
+ for (const member of block)
261
+ out.push(plainLine(member));
262
+ }
263
+ previous = block[block.length - 1];
264
+ at = end;
265
+ continue;
266
+ }
267
+ if (row.cells.length === 1)
268
+ out.push(plainLine(row));
269
+ previous = row;
270
+ at += 1;
271
+ }
272
+ return out;
273
+ }
274
+ /** One page's items as text. Empty string when the page has no text. */
275
+ export function layoutPage(items) {
276
+ const rows = toRows(items);
277
+ if (rows.length === 0)
278
+ return "";
279
+ const gutter = findGutter(rows);
280
+ const lines = [];
281
+ for (const section of sections(rows, gutter)) {
282
+ if (lines.length > 0 && lines[lines.length - 1] !== "")
283
+ lines.push("");
284
+ lines.push(...renderSection(section));
285
+ }
286
+ return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim();
287
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * One decoded PDF page image to a small grayscale PNG the image describe door takes (BLI-23347).
3
+ *
4
+ * A scanned PDF page is one big raster (a 7.4 MB scan decodes to 2550x3300 RGB, 25 MB). The describer
5
+ * takes at most 3 MB and reads text fine at about 1,600 px on the long side, so the raster is box-filtered
6
+ * down, turned to 8-bit grey and PNG-encoded, trying smaller sizes until it fits. Pure: pixels in, bytes
7
+ * out, no pdf library and no native dependency, so it runs the same on macOS and Windows.
8
+ */
9
+ import { createHash } from "node:crypto";
10
+ import { deflateSync } from "node:zlib";
11
+ /** Long-side sizes tried in order; the first PNG under the byte limit wins. */
12
+ const LONG_SIDES = [1600, 1200, 900, 640];
13
+ const CRC_TABLE = (() => {
14
+ const table = new Uint32Array(256);
15
+ for (let n = 0; n < 256; n += 1) {
16
+ let c = n;
17
+ for (let k = 0; k < 8; k += 1)
18
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
19
+ table[n] = c >>> 0;
20
+ }
21
+ return table;
22
+ })();
23
+ function crc32(buffer) {
24
+ let c = 0xffffffff;
25
+ for (let i = 0; i < buffer.length; i += 1)
26
+ c = CRC_TABLE[(c ^ buffer[i]) & 0xff] ^ (c >>> 8);
27
+ return (c ^ 0xffffffff) >>> 0;
28
+ }
29
+ function chunk(type, body) {
30
+ const head = Buffer.alloc(8);
31
+ head.writeUInt32BE(body.length, 0);
32
+ head.write(type, 4, "latin1");
33
+ const crc = Buffer.alloc(4);
34
+ crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), body])), 0);
35
+ return Buffer.concat([head, body, crc]);
36
+ }
37
+ /** Grey level (0-255) of every pixel, one byte each. */
38
+ function toGrey(raster) {
39
+ const { width, height, kind, data } = raster;
40
+ const grey = new Uint8Array(width * height);
41
+ if (kind === 1) {
42
+ const stride = Math.ceil(width / 8);
43
+ for (let y = 0; y < height; y += 1) {
44
+ for (let x = 0; x < width; x += 1)
45
+ grey[y * width + x] = (data[y * stride + (x >> 3)] >> (7 - (x & 7))) & 1 ? 255 : 0;
46
+ }
47
+ return grey;
48
+ }
49
+ const step = kind === 2 ? 3 : 4;
50
+ for (let i = 0, p = 0; i < grey.length; i += 1, p += step) {
51
+ const luma = (data[p] * 299 + data[p + 1] * 587 + data[p + 2] * 114) / 1000;
52
+ // Transparent pixels read as paper, not as black.
53
+ const alpha = kind === 3 ? data[p + 3] / 255 : 1;
54
+ grey[i] = Math.round(luma * alpha + 255 * (1 - alpha));
55
+ }
56
+ return grey;
57
+ }
58
+ /** Box-filter `grey` (width x height) down to `toWidth` x `toHeight`. */
59
+ function shrink(grey, width, height, toWidth, toHeight) {
60
+ if (toWidth === width && toHeight === height)
61
+ return grey;
62
+ const out = new Uint8Array(toWidth * toHeight);
63
+ for (let y = 0; y < toHeight; y += 1) {
64
+ const y0 = Math.floor((y * height) / toHeight);
65
+ const y1 = Math.max(y0 + 1, Math.floor(((y + 1) * height) / toHeight));
66
+ for (let x = 0; x < toWidth; x += 1) {
67
+ const x0 = Math.floor((x * width) / toWidth);
68
+ const x1 = Math.max(x0 + 1, Math.floor(((x + 1) * width) / toWidth));
69
+ let sum = 0;
70
+ for (let yy = y0; yy < y1; yy += 1)
71
+ for (let xx = x0; xx < x1; xx += 1)
72
+ sum += grey[yy * width + xx];
73
+ out[y * toWidth + x] = Math.round(sum / ((y1 - y0) * (x1 - x0)));
74
+ }
75
+ }
76
+ return out;
77
+ }
78
+ /** An 8-bit grey PNG with the Sub filter on every row. */
79
+ export function encodeGreyPng(grey, width, height) {
80
+ const rows = Buffer.alloc((width + 1) * height);
81
+ for (let y = 0; y < height; y += 1) {
82
+ const at = y * (width + 1);
83
+ rows[at] = 1;
84
+ for (let x = 0; x < width; x += 1)
85
+ rows[at + 1 + x] = (grey[y * width + x] - (x > 0 ? grey[y * width + x - 1] : 0)) & 0xff;
86
+ }
87
+ const header = Buffer.alloc(13);
88
+ header.writeUInt32BE(width, 0);
89
+ header.writeUInt32BE(height, 4);
90
+ header[8] = 8; // bit depth
91
+ header[9] = 0; // grey
92
+ return Buffer.concat([
93
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
94
+ chunk("IHDR", header),
95
+ chunk("IDAT", deflateSync(rows, { level: 6 })),
96
+ chunk("IEND", Buffer.alloc(0)),
97
+ ]);
98
+ }
99
+ /** The raster as a PNG of at most `maxBytes`, or null when even the smallest size is too big. */
100
+ export function rasterToPagePng(raster, maxBytes) {
101
+ const { width, height } = raster;
102
+ if (!(width > 0 && height > 0) || raster.data.length < (raster.kind === 1 ? Math.ceil(width / 8) * height : width * height * (raster.kind === 2 ? 3 : 4)))
103
+ return null;
104
+ const grey = toGrey(raster);
105
+ for (const longSide of LONG_SIDES) {
106
+ const scale = Math.min(1, longSide / Math.max(width, height));
107
+ const toWidth = Math.max(1, Math.round(width * scale));
108
+ const toHeight = Math.max(1, Math.round(height * scale));
109
+ const bytes = encodeGreyPng(shrink(grey, width, height, toWidth, toHeight), toWidth, toHeight);
110
+ if (bytes.length <= maxBytes)
111
+ return { bytes, sha256: createHash("sha256").update(bytes).digest("hex"), width: toWidth, height: toHeight };
112
+ }
113
+ return null;
114
+ }
@@ -0,0 +1,218 @@
1
+ /**
2
+ * The PDF step of the transcript prepare path (BLI-23347): turn each base64 PDF into a marker block, in the
3
+ * same pass and under the same budgets as images. Drop mode only.
4
+ *
5
+ * The session (`image-description-session.ts`) holds the image state; this module plugs into it through ONE
6
+ * seam, `addPdfsToFinds`, which takes the images the session found and returns the same shape with the PDFs
7
+ * added:
8
+ *
9
+ * - every PDF occurrence is a stand-in `EmbeddedImage` carrying `replacement` (its marker text, set once the
10
+ * conversion is complete), so the swap in `replaceImagesWithMarkers` and `slimLineStream` needs no PDF code;
11
+ * - a scanned PDF's page images join `unique`, so the existing describe door, budgets, backoff and state do
12
+ * the describing; the PDF is replaced only when every page image has a description (an undescribed PDF is
13
+ * kept, exactly as an undescribed image is);
14
+ * - the FIRST copy of a PDF in the transcript gets the full marker block, every later copy a one-line reference.
15
+ *
16
+ * Conversion results are cached on disk by sha256 (`pdf-conversion-cache.ts`), so a transcript rebuilt every
17
+ * tick converts each PDF once. A PDF that cannot be converted (too big, the budget spent, unreadable) is left
18
+ * as it is and says why in the log; it is not counted, so it never keeps a transcript "pending" forever.
19
+ */
20
+ import { createHash } from "node:crypto";
21
+ import { formatImageMarker, formatPdfMarker, formatPdfReference } from "@bli-cockpit/telemetry-core";
22
+ import { convertPdf } from "./pdf-convert.js";
23
+ import { readCachedPdf, writeCachedPdf } from "./pdf-conversion-cache.js";
24
+ import { bytesMayHoldPdf, scanPdfsInLine } from "./pdf-byte-scan.js";
25
+ const TAG = "[pdf-step]";
26
+ /** Largest PDF decoded for conversion: the document, its pdf.js structures and one page raster must fit comfortably. */
27
+ export const MAX_PDF_BYTES = 25 * 1024 * 1024;
28
+ /** Time spent converting across one sync; later PDFs wait for the next tick (the cache keeps finished ones). */
29
+ export const MAX_PDF_RUN_MS = 60_000;
30
+ const BLOCK_CHARS = 48 * 1024;
31
+ const runStates = new WeakMap();
32
+ function runStateOf(key) {
33
+ let state = runStates.get(key);
34
+ if (!state) {
35
+ state = { spentMs: 0, records: new Map() };
36
+ runStates.set(key, state);
37
+ }
38
+ return state;
39
+ }
40
+ /** `text` in a JSON string that sits `depth` strings deep: one escaping pass per level, plus the string's own. */
41
+ function jsonEscaped(text, depth) {
42
+ let out = JSON.stringify(text).slice(1, -1);
43
+ if (depth === 1)
44
+ out = JSON.stringify(out).slice(1, -1);
45
+ return out;
46
+ }
47
+ /** The decoded bytes of a base64 run, in blocks, into one exact-size buffer. */
48
+ function decode(payload, byteSize) {
49
+ const out = Buffer.alloc(byteSize);
50
+ let at = 0;
51
+ const length = payload.length;
52
+ for (let from = 0; from < length; from += BLOCK_CHARS) {
53
+ const block = Buffer.from(typeof payload === "string" ? payload.slice(from, from + BLOCK_CHARS) : payload.toString("latin1", from, Math.min(length, from + BLOCK_CHARS)), "base64");
54
+ if (at + block.length > byteSize)
55
+ return null;
56
+ block.copy(out, at);
57
+ at += block.length;
58
+ }
59
+ return at === byteSize ? new Uint8Array(out.buffer, out.byteOffset, out.length) : null;
60
+ }
61
+ const prefix = (sha256) => sha256.slice(0, 16);
62
+ export async function planPdfs(found, ctx) {
63
+ const state = runStateOf(ctx.key);
64
+ const distinct = new Map();
65
+ const order = new Map();
66
+ for (const pdf of found)
67
+ order.set(pdf.sha256, [...(order.get(pdf.sha256) ?? []), pdf]);
68
+ const pageImages = new Map();
69
+ const payloads = new Map();
70
+ for (const [sha256, copies] of order) {
71
+ const first = copies[0];
72
+ const started = Date.now();
73
+ // A PDF given up on earlier in this sync is not tried again.
74
+ if (state.records.has(sha256) && state.records.get(sha256) === null)
75
+ continue;
76
+ let entry = state.records.get(sha256) ?? (await readCachedPdf(ctx.stateDir, sha256));
77
+ const cacheHit = entry !== null;
78
+ const needsPages = entry?.kind === "scanned" && entry.pageImages.some((page) => !ctx.isDescribed(page.sha256));
79
+ if (!entry || needsPages) {
80
+ const skip = first.byteSize > MAX_PDF_BYTES ? "pdf_too_large" : state.spentMs > MAX_PDF_RUN_MS ? "pdf_time_budget_spent" : null;
81
+ if (skip) {
82
+ console.error(TAG + " PDF left as it is", JSON.stringify({ reason: skip, sha256_prefix: prefix(sha256), byte_size: first.byteSize, copies: copies.length }));
83
+ state.records.set(sha256, null);
84
+ continue;
85
+ }
86
+ const bytes = decode(await ctx.readBase64(first), first.byteSize);
87
+ if (!bytes || createHash("sha256").update(bytes).digest("hex") !== sha256)
88
+ throw ctx.sourceChanged();
89
+ const converted = await convertPdf(bytes);
90
+ state.spentMs += Date.now() - started;
91
+ if (converted.kind === "failed") {
92
+ console.error(TAG + " PDF left as it is", JSON.stringify({ reason: converted.reason, sha256_prefix: prefix(sha256), byte_size: first.byteSize, pages: converted.pages, copies: copies.length }));
93
+ state.records.set(sha256, null);
94
+ continue;
95
+ }
96
+ if (converted.kind === "text") {
97
+ entry = { kind: "text", pages: converted.pages, notes: converted.notes, body: converted.body };
98
+ }
99
+ else if (converted.pageImages.length === 0) {
100
+ console.error(TAG + " PDF left as it is", JSON.stringify({ reason: "scanned_without_page_images", sha256_prefix: prefix(sha256), byte_size: first.byteSize, pages: converted.pages, copies: copies.length }));
101
+ state.records.set(sha256, null);
102
+ continue;
103
+ }
104
+ else {
105
+ entry = { kind: "scanned", pages: converted.pages, notes: converted.notes, pageImages: converted.pageImages.map(({ page, png }) => ({ page, sha256: png.sha256, byteSize: png.bytes.length })) };
106
+ for (const { png } of converted.pageImages) {
107
+ if (ctx.isDescribed(png.sha256) || payloads.has(png.sha256))
108
+ continue;
109
+ payloads.set(png.sha256, png.bytes.toString("base64"));
110
+ }
111
+ }
112
+ await writeCachedPdf(ctx.stateDir, sha256, entry);
113
+ }
114
+ if (!entry)
115
+ continue;
116
+ state.records.set(sha256, entry);
117
+ if (entry.kind === "scanned") {
118
+ for (const page of entry.pageImages) {
119
+ if (ctx.isDescribed(page.sha256) || !payloads.has(page.sha256))
120
+ continue;
121
+ pageImages.set(page.sha256, { replaceStart: -1, replaceEnd: -1, base64Start: -1, base64End: -1, sha256: page.sha256, mediaType: "image/png", byteSize: page.byteSize });
122
+ }
123
+ }
124
+ console.error(TAG + " PDF converted", JSON.stringify({ kind: entry.kind, sha256_prefix: prefix(sha256), byte_size: first.byteSize, pages: entry.pages, chars: entry.kind === "text" ? entry.body.length : null, page_images: entry.kind === "scanned" ? entry.pageImages.length : null, copies: copies.length, cache: cacheHit ? "hit" : "miss", elapsed_ms: Date.now() - started }));
125
+ distinct.set(sha256, { pdf: first, copies, entry });
126
+ }
127
+ const occurrences = [];
128
+ const standIns = new Map();
129
+ let counted = 0;
130
+ for (const pdf of found) {
131
+ if (!distinct.has(pdf.sha256))
132
+ continue;
133
+ const standIn = { replaceStart: pdf.replaceStart, replaceEnd: pdf.replaceEnd, base64Start: pdf.base64Start, base64End: pdf.base64End, sha256: pdf.sha256, mediaType: "image/png", byteSize: pdf.byteSize };
134
+ standIns.set(pdf, standIn);
135
+ occurrences.push(standIn);
136
+ counted += 1;
137
+ }
138
+ const settle = () => {
139
+ let replacedCount = 0;
140
+ let charsSaved = 0;
141
+ for (const { copies, entry } of distinct.values()) {
142
+ const ready = entry.kind === "text" || entry.pageImages.every((page) => ctx.isDescribed(page.sha256));
143
+ if (!ready)
144
+ continue;
145
+ const facts = { sha256: copies[0].sha256, pages: entry.pages, byteSize: copies[0].byteSize };
146
+ const full = entry.kind === "text"
147
+ ? formatPdfMarker({ ...facts, method: "text-layer", notes: entry.notes }, entry.body)
148
+ : formatPdfMarker({ ...facts, method: "page-images", notes: entry.notes }, entry.pageImages.map((page) => `page ${page.page}: ${formatImageMarker({ sha256: page.sha256, mediaType: "image/png", byteSize: page.byteSize })}`).join("\n"));
149
+ const reference = formatPdfReference(facts);
150
+ copies.forEach((pdf, index) => {
151
+ const standIn = standIns.get(pdf);
152
+ const text = index === 0 ? full : reference;
153
+ standIn.replacement = jsonEscaped(text, pdf.escapeDepth);
154
+ replacedCount += 1;
155
+ charsSaved += pdf.replaceEnd - pdf.replaceStart - standIn.replacement.length;
156
+ });
157
+ }
158
+ return { replacedCount, charsSaved };
159
+ };
160
+ return { occurrences, pageImages, payloads, counted, settle };
161
+ }
162
+ /**
163
+ * The session's finds with the PDFs added; `finds` itself when there are none. Call only in drop mode.
164
+ */
165
+ export async function addPdfsToFinds(finds, found, ctx) {
166
+ if (found.length === 0)
167
+ return finds;
168
+ const step = await planPdfs(found, ctx);
169
+ if (step.counted === 0)
170
+ return finds;
171
+ const images = finds.images ? [...finds.images, ...step.occurrences].sort((a, b) => a.replaceStart - b.replaceStart) : undefined;
172
+ return {
173
+ ...finds,
174
+ total: finds.total + step.counted,
175
+ unique: new Map([...finds.unique, ...step.pageImages]),
176
+ base64For: (image) => step.payloads.get(image.sha256) ?? finds.base64For(image),
177
+ swapped: (replace) => {
178
+ const base = finds.swapped(replace);
179
+ const pdf = step.settle();
180
+ return { replacedCount: base.replacedCount + pdf.replacedCount, charsSaved: base.charsSaved + pdf.charsSaved };
181
+ },
182
+ ...(images ? { images } : {}),
183
+ extraOccurrences: step.occurrences,
184
+ };
185
+ }
186
+ /** The PDFs of a whole transcript's text, with character offsets (the text path; files at the streaming size use the census). */
187
+ export function pdfsOfText(text) {
188
+ if (!text.includes("JVBERi0") && !text.includes("data:application/pdf"))
189
+ return [];
190
+ const bytes = Buffer.from(text, "utf8");
191
+ if (!bytesMayHoldPdf(bytes))
192
+ return [];
193
+ const byteOffsets = scanPdfsInLine(bytes);
194
+ const out = [];
195
+ let consumedBytes = 0;
196
+ let consumedChars = 0;
197
+ const charAt = (byteOffset) => {
198
+ consumedChars += bytes.toString("utf8", consumedBytes, byteOffset).length;
199
+ consumedBytes = byteOffset;
200
+ return consumedChars;
201
+ };
202
+ for (const pdf of byteOffsets) {
203
+ const replaceStart = charAt(pdf.replaceStart);
204
+ const base64Start = replaceStart + (pdf.base64Start - pdf.replaceStart);
205
+ const length = pdf.base64End - pdf.base64Start;
206
+ const replaceEnd = base64Start + length;
207
+ consumedBytes = pdf.replaceEnd;
208
+ consumedChars = replaceEnd;
209
+ out.push({ ...pdf, replaceStart, replaceEnd, base64Start, base64End: replaceEnd });
210
+ }
211
+ return out;
212
+ }
213
+ /** Merge two lists already sorted by `replaceStart`. */
214
+ export function mergeOccurrences(a, b) {
215
+ if (!b || b.length === 0)
216
+ return a;
217
+ return [...a, ...b].sort((x, y) => x.replaceStart - y.replaceStart);
218
+ }
@@ -2,6 +2,7 @@ import { DELIVERY_BACKOFF_HOLDING } from "@bli-cockpit/telemetry-core";
2
2
  import crypto from "node:crypto";
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
+ import { bookkeepingRead, bookkeepingWrite } from "./bookkeeping-session.js";
5
6
  import { describeError, isMissingFileFailure } from "./health-detail.js";
6
7
  /**
7
8
  * Durable local state for raw-evidence STAGING and DELIVERY ATTEMPTS.
@@ -67,7 +68,9 @@ export function emptyRawEvidenceStagingState() {
67
68
  export function rawEvidenceStagingStatePath(stateDir) {
68
69
  return path.join(stateDir, RAW_EVIDENCE_STAGING_FILENAME);
69
70
  }
70
- export async function readRawEvidenceStagingState(stateDir) {
71
+ export const readRawEvidenceStagingState = (stateDir) => // BLI-23346: once per loop
72
+ bookkeepingRead(rawEvidenceStagingStatePath(stateDir), () => readStagingFromDisk(stateDir));
73
+ async function readStagingFromDisk(stateDir) {
71
74
  try {
72
75
  const raw = JSON.parse(await fs.readFile(rawEvidenceStagingStatePath(stateDir), "utf8"));
73
76
  const state = parseStagingState(raw);
@@ -94,11 +97,12 @@ export async function readRawEvidenceStagingState(stateDir) {
94
97
  * lose every attempt count at once — which is exactly the thing that must not
95
98
  * be losable.
96
99
  */
97
- export async function writeRawEvidenceStagingState(stateDir, state) {
100
+ export const writeRawEvidenceStagingState = (stateDir, state) => bookkeepingWrite(rawEvidenceStagingStatePath(stateDir), state, (held) => writeStagingToDisk(stateDir, held));
101
+ async function writeStagingToDisk(stateDir, state) {
98
102
  const filePath = rawEvidenceStagingStatePath(stateDir);
99
103
  await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
100
104
  const pruned = pruneStagingState(state);
101
- const serialized = `${JSON.stringify(pruned, null, 2)}\n`;
105
+ const serialized = `${JSON.stringify(pruned)}\n`; // compact (BLI-23346); readers accept either
102
106
  const tempPath = `${filePath}.tmp-${process.pid}-${crypto.randomUUID()}`;
103
107
  let handle = null;
104
108
  try {