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 +55 -26
- package/package.json +4 -2
- package/src/index.ts +197 -14
- package/src/pandoc.ts +75 -10
- package/src/pptx.ts +511 -0
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
|
[](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
|
|
55
|
-
at `path` plus CSV at `source_csv`. Choose a supported
|
|
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
|
|
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
|
|
119
|
-
a page range. Dry runs still read and validate all local
|
|
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,
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
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,
|
|
195
|
-
the result, and rename it into place only after success. Source
|
|
196
|
-
are not modified. PDF image rendering similarly
|
|
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,
|
|
225
|
-
the tracked static site
|
|
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.
|
|
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 {
|
|
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(
|
|
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/
|
|
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
|
|
613
|
-
"For filler
|
|
614
|
-
"For filler
|
|
615
|
-
"For filler
|
|
616
|
-
"For
|
|
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
|
|
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
|
-
[
|
|
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
|
-
|
|
52
|
-
|
|
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 =
|
|
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
|
+
}
|