fix: a named style's line spacing is the percentage the file declares
The style-registry path divided an ODF fo:line-height by 1.15 where the block path, both DOCX paths and the export all treat it as the plain percentage, so every paragraph whose spacing came from its style rendered 15% too tight. Its suppression yardstick is the block's own style too, so a 100% under a 115% style stays as direct formatting.
This commit is contained in:
1 parent
a70df06fc1
commit
aff3eaa812
3 files changed
+85
-2
No files matched your search
@@ -8,6 +8,13 @@
|
||||
The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt(bytes)` and replaces the document via `setContent` (after a confirm when the current doc is non-empty), also adopting the file's page margins/orientation. It parses `content.xml`/`styles.xml` directly (fflate + `DOMParser`) rather than odf-kit's reader, which flattens cell blocks and drops row heights / list number formats.
|
||||
|
||||
- **`StyleResolver`** flattens ODF style indirection: named + automatic styles from both XML files, `style:parent-style-name` chains rooted in `style:default-style`, font-face declarations (`style:font-name` → family), `rel-column-width`/`column-width`, `min-row-height`, list styles, and page geometry. `fo:font-family` and `style:font-name` shadow each other across chain levels (`layerTextProps`). A **percentage `fo:font-size`** (`"130%"` — how LibreOffice defines its heading styles) is resolved against the inherited size while walking the chain (`resolvePercentSize`); without it the file's size is dropped and our default silently replaces it.
|
||||
- **LibreOffice's paragraph properties are not all inherited one by one.** Its two vertical
|
||||
margins live in one item, so a style declaring only `fo:margin-top` (or only `-bottom`) takes
|
||||
the **default style's** value for the other, never the parent chain's (`layerParaProps`;
|
||||
probed — it is what makes a heading whose style sets only `fo:margin-bottom` sit flush).
|
||||
`fo:line-height` is stored as the ODF percentage itself (115% → `1.15`, as both DOCX paths and
|
||||
the export use it) and suppressed against the block's own style, so a 100% under a 115% style
|
||||
stays as direct formatting.
|
||||
- **The yardstick is the style the block can name.** Only a **body** block carries a `styleName`, so a paragraph in a cell or a text box is measured against the *default* paragraph style instead of its own — otherwise everything its style provides is suppressed as "the style will supply it" and nothing ever does (a Word caption in a text box arrived without its italic or its colour).
|
||||
- **Default suppression** keeps round trips (editor → LibreOffice → editor) from accreting explicit attrs: values matching the editor's defaults (12pt body, `HEADING_STYLE_OVERRIDES` sizes/margins, **0 paragraph spacing**, list margin 0, Times New Roman/Liberation Serif, `#000000` text) are imported as `null`/no mark, with small tolerances for producer unit rounding (pt↔cm↔twips). This is why the editor's defaults must equal Word's/LibreOffice's (below) — an editor-only default is indistinguishable from a failed style resolution and silently lands in every imported document.
|
||||
- **Mapping:** `text:h` → heading (level clamped to `MAX_HEADING_LEVEL`), per-depth `text:list-level-style-*` defs decide bullet vs ordered and `listStyleType` (reverse lookup `orderedTypeFromFormat`), tables handle covered cells / `number-columns-repeated` / header rows / colspans / cell shading (`StyleResolver.cellBackgroundColor` → `backgroundColor`) / cell borders (`StyleResolver.cellBorders` + `borderAttrFromOdf` → the per-side border attrs) / table margins (`tableMargins` reads the table style's `fo:margin-*`, `style:width`/`style:rel-width` and `table:align` against `Ctx.contentWidthCm` — so a table narrower than the text width stays narrow instead of being stretched; DOCX does the same from `w:tblInd` + the `w:tblGrid`/`w:tblW` width), `text:s`/`text:tab`/`text:line-break` become spaces/`\t`/`hardBreak`, `draw:frame`/`draw:image` → an `image` node (`convertFrame`: bytes read from the zip's `Pictures/` entry into a data-URI, `svg:width`/`-height` cm → px, `svg:title` → `alt`, `draw:transform` rotate → `rotation`; a non-`as-char` anchor or a graphic style's `style:wrap` → floating `wrap` mode via `StyleResolver.graphicProps` — an **explicit `as-char` anchor is always inline**: LibreOffice's named Graphics style carries a `style:wrap` that would otherwise float every inherited frame), text fields keep their stored value. **Text boxes / shapes** (`convertDrawElement`): `draw:frame`+`draw:text-box` → a `textBox` node (`convertTextBoxFrame`: height = `fo:min-height` else `svg:height`; fill/stroke read straight from `graphicProps`' `draw:fill(-color)`/`draw:stroke`/`svg:stroke-*`, values equal to the editor defaults suppressed, none → explicit `null`); `draw:rect`/`draw:ellipse`/`draw:custom-shape` with a rect/round-rectangle/ellipse preset → a `textBox` with the matching `shapeKind` (`convertShape`; other presets → "Unsupported shapes were removed"). A box is a block node but its frame sits inside a `text:p`, so converted boxes ride `Ctx.pendingBlocks` and `convertBlocks` flushes them after the anchor paragraph (dropping our own export's empty anchor); in cells/other boxes their blocks are unwrapped in place with a warning. The DOCX importer mirrors all of this (`convertWpsShape` for DrawingML `wps:wsp` — probed before the image `a:blip` path, `convertPict` for legacy VML; Word's `mc:AlternateContent` uses only the `mc:Choice` branch so the VML fallback can't double-import).
|
||||
|
||||
+16
-2
@@ -860,6 +860,9 @@ type BlockDefaults = {
|
||||
marginTopPt: number;
|
||||
marginBottomPt: number;
|
||||
indentPt: number;
|
||||
// The named style's line spacing as an ODF factor; a block declaring the same one adds
|
||||
// nothing, but a block that declares 100% under a 115% style is direct formatting.
|
||||
lineHeight: number;
|
||||
boldByDefault: boolean;
|
||||
fonts: Set<string>;
|
||||
color: string;
|
||||
@@ -880,6 +883,13 @@ const FONT_TWINS: Record<string, string[]> = {
|
||||
'liberation sans': ['arial'],
|
||||
};
|
||||
|
||||
// fo:line-height as a factor; null for a length or `normal`, which is not a percentage.
|
||||
function linePercent(lh: string | undefined): number | null {
|
||||
if (!lh || !lh.endsWith('%')) return null;
|
||||
const p = parseFloat(lh);
|
||||
return Number.isFinite(p) ? Math.round(p) / 100 : null;
|
||||
}
|
||||
|
||||
function blockDefaults(resolver: StyleResolver, named: string | null, headingLevel: number | null, boldByDefault: boolean): BlockDefaults {
|
||||
const hdef = headingLevel != null ? HEADING_DEFAULTS[headingLevel - 1] : null;
|
||||
const fallback: BlockDefaults = {
|
||||
@@ -887,6 +897,7 @@ function blockDefaults(resolver: StyleResolver, named: string | null, headingLev
|
||||
marginTopPt: hdef ? hdef.marginTopPt : 0,
|
||||
marginBottomPt: hdef ? hdef.marginBottomPt : 0,
|
||||
indentPt: 0,
|
||||
lineHeight: 1,
|
||||
boldByDefault: headingLevel != null || boldByDefault,
|
||||
fonts: new Set(headingLevel != null ? DEFAULT_HEADING_FONTS : DEFAULT_FONTS),
|
||||
color: '#000000',
|
||||
@@ -911,6 +922,7 @@ function blockDefaults(resolver: StyleResolver, named: string | null, headingLev
|
||||
marginTopPt: lengthToPt(para['fo:margin-top']) ?? 0,
|
||||
marginBottomPt: lengthToPt(para['fo:margin-bottom']) ?? 0,
|
||||
indentPt: lengthToPt(para['fo:margin-left']) ?? 0,
|
||||
lineHeight: linePercent(para['fo:line-height']) ?? 1,
|
||||
boldByDefault: weight ? weight === 'bold' || parseInt(weight, 10) >= 600 : fallback.boldByDefault,
|
||||
fonts,
|
||||
color: (text['fo:color'] && normalizeColor(text['fo:color'])) || fallback.color,
|
||||
@@ -946,9 +958,11 @@ function paraPropsFromOdf(props: PropMap): ParaProps {
|
||||
if (mt != null) out.spaceBefore = snapPt(mt);
|
||||
if (mb != null) out.spaceAfter = snapPt(mb);
|
||||
if (ml != null) out.indent = Math.round(ml * 100) / 100;
|
||||
// The ODF percentage itself, as everywhere else in the model (blockAttrs, both DOCX
|
||||
// paths, and the export, which writes it straight back out as a percentage).
|
||||
const lh = props['fo:line-height'];
|
||||
if (lh && lh.endsWith('%')) {
|
||||
const mult = parseFloat(lh) / 100 / LINE_HEIGHT_RATIO;
|
||||
const mult = parseFloat(lh) / 100;
|
||||
if (Number.isFinite(mult)) out.lineHeight = String(Math.round(mult * 100) / 100);
|
||||
}
|
||||
const bg = props['fo:background-color'];
|
||||
@@ -1150,7 +1164,7 @@ function blockAttrs(paraProps: PropMap, textProps: PropMap, defaults: BlockDefau
|
||||
}
|
||||
if (mult != null) {
|
||||
mult = Math.round(mult * 100) / 100;
|
||||
if (Math.abs(mult - 1) > 0.01) attrs.lineHeight = String(mult);
|
||||
if (Math.abs(mult - defaults.lineHeight) > 0.01) attrs.lineHeight = String(mult);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { zipSync, strToU8 } from 'fflate';
|
||||
import { importOdt } from '../../src/lib/import/odt';
|
||||
|
||||
// Probed against LibreOffice with the OPG fixture's own styles: it holds a paragraph's
|
||||
// two vertical margins in one item, so a style declaring just one of them takes the
|
||||
// *default* style's value for the other instead of the parent chain's.
|
||||
const NS =
|
||||
'xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" ' +
|
||||
'xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" ' +
|
||||
'xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" ' +
|
||||
'xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"';
|
||||
|
||||
// `Normal` sets both margins and 115% spacing; the block's automatic style overrides one.
|
||||
function odt(autoProps: string): Uint8Array {
|
||||
const styles = `<?xml version="1.0"?><office:document-styles ${NS}><office:styles>
|
||||
<style:default-style style:family="paragraph">
|
||||
<style:paragraph-properties fo:margin-bottom="8pt"/>
|
||||
<style:text-properties fo:font-size="12pt" fo:font-family="Liberation Serif"/>
|
||||
</style:default-style>
|
||||
<style:style style:name="Standard" style:family="paragraph"/>
|
||||
<style:style style:name="Normal" style:display-name="Normal" style:family="paragraph">
|
||||
<style:paragraph-properties fo:margin-top="20pt" fo:margin-bottom="12pt" fo:line-height="115%"/>
|
||||
</style:style>
|
||||
</office:styles></office:document-styles>`;
|
||||
const content = `<?xml version="1.0"?><office:document-content ${NS}><office:automatic-styles>
|
||||
<style:style style:name="P1" style:parent-style-name="Normal" style:family="paragraph">
|
||||
<style:paragraph-properties ${autoProps}/>
|
||||
</style:style>
|
||||
</office:automatic-styles><office:body><office:text>
|
||||
<text:p text:style-name="P1">Absatz</text:p>
|
||||
</office:text></office:body></office:document-content>`;
|
||||
return zipSync({ 'content.xml': strToU8(content), 'styles.xml': strToU8(styles) });
|
||||
}
|
||||
|
||||
const block = (autoProps: string) =>
|
||||
(importOdt(odt(autoProps)).content.content?.[0] as { attrs: Record<string, unknown> }).attrs;
|
||||
|
||||
describe('ODF paragraph property inheritance', () => {
|
||||
it('drops the inherited margin the other half of the pair replaced', () => {
|
||||
// Declaring only the top margin resets the bottom one to the default style's 8pt…
|
||||
expect(block('fo:margin-top="2.4pt"')).toMatchObject({ spaceBefore: 2.4, spaceAfter: 8 });
|
||||
// …and the other way round; the parent's 20pt does not survive.
|
||||
expect(block('fo:margin-bottom="6pt"')).toMatchObject({ spaceBefore: 0, spaceAfter: 6 });
|
||||
});
|
||||
|
||||
it('keeps both when the style declares neither', () => {
|
||||
// Nothing overrides the pair, so both stay with the style and the block adds nothing.
|
||||
const attrs = block('fo:text-align="center"');
|
||||
expect(attrs.spaceBefore).toBeUndefined();
|
||||
expect(attrs.spaceAfter).toBeUndefined();
|
||||
expect(importOdt(odt('fo:text-align="center"')).styles.paragraph['Normal']?.para)
|
||||
.toMatchObject({ spaceBefore: 20, spaceAfter: 12 });
|
||||
});
|
||||
|
||||
it('reads a line spacing as the ODF percentage, against the style it overrides', () => {
|
||||
expect(importOdt(odt('fo:text-align="center"')).styles.paragraph['Normal']?.para.lineHeight).toBe('1.15');
|
||||
// 100% under a 115% style is direct formatting, not the editor's implicit default.
|
||||
expect(block('fo:line-height="100%"').lineHeight).toBe('1');
|
||||
expect(block('fo:line-height="115%"').lineHeight).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user