Fork me on GitHub

The whole of PDF
in Go, with no C anywhere.

Read a file, write one, rearrange it, draw it, read it back as words and pictures, and edit it with other people โ€” in a browser tab if you like, since every one of these builds for js/wasm. Fonts are parsed and shaped by go-opentype, pages are rasterised by go-gfx. Pure Go, CGO_ENABLED=0. BSD-3-Clause.

15 repositories8 architectures, tests run118 863 real PDFs measured
pdfops โ€” zsh
$ pdfops merge out.pdf a.pdf b.pdf
$ pdfops nup -n 4 slides.pdf handout.pdf
$ pdfops encrypt -user letmein -allow print,copy plain.pdf locked.pdf
$ pdfops text -layout -pages 1 paper.pdf
Measured on a page, in points, with what could not be read
marked rather than guessed at.
$ pdfops images paper.pdf pictures/
a JPEG comes out a JPEG
One organisation, 15 libraries, every one of them pure Goand the whole suite runs on 8 of them, big-endian and 32-bit included
Measured, not asserted

Against 118 863 real PDFs, not a handful written to pass a test.

arXiv's figures, Matplotlib and Mathematica and pdfTeX and Ghostscript and Adobe. That corpus is what found a stroke that came out at half its colour, a font that took the dots off every i, and a colour transform that turned every plot's paper yellow. Each of them is in the release notes of the library it was found in.

๐Ÿ“‚

They open

118 833 of the 118 835 files that really are PDFs. Of the 118 863 collected, 27 are PNGs named .pdf and one is a PostScript file; of the rest, two are truncated past recovery.

๐Ÿ”

They round-trip

1 536 769 753 content operations read, and every one of them rewrites to an identical fingerprint. 118 833 files encrypt, decrypt and compress with every page byte for byte the same.

๐Ÿ–Œ๏ธ

They draw

4 108 pages rasterise with no panics and six blank, and the biggest changes are put beside what the operating system's own renderer draws โ€” pixels hashed before and after, never an exit status.

๐Ÿ”ค

They read back

121 946 pages come back as 34 million characters, each piece with a page, a place, a size and what it says. A piece the document gave no way to read is marked rather than guessed at.

The API

Verbs on a document, and a page as pixels.

The same operations the command line exposes are a library first. Nothing shells out, nothing is fetched at run time, and the whole of it compiles to one js/wasm module with no SharedArrayBuffer and no CDN.

main.go
// Take three pages out of a report, turn one the right way up,
// stamp the lot, and write it out.
doc, _ := ops.Open(bytes)
doc.Select("4-6")
doc.SetRotation("2", 90)
doc.Watermark("all", "DRAFT")
out, _ := doc.Bytes()

// Draw a page, and read one back.
img, _ := render.Page(src, 1, render.Options{DPI: 150})
text, _ := extract.Text(src, 1)
The organisation

One library a job, and they compose.

Each is its own module with its own release line, so a program takes the reader without the rasteriser, or the rasteriser without the writer.

reader ready

Reads and writes the format itself. Lexer and object model, cross-reference tables and streams with hybrid files and /Prev chains, object streams, repair by scanning when the table is wrong, every filter (Flate, LZW with early change, ASCIIHex, ASCII85, RunLength, PNG and TIFF predictors), the standard security handler from RC4-40 to AES-256, content streams and inline images, the page tree with inherited attributes โ€” and a writer that produces the same bytes twice. Of 118 863 real PDFs, 118 833 open; the thirty that do not are 27 PNGs named .pdf, one PostScript file and two truncated past recovery.

repository ยท pkg.go.dev

ops ready

The verbs, and the pdfops command โ€” 49 of them. Merge, select, delete, reverse, move, rotate, crop, resize, split, n-up, booklet, poster, divide, duplex, overlay, blank pages, watermarks, page numbers, Bates stamps, page labels, layers, text colour, sanitize, flatten, compress, encrypt, decrypt, permissions, repair, attachments, forms โ€” and reading a page back as text, pictures, tables (CSV, JSON or Markdown) or a whole document in Markdown. Links and bookmarks are carried over and pointed at the pages they became. Every verb is measured on the whole corpus: 118 833 files compressed and read back page for page identical, 35.2 GB down to 33.6 GB.

repository ยท pkg.go.dev

render ready

