Skip to content

Commit dd3e04a

Browse files
chittolinagArtem Nistuleychittolinacaio-pizzolharbournick
authored
SD-2534 - documents opened in collab corrupted after export (#2816)
* fix: endnotes * fix: settings.xml not persisted after export on collab session * fix: custom xml parts not being exported on collab session * test(super-editor): add SD-2534 collab regression + endnotes roundtrip tests * refactor(super-editor): gate settings.xml side-effects to footnotes path 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. --------- Co-authored-by: Artem Nistuley <artem@superdoc.dev> Co-authored-by: Gabriel Chittolina <gabrielchittolina1@gmail.com> Co-authored-by: Caio Pizzol <97641911+caio-pizzol@users.noreply.github.com> Co-authored-by: Caio Pizzol <caio@harbourshare.com> Co-authored-by: Nick Bernal <nick@superdoc.dev>
1 parent f4a845a commit dd3e04a

9 files changed

Lines changed: 710 additions & 31 deletions

File tree

packages/super-editor/src/editors/v1/core/DocxZipper.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,12 +291,20 @@ class DocxZipper {
291291
const hasFootnotes = types.elements?.some(
292292
(el) => el.name === 'Override' && el.attributes.PartName === '/word/footnotes.xml',
293293
);
294+
const hasEndnotes = types.elements?.some(
295+
(el) => el.name === 'Override' && el.attributes.PartName === '/word/endnotes.xml',
296+
);
294297

295298
if (hasFile('word/footnotes.xml')) {
296299
const footnotesDef = `<Override PartName="/word/footnotes.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml" />`;
297300
if (!hasFootnotes) typesString += footnotesDef;
298301
}
299302

303+
if (hasFile('word/endnotes.xml')) {
304+
const endnotesDef = `<Override PartName="/word/endnotes.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml" />`;
305+
if (!hasEndnotes) typesString += endnotesDef;
306+
}
307+
300308
// Update for managed document-level singleton parts (e.g., numbering)
301309
for (const entry of MANAGED_DOCUMENT_PARTS) {
302310
if (hasFile(entry.zipPath) && !hasPartOverride(`/${entry.zipPath}`)) {

packages/super-editor/src/editors/v1/core/Editor.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3177,6 +3177,17 @@ export class Editor extends EventEmitter<EditorEventMap> {
31773177
const footnotesRelsXml = footnotesRelsData?.elements?.[0]
31783178
? this.converter.schemaToXml(footnotesRelsData.elements[0])
31793179
: null;
3180+
const endnotesData = this.converter.convertedXml['word/endnotes.xml'];
3181+
const endnotesXml = endnotesData?.elements?.[0] ? this.converter.schemaToXml(endnotesData.elements[0]) : null;
3182+
const endnotesRelsData = this.converter.convertedXml['word/_rels/endnotes.xml.rels'];
3183+
const endnotesRelsXml = endnotesRelsData?.elements?.[0]
3184+
? this.converter.schemaToXml(endnotesRelsData.elements[0])
3185+
: null;
3186+
3187+
const settingsRelsData = this.converter.convertedXml['word/_rels/settings.xml.rels'];
3188+
const settingsRelsXml = settingsRelsData?.elements?.[0]
3189+
? this.converter.schemaToXml(settingsRelsData.elements[0])
3190+
: null;
31803191

31813192
const media = this.converter.addedMedia;
31823193

@@ -3212,7 +3223,15 @@ export class Editor extends EventEmitter<EditorEventMap> {
32123223
};
32133224

32143225
if (hasCustomSettings) {
3215-
updatedDocs['word/settings.xml'] = String(customSettings);
3226+
let settingsXml = String(customSettings);
3227+
if (settingsRelsXml) {
3228+
updatedDocs['word/_rels/settings.xml.rels'] = String(settingsRelsXml);
3229+
} else if (/<\w+:attachedTemplate\b/i.test(settingsXml)) {
3230+
// settings.xml references r:id on attachedTemplate via word/_rels/settings.xml.rels.
3231+
// If that part is missing (e.g. collab joiner), omit the element so the package stays valid.
3232+
settingsXml = settingsXml.replace(/<\w+:attachedTemplate\b[^>]*\/?>/gi, '');
3233+
}
3234+
updatedDocs['word/settings.xml'] = settingsXml;
32163235
}
32173236

32183237
if (footnotesXml) {
@@ -3223,6 +3242,14 @@ export class Editor extends EventEmitter<EditorEventMap> {
32233242
updatedDocs['word/_rels/footnotes.xml.rels'] = String(footnotesRelsXml);
32243243
}
32253244

3245+
if (endnotesXml) {
3246+
updatedDocs['word/endnotes.xml'] = String(endnotesXml);
3247+
}
3248+
3249+
if (endnotesRelsXml) {
3250+
updatedDocs['word/_rels/endnotes.xml.rels'] = String(endnotesRelsXml);
3251+
}
3252+
32263253
// Serialize each comment file if it exists in convertedXml, otherwise mark as null
32273254
// for deletion from the zip (removes stale originals).
32283255
const commentFiles = COMMENT_FILE_BASENAMES.map((name) => `word/${name}`);
@@ -3247,6 +3274,16 @@ export class Editor extends EventEmitter<EditorEventMap> {
32473274
}
32483275
}
32493276

3277+
for (const path of Object.keys(this.converter.convertedXml)) {
3278+
if (!path.startsWith('customXml/')) continue;
3279+
if (!path.endsWith('.xml') && !path.endsWith('.rels')) continue;
3280+
if (Object.prototype.hasOwnProperty.call(updatedDocs, path)) continue;
3281+
const partData = this.converter.convertedXml[path] as { elements?: unknown[] } | undefined;
3282+
if (partData?.elements?.[0]) {
3283+
updatedDocs[path] = String(this.converter.schemaToXml(partData.elements[0]));
3284+
}
3285+
}
3286+
32503287
const zipper = new DocxZipper();
32513288

32523289
if (getUpdatedDocs) {

packages/super-editor/src/editors/v1/core/super-converter/SuperConverter.js

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
prepareCommentParaIds,
2020
prepareCommentsXmlFilesForExport,
2121
} from './v2/exporter/commentsExporter.js';
22-
import { prepareFootnotesXmlForExport } from './v2/exporter/footnotesExporter.js';
22+
import { prepareFootnotesXmlForExport, prepareEndnotesXmlForExport } from './v2/exporter/footnotesExporter.js';
2323
import { writeAppStatistics } from '../../document-api-adapters/helpers/app-properties.js';
2424
import { getWordStatistics, resolveMainBodyEditor } from '../../document-api-adapters/helpers/word-statistics.js';
2525
import { refreshAllStatFields } from '../../document-api-adapters/helpers/refresh-stat-fields.js';
@@ -1194,12 +1194,25 @@ class SuperConverter {
11941194
});
11951195
this.convertedXml = { ...this.convertedXml, ...footnotesUpdatedXml };
11961196

1197+
const {
1198+
updatedXml: endnotesUpdatedXml,
1199+
relationships: endnotesRels,
1200+
media: endnotesMedia,
1201+
} = prepareEndnotesXmlForExport({
1202+
endnotes: this.endnotes,
1203+
editor,
1204+
converter: this,
1205+
convertedXml: this.convertedXml,
1206+
});
1207+
this.convertedXml = { ...this.convertedXml, ...endnotesUpdatedXml };
1208+
11971209
// Update media
11981210
await this.#exportProcessMediaFiles(
11991211
{
12001212
...documentMedia,
12011213
...params.media,
12021214
...footnotesMedia,
1215+
...endnotesMedia,
12031216
...this.media,
12041217
},
12051218
editor,
@@ -1235,7 +1248,13 @@ class SuperConverter {
12351248
this._currentStatFieldCacheMap = undefined; // cleanup after export cycle
12361249

12371250
// Update the rels table
1238-
this.#exportProcessNewRelationships([...params.relationships, ...commentsRels, ...footnotesRels, ...headFootRels]);
1251+
this.#exportProcessNewRelationships([
1252+
...params.relationships,
1253+
...commentsRels,
1254+
...footnotesRels,
1255+
...endnotesRels,
1256+
...headFootRels,
1257+
]);
12391258

12401259
// Prune relationships for comment parts that were removed
12411260
if (removedTargets?.length) {

packages/super-editor/src/editors/v1/core/super-converter/exporter.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,10 @@ export function translatePassthroughNode(params) {
255255
* @returns {XmlReadyNode} JSON of the XML-ready body node
256256
*/
257257
function translateBodyNode(params) {
258-
let sectPr = params.bodyNode?.elements?.find((n) => n.name === 'w:sectPr');
258+
const liveSectPr = params.converter?.bodySectPr;
259+
let sectPr =
260+
(liveSectPr && typeof liveSectPr === 'object' ? carbonCopy(liveSectPr) : null) ||
261+
params.bodyNode?.elements?.find((n) => n.name === 'w:sectPr');
259262
if (!sectPr) {
260263
sectPr = {
261264
type: 'element',

packages/super-editor/src/editors/v1/core/super-converter/v2/exporter/footnotesExporter.js

Lines changed: 83 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,47 @@ import { mergeRelationshipElements } from '../../relationship-helpers.js';
55

66
const RELS_XMLNS = 'http://schemas.openxmlformats.org/package/2006/relationships';
77
const FOOTNOTES_RELS_PATH = 'word/_rels/footnotes.xml.rels';
8+
const ENDNOTES_RELS_PATH = 'word/_rels/endnotes.xml.rels';
9+
10+
const FOOTNOTES_CONFIG = {
11+
notesPath: 'word/footnotes.xml',
12+
relsPath: FOOTNOTES_RELS_PATH,
13+
rootName: 'w:footnotes',
14+
noteName: 'w:footnote',
15+
refName: 'w:footnoteRef',
16+
refStyle: 'FootnoteReference',
17+
relationshipType: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes',
18+
relationshipTarget: 'footnotes.xml',
19+
// Footnotes own the settings.xml export side-effects (footnoteProperties +
20+
// viewSetting). The endnote path skips them so we don't double-apply.
21+
applySettingsSideEffects: true,
22+
};
23+
24+
const ENDNOTES_CONFIG = {
25+
notesPath: 'word/endnotes.xml',
26+
relsPath: ENDNOTES_RELS_PATH,
27+
rootName: 'w:endnotes',
28+
noteName: 'w:endnote',
29+
refName: 'w:endnoteRef',
30+
refStyle: 'EndnoteReference',
31+
relationshipType: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes',
32+
relationshipTarget: 'endnotes.xml',
33+
applySettingsSideEffects: false,
34+
};
835

936
const paragraphHasFootnoteRef = (node) => {
1037
if (!node) return false;
11-
if (node.name === 'w:footnoteRef') return true;
38+
if (node.name === 'w:footnoteRef' || node.name === 'w:endnoteRef') return true;
1239
const children = Array.isArray(node.elements) ? node.elements : [];
1340
return children.some((child) => paragraphHasFootnoteRef(child));
1441
};
1542

16-
const insertFootnoteRefIntoParagraph = (paragraph) => {
43+
const insertFootnoteRefIntoParagraph = (paragraph, config) => {
1744
if (!paragraph || paragraph.name !== 'w:p') return;
1845
if (!Array.isArray(paragraph.elements)) paragraph.elements = [];
1946
if (paragraphHasFootnoteRef(paragraph)) return;
2047

21-
const footnoteRef = { type: 'element', name: 'w:footnoteRef', elements: [] };
48+
const footnoteRef = { type: 'element', name: config.refName, elements: [] };
2249
const footnoteRefRun = {
2350
type: 'element',
2451
name: 'w:r',
@@ -27,7 +54,7 @@ const insertFootnoteRefIntoParagraph = (paragraph) => {
2754
type: 'element',
2855
name: 'w:rPr',
2956
elements: [
30-
{ type: 'element', name: 'w:rStyle', attributes: { 'w:val': 'FootnoteReference' } },
57+
{ type: 'element', name: 'w:rStyle', attributes: { 'w:val': config.refStyle } },
3158
{ type: 'element', name: 'w:vertAlign', attributes: { 'w:val': 'superscript' } },
3259
],
3360
},
@@ -40,11 +67,11 @@ const insertFootnoteRefIntoParagraph = (paragraph) => {
4067
paragraph.elements.splice(insertAt, 0, footnoteRefRun);
4168
};
4269

43-
const ensureFootnoteRefMarker = (elements) => {
70+
const ensureFootnoteRefMarker = (elements, config) => {
4471
if (!Array.isArray(elements)) return;
4572
const firstParagraphIndex = elements.findIndex((el) => el?.name === 'w:p');
4673
if (firstParagraphIndex >= 0) {
47-
insertFootnoteRefIntoParagraph(elements[firstParagraphIndex]);
74+
insertFootnoteRefIntoParagraph(elements[firstParagraphIndex], config);
4875
return;
4976
}
5077

@@ -53,7 +80,7 @@ const ensureFootnoteRefMarker = (elements) => {
5380
name: 'w:p',
5481
elements: [],
5582
};
56-
insertFootnoteRefIntoParagraph(paragraph);
83+
insertFootnoteRefIntoParagraph(paragraph, config);
5784
elements.unshift(paragraph);
5885
};
5986

@@ -74,7 +101,7 @@ const translateFootnoteContent = (content, exportContext) => {
74101
return translated;
75102
};
76103

77-
export const createFootnoteElement = (footnote, exportContext) => {
104+
export const createFootnoteElement = (footnote, exportContext, config = FOOTNOTES_CONFIG) => {
78105
if (!footnote) return null;
79106

80107
const { id, content, type, originalXml } = footnote;
@@ -93,14 +120,14 @@ export const createFootnoteElement = (footnote, exportContext) => {
93120
// in their footnote content - the custom symbol appears in the document body instead.
94121
const originalHadFootnoteRef = originalXml ? paragraphHasFootnoteRef(originalXml) : true;
95122
if (originalHadFootnoteRef) {
96-
ensureFootnoteRefMarker(translatedContent);
123+
ensureFootnoteRefMarker(translatedContent, config);
97124
}
98125

99126
const base = originalXml
100127
? carbonCopy(originalXml)
101128
: {
102129
type: 'element',
103-
name: 'w:footnote',
130+
name: config.noteName,
104131
attributes: {},
105132
elements: [],
106133
};
@@ -157,10 +184,10 @@ const applyViewSettingToSettings = (converter, convertedXml) => {
157184
return { ...convertedXml, 'word/settings.xml': updatedSettings };
158185
};
159186

160-
const buildFootnotesRelsXml = (converter, convertedXml, relationships) => {
187+
const buildFootnotesRelsXml = (converter, convertedXml, relationships, relsPath = FOOTNOTES_RELS_PATH) => {
161188
if (!relationships.length) return null;
162189

163-
const existingRels = convertedXml[FOOTNOTES_RELS_PATH];
190+
const existingRels = convertedXml[relsPath];
164191
const existingRoot = existingRels?.elements?.find((el) => el.name === 'Relationships');
165192
const existingElements = Array.isArray(existingRoot?.elements) ? existingRoot.elements : [];
166193
const merged = mergeRelationshipElements(existingElements, relationships);
@@ -180,14 +207,25 @@ const buildFootnotesRelsXml = (converter, convertedXml, relationships) => {
180207
return relsXml;
181208
};
182209

183-
export const prepareFootnotesXmlForExport = ({ footnotes, editor, converter, convertedXml }) => {
184-
let updatedXml = applyFootnotePropertiesToSettings(converter, convertedXml);
185-
// NOTE: applyViewSettingToSettings lives here because this function already
186-
// modifies settings.xml during export. If the footnotes export path is ever
187-
// refactored, this call must move to wherever settings.xml is written.
188-
updatedXml = applyViewSettingToSettings(converter, updatedXml);
210+
const createNotesXmlDefinition = (config) => {
211+
const base = carbonCopy(FOOTNOTES_XML_DEF);
212+
if (base.elements?.[0]) {
213+
base.elements[0].name = config.rootName;
214+
}
215+
return base;
216+
};
217+
218+
const prepareNotesXmlForExport = ({ notes, editor, converter, convertedXml, config }) => {
219+
// Settings.xml side-effects (re-emitting w:footnotePr and w:view) belong to
220+
// the footnotes path only. The endnote path skips them so we don't redo the
221+
// same idempotent work twice per export.
222+
let updatedXml = convertedXml;
223+
if (config.applySettingsSideEffects) {
224+
updatedXml = applyFootnotePropertiesToSettings(converter, updatedXml);
225+
updatedXml = applyViewSettingToSettings(converter, updatedXml);
226+
}
189227

190-
if (!footnotes || !Array.isArray(footnotes) || footnotes.length === 0) {
228+
if (!notes || !Array.isArray(notes) || notes.length === 0) {
191229
return { updatedXml, relationships: [], media: {} };
192230
}
193231

@@ -201,15 +239,15 @@ export const prepareFootnotesXmlForExport = ({ footnotes, editor, converter, con
201239
media: footnoteMedia,
202240
};
203241

204-
const footnoteElements = footnotes.map((fn) => createFootnoteElement(fn, exportContext)).filter(Boolean);
242+
const footnoteElements = notes.map((fn) => createFootnoteElement(fn, exportContext, config)).filter(Boolean);
205243

206244
if (footnoteElements.length === 0) {
207245
return { updatedXml, relationships: [], media: footnoteMedia };
208246
}
209247

210-
let footnotesXml = updatedXml['word/footnotes.xml'];
248+
let footnotesXml = updatedXml[config.notesPath];
211249
if (!footnotesXml) {
212-
footnotesXml = carbonCopy(FOOTNOTES_XML_DEF);
250+
footnotesXml = createNotesXmlDefinition(config);
213251
} else {
214252
footnotesXml = carbonCopy(footnotesXml);
215253
}
@@ -218,12 +256,12 @@ export const prepareFootnotesXmlForExport = ({ footnotes, editor, converter, con
218256
footnotesXml.elements[0].elements = footnoteElements;
219257
}
220258

221-
updatedXml = { ...updatedXml, 'word/footnotes.xml': footnotesXml };
259+
updatedXml = { ...updatedXml, [config.notesPath]: footnotesXml };
222260

223261
if (footnoteRelationships.length > 0) {
224-
const footnotesRelsXml = buildFootnotesRelsXml(converter, updatedXml, footnoteRelationships);
262+
const footnotesRelsXml = buildFootnotesRelsXml(converter, updatedXml, footnoteRelationships, config.relsPath);
225263
if (footnotesRelsXml) {
226-
updatedXml = { ...updatedXml, [FOOTNOTES_RELS_PATH]: footnotesRelsXml };
264+
updatedXml = { ...updatedXml, [config.relsPath]: footnotesRelsXml };
227265
}
228266
}
229267

@@ -232,11 +270,29 @@ export const prepareFootnotesXmlForExport = ({ footnotes, editor, converter, con
232270
type: 'element',
233271
name: 'Relationship',
234272
attributes: {
235-
Type: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes',
236-
Target: 'footnotes.xml',
273+
Type: config.relationshipType,
274+
Target: config.relationshipTarget,
237275
},
238276
},
239277
];
240278

241279
return { updatedXml, relationships, media: footnoteMedia };
242280
};
281+
282+
export const prepareFootnotesXmlForExport = ({ footnotes, editor, converter, convertedXml }) =>
283+
prepareNotesXmlForExport({
284+
notes: footnotes,
285+
editor,
286+
converter,
287+
convertedXml,
288+
config: FOOTNOTES_CONFIG,
289+
});
290+
291+
export const prepareEndnotesXmlForExport = ({ endnotes, editor, converter, convertedXml }) =>
292+
prepareNotesXmlForExport({
293+
notes: endnotes,
294+
editor,
295+
converter,
296+
convertedXml,
297+
config: ENDNOTES_CONFIG,
298+
});
Binary file not shown.

0 commit comments

Comments
 (0)