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) + } +}