feat: the paragraph mark carries the block's font, not only its size

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.
This commit is contained in:
Steffen Becker committed 2026-08-08 08:30:05 +00:00
1 parent d2701d38ae
commit e2e885e1e8
6 files changed
+85 -17

No files matched your search

+20 -3
View File
@@ -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<ReturnType> {
@@ -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<string, unknown>) => {
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}`,
};
},
},
},
},
];
+5 -6
View File
@@ -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<ReturnType> {
@@ -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<string, unknown>) => {
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}`,
+3 -2
View File
@@ -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),
});
}
+18 -5
View File
@@ -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 ("<size>|<family>", 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 <style:style> 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.
+14 -1
View File
@@ -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>): 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.
+25
View File
@@ -137,6 +137,28 @@ function applyFrameRotationAndWrap(el: Element, attrs: Record<string, unknown>,
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<string, unknown>, content: { type: string; marks?: { type: string; attrs?: Record<string, unknown> }[] }[]): 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 <draw:frame><draw:image> → 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;