Skip to content
5 changes: 3 additions & 2 deletions src/documents/src/engine/bibliography-fields.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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. */
Expand Down
9 changes: 7 additions & 2 deletions src/documents/src/engine/canvas-editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -242,6 +243,8 @@ export class CanvasEditor {
get pageStories() { return this.session.pageStories; }
exportText() { return this.session.body.text; }
export(pkg) { return this.session.export(pkg); }
/** export(), zipped off the page's thread: a Promise of the Blob. */
exportAsync(pkg) { return this.session.export(pkg, { async: true }); }
editStory(path = null, page = 0) {
if (this.readOnly || path !== null && !this.pageStories?.stories[path]?.editable) return false;
this.finishComposition();
Expand Down Expand Up @@ -697,8 +700,10 @@ export class CanvasEditor {
this.measure.paragraphCache?.().nextGeneration(view?.kind ?? null);
const body = this.session.body;
const stories = view ? viewPageStories(this.pageStories, view.kind) : this.pageStories;
let layout = layoutDocument(body.paragraphs, this.measure, pageGeometry(body.pageSettings), body.numbering, body.tables, stories, { bestEffort: this.readOnly, drawingLayouts: this.drawingLayouts, ...(!this.readOnly ? { styles: this.model.styles } : {}), ...(view ? { viewGeometry: viewGeometry(view) } : {}) });
const storyLayout = layoutPageStories(layout, stories, [...body.paragraphs.filter(p => p.sectionEnd).map(p => p.sectionEnd), body.pageSettings], this.measure, this.model.numbering, { bestEffort: this.readOnly, drawingLayouts: this.drawingLayouts, ...(!this.readOnly ? { styles: this.model.styles } : {}) });
// The body and its page stories read the same styles and list definitions: serialize each once.
const serialize = layoutSerializer();
let layout = layoutDocument(body.paragraphs, this.measure, pageGeometry(body.pageSettings), body.numbering, body.tables, stories, { bestEffort: this.readOnly, drawingLayouts: this.drawingLayouts, serialize, ...(!this.readOnly ? { styles: this.model.styles } : {}), ...(view ? { viewGeometry: viewGeometry(view) } : {}) });
const storyLayout = layoutPageStories(layout, stories, [...body.paragraphs.filter(p => p.sectionEnd).map(p => p.sectionEnd), body.pageSettings], this.measure, this.model.numbering, { bestEffort: this.readOnly, drawingLayouts: this.drawingLayouts, serialize, ...(!this.readOnly ? { styles: this.model.styles } : {}) });
if (view && isContinuousView(view.kind)) layout = fitContinuousPages(layout, storyLayout);
// Draft shows text only: floating drawings stay in the document but are not drawn or hit.
if (view?.kind === 'draft') layout = { ...layout, floatingObjects: [] };
Expand Down
7 changes: 5 additions & 2 deletions src/documents/src/engine/canvas-measurer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 = () => {};
Expand Down
9 changes: 6 additions & 3 deletions src/documents/src/engine/canvas-text.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -336,7 +337,8 @@ export function measurePreparedText(context, prepared, text, onSeparatorReady, o
let metrics = prepared.metrics;
if (!metrics || metrics.face !== face || metrics.ascent !== box.fontBoundingBoxAscent || metrics.descent !== box.fontBoundingBoxDescent)
metrics = prepared.metrics = { face, ascent: box.fontBoundingBoxAscent, descent: box.fontBoundingBoxDescent, value: fontMetrics(style, box, face) };
const result = { width: native.width / scale, ...metrics.value, ...(caretAdvances ? {caretAdvances} : {}) };
const result = { width: native.width / scale, ...metrics.value };
if (caretAdvances) result.caretAdvances = caretAdvances;
if(prepared.script)result.scriptTransform=prepared.scriptTransform;
return result;
}
Expand All @@ -350,8 +352,9 @@ export async function validateCanvasLayout(pkg) {
// A long document is laid out more than once (footnote reservations, square wrapping): remember measurements.
const measure = createCanvasMeasurer(context);
const { assets, drawingLayouts, autofitApproximations } = prepareShapeAutofit(pkg.assets, measure, { bestEffort: pkg.readOnly });
const body = layoutDocument(pkg.paragraphs, measure, pageGeometry(pkg.pageSettings), pkg.numbering, pkg.tables, pkg.pageStories, { ...(!pkg.readOnly ? { styles: pkg.styles } : {}), drawingLayouts, bestEffort: pkg.readOnly });
if (pkg.pageStories && !pkg.readOnly) layoutPageStories(body, pkg.pageStories, [...pkg.paragraphs.filter(p => p.sectionEnd).map(p => p.sectionEnd), pkg.pageSettings], measure, pkg.numbering, { styles: pkg.styles, drawingLayouts });
const serialize = layoutSerializer();
const body = layoutDocument(pkg.paragraphs, measure, pageGeometry(pkg.pageSettings), pkg.numbering, pkg.tables, pkg.pageStories, { ...(!pkg.readOnly ? { styles: pkg.styles } : {}), drawingLayouts, bestEffort: pkg.readOnly, serialize });
if (pkg.pageStories && !pkg.readOnly) layoutPageStories(body, pkg.pageStories, [...pkg.paragraphs.filter(p => p.sectionEnd).map(p => p.sectionEnd), pkg.pageSettings], measure, pkg.numbering, { styles: pkg.styles, drawingLayouts, serialize });
// A later layout failure must not leave fitted scenes in the package that
// the read-only retry will receive. Publish only a successful preparation.
Object.assign(pkg, { assets, drawingLayouts });
Expand Down
25 changes: 21 additions & 4 deletions src/documents/src/engine/docx-relationships.js
Original file line number Diff line number Diff line change
Expand Up @@ -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('/')) {
Expand Down
73 changes: 68 additions & 5 deletions src/documents/src/engine/docx.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand All @@ -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);
Expand Down Expand Up @@ -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.');
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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 || {}) }; }
Expand Down Expand Up @@ -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.');
Expand Down
Loading
Loading