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 = ` +

Vector héllo

+
+ +

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 the and the faded paragraph's bitmap", res.Key("XObject").Keys()) + } + for _, want := range []string{" TJ", " sh", " Do", "3 Tr", "W\nn\n"} { + if !strings.Contains(content, want) { + t.Errorf("content stream lacks %q", want) + } + } + // What copying gives: the /ToUnicode map has the characters drawn, + // é and the faded paragraph's invisible words included (rsc.io/pdf + // does not decode a Type0 font's text itself). + var cmap []byte + fonts := res.Key("Font") + for _, k := range fonts.Keys() { // the bold heading's face and the paragraph's + b, err := io.ReadAll(fonts.Key(k).Key("ToUnicode").Reader()) + if err != nil { + t.Fatal(err) + } + cmap = append(cmap, b...) + } + for _, u := range []string{"<0056>", "<00E9>", "<0066>", "<0077>"} { // V é f w + if !strings.Contains(string(cmap), u) { + t.Errorf("ToUnicode lacks %s:\n%s", u, cmap) + } + } +} + +// TestDegenerateCallsDrawNothing: what cannot be drawn is skipped, and what +// pdfkit refuses is reported once in Err. +func TestDegenerateCallsDrawNothing(t *testing.T) { + doc := pdfkit.New(pdfkit.Options{}) + pg := doc.AddPage(pdfkit.NewPageSize(100, 100)) + c := New(pg, 1, NewFonts()) + var empty paint.Path + c.Fill(&empty, css.Color{A: 255}, false) + c.Fill(nil, css.Color{A: 255}, false) + c.Image(nil, paint.Rect{W: 1, H: 1}) + c.Image(image.NewRGBA(image.Rect(0, 0, 1, 1)), paint.Rect{}) + c.Shade(paint.Shading{Stops: []paint.GradientStop{{Color: css.Color{A: 255}}}}) + two := []paint.GradientStop{{Pos: 0, Color: css.Color{A: 255}}, {Pos: 1, Color: css.Color{R: 255, A: 255}}} + c.Shade(paint.Shading{Stops: two, X0: 5, Y0: 5, X1: 5, Y1: 5}) + c.Shade(paint.Shading{Stops: two, Radial: true, RX: 0, RY: 4, R1: 1}) + c.Glyphs(nil) + c.Glyphs(&paint.GlyphRun{Size: 0, Glyphs: []paint.Glyph{{ID: 1}}}) + c.Fill(&empty, css.Color{}, false) + if c.Err != nil { + t.Errorf("Err = %v, want none for calls that draw nothing", c.Err) + } + // A shading whose stops do not increase is pdfkit's to refuse; the + // first refusal is kept. + c.Shade(paint.Shading{Stops: []paint.GradientStop{{Pos: .5, Color: css.Color{A: 255}}, {Pos: .5, Color: css.Color{A: 255}}}, X1: 10}) + first := c.Err + c.Glyphs(&paint.GlyphRun{Size: 10, Glyphs: []paint.Glyph{{ID: 1}}}) // no font + if first == nil || c.Err != first { + t.Errorf("Err = %v, want the first refusal kept (%v)", c.Err, first) + } + // An empty clip path clips everything; a translucent fill sets an alpha. + c.Clip(&empty, false) + var sq paint.Path + sq.RoundRect(paint.Rect{W: 10, H: 10}, 0) + c.Fill(&sq, css.Color{R: 255, A: 128}, true) + c.Fill(&sq, css.Color{}, false) // transparent: nothing + c.Clip(&sq, true) + // A glyph the font does not have is refused by pdfkit. + ot, err := opentype.Parse(inter.TTF) + if err != nil { + t.Fatal(err) + } + c2 := New(doc.AddPage(pdfkit.NewPageSize(100, 100)), 1, nil) + c2.Glyphs(&paint.GlyphRun{Font: ot, Size: 10, Color: css.Color{A: 255}, Glyphs: []paint.Glyph{{ID: 65535}}}) + if c2.Err == nil { + t.Error("a glyph id past the font: no error") + } +} + +func TestFontsAreSharedAcrossPages(t *testing.T) { + fs := NewFonts() + e := engine.New() + e.DisableJS = true + l, err := e.LayoutHTML(context.Background(), "

same

", "", 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()) + } +}