oficina / docs Public

PDF: draw text one margin in from the page edge

The vertical cursor started at MarginPt (newPage), the horizontal one
never did: every x came from blockContext.indent, which starts at zero
and is only ever added to for a blockquote's or a list's own inset. So
body text was drawn flush against the left edge of the paper, and
because line breaking still used the correct 6.5in column, every page
also carried a two-inch gutter on the right.

indent keeps meaning "extra indent beyond the margin" — the alternative,
making it absolute, would silently break `maxWidth := contentWidthPt -
ctx.indent`, since contentWidthPt is already the column between the two
margins. MarginPt is added at the three places that compute an x: the
text draw, the blockquote rule, and the list marker.

The test is the point. Nothing in this package asserted where anything
was drawn — only what it said — so a whole-page offset passed every one
of them, and pdfread_test.go's reader threw the positions away before a
test could see them. It now returns each drawn run with the baseline
from its own text matrix, and each filled rectangle, and the new test
pins the paragraph's x to the margin exactly, the quote rule's likewise,
and asserts nothing at all is drawn left of the margin — the list
marker, which sits deliberately left of its own text, is the tightest
case. Against the unfixed layout it reported x=0, x=0 and six runs in
the margin.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev d708412a3d2771b842f10f21d11227b8751eb066 parent 2392ea3
3 files changed, +160 −18
  • internal/export/pdf.go +11 −3
  • internal/export/pdf_test.go +60 −0
  • internal/export/pdfread_test.go +89 −15
