pi-quiver 6.4.0 → 6.5.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.
@@ -3,11 +3,11 @@
3
3
  *
4
4
  * Converts local PDF, DOCX, PPTX, XLSX, and XLS documents into disk bundles
5
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.
6
+ * then unpdf when no Python backend exists. DOCX converts directly in the Python child (mammoth, python-docx fallback)
7
+ * and falls back to headless soffice -> PDF; PPTX always goes through soffice.
8
8
  */
9
9
 
10
- import { existsSync, mkdtempSync, renameSync, rmSync, statSync } from "node:fs";
10
+ import { existsSync, mkdtempSync, readdirSync, renameSync, rmSync, statSync } from "node:fs";
11
11
  import { type ChildProcess, spawn } from "node:child_process";
12
12
  import { homedir, tmpdir } from "node:os";
13
13
  import { basename, dirname, extname, join, resolve } from "node:path";
@@ -22,10 +22,10 @@ export type { Engine, HandleData, InfoData, OutlineEntry, SheetInfo, Tier, TocEn
22
22
 
23
23
  // --- Types ---
24
24
 
25
- export const PACKAGE_PINS = { pymupdf4llm: TUNABLE_DEFAULTS.pymupdfVersion, openpyxl: "3.1.5", xlrd: "2.0.2", pillow: "12.3.0" } as const;
25
+ export const PACKAGE_PINS = { pymupdf4llm: TUNABLE_DEFAULTS.pymupdfVersion, openpyxl: "3.1.5", xlrd: "2.0.2", pillow: "12.3.0", mammoth: "1.13.0", markdownify: "1.2.3", "python-docx": "1.2.0" } as const;
26
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";
27
+ export const VENV_DIR_NAME = "doc-to-md-venv-v3";
28
+ export const LEGACY_VENV_DIR_NAMES = ["pymupdf-venv", "doc-to-md-venv-v2"] as const;
29
29
  const STDERR_CAP = 1_000_000;
30
30
  export const OUTPUT_MAX_BYTES = 20_000_000;
31
31
  export const EXCEL_PDF_FILTER = 'pdf:calc_pdf_Export:{"SinglePageSheets":{"type":"boolean","value":"true"}}';
@@ -42,16 +42,14 @@ export interface CappedResult {
42
42
 
43
43
  // --- Subprocess argv builders ---
44
44
 
45
- function withArgs(cfg: BackendConfig): string[] {
46
- return ["--with", `pymupdf4llm==${cfg.pymupdfVersion}`, "--with", `openpyxl==${PACKAGE_PINS.openpyxl}`, "--with", `xlrd==${PACKAGE_PINS.xlrd}`, "--with", `pillow==${PACKAGE_PINS.pillow}`];
47
- }
45
+ const pinSpecs = (cfg: BackendConfig) => [`pymupdf4llm==${cfg.pymupdfVersion}`, `openpyxl==${PACKAGE_PINS.openpyxl}`, `xlrd==${PACKAGE_PINS.xlrd}`, `pillow==${PACKAGE_PINS.pillow}`, `mammoth==${PACKAGE_PINS.mammoth}`, `markdownify==${PACKAGE_PINS.markdownify}`, `python-docx==${PACKAGE_PINS["python-docx"]}`];
48
46
 
49
- export function pipInstallArgs(cfg: BackendConfig): string[] {
50
- return ["-m", "pip", "install", `pymupdf4llm==${cfg.pymupdfVersion}`, `openpyxl==${PACKAGE_PINS.openpyxl}`, `xlrd==${PACKAGE_PINS.xlrd}`, `pillow==${PACKAGE_PINS.pillow}`];
51
- }
47
+ function withArgs(cfg: BackendConfig): string[] { return pinSpecs(cfg).flatMap((spec) => ["--with", spec]); }
48
+
49
+ export function pipInstallArgs(cfg: BackendConfig): string[] { return ["-m", "pip", "install", ...pinSpecs(cfg)]; }
52
50
 
53
51
  export function warmArgs(cfg: BackendConfig): string[] {
54
- return ["run", ...withArgs(cfg), "--python", "3.14", "python", "-c", "import pymupdf4llm, openpyxl, xlrd, PIL"];
52
+ return ["run", ...withArgs(cfg), "--python", "3.14", "python", "-c", "import pymupdf4llm, openpyxl, xlrd, PIL, mammoth, markdownify, docx"];
55
53
  }
56
54
 
57
55
  export function uvChildArgs(cfg: BackendConfig, script: string, mode: string): string[] {
@@ -201,6 +199,11 @@ try:
201
199
  print("XLSX", "yes")
202
200
  except Exception:
203
201
  print("XLSX", "no")
202
+ try:
203
+ import mammoth, markdownify, docx
204
+ print("DOCX", "yes")
205
+ except Exception:
206
+ print("DOCX", "no")
204
207
  `;
205
208
  export const PROBE_TIMEOUT_MS = 5000;
206
209
 
@@ -211,16 +214,16 @@ export const PYTHON_CANDIDATES = ["python3", "python"] as const;
211
214
 
212
215
  export type BackendKind = "uv" | "python" | "venv" | "none";
213
216
  export type Backend =
214
- | { kind: "uv"; pdf: true; xlsx: true }
215
- | { kind: "python"; exe: string; pdf: boolean; xlsx: boolean }
216
- | { kind: "venv"; exe: string; pdf: true; xlsx: true }
217
+ | { kind: "uv"; pdf: true; xlsx: true; docx: true }
218
+ | { kind: "python"; exe: string; pdf: boolean; xlsx: boolean; docx: boolean }
219
+ | { kind: "venv"; exe: string; pdf: true; xlsx: true; docx: true }
217
220
  | { kind: "none"; reason: string };
218
221
 
219
- export interface ProbeResult { major: number; minor: number; pdf: boolean; xlsx: boolean; }
222
+ export interface ProbeResult { major: number; minor: number; pdf: boolean; xlsx: boolean; docx: boolean; }
220
223
 
221
224
  export function parseProbeOutput(stdout: string): ProbeResult | null {
222
- const m = stdout.match(/^PY (\d+) (\d+)\r?\nPDF (yes|no)\r?\nXLSX (yes|no)\s*$/);
223
- return m ? { major: Number(m[1]), minor: Number(m[2]), pdf: m[3] === "yes", xlsx: m[4] === "yes" } : null;
225
+ const m = stdout.match(/^PY (\d+) (\d+)\r?\nPDF (yes|no)\r?\nXLSX (yes|no)\r?\nDOCX (yes|no)\s*$/);
226
+ return m ? { major: Number(m[1]), minor: Number(m[2]), pdf: m[3] === "yes", xlsx: m[4] === "yes", docx: m[5] === "yes" } : null;
224
227
  }
225
228
 
226
229
  export function meetsFloor(p: ProbeResult): boolean {
@@ -263,7 +266,7 @@ export async function resolveBackend(cfg: BackendConfig, deps: ResolverDeps, sig
263
266
  };
264
267
  const warm = await deps.run("uv", warmArgs(cfg), { timeoutMs: left(), capBytes: OUTPUT_MAX_BYTES, env: deps.env, signal });
265
268
  if (signal?.aborted) throw new Error("aborted");
266
- if (warm.code === 0 && !warm.timedOut) return { kind: "uv", pdf: true, xlsx: true };
269
+ if (warm.code === 0 && !warm.timedOut) return { kind: "uv", pdf: true, xlsx: true, docx: true };
267
270
  const uvAbsent = warm.code === null && !warm.timedOut; // spawn error (ENOENT)
268
271
 
269
272
  type ProbeOutcome = ProbeResult | Extract<Backend, { kind: "none" }> | null;
@@ -282,20 +285,21 @@ export async function resolveBackend(cfg: BackendConfig, deps: ResolverDeps, sig
282
285
  const p = await probe(exe);
283
286
  if (isDeadline(p)) return p;
284
287
  if (!p || !meetsFloor(p)) continue;
285
- if (p.pdf) return { kind: "python", exe, pdf: true, xlsx: p.xlsx };
288
+ if (p.pdf) return { kind: "python", exe, pdf: true, xlsx: p.xlsx, docx: p.docx };
286
289
  eligible ??= { exe, version: `${p.major}.${p.minor}` };
287
290
  }
288
291
 
292
+ const healthy = (p: ProbeResult) => meetsFloor(p) && p.pdf && p.xlsx && p.docx;
289
293
  const venvDir = join(deps.cacheRoot, VENV_DIR_NAME);
290
294
  const venvExe = venvPython(venvDir, deps.platform);
291
295
  const cached = await probe(venvExe);
292
296
  if (isDeadline(cached)) return cached;
293
- if (cached && meetsFloor(cached) && cached.pdf && cached.xlsx) return { kind: "venv", exe: venvExe, pdf: true, xlsx: true };
297
+ if (cached && healthy(cached)) return { kind: "venv", exe: venvExe, pdf: true, xlsx: true, docx: true };
294
298
 
295
299
  if (eligible) {
296
300
  const recheck = await probe(venvExe); // a competing process may have published since the first probe
297
301
  if (isDeadline(recheck)) return recheck;
298
- if (recheck && meetsFloor(recheck) && recheck.pdf && recheck.xlsx) return { kind: "venv", exe: venvExe, pdf: true, xlsx: true };
302
+ if (recheck && healthy(recheck)) return { kind: "venv", exe: venvExe, pdf: true, xlsx: true, docx: true };
299
303
  // Build in a sibling tmp dir without touching venvDir - a concurrent process can never probe a half-built venv.
300
304
  const tmp = `${venvDir}.tmp-${deps.pid}`;
301
305
  const bootFail = (stderr: string): Backend => {
@@ -316,20 +320,20 @@ export async function resolveBackend(cfg: BackendConfig, deps: ResolverDeps, sig
316
320
  try { deps.rename(tmp, venvDir); return true; } catch { return false; }
317
321
  };
318
322
  if (publish()) {
319
- deps.rmrf(join(deps.cacheRoot, LEGACY_VENV_DIR_NAME));
320
- return { kind: "venv", exe: venvExe, pdf: true, xlsx: true };
323
+ for (const legacy of LEGACY_VENV_DIR_NAMES) deps.rmrf(join(deps.cacheRoot, legacy));
324
+ return { kind: "venv", exe: venvExe, pdf: true, xlsx: true, docx: true };
321
325
  }
322
326
  // Rename failed - a competing process may have published first, or venvDir holds a stale/broken dir.
323
327
  const winner = await probe(venvExe, tmp);
324
328
  if (isDeadline(winner)) return winner;
325
- if (winner && meetsFloor(winner) && winner.pdf && winner.xlsx) {
329
+ if (winner && healthy(winner)) {
326
330
  deps.rmrf(tmp); // healthy winner - clean up our loser
327
- return { kind: "venv", exe: venvExe, pdf: true, xlsx: true };
331
+ return { kind: "venv", exe: venvExe, pdf: true, xlsx: true, docx: true };
328
332
  }
329
333
  deps.rmrf(venvDir); // unhealthy/absent dest - clear it and retry the rename once
330
334
  if (publish()) {
331
- deps.rmrf(join(deps.cacheRoot, LEGACY_VENV_DIR_NAME));
332
- return { kind: "venv", exe: venvExe, pdf: true, xlsx: true };
335
+ for (const legacy of LEGACY_VENV_DIR_NAMES) deps.rmrf(join(deps.cacheRoot, legacy));
336
+ return { kind: "venv", exe: venvExe, pdf: true, xlsx: true, docx: true };
333
337
  }
334
338
  return bootFail("rename after competing bootstrap");
335
339
  }
@@ -391,23 +395,28 @@ export async function tryConvertOffice(sofficeTimeoutMs: number, src: string, si
391
395
  } catch (e) { cleanup(); throw e; }
392
396
  }
393
397
 
394
- export async function convertOffice(sofficeTimeoutMs: number, src: string, signal?: AbortSignal, run: RunFn = runCapped): Promise<{ pdfPath: string; cleanup: () => void }> {
395
- const r = await tryConvertOffice(sofficeTimeoutMs, src, signal, run);
396
- if (r.ok) return r;
397
- if (r.kind === "missing") 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.");
398
- if (r.kind === "no-pdf") throw new Error("LibreOffice (soffice) ran but produced no usable PDF for this file. Ensure LibreOffice can open the document, or convert it to PDF manually first.");
399
- throw new Error(`soffice failed (code=${r.code} timedOut=${r.timedOut}): ${r.stderr}`);
398
+ export function officeFailure(r: Extract<OfficeResult, { ok: false }>): Error {
399
+ if (r.kind === "missing") return 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.");
400
+ if (r.kind === "no-pdf") return new Error("LibreOffice (soffice) ran but produced no usable PDF for this file. Ensure LibreOffice can open the document, or convert it to PDF manually first.");
401
+ return new Error(`soffice failed (code=${r.code} timedOut=${r.timedOut}): ${r.stderr}`);
400
402
  }
401
403
 
402
-
403
404
  // --- Conversion tiers and bundle orchestration ---
404
405
 
405
406
  export const DEGRADED_TEXT = "PyMuPDF text extraction - layout/tables not preserved";
406
407
  export const DEGRADED_UNPDF = "unpdf text extraction - structure not preserved";
408
+ export const DEGRADED_DOCX_TEXT = "python-docx text extraction - footnotes, hyperlinks, images not preserved";
409
+ export const DEGRADED_DOCX_OFFICE = "LibreOffice PDF route - heading styles and explicit page breaks not preserved; page numbers are LibreOffice pagination";
410
+ export const DOCX_PIP = "pip install mammoth markdownify python-docx";
411
+ export const DOCX_PAGES_OFFICE = `--pages on a DOCX needs the Python DOCX backend (explicit page-break segments); the LibreOffice route has none. Remedy: install uv, or ${DOCX_PIP}`;
412
+ const docxRemedy = `Remedy: install uv, or ${DOCX_PIP} into a Python that already has pymupdf4llm`;
413
+ const backendState = (b: Backend, missing: string) => b.kind === "none" ? b.reason : missing;
414
+ const lacksDocx = (b: Backend) => b.kind === "none" || !b.docx;
415
+ const clearStaging = (b: Pick<Bundle, "stagingDir">) => { for (const f of readdirSync(b.stagingDir)) rmSync(join(b.stagingDir, f), { recursive: true, force: true }); };
407
416
  export const EXCEL_REMEDY = "Remedy: install uv, or pip install openpyxl xlrd pillow";
408
417
 
409
- export type Mode = "info" | "pdf-primary" | "pdf-fallback" | "xlsx" | "pdf-text" | "render-pages";
410
- 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[]; renderPages?: number[]; sheetCount?: number; ok?: boolean; reason?: string; rendered?: { idx: number; file: string; dpi: number }[]; failed?: { idx: number; reason: string }[]; }
418
+ export type Mode = "info" | "pdf-primary" | "pdf-fallback" | "xlsx" | "pdf-text" | "render-pages" | "docx";
419
+ 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 | null][]; explicitBreaks?: number; engine?: string; degraded?: boolean; fallbackReason?: string | null; sheets?: SheetInfo[]; renderPages?: number[]; sheetCount?: number; ok?: boolean; reason?: string; rendered?: { idx: number; file: string; dpi: number }[]; failed?: { idx: number; reason: string }[]; }
411
420
  export type TierResult = { ok: true; json: TierJson } | { ok: false; reason: string; detail?: string } | { ok: false; userError: string; pageCount?: number };
412
421
 
413
422
  export interface PipelineSeams {
@@ -491,59 +500,94 @@ export async function convertDocument(o: DocToMdOptions, signal?: AbortSignal, s
491
500
  let office: { pdfPath: string; cleanup: () => void } | null = null;
492
501
  try {
493
502
  let pdfPath = inputPath;
494
- if (type === "docx" || type === "pptx") { office = await convertOffice(o.sofficeTimeoutMs, inputPath, signal); pdfPath = office.pdfPath; }
495
- const base = { path: pdfPath, pages: o.pages, stagingDir: b.stagingDir, sheetsStagingDir: b.sheetsStagingDir, imageDpi: o.imageDpi, imageFormat: o.imageFormat, maxOutputBytes: o.maxOutputBytes, pymupdfVersion: o.pymupdfVersion };
496
- let tier: Tier, engine: Engine, json: TierJson, degraded: string | null = null, fallbackReason: string | null = null;
503
+ const base = { path: inputPath, pages: o.pages, stagingDir: b.stagingDir, sheetsStagingDir: b.sheetsStagingDir, imageDpi: o.imageDpi, imageFormat: o.imageFormat, maxOutputBytes: o.maxOutputBytes, pymupdfVersion: o.pymupdfVersion };
504
+ let tier: Tier | undefined, engine: Engine | undefined, json: TierJson | undefined, degraded: string | null = null, fallbackReason: string | null = null;
505
+ let explicitBreaks: number | null = null;
497
506
  let notes: string[] = [];
498
- if (isExcel) {
499
- const r = await s.runTier("xlsx", base, b, signal, o.excelTimeoutMs, backend);
500
- if (!r.ok) {
501
- if ("userError" in r) throw new Error(r.userError);
502
- const remedy = r.reason.startsWith("timeout after") || r.reason === "output exceeded maxOutputBytes" ? ". Remedy: raise excelTimeoutMs" : "";
503
- throw new Error(`Excel conversion failed: ${r.reason}${detailSuffix(r)}${remedy}`);
507
+ let officeRoute: string | null = null;
508
+ if (type === "docx" && !lacksDocx(backend)) {
509
+ const d = await s.runTier("docx", base, b, signal, o.primaryTimeoutMs, backend);
510
+ if (d.ok) {
511
+ publishStaged(b);
512
+ tier = "docx"; engine = d.json.engine === "python-docx" ? "python-docx" : "mammoth"; json = d.json; explicitBreaks = d.json.explicitBreaks ?? 0;
513
+ if (d.json.degraded) { degraded = DEGRADED_DOCX_TEXT; fallbackReason = d.json.fallbackReason ?? null; }
514
+ } else if ("userError" in d) throw new Error(d.userError);
515
+ else if (d.reason === "exit 1") { officeRoute = `docx ${d.reason}${detailSuffix(d)}`; clearStaging(b); }
516
+ else throw new Error(`Conversion failed: docx ${d.reason}${detailSuffix(d)}`);
517
+ } else if (type === "docx") officeRoute = backend.kind === "none" ? backend.reason : "python backend lacks DOCX packages";
518
+ if (type === "docx" && officeRoute !== null) {
519
+ if (o.pages) throw new Error(officeRoute.startsWith("docx exit") ? `${DOCX_PAGES_OFFICE} (${officeRoute})` : DOCX_PAGES_OFFICE);
520
+ const r = await s.office(o.sofficeTimeoutMs, inputPath, signal);
521
+ if (!r.ok && r.kind === "missing") {
522
+ if (officeRoute.startsWith("docx exit")) throw new Error(`Conversion failed: ${officeRoute}; LibreOffice (soffice) not found on PATH`);
523
+ throw new Error(`DOCX conversion needs the Python DOCX packages or LibreOffice. Python backend: ${backendState(backend, "found without mammoth/markdownify/python-docx")}. ${docxRemedy}, or install LibreOffice (soffice)`);
504
524
  }
505
- publishSheetImages(b); publishSheetCsvs(b);
506
- tier = "excel"; engine = type === "xls" ? "xlrd" : "openpyxl"; json = r.json; notes = [...(json.notes ?? [])];
507
- const renderPages = json.renderPages ?? [];
508
- let skip: string | null = null;
509
- const perSheet = new Map<number, string>();
510
- if (renderPages.length) {
511
- const off = await s.office(o.sofficeTimeoutMs, inputPath, signal, runCapped, EXCEL_PDF_FILTER);
512
- if (!off.ok) skip = off.kind === "missing" ? "LibreOffice not found" : off.kind === "timeout" ? `soffice failed: timeout after ${o.sofficeTimeoutMs}ms` : off.kind === "exit" ? `soffice failed: exit ${off.code}` : "soffice produced no PDF";
525
+ if (!r.ok) throw officeRoute.startsWith("docx exit") ? new Error(`Conversion failed: ${officeRoute}; ${officeFailure(r).message}`) : officeFailure(r);
526
+ office = r; pdfPath = r.pdfPath;
527
+ }
528
+ if (type === "pptx") {
529
+ const r = await s.office(o.sofficeTimeoutMs, inputPath, signal);
530
+ if (!r.ok && r.kind === "missing") throw new Error(`PPTX conversion needs LibreOffice (soffice); direct conversion is not available. Python backend: ${backendState(backend, "available")}. Remedy: install LibreOffice`);
531
+ if (!r.ok) throw officeFailure(r);
532
+ office = r; pdfPath = r.pdfPath;
533
+ }
534
+ const pdfBase = { ...base, path: pdfPath };
535
+ if (json === undefined) {
536
+ if (isExcel) {
537
+ const r = await s.runTier("xlsx", base, b, signal, o.excelTimeoutMs, backend);
538
+ if (!r.ok) {
539
+ if ("userError" in r) throw new Error(r.userError);
540
+ const remedy = r.reason.startsWith("timeout after") || r.reason === "output exceeded maxOutputBytes" ? ". Remedy: raise excelTimeoutMs" : "";
541
+ throw new Error(`Excel conversion failed: ${r.reason}${detailSuffix(r)}${remedy}`);
542
+ }
543
+ publishSheetImages(b); publishSheetCsvs(b);
544
+ tier = "excel"; engine = type === "xls" ? "xlrd" : "openpyxl"; json = r.json; notes = [...(json.notes ?? [])];
545
+ const renderPages = json.renderPages ?? [];
546
+ let skip: string | null = null;
547
+ const perSheet = new Map<number, string>();
548
+ if (renderPages.length) {
549
+ const off = await s.office(o.sofficeTimeoutMs, inputPath, signal, runCapped, EXCEL_PDF_FILTER);
550
+ if (!off.ok) skip = off.kind === "missing" ? "LibreOffice not found" : off.kind === "timeout" ? `soffice failed: timeout after ${o.sofficeTimeoutMs}ms` : off.kind === "exit" ? `soffice failed: exit ${off.code}` : "soffice produced no PDF";
551
+ else {
552
+ try {
553
+ const rp = await s.runTier("render-pages", { path: off.pdfPath, sheetIndices: renderPages, expectedPages: json.sheetCount, imageDpi: o.imageDpi, imageFormat: o.imageFormat, stagingDir: b.stagingDir, maxOutputBytes: o.maxOutputBytes, pymupdfVersion: o.pymupdfVersion }, b, signal, o.fallbackTimeoutMs, backend);
554
+ if (!rp.ok) skip = `render failed: ${"userError" in rp ? rp.userError : rp.reason}`;
555
+ else if (rp.json.ok === false) skip = rp.json.reason ?? "render failed";
556
+ else {
557
+ publishSheetImages(b);
558
+ for (const f of rp.json.failed ?? []) perSheet.set(f.idx, f.reason);
559
+ for (const d of rp.json.rendered ?? []) if (d.dpi < o.imageDpi) notes.push(`Rendered view s${d.idx}: rendered at ${d.dpi} dpi`);
560
+ }
561
+ } finally { off.cleanup(); }
562
+ }
563
+ if (skip) notes.push(`Rendered views skipped: ${skip}`);
564
+ else if (perSheet.size) notes.push(`Rendered views: ${perSheet.size} of ${renderPages.length} unavailable`);
565
+ }
566
+ json = { ...json, markdown: reconcileRenderMarkers(json.markdown ?? "", renderPages, o.imageFormat, b.sourceMap, (idx) => perSheet.get(idx) ?? skip ?? "render failed") };
567
+ } else if (backend.kind === "none") {
568
+ const r = await s.runTier("pdf-text", pdfBase, b, signal, o.primaryTimeoutMs, backend);
569
+ if (!r.ok) throw new Error("userError" in r ? r.userError : `Conversion failed: unpdf ${r.reason}${detailSuffix(r)}`);
570
+ tier = "unpdf"; engine = "unpdf"; json = r.json; degraded = DEGRADED_UNPDF;
571
+ } else {
572
+ const p = await s.runTier("pdf-primary", pdfBase, b, signal, o.primaryTimeoutMs, backend);
573
+ const kept = publishStaged(b);
574
+ if (p.ok) { tier = "primary"; engine = "pymupdf4llm"; json = p.json; }
575
+ else if ("userError" in p) throw new Error(p.userError);
513
576
  else {
514
- try {
515
- const rp = await s.runTier("render-pages", { path: off.pdfPath, sheetIndices: renderPages, expectedPages: json.sheetCount, imageDpi: o.imageDpi, imageFormat: o.imageFormat, stagingDir: b.stagingDir, maxOutputBytes: o.maxOutputBytes, pymupdfVersion: o.pymupdfVersion }, b, signal, o.fallbackTimeoutMs, backend);
516
- if (!rp.ok) skip = `render failed: ${"userError" in rp ? rp.userError : rp.reason}`;
517
- else if (rp.json.ok === false) skip = rp.json.reason ?? "render failed";
518
- else {
519
- publishSheetImages(b);
520
- for (const f of rp.json.failed ?? []) perSheet.set(f.idx, f.reason);
521
- for (const d of rp.json.rendered ?? []) if (d.dpi < o.imageDpi) notes.push(`Rendered view s${d.idx}: rendered at ${d.dpi} dpi`);
522
- }
523
- } finally { off.cleanup(); }
577
+ if (signal?.aborted) throw new Error("aborted");
578
+ const keepPages = Object.fromEntries([...kept.entries()].map(([k, v]) => [String(k), v]));
579
+ const f = await s.runTier("pdf-fallback", { ...pdfBase, keepPages }, b, signal, o.fallbackTimeoutMs, backend);
580
+ publishStaged(b);
581
+ if (!f.ok) throw new Error("userError" in f ? f.userError : `Conversion failed: primary ${p.reason}; fallback ${f.reason}${detailSuffix(f)}`);
582
+ tier = "fallback"; engine = "pymupdf-text"; json = f.json; degraded = DEGRADED_TEXT; fallbackReason = `primary ${p.reason}`;
524
583
  }
525
- if (skip) notes.push(`Rendered views skipped: ${skip}`);
526
- else if (perSheet.size) notes.push(`Rendered views: ${perSheet.size} of ${renderPages.length} unavailable`);
527
- }
528
- json = { ...json, markdown: reconcileRenderMarkers(json.markdown ?? "", renderPages, o.imageFormat, b.sourceMap, (idx) => perSheet.get(idx) ?? skip ?? "render failed") };
529
- } else if (backend.kind === "none") {
530
- const r = await s.runTier("pdf-text", base, b, signal, o.primaryTimeoutMs, backend);
531
- if (!r.ok) throw new Error("userError" in r ? r.userError : `Conversion failed: unpdf ${r.reason}${detailSuffix(r)}`);
532
- tier = "unpdf"; engine = "unpdf"; json = r.json; degraded = DEGRADED_UNPDF;
533
- } else {
534
- const p = await s.runTier("pdf-primary", base, b, signal, o.primaryTimeoutMs, backend);
535
- const kept = publishStaged(b);
536
- if (p.ok) { tier = "primary"; engine = "pymupdf4llm"; json = p.json; }
537
- else if ("userError" in p) throw new Error(p.userError);
538
- else {
539
- if (signal?.aborted) throw new Error("aborted");
540
- const keepPages = Object.fromEntries([...kept.entries()].map(([k, v]) => [String(k), v]));
541
- const f = await s.runTier("pdf-fallback", { ...base, keepPages }, b, signal, o.fallbackTimeoutMs, backend);
542
- publishStaged(b);
543
- if (!f.ok) throw new Error("userError" in f ? f.userError : `Conversion failed: primary ${p.reason}; fallback ${f.reason}${detailSuffix(f)}`);
544
- tier = "fallback"; engine = "pymupdf-text"; json = f.json; degraded = DEGRADED_TEXT; fallbackReason = `primary ${p.reason}`;
545
584
  }
546
585
  }
586
+ if (officeRoute !== null) {
587
+ degraded = DEGRADED_DOCX_OFFICE;
588
+ fallbackReason = fallbackReason ? `${officeRoute}; ${fallbackReason}` : officeRoute;
589
+ }
590
+ if (tier === undefined || engine === undefined || json === undefined) throw new Error("internal: no tier produced output");
547
591
  if (!isExcel) notes = json.notes ?? [];
548
592
  const body = rewriteLinks(json.markdown ?? "", b.sourceMap);
549
593
  validateImageLinks(body, b.manifest, b.csvManifest);
@@ -556,7 +600,7 @@ export async function convertDocument(o: DocToMdOptions, signal?: AbortSignal, s
556
600
  const markdown = (head.length ? `${head.join("\n")}\n\n` : "") + body;
557
601
  commitBundle(b, markdown);
558
602
  const outline = scanOutline(markdown, o.outlineMaxEntries);
559
- 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, sheetsDir: b.csvManifest.size ? b.sheetsDir : null, 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, outline: outline.entries, outlineTotal: outline.total };
603
+ 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, sheetsDir: b.csvManifest.size ? b.sheetsDir : null, type, engine, tier, pageCount: json.pageCount ?? null, pages: o.pages, explicitBreaks, 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, outline: outline.entries, outlineTotal: outline.total };
560
604
  return { output: formatHandle(details), details };
561
605
  } catch (e) { abortBundle(b); throw e; }
562
606
  finally { office?.cleanup(); }
@@ -574,7 +618,13 @@ export async function inspectDocument(o: DocToMdOptions, signal?: AbortSignal, s
574
618
  let office: { pdfPath: string; cleanup: () => void } | null = null;
575
619
  try {
576
620
  let path = inputPath;
577
- if (type === "docx" || type === "pptx") { office = await convertOffice(o.sofficeTimeoutMs, inputPath, signal); path = office.pdfPath; }
621
+ if (type === "docx") {
622
+ if (lacksDocx(backend)) throw new Error(`DOCX inspection needs the Python DOCX packages. Python backend: ${backendState(backend, "found without mammoth/markdownify/python-docx")}. ${docxRemedy}`);
623
+ } else if (type === "pptx") {
624
+ const r = await s.office(o.sofficeTimeoutMs, inputPath, signal);
625
+ if (!r.ok) throw officeFailure(r);
626
+ office = r; path = r.pdfPath;
627
+ }
578
628
  const r = await s.runTier("info", { path, maxOutputBytes: o.maxOutputBytes, pymupdfVersion: o.pymupdfVersion }, { stagingDir: "" }, signal, isExcel ? o.excelTimeoutMs : o.fallbackTimeoutMs, backend);
579
629
  if (!r.ok) {
580
630
  if ("userError" in r) throw new Error(r.userError);
@@ -1,11 +1,11 @@
1
1
  import type { InputType } from "./doc-to-md-options.ts";
2
2
 
3
- export type Tier = "primary" | "fallback" | "unpdf" | "excel";
4
- export type Engine = "pymupdf4llm" | "pymupdf-text" | "unpdf" | "openpyxl" | "xlrd";
3
+ export type Tier = "primary" | "fallback" | "unpdf" | "excel" | "docx";
4
+ export type Engine = "pymupdf4llm" | "pymupdf-text" | "unpdf" | "openpyxl" | "xlrd" | "mammoth" | "python-docx";
5
5
  export type BackendKind = "uv" | "python" | "venv" | "none";
6
6
 
7
- export interface OutlineEntry { line: number; level: number; title: string; }
8
- export interface TocEntry { level: number; title: string; page: number; }
7
+ export interface OutlineEntry { line: number; level: number; title: string; page: number | null; }
8
+ export interface TocEntry { level: number; title: string; page: number | null; }
9
9
  export interface SheetInfo {
10
10
  index: number;
11
11
  name: string;
@@ -20,7 +20,7 @@ export interface SheetInfo {
20
20
 
21
21
  export interface HandleData {
22
22
  savedTo: string; imagesDir: string | null; sheetsDir: string | null; type: InputType; engine: Engine; tier: Tier;
23
- pageCount: number | null; pages: number[] | null; imageCount: number; bytes: number; lines: number;
23
+ pageCount: number | null; pages: number[] | null; explicitBreaks: number | null; imageCount: number; bytes: number; lines: number;
24
24
  degraded: string | null; fallbackReason: string | null; failedPages: number[]; emptyPages: number[];
25
25
  notes: string[]; outline: OutlineEntry[]; outlineTotal: number;
26
26
  }
@@ -57,35 +57,53 @@ export function compactRanges(nums: number[], maxEntries = 20): string {
57
57
  return `${parts.slice(0, maxEntries).join(", ")} (+${parts.length - maxEntries} more)`;
58
58
  }
59
59
 
60
+ const PAGE_MARKER_RE = /^--- end of page\.page_number=(\d+) ---$/;
61
+
60
62
  export function scanOutline(md: string, max: number): { entries: OutlineEntry[]; total: number } {
61
63
  const entries: OutlineEntry[] = [];
64
+ let pending: OutlineEntry[] = [];
62
65
  let total = 0, inFence = false;
63
66
  md.split("\n").forEach((raw, i) => {
64
67
  const line = raw.replace(/\r$/, "");
68
+ const marker = line.match(PAGE_MARKER_RE);
69
+ if (marker) { for (const e of pending) e.page = Number(marker[1]); pending = []; return; }
65
70
  if (/^\s*(```|~~~)/.test(line)) { inFence = !inFence; return; }
66
71
  if (inFence) return;
67
72
  const m = line.match(/^(#{1,6}) (.*)$/);
68
73
  if (!m) return;
69
74
  total++;
70
- if (entries.length < max) entries.push({ line: i + 1, level: m[1].length, title: trunc(m[2].trim(), TITLE_MAX) });
75
+ if (entries.length < max) {
76
+ const e: OutlineEntry = { line: i + 1, level: m[1].length, title: trunc(m[2].trim(), TITLE_MAX), page: null };
77
+ entries.push(e); pending.push(e);
78
+ }
71
79
  });
72
80
  return { entries, total };
73
81
  }
