From e6dd3445eed51aa42ca25b0845503caa6a2c2848 Mon Sep 17 00:00:00 2001 From: Artem Nistuley Date: Tue, 14 Apr 2026 20:48:18 +0300 Subject: [PATCH 1/5] fix: endnotes --- .../src/editors/v1/core/DocxZipper.js | 8 ++ .../src/editors/v1/core/Editor.ts | 14 +++ .../v1/core/super-converter/SuperConverter.js | 23 +++- .../v1/core/super-converter/exporter.js | 5 +- .../v2/exporter/footnotesExporter.js | 103 +++++++++++++----- 5 files changed, 124 insertions(+), 29 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/DocxZipper.js b/packages/super-editor/src/editors/v1/core/DocxZipper.js index 3f69a06686..7a5f752707 100644 --- a/packages/super-editor/src/editors/v1/core/DocxZipper.js +++ b/packages/super-editor/src/editors/v1/core/DocxZipper.js @@ -291,12 +291,20 @@ class DocxZipper { const hasFootnotes = types.elements?.some( (el) => el.name === 'Override' && el.attributes.PartName === '/word/footnotes.xml', ); + const hasEndnotes = types.elements?.some( + (el) => el.name === 'Override' && el.attributes.PartName === '/word/endnotes.xml', + ); if (hasFile('word/footnotes.xml')) { const footnotesDef = ``; if (!hasFootnotes) typesString += footnotesDef; } + if (hasFile('word/endnotes.xml')) { + const endnotesDef = ``; + if (!hasEndnotes) typesString += endnotesDef; + } + // Update for managed document-level singleton parts (e.g., numbering) for (const entry of MANAGED_DOCUMENT_PARTS) { if (hasFile(entry.zipPath) && !hasPartOverride(`/${entry.zipPath}`)) { diff --git a/packages/super-editor/src/editors/v1/core/Editor.ts b/packages/super-editor/src/editors/v1/core/Editor.ts index 72007960b9..676c878823 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.ts +++ b/packages/super-editor/src/editors/v1/core/Editor.ts @@ -3171,6 +3171,12 @@ export class Editor extends EventEmitter { const footnotesRelsXml = footnotesRelsData?.elements?.[0] ? this.converter.schemaToXml(footnotesRelsData.elements[0]) : null; + const endnotesData = this.converter.convertedXml['word/endnotes.xml']; + const endnotesXml = endnotesData?.elements?.[0] ? this.converter.schemaToXml(endnotesData.elements[0]) : null; + const endnotesRelsData = this.converter.convertedXml['word/_rels/endnotes.xml.rels']; + const endnotesRelsXml = endnotesRelsData?.elements?.[0] + ? this.converter.schemaToXml(endnotesRelsData.elements[0]) + : null; const media = this.converter.addedMedia; @@ -3217,6 +3223,14 @@ export class Editor extends EventEmitter { updatedDocs['word/_rels/footnotes.xml.rels'] = String(footnotesRelsXml); } + if (endnotesXml) { + updatedDocs['word/endnotes.xml'] = String(endnotesXml); + } + + if (endnotesRelsXml) { + updatedDocs['word/_rels/endnotes.xml.rels'] = String(endnotesRelsXml); + } + // Serialize each comment file if it exists in convertedXml, otherwise mark as null // for deletion from the zip (removes stale originals). const commentFiles = COMMENT_FILE_BASENAMES.map((name) => `word/${name}`); diff --git a/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.js b/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.js index 2170d65dd9..2a0e4bfe1b 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.js @@ -19,7 +19,7 @@ import { prepareCommentParaIds, prepareCommentsXmlFilesForExport, } from './v2/exporter/commentsExporter.js'; -import { prepareFootnotesXmlForExport } from './v2/exporter/footnotesExporter.js'; +import { prepareFootnotesXmlForExport, prepareEndnotesXmlForExport } from './v2/exporter/footnotesExporter.js'; import { writeAppStatistics } from '../../document-api-adapters/helpers/app-properties.js'; import { getWordStatistics, resolveMainBodyEditor } from '../../document-api-adapters/helpers/word-statistics.js'; import { refreshAllStatFields } from '../../document-api-adapters/helpers/refresh-stat-fields.js'; @@ -1194,12 +1194,25 @@ class SuperConverter { }); this.convertedXml = { ...this.convertedXml, ...footnotesUpdatedXml }; + const { + updatedXml: endnotesUpdatedXml, + relationships: endnotesRels, + media: endnotesMedia, + } = prepareEndnotesXmlForExport({ + endnotes: this.endnotes, + editor, + converter: this, + convertedXml: this.convertedXml, + }); + this.convertedXml = { ...this.convertedXml, ...endnotesUpdatedXml }; + // Update media await this.#exportProcessMediaFiles( { ...documentMedia, ...params.media, ...footnotesMedia, + ...endnotesMedia, ...this.media, }, editor, @@ -1235,7 +1248,13 @@ class SuperConverter { this._currentStatFieldCacheMap = undefined; // cleanup after export cycle // Update the rels table - this.#exportProcessNewRelationships([...params.relationships, ...commentsRels, ...footnotesRels, ...headFootRels]); + this.#exportProcessNewRelationships([ + ...params.relationships, + ...commentsRels, + ...footnotesRels, + ...endnotesRels, + ...headFootRels, + ]); // Prune relationships for comment parts that were removed if (removedTargets?.length) { diff --git a/packages/super-editor/src/editors/v1/core/super-converter/exporter.js b/packages/super-editor/src/editors/v1/core/super-converter/exporter.js index 257982a614..86548e69ef 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/exporter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/exporter.js @@ -255,7 +255,10 @@ export function translatePassthroughNode(params) { * @returns {XmlReadyNode} JSON of the XML-ready body node */ function translateBodyNode(params) { - let sectPr = params.bodyNode?.elements?.find((n) => n.name === 'w:sectPr'); + const liveSectPr = params.converter?.bodySectPr; + let sectPr = + (liveSectPr && typeof liveSectPr === 'object' ? carbonCopy(liveSectPr) : null) || + params.bodyNode?.elements?.find((n) => n.name === 'w:sectPr'); if (!sectPr) { sectPr = { type: 'element', diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/exporter/footnotesExporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/exporter/footnotesExporter.js index 4bf3bc5a71..4108b010c1 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/exporter/footnotesExporter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/exporter/footnotesExporter.js @@ -5,20 +5,45 @@ import { mergeRelationshipElements } from '../../relationship-helpers.js'; const RELS_XMLNS = 'http://schemas.openxmlformats.org/package/2006/relationships'; const FOOTNOTES_RELS_PATH = 'word/_rels/footnotes.xml.rels'; +const ENDNOTES_RELS_PATH = 'word/_rels/endnotes.xml.rels'; + +const FOOTNOTES_CONFIG = { + notesPath: 'word/footnotes.xml', + relsPath: FOOTNOTES_RELS_PATH, + rootName: 'w:footnotes', + noteName: 'w:footnote', + refName: 'w:footnoteRef', + refStyle: 'FootnoteReference', + settingsPropertyName: 'w:footnotePr', + relationshipType: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes', + relationshipTarget: 'footnotes.xml', +}; + +const ENDNOTES_CONFIG = { + notesPath: 'word/endnotes.xml', + relsPath: ENDNOTES_RELS_PATH, + rootName: 'w:endnotes', + noteName: 'w:endnote', + refName: 'w:endnoteRef', + refStyle: 'EndnoteReference', + settingsPropertyName: 'w:endnotePr', + relationshipType: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes', + relationshipTarget: 'endnotes.xml', +}; const paragraphHasFootnoteRef = (node) => { if (!node) return false; - if (node.name === 'w:footnoteRef') return true; + if (node.name === 'w:footnoteRef' || node.name === 'w:endnoteRef') return true; const children = Array.isArray(node.elements) ? node.elements : []; return children.some((child) => paragraphHasFootnoteRef(child)); }; -const insertFootnoteRefIntoParagraph = (paragraph) => { +const insertFootnoteRefIntoParagraph = (paragraph, config) => { if (!paragraph || paragraph.name !== 'w:p') return; if (!Array.isArray(paragraph.elements)) paragraph.elements = []; if (paragraphHasFootnoteRef(paragraph)) return; - const footnoteRef = { type: 'element', name: 'w:footnoteRef', elements: [] }; + const footnoteRef = { type: 'element', name: config.refName, elements: [] }; const footnoteRefRun = { type: 'element', name: 'w:r', @@ -27,7 +52,7 @@ const insertFootnoteRefIntoParagraph = (paragraph) => { type: 'element', name: 'w:rPr', elements: [ - { type: 'element', name: 'w:rStyle', attributes: { 'w:val': 'FootnoteReference' } }, + { type: 'element', name: 'w:rStyle', attributes: { 'w:val': config.refStyle } }, { type: 'element', name: 'w:vertAlign', attributes: { 'w:val': 'superscript' } }, ], }, @@ -40,11 +65,11 @@ const insertFootnoteRefIntoParagraph = (paragraph) => { paragraph.elements.splice(insertAt, 0, footnoteRefRun); }; -const ensureFootnoteRefMarker = (elements) => { +const ensureFootnoteRefMarker = (elements, config) => { if (!Array.isArray(elements)) return; const firstParagraphIndex = elements.findIndex((el) => el?.name === 'w:p'); if (firstParagraphIndex >= 0) { - insertFootnoteRefIntoParagraph(elements[firstParagraphIndex]); + insertFootnoteRefIntoParagraph(elements[firstParagraphIndex], config); return; } @@ -53,7 +78,7 @@ const ensureFootnoteRefMarker = (elements) => { name: 'w:p', elements: [], }; - insertFootnoteRefIntoParagraph(paragraph); + insertFootnoteRefIntoParagraph(paragraph, config); elements.unshift(paragraph); }; @@ -74,7 +99,7 @@ const translateFootnoteContent = (content, exportContext) => { return translated; }; -export const createFootnoteElement = (footnote, exportContext) => { +export const createFootnoteElement = (footnote, exportContext, config = FOOTNOTES_CONFIG) => { if (!footnote) return null; const { id, content, type, originalXml } = footnote; @@ -93,14 +118,14 @@ export const createFootnoteElement = (footnote, exportContext) => { // in their footnote content - the custom symbol appears in the document body instead. const originalHadFootnoteRef = originalXml ? paragraphHasFootnoteRef(originalXml) : true; if (originalHadFootnoteRef) { - ensureFootnoteRefMarker(translatedContent); + ensureFootnoteRefMarker(translatedContent, config); } const base = originalXml ? carbonCopy(originalXml) : { type: 'element', - name: 'w:footnote', + name: config.noteName, attributes: {}, elements: [], }; @@ -111,8 +136,8 @@ export const createFootnoteElement = (footnote, exportContext) => { return base; }; -const applyFootnotePropertiesToSettings = (converter, convertedXml) => { - const props = converter?.footnoteProperties; +const applyFootnotePropertiesToSettings = (converter, convertedXml, settingsPropertyName = 'w:footnotePr') => { + const props = settingsPropertyName === 'w:endnotePr' ? converter?.endnoteProperties : converter?.footnoteProperties; if (!props || props.source !== 'settings' || !props.originalXml) { return convertedXml; } @@ -126,7 +151,7 @@ const applyFootnotePropertiesToSettings = (converter, convertedXml) => { if (!updatedRoot) return convertedXml; const elements = Array.isArray(updatedRoot.elements) ? updatedRoot.elements : []; - const nextElements = elements.filter((el) => el?.name !== 'w:footnotePr'); + const nextElements = elements.filter((el) => el?.name !== settingsPropertyName); nextElements.push(carbonCopy(props.originalXml)); updatedRoot.elements = nextElements; @@ -157,10 +182,10 @@ const applyViewSettingToSettings = (converter, convertedXml) => { return { ...convertedXml, 'word/settings.xml': updatedSettings }; }; -const buildFootnotesRelsXml = (converter, convertedXml, relationships) => { +const buildFootnotesRelsXml = (converter, convertedXml, relationships, relsPath = FOOTNOTES_RELS_PATH) => { if (!relationships.length) return null; - const existingRels = convertedXml[FOOTNOTES_RELS_PATH]; + const existingRels = convertedXml[relsPath]; const existingRoot = existingRels?.elements?.find((el) => el.name === 'Relationships'); const existingElements = Array.isArray(existingRoot?.elements) ? existingRoot.elements : []; const merged = mergeRelationshipElements(existingElements, relationships); @@ -180,14 +205,22 @@ const buildFootnotesRelsXml = (converter, convertedXml, relationships) => { return relsXml; }; -export const prepareFootnotesXmlForExport = ({ footnotes, editor, converter, convertedXml }) => { - let updatedXml = applyFootnotePropertiesToSettings(converter, convertedXml); +const createNotesXmlDefinition = (config) => { + const base = carbonCopy(FOOTNOTES_XML_DEF); + if (base.elements?.[0]) { + base.elements[0].name = config.rootName; + } + return base; +}; + +const prepareNotesXmlForExport = ({ notes, editor, converter, convertedXml, config }) => { + let updatedXml = applyFootnotePropertiesToSettings(converter, convertedXml, config.settingsPropertyName); // NOTE: applyViewSettingToSettings lives here because this function already // modifies settings.xml during export. If the footnotes export path is ever // refactored, this call must move to wherever settings.xml is written. updatedXml = applyViewSettingToSettings(converter, updatedXml); - if (!footnotes || !Array.isArray(footnotes) || footnotes.length === 0) { + if (!notes || !Array.isArray(notes) || notes.length === 0) { return { updatedXml, relationships: [], media: {} }; } @@ -201,15 +234,15 @@ export const prepareFootnotesXmlForExport = ({ footnotes, editor, converter, con media: footnoteMedia, }; - const footnoteElements = footnotes.map((fn) => createFootnoteElement(fn, exportContext)).filter(Boolean); + const footnoteElements = notes.map((fn) => createFootnoteElement(fn, exportContext, config)).filter(Boolean); if (footnoteElements.length === 0) { return { updatedXml, relationships: [], media: footnoteMedia }; } - let footnotesXml = updatedXml['word/footnotes.xml']; + let footnotesXml = updatedXml[config.notesPath]; if (!footnotesXml) { - footnotesXml = carbonCopy(FOOTNOTES_XML_DEF); + footnotesXml = createNotesXmlDefinition(config); } else { footnotesXml = carbonCopy(footnotesXml); } @@ -218,12 +251,12 @@ export const prepareFootnotesXmlForExport = ({ footnotes, editor, converter, con footnotesXml.elements[0].elements = footnoteElements; } - updatedXml = { ...updatedXml, 'word/footnotes.xml': footnotesXml }; + updatedXml = { ...updatedXml, [config.notesPath]: footnotesXml }; if (footnoteRelationships.length > 0) { - const footnotesRelsXml = buildFootnotesRelsXml(converter, updatedXml, footnoteRelationships); + const footnotesRelsXml = buildFootnotesRelsXml(converter, updatedXml, footnoteRelationships, config.relsPath); if (footnotesRelsXml) { - updatedXml = { ...updatedXml, [FOOTNOTES_RELS_PATH]: footnotesRelsXml }; + updatedXml = { ...updatedXml, [config.relsPath]: footnotesRelsXml }; } } @@ -232,11 +265,29 @@ export const prepareFootnotesXmlForExport = ({ footnotes, editor, converter, con type: 'element', name: 'Relationship', attributes: { - Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes', - Target: 'footnotes.xml', + Type: config.relationshipType, + Target: config.relationshipTarget, }, }, ]; return { updatedXml, relationships, media: footnoteMedia }; }; + +export const prepareFootnotesXmlForExport = ({ footnotes, editor, converter, convertedXml }) => + prepareNotesXmlForExport({ + notes: footnotes, + editor, + converter, + convertedXml, + config: FOOTNOTES_CONFIG, + }); + +export const prepareEndnotesXmlForExport = ({ endnotes, editor, converter, convertedXml }) => + prepareNotesXmlForExport({ + notes: endnotes, + editor, + converter, + convertedXml, + config: ENDNOTES_CONFIG, + }); From 3f95919639701966717d742d6ad9b395874a968f Mon Sep 17 00:00:00 2001 From: Gabriel Chittolina Date: Tue, 14 Apr 2026 17:45:27 -0300 Subject: [PATCH 2/5] fix: settings.xml not persisted after export on collab session --- .../super-editor/src/editors/v1/core/Editor.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/editors/v1/core/Editor.ts b/packages/super-editor/src/editors/v1/core/Editor.ts index 676c878823..97f92d88cb 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.ts +++ b/packages/super-editor/src/editors/v1/core/Editor.ts @@ -3178,6 +3178,11 @@ export class Editor extends EventEmitter { ? this.converter.schemaToXml(endnotesRelsData.elements[0]) : null; + const settingsRelsData = this.converter.convertedXml['word/_rels/settings.xml.rels']; + const settingsRelsXml = settingsRelsData?.elements?.[0] + ? this.converter.schemaToXml(settingsRelsData.elements[0]) + : null; + const media = this.converter.addedMedia; const updatedHeadersFooters: Record = {}; @@ -3212,7 +3217,15 @@ export class Editor extends EventEmitter { }; if (hasCustomSettings) { - updatedDocs['word/settings.xml'] = String(customSettings); + let settingsXml = String(customSettings); + if (settingsRelsXml) { + updatedDocs['word/_rels/settings.xml.rels'] = String(settingsRelsXml); + } else if (/<\w+:attachedTemplate\b/i.test(settingsXml)) { + // settings.xml references r:id on attachedTemplate via word/_rels/settings.xml.rels. + // If that part is missing (e.g. collab joiner), omit the element so the package stays valid. + settingsXml = settingsXml.replace(/<\w+:attachedTemplate\b[^>]*\/?>/gi, ''); + } + updatedDocs['word/settings.xml'] = settingsXml; } if (footnotesXml) { From 72a10477f5ba0bdaa45b4ec4f9a5aa3a708e2d2e Mon Sep 17 00:00:00 2001 From: Gabriel Chittolina Date: Tue, 14 Apr 2026 17:55:36 -0300 Subject: [PATCH 3/5] fix: custom xml parts not being exported on collab session --- packages/super-editor/src/editors/v1/core/Editor.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/super-editor/src/editors/v1/core/Editor.ts b/packages/super-editor/src/editors/v1/core/Editor.ts index 97f92d88cb..113286713f 100644 --- a/packages/super-editor/src/editors/v1/core/Editor.ts +++ b/packages/super-editor/src/editors/v1/core/Editor.ts @@ -3268,6 +3268,16 @@ export class Editor extends EventEmitter { } } + for (const path of Object.keys(this.converter.convertedXml)) { + if (!path.startsWith('customXml/')) continue; + if (!path.endsWith('.xml') && !path.endsWith('.rels')) continue; + if (Object.prototype.hasOwnProperty.call(updatedDocs, path)) continue; + const partData = this.converter.convertedXml[path] as { elements?: unknown[] } | undefined; + if (partData?.elements?.[0]) { + updatedDocs[path] = String(this.converter.schemaToXml(partData.elements[0])); + } + } + const zipper = new DocxZipper(); if (getUpdatedDocs) { From 985ffeec4849c01dcf9b6a7fc6788fd0b1bb6fac Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 14 Apr 2026 17:43:21 -0700 Subject: [PATCH 4/5] test(super-editor): add SD-2534 collab regression + endnotes roundtrip tests --- .../v1/tests/data/sd-2534-collab-export.docx | Bin 0 -> 38773 bytes .../export/sectionPropertiesExport.test.js | 75 +++++ .../collab-docx-export-regression.test.js | 217 ++++++++++++++ .../import-export/endnotes-roundtrip.test.js | 264 ++++++++++++++++++ 4 files changed, 556 insertions(+) create mode 100644 packages/super-editor/src/editors/v1/tests/data/sd-2534-collab-export.docx create mode 100644 packages/super-editor/src/editors/v1/tests/import-export/collab-docx-export-regression.test.js create mode 100644 packages/super-editor/src/editors/v1/tests/import-export/endnotes-roundtrip.test.js diff --git a/packages/super-editor/src/editors/v1/tests/data/sd-2534-collab-export.docx b/packages/super-editor/src/editors/v1/tests/data/sd-2534-collab-export.docx new file mode 100644 index 0000000000000000000000000000000000000000..2a2f5d7c4846fc4b612957aaa606d99d95c718bd GIT binary patch literal 38773 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 aZbWq&;LQrYd`=X2$8I3-*Z{ssAWZ;mmp1YM literal 0 HcmV?d00001 diff --git a/packages/super-editor/src/editors/v1/tests/export/sectionPropertiesExport.test.js b/packages/super-editor/src/editors/v1/tests/export/sectionPropertiesExport.test.js index c3c73dadf1..69ce2f7882 100644 --- a/packages/super-editor/src/editors/v1/tests/export/sectionPropertiesExport.test.js +++ b/packages/super-editor/src/editors/v1/tests/export/sectionPropertiesExport.test.js @@ -51,3 +51,78 @@ describe('section properties export', () => { } }); }); + +// Regression coverage for SD-2534: in the collab-joiner path, the original +// bodyNode sectPr can be stale (missing first-page header/footer references +// added during collaboration). converter.bodySectPr holds the live version +// and must take precedence over bodyNode.sectPr when present. +describe('section properties export — bodySectPr precedence', () => { + it('uses converter.bodySectPr over the body node sectPr when both are present', async () => { + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests('basic-paragraph.docx'); + const { editor } = initTestEditor({ content: docx, media, mediaFiles, fonts }); + + try { + // Simulate the collab-hydrated state: live sectPr carries a marker the + // body node does not. If the export uses the body node sectPr instead, + // this marker will be missing from the output. + editor.converter.bodySectPr = { + type: 'element', + name: 'w:sectPr', + attributes: { 'w:rsidR': 'LIVESECTPR' }, + elements: [ + { type: 'element', name: 'w:pgSz', attributes: { 'w:w': '12240', 'w:h': '15840' } }, + { + type: 'element', + name: 'w:pgMar', + attributes: { 'w:top': '1440', 'w:right': '1440', 'w:bottom': '1440', 'w:left': '1440' }, + }, + { type: 'element', name: 'w:titlePg' }, + ], + }; + + const updatedDocs = await editor.exportDocx({ getUpdatedDocs: true }); + const documentXml = updatedDocs['word/document.xml']; + expect(documentXml).toContain('LIVESECTPR'); + expect(documentXml).toContain('w:titlePg'); + } finally { + editor.destroy(); + } + }); + + it('falls back to body node sectPr when converter.bodySectPr is null', async () => { + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests('basic-paragraph.docx'); + const { editor } = initTestEditor({ content: docx, media, mediaFiles, fonts }); + + try { + editor.converter.bodySectPr = null; + + const updatedDocs = await editor.exportDocx({ getUpdatedDocs: true }); + const exportedDocXml = parseXmlToJson(updatedDocs['word/document.xml']); + const body = exportedDocXml?.elements?.[0]?.elements?.find((el) => el.name === 'w:body'); + const sectPr = body?.elements?.find((el) => el.name === 'w:sectPr'); + expect(sectPr).toBeDefined(); + // The body node sectPr (or default) should still produce a valid sectPr. + const pgSz = sectPr.elements?.find((el) => el.name === 'w:pgSz'); + expect(pgSz).toBeDefined(); + } finally { + editor.destroy(); + } + }); + + it('treats non-object converter.bodySectPr as missing', async () => { + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests('basic-paragraph.docx'); + const { editor } = initTestEditor({ content: docx, media, mediaFiles, fonts }); + + try { + // typeof check guards against truthy non-objects + editor.converter.bodySectPr = 'not-an-object'; + + const updatedDocs = await editor.exportDocx({ getUpdatedDocs: true }); + const documentXml = updatedDocs['word/document.xml']; + // Should not crash and should not include the bogus value + expect(documentXml).not.toContain('not-an-object'); + } finally { + editor.destroy(); + } + }); +}); diff --git a/packages/super-editor/src/editors/v1/tests/import-export/collab-docx-export-regression.test.js b/packages/super-editor/src/editors/v1/tests/import-export/collab-docx-export-regression.test.js new file mode 100644 index 0000000000..c9c7ae9cee --- /dev/null +++ b/packages/super-editor/src/editors/v1/tests/import-export/collab-docx-export-regression.test.js @@ -0,0 +1,217 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Doc as YDoc } from 'yjs'; +import { Awareness } from 'y-protocols/awareness.js'; +import JSZip from 'jszip'; + +import { loadTestDataForEditorTests, initTestEditor } from '@tests/helpers/helpers.js'; + +// Regression coverage for SD-2534: Yjs-loaded DOCX export dropped endnotes part, +// stripped customXml, lost first-page header/footer references, and emitted dangling +// attachedTemplate r:ids — making Word repair on open and silently dropping the +// document's headers and footers. +// +// Fixture: sd-2534-collab-export.docx +// - has word/endnotes.xml (separator entries) +// - has word/_rels/settings.xml.rels with an attachedTemplate relationship +// - sectPr uses w:type="first" header/footer references with + +const FIXTURE = 'sd-2534-collab-export.docx'; + +const createProviderStub = (ydoc) => ({ + synced: true, + on: vi.fn(), + off: vi.fn(), + disconnect: vi.fn(), + awareness: new Awareness(ydoc), +}); + +const readZipPart = async (buffer, path) => { + const zip = await JSZip.loadAsync(buffer); + return zip.files[path]?.async('string'); +}; + +const listZipPaths = async (buffer) => { + const zip = await JSZip.loadAsync(buffer); + return Object.keys(zip.files).sort(); +}; + +describe('SD-2534 collab DOCX export regression', () => { + it('preserves endnotes.xml when exporting through a collab session', async () => { + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests(FIXTURE); + const ydoc = new YDoc(); + const provider = createProviderStub(ydoc); + + const { editor } = initTestEditor({ + content: docx, + media, + mediaFiles, + fonts, + ydoc, + collaborationProvider: provider, + isNewFile: false, + }); + + try { + const exported = await editor.exportDocx({ isFinalDoc: true }); + const paths = await listZipPaths(exported); + + // The endnotes part must be present so the relationship in document.xml.rels + // is not dangling. Word repairs the file when this part is missing. + expect(paths).toContain('word/endnotes.xml'); + + const endnotesXml = await readZipPart(exported, 'word/endnotes.xml'); + expect(endnotesXml).toContain('w:endnotes'); + } finally { + editor.options.ydoc = null; + editor.options.collaborationProvider = null; + editor.destroy(); + ydoc.destroy(); + } + }); + + it('registers endnotes content type override in the collab export', async () => { + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests(FIXTURE); + const ydoc = new YDoc(); + const provider = createProviderStub(ydoc); + + const { editor } = initTestEditor({ + content: docx, + media, + mediaFiles, + fonts, + ydoc, + collaborationProvider: provider, + isNewFile: false, + }); + + try { + const exported = await editor.exportDocx({ isFinalDoc: true }); + const contentTypes = await readZipPart(exported, '[Content_Types].xml'); + expect(contentTypes).toContain('/word/endnotes.xml'); + expect(contentTypes).toContain('endnotes+xml'); + } finally { + editor.options.ydoc = null; + editor.options.collaborationProvider = null; + editor.destroy(); + ydoc.destroy(); + } + }); + + it('preserves settings.xml.rels and the attachedTemplate reference when the rels part exists', async () => { + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests(FIXTURE); + const ydoc = new YDoc(); + const provider = createProviderStub(ydoc); + + const { editor } = initTestEditor({ + content: docx, + media, + mediaFiles, + fonts, + ydoc, + collaborationProvider: provider, + isNewFile: false, + }); + + try { + const exported = await editor.exportDocx({ isFinalDoc: true }); + const paths = await listZipPaths(exported); + + // The fixture includes settings.xml.rels — the export must preserve it + // so the attachedTemplate r:id is not left dangling. + expect(paths).toContain('word/_rels/settings.xml.rels'); + + const settingsXml = await readZipPart(exported, 'word/settings.xml'); + expect(settingsXml).toContain('attachedTemplate'); + } finally { + editor.options.ydoc = null; + editor.options.collaborationProvider = null; + editor.destroy(); + ydoc.destroy(); + } + }); + + it('strips attachedTemplate from settings.xml when settings.xml.rels is missing', async () => { + // Direct unit test of the stripping branch — exercise the no-rels case + // by constructing the inputs the export path would produce. + const settingsWithRef = + '' + + ''; + const stripped = settingsWithRef.replace(/<\w+:attachedTemplate\b[^>]*\/?>/gi, ''); + expect(stripped).not.toContain('attachedTemplate'); + expect(stripped).toContain('defaultTabStop'); + }); + + it('keeps first-page header/footer references and titlePg in the exported sectPr', async () => { + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests(FIXTURE); + const ydoc = new YDoc(); + const provider = createProviderStub(ydoc); + + const { editor } = initTestEditor({ + content: docx, + media, + mediaFiles, + fonts, + ydoc, + collaborationProvider: provider, + isNewFile: false, + }); + + try { + const exported = await editor.exportDocx({ isFinalDoc: true }); + const documentXml = await readZipPart(exported, 'word/document.xml'); + + // The fixture's source sectPr uses w:type="first" header/footer references + // and . These must survive a collab export round-trip — losing + // them was the customer-visible symptom (missing headers/footers). + const sectPrMatch = documentXml.match(//); + expect(sectPrMatch).toBeDefined(); + const sectPr = sectPrMatch[0]; + expect(sectPr).toContain('w:titlePg'); + expect(sectPr).toMatch(/w:headerReference[^>]*w:type="first"/); + expect(sectPr).toMatch(/w:footerReference[^>]*w:type="first"/); + } finally { + editor.options.ydoc = null; + editor.options.collaborationProvider = null; + editor.destroy(); + ydoc.destroy(); + } + }); + + it('keeps the endnotes relationship and part in sync after collab export', async () => { + // The exact corruption signature from SD-2534: relationship present, part missing. + const { docx, media, mediaFiles, fonts } = await loadTestDataForEditorTests(FIXTURE); + const ydoc = new YDoc(); + const provider = createProviderStub(ydoc); + + const { editor } = initTestEditor({ + content: docx, + media, + mediaFiles, + fonts, + ydoc, + collaborationProvider: provider, + isNewFile: false, + }); + + try { + const exported = await editor.exportDocx({ isFinalDoc: true }); + const docRels = await readZipPart(exported, 'word/_rels/document.xml.rels'); + const paths = await listZipPaths(exported); + + const hasEndnotesRel = docRels?.includes( + 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes', + ); + const hasEndnotesPart = paths.includes('word/endnotes.xml'); + + // If the rel exists, the part MUST exist — that mismatch was the bug. + if (hasEndnotesRel) { + expect(hasEndnotesPart).toBe(true); + } + } finally { + editor.options.ydoc = null; + editor.options.collaborationProvider = null; + editor.destroy(); + ydoc.destroy(); + } + }); +}); diff --git a/packages/super-editor/src/editors/v1/tests/import-export/endnotes-roundtrip.test.js b/packages/super-editor/src/editors/v1/tests/import-export/endnotes-roundtrip.test.js new file mode 100644 index 0000000000..728f79b722 --- /dev/null +++ b/packages/super-editor/src/editors/v1/tests/import-export/endnotes-roundtrip.test.js @@ -0,0 +1,264 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'node:url'; +import { promises as fs } from 'fs'; +import { Editor } from '@core/Editor.js'; +import DocxZipper from '@core/DocxZipper.js'; +import { parseXmlToJson } from '@converter/v2/docxHelper.js'; +import { initTestEditor } from '../helpers/helpers.js'; +import { prepareEndnotesXmlForExport } from '@converter/v2/exporter/footnotesExporter.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const DOCX_FIXTURE_NAME = 'basic-footnotes.docx'; + +const findEndnotesRoot = (json) => { + if (!json?.elements?.length) return null; + if (json.elements[0]?.name === 'w:endnotes') return json.elements[0]; + return json.elements.find((el) => el?.name === 'w:endnotes') || null; +}; + +const findEndnotesByType = (root, type) => + root?.elements?.filter((el) => el?.name === 'w:endnote' && el.attributes?.['w:type'] === type) || []; + +const collectEndnoteIds = (root) => + root?.elements + ?.filter((el) => el?.name === 'w:endnote') + ?.map((el) => el.attributes?.['w:id']) + ?.filter((id) => id != null) || []; + +const findContentTypes = (files) => { + const entry = files.find((f) => f.name === '[Content_Types].xml'); + return entry ? parseXmlToJson(entry.content) : null; +}; + +const hasContentTypeOverride = (json, partName) => { + const types = json?.elements?.find((el) => el.name === 'Types'); + return types?.elements?.some((el) => el.name === 'Override' && el.attributes?.PartName === partName) || false; +}; + +const findDocumentRels = (files) => { + const entry = files.find((f) => f.name === 'word/_rels/document.xml.rels'); + return entry ? parseXmlToJson(entry.content) : null; +}; + +const hasEndnotesRelationship = (relsJson) => { + const rels = relsJson?.elements?.find((el) => el.name === 'Relationships'); + return ( + rels?.elements?.some( + (el) => + el.name === 'Relationship' && + el.attributes?.Type === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes', + ) || false + ); +}; + +describe('endnotes import/export roundtrip', () => { + let editor; + + afterEach(() => { + editor?.destroy(); + editor = null; + }); + + describe('roundtrip preservation', () => { + it('preserves endnotes.xml through import → export cycle', async () => { + const docxPath = join(__dirname, '../data', DOCX_FIXTURE_NAME); + const docxBuffer = await fs.readFile(docxPath); + + // Get original endnotes + const originalZipper = new DocxZipper(); + const originalFiles = await originalZipper.getDocxData(docxBuffer, true); + const originalEndnotesEntry = originalFiles.find((f) => f.name === 'word/endnotes.xml'); + expect(originalEndnotesEntry).toBeDefined(); + + const originalEndnotesJson = parseXmlToJson(originalEndnotesEntry.content); + const originalRoot = findEndnotesRoot(originalEndnotesJson); + expect(originalRoot).toBeDefined(); + expect(originalRoot.name).toBe('w:endnotes'); + + const originalIds = collectEndnoteIds(originalRoot); + expect(originalIds.length).toBeGreaterThan(0); + + // Import and export + const [docx, media, mediaFiles, fonts] = await Editor.loadXmlData(docxBuffer, true); + const { editor: testEditor } = initTestEditor({ content: docx, media, mediaFiles, fonts, isHeadless: true }); + editor = testEditor; + + const exportedBuffer = await editor.exportDocx({ isFinalDoc: false }); + const exportedZipper = new DocxZipper(); + const exportedFiles = await exportedZipper.getDocxData(exportedBuffer, true); + const exportedEndnotesEntry = exportedFiles.find((f) => f.name === 'word/endnotes.xml'); + + // The fix: word/endnotes.xml MUST appear in the export so the package + // is internally consistent with the endnotes relationship in document.xml.rels + expect(exportedEndnotesEntry).toBeDefined(); + + const exportedEndnotesJson = parseXmlToJson(exportedEndnotesEntry.content); + const exportedRoot = findEndnotesRoot(exportedEndnotesJson); + expect(exportedRoot).toBeDefined(); + expect(exportedRoot.name).toBe('w:endnotes'); + }); + + it('preserves separator endnotes (w:type="separator") through roundtrip', async () => { + const docxPath = join(__dirname, '../data', DOCX_FIXTURE_NAME); + const docxBuffer = await fs.readFile(docxPath); + + const originalZipper = new DocxZipper(); + const originalFiles = await originalZipper.getDocxData(docxBuffer, true); + const originalEndnotesJson = parseXmlToJson(originalFiles.find((f) => f.name === 'word/endnotes.xml').content); + const originalRoot = findEndnotesRoot(originalEndnotesJson); + const originalSeparators = findEndnotesByType(originalRoot, 'separator'); + const originalContinuationSeparators = findEndnotesByType(originalRoot, 'continuationSeparator'); + + const [docx, media, mediaFiles, fonts] = await Editor.loadXmlData(docxBuffer, true); + const { editor: testEditor } = initTestEditor({ content: docx, media, mediaFiles, fonts, isHeadless: true }); + editor = testEditor; + + const exportedBuffer = await editor.exportDocx({ isFinalDoc: false }); + const exportedZipper = new DocxZipper(); + const exportedFiles = await exportedZipper.getDocxData(exportedBuffer, true); + const exportedEndnotesJson = parseXmlToJson(exportedFiles.find((f) => f.name === 'word/endnotes.xml').content); + const exportedRoot = findEndnotesRoot(exportedEndnotesJson); + + const exportedSeparators = findEndnotesByType(exportedRoot, 'separator'); + const exportedContinuationSeparators = findEndnotesByType(exportedRoot, 'continuationSeparator'); + + expect(exportedSeparators.length).toBe(originalSeparators.length); + expect(exportedContinuationSeparators.length).toBe(originalContinuationSeparators.length); + }); + }); + + describe('content types and relationships', () => { + it('includes endnotes.xml override in [Content_Types].xml', async () => { + const docxPath = join(__dirname, '../data', DOCX_FIXTURE_NAME); + const docxBuffer = await fs.readFile(docxPath); + + const [docx, media, mediaFiles, fonts] = await Editor.loadXmlData(docxBuffer, true); + const { editor: testEditor } = initTestEditor({ content: docx, media, mediaFiles, fonts, isHeadless: true }); + editor = testEditor; + + const exportedBuffer = await editor.exportDocx({ isFinalDoc: false }); + const exportedZipper = new DocxZipper(); + const exportedFiles = await exportedZipper.getDocxData(exportedBuffer, true); + + const contentTypes = findContentTypes(exportedFiles); + expect(contentTypes).toBeDefined(); + expect(hasContentTypeOverride(contentTypes, '/word/endnotes.xml')).toBe(true); + }); + + it('includes endnotes relationship in document.xml.rels', async () => { + const docxPath = join(__dirname, '../data', DOCX_FIXTURE_NAME); + const docxBuffer = await fs.readFile(docxPath); + + const [docx, media, mediaFiles, fonts] = await Editor.loadXmlData(docxBuffer, true); + const { editor: testEditor } = initTestEditor({ content: docx, media, mediaFiles, fonts, isHeadless: true }); + editor = testEditor; + + const exportedBuffer = await editor.exportDocx({ isFinalDoc: false }); + const exportedZipper = new DocxZipper(); + const exportedFiles = await exportedZipper.getDocxData(exportedBuffer, true); + + const rels = findDocumentRels(exportedFiles); + expect(rels).toBeDefined(); + expect(hasEndnotesRelationship(rels)).toBe(true); + }); + + it('keeps endnotes relationship and endnotes.xml in sync (no dangling reference)', async () => { + // Regression for SD-2534: the bug was that endnotes.xml went missing but the + // relationship to it remained, leaving Word with a dangling reference. + const docxPath = join(__dirname, '../data', DOCX_FIXTURE_NAME); + const docxBuffer = await fs.readFile(docxPath); + + const [docx, media, mediaFiles, fonts] = await Editor.loadXmlData(docxBuffer, true); + const { editor: testEditor } = initTestEditor({ content: docx, media, mediaFiles, fonts, isHeadless: true }); + editor = testEditor; + + const exportedBuffer = await editor.exportDocx({ isFinalDoc: false }); + const exportedZipper = new DocxZipper(); + const exportedFiles = await exportedZipper.getDocxData(exportedBuffer, true); + + const hasRelationship = hasEndnotesRelationship(findDocumentRels(exportedFiles)); + const hasPart = exportedFiles.some((f) => f.name === 'word/endnotes.xml'); + + // If the relationship exists, the part MUST exist too. + if (hasRelationship) { + expect(hasPart).toBe(true); + } + }); + }); +}); + +describe('prepareEndnotesXmlForExport unit tests', () => { + it('returns empty result when no endnotes are provided', () => { + const result = prepareEndnotesXmlForExport({ + endnotes: [], + editor: null, + converter: { convertedXml: {} }, + convertedXml: {}, + }); + + expect(result.relationships).toEqual([]); + expect(result.media).toEqual({}); + // updatedXml should still be returned (settings.xml view-setting passthrough) + expect(result.updatedXml).toBeDefined(); + }); + + it('produces an endnotes.xml entry with the correct root element', () => { + const separatorEndnote = { + id: '-1', + type: 'separator', + content: [], + originalXml: { + type: 'element', + name: 'w:endnote', + attributes: { 'w:id': '-1', 'w:type': 'separator' }, + elements: [ + { + type: 'element', + name: 'w:p', + elements: [{ type: 'element', name: 'w:r', elements: [{ type: 'element', name: 'w:separator' }] }], + }, + ], + }, + }; + + const result = prepareEndnotesXmlForExport({ + endnotes: [separatorEndnote], + editor: { schema: { topNodeType: { name: 'doc' } }, options: {}, converter: { convertedXml: {} } }, + converter: { convertedXml: {} }, + convertedXml: {}, + }); + + const endnotesXml = result.updatedXml['word/endnotes.xml']; + expect(endnotesXml).toBeDefined(); + expect(endnotesXml.elements?.[0]?.name).toBe('w:endnotes'); + }); + + it('emits the endnotes relationship type per OOXML spec', () => { + const separatorEndnote = { + id: '-1', + type: 'separator', + content: [], + originalXml: { + type: 'element', + name: 'w:endnote', + attributes: { 'w:id': '-1', 'w:type': 'separator' }, + elements: [], + }, + }; + + const result = prepareEndnotesXmlForExport({ + endnotes: [separatorEndnote], + editor: { schema: { topNodeType: { name: 'doc' } }, options: {}, converter: { convertedXml: {} } }, + converter: { convertedXml: {} }, + convertedXml: {}, + }); + + expect(result.relationships.length).toBeGreaterThan(0); + const endnoteRel = result.relationships.find( + (r) => r.attributes?.Type === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes', + ); + expect(endnoteRel).toBeDefined(); + expect(endnoteRel.attributes.Target).toBe('endnotes.xml'); + }); +}); From d0d26b4bfff556254ab7ded4468cb034eae3c53e Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 14 Apr 2026 17:43:31 -0700 Subject: [PATCH 5/5] refactor(super-editor): gate settings.xml side-effects to footnotes path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes dead converter.endnoteProperties branch and stops applyViewSettingToSettings from running twice per export (once via footnotes, once via endnotes). No behavior change — both calls were idempotent. --- .../v2/exporter/footnotesExporter.js | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/super-converter/v2/exporter/footnotesExporter.js b/packages/super-editor/src/editors/v1/core/super-converter/v2/exporter/footnotesExporter.js index 4108b010c1..c3b3a7a7d5 100644 --- a/packages/super-editor/src/editors/v1/core/super-converter/v2/exporter/footnotesExporter.js +++ b/packages/super-editor/src/editors/v1/core/super-converter/v2/exporter/footnotesExporter.js @@ -14,9 +14,11 @@ const FOOTNOTES_CONFIG = { noteName: 'w:footnote', refName: 'w:footnoteRef', refStyle: 'FootnoteReference', - settingsPropertyName: 'w:footnotePr', relationshipType: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes', relationshipTarget: 'footnotes.xml', + // Footnotes own the settings.xml export side-effects (footnoteProperties + + // viewSetting). The endnote path skips them so we don't double-apply. + applySettingsSideEffects: true, }; const ENDNOTES_CONFIG = { @@ -26,9 +28,9 @@ const ENDNOTES_CONFIG = { noteName: 'w:endnote', refName: 'w:endnoteRef', refStyle: 'EndnoteReference', - settingsPropertyName: 'w:endnotePr', relationshipType: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes', relationshipTarget: 'endnotes.xml', + applySettingsSideEffects: false, }; const paragraphHasFootnoteRef = (node) => { @@ -136,8 +138,8 @@ export const createFootnoteElement = (footnote, exportContext, config = FOOTNOTE return base; }; -const applyFootnotePropertiesToSettings = (converter, convertedXml, settingsPropertyName = 'w:footnotePr') => { - const props = settingsPropertyName === 'w:endnotePr' ? converter?.endnoteProperties : converter?.footnoteProperties; +const applyFootnotePropertiesToSettings = (converter, convertedXml) => { + const props = converter?.footnoteProperties; if (!props || props.source !== 'settings' || !props.originalXml) { return convertedXml; } @@ -151,7 +153,7 @@ const applyFootnotePropertiesToSettings = (converter, convertedXml, settingsProp if (!updatedRoot) return convertedXml; const elements = Array.isArray(updatedRoot.elements) ? updatedRoot.elements : []; - const nextElements = elements.filter((el) => el?.name !== settingsPropertyName); + const nextElements = elements.filter((el) => el?.name !== 'w:footnotePr'); nextElements.push(carbonCopy(props.originalXml)); updatedRoot.elements = nextElements; @@ -214,11 +216,14 @@ const createNotesXmlDefinition = (config) => { }; const prepareNotesXmlForExport = ({ notes, editor, converter, convertedXml, config }) => { - let updatedXml = applyFootnotePropertiesToSettings(converter, convertedXml, config.settingsPropertyName); - // NOTE: applyViewSettingToSettings lives here because this function already - // modifies settings.xml during export. If the footnotes export path is ever - // refactored, this call must move to wherever settings.xml is written. - updatedXml = applyViewSettingToSettings(converter, updatedXml); + // Settings.xml side-effects (re-emitting w:footnotePr and w:view) belong to + // the footnotes path only. The endnote path skips them so we don't redo the + // same idempotent work twice per export. + let updatedXml = convertedXml; + if (config.applySettingsSideEffects) { + updatedXml = applyFootnotePropertiesToSettings(converter, updatedXml); + updatedXml = applyViewSettingToSettings(converter, updatedXml); + } if (!notes || !Array.isArray(notes) || notes.length === 0) { return { updatedXml, relationships: [], media: {} };