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"'); }); }); });