From a2594db80812447e01b0680e75573c555bb2d036 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sun, 18 Oct 2026 16:03:37 +0200 Subject: [PATCH] perf: DrawImage copies an *image.RGBA's samples instead of calling At DrawImage read every pixel through image.Image's At, which boxes each color in an interface: 921,600 allocations for one 1280x720 page. An *image.RGBA already stores the alpha-premultiplied 8-bit samples At's RGBA method returns, so its rows are now copied directly; any other image keeps the generic path. The output is byte-identical (a test compares the two paths on a transparent sub-image with a non-zero origin, an opaque image and an empty one). BenchmarkDrawImage, one 1280x720 page written to io.Discard, Apple M4 Max: generic 72.8 ms/op 11.1 MB/op 921,772 allocs/op rgba 29.4 ms/op 7.4 MB/op 171 allocs/op Found by go-slides' benchmark against marp-cli, where writing the PDF of a 100-slide deck spent most of its time here. Co-Authored-By: Claude Opus 5.5 --- image.go | 58 +++++++++++++++++++++++++++++--------- samples_test.go | 75 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 13 deletions(-) create mode 100644 samples_test.go diff --git a/image.go b/image.go index de8c7b6..2f8588e 100644 --- a/image.go +++ b/image.go @@ -52,19 +52,7 @@ func imageKey(filter, colorSpace string, w, h, bpc int, samples, alpha []byte) s func (p *Page) DrawImage(img image.Image, r Rect) { b := img.Bounds() w, h := b.Dx(), b.Dy() - rgb := make([]byte, 0, w*h*3) - alpha := make([]byte, 0, w*h) - hasAlpha := false - for y := b.Min.Y; y < b.Max.Y; y++ { - for x := b.Min.X; x < b.Max.X; x++ { - cr, cg, cb, ca := img.At(x, y).RGBA() - rgb = append(rgb, byte(cr>>8), byte(cg>>8), byte(cb>>8)) - alpha = append(alpha, byte(ca>>8)) - if ca != 0xffff { - hasAlpha = true - } - } - } + rgb, alpha, hasAlpha := samples(img) if !hasAlpha { alpha = nil // a fully opaque image gets no soft mask, and keys as such } @@ -93,6 +81,50 @@ func (p *Page) DrawImage(img image.Image, r Rect) { p.placeImage(x, r) } +// samples splits an image into 8-bit RGB samples and alpha samples, the +// colour channels as image.Image's RGBA method gives them (alpha- +// premultiplied), and reports whether any pixel is not fully opaque. +// +// An *image.RGBA already stores exactly those bytes, so it is copied row by +// row; any other image goes through At, which boxes every pixel in an +// interface (a 1280×720 page: 921,600 allocations). +func samples(img image.Image) (rgb, alpha []byte, hasAlpha bool) { + b := img.Bounds() + w, h := b.Dx(), b.Dy() + rgb = make([]byte, w*h*3) + alpha = make([]byte, w*h) + if m, ok := img.(*image.RGBA); ok { + o, a := 0, 0 + for y := b.Min.Y; y < b.Max.Y; y++ { + row := m.Pix[m.PixOffset(b.Min.X, y):m.PixOffset(b.Max.X, y)] + for i := 0; i < len(row); i += 4 { + rgb[o], rgb[o+1], rgb[o+2] = row[i], row[i+1], row[i+2] + alpha[a] = row[i+3] + if row[i+3] != 0xff { + hasAlpha = true + } + o += 3 + a++ + } + } + return rgb, alpha, hasAlpha + } + o, a := 0, 0 + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + cr, cg, cb, ca := img.At(x, y).RGBA() + rgb[o], rgb[o+1], rgb[o+2] = byte(cr>>8), byte(cg>>8), byte(cb>>8) + alpha[a] = byte(ca >> 8) + if ca != 0xffff { + hasAlpha = true + } + o += 3 + a++ + } + } + return rgb, alpha, hasAlpha +} + // DrawPNG decodes PNG bytes and embeds the image into r. It returns an error if // the data is not a valid PNG. func (p *Page) DrawPNG(data []byte, r Rect) error { diff --git a/samples_test.go b/samples_test.go new file mode 100644 index 0000000..cad1eec --- /dev/null +++ b/samples_test.go @@ -0,0 +1,75 @@ +// Copyright (c) 2026 the go-pdfkit/pdfkit authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package pdfkit + +import ( + "bytes" + "image" + "image/color" + "io" + "math/rand/v2" + "testing" +) + +// opaqueImage hides an image's concrete type, so samples takes the generic +// path through At. +type opaqueImage struct{ image.Image } + +// TestSamplesRGBAFastPath checks that the *image.RGBA path gives the bytes +// the generic path gives, on a sub-image (non-zero origin, stride wider than +// the bounds) with and without transparency. +func TestSamplesRGBAFastPath(t *testing.T) { + rng := rand.New(rand.NewPCG(1, 2)) + full := image.NewRGBA(image.Rect(-3, -2, 61, 47)) + for i := range full.Pix { + full.Pix[i] = byte(rng.UintN(256)) + } + // Premultiplied: no colour channel above alpha. + for i := 0; i < len(full.Pix); i += 4 { + a := full.Pix[i+3] + for c := range 3 { + full.Pix[i+c] = min(full.Pix[i+c], a) + } + } + opaque := image.NewRGBA(image.Rect(0, 0, 17, 9)) + for i := 0; i < len(opaque.Pix); i += 4 { + opaque.Pix[i], opaque.Pix[i+1], opaque.Pix[i+2], opaque.Pix[i+3] = byte(i), byte(i/3), byte(i/7), 0xff + } + for name, img := range map[string]*image.RGBA{ + "transparent": full.SubImage(image.Rect(5, 4, 40, 30)).(*image.RGBA), + "opaque": opaque, + "empty": image.NewRGBA(image.Rect(0, 0, 0, 0)), + } { + rgb, alpha, has := samples(img) + wrgb, walpha, whas := samples(opaqueImage{img}) + if !bytes.Equal(rgb, wrgb) || !bytes.Equal(alpha, walpha) || has != whas { + t.Errorf("%s: the fast path differs from At (alpha %v, want %v)", name, has, whas) + } + } +} + +func BenchmarkDrawImage(b *testing.B) { + img := image.NewRGBA(image.Rect(0, 0, 1280, 720)) + for y := range 720 { + for x := range 1280 { + img.SetRGBA(x, y, color.RGBA{uint8(x), uint8(y), uint8(x + y), 0xff}) + } + } + for name, m := range map[string]image.Image{"rgba": img, "generic": opaqueImage{img}} { + b.Run(name, func(b *testing.B) { + b.ReportAllocs() + for i := 0; b.Loop(); i++ { + doc := New(Options{}) + // A different pixel each time, so the document-wide image + // cache does not answer. + img.Pix[0] = byte(i) + doc.AddPage(NewPageSize(960, 540)).DrawImage(m, Rect{Width: 960, Height: 540}) + if err := doc.Write(io.Discard); err != nil { + b.Fatal(err) + } + } + }) + } +}