diff --git a/src/documents/src/engine/bibliography-fields.js b/src/documents/src/engine/bibliography-fields.js
index 85ae1f244..f70ebeb55 100644
--- a/src/documents/src/engine/bibliography-fields.js
+++ b/src/documents/src/engine/bibliography-fields.js
@@ -4,6 +4,7 @@
// customXml relationships; a new one gets its item properties part (§22.5.2.1).
import { strToU8, strFromU8 } from 'fflate';
import { parseXml } from './docx-xml.js';
+import { relationshipEntries } from './docx-relationships.js';
import { bodyFieldRanges } from './body-fields.js';
import { BIB_NS, newGuid, parseSources, serializeSources, parseCitation, citationInstruction, bibliographyInstruction } from './bibliography.js';
import { CITATION_STYLES, DEFAULT_STYLE, citationContext, formatBibliography, formatCitation } from './citation-styles.js';
@@ -24,8 +25,8 @@ const resolve = (base, target) => {
/** Paths of the main document's custom XML items. */
export function customXmlItems(parts) {
if (!parts[DOCUMENT_RELS]) return [];
- const rels = parseXml(parts[DOCUMENT_RELS]).documentElement;
- return [...rels.children].filter(n => n.getAttribute('Type') === CUSTOM_XML && n.getAttribute('TargetMode') !== 'External').map(n => resolve('word/document.xml', n.getAttribute('Target'))).filter(path => Object.hasOwn(parts, path));
+ // The relationships are read once for each version of the part (relationshipEntries).
+ return relationshipEntries(parts[DOCUMENT_RELS]).filter(n => n.attribute('Type') === CUSTOM_XML && n.attribute('TargetMode') !== 'External').map(n => resolve('word/document.xml', n.attribute('Target'))).filter(path => Object.hasOwn(parts, path));
}
/** The bibliography part: { path, data } (data from parseSources), or null when the document has none. */
diff --git a/src/documents/src/engine/canvas-editor.js b/src/documents/src/engine/canvas-editor.js
index dc8a2df30..c34e0611b 100644
--- a/src/documents/src/engine/canvas-editor.js
+++ b/src/documents/src/engine/canvas-editor.js
@@ -30,6 +30,7 @@ import { text3dSurface } from './text-3d.js';
import { enterCellText, leaveCellText } from './canvas-cell-text.js';
import { tableBorderWidth } from './table-border-layout.js';
import { layoutPageStories } from './page-story-layout.js';
+import { layoutSerializer } from './page-story-measure.js';
import { drawingPageContexts, drawingPageContext } from './shape-page-fields.js';
import { paintParagraphBorder } from './canvas-paragraph-borders.js';
import { paintTableBorder, paintCellDiagonals } from './canvas-table-borders.js';
@@ -242,6 +243,8 @@ export class CanvasEditor {
get pageStories() { return this.session.pageStories; }
exportText() { return this.session.body.text; }
export(pkg) { return this.session.export(pkg); }
+ /** export(), zipped off the page's thread: a Promise of the Blob. */
+ exportAsync(pkg) { return this.session.export(pkg, { async: true }); }
editStory(path = null, page = 0) {
if (this.readOnly || path !== null && !this.pageStories?.stories[path]?.editable) return false;
this.finishComposition();
@@ -697,8 +700,10 @@ export class CanvasEditor {
this.measure.paragraphCache?.().nextGeneration(view?.kind ?? null);
const body = this.session.body;
const stories = view ? viewPageStories(this.pageStories, view.kind) : this.pageStories;
- let layout = layoutDocument(body.paragraphs, this.measure, pageGeometry(body.pageSettings), body.numbering, body.tables, stories, { bestEffort: this.readOnly, drawingLayouts: this.drawingLayouts, ...(!this.readOnly ? { styles: this.model.styles } : {}), ...(view ? { viewGeometry: viewGeometry(view) } : {}) });
- const storyLayout = layoutPageStories(layout, stories, [...body.paragraphs.filter(p => p.sectionEnd).map(p => p.sectionEnd), body.pageSettings], this.measure, this.model.numbering, { bestEffort: this.readOnly, drawingLayouts: this.drawingLayouts, ...(!this.readOnly ? { styles: this.model.styles } : {}) });
+ // The body and its page stories read the same styles and list definitions: serialize each once.
+ const serialize = layoutSerializer();
+ let layout = layoutDocument(body.paragraphs, this.measure, pageGeometry(body.pageSettings), body.numbering, body.tables, stories, { bestEffort: this.readOnly, drawingLayouts: this.drawingLayouts, serialize, ...(!this.readOnly ? { styles: this.model.styles } : {}), ...(view ? { viewGeometry: viewGeometry(view) } : {}) });
+ const storyLayout = layoutPageStories(layout, stories, [...body.paragraphs.filter(p => p.sectionEnd).map(p => p.sectionEnd), body.pageSettings], this.measure, this.model.numbering, { bestEffort: this.readOnly, drawingLayouts: this.drawingLayouts, serialize, ...(!this.readOnly ? { styles: this.model.styles } : {}) });
if (view && isContinuousView(view.kind)) layout = fitContinuousPages(layout, storyLayout);
// Draft shows text only: floating drawings stay in the document but are not drawn or hit.
if (view?.kind === 'draft') layout = { ...layout, floatingObjects: [] };
diff --git a/src/documents/src/engine/canvas-measurer.js b/src/documents/src/engine/canvas-measurer.js
index d22165145..5276c4bfe 100644
--- a/src/documents/src/engine/canvas-measurer.js
+++ b/src/documents/src/engine/canvas-measurer.js
@@ -39,7 +39,7 @@ export function createCanvasMeasurer(context, { limit = 100000 } = {}) {
};
const fontsReady = () => { clear(); measurer.onFontsReady(); };
// Each style's texts, and what measuring reads of the style itself (prepared on first use).
- const measurer = (text, style) => {
+ const remembered = (text, style) => {
checkFonts();
let measured = byStyle.get(style);
if (!measured) {
@@ -58,8 +58,11 @@ export function createCanvasMeasurer(context, { limit = 100000 } = {}) {
measured.texts.set(text, result);
if (++entries > limit) age();
}
- return { ...result };
+ return result;
};
+ const measurer = (text, style) => ({ ...remembered(text, style) });
+ // The remembered measurement itself, for a caller that only reads it (text-geometry.js): no copy.
+ measurer.shared = remembered;
measurer.paragraphCache = () => { checkFonts(); return paragraphs; };
measurer.clear = clear;
measurer.onFontsReady = () => {};
diff --git a/src/documents/src/engine/canvas-text.js b/src/documents/src/engine/canvas-text.js
index ed1725595..f7bd747c2 100644
--- a/src/documents/src/engine/canvas-text.js
+++ b/src/documents/src/engine/canvas-text.js
@@ -11,6 +11,7 @@ import { font, layoutDocument } from './layout.js';
import { fontMetrics, nativeFaceMetrics, clearNativeFaceMetrics } from './font-metrics.js';
import { pageGeometry } from './page-settings.js';
import { layoutPageStories } from './page-story-layout.js';
+import { layoutSerializer } from './page-story-measure.js';
import { textTransform } from './text-geometry.js';
import { kerningEnabled } from './kerning.js';
import { FONT_SLOTS, textFontStyle, cssFontString } from './font-families.js';
@@ -336,7 +337,8 @@ export function measurePreparedText(context, prepared, text, onSeparatorReady, o
let metrics = prepared.metrics;
if (!metrics || metrics.face !== face || metrics.ascent !== box.fontBoundingBoxAscent || metrics.descent !== box.fontBoundingBoxDescent)
metrics = prepared.metrics = { face, ascent: box.fontBoundingBoxAscent, descent: box.fontBoundingBoxDescent, value: fontMetrics(style, box, face) };
- const result = { width: native.width / scale, ...metrics.value, ...(caretAdvances ? {caretAdvances} : {}) };
+ const result = { width: native.width / scale, ...metrics.value };
+ if (caretAdvances) result.caretAdvances = caretAdvances;
if(prepared.script)result.scriptTransform=prepared.scriptTransform;
return result;
}
@@ -350,8 +352,9 @@ export async function validateCanvasLayout(pkg) {
// A long document is laid out more than once (footnote reservations, square wrapping): remember measurements.
const measure = createCanvasMeasurer(context);
const { assets, drawingLayouts, autofitApproximations } = prepareShapeAutofit(pkg.assets, measure, { bestEffort: pkg.readOnly });
- const body = layoutDocument(pkg.paragraphs, measure, pageGeometry(pkg.pageSettings), pkg.numbering, pkg.tables, pkg.pageStories, { ...(!pkg.readOnly ? { styles: pkg.styles } : {}), drawingLayouts, bestEffort: pkg.readOnly });
- if (pkg.pageStories && !pkg.readOnly) layoutPageStories(body, pkg.pageStories, [...pkg.paragraphs.filter(p => p.sectionEnd).map(p => p.sectionEnd), pkg.pageSettings], measure, pkg.numbering, { styles: pkg.styles, drawingLayouts });
+ const serialize = layoutSerializer();
+ const body = layoutDocument(pkg.paragraphs, measure, pageGeometry(pkg.pageSettings), pkg.numbering, pkg.tables, pkg.pageStories, { ...(!pkg.readOnly ? { styles: pkg.styles } : {}), drawingLayouts, bestEffort: pkg.readOnly, serialize });
+ if (pkg.pageStories && !pkg.readOnly) layoutPageStories(body, pkg.pageStories, [...pkg.paragraphs.filter(p => p.sectionEnd).map(p => p.sectionEnd), pkg.pageSettings], measure, pkg.numbering, { styles: pkg.styles, drawingLayouts, serialize });
// A later layout failure must not leave fitted scenes in the package that
// the read-only retry will receive. Publish only a successful preparation.
Object.assign(pkg, { assets, drawingLayouts });
diff --git a/src/documents/src/engine/docx-relationships.js b/src/documents/src/engine/docx-relationships.js
index 5ae4a13ac..05e0c5dae 100644
--- a/src/documents/src/engine/docx-relationships.js
+++ b/src/documents/src/engine/docx-relationships.js
@@ -4,14 +4,31 @@ import { UnsupportedDocumentError } from './errors.js';
const REL = 'http://schemas.openxmlformats.org/package/2006/relationships';
const OFFICE_REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/';
+// 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.
+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);
+ }
+ return entries;
+}
+
// Resolve a singleton internal relationship from the supported main-document
// location. Per OPC, its base is the source part, not its Relationships part.
export function relatedPartPath(parts, kind, relationshipType = OFFICE_REL + kind) {
- const rels = parts['word/_rels/document.xml.rels'] ? parseXml(parts['word/_rels/document.xml.rels']) : null;
- const links = [...(rels?.documentElement.children || [])].filter(n => n.namespaceURI === REL && n.localName === 'Relationship' && n.getAttribute('Type') === relationshipType);
+ const rels = parts['word/_rels/document.xml.rels'] ? relationshipEntries(parts['word/_rels/document.xml.rels']) : [];
+ const links = rels.filter(n => n.namespaceURI === REL && n.localName === 'Relationship' && n.attribute('Type') === relationshipType);
if (!links.length) return null;
- if (links.length !== 1 || ![null, 'Internal'].includes(links[0].getAttribute('TargetMode'))) throw new UnsupportedDocumentError(`duplicate or external ${kind} relationships`);
- const target = links[0].getAttribute('Target');
+ if (links.length !== 1 || ![null, 'Internal'].includes(links[0].attribute('TargetMode'))) throw new UnsupportedDocumentError(`duplicate or external ${kind} relationships`);
+ const target = links[0].attribute('Target');
if (!target || /[\\?#:\s]/.test(target) || target.startsWith('//') || /%(?![0-9a-f]{2})|%2f|%5c/i.test(target)) throw new UnsupportedDocumentError(`an invalid ${kind} target`);
const path = [];
for (const segment of (target.startsWith('/') ? target.slice(1) : 'word/' + target).split('/')) {
diff --git a/src/documents/src/engine/docx.js b/src/documents/src/engine/docx.js
index f05cb9245..2b4a3b344 100644
--- a/src/documents/src/engine/docx.js
+++ b/src/documents/src/engine/docx.js
@@ -93,6 +93,7 @@ import { validateIndent, normalizeTabStops } from './paragraph-geometry.js';
import { DEFAULT_PAGE_SETTINGS, validatePageSettings } from './page-settings.js';
import { checkedLineNumbers } from './line-number-settings.js';
import { zipPackage, MAX_PACKAGE_BYTES, compoundFileProblem, relationshipPartPath, partPathForUrl } from './package-part-paths.js';
+import { zipPackageAsync } from './package-zip.js';
import { canonicalMainPart } from './docx-main-part.js';
import { altChunkParagraphs } from './docx-alt-chunks.js';
import { unzipSync, strToU8 } from 'fflate';
@@ -864,6 +865,43 @@ function writeMoveRanges(node, names, counters, make) {
flush();
}
}
+// Each unchanged paragraph's source XML, parsed once: every save imports it again (importNode copies it). Saving a
+// long document parsed every paragraph's XML with a parser of its own on each autosave.
+const parsedOriginals = new WeakMap(), unparsedOriginals = new WeakMap();
+const parsedOriginal = original => {
+ let node = parsedOriginals.get(original);
+ if (!node) parsedOriginals.set(original, node = xml(original.xml).documentElement);
+ return node;
+};
+// The main document part as a save starts from it: its root and the body's final section properties, without the
+// body's content, which the save writes again. The whole part is parsed once for each version of it; each save
+// parses only this short text (a parsed copy, unlike a cloned document, keeps the XML declaration).
+const documentSkeletons = new WeakMap();
+/**
+ * Remember the skeleton of a main document part from `doc`, a parse of `bytes` that opening read: the save then
+ * needs no parse of its own. The body's content is set aside while it is serialized and put back.
+ */
+function rememberDocumentSkeleton(bytes, doc) {
+ if (!bytes || typeof bytes !== 'object' || documentSkeletons.has(bytes)) return;
+ const body = child(doc.documentElement, 'body'), section = body && child(body, 'sectPr');
+ if (!body) return;
+ const content = [...body.childNodes];
+ for (const node of content) if (node !== section) node.remove();
+ try { documentSkeletons.set(bytes, serialize(doc)); }
+ finally { body.replaceChildren(...content); }
+}
+function documentSkeleton(bytes) {
+ const keyed = !!bytes && typeof bytes === 'object';
+ let text = keyed ? documentSkeletons.get(bytes) : undefined;
+ if (text === undefined) {
+ const doc = xml(bytes);
+ const body = child(doc.documentElement, 'body'), section = body && child(body, 'sectPr');
+ if (body) for (const node of [...body.childNodes]) if (node !== section) node.remove();
+ if (!keyed) return doc;
+ documentSkeletons.set(bytes, text = serialize(doc));
+ }
+ return xml(text);
+}
function paragraphWriter(doc, styles, numbering, originals, writeImage, writeLink, sectionSource = p => ({ xml: p.sectionProperties, settings: p.sectionEnd })) {
const make = name => doc.createElementNS(W, `w:${name}`);
const moveNames = new WeakMap(), moveCounters = new Map();
@@ -872,7 +910,7 @@ function paragraphWriter(doc, styles, numbering, originals, writeImage, writeLin
const inheritedStrike = id => styleRun(styles, id || styles.defaultCharacter, paragraphRunStyle(p, styles), 'character').strikethrough;
const original = originals.get(p.id);
let node;
- if (original?.signature === signature(p)) node = doc.importNode(xml(original.xml).documentElement, true);
+ if (original?.signature === signature(p)) node = doc.importNode(parsedOriginal(original), true);
else {
node = p.shell ? doc.importNode(xml(p.shell).documentElement, true) : make('p');
if (!original) for (const attr of [...node.attributes]) if (['paraId', 'textId'].includes(attr.localName)) node.removeAttributeNode(attr);
@@ -1254,7 +1292,8 @@ export class DocxPackage {
if (doc.documentElement.namespaceURI !== W) throw new UnsupportedDocumentError('a different OOXML namespace');
const activeNodes = attempt(() => activeDescendants(doc.documentElement), () => [...doc.getElementsByTagNameNS(W, '*')]);
const sectionSourceIndices = new Map(activeNodes.filter(n => n.namespaceURI === W && n.localName === 'sectPr').map((node, index) => [node, index]));
- if (activeNodes.some(n => n.namespaceURI === W && n.localName === 'sdt')) bindUsedNamespaces(doc.documentElement);
+ const boundControlNamespaces = activeNodes.some(n => n.namespaceURI === W && n.localName === 'sdt');
+ if (boundControlNamespaces) bindUsedNamespaces(doc.documentElement);
if (preview) projectControlContents(doc.documentElement);
const body = child(doc.documentElement, 'body');
if (!body) throw new Error('The DOCX has no document body.');
@@ -1909,6 +1948,8 @@ export class DocxPackage {
}
if (documentText(paragraphs).length > MAX_DOCUMENT_CHARACTERS) throw new Error(DOCUMENT_CHARACTER_LIMIT_MESSAGE);
checkMarkers(paragraphs);
+ // Its first save starts from this parse (documentSkeleton), unless reading bound control namespaces on its root.
+ if (!preview && !boundControlNamespaces) rememberDocumentSkeleton(parts['word/document.xml'], doc);
const pkg = new DocxPackage(parts, paragraphs, originals, pageSettings, numbering, styles, tables, assets);
pkg.comments = comments; pkg.initialComments = JSON.stringify(comments);
pkg.mailMerge = attempt(() => readMailMerge(parts), null); pkg.initialMailMerge = JSON.stringify(pkg.mailMerge);
@@ -2147,7 +2188,29 @@ export class DocxPackage {
sceneAtPath(scene, frame.path).text = text;
return { shell: serializeFragment(frame.doc.documentElement), asset: { ...asset, scene } };
}
- export(paragraphs = this.paragraphs, pageSettings = this.pageSettings, numbering = this.numbering, styles = this.styles, tables = this.tables, assets = this.assets, storyEdits, comments = this.comments, trackRevisions = this.trackRevisions) {
+ export(...args) { return new Blob([zipPackage(this.exportParts(...args))], { type: DOCX_TYPE }); }
+ /**
+ * Parse ahead what the first save would: the source XML of each paragraph, which the save copies for every
+ * unchanged one. `timeLeft()` gives the milliseconds this call may use (an idle period's); it returns
+ * whether any is left to do. A long document's first autosave parsed them all at once, holding the page
+ * while the user went on typing.
+ */
+ prepareSave(timeLeft) {
+ let left = unparsedOriginals.get(this);
+ if (!left) unparsedOriginals.set(this, left = [...this.originals.values()]);
+ while (left.length && timeLeft() > 1) parsedOriginal(left.pop());
+ return left.length > 0;
+ }
+ /**
+ * export(), zipped off the page's thread (package-zip.js). The parts are written at once, so the file holds the
+ * document as it is when this is called; only the deflating waits.
+ */
+ async exportAsync(...args) {
+ const parts = this.exportParts(...args);
+ return new Blob([await zipPackageAsync(parts)], { type: DOCX_TYPE });
+ }
+ /** The package parts export() zips. */
+ exportParts(paragraphs = this.paragraphs, pageSettings = this.pageSettings, numbering = this.numbering, styles = this.styles, tables = this.tables, assets = this.assets, storyEdits, comments = this.comments, trackRevisions = this.trackRevisions) {
const parts = this.exportBodyParts(paragraphs, pageSettings, numbering, styles, tables, assets, comments, trackRevisions);
writeDocumentType(parts);
if (storyEdits !== undefined) this.writeStoryEdits(parts, storyEdits, numbering, styles, assets);
@@ -2161,7 +2224,7 @@ export class DocxPackage {
this.writeNoteSettingsParts(parts);
invalidateProofingState(parts, this.parts, this.initialProofingFingerprint !== null && this.initialProofingFingerprint !== undefined && proofingFingerprint(paragraphs) !== this.initialProofingFingerprint);
this.writeCoreProperties(parts);
- return new Blob([zipPackage(parts)], { type: DOCX_TYPE });
+ return parts;
}
/** Document properties (Part 2 §11): the file's values with this session's edits over them. */
get coreProperties() { return { ...readCoreProperties(this.parts), ...(this.coreEdits || {}) }; }
@@ -2328,7 +2391,7 @@ export class DocxPackage {
writeNumbering(parts, numbering);
writeStyles(parts, styles);
writeDefaultTabStop(parts, pageSettings.defaultTabStop, this.initialPageSettings.defaultTabStop, !this.originals.size);
- const doc = xml(parts['word/document.xml']);
+ const doc = documentSkeleton(parts['word/document.xml']);
if (pageSettings.backgroundPicture && JSON.stringify(pageSettings.backgroundPicture) !== JSON.stringify(this.initialPageSettings.backgroundPicture)) throw new Error('Creating a picture page background is not supported.');
if (pageSettings.background !== this.initialPageSettings.background || !!pageSettings.backgroundPicture !== !!this.initialPageSettings.backgroundPicture || !this.originals.size && pageSettings.background !== 'auto') {
if (pageSettings.backgroundPicture) throw new Error('Remove the picture background before changing its page color.');
diff --git a/src/documents/src/engine/drawing-xml.js b/src/documents/src/engine/drawing-xml.js
index 7ccca26f8..17ef467f9 100644
--- a/src/documents/src/engine/drawing-xml.js
+++ b/src/documents/src/engine/drawing-xml.js
@@ -515,18 +515,28 @@ export function describeDrawing(shell) {
}
/** The document theme's colors by scheme name (accent1…6, dk1, lt1, dk2, lt2), or null without a theme. */
+// Each theme part's colors, read once for each version of the part (its bytes, replaced when it is written):
+// the drawing controls ask for them after every keystroke.
+const readThemeColors = new WeakMap();
export function themeColors(parts) {
try {
- const path = relatedPartPath(parts, 'theme');
- if (!path || !parts[path]) return null;
- const root = parseXml(parts[path]).documentElement;
- const scheme = root.getElementsByTagNameNS(A, 'clrScheme')[0];
- if (!scheme) return null;
- const colors = {};
- for (const entry of scheme.children) {
- const node = entry.firstElementChild;
- if (node) colors[entry.localName] = (node.getAttribute('lastClr') || node.getAttribute('val') || '').toUpperCase();
+ const path = relatedPartPath(parts, 'theme'), bytes = path && parts[path];
+ if (!bytes) return null;
+ let colors = typeof bytes === 'object' ? readThemeColors.get(bytes) : undefined;
+ if (colors === undefined) {
+ colors = schemeColors(parseXml(bytes).documentElement);
+ if (typeof bytes === 'object') readThemeColors.set(bytes, colors);
}
- return /^[0-9A-F]{6}$/.test(colors.accent1 || '') ? colors : null;
+ return colors && { ...colors };
} catch { return null; }
}
+function schemeColors(root) {
+ const scheme = root.getElementsByTagNameNS(A, 'clrScheme')[0];
+ if (!scheme) return null;
+ const colors = {};
+ for (const entry of scheme.children) {
+ const node = entry.firstElementChild;
+ if (node) colors[entry.localName] = (node.getAttribute('lastClr') || node.getAttribute('val') || '').toUpperCase();
+ }
+ return /^[0-9A-F]{6}$/.test(colors.accent1 || '') ? colors : null;
+}
diff --git a/src/documents/src/engine/footnote-layout.js b/src/documents/src/engine/footnote-layout.js
index 7a8ead5ee..c3cbc8f9a 100644
--- a/src/documents/src/engine/footnote-layout.js
+++ b/src/documents/src/engine/footnote-layout.js
@@ -59,7 +59,7 @@ export function assignFootnotes(placed, refs) {
export function measureNote(story, display, numbers, geometry, measure, numbering, options = {}) {
const paragraphs = numberedParagraphs(story.paragraphs, numbers, display);
return measurePageStory({ ...story, paragraphs }, { ...geometry, storyKind: 'footnote', top: 0, bottom: 0 }, measure, numbering,
- { pageNumber: 1, pageCount: 1, sectionPageCount: 1 }, { styles: options.styles, drawingLayouts: options.drawingLayouts, onUnsupported: options.bestEffort ? () => {} : undefined, stylesKey: options.storyStylesKey, storyKeys: options.storyKeys, contentKey: options.contentKey });
+ { pageNumber: 1, pageCount: 1, sectionPageCount: 1 }, { styles: options.styles, drawingLayouts: options.drawingLayouts, onUnsupported: options.bestEffort ? () => {} : undefined, stylesKey: options.storyStylesKey, numberingKey: options.storyNumberingKey, storyKeys: options.storyKeys, contentKey: options.contentKey });
}
/**
diff --git a/src/documents/src/engine/formatting.js b/src/documents/src/engine/formatting.js
index 74107696d..e3286dd73 100644
--- a/src/documents/src/engine/formatting.js
+++ b/src/documents/src/engine/formatting.js
@@ -24,7 +24,8 @@ export const PAGINATION_DEFAULTS = Object.freeze({ keepNext: false, keepLines: f
export const PARAGRAPH_FLAGS = Object.freeze({ mirrorIndents: false, suppressLineNumbers: false, suppressAutoHyphens: false });
export const PARAGRAPH_FORMAT = Object.freeze({ paragraphBorders: Object.freeze({}), paragraphShading: 'auto', alignment: 'left', lineRule: 'auto', lineSpacing: 1, spaceBefore: 0, spaceAfter: 9, contextualSpacing: false, ...PAGINATION_DEFAULTS, indentLeft: 0, indentRight: 0, firstLine: 0, tabs: Object.freeze([]), outlineLevel: 9, ...PARAGRAPH_FLAGS });
export const RUN_FORMAT = Object.freeze({ ...FONT_DEFAULTS, bold: false, italic: false, boldCs: false, italicCs: false, fontSizeCs: 12, cs: false, rtl: false, noProof: false, underline: false, underlineStyle: 'none', strikethrough: 'none', capitalization: 'normal', fontSize: 12, textOutline: null, textShadow: null, textFill: null, text3d: null, textScene: null, color: 'auto', underlineColor: 'auto', highlight: 'none', shading: 'auto', verticalAlign: 'baseline', baselineShift: 0, characterSpacing: 0, textScale: 100, kerning: 0, ligatures: 'none', vanish: false });
-export const runFormat = run => { const result = Object.fromEntries(Object.entries(RUN_FORMAT).map(([k, v]) => [k, run?.[k] ?? v])); result.underlineStyle = underlinePattern(run); result.underline = result.underlineStyle !== 'none'; if (run?.legacyKerning !== undefined) result.legacyKerning = run.legacyKerning; return result; };
+const RUN_FORMAT_KEYS = Object.keys(RUN_FORMAT);
+export const runFormat = run => { const result = {}; for (const k of RUN_FORMAT_KEYS) result[k] = run?.[k] ?? RUN_FORMAT[k]; result.underlineStyle = underlinePattern(run); result.underline = result.underlineStyle !== 'none'; if (run?.legacyKerning !== undefined) result.legacyKerning = run.legacyKerning; return result; };
/**
* Indents with their character-unit forms applied (§17.3.1.12 ind): a nonzero leftChars, rightChars,
diff --git a/src/documents/src/engine/images.js b/src/documents/src/engine/images.js
index 68442e4fc..f4f296450 100644
--- a/src/documents/src/engine/images.js
+++ b/src/documents/src/engine/images.js
@@ -93,6 +93,11 @@ export function imageCropRect(crop, width, height) {
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
export function* inlineSegments(text) {
+ // In ASCII only CR LF joins two characters into one grapheme (UAX #29); text without CR needs no segmenter.
+ if (/^[\x00-\x0c\x0e-\x7f]*$/.test(text)) {
+ for (let index = 0; index < text.length; index++) yield { segment: text[index], index };
+ return;
+ }
let offset = 0;
for (const part of text.split(OBJECT)) {
if (offset) yield { segment: OBJECT, index: offset - 1 };
diff --git a/src/documents/src/engine/layout.js b/src/documents/src/engine/layout.js
index 055cad72d..7fe5951c7 100644
--- a/src/documents/src/engine/layout.js
+++ b/src/documents/src/engine/layout.js
@@ -12,7 +12,7 @@ import { paginateColumnWidths } from './unequal-columns.js';
import { sectionColumnIntervals } from './section-columns.js';
import { columnFlowPage, placeColumnFlow, columnAtPoint } from './column-layout.js';
import { flatLineContains, flatLineRect } from './flat-drawing-lines.js';
-import { canvasFontFamily, textFontStyle } from './font-families.js';
+import { canvasFontFamily, fontSlot, textFontStyle } from './font-families.js';
import { pageGeometry, readingPageGeometry } from './page-settings.js';
import { layoutParagraphBorders } from './paragraph-border-layout.js';
import { layoutTableBorders, tableRowBorderWidths, tableBorderWidth, cellBorderLayoutWidth, tableHorizontalInsets } from './table-border-layout.js';
@@ -62,6 +62,8 @@ const RTL_SCRIPTS = /[\u0590-\u08ff\ufb1d-\ufdff\ufe70-\ufefc]|[\u{10800}-\u{10f
const strongRtl = text => RTL_SCRIPTS.test(text) && [...text].some(c => RTL_SCRIPTS.test(c) && ['R', 'AL'].includes(bidiCharacterType(c)));
export const PAGE = Object.freeze({ width: 816, height: 1056, margin: 96, gap: 24, lineHeight: 26, paragraphGap: 12, fontSize: 16 });
export const MAX_PAGES = 200;
+// Each paragraph's cache key strings from the last layout that read it (layoutPass contentKeys).
+const earlierParagraphKeys = new WeakMap();
export const font = style => `${style.italic ? 'italic' : 'normal'} ${style.bold ? 700 : 400} ${(style.fontSize ?? 12) * 4 / 3}px ${canvasFontFamily(style)}`;
/** Layout is independent of the painter. Every caret and fragment maps to UTF-16 source offsets. */
@@ -135,13 +137,18 @@ function layoutDocumentContent(paragraphs, measureBase, page, numbering, tables,
const numberNotes = () => numberedParagraphs(source, { footnote: notes?.numbers || new Map(), endnote: endnotes?.numbers || new Map() });
if (notes || endnotes) paragraphs = numberNotes();
const visited = new Set();
+ // The styles and list definitions are long and the same throughout this layout: every pass, page story
+ // and cache key of it reads one serialization of each (page-story-measure.js, layoutPass).
+ const cache = measureBase.paragraphCache?.();
+ const serialize = storyOptions.serialize || (value => JSON.stringify(value ?? null));
+ const storyStylesKey = cache && serialize(storyOptions.styles), storyNumberingKey = cache && serialize(numbering);
for (let attempt = 0; attempt < MAX_PAGES; attempt++) {
// Page counts reach the body only through the header and footer heights they produce.
const storyHeights = totals ? new Map() : undefined, storyHeightCache = new Map(), paragraphKeys = new WeakMap();
// Page stories measured by this layout share one serialization of the styles and of each story (page-story-measure.js).
- const storyScope = measureBase.paragraphCache ? { storyStylesKey: JSON.stringify(storyOptions.styles ?? null), storyKeys: new WeakMap(), storyStories: pageStories } : {};
+ const storyScope = cache ? { storyStylesKey, storyNumberingKey, storyKeys: new WeakMap(), storyStories: pageStories } : {};
// Everything besides the blocks that decides where footnote references land and how tall their notes are.
- const flowContext = () => measureBase.paragraphCache && JSON.stringify([counts, storiesContentKey(pageStories), storyOptions.styles ?? null, numbering ?? null, !!storyOptions.bestEffort,
+ const flowContext = () => cache && JSON.stringify([counts, storiesContentKey(pageStories), cache.intern(storyStylesKey), cache.intern(storyNumberingKey), !!storyOptions.bestEffort,
storyOptions.drawingLayouts ? [...storyOptions.drawingLayouts] : null, revisionViewKey(Object.values(pageStories?.stories || {}).flatMap(story => story.paragraphs || []), true)]);
const flow = () => notes
? layoutWithFootnotes(notes, noteReserve => layoutWithWrap(paragraphs, measureBase, page, numbering, tables, pageStories, { ...storyOptions, ...storyScope, ...counts, storyHeights, storyHeightCache, paragraphKeys, noteReserve }), measureBase, numbering, { ...storyOptions, ...storyScope }, flowContext())
@@ -200,7 +207,7 @@ function layoutPass(paragraphs, measureBase, page, numbering, tables, pageStorie
const paragraphCache = measureBase.paragraphCache?.();
paragraphCache?.reserve(paragraphs.length);
// Page geometry and numbering are long; keys name them by the cache's short ids (intern).
- const pageKeys = new WeakMap(), sharedKey = paragraphCache && paragraphCache.intern(JSON.stringify(numbering ?? null));
+ const pageKeys = new WeakMap(), sharedKey = paragraphCache && paragraphCache.intern(storyOptions.storyNumberingKey ?? JSON.stringify(numbering ?? null));
// Paragraphs keep their content through the passes of one layout; serialize each once. A paragraph's
// content includes what its measurement reads of the revision view (revisionViewKey), not the whole view.
const paragraphKeys = storyOptions.paragraphKeys || new WeakMap();
@@ -208,7 +215,13 @@ function layoutPass(paragraphs, measureBase, page, numbering, tables, pageStorie
let keys = paragraphKeys.get(paragraph);
if (!keys) {
const view = revisionViewKey([paragraph]);
- paragraphKeys.set(paragraph, keys = { content: view ? `${JSON.stringify(paragraph)},${JSON.stringify(view)}` : JSON.stringify(paragraph), byContext: new Map() });
+ const content = view ? `${JSON.stringify(paragraph)},${JSON.stringify(view)}` : JSON.stringify(paragraph);
+ // An unchanged paragraph keeps the key strings of earlier layouts, whose hashes are known: looking up
+ // every paragraph of a long document by new strings hashed megabytes of text on each keystroke.
+ const earlier = earlierParagraphKeys.get(paragraph);
+ keys = earlier?.content === content && earlier.byContext.size < 16 ? earlier : { content, byContext: new Map() };
+ earlierParagraphKeys.set(paragraph, keys);
+ paragraphKeys.set(paragraph, keys);
}
return keys;
};
@@ -840,6 +853,17 @@ function measureParagraph(p, documentOffset, measure, page, listMarker, numberin
let chars = [];
// Segment the whole paragraph, so a grapheme spanning two format runs stays intact.
let runIndex = 0, runEnd = p.runs[0]?.text.length ?? 0, styleIndex = -1, runStyle, runHidden = false;
+ // A run's characters share one style object per font slot (textFontStyle reads only the slot of the
+ // text), rather than each copying the whole run format.
+ let slotStyles = new Map(), markFormat;
+ // Every line reads the paragraph mark's format; normalize it once.
+ const markRunFormat = () => markFormat ??= runFormat(p.markFormat);
+ const charStyle = text => {
+ const slot = fontSlot(text, runStyle);
+ let style = slotStyles.get(slot);
+ if (!style) slotStyles.set(slot, style = textFontStyle(text, runStyle));
+ return style;
+ };
const wordBreaks = dictionaryBreaks(text);
for (const s of inlineSegments(text)) {
while (s.index >= runEnd && runIndex < p.runs.length - 1) runEnd += p.runs[++runIndex].text.length;
@@ -855,13 +879,20 @@ function measureParagraph(p, documentOffset, measure, page, listMarker, numberin
const display = runRevisionDisplay(p.runs[runIndex]);
if (display?.hidden) runHidden = true; else if (display) Object.assign(runStyle, display);
styleIndex = runIndex;
+ slotStyles = new Map();
}
// Inline page/column breaks in cell XML are retained but have no
// advance or pagination effect. Keep source offsets for editing and undo.
const ignoredCellBreak = runHidden || !!p.cellId && (s.segment === '\f' || s.segment === '\u000e');
const noBreakHyphen = !!p.runs[runIndex]?.noBreakHyphen;
const displayText = inlineDisplayText(p.runs[runIndex], s.segment);
- chars.push({ text: ignoredCellBreak ? '' : displayText, ignoredCellBreak, ...(noBreakHyphen ? { noBreakHyphen: true } : {}), ...(wordBreaks?.has(s.index + s.segment.length) ? { wordBreak: true } : {}), breakClear: p.runs[runIndex]?.breakClear, start: documentOffset + s.index, end: documentOffset + s.index + s.segment.length, style: textFontStyle(displayText, runStyle), image: runHidden ? undefined : p.runs[runIndex]?.image, positionalTab: p.runs[runIndex]?.positionalTab });
+ // Assigned in order rather than spread from optional parts: this runs for every character.
+ const c = { text: ignoredCellBreak ? '' : displayText, ignoredCellBreak };
+ if (noBreakHyphen) c.noBreakHyphen = true;
+ if (wordBreaks?.has(s.index + s.segment.length)) c.wordBreak = true;
+ c.breakClear = p.runs[runIndex]?.breakClear; c.start = documentOffset + s.index; c.end = documentOffset + s.index + s.segment.length;
+ c.style = charStyle(displayText); c.image = runHidden ? undefined : p.runs[runIndex]?.image; c.positionalTab = p.runs[runIndex]?.positionalTab;
+ chars.push(c);
}
// Active RTL import/editing remains guarded while interaction is verified. Hebrew or Arabic typed
// into an ordinary run (no w:rtl) is still right-to-left text (UAX #9): it is ordered by the same
@@ -879,7 +910,7 @@ function measureParagraph(p, documentOffset, measure, page, listMarker, numberin
if (columnPage.columnWidthAt && !cellBounds) page = { ...columnPage, right: columnPage.width-columnPage.left-columnPage.columnWidthAt(chars[start]?.start ?? documentOffset+text.length) };
if (!row) row = squareRow(wrapHints, p.id, lines.length, paragraphEdges(format, page, start <= firstIndentedStart), page, 0, format.spaceBefore * 4 / 3, clearance);
const edges = row.intervals[pieceIndex], right = edges.right;
- const { left, marker } = markerGeometry(start === 0 ? listMarker : null, runFormat(p.markFormat), edges.left, right, format, page, measure, documentOffset);
+ const { left, marker } = markerGeometry(start === 0 ? listMarker : null, markRunFormat(), edges.left, right, format, page, measure, documentOffset);
const available = right - left;
const scanner = new LineBuilder(left, right, format, page, measure, chars[start]?.start ?? documentOffset + text.length);
let end = start, lastBreak = -1, contraction = null, visibleItems = 0, oversizedInline = false;
@@ -956,7 +987,8 @@ function measureParagraph(p, documentOffset, measure, page, listMarker, numberin
// A forced break inside an overlong Thai or Lao word keeps vowels with their consonant.
else if (end < chars.length && !oversizedInline) while (end - 1 > start && splitsSyllable(chars[end - 1].text, chars[end].text)) end--;
const softBreak = !oversizedInline && end < chars.length && !['\v', '\f', '\u000e'].includes(chars[end].text) && chars[end - 1]?.text === '\u00ad' && end === lastBreak;
- let span = chars.slice(start, end);
+ const scanned = chars.slice(start, end);
+ let span = scanned;
if (softBreak) span[span.length - 1] = { ...span.at(-1), text: '-', discretionaryHyphen: true };
if (autoHyphen !== null) span.push(hyphenCharacter(chars[end - 1]));
hyphenatedLines = softBreak || autoHyphen !== null ? hyphenatedLines + 1 : 0;
@@ -966,13 +998,17 @@ function measureParagraph(p, documentOffset, measure, page, listMarker, numberin
const virtual = emptySectionBoundary && !keepBoundaryLine || start === chars.length && chars.at(-1)?.text === '\f' && !page.splitPgBreakAndParaMark;
const hardBreak = chars[end]?.text === '\f' ? 'page' : chars[end]?.text === '\v' ? 'line' : chars[end]?.text === '\u000e' ? 'column' : null;
const offset = chars[start]?.start ?? documentOffset + text.length;
- const builder = new LineBuilder(left, right, format, page, measure, offset);
- for (const c of span) builder.add(c);
+ // A line that ends the paragraph is what the scanner built when it ran out of characters: it
+ // added exactly this span, in order, with the same geometry. Building it again only repeats
+ // every measurement (most paragraphs are one line).
+ const reuse = end === chars.length && !contraction && !softBreak && autoHyphen === null && span === scanned;
+ const builder = reuse ? scanner : new LineBuilder(left, right, format, page, measure, offset);
+ if (!reuse) for (const c of span) builder.add(c);
const pageBreakWithMark = hardBreak === 'page' && end === chars.length - 1 && !page.splitPgBreakAndParaMark;
// A terminal page break sharing the paragraph mark takes that mark's
// metrics, not the break run's font. Visible text still contributes its
// own metrics. Manual line breaks and split page breaks remain distinct.
- const markStyle = end === chars.length || pageBreakWithMark ? runFormat(p.markFormat) : hardBreak ? chars[end].style : null;
+ const markStyle = end === chars.length || pageBreakWithMark ? markRunFormat() : hardBreak ? chars[end].style : null;
// Terminal-mark and manual-line-break metrics are ignored on text lines.
// Blank lines retain their own mark/break formatting, including when the
// two have different fonts. Page and column breaks have separate rules.
diff --git a/src/documents/src/engine/line-builder.js b/src/documents/src/engine/line-builder.js
index d94fc75a6..269f21967 100644
--- a/src/documents/src/engine/line-builder.js
+++ b/src/documents/src/engine/line-builder.js
@@ -29,12 +29,22 @@ function trackBidiSpan(span, c) {
span.start ??= c.start;
span.end = c.end;
}
-const shapeKey = style => `${(style.fontFamily || style.fontAscii || '').toLowerCase()}/${style.capitalization}/${style.bold}/${style.italic}/${!!style.syntheticItalic}/${style.fontSize}/${style.verticalAlign}/${style.baselineShift}/${style.characterSpacing}/${style.textScale}/${kerningEnabled(style)}/${style.ligatures ?? 'auto'}/${style.justificationSpacing || 0}/${style.bidiLevel ?? ''}/${!!style.noteMark}/${!!style.legacyKerning}`;
+const shapeKeys = new WeakMap();
+// A style's shaping identity, built once per style object: line building compares it for every
+// character. Neither the characters' styles nor the groups built from them change these fields
+// once created (layout writes only its own output fields onto a group).
+const shapeKey = style => {
+ let key = shapeKeys.get(style);
+ if (key === undefined) shapeKeys.set(style, key = styleShapeKey(style));
+ return key;
+};
+const styleShapeKey = style => `${(style.fontFamily || style.fontAscii || '').toLowerCase()}/${style.capitalization}/${style.bold}/${style.italic}/${!!style.syntheticItalic}/${style.fontSize}/${style.verticalAlign}/${style.baselineShift}/${style.characterSpacing}/${style.textScale}/${kerningEnabled(style)}/${style.ligatures ?? 'auto'}/${style.justificationSpacing || 0}/${style.bidiLevel ?? ''}/${!!style.noteMark}/${!!style.legacyKerning}`;
-function measureLtrPrefix(measure, text, style) {
+function measureLtrPrefix(measure, text, group, style) {
// Complete caret shaping adds work. Reuse unchanged ASCII prefixes between
// fitting and final shaping within this layout's synchronous measurer.
- let widths = prefixFragmentCaches.get(style);
+ // `style` measures as the group does; the group, made for this measurer, holds the cache.
+ let widths = prefixFragmentCaches.get(group);
if (widths === undefined) {
widths = null;
if (style.ligatures && style.ligatures !== 'none' && !style.rtl && !style.cs && style.bidiDirection !== 'rtl' &&
@@ -46,7 +56,7 @@ function measureLtrPrefix(measure, text, style) {
widths = shapes.get(key);
if (!widths) shapes.set(key, widths = new Map());
}
- prefixFragmentCaches.set(style, widths);
+ prefixFragmentCaches.set(group, widths);
}
if (!widths || text.length > 4096 || !/^[\x20-\x7e]*$/.test(text)) return measure(text, style);
if (!widths.has(text)) {
@@ -59,7 +69,7 @@ function measureLtrPrefix(measure, text, style) {
function measureCompleteCarets(measure, text, style) {
let cache = shapedCaretMeasures.get(measure);
if (!cache) shapedCaretMeasures.set(measure, cache = new Map());
- const key = JSON.stringify([shapeKey(style), style.fontHAnsi, style.fontEastAsia, style.fontCs, style.fontSizeCs, style.cs, style.rtl, style.fontHint, style.fontLanguage, style.fontEastAsiaLanguage, style.fontBidiLanguage, style.fontEastAsiaCharset, text, style.caretOffsets]);
+ const key = JSON.stringify([styleShapeKey(style), style.fontHAnsi, style.fontEastAsia, style.fontCs, style.fontSizeCs, style.cs, style.rtl, style.fontHint, style.fontLanguage, style.fontEastAsiaLanguage, style.fontBidiLanguage, style.fontEastAsiaCharset, text, style.caretOffsets]);
if (!cache.has(key)) cache.set(key, measure(text, style));
return cache.get(key);
}
@@ -99,7 +109,7 @@ export class LineBuilder {
this.newField(left, null, offset);
}
newField(before, tab, offset) {
- this.field = { before, tab, origin: before, width: 0, contentWidth: 0, trailingStart: null, lastSpace: false, spaceMetrics: null, fragments: [], stops: [{ offset, x: 0 }], underlines: [], strikes: [], strike: null, backgrounds: [], background: null, ascent: 0, descent: 0, group: null, underline: null, numberEnd: null, numberDone: false, decimal: null };
+ this.field = { before, tab, origin: before, width: 0, contentWidth: 0, trailingStart: null, lastSpace: false, spaceMetrics: null, fragments: [], stops: [{ offset, x: 0 }], underlines: [], strikes: [], strike: null, backgrounds: [], background: null, ascent: 0, descent: 0, group: null, groupStyle: null, groupMeasure: null, underline: null, numberEnd: null, numberDone: false, decimal: null };
this.fields.push(this.field);
}
get end() { return this.field.origin + this.field.width; }
@@ -107,10 +117,19 @@ export class LineBuilder {
// advance is excluded from line fitting. Do not cross shaping boundaries.
endBeforeSpace(next) {
const f = this.field, g = f.group;
- if (f.lastSpace || this.page.wrapTrailSpaces || next?.text !== ' ' || next.image || bidiRtlSpace(next) || !g || !kerningEnabled(g) || shapeKey(g) !== shapeKey(next.style)) return this.end;
- const text = g.text + ' ', metrics = this.measure(text, g);
+ if (f.lastSpace || this.page.wrapTrailSpaces || next?.text !== ' ' || next.image || bidiRtlSpace(next) || !g || !kerningEnabled(g) || !this.sameShape(next.style)) return this.end;
+ const text = g.text + ' ', metrics = this.measure(text, f.groupMeasure);
f.followingSpace = { group: g, text, metrics };
- return f.origin + g.x + metrics.width - (f.spaceMetrics ||= this.measure(' ', g)).width;
+ return f.origin + g.x + metrics.width - (f.spaceMetrics ||= this.measure(' ', f.groupMeasure)).width;
+ }
+ /** Whether text in `style` continues the current group: it shapes alike. */
+ sameShape(style) {
+ const f = this.field;
+ if (!f.group) return false;
+ if (f.groupStyle === style) return true;
+ if (shapeKey(f.group) !== shapeKey(style)) return false;
+ f.groupStyle = style;
+ return true;
}
positionalTarget(c) { return positionalTabTarget(c.positionalTab, this.format, this.page, this.left, this.right); }
settleSpaces() {
@@ -119,7 +138,7 @@ export class LineBuilder {
// Native shaping can accumulate small rounding differences over a long
// string. Settle each completed group once, while its visible caret stops
// have already been measured in context. Only clipped stops used estimates.
- const settled = this.measure(g.text, g); g.width = settled.width;
+ const settled = this.measure(g.text, f.groupMeasure); g.width = settled.width;
if (settled.textParts) g.textParts = settled.textParts;
f.width = g.x + g.width; f.stops.at(-1).x = f.width;
g.colors.at(-1).width = g.width - g.colors.at(-1).x;
@@ -141,7 +160,7 @@ export class LineBuilder {
discretionaryEnd() {
const f = this.field, g = f.group;
const width = f.width, contentWidth = f.contentWidth, origin = f.origin;
- f.width = g.x + this.measure(g.text + '-', g).width;
+ f.width = g.x + this.measure(g.text + '-', f.groupMeasure).width;
f.contentWidth = f.width;
this.alignField(); const end = this.end;
f.width = width; f.contentWidth = contentWidth; f.origin = origin;
@@ -170,12 +189,16 @@ export class LineBuilder {
return this.end;
}
const f = this.field;
- if (c.text !== ' ' || f.group && shapeKey(f.group) !== shapeKey(c.style)) this.settleSpaces();
+ const sameShape = this.sameShape(c.style);
+ if (c.text !== ' ' || f.group && !sameShape) this.settleSpaces();
const left = f.width;
if (c.image) f.group = null;
- if (!c.image && (!f.group || shapeKey(f.group) !== shapeKey(c.style))) {
+ if (!c.image && !sameShape) {
f.group = { text: '', ...c.style, x: left, start: c.start, end: c.end, width: 0, colors: [] };
- if(c.style.bidiLevel!==undefined) f.group.textOffsets=[{offset:c.start,index:0}];
+ // The group measures as the style it copies: measuring reads none of the fields layout adds
+ // (LAYOUT_OUTPUT), so the measurer knows that style already. Bidi groups also carry offsets.
+ f.groupStyle = c.style; f.groupMeasure = c.style;
+ if(c.style.bidiLevel!==undefined) { f.group.textOffsets=[{offset:c.start,index:0}]; f.groupMeasure = f.group; }
else if(c.style.ligatures && c.style.ligatures !== 'none') completeCaretOffsets.set(f.group,[{offset:c.start,index:0}]);
f.fragments.push(f.group);
f.lastSpace = false; f.spaceMetrics = null;
@@ -195,9 +218,9 @@ export class LineBuilder {
// hanging tail does not repeatedly shape every growing whitespace prefix.
const repeatedSpace = c.text === ' ' && f.lastSpace && !this.page.wrapTrailSpaces && this.end > this.right;
if (!g.textOffsets && !repeatedSpace && !c.autoHyphen && g.ligatures && g.ligatures !== 'none') completeCaretOffsets.get(g)?.push({offset:c.end,index:g.text.length});
- const space = repeatedSpace && (f.spaceMetrics ||= this.measure(' ', g));
+ const space = repeatedSpace && (f.spaceMetrics ||= this.measure(' ', f.groupMeasure));
const shift = (c.style.baselineShift || 0) * 4 / 3;
- const m = isPositionedImage(c.image) ? { width: 0, ascent: 0, descent: 0 } : c.image ? { width: extent.width / EMU_PER_PX, ascent: extent.height / EMU_PER_PX + shift, descent: -shift } : repeatedSpace ? { ...space, width: g.width + space.width } : nextSpace || (g.ligatures && g.ligatures !== 'none' ? measureLtrPrefix(this.measure, g.text, g) : this.measure(g.text, g));
+ const m = isPositionedImage(c.image) ? { width: 0, ascent: 0, descent: 0 } : c.image ? { width: extent.width / EMU_PER_PX, ascent: extent.height / EMU_PER_PX + shift, descent: -shift } : repeatedSpace ? { ...space, width: g.width + space.width } : nextSpace || (g.ligatures && g.ligatures !== 'none' ? measureLtrPrefix(this.measure, g.text, g, f.groupMeasure) : this.measure(g.text, f.groupMeasure));
if (!c.image) {
let carets = caretFragmentState.get(g);
if (carets === undefined) {
@@ -221,7 +244,7 @@ export class LineBuilder {
else if (f.stops.at(-1).offset === c.start) {
// The new pair can move the preceding boundary. A prefix measured
// before this character arrived does not include its kerning.
- if (!widths.has(c.text)) widths.set(c.text, this.measure(c.text, g).width);
+ if (!widths.has(c.text)) widths.set(c.text, this.measure(c.text, f.groupMeasure).width);
f.stops.at(-1).x = g.x + m.width - widths.get(c.text);
}
}
@@ -240,7 +263,7 @@ export class LineBuilder {
f.lastSpace = c.text === ' ';
if (f.lastSpace && !bidiRtlSpace(c)) {
if (f.trailingStart === null && g.text.length > 1 && kerningEnabled(g)) {
- f.contentWidth = f.width - (f.spaceMetrics ||= this.measure(' ', g)).width;
+ f.contentWidth = f.width - (f.spaceMetrics ||= this.measure(' ', f.groupMeasure)).width;
// The end-of-word caret includes the pair's adjustment as well.
f.stops.at(-1).x = f.contentWidth;
}
@@ -251,18 +274,25 @@ export class LineBuilder {
f.stops.push({ offset: c.end, x: f.width });
if (!c.image) {
let color = g.colors.at(-1);
- if (!color || color.value !== c.style.color || color.textFill !== (c.style.textFill ?? null) || color.text3d !== (c.style.text3d ?? null) || color.textScene !== (c.style.textScene ?? null) || backgroundKey(color) !== backgroundKey(c.style) || outlineKey(color) !== outlineKey(c.style) || shadowKey(color) !== shadowKey(c.style)) { color = { value: c.style.color, textFill: c.style.textFill ?? null, textOutline: c.style.textOutline ?? null, textShadow: c.style.textShadow ?? null, text3d: c.style.text3d ?? null, textScene: c.style.textScene ?? null, highlight: c.style.highlight, shading: c.style.shading, x: left - g.x, width: 0 }; g.colors.push(color); }
+ // A span made from, or found to match, this very style object needs no comparing again.
+ if (!color || color !== this.colorSpan || c.style !== this.colorStyle) {
+ if (!color || color.value !== c.style.color || color.textFill !== (c.style.textFill ?? null) || color.text3d !== (c.style.text3d ?? null) || color.textScene !== (c.style.textScene ?? null) || backgroundKey(color) !== backgroundKey(c.style) || outlineKey(color) !== outlineKey(c.style) || shadowKey(color) !== shadowKey(c.style)) { color = { value: c.style.color, textFill: c.style.textFill ?? null, textOutline: c.style.textOutline ?? null, textShadow: c.style.textShadow ?? null, text3d: c.style.text3d ?? null, textScene: c.style.textScene ?? null, highlight: c.style.highlight, shading: c.style.shading, x: left - g.x, width: 0 }; g.colors.push(color); }
+ this.colorSpan = color; this.colorStyle = c.style;
+ }
color.width = g.width - color.x;
}
- if (hasRunBackground(c.style)) {
+ // Characters of a run share their style object: what it decides about decorations is read once.
+ if (this.factStyle !== c.style) this.facts = { background: hasRunBackground(c.style), pattern: underlinePattern(c.style), visibleUnderline: visibleUnderline(c.style) }, this.factStyle = c.style;
+ const facts = this.facts;
+ if (facts.background) {
if (!f.background || backgroundKey(f.background) !== backgroundKey(c.style) || f.background.ascent !== m.ascent || f.background.descent !== m.descent) {
f.background = { x: left, width: 0, ascent: m.ascent, descent: m.descent, highlight: c.style.highlight, shading: c.style.shading }; f.backgrounds.push(f.background);
}
f.background.width = f.width - f.background.x;
trackBidiSpan(f.background, c);
} else f.background = null;
- const pattern = underlinePattern(c.style);
- if (visibleUnderline(c.style) && pattern !== 'none' && !(pattern === 'words' && underlineSpace(c.text)) && !c.image) {
+ const pattern = facts.pattern;
+ if (facts.visibleUnderline && pattern !== 'none' && !(pattern === 'words' && underlineSpace(c.text)) && !c.image) {
const color = c.style.underlineColor === 'auto' || !c.style.underlineColor ? textFillColor(c.style) : c.style.underlineColor;
const { offset, thickness } = underlineGeometry(pattern, m);
if (!f.underline || f.underline.pattern !== pattern || f.underline.fontSize !== c.style.fontSize || f.underline.offset !== offset || f.underline.thickness !== thickness || f.underline.verticalAlign !== c.style.verticalAlign || f.underline.baselineShift !== c.style.baselineShift || f.underline.color !== color || backgroundKey(f.underline) !== backgroundKey(c.style)) {
diff --git a/src/documents/src/engine/model.js b/src/documents/src/engine/model.js
index 6b6d1c0ba..376bfd129 100644
--- a/src/documents/src/engine/model.js
+++ b/src/documents/src/engine/model.js
@@ -82,6 +82,8 @@ export const documentText = paragraphs => paragraphs.map(paragraphText).join('\n
// texts it was joined from, and the document's with its paragraphs' texts; comparing them confirms the
// cached text without joining, and only paragraphs whose runs changed are joined again.
const paragraphTexts = new WeakMap(), documentTexts = new WeakMap();
+// Each model's tracked changes as of its current revision (TextDocument.revisions).
+const listedRevisions = new WeakMap();
function cachedParagraphText(p) {
const cache = paragraphTexts.get(p), runs = p.runs;
if (cache && cache.runs.length === runs.length) {
@@ -2357,7 +2359,23 @@ export class TextDocument {
if (!hasRevisions(this.paragraphs)) return [];
return revisionRanges(this.paragraphs).filter(r => start === end ? r.start <= start && start <= r.end : r.start < end && r.end > start);
}
- get revisions() { return hasRevisions(this.paragraphs) ? revisionRanges(this.paragraphs) : []; }
+ /**
+ * The document's tracked changes, listed once per revision (every change to the document makes a new one,
+ * didChange). The review controls read them several times after each keystroke, and listing them reads
+ * every run of the document. Commands list them afresh (revisionRanges, revisionsAt): their edits are not
+ * a revision until they end. Callers read the list without changing it.
+ */
+ get revisions() {
+ const listed = listedRevisions.get(this);
+ if (listed?.revision === this.revision && listed.paragraphs === this.paragraphs) return listed.value;
+ const value = hasRevisions(this.paragraphs) ? revisionRanges(this.paragraphs) : [];
+ listedRevisions.set(this, { revision: this.revision, paragraphs: this.paragraphs, value });
+ return value;
+ }
+ /** revisionsAt for the review controls, from the revision's list (revisions). */
+ revisionsAround(start = this.range[0], end = this.range[1]) {
+ return this.revisions.filter(r => start === end ? r.start <= start && start <= r.end : r.start < end && r.end > start);
+ }
/** Accept or reject the changes overlapping [start, end] (the whole document by default). */
/** `only(change)` limits it to some changes (those Show Markup shows): the others in the range are left. */
resolveRevisions(accept, start = 0, end = documentLength(this.paragraphs) + 1, { only = () => true } = {}) {
diff --git a/src/documents/src/engine/package-zip-worker.js b/src/documents/src/engine/package-zip-worker.js
new file mode 100644
index 000000000..8fb8c49a0
--- /dev/null
+++ b/src/documents/src/engine/package-zip-worker.js
@@ -0,0 +1,12 @@
+// Zips a package's parts off the page's thread (package-zip.js): the parts arrive copied, the ZIP goes back moved.
+import { zipPackage } from './package-part-paths.js';
+
+self.onmessage = ({ data: { id, parts } }) => {
+ try {
+ const zipped = zipPackage(parts);
+ self.postMessage({ id, zipped }, [zipped.buffer]);
+ } catch (error) {
+ self.postMessage({ id, error: String(error?.message || error) });
+ }
+};
+self.postMessage({ ready: true });
diff --git a/src/documents/src/engine/package-zip.js b/src/documents/src/engine/package-zip.js
new file mode 100644
index 000000000..2380ffc36
--- /dev/null
+++ b/src/documents/src/engine/package-zip.js
@@ -0,0 +1,46 @@
+import { zipPackage } from './package-part-paths.js';
+
+// Saving a long document deflated its parts on the page's thread: an autosave a moment after typing stopped
+// held the page for a few hundred milliseconds, so the next keys typed waited. The parts are now zipped in a
+// worker, with the same zipPackage. Without workers (or when one fails), they are zipped here as before.
+const WORKER_START_MS = 10000;
+let worker = null, unavailable = false, nextId = 0;
+const pending = new Map();
+
+function fail() {
+ unavailable = true;
+ worker?.terminate();
+ worker = null;
+ for (const job of pending.values()) job.fallback();
+ pending.clear();
+}
+function startWorker() {
+ if (worker || unavailable) return worker;
+ if (typeof Worker !== 'function') { unavailable = true; return null; }
+ try { worker = new Worker(new URL('./package-zip-worker.js', import.meta.url), { type: 'module' }); }
+ catch { unavailable = true; return null; }
+ const started = setTimeout(fail, WORKER_START_MS);
+ worker.onmessage = ({ data }) => {
+ if (data?.ready) { clearTimeout(started); return; }
+ const job = pending.get(data?.id);
+ if (!job) return;
+ pending.delete(data.id);
+ if (data.error) job.reject(new Error(data.error));
+ else job.resolve(data.zipped);
+ };
+ // Blocked by policy, or no module workers: zip on the page instead.
+ worker.onerror = event => { event.preventDefault?.(); clearTimeout(started); fail(); };
+ return worker;
+}
+
+/** The package's parts as ZIP bytes (zipPackage), made off the page's thread where workers are available. */
+export function zipPackageAsync(parts) {
+ const zipper = startWorker();
+ if (!zipper) return Promise.resolve().then(() => zipPackage(parts));
+ return new Promise((resolve, reject) => {
+ const id = nextId++;
+ pending.set(id, { resolve, reject, fallback: () => { try { resolve(zipPackage(parts)); } catch (error) { reject(error); } } });
+ try { zipper.postMessage({ id, parts }); }
+ catch { pending.delete(id); try { resolve(zipPackage(parts)); } catch (error) { reject(error); } }
+ });
+}
diff --git a/src/documents/src/engine/page-story-layout.js b/src/documents/src/engine/page-story-layout.js
index f5ee8e344..406bc9549 100644
--- a/src/documents/src/engine/page-story-layout.js
+++ b/src/documents/src/engine/page-story-layout.js
@@ -1,4 +1,4 @@
-import { measurePageStory, storyContentKey } from './page-story-measure.js';
+import { measurePageStory, storyContentKey, layoutSerializer } from './page-story-measure.js';
import { pageStoryPath } from './page-stories.js';
import { positionedImageFrame } from './positioned-images.js';
import { resolveDrawingCollisions } from './drawing-collisions.js';
@@ -11,7 +11,7 @@ import { measureNote, FOOTNOTE_SEPARATOR } from './footnote-layout.js';
import { watermarkFrame } from './vml-watermarks.js';
// Page stories have their own offsets and are kept outside body hit testing.
-export function layoutPageStories(body, definition, sectionSettings, measure, numbering, { bestEffort = false, styles, drawingLayouts } = {}) {
+export function layoutPageStories(body, definition, sectionSettings, measure, numbering, { bestEffort = false, styles, drawingLayouts, serialize = layoutSerializer() } = {}) {
const lines = [], borders = [], cells = [], tableBorders = [], floatingObjects = [], paragraphFrames = [], unsupported = [];
// Word watermarks (retained VML in a header or footer), placed on each page that shows their story.
const watermarks = [];
@@ -21,7 +21,10 @@ export function layoutPageStories(body, definition, sectionSettings, measure, nu
const resolved = new Map(), storyKeys = new WeakMap();
// A measurer that remembers stories resolves their styles only when it measures one it has not seen.
const remembered = !!(styles && measure.paragraphCache);
- const styleOptions = remembered ? { styles, stylesKey: JSON.stringify(styles) } : {};
+ // Every page's stories read the same styles and list definitions: serialize them once (storyMeasureKey),
+ // with the body's layout when the caller shares its serializer (layoutSerializer).
+ const styleOptions = remembered ? { styles, stylesKey: serialize(styles) } : {};
+ const numberingKey = measure.paragraphCache ? serialize(numbering) : undefined;
const storyParagraphs = (path, story) => {
if (!styles || remembered) return story.paragraphs;
if (!resolved.has(path)) resolved.set(path, story.paragraphs.map(p => resolveStyledParagraph(structuredClone(p), styles, numbering)));
@@ -53,7 +56,7 @@ export function layoutPageStories(body, definition, sectionSettings, measure, nu
try {
const { placed, trim, storyGeometry, positions: storyPositions } = measurePageStory({ ...story, paragraphs: storyParagraphs(path, story) }, { ...geometry, storyKind: kind }, measure, numbering,
{ pageNumber: numbers[page], pageCount: body.pageCount, sectionPageCount: counts.get(section), pageNumbering: definition.sections[section].pageNumbering },
- { drawingLayouts, onUnsupported: bestEffort ? error => unsupported.push({ path, page, message: error.message }) : undefined, storyKeys, ...styleOptions, contentKey: storyContentKey(definition, path) });
+ { drawingLayouts, onUnsupported: bestEffort ? error => unsupported.push({ path, page, message: error.message }) : undefined, storyKeys, ...styleOptions, numberingKey, contentKey: storyContentKey(definition, path) });
positions.push({ path, page, positions: storyPositions });
const y = kind === 'header' ? settings.header / 15 : geometry.height - settings.footer / 15 - placed.endY + trim;
const frames = new Map((placed.paragraphFrames || []).map(f=>[f.frameId,f]));
@@ -98,7 +101,7 @@ export function layoutPageStories(body, definition, sectionSettings, measure, nu
if (!story) continue;
if (note.y !== undefined) y = note.y;
try {
- const { placed, storyGeometry, positions: storyPositions } = measureNote({ ...story, paragraphs: storyParagraphs(note.path, story) }, note.display, numbers, geometry, measure, numbering, { drawingLayouts, bestEffort, storyKeys, ...styleOptions, storyStylesKey: styleOptions.stylesKey, contentKey: storyContentKey(definition, note.path, note.display, shownKey) });
+ const { placed, storyGeometry, positions: storyPositions } = measureNote({ ...story, paragraphs: storyParagraphs(note.path, story) }, note.display, numbers, geometry, measure, numbering, { drawingLayouts, bestEffort, storyKeys, ...styleOptions, storyStylesKey: styleOptions.stylesKey, storyNumberingKey: numberingKey, contentKey: storyContentKey(definition, note.path, note.display, shownKey) });
positions.push({ path: note.path, page: area.page, positions: storyPositions });
const top = y, shifted = item => ({ ...item, page: area.page, pageStory: note.path, y: item.y + top, ...(item.cellClip ? { cellClip: shiftCellClip(item.cellClip, top) } : {}) });
lines.push(...placed.lines.map(line => ({ ...shifted(line), storyGeometry, baseline: line.baseline + top })));
diff --git a/src/documents/src/engine/page-story-measure.js b/src/documents/src/engine/page-story-measure.js
index ebd5c0cdc..dd440fba4 100644
--- a/src/documents/src/engine/page-story-measure.js
+++ b/src/documents/src/engine/page-story-measure.js
@@ -15,6 +15,21 @@ function checkNamedContent(name, content) {
namedContent.set(name, json);
}
+/**
+ * A serializer for one layout of a document (its body and page stories): each object it is given, such as
+ * the styles or the list definitions, is serialized once however many keys read it. Objects must not
+ * change while the layout runs; a new layout takes a new serializer.
+ */
+export function layoutSerializer() {
+ const serialized = new Map();
+ return value => {
+ if (!value || typeof value !== 'object') return JSON.stringify(value ?? null);
+ let text = serialized.get(value);
+ if (text === undefined) serialized.set(value, text = JSON.stringify(value));
+ return text;
+ };
+}
+
/**
* Page stories as layout keys them: the session's version of their content (StorySession
* pageStories) when it has one, so an edit to the body does not serialize every story again.
@@ -34,10 +49,13 @@ export function storyContentKey(pageStories, path, ...numbering) {
/**
* What a story measurement reads besides the measurer, as text. Every page of a long document lays
* its header, footer and notes out again on each edit, mostly unchanged. A layout serializes its
- * styles once (`stylesKey`) and each story's paragraphs once (`storyKeys`, by paragraphs array), or
- * names a versioned story's content (`contentKey`, storyContentKey) without serializing it.
+ * styles (`stylesKey`) and list definitions (`numberingKey`) once and each story's paragraphs once
+ * (`storyKeys`, by paragraphs array), or names a versioned story's content (`contentKey`,
+ * storyContentKey) without serializing it. The key names the long parts by the cache's short ids:
+ * a document with hundreds of lists serialized half a megabyte of list definitions into the key of
+ * every page's header and footer on each keystroke, and hashed each key again to look it up.
*/
-function storyMeasureKey(cache, story, geometry, numbering, context, { styles, drawingLayouts, onUnsupported, stylesKey, storyKeys, contentKey }) {
+function storyMeasureKey(cache, story, geometry, numbering, context, { styles, drawingLayouts, onUnsupported, stylesKey, numberingKey, storyKeys, contentKey }) {
let content;
if (contentKey !== undefined) { checkNamedContent(contentKey, story); content = cache.intern(contentKey); }
else {
@@ -45,8 +63,11 @@ function storyMeasureKey(cache, story, geometry, numbering, context, { styles, d
if (paragraphs === undefined) { paragraphs = cache.intern(JSON.stringify(story.paragraphs)); storyKeys?.set(story.paragraphs, paragraphs); }
content = [paragraphs, { ...story, paragraphs: undefined }];
}
+ // The page's number and counts reach the story only through its page fields (measureStory): a story
+ // without one measures alike on every page, so a long document measures it once, not once a page.
+ const pageContext = story.paragraphs.some(p => p.runs.some(r => r.field)) ? context : null;
// Of the revision view, only what the story's own changes and hidden text show (revisionViewKey).
- return JSON.stringify([content, geometry, numbering ?? null, context, revisionViewKey(story.paragraphs, true),
+ return JSON.stringify([content, geometry, cache.intern(numberingKey ?? JSON.stringify(numbering ?? null)), pageContext, revisionViewKey(story.paragraphs, true),
styles ? cache.intern(stylesKey ?? JSON.stringify(styles)) : null, drawingLayouts ? [...drawingLayouts] : null, !!onUnsupported]);
}
diff --git a/src/documents/src/engine/revisions.js b/src/documents/src/engine/revisions.js
index 15bbe9cf7..19ca8fb6d 100644
--- a/src/documents/src/engine/revisions.js
+++ b/src/documents/src/engine/revisions.js
@@ -229,12 +229,13 @@ export const hasRevisions = paragraphs => paragraphs.some(p => p.markRevision ||
* before it is first laid out: given later, they changed the colors on screen at the first edit
* and made it measure the whole document again.
*/
-export function registerRevisionAuthors(paragraphs) {
- for (const name of revisionAuthors(paragraphs)) if (!revisionView.authors.includes(name)) revisionView.authors.push(name);
+/** `ranges` may give the paragraphs' revisionRanges when the caller has them listed already. */
+export function registerRevisionAuthors(paragraphs, ranges) {
+ for (const name of revisionAuthors(paragraphs, ranges)) if (!revisionView.authors.includes(name)) revisionView.authors.push(name);
}
-export function revisionAuthors(paragraphs) {
+export function revisionAuthors(paragraphs, ranges = revisionRanges(paragraphs)) {
const authors = [];
- for (const range of revisionRanges(paragraphs)) if (!authors.includes(range.author)) authors.push(range.author);
+ for (const range of ranges) if (!authors.includes(range.author)) authors.push(range.author);
return authors;
}
diff --git a/src/documents/src/engine/story-clearance.js b/src/documents/src/engine/story-clearance.js
index ce79224ac..3f6df9c96 100644
--- a/src/documents/src/engine/story-clearance.js
+++ b/src/documents/src/engine/story-clearance.js
@@ -22,7 +22,7 @@ export function sectionStoryBounds(page, definition, sectionIndex, section, meas
try {
const { placed, trim } = measurePageStory(story, { ...page, storyKind: kind }, measure, numbering,
{ pageNumber, pageCount: counts.pageCount || 1, sectionPageCount: counts.sectionCounts?.[sectionIndex] || 1, pageNumbering: definition.sections[sectionIndex].pageNumbering },
- { styles: options.styles, drawingLayouts: options.drawingLayouts, onUnsupported: options.bestEffort ? () => {} : undefined, stylesKey: options.storyStylesKey, storyKeys: options.storyKeys, contentKey: storyContentKey(definition, path) });
+ { styles: options.styles, drawingLayouts: options.drawingLayouts, onUnsupported: options.bestEffort ? () => {} : undefined, stylesKey: options.storyStylesKey, numberingKey: options.storyNumberingKey, storyKeys: options.storyKeys, contentKey: storyContentKey(definition, path) });
return placed.endY - trim;
} catch (error) { if (!options.bestEffort) throw error; return 0; }
};
diff --git a/src/documents/src/engine/story-session.js b/src/documents/src/engine/story-session.js
index ee902805d..7050dec53 100644
--- a/src/documents/src/engine/story-session.js
+++ b/src/documents/src/engine/story-session.js
@@ -180,10 +180,11 @@ export class StorySession {
get storyEdits() {
return Object.fromEntries([...this.models].filter(([path]) => path !== null).map(([path, model]) => [path, { paragraphs: model.paragraphs, tables: model.tables }]));
}
- export(pkg) {
+ export(pkg, { async = false } = {}) {
const body = this.body;
// Tracking "just mine" is a session choice: the document keeps its own Track Changes setting.
const track = body.trackChangesMode === 'mine' ? !!pkg.trackRevisions : body.trackChanges;
- return pkg.export(body.paragraphs, body.pageSettings, body.numbering, body.styles, body.tables, body.assets, this.storyEdits, body.comments, track);
+ // The asynchronous export writes the parts now and zips them off the page's thread (DocxPackage.exportAsync).
+ return pkg[async && pkg.exportAsync ? 'exportAsync' : 'export'](body.paragraphs, body.pageSettings, body.numbering, body.styles, body.tables, body.assets, this.storyEdits, body.comments, track);
}
}
diff --git a/src/documents/src/engine/text-geometry.js b/src/documents/src/engine/text-geometry.js
index 69d48c52f..ea122b0ff 100644
--- a/src/documents/src/engine/text-geometry.js
+++ b/src/documents/src/engine/text-geometry.js
@@ -25,8 +25,12 @@ function leadingWordSpacing(text, style, measure) {
}
export function measureTextGeometry(text, style, measure) {
+ // Every measurement below is only read (each result is a new object): a measurer's own copy is not needed.
+ measure = measure.shared ?? measure;
let fonts;
- if (!style.fontFamily) {
+ // ASCII text has a single font slot (fontSlot): it needs no segmenting.
+ if (!style.fontFamily && /^[\x00-\x7f]*$/.test(text)) style = textFontStyle(text, style);
+ else if (!style.fontFamily) {
fonts = [];
for (const { segment } of inlineSegments(text)) {
const family = textFontStyle(segment, style).fontFamily, last = fonts.at(-1);
@@ -67,9 +71,15 @@ export function measureTextGeometry(text, style, measure) {
}
checkSpacedAdvance(metrics.width, 0, style);
const transform=metrics.scriptTransform??textTransform(style);
- const decoration = fontMetrics(style);
- const strike = { strikeOffset: metrics.strikeOffset ?? decoration.strikeOffset, strikeThickness: metrics.strikeThickness ?? decoration.strikeThickness };
- if (!transform) return { ...metrics, ...strike, ...(style.fontFamily ? { fontFamily: style.fontFamily } : {}) };
+ // The bundled defaults, for a measurement without its own decoration metrics (canvas ones have them).
+ let decoration;
+ const fallback = () => decoration ??= fontMetrics(style);
+ const strike = { strikeOffset: metrics.strikeOffset ?? fallback().strikeOffset, strikeThickness: metrics.strikeThickness ?? fallback().strikeThickness };
+ if (!transform) {
+ const result = { ...metrics, strikeOffset: strike.strikeOffset, strikeThickness: strike.strikeThickness };
+ if (style.fontFamily) result.fontFamily = style.fontFamily;
+ return result;
+ }
// Lines are allocated using the declared font's metrics. Script scaling
// changes ink/carets; only an explicit position shifts the allocation box.
const shift = (style.baselineShift || 0) * 4 / 3;
@@ -79,6 +89,6 @@ export function measureTextGeometry(text, style, measure) {
...(metrics.nativeUnderlineThickness === undefined ? {} : { nativeUnderlineOffset: metrics.nativeUnderlineOffset * transform.scaleY + transform.offsetY, nativeUnderlineThickness: metrics.nativeUnderlineThickness * transform.scaleY }),
ascent: metrics.ascent * transform.scaleY - transform.offsetY,
descent: metrics.descent * transform.scaleY + transform.offsetY,
- underlineOffset: (metrics.underlineOffset ?? decoration.underlineOffset) * transform.scaleY + transform.offsetY,
- underlineThickness: (metrics.underlineThickness ?? decoration.underlineThickness) * transform.scaleY };
+ underlineOffset: (metrics.underlineOffset ?? fallback().underlineOffset) * transform.scaleY + transform.offsetY,
+ underlineThickness: (metrics.underlineThickness ?? fallback().underlineThickness) * transform.scaleY };
}
diff --git a/src/documents/src/views/design-tab.js b/src/documents/src/views/design-tab.js
index a2167baf9..93dbef74e 100644
--- a/src/documents/src/views/design-tab.js
+++ b/src/documents/src/views/design-tab.js
@@ -49,18 +49,26 @@ export function createDesignTab({ editor, pkg, error, onChange = () => {}, comma
const editable = () => !!ready() && !!base.changePackage;
let api = null;
const loading = design().then(module => { api = module; onChange(); }).catch(() => {});
- // What the open package declares, read once per package; the theme the document opened with is kept for Reset.
+ // What the open package declares, read once per package and each part only when asked for; the theme the
+ // document opened with is kept for Reset. The watermark and page fill parse the whole body XML (and its
+ // headers), which in a long document took longer than opening it: only their menus ask for them.
let infoFor = null, info = null, openedTheme;
+ const packageInfo = parts => {
+ const read = { theme: () => api.readThemeInfo(parts), styleSet: () => api.currentStyleSetName(parts, [...STYLE_SETS, ...loadSaved('styleSets')]),
+ watermark: () => api.readWatermark(parts), fill: () => api.hasPageFill(parts) };
+ const fallback = { theme: null, styleSet: null, watermark: null, fill: false }, known = {};
+ return Object.defineProperties({}, Object.fromEntries(Object.keys(read).map(name => [name, { enumerable: true, get() {
+ if (!Object.hasOwn(known, name)) { try { known[name] = read[name](); } catch { known[name] = fallback[name]; } }
+ return known[name];
+ } }])));
+ };
function current() {
const p = pkg();
if (!p || !api) return { theme: null, styleSet: null, watermark: null, fill: false };
if (infoFor !== p) {
infoFor = p;
- const parts = p.parts;
- try {
- info = { theme: api.readThemeInfo(parts), styleSet: api.currentStyleSetName(parts, [...STYLE_SETS, ...loadSaved('styleSets')]), watermark: api.readWatermark(parts), fill: api.hasPageFill(parts) };
- } catch { info = { theme: null, styleSet: null, watermark: null, fill: false }; }
- if (openedTheme === undefined) openedTheme = api.themePartXml(parts);
+ info = packageInfo(p.parts);
+ if (openedTheme === undefined) openedTheme = api.themePartXml(p.parts);
}
return info;
}
diff --git a/src/documents/src/views/editor.js b/src/documents/src/views/editor.js
index 944707a19..02ca4b556 100644
--- a/src/documents/src/views/editor.js
+++ b/src/documents/src/views/editor.js
@@ -1841,8 +1841,17 @@ export function editorView({ doc, goHome, newDoc, importFile, openPath, newFromT
fitObserver = new ResizeObserver(() => { fitWidth(); engine?.revealCaret(); }); fitObserver.observe(host);
if (!pkg.readOnly) doc.attach(async () => {
if (engine.composition) throw new Error('Finish composing text before saving.');
- return engine.export(pkg);
+ // Saving zips the file off the page's thread, so typing during an autosave is not held up.
+ return engine.exportAsync ? engine.exportAsync(pkg) : engine.export(pkg);
}, { busy: () => !!engine?.composition });
+ // While the page is idle, parse ahead what the first save would (DocxPackage.prepareSave), a little at a time.
+ if (!pkg.readOnly && pkg.prepareSave && typeof requestIdleCallback === 'function') {
+ const prepared = pkg, prepare = deadline => {
+ if (destroyed || prepared !== pkg) return;
+ if (prepared.prepareSave(() => deadline.timeRemaining())) requestIdleCallback(prepare);
+ };
+ requestIdleCallback(prepare);
+ }
if (pkg.fieldsRecalculated && !pkg.readOnly) doc.changed();
const previewIssues = [...(pkg.previewIssues || []), ...engine.storyLayout.unsupported.map(issue =>
`${issue.path}, page ${issue.page + 1}: ${issue.message}`)];
diff --git a/src/documents/src/views/review.js b/src/documents/src/views/review.js
index 0dff6435b..1c66fdfb4 100644
--- a/src/documents/src/views/review.js
+++ b/src/documents/src/views/review.js
@@ -271,7 +271,7 @@ export function createReview({ editor, pkg, host, error, author, onChange = () =
},
revisionsAround() {
const engine = editor(); if (!engine) return [];
- return engine.session.body.revisionsAt?.(...engine.session.body.range) || [];
+ return engine.session.body.revisionsAround?.(...engine.session.body.range) || [];
},
hasRevisions() { return (editor()?.session.body.revisions || []).length > 0; },
resolve(accept, scope = 'here', { moveNext = false, stopTracking = false, change = null } = {}) {
@@ -604,7 +604,8 @@ export function createReview({ editor, pkg, host, error, author, onChange = () =
tints(list);
const body = engine.session.body;
body.revisionAuthor = author() || 'Author';
- registerRevisionAuthors(body.paragraphs);
+ // The revision's listed changes (TextDocument.revisions) are the paragraphs' revisionRanges.
+ registerRevisionAuthors(body.paragraphs, body.revisions);
const o = revisionView.options, barColor = o.changeBarColor && !['auto', 'author'].includes(o.changeBarColor) ? `#${o.changeBarColor}` : revisionView.mode === 'simple' ? '#C4314B' : '#7A7A7A';
engine.setMarginBars?.(['all', 'simple'].includes(revisionView.mode) && o.changeBars !== 'none' ? shownChanges(body.revisions) : [], barColor, o.changeBars === 'none' ? 'left' : o.changeBars || 'left');
const label = markupButton?.querySelector('.ribbon-small-label'), name = MARKUP_VIEWS.find(([mode]) => mode === revisionView.mode)?.[1];
diff --git a/src/documents/tests/field-update-scaling.test.js b/src/documents/tests/field-update-scaling.test.js
index 70cd02ea6..f71e9ded1 100644
--- a/src/documents/tests/field-update-scaling.test.js
+++ b/src/documents/tests/field-update-scaling.test.js
@@ -23,7 +23,8 @@ const ranges = paragraphs => bodyFieldRanges(paragraphs).map(r => [r.field.id, r
// paragraph with a field was split into graphemes again (to check the fields start and end between them),
// which made Update Fields and Update Table quadratic in long documents.
it('splits a paragraph with fields into graphemes once until its text changes', () => {
- const paragraphs = Array.from({ length: 20 }, (_, i) => withField(`Entry ${i}\t12`, `f${i}`, 7 + String(i).length, 9 + String(i).length));
+ // Accented text: plain ASCII is split without the segmenter (inlineSegments).
+ const paragraphs = Array.from({ length: 20 }, (_, i) => withField(`Entrée ${i}\t12`, `f${i}`, 8 + String(i).length, 10 + String(i).length));
const segment = vi.spyOn(Intl.Segmenter.prototype, 'segment');
const first = ranges(paragraphs);
expect(segment).toHaveBeenCalledTimes(20);
@@ -32,8 +33,8 @@ it('splits a paragraph with fields into graphemes once until its text changes',
expect(ranges(paragraphs.map(p => ({ ...p })))).toEqual(first);
expect(segment).toHaveBeenCalledTimes(20);
// A changed result is split again: only that paragraph.
- paragraphs[3].runs = [{ ...paragraphs[3].runs[0], text: 'Entry 3\t7' }];
- paragraphs[3].fieldMarkers[1].offset = 9;
+ paragraphs[3].runs = [{ ...paragraphs[3].runs[0], text: 'Entrée 3\t7' }];
+ paragraphs[3].fieldMarkers[1].offset = 10;
const [, start, end] = ranges(paragraphs)[3];
expect(end - start).toBe(1);
expect(segment).toHaveBeenCalledTimes(21);
diff --git a/src/documents/tests/line-measure-reuse.test.js b/src/documents/tests/line-measure-reuse.test.js
new file mode 100644
index 000000000..ef9a915bd
--- /dev/null
+++ b/src/documents/tests/line-measure-reuse.test.js
@@ -0,0 +1,94 @@
+// @vitest-environment jsdom
+import { it, expect } from 'vitest';
+import { TextDocument, paragraph, runFormat } from '../src/engine/model.js';
+import { RUN_FORMAT } from '../src/engine/formatting.js';
+import { underlinePattern } from '../src/engine/underlines.js';
+import { layoutDocument } from '../src/engine/layout.js';
+import { pageGeometry } from '../src/engine/page-settings.js';
+import { LineBuilder } from '../src/engine/line-builder.js';
+import { textFontStyle } from '../src/engine/font-families.js';
+import { inlineSegments } from '../src/engine/images.js';
+import { createCanvasMeasurer } from '../src/engine/canvas-measurer.js';
+
+// Opening a long document measures every line: work repeated per character there is felt as a slow open.
+const fixed = (text, style) => ({ width: [...text].length * (style.fontSize ?? 12) * .5, ascent: 10, descent: 3 });
+const recording = () => {
+ const calls = [];
+ const measure = (text, style) => { calls.push([text, style]); return fixed(text, style); };
+ return { calls, measure };
+};
+const lay = (doc, measure) => layoutDocument(doc.paragraphs, measure, pageGeometry(doc.pageSettings), doc.numbering, doc.tables);
+
+it("builds a paragraph's last line once: fitting it already measured each of its texts", () => {
+ const words = 'A short line of words.';
+ const { calls, measure } = recording();
+ lay(new TextDocument([paragraph(words)]), measure);
+ // Every prefix of the single line is measured exactly once, in the run's one style.
+ const texts = calls.filter(([text]) => text);
+ expect(new Set(texts.map(([, style]) => style)).size).toBe(1);
+ expect(texts.map(([text]) => text)).toEqual([...words].map((_, i) => words.slice(0, i + 1)));
+ // Wrapped and edited paragraphs lay out as with a measurer that remembers nothing.
+ const doc = new TextDocument([paragraph(words), paragraph('Another paragraph, wrapping onto a second line: '.repeat(6))]);
+ const layout = lay(doc, measure);
+ expect(layout.lines.length).toBeGreaterThan(3);
+ doc.select(3); doc.replace('very ');
+ const context = { fontKerning: 'auto', measureText: text => ({ width: text.length * 8 }), save() {}, restore() {} };
+ const canvas = createCanvasMeasurer(context);
+ expect(JSON.parse(JSON.stringify(lay(doc, canvas).lines))).toEqual(JSON.parse(JSON.stringify(lay(doc, (text, style) => createCanvasMeasurer(context)(text, style)).lines)));
+});
+
+it('measures a group of characters as the style object it copies, and keeps every span of a run', () => {
+ const page = { left: 0, defaultTabStop: 720 }, format = { tabs: [], indentLeft: 0, firstLine: 0 };
+ const red = textFontStyle('a', runFormat({ color: 'FF0000' }));
+ const blue = textFontStyle('a', runFormat({ color: '0000FF', underline: true }));
+ const sameAsRed = { ...red };
+ const { calls, measure } = recording();
+ const builder = new LineBuilder(0, 1000, format, page, measure, 0);
+ const text = 'abcdefgh', styles = [red, red, blue, blue, sameAsRed, red, blue, red];
+ [...text].forEach((c, i) => builder.add({ text: c, start: i, end: i + 1, style: styles[i] }));
+ const line = builder.finish(null, true);
+ // One shaping group (equal shape, different colors), measured as the first character's own style.
+ expect(line.fragments.map(f => f.text)).toEqual([text]);
+ expect(calls.filter(([t]) => t).every(([, style]) => style === red)).toBe(true);
+ // Color changes follow the characters, whatever style object carries them.
+ expect(line.fragments[0].colors.map(c => [c.value, c.x, c.width])).toEqual([['FF0000', 0, 12], ['0000FF', 12, 12], ['FF0000', 24, 12], ['0000FF', 36, 6], ['FF0000', 42, 6]]);
+ expect(line.underlines.map(u => [u.x, u.width])).toEqual([[12, 12], [36, 6]]);
+ expect(line.stops.map(s => s.x)).toEqual([0, 6, 12, 18, 24, 30, 36, 42, 48]);
+});
+
+it('a new shape still starts a new group after characters of an equal shape', () => {
+ const page = { left: 0, defaultTabStop: 720 }, format = { tabs: [], indentLeft: 0, firstLine: 0 };
+ const plain = textFontStyle('a', runFormat({})), bold = textFontStyle('a', runFormat({ bold: true }));
+ const builder = new LineBuilder(0, 1000, format, page, fixed, 0);
+ [plain, { ...plain }, bold, plain].forEach((style, i) => builder.add({ text: 'x', start: i, end: i + 1, style }));
+ expect(builder.finish(null, true).fragments.map(f => [f.text, f.bold])).toEqual([['xx', false], ['x', true], ['x', false]]);
+});
+
+it("a canvas measurer's shared measurement is its remembered one; callers otherwise get copies", () => {
+ const context = { fontKerning: 'auto', measureText: text => ({ width: text.length * 8 }), save() {}, restore() {} };
+ const measure = createCanvasMeasurer(context), style = textFontStyle('a', runFormat({}));
+ const shared = measure.shared('word', style);
+ expect(measure.shared('word', style)).toBe(shared);
+ const copy = measure('word', style);
+ expect(copy).not.toBe(shared);
+ expect(copy).toEqual(shared);
+ copy.width = 1;
+ expect(measure('word', style).width).toBe(shared.width);
+});
+
+it('segments ASCII text into the graphemes Intl.Segmenter finds', () => {
+ const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
+ const ascii = Array.from({ length: 128 }, (_, i) => String.fromCharCode(i)).join('');
+ for (const text of [ascii, [...ascii].reverse().join(''), 'a\r\nb', '\r\r\n\n\r', 'tab\there \v page\f', '', 'one word']) {
+ expect([...inlineSegments(text)]).toEqual([...segmenter.segment(text)].map(s => ({ segment: s.segment, index: s.index })));
+ }
+ // An object placeholder and non-ASCII text keep the full segmenter.
+ expect([...inlineSegments('éx')].map(s => s.segment)).toEqual(['é', '', 'x']);
+});
+
+it('normalizes a run format in the declared property order', () => {
+ const reference = run => { const result = Object.fromEntries(Object.entries(RUN_FORMAT).map(([k, v]) => [k, run?.[k] ?? v])); result.underlineStyle = underlinePattern(run); result.underline = result.underlineStyle !== 'none'; if (run?.legacyKerning !== undefined) result.legacyKerning = run.legacyKerning; return result; };
+ for (const run of [undefined, {}, { bold: true, fontSize: 9, color: null, underlineStyle: 'double' }, { legacyKerning: true, kerning: 8, text: 'ignored' }]) {
+ expect(JSON.stringify(runFormat(run))).toBe(JSON.stringify(reference(run)));
+ }
+});
diff --git a/src/documents/tests/page-story-cache-keys.test.js b/src/documents/tests/page-story-cache-keys.test.js
new file mode 100644
index 000000000..698e2c79e
--- /dev/null
+++ b/src/documents/tests/page-story-cache-keys.test.js
@@ -0,0 +1,66 @@
+// @vitest-environment jsdom
+import { it, expect } from 'vitest';
+import { paragraph } from '../src/engine/model.js';
+import { layoutDocument } from '../src/engine/layout.js';
+import { layoutPageStories } from '../src/engine/page-story-layout.js';
+import { layoutSerializer } from '../src/engine/page-story-measure.js';
+import { DEFAULT_PAGE_SETTINGS, pageGeometry } from '../src/engine/page-settings.js';
+import { ParagraphMeasureCache } from '../src/engine/paragraph-measure-cache.js';
+import { emptyNumbering, newList } from '../src/engine/numbering.js';
+
+const settings = { ...DEFAULT_PAGE_SETTINGS };
+const metrics = text => ({ width: [...text].length * 6, ascent: 12, descent: 4 });
+function remembering() {
+ const cache = new ParagraphMeasureCache(), measure = text => metrics(text);
+ measure.paragraphCache = () => cache;
+ return { measure, cache };
+}
+const plainMeasure = text => metrics(text);
+// Five pages of body text, each starting a new page.
+const body = () => Array.from({ length: 5 }, (_, i) => paragraph(`Page ${i + 1} text`, i ? { format: { pageBreakBefore: true } } : {}));
+const definition = header => ({ evenAndOdd: false, sections: [{ header: { default: 'header' }, footer: {} }], stories: { header } });
+function layout(measure, paragraphs, def, numbering, serialize = layoutSerializer()) {
+ measure.paragraphCache?.().nextGeneration();
+ const placed = layoutDocument(paragraphs, measure, pageGeometry(settings), numbering, [], def, { serialize });
+ return { body: placed, stories: layoutPageStories(placed, def, [settings], measure, numbering, { serialize }) };
+}
+const shown = stories => JSON.parse(JSON.stringify(stories.lines.map(line => ({ page: line.page, y: line.y, text: line.fragments.map(f => f.text).join(''), marker: line.listMarker ?? line.marker ?? null }))));
+
+it('measures a header without page fields once for all its pages, and one with a page number once a page', () => {
+ const { measure, cache } = remembering();
+ const plain = layout(measure, body(), definition({ paragraphs: [paragraph('Running head')] }), emptyNumbering());
+ expect(plain.body.pageCount).toBe(5);
+ expect(plain.stories.lines.filter(line => line.pageStory === 'header')).toHaveLength(5);
+ expect(cache.stories.size).toBe(1);
+
+ const numbered = remembering();
+ const field = { paragraphs: [paragraph('', { runs: [{ text: '', field: { type: 'PAGE', cachedText: '1' } }] })] };
+ const { stories } = layout(numbered.measure, body(), definition(field), emptyNumbering());
+ expect(stories.lines.map(line => line.fragments[0].text)).toEqual(['1', '2', '3', '4', '5']);
+ // The body's header clearance and the painted header each measure it on every page.
+ expect(numbered.cache.stories.size).toBeGreaterThanOrEqual(5);
+});
+
+it('lays a listed header out again when its list definition changes in place', () => {
+ const numbering = emptyNumbering(), numId = newList(numbering, 'bullet');
+ const header = { paragraphs: [paragraph('Listed head', { list: { numId, level: 0 } })] };
+ const { measure } = remembering();
+ layout(measure, body(), definition(header), numbering);
+ // Commands change list definitions in place (newList, the list library); the remembered measurement must not survive it.
+ const abstract = numbering.abstracts[numbering.instances[numId].abstractId];
+ abstract.levels[0] = { ...abstract.levels[0], format: 'decimal', text: '%1)', geometry: { ...abstract.levels[0].geometry, indentLeft: 2880 } };
+ const remembered = layout(measure, body(), definition(header), numbering);
+ const fresh = layout(plainMeasure, body(), definition(header), numbering);
+ expect(shown(remembered.stories)).toEqual(shown(fresh.stories));
+ expect(JSON.stringify(remembered.stories.lines.map(line => line.fragments.map(f => [f.x, f.text])))).toBe(JSON.stringify(fresh.stories.lines.map(line => line.fragments.map(f => [f.x, f.text]))));
+});
+
+it('serializes each object once per layout and again for the next one', () => {
+ const serialize = layoutSerializer(), value = { a: [1, 2] };
+ const first = serialize(value);
+ expect(serialize(value)).toBe(first);
+ expect(serialize(null)).toBe('null');
+ expect(serialize(undefined)).toBe('null');
+ value.a.push(3);
+ expect(layoutSerializer()(value)).toBe('{"a":[1,2,3]}');
+});
diff --git a/src/documents/tests/revision-list-cache.test.js b/src/documents/tests/revision-list-cache.test.js
new file mode 100644
index 000000000..4acfc2321
--- /dev/null
+++ b/src/documents/tests/revision-list-cache.test.js
@@ -0,0 +1,46 @@
+// @vitest-environment jsdom
+import { it, expect } from 'vitest';
+import { strToU8 as u8 } from 'fflate';
+const strToU8 = text => Uint8Array.from(u8(text));
+import { TextDocument, paragraph } from '../src/engine/model.js';
+import { themeColors } from '../src/engine/drawing-xml.js';
+import { relatedPartPath } from '../src/engine/docx-relationships.js';
+import { customXmlItems } from '../src/engine/bibliography-fields.js';
+
+it('lists tracked changes once per revision and anew after every change, undo included', () => {
+ const doc = new TextDocument([paragraph('Plain words')], undefined, undefined, undefined, [], {}, { trackChanges: true });
+ doc.revisionAuthor = 'Ada';
+ const none = doc.revisions;
+ expect(none).toEqual([]);
+ expect(doc.revisions).toBe(none);
+ doc.select(5); doc.checkpoint(); doc.replace('new ');
+ expect(doc.revisions.map(r => [r.type, r.author, r.start, r.end])).toEqual([['ins', 'Ada', 5, 9]]);
+ expect(doc.revisions).toBe(doc.revisions);
+ expect(doc.revisionsAround(6, 6)).toEqual(doc.revisionsAt(6, 6));
+ expect(doc.revisionsAround(0, 2)).toEqual([]);
+ doc.undo();
+ expect(doc.revisions).toEqual([]);
+});
+
+const RELS = 'word/_rels/document.xml.rels', R = 'http://schemas.openxmlformats.org/package/2006/relationships', T = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/';
+const rels = entries => strToU8(`${entries}`);
+const theme = accent => strToU8(``);
+
+it('reads a rewritten relationships or theme part again', () => {
+ const parts = { 'word/document.xml': strToU8(''), [RELS]: rels(``), 'word/theme/theme1.xml': theme('112233') };
+ expect(relatedPartPath(parts, 'theme')).toBe('word/theme/theme1.xml');
+ expect(themeColors(parts)).toEqual({ accent1: '112233' });
+ // The colors handed out are the caller's own.
+ themeColors(parts).accent1 = '000000';
+ expect(themeColors(parts)).toEqual({ accent1: '112233' });
+ parts['word/theme/theme1.xml'] = theme('445566');
+ expect(themeColors(parts)).toEqual({ accent1: '445566' });
+ parts['word/theme/theme2.xml'] = theme('778899');
+ parts[RELS] = rels(``);
+ parts['customXml/item1.xml'] = strToU8('');
+ expect(themeColors(parts)).toEqual({ accent1: '778899' });
+ expect(customXmlItems(parts)).toEqual(['customXml/item1.xml']);
+ parts[RELS] = rels(``);
+ expect(() => relatedPartPath(parts, 'theme')).toThrow(/duplicate or external theme/);
+ expect(themeColors(parts)).toBe(null);
+});
diff --git a/src/documents/tests/save-preparation.test.js b/src/documents/tests/save-preparation.test.js
new file mode 100644
index 000000000..0ec402dec
--- /dev/null
+++ b/src/documents/tests/save-preparation.test.js
@@ -0,0 +1,42 @@
+// @vitest-environment jsdom
+import { it, expect, vi, beforeEach } from 'vitest';
+import { Blob } from 'node:buffer';
+import { unzipSync, strFromU8 } from 'fflate';
+import { DocxPackage } from '../src/engine/docx.js';
+import { TextDocument } from '../src/engine/model.js';
+import { docxWithParagraphs } from './helpers/docx.js';
+
+// jsdom's Blob cannot be read back; Node's can.
+beforeEach(() => vi.stubGlobal('Blob', Blob));
+const source = () => docxWithParagraphs(Array.from({ length: 30 }, (_, i) => ({ text: `Paragraph ${i} of the saved document.`, heading: i % 10 === 0 })));
+const documentXml = async blob => strFromU8(unzipSync(new Uint8Array(await blob.arrayBuffer()))['word/document.xml']);
+async function saved(pkg, edit) {
+ const model = new TextDocument(pkg.paragraphs, pkg.pageSettings, pkg.numbering, pkg.styles, pkg.tables, pkg.assets);
+ edit(model);
+ return model;
+}
+
+it('saves each edit as a freshly opened package would, however often it saved before and whatever it parsed ahead', async () => {
+ const pkg = await DocxPackage.open(source());
+ // Parse ahead a little at a time, as the editor does while the page is idle.
+ let calls = 0;
+ while (pkg.prepareSave(() => (calls++ % 2 ? 0 : 5))) if (calls > 1000) throw new Error('prepareSave never finished');
+ for (const [at, text] of [[10, 'one '], [200, 'two '], [5, 'three ']]) {
+ const model = await saved(pkg, m => { m.select(at); m.replace(text); });
+ const fresh = await DocxPackage.open(source());
+ const expected = await documentXml(fresh.export(model.paragraphs, model.pageSettings, model.numbering, model.styles, model.tables, model.assets));
+ expect(await documentXml(pkg.export(model.paragraphs, model.pageSettings, model.numbering, model.styles, model.tables, model.assets))).toBe(expected);
+ expect(await documentXml(await pkg.exportAsync(model.paragraphs, model.pageSettings, model.numbering, model.styles, model.tables, model.assets))).toBe(expected);
+ expect(expected).toContain(text.trim());
+ }
+});
+
+it('zips the same parts asynchronously as at once', async () => {
+ const pkg = await DocxPackage.open(source());
+ const model = await saved(pkg, m => { m.select(0); m.replace('Start: '); });
+ const args = [model.paragraphs, model.pageSettings, model.numbering, model.styles, model.tables, model.assets];
+ const now = unzipSync(new Uint8Array(await pkg.export(...args).arrayBuffer()));
+ const later = unzipSync(new Uint8Array(await (await pkg.exportAsync(...args)).arrayBuffer()));
+ expect(Object.keys(later).sort()).toEqual(Object.keys(now).sort());
+ for (const name of Object.keys(now)) if (!/core\.xml$/.test(name)) expect(strFromU8(later[name])).toBe(strFromU8(now[name]));
+});