CARLOS rastrillo docs

🤖 pdf

amadan.net/rastrillo/rastrillo/pdf

Write a multi-page PDF — headings, paragraphs, field lists, tables and callouts, with pagination and repeating table headers — using only the standard library. No sidecar, no headless browser, no system font: every byte is written by this package, onto A4, in the three PDF base-14 faces every conforming reader already has.

Building a document

type Builder struct{ /* unexported fields */ }

func New(opt Options) *Builder

func (b *Builder) Heading(level int, text string)
func (b *Builder) Paragraph(text string)
func (b *Builder) Fields(title string, fields []Field)
func (b *Builder) Table(title string, columns []Column, rows [][]Cell, total []Cell)
func (b *Builder) List(title string, items []string)
func (b *Builder) Callout(title, text string, strong bool)
func (b *Builder) PageBreak()
func (b *Builder) KeepTogether(fn func(b *Builder))
func (b *Builder) Produced(by, at, note string)
func (b *Builder) Render() ([]byte, []rune, error)
b := pdf.New(pdf.Options{
	Produced: time.Now(),
	Title:    "Statement",
	Author:   "Example Ltd",
})
b.Heading(1, "Account summary")
b.Fields("", []pdf.Field{
	{Label: "Account", Value: "0001234"},
	{Label: "Period", Value: "March 2026"},
})
b.Table("Transactions",
	[]pdf.Column{{Title: "Date"}, {Title: "Amount", Align: "right"}},
	rows, nil)

bs, dropped, err := b.Render()

Call a Builder's methods in the order the page should read; each one flows on from where the one before it left off, starting a fresh page when the current one runs out of room. Call Render once, at the end.

Fields, Table and List take an empty title to omit the heading above them. A Table row, or its total, may be longer than fits on one page: both continue onto further pages, with the column headings repeated at the top of each, and total renders apart from the body so it cannot be misread as another row. Callout boxes a statement behind a left bar; strong draws the bar heavier and the text in italic, for whichever of a caller's own two levels of emphasis this is.

type Column struct {
	Title string
	Align string // "left" (default) or "right"
	Width float64
}

type Cell struct {
	Text string
	Bold bool
}

type Field struct {
	Label  string
	Value  string
	Source string
}

A Column's Width is its share of the table relative to the other columns' — a zero takes an equal share of what the rest leave, so a table with no widths at all still renders sensibly. A negative, infinite or absurdly large Width is treated the same as a zero, rather than reaching the page: there is no sensible share for a weight like that to claim, and letting one through would put a figure outside PDF's own numeric range into the file. Field.Source, left non-empty, prints a small "Recorded by …" line under the value — for a value that is somebody's own assertion rather than the caller's.

A Column with an empty Title is blank padding, not a real column — useful to keep a narrow right-aligned figure from being stretched out to the margin. A table's own rules (under its header, and under and double-under its total) stop at the right edge of the last column that does have a title, rather than running on into that padding; a table where every column is titled rules to the margin exactly as before. A Title that wraps to more than a few lines is cut short with an ellipsis: a table's header has to appear whole, above its data, on every page it repeats on, so there is no "paginate it normally" answer once one wraps to more lines than a page can hold above even one row.

Keeping a heading with what follows it

func (b *Builder) KeepTogether(fn func(b *Builder))
b.KeepTogether(func(b *pdf.Builder) {
	b.Heading(1, "Balance Sheet")
	b.Paragraph("As at 5 April 2026.")
	b.Table("", columns, rows, total)
})

Without this, a heading only ever reserves room for its own height, so one can land as the last line on a page with its table orphaned onto the next. KeepTogether lays out fn's calls — a heading and what always follows it, say — so that if they would not fit in the room left on the current page, a page break happens before any of fn's own output is drawn.

fn runs exactly once, against a throwaway Builder starting at the top of a blank page, never the real one directly. That run is the answer: if it fit on its one fresh page, its output is copied onto the real page wherever that currently is; if it did not — either because the real page has less room left than the fresh one did, or because fn is longer than any single page holds — the real page turns over and adopts that run's output outright, because turning over puts it in exactly the state the throwaway one started from. Either way fn's own pagination is untouched: a long table inside it still breaks across further pages with its header repeated, exactly as it would with no KeepTogether at all.

fn must draw only on the Builder it is handed, never the one KeepTogether was called on — easy to get backwards, since the natural-looking mistake is a closure that closes over the outer one instead of using its own parameter. That mistake is checked for and panics naming the parameter, rather than silently measuring nothing while the real page is drawn on with no room check at all.

Keeping a line with the end of the document

func (b *Builder) Produced(by, at, note string)
b.Produced("Alex Morgan", "24 August 2026", "Draft — not issued")

