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:
1 parent
a8a4a4c32f
commit
cd1da67586
3 files changed
+59
-10
No files matched your search
@@ -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
@@ -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.
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user