diff --git a/README.md b/README.md index 4c1ed55..e6c5e81 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ doc.Write(f) Or from the shell: -``` +```sh go run ./cmd/html2pdf -in report.html -out report.pdf ``` @@ -56,16 +56,20 @@ 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. -Two gaps, both inherited from — not introduced by — the layout engine: +Images — raster ``, `` and inline `` — go through +the engine's own fetch/decode/size pipeline (`Engine.LoadImages`) and are +embedded as bitmaps, so they're laid out and drawn exactly as the engine's +raster canvas would draw them. A relative `src` resolves against +`Options.BaseURL`; an image that fails to fetch or decode is simply left out, +as on the raster canvas. This is the one place `Export` touches the network. + +One gap remains, inherited from — not introduced by — the layout engine: - **Inline-level background/border/padding does not paint.** A styled `` never gets its own box in go-webengine's layout (confirmed against its reference raster painter too — a shared engine limitation). Style the *containing* block/table-cell instead of an inner inline element when you need a filled badge or pill. -- **Inline `` and `` are not painted yet.** Build charts from plain - block/table markup (backgrounds, borders, percentage widths) rather than - inline SVG until this lands. ## Status diff --git a/corpus/CORPUS.md b/corpus/CORPUS.md index e5027c4..d9d5dea 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 | 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 | +| [https://example.com/](https://example.com/) | ✅ | 1 | 26601 B | 127 | 38ms | 31ms | +| [https://en.wikipedia.org/wiki/Go_(programming_language)](https://en.wikipedia.org/wiki/Go_(programming_language)) | ✅ | 18 | 3737845 B | 63064 | 140ms | 244ms | +| [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 | 1402676 B | 22953 | 43ms | 595ms | +| [https://go.dev/blog/subtests](https://go.dev/blog/subtests) | ✅ | 6 | 984773 B | 13478 | 166ms | 601ms | +| [https://pkg.go.dev/net/http](https://pkg.go.dev/net/http) | ✅ | 49 | 8656699 B | 150974 | 207ms | 1109ms | +| [https://www.rfc-editor.org/rfc/rfc9110.html](https://www.rfc-editor.org/rfc/rfc9110.html) | ✅ | 120 | 28978109 B | 449957 | 241ms | 464ms | +| [https://news.ycombinator.com/](https://news.ycombinator.com/) | ✅ | 1 | 257685 B | 4074 | 467ms | 471ms | +| [https://react.dev/](https://react.dev/) | ✅ | 12 | 9095777 B | 7977 | 47ms | 558ms | @@ -67,6 +67,34 @@ 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×). +### Images now painted — ``, `` and inline `` + +Both image gaps the README used to document are closed in one step. Rather +than re-implement fetch/decode/budgeting, `Export` calls the engine's own +pipeline — `Engine.LoadImages`, exported for exactly this in +go-webengine/engine#114 — and hands its intrinsic-size map to +`layout.LayoutDocument` and its bitmap map to the PDF painter. So an image is +laid out at, and drawn at, precisely the size the engine's raster canvas would +use; an inline `` arrives already rasterised by the same path. + +Embedded image XObjects per page, this run: Wikipedia (Go) 30, countries +list 10, `go.dev/blog` 40, Hacker News 6, `pkg.go.dev/net/http` 88, +`react.dev` 166, `example.com` 0 and RFC 9110 0 (neither carries an ``). +Wikipedia's logo and react.dev's logo/icons land in the right place at the +right size on their page-1 previews. Text length is unchanged everywhere. + +Two things to read correctly in the table above after this change: + +- **`react.dev` grew 5→12 pages and 0.5→9 MB.** That is 166 icon-sized + inline SVGs, each rasterised and stored as raw FlateDecode RGB samples — + pdfkit does not JPEG-encode or deduplicate identical bitmaps. Correct, but + heavy; deduplicating repeated bitmaps into one shared XObject is the + obvious next saving. +- **Render times rose** (`pkg.go.dev` 177→1109 ms, countries 143→595 ms): + image fetch + decode now runs inside `Export`, so the Render column is + network-bound for image-bearing pages. The Fetch column still measures only + the page's own HTML. + ### Confirmed-expected: `react.dev` shows only its static shell 7 pages of real content (nav, hero, first section, one code sample) — the diff --git a/corpus/cmd/corpus/main.go b/corpus/cmd/corpus/main.go index 5be1a84..590e1ca 100644 --- a/corpus/cmd/corpus/main.go +++ b/corpus/cmd/corpus/main.go @@ -94,14 +94,15 @@ func pdfTextChars(path string) int { // renderWithRecover isolates a panic in the layout/paint pipeline to one // corpus entry instead of aborting the whole run — real-world pages are -// exactly where an unanticipated shape shows up first. -func renderWithRecover(html string) (pdfBytes []byte, err error) { +// exactly where an unanticipated shape shows up first. baseURL lets a +// relative resolve, so image-bearing pages are exercised for real. +func renderWithRecover(html, baseURL string) (pdfBytes []byte, err error) { defer func() { if r := recover(); r != nil { err = fmt.Errorf("panic: %v", r) } }() - doc, exportErr := html2pdf.Export(html, html2pdf.Options{}) + doc, exportErr := html2pdf.Export(html, html2pdf.Options{BaseURL: baseURL}) if exportErr != nil { return nil, exportErr } @@ -149,7 +150,7 @@ func run() int { r.HTMLBytes = len(doc.HTML) t1 := time.Now() - pdfBytes, err := renderWithRecover(doc.HTML) + pdfBytes, err := renderWithRecover(doc.HTML, doc.URL) r.RenderMs = time.Since(t1).Milliseconds() if err != nil { r.Error = "render: " + err.Error() diff --git a/corpus/go.mod b/corpus/go.mod index ca32611..4385629 100644 --- a/corpus/go.mod +++ b/corpus/go.mod @@ -4,7 +4,7 @@ go 1.26.4 require ( github.com/go-pdfkit/html2pdf v0.0.0 - github.com/go-webengine/engine v0.3.12-0.20260904101840-577bc2930954 + github.com/go-webengine/engine v0.3.12-0.20260904140318-e12c4d72307a ) require ( diff --git a/corpus/go.sum b/corpus/go.sum index 4715b03..2e32556 100644 --- a/corpus/go.sum +++ b/corpus/go.sum @@ -48,8 +48,8 @@ github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TC github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/go-typeset/bidi v0.3.0 h1:4fjGjejvjE2LzLNzY4si8PkVO321NcsKIiANhWT3jF4= github.com/go-typeset/bidi v0.3.0/go.mod h1:ct3cmYT8Qt1FGJQ+2QaakrUxqaP4ZysgdQQg2ym5+Xo= -github.com/go-webengine/engine v0.3.12-0.20260904101840-577bc2930954 h1:o8bNYAjmFwthTEh2A+6yZgtGrOWpv6ky3uCpbNrl/9E= -github.com/go-webengine/engine v0.3.12-0.20260904101840-577bc2930954/go.mod h1:CAI39d/IqtDZMjjh9NzyVNTMXxUc/miXgweCo8vG2iI= +github.com/go-webengine/engine v0.3.12-0.20260904140318-e12c4d72307a h1:uu/Ojq18zvxh6ogjgRvVJt8e7jTXmari37Zr3hFME/s= +github.com/go-webengine/engine v0.3.12-0.20260904140318-e12c4d72307a/go.mod h1:CAI39d/IqtDZMjjh9NzyVNTMXxUc/miXgweCo8vG2iI= github.com/go-webengine/esbuildsandbox v0.1.0 h1:1LdmX+0/yJarQlxZgJ7JIMu+Qy6P7Yzu4NFcX9+jFl8= github.com/go-webengine/esbuildsandbox v0.1.0/go.mod h1:za8oj1vbDfVkTpHLeRk7JfEQjmJfj+GHNq4R47681vQ= github.com/go-widgets/mvvm v0.9.0 h1:NwW/hAAsJUGlxmNt0ErQ7fUrud3w5Ys1RWm7hVugdJs= 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 1a353c6..dfdfc48 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-1.png b/corpus/out/en-wikipedia-org-wiki-List_of_countries_by_population_United_Nations-p1-1.png index bb89758..b56fa28 100644 Binary files a/corpus/out/en-wikipedia-org-wiki-List_of_countries_by_population_United_Nations-p1-1.png and b/corpus/out/en-wikipedia-org-wiki-List_of_countries_by_population_United_Nations-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 edc9959..986c44f 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 0be5391..041786a 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 b93bd17..79db4f0 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-01.png b/corpus/out/react-dev-p1-01.png new file mode 100644 index 0000000..b5bf493 Binary files /dev/null and b/corpus/out/react-dev-p1-01.png differ diff --git a/corpus/results.json b/corpus/results.json index 80869e9..412f260 100644 --- a/corpus/results.json +++ b/corpus/results.json @@ -3,8 +3,8 @@ "url": "https://example.com/", "slug": "example-com", "ok": true, - "fetch_ms": 41, - "render_ms": 63, + "fetch_ms": 38, + "render_ms": 31, "pdf_bytes": 26601, "pages": 1, "text_chars": 127, @@ -14,52 +14,52 @@ "url": "https://en.wikipedia.org/wiki/Go_(programming_language)", "slug": "en-wikipedia-org-wiki-Go_programming_language", "ok": true, - "fetch_ms": 186, - "render_ms": 178, - "pdf_bytes": 3694522, + "fetch_ms": 140, + "render_ms": 244, + "pdf_bytes": 3737845, "pages": 18, - "text_chars": 63058, + "text_chars": 63064, "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": 54, - "render_ms": 230, - "pdf_bytes": 1389439, + "fetch_ms": 43, + "render_ms": 595, + "pdf_bytes": 1402676, "pages": 9, - "text_chars": 22970, + "text_chars": 22953, "html_bytes": 773512 }, { "url": "https://go.dev/blog/subtests", "slug": "go-dev-blog-subtests", "ok": true, - "fetch_ms": 446, - "render_ms": 57, - "pdf_bytes": 849596, + "fetch_ms": 166, + "render_ms": 601, + "pdf_bytes": 984773, "pages": 6, - "text_chars": 13470, + "text_chars": 13478, "html_bytes": 46512 }, { "url": "https://pkg.go.dev/net/http", "slug": "pkg-go-dev-net-http", "ok": true, - "fetch_ms": 233, - "render_ms": 177, - "pdf_bytes": 8516859, + "fetch_ms": 207, + "render_ms": 1109, + "pdf_bytes": 8656699, "pages": 49, - "text_chars": 150969, + "text_chars": 150974, "html_bytes": 482331 }, { "url": "https://www.rfc-editor.org/rfc/rfc9110.html", "slug": "www-rfc-editor-org-rfc-rfc9110-html", "ok": true, - "fetch_ms": 300, - "render_ms": 675, + "fetch_ms": 241, + "render_ms": 464, "pdf_bytes": 28978109, "pages": 120, "text_chars": 449957, @@ -69,22 +69,22 @@ "url": "https://news.ycombinator.com/", "slug": "news-ycombinator-com", "ok": true, - "fetch_ms": 450, - "render_ms": 45, - "pdf_bytes": 248721, - "pages": 2, - "text_chars": 3985, - "html_bytes": 34445 + "fetch_ms": 467, + "render_ms": 471, + "pdf_bytes": 257685, + "pages": 1, + "text_chars": 4074, + "html_bytes": 34632 }, { "url": "https://react.dev/", "slug": "react-dev", "ok": true, - "fetch_ms": 91, - "render_ms": 58, - "pdf_bytes": 526645, - "pages": 5, - "text_chars": 7965, + "fetch_ms": 47, + "render_ms": 558, + "pdf_bytes": 9095777, + "pages": 12, + "text_chars": 7977, "html_bytes": 272458 } ] diff --git a/go.mod b/go.mod index c75c5ba..c18417b 100644 --- a/go.mod +++ b/go.mod @@ -5,14 +5,19 @@ go 1.26.4 require ( github.com/go-opentype/fonts v0.9.0 github.com/go-pdfkit/pdfkit v0.9.0 - github.com/go-webengine/engine v0.3.12-0.20260904101840-577bc2930954 + github.com/go-webengine/engine v0.3.12-0.20260904140318-e12c4d72307a ) require ( github.com/ajroetker/go-highway v0.0.4 // indirect github.com/ajroetker/go-jpeg2000 v0.0.2 // indirect github.com/andybalholm/brotli v1.2.3 // indirect + github.com/breml/rootcerts v0.3.7 // indirect github.com/coder/websocket v1.8.15 // indirect + github.com/dlclark/regexp2/v2 v2.5.2 // indirect + github.com/dop251/goja v0.0.0-20260826204918-8f1c0696a37b // indirect + github.com/evanw/esbuild v0.28.2 // indirect + github.com/go-browserhttp/browserhttp v0.2.0 // indirect github.com/go-crdt/collab v0.37.0 // indirect github.com/go-crdt/crdt v0.39.0 // indirect github.com/go-gfx/gfx v0.19.0 // indirect @@ -21,13 +26,21 @@ require ( github.com/go-opentype/opentype v0.12.0 // indirect github.com/go-opentype/shape v0.5.0 // indirect github.com/go-richdoc/richdoc v0.3.0 // indirect + github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect github.com/go-typeset/bidi v0.3.0 // indirect + github.com/go-webengine/esbuildsandbox v0.1.0 // indirect github.com/go-widgets/mvvm v0.9.0 // indirect github.com/go-widgets/painter v0.12.0 // indirect github.com/go-widgets/toolkit v0.301.0 // indirect + github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect + github.com/klauspost/compress v1.17.4 // indirect + github.com/refraction-networking/utls v1.8.2 // indirect github.com/sergeymakinen/go-bmp v1.0.0 // indirect github.com/sergeymakinen/go-ico v1.0.0 // indirect + github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect github.com/tannevaled/gobig2 v0.1.0 // indirect + golang.org/x/crypto v0.55.0 // indirect golang.org/x/image v0.45.0 // indirect golang.org/x/net v0.58.0 // indirect golang.org/x/sys v0.47.0 // indirect diff --git a/go.sum b/go.sum index 51e08a4..2e32556 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,25 @@ +github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= +github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/ajroetker/go-highway v0.0.4 h1:RDQo+9OhTXI6BFctLo+5gYpHNbb92VYJ0ObnR4l6xAQ= github.com/ajroetker/go-highway v0.0.4/go.mod h1:C/zYPNSSpOaraejY89FUTZTyQNEhi5+rEbU0LjlqJeU= github.com/ajroetker/go-jpeg2000 v0.0.2 h1:ni8brffZrci4Kacx3nM5d92ipmTDfak84KgHYi6IxFw= github.com/ajroetker/go-jpeg2000 v0.0.2/go.mod h1:7ld88W47lZy0x8gRQesRGAonDPOpr6ev8rckjCAfbzE= github.com/andybalholm/brotli v1.2.3 h1:8H1qwOkl2LPfjf3YezB90JnCliZb6SInJ/OJkEbA5NQ= github.com/andybalholm/brotli v1.2.3/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/breml/rootcerts v0.3.7 h1:KZZkmd591bmq//te85L0u33zgtI3phyGlfD8u7/pzUg= +github.com/breml/rootcerts v0.3.7/go.mod h1:S/PKh+4d1HUn4HQovEB8hPJZO6pUZYrIhmXBhsegfXw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/dlclark/regexp2/v2 v2.5.2 h1:HAsucWRhsqcDzl6Ua9aR8JwYOTzrZyPrF0/FNxJVAI0= +github.com/dlclark/regexp2/v2 v2.5.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= +github.com/dop251/goja v0.0.0-20260826204918-8f1c0696a37b h1:mYHoARbZ0mUYXXsaNeHoDFBft3TK4PpFEe3KU7hdDgg= +github.com/dop251/goja v0.0.0-20260826204918-8f1c0696a37b/go.mod h1:u8yZRUavu+N4EnFFy6J5fVtjE7lEcZ2YyV2GcBXY9c8= +github.com/evanw/esbuild v0.28.2 h1:A2uETn4jrQTcXaT/shwTDTYBxDjl7fV7nXmUrJxfA2w= +github.com/evanw/esbuild v0.28.2/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48= +github.com/go-browserhttp/browserhttp v0.2.0 h1:3RcjOQVHgqhSOmI5cfsURpexw/vetoDUoLXKzgTCSac= +github.com/go-browserhttp/browserhttp v0.2.0/go.mod h1:VJiwBxLetXdc8eDadOcNbiO0EelNQPny0cLZhg1hTKU= github.com/go-crdt/collab v0.37.0 h1:tLtDqao5GODY2pxE/wcEA8U9I/rPdN4LvjEPeE4/QwA= github.com/go-crdt/collab v0.37.0/go.mod h1:kxTeZDZ6p8wC9gEkf2iWgFg2870lYliHJyCC6vDlsAs= github.com/go-crdt/crdt v0.39.0 h1:DJimBDQqmdqKVCAiU4k1aCWPgXeZ+id/FbO09DklTBw= @@ -32,28 +44,44 @@ github.com/go-pdfkit/pdfkit v0.9.0 h1:ZTjjgPEP1IlQRe3BrumCWyO/jgomLb5GUlxb1MhoJM github.com/go-pdfkit/pdfkit v0.9.0/go.mod h1:NSn++aXoOrlB4AxwzY8mMzjG/v9fBGFGyQ/eZ0wRD5k= github.com/go-richdoc/richdoc v0.3.0 h1:BPUHZ0NUrmDVffs2jJcj6ur8mqlccZpZVpCOsuQT9gQ= github.com/go-richdoc/richdoc v0.3.0/go.mod h1:aCX8ulqg5CoKqSWgWeEWul1Oj0d2VxQeaQNI0+jsm7c= +github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q= +github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/go-typeset/bidi v0.3.0 h1:4fjGjejvjE2LzLNzY4si8PkVO321NcsKIiANhWT3jF4= github.com/go-typeset/bidi v0.3.0/go.mod h1:ct3cmYT8Qt1FGJQ+2QaakrUxqaP4ZysgdQQg2ym5+Xo= -github.com/go-webengine/engine v0.3.12-0.20260904101840-577bc2930954 h1:o8bNYAjmFwthTEh2A+6yZgtGrOWpv6ky3uCpbNrl/9E= -github.com/go-webengine/engine v0.3.12-0.20260904101840-577bc2930954/go.mod h1:CAI39d/IqtDZMjjh9NzyVNTMXxUc/miXgweCo8vG2iI= +github.com/go-webengine/engine v0.3.12-0.20260904140318-e12c4d72307a h1:uu/Ojq18zvxh6ogjgRvVJt8e7jTXmari37Zr3hFME/s= +github.com/go-webengine/engine v0.3.12-0.20260904140318-e12c4d72307a/go.mod h1:CAI39d/IqtDZMjjh9NzyVNTMXxUc/miXgweCo8vG2iI= +github.com/go-webengine/esbuildsandbox v0.1.0 h1:1LdmX+0/yJarQlxZgJ7JIMu+Qy6P7Yzu4NFcX9+jFl8= +github.com/go-webengine/esbuildsandbox v0.1.0/go.mod h1:za8oj1vbDfVkTpHLeRk7JfEQjmJfj+GHNq4R47681vQ= github.com/go-widgets/mvvm v0.9.0 h1:NwW/hAAsJUGlxmNt0ErQ7fUrud3w5Ys1RWm7hVugdJs= github.com/go-widgets/mvvm v0.9.0/go.mod h1:1cUYVpDei59Ka7W3pMD1FWksBmBXfPnbPWHzBAJ/ebA= github.com/go-widgets/painter v0.12.0 h1:gMqTVbIbGSKmaE28NKsbbKdNIk2drupNMNuLjFP6T6I= github.com/go-widgets/painter v0.12.0/go.mod h1:IPRLqdUJuJX8sfuHeYLZCzjoLvA0ApbOlyIAVmguJDQ= github.com/go-widgets/toolkit v0.301.0 h1:ymKltlmsMiMIHDObIiljo2umNF+ArbTmLD/Dg/9AYNw= github.com/go-widgets/toolkit v0.301.0/go.mod h1:NMj7CghnZKC+L9BRaAcKjykewTChSFuuTuVH+2wDjV4= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k= +github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-transports/websocket v0.2.0 h1:n4lN066PPcVTvTfLdwa84gfNiAzvS9k4SSQ1yWuG3+g= github.com/grpc-transports/websocket v0.2.0/go.mod h1:2z7Qy11qgPaGDfqzImOt0iyLwH0wjfsvaiidCIIpzgg= +github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= +github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= +github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/sergeymakinen/go-bmp v1.0.0 h1:SdGTzp9WvCV0A1V0mBeaS7kQAwNLdVJbmHlqNWq0R+M= github.com/sergeymakinen/go-bmp v1.0.0/go.mod h1:/mxlAQZRLxSvJFNIEGGLBE/m40f3ZnUifpgVDlcUIEY= github.com/sergeymakinen/go-ico v1.0.0 h1:uL3khgvKkY6WfAetA+RqsguClBuu7HpvBB/nq/Jvr80= github.com/sergeymakinen/go-ico v1.0.0/go.mod h1:wQ47mTczswBO5F0NoDt7O0IXgnV4Xy3ojrroMQzyhUk= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= +github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= +github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= github.com/tannevaled/gobig2 v0.1.0 h1:9PdMvmnmYQURlUF40zt8t35Wnrz3KhZrwfxgCMvQc04= github.com/tannevaled/gobig2 v0.1.0/go.mod h1:X0S1H+N35kg6zYgVYRqGMsGj3C35zs+iA2r1MXyPcV4= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= @@ -70,10 +98,13 @@ go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRk go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0= golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= diff --git a/html2pdf.go b/html2pdf.go index 5c69934..1e9badf 100644 --- a/html2pdf.go +++ b/html2pdf.go @@ -16,10 +16,14 @@ // background/border/padding do not paint: go-webengine's layout does not // give a styled inline run its own box, only block/table/flex-level elements // do (confirmed in the reference raster painter too — this is a shared engine -// limitation, not something this package works around). Inline `` and -// `` are not yet painted; a document that needs a chart should draw it -// with plain block/table markup (backgrounds, borders, percentage widths) -// rather than inline SVG. +// limitation, not something this package works around). +// +// Images — raster , and inline — are fetched, +// decoded and sized by the engine's own pipeline (Engine.LoadImages) and +// embedded as bitmaps, so they are laid out and drawn exactly as the engine's +// raster canvas would. A relative src resolves against Options.BaseURL; an +// image that fails to fetch or decode is simply left out. This is the one +// place Export touches the network. // // # Quick start // @@ -31,9 +35,11 @@ package html2pdf import ( + "context" "fmt" "github.com/go-pdfkit/pdfkit" + "github.com/go-webengine/engine" "github.com/go-webengine/engine/css" "github.com/go-webengine/engine/dom" "github.com/go-webengine/engine/layout" @@ -68,6 +74,11 @@ type Options struct { // 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 + + // BaseURL is the document's own URL, used to resolve a relative + // (and to satisfy same-origin-shaped fetch logic in the engine). Leave it + // empty for a document whose images are all absolute or data: URIs. + BaseURL string } func (o Options) resolved() Options { @@ -111,7 +122,14 @@ func Export(htmlSrc string, opts Options) (*pdfkit.Document, error) { scale := contentWPx / viewportPx pageHViewportPx := contentHPx / scale // page-height budget in viewport space - box, _ := layout.LayoutDocument(root, sm, viewportPx, fonts, nil) + // Images go through the engine's own fetch/decode/size pipeline so their + // boxes are laid out at the exact size the bitmaps come back at (an image + // wider than the viewport is already scaled down there) — the same maps + // RenderDocument feeds to its raster painter. + imgDoc := &engine.Document{URL: opts.BaseURL, Root: root, HTML: htmlSrc} + imgSizes, imgs := engine.New().LoadImages(context.Background(), imgDoc, sm, int(viewportPx)) + + box, _ := layout.LayoutDocument(root, sm, viewportPx, fonts, imgSizes) fs, err := loadFonts() if err != nil { @@ -123,7 +141,7 @@ func Export(htmlSrc string, opts Options) (*pdfkit.Document, error) { tops := append([]float64{0}, breaks...) doc := pdfkit.New(pdfkit.Options{}) - e := &exporter{fonts: fs, pageWPt: pageWPt, pageHPt: pageHPt, marginPt: marginPt, scale: scale} + e := &exporter{fonts: fs, imgs: imgs, pageWPt: pageWPt, pageHPt: pageHPt, marginPt: marginPt, scale: scale} for i, top := range tops { bot := pageHViewportPx * 1e9 // effectively unbounded: the last page if i+1 < len(tops) { diff --git a/images_test.go b/images_test.go new file mode 100644 index 0000000..4553f8d --- /dev/null +++ b/images_test.go @@ -0,0 +1,109 @@ +// 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 ( + "bytes" + "encoding/base64" + "image" + "image/png" + "testing" + + "github.com/go-webengine/engine/dom" + "github.com/go-webengine/engine/layout" +) + +// dataPNG returns a solid-colour w×h PNG as a data: URI, so image tests run +// entirely offline through the engine's real fetch/decode pipeline. +func dataPNG(t *testing.T, w, h int) string { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for i := 0; i < len(img.Pix); i += 4 { + img.Pix[i], img.Pix[i+1], img.Pix[i+2], img.Pix[i+3] = 200, 30, 30, 255 + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatal(err) + } + return "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()) +} + +func TestExportEmbedsAnImage(t *testing.T) { + html := `

