docs: the band is measured, a manual break keeps its space, headings number

Corrects two probed claims that measurement contradicted: a hard page break
does *not* swallow the block's space above (only an automatic one does), and
the header band is max(declared height, content + gap) rather than a line
count. Adds the chapter-numbering model, the mirrored master pair, and the
harness ceiling a CSS-drawn label is.
This commit is contained in:
Steffen Becker committed 2026-09-04 19:30:06 +02:00
1 parent 80463883f0
commit 43d9a864d4
6 files changed
+63 -5

No files matched your search

+11
View File
@@ -193,6 +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)
- 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**
@@ -206,6 +207,16 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-15.
Deviations from Word/LibreOffice the browser does not let us remove. Anything
merely unimplemented belongs in the list above, not here.
- A chapter number and a list marker are drawn by `::before`, so they are not
part of the text and no text walk sees them — the render-parity harness reads
a numbered heading as if it were unnumbered. Root cause: CSS gives `::marker`
no position, and both products set the label at the level's own hanging
indent, so the marker has to be a generated box rather than a real one; a
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.
- 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
(794×1123 px for A4 — see `editor.css`, `pageBreaks.ts`), while Word and
+5 -2
View File
@@ -37,8 +37,11 @@ leave nothing to catch).
**A block's spacing is part of the block.** LibreOffice's paragraph frame includes the space
below it, so the fit test adds `leaf.spaceAfter` (and keep-with-next adds the successor's space
above too): a block whose text fits but whose spacing doesn't moves down whole. The **space
above** is then dropped at the page top, as LibreOffice does (probed: an automatic *and* a hard
break at a page top swallow it, the document's first block keeps it). `pageBreaks.ts` marks those
above** is then dropped at the page top where the *flow* had to break, as LibreOffice does — a
break the document asks for keeps it, and so does the document's first block. Probed against the
reference, page top at 20.01mm: a block with 6/20mm above lands at 26.00/40.01 after
`fo:break-before`, at 20.01 for either after an automatic break. Every chapter opening of a
book-style document is the manual case (measured: 4.23mm a page on one file). `pageBreaks.ts` marks those
blocks with a `padding-top:0;margin-top:0` node decoration — geometrically, a leaf pushed by its
own spacer never measures as sitting at the page top, since `effectiveTop` excludes that push, so
the mark follows from the break it got. The value itself rides `--space-before`
+14 -1
View File
@@ -339,10 +339,23 @@ reports on `pm-pagecount`. Section 1 is the app's six per-zone states, the rest
`extraHfSections` (bound up to `App`, persisted whole). Editing targets the section of the
page it starts on, so a double-click edits the zone under the pointer and the Layout-panel
buttons the current page's; `zoneKey`/`writeZone` route the one live editor's read and
write-back to either side. Page geometry is still document-wide.
write-back to either side. A section with page margins of its own puts its zones on
**those** (`marginsOf`), not the document's — a mirrored body after a wider front matter
otherwise drew its running head 9mm off the text it belongs over.
Word's "different first page" is per section, so a later section's first page shows its
own variant. `Editor.svelte` publishes each section's zone reaches as `--pb-section-reach`
("topFirst|topRest|bottomFirst|bottomRest" per section, comma-separated) and pageBreaks
resolves the content area per page from it — without it a tall letterhead on a later
section's first page would sit on top of the body (measured: 24.9mm).
**The band is measured, not counted.** The body's margin has to clear the height the zone
really renders at, which no line count can know — a wrapping line, a run smaller than the
12pt floor and the paragraph's own padding are all invisible to one. `HeaderFooterLayer`
lays every set's six zones out off-screen (`.hf-measure`, at the section's text width, a
`ResizeObserver` so a late web font re-reports) and hands the heights up as `zoneHeights`;
`hfReachPx` uses them and keeps its estimate only for the first frame. The measuring copy
carries `.hf-zone`, so `styleCss` gives it the document's own zone font — without that it
measures the editor's serif. The ODF zone's gap to the body and its padding ride the
collapsed paragraph's spacing (`convertHfZone`), which is where the export already puts
them, so the measurement sees them and the round trip is unchanged.
+12 -2
View File
@@ -32,7 +32,7 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt(
- **Content controls** (`inlineChildren`, DOCX): a `w:sdt` inside a paragraph — Word wraps every citation and bibliography entry in one — is walked through to its `w:sdtContent`, or the runs inside it never reach the paragraph (a fixture measured against LibreOffice lost every `[7]`-style reference and broke its lines one page short). The exception is one tagged `edentext-placeholder`: it survives whole as a `placeholderField` node, its export-minted gray stripped so the presentation color never becomes a mark. ODF's `text:placeholder` becomes the same node, the shown text's `<>`/`‹›` brackets stripped.
- **Formulas** (`convertFormulaFrame`): a `<draw:frame>` whose `<draw:object>` resolves to a sub-document with a `math:math` root (or holds one inline) → a `formula` node. Our own `<annotation encoding="application/x-tex">` is preferred; LibreOffice strips it, so the MathML is parsed back to LaTeX instead. **`display` comes from the paragraph** (`aloneInParagraph`), not the object — LibreOffice writes `display="block"` on every formula it re-saves. DOCX: `m:oMath`/`m:oMathPara` sit beside the `w:r` runs in their own namespace, so `convertInline` probes them ahead of its `namespaceURI !== W` guard (that guard silently dropped every Word formula). See `docs/architecture/formulas.md`.
- **Bookmarks & cross-references**: `text:bookmark-start`/`-end` (ODF) and `w:bookmarkStart`/`End` (DOCX) become a `bookmark` **mark** on the text they cover; the open ranges live in `Ctx` because Word writes them beside paragraphs as well as beside runs, and a range may close in a later paragraph. A **point** bookmark has no range to mark and is dropped, `_GoBack` with it. `text:bookmark-ref` and `REF`/`PAGEREF` fields (the `fldChar` machine and `w:fldSimple` alike, including a field Word left unresolved) become `crossRef` nodes carrying the producer's cached text. A `w:hyperlink w:anchor` with no `r:id` is an internal link → `#name`. See `docs/architecture/formatting.md`.
- **Page geometry is the *body's* master page**, not the one named `Standard`: the importer counts the blocks each `style:master-page-name` governs and re-reads geometry (and the app's own header/footer) from the winner. A document whose Standard master no paragraph references would otherwise take margins — and a footer band — no page of it has. **Naming a master breaks the page** (LibreOffice's "page break with page style") — probed: even a style naming the master the page already uses breaks, which is how a book starts each chapter fresh, so only the document's first block is exempt. The *section* (its own header/footer set) starts only where the master really changes. An empty `style:master-page-name=""` is LibreOffice's explicit "no change" and ends the parent-chain walk, so an ancestor's master can't break every child style. `fo:break-after="page"` becomes the next block's `breakBefore`, the only break the editor stores. A master with a `style:next-style-name` is itself the different-first-page flag: its own (possibly absent) zones govern page one.
- **Page geometry is the *body's* master page**, not the one named `Standard`: a pre-pass over the body's paragraphs (`dominantMasterPage`) counts the blocks each `style:master-page-name` governs and geometry comes from the winner — **before** the walk, since the text width it yields is what a table's margins, an inline image's fit and the tab-stop suppression measure against (17.0cm against a real 14.7 on one file when it was elected after). A document whose Standard master no paragraph references would otherwise take margins — and a footer band — no page of it has. **Naming a master breaks the page** (LibreOffice's "page break with page style") — probed: even a style naming the master the page already uses breaks, which is how a book starts each chapter fresh, so only the document's first block is exempt. The *section* (its own header/footer set) starts only where the master really changes. An empty `style:master-page-name=""` is LibreOffice's explicit "no change" and ends the parent-chain walk, so an ancestor's master can't break every child style. `fo:break-after="page"` becomes the next block's `breakBefore`, the only break the editor stores. A master with a `style:next-style-name` is itself the different-first-page flag: its own (possibly absent) zones govern page one — **unless the two name each other**. A `next-style-name` cycle is LibreOffice's Left Page / Right Page, one mirrored setup rather than a hand-over: the right master's geometry governs, the left master's zones become the even variant, and `style:page-usage="left"`/`"right"` marks the margins `mirrored` (a left layout's pair is stored swapped, since the editor keeps the odd page's). Reading it as a hand-over cost a book-style body its running head on every second page and its inner/outer margins with it.
- **A page-anchored frame** (`text:anchor-type="page"`) is out of the flow: it rides a paragraph of its own that collapses to nothing, and the node view places it from its page's corner (`anchorPage`, `image.ts`) — that paragraph must stay `position: static` (`editor.css`, `!important` over the line-height rule's own `position: relative`), or it becomes the frame's containing block instead of the page and offsets it by a page margin. Behind the text by default (`style:run-through="background"`); a cover page's own graphic instead declares `"foreground"` (`inFront` attr) to sit in front and hide what the page hoists behind it (a redundant logo, a title paragraph the graphic already carries). In a cell it falls back to an ordinary floating frame.
- **A header/footer zone is fitted to its schema** (`fitZoneSchema`): the zone editor has fewer marks and nodes than the body, and one it doesn't know makes `generateHTML` throw — which renders the whole zone blank. Unknown marks are dropped, unknown nodes leave their text.
- **An as-char text box is placed by its paragraph**, which is where its alignment stays — the box keeps only the `style:horizontal-pos` a band-wrapped frame declares of its own. Its `fo:padding` is read too — the editor's own 0.15cm ring is only the default.
@@ -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"` on the page layout 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 — `style:dynamic-spacing` changes neither side. 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 importer folds only `max(height, gap)` into the page margin, and the runtime reach (`Editor.svelte`'s `hfReachPx`) then adds the content but not the gap, counting paragraphs rather than rendered lines — so a wrapping multi-paragraph header comes out short (measured: 14.9mm on one file, three lines and 4.99mm of gap read as two lines and none). `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 — `style:dynamic-spacing` changes neither side. 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 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.
## Defaults on DOCX import
@@ -100,3 +100,13 @@ per-level attrs, and its definition lands in `sheet.list` (`listStyleFromDocx`).
Body text with no resolved font falls back to the *document's own theme minor font*
(`docx.ts` `runMarks`), not the editor default — Word's implicit body default. Headings don't
(they keep the editor heading default); DOCX page margins default to Word's 2.54cm.
- **Odd/even is per zone.** A master declaring only `style:footer-left` still repeats its
header on left pages; reading the pair as one flag blanked the running head on every
even page. A left element that is *present but empty* still blanks its zone.
- **The page-number format is the section's, not the document's.** `style:num-format` sits
on the page layout governing the body (`HfSet.pageNumberFormat`), which is how a roman
front matter precedes a decimal body; Word's is `w:pgNumType w:fmt` per section, and the
document's own comes from the **first** section, as the paper and the margins already do.
- **Chapter numbering** — `text:outline-style` on the ODF side, the heading styles'
`w:numPr` on Word's — becomes `StyleSheet.outline`; see `src/lib/styles/CLAUDE.md`.
+12
View File
@@ -136,3 +136,15 @@ holds it as a reactive singleton persisted to `edentext-styles` (same shape as `
selection"). Its preview shows the style with every area on, since it edits the definition
rather than one table's look. The
text fields are shared by all three families via `ownText`/`resolvedText`/`editText`.
- **Chapter numbering** (`outlineNumbering.ts`, `StyleSheet.outline`): one definition per
document, level 1 first — format, prefix, suffix, `displayLevels` (how many levels the
label shows) and start. `outlineCss` draws it with a counter per level that every
heading of that level increments and every deeper one resets, so `2.1.4` restarts with
its chapter; a heading in a cell, a list item or a frame is outside the count, as in
both products. The label is a `hN::before`, like the list marker — **no text walk can
read it**, so the contents rows carry their own copy (`outlineLabel`, counted the same
way in `tableOfContents.ts`). ODF keeps it in one `text:outline-style` beside the named
styles, Word as a multilevel numbering the heading styles point at (`w:numPr` on the
style, `w:pStyle` in the level) — the DOCX pass mints that numbering's `w:num` itself,
since no paragraph references it.
+9
View File
@@ -103,3 +103,12 @@ in both engines at the same word. So a long justified document reports a `lineBr
every dozen pages with no defect behind it — the browser simply fits marginally less
per line, worth about a quarter page over fifty. Left-align a passage before believing
a break difference is ours.
## What the harness cannot see
`extractLayout` walks **text nodes**, so anything drawn by CSS is invisible to it: a
list marker and a chapter number both ride `hN::before`, and a numbered heading is
therefore always reported as if it carried no number. Check those against the contents
rows instead, which hold the same label as real text, or against a screenshot
(`docs/headless-testing.md`). A `text-transform` is the case the harness *does* handle
— it reads the case the style paints, not the node's.