@usejunior/docx-core 0.20.1 → 0.21.3

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 (125) 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/generation/compile.d.ts.map +1 -1
  7. package/dist/generation/compile.js +1 -3
  8. package/dist/generation/compile.js.map +1 -1
  9. package/dist/index.d.ts +2 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +2 -1
  12. package/dist/index.js.map +1 -1
  13. package/dist/integration/libreoffice-oracle.d.ts +3 -1
  14. package/dist/integration/libreoffice-oracle.d.ts.map +1 -1
  15. package/dist/integration/libreoffice-oracle.js +9 -3
  16. package/dist/integration/libreoffice-oracle.js.map +1 -1
  17. package/dist/primitives/accept_changes.d.ts +3 -4
  18. package/dist/primitives/accept_changes.d.ts.map +1 -1
  19. package/dist/primitives/accept_changes.js +165 -79
  20. package/dist/primitives/accept_changes.js.map +1 -1
  21. package/dist/primitives/bookmarks.d.ts +7 -2
  22. package/dist/primitives/bookmarks.d.ts.map +1 -1
  23. package/dist/primitives/bookmarks.js +30 -6
  24. package/dist/primitives/bookmarks.js.map +1 -1
  25. package/dist/primitives/comments.d.ts +73 -4
  26. package/dist/primitives/comments.d.ts.map +1 -1
  27. package/dist/primitives/comments.js +266 -165
  28. package/dist/primitives/comments.js.map +1 -1
  29. package/dist/primitives/conformance.d.ts +42 -0
  30. package/dist/primitives/conformance.d.ts.map +1 -0
  31. package/dist/primitives/conformance.js +61 -0
  32. package/dist/primitives/conformance.js.map +1 -0
  33. package/dist/primitives/document.d.ts +98 -1
  34. package/dist/primitives/document.d.ts.map +1 -1
  35. package/dist/primitives/document.js +315 -22
  36. package/dist/primitives/document.js.map +1 -1
  37. package/dist/primitives/errors.d.ts +3 -2
  38. package/dist/primitives/errors.d.ts.map +1 -1
  39. package/dist/primitives/errors.js +3 -1
  40. package/dist/primitives/errors.js.map +1 -1
  41. package/dist/primitives/extract_revisions.d.ts +23 -6
  42. package/dist/primitives/extract_revisions.d.ts.map +1 -1
  43. package/dist/primitives/extract_revisions.js +208 -38
  44. package/dist/primitives/extract_revisions.js.map +1 -1
  45. package/dist/primitives/footnotes.d.ts +36 -0
  46. package/dist/primitives/footnotes.d.ts.map +1 -1
  47. package/dist/primitives/footnotes.js +173 -75
  48. package/dist/primitives/footnotes.js.map +1 -1
  49. package/dist/primitives/index.d.ts +7 -1
  50. package/dist/primitives/index.d.ts.map +1 -1
  51. package/dist/primitives/index.js +7 -1
  52. package/dist/primitives/index.js.map +1 -1
  53. package/dist/primitives/paragraph-index.d.ts +31 -0
  54. package/dist/primitives/paragraph-index.d.ts.map +1 -0
  55. package/dist/primitives/paragraph-index.js +140 -0
  56. package/dist/primitives/paragraph-index.js.map +1 -0
  57. package/dist/primitives/paragraph_merge_formatting.d.ts +24 -0
  58. package/dist/primitives/paragraph_merge_formatting.d.ts.map +1 -0
  59. package/dist/primitives/paragraph_merge_formatting.js +86 -0
  60. package/dist/primitives/paragraph_merge_formatting.js.map +1 -0
  61. package/dist/primitives/paragraph_structure.d.ts +19 -0
  62. package/dist/primitives/paragraph_structure.d.ts.map +1 -0
  63. package/dist/primitives/paragraph_structure.js +66 -0
  64. package/dist/primitives/paragraph_structure.js.map +1 -0
  65. package/dist/primitives/reject_changes.d.ts +3 -4
  66. package/dist/primitives/reject_changes.d.ts.map +1 -1
  67. package/dist/primitives/reject_changes.js +166 -79
  68. package/dist/primitives/reject_changes.js.map +1 -1
  69. package/dist/primitives/relationships.d.ts +22 -0
  70. package/dist/primitives/relationships.d.ts.map +1 -1
  71. package/dist/primitives/relationships.js +87 -0
  72. package/dist/primitives/relationships.js.map +1 -1
  73. package/dist/primitives/revision-parts.d.ts +15 -0
  74. package/dist/primitives/revision-parts.d.ts.map +1 -1
  75. package/dist/primitives/revision-parts.js +33 -1
  76. package/dist/primitives/revision-parts.js.map +1 -1
  77. package/dist/primitives/sections.d.ts +2 -1
  78. package/dist/primitives/sections.d.ts.map +1 -1
  79. package/dist/primitives/sections.js +2 -2
  80. package/dist/primitives/sections.js.map +1 -1
  81. package/dist/primitives/styles.d.ts +1 -0
  82. package/dist/primitives/styles.d.ts.map +1 -1
  83. package/dist/primitives/styles.js +1 -0
  84. package/dist/primitives/styles.js.map +1 -1
  85. package/dist/primitives/table_cells.d.ts +49 -0
  86. package/dist/primitives/table_cells.d.ts.map +1 -0
  87. package/dist/primitives/table_cells.js +149 -0
  88. package/dist/primitives/table_cells.js.map +1 -0
  89. package/dist/primitives/table_columns.d.ts +50 -0
  90. package/dist/primitives/table_columns.d.ts.map +1 -0
  91. package/dist/primitives/table_columns.js +138 -0
  92. package/dist/primitives/table_columns.js.map +1 -0
  93. package/dist/primitives/table_edit_common.d.ts +39 -0
  94. package/dist/primitives/table_edit_common.d.ts.map +1 -0
  95. package/dist/primitives/table_edit_common.js +163 -0
  96. package/dist/primitives/table_edit_common.js.map +1 -0
  97. package/dist/primitives/table_occupancy.d.ts +39 -0
  98. package/dist/primitives/table_occupancy.d.ts.map +1 -0
  99. package/dist/primitives/table_occupancy.js +145 -0
  100. package/dist/primitives/table_occupancy.js.map +1 -0
  101. package/dist/primitives/table_rows.d.ts +56 -0
  102. package/dist/primitives/table_rows.d.ts.map +1 -0
  103. package/dist/primitives/table_rows.js +390 -0
  104. package/dist/primitives/table_rows.js.map +1 -0
  105. package/dist/primitives/text.d.ts +40 -1
  106. package/dist/primitives/text.d.ts.map +1 -1
  107. package/dist/primitives/text.js +309 -129
  108. package/dist/primitives/text.js.map +1 -1
  109. package/dist/primitives/track-changes-emitter.d.ts +16 -2
  110. package/dist/primitives/track-changes-emitter.d.ts.map +1 -1
  111. package/dist/primitives/track-changes-emitter.js +38 -13
  112. package/dist/primitives/track-changes-emitter.js.map +1 -1
  113. package/dist/primitives/zip.d.ts +22 -1
  114. package/dist/primitives/zip.d.ts.map +1 -1
  115. package/dist/primitives/zip.js +47 -8
  116. package/dist/primitives/zip.js.map +1 -1
  117. package/dist/shared/docx/DocxArchive.d.ts +6 -2
  118. package/dist/shared/docx/DocxArchive.d.ts.map +1 -1
  119. package/dist/shared/docx/DocxArchive.js +10 -4
  120. package/dist/shared/docx/DocxArchive.js.map +1 -1
  121. package/dist/shared/field-structure.d.ts +3 -0
  122. package/dist/shared/field-structure.d.ts.map +1 -1
  123. package/dist/shared/field-structure.js +7 -5
  124. package/dist/shared/field-structure.js.map +1 -1
  125. package/package.json +3 -4
