@ansonlai/docx-redline-js 0.1.0
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/AGENTS.md +176 -0
- package/ARCHITECTURE.md +121 -0
- package/LICENSE +21 -0
- package/README.md +177 -0
- package/adapters/config.js +43 -0
- package/adapters/logger.js +89 -0
- package/adapters/xml-adapter.js +74 -0
- package/core/list-targeting.js +398 -0
- package/core/ooxml-identifiers.js +15 -0
- package/core/paragraph-offset-policy.js +50 -0
- package/core/paragraph-targeting.js +501 -0
- package/core/table-targeting.js +233 -0
- package/core/types.js +204 -0
- package/core/xml-query.js +99 -0
- package/dist/docx-redline-js.esm.js +8801 -0
- package/dist/docx-redline-js.esm.js.map +7 -0
- package/dist/docx-redline-js.esm.min.js +195 -0
- package/dist/docx-redline-js.esm.min.js.map +7 -0
- package/engine/format-application.js +358 -0
- package/engine/format-extraction.js +232 -0
- package/engine/format-paragraph-targeting.js +208 -0
- package/engine/format-span-application.js +178 -0
- package/engine/formatting-removal.js +330 -0
- package/engine/oxml-engine.js +279 -0
- package/engine/reconstruction-mapper.js +270 -0
- package/engine/reconstruction-mode.js +38 -0
- package/engine/reconstruction-writer.js +276 -0
- package/engine/rpr-helpers.js +194 -0
- package/engine/run-builders.js +235 -0
- package/engine/surgical-mode.js +520 -0
- package/engine/table-cell-context.js +151 -0
- package/engine/table-mode.js +172 -0
- package/index.js +308 -0
- package/orchestration/list-markdown.js +141 -0
- package/orchestration/list-parsing.js +73 -0
- package/orchestration/list-structural-fallback.js +530 -0
- package/orchestration/redline-operation-converter.js +141 -0
- package/orchestration/route-plan.js +160 -0
- package/package.json +76 -0
- package/pipeline/content-analysis.js +107 -0
- package/pipeline/diff-engine.js +204 -0
- package/pipeline/ingestion-export.js +255 -0
- package/pipeline/ingestion-paragraph.js +351 -0
- package/pipeline/ingestion-table.js +169 -0
- package/pipeline/ingestion-xml.js +39 -0
- package/pipeline/ingestion.js +8 -0
- package/pipeline/list-generation.js +280 -0
- package/pipeline/list-markers.js +77 -0
- package/pipeline/markdown-processor.js +160 -0
- package/pipeline/patching.js +408 -0
- package/pipeline/pipeline.js +326 -0
- package/pipeline/serialization.js +395 -0
- package/services/browser-demo-prompt-context.js +345 -0
- package/services/comment-builders.js +60 -0
- package/services/comment-engine.js +248 -0
- package/services/comment-locator.js +197 -0
- package/services/comment-package.js +113 -0
- package/services/numbering-helpers.js +416 -0
- package/services/numbering-service.js +290 -0
- package/services/package-builder.js +147 -0
- package/services/standalone-docx-plumbing.js +443 -0
- package/services/standalone-operation-runner.js +1169 -0
- package/services/table-reconciliation.js +344 -0
- package/standalone.js +5 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared table-targeting helpers for per-paragraph redline callers.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
WORD_MAIN_NS,
|
|
7
|
+
getParagraphText,
|
|
8
|
+
isMarkdownTableText,
|
|
9
|
+
findContainingWordElement,
|
|
10
|
+
normalizeWhitespaceForTargeting
|
|
11
|
+
} from './paragraph-targeting.js';
|
|
12
|
+
|
|
13
|
+
function getDirectWordChildren(element, localName) {
|
|
14
|
+
if (!element) return [];
|
|
15
|
+
return Array.from(element.childNodes || []).filter(
|
|
16
|
+
node =>
|
|
17
|
+
node &&
|
|
18
|
+
node.nodeType === 1 &&
|
|
19
|
+
node.namespaceURI === WORD_MAIN_NS &&
|
|
20
|
+
node.localName === localName
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function escapeMarkdownCell(text) {
|
|
25
|
+
return String(text || '')
|
|
26
|
+
.replace(/\|/g, '\\|')
|
|
27
|
+
.replace(/\r?\n/g, '<br>');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function extractTableMatrix(tableElement) {
|
|
31
|
+
const rowElements = getDirectWordChildren(tableElement, 'tr');
|
|
32
|
+
const matrix = rowElements.map(row => {
|
|
33
|
+
const cellElements = getDirectWordChildren(row, 'tc');
|
|
34
|
+
return cellElements.map(cell => {
|
|
35
|
+
const paragraphs = getDirectWordChildren(cell, 'p');
|
|
36
|
+
if (paragraphs.length === 0) return normalizeWhitespaceForTargeting(getParagraphText(cell));
|
|
37
|
+
const lines = paragraphs
|
|
38
|
+
.map(p => normalizeWhitespaceForTargeting(getParagraphText(p)))
|
|
39
|
+
.filter(Boolean);
|
|
40
|
+
return lines.join('\n');
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const columnCount = matrix.reduce((max, row) => Math.max(max, row.length), 0);
|
|
45
|
+
matrix.forEach(row => {
|
|
46
|
+
while (row.length < columnCount) row.push('');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return { matrix, rowElements, columnCount };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function tableMatrixToMarkdown(matrix, columnCount) {
|
|
53
|
+
if (!Array.isArray(matrix) || matrix.length === 0 || columnCount <= 0) return null;
|
|
54
|
+
const normalized = matrix.map(row => {
|
|
55
|
+
const copy = Array.isArray(row) ? row.slice(0, columnCount) : [];
|
|
56
|
+
while (copy.length < columnCount) copy.push('');
|
|
57
|
+
return copy;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const header = normalized[0];
|
|
61
|
+
const separator = new Array(columnCount).fill('---');
|
|
62
|
+
const bodyRows = normalized.slice(1);
|
|
63
|
+
const toLine = row => `| ${row.map(cell => escapeMarkdownCell(cell)).join(' | ')} |`;
|
|
64
|
+
|
|
65
|
+
return [toLine(header), toLine(separator), ...bodyRows.map(toLine)].join('\n');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isSymmetricLabelRow(rowValues) {
|
|
69
|
+
if (!Array.isArray(rowValues) || rowValues.length < 2) return false;
|
|
70
|
+
const normalized = rowValues.map(value => normalizeWhitespaceForTargeting(value)).filter(Boolean);
|
|
71
|
+
if (normalized.length < 2) return false;
|
|
72
|
+
return normalized.every(value => value === normalized[0]);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Heuristic detector for paragraphs likely belonging to a table-source block.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} text - Paragraph text
|
|
79
|
+
* @returns {boolean}
|
|
80
|
+
*/
|
|
81
|
+
export function isLikelyStructuredTableSourceParagraph(text) {
|
|
82
|
+
const normalized = String(text || '').trim();
|
|
83
|
+
if (!normalized) return false;
|
|
84
|
+
if (/^and$/i.test(normalized)) return true;
|
|
85
|
+
if (/^\[.*\]$/.test(normalized)) return true;
|
|
86
|
+
if (/^\(.*\)$/.test(normalized)) return true;
|
|
87
|
+
if (/:\s*$/.test(normalized)) return true;
|
|
88
|
+
if (normalized.length <= 90 && !/[.!?]$/.test(normalized) && /[:\[\]()]/.test(normalized)) return true;
|
|
89
|
+
if (/^[\[(]/.test(normalized)) return true;
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Infers a contiguous paragraph block for table conversion starting from a paragraph.
|
|
95
|
+
*
|
|
96
|
+
* @param {Element|null} startParagraph - Starting w:p node
|
|
97
|
+
* @param {Object} [options={}] - Inference options
|
|
98
|
+
* @param {number} [options.maxScan=10] - Max sibling paragraphs to inspect
|
|
99
|
+
* @param {(paragraph: Element) => string} [options.getParagraphText] - Optional text getter
|
|
100
|
+
* @returns {Element[]|null}
|
|
101
|
+
*/
|
|
102
|
+
export function inferTableReplacementParagraphBlock(startParagraph, options = {}) {
|
|
103
|
+
const maxScan = Number.isInteger(options?.maxScan) && options.maxScan > 0 ? options.maxScan : 10;
|
|
104
|
+
const paragraphTextGetter = typeof options?.getParagraphText === 'function'
|
|
105
|
+
? options.getParagraphText
|
|
106
|
+
: getParagraphText;
|
|
107
|
+
|
|
108
|
+
if (!startParagraph || !startParagraph.parentNode) return null;
|
|
109
|
+
|
|
110
|
+
const block = [startParagraph];
|
|
111
|
+
let cursor = startParagraph.nextSibling;
|
|
112
|
+
let scanned = 0;
|
|
113
|
+
|
|
114
|
+
while (cursor && scanned < maxScan) {
|
|
115
|
+
scanned += 1;
|
|
116
|
+
const nextCursor = cursor.nextSibling;
|
|
117
|
+
if (cursor.nodeType !== 1 || cursor.namespaceURI !== WORD_MAIN_NS || cursor.localName !== 'p') {
|
|
118
|
+
cursor = nextCursor;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const text = String(paragraphTextGetter(cursor) || '').trim();
|
|
123
|
+
if (!text) {
|
|
124
|
+
if (block.length > 1) break;
|
|
125
|
+
cursor = nextCursor;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!isLikelyStructuredTableSourceParagraph(text)) break;
|
|
130
|
+
block.push(cursor);
|
|
131
|
+
cursor = nextCursor;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return block.length > 1 ? block : null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Builds full-table markdown when a table-cell redline uses multiline text.
|
|
139
|
+
*
|
|
140
|
+
* Heuristic:
|
|
141
|
+
* - If target paragraph is inside a table cell
|
|
142
|
+
* - and `modifiedText` is multiline but not markdown-table syntax
|
|
143
|
+
* - and first line matches current paragraph text
|
|
144
|
+
* Then treat extra lines as row insertions in the target column.
|
|
145
|
+
* For symmetric label rows (for example `Title:` in both signature columns),
|
|
146
|
+
* inserted values are mirrored across columns.
|
|
147
|
+
*
|
|
148
|
+
* @param {Element} targetParagraph - Resolved target paragraph
|
|
149
|
+
* @param {string} modifiedText - User/model modified text
|
|
150
|
+
* @param {{
|
|
151
|
+
* tableElement?: Element|null,
|
|
152
|
+
* currentParagraphText?: string,
|
|
153
|
+
* onInfo?: (msg:string)=>void,
|
|
154
|
+
* onWarn?: (msg:string)=>void
|
|
155
|
+
* }} [options] - Optional context/log callbacks
|
|
156
|
+
* @returns {string|null}
|
|
157
|
+
*/
|
|
158
|
+
export function synthesizeTableMarkdownFromMultilineCellEdit(targetParagraph, modifiedText, options = {}) {
|
|
159
|
+
const onInfo = typeof options.onInfo === 'function' ? options.onInfo : () => {};
|
|
160
|
+
const onWarn = typeof options.onWarn === 'function' ? options.onWarn : () => {};
|
|
161
|
+
|
|
162
|
+
const rawModified = String(modifiedText || '');
|
|
163
|
+
if (!rawModified.includes('\n')) return null;
|
|
164
|
+
if (isMarkdownTableText(rawModified)) return null;
|
|
165
|
+
|
|
166
|
+
const lines = rawModified
|
|
167
|
+
.split(/\r?\n/g)
|
|
168
|
+
.map(line => line.trim())
|
|
169
|
+
.filter(Boolean);
|
|
170
|
+
if (lines.length < 2) return null;
|
|
171
|
+
|
|
172
|
+
const tableElement = options.tableElement || findContainingWordElement(targetParagraph, 'tbl');
|
|
173
|
+
const rowElement = findContainingWordElement(targetParagraph, 'tr');
|
|
174
|
+
const cellElement = findContainingWordElement(targetParagraph, 'tc');
|
|
175
|
+
if (!tableElement || !rowElement || !cellElement) return null;
|
|
176
|
+
|
|
177
|
+
const currentParagraphText = normalizeWhitespaceForTargeting(
|
|
178
|
+
options.currentParagraphText || getParagraphText(targetParagraph)
|
|
179
|
+
);
|
|
180
|
+
const firstLine = normalizeWhitespaceForTargeting(lines[0]);
|
|
181
|
+
if (currentParagraphText && firstLine && firstLine !== currentParagraphText) {
|
|
182
|
+
// Avoid rewriting full tables from ambiguous multiline content.
|
|
183
|
+
onWarn('[Table] Multiline cell text did not anchor to original cell text; skipping table-row synthesis heuristic.');
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const { matrix, rowElements, columnCount } = extractTableMatrix(tableElement);
|
|
188
|
+
if (matrix.length === 0 || columnCount === 0) return null;
|
|
189
|
+
|
|
190
|
+
const rowIndex = rowElements.indexOf(rowElement);
|
|
191
|
+
const cellElements = getDirectWordChildren(rowElement, 'tc');
|
|
192
|
+
const colIndex = cellElements.indexOf(cellElement);
|
|
193
|
+
if (rowIndex < 0 || colIndex < 0 || colIndex >= columnCount) return null;
|
|
194
|
+
|
|
195
|
+
matrix[rowIndex][colIndex] = lines[0];
|
|
196
|
+
const mirrorAcrossColumns = isSymmetricLabelRow(matrix[rowIndex]);
|
|
197
|
+
if (mirrorAcrossColumns) {
|
|
198
|
+
onInfo('[Table] Symmetric row detected; mirroring inserted row values across columns.');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
for (let i = 1; i < lines.length; i++) {
|
|
202
|
+
const insertIndex = rowIndex + i;
|
|
203
|
+
const extraValue = lines[i];
|
|
204
|
+
if (
|
|
205
|
+
insertIndex < matrix.length &&
|
|
206
|
+
!normalizeWhitespaceForTargeting(matrix[insertIndex][colIndex])
|
|
207
|
+
) {
|
|
208
|
+
if (mirrorAcrossColumns) {
|
|
209
|
+
for (let col = 0; col < columnCount; col++) {
|
|
210
|
+
if (!normalizeWhitespaceForTargeting(matrix[insertIndex][col])) {
|
|
211
|
+
matrix[insertIndex][col] = extraValue;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
} else {
|
|
215
|
+
matrix[insertIndex][colIndex] = extraValue;
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
const newRow = new Array(columnCount).fill('');
|
|
219
|
+
if (mirrorAcrossColumns) {
|
|
220
|
+
for (let col = 0; col < columnCount; col++) newRow[col] = extraValue;
|
|
221
|
+
} else {
|
|
222
|
+
newRow[colIndex] = extraValue;
|
|
223
|
+
}
|
|
224
|
+
matrix.splice(Math.min(insertIndex, matrix.length), 0, newRow);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const markdown = tableMatrixToMarkdown(matrix, columnCount);
|
|
229
|
+
if (!markdown) return null;
|
|
230
|
+
|
|
231
|
+
onInfo('[Table] Synthesized full markdown table from multiline cell edit for table-scope reconciliation.');
|
|
232
|
+
return markdown;
|
|
233
|
+
}
|
package/core/types.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OOXML Reconciliation Pipeline - Core Types
|
|
3
|
+
*
|
|
4
|
+
* Data types and enums for the reconciliation system.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { getDefaultAuthor } from '../adapters/config.js';
|
|
8
|
+
|
|
9
|
+
// WordprocessingML namespace
|
|
10
|
+
export const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
|
11
|
+
export const NS_R = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Diff operation types from word-level diffing
|
|
15
|
+
*/
|
|
16
|
+
export const DiffOp = Object.freeze({
|
|
17
|
+
EQUAL: 'equal',
|
|
18
|
+
DELETE: 'delete',
|
|
19
|
+
INSERT: 'insert'
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Run types in the run model
|
|
24
|
+
*/
|
|
25
|
+
export const RunKind = Object.freeze({
|
|
26
|
+
TEXT: 'run',
|
|
27
|
+
DELETION: 'deletion',
|
|
28
|
+
INSERTION: 'insertion',
|
|
29
|
+
HYPERLINK: 'hyperlink',
|
|
30
|
+
BOOKMARK: 'bookmark',
|
|
31
|
+
FIELD: 'field',
|
|
32
|
+
// Container delimiters for preserving hierarchy
|
|
33
|
+
CONTAINER_START: 'container_start',
|
|
34
|
+
CONTAINER_END: 'container_end',
|
|
35
|
+
// Multi-paragraph support
|
|
36
|
+
PARAGRAPH_START: 'paragraph_start'
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Container types that wrap runs
|
|
41
|
+
*/
|
|
42
|
+
export const ContainerKind = Object.freeze({
|
|
43
|
+
SDT: 'sdt', // Content Control
|
|
44
|
+
SMART_TAG: 'smartTag', // Smart Tag
|
|
45
|
+
CUSTOM_XML: 'customXml', // Custom XML
|
|
46
|
+
FIELD_COMPLEX: 'fldComplex' // Complex field (fldChar-based)
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Content types for block-level detection
|
|
51
|
+
*/
|
|
52
|
+
export const ContentType = Object.freeze({
|
|
53
|
+
PARAGRAPH: 'paragraph',
|
|
54
|
+
BULLET_LIST: 'bullet_list',
|
|
55
|
+
NUMBERED_LIST: 'numbered_list',
|
|
56
|
+
TABLE: 'table'
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Supported numbering formats
|
|
61
|
+
*/
|
|
62
|
+
export const NumberFormat = Object.freeze({
|
|
63
|
+
DECIMAL: 'decimal', // 1, 2, 3
|
|
64
|
+
LOWER_ALPHA: 'lowerLetter', // a, b, c
|
|
65
|
+
UPPER_ALPHA: 'upperLetter', // A, B, C
|
|
66
|
+
LOWER_ROMAN: 'lowerRoman', // i, ii, iii
|
|
67
|
+
UPPER_ROMAN: 'upperRoman', // I, II, III
|
|
68
|
+
BULLET: 'bullet', // •
|
|
69
|
+
OUTLINE: 'outline' // 1.1.2.3
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Numbering suffixes/formats
|
|
74
|
+
*/
|
|
75
|
+
export const NumberSuffix = Object.freeze({
|
|
76
|
+
PERIOD: 'period', // 1.
|
|
77
|
+
PAREN_RIGHT: 'parenRight', // 1)
|
|
78
|
+
PAREN_BOTH: 'parenBoth', // (1)
|
|
79
|
+
NONE: 'none'
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @typedef {Object} RunEntry
|
|
84
|
+
* @property {string} kind - RunKind value
|
|
85
|
+
* @property {string} text - Text content of the run
|
|
86
|
+
* @property {string} [rPrXml] - Serialized run properties (formatting)
|
|
87
|
+
* @property {Element|null} [pPrElement] - Lazy paragraph properties element for PARAGRAPH_START entries
|
|
88
|
+
* @property {string} [pPrXml] - Serialized paragraph properties for PARAGRAPH_START entries
|
|
89
|
+
* @property {number} startOffset - Start offset in accepted text
|
|
90
|
+
* @property {number} endOffset - End offset in accepted text
|
|
91
|
+
* @property {string} [author] - Author for track changes
|
|
92
|
+
* @property {string} [nodeXml] - Original XML for special elements
|
|
93
|
+
*/
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @typedef {Object} DiffOperation
|
|
97
|
+
* @property {string} type - DiffOp value
|
|
98
|
+
* @property {number} startOffset - Start offset in original text
|
|
99
|
+
* @property {number} endOffset - End offset in original text
|
|
100
|
+
* @property {string} text - Text content of the operation
|
|
101
|
+
*/
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @typedef {Object} FormatHint
|
|
105
|
+
* @property {number} start - Start offset in clean text
|
|
106
|
+
* @property {number} end - End offset in clean text
|
|
107
|
+
* @property {Object} format - Format flags (bold, italic, etc.)
|
|
108
|
+
*/
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* @typedef {Object} IngestionResult
|
|
112
|
+
* @property {RunEntry[]} runModel - Array of run entries
|
|
113
|
+
* @property {string} acceptedText - Reconstructed text from runs
|
|
114
|
+
* @property {Element|null} pPr - Paragraph properties element
|
|
115
|
+
*/
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @typedef {Object} PreprocessResult
|
|
119
|
+
* @property {string} cleanText - Text with markdown stripped
|
|
120
|
+
* @property {FormatHint[]} formatHints - Position-based format information
|
|
121
|
+
*/
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* @typedef {Object} ReconciliationResult
|
|
125
|
+
* @property {string} ooxml - The reconciled OOXML output
|
|
126
|
+
* @property {boolean} isValid - Whether validation passed
|
|
127
|
+
* @property {string[]} warnings - Any warnings during processing
|
|
128
|
+
*/
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* @typedef {Object} SerializationOptions
|
|
132
|
+
* @property {string} [author] - Author for generated track changes (defaults to configured default author)
|
|
133
|
+
* @property {boolean} [generateRedlines=true] - Toggle track-change wrappers
|
|
134
|
+
* @property {string|null} [font=null] - Optional font override for generated runs
|
|
135
|
+
*/
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* @typedef {Object} DocumentFragmentOptions
|
|
139
|
+
* @property {boolean} [includeNumbering=false] - Include numbering relationship/part
|
|
140
|
+
* @property {string|null} [numberingXml=null] - Custom numbering part payload
|
|
141
|
+
* @property {boolean} [appendTrailingParagraph=true] - Append trailing blank paragraph
|
|
142
|
+
*/
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Escapes XML special characters
|
|
146
|
+
* @param {string} str - String to escape
|
|
147
|
+
* @returns {string} Escaped string
|
|
148
|
+
*/
|
|
149
|
+
export function escapeXml(str) {
|
|
150
|
+
if (!str) return '';
|
|
151
|
+
return str
|
|
152
|
+
.replace(/&/g, '&')
|
|
153
|
+
.replace(/</g, '<')
|
|
154
|
+
.replace(/>/g, '>')
|
|
155
|
+
.replace(/"/g, '"')
|
|
156
|
+
.replace(/'/g, ''');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Global revision ID counter for track changes
|
|
160
|
+
let revisionIdCounter = 1000;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Gets the next unique revision ID for track changes
|
|
164
|
+
* @returns {number} Unique revision ID
|
|
165
|
+
*/
|
|
166
|
+
export function getNextRevisionId() {
|
|
167
|
+
return revisionIdCounter++;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Returns the canonical ISO timestamp used for track-change metadata.
|
|
172
|
+
*
|
|
173
|
+
* @param {Date} [date] - Optional date source (for tests)
|
|
174
|
+
* @returns {string}
|
|
175
|
+
*/
|
|
176
|
+
export function getRevisionTimestamp(date = new Date()) {
|
|
177
|
+
return date.toISOString();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Creates shared revision metadata for OOXML track-change tags.
|
|
182
|
+
*
|
|
183
|
+
* @param {string} [author] - Track-change author (defaults to configured default author)
|
|
184
|
+
* @returns {{ id: number, author: string, date: string }}
|
|
185
|
+
*/
|
|
186
|
+
export function createRevisionMetadata(author) {
|
|
187
|
+
const resolvedAuthor = typeof author === 'string' && author.trim()
|
|
188
|
+
? author.trim()
|
|
189
|
+
: getDefaultAuthor();
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
id: getNextRevisionId(),
|
|
193
|
+
author: resolvedAuthor,
|
|
194
|
+
date: getRevisionTimestamp()
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Resets the revision ID counter (for testing)
|
|
200
|
+
* @param {number} [startValue=1000] - Value to reset to
|
|
201
|
+
*/
|
|
202
|
+
export function resetRevisionIdCounter(startValue = 1000) {
|
|
203
|
+
revisionIdCounter = startValue;
|
|
204
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared XML query helpers for OOXML documents.
|
|
3
|
+
*
|
|
4
|
+
* These wrappers provide consistent first/all element access across:
|
|
5
|
+
* - Namespace-aware lookups (`getElementsByTagNameNS`)
|
|
6
|
+
* - Prefix-based fallbacks (`getElementsByTagName('w:...')`)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Returns all descendant elements matching a qualified tag name.
|
|
11
|
+
*
|
|
12
|
+
* @param {Node|Document|Element|null|undefined} node - Query root
|
|
13
|
+
* @param {string} tagName - Qualified element name (for example, `w:p`)
|
|
14
|
+
* @returns {Element[]}
|
|
15
|
+
*/
|
|
16
|
+
export function getElementsByTag(node, tagName) {
|
|
17
|
+
if (!node || typeof node.getElementsByTagName !== 'function') return [];
|
|
18
|
+
return Array.from(node.getElementsByTagName(tagName));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Returns the first descendant element matching a qualified tag name.
|
|
23
|
+
*
|
|
24
|
+
* @param {Node|Document|Element|null|undefined} node - Query root
|
|
25
|
+
* @param {string} tagName - Qualified element name (for example, `w:p`)
|
|
26
|
+
* @returns {Element|null}
|
|
27
|
+
*/
|
|
28
|
+
export function getFirstElementByTag(node, tagName) {
|
|
29
|
+
if (!node || typeof node.getElementsByTagName !== 'function') return null;
|
|
30
|
+
const elements = node.getElementsByTagName(tagName);
|
|
31
|
+
return elements.length > 0 ? elements[0] : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Returns all descendant elements matching namespace + local name.
|
|
36
|
+
*
|
|
37
|
+
* @param {Node|Document|Element|null|undefined} node - Query root
|
|
38
|
+
* @param {string} namespaceUri - Namespace URI (or `*`)
|
|
39
|
+
* @param {string} localName - Local tag name (for example, `p`)
|
|
40
|
+
* @returns {Element[]}
|
|
41
|
+
*/
|
|
42
|
+
export function getElementsByTagNS(node, namespaceUri, localName) {
|
|
43
|
+
if (!node || typeof node.getElementsByTagNameNS !== 'function') return [];
|
|
44
|
+
return Array.from(node.getElementsByTagNameNS(namespaceUri, localName));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Returns the first descendant element matching namespace + local name.
|
|
49
|
+
*
|
|
50
|
+
* @param {Node|Document|Element|null|undefined} node - Query root
|
|
51
|
+
* @param {string} namespaceUri - Namespace URI (or `*`)
|
|
52
|
+
* @param {string} localName - Local tag name (for example, `p`)
|
|
53
|
+
* @returns {Element|null}
|
|
54
|
+
*/
|
|
55
|
+
export function getFirstElementByTagNS(node, namespaceUri, localName) {
|
|
56
|
+
if (!node || typeof node.getElementsByTagNameNS !== 'function') return null;
|
|
57
|
+
const elements = node.getElementsByTagNameNS(namespaceUri, localName);
|
|
58
|
+
return elements.length > 0 ? elements[0] : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Returns all elements using namespace-aware lookup with prefixed fallback.
|
|
63
|
+
*
|
|
64
|
+
* @param {Node|Document|Element|null|undefined} node - Query root
|
|
65
|
+
* @param {string} namespaceUri - Namespace URI
|
|
66
|
+
* @param {string} localName - Local name
|
|
67
|
+
* @param {string} [fallbackTagName] - Optional prefixed fallback (default: `w:${localName}`)
|
|
68
|
+
* @returns {Element[]}
|
|
69
|
+
*/
|
|
70
|
+
export function getElementsByTagNSOrTag(node, namespaceUri, localName, fallbackTagName = `w:${localName}`) {
|
|
71
|
+
const namespacedElements = getElementsByTagNS(node, namespaceUri, localName);
|
|
72
|
+
if (namespacedElements.length > 0) return namespacedElements;
|
|
73
|
+
return getElementsByTag(node, fallbackTagName);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Returns the first element using namespace-aware lookup with prefixed fallback.
|
|
78
|
+
*
|
|
79
|
+
* @param {Node|Document|Element|null|undefined} node - Query root
|
|
80
|
+
* @param {string} namespaceUri - Namespace URI
|
|
81
|
+
* @param {string} localName - Local name
|
|
82
|
+
* @param {string} [fallbackTagName] - Optional prefixed fallback (default: `w:${localName}`)
|
|
83
|
+
* @returns {Element|null}
|
|
84
|
+
*/
|
|
85
|
+
export function getFirstElementByTagNSOrTag(node, namespaceUri, localName, fallbackTagName = `w:${localName}`) {
|
|
86
|
+
const namespacedElement = getFirstElementByTagNS(node, namespaceUri, localName);
|
|
87
|
+
if (namespacedElement) return namespacedElement;
|
|
88
|
+
return getFirstElementByTag(node, fallbackTagName);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Returns XML parser error element if present.
|
|
93
|
+
*
|
|
94
|
+
* @param {Document|Element|null|undefined} xmlDoc - Parsed XML document
|
|
95
|
+
* @returns {Element|null}
|
|
96
|
+
*/
|
|
97
|
+
export function getXmlParseError(xmlDoc) {
|
|
98
|
+
return getFirstElementByTag(xmlDoc, 'parsererror');
|
|
99
|
+
}
|