Files
edentext/tests/unit/list-marker.test.ts
Steffen Becker cd1da67586 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.
2026-08-07 20:27:31 +00:00

152 lines
8.0 KiB
TypeScript

import { describe, it, expect, beforeAll } from 'vitest';
import { unzipSync, strFromU8 } from 'fflate';
import { getSchema } from '@tiptap/core';
import type { Node as PmNode } from '@tiptap/pm/model';
import Document from '@tiptap/extension-document';
import Paragraph from '@tiptap/extension-paragraph';
import Text from '@tiptap/extension-text';
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 { 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';
const schema = getSchema([Document, Paragraph, Text, Bold, Italic, ListItem, OrderedList]);
const bold = (t: string) => schema.text(t, [schema.marks.bold.create()]);
const italic = (t: string) => schema.text(t, [schema.marks.italic.create()]);
const item = (...inline: PmNode[]) =>
schema.nodes.listItem.create(null, schema.nodes.paragraph.create(null, inline));
const doc = (...items: PmNode[]) =>
schema.nodes.doc.create(null, schema.nodes.orderedList.create(null, items));
// The marker custom properties the plugin puts on each <li>, in document order.
function markerStyles(d: PmNode): string[] {
const set = listMarkerDecos(d);
const out: string[] = [];
d.descendants((node, pos) => {
if (node.type.name !== 'listItem') return;
const deco = set.find(pos, pos + 1).find((x) => x.from === pos) as { type?: { attrs?: Record<string, string> } } | undefined;
out.push(deco?.type?.attrs?.style ?? '');
});
return out;
}
// JSON list nodes for the export legs.
const text = (t: string, marks?: unknown[]) => ({ type: 'text', text: t, ...(marks ? { marks } : {}) });
const jsonList = (...inline: unknown[]) => ({
type: 'orderedList',
content: [{ type: 'listItem', content: [{ type: 'paragraph', content: inline }] }],
});
describe('list marker formatting', () => {
// The four cases LibreOffice renders as I. bold, II. plain, III. bold, IV. plain.
it('follows the first text portion, not the rest of the line', () => {
const d = doc(
item(bold('all bold')),
item(schema.text('all plain')),
item(bold('bold'), schema.text(' then plain')),
item(schema.text('plain then '), bold('bold')),
);
expect(markerStyles(d).map((s) => s.includes('--marker-weight:bold'))).toEqual([true, false, true, false]);
});
it('carries slant, and resets every property on an unformatted item', () => {
const [slanted, plain] = markerStyles(doc(item(italic('slanted')), item(schema.text('plain'))));
expect(slanted).toContain('--marker-style:italic');
// Reset, not absent: the properties inherit, so a nested item would take its parent's.
expect(plain).toBe('--marker-family:initial;--marker-weight:initial;--marker-style:initial;'
+ '--marker-size:initial;--marker-color:initial');
});
it('reads family, size and color off the run, falling back to the block size', () => {
expect(listMarkerFormat(jsonList(text('x', [{ type: 'textStyle', attrs: { fontFamily: 'Arial', fontSize: '18pt', color: '#FF0000' } }]))))
.toMatchObject({ fontFamily: 'Arial', fontSize: '18pt', color: '#FF0000' });
expect(listMarkerFormat({
type: 'orderedList',
content: [{ type: 'listItem', content: [{ type: 'paragraph', attrs: { fontSize: '9pt' }, content: [text('x')] }] }],
})).toMatchObject({ fontSize: '9pt' });
expect(listMarkerFormat(jsonList(text('x')))).toBeNull();
});
// A level definition covers every item, so a list whose items disagree gets none
// and both engines fall back to their own per-item rule.
it('has no list-wide format unless every item agrees', () => {
const bolded = { type: 'listItem', content: [{ type: 'paragraph', content: [text('a', [{ type: 'bold' }])] }] };
const plain = { type: 'listItem', content: [{ type: 'paragraph', content: [text('b')] }] };
expect(listMarkerFormat({ type: 'orderedList', content: [bolded, bolded] })).toMatchObject({ fontWeight: 'bold' });
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', () => {
const css = styleCss(builtinStyleSheet());
expect(css).toContain('.paper .tiptap li:has(> p:not([data-style]))');
expect(css).toContain('.paper .tiptap li:has(> [data-style="Heading 1"])');
});
// Both formats carry marker formatting on the level definition, not on the item.
describe('export', () => {
const fixture = {
type: 'doc',
content: [jsonList(text('heading', [{ type: 'bold' }, { type: 'italic' }, { type: 'textStyle', attrs: { fontFamily: 'Arial', fontSize: '18pt', color: '#FF0000' } }]))],
} as never;
let numbering: string;
let content: string;
let styles: string;
beforeAll(async () => {
numbering = strFromU8(unzipSync(await buildDocx(fixture))['word/numbering.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)', () => {
const lvl = (numbering.match(/<w:lvl [\s\S]*?<\/w:lvl>/g) ?? []).find((l) => l.includes('<w:rPr>')) ?? '';
expect(lvl).toContain('<w:b/>');
expect(lvl).toContain('<w:i/>');
expect(lvl).toContain('<w:sz w:val="36"/>'); // 18pt in half-points
expect(lvl).toContain('<w:color w:val="FF0000"/>');
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', () => {
expect(content).toMatch(/<text:list-level-style-number text:level="1" text:style-name="MK1"/);
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"');
});
});
});