feat: notes round-trip through DOCX

Each class gets its own part via the docx package's footnotes/endnotes maps,
counted from 1, and the anchor becomes a reference run. Import reads both
parts, skipping the separator entries Word references from nowhere, and
flattens a multi-paragraph note to hard breaks. The numbering settings ride
w:footnotePr/w:endnotePr in settings.xml — a post-pack pass, as the package
exposes neither.
This commit is contained in:
Steffen Becker committed 2026-08-13 11:42:08 +00:00
1 parent 5f0f5e7457
commit 3703cea1f2
4 files changed
+220 -7

No files matched your search

+1 -1
View File
@@ -650,7 +650,7 @@
try {
const json = editor.getJSON() as Parameters<typeof buildOdt>[0];
const { buildDocx } = await import('./lib/export/docx');
const bytes = await buildDocx(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, pageRtl);
const bytes = await buildDocx(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, pageRtl, noteSettings());
await saveAsDocx(bytes, suggestedFilenameDocx(json));
} catch (err) {
if ((err as DOMException)?.name === 'AbortError') return;
+67 -2
View File
@@ -1,5 +1,6 @@
import {
Document, Packer, Paragraph, TextRun, ImageRun, ExternalHyperlink, InternalHyperlink, Bookmark, Tab,
FootnoteReferenceRun, EndnoteReferenceRun,
TableOfContents,
Table, TableRow, TableCell, Header, Footer, PageNumber, SimpleField,
AlignmentType, LevelFormat, UnderlineType, BorderStyle, ShadingType,
@@ -19,6 +20,7 @@ import type { Orientation } from '../storage/pageOrientation';
import { pageDimsCm, type PageFormat } from '../storage/pageFormat';
import { DEFAULT_TAB_INTERVAL_CM } from '../storage/tabInterval';
import { HF_DISTANCE_CM, hfIsEmpty, type HfDoc, type HfSet } from '../storage/headerFooter';
import { DEFAULT_NOTE_SETTINGS, type NoteKind, type NoteSettings } from '../storage/noteSettings';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { parseBorderAttr, type BorderSide } from '../editor/extensions/tableCellBorders';
import { parseCellPadding, DEFAULT_CELL_PADDING } from '../editor/extensions/tableCellPadding';
@@ -98,6 +100,11 @@ const MTH = '';
type FormulaDocx = { latex: string; display: boolean };
let docFormulas: FormulaDocx[] = [];
// Word numbers footnotes and endnotes in separate files, so each class counts from 1.
// Filled from the note section before the body is walked, then read by the anchor —
// module-level for the same reason as docFormulas.
let docNoteIds = new Map<string, { id: number; kind: NoteKind }>();
// Sentinel wrapping a text box's index in a marker paragraph (same trick as
// export/odt.ts): the docx library can't emit DrawingML shapes, so buildDocx swaps
// the marker for a hand-built <w:drawing><wps:wsp> in a zip post-processing pass.
@@ -347,6 +354,13 @@ function inlineToRuns(content: TiptapNode[] = [], force: TextProps = {}): Inline
continue;
}
if (node.type === 'crossRef') { emit([crossRefField(node)], bookmark); continue; }
if (node.type === 'noteRef') {
const note = docNoteIds.get(String(node.attrs?.id ?? ''));
if (note) {
emit([note.kind === 'endnote' ? new EndnoteReferenceRun(note.id) : new FootnoteReferenceRun(note.id)], bookmark);
}
continue;
}
flush();
if (node.type === 'hardBreak') {
// Carry the run's props so an empty line between two breaks keeps its font size.
@@ -745,6 +759,37 @@ function applyMirrorMarginsDocx(bytes: Uint8Array): Uint8Array {
return zipSync(out);
}
// Word's num-format ids for the five ODF ones.
const DOCX_NUM_FMT: Record<string, string> = {
'1': 'decimal', a: 'lowerLetter', A: 'upperLetter', i: 'lowerRoman', I: 'upperRoman',
};
// Post-pack pass: the document-wide <w:footnotePr>/<w:endnotePr>, which the docx
// package does not expose. Word keeps its defaults in settings.xml, beside
// w:mirrorMargins.
function applyNotePrDocx(bytes: Uint8Array, notes: NoteSettings): Uint8Array {
const files = unzipSync(bytes);
const setBytes = files['word/settings.xml'];
if (!setBytes) return bytes;
const block = (kind: NoteKind) => {
const s = notes[kind];
const tag = kind === 'footnote' ? 'footnotePr' : 'endnotePr';
// Word's restart values; ODF's 'chapter' is its 'eachSect'.
const restart = s.restart === 'page' ? 'eachPage' : s.restart === 'chapter' ? 'eachSect' : 'continuous';
const pos = kind === 'footnote'
? `<w:pos w:val="${s.position === 'document' ? 'docEnd' : 'pageBottom'}"/>`
: '<w:pos w:val="docEnd"/>';
return `<w:${tag}>${pos}<w:numFmt w:val="${DOCX_NUM_FMT[s.numFormat] ?? 'decimal'}"/>`
+ `<w:numStart w:val="${Math.max(1, s.startAt)}"/><w:numRestart w:val="${restart}"/></w:${tag}>`;
};
files['word/settings.xml'] = strToU8(
strFromU8(setBytes).replace(/(<w:settings\b[^>]*>)/, `$1${block('footnote')}${block('endnote')}`),
);
const out: Record<string, [Uint8Array, { level: 6 }]> = {};
for (const [path, data] of Object.entries(files)) out[path] = [data, { level: 6 }];
return zipSync(out);
}
// Post-pack pass: a right-to-left section is <w:bidi/> in every w:sectPr, which the
// docx package does not expose (it has the paragraph-level flag only).
function applyBidiDocx(bytes: Uint8Array): Uint8Array {
@@ -1246,6 +1291,7 @@ export async function buildDocx(
styles: StyleSheet = builtinStyleSheet(),
tabIntervalCm: number = DEFAULT_TAB_INTERVAL_CM,
rtl = false,
notesSettings: NoteSettings = DEFAULT_NOTE_SETTINGS,
): Promise<Uint8Array> {
docLangTag = localeTag(language ? language.language : 'en');
exportSheet = styles;
@@ -1256,7 +1302,23 @@ export async function buildDocx(
const contentWidthCm = pageWidthCm - margins.left - margins.right;
const textBoxes: TextBoxDocx[] = [];
const groups = bodyGroups(docJson.content ?? [], num, contentWidthCm, textBoxes);
// The note section holds the notes in anchor order (notes.ts), which is the order
// Word numbers them in; each class counts from 1 because each has its own part.
// Numbered before the body is walked, so an anchor already knows its id.
const noteBlocks = (docJson.content ?? []).filter((n) => n.type === 'noteSection')
.flatMap((n) => n.content ?? []);
docNoteIds = new Map();
const notesByClass: Record<NoteKind, Record<string, { children: Paragraph[] }>> = { footnote: {}, endnote: {} };
for (const note of noteBlocks) {
const kind: NoteKind = note.attrs?.kind === 'endnote' ? 'endnote' : 'footnote';
const id = Object.keys(notesByClass[kind]).length + 1;
docNoteIds.set(String(note.attrs?.id ?? ''), { id, kind });
notesByClass[kind][String(id)] = {
children: [new Paragraph({ style: kind === 'endnote' ? 'EndnoteText' : 'FootnoteText', children: inlineToRuns(note.content ?? []) })],
};
}
const body = (docJson.content ?? []).filter((n) => n.type !== 'noteSection');
const groups = bodyGroups(body, num, contentWidthCm, textBoxes);
// A TOC field is empty until its field is calculated; ask the reader to update fields
// on open so Word/LibreOffice populate + hyperlink it (standard for TOC fields).
@@ -1325,6 +1387,8 @@ export async function buildDocx(
...(hasToc ? { features: { updateFields: true } } : {}),
styles: buildStyles(styles, usedStyleNames(docJson, styles), language, usedTableStyles(docJson, styles)),
numbering: { config: num.config },
...(Object.keys(notesByClass.footnote).length ? { footnotes: notesByClass.footnote } : {}),
...(Object.keys(notesByClass.endnote).length ? { endnotes: notesByClass.endnote } : {}),
// A columns group is its own section; w:type=continuous describes the break
// BEFORE a section, so it goes on every section but the first.
sections: groups.map((g, i) => ({
@@ -1344,6 +1408,7 @@ export async function buildDocx(
const blob = await Packer.toBlob(doc);
const packed = applyFormulasDocx(applyTextBoxesDocx(new Uint8Array(await blob.arrayBuffer()), textBoxes), docFormulas);
const mirrored = margins.mirrored ? applyMirrorMarginsDocx(packed) : packed;
const withNotes = docNoteIds.size ? applyNotePrDocx(packed, notesSettings) : packed;
const mirrored = margins.mirrored ? applyMirrorMarginsDocx(withNotes) : withNotes;
return rtl ? applyBidiDocx(mirrored) : mirrored;
}
+108 -4
View File
@@ -8,7 +8,7 @@ import { fitInlineImage, framePx } from '../editor/extensions/image';
import { formatTabStops } from '../editor/extensions/tabStops';
import type { CapsMode, LineStyle } from '../editor/extensions/textEffects';
import { tableLookAttr } from '../styles/tableStyles';
import { orderedTypeFromFormat, orderedTypeAttrAt, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
import { formatOrdinal, orderedTypeFromFormat, orderedTypeAttrAt, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
import { bulletCharAttr, bulletCharFromDocx } from '../utils/bulletListTypes';
import { DATE_FORMATS, TIME_FORMATS, docxPicture, toDateValue } from '../utils/dateTime';
import { imageDataUrl, placeholderImage, type ConvertedImages } from './imageFormats';
@@ -18,7 +18,7 @@ import { formatFromCm, type PageFormat } from '../storage/pageFormat';
import { clampTabInterval, DOCX_IMPLIED_TAB_CM } from '../storage/tabInterval';
import { languageFromOdf, NO_LANGUAGE, type DocumentLanguage } from '../storage/documentLanguage';
import { EMPTY_HF_SET, type HfDoc, type HfSet } from '../storage/headerFooter';
import { DEFAULT_NOTE_SETTINGS } from '../storage/noteSettings';
import { DEFAULT_NOTE_SETTINGS, type NoteKind, type NoteNumFormat, type NoteSettings } from '../storage/noteSettings';
import { applyUniformRunFont, pairAlignedFrames, sinkOffsetFrames, type OdtImportResult } from './odt';
import { chartDataUrl } from './chart';
import { deobfuscateOdttf, type EmbeddedFont } from '../fonts/embeddedFonts';
@@ -64,8 +64,27 @@ type Ctx = {
// Bookmarks open at this point of the walk (w:id → name). A range may start beside a
// paragraph and end inside a later one, so the state outlives both walks.
openBookmarks: Map<string, string>;
// word/footnotes.xml and endnotes.xml by w:id, and the notes the body referenced, in
// anchor order — the editor keeps them in one section at the document end (notes.ts).
noteParts: Record<NoteKind, Map<string, Element>>;
notes: { id: string; kind: NoteKind; text: string; content: Node[] }[];
};
// The real notes of a part, by w:id: Word's own separator entries carry a w:type and
// are referenced by nothing.
function noteParts(files: Record<string, Uint8Array>, part: string, tag: string): Map<string, Element> {
const out = new Map<string, Element>();
const bytes = files[`word/${part}.xml`];
if (!bytes) return out;
const root = parseXml(strFromU8(bytes)).documentElement;
for (const el of Array.from(root.getElementsByTagNameNS(W, tag))) {
if (el.getAttributeNS(W, 'type')) continue;
const id = el.getAttributeNS(W, 'id');
if (id != null) out.set(id, el);
}
return out;
}
// ---- units & editor defaults to suppress -----------------------------------
const twipToCm = (tw: number) => (tw / 1440) * 2.54;
const twipToPt = (tw: number) => tw / 20;
@@ -145,7 +164,10 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages =
const sectPr = fc(body, 'sectPr');
const contentWidthCm = sectionContentWidthCm(sectPr);
const leftMarginCm = twipToCm(intAttr(fc(sectPr, 'pgMar'), W, 'left') ?? 1440);
const ctx: Ctx = { styles, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, rels: parseRels(files['word/_rels/document.xml.rels']), imageCache: new Map(), convertedImages, pendingBlocks: [], listCounters: new Map(), contentWidthCm, leftMarginCm, cellSpacing: {}, tblIndToText: tblIndIsToText(files), accents: themeAccents(themeDoc), openBookmarks: new Map() };
const ctx: Ctx = { styles, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, rels: parseRels(files['word/_rels/document.xml.rels']), imageCache: new Map(), convertedImages, pendingBlocks: [], listCounters: new Map(), contentWidthCm, leftMarginCm, cellSpacing: {}, tblIndToText: tblIndIsToText(files), accents: themeAccents(themeDoc), openBookmarks: new Map(), notes: [], noteParts: {
footnote: noteParts(files, 'footnotes', 'footnote'),
endnote: noteParts(files, 'endnotes', 'endnote'),
} };
// Mid-body sectPr paragraphs delimit sections; a section whose w:cols declares
// more than one column becomes a columns node (the trailing group is described
@@ -171,6 +193,16 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages =
if (blocks.length === 0) blocks.push({ type: 'paragraph' });
pairAlignedFrames(blocks, Math.floor(cmToPx(ctx.contentWidthCm)));
// The notes the walk collected, in anchor order, as the one section the editor keeps
// at the document end.
if (ctx.notes.length) {
blocks.push({ type: 'noteSection', content: ctx.notes.map((n) => ({
type: 'note',
attrs: { id: n.id, kind: n.kind, label: null, text: n.text },
...(n.content.length ? { content: n.content } : {}),
})) });
}
// Odd/even pages: a document-level setting (settings.xml), not a section property.
const oddEven = docHasEvenOddHeaders(files);
const mirrored = docHasMirrorMargins(files);
@@ -188,7 +220,7 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages =
return {
content: { type: 'doc', content: blocks },
styles: collectStyleSheet(ctx),
notes: DEFAULT_NOTE_SETTINGS,
notes: docNoteSettings(files),
margins: withMirror(first.margins ?? sect.margins, mirrored),
// A right-to-left section (w:bidi): the columns fill from the right.
rtl: !!finalSectPr && (() => { const b = fc(finalSectPr, 'bidi'); return !!b && onOff(b); })(),
@@ -1012,6 +1044,33 @@ function inlineChildren(el: Element): Element[] {
});
}
// A w:footnoteReference → the anchor node, with the note's own paragraphs collected
// into ctx for the section the document end gets. Word's numbering is implicit, so the
// citation is counted here; several paragraphs flatten to hard breaks.
function noteRefNode(wid: string | null, kind: NoteKind, ctx: Ctx, baseRun: RunProps, defaults: BlockDefaults): Node | null {
const note = wid == null ? null : ctx.noteParts[kind].get(wid);
if (!note) return null;
const content: Node[] = [];
for (const para of Array.from(note.children)) {
if (para.namespaceURI !== W || para.localName !== 'p') continue;
const runs = convertInline(para, ctx, baseRun, defaults, false);
if (content.length && runs.length) content.push({ type: 'hardBreak' });
content.push(...runs);
}
// Word opens the note with its own marker run and a tab; the editor draws both from
// the note's own indent, so the leading tab would be a second one.
const first = content[0];
if (first?.type === 'text' && typeof first.text === 'string') {
first.text = first.text.replace(/^\t+/, '');
if (!first.text) content.shift();
}
const seen = ctx.notes.filter((n) => n.kind === kind).length;
const text = formatOrdinal(seen + 1, kind === 'endnote' ? 'i' : '1');
const id = `${kind}${wid}`;
ctx.notes.push({ id, kind, text, content });
return { type: 'noteRef', attrs: { id, kind, text } };
}
function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockDefaults, hfFields: boolean): Node[] {
const out: Node[] = [];
let fieldMode: 'none' | 'instr' | 'result' = 'none';
@@ -1146,6 +1205,15 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD
case 'cr': out.push(hardBreakNode(marks)); break;
case 'drawing': pushDrawn(convertDrawing(child, ctx), drawingIsFloating(child)); break;
case 'pict': pushDrawn(convertPict(child, ctx), drawingIsFloating(child)); break;
case 'footnoteReference':
case 'endnoteReference': {
// The zone schema has no notes, and Word's own separator notes are referenced
// by nothing — only a real anchor reaches here.
const kind: NoteKind = child.localName === 'endnoteReference' ? 'endnote' : 'footnote';
const ref = hfFields ? null : noteRefNode(child.getAttributeNS(W, 'id'), kind, ctx, baseRun, defaults);
if (ref) out.push(ref);
break;
}
}
}
};
@@ -2070,6 +2138,42 @@ function docHasMirrorMargins(files: Record<string, Uint8Array>): boolean {
}
// Word's "Different Odd & Even Pages" toggle lives in settings.xml, not the sectPr.
// Word's num-format ids, reversed onto the five ODF ones.
const NUM_FMT_FROM_DOCX: Record<string, NoteNumFormat> = {
decimal: '1', lowerLetter: 'a', upperLetter: 'A', lowerRoman: 'i', upperRoman: 'I',
};
// The document-wide <w:footnotePr>/<w:endnotePr> in settings.xml. The separator is not
// Word's to describe — it draws a fixed one — so it keeps the editor's.
function docNoteSettings(files: Record<string, Uint8Array>): NoteSettings {
const bytes = files['word/settings.xml'];
if (!bytes) return DEFAULT_NOTE_SETTINGS;
const out: NoteSettings = {
footnote: { ...DEFAULT_NOTE_SETTINGS.footnote },
endnote: { ...DEFAULT_NOTE_SETTINGS.endnote },
separator: { ...DEFAULT_NOTE_SETTINGS.separator },
};
try {
const root = parseXml(strFromU8(bytes)).documentElement;
for (const kind of ['footnote', 'endnote'] as NoteKind[]) {
const el = root.getElementsByTagNameNS(W, `${kind}Pr`)[0];
if (!el) continue;
const cls = out[kind];
const val = (name: string) => { const c = fc(el, name); return c ? wVal(c) : null; };
const fmt = NUM_FMT_FROM_DOCX[val('numFmt') ?? ''];
if (fmt) cls.numFormat = fmt;
const start = Number(val('numStart'));
if (Number.isFinite(start) && start > 0) cls.startAt = start;
const restart = val('numRestart');
if (restart === 'eachPage') cls.restart = 'page';
else if (restart === 'eachSect') cls.restart = 'chapter';
else if (restart === 'continuous') cls.restart = 'document';
if (kind === 'footnote' && val('pos') === 'docEnd') cls.position = 'document';
}
} catch { /* a malformed settings part just keeps the defaults */ }
return out;
}
function docHasEvenOddHeaders(files: Record<string, Uint8Array>): boolean {
const bytes = files['word/settings.xml'];
if (!bytes) return false;
+44
View File
@@ -13,6 +13,7 @@ import { builtinStyleSheet } from '../src/lib/styles/styleSheet';
import { buildDocx } from '../src/lib/export/docx';
import { importDocx } from '../src/lib/import/docx';
import { EMPTY_HF_SET } from '../src/lib/storage/headerFooter';
import { DEFAULT_NOTE_SETTINGS } from '../src/lib/storage/noteSettings';
type N = any;
@@ -446,6 +447,49 @@ describe('Leg 1a2c: footnotes and endnotes', () => {
check('the body text is untouched', JSON.stringify(res.content).includes('Body one') && JSON.stringify(res.content).includes('and on.'), null);
check('no note-removed warning is raised', !res.warnings.some((w: string) => /[Ff]ootnote/.test(w)), res.warnings);
});
it('DOCX: writes word/footnotes.xml + endnotes.xml and reads them back', async () => {
const bytes = await buildDocx(notesDoc(), margins, 'portrait');
const files = unzipSync(bytes);
check('a footnote part is written', !!files['word/footnotes.xml'], Object.keys(files).filter((f) => /note/.test(f)));
check('an endnote part is written', !!files['word/endnotes.xml'], Object.keys(files).filter((f) => /note/.test(f)));
const doc = strFromU8(files['word/document.xml']);
check('the body references both', /<w:footnoteReference w:id="1"/.test(doc) && /<w:endnoteReference w:id="1"/.test(doc), doc.match(/<w:(foot|end)noteReference[^>]*>/g));
const fn = strFromU8(files['word/footnotes.xml']);
check('the note text lives in its own part', fn.includes('The footnote, with'), fn.slice(0, 400));
const res = importDocx(bytes);
const refs: N[] = [];
(function walk(n: N) { if (n.type === 'noteRef') refs.push(n); for (const c of n.content ?? []) walk(c); })(res.content);
const section = (res.content.content ?? []).find((n: N) => n.type === 'noteSection');
check('DOCX: both anchors come back', refs.length === 2, refs.map((r) => r.attrs));
check('DOCX: their classes survive', refs.map((r) => r.attrs.kind).join(',') === 'footnote,endnote', refs.map((r) => r.attrs.kind));
// Word's own separator entries carry a w:type and are referenced by nothing.
check('DOCX: the separator notes are not imported', (section?.content ?? []).length === 2, section?.content?.map((n: N) => n.attrs));
check('DOCX: each anchor points at its own note', refs[0]?.attrs?.id === section?.content?.[0]?.attrs?.id, [refs.map((r) => r.attrs.id), section?.content?.map((n: N) => n.attrs.id)]);
check('DOCX: the note text comes back', JSON.stringify(section).includes('The footnote, with'), JSON.stringify(section)?.slice(0, 200));
check('DOCX: bold inside the note survives', JSON.stringify(section).includes('"bold"'), JSON.stringify(section)?.slice(0, 300));
check('DOCX: the body text is untouched', JSON.stringify(res.content).includes('Body one'), null);
});
it('round-trips changed numbering settings through both formats', async () => {
const custom = {
...DEFAULT_NOTE_SETTINGS,
footnote: { ...DEFAULT_NOTE_SETTINGS.footnote, numFormat: 'A' as const, startAt: 4, restart: 'page' as const },
separator: { ...DEFAULT_NOTE_SETTINGS.separator, relWidthPercent: 60, weightPt: 1.5, align: 'center' as const },
};
const odt = await buildOdt(notesDoc(), margins, 'portrait', undefined, null, 'A4', builtinStyleSheet(), 1.25, 'add', false, custom);
const back = importOdt(odt).notes;
check('ODF: format, start and restart survive',
back.footnote.numFormat === 'A' && back.footnote.startAt === 4 && back.footnote.restart === 'page', back.footnote);
check('ODF: the separator survives',
back.separator.relWidthPercent === 60 && Math.abs(back.separator.weightPt - 1.5) < 0.05 && back.separator.align === 'center', back.separator);
const docx = await buildDocx(notesDoc(), margins, 'portrait', undefined, null, 'A4', builtinStyleSheet(), 1.25, false, custom);
const dback = importDocx(docx).notes;
check('DOCX: format, start and restart survive',
dback.footnote.numFormat === 'A' && dback.footnote.startAt === 4 && dback.footnote.restart === 'page', dback.footnote);
});
});
describe('Leg 1a3: continued ordered-list start value', () => {