1014 lines
60 KiB
TypeScript
1014 lines
60 KiB
TypeScript
// Round-trip verification: editor JSON -> buildOdt() -> importOdt() -> compare,
|
||
// plus a hand-crafted LibreOffice/Word-style .odt exercising the style resolver.
|
||
// jsdom (vitest `environment`) supplies the global DOMParser.
|
||
import { describe, it, expect } from 'vitest';
|
||
import { zipSync, strToU8, unzipSync, strFromU8 } from 'fflate';
|
||
import { getSchema } from '@tiptap/core';
|
||
import { Node as PMNode } from '@tiptap/pm/model';
|
||
import { buildOdt } from '../src/lib/export/odt';
|
||
import { importOdt } from '../src/lib/import/odt';
|
||
import { hfExtensions } from '../src/lib/editor/extensions/headerFooter';
|
||
import { HEADER_SHADE } from '../src/lib/editor/extensions/tableHeaderRow';
|
||
|
||
type N = any;
|
||
|
||
// `expect.soft` so every assertion in a leg reports rather than bailing on the first.
|
||
function check(label: string, cond: boolean, detail?: unknown) {
|
||
expect.soft(cond, detail !== undefined ? `${label} — ${JSON.stringify(detail)}` : label).toBe(true);
|
||
}
|
||
|
||
// ---------- helpers to build editor-shaped JSON ----------
|
||
const T = (text: string, ...marks: N[]): N => ({ type: 'text', text, ...(marks.length ? { marks } : {}) });
|
||
const P = (attrs: N | null, ...content: N[]): N => ({ type: 'paragraph', ...(attrs ? { attrs } : {}), ...(content.length ? { content } : {}) });
|
||
const H = (attrs: N, ...content: N[]): N => ({ type: 'heading', attrs, ...(content.length ? { content } : {}) });
|
||
const LI = (...content: N[]): N => ({ type: 'listItem', content });
|
||
const CELL = (colwidth: number[] | null, ...content: N[]): N =>
|
||
({ type: 'tableCell', attrs: { colspan: 1, rowspan: 1, colwidth }, content });
|
||
const CELLM = (colspan: number, rowspan: number, colwidth: number[] | null, text: string, bg?: string): N =>
|
||
({ type: 'tableCell', attrs: { colspan, rowspan, colwidth, ...(bg ? { backgroundColor: bg } : {}) }, content: [P(null, T(text))] });
|
||
const ROW = (...cells: N[]): N => ({ type: 'tableRow', content: cells });
|
||
|
||
// A tiny valid PNG; only its bytes matter for the round-trip (no image decoding).
|
||
const PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||
const IMGN = (width: number, height: number, alt?: string, rotation?: number, wrap?: string): N =>
|
||
({ type: 'image', attrs: {
|
||
src: PNG, width, height,
|
||
...(alt ? { alt } : {}), ...(rotation ? { rotation } : {}), ...(wrap ? { wrap } : {}),
|
||
} });
|
||
const TBX = (attrs: N, ...content: N[]): N => ({ type: 'textBox', attrs, content });
|
||
const COLS = (attrs: N, ...content: N[]): N => ({ type: 'columns', attrs, content });
|
||
|
||
const margins = { top: 3, bottom: 2, left: 2.5, right: 1.5 };
|
||
|
||
const fixture: N = {
|
||
type: 'doc',
|
||
content: [
|
||
H({ level: 1, textAlign: 'center' }, T('Invoice Report 2026')),
|
||
P({ fontSize: '22pt' }), // empty sized line (bare)
|
||
P({ fontSize: '18pt', textAlign: 'center' }), // empty sized line + centered
|
||
P(null,
|
||
T('Plain '),
|
||
T('bold', { type: 'bold' }),
|
||
T(' italic', { type: 'italic' }),
|
||
T(' under', { type: 'underline' }),
|
||
T(' struck', { type: 'strike' }),
|
||
T(' sup', { type: 'superscript' }),
|
||
T(' sub', { type: 'subscript' }),
|
||
),
|
||
P(null,
|
||
T('arial 14 ', { type: 'textStyle', attrs: { fontFamily: 'Arial', fontSize: '14pt' } }),
|
||
T('red', { type: 'textStyle', attrs: { color: '#C00000' } }),
|
||
T(' marked', { type: 'highlight', attrs: { color: '#FFFF00' } }),
|
||
),
|
||
// Links: native run path, plus a bold link inside a styled (CUST_P) paragraph.
|
||
P(null, T('Visit '), T('our site', { type: 'link', attrs: { href: 'https://example.com/' } }), T(' today.')),
|
||
P({ lineHeight: '1.5' },
|
||
T('A '),
|
||
T('bold link', { type: 'bold' }, { type: 'link', attrs: { href: 'https://styled.example/' } }),
|
||
T(' here.'),
|
||
),
|
||
P({ textAlign: 'justify', lineHeight: '1.5', spaceBefore: 12, spaceAfter: 18 }, T('spaced and justified paragraph')),
|
||
P({ indent: 2.5 }, T('indented paragraph')),
|
||
P(null, T('first line'), { type: 'hardBreak' }, T('second line')),
|
||
P(null, T('before\ttab\tafter')),
|
||
P(null, T('logo: '), IMGN(100, 50, 'Logo')),
|
||
P(null, T('rotated: '), IMGN(120, 80, 'Rotated', 30)),
|
||
P(null, T('wrapped left '), IMGN(90, 60, 'Float', 0, 'left'), T(' text flows beside it')),
|
||
P(null, T('top/bottom '), IMGN(70, 50, 'Banner', 0, 'topBottom')),
|
||
TBX({ width: 288, height: 96 }, P(null, T('box para one')), P(null, T('box '), T('bold', { type: 'bold' }))),
|
||
TBX({ width: 192, height: 80, wrap: 'right', shapeKind: 'ellipse', fillColor: '#FFEE00', strokeColor: '#FF0000', strokeWidthPt: 2.25, rotation: 30 }, P(null, T('in ellipse'))),
|
||
COLS({ count: 2, gapCm: 0.5 }, P(null, T('newspaper column text one')), P(null, T('newspaper column text two'))),
|
||
{ type: 'bulletList', content: [
|
||
LI(P(null, T('bullet one'))),
|
||
LI(P(null, T('bullet two')), { type: 'orderedList', attrs: { listStyleType: 'upper-roman-paren' }, content: [
|
||
LI(P(null, T('nested i'))),
|
||
LI(P(null, T('nested ii'))),
|
||
] }),
|
||
] },
|
||
{ type: 'orderedList', attrs: { listStyleType: 'lower-alpha' }, content: [
|
||
LI(P({ textAlign: 'center' }, T('centered item'))),
|
||
LI(P(null, T('plain item'))),
|
||
] },
|
||
// Depth-default numbering: attr-less nested levels export as a./i. and re-import
|
||
// as null (cycle suppression).
|
||
{ type: 'orderedList', content: [
|
||
LI(P(null, T('cycle one')), { type: 'orderedList', content: [
|
||
LI(P(null, T('cycle sub')), { type: 'orderedList', content: [
|
||
LI(P(null, T('cycle subsub'))),
|
||
] }),
|
||
] }),
|
||
] },
|
||
// Re-anchoring: an explicit "a., b." parent makes its attr-less child default to
|
||
// i. (not another a., b.), and the child re-imports as null (no accreted attr).
|
||
{ type: 'orderedList', attrs: { listStyleType: 'lower-alpha' }, content: [
|
||
LI(P(null, T('reanchor a')), { type: 'orderedList', content: [
|
||
LI(P(null, T('reanchor i'))),
|
||
] }),
|
||
] },
|
||
// Suffix inheritance: an explicit "a)" parent makes attr-less children default to
|
||
// i) then 1) (paren, not dot), all re-importing as null.
|
||
{ type: 'orderedList', attrs: { listStyleType: 'lower-alpha-paren' }, content: [
|
||
LI(P(null, T('paren a')), { type: 'orderedList', content: [
|
||
LI(P(null, T('paren i')), { type: 'orderedList', content: [
|
||
LI(P(null, T('paren 1'))),
|
||
] }),
|
||
] }),
|
||
] },
|
||
// Multilevel (1. / 1.1. / 1.1.1.): attr on the top list only; an explicit style
|
||
// inside the chain breaks out (NL mint) and must survive as an explicit attr.
|
||
{ type: 'orderedList', attrs: { listStyleType: 'multilevel' }, content: [
|
||
LI(P(null, T('ml one')), { type: 'orderedList', content: [
|
||
LI(P(null, T('ml one-one')), { type: 'orderedList', content: [
|
||
LI(P(null, T('ml deep'))),
|
||
] }),
|
||
LI(P(null, T('ml one-two')), { type: 'orderedList', attrs: { listStyleType: 'upper-alpha' }, content: [
|
||
LI(P(null, T('ml override'))),
|
||
] }),
|
||
] }),
|
||
LI(P(null, T('ml two'))),
|
||
] },
|
||
{ type: 'bulletList', attrs: { indent: 2.5 }, content: [
|
||
LI(P(null, T('shifted bullet a'))),
|
||
LI(P(null, T('shifted bullet b'))),
|
||
] },
|
||
// Custom bullet chars: ❖ at level 1; ✓ on the DFS-first nested list (drives the
|
||
// L# level-2 char); a default nested sibling (must NL-mint back to ◦).
|
||
{ type: 'bulletList', attrs: { bulletChar: '❖' }, content: [
|
||
LI(P(null, T('diamond one')), { type: 'bulletList', attrs: { bulletChar: '✓' }, content: [
|
||
LI(P(null, T('check nested'))),
|
||
] }),
|
||
LI(P(null, T('diamond two')), { type: 'bulletList', content: [
|
||
LI(P(null, T('default nested'))),
|
||
] }),
|
||
] },
|
||
H({ level: 2 }, T('Un', { type: 'textStyle', attrs: { fontWeight: 'normal' } }), T('bolded')),
|
||
{ type: 'table', content: [
|
||
{ type: 'tableRow', attrs: { rowHeight: 60 }, content: [
|
||
CELL([120],
|
||
H({ level: 3 }, T('Cell head')),
|
||
P(null, T('cell para')),
|
||
{ type: 'bulletList', attrs: { bulletChar: '➢' }, content: [
|
||
LI(P(null, T('cell bullet'))),
|
||
LI(P(null, T('with nested')), { type: 'orderedList', attrs: { listStyleType: 'decimal-paren' }, content: [
|
||
LI(P(null, T('cell nested 1'))),
|
||
] }),
|
||
] },
|
||
),
|
||
CELL([240], P(null, IMGN(80, 40))),
|
||
] },
|
||
{ type: 'tableRow', content: [
|
||
CELL([120], P(null, T('A2'))),
|
||
CELL([240], P(null, T('B2 '), T('cell link', { type: 'link', attrs: { href: 'https://cell.example/' } }))),
|
||
] },
|
||
] },
|
||
P({ breakBefore: 'page', textAlign: 'center', lineHeight: '1.5' }, T('Forced page (styled)')),
|
||
P({ breakBefore: 'page' }, T('Forced page (plain)')),
|
||
P(null, T('The end.')),
|
||
],
|
||
};
|
||
|
||
// ---------- normalization for comparison ----------
|
||
const ORDERED_DEFAULTS: Record<string, unknown> = {
|
||
textAlign: 'left', lineHeight: null, spaceBefore: null, spaceAfter: null,
|
||
listStyleType: 'decimal', start: 1, rowHeight: null, colspan: 1, rowspan: 1,
|
||
type: null, level: undefined, rotation: 0, wrap: 'inline',
|
||
shapeKind: 'textbox', fillColor: '#FFFFFF', strokeColor: '#000000', strokeWidthPt: 1,
|
||
};
|
||
|
||
function normalize(node: N): N {
|
||
const out: N = { type: node.type };
|
||
if (node.text != null) out.text = node.text;
|
||
if (node.marks?.length) {
|
||
out.marks = node.marks
|
||
.map((m: N) => {
|
||
const mm: N = { type: m.type };
|
||
const attrs = Object.fromEntries(Object.entries(m.attrs ?? {}).filter(([, v]) => v != null));
|
||
if (Object.keys(attrs).length) mm.attrs = attrs;
|
||
return mm;
|
||
})
|
||
.sort((a: N, b: N) => a.type.localeCompare(b.type));
|
||
}
|
||
const attrs: N = {};
|
||
for (const [k, v] of Object.entries(node.attrs ?? {})) {
|
||
if (v == null) continue;
|
||
if (k in ORDERED_DEFAULTS && ORDERED_DEFAULTS[k] === v) continue;
|
||
if (k === 'colwidth') { attrs.colwidth = 'CW'; continue; } // ratios compared separately
|
||
attrs[k] = v;
|
||
}
|
||
if (node.attrs?.level != null) attrs.level = node.attrs.level;
|
||
if (Object.keys(attrs).length) out.attrs = attrs;
|
||
if (node.content?.length) {
|
||
// merge adjacent identical text nodes so run-splitting differences don't matter
|
||
const kids: N[] = [];
|
||
for (const c of node.content.map(normalize)) {
|
||
const prev = kids[kids.length - 1];
|
||
if (prev && prev.type === 'text' && c.type === 'text' &&
|
||
JSON.stringify(prev.marks ?? null) === JSON.stringify(c.marks ?? null)) {
|
||
prev.text += c.text;
|
||
} else kids.push(c);
|
||
}
|
||
out.content = kids;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function firstDiff(a: N, b: N, path = '$'): string | null {
|
||
if (typeof a !== typeof b) return `${path}: type ${typeof a} vs ${typeof b}`;
|
||
if (typeof a !== 'object' || a === null || b === null) {
|
||
return Object.is(a, b) ? null : `${path}: ${JSON.stringify(a)} vs ${JSON.stringify(b)}`;
|
||
}
|
||
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
||
for (const k of keys) {
|
||
const d = firstDiff(a[k], b[k], `${path}.${k}`);
|
||
if (d) return d;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function collectColwidths(node: N, acc: number[][] = []): number[][] {
|
||
if (Array.isArray(node.attrs?.colwidth)) acc.push(node.attrs.colwidth);
|
||
for (const c of node.content ?? []) collectColwidths(c, acc);
|
||
return acc;
|
||
}
|
||
|
||
function collectImages(node: N, acc: N[] = []): N[] {
|
||
if (node.type === 'image') acc.push(node);
|
||
for (const c of node.content ?? []) collectImages(c, acc);
|
||
return acc;
|
||
}
|
||
|
||
describe('Leg 1: editor → buildOdt → importOdt', () => {
|
||
it('round-trips the full fixture (margins, orientation, marks, images, lists, table)', async () => {
|
||
const bytes = await buildOdt(fixture, margins, 'landscape');
|
||
const res = importOdt(bytes);
|
||
|
||
check('no warnings on own export', res.warnings.length === 0, res.warnings);
|
||
check('orientation round-trips', res.orientation === 'landscape', res.orientation);
|
||
const m = res.margins!;
|
||
check('margins round-trip', !!m && Math.abs(m.top - 3) < 0.02 && Math.abs(m.bottom - 2) < 0.02 &&
|
||
Math.abs(m.left - 2.5) < 0.02 && Math.abs(m.right - 1.5) < 0.02, m);
|
||
|
||
const diff = firstDiff(normalize(fixture), normalize(res.content));
|
||
check('document JSON round-trips', diff === null, diff);
|
||
|
||
// Empty lines keep their paragraph font size (drives the empty line's height).
|
||
const sized = (res.content.content ?? []).filter((n: N) => n.type === 'paragraph' && !n.content?.length && n.attrs?.fontSize);
|
||
check('empty sized lines round-trip', sized.length === 2, sized.map((n: N) => n.attrs.fontSize));
|
||
check('bare empty line keeps 22pt', sized.some((n: N) => n.attrs.fontSize === '22pt'), sized);
|
||
|
||
// Images: src bytes (data-URI) and px size must round-trip exactly (body + cell).
|
||
const logoPara = (res.content.content ?? []).find((n: N) => n.content?.some((c: N) => c.type === 'image'));
|
||
const bodyImg = logoPara?.content?.find((c: N) => c.type === 'image');
|
||
check('image src round-trips (data-URI bytes)', bodyImg?.attrs?.src === PNG, bodyImg?.attrs?.src?.slice(0, 40));
|
||
check('image size round-trips (100×50 px)', bodyImg?.attrs?.width === 100 && bodyImg?.attrs?.height === 50, bodyImg?.attrs);
|
||
check('image alt round-trips', bodyImg?.attrs?.alt === 'Logo', bodyImg?.attrs);
|
||
const cellImg = collectImages(res.content).find((i: N) => i.attrs?.width === 80);
|
||
check('image in table cell round-trips (80×40 px)', cellImg?.attrs?.height === 40 && cellImg?.attrs?.src === PNG, cellImg?.attrs);
|
||
const rotImg = collectImages(res.content).find((i: N) => i.attrs?.rotation);
|
||
check('image rotation round-trips (30°, 120×80)', rotImg?.attrs?.rotation === 30 && rotImg?.attrs?.width === 120 && rotImg?.attrs?.height === 80, rotImg?.attrs);
|
||
const wrapImg = collectImages(res.content).find((i: N) => i.attrs?.wrap === 'left');
|
||
check('image wrap=left round-trips', wrapImg?.attrs?.width === 90 && wrapImg?.attrs?.height === 60, wrapImg?.attrs);
|
||
const tbImg = collectImages(res.content).find((i: N) => i.attrs?.wrap === 'topBottom');
|
||
check('image wrap=topBottom round-trips', !!tbImg, tbImg?.attrs);
|
||
|
||
// Hyperlinks: collect every link mark's href from the imported doc (body + cell).
|
||
const links: string[] = [];
|
||
(function walkLinks(n: N) {
|
||
for (const m of n.marks ?? []) if (m.type === 'link' && m.attrs?.href) links.push(m.attrs.href);
|
||
for (const c of n.content ?? []) walkLinks(c);
|
||
})(res.content);
|
||
check('native-path link round-trips', links.includes('https://example.com/'), links);
|
||
check('styled-path (CUST_P) bold link round-trips', links.includes('https://styled.example/'), links);
|
||
check('table-cell link round-trips', links.includes('https://cell.example/'), links);
|
||
|
||
const indented = (res.content.content ?? []).find((n: N) => n.content?.[0]?.text === 'indented paragraph');
|
||
check('indent → fo:margin-left round-trips (2.5cm)',
|
||
indented?.attrs?.indent != null && Math.abs(indented.attrs.indent - 2.5) < 0.02, indented?.attrs);
|
||
|
||
const shiftedList = (res.content.content ?? []).find(
|
||
(n: N) => n.type === 'bulletList' && n.content?.[0]?.content?.[0]?.content?.[0]?.text === 'shifted bullet a');
|
||
check('whole-list indent round-trips (2.5cm)',
|
||
shiftedList?.attrs?.indent != null && Math.abs(shiftedList.attrs.indent - 2.5) < 0.02, shiftedList?.attrs);
|
||
|
||
const cwIn = collectColwidths(fixture);
|
||
const cwOut = collectColwidths(res.content);
|
||
check('colwidth count matches', cwIn.length === cwOut.length, { in: cwIn.length, out: cwOut.length });
|
||
// per-cell weights are slices of one column set: compare cell-width fractions
|
||
const fracIn = cwIn.flat().map((w, _, all) => w / all.reduce((a, b) => a + b, 0));
|
||
const fracOut = cwOut.flat().map((w, _, all) => w / all.reduce((a, b) => a + b, 0));
|
||
check('column ratios round-trip', fracIn.length === fracOut.length &&
|
||
fracIn.every((f, i) => Math.abs(f - fracOut[i]) < 0.02), { fracIn, fracOut });
|
||
});
|
||
});
|
||
|
||
describe('Leg 1b: merged table cells (colspan/rowspan)', () => {
|
||
// 3×3 grid: A spans 2 cols (row 0); C spans 2 rows (col 0, rows 1–2).
|
||
// row0: [A A][B] row1: [C][D][E] row2: [C][F][G]
|
||
const mergedDoc: N = {
|
||
type: 'doc',
|
||
content: [
|
||
P(null, T('Merged cells:')),
|
||
{ type: 'table', content: [
|
||
ROW(CELLM(2, 1, [100, 100], 'A', '#FFFF00'), CELLM(1, 1, [100], 'B')),
|
||
ROW(CELLM(1, 2, [100], 'C'), CELLM(1, 1, [100], 'D'), CELLM(1, 1, [100], 'E')),
|
||
ROW(CELLM(1, 1, [100], 'F'), CELLM(1, 1, [100], 'G')),
|
||
] },
|
||
],
|
||
};
|
||
|
||
it('exports spans + covered cells and re-imports them', async () => {
|
||
const bytes = await buildOdt(mergedDoc, margins, 'portrait');
|
||
const content = strFromU8(unzipSync(bytes)['content.xml']);
|
||
check('content.xml emits number-columns-spanned=2', content.includes('table:number-columns-spanned="2"'));
|
||
check('content.xml emits number-rows-spanned=2', content.includes('table:number-rows-spanned="2"'));
|
||
check('content.xml emits a covered-table-cell', content.includes('<table:covered-table-cell'));
|
||
check('content.xml emits cell shading (fo:background-color)', content.includes('fo:background-color="#FFFF00"'), content.match(/fo:background-color="[^"]*"/g));
|
||
|
||
const res = importOdt(bytes);
|
||
check('no warnings on own export', res.warnings.length === 0, res.warnings);
|
||
const table = (res.content.content ?? []).find((n: N) => n.type === 'table');
|
||
const rows = table?.content ?? [];
|
||
check('row 0 has 2 cells', rows[0]?.content?.length === 2, rows[0]?.content?.length);
|
||
check('row 1 has 3 cells', rows[1]?.content?.length === 3, rows[1]?.content?.length);
|
||
check('row 2 has 2 cells (covered slot skipped)', rows[2]?.content?.length === 2, rows[2]?.content?.length);
|
||
check('A has colspan 2', rows[0]?.content?.[0]?.attrs?.colspan === 2, rows[0]?.content?.[0]?.attrs);
|
||
check('C has rowspan 2', rows[1]?.content?.[0]?.attrs?.rowspan === 2, rows[1]?.content?.[0]?.attrs);
|
||
check('A shading round-trips (#FFFF00)', rows[0]?.content?.[0]?.attrs?.backgroundColor === '#FFFF00', rows[0]?.content?.[0]?.attrs?.backgroundColor);
|
||
check('B has no shading', rows[0]?.content?.[1]?.attrs?.backgroundColor == null, rows[0]?.content?.[1]?.attrs?.backgroundColor);
|
||
|
||
const textOf = (cell: N) => cell?.content?.[0]?.content?.[0]?.text;
|
||
check('A text preserved', textOf(rows[0]?.content?.[0]) === 'A', textOf(rows[0]?.content?.[0]));
|
||
check('F at col 1 of row 2', textOf(rows[2]?.content?.[0]) === 'F', textOf(rows[2]?.content?.[0]));
|
||
check('G at col 2 of row 2', textOf(rows[2]?.content?.[1]) === 'G', textOf(rows[2]?.content?.[1]));
|
||
});
|
||
});
|
||
|
||
describe('Leg 1c: header-row cells (bold-by-default, editable)', () => {
|
||
// Header-shaded cells render bold via CSS; bold is editable via fontWeight:normal.
|
||
// Round-trip: a default-bold run carries no mark (CSS bolds), an un-bolded run keeps
|
||
// fontWeight:normal, and export bakes bold so Word/LibreOffice match.
|
||
const hcell = (...content: N[]): N =>
|
||
({ type: 'tableCell', attrs: { colspan: 1, rowspan: 1, colwidth: [100], backgroundColor: HEADER_SHADE }, content: [P(null, ...content)] });
|
||
const FW_NORMAL = { type: 'textStyle', attrs: { fontWeight: 'normal' } };
|
||
|
||
const doc: N = { type: 'doc', content: [
|
||
{ type: 'table', content: [
|
||
ROW(hcell(T('Bold')), hcell(T('Plain', FW_NORMAL))),
|
||
ROW(CELLM(1, 1, [100], 'x'), CELLM(1, 1, [100], 'y')),
|
||
] },
|
||
] };
|
||
|
||
it('bakes header bold on export and round-trips an un-bolded run', async () => {
|
||
const bytes = await buildOdt(doc, margins, 'portrait');
|
||
const xml = strFromU8(unzipSync(bytes)['content.xml']);
|
||
check('export bakes bold on the default-bold header run', /fo:font-weight="bold"/.test(xml), xml.match(/fo:font-weight="[^"]*"/g));
|
||
check('export emits the un-bold override', /fo:font-weight="normal"/.test(xml));
|
||
|
||
const res = importOdt(bytes);
|
||
const table = (res.content.content ?? []).find((n: N) => n.type === 'table');
|
||
const row0 = table?.content?.[0]?.content ?? [];
|
||
const marksOf = (cell: N) => cell?.content?.[0]?.content?.[0]?.marks ?? [];
|
||
// Both cells keep the header fill.
|
||
check('header cells keep the fill', row0[0]?.attrs?.backgroundColor === HEADER_SHADE && row0[1]?.attrs?.backgroundColor === HEADER_SHADE, row0.map((c: N) => c?.attrs?.backgroundColor));
|
||
// Default-bold run carries no mark (CSS provides bold).
|
||
check('default-bold run has no bold mark', !marksOf(row0[0]).some((m: N) => m.type === 'bold'), marksOf(row0[0]));
|
||
// Un-bolded run keeps fontWeight:normal.
|
||
const fw = marksOf(row0[1]).find((m: N) => m.type === 'textStyle')?.attrs?.fontWeight;
|
||
check('un-bolded run keeps fontWeight:normal', fw === 'normal', marksOf(row0[1]));
|
||
});
|
||
});
|
||
|
||
describe('Leg 1d: table cell borders (per-side, fo:border-*)', () => {
|
||
const bcell = (text: string, borders: Record<string, string> = {}): N =>
|
||
({ type: 'tableCell', attrs: { colspan: 1, rowspan: 1, colwidth: [100], ...borders }, content: [P(null, T(text))] });
|
||
|
||
// A: default borders (null attrs) · B: top hidden + custom red right ·
|
||
// C/D: fully borderless.
|
||
const doc: N = { type: 'doc', content: [
|
||
{ type: 'table', content: [
|
||
ROW(bcell('A'), bcell('B', { borderTop: 'none', borderRight: '2.25pt solid #FF0000' })),
|
||
ROW(bcell('C', { borderTop: 'none', borderRight: 'none', borderBottom: 'none', borderLeft: 'none' }),
|
||
bcell('D', { borderTop: 'none', borderRight: 'none', borderBottom: 'none', borderLeft: 'none' })),
|
||
] },
|
||
] };
|
||
|
||
it('exports fo:border-* per side and re-imports the attrs', async () => {
|
||
const bytes = await buildOdt(doc, margins, 'portrait');
|
||
const xml = strFromU8(unzipSync(bytes)['content.xml']);
|
||
check('export emits fo:border-top="none"', xml.includes('fo:border-top="none"'), xml.match(/fo:border-top="[^"]*"/g));
|
||
check('export emits the custom red right border', xml.includes('fo:border-right="2.25pt solid #FF0000"'), xml.match(/fo:border-right="[^"]*"/g));
|
||
|
||
const res = importOdt(bytes);
|
||
check('no warnings on own export', res.warnings.length === 0, res.warnings);
|
||
const table = (res.content.content ?? []).find((n: N) => n.type === 'table');
|
||
const [row0, row1] = table?.content ?? [];
|
||
const a = row0?.content?.[0]?.attrs ?? {};
|
||
const b = row0?.content?.[1]?.attrs ?? {};
|
||
const c = row1?.content?.[0]?.attrs ?? {};
|
||
check('A keeps default borders (attrs null)', a.borderTop == null && a.borderRight == null && a.borderBottom == null && a.borderLeft == null, a);
|
||
check('B top border stays hidden', b.borderTop === 'none', b);
|
||
check('B custom right border round-trips', b.borderRight === '2.25pt solid #FF0000', b);
|
||
check('B bottom/left stay default', b.borderBottom == null && b.borderLeft == null, b);
|
||
check('C is fully borderless', c.borderTop === 'none' && c.borderRight === 'none' && c.borderBottom === 'none' && c.borderLeft === 'none', c);
|
||
});
|
||
});
|
||
|
||
describe('Leg 2: foreign (LibreOffice/Word-style) .odt → importOdt', () => {
|
||
it('resolves named/automatic styles, repeated cells, lists, and reports degradations', () => {
|
||
const stylesXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||
<office:document-styles xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0">
|
||
<office:font-face-decls>
|
||
<style:font-face style:name="F1" svg:font-family="'Courier New', monospace"/>
|
||
</office:font-face-decls>
|
||
<office:styles>
|
||
<style:default-style style:family="paragraph">
|
||
<style:text-properties fo:font-size="12pt" fo:font-family="Liberation Serif"/>
|
||
</style:default-style>
|
||
<style:style style:name="Standard" style:family="paragraph"/>
|
||
<style:style style:name="Mono" style:family="paragraph" style:parent-style-name="Standard">
|
||
<style:text-properties style:font-name="F1" fo:font-size="10pt"/>
|
||
</style:style>
|
||
</office:styles>
|
||
<office:automatic-styles>
|
||
<style:page-layout style:name="pm1">
|
||
<style:page-layout-properties fo:page-width="21.59cm" fo:page-height="27.94cm" fo:margin-top="1in" fo:margin-bottom="1in" fo:margin-left="1in" fo:margin-right="1in"/>
|
||
</style:page-layout>
|
||
</office:automatic-styles>
|
||
<office:master-styles>
|
||
<style:master-page style:name="Standard" style:page-layout-name="pm1"/>
|
||
</office:master-styles>
|
||
</office:document-styles>`;
|
||
|
||
const contentXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||
<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0">
|
||
<office:automatic-styles>
|
||
<style:style style:name="P1" style:family="paragraph" style:parent-style-name="Standard">
|
||
<style:paragraph-properties fo:text-align="end" fo:margin-top="0.25in" fo:line-height="200%"/>
|
||
</style:style>
|
||
<style:style style:name="T1" style:family="text"><style:text-properties fo:font-weight="700"/></style:style>
|
||
<style:style style:name="T2" style:family="text"><style:text-properties fo:color="#ff0000" fo:background-color="#ffff00"/></style:style>
|
||
<text:list-style style:name="L1">
|
||
<text:list-level-style-number text:level="1" style:num-format="i" style:num-suffix=")"/>
|
||
<text:list-level-style-bullet text:level="2" text:bullet-char="•"/>
|
||
</text:list-style>
|
||
<text:list-style style:name="L2">
|
||
<text:list-level-style-bullet text:level="1" text:bullet-char="">
|
||
<style:text-properties style:font-name="Wingdings"/>
|
||
</text:list-level-style-bullet>
|
||
</text:list-style>
|
||
<style:style style:name="co1" style:family="table-column"><style:table-column-properties style:column-width="5cm"/></style:style>
|
||
<style:style style:name="co2" style:family="table-column"><style:table-column-properties style:column-width="10cm"/></style:style>
|
||
<style:style style:name="ro1" style:family="table-row"><style:table-row-properties style:min-row-height="2cm"/></style:style>
|
||
</office:automatic-styles>
|
||
<office:body><office:text>
|
||
<text:p>plain default text</text:p>
|
||
<text:p text:style-name="P1"><text:span text:style-name="T1">bold700</text:span> gap<text:s text:c="3"/>tab<text:tab/>end</text:p>
|
||
<text:p><text:span text:style-name="T2">colored</text:span><text:line-break/><text:a xlink:href="https://x.example">a link</text:a></text:p>
|
||
<text:p text:style-name="Mono">mono text</text:p>
|
||
<text:p>img:<draw:frame><draw:image xlink:href="Pictures/x.png"/></draw:frame><text:note text:note-class="footnote" text:id="ftn1"><text:note-citation>1</text:note-citation><text:note-body><text:p>note body</text:p></text:note-body></text:note></text:p>
|
||
<text:list text:style-name="L1">
|
||
<text:list-item text:start-value="3"><text:p>roman three</text:p>
|
||
<text:list><text:list-item><text:p>sub bullet</text:p></text:list-item></text:list>
|
||
</text:list-item>
|
||
</text:list>
|
||
<text:list text:style-name="L2">
|
||
<text:list-item><text:p>wingdings arrow</text:p></text:list-item>
|
||
</text:list>
|
||
<table:table>
|
||
<table:table-column table:style-name="co1"/>
|
||
<table:table-column table:style-name="co2" table:number-columns-repeated="2"/>
|
||
<table:table-header-rows>
|
||
<table:table-row><table:table-cell><text:p>h1</text:p></table:table-cell><table:table-cell table:number-columns-repeated="2"><text:p>hx</text:p></table:table-cell></table:table-row>
|
||
</table:table-header-rows>
|
||
<table:table-row table:style-name="ro1">
|
||
<table:table-cell table:number-columns-spanned="2"><text:p>spanned</text:p></table:table-cell>
|
||
<table:covered-table-cell/>
|
||
<table:table-cell><text:p>c3</text:p></table:table-cell>
|
||
</table:table-row>
|
||
</table:table>
|
||
</office:text></office:body>
|
||
</office:document-content>`;
|
||
|
||
// Real picture bytes so the embedded <draw:image> resolves on import.
|
||
const pngBytes = Uint8Array.from(atob(PNG.split(',')[1]), c => c.charCodeAt(0));
|
||
const foreign = zipSync({
|
||
mimetype: [strToU8('application/vnd.oasis.opendocument.text'), { level: 0 }],
|
||
'content.xml': [strToU8(contentXml), { level: 6 }],
|
||
'styles.xml': [strToU8(stylesXml), { level: 6 }],
|
||
'Pictures/x.png': [pngBytes, { level: 6 }],
|
||
} as any);
|
||
|
||
const f = importOdt(foreign);
|
||
const c = f.content.content!;
|
||
|
||
check('foreign: margins 1in → 2.54cm', Math.abs(f.margins!.top - 2.54) < 0.01, f.margins);
|
||
check('foreign: Letter stays portrait', f.orientation === 'portrait');
|
||
|
||
check('foreign: plain run has no marks', c[0].content![0].marks === undefined, c[0]);
|
||
// Omitted fo:margin-bottom → ODF default 0, not the editor's 6pt body default.
|
||
check('foreign: omitted margin → spaceAfter 0', c[0].attrs?.spaceAfter === 0, c[0].attrs);
|
||
|
||
const p1 = c[1];
|
||
check('foreign: P1 align end → right', p1.attrs?.textAlign === 'right', p1.attrs);
|
||
check('foreign: P1 0.25in → spaceBefore 18', p1.attrs?.spaceBefore === 18, p1.attrs);
|
||
check('foreign: P1 200% → lineHeight 2', p1.attrs?.lineHeight === '2', p1.attrs);
|
||
check('foreign: weight 700 → bold mark', p1.content![0].marks?.some((m: N) => m.type === 'bold'), p1.content![0]);
|
||
check('foreign: text:s ×3 + tab expanded', p1.content!.map((n: N) => n.text).join('') === 'bold700 gap tab\tend', p1.content);
|
||
|
||
const p2 = c[2];
|
||
check('foreign: lowercase color → #FF0000', p2.content![0].marks?.some((m: N) => m.type === 'textStyle' && m.attrs?.color === '#FF0000'), p2.content![0]);
|
||
check('foreign: highlight #FFFF00', p2.content![0].marks?.some((m: N) => m.type === 'highlight' && m.attrs?.color === '#FFFF00'), p2.content![0]);
|
||
check('foreign: line-break → hardBreak', p2.content!.some((n: N) => n.type === 'hardBreak'));
|
||
check('foreign: text:a → link mark (href preserved)',
|
||
p2.content!.some((n: N) => n.text === 'a link' && n.marks?.some((m: N) => m.type === 'link' && m.attrs?.href === 'https://x.example')), p2.content);
|
||
|
||
const mono = c[3];
|
||
check('foreign: font-face resolves Courier New', mono.content![0].marks?.some((m: N) => m.type === 'textStyle' && m.attrs?.fontFamily === 'Courier New' && m.attrs?.fontSize === '10pt'), mono.content![0]);
|
||
|
||
check('foreign: image imported, footnote dropped, text kept',
|
||
c[4].content!.length === 2 && c[4].content![0].text === 'img:' &&
|
||
c[4].content![1].type === 'image' && c[4].content![1].attrs?.src?.startsWith('data:image/png;base64,'), c[4]);
|
||
|
||
const list = c[5];
|
||
check('foreign: list → lower-roman-paren, start 3', list.type === 'orderedList' && list.attrs?.listStyleType === 'lower-roman-paren' && list.attrs?.start === 3, list.attrs);
|
||
check('foreign: level-2 def → nested bulletList', list.content![0].content![1]?.type === 'bulletList', list.content![0]);
|
||
|
||
const wdList = c.find((n: N) => n.type === 'bulletList' && n.content?.[0]?.content?.[0]?.content?.[0]?.text === 'wingdings arrow');
|
||
check('foreign: Wingdings PUA bullet-char → ➢', wdList?.attrs?.bulletChar === '➢', wdList?.attrs);
|
||
|
||
const table = c[7];
|
||
check('foreign: table present', table.type === 'table');
|
||
const [hdr, row] = table.content!;
|
||
check('foreign: header row → tableHeader ×3', hdr.content!.length === 3 && hdr.content!.every((cell: N) => cell.type === 'tableHeader'), hdr.content!.map((x: N) => x.type));
|
||
check('foreign: repeated cell expanded', hdr.content![1].content![0].content![0].text === 'hx' && hdr.content![2].content![0].content![0].text === 'hx');
|
||
check('foreign: colspan 2 + covered skipped', row.content!.length === 2 && row.content![0].attrs?.colspan === 2, row.content!.map((x: N) => x.attrs));
|
||
check('foreign: col weights 5/10/10cm', JSON.stringify(hdr.content!.map((x: N) => x.attrs.colwidth)) === JSON.stringify([[500], [1000], [1000]]), hdr.content!.map((x: N) => x.attrs.colwidth));
|
||
check('foreign: spanned cell colwidth [500,1000]', JSON.stringify(row.content![0].attrs?.colwidth) === JSON.stringify([500, 1000]), row.content![0].attrs);
|
||
check('foreign: min-row-height 2cm → 76px', row.attrs?.rowHeight === 76, row.attrs);
|
||
|
||
const expectWarn = ['Footnotes and endnotes were removed'];
|
||
check('foreign: warnings reported', expectWarn.every(w => f.warnings.includes(w)), f.warnings);
|
||
check('foreign: no hyperlink warning (now round-tripped)', !f.warnings.includes('Hyperlinks were converted to plain text'), f.warnings);
|
||
});
|
||
|
||
it('wraps the body in columns when the page layout declares them (Word-style)', () => {
|
||
const stylesXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||
<office:document-styles xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0">
|
||
<office:automatic-styles>
|
||
<style:page-layout style:name="pm1">
|
||
<style:page-layout-properties fo:page-width="21cm" fo:page-height="29.7cm" fo:margin-top="1in" fo:margin-bottom="1in" fo:margin-left="1in" fo:margin-right="1in">
|
||
<style:columns fo:column-count="3" fo:column-gap="0.1965in"/>
|
||
</style:page-layout-properties>
|
||
</style:page-layout>
|
||
</office:automatic-styles>
|
||
<office:master-styles>
|
||
<style:master-page style:name="Standard" style:page-layout-name="pm1"/>
|
||
</office:master-styles>
|
||
</office:document-styles>`;
|
||
|
||
const contentXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||
<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0">
|
||
<office:automatic-styles>
|
||
<style:style style:name="S1" style:family="section">
|
||
<style:section-properties><style:columns fo:column-count="1" fo:column-gap="0.5in"/></style:section-properties>
|
||
</style:style>
|
||
</office:automatic-styles>
|
||
<office:body><office:text>
|
||
<text:p>first</text:p>
|
||
<table:table><table:table-column/><table:table-row><table:table-cell><text:p>cell</text:p></table:table-cell></table:table-row></table:table>
|
||
<text:p>second</text:p>
|
||
<text:section text:name="Sect1" text:style-name="S1"><text:p>in section</text:p></text:section>
|
||
</office:text></office:body>
|
||
</office:document-content>`;
|
||
|
||
const foreign = zipSync({
|
||
mimetype: [strToU8('application/vnd.oasis.opendocument.text'), { level: 0 }],
|
||
'content.xml': [strToU8(contentXml), { level: 6 }],
|
||
'styles.xml': [strToU8(stylesXml), { level: 6 }],
|
||
} as any);
|
||
|
||
const f = importOdt(foreign);
|
||
const c = f.content.content!;
|
||
|
||
check('page-cols: shape columns/table/columns', c.map((n: N) => n.type).join(',') === 'columns,table,columns', c.map((n: N) => n.type));
|
||
check('page-cols: count 3, gap 0.1965in → 0.5cm', c[0].attrs?.count === 3 && c[0].attrs?.gapCm === 0.5, c[0].attrs);
|
||
check('page-cols: 1-col section content joins the run',
|
||
c[2].content!.map((n: N) => n.content?.[0]?.text).join(',') === 'second,in section', c[2].content);
|
||
check('page-cols: table move-out warned', f.warnings.some(w => w.includes('moved out of the columns')), f.warnings);
|
||
});
|
||
});
|
||
|
||
describe('Leg 3: header/footer → buildOdt → importOdt', () => {
|
||
it('round-trips header/footer content, fields, and the Word-style geometry mapping', async () => {
|
||
const header: N = { type: 'doc', content: [{ type: 'paragraph', attrs: { textAlign: 'right' }, content: [
|
||
{ type: 'text', text: 'Bericht ', marks: [{ type: 'bold' }, { type: 'textStyle', attrs: { color: '#C00000' } }] },
|
||
{ type: 'text', text: '2026' },
|
||
{ type: 'hardBreak' },
|
||
{ type: 'text', text: 'Zweite Zeile' },
|
||
] }] };
|
||
const footer: N = { type: 'doc', content: [{ type: 'paragraph', attrs: { textAlign: 'center' }, content: [
|
||
{ type: 'text', text: 'Seite ' },
|
||
{ type: 'pageNumber' },
|
||
{ type: 'text', text: ' von ' },
|
||
{ type: 'pageCount' },
|
||
] }] };
|
||
// Non-default edge distances (header 0.8cm from top, footer 1.6cm from bottom).
|
||
const hfDist = { headerDistanceCm: 0.8, footerDistanceCm: 1.6 };
|
||
const hfBytes = await buildOdt(fixture, margins, 'landscape', { header, footer, pageCount: 9, ...hfDist });
|
||
const hfRes = importOdt(hfBytes);
|
||
|
||
check('hf: no warnings', hfRes.warnings.length === 0, hfRes.warnings);
|
||
check('hf: header round-trips', firstDiff(normalize(header), normalize(hfRes.header)) === null,
|
||
firstDiff(normalize(header), normalize(hfRes.header)));
|
||
check('hf: footer round-trips', firstDiff(normalize(footer), normalize(hfRes.footer)) === null,
|
||
firstDiff(normalize(footer), normalize(hfRes.footer)));
|
||
// The body margins survive the Word-style header/footer geometry mapping.
|
||
const hm = hfRes.margins!;
|
||
check('hf: body margins preserved through geometry mapping',
|
||
!!hm && Math.abs(hm.top - 3) < 0.05 && Math.abs(hm.bottom - 2) < 0.05 &&
|
||
Math.abs(hm.left - 2.5) < 0.02 && Math.abs(hm.right - 1.5) < 0.02, hm);
|
||
// The configured edge distances round-trip (they become the ODF page margin).
|
||
check('hf: header distance round-trips (0.8cm)', Math.abs((hfRes.headerDistanceCm ?? 0) - 0.8) < 0.02, hfRes.headerDistanceCm);
|
||
check('hf: footer distance round-trips (1.6cm)', Math.abs((hfRes.footerDistanceCm ?? 0) - 1.6) < 0.02, hfRes.footerDistanceCm);
|
||
check('hf: body still round-trips alongside header/footer',
|
||
firstDiff(normalize(fixture), normalize(hfRes.content)) === null);
|
||
|
||
// Imported header/footer must be valid in the header/footer editor schema.
|
||
const hfSchema = getSchema(hfExtensions());
|
||
let hfSchemaOk = true;
|
||
for (const z of [hfRes.header, hfRes.footer]) {
|
||
if (!z) continue;
|
||
try { PMNode.fromJSON(hfSchema, z).check(); } catch { hfSchemaOk = false; }
|
||
}
|
||
check('hf: header/footer valid in hf schema', hfSchemaOk);
|
||
|
||
// Empty zones must not be exported (no master-page header/footer).
|
||
const emptyHf = await buildOdt(fixture, margins, 'portrait', { header: null, footer: null, pageCount: 1 });
|
||
const emptyRes = importOdt(emptyHf);
|
||
check('hf: empty zones not exported', emptyRes.header === null && emptyRes.footer === null);
|
||
});
|
||
});
|
||
|
||
describe('Leg 4: foreign header/footer → importOdt', () => {
|
||
it('parses page-number/count fields, reconstructs body margins, warns on per-page variants', () => {
|
||
const fStyles = `<?xml version="1.0" encoding="UTF-8"?>
|
||
<office:document-styles xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0">
|
||
<office:styles>
|
||
<style:default-style style:family="paragraph"><style:text-properties fo:font-size="12pt"/></style:default-style>
|
||
<style:style style:name="Standard" style:family="paragraph"/>
|
||
<style:style style:name="Header" style:family="paragraph" style:parent-style-name="Standard"><style:paragraph-properties fo:text-align="center"/></style:style>
|
||
<style:style style:name="Footer" style:family="paragraph" style:parent-style-name="Standard"/>
|
||
</office:styles>
|
||
<office:automatic-styles>
|
||
<style:page-layout style:name="pm1">
|
||
<style:page-layout-properties fo:page-width="21cm" fo:page-height="29.7cm" fo:margin-top="1.5cm" fo:margin-bottom="1.5cm" fo:margin-left="2cm" fo:margin-right="2cm"/>
|
||
<style:header-style><style:header-footer-properties svg:height="0.8cm" fo:margin-bottom="0.3cm"/></style:header-style>
|
||
<style:footer-style><style:header-footer-properties svg:height="0.6cm" fo:margin-top="0.3cm"/></style:footer-style>
|
||
</style:page-layout>
|
||
</office:automatic-styles>
|
||
<office:master-styles>
|
||
<style:master-page style:name="Standard" style:page-layout-name="pm1">
|
||
<style:header><text:p text:style-name="Header"><text:span text:style-name="X">Doc</text:span> — Page <text:page-number text:select-page="current">1</text:page-number> of <text:page-count>5</text:page-count></text:p></style:header>
|
||
<style:header-first><text:p text:style-name="Header">Cover</text:p></style:header-first>
|
||
<style:footer><text:p text:style-name="Footer">Confidential</text:p></style:footer>
|
||
</style:master-page>
|
||
</office:master-styles>
|
||
</office:document-styles>`;
|
||
const fContent = `<?xml version="1.0" encoding="UTF-8"?>
|
||
<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"><office:body><office:text><text:p>Body</text:p></office:text></office:body></office:document-content>`;
|
||
const foreignHf = zipSync({
|
||
mimetype: [strToU8('application/vnd.oasis.opendocument.text'), { level: 0 }],
|
||
'content.xml': [strToU8(fContent), { level: 6 }],
|
||
'styles.xml': [strToU8(fStyles), { level: 6 }],
|
||
} as any);
|
||
const fhf = importOdt(foreignHf);
|
||
|
||
check('foreign hf: header parsed with center align', fhf.header?.content?.[0]?.attrs?.textAlign === 'center', fhf.header);
|
||
const hPara = fhf.header?.content?.[0];
|
||
check('foreign hf: page-number field', hPara?.content?.some((n: N) => n.type === 'pageNumber'), hPara);
|
||
check('foreign hf: page-count field', hPara?.content?.some((n: N) => n.type === 'pageCount'), hPara);
|
||
check('foreign hf: footer text', fhf.footer?.content?.[0]?.content?.[0]?.text === 'Confidential', fhf.footer);
|
||
// Body top margin = page margin (1.5) + header height (0.8) + spacing (0.3) = 2.6.
|
||
check('foreign hf: body top margin reconstructed', Math.abs((fhf.margins?.top ?? 0) - 2.6) < 0.02, fhf.margins);
|
||
check('foreign hf: body bottom margin reconstructed', Math.abs((fhf.margins?.bottom ?? 0) - 2.4) < 0.02, fhf.margins);
|
||
check('foreign hf: first-page variant warning',
|
||
fhf.warnings.some(w => /per-page/i.test(w)), fhf.warnings);
|
||
});
|
||
});
|
||
|
||
describe('Leg 6: text boxes / shapes (ODT)', () => {
|
||
const boxDoc: N = {
|
||
type: 'doc',
|
||
content: [
|
||
P(null, T('before')),
|
||
TBX({ width: 288, height: 96, fillColor: '#FFFFFF', strokeColor: '#000000', strokeWidthPt: 1 },
|
||
P(null, T('plain box')),
|
||
P(null, T('with '), T('marks', { type: 'italic' })),
|
||
),
|
||
TBX({ width: 192, height: 96, wrap: 'right', shapeKind: 'ellipse', fillColor: '#FFEE00', strokeColor: '#FF0000', strokeWidthPt: 2.25, rotation: 30 },
|
||
P(null, T('in ellipse')),
|
||
),
|
||
TBX({ width: 192, height: 80, wrap: 'left', shapeKind: 'roundRect', fillColor: null, strokeColor: null },
|
||
P(null, T('transparent round')),
|
||
),
|
||
P(null, T('after')),
|
||
],
|
||
};
|
||
|
||
it('emits frames/shapes with graphic styles and round-trips every attr', async () => {
|
||
const bytes = await buildOdt(boxDoc, margins, 'portrait');
|
||
const xml = strFromU8(unzipSync(bytes)['content.xml']);
|
||
check('emits <draw:frame><draw:text-box>', /<draw:frame [^>]*><draw:text-box/.test(xml));
|
||
check('emits <draw:custom-shape> with ellipse geometry', /<draw:custom-shape[\s\S]*?draw:type="ellipse"/.test(xml));
|
||
check('emits round-rectangle geometry', xml.includes('draw:type="round-rectangle"'));
|
||
check('mints TbxFr graphic styles', xml.includes('style:name="TbxFr1"') && xml.includes('style:name="TbxFr3"'));
|
||
check('graphic style carries fill + stroke', xml.includes('draw:fill-color="#FFEE00"') && xml.includes('svg:stroke-color="#FF0000"'));
|
||
check('transparent box has fill/stroke none', /draw:fill="none" draw:stroke="none"/.test(xml));
|
||
check('no leftover TBX sentinel', !xml.includes(''));
|
||
|
||
const res = importOdt(bytes);
|
||
check('no warnings on own export', res.warnings.length === 0, res.warnings);
|
||
const boxes = (res.content.content ?? []).filter((n: N) => n.type === 'textBox');
|
||
check('all 3 boxes round-trip', boxes.length === 3, (res.content.content ?? []).map((n: N) => n.type));
|
||
|
||
const [plain, ellipse, round] = boxes;
|
||
check('plain box: size 288×96, defaults suppressed', plain?.attrs?.width === 288 && plain?.attrs?.height === 96 &&
|
||
plain?.attrs?.fillColor === undefined && plain?.attrs?.strokeColor === undefined && plain?.attrs?.shapeKind === undefined, plain?.attrs);
|
||
check('plain box: both paragraphs + marks survive',
|
||
plain?.content?.length === 2 && plain?.content?.[1]?.content?.some((n: N) => n.marks?.some((m: N) => m.type === 'italic')), plain?.content);
|
||
check('ellipse: shapeKind + wrap + rotation', ellipse?.attrs?.shapeKind === 'ellipse' && ellipse?.attrs?.wrap === 'right' && ellipse?.attrs?.rotation === 30, ellipse?.attrs);
|
||
check('ellipse: fill/stroke/width exact', ellipse?.attrs?.fillColor === '#FFEE00' && ellipse?.attrs?.strokeColor === '#FF0000' && ellipse?.attrs?.strokeWidthPt === 2.25, ellipse?.attrs);
|
||
check('roundRect: explicit null fill/stroke survive (transparent, no border)',
|
||
round?.attrs?.shapeKind === 'roundRect' && round?.attrs?.fillColor === null && round?.attrs?.strokeColor === null, round?.attrs);
|
||
check('document JSON round-trips', firstDiff(normalize(boxDoc), normalize(res.content)) === null,
|
||
firstDiff(normalize(boxDoc), normalize(res.content)));
|
||
});
|
||
});
|
||
|
||
describe('Leg 7: foreign shapes/text boxes → importOdt', () => {
|
||
it('imports text-box frames + preset shapes, flattens boxes in cells, warns on the rest', () => {
|
||
const contentXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||
<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0">
|
||
<office:automatic-styles>
|
||
<style:style style:name="gr1" style:family="graphic">
|
||
<style:graphic-properties draw:fill="solid" draw:fill-color="#ccffcc" draw:stroke="solid" svg:stroke-color="#003300" svg:stroke-width="0.0292in" style:wrap="left" style:horizontal-pos="right"/>
|
||
</style:style>
|
||
<style:style style:name="gr2" style:family="graphic">
|
||
<style:graphic-properties draw:fill="none" draw:stroke="none"/>
|
||
</style:style>
|
||
</office:automatic-styles>
|
||
<office:body><office:text>
|
||
<text:p>anchor <draw:frame draw:style-name="gr1" text:anchor-type="paragraph" svg:width="2in" svg:x="1cm" svg:y="2cm"><draw:text-box fo:min-height="1in"><text:p>floating box</text:p><text:p>second</text:p></draw:text-box></draw:frame>text continues</text:p>
|
||
<text:p><draw:rect draw:style-name="gr2" text:anchor-type="as-char" svg:width="5.08cm" svg:height="2.54cm"><text:p>rect text</text:p></draw:rect></text:p>
|
||
<text:p><draw:custom-shape draw:style-name="gr1" text:anchor-type="as-char" svg:width="5.08cm" svg:height="2.54cm"><text:p>lo ellipse</text:p><draw:enhanced-geometry svg:viewBox="0 0 21600 21600" draw:glue-points="10800 0 3163 3163 0 10800" draw:text-areas="3163 3163 18437 18437" draw:type="ellipse" draw:enhanced-path="U 10800 10800 10800 10800 0 360 Z N"/></draw:custom-shape></text:p>
|
||
<text:p><draw:custom-shape text:anchor-type="as-char" svg:width="2cm" svg:height="2cm"><text:p>star</text:p><draw:enhanced-geometry draw:type="star5"/></draw:custom-shape></text:p>
|
||
<text:p><draw:line svg:x1="0cm" svg:y1="0cm" svg:x2="5cm" svg:y2="0cm"/></text:p>
|
||
<table:table>
|
||
<table:table-column/>
|
||
<table:table-row><table:table-cell><text:p>cell <draw:frame text:anchor-type="as-char" svg:width="2cm"><draw:text-box><text:p>box in cell</text:p></draw:text-box></draw:frame></text:p></table:table-cell></table:table-row>
|
||
</table:table>
|
||
</office:text></office:body>
|
||
</office:document-content>`;
|
||
const foreign = zipSync({
|
||
mimetype: [strToU8('application/vnd.oasis.opendocument.text'), { level: 0 }],
|
||
'content.xml': [strToU8(contentXml), { level: 6 }],
|
||
} as any);
|
||
|
||
const f = importOdt(foreign);
|
||
const c = f.content.content!;
|
||
const boxes = c.filter((n: N) => n.type === 'textBox');
|
||
check('3 supported shapes imported', boxes.length === 3, c.map((n: N) => n.type));
|
||
|
||
const [floatBox, rect, ellipse] = boxes;
|
||
check('frame: free x/y collapses to wrap side (right)', floatBox?.attrs?.wrap === 'right', floatBox?.attrs);
|
||
check('frame: 2in → 192px, min-height 1in → 96px', floatBox?.attrs?.width === 192 && floatBox?.attrs?.height === 96, floatBox?.attrs);
|
||
check('frame: fill + stroke from graphic style', floatBox?.attrs?.fillColor === '#CCFFCC' && floatBox?.attrs?.strokeColor === '#003300', floatBox?.attrs);
|
||
check('frame: stroke width 0.0292in → ≈2.1pt', Math.abs((floatBox?.attrs?.strokeWidthPt ?? 0) - 2.1) < 0.05, floatBox?.attrs);
|
||
check('frame: both paragraphs kept', floatBox?.content?.length === 2, floatBox?.content);
|
||
check('frame: anchor paragraph text kept', c[0]?.content?.map((n: N) => n.text).join('') === 'anchor text continues', c[0]);
|
||
check('rect: imports as plain textbox, transparent', rect?.attrs?.shapeKind === undefined && rect?.attrs?.fillColor === null && rect?.attrs?.strokeColor === null, rect?.attrs);
|
||
check('rect: text preserved', rect?.content?.[0]?.content?.[0]?.text === 'rect text', rect?.content);
|
||
check('custom-shape ellipse: shapeKind + geometry', ellipse?.attrs?.shapeKind === 'ellipse' && ellipse?.attrs?.width === 192 && ellipse?.attrs?.height === 96, ellipse?.attrs);
|
||
check('star5 dropped with warning', f.warnings.includes('Unsupported shapes were removed'), f.warnings);
|
||
check('draw:line dropped with warning', f.warnings.includes('Drawings were removed'), f.warnings);
|
||
|
||
const table = c.find((n: N) => n.type === 'table');
|
||
const cellBlocks = table?.content?.[0]?.content?.[0]?.content ?? [];
|
||
check('box in cell flattened into the cell', cellBlocks.some((b: N) => b.content?.some((t: N) => t.text === 'box in cell')), cellBlocks);
|
||
check('cell flatten warning reported', f.warnings.includes('Text boxes nested in table cells or other text boxes were flattened'), f.warnings);
|
||
});
|
||
});
|
||
|
||
describe('Leg 8: multi-column sections (ODT)', () => {
|
||
const colsDoc: N = {
|
||
type: 'doc',
|
||
content: [
|
||
P(null, T('before')),
|
||
COLS({ count: 2, gapCm: 0.8 },
|
||
P(null, T('col text with '), T('bold', { type: 'bold' })),
|
||
{ type: 'bulletList', content: [
|
||
LI(P(null, T('col bullet one'))),
|
||
LI(P(null, T('col bullet two'))),
|
||
] },
|
||
H({ level: 3 }, T('Col heading')),
|
||
),
|
||
P(null, T('between')),
|
||
COLS({ count: 3, gapCm: 0.5 }, P(null, T('three column text'))),
|
||
P(null, T('after')),
|
||
],
|
||
};
|
||
|
||
it('emits <text:section> with a section style and round-trips attrs + content', async () => {
|
||
const bytes = await buildOdt(colsDoc, margins, 'portrait');
|
||
const xml = strFromU8(unzipSync(bytes)['content.xml']);
|
||
check('emits <text:section> with the minted style', /<text:section text:style-name="ColSec1"/.test(xml));
|
||
check('mints a section-family style', xml.includes('style:family="section"'));
|
||
check('2-col style carries count + gap', xml.includes('fo:column-count="2"') && xml.includes('fo:column-gap="0.8cm"'));
|
||
check('3-col style carries count + default gap', xml.includes('fo:column-count="3"') && xml.includes('fo:column-gap="0.5cm"'));
|
||
check('columns balance (dont-balance false)', xml.includes('text:dont-balance-text-columns="false"'));
|
||
check('section content is real block markup', /<text:section[^>]*>[\s\S]*?<text:list/.test(xml));
|
||
check('no leftover COL sentinel', !xml.includes(''));
|
||
|
||
const res = importOdt(bytes);
|
||
check('no warnings on own export', res.warnings.length === 0, res.warnings);
|
||
const cols = (res.content.content ?? []).filter((n: N) => n.type === 'columns');
|
||
check('both sections round-trip', cols.length === 2, (res.content.content ?? []).map((n: N) => n.type));
|
||
const [two, three] = cols;
|
||
check('2-col attrs exact', two?.attrs?.count === 2 && two?.attrs?.gapCm === 0.8, two?.attrs);
|
||
check('3-col attrs exact', three?.attrs?.count === 3 && three?.attrs?.gapCm === 0.5, three?.attrs);
|
||
check('marks + list + heading survive inside the section',
|
||
two?.content?.[0]?.content?.some((n: N) => n.marks?.some((m: N) => m.type === 'bold')) &&
|
||
two?.content?.[1]?.type === 'bulletList' && two?.content?.[2]?.type === 'heading', two?.content);
|
||
check('document JSON round-trips', firstDiff(normalize(colsDoc), normalize(res.content)) === null,
|
||
firstDiff(normalize(colsDoc), normalize(res.content)));
|
||
});
|
||
|
||
it('coalesces adjacent equal-attr fragments (columnsFlow page splits) into one section', async () => {
|
||
const fragmented: N = {
|
||
type: 'doc',
|
||
content: [
|
||
COLS({ count: 2, gapCm: 0.5 }, P(null, T('frag one a')), P(null, T('frag one b'))),
|
||
COLS({ count: 2, gapCm: 0.5 }, P(null, T('frag two'))),
|
||
COLS({ count: 3, gapCm: 0.5 }, P(null, T('other section'))),
|
||
P(null, T('tail')),
|
||
],
|
||
};
|
||
const bytes = await buildOdt(fragmented, margins, 'portrait');
|
||
const xml = strFromU8(unzipSync(bytes)['content.xml']);
|
||
check('two sections emitted (chain merged, 3-col separate)',
|
||
(xml.match(/<text:section /g) ?? []).length === 2, (xml.match(/<text:section /g) ?? []).length);
|
||
|
||
const res = importOdt(bytes);
|
||
const cols = (res.content.content ?? []).filter((n: N) => n.type === 'columns');
|
||
check('chain reimports as one node with all three blocks',
|
||
cols.length === 2 && cols[0]?.content?.length === 3 && cols[1]?.content?.length === 1,
|
||
cols.map((n: N) => n.content?.length));
|
||
});
|
||
|
||
it('re-merges a page-boundary line-split paragraph (joinPrev) on export', async () => {
|
||
const split: N = {
|
||
type: 'doc',
|
||
content: [
|
||
COLS({ count: 2, gapCm: 0.5 },
|
||
P(null, T('intact lead. ')),
|
||
P(null, T('first half of the long paragraph ')),
|
||
),
|
||
COLS({ count: 2, gapCm: 0.5 },
|
||
P({ joinPrev: true }, T('second half of it.')),
|
||
P(null, T('intact tail.')),
|
||
),
|
||
P(null, T('after')),
|
||
],
|
||
};
|
||
const bytes = await buildOdt(split, margins, 'portrait');
|
||
const xml = strFromU8(unzipSync(bytes)['content.xml']);
|
||
check('split paragraph exported as ONE text:p',
|
||
/first half of the long paragraph second half of it\./.test(xml.replace(/<[^>]+>/g, '')), null);
|
||
check('one section, three paragraphs inside',
|
||
(xml.match(/<text:section /g) ?? []).length === 1, null);
|
||
|
||
const res = importOdt(bytes);
|
||
const col = (res.content.content ?? []).find((n: N) => n.type === 'columns');
|
||
check('reimports as one section with three paragraphs', col?.content?.length === 3, col?.content?.length);
|
||
const texts = (col?.content ?? []).map((p: N) => (p.content ?? []).map((t: N) => t.text).join(''));
|
||
check('merged paragraph text intact',
|
||
texts[1] === 'first half of the long paragraph second half of it.', texts);
|
||
});
|
||
});
|
||
|
||
describe('Leg 9: foreign multi-column sections → importOdt', () => {
|
||
it('derives the gap from column indents, moves tables out, clamps counts, splices style-less sections', () => {
|
||
const contentXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||
<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0">
|
||
<office:automatic-styles>
|
||
<style:style style:name="Sect1" style:family="section">
|
||
<style:section-properties style:editable="false">
|
||
<style:columns fo:column-count="2">
|
||
<style:column style:rel-width="4818*" fo:start-indent="0cm" fo:end-indent="0.25cm"/>
|
||
<style:column style:rel-width="4818*" fo:start-indent="0.25cm" fo:end-indent="0cm"/>
|
||
</style:columns>
|
||
</style:section-properties>
|
||
</style:style>
|
||
<style:style style:name="Sect2" style:family="section">
|
||
<style:section-properties>
|
||
<style:columns fo:column-count="2" fo:column-gap="1cm"/>
|
||
</style:section-properties>
|
||
</style:style>
|
||
<style:style style:name="Sect3" style:family="section">
|
||
<style:section-properties>
|
||
<style:columns fo:column-count="4" fo:column-gap="0.3cm"/>
|
||
</style:section-properties>
|
||
</style:style>
|
||
</office:automatic-styles>
|
||
<office:body><office:text>
|
||
<text:section text:style-name="Sect1" text:name="S1">
|
||
<text:p>lo col one</text:p>
|
||
<text:p>lo col two</text:p>
|
||
</text:section>
|
||
<text:section text:style-name="Sect2" text:name="S2">
|
||
<text:p>before table</text:p>
|
||
<table:table>
|
||
<table:table-column/>
|
||
<table:table-row><table:table-cell><text:p>in table</text:p></table:table-cell></table:table-row>
|
||
</table:table>
|
||
<text:p>after table</text:p>
|
||
</text:section>
|
||
<text:section text:style-name="Sect3" text:name="S3">
|
||
<text:p>four cols</text:p>
|
||
</text:section>
|
||
<text:section text:name="S4">
|
||
<text:p>plain section text</text:p>
|
||
</text:section>
|
||
</office:text></office:body>
|
||
</office:document-content>`;
|
||
const foreign = zipSync({
|
||
mimetype: [strToU8('application/vnd.oasis.opendocument.text'), { level: 0 }],
|
||
'content.xml': [strToU8(contentXml), { level: 6 }],
|
||
} as any);
|
||
|
||
const f = importOdt(foreign);
|
||
const c = f.content.content!;
|
||
const cols = c.filter((n: N) => n.type === 'columns');
|
||
check('4 columns nodes (S1, S2 split around the table, S3)', cols.length === 4, c.map((n: N) => n.type));
|
||
|
||
const s1 = cols[0];
|
||
check('S1: gap derived from column indents (0.25+0.25)', s1?.attrs?.count === 2 && s1?.attrs?.gapCm === 0.5, s1?.attrs);
|
||
check('S1: both paragraphs inside', s1?.content?.length === 2 && s1?.content?.[0]?.content?.[0]?.text === 'lo col one', s1?.content);
|
||
|
||
const s2idx = c.findIndex((n: N) => n.type === 'columns' && n.content?.[0]?.content?.[0]?.text === 'before table');
|
||
check('S2: table moved out between two columns nodes',
|
||
c[s2idx]?.attrs?.gapCm === 1 && c[s2idx + 1]?.type === 'table' &&
|
||
c[s2idx + 2]?.type === 'columns' && c[s2idx + 2]?.content?.[0]?.content?.[0]?.text === 'after table',
|
||
c.slice(s2idx, s2idx + 3).map((n: N) => n.type));
|
||
check('S2: move-out warning reported',
|
||
f.warnings.includes('Tables and text boxes inside a multi-column layout were moved out of the columns'), f.warnings);
|
||
|
||
const s3 = cols[cols.length - 1];
|
||
check('S3: count clamped to 3', s3?.attrs?.count === 3 && s3?.attrs?.gapCm === 0.3, s3?.attrs);
|
||
check('S3: clamp warning reported', f.warnings.includes('Sections with more than 3 columns were reduced to 3 columns'), f.warnings);
|
||
|
||
check('style-less section spliced to plain paragraphs',
|
||
c.some((n: N) => n.type === 'paragraph' && n.content?.[0]?.text === 'plain section text'), c.map((n: N) => n.type));
|
||
});
|
||
});
|
||
|
||
describe('Leg 5: table of contents (text:table-of-content)', () => {
|
||
const tocDoc: N = {
|
||
type: 'doc',
|
||
content: [
|
||
{ type: 'tableOfContents', attrs: { entries: [
|
||
{ text: 'Introduction', level: 1, page: 1 },
|
||
{ text: 'Background & Aims', level: 2, page: 2 },
|
||
{ text: 'Deep Dive', level: 3, page: 3 },
|
||
] } },
|
||
H({ level: 1 }, T('Introduction')),
|
||
P(null, T('intro text')),
|
||
H({ level: 2 }, T('Background & Aims')),
|
||
H({ level: 3 }, T('Deep Dive')),
|
||
],
|
||
};
|
||
|
||
it('exports a real <text:table-of-content> and re-imports it as a tableOfContents node', async () => {
|
||
const bytes = await buildOdt(tocDoc, margins, 'portrait');
|
||
const content = strFromU8(unzipSync(bytes)['content.xml']);
|
||
check('emits <text:table-of-content>', content.includes('<text:table-of-content '), content.slice(0, 200));
|
||
check('source spans outline-level 3', content.includes('text:outline-level="3"'));
|
||
check('mints Contents_20_1 style', content.includes('style:name="Contents_20_1"'));
|
||
check('cached entry carries a tab + page', /Contents_20_1">Introduction<text:tab\/>1<\/text:p>/.test(content));
|
||
check('ampersand in entry text is escaped', content.includes('Background & Aims'));
|
||
check('no leftover sentinel', !content.includes(''));
|
||
|
||
const res = importOdt(bytes);
|
||
const blocks = res.content.content ?? [];
|
||
const toc = blocks.find((n: N) => n.type === 'tableOfContents');
|
||
check('imports a tableOfContents node', !!toc, blocks.map((n: N) => n.type));
|
||
const entries = toc?.attrs?.entries ?? [];
|
||
check('3 entries parsed', entries.length === 3, entries);
|
||
check('entry 1 text/level', entries[0]?.text === 'Introduction' && entries[0]?.level === 1, entries[0]);
|
||
check('entry 2 text/level (ampersand)', entries[1]?.text === 'Background & Aims' && entries[1]?.level === 2, entries[1]);
|
||
check('entry 3 level 3', entries[2]?.level === 3, entries[2]);
|
||
check('headings after the TOC survive',
|
||
blocks.some((n: N) => n.type === 'heading' && n.content?.[0]?.text === 'Introduction'));
|
||
});
|
||
});
|