feat: let a character style on the item's first portion reach the marker

The charStyle mark carries only a name, so resolving it needs the registry: the
plugin takes it as a `sheet` option (like ParagraphStyle), the exporters read
their own exportSheet. The style is the base, the portion's own marks stay on
top. The decoration pass now also re-runs on FORCE_PAGE_RECALC, so editing a
style repaints the markers.

The ODF marker style moves to styles.xml as a named style — LibreOffice ignores
a level's text:style-name when it resolves to an automatic style, which made the
whole ODF leg a no-op (probed both ways).
This commit is contained in:
Steffen Becker committed 2026-08-07 20:27:07 +00:00
1 parent 943a885754
commit a8a4a4c32f
5 files changed
+72 -30

No files matched your search

+3 -2
View File
@@ -115,8 +115,9 @@ export const extensions = [
BulletList,
OrderedList,
ListItem,
// Bullet/number weight: it follows the item's first text portion, as in LibreOffice.
ListMarker,
// Bullet/number formatting: it follows the item's first text portion, as in
// LibreOffice (the sheet resolves a character style on that portion).
ListMarker.configure({ sheet: styleSheet }),
// resizable:false keeps TipTap's columnResizing plugin off; TableView, the two
// resize plugins, and ResizableTableRow supply Word-style drag instead. Table
// (unlike TableKit) doesn't auto-add children, so ResizableTableRow is listed here.
+39 -17
View File
@@ -2,7 +2,8 @@ import { Extension } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
import { cssFontFamily } from '../../styles/styleSheet';
import { cssFontFamily, resolveStyle, type StyleSheet, type TextProps } from '../../styles/styleSheet';
import { FORCE_PAGE_RECALC } from './pageBreaks';
// A list marker's direct formatting. CSS values, since that is what two of the three
// consumers want; the DOCX exporter converts. The family is the plain name, not a CSS
@@ -18,35 +19,48 @@ export interface MarkerFormat {
type MarkLike = { type: string; attrs?: Record<string, unknown> | null };
type JsonNode = { type?: string; attrs?: Record<string, unknown> | null; marks?: MarkLike[] | null; content?: JsonNode[] };
// A named character style on the portion resolves through the registry, which neither
// the plugin nor an exporter owns — both hand in the lookup (`charProps` below).
export type CharStyleProps = (name: string) => TextProps;
export const charStyleProps = (sheet: StyleSheet): CharStyleProps =>
(name) => resolveStyle(sheet, name, 'character').text;
// The formatting a marker takes from the item's **first text portion** — the rule
// LibreOffice numbering uses (Word takes the paragraph mark instead). Verified by
// probe: bold-then-plain gets a bold number, plain-then-bold does not.
export function markerFormat(marks: MarkLike[], blockFontSize: unknown): MarkerFormat | null {
export function markerFormat(marks: MarkLike[], blockFontSize: unknown, charProps?: CharStyleProps): MarkerFormat | null {
const ts = marks.find((m) => m.type === 'textStyle')?.attrs ?? {};
const size = ts.fontSize ?? blockFontSize;
// The portion's character style is the base; its own marks stay on top (as in applyRuns).
const name = marks.find((m) => m.type === 'charStyle')?.attrs?.name;
const cs: TextProps = charProps && typeof name === 'string' && name ? charProps(name) : {};
const size = ts.fontSize ?? (cs.fontSizePt != null ? `${cs.fontSizePt}pt` : null) ?? blockFontSize;
const bold = marks.some((m) => m.type === 'bold') || cs.bold;
const format: MarkerFormat = {
fontFamily: typeof ts.fontFamily === 'string' && ts.fontFamily ? ts.fontFamily : null,
fontFamily: str(ts.fontFamily) ?? cs.fontFamily ?? null,
// An explicit fontWeight is the un-bold channel, so it outranks the bold mark.
fontWeight: ts.fontWeight != null ? String(ts.fontWeight) : marks.some((m) => m.type === 'bold') ? 'bold' : null,
fontStyle: marks.some((m) => m.type === 'italic') ? 'italic' : null,
fontSize: typeof size === 'string' && size ? size : null,
color: typeof ts.color === 'string' && ts.color ? ts.color : null,
fontWeight: ts.fontWeight != null ? String(ts.fontWeight) : bold ? 'bold' : null,
fontStyle: marks.some((m) => m.type === 'italic') || cs.italic ? 'italic' : null,
fontSize: str(size),
color: str(ts.color) ?? cs.color ?? null,
};
return KEYS.some((key) => format[key]) ? format : null;
}
const str = (v: unknown): string | null => (typeof v === 'string' && v ? v : null);
const KEYS = ['fontFamily', 'fontWeight', 'fontStyle', 'fontSize', 'color'] as const;
// The format the whole list agrees on (TipTap JSON), else null — a file carries
// marker formatting per level, so only a uniform list can carry it. Left out, both
// LibreOffice (first portion) and Word (paragraph mark) fall back to their own rule.
export function listMarkerFormat(list: JsonNode): MarkerFormat | null {
export function listMarkerFormat(list: JsonNode, charProps?: CharStyleProps): MarkerFormat | null {
let common: MarkerFormat | null = null;
for (const item of list.content ?? []) {
if (item.type !== 'listItem') continue;
const block = item.content?.[0];
const first = block?.content?.[0];
const format = block && first?.type === 'text' ? markerFormat(first.marks ?? [], block.attrs?.fontSize) : null;
const format = block && first?.type === 'text' ? markerFormat(first.marks ?? [], block.attrs?.fontSize, charProps) : null;
if (!format || (common && !sameFormat(common, format))) return null;
common = format;
}
@@ -68,34 +82,42 @@ function markerStyle(format: MarkerFormat | null): string {
+ `--marker-color:${format?.color ?? 'initial'}`;
}
export function listMarkerDecos(doc: ProseMirrorNode): DecorationSet {
export function listMarkerDecos(doc: ProseMirrorNode, charProps?: CharStyleProps): DecorationSet {
const decos: Decoration[] = [];
doc.descendants((node, pos) => {
if (node.type.name !== 'listItem') return;
decos.push(Decoration.node(pos, pos + node.nodeSize, { style: markerStyle(itemMarkerFormat(node)) }));
decos.push(Decoration.node(pos, pos + node.nodeSize, { style: markerStyle(itemMarkerFormat(node, charProps)) }));
});
return DecorationSet.create(doc, decos);
}
function itemMarkerFormat(item: ProseMirrorNode): MarkerFormat | null {
function itemMarkerFormat(item: ProseMirrorNode, charProps?: CharStyleProps): MarkerFormat | null {
const block = item.firstChild;
const first = block?.firstChild;
if (!block || !first?.isText) return null;
return markerFormat(first.marks.map((m) => ({ type: m.type.name, attrs: m.attrs })), block.attrs.fontSize);
return markerFormat(first.marks.map((m) => ({ type: m.type.name, attrs: m.attrs })), block.attrs.fontSize, charProps);
}
const listMarkerKey = new PluginKey<DecorationSet>('listMarkerFormat');
export const ListMarker = Extension.create({
export const ListMarker = Extension.create<{ sheet: () => StyleSheet }>({
name: 'listMarker',
addOptions() {
return { sheet: () => ({ paragraph: {}, character: {}, table: {} }) };
},
addProseMirrorPlugins() {
const props = () => charStyleProps(this.options.sheet());
return [
new Plugin({
key: listMarkerKey,
state: {
init: (_, state) => listMarkerDecos(state.doc),
apply: (tr, old) => (tr.docChanged ? listMarkerDecos(tr.doc) : old),
init: (_, state) => listMarkerDecos(state.doc, props()),
// An edited registry can change what a character style means, and that
// arrives as FORCE_PAGE_RECALC (Editor.svelte's stylesheet effect).
apply: (tr, old) =>
tr.docChanged || tr.getMeta(FORCE_PAGE_RECALC) ? listMarkerDecos(tr.doc, props()) : old,
},
props: {
decorations(state) {
+2 -2
View File
@@ -21,7 +21,7 @@ import { HF_DISTANCE_CM, hfIsEmpty } from '../storage/headerFooter';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { parseBorderAttr, type BorderSide } from '../editor/extensions/tableCellBorders';
import { parseTabStops, type TabAlign } from '../editor/extensions/tabStops';
import { listMarkerFormat } from '../editor/extensions/listMarker';
import { charStyleProps, listMarkerFormat } from '../editor/extensions/listMarker';
import { effectiveOrderedDefAt, formatOrdinal, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
import { defaultBulletChar } from '../utils/bulletListTypes';
import { normalizeColor, MAX_HEADING_LEVEL, mergeJoinedParagraphsJson, twinFontName, type HfExport } from './odt';
@@ -107,7 +107,7 @@ function hexColor(input: string): string | undefined {
// The level's number/bullet formatting (w:lvl/w:rPr) — Word carries it per level, so
// only a list whose items agree has one (listMarkerFormat).
function markerRunProps(node: TiptapNode): Writable<IRunStylePropertiesOptions> | undefined {
const format = listMarkerFormat(node);
const format = listMarkerFormat(node, charStyleProps(exportSheet));
if (!format) return undefined;
const props: Writable<IRunStylePropertiesOptions> = {};
if (format.fontFamily) props.font = format.fontFamily === SCREEN_FONT ? DOC_FONT : format.fontFamily;
+9 -4
View File
@@ -16,7 +16,7 @@ import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { BORDER_SIDES, parseBorderAttr } from '../editor/extensions/tableCellBorders';
import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox';
import { parseTabStops } from '../editor/extensions/tabStops';
import { listMarkerFormat, type MarkerFormat } from '../editor/extensions/listMarker';
import { charStyleProps, listMarkerFormat, type MarkerFormat } from '../editor/extensions/listMarker';
import { orderedTypeDef, effectiveOrderedDef, effectiveOrderedDefAt, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
import { DEFAULT_BULLET_CYCLE, defaultBulletChar } from '../utils/bulletListTypes';
import { findFormat, renderFormat, odfNumberStyle, toDateValue, toTimeValue, localeTag, DEFAULT_DATE_FORMAT, DEFAULT_TIME_FORMAT, type DtFormat } from '../utils/dateTime';
@@ -1572,7 +1572,7 @@ function collectListMarkerFormats(doc: TiptapNode, result: (MarkerFormat | null)
if (child.type !== 'bulletList' && child.type !== 'orderedList') continue;
const levels: (MarkerFormat | null)[] = [];
const visit = (list: TiptapNode, depth: number) => {
if (levels[depth - 1] === undefined) levels[depth - 1] = listMarkerFormat(list);
if (levels[depth - 1] === undefined) levels[depth - 1] = listMarkerFormat(list, charStyleProps(exportSheet));
for (const item of list.content ?? []) {
for (const block of item.content ?? []) {
if (block.type === 'bulletList' || block.type === 'orderedList') visit(block, depth + 1);
@@ -1586,12 +1586,15 @@ 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).
function applyListMarkerFormats(odtBytes: Uint8Array, formats: (MarkerFormat | null)[][]): Uint8Array {
if (!formats.some((levels) => levels.some(Boolean))) return odtBytes;
const files = unzipSync(odtBytes);
const contentBytes = files['content.xml'];
if (!contentBytes) return odtBytes;
const stylesBytes = files['styles.xml'];
if (!contentBytes || !stylesBytes) return odtBytes;
let content = strFromU8(contentBytes);
const minted: string[] = [];
@@ -1626,7 +1629,9 @@ function applyListMarkerFormats(odtBytes: Uint8Array, formats: (MarkerFormat | n
});
if (minted.length) {
content = content.replace('</office:automatic-styles>', `${minted.join('\n')}\n</office:automatic-styles>`);
files['styles.xml'] = strToU8(
strFromU8(stylesBytes).replace('</office:styles>', `${minted.join('\n')}\n</office:styles>`),
);
}
files['content.xml'] = strToU8(content);
return rezipOdt(files);
+19 -5
View File
@@ -9,7 +9,7 @@ import Bold from '@tiptap/extension-bold';
import Italic from '@tiptap/extension-italic';
import ListItem from '@tiptap/extension-list-item';
import { OrderedList } from '../../src/lib/editor/extensions/orderedList';
import { listMarkerDecos, listMarkerFormat } from '../../src/lib/editor/extensions/listMarker';
import { charStyleProps, listMarkerDecos, listMarkerFormat } from '../../src/lib/editor/extensions/listMarker';
import { builtinStyleSheet, styleCss } from '../../src/lib/styles/styleSheet';
import { buildOdt } from '../../src/lib/export/odt';
import { buildDocx } from '../../src/lib/export/docx';
@@ -81,6 +81,14 @@ describe('list marker formatting', () => {
expect(listMarkerFormat({ type: 'orderedList', content: [bolded, plain] })).toBeNull();
});
// A charStyle mark carries only a name, so the registry has to resolve it — the
// caller hands in the lookup (the plugin from its option, the exporters from the sheet).
it('resolves a character style on the portion', () => {
const list = jsonList(text('x', [{ type: 'charStyle', attrs: { name: 'Strong Emphasis' } }]));
expect(listMarkerFormat(list)).toBeNull();
expect(listMarkerFormat(list, charStyleProps(builtinStyleSheet()))).toMatchObject({ fontWeight: 'bold' });
});
// The marker inherits the item's font, so the style rule has to reach the item and
// not only its paragraph — else an imported document's numbers keep the editor default.
it('gives the list item its paragraph style\'s text half', () => {
@@ -97,10 +105,13 @@ describe('list marker formatting', () => {
} as never;
let numbering: string;
let content: string;
let styles: string;
beforeAll(async () => {
numbering = strFromU8(unzipSync(await buildDocx(fixture))['word/numbering.xml']);
content = strFromU8(unzipSync(await buildOdt(fixture))['content.xml']);
const odt = unzipSync(await buildOdt(fixture));
content = strFromU8(odt['content.xml']);
styles = strFromU8(odt['styles.xml']);
});
it('writes the DOCX level run properties (w:lvl/w:rPr)', () => {
@@ -112,11 +123,14 @@ describe('list marker formatting', () => {
expect(lvl).toMatch(/<w:rFonts[^>]*w:ascii="Arial"/);
});
it('points the ODF level definition at a minted character style', () => {
// 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', () => {
expect(content).toMatch(/<text:list-level-style-number text:level="1" text:style-name="MK1"/);
expect(content).toContain('<style:style style:name="MK1" style:family="text">');
expect(content).toContain('<style:text-properties fo:font-family="Arial" fo:font-weight="bold"'
expect(styles).toContain('<style:style style:name="MK1" style:family="text">');
expect(styles).toContain('<style:text-properties fo:font-family="Arial" fo:font-weight="bold"'
+ ' fo:font-style="italic" fo:font-size="18pt" fo:color="#FF0000"/>');
expect(content).not.toContain('style:name="MK1"');
});
});
});