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