From 4916a781ba31d7a8c405de4664e0914c84207b67 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Wed, 28 Oct 2026 10:56:44 -0700 Subject: [PATCH 1/7] Documents: keep typing quick in documents with many lists and a header or footer Every keystroke laid out each page's header and footer again from the measured-story cache, whose key held the document's whole list definitions. A document with 176 lists serialized half a megabyte of them into the key of every page's header and footer, then hashed each key to look it up: about 45 ms of each keystroke in a 28-page document. A layout now serializes the list definitions once, like the styles, and the keys name them by the cache's short id. A header or footer without page fields no longer takes the page number into its key either, so it is measured once rather than once a page, at opening and after. --- src/documents/src/engine/footnote-layout.js | 2 +- src/documents/src/engine/layout.js | 10 +++++++--- src/documents/src/engine/page-story-layout.js | 6 ++++-- src/documents/src/engine/page-story-measure.js | 14 ++++++++++---- src/documents/src/engine/story-clearance.js | 2 +- 5 files changed, 23 insertions(+), 11 deletions(-) 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/layout.js b/src/documents/src/engine/layout.js index 055cad72d..607bf2bf9 100644 --- a/src/documents/src/engine/layout.js +++ b/src/documents/src/engine/layout.js @@ -135,13 +135,17 @@ 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 storyStylesKey = cache && JSON.stringify(storyOptions.styles ?? null), storyNumberingKey = cache && JSON.stringify(numbering ?? null); 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 +204,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(); diff --git a/src/documents/src/engine/page-story-layout.js b/src/documents/src/engine/page-story-layout.js index f5ee8e344..83d5949f7 100644 --- a/src/documents/src/engine/page-story-layout.js +++ b/src/documents/src/engine/page-story-layout.js @@ -22,6 +22,8 @@ export function layoutPageStories(body, definition, sectionSettings, measure, nu // 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 list definitions: serialize them once (storyMeasureKey). + const numberingKey = measure.paragraphCache ? JSON.stringify(numbering ?? null) : 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 +55,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 +100,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..22981de3e 100644 --- a/src/documents/src/engine/page-story-measure.js +++ b/src/documents/src/engine/page-story-measure.js @@ -34,10 +34,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 +48,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/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; } }; From 8effff774c3bff439e1f9e5c5d2e38c2e625ac3b Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Wed, 28 Oct 2026 10:56:44 -0700 Subject: [PATCH 2/7] Documents: read the watermark and page fill only when their menus need them Opening a document read its watermark and page background for the Design tab at once, parsing the whole body XML twice (and every header): about 140 ms of opening a 670 KB document. Each piece of the package's design is now read the first time something asks for it. --- src/documents/src/views/design-tab.js | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) 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; } From f22a9b4a9eec328bdf5fe9613be61c828403b490 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Wed, 28 Oct 2026 10:56:44 -0700 Subject: [PATCH 3/7] Documents: refresh the controls after a keystroke without rereading the document After each keystroke the controls listed the document's tracked changes seven times, each a pass over every run, parsed the main document's relationships (a document with 300 hyperlinks has 60 KB of them) to find the theme and the bibliography, and parsed the theme. The changes are now listed once per document revision, and the relationships and theme colors once per version of their parts: 8.7 ms -> 3.6 ms per keystroke. --- .../src/engine/bibliography-fields.js | 5 ++-- .../src/engine/docx-relationships.js | 25 +++++++++++++--- src/documents/src/engine/drawing-xml.js | 30 ++++++++++++------- src/documents/src/engine/model.js | 20 ++++++++++++- src/documents/src/engine/revisions.js | 9 +++--- src/documents/src/views/review.js | 5 ++-- 6 files changed, 71 insertions(+), 23 deletions(-) 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/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/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/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/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/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]; From d7f4267365db70929d0f9ab55cdd46c987bc1744 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Wed, 28 Oct 2026 11:04:09 -0700 Subject: [PATCH 4/7] Documents: test the per-revision change list and per-part relationship reads --- .../tests/revision-list-cache.test.js | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/documents/tests/revision-list-cache.test.js 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); +}); From 90b859c23ecd3f5ad8eae5a453f1c9446eebc6e8 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Wed, 28 Oct 2026 11:04:09 -0700 Subject: [PATCH 5/7] Documents: serialize styles and lists once per layout, and keep unchanged paragraphs' keys The body and its headers and footers each serialized the styles and list definitions for their cache keys on every keystroke; one layout now serializes each once for both. An unchanged paragraph also keeps the key strings of the last layout, so looking every paragraph up no longer hashes the whole document's text again: about 1 ms of each keystroke in a 1,500-paragraph document. --- src/documents/src/engine/canvas-editor.js | 7 +- src/documents/src/engine/canvas-text.js | 6 +- src/documents/src/engine/layout.js | 13 +++- src/documents/src/engine/page-story-layout.js | 11 ++-- .../src/engine/page-story-measure.js | 15 +++++ .../tests/page-story-cache-keys.test.js | 66 +++++++++++++++++++ 6 files changed, 107 insertions(+), 11 deletions(-) create mode 100644 src/documents/tests/page-story-cache-keys.test.js diff --git a/src/documents/src/engine/canvas-editor.js b/src/documents/src/engine/canvas-editor.js index dc8a2df30..470cb3020 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'; @@ -697,8 +698,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-text.js b/src/documents/src/engine/canvas-text.js index ed1725595..08690c7b5 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'; @@ -350,8 +351,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/layout.js b/src/documents/src/engine/layout.js index 607bf2bf9..deb698784 100644 --- a/src/documents/src/engine/layout.js +++ b/src/documents/src/engine/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. */ @@ -138,7 +140,8 @@ function layoutDocumentContent(paragraphs, measureBase, page, numbering, tables, // 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 storyStylesKey = cache && JSON.stringify(storyOptions.styles ?? null), storyNumberingKey = cache && JSON.stringify(numbering ?? null); + 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(); @@ -212,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; }; diff --git a/src/documents/src/engine/page-story-layout.js b/src/documents/src/engine/page-story-layout.js index 83d5949f7..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,9 +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 list definitions: serialize them once (storyMeasureKey). - const numberingKey = measure.paragraphCache ? JSON.stringify(numbering ?? null) : undefined; + // 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))); diff --git a/src/documents/src/engine/page-story-measure.js b/src/documents/src/engine/page-story-measure.js index 22981de3e..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. 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]}'); +}); From c91e3de3c5907fb1653d46c0044a157200e23839 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Wed, 28 Oct 2026 11:26:20 -0700 Subject: [PATCH 6/7] Documents: keep typing smooth while a long document autosaves An autosave a moment after typing stopped held the page for about 380 ms in a 1,500-paragraph document, so keys typed then waited. The save parsed the whole body XML again just to keep its root and final section properties, parsed each unchanged paragraph's source XML with a parser of its own, and deflated the package on the page's thread. The save's starting skeleton is now taken from the parse made when the document opened, each paragraph's source XML is parsed once (ahead of the first save, a little at a time while the page is idle), and the autosave zips the parts in a worker, falling back to the page's thread where workers are unavailable: 380 ms -> 96 ms of the page's time. Saved files are byte for byte the same as before for all 2,257 fixture and corpus documents, edited and saved twice each. --- src/documents/src/engine/canvas-editor.js | 2 + src/documents/src/engine/docx.js | 73 +++++++++++++++++-- .../src/engine/package-zip-worker.js | 12 +++ src/documents/src/engine/package-zip.js | 46 ++++++++++++ src/documents/src/engine/story-session.js | 5 +- src/documents/src/views/editor.js | 11 ++- src/documents/tests/save-preparation.test.js | 42 +++++++++++ 7 files changed, 183 insertions(+), 8 deletions(-) create mode 100644 src/documents/src/engine/package-zip-worker.js create mode 100644 src/documents/src/engine/package-zip.js create mode 100644 src/documents/tests/save-preparation.test.js diff --git a/src/documents/src/engine/canvas-editor.js b/src/documents/src/engine/canvas-editor.js index 470cb3020..c34e0611b 100644 --- a/src/documents/src/engine/canvas-editor.js +++ b/src/documents/src/engine/canvas-editor.js @@ -243,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(); 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/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/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/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/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])); +}); From 59223c0ea373b94776eaa50c2f8950cf45cf618f Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Wed, 28 Oct 2026 12:10:42 -0700 Subject: [PATCH 7/7] Documents: measure paragraphs about 40% faster when a document opens Opening a document lays every paragraph out once, measuring the text of each line prefix by prefix. A 28-page document of 1,500 paragraphs spent about 255 ms there; profiling showed the canvas measureText calls were only about 50 ms of it. The rest was repeated bookkeeping around them: - Every line was built twice: once to find where it breaks and again to build the result, repeating each measurement. A paragraph's last line is exactly what the first pass built when it ran out of characters, so it is now used as is. Most paragraphs are a single line. - Each character copied its run's whole format (40 properties), and each line's text groups were new style objects, so the measurer serialized thousands of them to key its memory. A run's characters now share one style per font slot, and a group measures as the style it was copied from (it differs only in layout's own output fields). - Characters were built with conditional object spreads, the shaping key string was rebuilt for every character, and color, underline and background decisions were recomputed per character; they are now built plainly, computed once per style object, and skipped when the style object has not changed. - The geometry layer copied each remembered measurement before copying it again, built the bundled decoration defaults it never used for canvas measurements, and segmented ASCII text into graphemes. Layout output is unchanged: every line, fragment, caret stop and metric of the 2,256 test and public fixtures and a large real document is identical before and after, including after typing into its longest paragraph with the same measurer. Non-minified production build, opening the 28-page document in Chrome: validateCanvasLayout 293 -> 195 ms, paragraph measuring 254 -> 151 ms. A generated document of 120 paragraphs of 1,200 characters: first layout 464 -> 298 ms, relayout after a keystroke 14.8 -> 12.7 ms. --- src/documents/src/engine/canvas-measurer.js | 7 +- src/documents/src/engine/canvas-text.js | 3 +- src/documents/src/engine/formatting.js | 3 +- src/documents/src/engine/images.js | 5 + src/documents/src/engine/layout.js | 37 ++++++-- src/documents/src/engine/line-builder.js | 74 ++++++++++----- src/documents/src/engine/text-geometry.js | 22 +++-- .../tests/field-update-scaling.test.js | 7 +- .../tests/line-measure-reuse.test.js | 94 +++++++++++++++++++ 9 files changed, 210 insertions(+), 42 deletions(-) create mode 100644 src/documents/tests/line-measure-reuse.test.js 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 08690c7b5..f7bd747c2 100644 --- a/src/documents/src/engine/canvas-text.js +++ b/src/documents/src/engine/canvas-text.js @@ -337,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; } 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 deb698784..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'; @@ -853,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; @@ -868,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 @@ -892,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; @@ -969,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; @@ -979,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/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/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))); + } +});