From b602748217e73f023ee008b26d62b34df52b309d Mon Sep 17 00:00:00 2001 From: Dan Johansson Date: Tue, 1 Sep 2026 20:29:19 -0500 Subject: [PATCH] transform: optionally carry the source JPEG's ICC color profile image/jpeg sends every APPn marker but APP0 and APP14 to decoder.ignore, and Encode writes no APP segments at all, so a JPEG's embedded ICC profile does not survive the decode/encode round trip. The output is then rendered as sRGB, which visibly desaturates images authored in a wider gamut: measured against a correct color-managed render at /x80/, a Display P3 source is 3.64% RMSE off and an Adobe RGB source 4.93%. With the profile carried over both drop to ~0.3%, which is JPEG quantization noise. Add a passthroughICC flag, off by default, following the same Proxy -> Options path as scaleUp. It is opt-in 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 reattached where the decoded pixels are still in the space it describes. YCbCr is converted to RGB with the fixed JPEG matrix, which is color-space agnostic, so an RGB source's triplets stay in its own primaries; a CMYK or YCCK source is converted by naive inversion, so its profile no longer describes the result. Hence the guard: source and output both JPEG, profile data color space RGB, decoded image not CMYK or Gray, and profile no larger than 64KB. Fixes #160 Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 24 ++++++ cmd/imageproxy/main.go | 2 + data.go | 11 +++ icc.go | 174 +++++++++++++++++++++++++++++++++++++++++ icc_test.go | 128 ++++++++++++++++++++++++++++++ imageproxy.go | 7 ++ transform.go | 11 +++ 7 files changed, 357 insertions(+) create mode 100644 icc.go create mode 100644 icc_test.go 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)