web-doc 0.7.0 → 0.9.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.
Files changed (65) hide show
  1. package/THIRD_PARTY_NOTICES.md +10 -3
  2. package/dist/contracts.d.ts +6 -0
  3. package/dist/edit/ai/outline.d.ts +47 -0
  4. package/dist/edit/ai/outline.js +338 -0
  5. package/dist/edit/ai/targets.d.ts +16 -0
  6. package/dist/edit/ai/targets.js +309 -0
  7. package/dist/edit/ai/tools.d.ts +28 -0
  8. package/dist/edit/ai/tools.js +607 -0
  9. package/dist/edit/ai/types.d.ts +175 -0
  10. package/dist/edit/ai/types.js +1 -0
  11. package/dist/edit/docx/elements.js +19 -1
  12. package/dist/edit/docx/engine.d.ts +10 -4
  13. package/dist/edit/docx/engine.js +80 -6
  14. package/dist/edit/docx/model.d.ts +2 -0
  15. package/dist/edit/docx/model.js +11 -0
  16. package/dist/edit/docx/operations.d.ts +10 -0
  17. package/dist/edit/docx/provider.d.ts +4 -1
  18. package/dist/edit/docx/provider.js +3 -0
  19. package/dist/edit/docx/session.d.ts +12 -1
  20. package/dist/edit/docx/session.js +41 -0
  21. package/dist/edit/docx/structure-ops.js +39 -4
  22. package/dist/edit/docx/table-ops.js +26 -4
  23. package/dist/edit/docx/text-ops.d.ts +22 -0
  24. package/dist/edit/docx/text-ops.js +49 -16
  25. package/dist/edit/docx/text.js +21 -0
  26. package/dist/edit/docx/tracked.d.ts +52 -0
  27. package/dist/edit/docx/tracked.js +347 -0
  28. package/dist/edit/docx/types.d.ts +24 -1
  29. package/dist/edit/docx/write.d.ts +19 -5
  30. package/dist/edit/docx/write.js +31 -8
  31. package/dist/edit/engine.d.ts +14 -3
  32. package/dist/edit/history.d.ts +27 -3
  33. package/dist/edit/history.js +29 -7
  34. package/dist/edit/pdf/engine/document.js +48 -3
  35. package/dist/edit/pdf/engine/elements.d.ts +6 -0
  36. package/dist/edit/pdf/engine/fonts.js +13 -10
  37. package/dist/edit/pdf/engine/pages.js +9 -1
  38. package/dist/edit/pdf/range-map.d.ts +4 -2
  39. package/dist/edit/pdf/schemas.js +4 -1
  40. package/dist/edit/pdf/session.d.ts +10 -0
  41. package/dist/edit/pdf/session.js +39 -0
  42. package/dist/edit/pdf/types.d.ts +15 -3
  43. package/dist/edit/pptx/handler.js +10 -2
  44. package/dist/edit/pptx/session.d.ts +10 -0
  45. package/dist/edit/pptx/session.js +31 -0
  46. package/dist/edit/session.d.ts +11 -0
  47. package/dist/edit/session.js +269 -27
  48. package/dist/edit/types.d.ts +17 -3
  49. package/dist/edit/worker-engine.d.ts +2 -2
  50. package/dist/edit/worker-engine.js +2 -2
  51. package/dist/fonts/THIRD_PARTY_NOTICES.md +6 -3
  52. package/dist/fonts/manifest.json +4 -4
  53. package/dist/fonts/noto-sans-latin-cyrillic.ttf +0 -0
  54. package/dist/fuzzy-alignment.d.ts +11 -4
  55. package/dist/fuzzy-alignment.js +3 -9
  56. package/dist/headless.d.ts +1 -1
  57. package/dist/headless.js +4 -1
  58. package/dist/index.d.ts +5 -2
  59. package/dist/index.js +11 -2
  60. package/dist/limits.js +3 -0
  61. package/dist/worker-protocol.d.ts +1 -1
  62. package/dist/workers/fuzzy-search-worker.js +1 -1
  63. package/dist/workers/ooxml-edit-worker.js +1415 -860
  64. package/dist/workers/pdf-edit-worker.js +64 -16
  65. 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
- items.push(patches.removeElement(part, paragraph.node));
203
- removedIds.push(paragraph.elementId, ...paragraph.inlines.map((inline) => inline.elementId));
204
- removedParagraphIds.push(paragraph.id);
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
- ...(model.unauthoredSet.has(first.id) ? { stamped: [first.id] } : {}),
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 first = paragraphXml(record.node, record.id, pPr, split.before + (single ? split.after : ""));
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
- segments.slice(1).forEach((segment, index) => {
328
- const id = context.freshParagraphId();
329
- createdIds.push(`p:${id}`);
330
- const last = index === segments.length - 2;
331
- items.push(patches.insertAfter(part, record.node, paragraphXml(record.node, id, copiedPPr, runXml(split.rPr, runContentXml(segment)) + (last ? split.after : ""))));
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
  ? ""
@@ -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
+ }