Files
edentext/docs/architecture/notes.md
T
Steffen Becker bf564fbab4 fix: footnote counting per page/chapter and collecting at the end really work
Both were selectable in the dialog and changed nothing — they only rode the
file formats, so an imported document numbered and placed its notes wrong.
noteLabels now counts within a bucket (class, chapter, or the page pageBreaks
publishes through setNoteAnchorPages); a collected footnote leaves position:
absolute and opens its own page, as LibreOffice does. Counting and position
move to the footnote tab, the only place LibreOffice offers them.

The separator's own alignment moves the line alone — probed, notes stay left.
2026-08-14 23:11:10 +02:00

149 lines
8.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Footnotes and endnotes
Covers `editor/extensions/notes.ts`, the reservation loop in `pageBreaks.ts`, the
`storage/noteSettings.ts` model, and the ODF/DOCX halves in `export/` and `import/`.
## Model
Three nodes. The anchor rides the text, the note text is a **block**, and every note
lives in the one `noteSection` at the document end (`Document` is
`'(block | textBox | columns)+ noteSection?'`).
| Node | Content | Attrs |
|---|---|---|
| `noteRef` | inline atom | `id`, `kind`, `text` (the resolved label, cached like a Word field's result) |
| `note` | `inline*` | `id`, `kind`, `label` (a mark the file set by hand), `text`, `styleName` |
| `noteSection` | `note+` | — |
**Why the note text is a top-level block and not inline content of its anchor.** Only
then does its DOM hang directly under `.tiptap`, which is `position: relative` — the same
document-px space `pageBreaks.ts` and `.band-layer` already compute in. Inline in the
paragraph, the containing block would be that paragraph, whose own `position: relative`
(the line-height shift) would have to be defeated with an `!important` special case, the
way `editor.css` does for a page-anchored frame. It also makes **endnotes nearly free**:
they simply stay in the flow at the document end.
A note holds one paragraph of inline content. An imported note of several paragraphs is
flattened to hard breaks, exactly as `convertHfZone` does for a header/footer zone.
**The note's own marker is a widget decoration, not generated content.** A CSS `::before`
draws no text node, so it reaches neither the PDF, the clipboard nor a `Range`
measurement — the index draws its leader dots as real characters for the same reason. It
is not document content either: there is nothing to edit, delete or serialize.
**A note carries the file's own paragraph style** as `styleName` → `data-style`, so the
document stylesheet gives it that file's font, size and colour; `editor.css` supplies
LibreOffice's 10pt only for a note that names none. Its **indent** is the exception: a
note style declares a hanging pair (`fo:margin-left` with a negative `fo:text-indent`) and
`ParaProps` has no first-line half, so applying the margin alone would move every note
right by the indent. The hanging pair therefore stays the editor's — see
`tests/render-parity/FINDINGS.md`.
**The sync plugin** (`appendTransaction`) keeps one note per anchor, in anchor order, and
renumbers each class. It runs in two rounds by design — structure first, numbering on the
pass after — because doing both at once would need positions out of a document the same
transaction is still rewriting. An anchor pasted twice gets a fresh id and a clone of the
note it was copied from; an anchor that ends up *inside* a note is dropped, and inserting
one there is refused (LibreOffice and Word refuse it too). A settings change is no
document change, so the dialog's path carries the `RESYNC_NOTES` meta.
**Restarting the count.** `noteLabels` counts within a bucket: the class alone (document-wide),
its chapter (`collectNoteRefs` counts the level-1 headings above each anchor), or its page —
footnotes only, as in LibreOffice, since the endnote list has no page of its own. The page is
the one thing numbering cannot see, so `pageBreaks.ts` publishes its `anchorPages` through
`setNoteAnchorPages` and fires `RESYNC_NOTES` when the map moves; the renumber may rewrap, so
it costs one more pass off the same `MAX_CONVERGE_PASSES` budget. The dialog shows counting
and position on the footnote tab only, which is where LibreOffice keeps them.
## Placement (`pageBreaks.ts`)
A footnote is out of the flow (`position: absolute`, `left`/`right` = the page margins)
and `pageBreaks.ts` gives it its `top`. A **collected** note stays in flow and the first one
carries `forceBreakBefore`, because LibreOffice opens a new page for the list (probed).
Which of the two a footnote is comes from `position: 'document'` → the `data-collect-footnotes`
attribute (`applyNoteVars`) → `editor.css`, and pagination reads it back off the note's own
computed `position` rather than the setting: the box is the truth either way.
The reservation runs **inside** one pagination pass — reading it back across passes would
break the file's own rule that a pass must not read its own last answer:
1. `collectLeaves` skips the notes that are out of flow and walks the section for the rest.
2. Each footnote's height is measured once: out of flow at a fixed width, it does not
depend on the page it lands on.
3. `placeLeaves(reserved)` takes a per-page reservation off each `contentEnd`. A bounded
loop (`MAX_NOTE_FIT_PASSES` = 3) re-runs it until the reservation and the pages its own
anchors land on agree.
4. The anchor's page comes from the leaf that holds it plus the breaks above it: every
break carries the leaf offset it applies from (`naturalY`), so an anchor below a line
split follows its half of the paragraph onto the next page.
5. The placements key includes each note's **doc position and top**: without the top a
frozen layout leaves every footnote on the page it used to belong to, without the
position an edit above the section leaves the decorations on a stale one.
**Deliberate ceilings:** a note is never split across pages — a page whose notes outgrow its
content area keeps one line of body text and lets them overflow, and splitting the note as
LibreOffice does is the upgrade path. Collected notes take **one** page in anchor order;
LibreOffice gives each class a page of its own, which needs a section order that is not the
anchor order the exports read. Both carry a `ponytail:` comment at the cap.
## LibreOffice's defaults, as probed
Read out of a document `soffice` saved from a two-note source. These are the editor's
defaults — an editor-only default would land in every imported file as direct formatting.
| | value |
|---|---|
| Footnote numbering | `1, 2, 3`, start 1, **restart: document-wide** (not per page), position: bottom of page |
| Endnote numbering | `i, ii, iii`, start 1 |
| `Footnote` / `Endnote` style | parent `Standard`, 10pt, `fo:margin-left` 0.6cm with `fo:text-indent` −0.6cm (hanging) |
| `Footnote anchor` / `Endnote anchor` | `style:text-position="super 58%"` |
| `Footnote Symbol` / `Endnote Symbol` | empty — the note's own marker is plain text on the baseline, only the anchor is raised |
| `style:footnote-sep` | width 0.5pt, `rel-width` 25%, `adjustment` left, solid, `#000000`, 0.1cm above and below |
Rendered geometry from the same probe: the footnote area is carved out of the **bottom of
the content area**, above the bottom margin; the endnote list starts at the content top of
a fresh page.
**The separator is drawn a pixel wider than it measures.** 0.5pt is 0.66px, which Chromium
rounds away to nothing, so `editor.css` paints it at `max(1px, …)`. The reservation still
uses the true weight — the same thing a hairline border does.
The three separator lengths are registered with `@property … syntax: '<length>'`, so
`getComputedStyle` resolves them to px for the reservation; an unregistered custom
property reads back as the literal `"0.1cm"`. The dialog writes all six onto `:root`.
## ODF (`export/odt.ts`, `import/odt.ts`)
Export follows the text-box hoist: the anchor becomes an `FNT A{i}` sentinel run
(U+E017) and the note is re-emitted as its own top-level paragraph opening with
`FNT B{i}`, so its runs ride **every** existing pass — marks, character styles, links,
bookmarks, fields and formulas included. `applyNotes` runs last of the content.xml passes
and cuts each region back out into
```xml
<text:note text:id="ftn1" text:note-class="footnote">
<text:note-citation>1</text:note-citation>
<text:note-body><text:p text:style-name="Footnote">…</text:p></text:note-body>
</text:note>
```
`rewriteStylesXml` writes the six named styles, a `<text:notes-configuration>` per class,
and `<style:footnote-sep>` into the page layout. ODF counts `text:start-value` from 0, so
the first note carries `startAt - 1`. Import mirrors all of it;
`StyleResolver.noteSettings()` reads the configuration back.
## DOCX (`export/docx.ts`, `import/docx.ts`)
The `docx` package carries both parts natively (`footnotes`/`endnotes` maps plus
`FootnoteReferenceRun`/`EndnoteReferenceRun`), so no post-pack pass is needed for the
notes themselves — each class counts from 1, because each has its own part. Import parses
`word/footnotes.xml` and `word/endnotes.xml`, **skipping the entries that carry a
`w:type`**: those are Word's separator and continuation-separator notes, referenced from
nowhere. Word opens a note with its own marker run and a tab; the leading tab is stripped,
since the editor draws the marker and the indent from the note's own style.
The numbering settings ride `<w:footnotePr>`/`<w:endnotePr>` in `word/settings.xml` — a
post-pack pass beside `applyMirrorMarginsDocx`, as the package exposes neither. Word's
`eachSect` is ODF's `chapter`; the separator is not Word's to describe (it draws a fixed
one), so it keeps the editor's.