oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

1# Plan 5 — the formatting bar, font options, and export
2
3Brief, from Paul, 2026-08-31: *"the maximally viable checklist — a
4formatting bar, some font options, at least PDF and Markdown export."*
5
6The spec already rules on all four (§11 Fonts/DOCX/PDF/Markdown, §13's
7sequencing item 7: "Markdown, then DOCX, then PDF"). This plan is that
8item, brought forward past DOCX — DOCX is import *and* export and needs
9the schema work §11.2 describes; Markdown and PDF need only what the
10CRDT already holds. Nothing here changes a spec ruling. Two places
11where this plan reads a ruling differently from a literal reading of
12§11 are recorded as D1 and D2 below, for the branch discussion to
13settle rather than for this plan to settle by itself.
14
15## What exists to build on
16
17- The document's truth is a `Y.XmlFragment` on root `prosemirror`
18 (`core/src/fragment.rs`), replayed server-side through wazero.
19 `fragment_html` and `fragment_text` are its two renderers today.
20- `internal/core` exposes that through a C ABI (`docs_core_html_len` /
21 `_read`, and the text pair). `abiVersion` is 2.
22- The browser editor is ProseMirror + Yjs, one committed bundle
23 (`fixtures/build-editor.mjs` → `internal/docs/static/editor.<hash>.js`).
24 `prosemirror-schema-basic` + `addListNodes` is the schema; `Mod-b` and
25 `Mod-i` are already bound to `toggleMark`, so bold and italic exist
26 today with no way to *see* that they exist.
27- Every user-visible string is a catalog key (`internal/docs/render.go`).
28
29## The shape of the work
30
31Seven tasks. 1–3 are server-side and touch none of the files the
32concurrent caret-placement branch touches; 5–6 touch the editor bundle
33and are deliberately sequenced after that branch merges.
34
35### Task 1 — the core renders a structured document, not only markup
36
37`fragment.rs` gains `fragment_json`, a third renderer over the same
38walk: ProseMirror's own JSON shape (`{"type":…,"attrs":…,"content":[…]}`,
39text nodes as `{"type":"text","text":…,"marks":[…]}`), hand-written with
40no serde dependency (the crate has none outside dev-dependencies, and
41this is forty lines of escaping).
42
43The ABI gains `docs_core_json_len` / `docs_core_json_read`, the same
44len-then-read pair the other two use, and `abiVersion` goes to 3.
45
46**Why a third renderer rather than parsing the HTML in Go.** Markdown
47and PDF both need structure — where a list ends, which run is bold,
48which paragraph is a heading. Go's standard library has no HTML parser,
49and `golang.org/x/net/html` is a new module dependency this repo does
50not have and cannot fetch offline. Re-deriving structure from our own
51markup would also mean two parsers to keep honest against one emitter.
52One structured export, two consumers in Go, is fewer moving parts than
53either alternative.
54
55**Unknown nodes still render their children** — `fragment.rs`'s module
56rule, and it holds here for the same reason: a reader (or an exporter)
57silently dropping text is data loss, not a degradation.
58
59### Task 2 — font and size as marks the core knows
60
61Five new marks, all attribute-free: `font_sans`, `font_mono` (serif is
62the document default and carries no mark), and `size_sm`, `size_lg`,
63`size_xl` (normal carries no mark).
64
65`fragment.rs`'s `MARKS` table gains them, emitting a `<span>` with a
66fixed class — `<span class="docs-font-sans">` — chosen by lookup from a
67compile-time table.
68
69**Attribute-free marks, not a `fontFamily` mark with an attribute.**
70`Store.HTML` returns `template.HTML` — markup trusted to be written out
71unescaped — and its doc comment says exactly what earns that trust:
72`fragment.rs` escapes every text run *and emits no attributes at all*.
73A mark carrying a value from the CRDT into attribute position would put
74client-supplied bytes inside a quoted attribute, which is a different
75escaping problem with a different set of ways to get it wrong (and the
76CRDT accepts arbitrary client-posted bytes — `POST /d/{sqid}/updates`
77does not go through ProseMirror's schema). A fixed set of marks whose
78class names are constants in this binary keeps the property that made
79the `template.HTML` return safe in the first place: no byte from a
80document ever reaches attribute position.
81
82The cost is that the set is closed — a new size is a core change and a
83new binary, not a document that names its own point size. For "some
84font options" that is the right trade; §14 is where an open-ended font
85model goes if it is ever wanted.
86
87### Task 3 — `internal/fonts`: the core set, embedded
88
89Liberation Serif, Sans and Mono, four faces each (regular, bold,
90italic, bold-italic), embedded with `//go:embed` and served at
91`/static/fonts/…` behind the same immutable caching the editor bundle
92uses.
93
94**D1 — Liberation, where §11.1 names Carlito and Caladea.** §11.1 asks
95for two things that are separable, and this plan does the first
96without the second. The first is *a curated core set, self-hosted, the
97same font programs on the screen and in server-side layout, so both
98measure identically* — that is what makes a PDF match what the writer
99saw, and it is met here. The second is *metric-compatible substitution
100for the proprietary faces a DOCX import names*: Calibri → Carlito,
101Cambria → Caladea. That mapping only has a job once DOCX import exists
102to name Calibri, and there is no DOCX import. Liberation is the same
103idea one family over (it is the metric-compatible set for Times New
104Roman, Arial and Courier New), it is OFL-licensed, and it is what is
105actually available to this build. When DOCX import lands, Carlito and
106Caladea join the payload; nothing here forecloses that.
107
108**woff2 is not in this plan.** §11.1 says woff2 and means it — the TTFs
109are roughly twice the bytes. Converting requires `woff2_compress` or
110fontTools, neither of which is available here, and hand-writing a
111Brotli-based woff2 encoder to save download bytes is not a good use of
112this branch. The faces are declared with `@font-face` and a browser
113fetches only the faces a page actually uses, so an unused face costs
114nothing; the conversion is a follow-up with a real trigger (the first
115time page weight is measured and this shows up in it).
116
117### Task 4 — `internal/pdf`: a real PDF writer
118
119Pure Go, no new module dependencies, `CGO_ENABLED=0` like everything
120else:
121
122- **A TrueType reader** (`head`, `hhea`, `maxp`, `hmtx`, `cmap` format
123 4, `loca`, `glyf`) for advance widths and glyph outlines.
124- **A subsetter**: the glyphs a document actually uses, with `glyf`,
125 `loca` and `hmtx` rebuilt and composite glyphs' components followed
126 so an accented character does not lose its accent.
127- **The writer**: objects, an xref table, Flate-compressed content
128 streams, and a Type0/CIDFontType2 font with Identity-H encoding plus
129 a `ToUnicode` CMap, so text in the exported PDF is selectable and
130 searchable rather than a picture of text.
131- **Layout** from the same page geometry the screen uses: US Letter,
132 1in margins, 12pt/1.6 body — the constants live in Go and
133 `internal/docs` has a test that fails if `views/show.html` stops
134 agreeing with them. §11.3's "the same box tree the screen renders"
135 is a claim about the *engine* the layout plan will build; until that
136 exists this is the honest version — one set of constants, asserted
137 equal in both places, and a note that they are asserted rather than
138 shared.
139
140**D2 — this is not the layout engine.** §13 says the layout engine
141cannot slip because pagination, PDF and thumbnails all fall out of it,
142and the engine is not built. What Task 4 builds is a line-breaker and a
143paginator for the document shapes that exist today (paragraphs,
144headings, lists, blockquotes, inline marks) — real text measurement
145from real font metrics, not an approximation, but it knows nothing
146about tables, images, floats or widow/orphan control. When the layout
147engine lands, this becomes its back end (the writer, the subsetter and
148the font work all survive) and the line-breaking half of it is
149replaced. Recording it here so a later reader does not mistake this for
150the engine and build the engine's other consumers on top of it.
151
152### Task 5 — the formatting bar
153
154`fixtures/toolbar.mjs`, a new module under the 300-line cap, wired into
155`editor-entry.js` in a few lines. Bold, italic, code; paragraph and
156headings 1–3; bullet and ordered lists; blockquote; undo and redo; a
157font-family control and a size control driving Task 2's marks.
158
159**Link is cut, on the same reasoning as D-marks-carry-no-value.** A
160`link` mark carries an `href`, and the read view renders server-side
161through `fragment.rs` — so supporting it means putting a value from the
162CRDT into attribute position, which is the one property Task 2 exists
163to preserve, plus a URL-scheme allowlist (`javascript:` is a URL).
164Neither is hard; both are decisions rather than rows in a table, and
165they are not decisions to make at the end of a branch. Note that
166`prosemirror-schema-basic` already *defines* the link mark, so a paste
167can still produce one today and `fragment.rs` already renders it as
168plain text — that asymmetry predates this branch and is unchanged by
169it.
170
171Chrome, so it lives outside `[docs-editor]` — ProseMirror's
172`{mount: el}` takes over everything inside that element. Buttons
173reflect the selection's active marks, targets are 24×24 (§10.3, WCAG
1742.2 AA, no exemptions), every label is a catalog key rendered
175server-side into the markup the way the save-status copy already is —
176the bundle carries no copy of its own.
177
178Each command dispatches one transaction, so one toolbar click costs one
179retained update, not several: `internal/core`'s `maxRetainedUpdates` is
1804096 with no compaction yet, and toolbar-driven editing spends it
181faster than typing does.
182
183### Task 6 — the export routes and where a person clicks them
184
185`GET /d/{sqid}/export.md` and `GET /d/{sqid}/export.pdf`, both inside
186the `RequireSession` group and both owner-scoped through the same
187`Store.Get` every other document route uses — a document someone else
188owns 404s identically to one that does not exist (§4.2), with no
189separate check that could 403 by mistake.
190
191`Content-Disposition: attachment` with a filename derived from the
192document title, sanitised to a conservative set and falling back to the
193sqid — a title is arbitrary user text and a header is the wrong place
194to find that out.
195
196The controls go in `show.html`'s page header next to sign-out.
197
198### Task 7 — the gate
199
200`.amadan/ci.d/10-gate` already covers everything this adds (Rust tests,
201Go tests, the bundle-freshness check, gofmt). What this task adds is
202the tests themselves: fragment JSON fixtures on the Rust side, Markdown
203golden files, a PDF that is parsed back and asserted structurally
204(pages, fonts embedded, text extractable) rather than byte-compared,
205the geometry agreement test, and a toolbar test under `node --test` for
206whatever logic can be tested without a DOM.
207
208## What executing this actually found
209
210**The editor stopped working entirely, and nothing caught it.** Between
211Task 5 being written and Task 5 being finished, typing did nothing and
212every toolbar command did nothing — no error in the page, no error in
213the server log, a green Go suite, and a screenshot that looked right,
214because the bar renders correctly and the document looks untouched when
215it *is* untouched.
216
217The cause: `y-prosemirror`'s exported `undo` ignores its `dispatch`
218argument and undoes immediately. A ProseMirror command's contract is
219that a null dispatch performs nothing and only reports whether it
220could, and that is exactly how a toolbar decides whether to grey a
221button out — on every state update, because that is when the answer
222changes. So the bar asked "could I undo?" after every change and the
223library answered by undoing it. `undoCommand`/`redoCommand` are the
224library's own dispatch-aware pair.
225
226This is the shape AGENTS.md already warns about — a tool that answers
227confidently rather than failing — so it is recorded there too.
228
229**The gate that found it did not exist, so it does now.**
230`scripts/browser-drive.sh` + `fixtures/browser-drive.mjs` drive the real
231editor in a real headless Chrome over the DevTools protocol, with real
232input events (a synthetic DOM Range is not the selection ProseMirror
233reads, and an `element.click()` skips the `mousedown` the toolbar
234depends on). Twelve checks; it was verified to FAIL on the broken build
235before being kept, which is the only way to know a new test can tell
236"no" from "I can't see it". It is not in `.amadan/ci.d/10-gate`: it needs
237a Chrome on the runner, and that is a decision for whoever owns the
238runner rather than a side effect of this branch. Spec §12 asks for this
239gate; this is the first half of it.
240
241## Risks
242
243- **The bundle is a single committed artifact and only one may exist**
244 (`assets.go` panics at init otherwise). This branch rebuilds it once,
245 at the end, after the concurrent caret-placement branch has merged —
246 a rebuild is not hand-mergeable.
247- **`maxRetainedUpdates` = 4096, no compaction.** Not this branch's to
248 fix, made worse by this branch. Task 5's one-transaction-per-command
249 rule is the mitigation available without opening compaction.
250- **PDF is the long pole.** If it does not finish, Markdown, the
251 toolbar and the fonts still ship on their own and the branch is
252 still worth merging; the reverse is not true, so PDF goes last of
253 the server-side tasks and first of the ones that could be cut.
254