From cd6d934a140b4668395ab47e2a64b429a6678412 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Wed, 28 Oct 2026 12:10:48 -0700 Subject: [PATCH 1/2] Documents: open long documents faster by not repeating reading work Reading a 3 MB document with 1,500 paragraphs, 176 lists and 300 hyperlinks spent most of its time repeating work. Looking up an element's children copied the live children collection into an array (tens of thousands of times: every property of every paragraph, run, style and list level); the readers now walk siblings, several times faster. The main document's relationships (60 KB) were parsed again by the theme colors, hyperlinks, mail merge and header/footer readers, and each hyperlink scanned every relationship; they are now read once and indexed. Each list level read its paragraph properties twice, and the document declares 1,584 levels with only 36 distinct ones: a level's properties are now read once and each distinct level once, every level still getting its own copy. Serializing paragraph sources indexes attributes rather than copying them. DocxPackage.open on that document in Chrome: 505 ms -> 367 ms (median of 9). What is read and saved is unchanged: the opened package and its saved and edited exports are identical for all 2,257 corpus documents. --- .../src/engine/diagram-data-model.js | 3 +- src/documents/src/engine/docx-backgrounds.js | 3 +- src/documents/src/engine/docx-cell-margins.js | 3 +- src/documents/src/engine/docx-chart-text.js | 3 +- src/documents/src/engine/docx-colors.js | 15 +-- .../src/engine/docx-compatibility.js | 33 +++++- .../src/engine/docx-content-controls.js | 4 +- .../src/engine/docx-contextual-spacing.js | 3 +- .../src/engine/docx-drawing-metadata.js | 3 +- .../src/engine/docx-drawing-preview.js | 3 +- .../src/engine/docx-drawing-style.js | 4 +- .../src/engine/docx-field-results.js | 3 +- src/documents/src/engine/docx-fonts.js | 6 +- src/documents/src/engine/docx-hyperlinks.js | 26 +++-- src/documents/src/engine/docx-images.js | 4 +- src/documents/src/engine/docx-kerning.js | 3 +- .../src/engine/docx-macro-buttons.js | 3 +- src/documents/src/engine/docx-mail-merge.js | 21 ++-- src/documents/src/engine/docx-numbering.js | 23 +++- .../src/engine/docx-paragraph-borders.js | 3 +- .../src/engine/docx-paragraph-shading.js | 3 +- .../src/engine/docx-paragraph-spacing.js | 3 +- src/documents/src/engine/docx-picture-3d.js | 3 +- src/documents/src/engine/docx-preview.js | 4 +- .../src/engine/docx-relationships.js | 33 ++++-- .../src/engine/docx-review-settings.js | 4 +- src/documents/src/engine/docx-run-spacing.js | 5 +- .../src/engine/docx-shape-extensions.js | 3 +- src/documents/src/engine/docx-shapes.js | 3 +- .../src/engine/docx-table-borders.js | 3 +- src/documents/src/engine/docx-tables.js | 4 +- src/documents/src/engine/docx-text-scale.js | 3 +- src/documents/src/engine/docx-underlines.js | 3 +- .../src/engine/docx-vertical-align.js | 3 +- src/documents/src/engine/docx-xml.js | 34 ++++++ src/documents/src/engine/docx.js | 29 +++-- src/documents/src/engine/ligatures.js | 3 +- src/documents/src/engine/page-stories.js | 10 +- .../src/engine/sketch-line-preview.js | 3 +- src/documents/src/engine/text-3d.js | 6 +- src/documents/src/engine/text-fill.js | 3 +- src/documents/src/engine/text-outline.js | 3 +- src/documents/src/engine/text-shadow.js | 3 +- src/documents/tests/docx-read-reuse.test.js | 107 ++++++++++++++++++ 44 files changed, 349 insertions(+), 98 deletions(-) create mode 100644 src/documents/tests/docx-read-reuse.test.js 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..6d260888f 100644 --- a/src/documents/src/engine/docx-compatibility.js +++ b/src/documents/src/engine/docx-compatibility.js @@ -50,15 +50,38 @@ 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)); +} +// 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..1e4713b6e 100644 --- a/src/documents/src/engine/docx.js +++ b/src/documents/src/engine/docx.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); } 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..ba5e94ca2 --- /dev/null +++ b/src/documents/tests/docx-read-reuse.test.js @@ -0,0 +1,107 @@ +// Opening reuses work it used to repeat: each distinct numbering level is read once and +// relationships are parsed once. +// 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 } 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 fragment with the namespaces it inherits', () => { + const doc = new DOMParser().parseFromString(`Text`, 'application/xml'); + const text = serializeFragment(doc.getElementsByTagNameNS(W, 'p')[0]); + expect(text).toContain('xmlns:o="urn:other"'); + expect(new DOMParser().parseFromString(text, 'application/xml').documentElement.getAttributeNS('urn:other', 'attr')).toBe('1'); +}); + +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); +}); From 6f147e988b35dd89f17be4af8ba32a4cecb91c62 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Wed, 28 Oct 2026 12:11:00 -0700 Subject: [PATCH 2/2] Documents: serialize a paragraph's source only when a save needs it Opening kept the XML of every body paragraph, so that a save after other edits writes it unchanged. Each one is serialized with the two dozen namespace declarations it inherits from the document, about 3 MB of text for a 1,500-paragraph document, and most of it is never used: an unedited save writes the original part, and a read-only view saves nothing. Opening now copies each paragraph and the declarations it inherits, which later changes to the tree cannot reach, and serializes the copy when the source is first asked for. DocxPackage.open on that document in Chrome: 367 ms -> 325 ms (median of 9). The first save after an edit serializes the paragraphs instead. The opened package and its saved and edited exports are identical for all 2,257 corpus documents. --- .../src/engine/docx-compatibility.js | 14 ++++++++++++ src/documents/src/engine/docx.js | 7 ++++-- src/documents/tests/docx-read-reuse.test.js | 22 ++++++++++++------- 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/src/documents/src/engine/docx-compatibility.js b/src/documents/src/engine/docx-compatibility.js index 6d260888f..aee5fb979 100644 --- a/src/documents/src/engine/docx-compatibility.js +++ b/src/documents/src/engine/docx-compatibility.js @@ -55,6 +55,20 @@ export function activeDescendants(node) { export function serializeFragment(node) { 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(); diff --git a/src/documents/src/engine/docx.js b/src/documents/src/engine/docx.js index 1e4713b6e..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'; @@ -1818,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/tests/docx-read-reuse.test.js b/src/documents/tests/docx-read-reuse.test.js index ba5e94ca2..9fe303026 100644 --- a/src/documents/tests/docx-read-reuse.test.js +++ b/src/documents/tests/docx-read-reuse.test.js @@ -1,11 +1,11 @@ -// Opening reuses work it used to repeat: each distinct numbering level is read once and -// relationships are parsed once. +// 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 } from '../src/engine/docx-compatibility.js'; +import { serializeFragment, deferredFragment } from '../src/engine/docx-compatibility.js'; import { childElement, childElements, elementChildren } from '../src/engine/docx-xml.js'; let dom; @@ -86,11 +86,17 @@ it('reads hyperlinks through the shared relationship index and keeps saving unch expect(saved).toContain('First!'); }); -it('serializes a fragment with the namespaces it inherits', () => { - const doc = new DOMParser().parseFromString(`Text`, 'application/xml'); - const text = serializeFragment(doc.getElementsByTagNameNS(W, 'p')[0]); - expect(text).toContain('xmlns:o="urn:other"'); - expect(new DOMParser().parseFromString(text, 'application/xml').documentElement.getAttributeNS('urn:other', 'attr')).toBe('1'); +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', () => {