@usejunior/docx-core 0.20.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/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 +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.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 +9 -3
- package/dist/integration/libreoffice-oracle.js.map +1 -1
- package/dist/primitives/accept_changes.d.ts +3 -4
- package/dist/primitives/accept_changes.d.ts.map +1 -1
- package/dist/primitives/accept_changes.js +165 -79
- package/dist/primitives/accept_changes.js.map +1 -1
- package/dist/primitives/bookmarks.d.ts +7 -2
- package/dist/primitives/bookmarks.d.ts.map +1 -1
- package/dist/primitives/bookmarks.js +30 -6
- package/dist/primitives/bookmarks.js.map +1 -1
- package/dist/primitives/comments.d.ts +73 -4
- package/dist/primitives/comments.d.ts.map +1 -1
- package/dist/primitives/comments.js +266 -165
- 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 +98 -1
- package/dist/primitives/document.d.ts.map +1 -1
- package/dist/primitives/document.js +315 -22
- package/dist/primitives/document.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/footnotes.d.ts +36 -0
- package/dist/primitives/footnotes.d.ts.map +1 -1
- package/dist/primitives/footnotes.js +173 -75
- package/dist/primitives/footnotes.js.map +1 -1
- package/dist/primitives/index.d.ts +7 -1
- package/dist/primitives/index.d.ts.map +1 -1
- package/dist/primitives/index.js +7 -1
- package/dist/primitives/index.js.map +1 -1
- 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_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 +3 -4
- package/dist/primitives/reject_changes.d.ts.map +1 -1
- package/dist/primitives/reject_changes.js +166 -79
- 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/sections.d.ts +2 -1
- package/dist/primitives/sections.d.ts.map +1 -1
- package/dist/primitives/sections.js +2 -2
- package/dist/primitives/sections.js.map +1 -1
- package/dist/primitives/styles.d.ts +1 -0
- package/dist/primitives/styles.d.ts.map +1 -1
- package/dist/primitives/styles.js +1 -0
- package/dist/primitives/styles.js.map +1 -1
- 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 +40 -1
- package/dist/primitives/text.d.ts.map +1 -1
- package/dist/primitives/text.js +309 -129
- package/dist/primitives/text.js.map +1 -1
- package/dist/primitives/track-changes-emitter.d.ts +16 -2
- package/dist/primitives/track-changes-emitter.d.ts.map +1 -1
- package/dist/primitives/track-changes-emitter.js +38 -13
- package/dist/primitives/track-changes-emitter.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 +10 -4
- package/dist/shared/docx/DocxArchive.js.map +1 -1
- package/dist/shared/field-structure.d.ts +3 -0
- package/dist/shared/field-structure.d.ts.map +1 -1
- package/dist/shared/field-structure.js +7 -5
- package/dist/shared/field-structure.js.map +1 -1
- package/package.json +3 -4
package/dist/primitives/text.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
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';
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
589
|
+
const underlines = Array.from(rPr.childNodes).filter((child) => child.nodeType === 1 && isW(child, W.u));
|
|
457
590
|
if (val === false) {
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
rangeStartRunEl = left;
|
|
674
|
-
rangeEndRunEl = left;
|
|
831
|
+
if (needsEndSplit) {
|
|
832
|
+
rangeStartRunEl = splitEnd(rangeStartRunEl);
|
|
833
|
+
rangeEndRunEl = rangeStartRunEl;
|
|
675
834
|
}
|
|
676
|
-
if (
|
|
677
|
-
|
|
678
|
-
|
|
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 (
|
|
685
|
-
|
|
686
|
-
|
|
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 (
|
|
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 &&
|
|
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 (
|
|
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
|
-
|
|
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
|