Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 45 additions & 13 deletions image.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
75 changes: 75 additions & 0 deletions samples_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
})
}
}
Loading