From d461ce09a677a3c7df61638f4b5a9ce7533117d0 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Tue, 28 Jul 2026 22:45:24 +0200 Subject: [PATCH] fix: align styles and headings to libreoffice --- src/lib/components/Toolbar.svelte | 30 +++++---- src/lib/components/ToolbarExpanded.svelte | 4 +- src/lib/export/docx.ts | 4 +- src/lib/export/odt.ts | 38 ++++++++--- src/lib/import/docx.ts | 22 +++--- src/lib/import/odt.ts | 7 +- src/lib/import/styleResolver.ts | 18 +++++ src/styles/editor.css | 14 ++-- src/styles/global.css | 3 + tests/docx-roundtrip.test.ts | 17 +++-- tests/roundtrip.test.ts | 8 +-- tests/unit/heading-styles-import.test.ts | 81 +++++++++++++++++++++++ 12 files changed, 197 insertions(+), 49 deletions(-) create mode 100644 tests/unit/heading-styles-import.test.ts diff --git a/src/lib/components/Toolbar.svelte b/src/lib/components/Toolbar.svelte index 7d10445..5b278f8 100644 --- a/src/lib/components/Toolbar.svelte +++ b/src/lib/components/Toolbar.svelte @@ -6,6 +6,7 @@ import { BULLET_TYPES } from '../utils/bulletListTypes'; import { effectiveOrderedTypeAt } from '../editor/extensions/orderedList'; import { isInHeaderCell } from '../editor/extensions/tableHeaderRow'; + import { HEADING_FONT } from '../export/odt'; import { t } from '../i18n/i18n.svelte'; import { withShortcut } from '../i18n/shortcut'; @@ -22,23 +23,27 @@ key: string; level?: 1 | 2 | 3 | 4 | 5; fontSize?: string; // block font size (paragraph mark + inherited by its runs) + fontFamily?: string; align?: 'center'; bold?: boolean; italic?: boolean; indent?: number; // cm + spaceAfter?: number; // pt preview: string; // menu-entry font size }; + // LibreOffice's values: Title/Subtitle sans off the Heading style, Quotations + // indented with space below (LO also indents 1cm on the right — we have no such attr). const PARAGRAPH_STYLES: ParagraphStyle[] = [ - { key: 'default', preview: '0.85rem' }, - { key: 'title', fontSize: '28pt', align: 'center', bold: true, preview: '1.35rem' }, - { key: 'subtitle', fontSize: '18pt', align: 'center', italic: true, preview: '1.05rem' }, - { key: 'h1', level: 1, preview: '1.25rem' }, - { key: 'h2', level: 2, preview: '1.1rem' }, - { key: 'h3', level: 3, preview: '0.95rem' }, - { key: 'h4', level: 4, preview: '0.9rem' }, - { key: 'h5', level: 5, preview: '0.85rem' }, - { key: 'quote', italic: true, indent: 1.25, preview: '0.85rem' }, + { key: 'default', preview: '0.85rem' }, + { key: 'title', fontSize: '28pt', fontFamily: HEADING_FONT, align: 'center', bold: true, preview: '1.35rem' }, + { key: 'subtitle', fontSize: '18pt', fontFamily: HEADING_FONT, align: 'center', preview: '1.05rem' }, + { key: 'h1', level: 1, preview: '1.25rem' }, + { key: 'h2', level: 2, preview: '1.1rem' }, + { key: 'h3', level: 3, preview: '0.95rem' }, + { key: 'h4', level: 4, preview: '0.9rem' }, + { key: 'h5', level: 5, preview: '0.85rem' }, + { key: 'quote', indent: 1, spaceAfter: 14, preview: '0.85rem' }, ]; function styleLabel(key: string): string { @@ -62,7 +67,7 @@ const { fontSize, indent } = editor.getAttributes('paragraph'); const sized = PARAGRAPH_STYLES.find((s) => s.fontSize && s.fontSize === fontSize); if (sized) return sized.key; - if ((indent ?? 0) >= 1.25 && editor.isActive('italic')) return 'quote'; + if ((indent ?? 0) >= 1) return 'quote'; return 'default'; }); @@ -79,11 +84,12 @@ const type = s.level ? 'heading' : 'paragraph'; const chain = editor.chain().focus().setTextSelection({ from: start, to: end }) // A style replaces conflicting direct formatting, as in Word/LibreOffice. - .unsetBold().unsetItalic().unsetFontSize().unsetFontWeight().removeEmptyTextStyle() + .unsetBold().unsetItalic().unsetFontSize().unsetFontWeight().unsetFontFamily().removeEmptyTextStyle() .setNode(type, s.level ? { level: s.level } : {}) .setBlockFontSize(s.fontSize ?? null) .setTextAlign(s.align ?? 'left') - .updateAttributes(type, { indent: s.indent ?? 0 }); + .updateAttributes(type, { indent: s.indent ?? 0, spaceAfter: s.spaceAfter ?? null }); + if (s.fontFamily) chain.setFontFamily(s.fontFamily); if (s.bold) chain.setBold(); if (s.italic) chain.setItalic(); // On an empty block the marks are stored marks — restoring the selection would drop them. diff --git a/src/lib/components/ToolbarExpanded.svelte b/src/lib/components/ToolbarExpanded.svelte index 1e00305..d61e59d 100644 --- a/src/lib/components/ToolbarExpanded.svelte +++ b/src/lib/components/ToolbarExpanded.svelte @@ -386,7 +386,9 @@ function onSizeInputKeydown(e: KeyboardEvent) { if (e.key === 'Enter') { e.preventDefault(); - const num = parseInt(sizeInputValue, 10); + // Imported documents carry fractional sizes (producer rounding, relative style + // sizes), so keep one decimal instead of snapping the shown value to a whole point. + const num = Math.round(parseFloat(sizeInputValue.replace(',', '.')) * 10) / 10; if (!isNaN(num) && num >= 1 && num <= 400) applyFontSize(num); sizeInputFocused = false; (e.target as HTMLInputElement).blur(); diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 004296c..27c46e4 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -22,7 +22,7 @@ import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow'; import { parseBorderAttr, type BorderSide } from '../editor/extensions/tableCellBorders'; import { effectiveOrderedDefAt, formatOrdinal, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes'; import { defaultBulletChar } from '../utils/bulletListTypes'; -import { normalizeColor, HEADING_STYLE_OVERRIDES, MAX_HEADING_LEVEL, mergeJoinedParagraphsJson, type HfExport } from './odt'; +import { normalizeColor, HEADING_STYLE_OVERRIDES, HEADING_FONT, MAX_HEADING_LEVEL, mergeJoinedParagraphsJson, type HfExport } from './odt'; import { findFormat, renderFormat, docxPicture, localeTag, DEFAULT_DATE_FORMAT, DEFAULT_TIME_FORMAT } from '../utils/dateTime'; // BCP-47 tag for rendering a fixed field's cached text; set at buildDocx start from @@ -828,7 +828,7 @@ function bodyGroups(content: TiptapNode[], num: Numbering, contentWidthCm: numbe // ---- document styles ------------------------------------------------------- function headingStyle(o: { fontSize: string; marginTop: string; marginBottom: string }) { return { - run: { bold: true, color: '000000', font: DOC_FONT, size: Math.round(parseFloat(o.fontSize) * 2) }, + run: { bold: true, color: '000000', font: HEADING_FONT, size: Math.round(parseFloat(o.fontSize) * 2) }, paragraph: { spacing: { before: cmToTwip(parseFloat(o.marginTop)), after: cmToTwip(parseFloat(o.marginBottom)), line: 240, lineRule: LineRuleType.AUTO }, keepNext: true }, }; } diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index c766480..c5248f7 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -121,17 +121,23 @@ const EXT_BY_MIME: Record = { const CELL_LIST_BULLET_STYLE = 'TblListBullet'; const CELL_LIST_NUMBER_STYLE = 'TblListNumber'; -// Heading sizes/margins shown in the editor (editor.css); odf-kit's larger Heading_20_N -// defaults are rewritten to these on export. Margins are the em values (top 1.5em, -// bottom 0.5em) in cm; import/odt.ts uses them as defaults to suppress on re-import. +// LibreOffice's heading defaults, shown in the editor (editor.css) and written over +// odf-kit's larger Heading_20_N styles on export: its sizes plus the Heading style's +// margins, which every level inherits. import/odt.ts uses them as the defaults to +// suppress on re-import. export const HEADING_STYLE_OVERRIDES: { name: string; fontSize: string; marginTop: string; marginBottom: string }[] = [ - { name: 'Heading_20_1', fontSize: '20pt', marginTop: '1.058cm', marginBottom: '0.353cm' }, - { name: 'Heading_20_2', fontSize: '16pt', marginTop: '0.847cm', marginBottom: '0.282cm' }, - { name: 'Heading_20_3', fontSize: '14pt', marginTop: '0.741cm', marginBottom: '0.247cm' }, - { name: 'Heading_20_4', fontSize: '12pt', marginTop: '0.635cm', marginBottom: '0.212cm' }, - { name: 'Heading_20_5', fontSize: '11pt', marginTop: '0.582cm', marginBottom: '0.194cm' }, + { name: 'Heading_20_1', fontSize: '18pt', marginTop: '0.423cm', marginBottom: '0.212cm' }, + { name: 'Heading_20_2', fontSize: '16pt', marginTop: '0.423cm', marginBottom: '0.212cm' }, + { name: 'Heading_20_3', fontSize: '14pt', marginTop: '0.423cm', marginBottom: '0.212cm' }, + { name: 'Heading_20_4', fontSize: '13pt', marginTop: '0.423cm', marginBottom: '0.212cm' }, + { name: 'Heading_20_5', fontSize: '12pt', marginTop: '0.423cm', marginBottom: '0.212cm' }, ]; +// Headings are sans (LibreOffice's Heading style). On screen the bundled 'Arial' +// @font-face maps to Liberation Sans, so the declared name is metric-identical — +// the same trick as EXPORT_FONT for the serif body font. +export const HEADING_FONT = 'Arial'; + // Highest heading level the editor offers (extensions.ts, both importers, TOC). export const MAX_HEADING_LEVEL = HEADING_STYLE_OVERRIDES.length; export const HEADING_LEVELS = HEADING_STYLE_OVERRIDES.map((_, i) => i + 1); @@ -1523,6 +1529,22 @@ function rewriteStylesXml(odtBytes: Uint8Array, lang: { language: string; countr `$1fo:margin-bottom="0cm"`, ); + // odf-kit's Heading parent carries only weight, so its font would be inherited from + // Standard (= the serif body font). Declare the sans heading font on it; every + // Heading_20_N inherits it, matching the editor's --font-heading. + // ODF requires a font-face declaration for every referenced font name. + styles = styles.replace( + '', + ``, + ); + + styles = styles.replace(//, (block) => + block.replace( + '… block so the // font-size/margin replacements never bleed across heading levels. for (const { name, fontSize, marginTop, marginBottom } of HEADING_STYLE_OVERRIDES) { diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index e9241d8..cc7ce80 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -49,8 +49,12 @@ const round2 = (v: number) => Math.round(v * 100) / 100; const PB_MARKER = '__docxPageBreak__'; const BODY_FONT_SIZE_PT = 12; -const HEADING_SIZES = HEADING_STYLE_OVERRIDES.map((h) => lengthToPt(h.fontSize)!); // [20,16,14] +// Rounded to half points: that is all Word can store, so it is what our own export +// writes and what an imported heading must be compared against. +const HEADING_SIZES = HEADING_STYLE_OVERRIDES.map((h) => Math.round(lengthToPt(h.fontSize)! * 2) / 2); const DEFAULT_FONTS = new Set(['times new roman', 'liberation serif']); +// Headings render sans (HEADING_FONT); Word writes Arial, LibreOffice Liberation Sans. +const DEFAULT_HEADING_FONTS = new Set(['arial', 'liberation sans']); const LIST_LEFT_STEP_CM = 1.27; // matches export/docx.ts const LIST_INDENT_EPS_CM = 0.05; const LINK_BLUE = '#0563C1'; // the visual the exporter paints on hyperlink runs @@ -493,8 +497,8 @@ function convertParagraph(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault: const jcVal = directJc ? wVal(directJc) : ctx.styles.paragraphAlign(pStyle ? wVal(pStyle) : null); // Spacing: a paragraph style's own w:spacing (e.g. "No Spacing" after=0) is honored so // it isn't lost to the editor default; direct w:pPr wins per attribute (in blockAttrs). - // Headings keep their em-based editor defaults (direct-only), so skip style resolution. - const styleSpacing = level == null ? ctx.styles.paragraphSpacing(pStyle ? wVal(pStyle) : null) : {}; + // Headings included — a heading style's spacing is the file's, not ours to override. + const styleSpacing = ctx.styles.paragraphSpacing(pStyle ? wVal(pStyle) : null); const attrs = blockAttrs(ppr, kind, level, jcVal, styleSpacing); const baseRun = ctx.styles.paragraphRun(pStyle ? wVal(pStyle) : null); const content = convertInline(el, ctx, baseRun, level, false, boldByDefault); @@ -645,12 +649,11 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, headingLevel: nu const rPr = fc(r, 'rPr'); const rStyle = fc(rPr, 'rStyle'); const props = mergeRunProps(mergeRunProps(baseRun, ctx.styles.styleOwn(rStyle ? wVal(rStyle) : null)), parseRunProps(rPr)); - // No font resolved anywhere: Word's implicit body default is the theme's minor font, so - // fall back to the document's own theme (not the editor default). Headings keep the editor - // heading default, so only body text falls back. + // No font resolved anywhere: fall back to the document's own theme (not the editor + // default) — Word's implicit default is the minor font for body text, the major one + // for headings. if (!props.font) { - const theme = props.fontTheme ?? (headingLevel == null ? 'minor' : null); - if (theme) props.font = ctx.styles.themeFont(theme); + props.font = ctx.styles.themeFont(props.fontTheme ?? (headingLevel == null ? 'minor' : 'major')); } const marks = marksFor(props, headingLevel, boldByDefault, !!linkHref); if (linkHref) marks.push({ type: 'link', attrs: { href: linkHref } }); @@ -829,7 +832,8 @@ function marksFor(props: RunProps, headingLevel: number | null, boldByDefault: b const defSize = headingLevel != null ? HEADING_SIZES[headingLevel - 1] : BODY_FONT_SIZE_PT; if (sizePt != null && Math.abs(sizePt - defSize) > 0.05) textStyle.fontSize = `${Math.round(sizePt * 10) / 10}pt`; - if (props.font && !DEFAULT_FONTS.has(props.font.toLowerCase())) textStyle.fontFamily = props.font; + const defaultFonts = headingLevel != null ? DEFAULT_HEADING_FONTS : DEFAULT_FONTS; + if (props.font && !defaultFonts.has(props.font.toLowerCase())) textStyle.fontFamily = props.font; if (Object.keys(textStyle).length) marks.push({ type: 'textStyle', attrs: textStyle }); return marks; diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 9e7973d..56edd8d 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -302,8 +302,10 @@ const HEADING_DEFAULTS = HEADING_STYLE_OVERRIDES.map(h => ({ })); // The editor's on-screen default (Liberation Serif) and what the export declares -// in its place (Times New Roman) — both mean "default font", so no mark. +// in its place (Times New Roman) — both mean "default font", so no mark. Headings +// render sans (HEADING_FONT), so their default pair is the metric-identical one. const DEFAULT_FONTS = new Set(['times new roman', 'liberation serif']); +const DEFAULT_HEADING_FONTS = new Set(['arial', 'liberation sans']); // ODF line spacing multiplies the font's natural line height; see lineHeight.ts. const LINE_HEIGHT_RATIO = 1.15; @@ -998,7 +1000,8 @@ function marksFor(props: PropMap, resolver: StyleResolver, headingLevel: number if (sizePt != null && Math.abs(sizePt - defSize) > 0.05) textStyle.fontSize = formatPt(sizePt); const family = resolver.fontFamilyOf(props); - if (family && !DEFAULT_FONTS.has(family.toLowerCase())) textStyle.fontFamily = family; + const defaultFonts = headingLevel != null ? DEFAULT_HEADING_FONTS : DEFAULT_FONTS; + if (family && !defaultFonts.has(family.toLowerCase())) textStyle.fontFamily = family; if (Object.keys(textStyle).length) marks.push({ type: 'textStyle', attrs: textStyle }); return marks; diff --git a/src/lib/import/styleResolver.ts b/src/lib/import/styleResolver.ts index dc333d9..3a70cc2 100644 --- a/src/lib/import/styleResolver.ts +++ b/src/lib/import/styleResolver.ts @@ -78,6 +78,22 @@ export function layerTextProps(base: PropMap, over: PropMap): PropMap { return Object.assign(out, over); } +// The size a percentage font-size is relative to when nothing is inherited yet +// (ODF's implied default, matching the editor's body size). +const BASE_FONT_SIZE_PT = 12; + +// LibreOffice defines its heading styles relatively (`fo:font-size="130%"`), so a +// percentage must be resolved against the inherited size while walking the parent +// chain — a raw "130%" would be dropped and the file's size lost. +function resolvePercentSize(text: PropMap, inherited: string | undefined): void { + const size = text['fo:font-size']; + if (!size || !size.trim().endsWith('%')) return; + const pct = parseFloat(size); + if (!Number.isFinite(pct)) return; + const base = lengthToPt(inherited) ?? BASE_FONT_SIZE_PT; + text['fo:font-size'] = `${Math.round(base * pct) / 100}pt`; +} + // ---- resolver ---------------------------------------------------------------- function collectProps(el: Element, propsLocalName: string, into: PropMap): void { @@ -206,7 +222,9 @@ export class StyleResolver { const result = { text: {} as PropMap, para: {} as PropMap, misc: {} as PropMap }; // Apply root-first so nearer definitions overwrite. for (const entry of chain.reverse()) { + const inherited = result.text['fo:font-size']; result.text = layerTextProps(result.text, entry.text); + resolvePercentSize(result.text, inherited); Object.assign(result.para, entry.para); Object.assign(result.misc, entry.misc); } diff --git a/src/styles/editor.css b/src/styles/editor.css index 4499a5a..056452c 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -384,15 +384,19 @@ .paper .tiptap h3, .paper .tiptap h4, .paper .tiptap h5 { + /* LibreOffice's Heading style: sans, bold, one margin pair for every level. + Keep the sizes below in sync with HEADING_STYLE_OVERRIDES + (tests/unit/font-size-display.test.ts checks it). */ + font-family: var(--font-heading); font-weight: 700; line-height: 1.15; - margin-top: 1.5em; - margin-bottom: 0.5em; + margin-top: 0.423cm; + margin-bottom: 0.212cm; } .paper .tiptap h1 { - font-size: 20pt; + font-size: 18pt; } .paper .tiptap h2 { @@ -404,11 +408,11 @@ } .paper .tiptap h4 { - font-size: 12pt; + font-size: 13pt; } .paper .tiptap h5 { - font-size: 11pt; + font-size: 12pt; } .paper .tiptap ul, diff --git a/src/styles/global.css b/src/styles/global.css index 8d77db5..c9691f5 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -215,6 +215,9 @@ --shadow: 0 1px 3px rgba(0, 0, 0, 0.08); --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; --font-serif: 'Liberation Serif', 'Times New Roman', Georgia, serif; + /* Headings are sans, like LibreOffice's Heading style ('Arial' is the bundled + Liberation Sans; see HEADING_FONT in export/odt.ts). */ + --font-heading: 'Arial', 'Liberation Sans', sans-serif; /* Brand accents: the primary slate + the logo's warm coral; the gradient is the app's signature (island hairline). */ --accent-warm: #d96b4a; diff --git a/tests/docx-roundtrip.test.ts b/tests/docx-roundtrip.test.ts index 88b553a..fb9a52a 100644 --- a/tests/docx-roundtrip.test.ts +++ b/tests/docx-roundtrip.test.ts @@ -417,7 +417,7 @@ describe('DOCX import of a foreign Word document', () => { const stylesXml = ` - + `; const numberingXml = ` @@ -448,7 +448,12 @@ describe('DOCX import of a foreign Word document', () => { expect(h.attrs.level).toBe(1); const t = walk(h, 'text')[0]; expect(hasMark(t, 'bold')).toBe(false); // heading bold is presentational - expect(markAttrs(t, 'textStyle')?.fontFamily).toBe('Arial'); // resolved from the style + expect(markAttrs(t, 'textStyle')?.fontSize).toBe('20pt'); // resolved from the style + // Arial is the heading default (LibreOffice's sans Heading style), so no font mark. + expect(markAttrs(t, 'textStyle')?.fontFamily).toBeUndefined(); + // A heading style's own spacing is the file's, not ours to replace (480/120 twips). + expect(h.attrs.spaceBefore).toBe(24); + expect(h.attrs.spaceAfter).toBe(6); }); it('maps Word symbol-font bullets and suppresses the default cycle', () => { @@ -566,8 +571,8 @@ 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. + // No w:rFonts anywhere (not even a theme ref): Word uses the theme minor font as the + // implicit body default and the major one for headings — the file's fonts, not ours. const documentXml = ` Body text Heading text @@ -585,10 +590,10 @@ describe('DOCX import falls back to the theme font when nothing references it', 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)', () => { + it('applies the theme major font to a heading with no font of its own', () => { const h = doc.content![1]; expect(h.type).toBe('heading'); - expect(markAttrs(walk(h, 'text')[0], 'textStyle')?.fontFamily).toBeUndefined(); + expect(markAttrs(walk(h, 'text')[0], 'textStyle')?.fontFamily).toBe('Calibri Light'); }); }); diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts index 334d515..e71c0b2 100644 --- a/tests/roundtrip.test.ts +++ b/tests/roundtrip.test.ts @@ -1292,10 +1292,10 @@ describe('Leg 11: heading levels 4 and 5', () => { 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 12pt', - /style:name="Heading_20_4"[\s\S]*?fo:font-size="12pt"/.test(styles)); - check('Heading_20_5 sized 11pt', - /style:name="Heading_20_5"[\s\S]*?fo:font-size="11pt"/.test(styles)); + 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]); diff --git a/tests/unit/heading-styles-import.test.ts b/tests/unit/heading-styles-import.test.ts new file mode 100644 index 0000000..4959e4f --- /dev/null +++ b/tests/unit/heading-styles-import.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest'; +import { zipSync, strToU8 } from 'fflate'; +import { importOdt } from '../../src/lib/import/odt'; + +// LibreOffice defines heading styles relative to the Heading style ("130%"), so a +// percentage must resolve against the inherited size — otherwise the file's own size is +// dropped and the editor default silently overrides it. +const NS = + '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"'; + +function odtWithHeading(base: string, size: string): Uint8Array { + const styles = ` + + + + + + + + + `; + const content = ` + Kapitel + `; + return zipSync({ 'content.xml': strToU8(content), 'styles.xml': strToU8(styles) }); +} + +const sizeOf = (base: string, size: string) => { + const marks = (importOdt(odtWithHeading(base, size)).content.content?.[0] as any).content[0].marks ?? []; + return marks.find((m: any) => m.type === 'textStyle')?.attrs?.fontSize; +}; + +describe('percentage font sizes in the ODF style chain', () => { + it('resolves a percentage that lands on the editor default (no explicit mark)', () => { + // 12pt × 150% = 18pt = HEADING_STYLE_OVERRIDES[0] → suppressed like any default. + expect(sizeOf('12pt', '150%')).toBeUndefined(); + }); + + it("keeps the file's own size when the percentage differs from the default", () => { + expect(sizeOf('16pt', '125%')).toBe('20pt'); + expect(sizeOf('12pt', '200%')).toBe('24pt'); + }); + + it('still reads absolute sizes', () => { + expect(sizeOf('14pt', '24pt')).toBe('24pt'); + }); +}); + +// A document written by LibreOffice must come in looking exactly as it does there: +// its heading styles equal the editor defaults, so nothing lands as direct formatting. +describe('LibreOffice heading styles import unchanged', () => { + const LO_SIZES = ['18pt', '16pt', '14pt', '13pt', '12pt']; + const headingStyles = LO_SIZES.map( + (size, i) => ` + + `, + ).join(''); + const styles = ` + + + + + ${headingStyles}`; + const content = ` + ${LO_SIZES.map((_, i) => `H${i + 1}`).join('')} + `; + const doc = importOdt(zipSync({ 'content.xml': strToU8(content), 'styles.xml': strToU8(styles) })).content; + + it('keeps levels 1-5 without any direct formatting', () => { + const blocks = (doc.content ?? []) as any[]; + expect(blocks.map((b) => b.type)).toEqual(Array(5).fill('heading')); + expect(blocks.map((b) => b.attrs.level)).toEqual([1, 2, 3, 4, 5]); + // No size mark (sizes match), no font mark (Liberation Sans is the heading default), + // no spacing attrs (margins match) — the file renders as the editor's own heading style. + expect(blocks.flatMap((b) => b.content[0].marks ?? [])).toEqual([]); + expect(blocks.flatMap((b) => [b.attrs.spaceBefore, b.attrs.spaceAfter].filter((v) => v != null))).toEqual([]); + }); +});