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 +33 -7
- package/package.json +1 -1
- package/src/docx.ts +273 -61
- package/src/index.ts +94 -21
- package/src/pandoc.ts +20 -10
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`
|
|
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
|
|
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
|
|
143
|
-
|
|
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
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,
|
|
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
|
|
61
|
-
const builder = new XMLBuilder({
|
|
61
|
+
const xmlOptions = {
|
|
62
62
|
ignoreAttributes: false,
|
|
63
63
|
attributeNamePrefix: "@_",
|
|
64
|
-
|
|
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
|
|
68
|
-
|
|
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
|
|
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:
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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:
|
|
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
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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:
|
|
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
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
if (
|
|
182
|
-
|
|
183
|
-
const
|
|
184
|
-
|
|
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
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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[
|
|
255
|
-
? countTags(parseXml(pkg[
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
276
|
-
|
|
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 {
|
|
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(
|
|
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(
|
|
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(
|
|
214
|
-
vertical: Type.Optional(
|
|
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(
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
[
|
|
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
|
-
[
|
|
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
|
-
[
|
|
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?:
|
|
43
|
+
options?: DocxReadOptions,
|
|
38
44
|
): Promise<string> {
|
|
39
45
|
const source = resolve(path);
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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?:
|
|
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?:
|
|
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:
|
|
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=
|
|
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 {
|