pi-quiver 6.5.0 → 6.7.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,12 +1,39 @@
1
1
  #!/usr/bin/env node
2
-
3
- // bin/pi-quiver.ts
4
- import { existsSync as existsSync3, readFileSync, realpathSync } from "node:fs";
5
- import { homedir as homedir2 } from "node:os";
6
- import { join as join4, resolve as resolve3 } from "node:path";
7
- import { fileURLToPath as fileURLToPath2 } from "node:url";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res, err) => function __init() {
5
+ if (err) throw err[0];
6
+ try {
7
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
8
+ } catch (e) {
9
+ throw err = [e], e;
10
+ }
11
+ };
12
+ var __export = (target, all) => {
13
+ for (var name in all)
14
+ __defProp(target, name, { get: all[name], enumerable: true });
15
+ };
8
16
 
9
17
  // lib/fetch-core.ts
18
+ var fetch_core_exports = {};
19
+ __export(fetch_core_exports, {
20
+ DEFAULT_TIMEOUT_MS: () => DEFAULT_TIMEOUT_MS,
21
+ applyGate: () => applyGate,
22
+ binaryExtension: () => binaryExtension,
23
+ buildGhArgs: () => buildGhArgs,
24
+ buildGhLogArgs: () => buildGhLogArgs,
25
+ categorize: () => categorize,
26
+ classifyGitHubTarget: () => classifyGitHubTarget,
27
+ collectBody: () => collectBody,
28
+ executeGhRouting: () => executeGhRouting,
29
+ fetchUrl: () => fetchUrl,
30
+ formatSize: () => formatSize,
31
+ htmlToMarkdown: () => htmlToMarkdown,
32
+ htmlToMarkdownRaw: () => htmlToMarkdownRaw,
33
+ planGhRouting: () => planGhRouting,
34
+ prettyJson: () => prettyJson,
35
+ runGh: () => runGh
36
+ });
10
37
  import { mkdirSync, writeFileSync, createWriteStream } from "node:fs";
11
38
  import { rm } from "node:fs/promises";
12
39
  import { tmpdir } from "node:os";
@@ -27,24 +54,6 @@ function formatSize(bytes) {
27
54
  return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
28
55
  }
29
56
  }
30
- var RESERVED_OWNERS = /* @__PURE__ */ new Set([
31
- "orgs",
32
- "users",
33
- "sponsors",
34
- "topics",
35
- "marketplace",
36
- "apps",
37
- "collections",
38
- "stars",
39
- "settings",
40
- "notifications",
41
- "codespaces",
42
- "features",
43
- "trending",
44
- "security",
45
- "customer-stories"
46
- ]);
47
- var GH_NAME = /^[A-Za-z0-9._-]+$/;
48
57
  function classifyGitHubTarget(url) {
49
58
  const host = url.hostname.toLowerCase();
50
59
  if (host !== "github.com" && host !== "www.github.com") return null;
@@ -82,22 +91,6 @@ function buildGhLogArgs(target) {
82
91
  if (target.kind === "job") return ["run", "view", "--job", target.jobId, "--log-failed", "--repo", target.slug];
83
92
  return null;
84
93
  }
85
- var GH_MAX_BUFFER = 1e7;
86
- var execFileAsync = promisify(execFile);
87
- var runGh = async (args, timeoutMs, signal) => {
88
- try {
89
- const { stdout } = await execFileAsync("gh", args, {
90
- timeout: timeoutMs,
91
- signal,
92
- maxBuffer: GH_MAX_BUFFER,
93
- encoding: "utf8"
94
- });
95
- if (!stdout.trim()) return { ok: false };
96
- return { ok: true, stdout };
97
- } catch {
98
- return { ok: false };
99
- }
100
- };
101
94
  function planGhRouting(params, url) {
102
95
  if (params.raw) return null;
103
96
  if ((params.method ?? "GET") !== "GET") return null;
@@ -176,16 +169,6 @@ async function executeGhRouting(params, url, signal, runner = runGh) {
176
169
  }
177
170
  return renderGhResult(target, gh.stdout, failedLogs);
178
171
  }
179
- var PARSABLE_MAX_BYTES = 1e6;
180
- var BINARY_MAX_BYTES = 5e7;
181
- var SNIFF_MAX_BYTES = 64e3;
182
- var DEFAULT_TIMEOUT_MS = 2e4;
183
- var INLINE_MAX_BYTES = 32e3;
184
- var INLINE_MAX_LINES = 1e3;
185
- var PREVIEW_LINES = 60;
186
- var PREVIEW_MAX_BYTES = 4e3;
187
- var FIREFOX_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 14.7; rv:135.0) Gecko/20100101 Firefox/135.0";
188
- var DEFAULT_ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8";
189
172
  function parseCharset(contentType) {
190
173
  const m = /charset\s*=\s*"?([^";\s]+)"?/i.exec(contentType);
191
174
  return (m?.[1] ?? "utf-8").trim().toLowerCase();
@@ -205,27 +188,9 @@ function buildPreview(body) {
205
188
  }
206
189
  return preview;
207
190
  }
208
- var turndownService = new TurndownService({
209
- headingStyle: "atx",
210
- codeBlockStyle: "fenced",
211
- bulletListMarker: "-"
212
- });
213
- turndownService.use(gfm);
214
191
  function mimeType(contentType) {
215
192
  return contentType.split(";")[0].trim().toLowerCase();
216
193
  }
