pi-filler 0.2.0 → 0.3.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/README.md CHANGED
@@ -10,11 +10,11 @@ version](https://img.shields.io/npm/v/pi-filler.svg)](https://www.npmjs.com/pack
10
10
  downloads](https://img.shields.io/npm/dt/pi-filler.svg)](https://www.npmjs.com/package/pi-filler)
11
11
  [![license](https://img.shields.io/npm/l/pi-filler.svg)](https://gitlab.com/Joao-O-Santos/pi-filler/-/blob/main/LICENSE)
12
12
 
13
- **Deterministic DOCX, PDF, and XLSX tooling for Pi.**
13
+ **Deterministic DOCX, PDF, PPTX, and XLSX tooling for Pi.**
14
14
 
15
15
  `pi-filler` covers document tasks that do not fit an ordinary Markdown
16
16
  workflow. It delegates conversion and PDF extraction to mature
17
- command-line tools while keeping DOCX and XLSX mutations narrow,
17
+ command-line tools while keeping DOCX, PPTX, and XLSX mutations narrow,
18
18
  transactional, and inspectable.
19
19
 
20
20
  The project is experimental and pre-1.0. The README documents the
@@ -45,15 +45,15 @@ Node.js and do not require an office suite.
45
45
  The extension exposes one `filler` tool:
46
46
 
47
47
  ``` text
48
- format: docx | pdf | xlsx
48
+ format: docx | pdf | pptx | xlsx
49
49
  action: read | search | write | patch
50
50
  view: text | formatting | image | structure
51
51
  ```
52
52
 
53
- `format` names the document or target format, not every input: DOCX
54
- writes take Markdown at `path`, and XLSX writes take a workbook template
55
- at `path` plus CSV at `source_csv`. Choose a supported combination and
56
- supply only its relevant fields:
53
+ `format` names the document or target format, not every input: DOCX and
54
+ PPTX writes take Markdown at `path`, and XLSX writes take a workbook
55
+ template at `path` plus CSV at `source_csv`. Choose a supported
56
+ combination and supply only its relevant fields:
57
57
 
58
58
  | Format | Action | View | Required additional fields |
59
59
  |---------------|---------------|---------------|---------------------------|
@@ -62,11 +62,16 @@ supply only its relevant fields:
62
62
  | DOCX | `search` | `text` or omitted | `query`; optional `ignore_case` |
63
63
  | DOCX | `write` | omitted | Markdown `path`, `output`; optional `reference_docx` |
64
64
  | DOCX | `patch` | omitted | `output`, `patches`; optional `dry_run` |
65
+ | PPTX | `read` | `text` or `formatting` | none |
66
+ | PPTX | `read` | `image` | `output`; optional `first_page`, `last_page` |
67
+ | PPTX | `search` | `text` or omitted | `query`; optional `ignore_case` |
68
+ | PPTX | `write` | omitted | Markdown `path`, `output`; optional `reference_pptx` |
69
+ | PPTX | `patch` | omitted | `output`, `pptx_patches`; optional `dry_run` |
65
70
  | PDF | `read` | `text` | optional `first_page`, `last_page` |
66
71
  | PDF | `read` | `image` | `output`; optional `first_page`, `last_page` |
67
72
  | PDF | `search` | `text` or omitted | `query`; optional `first_page`, `last_page`, `literal`, `ignore_case` |
68
73
  | XLSX | `read` | `structure` | none |
69
- | XLSX | `read` | `image` | `output`; optional `first_page`, `last_page`; all workbook print pages |
74
+ | XLSX | `read` | `image` | `output`; optional `first_page`, `last_page` select resulting workbook print pages |
70
75
  | XLSX | `write` | omitted | template `path`, `source_csv`, `sheet`, A1 `start_cell`, `output`; optional `column_map` |
71
76
  | XLSX | `patch` | omitted | `sheet`, A1 `range`, `xlsx_patches`, `output` |
72
77
 
@@ -115,8 +120,9 @@ filler({
115
120
  ```
116
121
 
117
122
  Results may be truncated. For a truncated PDF text read, use a page
118
- range; for PDF or DOCX search, narrow the query. PDF search can also use
119
- a page range. Dry runs still read and validate all local inputs.
123
+ range; for PDF, DOCX, or PPTX search, narrow the query. PDF search can
124
+ also use a page range. Dry runs still read and validate all local
125
+ inputs.
120
126
 
121
127
  ## PDF operations
122
128
 
@@ -156,17 +162,20 @@ or element ordering may change.
156
162
 
157
163
  ## XLSX operations and privacy
158
164
 
159
- Spreadsheet cell values, CSV fields, formulas, comments, and other
160
- workbook text are processed locally and are not returned to the model.
161
- Structural results contain sheet names, ranges, dimensions, counts,
165
+ Spreadsheet cell values, CSV fields, formula contents, and comments are
166
+ processed locally and are not returned to the model. When a
167
+ deterministic XLSX write or patch does not require inspection, do not
168
+ read CSV or cell contents into model context merely to perform it.
169
+ Structural results may contain sheet names, ranges, dimensions, counts,
162
170
  merged-range addresses, hidden row and column ranges, changed package
163
- parts, and write status---not workbook or CSV contents. Layout-range
164
- arrays are capped and report whether they were truncated; this metadata
165
- can reveal limited layout and occupancy information.
171
+ parts, and write status---not cell or CSV contents. Layout-range arrays
172
+ are capped and report whether they were truncated; this metadata can
173
+ reveal limited layout and occupancy information.
166
174
 
167
175
  - `read` with `view: "structure"` reports workbook and worksheet shape.
168
- - `read` with `view: "image"` renders all workbook print pages through
176
+ - `read` with `view: "image"` renders workbook print pages through
169
177
  LibreOffice to numbered PNG files; it does not select a sheet.
178
+ `first_page` and `last_page` select resulting print pages.
170
179
  - `write` fills an existing workbook template from CSV.
171
180
  - `patch` changes formatting or layout while preserving cell contents.
172
181
 
@@ -189,12 +198,31 @@ calculation or generation, spreadsheet scripting, or manipulation of
189
198
  charts, pivots, macros, comments, named ranges, tables, data validation,
190
199
  conditional formatting, or external data.
191
200
 
201
+ ## PPTX operations
202
+
203
+ - Text reads and search convert PPTX through Pandoc to GFM Markdown.
204
+ - Writes convert GFM Markdown into a validated PPTX; an optional
205
+ reference PPTX supplies Pandoc presentation styles.
206
+ - Formatting reads report slide dimensions, slide count, and common core
207
+ metadata.
208
+ - Patches change slide dimensions, common core metadata, and targeted
209
+ text in slide text paragraphs. Text replacements may target one
210
+ 1-based slide or all slides. They can span formatted text runs, with
211
+ replacement text inheriting the first run's formatting.
212
+ - PPTX-to-Markdown extraction is semantic and lossy: it is not a
213
+ reversible representation of positions, animations, themes, or all
214
+ presentation objects.
215
+
216
+ Slide dimensions use EMUs (English Metric Units); 914,400 EMUs equal one
217
+ inch. PPTX image reads convert slides through LibreOffice to PDF and
218
+ render numbered PNG files; `first_page` and `last_page` select slides.
219
+
192
220
  ## Output and mutation behavior
193
221
 
194
- DOCX and XLSX writes and patches use temporary sibling files, validate
195
- the result, and rename it into place only after success. Source files
196
- are not modified. PDF image rendering similarly protects prior output
197
- until the new render succeeds.
222
+ DOCX, PPTX, and XLSX writes and patches use temporary sibling files,
223
+ validate the result, and rename it into place only after success. Source
224
+ files are not modified. PDF and office image rendering similarly
225
+ protects prior output until the new render succeeds.
198
226
 
199
227
  `dry_run` validates the requested operation and inputs without creating
200
228
  or replacing output; its required `output` path is still validated.
@@ -203,10 +231,10 @@ or replacing output; its required `output` path is still validated.
203
231
 
204
232
  Required for the corresponding operations:
205
233
 
206
- - `pandoc` for DOCX text conversion, search, and generation;
207
- - `libreoffice` for DOCX and XLSX image rendering;
234
+ - `pandoc` for DOCX/PPTX text conversion, search, and generation;
235
+ - `libreoffice` for DOCX, PPTX, and XLSX image rendering;
208
236
  - `pdftotext` for PDF text extraction;
209
- - `pdftocairo` for PDF, DOCX, and XLSX page rendering; and
237
+ - `pdftocairo` for PDF, DOCX, PPTX, and XLSX page rendering; and
210
238
  - `pdfgrep` for page-aware PDF search.
211
239
 
212
240
  A missing executable produces an explicit error naming the unavailable
@@ -221,8 +249,9 @@ make site
221
249
  ```
222
250
 
223
251
  `make verify` runs typechecking, lint and formatting checks,
224
- deterministic tests, and `npm pack --dry-run`. `make site` regenerates
225
- the tracked static site after documentation changes.
252
+ deterministic tests, project-wide line/function coverage checks, and
253
+ `npm pack --dry-run`. `make site` regenerates the tracked static site
254
+ after documentation changes.
226
255
 
227
256
  Dependencies and CI actions follow current upstream releases rather than
228
257
  being pinned. GitLab is canonical and the only release authority. GitHub
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "pi-filler",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Deterministic DOCX, PDF, and XLSX tooling for Pi",
5
5
  "keywords": [
6
6
  "pi-package",
7
7
  "pi-extension",
8
8
  "docx",
9
+ "pptx",
9
10
  "pdf",
10
11
  "xlsx",
11
12
  "pandoc",
@@ -32,6 +33,7 @@
32
33
  ],
33
34
  "scripts": {
34
35
  "test": "node --import tsx --test test/*.test.ts",
36
+ "test:coverage": "node --import tsx --test --experimental-test-coverage --test-coverage-include=src/**/*.ts --test-coverage-lines=90 --test-coverage-functions=90 test/*.test.ts",
35
37
  "typecheck": "tsc --noEmit",
36
38
  "lint": "biome lint src test scripts",
37
39
  "format:check": "biome ci --linter-enabled=false --assist-enabled=false src test scripts biome.json package.json tsconfig.json",
@@ -39,7 +41,7 @@
39
41
  "markdown:check": "node scripts/format-markdown.mjs",
40
42
  "markdown:fix": "node scripts/format-markdown.mjs --write",
41
43
  "style": "npm run lint && npm run format:check && npm run markdown:check",
42
- "check": "npm run typecheck && npm run style && npm test",
44
+ "check": "npm run typecheck && npm run style && npm test && npm run test:coverage",
43
45
  "site": "make site"
44
46
  },
45
47
  "pi": {
package/src/index.ts CHANGED
@@ -10,8 +10,16 @@ import {
10
10
  } from "@earendil-works/pi-coding-agent";
11
11
  import { type Static, Type } from "typebox";
12
12
  import { type DocxPatchSet, inspectDocx, patchDocx } from "./docx.js";
13
- import { readDocxText, searchDocx, writeDocx } from "./pandoc.js";
13
+ import {
14
+ readDocxText,
15
+ readPptxText,
16
+ searchDocx,
17
+ searchPptx,
18
+ writeDocx,
19
+ writePptx,
20
+ } from "./pandoc.js";
14
21
  import { readPdfText, renderPdfPages, searchPdf } from "./pdf.js";
22
+ import { inspectPptx, type PptxPatchSet, patchPptx } from "./pptx.js";
15
23
  import { commandError, runCommand } from "./process.js";
16
24
  import { fillXlsxFromCsv, inspectXlsxStructure, patchXlsx, type XlsxPatchSet } from "./xlsx.js";
17
25
 
@@ -115,6 +123,70 @@ const docxPatchesSchema = Type.Object(
115
123
  },
116
124
  );
117
125
 
126
+ const pptxPatchesSchema = Type.Object(
127
+ {
128
+ slideSize: Type.Optional(
129
+ Type.Object(
130
+ {
131
+ width: Type.Optional(Type.Integer({ description: "Slide width in EMUs", minimum: 1 })),
132
+ height: Type.Optional(Type.Integer({ description: "Slide height in EMUs", minimum: 1 })),
133
+ },
134
+ {
135
+ additionalProperties: false,
136
+ description: "Presentation slide size in EMUs",
137
+ minProperties: 1,
138
+ },
139
+ ),
140
+ ),
141
+ metadata: Type.Optional(
142
+ Type.Object(
143
+ {
144
+ title: Type.Optional(Type.String()),
145
+ subject: Type.Optional(Type.String()),
146
+ creator: Type.Optional(Type.String()),
147
+ keywords: Type.Optional(Type.String()),
148
+ description: Type.Optional(Type.String()),
149
+ lastModifiedBy: Type.Optional(Type.String()),
150
+ },
151
+ {
152
+ additionalProperties: false,
153
+ description: "Common docProps/core.xml metadata fields",
154
+ minProperties: 1,
155
+ },
156
+ ),
157
+ ),
158
+ replaceText: Type.Optional(
159
+ Type.Array(
160
+ Type.Object(
161
+ {
162
+ find: Type.String({
163
+ description: "Text to find within a slide text paragraph",
164
+ minLength: 1,
165
+ }),
166
+ replace: Type.String({ description: "Replacement text; may be empty" }),
167
+ slide: Type.Optional(
168
+ Type.Integer({
169
+ description: "1-based slide number; omit for all slides",
170
+ minimum: 1,
171
+ }),
172
+ ),
173
+ },
174
+ { additionalProperties: false, minProperties: 2 },
175
+ ),
176
+ { minItems: 1, description: "Targeted text replacements in slide text" },
177
+ ),
178
+ ),
179
+ clearCoreMetadata: Type.Optional(
180
+ Type.Boolean({ description: "Clear common core metadata fields" }),
181
+ ),
182
+ },
183
+ {
184
+ additionalProperties: false,
185
+ description: "PPTX slide, metadata, and targeted text changes for action=patch",
186
+ minProperties: 1,
187
+ },
188
+ );
189
+
118
190
  const xlsxPatchesSchema = Type.Object(
119
191
  {
120
192
  font: Type.Optional(
@@ -174,7 +246,7 @@ const xlsxPatchesSchema = Type.Object(
174
246
 
175
247
  export const fillerSchema = Type.Object(
176
248
  {
177
- format: StringEnum(["docx", "pdf", "xlsx"] as const, {
249
+ format: StringEnum(["docx", "pdf", "pptx", "xlsx"] as const, {
178
250
  description: "Document or target format; selects supported operations and path roles",
179
251
  }),
180
252
  action: StringEnum(["read", "search", "write", "patch"] as const, {
@@ -187,7 +259,7 @@ export const fillerSchema = Type.Object(
187
259
  ),
188
260
  path: Type.String({
189
261
  description:
190
- "Input path: document for read/search/patch, Markdown source for DOCX write, or template for XLSX write",
262
+ "Input path: document for read/search/patch, Markdown source for DOCX/PPTX write, or template for XLSX write",
191
263
  minLength: 1,
192
264
  }),
193
265
  output: Type.Optional(
@@ -197,11 +269,17 @@ export const fillerSchema = Type.Object(
197
269
  minLength: 1,
198
270
  }),
199
271
  ),
200
- query: Type.Optional(Type.String({ description: "DOCX or PDF search query", minLength: 1 })),
272
+ query: Type.Optional(
273
+ Type.String({ description: "DOCX, PDF, or PPTX search query", minLength: 1 }),
274
+ ),
201
275
  reference_docx: Type.Optional(
202
276
  Type.String({ description: "Reference DOCX for writes", minLength: 1 }),
203
277
  ),
204
278
  patches: Type.Optional(docxPatchesSchema),
279
+ pptx_patches: Type.Optional(pptxPatchesSchema),
280
+ reference_pptx: Type.Optional(
281
+ Type.String({ description: "Reference PPTX for writes", minLength: 1 }),
282
+ ),
205
283
  source_csv: Type.Optional(
206
284
  Type.String({ description: "CSV source path for XLSX write", minLength: 1 }),
207
285
  ),
@@ -242,7 +320,7 @@ export const fillerSchema = Type.Object(
242
320
  Type.Boolean({
243
321
  default: false,
244
322
  description:
245
- "Validate and report without creating or replacing output; output remains required for DOCX patch or XLSX write/patch",
323
+ "Validate and report without creating or replacing output; output remains required for DOCX/PPTX patch or XLSX write/patch",
246
324
  }),
247
325
  ),
248
326
  literal: Type.Optional(
@@ -257,14 +335,14 @@ export const fillerSchema = Type.Object(
257
335
  first_page: Type.Optional(
258
336
  Type.Integer({
259
337
  description:
260
- "First 1-based inclusive page for PDF read/search or DOCX/PDF image read; must not exceed last_page",
338
+ "First 1-based inclusive page for PDF read/search or DOCX/PPTX/XLSX image read; must not exceed last_page",
261
339
  minimum: 1,
262
340
  }),
263
341
  ),
264
342
  last_page: Type.Optional(
265
343
  Type.Integer({
266
344
  description:
267
- "Last 1-based inclusive page for PDF read/search or DOCX/PDF image read; must not precede first_page",
345
+ "Last 1-based inclusive page for PDF read/search or DOCX/PDF/XLSX image read; must not precede first_page",
268
346
  minimum: 1,
269
347
  }),
270
348
  ),
@@ -280,6 +358,8 @@ const controlFields: ControlField[] = [
280
358
  "output",
281
359
  "query",
282
360
  "reference_docx",
361
+ "pptx_patches",
362
+ "reference_pptx",
283
363
  "patches",
284
364
  "source_csv",
285
365
  "sheet",
@@ -364,6 +444,42 @@ function assertSupported(input: FillerInput): void {
364
444
  return;
365
445
  }
366
446
 
447
+ if (input.format === "pptx") {
448
+ if (input.action === "read") {
449
+ if (input.view === "image") {
450
+ if (!input.output) throw new Error("PPTX image reads require output");
451
+ assertParameters(input, ["output", "first_page", "last_page"]);
452
+ return;
453
+ }
454
+ if (input.view !== "text" && input.view !== "formatting") {
455
+ throw new Error("PPTX read requires view=text, view=formatting, or view=image");
456
+ }
457
+ assertParameters(input, []);
458
+ return;
459
+ }
460
+ if (input.action === "search") {
461
+ if (input.view !== undefined && input.view !== "text") {
462
+ throw new Error("PPTX search requires view=text or no view");
463
+ }
464
+ if (!input.query) throw new Error("PPTX search requires query");
465
+ assertParameters(input, ["query", "ignore_case"]);
466
+ return;
467
+ }
468
+ if (input.action === "write") {
469
+ if (input.view !== undefined || !input.output) {
470
+ throw new Error("PPTX write requires output and does not accept view");
471
+ }
472
+ assertParameters(input, ["output", "reference_pptx"]);
473
+ return;
474
+ }
475
+ if (input.view !== undefined || !input.output) {
476
+ throw new Error("PPTX patch requires output and does not accept view");
477
+ }
478
+ if (input.pptx_patches === undefined) throw new Error("PPTX patch requires pptx_patches");
479
+ assertParameters(input, ["output", "pptx_patches", "dry_run"]);
480
+ return;
481
+ }
482
+
367
483
  if (input.format === "xlsx") {
368
484
  if (input.action === "read") {
369
485
  if (input.view === "image") {
@@ -502,6 +618,69 @@ export async function executeFiller(
502
618
  return { text: `Wrote ${output}`, details: { output } };
503
619
  }
504
620
 
621
+ if (input.format === "pptx") {
622
+ if (input.action === "read" && input.view === "formatting") {
623
+ const formatting = await inspectPptx(path);
624
+ return { text: JSON.stringify(formatting, null, 2), details: { formatting } };
625
+ }
626
+ if (input.action === "read" && input.view === "image") {
627
+ const output = normalizePath(input.output ?? "", cwd);
628
+ const temporary = await mkdtemp(join(tmpdir(), "pi-filler-pptx-"));
629
+ try {
630
+ const converted = await runCommand(
631
+ "libreoffice",
632
+ ["--headless", "--convert-to", "pdf", "--outdir", temporary, path],
633
+ { signal, cwd: temporary },
634
+ );
635
+ if (converted.code !== 0) throw commandError(converted, "libreoffice");
636
+ const pdf = join(temporary, `${basename(path, extname(path))}.pdf`);
637
+ const result = await withFileMutationQueue(output, () =>
638
+ renderPdfPages(pdf, output, range, { signal }),
639
+ );
640
+ return {
641
+ text: `Rendered ${result.length} slide image${result.length === 1 ? "" : "s"}:\n${result.join("\n")}`,
642
+ details: { files: result },
643
+ };
644
+ } finally {
645
+ await rm(temporary, { recursive: true, force: true });
646
+ }
647
+ }
648
+ if (input.action === "read") {
649
+ const result = await readPptxText(path, { signal });
650
+ return { text: result.text, details: { truncation: result.truncation } };
651
+ }
652
+ if (input.action === "search") {
653
+ const result = await searchPptx(path, input.query ?? "", {
654
+ ignoreCase: input.ignore_case,
655
+ signal,
656
+ });
657
+ return {
658
+ text: result.text || "No matches found",
659
+ details: { matchCount: result.matchCount, truncation: result.truncation },
660
+ };
661
+ }
662
+ if (input.action === "patch") {
663
+ const output = normalizePath(input.output ?? "", cwd);
664
+ const result = await withFileMutationQueue(output, () =>
665
+ patchPptx(path, output, input.pptx_patches as PptxPatchSet, input.dry_run),
666
+ );
667
+ return {
668
+ text: JSON.stringify(result, null, 2),
669
+ details: result as unknown as Record<string, unknown>,
670
+ };
671
+ }
672
+
673
+ const output = normalizePath(input.output ?? "", cwd);
674
+ if (output === path) throw new Error("PPTX write output must differ from input");
675
+ const referencePptx = input.reference_pptx
676
+ ? normalizePath(input.reference_pptx, cwd)
677
+ : undefined;
678
+ await withFileMutationQueue(output, () =>
679
+ writePptx(path, output, { referencePptx, signal, cwd: dirname(path) }),
680
+ );
681
+ return { text: `Wrote ${output}`, details: { output } };
682
+ }
683
+
505
684
  if (input.format === "xlsx") {
506
685
  if (input.action === "read") {
507
686
  if (input.view === "image") {
@@ -605,15 +784,19 @@ export default function extension(pi: ExtensionAPI): void {
605
784
  name: "filler",
606
785
  label: "Filler",
607
786
  description:
608
- "Work directly with local DOCX, PDF, and XLSX files. Read, search, or render DOCX and PDF; write DOCX from Markdown; inspect or render XLSX, fill XLSX templates from CSV, and patch supported formatting.",
787
+ "Work directly with local DOCX, PDF, PPTX, and XLSX files. Read, search, or render DOCX/PPTX and PDF; write DOCX/PPTX from Markdown; inspect or render XLSX, fill XLSX templates from CSV, and patch supported formatting.",
609
788
  promptSnippet:
610
- "Work directly with local DOCX, PDF, and XLSX files using supported format/action/view combinations.",
789
+ "Work directly with local DOCX, PDF, PPTX, and XLSX files using supported format/action/view combinations.",
611
790
  promptGuidelines: [
612
- "Use filler directly for local DOCX, PDF, or XLSX work; choose a supported format/action/view combination and provide only that operation's fields.",
613
- "For filler read, use DOCX text, formatting, or image; PDF text or image; or XLSX structure or image. XLSX images render all workbook print pages through LibreOffice and do not accept sheet. For filler search, use DOCX or PDF text (or omit view); XLSX search is unsupported. Image output is an existing directory or PNG prefix that creates page images, not one final PNG.",
614
- "For filler DOCX write, path is Markdown and output is the new DOCX; reference_docx is optional. For filler XLSX write, path is the template and source_csv, sheet, A1-style start_cell, and output are required; column_map optionally maps all CSV ordinals or all exact headers to XLSX columns. XLSX processing stays local and results do not return cell or CSV contents.",
615
- "For filler patch, provide a new output path plus DOCX patches, or XLSX sheet, A1-style range, and xlsx_patches. dry_run: true still requires output but does not create or replace it. PDF queries are regular expressions by default; use literal: true for literal text. XLSX writes skip a header and preserve text by default.",
616
- "For filler results, DOCX search may be truncated; narrow its query. PDF text or search may be truncated; narrow its page range or query.",
791
+ "Use filler when a supported deterministic local DOCX, PDF, PPTX, or XLSX operation satisfies the task; choose a supported format/action/view combination and provide only that operation's fields.",
792
+ "For filler reads, use DOCX or PPTX text, formatting, or image; PDF text or image; or XLSX structure or image.",
793
+ "For filler searches, use DOCX, PPTX, or PDF text (or omit view); XLSX search is unsupported.",
794
+ "For filler image output, use an existing directory or PNG prefix; the operation creates numbered page/slide images, not one final PNG. DOCX, PPTX, and XLSX images convert through LibreOffice; XLSX images render workbook print pages and do not accept sheet. first_page and last_page select resulting pages.",
795
+ "For DOCX/PPTX writes, path is Markdown and output is the new document; reference_docx/reference_pptx is optional. PPTX uses GFM Markdown. For XLSX writes, path is the template and source_csv, sheet, A1-style start_cell, and output are required; column_map optionally maps all CSV ordinals or all exact headers to XLSX columns.",
796
+ "When an XLSX write or patch can perform the requested deterministic transformation without inspecting CSV or cell contents, do not read those contents into model context merely to carry out the transformation. Processing stays local and results do not return cell or CSV contents.",
797
+ "For filler patches, provide a new output path plus DOCX patches, PPTX pptx_patches, or XLSX sheet, A1-style range, and xlsx_patches. PPTX text replacements target slide text paragraphs and may name a 1-based slide. dry_run: true still requires output but does not create or replace it.",
798
+ "For filler defaults, PDF queries are regular expressions by default; use literal: true for literal text. XLSX writes skip a header and preserve text by default.",
799
+ "For filler results, DOCX/PPTX search may be truncated; narrow its query. PDF text or search may be truncated; narrow its page range or query.",
617
800
  ],
618
801
  parameters: fillerSchema,
619
802
  async execute(_toolCallId, input, signal, _onUpdate, ctx: ExtensionContext) {
package/src/pandoc.ts CHANGED
@@ -3,6 +3,7 @@ import { mkdir, rename, rm } from "node:fs/promises";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { type TruncationResult, truncateHead } from "@earendil-works/pi-coding-agent";
5
5
  import { inspectDocx } from "./docx.js";
6
+ import { inspectPptx } from "./pptx.js";
6
7
  import { commandError, type RunCommandOptions, runCommand } from "./process.js";
7
8
 
8
9
  export interface DocxTextResult {
@@ -30,16 +31,28 @@ async function runPandoc(
30
31
  return result.stdout;
31
32
  }
32
33
 
33
- async function extractDocxMarkdown(path: string, options?: RunCommandOptions): Promise<string> {
34
+ async function extractMarkdown(
35
+ path: string,
36
+ from: "docx" | "pptx",
37
+ options?: RunCommandOptions,
38
+ ): Promise<string> {
34
39
  const source = resolve(path);
35
40
  const output = await runPandoc(
36
- ["--from=docx", "--to=gfm", "--wrap=none", source],
41
+ [`--from=${from}`, "--to=gfm", "--wrap=none", source],
37
42
  options,
38
43
  source,
39
44
  );
40
45
  return output.toString("utf8");
41
46
  }
42
47
 
48
+ async function extractDocxMarkdown(path: string, options?: RunCommandOptions): Promise<string> {
49
+ return extractMarkdown(path, "docx", options);
50
+ }
51
+
52
+ async function extractPptxMarkdown(path: string, options?: RunCommandOptions): Promise<string> {
53
+ return extractMarkdown(path, "pptx", options);
54
+ }
55
+
43
56
  export async function readDocxText(
44
57
  path: string,
45
58
  options?: RunCommandOptions,
@@ -48,26 +61,49 @@ export async function readDocxText(
48
61
  return { text: truncation.content, truncation };
49
62
  }
50
63
 
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;
64
+ function searchMarkdown(extracted: string, query: string, ignoreCase = false): DocxSearchResult {
65
+ const needle = ignoreCase ? query.toLocaleLowerCase() : query;
58
66
  const matches: string[] = [];
59
67
  for (const [index, text] of extracted.split(/\r?\n/).entries()) {
60
- const haystack = options.ignoreCase ? text.toLocaleLowerCase() : text;
68
+ const haystack = ignoreCase ? text.toLocaleLowerCase() : text;
61
69
  if (haystack.includes(needle)) matches.push(`line ${index + 1}: ${text}`);
62
70
  }
63
71
  const truncation = truncateHead(matches.join("\n"));
64
72
  return { matchCount: matches.length, text: truncation.content, truncation };
65
73
  }
66
74
 
75
+ export async function searchDocx(
76
+ path: string,
77
+ query: string,
78
+ options: RunCommandOptions & { ignoreCase?: boolean } = {},
79
+ ): Promise<DocxSearchResult> {
80
+ return searchMarkdown(await extractDocxMarkdown(path, options), query, options.ignoreCase);
81
+ }
82
+
83
+ export async function readPptxText(
84
+ path: string,
85
+ options?: RunCommandOptions,
86
+ ): Promise<DocxTextResult> {
87
+ const truncation = truncateHead(await extractPptxMarkdown(path, options));
88
+ return { text: truncation.content, truncation };
89
+ }
90
+
91
+ export async function searchPptx(
92
+ path: string,
93
+ query: string,
94
+ options: RunCommandOptions & { ignoreCase?: boolean } = {},
95
+ ): Promise<DocxSearchResult> {
96
+ return searchMarkdown(await extractPptxMarkdown(path, options), query, options.ignoreCase);
97
+ }
98
+
67
99
  export interface WriteDocxOptions extends RunCommandOptions {
68
100
  referenceDocx?: string;
69
101
  }
70
102
 
103
+ export interface WritePptxOptions extends RunCommandOptions {
104
+ referencePptx?: string;
105
+ }
106
+
71
107
  export async function writeDocx(
72
108
  sourcePath: string,
73
109
  outputPath: string,
@@ -96,3 +132,32 @@ export async function writeDocx(
96
132
  await rm(temporary, { force: true });
97
133
  }
98
134
  }
135
+
136
+ export async function writePptx(
137
+ sourcePath: string,
138
+ outputPath: string,
139
+ options: WritePptxOptions = {},
140
+ ): Promise<void> {
141
+ const source = resolve(sourcePath);
142
+ const output = resolve(outputPath);
143
+ if (source === output) throw new Error("PPTX write output must differ from input");
144
+ const temporary = join(
145
+ dirname(output),
146
+ `.${output.split(/[\\/]/).pop() ?? "output.pptx"}.${randomUUID()}.tmp`,
147
+ );
148
+ await mkdir(dirname(output), { recursive: true });
149
+ const args = ["--from=gfm", "--to=pptx", "--output", temporary];
150
+ if (options.referencePptx) args.push("--reference-doc", resolve(options.referencePptx));
151
+ args.push(source);
152
+ try {
153
+ const result = await runCommand("pandoc", args, {
154
+ ...options,
155
+ cwd: options.cwd ?? dirname(source),
156
+ });
157
+ if (result.code !== 0) throw commandError(result, "pandoc");
158
+ await inspectPptx(temporary);
159
+ await rename(temporary, output);
160
+ } finally {
161
+ await rm(temporary, { force: true });
162
+ }
163
+ }
package/src/pptx.ts ADDED
@@ -0,0 +1,511 @@
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 { type OfficePackage, packOfficePackage, unpackOfficePackage } from "./ooxml.js";
6
+
7
+ export interface PptxPatchSet {
8
+ slideSize?: { width?: number; height?: number };
9
+ metadata?: {
10
+ title?: string;
11
+ subject?: string;
12
+ creator?: string;
13
+ keywords?: string;
14
+ description?: string;
15
+ lastModifiedBy?: string;
16
+ };
17
+ replaceText?: Array<{ find: string; replace: string; slide?: number }>;
18
+ clearCoreMetadata?: boolean;
19
+ }
20
+
21
+ export interface PptxFormatting {
22
+ slideSize: { width?: number; height?: number };
23
+ slideCount: number;
24
+ metadata: Record<string, string>;
25
+ }
26
+
27
+ export interface PptxPatchResult {
28
+ formatting: PptxFormatting;
29
+ replacementCount: number;
30
+ changedParts: string[];
31
+ preservedParts: string[];
32
+ written: boolean;
33
+ }
34
+
35
+ type Package = OfficePackage;
36
+ type XmlObject = Record<string, unknown>;
37
+
38
+ type TextNode = { parent: XmlObject; key: string; text: string };
39
+
40
+ const PRESENTATION_NS = "http://schemas.openxmlformats.org/presentationml/2006/main";
41
+ const DRAWING_NS = "http://schemas.openxmlformats.org/drawingml/2006/main";
42
+ const CORE_NS = "http://schemas.openxmlformats.org/package/2006/metadata/core-properties";
43
+ const DC_NS = "http://purl.org/dc/elements/1.1/";
44
+ const metadataNamespaces: Record<string, string> = {
45
+ title: DC_NS,
46
+ subject: DC_NS,
47
+ creator: DC_NS,
48
+ keywords: CORE_NS,
49
+ description: DC_NS,
50
+ lastModifiedBy: CORE_NS,
51
+ };
52
+
53
+ const parser = new XMLParser({
54
+ ignoreAttributes: false,
55
+ attributeNamePrefix: "@_",
56
+ trimValues: false,
57
+ });
58
+ const builder = new XMLBuilder({
59
+ ignoreAttributes: false,
60
+ attributeNamePrefix: "@_",
61
+ format: false,
62
+ });
63
+
64
+ function isObject(value: unknown): value is XmlObject {
65
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
66
+ }
67
+
68
+ function localName(name: string): string {
69
+ return name.replace(/^@_/, "").split(":").at(-1) ?? name;
70
+ }
71
+
72
+ function parseXml(bytes: Uint8Array | undefined, part: string): XmlObject {
73
+ if (!bytes) throw new Error(`PPTX is missing required part ${part}`);
74
+ try {
75
+ return parser.parse(new TextDecoder().decode(bytes), true) as XmlObject;
76
+ } catch (error) {
77
+ throw new Error(`Invalid XML in ${part}: ${error instanceof Error ? error.message : error}`);
78
+ }
79
+ }
80
+
81
+ function serializeXml(value: XmlObject): Uint8Array {
82
+ return new TextEncoder().encode(builder.build(value));
83
+ }
84
+
85
+ function findAll(value: unknown, suffix: string, found: XmlObject[] = []): XmlObject[] {
86
+ if (!value || typeof value !== "object") return found;
87
+ for (const [key, child] of Object.entries(value)) {
88
+ if (!key.startsWith("@_") && localName(key) === suffix) {
89
+ for (const item of Array.isArray(child) ? child : [child]) {
90
+ if (isObject(item)) found.push(item);
91
+ }
92
+ }
93
+ findAll(child, suffix, found);
94
+ }
95
+ return found;
96
+ }
97
+
98
+ function findPrefixed(
99
+ value: unknown,
100
+ prefix: string,
101
+ suffix: string,
102
+ found: XmlObject[] = [],
103
+ ): XmlObject[] {
104
+ if (!value || typeof value !== "object") return found;
105
+ for (const [key, child] of Object.entries(value)) {
106
+ if (!key.startsWith("@_") && key === `${prefix}:${suffix}`) {
107
+ for (const item of Array.isArray(child) ? child : [child]) {
108
+ if (isObject(item)) found.push(item);
109
+ }
110
+ }
111
+ findPrefixed(child, prefix, suffix, found);
112
+ }
113
+ return found;
114
+ }
115
+
116
+ function directObject(value: XmlObject | undefined, suffix: string): XmlObject | undefined {
117
+ if (!value) return undefined;
118
+ for (const [key, child] of Object.entries(value)) {
119
+ if (key.startsWith("@_") || localName(key) !== suffix) continue;
120
+ const candidate = Array.isArray(child) ? child[0] : child;
121
+ return isObject(candidate) ? candidate : undefined;
122
+ }
123
+ return undefined;
124
+ }
125
+
126
+ function rootObject(value: XmlObject, suffix: string): XmlObject {
127
+ const root = directObject(value, suffix);
128
+ if (!root) throw new Error(`PPTX XML is missing ${suffix}`);
129
+ return root;
130
+ }
131
+
132
+ function attr(value: XmlObject | undefined, name: string): string | undefined {
133
+ if (!value) return undefined;
134
+ for (const [key, child] of Object.entries(value)) {
135
+ if (key.startsWith("@_") && localName(key) === name && child !== undefined)
136
+ return String(child);
137
+ }
138
+ return undefined;
139
+ }
140
+
141
+ function numberAttr(value: XmlObject | undefined, name: string): number | undefined {
142
+ const raw = attr(value, name);
143
+ return raw === undefined ? undefined : Number(raw);
144
+ }
145
+
146
+ function existingNamespacePrefix(root: XmlObject, uri: string): string | undefined {
147
+ for (const [key, value] of Object.entries(root)) {
148
+ if (key === "@_xmlns" && value === uri) return "";
149
+ if (key.startsWith("@_xmlns:") && value === uri) return key.slice("@_xmlns:".length);
150
+ }
151
+ return undefined;
152
+ }
153
+
154
+ function namespacePrefix(root: XmlObject, uri: string, preferred: string): string {
155
+ return (
156
+ existingNamespacePrefix(root, uri) ??
157
+ (() => {
158
+ root[`@_xmlns:${preferred}`] = uri;
159
+ return preferred;
160
+ })()
161
+ );
162
+ }
163
+
164
+ function setAttr(value: XmlObject, name: string, setting: number | string, prefix?: string): void {
165
+ const existing = Object.keys(value).find(
166
+ (key) => key.startsWith("@_") && localName(key) === name,
167
+ );
168
+ value[existing ?? `@_${prefix ? `${prefix}:` : ""}${name}`] = String(setting);
169
+ }
170
+
171
+ function childText(value: XmlObject, name: string): string | undefined {
172
+ for (const [key, child] of Object.entries(value)) {
173
+ if (key.startsWith("@_") || localName(key) !== name) continue;
174
+ if (typeof child === "string" || typeof child === "number") return String(child);
175
+ if (isObject(child)) {
176
+ const text = child["#text"];
177
+ if (typeof text === "string" || typeof text === "number") return String(text);
178
+ }
179
+ }
180
+ return undefined;
181
+ }
182
+
183
+ function setChildText(value: XmlObject, name: string, text: string): void {
184
+ const existing = Object.keys(value).find(
185
+ (key) => !key.startsWith("@_") && localName(key) === name,
186
+ );
187
+ if (existing) {
188
+ const child = value[existing];
189
+ if (isObject(child)) child["#text"] = text;
190
+ else value[existing] = text;
191
+ return;
192
+ }
193
+ const uri = metadataNamespaces[name] ?? DC_NS;
194
+ const prefix = namespacePrefix(value, uri, uri === CORE_NS ? "cp" : "dc");
195
+ value[`${prefix ? `${prefix}:` : ""}${name}`] = text;
196
+ }
197
+
198
+ function coreMetadata(pkg: Package): Record<string, string> {
199
+ const bytes = pkg["docProps/core.xml"];
200
+ if (!bytes) return {};
201
+ const props = rootObject(parseXml(bytes, "docProps/core.xml"), "coreProperties");
202
+ const result: Record<string, string> = {};
203
+ for (const name of Object.keys(metadataNamespaces)) {
204
+ const value = childText(props, name);
205
+ if (value !== undefined) result[name] = value;
206
+ }
207
+ return result;
208
+ }
209
+
210
+ function relationshipBase(part: string): string {
211
+ if (part.startsWith("_rels/")) return "";
212
+ const marker = "/_rels/";
213
+ const index = part.indexOf(marker);
214
+ return index === -1 ? posix.dirname(part) : part.slice(0, index);
215
+ }
216
+
217
+ function relationshipTarget(part: string, target: string): string {
218
+ return target.startsWith("/")
219
+ ? posix.normalize(target.slice(1))
220
+ : posix.normalize(posix.join(relationshipBase(part), target));
221
+ }
222
+
223
+ function validateRelationships(pkg: Package): void {
224
+ for (const part of Object.keys(pkg).filter((name) => name.endsWith(".rels"))) {
225
+ const root = parseXml(pkg[part], part);
226
+ for (const relationship of findAll(root, "Relationship")) {
227
+ const target = attr(relationship, "Target");
228
+ if (!target || attr(relationship, "TargetMode") === "External") continue;
229
+ const normalized = relationshipTarget(part, target);
230
+ if (!pkg[normalized]) throw new Error(`PPTX relationship target is missing: ${normalized}`);
231
+ }
232
+ }
233
+ }
234
+
235
+ function validatePackage(pkg: Package): void {
236
+ for (const part of [
237
+ "[Content_Types].xml",
238
+ "ppt/presentation.xml",
239
+ "ppt/_rels/presentation.xml.rels",
240
+ ]) {
241
+ parseXml(pkg[part], part);
242
+ }
243
+ for (const [part, bytes] of Object.entries(pkg)) {
244
+ if (part.endsWith(".xml") || part.endsWith(".rels")) parseXml(bytes, part);
245
+ }
246
+ validateRelationships(pkg);
247
+ }
248
+
249
+ function slideParts(pkg: Package, presentation: XmlObject): string[] {
250
+ const relsPart = "ppt/_rels/presentation.xml.rels";
251
+ const relationships = findAll(parseXml(pkg[relsPart], relsPart), "Relationship");
252
+ const slides = new Map<string, string>();
253
+ for (const relationship of relationships) {
254
+ const type = attr(relationship, "Type");
255
+ const id = attr(relationship, "Id");
256
+ const target = attr(relationship, "Target");
257
+ if (type?.endsWith("/slide") && id && target)
258
+ slides.set(id, relationshipTarget(relsPart, target));
259
+ }
260
+ const ordered = findAll(presentation, "sldId")
261
+ .map((slide) => attr(slide, "id"))
262
+ .filter((id): id is string => id !== undefined)
263
+ .map((id) => slides.get(id))
264
+ .filter((part): part is string => part !== undefined);
265
+ return ordered.length > 0 ? ordered : [...slides.values()];
266
+ }
267
+
268
+ function inspectPackage(pkg: Package): PptxFormatting {
269
+ const presentation = parseXml(pkg["ppt/presentation.xml"], "ppt/presentation.xml");
270
+ const root = rootObject(presentation, "presentation");
271
+ const size = directObject(root, "sldSz");
272
+ return {
273
+ slideSize: { width: numberAttr(size, "cx"), height: numberAttr(size, "cy") },
274
+ slideCount: slideParts(pkg, presentation).length,
275
+ metadata: coreMetadata(pkg),
276
+ };
277
+ }
278
+
279
+ function textNodes(value: unknown, found: TextNode[] = []): TextNode[] {
280
+ if (!value || typeof value !== "object") return found;
281
+ for (const [key, child] of Object.entries(value)) {
282
+ if (key.startsWith("@_")) continue;
283
+ if (localName(key) === "t" && (typeof child === "string" || typeof child === "number")) {
284
+ found.push({ parent: value as XmlObject, key, text: String(child) });
285
+ } else {
286
+ textNodes(child, found);
287
+ }
288
+ }
289
+ return found;
290
+ }
291
+
292
+ function replaceInParagraph(paragraph: XmlObject, find: string, replacement: string): number {
293
+ const nodes = textNodes(paragraph);
294
+ let full = nodes.map((node) => node.text).join("");
295
+ let count = 0;
296
+ let start = full.indexOf(find);
297
+ while (start !== -1) {
298
+ const end = start + find.length;
299
+ let cursor = 0;
300
+ let startNode: TextNode | undefined;
301
+ let endNode: TextNode | undefined;
302
+ let startOffset = 0;
303
+ let endOffset = 0;
304
+ for (const node of nodes) {
305
+ const nodeEnd = cursor + node.text.length;
306
+ if (!startNode && start >= cursor && start <= nodeEnd) {
307
+ startNode = node;
308
+ startOffset = start - cursor;
309
+ }
310
+ if (end >= cursor && end <= nodeEnd) {
311
+ endNode = node;
312
+ endOffset = end - cursor;
313
+ break;
314
+ }
315
+ cursor = nodeEnd;
316
+ }
317
+ if (!startNode || !endNode) break;
318
+ if (startNode === endNode) {
319
+ startNode.text =
320
+ startNode.text.slice(0, startOffset) + replacement + startNode.text.slice(endOffset);
321
+ startNode.parent[startNode.key] = startNode.text;
322
+ } else {
323
+ startNode.text = startNode.text.slice(0, startOffset) + replacement;
324
+ startNode.parent[startNode.key] = startNode.text;
325
+ let clearing = false;
326
+ for (const node of nodes) {
327
+ if (node === startNode) {
328
+ clearing = true;
329
+ continue;
330
+ }
331
+ if (node === endNode) {
332
+ node.text = node.text.slice(endOffset);
333
+ node.parent[node.key] = node.text;
334
+ break;
335
+ }
336
+ if (clearing) {
337
+ node.text = "";
338
+ node.parent[node.key] = "";
339
+ }
340
+ }
341
+ }
342
+ full = nodes.map((node) => node.text).join("");
343
+ count += 1;
344
+ start = full.indexOf(find, start + replacement.length);
345
+ }
346
+ return count;
347
+ }
348
+
349
+ function replaceInSlide(document: XmlObject, find: string, replacement: string): number {
350
+ const root = rootObject(document, "sld");
351
+ const drawingPrefix = existingNamespacePrefix(root, DRAWING_NS) ?? "a";
352
+ let count = 0;
353
+ for (const paragraph of findPrefixed(document, drawingPrefix, "p")) {
354
+ count += replaceInParagraph(paragraph, find, replacement);
355
+ }
356
+ return count;
357
+ }
358
+
359
+ function applyPatch(
360
+ pkg: Package,
361
+ patch: PptxPatchSet,
362
+ ): { changedParts: string[]; replacementCount: number } {
363
+ const changed = new Set<string>();
364
+ let replacementCount = 0;
365
+ const presentation = parseXml(pkg["ppt/presentation.xml"], "ppt/presentation.xml");
366
+ const root = rootObject(presentation, "presentation");
367
+ if (patch.slideSize) {
368
+ const size =
369
+ directObject(root, "sldSz") ??
370
+ (() => {
371
+ const value: XmlObject = {};
372
+ const prefix = existingNamespacePrefix(root, PRESENTATION_NS) ?? "p";
373
+ root[`${prefix ? `${prefix}:` : ""}sldSz`] = value;
374
+ return value;
375
+ })();
376
+ if (patch.slideSize.width !== undefined) setAttr(size, "cx", patch.slideSize.width);
377
+ if (patch.slideSize.height !== undefined) setAttr(size, "cy", patch.slideSize.height);
378
+ pkg["ppt/presentation.xml"] = serializeXml(presentation);
379
+ changed.add("ppt/presentation.xml");
380
+ }
381
+
382
+ if (patch.metadata || patch.clearCoreMetadata) {
383
+ const core = parseXml(pkg["docProps/core.xml"], "docProps/core.xml");
384
+ const props = rootObject(core, "coreProperties");
385
+ if (patch.clearCoreMetadata) {
386
+ for (const name of Object.keys(metadataNamespaces)) {
387
+ if (childText(props, name) !== undefined) setChildText(props, name, "");
388
+ }
389
+ }
390
+ for (const [name, value] of Object.entries(patch.metadata ?? {}))
391
+ setChildText(props, name, value);
392
+ pkg["docProps/core.xml"] = serializeXml(core);
393
+ changed.add("docProps/core.xml");
394
+ }
395
+
396
+ if (patch.replaceText) {
397
+ const parts = slideParts(pkg, presentation);
398
+ const documents = new Map<string, XmlObject>();
399
+ for (const request of patch.replaceText) {
400
+ const targets = request.slide === undefined ? parts : [parts[request.slide - 1]];
401
+ if (targets.some((part) => part === undefined))
402
+ throw new Error("PPTX text replacement slide is out of range");
403
+ let matches = 0;
404
+ for (const part of targets) {
405
+ let document = documents.get(part);
406
+ if (!document) {
407
+ document = parseXml(pkg[part], part);
408
+ documents.set(part, document);
409
+ }
410
+ matches += replaceInSlide(document, request.find, request.replace);
411
+ }
412
+ if (matches === 0) throw new Error("PPTX text replacement found no matches");
413
+ replacementCount += matches;
414
+ }
415
+ for (const [part, document] of documents) {
416
+ pkg[part] = serializeXml(document);
417
+ changed.add(part);
418
+ }
419
+ }
420
+ return { changedParts: [...changed].sort(), replacementCount };
421
+ }
422
+
423
+ function hasValues(value: object | undefined): boolean {
424
+ return Boolean(value && Object.keys(value).length > 0);
425
+ }
426
+
427
+ function validatePatch(patch: PptxPatchSet): void {
428
+ if (
429
+ !hasValues(patch.slideSize) &&
430
+ !hasValues(patch.metadata) &&
431
+ !patch.replaceText?.length &&
432
+ patch.clearCoreMetadata !== true
433
+ ) {
434
+ throw new Error("PPTX patch requires at least one change");
435
+ }
436
+ if (patch.replaceText?.some((request) => !request.find)) {
437
+ throw new Error("PPTX text replacements require non-empty find values");
438
+ }
439
+ }
440
+
441
+ function validateRequested(formatting: PptxFormatting, patch: PptxPatchSet): void {
442
+ if (patch.slideSize) {
443
+ for (const [key, value] of Object.entries(patch.slideSize)) {
444
+ if (formatting.slideSize[key as keyof typeof formatting.slideSize] !== value) {
445
+ throw new Error(`PPTX slide size patch validation failed for ${key}`);
446
+ }
447
+ }
448
+ }
449
+ if (patch.metadata) {
450
+ for (const [key, value] of Object.entries(patch.metadata)) {
451
+ if (formatting.metadata[key] !== value)
452
+ throw new Error(`PPTX metadata patch validation failed for ${key}`);
453
+ }
454
+ }
455
+ if (patch.clearCoreMetadata) {
456
+ for (const name of Object.keys(metadataNamespaces)) {
457
+ if (patch.metadata?.[name as keyof NonNullable<PptxPatchSet["metadata"]>] !== undefined)
458
+ continue;
459
+ if (formatting.metadata[name])
460
+ throw new Error(`PPTX core metadata clearing validation failed for ${name}`);
461
+ }
462
+ }
463
+ }
464
+
465
+ async function load(path: string): Promise<Package> {
466
+ return unpackOfficePackage(new Uint8Array(await readFile(resolve(path))), "PPTX");
467
+ }
468
+
469
+ export async function inspectPptx(path: string): Promise<PptxFormatting> {
470
+ const pkg = await load(path);
471
+ validatePackage(pkg);
472
+ return inspectPackage(pkg);
473
+ }
474
+
475
+ export async function patchPptx(
476
+ inputPath: string,
477
+ outputPath: string,
478
+ patch: PptxPatchSet,
479
+ dryRun = false,
480
+ ): Promise<PptxPatchResult> {
481
+ const input = resolve(inputPath);
482
+ const output = resolve(outputPath);
483
+ if (input === output) throw new Error("PPTX patch output must differ from input");
484
+ validatePatch(patch);
485
+ const pkg = await load(input);
486
+ validatePackage(pkg);
487
+ const applied = applyPatch(pkg, patch);
488
+ validatePackage(pkg);
489
+ const formatting = inspectPackage(pkg);
490
+ validateRequested(formatting, patch);
491
+ const allParts = Object.keys(pkg).sort();
492
+ const result: PptxPatchResult = {
493
+ formatting,
494
+ replacementCount: applied.replacementCount,
495
+ changedParts: applied.changedParts,
496
+ preservedParts: allParts.filter((part) => !applied.changedParts.includes(part)),
497
+ written: false,
498
+ };
499
+ if (dryRun) return result;
500
+ const temporary = join(dirname(output), `.${output.split(/[\\/]/).pop()}.${randomUUID()}.tmp`);
501
+ await mkdir(dirname(output), { recursive: true });
502
+ try {
503
+ await writeFile(temporary, packOfficePackage(pkg));
504
+ await inspectPptx(temporary);
505
+ await rename(temporary, output);
506
+ result.written = true;
507
+ return result;
508
+ } finally {
509
+ await rm(temporary, { force: true });
510
+ }
511
+ }