diff --git a/src/lib/export/odt.ts b/src/lib/export/odt.ts index 74c98d5..0535b53 100644 --- a/src/lib/export/odt.ts +++ b/src/lib/export/odt.ts @@ -3058,6 +3058,16 @@ function rewriteStylesXml(odtBytes: Uint8Array, lang: { language: string; countr styles = styles.replace(/]*/, (m) => + m.replace(/fo:margin-(top|bottom|left|right)="([\d.]+)cm"/g, (_a, side, v) => + `fo:margin-${side}="${Math.max(0, round3(Number(v) - inset))}cm"`)); + } + // Page background and page border ride the page layout, exactly where LibreOffice // keeps them (probed). The border is offset from the text area by fo:padding. const decorProps = [ diff --git a/src/lib/import/styleResolver.ts b/src/lib/import/styleResolver.ts index 5d1ba94..5cf4a53 100644 --- a/src/lib/import/styleResolver.ts +++ b/src/lib/import/styleResolver.ts @@ -737,10 +737,14 @@ export class StyleResolver { if (!props) return null; const hf = this.masterPageHF(pageName); + // A page border + its fo:padding sit inside the margin and push the text in; the + // editor keeps text at the margin (like w:pgBorders offsetFrom="text"), so the + // shrunken ODF margins are read back grown by that inset (inverse of the export). + const inset = this.decorInsetCm(props); const cm = (attr: string, fallback: number, extra = 0) => { const v = lengthToCm(props.getAttributeNS(NS.fo, attr)); if (v == null) return fallback; - return Math.min(10, Math.max(0, Math.round((v + extra) * 100) / 100)); + return Math.min(10, Math.max(0, Math.round((v + extra + inset) * 100) / 100)); }; const margins: PageMargins = { top: cm('margin-top', 2.54, hf.header ? hf.headerExtraCm : 0), @@ -825,10 +829,20 @@ export class StyleResolver { edgeDistancesCm(): { top: number; bottom: number } | null { const props = this.pageLayoutEl()?.getElementsByTagNameNS(NS.style, 'page-layout-properties')[0] ?? null; if (!props) return null; + const inset = this.decorInsetCm(props); const cm = (attr: string) => { const v = lengthToCm(props.getAttributeNS(NS.fo, attr)); - return v == null ? null : Math.min(10, Math.max(0, Math.round(v * 100) / 100)); + return v == null ? null : Math.min(10, Math.max(0, Math.round((v + inset) * 100) / 100)); }; return { top: cm('margin-top') ?? 1.25, bottom: cm('margin-bottom') ?? 1.25 }; } + + // Border width + fo:padding of the page's own border, 0 without one — the amount ODF + // pushed the text in past the declared margin (pageGeometry adds it back). + private decorInsetCm(props: Element): number { + const border = props.getAttributeNS(NS.fo, 'border') ?? props.getAttributeNS(NS.fo, 'border-top'); + if (!border || border === 'none') return 0; + const widthPt = lengthToPt(border.trim().split(/\s+/)[0]) ?? 0; + return (lengthToCm(props.getAttributeNS(NS.fo, 'padding')) ?? 0) + (widthPt * 2.54) / 72; + } } diff --git a/src/lib/storage/CLAUDE.md b/src/lib/storage/CLAUDE.md index 66009e1..78bf651 100644 --- a/src/lib/storage/CLAUDE.md +++ b/src/lib/storage/CLAUDE.md @@ -16,7 +16,7 @@ - **`wordCompletion.ts`** / **`.svelte.ts`** — the word-completion settings *and* the collected words (`edentext-word-completion`), LibreOffice's defaults: on, no appended space, words from eight letters, 1000 of them. The list outlives the document that filled it, as LibreOffice's profile copy does; a reactive singleton, since the extension both reads it and adds to it on every keystroke while the AutoCorrect dialog edits it. - **`autoText.ts`** / **`.svelte.ts`** — the AutoText library (`edentext-autotext`, the key removed when empty): name, shortcut and the entry's HTML. App-level, not document-level, so no exporter looks at it. - **`tableOptions.svelte.ts`** — table number recognition (`edentext-number-recognition`, the key removed when off, as it is by default in LibreOffice). A reactive singleton like `trackChanges.svelte.ts`: the extension reads it on every transaction, both table surfaces flip it. It also holds the **number locale** — the document's language, which is what LibreOffice reads and writes a cell's number in — pushed by `App.svelte`'s language effect; the exporters take that locale from their own `language` argument instead, so they stay store-free. See `docs/architecture/tables.md`. -- **`pageDecor.ts`** — the page's own decoration (`edentext-page-decor`, the key removed when all three are off): background colour, a border around the text area at `paddingCm` from it, and a text watermark (LibreOffice's Format ▸ Watermark, field for field). All three ride the ODF page layout — the watermark as a `fontwork-plain-text` `` named `PowerPlusWaterMarkObject` in the **master page's header**, the only place LibreOffice reads one from (probed: hung off the master page directly it is dropped, and an undeclared `xmlns:draw` in styles.xml drops it too). Word keeps the background on `w:document` (plus `w:displayBackgroundShape`), the border in `w:sectPr` **after `w:pgMar`** — the child order is fixed, and ahead of the header references Word's reader loses those — and the watermark as the VML `_x0000_t136` shape, whose definition has to travel or LibreOffice cannot resolve it. `PageDecorLayer.svelte` draws border and watermark per page; the background is a `--color-page-bg` override on `.paper`. +- **`pageDecor.ts`** — the page's own decoration (`edentext-page-decor`, the key removed when all three are off): background colour, a border around the text area at `paddingCm` from it, and a text watermark (LibreOffice's Format ▸ Watermark, field for field). All three ride the ODF page layout — the watermark as a `fontwork-plain-text` `` named `PowerPlusWaterMarkObject` in the **master page's header**, the only place LibreOffice reads one from (probed: hung off the master page directly it is dropped, and an undeclared `xmlns:draw` in styles.xml drops it too). Word keeps the background on `w:document` (plus `w:displayBackgroundShape`), the border in `w:sectPr` **after `w:pgMar`** — the child order is fixed, and ahead of the header references Word's reader loses those — and the watermark as the VML `_x0000_t136` shape, whose definition has to travel or LibreOffice cannot resolve it. `PageDecorLayer.svelte` draws border and watermark per page; the background is a `--color-page-bg` override on `.paper`. ODF lays border + `fo:padding` out *inside* the page margin, shrinking the text area — the editor and Word (`offsetFrom="text"`) keep text at the margin — so the ODT export shrinks every `fo:margin-*` by border + padding and the import grows them back (`decorInsetCm`). - **`trackChanges.svelte.ts`** — whether the **document** records revisions (`edentext-record-changes`, the key removed when off, as it is by default in both word processors). It travels with the file — ODF `text:track-changes` on the change registry, Word `w:trackRevisions` in settings.xml (both probed against soffice) — so an import adopts the file's flag and a new document starts off; only the open document's state lives under the key. A reactive singleton because the extension reads it on every transaction while the Review tab flips it; the recorded changes themselves live in the document, on the marks. - **`lineNumbering.ts`** — numbers in the left margin (`edentext-line-numbering`, the key removed when off, as it is by default in both word processors). ODF keeps one `` in `office:styles`, Word a `` per `w:sectPr` (probed). `LineNumberLayer.svelte` measures the lines: CSS exposes no line boxes, so a Range per block is taken and its distinct client-rect tops are the lines. - **`foldMarks.ts`** — fold + punch marks in the left margin (`edentext-fold-marks`, the key removed when off; neither word processor prints any by default). The DIN 5008 positions (87/192mm folds, 148.5mm punch) are this module's constants; `FoldMarkLayer.svelte` draws them per page — the folds on **both** edges (both hands find the crease), the punch mark only left, where the pages are filed. They export as named lines (`EdenFoldMark*`) in the master page's header — the one place that repeats on every page, where LibreOffice's own DIN templates keep them — ODF as page-relative ``s, DOCX as VML ``s; both importers read the names back into the flag and never into the zone's content.