Skip to content
go-pdfkitPublic

About

Pure-Go static HTML to vector PDF, driven by go-webengine's layout tree and go-pdfkit's writer

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

html2pdf

CI Go Reference License: BSD-3-Clause

Pure-Go, zero-C, static HTML to vector PDF. No headless browser, no screenshot-then-slice: it drives go-webengine's own layout tree straight into go-pdfkit text/rect/stroke calls, so the PDF's text is real text — selectable, searchable, small — not a raster of it.

Quick start

doc, err := html2pdf.Export(htmlSource, html2pdf.Options{})
if err != nil {
    log.Fatal(err)
}
f, _ := os.Create("out.pdf")
defer f.Close()
doc.Write(f)

Or from the shell — a local file, or a page fetched through the engine's own client (its final URL then resolves relative image sources; use -base for that with --in):

go run ./cmd/html2pdf --in report.html --out report.pdf
go run ./cmd/html2pdf --url https://example.org/report --out report.pdf
go run ./cmd/html2pdf --in report.html --title "Q3 report" --author "A. Writer"

The CLI is a cobra command with POSIX flags: --in/-i, --url/-u, --out/-o, --base/-b, --margin/-m (mm), --media, --image-dpi, --offline, --timeout, and the PDF information entries --title, --author, --subject, --keywords. The single-dash spellings of earlier builds (-in, -out, -margin …) are still accepted. Exit status 2 is a usage error, 1 a runtime failure.

Pagination

Page breaks are the engine's (paginate.Breaks): they land between atoms — a text line, a whole <tr>, a rule or spacer — never through one, and they honour the document's CSS Fragmentation: break-before / break-after: page (and the page-break-* aliases) force a page; break-inside: avoid keeps a table, a figure, a code block whole when it can fit a page; break-after: avoid on a heading keeps it with what follows; orphans / widows (2 by default) keep a paragraph from leaving a lone line at either edge — with the spec's own order of relaxation when a page cannot be cut any other way. The answer key is Chrome: corpus/cmd/breakcheck renders corpus/fixtures/breaks.html and puts every marker on the page headless Chrome put it on (18/18, nine pages).

@page { size: …; margin: … } sets the paper and margins, over Options.PageSize / MarginMm — A4, A5, letter, landscape, explicit lengths, per-side margins. :first / :left / :right and named pages are not read yet.

Page numbers and a table of contents

CSS Paged Media 3 §5 margin boxes are painted on every page: @top-left, @top-center, @top-right, @bottom-left, @bottom-center, @bottom-right, with a content of strings (CSS escapes included), counter(page) and counter(pages), and font-size, font-family, font-weight, font-style, color, text-align:

@page { size: A4; margin: 20mm;
  @bottom-center { content: counter(page) " / " counter(pages); font-size: 9pt } }

target-counter(attr(href url), page) (CSS Content 3 §2.6.1) prints the page an href="#id" points to, in a ::before / ::after:

a.toc::after { content: target-counter(attr(href url), page) }

The layout runs again until the numbers are stable (at most four passes). leader() is accepted and ignored: no dotted fill is drawn. Not supported: the corner and side margin boxes, target-counters(), target-text(), counter styles other than decimal. Margin box positions match headless Chrome 156 to within half a point (@top-*, @bottom-* on A4); Chrome has no target-counter(), so that half has no oracle and is covered by the tests alone.

Layout width vs. print width

A web page is laid out at Options.ViewportPx (default 1024px), then scaled down to fit the print column — not laid out directly at the print column's own width (a plain A4 page is under 650px wide). Many real pages carry a fixed-width element sized for a desktop viewport (a sidebar, a multi-column nav) that a browser's own responsive CSS only collapses below some breakpoint; laying out narrower than that breakpoint just squeezes the rest of the page into a sliver instead of dropping the sidebar. Confirmed against RFC 9110's HTML edition, whose table-of-contents sidebar did exactly this — see corpus/CORPUS.md for the before/after page counts across all 8 corpus pages.

A document that declares @page is another matter: it was designed for its paper, its absolute units (a 12pt body, a 40mm figure) must print at their true size, and it is laid out 1:1, as a browser prints it — unless ViewportPx is set explicitly.

Navigation

An <a href> becomes a link annotation — a URI action for an http(s) target, a GoTo to a named destination for a fragment that points at an element id in the document — one clickable rectangle per line the anchor spans, so the clickable area follows the text; an anchor that lays out no text at all (a link around an empty, CSS-drawn block) gets the block's own rectangle, as a browser's print does. Every element id becomes a named destination, the headings become the viewer's bookmark tree (<h1> at the top level, deeper headings nested under the last shallower one), and the <title> fills the PDF's Title unless Options.Title is set (Author, Subject, Keywords likewise). javascript:, mailto:, tel: and fragments nobody anchors are dropped rather than written as dead links.

Scope

This renders static HTML: no JavaScript, no @font-face. Text is set in the three families go-webengine's own paint package bundles — Inter (sans), Lora (serif), Go Mono (mono) — so the glyphs drawn always match the metrics the layout pass measured against; there is no web-font fetch to fail silently. A character the family has no glyph for is set in the engine's last-resort DejaVu Sans (arrows, mathematical operators, box drawing, braille, Greek, Cyrillic, ①–⑩), embedded beside the family, the way a browser falls back per character to the system's fonts; CJK is beyond it and draws as nothing. The corpus report's Lost chars column counts what still vanishes.

Text is written glyph by glyph at each glyph's own advance — no kerning, no ligatures — because that is exactly how the engine measures and rasterises it; a kerned run would end short of its layout slot and the gap would read as a space. Kerning belongs in the engine's measure first. The corpus report's Glued column counts the words of the extracted text that are two of the page's words run together — a space lost in layout.

