oficina / docs Public

The exporter can report where page one's lines landed

PageOne runs the PDF exporter's own layout with a sink on drawLine and
a stop after the first page, so a caller gets the real geometry of a
document's first page — where each line starts, how wide it actually
is, how tall, and the largest type size on it — for one page's worth
of work regardless of how long the document is.

This is for the document list's thumbnails. Spec decision 11 asks for
real page-one thumbnails and §11.4 is right that a raster one needs a
pipeline this app has not got. What §11.4 did not anticipate is the
scale: at 26x34 a page is ~23pt to the pixel, so a glyph is a fraction
of a pixel and rasterizing one would cost a great deal to draw a grey
smudge. At that size the line boxes are the rendering.

Reusing the exporter's layout rather than writing an estimator beside
it is the whole point. A second opinion about where lines break is
free to drift from the first, and drift is what the list would show.
TestPageOneMatchesTheFirstPageOfThePDF reads the exported PDF back and
holds every reported line to the baseline and x the exporter really
drew it at; TestPageOneDoesNotDisturbPDFExport holds the sink to
changing no PDF byte, because "additive" is a claim about code and
that is a claim about output.

Both were seen to fail: reporting a line 1pt right of where it was
drawn reddens the agreement test against real PDF bytes, and removing
the page bound turns 400 paragraphs into 401 reported lines.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014JM96sbqzZLJgqYmAs4j5y
Paul Campbell pushed by paul@keymail.dev 748a3a0615eac0799fc60af278ab32ff876eb398 parent 7507a33
3 files changed, +365 −0
  • internal/export/pageone.go +94 −0
  • internal/export/pageone_test.go +217 −0
  • internal/export/pdf.go +54 −0
diff --git a/internal/export/pageone.go b/internal/export/pageone.go
new file mode 100644
index 0000000..6987980
--- /dev/null
+++ b/internal/export/pageone.go
@@ -0,0 +1,94 @@
+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 §6.4 and decision 11 ask for "real rendered
+// page-one thumbnails", and §11.4 is right that a *raster* one needs a
+// pipeline this app does not have — glyph rasterization, image decode,
+// PNG composition. What §11.4 did not anticipate is that at 26x34 px a
+// page is about twenty-three points to the pixel, so a glyph is a
+// fraction of a pixel wide and rasterizing one would render a grey
+// smudge at enormous cost. At that size the line boxes ARE the
+// rendering, and this reports the real ones.
+//
+// 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. When the layout engine and
+// internal/raster land (spec §13), the honest upgrade is to keep this
+// call and rasterize what it reports.
+//
+// Cost is one page of layout regardless of the document's length —
+// stopAfterPage ends it once the first page is full — which is what
+// makes it affordable on a page that renders a row per document.
+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()
+
+ // The title is laid out exactly as PDF does it, for the reason PDF
+ // does it: it is not part of the CRDT, and a page one that omitted
+ // it would not be the page one the exporter produces. Keep these
+ // two in step — TestPageOneMatchesTheFirstPageOfThePDF is what
+ // notices if they stop being.
+ if doc.Title != "" {
+ l.block(Node{
+ Type: "heading",
+ Attrs: Attrs{Level: 1},
+ Content: []Node{{Type: "text", Text: doc.Title}},
+ }, blockContext{scale: 1})
+ }
+
+ for _, n := range doc.Root.Content {
+ l.block(n, blockContext{scale: 1})
+ }
+ 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.
+//
+// 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..370167f
--- /dev/null
+++ b/internal/export/pageone_test.go
@@ -0,0 +1,217 @@
+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)
+ }
+}
+
+// PageOne must not change what PDF draws. The guard is cheap: the same
+// document exported before and after a PageOne run has to produce
+// identical bytes.
+func TestPageOneDoesNotDisturbPDFExport(t *testing.T) {
+ js := `{"type":"doc","content":[
+ {"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"Heading"}]},
+ {"type":"bullet_list","content":[
+ {"type":"list_item","content":[{"type":"paragraph","content":[{"type":"text","text":"An item."}]}]}
+ ]}
+ ]}`
+ doc := mustParse(t, "Notes", js)
+
+ before, _ := mustPDF(t, doc)
+ _ = export.PageOne(doc)
+ after, _ := mustPDF(t, doc)
+
+ if string(before) != string(after) {
+ t.Error("a PageOne run changed what PDF export produces")
+ }
+}
+
+// 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. Lists are covered by
+// TestPageOneDoesNotDisturbPDFExport instead, which compares bytes.
+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 83f7772..dad8254 100644
--- a/internal/export/pdf.go
+++ b/internal/export/pdf.go
@@ -330,10 +330,34 @@ 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 is byte-for-byte
+ // unaffected — TestPageOneDoesNotDisturbPDFExport is what holds
+ // that, because "additive" is a claim about code and that is a
+ // claim about output.
+ 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
@@ -370,6 +394,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)
@@ -582,9 +609,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
@@ -652,6 +685,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
@@ -663,8 +711,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