diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 28e4d69..adcd98e 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -32,6 +32,19 @@ const ODFKIT_DEFAULT_FONT = 'Liberation Serif'; const EXPORT_FONT = 'Times New Roman'; const DEFAULT_LINE_HEIGHT = 1; // must match line-height multiplier default in ToolbarExpanded.svelte +// Sentinel inserted between a cell's blocks (and between list items) so the +// single odf-kit emits per cell can be split back into real block +// elements in applyCellBlocks. A Unicode private-use char: never present in user +// text, and passes through odf-kit's XML escaper untouched (escapeXml only +// touches & < >) so it survives serialization as a literal, findable character. +const SEG = ''; + +// 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 built-in // Heading_20_N styles use larger sizes (28/24/20pt), so we rewrite them on // export. Margins are the editor's em-based values resolved against each @@ -130,6 +143,18 @@ function paraStyleProps(style: ParaStyle): string[] { return props; } +// Per-cell block descriptors, built during exportTable and consumed in document +// order by applyCellBlocks to rebuild real // elements +// from the single SEG-segmented odf-kit emits per cell. Each paragraph, +// heading, and list *item* contributes exactly one SEG-delimited segment, in the +// same DFS order they are emitted. +type CellListItem = { style: ParaStyle; nested: CellListBlock | null }; +type CellListBlock = { kind: 'list'; ordered: boolean; start: number | null; 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 elements // into content.xml. Items without overrides yield an all-null descriptor. @@ -150,28 +175,6 @@ function collectListItemStyles(node: TiptapNode, result: ParaStyle[]): void { } } -// Collect the paragraph style of each table cell's first paragraph, in the same -// row-major order that exportTable emits cells (and odf-kit writes -// into content.xml). One descriptor per cell; cells without overrides are all-null. -function collectTableCellStyles(node: TiptapNode, result: ParaStyle[]): void { - if (node.type === 'table') { - for (const row of node.content ?? []) { - if (row.type !== 'tableRow') continue; - for (const cell of row.content ?? []) { - if (cell.type !== 'tableCell' && cell.type !== 'tableHeader') continue; - // Mirror exportTable: the cell's emitted takes its alignment/ - // spacing from the first block, paragraph or heading. - const firstBlock = cell.content?.find(c => c.type === 'paragraph' || c.type === 'heading'); - result.push(paraStyleFromAttrs(firstBlock?.attrs)); - } - } - return; - } - for (const child of node.content ?? []) { - collectTableCellStyles(child, result); - } -} - // Collect each table row's explicit height (px → cm string), in DFS document // order — matching the order odf-kit emits into content.xml. // rowHeight is stored as unscaled document px at 96 dpi (tableRow.ts); convert to @@ -291,50 +294,149 @@ function applyListItemStyles(odtBytes: Uint8Array, styles: ParaStyle[]): Uint8Ar return rezipOdt(files); } -// odf-kit's TableBuilder emits every cell paragraph with text:style-name="Standard" -// and offers no per-cell paragraph alignment. We rewrite content.xml to point each -// overridden cell paragraph at a custom automatic style inheriting from Standard, -// adding fo:text-align / fo:margin-top / fo:margin-bottom / fo:line-height. The -// regex matches one per cell, in the same row-major order as collectTableCellStyles. -function applyTableCellStyles(odtBytes: Uint8Array, styles: ParaStyle[]): Uint8Array { - if (styles.every(paraStyleIsEmpty)) return odtBytes; +// 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): 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 = ``; + if (ordered) { + levels += `${labelAlign}`; + } else { + const ch = CELL_BULLET_CHARS[(level - 1) % CELL_BULLET_CHARS.length]; + levels += `${labelAlign}`; + } + } + return `${levels}`; +} + +// odf-kit's TableBuilder serializes every cell to a single of runs — it has no API for headings, lists, or +// multiple paragraphs in a cell. exportTable therefore emits all of a cell's +// inline content into that one paragraph, separated by SEG markers, and records a +// CellBlock[] descriptor per cell (in document order, via buildCellContent). This +// pass splits each cell paragraph back on SEG and rebuilds the real +// // elements, applying per-paragraph +// alignment/spacing/line-height overrides as minted automatic styles (same +// technique as applyListItemStyles). It subsumes the former applyTableCellStyles +// (single-paragraph cell styling). +// +// Ordering: runs *after* applyListItemStyles (so that pass sees only genuine +// top-level s — cell lists are still flat runs here), and *before* +// collapseRunWhitespace (so the new in-cell / get their inter-run +// newlines stripped too). +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); - const styleDefs: { name: string; style: ParaStyle }[] = []; - const nameByKey = new Map(); - let counter = 0; - let idx = 0; - // The cell's paragraph is the first child of . Matches both - // the filled (`>…`) and empty (`/>`) forms — only the style-name is touched. - content = content.replace( - /(]*>\s* { - const style = styles[idx++]; - if (!style || paraStyleIsEmpty(style)) return `${pre}Standard${post}`; - const key = `${style.align}|${style.spaceBefore}|${style.spaceAfter}|${style.lineHeight}`; - let name = nameByKey.get(key); - if (!name) { - counter++; - name = `TC${counter}`; - nameByKey.set(key, name); - styleDefs.push({ name, style }); + // 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(); + 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; + let usedNumberList = false; + + // Each (root or nested) carries its own style-name based on its own + // type, so mixed bullet/number nesting renders correctly; the actual indent + // level comes from the DOM nesting depth. One segment is consumed per list item, + // in DFS order — matching buildCellContent's emission. + const buildList = (list: CellListBlock, segments: string[], cur: { i: number }): string => { + const isBullet = !list.ordered; + if (isBullet) usedBulletList = true; else usedNumberList = true; + const listStyle = isBullet ? CELL_LIST_BULLET_STYLE : CELL_LIST_NUMBER_STYLE; + const paraParent = isBullet ? 'List_20_Bullet' : 'List_20_Number'; + let out = ``; + list.items.forEach((item, idx) => { + const startAttr = !isBullet && idx === 0 && list.start != null ? ` text:start-value="${list.start}"` : ''; + const seg = segments[cur.i++] ?? ''; + out += `${seg}`; + if (item.nested) out += buildList(item.nested, segments, cur); + out += ''; + }); + return out + ''; + }; + + 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 += `${seg}`; + } else { + const seg = segments[cur.i++] ?? ''; + out += `${seg}`; } - return `${pre}${name}${post}`; + } + return out; + }; + + // Match each real cell's single paragraph (filled or empty form) in document + // order. Covered cells use and never match; at this + // stage a cell holds exactly one with no nested , so the + // non-greedy inner capture is safe. + let idx = 0; + content = content.replace( + /(]*>\s*)]*?(?:\/>|>([\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 + // , 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}` + : `${pre}${inner}`; + } + return `${pre}${buildBlocks(blocks, inner)}`; }, ); - if (styleDefs.length === 0) return odtBytes; + const additions: string[] = styleDefs.map(({ name, parent, style }) => + ``, + ); + if (usedBulletList) additions.push(buildCellListStyle(CELL_LIST_BULLET_STYLE, false)); + if (usedNumberList) additions.push(buildCellListStyle(CELL_LIST_NUMBER_STYLE, true)); - const newStyles = styleDefs.map(({ name, style }) => - ``, - ).join('\n'); - - content = content.replace('', `${newStyles}\n`); + if (additions.length) { + content = content.replace('', `${additions.join('\n')}\n`); + } files['content.xml'] = strToU8(content); return rezipOdt(files); @@ -438,14 +540,14 @@ function normalizeColor(input: string): string | undefined { return s; } -// `base` seeds each run's formatting (e.g. a heading's bold + font size when its -// text is emitted inside a table cell). Explicit per-run marks/attrs override it. -function applyRuns(p: ParagraphBuilder | CellBuilder, content: TiptapNode[] = [], base?: TextFormatting) { +// Emit each text node as an odf-kit run, translating its TipTap marks/attrs into +// TextFormatting (bold/italic/underline, font family/size, colour, highlight). +function applyRuns(p: ParagraphBuilder | CellBuilder, content: TiptapNode[] = []) { for (const node of content) { if (node.type !== 'text' || !node.text) continue; const marks = node.marks ?? []; const tsm = marks.find(m => m.type === 'textStyle'); - const fmt: TextFormatting = { ...base }; + const fmt: TextFormatting = {}; if (marks.some(m => m.type === 'bold')) fmt.bold = true; if (marks.some(m => m.type === 'italic')) fmt.italic = true; if (marks.some(m => m.type === 'underline')) fmt.underline = true; @@ -472,41 +574,58 @@ function applyRuns(p: ParagraphBuilder | CellBuilder, content: TiptapNode[] = [] } } -// odf-kit's CellBuilder is run-based — it has no notion of a heading node, so a -// heading inside a cell can't be emitted as a real . We render its text -// as bold runs at the heading's font size instead (the same approach odf-kit uses -// for header cells). Sizes match editor.css / HEADING_STYLE_OVERRIDES. -function headingRunFormatting(level: number): TextFormatting { - const fontSize = level === 1 ? '20pt' : level === 2 ? '16pt' : '14pt'; - return { bold: true, fontSize }; -} +// Emit a cell's inline content into odf-kit's run-based CellBuilder and, in +// lockstep, return a CellBlock[] descriptor of its block structure. odf-kit will +// serialize all these runs into a single ; applyCellBlocks later splits +// that paragraph on the SEG markers we insert between segments and rebuilds the +// real // elements using this descriptor. +// +// A "segment" is one paragraph, one heading, or one list item's paragraph; we +// emit exactly one SEG between consecutive segments (never leading/trailing), so +// splitting yields one piece per segment in DFS order. Headings and list items +// get no base run formatting here — the real / List_20_* styles supply it. +function buildCellContent(cell: TiptapNode, c: CellBuilder): CellBlock[] { + const blocks: CellBlock[] = []; + const state = { emitted: false }; // whether any segment has been emitted yet -// odf-kit's CellBuilder is run-based — it has no notion of a list, so a list -// inside a cell can't be emitted as a real . We render each item as -// its own line (text:line-break) prefixed with a marker (• for bullets, 1./2.… -// for ordered lists); nested lists are indented. Same run-based fallback we use -// for cell headings. `state.emitted` tracks whether the cell already holds -// content so the very first line doesn't get a spurious leading break; it is -// shared across nested calls so inter-item breaks continue correctly. -function addListToCell(c: CellBuilder, listNode: TiptapNode, depth: number, state: { emitted: boolean }): void { - const ordered = listNode.type === 'orderedList'; - let n = ordered ? ((listNode.attrs?.start as number) ?? 1) : 0; - const indent = '  '.repeat(depth); // two non-breaking spaces per nesting level - for (const item of listNode.content ?? []) { - if (item.type !== 'listItem') continue; - if (state.emitted) c.addLineBreak(); + const emitSegment = (content: TiptapNode[] | undefined) => { + if (state.emitted) c.addText(SEG); state.emitted = true; - c.addText(indent + (ordered ? `${n++}. ` : '• ')); - // The item's text lives in its first paragraph; emit its runs. - const firstPara = item.content?.find(x => x.type === 'paragraph'); - if (firstPara) applyRuns(c, firstPara.content); - // Nested lists extend the same cell paragraph, one indent level deeper. - for (const child of item.content ?? []) { - if (child.type === 'bulletList' || child.type === 'orderedList') { - addListToCell(c, child, depth + 1, state); - } + applyRuns(c, content ?? []); + }; + + const walkList = (listNode: TiptapNode): CellListBlock => { + const ordered = listNode.type === 'orderedList'; + const start = ordered ? ((listNode.attrs?.start as number) ?? null) : 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, 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 ; 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; } // Derive per-column widths (in cm) from the table's first row. The editor stores @@ -548,7 +667,7 @@ function tableColumnWidthsCm(node: TiptapNode, contentWidthCm: number): string[] // explicit cell border — the native path emits none, so the table would be // invisible in LibreOffice/Word. Column widths come from the editor's per-column // weights (tableColumnWidthsCm); when absent odf-kit distributes columns evenly. -function exportTable(node: TiptapNode, doc: OdtDocument, contentWidthCm: number): void { +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); @@ -557,34 +676,13 @@ function exportTable(node: TiptapNode, doc: OdtDocument, contentWidthCm: number) t.addRow((r: RowBuilder) => { for (const cell of row.content ?? []) { if (cell.type !== 'tableCell' && cell.type !== 'tableHeader') continue; - // Walk every block in the cell, in order. Paragraphs/headings (a cell - // containing only a heading would otherwise export empty) emit as runs; - // bulletList/orderedList emit as marked lines — previously they were - // filtered out, dropping all list content in the cell. - const blocks = (cell.content ?? []).filter( - c => c.type === 'paragraph' || c.type === 'heading' - || c.type === 'bulletList' || c.type === 'orderedList', - ); + // Emit the cell's runs (SEG-separated) and record its block descriptor. + // odf-kit serializes this to one ; applyCellBlocks splits it back + // into real // using the descriptor. The push + // happens in document order (addCell runs the callback synchronously), + // matching the order applyCellBlocks walks cells in content.xml. r.addCell((c: CellBuilder) => { - // Consecutive paragraph/heading blocks are joined with a space - // (matching odf-kit's walkTable); a list is separated from its - // neighbours by a line break. Heading blocks seed their runs with - // bold + the heading font size so the text survives and reads as one. - let prev: 'none' | 'list' | 'text' = 'none'; - for (const block of blocks) { - if (block.type === 'bulletList' || block.type === 'orderedList') { - addListToCell(c, block, 0, { emitted: prev !== 'none' }); - prev = 'list'; - continue; - } - if (prev === 'text') c.addText(' '); - else if (prev === 'list') c.addLineBreak(); - const base = block.type === 'heading' - ? headingRunFormatting((block.attrs?.level as number) ?? 1) - : undefined; - applyRuns(c, block.content, base); - prev = 'text'; - } + cellBlocks.push(buildCellContent(cell, c)); }, { padding: CELL_PADDING }); } }); @@ -627,6 +725,10 @@ export async function exportToOdt(editor: Editor, margins: PageMargins = DEFAULT 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 //. + 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. @@ -639,7 +741,7 @@ export async function exportToOdt(editor: Editor, margins: PageMargins = DEFAULT marginRight: `${margins.right}cm`, unknownNodeHandler(node: TiptapNode, doc: OdtDocument) { if (node.type === CUST_TABLE) { - exportTable(node, doc, contentWidthCm); + exportTable(node, doc, contentWidthCm, cellBlocks); return; } const opts: { @@ -680,9 +782,9 @@ export async function exportToOdt(editor: Editor, margins: PageMargins = DEFAULT collectListItemStyles(raw, listStyles); const styledLists = applyListItemStyles(odt as Uint8Array, listStyles); - const cellStyles: ParaStyle[] = []; - collectTableCellStyles(raw, cellStyles); - const styledCells = applyTableCellStyles(styledLists, cellStyles); + // 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);