diff --git a/src/App.svelte b/src/App.svelte index 172e474..f19e43c 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -29,6 +29,8 @@ import { loadPageRtl, savePageRtl } from './lib/storage/writingMode'; import { loadPageFormat, savePageFormat, type PageFormat } from './lib/storage/pageFormat'; import { setStyleSheet, styleSheet } from './lib/styles/sheet.svelte'; + import { noteSettings, setNoteSettings } from './lib/storage/notes.svelte'; + import { DEFAULT_NOTE_SETTINGS } from './lib/storage/noteSettings'; import { builtinStyleSheet, type StyleFamily } from './lib/styles/styleSheet'; import { loadHfDoc, saveHfDoc, loadHfDistances, saveHfDistances, loadDifferentFirstPage, saveDifferentFirstPage, loadDifferentOddEven, saveDifferentOddEven, hfIsEmpty, DEFAULT_HF_DISTANCES, loadExtraHfSections, saveExtraHfSections, type HfDoc, type HfZone, type HfDistances, type HfSet } from './lib/storage/headerFooter'; import { loadDocName, saveDocName, stripOdtExtension, sanitizeNameForFile } from './lib/storage/documentName'; @@ -478,8 +480,9 @@ extraHfSections = []; documentName = ''; fileHandle = null; - // Styles live in the document, so a new one starts from the built-ins + // Styles and note settings live in the document, so a new one starts from the built-ins setStyleSheet(builtinStyleSheet()); + setNoteSettings(DEFAULT_NOTE_SETTINGS); clearEmbeddedFonts(); void clearEmbeddedFontStore(); editor.commands.focus(); @@ -541,6 +544,7 @@ // styles are not stored in the file (ODF has no banding), so the registry survives — // an imported table finds its style again by name. setStyleSheet({ ...result.styles, table: styleSheet().table }); + setNoteSettings(result.notes); // Adopt header/footer + first-page variants (null clears the zone); end any edit. hfActive = null; headerDoc = result.header; @@ -612,7 +616,7 @@ exportMenuOpen = false; const json = editor.getJSON() as Parameters[0]; try { - const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl); + const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings()); fileHandle = await saveOdt(bytes, suggestedFilename(json), fileHandle); } catch (err) { if ((err as DOMException)?.name === 'AbortError') return; @@ -628,7 +632,7 @@ exportMenuOpen = false; const json = editor.getJSON() as Parameters[0]; try { - const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl); + const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet(), tabIntervalCm, spacingModel, pageRtl, noteSettings()); fileHandle = await saveAsOdt(bytes, suggestedFilename(json)); } catch (err) { if ((err as DOMException)?.name === 'AbortError') return; diff --git a/src/lib/editor/extensions/notes.ts b/src/lib/editor/extensions/notes.ts index 467899d..46f6449 100644 --- a/src/lib/editor/extensions/notes.ts +++ b/src/lib/editor/extensions/notes.ts @@ -370,6 +370,11 @@ function syncNumbers(state: EditorState, settings: NoteSettings): Transaction | const refs = collectNoteRefs(state.doc); const labels = noteLabels(refs, settings); const section = findNoteSection(state.doc); + // A note the file numbered by hand keeps its own mark, and the anchor shows the same + // one — the two are one mark drawn twice, not two. + section?.node.forEach((child) => { + if (child.attrs.label) labels.set(String(child.attrs.id ?? ''), String(child.attrs.label)); + }); const tr = state.tr; let changed = false; @@ -380,7 +385,7 @@ function syncNumbers(state: EditorState, settings: NoteSettings): Transaction | changed = true; } section?.node.forEach((child, offset) => { - const own = child.attrs.label ? String(child.attrs.label) : labels.get(String(child.attrs.id ?? '')) ?? ''; + const own = labels.get(String(child.attrs.id ?? '')) ?? ''; if (child.attrs.text === own) return; tr.setNodeAttribute(section.pos + 1 + offset, 'text', own); changed = true; diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index cd4ed8e..fd1b18f 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -6,6 +6,7 @@ import type { SpacingModel } from '../storage/spacingModel'; 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, NOTE_FONT_SIZE_PT, NOTE_INDENT_CM, type NoteKind, type NoteSettings } from '../storage/noteSettings'; import { builtinStyleSheet, DEFAULT_STYLE, resolveStyle, type StyleSheet, type TextProps } from '../styles/styleSheet'; import { TABLE_REGIONS, parseTableLook, regionText, type TableLook, type TableRegion, @@ -162,6 +163,11 @@ const XRF = '\uE015'; // applyHfPostProcess rewrites it to in styles.xml. U+E016. const CHP = '\uE016'; +// Sentinels for footnotes/endnotes (notes.ts): FNT A{i} FNT marks the anchor in the +// running text, FNT B{i} FNT opens the note's own hoisted paragraph. applyNotes cuts +// the body out and splices it into a at the anchor. U+E017. +const FNT = '\uE017'; + const EXT_BY_MIME: Record = { 'image/png': 'png', 'image/jpeg': 'jpg', @@ -632,6 +638,59 @@ function replaceTextBoxes(doc: TiptapNode, boxes: TextBoxExport[]): TiptapNode { return { ...doc, content }; } +// One footnote/endnote, collected by replaceNotes and emitted by applyNotes. +type NoteExport = { kind: 'footnote' | 'endnote'; citation: string; label: string | null }; + +// Anchors become an FNT A{i} sentinel run in the running text; the note section is +// dissolved and each note re-emitted as its own top-level paragraph opening with an +// FNT B{i} sentinel. Hoisted like a text box, so the note text rides every existing +// pass — marks, character styles, links, bookmarks, fields and formulas all included. +function replaceNotes(doc: TiptapNode, notes: NoteExport[]): TiptapNode { + const bodies = new Map(); + const body: TiptapNode[] = []; + for (const child of doc.content ?? []) { + if (child.type !== 'noteSection') { body.push(child); continue; } + for (const note of child.content ?? []) { + const a = note.attrs ?? {}; + bodies.set(String(a.id ?? ''), { + content: note.content ?? [], + label: typeof a.label === 'string' && a.label ? a.label : null, + }); + } + } + + // Anchor order is the numbering order, so it is also the index order. + const order: string[] = []; + const swapRefs = (node: TiptapNode): TiptapNode => { + if (!node.content?.length) return node; + return { + ...node, + content: node.content.map((child) => { + if (child.type !== 'noteRef') return swapRefs(child); + const a = child.attrs ?? {}; + const id = String(a.id ?? ''); + const i = notes.length; + order.push(id); + notes.push({ + kind: a.kind === 'endnote' ? 'endnote' : 'footnote', + citation: String(a.text ?? ''), + label: bodies.get(id)?.label ?? null, + }); + return { type: 'text', text: `${FNT}A${i}${FNT}` }; + }), + }; + }; + + const hoisted = body.map(swapRefs); + order.forEach((id, i) => { + hoisted.push({ + type: 'paragraph', + content: [{ type: 'text', text: `${FNT}B${i}${FNT}` }, ...(bodies.get(id)?.content ?? [])], + }); + }); + return { ...doc, content: hoisted }; +} + // A multi-column section's geometry, collected by replaceColumns. type ColumnsExport = { count: number; gapCm: number }; @@ -2145,7 +2204,7 @@ function rezipOdt(files: Record): Uint8Array { // Rewrite styles.xml to match the editor's preview: default font Liberation Serif → // Times New Roman (metric-identical; see EXPORT_FONT), and Heading_20_1/2/3 sizes & // margins → the editor's values (odf-kit's defaults are larger; HEADING_STYLE_OVERRIDES). -function rewriteStylesXml(odtBytes: Uint8Array, lang: { language: string; country: string } | null, pageFormat: PageFormat, orientation: Orientation, sheet: StyleSheet, used: Set, usedTables: Set = new Set(), tabIntervalCm: number = DEFAULT_TAB_INTERVAL_CM, mirrored = false, rtl = false): Uint8Array { +function rewriteStylesXml(odtBytes: Uint8Array, lang: { language: string; country: string } | null, pageFormat: PageFormat, orientation: Orientation, sheet: StyleSheet, used: Set, usedTables: Set = new Set(), tabIntervalCm: number = DEFAULT_TAB_INTERVAL_CM, mirrored = false, rtl = false, notes: NoteSettings = DEFAULT_NOTE_SETTINGS): Uint8Array { const files = unzipSync(odtBytes); const stylesBytes = files['styles.xml']; if (!stylesBytes) return odtBytes; @@ -2194,7 +2253,14 @@ function rewriteStylesXml(odtBytes: Uint8Array, lang: { language: string; countr // that declares none falls back to ODF's own 2cm, which is not what the editor shows // (measured — LibreOffice puts a bare A⇥X at 2cm, not at its UI default of 1.25). styles = styles.replace('', '' - + ``); + + `` + + notesStyles() + notesConfiguration(notes)); + + // The separator belongs to the page layout, beside the margins it is measured against. + styles = styles.replace( + /]*\/>/, + (props) => `${props.slice(0, -2)}>${footnoteSepXml(notes.separator)}`, + ); // odf-kit's Standard style carries fo:margin-bottom="0.212cm", but the editor (like // LibreOffice and Word) has no paragraph spacing by default — every paragraph and list @@ -3222,6 +3288,55 @@ function applyColumns(odtBytes: Uint8Array, cols: ColumnsExport[]): Uint8Array { // Minted automatic paragraph style for a TOC entry level: per-level left indent and a // right tab stop at the text width with a dotted leader (so the page number right-aligns // with dots, matching the on-screen TOC and Word/LibreOffice). +// The note styles LibreOffice ships, written verbatim so a re-save changes nothing: +// Footnote/Endnote at 10pt with a 0.6cm hanging indent, and the two character styles +// its notes-configuration points at (the anchor raised, the symbol plain). +function notesStyles(): string { + const para = (name: string) => + `` + + `` + + `` + + ''; + const anchor = (name: string, display: string) => + `` + + ''; + return para('Footnote') + para('Endnote') + + `` + + `` + + anchor('Footnote_20_anchor', 'Footnote anchor') + + anchor('Endnote_20_anchor', 'Endnote anchor'); +} + +// per class. ODF counts start-value from 0, so the first +// note of a class carries startAt - 1. +function notesConfiguration(settings: NoteSettings): string { + const one = (kind: NoteKind) => { + const s = settings[kind]; + const foot = kind === 'footnote'; + const prefix = s.prefix ? ` style:num-prefix="${escapeXml(s.prefix)}"` : ''; + const suffix = s.suffix ? ` style:num-suffix="${escapeXml(s.suffix)}"` : ''; + return `'; + }; + return one('footnote') + one('endnote'); +} + +// lives inside the page layout's own properties, not the styles. +function footnoteSepXml(sep: NoteSettings['separator']): string { + const round3 = (v: number) => Math.round(v * 1000) / 1000; + return ``; +} + function contentsEntryStyle(name: string, level: number, tabPosCm: number): string { const indentCm = level === 2 ? 0.6 : level === 3 ? 1.2 : 0; const margin = indentCm > 0 ? ` fo:margin-left="${indentCm}cm"` : ''; @@ -3308,6 +3423,54 @@ function applyBookmarks(odtBytes: Uint8Array, refs: CrossRefExport[]): Uint8Arra return rezipOdt(files); } +// Cut each hoisted note paragraph out of content.xml and splice its runs into a +// at the anchor sentinel. Runs last of the content.xml passes, so the note +// text carries everything the earlier ones resolved inside it. +function applyNotes(odtBytes: Uint8Array, notes: NoteExport[]): Uint8Array { + if (!notes.length) return odtBytes; + const files = unzipSync(odtBytes); + const contentBytes = files['content.xml']; + if (!contentBytes) return odtBytes; + + let content = strFromU8(contentBytes); + if (!content.includes(FNT)) return odtBytes; + + // The note's runs, taken out of the paragraph the hoist gave it. A wrapper span may + // hold the sentinel, so it is cut by string and the empty remains are swept after. + const bodies: string[] = notes.map((_note, i) => { + const open = `${FNT}B${i}${FNT}`; + const at = content.indexOf(open); + if (at < 0) return ''; + const start = content.lastIndexOf('', at); + if (start < 0 || end < 0) return ''; + const block = content.slice(start, end + ''.length); + content = content.slice(0, start) + content.slice(end + ''.length); + return block + .replace(/^]*>/, '') + .replace(/<\/text:p>$/, '') + .replace(open, '') + .replace(/]*\/>|]*><\/text:span>/g, ''); + }); + + content = content.replace(new RegExp(`${FNT}A(\\d+)${FNT}`, 'g'), (_m, idx: string) => { + const i = Number(idx); + const note = notes[i]; + if (!note) return ''; + const endnote = note.kind === 'endnote'; + const label = note.label ? ` text:label="${escapeXml(note.label)}"` : ''; + return `` + + `${escapeXml(note.citation)}` + + `${bodies[i]}` + + ''; + }); + // Defensive: strip any sentinel the passes above left behind. + content = content.split(FNT).join(''); + + files['content.xml'] = strToU8(content); + return rezipOdt(files); +} + function applyToc(odtBytes: Uint8Array, tocs: TocExport[], contentWidthCm: number): Uint8Array { if (!tocs.length) return odtBytes; const files = unzipSync(odtBytes); @@ -3362,7 +3525,7 @@ export type HfExport = { }; // The full document → .odt pipeline, DOM-free; returns the .odt bytes. -export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait', hf?: HfExport, language?: { language: string; country: string } | null, pageFormat: PageFormat = 'A4', styles: StyleSheet = builtinStyleSheet(), tabIntervalCm: number = DEFAULT_TAB_INTERVAL_CM, spacingModel: SpacingModel = 'add', rtl = false): Promise { +export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait', hf?: HfExport, language?: { language: string; country: string } | null, pageFormat: PageFormat = 'A4', styles: StyleSheet = builtinStyleSheet(), tabIntervalCm: number = DEFAULT_TAB_INTERVAL_CM, spacingModel: SpacingModel = 'add', rtl = false, notesSettings: NoteSettings = DEFAULT_NOTE_SETTINGS): Promise { // Images become IMG sentinels before serialization; applyImages resolves them and writes // the Pictures/ + manifest entries. Text boxes and columns hoist after replacePageBreaks // (so PGB misses their blocks) and before the inline passes (which then cover them). @@ -3374,7 +3537,8 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU const dateFields: DateTimeFieldExport[] = []; const formulas: FormulaExport[] = []; const crossRefs: CrossRefExport[] = []; - const sentinels = replaceBookmarks(replaceFormulas(replaceDateTimeFields(replaceImages(replaceTabs(replaceHardBreaks(replaceSectionBreaks(replaceColumns(replaceTextBoxes(replacePageBreaks(replaceTableOfContents(docJson, tocs)), textBoxes), columns)))), images), dateFields), formulas), crossRefs); + const notes: NoteExport[] = []; + const sentinels = replaceBookmarks(replaceFormulas(replaceDateTimeFields(replaceImages(replaceTabs(replaceHardBreaks(replaceSectionBreaks(replaceNotes(replaceColumns(replaceTextBoxes(replacePageBreaks(replaceTableOfContents(docJson, tocs)), textBoxes), columns), notes)))), images), dateFields), formulas), crossRefs); const raw = markTextEffects(bakeListCharStyles(sentinels, styles)); let headerPara = hf && !hfIsEmpty(hf.header) ? (hf.header!.content![0] as TiptapNode) : null; let footerPara = hf && !hfIsEmpty(hf.footer) ? (hf.footer!.content![0] as TiptapNode) : null; @@ -3578,14 +3742,17 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU const withTextBoxes = applyTextBoxes(withFormulas, textBoxes); const withColumns = applyColumns(withTextBoxes, columns); const withBookmarks = applyBookmarks(withColumns, crossRefs); - const withToc = applyToc(withBookmarks, tocs, contentWidthCm); + // After every other content.xml pass: the note's own runs are hoisted into the body, + // so they must be fully resolved before they are cut out and moved into . + const withNotes = applyNotes(withBookmarks, notes); + const withToc = applyToc(withNotes, tocs, contentWidthCm); const withPageBreaks = applyPageBreaks(withToc); const withEmptyFontSizes = applyEmptyLineFontSizes(withPageBreaks); const withParaBoxes = applyParagraphBoxes(withEmptyFontSizes); // Effects first: applyCharacterStyles then clones the style that already carries them. const withNamedStyles = applyCharacterStyles(applyTextEffects(applyParagraphStyles(withParaBoxes))); const usedTables = new Set(tableStyleNames.filter((t): t is TableStyleRef => !!t).map(t => t.name)); - const withStyles = rewriteStylesXml(withNamedStyles, language ?? null, pageFormat, orientation, styles, usedStyleNames(docJson, styles), usedTables, tabIntervalCm, margins.mirrored === true, rtl); + const withStyles = rewriteStylesXml(withNamedStyles, language ?? null, pageFormat, orientation, styles, usedStyleNames(docJson, styles), usedTables, tabIntervalCm, margins.mirrored === true, rtl, notesSettings); const withHf = applyHfPostProcess(withStyles, margins, headerPara, footerPara, headerDist, footerDist, firstHeaderPara, firstFooterPara, hf?.pageCount ?? 1, hfImages, evenHeaderPara, evenFooterPara); // Sections past the first get their own master page, which is where ODF keeps a // section's header/footer; the SEC-marked block points at it. diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index 804d720..db99c20 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -637,7 +637,6 @@ const de: Messages = { hfFlattened: 'Listen/Tabellen in Kopf- oder Fußzeilen wurden in Text umgewandelt', textBoxFlattened: 'In Tabellenzellen oder anderen Textfeldern verschachtelte Textfelder wurden aufgelöst', nestedTables: 'Verschachtelte Tabellen wurden in Absätze umgewandelt', - footnotes: 'Fuß- und Endnoten wurden entfernt', comments: 'Kommentare wurden entfernt', missingFonts: (fonts) => `Diese Schriftarten sind nicht installiert und wurden durch Ersatzschriften dargestellt: ${fonts}`, }, diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index 8e79eec..622201f 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -643,7 +643,6 @@ const en = { hfFlattened: 'Lists/tables in headers or footers were flattened to text', textBoxFlattened: 'Text boxes nested in table cells or other text boxes were flattened', nestedTables: 'Nested tables were flattened to paragraphs', - footnotes: 'Footnotes and endnotes were removed', comments: 'Comments were removed', missingFonts: (fonts: string) => `These fonts aren't installed and were shown with a substitute: ${fonts}`, }, diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 9e8ed20..3260ed5 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -18,6 +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 { applyUniformRunFont, pairAlignedFrames, sinkOffsetFrames, type OdtImportResult } from './odt'; import { chartDataUrl } from './chart'; import { deobfuscateOdttf, type EmbeddedFont } from '../fonts/embeddedFonts'; @@ -187,6 +188,7 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages = return { content: { type: 'doc', content: blocks }, styles: collectStyleSheet(ctx), + notes: DEFAULT_NOTE_SETTINGS, 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); })(), diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 2bb2e8c..09c1e03 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -20,6 +20,7 @@ import type { SpacingModel } from '../storage/spacingModel'; import { pageDimsCm, type PageFormat } from '../storage/pageFormat'; import { languageFromOdf, NO_LANGUAGE, type DocumentLanguage } from '../storage/documentLanguage'; import type { HfDoc, HfSet } from '../storage/headerFooter'; +import type { NoteKind, NoteSettings } from '../storage/noteSettings'; import type { EmbeddedFont } from '../fonts/embeddedFonts'; import { cellPaddingAttr, DEFAULT_CELL_PADDING, type CellPadding } from '../editor/extensions/tableCellPadding'; import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox'; @@ -81,6 +82,8 @@ export interface OdtImportResult { // The document's named paragraph styles: the file's own (only those it uses, plus // their parent chains) merged over the editor's built-ins. styles: StyleSheet; + // How its footnotes and endnotes are numbered and separated. + notes: NoteSettings; warnings: string[]; } @@ -114,6 +117,9 @@ type Ctx = { // Bookmark ranges open at this point of the walk, outermost first; a range may end in // a later paragraph than it started in, so the set outlives one convertInline call. openBookmarks: Set; + // Footnotes/endnotes in anchor order: the file stores each note's text at its anchor, + // the editor keeps them in one section at the document end (notes.ts). + notes: { id: string; kind: NoteKind; label: string | null; text: string; content: Node[] }[]; }; // Read a Pictures/ entry into a base64 data-URI; null when it's missing or in a format @@ -585,7 +591,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages = const contentWidthCm = geo ? pageDimsCm(geo.format, geo.orientation).w - geo.margins.left - geo.margins.right : pageDimsCm('A4', 'portrait').w - 2 * 2.12; - const ctx: Ctx = { resolver, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, imageCache: new Map(), convertedImages, pendingBlocks: [], contentWidthCm, masterPages: [], masterBlocks: new Map(), openBookmarks: new Set() }; + const ctx: Ctx = { resolver, styleNames, usedStyles: new Set(), charStyleNames, usedCharStyles: new Set(), warnings, files, imageCache: new Map(), convertedImages, pendingBlocks: [], contentWidthCm, masterPages: [], masterBlocks: new Map(), openBookmarks: new Set(), notes: [] }; let blocks = convertBlocks(Array.from(body.children), ctx, 'body'); if (blocks.length === 0) blocks.push({ type: 'paragraph' }); pairAlignedFrames(blocks, Math.floor(cmToPx(contentWidthCm))); @@ -599,6 +605,16 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages = blocks = wrapped; } + // The notes the walk collected, in anchor order, as the section the editor keeps at + // the document end. Empty when the file has none, so nothing is added. + if (ctx.notes.length) { + blocks.push({ type: 'noteSection', content: ctx.notes.map((n) => ({ + type: 'note', + attrs: { id: n.id, kind: n.kind, label: n.label, text: n.text }, + ...(n.content.length ? { content: n.content } : {}), + })) }); + } + // Page geometry — margins, format, and the band a header/footer reserves — is // document-wide, so it comes from the master governing most of the body rather than // from one the file merely declares (`geo` above only sized the tables). @@ -672,6 +688,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages = language, fonts, styles: collectStyleSheet(resolver, ctx), + notes: resolver.noteSettings(), warnings: [...warnings], }; } @@ -1520,6 +1537,28 @@ function convertDateTimeField(e: Element, ctx: Ctx): Node | null { return { type: 'dateTimeField', attrs: { kind, format, fixed, value: fieldValue(kind, raw) } }; } +// → the anchor node, with the note's own text collected into ctx for the +// section the document end gets. A body of several paragraphs is flattened to hard +// breaks — the editor's note holds one paragraph, as convertHfZone does for a zone. +function convertNote(e: Element, ctx: Ctx, defaults: BlockDefaults): Node | null { + const kind: NoteKind = e.getAttributeNS(NS.text, 'note-class') === 'endnote' ? 'endnote' : 'footnote'; + const citation = e.getElementsByTagNameNS(NS.text, 'note-citation')[0] ?? null; + const label = citation?.getAttributeNS(NS.text, 'label') || null; + const shown = label ?? (citation?.textContent ?? '').trim(); + const body = e.getElementsByTagNameNS(NS.text, 'note-body')[0] ?? null; + + const content: Node[] = []; + for (const para of Array.from(body?.children ?? [])) { + if (para.localName !== 'p' && para.localName !== 'h') continue; + if (content.length) content.push({ type: 'hardBreak' }); + content.push(...convertInline(para, ctx, {}, defaults)); + } + // The file's own id is unique within it, so it is the anchor's id too. + const id = e.getAttributeNS(NS.text, 'id') || `${kind}${ctx.notes.length + 1}`; + ctx.notes.push({ id, kind, label, text: shown, content }); + return { type: 'noteRef', attrs: { id, kind, text: shown } }; +} + // A number style with any calendar token is a date style; hours/minutes → time; else null. function numberStyleKind(tokens: Token[]): 'date' | 'time' | null { if (tokens.some(t => t.t === 'year' || t.t === 'month' || t.t === 'day' || t.t === 'weekday')) return 'date'; @@ -1607,9 +1646,11 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, defaults: Bl walk(e, props, href || linkHref); continue; } - case 'note': - ctx.warnings.add('Footnotes and endnotes were removed'); + case 'note': { + const note = convertNote(e, ctx, defaults); + if (note) out.push(note); continue; + } case 'bookmark-start': case 'bookmark-end': { // A named range → a bookmark mark on the text it covers. A point bookmark diff --git a/src/lib/import/styleResolver.ts b/src/lib/import/styleResolver.ts index 5f65e6f..c1c113a 100644 --- a/src/lib/import/styleResolver.ts +++ b/src/lib/import/styleResolver.ts @@ -3,6 +3,7 @@ import type { Orientation } from '../storage/pageOrientation'; import { formatFromCm, type PageFormat } from '../storage/pageFormat'; import { ODF_IMPLIED_TAB_CM } from '../storage/tabInterval'; import { normalizeLeader, type TabAlign, type TabStop } from '../editor/extensions/tabStops'; +import { DEFAULT_NOTE_SETTINGS, type NoteKind, type NoteSettings } from '../storage/noteSettings'; // Resolves ODF style indirection for the importer: producers (our export, LibreOffice, // Word) spread formatting across named/automatic styles and parent-style-name chains. @@ -490,6 +491,53 @@ export class StyleResolver { return { count, gapCm: Math.round(gapCm * 100) / 100 }; } + // per class, plus off the page + // layout. Everything the file leaves out keeps the editor's (= LibreOffice's) default. + noteSettings(): NoteSettings { + const doc = this.stylesDoc; + if (!doc) return DEFAULT_NOTE_SETTINGS; + const out: NoteSettings = { + footnote: { ...DEFAULT_NOTE_SETTINGS.footnote }, + endnote: { ...DEFAULT_NOTE_SETTINGS.endnote }, + separator: { ...DEFAULT_NOTE_SETTINGS.separator }, + }; + for (const el of Array.from(doc.getElementsByTagNameNS(NS.text, 'notes-configuration'))) { + const kind: NoteKind = el.getAttributeNS(NS.text, 'note-class') === 'endnote' ? 'endnote' : 'footnote'; + const cls = out[kind]; + const fmt = el.getAttributeNS(NS.style, 'num-format'); + if (fmt === '1' || fmt === 'a' || fmt === 'A' || fmt === 'i' || fmt === 'I') cls.numFormat = fmt; + // ODF counts from 0, the dialog from the number the first note shows. + const start = Number(el.getAttributeNS(NS.text, 'start-value')); + if (Number.isFinite(start)) cls.startAt = Math.max(1, start + 1); + const restart = el.getAttributeNS(NS.text, 'start-numbering-at'); + if (restart === 'page' || restart === 'chapter' || restart === 'document') cls.restart = restart; + if (el.getAttributeNS(NS.text, 'footnotes-position') === 'document') cls.position = 'document'; + cls.prefix = el.getAttributeNS(NS.style, 'num-prefix') ?? ''; + cls.suffix = el.getAttributeNS(NS.style, 'num-suffix') ?? ''; + const body = el.getAttributeNS(NS.text, 'default-style-name'); + if (body) cls.bodyStyle = this.displayNames.get(body) ?? body.replace(/_20_/g, ' '); + const cite = el.getAttributeNS(NS.text, 'citation-style-name'); + if (cite) cls.citationStyle = this.displayNames.get(cite) ?? cite.replace(/_20_/g, ' '); + } + const sep = this.pageLayoutEl()?.getElementsByTagNameNS(NS.style, 'footnote-sep')[0]; + if (sep) { + const num = (v: string | null) => (v == null ? null : lengthToCm(v)); + const width = num(sep.getAttributeNS(NS.style, 'width')); + if (width != null) out.separator.weightPt = Math.round((width / 2.54) * 72 * 100) / 100; + const above = num(sep.getAttributeNS(NS.style, 'distance-before-sep')); + if (above != null) out.separator.spaceAboveCm = Math.round(above * 1000) / 1000; + const below = num(sep.getAttributeNS(NS.style, 'distance-after-sep')); + if (below != null) out.separator.spaceBelowCm = Math.round(below * 1000) / 1000; + const rel = parseFloat(sep.getAttributeNS(NS.style, 'rel-width') ?? ''); + if (Number.isFinite(rel)) out.separator.relWidthPercent = rel; + const align = sep.getAttributeNS(NS.style, 'adjustment'); + if (align === 'left' || align === 'center' || align === 'right') out.separator.align = align; + const color = sep.getAttributeNS(NS.style, 'color'); + if (color && /^#[0-9a-fA-F]{6}$/.test(color)) out.separator.color = color; + } + return out; + } + // The master page governing the document (prefer "Standard", else the first), or a // named one — a section past the first points at its own (style:master-page-name). private masterPageEl(name: string | null = null): Element | null { diff --git a/tests/lo-roundtrip.test.ts b/tests/lo-roundtrip.test.ts index 795cd7f..7885d9d 100644 --- a/tests/lo-roundtrip.test.ts +++ b/tests/lo-roundtrip.test.ts @@ -326,4 +326,40 @@ describe.skipIf(!SOFFICE)('LibreOffice round-trip (needs soffice on PATH)', () = // LibreOffice re-evaluates the fields on load, so the shown values are its own. check('LO bookmarks: the text reference resolves to the caption', refs[0]?.attrs?.text === 'Figure 1', refs[0]?.attrs?.text); }); + + it('survives a `soffice` re-save of footnotes and endnotes', { timeout: 180000 }, async () => { + const doc: N = { type: 'doc', content: [ + P(null, T('Body one'), { type: 'noteRef', attrs: { id: 'a', kind: 'footnote', text: '1' } }, T(' and on.')), + P(null, T('Body two'), { type: 'noteRef', attrs: { id: 'b', kind: 'endnote', text: 'i' } }, T(' ends.')), + { type: 'noteSection', content: [ + { type: 'note', attrs: { id: 'a', kind: 'footnote', label: null, text: '1' }, + content: [T('The footnote, with '), T('bold', { type: 'bold' }), T(' inside.')] }, + { type: 'note', attrs: { id: 'b', kind: 'endnote', label: null, text: 'i' }, + content: [T('The endnote.')] }, + ] }, + ] }; + mkdirSync('/tmp/lo-rt', { recursive: true }); + writeFileSync('/tmp/lo-rt/note.odt', await buildOdt(doc, margins, 'portrait')); + execSync('soffice --headless --convert-to odt --outdir /tmp/lo-rt/noteout /tmp/lo-rt/note.odt', { stdio: 'pipe', timeout: 120000 }); + const resaved = new Uint8Array(readFileSync('/tmp/lo-rt/noteout/note.odt')); + const files = unzipSync(resaved); + const xml = strFromU8(files['content.xml']); + + // LibreOffice drops a note it cannot parse, so surviving its re-save is the proof + // that the anchor, the citation and the body are where the format wants them. + check('LO notes: both classes survive', /text:note-class="footnote"/.test(xml) && /text:note-class="endnote"/.test(xml), xml.match(/]*>/g)); + check('LO notes: the body keeps the Footnote style', /[\s\S]{0,60}/g)); + const styles = strFromU8(files['styles.xml']); + check('LO notes: it keeps our numbering configuration', /text:note-class="endnote"[^>]*style:num-format="i"/.test(styles), styles.match(/]*>/g)); + + const res = importOdt(resaved); + 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('LO notes: both anchors come back', refs.length === 2, refs.map((r) => r.attrs)); + check('LO notes: their classes survive', refs.map((r) => r.attrs.kind).join(',') === 'footnote,endnote', refs.map((r) => r.attrs.kind)); + check('LO notes: each anchor still 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('LO notes: the note text comes back', JSON.stringify(section).includes('The footnote, with'), JSON.stringify(section)?.slice(0, 200)); + check('LO notes: bold inside the note survives', JSON.stringify(section).includes('"bold"'), JSON.stringify(section)?.slice(0, 300)); + }); }); diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts index 3a3d09d..6bf8787 100644 --- a/tests/roundtrip.test.ts +++ b/tests/roundtrip.test.ts @@ -401,6 +401,53 @@ describe('Leg 1a2b: formulas (embedded ODF formula objects)', () => { }); }); +describe('Leg 1a2c: footnotes and endnotes', () => { + const REF = (id: string, kind: string, text: string): N => + ({ type: 'noteRef', attrs: { id, kind, text } }); + const notesDoc = (): N => ({ type: 'doc', content: [ + P(null, T('Body one'), REF('a', 'footnote', '1'), T(' and on.')), + P(null, T('Body two'), REF('b', 'endnote', 'i'), T(' ends.')), + { type: 'noteSection', content: [ + { type: 'note', attrs: { id: 'a', kind: 'footnote', label: null, text: '1' }, + content: [T('The footnote, with '), T('bold', { type: 'bold' }), T(' inside.')] }, + { type: 'note', attrs: { id: 'b', kind: 'endnote', label: null, text: 'i' }, + content: [T('The endnote.')] }, + ] }, + ] }); + + it('writes text:note at the anchor and reads it back', async () => { + const bytes = await buildOdt(notesDoc(), margins, 'portrait'); + const files = unzipSync(bytes); + const content = strFromU8(files['content.xml']); + check('the footnote sits inside the body paragraph', /Body one/.test(content), content.match(/]*>/g)); + check('the endnote carries its own class', /text:note-class="endnote"/.test(content), content.match(/]*>/g)); + check('each note keeps its citation', /1<\/text:note-citation>/.test(content) && /i<\/text:note-citation>/.test(content), content.match(/]*>[^<]*/.test(content), content.match(/[\s\S]{0,80}/g)); + check('run formatting inside the note survives', /The footnote, with ]*>bold<\/text:span>/.test(content), content.match(/The footnote[^<]*(<[^>]*>[^<]*){0,4}/g)); + check('no hoisted note paragraph is left behind', !content.includes('\uE017'), content.match(/.\uE017./g)); + + const styles = strFromU8(files['styles.xml']); + check('both note classes are configured', /]*>/g)); + check('footnotes are numbered document-wide, as LibreOffice does', /text:footnotes-position="page" text:start-numbering-at="document"/.test(styles), styles.match(/]*>/g)); + check('endnotes keep their roman format', /text:note-class="endnote"[^>]*style:num-format="i"/.test(styles), styles.match(/]*>/g)); + check('the Footnote paragraph style is written out', /]*style:rel-width="25%"/.test(styles), styles.match(/]*>/g)); + + const res = importOdt(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('both anchors come back', refs.length === 2, refs.map((r) => r.attrs)); + check('their classes survive', refs[0]?.attrs?.kind === 'footnote' && refs[1]?.attrs?.kind === 'endnote', refs.map((r) => r.attrs?.kind)); + check('a note section is rebuilt', (section?.content ?? []).length === 2, section?.content?.length); + check('each anchor points at its own note', refs[0]?.attrs?.id === section?.content?.[0]?.attrs?.id && refs[1]?.attrs?.id === section?.content?.[1]?.attrs?.id, [refs.map((r) => r.attrs?.id), section?.content?.map((n: N) => n.attrs?.id)]); + check('the note text comes back', JSON.stringify(section).includes('The footnote, with'), JSON.stringify(section)?.slice(0, 200)); + check('bold inside the note survives', JSON.stringify(section).includes('"bold"'), JSON.stringify(section)?.slice(0, 300)); + 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); + }); +}); + describe('Leg 1a3: continued ordered-list start value', () => { it('round-trips a start > 1 (odf-kit drops it) via text:start-value', async () => { const olist = (start: number | null, t: string): N => ({ @@ -796,9 +843,10 @@ describe('Leg 2: foreign (LibreOffice/Word-style) .odt → importOdt', () => { check('foreign: Mono style keeps its size', monoStyle?.text.fontSizePt === 10, monoStyle); check('foreign: no direct formatting on the block', !mono.content![0].marks, mono.content![0]); - check('foreign: image imported, footnote dropped, text kept', - c[4].content!.length === 2 && c[4].content![0].text === 'img:' && - c[4].content![1].type === 'image' && c[4].content![1].attrs?.src?.startsWith('data:image/png;base64,'), c[4]); + check('foreign: image and footnote anchor imported, text kept', + c[4].content!.length === 3 && c[4].content![0].text === 'img:' && + c[4].content![1].type === 'image' && c[4].content![1].attrs?.src?.startsWith('data:image/png;base64,') && + c[4].content![2].type === 'noteRef', c[4]); const list = c[5]; check('foreign: list → lower-roman-paren, start 3', list.type === 'orderedList' && list.attrs?.listStyleType === 'lower-roman-paren' && list.attrs?.start === 3, list.attrs); @@ -817,9 +865,10 @@ describe('Leg 2: foreign (LibreOffice/Word-style) .odt → importOdt', () => { check('foreign: spanned cell colwidth [500,1000]', JSON.stringify(row.content![0].attrs?.colwidth) === JSON.stringify([500, 1000]), row.content![0].attrs); check('foreign: min-row-height 2cm → 76px', row.attrs?.rowHeight === 76, row.attrs); - const expectWarn = ['Footnotes and endnotes were removed']; - check('foreign: warnings reported', expectWarn.every(w => f.warnings.includes(w)), f.warnings); + const notes = c.find((n: N) => n.type === 'noteSection'); + check('foreign: the note body follows at the document end', notes?.content?.[0]?.content?.[0]?.text === 'note body', notes); check('foreign: no hyperlink warning (now round-tripped)', !f.warnings.includes('Hyperlinks were converted to plain text'), f.warnings); + check('foreign: no note warning (now round-tripped)', !f.warnings.some((w: string) => /[Ff]ootnote/.test(w)), f.warnings); }); it('resolves a drawn shape\'s fill/stroke inherited from the default graphic style', () => {