diff --git a/src/App.svelte b/src/App.svelte index 28ac03d..378a557 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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[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[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; diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index c5248f7..b8787fd 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -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 . U+E00B. const HFIMG = ''; +// Sentinel wrapping the named paragraph style of a block whose style isn't the ODF +// default for its node type (STYSTY), 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 = { '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 { + const used = new Set(); + 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('', ``); } +// ---- named paragraph styles (styles/styleSheet.ts) -------------------------------- + +// Set or replace attributes on an element's opening tag. +function setTagAttrs(tag: string, attrs: Record): 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 /, +// keeping whatever the producer already put there (language, keep-with-next, …). +function upsertProps(block: string, kind: 'paragraph' | 'text', attrs: Record): 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(']*\/>$/.test(block.trim())) + return block.trim().replace(/\/>$/, `>${props}`); + return block.replace('', `${props}`); +} + +// 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; text: Record }) { + const p = style.para as Record; + const t = style.text as Record; + const para: Record = {}; + 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 = {}; + 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 { + 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(/^]*?(\/?)>/, (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 = { + '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('', attrs).replace('/>', '>')}`; + block = upsertProps(upsertProps(block, 'paragraph', para), 'text', text); + added.push(block); + } + return added.length ? styles.replace('', `${added.join('')}`) : 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(); + 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(/^]*?(\/?)>/, (tag) => + setTagAttrs(tag, { 'style:parent-style-name': odfName })), + ); + return name; + }; + + const styRe = new RegExp(`${STY}([^${STY}]*)${STY}`); + content = content.replace( + new RegExp(`]*)>([\\s\\S]*?)`, '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 `${cleaned}`; + }, + ); + content = content.replace(new RegExp(`${STY}[^${STY}]*${STY}`, 'g'), ''); + + if (minted.length) { + content = content.replace('', `${minted.join('')}`); + } + + 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): 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): 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( '', ``, ); - styles = styles.replace(//, (block) => - block.replace( - '… block so the - // font-size/margin replacements never bleed across heading levels. - for (const { name, fontSize, marginTop, marginBottom } of HEADING_STYLE_OVERRIDES) { - const re = new RegExp(``); - 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 { +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 { // 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); } diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index cc7ce80..2e30af1 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -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, diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 56edd8d..ee0deeb 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -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; + usedStyles: Set; warnings: Set; files: Record; imageCache: Map; @@ -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(); + 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; + 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 = { + '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(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(); + for (const name of ctx.usedStyles) { + let cur: string | null = name; + const seen = new Set(); + 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 { +function blockAttrs(paraProps: PropMap, textProps: PropMap, defaults: BlockDefaults, kind: BlockKind): Record { const attrs: Record = {}; - 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 = {}; @@ -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; diff --git a/src/lib/import/styleResolver.ts b/src/lib/import/styleResolver.ts index 3a70cc2..929774f 100644 --- a/src/lib/import/styleResolver.ts +++ b/src/lib/import/styleResolver.ts @@ -136,6 +136,8 @@ export class StyleResolver { private sectionStyleEls = new Map(); private mergedCache = new Map(); private stylesDoc: Document | null; + private namedParagraphNames = new Set(); + private displayNames = new Map(); 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 — 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 { + const out = new Map(); + 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(); + 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'); diff --git a/src/lib/styles/styleSheet.ts b/src/lib/styles/styleSheet.ts index 2887520..a6b469a 100644 --- a/src/lib/styles/styleSheet.ts +++ b/src/lib/styles/styleSheet.ts @@ -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) => ({ diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts index e71c0b2..a7df951 100644 --- a/tests/roundtrip.test.ts +++ b/tests/roundtrip.test.ts @@ -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(//)?.[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]); + }); +}); diff --git a/tests/unit/default-spacing.test.ts b/tests/unit/default-spacing.test.ts index b11d8b0..d8af5e6 100644 --- a/tests/unit/default-spacing.test.ts +++ b/tests/unit/default-spacing.test.ts @@ -22,7 +22,8 @@ describe('exported Standard style', () => { const styles = strFromU8(unzipSync(bytes)['styles.xml']); const standard = styles.match(//)![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'); }); }); diff --git a/tests/unit/heading-styles-import.test.ts b/tests/unit/heading-styles-import.test.ts index 4959e4f..90c437c 100644 --- a/tests/unit/heading-styles-import.test.ts +++ b/tests/unit/heading-styles-import.test.ts @@ -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(); }); }); diff --git a/tests/unit/style-resolve.test.ts b/tests/unit/style-resolve.test.ts index 5a85f01..bc702af 100644 --- a/tests/unit/style-resolve.test.ts +++ b/tests/unit/style-resolve.test.ts @@ -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', () => {