stb-image-go/stb_image.go¶
:material-github: View on GitHub · 194 lines · live source, included at build time
// Package stbimagego provides image loading (PNG, JPEG, GIF) with support for
// decoding batches of images concurrently. It mirrors the role of the
// stb_image C library (github.com/nothings/stb) using the Go image stdlib.
package stbimagego
import (
"bytes"
"context"
"errors"
"fmt"
"image"
_ "image/gif" // Register GIF format
_ "image/jpeg" // Register JPEG format
_ "image/png" // Register PNG format
"io"
"runtime"
"sync"
)
// ImageInfo contains metadata about an image without decoding the full image.
type ImageInfo struct {
Width int
Height int
Format string
}
// GetInfo returns image metadata without fully decoding the image.
func GetInfo(data []byte) (*ImageInfo, error) {
if len(data) == 0 {
return nil, errors.New("empty image data")
}
cfg, format, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("failed to decode config: %w", err)
}
return &ImageInfo{
Width: cfg.Width,
Height: cfg.Height,
Format: format,
}, nil
}
// MaxImagePixels caps the number of pixels Load will decode, guarding against
// decode bombs — a tiny file whose header declares enormous dimensions can drive
// image.Decode to allocate gigabytes. The default is 64 megapixels (e.g.
// 8192x8192). Set it to 0 to disable the guard.
var MaxImagePixels = 64 << 20
// checkPixelLimit rejects images whose declared dimensions exceed MaxImagePixels,
// reading only the (cheap) header. A header that won't even decode is left for
// the full decode to report.
func checkPixelLimit(data []byte) error {
if MaxImagePixels <= 0 {
return nil
}
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
return nil //nolint:nilerr // let the full decode surface the real error
}
if cfg.Width > 0 && cfg.Height > 0 && int64(cfg.Width)*int64(cfg.Height) > int64(MaxImagePixels) {
return fmt.Errorf("image %dx%d exceeds the %d-pixel decode limit (adjust MaxImagePixels)",
cfg.Width, cfg.Height, MaxImagePixels)
}
return nil
}
// Load decodes an image from data. It rejects images larger than MaxImagePixels
// before decoding, so untrusted input cannot trigger a decode-bomb allocation.
func Load(data []byte) (image.Image, error) {
if len(data) == 0 {
return nil, errors.New("empty image data")
}
if err := checkPixelLimit(data); err != nil {
return nil, err
}
img, format, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("failed to decode image: %w", err)
}
if img == nil {
return nil, fmt.Errorf("decoded nil image (format: %s)", format)
}
return img, nil
}
// MaxBatchSize caps the number of images LoadBatchConcurrent will accept in a
// single call. MaxImagePixels bounds each individual image's decoded size, but
// a batch of many images that each individually pass that check can still
// exhaust memory or CPU in aggregate (e.g. 100,000 small-but-valid images).
// The default is generous enough for normal callers; set it to 0 to disable
// the guard entirely.
var MaxBatchSize = 10_000
// checkBatchLimit rejects a batch whose item count exceeds MaxBatchSize.
func checkBatchLimit(n int) error {
if MaxBatchSize > 0 && n > MaxBatchSize {
return fmt.Errorf("batch of %d images exceeds the %d-image limit (adjust MaxBatchSize)",
n, MaxBatchSize)
}
return nil
}
// batchWorker pulls image indices from jobs, decodes each via Load, and writes
// the result into results[idx] (each worker owns a distinct index, so no lock
// is needed) or reports a decode failure on errs. It returns once jobs is
// drained and closed, or ctx is canceled.
func batchWorker(ctx context.Context, datas [][]byte, jobs <-chan int, results []image.Image, errs chan<- error) {
for {
// Check cancellation first: a bare select races between a ready
// job and ctx.Done() (Go picks randomly), so an already-canceled
// context would only be honored intermittently.
if err := ctx.Err(); err != nil {
errs <- err
return
}
select {
case idx, ok := <-jobs:
// 'ok' will be false if the jobs channel is closed and empty.
if !ok {
return
}
img, err := Load(datas[idx])
if err != nil {
errs <- fmt.Errorf("failed to decode image at index %d: %w", idx, err)
} else {
results[idx] = img
}
case <-ctx.Done():
// The context was canceled, so stop processing.
errs <- ctx.Err()
return
}
}
}
// LoadBatchConcurrent decodes multiple images in parallel with context support and full error reporting.
func LoadBatchConcurrent(ctx context.Context, datas [][]byte) ([]image.Image, error) {
if err := checkBatchLimit(len(datas)); err != nil {
return nil, err
}
numWorkers := runtime.NumCPU()
if len(datas) < numWorkers {
numWorkers = len(datas)
}
// jobs channel sends indices of images to be processed.
jobs := make(chan int, len(datas))
for i := 0; i < len(datas); i++ {
jobs <- i
}
close(jobs)
results := make([]image.Image, len(datas))
// Buffer the worst case so no worker blocks on send: up to len(datas) decode
// failures plus up to numWorkers cancellation sends. An under-sized buffer
// deadlocks wg.Wait when cancellation coincides with decode failures.
errs := make(chan error, len(datas)+numWorkers)
var wg sync.WaitGroup
// Start workers.
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
batchWorker(ctx, datas, jobs, results, errs)
}()
}
wg.Wait()
close(errs) // Close the error channel after all workers are done.
// Collect all errors into a slice.
multiErr := make([]error, 0, len(errs))
for err := range errs {
multiErr = append(multiErr, err)
}
if len(multiErr) > 0 {
// If the context was canceled, return context.Canceled directly
// (multiple workers may report the same cancellation)
if errors.Is(multiErr[0], context.Canceled) || errors.Is(multiErr[0], context.DeadlineExceeded) {
return nil, multiErr[0]
}
// For other errors, aggregate them into a formatted error message
return nil, fmt.Errorf("multiple errors occurred: %v", multiErr)
}
return results, nil
}
// LoadStream decodes an image from an io.Reader. Like Load, it enforces the
// MaxImagePixels decode-bomb guard: the header is peeked to check the declared
// dimensions before the full image is decoded.
func LoadStream(r io.Reader) (image.Image, error) {
if r == nil {
return nil, errors.New("nil reader")
}
if MaxImagePixels > 0 {
// Tee the header bytes consumed by DecodeConfig into a buffer so the full
// image can still be decoded (DecodeConfig reads only the header).
var header bytes.Buffer
cfg, _, cfgErr := image.DecodeConfig(io.TeeReader(r, &header))
if cfgErr == nil && cfg.Width > 0 && cfg.Height > 0 &&
int64(cfg.Width)*int64(cfg.Height) > int64(MaxImagePixels) {
return nil, fmt.Errorf("image %dx%d exceeds the %d-pixel decode limit (adjust MaxImagePixels)",
cfg.Width, cfg.Height, MaxImagePixels)
}
// Replay the consumed header, then the remainder of the stream.
r = io.MultiReader(&header, r)
}
img, _, err := image.Decode(r)
if err != nil {
return nil, fmt.Errorf("decode image stream: %w", err)
}
return img, nil
}