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);
+});