Turns a page into pixels. The transform stack, every painting operator, both winding rules, strokes with caps/joins/miter/dashes, clipping, every colour space, transparency, form XObjects; images at every depth with soft and stencil masks; text from TrueType, OpenType, bare CFF, PostScript Type 1, composite and Type 3 fonts; PDF functions of all four kinds, all seven shadings โ€” axial, radial, function-based, and the four mesh kinds, triangles and Coons and tensor patches โ€” and shading and tiling patterns, placed in the space of the form that names them. Rasterises with go-gfx and reads outlines with go-opentype. Builds for js/wasm.

repository ยท pkg.go.dev

pdffont ready

What a document says about a font โ€” not the outlines, which are the program’s business. Which codes a string holds, how wide each is, what each is called, and what text each stands for, through /ToUnicode and the encodings. It refuses one guess: a symbolic font read through an assumed encoding gives the wrong letter with nothing to say so. The glyph-name table is checked against the documents themselves โ€” 63 515 names, 99.31% agreement.

repository ยท pkg.go.dev

convert ready

Pictures into a PDF, and a PDF back into pictures. Reads PNG, JPEG, GIF, BMP, TIFF and WebP; writes all but WebP โ€” which it refuses by name, because there is no pure-Go encoder and writing a PNG under a .webp name would be worse than saying no. The content decides a format, never the file’s name. Only the DPI says which page a picture is: 2480ร—3508 is A4 at 300 and a page a metre tall at 72. Fitting onto paper uses one scale for both axes and only ever shrinks.

repository ยท pkg.go.dev

extract ready

Reads a page back: the text with where each piece of it sits, the pictures with the box each covers, and the grids its text lines up into. A page holds instructions for drawing glyphs, not text โ€” so where the document says nothing about what they mean, this says so rather than guessing. A column boundary is a stretch of page every row leaves empty, and three things that look like columns are not: a bullet, a column nothing was drawn in, and a page number. On one 213-page document those three took the count from 588 tables to 40, which are the real ones; a prose report in the same run reports none. Over the whole corpus: 121 946 pages, 34 million characters, 1.89% of runs that nothing in the document could name, 300 685 pictures located, no panics.

repository ยท pkg.go.dev

document ready

A PDF read back as a document rather than as text โ€” headings, paragraphs, lists and tables, into the richdoc model, which gets Markdown, reST and HTML out of a PDF through code the fleet already has. A page has no headings: it has glyphs at places, and everything above that is an inference. A heading is text set larger than the body and rare with it โ€” measured over six real documents, heading sizes hold 0.01% to 5.76% of the characters and a second body size holds a third, and taking “larger” alone gave a nine-page report 131 headings of which 130 were prose. What it will not read is published too: ruled lines, columns of a multi-column layout, and footnotes.

repository ยท pkg.go.dev

coedit ready

A PDF several people edit at once. What is shared is not the file โ€” merging two piles of bytes cannot be done โ€” but the plan: which pages, from which files, in which order, turned and cropped how, with which bookmarks over them. A list, a tree and a handful of fields, which a CRDT merges without asking anybody. Checked with 25 editors making random edits for 40 rounds, merged in a random order, all agreeing on the same document.

repository ยท pkg.go.dev

forms ready

Read, fill and draw an AcroForm โ€” the boxes somebody types into, the squares they tick, the lists they choose from. A field’s value is not what gets drawn: what gets drawn is a little content stream beside it, so a reader that fills a field and does not rewrite that stream leaves the page blank. An AcroForm dictionary with an empty field list is not a form โ€” 561 of the 118 833 figure-corpus files are exactly that, left behind by a producer. Builds for js/wasm.

repository ยท pkg.go.dev

xfa ready

The form inside a PDF whose pages are blank. Of 2 240 real government forms, 560 carry an XFA package and 546 are static โ€” a second, proprietary copy of a form already drawn on the pages, which forms fills without help. The other fourteen are dynamic: their pages hold a panel reading Please wait… your PDF viewer may not be able to display this type of document, and the document exists only as XML. Adobe removed the format from PDF 2.0, and no browser lays one out.

repository ยท pkg.go.dev

html2pdf ready

Static HTML to VECTOR PDF, zero C. No headless browser and no screenshot-then-slice: it drives go-webengine’s own layout tree straight into pdfkit’s text, rect and stroke calls, so the text in the PDF is real text โ€” selectable, searchable, small โ€” rather than a picture of text.

repository ยท pkg.go.dev

latex ready

The other direction: reconstruct LaTeX source from a PDF. A PDF that came from TeX does not contain a document; it contains the marks TeX made putting one on paper โ€” glyphs at absolute positions, from fonts whose names are the only surviving record of what the author asked for. Reconstruct gives back the .tex, and writes out the pictures its includegraphics commands name.

