@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.
@@ -1,6 +1,7 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import { bookkeepingRead, bookkeepingWrite } from "../bookkeeping-session.js";
4
5
  import { describeError, isMissingFileFailure } from "../health-detail.js";
5
6
  /**
6
7
  * Durable local cursor for raw-evidence harvesting.
@@ -37,7 +38,22 @@ export function emptyRawEvidenceCursorState() {
37
38
  sessions: {},
38
39
  };
39
40
  }
41
+ /**
42
+ * Inside a worktree-loop bookkeeping session the cursor is loaded once and handed out as one shared object
43
+ * (`bookkeeping-session.ts`, BLI-23346); anywhere else this is the plain disk read.
44
+ */
40
45
  export async function readRawEvidenceCursor(paths, options = {}) {
46
+ let key;
47
+ try {
48
+ key = rawEvidenceCursorPath(paths, options.filename);
49
+ }
50
+ catch {
51
+ // Paths without a cursors dir: the disk reader below reports it exactly as it always did.
52
+ return readRawEvidenceCursorFromDisk(paths, options);
53
+ }
54
+ return bookkeepingRead(key, () => readRawEvidenceCursorFromDisk(paths, options));
55
+ }
56
+ async function readRawEvidenceCursorFromDisk(paths, options) {
41
57
  try {
42
58
  const raw = JSON.parse(await fs.readFile(rawEvidenceCursorPath(paths, options.filename), "utf8"));
43
59
  return parseCursorState(raw);
@@ -59,6 +75,9 @@ export async function readRawEvidenceCursor(paths, options = {}) {
59
75
  }
60
76
  }
61
77
  export async function writeRawEvidenceCursor(paths, state, options = {}) {
78
+ await bookkeepingWrite(rawEvidenceCursorPath(paths, options.filename), state, (held) => writeRawEvidenceCursorToDisk(paths, held, options));
79
+ }
80
+ async function writeRawEvidenceCursorToDisk(paths, state, options) {
62
81
  const filePath = rawEvidenceCursorPath(paths, options.filename);
63
82
  await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
64
83
  const pruned = pruneCursorState(state);
@@ -17,11 +17,17 @@ import { isUtf8 } from "node:buffer";
17
17
  import { formatImageMarker } from "@bli-cockpit/telemetry-core";
18
18
  import { TranscriptNotStreamable } from "./adapters/transcript-text-stream.js";
19
19
  import { bytesMayHoldImage, scanImagesInLine } from "./image-byte-scan.js";
20
+ import { bytesMayHoldPdf, scanPdfsInLine } from "./pdf-byte-scan.js";
20
21
  export async function censusOfLines(lines) {
21
- const census = { total: 0, occurrences: [], unique: new Map(), tally: new Map() };
22
+ const census = { total: 0, occurrences: [], unique: new Map(), tally: new Map(), pdfs: [] };
22
23
  for await (const { start, bytes } of lines) {
23
24
  if (!isUtf8(bytes))
24
25
  throw new TranscriptNotStreamable("invalid_utf8");
26
+ if (bytesMayHoldPdf(bytes)) {
27
+ for (const pdf of scanPdfsInLine(bytes)) {
28
+ census.pdfs.push({ ...pdf, replaceStart: pdf.replaceStart + start, replaceEnd: pdf.replaceEnd + start, base64Start: pdf.base64Start + start, base64End: pdf.base64End + start });
29
+ }
30
+ }
25
31
  if (!bytesMayHoldImage(bytes))
26
32
  continue;
27
33
  for (const found of scanImagesInLine(bytes)) {
@@ -11,6 +11,7 @@ import { sendPendingImages } from "./image-description-send.js";
11
11
  import { resolveImageHandlingMode } from "./image-mode-state.js";
12
12
  import { censusOfLines } from "./image-census-stream.js";
13
13
  import { sha256OfBase64Run } from "./image-byte-scan.js";
14
+ import { addPdfsToFinds, mergeOccurrences, pdfsOfText } from "./pdf-transcript-step.js";
14
15
  import { openPinnedTranscript } from "./adapters/pinned-transcript.js";
15
16
  import { openStagedOutput, readStagedText } from "./adapters/staged-output.js";
16
17
  import { TranscriptNotStreamable } from "./adapters/transcript-text-stream.js";
@@ -47,7 +48,12 @@ export async function openImageDescriptions(options) {
47
48
  isDescribed: (sha256) => described.has(imageStateKey(sha256)),
48
49
  hasDescribeRoom: (sha256s) => describeRoomLeft(run, sha256s),
49
50
  async prepareTranscript(input) {
50
- const { replace, finds, ...afterImages } = await describeAndReplace(run, input, findsOfText(input.text));
51
+ // BLI-23347: in drop mode each base64 PDF becomes a marker block (its text, or its described page images).
52
+ const imageFinds = findsOfText(input.text);
53
+ const finds0 = mode === "describe_and_drop"
54
+ ? await addPdfsToFinds(imageFinds, pdfsOfText(input.text), pdfContext(run, { stateDir: options.stateDir, read: async (pdf) => input.text.slice(pdf.base64Start, pdf.base64End), changed: () => new Error("pdf_changed_in_text") }))
55
+ : imageFinds;
56
+ const { replace, finds, ...afterImages } = await describeAndReplace(run, input, finds0);
51
57
  if (mode !== "describe_and_drop")
52
58
  return { ...afterImages, text: input.text, changed: false, slim: null };
53
59
  // BLI-23317: the image swap and the slim rules are ONE pass over the lines (one joined string, no
@@ -116,11 +122,17 @@ async function prepareFileInTwoPasses(run, input) {
116
122
  // Pass one: discover. Nothing of the text is kept; only where each image is and what it is.
117
123
  const census = await censusOfLines(source.lines());
118
124
  // Describe attempts finish and the swap decisions are settled before a single line is swapped, as with the whole text.
119
- const { replace, finds: _finds, ...afterImages } = await describeAndReplace(run, input, findsOfCensus(census, source));
125
+ // BLI-23347: the census also found the PDFs; they are converted here and swapped in the same pass as the images.
126
+ const pdfFinds = await addPdfsToFinds(findsOfCensus(census, source), census.pdfs, pdfContext(run, {
127
+ stateDir: run.options.stateDir,
128
+ read: (pdf) => source.read(pdf.base64Start, pdf.base64End - pdf.base64Start),
129
+ changed: () => new TranscriptNotStreamable("file_changed_during_pass"),
130
+ }));
131
+ const { replace, finds, ...afterImages } = await describeAndReplace(run, input, pdfFinds);
120
132
  // Pass two: swap and slim over exactly the bytes pass one saw, writing the result to staging as it goes.
121
133
  writer = input.stageDir ? await openStagedOutput(input.stageDir) : undefined;
122
134
  const memory = writer ? undefined : new LineBytesSink(source.length);
123
- const slim = await slimLineStream(source.lines(), input.source, census.occurrences, replace, writer ?? memory);
135
+ const slim = await slimLineStream(source.lines(), input.source, mergeOccurrences(census.occurrences, finds.extraOccurrences), replace, writer ?? memory);
124
136
  if (slim.replacedCount !== afterImages.replacedCount)
125
137
  throw new TranscriptNotStreamable("images_changed_between_passes");
126
138
  await source.verifyUnchanged();
@@ -202,14 +214,24 @@ function countImageSwaps(swap) {
202
214
  let replacedCount = 0;
203
215
  let charsSaved = 0;
204
216
  for (const image of swap.images) {
205
- if (!swap.replace(image))
217
+ if (image.replacement === undefined && !swap.replace(image))
206
218
  continue;
207
- const marker = formatImageMarker({ sha256: image.sha256, mediaType: image.mediaType, byteSize: image.byteSize });
219
+ const marker = image.replacement ?? formatImageMarker({ sha256: image.sha256, mediaType: image.mediaType, byteSize: image.byteSize });
208
220
  replacedCount += 1;
209
221
  charsSaved += image.replaceEnd - image.replaceStart - marker.length;
210
222
  }
211
223
  return { replacedCount, charsSaved };
212
224
  }
225
+ /** What the PDF step needs from this run (BLI-23347); `described` is the same set the image step reads. */
226
+ function pdfContext(run, parts) {
227
+ return {
228
+ key: run,
229
+ stateDir: parts.stateDir,
230
+ isDescribed: (sha256) => run.described.has(imageStateKey(sha256)),
231
+ readBase64: parts.read,
232
+ sourceChanged: parts.changed,
233
+ };
234
+ }
213
235
  function selectImagesToDescribe(run, unique, counts) {
214
236
  const { described, settledThisRun, failedImages, now } = run;
215
237
  const toSend = [];
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Find the base64 PDFs of one transcript line on its BYTES (BLI-23347), the PDF twin of `image-byte-scan.ts`.
3
+ *
4
+ * Claude Code stores a PDF it read twice on one line (`message.content[].content[].source.data` and
5
+ * `toolUseResult.file.base64`). Two shapes are recognised, both ASCII so a byte offset here is the character
6
+ * offset on ASCII text:
7
+ *
8
+ * - a whole-string payload: a base64 run that starts right after a `"` with `JVBERi0` (the base64 of
9
+ * `%PDF-`), is at least `PDF_MIN_BASE64_CHARS` long and is closed by `"` or by `\"` (a string that was
10
+ * itself stored inside a string; `escapeDepth` says which, so the replacement is escaped to match);
11
+ * - a data URL `data:application/pdf;base64,` followed by such a run; the whole URL is the replaced range.
12
+ *
13
+ * The run is hashed in blocks of 48 Ki characters straight from the line, so neither a string of the
14
+ * run nor a Buffer of the whole PDF exists here; the first block is sniffed for `%PDF-`. A run whose `=`
15
+ * characters are not plain trailing padding is not a PDF we can vouch for and is left alone.
16
+ */
17
+ import { createHash } from "node:crypto";
18
+ const QUOTE = 0x22;
19
+ const BACKSLASH = 0x5c;
20
+ const EQUALS = 0x3d;
21
+ /** Characters per decoded block. A multiple of 4: each block is a whole number of base64 quanta. */
22
+ const BLOCK_CHARS = 48 * 1024;
23
+ /** Below this a "PDF" is a stub, not a document anyone read (a one-line minimal PDF is about 400 bytes). */
24
+ export const PDF_MIN_BASE64_CHARS = 600;
25
+ const BASE64 = new Uint8Array(256);
26
+ for (const range of ["AZ", "az", "09"]) {
27
+ for (let code = range.charCodeAt(0); code <= range.charCodeAt(1); code += 1)
28
+ BASE64[code] = 1;
29
+ }
30
+ for (const char of "+/=")
31
+ BASE64[char.charCodeAt(0)] = 1;
32
+ const START = Buffer.from("JVBERi0", "latin1");
33
+ const DATA_URL = Buffer.from("data:application/pdf;base64,", "latin1");
34
+ const PDF_MAGIC = Buffer.from("%PDF-", "latin1");
35
+ /** Is there any chance this line holds a PDF? */
36
+ export function bytesMayHoldPdf(line) {
37
+ return line.includes(START) || line.includes(DATA_URL);
38
+ }
39
+ function runEnd(line, from) {
40
+ let at = from;
41
+ while (at < line.length && BASE64[line[at]] === 1)
42
+ at += 1;
43
+ return at;
44
+ }
45
+ /** sha256 and decoded size of the PDF `line[from, to)` encodes, or null when it is not one we can vouch for. */
46
+ export function digestPdfRun(line, from, to) {
47
+ const length = to - from;
48
+ const firstPad = line.subarray(from, to).indexOf(EQUALS);
49
+ if (firstPad !== -1) {
50
+ const padCount = length - firstPad;
51
+ if (padCount > 2 || length % 4 !== 0)
52
+ return null;
53
+ for (let at = from + firstPad; at < to; at += 1)
54
+ if (line[at] !== EQUALS)
55
+ return null;
56
+ }
57
+ const hash = createHash("sha256");
58
+ let byteSize = 0;
59
+ for (let at = from; at < to; at += BLOCK_CHARS) {
60
+ const block = Buffer.from(line.toString("latin1", at, Math.min(to, at + BLOCK_CHARS)), "base64");
61
+ if (at === from && block.compare(PDF_MAGIC, 0, PDF_MAGIC.length, 0, PDF_MAGIC.length) !== 0)
62
+ return null;
63
+ hash.update(block);
64
+ byteSize += block.byteLength;
65
+ }
66
+ return { sha256: hash.digest("hex"), byteSize };
67
+ }
68
+ /** Every embedded PDF in one physical line, in order, with byte offsets inside the line. */
69
+ export function scanPdfsInLine(line) {
70
+ const found = [];
71
+ if (line.length < PDF_MIN_BASE64_CHARS)
72
+ return found;
73
+ let position = 0;
74
+ while (position < line.length) {
75
+ const quoted = nextQuotedStart(line, position);
76
+ const dataUrl = line.indexOf(DATA_URL, position);
77
+ if (quoted === -1 && dataUrl === -1)
78
+ break;
79
+ const useUrl = dataUrl !== -1 && (quoted === -1 || dataUrl < quoted);
80
+ const base64Start = useUrl ? dataUrl + DATA_URL.length : quoted;
81
+ const end = runEnd(line, base64Start);
82
+ position = Math.max(end, base64Start + 1);
83
+ if (end - base64Start < PDF_MIN_BASE64_CHARS)
84
+ continue;
85
+ let escapeDepth = 0;
86
+ if (!useUrl) {
87
+ const plain = end < line.length && line[end] === QUOTE;
88
+ const escaped = end + 1 < line.length && line[end] === BACKSLASH && line[end + 1] === QUOTE;
89
+ if (!plain && !escaped)
90
+ continue;
91
+ escapeDepth = escaped ? 1 : 0;
92
+ }
93
+ else if (dataUrl >= 2 && line[dataUrl - 2] === BACKSLASH && line[dataUrl - 1] === QUOTE) {
94
+ escapeDepth = 1;
95
+ }
96
+ const digest = digestPdfRun(line, base64Start, end);
97
+ if (!digest)
98
+ continue;
99
+ found.push({ replaceStart: useUrl ? dataUrl : base64Start, replaceEnd: end, base64Start, base64End: end, escapeDepth, ...digest });
100
+ }
101
+ return found;
102
+ }
103
+ /** The next index at or after `from` where the PDF start sits right after a `"`, or -1. */
104
+ function nextQuotedStart(line, from) {
105
+ let at = line.indexOf(START, from);
106
+ while (at !== -1 && !(at > 0 && line[at - 1] === QUOTE))
107
+ at = line.indexOf(START, at + 1);
108
+ return at;
109
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * What a PDF became, kept on disk by its sha256 so a transcript that is rebuilt every tick does not re-read
3
+ * the same PDF every tick (BLI-23347).
4
+ *
5
+ * One small JSON file per distinct PDF under `<state dir>/pdf-conversions/`: the converted text, or for a
6
+ * scan the page list (page number, sha256 and size of each page image). Never the PDF, never a page image,
7
+ * never a path: the text is what the transcript carries anyway. A file written by other rules (a different
8
+ * `PDF_RULES_VERSION`) or damaged is treated as absent, with the reason logged. At most `MAX_ENTRIES` files
9
+ * stay; the oldest go first.
10
+ */
11
+ import { mkdir, readdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
12
+ import path from "node:path";
13
+ import { PDF_RULES_VERSION } from "@bli-cockpit/telemetry-core";
14
+ const TAG = "[pdf-cache]";
15
+ const DIRECTORY = "pdf-conversions";
16
+ const MAX_ENTRIES = 300;
17
+ const SHA = /^[0-9a-f]{64}$/;
18
+ function fileOf(stateDir, sha256) {
19
+ return path.join(stateDir, DIRECTORY, `${sha256}.json`);
20
+ }
21
+ function valid(value) {
22
+ if (typeof value !== "object" || value === null)
23
+ return false;
24
+ const record = value;
25
+ if (typeof record["pages"] !== "number" || !Array.isArray(record["notes"]) || !record["notes"].every((note) => typeof note === "string"))
26
+ return false;
27
+ if (record["kind"] === "text")
28
+ return typeof record["body"] === "string";
29
+ if (record["kind"] !== "scanned" || !Array.isArray(record["pageImages"]))
30
+ return false;
31
+ return record["pageImages"].every((entry) => typeof entry === "object" && entry !== null
32
+ && typeof entry["page"] === "number"
33
+ && typeof entry["sha256"] === "string" && SHA.test(entry["sha256"])
34
+ && typeof entry["byteSize"] === "number");
35
+ }
36
+ export async function readCachedPdf(stateDir, sha256) {
37
+ let raw;
38
+ try {
39
+ raw = await readFile(fileOf(stateDir, sha256), "utf8");
40
+ }
41
+ catch (error) {
42
+ if (error.code !== "ENOENT")
43
+ console.error(TAG + " entry unreadable, converting again", JSON.stringify({ reason: "cache_read_failed", error_name: error instanceof Error ? error.name : typeof error }));
44
+ return null;
45
+ }
46
+ try {
47
+ const parsed = JSON.parse(raw);
48
+ if (parsed.rules !== PDF_RULES_VERSION) {
49
+ console.error(TAG + " entry made by other rules, converting again", JSON.stringify({ reason: "cache_rules_changed" }));
50
+ return null;
51
+ }
52
+ if (valid(parsed.entry))
53
+ return parsed.entry;
54
+ }
55
+ catch {
56
+ // Falls through to the damaged-entry log below.
57
+ }
58
+ console.error(TAG + " entry damaged, converting again", JSON.stringify({ reason: "cache_entry_invalid", byte_size: raw.length }));
59
+ return null;
60
+ }
61
+ export async function writeCachedPdf(stateDir, sha256, entry) {
62
+ const directory = path.join(stateDir, DIRECTORY);
63
+ const target = fileOf(stateDir, sha256);
64
+ const partial = `${target}.${process.pid}.partial`;
65
+ try {
66
+ await mkdir(directory, { recursive: true });
67
+ await writeFile(partial, JSON.stringify({ rules: PDF_RULES_VERSION, entry }), "utf8");
68
+ await rename(partial, target);
69
+ }
70
+ catch (error) {
71
+ await unlink(partial).catch(() => undefined);
72
+ console.error(TAG + " entry not saved, it will be converted again next time", JSON.stringify({ reason: "cache_write_failed", error_name: error instanceof Error ? error.name : typeof error }));
73
+ return;
74
+ }
75
+ await prune(directory);
76
+ }
77
+ async function prune(directory) {
78
+ try {
79
+ const names = (await readdir(directory)).filter((name) => name.endsWith(".json"));
80
+ if (names.length <= MAX_ENTRIES)
81
+ return;
82
+ const aged = await Promise.all(names.map(async (name) => ({ name, mtime: (await stat(path.join(directory, name)).catch(() => null))?.mtimeMs ?? 0 })));
83
+ aged.sort((a, b) => a.mtime - b.mtime);
84
+ const drop = aged.slice(0, names.length - MAX_ENTRIES + 50);
85
+ await Promise.all(drop.map(({ name }) => unlink(path.join(directory, name)).catch(() => undefined)));
86
+ console.error(TAG + " oldest entries removed", JSON.stringify({ reason: "cache_over_limit", removed: drop.length, kept: names.length - drop.length }));
87
+ }
88
+ catch (error) {
89
+ console.error(TAG + " prune failed", JSON.stringify({ reason: "cache_prune_failed", error_name: error instanceof Error ? error.name : typeof error }));
90
+ }
91
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * One PDF's bytes to what the transcript keeps (BLI-23347): its text with tables kept, or, for a scan with no
3
+ * text layer, the PNG of each page's image so the existing image describe door can read it.
4
+ *
5
+ * pdf.js comes from `unpdf`, which ships it as one self-contained module: no native dependency, no canvas, so
6
+ * macOS and Windows behave the same. A scan is not RENDERED (that needs a canvas); each page's largest embedded
7
+ * image is decoded by pdf.js's own decoders and re-encoded by `pdf-page-image.ts`. A page drawn as vector shapes
8
+ * with no text and no image has nothing to extract and is named as such.
9
+ *
10
+ * Bounded: the document is held once (the caller's bytes), one page's raster at a time, at most
11
+ * `MAX_TEXT_PAGES` pages of text, `MAX_TEXT_CHARS` of it, `MAX_IMAGE_PAGES` page images, and a wall-clock
12
+ * budget per document. Hitting a bound is said in `notes`, never silent. Never throws: a document that cannot
13
+ * be read comes back as `failed` with a reason label.
14
+ *
15
+ * CHANGING WHAT THIS PRODUCES (limits, the text/scan rule, pdf-layout.ts heuristics) MUST BUMP
16
+ * `PDF_RULES_VERSION` in telemetry-core so every collector rebuilds the transcripts it converted.
17
+ */
18
+ import { IMAGE_DESCRIBE_MAX_BYTES } from "@bli-cockpit/telemetry-core";
19
+ import { layoutPage } from "./pdf-layout.js";
20
+ import { rasterToPagePng } from "./pdf-page-image.js";
21
+ export const MAX_TEXT_PAGES = 300;
22
+ export const MAX_TEXT_CHARS = 400_000;
23
+ export const MAX_IMAGE_PAGES = 8;
24
+ export const MAX_CONVERT_MS = 25_000;
25
+ /** A document whose first pages hold fewer characters per page than this has no usable text layer. */
26
+ const MIN_CHARS_PER_PAGE = 30;
27
+ /** Pages sampled to decide text or scan. */
28
+ const SAMPLE_PAGES = 10;
29
+ let loading;
30
+ /** pdf.js through unpdf, loaded once per process; null (and a logged reason) when the package is missing. */
31
+ async function loadPdfJs() {
32
+ loading ??= import("unpdf")
33
+ .then(async (unpdf) => (await unpdf.getResolvedPDFJS()))
34
+ .catch((error) => {
35
+ console.error("[pdf-convert] extractor unavailable", JSON.stringify({ reason: "extractor_unavailable", error_name: error instanceof Error ? error.name : typeof error }));
36
+ return null;
37
+ });
38
+ return loading;
39
+ }
40
+ function textItemsOf(content) {
41
+ const items = [];
42
+ for (const item of content.items) {
43
+ if (typeof item.str !== "string" || !item.transform)
44
+ continue;
45
+ items.push({ str: item.str, x: item.transform[4], y: item.transform[5], width: item.width ?? 0, size: Math.abs(item.transform[3]) || item.height || 10 });
46
+ }
47
+ return items;
48
+ }
49
+ /** Control characters other than tab and newline mean nothing to a reader and break line-oriented tools. */
50
+ function clean(text) {
51
+ return text.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "");
52
+ }
53
+ function failure(error, pages) {
54
+ const name = error instanceof Error ? error.name : typeof error;
55
+ const reason = name === "PasswordException" ? "password_protected" : name === "InvalidPDFException" || name === "FormatError" ? "not_a_readable_pdf" : "extract_error";
56
+ return { kind: "failed", reason, pages };
57
+ }
58
+ async function pageRaster(pdfjs, page) {
59
+ const list = await page.getOperatorList();
60
+ let best = null;
61
+ for (let index = 0; index < list.fnArray.length; index += 1) {
62
+ const op = list.fnArray[index];
63
+ if (op !== pdfjs.OPS.paintImageXObject && op !== pdfjs.OPS.paintInlineImageXObject)
64
+ continue;
65
+ const arg = list.argsArray[index]?.[0];
66
+ let image = arg;
67
+ if (typeof arg === "string") {
68
+ image = page.objs.has(arg) ? page.objs.get(arg) : await new Promise((resolve) => { page.objs.get(arg, resolve); });
69
+ }
70
+ const candidate = image;
71
+ if (!candidate?.data || !candidate.width || !candidate.height || (candidate.kind !== 1 && candidate.kind !== 2 && candidate.kind !== 3))
72
+ continue;
73
+ if (!best || candidate.width * candidate.height > best.width * best.height)
74
+ best = candidate;
75
+ }
76
+ return best;
77
+ }
78
+ export async function convertPdf(bytes, now = Date.now) {
79
+ const started = now();
80
+ const pdfjs = await loadPdfJs();
81
+ if (!pdfjs)
82
+ return { kind: "failed", reason: "extractor_unavailable", pages: null };
83
+ const task = pdfjs.getDocument({ data: bytes, isEvalSupported: false, disableFontFace: true, useSystemFonts: false, verbosity: 0 });
84
+ let doc;
85
+ try {
86
+ doc = await task.promise;
87
+ const pages = doc.numPages;
88
+ // Text first: the first pages decide text or scan, and a text document goes on page by page.
89
+ const rendered = [];
90
+ const notes = [];
91
+ let chars = 0;
92
+ let sampledChars = 0;
93
+ let shown = 0;
94
+ for (let number = 1; number <= Math.min(pages, MAX_TEXT_PAGES); number += 1) {
95
+ if (now() - started > MAX_CONVERT_MS) {
96
+ notes.push(`stopped after ${MAX_CONVERT_MS / 1000} s`);
97
+ break;
98
+ }
99
+ const page = await doc.getPage(number);
100
+ const text = clean(layoutPage(textItemsOf(await page.getTextContent())));
101
+ page.cleanup();
102
+ if (number <= SAMPLE_PAGES)
103
+ sampledChars += text.replace(/\s/g, "").length;
104
+ if (number === Math.min(pages, SAMPLE_PAGES) && sampledChars < MIN_CHARS_PER_PAGE * number)
105
+ return await scannedPages(pdfjs, doc, pages, started, now);
106
+ if (chars + text.length > MAX_TEXT_CHARS) {
107
+ notes.push(`text cut at ${MAX_TEXT_CHARS} characters`);
108
+ break;
109
+ }
110
+ chars += text.length;
111
+ rendered.push(text === "" ? `[page ${number}: no text]` : `[page ${number}]\n${text}`);
112
+ shown = number;
113
+ }
114
+ // Nothing read (the time budget was gone before page one): never a text block of nothing in place of a document.
115
+ if (shown === 0)
116
+ return { kind: "failed", reason: "time_budget_spent_before_first_page", pages };
117
+ if (shown < pages)
118
+ notes.push(`pages 1-${shown} of ${pages} shown`);
119
+ return { kind: "text", pages, body: rendered.join("\n\n"), notes };
120
+ }
121
+ catch (error) {
122
+ return failure(error, doc?.numPages ?? null);
123
+ }
124
+ finally {
125
+ await task.destroy().catch(() => undefined);
126
+ }
127
+ }
128
+ async function scannedPages(pdfjs, doc, pages, started, now) {
129
+ const pageImages = [];
130
+ const notes = ["no text layer"];
131
+ const missing = [];
132
+ const limit = Math.min(pages, MAX_IMAGE_PAGES);
133
+ for (let number = 1; number <= limit; number += 1) {
134
+ if (now() - started > MAX_CONVERT_MS) {
135
+ notes.push(`stopped after ${MAX_CONVERT_MS / 1000} s`);
136
+ break;
137
+ }
138
+ const page = await doc.getPage(number);
139
+ const raster = await pageRaster(pdfjs, page);
140
+ page.cleanup();
141
+ const png = raster ? rasterToPagePng(raster, IMAGE_DESCRIBE_MAX_BYTES - 100_000) : null;
142
+ if (png)
143
+ pageImages.push({ page: number, png });
144
+ else
145
+ missing.push(number);
146
+ }
147
+ if (missing.length > 0)
148
+ notes.push(`no page image on ${missing.length === 1 ? "page" : "pages"} ${missing.join(", ")}`);
149
+ if (pages > limit)
150
+ notes.push(`pages 1-${limit} of ${pages} described`);
151
+ return { kind: "scanned", pages, pageImages, notes };
152
+ }