LibreOffice opens a fresh page for it and we follow, so a Word-made PDF of a document with endnotes is one page shorter — measured by rendering one .docx in both.
156 lines
9.4 KiB
Markdown
156 lines
9.4 KiB
Markdown
# 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.
|
||
|
||
**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: '<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.
|