diff --git a/src/lib/import/CLAUDE.md b/src/lib/import/CLAUDE.md index 4733eaf..8b02d59 100644 --- a/src/lib/import/CLAUDE.md +++ b/src/lib/import/CLAUDE.md @@ -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). diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index b670ff3..c34c49b 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -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; color: string; @@ -880,6 +883,13 @@ const FONT_TWINS: Record = { '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); } } diff --git a/tests/unit/odf-para-inheritance.test.ts b/tests/unit/odf-para-inheritance.test.ts new file mode 100644 index 0000000..b3e8600 --- /dev/null +++ b/tests/unit/odf-para-inheritance.test.ts @@ -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 = ` + + + + + + + + + `; + const content = ` + + + + + Absatz + `; + return zipSync({ 'content.xml': strToU8(content), 'styles.xml': strToU8(styles) }); +} + +const block = (autoProps: string) => + (importOdt(odt(autoProps)).content.content?.[0] as { attrs: Record }).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(); + }); +});