fix: align styles and headings to libreoffice

This commit is contained in:
Steffen Becker committed 2026-07-28 22:45:24 +02:00
1 parent f4973caade
commit d461ce09a6
12 files changed
+197 -49

No files matched your search

+18 -12
View File
@@ -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.
+3 -1
View File
@@ -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();
+2 -2
View File
@@ -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 },
};
}
+30 -8
View File
@@ -121,17 +121,23 @@ const EXT_BY_MIME: Record<string, string> = {
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(
'</office:font-face-decls>',
`<style:font-face style:name="${HEADING_FONT}" svg:font-family="${HEADING_FONT}" style:font-family-generic="swiss" style:font-pitch="variable"/></office:font-face-decls>`,
);
styles = styles.replace(/<style:style style:name="Heading"[\s\S]*?<\/style:style>/, (block) =>
block.replace(
'<style:text-properties',
`<style:text-properties style:font-name="${HEADING_FONT}" style:font-name-asian="${HEADING_FONT}" style:font-name-complex="${HEADING_FONT}"`,
),
);
// Scope each rewrite to its own <style:style …>…</style:style> block so the
// font-size/margin replacements never bleed across heading levels.
for (const { name, fontSize, marginTop, marginBottom } of HEADING_STYLE_OVERRIDES) {
+13 -9
View File
@@ -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;
+5 -2
View File
@@ -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;
+18
View File
@@ -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);
}
+9 -5
View File
@@ -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,
+3
View File
@@ -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;
+11 -6
View File
@@ -417,7 +417,7 @@ describe('DOCX import of a foreign Word document', () => {
const stylesXml = `<?xml version="1.0"?><w:styles ${W}>
<w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:ascii="Times New Roman"/><w:sz w:val="24"/></w:rPr></w:rPrDefault></w:docDefaults>
<w:style w:type="paragraph" w:styleId="Normal"><w:name w:val="Normal"/></w:style>
<w:style w:type="paragraph" w:styleId="Heading1"><w:basedOn w:val="Normal"/><w:rPr><w:b/><w:sz w:val="40"/><w:rFonts w:ascii="Arial"/></w:rPr></w:style>
<w:style w:type="paragraph" w:styleId="Heading1"><w:basedOn w:val="Normal"/><w:pPr><w:spacing w:before="480" w:after="120"/></w:pPr><w:rPr><w:b/><w:sz w:val="40"/><w:rFonts w:ascii="Arial"/></w:rPr></w:style>
</w:styles>`;
const numberingXml = `<?xml version="1.0"?><w:numbering ${W}>
<w:abstractNum w:abstractNumId="0"><w:lvl w:ilvl="0"><w:numFmt w:val="decimal"/><w:lvlText w:val="%1."/><w:pPr><w:ind w:left="720" w:hanging="360"/></w:pPr></w:lvl></w:abstractNum>
@@ -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 = `<?xml version="1.0"?><w:document ${W}><w:body>
<w:p><w:r><w:t>Body text</w:t></w:r></w:p>
<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>Heading text</w:t></w:r></w:p>
@@ -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');
});
});
+4 -4
View File
@@ -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]);
+81
View File
@@ -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 = `<?xml version="1.0"?><office:document-styles ${NS}><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="Heading" style:family="paragraph" style:parent-style-name="Standard">
<style:text-properties style:font-name="Liberation Sans" fo:font-size="${base}" fo:font-weight="bold"/>
</style:style>
<style:style style:name="Heading_20_1" style:family="paragraph" style:parent-style-name="Heading">
<style:text-properties fo:font-size="${size}"/>
</style:style>
</office:styles></office:document-styles>`;
const content = `<?xml version="1.0"?><office:document-content ${NS}><office:body><office:text>
<text:h text:style-name="Heading_20_1" text:outline-level="1">Kapitel</text:h>
</office:text></office:body></office:document-content>`;
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) => `<style:style style:name="Heading_20_${i + 1}" style:family="paragraph" style:parent-style-name="Heading">
<style:paragraph-properties fo:margin-top="0.423cm" fo:margin-bottom="0.212cm"/>
<style:text-properties fo:font-size="${size}"/></style:style>`,
).join('');
const styles = `<?xml version="1.0"?><office:document-styles ${NS}><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="Heading" style:family="paragraph" style:parent-style-name="Standard">
<style:text-properties style:font-name="Liberation Sans" fo:font-weight="bold"/>
</style:style>${headingStyles}</office:styles></office:document-styles>`;
const content = `<?xml version="1.0"?><office:document-content ${NS}><office:body><office:text>
${LO_SIZES.map((_, i) => `<text:h text:style-name="Heading_20_${i + 1}" text:outline-level="${i + 1}">H${i + 1}</text:h>`).join('')}
</office:text></office:body></office:document-content>`;
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([]);
});
});