diff --git a/calibrated.go b/calibrated.go new file mode 100644 index 0000000..78fcafd --- /dev/null +++ b/calibrated.go @@ -0,0 +1,100 @@ +package render + +import ( + gfxcolor "github.com/go-gfx/gfx/color" + "github.com/go-pdfkit/reader" + "image/color" +) + +// This file reads the two calibrated spaces: CalGray and CalRGB. Each +// says what colour its numbers name by giving a white point and a rule for +// reaching CIE XYZ, so neither is its device namesake and neither can be read +// by passing its numbers through. +// +// Lab is the third CIE space and is NOT here. poppler's GfxLabColorSpace does +// not multiply by the white point where ISO 32000-2 8.6.5.4 says to +// (X = Xw*g(M)), so a correct Lab and the judge this repository measures +// against would disagree for a reason that is not ours. That wants an +// experiment of its own before either is changed. +// +// The arithmetic lives in gfx/color; what is here is reading a dictionary and +// carrying its defaults. + +// calDict returns the dictionary an array-form space carries as its second +// element, which is where all three keep their parameters. +func (r *renderer) calDict(arr reader.Array) reader.Dict { + if len(arr) < 2 { + return nil + } + d, _ := reader.ToDict(resolve(r.doc, arr[1])) + return d +} + +// calFloats reads a numeric array of exactly n entries, reporting whether it +// was there and whole. A partial one is not used: a white point missing its +// Z is not a white point. +func (r *renderer) calFloats(d reader.Dict, key reader.Name, n int) ([]float64, bool) { + arr, ok := reader.ToArray(resolve(r.doc, d.Get(key))) + if !ok || len(arr) != n { + return nil, false + } + out := make([]float64, n) + for i := range out { + v, ok := reader.ToFloat(resolve(r.doc, arr[i])) + if !ok { + return nil, false + } + out[i] = v + } + return out, true +} + +// calWhitePoint reads /WhitePoint, which all three spaces require. A file that +// omits it has said nothing about its colours, so the space falls back to its +// device namesake rather than to an invented illuminant. +func (r *renderer) calWhitePoint(d reader.Dict) (gfxcolor.WhitePoint, bool) { + v, ok := r.calFloats(d, "WhitePoint", 3) + if !ok || v[1] <= 0 { + return gfxcolor.WhitePoint{}, false + } + return gfxcolor.WhitePoint{X: v[0], Y: v[1], Z: v[2]}, true +} + +// calGraySpace reads a CalGray space: a white point and the gamma its single +// component is encoded by. +func (r *renderer) calGraySpace(arr reader.Array) *space { + d := r.calDict(arr) + white, ok := r.calWhitePoint(d) + if !ok { + return deviceGray + } + s := gfxcolor.NewCalGray(white) + if g, ok := reader.ToFloat(resolve(r.doc, d.Get("Gamma"))); ok && g > 0 { + s.Gamma = g + } + return &space{name: "CalGray", components: 1, convert: func(v []float64) color.RGBA { + red, green, blue := gfxcolor.CalGrayToSRGB(s, at(v, 0)) + return color.RGBA{R: byteOf(red), G: byteOf(green), B: byteOf(blue), A: 255} + }} +} + +// calRGBSpace reads a CalRGB space: a white point, three gammas and the matrix +// carrying the decoded components to CIE XYZ. +func (r *renderer) calRGBSpace(arr reader.Array) *space { + d := r.calDict(arr) + white, ok := r.calWhitePoint(d) + if !ok { + return deviceRGB + } + s := gfxcolor.NewCalRGB(white) + if g, ok := r.calFloats(d, "Gamma", 3); ok && g[0] > 0 && g[1] > 0 && g[2] > 0 { + s.Gamma = [3]float64{g[0], g[1], g[2]} + } + if m, ok := r.calFloats(d, "Matrix", 9); ok { + copy(s.Matrix[:], m) + } + return &space{name: "CalRGB", components: 3, convert: func(v []float64) color.RGBA { + red, green, blue := gfxcolor.CalRGBToSRGB(s, at(v, 0), at(v, 1), at(v, 2)) + return color.RGBA{R: byteOf(red), G: byteOf(green), B: byteOf(blue), A: 255} + }} +} diff --git a/calibrated_test.go b/calibrated_test.go new file mode 100644 index 0000000..1db356e --- /dev/null +++ b/calibrated_test.go @@ -0,0 +1,305 @@ +package render + +import ( + "bytes" + "image" + "image/color" + "image/jpeg" + "testing" + + "github.com/go-pdfkit/reader" +) + +// calSpace builds a colour space from an array written the way a file writes +// it, so these tests exercise the reading as well as the arithmetic. +func calSpace(t *testing.T, arr reader.Array) *space { + t.Helper() + r := &renderer{} + return r.colourSpaceArray(arr[0].(reader.Name), arr, nil, 0) +} + +// d65 and adobeRGBUnderD50 are the two spaces the tests below use. The second +// is the one openpdf's PDF 2.0 test file carries. +var ( + d65 = nums(0.9505, 1.0, 1.0890) + adobeMatrix = nums(0.7161, 0.2582, 0.0000, 0.1009, 0.7249, 0.0518, 0.1472, 0.0168, 0.7734) + adobeWhiteD50 = nums(0.9643, 1.0000, 0.8251) + gammaTwoPointTwo = nums(2.2, 2.2, 2.2) +) + +func TestCalGrayIsNotDeviceGray(t *testing.T) { + // The defect this file exists for. With the gamma the format defaults to, + // a CalGray component is linear luminance, so a mid grey is far lighter + // than the same number read as a device grey. Reading it as DeviceGray + // was worth 60 levels. + s := calSpace(t, reader.Array{reader.Name("CalGray"), reader.Dict{"WhitePoint": d65}}) + if s.name != "CalGray" { + t.Fatalf("space = %q, want CalGray", s.name) + } + got := s.convert([]float64{0.5}) + if got.R != 188 || got.G != 188 || got.B != 188 { + t.Errorf("mid CalGray = %v, want 188 on each channel", got) + } + if deviceGray.convert([]float64{0.5}).R == got.R { + t.Error("CalGray and DeviceGray agree at the mid tone; the gamma was dropped") + } +} + +func TestCalGrayHonoursItsGamma(t *testing.T) { + // With gamma 2.2 the component is already close to an sRGB encoding, so + // the mid tone comes back near where it went in. That the two gammas give + // different answers is the whole point of reading the entry. + s := calSpace(t, reader.Array{reader.Name("CalGray"), + reader.Dict{"WhitePoint": d65, "Gamma": reader.Real(2.2)}}) + got := s.convert([]float64{0.5}) + if got.R < 120 || got.R > 136 { + t.Errorf("mid CalGray at gamma 2.2 = %d, want it near the sRGB mid tone", got.R) + } +} + +func TestCalRGBReadsItsMatrixAndGamma(t *testing.T) { + s := calSpace(t, reader.Array{reader.Name("CalRGB"), + reader.Dict{"WhitePoint": adobeWhiteD50, "Gamma": gammaTwoPointTwo, "Matrix": adobeMatrix}}) + if s.name != "CalRGB" || s.components != 3 { + t.Fatalf("space = %q with %d components, want CalRGB with 3", s.name, s.components) + } + // A saturated red in Adobe RGB is outside sRGB and comes back clipped, + // but a NEUTRAL must stay neutral: the white point is the whole reason + // the adaptation is there. + white := s.convert([]float64{1, 1, 1}) + if white.R != 255 || white.G != 255 || white.B != 255 { + t.Errorf("CalRGB white = %v, want 255 on each channel", white) + } + black := s.convert([]float64{0, 0, 0}) + if black.R != 0 || black.G != 0 || black.B != 0 { + t.Errorf("CalRGB black = %v, want 0 on each channel", black) + } + // And a mid grey must not acquire a cast of more than a level or two. + mid := s.convert([]float64{0.5, 0.5, 0.5}) + if d := int(mid.R) - int(mid.B); d > 2 || d < -2 { + t.Errorf("CalRGB mid grey = %v, want it near neutral", mid) + } +} + +func TestCalRGBDiffersFromDeviceRGBOnTheRealDocument(t *testing.T) { + // openpdf-core/pdf-2-0_PDF_2.0_image_with_BPC.pdf draws the SAME JPEG + // stream twice, once through this space and once through DeviceRGB, and + // its own page text says the two should differ. Reading CalRGB as + // DeviceRGB made them identical. + s := calSpace(t, reader.Array{reader.Name("CalRGB"), + reader.Dict{"WhitePoint": adobeWhiteD50, "Gamma": gammaTwoPointTwo, "Matrix": adobeMatrix}}) + worst := 0 + for i := 0; i <= 16; i++ { + for j := 0; j <= 16; j++ { + for k := 0; k <= 16; k++ { + v := []float64{float64(i) / 16, float64(j) / 16, float64(k) / 16} + a, b := s.convert(v), deviceRGB.convert(v) + for _, d := range [][2]uint8{{a.R, b.R}, {a.G, b.G}, {a.B, b.B}} { + e := int(d[0]) - int(d[1]) + if e < 0 { + e = -e + } + if e > worst { + worst = e + } + } + } + } + } + // The extracted pictures differ by 110 at their peak; over the whole cube + // the gap is at least that. + if worst < 100 { + t.Errorf("worst gap between CalRGB and DeviceRGB = %d levels, want at least 100", worst) + } + t.Logf("worst gap over the colour cube: %d levels", worst) +} + +func TestACalibratedSpaceWithoutAWhitePointFallsBackToItsDeviceNamesake(t *testing.T) { + // The format requires /WhitePoint. A file that omits it has said nothing + // about its colours, so inventing an illuminant would be worse than + // reading the numbers as the device space they resemble. + for _, tc := range []struct { + name reader.Name + want *space + args []float64 + }{ + {"CalGray", deviceGray, []float64{0.5}}, + {"CalRGB", deviceRGB, []float64{0.5, 0.5, 0.5}}, + } { + t.Run(string(tc.name), func(t *testing.T) { + for label, d := range map[string]reader.Dict{ + "no dictionary": nil, + "empty dictionary": reader.Dict{}, + "a partial point": reader.Dict{"WhitePoint": nums(0.95, 1.0)}, + "a zero luminance": reader.Dict{"WhitePoint": nums(0.95, 0, 1.09)}, + "a point of names": reader.Dict{"WhitePoint": reader.Array{reader.Name("x"), reader.Name("y"), reader.Name("z")}}, + } { + got := calSpace(t, reader.Array{tc.name, d}) + if got.convert(tc.args) != tc.want.convert(tc.args) { + t.Errorf("%s: %s did not fall back to %s", label, tc.name, tc.want.name) + } + } + }) + } +} + +func TestACalibratedSpaceIgnoresAMalformedGammaOrMatrix(t *testing.T) { + // A gamma of zero or a matrix of the wrong length is not a space with an + // odd gamma, it is a file that got it wrong. The defaults stand, and the + // white point still applies. + base := calSpace(t, reader.Array{reader.Name("CalRGB"), reader.Dict{"WhitePoint": d65}}) + for label, d := range map[string]reader.Dict{ + "a gamma of zero": reader.Dict{"WhitePoint": d65, "Gamma": nums(0, 2.2, 2.2)}, + "a short gamma": reader.Dict{"WhitePoint": d65, "Gamma": nums(2.2, 2.2)}, + "a matrix of eight": reader.Dict{"WhitePoint": d65, "Matrix": nums(1, 0, 0, 0, 1, 0, 0, 0)}, + "a matrix of numbers and a name": reader.Dict{"WhitePoint": d65, + "Matrix": reader.Array{reader.Real(1), reader.Real(0), reader.Real(0), reader.Real(0), + reader.Real(1), reader.Real(0), reader.Real(0), reader.Real(0), reader.Name("one")}}, + } { + got := calSpace(t, reader.Array{reader.Name("CalRGB"), d}) + v := []float64{0.25, 0.5, 0.75} + if got.convert(v) != base.convert(v) { + t.Errorf("%s: the defaults were not kept (%v vs %v)", label, got.convert(v), base.convert(v)) + } + } + if got := calSpace(t, reader.Array{reader.Name("CalGray"), + reader.Dict{"WhitePoint": d65, "Gamma": reader.Real(0)}}).convert([]float64{0.5}); got != (color.RGBA{R: 188, G: 188, B: 188, A: 255}) { + t.Errorf("a CalGray gamma of zero was used: %v", got) + } +} + +// jpegInSpace draws one 8x8 JPEG of a single colour through the named colour +// space and returns the pixel it drew, so the tests below can say what the +// space did to the samples rather than what the dispatch thinks it did. +func jpegInSpace(t *testing.T, space reader.Object, src image.Image) color.RGBA { + t.Helper() + var buf bytes.Buffer + if err := jpeg.Encode(&buf, src, &jpeg.Options{Quality: 100}); err != nil { + t.Fatal(err) + } + w := reader.NewWriter("1.7") + pagesRef := w.Reserve() + img := w.Add(&reader.Stream{Dict: reader.Dict{ + "Type": reader.Name("XObject"), "Subtype": reader.Name("Image"), + "Width": reader.Integer(8), "Height": reader.Integer(8), + "ColorSpace": space, "BitsPerComponent": reader.Integer(8), + "Filter": reader.Name("DCTDecode")}, Raw: buf.Bytes()}) + pageRef := w.Add(reader.Dict{"Type": reader.Name("Page"), "Parent": pagesRef, + "MediaBox": nums(0, 0, 8, 8), + "Resources": reader.Dict{"XObject": reader.Dict{"I": img}}, + "Contents": w.Add(&reader.Stream{Dict: reader.Dict{}, + Raw: []byte("q 8 0 0 8 0 0 cm /I Do Q")})}) + w.Put(pagesRef, reader.Dict{"Type": reader.Name("Pages"), + "Kids": reader.Array{pageRef}, "Count": reader.Integer(1)}) + out, err := w.Finish(reader.Dict{"Root": w.Add(reader.Dict{ + "Type": reader.Name("Catalog"), "Pages": pagesRef})}) + if err != nil { + t.Fatal(err) + } + d, err := reader.Open(out) + if err != nil { + t.Fatal(err) + } + pic, err := Page(d, 1, Options{Scale: 1}) + if err != nil { + t.Fatal(err) + } + c := pic.At(4, 4) + r, g, b, a := c.RGBA() + return color.RGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8), A: uint8(a >> 8)} +} + +func TestAThreeComponentJPEGGoesThroughItsCalibratedSpace(t *testing.T) { + // The defect that made the library unreachable. jpegThroughSpace asked the + // colour space only for a ONE-component picture, so a three-component JPEG + // in a CalRGB space was drawn as though the space were DeviceRGB and the + // calibration never ran. On openpdf's PDF 2.0 test file that was worth a + // peak of 110 levels against poppler; opening this path took it to 20. + // A SATURATED colour, because that is where a calibrated space and its + // device namesake part company. A neutral barely moves -- gamma 2.2 + // decoding and the sRGB encoding very nearly cancel, and the white-point + // adaptation keeps a grey grey -- so a mid grey would pass this test + // whether the space was consulted or not. + src := image.NewRGBA(image.Rect(0, 0, 8, 8)) + for i := 0; i < len(src.Pix); i += 4 { + src.Pix[i], src.Pix[i+1], src.Pix[i+2], src.Pix[i+3] = 250, 113, 7, 255 + } + cal := jpegInSpace(t, reader.Array{reader.Name("CalRGB"), + reader.Dict{"WhitePoint": adobeWhiteD50, "Gamma": gammaTwoPointTwo, "Matrix": adobeMatrix}}, src) + dev := jpegInSpace(t, reader.Name("DeviceRGB"), src) + if cal == dev { + t.Fatalf("a CalRGB JPEG was drawn exactly as a DeviceRGB one (%v); the space was never asked", cal) + } + worst := 0 + for _, d := range [][2]uint8{{cal.R, dev.R}, {cal.G, dev.G}, {cal.B, dev.B}} { + e := int(d[0]) - int(d[1]) + if e < 0 { + e = -e + } + if e > worst { + worst = e + } + } + if worst < 20 { + t.Errorf("CalRGB = %v, DeviceRGB = %v, worst gap %d levels; want the calibration to show", + cal, dev, worst) + } + t.Logf("CalRGB %v vs DeviceRGB %v: %d levels", cal, dev, worst) +} + +func TestAGreyJPEGGoesThroughItsCalibratedSpace(t *testing.T) { + src := image.NewGray(image.Rect(0, 0, 8, 8)) + for i := range src.Pix { + src.Pix[i] = 128 + } + cal := jpegInSpace(t, reader.Array{reader.Name("CalGray"), + reader.Dict{"WhitePoint": d65}}, src) + dev := jpegInSpace(t, reader.Name("DeviceGray"), src) + if cal == dev { + t.Fatalf("a CalGray JPEG was drawn exactly as a DeviceGray one (%v)", cal) + } + if cal.R < 180 { + t.Errorf("CalGray mid tone = %v, want it near 188: the component is linear luminance", cal) + } +} + +func TestAJPEGWhoseSpaceDoesNotFitItsPlanesIsLeftAlone(t *testing.T) { + // A three-component JPEG declared in a one-component space, and a grey one + // declared in three: the sample count and the space disagree, so there is + // no honest way to put the samples through it. The picture is drawn from + // the decoder's own colours rather than dropped. + rgb := image.NewRGBA(image.Rect(0, 0, 8, 8)) + for i := 0; i < len(rgb.Pix); i += 4 { + rgb.Pix[i], rgb.Pix[i+1], rgb.Pix[i+2], rgb.Pix[i+3] = 200, 60, 60, 255 + } + got := jpegInSpace(t, reader.Array{reader.Name("CalGray"), reader.Dict{"WhitePoint": d65}}, rgb) + if got.A != 255 { + t.Errorf("a mismatched picture was dropped: %v", got) + } + grey := image.NewGray(image.Rect(0, 0, 8, 8)) + for i := range grey.Pix { + grey.Pix[i] = 128 + } + got = jpegInSpace(t, reader.Array{reader.Name("CalRGB"), + reader.Dict{"WhitePoint": d65, "Matrix": adobeMatrix}}, grey) + if got.A != 255 { + t.Errorf("a mismatched grey picture was dropped: %v", got) + } +} + +func TestACalibratedSpaceArrayWithNoDictionaryAtAll(t *testing.T) { + // [/CalRGB] with nothing after it. calDict has no second element to read. + for _, tc := range []struct { + name reader.Name + want *space + args []float64 + }{ + {"CalGray", deviceGray, []float64{0.5}}, + {"CalRGB", deviceRGB, []float64{0.5, 0.5, 0.5}}, + } { + got := calSpace(t, reader.Array{tc.name}) + if got.convert(tc.args) != tc.want.convert(tc.args) { + t.Errorf("%s alone did not fall back to %s", tc.name, tc.want.name) + } + } +} diff --git a/go.mod b/go.mod index b409434..76bf65f 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/go-pdfkit/render go 1.26.4 require ( - github.com/go-gfx/gfx v0.20.0 + github.com/go-gfx/gfx v0.22.0 github.com/go-opentype/fonts v0.9.0 github.com/go-opentype/opentype v0.12.0 github.com/go-pdfkit/reader v0.6.0 @@ -19,6 +19,6 @@ require ( github.com/sergeymakinen/go-bmp v1.0.0 // indirect github.com/sergeymakinen/go-ico v1.0.0 // indirect github.com/tannevaled/gobig2 v0.1.0 // indirect - golang.org/x/image v0.45.0 // indirect - golang.org/x/sys v0.47.0 // indirect + golang.org/x/image v0.46.0 // indirect + golang.org/x/sys v0.48.0 // indirect ) diff --git a/go.sum b/go.sum index 1eaa9f3..b2fac18 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ github.com/ajroetker/go-highway v0.0.4 h1:RDQo+9OhTXI6BFctLo+5gYpHNbb92VYJ0ObnR4 github.com/ajroetker/go-highway v0.0.4/go.mod h1:C/zYPNSSpOaraejY89FUTZTyQNEhi5+rEbU0LjlqJeU= github.com/ajroetker/go-jpeg2000 v0.0.2 h1:ni8brffZrci4Kacx3nM5d92ipmTDfak84KgHYi6IxFw= github.com/ajroetker/go-jpeg2000 v0.0.2/go.mod h1:7ld88W47lZy0x8gRQesRGAonDPOpr6ev8rckjCAfbzE= -github.com/go-gfx/gfx v0.20.0 h1:gZheZMfwkBm1mkcIqcAMiLwV/mZkkQxx7TfEJ1+6JB8= -github.com/go-gfx/gfx v0.20.0/go.mod h1:VAK6hgCgkhT3j3ek2K7G8zDfkCPD3RCF45UbcRlCV8Q= +github.com/go-gfx/gfx v0.22.0 h1:ZyATWI4zs9lM+GF7FSJqinsvYhMriqdU7miSFLaArok= +github.com/go-gfx/gfx v0.22.0/go.mod h1:5wUl8qCvaooyJ9Ly0oS2TLlawlvAp3kosrxh5kfxVTo= github.com/go-opentype/fonts v0.9.0 h1:slB6OB3riLyUPrOxqXe0s6/AzdenF1TDvCN8N87hhQk= github.com/go-opentype/fonts v0.9.0/go.mod h1:C6yQL2apHItfEZ5hztpsHF0S5mlX/hklLlq/Z5fRG/g= github.com/go-opentype/opentype v0.12.0 h1:wBlcDi+3ZaNZXEt5z+Ixr11/cYYwi5W+jX6yTl/qr1I= @@ -18,7 +18,7 @@ github.com/sergeymakinen/go-ico v1.0.0 h1:uL3khgvKkY6WfAetA+RqsguClBuu7HpvBB/nq/ github.com/sergeymakinen/go-ico v1.0.0/go.mod h1:wQ47mTczswBO5F0NoDt7O0IXgnV4Xy3ojrroMQzyhUk= github.com/tannevaled/gobig2 v0.1.0 h1:9PdMvmnmYQURlUF40zt8t35Wnrz3KhZrwfxgCMvQc04= github.com/tannevaled/gobig2 v0.1.0/go.mod h1:X0S1H+N35kg6zYgVYRqGMsGj3C35zs+iA2r1MXyPcV4= -golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0= -golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ= +golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= diff --git a/image.go b/image.go index 0233cb7..f782b44 100644 --- a/image.go +++ b/image.go @@ -362,37 +362,68 @@ func (r *renderer) jpegThroughSpace(dict reader.Dict, img image.Image, resources // would undo that. return cmykPicture(cm, w, h) } - g, ok := img.(*image.Gray) - if !ok { - return nil - } sp := r.colourSpace(dict.Get("ColorSpace"), resources, 0) - switch sp.name { - case "Separation", "DeviceN", "Indexed", "Lab": - default: - return nil - } - if sp.components != 1 { + if passesSamplesThrough(sp) { return nil } - // The same reading `samples` gives packed samples, over the one plane a - // grey JPEG has: a /Decode array still applies, and an Indexed space's - // samples are row numbers rather than fractions. + // The same reading `samples` gives packed samples: a /Decode array still + // applies, and an Indexed space's samples are row numbers rather than + // fractions. decode := r.decodeArray(dict, sp, 8) out := &sampled{w: w, h: h, pix: make([]uint8, w*h*4)} - comps := make([]float64, 1) - b := img.Bounds() - for y := 0; y < h; y++ { - for x := 0; x < w; x++ { - comps[0] = decode(0, uint32(g.GrayAt(b.Min.X+x, b.Min.Y+y).Y), 8) - col := sp.convert(comps) - i := (y*w + x) * 4 - out.pix[i], out.pix[i+1], out.pix[i+2], out.pix[i+3] = col.R, col.G, col.B, 255 + comps := make([]float64, sp.components) + switch { + case sp.components == 1: + g, ok := img.(*image.Gray) + if !ok { + return nil + } + b := img.Bounds() + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + comps[0] = decode(0, uint32(g.GrayAt(b.Min.X+x, b.Min.Y+y).Y), 8) + col := sp.convert(comps) + i := (y*w + x) * 4 + out.pix[i], out.pix[i+1], out.pix[i+2], out.pix[i+3] = col.R, col.G, col.B, 255 + } + } + case sp.components == 3: + // A three-component JPEG's samples are the space's three components, + // and the decoder has already turned the stored YCbCr back into them: + // that conversion is the JPEG's own, not the colour space's, and it + // runs first. What is left is to ask the space what the three numbers + // mean, which for a calibrated space is a gamma, a matrix and an + // adaptation rather than nothing at all. + src := jpegPixels(img) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + i := (y*w + x) * 4 + for c := 0; c < 3; c++ { + comps[c] = decode(c, uint32(src.Pix[i+c]), 8) + } + col := sp.convert(comps) + out.pix[i], out.pix[i+1], out.pix[i+2], out.pix[i+3] = col.R, col.G, col.B, 255 + } } + default: + return nil } return out } +// passesSamplesThrough reports a space that reads a picture's decoded samples +// as colour directly, so putting them through it would give back what went in. +// +// The four device spaces are package-level singletons and every path that +// yields one yields the same pointer -- byComponents for an ICCBased profile +// this package does not interpret, deviceSpace for a bare name -- so identity +// is the whole test. A calibrated space is NOT one of them even though it +// carries the same number of components under a similar name, which is the +// distinction this function exists to draw. +func passesSamplesThrough(sp *space) bool { + return sp == deviceGray || sp == deviceRGB || sp == deviceCMYK || sp == patternSpace +} + // decodeJPX reads a JPEG 2000 image, which is what a scanned page is stored in. // // Measured over a corpus of a thousand scanned documents: all 250 biodiversity diff --git a/space.go b/space.go index f92cc39..47519a9 100644 --- a/space.go +++ b/space.go @@ -129,9 +129,9 @@ func (r *renderer) colourSpaceArray(family reader.Name, arr reader.Array, resour } return deviceRGB case "CalRGB": - return deviceRGB + return r.calRGBSpace(arr) case "CalGray": - return deviceGray + return r.calGraySpace(arr) case "Lab": return labSpace() case "Indexed":