From 361ce14864f3c8e8274b699800ba9a2bc18f2d13 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Tue, 11 Aug 2026 07:23:20 +0000 Subject: [PATCH] feat: pair kerning follows the file, not the browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A browser always kerns; ODF kerns unless told not to and Word kerns only above the size w:kern names — nothing, in most files. Measured 5% on a kerning-heavy string, enough to break a line a word early throughout every Word document in the corpus. Carried on the style, since that is where w:docDefaults and the default paragraph style put it. --- src/lib/export/docx.ts | 4 ++++ src/lib/export/odt.ts | 1 + src/lib/import/docx.ts | 8 +++++++- src/lib/import/docxStyles.ts | 2 ++ src/lib/import/odt.ts | 3 +++ src/lib/styles/CLAUDE.md | 9 +++++++++ src/lib/styles/styleSheet.ts | 4 ++++ tests/unit/kerning.test.ts | 20 ++++++++++++++++++++ 8 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 tests/unit/kerning.test.ts diff --git a/src/lib/export/docx.ts b/src/lib/export/docx.ts index c0659a7..f3169f1 100644 --- a/src/lib/export/docx.ts +++ b/src/lib/export/docx.ts @@ -1114,6 +1114,9 @@ function paragraphStyleOf(style: Style): IParagraphStyleOptions { 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); + // Word kerns nothing unless a size to start at is named, so the on state is the one + // this side has to write — half a point, i.e. every run. + if (t.kerning !== false) run.kern = 1; if (t.bold != null) run.bold = t.bold; if (t.italic != null) run.italics = t.italic; if (t.underline) run.underline = {}; @@ -1147,6 +1150,7 @@ function characterStyleOf(style: Style): ICharacterStyleOptions { 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.kerning !== false) run.kern = 1; 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 4aef8ff..940b92c 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -1319,6 +1319,7 @@ function ownStyleAttrs(style: { para: Record; text: Record= 600; const style = props['fo:font-style']; diff --git a/src/lib/styles/CLAUDE.md b/src/lib/styles/CLAUDE.md index 88e568e..2a66222 100644 --- a/src/lib/styles/CLAUDE.md +++ b/src/lib/styles/CLAUDE.md @@ -12,6 +12,15 @@ built-ins (Standard → Heading → Heading 1–5 / Title / Subtitle, plus Quota (parent chain root-first, nearest wins, cycle-safe), `styleOrder`, and `styleCss`. `sheet.svelte.ts` holds it as a reactive singleton persisted to `odf-editor-styles` (same shape as `i18n.svelte.ts`). +- **Pair kerning** (`TextProps.kerning` → `font-kerning: none`) is where the two formats + disagree: ODF kerns unless `style:letter-kerning="false"`, Word kerns **nothing** unless + `w:kern` names the point size to start at — and a browser kerns always. Probed: the same + string runs 5% narrower kerned, which broke a line a word early on every Word document in + the corpus. So only the off state is stored (the editor saves `.odt`, so ODF wins the tie), + a DOCX run below its own `w:kern` threshold counts as off, and export writes `w:kern="1"` + wherever kerning is on. Document-wide only — `w:docDefaults` and the default paragraph + style are where files declare it; a single run overriding it needs a mark we don't have. + - **Assignment**: the `ParagraphStyle` extension adds a global `styleName` attr on paragraph/heading, rendered as `data-style`. `setParagraphStyle(name)` switches the node type when the style has an `outlineLevel` (heading) and **keeps hard formatting**, as in Word/LibreOffice; diff --git a/src/lib/styles/styleSheet.ts b/src/lib/styles/styleSheet.ts index 26c6949..3b04cf2 100644 --- a/src/lib/styles/styleSheet.ts +++ b/src/lib/styles/styleSheet.ts @@ -22,6 +22,9 @@ export type TextProps = { fontFamily?: string; fontSizePt?: number; letterSpacingPt?: number; // character spacing (Word's w:spacing, ODF fo:letter-spacing) + // Pair kerning. ODF has it on and Word off, so only `false` is ever stored — the + // editor saves .odt and follows ODF (which is also the browser default). + kerning?: boolean; bold?: boolean; italic?: boolean; underline?: boolean; @@ -230,6 +233,7 @@ export function textDeclarations(t: TextProps, asBlock = false): string[] { } if (t.fontSizePt != null) out.push(`font-size: ${t.fontSizePt}pt`); if (t.letterSpacingPt) out.push(`letter-spacing: ${t.letterSpacingPt}pt`); + if (t.kerning === false) out.push('font-kerning: none'); 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) { diff --git a/tests/unit/kerning.test.ts b/tests/unit/kerning.test.ts new file mode 100644 index 0000000..cffb1f9 --- /dev/null +++ b/tests/unit/kerning.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest'; +import { styleCss, builtinStyleSheet } from '../../src/lib/styles/styleSheet'; + +// ODF kerns by default and Word does not, so only the off state is ever stored. +describe('pair kerning', () => { + it('renders font-kerning only where a style turns it off', () => { + const sheet = builtinStyleSheet(); + expect(styleCss(sheet)).not.toContain('font-kerning'); + sheet.paragraph.Standard.text = { ...sheet.paragraph.Standard.text, kerning: false }; + expect(styleCss(sheet)).toContain('font-kerning: none'); + }); + + it('inherits the off state down the parent chain', () => { + const sheet = builtinStyleSheet(); + sheet.paragraph.Standard.text = { ...sheet.paragraph.Standard.text, kerning: false }; + const css = styleCss(sheet); + const heading = css.slice(css.indexOf('[data-style="Heading 1"]')); + expect(heading.slice(0, heading.indexOf('}'))).toContain('font-kerning: none'); + }); +});