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 fb653c0be1..45c3972614 100644
--- a/packages/super-editor/src/editors/v1/core/Editor.ts
+++ b/packages/super-editor/src/editors/v1/core/Editor.ts
@@ -3177,6 +3177,17 @@ 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 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;
@@ -3212,7 +3223,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) {
@@ -3223,6 +3242,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}`);
@@ -3247,6 +3274,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) {
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..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
@@ -5,20 +5,47 @@ 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',
+ 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 = {
+ notesPath: 'word/endnotes.xml',
+ relsPath: ENDNOTES_RELS_PATH,
+ rootName: 'w:endnotes',
+ noteName: 'w:endnote',
+ refName: 'w:endnoteRef',
+ refStyle: 'EndnoteReference',
+ relationshipType: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes',
+ relationshipTarget: 'endnotes.xml',
+ applySettingsSideEffects: false,
+};
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 +54,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 +67,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 +80,7 @@ const ensureFootnoteRefMarker = (elements) => {
name: 'w:p',
elements: [],
};
- insertFootnoteRefIntoParagraph(paragraph);
+ insertFootnoteRefIntoParagraph(paragraph, config);
elements.unshift(paragraph);
};
@@ -74,7 +101,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 +120,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: [],
};
@@ -157,10 +184,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 +207,25 @@ const buildFootnotesRelsXml = (converter, convertedXml, relationships) => {
return relsXml;
};
-export const prepareFootnotesXmlForExport = ({ footnotes, editor, converter, convertedXml }) => {
- let updatedXml = applyFootnotePropertiesToSettings(converter, convertedXml);
- // 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);
+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 }) => {
+ // 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 (!footnotes || !Array.isArray(footnotes) || footnotes.length === 0) {
+ if (!notes || !Array.isArray(notes) || notes.length === 0) {
return { updatedXml, relationships: [], media: {} };
}
@@ -201,15 +239,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 +256,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 +270,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,
+ });
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 0000000000..2a2f5d7c48
Binary files /dev/null and b/packages/super-editor/src/editors/v1/tests/data/sd-2534-collab-export.docx differ
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');
+ });
+});