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