feat: notes round-trip through ODF
Anchors become an FNT sentinel and each note is hoisted to a top-level paragraph, so its text rides every existing pass — marks, character styles, links, bookmarks and formulas included; applyNotes cuts the region back out into <text:note> at the anchor. styles.xml gains LibreOffice's own Footnote/ Endnote styles, the notes-configuration per class and the separator, and the resolver reads all of it back. The importer builds the section instead of warning that notes were dropped.
This commit is contained in:
1 parent
f07e020f88
commit
5f0f5e7457
10 files changed
+370
-20
No files matched your search
+7
-3
@@ -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<typeof buildOdt>[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<typeof buildOdt>[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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+173
-6
@@ -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 <text:chapter> 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 <text:note> at the anchor. U+E017.
|
||||
const FNT = '\uE017';
|
||||
|
||||
const EXT_BY_MIME: Record<string, string> = {
|
||||
'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<string, { content: TiptapNode[]; label: string | null }>();
|
||||
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<string, Uint8Array>): 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<string>, usedTables: Set<string> = 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<string>, usedTables: Set<string> = 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('<office:styles>', '<office:styles>'
|
||||
+ `<style:default-style style:family="paragraph"><style:paragraph-properties style:tab-stop-distance="${round3(tabIntervalCm)}cm"/></style:default-style>`);
|
||||
+ `<style:default-style style:family="paragraph"><style:paragraph-properties style:tab-stop-distance="${round3(tabIntervalCm)}cm"/></style:default-style>`
|
||||
+ notesStyles() + notesConfiguration(notes));
|
||||
|
||||
// The separator belongs to the page layout, beside the margins it is measured against.
|
||||
styles = styles.replace(
|
||||
/<style:page-layout-properties\b[^>]*\/>/,
|
||||
(props) => `${props.slice(0, -2)}>${footnoteSepXml(notes.separator)}</style:page-layout-properties>`,
|
||||
);
|
||||
|
||||
// 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) =>
|
||||
`<style:style style:name="${name}" style:family="paragraph" style:parent-style-name="Standard" style:class="extra">`
|
||||
+ `<style:paragraph-properties fo:margin-left="${NOTE_INDENT_CM}cm" fo:text-indent="-${NOTE_INDENT_CM}cm" style:auto-text-indent="false"/>`
|
||||
+ `<style:text-properties fo:font-size="${NOTE_FONT_SIZE_PT}pt" style:font-size-asian="${NOTE_FONT_SIZE_PT}pt" style:font-size-complex="${NOTE_FONT_SIZE_PT}pt"/>`
|
||||
+ '</style:style>';
|
||||
const anchor = (name: string, display: string) =>
|
||||
`<style:style style:name="${name}" style:display-name="${display}" style:family="text">`
|
||||
+ '<style:text-properties style:text-position="super 58%"/></style:style>';
|
||||
return para('Footnote') + para('Endnote')
|
||||
+ `<style:style style:name="Footnote_20_Symbol" style:display-name="Footnote Symbol" style:family="text"/>`
|
||||
+ `<style:style style:name="Endnote_20_Symbol" style:display-name="Endnote Symbol" style:family="text"/>`
|
||||
+ anchor('Footnote_20_anchor', 'Footnote anchor')
|
||||
+ anchor('Endnote_20_anchor', 'Endnote anchor');
|
||||
}
|
||||
|
||||
// <text:notes-configuration> 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 `<text:notes-configuration text:note-class="${kind}"`
|
||||
+ ` text:citation-style-name="${odfStyleName(s.citationStyle)}"`
|
||||
+ ` text:citation-body-style-name="${foot ? 'Footnote_20_anchor' : 'Endnote_20_anchor'}"`
|
||||
+ ` text:default-style-name="${odfStyleName(s.bodyStyle)}"`
|
||||
+ ` style:num-format="${s.numFormat}"${prefix}${suffix}`
|
||||
+ ` text:start-value="${Math.max(0, s.startAt - 1)}"`
|
||||
+ (foot ? ` text:footnotes-position="${s.position}" text:start-numbering-at="${s.restart}"` : '')
|
||||
+ '/>';
|
||||
};
|
||||
return one('footnote') + one('endnote');
|
||||
}
|
||||
|
||||
// <style:footnote-sep> 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 `<style:footnote-sep style:width="${round3((sep.weightPt / 72) * 2.54)}cm"`
|
||||
+ ` style:distance-before-sep="${round3(sep.spaceAboveCm)}cm"`
|
||||
+ ` style:distance-after-sep="${round3(sep.spaceBelowCm)}cm"`
|
||||
+ ` style:line-style="solid" style:adjustment="${sep.align}"`
|
||||
+ ` style:rel-width="${round3(sep.relWidthPercent)}%" style:color="${sep.color}"/>`;
|
||||
}
|
||||
|
||||
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
|
||||
// <text:note> 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('<text:p', at);
|
||||
const end = content.indexOf('</text:p>', at);
|
||||
if (start < 0 || end < 0) return '';
|
||||
const block = content.slice(start, end + '</text:p>'.length);
|
||||
content = content.slice(0, start) + content.slice(end + '</text:p>'.length);
|
||||
return block
|
||||
.replace(/^<text:p\b[^>]*>/, '')
|
||||
.replace(/<\/text:p>$/, '')
|
||||
.replace(open, '')
|
||||
.replace(/<text:span\b[^>]*\/>|<text:span\b[^>]*><\/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 `<text:note text:id="${endnote ? 'edn' : 'ftn'}${i + 1}" text:note-class="${note.kind}">`
|
||||
+ `<text:note-citation${label}>${escapeXml(note.citation)}</text:note-citation>`
|
||||
+ `<text:note-body><text:p text:style-name="${endnote ? 'Endnote' : 'Footnote'}">${bodies[i]}</text:p></text:note-body>`
|
||||
+ '</text:note>';
|
||||
});
|
||||
// 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<Uint8Array> {
|
||||
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<Uint8Array> {
|
||||
// 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 <text:note>.
|
||||
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.
|
||||
|
||||
@@ -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}`,
|
||||
},
|
||||
|
||||
@@ -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}`,
|
||||
},
|
||||
|
||||
@@ -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); })(),
|
||||
|
||||
+44
-3
@@ -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<string>;
|
||||
// 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) } };
|
||||
}
|
||||
|
||||
// <text:note> → 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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
// <text:notes-configuration> per class, plus <style:footnote-sep> 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 {
|
||||
|
||||
@@ -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(/<text:note [^>]*>/g));
|
||||
check('LO notes: the body keeps the Footnote style', /<text:note-body><text:p text:style-name="Footnote"/.test(xml), xml.match(/<text:note-body>[\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(/<text:notes-configuration[^>]*>/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));
|
||||
});
|
||||
});
|
||||
+54
-5
@@ -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<text:note text:id="ftn1" text:note-class="footnote">/.test(content), content.match(/<text:note[^>]*>/g));
|
||||
check('the endnote carries its own class', /text:note-class="endnote"/.test(content), content.match(/<text:note[^>]*>/g));
|
||||
check('each note keeps its citation', /<text:note-citation>1<\/text:note-citation>/.test(content) && /<text:note-citation>i<\/text:note-citation>/.test(content), content.match(/<text:note-citation[^>]*>[^<]*</g));
|
||||
check('the note body is a Footnote-styled paragraph', /<text:note-body><text:p text:style-name="Footnote">/.test(content), content.match(/<text:note-body>[\s\S]{0,80}/g));
|
||||
check('run formatting inside the note survives', /The footnote, with <text:span[^>]*>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', /<text:notes-configuration text:note-class="footnote"/.test(styles) && /<text:notes-configuration text:note-class="endnote"/.test(styles), styles.match(/<text:notes-configuration[^>]*>/g));
|
||||
check('footnotes are numbered document-wide, as LibreOffice does', /text:footnotes-position="page" text:start-numbering-at="document"/.test(styles), styles.match(/<text:notes-configuration text:note-class="footnote"[^>]*>/g));
|
||||
check('endnotes keep their roman format', /text:note-class="endnote"[^>]*style:num-format="i"/.test(styles), styles.match(/<text:notes-configuration text:note-class="endnote"[^>]*>/g));
|
||||
check('the Footnote paragraph style is written out', /<style:style style:name="Footnote" style:family="paragraph"/.test(styles), null);
|
||||
check('the separator rides the page layout', /<style:footnote-sep [^>]*style:rel-width="25%"/.test(styles), styles.match(/<style:footnote-sep[^>]*>/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', () => {
|
||||
|
||||
Reference in new issue
Block a user