74
82
 
75
83
  function outlineLines(entries: OutlineEntry[], total: number): string[] {
84
+ if (total === 0) return ["Outline: none"];
76
85
  if (entries.length === 0) return [];
77
- const width = Math.max(5, Math.max(...entries.map((e) => `L${e.line}`.length)) + 2);
78
- const out = ["Outline:", ...entries.map((e) => ` ${`L${e.line}`.padEnd(width)}${"#".repeat(e.level)} ${trunc(e.title, TITLE_MAX)}`)];
86
+ const lineWidth = Math.max(3, ...entries.map((e) => `L${e.line}`.length)) + 2;
87
+ const paged = entries.filter((e) => e.page !== null);
88
+ const pageWidth = paged.length ? Math.max(...paged.map((e) => `p${e.page}`.length)) + 2 : 0;
89
+ const out = ["Outline:", ...entries.map((e) => ` ${`L${e.line}`.padEnd(lineWidth)}${pageWidth ? (e.page === null ? "" : `p${e.page}`).padEnd(pageWidth) : ""}${"#".repeat(e.level)} ${trunc(e.title, TITLE_MAX)}`)];
79
90
  if (total > entries.length) out.push(` (+${total - entries.length} more)`);
80
91
  return out;
81
92
  }
82
93
 
