oficina / docs Public

Merge branch 'list-thumbnails' into main

The document list shows each document's real page one

The 26x34 box reserved at the left of every row since Task 3 now holds
a rendering of the document's first page: the line boxes the PDF
exporter's own layout placed, drawn as SVG rects in page coordinates.
Real line breaking, real font metrics, real margins, real alignment.

export.PageOne runs the exporter's layout through a sink on drawLine,
bounded to one page, so the thumbnail cannot drift from the document
the PDF would produce — an estimator beside it would have been a
second opinion free to disagree, and disagreement is what the list
would have shown. Spec 11.4 is amended rather than ignored: at 26x34 a
page is ~23pt to the pixel, so a glyph is a fraction of one and
rasterizing would spend a whole pipeline to draw a smudge. What is
elided is the glyphs, not the layout, and internal/raster moves to
14's trigger list.

The title is deliberately not drawn — show.html keeps it off the paper
and a thumbnail is a picture of the paper. Cached in memory keyed on
updated_at nanoseconds, never in SQLite: a bar carries no characters,
but a column would be a second thing to seal later and the
plaintext-grep gate could never catch the lapse.

Reviewed whole-branch by Fable; three defects fixed, including a test
that could not fail and a cache wipe that combined with the render
budget to leave a large instance permanently cold.
claude agent, operated by paul@keymail.dev pushed by paul@keymail.dev 121de3fb81e370ecf8445b053b3937280cb7258c parent d20a813parent 1334f7a
12 files changed, +1198 −32
  • AGENTS.md +34 −0
  • docs/superpowers/specs/2026-08-30-docs-design.md +21 −2
  • internal/docs/app.go +1 −1
  • internal/docs/handlers.go +27 −6
  • internal/docs/list_test.go +210 −0
  • internal/docs/preview.go +312 −0
  • internal/docs/preview_internal_test.go +148 −0
  • internal/docs/store.go +12 −0
  • internal/docs/views/list.html +38 −7
  • internal/export/pageone.go +88 −0
  • internal/export/pageone_test.go +211 −0
  • internal/export/pdf.go +96 −16

CI passed — run details

