pi-quiver 5.4.0 → 6.0.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,25 +1,33 @@
1
1
  /**
2
2
  * Bundle protocol: a call owns `<stem>` for its whole duration via `<stem>.md.lock`;
3
- * children stage images under `images/.stage-<lockId>/p<N>/` and mark pages `.done`;
4
- * Node publishes done pages to `images/<stem>-p<N>-<n>.<ext>`, records every file it
3
+ * children stage images under `images/.stage-<lockId>/p<N>/` and CSVs under `sheets/.stage-<lockId>/s<idx>-<slug>.csv`;
4
+ * Node publishes them to `images/<stem>-p<N>-<n>.<ext>` and `sheets/<stem>-s<idx>-<slug>.csv`, and records every file it
5
5
  * wrote in a manifest, and commits `<stem>.md` atomically (tmp + rename).
6
6
  */
7
- import fs, { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
7
+ import fs, { closeSync, existsSync, mkdirSync, openSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
8
8
  import { tmpdir } from "node:os";
9
9
  import { extname, join, resolve } from "node:path";
10
10
  import { randomBytes } from "node:crypto";
11
11
 
12
12
  export interface Bundle {
13
13
  root: string; stem: string; mdPath: string; lockPath: string; imagesDir: string; stagingDir: string; lockId: string;
14
+ sheetsDir: string; sheetsStagingDir: string;
14
15
  manifest: Set<string>;
16
+ csvManifest: Set<string>;
15
17
  sourceMap: Map<string, string>;
16
18
  }
17
19
 
20
+ const escRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
21
+
18
22
  export function ownedPattern(stem: string): RegExp {
19
- const esc = stem.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
20
- return new RegExp(`^${esc}-(p|s)\\d+-\\d+\\.[a-z0-9]+$`);
23
+ return new RegExp(`^${escRe(stem)}-(p|s)\\d+(-\\d+)?\\.[a-z0-9]+$`);
24
+ }
25
+
26
+ export function ownedCsvPattern(stem: string): RegExp {
27
+ return new RegExp(`^${escRe(stem)}-s\\d+-[a-z0-9-]+\\.csv$`);
21
28
  }
22
29
 
30
+ const SHEET_LINK_RE = /\[[^\]]*\]\(\s*(sheets\/[^)\s]+)\s*\)/g;
23
31
  const IMG_LINK_RE = /!\[[^\]]*\]\(\s*(?:<([^>]*)>|([^)]*?))\s*\)|<img\b[^>]*\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>"']+))/gi;
24
32
 