94
+ function pageCountLabel(h: HandleData): string {
95
+ const n = h.pageCount ?? "?";
96
+ if (h.type !== "docx") return String(n);
97
+ if (h.tier === "docx") return `${n} (${(h.explicitBreaks ?? 0) > 0 ? "explicit page breaks, not printed pages" : "no explicit page breaks"})`;
98
+ return `${n} (LibreOffice pagination)`;
99
+ }
100
+
83
101
  export function formatHandle(h: HandleData): string {
84
102
  const lines = [`Saved-To: ${h.savedTo}`];
85
103
  if (h.imagesDir && h.imageCount > 0) lines.push(`Images-Dir: ${h.imagesDir}`);
86
104
  if (h.sheetsDir) lines.push(`Sheets-Dir: ${h.sheetsDir}`);
87
105
  lines.push(`Type: ${h.type} Engine: ${h.engine} Tier: ${h.tier}`);
88
- lines.push(`Page-Count: ${h.pageCount ?? "?"} Pages: ${h.pages ? compactRanges(h.pages) : "all"} Images: ${h.imageCount} Size: ${formatSize(h.bytes)} / ${h.lines} lines`);
106
+ lines.push(`Page-Count: ${pageCountLabel(h)} Pages: ${h.pages ? compactRanges(h.pages) : "all"} Images: ${h.imageCount} Size: ${formatSize(h.bytes)} / ${h.lines} lines`);
89
107
  if (h.degraded) lines.push(`Degraded: ${h.degraded}`);
90
108
  if (h.fallbackReason) lines.push(`Fallback-Reason: ${h.fallbackReason}`);
91
109
  const fe: string[] = [];
@@ -112,8 +130,8 @@ export function formatInfoHandle(i: InfoData, max: number): string {
112
130
  const meta = Object.entries(i.metadata).filter(([, v]) => v).map(([k, v]) => `${k[0].toUpperCase()}${k.slice(1)}: ${trunc(v, META_MAX_CHARS)}`);
113
131
  if (meta.length) lines.push(meta.join(" "));
114
132
  if (i.toc.length) {
115
- lines.push("TOC:", ...i.toc.slice(0, max).map((t) => ` L${t.level} ${trunc(t.title, TITLE_MAX)} (p${t.page})`));
133
+ lines.push("TOC:", ...i.toc.slice(0, max).map((t) => ` L${t.level} ${trunc(t.title, TITLE_MAX)} (p${t.page ?? "?"})`));
116
134
  if (i.tocTotal > max) lines.push(` (+${i.tocTotal - max} more)`);
117
- }
135
+ } else if (i.type === "docx") lines.push("TOC: none (no heading styles found)");
118
136
  return lines.join("\n");
119
137
  }
