@oh-my-pi/pi-coding-agent 17.3.3 → 17.3.4

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/{CHANGELOG-s5t1xdmy.md → CHANGELOG-trcc215s.md} +13 -0
  3. package/dist/cli.js +3563 -3537
  4. package/dist/docs-index.generated.txt +1 -1
  5. package/dist/types/markit/converters/pdf/index.d.ts +2 -1
  6. package/dist/types/tools/read-pdf.d.ts +15 -0
  7. package/dist/types/utils/external-editor.d.ts +7 -0
  8. package/dist/types/utils/markit.d.ts +3 -4
  9. package/package.json +13 -16
  10. package/scripts/build-binary.ts +0 -2
  11. package/scripts/bundle-dist.ts +0 -1
  12. package/src/cli/read-cli.ts +2 -0
  13. package/src/markit/NOTICE +8 -8
  14. package/src/markit/converters/pdf/index.ts +14 -126
  15. package/src/mcp/client.ts +8 -9
  16. package/src/modes/rpc/rpc-client.ts +7 -0
  17. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  18. package/src/tools/read-pdf.ts +135 -0
  19. package/src/tools/read.ts +63 -37
  20. package/src/utils/external-editor.ts +24 -5
  21. package/src/utils/markit.ts +6 -44
  22. package/dist/types/markit/converters/pdf/columns.d.ts +0 -35
  23. package/dist/types/markit/converters/pdf/extract.d.ts +0 -10
  24. package/dist/types/markit/converters/pdf/grid.d.ts +0 -25
  25. package/dist/types/markit/converters/pdf/headers.d.ts +0 -24
  26. package/dist/types/markit/converters/pdf/render.d.ts +0 -24
  27. package/dist/types/markit/converters/pdf/types.d.ts +0 -75
  28. package/dist/types/tools/read-pdf-images.d.ts +0 -12
  29. package/dist/types/utils/mupdf-wasm-embed.d.ts +0 -1
  30. package/scripts/embed-mupdf-wasm.ts +0 -67
  31. package/src/markit/converters/pdf/columns.ts +0 -103
  32. package/src/markit/converters/pdf/extract.ts +0 -598
  33. package/src/markit/converters/pdf/grid.ts +0 -780
  34. package/src/markit/converters/pdf/headers.ts +0 -106
  35. package/src/markit/converters/pdf/render.ts +0 -501
  36. package/src/markit/converters/pdf/types.ts +0 -84
  37. package/src/tools/read-pdf-images.ts +0 -250
  38. package/src/utils/mupdf-wasm-embed.ts +0 -12
package/src/tools/read.ts CHANGED
@@ -100,7 +100,7 @@ import {
100
100
  isRemoteMountPath,
101
101
  type SuffixMatchCache,
102
102
  } from "./read-path-resolution";
103
- import { readPdfImageMember, rewritePdfImagePlaceholders, splitPdfImageMemberReadPath } from "./read-pdf-images";
103
+ import { type PdfImageReadTarget, renderPdfPageScreenshot, splitPdfImageReadPath } from "./read-pdf";
104
104
  import { isMultiRange, isRawSelector, type ParsedSelector, parseSel, selToOffsetLimit } from "./read-selector";
105
105
  import { readSqlite, resolveSqliteReadPath } from "./read-sqlite";
106
106
  import { isProseSummaryPath, renderSummary, routeReadThroughBridge, trySummarize } from "./read-summary";
@@ -398,8 +398,13 @@ type ReadParams = ReadToolInput;
398
398
  */
399
399
  export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
400
400
  readonly name = "read";
401
- readonly approval = (args: unknown): ToolTier =>
402
- pathTargetsSsh(String((args as { path?: unknown }).path ?? "")) ? "exec" : "read";
401
+ readonly approval = (args: unknown): ToolTier => {
402
+ let readPath = "";
403
+ if (args && typeof args === "object" && "path" in args) readPath = String(args.path ?? "");
404
+ if (pathTargetsSsh(readPath)) return "exec";
405
+ const target = splitPathAndSel(readPath);
406
+ return target.sel === undefined && splitPdfImageReadPath(readPath) ? "exec" : "read";
407
+ };
403
408
  readonly label = "Read";
404
409
  readonly loadMode = "essential";
405
410
  description: string;
@@ -546,6 +551,40 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
546
551
  return toolResult<ReadToolDetails>({ notes, displayReadTargets }).content(content).done();
