diff --git a/src/documents/src/engine/diagram-data-model.js b/src/documents/src/engine/diagram-data-model.js index 150af42e9..8a3b9e16b 100644 --- a/src/documents/src/engine/diagram-data-model.js +++ b/src/documents/src/engine/diagram-data-model.js @@ -1,10 +1,11 @@ import { UnsupportedDocumentError } from './errors.js'; +import { elementChildren } from './docx-xml.js'; const DGM = 'http://schemas.openxmlformats.org/drawingml/2006/diagram'; const pointTypes = new Set(['doc', 'node', 'asst', 'parTrans', 'sibTrans', 'pres']); const connectionTypes = new Set(['parOf', 'presOf', 'presParOf', 'unknownRelationship']); const fail = reason => { throw new UnsupportedDocumentError(`diagram data with ${reason}`); }; -const children = node => [...(node?.children || [])]; +const children = node => node?.children ? elementChildren(node) : []; function list(root, name, optional = false) { const matches = children(root).filter(n => n.namespaceURI === DGM && n.localName === name); if (optional && !matches.length) return []; diff --git a/src/documents/src/engine/docx-backgrounds.js b/src/documents/src/engine/docx-backgrounds.js index ba774a8af..ac029b12d 100644 --- a/src/documents/src/engine/docx-backgrounds.js +++ b/src/documents/src/engine/docx-backgrounds.js @@ -1,9 +1,10 @@ import { checkedHighlight } from './backgrounds.js'; import { UnsupportedDocumentError } from './errors.js'; +import { childElements } from './docx-xml.js'; const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; const fail = why => { throw new UnsupportedDocumentError(why); }; function single(parent, tag) { - const nodes = [...(parent?.children || [])].filter(n => n.namespaceURI === W && n.localName === tag); + const nodes = childElements(parent, W, tag); if (nodes.length > 1) fail(`duplicate run ${tag}`); if (nodes[0]?.children.length) fail(`nested run ${tag}`); return nodes[0]; diff --git a/src/documents/src/engine/docx-cell-margins.js b/src/documents/src/engine/docx-cell-margins.js index 4676910c8..c9dfc0d11 100644 --- a/src/documents/src/engine/docx-cell-margins.js +++ b/src/documents/src/engine/docx-cell-margins.js @@ -1,8 +1,9 @@ import { UnsupportedDocumentError } from './errors.js'; import { checkedCellMargins } from './cell-margins.js'; +import { elementChildren } from './docx-xml.js'; const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; const ORDER = ['top', 'start', 'left', 'bottom', 'end', 'right']; -const children = node => [...(node?.children || [])]; +const children = node => node?.children ? elementChildren(node) : []; const fail = reason => { throw new UnsupportedDocumentError('cell margins with ' + reason); }; export function readCellMargins(parent, table = false) { diff --git a/src/documents/src/engine/docx-chart-text.js b/src/documents/src/engine/docx-chart-text.js index 4a17a6ddd..a24473539 100644 --- a/src/documents/src/engine/docx-chart-text.js +++ b/src/documents/src/engine/docx-chart-text.js @@ -1,9 +1,10 @@ import { UnsupportedDocumentError } from './errors.js'; import { drawingColor } from './docx-drawing-style.js'; import { checkedFontName } from './font-families.js'; +import { elementChildren } from './docx-xml.js'; const A = 'http://schemas.openxmlformats.org/drawingml/2006/main'; -const children = n => [...(n?.children || [])]; +const children = n => n?.children ? elementChildren(n) : []; const child = (n, name) => children(n).find(c => c.namespaceURI === A && c.localName === name); const fail = why => { throw new UnsupportedDocumentError(`chart text with ${why}`); }; function check(n, names) { diff --git a/src/documents/src/engine/docx-colors.js b/src/documents/src/engine/docx-colors.js index 680084adb..69f9c44f6 100644 --- a/src/documents/src/engine/docx-colors.js +++ b/src/documents/src/engine/docx-colors.js @@ -1,4 +1,5 @@ -import { parseXml } from './docx-xml.js'; +import { childElements, elementChildren, parseXml } from './docx-xml.js'; +import { relationshipEntries } from './docx-relationships.js'; import { UnsupportedDocumentError } from './errors.js'; import { normalizeColor, themeColorTransform } from './colors.js'; @@ -6,12 +7,12 @@ const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; const A = 'http://schemas.openxmlformats.org/drawingml/2006/main'; const REL = 'http://schemas.openxmlformats.org/package/2006/relationships'; const R = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/'; -const children = n => [...(n?.children || [])]; +const children = n => n?.children ? elementChildren(n) : []; const fail = reason => { throw new UnsupportedDocumentError(`text colors with ${reason}`); }; const names = { dark1: 'dk1', light1: 'lt1', dark2: 'dk2', light2: 'lt2', hyperlink: 'hlink', followedHyperlink: 'folHlink', ...Object.fromEntries(Array.from({ length: 6 }, (_, i) => [`accent${i + 1}`, `accent${i + 1}`])) }; const aliases = { background1: ['bg1', 'light1'], background2: ['bg2', 'light2'], text1: ['t1', 'dark1'], text2: ['t2', 'dark2'] }; function only(parent, ns, name) { - const nodes = children(parent).filter(n => n.namespaceURI === ns && n.localName === name); + const nodes = childElements(parent, ns, name); if (nodes.length > 1) fail(`duplicate ${name}`); return nodes[0]; } @@ -25,16 +26,16 @@ export function colorReader(parts, settings) { let theme, mapping, themeLinks; const themeRelationships = () => { if (!themeLinks) { - const rels = parts['word/_rels/document.xml.rels'] && parseXml(parts['word/_rels/document.xml.rels']); - themeLinks = children(rels?.documentElement).filter(n => n.namespaceURI === REL && n.getAttribute('Type') === R + 'theme'); + const rels = parts['word/_rels/document.xml.rels'] && relationshipEntries(parts['word/_rels/document.xml.rels']); + themeLinks = (rels || []).filter(n => n.namespaceURI === REL && n.attribute('Type') === R + 'theme'); } return themeLinks; }; const themeBase = name => { if (!theme) { const links = themeRelationships(); - if (links.length !== 1 || ![null, 'Internal'].includes(links[0].getAttribute('TargetMode'))) fail('a missing, external or duplicate theme relationship'); - const target = links[0].getAttribute('Target') || ''; + if (links.length !== 1 || ![null, 'Internal'].includes(links[0].attribute('TargetMode'))) fail('a missing, external or duplicate theme relationship'); + const target = links[0].attribute('Target') || ''; if (/[\\?#%:]/.test(target)) fail('an unsupported theme target'); const segments = (target.startsWith('/') ? target.slice(1) : 'word/' + target).split('/'), normalized = []; for (const segment of segments) { diff --git a/src/documents/src/engine/docx-compatibility.js b/src/documents/src/engine/docx-compatibility.js index 27f97f402..aee5fb979 100644 --- a/src/documents/src/engine/docx-compatibility.js +++ b/src/documents/src/engine/docx-compatibility.js @@ -50,15 +50,52 @@ export function activeDescendants(node) { } // Prefixes used in attribute VALUES (Requires/Ignorable) are invisible to the // XML serializer. Carry in-scope namespace declarations on detached fragments. +// Reading a document serializes every paragraph this way, so the walks below index the +// attribute maps and follow siblings rather than copying live collections into arrays. export function serializeFragment(node) { - const copy = node.cloneNode(true), bound = new Set(); - for (let at = node; at?.nodeType === 1; at = at.parentElement) for (const a of at.attributes) if (a.namespaceURI === XMLNS && !bound.has(a.name)) { - bound.add(a.name); copy.setAttributeNS(XMLNS, a.name, a.value); + return serializeCopy(node.cloneNode(true), inScopeDeclarations(node)); +} +/** + * serializeFragment(node) as it is now, serialized only when first asked for. The node and + * the declarations it inherits are copied now, so later changes to the tree cannot alter the + * text. Opening keeps each body paragraph's source this way: most are never asked for (an + * unedited save writes the original part), and serializing them all was a large share of + * the time to open a long document. + */ +export function deferredFragment(node) { + let copy = node.cloneNode(true), declarations = inScopeDeclarations(node), text; + return () => { + if (text === undefined) { text = serializeCopy(copy, declarations); copy = declarations = null; } + return text; + }; +} +// The namespace declarations on `node` and its ancestors, the nearest of each prefix first. +function inScopeDeclarations(node) { + const declarations = [], bound = new Set(); + for (let at = node; at?.nodeType === 1; at = at.parentElement) { + const attributes = at.attributes; + for (let i = 0; i < attributes.length; i++) { + const a = attributes[i]; + if (a.namespaceURI === XMLNS && !bound.has(a.name)) { bound.add(a.name); declarations.push([a.name, a.value]); } + } } + return declarations; +} +function serializeCopy(copy, declarations) { + for (const [name, value] of declarations) copy.setAttributeNS(XMLNS, name, value); // Detached shells have no ancestors. Bind namespaced attributes explicitly so // DOM serializers cannot invent a prefix and reuse it on an unbound sibling. - for (const element of [copy, ...copy.getElementsByTagName('*')]) for (const a of [...element.attributes]) { - if (a.prefix && a.namespaceURI && a.namespaceURI !== XMLNS && a.prefix !== 'xml' && element.lookupNamespaceURI(a.prefix) !== a.namespaceURI) element.setAttributeNS(XMLNS, `xmlns:${a.prefix}`, a.namespaceURI); + // Each element and its descendants in document order; a declaration added here is + // appended to the element's attributes, so the count read first covers its own. + for (let element = copy; element;) { + const attributes = element.attributes; + for (let i = 0, count = attributes.length; i < count; i++) { + const a = attributes[i]; + if (a.prefix && a.namespaceURI && a.namespaceURI !== XMLNS && a.prefix !== 'xml' && element.lookupNamespaceURI(a.prefix) !== a.namespaceURI) element.setAttributeNS(XMLNS, `xmlns:${a.prefix}`, a.namespaceURI); + } + let next = element.firstElementChild; + while (!next && element !== copy) { next = element.nextElementSibling; if (!next) element = element.parentElement; } + element = next; } return new XMLSerializer().serializeToString(copy); } diff --git a/src/documents/src/engine/docx-content-controls.js b/src/documents/src/engine/docx-content-controls.js index 7875207f3..0045b5cf9 100644 --- a/src/documents/src/engine/docx-content-controls.js +++ b/src/documents/src/engine/docx-content-controls.js @@ -1,7 +1,7 @@ import { UnsupportedDocumentError } from './errors.js'; import { serializeFragment } from './docx-compatibility.js'; import { relatedPartPath } from './docx-relationships.js'; -import { parseXml, wordText } from './docx-xml.js'; +import { elementChildren, parseXml, wordText } from './docx-xml.js'; import { bookmarkReader } from './docx-bookmarks.js'; import { bookmarkEntries } from './bookmarks.js'; import { unresolvedBindingReader } from './docx-data-binding.js'; @@ -68,7 +68,7 @@ function projectRetainedControls(root) { } projectControlContents(root); } -const children = n => [...(n?.children || [])]; +const children = n => n?.children ? elementChildren(n) : []; const fail = reason => { throw new UnsupportedDocumentError(`content controls with ${reason}`); }; const child = (n, name) => children(n).find(c => c.namespaceURI === W && c.localName === name); function attributes(node, names = [], ns = W) { diff --git a/src/documents/src/engine/docx-contextual-spacing.js b/src/documents/src/engine/docx-contextual-spacing.js index f17ba9b28..a967fd0a7 100644 --- a/src/documents/src/engine/docx-contextual-spacing.js +++ b/src/documents/src/engine/docx-contextual-spacing.js @@ -1,8 +1,9 @@ import { UnsupportedDocumentError } from './errors.js'; +import { childElements } from './docx-xml.js'; const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; export function readContextualFlag(parent, tag = 'contextualSpacing') { - const nodes = [...(parent?.children || [])].filter(n => n.namespaceURI === W && n.localName === tag); + const nodes = childElements(parent, W, tag); if (nodes.length > 1) throw new UnsupportedDocumentError(`duplicate ${tag}`); const node = nodes[0]; if (!node) return undefined; if (node.children.length || node.textContent.trim() || [...node.attributes].some(a => a.namespaceURI === W && a.localName !== 'val')) throw new UnsupportedDocumentError(`unsupported ${tag} content`); diff --git a/src/documents/src/engine/docx-drawing-metadata.js b/src/documents/src/engine/docx-drawing-metadata.js index f4532b809..b2948253e 100644 --- a/src/documents/src/engine/docx-drawing-metadata.js +++ b/src/documents/src/engine/docx-drawing-metadata.js @@ -1,12 +1,13 @@ import { UnsupportedDocumentError } from './errors.js'; import { INK_EXTENSION_URI, readInkExtension } from './docx-ink-extension.js'; +import { elementChildren } from './docx-xml.js'; const A = 'http://schemas.openxmlformats.org/drawingml/2006/main'; const DECORATIVE = 'http://schemas.microsoft.com/office/drawing/2017/decorative'; const URI = '{C183D7F6-B498-43B3-948B-1728B52AA6E4}'; const CREATION = 'http://schemas.microsoft.com/office/drawing/2014/main'; const CREATION_URI = '{FF2B5EF4-FFF2-40B4-BE49-F238E27FC236}'; -const children = node => [...(node?.children || [])]; +const children = node => node?.children ? elementChildren(node) : []; const fail = why => { throw new UnsupportedDocumentError(`drawing metadata with ${why}`); }; function content(node, attrs = [], kind = 'decorative') { if ([...(node?.childNodes || [])].some(n => [3, 4].includes(n.nodeType) && /[^ \t\r\n]/.test(n.textContent))) fail('unexpected metadata text'); diff --git a/src/documents/src/engine/docx-drawing-preview.js b/src/documents/src/engine/docx-drawing-preview.js index 7c082ef4b..4cb524f50 100644 --- a/src/documents/src/engine/docx-drawing-preview.js +++ b/src/documents/src/engine/docx-drawing-preview.js @@ -1,8 +1,9 @@ import { validateImage } from './images.js'; +import { elementChildren } from './docx-xml.js'; const WP = 'http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing'; const A = 'http://schemas.openxmlformats.org/drawingml/2006/main'; -const children = node => [...(node?.children || [])]; +const children = node => node?.children ? elementChildren(node) : []; // Use only an unambiguous, unrotated inline frame. A floating placement or // invalid dimensions cannot safely be approximated as an inline rectangle. diff --git a/src/documents/src/engine/docx-drawing-style.js b/src/documents/src/engine/docx-drawing-style.js index 25c6ba68d..eb0ddd209 100644 --- a/src/documents/src/engine/docx-drawing-style.js +++ b/src/documents/src/engine/docx-drawing-style.js @@ -1,4 +1,4 @@ -import { parseXml } from './docx-xml.js'; +import { elementChildren, parseXml } from './docx-xml.js'; import { relatedPartPath } from './docx-relationships.js'; import { UnsupportedDocumentError } from './errors.js'; import { drawingPresetColors } from './drawing-preset-colors.js'; @@ -6,7 +6,7 @@ import { drawingSystemColors } from './drawing-system-colors.js'; import { luminanceTransform } from './colors.js'; const A = 'http://schemas.openxmlformats.org/drawingml/2006/main'; -const children = n => [...(n?.children || [])]; +const children = n => n?.children ? elementChildren(n) : []; const fail = why => { throw new UnsupportedDocumentError(`drawing styles with ${why}`); }; const clamp = n => Math.max(0, Math.min(1, n)); // The input and final color resolve through linear RGB percentage units. diff --git a/src/documents/src/engine/docx-field-results.js b/src/documents/src/engine/docx-field-results.js index be5298b1b..f3a04786d 100644 --- a/src/documents/src/engine/docx-field-results.js +++ b/src/documents/src/engine/docx-field-results.js @@ -1,9 +1,10 @@ import { MC, alternateBranch } from './docx-compatibility.js'; import { macroButtonLabelStart } from './docx-macro-buttons.js'; +import { elementChildren } from './docx-xml.js'; const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; const is = (node, name) => node.namespaceURI === W && node.localName === name; -const children = node => [...node.children]; +const children = node => elementChildren(node); const metadata = new Set(['pPr', 'rPr', 'sectPr', 'tblPr', 'trPr', 'tcPr', 'sdtPr', 'del', 'moveFrom']); // Read one story in document order, including fields spanning paragraphs and diff --git a/src/documents/src/engine/docx-fonts.js b/src/documents/src/engine/docx-fonts.js index ff19d3fde..673612e4a 100644 --- a/src/documents/src/engine/docx-fonts.js +++ b/src/documents/src/engine/docx-fonts.js @@ -1,4 +1,4 @@ -import { parseXml } from './docx-xml.js'; +import { childElements, elementChildren, parseXml } from './docx-xml.js'; import { relatedPartPath } from './docx-relationships.js'; import { FONT_SLOTS, checkedFontName } from './font-families.js'; import { UnsupportedDocumentError } from './errors.js'; @@ -6,10 +6,10 @@ import { documentEmbeddedFonts } from './embedded-fonts.js'; const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; const A = 'http://schemas.openxmlformats.org/drawingml/2006/main'; -const children = n => [...(n?.children || [])]; +const children = n => n?.children ? elementChildren(n) : []; const fail = why => { throw new UnsupportedDocumentError(`font declarations with ${why}`); }; function only(n, ns, name) { - const nodes = children(n).filter(c => c.namespaceURI === ns && c.localName === name); + const nodes = childElements(n, ns, name); if (nodes.length > 1) fail(`duplicate ${name}`); return nodes[0]; } diff --git a/src/documents/src/engine/docx-hyperlinks.js b/src/documents/src/engine/docx-hyperlinks.js index 585c1bac8..cc2117525 100644 --- a/src/documents/src/engine/docx-hyperlinks.js +++ b/src/documents/src/engine/docx-hyperlinks.js @@ -1,18 +1,19 @@ import { strToU8 } from 'fflate'; -import { parseXml } from './docx-xml.js'; +import { elementChildren, parseXml } from './docx-xml.js'; import { UnsupportedDocumentError } from './errors.js'; import { checkedHyperlinkTarget, hyperlinkId, internalHyperlink } from './hyperlinks.js'; import { relationshipPartPath } from './package-part-paths.js'; +import { relationshipEntries, relationshipRoot } from './docx-relationships.js'; const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; const R = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'; const REL = 'http://schemas.openxmlformats.org/package/2006/relationships'; const serialize = n => new XMLSerializer().serializeToString(n); -const children = n => [...n.children]; +const children = n => elementChildren(n); const relationships = (parts, path) => parts[path] ? parseXml(parts[path]) : parseXml(``); export function hyperlinkReader(parts, sourcePart = 'word/document.xml') { const path = relationshipPartPath(sourcePart); - let rels; + let root, byId; return node => { if ([...node.childNodes].some(n => [3, 4].includes(n.nodeType) && n.textContent.trim())) throw new UnsupportedDocumentError('text outside hyperlink runs'); for (const attr of [...node.attributes]) { @@ -28,14 +29,23 @@ export function hyperlinkReader(parts, sourcePart = 'word/document.xml') { if (node.hasAttributeNS(W, 'history') && !['true', 'false', '1', '0', 'on', 'off'].includes(history)) throw new UnsupportedDocumentError('invalid hyperlink history'); const common = { id: hyperlinkId(), shell: serialize(node.cloneNode(false)), tooltip: node.getAttributeNS(W, 'tooltip') || '', history: ['true', '1', 'on'].includes(history) }; if (!sourceId) return { ...common, anchor: node.hasAttributeNS(W, 'anchor') ? node.getAttributeNS(W, 'anchor') : null }; - rels ||= relationships(parts, path); - if (rels.documentElement.namespaceURI !== REL || rels.documentElement.localName !== 'Relationships') throw new UnsupportedDocumentError('invalid hyperlink relationships part'); - const matches = children(rels.documentElement).filter(r => r.getAttribute('Id') === sourceId); + // The relationships part is read once (relationshipEntries) and indexed: a document with + // hundreds of links would otherwise scan every relationship for each link. + if (!byId) { + const bytes = parts[path] || ``; + root = relationshipRoot(bytes); byId = new Map(); + for (const r of relationshipEntries(bytes)) { + const id = r.attribute('Id'); + if (byId.has(id)) byId.get(id).push(r); else byId.set(id, [r]); + } + } + if (root.namespaceURI !== REL || root.localName !== 'Relationships') throw new UnsupportedDocumentError('invalid hyperlink relationships part'); + const matches = byId.get(sourceId) || []; const rel = matches[0]; - if (matches.length !== 1 || rel.namespaceURI !== REL || rel.localName !== 'Relationship' || rel.getAttribute('Type') !== `${R}/hyperlink` || rel.getAttribute('TargetMode') !== 'External') throw new UnsupportedDocumentError('missing, duplicate or unsupported hyperlink relationship'); + if (matches.length !== 1 || rel.namespaceURI !== REL || rel.localName !== 'Relationship' || rel.attribute('Type') !== `${R}/hyperlink` || rel.attribute('TargetMode') !== 'External') throw new UnsupportedDocumentError('missing, duplicate or unsupported hyperlink relationship'); // Part 2 relationships permit any URI reference. A destination the editor // cannot open (a relative or file path, unescaped spaces) is kept verbatim. - const target = rel.getAttribute('Target'); + const target = rel.attribute('Target'); let retainedTarget = false; try { checkedHyperlinkTarget(target); } catch { retainedTarget = true; } return { ...common, target, sourceId, ...(retainedTarget ? { retainedTarget } : {}) }; diff --git a/src/documents/src/engine/docx-images.js b/src/documents/src/engine/docx-images.js index 52b8a5000..b0bf712ce 100644 --- a/src/documents/src/engine/docx-images.js +++ b/src/documents/src/engine/docx-images.js @@ -11,7 +11,7 @@ import { drawingStyleReader, drawingEffectColor } from './docx-drawing-style.js' import { readDrawingMetadata, readDrawingCreationId } from './docx-drawing-metadata.js'; import { readDrawingHyperlink, drawingHyperlinkWriter } from './docx-drawing-hyperlinks.js'; import { readShapeExtensions } from './docx-shape-extensions.js'; -import { parseXml, setContentTypeOverride } from './docx-xml.js'; +import { elementChildren, parseXml, setContentTypeOverride } from './docx-xml.js'; import { UnsupportedDocumentError } from './errors.js'; import { imageInfo, validateImage, normalizeCrop, normalizedRotation, imageExtents, CROP_SIDES } from './images.js'; import { strToU8 } from 'fflate'; @@ -39,7 +39,7 @@ export const ASVG = 'http://schemas.microsoft.com/office/drawing/2016/SVG/main'; const SVG_EXTENSION = '{96dac541-7b7a-43d3-8b79-37d633b846f1}'; const LOCAL_DPI = '{28a0092b-c50c-407e-a947-70e740481c1c}'; const serialize = n => new XMLSerializer().serializeToString(n); -const children = n => n ? [...n.children] : []; +const children = n => n ? elementChildren(n) : []; const child = (n, ns, name) => children(n).find(c => c.namespaceURI === ns && c.localName === name); const fail = why => { throw new UnsupportedDocumentError(`pictures with ${why}`); }; function check(n, ns, names) { diff --git a/src/documents/src/engine/docx-kerning.js b/src/documents/src/engine/docx-kerning.js index 6edcd87cb..0cf5e45e1 100644 --- a/src/documents/src/engine/docx-kerning.js +++ b/src/documents/src/engine/docx-kerning.js @@ -1,8 +1,9 @@ import { UnsupportedDocumentError } from './errors.js'; +import { childElements } from './docx-xml.js'; const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; export function readKerning(rPr) { - const nodes = [...(rPr?.children || [])].filter(n => n.namespaceURI === W && n.localName === 'kern'); + const nodes = childElements(rPr, W, 'kern'); if (nodes.length > 1) throw new UnsupportedDocumentError('duplicate kerning threshold'); const node = nodes[0]; if (!node) return {}; if (node.children.length || node.textContent.trim() || [...node.attributes].some(a => a.namespaceURI === W && a.localName !== 'val')) throw new UnsupportedDocumentError('unsupported kerning content'); diff --git a/src/documents/src/engine/docx-macro-buttons.js b/src/documents/src/engine/docx-macro-buttons.js index 90a092909..5e5f4181f 100644 --- a/src/documents/src/engine/docx-macro-buttons.js +++ b/src/documents/src/engine/docx-macro-buttons.js @@ -1,6 +1,6 @@ const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; const is = (node, name) => node?.namespaceURI === W && node.localName === name; -const children = node => [...(node?.children || [])]; +const children = node => node?.children ? elementChildren(node) : []; // The button shows the complete instruction tail, including literal quotes and // trailing spaces. The macro name is never exposed as a button or executed. @@ -169,3 +169,4 @@ export function previewMacroButtons(container, project = labels => labels) { import { pageFieldSegments } from './docx-page-fields.js'; import { serializeFragment } from './docx-compatibility.js'; import { UnsupportedDocumentError } from './errors.js'; +import { elementChildren } from './docx-xml.js'; diff --git a/src/documents/src/engine/docx-mail-merge.js b/src/documents/src/engine/docx-mail-merge.js index 85608c1ee..4c5f27e0b 100644 --- a/src/documents/src/engine/docx-mail-merge.js +++ b/src/documents/src/engine/docx-mail-merge.js @@ -13,6 +13,7 @@ import { strToU8, strFromU8 } from 'fflate'; import { parseXml } from './docx-xml.js'; import { relationshipPartPath, relativePartTarget } from './package-part-paths.js'; +import { relationshipEntries } from './docx-relationships.js'; import { planMerge, MERGE_TYPES, fieldKeyword } from './merge-fields.js'; import { xmlAttributeText } from './text-limits.js'; @@ -46,22 +47,26 @@ const resolveTarget = (source, target) => { for (const piece of target.replace(/^\//, '').split('/')) { if (piece === '..') parts.pop(); else if (piece && piece !== '.') parts.push(piece); } return parts.join('/'); }; +// The main document's relationships, read once for each version of the part (relationshipEntries): +// opening reads them in several places. Entries read attributes as getAttribute does. +function documentLinks(parts) { + const path = relationshipPartPath('word/document.xml'); + return relationshipEntries(parts[path] || ``).filter(n => n.namespaceURI === REL && n.localName === 'Relationship'); +} function settingsPath(parts) { - const rels = relationships(parts, 'word/document.xml').doc; - const link = elements(rels.documentElement, 'Relationship', REL).find(n => n.getAttribute('Type') === SETTINGS && n.getAttribute('TargetMode') !== 'External'); - return link ? resolveTarget('word/document.xml', link.getAttribute('Target')) : null; + const link = documentLinks(parts).find(n => n.attribute('Type') === SETTINGS && n.attribute('TargetMode') !== 'External'); + return link ? resolveTarget('word/document.xml', link.attribute('Target')) : null; } /** The custom XML part holding this editor's mail merge data: { path, doc } or null. */ function dataPart(parts) { - const rels = relationships(parts, 'word/document.xml').doc; - for (const link of elements(rels.documentElement, 'Relationship', REL)) { - if (link.getAttribute('Type') !== CUSTOM_XML || link.getAttribute('TargetMode') === 'External') continue; - const path = resolveTarget('word/document.xml', link.getAttribute('Target')); + for (const link of documentLinks(parts)) { + if (link.attribute('Type') !== CUSTOM_XML || link.attribute('TargetMode') === 'External') continue; + const path = resolveTarget('word/document.xml', link.attribute('Target')); if (!parts[path]) continue; try { const doc = parseXml(parts[path]); - if (doc.documentElement.namespaceURI === MAIL_MERGE_NS) return { path, doc, id: link.getAttribute('Id') }; + if (doc.documentElement.namespaceURI === MAIL_MERGE_NS) return { path, doc, id: link.attribute('Id') }; } catch { /* Someone else's malformed data part is not ours to read. */ } } return null; diff --git a/src/documents/src/engine/docx-numbering.js b/src/documents/src/engine/docx-numbering.js index 94314def3..2fc72fde7 100644 --- a/src/documents/src/engine/docx-numbering.js +++ b/src/documents/src/engine/docx-numbering.js @@ -13,7 +13,7 @@ import { readRunSpacing } from './docx-run-spacing.js'; import { checkedBaselineShift } from './vertical-align.js'; import { readRunVertical } from './docx-vertical-align.js'; import { readRunBackground } from './docx-backgrounds.js'; -import { parseXml as xml, setContentTypeOverride } from './docx-xml.js'; +import { childElement, elementChildren, parseXml as xml, setContentTypeOverride } from './docx-xml.js'; import { strToU8 } from 'fflate'; import { emptyNumbering, READ_NUMBER_FORMATS } from './numbering.js'; import { relatedPartPath } from './docx-relationships.js'; @@ -25,8 +25,8 @@ const W16CID = 'http://schemas.microsoft.com/office/word/2016/wordml/cid'; const REL = 'http://schemas.openxmlformats.org/package/2006/relationships'; const OFFICE_REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering'; const TYPE = 'application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml'; -const children = node => [...(node?.children || [])]; -const child = (node, name) => children(node).find(n => n.namespaceURI === W && n.localName === name); +const children = node => node?.children ? elementChildren(node) : []; +const child = (node, name) => node?.children ? childElement(node, W, name) : undefined; const val = node => node?.getAttributeNS(W, 'val'); const serialize = node => new XMLSerializer().serializeToString(node).replace(/^(<\?xml[^?]*encoding=)["'][^"']*["']/i, '$1"UTF-8"'); @@ -74,7 +74,7 @@ export function readNumbering(parts, readGeometry, readColor, readFonts, onError if (!path) return result; const root = xml(parts[path]).documentElement; if (root.namespaceURI !== W || root.localName !== 'numbering') throw new Error('Unsupported numbering namespace.'); - const readLevel = (node, emptyIndex) => guarded(() => { + const readLevelNow = (node, emptyIndex) => guarded(() => { const index = node ? integer(node.getAttributeNS(W, 'ilvl'), undefined, 8) : emptyIndex; if (index === undefined) throw new Error('Missing numbering level index.'); for (const n of children(node)) if (n.namespaceURI !== W || !['start', 'numFmt', 'lvlRestart', 'pStyle', 'isLgl', 'suff', 'lvlText', 'legacy', 'lvlJc', 'pPr', 'rPr'].includes(n.localName)) throw new Error(`Unsupported numbering property: ${n.localName}.`); @@ -142,6 +142,21 @@ export function readNumbering(parts, readGeometry, readColor, readFonts, onError restart: integer(val(child(node, 'lvlRestart')), undefined, 9), legal: !!on(child(node, 'isLgl')), styleId: val(child(node, 'pStyle')), geometry: readGeometry(pPr), ...(Object.keys(paragraph).length ? { paragraph, paragraphXml: serialize(pPr) } : {}), markerStyle, ...(symbol ? { symbol } : {}), ...(legacy ? { legacy } : {}) }; }); + // Lists made by copying one another repeat the same level definitions: a long document can + // declare thousands of levels and only dozens of distinct ones. A level's reading depends + // only on its markup (its serialization, which names its namespaces) and the document's + // fixed theme, fonts and colors, so each distinct level is read once; every level still + // gets its own copy. Errors a repeated level reports were reported when it was first read. + const levelsByMarkup = new Map(); + const readLevel = (node, emptyIndex) => { + if (!node) return readLevelNow(node, emptyIndex); + const key = new XMLSerializer().serializeToString(node); + const known = levelsByMarkup.get(key); + if (known) return structuredClone(known); + const level = readLevelNow(node, emptyIndex); + levelsByMarkup.set(key, structuredClone(level)); + return level; + }; // An ambiguous ID invalidates that identity, not every unrelated list. Keep // all source nodes for style-link ambiguity checks; never pick a duplicate. const identified = (tag, attribute, records) => { diff --git a/src/documents/src/engine/docx-paragraph-borders.js b/src/documents/src/engine/docx-paragraph-borders.js index 2a65eb571..3f5a2743c 100644 --- a/src/documents/src/engine/docx-paragraph-borders.js +++ b/src/documents/src/engine/docx-paragraph-borders.js @@ -1,8 +1,9 @@ import { BORDER_SIDES, checkedParagraphBorder, checkedParagraphBorders } from './paragraph-borders.js'; import { UnsupportedDocumentError } from './errors.js'; +import { elementChildren } from './docx-xml.js'; const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; -const children = node => [...(node?.children || [])]; +const children = node => node?.children ? elementChildren(node) : []; const fail = reason => { throw new UnsupportedDocumentError('paragraph borders with ' + reason); }; const number = (node, name, maximum, minimum = 0) => { const raw = node.getAttributeNS(W, name); diff --git a/src/documents/src/engine/docx-paragraph-shading.js b/src/documents/src/engine/docx-paragraph-shading.js index ed2c61a47..11329bef2 100644 --- a/src/documents/src/engine/docx-paragraph-shading.js +++ b/src/documents/src/engine/docx-paragraph-shading.js @@ -1,5 +1,6 @@ import { UnsupportedDocumentError } from './errors.js'; import { checkedShading } from './backgrounds.js'; +import { childElements } from './docx-xml.js'; const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; const percentages=new Map([5,10,12,15,20,25,30,35,37,40,45,50,55,60,62,65,70,75,80,85,87,90,95].map(n=>['pct'+n,2*n+([12,37,62,87].includes(n)?1:0)])); @@ -7,7 +8,7 @@ const percentages=new Map([5,10,12,15,20,25,30,35,37,40,45,50,55,60,62,65,70,75, // CT_Shd is an atomic declaration: omitted color channels mean auto, rather // than inheriting individual attributes from a preceding shading declaration. export function readParagraphShading(pPr, readColor) { - const nodes = [...(pPr?.children || [])].filter(n => n.namespaceURI === W && n.localName === 'shd'); + const nodes = childElements(pPr, W, 'shd'); if (!nodes.length) return undefined; if (nodes.length !== 1) throw new UnsupportedDocumentError('duplicate paragraph shading'); if ([...nodes[0].childNodes].some(n => n.nodeType === 1 || [3, 4].includes(n.nodeType) && n.textContent.trim())) throw new UnsupportedDocumentError('paragraph shading with child content'); diff --git a/src/documents/src/engine/docx-paragraph-spacing.js b/src/documents/src/engine/docx-paragraph-spacing.js index 5b3a7d9b8..4652865c1 100644 --- a/src/documents/src/engine/docx-paragraph-spacing.js +++ b/src/documents/src/engine/docx-paragraph-spacing.js @@ -1,9 +1,10 @@ import { UnsupportedDocumentError } from './errors.js'; +import { childElements } from './docx-xml.js'; const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; const fail = () => { throw new UnsupportedDocumentError('malformed or unsupported paragraph spacing declarations'); }; export function readParagraphSpacing(pPr) { - const nodes = [...(pPr?.children || [])].filter(n => n.namespaceURI === W && n.localName === 'spacing'); + const nodes = childElements(pPr, W, 'spacing'); if (nodes.length > 1) fail(); const node = nodes[0], result = {}; if (!node) return result; diff --git a/src/documents/src/engine/docx-picture-3d.js b/src/documents/src/engine/docx-picture-3d.js index a7a7abc08..9b1ddff68 100644 --- a/src/documents/src/engine/docx-picture-3d.js +++ b/src/documents/src/engine/docx-picture-3d.js @@ -1,11 +1,12 @@ import { UnsupportedDocumentError } from './errors.js'; import { drawingColor } from './docx-drawing-style.js'; import { neutralSketchLine } from './sketch-line-preview.js'; +import { elementChildren } from './docx-xml.js'; const A = 'http://schemas.openxmlformats.org/drawingml/2006/main'; const XMLNS = 'http://www.w3.org/2000/xmlns/'; const fail = why => { throw new UnsupportedDocumentError(`3D pictures with ${why}`); }; -const children = n => [...(n?.children || [])]; +const children = n => n?.children ? elementChildren(n) : []; const child = (n, name) => children(n).find(c => c.namespaceURI === A && c.localName === name); function check(node, attrs, names) { if (!node) return; diff --git a/src/documents/src/engine/docx-preview.js b/src/documents/src/engine/docx-preview.js index 6a5452d44..dbe004b07 100644 --- a/src/documents/src/engine/docx-preview.js +++ b/src/documents/src/engine/docx-preview.js @@ -1,11 +1,11 @@ import { paragraph } from './model.js'; import { MC, alternateBranch } from './docx-compatibility.js'; import { previewMacroButtons } from './docx-macro-buttons.js'; -import { runText } from './docx-xml.js'; +import { elementChildren, runText } from './docx-xml.js'; const MATH = 'http://schemas.openxmlformats.org/officeDocument/2006/math'; const WORD = new Set(['http://schemas.openxmlformats.org/wordprocessingml/2006/main', 'http://purl.oclc.org/ooxml/wordprocessingml/main']); -const children = node => [...(node?.children || [])]; +const children = node => node?.children ? elementChildren(node) : []; const word = (node, name) => WORD.has(node.namespaceURI) && node.localName === name; const child = (node, name) => children(node).find(n => word(n, name)); diff --git a/src/documents/src/engine/docx-relationships.js b/src/documents/src/engine/docx-relationships.js index 05e0c5dae..4915e2d12 100644 --- a/src/documents/src/engine/docx-relationships.js +++ b/src/documents/src/engine/docx-relationships.js @@ -7,18 +7,31 @@ const OFFICE_REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relati // A relationships part's entries, read once for each version of the part (its bytes, which are replaced, // not changed, when the part is written). The editor's controls look parts up through the main document's // relationships after every keystroke, and a document with hundreds of hyperlinks parsed them each time. +// Opening reads the main document's relationships in several readers; each used to parse the part. const readRelationships = new WeakMap(); -/** The child elements of a relationships part's root: [{ namespaceURI, localName, attribute(name) }]. */ -export function relationshipEntries(bytes) { - let entries = bytes && typeof bytes === 'object' ? readRelationships.get(bytes) : undefined; - if (!entries) { - entries = Object.freeze([...parseXml(bytes).documentElement.children].map(node => { - const attributes = new Map([...node.attributes].filter(a => !a.namespaceURI).map(a => [a.name, a.value])); - return Object.freeze({ namespaceURI: node.namespaceURI, localName: node.localName, attribute: name => attributes.get(name) ?? null }); - })); - if (bytes && typeof bytes === 'object') readRelationships.set(bytes, entries); +function readRelationshipPart(bytes) { + let read = bytes && typeof bytes === 'object' ? readRelationships.get(bytes) : undefined; + if (!read) { + const root = parseXml(bytes).documentElement; + read = Object.freeze({ + root: Object.freeze({ namespaceURI: root.namespaceURI, localName: root.localName }), + entries: Object.freeze([...root.children].map(node => { + const attributes = new Map([...node.attributes].filter(a => !a.namespaceURI).map(a => [a.name, a.value])); + return Object.freeze({ namespaceURI: node.namespaceURI, localName: node.localName, attribute: name => attributes.get(name) ?? null }); + })), + }); + if (bytes && typeof bytes === 'object') readRelationships.set(bytes, read); } - return entries; + return read; +} +/** The child elements of a relationships part's root: [{ namespaceURI, localName, attribute(name) }]. + * `attribute(name)` reads an attribute without a namespace, as the element's getAttribute(name) does. */ +export function relationshipEntries(bytes) { + return readRelationshipPart(bytes).entries; +} +/** The root element's name of a relationships part: { namespaceURI, localName }. */ +export function relationshipRoot(bytes) { + return readRelationshipPart(bytes).root; } // Resolve a singleton internal relationship from the supported main-document diff --git a/src/documents/src/engine/docx-review-settings.js b/src/documents/src/engine/docx-review-settings.js index 15eea5155..bd560d163 100644 --- a/src/documents/src/engine/docx-review-settings.js +++ b/src/documents/src/engine/docx-review-settings.js @@ -5,13 +5,13 @@ // styles for formatting restrictions (§17.7.4 style `locked`). Only declarations that change are // rewritten; everything else in the settings and styles parts is kept as it was. import { strToU8 } from 'fflate'; -import { parseXml } from './docx-xml.js'; +import { elementChildren, parseXml } from './docx-xml.js'; import { relatedPartPath } from './docx-relationships.js'; const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; const REL = 'http://schemas.openxmlformats.org/package/2006/relationships'; const serialize = n => new XMLSerializer().serializeToString(n).replace(/^(<\?xml[^?]*encoding=)["'][^"']*["']/i, '$1"UTF-8"'); -const children = node => node ? [...node.children] : []; +const children = node => node ? elementChildren(node) : []; const child = (node, name) => children(node).find(n => n.namespaceURI === W && n.localName === name); const flag = (node, name) => { const raw = node?.getAttributeNS(W, name); return raw === null || raw === undefined ? null : !['0', 'false', 'off'].includes(raw); }; const present = node => !!node && flag(node, 'val') !== false; diff --git a/src/documents/src/engine/docx-run-spacing.js b/src/documents/src/engine/docx-run-spacing.js index a4e4cf197..4a14b7df8 100644 --- a/src/documents/src/engine/docx-run-spacing.js +++ b/src/documents/src/engine/docx-run-spacing.js @@ -1,8 +1,9 @@ import { UnsupportedDocumentError } from './errors.js'; +import { childElements } from './docx-xml.js'; const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; export function readRunSpacing(rPr) { - const result = {}, nodes = [...(rPr?.children || [])].filter(n => n.namespaceURI === W && n.localName === 'spacing'); - if ([...(rPr?.children || [])].some(n => n.namespaceURI === W && n.localName === 'fitText')) result.fitText = true; + const result = {}, nodes = childElements(rPr, W, 'spacing'); + if (childElements(rPr, W, 'fitText').length) result.fitText = true; if (nodes.length > 1) throw new UnsupportedDocumentError('duplicate character spacing'); const node = nodes[0]; if (!node) return result; if (node.children.length || node.textContent.trim() || [...node.attributes].some(a => a.namespaceURI === W && a.localName !== 'val')) throw new UnsupportedDocumentError('unsupported character spacing content'); diff --git a/src/documents/src/engine/docx-shape-extensions.js b/src/documents/src/engine/docx-shape-extensions.js index 8adbabc73..5e2b2676d 100644 --- a/src/documents/src/engine/docx-shape-extensions.js +++ b/src/documents/src/engine/docx-shape-extensions.js @@ -1,4 +1,5 @@ import { UnsupportedDocumentError } from './errors.js'; +import { elementChildren } from './docx-xml.js'; const A = 'http://schemas.openxmlformats.org/drawingml/2006/main'; const A14 = 'http://schemas.microsoft.com/office/drawing/2010/main'; const MAC = 'http://schemas.microsoft.com/office/mac/drawingml/2011/main'; @@ -8,7 +9,7 @@ const HIDDEN_LINE = '{91240b29-f687-4f45-9708-019b960494df}'; const HIDDEN_FILL = '{909e8e84-426e-40dd-afc4-6f175d3dccd1}'; const MAC_WRAP = '{c572a759-6a51-4108-aa02-dfa0a04fc94b}'; const fail = reason => { throw new UnsupportedDocumentError(`shape extensions with ${reason}`); }; -const children = node => [...node.children]; +const children = node => elementChildren(node); function attributes(node, allowed) { for (const attr of node.attributes) if (attr.namespaceURI !== XMLNS && (attr.namespaceURI || !allowed.includes(attr.localName))) fail('unknown attributes'); if ([...node.childNodes].some(n => [3, 4].includes(n.nodeType) && /[^ \t\r\n]/.test(n.textContent))) fail('unexpected text'); diff --git a/src/documents/src/engine/docx-shapes.js b/src/documents/src/engine/docx-shapes.js index 701ca4444..87ecc9f41 100644 --- a/src/documents/src/engine/docx-shapes.js +++ b/src/documents/src/engine/docx-shapes.js @@ -20,10 +20,11 @@ import { ribbonGeometry } from './ribbon-geometry.js'; import { readShapeExtensions } from './docx-shape-extensions.js'; import { readPictureShadow } from './docx-picture-shadow.js'; import { readDrawingEffects } from './docx-drawing-effects.js'; +import { elementChildren } from './docx-xml.js'; const A = 'http://schemas.openxmlformats.org/drawingml/2006/main'; const PIC = 'http://schemas.openxmlformats.org/drawingml/2006/picture'; const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; -const children = n => [...(n?.children || [])]; +const children = n => n?.children ? elementChildren(n) : []; const child = (n, ns, name) => children(n).find(c => c.namespaceURI === ns && c.localName === name); const fail = why => { throw new UnsupportedDocumentError(`grouped drawings with ${why}`); }; function check(node, allowed) { diff --git a/src/documents/src/engine/docx-table-borders.js b/src/documents/src/engine/docx-table-borders.js index 75caabfe1..1ca0ee1d5 100644 --- a/src/documents/src/engine/docx-table-borders.js +++ b/src/documents/src/engine/docx-table-borders.js @@ -1,9 +1,10 @@ import { checkedCellBorder } from './cell-borders.js'; import { UnsupportedDocumentError } from './errors.js'; +import { elementChildren } from './docx-xml.js'; const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; const ORDER = ['top', 'start', 'left', 'bottom', 'end', 'right', 'insideH', 'insideV', 'tl2br', 'tr2bl']; -const children = n => [...(n?.children || [])]; +const children = n => n?.children ? elementChildren(n) : []; const fail = reason => { throw new UnsupportedDocumentError('table borders with ' + reason); }; const flag = (node, name) => { const value = node.getAttributeNS(W, name); diff --git a/src/documents/src/engine/docx-tables.js b/src/documents/src/engine/docx-tables.js index 01ff7608f..85aa6b04e 100644 --- a/src/documents/src/engine/docx-tables.js +++ b/src/documents/src/engine/docx-tables.js @@ -1,4 +1,4 @@ -import { parseXml } from './docx-xml.js'; +import { elementChildren, parseXml } from './docx-xml.js'; import { appendControlledBlocks, writeControlPlaceholder } from './docx-block-controls.js'; import { tableBookmarkResolver } from './bookmarks.js'; import { writeBookmark } from './docx-bookmarks.js'; @@ -20,7 +20,7 @@ import { tableStyleLook } from './table-style-regions.js'; import { refreshConditionalTable } from './conditional-table-formatting.js'; import { writeTableStyleProperties } from './docx-table-styles.js'; const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; -const children = n => [...(n?.children || [])]; +const children = n => n?.children ? elementChildren(n) : []; const child = (n, name) => children(n).find(c => c.namespaceURI === W && c.localName === name); const attr = (n, name) => n?.getAttributeNS(W, name); const normalTableStyle = style => !['1', 'true', 'on'].includes(attr(style, 'customStyle')) && attr(child(style, 'name'), 'val')?.toLowerCase() === 'normal table'; diff --git a/src/documents/src/engine/docx-text-scale.js b/src/documents/src/engine/docx-text-scale.js index 768296cfc..53e878c82 100644 --- a/src/documents/src/engine/docx-text-scale.js +++ b/src/documents/src/engine/docx-text-scale.js @@ -1,8 +1,9 @@ import { UnsupportedDocumentError } from './errors.js'; +import { childElements } from './docx-xml.js'; const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; export function readTextScale(rPr) { - const nodes = [...(rPr?.children || [])].filter(n => n.namespaceURI === W && n.localName === 'w'); + const nodes = childElements(rPr, W, 'w'); if (nodes.length > 1) throw new UnsupportedDocumentError('duplicate text width'); const node = nodes[0]; if (!node) return {}; if (node.children.length || node.textContent.trim() || [...node.attributes].some(a => a.namespaceURI === W && a.localName !== 'val')) throw new UnsupportedDocumentError('unsupported text width content'); diff --git a/src/documents/src/engine/docx-underlines.js b/src/documents/src/engine/docx-underlines.js index 24a176383..c055d25fa 100644 --- a/src/documents/src/engine/docx-underlines.js +++ b/src/documents/src/engine/docx-underlines.js @@ -1,8 +1,9 @@ import { checkedUnderline } from './underlines.js'; import { UnsupportedDocumentError } from './errors.js'; +import { childElements } from './docx-xml.js'; const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; export function readUnderline(rPr, readColor) { - const nodes = [...(rPr?.children || [])].filter(n => n.namespaceURI === W && n.localName === 'u'); + const nodes = childElements(rPr, W, 'u'); if (nodes.length > 1) throw new UnsupportedDocumentError('duplicate underline'); const node = nodes[0]; if (!node) return {}; const attrs = ['val', 'color', 'themeColor', 'themeTint', 'themeShade']; diff --git a/src/documents/src/engine/docx-vertical-align.js b/src/documents/src/engine/docx-vertical-align.js index cbce3f44d..100c2c0c4 100644 --- a/src/documents/src/engine/docx-vertical-align.js +++ b/src/documents/src/engine/docx-vertical-align.js @@ -1,11 +1,12 @@ import { checkedVerticalAlign } from './vertical-align.js'; import { UnsupportedDocumentError } from './errors.js'; +import { childElements } from './docx-xml.js'; const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; export function readRunVertical(rPr) { const result = {}; for (const tag of ['vertAlign', 'position']) { - const nodes = [...(rPr?.children || [])].filter(n => n.namespaceURI === W && n.localName === tag); + const nodes = childElements(rPr, W, tag); if (nodes.length > 1) throw new UnsupportedDocumentError(`duplicate run ${tag}`); const node = nodes[0]; if (!node) continue; if (node.children.length || node.textContent.trim() || [...node.attributes].some(a => a.namespaceURI === W && a.localName !== 'val')) throw new UnsupportedDocumentError(`unsupported run ${tag} content`); diff --git a/src/documents/src/engine/docx-xml.js b/src/documents/src/engine/docx-xml.js index b7a229f7d..aa8b249b5 100644 --- a/src/documents/src/engine/docx-xml.js +++ b/src/documents/src/engine/docx-xml.js @@ -13,6 +13,40 @@ export function wordText(node) { // w:br and w:cr (§17.3.3.1, §17.3.3.4). Word shows each one as a space. export const runText = node => wordText(node).replace(/\r\n|[\r\n]/g, ' '); +// Element children, read by walking siblings. Iterating a live `children` collection +// (`[...node.children]`) costs several times more, and the readers look up children +// tens of thousands of times while a large document opens. +/** The element children of `node`, as `[...node.children]` lists them (and failing as it fails). */ +export function elementChildren(node) { + const list = node.children; + let n = node.firstElementChild; + if (n === undefined) return [...list]; + const result = []; + for (; n; n = n.nextElementSibling) result.push(n); + return result; +} +/** The first element child of `node` with this namespace and local name, as + * `[...node.children].find(…)` finds it; undefined when there is none. */ +export function childElement(node, namespace, name) { + let n = node.firstElementChild; + if (n === undefined) return [...node.children].find(c => c.namespaceURI === namespace && c.localName === name); + for (; n; n = n.nextElementSibling) if (n.namespaceURI === namespace && n.localName === name) return n; + return undefined; +} +/** The element children of `node` with this namespace and local name, as + * `[...(node?.children || [])].filter(…)` lists them. */ +export function childElements(node, namespace, name) { + const result = []; + if (!node?.children) return result; + let n = node.firstElementChild; + if (n === undefined) { + for (const c of node.children) if (c.namespaceURI === namespace && c.localName === name) result.push(c); + return result; + } + for (; n; n = n.nextElementSibling) if (n.namespaceURI === namespace && n.localName === name) result.push(n); + return result; +} + const CONTENT_TYPES = 'http://schemas.openxmlformats.org/package/2006/content-types'; /** * Declare a part's content type in a parsed [Content_Types].xml. The stream holds at most one Override diff --git a/src/documents/src/engine/docx.js b/src/documents/src/engine/docx.js index 2b4a3b344..5737e5ab6 100644 --- a/src/documents/src/engine/docx.js +++ b/src/documents/src/engine/docx.js @@ -42,7 +42,7 @@ import { bodyFieldRanges } from './body-fields.js'; import { projectCachedFieldResults } from './docx-field-results.js'; import { shapeHasFields } from './shape-page-fields.js'; import { controlSignature, controlContentSignature, contentControlRanges, changedContentControls } from './content-controls.js'; -import { MC, alternateBranch, activeDescendants, serializeFragment, bindUsedNamespaces } from './docx-compatibility.js'; +import { MC, alternateBranch, activeDescendants, serializeFragment, deferredFragment, bindUsedNamespaces } from './docx-compatibility.js'; import { readParagraphBorders, writeParagraphBorders } from './docx-paragraph-borders.js'; import { readParagraphShading, writeParagraphShading } from './docx-paragraph-shading.js'; import { hyperlinkReader, hyperlinkWriter, bindHyperlinkNamespaces } from './docx-hyperlinks.js'; @@ -86,7 +86,7 @@ import { resolvedFormat, geometryPatch, checkedRunFormat, PARAGRAPH_FLAGS, chara import { levelParagraphPatch } from './numbering-paragraph.js'; import { UnsupportedDocumentError } from './errors.js'; export { UnsupportedDocumentError } from './errors.js'; -import { parseXml as xml, runText, setContentTypeOverride, xmlDepthExceeds } from './docx-xml.js'; +import { childElement, elementChildren, parseXml as xml, runText, setContentTypeOverride, xmlDepthExceeds } from './docx-xml.js'; import { emptyNumbering, levelFor, listMarkers } from './numbering.js'; import { readNumbering, writeNumbering } from './docx-numbering.js'; import { validateIndent, normalizeTabStops } from './paragraph-geometry.js'; @@ -114,8 +114,8 @@ export const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; const REL = 'http://schemas.openxmlformats.org/package/2006/relationships'; const XML = 'http://www.w3.org/XML/1998/namespace'; const serialize = node => new XMLSerializer().serializeToString(node); -const children = node => node ? [...node.children] : []; -const child = (node, name) => children(node).find(n => n.namespaceURI === W && n.localName === name); +const children = node => node ? elementChildren(node) : []; +const child = (node, name) => node ? childElement(node, W, name) : undefined; const value = node => node?.getAttributeNS(W, 'val'); const enabled = node => node ? !['0', 'false', 'off'].includes(value(node)) : null; const signature = p => JSON.stringify(p); @@ -189,9 +189,23 @@ function paragraphProperties(pPr, inherited = {}, readColor) { return result; } -// A numbering level's own paragraph properties, validated as a paragraph would be. -function levelParagraphReader(readColor) { - return pPr => { const patch = levelParagraphPatch(paragraphProperties(pPr, {}, readColor)); resolvedFormat(patch); return patch; }; +// A numbering level's paragraph properties (pPr) give both its geometry and its other +// paragraph properties. Each level reads the same pPr for one and then the other, and +// documents with many lists have thousands of levels: read each pPr once. The read is +// pure and neither patch changes it, so reusing the last one gives the same results. +function levelPropertyReaders(readColor) { + let lastNode, lastProperties; + const properties = pPr => { + if (pPr && pPr === lastNode) return lastProperties; + const result = paragraphProperties(pPr, {}, readColor); + if (pPr) { lastNode = pPr; lastProperties = result; } + return result; + }; + return { + geometry: pPr => geometryPatch(properties(pPr), true), + // A numbering level's own paragraph properties, validated as a paragraph would be. + paragraph: pPr => { const patch = levelParagraphPatch(properties(pPr)); resolvedFormat(patch); return patch; }, + }; } function numberingReference(pPr, inherited = {}, inTableStyle = false) { @@ -1362,7 +1376,8 @@ export class DocxPackage { styles.openTypeFeatures = attempt(() => readOpenTypeFeatures(settings?.documentElement, pageSettings.compatibilityMode), false); const tocHyperlinkRuns = preview ? projectCachedFieldResults(body, styles) : new WeakSet(); let numbering; - try { numbering = readNumbering(parts, node => geometryPatch(paragraphProperties(node, {}, readColor), true), readColor, readFonts, preview ? error => recordPreviewIssue(issues, error) : null, levelParagraphReader(readColor)); } catch (e) { if (preview) { recordPreviewIssue(issues, e); numbering = emptyNumbering(); } else throw e instanceof UnsupportedDocumentError ? e : new UnsupportedDocumentError(e.message); } + const levelReaders = levelPropertyReaders(readColor); + try { numbering = readNumbering(parts, levelReaders.geometry, readColor, readFonts, preview ? error => recordPreviewIssue(issues, error) : null, levelReaders.paragraph); } catch (e) { if (preview) { recordPreviewIssue(issues, e); numbering = emptyNumbering(); } else throw e instanceof UnsupportedDocumentError ? e : new UnsupportedDocumentError(e.message); } const checkMarkers = paragraphs => { const report = error => recordPreviewIssue(issues, error); try { listMarkers(paragraphs, numbering, preview ? report : null); } @@ -1803,7 +1818,10 @@ export class DocxPackage { } if (movedInto || moveRangeOpen()) crossingMoves.add(p); if (!context.drawingText && !context.pageStory) { - originals.set(p.id, { signature: signature(p), xml: serializeFragment(sourceNode), ...(proofingSafe && proofAnchors.length ? { proofAnchors, proofText: paragraphText(p) } : {}) }); + // The source XML is needed only to save this paragraph unchanged after other edits (and + // by the table reader), so it is serialized when first read; see deferredFragment. + const source = deferredFragment(sourceNode); + originals.set(p.id, { signature: signature(p), get xml() { return source(); }, ...(proofingSafe && proofAnchors.length ? { proofAnchors, proofText: paragraphText(p) } : {}) }); if (savedSpellingComplete && proofingSafe) spellingAnchors.set(p.id, proofAnchors); } return p; diff --git a/src/documents/src/engine/ligatures.js b/src/documents/src/engine/ligatures.js index 900de7f2d..cf5dea7bc 100644 --- a/src/documents/src/engine/ligatures.js +++ b/src/documents/src/engine/ligatures.js @@ -1,4 +1,5 @@ import { UnsupportedDocumentError } from './errors.js'; +import { childElements } from './docx-xml.js'; const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; const W14 = 'http://schemas.microsoft.com/office/word/2010/wordml'; const XMLNS = 'http://www.w3.org/2000/xmlns/'; @@ -8,7 +9,7 @@ export function checkedLigatures(value) { return value; } export function readLigatures(properties) { - const nodes = [...(properties?.children || [])].filter(n => n.namespaceURI === W14 && n.localName === 'ligatures'); + const nodes = childElements(properties, W14, 'ligatures'); if (nodes.length > 1) throw new UnsupportedDocumentError('duplicate ligature declaration'); if (!nodes.length) return {}; const node = nodes[0]; diff --git a/src/documents/src/engine/page-stories.js b/src/documents/src/engine/page-stories.js index 823e9413c..43f175053 100644 --- a/src/documents/src/engine/page-stories.js +++ b/src/documents/src/engine/page-stories.js @@ -1,4 +1,5 @@ import { parseXml } from './docx-xml.js'; +import { relationshipEntries } from './docx-relationships.js'; import { UnsupportedDocumentError } from './errors.js'; import { readPageNumbering } from './page-numbering.js'; @@ -32,7 +33,8 @@ function targetPath(target) { // Section references inherit independently for each first/even/default story. // Missing first/even stories remain blank; they do not fall back to default. export function readPageStories(parts, sectionNodes, settings, onUnsupported = null) { - const rels = parts['word/_rels/document.xml.rels'] ? parseXml(parts['word/_rels/document.xml.rels']).documentElement : null; + // Read once for each version of the part (relationshipEntries); entries read attributes as getAttribute does. + const rels = parts['word/_rels/document.xml.rels'] ? relationshipEntries(parts['word/_rels/document.xml.rels']) : null; const types = parseXml(parts['[Content_Types].xml']).documentElement; const stories = {}, sections = []; let inherited = { header: {}, footer: {} }; @@ -50,9 +52,9 @@ export function readPageStories(parts, sectionNodes, settings, onUnsupported = n const variant = ref.getAttributeNS(W, 'type'), id = ref.getAttributeNS(R, 'id'); try { if (!['default', 'first', 'even'].includes(variant) || counts.get(variant) > 1 || !id) fail(`invalid or duplicate ${kind} reference`); - const links = [...(rels?.children || [])].filter(n => n.namespaceURI === REL && n.localName === 'Relationship' && n.getAttribute('Id') === id); - if (links.length !== 1 || links[0].getAttribute('Type') !== R + '/' + kind || ![null, 'Internal'].includes(links[0].getAttribute('TargetMode'))) fail(`invalid or external ${kind} relationship`); - const path = targetPath(links[0].getAttribute('Target')); + const links = (rels || []).filter(n => n.namespaceURI === REL && n.localName === 'Relationship' && n.attribute('Id') === id); + if (links.length !== 1 || links[0].attribute('Type') !== R + '/' + kind || ![null, 'Internal'].includes(links[0].attribute('TargetMode'))) fail(`invalid or external ${kind} relationship`); + const path = targetPath(links[0].attribute('Target')); if (!Object.hasOwn(parts, path)) fail(`a missing ${kind} part`); const override = [...types.children].find(n => n.localName === 'Override' && n.getAttribute('PartName') === '/' + path); const type = override || [...types.children].find(n => n.localName === 'Default' && n.getAttribute('Extension') === path.split('.').at(-1)); diff --git a/src/documents/src/engine/sketch-line-preview.js b/src/documents/src/engine/sketch-line-preview.js index b621e8f05..82058faf0 100644 --- a/src/documents/src/engine/sketch-line-preview.js +++ b/src/documents/src/engine/sketch-line-preview.js @@ -1,11 +1,12 @@ import { UnsupportedDocumentError } from './errors.js'; import { curvedSketchRectangle, curvedSketchEllipse } from './sketch-curves.js'; +import { elementChildren } from './docx-xml.js'; const A = 'http://schemas.openxmlformats.org/drawingml/2006/main'; const ASK = 'http://schemas.microsoft.com/office/drawing/2018/sketchyshapes'; const XMLNS = 'http://www.w3.org/2000/xmlns/'; const URI = '{c807c97d-bfc1-408e-a445-0c87eb9f89a2}'; const fail = why => { throw new UnsupportedDocumentError(`sketch outlines with ${why}`); }; -const children = n => [...(n?.children || [])]; +const children = n => n?.children ? elementChildren(n) : []; const is = (n, ns, name) => n?.namespaceURI === ns && n.localName === name; function attributes(n, allowed) { if ([...n.attributes].some(a => a.namespaceURI !== XMLNS && (a.namespaceURI || !allowed.includes(a.localName))) || [...n.childNodes].some(c => [3, 4].includes(c.nodeType) && c.textContent.trim())) fail('invalid attributes or text'); diff --git a/src/documents/src/engine/text-3d.js b/src/documents/src/engine/text-3d.js index 533f9955c..b13293ae7 100644 --- a/src/documents/src/engine/text-3d.js +++ b/src/documents/src/engine/text-3d.js @@ -1,5 +1,5 @@ import { W14 } from './text-outline.js'; -import { parseXml } from './docx-xml.js'; +import { childElements, parseXml } from './docx-xml.js'; import { serializeFragment } from './docx-compatibility.js'; const names = { text3d: 'props3d', textScene: 'scene3d' }; @@ -19,7 +19,7 @@ export function checkedText3d(value, key) { declaration(value, key); return valu export function readText3d(rPr) { const result = {}; for (const [key, name] of Object.entries(names)) { - const nodes = [...(rPr?.children || [])].filter(n => n.namespaceURI === W14 && n.localName === name); + const nodes = childElements(rPr, W14, name); // An ambiguous malformed declaration must not accidentally expose an // inherited effect. Original run XML remains available for preservation. if (nodes.length) result[key] = nodes.length === 1 ? serializeFragment(nodes[0]) : null; @@ -42,7 +42,7 @@ export function writeText3d(properties, value, key) { } const attr = (n, key, fallback) => n?.getAttributeNS(W14, key) ?? fallback; -const children = (n, name) => [...(n?.children || [])].filter(c => c.namespaceURI === W14 && c.localName === name); +const children = (n, name) => childElements(n, W14, name); const number = (n, key, fallback = 0) => { const value = attr(n, key, String(fallback)); return /^\+?\d+$/.test(value) && Number.isSafeInteger(Number(value)) ? Number(value) : NaN; diff --git a/src/documents/src/engine/text-fill.js b/src/documents/src/engine/text-fill.js index 1561d2ff0..234152317 100644 --- a/src/documents/src/engine/text-fill.js +++ b/src/documents/src/engine/text-fill.js @@ -1,6 +1,7 @@ import { normalizeColor, luminanceTransform } from './colors.js'; import { UnsupportedDocumentError } from './errors.js'; import { W14 } from './text-outline.js'; +import { childElements } from './docx-xml.js'; const fail = () => { throw new UnsupportedDocumentError('unsupported text fill properties'); }; export function checkedTextFill(value) { @@ -11,7 +12,7 @@ export function checkedTextFill(value) { } export function readTextFill(rPr, readColor) { - const nodes = [...(rPr?.children || [])].filter(n => n.namespaceURI === W14 && n.localName === 'textFill'); + const nodes = childElements(rPr, W14, 'textFill'); if (!nodes.length) return {}; if (nodes.length !== 1) fail(); const attrs = (n, allowed = []) => { diff --git a/src/documents/src/engine/text-outline.js b/src/documents/src/engine/text-outline.js index 5f8e5973f..eac14e639 100644 --- a/src/documents/src/engine/text-outline.js +++ b/src/documents/src/engine/text-outline.js @@ -1,5 +1,6 @@ import { normalizeColor, luminanceTransform } from './colors.js'; import { UnsupportedDocumentError } from './errors.js'; +import { childElements } from './docx-xml.js'; export const W14 = 'http://schemas.microsoft.com/office/word/2010/wordml'; const fail = () => { throw new UnsupportedDocumentError('unsupported text outline properties'); }; @@ -15,7 +16,7 @@ export function checkedTextOutline(value) { } export function readTextOutline(rPr, readColor) { - const nodes = [...(rPr?.children || [])].filter(n => n.namespaceURI === W14 && n.localName === 'textOutline'); + const nodes = childElements(rPr, W14, 'textOutline'); if (!nodes.length) return {}; if (nodes.length !== 1) fail(); const node = nodes[0], children = [...node.children]; diff --git a/src/documents/src/engine/text-shadow.js b/src/documents/src/engine/text-shadow.js index 95a84e921..f0916fc4e 100644 --- a/src/documents/src/engine/text-shadow.js +++ b/src/documents/src/engine/text-shadow.js @@ -2,6 +2,7 @@ import { UnsupportedDocumentError } from './errors.js'; import { normalizeColor } from './colors.js'; import { drawingEffectColor } from './docx-drawing-style.js'; import { W14 } from './text-outline.js'; +import { childElements } from './docx-xml.js'; const A = 'http://schemas.openxmlformats.org/drawingml/2006/main'; const XMLNS = 'http://www.w3.org/2000/xmlns/'; @@ -23,7 +24,7 @@ export function checkedTextShadow(value) { } export function readTextShadow(properties, readColor) { - const nodes = [...(properties?.children || [])].filter(node => node.namespaceURI === W14 && node.localName === 'shadow'); + const nodes = childElements(properties, W14, 'shadow'); if (!nodes.length) return {}; if (nodes.length !== 1) fail(); const node = nodes[0], names = { blurRad: 'blurRadius', dist: 'distance', dir: 'direction', sx: 'scaleX', sy: 'scaleY', kx: 'skewX', ky: 'skewY', algn: 'alignment' }; diff --git a/src/documents/tests/docx-read-reuse.test.js b/src/documents/tests/docx-read-reuse.test.js new file mode 100644 index 000000000..9fe303026 --- /dev/null +++ b/src/documents/tests/docx-read-reuse.test.js @@ -0,0 +1,113 @@ +// Opening reuses work it used to repeat: each distinct numbering level is read once, +// relationships are parsed once, and body paragraph sources are serialized on demand. +// These check that the reuse never shares state or changes what is read or saved. +import { beforeAll, afterAll, it, expect } from 'vitest'; +import { JSDOM } from 'jsdom'; +import { zipSync, unzipSync, strToU8, strFromU8 } from 'fflate'; +import { DocxPackage } from '../src/engine/docx.js'; +import { serializeFragment, deferredFragment } from '../src/engine/docx-compatibility.js'; +import { childElement, childElements, elementChildren } from '../src/engine/docx-xml.js'; + +let dom; +beforeAll(() => { dom = new JSDOM(); globalThis.DOMParser = dom.window.DOMParser; globalThis.XMLSerializer = dom.window.XMLSerializer; }); +afterAll(() => { dom.window.close(); delete globalThis.DOMParser; delete globalThis.XMLSerializer; }); + +const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; +const R = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'; +const level = (ilvl, extra = '') => `${extra}`; +function docx({ numbering, body, rels = '' }) { + return zipSync({ + '[Content_Types].xml': strToU8(''), + '_rels/.rels': strToU8(''), + 'word/_rels/document.xml.rels': strToU8(`${rels}`), + 'word/numbering.xml': strToU8(`${numbering}`), + 'word/document.xml': strToU8(`${body}`), + }); +} +const listParagraph = (numId, text) => `${text}`; + +it('gives repeated numbering levels equal but separate readings', async () => { + const levels = level(0) + level(1); + const pkg = await DocxPackage.open(docx({ + numbering: `${levels}${levels}` + + `${level(0)}`, + body: listParagraph(1, 'One') + listParagraph(2, 'Two'), + })); + const [first, second] = [pkg.numbering.abstracts[1].levels, pkg.numbering.abstracts[2].levels]; + expect(second).toEqual(first); + expect(first[0].geometry).toEqual({ indentLeft: 720, firstLine: -360, tabs: [{ position: 720, alignment: 'left', leader: 'none', numberingOnly: true }] }); + expect(first[0].markerStyle.bold).toBe(true); + expect(second[0]).not.toBe(first[0]); + expect(second[0].geometry.tabs).not.toBe(first[0].geometry.tabs); + // The override reads the same markup; its later count adjustment touches only itself. + const override = pkg.numbering.instances[2].overrides[0].level; + expect(override.restartStart).toBe(1); + expect(first[0].restartStart).toBeUndefined(); + first[0].geometry.tabs.push({ position: 1 }); + expect(second[0].geometry.tabs).toHaveLength(1); + expect(override.geometry.tabs).toHaveLength(1); +}); + +it('reports an unsupported property of a repeated level and still reads every copy', async () => { + const odd = level(0, ''); + const bytes = docx({ + numbering: `${odd}${odd}` + + '', + body: listParagraph(1, 'One') + listParagraph(2, 'Two'), + }); + await expect(DocxPackage.open(bytes)).rejects.toThrow('Unsupported numbering paragraph property: snapToGrid.'); + const pkg = await DocxPackage.open(bytes, { bestEffort: true }); + expect(pkg.readOnly).toBe(true); + expect(pkg.previewIssues).toContain('Unsupported numbering paragraph property: snapToGrid.'); + const [first, second] = [pkg.numbering.abstracts[1].levels[0], pkg.numbering.abstracts[2].levels[0]]; + expect(first.geometry.indentLeft).toBe(720); + expect(second).toEqual(first); + expect(second).not.toBe(first); +}); + +it('reads hyperlinks through the shared relationship index and keeps saving unchanged paragraphs as they were', async () => { + const link = (id, text) => `${text}`; + const bytes = docx({ + numbering: '', + rels: '' + + '' + + '', + body: link('rA', 'First') + link('rA', 'Again') + 'Kept as written', + }); + const pkg = await DocxPackage.open(bytes); + expect(pkg.paragraphs.slice(0, 2).map(p => p.runs[0].link.target)).toEqual(['https://example.com/a', 'https://example.com/a']); + // A duplicate relationship ID is still ambiguous. + const ambiguous = docx({ numbering: '', rels: strFromU8(unzipSync(bytes)['word/_rels/document.xml.rels']).replace(/^.*?]*\/>/, '').replace('', ''), body: link('rB', 'Which') }); + await expect(DocxPackage.open(ambiguous)).rejects.toThrow(/hyperlink relationship/); + const edited = structuredClone(pkg.paragraphs); + edited[0].runs[0].text += '!'; + const saved = strFromU8(pkg.exportBodyParts(edited)['word/document.xml']); + expect(saved).toContain('Kept as written'); + expect(saved).toContain('First!'); +}); + +it('serializes a deferred fragment exactly as it was when taken', () => { + const doc = new DOMParser().parseFromString(`Text`, 'application/xml'); + const p = doc.getElementsByTagNameNS(W, 'p')[0], expected = serializeFragment(p); + const later = deferredFragment(p); + p.firstElementChild.remove(); + doc.documentElement.removeAttribute('xmlns:o'); + doc.documentElement.setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:x', 'urn:x'); + expect(expected).toContain('xmlns:o="urn:other"'); + expect(later()).toBe(expected); + expect(later()).toBe(expected); + expect(serializeFragment(p)).not.toBe(expected); +}); + +it('lists element children as the collection does', () => { + const doc = new DOMParser().parseFromString(` text`, 'application/xml'); + const root = doc.documentElement; + expect(elementChildren(root)).toEqual([...root.children]); + expect(elementChildren(doc)).toEqual([...doc.children]); + expect(childElement(root, W, 'b')).toBe(root.children[0]); + expect(childElement(root, W, 'missing')).toBeUndefined(); + expect(childElements(root, W, 'b')).toEqual([root.children[0], root.children[2]]); + expect(childElements(null, W, 'b')).toEqual([]); + expect(childElements(root.firstChild, W, 'b')).toEqual([]); + expect(() => elementChildren(root.firstChild)).toThrow(TypeError); +});