oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

1package docs
2
3import (
4 "context"
5 "errors"
6 "sync"
7
8 "github.com/carlosframework/docs/internal/export"
9)
10
11// The thumbnail on a document's row, and where it comes from.
12//
13// Spec decision 11 and §6.4 ask the document list for "real rendered
14// page-one thumbnails"; §11.4 rules that a raster thumbnail needs a
15// pipeline this app does not have. Both are honoured here rather than
16// one of them being quietly dropped. The geometry is real — it is the
17// PDF exporter's own layout, through export.PageOne, so a row shows
18// where the document's first page actually breaks its lines — and no
19// raster is involved, because at 26x34 px a US-Letter page is about
20// 23 points to the pixel and a glyph is a fraction of one. What is
21// drawn is where the text is, at the size the text is. When the layout
22// engine and internal/raster land (spec §13), this keeps its shape and
23// rasterizes what PageOne already reports.
24//
25// This replaces the empty box list.html reserved for it. That box was
26// deliberately not a fake — "reserved space, not a placeholder image"
27// — and the bar that had to be cleared to fill it was showing
28// something true about the document, not something plausible.
29
30// previewBar is one line of the first page, in PAGE POINTS — the same
31// coordinate space export.PageOne reports and the same one the SVG's
32// viewBox declares, so the browser does the scaling and this code does
33// no arithmetic that could disagree with the layout.
34//
35// Ints, not floats: they are written straight into an SVG attribute by
36// html/template, and a float would render in Go's shortest-round-trip
37// form (1e+06 for a large one), which is not SVG number syntax. Points
38// are fine enough at this scale — one point is a twenty-third of a
39// pixel in the rendered box.
40type previewBar struct {
41 X, Y, W, H int
42}
43
44// bars converts a laid-out first page into the rectangles that stand
45// in for its lines.
46//
47// The height of a bar is derived from the type size rather than from
48// the line box, so a heading's bar is proportionally taller than a
49// body line's, and minBarPt keeps a very small one from collapsing to
50// nothing. Both are legibility decisions and the only numbers here not
51// reported from the layout.
52//
53// Be honest about the scale they operate at. 34px for 792pt makes a
54// body line box 0.72px and its bar about 0.39px; a heading's is about
55// 0.54px. No renderer resolves that difference as "heavier" in any
56// literal sense — what actually reaches the eye is a band of sub-pixel
57// coverage, and headings read as slightly darker rows within it. It
58// works, checked in a browser at 1x and 2x against the seeded
59// documents, but the mechanism is antialiasing rather than a drawn
60// weight, and a future change here should be judged in a browser
61// rather than by the arithmetic.
62func bars(lines []export.LineBox) []previewBar {
63 const (
64 barOfSize = 0.66 // of the type size, roughly its cap height
65 minBarPt = 9.0
66 )
67 if len(lines) == 0 {
68 return nil
69 }
70 out := make([]previewBar, 0, len(lines))
71 for _, l := range lines {
72 h := l.Size * barOfSize
73 if h < minBarPt {
74 h = minBarPt
75 }
76 // Centred in its own line box, which is where the text it
77 // stands for sits.
78 y := l.Top + (l.Height-h)/2
79 out = append(out, previewBar{
80 X: int(l.Left + 0.5),
81 Y: int(y + 0.5),
82 W: int(l.Width + 0.5),
83 H: int(h + 0.5),
84 })
85 }
86 return out
87}
88
89// previews is the document list's thumbnail cache.
90//
91// IN MEMORY, AND NOT IN THE DATABASE — the one decision in this file
92// worth arguing with, so here is the argument. A previewBar carries no
93// characters: it is four numbers, and the document's words never leave
94// export.PageOne (see LineBox's own note). So this is not plaintext,
95// and writing it to SQLite would not put document text at rest.
96//
97// It is still not written down, for a reason that outlives that. Spec
98// §2 seals content at rest and §2's boundary test greps raw SQLite
99// bytes for a fixture's known plaintext. A preview column would be a
100// second thing to remember to seal when sealing lands, and that grep
101// could never catch a lapse, because a shape contains none of the
102// fixture's words: the check that guards the seal rule would pass
103// while the obligation went unmet. Keeping the cache in memory creates
104// no obligation to forget. §2.1's leak list already blesses the
105// running instance holding exactly this — item 1 names thumbnails as
106// what the trade buys.
107//
108// Freshness is by construction rather than by invalidation. The key is
109// the document's updated_at in Unix nanoseconds, so an edit makes the
110// stored entry unreachable without any writer having to remember to
111// evict it — and a write path added later cannot forget a call it does
112// not have to make. What it does rely on is that every content write
113// bumps updated_at; Store.ApplyUpdate's own comment now records that
114// as an invariant rather than leaving it as a habit.
115//
116// Two traps, both live:
117//
118// - Compare UnixNano, never time.Time with ==. A time read back
119// through the sqlite driver and one made with time.Now().UTC() are
120// never == even at the same instant, and a cache keyed on that
121// comparison silently never hits.
122// - Take the timestamp from the row the list query returned, never
123// from a *Document that has been through ApplyUpdate: that method
124// bumps the column, not the struct it was handed.
125type previews struct {
126 mu sync.Mutex
127 // by is keyed on Document.ID, and is bounded — see maxPreviews for
128 // how it is trimmed and why a wipe was the wrong answer.
129 by map[int64]previewEntry
130}
131
132type previewEntry struct {
133 updatedAtNanos int64
134 // bars is nil for a document with nothing on its first page, and
135 // for one whose structure would not read at this version. Both are
136 // settled answers meaning "draw nothing", and both are cached: an
137 // empty document must not be replayed on every list render, and a
138 // document whose log will not replay would otherwise be retried on
139 // every page view for as long as it stays broken, turning one bad
140 // row into a permanent error-log fountain.
141 //
142 // There is no separate "failed" flag. There was one, written and
143 // never read — get answered from bars being nil either way — so it
144 // recorded a distinction nothing acted on.
145 bars []previewBar
146}
147
148// maxPreviews bounds the map.
149//
150// Eviction is one arbitrary entry per insert, not a wipe. A wipe was
151// wrong in a way worth recording, because it only misbehaves at scale:
152// combined with thumbnailBudget, an instance whose listed documents
153// exceed this bound would drop everything, warm at most a budget's
154// worth on the next render, overflow, and drop it again — so every row
155// past the fortieth would draw the empty box on every render, for
156// ever. Evicting one keeps the map full of *something* and lets a
157// large list warm over successive renders.
158//
159// Arbitrary rather than least-recently-used on purpose: Go's map
160// iteration gives a usable arbitrary key for free, and at this size
161// the difference between evicting the coldest entry and evicting a
162// random one is not worth the bookkeeping an LRU needs.
163const maxPreviews = 512
164
165func newPreviews() *previews { return &previews{by: map[int64]previewEntry{}} }
166
167// get returns the cached bars for doc at its current updated_at, and
168// whether there was a usable entry.
169func (p *previews) get(id int64, updatedAtNanos int64) ([]previewBar, bool) {
170 p.mu.Lock()
171 defer p.mu.Unlock()
172 e, ok := p.by[id]
173 if !ok || e.updatedAtNanos != updatedAtNanos {
174 return nil, false
175 }
176 return e.bars, true
177}
178
179func (p *previews) put(id int64, updatedAtNanos int64, b []previewBar) {
180 p.mu.Lock()
181 defer p.mu.Unlock()
182 if _, replacing := p.by[id]; !replacing {
183 for len(p.by) >= maxPreviews {
184 for k := range p.by {
185 delete(p.by, k)
186 break
187 }
188 }
189 }
190 p.by[id] = previewEntry{updatedAtNanos: updatedAtNanos, bars: b}
191}
192
193// thumbnail lays out one document's first page and caches the result.
194// The caller has already missed the cache; thumbnailer is what checks
195// it, so this does not look again.
196//
197// It never returns an error. A document whose structure will not read
198// gets no thumbnail and the row renders the empty box, because the
199// list is the one page that must work and a single unreplayable
200// document turning it into a 500 would be a worse bug than the one it
201// is reporting.
202//
203// WHICH failures are remembered is the subtle part. A document whose
204// log is corrupt is remembered, so it is not retried on every page
205// view for as long as it stays broken. A request that was CANCELLED is
206// not: Store.JSON runs on the request's own context, so a viewer who
207// closes the tab during a cold render — up to a budget's worth of
208// replays — would otherwise stamp "no thumbnail" onto every document
209// still being laid out, and each would keep it until its next edit. A
210// timeout and a busy database are the same shape. Those say nothing
211// about the document, so they are logged at a lower level and left
212// uncached, and the next render simply tries again.
213func (h *handlers) thumbnail(ctx context.Context, doc *Document) []previewBar {
214 nanos := doc.UpdatedAt.UnixNano()
215
216 // remember records a settled "draw nothing" for this version. A
217 // context that is already done means the answer is about the
218 // request, not the document, so nothing is written down.
219 remember := func(what string, err error) []previewBar {
220 if ctx.Err() != nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
221 h.logger.Debug("docs: list thumbnail abandoned with the request", "doc_id", doc.ID, "stage", what, "err", err)
222 return nil
223 }
224 h.logger.Error("docs: "+what+" for a list thumbnail", "doc_id", doc.ID, "err", err)
225 h.previews.put(doc.ID, nanos, nil)
226 return nil
227 }
228
229 structure, err := h.store.JSON(ctx, doc)
230 if err != nil {
231 return remember("read structure", err)
232 }
233 // Deliberately no title. export.PageOne lays out whatever Title
234 // the Document carries, because the PDF exporter puts one at the
235 // top of its first page — a file that does not say which document
236 // it is, is worse than one that repeats a heading. A thumbnail is
237 // not a file: it is a picture of the paper, and views/show.html
238 // keeps the title off the paper entirely (it lives in the shell
239 // bar, chrome around the document rather than on it). Laying it in
240 // here would draw a heading the reader has never seen on the page,
241 // directly beside the very title it duplicates — and would give
242 // every empty document a one-bar preview of content it does not
243 // have.
244 parsed, err := export.Parse("", structure)
245 if err != nil {
246 return remember("parse structure", err)
247 }
248
249 b := bars(export.PageOne(parsed))
250 h.previews.put(doc.ID, nanos, b)
251 return b
252}
253
254// thumbnailBudget is how many documents one list render will lay out
255// on a cold cache before it stops and leaves the rest to fill in on
256// later renders.
257//
258// The list is the app's landing page and it has no pagination, so
259// without a bound the first render after a restart costs one page of
260// layout per document a person owns, serially, before a byte of HTML
261// is written. With one, a cold cache costs a bounded amount and warms
262// over the next few renders; a warm cache never reaches the bound at
263// all, because a hit does not spend from it.
264//
265// Measured rather than guessed, on the seeded sample documents
266// (2026-09-02, this machine): a miss is about 1.9ms — replay through
267// the snapshot and its tail, then one page of layout — and a hit is
268// far under that. 36 documents rendered cold in 68ms and warm in
269// 3.4ms. So this bound puts a cold landing page at roughly 75ms of
270// layout, which is a page load rather than a stall, and it is only
271// ever paid after a restart. Re-measure before raising it; the number
272// that matters is the miss, and it grows with a document's update log
273// between snapshots.
274const thumbnailBudget = 40
275
276// thumbnailer returns the function the row builders call for each
277// document, holding one render's budget between them.
278//
279// A cache hit costs nothing and spends nothing, which is what keeps a
280// warm list from ever reaching the bound: the budget exists for the
281// cold case, and the cold case is the only one that is slow.
282func (h *handlers) thumbnailer(ctx context.Context) func(*Document) thumbView {
283 budget := thumbnailBudget
284 return func(d *Document) thumbView {
285 if b, ok := h.previews.get(d.ID, d.UpdatedAt.UnixNano()); ok {
286 return newThumbView(b)
287 }
288 if budget <= 0 {
289 return newThumbView(nil)
290 }
291 budget--
292 return newThumbView(h.thumbnail(ctx, d))
293 }
294}
295
296// thumbView is what list.html's "thumb" template is handed: the bars,
297// and the viewBox they are drawn in.
298//
299// The viewBox travels with them rather than being written into the
300// template as a literal, so it is derived from the exporter's own page
301// constants — the SVG is the page at its own scale, and [docs-thumb]'s
302// 26x34 box does the whole reduction. A literal in the markup would be
303// a fourth place US-Letter is written down and the first one nothing
304// holds to the other three.
305type thumbView struct {
306 Bars []previewBar
307 W, H int
308}
309
310func newThumbView(b []previewBar) thumbView {
311 return thumbView{Bars: b, W: int(export.PageWidthPt), H: int(export.PageHeightPt)}
312}
313