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