pi-filler 0.3.0 → 0.4.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
@@ -47,7 +47,7 @@ The extension exposes one `filler` tool:
47
47
  ``` text
48
48
  format: docx | pdf | pptx | xlsx
49
49
  action: read | search | write | patch
50
- view: text | formatting | image | structure
50
+ view: text | formatting | image | structure | review
51
51
  ```
52
52
 
53
53
  `format` names the document or target format, not every input: DOCX and
@@ -57,9 +57,11 @@ combination and supply only its relevant fields:
57
57
 
58
58
  | Format | Action | View | Required additional fields |
59
59
  |---------------|---------------|---------------|---------------------------|
60
- | DOCX | `read` | `text` or `formatting` | none |
60
+ | DOCX | `read` | `text` | optional `track_changes` |
61
+ | DOCX | `read` | `formatting` | none |
62
+ | DOCX | `read` | `review` | optional `review_offset`, `review_limit` |
61
63
  | DOCX | `read` | `image` | `output`; optional `first_page`, `last_page` |
62
- | DOCX | `search` | `text` or omitted | `query`; optional `ignore_case` |
64
+ | DOCX | `search` | `text` or omitted | `query`; optional `ignore_case`, `track_changes` |
63
65
  | DOCX | `write` | omitted | Markdown `path`, `output`; optional `reference_docx` |
64
66
  | DOCX | `patch` | omitted | `output`, `patches`; optional `dry_run` |
65
67
  | PPTX | `read` | `text` or `formatting` | none |
@@ -136,17 +138,40 @@ inputs.
136
138
 
137
139
  ## DOCX operations
138
140
 
139
- - Text reads and search convert DOCX through Pandoc.
141
+ - Text reads and search use Pandoc Markdown, not GFM. `track_changes`
142
+ defaults to `accept`; choose `reject` for pre-edit text or `all` for
143
+ insertion/deletion spans and comments. This selects an extraction
144
+ view; it does not perform native Word acceptance or rejection.
140
145
  - Image reads convert DOCX through LibreOffice and then render the
141
146
  resulting PDF pages.
142
- - Writes convert a Markdown input into a validated DOCX, optionally
143
- using a reference DOCX for styles and layout.
147
+ - Writes convert Pandoc Markdown into a validated DOCX, including
148
+ supported native review spans and anchored comments. Smart typography
149
+ is disabled, preserving literal quotes and dashes. An optional
150
+ reference DOCX supplies styles and layout defaults; conversion is not
151
+ a lossless round trip of existing Word layout, comment threads, or
152
+ every revision type.
144
153
  - Formatting reads inspect page setup, margins, sections, line and page
145
154
  numbering, comments, inserted/deleted tracked-change markers, and
146
155
  common core metadata.
156
+ - Review reads return comment IDs, bounded text/author/date fields,
157
+ main-document anchor excerpts, and supported legacy reply/resolution
158
+ metadata. They never edit comments or resolve threads.
147
159
  - Patches change first-section page setup, margins, line numbering, page
148
160
  numbering, and common core metadata.
149
161
 
162
+ Review pagination uses zero-based `review_offset` (default 0) and
163
+ `review_limit` (default 20, maximum 50). A byte budget may return fewer
164
+ records; follow `nextOffset` until it is null. Comment text is capped at
165
+ 1,000 Unicode code points and anchor excerpts at 200, with truncation
166
+ flags; author/date fields are bounded to 128/64 UTF-16 code units.
167
+ Ranges include insertion and deletion text, not an accepted-view
168
+ projection. Point anchors show paragraph context. A missing anchor means
169
+ not found in the main document, not necessarily a damaged comment.
170
+ Legacy `commentsExtended` metadata supports resolution and reply-parent
171
+ reporting where matched; additional modern thread features may be
172
+ unsupported and are reported as warnings. This is not native Word
173
+ acceptance/rejection or a complete review-history export.
174
+
150
175
  DOCX page dimensions and margins use twips (twentieths of a point); 1
151
176
  inch is 1,440 twips. Page setup, margins, line numbering, and page
152
177
  numbering patches apply to the first section only. Core-metadata patches
@@ -232,7 +257,8 @@ or replacing output; its required `output` path is still validated.
232
257
  Required for the corresponding operations:
233
258
 
234
259
  - `pandoc` for DOCX/PPTX text conversion, search, and generation;
235
- - `libreoffice` for DOCX, PPTX, and XLSX image rendering;
260
+ - `libreoffice` for DOCX, PPTX, and XLSX image rendering (each
261
+ conversion uses a private temporary profile, removed on completion);
236
262
  - `pdftotext` for PDF text extraction;
237
263
  - `pdftocairo` for PDF, DOCX, PPTX, and XLSX page rendering; and
238
264
  - `pdfgrep` for page-aware PDF search.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-filler",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Deterministic DOCX, PDF, and XLSX tooling for Pi",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/docx.ts CHANGED
@@ -51,21 +51,30 @@ export interface DocxPatchResult {
51
51
  }
52
52
 
53
53
  type Package = OfficePackage;
54
- type XmlObject = Record<string, unknown>;
54
+ type XmlObject = { children: XmlEntry[]; attributes: Record<string, string> };
55
+ type XmlEntry = Record<string, unknown>;
55
56
 
56
57
  const WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
57
58
  const CORE_NS = "http://schemas.openxmlformats.org/package/2006/metadata/core-properties";
