@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.
Files changed (64) hide show
  1. package/AGENTS.md +176 -0
  2. package/ARCHITECTURE.md +121 -0
  3. package/LICENSE +21 -0
  4. package/README.md +177 -0
  5. package/adapters/config.js +43 -0
  6. package/adapters/logger.js +89 -0
  7. package/adapters/xml-adapter.js +74 -0
  8. package/core/list-targeting.js +398 -0
  9. package/core/ooxml-identifiers.js +15 -0
  10. package/core/paragraph-offset-policy.js +50 -0
  11. package/core/paragraph-targeting.js +501 -0
  12. package/core/table-targeting.js +233 -0
  13. package/core/types.js +204 -0
  14. package/core/xml-query.js +99 -0
  15. package/dist/docx-redline-js.esm.js +8801 -0
  16. package/dist/docx-redline-js.esm.js.map +7 -0
  17. package/dist/docx-redline-js.esm.min.js +195 -0
  18. package/dist/docx-redline-js.esm.min.js.map +7 -0
  19. package/engine/format-application.js +358 -0
  20. package/engine/format-extraction.js +232 -0
  21. package/engine/format-paragraph-targeting.js +208 -0
  22. package/engine/format-span-application.js +178 -0
  23. package/engine/formatting-removal.js +330 -0
  24. package/engine/oxml-engine.js +279 -0
  25. package/engine/reconstruction-mapper.js +270 -0
  26. package/engine/reconstruction-mode.js +38 -0
  27. package/engine/reconstruction-writer.js +276 -0
  28. package/engine/rpr-helpers.js +194 -0
  29. package/engine/run-builders.js +235 -0
  30. package/engine/surgical-mode.js +520 -0
  31. package/engine/table-cell-context.js +151 -0
  32. package/engine/table-mode.js +172 -0
  33. package/index.js +308 -0
  34. package/orchestration/list-markdown.js +141 -0
  35. package/orchestration/list-parsing.js +73 -0
  36. package/orchestration/list-structural-fallback.js +530 -0
  37. package/orchestration/redline-operation-converter.js +141 -0
  38. package/orchestration/route-plan.js +160 -0
  39. package/package.json +76 -0
  40. package/pipeline/content-analysis.js +107 -0
  41. package/pipeline/diff-engine.js +204 -0
  42. package/pipeline/ingestion-export.js +255 -0
  43. package/pipeline/ingestion-paragraph.js +351 -0
  44. package/pipeline/ingestion-table.js +169 -0
  45. package/pipeline/ingestion-xml.js +39 -0
  46. package/pipeline/ingestion.js +8 -0
  47. package/pipeline/list-generation.js +280 -0
  48. package/pipeline/list-markers.js +77 -0
  49. package/pipeline/markdown-processor.js +160 -0
  50. package/pipeline/patching.js +408 -0
  51. package/pipeline/pipeline.js +326 -0
  52. package/pipeline/serialization.js +395 -0
  53. package/services/browser-demo-prompt-context.js +345 -0
  54. package/services/comment-builders.js +60 -0
  55. package/services/comment-engine.js +248 -0
  56. package/services/comment-locator.js +197 -0
  57. package/services/comment-package.js +113 -0
  58. package/services/numbering-helpers.js +416 -0
  59. package/services/numbering-service.js +290 -0
  60. package/services/package-builder.js +147 -0
  61. package/services/standalone-docx-plumbing.js +443 -0
  62. package/services/standalone-operation-runner.js +1169 -0
  63. package/services/table-reconciliation.js +344 -0
  64. 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, '&amp;')
153
+ .replace(/</g, '&lt;')
154
+ .replace(/>/g, '&gt;')
155
+ .replace(/"/g, '&quot;')
156
+ .replace(/'/g, '&apos;');
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
+ }