diff --git a/internal/export/pdf.go b/internal/export/pdf.go
index 77bc827..c0b5c43 100644
--- a/internal/export/pdf.go
+++ b/internal/export/pdf.go
@@ -185,6 +185,14 @@ type style struct {
// inside: an indent, and whether it is inside a blockquote (which
// makes its text italic and grey, and draws a rule beside every line).
type blockContext struct {
+ // indent is EXTRA indent beyond the page margin, never an absolute
+ // x — a blockquote's or a list's own inset, and zero for ordinary
+ // body text. MarginPt is added at draw time, in the three places
+ // below that compute an x. Keeping it relative is what makes
+ // `maxWidth := contentWidthPt - ctx.indent` in paragraph correct:
+ // contentWidthPt is already the column between the two margins, so
+ // an absolute indent would subtract the left margin from the
+ // column a second time and wrap every line an inch early.
indent float64
quoted bool
// scale multiplies the base font size — headings set it for their
@@ -406,7 +414,7 @@ func (l *layout) list(list Node, ctx blockContext, ordered bool) {
if l.capturedPage != nil {
asc, _ := ascentDescent(markerStyle)
baseline := l.capturedTop + halfLeading(markerStyle, BodySizePt*BodyLeading) + asc
- l.draw(l.capturedPage, inner.indent-textWidth(marker, markerStyle), baseline, markerStyle, marker)
+ l.draw(l.capturedPage, MarginPt+inner.indent-textWidth(marker, markerStyle), baseline, markerStyle, marker)
l.capturedPage = nil
}
l.space(BodySizePt * listItemSpace)
@@ -516,11 +524,11 @@ func (l *layout) drawLine(line []token, ctx blockContext, lineHeight float64) {
if ctx.quoted {
// One rule segment per line, so a quote that breaks across a
// page gets a rule on both halves and none in the gap.
- l.page.Rule(ctx.indent-BodySizePt*quoteIndentEm, PageHeightPt-l.cursor-lineHeight,
+ l.page.Rule(MarginPt+ctx.indent-BodySizePt*quoteIndentEm, PageHeightPt-l.cursor-lineHeight,
quoteRulePt, lineHeight, quoteRuleGray)
}
- x := ctx.indent
+ x := MarginPt + ctx.indent
for _, t := range line {
l.draw(l.page, x, baseline, t.st, t.text)
x += t.width
diff --git a/internal/export/pdf_test.go b/internal/export/pdf_test.go
index 4880631..9012fdc 100644
--- a/internal/export/pdf_test.go
+++ b/internal/export/pdf_test.go
@@ -3,6 +3,7 @@ package export_test
import (
"bytes"
"fmt"
+ "math"
"regexp"
"strconv"
"strings"
@@ -363,3 +364,62 @@ func TestPDFDrawsListMarkersAndQuotedText(t *testing.T) {
}
}
}
+
+// Where the ink actually starts.
+//
+// Every other test in this file asks what the PDF says; none of them
+// asked where it says it, and a whole-page horizontal offset shipped
+// through all of them — body text was drawn at x=0, flush against the
+// left edge of the paper, while line breaking still used the correct
+// 6.5in column, so every page also carried a two-inch gutter on the
+// right. The words all copied back out perfectly. That is exactly the
+// shape of defect a text-only assertion cannot see, which is why this
+// reads the text matrix (pdfread_test.go's runs) rather than the text.
+//
+// The three x-coordinates the layout computes are all here: the text
+// draw, the blockquote's rule, and the list marker. A list marker is
+// the one thing deliberately drawn to the LEFT of its own text — it
+// sits in the list's indent — so it is the tightest check that "no ink
+// in the margin" is a real bound rather than a slack one.
+func TestPDFStartsTextAtTheLeftMargin(t *testing.T) {
+ js := `{"type":"doc","content":[
+ {"type":"paragraph","content":[{"type":"text","text":"Body text."}]},
+ {"type":"bullet_list","content":[
+ {"type":"list_item","content":[{"type":"paragraph","content":[{"type":"text","text":"Item"}]}]}
+ ]},
+ {"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"Quoted"}]}]}
+ ]}`
+ // No title: the first thing drawn has to be the first paragraph,
+ // with nothing above it to borrow a position from.
+ data, _ := mustPDF(t, mustParse(t, "", js))
+ f := readPDF(t, data)
+ page := f.pages(t)[0]
+
+ runs := f.runs(t, page)
+ if len(runs) == 0 {
+ t.Fatal("nothing was drawn on the page")
+ }
+ if got := runs[0].text; got != "Body" {
+ t.Fatalf("the first drawn run is %q, want %q — this test is no longer looking at the paragraph it thinks it is", got, "Body")
+ }
+ if math.Abs(runs[0].x-export.MarginPt) > 0.01 {
+ t.Errorf("body text starts at x=%v, want the page margin %v", runs[0].x, export.MarginPt)
+ }
+
+ rules := f.rules(t, page)
+ if len(rules) != 1 {
+ t.Fatalf("got %d filled rectangles, want 1 (the blockquote's edge)", len(rules))
+ }
+ if math.Abs(rules[0].x-export.MarginPt) > 0.01 {
+ t.Errorf("the blockquote rule starts at x=%v, want the page margin %v", rules[0].x, export.MarginPt)
+ }
+
+ // Nothing at all may be drawn in the margin — including the bullet,
+ // which sits left of its own text and is the closest thing to the
+ // edge on the page.
+ for _, r := range runs {
+ if r.x < export.MarginPt-0.01 {
+ t.Errorf("%q is drawn at x=%v, inside the %v page margin", r.text, r.x, export.MarginPt)
+ }
+ }
+}
diff --git a/internal/export/pdfread_test.go b/internal/export/pdfread_test.go
index 09ad573..3669096 100644
--- a/internal/export/pdfread_test.go
+++ b/internal/export/pdfread_test.go
@@ -195,19 +195,30 @@ var (
contentsRe = regexp.MustCompile(`/Contents (\d+) 0 R`)
toUnicodeRe = regexp.MustCompile(`/ToUnicode (\d+) 0 R`)
bfcharRe = regexp.MustCompile(`<([0-9A-Fa-f]+)> <([0-9A-Fa-f]+)>`)
- showRe = regexp.MustCompile(`/(F\d+) [\d.]+ Tf [^<]*<([0-9A-Fa-f]*)> Tj`)
+ // One drawn run, exactly as internal/pdf's Page.Text writes it:
+ // the text matrix carries the baseline position, so the x and y a
+ // run actually landed at are readable here and nowhere else.
+ showRe = regexp.MustCompile(
+ `/(F\d+) [\d.-]+ Tf 1 0 0 1 ([\d.-]+) ([\d.-]+) Tm <([0-9A-Fa-f]*)> Tj`)
+ // One filled rectangle (Page.Rule) — the blockquote's edge is the
+ // only non-text drawing this exporter does.
+ ruleRe = regexp.MustCompile(`([\d.-]+) ([\d.-]+) ([\d.-]+) ([\d.-]+) re f`)
)
-// text reconstructs what a person would get by selecting everything on
-// the page and copying it: the glyph ids in the content stream, mapped
-// back through each font's own ToUnicode CMap. If that mapping is
-// wrong or missing, this returns mojibake, which is exactly what a
-// reader would show.
-func (f pdfFile) text(t *testing.T, page pdfObject) string {
- t.Helper()
+// run is one drawn piece of text with where it was drawn: x and y are
+// the baseline's own coordinates, in points from the bottom-left of the
+// page, which is PDF's origin and not the layout's.
+type run struct {
+ x, y float64
+ text string
+}
- // resource name -> gid -> rune
- cmaps := map[string]map[uint16]rune{}
+// cmaps maps each font resource named by a page to its own ToUnicode
+// table — the glyph-id-to-rune mapping a reader uses to let a person
+// copy text out.
+func (f pdfFile) cmaps(t *testing.T, page pdfObject) map[string]map[uint16]rune {
+ t.Helper()
+ out := map[string]map[uint16]rune{}
for _, m := range fontResRe.FindAllStringSubmatch(page.dict, -1) {
n, _ := strconv.Atoi(m[2])
fontObj, ok := f.objects[n]
@@ -224,23 +235,48 @@ func (f pdfFile) text(t *testing.T, page pdfObject) string {
gid, _ := strconv.ParseUint(e[1], 16, 16)
cmap[uint16(gid)] = decodeUTF16BE(e[2])
}
- cmaps[m[1]] = cmap
+ out[m[1]] = cmap
}
+ return out
+}
+func (f pdfFile) content(t *testing.T, page pdfObject) string {
+ t.Helper()
cm := contentsRe.FindStringSubmatch(page.dict)
if cm == nil {
t.Fatal("page has no /Contents")
}
cn, _ := strconv.Atoi(cm[1])
- content := string(f.objects[cn].stream)
+ return string(f.objects[cn].stream)
+}
- var b strings.Builder
- for _, m := range showRe.FindAllStringSubmatch(content, -1) {
+// runs returns every drawn piece of text on the page WITH its baseline
+// position, in the order the content stream draws them.
+//
+// text below throws the positions away, which is why a whole-page
+// horizontal offset was invisible to every test in this package until
+// this existed: the words all copied back out correctly, they were just
+// drawn in the margin.
+func (f pdfFile) runs(t *testing.T, page pdfObject) []run {
+ t.Helper()
+ cmaps := f.cmaps(t, page)
+
+ var out []run
+ for _, m := range showRe.FindAllStringSubmatch(f.content(t, page), -1) {
cmap, ok := cmaps[m[1]]
if !ok {
t.Fatalf("content stream uses font %s, which the page resources do not declare", m[1])
}
- hex := m[2]
+ x, err := strconv.ParseFloat(m[2], 64)
+ if err != nil {
+ t.Fatalf("unreadable x in the text matrix: %q", m[2])
+ }
+ y, err := strconv.ParseFloat(m[3], 64)
+ if err != nil {
+ t.Fatalf("unreadable y in the text matrix: %q", m[3])
+ }
+ var b strings.Builder
+ hex := m[4]
for i := 0; i+4 <= len(hex); i += 4 {
gid, _ := strconv.ParseUint(hex[i:i+4], 16, 16)
r, ok := cmap[uint16(gid)]
@@ -250,6 +286,44 @@ func (f pdfFile) text(t *testing.T, page pdfObject) string {
}
b.WriteRune(r)
}
+ out = append(out, run{x: x, y: y, text: b.String()})
+ }
+ return out
+}
+
+// rect is one filled rectangle, in PDF coordinates.
+type rect struct{ x, y, w, h float64 }
+
+// rules returns every filled rectangle on the page — the blockquote
+// edge, which is drawn at an x of its own and would sit in the margin
+// alongside the text if that x were wrong.
+func (f pdfFile) rules(t *testing.T, page pdfObject) []rect {
+ t.Helper()
+ var out []rect
+ for _, m := range ruleRe.FindAllStringSubmatch(f.content(t, page), -1) {
+ var r rect
+ for i, dst := range []*float64{&r.x, &r.y, &r.w, &r.h} {
+ v, err := strconv.ParseFloat(m[i+1], 64)
+ if err != nil {
+ t.Fatalf("unreadable rectangle coordinate %q", m[i+1])
+ }
+ *dst = v
+ }
+ out = append(out, r)
+ }
+ return out
+}
+
+// text reconstructs what a person would get by selecting everything on
+// the page and copying it: the glyph ids in the content stream, mapped
+// back through each font's own ToUnicode CMap. If that mapping is
+// wrong or missing, this returns mojibake, which is exactly what a
+// reader would show.
+func (f pdfFile) text(t *testing.T, page pdfObject) string {
+ t.Helper()
+ var b strings.Builder
+ for _, r := range f.runs(t, page) {
+ b.WriteString(r.text)
}
return b.String()
}