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