From 3703cea1f2ccbad098f0a8475733955e9a1de5c4 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Thu, 13 Aug 2026 11:42:08 +0000 Subject: [PATCH] feat: notes round-trip through DOCX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/App.svelte | 2 +- src/lib/export/docx.ts | 69 ++++++++++++++++++++++++- src/lib/import/docx.ts | 112 ++++++++++++++++++++++++++++++++++++++-- tests/roundtrip.test.ts | 44 ++++++++++++++++ 4 files changed, 220 insertions(+), 7 deletions(-) diff --git a/src/App.svelte b/src/App.svelte index f19e43c..ba6d7db 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -650,7 +650,7 @@ try { const json = editor.getJSON() as Parameters[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; diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index e4bf378..1cc7329 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -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(); + // 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 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 = { + '1': 'decimal', a: 'lowerLetter', A: 'upperLetter', i: 'lowerRoman', I: 'upperRoman', +}; + +// Post-pack pass: the document-wide /, 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' + ? `` + : ''; + return `${pos}` + + ``; + }; + files['word/settings.xml'] = strToU8( + strFromU8(setBytes).replace(/(]*>)/, `$1${block('footnote')}${block('endnote')}`), + ); + const out: Record = {}; + 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 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 { 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> = { 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; } diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 3260ed5..154e973 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -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; + // 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>; + 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, part: string, tag: string): Map { + const out = new Map(); + 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): 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 = { + decimal: '1', lowerLetter: 'a', upperLetter: 'A', lowerRoman: 'i', upperRoman: 'I', +}; + +// The document-wide / 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): 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): boolean { const bytes = files['word/settings.xml']; if (!bytes) return false; diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts index 6bf8787..15d1eac 100644 --- a/tests/roundtrip.test.ts +++ b/tests/roundtrip.test.ts @@ -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', /]*>/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', () => {