217
- var TEXT_ALLOWLIST = [
218
- /^text\//,
219
- /^application\/(json|xml|xhtml\+xml|javascript)$/,
220
- /\+json$/,
221
- /\+xml$/
222
- ];
223
- var KNOWN_BINARY = [
224
- /^audio\//,
225
- /^video\//,
226
- /^font\//,
227
- /^application\/(pdf|zip|gzip|x-tar|x-7z-compressed|x-rar-compressed|wasm)$/
228
- ];
229
194
  function categorize(contentType, sniff, raw) {
230
195
  const mime = mimeType(contentType);
231
196
  if (/^image\//.test(mime)) return "binary";
@@ -265,6 +230,9 @@ function htmlToMarkdown(html, url) {
265
230
  ${md}`;
266
231
  return md;
267
232
  }
233
+ function htmlToMarkdownRaw(html) {
234
+ return turndownService.turndown(html).trim();
235
+ }
268
236
  function prettyJson(text) {
269
237
  try {
270
238
  return JSON.stringify(JSON.parse(text), null, 2);
@@ -300,18 +268,6 @@ function textExtension(category, contentType) {
300
268
  if (category === "json") return "json";
301
269
  return mimeType(contentType).includes("xml") ? "xml" : "txt";
302
270
  }
303
- var BINARY_EXT = {
304
- "application/pdf": "pdf",
305
- "application/zip": "zip",
306
- "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
307
- "application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
308
- "application/gzip": "gz",
309
- "image/png": "png",
310
- "image/jpeg": "jpg",
311
- "image/gif": "gif",
312
- "image/webp": "webp",
313
- "image/svg+xml": "svg"
314
- };
315
271
  function binaryExtension(contentType) {
316
272
  const mime = mimeType(contentType);
317
273
  if (BINARY_EXT[mime]) return BINARY_EXT[mime];
@@ -515,16 +471,103 @@ async function fetchUrl(opts) {
515
471
  opts.signal?.removeEventListener("abort", onAbort);
516
472
  }
517
473
  }
474
+ var RESERVED_OWNERS, GH_NAME, GH_MAX_BUFFER, execFileAsync, runGh, PARSABLE_MAX_BYTES, BINARY_MAX_BYTES, SNIFF_MAX_BYTES, DEFAULT_TIMEOUT_MS, INLINE_MAX_BYTES, INLINE_MAX_LINES, PREVIEW_LINES, PREVIEW_MAX_BYTES, FIREFOX_UA, DEFAULT_ACCEPT, turndownService, TEXT_ALLOWLIST, KNOWN_BINARY, BINARY_EXT;
475
+ var init_fetch_core = __esm({
476
+ "lib/fetch-core.ts"() {
477
+ "use strict";
478
+ RESERVED_OWNERS = /* @__PURE__ */ new Set([
479
+ "orgs",
480
+ "users",
481
+ "sponsors",
482
+ "topics",
483
+ "marketplace",
484
+ "apps",
485
+ "collections",
486
+ "stars",
487
+ "settings",
488
+ "notifications",
489
+ "codespaces",
490
+ "features",
491
+ "trending",
492
+ "security",
493
+ "customer-stories"
494
+ ]);
495
+ GH_NAME = /^[A-Za-z0-9._-]+$/;
496
+ GH_MAX_BUFFER = 1e7;
497
+ execFileAsync = promisify(execFile);
498
+ runGh = async (args, timeoutMs, signal) => {
499
+ try {
500
+ const { stdout } = await execFileAsync("gh", args, {
501
+ timeout: timeoutMs,
502
+ signal,
503
+ maxBuffer: GH_MAX_BUFFER,
504
+ encoding: "utf8"
505
+ });
506
+ if (!stdout.trim()) return { ok: false };
507
+ return { ok: true, stdout };
508
+ } catch {
509
+ return { ok: false };
510
+ }
511
+ };
512
+ PARSABLE_MAX_BYTES = 1e6;
513
+ BINARY_MAX_BYTES = 5e7;
514
+ SNIFF_MAX_BYTES = 64e3;
515
+ DEFAULT_TIMEOUT_MS = 2e4;
516
+ INLINE_MAX_BYTES = 32e3;
517
+ INLINE_MAX_LINES = 1e3;
518
+ PREVIEW_LINES = 60;
519
+ PREVIEW_MAX_BYTES = 4e3;
520
+ FIREFOX_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 14.7; rv:135.0) Gecko/20100101 Firefox/135.0";
521
+ DEFAULT_ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8";
522
+ turndownService = new TurndownService({
523
+ headingStyle: "atx",
524
+ codeBlockStyle: "fenced",
525
+ bulletListMarker: "-"
526
+ });
527
+ turndownService.use(gfm);
528
+ TEXT_ALLOWLIST = [
529
+ /^text\//,
530
+ /^application\/(json|xml|xhtml\+xml|javascript)$/,
531
+ /\+json$/,
532
+ /\+xml$/
533
+ ];
534
+ KNOWN_BINARY = [
535
+ /^audio\//,
536
+ /^video\//,
537
+ /^font\//,
538
+ /^application\/(pdf|zip|gzip|x-tar|x-7z-compressed|x-rar-compressed|wasm)$/
539
+ ];
540
+ BINARY_EXT = {
541
+ "application/pdf": "pdf",
542
+ "application/zip": "zip",
543
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
544
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
545
+ "application/gzip": "gz",
546
+ "image/png": "png",
547
+ "image/jpeg": "jpg",
548
+ "image/gif": "gif",
549
+ "image/webp": "webp",
550
+ "image/svg+xml": "svg"
551
+ };
552
+ }
553
+ });
554
+
555
+ // bin/pi-quiver.ts
556
+ init_fetch_core();
557
+ import { existsSync as existsSync3, readFileSync as readFileSync3, realpathSync } from "node:fs";
558
+ import { homedir as homedir2 } from "node:os";
559
+ import { join as join4, resolve as resolve3 } from "node:path";
560
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
518
561
 
519
562
  // lib/doc-to-md-core.ts
520
- import { existsSync as existsSync2, mkdtempSync, readdirSync as readdirSync2, renameSync as renameSync2, rmSync as rmSync2, statSync as statSync2 } from "node:fs";
563
+ import { copyFileSync, existsSync as existsSync2, mkdirSync as mkdirSync3, mkdtempSync, readFileSync as readFileSync2, readdirSync as readdirSync2, renameSync as renameSync2, rmSync as rmSync2, statSync as statSync2, writeFileSync as writeFileSync3 } from "node:fs";
521
564
  import { spawn } from "node:child_process";
522
565
  import { homedir, tmpdir as tmpdir3 } from "node:os";
523
- import { basename, dirname, extname as extname3, join as join3, resolve as resolve2 } from "node:path";
566
+ import { basename, dirname, extname as extname3, isAbsolute, join as join3, resolve as resolve2 } from "node:path";
524
567
  import { fileURLToPath, pathToFileURL } from "node:url";
525
568
 
526
569
  // lib/doc-to-md-bundle.ts
527
- import fs, { closeSync, existsSync, mkdirSync as mkdirSync2, openSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync as writeFileSync2 } from "node:fs";
570
+ import fs, { closeSync, existsSync, mkdirSync as mkdirSync2, openSync, readdirSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync as writeFileSync2 } from "node:fs";
528
571
  import { tmpdir as tmpdir2 } from "node:os";
529
572
  import { extname, join as join2, resolve } from "node:path";
530
573
  import { randomBytes } from "node:crypto";
@@ -535,31 +578,59 @@ function ownedPattern(stem) {
535
578
  function ownedCsvPattern(stem) {
536
579
  return new RegExp(`^${escRe(stem)}-s\\d+-[a-z0-9-]+\\.csv$`);
537
580
  }
538
- var SHEET_LINK_RE = /\[[^\]]*\]\(\s*(sheets\/[^)\s]+)\s*\)/g;
581
+ function ownedPagePattern(stem) {
582
+ return new RegExp(`^${escRe(stem)}-p\\d+\\.[a-z0-9]+$`);
583
+ }
584
+ var FILE_LINK_RE = /\[[^\]]*\]\(\s*((?:sheets|attachments)\/[^)\s]+)\s*\)/g;
539
585
  var IMG_LINK_RE = /!\[[^\]]*\]\(\s*(?:<([^>]*)>|([^)]*?))\s*\)|<img\b[^>]*\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>"']+))/gi;
540
586
  function imageTarget(m) {
541
587
  const target = (m[1] ?? m[2] ?? m[3] ?? m[4] ?? m[5] ?? "").trim();
542
588
  const destination = target.replace(/^<([\s\S]*)>$/, "$1").trim();
543
589
  return m[2] === void 0 ? destination : destination.replace(/\s+(?:"[^"]*"|'[^']*'|\([^)]*\))$/, "");
544
590
  }
545
- function openBundle(root, stem, overwrite) {
591
+ function openBundle(root, requested, overwrite) {
546
592
  mkdirSync2(root, { recursive: true });
547
- const mdPath = join2(root, `${stem}.md`);
548
- const lockPath = `${mdPath}.lock`;
549
- let fd;
550
- try {
551
- fd = openSync(lockPath, "wx");
552
- } catch (e) {
553
- if (e.code === "EEXIST") throw new Error(`Another conversion owns ${mdPath} (lock: ${lockPath}); if no conversion is running, delete the lock`);
554
- throw new Error(`Output directory not writable: ${root} (${e.message})`);
593
+ let stem, mdPath, lockPath;
594
+ let renameReason = null;
595
+ const exists = `${requested}.md exists`;
596
+ const held = `${requested}.md.lock held; delete it if no conversion is running`;
597
+ for (let i = 1; ; i++) {
598
+ stem = i === 1 ? requested : `${requested}-${i}`;
599
+ mdPath = join2(root, `${stem}.md`);
600
+ lockPath = `${mdPath}.lock`;
601
+ if (!overwrite) {
602
+ const mdExists = existsSync(mdPath);
603
+ if (mdExists || existsSync(lockPath)) {
604
+ if (i === 1) renameReason = mdExists ? exists : held;
605
+ continue;
606
+ }
607
+ }
608
+ let fd;
609
+ try {
610
+ fd = openSync(lockPath, "wx");
611
+ } catch (e) {
612
+ if (e.code !== "EEXIST") throw new Error(`Output directory not writable: ${root} (${e.message})`);
613
+ if (overwrite) throw new Error(`Another conversion owns ${mdPath} (lock: ${lockPath}); if no conversion is running, delete the lock`);
614
+ if (i === 1) renameReason = held;
615
+ continue;
616
+ }
617
+ closeSync(fd);
618
+ if (!overwrite && existsSync(mdPath)) {
619
+ unlinkSync(lockPath);
620
+ if (i === 1) renameReason = exists;
621
+ continue;
622
+ }
623
+ break;
555
624
  }
556
- closeSync(fd);
625
+ const renamedFrom = stem === requested ? null : requested;
557
626
  const imagesDir = join2(root, "images");
558
627
  const sheetsDir = join2(root, "sheets");
628
+ const pagesDir = join2(root, "pages");
629
+ const attachmentsDir = join2(root, "attachments");
559
630
  try {
560
- if (existsSync(mdPath)) {
561
- if (!overwrite) throw new Error(`Output exists: ${mdPath} (pass overwrite)`);
562
- const owned = ownedPattern(stem), ownedCsv = ownedCsvPattern(stem);
631
+ if (existsSync(mdPath) && overwrite) {
632
+ const owned = ownedPattern(stem), ownedCsv = ownedCsvPattern(stem), ownedPage = ownedPagePattern(stem);
633
+ const attachments = new Set([...readFileSync(mdPath, "utf8").matchAll(FILE_LINK_RE)].filter((m) => m[1].startsWith("attachments/")).map((m) => m[1].slice("attachments/".length)).filter((f) => f.startsWith(`${stem}-`)));
563
634
  unlinkSync(mdPath);
564
635
  if (existsSync(imagesDir)) {
565
636
  for (const f of readdirSync(imagesDir)) if (owned.test(f)) rmSync(join2(imagesDir, f), { force: true });
@@ -567,12 +638,20 @@ function openBundle(root, stem, overwrite) {
567
638
  if (existsSync(sheetsDir)) {
568
639
  for (const f of readdirSync(sheetsDir)) if (ownedCsv.test(f)) rmSync(join2(sheetsDir, f), { force: true });
569
640
  }
641
+ if (existsSync(pagesDir)) {
642
+ for (const f of readdirSync(pagesDir)) if (ownedPage.test(f)) rmSync(join2(pagesDir, f), { force: true });
643
+ }
644
+ if (existsSync(attachmentsDir)) {
645
+ for (const f of readdirSync(attachmentsDir)) if (attachments.has(f)) rmSync(join2(attachmentsDir, f), { force: true });
646
+ }
570
647
  }
571
648
  const lockId = randomBytes(6).toString("hex");
572
649
  const stagingDir = join2(imagesDir, `.stage-${lockId}`);
573
650
  const sheetsStagingDir = join2(sheetsDir, `.stage-${lockId}`);
574
651
  mkdirSync2(stagingDir, { recursive: true });
575
- return { root, stem, mdPath, lockPath, imagesDir, stagingDir, lockId, sheetsDir, sheetsStagingDir, manifest: /* @__PURE__ */ new Set(), csvManifest: /* @__PURE__ */ new Set(), sourceMap: /* @__PURE__ */ new Map() };
652
+ const pagesStagingDir = join2(pagesDir, `.stage-${lockId}`);
653
+ const attachmentsStagingDir = join2(attachmentsDir, `.stage-${lockId}`);
654
+ return { root, stem, renamedFrom, renameReason, mdPath, lockPath, imagesDir, stagingDir, lockId, sheetsDir, sheetsStagingDir, pagesDir, pagesStagingDir, attachmentsDir, attachmentsStagingDir, manifest: /* @__PURE__ */ new Set(), csvManifest: /* @__PURE__ */ new Set(), pageManifest: /* @__PURE__ */ new Set(), attachmentManifest: /* @__PURE__ */ new Set(), sourceMap: /* @__PURE__ */ new Map() };
576
655
  } catch (e) {
577
656
  rmSync(lockPath, { force: true });
578
657
  throw e;
@@ -623,24 +702,54 @@ function publishSheetCsvs(b) {
623
702
  b.sourceMap.set(`sheets/${f}`, `sheets/${name}`);
624
703
  }
625
704
  }
705
+ function publishPageImages(b, pageCount) {
706
+ if (!existsSync(b.pagesStagingDir)) return;
707
+ for (const f of readdirSync(b.pagesStagingDir).sort()) {
708
+ const m = f.match(/^p(\d+)(\.[a-z0-9]+)$/i);
709
+ if (!m || !statSync(join2(b.pagesStagingDir, f)).isFile()) continue;
710
+ const name = `${b.stem}-p${m[1].padStart(String(pageCount).length, "0")}${m[2]}`;
711
+ mkdirSync2(b.pagesDir, { recursive: true });
712
+ renameSync(join2(b.pagesStagingDir, f), join2(b.pagesDir, name));
713
+ b.pageManifest.add(name);
714
+ b.sourceMap.set(`pages/${f}`, `pages/${name}`);
715
+ }
716
+ rmSync(b.pagesStagingDir, { recursive: true, force: true });
717
+ }
718
+ function publishAttachments(b) {
719
+ if (!existsSync(b.attachmentsStagingDir)) return;
720
+ for (const f of readdirSync(b.attachmentsStagingDir).sort()) {
721
+ if (!statSync(join2(b.attachmentsStagingDir, f)).isFile()) continue;
722
+ const name = `${b.stem}-${f}`;
723
+ mkdirSync2(b.attachmentsDir, { recursive: true });
724
+ renameSync(join2(b.attachmentsStagingDir, f), join2(b.attachmentsDir, name));
725
+ b.attachmentManifest.add(name);
726
+ b.sourceMap.set(`attachments/${f}`, `attachments/${name}`);
727
+ }
728
+ rmSync(b.attachmentsStagingDir, { recursive: true, force: true });
729
+ }
626
730
  function rewriteLinks(md, sourceMap) {
627
731
  const images = md.replace(IMG_LINK_RE, (whole, mdAngle, mdPlain, htmlDouble, htmlSingle, htmlUnquoted) => {
628
732
  const target = imageTarget([whole, mdAngle, mdPlain, htmlDouble, htmlSingle, htmlUnquoted]);
629
733
  const dest = sourceMap.get(target) ?? sourceMap.get(target.replace(/^\.\//, ""));
630
734
  return dest ? whole.replace(mdAngle === void 0 ? target : `<${mdAngle}>`, dest) : whole;
631
735
  });
632
- return images.replace(SHEET_LINK_RE, (whole, target) => {
736
+ return images.replace(FILE_LINK_RE, (whole, target) => {
633
737
  const dest = sourceMap.get(target);
634
738
  return dest ? whole.split(target).join(dest) : whole;
635
739
  });
636
740
  }
637
- function validateImageLinks(md, manifest, csvManifest = /* @__PURE__ */ new Set()) {
741
+ function validateImageLinks(md, manifest, csvManifest = /* @__PURE__ */ new Set(), html = false, pageManifest = /* @__PURE__ */ new Set(), attachmentManifest = /* @__PURE__ */ new Set()) {
638
742
  for (const m of md.matchAll(IMG_LINK_RE)) {
639
743
  const target = imageTarget(m);
640
- if (!target.startsWith("images/") || !manifest.has(target.slice("images/".length))) throw new Error(`unexpected image reference in output: ${target}`);
744
+ if (html && !target.replace(/^\.\//, "").startsWith("p1/")) continue;
745
+ if (target.startsWith("pages/")) {
746
+ if (!pageManifest.has(target.slice("pages/".length))) throw new Error(`unexpected image reference in output: ${target}`);
747
+ } else if (!target.startsWith("images/") || !manifest.has(target.slice("images/".length))) throw new Error(`unexpected image reference in output: ${target}`);
641
748
  }
642
- for (const m of md.matchAll(SHEET_LINK_RE)) {
643
- if (!csvManifest.has(m[1].slice("sheets/".length))) throw new Error(`unexpected sheet reference in output: ${m[1]}`);
749
+ for (const m of md.matchAll(FILE_LINK_RE)) {
750
+ if (m[1].startsWith("attachments/")) {
751
+ if (!attachmentManifest.has(m[1].slice("attachments/".length))) throw new Error(`unexpected attachment reference in output: ${m[1]}`);
752
+ } else if (!csvManifest.has(m[1].slice("sheets/".length))) throw new Error(`unexpected sheet reference in output: ${m[1]}`);
644
753
  }
645
754
  }
646
755
  function commitBundle(b, markdown) {
@@ -655,6 +764,14 @@ function commitBundle(b, markdown) {
655
764
  fs.rmSync(b.sheetsStagingDir, { recursive: true, force: true });
656
765
  } catch {
657
766
  }
767
+ try {
768
+ fs.rmSync(b.pagesStagingDir, { recursive: true, force: true });
769
+ } catch {
770
+ }
771
+ try {
772
+ fs.rmSync(b.attachmentsStagingDir, { recursive: true, force: true });
773
+ } catch {
774
+ }
658
775
  try {
659
776
  fs.rmSync(b.lockPath, { force: true });
660
777
  } catch {
@@ -663,9 +780,13 @@ function commitBundle(b, markdown) {
663
780
  function abortBundle(b) {
664
781
  for (const f of b.manifest) rmSync(join2(b.imagesDir, f), { force: true });
665
782
  for (const f of b.csvManifest) rmSync(join2(b.sheetsDir, f), { force: true });
783
+ for (const f of b.pageManifest) rmSync(join2(b.pagesDir, f), { force: true });
784
+ for (const f of b.attachmentManifest) rmSync(join2(b.attachmentsDir, f), { force: true });
666
785
  rmSync(`${b.mdPath}.tmp`, { force: true });
667
786
  rmSync(b.stagingDir, { recursive: true, force: true });
668
787
  rmSync(b.sheetsStagingDir, { recursive: true, force: true });
788
+ rmSync(b.pagesStagingDir, { recursive: true, force: true });
789
+ rmSync(b.attachmentsStagingDir, { recursive: true, force: true });
669
790
  rmSync(b.lockPath, { force: true });
670
791
  }
671
792
  function tempBundleRoot() {
@@ -697,6 +818,33 @@ function compactRanges(nums, maxEntries = 20) {
697
818
  if (parts.length <= maxEntries) return parts.join(", ");
698
819
  return `${parts.slice(0, maxEntries).join(", ")} (+${parts.length - maxEntries} more)`;
699
820
  }
821
+ var INSTALL_HINT = "install Tesseract (see doc/doc-to-md.md), then rerun with ocr=true";
822
+ var BARE_REASONS = ["fallback tier", "no Python backend"];
823
+ function ocrLine(ocr, type) {
824
+ const image = type === "image";
825
+ const rerun = ocr.tesseract ? "rerun with ocr=true" : INSTALL_HINT;
826
+ switch (ocr.status) {
827
+ case "off":
828
+ return image ? `OCR: off; ${rerun}` : `OCR: off - ${ocr.textless.length} page(s) without a text layer; ${rerun}`;
829
+ case "unavailable": {
830
+ const reason = ocr.reason ?? "";
831
+ const bare = BARE_REASONS.includes(reason) || reason.startsWith("OCR child failed: ");
832
+ return `OCR: unavailable - ${reason}${bare ? "" : " (install Tesseract; see doc/doc-to-md.md)"}`;
833
+ }
834
+ case "skipped":
835
+ return "OCR: skipped - image too small";
836
+ case "ran": {
837
+ const clauses = [`OCR: ${ocr.pages.length} ${image ? "image" : "page(s)"} (${ocr.lang})`];
838
+ if (ocr.noText.length) clauses.push(`no text on pages ${compactRanges(ocr.noText)}`);
839
+ if (ocr.ocrFailed.length) clauses.push(`${ocr.ocrFailed.length} failed and were converted without OCR`);
840
+ if (ocr.budgetStopped.length) {
841
+ const r = compactRanges(ocr.budgetStopped, Number.POSITIVE_INFINITY).replaceAll(", ", ",");
842
+ clauses.push(`time budget reached for pages=${r}; rerun with pages=${r} or raise primaryTimeoutMs`);
843
+ }
844
+ return clauses.join("; ");
845
+ }
846
+ }
847
+ }
700
848
  var PAGE_MARKER_RE = /^--- end of page\.page_number=(\d+) ---$/;
701
849
  function scanOutline(md, max) {
702
850
  const entries = [];
@@ -739,13 +887,15 @@ function outlineLines(entries, total) {
739
887
  function pageCountLabel(h) {
740
888
  const n = h.pageCount ?? "?";
741
889
  if (h.type !== "docx") return String(n);
742
- if (h.tier === "docx") return `${n} (${(h.explicitBreaks ?? 0) > 0 ? "explicit page breaks, not printed pages" : "no explicit page breaks"})`;
890
+ if (h.tier === "docx") return (h.explicitBreaks ?? 0) > 0 ? `${n} (explicit page breaks, not printed pages)` : `${n} (no explicit page breaks) - no page markers; cite by Outline line`;
743
891
  return `${n} (LibreOffice pagination)`;
744
892
  }
745
893
  function formatHandle(h) {
746
894
  const lines = [`Saved-To: ${h.savedTo}`];
747
895
  if (h.imagesDir && h.imageCount > 0) lines.push(`Images-Dir: ${h.imagesDir}`);
748
896
  if (h.sheetsDir) lines.push(`Sheets-Dir: ${h.sheetsDir}`);
897
+ if (h.pagesDir && h.pageImageCount > 0) lines.push(`Pages-Dir: ${h.pagesDir} (${h.pageImageCount} pages)`);
898
+ else if (h.pageImagesReason) lines.push(`Pages-Dir: none - ${h.pageImagesReason}`);
749
899
  lines.push(`Type: ${h.type} Engine: ${h.engine} Tier: ${h.tier}`);
750
900
  lines.push(`Page-Count: ${pageCountLabel(h)} Pages: ${h.pages ? compactRanges(h.pages) : "all"} Images: ${h.imageCount} Size: ${formatSize2(h.bytes)} / ${h.lines} lines`);
751
901
  if (h.degraded) lines.push(`Degraded: ${h.degraded}`);
@@ -754,7 +904,8 @@ function formatHandle(h) {
754
904
  if (h.failedPages.length) fe.push(`Failed-Pages: ${compactRanges(h.failedPages)}`);
755
905
  if (h.emptyPages.length) fe.push(`Empty-Pages: ${compactRanges(h.emptyPages)}`);
756
906
  if (fe.length) lines.push(fe.join(" "));
757
- h.notes.slice(0, NOTE_MAX_LINES).forEach((n, i) => lines.push(`${i === 0 ? "Notes: " : " "}${trunc(n, NOTE_MAX_CHARS)}`));
907
+ if (h.ocr) lines.push(ocrLine(h.ocr, h.type));
908
+ h.notes.slice(0, NOTE_MAX_LINES).forEach((n, i) => lines.push(`${i === 0 ? "Notes: " : " "}${n.startsWith("preview truncated:") ? n : trunc(n, NOTE_MAX_CHARS)}`));
758
909
  lines.push(...outlineLines(h.outline, h.outlineTotal));
759
910
  return lines.join("\n");
760
911
  }
@@ -785,12 +936,15 @@ var UsageError = class extends Error {
785
936
  };
786
937
  var MIN_PYMUPDF4LLM = "1.27.0";
787
938
  var VERSION_RE = /^\d+(\.\d+)*$/;
939
+ var OCR_LANGUAGE_RE = /^[a-z][a-z0-9_]*(\+[a-z][a-z0-9_]*)*$/;
940
+ var IMAGE_EXTS = [".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp", ".gif"];
788
941
  var DOC_TO_MD_OPTIONS = [
789
- { key: "path", flag: null, type: "string", default: null, settable: false, help: "Local .pdf .docx .pptx .xlsx .xls file" },
942
+ { key: "path", flag: null, type: "string", default: null, settable: false, help: "Local .pdf .docx .doc .pptx .xlsx .xlsm .xls .msg .eml .html .htm .png .jpg .jpeg .tif .tiff .bmp .gif file" },
790
943
  { key: "info", flag: "--info", type: "bool", default: false, settable: false, help: "Inspect only (page count, metadata, TOC or sheet inventory); no bundle" },
791
- { 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' },
792
- { key: "outputDir", flag: "--output-dir", type: "string", default: null, settable: false, help: "Bundle root for <stem>.md + images/; default a per-call temp dir" },
944
+ { 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/DOC/PPTX only); default all; "" means all pages. DOCX: selects explicit-page-break segments; rejected when the file has none' },
945
+ { key: "outputDir", flag: "--output-dir", type: "string", default: null, settable: false, help: "Bundle root for <stem>.md + images/; default a per-call temp dir. <stem> = basename without extension with [^A-Za-z0-9._-]+ -> _ (empty -> document); a second call on the same stem writes <stem>-2.md" },
793
946
  { key: "overwrite", flag: "--overwrite", type: "bool", default: false, settable: false, help: "Replace an existing completed <stem>.md bundle" },
947
+ { key: "pageImages", flag: "--page-images", type: "bool", default: false, settable: false, help: "Also render every selected page to pages/<stem>-pNNN.<imageFormat> at imageDpi (PDF, PPTX, .doc, DOCX via LibreOffice); off by default" },
794
948
  { key: "primaryTimeoutMs", flag: "--primary-timeout", type: "int", default: 6e4, settable: true, env: "PI_DOC_TO_MD_CONVERT_TIMEOUT_MS", help: "pymupdf4llm tier and DOCX child (docx mode); also the unpdf tier" },
795
949
  { key: "fallbackTimeoutMs", flag: "--fallback-timeout", type: "int", default: 3e4, settable: true, help: "PyMuPDF get_text tier (including DOCX LibreOffice fallback); also PDF and DOCX info and Excel rendered views" },
796
950
  { key: "sofficeTimeoutMs", flag: "--soffice-timeout", type: "int", default: 12e4, settable: true, env: "PI_DOC_TO_MD_SOFFICE_TIMEOUT_MS", help: "DOCX/PPTX -> PDF via LibreOffice; also Excel rendered views" },
@@ -800,7 +954,9 @@ var DOC_TO_MD_OPTIONS = [
800
954
  { key: "imageDpi", flag: "--image-dpi", type: "int", default: 150, settable: true, help: "Render DPI for page images and Excel rendered views" },
801
955
  { key: "imageFormat", flag: "--image-format", type: "enum", default: "png", settable: true, enumValues: ["png", "jpg"], help: "Rendered image format (embedded images keep their native extension)" },
802
956
  { key: "maxOutputBytes", flag: "--max-output-bytes", type: "int", default: 2e7, settable: true, help: "Child stdout cap in bytes" },
803
- { key: "outlineMaxEntries", flag: "--outline-max-entries", type: "int", default: 40, settable: true, help: "Heading outline / TOC / sheet inventory cap in the handle" }
957
+ { key: "outlineMaxEntries", flag: "--outline-max-entries", type: "int", default: 40, settable: true, help: "Heading outline / TOC / sheet inventory cap in the handle" },
958
+ { key: "ocr", flag: "--ocr", type: "bool", default: false, settable: true, help: "Run OCR on pages without a text layer and on image inputs when Tesseract language data is installed; off by default (--no-ocr turns a settings-level true off)" },
959
+ { key: "ocrLanguage", flag: "--ocr-language", type: "lang", default: "eng", settable: true, help: "Tesseract language code(s), +-joined, e.g. deu+eng" }
804
960
  ];
805
961
  var TUNABLE_DEFAULTS = Object.fromEntries(
806
962
  DOC_TO_MD_OPTIONS.filter((d) => d.settable).map((d) => [d.key, d.default])
@@ -827,6 +983,8 @@ function coerceValue(d, raw, fromString = false) {
827
983
  if (typeof raw !== "string" || !VERSION_RE.test(raw)) return { ok: false, reason: "must be digits and dots" };
828
984
  if (!versionAtLeast(raw, MIN_PYMUPDF4LLM)) return { ok: false, reason: `must be >= ${MIN_PYMUPDF4LLM}` };
829
985
  return { ok: true, value: raw };
986
+ case "lang":
987
+ return typeof raw === "string" && OCR_LANGUAGE_RE.test(raw) ? { ok: true, value: raw } : { ok: false, reason: "must be Tesseract language codes joined by + (e.g. eng, deu+eng)" };
830
988
  case "string":
831
989
  return typeof raw === "string" && raw.length > 0 ? { ok: true, value: raw } : { ok: false, reason: "must be a non-empty string" };
832
990
  case "pages":
@@ -849,6 +1007,7 @@ function coerceDocToMdSettings(raw, warn = console.warn) {
849
1007
  return out;
850
1008
  }
851
1009
  function parsePages(spec) {
1010
+ if (spec.trim() === "") return null;
852
1011
  const out = /* @__PURE__ */ new Set();
853
1012
  const parts = spec.split(",").map((s) => s.trim());
854
1013
  if (parts.length === 0 || parts.some((p) => p === "")) throw new UsageError(`invalid --pages "${spec}": expected e.g. "12-15" or "3,7,10-12"`);
@@ -865,10 +1024,11 @@ function sanitizeStem(base) {
865
1024
  const s = base.replace(/[^A-Za-z0-9._-]+/g, "_");
866
1025
  return s.length ? s : "document";
867
1026
  }
868
- var SUPPORTED = { ".pdf": "pdf", ".docx": "docx", ".pptx": "pptx", ".xlsx": "xlsx", ".xls": "xls" };
1027
+ var SUPPORTED = { ".pdf": "pdf", ".docx": "docx", ".pptx": "pptx", ".xlsx": "xlsx", ".xls": "xls", ".xlsm": "xlsm", ".doc": "doc", ".msg": "email", ".eml": "email", ".html": "html", ".htm": "html", ...Object.fromEntries(IMAGE_EXTS.map((ext) => [ext, "image"])) };
1028
+ var SUPPORTED_EXTENSIONS = Object.keys(SUPPORTED);
869
1029
  function classifyInput(filePath) {
870
1030
  const t = SUPPORTED[extname2(filePath).toLowerCase()];
871
- if (!t) throw new Error(`Unsupported file type "${extname2(filePath) || "(none)"}"; supported: .pdf, .docx, .pptx, .xlsx, .xls`);
1031
+ if (!t) throw new Error(`Unsupported file type "${extname2(filePath) || "(none)"}"; supported: ${Object.keys(SUPPORTED).join(", ")}`);
872
1032
  return t;
873
1033
  }
874
1034
  function resolveOptions(perCall, settings, env) {
@@ -891,8 +1051,8 @@ function resolveOptions(perCall, settings, env) {
891
1051
  out[d.key] = value;
892
1052
  }
893
1053
  const o = out;
894
- if (o.info && (o.pages !== null || o.outputDir !== null || o.overwrite)) {
895
- throw new UsageError("--info cannot be combined with --pages, --output-dir or --overwrite");
1054
+ if (o.info && (o.pages !== null || o.outputDir !== null || o.overwrite || o.pageImages)) {
1055
+ throw new UsageError("--info cannot be combined with --pages, --output-dir, --overwrite or --page-images");
896
1056
  }
897
1057
  return o;
898
1058
  }
@@ -913,14 +1073,14 @@ function renderHelp() {
913
1073
  }
914
1074
 
915
1075
  // lib/doc-to-md-core.ts
916
- var 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" };
1076
+ var 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", "extract-msg": "0.56.1" };
917
1077
  var KILL_GRACE_MS = 2e3;
918
- var VENV_DIR_NAME = "doc-to-md-venv-v3";
919
- var LEGACY_VENV_DIR_NAMES = ["pymupdf-venv", "doc-to-md-venv-v2"];
1078
+ var VENV_DIR_NAME = "doc-to-md-venv-v4";
1079
+ var LEGACY_VENV_DIR_NAMES = ["pymupdf-venv", "doc-to-md-venv-v2", "doc-to-md-venv-v3"];
920
1080
  var STDERR_CAP = 1e6;
921
1081
  var OUTPUT_MAX_BYTES = 2e7;
922
1082
  var EXCEL_PDF_FILTER = 'pdf:calc_pdf_Export:{"SinglePageSheets":{"type":"boolean","value":"true"}}';
923
- var pinSpecs = (cfg) => [`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"]}`];
1083
+ var pinSpecs = (cfg) => [`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"]}`, `extract-msg==${PACKAGE_PINS["extract-msg"]}`];
924
1084
  function withArgs(cfg) {
925
1085
  return pinSpecs(cfg).flatMap((spec) => ["--with", spec]);
926
1086
  }
@@ -928,7 +1088,7 @@ function pipInstallArgs(cfg) {
928
1088
  return ["-m", "pip", "install", ...pinSpecs(cfg)];
929
1089
  }
930
1090
  function warmArgs(cfg) {
931
- return ["run", ...withArgs(cfg), "--python", "3.14", "python", "-c", "import pymupdf4llm, openpyxl, xlrd, PIL, mammoth, markdownify, docx"];
1091
+ return ["run", ...withArgs(cfg), "--python", "3.14", "python", "-c", "import pymupdf4llm, openpyxl, xlrd, PIL, mammoth, markdownify, docx, extract_msg"];
932
1092
  }
933
1093
  function uvChildArgs(cfg, script, mode) {
934
1094
  return ["run", ...withArgs(cfg), "--python", "3.14", "python", script, mode];
@@ -1085,6 +1245,11 @@ try:
1085
1245
  print("DOCX", "yes")
1086
1246
  except Exception:
1087
1247
  print("DOCX", "no")
1248
+ try:
1249
+ import extract_msg, markdownify
1250
+ print("EMAIL", "yes")
1251
+ except Exception:
1252
+ print("EMAIL", "no")
1088
1253
  `;
1089
1254
  var PROBE_TIMEOUT_MS = 5e3;
1090
1255
  function probeArgs() {
@@ -1092,8 +1257,8 @@ function probeArgs() {
1092
1257
  }
1093
1258
  var PYTHON_CANDIDATES = ["python3", "python"];
1094
1259
  function parseProbeOutput(stdout) {
1095
- const m = stdout.match(/^PY (\d+) (\d+)\r?\nPDF (yes|no)\r?\nXLSX (yes|no)\r?\nDOCX (yes|no)\s*$/);
1096
- return m ? { major: Number(m[1]), minor: Number(m[2]), pdf: m[3] === "yes", xlsx: m[4] === "yes", docx: m[5] === "yes" } : null;
1260
+ const m = stdout.match(/^PY (\d+) (\d+)\r?\nPDF (yes|no)\r?\nXLSX (yes|no)\r?\nDOCX (yes|no)\r?\nEMAIL (yes|no)\s*$/);
1261
+ return m ? { major: Number(m[1]), minor: Number(m[2]), pdf: m[3] === "yes", xlsx: m[4] === "yes", docx: m[5] === "yes", email: m[6] === "yes" } : null;
1097
1262
  }
1098
1263
  function meetsFloor(p) {
1099
1264
  return p.major > 3 || p.major === 3 && p.minor >= 12;
@@ -1118,7 +1283,7 @@ async function resolveBackend(cfg, deps, signal) {
1118
1283
  };
1119
1284
  const warm = await deps.run("uv", warmArgs(cfg), { timeoutMs: left(), capBytes: OUTPUT_MAX_BYTES, env: deps.env, signal });
1120
1285
  if (signal?.aborted) throw new Error("aborted");
1121
- if (warm.code === 0 && !warm.timedOut) return { kind: "uv", pdf: true, xlsx: true, docx: true };
1286
+ if (warm.code === 0 && !warm.timedOut) return { kind: "uv", pdf: true, xlsx: true, docx: true, email: true };
1122
1287
  const uvAbsent = warm.code === null && !warm.timedOut;
1123
1288
  const isDeadline = (result) => result !== null && "kind" in result;
1124
1289
  const probe = async (exe, tmp) => {
@@ -1134,19 +1299,19 @@ async function resolveBackend(cfg, deps, signal) {
1134
1299
  const p = await probe(exe);
1135
1300
  if (isDeadline(p)) return p;
1136
1301
  if (!p || !meetsFloor(p)) continue;
1137
- if (p.pdf) return { kind: "python", exe, pdf: true, xlsx: p.xlsx, docx: p.docx };
1302
+ if (p.pdf) return { kind: "python", exe, pdf: true, xlsx: p.xlsx, docx: p.docx, email: p.email };
1138
1303
  eligible ??= { exe, version: `${p.major}.${p.minor}` };
1139
1304
  }
1140
- const healthy = (p) => meetsFloor(p) && p.pdf && p.xlsx && p.docx;
1305
+ const healthy = (p) => meetsFloor(p) && p.pdf && p.xlsx && p.docx && p.email;
1141
1306
  const venvDir = join3(deps.cacheRoot, VENV_DIR_NAME);
1142
1307
  const venvExe = venvPython(venvDir, deps.platform);
1143
1308
  const cached = await probe(venvExe);
1144
1309
  if (isDeadline(cached)) return cached;
1145
- if (cached && healthy(cached)) return { kind: "venv", exe: venvExe, pdf: true, xlsx: true, docx: true };
1310
+ if (cached && healthy(cached)) return { kind: "venv", exe: venvExe, pdf: true, xlsx: true, docx: true, email: true };
1146
1311
  if (eligible) {
1147
1312
  const recheck = await probe(venvExe);
1148
1313
  if (isDeadline(recheck)) return recheck;
1149
- if (recheck && healthy(recheck)) return { kind: "venv", exe: venvExe, pdf: true, xlsx: true, docx: true };
1314
+ if (recheck && healthy(recheck)) return { kind: "venv", exe: venvExe, pdf: true, xlsx: true, docx: true, email: true };
1150
1315
  const tmp = `${venvDir}.tmp-${deps.pid}`;
1151
1316
  const bootFail = (stderr) => {
1152
1317
  deps.rmrf(tmp);
@@ -1172,18 +1337,18 @@ async function resolveBackend(cfg, deps, signal) {
1172
1337
  };
1173
1338
  if (publish()) {
1174
1339
  for (const legacy of LEGACY_VENV_DIR_NAMES) deps.rmrf(join3(deps.cacheRoot, legacy));
1175
- return { kind: "venv", exe: venvExe, pdf: true, xlsx: true, docx: true };
1340
+ return { kind: "venv", exe: venvExe, pdf: true, xlsx: true, docx: true, email: true };
1176
1341
  }
1177
1342
  const winner = await probe(venvExe, tmp);
1178
1343
  if (isDeadline(winner)) return winner;
1179
1344
  if (winner && healthy(winner)) {
1180
1345
  deps.rmrf(tmp);
1181
- return { kind: "venv", exe: venvExe, pdf: true, xlsx: true, docx: true };
1346
+ return { kind: "venv", exe: venvExe, pdf: true, xlsx: true, docx: true, email: true };
1182
1347
  }
1183
1348
  deps.rmrf(venvDir);
1184
1349
  if (publish()) {
1185
1350
  for (const legacy of LEGACY_VENV_DIR_NAMES) deps.rmrf(join3(deps.cacheRoot, legacy));
1186
- return { kind: "venv", exe: venvExe, pdf: true, xlsx: true, docx: true };
1351
+ return { kind: "venv", exe: venvExe, pdf: true, xlsx: true, docx: true, email: true };
1187
1352
  }
1188
1353
  return bootFail("rename after competing bootstrap");
1189
1354
  }
@@ -1247,6 +1412,76 @@ function officeFailure(r) {
1247
1412
  return new Error(`soffice failed (code=${r.code} timedOut=${r.timedOut}): ${r.stderr}`);
1248
1413
  }
1249
1414
  var DEGRADED_TEXT = "PyMuPDF text extraction - layout/tables not preserved";
1415
+ var DEGRADED_HTML_TURNDOWN = "Turndown HTML conversion - definition lists and headerless tables not preserved";
1416
+ var DATA_IMAGE_RE = /^data:image\/(png|jpeg|gif|bmp|tiff);base64,([A-Za-z0-9+/=\s]+)$/i;
1417
+ var DATA_EXT = { png: ".png", jpeg: ".jpg", gif: ".gif", bmp: ".bmp", tiff: ".tif" };
1418
+ var DATA_SIGNATURES = {
1419
+ png: [[137, 80, 78, 71]],
1420
+ jpeg: [[255, 216, 255]],
1421
+ gif: [[71, 73, 70, 56]],
1422
+ bmp: [[66, 77]],
1423
+ tiff: [[73, 73, 42, 0], [77, 77, 0, 42]]
1424
+ };
1425
+ async function prepareHtml(inputPath, stagingDir) {
1426
+ const { JSDOM: JSDOM2 } = await import("jsdom");
1427
+ const bytes = readFileSync2(inputPath);
1428
+ let source = bytes;
1429
+ try {
1430
+ source = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
1431
+ } catch {
1432
+ }
1433
+ const dom = new JSDOM2(source);
1434
+ const doc = dom.window.document;
1435
+ const title = doc.querySelector("title")?.textContent?.trim();
1436
+ if (title && !doc.body.querySelector("h1")) {
1437
+ const h1 = doc.createElement("h1");
1438
+ h1.textContent = title;
1439
+ doc.body.prepend(h1);
1440
+ }
1441
+ for (const el of [...doc.querySelectorAll("head, script, style, noscript, template")]) el.remove();
1442
+ const dir = join3(stagingDir, "p1");
1443
+ mkdirSync3(dir, { recursive: true });
1444
+ let k = 0, missing = 0;
1445
+ for (const img of [...doc.querySelectorAll("img")]) {
1446
+ const src = (img.getAttribute("src") ?? "").trim();
1447
+ const alt = img.getAttribute("alt") ?? "";
1448
+ if (/^(?:https?:)?\/\//i.test(src)) {
1449
+ const a = doc.createElement("a");
1450
+ const url = src.startsWith("//") ? `https:${src}` : src;
1451
+ a.setAttribute("href", url);
1452
+ a.textContent = alt || url;
1453
+ img.replaceWith(a);
1454
+ continue;
1455
+ }
1456
+ let target = null;
1457
+ const data = src.match(DATA_IMAGE_RE);
1458
+ if (data) {
1459
+ const buf = Buffer.from(data[2].replace(/\s+/g, ""), "base64");
1460
+ if (DATA_SIGNATURES[data[1].toLowerCase()].some((signature) => signature.every((byte, i) => buf[i] === byte))) {
1461
+ target = `p1/${++k}${DATA_EXT[data[1].toLowerCase()]}`;
1462
+ writeFileSync3(join3(stagingDir, target), buf);
1463
+ }
1464
+ } else if (src && !/^[a-z][a-z0-9+.-]*:/i.test(src) && !isAbsolute(src)) {
1465
+ let file = null;
1466
+ try {
1467
+ file = resolve2(dirname(inputPath), decodeURIComponent(src.split(/[?#]/)[0]));
1468
+ } catch {
1469
+ }
1470
+ const ext = file ? extname3(file).toLowerCase() : "";
1471
+ if (file && IMAGE_EXTS.includes(ext) && statSync2(file, { throwIfNoEntry: false })?.isFile()) {
1472
+ target = `p1/${++k}${ext}`;
1473
+ copyFileSync(file, join3(stagingDir, target));
1474
+ }
1475
+ }
1476
+ if (target) img.setAttribute("src", target);
1477
+ else {
1478
+ missing++;
1479
+ img.replaceWith(doc.createTextNode(alt));
1480
+ }
1481
+ }
1482
+ writeFileSync3(join3(dir, ".done"), "");
1483
+ return { html: dom.serialize(), missing };
1484
+ }
1250
1485
  var DEGRADED_UNPDF = "unpdf text extraction - structure not preserved";
1251
1486
  var DEGRADED_DOCX_TEXT = "python-docx text extraction - footnotes, hyperlinks, images not preserved";
1252
1487
  var DEGRADED_DOCX_OFFICE = "LibreOffice PDF route - heading styles and explicit page breaks not preserved; page numbers are LibreOffice pagination";
@@ -1312,26 +1547,96 @@ function reconcileRenderMarkers(md, renderPages, fmt, sourceMap, reason) {
1312
1547
  if (/<!--rvs?:\d+-->/.test(md)) throw new Error("internal: unresolved render marker");
1313
1548
  return md;
1314
1549
  }
1550
+ var emptyOcr = (lang) => ({ status: "off", lang, textless: [], pages: [], noText: [], ocrFailed: [], budgetStopped: [], reason: null, tesseract: null });
1551
+ var OCR_SENTINEL_RE = /\x00OCR ([^\x00]*)\x00/g;
1552
+ function resolveOcrLabels(md, sourceMap) {
1553
+ return md.replace(OCR_SENTINEL_RE, (_, key) => {
1554
+ const dest = sourceMap.get(key);
1555
+ return dest ? `> Text recognized in ${dest} (OCR, may contain recognition errors):` : "> Text recognized by OCR (source image missing):";
1556
+ });
1557
+ }
1558
+ function handleOcr(tier, type, o, json) {
1559
+ if (tier === "unpdf") return o.ocr ? { ...emptyOcr(o.ocrLanguage), status: "unavailable", reason: "no Python backend" } : null;
1560
+ const x = json.ocr;
1561
+ if (!x || type !== "image" && !x.textless.length && !x.pages.length && !x.ocrFailed.length) return null;
1562
+ return x;
1563
+ }
1315
1564
  async function convertDocument(o, signal, seams) {
1316
1565
  const s = { backend: (c) => getBackend(c, void 0, signal), runTier: runTierReal, office: tryConvertOffice, ...seams };
1317
1566
  const inputPath = resolve2(o.path);
1318
1567
  const st = statSync2(inputPath, { throwIfNoEntry: false });
1319
1568
  if (!st || !st.isFile()) throw new Error(`Not a readable file: ${o.path}`);
1569
+ if (st.size === 0) throw new Error(`empty file: ${o.path}`);
1320
1570
  const type = classifyInput(inputPath);
1321
- const isExcel = type === "xlsx" || type === "xls";
1571
+ if (o.pages && (type === "html" || type === "image" || type === "email")) throw new Error(`--pages does not apply to ${type === "html" ? "HTML files" : type === "image" ? "images" : "email"}`);
1572
+ const isExcel = type === "xlsx" || type === "xlsm" || type === "xls";
1322
1573
  if (isExcel && o.pages) throw new Error("--pages does not apply to spreadsheets: worksheets have no stable page numbering");
1323
1574
  const backend = await s.backend({ pymupdfVersion: o.pymupdfVersion, warmTimeoutMs: o.warmTimeoutMs });
1324
1575
  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}`);
1576
+ if (type === "email" && extname3(inputPath).toLowerCase() === ".msg" && (backend.kind === "none" || !backend.email)) throw new Error(`MSG conversion needs the extract-msg package. Python backend: ${backendState(backend, "found without extract-msg")}. Remedy: install uv, or pip install extract-msg markdownify into that Python`);
1577
+ if (type === "email" && extname3(inputPath).toLowerCase() !== ".msg" && lacksDocx(backend)) throw new Error(`EML conversion needs the Python DOCX/HTML packages (mammoth, markdownify, python-docx). Python backend: ${backendState(backend, "found without mammoth/markdownify/python-docx")}. ${docxRemedy}`);
1325
1578
  const stem = sanitizeStem(basename(inputPath, extname3(inputPath)));
1326
1579
  const b = openBundle(o.outputDir ? resolve2(o.outputDir) : tempBundleRoot(), stem, o.overwrite);
1327
1580
  let office = null;
1328
1581
  try {
1329
1582
  let pdfPath = inputPath;
1330
- 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 };
1583
+ const base = { path: inputPath, pages: o.pages, stagingDir: b.stagingDir, sheetsStagingDir: b.sheetsStagingDir, pageImages: o.pageImages, pagesStagingDir: b.pagesStagingDir, imageDpi: o.imageDpi, imageFormat: o.imageFormat, maxOutputBytes: o.maxOutputBytes, pymupdfVersion: o.pymupdfVersion, ocr: o.ocr, ocrLanguage: o.ocrLanguage, ocrBudgetMs: o.primaryTimeoutMs };
1331
1584
  let tier, engine, json, degraded = null, fallbackReason = null;
1332
1585
  let explicitBreaks = null;
1333
1586
  let notes = [];
1334
1587
  let officeRoute = null;
1588
+ if (type === "html") {
1589
+ const prepared = await prepareHtml(inputPath, b.stagingDir);
1590
+ if (signal?.aborted) throw new Error("aborted");
1591
+ if (prepared.missing) notes.push(`${prepared.missing} image(s) not found; replaced with alt text`);
1592
+ if (!lacksDocx(backend)) {
1593
+ const r = await s.runTier("html", { ...base, html: prepared.html }, b, signal, o.primaryTimeoutMs, backend);
1594
+ if (r.ok) {
1595
+ tier = "html";
1596
+ engine = "markdownify";
1597
+ json = r.json;
1598
+ } else if ("userError" in r) throw new Error(r.userError);
1599
+ else if (signal?.aborted || r.reason === "aborted") throw new Error("aborted");
1600
+ else fallbackReason = `html ${r.reason}${detailSuffix(r)}`;
1601
+ }
1602
+ if (json === void 0) {
1603
+ const { htmlToMarkdownRaw: htmlToMarkdownRaw2 } = await Promise.resolve().then(() => (init_fetch_core(), fetch_core_exports));
1604
+ tier = "html";
1605
+ engine = "turndown";
1606
+ degraded = DEGRADED_HTML_TURNDOWN;
1607
+ json = { markdown: `${htmlToMarkdownRaw2(prepared.html)}
1608
+ `, notes: [] };
1609
+ }
1610
+ publishStaged(b);
1611
+ }
1612
+ if (type === "image") {
1613
+ let reason = "no Python backend";
1614
+ if (backend.kind !== "none") {
1615
+ const r = await s.runTier("image", { ...base, stem: b.stem }, b, signal, o.primaryTimeoutMs, backend);
1616
+ if (r.ok) {
1617
+ publishStaged(b);
1618
+ tier = "image";
1619
+ engine = "pymupdf4llm";
1620
+ json = r.json;
1621
+ } else if ("userError" in r) throw new Error(r.userError);
1622
+ else if (signal?.aborted || r.reason === "aborted") throw new Error("aborted");
1623
+ else reason = `OCR child failed: ${r.reason}`;
1624
+ }
1625
+ if (signal?.aborted) throw new Error("aborted");
1626
+ if (json === void 0) {
1627
+ clearStaging(b);
1628
+ const file = `original${extname3(inputPath).toLowerCase()}`;
1629
+ const dir = join3(b.stagingDir, "p1");
1630
+ mkdirSync3(dir, { recursive: true });
1631
+ copyFileSync(inputPath, join3(dir, file));
1632
+ writeFileSync3(join3(dir, ".done"), "");
1633
+ publishStaged(b);
1634
+ tier = "image";
1635
+ engine = "copy";
1636
+ json = { markdown: `![${b.stem}](p1/${file})
1637
+ `, pageCount: 1, notes: [], ocr: { ...emptyOcr(o.ocrLanguage), status: "unavailable", reason } };
1638
+ }
1639
+ }
1335
1640
  if (type === "docx" && !lacksDocx(backend)) {
1336
1641
  const d = await s.runTier("docx", base, b, signal, o.primaryTimeoutMs, backend);
1337
1642
  if (d.ok) {
@@ -1361,13 +1666,23 @@ async function convertDocument(o, signal, seams) {
1361
1666
  office = r;
1362
1667
  pdfPath = r.pdfPath;
1363
1668
  }
1364
- if (type === "pptx") {
1669
+ if (type === "doc" || type === "pptx") {
1365
1670
  const r = await s.office(o.sofficeTimeoutMs, inputPath, signal);
1366
- 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`);
1671
+ if (!r.ok && r.kind === "missing") throw new Error(`${type.toUpperCase()} conversion needs LibreOffice (soffice); direct conversion is not available. Python backend: ${backendState(backend, "available")}. Remedy: install LibreOffice`);
1367
1672
  if (!r.ok) throw officeFailure(r);
1368
1673
  office = r;
1369
1674
  pdfPath = r.pdfPath;
1370
1675
  }
1676
+ if (type === "email") {
1677
+ const isMsg = extname3(inputPath).toLowerCase() === ".msg";
1678
+ const r = await s.runTier("email", { ...base, stem: b.stem, attachmentsStagingDir: b.attachmentsStagingDir }, b, signal, o.primaryTimeoutMs, backend);
1679
+ if (!r.ok) throw new Error("userError" in r ? r.userError : `Conversion failed: email ${r.reason}${detailSuffix(r)}`);
1680
+ publishStaged(b);
1681
+ publishAttachments(b);
1682
+ tier = "email";
1683
+ engine = isMsg ? "extract-msg" : "email";
1684
+ json = r.json;
1685
+ }
1371
1686
  const pdfBase = { ...base, path: pdfPath };
1372
1687
  if (json === void 0) {
1373
1688
  if (isExcel) {
@@ -1382,7 +1697,7 @@ async function convertDocument(o, signal, seams) {
1382
1697
  tier = "excel";
1383
1698
  engine = type === "xls" ? "xlrd" : "openpyxl";
1384
1699
  json = r.json;
1385
- notes = [...json.notes ?? []];
1700
+ notes = (json.notes ?? []).map((n) => n.replace(/sheets\/[^\s,;]+/g, (m) => b.sourceMap.get(m) ?? m));
1386
1701
  const renderPages = json.renderPages ?? [];
1387
1702
  let skip = null;
1388
1703
  const perSheet = /* @__PURE__ */ new Map();
@@ -1421,12 +1736,15 @@ async function convertDocument(o, signal, seams) {
1421
1736
  tier = "primary";
1422
1737
  engine = "pymupdf4llm";
1423
1738
  json = p.json;
1739
+ if (json.pageImages?.length) publishPageImages(b, json.pageCount ?? 0);
1424
1740
  } else if ("userError" in p) throw new Error(p.userError);
1425
1741
  else {
1426
1742
  if (signal?.aborted) throw new Error("aborted");
1427
1743
  const keepPages = Object.fromEntries([...kept.entries()].map(([k, v]) => [String(k), v]));
1744
+ rmSync2(b.pagesStagingDir, { recursive: true, force: true });
1428
1745
  const f = await s.runTier("pdf-fallback", { ...pdfBase, keepPages }, b, signal, o.fallbackTimeoutMs, backend);
1429
1746
  publishStaged(b);
1747
+ if (f.ok && f.json.pageImages?.length) publishPageImages(b, f.json.pageCount ?? 0);
1430
1748
  if (!f.ok) throw new Error("userError" in f ? f.userError : `Conversion failed: primary ${p.reason}; fallback ${f.reason}${detailSuffix(f)}`);
1431
1749
  tier = "fallback";
1432
1750
  engine = "pymupdf-text";
@@ -1436,14 +1754,14 @@ async function convertDocument(o, signal, seams) {
1436
1754
  }
1437
1755
  }
1438
1756
  }
1439
- if (officeRoute !== null) {
1440
- degraded = DEGRADED_DOCX_OFFICE;
1441
- fallbackReason = fallbackReason ? `${officeRoute}; ${fallbackReason}` : officeRoute;
1442
- }
1757
+ if (type === "doc" || officeRoute !== null) degraded = DEGRADED_DOCX_OFFICE;
1758
+ if (officeRoute !== null) fallbackReason = fallbackReason ? `${officeRoute}; ${fallbackReason}` : officeRoute;
1443
1759
  if (tier === void 0 || engine === void 0 || json === void 0) throw new Error("internal: no tier produced output");
1444
- if (!isExcel) notes = json.notes ?? [];
1445
- const body = rewriteLinks(json.markdown ?? "", b.sourceMap);
1446
- validateImageLinks(body, b.manifest, b.csvManifest);
1760
+ if (!isExcel) notes = [...notes, ...json.notes ?? []];
1761
+ if (b.renamedFrom) notes.splice(notes[0]?.startsWith("preview truncated:") ? 1 : 0, 0, `renamed to ${b.stem} (${b.renameReason})`);
1762
+ const pageImagesReason = !o.pageImages || b.pageManifest.size || tier === "primary" || tier === "fallback" ? null : tier === "unpdf" ? "page images need the Python backend" : `${type} has no page geometry`;
1763
+ const body = resolveOcrLabels(rewriteLinks(json.markdown ?? "", b.sourceMap), b.sourceMap);
1764
+ validateImageLinks(body, b.manifest, b.csvManifest, type === "html" || type === "email", b.pageManifest, b.attachmentManifest);
1447
1765
  const head = [];
1448
1766
  if (degraded) head.push(`Degraded: ${degraded}`);
1449
1767
  if (fallbackReason) head.push(`Fallback-Reason: ${fallbackReason}`);
@@ -1455,7 +1773,7 @@ async function convertDocument(o, signal, seams) {
1455
1773
  ` : "") + body;
1456
1774
  commitBundle(b, markdown);
1457
1775
  const outline = scanOutline(markdown, o.outlineMaxEntries);
1458
- const details = { 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 };
1776
+ const details = { 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, pagesDir: b.pageManifest.size ? b.pagesDir : null, pageImageCount: b.pageManifest.size, pageImagesReason, 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, ocr: handleOcr(tier, type, o, json) };
1459
1777
  return { output: formatHandle(details), details };
1460
1778
  } catch (e) {
1461
1779
  abortBundle(b);
@@ -1469,8 +1787,10 @@ async function inspectDocument(o, signal, seams) {
1469
1787
  const inputPath = resolve2(o.path);
1470
1788
  const st = statSync2(inputPath, { throwIfNoEntry: false });
1471
1789
  if (!st || !st.isFile()) throw new Error(`Not a readable file: ${o.path}`);
1790
+ if (st.size === 0) throw new Error(`empty file: ${o.path}`);
1472
1791
  const type = classifyInput(inputPath);
1473
- const isExcel = type === "xlsx" || type === "xls";
1792
+ if (type === "html" || type === "image" || type === "email") throw new Error(`info does not apply to ${type === "html" ? "HTML files" : type === "image" ? "images" : "email"}; convert directly`);
1793
+ const isExcel = type === "xlsx" || type === "xlsm" || type === "xls";
1474
1794
  const backend = await s.backend({ pymupdfVersion: o.pymupdfVersion, warmTimeoutMs: o.warmTimeoutMs });
1475
1795
  if (isExcel && (backend.kind === "none" || !backend.xlsx)) throw new Error(`Excel inspection needs a Python backend with openpyxl, xlrd and pillow. ${EXCEL_REMEDY}`);
1476
1796
  let office = null;
@@ -1478,7 +1798,7 @@ async function inspectDocument(o, signal, seams) {
1478
1798
  let path = inputPath;
1479
1799
  if (type === "docx") {
1480
1800
  if (lacksDocx(backend)) throw new Error(`DOCX inspection needs the Python DOCX packages. Python backend: ${backendState(backend, "found without mammoth/markdownify/python-docx")}. ${docxRemedy}`);
1481
- } else if (type === "pptx") {
1801
+ } else if (type === "pptx" || type === "doc") {
1482
1802
  const r2 = await s.office(o.sofficeTimeoutMs, inputPath, signal);
1483
1803
  if (!r2.ok) throw officeFailure(r2);
1484
1804
  office = r2;
@@ -1499,25 +1819,32 @@ async function inspectDocument(o, signal, seams) {
1499
1819
  }
1500
1820
 
1501
1821
  // bin/pi-quiver.ts
1502
- var USAGE = 'Usage: pi-quiver fetch <url> [--method GET|HEAD|POST] [--header "K: V"]... [--body <str>] [--raw] [--timeout-ms <n>]\n pi-quiver doc-to-md [--info] [--pages <spec>] [--output-dir <dir>] [--overwrite] [tunable flags] <path> (--help for all flags)';
1822
+ var USAGE = 'Usage: pi-quiver fetch <url> [--method GET|HEAD|POST] [--header "K: V"]... [--body <str>] [--raw] [--timeout-ms <n>]\n pi-quiver doc-to-md [--json] [--info] [--page-images] [--pages <spec>] [--output-dir <dir>] [--overwrite] [tunable flags] <path> (--help for all flags)';
1503
1823
  function parseDocToMd(rest) {
1504
1824
  if (rest.includes("--help") || rest.includes("-h")) return { ok: true, cmd: "doc-to-md-help" };
1825
+ const json = rest.includes("--json");
1826
+ const args = rest.filter((a) => a !== "--json");
1505
1827
  const perCall = {};
1506
1828
  let path;
1507
- for (let i = 0; i < rest.length; i++) {
1508
- const arg = rest[i];
1829
+ for (let i = 0; i < args.length; i++) {
1830
+ const arg = args[i];
1509
1831
  if (!arg.startsWith("--")) {
1510
1832
  if (path !== void 0) return { ok: false, error: `unexpected argument: ${arg}` };
1511
1833
  path = arg;
1512
1834
  continue;
1513
1835
  }
1836
+ const negated = arg.startsWith("--no-") ? DOC_TO_MD_OPTIONS.find((o) => o.type === "bool" && o.settable && o.flag === `--${arg.slice(5)}`) : void 0;
1837
+ if (negated) {
1838
+ perCall[negated.key] = false;
1839
+ continue;
1840
+ }
1514
1841
  const d = DOC_TO_MD_OPTIONS.find((o) => o.flag === arg);
1515
1842
  if (!d) return { ok: false, error: `unknown flag: ${arg}` };
1516
1843
  if (d.type === "bool") {
1517
1844
  perCall[d.key] = true;
1518
1845
  continue;
1519
1846
  }
1520
- const value = rest[++i];
1847
+ const value = args[++i];
1521
1848
  if (value === void 0) return { ok: false, error: `${arg} requires a value` };
1522
1849
  if (d.type === "int") {
1523
1850
  const n = Number(value);
@@ -1526,7 +1853,7 @@ function parseDocToMd(rest) {
1526
1853
  } else perCall[d.key] = value;
1527
1854
  }
1528
1855
  if (!path) return { ok: false, error: "missing <path>" };
1529
- return { ok: true, cmd: "doc-to-md", perCall: { ...perCall, path } };
1856
+ return { ok: true, cmd: "doc-to-md", perCall: { ...perCall, path }, json };
1530
1857
  }
1531
1858
  function cliAgentDir(env) {
1532
1859
  const raw = env.PI_CODING_AGENT_DIR;
@@ -1544,7 +1871,7 @@ function readCliSettings(cwd, env, warn) {
1544
1871
  if (!existsSync3(file)) continue;
1545
1872
  let raw;
1546
1873
  try {
1547
- raw = JSON.parse(readFileSync(file, "utf8")).quiver?.docToMd;
1874
+ raw = JSON.parse(readFileSync3(file, "utf8")).quiver?.docToMd;
1548
1875
  } catch {
1549
1876
  warn(`pi-quiver: ${file} is not valid JSON; ignored.`);
1550
1877
  continue;
@@ -1639,7 +1966,11 @@ ${USAGE}
1639
1966
  }
1640
1967
  try {
1641
1968
  const r = o.info ? await inspectDocument(o) : await convertDocument(o);
1642
- process.stdout.write(`${r.output}
1969
+ if (parsed.json) {
1970
+ const payload = o.info ? r.details : (({ path, backend, pymupdfVersion, inputType, file, outputDir, ...handle }) => handle)(r.details);
1971
+ process.stdout.write(`${JSON.stringify(payload)}
1972
+ `);
1973
+ } else process.stdout.write(`${r.output}
1643
1974
  `);
1644
1975
  return 0;
1645
1976
  } catch (err) {