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.
- package/CHANGELOG.md +24 -0
- package/README.md +36 -10
- package/dist/bin/pi-quiver.js +702 -259
- package/dist/lib/unpdf-worker.js +58 -0
- package/extensions/doc_to_md.ts +52 -62
- package/lib/doc-to-md-bundle.ts +139 -0
- package/lib/doc-to-md-core.ts +261 -301
- package/lib/doc-to-md-handle.ts +108 -0
- package/lib/doc-to-md-options.ts +186 -0
- package/lib/unpdf-worker.ts +54 -0
- package/package.json +6 -4
- package/scripts/doc_to_md.py +369 -0
- package/scripts/pdf_to_md.py +0 -30
package/lib/doc-to-md-core.ts
CHANGED
|
@@ -1,46 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* doc-to-md core (pi-free)
|
|
3
3
|
*
|
|
4
|
-
* Converts
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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,
|
|
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 {
|
|
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
|
|
23
|
-
export
|
|
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
|
|
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
|
-
// ---
|
|
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
|
-
|
|
126
|
-
|
|
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
|
|
135
|
-
|
|
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
|
|
141
|
-
|
|
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
|
-
|
|
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
|
|
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 = () =>
|
|
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
|
-
|
|
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", "
|
|
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
|
-
|
|
313
|
-
|
|
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("
|
|
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" | "
|
|
211
|
+
export type BackendKind = "uv" | "python" | "venv" | "none";
|
|
325
212
|
export type Backend =
|
|
326
|
-
| { kind: "uv" }
|
|
327
|
-
| { kind: "python"; exe: string;
|
|
328
|
-
| { kind: "venv"; exe: 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;
|
|
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?\
|
|
335
|
-
|
|
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:
|
|
254
|
+
export async function resolveBackend(cfg: BackendConfig, deps: ResolverDeps, signal?: AbortSignal): Promise<Backend> {
|
|
373
255
|
if (signal?.aborted) throw new Error("aborted");
|
|
374
|
-
const
|
|
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
|
-
|
|
380
|
-
|
|
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.
|
|
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,
|
|
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 (
|
|
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 (
|
|
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
|
|
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())
|
|
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 (
|
|
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,
|
|
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())
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
-
|
|
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
|
-
|
|
548
|
-
|
|
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
|
-
|
|
554
|
-
|
|
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
|
|
563
|
-
|
|
564
|
-
const
|
|
565
|
-
|
|
566
|
-
const
|
|
567
|
-
const
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
""
|
|
586
|
-
"
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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
|
}
|