From 299a2c459f207d851f9475708286e1c98e2d1ff1 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 19 Oct 2026 10:56:08 +0200 Subject: [PATCH] Paint a layout as vectors: paint.PaintVector, PageLayout, pdfcanvas paint.Canvas is a small vector surface (clip, fill, shade, image, glyph runs); paint.PaintVector walks the box tree in paintBoxContent's order and geometry and draws through it: backgrounds, borders and sharp shadows as paths, linear and radial gradients as shadings split into runs of constant opacity, text as positioned glyphs of the faces the layout measured with, images placed in their boxes. What has no vector form (opacity < 1, filter, mask-image, rotation, blurred shadows, fading gradients, form controls) is painted by the raster painter as a bitmap of that element only, its text laid over it invisibly so that it stays selectable. Engine.LayoutHTML/LayoutDocument return a PageLayout that can be painted as an image (identical to RenderHTML) or as vectors, so pages can be laid out concurrently and painted in order. pdfcanvas is the Canvas for a go-pdfkit page (v0.15.0: ShowGlyphs, NewFont, radial shadings). css.GradientSampler.Geometry exposes the resolved axis/ellipse and stops. The raster path is untouched. paint keeps 100 % coverage; the vector tests compare a reference rasterisation of the Canvas calls with PaintFull. Co-Authored-By: Claude Opus 5.5 --- README.md | 37 +- css/gradient_geometry_test.go | 40 ++ css/gradient_sample.go | 39 ++ go.mod | 36 +- go.sum | 80 +++- paint/canvas.go | 193 +++++++++ paint/vector.go | 763 ++++++++++++++++++++++++++++++++++ paint/vector_test.go | 655 +++++++++++++++++++++++++++++ pdfcanvas/pdfcanvas.go | 241 +++++++++++ pdfcanvas/pdfcanvas_test.go | 194 +++++++++ vectorpage.go | 102 +++++ vectorpage_test.go | 115 +++++ 12 files changed, 2459 insertions(+), 36 deletions(-) create mode 100644 css/gradient_geometry_test.go create mode 100644 paint/canvas.go create mode 100644 paint/vector.go create mode 100644 paint/vector_test.go create mode 100644 pdfcanvas/pdfcanvas.go create mode 100644 pdfcanvas/pdfcanvas_test.go create mode 100644 vectorpage.go create mode 100644 vectorpage_test.go 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()) + } +}