@usejunior/docx-core 0.19.1 → 0.20.1

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 (113) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/core-types.d.ts +1 -139
  3. package/dist/core-types.d.ts.map +1 -1
  4. package/dist/core-types.js +1 -3
  5. package/dist/core-types.js.map +1 -1
  6. package/dist/index.d.ts +3 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +11 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/integration/generation-probes.d.ts +36 -2
  11. package/dist/integration/generation-probes.d.ts.map +1 -1
  12. package/dist/integration/generation-probes.js +137 -15
  13. package/dist/integration/generation-probes.js.map +1 -1
  14. package/dist/integration/libreoffice-oracle.js +1 -1
  15. package/dist/integration/synthetic-docx-fixture.d.ts.map +1 -1
  16. package/dist/integration/synthetic-docx-fixture.js +8 -0
  17. package/dist/integration/synthetic-docx-fixture.js.map +1 -1
  18. package/dist/primitives/accept_ai_edits.js +14 -2
  19. package/dist/primitives/accept_ai_edits.js.map +1 -1
  20. package/dist/primitives/accept_changes.d.ts +7 -0
  21. package/dist/primitives/accept_changes.d.ts.map +1 -1
  22. package/dist/primitives/accept_changes.js +92 -4
  23. package/dist/primitives/accept_changes.js.map +1 -1
  24. package/dist/primitives/bookmarks.d.ts +1 -0
  25. package/dist/primitives/bookmarks.d.ts.map +1 -1
  26. package/dist/primitives/bookmarks.js +1 -1
  27. package/dist/primitives/bookmarks.js.map +1 -1
  28. package/dist/primitives/comments.d.ts +31 -4
  29. package/dist/primitives/comments.d.ts.map +1 -1
  30. package/dist/primitives/comments.js +111 -12
  31. package/dist/primitives/comments.js.map +1 -1
  32. package/dist/primitives/document.d.ts +47 -1
  33. package/dist/primitives/document.d.ts.map +1 -1
  34. package/dist/primitives/document.js +198 -13
  35. package/dist/primitives/document.js.map +1 -1
  36. package/dist/primitives/document_view-headings.d.ts +3 -1
  37. package/dist/primitives/document_view-headings.d.ts.map +1 -1
  38. package/dist/primitives/document_view-headings.js +5 -5
  39. package/dist/primitives/document_view-headings.js.map +1 -1
  40. package/dist/primitives/document_view.d.ts +2 -0
  41. package/dist/primitives/document_view.d.ts.map +1 -1
  42. package/dist/primitives/document_view.js +18 -4
  43. package/dist/primitives/document_view.js.map +1 -1
  44. package/dist/primitives/field_evaluation.d.ts +70 -0
  45. package/dist/primitives/field_evaluation.d.ts.map +1 -0
  46. package/dist/primitives/field_evaluation.js +462 -0
  47. package/dist/primitives/field_evaluation.js.map +1 -0
  48. package/dist/primitives/footnotes.d.ts +17 -3
  49. package/dist/primitives/footnotes.d.ts.map +1 -1
  50. package/dist/primitives/footnotes.js +64 -17
  51. package/dist/primitives/footnotes.js.map +1 -1
  52. package/dist/primitives/index.d.ts +6 -1
  53. package/dist/primitives/index.d.ts.map +1 -1
  54. package/dist/primitives/index.js +6 -1
  55. package/dist/primitives/index.js.map +1 -1
  56. package/dist/primitives/merge_runs.d.ts +3 -1
  57. package/dist/primitives/merge_runs.d.ts.map +1 -1
  58. package/dist/primitives/merge_runs.js +12 -2
  59. package/dist/primitives/merge_runs.js.map +1 -1
  60. package/dist/primitives/namespaces.d.ts +10 -2
  61. package/dist/primitives/namespaces.d.ts.map +1 -1
  62. package/dist/primitives/namespaces.js +11 -2
  63. package/dist/primitives/namespaces.js.map +1 -1
  64. package/dist/primitives/note_conversion.d.ts +11 -0
  65. package/dist/primitives/note_conversion.d.ts.map +1 -0
  66. package/dist/primitives/note_conversion.js +12 -0
  67. package/dist/primitives/note_conversion.js.map +1 -0
  68. package/dist/primitives/paragraph_numbering.d.ts +40 -0
  69. package/dist/primitives/paragraph_numbering.d.ts.map +1 -0
  70. package/dist/primitives/paragraph_numbering.js +198 -0
  71. package/dist/primitives/paragraph_numbering.js.map +1 -0
  72. package/dist/primitives/reject_changes.d.ts +7 -0
  73. package/dist/primitives/reject_changes.d.ts.map +1 -1
  74. package/dist/primitives/reject_changes.js +133 -10
  75. package/dist/primitives/reject_changes.js.map +1 -1
  76. package/dist/primitives/sectPrAudit.d.ts.map +1 -1
  77. package/dist/primitives/sectPrAudit.js +10 -1
  78. package/dist/primitives/sectPrAudit.js.map +1 -1
  79. package/dist/primitives/sections.d.ts +134 -0
  80. package/dist/primitives/sections.d.ts.map +1 -0
  81. package/dist/primitives/sections.js +644 -0
  82. package/dist/primitives/sections.js.map +1 -0
  83. package/dist/primitives/styles.d.ts +60 -0
  84. package/dist/primitives/styles.d.ts.map +1 -1
  85. package/dist/primitives/styles.js +206 -25
  86. package/dist/primitives/styles.js.map +1 -1
  87. package/dist/primitives/symbol_run_content.d.ts +42 -0
  88. package/dist/primitives/symbol_run_content.d.ts.map +1 -0
  89. package/dist/primitives/symbol_run_content.js +79 -0
  90. package/dist/primitives/symbol_run_content.js.map +1 -0
  91. package/dist/primitives/text.d.ts +29 -0
  92. package/dist/primitives/text.d.ts.map +1 -1
  93. package/dist/primitives/text.js +354 -44
  94. package/dist/primitives/text.js.map +1 -1
  95. package/dist/primitives/track-changes-emitter.d.ts +9 -0
  96. package/dist/primitives/track-changes-emitter.d.ts.map +1 -1
  97. package/dist/primitives/track-changes-emitter.js +39 -1
  98. package/dist/primitives/track-changes-emitter.js.map +1 -1
  99. package/dist/primitives/validate_document.d.ts.map +1 -1
  100. package/dist/primitives/validate_document.js +22 -1
  101. package/dist/primitives/validate_document.js.map +1 -1
  102. package/dist/shared/docx/DocxArchive.d.ts.map +1 -1
  103. package/dist/shared/docx/DocxArchive.js +7 -0
  104. package/dist/shared/docx/DocxArchive.js.map +1 -1
  105. package/dist/shared/field-semantics.d.ts +26 -0
  106. package/dist/shared/field-semantics.d.ts.map +1 -0
  107. package/dist/shared/field-semantics.js +232 -0
  108. package/dist/shared/field-semantics.js.map +1 -0
  109. package/dist/shared/field-structure.d.ts +7 -1
  110. package/dist/shared/field-structure.d.ts.map +1 -1
  111. package/dist/shared/field-structure.js +25 -8
  112. package/dist/shared/field-structure.js.map +1 -1
  113. package/package.json +3 -3
