From c82a78cc6a57b1e8565221a892f98f54041f4abd Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Fri, 7 Aug 2026 23:26:13 +0000 Subject: [PATCH] feat: character spacing, and a line box that multiplies its two halves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/lib/editor/extensions/blockFontSize.ts | 7 +++---- src/lib/editor/extensions/lineHeight.ts | 14 +++++++------- src/lib/export/docx.ts | 2 ++ src/lib/export/odt.ts | 1 + src/lib/import/docx.ts | 1 + src/lib/import/docxStyles.ts | 2 ++ src/lib/import/odt.ts | 2 ++ src/lib/styles/styleSheet.ts | 21 ++++++++++----------- src/styles/editor.css | 16 ++++++++++++---- 9 files changed, 40 insertions(+), 26 deletions(-) diff --git a/src/lib/editor/extensions/blockFontSize.ts b/src/lib/editor/extensions/blockFontSize.ts index 555ec36..c92942a 100644 --- a/src/lib/editor/extensions/blockFontSize.ts +++ b/src/lib/editor/extensions/blockFontSize.ts @@ -47,12 +47,11 @@ export const BlockFontSize = Extension.create({ 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)}`; + // 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)}`, }; }, }, diff --git a/src/lib/editor/extensions/lineHeight.ts b/src/lib/editor/extensions/lineHeight.ts index 33c01b4..b35d38a 100644 --- a/src/lib/editor/extensions/lineHeight.ts +++ b/src/lib/editor/extensions/lineHeight.ts @@ -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 { @@ -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) => { 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}`, }; }, }, diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index 8af465e..5b21bcb 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -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 = {}; 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 = {}; diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 5d9acc3..6ef2851 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -1153,6 +1153,7 @@ function ownStyleAttrs(style: { para: Record; text: Record= 600; const style = props['fo:font-style']; diff --git a/src/lib/styles/styleSheet.ts b/src/lib/styles/styleSheet.ts index 58431bf..fa081ba 100644 --- a/src/lib/styles/styleSheet.ts +++ b/src/lib/styles/styleSheet.ts @@ -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 = { 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`); diff --git a/src/styles/editor.css b/src/styles/editor.css index bdd53ac..ea33ed1 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -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,