From cbf230f395a89cc02a1e7d69f29beb0e00c0e2ce Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:01:07 +0200 Subject: [PATCH] CMYK destinations: draw into a pair of images, blend subtractively MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Canvas.ResetPair draws into two images at once: everything is rasterized once and composited into both, each image with its paint (Paint.Second, nil for the same). Two images of premultiplied ink amounts, (C, M, Y, A) and (K, K, K, A), make a CMYK destination with alpha; compositing treats the channels alike, and gradients, meshes and images interpolate each channel on its own, so one shader per image paints CMYK. The fast paths that write the destination directly take the second image along (the glyph mask blit) or step aside for a pair (the analytic rows of opaque strokes); a paint is invisible or opaque only when it is in both images. LayerShader.Subtractive blends layers of ink amounts on their complements (PDF 2.0, 11.3.5), α − c in premultiplied colours; the non-separable modes then blend (C, M, Y) as (R, G, B). LayerShader.Black makes the shader that of the K image: Hue, Saturation and Color take K from the backdrop, Luminosity from the source. Tests: a scene of fills, strokes (analytic rows among them), dashes, rectangles, shaders, clips and cached glyphs drawn as a pair gives the bytes of each image drawn alone; every blend mode, subtractive and for the K image, within a level of the formula in floating point. For timzifer/cera#209. Closes #39. --- README.md | 9 ++ blend.go | 77 ++++++++++++++++-- canvas.go | 94 +++++++++++++++++++-- cmyk_test.go | 221 ++++++++++++++++++++++++++++++++++++++++++++++++++ glyphcache.go | 27 ++++-- shape.go | 2 +- union.go | 2 +- 7 files changed, 405 insertions(+), 27 deletions(-) create mode 100644 cmyk_test.go diff --git a/README.md b/README.md index 49e764e..796bb02 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,15 @@ gc.FillGlyph(c, fontID, glyphID, outline, m, paint) `Canvas.ClipStroke` clips to the area a stroke paints, for strokes and stroked text painted with a shader. +**CMYK.** `Canvas.ResetPair(cmy, k, region)` draws into two images at +once, rasterizing once: (C, M, Y, A) and (K, K, K, A), premultiplied ink +amounts, make a CMYK destination with alpha; `Paint.Second` is what goes +into the second. Compositing treats channels alike and shaders interpolate +each on its own, so one shader per image paints CMYK. `LayerShader` blends +such layers with `Subtractive` (on the complements of the inks, PDF 2.0, +11.3.5) and, for the K image, `Black` (K of the backdrop for Hue, +Saturation and Color, of the source for Luminosity). + ## How it is fast - **Band accumulation**: edges are accumulated as area/cover into a diff --git a/blend.go b/blend.go index fa6b73d..6ff9735 100644 --- a/blend.go +++ b/blend.go @@ -82,6 +82,34 @@ type LayerShader struct { // and Alone the same group drawn onto a transparent image, of which // only the alpha is read. Both cover Src's rectangle. Initial, Alone *image.RGBA + + // Subtractive says that Src and Dst hold amounts of ink, 0 for none, + // as the images of a CMYK canvas do (Canvas.ResetPair): blend modes + // other than Normal blend the complements, 1 − c, and the result is + // complemented again (PDF 2.0, 11.3.5). In premultiplied colours the + // complement of c is α − c. The non-separable modes then blend + // (C, M, Y) as they blend (R, G, B). + Subtractive bool + // Black says that the layer is the black image of such a pair, + // (K, K, K, A): the non-separable modes take K from the backdrop + // (Hue, Saturation, Color) or from the source (Luminosity), as PDF + // 2.0, 11.3.5.3 has it; the separable ones blend K on its own. + Black bool +} + +// blendBackdrop is the blend function B(cb, cs) = cb, what the black +// image of a CMYK pair blends with in Hue, Saturation and Color. +const blendBackdrop BlendMode = 255 + +// mode returns the blend mode the shader blends with. +func (c *LayerShader) mode() BlendMode { + if c.Black && c.Blend >= BlendHue { + if c.Blend == BlendLuminosity { + return BlendNormal + } + return blendBackdrop + } + return c.Blend } // ShadeSpan implements Shader. @@ -97,7 +125,8 @@ func (c *LayerShader) ShadeSpan(y, x int, out []uint32) { c.removeBackdrop(y, x, sp, out) sp = unsafe.Slice((*byte)(unsafe.Pointer(&out[0])), 4*n) } - if c.Blend == BlendNormal && c.Mask == nil { + bm := c.mode() + if bm == BlendNormal && c.Mask == nil { // The layer's pixels are in the layout of out already. src := unsafe.Slice((*uint32)(unsafe.Pointer(&sp[0])), n) if c.Alpha == 255 { @@ -111,7 +140,7 @@ func (c *LayerShader) ShadeSpan(y, x int, out []uint32) { return } var dp []byte - if c.Blend != BlendNormal { + if bm != BlendNormal { dp = c.Dst.Pix[c.Dst.PixOffset(x, y):][: 4*n : 4*n] } var mrow []uint8 @@ -123,7 +152,7 @@ func (c *LayerShader) ShadeSpan(y, x int, out []uint32) { } mx = mr.Min.X } - if c.Blend == BlendNormal { + if bm == BlendNormal { // Masked Normal: scale the packed pixels by the combined alpha. src := unsafe.Slice((*uint32)(unsafe.Pointer(&sp[0])), n) k0 := uint32(c.Alpha) @@ -140,15 +169,15 @@ func (c *LayerShader) ShadeSpan(y, x int, out []uint32) { } return } - if c.Blend == BlendMultiply || c.Blend == BlendScreen { + if (bm == BlendMultiply || bm == BlendScreen) && !c.Subtractive { c.shadeSeparable(x, out, sp, dp, mrow, mx) return } // The separable modes look opaque pixels up in a table, taken once // per span. var tab *[1 << 16]uint8 - if c.Blend < BlendHue { - tab = blendTable(c.Blend) + if bm < BlendHue { + tab = blendTable(bm) } for i := range out { p := sp[4*i : 4*i+4 : 4*i+4] @@ -170,10 +199,24 @@ func (c *LayerShader) ShadeSpan(y, x int, out []uint32) { } if dp != nil && dp[4*i+3] != 0 { q := dp[4*i : 4*i+4 : 4*i+4] - if a == 255 && q[3] == 255 && tab != nil { - r, g, b = tab[int(q[0])<<8|int(r)], tab[int(q[1])<<8|int(g)], tab[int(q[2])<<8|int(b)] + qr, qg, qb, qa := q[0], q[1], q[2], q[3] + switch { + case bm == blendBackdrop: + // s·(1−αb) + αs·αb·cb = s·(1−αb) + αs·d. + r, g, b = backdropInt(r, a, qr, qa), backdropInt(g, a, qg, qa), backdropInt(b, a, qb, qa) + out[i] = pack(r, g, b, a) + continue + case c.Subtractive: + r, g, b = sub8(a, r), sub8(a, g), sub8(a, b) + qr, qg, qb = sub8(qa, qr), sub8(qa, qg), sub8(qa, qb) + } + if a == 255 && qa == 255 && tab != nil { + r, g, b = tab[int(qr)<<8|int(r)], tab[int(qg)<<8|int(g)], tab[int(qb)<<8|int(b)] } else { - r, g, b = blendPixel(c.Blend, r, g, b, a, q[0], q[1], q[2], q[3]) + r, g, b = blendPixel(bm, r, g, b, a, qr, qg, qb, qa) + } + if c.Subtractive { + r, g, b = sub8(a, r), sub8(a, g), sub8(a, b) } } out[i] = pack(r, g, b, a) @@ -287,6 +330,22 @@ func (c *LayerShader) shadeSeparable(x int, out []uint32, sp, dp, mrow []uint8, } } +// sub8 returns a − c, the complement of a premultiplied colour channel c +// of alpha a, 0 where c exceeds a. +func sub8(a, c uint8) uint8 { + if c > a { + return 0 + } + return a - c +} + +// backdropInt is one channel of s·(1−αb) + αs·d, the colour a source s +// of alpha sa takes the backdrop d of alpha da with. +func backdropInt(s, sa, d, da uint8) uint8 { + v := div255(uint32(s)*(255-uint32(da)) + uint32(sa)*uint32(d)) + return uint8(min(v, uint32(sa))) +} + // overAlpha is negative if a colour channel exceeds alpha. It does not // branch: on varied colours, the branches of max(r, g, b) > a mispredict. func overAlpha(r, g, b, a uint8) int32 { diff --git a/canvas.go b/canvas.go index a57e27b..495ab28 100644 --- a/canvas.go +++ b/canvas.go @@ -13,8 +13,26 @@ import ( type Paint struct { Color color.RGBA // premultiplied Shader Shader + // Second is what is painted into the second image of a canvas that + // draws into two (Canvas.ResetPair); nil paints Color or Shader there + // too. + Second *Paint } +// second returns what p paints into a second image. +func (p *Paint) second() *Paint { + if p.Second != nil { + return p.Second + } + return p +} + +// invisible reports that p paints nothing. +func (p *Paint) invisible() bool { return p.Shader == nil && p.Color.A == 0 } + +// opaque reports that p is an opaque solid colour. +func (p *Paint) opaque() bool { return p.Shader == nil && p.Color.A == 255 } + // Errors reported by Canvas.Err. var ( ErrEdgeBudget = errors.New("stilus: edge budget exceeded, path truncated") @@ -35,6 +53,13 @@ const DefaultMaxClipDepth = 256 // band is rendered by passing an image whose Rect is that tile, or by // restricting Reset's region. A Canvas is not safe for concurrent use; use // one per worker. After warm-up a Canvas does not allocate. +// +// A canvas reset with ResetPair draws into two images at once: what it +// paints is rasterized once and composited into both, each with its paint +// (Paint.Second). Two images of premultiplied ink amounts, (C, M, Y, A) and +// (K, K, K, A), make a CMYK destination with alpha: compositing treats +// the channels alike, and gradients and images interpolate each on its +// own, so that a shader per image paints a CMYK one. type Canvas struct { // MaxClipDepth overrides DefaultMaxClipDepth when non-zero. MaxClipDepth int @@ -46,6 +71,14 @@ type Canvas struct { s Stroker solid SolidBlitter shader ShaderBlitter + + // second is the second image of ResetPair, nil for none, and what + // paints into it. + second *image.RGBA + solid2 SolidBlitter + shader2 ShaderBlitter + pair pairBlitter + mread maskBlitter frac fracBlitter scale scaleBlitter @@ -73,7 +106,7 @@ func NewCanvas(dst *image.RGBA) *Canvas { // Reset retargets the canvas to dst, limited to region (in dst's // coordinates), clears the clip stack and the error. func (c *Canvas) Reset(dst *image.RGBA, region image.Rectangle) { - c.dst = dst + c.dst, c.second = dst, nil c.solid.t.set(dst) c.shader.t.set(dst) c.stack = append(c.stack[:0], newClipState(region.Intersect(dst.Bounds()))) @@ -81,6 +114,39 @@ func (c *Canvas) Reset(dst *image.RGBA, region image.Rectangle) { c.err = nil } +// ResetPair is Reset with a second image that everything is drawn into +// as well, with the second paint of each Paint; region is limited to both +// images. +func (c *Canvas) ResetPair(dst, second *image.RGBA, region image.Rectangle) { + c.Reset(dst, region.Intersect(second.Bounds())) + c.second = second + c.solid2.t.set(second) + c.shader2.t.set(second) +} + +// pairBlitter blits the same coverage through two blitters. +type pairBlitter struct{ a, b Blitter } + +func (p *pairBlitter) BlitRun(y, x0, x1 int, alpha uint8) { + p.a.BlitRun(y, x0, x1, alpha) + p.b.BlitRun(y, x0, x1, alpha) +} + +func (p *pairBlitter) BlitCoverage(y, x int, cov []uint8) { + p.a.BlitCoverage(y, x, cov) + p.b.BlitCoverage(y, x, cov) +} + +// invisible reports that p paints nothing into the canvas's images. +func (c *Canvas) invisible(p *Paint) bool { + return p.invisible() && (c.second == nil || p.second().invisible()) +} + +// opaque reports that p is an opaque solid colour in every image. +func (c *Canvas) opaque(p *Paint) bool { + return p.opaque() && (c.second == nil || p.second().opaque()) +} + // Err returns the first error since Reset, if any. Drawing continues after // an error; the error only reports that output may be incomplete. func (c *Canvas) Err() error { return c.err } @@ -136,12 +202,22 @@ func (c *Canvas) chain(st *clipState, b Blitter) Blitter { } func (c *Canvas) paint(p *Paint) Blitter { + b := paintWith(p, &c.solid, &c.shader) + if c.second == nil { + return b + } + c.pair.a, c.pair.b = b, paintWith(p.second(), &c.solid2, &c.shader2) + return &c.pair +} + +// paintWith returns the blitter of p, of the two given. +func paintWith(p *Paint, solid *SolidBlitter, shader *ShaderBlitter) Blitter { if p.Shader != nil { - c.shader.setShader(p.Shader) - return &c.shader + shader.setShader(p.Shader) + return shader } - c.solid.SetColor(p.Color) - return &c.solid + solid.SetColor(p.Color) + return solid } func (c *Canvas) setClip(b image.Rectangle) { @@ -160,7 +236,7 @@ func (c *Canvas) checkBudget() { func (c *Canvas) Fill(p *Path, m Matrix, rule FillRule, paint *Paint) { defer c.guard() st := c.top() - if st.bounds.Empty() || (paint.Shader == nil && paint.Color.A == 0) { + if st.bounds.Empty() || c.invisible(paint) { return } c.setClip(st.bounds) @@ -201,7 +277,7 @@ func (c *Canvas) fillRect(p *Path, m Matrix, st *clipState, paint *Paint) bool { func (c *Canvas) Stroke(p *Path, m Matrix, st *StrokeStyle, paint *Paint) { defer c.guard() cs := c.top() - if cs.bounds.Empty() || (paint.Shader == nil && paint.Color.A == 0) || len(p.Points) == 0 { + if cs.bounds.Empty() || c.invisible(paint) || len(p.Points) == 0 { return } pr := prepStroke(m, st) @@ -266,7 +342,7 @@ func (c *Canvas) strokePaint(cs *clipState, paint *Paint, pr strokePrep) (Blitte // sets c.seg up for it: b is the stroke's blitter chain, overlap and single // are mayOverlap and singleSegment of its path and style. func (c *Canvas) strokeFast(cs *clipState, b Blitter, paint *Paint, pr strokePrep, overlap, single bool) bool { - opaque := paint.Shader == nil && paint.Color.A == 255 + opaque := c.opaque(paint) if pr.dense || !(opaque && (cs.mask == nil || !overlap) || single) { return false } @@ -282,7 +358,7 @@ func (c *Canvas) strokeFast(cs *clipState, b Blitter, paint *Paint, pr strokePre c.seg.r, c.seg.b = &c.r, b c.seg.solid = nil c.seg.frac = cs.frac - if cs.mask == nil && opaque { + if cs.mask == nil && opaque && c.second == nil { c.seg.solid = &c.solid } c.seg.setBorder(cs, overlap) diff --git a/cmyk_test.go b/cmyk_test.go new file mode 100644 index 0000000..484b61a --- /dev/null +++ b/cmyk_test.go @@ -0,0 +1,221 @@ +package stilus + +import ( + "bytes" + "image" + "image/color" + "math" + "math/rand/v2" + "testing" +) + +// rampShader paints a premultiplied colour that changes with x and y, its +// channels scaled by k so that two of them differ. +type rampShader struct{ k uint32 } + +func (s rampShader) ShadeSpan(y, x int, dst []uint32) { + for i := range dst { + a := uint32(128 + (x+i+y)%128) + v := (uint32(x+i) * s.k) % (a + 1) + dst[i] = PackRGBA(color.RGBA{uint8(v), uint8(v / 2), uint8(a - v/3), uint8(a)}) + } +} + +// pairScene draws the same scene with the given canvas and a source of +// paints: fills of curves, opaque and translucent strokes (the analytic +// path among them), a dashed stroke, a pixel-aligned rectangle, shader +// fills, glyphs from the cache (blitted and through the mask shader), all +// under a fractional clip rectangle and a clip path. +func pairScene(c *Canvas, gc *GlyphCache, paint func(i int) *Paint) { + var p Path + p.MoveTo(10, 10) + p.CubicTo(60, 0, 90, 80, 30, 70) + p.Close() + var clip Path + clip.MoveTo(5, 5) + clip.LineTo(115, 12) + clip.LineTo(100, 95) + clip.LineTo(8, 88) + clip.Close() + c.ClipRect(Rect{X0: 2.5, Y0: 3.25, X1: 117.75, Y1: 96.5}, Identity) + c.ClipPath(&clip, Identity, NonZero) + c.Fill(&p, Identity, NonZero, paint(0)) + var line Path + line.MoveTo(5, 50) + line.LineTo(115, 60) + c.Stroke(&line, Identity, &StrokeStyle{Width: 6, MiterLimit: 10}, paint(1)) + c.Stroke(&line, Matrix{1, 0, 0, 1, 0, 15}, &StrokeStyle{Width: 3, MiterLimit: 10}, paint(2)) + c.Stroke(&p, Identity, &StrokeStyle{Width: 2, MiterLimit: 10, Dash: []float64{4, 3}}, paint(3)) + var r Path + r.Rect(70, 20, 30, 20) + c.Fill(&r, Identity, NonZero, paint(4)) + c.Fill(&r, Matrix{1, 0, 0, 1, -50, 40}, NonZero, paint(5)) + var g Path + g.MoveTo(0, 0) + g.LineTo(0.6, 0) + g.LineTo(0.3, -0.7) + g.Close() + for i := range 6 { + gc.FillGlyph(c, 1, 7, &g, Matrix{14, 0, 0, 14, float64(15 + 16*i), 90}, paint(i%3)) + } + c.PopClip() + c.PopClip() + // Without a clip: the opaque stroke's analytic rows and the glyph + // mask's direct blit. + c.Stroke(&line, Matrix{1, 0, 0, 1, 0, -30}, &StrokeStyle{Width: 5, MiterLimit: 10}, paint(1)) + c.Stroke(&line, Matrix{1, 0, 0, 1, 0, -40}, &StrokeStyle{Width: 4, MiterLimit: 10}, paint(0)) + for i := range 6 { + gc.FillGlyph(c, 1, 7, &g, Matrix{14, 0, 0, 14, float64(10 + 18*i), 30}, paint([3]int{0, 4, 2}[i%3])) + } +} + +// TestCanvasPair draws into two images at once, each with its paint, and +// gets the bytes of drawing them one by one. +func TestCanvasPair(t *testing.T) { + rect := image.Rect(0, 0, 120, 100) + first := []*Paint{ + {Color: color.RGBA{200, 10, 30, 255}}, {Color: color.RGBA{0, 90, 0, 255}}, {Color: color.RGBA{40, 40, 0, 128}}, + {Color: color.RGBA{0, 0, 255, 255}}, {Color: color.RGBA{9, 8, 7, 255}}, {Shader: rampShader{3}}, + } + second := []*Paint{ + {Color: color.RGBA{60, 60, 60, 255}}, {Shader: rampShader{5}}, {Color: color.RGBA{20, 20, 20, 128}}, + {Color: color.RGBA{255, 255, 255, 255}}, {Color: color.RGBA{1, 1, 1, 255}}, {Color: color.RGBA{0, 0, 0, 0}}, + } + one := func(ps []*Paint) *image.RGBA { + img := image.NewRGBA(rect) + c := NewCanvas(img) + gc := GlyphCache{MaxBytes: 1 << 20} + pairScene(c, &gc, func(i int) *Paint { return ps[i] }) + return img + } + want1, want2 := one(first), one(second) + + a, b := image.NewRGBA(rect), image.NewRGBA(rect) + c := NewCanvas(a) + c.ResetPair(a, b, rect) + gc := GlyphCache{MaxBytes: 1 << 20} + pairScene(c, &gc, func(i int) *Paint { + p := *first[i] + p.Second = second[i] + return &p + }) + if !bytes.Equal(a.Pix, want1.Pix) { + t.Error("the first image is not what it is drawn alone") + } + if !bytes.Equal(b.Pix, want2.Pix) { + t.Error("the second image is not what it is drawn alone") + } + if c.Err() != nil { + t.Error(c.Err()) + } + + // Without a second paint the same is painted into both; after Reset + // the canvas draws into one image again. + a, b = image.NewRGBA(rect), image.NewRGBA(rect) + c.ResetPair(a, b, rect) + pairScene(c, &gc, func(i int) *Paint { return first[i] }) + if !bytes.Equal(a.Pix, want1.Pix) || !bytes.Equal(b.Pix, want1.Pix) { + t.Error("one paint for both images") + } + c.Reset(a, rect) + if c.second != nil { + t.Error("Reset keeps the second image") + } +} + +// randomPremul returns a premultiplied pixel, opaque one time in four. +func randomPremul(rng *rand.Rand) [4]uint8 { + a := uint8(rng.IntN(256)) + if rng.IntN(4) == 0 { + a = 255 + } + var p [4]uint8 + for i := range 3 { + p[i] = uint8(rng.IntN(int(a) + 1)) + } + p[3] = a + return p +} + +func layerImages(px [][4]uint8) *image.RGBA { + img := image.NewRGBA(image.Rect(0, 0, len(px), 1)) + for i, p := range px { + copy(img.Pix[4*i:], p[:]) + } + return img +} + +// TestLayerShaderSubtractive blends ink amounts on their complements: each +// mode within a level of B(cb, cs) = 1 − B(1 − cb, 1 − cs) computed in +// floating point, Normal as it is, and the black image of a pair with K of +// the backdrop (Hue, Saturation, Color) or the source (Luminosity). +func TestLayerShaderSubtractive(t *testing.T) { + rng := rand.New(rand.NewPCG(9, 10)) + const n = 400 + src, dst := make([][4]uint8, n), make([][4]uint8, n) + for i := range n { + src[i], dst[i] = randomPremul(rng), randomPremul(rng) + } + s, d := layerImages(src), layerImages(dst) + out := make([]uint32, n) + for bm := BlendNormal; bm <= BlendLuminosity; bm++ { + for _, black := range []bool{false, true} { + if black && bm < BlendHue { + continue + } + ls := LayerShader{Src: s, Dst: d, Alpha: 255, Blend: bm, Subtractive: true, Black: black} + ls.ShadeSpan(0, 0, out) + for i := range n { + got := UnpackRGBA(out[i]) + sp, dp := src[i], dst[i] + want := subtractiveRef(bm, black, sp, dp) + g := [3]uint8{got.R, got.G, got.B} + for k := range 3 { + if math.Abs(float64(g[k])-want[k]) > 1 || got.A != sp[3] { + t.Fatalf("%v black %v: pixel %d: %v over %v gives %v, want %v", bm, black, i, sp, dp, got, want) + } + } + } + } + } +} + +// subtractiveRef is the colour the shader returns, s·(1−αb) + αs·αb·B, of +// premultiplied ink amounts, in floating point. +func subtractiveRef(bm BlendMode, black bool, sp, dp [4]uint8) [3]float64 { + as, ab := float64(sp[3])/255, float64(dp[3])/255 + var cs, cb [3]float64 + for k := range 3 { + if sp[3] > 0 { + cs[k] = min(float64(sp[k])/float64(sp[3]), 1) + } + if dp[3] > 0 { + cb[k] = min(float64(dp[k])/float64(dp[3]), 1) + } + } + var bl [3]float64 + switch { + case bm == BlendNormal || black && bm == BlendLuminosity: + bl = cs + case black: + bl = cb + default: + var ics, icb [3]float64 + for k := range 3 { + ics[k], icb[k] = 1-cs[k], 1-cb[k] + } + r := Blend(bm, icb, ics) + for k := range 3 { + bl[k] = 1 - r[k] + } + } + var o [3]float64 + for k := range 3 { + if dp[3] == 0 { + o[k] = float64(sp[k]) + continue + } + o[k] = float64(sp[k])*(1-ab) + as*ab*bl[k]*255 + } + return o +} diff --git a/glyphcache.go b/glyphcache.go index 7128406..102027a 100644 --- a/glyphcache.go +++ b/glyphcache.go @@ -75,6 +75,10 @@ type GlyphCache struct { shader MaskShader rect Path paint Paint + // shader2 and paint2 paint the second image of a canvas that draws + // into two. + shader2 MaskShader + paint2 Paint } // FillGlyph fills outline (nonzero), transformed by m into device space, @@ -95,7 +99,7 @@ func (gc *GlyphCache) FillGlyph(c *Canvas, font uint64, glyph int32, outline *Pa return } ox, oy := math.Floor(m[4]), math.Floor(m[5]) - if paint.Shader != nil || !(sigmaMax(m) <= MaxCachedEm) || + if paint.Shader != nil || c.second != nil && paint.second().Shader != nil || !(sigmaMax(m) <= MaxCachedEm) || !(math.Abs(ox) < 1<<24 && math.Abs(oy) < 1<<24) { c.Fill(outline, m, NonZero, paint) return @@ -164,24 +168,33 @@ func (gc *GlyphCache) FillGlyph(c *Canvas, font uint64, glyph int32, outline *Pa const lim = 1 << 23 if st := c.top(); st.mask == nil && st.clearOfFrac(r.Intersect(clip)) && (c.r.MaxEdges == 0 || c.r.MaxEdges >= 2) && r.Min.X >= -lim && r.Min.Y >= -lim && r.Max.X <= lim && r.Max.Y <= lim { - c.blitMask(mask, origin, PackRGBA(paint.Color), r.Intersect(clip)) + c.blitMask(&c.solid.t, mask, origin, PackRGBA(paint.Color), r.Intersect(clip)) + if c.second != nil { + c.blitMask(&c.solid2.t, mask, origin, PackRGBA(paint.second().Color), r.Intersect(clip)) + } return } gc.shader = MaskShader{Mask: mask, X: origin.X, Y: origin.Y, Color: PackRGBA(paint.Color)} gc.paint.Shader = &gc.shader + gc.paint.Second = nil + if c.second != nil { + gc.shader2 = MaskShader{Mask: mask, X: origin.X, Y: origin.Y, Color: PackRGBA(paint.second().Color)} + gc.paint2.Shader = &gc.shader2 + gc.paint.Second = &gc.paint2 + } gc.rect.Reset() gc.rect.Rect(float32(r.Min.X), float32(r.Min.Y), float32(r.Dx()), float32(r.Dy())) c.Fill(&gc.rect, Identity, NonZero, &gc.paint) - gc.shader.Mask = nil + gc.shader.Mask, gc.shader2.Mask = nil, nil } // blitMask composites the premultiplied colour col through mask, moved by -// origin, over the pixels of r, which must lie within the clip bounds and -// the moved mask. It rounds as the MaskShader path does (the colour scaled +// origin, over the pixels of r of t, which must lie within the clip bounds +// and the moved mask. It rounds as the MaskShader path does (the colour scaled // by coverage, then composited over), so an opaque colour does not go // through covOpaque, whose single blend can round differently: only full // coverage, where both agree, stores the colour. -func (c *Canvas) blitMask(mask *image.Alpha, origin image.Point, col uint32, r image.Rectangle) { +func (c *Canvas) blitMask(t *target, mask *image.Alpha, origin image.Point, col uint32, r image.Rectangle) { defer c.guard() if col == 0 { return @@ -191,7 +204,7 @@ func (c *Canvas) blitMask(mask *image.Alpha, origin image.Point, col uint32, r i for y := r.Min.Y; y < r.Max.Y; y++ { o := mask.PixOffset(r.Min.X-origin.X, y-origin.Y) cov := mask.Pix[o : o+r.Dx()] - d := c.solid.t.row(y, r.Min.X, r.Max.X) + d := t.row(y, r.Min.X, r.Max.X) if !opaque { covOver(d, cov, cx) continue diff --git a/shape.go b/shape.go index 0b24a32..d6417d3 100644 --- a/shape.go +++ b/shape.go @@ -558,7 +558,7 @@ func (s *Shape) Bounds() image.Rectangle { return s.bounds } func (c *Canvas) FillShape(s *Shape, paint *Paint) { defer c.guard() cs := c.top() - if cs.bounds.Empty() || (paint.Shader == nil && paint.Color.A == 0) { + if cs.bounds.Empty() || c.invisible(paint) { return } switch s.kind { diff --git a/union.go b/union.go index 436268c..a65c571 100644 --- a/union.go +++ b/union.go @@ -191,7 +191,7 @@ func signedArea(p *Path) float64 { func (c *Canvas) FillUnion(u *Union, paint *Paint) { defer c.guard() cs := c.top() - if cs.bounds.Empty() || len(u.ops) == 0 || (paint.Shader == nil && paint.Color.A == 0) { + if cs.bounds.Empty() || len(u.ops) == 0 || c.invisible(paint) { return } c.setClip(cs.bounds)