diff --git a/README.md b/README.md
index 414d82d..a50bbb8 100644
--- a/README.md
+++ b/README.md
@@ -58,7 +58,37 @@ For more control use an `*engine.Engine` (from `engine.New()`): it adds
and a `DisableJS` field to render the static, no-JavaScript document.
The viewport width is fixed; the height grows to fit the whole page (at least the
-viewport height). There is also a CLI:
+viewport height).
+
+### Vector output (PDF)
+
+`LayoutHTML` / `LayoutDocument` run the same pipeline without painting and
+return a `*PageLayout`. `Paint()` gives the image `RenderHTML` would;
+`PaintVector(c)` draws the same boxes through a `paint.Canvas` instead — paths,
+shadings, images placed in their boxes and positioned glyphs of the very faces
+the layout measured with. The `pdfcanvas` package is that canvas for a
+[go-pdfkit](https://github.com/go-pdfkit/pdfkit) page: text stays text
+(subset-embedded Type0 fonts with a `/ToUnicode` map — selectable,
+searchable), edges stay sharp at any zoom, and an image used on many pages is
+embedded once.
+
+```go
+l, _ := eng.LayoutHTML(ctx, html, baseURL, image.Rect(0, 0, 1280, 720))
+doc := pdfkit.New(pdfkit.Options{Compress: true})
+page := doc.AddPage(pdfkit.NewPageSize(960, 540)) // a CSS px is 3/4 pt
+stats := l.PaintVector(pdfcanvas.New(page, 0.75, pdfcanvas.NewFonts()))
+```
+
+What has no vector form here — a subtree under `opacity` < 1, a `filter`,
+`mask-image` or `transform: rotate()`, a blurred `box-shadow`, a gradient
+whose opacity fades between two stops, a form control — is painted by the
+raster painter as a bitmap of **that element only**, placed where it was, with
+its text laid over it invisibly (text rendering mode 3) so that it can still
+be selected; `stats.Rasterized` counts them. Laying out and painting are split
+so that pages can be laid out concurrently and painted, in order, into one
+document.
+
+There is also a CLI:
```
go run ./cmd/render -url https://example.com/ -out out.png -w 1024 -h 768
@@ -83,8 +113,9 @@ Fetch (go-browserhttp) → Parse (x/net/html → dom) → Cascade (css: +var
| `css` | Real CSS subset: value model, stylesheet/declaration parser, tag/class/id + descendant/child/sibling combinators + `:checked`/`:not()` selectors with specificity, `var()` custom properties, `@media` width queries, modern colour (`rgb()/hsl()`), dark-mode, UA stylesheet, cascade + inheritance. |
| `layout` | Full box model — block-and-inline flow, floats + clear, flexbox, CSS grid, tables, `position` (relative/absolute/fixed/sticky), margin collapsing, greedy word-wrap — driven by a `Measurer` interface (font-free, exactly testable). |
| `js` | JavaScript execution via [goja](https://github.com/dop251/goja) bound to a minimal real DOM, with `fetch()`/XHR and laid-out-geometry read-back (`getBoundingClientRect`, `offset*`, `getComputedStyle`). |
-| `paint` | Rasterises the box tree to `*image.RGBA` — AA text (real bold + italic), gradients, border-radius, box-shadow, opacity, images and SVG; also the real `Measurer` (go-opentype faces). |
-| `engine` (root) | `Fetch`, `Render`, `Screenshot`, `RenderHTML`, `RenderWithLinks`, the settle-then-render loop, image + SVG loading, and the anchor hit-map. |
+| `paint` | Rasterises the box tree to `*image.RGBA` — AA text (real bold + italic), gradients, border-radius, box-shadow, opacity, images and SVG; also the real `Measurer` (go-opentype faces). `PaintVector` draws the same tree through a vector `Canvas`. |
+| `pdfcanvas` | A `paint.Canvas` drawing into a go-pdfkit page: vector PDF with real text. |
+| `engine` (root) | `Fetch`, `Render`, `Screenshot`, `RenderHTML`, `RenderWithLinks`, `LayoutHTML` → `PageLayout` (paint as image or vectors), the settle-then-render loop, image + SVG loading, and the anchor hit-map. |
| `cmd/render` | CLI: `render -url URL -out shot.png -w 1024 -h 768` (or `-file page.html`). |
Everything reused is pure-Go and BSD/MIT — see [`SURVEY.md`](SURVEY.md) for the
diff --git a/css/gradient_geometry_test.go b/css/gradient_geometry_test.go
new file mode 100644
index 0000000..f6f95a9
--- /dev/null
+++ b/css/gradient_geometry_test.go
@@ -0,0 +1,40 @@
+// Copyright (c) the go-webengine/engine authors.
+// SPDX-License-Identifier: BSD-3-Clause
+
+package css
+
+import (
+ "math"
+ "testing"
+)
+
+// TestGradientGeometryAgreesWithAt: the shape Geometry reports is the one At
+// samples — the colour At gives at a point is the stop colour at that point's
+// position along the reported axis (linear) or ellipse (radial).
+func TestGradientGeometryAgreesWithAt(t *testing.T) {
+ black, white := Color{A: 255}, Color{R: 255, G: 255, B: 255, A: 255}
+ stops := []ColorStop{{Color: black}, {Color: white}}
+
+ lin := (&Gradient{AngleDeg: 90, Stops: stops}).Sampler(200, 50)
+ g := lin.Geometry()
+ near := func(a, b float64) bool { return math.Abs(a-b) < 1e-9 }
+ if g.Radial || !near(g.X0, 0) || !near(g.X1, 200) || !near(g.Y0, 25) || !near(g.Y1, 25) {
+ t.Errorf("linear 90deg on 200×50: axis (%v,%v)→(%v,%v), want (0,25)→(200,25)", g.X0, g.Y0, g.X1, g.Y1)
+ }
+ if len(g.Stops) != 2 || g.Stops[0].Pos != 0 || g.Stops[1].Pos != 1 || g.Stops[1].Color != white {
+ t.Errorf("stops = %+v", g.Stops)
+ }
+ if c := lin.At(150, 10); math.Abs(float64(c.R)-0.75*255) > 1 {
+ t.Errorf("At(150) = %v, want 75 %% along the axis", c)
+ }
+
+ rad := (&Gradient{Radial: true, Shape: RadialCircle, Extent: ExtentClosestSide,
+ PosX: Length{Percent: .5, IsPercent: true}, PosY: Length{Percent: .5, IsPercent: true}, Stops: stops}).Sampler(100, 60)
+ g = rad.Geometry()
+ if !g.Radial || g.CX != 50 || g.CY != 30 || g.RX != 30 || g.RY != 30 {
+ t.Errorf("closest-side circle on 100×60: centre (%v,%v) radii (%v,%v), want (50,30) and 30", g.CX, g.CY, g.RX, g.RY)
+ }
+ if c := rad.At(50+15, 30); math.Abs(float64(c.R)-0.5*255) > 1 {
+ t.Errorf("At half the radius = %v, want the middle colour", c)
+ }
+}
diff --git a/css/gradient_sample.go b/css/gradient_sample.go
index a495fdb..c6f6603 100644
--- a/css/gradient_sample.go
+++ b/css/gradient_sample.go
@@ -144,6 +144,45 @@ func scaleToCorner(sx, sy, dx, dy float64) (float64, float64) {
return sx * k, sy * k
}
+// GradientStop is one colour stop of a sampler, resolved: Pos is its
+// fraction along the gradient line (linear) or ray (radial), non-decreasing
+// from one stop to the next; two stops may share a position (a hard edge).
+type GradientStop struct {
+ Pos float64
+ Color Color
+}
+
+// GradientGeometry is a sampler's resolved shape, in the box-local pixel
+// space At is queried in: what a vector backend needs to emit the same
+// gradient as a shading instead of sampling it pixel by pixel.
+//
+// For a linear gradient (Radial false) the colour at fraction t of the stops
+// lies on the line through (X0,Y0) + t·((X1,Y1)−(X0,Y0)) perpendicular to
+// that axis, clamped to the end colours beyond it. For a radial one it lies
+// on the ellipse centred on (CX,CY) with radii t·RX and t·RY, clamped beyond
+// t = 1.
+type GradientGeometry struct {
+ Radial bool
+ X0, Y0, X1, Y1 float64
+ CX, CY, RX, RY float64
+ Stops []GradientStop
+}
+
+// Geometry returns the sampler's resolved shape and stops.
+func (s GradientSampler) Geometry() GradientGeometry {
+ g := GradientGeometry{Radial: s.radial, Stops: make([]GradientStop, len(s.stops))}
+ for i, st := range s.stops {
+ g.Stops[i] = GradientStop{Pos: st.pos, Color: st.color}
+ }
+ if s.radial {
+ g.CX, g.CY, g.RX, g.RY = s.cx, s.cy, s.rx, s.ry
+ } else {
+ g.X0, g.Y0 = s.sx, s.sy
+ g.X1, g.Y1 = s.sx+s.dx*s.length, s.sy+s.dy*s.length
+ }
+ return g
+}
+
// At returns the gradient colour at box-local point (px, py).
func (s GradientSampler) At(px, py float64) Color {
var t float64
diff --git a/go.mod b/go.mod
index be2fe81..509806b 100644
--- a/go.mod
+++ b/go.mod
@@ -6,28 +6,39 @@ require (
github.com/dop251/goja v0.0.0-20260926152631-39ec2650adc9
github.com/evanw/esbuild v0.28.2
github.com/go-browserhttp/browserhttp v0.2.0
- github.com/go-gfx/gfx v0.34.0
- github.com/go-images/images v0.0.0-20260927173152-87444e36aac4
- github.com/go-opentype/fonts v0.10.0
- github.com/go-opentype/opentype v0.13.1-0.20260927180318-ae6327b14eac
+ github.com/go-gfx/gfx v0.35.0
+ github.com/go-images/images v0.1.0
+ github.com/go-opentype/fonts v0.12.0
+ github.com/go-opentype/opentype v0.15.0
+ github.com/go-pdfkit/pdfkit v0.15.0
github.com/go-webengine/esbuildsandbox v0.1.0
- github.com/go-widgets/painter v0.13.0
+ github.com/go-widgets/painter v0.16.0
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
golang.org/x/image v0.46.0
golang.org/x/net v0.61.0
+ rsc.io/pdf v0.1.1
)
require (
- github.com/ajroetker/go-highway v0.0.4 // indirect
- github.com/andybalholm/brotli v1.2.5 // indirect
+ github.com/ajroetker/go-highway v0.0.12 // indirect
+ github.com/andybalholm/brotli v1.2.6 // 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/go-images/gif v0.1.0 // indirect
- github.com/go-images/jpeg v0.2.0 // indirect
- github.com/go-images/jpeg2000 v0.1.0 // indirect
- github.com/go-images/png v0.1.0 // indirect
+ github.com/go-crdt/collab v0.77.1 // indirect
+ github.com/go-crdt/crdt v0.57.0 // indirect
+ github.com/go-icons/iconoir v0.3.0 // indirect
+ github.com/go-images/gif v0.2.0 // indirect
+ github.com/go-images/jpeg v0.3.0 // indirect
+ github.com/go-images/jpeg2000 v0.13.3 // indirect
+ github.com/go-images/png v0.2.0 // indirect
+ github.com/go-opentype/shape v0.7.0 // indirect
+ github.com/go-richdoc/richdoc v0.6.0 // indirect
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
+ github.com/go-typeset/bidi v0.3.1 // indirect
+ github.com/go-widgets/mvvm v0.14.0 // indirect
+ github.com/go-widgets/toolkit v0.329.0 // indirect
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect
github.com/klauspost/compress v1.18.7 // indirect
github.com/refraction-networking/utls v1.8.2 // indirect
@@ -37,4 +48,7 @@ require (
golang.org/x/crypto v0.58.0 // indirect
golang.org/x/sys v0.49.0 // indirect
golang.org/x/text v0.43.0 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 // indirect
+ google.golang.org/grpc v1.84.0 // indirect
+ google.golang.org/protobuf v1.36.12 // indirect
)
diff --git a/go.sum b/go.sum
index 6f3d481..3e0c906 100644
--- a/go.sum
+++ b/go.sum
@@ -1,11 +1,13 @@
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/andybalholm/brotli v1.2.5 h1:BSI8V4zmx/3BAn6OKjF1PmfVq7Aoi52AdFsi6bpCx+s=
-github.com/andybalholm/brotli v1.2.5/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
+github.com/ajroetker/go-highway v0.0.12 h1:OcGp/l/W7WQtH5ELLuNnKXCuR7ePpWGh2RWZI/INtck=
+github.com/ajroetker/go-highway v0.0.12/go.mod h1:un7fcrwix7UOHJrr18Lgh5eeF+1yaZiGWdTw3FbfDv4=
+github.com/andybalholm/brotli v1.2.6 h1:ftYnfj6usCp+UGV5kSJ3+chpMQgU+gJf/AxsUQ52REI=
+github.com/andybalholm/brotli v1.2.6/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/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-20260926152631-39ec2650adc9 h1:Ksd3Vil12b/ykT5NjGzd3qNc688cJB9OUXJZZChEktI=
@@ -14,32 +16,56 @@ 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-gfx/gfx v0.34.0 h1:K46srusNnZls/xkmJRvxpBajlptgrXc6d3exa4ZxhoA=
-github.com/go-gfx/gfx v0.34.0/go.mod h1:TC2X+3GMXm8mYZPmp/v7bFrRa69p+k0xz0Nabbd9sII=
-github.com/go-images/gif v0.1.0 h1:g2ZnRd41PutXIt1mCZSZsRpqqUiD0m+UbAjZ5ylC0RU=
-github.com/go-images/gif v0.1.0/go.mod h1:8mk9sA/K3U61jL+rj4U+Yv3TRKeoGp2fMxF25ykOOwY=
-github.com/go-images/images v0.0.0-20260927173152-87444e36aac4 h1:Likv0wVxr9vDo2ITd4A7SOMKCrN3efOHs9CsANj2y+0=
-github.com/go-images/images v0.0.0-20260927173152-87444e36aac4/go.mod h1:bc99LrkZxVUjP+Di3JCmf4SoHzim/PNcHEJfKd1yUtg=
-github.com/go-images/jpeg v0.2.0 h1:dn55cTczPdOJf+NQq+sETCFeov/KeP1haF1ES+FJa+4=
-github.com/go-images/jpeg v0.2.0/go.mod h1:2C1DevAM+HaQlysTLbSdyol8X/RlvmsXmHAXs9teAqo=
-github.com/go-images/jpeg2000 v0.1.0 h1:SiFAZ6NGvGLU+VmxKji74RiSQDeK6TyjUZr1wjlxRzY=
-github.com/go-images/jpeg2000 v0.1.0/go.mod h1:MwSmrbiP6RK7eLphoNyzY60BAXWOD+8LVdBYDPIBrN4=
-github.com/go-images/png v0.1.0 h1:OI4yHotS0lA75sLnG2FTL2Vooh4PWpVWEidre1msSe0=
-github.com/go-images/png v0.1.0/go.mod h1:V9vaEbr2nrZGorUWn4xv9F24Yuu1/qLVtmailM9GBVo=
-github.com/go-opentype/fonts v0.10.0 h1:9f7ik795SlBEJjz6iqBE/ZV2PaTauAj537MMWw9t4OQ=
-github.com/go-opentype/fonts v0.10.0/go.mod h1:b+o8v1zDZ6B3GixA398jGILAh09+WwfyoYz52zKe7DA=
-github.com/go-opentype/opentype v0.13.1-0.20260927180318-ae6327b14eac h1:mpzfJQe1RlF2zawKqvL5poyNdaV6pMbHkK1h+GVLMuM=
-github.com/go-opentype/opentype v0.13.1-0.20260927180318-ae6327b14eac/go.mod h1:UjZOTCMtkHheDPzfP0ZghDoQ7wIr6L72T/kuC9Ph1hw=
+github.com/go-crdt/collab v0.77.1 h1:eKzzFLIt4eQpM/YLRbbViMbSdHcN6ivSHeA5aldqgzw=
+github.com/go-crdt/collab v0.77.1/go.mod h1:LFqHBOnnr2kRFrTxCVJYWGlaF8jc7l51Z7yVrUY2RnA=
+github.com/go-crdt/crdt v0.57.0 h1:sNLDTVCEnSb20gm77BUuYav8dzj5krmly/z9O7eRMCo=
+github.com/go-crdt/crdt v0.57.0/go.mod h1:zdVTTDLGV5JYAoTJDqCivcAT+/B5gGebDsVd8QNZpVs=
+github.com/go-gfx/gfx v0.35.0 h1:AaXMxDMxNB1hROxzx+3EoO06KxsrQUvnw/10olmSgVo=
+github.com/go-gfx/gfx v0.35.0/go.mod h1:qIZS9u2+VnBk5CX7o4f/fQ5uD8c+mPFTtAPE85s8V90=
+github.com/go-icons/iconoir v0.3.0 h1:kHG2sU5IsCvtBS1JICuXgfFo5f8UfwY1m7KYMVy97CI=
+github.com/go-icons/iconoir v0.3.0/go.mod h1:lPh5OuzVQ+rR4qDPGgAdSRUhnCbHeCAX70HDdwp8/RI=
+github.com/go-images/gif v0.2.0 h1:8Xl4Nan+PvyqH4wPre7wJUH61T2eJKOKonFKOdMdpkU=
+github.com/go-images/gif v0.2.0/go.mod h1:rjpP1X62GLs6pmdMxuvwBjpV4LYE4Ue6rPbeqANWHrQ=
+github.com/go-images/images v0.1.0 h1:KbbxTUpCbddpf6UsMFQetm75xsRXREyUjX0CIvd8YvE=
+github.com/go-images/images v0.1.0/go.mod h1:+8V6BRjITiKEZyyvZZm//bQ8Pdr5rgHgLEElkVOMm40=
+github.com/go-images/jpeg v0.3.0 h1:i6CGNbN2YNEz8stBI0hXjN6UwVGwQ9zCh8LvFeD8fHg=
+github.com/go-images/jpeg v0.3.0/go.mod h1:3eUjtNscxUULLUH88DvMN8OKTq2C0/0AjubOjAHoHHk=
+github.com/go-images/jpeg2000 v0.13.3 h1:GSnkQWQ72ANwfi/xYWt3za9Nfss+C1iBbk4uubLWYHw=
+github.com/go-images/jpeg2000 v0.13.3/go.mod h1:r07D0wUJxBPZcsLTkLFt3OkL3DubrH6DsmRjJRnZYlo=
+github.com/go-images/png v0.2.0 h1:RFPkHPuRYRvrDJol/Mmn9P1DSaLV8VPmEQ8BkZoCpL0=
+github.com/go-images/png v0.2.0/go.mod h1:g1qayDmWLClHo9R5KQ+RrcUNTBcaA4c1thaIfEoS3vg=
+github.com/go-opentype/fonts v0.12.0 h1:gjYYkbDZBK3JHpraakgGNnXdkGq8oADerhDYjC22ILQ=
+github.com/go-opentype/fonts v0.12.0/go.mod h1:Kiep7ojxxaR2ALFeB/akcFHQyl1gG5OLIobp8EzaZSA=
+github.com/go-opentype/opentype v0.15.0 h1:ioE3eCbNZavlk/w073J3uX5FKjqhgOHQw+wZZdJ+gE4=
+github.com/go-opentype/opentype v0.15.0/go.mod h1:ZB31jTJl2mhPESXYMuUW+p36UrOabw/2+6mtyh/vNnI=
+github.com/go-opentype/shape v0.7.0 h1:hXUkI4xdpkzq31ZYf/6zfkDAgRLZnTjI1q8DWLu5MGQ=
+github.com/go-opentype/shape v0.7.0/go.mod h1:n8pMQ/MH4Oscxwd9J0B7RiZ5gKlD0yagRQKYsAmJ9Rw=
+github.com/go-pdfkit/pdfkit v0.15.0 h1:xfG96T7VYUgGMLNTZG1w0ISwLTGdgY1oWF0VDG5A0kE=
+github.com/go-pdfkit/pdfkit v0.15.0/go.mod h1:FrCLKGwHSPlEvsT/Ud7mO81wK1rvKZFxeH2ioesa1/4=
+github.com/go-richdoc/richdoc v0.6.0 h1:cema3hE5AAektDXTmvhWyBVnkCcZTVKhnkingwqsN7k=
+github.com/go-richdoc/richdoc v0.6.0/go.mod h1:RhxAcqwoUg1pnTYCiRdKILzOZwOmraO0dMJI4N6yY70=
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.1 h1:fsTa0b9txt2SkH7M55EZ06+q3N9C4O0Oerf+SPLLGFs=
+github.com/go-typeset/bidi v0.3.1/go.mod h1:ct3cmYT8Qt1FGJQ+2QaakrUxqaP4ZysgdQQg2ym5+Xo=
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/painter v0.13.0 h1:xBPbopFuYfeDVbQKUKviU6HnP2mAhztJ+BpasahlJLU=
-github.com/go-widgets/painter v0.13.0/go.mod h1:IPRLqdUJuJX8sfuHeYLZCzjoLvA0ApbOlyIAVmguJDQ=
+github.com/go-widgets/mvvm v0.14.0 h1:T7rDfZkoaWLycULc8XC6ZXYE0fBMhQgqkZRahPRBSS8=
+github.com/go-widgets/mvvm v0.14.0/go.mod h1:LF77YW5D9sfgpS1WBP3uMG4gV4QfJhRUmFfUNPXxPlg=
+github.com/go-widgets/painter v0.16.0 h1:j8FFObisySQChY2JiXS7Di2Mo4cqufIc0PDUnoDPMiA=
+github.com/go-widgets/painter v0.16.0/go.mod h1:3wKWjaM8ZgEhir2HX0UDt8GemePKJ7l/yIjQb/vVSWk=
+github.com/go-widgets/toolkit v0.329.0 h1:rJt/qAV+5skj5ie7najnKtolEm5fR4x6kd/eAEViVNQ=
+github.com/go-widgets/toolkit v0.329.0/go.mod h1:9own9U7JOYqsiCp9p8/+zlIrobjZQj0uQ39KrY1cBgo=
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/grpc-transports/websocket v0.4.0 h1:P/PyEwzEOQRooWw4HNGEk/D8bkuoHJ/kwVw8EaPspPg=
+github.com/grpc-transports/websocket v0.4.0/go.mod h1:mgExQGqHkZm7empGD6W/+/Va58EaptwEqUpOR2DmnYY=
github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw=
github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
@@ -67,3 +93,13 @@ golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
+gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
+gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 h1:qEHAMpSaUhtD0p3NbEEI83HwNGFxEwaSJ1G9PLnCBZE=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0=
+google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0=
+google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
+google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4=
+rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
diff --git a/paint/canvas.go b/paint/canvas.go
new file mode 100644
index 0000000..83a2fe4
--- /dev/null
+++ b/paint/canvas.go
@@ -0,0 +1,193 @@
+// Copyright (c) the go-webengine/engine authors.
+// SPDX-License-Identifier: BSD-3-Clause
+
+package paint
+
+import (
+ "image"
+ "math"
+
+ "github.com/go-opentype/opentype"
+ "github.com/go-webengine/engine/css"
+)
+
+// Canvas is a vector drawing surface: what PaintVector draws a laid-out page
+// through instead of rasterising it. Coordinates are the layout's own device
+// pixels, origin at the page's top-left, y growing downwards; an
+// implementation maps them to its own space (a PDF page: points, y up).
+//
+// The operations are the few the box painter needs, chosen so that each maps
+// onto one construct of a page-description language: a filled path, a clip,
+// a shading painted through the clip, an image placed in a rectangle and a
+// run of positioned glyphs. Save and Restore bracket every Clip.
+type Canvas interface {
+ // Save pushes the graphics state (the clip); Restore pops it.
+ Save()
+ Restore()
+ // Clip intersects the clip with p, by the nonzero or the even-odd rule.
+ Clip(p *Path, evenOdd bool)
+ // Fill fills p with c, by the nonzero or the even-odd rule. c's alpha is
+ // a constant opacity.
+ Fill(p *Path, c css.Color, evenOdd bool)
+ // Shade paints a gradient through the current clip.
+ Shade(s Shading)
+ // Image paints img, scaled, into dst. img follows image.Image's own
+ // convention (its RGBA method premultiplied); transparent pixels let the
+ // page show through.
+ Image(img image.Image, dst Rect)
+ // Glyphs draws a run of positioned glyphs.
+ Glyphs(run *GlyphRun)
+}
+
+// Rect is a rectangle in device pixels.
+type Rect struct{ X, Y, W, H float64 }
+
+// rectOfImage converts an integer pixel rectangle.
+func rectOfImage(r image.Rectangle) Rect {
+ return Rect{X: float64(r.Min.X), Y: float64(r.Min.Y), W: float64(r.Dx()), H: float64(r.Dy())}
+}
+
+// PathOpKind says what one PathOp does.
+type PathOpKind uint8
+
+// The path operations.
+const (
+ PathMoveTo PathOpKind = iota // start a subpath at P[0]
+ PathLineTo // straight line to P[0]
+ PathCubeTo // cubic Bézier through controls P[0], P[1] to P[2]
+ PathClose // close the subpath
+)
+
+// Point is a point in device pixels.
+type Point struct{ X, Y float64 }
+
+// PathOp is one operation of a Path.
+type PathOp struct {
+ Kind PathOpKind
+ P [3]Point
+}
+
+// Path is an outline made of subpaths of lines and cubic Béziers.
+type Path struct{ Ops []PathOp }
+
+// MoveTo starts a subpath.
+func (p *Path) MoveTo(x, y float64) {
+ p.Ops = append(p.Ops, PathOp{Kind: PathMoveTo, P: [3]Point{{x, y}}})
+}
+
+// LineTo adds a straight segment.
+func (p *Path) LineTo(x, y float64) {
+ p.Ops = append(p.Ops, PathOp{Kind: PathLineTo, P: [3]Point{{x, y}}})
+}
+
+// CubeTo adds a cubic Bézier segment.
+func (p *Path) CubeTo(x1, y1, x2, y2, x3, y3 float64) {
+ p.Ops = append(p.Ops, PathOp{Kind: PathCubeTo, P: [3]Point{{x1, y1}, {x2, y2}, {x3, y3}}})
+}
+
+// Close closes the current subpath.
+func (p *Path) Close() { p.Ops = append(p.Ops, PathOp{Kind: PathClose}) }
+
+// kappa places the control points of the cubic that best approximates a
+// quarter circle: 4/3·(√2−1).
+const kappa = 0.5522847498307936
+
+// RoundRect adds r as a closed subpath with corners of radius rad, clamped
+// to half the shorter side as CSS and the raster painter clamp it; rad <= 0
+// is a plain rectangle. The subpath runs clockwise on screen.
+func (p *Path) RoundRect(r Rect, rad float64) {
+ if r.W <= 0 || r.H <= 0 {
+ return
+ }
+ rad = math.Min(rad, math.Min(r.W, r.H)/2)
+ x0, y0, x1, y1 := r.X, r.Y, r.X+r.W, r.Y+r.H
+ if rad <= 0 {
+ p.MoveTo(x0, y0)
+ p.LineTo(x1, y0)
+ p.LineTo(x1, y1)
+ p.LineTo(x0, y1)
+ p.Close()
+ return
+ }
+ k := rad * kappa
+ p.MoveTo(x0+rad, y0)
+ p.LineTo(x1-rad, y0)
+ p.CubeTo(x1-rad+k, y0, x1, y0+rad-k, x1, y0+rad)
+ p.LineTo(x1, y1-rad)
+ p.CubeTo(x1, y1-rad+k, x1-rad+k, y1, x1-rad, y1)
+ p.LineTo(x0+rad, y1)
+ p.CubeTo(x0+rad-k, y1, x0, y1-rad+k, x0, y1-rad)
+ p.LineTo(x0, y0+rad)
+ p.CubeTo(x0, y0+rad-k, x0+rad-k, y0, x0+rad, y0)
+ p.Close()
+}
+
+// Ellipse adds the ellipse inscribed in r as a closed subpath.
+func (p *Path) Ellipse(r Rect) {
+ if r.W <= 0 || r.H <= 0 {
+ return
+ }
+ rx, ry := r.W/2, r.H/2
+ cx, cy := r.X+rx, r.Y+ry
+ kx, ky := rx*kappa, ry*kappa
+ p.MoveTo(cx+rx, cy)
+ p.CubeTo(cx+rx, cy+ky, cx+kx, cy+ry, cx, cy+ry)
+ p.CubeTo(cx-kx, cy+ry, cx-rx, cy+ky, cx-rx, cy)
+ p.CubeTo(cx-rx, cy-ky, cx-kx, cy-ry, cx, cy-ry)
+ p.CubeTo(cx+kx, cy-ry, cx+rx, cy-ky, cx+rx, cy)
+ p.Close()
+}
+
+// Shading is a gradient to paint through the clip. Its stops all carry the
+// same alpha — a constant opacity for the whole shading — and strictly
+// increasing positions in [0,1]; PaintVector splits a CSS gradient into
+// such pieces.
+//
+// Linear: the colour at position t lies on the line through
+// (X0,Y0)+t·((X1,Y1)−(X0,Y0)), perpendicular to that axis. Radial: on the
+// ellipse centred on (CX,CY) whose radii are (RX,RY) scaled by R0+t·(R1−R0)
+// — a circle of radius R0+t·(R1−R0) under a transform scaling x by RX and y
+// by RY, which is how a PDF draws an ellipse. Extend says whether the end
+// colours continue beyond t=0 and t=1.
+type Shading struct {
+ Radial bool
+ X0, Y0, X1, Y1 float64
+ CX, CY, RX, RY float64
+ R0, R1 float64 // radial: fractions of the ellipse at t=0 and t=1
+ Stops []GradientStop
+ ExtendStart bool
+ ExtendEnd bool
+}
+
+// GradientStop is one stop of a Shading.
+type GradientStop struct {
+ Pos float64
+ Color css.Color
+}
+
+// GlyphRun is a run of glyphs of one font, size and colour, positioned by the
+// layout. X,Y is the run's baseline origin; each glyph's X is its offset
+// along the baseline from it, all in device pixels.
+//
+// An Invisible run is text that is there but not painted — the text of an
+// element PaintVector had to draw as a bitmap, laid over that bitmap so that
+// it can still be selected, searched and copied (PDF's text rendering mode
+// 3, ISO 32000-1 §9.3.6).
+type GlyphRun struct {
+ Font *opentype.Font
+ Size float64
+ Color css.Color
+ X, Y float64
+ Glyphs []Glyph
+ Invisible bool
+}
+
+// Glyph is one glyph of a GlyphRun: its id in the font, its origin's offset
+// from the run origin, and the text it stands for (several characters for a
+// ligature, empty for a glyph continuing an earlier one's cluster), which is
+// what copying the text out of the document gives.
+type Glyph struct {
+ ID opentype.GlyphIndex
+ X float64
+ Text string
+}
diff --git a/paint/vector.go b/paint/vector.go
new file mode 100644
index 0000000..9cb659d
--- /dev/null
+++ b/paint/vector.go
@@ -0,0 +1,763 @@
+// Copyright (c) the go-webengine/engine authors.
+// SPDX-License-Identifier: BSD-3-Clause
+
+package paint
+
+import (
+ "image"
+ "math"
+
+ "github.com/go-webengine/engine/css"
+ "github.com/go-webengine/engine/dom"
+ "github.com/go-webengine/engine/layout"
+ "github.com/go-widgets/painter"
+)
+
+// VectorStats says what PaintVector drew as vectors and what it had to
+// rasterise, so that a caller can tell a page that came out all vector from
+// one that leaned on bitmaps.
+type VectorStats struct {
+ // Rasterized counts the pieces drawn as bitmaps because no vector form
+ // exists for them here: a subtree under opacity < 1, a filter, a
+ // mask-image or a rotation; a blurred box-shadow; a gradient whose
+ // opacity fades between two stops; a form control; a background image
+ // repeated more than maxVectorTiles times.
+ Rasterized int
+ // RasterPixels is their total area, in device pixels.
+ RasterPixels int
+ // Skipped counts pieces that needed a bitmap and got none, because the
+ // page is larger than maxVectorScratchPixels.
+ Skipped int
+ // Images counts the bitmaps placed (, background tiles), Glyphs the
+ // glyphs drawn as text.
+ Images, Glyphs int
+}
+
+// maxVectorTiles bounds how many times one background image is placed as a
+// vector image. Beyond it — a 2-pixel pattern tiled over a whole page would be
+// hundreds of thousands of placements — the layer is rasterised once instead.
+const maxVectorTiles = 1024
+
+// maxVectorScratchPixels bounds the page-sized buffer the raster fallback
+// paints into (64 Mpx, 256 MiB). A page larger than that gets no fallback:
+// the pieces that need one are left out and counted in VectorStats.Skipped.
+const maxVectorScratchPixels = 64 << 20
+
+// PaintVector draws the box tree through c: the same painting order, geometry
+// and clipping as PaintFull, but backgrounds, borders and box shadows as
+// paths, gradients as shadings, text as positioned glyphs of the very faces
+// the layout measured with, and images as images placed at their layout box
+// — so a PDF made from it has selectable text and edges that stay sharp at
+// any zoom.
+//
+// page is the device-pixel rectangle of the page (the viewport): what
+// cannot be drawn as vectors (see VectorStats.Rasterized) is painted by the
+// raster painter into a buffer of that size, cropped to what it painted, and
+// placed as an image at the same position — a per-element fallback, so one
+// filtered icon does not turn the whole page into a bitmap.
+//
+// The page background (the canvas colour) is the caller's to paint first, as
+// for PaintFull.
+func PaintVector(c Canvas, box *layout.Box, f *Fonts, imgs map[*dom.Node]image.Image, bgImgs map[string]image.Image, page image.Rectangle) VectorStats {
+ v := &vpainter{c: c, f: f, imgs: imgs, bgImgs: bgImgs, page: page}
+ v.box(box, page)
+ return v.stats
+}
+
+type vpainter struct {
+ c Canvas
+ f *Fonts
+ imgs map[*dom.Node]image.Image
+ bgImgs map[string]image.Image
+ page image.Rectangle
+ stats VectorStats
+
+ // scratch is the raster fallback's page-sized buffer, allocated on first
+ // use and cleared back to transparent after each use (only where it was
+ // painted).
+ scratch *image.RGBA
+ spp *painter.PixelPainter
+}
+
+// box is paintBox: a subtree the vector forms cannot express goes to the
+// raster fallback whole, as the raster painter's own group pass would take it.
+func (v *vpainter) box(box *layout.Box, clip image.Rectangle) {
+ if box == nil || clip.Empty() {
+ return
+ }
+ if st := box.Style; st != nil {
+ if st.HasOpacity && st.Opacity <= 0 {
+ return
+ }
+ group := (st.HasOpacity && st.Opacity < 1) || len(st.Filters) > 0 ||
+ (st.MaskImage != "" && v.bgImgs[st.MaskImage] != nil) ||
+ st.RotateDeg != 0 || len(st.BackdropFilters) > 0
+ if group {
+ v.raster(clip, func(dst *image.RGBA, pp *painter.PixelPainter) {
+ paintBox(dst, pp, box, v.f, v.imgs, v.bgImgs, clip)
+ })
+ // The bitmap holds the subtree's text as pixels; lay the text
+ // itself over it, unpainted, so that it can still be found and
+ // copied. A rotated box's text is not where the layout put it,
+ // so it gets none.
+ if st.RotateDeg == 0 {
+ v.ghostText(box, clip)
+ }
+ return
+ }
+ }
+ v.content(box, clip)
+}
+
+// content is paintBoxContent.
+func (v *vpainter) content(box *layout.Box, clip image.Rectangle) {
+ if box.Style != nil && box.Style.HasClip && box.Position.OutOfFlow() {
+ if nc := intersectClipRect(clip, box); nc != clip {
+ if nc.Empty() {
+ return
+ }
+ v.c.Save()
+ defer v.c.Restore()
+ v.clipRect(nc)
+ clip = nc
+ }
+ }
+ st := box.Style
+ hidden := st != nil && st.Visibility != css.VisibilityVisible
+ drawable := !hidden && st != nil && box.W > 0 && box.H > 0
+ own := Rect{X: box.X, Y: box.Y, W: box.W, H: box.H}
+ rad := 0.0
+ if drawable {
+ rad = radiusOf(st, box.W, box.H)
+ }
+ visible := drawable && overlaps(own, clip)
+ // 1. Outset box shadows, behind the box.
+ if visible {
+ for i := len(st.BoxShadows) - 1; i >= 0; i-- {
+ if sh := st.BoxShadows[i]; !sh.Inset {
+ v.dropShadow(box, sh, own, rad, clip)
+ }
+ }
+ }
+ // 2. Background colour.
+ if visible && st.Background.A > 0 {
+ var p Path
+ p.RoundRect(own, rad)
+ v.c.Fill(&p, st.Background, false)
+ }
+ // 3. Background-image layers, last listed first.
+ if visible && len(st.BackgroundImages) > 0 {
+ v.backgroundLayers(st, own, rad, clip)
+ }
+ // 4. Inset shadows, over the background.
+ if visible {
+ for i := len(st.BoxShadows) - 1; i >= 0; i-- {
+ if sh := st.BoxShadows[i]; sh.Inset {
+ v.insetShadow(box, sh, own, rad, clip)
+ }
+ }
+ }
+ // 5. Borders.
+ if !hidden && st != nil && !box.Anonymous && box.W > 0 && box.H > 0 && overlaps(own, clip) {
+ v.edges(st.Border, own, radiusOf(st, box.W, box.H), true, true)
+ }
+ inner := descendantClip(clip, box)
+ if inner != clip {
+ if inner.Empty() {
+ return
+ }
+ v.c.Save()
+ defer v.c.Restore()
+ v.clipRect(inner)
+ }
+ // 5.5 List marker.
+ if !hidden && box.Marker != nil {
+ v.marker(box.Marker, inner)
+ }
+ // 6. Inline content: decorations, then words and atoms.
+ if !hidden {
+ for _, line := range box.Lines {
+ for i := range line.Inlines {
+ v.fragment(&line.Inlines[i])
+ }
+ for _, it := range line.Items {
+ v.item(it, inner)
+ }
+ }
+ }
+ // 7. Children.
+ for _, ch := range box.Children {
+ v.box(ch, inner)
+ }
+}
+
+// overlaps reports whether r intersects the integer clip rectangle.
+func overlaps(r Rect, clip image.Rectangle) bool {
+ return r.X < float64(clip.Max.X) && r.X+r.W > float64(clip.Min.X) &&
+ r.Y < float64(clip.Max.Y) && r.Y+r.H > float64(clip.Min.Y)
+}
+
+// radiusOf is styleRadius without the rounding to whole pixels: a vector
+// corner can be as round as the style says.
+func radiusOf(st *css.Style, w, h float64) float64 {
+ if st == nil || st.BorderRadius.Auto {
+ return 0
+ }
+ l := st.BorderRadius
+ r := l.Px
+ if l.IsPercent {
+ r = l.Percent * math.Min(w, h)
+ }
+ return math.Max(r, 0)
+}
+
+func (v *vpainter) clipRect(r image.Rectangle) {
+ var p Path
+ p.RoundRect(rectOfImage(r), 0)
+ v.c.Clip(&p, false)
+}
+
+// ---- backgrounds ----
+
+func (v *vpainter) backgroundLayers(st *css.Style, own Rect, rad float64, clip image.Rectangle) {
+ for i := len(st.BackgroundImages) - 1; i >= 0; i-- {
+ layer := st.BackgroundImages[i]
+ switch layer.Kind {
+ case css.BgGradient:
+ v.gradient(own, rad, layer.Grad, clip)
+ case css.BgURL:
+ if src := v.bgImgs[layer.URL]; src != nil {
+ v.bgBitmap(st, own, rad, src, nthSize(st.BackgroundSize, i), nthPosition(st.BackgroundPosition, i), nthRepeat(st.BackgroundRepeat, i), clip)
+ }
+ }
+ }
+}
+
+// gradient paints a gradient layer as shadings through the box's rounded
+// clip. A shading has one constant opacity, so the stops are split into runs
+// of equal alpha; runs meet only at a hard stop (two stops at one position),
+// and a run whose alpha is 0 is not painted — which covers the usual
+// "colour, then transparent from here on" corner of a theme. A gradient
+// whose opacity changes gradually between two stops has no such split and is
+// rasterised.
+func (v *vpainter) gradient(own Rect, rad float64, g *css.Gradient, clip image.Rectangle) {
+ if g == nil {
+ return
+ }
+ geo := g.Sampler(own.W, own.H).Geometry()
+ runs, ok := alphaRuns(geo.Stops)
+ if !ok {
+ bx := image.Rect(int(own.X), int(own.Y), int(own.X)+int(own.W), int(own.Y)+int(own.H))
+ irad := int(rad + 0.5)
+ v.raster(clip, func(dst *image.RGBA, _ *painter.PixelPainter) {
+ paintGradient(dst, bx, irad, g, clip)
+ })
+ return
+ }
+ v.c.Save()
+ defer v.c.Restore()
+ var p Path
+ p.RoundRect(own, rad)
+ v.c.Clip(&p, false)
+ for k, run := range runs {
+ if run[0].Color.A == 0 {
+ continue
+ }
+ first, last := k == 0, k == len(runs)-1
+ lo, hi := run[0].Pos, run[len(run)-1].Pos
+ stops := make([]GradientStop, 0, len(run))
+ for _, s := range run {
+ stops = append(stops, GradientStop{Pos: s.Pos, Color: s.Color})
+ }
+ if hi <= lo {
+ // One position: a solid band reaching out to whichever end of
+ // the gradient this run is at, or nothing at all in between.
+ switch {
+ case first && last:
+ lo, hi = lo-1, lo+1
+ case first:
+ lo = hi - 1
+ case last:
+ hi = lo + 1
+ default:
+ continue
+ }
+ c := stops[len(stops)-1].Color
+ if first {
+ c = stops[0].Color
+ }
+ stops = []GradientStop{{0, c}, {1, c}}
+ } else {
+ for i := range stops {
+ stops[i].Pos = (stops[i].Pos - lo) / (hi - lo)
+ }
+ stops = strictlyIncreasing(stops)
+ }
+ s := Shading{Radial: geo.Radial, Stops: stops, ExtendStart: first, ExtendEnd: last}
+ if geo.Radial {
+ s.CX, s.CY, s.RX, s.RY = own.X+geo.CX, own.Y+geo.CY, geo.RX, geo.RY
+ s.R0, s.R1 = math.Max(lo, 0), hi
+ if s.R1 <= 0 {
+ continue
+ }
+ } else {
+ ax, ay := geo.X1-geo.X0, geo.Y1-geo.Y0
+ s.X0, s.Y0 = own.X+geo.X0+ax*lo, own.Y+geo.Y0+ay*lo
+ s.X1, s.Y1 = own.X+geo.X0+ax*hi, own.Y+geo.Y0+ay*hi
+ }
+ v.c.Shade(s)
+ }
+}
+
+// alphaRuns splits stops into maximal runs of equal alpha. It fails when two
+// neighbouring stops of different alpha sit at different positions: the
+// opacity then fades, which one constant-opacity shading cannot paint.
+func alphaRuns(stops []css.GradientStop) ([][]css.GradientStop, bool) {
+ if len(stops) == 0 {
+ return nil, false
+ }
+ var runs [][]css.GradientStop
+ start := 0
+ for i := 1; i < len(stops); i++ {
+ if stops[i].Color.A == stops[i-1].Color.A {
+ continue
+ }
+ if stops[i].Pos != stops[i-1].Pos {
+ return nil, false
+ }
+ runs = append(runs, stops[start:i])
+ start = i
+ }
+ return append(runs, stops[start:]), true
+}
+
+// strictlyIncreasing nudges stops that share a position (a hard stop) apart
+// by a millionth of the gradient, keeping them in [0,1].
+func strictlyIncreasing(stops []GradientStop) []GradientStop {
+ const eps = 1e-6
+ for i := 1; i < len(stops); i++ {
+ if stops[i].Pos <= stops[i-1].Pos {
+ stops[i].Pos = stops[i-1].Pos + eps
+ }
+ }
+ if last := stops[len(stops)-1].Pos; last > 1 {
+ for i := range stops {
+ stops[i].Pos /= last
+ }
+ }
+ return stops
+}
+
+// bgBitmap places a background image's tiles as images, with the geometry
+// paintBgBitmap uses but unrounded — each tile is the source bitmap itself,
+// scaled by the placement, so a deck that uses one picture on many pages
+// embeds it once.
+func (v *vpainter) bgBitmap(st *css.Style, own Rect, rad float64, src image.Image, size css.BgSize, pos css.BgPosition, rep css.BgRepeat, clip image.Rectangle) {
+ iw, ih := src.Bounds().Dx(), src.Bounds().Dy()
+ if iw <= 0 || ih <= 0 {
+ return
+ }
+ dw, dh := bgTileSize(size, float64(iw), float64(ih), own.W, own.H)
+ if math.IsNaN(dw) || math.IsNaN(dh) || math.IsInf(dw, 0) || math.IsInf(dh, 0) {
+ return
+ }
+ // The raster painter never draws a tile under one pixel.
+ dw, dh = math.Max(dw, 1), math.Max(dh, 1)
+ ox := resolvePos(pos.X, own.W-dw)
+ oy := resolvePos(pos.Y, own.H-dh)
+ iMin, iMax, jMin, jMax := 0, 0, 0, 0
+ if rep == css.RepeatBoth || rep == css.RepeatX {
+ iMin, iMax = int(math.Floor(-ox/dw)), int(math.Ceil((own.W-ox)/dw))
+ }
+ if rep == css.RepeatBoth || rep == css.RepeatY {
+ jMin, jMax = int(math.Floor(-oy/dh)), int(math.Ceil((own.H-oy)/dh))
+ }
+ if tiles := float64(iMax-iMin+1) * float64(jMax-jMin+1); tiles > maxVectorTiles {
+ bx := image.Rect(int(own.X), int(own.Y), int(own.X)+int(own.W), int(own.Y)+int(own.H))
+ irad := int(rad + 0.5)
+ v.raster(clip, func(dst *image.RGBA, _ *painter.PixelPainter) {
+ paintBgBitmap(dst, bx, irad, src, size, pos, rep, clip, st)
+ })
+ return
+ }
+ v.c.Save()
+ defer v.c.Restore()
+ var p Path
+ p.RoundRect(own, rad)
+ v.c.Clip(&p, false)
+ for j := jMin; j <= jMax; j++ {
+ for i := iMin; i <= iMax; i++ {
+ r := Rect{X: own.X + ox + float64(i)*dw, Y: own.Y + oy + float64(j)*dh, W: dw, H: dh}
+ if !overlaps(r, clip) || r.X >= own.X+own.W || r.Y >= own.Y+own.H || r.X+r.W <= own.X || r.Y+r.H <= own.Y {
+ continue
+ }
+ v.c.Image(src, r)
+ v.stats.Images++
+ }
+ }
+}
+
+// ---- shadows ----
+
+// dropShadow paints an outset box shadow. Without blur it is the shadow
+// rectangle with the box's own shape cut out of it (as paintDropShadow
+// excludes the box's footprint); a blurred one is rasterised.
+func (v *vpainter) dropShadow(box *layout.Box, sh css.BoxShadow, own Rect, rad float64, clip image.Rectangle) {
+ if sh.Color.A == 0 {
+ return
+ }
+ if sh.Blur > 0 {
+ irad := int(rad + 0.5)
+ v.raster(clip, func(dst *image.RGBA, _ *painter.PixelPainter) {
+ paintDropShadow(dst, box, sh, irad, clip)
+ })
+ return
+ }
+ shadow := Rect{X: own.X + sh.OffsetX - sh.Spread, Y: own.Y + sh.OffsetY - sh.Spread, W: own.W + 2*sh.Spread, H: own.H + 2*sh.Spread}
+ if shadow.W <= 0 || shadow.H <= 0 {
+ return
+ }
+ v.c.Save()
+ defer v.c.Restore()
+ var outside Path
+ outside.RoundRect(rectOfImage(clip.Union(rectBounds(shadow)).Union(rectBounds(own))), 0)
+ outside.RoundRect(own, rad)
+ v.c.Clip(&outside, true)
+ var p Path
+ p.RoundRect(shadow, 0)
+ v.c.Fill(&p, sh.Color, false)
+}
+
+// insetShadow paints an inset box shadow: inside the box, everything but the
+// shifted, spread-reduced inner rectangle. A blurred one is rasterised.
+func (v *vpainter) insetShadow(box *layout.Box, sh css.BoxShadow, own Rect, rad float64, clip image.Rectangle) {
+ if sh.Color.A == 0 {
+ return
+ }
+ if sh.Blur > 0 {
+ irad := int(rad + 0.5)
+ v.raster(clip, func(dst *image.RGBA, _ *painter.PixelPainter) {
+ paintInsetShadow(dst, box, sh, irad, clip)
+ })
+ return
+ }
+ v.c.Save()
+ defer v.c.Restore()
+ var clipP Path
+ clipP.RoundRect(own, rad)
+ v.c.Clip(&clipP, false)
+ inner := Rect{X: own.X + sh.Spread + sh.OffsetX, Y: own.Y + sh.Spread + sh.OffsetY, W: own.W - 2*sh.Spread, H: own.H - 2*sh.Spread}
+ var p Path
+ p.RoundRect(own, 0)
+ if inner.W > 0 && inner.H > 0 {
+ p.RoundRect(inner, 0)
+ }
+ v.c.Fill(&p, sh.Color, true)
+}
+
+// rectBounds rounds a Rect out to whole pixels.
+func rectBounds(r Rect) image.Rectangle {
+ return pixelRect(r.X, r.Y, r.X+r.W, r.Y+r.H)
+}
+
+// ---- borders, markers, fragments ----
+
+// edges is paintEdges: a rounded uniform border is a ring between the outer
+// rounded rectangle and the one inset by the border width; any other border
+// is one rectangle per side.
+func (v *vpainter) edges(bd css.Borders, r Rect, rad float64, left, right bool) {
+ if rad > 0 && left && right && uniformBorder(bd) && paintsSide(bd.Top) {
+ w := bd.Top.Width
+ var p Path
+ p.RoundRect(r, rad)
+ in := Rect{X: r.X + w, Y: r.Y + w, W: r.W - 2*w, H: r.H - 2*w}
+ if in.W > 0 && in.H > 0 {
+ p.RoundRect(in, math.Max(math.Min(rad, math.Min(r.W, r.H)/2)-w, 0))
+ }
+ v.c.Fill(&p, bd.Top.Color, true)
+ return
+ }
+ // Every side painted here has a width and lies on a box of some size
+ // (paintsSide; the callers skip empty boxes).
+ fill := func(x, y, w, h float64, c css.Color) {
+ var p Path
+ p.RoundRect(Rect{X: x, Y: y, W: w, H: h}, 0)
+ v.c.Fill(&p, c, false)
+ }
+ if paintsSide(bd.Top) {
+ fill(r.X, r.Y, r.W, bd.Top.Width, bd.Top.Color)
+ }
+ if paintsSide(bd.Bottom) {
+ fill(r.X, r.Y+r.H-bd.Bottom.Width, r.W, bd.Bottom.Width, bd.Bottom.Color)
+ }
+ if left && paintsSide(bd.Left) {
+ fill(r.X, r.Y, bd.Left.Width, r.H, bd.Left.Color)
+ }
+ if right && paintsSide(bd.Right) {
+ fill(r.X+r.W-bd.Right.Width, r.Y, bd.Right.Width, r.H, bd.Right.Color)
+ }
+}
+
+// marker is paintMarker: an ordinal is text, a disc a filled circle, a
+// circle a one-pixel ring, a square a filled square.
+func (v *vpainter) marker(m *layout.Marker, clip image.Rectangle) {
+ if m.Type == css.ListDecimal {
+ v.item(&layout.InlineItem{Text: m.Text, Style: m.Style, X: m.X, Y: m.Y, Ascent: m.Ascent}, clip)
+ return
+ }
+ r := Rect{X: m.X, Y: m.Y, W: m.W, H: m.H}
+ if !overlaps(r, clip) || m.Style == nil {
+ return
+ }
+ var p Path
+ switch m.Type {
+ case css.ListSquare:
+ p.RoundRect(r, 0)
+ v.c.Fill(&p, m.Style.Color, false)
+ case css.ListCircle:
+ p.Ellipse(r)
+ if r.W > 2 && r.H > 2 {
+ p.Ellipse(Rect{X: r.X + 1, Y: r.Y + 1, W: r.W - 2, H: r.H - 2})
+ }
+ v.c.Fill(&p, m.Style.Color, true)
+ default:
+ p.Ellipse(r)
+ v.c.Fill(&p, m.Style.Color, false)
+ }
+}
+
+// fragment is paintInlineFragment.
+func (v *vpainter) fragment(fr *layout.InlineFragment) {
+ st := fr.Style
+ if st == nil || fr.W <= 0 || fr.H <= 0 {
+ return
+ }
+ r := Rect{X: fr.X, Y: fr.Y, W: fr.W, H: fr.H}
+ rad := radiusOf(st, fr.W, fr.H)
+ if st.Background.A > 0 {
+ var p Path
+ p.RoundRect(r, rad)
+ v.c.Fill(&p, st.Background, false)
+ }
+ v.edges(st.Border, r, rad, fr.First, fr.Last)
+}
+
+// ---- inline items ----
+
+// item is paintItem.
+func (v *vpainter) item(it *layout.InlineItem, clip image.Rectangle) {
+ if it.NestedBox != nil {
+ v.box(it.NestedBox, clip)
+ return
+ }
+ if it.Style != nil && it.Style.Visibility != css.VisibilityVisible {
+ return
+ }
+ if it.Image != nil {
+ v.image(it, clip)
+ return
+ }
+ if it.FormControl != nil {
+ v.raster(clip, func(dst *image.RGBA, pp *painter.PixelPainter) {
+ paintFormControl(dst, pp, it, v.f, v.imgs, v.bgImgs, clip)
+ })
+ return
+ }
+ if it.Text == "" || it.Style == nil {
+ return
+ }
+ v.text(it.Style, it.Text, it.X, it.Y+it.Ascent, it.Style.Color, false)
+}
+
+// ghostText draws the text of a subtree as invisible glyphs: what an element
+// drawn as a bitmap still says (see GlyphRun.Invisible). It follows the
+// subtree as content does — its visibility, its own clip — without painting
+// anything.
+func (v *vpainter) ghostText(box *layout.Box, clip image.Rectangle) {
+ if box == nil || clip.Empty() || box.Style == nil {
+ return
+ }
+ st := box.Style
+ if st.HasOpacity && st.Opacity <= 0 {
+ return
+ }
+ if st.HasClip && box.Position.OutOfFlow() {
+ clip = intersectClipRect(clip, box)
+ }
+ inner := descendantClip(clip, box)
+ if st.Visibility == css.VisibilityVisible {
+ if m := box.Marker; m != nil && m.Type == css.ListDecimal && m.Style != nil {
+ v.text(m.Style, m.Text, m.X, m.Y+m.Ascent, m.Style.Color, true)
+ }
+ }
+ for _, line := range box.Lines {
+ for _, it := range line.Items {
+ switch {
+ case it.NestedBox != nil:
+ v.ghostText(it.NestedBox, inner)
+ case it.Text != "" && it.Style != nil && it.Style.Visibility == css.VisibilityVisible && it.Image == nil && it.FormControl == nil:
+ if overlaps(Rect{X: it.X, Y: it.Y, W: math.Max(it.Width, 1), H: math.Max(it.LineHeight, 1)}, inner) {
+ v.text(it.Style, it.Text, it.X, it.Y+it.Ascent, it.Style.Color, true)
+ }
+ }
+ }
+ }
+ for _, ch := range box.Children {
+ v.ghostText(ch, inner)
+ }
+}
+
+// image places an
's bitmap in its layout box, honouring object-fit as
+// paintItem does; a filter is applied to the bitmap first.
+func (v *vpainter) image(it *layout.InlineItem, clip image.Rectangle) {
+ src, ok := v.imgs[it.Image]
+ if !ok || src == nil {
+ return
+ }
+ sb := src.Bounds()
+ if sb.Dx() <= 0 || sb.Dy() <= 0 {
+ return
+ }
+ w, h := it.Width, it.LineHeight
+ if w <= 0 || h <= 0 {
+ w, h = float64(sb.Dx()), float64(sb.Dy())
+ }
+ box := Rect{X: it.X, Y: it.Y, W: w, H: h}
+ if !overlaps(box, clip) {
+ return
+ }
+ if it.Style != nil && len(it.Style.Filters) > 0 {
+ src = applyFilters(toRGBA(src), it.Style.Filters, it.Style.Color)
+ }
+ fit := css.ObjectFitFill
+ if it.Style != nil {
+ fit = it.Style.ObjectFit
+ }
+ if fit != css.ObjectFitFill && it.Width > 0 && it.LineHeight > 0 {
+ tw, th := coverContain(float64(sb.Dx()), float64(sb.Dy()), w, h, fit == css.ObjectFitCover)
+ v.c.Save()
+ defer v.c.Restore()
+ var p Path
+ p.RoundRect(box, 0)
+ v.c.Clip(&p, false)
+ v.c.Image(src, Rect{X: it.X + (w-tw)/2, Y: it.Y + (h-th)/2, W: tw, H: th})
+ v.stats.Images++
+ return
+ }
+ v.c.Image(src, box)
+ v.stats.Images++
+}
+
+// text is drawText with glyphs instead of masks: the runs Fonts.Runs splits
+// the text into, each shaped with the face it is set in, every glyph placed
+// at the exact advance the layout measured with (Measure) plus the
+// letter-spacing, then the underline.
+func (v *vpainter) text(st *css.Style, s string, x, baseline float64, col css.Color, invisible bool) {
+ pen := x
+ bold := st.FontWeight >= 600
+ for _, run := range v.f.Runs(s, st.FontFamily, st.FontWeight, st.Italic) {
+ font := v.f.font(styleKey{st.FontFamily, bold, st.Italic})
+ if run.Fallback {
+ font = v.f.fallbackFont(bold, st.Italic)
+ }
+ face := v.f.runFace(run, st.FontFamily, st.FontSize, st.FontWeight, st.Italic)
+ upem := float64(font.UnitsPerEm())
+ gids := face.Shape(run.Text, requiredLigatureFeature)
+ runes := []rune(run.Text)
+ aligned := len(gids) == len(runes)
+ gr := &GlyphRun{Font: font, Size: st.FontSize, Color: col, X: pen, Y: baseline, Glyphs: make([]Glyph, 0, len(gids)), Invisible: invisible}
+ for i, gid := range gids {
+ if gid == 0 {
+ continue // .notdef: drawn and measured as nothing (see drawText)
+ }
+ g := Glyph{ID: gid, X: pen - gr.X}
+ switch {
+ case aligned:
+ g.Text = string(runes[i])
+ case len(gr.Glyphs) == 0:
+ g.Text = run.Text
+ }
+ gr.Glyphs = append(gr.Glyphs, g)
+ pen += face.AdvanceIndexUnits(gid)*st.FontSize/upem + st.LetterSpacing
+ }
+ if len(gr.Glyphs) > 0 && (col.A > 0 || invisible) {
+ v.c.Glyphs(gr)
+ v.stats.Glyphs += len(gr.Glyphs)
+ }
+ }
+ if st.Underline && pen > x && !invisible {
+ thick := math.Max(float64(int(st.FontSize/14)), 1)
+ var p Path
+ p.RoundRect(Rect{X: x, Y: baseline + thick + 1, W: pen - x, H: thick}, 0)
+ v.c.Fill(&p, col, false)
+ }
+}
+
+// ---- raster fallback ----
+
+// raster paints one piece with the raster painter into the scratch buffer,
+// confined to clip, and places what it painted — cropped to its ink — as an
+// image. The buffer holds the painter's own straight-alpha pixels, so the
+// crop is handed over as an *image.NRGBA.
+func (v *vpainter) raster(clip image.Rectangle, paint func(dst *image.RGBA, pp *painter.PixelPainter)) {
+ clip = clip.Intersect(v.page)
+ if clip.Empty() {
+ return
+ }
+ if v.scratch == nil {
+ if v.page.Min != (image.Point{}) || int64(v.page.Dx())*int64(v.page.Dy()) > maxVectorScratchPixels {
+ v.stats.Skipped++
+ return
+ }
+ v.scratch = image.NewRGBA(image.Rect(0, 0, v.page.Dx(), v.page.Dy()))
+ v.spp = painter.NewPixelPainter(v.scratch.Pix, v.page.Dx(), v.page.Dy())
+ }
+ paint(v.scratch, v.spp)
+ // The painter may have written anywhere in the buffer (a shadow or a
+ // rotated box can reach beyond clip, and the buffer is the page): find
+ // the ink over the whole of it, then keep only what clip shows.
+ ink := inkBounds(v.scratch, v.scratch.Rect)
+ keep := ink.Intersect(clip)
+ if !keep.Empty() {
+ out := image.NewNRGBA(image.Rect(0, 0, keep.Dx(), keep.Dy()))
+ for y := keep.Min.Y; y < keep.Max.Y; y++ {
+ copy(out.Pix[(y-keep.Min.Y)*out.Stride:], v.scratch.Pix[v.scratch.PixOffset(keep.Min.X, y):v.scratch.PixOffset(keep.Max.X, y)])
+ }
+ v.c.Image(out, rectOfImage(keep))
+ v.stats.Rasterized++
+ v.stats.RasterPixels += keep.Dx() * keep.Dy()
+ }
+ for y := ink.Min.Y; y < ink.Max.Y; y++ {
+ clear(v.scratch.Pix[v.scratch.PixOffset(ink.Min.X, y):v.scratch.PixOffset(ink.Max.X, y)])
+ }
+}
+
+// inkBounds is the smallest rectangle of r holding every pixel of img whose
+// alpha is not zero.
+func inkBounds(img *image.RGBA, r image.Rectangle) image.Rectangle {
+ x0, y0, x1, y1 := r.Max.X, r.Max.Y, r.Min.X, r.Min.Y
+ for y := r.Min.Y; y < r.Max.Y; y++ {
+ row := img.Pix[img.PixOffset(r.Min.X, y):img.PixOffset(r.Max.X, y)]
+ for i := 3; i < len(row); i += 4 {
+ if row[i] == 0 {
+ continue
+ }
+ // Plain comparisons: the package has its own min, over a slice.
+ x := r.Min.X + i/4
+ if x < x0 {
+ x0 = x
+ }
+ if x+1 > x1 {
+ x1 = x + 1
+ }
+ if y < y0 {
+ y0 = y
+ }
+ y1 = y + 1
+ }
+ }
+ if x1 <= x0 || y1 <= y0 {
+ return image.Rectangle{}
+ }
+ return image.Rect(x0, y0, x1, y1)
+}
diff --git a/paint/vector_test.go b/paint/vector_test.go
new file mode 100644
index 0000000..cb6f3c9
--- /dev/null
+++ b/paint/vector_test.go
@@ -0,0 +1,655 @@
+// Copyright (c) the go-webengine/engine authors.
+// SPDX-License-Identifier: BSD-3-Clause
+
+package paint
+
+import (
+ "image"
+ "image/color"
+ "math"
+ "strings"
+ "testing"
+
+ "github.com/go-webengine/engine/css"
+ "github.com/go-webengine/engine/dom"
+ "github.com/go-webengine/engine/layout"
+)
+
+// refCanvas is a reference rasteriser for the Canvas operations: it paints
+// what PaintVector emits into an image, point-sampling every pixel centre (no
+// antialiasing), so that a vector page can be compared with the raster
+// painter's image of the same boxes. It also records the calls.
+type refCanvas struct {
+ img *image.RGBA
+ clip []bool
+ stack [][]bool
+
+ fills, clips, shades, images int
+ shadings []Shading
+ runs []*GlyphRun
+ imageRects []Rect
+}
+
+func newRefCanvas(w, h int) *refCanvas {
+ c := &refCanvas{img: white(w, h), clip: make([]bool, w*h)}
+ for i := range c.clip {
+ c.clip[i] = true
+ }
+ return c
+}
+
+func (c *refCanvas) Save() { c.stack = append(c.stack, append([]bool(nil), c.clip...)) }
+func (c *refCanvas) Restore() {
+ c.clip = c.stack[len(c.stack)-1]
+ c.stack = c.stack[:len(c.stack)-1]
+}
+
+// polys flattens a path into closed polygons.
+func polys(p *Path) [][]Point {
+ var out [][]Point
+ var cur []Point
+ for _, op := range p.Ops {
+ switch op.Kind {
+ case PathMoveTo:
+ if len(cur) > 0 {
+ out = append(out, cur)
+ }
+ cur = []Point{op.P[0]}
+ case PathLineTo:
+ cur = append(cur, op.P[0])
+ case PathCubeTo:
+ p0 := cur[len(cur)-1]
+ for i := 1; i <= 16; i++ {
+ t := float64(i) / 16
+ u := 1 - t
+ cur = append(cur, Point{
+ u*u*u*p0.X + 3*u*u*t*op.P[0].X + 3*u*t*t*op.P[1].X + t*t*t*op.P[2].X,
+ u*u*u*p0.Y + 3*u*u*t*op.P[0].Y + 3*u*t*t*op.P[1].Y + t*t*t*op.P[2].Y,
+ })
+ }
+ case PathClose:
+ }
+ }
+ if len(cur) > 0 {
+ out = append(out, cur)
+ }
+ return out
+}
+
+// inside is the winding test at (x,y).
+func inside(ps [][]Point, x, y float64, evenOdd bool) bool {
+ wind := 0
+ for _, poly := range ps {
+ for i := range poly {
+ a, b := poly[i], poly[(i+1)%len(poly)]
+ if a.Y <= y && b.Y > y && (b.X-a.X)*(y-a.Y)-(x-a.X)*(b.Y-a.Y) > 0 {
+ wind++
+ } else if a.Y > y && b.Y <= y && (b.X-a.X)*(y-a.Y)-(x-a.X)*(b.Y-a.Y) < 0 {
+ wind--
+ }
+ }
+ }
+ if evenOdd {
+ return wind%2 != 0
+ }
+ return wind != 0
+}
+
+func (c *refCanvas) each(f func(x, y int)) {
+ b := c.img.Rect
+ for y := 0; y < b.Dy(); y++ {
+ for x := 0; x < b.Dx(); x++ {
+ if c.clip[y*b.Dx()+x] {
+ f(x, y)
+ }
+ }
+ }
+}
+
+func (c *refCanvas) Clip(p *Path, evenOdd bool) {
+ c.clips++
+ ps := polys(p)
+ w := c.img.Rect.Dx()
+ for i := range c.clip {
+ x, y := i%w, i/w
+ if c.clip[i] && !inside(ps, float64(x)+0.5, float64(y)+0.5, evenOdd) {
+ c.clip[i] = false
+ }
+ }
+}
+
+func (c *refCanvas) Fill(p *Path, col css.Color, evenOdd bool) {
+ c.fills++
+ ps := polys(p)
+ c.each(func(x, y int) {
+ if inside(ps, float64(x)+0.5, float64(y)+0.5, evenOdd) {
+ blendPixel(c.img, x, y, col, 255)
+ }
+ })
+}
+
+func (c *refCanvas) Shade(s Shading) {
+ c.shades++
+ c.shadings = append(c.shadings, s)
+ c.each(func(x, y int) {
+ px, py := float64(x)+0.5, float64(y)+0.5
+ var t float64
+ if s.Radial {
+ d := math.Hypot((px-s.CX)/s.RX, (py-s.CY)/s.RY)
+ t = (d - s.R0) / (s.R1 - s.R0)
+ } else {
+ ax, ay := s.X1-s.X0, s.Y1-s.Y0
+ t = ((px-s.X0)*ax + (py-s.Y0)*ay) / (ax*ax + ay*ay)
+ }
+ if (t < 0 && !s.ExtendStart) || (t > 1 && !s.ExtendEnd) {
+ return
+ }
+ t = math.Max(0, math.Min(1, t))
+ col := s.Stops[len(s.Stops)-1].Color
+ for i := 0; i+1 < len(s.Stops); i++ {
+ a, b := s.Stops[i], s.Stops[i+1]
+ if t <= b.Pos {
+ f := 0.0
+ if b.Pos > a.Pos {
+ f = math.Max(0, (t-a.Pos)/(b.Pos-a.Pos))
+ }
+ lerp := func(u, v uint8) uint8 { return uint8(float64(u) + (float64(v)-float64(u))*f + 0.5) }
+ col = css.Color{R: lerp(a.Color.R, b.Color.R), G: lerp(a.Color.G, b.Color.G), B: lerp(a.Color.B, b.Color.B), A: a.Color.A}
+ break
+ }
+ }
+ blendPixel(c.img, x, y, col, 255)
+ })
+}
+
+func (c *refCanvas) Image(img image.Image, dst Rect) {
+ c.images++
+ c.imageRects = append(c.imageRects, dst)
+ sb := img.Bounds()
+ c.each(func(x, y int) {
+ px, py := float64(x)+0.5, float64(y)+0.5
+ if px < dst.X || py < dst.Y || px >= dst.X+dst.W || py >= dst.Y+dst.H {
+ return
+ }
+ sx := sb.Min.X + int((px-dst.X)/dst.W*float64(sb.Dx()))
+ sy := sb.Min.Y + int((py-dst.Y)/dst.H*float64(sb.Dy()))
+ n := color.NRGBAModel.Convert(img.At(sx, sy)).(color.NRGBA)
+ if n.A > 0 {
+ blendPixel(c.img, x, y, css.Color{R: n.R, G: n.G, B: n.B, A: 255}, n.A)
+ }
+ })
+}
+
+func (c *refCanvas) Glyphs(run *GlyphRun) {
+ c.runs = append(c.runs, run)
+ if run.Invisible {
+ return
+ }
+ face := run.Font.NewFace(int(run.Size + 0.5))
+ w := c.img.Rect.Dx()
+ for _, g := range run.Glyphs {
+ bounds, mask, mp, _, ok := face.GlyphMaskIndex(g.ID, int(math.Round(run.X+g.X)), int(math.Round(run.Y)))
+ if !ok || mask == nil {
+ continue
+ }
+ r := bounds.Intersect(c.img.Rect)
+ for y := r.Min.Y; y < r.Max.Y; y++ {
+ for x := r.Min.X; x < r.Max.X; x++ {
+ if a := mask.AlphaAt(mp.X+x-bounds.Min.X, mp.Y+y-bounds.Min.Y).A; a > 0 && c.clip[y*w+x] {
+ blendPixel(c.img, x, y, run.Color, a)
+ }
+ }
+ }
+ }
+}
+
+// text is the concatenated text of the recorded runs, visible or not.
+func (c *refCanvas) text() string {
+ var b strings.Builder
+ for _, r := range c.runs {
+ for _, g := range r.Glyphs {
+ b.WriteString(g.Text)
+ }
+ }
+ return b.String()
+}
+
+// diffFraction is the fraction of pixels where any channel differs by more
+// than tol.
+func diffFraction(a, b *image.RGBA, tol int) float64 {
+ n := 0
+ for i := 0; i < len(a.Pix); i += 4 {
+ for k := 0; k < 3; k++ {
+ if absDiff(a.Pix[i+k], b.Pix[i+k]) > tol {
+ n++
+ break
+ }
+ }
+ }
+ return float64(n) / float64(len(a.Pix)/4)
+}
+
+// parity paints box both ways and fails when the vector page, rasterised by
+// refCanvas, differs from PaintFull's image on more than frac of its pixels.
+func parity(t *testing.T, box *layout.Box, w, h int, imgs map[*dom.Node]image.Image, bgImgs map[string]image.Image, frac float64) (*refCanvas, VectorStats) {
+ t.Helper()
+ want := white(w, h)
+ PaintFull(want, box, NewFonts(), imgs, bgImgs)
+ c := newRefCanvas(w, h)
+ st := PaintVector(c, box, NewFonts(), imgs, bgImgs, image.Rect(0, 0, w, h))
+ if d := diffFraction(want, c.img, 64); d > frac {
+ t.Errorf("vector page differs from the raster one on %.2f %% of pixels (allowed %.2f %%)", 100*d, 100*frac)
+ }
+ return c, st
+}
+
+func rgb(r, g, b uint8) css.Color { return css.Color{R: r, G: g, B: b, A: 255} }
+
+func div() *dom.Node { return &dom.Node{Type: dom.Element, Tag: "div"} }
+
+func TestVectorBackgroundsBordersAndText(t *testing.T) {
+ ts := &css.Style{Color: rgb(0, 0, 0), FontSize: 20, FontWeight: 400, FontFamily: css.Sans, Underline: true, LetterSpacing: 1}
+ border := css.BorderSide{Width: 3, Style: css.BorderSolid, Color: rgb(0, 0, 200)}
+ box := &layout.Box{
+ Node: div(), Style: &css.Style{Background: rgb(230, 230, 230)}, W: 120, H: 80,
+ Children: []*layout.Box{{
+ Node: div(), X: 10, Y: 10, W: 100, H: 40,
+ Style: &css.Style{Background: rgb(255, 255, 0), Border: css.Borders{Top: border, Right: border, Bottom: border, Left: css.BorderSide{Width: 6, Style: css.BorderSolid, Color: rgb(200, 0, 0)}}},
+ Lines: []*layout.LineBox{{
+ Inlines: []layout.InlineFragment{{Style: &css.Style{Background: rgb(0, 200, 0), BorderRadius: css.Length{Px: 4}, Border: css.Borders{Top: border}}, X: 16, Y: 14, W: 40, H: 26, First: true, Last: true}},
+ Items: []*layout.InlineItem{{Text: "Hi ─", Style: ts, X: 18, Y: 14, Ascent: 18, Width: 40, LineHeight: 26}},
+ }},
+ }},
+ }
+ c, st := parity(t, box, 120, 80, nil, nil, 0.03)
+ if got := c.text(); got != "Hi ─" {
+ t.Errorf("text = %q, want the item's own text, ─ from the fallback face", got)
+ }
+ if len(c.runs) != 2 || c.runs[1].Font == c.runs[0].Font {
+ t.Errorf("want two runs, ─ in the fallback font: %d", len(c.runs))
+ }
+ if st.Rasterized != 0 || st.Glyphs != 4 {
+ t.Errorf("stats = %+v, want all vector, four glyphs", st)
+ }
+}
+
+func TestVectorRoundedBoxAndUniformBorderRing(t *testing.T) {
+ side := css.BorderSide{Width: 4, Style: css.BorderSolid, Color: rgb(0, 0, 0)}
+ box := &layout.Box{Node: div(), X: 5, Y: 5, W: 60, H: 40,
+ Style: &css.Style{Background: rgb(250, 100, 0), BorderRadius: css.Length{Percent: 0.25, IsPercent: true}, Border: css.Borders{Top: side, Right: side, Bottom: side, Left: side}}}
+ parity(t, box, 70, 50, nil, nil, 0.04)
+ // A ring too thick for an inner edge is the outer shape alone.
+ thick := css.BorderSide{Width: 30, Style: css.BorderSolid, Color: rgb(0, 0, 0)}
+ c := newRefCanvas(70, 50)
+ box.Style.Border = css.Borders{Top: thick, Right: thick, Bottom: thick, Left: thick}
+ PaintVector(c, box, NewFonts(), nil, nil, image.Rect(0, 0, 70, 50))
+ if px := c.img.RGBAAt(35, 25); px.R != 0 {
+ t.Errorf("a border thicker than half the box fills it: %v", px)
+ }
+}
+
+func TestVectorGradients(t *testing.T) {
+ red, blue := rgb(255, 0, 0), rgb(0, 0, 255)
+ clear := css.Color{}
+ at := func(c css.Color, p float64) css.ColorStop {
+ return css.ColorStop{Color: c, HasPos: true, Pos: css.Length{Percent: p, IsPercent: true}}
+ }
+ for _, tc := range []struct {
+ name string
+ g *css.Gradient
+ shades int
+ raster int
+ }{
+ {"two stops", &css.Gradient{AngleDeg: 90, Stops: []css.ColorStop{stop(red), stop(blue)}}, 1, 0},
+ {"corner, hard stop", &css.Gradient{Corner: 2, Stops: []css.ColorStop{at(red, 0), at(red, .5), at(blue, .5), at(blue, 1)}}, 1, 0},
+ {"hard stop to transparent", &css.Gradient{AngleDeg: -45, Stops: []css.ColorStop{at(red, .5), at(clear, .5)}}, 1, 0},
+ {"transparent then colour", &css.Gradient{AngleDeg: 180, Stops: []css.ColorStop{at(clear, .3), at(blue, .3), at(red, 1)}}, 1, 0},
+ {"a zero-width band between two hard stops", &css.Gradient{AngleDeg: 90, Stops: []css.ColorStop{at(red, 0), at(red, .3), at(css.Color{B: 255, A: 128}, .3), at(clear, .3), at(clear, 1)}}, 1, 0},
+ {"hard stop to a translucent end", &css.Gradient{AngleDeg: 90, Stops: []css.ColorStop{at(red, 0), at(red, .5), at(css.Color{B: 255, A: 128}, .5)}}, 2, 0},
+ {"one stop", &css.Gradient{AngleDeg: 90, Stops: []css.ColorStop{stop(red)}}, 1, 0},
+ {"translucent", &css.Gradient{AngleDeg: 0, Stops: []css.ColorStop{stop(css.Color{R: 255, A: 128}), stop(css.Color{B: 255, A: 128})}}, 1, 0},
+ {"fading", &css.Gradient{AngleDeg: 90, Stops: []css.ColorStop{stop(red), stop(clear)}}, 0, 1},
+ {"radial circle", &css.Gradient{Radial: true, Shape: css.RadialCircle, PosX: css.Length{Percent: .5, IsPercent: true}, PosY: css.Length{Percent: .5, IsPercent: true}, Stops: []css.ColorStop{stop(rgb(255, 255, 255)), stop(blue)}}, 1, 0},
+ {"radial ellipse, hard ring", &css.Gradient{Radial: true, PosX: css.Length{Percent: .3, IsPercent: true}, PosY: css.Length{Percent: .4, IsPercent: true}, Stops: []css.ColorStop{at(red, 0), at(red, .4), at(blue, .4), at(blue, 1)}}, 1, 0},
+ {"radial, stops past the end", &css.Gradient{Radial: true, PosX: css.Length{Percent: .5, IsPercent: true}, PosY: css.Length{Percent: .5, IsPercent: true}, Stops: []css.ColorStop{at(red, 0), at(blue, 1.5)}}, 1, 0},
+ {"radial, opaque only at the centre", &css.Gradient{Radial: true, PosX: css.Length{Percent: .5, IsPercent: true}, PosY: css.Length{Percent: .5, IsPercent: true}, Stops: []css.ColorStop{at(red, 0), at(clear, 0)}}, 0, 0},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ st := &css.Style{BackgroundImages: []css.BgImage{{Kind: css.BgGradient, Grad: tc.g}}, BorderRadius: css.Length{Px: 6}}
+ box := &layout.Box{Node: div(), X: 4, Y: 4, W: 90, H: 50, Style: st}
+ c, vs := parity(t, box, 100, 60, nil, nil, 0.04)
+ if c.shades != tc.shades || vs.Rasterized != tc.raster {
+ t.Errorf("shades %d, rasterised %d; want %d and %d", c.shades, vs.Rasterized, tc.shades, tc.raster)
+ }
+ for _, s := range c.shadings {
+ for i := 1; i < len(s.Stops); i++ {
+ if s.Stops[i].Pos <= s.Stops[i-1].Pos || s.Stops[i].Color.A != s.Stops[0].Color.A {
+ t.Errorf("stops %v: positions must increase and share one alpha", s.Stops)
+ }
+ }
+ }
+ })
+ }
+ // A nil gradient layer paints nothing.
+ c := newRefCanvas(10, 10)
+ PaintVector(c, &layout.Box{Node: div(), W: 10, H: 10, Style: &css.Style{BackgroundImages: []css.BgImage{{Kind: css.BgGradient}}}}, NewFonts(), nil, nil, image.Rect(0, 0, 10, 10))
+ if c.shades != 0 || c.fills != 0 {
+ t.Error("a nil gradient drew")
+ }
+}
+
+func TestAlphaRunsAndStrictlyIncreasing(t *testing.T) {
+ if _, ok := alphaRuns(nil); ok {
+ t.Error("no stops: ok")
+ }
+ s := strictlyIncreasing([]GradientStop{{Pos: 0}, {Pos: 1}, {Pos: 1}})
+ if s[2].Pos > 1 || s[2].Pos <= s[1].Pos {
+ t.Errorf("hard stop at the end: %v", s)
+ }
+}
+
+func TestVectorBackgroundImages(t *testing.T) {
+ src := twoTone(4, 4)
+ for _, tc := range []struct {
+ name string
+ size css.BgSize
+ rep css.BgRepeat
+ images int
+ raster int
+ }{
+ {"cover", css.BgSize{Kind: css.SizeCover}, css.NoRepeat, 1, 0},
+ {"repeat", css.BgSize{Kind: css.SizeExplicit, W: css.Length{Px: 20}, H: css.Length{Px: 20}}, css.RepeatBoth, 18, 0},
+ {"repeat-x", css.BgSize{Kind: css.SizeExplicit, W: css.Length{Px: 30}, H: css.Length{Px: 30}}, css.RepeatX, 4, 0},
+ {"repeat-y", css.BgSize{Kind: css.SizeExplicit, W: css.Length{Px: 30}, H: css.Length{Px: 30}}, css.RepeatY, 3, 0},
+ // The raster fallback's one image.
+ {"too many tiles", css.BgSize{Kind: css.SizeExplicit, W: css.Length{Px: 2}, H: css.Length{Px: 2}}, css.RepeatBoth, 1, 1},
+ {"no size: one-pixel tiles, too many", css.BgSize{Kind: css.SizeExplicit, W: css.Length{Px: 0}, H: css.Length{Px: 0}}, css.RepeatBoth, 1, 1},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ st := urlStyle("u", tc.size, css.BgPosition{X: css.Length{Px: 5}, Y: css.Length{Percent: .5, IsPercent: true}}, tc.rep)
+ box := &layout.Box{Node: div(), X: 0, Y: 0, W: 90, H: 40, Style: st}
+ bg := map[string]image.Image{"u": src}
+ c, vs := parity(t, box, 300, 300, nil, bg, 0.02)
+ if c.images != tc.images || vs.Rasterized != tc.raster {
+ t.Errorf("images %d, rasterised %d; want %d and %d", c.images, vs.Rasterized, tc.images, tc.raster)
+ }
+ })
+ }
+ // An empty bitmap, a layer whose bitmap is missing and a size that is
+ // not a number draw nothing.
+ c := newRefCanvas(10, 10)
+ st := urlStyle("e", css.BgSize{Kind: css.SizeCover}, css.BgPosition{}, css.NoRepeat)
+ st.BackgroundImages = append(st.BackgroundImages, css.BgImage{Kind: css.BgURL, URL: "missing"}, css.BgImage{Kind: css.BgURL, URL: "nan"})
+ st.BackgroundSize = append(st.BackgroundSize, css.BgSize{Kind: css.SizeCover}, css.BgSize{Kind: css.SizeExplicit, W: css.Length{Px: math.NaN()}, H: css.Length{Px: 3}})
+ PaintVector(c, &layout.Box{Node: div(), W: 10, H: 10, Style: st}, NewFonts(), nil, map[string]image.Image{"e": image.NewRGBA(image.Rect(0, 0, 0, 0)), "nan": src}, image.Rect(0, 0, 10, 10))
+ if c.images != 0 {
+ t.Error("an empty bitmap was placed")
+ }
+}
+
+func TestVectorShadows(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ sh css.BoxShadow
+ raster int
+ fills int
+ }{
+ {"sharp outset", css.BoxShadow{OffsetX: 6, OffsetY: 4, Color: rgb(0, 120, 0)}, 0, 2},
+ {"blurred outset", css.BoxShadow{OffsetY: 4, Blur: 8, Color: css.Color{A: 160}}, 1, 1},
+ {"spread swallowing the box", css.BoxShadow{Spread: -40, Color: rgb(0, 120, 0)}, 0, 1},
+ {"transparent", css.BoxShadow{OffsetX: 6, Color: css.Color{}}, 0, 1},
+ {"sharp inset", css.BoxShadow{Inset: true, OffsetX: 3, OffsetY: 3, Spread: 2, Color: rgb(0, 0, 160)}, 0, 2},
+ {"inset swallowing the box", css.BoxShadow{Inset: true, Spread: 40, Color: rgb(0, 0, 160)}, 0, 2},
+ {"blurred inset", css.BoxShadow{Inset: true, Blur: 6, Color: css.Color{A: 200}}, 1, 1},
+ {"transparent inset", css.BoxShadow{Inset: true, Color: css.Color{}}, 0, 1},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ box := &layout.Box{Node: div(), X: 20, Y: 15, W: 50, H: 30, Style: &css.Style{Background: rgb(240, 240, 255), BorderRadius: css.Length{Px: 5}, BoxShadows: []css.BoxShadow{tc.sh}}}
+ c, vs := parity(t, box, 100, 70, nil, nil, 0.03)
+ if vs.Rasterized != tc.raster || c.fills != tc.fills {
+ t.Errorf("rasterised %d, fills %d; want %d and %d", vs.Rasterized, c.fills, tc.raster, tc.fills)
+ }
+ })
+ }
+}
+
+func TestVectorMarkers(t *testing.T) {
+ ms := &css.Style{Color: rgb(0, 0, 0), FontSize: 16, FontWeight: 400, FontFamily: css.Sans}
+ for _, typ := range []css.ListStyleType{css.ListDisc, css.ListCircle, css.ListSquare, css.ListDecimal} {
+ box := &layout.Box{Node: div(), X: 0, Y: 0, W: 60, H: 30, Style: &css.Style{},
+ Marker: &layout.Marker{Type: typ, Style: ms, Text: "1.", X: 10, Y: 10, W: 8, H: 8, Ascent: 14}}
+ parity(t, box, 60, 30, nil, nil, 0.03)
+ }
+ // A marker outside the clip, or with no style, draws nothing; a tiny
+ // circle has no hole.
+ c := newRefCanvas(40, 40)
+ v := &vpainter{c: c, f: NewFonts(), page: image.Rect(0, 0, 40, 40)}
+ v.marker(&layout.Marker{Type: css.ListDisc, Style: ms, X: 100, Y: 100, W: 8, H: 8}, v.page)
+ v.marker(&layout.Marker{Type: css.ListDisc, X: 1, Y: 1, W: 8, H: 8}, v.page)
+ v.marker(&layout.Marker{Type: css.ListCircle, Style: ms, X: 1, Y: 1, W: 2, H: 2}, v.page)
+ if c.fills != 1 {
+ t.Errorf("fills = %d, want only the tiny circle", c.fills)
+ }
+}
+
+func TestVectorImages(t *testing.T) {
+ n := &dom.Node{Type: dom.Element, Tag: "img"}
+ src := twoTone(8, 4)
+ item := func(st *css.Style, w, h float64) *layout.Box {
+ return &layout.Box{Node: div(), W: 80, H: 60, Style: &css.Style{}, Lines: []*layout.LineBox{{Items: []*layout.InlineItem{{Image: n, Style: st, X: 10, Y: 10, Width: w, LineHeight: h}}}}}
+ }
+ imgs := map[*dom.Node]image.Image{n: src}
+ for name, b := range map[string]*layout.Box{
+ "fill": item(&css.Style{}, 40, 20),
+ "cover": item(&css.Style{ObjectFit: css.ObjectFitCover}, 30, 30),
+ "contain": item(&css.Style{ObjectFit: css.ObjectFitContain}, 30, 30),
+ "natural": item(nil, 0, 0),
+ "filter": item(&css.Style{Filters: []css.Filter{{Kind: css.FilterInvert, Amount: 1}}}, 40, 20),
+ } {
+ t.Run(name, func(t *testing.T) {
+ c, vs := parity(t, b, 80, 60, imgs, nil, 0.03)
+ if c.images != 1 || vs.Images != 1 {
+ t.Errorf("images = %d", c.images)
+ }
+ })
+ }
+ // Missing, empty and off-page images draw nothing.
+ c := newRefCanvas(80, 60)
+ PaintVector(c, item(nil, 40, 20), NewFonts(), nil, nil, image.Rect(0, 0, 80, 60))
+ PaintVector(c, item(nil, 40, 20), NewFonts(), map[*dom.Node]image.Image{n: image.NewRGBA(image.Rect(0, 0, 0, 3))}, nil, image.Rect(0, 0, 80, 60))
+ off := item(nil, 40, 20)
+ off.Lines[0].Items[0].X = 500
+ PaintVector(c, off, NewFonts(), imgs, nil, image.Rect(0, 0, 80, 60))
+ if c.images != 0 {
+ t.Errorf("images = %d, want none", c.images)
+ }
+}
+
+// TestVectorGroupsAreRasterisedWithTheirTextKept: what has no vector form
+// is drawn as a bitmap of that element only — and its text is laid over it,
+// invisible, so it can still be selected.
+func TestVectorGroupsAreRasterisedWithTheirTextKept(t *testing.T) {
+ ts := &css.Style{Color: rgb(0, 0, 0), FontSize: 18, FontWeight: 400, FontFamily: css.Sans}
+ hidden := &css.Style{Color: rgb(0, 0, 0), FontSize: 18, FontWeight: 400, FontFamily: css.Sans, Visibility: css.VisibilityHidden}
+ mk := func(st *css.Style) *layout.Box {
+ inner := &layout.Box{Node: div(), X: 10, Y: 40, W: 40, H: 20, Style: &css.Style{Background: rgb(0, 0, 255)},
+ Lines: []*layout.LineBox{{Items: []*layout.InlineItem{{Text: "in", Style: ts, X: 12, Y: 40, Ascent: 15, Width: 20, LineHeight: 20}}}}}
+ nested := &layout.Box{Node: div(), Style: &css.Style{}, Lines: []*layout.LineBox{{Items: []*layout.InlineItem{{Text: "nest", Style: ts, X: 60, Y: 40, Ascent: 15, Width: 30, LineHeight: 20}}}}}
+ return &layout.Box{Node: div(), X: 5, Y: 5, W: 100, H: 70, Style: st,
+ Marker: &layout.Marker{Type: css.ListDecimal, Style: ts, Text: "1.", X: 0, Y: 5, Ascent: 15},
+ Lines: []*layout.LineBox{{Items: []*layout.InlineItem{
+ {Text: "Group", Style: ts, X: 10, Y: 8, Ascent: 15, Width: 50, LineHeight: 22},
+ {Text: "gone", Style: hidden, X: 70, Y: 8, Ascent: 15, Width: 30, LineHeight: 22},
+ {Text: "far", Style: ts, X: 900, Y: 8, Ascent: 15, Width: 30, LineHeight: 22},
+ {NestedBox: nested},
+ }}},
+ Children: []*layout.Box{inner, {Node: div(), Style: &css.Style{HasOpacity: true, Opacity: 0}}, nil}}
+ }
+ for name, st := range map[string]*css.Style{
+ "opacity": {Background: rgb(255, 200, 0), HasOpacity: true, Opacity: 0.5},
+ "filter": {Background: rgb(255, 200, 0), Filters: []css.Filter{{Kind: css.FilterGrayscale, Amount: 1}}},
+ "clip": {Background: rgb(255, 200, 0), HasOpacity: true, Opacity: 0.5, HasClip: true, ClipRect: css.Edges{Right: 1000, Bottom: 1000}},
+ } {
+ t.Run(name, func(t *testing.T) {
+ b := mk(st)
+ if name == "clip" {
+ b.Position = css.PositionAbsolute
+ }
+ c, vs := parity(t, b, 120, 80, nil, nil, 0.03)
+ if vs.Rasterized != 1 {
+ t.Errorf("rasterised %d, want the one group", vs.Rasterized)
+ }
+ if got := c.text(); got != "1.Groupnestin" {
+ t.Errorf("ghost text = %q", got)
+ }
+ for _, r := range c.runs {
+ if !r.Invisible {
+ t.Error("a group's text must be invisible over its bitmap")
+ }
+ }
+ })
+ }
+ // A rotated box keeps no ghost text: its words are not where the
+ // layout put them.
+ b := mk(&css.Style{Background: rgb(255, 200, 0), RotateDeg: 10})
+ c, vs := parity(t, b, 120, 80, nil, nil, 1)
+ if vs.Rasterized != 1 || len(c.runs) != 0 {
+ t.Errorf("rotated: rasterised %d, runs %d", vs.Rasterized, len(c.runs))
+ }
+ // Opacity 0 draws nothing at all, and so does ghostText of nothing.
+ c = newRefCanvas(10, 10)
+ PaintVector(c, &layout.Box{Style: &css.Style{HasOpacity: true, Opacity: 0, Background: rgb(1, 2, 3)}, W: 5, H: 5}, NewFonts(), nil, nil, image.Rect(0, 0, 10, 10))
+ v := &vpainter{c: c, f: NewFonts(), page: image.Rect(0, 0, 10, 10)}
+ v.ghostText(nil, v.page)
+ v.ghostText(&layout.Box{}, v.page)
+ v.ghostText(&layout.Box{Style: &css.Style{HasOpacity: true}}, v.page)
+ if c.fills+len(c.runs) != 0 {
+ t.Error("nothing should have been drawn")
+ }
+}
+
+func TestVectorClipsOverflowAndClipRect(t *testing.T) {
+ ts := &css.Style{Color: rgb(0, 0, 0), FontSize: 18, FontWeight: 400, FontFamily: css.Sans}
+ child := &layout.Box{Node: div(), X: 0, Y: 0, W: 200, H: 200, Style: &css.Style{Background: rgb(255, 0, 0)},
+ Lines: []*layout.LineBox{{Items: []*layout.InlineItem{{Text: "overflowing text", Style: ts, X: 5, Y: 5, Ascent: 15}}}}}
+ box := &layout.Box{Node: div(), X: 10, Y: 10, W: 40, H: 30, Children: []*layout.Box{child},
+ Style: &css.Style{OverflowX: css.OverflowHidden, OverflowY: css.OverflowHidden, Height: css.Length{Px: 30}}}
+ c, _ := parity(t, box, 100, 100, nil, nil, 0.03)
+ if c.clips == 0 {
+ t.Error("an overflow clip must become a clip path")
+ }
+ // clip: rect(0 0 0 0), the visually-hidden idiom, draws nothing.
+ sr := &layout.Box{Node: div(), X: 10, Y: 10, W: 40, H: 30, Position: css.PositionAbsolute,
+ Style: &css.Style{Background: rgb(255, 0, 0), HasClip: true}}
+ c = newRefCanvas(60, 60)
+ PaintVector(c, sr, NewFonts(), nil, nil, image.Rect(0, 0, 60, 60))
+ if c.fills != 0 {
+ t.Error("clip: rect(0 0 0 0) painted")
+ }
+ // An overflow clip of zero size hides the children.
+ zero := &layout.Box{Node: div(), X: 10, Y: 10, W: 0, H: 0, Children: []*layout.Box{child},
+ Style: &css.Style{OverflowX: css.OverflowHidden, OverflowY: css.OverflowHidden, Height: css.Length{Px: 0}}}
+ c = newRefCanvas(60, 60)
+ PaintVector(c, zero, NewFonts(), nil, nil, image.Rect(0, 0, 60, 60))
+ if c.fills != 0 {
+ t.Error("a zero-sized clip let its children through")
+ }
+ // A narrower clip rect is a clip path.
+ part := &layout.Box{Node: div(), X: 10, Y: 10, W: 40, H: 30, Position: css.PositionAbsolute,
+ Style: &css.Style{Background: rgb(255, 0, 0), HasClip: true, ClipRect: css.Edges{Right: 20, Bottom: 30}}}
+ parity(t, part, 60, 60, nil, nil, 0.03)
+}
+
+func TestVectorInlineContent(t *testing.T) {
+ ts := &css.Style{Color: rgb(0, 0, 0), FontSize: 18, FontWeight: 400, FontFamily: css.Sans}
+ nested := &layout.Box{Node: div(), X: 40, Y: 5, W: 30, H: 20, Style: &css.Style{Background: rgb(0, 160, 0)}}
+ box := &layout.Box{Node: div(), W: 100, H: 40, Style: &css.Style{}, Lines: []*layout.LineBox{{
+ Inlines: []layout.InlineFragment{{Style: nil, W: 10, H: 10}, {Style: &css.Style{Background: rgb(9, 9, 9)}, W: 0, H: 10}},
+ Items: []*layout.InlineItem{
+ {NestedBox: nested},
+ {Text: "x", Style: &css.Style{Color: rgb(0, 0, 0), FontSize: 18, FontFamily: css.Sans, Visibility: css.VisibilityHidden}, X: 2, Y: 2, Ascent: 15},
+ {Text: "", Style: ts},
+ {Text: "y"},
+ {Text: "中", Style: ts, X: 80, Y: 2, Ascent: 15},
+ {Text: "z", Style: &css.Style{Color: css.Color{}, FontSize: 18, FontFamily: css.Sans}, X: 90, Y: 2, Ascent: 15},
+ },
+ }}}
+ c, _ := parity(t, box, 100, 40, nil, nil, 0.02)
+ if c.text() != "" {
+ t.Errorf("text = %q, want none drawn: hidden, empty, unstyled, uncovered, transparent", c.text())
+ }
+}
+
+// TestVectorLigatureKeepsItsWholeText: a glyph standing for several
+// characters carries all of them, so copying an icon-font word gives the word.
+func TestVectorLigatureKeepsItsWholeText(t *testing.T) {
+ f, fam := materialIconsFamily(t)
+ st := &css.Style{FontFamily: fam, FontSize: 48, FontWeight: 400, Color: rgb(0, 0, 0)}
+ c := newRefCanvas(80, 80)
+ v := &vpainter{c: c, f: f, page: image.Rect(0, 0, 80, 80)}
+ v.text(st, "arrow_drop_down", 4, 60, st.Color, false)
+ if len(c.runs) != 1 || len(c.runs[0].Glyphs) != 1 || c.runs[0].Glyphs[0].Text != "arrow_drop_down" {
+ t.Errorf("runs = %+v, want one glyph carrying the word", c.runs)
+ }
+}
+
+func TestVectorFormControlIsRasterised(t *testing.T) {
+ root, err := dom.Parse("")
+ if err != nil {
+ t.Fatal(err)
+ }
+ btn := dom.Find(root, "button")
+ st := &css.Style{Color: rgb(0, 0, 0), FontSize: 14, FontFamily: css.Sans, Background: rgb(239, 239, 239)}
+ box := &layout.Box{Node: div(), W: 100, H: 40, Style: &css.Style{}, Lines: []*layout.LineBox{{Items: []*layout.InlineItem{
+ {FormControl: btn, Style: st, X: 10, Y: 5, Width: 60, LineHeight: 26, Ascent: 18},
+ }}}}
+ _, vs := parity(t, box, 100, 40, nil, nil, 0.02)
+ if vs.Rasterized != 1 {
+ t.Errorf("rasterised %d, want the control", vs.Rasterized)
+ }
+}
+
+func TestVectorRasterFallbackBounds(t *testing.T) {
+ c := newRefCanvas(10, 10)
+ // A page that does not start at the origin, or is too large, gets no
+ // scratch buffer: the piece is skipped and counted.
+ for _, page := range []image.Rectangle{image.Rect(1, 1, 10, 10), image.Rect(0, 0, 1<<14, 1<<14)} {
+ v := &vpainter{c: c, f: NewFonts(), page: page}
+ v.box(&layout.Box{Node: div(), X: 2, Y: 2, W: 5, H: 5, Style: &css.Style{Background: rgb(1, 2, 3), HasOpacity: true, Opacity: .5}}, page)
+ if v.stats.Skipped != 1 || v.stats.Rasterized != 0 {
+ t.Errorf("page %v: stats %+v", page, v.stats)
+ }
+ }
+ // Outside the page, and painting nothing, place nothing; nor do no box
+ // and an empty clip.
+ v := &vpainter{c: c, f: NewFonts(), page: image.Rect(0, 0, 10, 10)}
+ v.box(nil, v.page)
+ v.box(&layout.Box{Style: &css.Style{Background: rgb(1, 2, 3)}, W: 5, H: 5}, image.Rectangle{})
+ v.box(&layout.Box{Node: div(), X: 50, Y: 50, W: 5, H: 5, Style: &css.Style{Background: rgb(1, 2, 3), HasOpacity: true, Opacity: .5}}, image.Rect(20, 20, 30, 30))
+ v.box(&layout.Box{Node: div(), X: 2, Y: 2, W: 5, H: 5, Style: &css.Style{HasOpacity: true, Opacity: .5}}, v.page)
+ if c.images != 0 || v.stats.Rasterized != 0 {
+ t.Errorf("images %d, stats %+v", c.images, v.stats)
+ }
+ if !inkBounds(image.NewRGBA(image.Rect(0, 0, 4, 4)), image.Rect(0, 0, 4, 4)).Empty() {
+ t.Error("an empty buffer has ink")
+ }
+}
+
+func TestPathShapes(t *testing.T) {
+ var p Path
+ p.RoundRect(Rect{W: 0, H: 5}, 2)
+ p.Ellipse(Rect{W: 5, H: 0})
+ if len(p.Ops) != 0 {
+ t.Errorf("empty shapes added %d ops", len(p.Ops))
+ }
+ p.RoundRect(Rect{W: 10, H: 4}, 50) // clamped to 2
+ if got := p.Ops[1].P[0].X; got != 8 {
+ t.Errorf("radius not clamped to half the short side: line ends at %v", got)
+ }
+ if r := rectOfImage(image.Rect(1, 2, 4, 8)); r != (Rect{X: 1, Y: 2, W: 3, H: 6}) {
+ t.Errorf("rectOfImage = %+v", r)
+ }
+ if radiusOf(nil, 1, 1) != 0 || radiusOf(&css.Style{BorderRadius: css.Length{Auto: true, Px: 3}}, 1, 1) != 0 || radiusOf(&css.Style{BorderRadius: css.Length{Px: -3}}, 1, 1) != 0 {
+ t.Error("radiusOf: no style, auto and negative are 0")
+ }
+}
diff --git a/pdfcanvas/pdfcanvas.go b/pdfcanvas/pdfcanvas.go
new file mode 100644
index 0000000..79c9cc5
--- /dev/null
+++ b/pdfcanvas/pdfcanvas.go
@@ -0,0 +1,241 @@
+// Copyright (c) the go-webengine/engine authors.
+// SPDX-License-Identifier: BSD-3-Clause
+
+// Package pdfcanvas draws a go-webengine layout into a go-pdfkit page: a
+// paint.Canvas whose paths, shadings, images and glyphs become the PDF's
+// own, so the page keeps its text as text (subset-embedded fonts, with a
+// /ToUnicode map, so it can be selected, searched and copied) and its edges
+// as vectors.
+//
+// doc := pdfkit.New(pdfkit.Options{Compress: true})
+// fonts := pdfcanvas.NewFonts()
+// l, _ := eng.LayoutHTML(ctx, html, base, image.Rect(0, 0, 1280, 720))
+// page := doc.AddPage(pdfkit.NewPageSize(960, 540))
+// l.PaintVector(pdfcanvas.New(page, 0.75, fonts))
+//
+// A device pixel is mapped to scale points (0.75 for a CSS pixel at device
+// scale 1: 1/96 in against 1/72 in) and the y axis is flipped, the page's top
+// left being the layout's origin.
+package pdfcanvas
+
+import (
+ "image"
+
+ "github.com/go-opentype/opentype"
+ "github.com/go-pdfkit/pdfkit"
+ "github.com/go-webengine/engine/css"
+ "github.com/go-webengine/engine/paint"
+)
+
+// Fonts converts the layout's parsed fonts into pdfkit fonts, once each. Use
+// one per document (a pdfkit.Font may be shared, but a document is where the
+// set of fonts is bounded); it is not safe for concurrent use.
+type Fonts struct {
+ m map[*opentype.Font]*pdfkit.Font
+}
+
+// NewFonts returns an empty font map.
+func NewFonts() *Fonts { return &Fonts{m: map[*opentype.Font]*pdfkit.Font{}} }
+
+func (fs *Fonts) get(ot *opentype.Font) (*pdfkit.Font, error) {
+ if f, ok := fs.m[ot]; ok {
+ return f, nil
+ }
+ f, err := pdfkit.NewFont(ot)
+ if err != nil {
+ return nil, err
+ }
+ fs.m[ot] = f
+ return f, nil
+}
+
+// Canvas is a paint.Canvas drawing into one pdfkit page.
+type Canvas struct {
+ p *pdfkit.Page
+ s float64 // points per device pixel
+ h float64 // page height, points
+ fonts *Fonts
+
+ // Err is the first error a drawing call met (a font pdfkit cannot embed,
+ // a shading it refuses); the call that met it drew nothing, the others
+ // went on.
+ Err error
+}
+
+var _ paint.Canvas = (*Canvas)(nil)
+
+// New returns a canvas drawing into p, scale points per device pixel, with
+// fonts shared across the document's pages. A nil fonts gets a map of its
+// own.
+func New(p *pdfkit.Page, scale float64, fonts *Fonts) *Canvas {
+ if fonts == nil {
+ fonts = NewFonts()
+ }
+ return &Canvas{p: p, s: scale, h: p.Height(), fonts: fonts}
+}
+
+func (c *Canvas) x(v float64) float64 { return v * c.s }
+func (c *Canvas) y(v float64) float64 { return c.h - v*c.s }
+
+func (c *Canvas) fail(err error) {
+ if c.Err == nil {
+ c.Err = err
+ }
+}
+
+// Save implements paint.Canvas.
+func (c *Canvas) Save() { c.p.Save() }
+
+// Restore implements paint.Canvas.
+func (c *Canvas) Restore() { c.p.Restore() }
+
+// path writes p's segments, mapped to the page.
+func (c *Canvas) path(p *paint.Path) bool {
+ if p == nil || len(p.Ops) == 0 {
+ return false
+ }
+ for _, op := range p.Ops {
+ switch op.Kind {
+ case paint.PathMoveTo:
+ c.p.MoveTo(c.x(op.P[0].X), c.y(op.P[0].Y))
+ case paint.PathLineTo:
+ c.p.LineTo(c.x(op.P[0].X), c.y(op.P[0].Y))
+ case paint.PathCubeTo:
+ c.p.CurveTo(c.x(op.P[0].X), c.y(op.P[0].Y), c.x(op.P[1].X), c.y(op.P[1].Y), c.x(op.P[2].X), c.y(op.P[2].Y))
+ case paint.PathClose:
+ c.p.ClosePath()
+ }
+ }
+ return true
+}
+
+// Clip implements paint.Canvas: the path, then W (or W*) and n, the clipping
+// path operators of ISO 32000-1 §8.5.4.
+func (c *Canvas) Clip(p *paint.Path, evenOdd bool) {
+ if !c.path(p) {
+ // An empty clip path clips everything away.
+ c.p.Rectangle(pdfkit.Rect{})
+ }
+ if evenOdd {
+ c.p.ClipEvenOdd()
+ } else {
+ c.p.Clip()
+ }
+ c.p.EndPath()
+}
+
+// rgb is a css.Color's colour, without its alpha.
+func rgb(col css.Color) pdfkit.RGB {
+ return pdfkit.RGB{R: float64(col.R) / 255, G: float64(col.G) / 255, B: float64(col.B) / 255}
+}
+
+// withAlpha runs draw under a constant opacity (the ca/CA of an ExtGState,
+// ISO 32000-1 §11.6.4.4) when a is below opaque.
+func (c *Canvas) withAlpha(a uint8, draw func()) {
+ if a == 0 {
+ return
+ }
+ if a == 255 {
+ draw()
+ return
+ }
+ c.p.Save()
+ f := float64(a) / 255
+ c.p.SetAlpha(f, f)
+ draw()
+ c.p.Restore()
+}
+
+// Fill implements paint.Canvas.
+func (c *Canvas) Fill(p *paint.Path, col css.Color, evenOdd bool) {
+ if p == nil || len(p.Ops) == 0 {
+ return
+ }
+ c.withAlpha(col.A, func() {
+ c.p.SetFillColor(rgb(col))
+ c.path(p)
+ if evenOdd {
+ c.p.FillEvenOdd()
+ } else {
+ c.p.Fill()
+ }
+ })
+}
+
+// Shade implements paint.Canvas: an axial shading (type 2) for a linear
+// gradient, a radial one (type 3) drawn under a transform that makes its
+// circles the gradient's ellipses.
+func (c *Canvas) Shade(s paint.Shading) {
+ if len(s.Stops) < 2 {
+ return
+ }
+ stops := make([]pdfkit.Stop, len(s.Stops))
+ for i, st := range s.Stops {
+ stops[i] = pdfkit.Stop{Offset: st.Pos, Color: rgb(st.Color)}
+ }
+ sh := pdfkit.Shading{Stops: stops, ExtendStart: s.ExtendStart, ExtendEnd: s.ExtendEnd}
+ c.withAlpha(s.Stops[0].Color.A, func() {
+ if s.Radial {
+ if s.RX <= 0 || s.RY <= 0 || s.R1 <= 0 {
+ return
+ }
+ c.p.Save()
+ defer c.p.Restore()
+ // Unit space: the ellipse of radii (RX,RY) is the unit circle.
+ c.p.Transform(s.RX*c.s, 0, 0, -s.RY*c.s, c.x(s.CX), c.y(s.CY))
+ sh.Radial, sh.R0, sh.R1 = true, s.R0, s.R1
+ } else {
+ if s.X0 == s.X1 && s.Y0 == s.Y1 {
+ return
+ }
+ sh.X0, sh.Y0, sh.X1, sh.Y1 = c.x(s.X0), c.y(s.Y0), c.x(s.X1), c.y(s.Y1)
+ }
+ if err := c.p.Shade(sh); err != nil {
+ c.fail(err)
+ }
+ })
+}
+
+// Image implements paint.Canvas. Pixel-identical images are embedded once per
+// document (pdfkit content-addresses them).
+func (c *Canvas) Image(img image.Image, dst paint.Rect) {
+ if img == nil || dst.W <= 0 || dst.H <= 0 || img.Bounds().Empty() {
+ return
+ }
+ c.p.DrawImage(img, pdfkit.Rect{X: c.x(dst.X), Y: c.y(dst.Y + dst.H), Width: dst.W * c.s, Height: dst.H * c.s})
+}
+
+// Glyphs implements paint.Canvas: the run's font, subset-embedded with a
+// /ToUnicode map, and its glyphs at the layout's positions.
+func (c *Canvas) Glyphs(run *paint.GlyphRun) {
+ if run == nil || len(run.Glyphs) == 0 || run.Size <= 0 {
+ return
+ }
+ f, err := c.fonts.get(run.Font)
+ if err != nil {
+ c.fail(err)
+ return
+ }
+ gs := make([]pdfkit.Glyph, len(run.Glyphs))
+ for i, g := range run.Glyphs {
+ gs[i] = pdfkit.Glyph{ID: g.ID, X: g.X * c.s, Text: g.Text}
+ }
+ show := func() {
+ c.p.SetFont(f, run.Size*c.s)
+ if err := c.p.ShowGlyphs(c.x(run.X), c.y(run.Y), gs); err != nil {
+ c.fail(err)
+ }
+ }
+ if run.Invisible {
+ // Text rendering mode 3, neither filled nor stroked (ISO 32000-1
+ // §9.3.6): text to find and copy over a bitmap of the same words.
+ c.p.SetRenderMode(pdfkit.RenderInvisible)
+ show()
+ c.p.SetRenderMode(pdfkit.RenderFill)
+ return
+ }
+ c.withAlpha(run.Color.A, func() {
+ c.p.SetFillColor(rgb(run.Color))
+ show()
+ })
+}
diff --git a/pdfcanvas/pdfcanvas_test.go b/pdfcanvas/pdfcanvas_test.go
new file mode 100644
index 0000000..01ab08d
--- /dev/null
+++ b/pdfcanvas/pdfcanvas_test.go
@@ -0,0 +1,194 @@
+// Copyright (c) the go-webengine/engine authors.
+// SPDX-License-Identifier: BSD-3-Clause
+
+package pdfcanvas
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "image"
+ "image/png"
+ "io"
+ "strings"
+ "testing"
+
+ "github.com/go-opentype/fonts/inter"
+ "github.com/go-opentype/opentype"
+ "github.com/go-pdfkit/pdfkit"
+ "rsc.io/pdf"
+
+ "github.com/go-webengine/engine"
+ "github.com/go-webengine/engine/css"
+ "github.com/go-webengine/engine/paint"
+)
+
+const page = `
faded words
+` + +// pngURI is a 2×2 PNG as a data: URI. +func pngURI(t *testing.T) string { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, 2, 2)) + for i := range img.Pix { + img.Pix[i] = 0xff + } + img.Pix[0] = 0 + 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()) +} + +// render lays page out and draws it into a one-page PDF, which it reads +// back with rsc.io/pdf, an independent reader. +func render(t *testing.T) (*pdf.Reader, string, *Canvas) { + t.Helper() + e := engine.New() + e.DisableJS = true + l, err := e.LayoutHTML(context.Background(), strings.Replace(page, "IMG", pngURI(t), 1), "", image.Rect(0, 0, 400, 300)) + if err != nil { + t.Fatal(err) + } + doc := pdfkit.New(pdfkit.Options{Compress: true}) + pg := doc.AddPage(pdfkit.NewPageSize(300, 225)) + c := New(pg, 0.75, nil) + st := l.PaintVector(c) + if c.Err != nil { + t.Fatal(c.Err) + } + if st.Rasterized != 1 { + t.Errorf("rasterised %d, want the faded paragraph", st.Rasterized) + } + var buf bytes.Buffer + if err := doc.Write(&buf); err != nil { + t.Fatal(err) + } + r, err := pdf.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len())) + if err != nil { + t.Fatal(err) + } + cs, err := io.ReadAll(r.Page(1).V.Key("Contents").Reader()) + if err != nil { + t.Fatal(err) + } + return r, string(cs), c +} + +func TestAPageIsVectorTextShadingsAndImages(t *testing.T) { + r, content, _ := render(t) + res := r.Page(1).V.Key("Resources") + font := res.Key("Font").Key("F0") + if font.Key("Subtype").Name() != "Type0" || font.Key("ToUnicode").Kind() == pdf.Null { + t.Errorf("font F0: want a Type0 font with a /ToUnicode map, got %v", font) + } + types := map[int64]bool{} + sh := res.Key("Shading") + for _, k := range sh.Keys() { + types[sh.Key(k).Key("ShadingType").Int64()] = true + } + if !types[2] || !types[3] { + t.Errorf("shading types %v, want axial (2) and radial (3)", types) + } + if len(res.Key("XObject").Keys()) < 2 { + t.Errorf("XObjects %v: want thesame
", "", image.Rect(0, 0, 200, 100)) + if err != nil { + t.Fatal(err) + } + doc := pdfkit.New(pdfkit.Options{}) + for range 2 { + l.PaintVector(New(doc.AddPage(pdfkit.NewPageSize(150, 75)), 0.75, fs)) + } + if len(fs.m) != 1 { + t.Errorf("%d fonts for one face drawn on two pages", len(fs.m)) + } +} diff --git a/vectorpage.go b/vectorpage.go new file mode 100644 index 0000000..adf0a9c --- /dev/null +++ b/vectorpage.go @@ -0,0 +1,102 @@ +// Copyright (c) the go-webengine/engine authors. +// SPDX-License-Identifier: BSD-3-Clause + +package engine + +import ( + "context" + "image" + + "github.com/go-webengine/engine/css" + "github.com/go-webengine/engine/dom" + "github.com/go-webengine/engine/paint" +) + +// PageLayout is a document laid out and ready to paint: the result of the +// cascade → JavaScript settle → layout pipeline Render runs, kept so that it +// can be painted somewhere other than an image. Painting it with Paint gives +// the very image RenderDocument returns; PaintVector draws the same boxes as +// vectors — a PDF page with real text, for one. +// +// Laying out and painting are split so that a caller can lay out several +// documents concurrently and paint them, in order, into one output that is +// not safe for concurrent use. A PageLayout is not itself safe for +// concurrent use: paint it from one goroutine at a time. +type PageLayout struct { + e *Engine + doc *Document + rp *renderPass + fonts *paint.Fonts + viewport image.Rectangle // CSS px, as asked + vpW int +} + +// LayoutHTML lays out an HTML string at the given viewport (CSS px), as +// RenderHTML does, without painting it. +func (e *Engine) LayoutHTML(ctx context.Context, htmlSrc, baseURL string, viewport image.Rectangle) (*PageLayout, error) { + root, err := dom.Parse(htmlSrc) + if err != nil { + return nil, err + } + return e.LayoutDocument(ctx, &Document{URL: baseURL, Title: dom.Title(root), Root: root, HTML: htmlSrc}, viewport), nil +} + +// LayoutDocument lays out an already-fetched Document, as RenderDocument +// does, without painting it. +func (e *Engine) LayoutDocument(ctx context.Context, doc *Document, viewport image.Rectangle) *PageLayout { + fonts := paint.NewFonts() + vpW, vpH := viewportSize(viewport) + rp := e.renderCore(ctx, doc, vpW, vpH, fonts) + return &PageLayout{e: e, doc: doc, rp: rp, fonts: fonts, viewport: viewport, vpW: vpW} +} + +// Info is the render information RenderDocument would report. +func (l *PageLayout) Info() *RenderInfo { return renderInfo(l.doc, l.rp) } + +// Links is the anchor hit-map of the layout, as RenderDocumentWithLinks +// returns it, in device pixels. +func (l *PageLayout) Links() []Link { return LinksFromBox(l.rp.box, l.doc.URL) } + +// Paint rasterises the layout: the image RenderDocument returns. +func (l *PageLayout) Paint() *image.RGBA { + img := l.e.newCanvas(l.doc, l.rp, l.viewport, l.vpW) + paint.PaintFull(img, l.rp.box, l.fonts, l.rp.imgs, l.rp.bgImgs) + return img +} + +// Page is the device-pixel rectangle PaintVector draws: the viewport, at +// Engine.DeviceScale. Unlike Paint's image, it does not grow with the +// content — a page has the size it was given. +func (l *PageLayout) Page() image.Rectangle { + return l.e.deviceViewport(image.Rect(0, 0, l.vpW, viewportHeight(l.viewport))) +} + +// viewportHeight is viewportSize's height. +func viewportHeight(viewport image.Rectangle) int { + _, h := viewportSize(viewport) + return h +} + +// PaintVector draws the layout through c (see paint.PaintVector): first the +// page backdrop Paint starts from — Engine.Backdrop or white, then the +// document's own html/body background — over the whole page, then the boxes. +// Coordinates are device pixels; what lies outside Page is the canvas's to +// clip. +func (l *PageLayout) PaintVector(c paint.Canvas) paint.VectorStats { + page := l.Page() + base := css.Color{R: 255, G: 255, B: 255, A: 255} + if l.e.Backdrop.A > 0 { + base = l.e.Backdrop + base.A = 255 + } + if bg, ok := pageBackground(l.doc.Root, l.rp.sm); ok { + // fillColor paints the page colour opaque over the base, whatever + // its own alpha. + base = bg + base.A = 255 + } + var p paint.Path + p.RoundRect(paint.Rect{W: float64(page.Dx()), H: float64(page.Dy())}, 0) + c.Fill(&p, base, false) + return paint.PaintVector(c, l.rp.box, l.fonts, l.rp.imgs, l.rp.bgImgs, page) +} diff --git a/vectorpage_test.go b/vectorpage_test.go new file mode 100644 index 0000000..e26844b --- /dev/null +++ b/vectorpage_test.go @@ -0,0 +1,115 @@ +// Copyright (c) the go-webengine/engine authors. +// SPDX-License-Identifier: BSD-3-Clause + +package engine + +import ( + "bytes" + "context" + "image" + "strings" + "testing" + + "github.com/go-webengine/engine/css" + "github.com/go-webengine/engine/paint" +) + +// recCanvas records what PaintVector draws. +type recCanvas struct { + fills []css.Color + text strings.Builder +} + +func (c *recCanvas) Save() {} +func (c *recCanvas) Restore() {} +func (c *recCanvas) Clip(*paint.Path, bool) {} +func (c *recCanvas) Fill(_ *paint.Path, col css.Color, _ bool) { c.fills = append(c.fills, col) } +func (c *recCanvas) Shade(paint.Shading) {} +func (c *recCanvas) Image(image.Image, paint.Rect) {} +func (c *recCanvas) Glyphs(r *paint.GlyphRun) { + for _, g := range r.Glyphs { + c.text.WriteString(g.Text) + } +} + +const vectorPageHTML = `Hello vector
` + +// TestPageLayoutPaintIsRenderHTML: painting a layout to an image gives the +// very image RenderHTML returns; the layout's links and info are the ones +// RenderDocumentWithLinks reports. +func TestPageLayoutPaintIsRenderHTML(t *testing.T) { + e := New() + e.DisableJS = true + view := image.Rect(0, 0, 320, 200) + want, info, err := e.RenderHTML(context.Background(), vectorPageHTML, "https://example.com/", view) + if err != nil { + t.Fatal(err) + } + l, err := e.LayoutHTML(context.Background(), vectorPageHTML, "https://example.com/", view) + if err != nil { + t.Fatal(err) + } + if got := l.Paint(); !bytes.Equal(got.Pix, want.Pix) || got.Rect != want.Rect { + t.Error("PageLayout.Paint differs from RenderHTML") + } + if got := l.Info(); got.ContentHeight != info.ContentHeight || got.URL != info.URL { + t.Errorf("Info = %+v, want %+v", got, info) + } + if ls := l.Links(); len(ls) != 1 || ls[0].Href != "https://example.com/x" { + t.Errorf("Links = %+v", ls) + } + if p := l.Page(); p != view { + t.Errorf("Page = %v, want the viewport %v", p, view) + } + if _, err := e.LayoutHTML(context.Background(), "\x00<", "", view); err != nil { + t.Logf("a hostile source is laid out or refused, never a panic: %v", err) + } +} + +// TestPageLayoutPaintVector: the page backdrop first — the body's +// background, opaque — then the boxes, the text as glyphs. +func TestPageLayoutPaintVector(t *testing.T) { + e := New() + e.DisableJS = true + e.DeviceScale = 2 + l, err := e.LayoutHTML(context.Background(), vectorPageHTML, "", image.Rect(0, 0, 320, 200)) + if err != nil { + t.Fatal(err) + } + if p := l.Page(); p != image.Rect(0, 0, 640, 400) { + t.Errorf("Page at device scale 2 = %v", p) + } + var c recCanvas + st := l.PaintVector(&c) + if len(c.fills) == 0 || c.fills[0] != (css.Color{R: 0x10, G: 0x20, B: 0x30, A: 255}) { + t.Errorf("first fill = %v, want the body background", c.fills) + } + // Words are the layout's items, the spaces between them gaps: a PDF + // reader puts them back from the gaps, as pdftotext does. + if got := c.text.String(); got != "Hellovector" { + t.Errorf("text = %q", got) + } + if st.Rasterized != 0 || st.Glyphs == 0 { + t.Errorf("stats = %+v", st) + } + // No page background: the engine's backdrop, else white. + e.Backdrop = css.Color{R: 1, G: 2, B: 3, A: 9} + l, _ = e.LayoutHTML(context.Background(), "x
", "", image.Rect(0, 0, 0, 0)) + c = recCanvas{} + l.PaintVector(&c) + if c.fills[0] != (css.Color{R: 1, G: 2, B: 3, A: 255}) { + t.Errorf("backdrop fill = %v", c.fills[0]) + } + e.Backdrop = css.Color{} + l, _ = e.LayoutHTML(context.Background(), "x
", "", image.Rect(0, 0, 0, 0)) + c = recCanvas{} + l.PaintVector(&c) + if c.fills[0] != (css.Color{R: 255, G: 255, B: 255, A: 255}) { + t.Errorf("default fill = %v", c.fills[0]) + } + if l.Page() != image.Rect(0, 0, 2048, 1536) { + t.Errorf("default viewport page = %v", l.Page()) + } +}