feat: pair kerning follows the file, not the browser
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.
This commit is contained in:
1 parent
b825e483ac
commit
361ce14864
8 files changed
+50
-1
No files matched your search
@@ -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 = {};
|
||||
|
||||
@@ -1319,6 +1319,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.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';
|
||||
|
||||
@@ -681,7 +681,9 @@ function stylePara(ctx: Ctx, id: string | null): ParaProps {
|
||||
// paragraph's formatting, so the document defaults are not its properties.
|
||||
function styleText(ctx: Ctx, id: string | null, own = false): TextProps {
|
||||
if (!id) return {};
|
||||
if (own) return runTextProps(ctx.styles.styleOwn(id));
|
||||
// A character style adds to the run it decorates, so its own chain saying nothing
|
||||
// about kerning means "unchanged", not Word's off.
|
||||
if (own) { const t = runTextProps(ctx.styles.styleOwn(id)); delete t.kerning; return t; }
|
||||
// A paragraph style naming no font uses the document's theme (minorHAnsi for body,
|
||||
// majorHAnsi for headings) — the family single line spacing is measured against.
|
||||
const run = ctx.styles.paragraphRun(id);
|
||||
@@ -696,6 +698,10 @@ function runTextProps(run: RunProps): TextProps {
|
||||
: run.font === 'Arial' ? 'Liberation Sans' : run.font;
|
||||
if (run.sizeHalfPt != null) out.fontSizePt = Math.round((run.sizeHalfPt / 2) * 10) / 10;
|
||||
if (run.spacingTwip) out.letterSpacingPt = Math.round((run.spacingTwip / 20) * 100) / 100;
|
||||
// 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;
|
||||
if (run.bold != null) out.bold = run.bold;
|
||||
if (run.italic != null) out.italic = run.italic;
|
||||
if (run.underline != null) out.underline = run.underline;
|
||||
|
||||
@@ -27,6 +27,7 @@ export type RunProps = {
|
||||
color?: string; // raw w:val hex (no #) or 'auto'
|
||||
sizeHalfPt?: number; // w:sz (half-points)
|
||||
spacingTwip?: number; // w:spacing (character spacing, twentieths of a point)
|
||||
kernHalfPt?: number; // w:kern: the smallest size pair kerning applies to (0/absent = never)
|
||||
font?: string; // explicit w:rFonts w:ascii/hAnsi
|
||||
fontTheme?: 'minor' | 'major'; // w:rFonts w:asciiTheme/hAnsiTheme → theme1.xml font
|
||||
highlightFill?: string; // text highlight: w:shd w:fill, or w:highlight's palette colour
|
||||
@@ -131,6 +132,7 @@ export function parseRunProps(rPr: Element | null | undefined): RunProps {
|
||||
case 'color': { const v = wVal(child); if (v) p.color = v; break; }
|
||||
case 'sz': { const n = parseInt(wVal(child) ?? '', 10); if (Number.isFinite(n)) p.sizeHalfPt = n; break; }
|
||||
case 'spacing': { const n = parseInt(wVal(child) ?? '', 10); if (Number.isFinite(n)) p.spacingTwip = n; break; }
|
||||
case 'kern': { const n = parseInt(wVal(child) ?? '', 10); if (Number.isFinite(n)) p.kernHalfPt = n; break; }
|
||||
case 'rFonts': {
|
||||
const f = child.getAttributeNS(W, 'ascii') ?? child.getAttributeNS(W, 'hAnsi');
|
||||
if (f) { p.font = f; break; }
|
||||
|
||||
@@ -1128,6 +1128,9 @@ function textPropsFromOdf(props: PropMap, resolver: StyleResolver): TextProps {
|
||||
if (size != null) out.fontSizePt = Math.round(size * 10) / 10;
|
||||
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;
|
||||
const weight = props['fo:font-weight'];
|
||||
if (weight) out.bold = weight === 'bold' || parseInt(weight, 10) >= 600;
|
||||
const style = props['fo:font-style'];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user