Skip to content
Open
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
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions cmd/imageproxy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions data.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const (
optSignaturePrefix = "s"
optSizeDelimiter = "x"
optScaleUp = "scaleUp"
optPassthroughICC = "passthroughICC"
optCropX = "cx"
optCropY = "cy"
optCropWidth = "cw"
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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:
Expand Down
174 changes: 174 additions & 0 deletions icc.go
Original file line number Diff line number Diff line change
@@ -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
}
128 changes: 128 additions & 0 deletions icc_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading