diff --git a/CHANGELOG.md b/CHANGELOG.md index e8cce35..ef11464 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/architecture/pagination.md b/docs/architecture/pagination.md index ba3d0db..2209579 100644 --- a/docs/architecture/pagination.md +++ b/docs/architecture/pagination.md @@ -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` diff --git a/src/lib/components/CLAUDE.md b/src/lib/components/CLAUDE.md index bff08b9..8ab5f55 100644 --- a/src/lib/components/CLAUDE.md +++ b/src/lib/components/CLAUDE.md @@ -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. diff --git a/src/lib/import/CLAUDE.md b/src/lib/import/CLAUDE.md index 36db226..693abc1 100644 --- a/src/lib/import/CLAUDE.md +++ b/src/lib/import/CLAUDE.md @@ -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 `` whose `` resolves to a sub-document with a `math:math` root (or holds one inline) → a `formula` node. Our own `` 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`. diff --git a/src/lib/styles/CLAUDE.md b/src/lib/styles/CLAUDE.md index 1954c5d..802c36b 100644 --- a/src/lib/styles/CLAUDE.md +++ b/src/lib/styles/CLAUDE.md @@ -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. diff --git a/tests/render-parity/README.md b/tests/render-parity/README.md index 6d849e8..5358b9d 100644 --- a/tests/render-parity/README.md +++ b/tests/render-parity/README.md @@ -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.