LibreOffice restarts the tab grid on the continuation line; Chromium hangs the leftover tabs and starts the next line at the margin, 160mm away in the repro. The run is walked from the pen before it — the one thing the fix can't move, so the answer holds whether or not the break is already in place — and a break widget goes where the line runs out. Measuring this turned up why the two disagreed in the first place: a file that declares no tab interval falls back to ODF's own 2cm, not to LibreOffice's UI default of 1.25, so leaving ours implied on export rendered every tab elsewhere. Both exports declare it now, and both imports fall back to their format's value rather than to ours. Only runs of two or more are walked: reading a block's geometry forces a reflow, and doing it per tab across 48 pages slowed pagination enough that the parity harness compared a layout still settling.
82 lines
3.6 KiB
TypeScript
82 lines
3.6 KiB
TypeScript
// 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(/<style:tab-stop style:position="6cm" style:type="center"\/>/);
|
|
// 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(/<w:tab w:val="center" w:pos="3402"\/>/);
|
|
expect(xml).toMatch(/<w:tab w:val="decimal" w:pos="9071"\/>/);
|
|
// The indented paragraph's stop keeps its margin-relative position.
|
|
expect(xml).toMatch(/<w:tab w:val="right" w:pos="5669"\/>/);
|
|
|
|
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');
|
|
});
|
|
});
|