pi-filler 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/COPYING.md ADDED
@@ -0,0 +1,9 @@
1
+ # Licensing and attribution
2
+
3
+ The project source and template files are released under the MIT License
4
+ in `LICENSE` unless a file states otherwise.
5
+
6
+ The documentation build follows the design of João Oliveira Santos and
7
+ Carlos Pinto Machado's `make it_stop` project. This repository does not
8
+ vendor the upstream project; it keeps a small project-local Make-based
9
+ implementation so the package remains self-contained.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 João Oliveira Santos
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # pi-filler
2
+
3
+ <p align="center"><img src="logo.png" alt="pi-filler logo" width="220"></p>
4
+
5
+ Deterministic DOCX and PDF tooling for Pi.
6
+
7
+ `pi-filler` fills document-handling gaps between ordinary Markdown
8
+ workflows and final office-format requirements. It delegates common
9
+ conversion and PDF extraction to mature command-line tools while keeping
10
+ Word-specific mutations narrow and inspectable.
11
+
12
+ ## Status
13
+
14
+ Early experimental release. The tool schema and supported document
15
+ operations may change before 1.0.0.
16
+
17
+ ## Tool
18
+
19
+ The extension exposes one `filler` tool:
20
+
21
+ ```text
22
+ format: docx | pdf
23
+ action: read | search | write | patch
24
+ view: text | formatting | image
25
+ ```
26
+
27
+ Unsupported combinations and operation-irrelevant parameters fail
28
+ explicitly. DOCX patching also accepts `dry_run: true` to validate and
29
+ report the requested result without writing the output file.
30
+
31
+ ## PDF
32
+
33
+ - Read text with layout preservation and optional page ranges.
34
+ - Search with literal or regular-expression queries and page numbers.
35
+ - Restrict searches to optional page ranges.
36
+ - Render selected pages to PNG files without destroying prior output
37
+ when rendering fails.
38
+ - PDF writing and patching are not supported.
39
+
40
+ ## DOCX
41
+
42
+ - Read and search text through Pandoc.
43
+ - Render selected pages to PNG through LibreOffice and the existing PDF
44
+ rendering path.
45
+ - Generate DOCX from Markdown with an optional reference document.
46
+ - Validate generated DOCX before replacing the requested output.
47
+ - Inspect page setup, margins, sections, line numbering, page
48
+ numbering, comments, inserted/deleted tracked-change markers, and
49
+ common core metadata.
50
+ - Patch page setup, margins, line numbering, page numbering, and common
51
+ core metadata.
52
+ - Use `clearCoreMetadata` to clear common `docProps/core.xml` fields.
53
+ This does **not** remove comment authors, revision authors, or every
54
+ possible identity-bearing property in a DOCX.
55
+
56
+ Page setup, margins, line numbering, and page numbering patches affect
57
+ the first section only. Core-metadata patches are document-level.
58
+ Untouched ZIP package parts remain content-identical. XML parts that are
59
+ edited are parsed and serialized again, so lexical formatting, prefix
60
+ choices, or element ordering inside those edited parts may change.
61
+
62
+ ## External tools
63
+
64
+ Required for the corresponding operations:
65
+
66
+ - `pandoc` for DOCX text conversion and generation.
67
+ - `libreoffice` for DOCX image rendering.
68
+ - `pdftotext` for PDF text extraction.
69
+ - `pdftocairo` for PDF and DOCX page rendering.
70
+ - `pdfgrep` for page-aware PDF search.
71
+
72
+ ## Development
73
+
74
+ ```sh
75
+ npm install
76
+ make verify
77
+ make site
78
+ ```
79
+
80
+ Dependencies and CI actions follow current upstream releases rather than
81
+ being pinned. GitLab is canonical and the only release authority.
82
+ GitHub may mirror the repository, verify it, and publish GitHub Pages.
package/logo.png ADDED
Binary file
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "pi-filler",
3
+ "version": "0.0.0",
4
+ "description": "Deterministic DOCX and PDF tooling for Pi",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "docx",
9
+ "pdf",
10
+ "pandoc",
11
+ "document-processing"
12
+ ],
13
+ "type": "module",
14
+ "license": "MIT",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://gitlab.com/Joao-O-Santos/pi-filler.git"
18
+ },
19
+ "bugs": {
20
+ "url": "https://gitlab.com/Joao-O-Santos/pi-filler/-/issues"
21
+ },
22
+ "homepage": "https://gitlab.com/Joao-O-Santos/pi-filler",
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "files": [
27
+ "src",
28
+ "README.md",
29
+ "logo.png",
30
+ "LICENSE"
31
+ ],
32
+ "scripts": {
33
+ "test": "node --import tsx --test test/*.test.ts",
34
+ "typecheck": "tsc --noEmit",
35
+ "lint": "biome lint src test scripts",
36
+ "format:check": "biome ci --linter-enabled=false --assist-enabled=false src test scripts biome.json package.json tsconfig.json",
37
+ "format:fix": "biome check --write src test scripts biome.json package.json tsconfig.json",
38
+ "markdown:check": "node scripts/format-markdown.mjs",
39
+ "markdown:fix": "node scripts/format-markdown.mjs --write",
40
+ "style": "npm run lint && npm run format:check && npm run markdown:check",
41
+ "check": "npm run typecheck && npm run style && npm test",
42
+ "site": "make site"
43
+ },
44
+ "pi": {
45
+ "extensions": [
46
+ "./src/index.ts"
47
+ ]
48
+ },
49
+ "peerDependencies": {
50
+ "@earendil-works/pi-ai": "*",
51
+ "@earendil-works/pi-coding-agent": "*",
52
+ "@earendil-works/pi-tui": "*",
53
+ "typebox": "*"
54
+ },
55
+ "devDependencies": {
56
+ "@biomejs/biome": "latest",
57
+ "@earendil-works/pi-ai": "latest",
58
+ "@earendil-works/pi-coding-agent": "latest",
59
+ "@earendil-works/pi-tui": "latest",
60
+ "@types/node": "latest",
61
+ "tsx": "latest",
62
+ "typebox": "latest",
63
+ "typescript": "latest"
64
+ },
65
+ "dependencies": {
66
+ "fast-xml-parser": "latest",
67
+ "fflate": "latest"
68
+ }
69
+ }
package/src/docx.ts ADDED
@@ -0,0 +1,518 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { dirname, join, posix, resolve } from "node:path";
4
+ import { XMLBuilder, XMLParser } from "fast-xml-parser";
5
+ import { unzipSync, zipSync } from "fflate";
6
+
7
+ export interface DocxPatchSet {
8
+ page?: { width?: number; height?: number; orientation?: "portrait" | "landscape" };
9
+ margins?: {
10
+ top?: number;
11
+ bottom?: number;
12
+ left?: number;
13
+ right?: number;
14
+ header?: number;
15
+ footer?: number;
16
+ gutter?: number;
17
+ };
18
+ lineNumbering?: {
19
+ mode?: "off" | "continuous" | "newPage" | "newSection";
20
+ start?: number;
21
+ count_by?: number;
22
+ };
23
+ pageNumbering?: { start?: number; format?: string };
24
+ metadata?: {
25
+ title?: string;
26
+ subject?: string;
27
+ creator?: string;
28
+ keywords?: string;
29
+ description?: string;
30
+ lastModifiedBy?: string;
31
+ };
32
+ clearCoreMetadata?: boolean;
33
+ }
34
+
35
+ export interface DocxFormatting {
36
+ page: { width?: number; height?: number; orientation?: string };
37
+ margins: Record<string, number>;
38
+ sectionCount: number;
39
+ lineNumbering: { mode: string; start?: number; count_by?: number };
40
+ pageNumbering: { start?: number; format?: string };
41
+ comments: number;
42
+ trackedChanges: number;
43
+ metadata: Record<string, string>;
44
+ }
45
+
46
+ export interface DocxPatchResult {
47
+ formatting: DocxFormatting;
48
+ changedParts: string[];
49
+ preservedParts: string[];
50
+ written: boolean;
51
+ }
52
+
53
+ type Package = Record<string, Uint8Array>;
54
+ type XmlObject = Record<string, unknown>;
55
+
56
+ const WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
57
+ const CORE_NS = "http://schemas.openxmlformats.org/package/2006/metadata/core-properties";
58
+ const DC_NS = "http://purl.org/dc/elements/1.1/";
59
+
60
+ const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@_" });
61
+ const builder = new XMLBuilder({
62
+ ignoreAttributes: false,
63
+ attributeNamePrefix: "@_",
64
+ format: false,
65
+ });
66
+
67
+ function isObject(value: unknown): value is XmlObject {
68
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
69
+ }
70
+
71
+ function localName(name: string): string {
72
+ return name.replace(/^@_/, "").split(":").at(-1) ?? name;
73
+ }
74
+
75
+ function parseXml(bytes: Uint8Array | undefined, part: string): XmlObject {
76
+ if (!bytes) throw new Error(`DOCX is missing required part ${part}`);
77
+ try {
78
+ return parser.parse(new TextDecoder().decode(bytes), true) as XmlObject;
79
+ } catch (error) {
80
+ throw new Error(`Invalid XML in ${part}: ${error instanceof Error ? error.message : error}`);
81
+ }
82
+ }
83
+
84
+ function serializeXml(value: XmlObject): Uint8Array {
85
+ return new TextEncoder().encode(builder.build(value));
86
+ }
87
+
88
+ function unpack(bytes: Uint8Array): Package {
89
+ try {
90
+ return unzipSync(bytes);
91
+ } catch (error) {
92
+ throw new Error(`Invalid DOCX ZIP package: ${error instanceof Error ? error.message : error}`);
93
+ }
94
+ }
95
+
96
+ function findAll(value: unknown, suffix: string, found: XmlObject[] = []): XmlObject[] {
97
+ if (!value || typeof value !== "object") return found;
98
+ for (const [key, child] of Object.entries(value)) {
99
+ if (!key.startsWith("@_") && localName(key) === suffix) {
100
+ for (const item of Array.isArray(child) ? child : [child]) {
101
+ if (isObject(item)) found.push(item);
102
+ }
103
+ }
104
+ findAll(child, suffix, found);
105
+ }
106
+ return found;
107
+ }
108
+
109
+ function first(value: unknown, suffix: string): XmlObject | undefined {
110
+ return findAll(value, suffix)[0];
111
+ }
112
+
113
+ function directObject(value: XmlObject | undefined, suffix: string): XmlObject | undefined {
114
+ if (!value) return undefined;
115
+ for (const [key, child] of Object.entries(value)) {
116
+ if (key.startsWith("@_") || localName(key) !== suffix) continue;
117
+ const candidate = Array.isArray(child) ? child[0] : child;
118
+ return isObject(candidate) ? candidate : undefined;
119
+ }
120
+ return undefined;
121
+ }
122
+
123
+ function rootObject(value: XmlObject, suffix: string): XmlObject {
124
+ const root = directObject(value, suffix);
125
+ if (!root) throw new Error(`DOCX XML is missing ${suffix}`);
126
+ return root;
127
+ }
128
+
129
+ function attr(value: XmlObject | undefined, name: string): string | undefined {
130
+ if (!value) return undefined;
131
+ for (const [key, child] of Object.entries(value)) {
132
+ if (key.startsWith("@_") && localName(key) === name && child !== undefined) {
133
+ return String(child);
134
+ }
135
+ }
136
+ return undefined;
137
+ }
138
+
139
+ function numberAttr(value: XmlObject | undefined, name: string): number | undefined {
140
+ const raw = attr(value, name);
141
+ return raw === undefined ? undefined : Number(raw);
142
+ }
143
+
144
+ function namespacePrefix(root: XmlObject, uri: string, preferred: string): string {
145
+ for (const [key, value] of Object.entries(root)) {
146
+ if (key.startsWith("@_xmlns:") && value === uri) return key.slice("@_xmlns:".length);
147
+ }
148
+ root[`@_xmlns:${preferred}`] = uri;
149
+ return preferred;
150
+ }
151
+
152
+ function setAttr(value: XmlObject, name: string, setting: number | string, prefix: string): void {
153
+ const existing = Object.keys(value).find(
154
+ (key) => key.startsWith("@_") && localName(key) === name,
155
+ );
156
+ value[existing ?? `@_${prefix}:${name}`] = String(setting);
157
+ }
158
+
159
+ function removeDirect(value: XmlObject, name: string): void {
160
+ for (const key of Object.keys(value)) {
161
+ if (!key.startsWith("@_") && localName(key) === name) delete value[key];
162
+ }
163
+ }
164
+
165
+ function countTags(value: unknown, suffix: string): number {
166
+ return findAll(value, suffix).length;
167
+ }
168
+
169
+ function hasValues(value: object | undefined): boolean {
170
+ return Boolean(value && Object.keys(value).length > 0);
171
+ }
172
+
173
+ const metadataNamespaces: Record<string, string> = {
174
+ title: DC_NS,
175
+ subject: DC_NS,
176
+ creator: DC_NS,
177
+ keywords: CORE_NS,
178
+ description: DC_NS,
179
+ lastModifiedBy: CORE_NS,
180
+ };
181
+
182
+ function childText(value: XmlObject, name: string): string | undefined {
183
+ for (const [key, child] of Object.entries(value)) {
184
+ if (key.startsWith("@_") || localName(key) !== name) continue;
185
+ if (typeof child === "string" || typeof child === "number") return String(child);
186
+ if (isObject(child)) {
187
+ const text = child["#text"];
188
+ if (typeof text === "string" || typeof text === "number") return String(text);
189
+ }
190
+ }
191
+ return undefined;
192
+ }
193
+
194
+ function setChildText(value: XmlObject, name: string, text: string): void {
195
+ const existing = Object.keys(value).find(
196
+ (key) => !key.startsWith("@_") && localName(key) === name,
197
+ );
198
+ if (existing) {
199
+ const child = value[existing];
200
+ if (isObject(child)) child["#text"] = text;
201
+ else value[existing] = text;
202
+ return;
203
+ }
204
+ const uri = metadataNamespaces[name] ?? DC_NS;
205
+ const prefix = namespacePrefix(value, uri, uri === CORE_NS ? "cp" : "dc");
206
+ value[`${prefix}:${name}`] = text;
207
+ }
208
+
209
+ function coreMetadata(pkg: Package): Record<string, string> {
210
+ const bytes = pkg["docProps/core.xml"];
211
+ if (!bytes) return {};
212
+ const props = rootObject(parseXml(bytes, "docProps/core.xml"), "coreProperties");
213
+ const result: Record<string, string> = {};
214
+ for (const name of Object.keys(metadataNamespaces)) {
215
+ const value = childText(props, name);
216
+ if (value !== undefined) result[name] = value;
217
+ }
218
+ return result;
219
+ }
220
+
221
+ function countTrackedChanges(pkg: Package, document: XmlObject): number {
222
+ let count = countTags(document, "ins") + countTags(document, "del");
223
+ const contentPart = /^word\/(?:header\d+|footer\d+|footnotes|endnotes|comments)\.xml$/;
224
+ for (const [part, bytes] of Object.entries(pkg)) {
225
+ if (!contentPart.test(part)) continue;
226
+ const content = parseXml(bytes, part);
227
+ count += countTags(content, "ins") + countTags(content, "del");
228
+ }
229
+ return count;
230
+ }
231
+
232
+ function inspectPackage(pkg: Package): DocxFormatting {
233
+ const document = parseXml(pkg["word/document.xml"], "word/document.xml");
234
+ const sectPrs = findAll(document, "sectPr");
235
+ const section = sectPrs[0];
236
+ const size = directObject(section, "pgSz");
237
+ const margin = directObject(section, "pgMar");
238
+ const line = directObject(section, "lnNumType");
239
+ const pageNum = directObject(section, "pgNumType");
240
+ return {
241
+ page: {
242
+ width: numberAttr(size, "w"),
243
+ height: numberAttr(size, "h"),
244
+ orientation: size ? (attr(size, "orient") ?? "portrait") : undefined,
245
+ },
246
+ margins: Object.fromEntries(
247
+ ["top", "bottom", "left", "right", "header", "footer", "gutter"]
248
+ .map((name) => [name, numberAttr(margin, name)])
249
+ .filter((entry): entry is [string, number] => entry[1] !== undefined),
250
+ ),
251
+ sectionCount: sectPrs.length,
252
+ lineNumbering: {
253
+ mode: line ? (attr(line, "restart") ?? "newPage") : "off",
254
+ start: numberAttr(line, "start"),
255
+ count_by: numberAttr(line, "countBy"),
256
+ },
257
+ pageNumbering: { start: numberAttr(pageNum, "start"), format: attr(pageNum, "fmt") },
258
+ comments: pkg["word/comments.xml"]
259
+ ? countTags(parseXml(pkg["word/comments.xml"], "word/comments.xml"), "comment")
260
+ : 0,
261
+ trackedChanges: countTrackedChanges(pkg, document),
262
+ metadata: coreMetadata(pkg),
263
+ };
264
+ }
265
+
266
+ function firstSection(document: XmlObject, prefix: string): XmlObject {
267
+ const existing = first(document, "sectPr");
268
+ if (existing) return existing;
269
+ const body = first(document, "body");
270
+ if (!body) throw new Error("DOCX is missing document body");
271
+ const section: XmlObject = {};
272
+ body[`${prefix}:sectPr`] = section;
273
+ return section;
274
+ }
275
+
276
+ function ensureChild(parent: XmlObject, name: string, prefix: string): XmlObject {
277
+ let child = directObject(parent, name);
278
+ if (!child) {
279
+ child = {};
280
+ parent[`${prefix}:${name}`] = child;
281
+ }
282
+ return child;
283
+ }
284
+
285
+ function patchPage(
286
+ section: XmlObject,
287
+ patch: NonNullable<DocxPatchSet["page"]>,
288
+ prefix: string,
289
+ ): void {
290
+ const size = ensureChild(section, "pgSz", prefix);
291
+ const width = numberAttr(size, "w");
292
+ const height = numberAttr(size, "h");
293
+ if (
294
+ patch.orientation !== undefined &&
295
+ patch.width === undefined &&
296
+ patch.height === undefined &&
297
+ width !== undefined &&
298
+ height !== undefined &&
299
+ ((patch.orientation === "landscape" && width < height) ||
300
+ (patch.orientation === "portrait" && width > height))
301
+ ) {
302
+ setAttr(size, "w", height, prefix);
303
+ setAttr(size, "h", width, prefix);
304
+ }
305
+ if (patch.width !== undefined) setAttr(size, "w", patch.width, prefix);
306
+ if (patch.height !== undefined) setAttr(size, "h", patch.height, prefix);
307
+ if (patch.orientation !== undefined) setAttr(size, "orient", patch.orientation, prefix);
308
+ }
309
+
310
+ function applyPatch(pkg: Package, patch: DocxPatchSet): string[] {
311
+ const changed = new Set<string>();
312
+ const documentPatch =
313
+ hasValues(patch.page) ||
314
+ hasValues(patch.margins) ||
315
+ hasValues(patch.lineNumbering) ||
316
+ hasValues(patch.pageNumbering);
317
+ if (documentPatch) {
318
+ const document = parseXml(pkg["word/document.xml"], "word/document.xml");
319
+ const documentRoot = rootObject(document, "document");
320
+ const prefix = namespacePrefix(documentRoot, WORD_NS, "w");
321
+ const sectPr = firstSection(document, prefix);
322
+
323
+ if (hasValues(patch.page)) patchPage(sectPr, patch.page ?? {}, prefix);
324
+ if (hasValues(patch.margins)) {
325
+ const margin = ensureChild(sectPr, "pgMar", prefix);
326
+ for (const [name, value] of Object.entries(patch.margins ?? {})) {
327
+ setAttr(margin, name, value, prefix);
328
+ }
329
+ }
330
+ if (hasValues(patch.lineNumbering)) {
331
+ if (patch.lineNumbering?.mode === "off") removeDirect(sectPr, "lnNumType");
332
+ else {
333
+ const line = ensureChild(sectPr, "lnNumType", prefix);
334
+ if (patch.lineNumbering?.mode) {
335
+ setAttr(line, "restart", patch.lineNumbering.mode, prefix);
336
+ }
337
+ if (patch.lineNumbering?.start !== undefined) {
338
+ setAttr(line, "start", patch.lineNumbering.start, prefix);
339
+ }
340
+ if (patch.lineNumbering?.count_by !== undefined) {
341
+ setAttr(line, "countBy", patch.lineNumbering.count_by, prefix);
342
+ }
343
+ }
344
+ }
345
+ if (hasValues(patch.pageNumbering)) {
346
+ const page = ensureChild(sectPr, "pgNumType", prefix);
347
+ if (patch.pageNumbering?.start !== undefined) {
348
+ setAttr(page, "start", patch.pageNumbering.start, prefix);
349
+ }
350
+ if (patch.pageNumbering?.format !== undefined) {
351
+ setAttr(page, "fmt", patch.pageNumbering.format, prefix);
352
+ }
353
+ }
354
+ pkg["word/document.xml"] = serializeXml(document);
355
+ changed.add("word/document.xml");
356
+ }
357
+
358
+ if (hasValues(patch.metadata) || patch.clearCoreMetadata) {
359
+ const core = parseXml(pkg["docProps/core.xml"], "docProps/core.xml");
360
+ const props = rootObject(core, "coreProperties");
361
+ if (patch.clearCoreMetadata) {
362
+ for (const name of Object.keys(metadataNamespaces)) {
363
+ if (childText(props, name) !== undefined) setChildText(props, name, "");
364
+ }
365
+ }
366
+ for (const [name, value] of Object.entries(patch.metadata ?? {})) {
367
+ setChildText(props, name, value);
368
+ }
369
+ pkg["docProps/core.xml"] = serializeXml(core);
370
+ changed.add("docProps/core.xml");
371
+ }
372
+ return [...changed];
373
+ }
374
+
375
+ function validatePatch(patch: DocxPatchSet): void {
376
+ const requested =
377
+ hasValues(patch.page) ||
378
+ hasValues(patch.margins) ||
379
+ hasValues(patch.lineNumbering) ||
380
+ hasValues(patch.pageNumbering) ||
381
+ hasValues(patch.metadata) ||
382
+ patch.clearCoreMetadata === true;
383
+ if (!requested) throw new Error("DOCX patch requires at least one change");
384
+ if (
385
+ patch.lineNumbering?.mode === "off" &&
386
+ (patch.lineNumbering.start !== undefined || patch.lineNumbering.count_by !== undefined)
387
+ ) {
388
+ throw new Error("Line numbering mode=off cannot include start or count_by");
389
+ }
390
+ }
391
+
392
+ function validateRequested(formatting: DocxFormatting, patch: DocxPatchSet): void {
393
+ if (patch.page) {
394
+ for (const [key, value] of Object.entries(patch.page)) {
395
+ if (formatting.page[key as keyof typeof formatting.page] !== value) {
396
+ throw new Error(`DOCX page patch validation failed for ${key}`);
397
+ }
398
+ }
399
+ }
400
+ if (patch.margins) {
401
+ for (const [key, value] of Object.entries(patch.margins)) {
402
+ if (formatting.margins[key] !== value) {
403
+ throw new Error(`DOCX margin patch validation failed for ${key}`);
404
+ }
405
+ }
406
+ }
407
+ if (patch.lineNumbering) {
408
+ for (const [key, value] of Object.entries(patch.lineNumbering)) {
409
+ if (formatting.lineNumbering[key as keyof typeof formatting.lineNumbering] !== value) {
410
+ throw new Error(`DOCX line numbering patch validation failed for ${key}`);
411
+ }
412
+ }
413
+ }
414
+ if (patch.pageNumbering) {
415
+ for (const [key, value] of Object.entries(patch.pageNumbering)) {
416
+ if (formatting.pageNumbering[key as keyof typeof formatting.pageNumbering] !== value) {
417
+ throw new Error(`DOCX page numbering patch validation failed for ${key}`);
418
+ }
419
+ }
420
+ }
421
+ if (patch.metadata) {
422
+ for (const [key, value] of Object.entries(patch.metadata)) {
423
+ if (formatting.metadata[key] !== value) {
424
+ throw new Error(`DOCX metadata patch validation failed for ${key}`);
425
+ }
426
+ }
427
+ }
428
+ if (patch.clearCoreMetadata) {
429
+ for (const name of Object.keys(metadataNamespaces)) {
430
+ if (patch.metadata?.[name as keyof NonNullable<DocxPatchSet["metadata"]>] !== undefined) {
431
+ continue;
432
+ }
433
+ if (formatting.metadata[name]) {
434
+ throw new Error(`DOCX core metadata clearing validation failed for ${name}`);
435
+ }
436
+ }
437
+ }
438
+ }
439
+
440
+ function relationshipBase(part: string): string {
441
+ if (part.startsWith("_rels/")) return "";
442
+ const marker = "/_rels/";
443
+ const index = part.indexOf(marker);
444
+ return index === -1 ? posix.dirname(part) : part.slice(0, index);
445
+ }
446
+
447
+ function validateRelationships(pkg: Package): void {
448
+ for (const part of Object.keys(pkg).filter((name) => name.endsWith(".rels"))) {
449
+ const root = parseXml(pkg[part], part);
450
+ for (const relationship of findAll(root, "Relationship")) {
451
+ const target = attr(relationship, "Target");
452
+ if (!target || attr(relationship, "TargetMode") === "External") continue;
453
+ const normalized = target.startsWith("/")
454
+ ? posix.normalize(target.slice(1))
455
+ : posix.normalize(posix.join(relationshipBase(part), target));
456
+ if (!pkg[normalized]) {
457
+ throw new Error(`DOCX relationship target is missing: ${normalized}`);
458
+ }
459
+ }
460
+ }
461
+ }
462
+
463
+ function validatePackage(pkg: Package): void {
464
+ for (const part of ["[Content_Types].xml", "word/document.xml"]) {
465
+ parseXml(pkg[part], part);
466
+ }
467
+ for (const [part, bytes] of Object.entries(pkg)) {
468
+ if (part.endsWith(".xml") || part.endsWith(".rels")) parseXml(bytes, part);
469
+ }
470
+ validateRelationships(pkg);
471
+ }
472
+
473
+ async function load(path: string): Promise<Package> {
474
+ return unpack(new Uint8Array(await readFile(resolve(path))));
475
+ }
476
+
477
+ export async function inspectDocx(path: string): Promise<DocxFormatting> {
478
+ const pkg = await load(path);
479
+ validatePackage(pkg);
480
+ return inspectPackage(pkg);
481
+ }
482
+
483
+ export async function patchDocx(
484
+ inputPath: string,
485
+ outputPath: string,
486
+ patch: DocxPatchSet,
487
+ dryRun = false,
488
+ ): Promise<DocxPatchResult> {
489
+ const input = resolve(inputPath);
490
+ const output = resolve(outputPath);
491
+ if (input === output) throw new Error("DOCX patch output must differ from input");
492
+ validatePatch(patch);
493
+ const pkg = await load(input);
494
+ validatePackage(pkg);
495
+ const changedParts = applyPatch(pkg, patch);
496
+ validatePackage(pkg);
497
+ const formatting = inspectPackage(pkg);
498
+ validateRequested(formatting, patch);
499
+ const allParts = Object.keys(pkg).sort();
500
+ const result = {
501
+ formatting,
502
+ changedParts: changedParts.sort(),
503
+ preservedParts: allParts.filter((part) => !changedParts.includes(part)),
504
+ written: false,
505
+ };
506
+ if (dryRun) return result;
507
+ const temporary = join(dirname(output), `.${output.split(/[\\/]/).pop()}.${randomUUID()}.tmp`);
508
+ await mkdir(dirname(output), { recursive: true });
509
+ try {
510
+ await writeFile(temporary, zipSync(pkg));
511
+ await inspectDocx(temporary);
512
+ await rename(temporary, output);
513
+ result.written = true;
514
+ return result;
515
+ } finally {
516
+ await rm(temporary, { force: true });
517
+ }
518
+ }
package/src/index.ts ADDED
@@ -0,0 +1,335 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { basename, dirname, extname, join, resolve } from "node:path";
4
+ import { StringEnum } from "@earendil-works/pi-ai";
5
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+ import {
7
+ DEFAULT_MAX_BYTES,
8
+ truncateHead,
9
+ withFileMutationQueue,
10
+ } from "@earendil-works/pi-coding-agent";
11
+ import { type Static, Type } from "typebox";
12
+ import { type DocxPatchSet, inspectDocx, patchDocx } from "./docx.js";
13
+ import { readDocxText, searchDocx, writeDocx } from "./pandoc.js";
14
+ import { readPdfText, renderPdfPages, searchPdf } from "./pdf.js";
15
+ import { commandError, runCommand } from "./process.js";
16
+
17
+ const docxPatchesSchema = Type.Object(
18
+ {
19
+ page: Type.Optional(
20
+ Type.Object(
21
+ {
22
+ width: Type.Optional(Type.Integer({ minimum: 1 })),
23
+ height: Type.Optional(Type.Integer({ minimum: 1 })),
24
+ orientation: Type.Optional(StringEnum(["portrait", "landscape"] as const)),
25
+ },
26
+ { additionalProperties: false, minProperties: 1 },
27
+ ),
28
+ ),
29
+ margins: Type.Optional(
30
+ Type.Object(
31
+ {
32
+ top: Type.Optional(Type.Integer()),
33
+ bottom: Type.Optional(Type.Integer()),
34
+ left: Type.Optional(Type.Integer()),
35
+ right: Type.Optional(Type.Integer()),
36
+ header: Type.Optional(Type.Integer()),
37
+ footer: Type.Optional(Type.Integer()),
38
+ gutter: Type.Optional(Type.Integer()),
39
+ },
40
+ { additionalProperties: false, minProperties: 1 },
41
+ ),
42
+ ),
43
+ lineNumbering: Type.Optional(
44
+ Type.Object(
45
+ {
46
+ mode: Type.Optional(StringEnum(["off", "continuous", "newPage", "newSection"] as const)),
47
+ start: Type.Optional(Type.Integer({ minimum: 0 })),
48
+ count_by: Type.Optional(Type.Integer({ minimum: 1 })),
49
+ },
50
+ { additionalProperties: false, minProperties: 1 },
51
+ ),
52
+ ),
53
+ pageNumbering: Type.Optional(
54
+ Type.Object(
55
+ {
56
+ start: Type.Optional(Type.Integer({ minimum: 0 })),
57
+ format: Type.Optional(Type.String({ minLength: 1 })),
58
+ },
59
+ { additionalProperties: false, minProperties: 1 },
60
+ ),
61
+ ),
62
+ metadata: Type.Optional(
63
+ Type.Object(
64
+ {
65
+ title: Type.Optional(Type.String()),
66
+ subject: Type.Optional(Type.String()),
67
+ creator: Type.Optional(Type.String()),
68
+ keywords: Type.Optional(Type.String()),
69
+ description: Type.Optional(Type.String()),
70
+ lastModifiedBy: Type.Optional(Type.String()),
71
+ },
72
+ { additionalProperties: false, minProperties: 1 },
73
+ ),
74
+ ),
75
+ clearCoreMetadata: Type.Optional(
76
+ Type.Boolean({
77
+ description: "Clear common fields in docProps/core.xml; not comment or revision authors",
78
+ }),
79
+ ),
80
+ },
81
+ { additionalProperties: false, minProperties: 1 },
82
+ );
83
+
84
+ export const fillerSchema = Type.Object(
85
+ {
86
+ format: StringEnum(["docx", "pdf"] as const),
87
+ action: StringEnum(["read", "search", "write", "patch"] as const),
88
+ view: Type.Optional(StringEnum(["text", "formatting", "image"] as const)),
89
+ path: Type.String({ description: "Input document path", minLength: 1 }),
90
+ output: Type.Optional(
91
+ Type.String({ description: "Output file or image prefix", minLength: 1 }),
92
+ ),
93
+ query: Type.Optional(Type.String({ description: "Search query", minLength: 1 })),
94
+ reference_docx: Type.Optional(
95
+ Type.String({ description: "Reference DOCX for writes", minLength: 1 }),
96
+ ),
97
+ patches: Type.Optional(docxPatchesSchema),
98
+ dry_run: Type.Optional(Type.Boolean({ description: "Validate and report without writing" })),
99
+ literal: Type.Optional(
100
+ Type.Boolean({ description: "Treat a PDF search query as literal text" }),
101
+ ),
102
+ ignore_case: Type.Optional(Type.Boolean({ description: "Ignore case while searching" })),
103
+ first_page: Type.Optional(Type.Integer({ minimum: 1 })),
104
+ last_page: Type.Optional(Type.Integer({ minimum: 1 })),
105
+ },
106
+ { additionalProperties: false },
107
+ );
108
+
109
+ export type FillerInput = Static<typeof fillerSchema>;
110
+
111
+ type ControlField = Exclude<keyof FillerInput, "format" | "action" | "view" | "path">;
112
+
113
+ const controlFields: ControlField[] = [
114
+ "output",
115
+ "query",
116
+ "reference_docx",
117
+ "patches",
118
+ "dry_run",
119
+ "literal",
120
+ "ignore_case",
121
+ "first_page",
122
+ "last_page",
123
+ ];
124
+
125
+ export function normalizePath(path: string, cwd: string): string {
126
+ const withoutAt = path.startsWith("@") ? path.slice(1) : path;
127
+ if (!withoutAt) throw new Error("Path must not be empty");
128
+ return resolve(cwd, withoutAt);
129
+ }
130
+
131
+ function validatePageRange(input: FillerInput): void {
132
+ if (input.first_page !== undefined && input.first_page < 1) {
133
+ throw new Error("first_page must be at least 1");
134
+ }
135
+ if (input.last_page !== undefined && input.last_page < 1) {
136
+ throw new Error("last_page must be at least 1");
137
+ }
138
+ if (
139
+ input.first_page !== undefined &&
140
+ input.last_page !== undefined &&
141
+ input.first_page > input.last_page
142
+ ) {
143
+ throw new Error("first_page cannot be greater than last_page");
144
+ }
145
+ }
146
+
147
+ function assertParameters(input: FillerInput, allowed: readonly ControlField[]): void {
148
+ for (const field of controlFields) {
149
+ if (input[field] !== undefined && !allowed.includes(field)) {
150
+ throw new Error(`${input.format.toUpperCase()} ${input.action} does not accept ${field}`);
151
+ }
152
+ }
153
+ }
154
+
155
+ function assertSupported(input: FillerInput): void {
156
+ if (!input.path) throw new Error("path must not be empty");
157
+ if (input.format === "docx") {
158
+ if (input.action === "read") {
159
+ if (input.view === "image") {
160
+ if (!input.output) throw new Error("DOCX image reads require output");
161
+ assertParameters(input, ["output", "first_page", "last_page"]);
162
+ return;
163
+ }
164
+ if (input.view !== "text" && input.view !== "formatting") {
165
+ throw new Error("DOCX read requires view=text, view=formatting, or view=image");
166
+ }
167
+ assertParameters(input, []);
168
+ return;
169
+ }
170
+ if (input.action === "search") {
171
+ if (input.view !== undefined && input.view !== "text") {
172
+ throw new Error("DOCX search requires view=text or no view");
173
+ }
174
+ if (!input.query) throw new Error("DOCX search requires query");
175
+ assertParameters(input, ["query", "ignore_case"]);
176
+ return;
177
+ }
178
+ if (input.action === "write") {
179
+ if (input.view !== undefined || !input.output) {
180
+ throw new Error("DOCX write requires output and does not accept view");
181
+ }
182
+ assertParameters(input, ["output", "reference_docx"]);
183
+ return;
184
+ }
185
+ if (input.view !== undefined || !input.output) {
186
+ throw new Error("DOCX patch requires output and does not accept view");
187
+ }
188
+ if (input.patches === undefined) throw new Error("DOCX patch requires patches");
189
+ assertParameters(input, ["output", "patches", "dry_run"]);
190
+ return;
191
+ }
192
+
193
+ if (input.action === "write" || input.action === "patch") {
194
+ throw new Error("PDF write and patch operations are not supported");
195
+ }
196
+ if (input.action === "search") {
197
+ if (input.view !== undefined && input.view !== "text") {
198
+ throw new Error("PDF search requires view=text or no view");
199
+ }
200
+ if (!input.query) throw new Error("PDF search requires query");
201
+ assertParameters(input, ["query", "literal", "ignore_case", "first_page", "last_page"]);
202
+ return;
203
+ }
204
+ if (input.view !== "text" && input.view !== "image") {
205
+ throw new Error("PDF read requires view=text or view=image");
206
+ }
207
+ if (input.view === "image") {
208
+ if (!input.output) throw new Error("PDF image reads require output");
209
+ assertParameters(input, ["output", "first_page", "last_page"]);
210
+ return;
211
+ }
212
+ assertParameters(input, ["first_page", "last_page"]);
213
+ }
214
+
215
+ export interface FillerResult {
216
+ text: string;
217
+ details: Record<string, unknown>;
218
+ }
219
+
220
+ export async function executeFiller(
221
+ input: FillerInput,
222
+ cwd: string,
223
+ signal?: AbortSignal,
224
+ ): Promise<FillerResult> {
225
+ assertSupported(input);
226
+ validatePageRange(input);
227
+ const path = normalizePath(input.path, cwd);
228
+ const range = { firstPage: input.first_page, lastPage: input.last_page };
229
+
230
+ if (input.format === "docx") {
231
+ if (input.action === "read" && input.view === "formatting") {
232
+ const formatting = await inspectDocx(path);
233
+ return { text: JSON.stringify(formatting, null, 2), details: { formatting } };
234
+ }
235
+ if (input.action === "read" && input.view === "image") {
236
+ const output = normalizePath(input.output ?? "", cwd);
237
+ const temporary = await mkdtemp(join(tmpdir(), "pi-filler-docx-"));
238
+ try {
239
+ const converted = await runCommand(
240
+ "libreoffice",
241
+ ["--headless", "--convert-to", "pdf", "--outdir", temporary, path],
242
+ { signal, cwd: temporary },
243
+ );
244
+ if (converted.code !== 0) throw commandError(converted, "libreoffice");
245
+ const pdf = join(temporary, `${basename(path, extname(path))}.pdf`);
246
+ const result = await withFileMutationQueue(output, () =>
247
+ renderPdfPages(pdf, output, range, { signal }),
248
+ );
249
+ return {
250
+ text: `Rendered ${result.length} page image${result.length === 1 ? "" : "s"}:\n${result.join("\n")}`,
251
+ details: { files: result },
252
+ };
253
+ } finally {
254
+ await rm(temporary, { recursive: true, force: true });
255
+ }
256
+ }
257
+ if (input.action === "read") {
258
+ const result = await readDocxText(path, { signal });
259
+ return { text: result.text, details: { truncation: result.truncation } };
260
+ }
261
+ if (input.action === "search") {
262
+ const result = await searchDocx(path, input.query ?? "", {
263
+ ignoreCase: input.ignore_case,
264
+ signal,
265
+ });
266
+ return {
267
+ text: result.text || "No matches found",
268
+ details: { matchCount: result.matchCount, truncation: result.truncation },
269
+ };
270
+ }
271
+ if (input.action === "patch") {
272
+ const output = normalizePath(input.output ?? "", cwd);
273
+ const result = await withFileMutationQueue(output, () =>
274
+ patchDocx(path, output, input.patches as DocxPatchSet, input.dry_run),
275
+ );
276
+ return {
277
+ text: JSON.stringify(result, null, 2),
278
+ details: result as unknown as Record<string, unknown>,
279
+ };
280
+ }
281
+
282
+ const output = normalizePath(input.output ?? "", cwd);
283
+ if (output === path) throw new Error("DOCX write output must differ from input");
284
+ const referenceDocx = input.reference_docx
285
+ ? normalizePath(input.reference_docx, cwd)
286
+ : undefined;
287
+ await withFileMutationQueue(output, () =>
288
+ writeDocx(path, output, { referenceDocx, signal, cwd: dirname(path) }),
289
+ );
290
+ return { text: `Wrote ${output}`, details: { output } };
291
+ }
292
+
293
+ if (input.action === "search") {
294
+ const result = await searchPdf(path, input.query ?? "", {
295
+ ...range,
296
+ ignoreCase: input.ignore_case,
297
+ literal: input.literal,
298
+ signal,
299
+ });
300
+ const truncation = truncateHead(result.text);
301
+ return {
302
+ text: truncation.truncated
303
+ ? `${truncation.content}\n\n[Search output truncated at ${DEFAULT_MAX_BYTES} bytes]`
304
+ : truncation.content || "No matches found",
305
+ details: { matchCount: result.matchCount, truncation },
306
+ };
307
+ }
308
+ if (input.view === "text") {
309
+ const result = await readPdfText(path, range, { signal });
310
+ return { text: result.text, details: { truncation: result.truncation } };
311
+ }
312
+
313
+ const output = normalizePath(input.output ?? "", cwd);
314
+ const result = await withFileMutationQueue(output, () =>
315
+ renderPdfPages(path, output, range, { signal }),
316
+ );
317
+ return {
318
+ text: `Rendered ${result.length} page image${result.length === 1 ? "" : "s"}:\n${result.join("\n")}`,
319
+ details: { files: result },
320
+ };
321
+ }
322
+
323
+ export default function extension(pi: ExtensionAPI): void {
324
+ pi.registerTool({
325
+ name: "filler",
326
+ label: "Filler",
327
+ description: "Read, search, inspect, write, or patch DOCX/PDF documents with bounded output.",
328
+ promptSnippet: "Read, search, inspect, write, or patch a document",
329
+ parameters: fillerSchema,
330
+ async execute(_toolCallId, input, signal, _onUpdate, ctx: ExtensionContext) {
331
+ const result = await executeFiller(input, ctx.cwd, signal);
332
+ return { content: [{ type: "text", text: result.text }], details: result.details };
333
+ },
334
+ });
335
+ }
package/src/pandoc.ts ADDED
@@ -0,0 +1,98 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, rename, rm } from "node:fs/promises";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { type TruncationResult, truncateHead } from "@earendil-works/pi-coding-agent";
5
+ import { inspectDocx } from "./docx.js";
6
+ import { commandError, type RunCommandOptions, runCommand } from "./process.js";
7
+
8
+ export interface DocxTextResult {
9
+ text: string;
10
+ truncation: TruncationResult;
11
+ }
12
+
13
+ export interface DocxSearchResult {
14
+ matchCount: number;
15
+ text: string;
16
+ truncation: TruncationResult;
17
+ }
18
+
19
+ function commandOptions(options: RunCommandOptions | undefined, path: string): RunCommandOptions {
20
+ return { ...options, cwd: options?.cwd ?? dirname(path) };
21
+ }
22
+
23
+ async function runPandoc(
24
+ args: string[],
25
+ options: RunCommandOptions | undefined,
26
+ path: string,
27
+ ): Promise<Buffer> {
28
+ const result = await runCommand("pandoc", args, commandOptions(options, path));
29
+ if (result.code !== 0) throw commandError(result, "pandoc");
30
+ return result.stdout;
31
+ }
32
+
33
+ async function extractDocxMarkdown(path: string, options?: RunCommandOptions): Promise<string> {
34
+ const source = resolve(path);
35
+ const output = await runPandoc(
36
+ ["--from=docx", "--to=gfm", "--wrap=none", source],
37
+ options,
38
+ source,
39
+ );
40
+ return output.toString("utf8");
41
+ }
42
+
43
+ export async function readDocxText(
44
+ path: string,
45
+ options?: RunCommandOptions,
46
+ ): Promise<DocxTextResult> {
47
+ const truncation = truncateHead(await extractDocxMarkdown(path, options));
48
+ return { text: truncation.content, truncation };
49
+ }
50
+
51
+ export async function searchDocx(
52
+ path: string,
53
+ query: string,
54
+ options: RunCommandOptions & { ignoreCase?: boolean } = {},
55
+ ): Promise<DocxSearchResult> {
56
+ const extracted = await extractDocxMarkdown(path, options);
57
+ const needle = options.ignoreCase ? query.toLocaleLowerCase() : query;
58
+ const matches: string[] = [];
59
+ for (const [index, text] of extracted.split(/\r?\n/).entries()) {
60
+ const haystack = options.ignoreCase ? text.toLocaleLowerCase() : text;
61
+ if (haystack.includes(needle)) matches.push(`line ${index + 1}: ${text}`);
62
+ }
63
+ const truncation = truncateHead(matches.join("\n"));
64
+ return { matchCount: matches.length, text: truncation.content, truncation };
65
+ }
66
+
67
+ export interface WriteDocxOptions extends RunCommandOptions {
68
+ referenceDocx?: string;
69
+ }
70
+
71
+ export async function writeDocx(
72
+ sourcePath: string,
73
+ outputPath: string,
74
+ options: WriteDocxOptions = {},
75
+ ): Promise<void> {
76
+ const source = resolve(sourcePath);
77
+ const output = resolve(outputPath);
78
+ if (source === output) throw new Error("DOCX write output must differ from input");
79
+ const temporary = join(
80
+ dirname(output),
81
+ `.${output.split(/[\\/]/).pop() ?? "output.docx"}.${randomUUID()}.tmp`,
82
+ );
83
+ await mkdir(dirname(output), { recursive: true });
84
+ const args = ["--from=gfm", "--to=docx", "--output", temporary];
85
+ if (options.referenceDocx) args.push("--reference-doc", resolve(options.referenceDocx));
86
+ args.push(source);
87
+ try {
88
+ const result = await runCommand("pandoc", args, {
89
+ ...options,
90
+ cwd: options.cwd ?? dirname(source),
91
+ });
92
+ if (result.code !== 0) throw commandError(result, "pandoc");
93
+ await inspectDocx(temporary);
94
+ await rename(temporary, output);
95
+ } finally {
96
+ await rm(temporary, { force: true });
97
+ }
98
+ }
package/src/pdf.ts ADDED
@@ -0,0 +1,154 @@
1
+ import { mkdir, mkdtemp, readdir, rename, rm, stat } from "node:fs/promises";
2
+ import { basename, dirname, extname, join, resolve } from "node:path";
3
+ import { type TruncationResult, truncateHead } from "@earendil-works/pi-coding-agent";
4
+ import { commandError, type RunCommandOptions, runCommand } from "./process.js";
5
+
6
+ export interface PageRange {
7
+ firstPage?: number;
8
+ lastPage?: number;
9
+ }
10
+
11
+ export interface PdfTextResult {
12
+ text: string;
13
+ truncation: TruncationResult;
14
+ }
15
+
16
+ export interface PdfSearchResult {
17
+ matchCount: number;
18
+ text: string;
19
+ }
20
+
21
+ const PDFGREP_MAX_PAGE = 2_147_483_647;
22
+
23
+ function pageArgs(range: PageRange): string[] {
24
+ const args: string[] = [];
25
+ if (range.firstPage !== undefined) args.push("-f", String(range.firstPage));
26
+ if (range.lastPage !== undefined) args.push("-l", String(range.lastPage));
27
+ return args;
28
+ }
29
+
30
+ function searchPageArgs(range: PageRange): string[] {
31
+ if (range.firstPage === undefined && range.lastPage === undefined) return [];
32
+ const first = range.firstPage ?? 1;
33
+ const last = range.lastPage ?? PDFGREP_MAX_PAGE;
34
+ return ["--page-range", first === last ? String(first) : `${first}-${last}`];
35
+ }
36
+
37
+ function commandOptions(options: RunCommandOptions | undefined, cwd: string): RunCommandOptions {
38
+ return { ...options, cwd: options?.cwd ?? cwd };
39
+ }
40
+
41
+ export async function readPdfText(
42
+ path: string,
43
+ range: PageRange = {},
44
+ options?: RunCommandOptions,
45
+ ): Promise<PdfTextResult> {
46
+ const source = resolve(path);
47
+ const result = await runCommand(
48
+ "pdftotext",
49
+ ["-layout", ...pageArgs(range), source, "-"],
50
+ commandOptions(options, dirname(source)),
51
+ );
52
+ if (result.code !== 0) throw commandError(result, "pdftotext");
53
+ const truncation = truncateHead(result.stdout.toString("utf8"));
54
+ return { text: truncation.content, truncation };
55
+ }
56
+
57
+ function escapeRegex(value: string): string {
58
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
59
+ }
60
+
61
+ function parseSearchOutput(output: string): string[] {
62
+ return output
63
+ .split(/\r?\n/)
64
+ .filter(Boolean)
65
+ .map((line) => {
66
+ const match = line.match(/:(\d+):(.*)$/);
67
+ if (!match) throw new Error(`Unexpected pdfgrep output: ${line}`);
68
+ return `page ${match[1]}: ${match[2] ?? ""}`;
69
+ });
70
+ }
71
+
72
+ export async function searchPdf(
73
+ path: string,
74
+ query: string,
75
+ options: RunCommandOptions & PageRange & { ignoreCase?: boolean; literal?: boolean } = {},
76
+ ): Promise<PdfSearchResult> {
77
+ const source = resolve(path);
78
+ const args = ["--color", "never", "--with-filename", "--page-number", ...searchPageArgs(options)];
79
+ if (options.ignoreCase) args.push("--ignore-case");
80
+ if (options.literal) args.push("--fixed-strings");
81
+ args.push("--", query, source);
82
+ const result = await runCommand("pdfgrep", args, commandOptions(options, dirname(source)));
83
+ if (result.code !== 0 && result.code !== 1) throw commandError(result, "pdfgrep");
84
+ const matches = parseSearchOutput(result.stdout.toString("utf8"));
85
+ return { matchCount: matches.length, text: matches.join("\n") };
86
+ }
87
+
88
+ async function outputPrefix(output: string, sourcePath: string): Promise<string> {
89
+ const absolute = resolve(output);
90
+ let isDirectory = output.endsWith("/") || output.endsWith("\\");
91
+ try {
92
+ isDirectory = (await stat(absolute)).isDirectory();
93
+ } catch {
94
+ // A not-yet-existing path is treated as a prefix unless it ends in a separator.
95
+ }
96
+ if (isDirectory) {
97
+ await mkdir(absolute, { recursive: true });
98
+ return join(absolute, basename(sourcePath, extname(sourcePath)));
99
+ }
100
+ await mkdir(dirname(absolute), { recursive: true });
101
+ return absolute.replace(/\.png$/i, "");
102
+ }
103
+
104
+ function generatedPage(name: string, prefixName: string): number | undefined {
105
+ if (name === `${prefixName}.png`) return 1;
106
+ const match = name.match(new RegExp(`^${escapeRegex(prefixName)}-(\\d+)\\.png$`));
107
+ return match ? Number(match[1]) : undefined;
108
+ }
109
+
110
+ async function generatedPages(directory: string, prefixName: string): Promise<string[]> {
111
+ return (await readdir(directory))
112
+ .map((name) => ({ name, page: generatedPage(name, prefixName) }))
113
+ .filter((entry): entry is { name: string; page: number } => entry.page !== undefined)
114
+ .sort((a, b) => a.page - b.page)
115
+ .map(({ name }) => name);
116
+ }
117
+
118
+ async function clearGenerated(directory: string, prefixName: string): Promise<void> {
119
+ for (const name of await generatedPages(directory, prefixName)) {
120
+ await rm(join(directory, name), { force: true });
121
+ }
122
+ }
123
+
124
+ export async function renderPdfPages(
125
+ path: string,
126
+ output: string,
127
+ range: PageRange = {},
128
+ options?: RunCommandOptions,
129
+ ): Promise<string[]> {
130
+ const source = resolve(path);
131
+ const prefix = await outputPrefix(output, source);
132
+ const directory = dirname(prefix);
133
+ const prefixName = basename(prefix);
134
+ const temporaryDirectory = await mkdtemp(join(directory, `.${prefixName}.`));
135
+ const temporaryPrefix = join(temporaryDirectory, prefixName);
136
+ try {
137
+ const result = await runCommand(
138
+ "pdftocairo",
139
+ ["-png", ...pageArgs(range), source, temporaryPrefix],
140
+ commandOptions(options, dirname(source)),
141
+ );
142
+ if (result.code !== 0) throw commandError(result, "pdftocairo");
143
+
144
+ const names = await generatedPages(temporaryDirectory, prefixName);
145
+ if (names.length === 0) throw new Error("pdftocairo produced no PNG files");
146
+ await clearGenerated(directory, prefixName);
147
+ for (const name of names) {
148
+ await rename(join(temporaryDirectory, name), join(directory, name));
149
+ }
150
+ return names.map((name) => join(directory, name));
151
+ } finally {
152
+ await rm(temporaryDirectory, { recursive: true, force: true });
153
+ }
154
+ }
package/src/process.ts ADDED
@@ -0,0 +1,97 @@
1
+ import { execFile } from "node:child_process";
2
+
3
+ export const DEFAULT_COMMAND_TIMEOUT_MS = 30_000;
4
+ export const DEFAULT_COMMAND_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
5
+
6
+ export interface CommandResult {
7
+ stdout: Buffer;
8
+ stderr: Buffer;
9
+ code: number;
10
+ }
11
+
12
+ export class MissingExecutableError extends Error {
13
+ constructor(public readonly executable: string) {
14
+ super(
15
+ `Required executable "${executable}" was not found. ` +
16
+ `Install it and make sure it is available on PATH.`,
17
+ );
18
+ this.name = "MissingExecutableError";
19
+ }
20
+ }
21
+
22
+ export interface RunCommandOptions {
23
+ cwd?: string;
24
+ env?: NodeJS.ProcessEnv;
25
+ signal?: AbortSignal;
26
+ timeoutMs?: number;
27
+ maxBufferBytes?: number;
28
+ }
29
+
30
+ type ExecFailure = Error & {
31
+ code?: string | number | null;
32
+ killed?: boolean;
33
+ };
34
+
35
+ export function runCommand(
36
+ executable: string,
37
+ args: readonly string[],
38
+ options: RunCommandOptions = {},
39
+ ): Promise<CommandResult> {
40
+ return new Promise((resolve, reject) => {
41
+ execFile(
42
+ executable,
43
+ [...args],
44
+ {
45
+ cwd: options.cwd,
46
+ env: options.env,
47
+ signal: options.signal,
48
+ timeout: options.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS,
49
+ maxBuffer: options.maxBufferBytes ?? DEFAULT_COMMAND_MAX_BUFFER_BYTES,
50
+ killSignal: "SIGKILL",
51
+ encoding: null,
52
+ },
53
+ (error, stdout, stderr) => {
54
+ if (!error) {
55
+ resolve({ stdout, stderr, code: 0 });
56
+ return;
57
+ }
58
+
59
+ const failure = error as ExecFailure;
60
+ if (failure.code === "ENOENT") {
61
+ reject(new MissingExecutableError(executable));
62
+ return;
63
+ }
64
+ if (failure.code === "ABORT_ERR") {
65
+ reject(new Error("Operation aborted"));
66
+ return;
67
+ }
68
+ if (failure.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") {
69
+ reject(
70
+ new Error(
71
+ `${executable} output exceeded ${options.maxBufferBytes ?? DEFAULT_COMMAND_MAX_BUFFER_BYTES} bytes`,
72
+ ),
73
+ );
74
+ return;
75
+ }
76
+ if (failure.killed) {
77
+ reject(
78
+ new Error(
79
+ `${executable} timed out after ${options.timeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS}ms`,
80
+ ),
81
+ );
82
+ return;
83
+ }
84
+ if (typeof failure.code === "number") {
85
+ resolve({ stdout, stderr, code: failure.code });
86
+ return;
87
+ }
88
+ reject(new Error(`Failed to run ${executable}: ${failure.message}`));
89
+ },
90
+ );
91
+ });
92
+ }
93
+
94
+ export function commandError(result: CommandResult, executable: string): Error {
95
+ const message = result.stderr.toString("utf8").trim();
96
+ return new Error(message || `${executable} exited with code ${result.code}`);
97
+ }