Files
edentext/docs/architecture/notes.md
T
Steffen Becker bbbe72bd22 feat: a note renders in the file's own style, marker included
The marker moves from a CSS ::before to a widget decoration: generated
content is no text node, so it reached neither the PDF nor a measurement.
The note also carries the paragraph style its file names, so font, size and
colour are the document's; only the hanging indent stays the editor's,
because the style model has no first-line half to pair with the margin.
Adds a unit test for the numbering and the settings, plus the architecture
doc and the two open parity findings.
2026-08-13 12:17:45 +00:00

7.7 KiB
Raw Blame History

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.

Placement (pageBreaks.ts)

A footnote is out of the flow (position: absolute, left/right = the page margins) and pageBreaks.ts gives it its top. Endnotes stay in flow and the first one carries forceBreakBefore, because LibreOffice starts the endnote list on a new page (probed).

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 footnote notes and walks the section for the endnotes.
  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. prevPlacementsKey includes the note tops, or a frozen layout would leave every footnote on the page it used to belong to.

Deliberate ceiling: 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. Splitting the note, as LibreOffice does, is the upgrade path (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

<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.