| 1 | package docs |
| 2 | |
| 3 | import ( |
| 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. |
| 40 | type 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. |
| 62 | func 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. |
| 125 | type 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 | |
| 132 | type 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. |
| 163 | const maxPreviews = 512 |
| 164 | |
| 165 | func 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. |
| 169 | func (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 | |
| 179 | func (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. |
| 213 | func (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. |
| 274 | const 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. |
| 282 | func (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. |
| 305 | type thumbView struct { |
| 306 | Bars []previewBar |
| 307 | W, H int |
| 308 | } |
| 309 | |
| 310 | func newThumbView(b []previewBar) thumbView { |
| 311 | return thumbView{Bars: b, W: int(export.PageWidthPt), H: int(export.PageHeightPt)} |
| 312 | } |
| 313 | |