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:
1 parent
d2701d38ae
commit
e2e885e1e8
6 files changed
+85
-17
No files matched your search
@@ -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}`,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user