547
552
  }
548
553
 
554
+ async #readPdfPageScreenshot(options: {
555
+ readPath: string;
556
+ absolutePdfPath: string;
557
+ page: number;
558
+ pdfFileSize: number;
559
+ suffixResolution?: { from: string; to: string };
560
+ signal?: AbortSignal;
561
+ }): Promise<AgentToolResult<ReadToolDetails>> {
562
+ const { readPath, absolutePdfPath, page, pdfFileSize, suffixResolution, signal } = options;
563
+ const screenshot = await renderPdfPageScreenshot(this.session, absolutePdfPath, page, signal);
564
+ const screenshotFile = Bun.file(screenshot.dest);
565
+ const screenshotMetadata = await readImageMetadata(screenshot.dest);
566
+ const loaded = await this.#loadImageContent({
567
+ readPath,
568
+ absolutePath: screenshot.dest,
569
+ mimeType: screenshot.mimeType,
570
+ imageMetadata: screenshotMetadata,
571
+ fileSize: screenshotFile.size,
572
+ });
573
+ if (suffixResolution) {
574
+ const firstText = loaded.content.find((entry): entry is TextContent => entry.type === "text");
575
+ if (firstText) firstText.text = prependSuffixResolutionNotice(firstText.text, suffixResolution);
576
+ }
577
+ const image = loaded.content.find((entry): entry is ImageContent => entry.type === "image");
578
+ const details: ReadToolDetails = {
579
+ ...loaded.details,
580
+ resolvedPath: absolutePdfPath,
581
+ contentType: image?.mimeType ?? screenshot.mimeType,
582
+ fileSize: pdfFileSize,
583
+ suffixResolution,
584
+ };
585
+ return toolResult(details).content(loaded.content).sourcePath(loaded.sourcePath).done();
586
+ }
587
+
549
588
  /**
550
589
  * Build content blocks for an on-disk image file: an `inspect_image`
551
590
  * metadata note when inspection is active, otherwise the decoded image
@@ -892,7 +931,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
892
931
 
893
932
  // Prefer a literal filesystem match over selector interpretation so real
894
933
  // POSIX filenames containing selector-looking suffixes win over structured
895
- // archive / sqlite / pdf-image dispatch. A selector promoted from local://
934
+ // archive / sqlite / unsupported PDF-image dispatch. A selector promoted from local://
896
935
  // remains separate so it cannot be mistaken for part of the resolved path.
897
936
  const literalSplit =
898
937
  promotedSelector === undefined
@@ -903,6 +942,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
903
942
  ? readPath.includes(":") && (await probeLiteralPathExists(readPath, this.session.cwd)) !== "missing"
904
943
  : literalSplit.sel === undefined && splitPathAndSel(readPath).sel !== undefined;
905
944
 
945
+ let pdfImageRead: PdfImageReadTarget | null = null;
946
+
906
947
  if (!rawPathIsLiteral) {
907
948
  const archivePath = await resolveArchiveReadPath(this.session, readPath, suffixCache, signal);
908
949
  if (archivePath) {
@@ -925,39 +966,14 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
925
966
  return readSqlite(sqlitePath, signal);
926
967
  }
927
968
 
928
- const pdfImageMemberPath = splitPdfImageMemberReadPath(readPath);
929
- if (pdfImageMemberPath) {
930
- let absolutePdfPath = resolveReadPath(pdfImageMemberPath.pdfPath, this.session.cwd);
931
- let suffixResolution: { from: string; to: string } | undefined;
932
- try {
933
- const stat = await Bun.file(absolutePdfPath).stat();
934
- if (stat.isDirectory())
935
- throw new ToolError(`Path '${pdfImageMemberPath.pdfPath}' is a directory, not a PDF file`);
936
- } catch (error) {
937
- if (!isNotFoundError(error) || isRemoteMountPath(absolutePdfPath)) throw error;
938
- const suffixMatch = await findSuffixMatchCached(
939
- this.session,
940
- suffixCache,
941
- pdfImageMemberPath.pdfPath,
942
- signal,
943
- );
944
- if (!suffixMatch) throw new ToolError(`Path '${pdfImageMemberPath.pdfPath}' not found`);
945
- absolutePdfPath = suffixMatch.absolutePath;
946
- suffixResolution = { from: pdfImageMemberPath.pdfPath, to: suffixMatch.displayPath };
947
- }
948
- return readPdfImageMember(
949
- this.session,
950
- this.#autoResizeImages,
951
- absolutePdfPath,
952
- pdfImageMemberPath.pdfPath,
953
- pdfImageMemberPath.member,
954
- suffixResolution,
955
- signal,
956
- );
957
- }
969
+ const pdfCandidate = literalSplit.sel === undefined ? splitPdfImageReadPath(readPath) : null;
970
+ pdfImageRead =
971
+ pdfCandidate && (await probeLiteralPathExists(readPath, this.session.cwd)) === "missing"
972
+ ? pdfCandidate
973
+ : null;
958
974
  }
959
975
 
960
- const localTarget = literalSplit;
976
+ const localTarget = pdfImageRead ? { path: pdfImageRead.pdfPath, sel: undefined } : literalSplit;
961
977
  const localReadPath = localTarget.path;
962
978
  const parsed = parseSel(localTarget.sel);
963
979
 
@@ -1036,6 +1052,17 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1036
1052
  return this.#readFileConflicts(absolutePath, suffixResolution, signal);
1037
1053
  }
1038
1054
 
1055
+ if (pdfImageRead) {
1056
+ return this.#readPdfPageScreenshot({
1057
+ readPath,
1058
+ absolutePdfPath: absolutePath,
1059
+ page: pdfImageRead.page,
1060
+ pdfFileSize: fileSize,
1061
+ suffixResolution,
1062
+ signal,
1063
+ });
1064
+ }
1065
+
1039
1066
  const imageMetadata = await readImageMetadata(absolutePath);
1040
1067
  const mimeType = imageMetadata?.mimeType;
1041
1068
  const ext = path.extname(absolutePath).toLowerCase();
@@ -1102,8 +1129,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1102
1129
  // Convert document via markit.
1103
1130
  const result = await convertFileWithMarkit(absolutePath, signal);
1104
1131
  if (result.ok) {
1105
- const renderedContent =
1106
- ext === ".pdf" ? rewritePdfImagePlaceholders(result.content, resolvedDisplayPath) : result.content;
1132
+ const renderedContent = result.content;
1107
1133
  // Route the converted markdown through the in-memory text builder
1108
1134
  // so line-range selectors (`file.pdf:50-100`, `:5-16,40-80`) and
1109
1135
  // raw mode apply against the converted output. Without this,
@@ -33,6 +33,27 @@ export interface OpenInEditorOptions {
33
33
  trimTrailingNewline?: boolean;
34
34
  }
35
35
 
36
+ /** Subprocess argv and Windows quoting mode used to launch an external editor. */
37
+ export interface EditorSpawnCommand {
38
+ cmd: string[];
39
+ windowsVerbatimArguments: boolean;
40
+ }
41
+
42
+ /** Resolves shell argv without letting the host runtime re-quote the editor command. */
43
+ export function resolveEditorSpawnCommand(
44
+ editorCmd: string,
45
+ tmpFile: string,
46
+ platform: NodeJS.Platform = process.platform,
47
+ ): EditorSpawnCommand {
48
+ const windows = platform === "win32";
49
+ // cmd.exe strips the outer /s /c quote pair; Bun must pass the embedded
50
+ // editor/path quotes verbatim instead of applying argv escaping to them.
51
+ const cmd = windows
52
+ ? ["cmd.exe", "/d", "/s", "/c", `"${editorCmd} "${tmpFile}""`]
53
+ : [$which("sh") ?? "sh", "-c", `${editorCmd} "$1"`, "sh", tmpFile];
54
+ return { cmd, windowsVerbatimArguments: windows };
55
+ }
56
+
36
57
  /**
37
58
  * Opens `content` in the user's external editor and returns the edited text.
38
59
  * Returns `null` if the editor exits with a non-zero code.
@@ -50,15 +71,13 @@ export async function openInEditor(
50
71
  try {
51
72
  await Bun.write(tmpFile, content);
52
73
 
74
+ const spawnCommand = resolveEditorSpawnCommand(editorCmd, tmpFile);
53
75
  const [stdin, stdout, stderr] = options?.stdio ?? ["inherit", "inherit", "inherit"];
54
- const cmd =
55
- process.platform === "win32"
56
- ? ["cmd", "/c", `${editorCmd} "${tmpFile}"`]
57
- : [$which("sh") ?? "sh", "-c", `${editorCmd} "$1"`, "sh", tmpFile];
58
- const child = Bun.spawn(cmd, {
76
+ const child = Bun.spawn(spawnCommand.cmd, {
59
77
  stdin,
60
78
  stdout,
61
79
  stderr,
80
+ windowsVerbatimArguments: spawnCommand.windowsVerbatimArguments,
62
81
  });
63
82
  const exitCode = await child.exited;
64
83
  if (exitCode === 0) {
@@ -1,5 +1,5 @@
1
1
  import * as path from "node:path";
2
- import { logger, untilAborted } from "@oh-my-pi/pi-utils";
2
+ import { untilAborted } from "@oh-my-pi/pi-utils";
3
3
  import type { ConversionResult, Markit, StreamInfo } from "../markit";
4
4
  import { ToolAbortError } from "../tools/tool-errors";
5
5
  import {
@@ -8,7 +8,6 @@ import {
8
8
  readMarkitConversionCache,
9
9
  writeMarkitConversionCache,
10
10
  } from "./markit-cache";
11
- import { loadEmbeddedMupdfWasm } from "./mupdf-wasm-embed";
12
11
 
13
12
  /**
14
13
  * File extensions markit can actually convert to markdown — one per registered
@@ -31,53 +30,16 @@ export interface MarkitConversionResult {
31
30
 
32
31
  export interface MarkitFileConversionOptions {
33
32
  /**
34
- * Directory the PDF converter writes extracted images/diagrams into. When
35
- * set, each embedded image is rendered to `<id>.png` and referenced by path
36
- * in the markdown; when unset, markit emits an `<!-- image: <id> ... -->`
37
- * placeholder comment instead.
33
+ * Directory converters may use for extracted image or diagram files. Since
34
+ * those files are conversion side effects, conversions using this option
35
+ * bypass the markdown cache.
38
36
  */
