Support SVG sources (closes #68)
check / check (push) Canceled after 0s

The fetcher accepts image/svg+xml again and internal/magic detects SVG.
A raster format renders the SVG with librsvg at the density that makes
it cover the output size, so an enlarged SVG stays sharp; the rendered
size is held to MaxInputDimension. orig of an SVG at the orig size is
the SVG unchanged, cached as a variant; at any other size it is
ErrSVGNotOriginalSize, answered 400. The cache reads up to 1024 first
bytes of a file to find its format, as an SVG's root element can come
after a prolog. Tests that pinned SVG as refused now expect it to be
read.

Model: opus-5-5
This commit is contained in:
sneak committed 2026-11-11 00:38:16 +00:00
1 parent 9f1e9f5253
commit 632fe0bd36
20 files changed
+669 -76

No files matched your search

+130 -26
View File
@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"io"
"math"
"sync"
"time"
@@ -113,10 +114,16 @@ var ErrUnsupportedOutputFormat = errors.New("unsupported output format")
// ErrUnsupportedInputFormat is returned, before the input is decoded, when
// libvips reads the input's first bytes as another format than its source
// format, or as none. An SVG whose first bytes pass the first-bytes check for
// AVIF is one example. A source format other than the six pixa reads is also
// refused with it.
// AVIF is one example. A source format other than the seven pixa reads is
// also refused with it.
var ErrUnsupportedInputFormat = errors.New("unsupported input format")
// ErrSVGNotOriginalSize is returned when orig is asked of an SVG source at a
// size other than orig. pixa returns an SVG only as it is; a raster format
// gives it a size.
var ErrSVGNotOriginalSize = errors.New(
"an SVG is returned as SVG only at the orig size")
// ErrTooManyImages is returned when MaxConcurrentProcessing images are being
// processed and none finishes within ProcessingWaitTimeout.
var ErrTooManyImages = errors.New("too many images being processed at once")
@@ -164,7 +171,9 @@ func New(params Params) *ImageProcessor {
// encodes, it returns ctx's error and gives the slot back. govips gives pixa
// no way to stop libvips once an encode has begun, so an encode keeps its
// slot until it finishes. An AVIF output of more than MaxAVIFOutputPixels is
// refused with ErrAVIFOutputTooLarge before anything is decoded.
// refused with ErrAVIFOutputTooLarge before anything is decoded. orig of an
// SVG source is the SVG as it is, since libvips cannot save SVG; any other
// format renders it at the output size.
func (p *ImageProcessor) Process(
ctx context.Context,
input io.Reader,
@@ -190,27 +199,17 @@ func (p *ImageProcessor) Process(
return nil, err
}
// Decode image
img, err := vips.NewImageFromBuffer(data)
img, err := loadImage(data)
if err != nil {
return nil, fmt.Errorf("failed to decode image: %w", err)
return nil, err
}
defer img.Close()
// Turn the image upright now: encode strips the EXIF orientation tag,
// and sizes below must be worked out on the upright image.
err = img.AutoRotate()
if err != nil {
return nil, fmt.Errorf("failed to auto-rotate: %w", err)
}
origWidth, origHeight := img.Width(), img.Height()
outputFormat := resolveOutputFormat(transform.Format, sourceFormat)
// Get original dimensions
origWidth := img.Width()
origHeight := img.Height()
// Validate input dimensions to prevent DoS via decompression bombs
if origWidth > MaxInputDimension || origHeight > MaxInputDimension {
return nil, ErrInputTooLarge
if outputFormat == imagerequest.FormatSVG && sourceFormat == imagerequest.FormatSVG {
return svgAsItIs(data, transform.Size, origWidth, origHeight)
}
// Determine target dimensions; too large ones are refused before resizing
@@ -220,12 +219,6 @@ func (p *ImageProcessor) Process(
return nil, err
}
// orig is the source's own format; encode refuses an empty format
outputFormat := transform.Format
if outputFormat == imagerequest.FormatOriginal {
outputFormat = sourceFormat
}
// Determine output dimensions; too large ones are refused before
// resizing, which with FitCover already decodes the input
outputWidth, outputHeight, err := outputDimensions(origWidth, origHeight,
@@ -234,6 +227,19 @@ func (p *ImageProcessor) Process(
return nil, err
}
// An SVG is rendered again at about the output size, to stay sharp
if sourceFormat == imagerequest.FormatSVG {
rendered, err := renderSVG(data,
svgDensity(origWidth, origHeight, outputWidth, outputHeight))
if err != nil {
return nil, err
}
defer rendered.Close()
img = rendered
}
// Resize if needed
if targetWidth != origWidth || targetHeight != origHeight {
err := p.resize(img, outputWidth, outputHeight, transform.FitMode)
@@ -290,6 +296,100 @@ func targetDimensions(
return width, height, nil
}
// resolveOutputFormat returns the format of the output for the requested
// format: sourceFormat for orig, and format itself for any other. encode
// refuses an empty format.
func resolveOutputFormat(
format, sourceFormat imagerequest.Format,
) imagerequest.Format {
if format == imagerequest.FormatOriginal {
return sourceFormat
}
return format
}
// loadImage decodes data and turns the image upright: encode strips the EXIF
// orientation tag, and sizes must be worked out on the upright image. An
// image with a side above MaxInputDimension is ErrInputTooLarge, which guards
// against decompression bombs.
func loadImage(data []byte) (*vips.ImageRef, error) {
img, err := vips.NewImageFromBuffer(data)
if err != nil {
return nil, fmt.Errorf("failed to decode image: %w", err)
}
err = img.AutoRotate()
if err != nil {
img.Close()
return nil, fmt.Errorf("failed to auto-rotate: %w", err)
}
if img.Width() > MaxInputDimension || img.Height() > MaxInputDimension {
img.Close()
return nil, ErrInputTooLarge
}
return img, nil
}
// svgAsItIs returns the SVG data unchanged, which is orig of an SVG source
// at the orig size. width x height is the SVG's own size. Any other size is
// ErrSVGNotOriginalSize.
func svgAsItIs(
data []byte, size imagerequest.Size, width, height int,
) (*Result, error) {
if !size.OriginalSize() {
return nil, fmt.Errorf("%w: asked for %dx%d", ErrSVGNotOriginalSize,
size.Width, size.Height)
}
return &Result{
Content: io.NopCloser(bytes.NewReader(data)),
ContentLength: int64(len(data)),
ContentType: imagerequest.FormatSVG.MIMEType(),
Width: width,
Height: height,
InputWidth: width,
InputHeight: height,
}, nil
}
// svgDefaultDensity is the density, in dots per inch, at which libvips
// renders an SVG unless told otherwise. At it, an SVG of width="64" is 64
// pixels wide.
const svgDefaultDensity = 72
// svgDensity returns the least density at which an SVG of width x height,
// its size at svgDefaultDensity, covers outputWidth x outputHeight, so that
// resize hardly changes it. Neither side is rendered above
// MaxInputDimension.
func svgDensity(width, height, outputWidth, outputHeight int) int {
scale := max(float64(outputWidth)/float64(width),
float64(outputHeight)/float64(height))
maxScale := float64(MaxInputDimension) / float64(max(width, height))
return min(int(math.Ceil(svgDefaultDensity*scale)),
int(svgDefaultDensity*maxScale))
}
// renderSVG loads the SVG data rendered at density, in dots per inch.
// librsvg renders it and, given the SVG as bytes with no file name, loads
// no file or URL the SVG refers to.
func renderSVG(data []byte, density int) (*vips.ImageRef, error) {
params := vips.NewImportParams()
params.Density.Set(density)
img, err := vips.LoadImageFromBuffer(data, params)
if err != nil {
return nil, fmt.Errorf("failed to render SVG: %w", err)
}
return img, nil
}
// WaitForProcessing waits until no image is being processed, or until ctx
// ends, and returns how many images were still being processed then. It
// waits by taking each slot in processingSemaphore as it frees up until it
@@ -397,6 +497,8 @@ func vipsImageType(format imagerequest.Format) vips.ImageType {
return vips.ImageTypeAVIF
case imagerequest.FormatJXL:
return vips.ImageTypeJXL
case imagerequest.FormatSVG:
return vips.ImageTypeSVG
case imagerequest.FormatOriginal, imagerequest.FormatAuto:
return vips.ImageTypeUnknown
default:
@@ -575,7 +677,9 @@ func (p *ImageProcessor) encode(
case imagerequest.FormatJXL:
return exportJXL(img, quality)
case imagerequest.FormatOriginal, imagerequest.FormatAuto:
// libvips cannot save SVG; Process returns an SVG source as it is
case imagerequest.FormatSVG, imagerequest.FormatOriginal,
imagerequest.FormatAuto:
return nil, fmt.Errorf("%w: %s", ErrUnsupportedOutputFormat, format)
default: