Skip to content
Rental Agreement

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:

HelperTurnsInto
listItems["Alice", "Bob", "Carol"]Alice, Bob and Carol, or with commas only if the flag is false
numberToWord3three, or Three when capitalized
formatTimean RFC 3339 timestampa 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.go

If this is your first time using UniPDF, follow the getting started guide to create an API key and set up your development environment.

View the full source
Last updated on