Skip to content
Merged
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
55 changes: 47 additions & 8 deletions calibrated.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,18 @@ import (
"image/color"
)

// This file reads the two calibrated spaces: CalGray and CalRGB. Each
// This file reads the three CIE-based spaces: CalGray, CalRGB and Lab. 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.
// reaching CIE XYZ, so none of them is its device namesake and none 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.
// Lab is the third and is here too. It was left out at first on the strength
// of a misreading: GfxLabColorSpace::getXYZ does not multiply by the white
// point where ISO 32000-2 8.6.5.4 says to, which looked like poppler
// disagreeing with the format. It does not -- ::getRGB multiplies immediately
// after calling it. A four-pixel Lab document run through pdfimages settles
// it: the specification's formula matches 4 of 4 pixels within one level, and
// the no-white-point formula is 13 levels out on a neutral mid tone.
//
// The arithmetic lives in gfx/color; what is here is reading a dictionary and
// carrying its defaults.
Expand Down Expand Up @@ -98,3 +100,40 @@ func (r *renderer) calRGBSpace(arr reader.Array) *space {
return color.RGBA{R: byteOf(red), G: byteOf(green), B: byteOf(blue), A: 255}
}}
}

// labRangeDefault is the default /Range: the two opponent axes run from -100
// to 100 unless the space narrows them. Lightness is always 0 to 100 and is
// not part of /Range.
var labRangeDefault = [4]float64{-100, 100, -100, 100}

// labSpace reads a Lab space: a white point and the range of its two opponent
// axes.
//
// The range is carried on the space because an image decodes against it. Lab
// is the one space in the format whose default /Decode is not [0 1] per
// component: it is [0 100 amin amax bmin bmax], so an image that gives no
// /Decode of its own would otherwise have its lightness read as a hundredth of
// what it says.
func (r *renderer) labSpace(arr reader.Array) *space {
d := r.calDict(arr)
white, ok := r.calWhitePoint(d)
if !ok {
// Lab has no device namesake to fall back to, so unlike CalGray and
// CalRGB there is nothing to decline into. (1, 1, 1) is the equal-
// energy point, which makes the white-point multiplication the
// identity -- the most conservative reading of a file that said
// nothing -- and it is also what GfxLabColorSpace's constructor uses
// when /WhitePoint is missing, so a malformed file is read the same
// way by both.
white = gfxcolor.WhitePoint{X: 1, Y: 1, Z: 1}
}
rng := labRangeDefault
if v, ok := r.calFloats(d, "Range", 4); ok && v[0] <= v[1] && v[2] <= v[3] {
copy(rng[:], v)
}
return &space{name: "Lab", components: 3, labRange: &rng, convert: func(v []float64) color.RGBA {
red, green, blue := gfxcolor.LabToSRGBWP(
gfxcolor.Lab{L: at(v, 0), A: at(v, 1), B: at(v, 2)}, white)
return color.RGBA{R: byteOf(red), G: byteOf(green), B: byteOf(blue), A: 255}
}}
}
100 changes: 100 additions & 0 deletions calibrated_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -303,3 +303,103 @@ func TestACalibratedSpaceArrayWithNoDictionaryAtAll(t *testing.T) {
}
}
}

