From 5059e971edea62f90cb1a365185dc6d83a581c60 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Thu, 3 Sep 2026 02:23:55 +0200 Subject: [PATCH] fix: a text box in a zone keeps its lines instead of vanishing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The zone is a one-paragraph document, so a box's paragraphs had nowhere to go and were dropped with the drawing — which took the band down with them: a letterhead whose logo box holds two empty spacer lines came out 7.6mm short on every page, while the same document's DOCX twin, where the converter had already flattened the box, was right. They become lines of the zone ahead of the paragraph that anchors it now, as that converter makes them. Fixture 48 → 44, and the two formats agree again. --- CHANGELOG.md | 9 ++++++--- src/lib/import/CLAUDE.md | 2 +- src/lib/import/odt.ts | 15 +++++++++++++++ 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef11464..007a49a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -181,7 +181,7 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-15. **Content an imported document loses** - Charts are **drawn** from the file (`import/chart.ts`: DrawingML `chartN.xml` and ODF `chart:chart`), but as a picture, not a chart object — a re-export carries the drawing and the numbers behind it are no longer editable. The same holds for an **EMF** metafile (`import/emf.ts`): it is drawn, but as the SVG picture it was rebuilt into, and only from the record set a plot consists of — a hatched brush, a clipping region or a rotated bitmap is skipped. **WMF/SVM** metafiles and OLE objects still keep their box and a placeholder label, and export writes that back out: WMF is a different (16-bit) record format, SVM is StarOffice-proprietary, and an OLE object cannot be rendered without its application -- Two drawings still drop with the "Drawings were removed" warning: a **text box in the header/footer** (the zone is a one-paragraph document, so no block node fits in it — whatever the box carried, a letterhead logo included, goes with it) and a **shape group** (ODF `draw:g`: unwrapping it would anchor every child on its own, and a box cannot be put at a free point — see the limitation below) +- A **text box in the header/footer** keeps its text but not its box: the zone is a one-paragraph document, so no block node fits in it and the box's paragraphs become lines of the zone ahead of the one that anchors it — which is what Word's own converter makes of the same file, and what keeps the band as tall as LibreOffice lays it out (measured: 7.6mm of band on one letterhead). A positioned box therefore reads in the zone's own flow rather than at its corner. A **shape group** still drops with the "Drawings were removed" warning (ODF `draw:g`: unwrapping it would anchor every child on its own, and a box cannot be put at a free point — see the limitation below) - A table of contents is **regenerated** on load, never read from the field's cached rows — so an index whose producer left headings out of its cache comes back listing every heading in the document. Reading the rows instead is the opposite of a live index; what the file says *about* the rows (depth, leader, tab stop, entry styles, page numbers) is read - A drawing tool: a freeform, a polygon or a connector **imports, draws and saves** (see below), but there is no way to author one here. A Word connector preset (`bentConnector3`) is also still dropped — Word resolves that geometry and writes no path for it @@ -193,7 +193,7 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-15. - Multi-document management: one document is open at a time, so there is no window list and no side-by-side compare - Grammar check: there is no offline engine small enough to bundle, and the ones that exist are servers - A vertical writing mode for the **page** (ODF `tb-rl` on the page layout): a text box can run its text top-to-bottom, the body cannot — pagination fills a page downwards. A ruby annotation's own alignment and position are not offered either; both products' defaults are what we write -- A chapter number's own label geometry: the number is drawn in front of its heading, not set at the level's `text:list-tab-stop-position` in a hanging indent. Where the label is wider than that stop and the heading is large, LibreOffice breaks the title onto a second line and we keep it on one — measured on a book-style document whose chapter openings then cost it two pages. A caption still numbers from the document rather than restarting per chapter (ODF `text:sequence` on an outline level) +- A chapter number whose label is **wider than its own tab stop**: it is set in the character style the file names, hung out of the level's indent and given that stop as its minimum width, but where the label overruns the stop LibreOffice advances to the paragraph's next tab stop (its own, else the 1.25cm grid) and CSS cannot round a box to a grid — so the title sits up to one step early. A caption still numbers from the document rather than restarting per chapter (ODF `text:sequence` on an outline level) - Password-protected ODT/DOCX; digital signatures. ODF encrypts each zip entry (AES-256-CBC, PBKDF2, the manifest carrying salt, IV and checksum), which WebCrypto can do; Word's is an OLE compound file we would have to write from scratch, so the two legs are nowhere near the same size **Out of scope for now** @@ -215,7 +215,10 @@ merely unimplemented belongs in the list above, not here. ProseMirror widget would put it in the document, where every caret, selection and export pass would have to step over it. The contents rows carry the label as real text (`outlineLabel`), which is the part that can be checked. - Noted 2026-09-02. + `getComputedStyle(el, '::before').content` returns the *specified* value + (`counter(edt-outline-1, decimal) ". "`), never the resolved number, so the + harness cannot synthesize the word from the page either. + Noted 2026-09-02, revised 2026-09-03. - Zoom 100% does not visually match Word/LibreOffice at 100% on the same screen. Root cause: the editor uses the browser's fixed 96 CSS DPI diff --git a/src/lib/import/CLAUDE.md b/src/lib/import/CLAUDE.md index 8380c77..f899b2b 100644 --- a/src/lib/import/CLAUDE.md +++ b/src/lib/import/CLAUDE.md @@ -49,7 +49,7 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt( ## Header/footer import -- **Import:** `StyleResolver.masterPageHF()` reads `style:header`/`style:footer` + the header/footer-style heights; `pageGeometry()` reconstructs the body margin (page margin + zone height + spacing) and `edgeDistancesCm()` returns the raw page margins as the zone distances. `style:page-usage="mirrored"` — or `"left"`/`"right"`, which is one side of a book and mirrors just as surely — marks the left/right pair as **inner/outer**, so an even page swaps them (`margins.mirrored`, set only when true). `style:writing-mode` starting `rl` is a right-to-left page (`rtl`); DOCX reads the section's `w:bidi` for the same thing. The band LibreOffice lays out is **`max(declared height, zone content + the zone's own gap)`**, and the body starts at the page margin plus it — unless the gap is **dynamic** (`style:dynamic-spacing="true"`, which is what LibreOffice writes for a Word header), where it is absorbed rather than added and the band is `max(height, content)`. Probed at min-height 7.51mm, gap 6.51mm, 12pt lines: `false` → 11.37 / 21.11 / 35.71mm for 1 / 3 / 6 lines, `true` → 7.51 / 14.60 / 29.21. Probed on a 20mm top margin, 10pt zone: one line + `fo:margin-bottom` 0 → body at 24.07mm, the same with 5mm → 29.07, three lines → 37.21, one line under `fo:min-height="20mm"` → 40.00 (the floor wins, the gap is not added on top). A zone paragraph that *wraps* counts as the lines it renders, and its own `fo:padding` counts too. The **last** paragraph's `fo:margin-bottom` is no part of the band, in either zone — probed: a header of one 10pt line with a 12mm bottom margin puts the body at the 6mm min-height, and a footer's is dropped the same way, while a margin *between* two of the zone's paragraphs counts in full (two lines 12mm apart → a 20.16mm band). The collapsed paragraph therefore carries only the margins between the source paragraphs, so a zone's own trailing margin does not survive a re-save; nothing renders differently for it. Missing that put a page's body 10.3mm low on every page of one file. The importer folds only `max(height, gap)` into the page margin — the floor it can know without laying the zone out — and the gap plus the zone's and the paragraphs' `fo:padding` join the collapsed paragraph's spacing on the body-facing side (`convertHfZone`'s `bandCm`), so the runtime's **measured** band (`src/lib/components/CLAUDE.md`) sees them; `max(margin, dist + measured)` is then LibreOffice's `rawTop + max(height, content + gap)` exactly. Verified against a copy of one file's own header: LibreOffice puts the body at 33.71mm, the editor at 33.79. `convertHfZone` → single-paragraph doc (extra paragraphs → hard breaks; a text-less zone that carries a background/border rule line is kept, box props merged with the bottom rule from the last paragraph); `convertInline(…, hfFields=true)` keeps `text:page-number`/`-count` as field nodes. A master page whose `style:next-style-name` points at *another* master governs one page only (the "different first page" idiom): its own zones become the first-page variants and the successor's the running ones — read as the running header instead, a title-page master repeats on every page of its section and its height pushes each block onto its own page. A zone's positioned (non-`as-char`) frame keeps its wrap and its `svg:x`/`svg:y` from the page corner: it is out of flow (a letterhead or full-page title background), so `HeaderFooterLayer` paints it per page behind the body and the zone's reach ignores it — inline it would reserve a page-tall line of body space. Text boxes there are still dropped with a warning. `hfIsEmpty` counts a rule-/shading-only paragraph as non-empty so it renders and exports. +- **Import:** `StyleResolver.masterPageHF()` reads `style:header`/`style:footer` + the header/footer-style heights; `pageGeometry()` reconstructs the body margin (page margin + zone height + spacing) and `edgeDistancesCm()` returns the raw page margins as the zone distances. `style:page-usage="mirrored"` — or `"left"`/`"right"`, which is one side of a book and mirrors just as surely — marks the left/right pair as **inner/outer**, so an even page swaps them (`margins.mirrored`, set only when true). `style:writing-mode` starting `rl` is a right-to-left page (`rtl`); DOCX reads the section's `w:bidi` for the same thing. The band LibreOffice lays out is **`max(declared height, zone content + the zone's own gap)`**, and the body starts at the page margin plus it — unless the gap is **dynamic** (`style:dynamic-spacing="true"`, which is what LibreOffice writes for a Word header), where it is absorbed rather than added and the band is `max(height, content)`. Probed at min-height 7.51mm, gap 6.51mm, 12pt lines: `false` → 11.37 / 21.11 / 35.71mm for 1 / 3 / 6 lines, `true` → 7.51 / 14.60 / 29.21. Probed on a 20mm top margin, 10pt zone: one line + `fo:margin-bottom` 0 → body at 24.07mm, the same with 5mm → 29.07, three lines → 37.21, one line under `fo:min-height="20mm"` → 40.00 (the floor wins, the gap is not added on top). A zone paragraph that *wraps* counts as the lines it renders, and its own `fo:padding` counts too. The **last** paragraph's `fo:margin-bottom` is no part of the band, in either zone — probed: a header of one 10pt line with a 12mm bottom margin puts the body at the 6mm min-height, and a footer's is dropped the same way, while a margin *between* two of the zone's paragraphs counts in full (two lines 12mm apart → a 20.16mm band). The collapsed paragraph therefore carries only the margins between the source paragraphs, so a zone's own trailing margin does not survive a re-save; nothing renders differently for it. Missing that put a page's body 10.3mm low on every page of one file. The importer folds only `max(height, gap)` into the page margin — the floor it can know without laying the zone out — and the gap plus the zone's and the paragraphs' `fo:padding` join the collapsed paragraph's spacing on the body-facing side (`convertHfZone`'s `bandCm`), so the runtime's **measured** band (`src/lib/components/CLAUDE.md`) sees them; `max(margin, dist + measured)` is then LibreOffice's `rawTop + max(height, content + gap)` exactly. Verified against a copy of one file's own header: LibreOffice puts the body at 33.71mm, the editor at 33.79. `convertHfZone` → single-paragraph doc (extra paragraphs → hard breaks; a text-less zone that carries a background/border rule line is kept, box props merged with the bottom rule from the last paragraph); `convertInline(…, hfFields=true)` keeps `text:page-number`/`-count` as field nodes. A master page whose `style:next-style-name` points at *another* master governs one page only (the "different first page" idiom): its own zones become the first-page variants and the successor's the running ones — read as the running header instead, a title-page master repeats on every page of its section and its height pushes each block onto its own page. A zone's positioned (non-`as-char`) frame keeps its wrap and its `svg:x`/`svg:y` from the page corner: it is out of flow (a letterhead or full-page title background), so `HeaderFooterLayer` paints it per page behind the body and the zone's reach ignores it — inline it would reserve a page-tall line of body space. A **text box** has no block node to live in, so its paragraphs become lines of the zone ahead of the one anchoring it (warned): Word's own converter flattens the same document the same way, and dropping it cost one letterhead 7.6mm of band on every page. `hfIsEmpty` counts a rule-/shading-only paragraph as non-empty so it renders and exports. ## Defaults on DOCX import diff --git a/src/lib/import/odt.ts b/src/lib/import/odt.ts index 644a291..3f96536 100644 --- a/src/lib/import/odt.ts +++ b/src/lib/import/odt.ts @@ -993,8 +993,23 @@ function convertHfZone(zoneEl: Element, ctx: Ctx, bandCm = 0, footer = false): H inline.push(...convertInline(p, ctx, ctx.resolver.paraTextProps(styleName), blockDefaults(ctx.resolver, null, null, false), true)); }; + // A text box in a zone has no block node to live in, so its paragraphs become lines of + // the zone ahead of the one anchoring it — which is what Word's own converter makes of + // the same document, and what keeps the band as tall as LibreOffice lays it out. + const boxLines = (p: Element) => { + for (const frame of Array.from(p.getElementsByTagNameNS(NS.draw, 'frame'))) { + const box = frame.getElementsByTagNameNS(NS.draw, 'text-box')[0]; + if (!box) continue; + ctx.warnings.add('Text boxes in headers or footers were flattened to text'); + for (const inner of Array.from(box.children)) { + if (inner.namespaceURI === NS.text && (inner.localName === 'p' || inner.localName === 'h')) addPara(inner); + } + } + }; + for (const child of Array.from(zoneEl.children)) { if (child.namespaceURI === NS.text && (child.localName === 'p' || child.localName === 'h')) { + boxLines(child); addPara(child); } else if (child.namespaceURI === NS.text || child.namespaceURI === NS.table) { // Lists/tables in headers are beyond the one-paragraph model — keep their text.