@@ -3,7 +3,17 @@ export type TextRun = {
3
3
  r: Element;
4
4
  text: string;
5
5
  isFieldResult: boolean;
6
+ fieldResultId?: number | null;
7
+ fieldInstruction?: string | null;
6
8
  };
9
+ /**
10
+ * Return the paragraph's visible runs while retaining enough complex-field
11
+ * provenance to distinguish a safe cached-result edit from a field-boundary
12
+ * rewrite.
13
+ *
14
+ * @conformance ECMA-376 edition 5, Part 1 § 17.16.18
15
+ * @see #651
16
+ */
7
17
  export declare function getParagraphRuns(p: Element): TextRun[];
8
18
  export declare function getParagraphText(p: Element): string;
9
19
  export declare function visibleLengthForEl(el: Element): number;
@@ -27,5 +37,24 @@ export type ReplacementPart = {
27
37
  addRunProps?: AddRunProps;
28
38
  clearHighlight?: boolean;
29
39
  };
40
+ /**
41
+ * Replace a visible paragraph range while keeping tracked runs inside their
42
+ * existing run container. In particular, edits wholly inside `w:hyperlink`
43
+ * remain nested there, while cross-container ranges are refused before the
44
+ * DOM is changed.
45
+ *
46
+ * Embedded content (`w:drawing`, `w:pict`, `w:object`, `w:contentPart`)
47
+ * inside the replaced range is preserved as live runs: it contributes no
48
+ * visible text, so no text match ever covers it and no text edit may delete
49
+ * it. Tracked deletions around preserved content are emitted as in-place
50
+ * segments so rejectChanges() restores the original content order exactly.
51
+ *
52
+ * @conformance ECMA-376 edition 5, Part 1 § 17.13.5.14
53
+ * @conformance ECMA-376 edition 5, Part 1 § 17.13.5.15
54
+ * @conformance ECMA-376 edition 5, Part 1 § 17.16.22
55
+ * @see #652
56
+ * @see #741
57
+ * @see #739
58
+ */
30
59
  export declare function replaceParagraphTextRange(p: Element, start: number, end: number, replacement: string | ReplacementPart[], ctx?: RevisionContext): void;
