t0ul

Automate the Boring Stuff with Go, Ch. 10: PDFs

Part of Automate the Boring Stuff with Go, a beginner-friendly guide to programming in Go.

Updated for Go 1.27 in October 2026. What changed: the first edition built on gofpdf, which is archived, and its “combine PDFs” project did not combine PDFs. This chapter is rewritten on pdfcpu for merging and stamping and ledongthuc/pdf for reading text, with three new projects and the sample PDFs generated by a program in the repo.

PDF is the format documents arrive in when nobody expects you to edit them: invoices, contracts, scanned forms, the report someone exported from a tool you don’t have. Two jobs come up over and over: get the text out, and put several files together (or stamp something across them). Go handles both without Acrobat.

A thing to know before we start: a PDF does not store paragraphs. It stores drawing instructions, “put these glyphs at this position in this font”. Extracted text is a reconstruction, and it is only as tidy as the document that produced it. Scanned PDFs contain pictures of text and no text at all; that needs OCR, which is outside this chapter.

Inside the repo both libraries are already in go.mod. In a project of your own:

go get github.com/pdfcpu/pdfcpu github.com/ledongthuc/pdf

The code is under ch10/projects, and the sample PDFs next to it come from go run ./tools/genfixtures, which includes a tiny PDF writer of its own, so there is nothing in the repo we did not make.

Reading the text of a PDF

The first project prints the page count and the plain text of every page:

// read-pdf-text prints the page count of a PDF and the plain text of every page.
// PDFs store drawing instructions, not paragraphs, so extracted text is only as tidy
// as the document that produced it.
//
//	go run ./ch10/projects/read-pdf-text ./ch10/projects/read-pdf-text/sample.pdf
package main

import (
	"errors"
	"fmt"
	"os"
	"strings"

	"github.com/ledongthuc/pdf"
)

// PageTexts returns the plain text of each page, in order.
func PageTexts(path string) ([]string, error) {
	f, r, err := pdf.Open(path)
	if err != nil {
		return nil, fmt.Errorf("open %s: %w", path, err)
	}
	defer f.Close()

	var pages []string
	for i := 1; i <= r.NumPage(); i++ {
		p := r.Page(i)
		if p.V.IsNull() {
			pages = append(pages, "")
			continue
		}
		text, err := p.GetPlainText(nil)
		if err != nil {
			return nil, fmt.Errorf("page %d: %w", i, err)
		}
		pages = append(pages, strings.TrimSpace(text))
	}
	return pages, nil
}

func run(args []string) error {
	if len(args) != 1 {
		return errors.New("usage: read-pdf-text <file.pdf>")
	}
	pages, err := PageTexts(args[0])
	if err != nil {
		return err
	}
	fmt.Printf("%s: %d page(s)\n", args[0], len(pages))
	for i, text := range pages {
		fmt.Printf("\n--- page %d ---\n%s\n", i+1, text)
	}
	return nil
}

func main() {
	if err := run(os.Args[1:]); err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1)
	}
}
$ go run ./ch10/projects/read-pdf-text ./ch10/projects/read-pdf-text/sample.pdf
sample.pdf: 2 page(s)

--- page 1 ---
Automate the Boring Stuff with Go
Chapter 10: PDFs

This is page one of the sample document.

--- page 2 ---
Page two.
The text on this page was extracted by a Go program.

pdf.Open returns three things: the open file (so you can close it), the reader, and an error. Pages count from 1, not 0, because that is how PDFs count. The p.V.IsNull() check skips a page the reader could not load rather than crashing on it; real-world PDFs are full of small surprises.

Merging PDFs

The second project joins PDF files into one, in the order given, and reports the page count of the result. Hand it a folder and it merges every PDF in it, sorted by name:

// merge-pdfs joins PDF files into one, in the order given, and reports the page count
// of the result. Pass a folder instead of files to merge every PDF in it, sorted by name.
//
//	go run ./ch10/projects/merge-pdfs -out merged.pdf ./ch10/projects/merge-pdfs
package main

import (
	"context"
	"errors"
	"flag"
	"fmt"
	"os"
	"path/filepath"
	"strings"

	"github.com/pdfcpu/pdfcpu/pkg/api"
)

// Inputs expands the arguments: a folder becomes its *.pdf files sorted by name; a file
// is taken as given. The output file is never included even if it sits in the folder.
func Inputs(args []string, out string) ([]string, error) {
	var files []string
	outAbs, _ := filepath.Abs(out)
	for _, a := range args {
		info, err := os.Stat(a)
		if err != nil {
			return nil, err
		}
		if !info.IsDir() {
			files = append(files, a)
			continue
		}
		matches, err := filepath.Glob(filepath.Join(a, "*.pdf"))
		if err != nil {
			return nil, err
		}
		for _, m := range matches {
			if abs, _ := filepath.Abs(m); abs == outAbs {
				continue
			}
			files = append(files, m)
		}
	}
	if len(files) < 2 {
		return nil, fmt.Errorf("need at least two PDFs, found %d", len(files))
	}
	return files, nil
}

