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
, 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 } } | 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(//g) ?? []).find((l) => l.includes('')) ?? '';
expect(lvl).toContain('');
expect(lvl).toContain('');
expect(lvl).toContain(''); // 18pt in half-points
expect(lvl).toContain('');
expect(lvl).toMatch(/]*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 = /[\s\S]*?`).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(/');
expect(styles).toContain('');
expect(content).not.toContain('style:name="MK1"');
});
});
});