pi-quiver 5.2.4 → 5.3.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.
@@ -1,46 +1,35 @@
1
1
  /**
2
2
  * doc-to-md core (pi-free)
3
3
  *
4
- * Converts a local PDF, DOCX, or PPTX file to Markdown. Primary engine:
5
- * pymupdf4llm via ephemeral `uv run --with` (warm-once per process, no repo
6
- * venv). Fallback: unpdf pure-JS text extraction (degraded, explicitly
7
- * marked). DOCX/PPTX convert to PDF first via headless soffice, then feed
8
- * the PDF pipeline. Output over 32 KB or 1000 lines is spilled to a temp
9
- * .md file with a 60-line preview; smaller content is returned inline.
4
+ * Converts local PDF, DOCX, PPTX, XLSX, and XLS documents into disk bundles
5
+ * with concise handles. PDF uses primary pymupdf4llm, PyMuPDF-text fallback,
6
+ * then unpdf when no Python backend exists. DOCX/PPTX convert through
7
+ * headless soffice before entering the PDF pipeline.
10
8
  */
11
9
 
12
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
13
- import { spawn } from "node:child_process";
10
+ import { existsSync, mkdtempSync, renameSync, rmSync, statSync } from "node:fs";
11
+ import { type ChildProcess, spawn } from "node:child_process";
14
12
  import { homedir, tmpdir } from "node:os";
15
13
  import { basename, dirname, extname, join, resolve } from "node:path";
16
- import { createHash } from "node:crypto";
17
14
  import { fileURLToPath, pathToFileURL } from "node:url";
18
- import { extractText, getDocumentProxy } from "unpdf";
15
+ import { type Bundle, abortBundle, commitBundle, openBundle, publishSheetImages, publishStaged, rewriteImageLinks, tempBundleRoot, validateImageLinks } from "./doc-to-md-bundle.ts";
16
+ import { type Engine, type HandleData, type InfoData, type Tier, formatHandle, formatInfoHandle, scanOutline, type SheetInfo, type TocEntry } from "./doc-to-md-handle.ts";
17
+ import { type DocToMdOptions, type InputType, TUNABLE_DEFAULTS, classifyInput, sanitizeStem } from "./doc-to-md-options.ts";
18
+
19
+ export * from "./doc-to-md-options.ts";
20
+ export { compactRanges, formatHandle, formatInfoHandle, formatSize, scanOutline } from "./doc-to-md-handle.ts";
21
+ export type { Engine, HandleData, InfoData, OutlineEntry, SheetInfo, Tier, TocEntry } from "./doc-to-md-handle.ts";
19
22
 
20
23
  // --- Types ---
21
24
 
22
- export type InputType = "pdf" | "docx" | "pptx";
23
- export type Engine = "pymupdf4llm" | "unpdf";
25
+ export const PACKAGE_PINS = { pymupdf4llm: TUNABLE_DEFAULTS.pymupdfVersion, openpyxl: "3.1.5", xlrd: "2.0.2", pillow: "12.3.0" } as const;
26
+ export const KILL_GRACE_MS = 2000;
27
+ export const VENV_DIR_NAME = "doc-to-md-venv-v2";
28
+ export const LEGACY_VENV_DIR_NAME = "pymupdf-venv";
29
+ const STDERR_CAP = 1_000_000;
30
+ export const OUTPUT_MAX_BYTES = 20_000_000;
24
31
 
25
- export interface DocToMdConfig {
26
- pymupdfVersion: string;
27
- warmTimeoutMs: number;
28
- convertTimeoutMs: number;
29
- sofficeTimeoutMs: number;
30
- }
31
-
32
- export interface DocToMdDetails {
33
- path: string;
34
- inputType: InputType;
35
- engine: Engine;
36
- backend: BackendKind;
37
- pymupdfVersion?: string;
38
- degraded: boolean;
39
- bytes: number;
40
- lines: number;
41
- spilled: boolean;
42
- file?: string;
43
- }
32
+ export interface BackendConfig { pymupdfVersion: string; warmTimeoutMs: number; }
44
33
 
