Files
edentext/src/lib/export/odt.ts
T
2026-06-13 15:20:24 +02:00

1062 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Editor } from '@tiptap/core';
import { tiptapToOdt, type TiptapNode, type TextFormatting, type OdtDocument, type ParagraphBuilder, type TableBuilder, type RowBuilder, type CellBuilder, type HeaderFooterBuilder } from 'odf-kit';
import { unzipSync, zipSync, strFromU8, strToU8 } from 'fflate';
import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
import { HF_DISTANCE_CM, hfIsEmpty, type HfDoc } from '../storage/headerFooter';
import { DEFAULT_ORDERED_TYPE, orderedTypeDef } from '../editor/orderedListTypes';
type AlignValue = 'left' | 'center' | 'right' | 'justify';
// tiptapToOdt ignores paragraph/heading node attrs (including lineHeight and
// textAlign). We rename nodes that carry either attr to custom types and
// handle them via unknownNodeHandler, which has access to the full OdtDocument API.
const CUST_P = '__cust_p__';
const CUST_H = '__cust_h__';
// Tables are renamed to this so odf-kit routes them to unknownNodeHandler. Its
// native path emits cells with no border (invisible in LibreOffice/Word), so we
// build the table ourselves with an explicit border.
const CUST_TABLE = '__cust_table__';
// Synthetic first node: routes header/footer emission through unknownNodeHandler,
// which is the only hook with access to the OdtDocument (setHeader/setFooter).
const CUST_HF = '__cust_hf__';
// Table export styling. Values mirror the editor's table CSS (src/styles/editor.css)
// so the .odt matches the on-screen preview.
const TABLE_BORDER = '0.5pt solid #000000';
const CELL_PADDING = '0.1cm'; // must match td/th padding in editor.css
// odf-kit emits this as the document's default font (Standard style). The editor
// renders the bundled, metric-identical Liberation Serif on screen.
const ODFKIT_DEFAULT_FONT = 'Liberation Serif';
// …but we declare Times New Roman in the .odt: it is metric-identical to
// Liberation Serif, so LibreOffice (substitutes TNR→Liberation Serif) and Word
// (has the real TNR) both render with the same metrics as the editor.
const EXPORT_FONT = 'Times New Roman';
const DEFAULT_LINE_HEIGHT = 1; // must match line-height multiplier default in ToolbarExpanded.svelte
// Sentinel between a cell's blocks (and list items) so the single <text:p> odf-kit
// emits per cell can be split back into real blocks in applyCellBlocks. A private-use
// char: never in user text, and passes odf-kit's XML escaper (only & < >) untouched.
const SEG = '';
// Sentinel for hardBreak nodes: replaceHardBreaks turns them into text so every
// odf-kit path (plain paragraphs, list items, cells) carries them; applyLineBreaks
// swaps the char for <text:line-break/> in content.xml. U+E001 (SEG is U+E000).
const LBR = '';
// Sentinel wrapping the page-count digits in header/footer runs; applyHfPostProcess
// rewrites it to <text:page-count> in styles.xml. U+E002 (SEG/LBR are E000/E001).
const PGC = '';
// Automatic list styles minted for in-cell lists (see applyCellBlocks). odf-kit's
// CellBuilder is run-based, so a list inside a cell can't reference a list style it
// generates for top-level lists — we inject our own, mirroring its buildListStyle.
const CELL_LIST_BULLET_STYLE = 'TblListBullet';
const CELL_LIST_NUMBER_STYLE = 'TblListNumber';
// Heading sizes/margins shown in the editor (editor.css); odf-kit's Heading_20_N
// defaults are larger, so we rewrite them on export. Margins are the editor's em
// values (top 1.5em, bottom 0.5em) resolved against each heading's font size, in cm.
// Exported: import/odt.ts uses these as the defaults to suppress on re-import.
export const HEADING_STYLE_OVERRIDES: { name: string; fontSize: string; marginTop: string; marginBottom: string }[] = [
{ name: 'Heading_20_1', fontSize: '20pt', marginTop: '1.058cm', marginBottom: '0.353cm' },
{ name: 'Heading_20_2', fontSize: '16pt', marginTop: '0.847cm', marginBottom: '0.282cm' },
{ name: 'Heading_20_3', fontSize: '14pt', marginTop: '0.741cm', marginBottom: '0.247cm' },
];
function hasCustomAttrs(attrs: TiptapNode['attrs']): boolean {
if (!attrs) return false;
if (attrs.lineHeight) return true;
if (attrs.spaceBefore != null) return true;
if (attrs.spaceAfter != null) return true;
const ta = attrs.textAlign;
return ta === 'left' || ta === 'center' || ta === 'right' || ta === 'justify';
}
// odf-kit's native run handling ignores the fontWeight textStyle attr (used to
// un-bold heading text), so such blocks must go through applyRuns via CUST_P/_H.
function hasFontWeightRun(content: TiptapNode['content']): boolean {
return !!content?.some(c =>
c.type === 'text' && c.marks?.some(m => m.type === 'textStyle' && m.attrs?.fontWeight),
);
}
function injectCustomTypes(node: TiptapNode, inContainer = false): TiptapNode {
// Rename tables so odf-kit routes them to our unknownNodeHandler (see
// CUST_TABLE). Recurse with inContainer=true so the cell paragraphs are NOT
// renamed — our table handler walks them by type === "paragraph".
if (node.type === 'table') {
return {
...node,
type: CUST_TABLE,
content: node.content?.map(c => injectCustomTypes(c, true)),
};
}
// Don't rename paragraphs inside list items or table cells — tiptapToOdt's list
// builder (and our table handler) walk them by type === "paragraph"; renaming
// breaks that.
if (!inContainer && (hasCustomAttrs(node.attrs) || hasFontWeightRun(node.content))) {
if (node.type === 'paragraph') return { ...node, type: CUST_P };
if (node.type === 'heading') return { ...node, type: CUST_H };
}
if (node.content?.length) {
const childInContainer = inContainer
|| node.type === 'bulletList'
|| node.type === 'orderedList'
|| node.type === 'listItem';
return { ...node, content: node.content.map(c => injectCustomTypes(c, childInContainer)) };
}
return node;
}
// Turn hardBreak nodes into LBR-sentinel text nodes so they ride through every
// odf-kit serialization path as plain run text; applyLineBreaks rewrites the char
// to <text:line-break/> in content.xml afterwards.
function replaceHardBreaks(node: TiptapNode): TiptapNode {
if (!node.content?.length) return node;
return {
...node,
content: node.content.map(c => c.type === 'hardBreak' ? { type: 'text', text: LBR } : replaceHardBreaks(c)),
};
}
// Mirror odf-kit's normalizeLineHeight (content.js): a number is a multiplier
// (1.5 → "150%"); a string with a unit passes through ("18pt" → "18pt").
function normalizeLineHeight(lh: number | string): string {
return typeof lh === 'number' ? `${Math.round(lh * 100)}%` : lh;
}
// Paragraph property overrides for the exported .odt, for both list-item and
// table-cell paragraphs (odf-kit's List/TableBuilder support no per-paragraph
// alignment/spacing/line-height), injected as automatic styles in post-processing.
type ParaStyle = {
align: AlignValue | null;
spaceBefore: number | null;
spaceAfter: number | null;
lineHeight: number | string | null;
};
function paraStyleIsEmpty(s: ParaStyle): boolean {
return s.align === null && s.spaceBefore === null && s.spaceAfter === null && s.lineHeight === null;
}
// Extract the overridable paragraph properties from a node's attrs. Left
// alignment yields null (it's the Standard-style default, so no override needed).
function paraStyleFromAttrs(attrs: TiptapNode['attrs']): ParaStyle {
const ta = attrs?.textAlign as AlignValue | undefined;
const sb = attrs?.spaceBefore;
const sa = attrs?.spaceAfter;
const lh = attrs?.lineHeight;
let lineHeight: number | string | null = null;
if (lh != null) {
const lhNum = parseFloat(String(lh));
lineHeight = isNaN(lhNum) ? String(lh) : lhNum;
}
return {
align: ta === 'center' || ta === 'right' || ta === 'justify' ? ta : null,
spaceBefore: typeof sb === 'number' ? sb : null,
spaceAfter: typeof sa === 'number' ? sa : null,
lineHeight,
};
}
// fo:* paragraph-properties attribute strings for a ParaStyle override.
function paraStyleProps(style: ParaStyle): string[] {
const props: string[] = [];
if (style.align) props.push(`fo:text-align="${style.align}"`);
if (style.spaceBefore != null) props.push(`fo:margin-top="${style.spaceBefore}pt"`);
if (style.spaceAfter != null) props.push(`fo:margin-bottom="${style.spaceAfter}pt"`);
if (style.lineHeight != null) props.push(`fo:line-height="${normalizeLineHeight(style.lineHeight)}"`);
return props;
}
// Per-cell block descriptors, built in exportTable and consumed in document order by
// applyCellBlocks to rebuild real <text:h>/<text:p>/<text:list> from the SEG-segmented
// <text:p> odf-kit emits. Each paragraph/heading/list item is one segment, in DFS order.
type CellListItem = { style: ParaStyle; nested: CellListBlock | null };
// numFormat/numSuffix carry the ordered-list numbering style (orderedTypeDef);
// for bullet lists they hold the decimal defaults and are unused.
type CellListBlock = { kind: 'list'; ordered: boolean; start: number | null; numFormat: string; numSuffix: string; items: CellListItem[] };
type CellBlock =
| { kind: 'paragraph'; style: ParaStyle }
| { kind: 'heading'; level: number; style: ParaStyle }
| CellListBlock;
// Collect the alignment + paragraph spacing of each listItem's first paragraph,
// in DFS order — matching the order that odf-kit emits <text:list-item> elements
// into content.xml. Items without overrides yield an all-null descriptor.
function collectListItemStyles(node: TiptapNode, result: ParaStyle[]): void {
if (node.type === 'listItem') {
const firstPara = node.content?.find(c => c.type === 'paragraph');
result.push(paraStyleFromAttrs(firstPara?.attrs));
// Recurse into nested lists only (their listItems extend the DFS sequence).
for (const child of node.content ?? []) {
if (child.type === 'bulletList' || child.type === 'orderedList') {
collectListItemStyles(child, result);
}
}
return;
}
for (const child of node.content ?? []) {
collectListItemStyles(child, result);
}
}
// Collect each table row's explicit height (px → cm), in DFS order matching odf-kit's
// <table:table-row> emission. rowHeight is unscaled px @96dpi (tableRow.ts), converted
// to cm (px × 2.54 / 96). Rows without an explicit height yield null.
function collectTableRowHeights(node: TiptapNode, result: (string | null)[]): void {
if (node.type === 'table') {
for (const row of node.content ?? []) {
if (row.type !== 'tableRow') continue;
const h = row.attrs?.rowHeight;
if (typeof h === 'number' && h > 0) {
const cm = Math.round(((h * 2.54) / 96) * 1000) / 1000;
result.push(`${cm}cm`);
} else {
result.push(null);
}
}
return;
}
for (const child of node.content ?? []) {
collectTableRowHeights(child, result);
}
}
// odf-kit's TableBuilder has no row-height option, so post-process content.xml: each
// <table:table-row> with a height gets an automatic style with style:min-row-height
// (a minimum) + use-optimal-row-height="false". One heights[] entry per row, in order.
function applyTableRowHeights(odtBytes: Uint8Array, heights: (string | null)[]): Uint8Array {
if (heights.every(h => h === null)) return odtBytes;
const files = unzipSync(odtBytes);
const contentBytes = files['content.xml'];
if (!contentBytes) return odtBytes;
let content = strFromU8(contentBytes);
const styleDefs: { name: string; height: string }[] = [];
const nameByHeight = new Map<string, string>();
let counter = 0;
let idx = 0;
content = content.replace(/<table:table-row\b([^>]*)>/g, (match, attrs: string) => {
const height = heights[idx++];
if (!height) return match;
if (/\btable:style-name=/.test(attrs)) return match;
let name = nameByHeight.get(height);
if (!name) {
counter++;
name = `TRH${counter}`;
nameByHeight.set(height, name);
styleDefs.push({ name, height });
}
return `<table:table-row table:style-name="${name}"${attrs}>`;
});
if (styleDefs.length === 0) return odtBytes;
const newStyles = styleDefs.map(({ name, height }) =>
`<style:style style:name="${name}" style:family="table-row"><style:table-row-properties style:min-row-height="${height}" style:use-optimal-row-height="false"/></style:style>`,
).join('\n');
content = content.replace('</office:automatic-styles>', `${newStyles}\n</office:automatic-styles>`);
files['content.xml'] = strToU8(content);
return rezipOdt(files);
}
// odf-kit's ListBuilder has no per-item paragraph options, so list-item paragraphs all
// emit as List_20_Bullet/Number. Rewrite content.xml to point those at automatic styles
// that inherit the list style and add fo:text-align / fo:margin-top / fo:margin-bottom.
function applyListItemStyles(odtBytes: Uint8Array, styles: ParaStyle[]): Uint8Array {
if (styles.every(paraStyleIsEmpty)) return odtBytes;
const files = unzipSync(odtBytes);
const contentBytes = files['content.xml'];
if (!contentBytes) return odtBytes;
let content = strFromU8(contentBytes);
const styleDefs: { name: string; parent: string; style: ParaStyle }[] = [];
const nameByKey = new Map<string, string>();
let counter = 0;
let idx = 0;
// Each text:list-item directly contains the item's paragraph as its first child.
content = content.replace(
/(<text:list-item>\s*<text:p text:style-name=")(List_20_Bullet|List_20_Number)(")/g,
(_match, pre, parentStyle, post) => {
const style = styles[idx++];
if (!style || paraStyleIsEmpty(style)) return `${pre}${parentStyle}${post}`;
const key = `${parentStyle}|${style.align}|${style.spaceBefore}|${style.spaceAfter}|${style.lineHeight}`;
let name = nameByKey.get(key);
if (!name) {
counter++;
name = `LP${counter}`;
nameByKey.set(key, name);
styleDefs.push({ name, parent: parentStyle, style });
}
return `${pre}${name}${post}`;
},
);
if (styleDefs.length === 0) return odtBytes;
const newStyles = styleDefs.map(({ name, parent, style }) =>
`<style:style style:name="${name}" style:family="paragraph" style:parent-style-name="${parent}"><style:paragraph-properties ${paraStyleProps(style).join(' ')}/></style:style>`,
).join('\n');
content = content.replace('</office:automatic-styles>', `${newStyles}\n</office:automatic-styles>`);
files['content.xml'] = strToU8(content);
return rezipOdt(files);
}
// odf-kit always emits ordered lists as "1." (num-format="1" num-suffix="."). This
// rewrites the matching L# list style to the user's chosen format; formats[] has one
// entry per top-level list in odf-kit's order (null for bullets/default 'decimal').
// Limitation: the chosen format applies to every nesting level (odf-kit emits one
// L# style per top-level list), so a nested list of a different type inherits it.
type OrderedFmt = { numFormat: string; numSuffix: string };
function collectOrderedListFormats(node: TiptapNode, result: (OrderedFmt | null)[]): void {
for (const child of node.content ?? []) {
if (child.type === 'bulletList') {
result.push(null);
} else if (child.type === 'orderedList') {
const def = orderedTypeDef(child.attrs?.listStyleType as string | undefined);
result.push(def.key === DEFAULT_ORDERED_TYPE ? null : { numFormat: def.numFormat, numSuffix: def.numSuffix });
}
}
}
function applyOrderedListFormats(odtBytes: Uint8Array, formats: (OrderedFmt | null)[]): Uint8Array {
if (formats.every(f => f === null)) return odtBytes;
const files = unzipSync(odtBytes);
const contentBytes = files['content.xml'];
if (!contentBytes) return odtBytes;
let content = strFromU8(contentBytes);
formats.forEach((fmt, i) => {
if (!fmt) return;
// Rewrite only inside this list's own <text:list-style> block (all 6 levels).
const re = new RegExp(`(<text:list-style style:name="L${i + 1}">)([\\s\\S]*?)(</text:list-style>)`);
content = content.replace(re, (_m, open: string, body: string, close: string) =>
open +
body
.replace(/style:num-format="1"/g, `style:num-format="${fmt.numFormat}"`)
.replace(/style:num-suffix="\."/g, `style:num-suffix="${fmt.numSuffix}"`) +
close,
);
});
files['content.xml'] = strToU8(content);
return rezipOdt(files);
}
// odf-kit emits nested lists as bare <text:list> sharing the top-level L# style,
// so a nested list of a *different* kind/format (e.g. ordered inside bullets)
// loses its type. Give those nested lists their own minted 6-level list style —
// the same pattern applyCellBlocks uses for in-cell lists.
type ListDef = { ordered: boolean; numFormat: string; numSuffix: string };
function listDefOf(node: TiptapNode): ListDef {
const ordered = node.type === 'orderedList';
const def = ordered ? orderedTypeDef(node.attrs?.listStyleType as string | undefined) : null;
return { ordered, numFormat: def?.numFormat ?? '1', numSuffix: def?.numSuffix ?? '.' };
}
// One entry per nested <text:list> in DFS order (null = inherits its governing
// style correctly). Only walks top-level lists — cell lists never emit bare tags.
function collectNestedListFixes(doc: TiptapNode, result: (ListDef | null)[]): void {
const walkList = (list: TiptapNode, governing: ListDef, isTop: boolean) => {
let gov = governing;
if (!isTop) {
const def = listDefOf(list);
const differs = def.ordered !== governing.ordered
|| (def.ordered && (def.numFormat !== governing.numFormat || def.numSuffix !== governing.numSuffix));
result.push(differs ? def : null);
if (differs) gov = def; // restyled list governs its own descendants
}
for (const item of list.content ?? []) {
if (item.type !== 'listItem') continue;
for (const child of item.content ?? []) {
if (child.type === 'bulletList' || child.type === 'orderedList') walkList(child, gov, false);
}
}
};
for (const child of doc.content ?? []) {
if (child.type === 'bulletList' || child.type === 'orderedList') walkList(child, listDefOf(child), true);
}
}
function applyNestedListTypes(odtBytes: Uint8Array, fixes: (ListDef | null)[]): Uint8Array {
if (fixes.every(f => f === null)) return odtBytes;
const files = unzipSync(odtBytes);
const contentBytes = files['content.xml'];
if (!contentBytes) return odtBytes;
let content = strFromU8(contentBytes);
const minted: string[] = [];
const nameByKey = new Map<string, string>();
const styleFor = (fix: ListDef): string => {
const key = `${fix.ordered}|${fix.numFormat}|${fix.numSuffix}`;
let name = nameByKey.get(key);
if (!name) {
name = `NL${nameByKey.size + 1}`;
nameByKey.set(key, name);
minted.push(buildCellListStyle(name, fix.ordered, fix.numFormat, fix.numSuffix));
}
return name;
};
// Bare <text:list> tags are exactly odf-kit's nested lists, in DFS order.
let idx = 0;
content = content.replace(/<text:list>/g, (match) => {
const fix = fixes[idx++];
return fix ? `<text:list text:style-name="${styleFor(fix)}">` : match;
});
if (minted.length) {
content = content.replace('</office:automatic-styles>', `${minted.join('\n')}\n</office:automatic-styles>`);
}
files['content.xml'] = strToU8(content);
return rezipOdt(files);
}
// Mirror odf-kit's buildListStyle (content.js) for the automatic list style our
// in-cell lists reference. Six nesting levels with identical bullet chars /
// indents / numbering, so cell lists match the editor's top-level lists.
const CELL_BULLET_CHARS = ['•', '◦', '▪', '▸', '–', '·'];
function buildCellListStyle(styleName: string, ordered: boolean, numFormat = '1', numSuffix = '.'): string {
let levels = '';
for (let level = 1; level <= 6; level++) {
const indent = level * 0.635;
const marginLeft = `${(indent * 2).toFixed(3)}cm`;
const textIndent = `-${indent.toFixed(3)}cm`;
const labelAlign = `<style:list-level-properties text:list-level-position-and-space-mode="label-alignment"><style:list-level-label-alignment text:label-followed-by="listtab" text:list-tab-stop-position="${marginLeft}" fo:text-indent="${textIndent}" fo:margin-left="${marginLeft}"/></style:list-level-properties>`;
if (ordered) {
levels += `<text:list-level-style-number text:level="${level}" style:num-format="${numFormat}" style:num-suffix="${numSuffix}">${labelAlign}</text:list-level-style-number>`;
} else {
const ch = CELL_BULLET_CHARS[(level - 1) % CELL_BULLET_CHARS.length];
levels += `<text:list-level-style-bullet text:level="${level}" text:bullet-char="${ch}">${labelAlign}</text:list-level-style-bullet>`;
}
}
return `<text:list-style style:name="${styleName}">${levels}</text:list-style>`;
}
// odf-kit serializes every cell to a single <text:p> of runs (no API for headings,
// lists, or multiple paragraphs). exportTable emits a cell's content into that paragraph
// SEG-separated; this pass splits on SEG and rebuilds real <text:h>/<text:p>/<text:list>.
// Ordering: after applyListItemStyles (so it sees only top-level list items, not cell
// lists) and before collapseRunWhitespace (so new in-cell paragraphs get newlines stripped).
function applyCellBlocks(odtBytes: Uint8Array, cellBlocks: CellBlock[][]): Uint8Array {
const needsWork = (blocks: CellBlock[]): boolean => {
if (blocks.length !== 1) return true;
const b = blocks[0];
return b.kind !== 'paragraph' || !paraStyleIsEmpty(b.style);
};
if (!cellBlocks.some(needsWork)) return odtBytes;
const files = unzipSync(odtBytes);
const contentBytes = files['content.xml'];
if (!contentBytes) return odtBytes;
let content = strFromU8(contentBytes);
// Minted paragraph automatic styles (parent = Standard / Heading_20_N /
// List_20_*), deduped by parent + properties. An empty ParaStyle reuses the
// parent directly (no minted style).
const styleDefs: { name: string; parent: string; style: ParaStyle }[] = [];
const nameByKey = new Map<string, string>();
let styleCounter = 0;
const styleNameFor = (parent: string, style: ParaStyle): string => {
if (paraStyleIsEmpty(style)) return parent;
const key = `${parent}|${style.align}|${style.spaceBefore}|${style.spaceAfter}|${style.lineHeight}`;
let name = nameByKey.get(key);
if (!name) {
styleCounter++;
name = `CB${styleCounter}`;
nameByKey.set(key, name);
styleDefs.push({ name, parent, style });
}
return name;
};
let usedBulletList = false;
// Ordered cell lists are minted one style per distinct numbering format (1. / a) /
// I. / …) so each list exports with its chosen type. The first format reuses the
// legacy CELL_LIST_NUMBER_STYLE name; further ones get a counter suffix.
const numberStyles: { name: string; numFormat: string; numSuffix: string }[] = [];
const numberStyleByKey = new Map<string, string>();
const numberStyleFor = (numFormat: string, numSuffix: string): string => {
const key = `${numFormat}|${numSuffix}`;
let name = numberStyleByKey.get(key);
if (!name) {
name = numberStyleByKey.size === 0 ? CELL_LIST_NUMBER_STYLE : `${CELL_LIST_NUMBER_STYLE}${numberStyleByKey.size}`;
numberStyleByKey.set(key, name);
numberStyles.push({ name, numFormat, numSuffix });
}
return name;
};
// Each <text:list> (root or nested) carries its own type-based style-name, so mixed
// bullet/number nesting renders correctly; indent comes from nesting depth. One
// segment consumed per list item, DFS order (matching buildCellContent).
const buildList = (list: CellListBlock, segments: string[], cur: { i: number }): string => {
const isBullet = !list.ordered;
if (isBullet) usedBulletList = true;
const listStyle = isBullet ? CELL_LIST_BULLET_STYLE : numberStyleFor(list.numFormat, list.numSuffix);
const paraParent = isBullet ? 'List_20_Bullet' : 'List_20_Number';
let out = `<text:list text:style-name="${listStyle}">`;
list.items.forEach((item, idx) => {
const startAttr = !isBullet && idx === 0 && list.start != null ? ` text:start-value="${list.start}"` : '';
const seg = segments[cur.i++] ?? '';
out += `<text:list-item${startAttr}><text:p text:style-name="${styleNameFor(paraParent, item.style)}">${seg}</text:p>`;
if (item.nested) out += buildList(item.nested, segments, cur);
out += '</text:list-item>';
});
return out + '</text:list>';
};
const buildBlocks = (blocks: CellBlock[], inner: string): string => {
const segments = inner.split(SEG);
const cur = { i: 0 };
let out = '';
for (const block of blocks) {
if (block.kind === 'list') {
out += buildList(block, segments, cur);
} else if (block.kind === 'heading') {
const seg = segments[cur.i++] ?? '';
const name = styleNameFor(`Heading_20_${block.level}`, block.style);
out += `<text:h text:style-name="${name}" text:outline-level="${block.level}">${seg}</text:h>`;
} else {
const seg = segments[cur.i++] ?? '';
out += `<text:p text:style-name="${styleNameFor('Standard', block.style)}">${seg}</text:p>`;
}
}
return out;
};
// Match each real cell's single paragraph (filled or empty) in document order.
// Covered cells use <table:covered-table-cell> and never match; a cell holds exactly
// one <text:p> with no nesting here, so the non-greedy inner capture is safe.
let idx = 0;
content = content.replace(
/(<table:table-cell\b[^>]*>\s*)<text:p\b[^>]*?(?:\/>|>([\s\S]*?)<\/text:p>)/g,
(match, pre: string, innerRaw: string | undefined) => {
const blocks = cellBlocks[idx++];
if (!blocks || !needsWork(blocks)) return match;
const inner = innerRaw ?? '';
// Trivial single paragraph that only needs a style override: keep one
// <text:p>, just point it at the minted style.
if (blocks.length === 1 && blocks[0].kind === 'paragraph') {
const name = styleNameFor('Standard', blocks[0].style);
return innerRaw === undefined
? `${pre}<text:p text:style-name="${name}"/>`
: `${pre}<text:p text:style-name="${name}">${inner}</text:p>`;
}
return `${pre}${buildBlocks(blocks, inner)}`;
},
);
const additions: string[] = styleDefs.map(({ name, parent, style }) =>
`<style:style style:name="${name}" style:family="paragraph" style:parent-style-name="${parent}"><style:paragraph-properties ${paraStyleProps(style).join(' ')}/></style:style>`,
);
if (usedBulletList) additions.push(buildCellListStyle(CELL_LIST_BULLET_STYLE, false));
for (const { name, numFormat, numSuffix } of numberStyles) {
additions.push(buildCellListStyle(name, true, numFormat, numSuffix));
}
if (additions.length) {
content = content.replace('</office:automatic-styles>', `${additions.join('\n')}\n</office:automatic-styles>`);
}
files['content.xml'] = strToU8(content);
return rezipOdt(files);
}
// Re-zip an unpacked ODF, preserving the mimetype-first uncompressed requirement.
function rezipOdt(files: Record<string, Uint8Array>): Uint8Array {
const mimetype = files['mimetype'];
const out: Record<string, [Uint8Array, { level: 0 | 6 }]> = {};
if (mimetype) out['mimetype'] = [mimetype, { level: 0 }];
for (const [path, data] of Object.entries(files)) {
if (path === 'mimetype') continue;
out[path] = [data, { level: 6 }];
}
return zipSync(out);
}
// 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): Uint8Array {
const files = unzipSync(odtBytes);
const stylesBytes = files['styles.xml'];
if (!stylesBytes) return odtBytes;
let styles = strFromU8(stylesBytes);
// The only occurrences of "Liberation Serif" in styles.xml are the default
// font-face declaration and the Standard style's font-name attributes.
styles = styles.split(ODFKIT_DEFAULT_FONT).join(EXPORT_FONT);
styles = styles.replace(
'<style:master-page style:name="Default"',
'<style:master-page style:name="Standard"',
);
// List item paragraphs inherit Standard's fo:margin-bottom (0.212cm), but the editor
// zeroes it (editor.css), so without this override every exported bullet gains ~6pt
// vs. the preview. Explicit per-item spacing overrides this via LP styles.
for (const name of ['List_20_Bullet', 'List_20_Number']) {
styles = styles.replace(
new RegExp(`(<style:style style:name="${name}"[^>]*?)/>`),
`$1><style:paragraph-properties fo:margin-bottom="0cm"/></style:style>`,
);
}
// Scope each rewrite to its own <style:style …>…</style:style> block so the
// font-size/margin replacements never bleed across heading levels.
for (const { name, fontSize, marginTop, marginBottom } of HEADING_STYLE_OVERRIDES) {
const re = new RegExp(`<style:style style:name="${name}"[\\s\\S]*?</style:style>`);
styles = styles.replace(re, (block) =>
block
.replace(/fo:font-size="[^"]*"/g, `fo:font-size="${fontSize}"`)
.replace(/style:font-size-asian="[^"]*"/g, `style:font-size-asian="${fontSize}"`)
.replace(/style:font-size-complex="[^"]*"/g, `style:font-size-complex="${fontSize}"`)
.replace(/fo:margin-top="[^"]*"/g, `fo:margin-top="${marginTop}"`)
.replace(/fo:margin-bottom="[^"]*"/g, `fo:margin-bottom="${marginBottom}"`),
);
}
files['styles.xml'] = strToU8(styles);
return rezipOdt(files);
}
// ODF requires fo:color as #RRGGBB. TipTap may store hex (color picker) or rgb(r,g,b)
// after an HTML round-trip. Anything not valid hex is silently dropped by
// Word/LibreOffice → text renders black, so coerce it here. Also used on import.
export function normalizeColor(input: string): string | undefined {
const s = input.trim();
if (!s) return undefined;
const hex = s.match(/^#([0-9a-fA-F]{3,8})$/);
if (hex) {
const h = hex[1];
if (h.length === 3 || h.length === 4) {
return `#${h[0]}${h[0]}${h[1]}${h[1]}${h[2]}${h[2]}`.toUpperCase();
}
if (h.length === 6 || h.length === 8) {
return `#${h.slice(0, 6)}`.toUpperCase();
}
return undefined;
}
const rgb = s.match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*[\d.]+\s*)?\)$/i);
if (rgb) {
const toHex = (v: string) => {
const n = Math.max(0, Math.min(255, Math.round(parseFloat(v))));
return n.toString(16).padStart(2, '0');
};
return `#${toHex(rgb[1])}${toHex(rgb[2])}${toHex(rgb[3])}`.toUpperCase();
}
// Named colors (red, blue, …): pass through — odf-kit resolves them.
return s;
}
// Translate a TipTap mark set into odf-kit TextFormatting (bold/italic/underline,
// font family/size, colour, highlight). Shared by body runs and header/footer runs.
function formattingFromMarks(marks: TiptapNode['marks'] = []): TextFormatting {
const tsm = marks.find(m => m.type === 'textStyle');
const fmt: TextFormatting = {};
if (marks.some(m => m.type === 'bold')) fmt.bold = true;
// Explicit weight (e.g. un-bolding heading text) overrides the bold shortcut.
if (tsm?.attrs?.fontWeight) {
const fw = String(tsm.attrs.fontWeight);
fmt.fontWeight = (/^\d+$/.test(fw) ? Number(fw) : fw) as TextFormatting['fontWeight'];
}
if (marks.some(m => m.type === 'italic')) fmt.italic = true;
if (marks.some(m => m.type === 'underline')) fmt.underline = true;
if (marks.some(m => m.type === 'strike')) fmt.strikethrough = true;
if (marks.some(m => m.type === 'superscript')) fmt.superscript = true;
else if (marks.some(m => m.type === 'subscript')) fmt.subscript = true;
if (tsm?.attrs?.fontFamily) {
const ff = String(tsm.attrs.fontFamily);
// Explicitly choosing the editor default should match the untagged
// default, which resolves to EXPORT_FONT via the Standard style.
fmt.fontFamily = ff === ODFKIT_DEFAULT_FONT ? EXPORT_FONT : ff;
}
if (tsm?.attrs?.fontSize) fmt.fontSize = String(tsm.attrs.fontSize);
if (tsm?.attrs?.color) {
const c = normalizeColor(String(tsm.attrs.color));
if (c) fmt.color = c;
}
// Text highlight (background). odf-kit maps highlightColor → fo:background-color
// natively for normal paragraphs; this covers the custom-attr-paragraph path
// (CUST_P/CUST_H), which bypasses odf-kit's own mark handling.
const hl = marks.find(m => m.type === 'highlight');
if (hl?.attrs?.color) {
const c = normalizeColor(String(hl.attrs.color));
if (c) fmt.highlightColor = c;
}
return fmt;
}
// Emit each text node as an odf-kit run.
function applyRuns(p: ParagraphBuilder | CellBuilder, content: TiptapNode[] = []) {
for (const node of content) {
if (node.type !== 'text' || !node.text) continue;
const fmt = formattingFromMarks(node.marks);
p.addText(node.text, Object.keys(fmt).length ? fmt : undefined);
}
}
// Emit the header/footer paragraph into odf-kit's HeaderFooterBuilder. hardBreak
// and pageCount ride as sentinels (LBR / PGC-wrapped digits) and are rewritten to
// <text:line-break/> / <text:page-count> in applyHfPostProcess (styles.xml).
function applyHfRuns(b: HeaderFooterBuilder, para: TiptapNode, pageCount: number): void {
for (const node of para.content ?? []) {
const fmt = formattingFromMarks(node.marks);
const f = Object.keys(fmt).length ? fmt : undefined;
if (node.type === 'text' && node.text) b.addText(node.text, f);
else if (node.type === 'hardBreak') b.addText(LBR);
else if (node.type === 'pageNumber') b.addPageNumber(f);
else if (node.type === 'pageCount') b.addText(`${PGC}${pageCount}${PGC}`, f);
}
}
// Emit a cell's inline content into odf-kit's run-based CellBuilder and, in lockstep,
// return a CellBlock[] descriptor. odf-kit serializes the runs into one <text:p>;
// applyCellBlocks splits on SEG and rebuilds real <text:h>/<text:p>/<text:list>.
// A segment is one paragraph, heading, or list item's paragraph; exactly one SEG
// between consecutive segments, so splitting yields one piece per segment in DFS order.
function buildCellContent(cell: TiptapNode, c: CellBuilder): CellBlock[] {
const blocks: CellBlock[] = [];
const state = { emitted: false }; // whether any segment has been emitted yet
const emitSegment = (content: TiptapNode[] | undefined) => {
if (state.emitted) c.addText(SEG);
state.emitted = true;
applyRuns(c, content ?? []);
};
const walkList = (listNode: TiptapNode): CellListBlock => {
const ordered = listNode.type === 'orderedList';
const start = ordered ? ((listNode.attrs?.start as number) ?? null) : null;
// Same numbering style the editor shows / top-level export uses (orderedTypeDef).
const def = ordered ? orderedTypeDef(listNode.attrs?.listStyleType as string | undefined) : null;
const items: CellListItem[] = [];
for (const item of listNode.content ?? []) {
if (item.type !== 'listItem') continue;
const firstPara = item.content?.find(x => x.type === 'paragraph');
emitSegment(firstPara?.content); // one segment per item, in DFS order
const nested = item.content?.find(x => x.type === 'bulletList' || x.type === 'orderedList');
items.push({ style: paraStyleFromAttrs(firstPara?.attrs), nested: nested ? walkList(nested) : null });
}
return { kind: 'list', ordered, start, numFormat: def?.numFormat ?? '1', numSuffix: def?.numSuffix ?? '.', items };
};
for (const block of cell.content ?? []) {
if (block.type === 'paragraph') {
emitSegment(block.content);
blocks.push({ kind: 'paragraph', style: paraStyleFromAttrs(block.attrs) });
} else if (block.type === 'heading') {
emitSegment(block.content);
blocks.push({ kind: 'heading', level: (block.attrs?.level as number) ?? 1, style: paraStyleFromAttrs(block.attrs) });
} else if (block.type === 'bulletList' || block.type === 'orderedList') {
blocks.push(walkList(block));
}
}
// Empty cell: odf-kit emits an empty <text:p/>; record one empty paragraph so
// applyCellBlocks stays aligned and leaves it untouched.
if (blocks.length === 0) {
blocks.push({ kind: 'paragraph', style: paraStyleFromAttrs(undefined) });
}
return blocks;
}
// Per-column widths (cm) from the table's first row: the editor's proportional
// `colwidth` weights (tableView.ts) turned into absolute cm summing to the text width.
// With table:align="margins" this matches the preview. undefined → odf-kit even split.
function tableColumnWidthsCm(node: TiptapNode, contentWidthCm: number): string[] | undefined {
const firstRow = (node.content ?? []).find(r => r.type === 'tableRow');
if (!firstRow) return undefined;
const weights: (number | null)[] = [];
for (const cell of firstRow.content ?? []) {
if (cell.type !== 'tableCell' && cell.type !== 'tableHeader') continue;
const colspan = (cell.attrs?.colspan as number) ?? 1;
const cw = cell.attrs?.colwidth as number[] | null | undefined;
for (let k = 0; k < colspan; k++) weights.push(cw && cw[k] ? cw[k] : null);
}
if (weights.length === 0 || weights.every(w => w == null)) return undefined;
const present = weights.filter((w): w is number => w != null);
const avg = present.reduce((a, b) => a + b, 0) / present.length;
const filled = weights.map(w => (w != null ? w : avg));
const total = filled.reduce((a, b) => a + b, 0);
const round3 = (v: number) => Math.round(v * 1000) / 1000;
const cm = filled.map(w => round3((w / total) * contentWidthCm));
// Absorb the rounding remainder into the last column so the widths sum exactly
// to the text width (table:align="margins" scales otherwise).
const sum = cm.reduce((a, b) => a + b, 0);
cm[cm.length - 1] = round3(cm[cm.length - 1] + (contentWidthCm - sum));
return cm.map(v => `${v}cm`);
}
// Build an ODF table from a CUST_TABLE node, bypassing odf-kit's native walkTable to
// pass an explicit cell border (the native path emits none → invisible). Column widths
// come from tableColumnWidthsCm; when absent odf-kit distributes columns evenly.
function exportTable(node: TiptapNode, doc: OdtDocument, contentWidthCm: number, cellBlocks: CellBlock[][]): void {
const rows = (node.content ?? []).filter(r => r.type === 'tableRow');
if (rows.length === 0) return;
const columnWidths = tableColumnWidthsCm(node, contentWidthCm);
doc.addTable((t: TableBuilder) => {
for (const row of rows) {
t.addRow((r: RowBuilder) => {
for (const cell of row.content ?? []) {
if (cell.type !== 'tableCell' && cell.type !== 'tableHeader') continue;
// Emit the cell's runs (SEG-separated) and record its block descriptor.
// addCell runs synchronously, so the push is in document order — matching
// how applyCellBlocks later walks cells in content.xml.
r.addCell((c: CellBuilder) => {
cellBlocks.push(buildCellContent(cell, c));
}, { padding: CELL_PADDING });
}
});
}
}, columnWidths ? { border: TABLE_BORDER, columnWidths } : { border: TABLE_BORDER });
}
// odf-kit joins a multi-child element's children with "\n"; inside a <text:p>/<text:h>
// that newline is significant and collapses to a space, adding a spurious space at any
// mid-word run boundary. Strip the bare "\n" separators (real spaces sit adjacent, kept).
function collapseRunWhitespace(odtBytes: Uint8Array): Uint8Array {
const files = unzipSync(odtBytes);
const contentBytes = files['content.xml'];
if (!contentBytes) return odtBytes;
let content = strFromU8(contentBytes);
content = content.replace(
/<text:(p|h)\b[^>]*>[\s\S]*?<\/text:\1>/g,
(block) => block.replace(/\n/g, ''),
);
files['content.xml'] = strToU8(content);
return rezipOdt(files);
}
// Rewrite the LBR sentinels replaceHardBreaks planted into real <text:line-break/>
// elements (valid both as bare paragraph text and inside <text:span>).
function applyLineBreaks(odtBytes: Uint8Array): Uint8Array {
const files = unzipSync(odtBytes);
const contentBytes = files['content.xml'];
if (!contentBytes) return odtBytes;
const content = strFromU8(contentBytes);
if (!content.includes(LBR)) return odtBytes;
files['content.xml'] = strToU8(content.split(LBR).join('<text:line-break/>'));
return rezipOdt(files);
}
// Header/footer input for the export: one single-paragraph doc per zone (or null)
// plus the page count snapshot used as the stored <text:page-count> value.
export type HfExport = { header: HfDoc; footer: HfDoc; pageCount: number };
// The full document → .odt pipeline, DOM-free (exportToOdt adds the download).
export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait', hf?: HfExport): Promise<Uint8Array> {
const raw = replaceHardBreaks(docJson);
const headerPara = hf && !hfIsEmpty(hf.header) ? (hf.header!.content![0] as TiptapNode) : null;
const footerPara = hf && !hfIsEmpty(hf.footer) ? (hf.footer!.content![0] as TiptapNode) : null;
let json = injectCustomTypes(raw);
if (headerPara || footerPara) {
json = { ...json, content: [{ type: CUST_HF }, ...(json.content ?? [])] };
}
// Text width = A4 page width (portrait 21cm / landscape 29.7cm) minus the L/R
// margins. Table column widths are scaled to fill exactly this width.
const pageWidthCm = orientation === 'landscape' ? 29.7 : 21;
const contentWidthCm = pageWidthCm - margins.left - margins.right;
// Filled by exportTable, in document order, one CellBlock[] per table cell.
// applyCellBlocks consumes it to rebuild real <text:h>/<text:p>/<text:list>.
const cellBlocks: CellBlock[][] = [];
const odt = await tiptapToOdt(json, {
// Orientation comes from the Layout panel; odf-kit swaps the A4 dimensions
// automatically (29.7×21cm) and writes style:print-orientation accordingly.
orientation,
// Margins (cm) come from the Layout panel via App state. They match the
// editor's on-screen padding so exported line wrapping / page flow is identical.
// With a header/footer, ODF's vertical margin is the page-edge→header distance;
// applyHfPostProcess sizes the header box so the body still starts at the
// editor's margin (Word-style mapping).
marginTop: `${headerPara ? HF_DISTANCE_CM : margins.top}cm`,
marginBottom: `${footerPara ? HF_DISTANCE_CM : margins.bottom}cm`,
marginLeft: `${margins.left}cm`,
marginRight: `${margins.right}cm`,
unknownNodeHandler(node: TiptapNode, doc: OdtDocument) {
if (node.type === CUST_HF) {
if (headerPara) doc.setHeader((b: HeaderFooterBuilder) => applyHfRuns(b, headerPara, hf!.pageCount));
if (footerPara) doc.setFooter((b: HeaderFooterBuilder) => applyHfRuns(b, footerPara, hf!.pageCount));
return;
}
if (node.type === CUST_TABLE) {
exportTable(node, doc, contentWidthCm, cellBlocks);
return;
}
const opts: {
lineHeight?: number | string;
align?: AlignValue;
spaceBefore?: string;
spaceAfter?: string;
} = {};
if (node.attrs?.lineHeight != null) {
const lhRaw = String(node.attrs.lineHeight);
const lhNum = parseFloat(lhRaw);
opts.lineHeight = isNaN(lhNum) ? lhRaw : lhNum;
} else {
opts.lineHeight = DEFAULT_LINE_HEIGHT;
}
const ta = node.attrs?.textAlign;
if (ta === 'left' || ta === 'center' || ta === 'right' || ta === 'justify') {
opts.align = ta;
}
if (node.attrs?.spaceBefore != null) opts.spaceBefore = `${node.attrs.spaceBefore}pt`;
if (node.attrs?.spaceAfter != null) opts.spaceAfter = `${node.attrs.spaceAfter}pt`;
const content = node.content ?? [];
if (node.type === CUST_P) {
if (content.length === 0) {
doc.addParagraph('', opts);
} else {
doc.addParagraph((p: ParagraphBuilder) => applyRuns(p, content), opts);
}
} else if (node.type === CUST_H) {
const level = (node.attrs?.level as number) ?? 1;
doc.addHeading((p: ParagraphBuilder) => applyRuns(p, content), level, opts);
}
},
});
// Rewrite odf-kit's default numbering (1.) into the per-list style the user
// chose (a) / I.) / …). Runs before applyListItemStyles, which only touches
// <text:p> styles, not the <text:list-style> definitions.
const olFormats: (OrderedFmt | null)[] = [];
collectOrderedListFormats(raw, olFormats);
let numberedOdt = applyOrderedListFormats(odt as Uint8Array, olFormats);
const nestedFixes: (ListDef | null)[] = [];
collectNestedListFixes(raw, nestedFixes);
numberedOdt = applyNestedListTypes(numberedOdt, nestedFixes);
const listStyles: ParaStyle[] = [];
collectListItemStyles(raw, listStyles);
const styledLists = applyListItemStyles(numberedOdt, listStyles);
// Rebuild real headings/lists/paragraphs inside table cells. Must run after
// applyListItemStyles (cell lists don't exist yet) and before collapseRunWhitespace.
const styledCells = applyCellBlocks(styledLists, cellBlocks);
const rowHeights: (string | null)[] = [];
collectTableRowHeights(raw, rowHeights);
const styledRows = applyTableRowHeights(styledCells, rowHeights);
const cleaned = collapseRunWhitespace(styledRows);
const withBreaks = applyLineBreaks(cleaned);
const withStyles = rewriteStylesXml(withBreaks);
return applyHfPostProcess(withStyles, margins, headerPara, footerPara);
}
function hfAlign(para: TiptapNode): AlignValue | null {
const ta = para.attrs?.textAlign;
return ta === 'center' || ta === 'right' || ta === 'justify' ? ta : null;
}
// Header/footer post-processing on styles.xml: resolve the LBR/PGC sentinels in the
// emitted runs, rewrite odf-kit's fixed header/footer geometry (0.6/0.5cm) to the
// Word-style mapping (page margin = HF distance, min-height fills up to the body
// margin so the body starts exactly at the editor's margin), and apply the
// paragraph alignment to the named Header/Footer styles.
function applyHfPostProcess(odtBytes: Uint8Array, margins: PageMargins, headerPara: TiptapNode | null, footerPara: TiptapNode | null): Uint8Array {
if (!headerPara && !footerPara) return odtBytes;
const files = unzipSync(odtBytes);
const stylesBytes = files['styles.xml'];
if (!stylesBytes) return odtBytes;
let styles = strFromU8(stylesBytes);
// Same fix as collapseRunWhitespace: odf-kit joins runs with "\n", which would
// collapse into spurious spaces. styles.xml only has text:p inside header/footer.
styles = styles.replace(/<text:p\b[^>]*>[\s\S]*?<\/text:p>/g, (block) => block.replace(/\n/g, ''));
styles = styles.split(LBR).join('<text:line-break/>');
styles = styles.replace(new RegExp(`${PGC}(\\d*)${PGC}`, 'g'), '<text:page-count>$1</text:page-count>');
const round3 = (v: number) => Math.round(v * 1000) / 1000;
const zone = (kind: 'header' | 'footer', para: TiptapNode, bodyMarginCm: number) => {
const minH = round3(Math.max(0.2, bodyMarginCm - HF_DISTANCE_CM));
// The spacing attr sits on the body-facing side: below the header, above the footer.
const spacingAttr = kind === 'header' ? 'fo:margin-bottom' : 'fo:margin-top';
styles = styles.replace(
new RegExp(`<style:${kind}-style>[\\s\\S]*?</style:${kind}-style>`),
`<style:${kind}-style><style:header-footer-properties fo:min-height="${minH}cm" ${spacingAttr}="0cm" style:dynamic-spacing="false"/></style:${kind}-style>`,
);
const align = hfAlign(para);
if (align) {
const styleName = kind === 'header' ? 'Header' : 'Footer';
styles = styles.replace(
new RegExp(`(<style:style style:name="${styleName}"[^>]*?)/>`),
`$1><style:paragraph-properties fo:text-align="${align}"/></style:style>`,
);
}
};
if (headerPara) zone('header', headerPara, margins.top);
if (footerPara) zone('footer', footerPara, margins.bottom);
files['styles.xml'] = strToU8(styles);
return rezipOdt(files);
}
export async function exportToOdt(editor: Editor, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait', hf?: HfExport): Promise<void> {
const finalBytes = await buildOdt(editor.getJSON() as TiptapNode, margins, orientation, hf);
const blob = new Blob([finalBytes as Uint8Array<ArrayBuffer>], { type: 'application/vnd.oasis.opendocument.text' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = getFilename(editor);
a.click();
URL.revokeObjectURL(url);
}
function getFilename(editor: Editor): string {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const json = editor.getJSON() as any;
const heading = json.content?.find(
(node: any) => node.type === 'heading' && node.content?.length
);
const firstText: string | undefined = heading?.content?.[0]?.text;
if (firstText) {
const name = firstText
.slice(0, 50)
.replace(/[^a-zA-Z0-9\s-]/g, '')
.trim()
.replace(/\s+/g, '-');
if (name) return `${name}.odt`;
}
return 'document.odt';
}