@usejunior/docx-core 0.19.1 → 0.21.2

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 (180) hide show
  1. package/README.md +12 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/cli/conformance-adapter.d.ts.map +1 -1
  4. package/dist/cli/conformance-adapter.js +0 -27
  5. package/dist/cli/conformance-adapter.js.map +1 -1
  6. package/dist/core-types.d.ts +1 -139
  7. package/dist/core-types.d.ts.map +1 -1
  8. package/dist/core-types.js +1 -3
  9. package/dist/core-types.js.map +1 -1
  10. package/dist/generation/compile.d.ts.map +1 -1
  11. package/dist/generation/compile.js +1 -3
  12. package/dist/generation/compile.js.map +1 -1
  13. package/dist/index.d.ts +5 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +13 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/integration/generation-probes.d.ts +36 -2
  18. package/dist/integration/generation-probes.d.ts.map +1 -1
  19. package/dist/integration/generation-probes.js +137 -15
  20. package/dist/integration/generation-probes.js.map +1 -1
  21. package/dist/integration/libreoffice-oracle.d.ts +3 -1
  22. package/dist/integration/libreoffice-oracle.d.ts.map +1 -1
  23. package/dist/integration/libreoffice-oracle.js +10 -4
  24. package/dist/integration/libreoffice-oracle.js.map +1 -1
  25. package/dist/integration/synthetic-docx-fixture.d.ts.map +1 -1
  26. package/dist/integration/synthetic-docx-fixture.js +8 -0
  27. package/dist/integration/synthetic-docx-fixture.js.map +1 -1
  28. package/dist/primitives/accept_ai_edits.js +14 -2
  29. package/dist/primitives/accept_ai_edits.js.map +1 -1
  30. package/dist/primitives/accept_changes.d.ts +6 -0
  31. package/dist/primitives/accept_changes.d.ts.map +1 -1
  32. package/dist/primitives/accept_changes.js +236 -62
  33. package/dist/primitives/accept_changes.js.map +1 -1
  34. package/dist/primitives/bookmarks.d.ts +8 -2
  35. package/dist/primitives/bookmarks.d.ts.map +1 -1
  36. package/dist/primitives/bookmarks.js +31 -7
  37. package/dist/primitives/bookmarks.js.map +1 -1
  38. package/dist/primitives/comments.d.ts +101 -5
  39. package/dist/primitives/comments.d.ts.map +1 -1
  40. package/dist/primitives/comments.js +356 -156
  41. package/dist/primitives/comments.js.map +1 -1
  42. package/dist/primitives/conformance.d.ts +42 -0
  43. package/dist/primitives/conformance.d.ts.map +1 -0
  44. package/dist/primitives/conformance.js +61 -0
  45. package/dist/primitives/conformance.js.map +1 -0
  46. package/dist/primitives/document.d.ts +145 -2
  47. package/dist/primitives/document.d.ts.map +1 -1
  48. package/dist/primitives/document.js +505 -27
  49. package/dist/primitives/document.js.map +1 -1
  50. package/dist/primitives/document_view-headings.d.ts +3 -1
  51. package/dist/primitives/document_view-headings.d.ts.map +1 -1
  52. package/dist/primitives/document_view-headings.js +5 -5
  53. package/dist/primitives/document_view-headings.js.map +1 -1
  54. package/dist/primitives/document_view.d.ts +2 -0
  55. package/dist/primitives/document_view.d.ts.map +1 -1
  56. package/dist/primitives/document_view.js +18 -4
  57. package/dist/primitives/document_view.js.map +1 -1
  58. package/dist/primitives/errors.d.ts +3 -2
  59. package/dist/primitives/errors.d.ts.map +1 -1
  60. package/dist/primitives/errors.js +3 -1
  61. package/dist/primitives/errors.js.map +1 -1
  62. package/dist/primitives/extract_revisions.d.ts +23 -6
  63. package/dist/primitives/extract_revisions.d.ts.map +1 -1
  64. package/dist/primitives/extract_revisions.js +208 -38
  65. package/dist/primitives/extract_revisions.js.map +1 -1
  66. package/dist/primitives/field_evaluation.d.ts +70 -0
  67. package/dist/primitives/field_evaluation.d.ts.map +1 -0
  68. package/dist/primitives/field_evaluation.js +462 -0
  69. package/dist/primitives/field_evaluation.js.map +1 -0
  70. package/dist/primitives/footnotes.d.ts +53 -3
  71. package/dist/primitives/footnotes.d.ts.map +1 -1
  72. package/dist/primitives/footnotes.js +228 -83
  73. package/dist/primitives/footnotes.js.map +1 -1
  74. package/dist/primitives/index.d.ts +12 -1
  75. package/dist/primitives/index.d.ts.map +1 -1
  76. package/dist/primitives/index.js +12 -1
  77. package/dist/primitives/index.js.map +1 -1
  78. package/dist/primitives/merge_runs.d.ts +3 -1
  79. package/dist/primitives/merge_runs.d.ts.map +1 -1
  80. package/dist/primitives/merge_runs.js +12 -2
  81. package/dist/primitives/merge_runs.js.map +1 -1
  82. package/dist/primitives/namespaces.d.ts +10 -2
  83. package/dist/primitives/namespaces.d.ts.map +1 -1
  84. package/dist/primitives/namespaces.js +11 -2
  85. package/dist/primitives/namespaces.js.map +1 -1
  86. package/dist/primitives/note_conversion.d.ts +11 -0
  87. package/dist/primitives/note_conversion.d.ts.map +1 -0
  88. package/dist/primitives/note_conversion.js +12 -0
  89. package/dist/primitives/note_conversion.js.map +1 -0
  90. package/dist/primitives/paragraph-index.d.ts +31 -0
  91. package/dist/primitives/paragraph-index.d.ts.map +1 -0
  92. package/dist/primitives/paragraph-index.js +140 -0
  93. package/dist/primitives/paragraph-index.js.map +1 -0
  94. package/dist/primitives/paragraph_merge_formatting.d.ts +24 -0
  95. package/dist/primitives/paragraph_merge_formatting.d.ts.map +1 -0
  96. package/dist/primitives/paragraph_merge_formatting.js +86 -0
  97. package/dist/primitives/paragraph_merge_formatting.js.map +1 -0
  98. package/dist/primitives/paragraph_numbering.d.ts +40 -0
  99. package/dist/primitives/paragraph_numbering.d.ts.map +1 -0
  100. package/dist/primitives/paragraph_numbering.js +198 -0
  101. package/dist/primitives/paragraph_numbering.js.map +1 -0
  102. package/dist/primitives/paragraph_structure.d.ts +19 -0
  103. package/dist/primitives/paragraph_structure.d.ts.map +1 -0
  104. package/dist/primitives/paragraph_structure.js +66 -0
  105. package/dist/primitives/paragraph_structure.js.map +1 -0
  106. package/dist/primitives/reject_changes.d.ts +6 -0
  107. package/dist/primitives/reject_changes.d.ts.map +1 -1
  108. package/dist/primitives/reject_changes.js +278 -68
  109. package/dist/primitives/reject_changes.js.map +1 -1
  110. package/dist/primitives/relationships.d.ts +22 -0
  111. package/dist/primitives/relationships.d.ts.map +1 -1
  112. package/dist/primitives/relationships.js +87 -0
  113. package/dist/primitives/relationships.js.map +1 -1
  114. package/dist/primitives/revision-parts.d.ts +15 -0
  115. package/dist/primitives/revision-parts.d.ts.map +1 -1
  116. package/dist/primitives/revision-parts.js +33 -1
  117. package/dist/primitives/revision-parts.js.map +1 -1
  118. package/dist/primitives/sectPrAudit.d.ts.map +1 -1
  119. package/dist/primitives/sectPrAudit.js +10 -1
  120. package/dist/primitives/sectPrAudit.js.map +1 -1
  121. package/dist/primitives/sections.d.ts +135 -0
  122. package/dist/primitives/sections.d.ts.map +1 -0
  123. package/dist/primitives/sections.js +644 -0
  124. package/dist/primitives/sections.js.map +1 -0
  125. package/dist/primitives/styles.d.ts +61 -0
  126. package/dist/primitives/styles.d.ts.map +1 -1
  127. package/dist/primitives/styles.js +207 -25
  128. package/dist/primitives/styles.js.map +1 -1
  129. package/dist/primitives/symbol_run_content.d.ts +42 -0
  130. package/dist/primitives/symbol_run_content.d.ts.map +1 -0
  131. package/dist/primitives/symbol_run_content.js +79 -0
  132. package/dist/primitives/symbol_run_content.js.map +1 -0
  133. package/dist/primitives/table_cells.d.ts +49 -0
  134. package/dist/primitives/table_cells.d.ts.map +1 -0
  135. package/dist/primitives/table_cells.js +149 -0
  136. package/dist/primitives/table_cells.js.map +1 -0
  137. package/dist/primitives/table_columns.d.ts +50 -0
  138. package/dist/primitives/table_columns.d.ts.map +1 -0
  139. package/dist/primitives/table_columns.js +138 -0
  140. package/dist/primitives/table_columns.js.map +1 -0
  141. package/dist/primitives/table_edit_common.d.ts +39 -0
  142. package/dist/primitives/table_edit_common.d.ts.map +1 -0
  143. package/dist/primitives/table_edit_common.js +163 -0
  144. package/dist/primitives/table_edit_common.js.map +1 -0
  145. package/dist/primitives/table_occupancy.d.ts +39 -0
  146. package/dist/primitives/table_occupancy.d.ts.map +1 -0
  147. package/dist/primitives/table_occupancy.js +145 -0
  148. package/dist/primitives/table_occupancy.js.map +1 -0
  149. package/dist/primitives/table_rows.d.ts +56 -0
  150. package/dist/primitives/table_rows.d.ts.map +1 -0
  151. package/dist/primitives/table_rows.js +390 -0
  152. package/dist/primitives/table_rows.js.map +1 -0
  153. package/dist/primitives/text.d.ts +69 -1
  154. package/dist/primitives/text.d.ts.map +1 -1
  155. package/dist/primitives/text.js +592 -102
  156. package/dist/primitives/text.js.map +1 -1
  157. package/dist/primitives/track-changes-emitter.d.ts +23 -0
  158. package/dist/primitives/track-changes-emitter.d.ts.map +1 -1
  159. package/dist/primitives/track-changes-emitter.js +64 -1
  160. package/dist/primitives/track-changes-emitter.js.map +1 -1
  161. package/dist/primitives/validate_document.d.ts.map +1 -1
  162. package/dist/primitives/validate_document.js +22 -1
  163. package/dist/primitives/validate_document.js.map +1 -1
  164. package/dist/primitives/zip.d.ts +22 -1
  165. package/dist/primitives/zip.d.ts.map +1 -1
  166. package/dist/primitives/zip.js +47 -8
  167. package/dist/primitives/zip.js.map +1 -1
  168. package/dist/shared/docx/DocxArchive.d.ts +6 -2
  169. package/dist/shared/docx/DocxArchive.d.ts.map +1 -1
  170. package/dist/shared/docx/DocxArchive.js +17 -4
  171. package/dist/shared/docx/DocxArchive.js.map +1 -1
  172. package/dist/shared/field-semantics.d.ts +26 -0
  173. package/dist/shared/field-semantics.d.ts.map +1 -0
  174. package/dist/shared/field-semantics.js +232 -0
  175. package/dist/shared/field-semantics.js.map +1 -0
  176. package/dist/shared/field-structure.d.ts +10 -1
  177. package/dist/shared/field-structure.d.ts.map +1 -1
  178. package/dist/shared/field-structure.js +31 -12
  179. package/dist/shared/field-structure.js.map +1 -1
  180. package/package.json +4 -5
