// Tab stops: positions are cm from the left text margin in both formats (probe: a Word // stop at 100mm in a 30mm-indented paragraph stays 100mm in ODF and renders there), so // neither side shifts them by the paragraph indent. import { describe, it, expect } from 'vitest'; import { unzipSync, strFromU8 } from 'fflate'; import { buildOdt } from '../../src/lib/export/odt'; import { buildDocx } from '../../src/lib/export/docx'; import { importOdt } from '../../src/lib/import/odt'; import { importDocx } from '../../src/lib/import/docx'; import { parseTabStops, formatTabStops, nextStopCm } from '../../src/lib/editor/extensions/tabStops'; type N = any; const P = (text: string, attrs?: N): N => ({ type: 'paragraph', ...(attrs ? { attrs } : {}), content: [{ type: 'text', text }] }); const doc: N = { type: 'doc', content: [ P('plain'), P('left\tcentred\tright\t12.34', { tabStops: '6c;12r;16d' }), P('indented\tright', { indent: 3, tabStops: '10r' }), ], }; const margins = { top: 2, bottom: 2, left: 2, right: 2 }; const stopsOf = (d: N, i: number) => d.content[i].attrs?.tabStops ?? null; describe('tab stops', () => { it('parses and formats the canonical attr form', () => { expect(parseTabStops('6c;12r;16d')).toEqual([ { pos: 6, align: 'center', leader: null }, { pos: 12, align: 'right', leader: null }, { pos: 16, align: 'decimal', leader: null }, ]); // Sorted, deduped by position, rounded to 0.01cm; empty becomes null. expect(formatTabStops([{ pos: 12, align: 'right' }, { pos: 1.004, align: 'left' }])).toBe('1l;12r'); expect(formatTabStops([])).toBe(null); expect(parseTabStops(null)).toEqual([]); }); it('advances to the next stop, else the next grid multiple', () => { // The rule a run of tabs is walked with: LibreOffice carries the run onto the next // line and restarts the grid there, which is what the break widget reproduces. expect(nextStopCm(0, [], 1.25)).toBeCloseTo(1.25, 3); expect(nextStopCm(1.25, [], 1.25)).toBeCloseTo(2.5, 3); expect(nextStopCm(1.65, [], 1.25)).toBeCloseTo(2.5, 3); // A custom stop right of the pen wins over the grid; past the last one the grid // takes over again, on its own absolute multiples rather than from that stop. expect(nextStopCm(1, [6, 12], 1.25)).toBeCloseTo(6, 3); expect(nextStopCm(6, [6, 12], 1.25)).toBeCloseTo(12, 3); expect(nextStopCm(12, [6, 12], 1.25)).toBeCloseTo(12.5, 3); }); it('round-trips through ODF', async () => { const bytes = await buildOdt(doc, margins, 'portrait'); const xml = strFromU8(unzipSync(bytes)['content.xml']); expect(xml).toMatch(//); // ODF's decimal stop is style:type="char" plus the separator itself. expect(xml).toMatch(/style:position="16cm" style:type="char" style:char="\."/); const back = importOdt(bytes).content as N; expect(stopsOf(back, 0)).toBe(null); expect(stopsOf(back, 1)).toBe('6c;12r;16d'); expect(stopsOf(back, 2)).toBe('10r'); }); it('round-trips through DOCX', async () => { const bytes = await buildDocx(doc, margins, 'portrait'); const xml = strFromU8(unzipSync(bytes)['word/document.xml']); expect(xml).toMatch(//); expect(xml).toMatch(//); // The indented paragraph's stop keeps its margin-relative position. expect(xml).toMatch(//); const back = importDocx(bytes).content as N; expect(stopsOf(back, 0)).toBe(null); expect(stopsOf(back, 1)).toBe('6c;12r;16d'); expect(stopsOf(back, 2)).toBe('10r'); }); });