ODF: every part declares 1.3 (the version LibreOffice writes; header-first is a 1.3 element), draw:image gains the mandatory xlink:type trio, text:time-value becomes an xsd dateTime (import still reads the legacy duration), index-entry-link-start/-end stay in TOC templates only, and dont-balance-text-columns moves onto style:section-properties (probed: LO reads and re-writes it there). DOCX: a final pass re-sorts w:settings into the CT_Settings sequence, and comments.xml declares w14 mc:Ignorable.
2730 lines
168 KiB
TypeScript
2730 lines
168 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, MAX_HEADING_LEVEL } from '../src/lib/export/odt';
|
||
import { importOdt } from '../src/lib/import/odt';
|
||
import { normalize, firstDiff } from './normalize';
|
||
import { hfExtensions } from '../src/lib/editor/extensions/headerFooter';
|
||
import { HEADER_SHADE } from '../src/lib/editor/extensions/tableHeaderRow';
|
||
import { builtinStyleSheet } from '../src/lib/styles/styleSheet';
|
||
import { buildDocx } from '../src/lib/export/docx';
|
||
import { importDocx } from '../src/lib/import/docx';
|
||
import { EMPTY_HF_SET } from '../src/lib/storage/headerFooter';
|
||
import { DEFAULT_NOTE_SETTINGS } from '../src/lib/storage/noteSettings';
|
||
import { DEFAULT_PAGE_NUMBERING } from '../src/lib/storage/pageNumbering';
|
||
import { EMPTY_PAGE_DECOR } from '../src/lib/storage/pageDecor';
|
||
import { DEFAULT_LINE_NUMBERING } from '../src/lib/storage/lineNumbering';
|
||
import { EMPTY_DOC_PROPERTIES } from '../src/lib/storage/docProperties';
|
||
import { FOLD_MARK_NAME } from '../src/lib/storage/foldMarks';
|
||
|
||
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, wrapOffsetY?: number): N =>
|
||
({ type: 'image', attrs: {
|
||
src: PNG, width, height,
|
||
...(alt ? { alt } : {}), ...(rotation ? { rotation } : {}), ...(wrap ? { wrap } : {}),
|
||
...(wrapOffsetY ? { wrapOffsetY } : {}),
|
||
} });
|
||
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' }),
|
||
),
|
||
// Character effects: letter case, line shapes, a freely raised run.
|
||
P(null,
|
||
T('caps ', { type: 'textStyle', attrs: { caps: 'uppercase' } }),
|
||
T('petite ', { type: 'textStyle', attrs: { caps: 'smallCaps' } }),
|
||
T('dotted ', { type: 'underline', attrs: { lineStyle: 'dotted', lineColor: '#FF0000' } }),
|
||
T('twice ', { type: 'underline', attrs: { lineStyle: 'double' } }),
|
||
T('crossed ', { type: 'strike', attrs: { lineStyle: 'double' } }),
|
||
T('raised', { type: 'textStyle', attrs: { fontSize: '14pt', textPosition: 3 } }),
|
||
),
|
||
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', 2.5), 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', wrapOffset: 6, wrapOffsetY: 1.5, 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.')),
|
||
],
|
||
};
|
||
|
||
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);
|
||
check('image vertical anchor offset round-trips (svg:y)', wrapImg?.attrs?.wrapOffsetY === 2.5, 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 1a: page format', () => {
|
||
it('round-trips a non-A4 format (legal)', async () => {
|
||
const doc: N = { type: 'doc', content: [P(null, T('Legal page.'))] };
|
||
const bytes = await buildOdt(doc, margins, 'portrait', undefined, null, 'legal');
|
||
const styles = strFromU8(unzipSync(bytes)['styles.xml']);
|
||
check('styles.xml emits legal page height (35.56cm)', styles.includes('35.56cm'), styles.match(/fo:page-(width|height)="[^"]*"/g));
|
||
|
||
const res = importOdt(bytes);
|
||
check('format round-trips as legal', res.format === 'legal', res.format);
|
||
});
|
||
|
||
it('detects letter format', async () => {
|
||
const doc: N = { type: 'doc', content: [P(null, T('US letter.'))] };
|
||
const res = importOdt(await buildOdt(doc, margins, 'portrait', undefined, null, 'letter'));
|
||
check('format round-trips as letter', res.format === 'letter', res.format);
|
||
});
|
||
|
||
it('round-trips a non-preset format via the styles.xml override (tabloid)', async () => {
|
||
const doc: N = { type: 'doc', content: [P(null, T('Tabloid page.'))] };
|
||
const bytes = await buildOdt(doc, margins, 'portrait', undefined, null, 'tabloid');
|
||
const styles = strFromU8(unzipSync(bytes)['styles.xml']);
|
||
check('styles.xml emits tabloid width (27.94cm)', styles.includes('fo:page-width="27.94cm"'), styles.match(/fo:page-(width|height)="[^"]*"/g));
|
||
check('styles.xml emits tabloid height (43.18cm)', styles.includes('fo:page-height="43.18cm"'));
|
||
check('format round-trips as tabloid', importOdt(bytes).format === 'tabloid', importOdt(bytes).format);
|
||
});
|
||
|
||
it('round-trips executive (fractional cm) and landscape swap', async () => {
|
||
const doc: N = { type: 'doc', content: [P(null, T('Executive landscape.'))] };
|
||
const bytes = await buildOdt(doc, margins, 'landscape', undefined, null, 'executive');
|
||
const styles = strFromU8(unzipSync(bytes)['styles.xml']);
|
||
check('landscape swaps executive width (26.67cm)', styles.includes('fo:page-width="26.67cm"'), styles.match(/fo:page-(width|height)="[^"]*"/g));
|
||
const res = importOdt(bytes);
|
||
check('format round-trips as executive', res.format === 'executive', res.format);
|
||
check('orientation round-trips as landscape', res.orientation === 'landscape', res.orientation);
|
||
});
|
||
});
|
||
|
||
describe('Leg 1a2: hardBreak font size (empty-line height)', () => {
|
||
it('round-trips a break run font so a blank line between two breaks keeps its size', async () => {
|
||
const fs36: N = { type: 'textStyle', attrs: { fontSize: '36pt' } };
|
||
const br = (): N => ({ type: 'hardBreak', marks: [fs36] });
|
||
const doc: N = { type: 'doc', content: [P(null, T('Muster', fs36), br(), br(), T('Arbeitsvertrag', fs36))] };
|
||
const res = importOdt(await buildOdt(doc, margins, 'portrait'));
|
||
const brs: N[] = [];
|
||
(function walk(n: N) { if (n.type === 'hardBreak') brs.push(n); for (const c of n.content ?? []) walk(c); })(res.content);
|
||
check('both hardBreaks survive', brs.length === 2, brs.length);
|
||
const sized = brs.every((b) => b.marks?.some((m) => m.type === 'textStyle' && m.attrs?.fontSize === '36pt'));
|
||
check('both hardBreaks carry the 36pt run font', sized, JSON.stringify(brs));
|
||
});
|
||
});
|
||
|
||
describe('Leg 1a2b: formulas (embedded ODF formula objects)', () => {
|
||
it('exports an inline and a display formula and re-imports both LaTeX sources', async () => {
|
||
const F = (latex: string, display: boolean): N => ({ type: 'formula', attrs: { latex, display } });
|
||
const inline = '\\phi _{ref}=\\frac{a+1}{2\\pi }';
|
||
const block = '\\sum_{i=1}^{n} \\sqrt{x_{i}}';
|
||
const doc: N = { type: 'doc', content: [
|
||
P(null, T('Die Formel '), F(inline, false), T(' im Text.')),
|
||
P(null, F(block, true)),
|
||
] };
|
||
const bytes = await buildOdt(doc, margins, 'portrait');
|
||
const files = unzipSync(bytes);
|
||
const content = strFromU8(files['content.xml']);
|
||
check('content.xml anchors both frames as-char', (content.match(/<draw:object xlink:href="\.\/Formula\d"/g) ?? []).length === 2, content.match(/<draw:frame[^>]*Formula[^>]*>/g));
|
||
// A sized frame makes LibreOffice scale the object to fit instead of typesetting
|
||
// it at its natural size.
|
||
check('the frames carry no svg geometry', !/<draw:frame[^>]*Formula[^>]*svg:width/.test(content), content.match(/<draw:frame[^>]*Formula[^>]*>/g));
|
||
// The formula object is its own ODF sub-document; without the manifest entries
|
||
// LibreOffice ignores it.
|
||
const manifest = strFromU8(files['META-INF/manifest.xml']);
|
||
check('manifest declares both formula objects', /Formula1\/" manifest:media-type="application\/vnd\.oasis\.opendocument\.formula"/.test(manifest) && /Formula2\/content\.xml/.test(manifest), manifest.match(/Formula\d\/[^"]*/g));
|
||
const obj = strFromU8(files['Formula1/content.xml']);
|
||
check('the object holds MathML, not just the source', /<mfrac>/.test(obj) && /<msub>/.test(obj), obj.slice(0, 200));
|
||
|
||
const res = importOdt(bytes);
|
||
const found: N[] = [];
|
||
(function walk(n: N) { if (n.type === 'formula') found.push(n); for (const c of n.content ?? []) walk(c); })(res.content);
|
||
check('both formulas come back', found.length === 2, found.length);
|
||
check('inline source is unchanged', found[0]?.attrs?.latex === inline, found[0]?.attrs?.latex);
|
||
check('display source is unchanged', found[1]?.attrs?.latex === block, found[1]?.attrs?.latex);
|
||
check('the display flag survives', found[0]?.attrs?.display === false && found[1]?.attrs?.display === true, found.map((f) => f.attrs?.display));
|
||
check('surrounding text is untouched', JSON.stringify(res.content).includes('Die Formel '), null);
|
||
});
|
||
});
|
||
|
||
describe('Leg 1a2c: footnotes and endnotes', () => {
|
||
const REF = (id: string, kind: string, text: string): N =>
|
||
({ type: 'noteRef', attrs: { id, kind, text } });
|
||
const notesDoc = (): N => ({ type: 'doc', content: [
|
||
P(null, T('Body one'), REF('a', 'footnote', '1'), T(' and on.')),
|
||
P(null, T('Body two'), REF('b', 'endnote', 'i'), T(' ends.')),
|
||
{ type: 'noteSection', content: [
|
||
{ type: 'note', attrs: { id: 'a', kind: 'footnote', label: null, text: '1' },
|
||
content: [T('The footnote, with '), T('bold', { type: 'bold' }), T(' inside.')] },
|
||
{ type: 'note', attrs: { id: 'b', kind: 'endnote', label: null, text: 'i' },
|
||
content: [T('The endnote.')] },
|
||
] },
|
||
] });
|
||
|
||
it('writes text:note at the anchor and reads it back', async () => {
|
||
const bytes = await buildOdt(notesDoc(), margins, 'portrait');
|
||
const files = unzipSync(bytes);
|
||
const content = strFromU8(files['content.xml']);
|
||
check('the footnote sits inside the body paragraph', /Body one<text:note text:id="ftn1" text:note-class="footnote">/.test(content), content.match(/<text:note[^>]*>/g));
|
||
check('the endnote carries its own class', /text:note-class="endnote"/.test(content), content.match(/<text:note[^>]*>/g));
|
||
check('each note keeps its citation', /<text:note-citation>1<\/text:note-citation>/.test(content) && /<text:note-citation>i<\/text:note-citation>/.test(content), content.match(/<text:note-citation[^>]*>[^<]*</g));
|
||
check('the note body is a Footnote-styled paragraph', /<text:note-body><text:p text:style-name="Footnote">/.test(content), content.match(/<text:note-body>[\s\S]{0,80}/g));
|
||
check('run formatting inside the note survives', /The footnote, with <text:span[^>]*>bold<\/text:span>/.test(content), content.match(/The footnote[^<]*(<[^>]*>[^<]*){0,4}/g));
|
||
check('no hoisted note paragraph is left behind', !content.includes('\uE017'), content.match(/.\uE017./g));
|
||
|
||
const styles = strFromU8(files['styles.xml']);
|
||
check('both note classes are configured', /<text:notes-configuration text:note-class="footnote"/.test(styles) && /<text:notes-configuration text:note-class="endnote"/.test(styles), styles.match(/<text:notes-configuration[^>]*>/g));
|
||
check('footnotes are numbered document-wide, as LibreOffice does', /text:footnotes-position="page" text:start-numbering-at="document"/.test(styles), styles.match(/<text:notes-configuration text:note-class="footnote"[^>]*>/g));
|
||
check('endnotes keep their roman format', /text:note-class="endnote"[^>]*style:num-format="i"/.test(styles), styles.match(/<text:notes-configuration text:note-class="endnote"[^>]*>/g));
|
||
check('the Footnote paragraph style is written out', /<style:style style:name="Footnote" style:family="paragraph"/.test(styles), null);
|
||
check('the separator rides the page layout', /<style:footnote-sep [^>]*style:rel-width="25%"/.test(styles), styles.match(/<style:footnote-sep[^>]*>/g));
|
||
|
||
const res = importOdt(bytes);
|
||
const refs: N[] = [];
|
||
(function walk(n: N) { if (n.type === 'noteRef') refs.push(n); for (const c of n.content ?? []) walk(c); })(res.content);
|
||
const section = (res.content.content ?? []).find((n: N) => n.type === 'noteSection');
|
||
check('both anchors come back', refs.length === 2, refs.map((r) => r.attrs));
|
||
check('their classes survive', refs[0]?.attrs?.kind === 'footnote' && refs[1]?.attrs?.kind === 'endnote', refs.map((r) => r.attrs?.kind));
|
||
check('a note section is rebuilt', (section?.content ?? []).length === 2, section?.content?.length);
|
||
check('each anchor points at its own note', refs[0]?.attrs?.id === section?.content?.[0]?.attrs?.id && refs[1]?.attrs?.id === section?.content?.[1]?.attrs?.id, [refs.map((r) => r.attrs?.id), section?.content?.map((n: N) => n.attrs?.id)]);
|
||
check('the note text comes back', JSON.stringify(section).includes('The footnote, with'), JSON.stringify(section)?.slice(0, 200));
|
||
check('bold inside the note survives', JSON.stringify(section).includes('"bold"'), JSON.stringify(section)?.slice(0, 300));
|
||
check('the body text is untouched', JSON.stringify(res.content).includes('Body one') && JSON.stringify(res.content).includes('and on.'), null);
|
||
check('no note-removed warning is raised', !res.warnings.some((w: string) => /[Ff]ootnote/.test(w)), res.warnings);
|
||
});
|
||
|
||
it('DOCX: writes word/footnotes.xml + endnotes.xml and reads them back', async () => {
|
||
const bytes = await buildDocx(notesDoc(), margins, 'portrait');
|
||
const files = unzipSync(bytes);
|
||
check('a footnote part is written', !!files['word/footnotes.xml'], Object.keys(files).filter((f) => /note/.test(f)));
|
||
check('an endnote part is written', !!files['word/endnotes.xml'], Object.keys(files).filter((f) => /note/.test(f)));
|
||
const doc = strFromU8(files['word/document.xml']);
|
||
check('the body references both', /<w:footnoteReference w:id="1"/.test(doc) && /<w:endnoteReference w:id="1"/.test(doc), doc.match(/<w:(foot|end)noteReference[^>]*>/g));
|
||
const fn = strFromU8(files['word/footnotes.xml']);
|
||
check('the note text lives in its own part', fn.includes('The footnote, with'), fn.slice(0, 400));
|
||
|
||
const res = importDocx(bytes);
|
||
const refs: N[] = [];
|
||
(function walk(n: N) { if (n.type === 'noteRef') refs.push(n); for (const c of n.content ?? []) walk(c); })(res.content);
|
||
const section = (res.content.content ?? []).find((n: N) => n.type === 'noteSection');
|
||
check('DOCX: both anchors come back', refs.length === 2, refs.map((r) => r.attrs));
|
||
check('DOCX: their classes survive', refs.map((r) => r.attrs.kind).join(',') === 'footnote,endnote', refs.map((r) => r.attrs.kind));
|
||
// Word's own separator entries carry a w:type and are referenced by nothing.
|
||
check('DOCX: the separator notes are not imported', (section?.content ?? []).length === 2, section?.content?.map((n: N) => n.attrs));
|
||
check('DOCX: each anchor points at its own note', refs[0]?.attrs?.id === section?.content?.[0]?.attrs?.id, [refs.map((r) => r.attrs.id), section?.content?.map((n: N) => n.attrs.id)]);
|
||
check('DOCX: the note text comes back', JSON.stringify(section).includes('The footnote, with'), JSON.stringify(section)?.slice(0, 200));
|
||
check('DOCX: bold inside the note survives', JSON.stringify(section).includes('"bold"'), JSON.stringify(section)?.slice(0, 300));
|
||
check('DOCX: the body text is untouched', JSON.stringify(res.content).includes('Body one'), null);
|
||
});
|
||
|
||
it('round-trips changed numbering settings through both formats', async () => {
|
||
const custom = {
|
||
...DEFAULT_NOTE_SETTINGS,
|
||
footnote: { ...DEFAULT_NOTE_SETTINGS.footnote, numFormat: 'A' as const, startAt: 4, restart: 'page' as const },
|
||
separator: { ...DEFAULT_NOTE_SETTINGS.separator, relWidthPercent: 60, weightPt: 1.5, align: 'center' as const },
|
||
};
|
||
const odt = await buildOdt(notesDoc(), margins, 'portrait', undefined, null, 'A4', builtinStyleSheet(), 1.25, 'add', false, custom);
|
||
const back = importOdt(odt).notes;
|
||
check('ODF: format, start and restart survive',
|
||
back.footnote.numFormat === 'A' && back.footnote.startAt === 4 && back.footnote.restart === 'page', back.footnote);
|
||
check('ODF: the separator survives',
|
||
back.separator.relWidthPercent === 60 && Math.abs(back.separator.weightPt - 1.5) < 0.05 && back.separator.align === 'center', back.separator);
|
||
|
||
const docx = await buildDocx(notesDoc(), margins, 'portrait', undefined, null, 'A4', builtinStyleSheet(), 1.25, 'add', false, custom);
|
||
const dback = importDocx(docx).notes;
|
||
check('DOCX: format, start and restart survive',
|
||
dback.footnote.numFormat === 'A' && dback.footnote.startAt === 4 && dback.footnote.restart === 'page', dback.footnote);
|
||
});
|
||
});
|
||
|
||
describe('Leg 1a3: continued ordered-list start value', () => {
|
||
it('round-trips a start > 1 (odf-kit drops it) via text:start-value', async () => {
|
||
const olist = (start: number | null, t: string): N => ({
|
||
type: 'orderedList',
|
||
attrs: { listStyleType: 'upper-roman', ...(start ? { start } : {}) },
|
||
content: [{ type: 'listItem', content: [P(null, T(t))] }],
|
||
});
|
||
const doc: N = { type: 'doc', content: [
|
||
olist(null, 'First'), P(null, T('gap 1')), olist(2, 'Second'), P(null, T('gap 2')), olist(3, 'Third'),
|
||
] };
|
||
const bytes = await buildOdt(doc, margins, 'portrait');
|
||
const content = strFromU8(unzipSync(bytes)['content.xml']);
|
||
check('content.xml carries text:start-value 2 and 3', /start-value="2"/.test(content) && /start-value="3"/.test(content), content.match(/text:start-value="\d+"/g));
|
||
const round = importOdt(bytes).content;
|
||
const starts = (round.content ?? []).filter((n: N) => n.type === 'orderedList').map((n: N) => n.attrs?.start ?? null);
|
||
check('re-imported starts continue (1, 2, 3)', JSON.stringify(starts) === JSON.stringify([null, 2, 3]), starts);
|
||
});
|
||
});
|
||
|
||
describe('Leg 1a4: named table style', () => {
|
||
// The style itself lives in the app registry (ODF has no banding), so only its name
|
||
// travels — the look rides on the cell attrs the style painted.
|
||
const styled: N = {
|
||
type: 'doc',
|
||
content: [{
|
||
type: 'table',
|
||
attrs: { tableStyle: 'Simple List Shaded' },
|
||
content: [
|
||
ROW(CELLM(1, 1, null, 'Name', '#F2F2F2'), CELLM(1, 1, null, 'Menge', '#F2F2F2')),
|
||
ROW(CELL(null, P(null, T('Apfel'))), CELL(null, P(null, T('3')))),
|
||
ROW(CELLM(1, 1, null, 'Birne', '#F7F7F7'), CELLM(1, 1, null, '5', '#F7F7F7')),
|
||
],
|
||
}],
|
||
};
|
||
|
||
it('round-trips the style name and the painted cells', async () => {
|
||
const bytes = await buildOdt(styled, margins, 'portrait', undefined, null, 'A4', builtinStyleSheet());
|
||
const files = unzipSync(bytes);
|
||
const stylesXml = strFromU8(files['styles.xml']);
|
||
const contentXml = strFromU8(files['content.xml']);
|
||
check('styles.xml defines the table style', stylesXml.includes('style:family="table"')
|
||
&& stylesXml.includes('style:name="Simple_20_List_20_Shaded"'),
|
||
stylesXml.match(/<style:style[^>]*family="table"[^>]*>/g));
|
||
check('the table points at it', /style:name="Table1"[^>]*style:parent-style-name="Simple_20_List_20_Shaded"/.test(contentXml),
|
||
contentXml.match(/<style:style[^>]*style:name="Table1"[^>]*>/g));
|
||
|
||
const res = importOdt(bytes);
|
||
const table = res.content.content!.find((n: N) => n.type === 'table') as N;
|
||
check('the name comes back on the table', table?.attrs?.tableStyle === 'Simple List Shaded', table?.attrs);
|
||
const fills = table.content.map((r: N) => r.content[0].attrs.backgroundColor ?? null);
|
||
check('the painted fills survive', JSON.stringify(fills) === JSON.stringify(['#F2F2F2', null, '#F7F7F7']), fills);
|
||
});
|
||
|
||
it('round-trips the table style options (Word\'s tblLook)', async () => {
|
||
// Header row + banded rows on, everything else off.
|
||
const opts: N = { ...styled, content: [{ ...styled.content![0],
|
||
attrs: { tableStyle: 'Simple List Shaded', tableLook: 'bandedRow headerRow' } }] };
|
||
const bytes = await buildOdt(opts, margins, 'portrait', undefined, null, 'A4', builtinStyleSheet());
|
||
const contentXml = strFromU8(unzipSync(bytes)['content.xml']);
|
||
check('the options ride on table:use-*-styles',
|
||
contentXml.includes('table:use-first-row-styles="true"')
|
||
&& contentXml.includes('table:use-banding-rows-styles="true"')
|
||
&& contentXml.includes('table:use-first-column-styles="false"'),
|
||
contentXml.match(/table:use-[a-z-]*="[a-z]*"/g));
|
||
|
||
const table = importOdt(bytes).content.content!.find((n: N) => n.type === 'table') as N;
|
||
check('they come back on the table', table?.attrs?.tableLook === 'bandedRow headerRow', table?.attrs);
|
||
});
|
||
|
||
it('keeps two styled tables apart (the pass walks table elements, not cells)', async () => {
|
||
const one = { ...styled.content![0], attrs: { tableStyle: 'Simple Grid', tableLook: 'headerRow' } };
|
||
const two = { ...styled.content![0], attrs: { tableStyle: 'Academic', tableLook: 'headerRow lastRow' } };
|
||
const bytes = await buildOdt({ type: 'doc', content: [one, P(null, T('between')), two] } as N,
|
||
margins, 'portrait', undefined, null, 'A4', builtinStyleSheet());
|
||
const tables = importOdt(bytes).content.content!.filter((n: N) => n.type === 'table');
|
||
check('both names survive',
|
||
tables.map((t: N) => t.attrs?.tableStyle).join('|') === 'Simple Grid|Academic',
|
||
tables.map((t: N) => t.attrs));
|
||
check('each keeps its own options',
|
||
tables.map((t: N) => t.attrs?.tableLook).join('|') === 'headerRow|lastRow headerRow',
|
||
tables.map((t: N) => t.attrs?.tableLook));
|
||
});
|
||
|
||
it('bakes a region font onto the runs so Word/LibreOffice match', async () => {
|
||
// Box List Blue writes white bold on the header row; the editor renders that from CSS.
|
||
const blue: N = { ...styled, content: [{ ...styled.content![0], attrs: { tableStyle: 'Box List Blue' },
|
||
content: [ROW(CELLM(1, 1, null, 'Kopf', '#4A7EBB'))] }] };
|
||
blue.content[0].content[0].content[0].attrs.region = 'headerRow';
|
||
const bytes = await buildOdt(blue, margins, 'portrait', undefined, null, 'A4', builtinStyleSheet());
|
||
const contentXml = strFromU8(unzipSync(bytes)['content.xml']);
|
||
check('a bold white run style is minted', /fo:font-weight="bold"/.test(contentXml) && /fo:color="#FFFFFF"/i.test(contentXml),
|
||
contentXml.match(/<style:text-properties[^>]*>/g));
|
||
});
|
||
});
|
||
|
||
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')),
|
||
] },
|
||
],
|
||
};
|
||
mergedDoc.content![1].content![0].content![1].attrs!.verticalAlign = 'middle';
|
||
|
||
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);
|
||
check('B keeps vertical-align middle', rows[0]?.content?.[1]?.attrs?.verticalAlign === 'middle', rows[0]?.content?.[1]?.attrs?.verticalAlign);
|
||
check('A stays top-aligned', rows[0]?.content?.[0]?.attrs?.verticalAlign == null, rows[0]?.content?.[0]?.attrs?.verticalAlign);
|
||
|
||
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 1e: table margins (dragged outer edges)', () => {
|
||
// Text width here is 21 - 2.5 - 1.5 = 17cm, so the table is 17 - 2 - 3 = 12cm wide.
|
||
const doc: N = { type: 'doc', content: [
|
||
{ type: 'table', attrs: { marginLeft: 2, marginRight: 3 }, content: [
|
||
ROW(CELL([200], P(null, T('A'))), CELL([100], P(null, T('B')))),
|
||
] },
|
||
] };
|
||
|
||
it('exports fo:margin-* + style:width and re-imports the attrs', async () => {
|
||
const bytes = await buildOdt(doc, margins, 'portrait');
|
||
const xml = strFromU8(unzipSync(bytes)['content.xml']);
|
||
const tableStyle = xml.match(/<style:style[^>]*style:family="table"[^>]*>\s*<style:table-properties[^>]*>/)?.[0] ?? '';
|
||
check('export emits the left margin', tableStyle.includes('fo:margin-left="2cm"'), tableStyle);
|
||
check('export emits the right margin', tableStyle.includes('fo:margin-right="3cm"'), tableStyle);
|
||
check('export emits the remaining width', tableStyle.includes('style:width="12cm"'), tableStyle);
|
||
// Columns keep their 2:1 ratio inside the narrowed table.
|
||
check('columns fill the narrowed table', xml.includes('style:column-width="8cm"') && xml.includes('style:column-width="4cm"'), xml.match(/style:column-width="[^"]*"/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');
|
||
check('left margin round-trips', table?.attrs?.marginLeft === 2, table?.attrs);
|
||
check('right margin round-trips', table?.attrs?.marginRight === 3, table?.attrs);
|
||
const weights = (table?.content?.[0]?.content ?? []).map((c: N) => c.attrs?.colwidth?.[0]);
|
||
check('column ratio survives', weights[0] === 2 * weights[1], weights);
|
||
});
|
||
|
||
it('leaves a full-width table without margin attrs', async () => {
|
||
const plain: N = { type: 'doc', content: [
|
||
{ type: 'table', content: [ROW(CELL([100], P(null, T('A'))), CELL([100], P(null, T('B'))))] },
|
||
] };
|
||
const res = importOdt(await buildOdt(plain, margins, 'portrait'));
|
||
const table = (res.content.content ?? []).find((n: N) => n.type === 'table');
|
||
check('no margins on a full-width table', !table?.attrs?.marginLeft && !table?.attrs?.marginRight, table?.attrs);
|
||
});
|
||
});
|
||
|
||
describe('Leg 1f: page-anchored frame (cover graphic) stacking', () => {
|
||
// A cover page's own graphic sits in front of text (ODF style:run-through="foreground");
|
||
// the default — no attr — is the usual behind-text watermark case.
|
||
const doc: N = { type: 'doc', content: [
|
||
P(null, { type: 'image', attrs: { src: PNG, width: 60, height: 40, anchorPage: 1, inFront: true } }),
|
||
] };
|
||
|
||
it('exports style:run-through="foreground" and re-imports inFront', async () => {
|
||
const bytes = await buildOdt(doc, margins, 'portrait');
|
||
const xml = strFromU8(unzipSync(bytes)['content.xml']);
|
||
check('export writes foreground run-through', xml.includes('style:run-through="foreground"'), xml.match(/style:run-through="[^"]*"/g));
|
||
|
||
const res = importOdt(bytes);
|
||
check('no warnings on own export', res.warnings.length === 0, res.warnings);
|
||
const img = collectImages(res.content).find((i: N) => i.attrs?.anchorPage);
|
||
check('page and foreground stacking round-trip', img?.attrs?.anchorPage === 1 && img?.attrs?.inFront === true, img?.attrs);
|
||
});
|
||
|
||
it('defaults to background (no inFront) when the file has none', async () => {
|
||
const behind: N = { type: 'doc', content: [
|
||
P(null, { type: 'image', attrs: { src: PNG, width: 60, height: 40, anchorPage: 1 } }),
|
||
] };
|
||
const bytes = await buildOdt(behind, margins, 'portrait');
|
||
const xml = strFromU8(unzipSync(bytes)['content.xml']);
|
||
check('export writes background run-through', xml.includes('style:run-through="background"'), xml.match(/style:run-through="[^"]*"/g));
|
||
const img = collectImages(importOdt(bytes).content).find((i: N) => i.attrs?.anchorPage);
|
||
check('inFront stays unset', !img?.attrs?.inFront, img?.attrs);
|
||
});
|
||
});
|
||
|
||
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's default 0, which is the editor's default too, so no attr.
|
||
check('foreign: omitted margin → no spaceAfter', c[0].attrs?.spaceAfter == null, 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);
|
||
|
||
// A named style's formatting lands in the style registry, not on the block: the
|
||
// paragraph just references "Mono" (font-face resolved to the real family).
|
||
const mono = c[3];
|
||
check('foreign: paragraph references its named style', mono.attrs?.styleName === 'Mono', mono.attrs);
|
||
const monoStyle = f.styles.paragraph['Mono'];
|
||
check('foreign: Mono style resolves the font face', monoStyle?.text.fontFamily === 'Courier New', monoStyle);
|
||
check('foreign: Mono style keeps its size', monoStyle?.text.fontSizePt === 10, monoStyle);
|
||
check('foreign: no direct formatting on the block', !mono.content![0].marks, mono.content![0]);
|
||
|
||
check('foreign: image and footnote anchor imported, text kept',
|
||
c[4].content!.length === 3 && 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].content![2].type === 'noteRef', 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 notes = c.find((n: N) => n.type === 'noteSection');
|
||
check('foreign: the note body follows at the document end', notes?.content?.[0]?.content?.[0]?.text === 'note body', notes);
|
||
check('foreign: no hyperlink warning (now round-tripped)', !f.warnings.includes('Hyperlinks were converted to plain text'), f.warnings);
|
||
check('foreign: no note warning (now round-tripped)', !f.warnings.some((w: string) => /[Ff]ootnote/.test(w)), f.warnings);
|
||
});
|
||
|
||
it('resolves a drawn shape\'s fill/stroke inherited from the default graphic style', () => {
|
||
// LibreOffice omits draw:fill/draw:stroke on a shape that keeps the app default
|
||
// (solid), taking the color from the default graphic style; only "none" turns it off.
|
||
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:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0">
|
||
<office:styles>
|
||
<style:default-style style:family="graphic">
|
||
<style:graphic-properties svg:stroke-color="#3465a4" draw:fill-color="#729fcf"/>
|
||
</style:default-style>
|
||
</office: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:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0">
|
||
<office:automatic-styles>
|
||
<style:style style:name="grInherit" style:family="graphic"><style:graphic-properties draw:auto-grow-height="false"/></style:style>
|
||
<style:style style:name="grNone" style:family="graphic"><style:graphic-properties draw:fill="none" draw:stroke="none"/></style:style>
|
||
</office:automatic-styles>
|
||
<office:body><office:text>
|
||
<text:p><draw:custom-shape draw:style-name="grInherit" svg:width="4cm" svg:height="3cm"><text:p>inherit</text:p><draw:enhanced-geometry svg:viewBox="0 0 21600 21600" draw:type="ellipse"/></draw:custom-shape></text:p>
|
||
<text:p><draw:custom-shape draw:style-name="grNone" svg:width="4cm" svg:height="3cm"><text:p>none</text:p><draw:enhanced-geometry svg:viewBox="0 0 21600 21600" draw:type="ellipse"/></draw:custom-shape></text:p>
|
||
</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 boxes: N[] = [];
|
||
const walk = (n: N): void => { if (n?.type === 'textBox') boxes.push(n); (n?.content || []).forEach(walk); };
|
||
walk(importOdt(foreign).content);
|
||
check('shape fill/stroke: two ellipses imported', boxes.length === 2, boxes.map((b: N) => b.attrs));
|
||
const [inherit, none] = boxes;
|
||
check('shape fill/stroke: inherited fill #729FCF', inherit.attrs.fillColor === '#729FCF', inherit.attrs);
|
||
check('shape fill/stroke: inherited stroke #3465A4', inherit.attrs.strokeColor === '#3465A4', inherit.attrs);
|
||
check('shape fill/stroke: explicit none → null fill', none.attrs.fillColor === null, none.attrs);
|
||
check('shape fill/stroke: explicit none → null stroke', none.attrs.strokeColor === null, none.attrs);
|
||
});
|
||
|
||
it('wraps the body in columns when the page layout declares them', () => {
|
||
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 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 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,
|
||
firstDiff(normalize(fixture), normalize(hfRes.content)));
|
||
|
||
// 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 3a: different first page header/footer → buildOdt → importOdt', () => {
|
||
it('round-trips the first-page variants and the flag alongside the defaults', async () => {
|
||
// Arial 10pt on the runs AND the page-field atoms, so the field digits keep the font.
|
||
const arial = [{ type: 'textStyle', attrs: { fontFamily: 'Arial', fontSize: '10pt' } }];
|
||
const header: N = { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Default header' }] }] };
|
||
// The strut follows runs that agree on a font (applyUniformRunFont), so the zone
|
||
// paragraph carries it either way — stating it keeps the round trip an identity.
|
||
const footer: N = { type: 'doc', content: [{ type: 'paragraph', attrs: { textAlign: 'center', fontFamily: 'Arial', fontSize: '10pt' }, content: [
|
||
{ type: 'text', text: 'Seite ', marks: arial }, { type: 'pageNumber', marks: arial }, { type: 'text', text: ' von ', marks: arial }, { type: 'pageCount', marks: arial },
|
||
] }] };
|
||
const headerFirst: N = { type: 'doc', content: [{ type: 'paragraph', attrs: { fontFamily: 'Arial', fontSize: '10pt' }, content: [
|
||
{ type: 'text', text: 'Cover', marks: [{ type: 'bold' }, ...arial] },
|
||
] }] };
|
||
const footerFirst: N = { type: 'doc', content: [{ type: 'paragraph', content: [
|
||
{ type: 'text', text: 'Stand: x', marks: arial }, { type: 'text', text: ' ' }, { type: 'pageNumber', marks: arial },
|
||
{ type: 'hardBreak' }, { type: 'hardBreak' },
|
||
] }] };
|
||
|
||
const bytes = await buildOdt(fixture, margins, 'portrait',
|
||
{ header, footer, headerFirst, footerFirst, differentFirstPage: true, pageCount: 3 });
|
||
const res = importOdt(bytes);
|
||
|
||
check('dfp: no warnings', res.warnings.length === 0, res.warnings);
|
||
check('dfp: flag round-trips', res.differentFirstPage === true, res.differentFirstPage);
|
||
check('dfp: default header round-trips', firstDiff(normalize(header), normalize(res.header)) === null, firstDiff(normalize(header), normalize(res.header)));
|
||
check('dfp: default footer round-trips', firstDiff(normalize(footer), normalize(res.footer)) === null, firstDiff(normalize(footer), normalize(res.footer)));
|
||
check('dfp: first-page header round-trips (incl. marks)', firstDiff(normalize(headerFirst), normalize(res.headerFirst)) === null, firstDiff(normalize(headerFirst), normalize(res.headerFirst)));
|
||
check('dfp: first-page footer preserves spacing', res.footerFirst?.content?.[0]?.content?.[0]?.text === 'Stand: x', res.footerFirst);
|
||
const ffInline = res.footerFirst?.content?.[0]?.content ?? [];
|
||
const ffBreaks = ffInline.filter((n: N) => n.type === 'hardBreak').length;
|
||
check('dfp: first-page footer keeps trailing blank lines', ffBreaks === 2 && ffInline[ffInline.length - 1]?.type === 'hardBreak', ffInline);
|
||
|
||
// Every variant must be valid in the header/footer editor schema.
|
||
const hfSchema = getSchema(hfExtensions());
|
||
let ok = true;
|
||
for (const z of [res.header, res.footer, res.headerFirst, res.footerFirst]) {
|
||
if (!z) continue;
|
||
try { PMNode.fromJSON(hfSchema, z).check(); } catch { ok = false; }
|
||
}
|
||
check('dfp: all variants valid in hf schema', ok);
|
||
|
||
// Flag off ⇒ first-page zones aren't exported.
|
||
const offBytes = await buildOdt(fixture, margins, 'portrait',
|
||
{ header, footer, headerFirst, footerFirst, differentFirstPage: false, pageCount: 3 });
|
||
const offRes = importOdt(offBytes);
|
||
check('dfp: first-page zones skipped when flag off', offRes.differentFirstPage === false && offRes.headerFirst === null && offRes.footerFirst === null, offRes);
|
||
|
||
// Flag on with an empty first-page footer (default footer present): page 1's footer
|
||
// is deliberately blank, and the flag still round-trips (element presence = flag).
|
||
const blankFirst = await buildOdt(fixture, margins, 'portrait',
|
||
{ header: null, footer, headerFirst: null, footerFirst: null, differentFirstPage: true, pageCount: 3 });
|
||
const blankRes = importOdt(blankFirst);
|
||
check('dfp: flag survives an empty first-page zone', blankRes.differentFirstPage === true, blankRes.differentFirstPage);
|
||
check('dfp: empty first-page footer stays blank', blankRes.footerFirst === null, blankRes.footerFirst);
|
||
check('dfp: default footer still present', firstDiff(normalize(footer), normalize(blankRes.footer)) === null, blankRes.footer);
|
||
});
|
||
});
|
||
|
||
describe('Leg 3b: inline images in header/footer → buildOdt → importOdt', () => {
|
||
it('round-trips an as-char image in a default zone and a first-page zone', async () => {
|
||
const PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||
const img = (w: number, h: number): N => ({ type: 'image', attrs: { src: PNG, alt: 'Logo', width: w, height: h, wrap: 'inline' } });
|
||
const footer: N = { type: 'doc', content: [P({ textAlign: 'center' }, T('Logo '), img(120, 48))] };
|
||
const headerFirst: N = { type: 'doc', content: [P(null, img(200, 60))] };
|
||
const header: N = { type: 'doc', content: [P(null, T('Default header'))] };
|
||
const res = importOdt(await buildOdt(fixture, margins, 'portrait',
|
||
{ header, footer, headerFirst, footerFirst: null, differentFirstPage: true, pageCount: 3 }));
|
||
const imgs = (doc: N): N[] => { const o: N[] = []; const w = (n: N) => { if (!n) return; if (n.type === 'image') o.push(n); (n.content ?? []).forEach(w); }; w(doc); return o; };
|
||
|
||
check('hf image: no warnings', res.warnings.length === 0, res.warnings);
|
||
const fi = imgs(res.footer);
|
||
check('hf image: default footer keeps one image', fi.length === 1, res.footer);
|
||
check('hf image: src is a data-URI', /^data:image\//.test(fi[0]?.attrs?.src ?? ''), fi[0]?.attrs?.src?.slice(0, 24));
|
||
check('hf image: size preserved (px→cm→px)', Math.abs(fi[0]?.attrs?.width - 120) <= 2 && Math.abs(fi[0]?.attrs?.height - 48) <= 2, fi[0]?.attrs);
|
||
check('hf image: stays inline (as-char)', (fi[0]?.attrs?.wrap ?? 'inline') === 'inline', fi[0]?.attrs?.wrap);
|
||
check('hf image: first-page header keeps its image', imgs(res.headerFirst).length === 1, res.headerFirst);
|
||
|
||
// Both zones must remain valid in the header/footer editor schema.
|
||
const hfSchema = getSchema(hfExtensions());
|
||
let ok = true;
|
||
for (const z of [res.footer, res.headerFirst]) { if (!z) continue; try { PMNode.fromJSON(hfSchema, z).check(); } catch { ok = false; } }
|
||
check('hf image: zones valid in hf schema', ok);
|
||
});
|
||
});
|
||
|
||
describe('Leg 3c: odd/even page header/footer → buildOdt → importOdt', () => {
|
||
it('round-trips the even-page variants and the flag alongside default + first', async () => {
|
||
const header: N = { type: 'doc', content: [P(null, T('Default header'))] };
|
||
const footer: N = { type: 'doc', content: [P(null, T('Default footer'))] };
|
||
const headerEven: N = { type: 'doc', content: [P({ textAlign: 'right' }, T('Even header'))] };
|
||
const footerEven: N = { type: 'doc', content: [P({ textAlign: 'center' }, T('Even footer'))] };
|
||
const headerFirst: N = { type: 'doc', content: [P(null, T('First header'))] };
|
||
const bytes = await buildOdt(fixture, margins, 'portrait',
|
||
{ header, footer, headerEven, footerEven, differentOddEven: true, headerFirst, footerFirst: null, differentFirstPage: true, pageCount: 4 });
|
||
check('odd/even: styles.xml has <style:header-left>', strFromU8(unzipSync(bytes)['styles.xml']).includes('<style:header-left>'));
|
||
|
||
const res = importOdt(bytes);
|
||
check('odd/even: no warnings', res.warnings.length === 0, res.warnings);
|
||
check('odd/even: flag round-trips', res.differentOddEven === true, res.differentOddEven);
|
||
check('odd/even: even header round-trips', firstDiff(normalize(headerEven), normalize(res.headerEven)) === null, res.headerEven);
|
||
check('odd/even: even footer round-trips', firstDiff(normalize(footerEven), normalize(res.footerEven)) === null, res.footerEven);
|
||
check('odd/even: default + first still round-trip',
|
||
firstDiff(normalize(header), normalize(res.header)) === null && firstDiff(normalize(headerFirst), normalize(res.headerFirst)) === null, res.header);
|
||
|
||
const hfSchema = getSchema(hfExtensions());
|
||
let ok = true;
|
||
for (const z of [res.headerEven, res.footerEven]) { if (!z) continue; try { PMNode.fromJSON(hfSchema, z).check(); } catch { ok = false; } }
|
||
check('odd/even: even zones valid in hf schema', ok);
|
||
});
|
||
});
|
||
|
||
describe('Leg 4: foreign header/footer → importOdt', () => {
|
||
it('parses page-number/count fields, reconstructs body margins, imports the first-page variant', () => {
|
||
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) + the header band (0.8): its 0.3 spacing to
|
||
// the body is laid out inside that height, not added to it (styleResolver.ts).
|
||
check('foreign hf: body top margin reconstructed', Math.abs((fhf.margins?.top ?? 0) - 2.3) < 0.02, fhf.margins);
|
||
check('foreign hf: body bottom margin reconstructed', Math.abs((fhf.margins?.bottom ?? 0) - 2.1) < 0.02, fhf.margins);
|
||
// header-first is a supported variant (Word "Different First Page" / ODF header-first).
|
||
check('foreign hf: first-page header parsed', fhf.headerFirst?.content?.[0]?.content?.[0]?.text === 'Cover', fhf.headerFirst);
|
||
check('foreign hf: different-first-page flag set', fhf.differentFirstPage === true, fhf.differentFirstPage);
|
||
check('foreign hf: no unsupported-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')),
|
||
),
|
||
TBX({ width: 192, height: 96, spaceBefore: 6, spaceAfter: 8 },
|
||
P(null, T('spaced box')),
|
||
),
|
||
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 4 boxes round-trip', boxes.length === 4, (res.content.content ?? []).map((n: N) => n.type));
|
||
|
||
const [plain, ellipse, round, spaced] = boxes;
|
||
check('anchor paragraph carries the box spacing',
|
||
xml.includes('style:name="TbxP4"') && xml.includes('fo:margin-top="6pt" fo:margin-bottom="8pt"'), xml.slice(0, 0));
|
||
check('box spacing round-trips', spaced?.attrs?.spaceBefore === 6 && spaced?.attrs?.spaceAfter === 8, spaced?.attrs);
|
||
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:custom-shape text:anchor-type="as-char" svg:width="2cm" svg:height="2cm"><text:p>smiley</text:p><draw:enhanced-geometry draw:type="smiley"/></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('5 supported shapes imported', boxes.length === 5, c.map((n: N) => n.type));
|
||
|
||
const [floatBox, rect, ellipse, star, line] = 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: preset kept', star?.attrs?.shapeKind === 'star5', star?.attrs);
|
||
check('a preset we can\'t draw is dropped with a warning', f.warnings.includes('Unsupported shapes were removed'), f.warnings);
|
||
// A 5cm horizontal draw:line: two endpoints, so it arrives as a flat frame.
|
||
check('draw:line: a plain line, no heads declared', line?.attrs?.shapeKind === 'line', line?.attrs);
|
||
check('draw:line: 5cm wide and flat', line?.attrs?.width === 189 && line?.attrs?.height === 0, line?.attrs);
|
||
|
||
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 all heading levels', content.includes(`<text:table-of-content-source text:outline-level="${MAX_HEADING_LEVEL}"`));
|
||
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'));
|
||
});
|
||
|
||
// Listing deeper than the index asks for inflates it by whole pages; a title the file
|
||
// doesn't have doubles the heading standing above it.
|
||
it('round-trips the index depth and a title-less index', async () => {
|
||
const shallow: N = { ...tocDoc, content: [
|
||
{ type: 'tableOfContents', attrs: { entries: [], title: '', maxLevel: 1 } },
|
||
...(tocDoc.content ?? []).slice(1),
|
||
] };
|
||
const bytes = await buildOdt(shallow, margins, 'portrait');
|
||
const content = strFromU8(unzipSync(bytes)['content.xml']);
|
||
check('source stops at the index depth', content.includes('<text:table-of-content-source text:outline-level="1"'));
|
||
check('one entry template only', (content.match(/<text:table-of-content-entry-template/g) ?? []).length === 1);
|
||
check('no index title emitted', !content.includes('<text:index-title'));
|
||
|
||
const toc = (importOdt(bytes).content.content ?? []).find((n: N) => n.type === 'tableOfContents');
|
||
check('maxLevel survives', toc?.attrs?.maxLevel === 1, toc?.attrs);
|
||
check('title stays empty', toc?.attrs?.title === '', toc?.attrs);
|
||
});
|
||
});
|
||
|
||
describe('Leg 10: date/time fields (text:date / text:time)', () => {
|
||
const DTF = (kind: string, format: string, fixed: boolean, value: string, marks?: N[]): N =>
|
||
({ type: 'dateTimeField', attrs: { kind, format, fixed, value }, ...(marks ? { marks } : {}) });
|
||
const FONT = { type: 'textStyle', attrs: { fontFamily: 'Calibri' } };
|
||
const dtDoc: N = {
|
||
type: 'doc',
|
||
content: [
|
||
P(null, T('Signed on '), DTF('date', 'dmy_dots', true, '2026-07-08T14:30:45', [FONT]), T(' at '),
|
||
DTF('time', 'hms24', true, '2026-07-08T14:30:45')),
|
||
P(null, T('Printed: '), DTF('date', 'weekday_mdy', false, '2026-07-08T09:00:00')),
|
||
],
|
||
};
|
||
|
||
it('exports <text:date>/<text:time> with a minted number style and re-imports the fields', async () => {
|
||
const bytes = await buildOdt(dtDoc, margins, 'portrait', undefined, { language: 'de', country: 'DE' });
|
||
const content = strFromU8(unzipSync(bytes)['content.xml']);
|
||
check('emits <text:date>', content.includes('<text:date '), content.slice(0, 120));
|
||
check('emits <text:time>', content.includes('<text:time '));
|
||
check('fixed date carries text:fixed=true', /<text:date[^>]*text:fixed="true"/.test(content));
|
||
check('auto date carries text:fixed=false', /<text:date[^>]*text:fixed="false"/.test(content));
|
||
check('date-value present', content.includes('text:date-value="2026-07-08T14:30:45"'));
|
||
// xsd dateTime, as LibreOffice writes it — a PT…S duration is a schema violation.
|
||
check('time-value present', content.includes('text:time-value="2026-07-08T14:30:45"'));
|
||
check('mints a number:date-style', content.includes('<number:date-style '));
|
||
check('mints a number:time-style', content.includes('<number:time-style '));
|
||
check('declares number namespace', content.includes('xmlns:number='));
|
||
check('no leftover sentinel', !content.includes(''));
|
||
|
||
const res = importOdt(bytes);
|
||
const fields: N[] = [];
|
||
(function walk(n: N) { if (n.type === 'dateTimeField') fields.push(n); for (const c of n.content ?? []) walk(c); })(res.content);
|
||
check('3 date/time fields imported', fields.length === 3, fields.map((f) => f.attrs));
|
||
const fixedDate = fields.find((f) => f.attrs.kind === 'date' && f.attrs.fixed);
|
||
check('fixed date format round-trips', fixedDate?.attrs.format === 'dmy_dots', fixedDate?.attrs);
|
||
check('fixed date value round-trips', fixedDate?.attrs.value === '2026-07-08T14:30:45', fixedDate?.attrs);
|
||
// The field carries the surrounding font so the atom doesn't fall back to the
|
||
// editor default (regression: DOCX date field imported without its font).
|
||
const font = (fixedDate?.marks ?? []).find((m: N) => m.type === 'textStyle')?.attrs?.fontFamily;
|
||
check('fixed date carries its font mark', font === 'Calibri', fixedDate?.marks);
|
||
const fixedTime = fields.find((f) => f.attrs.kind === 'time');
|
||
check('fixed time format round-trips', fixedTime?.attrs.format === 'hms24', fixedTime?.attrs);
|
||
const autoDate = fields.find((f) => f.attrs.kind === 'date' && !f.attrs.fixed);
|
||
check('auto date format round-trips', autoDate?.attrs.format === 'weekday_mdy', autoDate?.attrs);
|
||
check('surrounding text preserved',
|
||
(res.content.content ?? [])[0]?.content?.[0]?.text === 'Signed on ');
|
||
});
|
||
});
|
||
|
||
describe('Leg 11: heading levels 4 and 5', () => {
|
||
const doc: N = {
|
||
type: 'doc',
|
||
content: [H({ level: 4 }, T('Fourth')), H({ level: 5 }, T('Fifth'))],
|
||
};
|
||
|
||
it('exports Heading_20_4/5 at the editor sizes and re-imports the levels', async () => {
|
||
const bytes = await buildOdt(doc, margins, 'portrait');
|
||
const files = unzipSync(bytes);
|
||
const content = strFromU8(files['content.xml']);
|
||
const styles = strFromU8(files['styles.xml']);
|
||
check('h4 outline level', content.includes('text:outline-level="4"'), content.slice(0, 400));
|
||
check('h5 outline level', content.includes('text:outline-level="5"'));
|
||
check('Heading_20_4 sized 13pt',
|
||
/style:name="Heading_20_4"[\s\S]*?fo:font-size="13pt"/.test(styles));
|
||
check('Heading_20_5 sized 12pt',
|
||
/style:name="Heading_20_5"[\s\S]*?fo:font-size="12pt"/.test(styles));
|
||
|
||
const blocks = importOdt(bytes).content.content ?? [];
|
||
check('h4 round-trips', blocks[0]?.type === 'heading' && blocks[0]?.attrs?.level === 4, blocks[0]);
|
||
check('h5 round-trips', blocks[1]?.type === 'heading' && blocks[1]?.attrs?.level === 5, blocks[1]);
|
||
// Sizes equal to the level defaults must not land as explicit fontSize marks.
|
||
check('no explicit size mark on h4', !(blocks[0]?.content?.[0]?.marks ?? []).length, blocks[0]?.content);
|
||
check('no explicit size mark on h5', !(blocks[1]?.content?.[0]?.marks ?? []).length, blocks[1]?.content);
|
||
});
|
||
});
|
||
|
||
describe('Leg 12: named paragraph styles (ODF)', () => {
|
||
const sheet = builtinStyleSheet();
|
||
sheet.paragraph['Merksatz'] = {
|
||
name: 'Merksatz', parent: 'Standard', next: 'Standard',
|
||
para: { indent: 2, spaceBefore: 6 }, text: { bold: true, color: '#0000AA' },
|
||
};
|
||
|
||
const doc: N = {
|
||
type: 'doc',
|
||
content: [
|
||
H({ level: 1 }, T('Kapitel')),
|
||
P({ styleName: 'Merksatz' }, T('gemerkt')),
|
||
// Hard formatting on top of the style must stay direct formatting.
|
||
P({ styleName: 'Merksatz', spaceAfter: 20 }, T('mit Abstand')),
|
||
P({ styleName: 'Quotations' }, T('zitiert')),
|
||
],
|
||
};
|
||
|
||
it('writes real named styles and round-trips the assignment', async () => {
|
||
const bytes = await buildOdt(doc, margins, 'portrait', undefined, null, 'A4', sheet);
|
||
const files = unzipSync(bytes);
|
||
const styles = strFromU8(files['styles.xml']);
|
||
const content = strFromU8(files['content.xml']);
|
||
|
||
const merk = styles.match(/<style:style style:name="Merksatz"[\s\S]*?<\/style:style>/)?.[0] ?? '';
|
||
check('mints the user style with its parent', merk.includes('style:parent-style-name="Standard"'), merk);
|
||
check('user style carries its own props', merk.includes('fo:margin-left="2cm"') && merk.includes('fo:color="#0000AA"'), merk);
|
||
check('heading styles inherit from Heading', /style:name="Heading_20_1"[\s\S]*?style:parent-style-name="Heading"/.test(styles));
|
||
check('the Heading parent holds the sans font', /style:name="Heading"[\s\S]*?style:font-name="Arial"/.test(styles));
|
||
check('no leftover sentinel', !content.includes('\uE00D'));
|
||
|
||
const res = importOdt(bytes);
|
||
const blocks = res.content.content ?? [];
|
||
check('block references its style', blocks[1]?.attrs?.styleName === 'Merksatz', blocks[1]?.attrs);
|
||
check('built-in style assignment round-trips', blocks[3]?.attrs?.styleName === 'Quotations', blocks[3]?.attrs);
|
||
check('hard formatting stays direct', blocks[2]?.attrs?.spaceAfter === 20, blocks[2]?.attrs);
|
||
check('style formatting is NOT copied onto the block', !blocks[1]?.content?.[0]?.marks, blocks[1]?.content?.[0]);
|
||
|
||
const imported = res.styles.paragraph['Merksatz'];
|
||
check('the style itself round-trips', imported?.parent === 'Standard', imported);
|
||
check('with its own properties', imported?.text.bold === true && imported?.text.color === '#0000AA'
|
||
&& imported?.para.indent === 2 && imported?.para.spaceBefore === 6, imported);
|
||
// A heading keeps rendering from the registry, not from copied attrs.
|
||
check('heading needs no direct formatting', !blocks[0]?.content?.[0]?.marks, blocks[0]);
|
||
});
|
||
});
|
||
|
||
describe('Leg 13: named character styles (ODF)', () => {
|
||
const sheet = builtinStyleSheet();
|
||
sheet.character['Signal'] = {
|
||
name: 'Signal', parent: null, next: null, para: {}, text: { bold: true, color: '#CC0000' },
|
||
};
|
||
const CS = (text: string, name: string, ...extra: N[]): N =>
|
||
({ type: 'text', text, marks: [{ type: 'charStyle', attrs: { name } }, ...extra] });
|
||
|
||
const doc: N = {
|
||
type: 'doc',
|
||
content: [P(null, T('plain '), CS('emphasised', 'Emphasis'), T(' and '), CS('signal', 'Signal', { type: 'italic' }))],
|
||
};
|
||
|
||
it('writes style:family="text" styles and round-trips the run assignment', async () => {
|
||
const bytes = await buildOdt(doc, margins, 'portrait', undefined, null, 'A4', sheet);
|
||
const files = unzipSync(bytes);
|
||
const styles = strFromU8(files['styles.xml']);
|
||
const content = strFromU8(files['content.xml']);
|
||
|
||
check('mints the built-in Emphasis as a text style',
|
||
/<style:style style:name="Emphasis" style:family="text"[\s\S]*?fo:font-style="italic"/.test(styles), styles.slice(0, 200));
|
||
check('mints the user character style', /style:name="Signal" style:family="text"/.test(styles));
|
||
// The run's span points at the style; its own formatting is baked alongside.
|
||
check('run references the style via its automatic style',
|
||
/<style:style style:name="TS\d+"[^>]*style:parent-style-name="Signal"/.test(content), content.slice(0, 400));
|
||
check('no leftover sentinel', !content.includes('\uE00E'));
|
||
|
||
const runs = (importOdt(bytes).content.content?.[0] as N).content as N[];
|
||
const charOf = (r: N) => (r.marks ?? []).find((m: N) => m.type === 'charStyle')?.attrs?.name;
|
||
check('plain run stays plain', !runs[0].marks, runs[0]);
|
||
check('built-in style round-trips', charOf(runs[1]) === 'Emphasis', runs[1]);
|
||
check('style formatting is not copied onto the run',
|
||
(runs[1].marks ?? []).every((m: N) => m.type === 'charStyle'), runs[1]);
|
||
const signal = runs.find((r: N) => charOf(r) === 'Signal');
|
||
check('user style round-trips', !!signal, runs);
|
||
check('direct formatting on top survives',
|
||
(signal?.marks ?? []).some((m: N) => m.type === 'italic'), signal);
|
||
const imported = importOdt(bytes).styles.character['Signal'];
|
||
check('the character style itself round-trips',
|
||
imported?.text.bold === true && imported?.text.color === '#CC0000', imported);
|
||
});
|
||
});
|
||
|
||
describe('Leg 14: named list styles (ODF)', () => {
|
||
const sheet = builtinStyleSheet();
|
||
sheet.list['Prüfliste'] = {
|
||
name: 'Prüfliste',
|
||
levels: [
|
||
{ kind: 'number', numType: 'upper-roman-paren', markerAlign: 'right', indentCm: 0.5 },
|
||
{ kind: 'bullet', bulletChar: '✓' },
|
||
{ kind: 'number', numType: 'lower-alpha' },
|
||
],
|
||
};
|
||
|
||
// Two clean assignments (user style with a nested level, a built-in) — these
|
||
// round-trip byte-for-byte. The overridden list is asserted separately.
|
||
const cleanDoc: N = { type: 'doc', content: [
|
||
P(null, T('before')),
|
||
{ type: 'orderedList', attrs: { listStyleName: 'Prüfliste' }, content: [
|
||
LI(P(null, T('one')), { type: 'bulletList', content: [LI(P(null, T('sub')))] }),
|
||
LI(P(null, T('two'))),
|
||
] },
|
||
P(null, T('between')),
|
||
{ type: 'bulletList', attrs: { listStyleName: 'Diamond Bullets' }, content: [LI(P(null, T('dash')))] },
|
||
] };
|
||
|
||
it('writes named text:list-style definitions and round-trips the assignment', async () => {
|
||
const bytes = await buildOdt(cleanDoc, margins, 'portrait', undefined, null, 'A4', sheet);
|
||
const files = unzipSync(bytes);
|
||
const styles = strFromU8(files['styles.xml']);
|
||
const content = strFromU8(files['content.xml']);
|
||
|
||
const def = styles.match(/<text:list-style style:name="Prüfliste"[\s\S]*?<\/text:list-style>/)?.[0] ?? '';
|
||
check('mints the named definition with its display name', def.includes('style:display-name="Prüfliste"'), styles.slice(0, 300));
|
||
check('level 1 carries the numbering and the right-set label',
|
||
/text:level="1" style:num-format="I" style:num-suffix="\)"/.test(def) && def.includes('fo:text-align="end"'), def);
|
||
check('level 2 is the bullet level', /text:level="2" text:bullet-char="✓"/.test(def), def);
|
||
check('level 1 margin carries the extra step (1.77cm)', def.includes('fo:margin-left="1.770cm"'), def);
|
||
check('all ten levels are written', (def.match(/text:level="/g) ?? []).length === 10, def);
|
||
check('the built-in Diamond Bullets is minted too', /<text:list-style style:name="Diamond_20_Bullets" style:display-name="Diamond Bullets">/.test(styles));
|
||
check('the list references the named style', content.includes('<text:list text:style-name="Prüfliste">'), content.match(/<text:list [^>]*>/g));
|
||
check('the bullet list references Diamond Bullets', content.includes('<text:list text:style-name="Diamond_20_Bullets">'));
|
||
check('nested lists stay bare (they inherit the style)', /<text:list>\s*<text:list-item>/.test(content), content.match(/<text:list[^>]*>/g));
|
||
|
||
const res = importOdt(bytes);
|
||
check('no warnings on own export', res.warnings.length === 0, res.warnings);
|
||
const diff = firstDiff(normalize(cleanDoc), normalize(res.content));
|
||
check('document JSON round-trips without accreted attrs', diff === null, diff);
|
||
|
||
const imported = res.styles.list['Prüfliste'];
|
||
check('the style itself round-trips', !!imported, Object.keys(res.styles.list));
|
||
check('level 1 survives whole', imported?.levels[0]?.numType === 'upper-roman-paren'
|
||
&& imported?.levels[0]?.markerAlign === 'right' && imported?.levels[0]?.indentCm === 0.5, imported?.levels[0]);
|
||
check('level 2 keeps its bullet', imported?.levels[1]?.bulletChar === '✓', imported?.levels[1]);
|
||
expect.soft(res.styles.list['Diamond Bullets'], 'the built-in comes back as itself').toEqual(sheet.list['Diamond Bullets']);
|
||
});
|
||
|
||
it('an overridden list keeps the resolved automatic clone and drops the name', async () => {
|
||
const doc: N = { type: 'doc', content: [
|
||
{ type: 'orderedList', attrs: { listStyleName: 'Outline A.I.1', listStyleType: 'lower-roman' }, content: [LI(P(null, T('broken out')))] },
|
||
] };
|
||
const bytes = await buildOdt(doc, margins, 'portrait', undefined, null, 'A4', sheet);
|
||
const files = unzipSync(bytes);
|
||
const content = strFromU8(files['content.xml']);
|
||
|
||
check('the list keeps its automatic style', content.includes('<text:list text:style-name="L1">'), content.match(/<text:list [^>]*>/g));
|
||
check('no named definition is written', !strFromU8(files['styles.xml']).includes('Outline_20_A.I.1'));
|
||
check('the clone resolves the override', /<text:list-style style:name="L1">[\s\S]*?text:level="1" style:num-format="i" style:num-suffix="\."/.test(content), content.match(/<text:list-style[\s\S]*?<\/text:list-style>/)?.[0]);
|
||
|
||
const list = (importOdt(bytes).content.content ?? []).find((n: N) => n.type === 'orderedList');
|
||
check('the look survives as direct formatting', list?.attrs?.listStyleType === 'lower-roman', list?.attrs);
|
||
check('the name is gone', !list?.attrs?.listStyleName, list?.attrs);
|
||
});
|
||
|
||
it('the style level decides a depth\'s kind, whatever species the editor holds', async () => {
|
||
sheet.list['Tab Test'] = { name: 'Tab Test', levels: [
|
||
{ kind: 'bullet', bulletChar: '–' },
|
||
...Array.from({ length: 9 }, () => ({ kind: 'number' as const, numType: 'decimal' as const })),
|
||
] };
|
||
// A Tab-nested tree is bullet lists all the way down; the style numbers depth 2+.
|
||
const tree = (attrs: N | null): N => ({ type: 'bulletList', ...(attrs ? { attrs } : {}), content: [
|
||
LI(P(null, T('one')), { type: 'bulletList', content: [LI(P(null, T('two')))] }),
|
||
] });
|
||
|
||
const clean = await buildOdt({ type: 'doc', content: [tree({ listStyleName: 'Tab Test' })] },
|
||
margins, 'portrait', undefined, null, 'A4', sheet);
|
||
const res = importOdt(clean);
|
||
const top = (res.content.content ?? []).find((n: N) => n.type === 'bulletList');
|
||
check('the clean list references the named style', top?.attrs?.listStyleName === 'Tab Test', top?.attrs);
|
||
const nested = top?.content?.[0]?.content?.[1];
|
||
check('the nested list comes back as the number level says', nested?.type === 'orderedList', nested);
|
||
check('without accreted attrs', !nested?.attrs, nested?.attrs);
|
||
|
||
// Overridden: the automatic clone's level 2 must switch species too.
|
||
const dirty = await buildOdt({ type: 'doc', content: [tree({ listStyleName: 'Tab Test', bulletChar: '➢' })] },
|
||
margins, 'portrait', undefined, null, 'A4', sheet);
|
||
const content = strFromU8(unzipSync(dirty)['content.xml']);
|
||
const clone = content.match(/<text:list-style style:name="L1">[\s\S]*?<\/text:list-style>/)?.[0] ?? '';
|
||
check('the clone keeps the bullet level 1', /text:level="1" text:bullet-char="➢"/.test(clone), clone);
|
||
check('the clone numbers level 2', /<text:list-level-style-number text:level="2" style:num-format="1"/.test(clone), clone);
|
||
const dirtyTop = (importOdt(dirty).content.content ?? []).find((n: N) => n.type === 'bulletList');
|
||
check('the overridden look survives without the name',
|
||
dirtyTop?.attrs?.bulletChar === '➢' && !dirtyTop?.attrs?.listStyleName, dirtyTop?.attrs);
|
||
check('its nested list is numbered', dirtyTop?.content?.[0]?.content?.[1]?.type === 'orderedList',
|
||
dirtyTop?.content?.[0]?.content?.[1]);
|
||
delete sheet.list['Tab Test'];
|
||
});
|
||
|
||
it('a foreign named list style is read back (display name decoded)', async () => {
|
||
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:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" office:version="1.2">
|
||
<office:styles>
|
||
<text:list-style style:name="Foreign_20_List" style:display-name="Foreign List">
|
||
<text:list-level-style-number text:level="1" style:num-format="A" style:num-suffix=")" text:start-value="5">
|
||
<style:list-level-properties text:list-level-position-and-space-mode="label-alignment">
|
||
<style:list-level-label-alignment text:label-followed-by="listtab" text:list-tab-stop-position="2cm" fo:text-indent="-0.635cm" fo:margin-left="2cm"/>
|
||
</style:list-level-properties>
|
||
</text:list-level-style-number>
|
||
<text:list-level-style-bullet text:level="2" text:bullet-char="◦">
|
||
<style:list-level-properties text:list-level-position-and-space-mode="label-alignment">
|
||
<style:list-level-label-alignment text:label-followed-by="listtab" text:list-tab-stop-position="3cm" fo:text-indent="-0.635cm" fo:margin-left="3cm"/>
|
||
</style:list-level-properties>
|
||
</text:list-level-style-bullet>
|
||
</text:list-style>
|
||
</office: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:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" office:version="1.2">
|
||
<office:body><office:text>
|
||
<text:list text:style-name="Foreign_20_List">
|
||
<text:list-item><text:p>alpha</text:p>
|
||
<text:list><text:list-item><text:p>circle</text:p></text:list-item></text:list>
|
||
</text:list-item>
|
||
</text:list>
|
||
</office:text></office:body>
|
||
</office:document-content>`;
|
||
const bytes = zipSync({
|
||
mimetype: strToU8('application/vnd.oasis.opendocument.text'),
|
||
'content.xml': strToU8(contentXml),
|
||
'styles.xml': strToU8(stylesXml),
|
||
});
|
||
const res = importOdt(bytes);
|
||
const list = (res.content.content ?? []).find((n: N) => n.type === 'orderedList');
|
||
check('the list references the decoded display name', list?.attrs?.listStyleName === 'Foreign List', list?.attrs);
|
||
check('no per-level attrs accrete on the nested list', !list?.content?.[0]?.content?.[1]?.attrs, list?.content?.[0]?.content?.[1]);
|
||
const imported = res.styles.list['Foreign List'];
|
||
check('the definition lands in the registry', imported?.levels[0]?.numType === 'upper-alpha-paren'
|
||
&& imported?.levels[0]?.startAt === 5 && imported?.levels[1]?.kind === 'bullet', imported);
|
||
check('the level indent derives from the margin step (2cm → +0.73)', imported?.levels[0]?.indentCm === 0.73, imported?.levels[0]);
|
||
});
|
||
});
|
||
|
||
describe('Leg 12: per-section page margins (ODT + DOCX)', () => {
|
||
const secDoc: N = {
|
||
type: 'doc',
|
||
content: [
|
||
P(null, T('first section')),
|
||
P({ sectionBreak: true, breakBefore: 'page' }, T('second section')),
|
||
],
|
||
};
|
||
const wide = { top: 1, bottom: 1.91, left: 3.18, right: 2.41 };
|
||
const sections = [
|
||
{ ...EMPTY_HF_SET },
|
||
{ ...EMPTY_HF_SET, margins: wide },
|
||
];
|
||
|
||
it('ODT: the section gets a page layout of its own, shifted off the document one', async () => {
|
||
const bytes = await buildOdt(secDoc, margins, 'portrait', { sections, pageCount: 2 });
|
||
const styles = strFromU8(unzipSync(bytes)['styles.xml']);
|
||
check('section master points at its own layout', /<style:master-page style:name="Section2" style:page-layout-name="([^"]*)Sec2"/.test(styles), styles.slice(0, 200));
|
||
const res = importOdt(bytes);
|
||
check('document margins unchanged', JSON.stringify(res.margins) === JSON.stringify(margins), res.margins);
|
||
const back = res.hfSections?.[1]?.margins ?? null;
|
||
check('section margins round-trip', JSON.stringify(back) === JSON.stringify(wide), back);
|
||
});
|
||
|
||
it('DOCX: each sectPr carries its own w:pgMar', async () => {
|
||
const bytes = await buildDocx(secDoc, margins, 'portrait', { sections, pageCount: 2 });
|
||
const res = importDocx(bytes);
|
||
check('document margins unchanged', JSON.stringify(res.margins) === JSON.stringify(margins), res.margins);
|
||
const back = res.hfSections?.[1]?.margins ?? null;
|
||
check('section margins round-trip', JSON.stringify(back) === JSON.stringify(wide), back);
|
||
});
|
||
});
|
||
|
||
describe('Leg 13: document properties (meta.xml / docProps/core.xml)', () => {
|
||
const propsDoc: N = { type: 'doc', content: [P(null, T('body'))] };
|
||
const props = {
|
||
title: 'Jahresbericht', subject: 'Finanzen', author: 'A. Muster',
|
||
keywords: 'Bilanz, Prüfung', description: 'Entwurf & Vorlage',
|
||
};
|
||
|
||
it('ODT: meta.xml carries the fields and they come back', async () => {
|
||
const bytes = await buildOdt(propsDoc, margins, 'portrait', undefined, null, 'A4',
|
||
builtinStyleSheet(), 1.25, 'add', false, DEFAULT_NOTE_SETTINGS, props);
|
||
const meta = strFromU8(unzipSync(bytes)['meta.xml']);
|
||
check('dc:title', meta.includes('<dc:title>Jahresbericht</dc:title>'), meta);
|
||
check('one meta:keyword per keyword', /<meta:keyword>Bilanz<\/meta:keyword><meta:keyword>Prüfung<\/meta:keyword>/.test(meta));
|
||
check('ampersand escaped', meta.includes('Entwurf & Vorlage'));
|
||
check('generator is ours', meta.includes('<meta:generator>EdenText</meta:generator>'));
|
||
const back = importOdt(bytes).props;
|
||
check('round-trips whole', JSON.stringify(back) === JSON.stringify(props), back);
|
||
});
|
||
|
||
it('DOCX: docProps/core.xml carries the fields and they come back', async () => {
|
||
const bytes = await buildDocx(propsDoc, margins, 'portrait', undefined, null, 'A4',
|
||
builtinStyleSheet(), 1.25, 'add', false, DEFAULT_NOTE_SETTINGS, props);
|
||
const back = importDocx(bytes).props;
|
||
check('round-trips whole', JSON.stringify(back) === JSON.stringify(props), back);
|
||
});
|
||
|
||
it('an empty set leaves no fields behind', async () => {
|
||
const bytes = await buildOdt(propsDoc, margins, 'portrait');
|
||
const meta = strFromU8(unzipSync(bytes)['meta.xml']);
|
||
check('no dc:title', !meta.includes('<dc:title>'), meta);
|
||
const back = importOdt(bytes).props;
|
||
check('all empty', Object.values(back).every((v) => v === ''), back);
|
||
});
|
||
});
|
||
|
||
describe('Leg 14: automatic hyphenation', () => {
|
||
const hDoc: N = { type: 'doc', content: [P(null, T('Silbentrennung'))] };
|
||
const args = [margins, 'portrait', undefined, null, 'A4', builtinStyleSheet(), 1.25, 'add', false, DEFAULT_NOTE_SETTINGS, undefined] as const;
|
||
|
||
it('ODT: fo:hyphenate rides the base style and comes back', async () => {
|
||
const on = await buildOdt(hDoc, ...args, true);
|
||
const styles = strFromU8(unzipSync(on)['styles.xml']);
|
||
check('written into Standard\'s text properties', /style:name="Standard"[\s\S]*?<style:text-properties[^>]*fo:hyphenate="true"/.test(styles), styles.slice(0, 600));
|
||
check('on round-trips', importOdt(on).hyphenate === true);
|
||
const off = await buildOdt(hDoc, ...args, false);
|
||
check('off writes nothing', !strFromU8(unzipSync(off)['styles.xml']).includes('fo:hyphenate'));
|
||
check('off round-trips', importOdt(off).hyphenate === false);
|
||
});
|
||
|
||
it('DOCX: w:autoHyphenation rides settings.xml and comes back', async () => {
|
||
const on = await buildDocx(hDoc, ...args, true);
|
||
check('in settings.xml', strFromU8(unzipSync(on)['word/settings.xml']).includes('autoHyphenation'));
|
||
check('on round-trips', importDocx(on).hyphenate === true);
|
||
const off = await buildDocx(hDoc, ...args, false);
|
||
check('off round-trips', importDocx(off).hyphenate === false);
|
||
});
|
||
});
|
||
|
||
describe('Leg 15: page numbering (format + start value)', () => {
|
||
const pnDoc: N = { type: 'doc', content: [P(null, T('erste Seite'))] };
|
||
const args = [margins, 'portrait', undefined, null, 'A4', builtinStyleSheet(), 1.25, 'add', false, DEFAULT_NOTE_SETTINGS, undefined, false] as const;
|
||
const roman = { format: 'i' as const, start: 7 };
|
||
|
||
it('ODT: num-format rides the page layout, the start the first paragraph', async () => {
|
||
const bytes = await buildOdt(pnDoc, ...args, roman);
|
||
const files = unzipSync(bytes);
|
||
check('num-format on the layout', strFromU8(files['styles.xml']).includes('style:num-format="i"'));
|
||
check('start on the first paragraph', strFromU8(files['content.xml']).includes('style:page-number="7"'));
|
||
const back = importOdt(bytes).pageNumbering;
|
||
check('round-trips whole', JSON.stringify(back) === JSON.stringify(roman), back);
|
||
});
|
||
|
||
it('DOCX: w:pgNumType carries both', async () => {
|
||
const bytes = await buildDocx(pnDoc, ...args, roman);
|
||
check('in the sectPr', strFromU8(unzipSync(bytes)['word/document.xml']).includes('lowerRoman'));
|
||
const back = importDocx(bytes).pageNumbering;
|
||
check('round-trips whole', JSON.stringify(back) === JSON.stringify(roman), back);
|
||
});
|
||
|
||
it('the defaults write nothing', async () => {
|
||
const bytes = await buildOdt(pnDoc, ...args, { format: '1', start: 1 });
|
||
const files = unzipSync(bytes);
|
||
check('no num-format on the layout', !/<style:page-layout-properties[^>]*style:num-format=/.test(strFromU8(files['styles.xml'])));
|
||
check('no page-number', !strFromU8(files['content.xml']).includes('style:page-number='));
|
||
});
|
||
});
|
||
|
||
describe('Leg 16: comments (office:annotation / w:comment)', () => {
|
||
const CM = (text: string, attrs: N): N => T(text, { type: 'comment', attrs });
|
||
const attrs = { id: 'c1', author: 'A. Muster', date: '2026-08-14T10:00:00.000Z', text: 'Bitte prüfen', resolved: false };
|
||
const cDoc: N = { type: 'doc', content: [P(null, T('vor '), CM('markiert', attrs), T(' nach'))] };
|
||
|
||
it('ODT: a named annotation brackets the text and comes back', async () => {
|
||
const bytes = await buildOdt(cDoc, margins, 'portrait');
|
||
const content = strFromU8(unzipSync(bytes)['content.xml']);
|
||
check('annotation opens the range', content.includes('<office:annotation office:name="c1"'), content.slice(0, 300));
|
||
check('carries the author', content.includes('<dc:creator>A. Muster</dc:creator>'));
|
||
check('carries the body', content.includes('Bitte prüfen'));
|
||
check('closes the range', content.includes('<office:annotation-end office:name="c1"/>'));
|
||
check('no leftover sentinel', !/[]/.test(content));
|
||
|
||
const back = importOdt(bytes).content as N;
|
||
const runs = back.content[0].content as N[];
|
||
const marked = runs.find((r: N) => r.marks?.some((m: N) => m.type === 'comment'));
|
||
check('the marked run is the annotated text', marked?.text === 'markiert', runs.map((r: N) => r.text));
|
||
const m = marked?.marks.find((x: N) => x.type === 'comment');
|
||
check('attrs round-trip', m?.attrs.author === 'A. Muster' && m?.attrs.text === 'Bitte prüfen', m?.attrs);
|
||
check('neighbours stay uncommented', runs.filter((r: N) => r.marks?.some((x: N) => x.type === 'comment')).length === 1);
|
||
});
|
||
|
||
it('DOCX: the range brackets the runs and word/comments.xml holds the body', async () => {
|
||
const bytes = await buildDocx(cDoc, margins, 'portrait');
|
||
const files = unzipSync(bytes);
|
||
const doc = strFromU8(files['word/document.xml']);
|
||
check('range start', doc.includes('<w:commentRangeStart'), doc.slice(0, 200));
|
||
check('range end', doc.includes('<w:commentRangeEnd'));
|
||
check('reference run', doc.includes('<w:commentReference'));
|
||
check('comments part exists', !!files['word/comments.xml']);
|
||
check('body in the part', strFromU8(files['word/comments.xml'] ?? new Uint8Array()).includes('Bitte prüfen'));
|
||
|
||
const back = importDocx(bytes).content as N;
|
||
const runs = back.content[0].content as N[];
|
||
const marked = runs.find((r: N) => r.marks?.some((m: N) => m.type === 'comment'));
|
||
check('the marked run is the annotated text', marked?.text === 'markiert', runs.map((r: N) => r.text));
|
||
const m = marked?.marks.find((x: N) => x.type === 'comment');
|
||
check('author and body round-trip', m?.attrs.author === 'A. Muster' && m?.attrs.text === 'Bitte prüfen', m?.attrs);
|
||
});
|
||
});
|
||
|
||
describe('Leg 17: the header row repeats on every page', () => {
|
||
const hdrTable: N = { type: 'doc', content: [
|
||
{ type: 'table', attrs: { repeatHeader: true }, content: [
|
||
ROW(CELL(null, P(null, T('Kopf A'))), CELL(null, P(null, T('Kopf B')))),
|
||
ROW(CELL(null, P(null, T('a1'))), CELL(null, P(null, T('b1')))),
|
||
] },
|
||
] };
|
||
|
||
it('ODT: <table:table-header-rows> wraps the first row and comes back', async () => {
|
||
const bytes = await buildOdt(hdrTable, margins, 'portrait');
|
||
const content = strFromU8(unzipSync(bytes)['content.xml']);
|
||
check('wrapper emitted', content.includes('<table:table-header-rows>'), content.slice(0, 300));
|
||
check('wraps exactly one row', (content.match(/<table:table-row/g) ?? []).length === 2
|
||
&& /<table:table-header-rows><table:table-row[\s\S]*?<\/table:table-row><\/table:table-header-rows>/.test(content));
|
||
const back = importOdt(bytes).content as N;
|
||
check('attr round-trips', back.content[0].attrs?.repeatHeader === true, back.content[0].attrs);
|
||
check('both rows survive', back.content[0].content.length === 2, back.content[0].content.length);
|
||
});
|
||
|
||
it('DOCX: w:tblHeader rides the first row only and comes back', async () => {
|
||
const bytes = await buildDocx(hdrTable, margins, 'portrait');
|
||
const xml = strFromU8(unzipSync(bytes)['word/document.xml']);
|
||
check('emitted once', (xml.match(/<w:tblHeader/g) ?? []).length === 1, xml.match(/<w:tblHeader[^>]*>/g));
|
||
const back = importDocx(bytes).content as N;
|
||
check('attr round-trips', back.content[0].attrs?.repeatHeader === true, back.content[0].attrs);
|
||
});
|
||
|
||
it('a plain table writes neither', async () => {
|
||
const plain: N = { type: 'doc', content: [{ type: 'table', content: [ROW(CELL(null, P(null, T('x'))))] }] };
|
||
check('no ODF wrapper', !strFromU8(unzipSync(await buildOdt(plain, margins, 'portrait'))['content.xml']).includes('table-header-rows'));
|
||
check('no w:tblHeader', !strFromU8(unzipSync(await buildDocx(plain, margins, 'portrait'))['word/document.xml']).includes('<w:tblHeader'));
|
||
});
|
||
});
|
||
|
||
describe('Leg 18: per-paragraph text direction', () => {
|
||
const doc: N = { type: 'doc', content: [
|
||
P(null, T('left to right')),
|
||
P({ dir: 'rtl' }, T('טקסט מימין לשמאל')),
|
||
{ type: 'table', content: [ROW(CELL(null, P({ dir: 'rtl' }, T('בתא'))))] },
|
||
] };
|
||
|
||
it('ODT: style:writing-mode="rl-tb" on the block, and back', async () => {
|
||
const bytes = await buildOdt(doc, margins, 'portrait');
|
||
const content = strFromU8(unzipSync(bytes)['content.xml']);
|
||
check('emitted twice (block + cell)', (content.match(/style:writing-mode="rl-tb"/g) ?? []).length === 2,
|
||
content.match(/style:writing-mode="[^"]*"/g));
|
||
const back = importOdt(bytes).content as N;
|
||
check('plain block stays undirected', back.content[0].attrs?.dir === undefined, back.content[0].attrs);
|
||
check('rtl block round-trips', back.content[1].attrs?.dir === 'rtl', back.content[1].attrs);
|
||
const cellPara = back.content[2].content[0].content[0].content[0];
|
||
check('cell paragraph round-trips', cellPara.attrs?.dir === 'rtl', cellPara.attrs);
|
||
});
|
||
|
||
it('DOCX: w:bidi in w:pPr, and back', async () => {
|
||
const bytes = await buildDocx(doc, margins, 'portrait');
|
||
const xml = strFromU8(unzipSync(bytes)['word/document.xml']);
|
||
check('emitted twice (block + cell)', (xml.match(/<w:bidi\s*\/>/g) ?? []).length === 2, xml.match(/<w:bidi[^>]*>/g));
|
||
const back = importDocx(bytes).content as N;
|
||
check('plain block stays undirected', back.content[0].attrs?.dir === undefined, back.content[0].attrs);
|
||
check('rtl block round-trips', back.content[1].attrs?.dir === 'rtl', back.content[1].attrs);
|
||
});
|
||
|
||
it('an undirected document writes no writing mode at all', async () => {
|
||
const plain: N = { type: 'doc', content: [P(null, T('plain'))] };
|
||
const content = strFromU8(unzipSync(await buildOdt(plain, margins, 'portrait'))['content.xml']);
|
||
check('no ODF writing mode', !content.includes('style:writing-mode'), content.match(/style:writing-mode="[^"]*"/g));
|
||
check('no w:bidi', !strFromU8(unzipSync(await buildDocx(plain, margins, 'portrait'))['word/document.xml']).includes('<w:bidi'));
|
||
});
|
||
});
|
||
|
||
describe('Leg 19: captions (a sequence field per category)', () => {
|
||
const SEQ = (category: string, number: number): N =>
|
||
({ type: 'sequenceField', attrs: { category, format: '1', number } });
|
||
const CAP = (category: string, label: string, n: number, text: string, box?: N): N =>
|
||
P({ styleName: 'Caption', ...box }, T(`${label} `), SEQ(category, n), T(`: ${text}`));
|
||
// The box a caption under a centred picture gets (caption.ts): as wide as the frame,
|
||
// centred in it. All three have to travel, or the caption drifts back to the margin.
|
||
const BOX = { textAlign: 'center', indent: 4.2, indentRight: 4.2 };
|
||
const doc: N = { type: 'doc', content: [
|
||
CAP('figure', 'Figure', 1, 'first picture', BOX),
|
||
CAP('table', 'Table', 1, 'first table'),
|
||
CAP('figure', 'Figure', 2, 'second picture'),
|
||
] };
|
||
|
||
it('ODT: <text:sequence> per category, declared and counting', async () => {
|
||
const bytes = await buildOdt(doc, margins, 'portrait');
|
||
const content = strFromU8(unzipSync(bytes)['content.xml']);
|
||
check('both counters declared', content.includes('text:name="Illustration"/>')
|
||
&& content.includes('text:name="Table"/>'), content.match(/<text:sequence-decl[^>]*>/g));
|
||
check('LibreOffice’s own formula', (content.match(/text:formula="ooow:Illustration\+1"/g) ?? []).length === 2,
|
||
content.match(/text:formula="[^"]*"/g));
|
||
const back = importOdt(bytes).content as N;
|
||
const fields = back.content.map((p: N) => p.content?.find((c: N) => c.type === 'sequenceField'));
|
||
check('categories round-trip', fields.map((f: N) => f?.attrs?.category).join() === 'figure,table,figure',
|
||
fields.map((f: N) => f?.attrs));
|
||
check('numbers round-trip', fields.map((f: N) => f?.attrs?.number).join() === '1,1,2',
|
||
fields.map((f: N) => f?.attrs?.number));
|
||
check('caption style kept', back.content[0].attrs?.styleName === 'Caption', back.content[0].attrs);
|
||
check('the picture’s box kept', back.content[0].attrs?.textAlign === 'center'
|
||
&& back.content[0].attrs?.indent === 4.2 && back.content[0].attrs?.indentRight === 4.2,
|
||
back.content[0].attrs);
|
||
});
|
||
|
||
it('DOCX: a SEQ field per category, and back', async () => {
|
||
const bytes = await buildDocx(doc, margins, 'portrait');
|
||
const xml = strFromU8(unzipSync(bytes)['word/document.xml']);
|
||
check('three SEQ fields', (xml.match(/SEQ (Figure|Table) \\\* ARABIC/g) ?? []).length === 3,
|
||
xml.match(/SEQ [^"]*/g));
|
||
const back = importDocx(bytes).content as N;
|
||
const fields = back.content.map((p: N) => p.content?.find((c: N) => c.type === 'sequenceField'));
|
||
check('categories round-trip', fields.map((f: N) => f?.attrs?.category).join() === 'figure,table,figure',
|
||
fields.map((f: N) => f?.attrs));
|
||
check('numbers round-trip', fields.map((f: N) => f?.attrs?.number).join() === '1,1,2',
|
||
fields.map((f: N) => f?.attrs?.number));
|
||
check('the picture’s box kept', back.content[0].attrs?.textAlign === 'center'
|
||
&& back.content[0].attrs?.indent === 4.2 && back.content[0].attrs?.indentRight === 4.2,
|
||
back.content[0].attrs);
|
||
});
|
||
|
||
it('a document without captions declares no counter', async () => {
|
||
const plain: N = { type: 'doc', content: [P(null, T('plain'))] };
|
||
const content = strFromU8(unzipSync(await buildOdt(plain, margins, 'portrait'))['content.xml']);
|
||
check('no sequence declared', !content.includes('text:sequence-decl'), content.slice(0, 200));
|
||
check('no SEQ field', !strFromU8(unzipSync(await buildDocx(plain, margins, 'portrait'))['word/document.xml']).includes('SEQ '));
|
||
});
|
||
});
|
||
|
||
describe('Leg 20: list of figures / list of tables', () => {
|
||
const idx = (index: string, ...entries: { text: string; level: number; page: number }[]): N =>
|
||
({ type: 'tableOfContents', attrs: { index, title: null, maxLevel: 5, leader: '.', tabPosCm: null, entries } });
|
||
const doc: N = { type: 'doc', content: [
|
||
idx('figures', { text: 'Figure 1: a picture', level: 1, page: 1 }),
|
||
idx('tables', { text: 'Table 1: a table', level: 1, page: 2 }),
|
||
P(null, T('body')),
|
||
] };
|
||
|
||
it('ODT: the two caption index families, and back', async () => {
|
||
const bytes = await buildOdt(doc, margins, 'portrait');
|
||
const content = strFromU8(unzipSync(bytes)['content.xml']);
|
||
check('illustration index emitted', content.includes('<text:illustration-index '), content.slice(0, 200));
|
||
check('table index emitted', content.includes('<text:table-index '), content.slice(0, 200));
|
||
check('found by the counter name', content.includes('text:caption-sequence-name="Illustration"')
|
||
&& content.includes('text:caption-sequence-name="Table"'),
|
||
content.match(/text:caption-sequence-name="[^"]*"/g));
|
||
const back = importOdt(bytes).content as N;
|
||
check('kinds round-trip', back.content.slice(0, 2).map((n: N) => n.attrs?.index).join() === 'figures,tables',
|
||
back.content.map((n: N) => [n.type, n.attrs?.index]));
|
||
check('entries survive', back.content[0].attrs?.entries?.[0]?.text === 'Figure 1: a picture',
|
||
back.content[0].attrs?.entries);
|
||
});
|
||
|
||
it('DOCX: a TOC field over a caption label, and back', async () => {
|
||
const bytes = await buildDocx(doc, margins, 'portrait');
|
||
const xml = strFromU8(unzipSync(bytes)['word/document.xml']);
|
||
// The instruction is an XML attribute, so its quotes arrive escaped.
|
||
check('\\c switches emitted', /TOC[^<]*\\c\s+"Figure"/.test(xml) && /TOC[^<]*\\c\s+"Table"/.test(xml),
|
||
xml.match(/TOC[^<]*/g));
|
||
const back = importDocx(bytes).content as N;
|
||
const kinds = back.content.filter((n: N) => n.type === 'tableOfContents').map((n: N) => n.attrs?.index);
|
||
check('kinds round-trip', kinds.join() === 'figures,tables', kinds);
|
||
});
|
||
|
||
it('a plain table of contents is unchanged by the new families', async () => {
|
||
const plain: N = { type: 'doc', content: [idx('toc', { text: 'Heading', level: 1, page: 1 })] };
|
||
const content = strFromU8(unzipSync(await buildOdt(plain, margins, 'portrait'))['content.xml']);
|
||
check('still a table-of-content', content.includes('<text:table-of-content ')
|
||
&& !content.includes('caption-sequence-name'), content.slice(0, 200));
|
||
check('reads back as toc', (importOdt(await buildOdt(plain, margins, 'portrait')).content as N)
|
||
.content[0].attrs?.index === 'toc');
|
||
});
|
||
});
|
||
|
||
describe('Leg 21: page background, page border and watermark', () => {
|
||
const decor = {
|
||
background: '#fff5c8',
|
||
border: { widthPt: 1, color: '#0046a0', paddingCm: 0.2 },
|
||
watermark: { text: 'ENTWURF', font: 'Liberation Sans', color: '#c8c8c8', angle: 45, transparency: 50 },
|
||
};
|
||
const doc: N = { type: 'doc', content: [P(null, T('body'))] };
|
||
const odt = () => buildOdt(doc, margins, 'portrait', undefined, null, 'A4', builtinStyleSheet(),
|
||
1.25, 'add', false, DEFAULT_NOTE_SETTINGS, undefined, false, DEFAULT_PAGE_NUMBERING, decor);
|
||
const docx = () => buildDocx(doc, margins, 'portrait', undefined, null, 'A4', builtinStyleSheet(),
|
||
1.25, 'add', false, DEFAULT_NOTE_SETTINGS, undefined, false, DEFAULT_PAGE_NUMBERING, decor);
|
||
|
||
it('ODT: all three on the page layout and the master page, and back', async () => {
|
||
const bytes = await odt();
|
||
const styles = strFromU8(unzipSync(bytes)['styles.xml']);
|
||
check('background', styles.includes('fo:background-color="#fff5c8"'), styles.slice(0, 200));
|
||
check('border', styles.includes('fo:border="1pt solid #0046a0"') && styles.includes('fo:padding="0.2cm"'),
|
||
styles.match(/fo:(border|padding)="[^"]*"/g));
|
||
check('watermark in the header', /<style:header>[\s\S]*PowerPlusWaterMarkObject/.test(styles));
|
||
// The draw: prefix has to be declared or LibreOffice drops the whole shape.
|
||
check('draw namespace declared', styles.includes('xmlns:draw='));
|
||
const back = importOdt(bytes).decor;
|
||
check('background round-trips', back.background === '#fff5c8', back.background);
|
||
check('border round-trips', back.border?.widthPt === 1 && back.border?.color === '#0046a0'
|
||
&& back.border?.paddingCm === 0.2, back.border);
|
||
check('watermark round-trips', back.watermark?.text === 'ENTWURF' && back.watermark?.angle === 45
|
||
&& back.watermark?.transparency === 50, back.watermark);
|
||
});
|
||
|
||
it('DOCX: background, w:pgBorders and the VML shape, and back', async () => {
|
||
const bytes = await docx();
|
||
const files = unzipSync(bytes);
|
||
const xml = strFromU8(files['word/document.xml']);
|
||
check('background', xml.includes('<w:background w:color="FFF5C8"/>'), xml.slice(0, 200));
|
||
check('switched on', strFromU8(files['word/settings.xml']).includes('<w:displayBackgroundShape/>'));
|
||
// w:sectPr has a fixed child order: pgBorders belongs after pgMar, never before
|
||
// the header references.
|
||
check('border after pgMar', /<w:pgMar\b[^>]*\/><w:pgBorders/.test(xml), xml.match(/<w:sectPr[\s\S]*?<\/w:sectPr>/)?.[0]);
|
||
const header = Object.keys(files).find((p) => /^word\/header\d*\.xml$/.test(p));
|
||
check('a header part carries the shape', !!header && strFromU8(files[header]).includes('PowerPlusWaterMarkObject'), header);
|
||
// Word has the WordArt preset built in, LibreOffice needs the definition to travel.
|
||
check('shapetype travels', !!header && strFromU8(files[header]).includes('<v:shapetype id="_x0000_t136"'));
|
||
const back = importDocx(bytes).decor;
|
||
check('background round-trips', back.background === '#fff5c8', back.background);
|
||
check('border round-trips', back.border?.widthPt === 1 && back.border?.color === '#0046a0', back.border);
|
||
check('watermark round-trips', back.watermark?.text === 'ENTWURF' && back.watermark?.angle === 45,
|
||
back.watermark);
|
||
});
|
||
|
||
it('an undecorated document writes none of it', async () => {
|
||
const styles = strFromU8(unzipSync(await buildOdt(doc, margins, 'portrait'))['styles.xml']);
|
||
check('no page background', !styles.includes('fo:background-color'), styles.match(/fo:background-color="[^"]*"/g));
|
||
check('no watermark', !styles.includes('PowerPlusWaterMarkObject'));
|
||
const files = unzipSync(await buildDocx(doc, margins, 'portrait'));
|
||
check('no w:background', !strFromU8(files['word/document.xml']).includes('<w:background'));
|
||
check('no w:pgBorders', !strFromU8(files['word/document.xml']).includes('<w:pgBorders'));
|
||
check('no header part minted', !Object.keys(files).some((p) => /^word\/header\d*\.xml$/.test(p)),
|
||
Object.keys(files));
|
||
});
|
||
});
|
||
|
||
describe('Leg 22: line numbering', () => {
|
||
const ln = { on: true, interval: 5, distanceCm: 0.5, restart: 'page' as const, countEmpty: false };
|
||
const doc: N = { type: 'doc', content: [P(null, T('body'))] };
|
||
|
||
it('ODT: one configuration element in office:styles, and back', async () => {
|
||
const bytes = await buildOdt(doc, margins, 'portrait', undefined, null, 'A4', builtinStyleSheet(),
|
||
1.25, 'add', false, DEFAULT_NOTE_SETTINGS, undefined, false, DEFAULT_PAGE_NUMBERING, undefined, ln);
|
||
const styles = strFromU8(unzipSync(bytes)['styles.xml']);
|
||
check('emitted once', (styles.match(/<text:linenumbering-configuration/g) ?? []).length === 1,
|
||
styles.match(/<text:linenumbering-configuration[^>]*>/g));
|
||
check('every value written', /text:increment="5"/.test(styles) && /text:offset="0.5cm"/.test(styles)
|
||
&& /text:restart-on-page="true"/.test(styles) && /text:count-empty-lines="false"/.test(styles),
|
||
styles.match(/<text:linenumbering-configuration[^>]*>/g));
|
||
const back = importOdt(bytes).lineNumbering;
|
||
check('round-trips', back.on && back.interval === 5 && back.distanceCm === 0.5
|
||
&& back.restart === 'page' && back.countEmpty === false, back);
|
||
});
|
||
|
||
it('DOCX: w:lnNumType in the section, and back', async () => {
|
||
const bytes = await buildDocx(doc, margins, 'portrait', undefined, null, 'A4', builtinStyleSheet(),
|
||
1.25, 'add', false, DEFAULT_NOTE_SETTINGS, undefined, false, DEFAULT_PAGE_NUMBERING, undefined, ln);
|
||
const xml = strFromU8(unzipSync(bytes)['word/document.xml']);
|
||
check('emitted', /<w:lnNumType[^>]*w:countBy="5"[^>]*\/>/.test(xml), xml.match(/<w:lnNumType[^>]*>/g));
|
||
check('restarts per page', /w:restart="newPage"/.test(xml), xml.match(/<w:lnNumType[^>]*>/g));
|
||
const back = importDocx(bytes).lineNumbering;
|
||
// Word has no "count empty lines" switch, so it comes back at its own default.
|
||
check('round-trips', back.on && back.interval === 5 && back.restart === 'page'
|
||
&& Math.abs(back.distanceCm - 0.5) < 0.02, back);
|
||
});
|
||
|
||
it('an unnumbered document writes nothing', async () => {
|
||
const styles = strFromU8(unzipSync(await buildOdt(doc, margins, 'portrait'))['styles.xml']);
|
||
check('no ODF configuration', !styles.includes('linenumbering-configuration'));
|
||
check('no w:lnNumType', !strFromU8(unzipSync(await buildDocx(doc, margins, 'portrait'))['word/document.xml']).includes('<w:lnNumType'));
|
||
});
|
||
});
|
||
|
||
describe('Leg 23: recorded revisions', () => {
|
||
const rev = (type: string, id: string): N =>
|
||
({ type, attrs: { id, author: 'Steffen', date: '2026-08-14T06:57:08.000Z' } });
|
||
const doc: N = { type: 'doc', content: [P(null,
|
||
T('Der '),
|
||
T('NEUER TEXT ', rev('insertion', 'r1')),
|
||
T('unveraenderte S'),
|
||
T('atz bleib', rev('deletion', 'r2')),
|
||
T('t stehen.'),
|
||
)] };
|
||
const runs = (back: N) => back.content[0].content.map(
|
||
(r: N) => `${r.text}|${(r.marks ?? []).map((m: N) => m.type).join(',')}`).join(' ');
|
||
|
||
it('ODT: the registry plus change-start/-end and a deletion marker, and back', async () => {
|
||
const bytes = await buildOdt(doc, margins, 'portrait');
|
||
const content = strFromU8(unzipSync(bytes)['content.xml']);
|
||
// The registry carries whether recording goes on; this document has changes but
|
||
// records no more, which is the pair LibreOffice writes for a stopped review.
|
||
check('registry emitted', /<text:tracked-changes text:track-changes="false"><text:changed-region/.test(content),
|
||
content.slice(0, 200));
|
||
check('insertion bracketed', /<text:change-start text:change-id="ct1"\/>NEUER TEXT <text:change-end/.test(content),
|
||
content.match(/<text:p[^>]*>[\s\S]*?<\/text:p>/g)?.slice(-1));
|
||
// ODF keeps a deletion's text in the registry, never inline.
|
||
check('deletion lifted out', content.includes('<text:change text:change-id="ct2"/>')
|
||
&& !/<text:p text:style-name="Standard">Der [\s\S]*?atz bleib/.test(content),
|
||
content.match(/<text:deletion>[\s\S]*?<\/text:deletion>/)?.[0]);
|
||
// dc:creator needs its prefix declared or LibreOffice drops the change-info.
|
||
check('dc namespace declared', content.includes('xmlns:dc='));
|
||
check('round-trips', runs(importOdt(bytes).content as N)
|
||
=== 'Der | NEUER TEXT |insertion unveraenderte S| atz bleib|deletion t stehen.|',
|
||
runs(importOdt(bytes).content as N));
|
||
});
|
||
|
||
it('DOCX: w:ins and w:del/w:delText, and back', async () => {
|
||
const bytes = await buildDocx(doc, margins, 'portrait');
|
||
const xml = strFromU8(unzipSync(bytes)['word/document.xml']);
|
||
check('w:ins emitted', /<w:ins [^>]*w:author="Steffen"/.test(xml), xml.match(/<w:ins [^>]*>/g));
|
||
check('w:del keeps its text', xml.includes('<w:delText') && xml.includes('atz bleib'),
|
||
xml.match(/<w:del [\s\S]*?<\/w:del>/)?.[0]);
|
||
check('round-trips', runs(importDocx(bytes).content as N)
|
||
=== 'Der | NEUER TEXT |insertion unveraenderte S| atz bleib|deletion t stehen.|',
|
||
runs(importDocx(bytes).content as N));
|
||
});
|
||
|
||
it('a document with no revisions writes no registry', async () => {
|
||
const plain: N = { type: 'doc', content: [P(null, T('plain'))] };
|
||
check('no ODF registry', !strFromU8(unzipSync(await buildOdt(plain, margins, 'portrait'))['content.xml']).includes('tracked-changes'));
|
||
const xml = strFromU8(unzipSync(await buildDocx(plain, margins, 'portrait'))['word/document.xml']);
|
||
check('no w:ins/w:del', !xml.includes('<w:ins ') && !xml.includes('<w:del '));
|
||
});
|
||
});
|
||
|
||
describe('Leg 24: preset shapes beyond rect / round-rect / ellipse', () => {
|
||
const shape = (kind: string, text: string): N =>
|
||
TBX({ width: 200, height: 120, shapeKind: kind }, P(null, T(text)));
|
||
const doc: N = { type: 'doc', content: [
|
||
shape('triangle', 'tri'), shape('star5', 'star'), shape('rightArrow', 'arrow'),
|
||
] };
|
||
const kinds = (back: N) => (back.content.content ?? [])
|
||
.filter((n: N) => n.type === 'textBox').map((n: N) => n.attrs?.shapeKind).join(',');
|
||
|
||
it('ODT: each preset gets its draw:type, its own path and its text area', async () => {
|
||
const bytes = await buildOdt(doc, margins, 'portrait');
|
||
const xml = strFromU8(unzipSync(bytes)['content.xml']);
|
||
for (const type of ['isosceles-triangle', 'star5', 'right-arrow']) {
|
||
check(`draw:type="${type}"`, xml.includes(`draw:type="${type}"`), xml.match(/draw:type="[^"]*"/g));
|
||
}
|
||
check('the triangle carries its own path', xml.includes('draw:enhanced-path="M 10800 0 L 21600 21600 0 21600 Z N"'),
|
||
xml.match(/draw:enhanced-path="[^"]*"/g));
|
||
check('a text area travels', xml.includes('draw:text-areas="5400 10800 16200 21600"'));
|
||
check('round-trips', kinds(importOdt(bytes)) === 'triangle,star5,rightArrow', kinds(importOdt(bytes)));
|
||
});
|
||
|
||
it('DOCX: each preset gets its prstGeom, and back', async () => {
|
||
const bytes = await buildDocx(doc, margins, 'portrait');
|
||
const xml = strFromU8(unzipSync(bytes)['word/document.xml']);
|
||
for (const prst of ['triangle', 'star5', 'rightArrow']) {
|
||
check(`prst="${prst}"`, xml.includes(`<a:prstGeom prst="${prst}">`), xml.match(/prst="[^"]*"/g));
|
||
}
|
||
check('round-trips', kinds(importDocx(bytes)) === 'triangle,star5,rightArrow', kinds(importDocx(bytes)));
|
||
});
|
||
});
|
||
|
||
describe('Leg 25: alphabetical index', () => {
|
||
const mark = (term: string, key1 = ''): N => ({ type: 'indexEntry', attrs: { term, key1 } });
|
||
const doc: N = { type: 'doc', content: [
|
||
P(null, T('Der '), mark('Kaffee'), T('Kaffee kommt aus Äthiopien.')),
|
||
P(null, T('Auch '), mark('Bohne', 'Kaffee'), T('die Bohne.')),
|
||
P(null, T('Und wieder '), mark('Kaffee'), T('Kaffee.')),
|
||
{ type: 'tableOfContents', attrs: {
|
||
index: 'alphabetical', title: 'Index', leader: '.',
|
||
entries: [
|
||
{ text: 'Kaffee', level: 1, page: 1, pages: [1, 2] },
|
||
{ text: 'Kaffee: Bohne', level: 1, page: 1 },
|
||
],
|
||
} },
|
||
] };
|
||
const terms = (back: N) => {
|
||
const out: string[] = [];
|
||
const walk = (n: N): void => {
|
||
if (n?.type === 'indexEntry') out.push(`${n.attrs.key1 ? `${n.attrs.key1}:` : ''}${n.attrs.term}`);
|
||
(n?.content ?? []).forEach(walk);
|
||
};
|
||
walk(back.content);
|
||
return out.join(' ');
|
||
};
|
||
const indexKind = (back: N) => (back.content.content ?? [])
|
||
.find((n: N) => n.type === 'tableOfContents')?.attrs?.index;
|
||
|
||
it('ODT: point marks plus a text:alphabetical-index, and back', async () => {
|
||
const bytes = await buildOdt(doc, margins, 'portrait');
|
||
const xml = strFromU8(unzipSync(bytes)['content.xml']);
|
||
check('mark emitted', xml.includes('<text:alphabetical-index-mark text:string-value="Kaffee"/>'),
|
||
xml.match(/<text:alphabetical-index-mark[^>]*>/g));
|
||
check('a key files the term under it', xml.includes('text:string-value="Bohne" text:key1="Kaffee"'),
|
||
xml.match(/<text:alphabetical-index-mark[^>]*>/g));
|
||
check('index element emitted', xml.includes('<text:alphabetical-index ') || xml.includes('<text:alphabetical-index>'),
|
||
xml.match(/<text:alphabetical-index[^>]*>/g));
|
||
check('source merges repeated entries', xml.includes('text:combine-entries="true"'));
|
||
check('the row keeps every page it was marked on', xml.includes('<text:tab/>1, 2</text:p>'),
|
||
xml.match(/<text:index-body>[\s\S]*?<\/text:index-body>/)?.[0]);
|
||
const back = importOdt(bytes);
|
||
check('no warnings on own export', back.warnings.length === 0, back.warnings);
|
||
check('marks round-trip', terms(back as N) === 'Kaffee Kaffee:Bohne Kaffee', terms(back as N));
|
||
check('index round-trips as alphabetical', indexKind(back) === 'alphabetical', indexKind(back));
|
||
});
|
||
|
||
it('DOCX: XE fields plus an INDEX field, and back', async () => {
|
||
const bytes = await buildDocx(doc, margins, 'portrait');
|
||
const xml = strFromU8(unzipSync(bytes)['word/document.xml']);
|
||
check('XE emitted', xml.includes('XE "Kaffee"'), xml.match(/w:instr="[^"]*XE[^"]*"/g));
|
||
check('a key rides the term', xml.includes('XE "Kaffee:Bohne"'), xml.match(/w:instr="[^"]*XE[^"]*"/g));
|
||
check('INDEX field emitted', /w:instr="INDEX[^"]*"/.test(xml), xml.match(/w:instr="[^"]*"/g));
|
||
|
||
const back = importDocx(bytes);
|
||
check('marks round-trip', terms(back as N) === 'Kaffee Kaffee:Bohne Kaffee', terms(back as N));
|
||
check('index round-trips as alphabetical', indexKind(back) === 'alphabetical', indexKind(back));
|
||
});
|
||
});
|
||
|
||
describe('Leg 26: bibliography', () => {
|
||
const cite = (identifier: string, type: string, fields: Record<string, string>): N =>
|
||
({ type: 'bibliographyEntry', attrs: { identifier, type, fields, text: '' } });
|
||
const KAF = { author: 'Kafka, Franz', title: 'Der Process', year: '1925', publisher: 'Kurt Wolff' };
|
||
const MEY = { author: 'Meyer, Anna', title: 'Zur Sache', year: '1999', journal: 'Zeitschrift' };
|
||
const doc: N = { type: 'doc', content: [
|
||
P(null, T('Ein Satz mit Beleg '), cite('KAF01', 'book', KAF), T('.')),
|
||
P(null, T('Und noch einer '), cite('MEY99', 'article', MEY), T(', wieder '), cite('KAF01', 'book', KAF), T('.')),
|
||
{ type: 'tableOfContents', attrs: {
|
||
index: 'bibliography', title: 'Literaturverzeichnis',
|
||
entries: [
|
||
{ text: 'KAF01: Kafka, Franz, Der Process, 1925', level: 1, page: 1 },
|
||
{ text: 'MEY99: Meyer, Anna, Zur Sache, 1999', level: 1, page: 1 },
|
||
],
|
||
} },
|
||
] };
|
||
const cites = (back: N) => {
|
||
const out: string[] = [];
|
||
const walk = (n: N): void => {
|
||
if (n?.type === 'bibliographyEntry') out.push(`${n.attrs.identifier}/${n.attrs.type}/${n.attrs.fields?.author ?? ''}`);
|
||
(n?.content ?? []).forEach(walk);
|
||
};
|
||
walk(back);
|
||
return out.join(' ');
|
||
};
|
||
const indexKind = (back: N) => (back.content.content ?? [])
|
||
.find((n: N) => n.type === 'tableOfContents')?.attrs?.index;
|
||
const WANT = 'KAF01/book/Kafka, Franz MEY99/article/Meyer, Anna KAF01/book/Kafka, Franz';
|
||
|
||
it('ODT: bibliography marks plus a text:bibliography, and back', async () => {
|
||
const bytes = await buildOdt(doc, margins, 'portrait');
|
||
const xml = strFromU8(unzipSync(bytes)['content.xml']);
|
||
check('mark carries its whole record',
|
||
xml.includes('<text:bibliography-mark text:identifier="KAF01" text:bibliography-type="book"')
|
||
&& xml.includes('text:author="Kafka, Franz"') && xml.includes('text:year="1925"'),
|
||
xml.match(/<text:bibliography-mark[^>]*>/g));
|
||
check('the mark wraps the text the citation shows', xml.includes('>[KAF01]</text:bibliography-mark>'),
|
||
xml.match(/<text:bibliography-mark[\s\S]{0,200}?<\/text:bibliography-mark>/g));
|
||
check('index element emitted', xml.includes('<text:bibliography '), xml.match(/<text:bibliography[^>-][^>]*>/g));
|
||
check('a template per type cited',
|
||
xml.includes('text:bibliography-type="book" text:style-name="Bibliography_20_1"')
|
||
&& xml.includes('text:bibliography-type="article" text:style-name="Bibliography_20_1"'),
|
||
xml.match(/<text:bibliography-entry-template[^>]*>/g));
|
||
check('a row has no tab and no page number',
|
||
xml.includes('>KAF01: Kafka, Franz, Der Process, 1925</text:p>'),
|
||
xml.match(/<text:index-body>[\s\S]*?<\/text:index-body>/)?.[0]);
|
||
|
||
const back = importOdt(bytes);
|
||
check('no warnings on own export', back.warnings.length === 0, back.warnings);
|
||
check('citations round-trip', cites(back.content as N) === WANT, cites(back.content as N));
|
||
check('index round-trips as a bibliography', indexKind(back) === 'bibliography', indexKind(back));
|
||
});
|
||
|
||
it('DOCX: CITATION fields over a custom-XML source, and back', async () => {
|
||
const bytes = await buildDocx(doc, margins, 'portrait');
|
||
const files = unzipSync(bytes);
|
||
const xml = strFromU8(files['word/document.xml']);
|
||
check('CITATION emitted', xml.includes('w:instr="CITATION "KAF01""'), xml.match(/w:instr="[^"]*"/g));
|
||
check('BIBLIOGRAPHY field emitted', xml.includes('w:instr="BIBLIOGRAPHY"'), xml.match(/w:instr="[^"]*"/g));
|
||
|
||
const item = files['customXml/item1.xml'] ? strFromU8(files['customXml/item1.xml']) : '';
|
||
check('one source per tag, not per citation', (item.match(/<b:Source>/g) ?? []).length === 2,
|
||
item.match(/<b:Tag>[^<]*<\/b:Tag>/g));
|
||
check('the author splits into Word’s name list',
|
||
item.includes('<b:Last>Kafka</b:Last><b:First>Franz</b:First>'), item.slice(0, 600));
|
||
check('the type maps onto Word’s', item.includes('<b:SourceType>Book</b:SourceType>')
|
||
&& item.includes('<b:SourceType>JournalArticle</b:SourceType>'), item.match(/<b:SourceType>[^<]*</g));
|
||
check('the part is declared', !!files['customXml/itemProps1.xml']
|
||
&& !!files['customXml/_rels/item1.xml.rels'], Object.keys(files).filter(k => k.startsWith('customXml')));
|
||
check('the document relates to it',
|
||
strFromU8(files['word/_rels/document.xml.rels']).includes('../customXml/item1.xml'));
|
||
check('its properties part has a content type',
|
||
strFromU8(files['[Content_Types].xml']).includes('/customXml/itemProps1.xml'));
|
||
|
||
const back = importDocx(bytes);
|
||
check('citations round-trip', cites(back.content as N) === WANT, cites(back.content as N));
|
||
check('index round-trips as a bibliography', indexKind(back) === 'bibliography', indexKind(back));
|
||
});
|
||
});
|
||
|
||
describe('Leg 27: per-section page size and orientation (ODT + DOCX)', () => {
|
||
const secDoc: N = {
|
||
type: 'doc',
|
||
content: [
|
||
P(null, T('portrait section')),
|
||
P({ sectionBreak: true, breakBefore: 'page' }, T('the wide table lives here')),
|
||
P({ sectionBreak: true, breakBefore: 'page' }, T('portrait again')),
|
||
],
|
||
};
|
||
// The common case: one landscape page amid portrait ones, plus a section on
|
||
// another paper entirely.
|
||
const sections = [
|
||
{ ...EMPTY_HF_SET },
|
||
{ ...EMPTY_HF_SET, orientation: 'landscape' as const },
|
||
{ ...EMPTY_HF_SET, format: 'A5' as const },
|
||
];
|
||
const paperOf = (res: N, i: number) =>
|
||
`${res.hfSections?.[i]?.format ?? '-'}/${res.hfSections?.[i]?.orientation ?? '-'}`;
|
||
|
||
it('ODT: each section gets its own page layout', async () => {
|
||
const bytes = await buildOdt(secDoc, margins, 'portrait', { sections, pageCount: 3 });
|
||
const styles = strFromU8(unzipSync(bytes)['styles.xml']);
|
||
const layoutOf = (n: number) => {
|
||
const name = new RegExp(`<style:master-page style:name="Section${n}" style:page-layout-name="([^"]*)"`).exec(styles)?.[1];
|
||
return name ? new RegExp(`<style:page-layout\\b[^>]*style:name="${name}"[\\s\\S]*?<style:page-layout-properties\\b[^>]*>`).exec(styles)?.[0] ?? '' : '';
|
||
};
|
||
check('the landscape section swaps the page box',
|
||
/fo:page-width="29.7cm"/.test(layoutOf(2)) && /fo:page-height="21cm"/.test(layoutOf(2)), layoutOf(2));
|
||
check('and says so on the layout', /style:print-orientation="landscape"/.test(layoutOf(2)), layoutOf(2));
|
||
check('the A5 section takes A5', /fo:page-width="14.8cm"/.test(layoutOf(3)) && /fo:page-height="21cm"/.test(layoutOf(3)), layoutOf(3));
|
||
|
||
const res = importOdt(bytes);
|
||
check('the document keeps its own paper', `${res.format}/${res.orientation}` === 'A4/portrait', `${res.format}/${res.orientation}`);
|
||
check('the landscape section round-trips', paperOf(res, 1) === '-/landscape', paperOf(res, 1));
|
||
check('the A5 section round-trips', paperOf(res, 2) === 'A5/-', paperOf(res, 2));
|
||
});
|
||
|
||
it('DOCX: each sectPr carries its own w:pgSz', async () => {
|
||
const bytes = await buildDocx(secDoc, margins, 'portrait', { sections, pageCount: 3 });
|
||
const xml = strFromU8(unzipSync(bytes)['word/document.xml']);
|
||
const sizes = xml.match(/<w:pgSz[^>]*>/g) ?? [];
|
||
check('three sections, three page boxes', sizes.length === 3, sizes);
|
||
check('the middle one is landscape', /w:orient="landscape"/.test(sizes[1] ?? '') && /w:w="16838"/.test(sizes[1] ?? ''), sizes[1]);
|
||
check('the last one is A5', /w:w="839[12]"/.test(sizes[2] ?? ''), sizes[2]);
|
||
|
||
const res = importDocx(bytes);
|
||
check('the document keeps its own paper', `${res.format}/${res.orientation}` === 'A4/portrait', `${res.format}/${res.orientation}`);
|
||
check('the landscape section round-trips', paperOf(res, 1) === '-/landscape', paperOf(res, 1));
|
||
check('the A5 section round-trips', paperOf(res, 2) === 'A5/-', paperOf(res, 2));
|
||
});
|
||
});
|
||
|
||
describe('Leg 29: lines and arrows (ODT + DOCX)', () => {
|
||
const line = (kind: string, flipV = false): N => ({
|
||
type: 'textBox',
|
||
// strokeColor as the schema defaults it: a line with no pen is invisible, and
|
||
// both exports then write it as one.
|
||
attrs: { width: 200, height: 60, shapeKind: kind, strokeColor: '#000000', ...(flipV ? { flipV: true } : {}) },
|
||
content: [P(null)],
|
||
});
|
||
const doc: N = { type: 'doc', content: [P(null, T('above')), line('lineArrow', true), P(null, T('below'))] };
|
||
const boxOf = (res: N): N => (res.content.content ?? []).find((n: N) => n.type === 'textBox');
|
||
|
||
it('ODT: a line is two endpoints, not a frame', async () => {
|
||
const bytes = await buildOdt(doc, margins);
|
||
const xml = strFromU8(unzipSync(bytes)['content.xml']);
|
||
const el = /<draw:line\b[^>]*\/>/.exec(xml)?.[0] ?? '';
|
||
check('the flipped line runs bottom-left to top-right', /svg:y1="[^0][^"]*cm" svg:x2="[^"]*" svg:y2="0cm"/.test(el), el);
|
||
const style = /<style:style style:name="TbxFr1"[\s\S]*?\/><\/style:style>/.exec(xml)?.[0] ?? '';
|
||
check('and carries the arrow head at its end', /draw:marker-end="Arrow"/.test(style), style);
|
||
check('but not at its start', !/draw:marker-start=/.test(style), style);
|
||
check('the marker itself is defined once', /<draw:marker draw:name="Arrow"/.test(strFromU8(unzipSync(bytes)['styles.xml'])), 'styles.xml');
|
||
|
||
const res = importOdt(bytes);
|
||
check('no warnings — the line is not dropped', res.warnings.length === 0, res.warnings);
|
||
check('it comes back as an arrow', boxOf(res)?.attrs?.shapeKind === 'lineArrow', boxOf(res)?.attrs);
|
||
check('on the same diagonal', boxOf(res)?.attrs?.flipV === true, boxOf(res)?.attrs);
|
||
});
|
||
|
||
it('DOCX: a line is the preset Word flips across the frame', async () => {
|
||
const bytes = await buildDocx(doc, margins);
|
||
const xml = strFromU8(unzipSync(bytes)['word/document.xml']);
|
||
check('the frame is flipped, not the geometry', /<a:xfrm flipV="1">/.test(xml), xml.slice(xml.indexOf('<a:xfrm'), xml.indexOf('<a:xfrm') + 80));
|
||
check('one head, at the tail', /<a:tailEnd type="triangle"\/>/.test(xml) && !/<a:headEnd/.test(xml), 'a:ln');
|
||
check('and no text body on it', !/<wps:txbx>/.test(xml), 'wps:txbx');
|
||
|
||
const res = importDocx(bytes);
|
||
check('it comes back as an arrow', boxOf(res)?.attrs?.shapeKind === 'lineArrow', boxOf(res)?.attrs);
|
||
check('on the same diagonal', boxOf(res)?.attrs?.flipV === true, boxOf(res)?.attrs);
|
||
});
|
||
});
|
||
|
||
describe('Leg 28: a cell formula (ODT + DOCX)', () => {
|
||
// A1..A3 hold the numbers, A4 sums them the way Word names the range.
|
||
const num = (text: string): N => ({ type: 'tableCell', attrs: { colspan: 1, rowspan: 1, colwidth: [100] }, content: [P(null, T(text))] });
|
||
const sum: N = {
|
||
type: 'tableCell',
|
||
attrs: { colspan: 1, rowspan: 1, colwidth: [100], formula: 'SUM(ABOVE)' },
|
||
content: [P(null, T('19.5'))],
|
||
};
|
||
const doc: N = {
|
||
type: 'doc',
|
||
content: [
|
||
P(null, T('before')),
|
||
{ type: 'table', content: [ROW(num('10')), ROW(num('2.5')), ROW(num('7')), ROW(sum)] },
|
||
P(null, T('after')),
|
||
],
|
||
};
|
||
const formulaOf = (res: N): unknown =>
|
||
(res.content.content ?? []).find((n: N) => n.type === 'table')?.content?.[3]?.content?.[0]?.attrs?.formula;
|
||
|
||
it('ODT: the formula rides the cell, in LibreOffice\'s own language', async () => {
|
||
const bytes = await buildOdt(doc, margins);
|
||
const xml = strFromU8(unzipSync(bytes)['content.xml']);
|
||
const cell = /<table:table-cell[^>]*table:formula="[^"]*"[^>]*>/.exec(xml)?.[0] ?? '';
|
||
check('ABOVE is resolved to the range it stands for', /table:formula="ooow:sum <A1:A3>"/.test(cell), cell);
|
||
check('and the result is cached as the cell value', /office:value-type="float" office:value="19.5"/.test(cell), cell);
|
||
|
||
const res = importOdt(bytes);
|
||
check('it comes back as the stored formula', formulaOf(res) === 'SUM(A1:A3)', formulaOf(res));
|
||
});
|
||
|
||
it('DOCX: the cell holds a formula field', async () => {
|
||
const bytes = await buildDocx(doc, margins);
|
||
const xml = strFromU8(unzipSync(bytes)['word/document.xml']);
|
||
const field = /<w:fldSimple[^>]*w:instr="[^"]*=[^"]*"[^>]*>[\s\S]*?<\/w:fldSimple>/.exec(xml)?.[0] ?? '';
|
||
check('the field is the formula Word reads', /w:instr="\s*=SUM\(ABOVE\)\s*"/.test(field), field);
|
||
check('with the result cached in it', />19\.5</.test(field), field);
|
||
|
||
const res = importDocx(bytes);
|
||
check('it comes back as the stored formula', formulaOf(res) === 'SUM(ABOVE)', formulaOf(res));
|
||
});
|
||
});
|
||
|
||
// A picture and its caption in one frame — LibreOffice's own arrangement for a
|
||
// captioned figure, and what a caption that stays with its picture would build.
|
||
describe('Leg 30: a figure frame (picture + caption in one box)', () => {
|
||
const SEQ: N = { type: 'sequenceField', attrs: { category: 'figure', format: '1', number: 1 } };
|
||
const frame = TBX(
|
||
{ width: 240, height: 190, wrap: 'topBottom', wrapAlign: 'center', fillColor: null, strokeColor: null },
|
||
P(null, IMGN(240, 160, 'pic')),
|
||
P({ styleName: 'Caption' }, T('Figure '), SEQ, T(': framed')),
|
||
);
|
||
const doc: N = { type: 'doc', content: [P(null, T('body')), frame] };
|
||
const boxOf = (res: N): N => (res.content.content ?? []).find((n: N) => n.type === 'textBox');
|
||
const shapeOf = (box: N): string =>
|
||
(box?.content ?? []).map((b: N) => (b.content ?? []).map((c: N) => c.type).join('+')).join('|');
|
||
|
||
it('ODT: the picture rides inside the box, caption and counter with it', async () => {
|
||
const bytes = await buildOdt(doc, margins);
|
||
const xml = strFromU8(unzipSync(bytes)['content.xml']);
|
||
const box = xml.match(/<draw:text-box[\s\S]*?<\/draw:text-box>/)?.[0] ?? '';
|
||
check('the picture is inside the text box', box.includes('<draw:image'), box.slice(0, 200));
|
||
check('so is the counter', box.includes('text:name="Illustration"'), box.slice(0, 400));
|
||
const back = boxOf(importOdt(bytes));
|
||
check('box comes back whole', shapeOf(back) === 'image|text+sequenceField+text', shapeOf(back));
|
||
});
|
||
|
||
it('DOCX: the drawing inside the box does not swallow it', async () => {
|
||
const bytes = await buildDocx(doc, margins);
|
||
const xml = strFromU8(unzipSync(bytes)['word/document.xml']);
|
||
const box = xml.match(/<w:txbxContent>[\s\S]*?<\/w:txbxContent>/)?.[0] ?? '';
|
||
check('the picture is inside the box', box.includes('<w:drawing>'), box.slice(0, 200));
|
||
check('the counter stays a field', box.includes('SEQ Figure'), box.slice(0, 600));
|
||
// A nested drawing's own wp:inline used to be read as the outer one's root, which
|
||
// dropped the box and every word of the caption.
|
||
const back = boxOf(importDocx(bytes));
|
||
check('box comes back whole', shapeOf(back) === 'image|text+sequenceField+text', shapeOf(back));
|
||
});
|
||
});
|
||
|
||
describe('Leg 31: placeholder fields (ODT + DOCX)', () => {
|
||
const PLF = (text: string, marks?: N[]): N =>
|
||
({ type: 'placeholderField', attrs: { text }, ...(marks ? { marks } : {}) });
|
||
const BOLD = { type: 'bold' };
|
||
const doc: N = {
|
||
type: 'doc',
|
||
content: [
|
||
P(null, T('Sehr geehrte '), PLF('Empfängername'), T(',')),
|
||
P(null, PLF('Betreff & <Zeichen>', [BOLD])),
|
||
],
|
||
};
|
||
const fieldsOf = (res: N): N[] => {
|
||
const out: N[] = [];
|
||
(function walk(n: N) { if (n.type === 'placeholderField') out.push(n); for (const c of n.content ?? []) walk(c); })(res.content);
|
||
return out;
|
||
};
|
||
|
||
it('ODT: exports <text:placeholder> and re-imports label + marks', async () => {
|
||
const bytes = await buildOdt(doc, margins);
|
||
const content = strFromU8(unzipSync(bytes)['content.xml']);
|
||
check('emits text:placeholder', content.includes('<text:placeholder text:placeholder-type="text">'), content.slice(0, 200));
|
||
check('label carries LibreOffice-style brackets', content.includes('<Empfängername>'));
|
||
check('no leftover sentinel', !content.includes(''));
|
||
const fields = fieldsOf(importOdt(bytes));
|
||
check('both fields imported', fields.length === 2, fields);
|
||
check('label round-trips', fields[0]?.attrs?.text === 'Empfängername', fields[0]?.attrs);
|
||
check('special characters survive', fields[1]?.attrs?.text === 'Betreff & <Zeichen>', fields[1]?.attrs);
|
||
check('bold mark survives', (fields[1]?.marks ?? []).some((m: N) => m.type === 'bold'), fields[1]?.marks);
|
||
});
|
||
|
||
it('DOCX: exports a tagged <w:sdt> and re-imports it as a field', async () => {
|
||
const bytes = await buildDocx(doc, margins);
|
||
const xml = strFromU8(unzipSync(bytes)['word/document.xml']);
|
||
check('emits a content control', xml.includes('<w:sdt>'), xml.slice(0, 200));
|
||
check('tagged as ours', xml.includes('w:tag w:val="edentext-placeholder"'));
|
||
check('gray placeholder color', xml.includes('<w:color w:val="808080"/>'));
|
||
check('no leftover sentinel', !xml.includes(''));
|
||
const fields = fieldsOf(importDocx(bytes));
|
||
check('both fields imported', fields.length === 2, fields);
|
||
check('label round-trips', fields[0]?.attrs?.text === 'Empfängername', fields[0]?.attrs);
|
||
check('special characters survive', fields[1]?.attrs?.text === 'Betreff & <Zeichen>', fields[1]?.attrs);
|
||
check('bold mark survives', (fields[1]?.marks ?? []).some((m: N) => m.type === 'bold'), fields[1]?.marks);
|
||
check('the gray does not come back as a color mark',
|
||
!fields.some((f) => (f.marks ?? []).some((m: N) => m.type === 'textStyle' && m.attrs?.color)), fields);
|
||
});
|
||
});
|
||
|
||
describe('Leg 32: fold marks (ODT + DOCX)', () => {
|
||
const doc: N = { type: 'doc', content: [P(null, T('Brieftext'))] };
|
||
const commonTail = [undefined, undefined, 'A4', builtinStyleSheet(), 1.25, 'add', false,
|
||
DEFAULT_NOTE_SETTINGS, EMPTY_DOC_PROPERTIES, false, DEFAULT_PAGE_NUMBERING,
|
||
EMPTY_PAGE_DECOR, DEFAULT_LINE_NUMBERING, false, true] as const;
|
||
|
||
it('ODT: named header lines carry the flag both ways', async () => {
|
||
const bytes = await buildOdt(doc, margins, 'portrait', ...commonTail);
|
||
const styles = strFromU8(unzipSync(bytes)['styles.xml']);
|
||
check('five named lines in the header (folds both edges + punch)', (styles.match(new RegExp(FOLD_MARK_NAME, 'g')) ?? []).length === 5, styles.slice(0, 200));
|
||
check('a right-edge fold mark', styles.includes(`${FOLD_MARK_NAME}1R`));
|
||
check('page-relative position', styles.includes('style:vertical-rel="page"'));
|
||
const res = importOdt(bytes);
|
||
check('flag comes back', res.foldMarks === true);
|
||
check('no shape leaks into the header zone', res.header === null, res.header);
|
||
check('no warning for the dropped lines', res.warnings.length === 0, res.warnings);
|
||
// Off exports nothing and imports off.
|
||
const plain = await buildOdt(doc, margins, 'portrait');
|
||
check('off writes no lines', !strFromU8(unzipSync(plain)['styles.xml']).includes(FOLD_MARK_NAME));
|
||
check('off imports off', importOdt(plain).foldMarks === false);
|
||
});
|
||
|
||
it('DOCX: named VML lines carry the flag both ways', async () => {
|
||
const bytes = await buildDocx(doc, margins, 'portrait', ...commonTail);
|
||
const files = unzipSync(bytes);
|
||
const headers = Object.keys(files).filter((p) => /^word\/header\d*\.xml$/.test(p));
|
||
check('a header part exists for the lines', headers.length > 0, Object.keys(files));
|
||
check('the lines ride a header', headers.some((p) => strFromU8(files[p]).includes(FOLD_MARK_NAME)));
|
||
check('a right-edge fold mark', headers.some((p) => strFromU8(files[p]).includes(`${FOLD_MARK_NAME}1R`)));
|
||
const res = importDocx(bytes);
|
||
check('flag comes back', res.foldMarks === true);
|
||
check('no shape leaks into the header zone', res.header === null, res.header);
|
||
check('no warning for the dropped lines', res.warnings.length === 0, res.warnings);
|
||
const plain = await buildDocx(doc, margins, 'portrait');
|
||
check('off imports off', importDocx(plain).foldMarks === false);
|
||
});
|
||
});
|