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) {