before

` + + `` + + `

after

` + 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) + } + // pdfkit writes every embedded bitmap as an image XObject. + if !bytes.Contains(buf.Bytes(), []byte("/Subtype /Image")) { + t.Error("no image XObject in output: the was not embedded") + } +} + +func TestExportInlineSVGIsRasterisedAndEmbedded(t *testing.T) { + // Inline rides the same engine pipeline (serialised then + // rasterised), so it lands as a bitmap too — closing the gap the README + // used to document. + html := `` + + `` + + `` + + `` + 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) + } + if !bytes.Contains(buf.Bytes(), []byte("/Subtype /Image")) { + t.Error("no image XObject in output: the inline was not embedded") + } +} + +func TestExportSkipsAnUnfetchableImage(t *testing.T) { + // A src that can't be fetched is left out of the maps by the engine — + // the document still exports, with no image XObject and no panic. Uses an + // unroutable scheme so this never touches the network. + html := `

text

` + 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) + } + if bytes.Contains(buf.Bytes(), []byte("/Subtype /Image")) { + t.Error("an image XObject appeared for an unfetchable src") + } +} + +func TestExportRelativeImageResolvesAgainstBaseURL(t *testing.T) { + // With no BaseURL a relative src has nothing to resolve against and is + // skipped rather than crashing; the export still succeeds. (A real + // resolution round-trip needs the network and lives in corpus/.) + html := `

