From ccf1acb5ae9cd4ad6f3046a22260a9cefa00ac07 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Thu, 10 Sep 2026 22:04:15 +0200 Subject: [PATCH] Read CalGray and CalRGB, and let a JPEG reach its colour space Two defects, and the second is what made the first unreachable. CalGray and CalRGB were read as DeviceGray and DeviceRGB. They are not: each says what CIE XYZ its numbers stand for under a white point of its own, so reaching sRGB is a gamma, a matrix and a chromatic adaptation. With the gamma the format defaults to, a CalGray component is linear luminance and a mid grey encodes to 188 rather than 128. calibrated.go reads the two dictionaries and gfx/color v0.22.0 does the arithmetic. jpegThroughSpace then asked the colour space only for a ONE-component picture in one of four named families, so a three-component JPEG in a calibrated space was drawn from the decoder's own colours and the space was never consulted. The rule is now what it should always have been: a space that is not one of the four device singletons has something to say about the samples, whatever their count. Measured on openpdf's PDF 2.0 test file, whose two pictures are the SAME 1466-byte JPEG stream drawn once through a CalRGB and once through DeviceRGB, and whose own page text says they must differ: peak 110 -> 20 share 0.5764 -> 0.0098 mse 909.34 -> 0.30 The 20 that remains is not colour. Our conversion applied to poppler's own decoded samples disagrees with poppler by ONE level; but a sample error of +/-3, which is exactly what the direct bucket measures for this same stream, moves the answer by up to 35 levels once a gamma of 2.2 and an Adobe RGB matrix have amplified it. A calibrated space magnifies a decoder disagreement, which is worth knowing before reading any per-channel bound. Lab is deliberately left as it was. poppler's GfxLabColorSpace does not multiply by the white point where ISO 32000-2 8.6.5.4 says to, so a correct Lab and the judge would disagree for a reason that is not ours; that wants an experiment of its own. 12 of 2268 form documents mention /Lab, none of the 1013 scans. --- calibrated.go | 100 +++++++++++++++ calibrated_test.go | 305 +++++++++++++++++++++++++++++++++++++++++++++ go.mod | 6 +- go.sum | 12 +- image.go | 73 +++++++---- space.go | 4 +- 6 files changed, 468 insertions(+), 32 deletions(-) create mode 100644 calibrated.go create mode 100644 calibrated_test.go 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":