@@ -1,7 +1,10 @@
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';
5
8
  /**
6
9
  * Return the paragraph's visible runs while retaining enough complex-field
7
10
  * provenance to distinguish a safe cached-result edit from a field-boundary
@@ -11,93 +14,14 @@ import { buildRPrChangeElement, createRevisionContainer, prepareElementForDeleti
11
14
  * @see #651
12
15
  */
13
16
  export function getParagraphRuns(p) {
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
- }
19
- function getWAttr(el, localName) {
20
- return getAttributeSafe(el, OOXML.W_NS, localName, 'w');
21
- }
22
- const runs = [];
23
- const rElems = Array.from(p.getElementsByTagNameNS(OOXML.W_NS, W.r));
24
- const fieldStack = [];
25
- const fieldInstructions = new Map();
26
- let nextFieldId = 1;
27
- for (const r of rElems) {
28
- let runText = '';
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
- };
44
- // Walk children in order so we can handle rare cases where fldChar and result text
45
- // appear in the same run.
46
- for (const child of Array.from(r.childNodes)) {
47
- if (child.nodeType !== 1)
48
- continue;
49
- const el = child;
50
- if (el.namespaceURI !== OOXML.W_NS)
51
- continue;
52
- if (el.localName === W.fldChar) {
53
- const typ = getWAttr(el, 'fldCharType') ?? '';
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
- }
67
- continue;
68
- }
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
- }
74
- // Skip field code/instruction text.
75
- continue;
76
- }
77
- if (el.localName === W.t) {
78
- appendVisibleText(el.textContent ?? '');
79
- }
80
- else if (el.localName === W.tab) {
81
- appendVisibleText('\t');
82
- }
83
- else if (el.localName === W.br) {
84
- appendVisibleText('\n');
85
- }
86
- }
87
- if (runText) {
88
- runs.push({
89
- r,
90
- text: runText,
91
- isFieldResult: sawResult,
92
- fieldResultId: runFieldResultId ?? null,
93
- });
94
- }
95
- }
96
- return runs.map((run) => ({
97
- ...run,
98
- fieldInstruction: run.fieldResultId === null
99
- ? null
100
- : (fieldInstructions.get(run.fieldResultId) ?? null),
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,
101
25
  }));