t

` + if _, err := Export(html, Options{}); err != nil { + t.Fatalf("Export without BaseURL: %v", err) + } + if _, err := Export(html, Options{BaseURL: "https://x.test/dir/"}); err != nil { + t.Fatalf("Export with BaseURL: %v", err) + } +} + +func TestPaintImageIgnoresAnItemWithNoBitmap(t *testing.T) { + // Defensive: an image item whose element has no decoded bitmap must draw + // nothing and not dereference a nil map entry (a nil imgs map is the + // case where every fetch failed). + e := &exporter{imgs: nil, scale: 1, pageHPt: 800, marginPt: 10} + e.paintImage(&layout.InlineItem{Image: &dom.Node{}, ImgW: 10, ImgH: 10}) +} diff --git a/render.go b/render.go index 85d054d..1eb6411 100644 --- a/render.go +++ b/render.go @@ -5,14 +5,18 @@ package html2pdf import ( + "image" + "github.com/go-pdfkit/pdfkit" "github.com/go-webengine/engine/css" + "github.com/go-webengine/engine/dom" "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 + imgs map[*dom.Node]image.Image // decoded bitmaps, keyed by / element pageWPt float64 pageHPt float64 marginPt float64 @@ -102,13 +106,18 @@ func (e *exporter) paintBorders(b *layout.Box, top, bot, x0, y0, x1, y1 float64) } } -// paintLine draws one line box's text items, skipping the line entirely if it -// doesn't intersect the current page slice. +// paintLine draws one line box's items — text runs, and image items as +// embedded bitmaps — 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.Image != nil { + e.paintImage(it) + continue + } if it.Text == "" || it.Style == nil { continue } @@ -119,3 +128,21 @@ func (e *exporter) paintLine(line *layout.LineBox) { _ = e.p.TextShaped(x, y, it.Text) } } + +// paintImage embeds an image item's decoded bitmap into the box layout gave +// it. The engine's raster painter blits the bitmap at (X, Y) at its native +// size, which its loader already made equal to (ImgW, ImgH) — so that box, +// scaled to the print column, is the destination rectangle. An element whose +// fetch or decode failed has no bitmap and draws nothing, same as on the +// raster canvas. Unlike a background, an image that straddles a page break is +// not clipped per page — it's an atom (its own line box), so pagination +// already keeps it whole; the page-slice test on the line is enough. +func (e *exporter) paintImage(it *layout.InlineItem) { + bmp, ok := e.imgs[it.Image] + if !ok || bmp == nil || it.ImgW <= 0 || it.ImgH <= 0 { + return + } + x0, y0 := e.toPdf(it.X, it.Y) + x1, y1 := e.toPdf(it.X+it.ImgW, it.Y+it.ImgH) + e.p.DrawImage(bmp, pdfkit.Rect{X: x0, Y: y1, Width: x1 - x0, Height: y0 - y1}) +}