// labImage draws a 1x1 Lab image of one sample triple through the given space
// array and returns the pixel. Lab is the one space whose samples do not
// decode over [0, 1], so an image is the only way to exercise that.
func labImage(t *testing.T, spaceArr reader.Object, decode reader.Object, samples []byte) color.RGBA {
t.Helper()
w := reader.NewWriter("1.7")
pagesRef := w.Reserve()
dict := reader.Dict{
"Type": reader.Name("XObject"), "Subtype": reader.Name("Image"),
"Width": reader.Integer(1), "Height": reader.Integer(1),
"ColorSpace": spaceArr, "BitsPerComponent": reader.Integer(8)}
if decode != nil {
dict["Decode"] = decode
}
img := w.Add(&reader.Stream{Dict: dict, Raw: samples})
pageRef := w.Add(reader.Dict{"Type": reader.Name("Page"), "Parent": pagesRef,
"MediaBox": nums(0, 0, 1, 1),
"Resources": reader.Dict{"XObject": reader.Dict{"I": img}},
"Contents": w.Add(&reader.Stream{Dict: reader.Dict{},
Raw: []byte("q 1 0 0 1 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)
}
r, g, b, a := pic.At(0, 0).RGBA()
return color.RGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8), A: uint8(a >> 8)}
}

func TestALabImageWithNoDecodeArrayUsesLabsOwnDefault(t *testing.T) {
// Lab is the one space in the format whose default /Decode is not [0 1]
// per component: it is [0 100 amin amax bmin bmax]. Without that, a
// lightness of 50 would be read as 0.5 and the picture would be black.
space := reader.Array{reader.Name("Lab"), reader.Dict{"WhitePoint": d65}}
// 0x80 0x99 0x59 decodes to L=50.2, a=20.0, b=-30.2 under the default.
samples := []byte{0x80, 0x99, 0x59}
implied := labImage(t, space, nil, samples)
spelled := labImage(t, space, nums(0, 100, -100, 100, -100, 100), samples)
if implied != spelled {
t.Errorf("the implied default (%v) and the same array written out (%v) differ", implied, spelled)
}
if implied.R < 100 || implied.B < 100 {
t.Errorf("a mid-lightness Lab sample came out as %v; the decode was read over [0, 1]", implied)
}
}

func TestALabSpaceNarrowsItsAxesWithRange(t *testing.T) {
// /Range moves what a sample means, so the same bytes name a different
// colour. A malformed range -- a maximum below its minimum -- is not a
// narrower space, it is a file that got it wrong, and the default stands.
wide := reader.Array{reader.Name("Lab"), reader.Dict{"WhitePoint": d65}}
narrow := reader.Array{reader.Name("Lab"),
reader.Dict{"WhitePoint": d65, "Range": nums(-20, 20, -20, 20)}}
backwards := reader.Array{reader.Name("Lab"),
reader.Dict{"WhitePoint": d65, "Range": nums(20, -20, -20, 20)}}
samples := []byte{0x80, 0xff, 0x00}
a, b, c := labImage(t, wide, nil, samples), labImage(t, narrow, nil, samples), labImage(t, backwards, nil, samples)
if a == b {
t.Errorf("narrowing /Range changed nothing: %v", a)
}
if c != a {
t.Errorf("a backwards /Range was used (%v); the default should stand (%v)", c, a)
}
}

func TestALabSpaceWithoutAWhitePointUsesTheEqualEnergyPoint(t *testing.T) {
// Lab has no device namesake to decline into. (1, 1, 1) makes the
// white-point multiplication the identity and is what poppler's own
// constructor uses, so a malformed file reads the same way in both.
// poppler draws Lab(50, 20, -30) in such a space as (131, 109, 171).
got := labImage(t, reader.Array{reader.Name("Lab"), reader.Dict{}}, nil, []byte{0x80, 0x99, 0x59})
for i, pair := range [][2]uint8{{got.R, 131}, {got.G, 109}, {got.B, 171}} {
if d := int(pair[0]) - int(pair[1]); d > 2 || d < -2 {
t.Errorf("channel %d = %d, poppler says %d", i, pair[0], pair[1])
}
}
}

func TestADecodeArrayWithSomethingThatIsNotANumber(t *testing.T) {
// A /Decode entry that is a name rather than a number: the array cannot be
// read, so the samples fall back to fractions of their range.
space := reader.Array{reader.Name("Lab"), reader.Dict{"WhitePoint": d65}}
bad := reader.Array{reader.Real(0), reader.Real(100), reader.Real(-100), reader.Real(100),
reader.Real(-100), reader.Name("oops")}
got := labImage(t, space, bad, []byte{0x80, 0x99, 0x59})
if got.A != 255 {
t.Errorf("a malformed /Decode dropped the picture: %v", got)
}
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module github.com/go-pdfkit/render
go 1.26.4

require (
github.com/go-gfx/gfx v0.22.0
github.com/go-gfx/gfx v0.23.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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ github.com/ajroetker/go-jpeg2000 v0.0.2 h1:ni8brffZrci4Kacx3nM5d92ipmTDfak84KgHY
github.com/ajroetker/go-jpeg2000 v0.0.2/go.mod h1:7ld88W47lZy0x8gRQesRGAonDPOpr6ev8rckjCAfbzE=
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-gfx/gfx v0.23.0 h1:PdxREtW4NrMYEGzcnbILCLnE3xNwReXU4bFxQ02lKO4=
github.com/go-gfx/gfx v0.23.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=
Expand Down
12 changes: 12 additions & 0 deletions image.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,18 @@ func (r *renderer) decodeArray(dict reader.Dict, sp *space, bpc int) func(c int,
// An indexed image's samples are row numbers, not fractions.
return func(_ int, raw uint32, _ int) float64 { return float64(raw) }
}
if sp.labRange != nil {
// Lab is the one space whose default decode is not [0 1] per
// component: lightness runs to 100 and the two opponent axes over
// the space's own /Range.
// Three components, and the caller only ever asks for one of
// them: decodeArray is called with the space's own count.
lo := [3]float64{0, sp.labRange[0], sp.labRange[2]}
hi := [3]float64{100, sp.labRange[1], sp.labRange[3]}
return func(c int, raw uint32, _ int) float64 {
return lo[c] + float64(raw)*(hi[c]-lo[c])/maxValue
}
}
return func(_ int, raw uint32, _ int) float64 { return float64(raw) / maxValue }
}
bounds := make([]float64, 2*sp.components)
Expand Down
16 changes: 5 additions & 11 deletions space.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ type space struct {
// under, for a Pattern space, is the space an uncoloured pattern's own
// colour is given in.
under *space
// labRange, for a Lab space, is the range of its two opponent axes. It is
// kept because an image with no /Decode array of its own decodes against
// it; see decodeArray.
labRange *[4]float64
}

// The device spaces, which every file may use without saying anything first.
Expand Down Expand Up @@ -133,7 +137,7 @@ func (r *renderer) colourSpaceArray(family reader.Name, arr reader.Array, resour
case "CalGray":
return r.calGraySpace(arr)
case "Lab":
return labSpace()
return r.labSpace(arr)
case "Indexed":
return r.indexedSpace(arr, resources, depth)
case "Separation", "DeviceN":
Expand Down Expand Up @@ -179,16 +183,6 @@ func byComponents(n int) *space {
return deviceRGB
}

// labSpace reads three numbers as lightness and two opponent axes. Only the
// lightness is used, which is a grey of the right weight rather than the right
// colour — enough not to lose the mark, and honest about what it is.
func labSpace() *space {
return &space{name: "Lab", components: 3, convert: func(v []float64) color.RGBA {
g := byteOf(at(v, 0) / 100)
return color.RGBA{R: g, G: g, B: g, A: 255}
}}
}

// indexedSpace reads one number as a row of a table of colours.
func (r *renderer) indexedSpace(arr reader.Array, resources reader.Dict, depth int) *space {
if len(arr) < 4 {
Expand Down
7 changes: 6 additions & 1 deletion space_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@ func TestNamedColourSpaces(t *testing.T) {
// Full key ink on paper, which is not absolute black: (35,31,32) is
// the SWOP key primary, and it is what poppler draws for 0 0 0 1 too.
{"the device spaces by array", reader.Array{reader.Name("DeviceCMYK")}, "0 0 0 1 sc", color.RGBA{35, 31, 32, 255}},
{"lightness and two axes", reader.Array{reader.Name("Lab"), reader.Dict{}}, "50 20 -30 sc", color.RGBA{128, 128, 128, 255}},
// A positive a* is toward magenta and a negative b* toward blue, so
// this is a mid purple and NOT the grey of the same lightness the old
// stand-in drew. The value is poppler's: a one-pixel Lab image of
// exactly this colour, in a space with no /WhitePoint just as here,
// comes out of pdfimages as (131, 109, 171).
{"lightness and two axes", reader.Array{reader.Name("Lab"), reader.Dict{}}, "50 20 -30 sc", color.RGBA{131, 109, 171, 255}},
{"a spot colour at full strength", reader.Array{reader.Name("Separation"), reader.Name("Spot"),
reader.Name("DeviceGray"), reader.Dict{}}, "1 sc", color.RGBA{0, 0, 0, 255}},
{"a spot colour at none", reader.Array{reader.Name("Separation"), reader.Name("Spot"),
Expand Down