Produced records who produced the document and when, as a short line — "Produced by Alex Morgan on 24 August 2026." — with note (left empty for none) printed beneath it in bold. It draws into the last page's existing footer area at Render time, rather than flowing as an ordinary block, so it can never end up alone at the top of a trailing page holding nothing else: the same failure KeepTogether exists to stop a heading having, reached from the tail of the document instead of partway through it. Call it at most once; a later call replaces the line rather than adding a second one.

type Options struct {
	Produced       time.Time
	Title          string
	Reference      string
	Author         string
	Stamp          *Stamp
	NoRunningTitle bool
	Landscape      bool
	Size           Size
}

type Stamp struct {
	Head string
	Line string
}

Two renders built from the same calls with the same Options produce identical bytes: nothing in this package reads the clock or iterates a map. Produced stamps the file's creation and modification dates in its metadata. Title and, if set, Reference print on every page as a running footer — "Title — Reference" beside "Page n of N" — so a reader holding a loose sheet can tell what it belongs to and whether one is missing; NoRunningTitle leaves that label off while the page numbers stay. Stamp, left nil, draws nothing; set, it lays Head (large, red) and Line (small, beneath it) over every page — an approval mark, in words, with no signature. Landscape turns the page on its side — margins and type stay the same, only the page's own width and height swap — for a table with more columns than a portrait page can hold at a readable size.

Page size

type Size struct{ W, H float64 }

var (
	A4      = Size{595.28, 841.89}
	A3      = Size{841.89, 1190.55}
	A5      = Size{419.53, 595.28}
	Letter  = Size{612, 792}
	Legal   = Size{612, 1008}
	Tabloid = Size{792, 1224}
)
b := pdf.New(pdf.Options{Size: pdf.Letter})

Options.Size is the page size, in points, portrait orientation; the zero value is A4, so a caller that never sets it gets A4 without naming it. Landscape swaps W and H of whichever Size was chosen, so a landscape Letter page is Options{Size: pdf.Letter, Landscape: true} — there is no separate landscape constant for each size.

Nothing about this package requires one of the named sizes: a Size is just a rectangle, so a caller can supply their own dimensions for anything else a document needs to be printed on, such as a pre-printed form. A Size that cannot hold a page falls back to A4, turned the same way if Landscape is set. That covers the zero value, a negative or non-finite side, a side longer than 14,400pt (200in, the most a PDF reader accepts), and a page too short to hold a four-line table header and one row once the margins are taken off. The check runs after Landscape turns the page.

What can't be drawn

func (b *Builder) Render() ([]byte, []rune, error)

The three faces this package draws with are PDF base-14 — Helvetica, Helvetica-Bold, Helvetica-Oblique — encoded as Windows-1252, so nothing is embedded and the file stays small. A character outside that encoding cannot be drawn: Render's second return is every rune this was asked to draw and could not, across the whole document, deduplicated and sorted. An empty slice means every such character was either drawn or, for a handful of close lookalikes documented under Encode below, deliberately drawn as something else; a caller that ships the bytes without checking it is shipping a character the reader cannot see drawn as a question mark instead.

Measuring without drawing

type Face int

const (
	Regular Face = Face(faceRegular)
	Bold    Face = Face(faceBold)
	Italic  Face = Face(faceItalic)
)

func Encode(s string) ([]byte, []rune)
func Measure(s string, f Face, size float64) float64
func Wrap(s string, f Face, size, max float64) []string

For drawing onto something this package does not lay out itself — a blank form somebody else published, a fixed-position label — Measure and Wrap use the same base-14 metrics Builder draws with, so a value that fits in one place fits in the other: there is exactly one right answer to "how wide is this word", and this package carries it once. Wrap cuts a single token too long for a line of its own where it stops fitting, by rune — except a single rune wider than max itself, which still gets a line of its own even though that line does not fit either, since there is no narrower way to draw it.

Encode is the same Windows-1252 conversion Render uses internally, exposed for the same reason Render returns its dropped runes: a caller that measures a string has to measure the bytes it will actually draw, and has to be able to tell when one of them cannot be. A handful of characters with no base-14 glyph of their own are drawn as something else instead, close enough in appearance that this does not count as a loss and is not reported: a non-breaking space and its narrower relatives as a plain space, a soft hyphen as nothing, a non-breaking hyphen or minus sign as a plain hyphen, and the prime marks as plain quotes. A tab, line feed or carriage return is drawn as a space, and any other control character below U+0020 is dropped. A caller comparing drawn text back against its own source has to know these do not round-trip.

Read this page as markdown — exact, unstyled, and cheap for an agent to fetch.