@@ -58,11 +58,11 @@ const VERSION_RE = /^\d+(\.\d+)*$/;
58
58
  export const DOC_TO_MD_OPTIONS: readonly OptionDescriptor[] = [
59
59
  { key: "path", flag: null, type: "string", default: null, settable: false, help: "Local .pdf .docx .pptx .xlsx .xls file" },
60
60
  { key: "info", flag: "--info", type: "bool", default: false, settable: false, help: "Inspect only (page count, metadata, TOC or sheet inventory); no bundle" },
61
- { key: "pages", flag: "--pages", type: "pages", default: null, settable: false, help: "Inclusive 1-based pages, e.g. \"12-15\" or \"3,7,10-12\" (PDF/DOCX/PPTX only); default all" },
61
+ { key: "pages", flag: "--pages", type: "pages", default: null, settable: false, help: "Inclusive 1-based pages, e.g. \"12-15\" or \"3,7,10-12\" (PDF/DOCX/PPTX only); default all. DOCX: selects explicit-page-break segments; rejected when the file has none" },
62
62
  { key: "outputDir", flag: "--output-dir", type: "string", default: null, settable: false, help: "Bundle root for <stem>.md + images/; default a per-call temp dir" },
63
63
  { key: "overwrite", flag: "--overwrite", type: "bool", default: false, settable: false, help: "Replace an existing completed <stem>.md bundle" },
64
- { key: "primaryTimeoutMs", flag: "--primary-timeout", type: "int", default: 60000, settable: true, env: "PI_DOC_TO_MD_CONVERT_TIMEOUT_MS", help: "pymupdf4llm tier; also the unpdf tier" },
65
- { key: "fallbackTimeoutMs", flag: "--fallback-timeout", type: "int", default: 30000, settable: true, help: "PyMuPDF get_text tier; also info on PDF and Excel rendered views" },
64
+ { key: "primaryTimeoutMs", flag: "--primary-timeout", type: "int", default: 60000, settable: true, env: "PI_DOC_TO_MD_CONVERT_TIMEOUT_MS", help: "pymupdf4llm tier and DOCX child (docx mode); also the unpdf tier" },
65
+ { key: "fallbackTimeoutMs", flag: "--fallback-timeout", type: "int", default: 30000, settable: true, help: "PyMuPDF get_text tier (including DOCX LibreOffice fallback); also PDF and DOCX info and Excel rendered views" },
66
66
  { key: "sofficeTimeoutMs", flag: "--soffice-timeout", type: "int", default: 120000, settable: true, env: "PI_DOC_TO_MD_SOFFICE_TIMEOUT_MS", help: "DOCX/PPTX -> PDF via LibreOffice; also Excel rendered views" },
67
67
  { key: "excelTimeoutMs", flag: "--excel-timeout", type: "int", default: 60000, settable: true, help: "Excel child (both openpyxl loads); also info on Excel" },
68
68
  { key: "warmTimeoutMs", flag: "--warm-timeout", type: "int", default: 120000, settable: true, env: "PI_DOC_TO_MD_WARM_TIMEOUT_MS", help: "Absolute backend discovery/bootstrap deadline (first call per process)" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-quiver",
3
- "version": "6.4.0",
3
+ "version": "6.5.0",
4
4
  "description": "Personal pack of Pi coding-agent extensions: context-safe fetch, doc_to_md PDF/DOCX/PPTX-to-Markdown conversion, session naming, a themed ASCII startup header, Opus 4.8 fast mode, and a provider-stall watchdog.",
5
5
  "author": "Jacek Juraszek",
6
6
  "license": "MIT",