From a8a4a4c32fa7c110643986334f6a73f6f82a38f5 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Fri, 7 Aug 2026 18:35:21 +0000 Subject: [PATCH] feat: let a character style on the item's first portion reach the marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/lib/editor/extensions.ts | 5 ++- src/lib/editor/extensions/listMarker.ts | 56 +++++++++++++++++-------- src/lib/export/docx.ts | 4 +- src/lib/export/odt.ts | 13 ++++-- tests/unit/list-marker.test.ts | 24 ++++++++--- 5 files changed, 72 insertions(+), 30 deletions(-) diff --git a/src/lib/editor/extensions.ts b/src/lib/editor/extensions.ts index 27adb3c..67e9031 100644 --- a/src/lib/editor/extensions.ts +++ b/src/lib/editor/extensions.ts @@ -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. diff --git a/src/lib/editor/extensions/listMarker.ts b/src/lib/editor/extensions/listMarker.ts index fe3dec5..6b773ec 100644 --- a/src/lib/editor/extensions/listMarker.ts +++ b/src/lib/editor/extensions/listMarker.ts @@ -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 | null }; type JsonNode = { type?: string; attrs?: Record | 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('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) { diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 5d9bb52..c20365b 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -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 | undefined { - const format = listMarkerFormat(node); + const format = listMarkerFormat(node, charStyleProps(exportSheet)); if (!format) return undefined; const props: Writable = {}; if (format.fontFamily) props.font = format.fontFamily === SCREEN_FONT ? DOC_FONT : format.fontFamily; diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 69160ac..4da9544 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -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('', `${minted.join('\n')}\n`); + files['styles.xml'] = strToU8( + strFromU8(stylesBytes).replace('', `${minted.join('\n')}\n`), + ); } files['content.xml'] = strToU8(content); return rezipOdt(files); diff --git a/tests/unit/list-marker.test.ts b/tests/unit/list-marker.test.ts index 663a4a3..227f07d 100644 --- a/tests/unit/list-marker.test.ts +++ b/tests/unit/list-marker.test.ts @@ -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: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(/'); - expect(content).toContain(''); + expect(styles).toContain(''); + expect(content).not.toContain('style:name="MK1"'); }); }); });