Files

421 lines
13 KiB
Go

package icc
import (
"encoding/binary"
"math"
"slices"
"strings"
"testing"
)
func TestSampleCurve(t *testing.T) {
t.Run("Identity", func(t *testing.T) {
c := Curve{Type: CurveIdentity}
for _, tt := range []float64{0, 0.25, 0.5, 0.75, 1.0} {
got := SampleCurve(c, tt)
if got != tt {
t.Errorf("SampleCurve(identity, %f) = %f, want %f", tt, got, tt)
}
}
})
t.Run("Parametric", func(t *testing.T) {
c := Curve{Type: CurveParametric, Gamma: 2.2}
if got := SampleCurve(c, 0.5); math.Abs(got-0.7297) > 0.001 {
t.Errorf("SampleCurve(gamma2.2, 0.5) = %f, want ~0.7297", got)
}
// Boundary conditions
if v := SampleCurve(c, 0); v != 0 {
t.Errorf("SampleCurve(gamma2.2, 0) = %f, want 0", v)
}
if v := SampleCurve(c, 1); v != 1 {
t.Errorf("SampleCurve(gamma2.2, 1) = %f, want 1", v)
}
})
t.Run("Table", func(t *testing.T) {
// Identity table: 2 entries [0, 65535]
c := Curve{
Type: CurveTable,
Entries: []uint16{0, 65535},
}
if v := SampleCurve(c, 0.5); math.Abs(v-0.5) > 0.001 {
t.Errorf("SampleCurve(identityTable, 0.5) = %f, want ~0.5", v)
}
// 4-entry table: [0, 21845, 43690, 65535] (approx 1/3, 2/3, 3/3)
c2 := Curve{
Type: CurveTable,
Entries: []uint16{0, 21845, 43690, 65535},
}
if v := SampleCurve(c2, 0); v != 0 {
t.Errorf("SampleCurve(4entry, 0) = %f, want 0", v)
}
if v := SampleCurve(c2, 1.0); math.Abs(v-1.0) > 0.001 {
t.Errorf("SampleCurve(4entry, 1.0) = %f, want ~1.0", v)
}
})
t.Run("ClampInput", func(t *testing.T) {
c := Curve{Type: CurveIdentity}
// Negative t should be clamped to 0
if v := SampleCurve(c, -0.5); v != 0 {
t.Errorf("SampleCurve(identity, -0.5) = %f, want 0", v)
}
// t > 1 should be clamped to 1
if v := SampleCurve(c, 1.5); v != 1 {
t.Errorf("SampleCurve(identity, 1.5) = %f, want 1", v)
}
})
}
func TestParseBytes(t *testing.T) {
t.Run("TooShort", func(t *testing.T) {
_, err := ParseBytes([]byte{0, 1, 2})
if err == nil {
t.Error("expected error for short data")
}
})
}
// --- synthetic profile tests (self-contained; also cover CI) ---
func putS15Fixed16(b []byte, off int, v float64) {
binary.BigEndian.PutUint32(b[off:], uint32(int32(math.Round(v*65536))))
}
func putU8Fixed8(b []byte, off int, v float64) {
binary.BigEndian.PutUint16(b[off:], uint16(math.Round(v*256)))
}
// buildSyntheticProfile builds a minimal but valid ICC v2 monitor profile with
// desc, rXYZ/gXYZ/bXYZ, rTRC/gTRC/bTRC (single gamma per channel), wtpt and a
// table vcgt tag, so parsing and ramp generation can be exercised without a
// vendor profile on disk.
func buildSyntheticProfile(desc string, gamma float64) []byte {
xyzTag := func(x, y, z float64) []byte {
d := make([]byte, 20)
copy(d[0:], "XYZ ")
putS15Fixed16(d, 8, x)
putS15Fixed16(d, 12, y)
putS15Fixed16(d, 16, z)
return d
}
curvTag := func(g float64) []byte {
d := make([]byte, 16)
copy(d[0:], "curv")
binary.BigEndian.PutUint32(d[8:], 1) // count=1 -> single gamma value
putU8Fixed8(d, 12, g)
return d
}
descTag := make([]byte, 12+len(desc)+1)
copy(descTag[0:], "desc")
binary.BigEndian.PutUint32(descTag[8:], uint32(len(desc)+1)) // length includes NUL
copy(descTag[12:], desc)
const vcgtEntries = 4
vcgtTag := make([]byte, 18+3*vcgtEntries*2)
copy(vcgtTag[0:], "vcgt")
binary.BigEndian.PutUint16(vcgtTag[12:], 3) // channels
binary.BigEndian.PutUint16(vcgtTag[14:], vcgtEntries) // entries per channel
binary.BigEndian.PutUint16(vcgtTag[16:], 2) // entry size in bytes
for ch := 0; ch < 3; ch++ {
for i := 0; i < vcgtEntries; i++ {
v := uint16(math.Round(65535.0 * float64(i) / float64(vcgtEntries-1)))
binary.BigEndian.PutUint16(vcgtTag[18+(ch*vcgtEntries+i)*2:], v)
}
}
type tag struct {
sig string
data []byte
}
tags := []tag{
{"desc", descTag},
{"rXYZ", xyzTag(0.4360, 0.2225, 0.0139)},
{"gXYZ", xyzTag(0.3851, 0.7169, 0.0971)},
{"bXYZ", xyzTag(0.1431, 0.0606, 0.7141)},
{"rTRC", curvTag(gamma)},
{"gTRC", curvTag(gamma)},
{"bTRC", curvTag(gamma)},
{"wtpt", xyzTag(0.9642, 1.0, 0.8249)},
{"vcgt", vcgtTag},
}
const headerSize = 128
tableSize := 4 + 12*len(tags)
total := headerSize + tableSize
for _, tg := range tags {
total += len(tg.data)
}
buf := make([]byte, total)
binary.BigEndian.PutUint32(buf[0:], uint32(total))
binary.BigEndian.PutUint32(buf[8:], 0x02100000) // version 2.1.0
copy(buf[12:], "mntr")
copy(buf[16:], "RGB ")
copy(buf[20:], "XYZ ")
binary.BigEndian.PutUint16(buf[24:], 2026)
binary.BigEndian.PutUint16(buf[26:], 9)
binary.BigEndian.PutUint16(buf[28:], 10)
copy(buf[36:], "acsp")
putS15Fixed16(buf, 68, 0.9642) // D50 illuminant
putS15Fixed16(buf, 72, 1.0)
putS15Fixed16(buf, 76, 0.8249)
binary.BigEndian.PutUint32(buf[128:], uint32(len(tags)))
off := headerSize + tableSize
for i, tg := range tags {
base := 132 + i*12
copy(buf[base:], tg.sig)
binary.BigEndian.PutUint32(buf[base+4:], uint32(off))
binary.BigEndian.PutUint32(buf[base+8:], uint32(len(tg.data)))
copy(buf[off:], tg.data)
off += len(tg.data)
}
return buf
}
func TestParseBytesSynthetic(t *testing.T) {
const desc = "Synthetic Test Display"
data := buildSyntheticProfile(desc, 2.2)
p, err := ParseBytes(data)
if err != nil {
t.Fatalf("ParseBytes failed: %v", err)
}
if p.Size != uint32(len(data)) {
t.Errorf("Size = %d, want %d", p.Size, len(data))
}
if p.Version != "2.1.0" {
t.Errorf("Version = %q, want %q", p.Version, "2.1.0")
}
if p.Class != "mntr" {
t.Errorf("Class = %q, want %q", p.Class, "mntr")
}
if p.ColorSpace != "RGB" {
t.Errorf("ColorSpace = %q, want %q", p.ColorSpace, "RGB")
}
if p.Description != desc {
t.Errorf("Description = %q, want %q", p.Description, desc)
}
if !p.HasMatrix {
t.Error("HasMatrix = false, want true")
}
if !p.HasTRC {
t.Error("HasTRC = false, want true")
}
if !p.HasVCGT {
t.Error("HasVCGT = false, want true")
}
if got := p.TRC[0].Gamma; math.Abs(got-2.2) > 0.01 {
t.Errorf("TRC[0].Gamma = %f, want ~2.2", got)
}
if got := p.WhitePoint[0]; math.Abs(got-0.9642) > 0.001 {
t.Errorf("WhitePoint X = %f, want ~0.9642", got)
}
}
func TestGenerateGammaRampSynthetic(t *testing.T) {
p, err := ParseBytes(buildSyntheticProfile("Synthetic Test Display", 2.2))
if err != nil {
t.Fatalf("ParseBytes failed: %v", err)
}
const size = 256
ramp, err := GenerateGammaRamp(size, p)
if err != nil {
t.Fatalf("GenerateGammaRamp failed: %v", err)
}
if len(ramp.Red) != size || len(ramp.Green) != size || len(ramp.Blue) != size {
t.Fatalf("ramp lengths = %d/%d/%d, want %d", len(ramp.Red), len(ramp.Green), len(ramp.Blue), size)
}
if ramp.Red[size-1] <= ramp.Red[0] {
t.Errorf("ramp not increasing: first=%d last=%d", ramp.Red[0], ramp.Red[size-1])
}
}
// A profile without a vcgt table has no video card gamma ramp: the TRC tags
// describe the display's own transfer function, so writing them into the GPU
// LUT washes the output out instead of calibrating it.
func TestGenerateGammaRampRequiresVCGT(t *testing.T) {
trcOnly := &Profile{
HasTRC: true,
HasVCGT: false,
TRC: [3]Curve{
{Type: CurveParametric, Gamma: 2.2},
{Type: CurveParametric, Gamma: 2.2},
{Type: CurveParametric, Gamma: 2.2},
},
}
if _, err := GenerateGammaRamp(256, trcOnly); err == nil {
t.Fatal("expected an error for a profile without a vcgt table")
}
// A vcgt tag with no channel data is unusable in the same way.
emptyVCGT := &Profile{HasVCGT: true}
if _, err := GenerateGammaRamp(256, emptyVCGT); err == nil {
t.Fatal("expected an error for a profile without a vcgt table")
}
// The ramp size has to be at least 2: the values are spread over size-1.
if _, err := GenerateGammaRamp(1, &Profile{HasVCGT: true, VCGT: &VCGT{Channels: 3, Entries: 4}}); err == nil {
t.Fatal("expected an error for a ramp size below 2")
}
}
// TestParseDescriptionMalformed feeds desc/mluc tags whose offsets or lengths
// point outside the buffer. These must return an error or a clamped string
// instead of a slice-bounds panic: `dms icc list` parses every file in the
// profile directory and the daemon parses user-picked files in-process, so a
// panic here takes the whole daemon down.
func TestParseDescriptionMalformed(t *testing.T) {
descSig := [4]byte{'d', 'e', 's', 'c'}
mlucSig := [4]byte{'m', 'l', 'u', 'c'}
t.Run("desc length wraps past end", func(t *testing.T) {
data := make([]byte, 32)
copy(data[0:], "desc")
// 12 + 0xFFFFFFF8 wraps to 4 in uint32 arithmetic.
binary.BigEndian.PutUint32(data[8:], 0xFFFFFFF8)
if _, err := parseDescription(data, tagEntry{sig: descSig, offset: 0, size: 32}); err != nil {
t.Fatalf("expected clamped string, got error %v", err)
}
})
t.Run("desc length past end", func(t *testing.T) {
data := make([]byte, 24)
copy(data[0:], "desc")
binary.BigEndian.PutUint32(data[8:], 4096)
desc, err := parseDescription(data, tagEntry{sig: descSig, offset: 0, size: 24})
if err != nil {
t.Fatalf("expected clamped string, got error %v", err)
}
if desc != "" {
t.Errorf("expected empty description, got %q", desc)
}
})
t.Run("mluc record header past end", func(t *testing.T) {
data := make([]byte, 16)
copy(data[0:], "mluc")
binary.BigEndian.PutUint32(data[8:], 1) // numRecords
binary.BigEndian.PutUint32(data[12:], 12) // recordSize
if _, err := parseDescription(data, tagEntry{sig: mlucSig, offset: 0, size: 16}); err == nil {
t.Fatal("expected error for truncated mluc record header")
}
})
t.Run("mluc string offset past end", func(t *testing.T) {
data := make([]byte, 32)
copy(data[0:], "mluc")
binary.BigEndian.PutUint32(data[8:], 1) // numRecords
binary.BigEndian.PutUint32(data[12:], 12) // recordSize
binary.BigEndian.PutUint32(data[20:], 4) // string length
binary.BigEndian.PutUint32(data[24:], 0xFFFFFF00)
if _, err := parseDescription(data, tagEntry{sig: mlucSig, offset: 0, size: 32}); err == nil {
t.Fatal("expected error for out-of-bounds mluc string")
}
})
}
// TestParseBytesTagTableBounds feeds tag counts that do not fit in the file.
// The count sizes a slice before any entry is read, so a wrapping count has to
// be rejected up front: with tagCount*12 wrapping in uint32, a 140-byte profile
// passed the old bounds check, allocated a ~4 GB tag slice and then panicked
// with slice bounds out of range. ParseFile runs on the wayland actor goroutine,
// which has no recover(), so that panic took the dms daemon down at startup.
func TestParseBytesTagTableBounds(t *testing.T) {
t.Run("count wraps in uint32 arithmetic", func(t *testing.T) {
data := buildSyntheticProfile("Wrapping tag count", 2.2)
binary.BigEndian.PutUint32(data[128:132], 0x15555556) // *12 == 8
_, err := ParseBytes(data)
if err == nil {
t.Fatal("expected error for a tag count that does not fit in the profile")
}
if !strings.Contains(err.Error(), "tag entries") {
t.Fatalf("ParseBytes() error = %v, want a tag entry count error", err)
}
})
t.Run("count exceeds remaining bytes", func(t *testing.T) {
data := buildSyntheticProfile("Too many tags", 2.2)
binary.BigEndian.PutUint32(data[128:132], uint32((len(data)-132)/12+1))
_, err := ParseBytes(data)
if err == nil {
t.Fatal("expected error for more tag entries than the profile can hold")
}
if !strings.Contains(err.Error(), "tag entries") {
t.Fatalf("ParseBytes() error = %v, want a tag entry count error", err)
}
})
t.Run("count filling the file exactly still parses", func(t *testing.T) {
data := buildSyntheticProfile("Exact fit", 2.2)
binary.BigEndian.PutUint32(data[128:132], uint32((len(data)-132)/12))
if _, err := ParseBytes(data); err != nil {
t.Fatalf("ParseBytes() = %v, want the boundary count accepted", err)
}
})
}
func vcgtTableTag(channels int, entries []uint16) []byte {
tag := make([]byte, 18+channels*len(entries)*2)
copy(tag[0:], "vcgt")
binary.BigEndian.PutUint16(tag[12:], uint16(channels))
binary.BigEndian.PutUint16(tag[14:], uint16(len(entries)))
binary.BigEndian.PutUint16(tag[16:], 2)
for ch := 0; ch < channels; ch++ {
for i, v := range entries {
binary.BigEndian.PutUint16(tag[18+(ch*len(entries)+i)*2:], v)
}
}
return tag
}
func TestParseVCGTChannelCount(t *testing.T) {
entries := []uint16{0, 21845, 43690, 65535}
t.Run("one channel is replicated to all three", func(t *testing.T) {
tag := vcgtTableTag(1, entries)
vcgt, err := parseVCGT(tag, tagEntry{offset: 0, size: uint32(len(tag))})
if err != nil {
t.Fatalf("parseVCGT: %v", err)
}
if !slices.Equal(vcgt.Red, entries) {
t.Fatalf("red = %v, want %v", vcgt.Red, entries)
}
if !slices.Equal(vcgt.Green, entries) || !slices.Equal(vcgt.Blue, entries) {
t.Fatalf("green/blue must copy the single curve: green=%v blue=%v", vcgt.Green, vcgt.Blue)
}
})
t.Run("three channels are read independently", func(t *testing.T) {
tag := vcgtTableTag(3, entries)
vcgt, err := parseVCGT(tag, tagEntry{offset: 0, size: uint32(len(tag))})
if err != nil {
t.Fatalf("parseVCGT: %v", err)
}
if !slices.Equal(vcgt.Blue, entries) {
t.Fatalf("blue = %v, want %v", vcgt.Blue, entries)
}
})
t.Run("two channels are rejected", func(t *testing.T) {
tag := vcgtTableTag(2, entries)
if _, err := parseVCGT(tag, tagEntry{offset: 0, size: uint32(len(tag))}); err == nil {
t.Fatal("expected an error for a 2-channel vcgt")
}
})
}