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:
1 parent
ab25abd492
commit
6957f5682b
6 files changed
+75
-13
No files matched your search
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user