# 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 **DOCX** export writes that style as the note paragraph's `w:pStyle` and defines it; only a note naming none falls to the stock `FootnoteText`/`EndnoteText`, whose 10pt the importer suppresses again. **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. **The affixes stay in the note area.** A configured prefix/suffix wraps the number in the note's own `text` only — the anchor keeps the bare number, which is where LibreOffice draws its Before/After text too (probed; Word has no such setting, so the DOCX carries none anywhere). **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. **Word appends the list to the last page instead**, so it ends a document with endnotes one page earlier than LibreOffice and than we do — measured by rendering one `.docx` in both, identical body text on every page before the notes. **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: ''`, 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 1 … ``` `rewriteStylesXml` writes the six named styles, a `` per class, and `` 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 ``/`` 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.