External stylesheets — <link rel="stylesheet"> and their @import chains — are fetched through the engine's own bounded loader (Engine.LoadStylesheets: 64 sheets, 4 MB each, two @import levels, 10 s) and cascaded for the print medium by default (Options.Media, CLI -media): a page's @media print rules and print-only stylesheets apply, its screen-only ones do not — a browser's print preview, where a site's navigation, sidebars and footers are usually hidden. -media screen styles the page as displayed. Width features (min-width, max-width) are evaluated at ViewportPx under either.

Images — raster <img>, <img src="*.svg"> and inline <svg> — go through the engine's own fetch/decode/size pipeline (Engine.LoadImageSet), so they're laid out and drawn exactly as the engine's raster canvas would. How the pixels are stored follows the source, the way Chrome's PDF backend decides it: a JPEG the engine did not resize is embedded byte for byte (DCTDecode); any other lossy source (a resized JPEG, a lossy WebP) that is opaque is re-encoded as JPEG at quality 85; PNG, GIF, SVG rasters and anything with transparency are flate bitmaps with a soft mask, so line art and screenshots keep every pixel. Options.ImageDPI (CLI -image-dpi) caps a bitmap's density at its painted size — 0, the default, keeps every fetched pixel as Chrome and WeasyPrint do; WeasyPrint's --dpi is the same lever. A relative src resolves against BaseURL; an image that fails to fetch or decode is left out.

Images above 40 megapixels. Since go-gfx/gfx v0.37.0 the codec refuses an image of more than codec.DefaultMaxPixels (40 million pixels, width times height) from its header, before allocating anything: the size comes from the file, and a compressed format describes a huge image in a few bytes. The second, full-size decode of an <img> described above is subject to it. Above the ceiling it fails with codec.ErrTooLarge, and the embedded image is then the engine's box-size bitmap instead of the full-size source (a source that large is replaced, not dropped). There is no option to raise the ceiling here.

Rendering with no network access

Stylesheets (<link>, @import) and <img> images are the only places Export opens a connection. There is no script engine, no @font-face fetch, and an <iframe>, <object> or <embed> is never loaded; CSS background images are not painted. By default the engine's own client is used: it follows redirects (Go's limit of 10 hops, to any host — a loopback or private address included), times out after 30 s, and has no allow-list. A server that renders HTML built from untrusted text should not rely on escaping alone to keep that out of its own network:

var refused atomic.Int64
doc, err := html2pdf.Export(src, html2pdf.Options{
    Fetch:     html2pdf.Offline,                   // no request is ever sent
    OnRefused: func(string) { refused.Add(1) },    // optional; may run concurrently
})

Options.Fetch replaces the client for every http(s) sub-resource (it is the engine's Engine.Fetcher hook); html2pdf.Offline refuses all of them with ErrOffline. A refused resource is treated as missing — the layout continues, Export does not fail — and OnRefused is called with its URL (it counts only errors wrapping ErrOffline). Nothing is sent, so no redirect can be followed and no DNS lookup happens. data: URLs are decoded in place and still render. file: URLs are never read, with or without Offline, whatever BaseURL is (the engine's FileRoot stays empty). From the shell: --offline (only with --in; refusals are listed on stderr).

List markers are real text in the PDF: the engine (v0.18.0 and later) lays out each list item's ::marker with its text — "•", "3.", "iv." — and html2pdf draws it where the layout put it, so a marker is selectable and extractable like the item's words. list-style-type (disc, circle, square, decimal, decimal-leading-zero, lower-/upper-alpha, lower-/upper-roman, a <string>, none), list-style-position (outside, inside), ::marker colour and content, and <ol start>, reversed, <li value> and type all come from the engine, as do ::before/::after with CSS escapes (content: "\2022\00a0"), counter() and counters() over counter-reset/counter-increment/counter-set.

Inline-level decoration — a styled <span>, <code>, <a>… with a background, border or padding — paints too, fragmented per line the way CSS does (box-decoration-break: slice: the left border only on an element's first fragment, the right only on its last). The geometry comes from the engine's own LineBox.Inlines (go-webengine/engine#128), so a badge or pill lands exactly where the raster canvas puts it. Not painted on inline elements: border-radius, background-image/gradients, box-shadow — a fragment paints a flat background and straight borders.

Output

PDF 1.5. Content and font streams are flated; every non-stream object — the link annotations (one per clickable line), named destinations, outline items, the Info dictionary — is packed into flated object streams with a cross-reference stream (pdfkit's ObjectStreams), which is what keeps a document with thousands of links close to its text size (RFC 9110: 3 398 links, 1.06 MB; Chrome 4.5 MB). Fonts are embedded as subsets. Output is deterministic: the same input gives the same bytes.

Status

Validated three ways, all in corpus/ (in the spirit of go-webengine's own bench/):

  • a hand-built regression suite (html2pdf_test.go, ~94% statement coverage) and a corpus of 8 real public pages — corpus/CORPUS.md, with the bugs the corpus has found;
  • a timing/size bench against headless Chrome's own print-to-PDF — corpus/BENCH.md;
  • every reference reader on the machine — qpdf, poppler, MuPDF, Ghostscript, pdfium (Chrome's engine), pdf.js (Firefox's) and Quartz (Preview's) — run over every output, with Chrome's PDFs as the control: corpus/JUDGES.md. Being the smallest file means nothing if one reader in the field disagrees about it.

License

BSD-3-Clause, see LICENSE.

About

Pure-Go static HTML to vector PDF, driven by go-webengine's layout tree and go-pdfkit's writer

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages