diff --git a/README.md b/README.md index 6fd083b1d..0f74ae953 100644 --- a/README.md +++ b/README.md @@ -346,6 +346,30 @@ However, you can use the `scaleUp` command-line flag to allow this to happen: imageproxy -scaleUp true ``` +### ICC color profiles + +Go's `image/jpeg` package does not read or write APP2 marker segments, so a +JPEG's embedded ICC color profile is lost when imageproxy decodes and +re-encodes it. The result is then rendered as sRGB, which visibly desaturates +images authored in a wider gamut such as Display P3 or Adobe RGB. + +The `passthroughICC` flag carries the source profile over to the transformed +image: + +```sh +imageproxy -passthroughICC true +``` + +It is off by default because the profile's bytes are added to every response +that carries one — commonly 0.5-4KB, which is a large relative cost on a small +thumbnail. + +The profile is only carried over when the decoded pixels are still in the space +it describes: both the source and the output must be JPEG, the profile's data +color space must be RGB, and the decoded image must not be CMYK or grayscale +(the decoder converts those, so the source profile no longer describes the +result). Profiles larger than 64KB are dropped rather than attached. + ### WebP and TIFF support Imageproxy can proxy remote webp images, but they will be served in either jpeg diff --git a/cmd/imageproxy/main.go b/cmd/imageproxy/main.go index a04296ff7..ca9514810 100644 --- a/cmd/imageproxy/main.go +++ b/cmd/imageproxy/main.go @@ -43,6 +43,7 @@ var passResponseHeaders = flag.String("passResponseHeaders", "Cache-Control,Last var cache tieredCache var signatureKeys signatureKeyList var scaleUp = flag.Bool("scaleUp", false, "allow images to scale beyond their original dimensions") +var passthroughICC = flag.Bool("passthroughICC", false, "carry the source JPEG's embedded ICC color profile over to the transformed image") var timeout = flag.Duration("timeout", 0, "time limit for requests served by this proxy") var verbose = flag.Bool("verbose", false, "print verbose logging messages") var _ = flag.Bool("version", false, "Deprecated: this flag does nothing") @@ -95,6 +96,7 @@ func main() { p.FollowRedirects = *followRedirects p.Timeout = *timeout p.ScaleUp = *scaleUp + p.PassthroughICC = *passthroughICC p.Verbose = *verbose p.UserAgent = *userAgent p.MinimumCacheDuration = *minCacheDuration diff --git a/data.go b/data.go index 8b5765cc7..fa73727be 100644 --- a/data.go +++ b/data.go @@ -28,6 +28,7 @@ const ( optSignaturePrefix = "s" optSizeDelimiter = "x" optScaleUp = "scaleUp" + optPassthroughICC = "passthroughICC" optCropX = "cx" optCropY = "cy" optCropWidth = "cw" @@ -74,6 +75,11 @@ type Options struct { // will always be overwritten by the value of Proxy.ScaleUp. ScaleUp bool + // Carry the source image's embedded ICC color profile over to the + // transformed image. This value will always be overwritten by the value + // of Proxy.PassthroughICC. + PassthroughICC bool + // Desired image format. Valid values are "jpeg", "png", "tiff". Format string @@ -116,6 +122,9 @@ func (o Options) String() string { if o.ScaleUp { opts = append(opts, optScaleUp) } + if o.PassthroughICC { + opts = append(opts, optPassthroughICC) + } if o.Format != "" { opts = append(opts, o.Format) } @@ -278,6 +287,8 @@ func ParseOptions(str string) Options { options.FlipHorizontal = true case opt == optScaleUp: // this option is intentionally not documented above options.ScaleUp = true + case opt == optPassthroughICC: // this option is intentionally not documented above + options.PassthroughICC = true case opt == optFormatJPEG, opt == optFormatPNG, opt == optFormatTIFF: options.Format = opt case opt == optSmartCrop: diff --git a/icc.go b/icc.go new file mode 100644 index 000000000..18ac60304 --- /dev/null +++ b/icc.go @@ -0,0 +1,174 @@ +// Copyright 2013 The imageproxy authors. +// SPDX-License-Identifier: Apache-2.0 + +package imageproxy + +import ( + "bytes" + "encoding/binary" + "image" +) + +// ICC_PROFILE marker segments are identified by this null-terminated string, +// followed by a 1-based chunk number and the total chunk count (ITU-T T.872 B.4). +const iccIdentifier = "ICC_PROFILE\x00" + +const ( + iccChunkHeaderLen = len(iccIdentifier) + 2 // identifier + seq + count + // A JPEG marker segment payload is limited by its 16-bit length field, + // which counts itself. + maxSegmentPayload = 0xFFFF - 2 + maxICCChunk = maxSegmentPayload - iccChunkHeaderLen + + // Profiles larger than this are dropped rather than attached: a 500 KB + // LUT profile on an 80px thumbnail costs far more than the color it + // preserves. + maxICCProfileSize = 64 << 10 + + // Length of the ICC profile header (ICC.1:2010 section 7.2). + iccHeaderSize = 128 +) + +// extractICCProfile returns the ICC profile embedded in the APP2 segments of +// the JPEG in img, or nil if it carries none. Chunks are reassembled in the +// order declared by their sequence numbers. +func extractICCProfile(img []byte) []byte { + chunks := map[byte][]byte{} + var count byte + + forEachJPEGSegment(img, func(marker byte, payload []byte) { + if marker != 0xE2 || len(payload) <= iccChunkHeaderLen { + return + } + if !bytes.HasPrefix(payload, []byte(iccIdentifier)) { + return + } + seq := payload[len(iccIdentifier)] + total := payload[len(iccIdentifier)+1] + if seq == 0 || total == 0 || seq > total { + return + } + if count == 0 { + count = total + } else if total != count { + return // inconsistent chunk set; refuse to guess + } + chunks[seq] = payload[iccChunkHeaderLen:] + }) + + if count == 0 || len(chunks) != int(count) { + return nil + } + + // Chunks are still sub-slices of img at this point. A crafted source can + // declare 255 of them, so measure before allocating rather than build a + // ~16 MB profile for preservesSourceColorSpace to throw away. + size := 0 + for _, c := range chunks { + size += len(c) + } + if size > maxICCProfileSize { + return nil + } + + profile := make([]byte, 0, size) + for i := byte(1); i <= count; i++ { + profile = append(profile, chunks[i]...) + } + return profile +} + +// embedICCProfile returns the JPEG in img with profile written into APP2 +// segments immediately after the SOI marker. The rest of img is carried over +// untouched, so the caller must only pass a JPEG that has no ICC segments of +// its own — two chunk sets would leave the profile unreadable. Output from +// Go's jpeg encoder, which writes no APP segments at all, satisfies that. +func embedICCProfile(img, profile []byte) []byte { + if len(profile) == 0 || len(img) < 2 { + return img + } + + chunkCount := (len(profile) + maxICCChunk - 1) / maxICCChunk + if chunkCount > 255 { + return img + } + + out := make([]byte, 0, len(img)+len(profile)+chunkCount*(iccChunkHeaderLen+4)) + out = append(out, img[:2]...) // SOI + + for i := 0; i < chunkCount; i++ { + chunk := profile[i*maxICCChunk:] + if len(chunk) > maxICCChunk { + chunk = chunk[:maxICCChunk] + } + out = append(out, 0xFF, 0xE2) + out = binary.BigEndian.AppendUint16(out, uint16(2+iccChunkHeaderLen+len(chunk))) + out = append(out, iccIdentifier...) + out = append(out, byte(i+1), byte(chunkCount)) + out = append(out, chunk...) + } + + return append(out, img[2:]...) +} + +// forEachJPEGSegment walks the marker segments of a JPEG, calling fn for each +// one that carries a payload. It stops at the start of scan, after which the +// entropy-coded data begins. +func forEachJPEGSegment(img []byte, fn func(marker byte, payload []byte)) { + if len(img) < 2 || img[0] != 0xFF || img[1] != 0xD8 { + return + } + + for i := 2; i+1 < len(img); { + if img[i] != 0xFF { + return // not at a marker boundary; give up rather than resync + } + marker := img[i+1] + i += 2 + + switch { + case marker == 0xFF: + i-- // fill byte; the next byte may be the marker + continue + case marker == 0x01, marker >= 0xD0 && marker <= 0xD9: + continue // standalone markers carry no payload + case marker == 0xDA: + return // start of scan + } + + if i+2 > len(img) { + return + } + length := int(binary.BigEndian.Uint16(img[i:])) + if length < 2 || i+length > len(img) { + return + } + fn(marker, img[i+2:i+length]) + i += length + } +} + +// preservesSourceColorSpace reports whether the pixels decoded from a source +// image still live in the color space its embedded profile describes, so that +// re-attaching the profile to the output is correct rather than merely present. +// +// Go's JPEG decoder converts YCbCr to RGB with the fixed JPEG matrix, which is +// color-space agnostic — the resulting triplets stay in the source's primaries. +// A CMYK or YCCK source is different: the decoder converts it to RGB with a +// naive inversion, so the CMYK profile no longer describes the result. +func preservesSourceColorSpace(m image.Image, profile []byte) bool { + // Every ICC profile opens with a 128-byte header, so a shorter blob is + // malformed however plausible its color-space field looks. + if len(profile) < iccHeaderSize || len(profile) > maxICCProfileSize { + return false + } + // Bytes 16..20 of the ICC header hold the data color space signature. + if !bytes.Equal(profile[16:20], []byte("RGB ")) { + return false + } + switch m.(type) { + case *image.CMYK, *image.Gray, *image.Gray16: + return false + } + return true +} diff --git a/icc_test.go b/icc_test.go new file mode 100644 index 000000000..083079013 --- /dev/null +++ b/icc_test.go @@ -0,0 +1,128 @@ +package imageproxy + +import ( + "bytes" + "image" + "image/jpeg" + "testing" +) + +// syntheticProfile builds an ICC blob of n bytes whose header declares the +// given data color space. +func syntheticProfile(n int, space string) []byte { + p := make([]byte, n) + if n >= 20 { + copy(p[16:20], space) + } + for i := 20; i < n; i++ { + p[i] = byte(i % 251) + } + return p +} + +func TestICCRoundTrip(t *testing.T) { + // a minimal JPEG skeleton: SOI, a DQT-ish segment, SOS, EOI + base := []byte{0xFF, 0xD8, 0xFF, 0xDB, 0x00, 0x04, 0xAA, 0xBB, 0xFF, 0xDA, 0x00, 0x02, 0xFF, 0xD9} + + for _, size := range []int{1, iccHeaderSize, maxICCChunk, maxICCChunk + 1, maxICCProfileSize} { + profile := syntheticProfile(size, "RGB ") + embedded := embedICCProfile(base, profile) + got := extractICCProfile(embedded) + if !bytes.Equal(got, profile) { + t.Errorf("size %d: round trip lost the profile (got %d bytes, want %d)", size, len(got), len(profile)) + } + if !bytes.HasSuffix(embedded, base[2:]) { + t.Errorf("size %d: original segments were not preserved", size) + } + } +} + +func TestExtractICCProfileRejectsMalformed(t *testing.T) { + tests := map[string][]byte{ + "not a jpeg": {0x00, 0x01, 0x02}, + "truncated": {0xFF, 0xD8, 0xFF, 0xE2, 0x00}, + "length past end": {0xFF, 0xD8, 0xFF, 0xE2, 0xFF, 0xFF, 0x01}, + "no icc segment": {0xFF, 0xD8, 0xFF, 0xDA, 0x00, 0x02, 0xFF, 0xD9}, + } + for name, img := range tests { + if got := extractICCProfile(img); got != nil { + t.Errorf("%s: expected no profile, got %d bytes", name, len(got)) + } + } +} + +func TestExtractICCProfileRejectsIncompleteChunkSet(t *testing.T) { + base := []byte{0xFF, 0xD8, 0xFF, 0xDA, 0x00, 0x02, 0xFF, 0xD9} + full := embedICCProfile(base, syntheticProfile(maxICCChunk+1, "RGB ")) + + // drop the first APP2 segment, leaving chunk 2 claiming a set of 2 + partial := append([]byte{0xFF, 0xD8}, full[2+4+iccChunkHeaderLen+maxICCChunk:]...) + if got := extractICCProfile(partial); got != nil { + t.Errorf("expected no profile from an incomplete chunk set, got %d bytes", len(got)) + } +} + +func TestExtractICCProfileRejectsOversizedProfile(t *testing.T) { + base := []byte{0xFF, 0xD8, 0xFF, 0xDA, 0x00, 0x02, 0xFF, 0xD9} + + if got := extractICCProfile(embedICCProfile(base, syntheticProfile(maxICCProfileSize+1, "RGB "))); got != nil { + t.Errorf("expected an oversized profile to be refused before reassembly, got %d bytes", len(got)) + } + if got := extractICCProfile(embedICCProfile(base, syntheticProfile(maxICCProfileSize, "RGB "))); len(got) != maxICCProfileSize { + t.Errorf("a profile exactly at the cap should survive, got %d bytes", len(got)) + } +} + +func TestPreservesSourceColorSpace(t *testing.T) { + rgb := image.NewRGBA(image.Rect(0, 0, 1, 1)) + tests := []struct { + name string + m image.Image + p []byte + want bool + }{ + {"rgb profile on rgb pixels", rgb, syntheticProfile(512, "RGB "), true}, + {"cmyk profile", rgb, syntheticProfile(512, "CMYK"), false}, + {"gray source", image.NewGray(image.Rect(0, 0, 1, 1)), syntheticProfile(512, "RGB "), false}, + {"cmyk source", image.NewCMYK(image.Rect(0, 0, 1, 1)), syntheticProfile(512, "RGB "), false}, + {"oversized profile", rgb, syntheticProfile(maxICCProfileSize+1, "RGB "), false}, + {"short of a full icc header", rgb, syntheticProfile(iccHeaderSize-1, "RGB "), false}, + {"exactly one icc header", rgb, syntheticProfile(iccHeaderSize, "RGB "), true}, + {"no profile", rgb, nil, false}, + {"runt profile", rgb, []byte{1, 2, 3}, false}, + } + for _, tt := range tests { + if got := preservesSourceColorSpace(tt.m, tt.p); got != tt.want { + t.Errorf("%s: got %v, want %v", tt.name, got, tt.want) + } + } +} + +func TestTransformPassthroughICC(t *testing.T) { + profile := syntheticProfile(256, "RGB ") + + var buf bytes.Buffer + if err := jpeg.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 32, 32)), nil); err != nil { + t.Fatal(err) + } + img := embedICCProfile(buf.Bytes(), profile) + if got := extractICCProfile(img); !bytes.Equal(got, profile) { + t.Fatalf("fixture lost its profile before Transform ran") + } + + off, err := Transform(img, Options{Width: 16}) + if err != nil { + t.Fatal(err) + } + if got := extractICCProfile(off); got != nil { + t.Errorf("PassthroughICC off: expected no profile, got %d bytes", len(got)) + } + + on, err := Transform(img, Options{Width: 16, PassthroughICC: true}) + if err != nil { + t.Fatal(err) + } + if got := extractICCProfile(on); !bytes.Equal(got, profile) { + t.Errorf("PassthroughICC on: got %d profile bytes, want %d", len(got), len(profile)) + } +} diff --git a/imageproxy.go b/imageproxy.go index 18df0b325..e481ecdae 100644 --- a/imageproxy.go +++ b/imageproxy.go @@ -75,6 +75,12 @@ type Proxy struct { // Allow images to scale beyond their original dimensions. ScaleUp bool + // PassthroughICC controls whether a JPEG's embedded ICC color profile + // is carried over to the transformed image. Off by default: it adds the + // profile's bytes to every response that has one, which is a meaningful + // cost on small thumbnails. + PassthroughICC bool + // Timeout specifies a time limit for requests served by this Proxy. // If a call runs for longer than its time limit, a 504 Gateway Timeout // response is returned. A Timeout of zero means no timeout. @@ -254,6 +260,7 @@ func (p *Proxy) serveImage(w http.ResponseWriter, r *http.Request) { // assign static settings from proxy to req.Options req.Options.ScaleUp = p.ScaleUp + req.Options.PassthroughICC = p.PassthroughICC actualReq, _ := http.NewRequest("GET", req.String(), nil) if p.UserAgent != "" { diff --git a/transform.go b/transform.go index a5e87d4b0..737f8cdd9 100644 --- a/transform.go +++ b/transform.go @@ -62,6 +62,7 @@ func Transform(img []byte, opt Options) ([]byte, error) { if err != nil { return nil, err } + srcFormat, srcImage := format, m // apply EXIF orientation for jpeg and tiff source images. Read at most // up to maxExifSize looking for EXIF tags. @@ -109,6 +110,16 @@ func Transform(img []byte, opt Options) ([]byte, error) { if err != nil { return nil, err } + + // image/jpeg neither reads nor writes APP2 segments, so the source's + // ICC profile is lost across the decode/encode round trip and the + // output is rendered as sRGB. When asked to, carry the profile over — + // but only where the pixels are still in the space it describes. + if opt.PassthroughICC && srcFormat == "jpeg" { + if profile := extractICCProfile(img); preservesSourceColorSpace(srcImage, profile) { + return embedICCProfile(buf.Bytes(), profile), nil + } + } case "png": m = transformImage(m, opt) err = png.Encode(buf, m)