// Merge writes the concatenation of in to out and returns the page count of out.
func Merge(ctx context.Context, in []string, out string) (int, error) {
	if err := api.MergeCreateFile(ctx, in, out, false, nil); err != nil {
		return 0, fmt.Errorf("merge: %w", err)
	}
	n, err := api.PageCountFile(ctx, out)
	if err != nil {
		return 0, fmt.Errorf("count pages of %s: %w", out, err)
	}
	return n, nil
}

func run(args []string) error {
	flags := flag.NewFlagSet("merge-pdfs", flag.ContinueOnError)
	out := flags.String("out", "merged.pdf", "output file")
	if err := flags.Parse(args); err != nil {
		return err
	}
	if flags.NArg() == 0 {
		return errors.New("usage: merge-pdfs [-out merged.pdf] <folder or file.pdf ...>")
	}
	in, err := Inputs(flags.Args(), *out)
	if err != nil {
		return err
	}
	n, err := Merge(context.Background(), in, *out)
	if err != nil {
		return err
	}
	fmt.Printf("merged %s\ninto %s (%d pages)\n", strings.Join(in, ", "), *out, n)
	return nil
}

func main() {
	if err := run(os.Args[1:]); err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1)
	}
}
$ go run ./ch10/projects/merge-pdfs -out merged.pdf ./ch10/projects/merge-pdfs
merged ch10/projects/merge-pdfs/part1.pdf, ch10/projects/merge-pdfs/part2.pdf, ch10/projects/merge-pdfs/part3.pdf
into merged.pdf (6 pages)

All the work is api.MergeCreateFile. The rest of the program is the part people forget: expanding a folder to its PDFs in a predictable order, and making sure the output file is never one of the inputs, which matters the second time you run it in the same folder. pdfcpu’s functions take a context.Context first; that is how you would give a long merge a deadline. We pass context.Background(), meaning no deadline.

The same library does the opposite jobs too: api.SplitFile cuts a document into one file per page (or per N pages), api.RotateFile rotates, api.EncryptFile password-protects. They all have the same shape as the merge call.

Stamping a watermark

The third project writes a word diagonally across every page, the way DRAFT or CONFIDENTIAL appears on documents that leave the building:

// watermark-pdfs stamps a word diagonally across every page of a PDF, the way "DRAFT"
// or "CONFIDENTIAL" appears on documents that leave the building.
//
//	go run ./ch10/projects/watermark-pdfs -text DRAFT ./ch10/projects/watermark-pdfs/contract.pdf
package main

import (
	"context"
	"errors"
	"flag"
	"fmt"
	"os"
	"path/filepath"
	"strings"

	"github.com/pdfcpu/pdfcpu/pkg/api"
	"github.com/pdfcpu/pdfcpu/pkg/pdfcpu/types"
)

// Stamp writes a copy of in to out with text across every page.
func Stamp(ctx context.Context, in, out, text string) error {
	desc := "font:Helvetica, points:56, rot:45, op:0.25, fillcolor:#b00020, scale:0.8 rel"
	wm, err := api.TextWatermark(ctx, text, desc, true, false, types.POINTS, nil)
	if err != nil {
		return fmt.Errorf("describe watermark: %w", err)
	}
	if err := api.AddWatermarksFile(ctx, in, out, nil, wm, nil); err != nil {
		return fmt.Errorf("stamp %s: %w", in, err)
	}
	return nil
}

// OutputName puts "_stamped" before the extension: contract.pdf -> contract_stamped.pdf.
func OutputName(in string) string {
	ext := filepath.Ext(in)
	return strings.TrimSuffix(in, ext) + "_stamped" + ext
}

func run(args []string) error {
	flags := flag.NewFlagSet("watermark-pdfs", flag.ContinueOnError)
	text := flags.String("text", "DRAFT", "the word to stamp")
	if err := flags.Parse(args); err != nil {
		return err
	}
	if flags.NArg() == 0 {
		return errors.New("usage: watermark-pdfs [-text DRAFT] <file.pdf ...>")
	}
	for _, in := range flags.Args() {
		out := OutputName(in)
		if err := Stamp(context.Background(), in, out, *text); err != nil {
			return err
		}
		fmt.Println("wrote", out)
	}
	return nil
}

func main() {
	if err := run(os.Args[1:]); err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1)
	}
}
$ go run ./ch10/projects/watermark-pdfs -text DRAFT ./ch10/projects/watermark-pdfs/contract.pdf
wrote ch10/projects/watermark-pdfs/contract_stamped.pdf

The watermark is described by a small text string, font:Helvetica, points:56, rot:45, op:0.25 ..., which is pdfcpu’s own mini-language for size, rotation, opacity and colour. OutputName keeps the original untouched and writes _stamped beside it; the same don’t-overwrite-the-input habit as chapter 9.

Conclusion

Read the text, merge, split, stamp: that covers most of what people need from PDFs in a script. When a PDF gives you garbage text, it is usually a scan, and the fix is OCR rather than a different Go library.

← Back to the blog