diff --git a/AGENTS.md b/AGENTS.md
index b2580de..c019f14 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -359,6 +359,40 @@ put it back. `scripts/browser-drive.sh` was kept only after it was
confirmed to fail on the build whose bug it had just found — without
that step a green run means "passed" and "could not see" equally.
+**And it must fail for the right reason.** Watching it go red is not
+enough — read *why* it went red. Three of these were found in one
+evening on the `list-thumbnails` branch, in three different shapes:
+
+- A test that could not fail at all. `TestPageOneDoesNotDisturbPDFExport`
+ exported a document, ran the thumbnail layout, exported again and
+ compared bytes — but the two calls share no state, so only a
+ package-level global could ever have reddened it. It stayed green
+ while a deliberate `l.cursor += 100` inside the code it claimed to
+ guard reddened a different test. Two comments and a commit message
+ cited it as proof. Deleted.
+- A mutation that reddened the right test for the wrong reason. Proving
+ a canary test could fail, the injected leak used a template variable
+ that was not in scope, so the page 500ed and the test failed on its
+ status check without the canary ever firing — a red test proving
+ nothing. Redone with a leak that actually renders. **If your mutation
+ changes the response code, you have tested the mutation, not the
+ test.**
+- A hit test that measured something else. Recorded in `list.html`:
+ restated declarations "fixed" row clicking, and the probe was hitting
+ rastrillo's own overlay both before and after.
+
+The generalisation, and the question worth asking of any gate: **what
+would make this go red, and have I actually done it?** A CSS rule keyed
+on an attribute nothing writes is the same defect in another costume —
+it matches no element, so it is invisible to every test and every
+screenshot, and any bug inside it is unreachable. The Sheets session
+found one of those the same night, hiding a second defect behind it.
+Worth a grep when touching a stylesheet; not worth a permanent gate,
+because it has enough false positives (comments quoting old selectors,
+framework-emitted hooks) that people would learn to skim it — and a
+gate nobody reads is another probe that answers the same in both
+worlds.
+
**Do not pass on somebody else's finding as your own verification.** A
report from another session is evidence about what they saw, not a fact
you have checked, and relaying it flatly launders it into one. Say where
diff --git a/docs/superpowers/specs/2026-08-30-docs-design.md b/docs/superpowers/specs/2026-08-30-docs-design.md
index 665797b..7173fb4 100644
--- a/docs/superpowers/specs/2026-08-30-docs-design.md
+++ b/docs/superpowers/specs/2026-08-30-docs-design.md
@@ -937,6 +937,20 @@ scale, and PNG composition — a pipeline nothing else needs, since the PDF path
embeds fonts and never rasterizes. `internal/raster/` is a real component.
Thumbnails stay, because they are worth it (§6.4), but they are not free.
+**Amended 2026-09-02, after building them.** The conflation was real and the
+conclusion was half right. What this missed is the *scale*: the list's
+thumbnail is 26×34, so a US-Letter page is about 23 points to the pixel and a
+glyph is a fraction of a pixel wide. There is nothing to rasterize at that
+size — a rasterizer would spend the whole pipeline above to produce a grey
+smudge. So the shipped thumbnail draws the line boxes, and they are real
+ones: `export.PageOne` runs the PDF exporter's own layout, with its own font
+metrics and its own line breaking, and reports where every line of page one
+actually landed. Decision 11's "real rendered" is satisfied in the sense that
+matters — the row shows where this document's first page really breaks — and
+what is elided is the glyphs, not the layout. `internal/raster/` remains
+unbuilt and remains what a *larger* thumbnail would need; it moved from §13's
+sequence to §14's trigger list.
+
### 11.5 Blobs
**All blob storage goes through `blobs.Sealed`** (`blobs/blobs.go:218`); the
@@ -1024,11 +1038,15 @@ Corrected.
6. Sharing: grants, guests, link shares, the home outbox.
7. Markdown, then DOCX, then PDF.
8. Comments; version capture and restore; the outline pane.
-9. Thumbnails.
+9. ~~Thumbnails.~~ **Done 2026-09-02, and not the way this line assumed.**
+ They did not wait for the layout engine: the PDF exporter's layout was
+ enough to report where page one's lines fall, which at 26×34 is the whole
+ of what a thumbnail shows. See §11.4's amendment.
**Slip order, written rather than discovered:** the outline pane goes first,
then offline. **The layout engine cannot slip**, because pagination, PDF and
-thumbnails all fall out of it.
+thumbnails all fall out of it. *(Thumbnails turned out not to — see §11.4's
+amendment. Pagination and PDF still do.)*
---
@@ -1044,6 +1062,7 @@ thumbnails all fall out of it.
| Pageless mode | Long-document editing proves painful in page view |
| Embedded live Sheets ranges | Both apps stable, and the seam between two collaboration models designed deliberately |
| Templates | Somebody has made the same document three times |
+| Rasterized thumbnails (§11.4's `internal/raster/`) | A thumbnail is wanted at a size where glyphs are legible. The list already draws real page-one line boxes — §11.4 |
---
diff --git a/internal/docs/app.go b/internal/docs/app.go
index 757f415..581c7aa 100644
--- a/internal/docs/app.go
+++ b/internal/docs/app.go
@@ -401,7 +401,7 @@ func New(cfg Config) (*App, error) {
return nil, fmt.Errorf("docs: build auth: %w", err)
}
- h := &handlers{render: render, logger: logger, store: store, hub: newHub()}
+ h := &handlers{render: render, logger: logger, store: store, hub: newHub(), previews: newPreviews()}
r := chi.NewRouter()
// Mounted above every route, per rastrillo SKILL.md §5: refuses a
diff --git a/internal/docs/handlers.go b/internal/docs/handlers.go
index d7aceaa..85d655b 100644
--- a/internal/docs/handlers.go
+++ b/internal/docs/handlers.go
@@ -46,6 +46,11 @@ type handlers struct {
// sample seeding, which runs before any editor can be open.
hub *hub
+ // previews is the document list's thumbnail cache (preview.go).
+ // Here rather than on Store for the same reason hub is: Store is
+ // persistence, and a laid-out page of line boxes is presentation.
+ previews *previews
+
// streamHeartbeat and streamTTL override stream.go's own defaults.
// Zero means "use the default", so nothing but a test ever sets
// them — see heartbeatInterval/ttlInterval for why the seam
@@ -122,6 +127,13 @@ type docRow struct {
Href string
Title string
When string
+
+ // Thumb is the document's first page as line boxes (preview.go),
+ // drawn by list.html into the row's reserved 26x34 box. No bars
+ // means no thumbnail — an empty document, one whose structure
+ // could not be read, or one past a cold render's budget — and
+ // renders as the empty box the row has always shown.
+ Thumb thumbView
}
// owner resolves the signed-in person's Store.Person id for r —
@@ -146,10 +158,16 @@ func (h *handlers) owner(r *http.Request) (int64, error) {
// document. Cut, deliberately, not by oversight: no template
// carousel, no "Owned by anyone"/"Last opened by me" filter chrome, no
// grid/list/AZ/folder toggle, no commercial or platform chrome. The
-// row reserves a 26×34 slot at its left edge (list.html's
-// [docs-thumb]) for the page-one thumbnail a later plan's layout
-// engine renders there — reserved, not faked: today it is an empty,
-// bordered box.
+// row's 26×34 slot at its left edge (list.html's [docs-thumb]) now
+// holds a real rendering of the document's page one, drawn from the
+// PDF exporter's own layout through export.PageOne (preview.go). It
+// was reserved and deliberately empty until that was possible —
+// "reserved, not faked" — and the reservation did its job: the box is
+// the same box, so filling it reflowed nothing.
+//
+// What is drawn is where the text is, not the text: at this size a
+// glyph is a fraction of a pixel. Spec §11.4's raster pipeline is
+// still unbuilt and still what a larger thumbnail would need.
//
// A row shows title and when it was last touched, not who: List is
// already scoped to the signed-in viewer's own documents (Store.List's
@@ -197,11 +215,13 @@ func (h *handlers) list(w http.ResponseWriter, r *http.Request) {
}
now := time.Now().UTC()
data.Searching = true
+ thumb := h.thumbnailer(r.Context())
for _, d := range hits {
data.Results = append(data.Results, docRow{
Href: "/d/" + d.Sqid,
Title: d.Title,
When: formatWhen(now, d.UpdatedAt),
+ Thumb: thumb(d),
})
}
if err := h.render.page(w, r, "list", data); err != nil {
@@ -217,7 +237,7 @@ func (h *handlers) list(w http.ResponseWriter, r *http.Request) {
http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
return
}
- data.Groups = groupByRecency(r, time.Now().UTC(), docs)
+ data.Groups = groupByRecency(r, time.Now().UTC(), docs, h.thumbnailer(r.Context()))
if err := h.render.page(w, r, "list", data); err != nil {
h.logger.Error("docs: render list", "err", err)
http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
@@ -747,7 +767,7 @@ func updateIsClientFault(err error) bool {
// group labels ("Today … Yesterday … Today") the instant an older
// document got a new update. See store_test.go's
// TestListOrdersByUpdatedAt.
-func groupByRecency(r *http.Request, now time.Time, docs []*Document) []docGroup {
+func groupByRecency(r *http.Request, now time.Time, docs []*Document, thumb func(*Document) thumbView) []docGroup {
var groups []docGroup
var key string
for _, d := range docs {
@@ -761,6 +781,7 @@ func groupByRecency(r *http.Request, now time.Time, docs []*Document) []docGroup
Href: "/d/" + d.Sqid,
Title: d.Title,
When: formatWhen(now, d.UpdatedAt),
+ Thumb: thumb(d),
})
}
return groups
diff --git a/internal/docs/list_test.go b/internal/docs/list_test.go
index 61e25ea..41fe248 100644
--- a/internal/docs/list_test.go
+++ b/internal/docs/list_test.go
@@ -83,6 +83,216 @@ func TestListShowsDocuments(t *testing.T) {
}
}
+// The end-to-end one: a document with real content on its first page
+// gets a real thumbnail drawn from that page's own layout, and a
+// document with nothing on it gets the empty box the row has always
+// shown rather than an invented preview.
+//
+// It goes through the whole path deliberately — seed a document with
+// prose in it, replay it through the core, lay out page one, render
+// the list — because every unit below this is a table test on a
+// struct, and the thing that actually has to work is that a person
+// signing in sees their documents.
+func TestListDrawsAThumbnailOfPageOne(t *testing.T) {
+ app, _ := docs.NewForTest(t)
+ ctx := context.Background()
+ owner := app.TestPerson(t, "reader@example.com")
+ if err := docs.Seed(ctx, app.Store(), owner); err != nil {
+ t.Fatalf("Seed: %v", err)
+ }
+ cookies := signInCookies(t, app, "reader@example.com")
+
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ for _, c := range cookies {
+ req.AddCookie(c)
+ }
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d", rec.Code)
+ }
+ body := rec.Body.String()
+
+ if n := strings.Count(body, "<svg"); n == 0 {
+ t.Fatal("no thumbnail was drawn for any seeded document")
+ }
+ if n := strings.Count(body, "<rect"); n < 10 {
+ t.Errorf("only %d line boxes across the whole list — a seeded document has more than that on its first page", n)
+ }
+ // The viewBox is the page, so the browser does the scaling and the
+ // thumbnail is not distorted to fit its box.
+ if !strings.Contains(body, `viewBox="0 0 612 792"`) {
+ t.Error("the thumbnail is not drawn in page coordinates")
+ }
+}
+
+func TestAnEmptyDocumentGetsNoThumbnail(t *testing.T) {
+ app, _ := docs.NewForTest(t)
+ ctx := context.Background()
+ owner := app.TestPerson(t, "reader@example.com")
+ // Create leaves a document with no updates at all: nothing has
+ // been typed into it, so its first page has nothing on it. It is
+ // given a title because every document in the real app has one
+ // (the create handler substitutes "Untitled document" for a blank
+ // field) — and because a titleless fixture would make this test
+ // unable to see the thing it is here to check: the title must not
+ // be laid out onto the paper, and with no title there is nothing
+ // to lay out either way.
+ if _, err := app.Store().Create(ctx, owner, "Untitled document"); err != nil {
+ t.Fatal(err)
+ }
+ cookies := signInCookies(t, app, "reader@example.com")
+
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ for _, c := range cookies {
+ req.AddCookie(c)
+ }
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d", rec.Code)
+ }
+ // Asserted on the empty box rather than on the absence of any
+ // <svg> on the page: signing a new person in seeds them sample
+ // documents (Store.seedIfEnabled), so this list has real
+ // thumbnails on it too, and "no thumbnail anywhere" would be a
+ // test of the seeder rather than of the empty document.
+ if !strings.Contains(rec.Body.String(), `<span docs-thumb aria-hidden="true"></span>`) {
+ t.Error("an empty document did not render the empty box — it is meant to stay empty rather than show an invented preview")
+ }
+}
+
+// Editing a document changes its thumbnail on the next list render.
+//
+// This is the cache's central claim and every other test of it is a
+// unit test on a map. It holds through the real stack for two reasons
+// that are worth pinning here rather than trusting: ApplyUpdate bumps
+// updated_at, which is the cache key, so an edit makes the old entry
+// unreachable with nobody calling an invalidate; and the sqlite driver
+// round-trips that timestamp with enough precision that the two
+// versions do not collide.
+//
+// The one stale case this does not cover, because it is accepted
+// rather than prevented: if the update commits and the updated_at bump
+// then fails while the process lives, the row has new content under
+// its old key. The client's save queue retries, and a restart clears
+// the cache regardless.
+func TestEditingADocumentChangesItsThumbnail(t *testing.T) {
+ app, _ := docs.NewForTest(t)
+ ctx := context.Background()
+ owner := app.TestPerson(t, "reader@example.com")
+ doc, err := app.Store().Create(ctx, owner, "Notes")
+ if err != nil {
+ t.Fatal(err)
+ }
+ cookies := signInCookies(t, app, "reader@example.com")
+
+ rects := func() int {
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ for _, c := range cookies {
+ req.AddCookie(c)
+ }
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d", rec.Code)
+ }
+ return strings.Count(rec.Body.String(), "<rect")
+ }
+
+ before := rects()
+
+ // A real update through the real path, which is what bumps
+ // updated_at.
+ if _, err := app.Store().ApplyUpdate(ctx, doc, docs.ReadFragmentFixture(t, "hello")); err != nil {
+ t.Fatalf("ApplyUpdate: %v", err)
+ }
+
+ after := rects()
+ if after == before {
+ t.Errorf("the list drew %d line boxes before the edit and %d after — the thumbnail did not follow the document", before, after)
+ }
+}
+
+// A thumbnail must carry no word of the document it depicts.
+//
+// This is the property the whole cache argument rests on — a preview
+// is geometry, so caching one holds no prose in memory and would hold
+// none at rest either. That argument was made in a comment and
+// believed; this makes it a gate. Suggested by the Sheets session,
+// which built the same feature tonight and made the equivalent check
+// there.
+//
+// Docs cannot make the stronger claim Sheets can. Its preview never
+// reads a cell at all, so plaintext cannot reach the drawing code; our
+// text does enter the function — PageOne measures every word of the
+// paragraph it is breaking — and what holds is that only the geometry
+// comes back out. That is exactly the kind of claim worth a test
+// rather than a comment.
+//
+// The second assertion matters as much as the first: without it this
+// passes by drawing nothing at all.
+func TestAThumbnailCarriesNoneOfTheDocumentsWords(t *testing.T) {
+ app, _ := docs.NewForTest(t)
+ ctx := context.Background()
+ owner := app.TestPerson(t, "reader@example.com")
+ if err := docs.Seed(ctx, app.Store(), owner); err != nil {
+ t.Fatalf("Seed: %v", err)
+ }
+ cookies := signInCookies(t, app, "reader@example.com")
+
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ for _, c := range cookies {
+ req.AddCookie(c)
+ }
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d", rec.Code)
+ }
+ body := rec.Body.String()
+
+ if !strings.Contains(body, "<rect") {
+ t.Fatal("no thumbnail was drawn, so this test would pass without checking anything")
+ }
+
+ // Words from the seeded documents' bodies. The list shows titles,
+ // never content, so none of these belongs anywhere on this page —
+ // and certainly not inside a thumbnail.
+ for _, canary := range []string{
+ "Millbrook", "allotment", "printer", "waterworks", "committee",
+ } {
+ if strings.Contains(strings.ToLower(body), strings.ToLower(canary)) {
+ // Titles legitimately contain some of these, so report only
+ // what leaked into an <svg>.
+ for _, svg := range svgFragments(body) {
+ if strings.Contains(strings.ToLower(svg), strings.ToLower(canary)) {
+ t.Errorf("a thumbnail carries the document's own word %q", canary)
+ }
+ }
+ }
+ }
+}
+
+// svgFragments returns every <svg>...</svg> on the page.
+func svgFragments(body string) []string {
+ var out []string
+ rest := body
+ for {
+ i := strings.Index(rest, "<svg")
+ if i < 0 {
+ return out
+ }
+ rest = rest[i:]
+ j := strings.Index(rest, "</svg>")
+ if j < 0 {
+ return append(out, rest)
+ }
+ out = append(out, rest[:j])
+ rest = rest[j:]
+ }
+}
+
func TestEmptyListSaysSo(t *testing.T) {
app, _ := docs.NewForTest(t)
cookies := signInCookies(t, app, "reader@example.com")
diff --git a/internal/docs/preview.go b/internal/docs/preview.go
new file mode 100644
index 0000000..c3c3986
--- /dev/null
+++ b/internal/docs/preview.go
@@ -0,0 +1,312 @@
+package docs
+
+import (
+ "context"
+ "errors"
+ "sync"
+
+ "github.com/carlosframework/docs/internal/export"
+)
+
+// The thumbnail on a document's row, and where it comes from.
+//
+// Spec decision 11 and §6.4 ask the document list for "real rendered
+// page-one thumbnails"; §11.4 rules that a raster thumbnail needs a
+// pipeline this app does not have. Both are honoured here rather than
+// one of them being quietly dropped. The geometry is real — it is the
+// PDF exporter's own layout, through export.PageOne, so a row shows
+// where the document's first page actually breaks its lines — and no
+// raster is involved, because at 26x34 px a US-Letter page is about
+// 23 points to the pixel and a glyph is a fraction of one. What is
+// drawn is where the text is, at the size the text is. When the layout
+// engine and internal/raster land (spec §13), this keeps its shape and
+// rasterizes what PageOne already reports.
+//
+// This replaces the empty box list.html reserved for it. That box was
+// deliberately not a fake — "reserved space, not a placeholder image"
+// — and the bar that had to be cleared to fill it was showing
+// something true about the document, not something plausible.
+
+// previewBar is one line of the first page, in PAGE POINTS — the same
+// coordinate space export.PageOne reports and the same one the SVG's
+// viewBox declares, so the browser does the scaling and this code does
+// no arithmetic that could disagree with the layout.
+//
+// Ints, not floats: they are written straight into an SVG attribute by
+// html/template, and a float would render in Go's shortest-round-trip
+// form (1e+06 for a large one), which is not SVG number syntax. Points
+// are fine enough at this scale — one point is a twenty-third of a
+// pixel in the rendered box.
+type previewBar struct {
+ X, Y, W, H int
+}
+
+// bars converts a laid-out first page into the rectangles that stand
+// in for its lines.
+//
+// The height of a bar is derived from the type size rather than from
+// the line box, so a heading's bar is proportionally taller than a
+// body line's, and minBarPt keeps a very small one from collapsing to
+// nothing. Both are legibility decisions and the only numbers here not
+// reported from the layout.
+//
+// Be honest about the scale they operate at. 34px for 792pt makes a
+// body line box 0.72px and its bar about 0.39px; a heading's is about
+// 0.54px. No renderer resolves that difference as "heavier" in any
+// literal sense — what actually reaches the eye is a band of sub-pixel
+// coverage, and headings read as slightly darker rows within it. It
+// works, checked in a browser at 1x and 2x against the seeded
+// documents, but the mechanism is antialiasing rather than a drawn
+// weight, and a future change here should be judged in a browser
+// rather than by the arithmetic.
+func bars(lines []export.LineBox) []previewBar {
+ const (
+ barOfSize = 0.66 // of the type size, roughly its cap height
+ minBarPt = 9.0
+ )
+ if len(lines) == 0 {
+ return nil
+ }
+ out := make([]previewBar, 0, len(lines))
+ for _, l := range lines {
+ h := l.Size * barOfSize
+ if h < minBarPt {
+ h = minBarPt
+ }
+ // Centred in its own line box, which is where the text it
+ // stands for sits.
+ y := l.Top + (l.Height-h)/2
+ out = append(out, previewBar{
+ X: int(l.Left + 0.5),
+ Y: int(y + 0.5),
+ W: int(l.Width + 0.5),
+ H: int(h + 0.5),
+ })
+ }
+ return out
+}
+
+// previews is the document list's thumbnail cache.
+//
+// IN MEMORY, AND NOT IN THE DATABASE — the one decision in this file
+// worth arguing with, so here is the argument. A previewBar carries no
+// characters: it is four numbers, and the document's words never leave
+// export.PageOne (see LineBox's own note). So this is not plaintext,
+// and writing it to SQLite would not put document text at rest.
+//
+// It is still not written down, for a reason that outlives that. Spec
+// §2 seals content at rest and §2's boundary test greps raw SQLite
+// bytes for a fixture's known plaintext. A preview column would be a
+// second thing to remember to seal when sealing lands, and that grep
+// could never catch a lapse, because a shape contains none of the
+// fixture's words: the check that guards the seal rule would pass
+// while the obligation went unmet. Keeping the cache in memory creates
+// no obligation to forget. §2.1's leak list already blesses the
+// running instance holding exactly this — item 1 names thumbnails as
+// what the trade buys.
+//
+// Freshness is by construction rather than by invalidation. The key is
+// the document's updated_at in Unix nanoseconds, so an edit makes the
+// stored entry unreachable without any writer having to remember to
+// evict it — and a write path added later cannot forget a call it does
+// not have to make. What it does rely on is that every content write
+// bumps updated_at; Store.ApplyUpdate's own comment now records that
+// as an invariant rather than leaving it as a habit.
+//
+// Two traps, both live:
+//
+// - Compare UnixNano, never time.Time with ==. A time read back
+// through the sqlite driver and one made with time.Now().UTC() are
+// never == even at the same instant, and a cache keyed on that
+// comparison silently never hits.
+// - Take the timestamp from the row the list query returned, never
+// from a *Document that has been through ApplyUpdate: that method
+// bumps the column, not the struct it was handed.
+type previews struct {
+ mu sync.Mutex
+ // by is keyed on Document.ID, and is bounded — see maxPreviews for
+ // how it is trimmed and why a wipe was the wrong answer.
+ by map[int64]previewEntry
+}
+
+type previewEntry struct {
+ updatedAtNanos int64
+ // bars is nil for a document with nothing on its first page, and
+ // for one whose structure would not read at this version. Both are
+ // settled answers meaning "draw nothing", and both are cached: an
+ // empty document must not be replayed on every list render, and a
+ // document whose log will not replay would otherwise be retried on
+ // every page view for as long as it stays broken, turning one bad
+ // row into a permanent error-log fountain.
+ //
+ // There is no separate "failed" flag. There was one, written and
+ // never read — get answered from bars being nil either way — so it
+ // recorded a distinction nothing acted on.
+ bars []previewBar
+}
+
+// maxPreviews bounds the map.
+//
+// Eviction is one arbitrary entry per insert, not a wipe. A wipe was
+// wrong in a way worth recording, because it only misbehaves at scale:
+// combined with thumbnailBudget, an instance whose listed documents
+// exceed this bound would drop everything, warm at most a budget's
+// worth on the next render, overflow, and drop it again — so every row
+// past the fortieth would draw the empty box on every render, for
+// ever. Evicting one keeps the map full of *something* and lets a
+// large list warm over successive renders.
+//
+// Arbitrary rather than least-recently-used on purpose: Go's map
+// iteration gives a usable arbitrary key for free, and at this size
+// the difference between evicting the coldest entry and evicting a
+// random one is not worth the bookkeeping an LRU needs.
+const maxPreviews = 512
+
+func newPreviews() *previews { return &previews{by: map[int64]previewEntry{}} }
+
+// get returns the cached bars for doc at its current updated_at, and
+// whether there was a usable entry.
+func (p *previews) get(id int64, updatedAtNanos int64) ([]previewBar, bool) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ e, ok := p.by[id]
+ if !ok || e.updatedAtNanos != updatedAtNanos {
+ return nil, false
+ }
+ return e.bars, true
+}
+
+func (p *previews) put(id int64, updatedAtNanos int64, b []previewBar) {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if _, replacing := p.by[id]; !replacing {
+ for len(p.by) >= maxPreviews {
+ for k := range p.by {
+ delete(p.by, k)
+ break
+ }
+ }
+ }
+ p.by[id] = previewEntry{updatedAtNanos: updatedAtNanos, bars: b}
+}
+
+// thumbnail lays out one document's first page and caches the result.
+// The caller has already missed the cache; thumbnailer is what checks
+// it, so this does not look again.
+//
+// It never returns an error. A document whose structure will not read
+// gets no thumbnail and the row renders the empty box, because the
+// list is the one page that must work and a single unreplayable
+// document turning it into a 500 would be a worse bug than the one it
+// is reporting.
+//
+// WHICH failures are remembered is the subtle part. A document whose
+// log is corrupt is remembered, so it is not retried on every page
+// view for as long as it stays broken. A request that was CANCELLED is
+// not: Store.JSON runs on the request's own context, so a viewer who
+// closes the tab during a cold render — up to a budget's worth of
+// replays — would otherwise stamp "no thumbnail" onto every document
+// still being laid out, and each would keep it until its next edit. A
+// timeout and a busy database are the same shape. Those say nothing
+// about the document, so they are logged at a lower level and left
+// uncached, and the next render simply tries again.
+func (h *handlers) thumbnail(ctx context.Context, doc *Document) []previewBar {
+ nanos := doc.UpdatedAt.UnixNano()
+
+ // remember records a settled "draw nothing" for this version. A
+ // context that is already done means the answer is about the
+ // request, not the document, so nothing is written down.
+ remember := func(what string, err error) []previewBar {
+ if ctx.Err() != nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
+ h.logger.Debug("docs: list thumbnail abandoned with the request", "doc_id", doc.ID, "stage", what, "err", err)
+ return nil
+ }
+ h.logger.Error("docs: "+what+" for a list thumbnail", "doc_id", doc.ID, "err", err)
+ h.previews.put(doc.ID, nanos, nil)
+ return nil
+ }
+
+ structure, err := h.store.JSON(ctx, doc)
+ if err != nil {
+ return remember("read structure", err)
+ }
+ // Deliberately no title. export.PageOne lays out whatever Title
+ // the Document carries, because the PDF exporter puts one at the
+ // top of its first page — a file that does not say which document
+ // it is, is worse than one that repeats a heading. A thumbnail is
+ // not a file: it is a picture of the paper, and views/show.html
+ // keeps the title off the paper entirely (it lives in the shell
+ // bar, chrome around the document rather than on it). Laying it in
+ // here would draw a heading the reader has never seen on the page,
+ // directly beside the very title it duplicates — and would give
+ // every empty document a one-bar preview of content it does not
+ // have.
+ parsed, err := export.Parse("", structure)
+ if err != nil {
+ return remember("parse structure", err)
+ }
+
+ b := bars(export.PageOne(parsed))
+ h.previews.put(doc.ID, nanos, b)
+ return b
+}
+
+// thumbnailBudget is how many documents one list render will lay out
+// on a cold cache before it stops and leaves the rest to fill in on
+// later renders.
+//
+// The list is the app's landing page and it has no pagination, so
+// without a bound the first render after a restart costs one page of
+// layout per document a person owns, serially, before a byte of HTML
+// is written. With one, a cold cache costs a bounded amount and warms
+// over the next few renders; a warm cache never reaches the bound at
+// all, because a hit does not spend from it.
+//
+// Measured rather than guessed, on the seeded sample documents
+// (2026-09-02, this machine): a miss is about 1.9ms — replay through
+// the snapshot and its tail, then one page of layout — and a hit is
+// far under that. 36 documents rendered cold in 68ms and warm in
+// 3.4ms. So this bound puts a cold landing page at roughly 75ms of
+// layout, which is a page load rather than a stall, and it is only
+// ever paid after a restart. Re-measure before raising it; the number
+// that matters is the miss, and it grows with a document's update log
+// between snapshots.
+const thumbnailBudget = 40
+
+// thumbnailer returns the function the row builders call for each
+// document, holding one render's budget between them.
+//
+// A cache hit costs nothing and spends nothing, which is what keeps a
+// warm list from ever reaching the bound: the budget exists for the
+// cold case, and the cold case is the only one that is slow.
+func (h *handlers) thumbnailer(ctx context.Context) func(*Document) thumbView {
+ budget := thumbnailBudget
+ return func(d *Document) thumbView {
+ if b, ok := h.previews.get(d.ID, d.UpdatedAt.UnixNano()); ok {
+ return newThumbView(b)
+ }
+ if budget <= 0 {
+ return newThumbView(nil)
+ }
+ budget--
+ return newThumbView(h.thumbnail(ctx, d))
+ }
+}
+
+// thumbView is what list.html's "thumb" template is handed: the bars,
+// and the viewBox they are drawn in.
+//
+// The viewBox travels with them rather than being written into the
+// template as a literal, so it is derived from the exporter's own page
+// constants — the SVG is the page at its own scale, and [docs-thumb]'s
+// 26x34 box does the whole reduction. A literal in the markup would be
+// a fourth place US-Letter is written down and the first one nothing
+// holds to the other three.
+type thumbView struct {
+ Bars []previewBar
+ W, H int
+}
+
+func newThumbView(b []previewBar) thumbView {
+ return thumbView{Bars: b, W: int(export.PageWidthPt), H: int(export.PageHeightPt)}
+}
diff --git a/internal/docs/preview_internal_test.go b/internal/docs/preview_internal_test.go
new file mode 100644
index 0000000..a04708b
--- /dev/null
+++ b/internal/docs/preview_internal_test.go
@@ -0,0 +1,148 @@
+package docs
+
+import (
+ "testing"
+
+ "github.com/carlosframework/docs/internal/export"
+)
+
+// bars is the only arithmetic in the thumbnail path that is not simply
+// reported from the layout, so it is the part worth pinning.
+
+func TestBarsOfNothingIsNothing(t *testing.T) {
+ if got := bars(nil); got != nil {
+ t.Errorf("got %+v, want nil", got)
+ }
+}
+
+func TestABarSitsInsideTheLineItStandsFor(t *testing.T) {
+ line := export.LineBox{Left: 72, Top: 100, Width: 300, Height: 16.8, Size: 12}
+ got := bars([]export.LineBox{line})
+ if len(got) != 1 {
+ t.Fatalf("got %d bars, want 1", len(got))
+ }
+ b := got[0]
+ if b.X != 72 {
+ t.Errorf("X = %d, want the line's own left edge 72", b.X)
+ }
+ if b.W != 300 {
+ t.Errorf("W = %d, want the line's measured width 300", b.W)
+ }
+ // Vertically centred in the line box, and never taller than it —
+ // a bar that overflowed its line would overlap its neighbour and
+ // a page of text would read as one solid block.
+ if float64(b.Y) < line.Top-1 || float64(b.Y+b.H) > line.Top+line.Height+1 {
+ t.Errorf("bar [%d, %d] is not inside the line box [%.1f, %.1f]",
+ b.Y, b.Y+b.H, line.Top, line.Top+line.Height)
+ }
+}
+
+// A heading draws heavier than body text, and it does so because the
+// type really is bigger — this is what carries the distinction without
+// colour (WCAG 2.2, and AGENTS.md's "colour never carries meaning
+// alone").
+func TestAHeadingBarIsTallerThanABodyBar(t *testing.T) {
+ got := bars([]export.LineBox{
+ {Left: 72, Top: 100, Width: 200, Height: 25, Size: 19.2},
+ {Left: 72, Top: 130, Width: 200, Height: 16.8, Size: 12},
+ })
+ if len(got) != 2 {
+ t.Fatalf("got %d bars, want 2", len(got))
+ }
+ if got[0].H <= got[1].H {
+ t.Errorf("heading bar %d is not taller than body bar %d", got[0].H, got[1].H)
+ }
+}
+
+// The floor. A bar of literally correct height at this scale is a
+// fraction of a pixel; without a minimum, small type vanishes.
+func TestATinyLineStillDrawsSomething(t *testing.T) {
+ got := bars([]export.LineBox{{Left: 72, Top: 100, Width: 200, Height: 4, Size: 1}})
+ if len(got) != 1 || got[0].H <= 0 {
+ t.Fatalf("a tiny line drew nothing: %+v", got)
+ }
+}
+
+// The cache's whole freshness story is this test: an edit changes
+// updated_at, and a changed updated_at makes the old entry
+// unreachable without anyone having called an invalidate.
+func TestAChangedUpdatedAtMissesTheCache(t *testing.T) {
+ p := newPreviews()
+ want := []previewBar{{X: 1, Y: 2, W: 3, H: 4}}
+ p.put(7, 1000, want)
+
+ if got, ok := p.get(7, 1000); !ok || len(got) != 1 || got[0] != want[0] {
+ t.Errorf("same version did not hit: got %+v ok=%v", got, ok)
+ }
+ if _, ok := p.get(7, 1001); ok {
+ t.Error("a document edited since caching still hit the cache")
+ }
+ if _, ok := p.get(8, 1000); ok {
+ t.Error("a different document hit another document's entry")
+ }
+}
+
+// A cached failure is a hit, not a miss: the answer is known and it is
+// "draw nothing". Without this a document whose log will not replay is
+// laid out again on every single list render.
+func TestACachedFailureIsAHit(t *testing.T) {
+ p := newPreviews()
+ p.put(7, 1000, nil)
+ got, ok := p.get(7, 1000)
+ if !ok {
+ t.Fatal("a cached failure missed, so it will be retried on every render")
+ }
+ if got != nil {
+ t.Errorf("a cached failure returned bars: %+v", got)
+ }
+}
+
+func TestTheCacheIsBounded(t *testing.T) {
+ p := newPreviews()
+ for i := int64(0); i < maxPreviews+10; i++ {
+ p.put(i, 1, []previewBar{{X: 1}})
+ }
+ p.mu.Lock()
+ n := len(p.by)
+ p.mu.Unlock()
+ if n > maxPreviews {
+ t.Errorf("cache holds %d entries, past its %d bound", n, maxPreviews)
+ }
+}
+
+// Overflowing the bound must not empty it. This is the bug an earlier
+// version had: the map was wiped when full, and combined with
+// thumbnailBudget that left an instance with more documents than the
+// bound permanently cold — everything past the budget drew the empty
+// box on every render, for ever, because each render wiped what the
+// last one warmed.
+func TestOverflowingTheCacheKeepsItWarm(t *testing.T) {
+ p := newPreviews()
+ for i := int64(0); i < maxPreviews*2; i++ {
+ p.put(i, 1, []previewBar{{X: 1}})
+ }
+ p.mu.Lock()
+ n := len(p.by)
+ p.mu.Unlock()
+ if n < maxPreviews {
+ t.Errorf("cache holds only %d entries after overflowing — it was emptied rather than trimmed", n)
+ }
+}
+
+// Re-caching the same document must not evict anything: a list that
+// re-renders the same rows would otherwise churn the map for no reason.
+func TestReplacingAnEntryEvictsNothing(t *testing.T) {
+ p := newPreviews()
+ for i := int64(0); i < maxPreviews; i++ {
+ p.put(i, 1, []previewBar{{X: 1}})
+ }
+ for i := int64(0); i < maxPreviews; i++ {
+ p.put(i, 2, []previewBar{{X: 2}})
+ }
+ p.mu.Lock()
+ n := len(p.by)
+ p.mu.Unlock()
+ if n != maxPreviews {
+ t.Errorf("cache holds %d entries after re-caching the same documents, want %d", n, maxPreviews)
+ }
+}
diff --git a/internal/docs/store.go b/internal/docs/store.go
index a091d8b..846eeea 100644
--- a/internal/docs/store.go
+++ b/internal/docs/store.go
@@ -706,6 +706,18 @@ func (s *Store) currentSeq(ctx context.Context, docID int64) (int64, error) {
// owner makes the Where match zero rows, First returns
// gorm.ErrRecordNotFound, and this method returns before replay is
// even called — no core touched, no row written.
+//
+// AN INVARIANT THIS METHOD OWNS, written down because something now
+// depends on it: every write that changes a document's content bumps
+// its updated_at. The document list's thumbnail cache (preview.go) is
+// keyed on that column, so a content write that left it alone would
+// serve a preview of the document as it used to be, with nothing
+// anywhere raising a hand. Today this is the only content write, so
+// the invariant holds by having one writer; the writes that will test
+// it are version restore (spec §14) and any future log compaction.
+// Note also that this bumps the COLUMN and not the *Document it was
+// handed — a caller holding that struct has a stale UpdatedAt, which
+// is why the cache takes its key from the row Store.List returned.
func (s *Store) ApplyUpdate(ctx context.Context, doc *Document, update []byte) (int64, error) {
var ownerCheck Document
if err := owned(s.db.WithContext(ctx), doc.OwnerID).
diff --git a/internal/docs/views/list.html b/internal/docs/views/list.html
index 929e38a..27806b2 100644
--- a/internal/docs/views/list.html
+++ b/internal/docs/views/list.html
@@ -59,12 +59,27 @@
shared partial because a page template is where a shell block
override has to live for the clone that page is parsed into. */}}
{{define "account"}}<form method="post" action="/signout"><button type="submit">{{.SignOutLabel}}</button></form>{{end}}
+{{/* thumb — one document's page one, drawn from the line boxes
+ preview.go reports. aria-hidden because it is decorative: it is
+ wholly redundant with the document's own title beside it, and
+ there is no non-visual equivalent of "the shape of the
+ paragraphs" to offer instead. Deliberately no <title>, no role
+ and no tabindex — see AGENTS.md on a role being a promise.
+
+ An empty .Thumb renders the bordered box with nothing in it,
+ which is what an empty document, and a document whose structure
+ could not be read, both get. */}}
+{{define "thumb"}}<span docs-thumb aria-hidden="true">{{if .Bars}}<svg viewBox="0 0 {{.W}} {{.H}}" preserveAspectRatio="xMidYMin slice">{{range .Bars}}<rect x="{{.X}}" y="{{.Y}}" width="{{.W}}" height="{{.H}}"/>{{end}}</svg>{{end}}</span>{{end}}
{{define "content"}}<style>
- /* [docs-thumb] is reserved space, not a placeholder image: a later
- plan's layout engine draws a real rendering of the document's
- page one here, at exactly this box, so nothing else on the row
- reflows the day that lands. 26×34 is literal — the brief's own
- figure, close to a US-letter page's aspect ratio at row scale. */
+ /* [docs-thumb] holds a real rendering of the document's page one:
+ the <svg> inside it is the line boxes the PDF exporter's own
+ layout placed, through export.PageOne (see preview.go). It was
+ reserved and deliberately empty until that existed — "reserved
+ space, not a placeholder image" — and the box is unchanged now
+ that it is filled, which is what that reservation was for.
+ 26×34 is literal: the brief's own figure, and within a percent
+ of a US-Letter page's aspect at row scale, so the page inside it
+ is not distorted to fit. */
[docs-thumb] {
display: block;
flex: none;
@@ -73,6 +88,22 @@
border: 1px solid var(--rst-line-strong);
border-radius: var(--rst-radius-sm);
background: var(--rst-surface);
+ overflow: hidden;
+ }
+ [docs-thumb] > svg {
+ display: block;
+ inline-size: 100%;
+ block-size: 100%;
+ }
+ /* The fill is a token rather than a presentation attribute on each
+ rect: var() in an SVG presentation attribute has a patchy support
+ history, and a token here follows the theme the way every other
+ colour on this page does. Nothing about the thumbnail carries
+ meaning by colour alone — a heading reads as a heading because
+ its bar is taller, which is the real ratio between the two type
+ sizes. */
+ [docs-thumb] rect {
+ fill: var(--rst-text-muted);
}
[docs-group-label] {
margin: 0;
@@ -295,7 +326,7 @@
<div rst-list>
{{range .Results}}
<div rst-row>
- <span docs-thumb aria-hidden="true"></span>
+ {{template "thumb" .Thumb}}
<span rst-row-main><a href="{{.Href}}">{{.Title}}</a></span>
<span docs-row-when>{{.When}}</span>
</div>
@@ -314,7 +345,7 @@
<h2 docs-group-label>{{.Label}}</h2>
{{range .Rows}}
<div rst-row>
- <span docs-thumb aria-hidden="true"></span>
+ {{template "thumb" .Thumb}}
<span rst-row-main><a href="{{.Href}}">{{.Title}}</a></span>
<span docs-row-when>{{.When}}</span>
</div>
diff --git a/internal/export/pageone.go b/internal/export/pageone.go
new file mode 100644
index 0000000..8cd6c2b
--- /dev/null
+++ b/internal/export/pageone.go
@@ -0,0 +1,88 @@
+package export
+
+import "github.com/carlosframework/docs/internal/pdf"
+
+// PageOne is the geometry of the first page of a document: where every
+// line of text actually landed, in points, measured from the top-left
+// of the page.
+//
+// It exists for the document list's thumbnails (internal/docs's
+// preview.go); spec §11.4 is where the argument for drawing line boxes
+// rather than rasterizing them lives, and it is not repeated here.
+//
+// Real is the whole point, and it is why this is a sink on the
+// exporter's own layout rather than a second, simpler one beside it.
+// The line breaking here is done by the same code that breaks lines for
+// the PDF, measuring with the same font programs the browser is served,
+// against the same page geometry views/show.html sets — so a thumbnail
+// shows the document's actual first page, wrapped where it actually
+// wraps. A separate estimator ("about sixty characters to a line")
+// would have been a second opinion about the document, free to drift
+// from the first, and drift is exactly what the list would show.
+//
+// What is elided, and should be said plainly: the glyphs. This draws
+// where the text is, not the text.
+//
+// This is page one of a PDF of THIS Document, title included — it runs
+// the same layout.document PDF does, so whatever the exporter puts on a
+// first page is what this reports. A caller that wants the paper
+// without the title passes a Document with an empty Title; the document
+// list does exactly that, and preview.go says why.
+//
+// What it bounds is the LAYOUT: stopAfterPage ends that after the first
+// page, so a four-hundred-page document costs one page of it. The rest
+// of the work is not bounded and it would be wrong to imply otherwise —
+// the caller has already replayed and serialised the whole document to
+// get here, Parse decodes all of it, and the paragraph in progress is
+// measured in full before its first line is drawn. Measured on the seed
+// documents, the two halves are the same order: about 0.5-3ms to reach
+// the tree and 0.3-2ms to lay the page out.
+func PageOne(doc Document) []LineBox {
+ var lines []LineBox
+ l := &layout{
+ b: pdf.New(PageWidthPt, PageHeightPt),
+ missing: map[rune]bool{},
+ stopAfterPage: 1,
+ sink: func(b LineBox) { lines = append(lines, b) },
+ }
+ l.newPage()
+ l.document(doc)
+ return lines
+}
+
+// LineBox is one laid-out line of text, in points from the top-left of
+// the page. It carries no text: a consumer of this is drawing where the
+// words are, and the words themselves never leave the layout.
+//
+// Lines only. A list's bullets and numbers are drawn beside their
+// item's first line rather than as lines of their own, so they never
+// reach the sink and a list reports as indented lines with no markers.
+// At thumbnail scale a marker would be well under a pixel, so this is
+// not worth fixing — but it is worth knowing before someone reads a
+// list preview as a run of short paragraphs.
+//
+// That is a property worth keeping rather than an accident. It is what
+// lets the document list cache a preview in memory without holding any
+// of the document's prose, and it is why a preview built from these is
+// not a plaintext derivative of the document (see preview.go's own
+// note on what it is, and is not, safe to write down).
+type LineBox struct {
+ // Left and Top are the top-left corner of the line box. Left
+ // already accounts for alignment, list and quote indents — it is
+ // where the line starts, not where its column does.
+ Left, Top float64
+
+ // Width is the measured width of the text on the line, which for a
+ // wrapped line is short of the column and for the last line of a
+ // paragraph is usually well short of it. This is what makes a
+ // thumbnail read as prose rather than as a solid block.
+ Width float64
+
+ // Height is the line box's height — the leading, not the glyphs.
+ Height float64
+
+ // Size is the largest type size on the line, in points. A heading
+ // reports a bigger one than body text, which lets a drawing
+ // consumer weight it accordingly without knowing what a heading is.
+ Size float64
+}
diff --git a/internal/export/pageone_test.go b/internal/export/pageone_test.go
new file mode 100644
index 0000000..2a99e46
--- /dev/null
+++ b/internal/export/pageone_test.go
@@ -0,0 +1,211 @@
+package export_test
+
+import (
+ "math"
+ "sort"
+ "testing"
+
+ "github.com/carlosframework/docs/internal/export"
+)
+
+// The whole point of PageOne is that it is the SAME layout the PDF
+// runs, not a second one that agrees with it by inspection. These
+// tests hold it to that: the geometry it reports has to be geometry
+// the exporter would really have drawn.
+
+func TestPageOneReportsALineForEachLineOfText(t *testing.T) {
+ js := `{"type":"doc","content":[
+ {"type":"heading","attrs":{"level":1},"content":[{"type":"text","text":"A heading"}]},
+ {"type":"paragraph","content":[{"type":"text","text":"One line of body text."}]}
+ ]}`
+ // Three lines: the title (set as an h1 by PDF and PageOne alike),
+ // the heading, and the paragraph.
+ lines := export.PageOne(mustParse(t, "Notes", js))
+ if len(lines) != 3 {
+ t.Fatalf("got %d lines, want 3: %+v", len(lines), lines)
+ }
+ for i, l := range lines {
+ if l.Height <= 0 || l.Width <= 0 {
+ t.Errorf("line %d has no extent: %+v", i, l)
+ }
+ if l.Top < export.MarginPt {
+ t.Errorf("line %d starts above the top margin: %+v", i, l)
+ }
+ if l.Left < export.MarginPt {
+ t.Errorf("line %d starts left of the margin: %+v", i, l)
+ }
+ }
+}
+
+// A document with no content at all still has its title, and nothing
+// else — an empty document must not report a page full of phantom
+// lines.
+func TestPageOneOfAnEmptyDocumentIsJustItsTitle(t *testing.T) {
+ lines := export.PageOne(mustParse(t, "Untitled document", `{"type":"doc","content":[]}`))
+ if len(lines) != 1 {
+ t.Fatalf("got %d lines, want 1 (the title): %+v", len(lines), lines)
+ }
+}
+
+// An untitled, contentless document reports nothing, which is what
+// lets the list render its empty box rather than a one-bar preview.
+func TestPageOneOfATrulyEmptyDocumentIsEmpty(t *testing.T) {
+ lines := export.PageOne(mustParse(t, "", `{"type":"doc","content":[]}`))
+ if len(lines) != 0 {
+ t.Fatalf("got %d lines, want none: %+v", len(lines), lines)
+ }
+}
+
+// The bound that makes this affordable on a list page: a document of
+// any length costs one page of layout, not all of it.
+func TestPageOneStopsAtTheEndOfTheFirstPage(t *testing.T) {
+ js := `{"type":"doc","content":[`
+ for i := 0; i < 400; i++ {
+ if i > 0 {
+ js += ","
+ }
+ js += `{"type":"paragraph","content":[{"type":"text","text":"A paragraph."}]}`
+ }
+ js += `]}`
+
+ lines := export.PageOne(mustParse(t, "Long", js))
+ if len(lines) == 0 {
+ t.Fatal("no lines at all")
+ }
+ // 648pt of content box at 16.8pt a line is 38-ish; the exact
+ // number is the layout's business, but 400 paragraphs must not
+ // all be here and none may sit below the bottom margin.
+ if len(lines) > 60 {
+ t.Errorf("got %d lines — that is more than one page's worth", len(lines))
+ }
+ for i, l := range lines {
+ if l.Top+l.Height > export.PageHeightPt-export.MarginPt+0.01 {
+ t.Errorf("line %d runs past the bottom margin: %+v", i, l)
+ }
+ }
+}
+
+// A heading's line reports a bigger size than a body line's. This is
+// what lets a thumbnail draw a heading heavier without the drawing
+// code having to know what a heading is.
+func TestPageOneReportsTheSizeOfEachLine(t *testing.T) {
+ js := `{"type":"doc","content":[
+ {"type":"heading","attrs":{"level":1},"content":[{"type":"text","text":"Big"}]},
+ {"type":"paragraph","content":[{"type":"text","text":"Small"}]}
+ ]}`
+ lines := export.PageOne(mustParse(t, "", js))
+ if len(lines) != 2 {
+ t.Fatalf("got %d lines, want 2: %+v", len(lines), lines)
+ }
+ if lines[0].Size <= lines[1].Size {
+ t.Errorf("heading size %v is not bigger than body size %v", lines[0].Size, lines[1].Size)
+ }
+ if lines[1].Size != export.BodySizePt {
+ t.Errorf("body line size %v, want %v", lines[1].Size, float64(export.BodySizePt))
+ }
+}
+
+// A centred line is reported where it was actually placed, not at the
+// margin — the thumbnail shows alignment because the layout already
+// knows it.
+func TestPageOneReportsAlignment(t *testing.T) {
+ js := `{"type":"doc","content":[
+ {"type":"paragraph","content":[{"type":"text","text":"Left."}]},
+ {"type":"paragraph","attrs":{"align":"center"},"content":[{"type":"text","text":"Middle."}]}
+ ]}`
+ lines := export.PageOne(mustParse(t, "", js))
+ if len(lines) != 2 {
+ t.Fatalf("got %d lines, want 2: %+v", len(lines), lines)
+ }
+ if lines[0].Left != export.MarginPt {
+ t.Errorf("left-aligned line at %v, want the margin %v", lines[0].Left, float64(export.MarginPt))
+ }
+ if lines[1].Left <= lines[0].Left {
+ t.Errorf("centred line at %v is not indented past the left-aligned one at %v", lines[1].Left, lines[0].Left)
+ }
+}
+
+// A test named TestPageOneDoesNotDisturbPDFExport stood here. It
+// exported a document, ran PageOne, exported it again and compared the
+// bytes — and it could not fail. PDF and PageOne build separate layout
+// values sharing no state, so only a package-level global could ever
+// have reddened it; a deliberate `l.cursor += 100` inside drawLine's
+// sink block left it green while reddening the agreement test below.
+//
+// Deleted rather than repaired, because a check that cannot
+// distinguish "no" from "I can't see it" is worse than no check: two
+// comments and a commit message cited it as the thing holding the sink
+// additive, which it never was. The exporter's own suite covers its
+// output, and TestPageOneMatchesTheFirstPageOfThePDF covers the
+// direction that can actually drift.
+
+// The test PageOne's own comment promises, and the one that makes "the
+// same layout, not a second opinion" a mechanism rather than a claim:
+// every line PageOne reports has to be a line the PDF exporter really
+// drew, at the same place.
+//
+// It reads the PDF back rather than trusting the layout twice. A run's
+// text matrix gives the baseline it was drawn at and the x it started
+// from; PageOne reports the top of the line box and its left edge. The
+// baseline must fall inside that box, and the left edges must be equal.
+//
+// Deliberately no list in the fixture: a list marker is drawn at an x
+// to the LEFT of its item's text (in the list's own indent), so the
+// leftmost run on that line is the marker rather than the line itself,
+// and this test's "leftmost run starts where the line starts" would be
+// comparing the wrong two numbers. That is a real gap, named rather
+// than papered over — a list's own geometry is not held by anything
+// here, and the pretext that another test covered it was false.
+func TestPageOneMatchesTheFirstPageOfThePDF(t *testing.T) {
+ js := `{"type":"doc","content":[
+ {"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"A heading that is long enough to be interesting"}]},
+ {"type":"paragraph","content":[{"type":"text","text":"A paragraph long enough that it has to wrap onto a second line, which is the case where an estimator and a real layout would disagree about where the break falls."}]},
+ {"type":"paragraph","attrs":{"align":"center"},"content":[{"type":"text","text":"Centred."}]},
+ {"type":"paragraph","attrs":{"align":"right"},"content":[{"type":"text","text":"Right."}]}
+ ]}`
+ doc := mustParse(t, "Notes", js)
+
+ lines := export.PageOne(doc)
+ data, _ := mustPDF(t, doc)
+ f := readPDF(t, data)
+ pages := f.pages(t)
+ if len(pages) != 1 {
+ t.Fatalf("fixture spilled onto %d pages; it is meant to fit on one", len(pages))
+ }
+
+ // Group the drawn runs into lines by baseline, keeping the leftmost
+ // x on each.
+ type drawn struct{ baseline, left float64 }
+ var byBaseline []drawn
+ for _, r := range f.runs(t, pages[0]) {
+ baseline := float64(export.PageHeightPt) - r.y
+ found := false
+ for i := range byBaseline {
+ if math.Abs(byBaseline[i].baseline-baseline) < 0.01 {
+ if r.x < byBaseline[i].left {
+ byBaseline[i].left = r.x
+ }
+ found = true
+ break
+ }
+ }
+ if !found {
+ byBaseline = append(byBaseline, drawn{baseline: baseline, left: r.x})
+ }
+ }
+ sort.Slice(byBaseline, func(i, j int) bool { return byBaseline[i].baseline < byBaseline[j].baseline })
+
+ if len(byBaseline) != len(lines) {
+ t.Fatalf("PageOne reports %d lines, the PDF drew %d", len(lines), len(byBaseline))
+ }
+ for i, d := range byBaseline {
+ l := lines[i]
+ if d.baseline < l.Top || d.baseline > l.Top+l.Height {
+ t.Errorf("line %d: baseline %.2f is outside the reported box [%.2f, %.2f]",
+ i, d.baseline, l.Top, l.Top+l.Height)
+ }
+ if math.Abs(d.left-l.Left) > 0.01 {
+ t.Errorf("line %d: the PDF drew it at x=%.2f, PageOne reports Left=%.2f", i, d.left, l.Left)
+ }
+ }
+}
diff --git a/internal/export/pdf.go b/internal/export/pdf.go
index 68937c2..5a32957 100644
--- a/internal/export/pdf.go
+++ b/internal/export/pdf.go
@@ -368,16 +368,38 @@ func PDF(doc Document) ([]byte, Report, error) {
}
l.b.Title = doc.Title
l.newPage()
+ l.document(doc)
- // The title is set as a level-1 heading at the top of the first
- // page. It is not part of the CRDT and so not part of the box tree
- // the screen renders — the screen puts it in the shell bar beside
- // the brand link, which is chrome around the paper rather than on
- // it (it was a page header until the chrome moved to the topbar
- // shell; either way it is not on the paper). A PDF has no
- // chrome: a file that does not say which document it is, is worse
- // than one that occasionally repeats a heading its author also
- // typed. Markdown does the same thing for the same reason.
+ out, err := l.b.Bytes()
+ if err != nil {
+ return nil, Report{}, err
+ }
+ return out, l.report(), nil
+}
+
+// document lays out a whole Document: its title, then its content.
+//
+// Shared by PDF and PageOne rather than written out in both, so a
+// change to what a first page contains reaches the thumbnail too. The
+// two did duplicate it, which meant something added here would have
+// reached the thumbnail only if it happened to move a run in the
+// agreement test's fixture.
+//
+// The title is set as a level-1 heading at the top of the first page.
+// It is not part of the CRDT and so not part of the box tree the
+// screen renders — the screen puts it in the shell bar beside the
+// brand link, which is chrome around the paper rather than on it (it
+// was a page header until the chrome moved to the topbar shell; either
+// way it is not on the paper). A PDF has no chrome: a file that does
+// not say which document it is, is worse than one that occasionally
+// repeats a heading its author also typed. Markdown does the same
+// thing for the same reason.
+//
+// A caller wanting the paper WITHOUT that chrome — the document list's
+// thumbnail does, and preview.go says why — passes a Document with an
+// empty Title, which is why this branches rather than always laying
+// one.
+func (l *layout) document(doc Document) {
if doc.Title != "" {
l.block(Node{
Type: "heading",
@@ -385,16 +407,9 @@ func PDF(doc Document) ([]byte, Report, error) {
Content: []Node{{Type: "text", Text: doc.Title}},
}, blockContext{scale: 1})
}
-
for _, n := range doc.Root.Content {
l.block(n, blockContext{scale: 1})
}
-
- out, err := l.b.Bytes()
- if err != nil {
- return nil, Report{}, err
- }
- return out, l.report(), nil
}
type layout struct {
@@ -427,10 +442,45 @@ type layout struct {
capturedPage *pdf.Page
capturedTop float64
+ // sink, when set, is told where every line box landed as it is
+ // placed (pageone.go). PDF export leaves it nil, and leaves
+ // stopAfterPage 0, so no guard below ever fires and no PDF byte
+ // moves.
+ //
+ // That is a claim by inspection, and it is worth being straight
+ // about what does and does not hold it. A test that exported the
+ // same document before and after a PageOne run was written for
+ // exactly this and then deleted: the two calls share no state, so
+ // only a package-level global could ever have made it red. It
+ // passed unchanged while a deliberate `l.cursor += 100` inside
+ // this block reddened the agreement test — a check that could not
+ // distinguish "no" from "I can't see it" (AGENTS.md). What covers
+ // the exporter is its own suite, which measures real output, and
+ // TestPageOneMatchesTheFirstPageOfThePDF for the reverse
+ // direction.
+ sink func(LineBox)
+
+ // pages counts the pages started so far, and stopAfterPage — zero
+ // for "never stop", which is every PDF export — ends the layout
+ // once that many are complete. Nothing is drawn or reported after
+ // stopped is set, so a caller that wants one page pays for one page
+ // of layout rather than for all of a long document's.
+ pages int
+ stopAfterPage int
+ stopped bool
+
missing map[rune]bool
}
func (l *layout) newPage() {
+ if l.stopAfterPage > 0 && l.pages >= l.stopAfterPage {
+ // The page just finished was the last one asked for. Stop
+ // before allocating another: every drawing path checks stopped
+ // and returns, so the layout unwinds without drawing again.
+ l.stopped = true
+ return
+ }
+ l.pages++
l.page = l.b.AddPage()
l.cursor = MarginPt
l.pending = 0
@@ -467,6 +517,9 @@ func (l *layout) applyPending() {
// block lays out one node.
func (l *layout) block(n Node, ctx blockContext) {
+ if l.stopped {
+ return
+ }
switch kindOf(n) {
case blockHeading:
level := headingLevel(n)
@@ -686,9 +739,15 @@ func (l *layout) paragraph(content []Node, ctx blockContext, lineHeight float64)
// drawLine places one assembled line and moves the cursor past it.
func (l *layout) drawLine(line []token, ctx blockContext, lineHeight float64) {
+ if l.stopped {
+ return
+ }
l.applyPending()
if l.cursor+lineHeight > contentBottom {
l.newPage()
+ if l.stopped {
+ return
+ }
}
if l.captureNextLine {
l.capturedPage = l.page
@@ -756,6 +815,21 @@ func (l *layout) drawLine(line []token, ctx blockContext, lineHeight float64) {
}
}
}
+ if l.sink != nil {
+ // Reported from inside drawLine, after alignment has been
+ // resolved and before the pen moves, so what the sink hears is
+ // where the line really went — not where a second pass would
+ // calculate that it ought to have gone.
+ var width, size float64
+ for _, t := range line {
+ width += t.width
+ if t.st.size > size {
+ size = t.st.size
+ }
+ }
+ l.sink(LineBox{Left: x, Top: l.cursor, Width: width, Height: lineHeight, Size: size})
+ }
+
for _, t := range line {
l.draw(l.page, x, baseline, t.st, t.text)
x += t.width
@@ -767,8 +841,14 @@ func (l *layout) drawLine(line []token, ctx blockContext, lineHeight float64) {
// advance moves past a line box without drawing anything — an empty
// paragraph.
func (l *layout) advance(lineHeight float64, ctx blockContext) {
+ if l.stopped {
+ return
+ }
if l.cursor+lineHeight > contentBottom {
l.newPage()
+ if l.stopped {
+ return
+ }
}
l.cursor += lineHeight
l.atPageTop = false