feat: odf import/export of named paragraph styles

This commit is contained in:
Steffen Becker committed 2026-07-29 10:53:54 +02:00
1 parent ac9d6cdcff
commit d79287c10a
10 files changed
+575 -77

No files matched your search

+5 -2
View File
@@ -23,6 +23,7 @@
import { loadPageMargins, savePageMargins, DEFAULT_MARGINS, type PageMargins } from './lib/storage/pageMargins';
import { loadOrientation, saveOrientation, type Orientation } from './lib/storage/pageOrientation';
import { loadPageFormat, savePageFormat, type PageFormat } from './lib/storage/pageFormat';
import { setStyleSheet, styleSheet } from './lib/styles/sheet.svelte';
import { loadHfDoc, saveHfDoc, loadHfDistances, saveHfDistances, loadDifferentFirstPage, saveDifferentFirstPage, loadDifferentOddEven, saveDifferentOddEven, hfIsEmpty, DEFAULT_HF_DISTANCES, type HfDoc, type HfZone, type HfDistances } from './lib/storage/headerFooter';
import { loadDocName, saveDocName, stripOdtExtension, sanitizeNameForFile } from './lib/storage/documentName';
import { loadDocumentLanguage, saveDocumentLanguage, odfFromLanguage, type DocumentLanguage } from './lib/storage/documentLanguage';
@@ -443,6 +444,8 @@
// Adopt the document's spell-check language (the $effect switches the
// controller + loads its dictionary). null = file declared none; keep ours.
if (result.language) documentLanguage = result.language;
// Adopt the document's named paragraph styles (built-ins + the file's own).
setStyleSheet(result.styles);
// Adopt header/footer + first-page variants (null clears the zone); end any edit.
hfActive = null;
headerDoc = result.header;
@@ -513,7 +516,7 @@
exportMenuOpen = false;
const json = editor.getJSON() as Parameters<typeof buildOdt>[0];
try {
const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat);
const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet());
fileHandle = await saveOdt(bytes, suggestedFilename(json), fileHandle);
} catch (err) {
if ((err as DOMException)?.name === 'AbortError') return;
@@ -529,7 +532,7 @@
exportMenuOpen = false;
const json = editor.getJSON() as Parameters<typeof buildOdt>[0];
try {
const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat);
const bytes = await buildOdt(json, pageMargins, pageOrientation, hfOpts(), odfFromLanguage(documentLanguage), pageFormat, styleSheet());
fileHandle = await saveAsOdt(bytes, suggestedFilename(json));
} catch (err) {
if ((err as DOMException)?.name === 'AbortError') return;
+242 -29
View File
@@ -4,6 +4,7 @@ import { DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins';
import type { Orientation } from '../storage/pageOrientation';
import { pageDimsCm, type PageFormat } from '../storage/pageFormat';
import { HF_DISTANCE_CM, hfIsEmpty, type HfDoc } from '../storage/headerFooter';
import { builtinStyleSheet, DEFAULT_STYLE, type StyleSheet } from '../styles/styleSheet';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { BORDER_SIDES, parseBorderAttr } from '../editor/extensions/tableCellBorders';
import { TEXTBOX_PADDING_CM } from '../editor/extensions/textBox';
@@ -105,6 +106,11 @@ const DTF = '';
// applyHfPostProcess rewrites it to an as-char <draw:frame>. U+E00B.
const HFIMG = '';
// Sentinel wrapping the named paragraph style of a block whose style isn't the ODF
// default for its node type (STY<name>STY), emitted as plain run text so it rides the
// odf-kit path; applyParagraphStyles points the block at that style. U+E00D.
const STY = '';
const EXT_BY_MIME: Record<string, string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
@@ -142,8 +148,55 @@ export const HEADING_FONT = 'Arial';
export const MAX_HEADING_LEVEL = HEADING_STYLE_OVERRIDES.length;
export const HEADING_LEVELS = HEADING_STYLE_OVERRIDES.map((_, i) => i + 1);
// ODF encodes spaces in style names as _20_ ("Heading 1" → "Heading_20_1").
export function odfStyleName(name: string): string {
return name.replace(/ /g, '_20_');
}
// The style name a block carries: its own, else the node type's default. Mirrors
// blockStyleName in editor/extensions/paragraphStyle.ts (kept local: this module is
// framework-free).
function styleOf(node: TiptapNode): string {
const own = node.attrs?.styleName;
if (typeof own === 'string' && own) return own;
return isHeadingNode(node) ? `Heading ${(node.attrs?.level as number) ?? 1}` : DEFAULT_STYLE;
}
// Headings reach the handler renamed to CUST_H (injectCustomTypes).
function isHeadingNode(node: TiptapNode): boolean {
return node.type === 'heading' || node.type === CUST_H;
}
// Styles to define in the file: every built-in (Word/LibreOffice always define their
// standard styles) plus the user styles the document actually references, with their
// parent chains.
function usedStyleNames(doc: TiptapNode, sheet: StyleSheet): Set<string> {
const used = new Set<string>();
const addChain = (name: string) => {
let cur: string | null | undefined = name;
while (cur && sheet.paragraph[cur] && !used.has(cur)) {
used.add(cur);
cur = sheet.paragraph[cur].parent;
}
};
for (const style of Object.values(sheet.paragraph)) if (style.builtin) addChain(style.name);
const walk = (node: TiptapNode) => {
if (node.type === 'paragraph' || node.type === 'heading') addChain(styleOf(node));
node.content?.forEach(walk);
};
walk(doc);
return used;
}
// What odf-kit itself puts on the block, so only a differing style needs a sentinel.
function odfDefaultStyleOf(node: TiptapNode): string {
return isHeadingNode(node) ? `Heading ${(node.attrs?.level as number) ?? 1}` : DEFAULT_STYLE;
}
function hasCustomAttrs(attrs: TiptapNode['attrs']): boolean {
if (!attrs) return false;
// A named style is emitted via the STY sentinel, which needs the applyRuns path.
if (typeof attrs.styleName === 'string' && attrs.styleName) return true;
if (attrs.lineHeight) return true;
if (attrs.spaceBefore != null) return true;
if (attrs.spaceAfter != null) return true;
@@ -898,6 +951,181 @@ function cloneStyleWithParaProps(def: string, newName: string, props: string): s
return s.replace('</style:style>', `<style:paragraph-properties ${props}/></style:style>`);
}
// ---- named paragraph styles (styles/styleSheet.ts) --------------------------------
// Set or replace attributes on an element's opening tag.
function setTagAttrs(tag: string, attrs: Record<string, string>): string {
let out = tag;
for (const [name, value] of Object.entries(attrs)) {
const re = new RegExp(`\\s${name}="[^"]*"`);
out = re.test(out)
? out.replace(re, ` ${name}="${value}"`)
: out.replace(/(\/?>)$/, ` ${name}="${value}"$1`);
}
return out;
}
// Merge attributes into a style's <style:paragraph-properties>/<style:text-properties>,
// keeping whatever the producer already put there (language, keep-with-next, …).
function upsertProps(block: string, kind: 'paragraph' | 'text', attrs: Record<string, string>): string {
if (!Object.keys(attrs).length) return block;
const el = `style:${kind}-properties`;
const open = new RegExp(`<${el}\\b[^>]*>`);
const m = open.exec(block);
if (m) return block.replace(open, setTagAttrs(m[0], attrs));
const props = setTagAttrs(`<${el}/>`, attrs);
// paragraph-properties precedes text-properties in ODF.
if (kind === 'paragraph' && block.includes('<style:text-properties'))
return block.replace('<style:text-properties', `${props}<style:text-properties`);
if (/^<style:style\b[^>]*\/>$/.test(block.trim()))
return block.trim().replace(/\/>$/, `>${props}</style:style>`);
return block.replace('</style:style>', `${props}</style:style>`);
}
// A style's OWN properties as ODF attributes — the chain stays a chain in the file,
// so only what the style itself declares is written.
function ownStyleAttrs(style: { para: Record<string, unknown>; text: Record<string, unknown> }) {
const p = style.para as Record<string, string | number | undefined>;
const t = style.text as Record<string, string | number | boolean | undefined>;
const para: Record<string, string> = {};
if (p.textAlign) para['fo:text-align'] = String(p.textAlign);
if (p.lineHeight != null) para['fo:line-height'] = normalizeLineHeight(p.lineHeight as string | number);
if (p.spaceBefore != null) para['fo:margin-top'] = `${p.spaceBefore}pt`;
if (p.spaceAfter != null) para['fo:margin-bottom'] = `${p.spaceAfter}pt`;
if (p.indent != null) para['fo:margin-left'] = `${p.indent}cm`;
if (p.backgroundColor) para['fo:background-color'] = String(p.backgroundColor);
for (const [key, side] of [['borderTop', 'top'], ['borderRight', 'right'], ['borderBottom', 'bottom'], ['borderLeft', 'left']] as const) {
const v = p[key];
if (v && v !== 'none') para[`fo:border-${side}`] = String(v);
}
const text: Record<string, string> = {};
if (t.fontFamily) {
// Liberation Serif is the on-screen name; the file declares its metric twin.
const font = t.fontFamily === ODFKIT_DEFAULT_FONT ? EXPORT_FONT : String(t.fontFamily);
text['style:font-name'] = font;
text['style:font-name-asian'] = font;
text['style:font-name-complex'] = font;
}
if (t.fontSizePt != null) {
const size = `${t.fontSizePt}pt`;
text['fo:font-size'] = size;
text['style:font-size-asian'] = size;
text['style:font-size-complex'] = size;
}
if (t.bold != null) text['fo:font-weight'] = t.bold ? 'bold' : 'normal';
if (t.italic != null) text['fo:font-style'] = t.italic ? 'italic' : 'normal';
if (t.underline) text['style:text-underline-style'] = 'solid';
if (t.strike) text['style:text-line-through-style'] = 'solid';
if (t.color) text['fo:color'] = String(t.color);
return { para, text };
}
// Attributes the style model owns: dropped from a producer's block before the style's
// own values go in, so anything the style leaves open really comes from its parent.
const MANAGED_STYLE_ATTRS = [
'fo:margin-top', 'fo:margin-bottom', 'fo:margin-left', 'fo:text-align', 'fo:line-height',
'fo:background-color', 'fo:border-top', 'fo:border-right', 'fo:border-bottom', 'fo:border-left',
'fo:font-size', 'style:font-size-asian', 'style:font-size-complex',
'fo:font-weight', 'style:font-weight-asian', 'style:font-weight-complex',
'fo:font-style', 'style:font-style-asian', 'style:font-style-complex',
'style:text-underline-style', 'style:text-line-through-style', 'fo:color',
'style:font-name', 'style:font-name-asian', 'style:font-name-complex',
];
function stripManagedProps(block: string): string {
let out = block;
for (const attr of MANAGED_STYLE_ATTRS) out = out.replace(new RegExp(`\\s${attr}="[^"]*"`, 'g'), '');
return out;
}
// Write the document's named paragraph styles into styles.xml: merge into the blocks
// odf-kit already emits (Standard, Heading, Heading_20_N), append the rest. The parent
// chain is preserved, so LibreOffice/Word show them as real, inheriting styles.
function applyNamedStyles(styles: string, sheet: StyleSheet, used: Set<string>): string {
const added: string[] = [];
for (const style of Object.values(sheet.paragraph)) {
if (!used.has(style.name)) continue;
const odfName = odfStyleName(style.name);
const { para, text } = ownStyleAttrs(style);
const existing = findAutoStyle(styles, odfName);
if (existing) {
let block = stripManagedProps(existing);
if (style.parent) block = block.replace(/^<style:style\b[^>]*?(\/?)>/, (tag) =>
setTagAttrs(tag, { 'style:parent-style-name': odfStyleName(style.parent!) }));
block = upsertProps(upsertProps(block, 'paragraph', para), 'text', text);
styles = styles.replace(existing, block);
continue;
}
const attrs: Record<string, string> = {
'style:name': odfName, 'style:family': 'paragraph', 'style:display-name': style.name,
};
if (style.parent) attrs['style:parent-style-name'] = odfStyleName(style.parent);
if (style.next) attrs['style:next-style-name'] = odfStyleName(style.next);
let block = `${setTagAttrs('<style:style/>', attrs).replace('/>', '>')}</style:style>`;
block = upsertProps(upsertProps(block, 'paragraph', para), 'text', text);
added.push(block);
}
return added.length ? styles.replace('</office:styles>', `${added.join('')}</office:styles>`) : styles;
}
// Resolve STY sentinels: point each marked block at its named style — directly when it
// carries no direct formatting, else via a clone of its automatic style whose parent
// becomes the named one (so hard formatting keeps overriding the style, as in LO).
function applyParagraphStyles(odtBytes: Uint8Array): Uint8Array {
const files = unzipSync(odtBytes);
const contentBytes = files['content.xml'];
if (!contentBytes) return odtBytes;
let content = strFromU8(contentBytes);
if (!content.includes(STY)) return odtBytes;
const minted: string[] = [];
const nameByKey = new Map<string, string>();
let counter = 0;
const styleFor = (source: string, styleName: string): string => {
const odfName = odfStyleName(styleName);
const auto = source ? findAutoStyle(content, source) : null;
if (!auto) return odfName; // no direct formatting: reference the style itself
const key = `${source}|${odfName}`;
const existing = nameByKey.get(key);
if (existing) return existing;
const name = `PS${++counter}`;
nameByKey.set(key, name);
minted.push(
auto
.replace(/style:name="[^"]*"/, `style:name="${name}"`)
.replace(/^<style:style\b[^>]*?(\/?)>/, (tag) =>
setTagAttrs(tag, { 'style:parent-style-name': odfName })),
);
return name;
};
const styRe = new RegExp(`${STY}([^${STY}]*)${STY}`);
content = content.replace(
new RegExp(`<text:(p|h)\\b([^>]*)>([\\s\\S]*?)</text:\\1>`, 'g'),
(m, tag: string, attrs: string, inner: string) => {
const sm = styRe.exec(inner);
if (!sm) return m;
const cleaned = inner.replace(styRe, '');
const srcM = /text:style-name="([^"]*)"/.exec(attrs);
const name = styleFor(srcM ? srcM[1] : '', sm[1]);
const newAttrs = srcM
? attrs.replace(/text:style-name="[^"]*"/, `text:style-name="${name}"`)
: ` text:style-name="${name}"${attrs}`;
return `<text:${tag}${newAttrs}>${cleaned}</text:${tag}>`;
},
);
content = content.replace(new RegExp(`${STY}[^${STY}]*${STY}`, 'g'), '');
if (minted.length) {
content = content.replace('</office:automatic-styles>', `${minted.join('')}</office:automatic-styles>`);
}
files['content.xml'] = strToU8(content);
return rezipOdt(files);
}
// Resolve PBX sentinels: per (source style, box spec) mint a paragraph style cloning the
// source plus fo:background-color/fo:border-*, reassign the block, and strip the sentinel.
// Mirrors applyEmptyLineFontSizes; runs after it so an empty line's FSZ is already resolved.
@@ -1487,7 +1715,7 @@ function rezipOdt(files: Record<string, Uint8Array>): Uint8Array {
// Rewrite styles.xml to match the editor's preview: default font Liberation Serif →
// Times New Roman (metric-identical; see EXPORT_FONT), and Heading_20_1/2/3 sizes &
// margins → the editor's values (odf-kit's defaults are larger; HEADING_STYLE_OVERRIDES).
function rewriteStylesXml(odtBytes: Uint8Array, lang: { language: string; country: string } | null, pageFormat: PageFormat, orientation: Orientation): Uint8Array {
function rewriteStylesXml(odtBytes: Uint8Array, lang: { language: string; country: string } | null, pageFormat: PageFormat, orientation: Orientation, sheet: StyleSheet, used: Set<string>): Uint8Array {
const files = unzipSync(odtBytes);
const stylesBytes = files['styles.xml'];
if (!stylesBytes) return odtBytes;
@@ -1529,35 +1757,15 @@ 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) {
const re = new RegExp(`<style:style style:name="${name}"[\\s\\S]*?</style:style>`);
styles = styles.replace(re, (block) =>
block
.replace(/fo:font-size="[^"]*"/g, `fo:font-size="${fontSize}"`)
.replace(/style:font-size-asian="[^"]*"/g, `style:font-size-asian="${fontSize}"`)
.replace(/style:font-size-complex="[^"]*"/g, `style:font-size-complex="${fontSize}"`)
.replace(/fo:margin-top="[^"]*"/g, `fo:margin-top="${marginTop}"`)
.replace(/fo:margin-bottom="[^"]*"/g, `fo:margin-bottom="${marginBottom}"`),
);
}
// The document's named paragraph styles (Standard, Heading, Heading_20_N, Title, …)
// with their parent chain: merged into odf-kit's own blocks, appended when new.
styles = applyNamedStyles(styles, sheet, used);
files['styles.xml'] = strToU8(styles);
return rezipOdt(files);
@@ -2237,7 +2445,7 @@ export type HfExport = {
};
// The full document → .odt pipeline, DOM-free; returns the .odt bytes.
export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait', hf?: HfExport, language?: { language: string; country: string } | null, pageFormat: PageFormat = 'A4'): Promise<Uint8Array> {
export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAULT_MARGINS, orientation: Orientation = 'portrait', hf?: HfExport, language?: { language: string; country: string } | null, pageFormat: PageFormat = 'A4', styles: StyleSheet = builtinStyleSheet()): Promise<Uint8Array> {
// Collect embedded images and swap them for IMG sentinels before serialization;
// applyImages resolves the sentinels and writes the Pictures/ + manifest entries.
// Text boxes and columns sections are hoisted after replacePageBreaks (so PGB never
@@ -2354,14 +2562,18 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
// which runs earlier, still matches it right after the opening tag.
const spec = paraBoxSpec(node.attrs);
const pbx = spec ? `${PBX}${spec}${PBX}` : '';
const withPbx = (p: ParagraphBuilder) => { if (pbx) p.addText(pbx); applyRuns(p, content); };
// Named style, unless it is the one odf-kit puts on this node type anyway.
const styleName = styleOf(node);
const sty = styleName !== odfDefaultStyleOf(node) ? `${STY}${styleName}${STY}` : '';
const marks = sty + pbx;
const withPbx = (p: ParagraphBuilder) => { if (marks) p.addText(marks); applyRuns(p, content); };
if (node.type === CUST_P) {
if (content.length === 0) doc.addParagraph(emptyFs + pbx, opts);
if (content.length === 0) doc.addParagraph(emptyFs + marks, opts);
else doc.addParagraph(withPbx, opts);
} else if (node.type === CUST_H) {
const level = (node.attrs?.level as number) ?? 1;
if (content.length === 0) doc.addHeading(emptyFs + pbx, level, opts);
if (content.length === 0) doc.addHeading(emptyFs + marks, level, opts);
else doc.addHeading(withPbx, level, opts);
}
},
@@ -2416,7 +2628,8 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
const withPageBreaks = applyPageBreaks(withToc);
const withEmptyFontSizes = applyEmptyLineFontSizes(withPageBreaks);
const withParaBoxes = applyParagraphBoxes(withEmptyFontSizes);
const withStyles = rewriteStylesXml(withParaBoxes, language ?? null, pageFormat, orientation);
const withNamedStyles = applyParagraphStyles(withParaBoxes);
const withStyles = rewriteStylesXml(withNamedStyles, language ?? null, pageFormat, orientation, styles, usedStyleNames(docJson, styles));
return applyHfPostProcess(withStyles, margins, headerPara, footerPara, headerDist, footerDist, firstHeaderPara, firstFooterPara, hf?.pageCount ?? 1, hfImages, evenHeaderPara, evenFooterPara);
}
+4
View File
@@ -2,6 +2,7 @@ import { unzipSync, strFromU8 } from 'fflate';
import { DocxStyles, parseRunProps, mergeRunProps, readNumPr, wVal, W, R, WP, A, WPS, MC, VML, PKG_REL, type RunProps, type ParaSpacing } from './docxStyles';
import { lengthToPt } from './styleResolver';
import { HEADING_STYLE_OVERRIDES, MAX_HEADING_LEVEL, normalizeColor } from '../export/odt';
import { builtinStyleSheet } from '../styles/styleSheet';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { orderedTypeFromFormat, orderedTypeAttrAt, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
import { bulletCharAttr, bulletCharFromDocx } from '../utils/bulletListTypes';
@@ -157,6 +158,9 @@ export function importDocx(bytes: Uint8Array, convertedImages: ConvertedImages =
return {
content: { type: 'doc', content: blocks },
// DOCX styles still import as direct formatting (the ODF path leads); the document
// keeps the editor's built-in registry.
styles: builtinStyleSheet(),
margins: sect.margins,
orientation: sect.orientation,
format: sect.format,
+215 -30
View File
@@ -1,6 +1,7 @@
import { unzipSync, strFromU8 } from 'fflate';
import { StyleResolver, NS, lengthToPt, lengthToCm, layerTextProps, type PropMap } from './styleResolver';
import { HEADING_STYLE_OVERRIDES, MAX_HEADING_LEVEL, normalizeColor } from '../export/odt';
import { builtinStyleSheet, DEFAULT_STYLE, type ParaProps, type Style, type StyleSheet, type TextProps } from '../styles/styleSheet';
import { HEADER_SHADE } from '../editor/extensions/tableHeaderRow';
import { orderedTypeFromFormat, orderedTypeAttrAt, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
import { bulletCharAttr, bulletCharFromOdf } from '../utils/bulletListTypes';
@@ -52,6 +53,9 @@ export interface OdtImportResult {
language: DocumentLanguage | null;
// Fonts embedded in the package, to register via FontFace so they render.
fonts: EmbeddedFont[];
// The document's named paragraph styles: the file's own (only those it uses, plus
// their parent chains) merged over the editor's built-ins.
styles: StyleSheet;
warnings: string[];
}
@@ -65,6 +69,10 @@ type BlockKind = 'body' | 'list' | 'cell';
// they are block nodes, so convertBlocks flushes them after the anchor paragraph.
type Ctx = {
resolver: StyleResolver;
// ODF style name → display name, and the names blocks actually reference: the
// document's style registry is built from these (only used styles are kept).
styleNames: Map<string, string>;
usedStyles: Set<string>;
warnings: Set<string>;
files: Record<string, Uint8Array>;
imageCache: Map<string, string>;
@@ -335,7 +343,9 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages =
const body = contentDoc.getElementsByTagNameNS(NS.office, 'text')[0];
if (!body) throw new Error('Not a text document (no office:text body).');
const ctx: Ctx = { resolver, warnings, files, imageCache: new Map(), convertedImages, pendingBlocks: [] };
const styleNames = new Map<string, string>();
for (const [name, def] of resolver.namedParagraphStyles()) styleNames.set(name, displayStyleName(name, def.display));
const ctx: Ctx = { resolver, styleNames, usedStyles: new Set(), warnings, files, imageCache: new Map(), convertedImages, pendingBlocks: [] };
let blocks = convertBlocks(Array.from(body.children), ctx, 'body');
if (blocks.length === 0) blocks.push({ type: 'paragraph' });
@@ -401,6 +411,7 @@ export function importOdt(bytes: Uint8Array, convertedImages: ConvertedImages =
footerDistanceCm: hasFooter ? edge?.bottom ?? null : null,
language,
fonts,
styles: collectStyleSheet(resolver, ctx),
warnings: [...warnings],
};
}
@@ -421,7 +432,7 @@ function convertHfZone(zoneEl: Element, ctx: Ctx): HfDoc {
if (textAlign === null) textAlign = ''; // only the first paragraph decides
}
boxMaps.push(paraBoxAttrs(ctx.resolver.paraProps(styleName)));
inline.push(...convertInline(p, ctx, ctx.resolver.paraTextProps(styleName), null, true));
inline.push(...convertInline(p, ctx, ctx.resolver.paraTextProps(styleName), blockDefaults(ctx.resolver, null, null, false), true));
};
for (const child of Array.from(zoneEl.children)) {
@@ -617,6 +628,170 @@ function tocEntryTextAndPage(p: Element): { text: string; page: string } {
return { text: before.trim(), page: after.trim() };
}
// What a block's named style already gives it — the yardstick for "is this direct
// formatting?". Without a style in the file, the editor's own defaults stand in.
type BlockDefaults = {
fontSizePt: number;
marginTopPt: number;
marginBottomPt: number;
indentPt: number;
boldByDefault: boolean;
fonts: Set<string>;
color: string;
// Marks the style already provides. A run that opts *out* of one can't be expressed
// (only bold has fontWeight:'normal'), so it keeps the style's rendering.
// ponytail: per-mark "off" overrides need mark attrs like fontWeight's; add if files need it.
italic: boolean;
underline: boolean;
strike: boolean;
};
// Metric twins: the on-screen font and the name we declare in files mean the same thing.
const FONT_TWINS: Record<string, string[]> = {
'times new roman': ['liberation serif'],
'liberation serif': ['times new roman'],
arial: ['liberation sans'],
'liberation sans': ['arial'],
};
function blockDefaults(resolver: StyleResolver, named: string | null, headingLevel: number | null, boldByDefault: boolean): BlockDefaults {
const hdef = headingLevel != null ? HEADING_DEFAULTS[headingLevel - 1] : null;
const fallback: BlockDefaults = {
fontSizePt: hdef ? hdef.fontSizePt : BODY_FONT_SIZE_PT,
marginTopPt: hdef ? hdef.marginTopPt : 0,
marginBottomPt: hdef ? hdef.marginBottomPt : 0,
indentPt: 0,
boldByDefault: headingLevel != null || boldByDefault,
fonts: new Set(headingLevel != null ? DEFAULT_HEADING_FONTS : DEFAULT_FONTS),
color: '#000000',
italic: false,
underline: false,
strike: false,
};
if (!named) return fallback;
const para = resolver.paraProps(named);
const text = resolver.paraTextProps(named);
const family = resolver.fontFamilyOf(text)?.toLowerCase();
const fonts = new Set(fallback.fonts);
if (family) {
fonts.add(family);
for (const twin of FONT_TWINS[family] ?? []) fonts.add(twin);
}
const weight = text['fo:font-weight'];
const fontStyle = text['fo:font-style'];
return {
fontSizePt: lengthToPt(text['fo:font-size']) ?? fallback.fontSizePt,
marginTopPt: lengthToPt(para['fo:margin-top']) ?? 0,
marginBottomPt: lengthToPt(para['fo:margin-bottom']) ?? 0,
indentPt: lengthToPt(para['fo:margin-left']) ?? 0,
boldByDefault: weight ? weight === 'bold' || parseInt(weight, 10) >= 600 : fallback.boldByDefault,
fonts,
color: (text['fo:color'] && normalizeColor(text['fo:color'])) || fallback.color,
italic: fontStyle === 'italic' || fontStyle === 'oblique',
underline: !!text['style:text-underline-style'] && text['style:text-underline-style'] !== 'none',
strike: !!text['style:text-line-through-style'] && text['style:text-line-through-style'] !== 'none',
};
}
// ODF encodes spaces as _20_; a style:display-name (when present) is authoritative.
function displayStyleName(odfName: string, display?: string): string {
return display || odfName.replace(/_20_/g, ' ');
}
function paraPropsFromOdf(props: PropMap): ParaProps {
const out: ParaProps = {};
const ta = props['fo:text-align'];
if (ta === 'center' || ta === 'justify') out.textAlign = ta;
else if (ta === 'right' || ta === 'end') out.textAlign = 'right';
else if (ta === 'left' || ta === 'start') out.textAlign = 'left';
const mt = lengthToPt(props['fo:margin-top']);
const mb = lengthToPt(props['fo:margin-bottom']);
const ml = lengthToCm(props['fo:margin-left']);
if (mt != null) out.spaceBefore = snapPt(mt);
if (mb != null) out.spaceAfter = snapPt(mb);
if (ml != null) out.indent = Math.round(ml * 100) / 100;
const lh = props['fo:line-height'];
if (lh && lh.endsWith('%')) {
const mult = parseFloat(lh) / 100 / LINE_HEIGHT_RATIO;
if (Number.isFinite(mult)) out.lineHeight = String(Math.round(mult * 100) / 100);
}
const bg = props['fo:background-color'];
if (bg && bg !== 'transparent') out.backgroundColor = normalizeColor(bg) ?? undefined;
for (const [key, side] of [['borderTop', 'top'], ['borderRight', 'right'], ['borderBottom', 'bottom'], ['borderLeft', 'left']] as const) {
const v = props[`fo:border-${side}`] ?? props['fo:border'];
if (v && v !== 'none') out[key] = borderAttrFromOdf(v) ?? undefined;
}
return out;
}
function textPropsFromOdf(props: PropMap, resolver: StyleResolver): TextProps {
const out: TextProps = {};
const family = resolver.fontFamilyOf(props);
// Our own export declares the metric twin; keep the registry on the on-screen name
// so an export→import loop doesn't drift.
if (family) out.fontFamily = family === 'Times New Roman' ? 'Liberation Serif' : family;
const size = lengthToPt(props['fo:font-size']);
if (size != null) out.fontSizePt = Math.round(size * 10) / 10;
const weight = props['fo:font-weight'];
if (weight) out.bold = weight === 'bold' || parseInt(weight, 10) >= 600;
const style = props['fo:font-style'];
if (style) out.italic = style === 'italic' || style === 'oblique';
const ul = props['style:text-underline-style'];
if (ul) out.underline = ul !== 'none';
const lt = props['style:text-line-through-style'];
if (lt) out.strike = lt !== 'none';
const color = props['fo:color'] ? normalizeColor(props['fo:color']) : undefined;
if (color) out.color = color;
return out;
}
// What a style declares itself: its resolved props minus the parent's.
function ownProps<T extends object>(resolved: T, parent: T): T {
const out = {} as T;
for (const key of Object.keys(resolved) as (keyof T)[]) {
if (resolved[key] !== undefined && resolved[key] !== parent[key]) out[key] = resolved[key];
}
return out;
}
// The document's style registry: the editor's built-ins, with the file's own definitions
// merged over them — only the styles blocks actually reference, plus their parent chains.
function collectStyleSheet(resolver: StyleResolver, ctx: Ctx): StyleSheet {
const defs = resolver.namedParagraphStyles();
const sheet = builtinStyleSheet();
const keep = new Set<string>();
for (const name of ctx.usedStyles) {
let cur: string | null = name;
const seen = new Set<string>();
while (cur && defs.has(cur) && !seen.has(cur)) {
seen.add(cur);
keep.add(cur);
cur = defs.get(cur)!.parent;
}
}
for (const odfName of keep) {
const def = defs.get(odfName)!;
const name = displayStyleName(odfName, def.display);
const parent = def.parent ? displayStyleName(def.parent, defs.get(def.parent)?.display) : null;
const builtin = sheet.paragraph[name];
// Own props = resolved minus the parent's resolved values. Going through the
// resolver keeps relative sizes ("130%") and values a file repeats from its parent
// from landing here raw.
const style: Style = {
name,
parent: parent && parent !== name ? parent : builtin?.parent ?? null,
next: builtin?.next ?? null,
builtin: builtin?.builtin,
para: ownProps(paraPropsFromOdf(resolver.paraProps(odfName)), paraPropsFromOdf(def.parent ? resolver.paraProps(def.parent) : {})),
text: ownProps(textPropsFromOdf(resolver.paraTextProps(odfName), resolver), textPropsFromOdf(def.parent ? resolver.paraTextProps(def.parent) : {}, resolver)),
};
const level = /^Heading (\d)$/.exec(name);
if (level) style.outlineLevel = Number(level[1]);
sheet.paragraph[name] = style;
}
return sheet;
}
function convertParaLike(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault = false): Node {
const { resolver } = ctx;
const styleName = el.getAttributeNS(NS.text, 'style-name');
@@ -630,19 +805,30 @@ function convertParaLike(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault =
level = Math.min(MAX_HEADING_LEVEL, Math.max(1, Number.isFinite(raw) ? raw : 1));
}
const attrs = blockAttrs(paraProps, baseTextProps, isHeading ? level : null, kind);
const content = convertInline(el, ctx, baseTextProps, isHeading ? level : null, false, boldByDefault);
// The file's own named style for this block (an automatic style is direct formatting
// layered on top of it); everything it already provides is not direct formatting.
const named = resolver.namedAncestor(styleName);
const defaults = blockDefaults(resolver, named, isHeading ? level : null, boldByDefault);
const attrs = blockAttrs(paraProps, baseTextProps, defaults, kind);
const content = convertInline(el, ctx, baseTextProps, defaults, false);
// An empty line has no runs, so its height comes from the paragraph style's own
// font size; carry it as a block attr (mirrors the docx paragraph-mark size).
if (content.length === 0) {
const sizePt = lengthToPt(baseTextProps['fo:font-size']);
const defSize = isHeading ? HEADING_DEFAULTS[level - 1].fontSizePt : BODY_FONT_SIZE_PT;
if (sizePt != null && Math.abs(sizePt - defSize) > 0.05) attrs.fontSize = formatPt(sizePt);
if (sizePt != null && Math.abs(sizePt - defaults.fontSizePt) > 0.05) attrs.fontSize = formatPt(sizePt);
}
const node: Node = { type: isHeading ? 'heading' : 'paragraph' };
if (isHeading) attrs.level = level;
// Only top-level blocks carry a style: list items and cells reference the producer's
// own plumbing styles (List_20_Bullet, Table_20_Contents), which are not user styles.
if (named && kind === 'body') {
const display = ctx.styleNames.get(named) ?? named;
ctx.usedStyles.add(named);
if (display !== (isHeading ? `Heading ${level}` : DEFAULT_STYLE)) attrs.styleName = display;
}
if (Object.keys(attrs).length) node.attrs = attrs;
if (content.length) node.content = content;
return node;
@@ -650,9 +836,8 @@ function convertParaLike(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault =
// textAlign / lineHeight / spaceBefore / spaceAfter from resolved paragraph
// props, suppressing values that match the editor's defaults for this context.
function blockAttrs(paraProps: PropMap, textProps: PropMap, headingLevel: number | null, kind: BlockKind): Record<string, unknown> {
function blockAttrs(paraProps: PropMap, textProps: PropMap, defaults: BlockDefaults, kind: BlockKind): Record<string, unknown> {
const attrs: Record<string, unknown> = {};
const hdef = headingLevel != null ? HEADING_DEFAULTS[headingLevel - 1] : null;
const ta = paraProps['fo:text-align'];
if (ta === 'center' || ta === 'justify') attrs.textAlign = ta;
@@ -667,7 +852,7 @@ function blockAttrs(paraProps: PropMap, textProps: PropMap, headingLevel: number
} else {
// Fixed line height: best-effort multiplier against the resolved font size.
const pt = lengthToPt(lh);
const fontPt = lengthToPt(textProps['fo:font-size']) ?? hdef?.fontSizePt ?? BODY_FONT_SIZE_PT;
const fontPt = lengthToPt(textProps['fo:font-size']) ?? defaults.fontSizePt;
if (pt != null && fontPt > 0) mult = pt / (fontPt * LINE_HEIGHT_RATIO);
}
if (mult != null) {
@@ -676,8 +861,8 @@ function blockAttrs(paraProps: PropMap, textProps: PropMap, headingLevel: number
}
}
const defTop = hdef ? hdef.marginTopPt : 0;
const defBottom = hdef ? hdef.marginBottomPt : 0;
const defTop = defaults.marginTopPt;
const defBottom = defaults.marginBottomPt;
// An unspecified fo:margin is ODF's default of 0, which is also the editor's
// paragraph default — so it is suppressed, and a file that does declare spacing
// (e.g. LO's 0.212cm Text Body) keeps it as an explicit value.
@@ -687,10 +872,11 @@ function blockAttrs(paraProps: PropMap, textProps: PropMap, headingLevel: number
if (Math.abs(mb - defBottom) > EPS_PT) attrs.spaceAfter = snapPt(mb);
// Left indent → fo:margin-left (cm). Skip lists: their indent lives in the
// list-style level properties, not paraProps. Default 0 is suppressed.
// list-style level properties, not paraProps. What the style already indents is not
// direct formatting.
if (kind !== 'list') {
const ml = lengthToPt(paraProps['fo:margin-left']);
if (ml != null && ml > EPS_PT) attrs.indent = Math.round((ml / 72) * 2.54 * 100) / 100;
const ml = lengthToPt(paraProps['fo:margin-left']) ?? 0;
if (Math.abs(ml - defaults.indentPt) > EPS_PT) attrs.indent = Math.round((ml / 72) * 2.54 * 100) / 100;
}
// Manual page break (fo:break-before). Honored for top-level blocks only
@@ -811,7 +997,7 @@ function numberStyleKind(tokens: Token[]): 'date' | 'time' | null {
// ---- inline conversion --------------------------------------------------------
function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, headingLevel: number | null, hfFields = false, boldByDefault = false): Node[] {
function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, defaults: BlockDefaults, hfFields = false): Node[] {
const out: Node[] = [];
const pushText = (text: string, props: PropMap, linkHref?: string) => {
@@ -824,7 +1010,7 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, headingLevel
clean = clean.replace(/[ \t]*\n[ \t]*/g, ' ');
}
if (!clean) return;
const marks = marksFor(props, ctx.resolver, headingLevel, boldByDefault);
const marks = marksFor(props, ctx.resolver, defaults);
if (linkHref) marks.push({ type: 'link', attrs: { href: linkHref } });
const node: Node = { type: 'text', text: clean };
if (marks.length) node.marks = marks;
@@ -855,7 +1041,7 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, headingLevel
continue;
case 'line-break': {
// Carry the run's marks so an empty line between two breaks keeps its font size.
const marks = marksFor(props, ctx.resolver, headingLevel, boldByDefault);
const marks = marksFor(props, ctx.resolver, defaults);
const br: Node = { type: 'hardBreak' };
if (marks.length) br.marks = marks;
out.push(br);
@@ -884,7 +1070,7 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, headingLevel
// carries the run's marks so its digits render in the run's font/size.
if (hfFields && (e.localName === 'page-number' || e.localName === 'page-count')) {
const field: Node = { type: e.localName === 'page-count' ? 'pageCount' : 'pageNumber' };
const marks = marksFor(props, ctx.resolver, headingLevel, boldByDefault);
const marks = marksFor(props, ctx.resolver, defaults);
if (marks.length) field.marks = marks;
out.push(field);
continue;
@@ -898,7 +1084,7 @@ function convertInline(root: Element, ctx: Ctx, baseProps: PropMap, headingLevel
// The field is an atom without inline children, so it can't inherit
// the surrounding run's font from a sibling span — carry the run's
// marks on the node so it renders in the same font/size/etc.
const marks = marksFor(props, ctx.resolver, headingLevel, boldByDefault);
const marks = marksFor(props, ctx.resolver, defaults);
if (linkHref) marks.push({ type: 'link', attrs: { href: linkHref } });
if (marks.length) field.marks = marks;
out.push(field);
@@ -952,7 +1138,7 @@ function mergeAdjacentText(nodes: Node[]): Node[] {
return out;
}
function marksFor(props: PropMap, resolver: StyleResolver, headingLevel: number | null, boldByDefault = false): Mark[] {
function marksFor(props: PropMap, resolver: StyleResolver, defaults: BlockDefaults): Mark[] {
const marks: Mark[] = [];
const textStyle: Record<string, unknown> = {};
@@ -960,18 +1146,19 @@ function marksFor(props: PropMap, resolver: StyleResolver, headingLevel: number
const bold = weight ? weight === 'bold' || parseInt(weight, 10) >= 600 : null;
// Headings and header-row cells render bold by default (CSS): only a *non*-bold run
// needs a mark (fontWeight:normal), and a bold run needs none.
if (headingLevel != null || boldByDefault) {
if (defaults.boldByDefault) {
if (bold === false) textStyle.fontWeight = 'normal';
} else if (bold === true) {
marks.push({ type: 'bold' });
}
// Marks the block's named style already renders need no mark of their own.
const fs = props['fo:font-style'];
if (fs === 'italic' || fs === 'oblique') marks.push({ type: 'italic' });
if ((fs === 'italic' || fs === 'oblique') && !defaults.italic) marks.push({ type: 'italic' });
const ul = props['style:text-underline-style'];
if (ul && ul !== 'none') marks.push({ type: 'underline' });
if (ul && ul !== 'none' && !defaults.underline) marks.push({ type: 'underline' });
const lt = props['style:text-line-through-style'];
if (lt && lt !== 'none') marks.push({ type: 'strike' });
if (lt && lt !== 'none' && !defaults.strike) marks.push({ type: 'strike' });
// "super 58%" / "sub" / bare percentage (positive = raised, negative = lowered).
const pos = props['style:text-position'];
@@ -992,16 +1179,14 @@ function marksFor(props: PropMap, resolver: StyleResolver, headingLevel: number
}
const color = props['fo:color'] ? normalizeColor(props['fo:color']) : undefined;
// Pure black is the theme default; an explicit mark would fight dark/allBlack.
if (color && color !== '#000000') textStyle.color = color;
// The style's own color (black by default; an explicit black mark would fight dark/allBlack).
if (color && color !== defaults.color) textStyle.color = color;
const sizePt = lengthToPt(props['fo:font-size']);
const defSize = headingLevel != null ? HEADING_DEFAULTS[headingLevel - 1].fontSizePt : BODY_FONT_SIZE_PT;
if (sizePt != null && Math.abs(sizePt - defSize) > 0.05) textStyle.fontSize = formatPt(sizePt);
if (sizePt != null && Math.abs(sizePt - defaults.fontSizePt) > 0.05) textStyle.fontSize = formatPt(sizePt);
const family = resolver.fontFamilyOf(props);
const defaultFonts = headingLevel != null ? DEFAULT_HEADING_FONTS : DEFAULT_FONTS;
if (family && !defaultFonts.has(family.toLowerCase())) textStyle.fontFamily = family;
if (family && !defaults.fonts.has(family.toLowerCase())) textStyle.fontFamily = family;
if (Object.keys(textStyle).length) marks.push({ type: 'textStyle', attrs: textStyle });
return marks;
+34 -2
View File
@@ -136,6 +136,8 @@ export class StyleResolver {
private sectionStyleEls = new Map<string, Element>();
private mergedCache = new Map<string, { text: PropMap; para: PropMap; misc: PropMap }>();
private stylesDoc: Document | null;
private namedParagraphNames = new Set<string>();
private displayNames = new Map<string, string>();
constructor(contentDoc: Document, stylesDoc: Document | null) {
this.stylesDoc = stylesDoc;
@@ -151,7 +153,32 @@ export class StyleResolver {
const fontDecls = doc.getElementsByTagNameNS(NS.office, 'font-face-decls')[0];
if (fontDecls) this.scanFontFaces(fontDecls);
}
for (const c of containers) this.scanContainer(c);
for (const c of containers) this.scanContainer(c, c.localName === 'styles');
}
// Paragraph styles from <office:styles> — the file's named styles (as opposed to the
// automatic styles, which are direct formatting). Own props only; the chain stays a chain.
namedParagraphStyles(): Map<string, { parent: string | null; display?: string; text: PropMap; para: PropMap }> {
const out = new Map<string, { parent: string | null; display?: string; text: PropMap; para: PropMap }>();
for (const name of this.namedParagraphNames) {
const entry = this.styles.get(`paragraph\0${name}`);
// display: only the file's own style:display-name — the caller decodes _20_ itself.
if (entry) out.set(name, { parent: entry.parent, display: this.displayNames.get(name), text: entry.text, para: entry.para });
}
return out;
}
// The nearest named style in a style's parent chain (an automatic style is direct
// formatting layered on top of it); null when the chain reaches none.
namedAncestor(styleName: string | null | undefined): string | null {
let cur = styleName ?? null;
const seen = new Set<string>();
while (cur && !seen.has(cur)) {
seen.add(cur);
if (this.namedParagraphNames.has(cur)) return cur;
cur = this.styles.get(`paragraph\0${cur}`)?.parent ?? null;
}
return null;
}
private scanFontFaces(container: Element): void {
@@ -179,12 +206,17 @@ export class StyleResolver {
return this.fontSources;
}
private scanContainer(container: Element): void {
private scanContainer(container: Element, named: boolean): void {
for (const el of Array.from(container.children)) {
if (el.namespaceURI === NS.style && el.localName === 'style') {
const name = el.getAttributeNS(NS.style, 'name');
const family = el.getAttributeNS(NS.style, 'family');
if (name && family) this.styles.set(`${family}\0${name}`, entryFromStyleElement(el));
if (name && family === 'paragraph' && named) {
this.namedParagraphNames.add(name);
const display = el.getAttributeNS(NS.style, 'display-name');
if (display) this.displayNames.set(name, display);
}
if (name && family === 'section') this.sectionStyleEls.set(name, el);
} else if (el.namespaceURI === NS.style && el.localName === 'default-style') {
const family = el.getAttributeNS(NS.style, 'family');
+1 -1
View File
@@ -49,7 +49,7 @@ export const HEADING_PARENT = 'Heading';
// asserts the two agree).
const BUILTINS: Style[] = [
{ name: DEFAULT_STYLE, parent: null, next: null, builtin: true,
para: {}, text: { fontFamily: 'Liberation Serif', fontSizePt: 12 } },
para: { spaceBefore: 0, spaceAfter: 0 }, text: { fontFamily: 'Liberation Serif', fontSizePt: 12 } },
{ name: HEADING_PARENT, parent: DEFAULT_STYLE, next: DEFAULT_STYLE, builtin: true,
para: { spaceBefore: 12, spaceAfter: 6 }, text: { fontFamily: 'Arial', bold: true } },
...[18, 16, 14, 13, 12].map((size, i) => ({
+55 -1
View File
@@ -9,6 +9,7 @@ import { buildOdt } from '../src/lib/export/odt';
import { importOdt } from '../src/lib/import/odt';
import { hfExtensions } from '../src/lib/editor/extensions/headerFooter';
import { HEADER_SHADE } from '../src/lib/editor/extensions/tableHeaderRow';
import { builtinStyleSheet } from '../src/lib/styles/styleSheet';
type N = any;
@@ -592,8 +593,14 @@ describe('Leg 2: foreign (LibreOffice/Word-style) .odt → importOdt', () => {
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: font-face resolves Courier New', mono.content![0].marks?.some((m: N) => m.type === 'textStyle' && m.attrs?.fontFamily === 'Courier New' && m.attrs?.fontSize === '10pt'), mono.content![0]);
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 imported, footnote dropped, text kept',
c[4].content!.length === 2 && c[4].content![0].text === 'img:' &&
@@ -1305,3 +1312,50 @@ describe('Leg 11: heading levels 4 and 5', () => {
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]);
});
});
+2 -1
View File
@@ -22,7 +22,8 @@ describe('exported Standard style', () => {
const styles = strFromU8(unzipSync(bytes)['styles.xml']);
const standard = styles.match(/<style:style style:name="Standard"[\s\S]*?<\/style:style>/)![0];
expect(standard).toContain('fo:margin-bottom="0cm"');
// The Standard style declares its zero spacing (LibreOffice's Default Paragraph Style).
expect(standard).toMatch(/fo:margin-bottom="0(pt|cm)"/);
expect(standard).not.toContain('0.212cm');
});
});
+16 -10
View File
@@ -28,24 +28,30 @@ function odtWithHeading(base: string, size: string): Uint8Array {
return zipSync({ 'content.xml': strToU8(content), 'styles.xml': strToU8(styles) });
}
const sizeOf = (base: string, size: string) => {
// The size a heading renders at: from the imported style registry, since a named
// style's formatting is no longer copied onto the block.
const styleSizeOf = (base: string, size: string) =>
importOdt(odtWithHeading(base, size)).styles.paragraph['Heading 1']?.text.fontSizePt;
const markSizeOf = (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('resolves a percentage against the parent style', () => {
expect(styleSizeOf('12pt', '150%')).toBe(18);
expect(styleSizeOf('16pt', '125%')).toBe(20);
expect(styleSizeOf('14pt', '130%')).toBe(18.2);
});
it('still reads absolute sizes', () => {
expect(sizeOf('14pt', '24pt')).toBe('24pt');
expect(styleSizeOf('14pt', '24pt')).toBe(24);
});
it('leaves the block free of direct formatting either way', () => {
expect(markSizeOf('12pt', '150%')).toBeUndefined();
expect(markSizeOf('16pt', '125%')).toBeUndefined();
});
});
+1 -1
View File
@@ -16,7 +16,7 @@ describe('named paragraph styles', () => {
expect(h1.para.spaceBefore).toBe(12); // from Heading
const standard = resolveStyle(sheet, DEFAULT_STYLE);
expect(standard.text.fontFamily).toBe('Liberation Serif');
expect(standard.para.spaceBefore).toBeUndefined();
expect(standard.para.spaceBefore).toBe(0);
});
it('falls back to Standard for unknown names and survives a parent cycle', () => {