From e2e885e1e8609cdf814483c0c10bbde7ea34bf4a Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Fri, 7 Aug 2026 23:12:20 +0000 Subject: [PATCH] feat: the paragraph mark carries the block's font, not only its size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The block's CSS strut is the floor under every line, so a paragraph whose runs are smaller or of another family than the style rendered too tall — 03-fonts drifted 4mm over a page. The mark's family now rides the block like its size already did (Word's w:pPr/w:rPr/w:rFonts, ODF the paragraph's own text properties), and supplies the natural line height single spacing means. A mark that declares nothing but whose runs all agree takes their font: that is the paragraph's font, and it is what a word processor struts with. --- src/lib/editor/extensions/blockFontSize.ts | 23 +++++++++++++++++--- src/lib/editor/extensions/lineHeight.ts | 11 +++++----- src/lib/export/docx.ts | 5 +++-- src/lib/export/odt.ts | 23 +++++++++++++++----- src/lib/import/docx.ts | 15 ++++++++++++- src/lib/import/odt.ts | 25 ++++++++++++++++++++++ 6 files changed, 85 insertions(+), 17 deletions(-) diff --git a/src/lib/editor/extensions/blockFontSize.ts b/src/lib/editor/extensions/blockFontSize.ts index 4e53e34..555ec36 100644 --- a/src/lib/editor/extensions/blockFontSize.ts +++ b/src/lib/editor/extensions/blockFontSize.ts @@ -1,8 +1,9 @@ import { Extension } from '@tiptap/core'; +import { cssFontFamily, singleLineHeight } from '../../styles/styleSheet'; -// Font size of the paragraph mark (Word's w:pPr/w:rPr sz, ODF paragraph -// fo:font-size): governs an empty line's height and the size of text typed into it. -// A CSS length (e.g. "22pt") rendered as font-size on the block; null = style default. +// Font of the paragraph mark (Word's w:pPr/w:rPr, ODF the paragraph's own text +// properties): the block's CSS strut, so it sets every line's minimum height and what +// text typed into an empty one looks like. Size is a CSS length; null = style default. declare module '@tiptap/core' { interface Commands { @@ -39,6 +40,22 @@ export const BlockFontSize = Extension.create({ }; }, }, + fontFamily: { + default: null, + parseHTML: (element: HTMLElement) => + element.getAttribute('data-block-font-family') || null, + renderHTML: (attributes: Record) => { + if (!attributes.fontFamily) return {}; + const family = String(attributes.fontFamily); + // Single spacing is this family's natural line height. An explicit line + // spacing already scales by it (lineHeight.ts), so it wins outright. + const lh = attributes.lineHeight ? '' : `; line-height: ${singleLineHeight(family)}`; + return { + 'data-block-font-family': family, + style: `font-family: ${cssFontFamily(family)}${lh}`, + }; + }, + }, }, }, ]; diff --git a/src/lib/editor/extensions/lineHeight.ts b/src/lib/editor/extensions/lineHeight.ts index e05089a..33c01b4 100644 --- a/src/lib/editor/extensions/lineHeight.ts +++ b/src/lib/editor/extensions/lineHeight.ts @@ -1,9 +1,5 @@ import { Extension } from '@tiptap/core'; -import { DEFAULT_SINGLE_LINE_HEIGHT } from '../../styles/styleSheet'; - -// ODF line spacing multiplies the font's natural line height, CSS unitless -// line-height the font size. A node attr knows no family, so it takes the default. -const LINE_HEIGHT_RATIO = DEFAULT_SINGLE_LINE_HEIGHT; +import { singleLineHeight } from '../../styles/styleSheet'; declare module '@tiptap/core' { interface Commands { @@ -32,11 +28,14 @@ export const LineHeight = Extension.create({ default: null, parseHTML: (element: HTMLElement) => element.getAttribute('data-line-height') || null, + // A proportional spacing multiplies the font's natural line height, CSS the + // font size — so it is scaled by the paragraph mark's family (blockFontSize). renderHTML: (attributes: Record) => { if (!attributes.lineHeight) return {}; const raw = String(attributes.lineHeight); const num = parseFloat(raw); - const rendered = isNaN(num) ? raw : `${num * LINE_HEIGHT_RATIO}`; + const family = attributes.fontFamily ? String(attributes.fontFamily) : undefined; + const rendered = isNaN(num) ? raw : `${Math.round(num * singleLineHeight(family) * 1000) / 1000}`; return { 'data-line-height': raw, style: `line-height: ${rendered}`, diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index e8b70b7..8af465e 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -645,8 +645,9 @@ function paragraphToDocx(node: TiptapNode, opts: ParaOpts = {}): Paragraph { // w:tabs. Word measures w:pos from the left text margin, the same origin the attr // uses, so the position goes out unshifted by the paragraph's own indent. const stops = parseTabStops(attrs.tabStops); - // Paragraph-mark run props carry an empty line's font size (see import/docx.ts). + // Paragraph-mark run props carry the block's own font (see import/docx.ts). const markSize = typeof attrs.fontSize === 'string' ? fontSizeToHalfPoints(attrs.fontSize) : undefined; + const markFont = typeof attrs.fontFamily === 'string' && attrs.fontFamily ? attrs.fontFamily : undefined; return new Paragraph({ style, alignment: alignOf(attrs), @@ -660,7 +661,7 @@ function paragraphToDocx(node: TiptapNode, opts: ParaOpts = {}): Paragraph { numbering: opts.numbering, shading: paraShadingOf(attrs), border: paraBordersOf(attrs), - run: markSize ? { size: markSize } : undefined, + run: markSize || markFont ? { size: markSize, font: markFont } : undefined, children: inlineToRuns(node.content, opts.force), }); } diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 6b6f59f..5d9acc3 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -223,6 +223,7 @@ function hasCustomAttrs(attrs: TiptapNode['attrs']): boolean { if (attrs.spaceBefore != null) return true; if (attrs.spaceAfter != null) return true; if (typeof attrs.fontSize === 'string' && attrs.fontSize) return true; + if (typeof attrs.fontFamily === 'string' && attrs.fontFamily) return true; if (typeof attrs.indent === 'number' && attrs.indent > 0) return true; if (typeof attrs.indentFirst === 'number' && attrs.indentFirst !== 0) return true; if (typeof attrs.indentRight === 'number' && attrs.indentRight > 0) return true; @@ -965,9 +966,21 @@ function applyPageBreaks(odtBytes: Uint8Array): Uint8Array { return rezipOdt(files); } -// fo:font-size (+ asian/complex aliases) attribute string for a paragraph style. -function fontSizeProps(size: string): string { - return `fo:font-size="${size}" style:font-size-asian="${size}" style:font-size-complex="${size}"`; +// The paragraph mark's font as an FSZ payload ("|", either half empty). +function markFontPayload(attrs: TiptapNode['attrs']): string { + const size = typeof attrs?.fontSize === 'string' ? attrs.fontSize : ''; + const family = typeof attrs?.fontFamily === 'string' ? attrs.fontFamily : ''; + return size || family ? `${size}|${family}` : ''; +} + +// The paragraph style's own text properties for that payload (+ asian/complex aliases). +function fontSizeProps(payload: string): string { + const [size, family] = payload.split('|'); + const font = family ? twinFontName(family) : ''; + return [ + size ? `fo:font-size="${size}" style:font-size-asian="${size}" style:font-size-complex="${size}"` : '', + font ? `style:font-name="${font}" style:font-name-asian="${font}" style:font-name-complex="${font}"` : '', + ].filter(Boolean).join(' '); } // Clone a def under newName, adding fo:font-size to its text-properties. @@ -2955,8 +2968,8 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU // The paragraph-mark font size rides as an FSZ sentinel that // applyEmptyLineFontSizes turns into a paragraph-style fo:font-size — it is the // block's line-height floor, so it matters on a filled block as much as an empty one. - const fsz = typeof node.attrs?.fontSize === 'string' && node.attrs.fontSize - ? `${FSZ}${node.attrs.fontSize}${FSZ}` : ''; + const fszPayload = markFontPayload(node.attrs); + const fsz = fszPayload ? `${FSZ}${fszPayload}${FSZ}` : ''; // Paragraph background/borders ride as a leading PBX sentinel (odf-kit has no such // options); applyParagraphBoxes mints the style. FSZ stays first so its own pass, // which runs earlier, still matches it right after the opening tag. diff --git a/src/lib/import/docx.ts b/src/lib/import/docx.ts index 5a6c382..4abf98b 100644 --- a/src/lib/import/docx.ts +++ b/src/lib/import/docx.ts @@ -16,7 +16,7 @@ import type { Orientation } from '../storage/pageOrientation'; import { formatFromCm, type PageFormat } from '../storage/pageFormat'; import { languageFromOdf, NO_LANGUAGE, type DocumentLanguage } from '../storage/documentLanguage'; import type { HfDoc } from '../storage/headerFooter'; -import type { OdtImportResult } from './odt'; +import { applyUniformRunFont, type OdtImportResult } from './odt'; import { deobfuscateOdttf, type EmbeddedFont } from '../fonts/embeddedFonts'; // .docx → TipTap JSON, inverting export/docx.ts. Editor-expressible OOXML becomes its @@ -721,6 +721,9 @@ function convertParagraph(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault: // would otherwise keep the style's taller strut. Carried as a block attr. const fs = paragraphMarkFontSize(ppr, ctx, baseRun, defaults.fontSizePt); if (fs) attrs.fontSize = fs; + const ff = paragraphMarkFont(ppr, ctx, baseRun, defaults.fonts); + if (ff) attrs.fontFamily = ff; + applyUniformRunFont(attrs, content); const node: Node = { type: level ? 'heading' : 'paragraph' }; if (level) attrs.level = level; @@ -753,6 +756,16 @@ function paragraphMarkFontSize(ppr: Element | null, ctx: Ctx, baseRun: RunProps, return Math.abs(sizePt - defaultPt) > 0.05 ? `${Math.round(sizePt * 10) / 10}pt` : null; } +// The paragraph mark's resolved font family (w:pPr/w:rPr/w:rFonts, incl. its rStyle), +// or null when it is what the block renders at anyway. +function paragraphMarkFont(ppr: Element | null, ctx: Ctx, baseRun: RunProps, blockFonts: Set): string | null { + const rPr = fc(ppr, 'rPr'); + const rStyle = fc(rPr, 'rStyle'); + const props = mergeRunProps(mergeRunProps(baseRun, ctx.styles.styleOwn(rStyle ? wVal(rStyle) : null)), parseRunProps(rPr)); + const font = props.font ?? ctx.styles.themeFont(props.fontTheme ?? 'minor'); + return !font || blockFonts.has(font.toLowerCase()) ? null : font; +} + // Heading + clamped level. Detect via the paragraph style id (fast path for our own // files / English Word), then the style's resolved outline level (locale-independent, // catches LibreOffice/localized heading styles), then a direct paragraph outline level. diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 1da4f5b..647b1a6 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -137,6 +137,28 @@ function applyFrameRotationAndWrap(el: Element, attrs: Record, if (x != null && attrs.wrap && attrs.wrap !== 'topBottom') attrs.wrapOffset = Math.round(x * 100) / 100; } +// A paragraph mark that declares nothing keeps the style's font, but the runs may all +// agree on another one — then that is the paragraph's font, strut included. Without it +// the taller style strut governs every line and the block renders too high. +export function applyUniformRunFont(attrs: Record, content: { type: string; marks?: { type: string; attrs?: Record }[] }[]): void { + let family: string | null | undefined; + let size: string | null | undefined; + let runs = 0; + for (const n of content) { + if (n.type !== 'text') continue; + const ts = (n.marks ?? []).find((m) => m.type === 'textStyle')?.attrs ?? {}; + const f = (ts.fontFamily as string) ?? null; + const s = (ts.fontSize as string) ?? null; + if (runs && f !== family) family = undefined; + if (runs && s !== size) size = undefined; + if (!runs) { family = f; size = s; } + runs++; + } + if (!runs) return; + if (attrs.fontFamily == null && family) attrs.fontFamily = family; + if (attrs.fontSize == null && size) attrs.fontSize = size; +} + // A → an image node. Size comes from the frame's svg // geometry (cm → px). as-char frames stay inline; paragraph/page-anchored frames // with a wrap become floating (wrap mode + svg:x/svg:y position). @@ -874,6 +896,9 @@ function convertParaLike(el: Element, ctx: Ctx, kind: BlockKind, boldByDefault = // paragraph-mark size), or text smaller than the style keeps the taller strut. const markSize = lengthToPt(baseTextProps['fo:font-size']); if (markSize != null && Math.abs(markSize - defaults.fontSizePt) > 0.05) attrs.fontSize = formatPt(markSize); + const markFont = resolver.fontFamilyOf(baseTextProps); + if (markFont && !defaults.fonts.has(markFont.toLowerCase())) attrs.fontFamily = markFont; + applyUniformRunFont(attrs, content); const node: Node = { type: isHeading ? 'heading' : 'paragraph' }; if (isHeading) attrs.level = level;