diff --git a/README.md b/README.md
index 8b62834..4c1ed55 100644
--- a/README.md
+++ b/README.md
@@ -35,6 +35,19 @@ through one. A table row that would overflow the page moves to the next page
whole; a paragraph may still break between its own lines, same as printed
text always has.
+## Layout width vs. print width
+
+A 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`](corpus/CORPUS.md) for the before/after page counts
+across all 8 corpus pages.
+
## Scope
This renders **static** HTML: no JavaScript, no external stylesheets, no
@@ -56,11 +69,12 @@ Two gaps, both inherited from — not introduced by — the layout engine:
## Status
-Early — validated so far against a hand-built regression suite
-(`html2pdf_test.go`) and one real multi-page report. A corpus run against
-public real-world pages, in the spirit of go-webengine's own
-[`bench/`](https://github.com/go-webengine/engine/tree/main/bench), is
-tracked in [`corpus/`](corpus/) and [`CORPUS.md`](CORPUS.md).
+Validated against a hand-built regression suite (`html2pdf_test.go`, ~94%
+statement coverage) and a corpus of 8 real public pages
+([`corpus/`](corpus/), in the spirit of go-webengine's own
+[`bench/`](https://github.com/go-webengine/engine/tree/main/bench)) — see
+[`corpus/CORPUS.md`](corpus/CORPUS.md) for current results and the bugs the
+corpus run has found so far.
## License
diff --git a/atoms.go b/atoms.go
new file mode 100644
index 0000000..8b164ca
--- /dev/null
+++ b/atoms.go
@@ -0,0 +1,100 @@
+// Copyright (c) the go-pdfkit/html2pdf authors. All rights reserved.
+// Use of this source code is governed by a BSD-3-Clause license that can be
+// found in the LICENSE file at the root of this repository.
+
+package html2pdf
+
+import (
+ "sort"
+
+ "github.com/go-webengine/engine/dom"
+ "github.com/go-webengine/engine/layout"
+)
+
+// atom is one indivisible vertical slice of content for pagination purposes:
+// a single text line, or a whole table row (never split mid-row). Coordinates
+// are in the layout viewport's px space (see Options.ViewportPx), before the
+// print-column scale is applied.
+type atom struct{ top, bottom float64 }
+
+// collectAtoms walks the box tree and returns every atom in document order,
+// sorted by top.
+//
+// A
row is one atom regardless of how many lines its cells wrap to —
+// splitting a row across pages reads worse than a few extra blank
+// millimetres at the bottom of a page — unless that row is itself a
+// layout-table wrapper (its cell holds a nested , e.g. Hacker News'
+// classic markup): swallowing that whole nested table into one atom made it
+// taller than a page, so it could only start at a page top, wasting
+// everything before it. hasDescendantTr tells the two cases apart; only a
+// childless counts as one atom, a wrapper is descended into so its real
+// rows become the atoms instead.
+//
+// Any other box's own text lines are each their own atom, so a paragraph can
+// still break between lines. A childless, line-less box with real height (a
+// rule, a spacer) gets one atom too, so its height is accounted for even
+// though nothing inside it can break.
+func collectAtoms(b *layout.Box) []atom {
+ var out []atom
+ var walk func(b *layout.Box)
+ walk = func(b *layout.Box) {
+ if b == nil {
+ return
+ }
+ if isRow(b) && !hasDescendantTr(b) {
+ out = append(out, atom{b.Y, b.Y + b.H})
+ return
+ }
+ for _, ln := range b.Lines {
+ out = append(out, atom{ln.Y, ln.Y + ln.H})
+ }
+ if len(b.Children) == 0 && len(b.Lines) == 0 && b.H > 0 {
+ out = append(out, atom{b.Y, b.Y + b.H})
+ }
+ for _, c := range b.Children {
+ walk(c)
+ }
+ }
+ walk(b)
+ sort.Slice(out, func(i, j int) bool { return out[i].top < out[j].top })
+ return out
+}
+
+// isRow reports whether b is a
box.
+func isRow(b *layout.Box) bool {
+ return b.Node != nil && b.Node.Type == dom.Element && b.Node.Tag == "tr"
+}
+
+// hasDescendantTr reports whether b's subtree contains another
— the
+// signature of a layout-table trick (a row whose cell holds a nested table)
+// rather than a plain data row.
+func hasDescendantTr(b *layout.Box) bool {
+ for _, c := range b.Children {
+ if isRow(c) || hasDescendantTr(c) {
+ return true
+ }
+ }
+ return false
+}
+
+// pageBreaks returns the y (viewport px) at which each page after the first
+// starts, given the usable content height per page (viewport px). It only
+// ever cuts between atoms — before whichever atom would otherwise overflow
+// the page — so no line or table row is split across pages. An atom taller
+// than pageH still gets exactly one break before it: it cannot be split
+// further, so it simply overflows its own page's bottom margin rather than
+// looping forever trying to fit it.
+func pageBreaks(atoms []atom, pageH float64) []float64 {
+ if len(atoms) == 0 {
+ return nil
+ }
+ var breaks []float64
+ pageTop := 0.0
+ for _, a := range atoms {
+ if a.bottom-pageTop > pageH && a.top > pageTop {
+ breaks = append(breaks, a.top)
+ pageTop = a.top
+ }
+ }
+ return breaks
+}
diff --git a/corpus/CORPUS.md b/corpus/CORPUS.md
index f4e451a..e5027c4 100644
--- a/corpus/CORPUS.md
+++ b/corpus/CORPUS.md
@@ -4,14 +4,14 @@
| URL | Status | Pages | PDF | Text chars | Fetch | Render |
|---|---|---|---|---|---|---|
-| [https://example.com/](https://example.com/) | ✅ | 1 | 26285 B | 127 | 41ms | 31ms |
-| [https://en.wikipedia.org/wiki/Go_(programming_language)](https://en.wikipedia.org/wiki/Go_(programming_language)) | ✅ | 34 | 3530766 B | 63005 | 135ms | 108ms |
-| [https://en.wikipedia.org/wiki/List_of_countries_by_population_(United_Nations)](https://en.wikipedia.org/wiki/List_of_countries_by_population_(United_Nations)) | ✅ | 17 | 1347476 B | 23256 | 49ms | 144ms |
-| [https://go.dev/blog/subtests](https://go.dev/blog/subtests) | ✅ | 9 | 825623 B | 13334 | 165ms | 38ms |
-| [https://pkg.go.dev/net/http](https://pkg.go.dev/net/http) | ✅ | 86 | 8258945 B | 150642 | 487ms | 104ms |
-| [https://www.rfc-editor.org/rfc/rfc9110.html](https://www.rfc-editor.org/rfc/rfc9110.html) | ✅ | 428 | 28285153 B | 450968 | 207ms | 488ms |
-| [https://news.ycombinator.com/](https://news.ycombinator.com/) | ✅ | 4 | 239893 B | 4022 | 469ms | 33ms |
-| [https://react.dev/](https://react.dev/) | ✅ | 7 | 501695 B | 7909 | 55ms | 43ms |
+| [https://example.com/](https://example.com/) | ✅ | 1 | 26601 B | 127 | 41ms | 63ms |
+| [https://en.wikipedia.org/wiki/Go_(programming_language)](https://en.wikipedia.org/wiki/Go_(programming_language)) | ✅ | 18 | 3694522 B | 63058 | 186ms | 178ms |
+| [https://en.wikipedia.org/wiki/List_of_countries_by_population_(United_Nations)](https://en.wikipedia.org/wiki/List_of_countries_by_population_(United_Nations)) | ✅ | 9 | 1389439 B | 22970 | 54ms | 230ms |
+| [https://go.dev/blog/subtests](https://go.dev/blog/subtests) | ✅ | 6 | 849596 B | 13470 | 446ms | 57ms |
+| [https://pkg.go.dev/net/http](https://pkg.go.dev/net/http) | ✅ | 49 | 8516859 B | 150969 | 233ms | 177ms |
+| [https://www.rfc-editor.org/rfc/rfc9110.html](https://www.rfc-editor.org/rfc/rfc9110.html) | ✅ | 120 | 28978109 B | 449957 | 300ms | 675ms |
+| [https://news.ycombinator.com/](https://news.ycombinator.com/) | ✅ | 2 | 248721 B | 3985 | 450ms | 45ms |
+| [https://react.dev/](https://react.dev/) | ✅ | 5 | 526645 B | 7965 | 91ms | 58ms |
@@ -37,25 +37,35 @@ into instead, so its real rows become the atoms. After the fix: 4 pages,
content from the top of page 1, 4009 characters extracted (~3×). Regression
test: `TestExportNestedLayoutTableSplitsAcrossPages`.
-### Real limitation, not a bug: narrow print column vs. desktop-only responsive CSS
+### Fixed: narrow print column vs. desktop-only responsive CSS (`Options.ViewportPx`)
-`rfc-editor.org`'s RFC 9110 page renders technically correctly but
+`rfc-editor.org`'s RFC 9110 page rendered technically correctly but
inefficiently: 428 pages for a document whose official PDF runs closer to
-180. `out/www-rfc-editor-org-rfc-rfc9110-html-p1-001.png` shows why — the
-page's table-of-contents sidebar sits *beside* the article in a fixed-width
-column, and at html2pdf's 170mm (≈642px) print column that squeezes the
-actual prose down to under half the page width, so it wraps into roughly
-twice the line count it would at full desktop width. The page was designed
-for a 1200px+ viewport with no narrower breakpoint that drops the sidebar;
-html2pdf has no `@media print` handling or "render wide, shrink to fit" mode
-to compensate. `pkg.go.dev/net/http` at 86 pages is plausibly just genuinely
-long (net/http is one of the largest stdlib packages) rather than showing the
-same artifact — not confirmed either way.
+180. The page's table-of-contents sidebar sits *beside* the article in a
+fixed-width column with no breakpoint that drops it below desktop width, so
+laying out directly at html2pdf's print column (170mm, ≈642px) squeezed the
+prose to under half the page width — roughly double the line count it needed.
-**Possible future direction**: lay out at a wider virtual viewport (matching
-what the page's own CSS was designed for) and scale the result down to the
-print column, the way a browser's print dialog often does — real work, not
-attempted here.
+Fix: `Export` now lays out at a wider virtual viewport (`Options.ViewportPx`,
+default 1024px) and scales the whole page down to fit the print column,
+same idea as a browser print dialog's "shrink to fit". Result, this run vs.
+the one that found the problem:
+
+| Page | Before | After |
+|---|---|---|
+| RFC 9110 | 428 pages | **120 pages** |
+| `pkg.go.dev/net/http` | 86 | 49 |
+| Wikipedia (Go) | 34 | 18 |
+| Wikipedia (countries list) | 17 | 9 |
+| `go.dev/blog` | 9 | 6 |
+| Hacker News | 4 | 2 |
+| `react.dev` | 7 | 5 |
+
+Extracted text length stayed within 1% on every page — this is a layout
+density change, not a content change. `out/www-rfc-editor-org-rfc-rfc9110-html-p1-001.png`
+and `out/news-ycombinator-com-p1-1.png` after the fix both show full-width,
+readable text at a normal size — the scale-down doesn't make anything too
+small to read at these ratios (642/1024 ≈ 0.63×).
### Confirmed-expected: `react.dev` shows only its static shell
diff --git a/corpus/out/en-wikipedia-org-wiki-Go_programming_language-p1-01.png b/corpus/out/en-wikipedia-org-wiki-Go_programming_language-p1-01.png
index 0b33185..1a353c6 100644
Binary files a/corpus/out/en-wikipedia-org-wiki-Go_programming_language-p1-01.png and b/corpus/out/en-wikipedia-org-wiki-Go_programming_language-p1-01.png differ
diff --git a/corpus/out/en-wikipedia-org-wiki-List_of_countries_by_population_United_Nations-p1-01.png b/corpus/out/en-wikipedia-org-wiki-List_of_countries_by_population_United_Nations-p1-01.png
deleted file mode 100644
index 0b33185..0000000
Binary files a/corpus/out/en-wikipedia-org-wiki-List_of_countries_by_population_United_Nations-p1-01.png and /dev/null differ
diff --git a/corpus/out/en-wikipedia-org-wiki-List_of_countries_by_population_United_Nations-p1-1.png b/corpus/out/en-wikipedia-org-wiki-List_of_countries_by_population_United_Nations-p1-1.png
new file mode 100644
index 0000000..bb89758
Binary files /dev/null and b/corpus/out/en-wikipedia-org-wiki-List_of_countries_by_population_United_Nations-p1-1.png differ
diff --git a/corpus/out/example-com-p1-1.png b/corpus/out/example-com-p1-1.png
index 33e30cc..b5999a0 100644
Binary files a/corpus/out/example-com-p1-1.png and b/corpus/out/example-com-p1-1.png differ
diff --git a/corpus/out/go-dev-blog-subtests-p1-1.png b/corpus/out/go-dev-blog-subtests-p1-1.png
index 7529f7c..edc9959 100644
Binary files a/corpus/out/go-dev-blog-subtests-p1-1.png and b/corpus/out/go-dev-blog-subtests-p1-1.png differ
diff --git a/corpus/out/news-ycombinator-com-p1-1.png b/corpus/out/news-ycombinator-com-p1-1.png
index 001d166..0be5391 100644
Binary files a/corpus/out/news-ycombinator-com-p1-1.png and b/corpus/out/news-ycombinator-com-p1-1.png differ
diff --git a/corpus/out/pkg-go-dev-net-http-p1-01.png b/corpus/out/pkg-go-dev-net-http-p1-01.png
index 763eb43..b93bd17 100644
Binary files a/corpus/out/pkg-go-dev-net-http-p1-01.png and b/corpus/out/pkg-go-dev-net-http-p1-01.png differ
diff --git a/corpus/out/react-dev-p1-1.png b/corpus/out/react-dev-p1-1.png
index f5fc13c..85d715a 100644
Binary files a/corpus/out/react-dev-p1-1.png and b/corpus/out/react-dev-p1-1.png differ
diff --git a/corpus/out/www-rfc-editor-org-rfc-rfc9110-html-p1-001.png b/corpus/out/www-rfc-editor-org-rfc-rfc9110-html-p1-001.png
index 90ba567..15c7719 100644
Binary files a/corpus/out/www-rfc-editor-org-rfc-rfc9110-html-p1-001.png and b/corpus/out/www-rfc-editor-org-rfc-rfc9110-html-p1-001.png differ
diff --git a/corpus/results.json b/corpus/results.json
index 2eb4817..80869e9 100644
--- a/corpus/results.json
+++ b/corpus/results.json
@@ -4,8 +4,8 @@
"slug": "example-com",
"ok": true,
"fetch_ms": 41,
- "render_ms": 31,
- "pdf_bytes": 26285,
+ "render_ms": 63,
+ "pdf_bytes": 26601,
"pages": 1,
"text_chars": 127,
"html_bytes": 559
@@ -14,77 +14,77 @@
"url": "https://en.wikipedia.org/wiki/Go_(programming_language)",
"slug": "en-wikipedia-org-wiki-Go_programming_language",
"ok": true,
- "fetch_ms": 135,
- "render_ms": 108,
- "pdf_bytes": 3530766,
- "pages": 34,
- "text_chars": 63005,
+ "fetch_ms": 186,
+ "render_ms": 178,
+ "pdf_bytes": 3694522,
+ "pages": 18,
+ "text_chars": 63058,
"html_bytes": 734710
},
{
"url": "https://en.wikipedia.org/wiki/List_of_countries_by_population_(United_Nations)",
"slug": "en-wikipedia-org-wiki-List_of_countries_by_population_United_Nations",
"ok": true,
- "fetch_ms": 49,
- "render_ms": 144,
- "pdf_bytes": 1347476,
- "pages": 17,
- "text_chars": 23256,
+ "fetch_ms": 54,
+ "render_ms": 230,
+ "pdf_bytes": 1389439,
+ "pages": 9,
+ "text_chars": 22970,
"html_bytes": 773512
},
{
"url": "https://go.dev/blog/subtests",
"slug": "go-dev-blog-subtests",
"ok": true,
- "fetch_ms": 165,
- "render_ms": 38,
- "pdf_bytes": 825623,
- "pages": 9,
- "text_chars": 13334,
+ "fetch_ms": 446,
+ "render_ms": 57,
+ "pdf_bytes": 849596,
+ "pages": 6,
+ "text_chars": 13470,
"html_bytes": 46512
},
{
"url": "https://pkg.go.dev/net/http",
"slug": "pkg-go-dev-net-http",
"ok": true,
- "fetch_ms": 487,
- "render_ms": 104,
- "pdf_bytes": 8258945,
- "pages": 86,
- "text_chars": 150642,
+ "fetch_ms": 233,
+ "render_ms": 177,
+ "pdf_bytes": 8516859,
+ "pages": 49,
+ "text_chars": 150969,
"html_bytes": 482331
},
{
"url": "https://www.rfc-editor.org/rfc/rfc9110.html",
"slug": "www-rfc-editor-org-rfc-rfc9110-html",
"ok": true,
- "fetch_ms": 207,
- "render_ms": 488,
- "pdf_bytes": 28285153,
- "pages": 428,
- "text_chars": 450968,
+ "fetch_ms": 300,
+ "render_ms": 675,
+ "pdf_bytes": 28978109,
+ "pages": 120,
+ "text_chars": 449957,
"html_bytes": 1187554
},
{
"url": "https://news.ycombinator.com/",
"slug": "news-ycombinator-com",
"ok": true,
- "fetch_ms": 469,
- "render_ms": 33,
- "pdf_bytes": 239893,
- "pages": 4,
- "text_chars": 4022,
- "html_bytes": 34654
+ "fetch_ms": 450,
+ "render_ms": 45,
+ "pdf_bytes": 248721,
+ "pages": 2,
+ "text_chars": 3985,
+ "html_bytes": 34445
},
{
"url": "https://react.dev/",
"slug": "react-dev",
"ok": true,
- "fetch_ms": 55,
- "render_ms": 43,
- "pdf_bytes": 501695,
- "pages": 7,
- "text_chars": 7909,
+ "fetch_ms": 91,
+ "render_ms": 58,
+ "pdf_bytes": 526645,
+ "pages": 5,
+ "text_chars": 7965,
"html_bytes": 272458
}
]
diff --git a/fonts.go b/fonts.go
new file mode 100644
index 0000000..3533638
--- /dev/null
+++ b/fonts.go
@@ -0,0 +1,76 @@
+// Copyright (c) the go-pdfkit/html2pdf authors. All rights reserved.
+// Use of this source code is governed by a BSD-3-Clause license that can be
+// found in the LICENSE file at the root of this repository.
+
+package html2pdf
+
+import (
+ "github.com/go-opentype/fonts/gomono"
+ "github.com/go-opentype/fonts/inter"
+ "github.com/go-opentype/fonts/lora"
+ "github.com/go-pdfkit/pdfkit"
+ "github.com/go-webengine/engine/css"
+)
+
+// fontSet resolves the (family, bold, italic) requests the layout produced to
+// loaded pdfkit fonts. Mono has only a regular face, matching paint.Fonts'
+// own fallback (see engine's paint/fonts.go): the family ships no bold or
+// italic style, so both requests render in the upright regular face.
+type fontSet struct {
+ sans, sansB, sansI, sansBI *pdfkit.Font
+ serif, serifB, serifI, serifBI *pdfkit.Font
+ mono *pdfkit.Font
+}
+
+// loadFonts embeds the three families go-webengine's own paint package
+// bundles, so glyph metrics always match what the layout pass measured
+// against.
+func loadFonts() (*fontSet, error) {
+ fs := &fontSet{}
+ for _, pair := range []struct {
+ dst **pdfkit.Font
+ b []byte
+ }{
+ {&fs.sans, inter.TTF}, {&fs.sansB, inter.BoldTTF}, {&fs.sansI, inter.ItalicTTF}, {&fs.sansBI, inter.BoldItalicTTF},
+ {&fs.serif, lora.TTF}, {&fs.serifB, lora.BoldTTF}, {&fs.serifI, lora.ItalicTTF}, {&fs.serifBI, lora.BoldItalicTTF},
+ {&fs.mono, gomono.TTF},
+ } {
+ f, err := pdfkit.LoadFont(pair.b)
+ if err != nil {
+ return nil, err
+ }
+ *pair.dst = f
+ }
+ return fs, nil
+}
+
+// pick returns the loaded face matching a CSS font-family/weight/style
+// request.
+func (fs *fontSet) pick(fam css.FontFamily, bold, italic bool) *pdfkit.Font {
+ switch fam {
+ case css.Serif:
+ switch {
+ case bold && italic:
+ return fs.serifBI
+ case bold:
+ return fs.serifB
+ case italic:
+ return fs.serifI
+ default:
+ return fs.serif
+ }
+ case css.Mono:
+ return fs.mono
+ default:
+ switch {
+ case bold && italic:
+ return fs.sansBI
+ case bold:
+ return fs.sansB
+ case italic:
+ return fs.sansI
+ default:
+ return fs.sans
+ }
+ }
+}
diff --git a/html2pdf.go b/html2pdf.go
index 76e7f2d..5c69934 100644
--- a/html2pdf.go
+++ b/html2pdf.go
@@ -4,8 +4,8 @@
// Package html2pdf renders static HTML straight to a vector PDF: it drives
// go-webengine's own layout tree (no screenshot, no raster slicing) into
-// go-pdfkit text/rect/stroke calls. Pagination breaks between text lines and
-// table rows, never through one.
+// go-pdfkit text/rect/stroke calls. Pagination breaks between atoms — a text
+// line, or a whole table row — never through one; see atoms.go.
//
// # Scope
//
@@ -32,11 +32,7 @@ package html2pdf
import (
"fmt"
- "sort"
- "github.com/go-opentype/fonts/gomono"
- "github.com/go-opentype/fonts/inter"
- "github.com/go-opentype/fonts/lora"
"github.com/go-pdfkit/pdfkit"
"github.com/go-webengine/engine/css"
"github.com/go-webengine/engine/dom"
@@ -48,11 +44,30 @@ import (
// to a PDF point (1/72in).
const pxToPt = 72.0 / 96.0
-// Options configures a single Export call. The zero value is A4 with 20mm
-// margins on all sides.
+// defaultViewportPx is the width a page is laid out against before being
+// scaled down to fit the print column — see Options.ViewportPx.
+const defaultViewportPx = 1024
+
+// Options configures a single Export call. The zero value is A4, 20mm
+// margins on all sides, and a 1024px layout viewport.
type Options struct {
PageSize pdfkit.PageSize // zero value: pdfkit.A4
MarginMm float64 // zero value: 20
+
+ // ViewportPx is the width (CSS px) the page is laid out against, then
+ // uniformly scaled down to fit the print column. 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 directly at the print column's actual
+ // width (a plain A4 page is under 650px wide) sits below most such
+ // breakpoints, so that fixed-width element squeezes the rest of the page
+ // into a narrow remainder and the whole document wraps far taller than it
+ // needs to — confirmed against RFC 9110's HTML edition, whose
+ // table-of-contents sidebar did exactly this (428 pages laid out at the
+ // print column's own ~642px width vs. 184 at 1024px). Zero value: 1024,
+ // a common small-desktop/tablet breakpoint. Set below the print column's
+ // own width (rare) to lay out 1:1 with no scaling.
+ ViewportPx float64
}
func (o Options) resolved() Options {
@@ -62,12 +77,15 @@ func (o Options) resolved() Options {
if o.MarginMm == 0 {
o.MarginMm = 20
}
+ if o.ViewportPx == 0 {
+ o.ViewportPx = defaultViewportPx
+ }
return o
}
-// Export parses htmlSrc, lays it out at the page's printable width and
-// returns a paginated pdfkit.Document ready to Write. baseURL is unused today
-// (no external resource fetching yet) and accepted for forward compatibility.
+// Export parses htmlSrc, lays it out at opts.ViewportPx and returns a
+// paginated pdfkit.Document — scaled to fit the page's printable width —
+// ready to Write.
func Export(htmlSrc string, opts Options) (*pdfkit.Document, error) {
opts = opts.resolved()
@@ -86,7 +104,14 @@ func Export(htmlSrc string, opts Options) (*pdfkit.Document, error) {
contentWPx := contentWPt / pxToPt
contentHPx := contentHPt / pxToPt
- box, _ := layout.LayoutDocument(root, sm, contentWPx, fonts, nil)
+ viewportPx := opts.ViewportPx
+ if viewportPx < contentWPx {
+ viewportPx = contentWPx // never upscale — 1:1 is the narrowest layout
+ }
+ scale := contentWPx / viewportPx
+ pageHViewportPx := contentHPx / scale // page-height budget in viewport space
+
+ box, _ := layout.LayoutDocument(root, sm, viewportPx, fonts, nil)
fs, err := loadFonts()
if err != nil {
@@ -94,17 +119,17 @@ func Export(htmlSrc string, opts Options) (*pdfkit.Document, error) {
}
atoms := collectAtoms(box)
- breaks := pageBreaks(atoms, contentHPx)
+ breaks := pageBreaks(atoms, pageHViewportPx)
tops := append([]float64{0}, breaks...)
doc := pdfkit.New(pdfkit.Options{})
- e := &exporter{fonts: fs, pageWPt: pageWPt, pageHPt: pageHPt, marginPt: marginPt}
+ e := &exporter{fonts: fs, pageWPt: pageWPt, pageHPt: pageHPt, marginPt: marginPt, scale: scale}
for i, top := range tops {
- bot := contentHPx * 1e9 // effectively unbounded: the last page
+ bot := pageHViewportPx * 1e9 // effectively unbounded: the last page
if i+1 < len(tops) {
bot = tops[i+1]
}
- e.pageTop, e.pageBot = top, top+contentHPx
+ e.pageTop, e.pageBot = top, top+pageHViewportPx
if bot < e.pageBot {
e.pageBot = bot
}
@@ -113,246 +138,3 @@ func Export(htmlSrc string, opts Options) (*pdfkit.Document, error) {
}
return doc, nil
}
-
-// fontSet resolves the (family, bold, italic) requests the layout produced to
-// loaded pdfkit fonts. Mono has only a regular face, matching paint.Fonts'
-// own fallback (see engine's paint/fonts.go): the family ships no bold or
-// italic style, so both requests render in the upright regular face.
-type fontSet struct {
- sans, sansB, sansI, sansBI *pdfkit.Font
- serif, serifB, serifI, serifBI *pdfkit.Font
- mono *pdfkit.Font
-}
-
-func loadFonts() (*fontSet, error) {
- fs := &fontSet{}
- for _, pair := range []struct {
- dst **pdfkit.Font
- b []byte
- }{
- {&fs.sans, inter.TTF}, {&fs.sansB, inter.BoldTTF}, {&fs.sansI, inter.ItalicTTF}, {&fs.sansBI, inter.BoldItalicTTF},
- {&fs.serif, lora.TTF}, {&fs.serifB, lora.BoldTTF}, {&fs.serifI, lora.ItalicTTF}, {&fs.serifBI, lora.BoldItalicTTF},
- {&fs.mono, gomono.TTF},
- } {
- f, err := pdfkit.LoadFont(pair.b)
- if err != nil {
- return nil, err
- }
- *pair.dst = f
- }
- return fs, nil
-}
-
-func (fs *fontSet) pick(fam css.FontFamily, bold, italic bool) *pdfkit.Font {
- switch fam {
- case css.Serif:
- switch {
- case bold && italic:
- return fs.serifBI
- case bold:
- return fs.serifB
- case italic:
- return fs.serifI
- default:
- return fs.serif
- }
- case css.Mono:
- return fs.mono
- default:
- switch {
- case bold && italic:
- return fs.sansBI
- case bold:
- return fs.sansB
- case italic:
- return fs.sansI
- default:
- return fs.sans
- }
- }
-}
-
-// hasDescendantTr reports whether b's subtree contains another
— the
-// signature of a layout-table trick (a row whose cell holds a nested table)
-// rather than a plain data row.
-func hasDescendantTr(b *layout.Box) bool {
- for _, c := range b.Children {
- if c.Node != nil && c.Node.Type == dom.Element && c.Node.Tag == "tr" {
- return true
- }
- if hasDescendantTr(c) {
- return true
- }
- }
- return false
-}
-
-// atom is one indivisible vertical slice of content for pagination purposes:
-// a single text line, or a whole table row (never split mid-row).
-type atom struct{ top, bottom float64 }
-
-// collectAtoms walks the box tree and returns every atom in document order,
-// sorted by top. A
row is one atom regardless of how many lines its
-// cells wrap to — splitting a row across pages reads worse than a few extra
-// blank millimetres at the bottom of a page. Any other box's own text lines
-// are each their own atom, so a paragraph can still break between lines. A
-// childless, line-less box with real height (a rule, a spacer) gets one atom
-// too, so its height is accounted for even though nothing inside it can break.
-func collectAtoms(b *layout.Box) []atom {
- var out []atom
- var walk func(b *layout.Box)
- walk = func(b *layout.Box) {
- if b == nil {
- return
- }
- // A layout-table trick (a
whose cell holds a nested , e.g.
- // Hacker News' classic markup) must not become one giant atom: that
- // swallowed the inner rows entirely, so the whole nested table — often
- // many pages tall — could only start at a page top, wasting the rest
- // of whichever page it didn't fit. Only a with no
inside it
- // is real tabular content and worth keeping whole.
- if b.Node != nil && b.Node.Type == dom.Element && b.Node.Tag == "tr" && !hasDescendantTr(b) {
- out = append(out, atom{b.Y, b.Y + b.H})
- return
- }
- for _, ln := range b.Lines {
- out = append(out, atom{ln.Y, ln.Y + ln.H})
- }
- if len(b.Children) == 0 && len(b.Lines) == 0 && b.H > 0 {
- out = append(out, atom{b.Y, b.Y + b.H})
- }
- for _, c := range b.Children {
- walk(c)
- }
- }
- walk(b)
- sort.Slice(out, func(i, j int) bool { return out[i].top < out[j].top })
- return out
-}
-
-// pageBreaks returns the y (px, document space) at which each page after the
-// first starts, given the usable content height per page (px). It only ever
-// cuts between atoms — before whichever atom would otherwise overflow the
-// page — so no line or table row is split across pages.
-func pageBreaks(atoms []atom, pageH float64) []float64 {
- if len(atoms) == 0 {
- return nil
- }
- var breaks []float64
- pageTop := 0.0
- for _, a := range atoms {
- if a.bottom-pageTop > pageH && a.top > pageTop {
- breaks = append(breaks, a.top)
- pageTop = a.top
- }
- }
- return breaks
-}
-
-// exporter holds the state for painting one page's slice of the box tree.
-type exporter struct {
- fonts *fontSet
- pageWPt float64
- pageHPt float64
- marginPt float64
- pageTop float64 // px, top of the current page's content slice
- pageBot float64 // px
- p *pdfkit.Page
-}
-
-// toPdf converts a document-space (px) point to this page's PDF point space.
-func (e *exporter) toPdf(xPx, yPx float64) (x, y float64) {
- x = e.marginPt + xPx*pxToPt
- y = e.pageHPt - e.marginPt - (yPx-e.pageTop)*pxToPt
- return
-}
-
-func toRGB(c css.Color) pdfkit.RGB {
- return pdfkit.RGB{R: float64(c.R) / 255, G: float64(c.G) / 255, B: float64(c.B) / 255}
-}
-
-// paintBox paints one box's background/border (clipped to the current page's
-// vertical slice) and its text lines, then recurses into its children.
-func (e *exporter) paintBox(b *layout.Box) {
- if b == nil {
- return
- }
- top, bot := b.Y, b.Y+b.H
- visible := top < e.pageBot && bot > e.pageTop
- if visible && b.Style != nil && b.W > 0 && b.H > 0 {
- ct, cb := top, bot
- if ct < e.pageTop {
- ct = e.pageTop
- }
- if cb > e.pageBot {
- cb = e.pageBot
- }
- if cb > ct {
- x0, y0 := e.toPdf(b.X, ct)
- x1, y1 := e.toPdf(b.X+b.W, cb)
- r := pdfkit.Rect{X: x0, Y: y1, Width: x1 - x0, Height: y0 - y1}
- if b.Style.Background.A > 0 {
- e.p.SetFillColor(toRGB(b.Style.Background))
- e.p.Rectangle(r)
- e.p.Fill()
- }
- e.paintBorders(b, top, bot, x0, y0, x1, y1)
- }
- }
- for _, line := range b.Lines {
- e.paintLine(line)
- }
- for _, c := range b.Children {
- e.paintBox(c)
- }
-}
-
-// paintBorders draws each side present with non-zero width/style/alpha, per
-// css.Borders.Widths' own definition of "present" (mirrored here since that
-// predicate is unexported). Top/bottom only draw on the page where that edge
-// actually falls, so a box spanning a page break doesn't paint a border
-// across the middle of a page; left/right draw the full clipped slice height
-// on every page the box appears on.
-func (e *exporter) paintBorders(b *layout.Box, top, bot, x0, y0, x1, y1 float64) {
- bw := b.Style.Border.Widths()
- draw := func(x0, y0, x1, y1 float64, side css.BorderSide) {
- if side.Width <= 0 || side.Style == css.BorderNone || side.Color.A == 0 {
- return
- }
- e.p.SetStrokeColor(toRGB(side.Color))
- e.p.SetLineWidth(side.Width * pxToPt)
- e.p.MoveTo(x0, y0)
- e.p.LineTo(x1, y1)
- e.p.Stroke()
- }
- if bw.Top > 0 && top >= e.pageTop && top < e.pageBot {
- draw(x0, y0, x1, y0, b.Style.Border.Top)
- }
- if bw.Bottom > 0 && bot > e.pageTop && bot <= e.pageBot {
- draw(x0, y1, x1, y1, b.Style.Border.Bottom)
- }
- if bw.Left > 0 {
- draw(x0, y0, x0, y1, b.Style.Border.Left)
- }
- if bw.Right > 0 {
- draw(x1, y0, x1, y1, b.Style.Border.Right)
- }
-}
-
-// paintLine draws one line box's text items, skipping the line entirely if it
-// doesn't intersect the current page slice.
-func (e *exporter) paintLine(line *layout.LineBox) {
- if line.Y+line.H <= e.pageTop || line.Y >= e.pageBot {
- return
- }
- for _, it := range line.Items {
- if it.Text == "" || it.Style == nil {
- continue
- }
- f := e.fonts.pick(it.Style.FontFamily, it.Style.Bold(), it.Style.Italic)
- e.p.SetFont(f, it.Style.FontSize*pxToPt)
- e.p.SetFillColor(toRGB(it.Style.Color))
- x, y := e.toPdf(it.X, it.Y+it.Ascent)
- _ = e.p.TextShaped(x, y, it.Text)
- }
-}
diff --git a/html2pdf_test.go b/html2pdf_test.go
index 0459105..db5c05b 100644
--- a/html2pdf_test.go
+++ b/html2pdf_test.go
@@ -9,24 +9,32 @@ import (
"strings"
"testing"
+ "github.com/go-pdfkit/pdfkit"
"github.com/go-webengine/engine/css"
"github.com/go-webengine/engine/dom"
"github.com/go-webengine/engine/layout"
"github.com/go-webengine/engine/paint"
)
-// parseAndLayoutForTest mirrors Export's own parse+cascade+layout steps, for
-// tests that need to inspect the box tree collectAtoms sees rather than only
-// the final PDF bytes.
+// parseAndLayoutForTest mirrors Export's own parse+cascade+layout steps at
+// the print column's own width (no viewport scaling), for tests that need to
+// inspect the box tree collectAtoms sees rather than only the final PDF
+// bytes.
func parseAndLayoutForTest(htmlSrc string) (*layout.Box, error) {
+ o := (Options{}).resolved()
+ contentWPx := (o.PageSize.Width - 2*pdfkit.Mm(o.MarginMm)) / pxToPt
+ return layoutAtWidthForTest(htmlSrc, contentWPx)
+}
+
+// layoutAtWidthForTest lays out htmlSrc at an arbitrary viewport width.
+func layoutAtWidthForTest(htmlSrc string, widthPx float64) (*layout.Box, error) {
root, err := dom.Parse(htmlSrc)
if err != nil {
return nil, err
}
sm := css.Cascade(root)
fonts := paint.NewFonts()
- contentWPx := (Options{}).resolved().PageSize.Width / pxToPt
- box, _ := layout.LayoutDocument(root, sm, contentWPx, fonts, nil)
+ box, _ := layout.LayoutDocument(root, sm, widthPx, fonts, nil)
return box, nil
}
@@ -241,6 +249,58 @@ func TestCollectAtomsHandlesNilBox(t *testing.T) {
}
}
+func TestExportScalesAWideFixedWidthPageToFitTheColumn(t *testing.T) {
+ // A fixed-width sidebar beside flexible prose is the real shape this
+ // guards against (RFC 9110's table-of-contents column, found via the
+ // corpus): the sidebar's own width never changes, but the prose next to
+ // it gets whatever the viewport has left over — squeezed to a sliver at
+ // the print column's own ~642px, much roomier at ViewportPx's 1024px —
+ // so it wraps into far fewer lines at the wider layout.
+ html := `` +
+ `
sidebar
` +
+ `
` + strings.Repeat("word ", 400) + `
` +
+ `
`
+
+ narrow, err := parseAndLayoutForTest(html)
+ if err != nil {
+ t.Fatalf("layout: %v", err)
+ }
+ narrowLines := len(collectAtoms(narrow))
+
+ wideBox, err := layoutAtWidthForTest(html, 1024)
+ if err != nil {
+ t.Fatalf("layout: %v", err)
+ }
+ wideLines := len(collectAtoms(wideBox))
+
+ if wideLines >= narrowLines {
+ t.Errorf("wide-viewport layout produced %d line atoms, want fewer than the %d from a 642px-equivalent layout", wideLines, narrowLines)
+ }
+
+ doc, err := Export(html, Options{})
+ if err != nil {
+ t.Fatalf("Export: %v", err)
+ }
+ var buf bytes.Buffer
+ if err := doc.Write(&buf); err != nil {
+ t.Fatalf("Write: %v", err)
+ }
+}
+
+func TestExportViewportNeverNarrowerThanPrintColumn(t *testing.T) {
+ // A ViewportPx set below the print column's own width must not shrink
+ // the layout further — it's clamped up to the column width (scale 1, no
+ // downscaling) rather than upscaling the page.
+ doc, err := Export(`x`, Options{ViewportPx: 10})
+ if err != nil {
+ t.Fatalf("Export: %v", err)
+ }
+ var buf bytes.Buffer
+ if err := doc.Write(&buf); err != nil {
+ t.Fatalf("Write: %v", err)
+ }
+}
+
func TestExportRespectsCustomMargin(t *testing.T) {
doc, err := Export(`x`, Options{MarginMm: 40})
if err != nil {
diff --git a/render.go b/render.go
new file mode 100644
index 0000000..85d054d
--- /dev/null
+++ b/render.go
@@ -0,0 +1,121 @@
+// Copyright (c) the go-pdfkit/html2pdf authors. All rights reserved.
+// Use of this source code is governed by a BSD-3-Clause license that can be
+// found in the LICENSE file at the root of this repository.
+
+package html2pdf
+
+import (
+ "github.com/go-pdfkit/pdfkit"
+ "github.com/go-webengine/engine/css"
+ "github.com/go-webengine/engine/layout"
+)
+
+// exporter holds the state for painting one page's slice of the box tree.
+type exporter struct {
+ fonts *fontSet
+ pageWPt float64
+ pageHPt float64
+ marginPt float64
+ scale float64 // viewport px -> print-column px (see Options.ViewportPx)
+ pageTop float64 // viewport px, top of the current page's content slice
+ pageBot float64 // viewport px
+ p *pdfkit.Page
+}
+
+// toPdf converts a viewport-space (px) point to this page's PDF point space,
+// applying the print-column scale.
+func (e *exporter) toPdf(xPx, yPx float64) (x, y float64) {
+ x = e.marginPt + xPx*e.scale*pxToPt
+ y = e.pageHPt - e.marginPt - (yPx-e.pageTop)*e.scale*pxToPt
+ return
+}
+
+func toRGB(c css.Color) pdfkit.RGB {
+ return pdfkit.RGB{R: float64(c.R) / 255, G: float64(c.G) / 255, B: float64(c.B) / 255}
+}
+
+// paintBox paints one box's background/border (clipped to the current page's
+// vertical slice) and its text lines, then recurses into its children.
+func (e *exporter) paintBox(b *layout.Box) {
+ if b == nil {
+ return
+ }
+ top, bot := b.Y, b.Y+b.H
+ visible := top < e.pageBot && bot > e.pageTop
+ if visible && b.Style != nil && b.W > 0 && b.H > 0 {
+ ct, cb := top, bot
+ if ct < e.pageTop {
+ ct = e.pageTop
+ }
+ if cb > e.pageBot {
+ cb = e.pageBot
+ }
+ if cb > ct {
+ x0, y0 := e.toPdf(b.X, ct)
+ x1, y1 := e.toPdf(b.X+b.W, cb)
+ r := pdfkit.Rect{X: x0, Y: y1, Width: x1 - x0, Height: y0 - y1}
+ if b.Style.Background.A > 0 {
+ e.p.SetFillColor(toRGB(b.Style.Background))
+ e.p.Rectangle(r)
+ e.p.Fill()
+ }
+ e.paintBorders(b, top, bot, x0, y0, x1, y1)
+ }
+ }
+ for _, line := range b.Lines {
+ e.paintLine(line)
+ }
+ for _, c := range b.Children {
+ e.paintBox(c)
+ }
+}
+
+// paintBorders draws each side present with non-zero width/style/alpha, per
+// css.Borders.Widths' own definition of "present" (mirrored here since that
+// predicate is unexported). Top/bottom only draw on the page where that edge
+// actually falls, so a box spanning a page break doesn't paint a border
+// across the middle of a page; left/right draw the full clipped slice height
+// on every page the box appears on.
+func (e *exporter) paintBorders(b *layout.Box, top, bot, x0, y0, x1, y1 float64) {
+ bw := b.Style.Border.Widths()
+ draw := func(x0, y0, x1, y1 float64, side css.BorderSide) {
+ if side.Width <= 0 || side.Style == css.BorderNone || side.Color.A == 0 {
+ return
+ }
+ e.p.SetStrokeColor(toRGB(side.Color))
+ e.p.SetLineWidth(side.Width * e.scale * pxToPt)
+ e.p.MoveTo(x0, y0)
+ e.p.LineTo(x1, y1)
+ e.p.Stroke()
+ }
+ if bw.Top > 0 && top >= e.pageTop && top < e.pageBot {
+ draw(x0, y0, x1, y0, b.Style.Border.Top)
+ }
+ if bw.Bottom > 0 && bot > e.pageTop && bot <= e.pageBot {
+ draw(x0, y1, x1, y1, b.Style.Border.Bottom)
+ }
+ if bw.Left > 0 {
+ draw(x0, y0, x0, y1, b.Style.Border.Left)
+ }
+ if bw.Right > 0 {
+ draw(x1, y0, x1, y1, b.Style.Border.Right)
+ }
+}
+
+// paintLine draws one line box's text items, skipping the line entirely if it
+// doesn't intersect the current page slice.
+func (e *exporter) paintLine(line *layout.LineBox) {
+ if line.Y+line.H <= e.pageTop || line.Y >= e.pageBot {
+ return
+ }
+ for _, it := range line.Items {
+ if it.Text == "" || it.Style == nil {
+ continue
+ }
+ f := e.fonts.pick(it.Style.FontFamily, it.Style.Bold(), it.Style.Italic)
+ e.p.SetFont(f, it.Style.FontSize*e.scale*pxToPt)
+ e.p.SetFillColor(toRGB(it.Style.Color))
+ x, y := e.toPdf(it.X, it.Y+it.Ascent)
+ _ = e.p.TextShaped(x, y, it.Text)
+ }
+}