102
26
  }
103
27
  export function getParagraphText(p) {
@@ -172,14 +96,19 @@ function cloneRPrWithoutChangeRecords(doc, rPr) {
172
96
  }
173
97
  return clone;
174
98
  }
175
- function appendTextToRun(doc, run, text) {
99
+ function appendTextToRun(doc, run, text, preserveXmlSpace = false) {
176
100
  // Convert \t and \n to OOXML equivalents where possible.
177
101
  let buf = '';
178
102
  const flush = () => {
179
103
  if (!buf)
180
104
  return;
181
105
  const t = doc.createElementNS(OOXML.W_NS, 'w:t');
182
- 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
+ }
183
112
  t.appendChild(doc.createTextNode(buf));
184
113
  run.appendChild(t);
185
114
  buf = '';
@@ -226,7 +155,7 @@ export function getDirectContentElements(run) {
226
155
  }
227
156
  return out;
228
157
  }
229
- export function splitRunAtVisibleOffset(run, offset) {
158
+ export function splitRunAtVisibleOffset(run, offset, zeroLengthAtOffset = 'right') {
230
159
  const doc = run.ownerDocument;
231
160
  if (!doc)
232
161
  throw new Error('Run has no ownerDocument');
@@ -244,8 +173,9 @@ export function splitRunAtVisibleOffset(run, offset) {
244
173
  const len = visibleLengthForEl(lEl);
245
174
  if (len === 0) {
246
175
  // Zero-length nodes (proofing, field markers, etc.) should not be duplicated. Keep them on the side
247
- // determined by the current visible position.
248
- 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)
249
179
  rEl.parentNode?.removeChild(rEl);
250
180
  else
251
181
  lEl.parentNode?.removeChild(lEl);
@@ -312,17 +242,100 @@ function cleanupEmptyRuns(parent) {
312
242
  function getRunVisibleLength(run) {
313
243
  return getDirectContentElements(run).reduce((sum, child) => sum + visibleLengthForEl(child), 0);
314
244
  }
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).
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.
320
319
  const EMBEDDED_CONTENT_LOCALS = new Set([
321
320
  W.drawing,
322
321
  W.pict,
323
322
  W.object,
324
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',
325
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
+ }
326
339
  function isEmbeddedContentElement(node) {
327
340
  return (node.nodeType === 1 &&
328
341
  node.namespaceURI === OOXML.W_NS &&
@@ -331,6 +344,73 @@ function isEmbeddedContentElement(node) {
331
344
  function getEmbeddedContentElements(run) {
332
345
  return getDirectContentElements(run).filter((el) => EMBEDDED_CONTENT_LOCALS.has(el.localName ?? ''));
333
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
+ }
334
414
  function getDirectChild(parent, localName) {
335
415
  for (const child of Array.from(parent.childNodes)) {
336
416
  if (child.nodeType !== 1)
@@ -377,6 +457,59 @@ function addParagraphMarkDeletion(p, ctx) {
377
457
  rPr.insertBefore(marker, rPr.firstChild);
378
458
  }
379
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
+ }
380
513
  // OOXML on/off toggle properties (ECMA-376 ST_OnOff). Absence of w:val means
381
514
  // "1", and the values "1"/"true"/"on" are equivalent (likewise for the falsy
382
515
  // triple). We normalize so semantically-identical inputs hash the same.
@@ -453,12 +586,15 @@ function ensureBoolProp(doc, rPr, localName, val) {
453
586
  }
454
587
  }
455
588
  function ensureUnderline(doc, rPr, val) {
456
- 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));
457
590
  if (val === false) {
458
- if (el)
459
- el.parentNode?.removeChild(el);
591
+ for (const underline of underlines)
592
+ underline.parentNode?.removeChild(underline);
460
593
  return;
461
594
  }
595
+ let el = underlines[0];
596
+ for (const duplicate of underlines.slice(1))
597
+ duplicate.parentNode?.removeChild(duplicate);
462
598
  if (!el) {
463
599
  el = doc.createElementNS(OOXML.W_NS, `w:${W.u}`);
464
600
  rPr.insertBefore(el, rPr.firstChild);
@@ -596,6 +732,16 @@ function getContainerBoundaryError(runs, startRunIdx, endRunIdx, start, end, ful
596
732
  * visible text, so no text match ever covers it and no text edit may delete
597
733
  * it. Tracked deletions around preserved content are emitted as in-place
598
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.
599
745
  *
600
746
  * @conformance ECMA-376 edition 5, Part 1 § 17.13.5.14
601
747
  * @conformance ECMA-376 edition 5, Part 1 § 17.13.5.15
@@ -603,6 +749,8 @@ function getContainerBoundaryError(runs, startRunIdx, endRunIdx, start, end, ful
603
749
  * @see #652
604
750
  * @see #741
605
751
  * @see #739
752
+ * @see #1083
753
+ * @see #1098
606
754
  */
607
755
  export function replaceParagraphTextRange(p, start, end, replacement, ctx) {
608
756
  // Replace visible text in [start, end) in paragraph by operating on w:t nodes.
@@ -663,33 +811,38 @@ export function replaceParagraphTextRange(p, start, end, replacement, ctx) {
663
811
  }
664
812
  const parts = typeof replacement === 'string' ? [{ text: replacement }] : replacement;
665
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).
666
823
  let rangeStartRunEl = startRun.r;
667
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);
668
829
  if (startRunIdx === endRunIdx) {
669
830
  // Single-run replacement: split end first, then start.
670
- const runLen = startRun.text.length;
671
- if (endOffset < runLen) {
672
- const { left } = splitRunAtVisibleOffset(rangeStartRunEl, endOffset);
673
- rangeStartRunEl = left;
674
- rangeEndRunEl = left;
831
+ if (needsEndSplit) {
832
+ rangeStartRunEl = splitEnd(rangeStartRunEl);
833
+ rangeEndRunEl = rangeStartRunEl;
675
834
  }
676
- if (startOffset > 0) {
677
- const { right } = splitRunAtVisibleOffset(rangeStartRunEl, startOffset);
678
- rangeStartRunEl = right;
679
- rangeEndRunEl = right;
835
+ if (needsStartSplit) {
836
+ rangeStartRunEl = splitStart(rangeStartRunEl);
837
+ rangeEndRunEl = rangeStartRunEl;
680
838
  }
681
839
  }
682
840
  else {
683
841
  // Multi-run replacement: split start then end.
684
- if (startOffset > 0) {
685
- const { right } = splitRunAtVisibleOffset(rangeStartRunEl, startOffset);
686
- rangeStartRunEl = right;
687
- }
688
- const endLen = endRun.text.length;
689
- if (endOffset < endLen) {
690
- const { left } = splitRunAtVisibleOffset(rangeEndRunEl, endOffset);
691
- rangeEndRunEl = left;
692
- }
842
+ if (needsStartSplit)
843
+ rangeStartRunEl = splitStart(rangeStartRunEl);
844
+ if (needsEndSplit)
845
+ rangeEndRunEl = splitEnd(rangeEndRunEl);
693
846
  }
694
847
  const parent = rangeStartRunEl.parentNode;
695
848
  if (!parent)
@@ -717,11 +870,17 @@ export function replaceParagraphTextRange(p, start, end, replacement, ctx) {
717
870
  // restore the removed text AFTER the preserved object, permanently
718
871
  // reordering content the user never touched. When no embedded content is
719
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.
720
880
  let rangeContainsEmbeddedContent = false;
721
881
  for (let node = rangeStartRunEl; node; node = node.nextSibling) {
722
882
  if (node.nodeType === 1 &&
723
- isW(node, W.r) &&
724
- getEmbeddedContentElements(node).length > 0) {
883
+ (!isW(node, W.r) || getEmbeddedContentElements(node).length > 0)) {
725
884
  rangeContainsEmbeddedContent = true;
726
885
  }
727
886
  if (node === rangeEndRunEl)
@@ -736,7 +895,7 @@ export function replaceParagraphTextRange(p, start, end, replacement, ctx) {
736
895
  if (cur.nodeType === 1 && isW(cur, W.r)) {
737
896
  const runEl = cur;
738
897
  runEl.parentNode?.removeChild(runEl);
739
- if (getRunVisibleLength(runEl) > 0) {
898
+ if (runCarriesDeletableContent(runEl)) {
740
899
  removedRuns.push(runEl);
741
900
  }
742
901
  }
@@ -754,7 +913,7 @@ export function replaceParagraphTextRange(p, start, end, replacement, ctx) {
754
913
  const parentNode = runEl.parentNode;
755
914
  if (!parentNode)
756
915
  return;
757
- if (ctx && getRunVisibleLength(runEl) > 0) {
916
+ if (ctx && runCarriesDeletableContent(runEl)) {
758
917
  if (!currentDeletion) {
759
918
  currentDeletion = createRevisionContainer(doc, 'del', ctx);
760
919
  parentNode.insertBefore(currentDeletion, runEl);
@@ -795,15 +954,22 @@ export function replaceParagraphTextRange(p, start, end, replacement, ctx) {
795
954
  while (cur) {
796
955
  const nextNode = cur.nextSibling;
797
956
  const atRangeEnd = cur === rangeEndRunEl;
798
- if (cur.nodeType === 1 && isW(cur, W.r)) {
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) {
799
963
  const runEl = cur;
800
964
  const embeddedContent = getEmbeddedContentElements(runEl);
801
965
  if (embeddedContent.length === 0) {
802
966
  removeRunInPlace(runEl);
803
967
  }
804
- else if (getRunVisibleLength(runEl) === 0) {
968
+ else if (!runCarriesDeletableContent(runEl)) {
805
969
  // Embedded-only run: the replaced text lives entirely in sibling
806
- // runs. Leave it in the paragraph as-is.
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.
807
973
  preservedEmbeddedContent = true;
808
974
  currentDeletion = null;
809
975
  }
@@ -837,7 +1003,7 @@ export function replaceParagraphTextRange(p, start, end, replacement, ctx) {
837
1003
  if (ctx && hasExplicitFormattingMutation && rPrComparableSignature(newRPr) !== sourceRPrSignature) {
838
1004
  ensureRPr(doc, newRun).appendChild(buildRPrChangeElement(getSnapshotRPr(doc, sourceRPr), ctx));
839
1005
  }
840
- appendTextToRun(doc, newRun, part.text);
1006
+ appendTextToRun(doc, newRun, part.text, part.preserveXmlSpace);
841
1007
  if (getRunVisibleLength(newRun) > 0) {
842
1008
  replacementRuns.push(newRun);
843
1009
  }
@@ -860,8 +1026,16 @@ export function replaceParagraphTextRange(p, start, end, replacement, ctx) {
860
1026
  // Only delete the paragraph mark when the edit leaves nothing live behind.
861
1027
  // Preserved embedded content keeps the paragraph meaningful, and merging
862
1028
  // 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) {
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)) {
865
1039
  addParagraphMarkDeletion(p, ctx);
866
1040
  }
867
1041
  }
@@ -871,5 +1045,11 @@ export function replaceParagraphTextRange(p, start, end, replacement, ctx) {
871
1045
  }
872
1046
  }
873
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
+ }
874
1054
  }
875
1055
  //# sourceMappingURL=text.js.map