45
34
  export interface CappedResult {
46
35
  stdout: string;
@@ -50,110 +39,25 @@ export interface CappedResult {
50
39
  capped: boolean;
51
40
  }
52
41
 
53
- // --- Constants ---
54
-
55
- const DEFAULT_PYMUPDF_VERSION = "1.27.2.3";
56
- const WARM_TIMEOUT_DEFAULT = 120_000;
57
- const CONVERT_TIMEOUT_DEFAULT = 60_000;
58
- const SOFFICE_TIMEOUT_DEFAULT = 120_000;
59
- const STDERR_CAP = 1_000_000;
60
- const INLINE_MAX_BYTES = 32_000;
61
- const INLINE_MAX_LINES = 1_000;
62
- const PREVIEW_LINES = 60;
63
- const PREVIEW_MAX_BYTES = 4_000;
64
- const VERSION_RE = /^\d+(\.\d+)*$/;
65
- const PAGE_SEP = "\n\n---\n\n";
66
- export const OUTPUT_MAX_BYTES = 20_000_000;
67
-
68
- export const DEGRADED_MARKER =
69
- "[Note: degraded extraction via unpdf — structure (tables/headings) not preserved]";
70
-
71
- // --- Config ---
72
-
73
- export function parseConfig(env: NodeJS.ProcessEnv): DocToMdConfig {
74
- const version = env.PI_DOC_TO_MD_PYMUPDF_VERSION ?? DEFAULT_PYMUPDF_VERSION;
75
- if (!VERSION_RE.test(version)) {
76
- throw new Error(`PI_DOC_TO_MD_PYMUPDF_VERSION must be digits and dots (got "${version}")`);
77
- }
78
- const num = (key: string, def: number): number => {
79
- const raw = env[key];
80
- if (raw === undefined) return def;
81
- const n = Number.parseInt(raw, 10);
82
- if (!Number.isInteger(n) || n <= 0) throw new Error(`${key} must be a positive integer (got "${raw}")`);
83
- return n;
84
- };
85
- return {
86
- pymupdfVersion: version,
87
- warmTimeoutMs: num("PI_DOC_TO_MD_WARM_TIMEOUT_MS", WARM_TIMEOUT_DEFAULT),
88
- convertTimeoutMs: num("PI_DOC_TO_MD_CONVERT_TIMEOUT_MS", CONVERT_TIMEOUT_DEFAULT),
89
- sofficeTimeoutMs: num("PI_DOC_TO_MD_SOFFICE_TIMEOUT_MS", SOFFICE_TIMEOUT_DEFAULT),
90
- };
91
- }
92
-
93
- // --- Input classification ---
94
-
95
- const SUPPORTED: Record<string, InputType> = { ".pdf": "pdf", ".docx": "docx", ".pptx": "pptx" };
96
-
97
- export function classifyInput(filePath: string): InputType {
98
- const ext = extname(filePath).toLowerCase();
99
- const t = SUPPORTED[ext];
100
- if (!t) throw new Error(`Unsupported file type "${ext || "(none)"}"; supported: .pdf, .docx, .pptx`);
101
- return t;
102
- }
103
-
104
- // --- Size gate ---
105
-
106
- export function applyGate(body: string): { spill: boolean; bytes: number; lines: number } {
107
- const bytes = Buffer.byteLength(body, "utf8");
108
- const lines = body.length ? body.split("\n").length : 0;
109
- const spill = body.length > 0 && (bytes > INLINE_MAX_BYTES || lines > INLINE_MAX_LINES);
110
- return { spill, bytes, lines };
111
- }
112
-
113
- // --- Markdown helpers ---
114
-
115
- export function withMarker(md: string, degraded: boolean): string {
116
- return degraded ? `${DEGRADED_MARKER}\n\n${md}` : md;
117
- }
118
-
119
- export function pagesToMarkdown(pages: string[]): string {
120
- return pages.map((p) => p.trim()).filter((p) => p.length > 0).join(PAGE_SEP);
121
- }
122
-
123
- // --- Temp file helpers ---
42
+ // --- Subprocess argv builders ---
124
43
 
125
- export function tempFilePath(inputPath: string, ext: string): string {
126
- const dir = join(tmpdir(), "pi-doc-to-md");
127
- mkdirSync(dir, { recursive: true });
128
- const base = basename(inputPath).replace(/\.[^.]+$/, "").replace(/[^\w.-]+/g, "-");
129
- const hash = createHash("sha1").update(resolve(inputPath)).digest("hex").slice(0, 8);
130
- const stamp = new Date().toISOString().replace(/[:.]/g, "-");
131
- return join(dir, `${stamp}-${base}-${hash}.${ext}`);
44
+ function withArgs(cfg: BackendConfig): string[] {
45
+ return ["--with", `pymupdf4llm==${cfg.pymupdfVersion}`, "--with", `openpyxl==${PACKAGE_PINS.openpyxl}`, "--with", `xlrd==${PACKAGE_PINS.xlrd}`, "--with", `pillow==${PACKAGE_PINS.pillow}`];
132
46
  }
133
47
 
134
- export function spillToFile(inputPath: string, body: string): string {
135
- const file = tempFilePath(inputPath, "md");
136
- writeFileSync(file, `${body}\n`, "utf8");
137
- return file;
48
+ export function pipInstallArgs(cfg: BackendConfig): string[] {
49
+ return ["-m", "pip", "install", `pymupdf4llm==${cfg.pymupdfVersion}`, `openpyxl==${PACKAGE_PINS.openpyxl}`, `xlrd==${PACKAGE_PINS.xlrd}`, `pillow==${PACKAGE_PINS.pillow}`];
138
50
  }
139
51
 
140
- export function buildPreview(body: string): string {
141
- let preview = body.split("\n").slice(0, PREVIEW_LINES).join("\n");
142
- if (preview.length > PREVIEW_MAX_BYTES) {
143
- preview = `${preview.slice(0, PREVIEW_MAX_BYTES)}\n…[preview truncated]`;
144
- }
145
- return preview;
52
+ export function warmArgs(cfg: BackendConfig): string[] {
53
+ return ["run", ...withArgs(cfg), "--python", "3.14", "python", "-c", "import pymupdf4llm, openpyxl, xlrd, PIL"];
146
54
  }
147
55
 
148
- // --- Subprocess argv builders ---
149
-
150
- export function warmArgs(cfg: DocToMdConfig): string[] {
151
- return ["run", "--with", `pymupdf4llm==${cfg.pymupdfVersion}`, "--python", "3.14", "python", "-c", "import pymupdf4llm"];
56
+ export function uvChildArgs(cfg: BackendConfig, script: string, mode: string): string[] {
57
+ return ["run", ...withArgs(cfg), "--python", "3.14", "python", script, mode];
152
58
  }
153
59
 
154
- export function convertArgs(cfg: DocToMdConfig, scriptPath: string, pdfPath: string): string[] {
155
- return ["run", "--with", `pymupdf4llm==${cfg.pymupdfVersion}`, "--python", "3.14", "python", scriptPath, pdfPath];
156
- }
60
+ export function pythonChildArgs(script: string, mode: string): string[] { return [script, mode]; }
157
61
 
158
62
  export function soffArgs(src: string, profileDir: string, outDir: string): string[] {
159
63
  return [
@@ -166,26 +70,43 @@ export function soffArgs(src: string, profileDir: string, outDir: string): strin
166
70
 
167
71
  // --- Subprocess runner ---
168
72
 
73
+ const LIVE = new Set<ChildProcess>();
74
+
75
+ function killTree(child: ChildProcess): void {
76
+ if (child.pid === undefined) return;
77
+ try {
78
+ if (process.platform === "win32") spawn("taskkill", ["/T", "/F", "/PID", String(child.pid)], { stdio: "ignore" });
79
+ else process.kill(-child.pid, "SIGKILL");
80
+ } catch { try { child.kill("SIGKILL"); } catch { /* already gone */ } }
81
+ }
82
+
83
+ process.on("exit", () => { for (const c of LIVE) killTree(c); });
84
+
169
85
  export async function runCapped(
170
86
  cmd: string,
171
87
  args: string[],
172
- opts: { timeoutMs: number; capBytes: number; env?: NodeJS.ProcessEnv; signal?: AbortSignal },
88
+ opts: { timeoutMs: number; capBytes: number; env?: NodeJS.ProcessEnv; signal?: AbortSignal; stdin?: string },
173
89
  ): Promise<CappedResult> {
174
90
  return new Promise((resolveP) => {
175
91
  let settled = false;
176
92
  let timedOut = false;
177
93
  let capped = false;
94
+ let timer: ReturnType<typeof setTimeout> | undefined;
95
+ let graceTimer: ReturnType<typeof setTimeout> | undefined;
178
96
  const stdoutChunks: Buffer[] = [];
179
97
  const stderrChunks: Buffer[] = [];
180
98
  let stdoutBytes = 0;
181
99
  let stderrBytes = 0;
182
100
 
183
- const child = spawn(cmd, args, { env: opts.env ?? process.env });
101
+ const child = spawn(cmd, args, { env: opts.env ?? process.env, detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"] });
102
+ LIVE.add(child);
184
103
 
185
104
  const finish = (code: number | null) => {
186
105
  if (settled) return;
187
106
  settled = true;
107
+ LIVE.delete(child);
188
108
  clearTimeout(timer);
109
+ clearTimeout(graceTimer);
189
110
  opts.signal?.removeEventListener("abort", onAbort);
190
111
  resolveP({
191
112
  stdout: Buffer.concat(stdoutChunks).toString("utf8"),
@@ -196,13 +117,20 @@ export async function runCapped(
196
117
  });
197
118
  };
198
119
 
199
- const kill = () => child.kill("SIGKILL");
120
+ const kill = () => {
121
+ killTree(child);
122
+ graceTimer ??= setTimeout(() => finish(null), KILL_GRACE_MS);
123
+ };
200
124
 
201
125
  const onAbort = () => { kill(); };
202
126
  if (opts.signal?.aborted) { kill(); }
203
127
  else opts.signal?.addEventListener("abort", onAbort);
204
128
 
205
- const timer = setTimeout(() => { timedOut = true; kill(); }, opts.timeoutMs);
129
+ timer = setTimeout(() => { timedOut = true; kill(); }, opts.timeoutMs);
130
+
131
+ child.stdin.on("error", (err: NodeJS.ErrnoException) => { if (err.code !== "EPIPE") throw err; });
132
+ child.stdin.write(opts.stdin ?? "");
133
+ child.stdin.end();
206
134
 
207
135
  child.stdout.on("data", (chunk: Buffer) => {
208
136
  if (capped) return;
@@ -252,56 +180,8 @@ export function findPackageRoot(startDir: string): string {
252
180
  }
253
181
  }
254
182
 
255
- function scriptPath(): string {
256
- return join(findPackageRoot(dirname(fileURLToPath(import.meta.url))), "scripts", "pdf_to_md.py");
257
- }
258
-
259
- // Deliberate local copy of the host package's formatSize - this core must stay pi-free (see test/layout.test.ts purity check).
260
- function formatSize(bytes: number): string {
261
- if (bytes < 1024) {
262
- return `${bytes}B`;
263
- } else if (bytes < 1024 * 1024) {
264
- return `${(bytes / 1024).toFixed(1)}KB`;
265
- } else {
266
- return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
267
- }
268
- }
269
-
270
- async function convertViaUv(cfg: DocToMdConfig, pdfPath: string, signal?: AbortSignal): Promise<string> {
271
- const r = await runCapped("uv", convertArgs(cfg, scriptPath(), pdfPath), {
272
- timeoutMs: cfg.convertTimeoutMs, capBytes: OUTPUT_MAX_BYTES, signal,
273
- });
274
- if (r.timedOut || r.capped || r.code !== 0) {
275
- throw new Error(`code=${r.code} timedOut=${r.timedOut} capped=${r.capped}: ${r.stderr.slice(0, 500)}`);
276
- }
277
- return r.stdout;
278
- }
279
-
280
- async function convertViaPython(cfg: DocToMdConfig, exe: string, pdfPath: string, signal?: AbortSignal): Promise<string> {
281
- const r = await runCapped(exe, pythonConvertArgs(scriptPath(), pdfPath), {
282
- timeoutMs: cfg.convertTimeoutMs, capBytes: OUTPUT_MAX_BYTES, signal,
283
- });
284
- if (r.timedOut || r.capped || r.code !== 0) {
285
- throw new Error(`code=${r.code} timedOut=${r.timedOut} capped=${r.capped}: ${r.stderr.slice(0, 500)}`);
286
- }
287
- return r.stdout;
288
- }
289
-
290
- async function convertViaUnpdf(pdfPath: string, cfg: DocToMdConfig, signal?: AbortSignal): Promise<string> {
291
- if (signal?.aborted) throw new Error("aborted");
292
- const buf = readFileSync(pdfPath);
293
- const work = (async () => {
294
- const pdf = await getDocumentProxy(new Uint8Array(buf));
295
- const { text } = await extractText(pdf, { mergePages: false });
296
- const md = pagesToMarkdown(text);
297
- if (Buffer.byteLength(md, "utf8") > OUTPUT_MAX_BYTES) throw new Error("unpdf output exceeded cap");
298
- return md;
299
- })();
300
- let timer: NodeJS.Timeout;
301
- const timeout = new Promise<never>((_, rej) => {
302
- timer = setTimeout(() => rej(new Error("unpdf conversion timed out")), cfg.convertTimeoutMs);
303
- });
304
- try { return await Promise.race([work, timeout]); } finally { clearTimeout(timer!); }
183
+ export function scriptPath(): string {
184
+ return join(findPackageRoot(dirname(fileURLToPath(import.meta.url))), "scripts", "doc_to_md.py");
305
185
  }
306
186
 
307
187
  // --- Backend resolver ---
@@ -309,10 +189,17 @@ async function convertViaUnpdf(pdfPath: string, cfg: DocToMdConfig, signal?: Abo
309
189
  export const PROBE_PROGRAM = `import sys
310
190
  print("PY", sys.version_info[0], sys.version_info[1])
311
191
  try:
312
- import pymupdf4llm
313
- print("PKG", pymupdf4llm.__version__)
192
+ # pymupdf4llm.__version__ >= 1.27.0
193
+ import pymupdf, pymupdf4llm
194
+ v = tuple(int(x) for x in pymupdf4llm.__version__.split(".")[:3])
195
+ print("PDF", "yes" if v >= (1, 27, 0) else "no")
314
196
  except Exception:
315
- print("PKG", "none")
197
+ print("PDF", "no")
198
+ try:
199
+ import openpyxl, xlrd, PIL
200
+ print("XLSX", "yes")
201
+ except Exception:
202
+ print("XLSX", "no")
316
203
  `;
317
204
  export const PROBE_TIMEOUT_MS = 5000;
318
205
 
@@ -321,19 +208,18 @@ export function probeArgs(): string[] {
321
208
  }
322
209
  export const PYTHON_CANDIDATES = ["python3", "python"] as const;
323
210
 
324
- export type BackendKind = "uv" | "python" | "venv" | "unpdf";
211
+ export type BackendKind = "uv" | "python" | "venv" | "none";
325
212
  export type Backend =
326
- | { kind: "uv" }
327
- | { kind: "python"; exe: string; version: string }
328
- | { kind: "venv"; exe: string; version: string }
213
+ | { kind: "uv"; pdf: true; xlsx: true }
214
+ | { kind: "python"; exe: string; pdf: boolean; xlsx: boolean }
215
+ | { kind: "venv"; exe: string; pdf: true; xlsx: true }
329
216
  | { kind: "none"; reason: string };
330
217
 
331
- export interface ProbeResult { major: number; minor: number; pkg: string | null; }
218
+ export interface ProbeResult { major: number; minor: number; pdf: boolean; xlsx: boolean; }
332
219
 
333
220
  export function parseProbeOutput(stdout: string): ProbeResult | null {
334
- const m = stdout.match(/^PY (\d+) (\d+)\r?\nPKG (\S+)\s*$/);
335
- if (!m) return null;
336
- return { major: Number(m[1]), minor: Number(m[2]), pkg: m[3] === "none" ? null : m[3] };
221
+ const m = stdout.match(/^PY (\d+) (\d+)\r?\nPDF (yes|no)\r?\nXLSX (yes|no)\s*$/);
222
+ return m ? { major: Number(m[1]), minor: Number(m[2]), pdf: m[3] === "yes", xlsx: m[4] === "yes" } : null;
337
223
  }
338
224
 
339
225
  export function meetsFloor(p: ProbeResult): boolean {
@@ -350,10 +236,6 @@ export function venvPython(venvDir: string, platform: NodeJS.Platform): string {
350
236
  return platform === "win32" ? join(venvDir, "Scripts", "python.exe") : join(venvDir, "bin", "python");
351
237
  }
352
238
 
353
- export function pythonConvertArgs(script: string, pdfPath: string): string[] {
354
- return [script, pdfPath];
355
- }
356
-
357
239
  export type RunFn = (cmd: string, args: string[], opts: { timeoutMs: number; capBytes: number; env?: NodeJS.ProcessEnv; signal?: AbortSignal }) => Promise<CappedResult>;
358
240
 
359
241
  export interface ResolverDeps {
@@ -369,15 +251,26 @@ export interface ResolverDeps {
369
251
 
370
252
  const tail = (s: string) => s.slice(-500).trim();
371
253
 
372
- export async function resolveBackend(cfg: DocToMdConfig, deps: ResolverDeps, signal?: AbortSignal): Promise<Backend> {
254
+ export async function resolveBackend(cfg: BackendConfig, deps: ResolverDeps, signal?: AbortSignal): Promise<Backend> {
373
255
  if (signal?.aborted) throw new Error("aborted");
374
- const warm = await deps.run("uv", warmArgs(cfg), { timeoutMs: cfg.warmTimeoutMs, capBytes: OUTPUT_MAX_BYTES, env: deps.env, signal });
256
+ const deadline = deps.now() + cfg.warmTimeoutMs;
257
+ const left = () => Math.max(1, deadline - deps.now());
258
+ const expired = (tmp?: string): Extract<Backend, { kind: "none" }> | null => {
259
+ if (deadline - deps.now() > 0) return null;
260
+ if (tmp) deps.rmrf(tmp);
261
+ return { kind: "none", reason: `backend discovery exceeded warmTimeoutMs (${cfg.warmTimeoutMs}ms) - raise warmTimeoutMs, or install uv` };
262
+ };
263
+ const warm = await deps.run("uv", warmArgs(cfg), { timeoutMs: left(), capBytes: OUTPUT_MAX_BYTES, env: deps.env, signal });
375
264
  if (signal?.aborted) throw new Error("aborted");
376
- if (warm.code === 0 && !warm.timedOut) return { kind: "uv" };
265
+ if (warm.code === 0 && !warm.timedOut) return { kind: "uv", pdf: true, xlsx: true };
377
266
  const uvAbsent = warm.code === null && !warm.timedOut; // spawn error (ENOENT)
378
267
 
379
- const probe = async (exe: string): Promise<ProbeResult | null> => {
380
- const r = await deps.run(exe, probeArgs(), { timeoutMs: PROBE_TIMEOUT_MS, capBytes: 4000, env: deps.env, signal });
268
+ type ProbeOutcome = ProbeResult | Extract<Backend, { kind: "none" }> | null;
269
+ const isDeadline = (result: ProbeOutcome): result is Extract<Backend, { kind: "none" }> => result !== null && "kind" in result;
270
+ const probe = async (exe: string, tmp?: string): Promise<ProbeOutcome> => {
271
+ const timeout = expired(tmp);
272
+ if (timeout) return timeout;
273
+ const r = await deps.run(exe, probeArgs(), { timeoutMs: Math.min(PROBE_TIMEOUT_MS, left()), capBytes: 4000, env: deps.env, signal });
381
274
  if (signal?.aborted) throw new Error("aborted");
382
275
  if (r.code !== 0 || r.timedOut) return null;
383
276
  return parseProbeOutput(r.stdout);
@@ -386,45 +279,57 @@ export async function resolveBackend(cfg: DocToMdConfig, deps: ResolverDeps, sig
386
279
  let eligible: { exe: string; version: string } | null = null;
387
280
  for (const exe of PYTHON_CANDIDATES) {
388
281
  const p = await probe(exe);
282
+ if (isDeadline(p)) return p;
389
283
  if (!p || !meetsFloor(p)) continue;
390
- if (p.pkg) return { kind: "python", exe, version: p.pkg };
284
+ if (p.pdf) return { kind: "python", exe, pdf: true, xlsx: p.xlsx };
391
285
  eligible ??= { exe, version: `${p.major}.${p.minor}` };
392
286
  }
393
287
 
394
- const venvDir = join(deps.cacheRoot, "pymupdf-venv");
288
+ const venvDir = join(deps.cacheRoot, VENV_DIR_NAME);
395
289
  const venvExe = venvPython(venvDir, deps.platform);
396
290
  const cached = await probe(venvExe);
397
- if (cached && meetsFloor(cached) && cached.pkg) return { kind: "venv", exe: venvExe, version: cached.pkg };
291
+ if (isDeadline(cached)) return cached;
292
+ if (cached && meetsFloor(cached) && cached.pdf && cached.xlsx) return { kind: "venv", exe: venvExe, pdf: true, xlsx: true };
398
293
 
399
294
  if (eligible) {
400
295
  const recheck = await probe(venvExe); // a competing process may have published since the first probe
401
- if (recheck && meetsFloor(recheck) && recheck.pkg) return { kind: "venv", exe: venvExe, version: recheck.pkg };
296
+ if (isDeadline(recheck)) return recheck;
297
+ if (recheck && meetsFloor(recheck) && recheck.pdf && recheck.xlsx) return { kind: "venv", exe: venvExe, pdf: true, xlsx: true };
402
298
  // Build in a sibling tmp dir without touching venvDir - a concurrent process can never probe a half-built venv.
403
299
  const tmp = `${venvDir}.tmp-${deps.pid}`;
404
- const deadline = deps.now() + cfg.warmTimeoutMs;
405
- const left = () => Math.max(1, deadline - deps.now());
406
300
  const bootFail = (stderr: string): Backend => {
407
301
  deps.rmrf(tmp);
408
302
  return { kind: "none", reason: `python ${eligible!.version} found but venv bootstrap failed: ${tail(stderr)} - install python3-venv, or uv` };
409
303
  };
304
+ const mkTimeout = expired(tmp);
305
+ if (mkTimeout) return mkTimeout;
410
306
  const mk = await deps.run(eligible.exe, ["-m", "venv", tmp], { timeoutMs: left(), capBytes: OUTPUT_MAX_BYTES, env: deps.env, signal });
411
307
  if (signal?.aborted) throw new Error("aborted");
412
308
  if (mk.code !== 0 || mk.timedOut) return bootFail(mk.stderr);
413
- const pip = await deps.run(venvPython(tmp, deps.platform), ["-m", "pip", "install", `pymupdf4llm==${cfg.pymupdfVersion}`], { timeoutMs: left(), capBytes: OUTPUT_MAX_BYTES, env: deps.env, signal });
309
+ const pipTimeout = expired(tmp);
310
+ if (pipTimeout) return pipTimeout;
311
+ const pip = await deps.run(venvPython(tmp, deps.platform), pipInstallArgs(cfg), { timeoutMs: left(), capBytes: OUTPUT_MAX_BYTES, env: deps.env, signal });
414
312
  if (signal?.aborted) throw new Error("aborted");
415
313
  if (pip.code !== 0 || pip.timedOut) return bootFail(pip.stderr);
416
314
  const publish = (): boolean => {
417
315
  try { deps.rename(tmp, venvDir); return true; } catch { return false; }
418
316
  };
419
- if (publish()) return { kind: "venv", exe: venvExe, version: cfg.pymupdfVersion };
317
+ if (publish()) {
318
+ deps.rmrf(join(deps.cacheRoot, LEGACY_VENV_DIR_NAME));
319
+ return { kind: "venv", exe: venvExe, pdf: true, xlsx: true };
320
+ }
420
321
  // Rename failed - a competing process may have published first, or venvDir holds a stale/broken dir.
421
- const winner = await probe(venvExe);
422
- if (winner && meetsFloor(winner) && winner.pkg) {
322
+ const winner = await probe(venvExe, tmp);
323
+ if (isDeadline(winner)) return winner;
324
+ if (winner && meetsFloor(winner) && winner.pdf && winner.xlsx) {
423
325
  deps.rmrf(tmp); // healthy winner - clean up our loser
424
- return { kind: "venv", exe: venvExe, version: winner.pkg };
326
+ return { kind: "venv", exe: venvExe, pdf: true, xlsx: true };
425
327
  }
426
328
  deps.rmrf(venvDir); // unhealthy/absent dest - clear it and retry the rename once
427
- if (publish()) return { kind: "venv", exe: venvExe, version: cfg.pymupdfVersion };
329
+ if (publish()) {
330
+ deps.rmrf(join(deps.cacheRoot, LEGACY_VENV_DIR_NAME));
331
+ return { kind: "venv", exe: venvExe, pdf: true, xlsx: true };
332
+ }
428
333
  return bootFail("rename after competing bootstrap");
429
334
  }
430
335
 
@@ -448,7 +353,7 @@ function realDeps(): ResolverDeps {
448
353
  };
449
354
  }
450
355
 
451
- export function getBackend(cfg: DocToMdConfig, deps?: ResolverDeps, signal?: AbortSignal): Promise<Backend> {
356
+ export function getBackend(cfg: BackendConfig, deps?: ResolverDeps, signal?: AbortSignal): Promise<Backend> {
452
357
  if (!backendPromise) {
453
358
  const promise = resolveBackend(cfg, deps ?? realDeps(), signal);
454
359
  backendPromise = promise;
@@ -463,7 +368,7 @@ export function resetBackendCacheForTests(): void {
463
368
  }
464
369
 
465
370
  export async function convertOffice(
466
- cfg: DocToMdConfig,
371
+ sofficeTimeoutMs: number,
467
372
  src: string,
468
373
  signal?: AbortSignal,
469
374
  run: RunFn = runCapped,
@@ -477,7 +382,7 @@ export async function convertOffice(
477
382
  };
478
383
  try {
479
384
  const env = { ...process.env, SAL_USE_VCLPLUGIN: "svp", OOO_DISABLE_RECOVERY: "1", SAL_NO_MOUSEGRABS: "1" };
480
- const r = await run("soffice", soffArgs(src, profileDir, outDir), { timeoutMs: cfg.sofficeTimeoutMs, capBytes: OUTPUT_MAX_BYTES, env, signal });
385
+ const r = await run("soffice", soffArgs(src, profileDir, outDir), { timeoutMs: sofficeTimeoutMs, capBytes: OUTPUT_MAX_BYTES, env, signal });
481
386
  if (signal?.aborted) throw new Error("aborted");
482
387
  if (r.code === null && !r.timedOut) {
483
388
  throw new Error("LibreOffice (soffice) is required to convert .docx/.pptx but was not found on PATH. Install LibreOffice or convert the file to PDF first.");
@@ -493,99 +398,154 @@ export async function convertOffice(
493
398
  } catch (e) { cleanup(); throw e; }
494
399
  }
495
400
 
496
- export interface PipelineSeams {
497
- backend: (cfg: DocToMdConfig) => Promise<Backend>;
498
- convertPymupdf: (cfg: DocToMdConfig, backend: Backend, pdfPath: string, signal?: AbortSignal) => Promise<string>;
499
- convertUnpdf: (pdfPath: string, cfg: DocToMdConfig, signal?: AbortSignal) => Promise<string>;
500
- }
501
401
 
502
- export async function runPipeline(
503
- cfg: DocToMdConfig,
504
- inputPath: string,
505
- type: InputType,
506
- signal?: AbortSignal,
507
- seams?: Partial<PipelineSeams>,
508
- ): Promise<{ markdown: string; engine: Engine; degraded: boolean; fallbackReason?: string; backend: BackendKind; pymupdfVersion?: string }> {
509
- const s: PipelineSeams = {
510
- backend: (c) => getBackend(c, undefined, signal),
511
- convertPymupdf: (c, b, p, sig) => (b.kind === "uv" ? convertViaUv(c, p, sig) : convertViaPython(c, (b as { exe: string }).exe, p, sig)),
512
- convertUnpdf: convertViaUnpdf,
513
- ...seams,
514
- };
515
- let pdfPath = inputPath;
516
- let cleanup: (() => void) | null = null;
517
- if (type === "docx" || type === "pptx") {
518
- const o = await convertOffice(cfg, inputPath, signal);
519
- pdfPath = o.pdfPath;
520
- cleanup = o.cleanup;
521
- }
522
- try {
523
- const backend = await s.backend(cfg);
524
- if (backend.kind === "none") {
525
- return { markdown: await s.convertUnpdf(pdfPath, cfg, signal), engine: "unpdf", degraded: true, fallbackReason: backend.reason, backend: "unpdf" };
526
- }
527
- try {
528
- const markdown = await s.convertPymupdf(cfg, backend, pdfPath, signal);
529
- return {
530
- markdown, engine: "pymupdf4llm", degraded: false, backend: backend.kind,
531
- ...(backend.kind === "python" || backend.kind === "venv" ? { pymupdfVersion: backend.version } : {}),
532
- };
533
- } catch (e) {
534
- const msg = e instanceof Error ? e.message : String(e);
535
- return {
536
- markdown: await s.convertUnpdf(pdfPath, cfg, signal), engine: "unpdf", degraded: true,
537
- fallbackReason: `pymupdf4llm (${backend.kind}) conversion failed: ${msg} - fell back to unpdf`, backend: "unpdf",
538
- };
539
- }
540
- } finally {
541
- cleanup?.();
402
+ // --- Conversion tiers and bundle orchestration ---
403
+
404
+ export const DEGRADED_TEXT = "PyMuPDF text extraction - layout/tables not preserved";
405
+ export const DEGRADED_UNPDF = "unpdf text extraction - structure not preserved";
406
+ export const EXCEL_REMEDY = "Remedy: install uv, or pip install openpyxl xlrd pillow";
407
+
408
+ export type Mode = "info" | "pdf-primary" | "pdf-fallback" | "xlsx" | "pdf-text";
409
+ export interface TierJson { markdown?: string; pages?: number[]; pageCount?: number; emptyPages?: number[]; failedPages?: { page: number; error: string }[]; notes?: string[]; images?: { sheetIndex: number; file: string }[]; metadata?: Record<string, string>; toc?: [number, string, number][]; sheets?: SheetInfo[]; }
410
+ export type TierResult = { ok: true; json: TierJson } | { ok: false; reason: string; detail?: string } | { ok: false; userError: string; pageCount?: number };
411
+
412
+ export interface PipelineSeams {
413
+ backend: (cfg: BackendConfig) => Promise<Backend>;
414
+ runTier: (mode: Mode, childOptions: Record<string, unknown>, bundle: Pick<Bundle, "stagingDir">, signal: AbortSignal | undefined, timeoutMs: number, backend: Backend) => Promise<TierResult>;
415
+ }
416
+
417
+ export function resolveUnpdfWorker(candidates: string[], exists: (path: string) => boolean): string {
418
+ for (const candidate of candidates) if (exists(candidate)) return candidate;
419
+ throw new Error("unpdf worker not found");
420
+ }
421
+
422
+ function unpdfWorkerPath(): string {
423
+ const here = dirname(fileURLToPath(import.meta.url));
424
+ return resolveUnpdfWorker([
425
+ join(findPackageRoot(here), "dist", "lib", "unpdf-worker.js"),
426
+ join(here, "unpdf-worker.js"),
427
+ join(here, "unpdf-worker.ts"),
428
+ ], existsSync);
429
+ }
430
+
431
+ async function runTierReal(mode: Mode, childOptions: Record<string, unknown>, _b: Pick<Bundle, "stagingDir">, signal: AbortSignal | undefined, timeoutMs: number, backend: Backend): Promise<TierResult> {
432
+ const cfg = { pymupdfVersion: String(childOptions.pymupdfVersion), warmTimeoutMs: 0 };
433
+ let cmd: string, args: string[];
434
+ if (mode === "pdf-text" || (mode === "info" && backend.kind === "none")) { cmd = process.execPath; args = [unpdfWorkerPath(), mode]; }
435
+ else if (backend.kind === "uv") { cmd = "uv"; args = uvChildArgs(cfg, scriptPath(), mode); }
436
+ else if (backend.kind === "python" || backend.kind === "venv") { cmd = backend.exe; args = pythonChildArgs(scriptPath(), mode); }
437
+ else return { ok: false, reason: backend.reason };
438
+ const r = await runCapped(cmd, args, { timeoutMs, capBytes: Number(childOptions.maxOutputBytes), signal, stdin: JSON.stringify(childOptions) });
439
+ if (signal?.aborted) return { ok: false, reason: "aborted" };
440
+ if (r.timedOut) return { ok: false, reason: `timeout after ${timeoutMs}ms` };
441
+ if (r.capped) return { ok: false, reason: "output exceeded maxOutputBytes" };
442
+ const detail = r.stderr.slice(-300).trim();
443
+ let json: TierJson & { error?: string };
444
+ try { json = JSON.parse(r.stdout); } catch {
445
+ return r.code === 0 ? { ok: false, reason: "invalid-json" } : { ok: false, reason: `exit ${r.code ?? -1}`, ...(detail ? { detail } : {}) };
542
446
  }
447
+ if (r.code === 3) return { ok: false, userError: json.error ?? "user error", ...(json.pageCount !== undefined ? { pageCount: json.pageCount } : {}) };
448
+ if (r.code !== 0) return { ok: false, reason: `exit ${r.code ?? -1}`, ...(detail ? { detail } : {}) };
449
+ return { ok: true, json };
543
450
  }
544
451
 
545
- // --- Public entry point ---
452
+ export interface ConvertOutcome { output: string; details: DocToMdDetails; }
453
+ export interface DocToMdDetails extends HandleData {
454
+ path: string;
455
+ backend: BackendKind;
456
+ pymupdfVersion: string;
457
+ inputType: InputType;
458
+ file: string;
459
+ outputDir: string;
460
+ }
546
461
 
547
- export interface ConvertOutcome {
548
- output: string;
549
- details: DocToMdDetails;
462
+ function detailSuffix(r: Extract<TierResult, { ok: false; reason: string }>): string {
463
+ return r.detail ? ` (${r.detail})` : "";
550
464
  }
551
465
 
552
- export async function convertDocument(
553
- path: string,
554
- cfg: DocToMdConfig,
555
- signal?: AbortSignal,
556
- seams?: Partial<PipelineSeams>,
557
- ): Promise<ConvertOutcome> {
558
- const inputPath = resolve(path);
466
+ export async function convertDocument(o: DocToMdOptions, signal?: AbortSignal, seams?: Partial<PipelineSeams>): Promise<ConvertOutcome> {
467
+ const s: PipelineSeams = { backend: (c) => getBackend(c, undefined, signal), runTier: runTierReal, ...seams };
468
+ const inputPath = resolve(o.path);
559
469
  const st = statSync(inputPath, { throwIfNoEntry: false });
560
- if (!st || !st.isFile()) throw new Error(`Not a readable file: ${path}`);
470
+ if (!st || !st.isFile()) throw new Error(`Not a readable file: ${o.path}`);
561
471
  const type = classifyInput(inputPath);
562
- const { markdown, engine, degraded, fallbackReason, backend, pymupdfVersion } = await runPipeline(cfg, inputPath, type, signal, seams);
563
- // The formatter's output carries no trailing newline (the CLI appends exactly one); engines vary here, so normalize once.
564
- const body = withMarker(markdown, degraded).replace(/\s+$/, "");
565
- const { spill, bytes, lines } = applyGate(body);
566
- const details: DocToMdDetails = { path: inputPath, inputType: type, engine, backend, ...(pymupdfVersion !== undefined ? { pymupdfVersion } : {}), degraded, bytes, lines, spilled: spill };
567
- const header: string[] = [
568
- `Source: ${inputPath}`,
569
- `Type: ${type} Engine: ${engine}${degraded ? " (degraded fallback)" : ""}`,
570
- ];
571
- if (fallbackReason) header.push(`Fallback-Reason: ${fallbackReason}`);
572
- // The formatter's output carries no trailing newline (the CLI appends exactly one); strip once more on the composed string, since an empty body leaves a trailing blank-separator newline.
573
- const compose = (parts: string[]) => parts.join("\n").replace(/\n+$/, "");
574
- if (!spill) {
575
- return { output: compose([...header, `Length: ${bytes} bytes, ${lines} lines`, "", body]), details };
576
- }
577
- const file = spillToFile(inputPath, body);
578
- return {
579
- output: compose([
580
- ...header,
581
- `Body: ${formatSize(bytes)} across ${lines} lines — written to file (too large to inline)`,
582
- `Saved-To: ${file}`,
583
- "",
584
- "Read slices of this file with the read tool (offset/limit) or grep it; do not read the whole file unless you must. Markdown is grep-able by heading (^#).",
585
- "",
586
- "----- preview (first 60 lines) -----",
587
- buildPreview(body),
588
- ]),
589
- details: { ...details, spilled: true, file },
590
- };
472
+ const isExcel = type === "xlsx" || type === "xls";
473
+ if (isExcel && o.pages) throw new Error("--pages does not apply to spreadsheets: worksheets have no stable page numbering");
474
+ const backend = await s.backend({ pymupdfVersion: o.pymupdfVersion, warmTimeoutMs: o.warmTimeoutMs });
475
+ if (isExcel && (backend.kind === "none" || !backend.xlsx)) throw new Error(`Excel conversion needs a Python backend with openpyxl, xlrd and pillow (${backend.kind === "none" ? backend.reason : `${backend.kind} lacks the Excel packages`}). ${EXCEL_REMEDY}`);
476
+ const stem = sanitizeStem(basename(inputPath, extname(inputPath)));
477
+ const b = openBundle(o.outputDir ? resolve(o.outputDir) : tempBundleRoot(), stem, o.overwrite);
478
+ let office: { pdfPath: string; cleanup: () => void } | null = null;
479
+ try {
480
+ let pdfPath = inputPath;
481
+ if (type === "docx" || type === "pptx") { office = await convertOffice(o.sofficeTimeoutMs, inputPath, signal); pdfPath = office.pdfPath; }
482
+ const base = { path: pdfPath, pages: o.pages, stagingDir: b.stagingDir, imageDpi: o.imageDpi, imageFormat: o.imageFormat, maxCellsPerSheet: o.maxCellsPerSheet, maxOutputBytes: o.maxOutputBytes, pymupdfVersion: o.pymupdfVersion };
483
+ let tier: Tier, engine: Engine, json: TierJson, degraded: string | null = null, fallbackReason: string | null = null;
484
+ if (isExcel) {
485
+ const r = await s.runTier("xlsx", base, b, signal, o.excelTimeoutMs, backend);
486
+ if (!r.ok) {
487
+ if ("userError" in r) throw new Error(r.userError);
488
+ const remedy = r.reason.startsWith("timeout after") || r.reason === "output exceeded maxOutputBytes" ? ". Remedy: raise excelTimeoutMs or lower maxCellsPerSheet" : "";
489
+ throw new Error(`Excel conversion failed: ${r.reason}${detailSuffix(r)}${remedy}`);
490
+ }
491
+ publishSheetImages(b);
492
+ tier = "excel"; engine = type === "xls" ? "xlrd" : "openpyxl"; json = r.json;
493
+ } else if (backend.kind === "none") {
494
+ const r = await s.runTier("pdf-text", base, b, signal, o.primaryTimeoutMs, backend);
495
+ if (!r.ok) throw new Error("userError" in r ? r.userError : `Conversion failed: unpdf ${r.reason}${detailSuffix(r)}`);
496
+ tier = "unpdf"; engine = "unpdf"; json = r.json; degraded = DEGRADED_UNPDF;
497
+ } else {
498
+ const p = await s.runTier("pdf-primary", base, b, signal, o.primaryTimeoutMs, backend);
499
+ const kept = publishStaged(b);
500
+ if (p.ok) { tier = "primary"; engine = "pymupdf4llm"; json = p.json; }
501
+ else if ("userError" in p) throw new Error(p.userError);
502
+ else {
503
+ if (signal?.aborted) throw new Error("aborted");
504
+ const keepPages = Object.fromEntries([...kept.entries()].map(([k, v]) => [String(k), v]));
505
+ const f = await s.runTier("pdf-fallback", { ...base, keepPages }, b, signal, o.fallbackTimeoutMs, backend);
506
+ publishStaged(b);
507
+ if (!f.ok) throw new Error("userError" in f ? f.userError : `Conversion failed: primary ${p.reason}; fallback ${f.reason}${detailSuffix(f)}`);
508
+ tier = "fallback"; engine = "pymupdf-text"; json = f.json; degraded = DEGRADED_TEXT; fallbackReason = `primary ${p.reason}`;
509
+ }
510
+ }
511
+ let body = rewriteImageLinks(json.markdown ?? "", b.sourceMap);
512
+ validateImageLinks(body, b.manifest);
513
+ const head: string[] = [];
514
+ if (degraded) head.push(`Degraded: ${degraded}`);
515
+ if (fallbackReason) head.push(`Fallback-Reason: ${fallbackReason}`);
516
+ if (json.failedPages?.length) head.push(`Failed pages: ${json.failedPages.map((f) => `${f.page} (${f.error})`).join("; ")}`);
517
+ if (json.emptyPages?.length) head.push(`Empty pages: ${json.emptyPages.join(", ")}`);
518
+ for (const n of json.notes ?? []) head.push(`Notes: ${n}`);
519
+ const markdown = (head.length ? `${head.join("\n")}\n\n` : "") + body;
520
+ commitBundle(b, markdown);
521
+ const outline = scanOutline(markdown, o.outlineMaxEntries);
522
+ const details: DocToMdDetails = { path: inputPath, backend: backend.kind, pymupdfVersion: o.pymupdfVersion, inputType: type, file: b.mdPath, outputDir: b.root, savedTo: b.mdPath, imagesDir: b.imagesDir, type, engine, tier, pageCount: json.pageCount ?? null, pages: o.pages, imageCount: b.manifest.size, bytes: Buffer.byteLength(markdown, "utf8"), lines: markdown.split("\n").length, degraded, fallbackReason, failedPages: (json.failedPages ?? []).map((f) => f.page), emptyPages: json.emptyPages ?? [], notes: json.notes ?? [], outline: outline.entries, outlineTotal: outline.total };
523
+ return { output: formatHandle(details), details };
524
+ } catch (e) { abortBundle(b); throw e; }
525
+ finally { office?.cleanup(); }
526
+ }
527
+
528
+ export async function inspectDocument(o: DocToMdOptions, signal?: AbortSignal, seams?: Partial<PipelineSeams>): Promise<{ output: string; details: InfoData }> {
529
+ const s: PipelineSeams = { backend: (c) => getBackend(c, undefined, signal), runTier: runTierReal, ...seams };
530
+ const inputPath = resolve(o.path);
531
+ const st = statSync(inputPath, { throwIfNoEntry: false });
532
+ if (!st || !st.isFile()) throw new Error(`Not a readable file: ${o.path}`);
533
+ const type = classifyInput(inputPath);
534
+ const isExcel = type === "xlsx" || type === "xls";
535
+ const backend = await s.backend({ pymupdfVersion: o.pymupdfVersion, warmTimeoutMs: o.warmTimeoutMs });
536
+ if (isExcel && (backend.kind === "none" || !backend.xlsx)) throw new Error(`Excel inspection needs a Python backend with openpyxl, xlrd and pillow. ${EXCEL_REMEDY}`);
537
+ let office: { pdfPath: string; cleanup: () => void } | null = null;
538
+ try {
539
+ let path = inputPath;
540
+ if (type === "docx" || type === "pptx") { office = await convertOffice(o.sofficeTimeoutMs, inputPath, signal); path = office.pdfPath; }
541
+ const r = await s.runTier("info", { path, maxOutputBytes: o.maxOutputBytes, pymupdfVersion: o.pymupdfVersion }, { stagingDir: "" }, signal, isExcel ? o.excelTimeoutMs : o.fallbackTimeoutMs, backend);
542
+ if (!r.ok) {
543
+ if ("userError" in r) throw new Error(r.userError);
544
+ if (isExcel && (r.reason.startsWith("timeout after") || r.reason === "output exceeded maxOutputBytes")) throw new Error(`Excel inspection failed: ${r.reason}. Remedy: raise excelTimeoutMs or lower maxCellsPerSheet${detailSuffix(r)}`);
545
+ throw new Error(`Inspection failed: ${r.reason}${detailSuffix(r)}`);
546
+ }
547
+ const toc: TocEntry[] = (r.json.toc ?? []).map(([level, title, page]) => ({ level, title, page }));
548
+ const details: InfoData = { type, backend: backend.kind, pageCount: r.json.pageCount ?? null, metadata: r.json.metadata ?? {}, toc: toc.slice(0, o.outlineMaxEntries), tocTotal: toc.length, sheets: r.json.sheets ? r.json.sheets.slice(0, o.outlineMaxEntries) : null, sheetsTotal: r.json.sheets?.length ?? 0 };
549
+ return { output: formatInfoHandle(details, o.outlineMaxEntries), details };
550
+ } finally { office?.cleanup(); }
591
551
  }