From 6957f5682b32c8f8a7dd1f4ec1b2945b2c488da9 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Thu, 13 Aug 2026 19:15:28 +0200 Subject: [PATCH] fix: a style kerns where its own w:kern says so, not only the document's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Word kerns nothing unless w:kern names a size to start at, and the Office theme declares it on Title alone — but TextProps.kerning stored only the off state, and a style inherits its parent's, so a title under a document that kerns nothing could never turn it back on. Its 26pt line then ran 0.36mm over the text width and wrapped a word onto a second line where LibreOffice sets it in one (measured 151.64mm kerned against 151.98). Both states are stored now, and both formats read and write them. --- src/lib/export/odt.ts | 2 +- src/lib/import/docx.ts | 2 +- src/lib/import/odt.ts | 5 +-- src/lib/styles/CLAUDE.md | 12 ++++--- src/lib/styles/styleSheet.ts | 6 ++-- tests/unit/kerning.test.ts | 61 +++++++++++++++++++++++++++++++++++- 6 files changed, 75 insertions(+), 13 deletions(-) diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 7f88b88..35869ac 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -1385,7 +1385,7 @@ function ownStyleAttrs(style: { para: Record; text: Record 0 && (run.sizeHalfPt == null || run.sizeHalfPt >= kern); if (run.bold != null) out.bold = run.bold; if (run.italic != null) out.italic = run.italic; if (run.underline != null) out.underline = run.underline; diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index d24f28a..ddbd541 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -1181,8 +1181,9 @@ function textPropsFromOdf(props: PropMap, resolver: StyleResolver): TextProps { const spacing = lengthToPt(props['fo:letter-spacing']); if (spacing) out.letterSpacingPt = Math.round(spacing * 100) / 100; // Kerning is on unless the file says otherwise (probed), which is the browser default - // too — so only the "off" is worth carrying. - if (props['style:letter-kerning'] === 'false') out.kerning = false; + // too — but a style under one that turns it off has to say so to get it back. + const kerning = props['style:letter-kerning']; + if (kerning) out.kerning = kerning !== 'false'; const weight = props['fo:font-weight']; if (weight) out.bold = weight === 'bold' || parseInt(weight, 10) >= 600; const style = props['fo:font-style']; diff --git a/src/lib/styles/CLAUDE.md b/src/lib/styles/CLAUDE.md index 2a66222..7367626 100644 --- a/src/lib/styles/CLAUDE.md +++ b/src/lib/styles/CLAUDE.md @@ -12,14 +12,16 @@ 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 +- **Pair kerning** (`TextProps.kerning` → `font-kerning`) 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. + the corpus. **Both states are stored, per style**: a style inherits its parent's, so a + Title whose own `w:kern` names 14pt has to say so to overrule a document kerning nothing + (measured at 26pt: 0.34mm, which is the difference between a title of one line and of + two). A style set below its own threshold counts as off. Export writes the state it has — + ODF `style:letter-kerning`, DOCX `w:kern="1"` wherever kerning is on. A single **run** + overriding its style still 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 diff --git a/src/lib/styles/styleSheet.ts b/src/lib/styles/styleSheet.ts index 3e3bebf..272ca95 100644 --- a/src/lib/styles/styleSheet.ts +++ b/src/lib/styles/styleSheet.ts @@ -25,8 +25,8 @@ 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). + // Pair kerning. Both states are stored: a style inherits its parent's, so a heading + // whose file kerns needs an explicit `true` to overrule a document that does not. kerning?: boolean; bold?: boolean; italic?: boolean; @@ -236,7 +236,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.kerning != null) out.push(`font-kerning: ${t.kerning ? 'normal' : '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 index cffb1f9..2cea0da 100644 --- a/tests/unit/kerning.test.ts +++ b/tests/unit/kerning.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect } from 'vitest'; -import { styleCss, builtinStyleSheet } from '../../src/lib/styles/styleSheet'; +import { zipSync, strToU8 } from 'fflate'; +import { styleCss, builtinStyleSheet, resolveStyle } from '../../src/lib/styles/styleSheet'; +import { importDocx } from '../../src/lib/import/docx'; +import { buildDocx } from '../../src/lib/export/docx'; // ODF kerns by default and Word does not, so only the off state is ever stored. describe('pair kerning', () => { @@ -17,4 +20,60 @@ describe('pair kerning', () => { const heading = css.slice(css.indexOf('[data-style="Heading 1"]')); expect(heading.slice(0, heading.indexOf('}'))).toContain('font-kerning: none'); }); + + it('lets a style turn it back on under one that turns it off', () => { + const sheet = builtinStyleSheet(); + sheet.paragraph.Standard.text = { ...sheet.paragraph.Standard.text, kerning: false }; + sheet.paragraph.Title.text = { ...sheet.paragraph.Title.text, kerning: true }; + const css = styleCss(sheet); + const title = css.slice(css.indexOf('[data-style="Title"]')); + expect(title.slice(0, title.indexOf('}'))).toContain('font-kerning: normal'); + }); +}); + +// w:kern names the smallest size pair kerning applies to. A file that declares it on the +// Title style alone kerns the title and nothing else — LibreOffice sets that title 0.34mm +// narrower, which is a line break where the text fills its column. +const W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'; + +const STYLES = ` + + + + + + + + + +`; + +const docx = () => + zipSync({ + 'word/document.xml': strToU8(`` + + `Title` + + `small` + + `body`), + 'word/styles.xml': strToU8(STYLES), + }); + +describe('a Word style’s own w:kern', () => { + const sheet = importDocx(docx()).styles!; + + it('kerns a style set at or above the size it names', () => { + expect(sheet.paragraph.Title.text.kerning).toBe(true); + }); + + it('leaves the body and a style set below that size unkerned', () => { + expect(sheet.paragraph.Standard.text.kerning).toBe(false); + expect(resolveStyle(sheet, 'Small').text.kerning).toBe(false); + }); + + it('survives a round trip through the DOCX export', async () => { + const doc = importDocx(docx()); + const out = await buildDocx(doc.content as never, undefined, 'portrait', undefined, null, 'A4', doc.styles); + const again = importDocx(out).styles!; + expect(again.paragraph.Title.text.kerning).toBe(true); + expect(again.paragraph.Standard.text.kerning).toBe(false); + }); });