oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

Plan 5 — the formatting bar, font options, and export

Brief, from Paul, 2026-08-31: "the maximally viable checklist — a formatting bar, some font options, at least PDF and Markdown export."

The spec already rules on all four (§11 Fonts/DOCX/PDF/Markdown, §13's sequencing item 7: "Markdown, then DOCX, then PDF"). This plan is that item, brought forward past DOCX — DOCX is import and export and needs the schema work §11.2 describes; Markdown and PDF need only what the CRDT already holds. Nothing here changes a spec ruling. Two places where this plan reads a ruling differently from a literal reading of §11 are recorded as D1 and D2 below, for the branch discussion to settle rather than for this plan to settle by itself.

What exists to build on

  • The document's truth is a Y.XmlFragment on root prosemirror (core/src/fragment.rs), replayed server-side through wazero. fragment_html and fragment_text are its two renderers today.
  • internal/core exposes that through a C ABI (docs_core_html_len / _read, and the text pair). abiVersion is 2.
  • The browser editor is ProseMirror + Yjs, one committed bundle (fixtures/build-editor.mjs → internal/docs/static/editor.<hash>.js). prosemirror-schema-basic + addListNodes is the schema; Mod-b and Mod-i are already bound to toggleMark, so bold and italic exist today with no way to see that they exist.
  • Every user-visible string is a catalog key (internal/docs/render.go).

The shape of the work

Seven tasks. 1–3 are server-side and touch none of the files the concurrent caret-placement branch touches; 5–6 touch the editor bundle and are deliberately sequenced after that branch merges.

Task 1 — the core renders a structured document, not only markup

fragment.rs gains fragment_json, a third renderer over the same walk: ProseMirror's own JSON shape ({"type":…,"attrs":…,"content":[…]}, text nodes as {"type":"text","text":…,"marks":[…]}), hand-written with no serde dependency (the crate has none outside dev-dependencies, and this is forty lines of escaping).

The ABI gains docs_core_json_len / docs_core_json_read, the same len-then-read pair the other two use, and abiVersion goes to 3.

Why a third renderer rather than parsing the HTML in Go. Markdown and PDF both need structure — where a list ends, which run is bold, which paragraph is a heading. Go's standard library has no HTML parser, and golang.org/x/net/html is a new module dependency this repo does not have and cannot fetch offline. Re-deriving structure from our own markup would also mean two parsers to keep honest against one emitter. One structured export, two consumers in Go, is fewer moving parts than either alternative.

Unknown nodes still render their children — fragment.rs's module rule, and it holds here for the same reason: a reader (or an exporter) silently dropping text is data loss, not a degradation.

Task 2 — font and size as marks the core knows

Five new marks, all attribute-free: font_sans, font_mono (serif is the document default and carries no mark), and size_sm, size_lg, size_xl (normal carries no mark).

fragment.rs's MARKS table gains them, emitting a <span> with a fixed class — <span class="docs-font-sans"> — chosen by lookup from a compile-time table.

Attribute-free marks, not a fontFamily mark with an attribute. Store.HTML returns template.HTML — markup trusted to be written out unescaped — and its doc comment says exactly what earns that trust: fragment.rs escapes every text run and emits no attributes at all. A mark carrying a value from the CRDT into attribute position would put client-supplied bytes inside a quoted attribute, which is a different escaping problem with a different set of ways to get it wrong (and the CRDT accepts arbitrary client-posted bytes — POST /d/{sqid}/updates does not go through ProseMirror's schema). A fixed set of marks whose class names are constants in this binary keeps the property that made the template.HTML return safe in the first place: no byte from a document ever reaches attribute position.

The cost is that the set is closed — a new size is a core change and a new binary, not a document that names its own point size. For "some font options" that is the right trade; §14 is where an open-ended font model goes if it is ever wanted.

Task 3 — internal/fonts: the core set, embedded

Liberation Serif, Sans and Mono, four faces each (regular, bold, italic, bold-italic), embedded with //go:embed and served at /static/fonts/… behind the same immutable caching the editor bundle uses.

D1 — Liberation, where §11.1 names Carlito and Caladea. §11.1 asks for two things that are separable, and this plan does the first without the second. The first is a curated core set, self-hosted, the same font programs on the screen and in server-side layout, so both measure identically — that is what makes a PDF match what the writer saw, and it is met here. The second is metric-compatible substitution for the proprietary faces a DOCX import names: Calibri → Carlito, Cambria → Caladea. That mapping only has a job once DOCX import exists to name Calibri, and there is no DOCX import. Liberation is the same idea one family over (it is the metric-compatible set for Times New Roman, Arial and Courier New), it is OFL-licensed, and it is what is actually available to this build. When DOCX import lands, Carlito and Caladea join the payload; nothing here forecloses that.

woff2 is not in this plan. §11.1 says woff2 and means it — the TTFs are roughly twice the bytes. Converting requires woff2_compress or fontTools, neither of which is available here, and hand-writing a Brotli-based woff2 encoder to save download bytes is not a good use of this branch. The faces are declared with @font-face and a browser fetches only the faces a page actually uses, so an unused face costs nothing; the conversion is a follow-up with a real trigger (the first time page weight is measured and this shows up in it).

Task 4 — internal/pdf: a real PDF writer

Pure Go, no new module dependencies, CGO_ENABLED=0 like everything else:

  • A TrueType reader (head, hhea, maxp, hmtx, cmap format 4, loca, glyf) for advance widths and glyph outlines.
  • A subsetter: the glyphs a document actually uses, with glyf, loca and hmtx rebuilt and composite glyphs' components followed so an accented character does not lose its accent.
  • The writer: objects, an xref table, Flate-compressed content streams, and a Type0/CIDFontType2 font with Identity-H encoding plus a ToUnicode CMap, so text in the exported PDF is selectable and searchable rather than a picture of text.
  • Layout from the same page geometry the screen uses: US Letter, 1in margins, 12pt/1.6 body — the constants live in Go and internal/docs has a test that fails if views/show.html stops agreeing with them. §11.3's "the same box tree the screen renders" is a claim about the engine the layout plan will build; until that exists this is the honest version — one set of constants, asserted equal in both places, and a note that they are asserted rather than shared.

D2 — this is not the layout engine. §13 says the layout engine cannot slip because pagination, PDF and thumbnails all fall out of it, and the engine is not built. What Task 4 builds is a line-breaker and a paginator for the document shapes that exist today (paragraphs, headings, lists, blockquotes, inline marks) — real text measurement from real font metrics, not an approximation, but it knows nothing about tables, images, floats or widow/orphan control. When the layout engine lands, this becomes its back end (the writer, the subsetter and the font work all survive) and the line-breaking half of it is replaced. Recording it here so a later reader does not mistake this for the engine and build the engine's other consumers on top of it.

Task 5 — the formatting bar

fixtures/toolbar.mjs, a new module under the 300-line cap, wired into editor-entry.js in a few lines. Bold, italic, code; paragraph and headings 1–3; bullet and ordered lists; blockquote; undo and redo; a font-family control and a size control driving Task 2's marks.

Link is cut, on the same reasoning as D-marks-carry-no-value. A link mark carries an href, and the read view renders server-side through fragment.rs — so supporting it means putting a value from the CRDT into attribute position, which is the one property Task 2 exists to preserve, plus a URL-scheme allowlist (javascript: is a URL). Neither is hard; both are decisions rather than rows in a table, and they are not decisions to make at the end of a branch. Note that prosemirror-schema-basic already defines the link mark, so a paste can still produce one today and fragment.rs already renders it as plain text — that asymmetry predates this branch and is unchanged by it.

Chrome, so it lives outside [docs-editor] — ProseMirror's {mount: el} takes over everything inside that element. Buttons reflect the selection's active marks, targets are 24×24 (§10.3, WCAG 2.2 AA, no exemptions), every label is a catalog key rendered server-side into the markup the way the save-status copy already is — the bundle carries no copy of its own.

Each command dispatches one transaction, so one toolbar click costs one retained update, not several: internal/core's maxRetainedUpdates is 4096 with no compaction yet, and toolbar-driven editing spends it faster than typing does.

Task 6 — the export routes and where a person clicks them

GET /d/{sqid}/export.md and GET /d/{sqid}/export.pdf, both inside the RequireSession group and both owner-scoped through the same Store.Get every other document route uses — a document someone else owns 404s identically to one that does not exist (§4.2), with no separate check that could 403 by mistake.

Content-Disposition: attachment with a filename derived from the document title, sanitised to a conservative set and falling back to the sqid — a title is arbitrary user text and a header is the wrong place to find that out.

The controls go in show.html's page header next to sign-out.

Task 7 — the gate

.amadan/ci.d/10-gate already covers everything this adds (Rust tests, Go tests, the bundle-freshness check, gofmt). What this task adds is the tests themselves: fragment JSON fixtures on the Rust side, Markdown golden files, a PDF that is parsed back and asserted structurally (pages, fonts embedded, text extractable) rather than byte-compared, the geometry agreement test, and a toolbar test under node --test for whatever logic can be tested without a DOM.

What executing this actually found

The editor stopped working entirely, and nothing caught it. Between Task 5 being written and Task 5 being finished, typing did nothing and every toolbar command did nothing — no error in the page, no error in the server log, a green Go suite, and a screenshot that looked right, because the bar renders correctly and the document looks untouched when it is untouched.

The cause: y-prosemirror's exported undo ignores its dispatch argument and undoes immediately. A ProseMirror command's contract is that a null dispatch performs nothing and only reports whether it could, and that is exactly how a toolbar decides whether to grey a button out — on every state update, because that is when the answer changes. So the bar asked "could I undo?" after every change and the library answered by undoing it. undoCommand/redoCommand are the library's own dispatch-aware pair.

This is the shape AGENTS.md already warns about — a tool that answers confidently rather than failing — so it is recorded there too.

The gate that found it did not exist, so it does now. scripts/browser-drive.sh + fixtures/browser-drive.mjs drive the real editor in a real headless Chrome over the DevTools protocol, with real input events (a synthetic DOM Range is not the selection ProseMirror reads, and an element.click() skips the mousedown the toolbar depends on). Twelve checks; it was verified to FAIL on the broken build before being kept, which is the only way to know a new test can tell "no" from "I can't see it". It is not in .amadan/ci.d/10-gate: it needs a Chrome on the runner, and that is a decision for whoever owns the runner rather than a side effect of this branch. Spec §12 asks for this gate; this is the first half of it.

Risks

  • The bundle is a single committed artifact and only one may exist (assets.go panics at init otherwise). This branch rebuilds it once, at the end, after the concurrent caret-placement branch has merged — a rebuild is not hand-mergeable.
  • maxRetainedUpdates = 4096, no compaction. Not this branch's to fix, made worse by this branch. Task 5's one-transaction-per-command rule is the mitigation available without opening compaction.
  • PDF is the long pole. If it does not finish, Markdown, the toolbar and the fonts still ship on their own and the branch is still worth merging; the reverse is not true, so PDF goes last of the server-side tasks and first of the ones that could be cut.