feat: character spacing, and a line box that multiplies its two halves

Word's Title style tightens by -0.5pt, so a title came out ~3mm wide of
LibreOffice's. letterSpacingPt joins TextProps and rides both formats
(w:spacing, fo:letter-spacing).

Line spacing needed the same care: it is proportional to the font's natural
line height, so the factor and that height are now separate CSS variables and
editor.css multiplies them in one place. Before, whichever of the two was
declared nearer simply overwrote the other — a block carrying its own font
silently dropped the style's spacing.
This commit is contained in:
Steffen Becker committed 2026-08-08 08:30:05 +00:00
1 parent e2e885e1e8
commit c82a78cc6a
9 files changed
+40 -26

No files matched your search

+3 -4
View File
@@ -47,12 +47,11 @@ export const BlockFontSize = Extension.create({
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)}`;
// Single spacing is this family's natural line height; the paragraph's own
// spacing factor multiplies it (editor.css).
return {
'data-block-font-family': family,
style: `font-family: ${cssFontFamily(family)}${lh}`,
style: `font-family: ${cssFontFamily(family)}; --natural-line: ${singleLineHeight(family)}`,
};
},
},
+7 -7
View File
@@ -1,5 +1,8 @@
import { Extension } from '@tiptap/core';
import { singleLineHeight } from '../../styles/styleSheet';
// A line spacing is proportional to the font's natural line height, not to the font
// size like CSS unitless line-height. The factor rides as a variable editor.css
// multiplies by the block's own natural line height (blockFontSize.ts).
declare module '@tiptap/core' {
interface Commands<ReturnType> {
@@ -28,17 +31,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).
// The factor alone: editor.css multiplies it by the block's font's natural
// line height, which is what a proportional spacing is proportional to.
renderHTML: (attributes: Record<string, unknown>) => {
if (!attributes.lineHeight) return {};
const raw = String(attributes.lineHeight);
const num = parseFloat(raw);
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}`,
style: `--line-factor: ${raw}`,
};
},
},
+2
View File
@@ -941,6 +941,7 @@ function paragraphStyleOf(style: Style): IParagraphStyleOptions {
// The registry holds the on-screen family; the file declares its metric twin.
if (t.fontFamily) run.font = twinFontName(t.fontFamily);
if (t.fontSizePt != null) run.size = Math.round(t.fontSizePt * 2);
if (t.letterSpacingPt) run.characterSpacing = Math.round(t.letterSpacingPt * 20);
if (t.bold != null) run.bold = t.bold;
if (t.italic != null) run.italics = t.italic;
if (t.underline) run.underline = {};
@@ -971,6 +972,7 @@ function characterStyleOf(style: Style): ICharacterStyleOptions {
const run: Writable<IRunStylePropertiesOptions> = {};
if (t.fontFamily) run.font = t.fontFamily === 'Liberation Serif' ? DOC_FONT : t.fontFamily;
if (t.fontSizePt != null) run.size = Math.round(t.fontSizePt * 2);
if (t.letterSpacingPt) run.characterSpacing = Math.round(t.letterSpacingPt * 20);
if (t.bold != null) run.bold = t.bold;
if (t.italic != null) run.italics = t.italic;
if (t.underline) run.underline = {};
+1
View File
@@ -1153,6 +1153,7 @@ function ownStyleAttrs(style: { para: Record<string, unknown>; text: Record<stri
text['style:font-size-asian'] = size;
text['style:font-size-complex'] = size;
}
if (t.letterSpacingPt) text['fo:letter-spacing'] = `${t.letterSpacingPt}pt`;
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';
+1
View File
@@ -618,6 +618,7 @@ function runTextProps(run: RunProps): TextProps {
if (run.font) out.fontFamily = run.font === 'Times New Roman' ? 'Liberation Serif'
: run.font === 'Arial' ? 'Liberation Sans' : run.font;
if (run.sizeHalfPt != null) out.fontSizePt = Math.round((run.sizeHalfPt / 2) * 10) / 10;
if (run.spacingTwip) out.letterSpacingPt = Math.round((run.spacingTwip / 20) * 100) / 100;
if (run.bold != null) out.bold = run.bold;
if (run.italic != null) out.italic = run.italic;
if (run.underline != null) out.underline = run.underline;
+2
View File
@@ -24,6 +24,7 @@ export type RunProps = {
vertAlign?: 'superscript' | 'subscript';
color?: string; // raw w:val hex (no #) or 'auto'
sizeHalfPt?: number; // w:sz (half-points)
spacingTwip?: number; // w:spacing (character spacing, twentieths of a point)
font?: string; // explicit w:rFonts w:ascii/hAnsi
fontTheme?: 'minor' | 'major'; // w:rFonts w:asciiTheme/hAnsiTheme → theme1.xml font
highlightFill?: string; // run-level w:shd w:fill (text highlight)
@@ -82,6 +83,7 @@ export function parseRunProps(rPr: Element | null | undefined): RunProps {
}
case 'color': { const v = wVal(child); if (v) p.color = v; break; }
case 'sz': { const n = parseInt(wVal(child) ?? '', 10); if (Number.isFinite(n)) p.sizeHalfPt = n; break; }
case 'spacing': { const n = parseInt(wVal(child) ?? '', 10); if (Number.isFinite(n)) p.spacingTwip = n; break; }
case 'rFonts': {
const f = child.getAttributeNS(W, 'ascii') ?? child.getAttributeNS(W, 'hAnsi');
if (f) { p.font = f; break; }
+2
View File
@@ -775,6 +775,8 @@ function textPropsFromOdf(props: PropMap, resolver: StyleResolver): TextProps {
if (family) out.fontFamily = screenFontName(family);
const size = lengthToPt(props['fo:font-size']);
if (size != null) out.fontSizePt = Math.round(size * 10) / 10;
const spacing = lengthToPt(props['fo:letter-spacing']);
if (spacing) out.letterSpacingPt = Math.round(spacing * 100) / 100;
const weight = props['fo:font-weight'];
if (weight) out.bold = weight === 'bold' || parseInt(weight, 10) >= 600;
const style = props['fo:font-style'];
+10 -11
View File
@@ -20,6 +20,7 @@ export type ParaProps = {
export type TextProps = {
fontFamily?: string;
fontSizePt?: number;
letterSpacingPt?: number; // character spacing (Word's w:spacing, ODF fo:letter-spacing)
bold?: boolean;
italic?: boolean;
underline?: boolean;
@@ -200,7 +201,7 @@ export function cssFontFamily(name: string): string {
// Single spacing is the font's *natural* line height, so it differs per family.
// Liberation Serif's 1.15 is the default (editor.css); only the bundled families that
// deviate are listed, measured against LibreOffice at 12pt.
export const DEFAULT_SINGLE_LINE_HEIGHT = 1.15;
const DEFAULT_SINGLE_LINE_HEIGHT = 1.15;
const SINGLE_LINE_HEIGHT: Record<string, number> = {
Calibri: 1.2208,
'Calibri Light': 1.2208,
@@ -215,16 +216,18 @@ export function singleLineHeight(fontFamily?: string): number {
return (fontFamily && SINGLE_LINE_HEIGHT[fontFamily]) || DEFAULT_SINGLE_LINE_HEIGHT;
}
// The text half of a rule, shared with the table-style family (tableStyles.ts).
export function textDeclarations(t: TextProps): string[] {
// The text half of a rule, shared with the table-style family (tableStyles.ts). A block
// takes the family's natural line height as the variable editor.css multiplies by the
// paragraph's spacing factor; a run box sets its line height outright.
export function textDeclarations(t: TextProps, asBlock = false): string[] {
const out: string[] = [];
if (t.fontFamily) {
out.push(`font-family: ${cssFontFamily(t.fontFamily)}`);
const lh = SINGLE_LINE_HEIGHT[t.fontFamily];
// An explicit line spacing is emitted after this and wins.
if (lh) out.push(`line-height: ${lh}`);
if (lh) out.push(`${asBlock ? '--natural-line' : 'line-height'}: ${lh}`);
}
if (t.fontSizePt != null) out.push(`font-size: ${t.fontSizePt}pt`);
if (t.letterSpacingPt) out.push(`letter-spacing: ${t.letterSpacingPt}pt`);
if (t.bold != null) out.push(`font-weight: ${t.bold ? 700 : 400}`);
if (t.italic != null) out.push(`font-style: ${t.italic ? 'italic' : 'normal'}`);
if (t.underline || t.strike) {
@@ -236,13 +239,9 @@ export function textDeclarations(t: TextProps): string[] {
function declarations(r: ResolvedStyle): string[] {
const { para: p } = r;
const out = textDeclarations(r.text);
const out = textDeclarations(r.text, true);
if (p.textAlign) out.push(`text-align: ${p.textAlign}`);
if (p.lineHeight) {
const n = parseFloat(p.lineHeight);
const scaled = Math.round(n * singleLineHeight(r.text.fontFamily) * 1000) / 1000;
out.push(`line-height: ${isNaN(n) ? p.lineHeight : scaled}`);
}
if (p.lineHeight) out.push(`--line-factor: ${p.lineHeight}`);
if (p.spaceBefore != null) out.push(`margin-top: ${p.spaceBefore}pt`);
if (p.spaceAfter != null) out.push(`margin-bottom: ${p.spaceAfter}pt`);
if (p.indent != null) out.push(`margin-left: ${p.indent}cm`);
+12 -4
View File
@@ -49,9 +49,11 @@
font-family: var(--font-serif);
/* 12pt matches odf-kit's Standard style and the toolbar's declared default. */
font-size: 12pt;
/* ODF/LibreOffice "single" spacing = the font's natural line height
(Liberation Serif ≈ 1.15× em), NOT 1.0× the font size like CSS unitless 1.
Keep this in sync with LINE_HEIGHT_RATIO in lineHeight.ts. */
/* "Single" spacing is the font's natural line height (Liberation Serif ≈ 1.15× em),
not CSS unitless 1. A paragraph's line spacing multiplies that, so the two ride as
separate variables and the block rule below is the one place they meet. */
--natural-line: 1.15;
--line-factor: 1;
line-height: 1.15;
color: var(--color-page-text);
@@ -392,7 +394,13 @@
(styles/styleSheet.ts → the #document-styles element Editor.svelte writes). */
font: inherit;
margin: 0;
line-height: 1.15;
}
/* The line box: the paragraph's spacing factor times its font's natural line height.
Either half may come from the style rule or, nearer, from the block's own attrs
(lineHeight.ts / blockFontSize.ts) — multiplying them here is what keeps both. */
.paper .tiptap :is(p, h1, h2, h3, h4, h5, li) {
line-height: calc(var(--line-factor) * var(--natural-line));
}
.paper .tiptap ul,