repository ยท pkg.go.dev

conformance ready

Where all of the above is judged by implementations that are not ours, over corpora of real PDFs. A file our own reader reads back perfectly can draw nothing anywhere else โ€” that has happened here: a rebuilt cross-reference stream our reader was delighted with, and macOS drew a blank page. So these tools ask poppler rather than asking ourselves. A figure is only as honest as the population it came from, so the corpora are chosen and stated rather than collected.

repository ยท pkg.go.dev

app ready

A PDF workbench that runs in a browser tab and nowhere else. Open a file, turn its pages, rotate one, drop one, lay it out two to a sheet, write across it, save it back out โ€” and none of it leaves the machine, because there is nowhere for it to go. The whole tool is one wasm binary the browser downloads once and a service worker then keeps. Driven in CI by a real Chrome over the DevTools protocol, reading the canvas pixels back.

repository ยท pkg.go.dev

pdfkit ready

The document builder: pages, vector graphics, and text set in embedded, subsetted fonts. TrueType and OpenType/CFF embed as Type0 (Identity-H) composite fonts with a /ToUnicode CMap; JPEG and raster images place directly; a widget bridge prints a go-widgets tree onto a page, rasterised or as selectable vector text. With the zero Options the output has no timestamps and a content-derived /ID, so identical inputs produce byte-identical PDFs.

repository ยท pkg.go.dev

Where it runs

8 architectures, and the tests run on every one.

Cross-compiling proves a program builds. It says nothing about what it does when a byte order, a word size or a floating-point unit differs โ€” and that is where a format parser lives. Every repository here runs its whole suite on each of these on every push: amd64, arm64, riscv64, loong64, ppc64le, s390x, 386 and arm.

๐Ÿ”„

s390x is big-endian

Every lane was green from the first run, which is the useful kind of result: nothing in the stack had quietly assumed a byte order. A PDF is full of two- and four-byte integers, and a reader that reads them the wrong way round on one machine is a reader nobody can trust on any.

๐Ÿ“

386 and arm are 32-bit

They found four real defects. One of them โ€” a guard converting a float before it could judge it โ€” crashes on arm64 as well, so a 32-bit lane paid for itself on a 64-bit machine. 386 runs natively, not under emulation, because an emulator that loses floating-point state across a preemption makes a flaky lane out of correct code.

โš ๏ธ

loong64 is pinned, and we say why

Its lane names Go 1.26.4 rather than the current release, because golang/go#81000 miscompiles a float-to-integer conversion there and the backport is still open. We found it with this suite, on real LoongArch silicon, and reported it upstream. A pinned lane with a reason beats a green one with a workaround nobody wrote down.

๐ŸŒ

and js/wasm, which is the workbench

The same libraries compile to one wasm binary with no C, no CDN and no SharedArrayBuffer โ€” which is why the workbench opens a file, rearranges it and saves it back without any of it leaving the machine.

Text, fonts and widgets

A subset, an encoding, and text you can still copy.

โœ‚๏ธ

Every embedded font is a subset

Identity-H encoding, a per-glyph /W width array and a /ToUnicode CMap, so copy and paste still work. TrueType glyf outlines embed as a subsetted FontFile2 / CIDFontType2 with a /CIDToGIDMap stream; CFF and OpenType outlines embed as a charstring-subsetted FontFile3 / CIDFontType0. Only a CID-keyed CFF or a CFF2 variable font falls back to embedding the whole table.

๐ŸŒ

Shaped, where shaping is the point

TextShaped runs the go-opentype shaper's GSUB and GPOS tables, so Arabic joins, Indic reorders and CJK sits where it should โ€” rather than a run of glyphs laid out left to right and called typesetting.

๐Ÿงฉ

A widget tree, printed

Page.AddWidget and Page.AddWidgetVector put a go-widgets/toolkit tree on a page โ€” the first as a rasterised image XObject, pixel-identical to the screen; the second as PDF vector operators, so fills and strokes stay crisp and the text stays selectable, a TrueType widget label's own face embedded as real Type0 text.

๐ŸŽฏ

The same bytes twice

The writer is deterministic: the same document written twice is the same file, which is what makes a before-and-after comparison mean anything at all.

100 % statement coverage, including the branches that report a bad file.

And it runs on 8 architectures, not merely compiles for them. Pure Go, cgo disabled, BSD-3-Clause.

Read the source on GitHub โ†’