39
37
  imageDir?: string;
40
38
  }
41
39
 
42
- interface MuPdfWasmModuleConfig {
43
- print?: (...values: unknown[]) => void;
44
- printErr?: (...values: unknown[]) => void;
45
- wasmBinary?: Uint8Array;
46
- }
47
-
48
- function logMuPdfWasmOutput(stream: "stdout" | "stderr", values: unknown[]): void {
49
- const message = values.length === 1 && typeof values[0] === "string" ? values[0] : values.map(String).join(" ");
50
- logger.debug("mupdf wasm output", { stream, message });
51
- }
52
-
53
- // `$libmupdf_wasm_Module` is declared globally (as `any`) by the mupdf package.
54
- // Install print hooks before the WASM module initializes so its stdout/stderr
55
- // route to the file logger instead of corrupting the TUI.
56
- function installMuPdfWasmLogger(): void {
57
- const moduleConfig: MuPdfWasmModuleConfig = globalThis.$libmupdf_wasm_Module ?? {};
58
- moduleConfig.print = (...values: unknown[]) => logMuPdfWasmOutput("stdout", values);
59
- moduleConfig.printErr = (...values: unknown[]) => logMuPdfWasmOutput("stderr", values);
60
- globalThis.$libmupdf_wasm_Module = moduleConfig;
61
- }
62
-
63
- // Hand the WASM module its bytes directly when the compiled binary embedded them
64
- // (scripts/embed-mupdf-wasm.ts); a single-file binary has no node_modules for
65
- // mupdf to read `mupdf-wasm.wasm` from. Source/npm builds get undefined here and
66
- // mupdf loads its own wasm. Must run before the mupdf module evaluates.
67
- function installEmbeddedMupdfWasm(): void {
68
- const wasmBinary = loadEmbeddedMupdfWasm();
69
- if (!wasmBinary) return;
70
- const moduleConfig: MuPdfWasmModuleConfig = globalThis.$libmupdf_wasm_Module ?? {};
71
- moduleConfig.wasmBinary = wasmBinary;
72
- globalThis.$libmupdf_wasm_Module = moduleConfig;
73
- }
74
-
75
- installMuPdfWasmLogger();
76
-
77
40
  let markit: () => Markit | Promise<Markit> = async () => {
78
- // Lazy: keep the document engine (mammoth/mupdf) off the startup
79
- // import graph — it loads only when a document is first converted.
80
- installEmbeddedMupdfWasm();
41
+ // Lazy: keep the document engine off the startup import graph — it loads
42
+ // only when a document is first converted.
81
43
  const promise = import("../markit").then(({ Markit }) => {
82
44
  const instance = new Markit();
83
45
  markit = () => instance;
@@ -1,35 +0,0 @@
1
- /**
2
- * Multi-column layout detection and text box reordering.
3
- *
4
- * Many PDFs (legal documents, datasheets, academic papers) use two-column
5
- * layouts. Without column detection, text boxes are ordered by Y position
6
- * only, interleaving left and right column content.
7
- *
8
- * Algorithm:
9
- * 1. Collect left edges of all text boxes on the page
10
- * 2. Find the largest horizontal gap between consecutive left edges
11
- * 3. If gap > MIN_GAP_RATIO of the text width and both sides have
12
- * enough boxes → multi-column detected
13
- * 4. Assign each text box to a column based on its center X
14
- * 5. Return columns in reading order (left-to-right, top-to-bottom)
15
- *
16
- * This only detects the column structure. The caller is responsible for
17
- * processing each column's text boxes independently (table detection,
18
- * rendering, etc.).
19
- */
20
- import type { TextBox } from "./types.js";
21
- export interface ColumnLayout {
22
- /** Number of columns detected (1 = single column, 2+ = multi-column). */
23
- columnCount: number;
24
- /** Text boxes grouped by column, in reading order (left to right). */
25
- columns: TextBox[][];
26
- /** X positions of column boundaries (between columns). */
27
- boundaries: number[];
28
- }
29
- /**
30
- * Detect column layout and return text boxes grouped by column.
31
- *
32
- * For single-column pages, returns all boxes in one group.
33
- * For multi-column pages, returns boxes split by column in reading order.
34
- */
35
- export declare function detectColumns(textBoxes: TextBox[]): ColumnLayout;
@@ -1,10 +0,0 @@
1
- import type { ImageRegion, PageContent } from "./types.js";
2
- /**
3
- * Render an image region from a PDF page as a PNG buffer.
4
- * Uses mupdf's DrawDevice to render just the cropped area at 2x resolution.
5
- */
6
- export declare function renderImageRegion(input: Uint8Array, region: ImageRegion): Promise<Uint8Array>;
7
- /**
8
- * Extract text boxes and vector segments from all pages of a PDF buffer.
9
- */
10
- export declare function extractPages(input: Uint8Array): Promise<PageContent[]>;
@@ -1,25 +0,0 @@
1
- /**
2
- * Table grid detection from vector segments and text boxes.
3
- *
4
- * Ported from @oharato/pdf2md-ts with TypeScript types and without
5
- * CJK-specific borderless table heuristics. The core algorithm:
6
- *
7
- * 1. Classify segments as horizontal or vertical lines
8
- * 2. Group horizontal Y-lines into table groups (split by vertical gaps)
9
- * 3. For each group:
10
- * a. Full grid (H+V lines): build cells from grid intersections,
11
- * place text via raycasting
12
- * b. H-line only (no V lines): infer columns from text X positions
13
- * 4. Prune empty rows/cols
14
- *
15
- * Coordinate system: PDF native (bottom-left origin, Y increases upward).
16
- */
17
- import type { Segment, TableGrid, TextBox } from "./types.js";
18
- export interface GridResult {
19
- grids: TableGrid[];
20
- consumedIds: string[];
21
- }
22
- /**
23
- * Detect all table grids on a single page from its text boxes and segments.
24
- */
25
- export declare function resolveTableGrids(pageNumber: number, textBoxes: TextBox[], segments: Segment[]): GridResult;
@@ -1,24 +0,0 @@
1
- /**
2
- * Running header/footer detection and removal.
3
- *
4
- * Many PDFs have repeated text at the top or bottom of every page:
5
- * document titles, chapter names, page numbers, copyright notices.
6
- * These pollute the markdown output as false headings or noise.
7
- *
8
- * Algorithm:
9
- * 1. For each page, bucket text boxes by Y position (top/bottom zones)
10
- * 2. Collect the text content at each zone across all pages
11
- * 3. Text appearing on >20% of pages OR 8+ consecutive pages is a
12
- * running header/footer
13
- * 4. Remove matching text boxes before further processing
14
- */
15
- import type { PageContent } from "./types.js";
16
- /**
17
- * Detect and remove running headers and footers from all pages.
18
- * Mutates the pages array in place, removing header/footer text boxes.
19
- *
20
- * Uses two strategies:
21
- * 1. Global frequency: text appearing on > 20% of all pages
22
- * 2. Consecutive runs: text appearing on 8+ consecutive pages
23
- */
24
- export declare function stripHeadersFooters(pages: PageContent[]): void;
@@ -1,24 +0,0 @@
1
- /**
2
- * Markdown rendering for PDF pages.
3
- *
4
- * Converts table grids and free text boxes into markdown, handling:
5
- * - Table grid → markdown table (`| col | col |`)
6
- * - Free text → paragraphs with heading detection (by font size)
7
- * - Content ordering (top-to-bottom via Y coordinate)
8
- * - Paragraph wrap merging (lines broken across PDF line boundaries)
9
- * - Page number removal
10
- *
11
- * Ported from @oharato/pdf2md-ts, stripped of CJK/TDnet-specific logic.
12
- */
13
- import type { TableGrid, TextBox } from "./types.js";
14
- /**
15
- * Render a TableGrid as a markdown table.
16
- */
17
- export declare function renderTableToMarkdown(table: TableGrid): string;
18
- /**
19
- * Render one page's content: free text and tables interleaved top-to-bottom.
20
- */
21
- export declare function renderPageContent(freeTextBoxes: TextBox[], tables: TableGrid[], imageBlocks?: Array<{
22
- topY: number;
23
- markdown: string;
24
- }>, allTextBoxes?: TextBox[]): string;
@@ -1,75 +0,0 @@
1
- /** Bounding box in PDF coordinate space (origin = bottom-left). */
2
- export type Bounds = {
3
- left: number;
4
- right: number;
5
- /** Higher value = higher on the page. */
6
- top: number;
7
- bottom: number;
8
- };
9
- /** A text fragment with position and font metadata. */
10
- export type TextBox = {
11
- id: string;
12
- text: string;
13
- bounds: Bounds;
14
- pageNumber: number;
15
- /** Dominant font size in points. */
16
- fontSize: number;
17
- /** True if rendered bold (font name or rendering mode). */
18
- isBold: boolean;
19
- };
20
- /** A horizontal or vertical line segment extracted from vector graphics. */
21
- export type Segment = {
22
- id: string;
23
- x1: number;
24
- y1: number;
25
- x2: number;
26
- y2: number;
27
- };
28
- /** A single cell in a resolved table grid. */
29
- export type TableCell = {
30
- row: number;
31
- col: number;
32
- text: string;
33
- rowSpan: number;
34
- colSpan: number;
35
- };
36
- /** A resolved table grid ready for markdown rendering. */
37
- export type TableGrid = {
38
- pageNumber: number;
39
- rows: number;
40
- cols: number;
41
- cells: TableCell[];
42
- warnings: string[];
43
- /** Top Y coordinate (PDF space: larger = higher on page). */
44
- topY: number;
45
- /** True for tables detected without vector borders. */
46
- isBorderless: boolean;
47
- };
48
- /** An image/diagram region detected on a page. */
49
- export type ImageRegion = {
50
- id: string;
51
- pageNumber: number;
52
- /** Bounding box in mupdf coordinates (top-left origin). */
53
- bbox: {
54
- x: number;
55
- y: number;
56
- w: number;
57
- h: number;
58
- };
59
- /** Y position in PDF coordinates (bottom-left) for ordering. */
60
- topY: number;
61
- };
62
- /** Result of extracting content from a single PDF page. */
63
- export type PageContent = {
64
- pageNumber: number;
65
- textBoxes: TextBox[];
66
- segments: Segment[];
67
- images: ImageRegion[];
68
- };
69
- /** A block of rendered content (text paragraph or table). */
70
- export type ContentBlock = {
71
- topY: number;
72
- content: string;
73
- /** True if this line has wide gaps between text boxes (column headers). */
74
- isTabular?: boolean;
75
- };
@@ -1,12 +0,0 @@
1
- import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
2
- import type { ToolSession } from "../sdk.js";
3
- import type { ReadToolDetails } from "./read.js";
4
- export declare function rewritePdfImagePlaceholders(markdown: string, pdfPath: string): string;
5
- export declare function splitPdfImageMemberReadPath(readPath: string): {
6
- pdfPath: string;
7
- member: string;
8
- } | null;
9
- export declare function readPdfImageMember(session: ToolSession, autoResizeImages: boolean, absolutePdfPath: string, pdfDisplayPath: string, member: string, suffixResolution: {
10
- from: string;
11
- to: string;
12
- } | undefined, signal?: AbortSignal): Promise<AgentToolResult<ReadToolDetails>>;
@@ -1 +0,0 @@
1
- export declare function loadEmbeddedMupdfWasm(): Uint8Array | undefined;
@@ -1,67 +0,0 @@
1
- #!/usr/bin/env bun
2
-
3
- // Embeds mupdf's `mupdf-wasm.wasm` into the compiled single-file binary.
4
- //
5
- // mupdf loads its wasm by reading the `mupdf-wasm.wasm` sibling of its own
6
- // module via `new URL(..., import.meta.url)` + `readFileSync`. A `bun --compile`
7
- // binary has no node_modules, so that read fails (`ENOENT .../mupdf-wasm.wasm`),
8
- // and marking mupdf `--external` instead makes `bun --compile` eagerly fail to
9
- // resolve the package at startup (the static `import * as mupdf` lives in a lazy
10
- // chunk but is hoisted). So the binary build bundles mupdf and embeds the wasm
11
- // bytes here, handing them to the WASM module as `$libmupdf_wasm_Module.wasmBinary`
12
- // (see src/utils/markit.ts).
13
- //
14
- // `--generate` copies the wasm next to src/utils/mupdf-wasm-embed.ts and rewrites
15
- // that module to import it via `with { type: "file" }`; `--reset` restores the
16
- // checked-in placeholder and removes the copy. The npm `dist/cli.js` bundle never
17
- // runs this — it keeps mupdf external and loads the wasm from node_modules.
18
-
19
- import * as fs from "node:fs/promises";
20
- import { createRequire } from "node:module";
21
- import * as path from "node:path";
22
-
23
- const utilsDir = path.join(import.meta.dir, "..", "src", "utils");
24
- const helperPath = path.join(utilsDir, "mupdf-wasm-embed.ts");
25
- const wasmCopyPath = path.join(utilsDir, "mupdf-wasm.wasm");
26
-
27
- const placeholder = `// AUTOGENERATED -- managed by scripts/embed-mupdf-wasm.ts. Do not edit by hand.
28
- //
29
- // Compiled single-file binaries cannot let mupdf resolve its \`mupdf-wasm.wasm\`
30
- // sibling from the read-only bunfs, so the binary build (scripts/build-binary.ts
31
- // and scripts/ci-release-build-binaries.ts) regenerates this module to embed the
32
- // wasm bytes via \`with { type: "file" }\` and copies the wasm next to it. Source
33
- // checkouts, \`bun test\`, and the npm \`dist/cli.js\` bundle keep mupdf external and
34
- // load the wasm from node_modules, so this placeholder returns undefined and the
35
- // build resets back to it afterward.
36
- export function loadEmbeddedMupdfWasm(): Uint8Array | undefined {
37
- \treturn undefined;
38
- }
39
- `;
40
-
41
- const generated = `// AUTOGENERATED -- managed by scripts/embed-mupdf-wasm.ts. Do not edit or commit.
42
- import { readFileSync } from "node:fs";
43
- import wasmPath from "./mupdf-wasm.wasm" with { type: "file" };
44
-
45
- export function loadEmbeddedMupdfWasm(): Uint8Array | undefined {
46
- \treturn readFileSync(wasmPath);
47
- }
48
- `;
49
-
50
- if (process.argv.includes("--reset")) {
51
- await Bun.write(helperPath, placeholder);
52
- try {
53
- await fs.unlink(wasmCopyPath);
54
- } catch (err) {
55
- if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
56
- }
57
- process.exit(0);
58
- }
59
-
60
- const wasmSource = path.join(path.dirname(createRequire(import.meta.url).resolve("mupdf")), "mupdf-wasm.wasm");
61
- const wasmFile = Bun.file(wasmSource);
62
- if (!(await wasmFile.exists())) {
63
- throw new Error(`mupdf wasm not found at ${wasmSource}; run \`bun install\` first.`);
64
- }
65
- await Bun.write(wasmCopyPath, wasmFile);
66
- await Bun.write(helperPath, generated);
67
- console.log(`Embedded mupdf wasm (${wasmFile.size} bytes) into ${path.relative(process.cwd(), wasmCopyPath)}`);
@@ -1,103 +0,0 @@
1
- // Adapted from markit-ai (MIT). See ../../NOTICE.
2
-
3
- /**
4
- * Multi-column layout detection and text box reordering.
5
- *
6
- * Many PDFs (legal documents, datasheets, academic papers) use two-column
7
- * layouts. Without column detection, text boxes are ordered by Y position
8
- * only, interleaving left and right column content.
9
- *
10
- * Algorithm:
11
- * 1. Collect left edges of all text boxes on the page
12
- * 2. Find the largest horizontal gap between consecutive left edges
13
- * 3. If gap > MIN_GAP_RATIO of the text width and both sides have
14
- * enough boxes → multi-column detected
15
- * 4. Assign each text box to a column based on its center X
16
- * 5. Return columns in reading order (left-to-right, top-to-bottom)
17
- *
18
- * This only detects the column structure. The caller is responsible for
19
- * processing each column's text boxes independently (table detection,
20
- * rendering, etc.).
21
- */
22
- import type { TextBox } from "./types";
23
-
24
- export interface ColumnLayout {
25
- /** Number of columns detected (1 = single column, 2+ = multi-column). */
26
- columnCount: number;
27
- /** Text boxes grouped by column, in reading order (left to right). */
28
- columns: TextBox[][];
29
- /** X positions of column boundaries (between columns). */
30
- boundaries: number[];
31
- }
32
-
33
- /**
34
- * Minimum gap as a fraction of the total text width to consider a column
35
- * boundary. A two-column layout typically has ~50% gap; we use a lower
36
- * threshold to catch asymmetric columns.
37
- */
38
- const MIN_GAP_RATIO = 0.15;
39
- /** Minimum number of text boxes on each side of the gap. */
40
- const MIN_BOXES_PER_COLUMN = 4;
41
- /** Minimum gap in absolute points to avoid splitting on small whitespace. */
42
- const MIN_GAP_PTS = 40;
43
-
44
- /**
45
- * Detect column layout and return text boxes grouped by column.
46
- *
47
- * For single-column pages, returns all boxes in one group.
48
- * For multi-column pages, returns boxes split by column in reading order.
49
- */
50
- export function detectColumns(textBoxes: TextBox[]): ColumnLayout {
51
- if (textBoxes.length < MIN_BOXES_PER_COLUMN * 2) {
52
- return { columnCount: 1, columns: [textBoxes], boundaries: [] };
53
- }
54
- // Collect unique left edges (rounded to avoid float noise)
55
- const lefts = [...new Set(textBoxes.map(tb => Math.round(tb.bounds.left)))].sort((a, b) => a - b);
56
- if (lefts.length < 2) {
57
- return { columnCount: 1, columns: [textBoxes], boundaries: [] };
58
- }
59
- const textXMin = lefts[0];
60
- const textXMax = Math.max(...textBoxes.map(tb => Math.round(tb.bounds.right)));
61
- const textWidth = textXMax - textXMin;
62
- if (textWidth <= 0) {
63
- return { columnCount: 1, columns: [textBoxes], boundaries: [] };
64
- }
65
- // Find the largest gap between consecutive left-edge positions
66
- let maxGap = 0;
67
- let gapLeft = 0;
68
- let gapRight = 0;
69
- for (let i = 1; i < lefts.length; i++) {
70
- const gap = lefts[i] - lefts[i - 1];
71
- if (gap > maxGap) {
72
- maxGap = gap;
73
- gapLeft = lefts[i - 1];
74
- gapRight = lefts[i];
75
- }
76
- }
77
- const gapRatio = maxGap / textWidth;
78
- if (gapRatio < MIN_GAP_RATIO || maxGap < MIN_GAP_PTS) {
79
- return { columnCount: 1, columns: [textBoxes], boundaries: [] };
80
- }
81
- // Split point is the midpoint of the gap
82
- const splitX = (gapLeft + gapRight) / 2;
83
- // Assign boxes to columns based on center X
84
- const leftCol: TextBox[] = [];
85
- const rightCol: TextBox[] = [];
86
- for (const tb of textBoxes) {
87
- const cx = (tb.bounds.left + tb.bounds.right) / 2;
88
- if (cx < splitX) {
89
- leftCol.push(tb);
90
- } else {
91
- rightCol.push(tb);
92
- }
93
- }
94
- // Validate both columns have enough content
95
- if (leftCol.length < MIN_BOXES_PER_COLUMN || rightCol.length < MIN_BOXES_PER_COLUMN) {
96
- return { columnCount: 1, columns: [textBoxes], boundaries: [] };
97
- }
98
- return {
99
- columnCount: 2,
100
- columns: [leftCol, rightCol],
101
- boundaries: [splitX],
102
- };
103
- }