fix: align styles and headings to libreoffice
This commit is contained in:
1 parent
f4973caade
commit
d461ce09a6
12 files changed
+197
-49
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user