Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
100 changes: 100 additions & 0 deletions atoms.go
Original file line number Diff line number Diff line change
@@ -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 <tr> 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 <table>, 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 <tr> 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 <tr> 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 <tr> — 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
}
58 changes: 34 additions & 24 deletions corpus/CORPUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

<!-- BEGIN ANALYSIS -->

Expand All @@ -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

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified corpus/out/example-com-p1-1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified corpus/out/go-dev-blog-subtests-p1-1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified corpus/out/news-ycombinator-com-p1-1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified corpus/out/pkg-go-dev-net-http-p1-01.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified corpus/out/react-dev-p1-1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified corpus/out/www-rfc-editor-org-rfc-rfc9110-html-p1-001.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
76 changes: 38 additions & 38 deletions corpus/results.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
]
Loading
Loading