Rental Agreement
A twelve-page lease agreement, mostly running prose with numbered clauses, followed by a signature page, a security deposit receipt, an amount-due page and a move-in checklist. It is the longest template in the set and the one where the markup is doing least of the work: the layout is a single column of paragraphs, and almost all the interesting logic is in the Go helper functions that turn data into English. The templates overview covers the shared mechanics.
Reach for this example if you are generating contracts, policies or anything else where the output is text with values substituted into it rather than a grid of figures.
Page margins hold the header and footer
c := creator.New()
c.SetPageMargins(90, 60, 95, 135)Those are left, right, top and bottom. The top and bottom are unusually large because the
header and footer are themselves templates, and the block a header or footer callback receives
is exactly as tall as the corresponding page margin. A 95 point top margin is what gives
header.tpl room for a logo, a company name and a rule line. Shrink the margin and the header
draws over the body text instead of above it.
The document is split by page-break, not by chapters
There are no chapters here. Sections that must start on a fresh page are separated with an explicit break in the markup:
{{/* render on a new page */}}
<page-break></page-break>
{{template "simple-paragraph" dict "Margin" "18 0 10 0" "Font" "times-bold" "Text" "LANDLORD(S) SIGNATURE"}}Four breaks divide the file into the agreement body, the signature page, the deposit receipt, the amount-due summary and the checklist. Everything before the first break flows continuously and paginates on its own.
That is the right choice when the sections are not a hierarchy and you do not want a table of
contents. If you do want numbered sections and a generated contents page, that is
c.NewChapter on the Go side rather than markup - see report.
Two paragraph subtemplates carry the whole body
Nearly every clause goes through one of two definitions:
{{define "paragraph-with-header"}}
<paragraph margin="{{$margin}}" line-height="1.1">
<text-chunk font="times-bold" font-size="12">{{.Header}}: </text-chunk>
<text-chunk font="times" font-size="12">{{.Text}} </text-chunk>
</paragraph>
{{end}}paragraph-with-header is a bold clause title followed by the body in the same paragraph;
simple-paragraph is the body alone, with optional font, size and margin. Both take a dict,
which is what lets a caller override one field and leave the rest defaulted:
{{template "simple-paragraph" dict "FontSize" 18.5 "Margin" "15 0 0 0" "Font" "times-bold" "Text" "Living Room"}}simple-paragraph implements its defaults with {{$font := "times"}} at the top and
{{$font = .Font}} inside the if. Note the second one is =, not :=. Using := inside an
if block declares a new variable scoped to that block, so the assignment is thrown away when
the block ends and the default survives. paragraph-with-header in this example has that exact
mistake on its margin, which is why the "Margin" its callers pass has no effect. It is worth
knowing about because the template still compiles and still renders - the value is simply
ignored.
Helpers do the language, not the layout
TemplateOptions.HelperFuncMap carries four functions, and three of them exist because legal
prose has to read properly:
| Helper | Turns | Into |
|---|---|---|
listItems | ["Alice", "Bob", "Carol"] | Alice, Bob and Carol, or with commas only if the flag is false |
numberToWord | 3 | three, or Three when capitalized |
formatTime | an RFC 3339 timestamp | a date formatted with a Go layout |
They are called inline through printf, so the sentence and its substitutions stay together in
the markup:
{{template "paragraph-with-header" dict "Header" "OCCUPANT(S)" "Text" (printf `... the
following %s (%d) Occupants to reside on the Premises ...: %s ...` (numberToWord (len .Tenant.Names) true) (len .Tenant.Names) (listItems .Tenant.Names true))}}numberToWord covers 1 to 99 through a map plus a tens-and-units rule. Anything at or above
100 falls through to an empty string rather than an error, so a clause reading “() days notice”
is the symptom of a value outside that range.
Measuring text to place a rule line
The fourth helper, getWidth, is the one that solves a real layout problem. The move-in
checklist needs a label followed by a ruled blank of the remaining width, and the rule has to
start where the label ends. There is no markup construct for “as wide as the previous
text”, so the width is computed in Go:
"getWidth": func(text, stdFontName string) float64 {
stdFont := model.StdFontName(stdFontName)
font, err := model.NewStandard14Font(stdFont)
if err != nil {
log.Fatal(err)
}
textWidth := 0.0
for _, r := range text {
metrics, bool := font.GetRuneMetrics(r)
if !bool {
log.Fatal("failed to get width")
}
textWidth += metrics.Wx
}
return textWidth / 100
},and used as the left margin of a full-width line:
{{define "checklist-row"}}
{{$margin := getWidth (printf "%s%s" . " Condition ") "Times-Roman"}}
<table-cell>
<division margin="5 0">
<paragraph>
<text-chunk font = "times">{{.}} Condition </text-chunk>
</paragraph>
<line fit-mode="fill-width" position="relative" thickness= "0.5" margin="0 0 0 {{$margin}}"></line>
</division>
</table-cell>
...
{{end}}fit-mode="fill-width" makes the line span the cell, and the left margin pushes its start
point past the label. Margin values are top right bottom left, so 0 0 0 {{$margin}} is a
left offset only.
Two things to know if you copy this. GetRuneMetrics returns widths in glyph space, meaning
thousandths of the font size, so the width in points is sum / 1000 * fontSize. Dividing by
100 as above is a shortcut that lands close to correct at 12 point and drifts at any other
size. And GetRuneMetrics returns false for a rune the standard 14 font has no metrics for,
which here calls log.Fatal - fine for an example, worth replacing with an error return in
anything real.
Header and footer share one closure
Both callbacks do the same thing, so the example writes it once:
drawHeader := func(tplPath string, block *creator.Block, pageNum, totalPages int) {
tpl, err := readTemplate(tplPath)
if err != nil {
log.Fatal(err)
}
data := map[string]interface{}{
"Agreement": rentalAgreement,
"PageNum": pageNum,
}
if err := block.DrawTemplate(c, tpl, data, tplOpts); err != nil {
log.Fatal(err)
}
}
c.DrawHeader(func(block *creator.Block, args creator.HeaderFunctionArgs) {
drawHeader("templates/header.tpl", block, args.PageNum, args.TotalPages)
})
c.DrawFooter(func(block *creator.Block, args creator.FooterFunctionArgs) {
drawHeader("templates/footer.tpl", block, args.PageNum, args.TotalPages)
})block.DrawTemplate is the block-level equivalent of c.DrawTemplate, and passing the same
tplOpts is what makes arial-bold resolve inside footer.tpl - the font map is not global,
it travels with the options. Note the template is read from disk on every page. For a
twelve-page document that does not matter; for a few hundred pages, read it once into a buffer
and reuse the bytes.
The footer decides per page what to show:
{{if gt 10 .PageNum}}
<image src="path('templates/res/house.png')" width="45" height="48"></image>
{{end}}gt 10 .PageNum reads as 10 > PageNum, so the house image, the initials line and the QR code
appear on pages 1 to 9 and drop off from page 10 onward, where the appendices start. The page
number itself is outside the guard and prints on every page. Only PageNum is passed into the
data map, so a footer reading “Page 3 of 12” would need TotalPages added there as well.
Run the example
The Go file is about 280 lines, over half of them the data struct and the number-to-word map.
templates/main.tpl is around 480 lines and is mostly the text of the lease.
git clone https://github.com/unidoc/unipdf-examples.git
cd unipdf-examples/templates/rental-agreement
go run pdf_rental_agreement.goIf this is your first time using UniPDF, follow the getting started guide to create an API key and set up your development environment.