From cd1da6758683b2a3cc8f0b533886d6620663fcaf Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Fri, 7 Aug 2026 20:27:31 +0000 Subject: [PATCH] fix: carry a character style through a list item on ODF export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit odf-kit's list builder reads a run's marks itself and knows nothing about charStyle, and applyRuns — which handles the style on every other path — never sees a list paragraph, so a character-styled list item exported as plain text. A pre-pass now does both of applyRuns' jobs: bake the resolved props into direct marks (the run's own marks staying on top), which is what gets the run a span at all, and prefix the CST sentinel so applyCharacterStyles re-points that span at the named style. Formatting and name both round-trip. --- src/lib/editor/extensions/listMarker.ts | 7 ++-- src/lib/export/odt.ts | 47 +++++++++++++++++++++---- tests/unit/list-marker.test.ts | 15 ++++++++ 3 files changed, 59 insertions(+), 10 deletions(-) diff --git a/src/lib/editor/extensions/listMarker.ts b/src/lib/editor/extensions/listMarker.ts index 6b773ec..3d5b7dd 100644 --- a/src/lib/editor/extensions/listMarker.ts +++ b/src/lib/editor/extensions/listMarker.ts @@ -71,10 +71,9 @@ function sameFormat(a: MarkerFormat, b: MarkerFormat): boolean { return KEYS.every((key) => a[key] === b[key]); } -// Every item is decorated, resets included: the custom properties inherit, so a -// nested item would otherwise take its parent item's marker format. The reset is -// `initial` — on a custom property that is the guaranteed-invalid value, which makes -// the var() in editor.css fall back; `inherit` would pull the parent's value in. +// Every item is decorated, resets included: the properties inherit, so a nested item +// would take its parent's. The reset is `initial` — guaranteed-invalid on a custom +// property, so editor.css's var() falls back; `inherit` would pull the parent's in. function markerStyle(format: MarkerFormat | null): string { const family = format?.fontFamily ? cssFontFamily(format.fontFamily) : 'initial'; return `--marker-family:${family};--marker-weight:${format?.fontWeight ?? 'initial'};` diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 4da9544..029062b 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -1,4 +1,4 @@ -import { tiptapToOdt, type TiptapNode, type TextFormatting, type OdtDocument, type ParagraphBuilder, type TableBuilder, type RowBuilder, type CellBuilder, type CellOptions, type HeaderFooterBuilder } from 'odf-kit'; +import { tiptapToOdt, type TiptapNode, type TiptapMark, type TextFormatting, type OdtDocument, type ParagraphBuilder, type TableBuilder, type RowBuilder, type CellBuilder, type CellOptions, 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'; @@ -1584,10 +1584,9 @@ function collectListMarkerFormats(doc: TiptapNode, result: (MarkerFormat | null) } } -// text:style-name on the level definition points at a style:family="text" style — -// how ODF formats a number/bullet (a run inside the paragraph never reaches it). -// The style has to be a **named** one in styles.xml: LibreOffice ignores the -// reference when it resolves to an automatic style (verified by probe). +// text:style-name on the level definition points at a style:family="text" style — how +// ODF formats a number/bullet. It has to be a **named** style in styles.xml: +// LibreOffice ignores the reference when it resolves to an automatic one (probed). function applyListMarkerFormats(odtBytes: Uint8Array, formats: (MarkerFormat | null)[][]): Uint8Array { if (!formats.some((levels) => levels.some(Boolean))) return odtBytes; @@ -2118,6 +2117,41 @@ function charFormatting(sheet: StyleSheet, name: string): Record bakeListCharStyles(c, sheet, nested)) }; +} + +// The style's props as direct marks, with the run's own marks on top (as in applyRuns). +function bakeMarks(marks: TiptapMark[], t: TextProps): TiptapMark[] { + const out = marks.filter(m => m.type !== 'textStyle'); + const own = marks.find(m => m.type === 'textStyle')?.attrs ?? {}; + const attrs: Record = {}; + if (t.fontFamily) attrs.fontFamily = t.fontFamily; + if (t.fontSizePt != null) attrs.fontSize = `${t.fontSizePt}pt`; + if (t.color) attrs.color = t.color; + for (const [key, value] of Object.entries(own)) if (value != null) attrs[key] = value; + // An explicit weight is the un-bold channel and outranks the style's bold. + const flags = [[t.bold && own.fontWeight == null, 'bold'], [t.italic, 'italic'], + [t.underline, 'underline'], [t.strike, 'strike']] as const; + for (const [on, type] of flags) if (on && !marks.some(m => m.type === type)) out.push({ type }); + if (Object.keys(attrs).length) out.push({ type: 'textStyle', attrs }); + return out; +} + // TextProps as odf-kit run formatting. function textFormatting(t: TextProps): Record { const fmt: Record = {}; @@ -2803,7 +2837,8 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU const textBoxes: TextBoxExport[] = []; const columns: ColumnsExport[] = []; const dateFields: DateTimeFieldExport[] = []; - const raw = replaceDateTimeFields(replaceImages(replaceTabs(replaceHardBreaks(replaceColumns(replaceTextBoxes(replacePageBreaks(replaceTableOfContents(docJson, tocs)), textBoxes), columns))), images), dateFields); + const sentinels = replaceDateTimeFields(replaceImages(replaceTabs(replaceHardBreaks(replaceColumns(replaceTextBoxes(replacePageBreaks(replaceTableOfContents(docJson, tocs)), textBoxes), columns))), images), dateFields); + const raw = 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; // Different first page (ODF header-first): page 1 gets its own zone content. diff --git a/tests/unit/list-marker.test.ts b/tests/unit/list-marker.test.ts index 227f07d..31acaf0 100644 --- a/tests/unit/list-marker.test.ts +++ b/tests/unit/list-marker.test.ts @@ -123,6 +123,21 @@ describe('list marker formatting', () => { expect(lvl).toMatch(/]*w:ascii="Arial"/); }); + // odf-kit's list builder ignores charStyle, so the style is baked into the run's + // marks and its name rides the CST sentinel — else the number would be bold and + // the text it labels plain. + it('carries a character style into a list item, formatting and name', async () => { + const styled = { type: 'doc', content: [jsonList(text('x', [{ type: 'charStyle', attrs: { name: 'Strong Emphasis' } }]))] } as never; + const xml = strFromU8(unzipSync(await buildOdt(styled))['content.xml']); + const span = /[\s\S]*?`).exec(xml)?.[0] ?? ''; + expect(style).toContain('fo:font-weight="bold"'); + // The CST sentinel is gone and the clone carries the named style as its parent. + expect(xml).not.toContain('\ue00e'); + expect(style).toContain('style:parent-style-name="Strong_20_Emphasis"'); + }); + // The style has to land in styles.xml: LibreOffice ignores the level's reference // when it resolves to an automatic style (probed). it('points the ODF level definition at a named character style', () => {