@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.
- package/README.md +12 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/cli/conformance-adapter.d.ts.map +1 -1
- package/dist/cli/conformance-adapter.js +0 -27
- package/dist/cli/conformance-adapter.js.map +1 -1
- package/dist/core-types.d.ts +1 -139
- package/dist/core-types.d.ts.map +1 -1
- package/dist/core-types.js +1 -3
- package/dist/core-types.js.map +1 -1
- package/dist/generation/compile.d.ts.map +1 -1
- package/dist/generation/compile.js +1 -3
- package/dist/generation/compile.js.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -1
- package/dist/index.js.map +1 -1
- package/dist/integration/generation-probes.d.ts +36 -2
- package/dist/integration/generation-probes.d.ts.map +1 -1
- package/dist/integration/generation-probes.js +137 -15
- package/dist/integration/generation-probes.js.map +1 -1
- package/dist/integration/libreoffice-oracle.d.ts +3 -1
- package/dist/integration/libreoffice-oracle.d.ts.map +1 -1
- package/dist/integration/libreoffice-oracle.js +10 -4
- package/dist/integration/libreoffice-oracle.js.map +1 -1
- package/dist/integration/synthetic-docx-fixture.d.ts.map +1 -1
- package/dist/integration/synthetic-docx-fixture.js +8 -0
- package/dist/integration/synthetic-docx-fixture.js.map +1 -1
- package/dist/primitives/accept_ai_edits.js +14 -2
- package/dist/primitives/accept_ai_edits.js.map +1 -1
- package/dist/primitives/accept_changes.d.ts +6 -0
- package/dist/primitives/accept_changes.d.ts.map +1 -1
- package/dist/primitives/accept_changes.js +236 -62
- package/dist/primitives/accept_changes.js.map +1 -1
- package/dist/primitives/bookmarks.d.ts +8 -2
- package/dist/primitives/bookmarks.d.ts.map +1 -1
- package/dist/primitives/bookmarks.js +31 -7
- package/dist/primitives/bookmarks.js.map +1 -1
- package/dist/primitives/comments.d.ts +101 -5
- package/dist/primitives/comments.d.ts.map +1 -1
- package/dist/primitives/comments.js +356 -156
- package/dist/primitives/comments.js.map +1 -1
- package/dist/primitives/conformance.d.ts +42 -0
- package/dist/primitives/conformance.d.ts.map +1 -0
- package/dist/primitives/conformance.js +61 -0
- package/dist/primitives/conformance.js.map +1 -0
- package/dist/primitives/document.d.ts +145 -2
- package/dist/primitives/document.d.ts.map +1 -1
- package/dist/primitives/document.js +505 -27
- package/dist/primitives/document.js.map +1 -1
- package/dist/primitives/document_view-headings.d.ts +3 -1
- package/dist/primitives/document_view-headings.d.ts.map +1 -1
- package/dist/primitives/document_view-headings.js +5 -5
- package/dist/primitives/document_view-headings.js.map +1 -1
- package/dist/primitives/document_view.d.ts +2 -0
- package/dist/primitives/document_view.d.ts.map +1 -1
- package/dist/primitives/document_view.js +18 -4
- package/dist/primitives/document_view.js.map +1 -1
- package/dist/primitives/errors.d.ts +3 -2
- package/dist/primitives/errors.d.ts.map +1 -1
- package/dist/primitives/errors.js +3 -1
- package/dist/primitives/errors.js.map +1 -1
- package/dist/primitives/extract_revisions.d.ts +23 -6
- package/dist/primitives/extract_revisions.d.ts.map +1 -1
- package/dist/primitives/extract_revisions.js +208 -38
- package/dist/primitives/extract_revisions.js.map +1 -1
- package/dist/primitives/field_evaluation.d.ts +70 -0
- package/dist/primitives/field_evaluation.d.ts.map +1 -0
- package/dist/primitives/field_evaluation.js +462 -0
- package/dist/primitives/field_evaluation.js.map +1 -0
- package/dist/primitives/footnotes.d.ts +53 -3
- package/dist/primitives/footnotes.d.ts.map +1 -1
- package/dist/primitives/footnotes.js +228 -83
- package/dist/primitives/footnotes.js.map +1 -1
- package/dist/primitives/index.d.ts +12 -1
- package/dist/primitives/index.d.ts.map +1 -1
- package/dist/primitives/index.js +12 -1
- package/dist/primitives/index.js.map +1 -1
- package/dist/primitives/merge_runs.d.ts +3 -1
- package/dist/primitives/merge_runs.d.ts.map +1 -1
- package/dist/primitives/merge_runs.js +12 -2
- package/dist/primitives/merge_runs.js.map +1 -1
- package/dist/primitives/namespaces.d.ts +10 -2
- package/dist/primitives/namespaces.d.ts.map +1 -1
- package/dist/primitives/namespaces.js +11 -2
- package/dist/primitives/namespaces.js.map +1 -1
- package/dist/primitives/note_conversion.d.ts +11 -0
- package/dist/primitives/note_conversion.d.ts.map +1 -0
- package/dist/primitives/note_conversion.js +12 -0
- package/dist/primitives/note_conversion.js.map +1 -0
- package/dist/primitives/paragraph-index.d.ts +31 -0
- package/dist/primitives/paragraph-index.d.ts.map +1 -0
- package/dist/primitives/paragraph-index.js +140 -0
- package/dist/primitives/paragraph-index.js.map +1 -0
- package/dist/primitives/paragraph_merge_formatting.d.ts +24 -0
- package/dist/primitives/paragraph_merge_formatting.d.ts.map +1 -0
- package/dist/primitives/paragraph_merge_formatting.js +86 -0
- package/dist/primitives/paragraph_merge_formatting.js.map +1 -0
- package/dist/primitives/paragraph_numbering.d.ts +40 -0
- package/dist/primitives/paragraph_numbering.d.ts.map +1 -0
- package/dist/primitives/paragraph_numbering.js +198 -0
- package/dist/primitives/paragraph_numbering.js.map +1 -0
- package/dist/primitives/paragraph_structure.d.ts +19 -0
- package/dist/primitives/paragraph_structure.d.ts.map +1 -0
- package/dist/primitives/paragraph_structure.js +66 -0
- package/dist/primitives/paragraph_structure.js.map +1 -0
- package/dist/primitives/reject_changes.d.ts +6 -0
- package/dist/primitives/reject_changes.d.ts.map +1 -1
- package/dist/primitives/reject_changes.js +278 -68
- package/dist/primitives/reject_changes.js.map +1 -1
- package/dist/primitives/relationships.d.ts +22 -0
- package/dist/primitives/relationships.d.ts.map +1 -1
- package/dist/primitives/relationships.js +87 -0
- package/dist/primitives/relationships.js.map +1 -1
- package/dist/primitives/revision-parts.d.ts +15 -0
- package/dist/primitives/revision-parts.d.ts.map +1 -1
- package/dist/primitives/revision-parts.js +33 -1
- package/dist/primitives/revision-parts.js.map +1 -1
- package/dist/primitives/sectPrAudit.d.ts.map +1 -1
- package/dist/primitives/sectPrAudit.js +10 -1
- package/dist/primitives/sectPrAudit.js.map +1 -1
- package/dist/primitives/sections.d.ts +135 -0
- package/dist/primitives/sections.d.ts.map +1 -0
- package/dist/primitives/sections.js +644 -0
- package/dist/primitives/sections.js.map +1 -0
- package/dist/primitives/styles.d.ts +61 -0
- package/dist/primitives/styles.d.ts.map +1 -1
- package/dist/primitives/styles.js +207 -25
- package/dist/primitives/styles.js.map +1 -1
- package/dist/primitives/symbol_run_content.d.ts +42 -0
- package/dist/primitives/symbol_run_content.d.ts.map +1 -0
- package/dist/primitives/symbol_run_content.js +79 -0
- package/dist/primitives/symbol_run_content.js.map +1 -0
- package/dist/primitives/table_cells.d.ts +49 -0
- package/dist/primitives/table_cells.d.ts.map +1 -0
- package/dist/primitives/table_cells.js +149 -0
- package/dist/primitives/table_cells.js.map +1 -0
- package/dist/primitives/table_columns.d.ts +50 -0
- package/dist/primitives/table_columns.d.ts.map +1 -0
- package/dist/primitives/table_columns.js +138 -0
- package/dist/primitives/table_columns.js.map +1 -0
- package/dist/primitives/table_edit_common.d.ts +39 -0
- package/dist/primitives/table_edit_common.d.ts.map +1 -0
- package/dist/primitives/table_edit_common.js +163 -0
- package/dist/primitives/table_edit_common.js.map +1 -0
- package/dist/primitives/table_occupancy.d.ts +39 -0
- package/dist/primitives/table_occupancy.d.ts.map +1 -0
- package/dist/primitives/table_occupancy.js +145 -0
- package/dist/primitives/table_occupancy.js.map +1 -0
- package/dist/primitives/table_rows.d.ts +56 -0
- package/dist/primitives/table_rows.d.ts.map +1 -0
- package/dist/primitives/table_rows.js +390 -0
- package/dist/primitives/table_rows.js.map +1 -0
- package/dist/primitives/text.d.ts +69 -1
- package/dist/primitives/text.d.ts.map +1 -1
- package/dist/primitives/text.js +592 -102
- package/dist/primitives/text.js.map +1 -1
- package/dist/primitives/track-changes-emitter.d.ts +23 -0
- package/dist/primitives/track-changes-emitter.d.ts.map +1 -1
- package/dist/primitives/track-changes-emitter.js +64 -1
- package/dist/primitives/track-changes-emitter.js.map +1 -1
- package/dist/primitives/validate_document.d.ts.map +1 -1
- package/dist/primitives/validate_document.js +22 -1
- package/dist/primitives/validate_document.js.map +1 -1
- package/dist/primitives/zip.d.ts +22 -1
- package/dist/primitives/zip.d.ts.map +1 -1
- package/dist/primitives/zip.js +47 -8
- package/dist/primitives/zip.js.map +1 -1
- package/dist/shared/docx/DocxArchive.d.ts +6 -2
- package/dist/shared/docx/DocxArchive.d.ts.map +1 -1
- package/dist/shared/docx/DocxArchive.js +17 -4
- package/dist/shared/docx/DocxArchive.js.map +1 -1
- package/dist/shared/field-semantics.d.ts +26 -0
- package/dist/shared/field-semantics.d.ts.map +1 -0
- package/dist/shared/field-semantics.js +232 -0
- package/dist/shared/field-semantics.js.map +1 -0
- package/dist/shared/field-structure.d.ts +10 -1
- package/dist/shared/field-structure.d.ts.map +1 -1
- package/dist/shared/field-structure.js +31 -12
- package/dist/shared/field-structure.js.map +1 -1
- package/package.json +4 -5
package/dist/primitives/text.js
CHANGED
|
@@ -1,61 +1,28 @@
|
|
|
1
1
|
import { OOXML, W } from './namespaces.js';
|
|
2
2
|
import { SafeDocxError } from './errors.js';
|
|
3
|
-
import {
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
|
49
|
+
if (endRunIdx === -1 && end > pos && end <= nextPos) {
|
|
79
50
|
endRunIdx = i;
|
|
80
51
|
endOffset = end - pos;
|
|
81
52
|
break;
|
|
82
53
|
}
|
|
83
|
-
pos
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
589
|
+
const underlines = Array.from(rPr.childNodes).filter((child) => child.nodeType === 1 && isW(child, W.u));
|
|
351
590
|
if (val === false) {
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
451
|
-
if (
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
rangeStartRunEl = left;
|
|
481
|
-
rangeEndRunEl = left;
|
|
831
|
+
if (needsEndSplit) {
|
|
832
|
+
rangeStartRunEl = splitEnd(rangeStartRunEl);
|
|
833
|
+
rangeEndRunEl = rangeStartRunEl;
|
|
482
834
|
}
|
|
483
|
-
if (
|
|
484
|
-
|
|
485
|
-
|
|
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 (
|
|
492
|
-
|
|
493
|
-
|
|
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
|
|
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
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
const
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|