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>
3 files changed,
+160
−18
internal/export/pdf.go+11 −3internal/export/pdf_test.go+60 −0internal/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() |
| } |