25
33
  function imageTarget(m: RegExpMatchArray): string {
@@ -28,17 +36,6 @@ function imageTarget(m: RegExpMatchArray): string {
28
36
  return m[2] === undefined ? destination : destination.replace(/\s+(?:"[^"]*"|'[^']*'|\([^)]*\))$/, "");
29
37
  }
30
38
 
31
- function linkedFiles(md: string): string[] {
32
- const out: string[] = [];
33
- for (const m of md.matchAll(IMG_LINK_RE)) {
34
- const target = imageTarget(m);
35
- if (!target.startsWith("images/")) continue;
36
- const file = target.slice("images/".length);
37
- if (file && file !== "." && file !== ".." && /^[^/\\]+$/.test(file)) out.push(file);
38
- }
39
- return out;
40
- }
41
-
42
39
  export function openBundle(root: string, stem: string, overwrite: boolean): Bundle {
43
40
  mkdirSync(root, { recursive: true });
44
41
  const mdPath = join(root, `${stem}.md`);
@@ -51,18 +48,20 @@ export function openBundle(root: string, stem: string, overwrite: boolean): Bund
51
48
  }
52
49
  closeSync(fd);
53
50
  const imagesDir = join(root, "images");
51
+ const sheetsDir = join(root, "sheets");
54
52
  try {
55
53
  if (existsSync(mdPath)) {
56
54
  if (!overwrite) throw new Error(`Output exists: ${mdPath} (pass overwrite)`);
57
- const owned = ownedPattern(stem);
58
- const linked = new Set(linkedFiles(readFileSync(mdPath, "utf8")));
55
+ const owned = ownedPattern(stem), ownedCsv = ownedCsvPattern(stem);
59
56
  unlinkSync(mdPath);
60
- if (existsSync(imagesDir)) for (const f of readdirSync(imagesDir)) if (owned.test(f) || linked.has(f)) rmSync(join(imagesDir, f), { force: true });
57
+ if (existsSync(imagesDir)) for (const f of readdirSync(imagesDir)) if (owned.test(f)) rmSync(join(imagesDir, f), { force: true });
58
+ if (existsSync(sheetsDir)) for (const f of readdirSync(sheetsDir)) if (ownedCsv.test(f)) rmSync(join(sheetsDir, f), { force: true });
61
59
  }
62
60
  const lockId = randomBytes(6).toString("hex");
63
61
  const stagingDir = join(imagesDir, `.stage-${lockId}`);
62
+ const sheetsStagingDir = join(sheetsDir, `.stage-${lockId}`);
64
63
  mkdirSync(stagingDir, { recursive: true });
65
- return { root, stem, mdPath, lockPath, imagesDir, stagingDir, lockId, manifest: new Set(), sourceMap: new Map() };
64
+ return { root, stem, mdPath, lockPath, imagesDir, stagingDir, lockId, sheetsDir, sheetsStagingDir, manifest: new Set(), csvManifest: new Set(), sourceMap: new Map() };
66
65
  } catch (e) { rmSync(lockPath, { force: true }); throw e; }
67
66
  }
68
67
 
@@ -91,10 +90,10 @@ export function publishStaged(b: Bundle): Map<number, string[]> {
91
90
  return out;
92
91
  }
93
92
 
94
- /** Excel: files staged flat in stagingDir as `s<idx>-<n>.<ext>` -> `images/<stem>-s<idx>-<n>.<ext>`. */
93
+ /** Excel: `s<idx>-<n>.<ext>` (embedded) and `s<idx>.<fmt>` (rendered view) staged flat -> `images/<stem>-<file>`. */
95
94
  export function publishSheetImages(b: Bundle): void {
96
95
  for (const f of readdirSync(b.stagingDir).sort()) {
97
- if (!/^s\d+-\d+\.[a-z0-9]+$/i.test(f)) continue;
96
+ if (!/^s\d+(-\d+)?\.[a-z0-9]+$/i.test(f)) continue;
98
97
  const name = `${b.stem}-${f.toLowerCase()}`;
99
98
  renameSync(join(b.stagingDir, f), join(b.imagesDir, name));
100
99
  b.manifest.add(name);
@@ -102,19 +101,38 @@ export function publishSheetImages(b: Bundle): void {
102
101
  }
103
102
  }
104
103
 
105
- export function rewriteImageLinks(md: string, sourceMap: Map<string, string>): string {
106
- return md.replace(IMG_LINK_RE, (whole, mdAngle, mdPlain, htmlDouble, htmlSingle, htmlUnquoted) => {
104
+ /** Excel: `sheetsStagingDir/s<idx>-<slug>.csv` -> `sheets/<stem>-s<idx>-<slug>.csv`; `sheets/` is created only when a CSV exists. */
105
+ export function publishSheetCsvs(b: Bundle): void {
106
+ if (!existsSync(b.sheetsStagingDir)) return;
107
+ for (const f of readdirSync(b.sheetsStagingDir).sort()) {
108
+ if (!/^s\d+-[a-z0-9-]+\.csv$/.test(f)) continue;
109
+ const name = `${b.stem}-${f}`;
110
+ renameSync(join(b.sheetsStagingDir, f), join(b.sheetsDir, name));
111
+ b.csvManifest.add(name);
112
+ b.sourceMap.set(`sheets/${f}`, `sheets/${name}`);
113
+ }
114
+ }
115
+
116
+ export function rewriteLinks(md: string, sourceMap: Map<string, string>): string {
117
+ const images = md.replace(IMG_LINK_RE, (whole, mdAngle, mdPlain, htmlDouble, htmlSingle, htmlUnquoted) => {
107
118
  const target = imageTarget([whole, mdAngle, mdPlain, htmlDouble, htmlSingle, htmlUnquoted] as unknown as RegExpMatchArray);
108
119
  const dest = sourceMap.get(target) ?? sourceMap.get(target.replace(/^\.\//, ""));
109
120
  return dest ? whole.replace(mdAngle === undefined ? target : `<${mdAngle}>`, dest) : whole;
110
121
  });
122
+ return images.replace(SHEET_LINK_RE, (whole, target: string) => {
123
+ const dest = sourceMap.get(target);
124
+ return dest ? whole.split(target).join(dest) : whole;
125
+ });
111
126
  }
112
127
 
113
- export function validateImageLinks(md: string, manifest: Set<string>): void {
128
+ export function validateImageLinks(md: string, manifest: Set<string>, csvManifest: Set<string> = new Set()): void {
114
129
  for (const m of md.matchAll(IMG_LINK_RE)) {
115
130
  const target = imageTarget(m);
116
131
  if (!target.startsWith("images/") || !manifest.has(target.slice("images/".length))) throw new Error(`unexpected image reference in output: ${target}`);
117
132
  }
133
+ for (const m of md.matchAll(SHEET_LINK_RE)) {
134
+ if (!csvManifest.has(m[1].slice("sheets/".length))) throw new Error(`unexpected sheet reference in output: ${m[1]}`);
135
+ }
118
136
  }
119
137
 
120
138
  export function commitBundle(b: Bundle, markdown: string): void {
@@ -122,13 +140,16 @@ export function commitBundle(b: Bundle, markdown: string): void {
122
140
  writeFileSync(tmp, markdown, "utf8");
123
141
  renameSync(tmp, b.mdPath);
124
142
  try { fs.rmSync(b.stagingDir, { recursive: true, force: true }); } catch { /* Markdown is published; cleanup is best-effort. */ }
143
+ try { fs.rmSync(b.sheetsStagingDir, { recursive: true, force: true }); } catch { /* best-effort */ }
125
144
  try { fs.rmSync(b.lockPath, { force: true }); } catch { /* Markdown is published; cleanup is best-effort. */ }
126
145
  }
127
146
 
128
147
  export function abortBundle(b: Bundle): void {
129
148
  for (const f of b.manifest) rmSync(join(b.imagesDir, f), { force: true });
149
+ for (const f of b.csvManifest) rmSync(join(b.sheetsDir, f), { force: true });
130
150
  rmSync(`${b.mdPath}.tmp`, { force: true });
131
151
  rmSync(b.stagingDir, { recursive: true, force: true });
152
+ rmSync(b.sheetsStagingDir, { recursive: true, force: true });
132
153
  rmSync(b.lockPath, { force: true });
133
154
  }
134
155
 
@@ -12,7 +12,7 @@ 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";
14
14
  import { fileURLToPath, pathToFileURL } from "node:url";
15
- import { type Bundle, abortBundle, commitBundle, openBundle, publishSheetImages, publishStaged, rewriteImageLinks, tempBundleRoot, validateImageLinks } from "./doc-to-md-bundle.ts";
15
+ import { type Bundle, abortBundle, commitBundle, openBundle, publishSheetCsvs, publishSheetImages, publishStaged, rewriteLinks, tempBundleRoot, validateImageLinks } from "./doc-to-md-bundle.ts";
16
16
  import { type Engine, type HandleData, type InfoData, type Tier, formatHandle, formatInfoHandle, scanOutline, type SheetInfo, type TocEntry } from "./doc-to-md-handle.ts";
17
17
  import { type DocToMdOptions, type InputType, TUNABLE_DEFAULTS, classifyInput, sanitizeStem } from "./doc-to-md-options.ts";
18
18
 
@@ -28,6 +28,7 @@ export const VENV_DIR_NAME = "doc-to-md-venv-v2";
28
28
  export const LEGACY_VENV_DIR_NAME = "pymupdf-venv";
29
29
  const STDERR_CAP = 1_000_000;
30
30
  export const OUTPUT_MAX_BYTES = 20_000_000;
31
+ export const EXCEL_PDF_FILTER = 'pdf:calc_pdf_Export:{"SinglePageSheets":{"type":"boolean","value":"true"}}';
31
32
 
32
33
  export interface BackendConfig { pymupdfVersion: string; warmTimeoutMs: number; }
33
34
 
@@ -59,12 +60,12 @@ export function uvChildArgs(cfg: BackendConfig, script: string, mode: string): s
59
60
 
60
61
  export function pythonChildArgs(script: string, mode: string): string[] { return [script, mode]; }
61
62
 
62
- export function soffArgs(src: string, profileDir: string, outDir: string): string[] {
63
+ export function soffArgs(src: string, profileDir: string, outDir: string, filter = "pdf"): string[] {
63
64
  return [
64
65
  "--headless", "--invisible", "--nocrashreport", "--nodefault", "--nofirststartwizard",
65
66
  "--nolockcheck", "--nologo", "--norestore", "--quickstart=no",
66
67
  `-env:UserInstallation=${pathToFileURL(profileDir).href}`,
67
- "--convert-to", "pdf", "--outdir", outDir, src,
68
+ "--convert-to", filter, "--outdir", outDir, src,
68
69
  ];
69
70
  }
70
71
 
@@ -367,37 +368,37 @@ export function resetBackendCacheForTests(): void {
367
368
  backendPromise = null;
368
369
  }
369
370
 
370
- export async function convertOffice(
371
- sofficeTimeoutMs: number,
372
- src: string,
373
- signal?: AbortSignal,
374
- run: RunFn = runCapped,
375
- ): Promise<{ pdfPath: string; cleanup: () => void }> {
371
+ export type OfficeResult =
372
+ | { ok: true; pdfPath: string; cleanup: () => void }
373
+ | { ok: false; kind: "missing" | "timeout" | "exit" | "no-pdf"; code: number | null; timedOut: boolean; stderr: string };
374
+
375
+ export async function tryConvertOffice(sofficeTimeoutMs: number, src: string, signal?: AbortSignal, run: RunFn = runCapped, filter = "pdf"): Promise<OfficeResult> {
376
376
  const profileDir = mkdtempSync(join(tmpdir(), "pi-doc-soffice-prof-"));
377
377
  const outDir = mkdtempSync(join(tmpdir(), "pi-doc-soffice-out-"));
378
- const cleanup = () => {
379
- for (const d of [profileDir, outDir]) {
380
- try { rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ }
381
- }
382
- };
378
+ const cleanup = () => { for (const d of [profileDir, outDir]) try { rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ } };
383
379
  try {
384
380
  const env = { ...process.env, SAL_USE_VCLPLUGIN: "svp", OOO_DISABLE_RECOVERY: "1", SAL_NO_MOUSEGRABS: "1" };
385
- const r = await run("soffice", soffArgs(src, profileDir, outDir), { timeoutMs: sofficeTimeoutMs, capBytes: OUTPUT_MAX_BYTES, env, signal });
381
+ const r = await run("soffice", soffArgs(src, profileDir, outDir, filter), { timeoutMs: sofficeTimeoutMs, capBytes: OUTPUT_MAX_BYTES, env, signal });
386
382
  if (signal?.aborted) throw new Error("aborted");
387
- if (r.code === null && !r.timedOut) {
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.");
389
- }
390
- if (r.timedOut || r.code !== 0) throw new Error(`soffice failed (code=${r.code} timedOut=${r.timedOut}): ${r.stderr.slice(0, 500)}`);
391
- const base = basename(src).replace(/\.[^.]+$/, "");
392
- const pdfPath = join(outDir, `${base}.pdf`);
383
+ const fail = (kind: "missing" | "timeout" | "exit" | "no-pdf"): OfficeResult => { cleanup(); return { ok: false, kind, code: r.code, timedOut: r.timedOut, stderr: r.stderr.slice(0, 500) }; };
384
+ if (r.code === null && !r.timedOut) return fail("missing");
385
+ if (r.timedOut) return fail("timeout");
386
+ if (r.code !== 0) return fail("exit");
387
+ const pdfPath = join(outDir, `${basename(src).replace(/\.[^.]+$/, "")}.pdf`);
393
388
  const st = statSync(pdfPath, { throwIfNoEntry: false });
394
- if (!st || !st.isFile() || st.size === 0) {
395
- 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.");
396
- }
397
- return { pdfPath, cleanup };
389
+ if (!st || !st.isFile() || st.size === 0) return fail("no-pdf");
390
+ return { ok: true, pdfPath, cleanup };
398
391
  } catch (e) { cleanup(); throw e; }
399
392
  }
400
393
 
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}`);
400
+ }
401
+
401
402
 
402
403
  // --- Conversion tiers and bundle orchestration ---
403
404
 
@@ -405,13 +406,14 @@ export const DEGRADED_TEXT = "PyMuPDF text extraction - layout/tables not preser
405
406
  export const DEGRADED_UNPDF = "unpdf text extraction - structure not preserved";
406
407
  export const EXCEL_REMEDY = "Remedy: install uv, or pip install openpyxl xlrd pillow";
407
408
 
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[]; }
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 }[]; }
410
411
  export type TierResult = { ok: true; json: TierJson } | { ok: false; reason: string; detail?: string } | { ok: false; userError: string; pageCount?: number };
411
412
 
412
413
  export interface PipelineSeams {
413
414
  backend: (cfg: BackendConfig) => Promise<Backend>;
414
415
  runTier: (mode: Mode, childOptions: Record<string, unknown>, bundle: Pick<Bundle, "stagingDir">, signal: AbortSignal | undefined, timeoutMs: number, backend: Backend) => Promise<TierResult>;
416
+ office: typeof tryConvertOffice;
415
417
  }
416
418
 
417
419
  export function resolveUnpdfWorker(candidates: string[], exists: (path: string) => boolean): string {
@@ -463,8 +465,19 @@ function detailSuffix(r: Extract<TierResult, { ok: false; reason: string }>): st
463
465
  return r.detail ? ` (${r.detail})` : "";
464
466
  }
465
467
 
468
+ export function reconcileRenderMarkers(md: string, renderPages: number[], fmt: string, sourceMap: Map<string, string>, reason: (idx: number) => string): string {
469
+ for (const idx of renderPages) {
470
+ const file = `s${idx}.${fmt}`;
471
+ const ok = sourceMap.has(file);
472
+ md = md.replace(`<!--rv:${idx}-->`, ok ? `Rendered view: ![Rendered view of sheet ${idx}](${file})` : `Rendered view: unavailable (${reason(idx)})`);
473
+ md = md.replace(`<!--rvs:${idx}-->`, ok ? "yes" : "no");
474
+ }
475
+ if (/<!--rvs?:\d+-->/.test(md)) throw new Error("internal: unresolved render marker");
476
+ return md;
477
+ }
478
+
466
479
  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 };
480
+ const s: PipelineSeams = { backend: (c) => getBackend(c, undefined, signal), runTier: runTierReal, office: tryConvertOffice, ...seams };
468
481
  const inputPath = resolve(o.path);
469
482
  const st = statSync(inputPath, { throwIfNoEntry: false });
470
483
  if (!st || !st.isFile()) throw new Error(`Not a readable file: ${o.path}`);
@@ -479,17 +492,40 @@ export async function convertDocument(o: DocToMdOptions, signal?: AbortSignal, s
479
492
  try {
480
493
  let pdfPath = inputPath;
481
494
  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 };
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 };
483
496
  let tier: Tier, engine: Engine, json: TierJson, degraded: string | null = null, fallbackReason: string | null = null;
497
+ let notes: string[] = [];
484
498
  if (isExcel) {
485
499
  const r = await s.runTier("xlsx", base, b, signal, o.excelTimeoutMs, backend);
486
500
  if (!r.ok) {
487
501
  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" : "";
502
+ const remedy = r.reason.startsWith("timeout after") || r.reason === "output exceeded maxOutputBytes" ? ". Remedy: raise excelTimeoutMs" : "";
489
503
  throw new Error(`Excel conversion failed: ${r.reason}${detailSuffix(r)}${remedy}`);
490
504
  }
491
- publishSheetImages(b);
492
- tier = "excel"; engine = type === "xls" ? "xlrd" : "openpyxl"; json = r.json;
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";
513
+ 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(); }
524
+ }
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") };
493
529
  } else if (backend.kind === "none") {
494
530
  const r = await s.runTier("pdf-text", base, b, signal, o.primaryTimeoutMs, backend);
495
531
  if (!r.ok) throw new Error("userError" in r ? r.userError : `Conversion failed: unpdf ${r.reason}${detailSuffix(r)}`);
@@ -508,25 +544,26 @@ export async function convertDocument(o: DocToMdOptions, signal?: AbortSignal, s
508
544
  tier = "fallback"; engine = "pymupdf-text"; json = f.json; degraded = DEGRADED_TEXT; fallbackReason = `primary ${p.reason}`;
509
545
  }
510
546
  }
511
- let body = rewriteImageLinks(json.markdown ?? "", b.sourceMap);
512
- validateImageLinks(body, b.manifest);
547
+ if (!isExcel) notes = json.notes ?? [];
548
+ const body = rewriteLinks(json.markdown ?? "", b.sourceMap);
549
+ validateImageLinks(body, b.manifest, b.csvManifest);
513
550
  const head: string[] = [];
514
551
  if (degraded) head.push(`Degraded: ${degraded}`);
515
552
  if (fallbackReason) head.push(`Fallback-Reason: ${fallbackReason}`);
516
553
  if (json.failedPages?.length) head.push(`Failed pages: ${json.failedPages.map((f) => `${f.page} (${f.error})`).join("; ")}`);
517
554
  if (json.emptyPages?.length) head.push(`Empty pages: ${json.emptyPages.join(", ")}`);
518
- for (const n of json.notes ?? []) head.push(`Notes: ${n}`);
555
+ for (const n of notes) head.push(`Notes: ${n}`);
519
556
  const markdown = (head.length ? `${head.join("\n")}\n\n` : "") + body;
520
557
  commitBundle(b, markdown);
521
558
  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 };
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 };
523
560
  return { output: formatHandle(details), details };
524
561
  } catch (e) { abortBundle(b); throw e; }
525
562
  finally { office?.cleanup(); }
526
563
  }
527
564
 
528
565
  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 };
566
+ const s: PipelineSeams = { backend: (c) => getBackend(c, undefined, signal), runTier: runTierReal, office: tryConvertOffice, ...seams };
530
567
  const inputPath = resolve(o.path);
531
568
  const st = statSync(inputPath, { throwIfNoEntry: false });
532
569
  if (!st || !st.isFile()) throw new Error(`Not a readable file: ${o.path}`);
@@ -541,7 +578,7 @@ export async function inspectDocument(o: DocToMdOptions, signal?: AbortSignal, s
541
578
  const r = await s.runTier("info", { path, maxOutputBytes: o.maxOutputBytes, pymupdfVersion: o.pymupdfVersion }, { stagingDir: "" }, signal, isExcel ? o.excelTimeoutMs : o.fallbackTimeoutMs, backend);
542
579
  if (!r.ok) {
543
580
  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)}`);
581
+ if (isExcel && (r.reason.startsWith("timeout after") || r.reason === "output exceeded maxOutputBytes")) throw new Error(`Excel inspection failed: ${r.reason}. Remedy: raise excelTimeoutMs${detailSuffix(r)}`);
545
582
  throw new Error(`Inspection failed: ${r.reason}${detailSuffix(r)}`);
546
583
  }
547
584
  const toc: TocEntry[] = (r.json.toc ?? []).map(([level, title, page]) => ({ level, title, page }));
@@ -6,10 +6,20 @@ export type BackendKind = "uv" | "python" | "venv" | "none";
6
6
 
7
7
  export interface OutlineEntry { line: number; level: number; title: string; }
8
8
  export interface TocEntry { level: number; title: string; page: number; }
9
- export interface SheetInfo { index: number; name: string; hidden: boolean; rows: number | null; cols: number | null; hiddenRows: number; hiddenCols: number; }
9
+ export interface SheetInfo {
10
+ index: number;
11
+ name: string;
12
+ kind: "worksheet" | "chartsheet";
13
+ hidden: boolean;
14
+ rows: number | null; cols: number | null;
15
+ hiddenRows: number; hiddenCols: number;
16
+ charts: number; images: number;
17
+ rendered: boolean;
18
+ csv: string | null;
19
+ }
10
20
 
11
21
  export interface HandleData {
12
- savedTo: string; imagesDir: string | null; type: InputType; engine: Engine; tier: Tier;
22
+ savedTo: string; imagesDir: string | null; sheetsDir: string | null; type: InputType; engine: Engine; tier: Tier;
13
23
  pageCount: number | null; pages: number[] | null; imageCount: number; bytes: number; lines: number;
14
24
  degraded: string | null; fallbackReason: string | null; failedPages: number[]; emptyPages: number[];
15
25
  notes: string[]; outline: OutlineEntry[]; outlineTotal: number;
@@ -73,6 +83,7 @@ function outlineLines(entries: OutlineEntry[], total: number): string[] {
73
83
  export function formatHandle(h: HandleData): string {
74
84
  const lines = [`Saved-To: ${h.savedTo}`];
75
85
  if (h.imagesDir && h.imageCount > 0) lines.push(`Images-Dir: ${h.imagesDir}`);
86
+ if (h.sheetsDir) lines.push(`Sheets-Dir: ${h.sheetsDir}`);
76
87
  lines.push(`Type: ${h.type} Engine: ${h.engine} Tier: ${h.tier}`);
77
88
  lines.push(`Page-Count: ${h.pageCount ?? "?"} Pages: ${h.pages ? compactRanges(h.pages) : "all"} Images: ${h.imageCount} Size: ${formatSize(h.bytes)} / ${h.lines} lines`);
78
89
  if (h.degraded) lines.push(`Degraded: ${h.degraded}`);
@@ -90,7 +101,7 @@ export function formatInfoHandle(i: InfoData, max: number): string {
90
101
  if (i.sheets) {
91
102
  const lines = [`Type: ${i.type} Sheets: ${i.sheetsTotal}`];
92
103
  for (const s of i.sheets.slice(0, max)) {
93
- const dims = `rows=${s.rows ?? "?"} cols=${s.cols ?? "?"}`;
104
+ const dims = `${s.kind} rows=${s.rows ?? "-"} cols=${s.cols ?? "-"} charts=${s.charts} images=${s.images}`;
94
105
  const hidden = s.hiddenRows || s.hiddenCols ? ` hiddenRows=${s.hiddenRows} hiddenCols=${s.hiddenCols}` : "";
95
106
  lines.push(` ${trunc(s.name, TITLE_MAX)} ${s.hidden ? "hidden " : ""}${dims}${hidden}`);
96
107
  }
@@ -16,7 +16,6 @@ export interface Tunables {
16
16
  pymupdfVersion: string;
17
17
  imageDpi: number;
18
18
  imageFormat: ImageFormat;
19
- maxCellsPerSheet: number;
20
19
  maxOutputBytes: number;
21
20
  outlineMaxEntries: number;
22
21
  }
@@ -63,14 +62,13 @@ export const DOC_TO_MD_OPTIONS: readonly OptionDescriptor[] = [
63
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" },
64
63
  { key: "overwrite", flag: "--overwrite", type: "bool", default: false, settable: false, help: "Replace an existing completed <stem>.md bundle" },
65
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" },
66
- { key: "fallbackTimeoutMs", flag: "--fallback-timeout", type: "int", default: 30000, settable: true, help: "PyMuPDF get_text tier; also info on PDF" },
67
- { 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" },
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" },
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" },
68
67
  { key: "excelTimeoutMs", flag: "--excel-timeout", type: "int", default: 60000, settable: true, help: "Excel child (both openpyxl loads); also info on Excel" },
69
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)" },
70
69
  { key: "pymupdfVersion", flag: "--pymupdf-version", type: "version", default: "1.27.2.3", settable: true, env: "PI_DOC_TO_MD_PYMUPDF_VERSION", help: "pymupdf4llm pin (>= 1.27.0)" },
71
- { key: "imageDpi", flag: "--image-dpi", type: "int", default: 150, settable: true, help: "Render DPI for page images" },
70
+ { key: "imageDpi", flag: "--image-dpi", type: "int", default: 150, settable: true, help: "Render DPI for page images and Excel rendered views" },
72
71
  { key: "imageFormat", flag: "--image-format", type: "enum", default: "png", settable: true, enumValues: ["png", "jpg"], help: "Rendered image format (embedded images keep their native extension)" },
73
- { key: "maxCellsPerSheet", flag: "--max-cells-per-sheet", type: "int", default: 50000, settable: true, help: "rows x cols budget per worksheet" },
74
72
  { key: "maxOutputBytes", flag: "--max-output-bytes", type: "int", default: 20000000, settable: true, help: "Child stdout cap in bytes" },
75
73
  { key: "outlineMaxEntries", flag: "--outline-max-entries", type: "int", default: 40, settable: true, help: "Heading outline / TOC / sheet inventory cap in the handle" },
76
74
  ];
@@ -47,7 +47,7 @@ export const QUIVER_CONFIG_KEYS: Record<string, readonly string[]> = {
47
47
  sessionAutoName: ["enabled", "ghosttyTab", "herdrTab", "rules", "deny", "revisitFirstTurn", "revisitEveryTurns"],
48
48
  swordHeader: ["enabled"],
49
49
  providerStallWatchdog: ["enabled", "firstEventMs", "warningMs", "recoveryMs", "maxStallRetries"],
50
- slack: ["enabled", "cachePath", "policyPath", "userTokenEnv", "botTokenEnv", "uploadThresholdChars"],
50
+ slack: ["enabled", "cachePath", "policyPath", "userTokenEnv", "userTokenCommand", "userTokenCommandTimeoutSeconds", "botTokenEnv", "uploadThresholdChars"],
51
51
  docToMd: DOC_TO_MD_OPTIONS.filter((o) => o.settable).map((o) => o.key),
52
52
  };
53
53
 
@@ -104,7 +104,7 @@ export function resolveConfig<T extends object>(
104
104
  cwd: string,
105
105
  key: string,
106
106
  defaults: T,
107
- coerce: (raw: unknown) => Partial<T> | undefined,
107
+ coerce: (raw: unknown, warn?: (message: string) => void) => Partial<T> | undefined,
108
108
  warn?: (message: string) => void,
109
109
  ): T {
110
110
  const cfg: T = { ...defaults };
@@ -119,7 +119,7 @@ export function resolveConfig<T extends object>(
119
119
  const hasFlat = Object.hasOwn(settings, key);
120
120
  if (!hasNested && !(hasFlat && LEGACY_FLAT_KEYS.has(key))) continue;
121
121
  const candidate = hasNested ? nested![key] : settings[key];
122
- const patch = coerce(candidate);
122
+ const patch = coerce(candidate, (message) => emitWarning(warn, message));
123
123
  if (patch) Object.assign(cfg, patch);
124
124
  else emitWarning(warn, `pi-quiver: "${key}" in ${path} has an unrecognized value; ignored.`);
125
125
  }
package/lib/slack-core.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  * slack-local `PI_QUIVER_SLACK_*` env-var overlay applied on top.
10
10
  */
11
11
 
12
- import { execFileSync } from "node:child_process";
12
+ import { execFile, execFileSync } from "node:child_process";
13
13
  import { createHash } from "node:crypto";
14
14
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
15
15
  import { tmpdir } from "node:os";
@@ -21,6 +21,8 @@ export interface SlackConfig {
21
21
  cachePath: string | undefined;
22
22
  policyPath: string | undefined;
23
23
  userTokenEnv: string;
24
+ userTokenCommand: string[] | undefined;
25
+ userTokenCommandTimeoutSeconds: number;
24
26
  botTokenEnv: string;
25
27
  uploadThresholdChars: number;
26
28
  }
@@ -30,6 +32,8 @@ export const DEFAULT_SLACK_CONFIG: SlackConfig = {
30
32
  cachePath: undefined,
31
33
  policyPath: undefined,
32
34
  userTokenEnv: "SLACK_USER_TOKEN",
35
+ userTokenCommand: undefined,
36
+ userTokenCommandTimeoutSeconds: 10,
33
37
  botTokenEnv: "SLACK_BOT_TOKEN",
34
38
  uploadThresholdChars: 4000,
35
39
  };
@@ -44,7 +48,9 @@ export class SlackError extends Error {
44
48
  }
45
49
  }
46
50
 
47
- export function coerce(raw: unknown): Partial<SlackConfig> | undefined {
51
+ const MAX_TIMER_SECONDS = 2_147_483.647;
52
+
53
+ export function coerce(raw: unknown, warn?: (message: string) => void): Partial<SlackConfig> | undefined {
48
54
  if (typeof raw === "boolean") return { enabled: raw };
49
55
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined;
50
56
  const o = raw as Record<string, unknown>;
@@ -53,6 +59,28 @@ export function coerce(raw: unknown): Partial<SlackConfig> | undefined {
53
59
  if (typeof o.cachePath === "string") patch.cachePath = o.cachePath;
54
60
  if (typeof o.policyPath === "string") patch.policyPath = o.policyPath;
55
61
  if (typeof o.userTokenEnv === "string") patch.userTokenEnv = o.userTokenEnv;
62
+ if (
63
+ Array.isArray(o.userTokenCommand) &&
64
+ typeof o.userTokenCommand[0] === "string" &&
65
+ o.userTokenCommand[0].length > 0 &&
66
+ o.userTokenCommand.every((part) => typeof part === "string")
67
+ ) {
68
+ patch.userTokenCommand = [...o.userTokenCommand];
69
+ }
70
+ if (o.userTokenCommandTimeoutSeconds !== undefined) {
71
+ if (
72
+ typeof o.userTokenCommandTimeoutSeconds === "number" &&
73
+ Number.isFinite(o.userTokenCommandTimeoutSeconds) &&
74
+ o.userTokenCommandTimeoutSeconds > 0 &&
75
+ o.userTokenCommandTimeoutSeconds <= MAX_TIMER_SECONDS
76
+ ) {
77
+ patch.userTokenCommandTimeoutSeconds = o.userTokenCommandTimeoutSeconds;
78
+ } else {
79
+ warn?.(
80
+ `pi-quiver: quiver.slack.userTokenCommandTimeoutSeconds must be a positive finite number no greater than ${MAX_TIMER_SECONDS}; ignored.`,
81
+ );
82
+ }
83
+ }
56
84
  if (typeof o.botTokenEnv === "string") patch.botTokenEnv = o.botTokenEnv;
57
85
  if (typeof o.uploadThresholdChars === "number" && Number.isInteger(o.uploadThresholdChars) && o.uploadThresholdChars > 0) {
58
86
  patch.uploadThresholdChars = o.uploadThresholdChars;
@@ -222,6 +250,42 @@ export function resolveToken(
222
250
  );
223
251
  }
224
252
 
253
+ function runCredentialCommand(command: string[], timeoutMs: number): Promise<string> {
254
+ return new Promise((resolve, reject) => {
255
+ const [file, ...args] = command;
256
+ execFile(file, args, { timeout: timeoutMs, killSignal: "SIGKILL", maxBuffer: 64 * 1024, encoding: "utf8" }, (err, stdout) => {
257
+ if (err) {
258
+ const childError = err as NodeJS.ErrnoException & { killed?: boolean; signal?: string | null };
259
+ if (childError.killed) {
260
+ reject(new SlackError("credential_command_failed", `Slack user credential command timed out after ${timeoutMs}ms.`));
261
+ return;
262
+ }
263
+ const status = typeof childError.code === "number" ? ` (status ${childError.code})` : "";
264
+ reject(new SlackError("credential_command_failed", `Slack user credential command failed${status}.`));
265
+ return;
266
+ }
267
+ const token = stdout.trim();
268
+ if (!token) {
269
+ reject(new SlackError("missing_token", "Slack user credential command returned no token."));
270
+ return;
271
+ }
272
+ resolve(token);
273
+ });
274
+ });
275
+ }
276
+
277
+ export async function resolveCredential(
278
+ identity: "user" | "bot",
279
+ cfg: SlackConfig,
280
+ env: Record<string, string | undefined>,
281
+ repoRoot: string,
282
+ ): Promise<string> {
283
+ if (identity === "user" && cfg.userTokenCommand) {
284
+ return runCredentialCommand(cfg.userTokenCommand, Math.ceil(cfg.userTokenCommandTimeoutSeconds * 1000));
285
+ }
286
+ return resolveToken(identity, cfg, env, repoRoot);
287
+ }
288
+
225
289
  /**
226
290
  * Data-plane transport: every Slack API call and file upload funnels through
227
291
  * these two injectable functions so tests script responses without network.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-quiver",
3
- "version": "5.4.0",
3
+ "version": "6.0.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",