web-doc 0.7.0 → 0.8.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/THIRD_PARTY_NOTICES.md +10 -3
- package/dist/contracts.d.ts +6 -0
- package/dist/edit/ai/outline.d.ts +47 -0
- package/dist/edit/ai/outline.js +338 -0
- package/dist/edit/ai/targets.d.ts +16 -0
- package/dist/edit/ai/targets.js +309 -0
- package/dist/edit/ai/tools.d.ts +28 -0
- package/dist/edit/ai/tools.js +605 -0
- package/dist/edit/ai/types.d.ts +175 -0
- package/dist/edit/ai/types.js +1 -0
- package/dist/edit/docx/engine.d.ts +10 -4
- package/dist/edit/docx/engine.js +80 -6
- package/dist/edit/docx/model.d.ts +2 -0
- package/dist/edit/docx/model.js +11 -0
- package/dist/edit/docx/operations.d.ts +10 -0
- package/dist/edit/docx/provider.d.ts +4 -1
- package/dist/edit/docx/provider.js +3 -0
- package/dist/edit/docx/session.d.ts +12 -1
- package/dist/edit/docx/session.js +41 -0
- package/dist/edit/docx/structure-ops.js +39 -4
- package/dist/edit/docx/table-ops.js +26 -4
- package/dist/edit/docx/text-ops.d.ts +22 -0
- package/dist/edit/docx/text-ops.js +49 -16
- package/dist/edit/docx/text.js +21 -0
- package/dist/edit/docx/tracked.d.ts +52 -0
- package/dist/edit/docx/tracked.js +347 -0
- package/dist/edit/docx/types.d.ts +16 -1
- package/dist/edit/docx/write.d.ts +19 -5
- package/dist/edit/docx/write.js +31 -8
- package/dist/edit/engine.d.ts +14 -3
- package/dist/edit/history.d.ts +27 -3
- package/dist/edit/history.js +29 -7
- package/dist/edit/pdf/range-map.d.ts +4 -2
- package/dist/edit/pdf/session.d.ts +10 -0
- package/dist/edit/pdf/session.js +39 -0
- package/dist/edit/pptx/handler.js +10 -2
- package/dist/edit/pptx/session.d.ts +10 -0
- package/dist/edit/pptx/session.js +31 -0
- package/dist/edit/session.d.ts +11 -0
- package/dist/edit/session.js +269 -27
- package/dist/edit/types.d.ts +17 -3
- package/dist/edit/worker-engine.d.ts +2 -2
- package/dist/edit/worker-engine.js +2 -2
- package/dist/fuzzy-alignment.d.ts +11 -4
- package/dist/fuzzy-alignment.js +3 -9
- package/dist/headless.d.ts +1 -1
- package/dist/headless.js +4 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.js +11 -2
- package/dist/limits.js +3 -0
- package/dist/worker-protocol.d.ts +1 -1
- package/dist/workers/fuzzy-search-worker.js +1 -1
- package/dist/workers/ooxml-edit-worker.js +1400 -859
- package/dist/workers/pdf-edit-worker.js +4 -1
- package/package.json +1 -1
|
@@ -2,6 +2,7 @@ import { patches } from "../ooxml/patch.js";
|
|
|
2
2
|
import { W_NS } from "./ids.js";
|
|
3
3
|
import { LINE_BREAK } from "./text.js";
|
|
4
4
|
import { replacedParagraph } from "./text-ops.js";
|
|
5
|
+
import { deletedParagraphXml, trackedRangeProblem, unsupportedTracked, } from "./tracked.js";
|
|
5
6
|
import { namespacePatches, normalizeText, paragraphXml, runContentXml, runXml, textProblem, } from "./write.js";
|
|
6
7
|
/*
|
|
7
8
|
* Tables: a new `w:tbl` next to a paragraph or table of the body, with a
|
|
@@ -101,6 +102,10 @@ function commit(context, items, result) {
|
|
|
101
102
|
}
|
|
102
103
|
export const insertTableHandler = {
|
|
103
104
|
async validate(operation, context, issue) {
|
|
105
|
+
if (context.tracked) {
|
|
106
|
+
unsupportedTracked(issue, "", "Inserting a table");
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
104
109
|
placement(operation, context, issue);
|
|
105
110
|
const columns = operation.rows[0]?.length ?? 0;
|
|
106
111
|
if (operation.rows.some((row) => row.length !== columns))
|
|
@@ -186,6 +191,11 @@ export const setTableCellHandler = {
|
|
|
186
191
|
issue("/target", "invalid-target", "The cell holds no paragraph");
|
|
187
192
|
else if (first.readOnlyReason)
|
|
188
193
|
issue("/target", "invalid-target", `The cell's paragraph is read-only (${first.readOnlyReason})`);
|
|
194
|
+
else if (context.tracked)
|
|
195
|
+
for (const paragraph of cell.paragraphs)
|
|
196
|
+
if (paragraph.readOnlyReason ||
|
|
197
|
+
trackedRangeProblem(paragraph, 0, paragraph.text.text.length, issue, "/target"))
|
|
198
|
+
return;
|
|
189
199
|
},
|
|
190
200
|
async apply(operation, context) {
|
|
191
201
|
const { model } = context;
|
|
@@ -198,10 +208,22 @@ export const setTableCellHandler = {
|
|
|
198
208
|
const items = [...replaced.items];
|
|
199
209
|
const removedIds = [...replaced.removedIds];
|
|
200
210
|
const removedParagraphIds = [...replaced.removedParagraphIds];
|
|
211
|
+
const stamped = model.unauthoredSet.has(first.id)
|
|
212
|
+
? [first.id]
|
|
213
|
+
: [];
|
|
201
214
|
for (const paragraph of rest) {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
215
|
+
if (context.tracked) {
|
|
216
|
+
if (model.unauthoredSet.has(paragraph.id))
|
|
217
|
+
stamped.push(paragraph.id);
|
|
218
|
+
// The paragraph stays, marked deleted, until Word accepts.
|
|
219
|
+
items.push(patches.replaceElement(part, paragraph.node, deletedParagraphXml(context, paragraph)));
|
|
220
|
+
removedIds.push(...paragraph.inlines.map((inline) => inline.elementId));
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
items.push(patches.removeElement(part, paragraph.node));
|
|
224
|
+
removedIds.push(paragraph.elementId, ...paragraph.inlines.map((inline) => inline.elementId));
|
|
225
|
+
removedParagraphIds.push(paragraph.id);
|
|
226
|
+
}
|
|
205
227
|
for (const nested of part.findAll("p", paragraph.node)) {
|
|
206
228
|
const id = model.paragraphIds.get(nested);
|
|
207
229
|
if (id && nested !== paragraph.node)
|
|
@@ -212,7 +234,7 @@ export const setTableCellHandler = {
|
|
|
212
234
|
createdIds: replaced.createdIds,
|
|
213
235
|
...(removedIds.length > 0 ? { removedIds } : {}),
|
|
214
236
|
...(removedParagraphIds.length > 0 ? { removedParagraphIds } : {}),
|
|
215
|
-
...(
|
|
237
|
+
...(stamped.length > 0 ? { stamped } : {}),
|
|
216
238
|
reflowFrom: record.id,
|
|
217
239
|
});
|
|
218
240
|
},
|
|
@@ -1,7 +1,29 @@
|
|
|
1
1
|
import { type XmlPatch } from "../ooxml/patch.js";
|
|
2
|
+
import type { XmlElement, XmlPart } from "../ooxml/xml.js";
|
|
2
3
|
import type { ParagraphRecord } from "./model.js";
|
|
3
4
|
import type { DocxOperationContext, DocxOperationHandler } from "./operations.js";
|
|
5
|
+
import type { RunItem } from "./text.js";
|
|
4
6
|
import type { DocxReplaceTextOperation, DocxSetParagraphStyleOperation, DocxSetTextStyleOperation } from "./types.js";
|
|
7
|
+
/** A direct child of a container, or the children a complex field spans. */
|
|
8
|
+
export interface Unit {
|
|
9
|
+
readonly nodes: readonly XmlElement[];
|
|
10
|
+
readonly kind: "zero" | "run" | "wrapper" | "field";
|
|
11
|
+
/** Half-open text span; `start === end` for a unit without text. */
|
|
12
|
+
readonly start: number;
|
|
13
|
+
readonly end: number;
|
|
14
|
+
readonly items: readonly RunItem[];
|
|
15
|
+
}
|
|
16
|
+
/** The units of a container's children for the items it holds. */
|
|
17
|
+
export declare function unitsOf(container: XmlElement, items: readonly RunItem[]): Unit[];
|
|
18
|
+
export declare function rPrOf(part: XmlPart, item: RunItem | undefined): string;
|
|
19
|
+
/**
|
|
20
|
+
* The content of a run's items in `[from, to]`: `w:t` text sliced, every
|
|
21
|
+
* other child (tab, break, picture, symbol, note reference) as its bytes
|
|
22
|
+
* when it lies inside. A zero-width item at `from` belongs to the slice
|
|
23
|
+
* only when `includeStart` says so, which is how a caret or a range edge
|
|
24
|
+
* hands such an item to exactly one side.
|
|
25
|
+
*/
|
|
26
|
+
export declare function contentOf(part: XmlPart, items: readonly RunItem[], from: number, to: number, includeStart: boolean): string;
|
|
5
27
|
export declare const replaceTextHandler: DocxOperationHandler<DocxReplaceTextOperation>;
|
|
6
28
|
/**
|
|
7
29
|
* The patches that replace `[start, end)` of a paragraph's text with
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { patches } from "../ooxml/patch.js";
|
|
2
2
|
import { W_NS } from "./ids.js";
|
|
3
|
+
import { insertedRunXml, markedParagraphProperties, revisionAttributes, trackedRangeProblem, trackedSplit, } from "./tracked.js";
|
|
3
4
|
import { changedParagraphProperties, changedRunProperties, attributeProblem, colorProblem, namespacePatches, normalizeText, paragraphMarkProperties, paragraphPropertiesWithoutSection, paragraphXml, runContentXml, runXml, sliceOf, textProblem, } from "./write.js";
|
|
4
5
|
/** The child of `container` an item sits under. */
|
|
5
6
|
function topOf(node, container) {
|
|
@@ -9,7 +10,7 @@ function topOf(node, container) {
|
|
|
9
10
|
return current;
|
|
10
11
|
}
|
|
11
12
|
/** The units of a container's children for the items it holds. */
|
|
12
|
-
function unitsOf(container, items) {
|
|
13
|
+
export function unitsOf(container, items) {
|
|
13
14
|
const byTop = new Map();
|
|
14
15
|
for (const item of items) {
|
|
15
16
|
const top = topOf(item.run, container);
|
|
@@ -57,7 +58,7 @@ function unitsOf(container, items) {
|
|
|
57
58
|
}
|
|
58
59
|
return units;
|
|
59
60
|
}
|
|
60
|
-
function rPrOf(part, item) {
|
|
61
|
+
export function rPrOf(part, item) {
|
|
61
62
|
return item ? sliceOf(part, item.rPr) : "";
|
|
62
63
|
}
|
|
63
64
|
function startTagOf(part, node) {
|
|
@@ -73,7 +74,7 @@ function endTagOf(part, node) {
|
|
|
73
74
|
* only when `includeStart` says so, which is how a caret or a range edge
|
|
74
75
|
* hands such an item to exactly one side.
|
|
75
76
|
*/
|
|
76
|
-
function contentOf(part, items, from, to, includeStart) {
|
|
77
|
+
export function contentOf(part, items, from, to, includeStart) {
|
|
77
78
|
let out = "";
|
|
78
79
|
for (const item of items) {
|
|
79
80
|
if (item.start === item.end) {
|
|
@@ -291,6 +292,10 @@ export const replaceTextHandler = {
|
|
|
291
292
|
if (!target)
|
|
292
293
|
return;
|
|
293
294
|
const range = checkRange(target, operation.target, operation.range, issue, true);
|
|
295
|
+
if (range &&
|
|
296
|
+
context.tracked &&
|
|
297
|
+
trackedRangeProblem(target.record, range.start, range.end, issue, "/range"))
|
|
298
|
+
return;
|
|
294
299
|
if (!range || !text.includes("\n"))
|
|
295
300
|
return;
|
|
296
301
|
// A paragraph cannot be split inside a hyperlink or content control.
|
|
@@ -317,19 +322,47 @@ export const replaceTextHandler = {
|
|
|
317
322
|
export function replacedParagraph(context, record, start, end, text) {
|
|
318
323
|
const part = context.model.document;
|
|
319
324
|
const segments = text.split("\n");
|
|
320
|
-
const split = splitAt(part, record.node, record.text.items, start, end, (rPr) => runXml(rPr, runContentXml(segments[0])), paragraphMarkRPr(part, record));
|
|
321
|
-
const pPr = sliceOf(part, record.pPr);
|
|
322
325
|
const single = segments.length === 1;
|
|
323
|
-
const
|
|
324
|
-
const items = [patches.replaceElement(part, record.node, first)];
|
|
326
|
+
const items = [];
|
|
325
327
|
const createdIds = [];
|
|
326
328
|
const copiedPPr = paragraphPropertiesWithoutSection(part, record.pPr);
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
329
|
+
if (context.tracked) {
|
|
330
|
+
// The removed runs stay as a deletion, the new text goes in as an
|
|
331
|
+
// insertion; a paragraph split marks the new paragraph marks inserted
|
|
332
|
+
// and leaves the original mark on the last paragraph.
|
|
333
|
+
const split = trackedSplit(context, record, start, end, paragraphMarkRPr(part, record));
|
|
334
|
+
const inserted = (segment) => insertedRunXml(context, split.rPr, segment);
|
|
335
|
+
// The original mark (section properties included) ends the last
|
|
336
|
+
// paragraph; every mark before it is an insertion.
|
|
337
|
+
const originalPPr = sliceOf(part, record.pPr);
|
|
338
|
+
const firstPPr = single
|
|
339
|
+
? originalPPr
|
|
340
|
+
: markedParagraphProperties(context, record.pPr, "ins");
|
|
341
|
+
items.push(patches.replaceElement(part, record.node, paragraphXml(record.node, record.id, firstPPr, split.before +
|
|
342
|
+
split.deleted +
|
|
343
|
+
inserted(segments[0]) +
|
|
344
|
+
(single ? split.after : ""))));
|
|
345
|
+
segments.slice(1).forEach((segment, index) => {
|
|
346
|
+
const id = context.freshParagraphId();
|
|
347
|
+
createdIds.push(`p:${id}`);
|
|
348
|
+
const last = index === segments.length - 2;
|
|
349
|
+
items.push(patches.insertAfter(part, record.node, paragraphXml(record.node, id, last
|
|
350
|
+
? originalPPr
|
|
351
|
+
: markedParagraphProperties(context, record.pPr, "ins"), inserted(segment) + (last ? split.after : ""))));
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
else {
|
|
355
|
+
const split = splitAt(part, record.node, record.text.items, start, end, (rPr) => runXml(rPr, runContentXml(segments[0])), paragraphMarkRPr(part, record));
|
|
356
|
+
const pPr = sliceOf(part, record.pPr);
|
|
357
|
+
items.push(patches.replaceElement(part, record.node, paragraphXml(record.node, record.id, pPr, split.before + (single ? split.after : ""))));
|
|
358
|
+
segments.slice(1).forEach((segment, index) => {
|
|
359
|
+
const id = context.freshParagraphId();
|
|
360
|
+
createdIds.push(`p:${id}`);
|
|
361
|
+
const last = index === segments.length - 2;
|
|
362
|
+
items.push(patches.insertAfter(part, record.node, paragraphXml(record.node, id, copiedPPr, runXml(split.rPr, runContentXml(segment)) +
|
|
363
|
+
(last ? split.after : ""))));
|
|
364
|
+
});
|
|
365
|
+
}
|
|
333
366
|
const dropped = droppedInlines(context.model, record, record.text.items, start, end);
|
|
334
367
|
const removedIds = dropped.removedIds;
|
|
335
368
|
// Inline objects that moved to a new paragraph change their id.
|
|
@@ -362,7 +395,7 @@ function restyledRun(part, run, rPr) {
|
|
|
362
395
|
/** The container's content with the runs in `[start, end)` restyled. */
|
|
363
396
|
function restyled(part, container, items, start, end, change, context) {
|
|
364
397
|
let out = "";
|
|
365
|
-
const changed = (rPr) => changedRunProperties(part, rPr, change, context.model.styles);
|
|
398
|
+
const changed = (rPr) => changedRunProperties(part, rPr, change, context.model.styles, context.tracked ? revisionAttributes(context) : undefined);
|
|
366
399
|
for (const unit of unitsOf(container, items)) {
|
|
367
400
|
const raw = unit.nodes.map((node) => sliceOf(part, node)).join("");
|
|
368
401
|
const overlaps = unit.start < end && start < unit.end
|
|
@@ -428,7 +461,7 @@ export const setTextStyleHandler = {
|
|
|
428
461
|
return { createdIds: [], warnings: [] };
|
|
429
462
|
const content = restyled(part, record.node, record.text.items, start, end, operation.style, context);
|
|
430
463
|
const pPr = end >= record.text.text.length
|
|
431
|
-
? paragraphMarkProperties(part, record.pPr, operation.style, context.model.styles)
|
|
464
|
+
? paragraphMarkProperties(part, record.pPr, operation.style, context.model.styles, context.tracked ? revisionAttributes(context) : undefined)
|
|
432
465
|
: sliceOf(part, record.pPr);
|
|
433
466
|
return commitParagraph(context, target, [
|
|
434
467
|
patches.replaceElement(part, record.node, paragraphXml(record.node, record.id, pPr, content)),
|
|
@@ -446,7 +479,7 @@ export const setParagraphStyleHandler = {
|
|
|
446
479
|
if (style.align === undefined &&
|
|
447
480
|
(style.spacing === undefined || Object.keys(style.spacing).length === 0))
|
|
448
481
|
return { createdIds: [], warnings: [] };
|
|
449
|
-
const pPr = changedParagraphProperties(part, record.pPr, style);
|
|
482
|
+
const pPr = changedParagraphProperties(part, record.pPr, style, context.tracked ? revisionAttributes(context) : undefined);
|
|
450
483
|
const node = record.node;
|
|
451
484
|
const content = node.selfClosing
|
|
452
485
|
? ""
|
package/dist/edit/docx/text.js
CHANGED
|
@@ -43,6 +43,11 @@ class Reader {
|
|
|
43
43
|
this.#child(child, undefined);
|
|
44
44
|
if (this.#field)
|
|
45
45
|
this.#closeField();
|
|
46
|
+
// A paragraph mark inserted, deleted or moved under revision marks the
|
|
47
|
+
// paragraph as well.
|
|
48
|
+
const pPr = paragraph.children.find((child) => child.local === "pPr" && child.namespace === W_NS);
|
|
49
|
+
if (pPr && paragraphMarkTracked(pPr))
|
|
50
|
+
this.#tracked();
|
|
46
51
|
}
|
|
47
52
|
#child(node, wrapper) {
|
|
48
53
|
if (node.namespace !== W_NS) {
|
|
@@ -251,6 +256,22 @@ class Reader {
|
|
|
251
256
|
}
|
|
252
257
|
}
|
|
253
258
|
_a = Reader;
|
|
259
|
+
const MARK_REVISIONS = new Set(["ins", "del", "moveFrom", "moveTo"]);
|
|
260
|
+
/**
|
|
261
|
+
* Whether the paragraph mark's `w:rPr` carries an insertion, deletion or
|
|
262
|
+
* move. Changed properties alone (`w:rPrChange`, `w:pPrChange`) leave a
|
|
263
|
+
* paragraph editable, as module 06 decided.
|
|
264
|
+
*/
|
|
265
|
+
function paragraphMarkTracked(pPr) {
|
|
266
|
+
for (const child of pPr.children) {
|
|
267
|
+
if (child.namespace !== W_NS)
|
|
268
|
+
continue;
|
|
269
|
+
if (child.local === "rPr" &&
|
|
270
|
+
child.children.some((mark) => mark.namespace === W_NS && MARK_REVISIONS.has(mark.local)))
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
254
275
|
/** Reads the text of a `w:p`. */
|
|
255
276
|
export function readParagraphText(part, paragraph) {
|
|
256
277
|
const reader = new Reader(part);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { XmlElement, XmlPart } from "../ooxml/xml.js";
|
|
2
|
+
import type { ParagraphRecord } from "./model.js";
|
|
3
|
+
import type { DocxOperationContext, Issue } from "./operations.js";
|
|
4
|
+
import type { DocxRevision } from "./types.js";
|
|
5
|
+
/** The attributes of one revision element, with a fresh `w:id`. */
|
|
6
|
+
export declare function revisionAttributes(context: DocxOperationContext): string;
|
|
7
|
+
/** A refusal for an operation, or a part of one, that has no tracked form. */
|
|
8
|
+
export declare function unsupportedTracked(issue: Issue, path: string, what: string): void;
|
|
9
|
+
/**
|
|
10
|
+
* Whether a tracked change of `[start, end)` would touch a hyperlink, a
|
|
11
|
+
* content control, a field or a run already under revision, which the
|
|
12
|
+
* tracked rebuild cannot express; reports it as an issue at `path`.
|
|
13
|
+
*/
|
|
14
|
+
export declare function trackedRangeProblem(record: ParagraphRecord, start: number, end: number, issue: Issue, path: string): boolean;
|
|
15
|
+
/** A deleted run: its properties and its content, text as `w:delText`. */
|
|
16
|
+
export declare function deletedRunXml(rPr: string, content: string): string;
|
|
17
|
+
export interface TrackedSplit {
|
|
18
|
+
/** Everything before the change, bytes kept. */
|
|
19
|
+
readonly before: string;
|
|
20
|
+
/** The removed runs inside one `w:del`, or "". */
|
|
21
|
+
readonly deleted: string;
|
|
22
|
+
/** The `w:rPr` bytes new text takes. */
|
|
23
|
+
readonly rPr: string;
|
|
24
|
+
/** Everything after the change, bytes kept. */
|
|
25
|
+
readonly after: string;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The paragraph's content around `[start, end)`, unit by unit as the direct
|
|
29
|
+
* edit walks it: the runs the range covers re-serialized as deleted, a cut
|
|
30
|
+
* run split into a kept head, a deleted middle and a kept tail, everything
|
|
31
|
+
* else as its bytes. New text takes the style of the first replaced run,
|
|
32
|
+
* else of the run before a caret, else of the run after it. The caller
|
|
33
|
+
* places the insertion between `deleted` and `after`. A wrapper or a field
|
|
34
|
+
* inside the range never reaches here: validation refuses it.
|
|
35
|
+
*/
|
|
36
|
+
export declare function trackedSplit(context: DocxOperationContext, record: ParagraphRecord, start: number, end: number, fallbackRPr: string): TrackedSplit;
|
|
37
|
+
/** `text` as an inserted run under revision; nothing for empty text. */
|
|
38
|
+
export declare function insertedRunXml(context: DocxOperationContext, rPr: string, text: string): string;
|
|
39
|
+
/** `w:pPr` bytes with the paragraph mark under revision (`w:ins` or `w:del` first in its `w:rPr`), section properties left out. */
|
|
40
|
+
export declare function markedParagraphProperties(context: DocxOperationContext, pPr: XmlElement | undefined, mark: "ins" | "del"): string;
|
|
41
|
+
/**
|
|
42
|
+
* A whole paragraph marked deleted: its mark under `w:del`, every run
|
|
43
|
+
* inside `w:del` with `w:delText`, other children kept. Word removes the
|
|
44
|
+
* paragraph when the deletion is accepted.
|
|
45
|
+
*/
|
|
46
|
+
export declare function deletedParagraphXml(context: DocxOperationContext, record: ParagraphRecord): string;
|
|
47
|
+
/**
|
|
48
|
+
* The revisions a paragraph holds, in document order: run-level
|
|
49
|
+
* insertions, deletions and moves with their text, the paragraph mark's
|
|
50
|
+
* own revision, and changed run or paragraph properties.
|
|
51
|
+
*/
|
|
52
|
+
export declare function revisionsOf(part: XmlPart, paragraph: XmlElement): DocxRevision[];
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { W_NS } from "./ids.js";
|
|
2
|
+
import { LINE_BREAK, TAB } from "./text.js";
|
|
3
|
+
import { contentOf, rPrOf, unitsOf } from "./text-ops.js";
|
|
4
|
+
import { escapeAttributeValue, innerPropertiesXml, mergedProperties, paragraphXml, PPR_ORDER, runContentXml, runXml, sliceOf, } from "./write.js";
|
|
5
|
+
/*
|
|
6
|
+
* Tracked changes: the same paragraph rebuild as a direct edit, but what a
|
|
7
|
+
* direct edit drops is kept inside `w:del` (text as `w:delText`) and what
|
|
8
|
+
* it adds goes inside `w:ins`, each with an id, the author and the date of
|
|
9
|
+
* the batch, so Word and Pages show the edit as a suggestion to accept or
|
|
10
|
+
* reject. A paragraph rebuilt this way holds revisions and is read-only
|
|
11
|
+
* for the next edit (decision 6 of the ai-edit module). Hyperlinks,
|
|
12
|
+
* content controls and fields have no tracked form here: a change that
|
|
13
|
+
* touches one is refused.
|
|
14
|
+
*/
|
|
15
|
+
/** The attributes of one revision element, with a fresh `w:id`. */
|
|
16
|
+
export function revisionAttributes(context) {
|
|
17
|
+
const tracked = context.tracked;
|
|
18
|
+
return ` w:id="${context.nextRevisionId()}" w:author="${escapeAttributeValue(tracked.author)}"${tracked.date === undefined
|
|
19
|
+
? ""
|
|
20
|
+
: ` w:date="${escapeAttributeValue(tracked.date)}"`}`;
|
|
21
|
+
}
|
|
22
|
+
/** A refusal for an operation, or a part of one, that has no tracked form. */
|
|
23
|
+
export function unsupportedTracked(issue, path, what) {
|
|
24
|
+
issue(path, "unsupported-change-mode", `${what} has no tracked form; apply it directly and review with checkpoints`);
|
|
25
|
+
}
|
|
26
|
+
/** The child of `container` an item sits under. */
|
|
27
|
+
function topOf(node, container) {
|
|
28
|
+
let current = node;
|
|
29
|
+
while (current.parent && current.parent !== container)
|
|
30
|
+
current = current.parent;
|
|
31
|
+
return current;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Whether a tracked change of `[start, end)` would touch a hyperlink, a
|
|
35
|
+
* content control, a field or a run already under revision, which the
|
|
36
|
+
* tracked rebuild cannot express; reports it as an issue at `path`.
|
|
37
|
+
*/
|
|
38
|
+
export function trackedRangeProblem(record, start, end, issue, path) {
|
|
39
|
+
for (const item of record.text.items) {
|
|
40
|
+
const overlaps = item.start === item.end
|
|
41
|
+
? start < item.start && item.start < end
|
|
42
|
+
: item.start < end && start < item.end;
|
|
43
|
+
if (!overlaps)
|
|
44
|
+
continue;
|
|
45
|
+
const top = topOf(item.run, record.node);
|
|
46
|
+
// An equation or an alternate-content block sits in the paragraph
|
|
47
|
+
// without a run; a revision cannot wrap it either.
|
|
48
|
+
const plainRun = item.run.local === "r" && item.run.namespace === W_NS;
|
|
49
|
+
if (!plainRun ||
|
|
50
|
+
item.fieldRuns ||
|
|
51
|
+
item.kind === "field" ||
|
|
52
|
+
top !== item.run ||
|
|
53
|
+
item.wrapper !== undefined) {
|
|
54
|
+
unsupportedTracked(issue, path, "A change touching a hyperlink, content control, field or equation");
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
/** Run content with `w:t` as `w:delText`, for a deleted run. */
|
|
61
|
+
function deletedContent(content) {
|
|
62
|
+
return content
|
|
63
|
+
.replaceAll("<w:t>", "<w:delText>")
|
|
64
|
+
.replaceAll("<w:t ", "<w:delText ")
|
|
65
|
+
.replaceAll("</w:t>", "</w:delText>")
|
|
66
|
+
.replaceAll("<w:instrText", "<w:delInstrText")
|
|
67
|
+
.replaceAll("</w:instrText>", "</w:delInstrText>");
|
|
68
|
+
}
|
|
69
|
+
/** A deleted run: its properties and its content, text as `w:delText`. */
|
|
70
|
+
export function deletedRunXml(rPr, content) {
|
|
71
|
+
return content.length === 0
|
|
72
|
+
? ""
|
|
73
|
+
: `<w:r>${rPr}${deletedContent(content)}</w:r>`;
|
|
74
|
+
}
|
|
75
|
+
/** The whole content of a run (every child but `w:rPr`) as bytes. */
|
|
76
|
+
function runContentOf(part, run) {
|
|
77
|
+
return run.children
|
|
78
|
+
.filter((child) => !(child.local === "rPr" && child.namespace === W_NS))
|
|
79
|
+
.map((child) => sliceOf(part, child))
|
|
80
|
+
.join("");
|
|
81
|
+
}
|
|
82
|
+
/** `w:rPr` bytes of a run element. */
|
|
83
|
+
function runPropertiesOf(part, run) {
|
|
84
|
+
const rPr = run.children.find((child) => child.local === "rPr" && child.namespace === W_NS);
|
|
85
|
+
return sliceOf(part, rPr);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The deleted part of a cut run: like `contentOf`, but a zero-width item
|
|
89
|
+
* (a note reference, an anchored drawing) belongs to the deletion only
|
|
90
|
+
* strictly inside `(from, to)`, so the kept head or tail keeps it instead
|
|
91
|
+
* and nothing is written twice.
|
|
92
|
+
*/
|
|
93
|
+
function deletedContentOf(part, items, from, to) {
|
|
94
|
+
let out = "";
|
|
95
|
+
for (const item of items) {
|
|
96
|
+
if (item.start === item.end) {
|
|
97
|
+
if (from < item.start && item.start < to)
|
|
98
|
+
out += sliceOf(part, item.child);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const begin = Math.max(item.start, from);
|
|
102
|
+
const stop = Math.min(item.end, to);
|
|
103
|
+
if (stop <= begin)
|
|
104
|
+
continue;
|
|
105
|
+
if (item.kind === "text" && item.child.local === "t")
|
|
106
|
+
out += runContentXml(item.text.slice(begin - item.start, stop - item.start));
|
|
107
|
+
else if (begin === item.start && stop === item.end)
|
|
108
|
+
out += sliceOf(part, item.child);
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The paragraph's content around `[start, end)`, unit by unit as the direct
|
|
114
|
+
* edit walks it: the runs the range covers re-serialized as deleted, a cut
|
|
115
|
+
* run split into a kept head, a deleted middle and a kept tail, everything
|
|
116
|
+
* else as its bytes. New text takes the style of the first replaced run,
|
|
117
|
+
* else of the run before a caret, else of the run after it. The caller
|
|
118
|
+
* places the insertion between `deleted` and `after`. A wrapper or a field
|
|
119
|
+
* inside the range never reaches here: validation refuses it.
|
|
120
|
+
*/
|
|
121
|
+
export function trackedSplit(context, record, start, end, fallbackRPr) {
|
|
122
|
+
const part = context.model.document;
|
|
123
|
+
let before = "";
|
|
124
|
+
let after = "";
|
|
125
|
+
const deletedRuns = [];
|
|
126
|
+
let rPr;
|
|
127
|
+
let lastRPr;
|
|
128
|
+
/** The change has been passed: zero-width children now follow it. */
|
|
129
|
+
let passed = false;
|
|
130
|
+
const raw = (unit) => unit.nodes.map((node) => sliceOf(part, node)).join("");
|
|
131
|
+
for (const unit of unitsOf(record.node, record.text.items)) {
|
|
132
|
+
if (unit.kind === "zero") {
|
|
133
|
+
if (passed)
|
|
134
|
+
after += raw(unit);
|
|
135
|
+
else
|
|
136
|
+
before += raw(unit);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (unit.end <= start) {
|
|
140
|
+
before += raw(unit);
|
|
141
|
+
lastRPr = rPrOf(part, unit.items.at(-1));
|
|
142
|
+
// A caret right after this unit: the new text follows its style.
|
|
143
|
+
if (start === end && unit.end === start) {
|
|
144
|
+
rPr ??= lastRPr;
|
|
145
|
+
passed = true;
|
|
146
|
+
}
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (unit.start >= end) {
|
|
150
|
+
rPr ??= lastRPr ?? rPrOf(part, unit.items[0]);
|
|
151
|
+
passed = true;
|
|
152
|
+
after += raw(unit);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (unit.kind !== "run")
|
|
156
|
+
throw new Error("A tracked change cannot cut a field or a wrapper");
|
|
157
|
+
const run = unit.nodes[0];
|
|
158
|
+
const style = runPropertiesOf(part, run);
|
|
159
|
+
if (unit.start >= start && unit.end <= end) {
|
|
160
|
+
// Covered whole: the run goes into the deletion as it is.
|
|
161
|
+
deletedRuns.push(deletedRunXml(style, runContentOf(part, run)));
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
const head = contentOf(part, unit.items, unit.start, start, true);
|
|
165
|
+
const middle = deletedContentOf(part, unit.items, Math.max(unit.start, start), Math.min(unit.end, end));
|
|
166
|
+
const tail = contentOf(part, unit.items, end, unit.end, start < end);
|
|
167
|
+
if (head)
|
|
168
|
+
before += runXml(style, head);
|
|
169
|
+
if (middle)
|
|
170
|
+
deletedRuns.push(deletedRunXml(style, middle));
|
|
171
|
+
if (tail)
|
|
172
|
+
after += runXml(style, tail);
|
|
173
|
+
}
|
|
174
|
+
rPr ??= style;
|
|
175
|
+
if (unit.end >= end)
|
|
176
|
+
passed = true;
|
|
177
|
+
}
|
|
178
|
+
const deleted = deletedRuns.length > 0
|
|
179
|
+
? `<w:del${revisionAttributes(context)}>${deletedRuns.join("")}</w:del>`
|
|
180
|
+
: "";
|
|
181
|
+
return { before, deleted, rPr: rPr ?? lastRPr ?? fallbackRPr, after };
|
|
182
|
+
}
|
|
183
|
+
/** `text` as an inserted run under revision; nothing for empty text. */
|
|
184
|
+
export function insertedRunXml(context, rPr, text) {
|
|
185
|
+
const content = runContentXml(text);
|
|
186
|
+
return content.length === 0
|
|
187
|
+
? ""
|
|
188
|
+
: `<w:ins${revisionAttributes(context)}>${runXml(rPr, content)}</w:ins>`;
|
|
189
|
+
}
|
|
190
|
+
/** `w:pPr` bytes with the paragraph mark under revision (`w:ins` or `w:del` first in its `w:rPr`), section properties left out. */
|
|
191
|
+
export function markedParagraphProperties(context, pPr, mark) {
|
|
192
|
+
const part = context.model.document;
|
|
193
|
+
const rPr = pPr?.children.find((child) => child.local === "rPr" && child.namespace === W_NS);
|
|
194
|
+
const inner = innerPropertiesXml(part, rPr, ["ins", "del"]);
|
|
195
|
+
const marked = `<w:rPr><w:${mark}${revisionAttributes(context)}/>${inner}</w:rPr>`;
|
|
196
|
+
return mergedProperties(part, pPr, "w:pPr", PPR_ORDER, new Map([
|
|
197
|
+
["rPr", marked],
|
|
198
|
+
["sectPr", null],
|
|
199
|
+
]));
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* A whole paragraph marked deleted: its mark under `w:del`, every run
|
|
203
|
+
* inside `w:del` with `w:delText`, other children kept. Word removes the
|
|
204
|
+
* paragraph when the deletion is accepted.
|
|
205
|
+
*/
|
|
206
|
+
export function deletedParagraphXml(context, record) {
|
|
207
|
+
const part = context.model.document;
|
|
208
|
+
const pPr = markedParagraphProperties(context, record.pPr, "del");
|
|
209
|
+
let content = "";
|
|
210
|
+
let open = [];
|
|
211
|
+
const flush = () => {
|
|
212
|
+
if (open.length > 0)
|
|
213
|
+
content += `<w:del${revisionAttributes(context)}>${open.join("")}</w:del>`;
|
|
214
|
+
open = [];
|
|
215
|
+
};
|
|
216
|
+
for (const child of record.node.children) {
|
|
217
|
+
if (child.local === "pPr" && child.namespace === W_NS)
|
|
218
|
+
continue;
|
|
219
|
+
if (child.local === "r" && child.namespace === W_NS) {
|
|
220
|
+
const run = deletedRunXml(runPropertiesOf(part, child), runContentOf(part, child));
|
|
221
|
+
if (run)
|
|
222
|
+
open.push(run);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
flush();
|
|
226
|
+
content += sliceOf(part, child);
|
|
227
|
+
}
|
|
228
|
+
flush();
|
|
229
|
+
return paragraphXml(record.node, record.id, pPr, content);
|
|
230
|
+
}
|
|
231
|
+
const RUN_REVISIONS = new Set(["ins", "del", "moveFrom", "moveTo"]);
|
|
232
|
+
/**
|
|
233
|
+
* The revisions a paragraph holds, in document order: run-level
|
|
234
|
+
* insertions, deletions and moves with their text, the paragraph mark's
|
|
235
|
+
* own revision, and changed run or paragraph properties.
|
|
236
|
+
*/
|
|
237
|
+
export function revisionsOf(part, paragraph) {
|
|
238
|
+
const out = [];
|
|
239
|
+
const attributes = (node) => {
|
|
240
|
+
const author = part.attribute(node, "w:author");
|
|
241
|
+
const date = part.attribute(node, "w:date");
|
|
242
|
+
const raw = part.attribute(node, "w:id");
|
|
243
|
+
return {
|
|
244
|
+
id: raw !== undefined && /^\d+$/.test(raw) ? Number(raw) : -1,
|
|
245
|
+
...(author === undefined ? {} : { author }),
|
|
246
|
+
...(date === undefined ? {} : { date }),
|
|
247
|
+
};
|
|
248
|
+
};
|
|
249
|
+
const visit = (node, inRPr, inPPr) => {
|
|
250
|
+
for (const child of node.children) {
|
|
251
|
+
if (child.namespace !== W_NS) {
|
|
252
|
+
visit(child, inRPr, inPPr);
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (child.local === "pPr") {
|
|
256
|
+
visit(child, false, true);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (child.local === "rPr") {
|
|
260
|
+
visit(child, true, inPPr);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (inRPr && RUN_REVISIONS.has(child.local)) {
|
|
264
|
+
out.push({
|
|
265
|
+
start: child.start,
|
|
266
|
+
revision: {
|
|
267
|
+
kind: child.local,
|
|
268
|
+
scope: inPPr ? "mark" : "runs",
|
|
269
|
+
...attributes(child),
|
|
270
|
+
...(inPPr ? { text: "\n" } : {}),
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (inRPr && child.local === "rPrChange") {
|
|
276
|
+
out.push({
|
|
277
|
+
start: child.start,
|
|
278
|
+
revision: {
|
|
279
|
+
kind: "rPrChange",
|
|
280
|
+
scope: inPPr ? "mark" : "runs",
|
|
281
|
+
...attributes(child),
|
|
282
|
+
...(inPPr || !node.parent
|
|
283
|
+
? {}
|
|
284
|
+
: { text: textOf(part, node.parent) }),
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (inPPr && child.local === "pPrChange") {
|
|
290
|
+
out.push({
|
|
291
|
+
start: child.start,
|
|
292
|
+
revision: {
|
|
293
|
+
kind: "pPrChange",
|
|
294
|
+
scope: "paragraph",
|
|
295
|
+
...attributes(child),
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (!inRPr && !inPPr && RUN_REVISIONS.has(child.local)) {
|
|
301
|
+
out.push({
|
|
302
|
+
start: child.start,
|
|
303
|
+
revision: {
|
|
304
|
+
kind: child.local,
|
|
305
|
+
scope: "runs",
|
|
306
|
+
...attributes(child),
|
|
307
|
+
text: textOf(part, child),
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
visit(child, false, false);
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
visit(child, inRPr, inPPr);
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
visit(paragraph, false, false);
|
|
317
|
+
return out
|
|
318
|
+
.sort((a, b) => a.start - b.start)
|
|
319
|
+
.map((entry) => Object.freeze(entry.revision));
|
|
320
|
+
}
|
|
321
|
+
/** The text of every run under `node`: `w:t` and `w:delText`, tabs and breaks. */
|
|
322
|
+
function textOf(part, node) {
|
|
323
|
+
let text = "";
|
|
324
|
+
const visit = (current) => {
|
|
325
|
+
for (const child of current.children) {
|
|
326
|
+
if (child.namespace === W_NS) {
|
|
327
|
+
if (child.local === "t" || child.local === "delText") {
|
|
328
|
+
text += part.textOf(child);
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (child.local === "tab") {
|
|
332
|
+
text += TAB;
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
if (child.local === "br" || child.local === "cr") {
|
|
336
|
+
text += LINE_BREAK;
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
if (child.local === "rPr" || child.local === "pPr")
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
visit(child);
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
visit(node);
|
|
346
|
+
return text;
|
|
347
|
+
}
|