Files

815 lines
36 KiB
Odin
Raw Permalink Normal View History

2026-09-25 23:32:29 +02:00
package pdf
import "core:testing"
// The fixtures -- `cf_open`, `cf_parse`, `cf_stream`, `cf_bytes` -- live in
// function_test.odin, because a colour space is not testable without the
// functions its tint transforms are made of.
@(private = "file")
TOL :: 1e-9
@(private = "file")
expect_rgb :: proc(t: ^testing.T, got: [3]f64, r, g, b: f64, tol: f64, what: string,
loc := #caller_location) {
if !(abs(got[0] - r) <= tol && abs(got[1] - g) <= tol && abs(got[2] - b) <= tol) {
testing.expectf(t, false, "%s: got (%f %f %f), want (%f %f %f)",
what, got[0], got[1], got[2], r, g, b, loc = loc)
}
}
@(private = "file")
load_cs :: proc(t: ^testing.T, doc: ^Document, src: string, res: ^Dict = nil) -> ^Colorspace {
cs, err := colorspace_load(doc, cf_parse(doc, src), res)
testing.expect_value(t, err, Error.None)
return cs
}
@(private = "file")
cf_array :: proc(doc: ^Document, items: ..Object) -> ^Array {
a := new_array(doc.allocator, max(len(items), 1))
for it in items {
array_push(a, it)
}
return a
}
// ---------------------------------------------------------------------------
// The device spaces
// ---------------------------------------------------------------------------
@(test)
test_colorspace_device_gray :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
cs := colorspace_device_gray(doc)
testing.expect_value(t, cs.kind, Colorspace_Kind.Device_Gray)
testing.expect_value(t, cs.n, 1)
expect_rgb(t, colorspace_to_rgb(cs, color_make(0)), 0, 0, 0, TOL, "black")
expect_rgb(t, colorspace_to_rgb(cs, color_make(1)), 1, 1, 1, TOL, "white")
expect_rgb(t, colorspace_to_rgb(cs, color_make(0.25)), 0.25, 0.25, 0.25, TOL, "quarter")
// Components outside 0..1 are clamped, not wrapped.
expect_rgb(t, colorspace_to_rgb(cs, color_make(2)), 1, 1, 1, TOL, "too bright")
expect_rgb(t, colorspace_to_rgb(cs, color_make(-1)), 0, 0, 0, TOL, "negative")
// Naming it produces the very same space, not a copy.
named := load_cs(t, doc, "/DeviceGray")
testing.expect(t, named == cs, "/DeviceGray must be the shared space")
testing.expect(t, load_cs(t, doc, "[/DeviceGray]") == cs, "and so must [/DeviceGray]")
testing.expect(t, load_cs(t, doc, "/G") == cs, "and the inline-image abbreviation")
}
@(test)
test_colorspace_device_rgb :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
cs := colorspace_device_rgb(doc)
testing.expect_value(t, cs.n, 3)
expect_rgb(t, colorspace_to_rgb(cs, color_make(0, 0, 0)), 0, 0, 0, TOL, "black")
expect_rgb(t, colorspace_to_rgb(cs, color_make(1, 1, 1)), 1, 1, 1, TOL, "white")
expect_rgb(t, colorspace_to_rgb(cs, color_make(1, 0.5, 0)), 1, 0.5, 0, TOL, "orange")
// A colour with too few components reads the missing ones as zero.
expect_rgb(t, colorspace_to_rgb(cs, color_make(1)), 1, 0, 0, TOL, "short colour")
testing.expect(t, load_cs(t, doc, "/DeviceRGB") == cs, "/DeviceRGB")
testing.expect(t, load_cs(t, doc, "/RGB") == cs, "/RGB abbreviation")
}
// The naive conversion: subtract each ink and the black from full intensity.
// It is what every non-colour-managed viewer does.
@(test)
test_colorspace_device_cmyk :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
cs := colorspace_device_cmyk(doc)
testing.expect_value(t, cs.n, 4)
expect_rgb(t, colorspace_to_rgb(cs, color_make(0, 0, 0, 0)), 1, 1, 1, TOL, "no ink is white")
expect_rgb(t, colorspace_to_rgb(cs, color_make(0, 0, 0, 1)), 0, 0, 0, TOL, "black ink")
expect_rgb(t, colorspace_to_rgb(cs, color_make(1, 0, 0, 0)), 0, 1, 1, TOL, "cyan")
expect_rgb(t, colorspace_to_rgb(cs, color_make(0, 1, 0, 0)), 1, 0, 1, TOL, "magenta")
expect_rgb(t, colorspace_to_rgb(cs, color_make(0, 0, 1, 0)), 1, 1, 0, TOL, "yellow")
expect_rgb(t, colorspace_to_rgb(cs, color_make(0, 1, 1, 0)), 1, 0, 0, TOL, "red")
// Half a cyan under a quarter black: 1 - (0.5 + 0.25) against 1 - 0.25.
expect_rgb(t, colorspace_to_rgb(cs, color_make(0.5, 0, 0, 0.25)),
0.25, 0.75, 0.75, TOL, "ink plus black")
// Ink and black together past 1 saturate rather than going negative.
expect_rgb(t, colorspace_to_rgb(cs, color_make(1, 1, 1, 1)), 0, 0, 0, TOL, "everything")
testing.expect(t, load_cs(t, doc, "/DeviceCMYK") == cs, "/DeviceCMYK")
testing.expect(t, load_cs(t, doc, "/CMYK") == cs, "/CMYK abbreviation")
}
// ---------------------------------------------------------------------------
// CalGray and CalRGB
// ---------------------------------------------------------------------------
// With the default /Gamma these behave exactly as the device spaces, which is
// what the format permits and what viewers do.
@(test)
test_colorspace_cal_defaults_to_device :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
gray := load_cs(t, doc, "[/CalGray << /WhitePoint [0.9505 1 1.089] >>]")
if gray == nil { return }
testing.expect_value(t, gray.kind, Colorspace_Kind.Cal_Gray)
testing.expect_value(t, gray.n, 1)
expect_rgb(t, colorspace_to_rgb(gray, color_make(0.25)), 0.25, 0.25, 0.25, TOL, "gamma 1 gray")
rgb := load_cs(t, doc, "[/CalRGB << /WhitePoint [0.9505 1 1.089] >>]")
if rgb == nil { return }
testing.expect_value(t, rgb.kind, Colorspace_Kind.Cal_RGB)
testing.expect_value(t, rgb.n, 3)
expect_rgb(t, colorspace_to_rgb(rgb, color_make(1, 0.5, 0)), 1, 0.5, 0, TOL, "gamma 1 rgb")
// A white point that would divide by zero is refused; the space still
// loads, with the format's default.
bad := load_cs(t, doc, "[/CalGray << /WhitePoint [0 0 0] /Gamma 1 >>]")
if bad != nil {
testing.expect(t, bad.white_point[0] > 0, "a zero white point is replaced")
}
}
// /Gamma is honoured by decoding to linear light and re-encoding for sRGB. At
// the usual 2.2 that round trip lands within half a level of 255 of leaving
// the value alone, so the common case still agrees with every other viewer.
@(test)
test_colorspace_calgray_gamma :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
cs := load_cs(t, doc, "[/CalGray << /WhitePoint [0.9505 1 1.089] /Gamma 2.2 >>]")
if cs == nil { return }
expect_rgb(t, colorspace_to_rgb(cs, color_make(0)), 0, 0, 0, TOL, "black stays black")
expect_rgb(t, colorspace_to_rgb(cs, color_make(1)), 1, 1, 1, TOL, "white stays white")
expect_rgb(t, colorspace_to_rgb(cs, color_make(0.5)),
0.5038667822745281, 0.5038667822745281, 0.5038667822745281, 1e-9,
"gamma 2.2 round trip at 0.5")
expect_rgb(t, colorspace_to_rgb(cs, color_make(0.25)),
0.2410493652415971, 0.2410493652415971, 0.2410493652415971, 1e-9,
"gamma 2.2 round trip at 0.25")
// Which is to say: within one level of 255 of the DeviceGray answer.
got := colorspace_to_rgb(cs, color_make(0.5))
testing.expect(t, abs(got[0] - 0.5) < 1.0 / 255, "still what a viewer shows")
// A per-component /Gamma on CalRGB.
rgb := load_cs(t, doc,
"[/CalRGB << /WhitePoint [0.9505 1 1.089] /Gamma [2.2 1 2.2] >>]")
if rgb == nil { return }
out := colorspace_to_rgb(rgb, color_make(0.5, 0.5, 0.5))
testing.expect(t, abs(out[0] - 0.5038667822745281) < 1e-9, "red honours its gamma")
testing.expect(t, abs(out[1] - 0.5) < 1e-12, "green is left alone at gamma 1")
testing.expect(t, abs(out[2] - 0.5038667822745281) < 1e-9, "blue honours its gamma")
}
// ---------------------------------------------------------------------------
// Lab
//
// The reference values are the published sRGB primaries expressed in L*a*b*
// under a D50 white point, which is the white point PDF Lab spaces almost
// always carry. Getting back to within a level of 255 of pure red, green and
// blue is what says the conversion -- including the Bradford adaptation from
// D50 to sRGB's D65 -- is right.
// ---------------------------------------------------------------------------
@(test)
test_colorspace_lab_known_colours :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
cs := load_cs(t, doc,
"[/Lab << /WhitePoint [0.9642 1 0.8249] /Range [-128 127 -128 127] >>]")
if cs == nil { return }
testing.expect_value(t, cs.kind, Colorspace_Kind.Lab)
testing.expect_value(t, cs.n, 3)
expect_rgb(t, colorspace_to_rgb(cs, color_make(100, 0, 0)), 1, 1, 1, 1e-6, "L* 100 is white")
expect_rgb(t, colorspace_to_rgb(cs, color_make(0, 0, 0)), 0, 0, 0, 1e-9, "L* 0 is black")
// L* 53.585 is the mid grey of sRGB: 128/255.
expect_rgb(t, colorspace_to_rgb(cs, color_make(53.5850, 0, 0)),
128.0 / 255, 128.0 / 255, 128.0 / 255, 1e-5, "L* 53.585 is sRGB 128")
expect_rgb(t, colorspace_to_rgb(cs, color_make(54.2917, 80.8125, 69.8911)),
1, 0, 0, 0.002, "sRGB red")
expect_rgb(t, colorspace_to_rgb(cs, color_make(87.8181, -79.2718, 80.9946)),
0, 1, 0, 0.002, "sRGB green")
expect_rgb(t, colorspace_to_rgb(cs, color_make(29.5676, 68.2986, -112.0294)),
0, 0, 1, 0.002, "sRGB blue")
}
@(test)
test_colorspace_lab_d65_and_clamping :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
// The same conversion under a D65 white point, where sRGB red has
// different coordinates.
d65 := load_cs(t, doc, "[/Lab << /WhitePoint [0.9505 1 1.089] >>]")
if d65 == nil { return }
expect_rgb(t, colorspace_to_rgb(d65, color_make(53.2408, 80.0925, 67.2032)),
1, 0, 0, 0.002, "sRGB red under D65")
expect_rgb(t, colorspace_to_rgb(d65, color_make(100, 0, 0)), 1, 1, 1, 1e-6, "white under D65")
// /Range clamps a and b before anything else happens, so a wild value
// cannot walk out of the gamut and take the whole colour with it.
narrow := load_cs(t, doc,
"[/Lab << /WhitePoint [0.9642 1 0.8249] /Range [-10 10 -10 10] >>]")
if narrow == nil { return }
testing.expect_value(t, narrow.range[0], -10)
a := colorspace_to_rgb(narrow, color_make(50, 100, 0))
b := colorspace_to_rgb(narrow, color_make(50, 10, 0))
expect_rgb(t, a, b[0], b[1], b[2], 1e-12, "a is clamped to /Range")
// L* outside 0..100 is clamped too.
hi := colorspace_to_rgb(narrow, color_make(500, 0, 0))
expect_rgb(t, hi, 1, 1, 1, 1e-6, "L* above 100")
lo := colorspace_to_rgb(narrow, color_make(-500, 0, 0))
expect_rgb(t, lo, 0, 0, 0, 1e-9, "L* below 0")
}
// ---------------------------------------------------------------------------
// ICCBased
// ---------------------------------------------------------------------------
@(test)
test_colorspace_iccbased :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
// A colour space is a name or an array, never a bare stream: ICCBased is
// always written [/ICCBased stream] (ISO 32000-2, 8.6.5.5). Handing the
// stream over on its own must be refused rather than guessed at, or a
// stream that happens to carry an /N would be read as a colour space
// wherever one is expected.
_, bare_err := colorspace_load(doc, cf_stream(doc, "<< /N 3 >>", nil), nil)
testing.expect_value(t, bare_err, Error.Bad_Colorspace)
// No /Alternate: the device space /N implies.
icc3_arr := cf_array(doc, Name("ICCBased"), cf_stream(doc, "<< /N 3 >>", nil))
icc3, icc3_err := colorspace_load(doc, icc3_arr, nil)
testing.expect_value(t, icc3_err, Error.None)
if icc3 == nil {
testing.expect(t, false, "an ICCBased space with /N 3 must load")
return
}
testing.expect_value(t, icc3.n, 3)
expect_rgb(t, colorspace_to_rgb(icc3, color_make(1, 0, 0)), 1, 0, 0, TOL,
"three components render as RGB")
// The array form is what a file actually writes; the stream alone is what
// cf_stream can build, so both are exercised.
arr := cf_array(doc, Name("ICCBased"), cf_stream(doc, "<< /N 4 >>", nil))
icc4, err4 := colorspace_load(doc, arr, nil)
testing.expect_value(t, err4, Error.None)
if icc4 == nil { return }
testing.expect_value(t, icc4.kind, Colorspace_Kind.ICC_Based)
testing.expect_value(t, icc4.n, 4)
expect_rgb(t, colorspace_to_rgb(icc4, color_make(0, 1, 1, 0)), 1, 0, 0, TOL,
"four components render as CMYK")
// An /Alternate is used when it agrees with /N.
alt := cf_array(doc, Name("ICCBased"),
cf_stream(doc, "<< /N 3 /Alternate [/Lab << /WhitePoint [0.9642 1 0.8249] >>] >>", nil))
lab_icc, _ := colorspace_load(doc, alt, nil)
if lab_icc == nil { return }
testing.expect_value(t, lab_icc.n, 3)
expect_rgb(t, colorspace_to_rgb(lab_icc, color_make(100, 0, 0)), 1, 1, 1, 1e-6,
"rendered through the alternate")
// An /Alternate that contradicts /N is ignored in favour of /N.
bad := cf_array(doc, Name("ICCBased"),
cf_stream(doc, "<< /N 1 /Alternate /DeviceCMYK >>", nil))
gray_icc, _ := colorspace_load(doc, bad, nil)
if gray_icc == nil { return }
testing.expect_value(t, gray_icc.n, 1)
expect_rgb(t, colorspace_to_rgb(gray_icc, color_make(0.5)), 0.5, 0.5, 0.5, TOL,
"one component is grey whatever the alternate said")
// Neither /N nor /Alternate: three components assumed, and a repair.
guess := cf_array(doc, Name("ICCBased"), cf_stream(doc, "<< >>", nil))
guessed, _ := colorspace_load(doc, guess, nil)
if guessed != nil {
testing.expect_value(t, guessed.n, 3)
testing.expect(t, doc_has_repair(doc, .Bad_Resource), "guessing /N is a repair")
}
}
// ---------------------------------------------------------------------------
// Indexed
// ---------------------------------------------------------------------------
@(test)
test_colorspace_indexed :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
cs, err := colorspace_load(doc, Ref{10, 0}, nil)
testing.expect_value(t, err, Error.None)
if cs == nil { return }
testing.expect_value(t, cs.kind, Colorspace_Kind.Indexed)
// An Indexed colour has one component -- the index -- whatever the base is.
testing.expect_value(t, cs.n, 1)
testing.expect_value(t, cs.hival, 3)
expect_rgb(t, colorspace_to_rgb(cs, color_make(0)), 1, 0, 0, TOL, "entry 0")
expect_rgb(t, colorspace_to_rgb(cs, color_make(1)), 0, 1, 0, TOL, "entry 1")
expect_rgb(t, colorspace_to_rgb(cs, color_make(2)), 0, 0, 1, TOL, "entry 2")
expect_rgb(t, colorspace_to_rgb(cs, color_make(3)), 1, 1, 1, TOL, "entry 3")
}
// An index outside 0..hival is clamped. It must never read past the table.
@(test)
test_colorspace_indexed_clamps :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
cs, _ := colorspace_load(doc, Ref{10, 0}, nil)
if cs == nil { return }
expect_rgb(t, colorspace_to_rgb(cs, color_make(4)), 1, 1, 1, TOL, "one past the end")
expect_rgb(t, colorspace_to_rgb(cs, color_make(9999)), 1, 1, 1, TOL, "far past the end")
expect_rgb(t, colorspace_to_rgb(cs, color_make(-1)), 1, 0, 0, TOL, "below the start")
expect_rgb(t, colorspace_to_rgb(cs, color_make(-9999)), 1, 0, 0, TOL, "far below")
// A table shorter than /HiVal claims: the entries that are not there read
// as zero rather than off the end of the buffer.
short := load_cs(t, doc, "[/Indexed /DeviceRGB 5 <FF0000>]")
if short == nil { return }
testing.expect_value(t, short.hival, 5)
expect_rgb(t, colorspace_to_rgb(short, color_make(0)), 1, 0, 0, TOL, "the entry that exists")
expect_rgb(t, colorspace_to_rgb(short, color_make(5)), 0, 0, 0, TOL, "one that does not")
// /HiVal above 255 is not a table this format can index.
big := load_cs(t, doc, "[/Indexed /DeviceRGB 99999 <FF0000>]")
if big != nil {
testing.expect_value(t, big.hival, 255)
}
}
@(test)
test_colorspace_indexed_variants :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
// The lookup table may be a stream instead of a string.
stm := cf_stream(doc, "<< >>", []byte{0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF})
cs, err := colorspace_load(doc,
cf_array(doc, Name("Indexed"), Name("DeviceRGB"), i64(1), stm), nil)
testing.expect_value(t, err, Error.None)
if cs == nil { return }
expect_rgb(t, colorspace_to_rgb(cs, color_make(0)), 0, 0, 0, TOL, "stream entry 0")
expect_rgb(t, colorspace_to_rgb(cs, color_make(1)), 1, 1, 1, TOL, "stream entry 1")
// A base whose components are not 0..1: each table byte spans the base
// component's own range, so 255 in the L* slot is L* 100.
lab := load_cs(t, doc,
"[/Indexed [/Lab << /WhitePoint [0.9642 1 0.8249] /Range [-128 127] >>] " +
"1 <008080 FF8080>]")
if lab == nil { return }
// 0x80 is the middle of [-128 127], which is a* = b* = -0.5: near neutral.
expect_rgb(t, colorspace_to_rgb(lab, color_make(0)), 0, 0, 0, 0.01, "L* 0 entry")
expect_rgb(t, colorspace_to_rgb(lab, color_make(1)), 1, 1, 1, 0.01, "L* 100 entry")
// An Indexed space over another Indexed space, or over a Pattern, means
// nothing and is refused.
_, e1 := colorspace_load(doc, cf_parse(doc, "[/Indexed [/Indexed /DeviceRGB 1 <0000>] 1 <0001>]"), nil)
testing.expect_value(t, e1, Error.Bad_Colorspace)
_, e2 := colorspace_load(doc, cf_parse(doc, "[/Indexed /Pattern 1 <0001>]"), nil)
testing.expect_value(t, e2, Error.Bad_Colorspace)
}
// ---------------------------------------------------------------------------
// Separation and DeviceN
// ---------------------------------------------------------------------------
@(test)
test_colorspace_separation :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
// A spot colour whose tint transform is a type 2 function into DeviceCMYK:
// full tint is magenta plus yellow, which is red.
cs, err := colorspace_load(doc, Ref{5, 0}, nil)
testing.expect_value(t, err, Error.None)
if cs == nil { return }
testing.expect_value(t, cs.kind, Colorspace_Kind.Separation)
testing.expect_value(t, cs.n, 1)
testing.expect(t, cs.tint != nil, "the tint transform loaded")
testing.expect(t, cs.alt != nil && cs.alt.kind == .Device_CMYK, "the alternate space")
testing.expect(t, len(cs.colorant_names) == 1 && cs.colorant_names[0] == "Spot", "colorant")
expect_rgb(t, colorspace_to_rgb(cs, color_make(0)), 1, 1, 1, TOL, "no tint is white")
expect_rgb(t, colorspace_to_rgb(cs, color_make(1)), 1, 0, 0, TOL, "full tint is red")
expect_rgb(t, colorspace_to_rgb(cs, color_make(0.5)), 1, 0.5, 0.5, TOL, "half tint")
// The initial colour in a Separation is full tint, not zero (8.6.8).
c := colorspace_default_color(cs)
testing.expect_value(t, c.n, 1)
testing.expect_value(t, c.v[0], 1)
}
// /None marks nothing at all on the page.
@(test)
test_colorspace_separation_none :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
cs := load_cs(t, doc,
"[/Separation /None /DeviceCMYK << /FunctionType 2 /Domain [0 1] " +
"/C0 [0 0 0 0] /C1 [0 0 0 1] /N 1 >>]")
if cs == nil { return }
testing.expect(t, cs.is_none, "/None paints nothing")
testing.expect(t, !cs.is_all, "and is not /All")
// Even at full tint, which would otherwise be solid black.
expect_rgb(t, colorspace_to_rgb(cs, color_make(1)), 1, 1, 1, TOL, "nothing is painted")
all := load_cs(t, doc,
"[/Separation /All /DeviceCMYK << /FunctionType 2 /Domain [0 1] " +
"/C0 [0 0 0 0] /C1 [0 0 0 1] /N 1 >>]")
if all == nil { return }
testing.expect(t, all.is_all, "/All marks every separation")
testing.expect(t, !all.is_none, "and is not /None")
expect_rgb(t, colorspace_to_rgb(all, color_make(1)), 0, 0, 0, TOL, "full tint is black")
}
// A tint transform that is missing or unusable must not lose the artwork: the
// colorant is treated as ink coverage instead.
@(test)
test_colorspace_separation_broken_tint :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
cs := load_cs(t, doc, "[/Separation /Spot /DeviceCMYK << /FunctionType 9 >>]")
if cs == nil { return }
testing.expect(t, cs.tint == nil, "the tint transform did not load")
expect_rgb(t, colorspace_to_rgb(cs, color_make(0)), 1, 1, 1, TOL, "no ink")
expect_rgb(t, colorspace_to_rgb(cs, color_make(1)), 0, 0, 0, TOL, "full ink")
expect_rgb(t, colorspace_to_rgb(cs, color_make(0.25)), 0.75, 0.75, 0.75, TOL, "a quarter")
testing.expect(t, doc_has_repair(doc, .Bad_Resource), "a broken tint is a repair")
// The alternate space missing entirely is survivable in the same way.
no_alt := load_cs(t, doc, "[/Separation /Spot]")
if no_alt != nil {
expect_rgb(t, colorspace_to_rgb(no_alt, color_make(1)), 0, 0, 0, TOL, "no alternate")
}
}
// 8.6.6.4 and 8.6.6.5: the alternate "shall not be another special colour
// space (Pattern, Indexed, Separation, or DeviceN)", and the four are excluded
// for one reason -- a tint transform's output cannot be read as a colour in
// any of them. Indexed is the one that shows: its single component is a
// palette index, so a tint running 0..1 lands on entry 0 or entry 1 and the
// whole ramp collapses onto two colours. Refusing the alternate drops the
// space onto the ink-coverage fallback, which is monotone in the tint and
// keeps the artwork.
@(test)
test_colorspace_separation_special_alternate :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
RAMP :: " << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>"
for src in ([]string{
"[/Separation /Spot [/Indexed /DeviceRGB 3 <FF000000FF000000FFFFFFFF>]" + RAMP + "]",
"[/Separation /Spot [/Separation /Other /DeviceGray" + RAMP + "]" + RAMP + "]",
"[/Separation /Spot [/DeviceN [/A] /DeviceGray" + RAMP + "]" + RAMP + "]",
"[/Separation /Spot /Pattern" + RAMP + "]",
"[/DeviceN [/A] [/Indexed /DeviceRGB 3 <FF000000FF000000FFFFFFFF>]" + RAMP + "]",
}) {
cs := load_cs(t, doc, src)
if cs == nil { continue }
testing.expectf(t, cs.alt == nil, "%s: the alternate must be refused", src)
testing.expectf(t, cs.tint == nil, "%s: and its tint transform with it", src)
// Ink coverage, not two palette entries: the ramp runs the right way
// and takes every value in between.
expect_rgb(t, colorspace_to_rgb(cs, color_make(0)), 1, 1, 1, TOL, "no ink")
expect_rgb(t, colorspace_to_rgb(cs, color_make(0.25)), 0.75, 0.75, 0.75, TOL, "a quarter")
expect_rgb(t, colorspace_to_rgb(cs, color_make(0.5)), 0.5, 0.5, 0.5, TOL, "half")
expect_rgb(t, colorspace_to_rgb(cs, color_make(1)), 0, 0, 0, TOL, "full ink")
}
testing.expect(t, doc_has_repair(doc, .Bad_Resource), "an unusable alternate is a repair")
// The control: a device alternate is what the rule allows, and it still
// goes through the tint transform.
good := load_cs(t, doc,
"[/Separation /Spot /DeviceGray << /FunctionType 2 /Domain [0 1] " +
"/C0 [1] /C1 [0] /N 1 >>]")
if good == nil { return }
testing.expect(t, good.alt != nil && good.alt.kind == .Device_Gray, "a device alternate loads")
testing.expect(t, good.tint != nil, "and keeps its tint transform")
expect_rgb(t, colorspace_to_rgb(good, color_make(0.25)), 0.75, 0.75, 0.75, TOL, "through the tint")
}
@(test)
test_colorspace_devicen :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
// Two colorants into DeviceRGB. The program throws the second away and
// copies the first into all three outputs.
tint := cf_stream(doc,
"<< /FunctionType 4 /Domain [0 1 0 1] /Range [0 1 0 1 0 1] >>",
cf_bytes("{ pop dup dup }"))
names := cf_array(doc, Name("Ink1"), Name("Ink2"))
cs, err := colorspace_load(doc,
cf_array(doc, Name("DeviceN"), names, Name("DeviceRGB"), tint), nil)
testing.expect_value(t, err, Error.None)
if cs == nil { return }
testing.expect_value(t, cs.kind, Colorspace_Kind.Device_N)
testing.expect_value(t, cs.n, 2)
testing.expect(t, cs.tint != nil, "the tint transform loaded")
testing.expect(t, len(cs.colorant_names) == 2 && cs.colorant_names[1] == "Ink2", "colorants")
expect_rgb(t, colorspace_to_rgb(cs, color_make(0.25, 0.9)),
0.25, 0.25, 0.25, TOL, "the first colorant drives all three outputs")
expect_rgb(t, colorspace_to_rgb(cs, color_make(1, 0)), 1, 1, 1, TOL, "full first colorant")
// The initial colour is 1.0 in every component.
c := colorspace_default_color(cs)
testing.expect_value(t, c.n, 2)
testing.expect_value(t, c.v[0], 1)
testing.expect_value(t, c.v[1], 1)
}
@(test)
test_colorspace_devicen_degrades :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
// No tint transform at all: the darkest colorant becomes ink coverage.
names := cf_array(doc, Name("Ink1"), Name("Ink2"))
cs, err := colorspace_load(doc,
cf_array(doc, Name("DeviceN"), names, Name("DeviceRGB")), nil)
testing.expect_value(t, err, Error.None)
if cs == nil { return }
testing.expect_value(t, cs.n, 2)
expect_rgb(t, colorspace_to_rgb(cs, color_make(0, 0)), 1, 1, 1, TOL, "no ink")
expect_rgb(t, colorspace_to_rgb(cs, color_make(0.25, 0.75)),
0.25, 0.25, 0.25, TOL, "the darkest colorant")
// Every colorant /None: nothing is marked.
nones := cf_array(doc, Name("None"), Name("None"))
all_none, _ := colorspace_load(doc,
cf_array(doc, Name("DeviceN"), nones, Name("DeviceRGB")), nil)
if all_none != nil {
testing.expect(t, all_none.is_none, "a DeviceN of nothing but /None marks nothing")
}
// One real colorant among them still marks.
mixed := cf_array(doc, Name("None"), Name("Ink"))
some, _ := colorspace_load(doc,
cf_array(doc, Name("DeviceN"), mixed, Name("DeviceRGB")), nil)
if some != nil {
testing.expect(t, !some.is_none, "one real colorant is still painted")
}
// A DeviceN with no colorants at all is not a colour space.
_, e := colorspace_load(doc, cf_parse(doc, "[/DeviceN [] /DeviceRGB]"), nil)
testing.expect_value(t, e, Error.Bad_Colorspace)
}
// ---------------------------------------------------------------------------
// Pattern
// ---------------------------------------------------------------------------
@(test)
test_colorspace_pattern :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
plain := load_cs(t, doc, "/Pattern")
if plain == nil { return }
testing.expect_value(t, plain.kind, Colorspace_Kind.Pattern)
testing.expect(t, plain.under == nil, "a coloured pattern has no underlying space")
// Nothing has been selected yet, so there are no components to carry --
// `/P1 scn` is a name and no numbers, which is what cs.n has to say.
testing.expect_value(t, plain.n, 0)
testing.expect_value(t, colorspace_default_color(plain).n, 0)
// And no image can be in a Pattern space (8.9.5.1), so it has no default
// /Decode either.
testing.expect(t, colorspace_default_decode(plain, 8, context.allocator) == nil,
"a Pattern space has no image decode array")
// An uncoloured pattern carries the colour it is painted in.
under := load_cs(t, doc, "[/Pattern /DeviceCMYK]")
if under == nil { return }
testing.expect_value(t, under.kind, Colorspace_Kind.Pattern)
testing.expect_value(t, under.n, 4)
testing.expect(t, under.under != nil && under.under.kind == .Device_CMYK, "underlying space")
expect_rgb(t, colorspace_to_rgb(under, color_make(0, 1, 1, 0)), 1, 0, 0, TOL,
"painted through the underlying space")
// Whose initial colour is the underlying space's.
c := colorspace_default_color(under)
testing.expect_value(t, c.n, 4)
testing.expect_value(t, c.v[3], 1)
}
// ---------------------------------------------------------------------------
// Resource lookup, caching and malformed input
// ---------------------------------------------------------------------------
@(test)
test_colorspace_named_through_resources :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
res, _ := to_dict(cf_parse(doc,
"<< /ColorSpace << /CS0 [/Indexed /DeviceRGB 1 <FF000000FF00>] /CS1 /DeviceCMYK >> >>"))
cs0 := load_cs(t, doc, "/CS0", res)
if cs0 == nil { return }
testing.expect_value(t, cs0.kind, Colorspace_Kind.Indexed)
expect_rgb(t, colorspace_to_rgb(cs0, color_make(1)), 0, 1, 0, TOL, "resolved through /Resources")
cs1 := load_cs(t, doc, "/CS1", res)
testing.expect(t, cs1 == colorspace_device_cmyk(doc), "a name for a device space")
// A caller holding only the /ColorSpace dictionary may pass that instead.
csdict, _ := to_dict(cf_parse(doc, "<< /CSX /DeviceCMYK >>"))
testing.expect(t, load_cs(t, doc, "/CSX", csdict) == colorspace_device_cmyk(doc),
"the /ColorSpace dictionary works too")
// A name that is in no resource dictionary is not a colour space.
_, err := colorspace_load(doc, cf_parse(doc, "/Nope"), res)
testing.expect_value(t, err, Error.Bad_Colorspace)
// A device name cannot be redefined by a resource dictionary.
shadow, _ := to_dict(cf_parse(doc, "<< /ColorSpace << /DeviceRGB /DeviceCMYK >> >>"))
testing.expect(t, load_cs(t, doc, "/DeviceRGB", shadow) == colorspace_device_rgb(doc),
"/DeviceRGB is always /DeviceRGB")
}
// A page that names the same space in four hundred XObjects must build it once.
@(test)
test_colorspace_cached_by_object :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
a, _ := colorspace_load(doc, Ref{10, 0}, nil)
b, _ := colorspace_load(doc, Ref{10, 0}, nil)
testing.expect(t, a != nil && a == b, "the same object must build one space")
// Reached by name through a resource dictionary, the space behind the name
// is the same object and so is cached as well.
res, _ := to_dict(cf_parse(doc, "<< /ColorSpace << /CS0 10 0 R >> >>"))
c := load_cs(t, doc, "/CS0", res)
testing.expect(t, c == a, "the name resolves onto the cached space")
// A Separation loaded twice reuses the loaded tint transform, too.
s1, _ := colorspace_load(doc, Ref{5, 0}, nil)
s2, _ := colorspace_load(doc, Ref{5, 0}, nil)
testing.expect(t, s1 != nil && s1 == s2, "a Separation is cached")
}
// A colour space that names itself must stop rather than recursing forever.
@(test)
test_colorspace_self_reference :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
res, _ := to_dict(cf_parse(doc, "<< /ColorSpace << /Self 9 0 R >> >>"))
cs, err := colorspace_load(doc, cf_parse(doc, "/Self"), res)
testing.expect(t, cs == nil, "a self-naming space cannot be built")
testing.expect_value(t, err, Error.Recursion_Limit)
testing.expect(t, doc_has_repair(doc, .Recursion_Limit_Hit), "and it is a repair")
}
@(test)
test_colorspace_rejects_nonsense :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
_, e1 := colorspace_load(doc, nil, nil)
testing.expect_value(t, e1, Error.Bad_Colorspace)
_, e2 := colorspace_load(doc, cf_parse(doc, "42"), nil)
testing.expect_value(t, e2, Error.Bad_Colorspace)
_, e3 := colorspace_load(doc, cf_parse(doc, "[]"), nil)
testing.expect_value(t, e3, Error.Bad_Colorspace)
_, e4 := colorspace_load(doc, cf_parse(doc, "[/NotASpace 1 2 3]"), nil)
testing.expect_value(t, e4, Error.Bad_Colorspace)
// Converting through no space at all is black, not a crash.
expect_rgb(t, colorspace_to_rgb(nil, color_make(1, 1, 1)), 0, 0, 0, TOL, "nil space")
testing.expect_value(t, colorspace_default_color(nil).n, 1)
testing.expect(t, colorspace_default_decode(nil, 8) == nil, "nil space has no decode")
}
// ---------------------------------------------------------------------------
// Initial colours and image decode arrays
// ---------------------------------------------------------------------------
@(test)
test_colorspace_default_color :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
gray := colorspace_default_color(colorspace_device_gray(doc))
testing.expect_value(t, gray.n, 1)
testing.expect_value(t, gray.v[0], 0)
rgb := colorspace_default_color(colorspace_device_rgb(doc))
testing.expect_value(t, rgb.n, 3)
expect_rgb(t, colorspace_to_rgb(colorspace_device_rgb(doc), rgb), 0, 0, 0, TOL, "black")
// DeviceCMYK starts at 0 0 0 1 -- black -- not at four zeros, which would
// be white (8.6.4.4).
cmyk := colorspace_default_color(colorspace_device_cmyk(doc))
testing.expect_value(t, cmyk.n, 4)
testing.expect_value(t, cmyk.v[3], 1)
expect_rgb(t, colorspace_to_rgb(colorspace_device_cmyk(doc), cmyk), 0, 0, 0, TOL, "black")
// Indexed starts at entry zero.
idx, _ := colorspace_load(doc, Ref{10, 0}, nil)
if idx != nil {
c := colorspace_default_color(idx)
testing.expect_value(t, c.n, 1)
testing.expect_value(t, c.v[0], 0)
}
// Lab starts at 0 0 0, which is black.
lab := load_cs(t, doc, "[/Lab << /WhitePoint [0.9642 1 0.8249] >>]")
if lab != nil {
c := colorspace_default_color(lab)
testing.expect_value(t, c.n, 3)
expect_rgb(t, colorspace_to_rgb(lab, c), 0, 0, 0, TOL, "black")
}
}
@(test)
test_colorspace_default_decode :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
gray := colorspace_default_decode(colorspace_device_gray(doc), 8)
defer delete(gray)
testing.expect_value(t, len(gray), 2)
testing.expect_value(t, gray[0], 0)
testing.expect_value(t, gray[1], 1)
cmyk := colorspace_default_decode(colorspace_device_cmyk(doc), 8)
defer delete(cmyk)
testing.expect_value(t, len(cmyk), 8)
testing.expect_value(t, cmyk[7], 1)
// Indexed maps the raw sample straight onto the table, so its default
// depends on the bit depth.
idx, _ := colorspace_load(doc, Ref{10, 0}, nil)
if idx != nil {
Depth :: struct { bpc: int, want: f64 }
depths := []Depth{
Depth{1, 1}, Depth{2, 3}, Depth{4, 15}, Depth{8, 255}, Depth{16, 65535},
}
for c in depths {
d := colorspace_default_decode(idx, c.bpc)
defer delete(d)
testing.expect_value(t, len(d), 2)
testing.expect_value(t, d[0], 0)
testing.expectf(t, d[1] == c.want, "bpc %d: got %f, want %f", c.bpc, d[1], c.want)
}
}
// Lab's default decode is its own component ranges.
lab := load_cs(t, doc,
"[/Lab << /WhitePoint [0.9642 1 0.8249] /Range [-90 80 -70 60] >>]")
if lab != nil {
d := colorspace_default_decode(lab, 8)
defer delete(d)
testing.expect_value(t, len(d), 6)
testing.expect_value(t, d[0], 0)
testing.expect_value(t, d[1], 100)
testing.expect_value(t, d[2], -90)
testing.expect_value(t, d[3], 80)
testing.expect_value(t, d[4], -70)
testing.expect_value(t, d[5], 60)
}
// A Separation's decode is one 0..1 pair, whatever its alternate space is.
sep, _ := colorspace_load(doc, Ref{5, 0}, nil)
if sep != nil {
d := colorspace_default_decode(sep, 8)
defer delete(d)
testing.expect_value(t, len(d), 2)
testing.expect_value(t, d[1], 1)
}
}
// A colour space nested inside another still converts: an Indexed table whose
// entries are spot-colour tints.
@(test)
test_colorspace_nested :: proc(t: ^testing.T) {
doc := cf_open(t)
defer cf_close(t, doc)
cs, err := colorspace_load(doc,
cf_array(doc, Name("Indexed"), Ref{5, 0}, i64(1), String(transmute([]byte)string("\x00\xff"))),
nil)
testing.expect_value(t, err, Error.None)
if cs == nil { return }
testing.expect(t, cs.base != nil && cs.base.kind == .Separation, "the base is the spot colour")
// Entry 0 is no tint at all, entry 1 is full tint: white, then red.
expect_rgb(t, colorspace_to_rgb(cs, color_make(0)), 1, 1, 1, TOL, "entry 0")
expect_rgb(t, colorspace_to_rgb(cs, color_make(1)), 1, 0, 0, TOL, "entry 1")
}