Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/super-editor/src/editors/v1/core/DocxZipper.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<Override PartName="/word/footnotes.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml" />`;
if (!hasFootnotes) typesString += footnotesDef;
}

if (hasFile('word/endnotes.xml')) {
const endnotesDef = `<Override PartName="/word/endnotes.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml" />`;
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}`)) {
Expand Down
39 changes: 38 additions & 1 deletion packages/super-editor/src/editors/v1/core/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3177,6 +3177,17 @@ export class Editor extends EventEmitter<EditorEventMap> {
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;

Expand Down Expand Up @@ -3212,7 +3223,15 @@ export class Editor extends EventEmitter<EditorEventMap> {
};

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) {
Expand All @@ -3223,6 +3242,14 @@ export class Editor extends EventEmitter<EditorEventMap> {
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}`);
Expand All @@ -3247,6 +3274,16 @@ export class Editor extends EventEmitter<EditorEventMap> {
}
}

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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Comment thread
harbournick marked this conversation as resolved.
(liveSectPr && typeof liveSectPr === 'object' ? carbonCopy(liveSectPr) : null) ||
params.bodyNode?.elements?.find((n) => n.name === 'w:sectPr');
if (!sectPr) {
sectPr = {
type: 'element',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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' } },
],
},
Expand All @@ -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;
}

Expand All @@ -53,7 +80,7 @@ const ensureFootnoteRefMarker = (elements) => {
name: 'w:p',
elements: [],
};
insertFootnoteRefIntoParagraph(paragraph);
insertFootnoteRefIntoParagraph(paragraph, config);
elements.unshift(paragraph);
};

Expand All @@ -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;
Expand All @@ -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: [],
};
Expand Down Expand Up @@ -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);
Expand All @@ -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: {} };
}

Expand All @@ -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);
}
Expand All @@ -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 };
}
}

Expand All @@ -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,
});
Binary file not shown.
Loading
Loading