fix: a style kerns where its own w:kern says so, not only the document's

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.
This commit is contained in:
Steffen Becker committed 2026-08-13 19:15:28 +02:00
1 parent ab25abd492
commit 6957f5682b
6 files changed
+75 -13

No files matched your search

+1 -1
View File
@@ -1385,7 +1385,7 @@ function ownStyleAttrs(style: { para: Record<string, unknown>; text: Record<stri
text['style:font-size-complex'] = size;
}
if (t.letterSpacingPt) text['fo:letter-spacing'] = `${t.letterSpacingPt}pt`;
if (t.kerning === false) text['style:letter-kerning'] = 'false';
if (t.kerning != null) text['style:letter-kerning'] = String(t.kerning);
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 -1
View File
@@ -762,7 +762,7 @@ function runTextProps(run: RunProps): TextProps {
// Word kerns nothing unless w:kern names the size to start at, and a document that
// says so for headings only leaves body text unkerned (probed against LibreOffice).
const kern = run.kernHalfPt ?? 0;
if (!kern || (run.sizeHalfPt != null && run.sizeHalfPt < kern)) out.kerning = false;
out.kerning = kern > 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;
+3 -2
View File
@@ -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'];
+7 -5
View File
@@ -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
+3 -3
View File
@@ -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) {
+60 -1
View File
@@ -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 = `<?xml version="1.0"?>
<w:styles xmlns:w="${W}">
<w:docDefaults><w:rPrDefault><w:rPr><w:sz w:val="22"/></w:rPr></w:rPrDefault></w:docDefaults>
<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>
<w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/><w:basedOn w:val="Normal"/>
<w:rPr><w:kern w:val="28"/><w:sz w:val="52"/></w:rPr>
</w:style>
<w:style w:type="paragraph" w:styleId="Small"><w:name w:val="Small"/><w:basedOn w:val="Normal"/>
<w:rPr><w:kern w:val="28"/><w:sz w:val="20"/></w:rPr>
</w:style>
</w:styles>`;
const docx = () =>
zipSync({
'word/document.xml': strToU8(`<?xml version="1.0"?><w:document xmlns:w="${W}"><w:body>` +
`<w:p><w:pPr><w:pStyle w:val="Title"/></w:pPr><w:r><w:t>Title</w:t></w:r></w:p>` +
`<w:p><w:pPr><w:pStyle w:val="Small"/></w:pPr><w:r><w:t>small</w:t></w:r></w:p>` +
`<w:p><w:r><w:t>body</w:t></w:r></w:p></w:body></w:document>`),
'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);
});
});