@@ -1,61 +1,28 @@
1
1
  import { OOXML, W } from './namespaces.js';
2
2
  import { SafeDocxError } from './errors.js';
3
- import { getAttributeSafe, getFirstChild } from './xml-helpers.js';
3
+ import { getFirstChild } from './xml-helpers.js';
4
4
  import { buildRPrChangeElement, createRevisionContainer, prepareElementForDeletion, } from './track-changes-emitter.js';
5
+ import { buildParagraphIndex } from './paragraph-index.js';
6
+ import { canSafelyRemoveEmptyParagraph } from './paragraph_structure.js';
7
+ import { SYM_LOCAL_NAME } from './symbol_run_content.js';
8
+ /**
9
+ * Return the paragraph's visible runs while retaining enough complex-field
10
+ * provenance to distinguish a safe cached-result edit from a field-boundary
11
+ * rewrite.
12
+ *
13
+ * @conformance ECMA-376 edition 5, Part 1 § 17.16.18
14
+ * @see #651
15
+ */
5
16
  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 = {}));
12
- function getWAttr(el, localName) {
13
- return getAttributeSafe(el, OOXML.W_NS, localName, 'w');
14
- }
15
- const runs = [];
16
- const rElems = Array.from(p.getElementsByTagNameNS(OOXML.W_NS, W.r));
17
- let state = FieldState.OUTSIDE_FIELD;
18
- for (const r of rElems) {
19
- let runText = '';
20
- let sawResult = state === FieldState.IN_FIELD_RESULT;
21
- // Walk children in order so we can handle rare cases where fldChar and result text
22
- // appear in the same run.
23
- for (const child of Array.from(r.childNodes)) {
24
- if (child.nodeType !== 1)
25
- continue;
26
- const el = child;
27
- if (el.namespaceURI !== OOXML.W_NS)
28
- continue;
29
- if (el.localName === W.fldChar) {
30
- 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;
37
- continue;
38
- }
39
- if (state === FieldState.IN_FIELD_CODE) {
40
- // Skip field code/instruction text.
41
- continue;
42
- }
43
- if (state === FieldState.IN_FIELD_RESULT)
44
- sawResult = true;
45
- if (el.localName === W.t) {
46
- runText += el.textContent ?? '';
47
- }
48
- else if (el.localName === W.tab) {
49
- runText += '\t';
50
- }
51
- else if (el.localName === W.br) {
52
- runText += '\n';
53
- }
54
- }
55
- if (runText)
56
- runs.push({ r, text: runText, isFieldResult: sawResult });
57
- }
58
- return runs;
17
+ return buildParagraphIndex(p).runs
18
+ .filter((run) => run.visibleText.length > 0)
19
+ .map((run) => ({
20
+ r: run.element,
21
+ text: run.visibleText,
22
+ isFieldResult: run.isFieldResult,
23
+ fieldResultId: run.fieldResultId,
24
+ fieldInstruction: run.fieldInstruction,
25
+ }));
59
26
  }