31
60
  //# sourceMappingURL=text.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"text.d.ts","sourceRoot":"","sources":["../../src/primitives/text.ts"],"names":[],"mappings":"AAGA,OAAO,EAIL,KAAK,eAAe,EACrB,MAAM,4BAA4B,CAAC;AAEpC,MAAM,MAAM,OAAO,GAAG;IACpB,CAAC,EAAE,OAAO,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,aAAa,EAAE,OAAO,CAAC;CACxB,CAAC;AAEF,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,OAAO,GAAG,OAAO,EAAE,CAsDtD;AAED,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,OAAO,GAAG,MAAM,CAInD;AAoGD,wBAAgB,kBAAkB,CAAC,EAAE,EAAE,OAAO,GAAG,MAAM,CAMtD;AAED,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,EAAE,CAWhE;AAED,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,CAgEvG;AA0BD,MAAM,MAAM,WAAW,GAAG;IAGxB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,SAAS,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;IAC7B,SAAS,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;IAC7B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,OAAO,GAAG,IAAI,CAAC;IAC7B,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B,CAAC;AA2KF,wBAAgB,yBAAyB,CACvC,CAAC,EAAE,OAAO,EACV,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,MAAM,EACX,WAAW,EAAE,MAAM,GAAG,eAAe,EAAE,EACvC,GAAG,CAAC,EAAE,eAAe,GACpB,IAAI,CAsJN"}
1
+ {"version":3,"file":"text.d.ts","sourceRoot":"","sources":["../../src/primitives/text.ts"],"names":[],"mappings":"AAGA,OAAO,EAIL,KAAK,eAAe,EACrB,MAAM,4BAA4B,CAAC;AAEpC,MAAM,MAAM,OAAO,GAAG;IACpB,CAAC,EAAE,OAAO,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,aAAa,EAAE,OAAO,CAAC;IACvB,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,gBAAgB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAClC,CAAC;AAEF;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,OAAO,GAAG,OAAO,EAAE,CAqGtD;AAED,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,OAAO,GAAG,MAAM,CAInD;AA6GD,wBAAgB,kBAAkB,CAAC,EAAE,EAAE,OAAO,GAAG,MAAM,CAMtD;AAED,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,EAAE,CAWhE;AAED,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,CAgEvG;AAkDD,MAAM,MAAM,WAAW,GAAG;IAGxB,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,SAAS,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;IAC7B,SAAS,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;IAC7B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,OAAO,GAAG,IAAI,CAAC;IAC7B,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B,CAAC;AAyRF;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,yBAAyB,CACvC,CAAC,EAAE,OAAO,EACV,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,MAAM,EACX,WAAW,EAAE,MAAM,GAAG,eAAe,EAAE,EACvC,GAAG,CAAC,EAAE,eAAe,GACpB,IAAI,CAkSN"}
@@ -2,22 +2,45 @@ import { OOXML, W } from './namespaces.js';
2
2
  import { SafeDocxError } from './errors.js';
3
3
  import { getAttributeSafe, getFirstChild } from './xml-helpers.js';
4
4
  import { buildRPrChangeElement, createRevisionContainer, prepareElementForDeletion, } from './track-changes-emitter.js';