58
59
  const DC_NS = "http://purl.org/dc/elements/1.1/";
59
60
 
60
- const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@_" });
61
- const builder = new XMLBuilder({
61
+ const xmlOptions = {
62
62
  ignoreAttributes: false,
63
63
  attributeNamePrefix: "@_",
64
- format: false,
65
- });
64
+ preserveOrder: true,
65
+ parseTagValue: false,
66
+ parseAttributeValue: false,
67
+ trimValues: false,
68
+ commentPropName: "#comment",
69
+ cdataPropName: "#cdata",
70
+ };
71
+ const parser = new XMLParser(xmlOptions);
72
+ const builder = new XMLBuilder(xmlOptions);
66
73
 
67
- function isObject(value: unknown): value is XmlObject {
68
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
74
+ function wrap(entry: XmlEntry, key: string): XmlObject {
75
+ entry[":@"] ??= {};
76
+ const attributes = entry[":@"] as Record<string, string>;
77
+ return { children: entry[key] as XmlEntry[], attributes };
69
78
  }
70
79
 
71
80
  function localName(name: string): string {
@@ -75,43 +84,44 @@ function localName(name: string): string {
75
84
  function parseXml(bytes: Uint8Array | undefined, part: string): XmlObject {
76
85
  if (!bytes) throw new Error(`DOCX is missing required part ${part}`);
77
86
  try {
78
- return parser.parse(new TextDecoder().decode(bytes), true) as XmlObject;
87
+ return {
88
+ children: parser.parse(new TextDecoder().decode(bytes), true) as XmlEntry[],
89
+ attributes: {},
90
+ };
79
91
  } catch (error) {
80
92
  throw new Error(`Invalid XML in ${part}: ${error instanceof Error ? error.message : error}`);
81
93
  }
82
94
  }
83
95
 
84
96
  function serializeXml(value: XmlObject): Uint8Array {
85
- return new TextEncoder().encode(builder.build(value));
97
+ return new TextEncoder().encode(builder.build(value.children));
86
98
  }
87
99
 
88
100
  function unpack(bytes: Uint8Array): Package {
89
101
  return unpackOfficePackage(bytes, "DOCX");
90
102
  }
91
103
 
92
- function findAll(value: unknown, suffix: string, found: XmlObject[] = []): XmlObject[] {
93
- if (!value || typeof value !== "object") return found;
94
- for (const [key, child] of Object.entries(value)) {
95
- if (!key.startsWith("@_") && localName(key) === suffix) {
96
- for (const item of Array.isArray(child) ? child : [child]) {
97
- if (isObject(item)) found.push(item);
98
- }
104
+ function findAll(value: XmlObject, suffix: string, found: XmlObject[] = []): XmlObject[] {
105
+ for (const entry of value.children) {
106
+ for (const [key, child] of Object.entries(entry)) {
107
+ if (!Array.isArray(child)) continue;
108
+ const node = wrap(entry, key);
109
+ if (localName(key) === suffix) found.push(node);
110
+ findAll(node, suffix, found);
99
111
  }
100
- findAll(child, suffix, found);
101
112
  }
102
113
  return found;
103
114
  }
104
115
 
105
- function first(value: unknown, suffix: string): XmlObject | undefined {
116
+ function first(value: XmlObject, suffix: string): XmlObject | undefined {
106
117
  return findAll(value, suffix)[0];
107
118
  }
108
119
 
109
120
  function directObject(value: XmlObject | undefined, suffix: string): XmlObject | undefined {
110
121
  if (!value) return undefined;
111
- for (const [key, child] of Object.entries(value)) {
112
- if (key.startsWith("@_") || localName(key) !== suffix) continue;
113
- const candidate = Array.isArray(child) ? child[0] : child;
114
- return isObject(candidate) ? candidate : undefined;
122
+ for (const entry of value.children) {
123
+ const key = Object.keys(entry).find((name) => name !== ":@" && localName(name) === suffix);
124
+ if (key && Array.isArray(entry[key])) return wrap(entry, key);
115
125
  }
116
126
  return undefined;
117
127
  }
@@ -124,7 +134,7 @@ function rootObject(value: XmlObject, suffix: string): XmlObject {
124
134
 
125
135
  function attr(value: XmlObject | undefined, name: string): string | undefined {
126
136
  if (!value) return undefined;
127
- for (const [key, child] of Object.entries(value)) {
137
+ for (const [key, child] of Object.entries(value.attributes)) {
128
138
  if (key.startsWith("@_") && localName(key) === name && child !== undefined) {
129
139
  return String(child);
130
140
  }
@@ -138,27 +148,35 @@ function numberAttr(value: XmlObject | undefined, name: string): number | undefi
138
148
  }
139
149
 
140
150
  function namespacePrefix(root: XmlObject, uri: string, preferred: string): string {
141
- for (const [key, value] of Object.entries(root)) {
151
+ for (const [key, value] of Object.entries(root.attributes)) {
142
152
  if (key.startsWith("@_xmlns:") && value === uri) return key.slice("@_xmlns:".length);
143
153
  }
144
- root[`@_xmlns:${preferred}`] = uri;
145
- return preferred;
154
+ let prefix = preferred;
155
+ for (let suffix = 1; root.attributes[`@_xmlns:${prefix}`] !== undefined; suffix++) {
156
+ prefix = `${preferred}${suffix}`;
157
+ }
158
+ root.attributes[`@_xmlns:${prefix}`] = uri;
159
+ return prefix;
146
160
  }
147
161
 
148
162
  function setAttr(value: XmlObject, name: string, setting: number | string, prefix: string): void {
149
- const existing = Object.keys(value).find(
163
+ const existing = Object.keys(value.attributes).find(
150
164
  (key) => key.startsWith("@_") && localName(key) === name,
151
165
  );
152
- value[existing ?? `@_${prefix}:${name}`] = String(setting);
166
+ value.attributes[existing ?? `@_${prefix}:${name}`] = String(setting);
153
167
  }
154
168
 
155
169
  function removeDirect(value: XmlObject, name: string): void {
156
- for (const key of Object.keys(value)) {
157
- if (!key.startsWith("@_") && localName(key) === name) delete value[key];
158
- }
170
+ value.children.splice(
171
+ 0,
172
+ value.children.length,
173
+ ...value.children.filter(
174
+ (entry) => !Object.keys(entry).some((key) => key !== ":@" && localName(key) === name),
175
+ ),
176
+ );
159
177
  }
160
178
 
161
- function countTags(value: unknown, suffix: string): number {
179
+ function countTags(value: XmlObject, suffix: string): number {
162
180
  return findAll(value, suffix).length;
163
181
  }
164
182
 
@@ -175,31 +193,31 @@ const metadataNamespaces: Record<string, string> = {
175
193
  lastModifiedBy: CORE_NS,
176
194
  };
177
195
 
178
- function childText(value: XmlObject, name: string): string | undefined {
179
- for (const [key, child] of Object.entries(value)) {
180
- if (key.startsWith("@_") || localName(key) !== name) continue;
181
- if (typeof child === "string" || typeof child === "number") return String(child);
182
- if (isObject(child)) {
183
- const text = child["#text"];
184
- if (typeof text === "string" || typeof text === "number") return String(text);
185
- }
196
+ function literalText(value: XmlObject): string {
197
+ let text = "";
198
+ for (const entry of value.children) {
199
+ if (entry["#text"] !== undefined) text += String(entry["#text"]);
200
+ else
201
+ for (const [key, child] of Object.entries(entry)) {
202
+ if (Array.isArray(child)) text += literalText(wrap(entry, key));
203
+ }
186
204
  }
187
- return undefined;
205
+ return text;
206
+ }
207
+
208
+ function childText(value: XmlObject, name: string): string | undefined {
209
+ const child = directObject(value, name);
210
+ return child ? literalText(child) : undefined;
188
211
  }
189
212
 
190
213
  function setChildText(value: XmlObject, name: string, text: string): void {
191
- const existing = Object.keys(value).find(
192
- (key) => !key.startsWith("@_") && localName(key) === name,
193
- );
194
- if (existing) {
195
- const child = value[existing];
196
- if (isObject(child)) child["#text"] = text;
197
- else value[existing] = text;
198
- return;
214
+ let child = directObject(value, name);
215
+ if (!child) {
216
+ const uri = metadataNamespaces[name] ?? DC_NS;
217
+ const prefix = namespacePrefix(value, uri, uri === CORE_NS ? "cp" : "dc");
218
+ child = ensureChild(value, name, prefix);
199
219
  }
200
- const uri = metadataNamespaces[name] ?? DC_NS;
201
- const prefix = namespacePrefix(value, uri, uri === CORE_NS ? "cp" : "dc");
202
- value[`${prefix}:${name}`] = text;
220
+ child.children.splice(0, child.children.length, { "#text": text });
203
221
  }
204
222
 
205
223
  function coreMetadata(pkg: Package): Record<string, string> {
@@ -217,8 +235,9 @@ function coreMetadata(pkg: Package): Record<string, string> {
217
235
  function countTrackedChanges(pkg: Package, document: XmlObject): number {
218
236
  let count = countTags(document, "ins") + countTags(document, "del");
219
237
  const contentPart = /^word\/(?:header\d+|footer\d+|footnotes|endnotes|comments)\.xml$/;
238
+ const commentPart = relatedPart(pkg, "comments", "word/comments.xml");
220
239
  for (const [part, bytes] of Object.entries(pkg)) {
221
- if (!contentPart.test(part)) continue;
240
+ if (!contentPart.test(part) && part !== commentPart) continue;
222
241
  const content = parseXml(bytes, part);
223
242
  count += countTags(content, "ins") + countTags(content, "del");
224
243
  }
@@ -233,6 +252,7 @@ function inspectPackage(pkg: Package): DocxFormatting {
233
252
  const margin = directObject(section, "pgMar");
234
253
  const line = directObject(section, "lnNumType");
235
254
  const pageNum = directObject(section, "pgNumType");
255
+ const commentsPart = relatedPart(pkg, "comments", "word/comments.xml");
236
256
  return {
237
257
  page: {
238
258
  width: numberAttr(size, "w"),
@@ -251,8 +271,8 @@ function inspectPackage(pkg: Package): DocxFormatting {
251
271
  count_by: numberAttr(line, "countBy"),
252
272
  },
253
273
  pageNumbering: { start: numberAttr(pageNum, "start"), format: attr(pageNum, "fmt") },
254
- comments: pkg["word/comments.xml"]
255
- ? countTags(parseXml(pkg["word/comments.xml"], "word/comments.xml"), "comment")
274
+ comments: pkg[commentsPart]
275
+ ? countTags(parseXml(pkg[commentsPart], commentsPart), "comment")
256
276
  : 0,
257
277
  trackedChanges: countTrackedChanges(pkg, document),
258
278
  metadata: coreMetadata(pkg),
@@ -264,16 +284,48 @@ function firstSection(document: XmlObject, prefix: string): XmlObject {
264
284
  if (existing) return existing;
265
285
  const body = first(document, "body");
266
286
  if (!body) throw new Error("DOCX is missing document body");
267
- const section: XmlObject = {};
268
- body[`${prefix}:sectPr`] = section;
269
- return section;
270
- }
287
+ return ensureChild(body, "sectPr", prefix);
288
+ }
289
+
290
+ // CT_SectPr sequence: insert new properties without moving existing children.
291
+ const sectionOrder = [
292
+ "headerReference",
293
+ "footerReference",
294
+ "footnotePr",
295
+ "endnotePr",
296
+ "type",
297
+ "pgSz",
298
+ "pgMar",
299
+ "paperSrc",
300
+ "pgBorders",
301
+ "lnNumType",
302
+ "pgNumType",
303
+ "cols",
304
+ "formProt",
305
+ "vAlign",
306
+ "noEndnote",
307
+ "titlePg",
308
+ "textDirection",
309
+ "bidi",
310
+ "rtlGutter",
311
+ "docGrid",
312
+ "printerSettings",
313
+ "sectPrChange",
314
+ ];
271
315
 
272
316
  function ensureChild(parent: XmlObject, name: string, prefix: string): XmlObject {
273
317
  let child = directObject(parent, name);
274
318
  if (!child) {
275
- child = {};
276
- parent[`${prefix}:${name}`] = child;
319
+ const entry: XmlEntry = { [`${prefix}:${name}`]: [] };
320
+ const rank = sectionOrder.indexOf(name);
321
+ const next =
322
+ rank < 0
323
+ ? -1
324
+ : parent.children.findIndex((node) =>
325
+ Object.keys(node).some((key) => sectionOrder.indexOf(localName(key)) > rank),
326
+ );
327
+ parent.children.splice(next < 0 ? parent.children.length : next, 0, entry);
328
+ child = wrap(entry, `${prefix}:${name}`);
277
329
  }
278
330
  return child;
279
331
  }
@@ -470,6 +522,166 @@ async function load(path: string): Promise<Package> {
470
522
  return unpack(new Uint8Array(await readFile(resolve(path))));
471
523
  }
472
524
 
525
+ export interface DocxReviewComment {
526
+ id: string;
527
+ author?: string;
528
+ date?: string;
529
+ text: string;
530
+ textTruncated: boolean;
531
+ anchor: { kind: "range" | "point" | "unmatched" | "not-found"; text: string; truncated: boolean };
532
+ resolved?: boolean;
533
+ replyTo?: string;
534
+ }
535
+
536
+ function reviewText(node: XmlObject): string {
537
+ let text = "";
538
+ for (const entry of node.children) {
539
+ for (const [name, value] of Object.entries(entry)) {
540
+ if (!Array.isArray(value)) continue;
541
+ const child = wrap(entry, name);
542
+ const tag = localName(name);
543
+ if (tag === "t" || tag === "delText") {
544
+ text += literalText(child);
545
+ } else if (tag === "tab") text += "\t";
546
+ else if (tag === "br" || tag === "cr") text += "\n";
547
+ else text += reviewText(child);
548
+ if (tag === "p") text += "\n";
549
+ }
550
+ }
551
+ return text;
552
+ }
553
+
554
+ function relatedPart(pkg: Package, suffix: string, fallback: string): string {
555
+ const bytes = pkg["word/_rels/document.xml.rels"];
556
+ if (bytes) {
557
+ for (const rel of findAll(parseXml(bytes, "word/_rels/document.xml.rels"), "Relationship")) {
558
+ if (attr(rel, "Type")?.endsWith(`/${suffix}`) && attr(rel, "TargetMode") !== "External") {
559
+ const target = attr(rel, "Target") ?? "";
560
+ return posix.normalize(
561
+ target.startsWith("/") ? target.slice(1) : posix.join("word", target),
562
+ );
563
+ }
564
+ }
565
+ }
566
+ return fallback;
567
+ }
568
+
569
+ /** Read-only, bounded inspection; anchors include all revision text in the main document. */
570
+ export async function inspectDocxReview(path: string, offset = 0, limit = 20) {
571
+ if (!Number.isInteger(offset) || offset < 0)
572
+ throw new Error("review_offset must be a nonnegative integer");
573
+ if (!Number.isInteger(limit) || limit < 1 || limit > 50)
574
+ throw new Error("review_limit must be between 1 and 50");
575
+ const pkg = await load(path);
576
+ validatePackage(pkg);
577
+ const part = relatedPart(pkg, "comments", "word/comments.xml");
578
+ const comments = pkg[part] ? findAll(parseXml(pkg[part], part), "comment") : [];
579
+ const selected = comments.slice(offset, offset + limit);
580
+ const warnings = new Set<string>();
581
+ const anchors = new Map<string, DocxReviewComment["anchor"]>();
582
+ const active = new Set<string>();
583
+ const ids = new Set(selected.map((comment) => attr(comment, "id") ?? ""));
584
+ const clip = (value: string, size: number) => [...value].slice(0, size).join("");
585
+ function append(text: string) {
586
+ for (const id of active) {
587
+ const anchor = anchors.get(id);
588
+ if (!anchor) continue;
589
+ const combined = anchor.text + text;
590
+ anchor.text = clip(combined, 200);
591
+ anchor.truncated ||= anchor.text !== combined;
592
+ }
593
+ }
594
+ function walk(node: XmlObject, paragraph = "") {
595
+ for (const entry of node.children) {
596
+ for (const [name, value] of Object.entries(entry)) {
597
+ if (!Array.isArray(value)) continue;
598
+ const child = wrap(entry, name);
599
+ const tag = localName(name);
600
+ const id = attr(child, "id") ?? "";
601
+ if (tag === "commentRangeStart" && ids.has(id)) {
602
+ if (anchors.has(id))
603
+ warnings.add(
604
+ "Multiple or overlapping ranges for the same comment ID are not fully represented.",
605
+ );
606
+ anchors.set(id, { kind: "unmatched", text: "", truncated: false });
607
+ active.add(id);
608
+ } else if (tag === "commentRangeEnd" && ids.has(id)) {
609
+ const anchor = anchors.get(id);
610
+ if (anchor && active.has(id)) {
611
+ anchor.kind = "range";
612
+ active.delete(id);
613
+ } else warnings.add("An unmatched comment range end was found.");
614
+ } else if (tag === "commentReference" && ids.has(id) && !anchors.has(id)) {
615
+ anchors.set(id, {
616
+ kind: "point",
617
+ text: clip(paragraph, 200),
618
+ truncated: [...paragraph].length > 200,
619
+ });
620
+ } else if (tag === "t" || tag === "delText") {
621
+ append(literalText(child));
622
+ } else if (tag === "tab") append("\t");
623
+ else if (tag === "br" || tag === "cr") append("\n");
624
+ else walk(child, tag === "p" ? reviewText(child) : paragraph);
625
+ if (tag === "p") append("\n");
626
+ }
627
+ }
628
+ }
629
+ walk(parseXml(pkg["word/document.xml"], "word/document.xml"));
630
+ if (active.size) warnings.add("Some comment ranges have no closing marker.");
631
+ const extendedPart = relatedPart(pkg, "commentsExtended", "word/commentsExtended.xml");
632
+ const extended = pkg[extendedPart]
633
+ ? findAll(parseXml(pkg[extendedPart], extendedPart), "commentEx")
634
+ : [];
635
+ const paraIds = new Map(
636
+ comments.map((comment) => [attr(findAll(comment, "p").at(-1), "paraId"), attr(comment, "id")]),
637
+ );
638
+ const result: DocxReviewComment[] = [];
639
+ let consumed = 0;
640
+ for (const comment of selected) {
641
+ const id = attr(comment, "id") ?? "";
642
+ if (Buffer.byteLength(id) > 512)
643
+ throw new Error("DOCX comment identifier exceeds inspection limit");
644
+ const fullText = reviewText(comment).replace(/\n$/, "");
645
+ const text = clip(fullText, 1000);
646
+ const paraId = attr(findAll(comment, "p").at(-1), "paraId");
647
+ const ex =
648
+ paraId === undefined ? undefined : extended.find((item) => attr(item, "paraId") === paraId);
649
+ const done = attr(ex, "done");
650
+ const knownDone = done !== undefined && ["1", "0", "true", "false", "on", "off"].includes(done);
651
+ if (done !== undefined && !knownDone)
652
+ warnings.add("An unsupported comment resolution value was found.");
653
+ const parent = attr(ex, "paraIdParent");
654
+ const record: DocxReviewComment = {
655
+ id,
656
+ author: attr(comment, "author")?.slice(0, 128),
657
+ date: attr(comment, "date")?.slice(0, 64),
658
+ text,
659
+ textTruncated: text !== fullText,
660
+ anchor: anchors.get(id) ?? { kind: "not-found", text: "", truncated: false },
661
+ resolved: knownDone ? ["1", "true", "on"].includes(done ?? "") : undefined,
662
+ replyTo: parent === undefined ? undefined : paraIds.get(parent),
663
+ };
664
+ if (parent !== undefined && record.replyTo === undefined)
665
+ warnings.add("A comment reply parent could not be matched.");
666
+ if (result.length && Buffer.byteLength(JSON.stringify([...result, record])) > 24000) break;
667
+ result.push(record);
668
+ consumed++;
669
+ }
670
+ if (Object.keys(pkg).some((name) => /comments(?:Ids|Extensible)|people\.xml/.test(name)))
671
+ warnings.add(
672
+ "Additional modern comment metadata is present; not all thread features are inspected.",
673
+ );
674
+ return {
675
+ totalComments: comments.length,
676
+ offset,
677
+ returned: result.length,
678
+ nextOffset: offset + consumed < comments.length ? offset + consumed : null,
679
+ comments: result,
680
+ warnings: [...warnings],
681
+ anchorScope: "main document, including insertion and deletion text",
682
+ };
683
+ }
684
+
473
685
  export async function inspectDocx(path: string): Promise<DocxFormatting> {
474
686
  const pkg = await load(path);
475
687
  validatePackage(pkg);
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { mkdtemp, rm } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import { basename, dirname, extname, join, resolve } from "node:path";
4
- import { StringEnum } from "@earendil-works/pi-ai";
4
+ import { pathToFileURL } from "node:url";
5
5
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
6
6
  import {
7
7
  DEFAULT_MAX_BYTES,
@@ -9,7 +9,7 @@ import {
9
9
  withFileMutationQueue,
10
10
  } from "@earendil-works/pi-coding-agent";
11
11
  import { type Static, Type } from "typebox";
12
- import { type DocxPatchSet, inspectDocx, patchDocx } from "./docx.js";
12
+ import { type DocxPatchSet, inspectDocx, inspectDocxReview, patchDocx } from "./docx.js";
13
13
  import {
14
14
  readDocxText,
15
15
  readPptxText,
@@ -34,7 +34,7 @@ const docxPatchesSchema = Type.Object(
34
34
  height: Type.Optional(
35
35
  Type.Integer({ description: "Page height in twips (1/20 point)", minimum: 1 }),
36
36
  ),
37
- orientation: Type.Optional(StringEnum(["portrait", "landscape"] as const)),
37
+ orientation: Type.Optional(Type.Enum(["portrait", "landscape"], { type: "string" })),
38
38
  },
39
39
  {
40
40
  additionalProperties: false,
@@ -64,7 +64,9 @@ const docxPatchesSchema = Type.Object(
64
64
  lineNumbering: Type.Optional(
65
65
  Type.Object(
66
66
  {
67
- mode: Type.Optional(StringEnum(["off", "continuous", "newPage", "newSection"] as const)),
67
+ mode: Type.Optional(
68
+ Type.Enum(["off", "continuous", "newPage", "newSection"], { type: "string" }),
69
+ ),
68
70
  start: Type.Optional(Type.Integer({ description: "Starting line number", minimum: 0 })),
69
71
  count_by: Type.Optional(Type.Integer({ description: "Numbering interval", minimum: 1 })),
70
72
  },
@@ -210,8 +212,8 @@ const xlsxPatchesSchema = Type.Object(
210
212
  alignment: Type.Optional(
211
213
  Type.Object(
212
214
  {
213
- horizontal: Type.Optional(StringEnum(["left", "center", "right"] as const)),
214
- vertical: Type.Optional(StringEnum(["top", "center", "bottom"] as const)),
215
+ horizontal: Type.Optional(Type.Enum(["left", "center", "right"], { type: "string" })),
216
+ vertical: Type.Optional(Type.Enum(["top", "center", "bottom"], { type: "string" })),
215
217
  wrap_text: Type.Optional(Type.Boolean()),
216
218
  },
217
219
  { additionalProperties: false, description: "Cell alignment changes", minProperties: 1 },
@@ -222,7 +224,7 @@ const xlsxPatchesSchema = Type.Object(
222
224
  ),
223
225
  border: Type.Optional(
224
226
  Type.Object(
225
- { style: Type.Optional(StringEnum(["thin", "medium", "thick"] as const)) },
227
+ { style: Type.Optional(Type.Enum(["thin", "medium", "thick"], { type: "string" })) },
226
228
  { additionalProperties: false, minProperties: 1 },
227
229
  ),
228
230
  ),
@@ -246,14 +248,17 @@ const xlsxPatchesSchema = Type.Object(
246
248
 
247
249
  export const fillerSchema = Type.Object(
248
250
  {
249
- format: StringEnum(["docx", "pdf", "pptx", "xlsx"] as const, {
251
+ format: Type.Enum(["docx", "pdf", "pptx", "xlsx"], {
252
+ type: "string",
250
253
  description: "Document or target format; selects supported operations and path roles",
251
254
  }),
252
- action: StringEnum(["read", "search", "write", "patch"] as const, {
255
+ action: Type.Enum(["read", "search", "write", "patch"], {
256
+ type: "string",
253
257
  description: "Operation; supported combinations depend on format and view",
254
258
  }),
255
259
  view: Type.Optional(
256
- StringEnum(["text", "formatting", "image", "structure"] as const, {
260
+ Type.Enum(["text", "formatting", "image", "structure", "review"], {
261
+ type: "string",
257
262
  description: "Read/search representation; omit for write and patch",
258
263
  }),
259
264
  ),
@@ -272,6 +277,28 @@ export const fillerSchema = Type.Object(
272
277
  query: Type.Optional(
273
278
  Type.String({ description: "DOCX, PDF, or PPTX search query", minLength: 1 }),
274
279
  ),
280
+ review_offset: Type.Optional(
281
+ Type.Integer({
282
+ minimum: 0,
283
+ default: 0,
284
+ description: "DOCX review read: zero-based comment offset",
285
+ }),
286
+ ),
287
+ review_limit: Type.Optional(
288
+ Type.Integer({
289
+ minimum: 1,
290
+ maximum: 50,
291
+ default: 20,
292
+ description: "DOCX review read: maximum comments; byte budget may return fewer",
293
+ }),
294
+ ),
295
+ track_changes: Type.Optional(
296
+ Type.Enum(["accept", "reject", "all"], {
297
+ type: "string",
298
+ default: "accept",
299
+ description: "DOCX text read/search revision view; all includes review spans and comments",
300
+ }),
301
+ ),
275
302
  reference_docx: Type.Optional(
276
303
  Type.String({ description: "Reference DOCX for writes", minLength: 1 }),
277
304
  ),
@@ -303,7 +330,8 @@ export const fillerSchema = Type.Object(
303
330
  Type.Boolean({ default: true, description: "Skip the first CSV record as a header" }),
304
331
  ),
305
332
  value_mode: Type.Optional(
306
- StringEnum(["text", "auto"] as const, {
333
+ Type.Enum(["text", "auto"], {
334
+ type: "string",
307
335
  default: "text",
308
336
  description: "Write CSV fields as text, or infer only empty, number, and boolean values",
309
337
  }),
@@ -357,6 +385,9 @@ type ControlField = Exclude<keyof FillerInput, "format" | "action" | "view" | "p
357
385
  const controlFields: ControlField[] = [
358
386
  "output",
359
387
  "query",
388
+ "track_changes",
389
+ "review_offset",
390
+ "review_limit",
360
391
  "reference_docx",
361
392
  "pptx_patches",
362
393
  "reference_pptx",
@@ -410,15 +441,21 @@ function assertSupported(input: FillerInput): void {
410
441
  if (!input.path) throw new Error("path must not be empty");
411
442
  if (input.format === "docx") {
412
443
  if (input.action === "read") {
444
+ if (input.view === "review") {
445
+ assertParameters(input, ["review_offset", "review_limit"]);
446
+ return;
447
+ }
413
448
  if (input.view === "image") {
414
449
  if (!input.output) throw new Error("DOCX image reads require output");
415
450
  assertParameters(input, ["output", "first_page", "last_page"]);
416
451
  return;
417
452
  }
418
453
  if (input.view !== "text" && input.view !== "formatting") {
419
- throw new Error("DOCX read requires view=text, view=formatting, or view=image");
454
+ throw new Error(
455
+ "DOCX read requires view=text, view=formatting, view=review, or view=image",
456
+ );
420
457
  }
421
- assertParameters(input, []);
458
+ assertParameters(input, input.view === "text" ? ["track_changes"] : []);
422
459
  return;
423
460
  }
424
461
  if (input.action === "search") {
@@ -426,7 +463,7 @@ function assertSupported(input: FillerInput): void {
426
463
  throw new Error("DOCX search requires view=text or no view");
427
464
  }
428
465
  if (!input.query) throw new Error("DOCX search requires query");
429
- assertParameters(input, ["query", "ignore_case"]);
466
+ assertParameters(input, ["query", "ignore_case", "track_changes"]);
430
467
  return;
431
468
  }
432
469
  if (input.action === "write") {
@@ -556,6 +593,17 @@ export async function executeFiller(
556
593
  const range = { firstPage: input.first_page, lastPage: input.last_page };
557
594
 
558
595
  if (input.format === "docx") {
596
+ if (input.action === "read" && input.view === "review") {
597
+ const review = await inspectDocxReview(path, input.review_offset, input.review_limit);
598
+ return {
599
+ text: JSON.stringify(review, null, 2),
600
+ details: {
601
+ totalComments: review.totalComments,
602
+ returned: review.returned,
603
+ nextOffset: review.nextOffset,
604
+ },
605
+ };
606
+ }
559
607
  if (input.action === "read" && input.view === "formatting") {
560
608
  const formatting = await inspectDocx(path);
561
609
  return { text: JSON.stringify(formatting, null, 2), details: { formatting } };
@@ -566,7 +614,15 @@ export async function executeFiller(
566
614
  try {
567
615
  const converted = await runCommand(
568
616
  "libreoffice",
569
- ["--headless", "--convert-to", "pdf", "--outdir", temporary, path],
617
+ [
618
+ `-env:UserInstallation=${pathToFileURL(join(temporary, "profile")).href}`,
619
+ "--headless",
620
+ "--convert-to",
621
+ "pdf",
622
+ "--outdir",
623
+ temporary,
624
+ path,
625
+ ],
570
626
  { signal, cwd: temporary },
571
627
  );
572
628
  if (converted.code !== 0) throw commandError(converted, "libreoffice");
@@ -583,11 +639,12 @@ export async function executeFiller(
583
639
  }
584
640
  }
585
641
  if (input.action === "read") {
586
- const result = await readDocxText(path, { signal });
642
+ const result = await readDocxText(path, { signal, trackChanges: input.track_changes });
587
643
  return { text: result.text, details: { truncation: result.truncation } };
588
644
  }
589
645
  if (input.action === "search") {
590
646
  const result = await searchDocx(path, input.query ?? "", {
647
+ trackChanges: input.track_changes,
591
648
  ignoreCase: input.ignore_case,
592
649
  signal,
593
650
  });
@@ -629,7 +686,15 @@ export async function executeFiller(
629
686
  try {
630
687
  const converted = await runCommand(
631
688
  "libreoffice",
632
- ["--headless", "--convert-to", "pdf", "--outdir", temporary, path],
689
+ [
690
+ `-env:UserInstallation=${pathToFileURL(join(temporary, "profile")).href}`,
691
+ "--headless",
692
+ "--convert-to",
693
+ "pdf",
694
+ "--outdir",
695
+ temporary,
696
+ path,
697
+ ],
633
698
  { signal, cwd: temporary },
634
699
  );
635
700
  if (converted.code !== 0) throw commandError(converted, "libreoffice");
@@ -689,7 +754,15 @@ export async function executeFiller(
689
754
  try {
690
755
  const converted = await runCommand(
691
756
  "libreoffice",
692
- ["--headless", "--convert-to", "pdf", "--outdir", temporary, path],
757
+ [
758
+ `-env:UserInstallation=${pathToFileURL(join(temporary, "profile")).href}`,
759
+ "--headless",
760
+ "--convert-to",
761
+ "pdf",
762
+ "--outdir",
763
+ temporary,
764
+ path,
765
+ ],
693
766
  { signal, cwd: temporary },
694
767
  );
695
768
  if (converted.code !== 0) throw commandError(converted, "libreoffice");
@@ -789,13 +862,13 @@ export default function extension(pi: ExtensionAPI): void {
789
862
  "Work directly with local DOCX, PDF, PPTX, and XLSX files using supported format/action/view combinations.",
790
863
  promptGuidelines: [
791
864
  "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.",
865
+ "For filler reads, use DOCX text, formatting, review, or image; PPTX text, formatting, or image; PDF text or image; or XLSX structure or image. DOCX review returns bounded comment text, authors, main-document anchor excerpts, and supported legacy reply/resolution metadata; review_offset defaults to 0 and review_limit to 20 (max 50). Follow nextOffset; text/excerpts may be truncated and modern thread metadata may be unsupported.",
793
866
  "For filler searches, use DOCX, PPTX, or PDF text (or omit view); XLSX search is unsupported.",
794
867
  "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.",
868
+ "For DOCX/PPTX writes, path is Markdown and output is the new document; reference_docx/reference_pptx is optional. DOCX uses Pandoc Markdown (smart typography disabled), including native review spans; PPTX uses GFM Markdown. DOCX generation is not a lossless round trip of an existing Word layout. 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
869
  "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
870
  "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.",
871
+ "For filler DOCX text reads/searches, track_changes defaults to accept; use reject for the pre-edit text or all for insertion/deletion spans and comments. This selects an extraction view, not native Word acceptance/rejection. 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
872
  "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.",
800
873
  ],
801
874
  parameters: fillerSchema,
package/src/pandoc.ts CHANGED
@@ -6,6 +6,12 @@ import { inspectDocx } from "./docx.js";
6
6
  import { inspectPptx } from "./pptx.js";
7
7
  import { commandError, type RunCommandOptions, runCommand } from "./process.js";
8
8
 
9
+ export type TrackChanges = "accept" | "reject" | "all";
10
+
11
+ export interface DocxReadOptions extends RunCommandOptions {
12
+ trackChanges?: TrackChanges;
13
+ }
14
+
9
15
  export interface DocxTextResult {
10
16
  text: string;
11
17
  truncation: TruncationResult;
@@ -34,18 +40,22 @@ async function runPandoc(
34
40
  async function extractMarkdown(
35
41
  path: string,
36
42
  from: "docx" | "pptx",
37
- options?: RunCommandOptions,
43
+ options?: DocxReadOptions,
38
44
  ): Promise<string> {
39
45
  const source = resolve(path);
40
- const output = await runPandoc(
41
- [`--from=${from}`, "--to=gfm", "--wrap=none", source],
42
- options,
43
- source,
44
- );
46
+ const args =
47
+ from === "docx"
48
+ ? [
49
+ "--from=docx",
50
+ "--to=markdown-smart",
51
+ `--track-changes=${options?.trackChanges ?? "accept"}`,
52
+ ]
53
+ : ["--from=pptx", "--to=gfm"];
54
+ const output = await runPandoc([...args, "--wrap=none", source], options, source);
45
55
  return output.toString("utf8");
46
56
  }
47
57
 
48
- async function extractDocxMarkdown(path: string, options?: RunCommandOptions): Promise<string> {
58
+ async function extractDocxMarkdown(path: string, options?: DocxReadOptions): Promise<string> {
49
59
  return extractMarkdown(path, "docx", options);
50
60
  }
51
61
 
@@ -55,7 +65,7 @@ async function extractPptxMarkdown(path: string, options?: RunCommandOptions): P
55
65
 
56
66
  export async function readDocxText(
57
67
  path: string,
58
- options?: RunCommandOptions,
68
+ options?: DocxReadOptions,
59
69
  ): Promise<DocxTextResult> {
60
70
  const truncation = truncateHead(await extractDocxMarkdown(path, options));
61
71
  return { text: truncation.content, truncation };
@@ -75,7 +85,7 @@ function searchMarkdown(extracted: string, query: string, ignoreCase = false): D
75
85
  export async function searchDocx(
76
86
  path: string,
77
87
  query: string,
78
- options: RunCommandOptions & { ignoreCase?: boolean } = {},
88
+ options: DocxReadOptions & { ignoreCase?: boolean } = {},
79
89
  ): Promise<DocxSearchResult> {
80
90
  return searchMarkdown(await extractDocxMarkdown(path, options), query, options.ignoreCase);
81
91
  }
@@ -117,7 +127,7 @@ export async function writeDocx(
117
127
  `.${output.split(/[\\/]/).pop() ?? "output.docx"}.${randomUUID()}.tmp`,
118
128
  );
119
129
  await mkdir(dirname(output), { recursive: true });
120
- const args = ["--from=gfm", "--to=docx", "--output", temporary];
130
+ const args = ["--from=markdown-smart", "--to=docx", "--output", temporary];
121
131
  if (options.referenceDocx) args.push("--reference-doc", resolve(options.referenceDocx));
122
132
  args.push(source);
123
133
  try {