by TipTap's node view. */
.paper .tiptap .tableWrapper {
- margin: 0 0 0.212cm;
+ /* The exported table style carries no margin (nor do Word/LO tables). */
+ margin: 0;
overflow-x: auto;
}
diff --git a/tests/docx-roundtrip.test.ts b/tests/docx-roundtrip.test.ts
index 23b6f59..0a41495 100644
--- a/tests/docx-roundtrip.test.ts
+++ b/tests/docx-roundtrip.test.ts
@@ -461,6 +461,35 @@ describe('DOCX import resolves theme fonts (Word default = Calibri)', () => {
});
});
+describe('DOCX import falls back to the theme font when nothing references it', () => {
+ const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"';
+ const A = 'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"';
+ // No w:rFonts anywhere (not even a theme ref): Word still uses the theme minor font as the
+ // implicit body default. A heading must keep the editor default, not the theme major font.
+ const documentXml = `
+ Body text
+ Heading text
+ `;
+ const themeXml = `
+
+
+ `;
+
+ const bytes = zipSync({ 'word/document.xml': strToU8(documentXml), 'word/theme/theme1.xml': strToU8(themeXml) });
+ const doc = importDocx(bytes).content as N;
+
+ it('applies the theme minor font to body text with no font of its own', () => {
+ const t = walk(doc.content![0], 'text')[0];
+ expect(t.text).toBe('Body text');
+ expect(markAttrs(t, 'textStyle')?.fontFamily).toBe('Calibri');
+ });
+ it('leaves a heading on the editor default (no theme major font)', () => {
+ const h = doc.content![1];
+ expect(h.type).toBe('heading');
+ expect(markAttrs(walk(h, 'text')[0], 'textStyle')?.fontFamily).toBeUndefined();
+ });
+});
+
describe('DOCX import detects headings by outline level (non-"HeadingN" style ids)', () => {
const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"';
// A heading style whose id is NOT "Heading1" — only its w:outlineLvl marks it a heading.
@@ -690,3 +719,92 @@ describe('DOCX import of foreign multi-column sections', () => {
expect(result.warnings).toContain('Tables and text boxes inside a multi-column layout were moved out of the columns');
});
});
+
+describe('DOCX import: hardBreak carries its run font (empty-line height)', () => {
+ const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"';
+ // "Muster"
"Arbeitsvertrag", every run at 36pt (sz=72): the blank line
+ // between the two breaks must keep the 36pt height, like Word/LibreOffice.
+ const run = (inner: string) => `
${inner}`;
+ const documentXml = `
+
+ ${run('Muster')}${run('')}${run('')}${run('Arbeitsvertrag')}
+
+ `;
+ const bytes = zipSync({ 'word/document.xml': strToU8(documentXml) });
+
+ it('tags both hardBreaks with the run font size', () => {
+ const doc = importDocx(bytes).content as N;
+ const brs = walk(doc.content![0], 'hardBreak');
+ expect(brs.length).toBe(2);
+ for (const br of brs) expect(markAttrs(br, 'textStyle')?.fontSize).toBe('36pt');
+ });
+
+ it('keeps the break font size through a DOCX export round trip', async () => {
+ const doc = importDocx(bytes).content as N;
+ const round = importDocx(await buildDocx(doc as never)).content as N;
+ const brs = walk(round.content![0], 'hardBreak');
+ expect(brs.length).toBe(2);
+ for (const br of brs) expect(markAttrs(br, 'textStyle')?.fontSize).toBe('36pt');
+ });
+});
+
+describe('DOCX import: a list continued across paragraphs keeps counting (same numId)', () => {
+ const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"';
+ const item = (numId: number, t: string) =>
+ `
${t}`;
+ const gap = (t: string) => `
${t}`;
+ // numId 5 (upperRoman) split by body paragraphs; numId 6 shares the format but is its own
+ // counter, so it must restart — Word continues per numId, not per glyph style.
+ const documentXml = `
+ ${item(5, 'One')}${gap('body a')}${item(5, 'Two')}${gap('body b')}${item(5, 'Three')}${item(6, 'Other')}
+ `;
+ const numberingXml = `
+
+
+
+ `;
+ const bytes = zipSync({ 'word/document.xml': strToU8(documentXml), 'word/numbering.xml': strToU8(numberingXml) });
+ const doc = importDocx(bytes).content as N;
+
+ it('emits separate ordered lists whose start values continue the numId', () => {
+ const ols = doc.content!.filter((n) => n.type === 'orderedList');
+ expect(ols.length).toBe(4);
+ expect(ols.map((o) => o.attrs?.start)).toEqual([undefined, 2, 3, undefined]);
+ expect(ols.every((o) => o.attrs?.listStyleType === 'upper-roman')).toBe(true);
+ expect(walk(ols[3], 'text')[0].text).toBe('Other'); // separate numId → restarts at 1
+ });
+});
+
+describe('DOCX import: a mid-body section break starts a new page', () => {
+ const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"';
+ const geom = '
';
+ const build = (type: string | null) => {
+ const t = type ? `
` : '';
+ const documentXml = `
+ section one
+ ${geom}
+ section two
+ ${t}${geom}
+ `;
+ return importDocx(zipSync({ 'word/document.xml': strToU8(documentXml) })).content as N;
+ };
+
+ it("marks the next section's first block with breakBefore for a default (nextPage) break", () => {
+ const top = build(null).content!;
+ expect(top.map((n) => n.type)).toEqual(['paragraph', 'paragraph']);
+ expect(walk(top[1], 'text')[0].text).toBe('section two');
+ expect(top[0].attrs?.breakBefore).toBeFalsy();
+ expect(top[1].attrs?.breakBefore).toBe('page');
+ });
+
+ it('does not break for a continuous section', () => {
+ const top = build('continuous').content!;
+ expect(top[1].attrs?.breakBefore).toBeFalsy();
+ });
+
+ it('keeps the section-break page break through a DOCX export round trip', async () => {
+ const round = importDocx(await buildDocx(build(null) as never)).content as N;
+ const two = round.content!.find((n) => walk(n, 'text').some((t) => t.text === 'section two'))!;
+ expect(two.attrs?.breakBefore).toBe('page');
+ });
+});
diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts
index 8dfbe2e..adae1a0 100644
--- a/tests/roundtrip.test.ts
+++ b/tests/roundtrip.test.ts
@@ -338,6 +338,39 @@ describe('Leg 1a: page format', () => {
});
});
+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 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 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]
@@ -542,8 +575,8 @@ describe('Leg 2: foreign (LibreOffice/Word-style) .odt → importOdt', () => {
check('foreign: Letter stays portrait', f.orientation === 'portrait');
check('foreign: plain run has no marks', c[0].content![0].marks === undefined, c[0]);
- // Omitted fo:margin-bottom → ODF default 0, not the editor's 6pt body default.
- check('foreign: omitted margin → spaceAfter 0', c[0].attrs?.spaceAfter === 0, c[0].attrs);
+ // 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);
diff --git a/tests/unit/default-spacing.test.ts b/tests/unit/default-spacing.test.ts
new file mode 100644
index 0000000..b11d8b0
--- /dev/null
+++ b/tests/unit/default-spacing.test.ts
@@ -0,0 +1,28 @@
+import { describe, it, expect } from 'vitest';
+import { unzipSync, strFromU8 } from 'fflate';
+import { buildOdt } from '../../src/lib/export/odt';
+import { DEFAULT_MARGINS } from '../../src/lib/storage/pageMargins';
+
+// The editor renders no paragraph spacing (editor.css), like LibreOffice and Word. odf-kit's
+// Standard style carries 0.212cm, which every paragraph and list item inherits — so the
+// export must zero it, or LibreOffice shows ~6pt per block that the preview never had.
+describe('exported Standard style', () => {
+ it('has no bottom margin, so the export matches the preview', async () => {
+ const json = {
+ type: 'doc',
+ content: [
+ { type: 'paragraph', content: [{ type: 'text', text: 'body' }] },
+ {
+ type: 'bulletList',
+ content: [{ type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'item' }] }] }],
+ },
+ ],
+ };
+ const bytes = await buildOdt(json as never, DEFAULT_MARGINS, 'portrait');
+ const styles = strFromU8(unzipSync(bytes)['styles.xml']);
+
+ const standard = styles.match(/
/)![0];
+ expect(standard).toContain('fo:margin-bottom="0cm"');
+ expect(standard).not.toContain('0.212cm');
+ });
+});
diff --git a/tests/unit/docx-default-style.test.ts b/tests/unit/docx-default-style.test.ts
new file mode 100644
index 0000000..7c8b809
--- /dev/null
+++ b/tests/unit/docx-default-style.test.ts
@@ -0,0 +1,72 @@
+import { describe, it, expect } from 'vitest';
+import { DocxStyles } from '../../src/lib/import/docxStyles';
+
+const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
+
+// Word's "Normal" (here LibreOffice's German "Standard"): the w:default="1" paragraph
+// style carries the real body font, while docDefaults still says Times New Roman.
+const STYLES = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+const styles = () => new DocxStyles(new DOMParser().parseFromString(STYLES, 'application/xml'), null);
+
+describe('DocxStyles.paragraphRun', () => {
+ it('applies the default paragraph style to a paragraph with no w:pStyle', () => {
+ expect(styles().paragraphRun(null)).toMatchObject({ font: 'Arial', sizeHalfPt: 22 });
+ });
+
+ it('resolves a pStyle through its basedOn chain to the default style', () => {
+ expect(styles().paragraphRun('Listenabsatz')).toMatchObject({ font: 'Arial', sizeHalfPt: 22 });
+ });
+
+ it("lets a pStyle's own font win over the default style", () => {
+ expect(styles().paragraphRun('Quote').font).toBe('Georgia');
+ });
+
+ it('falls back to docDefaults when there is no default paragraph style', () => {
+ const bare = `
+
+
+ `;
+ const s = new DocxStyles(new DOMParser().parseFromString(bare, 'application/xml'), null);
+ expect(s.paragraphRun(null).font).toBe('Times New Roman');
+ });
+});
+
+describe('DocxStyles.paragraphSpacing', () => {
+ it('is empty when no layer sets spacing (Word implies 0)', () => {
+ expect(styles().paragraphSpacing(null)).toEqual({});
+ });
+
+ it("reads modern Word's docDefaults spacing", () => {
+ const modern = `
+
+
+
+
+
+
+
+
+
+ `;
+ const s = new DocxStyles(new DOMParser().parseFromString(modern, 'application/xml'), null);
+ expect(s.paragraphSpacing(null)).toMatchObject({ after: 160, line: 259 });
+ expect(s.paragraphSpacing('NoSpacing')).toMatchObject({ after: 0, line: 240 });
+ });
+});
diff --git a/tests/unit/page-margins.test.ts b/tests/unit/page-margins.test.ts
index 0c5a176..e2a3d4d 100644
--- a/tests/unit/page-margins.test.ts
+++ b/tests/unit/page-margins.test.ts
@@ -18,7 +18,7 @@ describe('pageMargins units', () => {
expect(cmToPx(0)).toBe(0);
});
- it('default margins are the documented A4 values', () => {
- expect(DEFAULT_MARGINS).toEqual({ top: 2.54, bottom: 2.54, left: 2.12, right: 2.12 });
+ it("default margins are LibreOffice's 2cm all round", () => {
+ expect(DEFAULT_MARGINS).toEqual({ top: 2, bottom: 2, left: 2, right: 2 });
});
});