fix: carry a character style through a list item on ODF export

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.
This commit is contained in:
Steffen Becker committed 2026-08-07 20:27:31 +00:00
1 parent a8a4a4c32f
commit cd1da67586
3 files changed
+59 -10

No files matched your search

+3 -4
View File
@@ -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'};`
+41 -6
View File
@@ -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<string, unknown
return textFormatting(resolveStyle(sheet, name, 'character').text);
}
// odf-kit's list builder reads a run's marks itself and knows nothing about charStyle,
// and applyRuns never sees a list paragraph — so do its two jobs here: bake the style
// into direct marks (the run then gets a span) and prefix the CST sentinel for it.
function bakeListCharStyles(node: TiptapNode, sheet: StyleSheet, inList = false): TiptapNode {
if (node.type === 'text') {
const name = inList ? charStyleOf(node.marks) : null;
if (!name) return node;
return {
...node,
text: `${CST}${name}${CST}${node.text ?? ''}`,
marks: bakeMarks(node.marks ?? [], resolveStyle(sheet, name, 'character').text),
};
}
if (!node.content?.length) return node;
const nested = inList || node.type === 'bulletList' || node.type === 'orderedList';
return { ...node, content: node.content.map(c => 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<string, unknown> = {};
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<string, unknown> {
const fmt: Record<string, unknown> = {};
@@ -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.
+15
View File
@@ -123,6 +123,21 @@ describe('list marker formatting', () => {
expect(lvl).toMatch(/<w:rFonts[^>]*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 = /<text:list-item>[\s\S]*?<text:span text:style-name="(\w+)"/.exec(xml);
expect(span, 'the run must carry formatting').not.toBeNull();
const style = new RegExp(`<style:style style:name="${span![1]}"[\\s\\S]*?</style:style>`).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', () => {