60
27
  export function getParagraphText(p) {
61
28
  return getParagraphRuns(p)
@@ -71,16 +38,24 @@ function findOffsetInRuns(runs, start, end) {
71
38
  let endOffset = 0;
72
39
  for (let i = 0; i < runs.length; i++) {
73
40
  const len = runs[i].text.length;
74
- if (startRunIdx === -1 && start >= pos && start <= pos + len) {
41
+ const nextPos = pos + len;
42
+ const startIsInRun = start === end
43
+ ? start <= nextPos
44
+ : start < nextPos;
45
+ if (startRunIdx === -1 && start >= pos && startIsInRun) {
75
46
  startRunIdx = i;
76
47
  startOffset = start - pos;
77
48
  }
78
- if (endRunIdx === -1 && end >= pos && end <= pos + len) {
49
+ if (endRunIdx === -1 && end > pos && end <= nextPos) {
79
50
  endRunIdx = i;
80
51
  endOffset = end - pos;
81
52
  break;
82
53
  }
83
- pos += len;
54
+ pos = nextPos;
55
+ }
56
+ if (start === end && startRunIdx !== -1) {
57
+ endRunIdx = startRunIdx;
58
+ endOffset = startOffset;
84
59
  }
85
60
  if (startRunIdx === -1 || endRunIdx === -1) {
86
61
  throw new Error('Offset mapping failed');
@@ -121,14 +96,19 @@ function cloneRPrWithoutChangeRecords(doc, rPr) {
121
96
  }
122
97
  return clone;
123
98
  }
124
- function appendTextToRun(doc, run, text) {
99
+ function appendTextToRun(doc, run, text, preserveXmlSpace = false) {
125
100
  // Convert \t and \n to OOXML equivalents where possible.
126
101
  let buf = '';
127
102
  const flush = () => {
128
103
  if (!buf)
129
104
  return;
130
105
  const t = doc.createElementNS(OOXML.W_NS, 'w:t');
131
- setXmlSpacePreserveIfNeeded(t, buf);
106
+ if (preserveXmlSpace) {
107
+ t.setAttributeNS('http://www.w3.org/XML/1998/namespace', 'xml:space', 'preserve');
108
+ }
109
+ else {
110
+ setXmlSpacePreserveIfNeeded(t, buf);
111
+ }
132
112
  t.appendChild(doc.createTextNode(buf));
133
113
  run.appendChild(t);
134
114
  buf = '';
@@ -175,7 +155,7 @@ export function getDirectContentElements(run) {
175
155
  }
176
156
  return out;
177
157
  }
178
- export function splitRunAtVisibleOffset(run, offset) {
158
+ export function splitRunAtVisibleOffset(run, offset, zeroLengthAtOffset = 'right') {
179
159
  const doc = run.ownerDocument;
180
160
  if (!doc)
181
161
  throw new Error('Run has no ownerDocument');
@@ -193,8 +173,9 @@ export function splitRunAtVisibleOffset(run, offset) {
193
173
  const len = visibleLengthForEl(lEl);
194
174
  if (len === 0) {
195
175
  // Zero-length nodes (proofing, field markers, etc.) should not be duplicated. Keep them on the side
196
- // determined by the current visible position.
197
- if (pos < offset)
176
+ // determined by the current visible position; a node exactly at the offset goes to the caller's side.
177
+ const keepLeft = pos < offset || (pos === offset && zeroLengthAtOffset === 'left');
178
+ if (keepLeft)
198
179
  rEl.parentNode?.removeChild(rEl);
199
180
  else
200
181
  lEl.parentNode?.removeChild(lEl);
@@ -261,6 +242,175 @@ function cleanupEmptyRuns(parent) {
261
242
  function getRunVisibleLength(run) {
262
243
  return getDirectContentElements(run).reduce((sum, child) => sum + visibleLengthForEl(child), 0);
263
244
  }
245
+ /** True when the run's first direct content element carries no visible length. */
246
+ function runStartsWithZeroLengthContent(run) {
247
+ const first = getDirectContentElements(run)[0];
248
+ return first !== undefined && visibleLengthForEl(first) === 0;
249
+ }
250
+ /** True when the run's last direct content element carries no visible length. */
251
+ function runEndsWithZeroLengthContent(run) {
252
+ const last = getDirectContentElements(run).at(-1);
253
+ return last !== undefined && visibleLengthForEl(last) === 0;
254
+ }
255
+ /**
256
+ * Zero-visible-length run content that a replaced range deletes with the
257
+ * surrounding text: a `w:sym` symbol character (issue #1044), the
258
+ * `w:fldChar` / `w:instrText` markers of a complex field whose markers sit
259
+ * inside the range (issue #1082: a result-less field such as an `XE` index
260
+ * entry or a `TC` entry — begin, instruction, end, no `separate`), and a
261
+ * footnote or endnote reference mark (issue #1094). A field whose cached
262
+ * result is empty (begin, instruction, separate, end) is also zero-length and
263
+ * is handled the same way. A field with a non-empty cached result never
264
+ * reaches here with its markers in range: the range would include the result
265
+ * text, and that edit is refused earlier.
266
+ *
267
+ * A note reference is deleted with the text, as Word does when a tracked
268
+ * deletion covers it, rather than kept live like a comment reference: the
269
+ * comment's range markers stay live around the edit, so its reference must
270
+ * too (issue #1083), whereas a note is anchored only by its reference, and a
271
+ * caller replacing the text around it has covered it.
272
+ */
273
+ const DELETABLE_ZERO_LENGTH_LOCALS = new Set([
274
+ SYM_LOCAL_NAME,
275
+ W.fldChar,
276
+ W.instrText,
277
+ W.footnoteReference,
278
+ W.endnoteReference,
279
+ ]);
280
+ /**
281
+ * True when a run removed from a replaced range must be kept for `w:del`
282
+ * wrapping. Visible text qualifies, and so does zero-length content the range
283
+ * deletes with the text. A `w:sym` symbol character (a Wingdings checkbox, a
284
+ * bullet) is run content that Word renders as a character, but it contributes
285
+ * no visible length in the paragraph text coordinate space, so the length test
286
+ * alone let a sym-only run be detached and never recorded — an untracked
287
+ * deletion inside a tracked edit (issue #1044). Complex field markers
288
+ * (`w:fldChar`, `w:instrText`) had the same defect (issue #1082): a run
289
+ * holding only a marker was dropped, so reject-all could not restore the
290
+ * field. Recording the run puts it in the same `w:del` as the surrounding
291
+ * text, so accept-all removes it with the text and reject-all restores it in
292
+ * place; the emitter renames a deleted `w:instrText` to `w:delInstrText`.
293
+ * A footnote or endnote reference mark alone in a run — the shape Word
294
+ * writes, with `w:rStyle` FootnoteReference — was dropped the same way
295
+ * (issue #1094); it now joins the `w:del` so reject-all restores the note.
296
+ *
297
+ * @conformance ECMA-376 edition 5, Part 1 § 17.3.3.30
298
+ * @conformance ECMA-376 edition 5, Part 1 § 17.16.18
299
+ * @conformance ECMA-376 edition 5, Part 1 § 17.11.14
300
+ * @conformance ECMA-376 edition 5, Part 1 § 17.13.5.14
301
+ * @see https://github.com/UseJunior/safe-docx/issues/1044
302
+ * @see https://github.com/UseJunior/safe-docx/issues/1082
303
+ * @see https://github.com/UseJunior/safe-docx/issues/1094
304
+ */
305
+ function runCarriesDeletableContent(run) {
306
+ if (getRunVisibleLength(run) > 0)
307
+ return true;
308
+ return getDirectContentElements(run).some((el) => DELETABLE_ZERO_LENGTH_LOCALS.has(el.localName ?? ''));
309
+ }
310
+ // Run content a text replacement keeps live, in place. OOXML embedded content
311
+ // that references package parts: DrawingML drawing (w:drawing), VML picture
312
+ // (w:pict), embedded OLE object (w:object), and imported content part
313
+ // (w:contentPart, a CT_Rel relationship reference) (issue #739). And a comment
314
+ // anchor's reference mark (w:commentReference): deleting it with the text
315
+ // would leave the comment's live w:commentRangeStart/End without a reference
316
+ // after accept-all, or, when it sat alone in its run, drop it untracked
317
+ // (issue #1083). None of these carry visible text length, so a caller-approved
318
+ // text match never covers them — a text replacement must not destroy them.
319
+ const EMBEDDED_CONTENT_LOCALS = new Set([
320
+ W.drawing,
321
+ W.pict,
322
+ W.object,
323
+ W.contentPart,
324
+ W.commentReference,
325
+ ]);
326
+ const FORMAT_RANGE_CONTENT_LOCALS = new Set([
327
+ W.t,
328
+ W.tab,
329
+ W.br,
330
+ 'lastRenderedPageBreak',
331
+ ]);
332
+ function belongsToParagraph(run, paragraph) {
333
+ for (let parent = run.parentNode; parent; parent = parent.parentNode) {
334
+ if (parent.nodeType === 1 && isW(parent, W.p))
335
+ return parent === paragraph;
336
+ }
337
+ return false;
338
+ }
339
+ function isEmbeddedContentElement(node) {
340
+ return (node.nodeType === 1 &&
341
+ node.namespaceURI === OOXML.W_NS &&
342
+ EMBEDDED_CONTENT_LOCALS.has(node.localName ?? ''));
343
+ }
344
+ function getEmbeddedContentElements(run) {
345
+ return getDirectContentElements(run).filter((el) => EMBEDDED_CONTENT_LOCALS.has(el.localName ?? ''));
346
+ }
347
+ /**
348
+ * Change direct run properties over an exact visible-text range while keeping
349
+ * every character and each touched run's undeclared direct properties.
350
+ *
351
+ * The existing bounded replacement engine owns boundary splitting and rejects
352
+ * unsafe container crossings. This wrapper supplies one text-identical part
353
+ * per touched run, so harmless physical fragmentation is retained rather than
354
+ * flattened.
355
+ *
356
+ * @conformance ECMA-376 edition 5, Part 1 § 17.3.2.28
357
+ * @see #998
358
+ */
359
+ export function formatParagraphTextRange(paragraph, start, end, format) {
360
+ const text = getParagraphText(paragraph);
361
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end <= start || end > text.length) {
362
+ throw new SafeDocxError('INVALID_ARGUMENT', 'Formatting range must be a non-empty bounded visible-text interval.');
363
+ }
364
+ let physicalOffset = 0;
365
+ const physicalRuns = Array.from(paragraph.getElementsByTagNameNS(OOXML.W_NS, W.r))
366
+ .filter((run) => belongsToParagraph(run, paragraph));
367
+ for (const run of physicalRuns) {
368
+ const runLength = getRunVisibleLength(run);
369
+ const intersects = runLength > 0
370
+ ? physicalOffset < end && physicalOffset + runLength > start
371
+ : physicalOffset > start && physicalOffset < end;
372
+ if (intersects) {
373
+ const content = Array.from(run.childNodes).filter((child) => child.nodeType === 1 && !isW(child, W.rPr));
374
+ const unsupported = content
375
+ .filter((element) => element.namespaceURI !== OOXML.W_NS
376
+ || !FORMAT_RANGE_CONTENT_LOCALS.has(element.localName ?? ''));
377
+ const isDisposablePaginationCache = content.length > 0
378
+ && content.every((element) => element.localName === 'lastRenderedPageBreak');
379
+ if (unsupported.length > 0 || (runLength === 0 && !isDisposablePaginationCache)) {
380
+ const names = unsupported.length > 0
381
+ ? [...new Set(unsupported.map((element) => element.localName))].sort().join(', ')
382
+ : 'empty run';
383
+ throw new SafeDocxError('UNSUPPORTED_EDIT', `Formatting range intersects unsupported run content: ${names}.`);
384
+ }
385
+ }
386
+ physicalOffset += runLength;
387
+ }
388
+ const runs = getParagraphRuns(paragraph);
389
+ const parts = [];
390
+ let offset = 0;
391
+ for (const run of runs) {
392
+ const runStart = offset;
393
+ const runEnd = offset + run.text.length;
394
+ offset = runEnd;
395
+ const overlapStart = Math.max(start, runStart);
396
+ const overlapEnd = Math.min(end, runEnd);
397
+ if (overlapEnd <= overlapStart)
398
+ continue;
399
+ parts.push({
400
+ text: text.slice(overlapStart, overlapEnd),
401
+ templateRun: run.r,
402
+ preserveXmlSpace: Array.from(run.r.getElementsByTagNameNS(OOXML.W_NS, W.t))
403
+ .some((node) => node.getAttributeNS('http://www.w3.org/XML/1998/namespace', 'space') === 'preserve'),
404
+ addRunProps: {
405
+ ...(format.underline === undefined ? {} : { underline: format.underline === 'none' ? false : format.underline }),
406
+ ...(format.highlight === undefined ? {} : { highlight: format.highlight === 'none' ? false : format.highlight }),
407
+ },
408
+ });
409
+ }
410
+ if (parts.length === 0)
411
+ throw new SafeDocxError('INVALID_ARGUMENT', 'Formatting range does not intersect visible run text.');
412
+ replaceParagraphTextRange(paragraph, start, end, parts);
413
+ }
264
414
  function getDirectChild(parent, localName) {
265
415
  for (const child of Array.from(parent.childNodes)) {
266
416
  if (child.nodeType !== 1)
@@ -271,6 +421,95 @@ function getDirectChild(parent, localName) {
271
421
  }
272
422
  return null;
273
423
  }
424
+ /**
425
+ * Mark the paragraph mark as deleted when a tracked edit removes the
426
+ * paragraph's entire visible payload. The marker belongs in w:pPr/w:rPr;
427
+ * accepting it removes the paragraph break while the run-level w:del removes
428
+ * the old contents.
429
+ *
430
+ * @conformance ECMA-376 edition 5, Part 1 § 17.13.5.15
431
+ * @see https://github.com/UseJunior/safe-docx/issues/741
432
+ */
433
+ function addParagraphMarkDeletion(p, ctx) {
434
+ const doc = p.ownerDocument;
435
+ if (!doc)
436
+ throw new Error('Paragraph has no ownerDocument');
437
+ let pPr = getDirectChild(p, W.pPr);
438
+ if (!pPr) {
439
+ pPr = doc.createElementNS(OOXML.W_NS, `w:${W.pPr}`);
440
+ p.insertBefore(pPr, p.firstChild);
441
+ }
442
+ let rPr = getDirectChild(pPr, W.rPr);
443
+ if (!rPr) {
444
+ rPr = doc.createElementNS(OOXML.W_NS, `w:${W.rPr}`);
445
+ const sectPr = getDirectChild(pPr, 'sectPr');
446
+ const pPrChange = getDirectChild(pPr, 'pPrChange');
447
+ pPr.insertBefore(rPr, sectPr ?? pPrChange);
448
+ }
449
+ if (getDirectChild(rPr, 'del'))
450
+ return;
451
+ const marker = createRevisionContainer(doc, 'del', ctx);
452
+ const insertionMarker = getDirectChild(rPr, 'ins');
453
+ if (insertionMarker) {
454
+ rPr.insertBefore(marker, insertionMarker.nextSibling);
455
+ }
456
+ else {
457
+ rPr.insertBefore(marker, rPr.firstChild);
458
+ }
459
+ }
460
+ /**
461
+ * True when a blanking edit left nothing in the paragraph that renders or
462
+ * anchors: only w:pPr, proofing marks, and runs or hyperlinks whose content
463
+ * was removed. Range markers (bookmarks, comment anchors), embedded content,
464
+ * and every other child count as content.
465
+ *
466
+ * Under tracked changes the blanked text is still in the paragraph, inside
467
+ * the `w:del` the edit emitted, and accept-all removes it; so with
468
+ * `deletionsAreInert` a `w:del` (or `w:moveFrom`) counts as nothing, and a
469
+ * `w:ins` counts as whatever it wraps. The tracked and clean paths then ask
470
+ * the same question of the same paragraph (issue #1098).
471
+ */
472
+ function isParagraphInertAfterBlanking(p, deletionsAreInert = false) {
473
+ const isInert = (el) => {
474
+ if (isW(el, W.pPr) || isW(el, 'proofErr'))
475
+ return true;
476
+ if (deletionsAreInert && (isW(el, W.del) || isW(el, W.moveFrom)))
477
+ return true;
478
+ if (isW(el, W.r)) {
479
+ return Array.from(el.childNodes).every((c) => c.nodeType !== 1 || isW(c, W.rPr));
480
+ }
481
+ if (isW(el, W.hyperlink) || (deletionsAreInert && isW(el, 'ins'))) {
482
+ return Array.from(el.childNodes).every((c) => c.nodeType !== 1 || isInert(c));
483
+ }
484
+ return false;
485
+ };
486
+ return Array.from(p.childNodes).every((c) => c.nodeType !== 1 || isInert(c));
487
+ }
488
+ /**
489
+ * Remove a paragraph that an untracked edit has blanked. Untracked mode has no
490
+ * paragraph-mark revision for a clean save to resolve, so the emptied `w:p`
491
+ * used to survive with its `w:numPr` and render as a bare list label
492
+ * (issue #740). Mirrors the outcome of accepting a paragraph-mark deletion,
493
+ * keeping the tool reference's exclusions: the paragraph stays when it
494
+ * carries section properties, when its parent needs it to remain structurally
495
+ * valid (a table cell's only paragraph, a trailing table), or when it still
496
+ * owns range markers or other non-text content.
497
+ *
498
+ * @see https://github.com/UseJunior/safe-docx/issues/740
499
+ */
500
+ function removeBlankedParagraph(p) {
501
+ const parent = p.parentNode;
502
+ if (!parent)
503
+ return;
504
+ const pPr = getDirectChild(p, W.pPr);
505
+ if (pPr && getDirectChild(pPr, W.sectPr))
506
+ return;
507
+ if (!isParagraphInertAfterBlanking(p))
508
+ return;
509
+ if (!canSafelyRemoveEmptyParagraph(p))
510
+ return;
511
+ parent.removeChild(p);
512
+ }
274
513
  // OOXML on/off toggle properties (ECMA-376 ST_OnOff). Absence of w:val means
275
514
  // "1", and the values "1"/"true"/"on" are equivalent (likewise for the falsy
276
515
  // triple). We normalize so semantically-identical inputs hash the same.
@@ -347,12 +586,15 @@ function ensureBoolProp(doc, rPr, localName, val) {
347
586
  }
348
587
  }
349
588
  function ensureUnderline(doc, rPr, val) {
350
- let el = getFirstChild(rPr, OOXML.W_NS, W.u);
589
+ const underlines = Array.from(rPr.childNodes).filter((child) => child.nodeType === 1 && isW(child, W.u));
351
590
  if (val === false) {
352
- if (el)
353
- el.parentNode?.removeChild(el);
591
+ for (const underline of underlines)
592
+ underline.parentNode?.removeChild(underline);
354
593
  return;
355
594
  }
595
+ let el = underlines[0];
596
+ for (const duplicate of underlines.slice(1))
597
+ duplicate.parentNode?.removeChild(duplicate);
356
598
  if (!el) {
357
599
  el = doc.createElementNS(OOXML.W_NS, `w:${W.u}`);
358
600
  rPr.insertBefore(el, rPr.firstChild);
@@ -428,6 +670,88 @@ function applyRunProps(doc, run, add, clearHighlight) {
428
670
  if (add.fontName !== undefined)
429
671
  ensureFont(doc, rPr, add.fontName);
430
672
  }
673
+ function describeRunContainer(node) {
674
+ if (node.nodeType !== 1)
675
+ return node.nodeName;
676
+ const el = node;
677
+ if (el.namespaceURI === OOXML.W_NS)
678
+ return `w:${el.localName}`;
679
+ return el.tagName;
680
+ }
681
+ function previewContainerText(text, start, end) {
682
+ const value = text.slice(start, end);
683
+ return value.length <= 120 ? value : `${value.slice(0, 117)}...`;
684
+ }
685
+ function getContainerBoundaryError(runs, startRunIdx, endRunIdx, start, end, fullText) {
686
+ const segments = [];
687
+ let runStart = 0;
688
+ for (let i = 0; i < runs.length; i++) {
689
+ const run = runs[i];
690
+ const runEnd = runStart + run.text.length;
691
+ if (i >= startRunIdx && i <= endRunIdx) {
692
+ const overlapStart = Math.max(start, runStart);
693
+ const overlapEnd = Math.min(end, runEnd);
694
+ if (overlapEnd > overlapStart) {
695
+ const parent = run.r.parentNode;
696
+ if (!parent)
697
+ throw new Error('Run has no parent');
698
+ const previous = segments.at(-1);
699
+ if (previous?.parent === parent && previous.end === overlapStart) {
700
+ previous.end = overlapEnd;
701
+ }
702
+ else {
703
+ segments.push({ parent, start: overlapStart, end: overlapEnd });
704
+ }
705
+ }
706
+ }
707
+ runStart = runEnd;
708
+ }
709
+ if (segments.length <= 1)
710
+ return null;
711
+ const first = segments[0];
712
+ const second = segments[1];
713
+ const largest = segments.reduce((best, segment) => segment.end - segment.start > best.end - best.start ? segment : best);
714
+ const boundaryOffset = first.end;
715
+ const firstContainer = describeRunContainer(first.parent);
716
+ const secondContainer = describeRunContainer(second.parent);
717
+ const largestContainer = describeRunContainer(largest.parent);
718
+ const preview = previewContainerText(fullText, largest.start, largest.end);
719
+ return new SafeDocxError('UNSAFE_CONTAINER_BOUNDARY', `Edit range [${start}, ${end}) crosses a container boundary at offset ${boundaryOffset} ` +
720
+ `(${firstContainer} → ${secondContainer}). Largest contained sub-span: ` +
721
+ `[${largest.start}, ${largest.end}) in ${largestContainer}, ${JSON.stringify(preview)}.`, `Retry with old_string limited to one container, such as the text in range ` +
722
+ `[${largest.start}, ${largest.end}), or make separate edits on each side of offset ${boundaryOffset}.`);
723
+ }
724
+ /**
725
+ * Replace a visible paragraph range while keeping tracked runs inside their
726
+ * existing run container. In particular, edits wholly inside `w:hyperlink`
727
+ * remain nested there, while cross-container ranges are refused before the
728
+ * DOM is changed.
729
+ *
730
+ * Embedded content (`w:drawing`, `w:pict`, `w:object`, `w:contentPart`)
731
+ * inside the replaced range is preserved as live runs: it contributes no
732
+ * visible text, so no text match ever covers it and no text edit may delete
733
+ * it. Tracked deletions around preserved content are emitted as in-place
734
+ * segments so rejectChanges() restores the original content order exactly.
735
+ * A comment's reference mark is preserved the same way, and zero-length
736
+ * sibling markers inside the range (comment range start/end, a result-less
737
+ * `w:fldSimple`, bookmarks) stay live where they are, with the deletion split
738
+ * around them, so reject-all equals the original paragraph.
739
+ *
740
+ * Blanking the whole paragraph deletes its paragraph mark (tracked) or
741
+ * removes the paragraph (untracked) only when nothing that renders or
742
+ * anchors is left: embedded content, a bookmark or a comment range keeps the
743
+ * paragraph in both modes, so accept-all of the tracked edit equals the
744
+ * untracked edit and a bookmark never disappears.
745
+ *
746
+ * @conformance ECMA-376 edition 5, Part 1 § 17.13.5.14
747
+ * @conformance ECMA-376 edition 5, Part 1 § 17.13.5.15
748
+ * @conformance ECMA-376 edition 5, Part 1 § 17.16.22
749
+ * @see #652
750
+ * @see #741
751
+ * @see #739
752
+ * @see #1083
753
+ * @see #1098
754
+ */
431
755
  export function replaceParagraphTextRange(p, start, end, replacement, ctx) {
432
756
  // Replace visible text in [start, end) in paragraph by operating on w:t nodes.
433
757
  // Strategy:
@@ -447,12 +771,29 @@ export function replaceParagraphTextRange(p, start, end, replacement, ctx) {
447
771
  const { startRunIdx, startOffset, endRunIdx, endOffset } = findOffsetInRuns(runs, start, end);
448
772
  const startRun = runs[startRunIdx];
449
773
  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
- }
774
+ const containerBoundaryError = getContainerBoundaryError(runs, startRunIdx, endRunIdx, start, end, fullText);
775
+ if (containerBoundaryError)
776
+ throw containerBoundaryError;
777
+ // A cached result may span many runs, but every touched run must belong to
778
+ // the same complex field. Moving ordinary text, a field marker, or another
779
+ // field's result across a fldChar boundary would change document semantics.
780
+ const spanRuns = runs.slice(startRunIdx, endRunIdx + 1);
781
+ const fieldRuns = spanRuns.filter((run) => run.isFieldResult);
782
+ if (fieldRuns.length > 0) {
783
+ const fieldIds = new Set(fieldRuns.map((run) => run.fieldResultId));
784
+ const containsInlineFieldMarker = fieldRuns.some((run) => getDirectContentElements(run.r).some((el) => isW(el, W.fldChar)));
785
+ const isOneCompleteResultSpan = fieldRuns.length === spanRuns.length &&
786
+ [...fieldIds].every((fieldId) => typeof fieldId === 'number') &&
787
+ fieldIds.size === 1 &&
788
+ !containsInlineFieldMarker;
789
+ if (!isOneCompleteResultSpan) {
790
+ const instructions = new Set(fieldRuns
791
+ .map((run) => run.fieldInstruction?.split(/\s+/u)[0])
792
+ .filter((instruction) => !!instruction));
793
+ const fieldLabel = instructions.size === 1
794
+ ? `${[...instructions][0]} field result`
795
+ : 'complex field result';
796
+ 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
797
  }
457
798
  }
458
799
  // Pick a template run from the span: the run with the largest overlap by visible character count.
@@ -470,56 +811,184 @@ export function replaceParagraphTextRange(p, start, end, replacement, ctx) {
470
811
  }
471
812
  const parts = typeof replacement === 'string' ? [{ text: replacement }] : replacement;
472
813
  // Split boundary runs so we can remove whole runs cleanly.
814
+ //
815
+ // Zero-length content at a range boundary — a result-less field's markers,
816
+ // a note reference, a proofing mark — that shares a run with the matched
817
+ // text stays outside the range: the caller's match never covered it. So a
818
+ // boundary run is split even when the range starts at its first visible
819
+ // character or ends at its last one, whenever zero-length content sits at
820
+ // that edge, and a split puts content exactly at the offset on the side
821
+ // away from the range (issue #1096). Only content between the first and
822
+ // last matched characters is removed with the text (issue #1082).
473
823
  let rangeStartRunEl = startRun.r;
474
824
  let rangeEndRunEl = endRun.r;
825
+ const splitStart = (run) => splitRunAtVisibleOffset(run, startOffset, 'left').right;
826
+ const splitEnd = (run) => splitRunAtVisibleOffset(run, endOffset, 'right').left;
827
+ const needsStartSplit = startOffset > 0 || runStartsWithZeroLengthContent(startRun.r);
828
+ const needsEndSplit = endOffset < endRun.text.length || runEndsWithZeroLengthContent(endRun.r);
475
829
  if (startRunIdx === endRunIdx) {
476
830
  // Single-run replacement: split end first, then start.
477
- const runLen = startRun.text.length;
478
- if (endOffset < runLen) {
479
- const { left } = splitRunAtVisibleOffset(rangeStartRunEl, endOffset);
480
- rangeStartRunEl = left;
481
- rangeEndRunEl = left;
831
+ if (needsEndSplit) {
832
+ rangeStartRunEl = splitEnd(rangeStartRunEl);
833
+ rangeEndRunEl = rangeStartRunEl;
482
834
  }
483
- if (startOffset > 0) {
484
- const { right } = splitRunAtVisibleOffset(rangeStartRunEl, startOffset);
485
- rangeStartRunEl = right;
486
- rangeEndRunEl = right;
835
+ if (needsStartSplit) {
836
+ rangeStartRunEl = splitStart(rangeStartRunEl);
837
+ rangeEndRunEl = rangeStartRunEl;
487
838
  }
488
839
  }
489
840
  else {
490
841
  // Multi-run replacement: split start then end.
491
- if (startOffset > 0) {
492
- const { right } = splitRunAtVisibleOffset(rangeStartRunEl, startOffset);
493
- rangeStartRunEl = right;
494
- }
495
- const endLen = endRun.text.length;
496
- if (endOffset < endLen) {
497
- const { left } = splitRunAtVisibleOffset(rangeEndRunEl, endOffset);
498
- rangeEndRunEl = left;
499
- }
842
+ if (needsStartSplit)
843
+ rangeStartRunEl = splitStart(rangeStartRunEl);
844
+ if (needsEndSplit)
845
+ rangeEndRunEl = splitEnd(rangeEndRunEl);
500
846
  }
501
847
  const parent = rangeStartRunEl.parentNode;
502
848
  if (!parent)
503
849
  throw new Error('Run has no parent');
504
850
  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.');
851
+ throw new Error('Container boundary changed while splitting replacement runs');
506
852
  }
507
853
  const insertBeforeNode = rangeEndRunEl.nextSibling;
508
854
  // Remove runs in [rangeStartRunEl, rangeEndRunEl] inclusive (only w:r elements).
855
+ //
856
+ // Embedded content (w:drawing / w:pict / w:object / w:contentPart) has zero
857
+ // visible text length, so the text match the caller approved never covered
858
+ // it. It must survive the replacement as live content (issue #739): an
859
+ // embedded-only run stays in place untouched, and embedded content
860
+ // co-resident with replaced text is split into its own run at the same
861
+ // relative position before the text is removed. Without this, an
862
+ // embedded-only run would be detached here but never recorded
863
+ // (getRunVisibleLength returns 0 for it), so neither the tracked nor the
864
+ // clean branch could re-emit it.
865
+ //
866
+ // When embedded content is preserved, tracked deletions are emitted as
867
+ // IN-PLACE SEGMENTS — one w:del per contiguous stretch of removed text,
868
+ // inserted at that stretch's original position — instead of one terminal
869
+ // w:del after the range. A single terminal w:del would make rejectChanges()
870
+ // restore the removed text AFTER the preserved object, permanently
871
+ // reordering content the user never touched. When no embedded content is
872
+ // involved the historical single-deletion emission is kept unchanged.
873
+ //
874
+ // Zero-length markers that are siblings of the removed runs — comment range
875
+ // markers (w:commentRangeStart / w:commentRangeEnd), a result-less
876
+ // w:fldSimple, bookmarks — are never removed, but a single terminal w:del
877
+ // would still move the removed text past them, so reject-all would restore
878
+ // the text on the wrong side of each marker (issue #1083). They take the
879
+ // in-place segment emission too, and each one closes the open segment.
880
+ let rangeContainsEmbeddedContent = false;
881
+ for (let node = rangeStartRunEl; node; node = node.nextSibling) {
882
+ if (node.nodeType === 1 &&
883
+ (!isW(node, W.r) || getEmbeddedContentElements(node).length > 0)) {
884
+ rangeContainsEmbeddedContent = true;
885
+ }
886
+ if (node === rangeEndRunEl)
887
+ break;
888
+ }
509
889
  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);
890
+ let preservedEmbeddedContent = false;
891
+ if (!rangeContainsEmbeddedContent) {
892
+ let cur = rangeStartRunEl;
893
+ while (cur) {
894
+ const nextNode = cur.nextSibling;
895
+ if (cur.nodeType === 1 && isW(cur, W.r)) {
896
+ const runEl = cur;
897
+ runEl.parentNode?.removeChild(runEl);
898
+ if (runCarriesDeletableContent(runEl)) {
899
+ removedRuns.push(runEl);
900
+ }
518
901
  }
902
+ if (cur === rangeEndRunEl)
903
+ break;
904
+ cur = nextNode;
905
+ }
906
+ }
907
+ else {
908
+ // The open deletion segment; reset to null whenever preserved embedded
909
+ // content interrupts the removed stretch so the next removed run starts
910
+ // a new w:del at its own position.
911
+ let currentDeletion = null;
912
+ const removeRunInPlace = (runEl) => {
913
+ const parentNode = runEl.parentNode;
914
+ if (!parentNode)
915
+ return;
916
+ if (ctx && runCarriesDeletableContent(runEl)) {
917
+ if (!currentDeletion) {
918
+ currentDeletion = createRevisionContainer(doc, 'del', ctx);
919
+ parentNode.insertBefore(currentDeletion, runEl);
920
+ }
921
+ parentNode.removeChild(runEl);
922
+ currentDeletion.appendChild(prepareElementForDeletion(runEl));
923
+ }
924
+ else {
925
+ parentNode.removeChild(runEl);
926
+ }
927
+ };
928
+ // Split a run holding both text and embedded content into consecutive
929
+ // homogeneous runs (formatting cloned), inserted at the run's position in
930
+ // original child order, so both the preserved content and the removed
931
+ // text keep their exact relative positions.
932
+ const splitMixedRun = (runEl) => {
933
+ const parentNode = runEl.parentNode;
934
+ if (!parentNode)
935
+ throw new Error('Run has no parent');
936
+ const pieces = [];
937
+ let currentPiece = null;
938
+ for (const child of Array.from(runEl.childNodes)) {
939
+ if (child.nodeType === 1 && isW(child, W.rPr))
940
+ continue;
941
+ const embedded = isEmbeddedContentElement(child);
942
+ if (!currentPiece || currentPiece.embedded !== embedded) {
943
+ currentPiece = { el: cloneRunFormattingOnly(doc, runEl), embedded };
944
+ pieces.push(currentPiece);
945
+ }
946
+ currentPiece.el.appendChild(child);
947
+ }
948
+ for (const piece of pieces)
949
+ parentNode.insertBefore(piece.el, runEl);
950
+ parentNode.removeChild(runEl);
951
+ return pieces;
952
+ };
953
+ let cur = rangeStartRunEl;
954
+ while (cur) {
955
+ const nextNode = cur.nextSibling;
956
+ const atRangeEnd = cur === rangeEndRunEl;
957
+ if (cur.nodeType === 1 && !isW(cur, W.r)) {
958
+ // A sibling marker stays where it is; the next removed run opens a
959
+ // new w:del after it (issue #1083).
960
+ currentDeletion = null;
961
+ }
962
+ else if (cur.nodeType === 1) {
963
+ const runEl = cur;
964
+ const embeddedContent = getEmbeddedContentElements(runEl);
965
+ if (embeddedContent.length === 0) {
966
+ removeRunInPlace(runEl);
967
+ }
968
+ else if (!runCarriesDeletableContent(runEl)) {
969
+ // Embedded-only run: the replaced text lives entirely in sibling
970
+ // runs. Leave it in the paragraph as-is. A run that also holds a
971
+ // w:sym is mixed, not embedded-only: the split below keeps the
972
+ // embedded content live and deletes the symbol with the range.
973
+ preservedEmbeddedContent = true;
974
+ currentDeletion = null;
975
+ }
976
+ else {
977
+ for (const piece of splitMixedRun(runEl)) {
978
+ if (piece.embedded) {
979
+ preservedEmbeddedContent = true;
980
+ currentDeletion = null;
981
+ }
982
+ else {
983
+ removeRunInPlace(piece.el);
984
+ }
985
+ }
986
+ }
987
+ }
988
+ if (atRangeEnd)
989
+ break;
990
+ cur = nextNode;
519
991
  }
520
- if (cur === rangeEndRunEl)
521
- break;
522
- cur = nextNode;
523
992
  }
524
993
  // Build replacement runs using the same formatting/template logic as the legacy path.
525
994
  const replacementRuns = [];
@@ -534,7 +1003,7 @@ export function replaceParagraphTextRange(p, start, end, replacement, ctx) {
534
1003
  if (ctx && hasExplicitFormattingMutation && rPrComparableSignature(newRPr) !== sourceRPrSignature) {
535
1004
  ensureRPr(doc, newRun).appendChild(buildRPrChangeElement(getSnapshotRPr(doc, sourceRPr), ctx));
536
1005
  }
537
- appendTextToRun(doc, newRun, part.text);
1006
+ appendTextToRun(doc, newRun, part.text, part.preserveXmlSpace);
538
1007
  if (getRunVisibleLength(newRun) > 0) {
539
1008
  replacementRuns.push(newRun);
540
1009
  }
@@ -554,6 +1023,21 @@ export function replaceParagraphTextRange(p, start, end, replacement, ctx) {
554
1023
  }
555
1024
  parent.insertBefore(insertion, insertBeforeNode);
556
1025
  }
1026
+ // Only delete the paragraph mark when the edit leaves nothing live behind.
1027
+ // Preserved embedded content keeps the paragraph meaningful, and merging
1028
+ // it into the following paragraph on accept would move the image the user
1029
+ // never touched (issue #739). Range markers the paragraph still carries —
1030
+ // a bookmark, a comment anchor — keep it the same way: accepting a
1031
+ // deleted mark would merge the comment's markers into the next paragraph,
1032
+ // or drop a bookmark whose whole content was deleted and with it a
1033
+ // cross-reference target, while the untracked edit below keeps the
1034
+ // paragraph with its markers. Tracked and clean must agree on whether the
1035
+ // paragraph survives, so this asks the same question removeBlankedParagraph
1036
+ // asks (issue #1098).
1037
+ if (start === 0 && end === fullText.length && replacementRuns.length === 0 && !preservedEmbeddedContent &&
1038
+ isParagraphInertAfterBlanking(p, true)) {
1039
+ addParagraphMarkDeletion(p, ctx);
1040
+ }
557
1041
  }
558
1042
  else {
559
1043
  for (const replacementRun of replacementRuns) {
@@ -561,5 +1045,11 @@ export function replaceParagraphTextRange(p, start, end, replacement, ctx) {
561
1045
  }
562
1046
  }
563
1047
  cleanupEmptyRuns(parent);
1048
+ // Untracked counterpart of the paragraph-mark deletion above: with no
1049
+ // revision for a clean save to resolve, the emptied paragraph is removed
1050
+ // here so it cannot survive as a bare list label (issue #740).
1051
+ if (!ctx && start === 0 && end === fullText.length && replacementRuns.length === 0 && !preservedEmbeddedContent) {
1052
+ removeBlankedParagraph(p);
1053
+ }
564
1054
  }
565
1055
  //# sourceMappingURL=text.js.map