5
+ /**
6
+ * Return the paragraph's visible runs while retaining enough complex-field
7
+ * provenance to distinguish a safe cached-result edit from a field-boundary
8
+ * rewrite.
9
+ *
10
+ * @conformance ECMA-376 edition 5, Part 1 § 17.16.18
11
+ * @see #651
12
+ */
5
13
  export function getParagraphRuns(p) {
6
- let FieldState;
7
- (function (FieldState) {
8
- FieldState[FieldState["OUTSIDE_FIELD"] = 0] = "OUTSIDE_FIELD";
9
- FieldState[FieldState["IN_FIELD_CODE"] = 1] = "IN_FIELD_CODE";
10
- FieldState[FieldState["IN_FIELD_RESULT"] = 2] = "IN_FIELD_RESULT";
11
- })(FieldState || (FieldState = {}));
14
+ function currentResultId(stack) {
15
+ if (stack.length === 0 || stack.some((frame) => frame.phase === 'instruction'))
16
+ return null;
17
+ return stack.at(-1).id;
18
+ }
12
19
  function getWAttr(el, localName) {
13
20
  return getAttributeSafe(el, OOXML.W_NS, localName, 'w');
14
21
  }
15
22
  const runs = [];
16
23
  const rElems = Array.from(p.getElementsByTagNameNS(OOXML.W_NS, W.r));
17
- let state = FieldState.OUTSIDE_FIELD;
24
+ const fieldStack = [];
25
+ const fieldInstructions = new Map();
26
+ let nextFieldId = 1;
18
27
  for (const r of rElems) {
19
28
  let runText = '';
20
- let sawResult = state === FieldState.IN_FIELD_RESULT;
29
+ let sawResult = false;
30
+ let runFieldResultId;
31
+ const appendVisibleText = (text) => {
32
+ const resultId = currentResultId(fieldStack);
33
+ sawResult ||= resultId !== null;
34
+ if (runFieldResultId === undefined) {
35
+ runFieldResultId = resultId;
36
+ }
37
+ else if (runFieldResultId !== resultId) {
38
+ // A run whose visible content straddles a field boundary cannot be
39
+ // rewritten as one unit without moving content across that boundary.
40
+ runFieldResultId = null;
41
+ }
42
+ runText += text;
43
+ };
21
44
  // Walk children in order so we can handle rare cases where fldChar and result text
22
45
  // appear in the same run.
23
46
  for (const child of Array.from(r.childNodes)) {
@@ -28,34 +51,54 @@ export function getParagraphRuns(p) {
28
51
  continue;
29
52
  if (el.localName === W.fldChar) {
30
53
  const typ = getWAttr(el, 'fldCharType') ?? '';
31
- if (typ === 'begin')
32
- state = FieldState.IN_FIELD_CODE;
33
- else if (typ === 'separate')
34
- state = FieldState.IN_FIELD_RESULT;
35
- else if (typ === 'end')
36
- state = FieldState.OUTSIDE_FIELD;
54
+ if (typ === 'begin') {
55
+ fieldStack.push({ id: nextFieldId++, phase: 'instruction', instruction: '' });
56
+ }
57
+ else if (typ === 'separate') {
58
+ const frame = fieldStack.at(-1);
59
+ if (frame) {
60
+ frame.phase = 'result';
61
+ fieldInstructions.set(frame.id, frame.instruction.trim());
62
+ }
63
+ }
64
+ else if (typ === 'end') {
65
+ fieldStack.pop();
66
+ }
37
67
  continue;
38
68
  }
39
- if (state === FieldState.IN_FIELD_CODE) {
69
+ const instructionFrame = [...fieldStack].reverse().find((frame) => frame.phase === 'instruction');
70
+ if (instructionFrame) {
71
+ if (el.localName === W.instrText || el.localName === 'delInstrText') {
72
+ instructionFrame.instruction += el.textContent ?? '';
73
+ }
40
74
  // Skip field code/instruction text.
41
75
  continue;
42
76
  }
43
- if (state === FieldState.IN_FIELD_RESULT)
44
- sawResult = true;
45
77
  if (el.localName === W.t) {
46
- runText += el.textContent ?? '';
78
+ appendVisibleText(el.textContent ?? '');
47
79
  }
48
80
  else if (el.localName === W.tab) {
49
- runText += '\t';
81
+ appendVisibleText('\t');
50
82
  }
51
83
  else if (el.localName === W.br) {
52
- runText += '\n';
84
+ appendVisibleText('\n');
53
85
  }
54
86
  }
55
- if (runText)
56
- runs.push({ r, text: runText, isFieldResult: sawResult });
87
+ if (runText) {
88
+ runs.push({
89
+ r,
90
+ text: runText,
91
+ isFieldResult: sawResult,
92
+ fieldResultId: runFieldResultId ?? null,
93
+ });
94
+ }
57
95
  }
58
- return runs;
96
+ return runs.map((run) => ({
97
+ ...run,
98
+ fieldInstruction: run.fieldResultId === null
99
+ ? null
100
+ : (fieldInstructions.get(run.fieldResultId) ?? null),
101
+ }));
59
102
  }
60
103
  export function getParagraphText(p) {
61
104
  return getParagraphRuns(p)
@@ -71,16 +114,24 @@ function findOffsetInRuns(runs, start, end) {
71
114
  let endOffset = 0;
72
115
  for (let i = 0; i < runs.length; i++) {
73
116
  const len = runs[i].text.length;
74
- if (startRunIdx === -1 && start >= pos && start <= pos + len) {
117
+ const nextPos = pos + len;
118
+ const startIsInRun = start === end
119
+ ? start <= nextPos
120
+ : start < nextPos;
121
+ if (startRunIdx === -1 && start >= pos && startIsInRun) {
75
122
  startRunIdx = i;
76
123
  startOffset = start - pos;
77
124
  }
78
- if (endRunIdx === -1 && end >= pos && end <= pos + len) {
125
+ if (endRunIdx === -1 && end > pos && end <= nextPos) {
79
126
  endRunIdx = i;
80
127
  endOffset = end - pos;
81
128
  break;
82
129
  }
83
- pos += len;
130
+ pos = nextPos;
131
+ }
132
+ if (start === end && startRunIdx !== -1) {
133
+ endRunIdx = startRunIdx;
134
+ endOffset = startOffset;
84
135
  }
85
136
  if (startRunIdx === -1 || endRunIdx === -1) {
86
137
  throw new Error('Offset mapping failed');
@@ -261,6 +312,25 @@ function cleanupEmptyRuns(parent) {
261
312
  function getRunVisibleLength(run) {
262
313
  return getDirectContentElements(run).reduce((sum, child) => sum + visibleLengthForEl(child), 0);
263
314
  }
315
+ // OOXML embedded run content that references package parts: DrawingML drawing
316
+ // (w:drawing), VML picture (w:pict), embedded OLE object (w:object), and
317
+ // imported content part (w:contentPart, a CT_Rel relationship reference).
318
+ // These carry no visible text length, so a caller-approved text match never
319
+ // covers them — a text replacement must not destroy them (issue #739).
320
+ const EMBEDDED_CONTENT_LOCALS = new Set([
321
+ W.drawing,
322
+ W.pict,
323
+ W.object,
324
+ W.contentPart,
325
+ ]);
326
+ function isEmbeddedContentElement(node) {
327
+ return (node.nodeType === 1 &&
328
+ node.namespaceURI === OOXML.W_NS &&
329
+ EMBEDDED_CONTENT_LOCALS.has(node.localName ?? ''));
330
+ }
331
+ function getEmbeddedContentElements(run) {
332
+ return getDirectContentElements(run).filter((el) => EMBEDDED_CONTENT_LOCALS.has(el.localName ?? ''));
333
+ }
264
334
  function getDirectChild(parent, localName) {
265
335
  for (const child of Array.from(parent.childNodes)) {
266
336
  if (child.nodeType !== 1)
@@ -271,6 +341,42 @@ function getDirectChild(parent, localName) {
271
341
  }
272
342
  return null;
273
343
  }
344
+ /**
345
+ * Mark the paragraph mark as deleted when a tracked edit removes the
346
+ * paragraph's entire visible payload. The marker belongs in w:pPr/w:rPr;
347
+ * accepting it removes the paragraph break while the run-level w:del removes
348
+ * the old contents.
349
+ *
350
+ * @conformance ECMA-376 edition 5, Part 1 § 17.13.5.15
351
+ * @see https://github.com/UseJunior/safe-docx/issues/741
352
+ */
353
+ function addParagraphMarkDeletion(p, ctx) {
354
+ const doc = p.ownerDocument;
355
+ if (!doc)
356
+ throw new Error('Paragraph has no ownerDocument');
357
+ let pPr = getDirectChild(p, W.pPr);
358
+ if (!pPr) {
359
+ pPr = doc.createElementNS(OOXML.W_NS, `w:${W.pPr}`);
360
+ p.insertBefore(pPr, p.firstChild);
361
+ }
362
+ let rPr = getDirectChild(pPr, W.rPr);
363
+ if (!rPr) {
364
+ rPr = doc.createElementNS(OOXML.W_NS, `w:${W.rPr}`);
365
+ const sectPr = getDirectChild(pPr, 'sectPr');
366
+ const pPrChange = getDirectChild(pPr, 'pPrChange');
367
+ pPr.insertBefore(rPr, sectPr ?? pPrChange);
368
+ }
369
+ if (getDirectChild(rPr, 'del'))
370
+ return;
371
+ const marker = createRevisionContainer(doc, 'del', ctx);
372
+ const insertionMarker = getDirectChild(rPr, 'ins');
373
+ if (insertionMarker) {
374
+ rPr.insertBefore(marker, insertionMarker.nextSibling);
375
+ }
376
+ else {
377
+ rPr.insertBefore(marker, rPr.firstChild);
378
+ }
379
+ }
274
380
  // OOXML on/off toggle properties (ECMA-376 ST_OnOff). Absence of w:val means
275
381
  // "1", and the values "1"/"true"/"on" are equivalent (likewise for the falsy
276
382
  // triple). We normalize so semantically-identical inputs hash the same.
@@ -428,6 +534,76 @@ function applyRunProps(doc, run, add, clearHighlight) {
428
534
  if (add.fontName !== undefined)
429
535
  ensureFont(doc, rPr, add.fontName);
430
536
  }
537
+ function describeRunContainer(node) {
538
+ if (node.nodeType !== 1)
539
+ return node.nodeName;
540
+ const el = node;
541
+ if (el.namespaceURI === OOXML.W_NS)
542
+ return `w:${el.localName}`;
543
+ return el.tagName;
544
+ }
545
+ function previewContainerText(text, start, end) {
546
+ const value = text.slice(start, end);
547
+ return value.length <= 120 ? value : `${value.slice(0, 117)}...`;
548
+ }
549
+ function getContainerBoundaryError(runs, startRunIdx, endRunIdx, start, end, fullText) {
550
+ const segments = [];
551
+ let runStart = 0;
552
+ for (let i = 0; i < runs.length; i++) {
553
+ const run = runs[i];
554
+ const runEnd = runStart + run.text.length;
555
+ if (i >= startRunIdx && i <= endRunIdx) {
556
+ const overlapStart = Math.max(start, runStart);
557
+ const overlapEnd = Math.min(end, runEnd);
558
+ if (overlapEnd > overlapStart) {
559
+ const parent = run.r.parentNode;
560
+ if (!parent)
561
+ throw new Error('Run has no parent');
562
+ const previous = segments.at(-1);
563
+ if (previous?.parent === parent && previous.end === overlapStart) {
564
+ previous.end = overlapEnd;
565
+ }
566
+ else {
567
+ segments.push({ parent, start: overlapStart, end: overlapEnd });
568
+ }
569
+ }
570
+ }
571
+ runStart = runEnd;
572
+ }
573
+ if (segments.length <= 1)
574
+ return null;
575
+ const first = segments[0];
576
+ const second = segments[1];
577
+ const largest = segments.reduce((best, segment) => segment.end - segment.start > best.end - best.start ? segment : best);
578
+ const boundaryOffset = first.end;
579
+ const firstContainer = describeRunContainer(first.parent);
580
+ const secondContainer = describeRunContainer(second.parent);
581
+ const largestContainer = describeRunContainer(largest.parent);
582
+ const preview = previewContainerText(fullText, largest.start, largest.end);
583
+ return new SafeDocxError('UNSAFE_CONTAINER_BOUNDARY', `Edit range [${start}, ${end}) crosses a container boundary at offset ${boundaryOffset} ` +
584
+ `(${firstContainer} → ${secondContainer}). Largest contained sub-span: ` +
585
+ `[${largest.start}, ${largest.end}) in ${largestContainer}, ${JSON.stringify(preview)}.`, `Retry with old_string limited to one container, such as the text in range ` +
586
+ `[${largest.start}, ${largest.end}), or make separate edits on each side of offset ${boundaryOffset}.`);
587
+ }
588
+ /**
589
+ * Replace a visible paragraph range while keeping tracked runs inside their
590
+ * existing run container. In particular, edits wholly inside `w:hyperlink`
591
+ * remain nested there, while cross-container ranges are refused before the
592
+ * DOM is changed.
593
+ *
594
+ * Embedded content (`w:drawing`, `w:pict`, `w:object`, `w:contentPart`)
595
+ * inside the replaced range is preserved as live runs: it contributes no
596
+ * visible text, so no text match ever covers it and no text edit may delete
597
+ * it. Tracked deletions around preserved content are emitted as in-place
598
+ * segments so rejectChanges() restores the original content order exactly.
599
+ *
600
+ * @conformance ECMA-376 edition 5, Part 1 § 17.13.5.14
601
+ * @conformance ECMA-376 edition 5, Part 1 § 17.13.5.15
602
+ * @conformance ECMA-376 edition 5, Part 1 § 17.16.22
603
+ * @see #652
604
+ * @see #741
605
+ * @see #739
606
+ */
431
607
  export function replaceParagraphTextRange(p, start, end, replacement, ctx) {
432
608
  // Replace visible text in [start, end) in paragraph by operating on w:t nodes.
433
609
  // Strategy:
@@ -447,12 +623,29 @@ export function replaceParagraphTextRange(p, start, end, replacement, ctx) {
447
623
  const { startRunIdx, startOffset, endRunIdx, endOffset } = findOffsetInRuns(runs, start, end);
448
624
  const startRun = runs[startRunIdx];
449
625
  const endRun = runs[endRunIdx];
450
- // For now we only support field edits that stay within a single visible run.
451
- if (startRunIdx !== endRunIdx) {
452
- for (let i = startRunIdx; i <= endRunIdx; i++) {
453
- if (runs[i]?.isFieldResult) {
454
- throw new SafeDocxError('UNSUPPORTED_EDIT', 'Edit spans multiple runs and intersects a field result. This is currently unsupported in Safe-Docx TS.', 'Narrow old_string so the edit does not cross a field, or edit the field result text in a smaller span.');
455
- }
626
+ const containerBoundaryError = getContainerBoundaryError(runs, startRunIdx, endRunIdx, start, end, fullText);
627
+ if (containerBoundaryError)
628
+ throw containerBoundaryError;
629
+ // A cached result may span many runs, but every touched run must belong to
630
+ // the same complex field. Moving ordinary text, a field marker, or another
631
+ // field's result across a fldChar boundary would change document semantics.
632
+ const spanRuns = runs.slice(startRunIdx, endRunIdx + 1);
633
+ const fieldRuns = spanRuns.filter((run) => run.isFieldResult);
634
+ if (fieldRuns.length > 0) {
635
+ const fieldIds = new Set(fieldRuns.map((run) => run.fieldResultId));
636
+ const containsInlineFieldMarker = fieldRuns.some((run) => getDirectContentElements(run.r).some((el) => isW(el, W.fldChar)));
637
+ const isOneCompleteResultSpan = fieldRuns.length === spanRuns.length &&
638
+ [...fieldIds].every((fieldId) => typeof fieldId === 'number') &&
639
+ fieldIds.size === 1 &&
640
+ !containsInlineFieldMarker;
641
+ if (!isOneCompleteResultSpan) {
642
+ const instructions = new Set(fieldRuns
643
+ .map((run) => run.fieldInstruction?.split(/\s+/u)[0])
644
+ .filter((instruction) => !!instruction));
645
+ const fieldLabel = instructions.size === 1
646
+ ? `${[...instructions][0]} field result`
647
+ : 'complex field result';
648
+ throw new SafeDocxError('UNSUPPORTED_EDIT', `Edit crosses the boundary of a ${fieldLabel}; cached-result edits must stay inside one field.`, 'Narrow old_string so the changed range is entirely inside one cached field result.');
456
649
  }
457
650
  }
458
651
  // Pick a template run from the span: the run with the largest overlap by visible character count.
@@ -502,24 +695,134 @@ export function replaceParagraphTextRange(p, start, end, replacement, ctx) {
502
695
  if (!parent)
503
696
  throw new Error('Run has no parent');
504
697
  if (rangeEndRunEl.parentNode !== parent) {
505
- throw new SafeDocxError('UNSAFE_CONTAINER_BOUNDARY', 'Edit crosses container boundaries (e.g. hyperlinks/SDTs).', 'Narrow old_string so the match is contained within a single container, or avoid editing inside hyperlinks.');
698
+ throw new Error('Container boundary changed while splitting replacement runs');
506
699
  }
507
700
  const insertBeforeNode = rangeEndRunEl.nextSibling;
508
701
  // Remove runs in [rangeStartRunEl, rangeEndRunEl] inclusive (only w:r elements).
702
+ //
703
+ // Embedded content (w:drawing / w:pict / w:object / w:contentPart) has zero
704
+ // visible text length, so the text match the caller approved never covered
705
+ // it. It must survive the replacement as live content (issue #739): an
706
+ // embedded-only run stays in place untouched, and embedded content
707
+ // co-resident with replaced text is split into its own run at the same
708
+ // relative position before the text is removed. Without this, an
709
+ // embedded-only run would be detached here but never recorded
710
+ // (getRunVisibleLength returns 0 for it), so neither the tracked nor the
711
+ // clean branch could re-emit it.
712
+ //
713
+ // When embedded content is preserved, tracked deletions are emitted as
714
+ // IN-PLACE SEGMENTS — one w:del per contiguous stretch of removed text,
715
+ // inserted at that stretch's original position — instead of one terminal
716
+ // w:del after the range. A single terminal w:del would make rejectChanges()
717
+ // restore the removed text AFTER the preserved object, permanently
718
+ // reordering content the user never touched. When no embedded content is
719
+ // involved the historical single-deletion emission is kept unchanged.
720
+ let rangeContainsEmbeddedContent = false;
721
+ for (let node = rangeStartRunEl; node; node = node.nextSibling) {
722
+ if (node.nodeType === 1 &&
723
+ isW(node, W.r) &&
724
+ getEmbeddedContentElements(node).length > 0) {
725
+ rangeContainsEmbeddedContent = true;
726
+ }
727
+ if (node === rangeEndRunEl)
728
+ break;
729
+ }
509
730
  const removedRuns = [];
510
- let cur = rangeStartRunEl;
511
- while (cur) {
512
- const nextNode = cur.nextSibling;
513
- if (cur.nodeType === 1 && isW(cur, W.r)) {
514
- const runEl = cur;
515
- runEl.parentNode?.removeChild(runEl);
516
- if (getRunVisibleLength(runEl) > 0) {
517
- removedRuns.push(runEl);
731
+ let preservedEmbeddedContent = false;
732
+ if (!rangeContainsEmbeddedContent) {
733
+ let cur = rangeStartRunEl;
734
+ while (cur) {
735
+ const nextNode = cur.nextSibling;
736
+ if (cur.nodeType === 1 && isW(cur, W.r)) {
737
+ const runEl = cur;
738
+ runEl.parentNode?.removeChild(runEl);
739
+ if (getRunVisibleLength(runEl) > 0) {
740
+ removedRuns.push(runEl);
741
+ }
742
+ }
743
+ if (cur === rangeEndRunEl)
744
+ break;
745
+ cur = nextNode;
746
+ }
747
+ }
748
+ else {
749
+ // The open deletion segment; reset to null whenever preserved embedded
750
+ // content interrupts the removed stretch so the next removed run starts
751
+ // a new w:del at its own position.
752
+ let currentDeletion = null;
753
+ const removeRunInPlace = (runEl) => {
754
+ const parentNode = runEl.parentNode;
755
+ if (!parentNode)
756
+ return;
757
+ if (ctx && getRunVisibleLength(runEl) > 0) {
758
+ if (!currentDeletion) {
759
+ currentDeletion = createRevisionContainer(doc, 'del', ctx);
760
+ parentNode.insertBefore(currentDeletion, runEl);
761
+ }
762
+ parentNode.removeChild(runEl);
763
+ currentDeletion.appendChild(prepareElementForDeletion(runEl));
764
+ }
765
+ else {
766
+ parentNode.removeChild(runEl);
518
767
  }
768
+ };
769
+ // Split a run holding both text and embedded content into consecutive
770
+ // homogeneous runs (formatting cloned), inserted at the run's position in
771
+ // original child order, so both the preserved content and the removed
772
+ // text keep their exact relative positions.
773
+ const splitMixedRun = (runEl) => {
774
+ const parentNode = runEl.parentNode;
775
+ if (!parentNode)
776
+ throw new Error('Run has no parent');
777
+ const pieces = [];
778
+ let currentPiece = null;
779
+ for (const child of Array.from(runEl.childNodes)) {
780
+ if (child.nodeType === 1 && isW(child, W.rPr))
781
+ continue;
782
+ const embedded = isEmbeddedContentElement(child);
783
+ if (!currentPiece || currentPiece.embedded !== embedded) {
784
+ currentPiece = { el: cloneRunFormattingOnly(doc, runEl), embedded };
785
+ pieces.push(currentPiece);
786
+ }
787
+ currentPiece.el.appendChild(child);
788
+ }
789
+ for (const piece of pieces)
790
+ parentNode.insertBefore(piece.el, runEl);
791
+ parentNode.removeChild(runEl);
792
+ return pieces;
793
+ };
794
+ let cur = rangeStartRunEl;
795
+ while (cur) {
796
+ const nextNode = cur.nextSibling;
797
+ const atRangeEnd = cur === rangeEndRunEl;
798
+ if (cur.nodeType === 1 && isW(cur, W.r)) {
799
+ const runEl = cur;
800
+ const embeddedContent = getEmbeddedContentElements(runEl);
801
+ if (embeddedContent.length === 0) {
802
+ removeRunInPlace(runEl);
803
+ }
804
+ else if (getRunVisibleLength(runEl) === 0) {
805
+ // Embedded-only run: the replaced text lives entirely in sibling
806
+ // runs. Leave it in the paragraph as-is.
807
+ preservedEmbeddedContent = true;
808
+ currentDeletion = null;
809
+ }
810
+ else {
811
+ for (const piece of splitMixedRun(runEl)) {
812
+ if (piece.embedded) {
813
+ preservedEmbeddedContent = true;
814
+ currentDeletion = null;
815
+ }
816
+ else {
817
+ removeRunInPlace(piece.el);
818
+ }
819
+ }
820
+ }
821
+ }
822
+ if (atRangeEnd)
823
+ break;
824
+ cur = nextNode;
519
825
  }
520
- if (cur === rangeEndRunEl)
521
- break;
522
- cur = nextNode;
523
826
  }
524
827
  // Build replacement runs using the same formatting/template logic as the legacy path.
525
828
  const replacementRuns = [];
@@ -554,6 +857,13 @@ export function replaceParagraphTextRange(p, start, end, replacement, ctx) {
554
857
  }
555
858
  parent.insertBefore(insertion, insertBeforeNode);
556
859
  }
860
+ // Only delete the paragraph mark when the edit leaves nothing live behind.
861
+ // Preserved embedded content keeps the paragraph meaningful, and merging
862
+ // it into the following paragraph on accept would move the image the user
863
+ // never touched (issue #739).
864
+ if (start === 0 && end === fullText.length && replacementRuns.length === 0 && !preservedEmbeddedContent) {
865
+ addParagraphMarkDeletion(p, ctx);
866
+ }
557
867
  }
558
868
  else {
559
869
  for (const replacementRun of replacementRuns) {