@bli-cockpit/cli 0.2.270 → 0.2.272

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,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
+ }
@@ -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
+ }