Files

1038 lines
43 KiB
Odin
Raw Permalink Normal View History

2026-09-25 23:32:29 +02:00
package pdf
import "core:math"
import "core:slice"
import "core:testing"
@(private = "file")
EPS :: 1e-12
@(private = "file")
expect_near :: proc(t: ^testing.T, got, want: f64, label: string, eps := EPS, loc := #caller_location) {
testing.expectf(t, abs(got - want) <= eps, "%s: got %.17g, want %.17g", label, got, want, loc = loc)
}
@(private = "file")
expect_near3 :: proc(t: ^testing.T, got, want: [3]f64, label: string, eps := EPS, loc := #caller_location) {
ok := abs(got[0] - want[0]) <= eps && abs(got[1] - want[1]) <= eps && abs(got[2] - want[2]) <= eps
testing.expectf(t, ok, "%s: got %.17g, want %.17g", label, got, want, loc = loc)
}
// A one-pixel-tall RGBA bitmap whose pixels are set from premultiplied bytes.
@(private = "file")
make_bmp :: proc(t: ^testing.T, w, h, n: int, fill: []u8, loc := #caller_location) -> Bitmap {
bmp, err := bitmap_make(w, h, n, context.allocator)
testing.expect_value(t, err, Error.None, loc = loc)
bmp.premultiplied = true
if fill != nil {
for i in 0 ..< len(bmp.pixels) {
bmp.pixels[i] = fill[i % len(fill)]
}
}
return bmp
}
// ---------------------------------------------------------------------------
// Separable blend functions (ISO 32000-2, 11.3.5.2)
// ---------------------------------------------------------------------------
@(private = "file")
Sep_Case :: struct {
mode: Blend_Mode,
cb, cs: f64,
want: f64,
}
@(test)
test_blend_separable_boundaries :: proc(t: ^testing.T) {
cases := []Sep_Case{
// Normal: the source, whatever the backdrop.
{.Normal, 0, 0, 0}, {.Normal, 0, 1, 1}, {.Normal, 1, 0, 0}, {.Normal, 0.5, 0.25, 0.25},
// Multiply: cb * cs.
{.Multiply, 0, 0, 0}, {.Multiply, 1, 1, 1}, {.Multiply, 0.5, 0.5, 0.25},
{.Multiply, 1, 0.5, 0.5}, {.Multiply, 0, 1, 0},
// Screen: cb + cs - cb*cs.
{.Screen, 0, 0, 0}, {.Screen, 1, 1, 1}, {.Screen, 0.5, 0.5, 0.75},
{.Screen, 0, 0.5, 0.5}, {.Screen, 1, 0, 1},
// Overlay = HardLight with the operands exchanged, so the branch is on
// the BACKDROP: dark backdrops multiply, light ones screen.
{.Overlay, 0.5, 0.5, 0.5}, {.Overlay, 0.25, 0.5, 0.25}, {.Overlay, 0.75, 0.5, 0.75},
{.Overlay, 0, 1, 0}, {.Overlay, 1, 0, 1}, {.Overlay, 0.25, 1, 0.5},
{.Darken, 0.3, 0.7, 0.3}, {.Darken, 1, 0, 0}, {.Darken, 0.5, 0.5, 0.5},
{.Lighten, 0.3, 0.7, 0.7}, {.Lighten, 1, 0, 1}, {.Lighten, 0.5, 0.5, 0.5},
// HardLight branches on the SOURCE.
{.Hard_Light, 0.5, 0.25, 0.25}, {.Hard_Light, 0.5, 0.75, 0.75},
{.Hard_Light, 0.5, 0.5, 0.5}, {.Hard_Light, 0.2, 1, 1}, {.Hard_Light, 0.8, 0, 0},
// Just above the threshold, where multiplying and screening disagree:
// Screen(0.25, 0.1) = 0.325, while the wrong branch gives 0.275.
{.Hard_Light, 0.25, 0.55, 0.325}, {.Overlay, 0.55, 0.25, 0.325},
// SoftLight. cs == 0.5 is the identity; cs == 0 darkens by cb*(1-cb);
// cs == 1 lightens to D(cb).
{.Soft_Light, 0.5, 0.5, 0.5}, {.Soft_Light, 0.5, 0, 0.25},
{.Soft_Light, 0.5, 1, 0.70710678118654752}, {.Soft_Light, 0.25, 1, 0.5},
{.Soft_Light, 0.05, 1, 0.172},
{.Soft_Light, 0, 0, 0}, {.Soft_Light, 1, 0, 1},
{.Soft_Light, 0, 1, 0}, {.Soft_Light, 1, 1, 1},
{.Difference, 0.25, 0.75, 0.5}, {.Difference, 1, 0, 1}, {.Difference, 0.5, 0.5, 0},
{.Difference, 0, 0, 0}, {.Difference, 1, 1, 0},
{.Exclusion, 0.5, 0.5, 0.5}, {.Exclusion, 1, 1, 0}, {.Exclusion, 0, 0.25, 0.25},
{.Exclusion, 1, 0, 1}, {.Exclusion, 0, 0, 0},
}
for c in cases {
got := blend_separable(c.mode, c.cb, c.cs)
testing.expectf(t, abs(got - c.want) <= EPS,
"%v(cb=%v, cs=%v): got %.17g, want %.17g", c.mode, c.cb, c.cs, got, c.want)
}
}
// ColorDodge and ColorBurn are where the division by zero lives. ISO 32000-2
// orders the case split so that a black backdrop stays black under dodge and a
// white backdrop stays white under burn; ISO 32000-1 tested the source first
// and produced the opposite (and visibly wrong) answer at those two corners.
@(test)
test_blend_color_dodge_edges :: proc(t: ^testing.T) {
cases := []Sep_Case{
{.Color_Dodge, 0, 1, 0}, // the corner ISO 32000-1 got wrong (it said 1)
{.Color_Dodge, 0, 0, 0},
{.Color_Dodge, 0, 0.5, 0},
{.Color_Dodge, 0.5, 1, 1}, // cs == 1 with a non-black backdrop: white
{.Color_Dodge, 1, 1, 1},
{.Color_Dodge, 0.5, 0, 0.5}, // cs == 0 is the identity
{.Color_Dodge, 1, 0, 1},
{.Color_Dodge, 0.5, 0.5, 1}, // cb/(1-cs) == 1
{.Color_Dodge, 0.25, 0.5, 0.5},
{.Color_Dodge, 0.1, 0.5, 0.2},
{.Color_Dodge, 0.75, 0.5, 1}, // clamped by min(1, ...)
}
for c in cases {
got := blend_separable(c.mode, c.cb, c.cs)
testing.expectf(t, abs(got - c.want) <= EPS,
"ColorDodge(cb=%v, cs=%v): got %.17g, want %.17g", c.cb, c.cs, got, c.want)
}
}
@(test)
test_blend_color_burn_edges :: proc(t: ^testing.T) {
cases := []Sep_Case{
{.Color_Burn, 1, 0, 1}, // the corner ISO 32000-1 got wrong (it said 0)
{.Color_Burn, 1, 1, 1},
{.Color_Burn, 1, 0.5, 1},
{.Color_Burn, 0, 0, 0}, // cs == 0 with a non-white backdrop: black
{.Color_Burn, 0.5, 0, 0},
{.Color_Burn, 0.5, 1, 0.5}, // cs == 1 is the identity
{.Color_Burn, 0, 1, 0},
{.Color_Burn, 0.5, 0.5, 0}, // (1-cb)/cs == 1
{.Color_Burn, 0.75, 0.5, 0.5},
{.Color_Burn, 0.9, 0.5, 0.8},
{.Color_Burn, 0.25, 0.5, 0}, // clamped by min(1, ...)
}
for c in cases {
got := blend_separable(c.mode, c.cb, c.cs)
testing.expectf(t, abs(got - c.want) <= EPS,
"ColorBurn(cb=%v, cs=%v): got %.17g, want %.17g", c.cb, c.cs, got, c.want)
}
}
// D(x) is piecewise: a cubic below 0.25 and sqrt above. Using sqrt everywhere
// is the common bug, so the test pins a point where the two disagree by 0.05 --
// far more than any tolerance could hide.
@(test)
test_blend_soft_light_transfer :: proc(t: ^testing.T) {
expect_near(t, blend_soft_light_d(0), 0, "D(0)")
expect_near(t, blend_soft_light_d(1), 1, "D(1)")
expect_near(t, blend_soft_light_d(0.05), 0.172, "D(0.05) is the cubic")
testing.expect(t, abs(blend_soft_light_d(0.05) - math.sqrt(f64(0.05))) > 0.04,
"the low branch must be the cubic, not sqrt")
// The two branches meet at 0.25: both give exactly 0.5.
expect_near(t, blend_soft_light_d(0.25), 0.5, "D(0.25) from the cubic")
expect_near(t, blend_soft_light_d(0.25 + 1e-9), 0.5, "D just above 0.25", 1e-8)
// 0.1 is a second point on the cubic: ((1.6-12)*0.1+4)*0.1 = 0.296.
expect_near(t, blend_soft_light_d(0.1), 0.296, "D(0.1)")
expect_near(t, blend_soft_light_d(0.81), 0.9, "D(0.81) is sqrt")
}
// Overlay is defined as HardLight with the operands exchanged; checking the
// identity over a grid is stronger than any handful of sample points.
@(test)
test_blend_overlay_is_hard_light_swapped :: proc(t: ^testing.T) {
for i in 0 ..= 8 {
for j in 0 ..= 8 {
cb := f64(i) / 8
cs := f64(j) / 8
expect_near(t, blend_separable(.Overlay, cb, cs),
blend_separable(.Hard_Light, cs, cb), "Overlay == HardLight swapped")
}
}
}
// Every separable mode must stay inside the unit interval and never produce a
// NaN, including at the corners where a division by zero lurks.
@(test)
test_blend_separable_stays_in_range :: proc(t: ^testing.T) {
for mode in Blend_Mode {
if !blend_mode_is_separable(mode) { continue }
for i in 0 ..= 16 {
for j in 0 ..= 16 {
cb := f64(i) / 16
cs := f64(j) / 16
v := blend_separable(mode, cb, cs)
testing.expectf(t, is_finite(v), "%v(%v,%v) is not finite: %v", mode, cb, cs, v)
testing.expectf(t, v >= -EPS && v <= 1 + EPS, "%v(%v,%v) = %v out of range", mode, cb, cs, v)
}
}
}
}
// ---------------------------------------------------------------------------
// The non-separable helpers (11.3.5.3)
// ---------------------------------------------------------------------------
@(test)
test_blend_lum_and_sat :: proc(t: ^testing.T) {
expect_near(t, blend_lum({1, 0, 0}), 0.3, "Lum(red)")
expect_near(t, blend_lum({0, 1, 0}), 0.59, "Lum(green)")
expect_near(t, blend_lum({0, 0, 1}), 0.11, "Lum(blue)")
expect_near(t, blend_lum({1, 1, 1}), 1, "Lum(white)")
expect_near(t, blend_lum({0.5, 0.5, 0.5}), 0.5, "Lum(mid grey)")
expect_near(t, blend_sat({0.2, 0.9, 0.5}), 0.7, "Sat")
expect_near(t, blend_sat({0.4, 0.4, 0.4}), 0, "Sat(grey)")
expect_near(t, blend_sat({0, 0, 1}), 1, "Sat(blue)")
}
// SetSat keeps each component in its own slot: the largest becomes s, the
// smallest becomes 0, the middle is interpolated. An implementation that sorts
// the values but writes them back in order passes a sorted input and fails
// this one.
@(test)
test_blend_set_sat :: proc(t: ^testing.T) {
expect_near3(t, blend_set_sat({1, 0.5, 0}, 0.4), {0.4, 0.2, 0}, "SetSat descending")
expect_near3(t, blend_set_sat({0, 0.5, 1}, 1), {0, 0.5, 1}, "SetSat ascending")
// 0.6 > 0.4 > 0.2 lives in components r > b > g, and must stay there.
expect_near3(t, blend_set_sat({0.6, 0.2, 0.4}, 1), {1, 0, 0.5}, "SetSat unsorted")
// A grey has no ordering to preserve and no range to scale by: all zero.
expect_near3(t, blend_set_sat({0.5, 0.5, 0.5}, 0.8), {0, 0, 0}, "SetSat of a grey")
expect_near(t, blend_sat(blend_set_sat({0.6, 0.2, 0.4}, 0.25)), 0.25, "SetSat sets Sat")
}
// ClipColor scales a colour towards the grey axis without changing its
// luminosity. n, x and L all come from the colour as it arrived: recomputing
// the maximum after the low clip has run changes the answer.
@(test)
test_blend_clip_color :: proc(t: ^testing.T) {
expect_near3(t, blend_clip_color({0.5, 0.25, 0.75}), {0.5, 0.25, 0.75}, "in gamut: unchanged")
// Lum = 0.5, x = 1.2, so everything scales by (1-L)/(x-L) = 5/7.
got := blend_clip_color({1.2, 0.2, 0.2})
expect_near3(t, got, {1, 0.2857142857142857, 0.2857142857142857}, "clip high")
expect_near(t, blend_lum(got), 0.5, "clipping preserves luminosity")
// Lum = 0.29, n = -0.3, so everything scales by L/(L-n) = 0.29/0.59.
got2 := blend_clip_color({-0.3, 0.7, -0.3})
expect_near3(t, got2, {0, 0.49152542372881356, 0}, "clip low")
expect_near(t, blend_lum(got2), 0.29, "clipping preserves luminosity")
// Both branches can fire for the same colour, and the second one uses the
// maximum of the ORIGINAL colour, not the maximum left by the first. Here
// the low clip brings the blue down to 0.765; recomputing the maximum
// there would skip the high clip entirely and stop at that value, whereas
// the spec keeps compressing with x = 1.5 and lands on 0.574.
both := blend_clip_color({-0.5, 0.5, 1.5})
expect_near3(t, both,
{0.13025210084033614, 0.35216308745720551, 0.57407407407407407}, "both branches")
expect_near(t, blend_lum(both), 0.31, "luminosity survives both clips")
// A uniform out-of-gamut grey drives L - n and x - L to zero. The constants
// matter: at -0.5 the rounding of the Lum weights leaves a denominator of
// ~2e-17 and the division still lands on the right answer by luck, but at
// -0.001 the weighted sum rounds to exactly the component, the denominator
// is exactly zero, and an unguarded divide yields 0/0 = NaN.
expect_near3(t, blend_clip_color({-0.001, -0.001, -0.001}), {0, 0, 0}, "uniform negative")
expect_near3(t, blend_clip_color({1.001, 1.001, 1.001}), {1, 1, 1}, "uniform above one")
expect_near3(t, blend_clip_color({-0.5, -0.5, -0.5}), {0, 0, 0}, "uniform negative (large)")
expect_near3(t, blend_clip_color({1.5, 1.5, 1.5}), {1, 1, 1}, "uniform above one (large)")
}
@(test)
test_blend_set_lum :: proc(t: ^testing.T) {
expect_near3(t, blend_set_lum({0.5, 0.5, 0.5}, 0.3), {0.3, 0.3, 0.3}, "SetLum of a grey")
got := blend_set_lum({1, 0, 0}, 0.5)
expect_near3(t, got, {1, 0.2857142857142857, 0.2857142857142857}, "SetLum needing a clip")
expect_near(t, blend_lum(got), 0.5, "SetLum sets Lum")
expect_near(t, blend_lum(blend_set_lum({0.2, 0.4, 0.6}, 0.9)), 0.9, "SetLum sets Lum (2)")
}
// Worked examples derived by hand from the spec's own pseudo-code.
@(test)
test_blend_non_separable_worked_examples :: proc(t: ^testing.T) {
cb := [3]f64{0.2, 0.4, 0.6} // Lum 0.362, Sat 0.4
cs := [3]f64{1, 0.5, 0} // Lum 0.59, Sat 1
// Hue: SetLum(SetSat(Cs, Sat(Cb)), Lum(Cb))
// SetSat((1,0.5,0), 0.4) = (0.4, 0.2, 0), Lum 0.238
// + (0.362 - 0.238) = (0.524, 0.324, 0.124), in gamut.
expect_near3(t, blend_non_separable(.Hue, cb, cs), {0.524, 0.324, 0.124}, "Hue")
// Saturation: SetLum(SetSat(Cb, Sat(Cs)), Lum(Cb))
// SetSat((0.2,0.4,0.6), 1) = (0, 0.5, 1), Lum 0.405
// + (0.362 - 0.405) = (-0.043, 0.457, 0.957), which clips low by
// L/(L-n) = 0.362/0.405.
expect_near3(t, blend_non_separable(.Saturation, cb, cs),
{0, 0.44691358024691358, 0.89382716049382716}, "Saturation")
grey := [3]f64{0.5, 0.5, 0.5}
red := [3]f64{1, 0, 0}
// Color: SetLum(Cs, Lum(Cb)) -- red pushed to luminosity 0.5, clipped high.
expect_near3(t, blend_non_separable(.Color, grey, red),
{1, 0.2857142857142857, 0.2857142857142857}, "Color")
// Luminosity: SetLum(Cb, Lum(Cs)) -- grey pulled down to red's 0.3.
expect_near3(t, blend_non_separable(.Luminosity, grey, red), {0.3, 0.3, 0.3}, "Luminosity")
// Luminosity to full white: SetLum((1,0,0), 1) needs the high clip, and
// (1-L)/(x-L) is exactly zero there, so every component lands on L.
expect_near3(t, blend_non_separable(.Luminosity, red, {1, 1, 1}), {1, 1, 1}, "Luminosity to white")
// ... and to black, through the low clip.
expect_near3(t, blend_non_separable(.Luminosity, red, {0, 0, 0}), {0, 0, 0}, "Luminosity to black")
}
// The defining identities: Hue and Color take the backdrop's luminosity, Hue
// and Saturation take a stated saturation, Luminosity takes the source's.
@(test)
test_blend_non_separable_identities :: proc(t: ^testing.T) {
cb := [3]f64{0.15, 0.55, 0.35}
cs := [3]f64{0.8, 0.1, 0.45}
expect_near(t, blend_lum(blend_non_separable(.Hue, cb, cs)), blend_lum(cb), "Hue keeps Lum(Cb)")
expect_near(t, blend_lum(blend_non_separable(.Saturation, cb, cs)), blend_lum(cb), "Sat keeps Lum(Cb)")
expect_near(t, blend_lum(blend_non_separable(.Color, cb, cs)), blend_lum(cb), "Color keeps Lum(Cb)")
expect_near(t, blend_lum(blend_non_separable(.Luminosity, cb, cs)), blend_lum(cs), "Luminosity takes Lum(Cs)")
// Saturation survives exactly only while the result stays inside the unit
// cube. Hue does here; Saturation with this pair lands at -0.0435 in red
// and ClipColor compresses it back towards the grey axis, which can only
// reduce the saturation. Both halves of that are asserted.
expect_near(t, blend_sat(blend_non_separable(.Hue, cb, cs)), blend_sat(cb), "Hue takes Sat(Cb)")
low_sat := [3]f64{0.5, 0.45, 0.5} // Sat 0.05: small enough to stay in gamut
expect_near(t, blend_sat(blend_non_separable(.Saturation, cb, low_sat)), blend_sat(low_sat),
"Saturation takes Sat(Cs)")
testing.expect(t, blend_sat(blend_non_separable(.Saturation, cb, cs)) < blend_sat(cs) - 0.01,
"clipping an out-of-gamut result reduces its saturation")
// A grey source has no hue and no saturation to donate.
expect_near3(t, blend_non_separable(.Hue, cb, {0.5, 0.5, 0.5}),
{blend_lum(cb), blend_lum(cb), blend_lum(cb)}, "Hue of a grey source")
}
@(test)
test_blend_non_separable_falls_back_for_separable_modes :: proc(t: ^testing.T) {
cb := [3]f64{0.2, 0.5, 0.8}
cs := [3]f64{0.5, 0.5, 0.5}
expect_near3(t, blend_non_separable(.Multiply, cb, cs), {0.1, 0.25, 0.4}, "componentwise Multiply")
expect_near3(t, blend_non_separable(.Normal, cb, cs), cs, "componentwise Normal")
}
@(test)
test_blend_non_separable_stays_in_range :: proc(t: ^testing.T) {
for mode in Blend_Mode {
if blend_mode_is_separable(mode) { continue }
for i in 0 ..= 5 {
for j in 0 ..= 5 {
for k in 0 ..= 5 {
cb := [3]f64{f64(i) / 5, f64(j) / 5, f64(k) / 5}
cs := [3]f64{f64(k) / 5, f64(i) / 5, f64(j) / 5}
v := blend_non_separable(mode, cb, cs)
for c in v {
testing.expectf(t, is_finite(c), "%v produced %v", mode, c)
testing.expectf(t, c >= -1e-9 && c <= 1 + 1e-9, "%v produced %v out of range", mode, c)
}
}
}
}
}
}
// ---------------------------------------------------------------------------
// The compositing formula (11.3.6)
// ---------------------------------------------------------------------------
// With Normal the union formula must collapse to plain source-over, because
// B(Cb,Cs) = Cs makes the first two terms sum to as*Cs.
@(test)
test_blend_over_normal_is_source_over :: proc(t: ^testing.T) {
for i in 0 ..= 4 {
for j in 0 ..= 4 {
ab := f64(i) / 4
as_ := f64(j) / 4
cb := [3]f64{0.2, 0.5, 0.9}
cs := [3]f64{0.7, 0.1, 0.4}
co, ao := blend_over(cb, ab, cs, as_, .Normal)
for c in 0 ..< 3 {
want := as_ * cs[c] + (1 - as_) * ab * cb[c]
expect_near(t, co[c], want, "source-over colour")
}
expect_near(t, ao, as_ + ab * (1 - as_), "source-over alpha")
}
}
}
// A fully transparent backdrop leaves only the as*(1-ab)*Cs term, whatever the
// blend mode: the B() term is multiplied by ab.
@(test)
test_blend_over_transparent_backdrop_gives_source :: proc(t: ^testing.T) {
cs := [3]f64{0.25, 0.5, 0.75}
for mode in Blend_Mode {
for j in 0 ..= 4 {
as_ := f64(j) / 4
co, ao := blend_over({0, 0, 0}, 0, cs, as_, mode)
expect_near3(t, co, {as_ * cs[0], as_ * cs[1], as_ * cs[2]}, "premultiplied source")
expect_near(t, ao, as_, "alpha is the source alpha")
}
}
}
// A source alpha of zero must leave the backdrop exactly as it was, whatever
// the blend mode: this is what makes `0 gs` invisible rather than merely faint.
@(test)
test_blend_over_zero_source_alpha_is_a_no_op :: proc(t: ^testing.T) {
cb := [3]f64{0.2, 0.6, 0.9}
for mode in Blend_Mode {
for i in 0 ..= 4 {
ab := f64(i) / 4
co, ao := blend_over(cb, ab, {1, 1, 1}, 0, mode)
expect_near3(t, co, {ab * cb[0], ab * cb[1], ab * cb[2]}, "backdrop unchanged")
expect_near(t, ao, ab, "alpha unchanged")
}
}
}
// Over an opaque backdrop the result is opaque and is exactly B(Cb, Cs) when
// the source is opaque too -- the blend function alone, with no interpolation.
@(test)
test_blend_over_opaque_is_the_blend_function :: proc(t: ^testing.T) {
cb := [3]f64{0.25, 0.5, 0.75}
cs := [3]f64{0.5, 0.5, 0.5}
for mode in Blend_Mode {
co, ao := blend_over(cb, 1, cs, 1, mode)
want := blend_mode_is_separable(mode) \
? [3]f64{blend_separable(mode, cb[0], cs[0]),
blend_separable(mode, cb[1], cs[1]),
blend_separable(mode, cb[2], cs[2])} \
: blend_non_separable(mode, cb, cs)
expect_near3(t, co, want, "opaque over opaque is B()")
expect_near(t, ao, 1, "opaque stays opaque")
}
}
@(test)
test_blend_over_never_produces_nan :: proc(t: ^testing.T) {
for mode in Blend_Mode {
for i in 0 ..= 3 {
for j in 0 ..= 3 {
cb := [3]f64{f64(i) / 3, 1 - f64(i) / 3, 0}
cs := [3]f64{f64(j) / 3, 0, 1 - f64(j) / 3}
co, ao := blend_over(cb, f64(i) / 3, cs, f64(j) / 3, mode)
testing.expect(t, is_finite(ao), "alpha is finite")
for c in co {
testing.expectf(t, is_finite(c), "%v produced %v", mode, c)
// Premultiplied output can never exceed its own alpha.
testing.expectf(t, c >= -EPS && c <= ao + 1e-9, "%v produced %v with ao %v", mode, c, ao)
}
}
}
}
}
// ---------------------------------------------------------------------------
// blend_span
// ---------------------------------------------------------------------------
// Painting onto nothing gives back the source, premultiplied. 0.25/0.5/0.75
// are exact in binary, so the bytes are exact too.
@(test)
test_blend_span_onto_transparent_gives_source :: proc(t: ^testing.T) {
dst := make_bmp(t, 1, 1, 4, nil)
defer bitmap_destroy(&dst)
blend_span(&dst, 0, 0, {255}, {0.25, 0.5, 0.75, 0.5}, .Normal)
testing.expect_value(t, dst.pixels[0], 32) // 0.25 * 0.5 * 255 = 31.875
testing.expect_value(t, dst.pixels[1], 64) // 0.5 * 0.5 * 255 = 63.75
testing.expect_value(t, dst.pixels[2], 96) // 0.75 * 0.5 * 255 = 95.625
testing.expect_value(t, dst.pixels[3], 128) // 0.5 * 255 = 127.5
// Undoing the premultiplication recovers the colour that went in.
bitmap_unpremultiply(&dst)
testing.expect_value(t, dst.pixels[0], 64) // 0.25 -> 63.75
testing.expect_value(t, dst.pixels[1], 128) // 0.5 -> 127.5
testing.expect_value(t, dst.pixels[2], 191) // 0.75 -> 191.25
testing.expect_value(t, dst.pixels[3], 128)
}
@(test)
test_blend_span_sets_premultiplied :: proc(t: ^testing.T) {
dst, _ := bitmap_make(1, 1, 4)
defer bitmap_destroy(&dst)
testing.expect(t, !dst.premultiplied, "bitmap_make starts straight")
blend_span(&dst, 0, 0, {255}, {1, 1, 1, 1}, .Normal)
testing.expect(t, dst.premultiplied, "compositing leaves premultiplied pixels")
}
@(test)
test_blend_span_alpha_and_coverage_scale :: proc(t: ^testing.T) {
// Opaque black background, opaque white source at half alpha.
dst := make_bmp(t, 1, 1, 4, {0, 0, 0, 255})
defer bitmap_destroy(&dst)
blend_span(&dst, 0, 0, {255}, {1, 1, 1, 0.5}, .Normal)
testing.expect_value(t, dst.pixels[0], 128)
testing.expect_value(t, dst.pixels[3], 255)
// Half coverage of a fully opaque source is the same thing.
dst2 := make_bmp(t, 1, 1, 4, {0, 0, 0, 255})
defer bitmap_destroy(&dst2)
blend_span(&dst2, 0, 0, {128}, {1, 1, 1, 1}, .Normal)
testing.expect_value(t, dst2.pixels[0], 128)
testing.expect_value(t, dst2.pixels[3], 255)
}
@(test)
test_blend_span_zero_alpha_changes_nothing :: proc(t: ^testing.T) {
before := []u8{40, 80, 120, 255}
dst := make_bmp(t, 1, 1, 4, before)
defer bitmap_destroy(&dst)
blend_span(&dst, 0, 0, {255}, {1, 1, 1, 0}, .Normal)
testing.expect(t, slice.equal(dst.pixels, before), "alpha 0 must not change the destination")
// Nor may zero coverage, whatever the alpha.
blend_span(&dst, 0, 0, {0}, {1, 1, 1, 1}, .Multiply)
testing.expect(t, slice.equal(dst.pixels, before), "coverage 0 must not change the destination")
}
// A mask of all 255 must be byte-for-byte identical to no mask at all: any
// rounding introduced by the mask multiply would show as a seam at the edge of
// a masked region.
@(test)
test_blend_span_full_mask_is_a_no_op :: proc(t: ^testing.T) {
fill := []u8{40, 80, 120, 200}
a := make_bmp(t, 6, 1, 4, fill)
defer bitmap_destroy(&a)
b := make_bmp(t, 6, 1, 4, fill)
defer bitmap_destroy(&b)
cov := []u8{255, 200, 128, 64, 1, 0}
mask := []u8{255, 255, 255, 255, 255, 255}
for mode in Blend_Mode {
blend_span(&a, 0, 0, cov, {0.3, 0.6, 0.9, 0.7}, mode)
blend_span(&b, 0, 0, cov, {0.3, 0.6, 0.9, 0.7}, mode, mask)
}
testing.expect(t, slice.equal(a.pixels, b.pixels), "an all-255 mask must change nothing")
}
@(test)
test_blend_span_mask_scales_and_erases :: proc(t: ^testing.T) {
before := []u8{0, 0, 0, 255}
dst := make_bmp(t, 2, 1, 4, before)
defer bitmap_destroy(&dst)
// Mask 0 erases the first pixel entirely; mask 128 halves the second.
blend_span(&dst, 0, 0, {255, 255}, {1, 1, 1, 1}, .Normal, {0, 128})
testing.expect_value(t, dst.pixels[0], 0)
testing.expect_value(t, dst.pixels[3], 255)
testing.expect_value(t, dst.pixels[4], 128)
testing.expect_value(t, dst.pixels[7], 255)
}
// A span that starts left of the bitmap keeps its coverage and its mask in
// step: pixel 0 must take coverage[2] and mask[2], not coverage[0].
@(test)
test_blend_span_clips_left_edge_in_step :: proc(t: ^testing.T) {
dst := make_bmp(t, 3, 1, 1, {0})
defer bitmap_destroy(&dst)
blend_span(&dst, 0, -2, {0, 0, 255, 128}, {1, 1, 1, 1}, .Normal)
testing.expect_value(t, dst.pixels[0], 255)
testing.expect_value(t, dst.pixels[1], 128)
testing.expect_value(t, dst.pixels[2], 0) // past the end of the coverage run
dst2 := make_bmp(t, 3, 1, 1, {0})
defer bitmap_destroy(&dst2)
blend_span(&dst2, 0, -2, {0, 0, 255, 128}, {1, 1, 1, 1}, .Normal, {255, 255, 0, 255})
testing.expect_value(t, dst2.pixels[0], 0) // mask[2] = 0
testing.expect_value(t, dst2.pixels[1], 128) // mask[3] = 255
}
@(test)
test_blend_span_clips_right_edge :: proc(t: ^testing.T) {
dst := make_bmp(t, 3, 1, 1, {0})
defer bitmap_destroy(&dst)
blend_span(&dst, 0, 2, {255, 255, 255}, {1, 1, 1, 1}, .Normal)
testing.expect_value(t, dst.pixels[0], 0)
testing.expect_value(t, dst.pixels[1], 0)
testing.expect_value(t, dst.pixels[2], 255)
}
@(test)
test_blend_span_out_of_range_is_ignored :: proc(t: ^testing.T) {
before := []u8{7, 7, 7, 7}
dst := make_bmp(t, 2, 2, 1, before)
defer bitmap_destroy(&dst)
blend_span(&dst, -1, 0, {255, 255}, {1, 1, 1, 1}, .Normal)
blend_span(&dst, 2, 0, {255, 255}, {1, 1, 1, 1}, .Normal)
blend_span(&dst, 0, 2, {255, 255}, {1, 1, 1, 1}, .Normal)
blend_span(&dst, 0, -5, {255, 255}, {1, 1, 1, 1}, .Normal)
blend_span(&dst, 0, 0, {}, {1, 1, 1, 1}, .Normal)
blend_span(nil, 0, 0, {255}, {1, 1, 1, 1}, .Normal)
testing.expect(t, slice.equal(dst.pixels, before), "out-of-range spans must not write")
}
// Numbers from a content stream reach the paint colour, so a NaN or an
// infinity must be absorbed here rather than turning a pixel into garbage.
@(test)
test_blend_span_rejects_non_finite_colour :: proc(t: ^testing.T) {
nan := math.nan_f64()
inf := math.INF_F64
before := []u8{0, 0, 0, 255}
dst := make_bmp(t, 1, 1, 4, before)
defer bitmap_destroy(&dst)
blend_span(&dst, 0, 0, {255}, {1, 1, 1, nan}, .Normal)
testing.expect(t, slice.equal(dst.pixels, before), "a NaN alpha paints nothing")
// A non-finite or out-of-range component clamps to the end of the range it
// points at instead of escaping into the pixel.
blend_span(&dst, 0, 0, {255}, {inf, -inf, nan, 1}, .Normal)
testing.expect_value(t, dst.pixels[0], 255)
testing.expect_value(t, dst.pixels[1], 0)
testing.expect_value(t, dst.pixels[2], 0)
testing.expect_value(t, dst.pixels[3], 255)
}
@(test)
test_blend_span_modes_on_opaque_grey :: proc(t: ^testing.T) {
// Multiply by white and Screen by black are both the identity; Multiply by
// black is black and Screen by white is white.
check :: proc(t: ^testing.T, mode: Blend_Mode, src: f64, want: u8, loc := #caller_location) {
dst := make_bmp(t, 1, 1, 1, {128}, loc)
defer bitmap_destroy(&dst)
blend_span(&dst, 0, 0, {255}, {src, src, src, 1}, mode)
testing.expectf(t, dst.pixels[0] == want, "%v with source %v: got %v, want %v",
mode, src, dst.pixels[0], want, loc = loc)
}
check(t, .Multiply, 1, 128)
check(t, .Multiply, 0, 0)
check(t, .Screen, 0, 128)
check(t, .Screen, 1, 255)
check(t, .Darken, 1, 128)
check(t, .Lighten, 0, 128)
check(t, .Difference, 0, 128)
check(t, .Normal, 1, 255)
}
// A greyscale destination takes the source's luminosity, not its red channel.
@(test)
test_blend_span_grey_destination_uses_luminosity :: proc(t: ^testing.T) {
dst := make_bmp(t, 3, 1, 1, {0})
defer bitmap_destroy(&dst)
blend_span(&dst, 0, 0, {255}, {1, 0, 0, 1}, .Normal) // Lum 0.3 -> 76.5
blend_span(&dst, 0, 1, {255}, {0, 1, 0, 1}, .Normal) // Lum 0.59 -> 150.45
blend_span(&dst, 0, 2, {255}, {0, 0, 1, 1}, .Normal) // Lum 0.11 -> 28.05
testing.expect(t, dst.pixels[0] == 76 || dst.pixels[0] == 77, "red luminosity is 0.3")
testing.expect_value(t, dst.pixels[1], 150)
testing.expect_value(t, dst.pixels[2], 28)
}
@(test)
test_blend_span_rgb_destination_has_no_alpha :: proc(t: ^testing.T) {
dst := make_bmp(t, 1, 1, 3, {255, 255, 255})
defer bitmap_destroy(&dst)
blend_span(&dst, 0, 0, {255}, {1, 0, 0, 0.5}, .Normal)
testing.expect_value(t, dst.pixels[0], 255)
testing.expect_value(t, dst.pixels[1], 128)
testing.expect_value(t, dst.pixels[2], 128)
}
// ---------------------------------------------------------------------------
// blend_span_rgba8
// ---------------------------------------------------------------------------
@(test)
test_blend_span_rgba8_places_pixels :: proc(t: ^testing.T) {
dst := make_bmp(t, 4, 1, 4, nil)
defer bitmap_destroy(&dst)
src := []u8{255, 0, 0, 255, 0, 255, 0, 128}
blend_span_rgba8(&dst, 0, 1, src, 1, .Normal)
testing.expect_value(t, dst.pixels[0], 0) // untouched
testing.expect_value(t, dst.pixels[4], 255)
testing.expect_value(t, dst.pixels[7], 255)
testing.expect_value(t, dst.pixels[8], 0)
testing.expect_value(t, dst.pixels[9], 128) // 0.5 premultiplied green
testing.expect_value(t, dst.pixels[11], 128)
testing.expect_value(t, dst.pixels[12], 0) // untouched
}
@(test)
test_blend_span_rgba8_alpha_coverage_and_mask :: proc(t: ^testing.T) {
src := []u8{255, 255, 255, 255, 255, 255, 255, 255}
// Each of the three multipliers must reach the same place in the product.
for k in 0 ..< 3 {
dst := make_bmp(t, 2, 1, 4, {0, 0, 0, 255})
defer bitmap_destroy(&dst)
switch k {
case 0: blend_span_rgba8(&dst, 0, 0, src, 0.5, .Normal)
case 1: blend_span_rgba8(&dst, 0, 0, src, 1, .Normal, {128, 128})
case 2: blend_span_rgba8(&dst, 0, 0, src, 1, .Normal, nil, {128, 128})
}
testing.expectf(t, dst.pixels[0] == 128, "case %d: got %v", k, dst.pixels[0])
}
}
@(test)
test_blend_span_rgba8_clips :: proc(t: ^testing.T) {
before := []u8{9, 9, 9, 255, 9, 9, 9, 255}
dst := make_bmp(t, 2, 1, 4, before)
defer bitmap_destroy(&dst)
blend_span_rgba8(&dst, 1, 0, {255, 255, 255, 255}, 1, .Normal) // y out of range
blend_span_rgba8(&dst, 0, 2, {255, 255, 255, 255}, 1, .Normal) // x past the edge
blend_span_rgba8(&dst, 0, 0, {}, 1, .Normal)
blend_span_rgba8(nil, 0, 0, {255, 255, 255, 255}, 1, .Normal)
testing.expect(t, slice.equal(dst.pixels, before), "clipped runs must not write")
// A run starting left of zero keeps its source pixels in step.
dst2 := make_bmp(t, 2, 1, 4, nil)
defer bitmap_destroy(&dst2)
blend_span_rgba8(&dst2, 0, -1, {255, 0, 0, 255, 0, 0, 255, 255}, 1, .Normal)
testing.expect_value(t, dst2.pixels[2], 255) // the blue pixel landed at x = 0
testing.expect_value(t, dst2.pixels[0], 0)
// ... and so do its coverage and its mask. Entry 0 of each belongs to the
// pixel that was clipped away, so reading it would paint what should be
// blank.
white := []u8{255, 255, 255, 255, 255, 255, 255, 255}
dst3 := make_bmp(t, 2, 1, 4, nil)
defer bitmap_destroy(&dst3)
blend_span_rgba8(&dst3, 0, -1, white, 1, .Normal, {255, 0})
testing.expect_value(t, dst3.pixels[0], 0)
testing.expect_value(t, dst3.pixels[3], 0)
dst4 := make_bmp(t, 2, 1, 4, nil)
defer bitmap_destroy(&dst4)
blend_span_rgba8(&dst4, 0, -1, white, 1, .Normal, nil, {255, 0})
testing.expect_value(t, dst4.pixels[0], 0)
testing.expect_value(t, dst4.pixels[3], 0)
}
// ---------------------------------------------------------------------------
// blend_bitmap
// ---------------------------------------------------------------------------
@(test)
test_blend_bitmap_onto_transparent_gives_source :: proc(t: ^testing.T) {
src := make_bmp(t, 2, 2, 4, {100, 50, 25, 200})
defer bitmap_destroy(&src)
dst := make_bmp(t, 4, 4, 4, nil)
defer bitmap_destroy(&dst)
blend_bitmap(&dst, &src, 1, 1, 1, .Normal, nil)
for j in 0 ..< 4 {
for i in 0 ..< 4 {
off := j * dst.stride + i * 4
inside := i >= 1 && i <= 2 && j >= 1 && j <= 2
want := inside ? []u8{100, 50, 25, 200} : []u8{0, 0, 0, 0}
testing.expectf(t, slice.equal(dst.pixels[off:off + 4], want),
"pixel (%d,%d): got %v, want %v", i, j, dst.pixels[off:off + 4], want)
}
}
}
@(test)
test_blend_bitmap_constant_alpha_and_mask :: proc(t: ^testing.T) {
src := make_bmp(t, 1, 1, 4, {255, 0, 0, 255})
defer bitmap_destroy(&src)
dst := make_bmp(t, 1, 1, 4, nil)
defer bitmap_destroy(&dst)
blend_bitmap(&dst, &src, 0, 0, 0.5, .Normal, nil)
testing.expect_value(t, dst.pixels[0], 128)
testing.expect_value(t, dst.pixels[3], 128)
// A one-component mask of 128 does the same job as a constant alpha of 0.5.
mask := make_bmp(t, 1, 1, 1, {128})
defer bitmap_destroy(&mask)
dst2 := make_bmp(t, 1, 1, 4, nil)
defer bitmap_destroy(&dst2)
blend_bitmap(&dst2, &src, 0, 0, 1, .Normal, &mask)
testing.expect(t, slice.equal(dst.pixels, dst2.pixels), "mask 128 == alpha 0.5")
// ... and a mask of 255 is no mask at all.
full := make_bmp(t, 1, 1, 1, {255})
defer bitmap_destroy(&full)
dst3 := make_bmp(t, 1, 1, 4, nil)
defer bitmap_destroy(&dst3)
dst4 := make_bmp(t, 1, 1, 4, nil)
defer bitmap_destroy(&dst4)
blend_bitmap(&dst3, &src, 0, 0, 1, .Normal, &full)
blend_bitmap(&dst4, &src, 0, 0, 1, .Normal, nil)
testing.expect(t, slice.equal(dst3.pixels, dst4.pixels), "an all-255 mask must change nothing")
// Zero alpha leaves the destination untouched.
dst5 := make_bmp(t, 1, 1, 4, {10, 20, 30, 255})
defer bitmap_destroy(&dst5)
blend_bitmap(&dst5, &src, 0, 0, 0, .Normal, nil)
testing.expect(t, slice.equal(dst5.pixels, []u8{10, 20, 30, 255}), "alpha 0 changes nothing")
}
@(test)
test_blend_bitmap_clips_to_destination :: proc(t: ^testing.T) {
src := make_bmp(t, 2, 2, 4, {255, 255, 255, 255})
defer bitmap_destroy(&src)
// Overhanging the top-left: only src(1,1) lands, at dst(0,0).
dst := make_bmp(t, 2, 2, 4, nil)
defer bitmap_destroy(&dst)
blend_bitmap(&dst, &src, -1, -1, 1, .Normal, nil)
testing.expect_value(t, dst.pixels[0], 255)
testing.expect_value(t, dst.pixels[4], 0)
testing.expect_value(t, dst.pixels[dst.stride], 0)
// Entirely outside: nothing at all.
dst2 := make_bmp(t, 2, 2, 4, nil)
defer bitmap_destroy(&dst2)
blend_bitmap(&dst2, &src, 5, 5, 1, .Normal, nil)
blend_bitmap(&dst2, &src, -9, 0, 1, .Normal, nil)
blend_bitmap(&dst2, &src, 0, -9, 1, .Normal, nil)
blend_bitmap(nil, &src, 0, 0, 1, .Normal, nil)
blend_bitmap(&dst2, nil, 0, 0, 1, .Normal, nil)
for b in dst2.pixels {
testing.expect_value(t, b, 0)
}
}
@(test)
test_blend_bitmap_mixed_component_counts :: proc(t: ^testing.T) {
// An opaque grey group composited onto an RGB page.
src := make_bmp(t, 1, 1, 1, {128})
defer bitmap_destroy(&src)
dst := make_bmp(t, 1, 1, 3, {0, 0, 0})
defer bitmap_destroy(&dst)
blend_bitmap(&dst, &src, 0, 0, 1, .Normal, nil)
testing.expect_value(t, dst.pixels[0], 128)
testing.expect_value(t, dst.pixels[1], 128)
testing.expect_value(t, dst.pixels[2], 128)
// An RGBA group onto a grey page takes the luminosity.
src2 := make_bmp(t, 1, 1, 4, {0, 255, 0, 255})
defer bitmap_destroy(&src2)
dst2 := make_bmp(t, 1, 1, 1, {0})
defer bitmap_destroy(&dst2)
blend_bitmap(&dst2, &src2, 0, 0, 1, .Normal, nil)
testing.expect_value(t, dst2.pixels[0], 150)
}
@(test)
test_blend_bitmap_applies_the_blend_mode :: proc(t: ^testing.T) {
// A white group multiplied onto a grey page is the identity; a black one
// wipes it out.
src := make_bmp(t, 1, 1, 4, {255, 255, 255, 255})
defer bitmap_destroy(&src)
dst := make_bmp(t, 1, 1, 4, {128, 128, 128, 255})
defer bitmap_destroy(&dst)
blend_bitmap(&dst, &src, 0, 0, 1, .Multiply, nil)
testing.expect_value(t, dst.pixels[0], 128)
testing.expect_value(t, dst.pixels[3], 255)
black := make_bmp(t, 1, 1, 4, {0, 0, 0, 255})
defer bitmap_destroy(&black)
dst2 := make_bmp(t, 1, 1, 4, {128, 128, 128, 255})
defer bitmap_destroy(&dst2)
blend_bitmap(&dst2, &black, 0, 0, 1, .Multiply, nil)
testing.expect_value(t, dst2.pixels[0], 0)
testing.expect_value(t, dst2.pixels[3], 255)
}
// ---------------------------------------------------------------------------
// mask_from_bitmap (11.6.5)
// ---------------------------------------------------------------------------
@(test)
test_mask_from_bitmap_luminosity :: proc(t: ^testing.T) {
src := make_bmp(t, 6, 1, 3, nil)
defer bitmap_destroy(&src)
px := [][3]u8{{0, 0, 0}, {128, 128, 128}, {255, 255, 255}, {255, 0, 0}, {0, 255, 0}, {0, 0, 255}}
for p, i in px {
src.pixels[i * 3 + 0] = p[0]
src.pixels[i * 3 + 1] = p[1]
src.pixels[i * 3 + 2] = p[2]
}
m, err := mask_from_bitmap(&src, true, {0, 0, 0}, nil)
testing.expect_value(t, err, Error.None)
defer bitmap_destroy(&m)
testing.expect_value(t, m.n, 1)
testing.expect_value(t, m.width, 6)
testing.expect_value(t, m.height, 1)
testing.expect_value(t, m.pixels[0], 0)
testing.expect_value(t, m.pixels[1], 128) // mid grey stays mid grey
testing.expect_value(t, m.pixels[2], 255)
testing.expect(t, m.pixels[3] == 76 || m.pixels[3] == 77, "red is 0.3")
testing.expect_value(t, m.pixels[4], 150) // 0.59 * 255 = 150.45
testing.expect_value(t, m.pixels[5], 28) // 0.11 * 255 = 28.05
}
// An alpha mask ignores the colour entirely; a bitmap with no alpha channel is
// opaque, so its mask is solid.
@(test)
test_mask_from_bitmap_alpha :: proc(t: ^testing.T) {
src := make_bmp(t, 2, 1, 4, nil)
defer bitmap_destroy(&src)
copy(src.pixels[0:4], []u8{200, 100, 50, 200})
copy(src.pixels[4:8], []u8{0, 0, 0, 0})
m, err := mask_from_bitmap(&src, false, {1, 1, 1}, nil)
testing.expect_value(t, err, Error.None)
defer bitmap_destroy(&m)
testing.expect_value(t, m.pixels[0], 200)
testing.expect_value(t, m.pixels[1], 0)
opaque := make_bmp(t, 1, 1, 3, {10, 20, 30})
defer bitmap_destroy(&opaque)
m2, err2 := mask_from_bitmap(&opaque, false, {0, 0, 0}, nil)
testing.expect_value(t, err2, Error.None)
defer bitmap_destroy(&m2)
testing.expect_value(t, m2.pixels[0], 255)
}
// A luminosity mask composites the group onto an opaque backdrop first, so the
// backdrop is what shows through wherever the group painted nothing. This is
// why /BC defaults to black: unpainted area masks everything out.
@(test)
test_mask_from_bitmap_luminosity_uses_the_backdrop :: proc(t: ^testing.T) {
src := make_bmp(t, 2, 1, 4, nil)
defer bitmap_destroy(&src)
copy(src.pixels[0:4], []u8{128, 128, 128, 128}) // premultiplied white at 50%
copy(src.pixels[4:8], []u8{0, 0, 0, 0}) // nothing painted
black, e1 := mask_from_bitmap(&src, true, {0, 0, 0}, nil)
testing.expect_value(t, e1, Error.None)
defer bitmap_destroy(&black)
testing.expect_value(t, black.pixels[0], 128)
testing.expect_value(t, black.pixels[1], 0)
white, e2 := mask_from_bitmap(&src, true, {1, 1, 1}, nil)
testing.expect_value(t, e2, Error.None)
defer bitmap_destroy(&white)
testing.expect_value(t, white.pixels[0], 255) // 0.5 white + 0.5 white
testing.expect_value(t, white.pixels[1], 255)
}
// The same pixel written as straight alpha must give the same mask: the flag,
// not the caller, decides whether the a*C factor is already there.
@(test)
test_mask_from_bitmap_honours_the_premultiplied_flag :: proc(t: ^testing.T) {
straight := make_bmp(t, 1, 1, 4, {255, 255, 255, 128})
defer bitmap_destroy(&straight)
straight.premultiplied = false
m, err := mask_from_bitmap(&straight, true, {0, 0, 0}, nil)
testing.expect_value(t, err, Error.None)
defer bitmap_destroy(&m)
testing.expect_value(t, m.pixels[0], 128)
m2, err2 := mask_from_bitmap(&straight, true, {1, 1, 1}, nil)
testing.expect_value(t, err2, Error.None)
defer bitmap_destroy(&m2)
testing.expect_value(t, m2.pixels[0], 255)
}
@(test)
test_mask_from_bitmap_rejects_bad_input :: proc(t: ^testing.T) {
_, err := mask_from_bitmap(nil, true, {0, 0, 0}, nil)
testing.expect_value(t, err, Error.Invalid_Argument)
empty: Bitmap
_, err2 := mask_from_bitmap(&empty, false, {0, 0, 0}, nil)
testing.expect_value(t, err2, Error.Invalid_Argument)
}
// /TR is applied to every mask value. A type 2 function with C0 = 1, C1 = 0 and
// N = 1 is the inverting transfer, which is the one real files use to turn a
// luminosity mask inside out.
@(test)
test_mask_from_bitmap_transfer_function :: proc(t: ^testing.T) {
domain := [2]f64{0, 1}
range := [2]f64{0, 1}
c0 := [1]f64{1}
c1 := [1]f64{0}
fn := Function{
kind = .Exponential,
n_in = 1,
n_out = 1,
domain = domain[:],
range = range[:],
c0 = c0[:],
c1 = c1[:],
exponent = 1,
}
src := make_bmp(t, 3, 1, 4, nil)
defer bitmap_destroy(&src)
copy(src.pixels[0:4], []u8{0, 0, 0, 0})
copy(src.pixels[4:8], []u8{0, 0, 0, 200})
copy(src.pixels[8:12], []u8{0, 0, 0, 255})
m, err := mask_from_bitmap(&src, false, {0, 0, 0}, &fn)
testing.expect_value(t, err, Error.None)
defer bitmap_destroy(&m)
testing.expect_value(t, m.pixels[0], 255)
testing.expect_value(t, m.pixels[1], 55)
testing.expect_value(t, m.pixels[2], 0)
// A function with the wrong arity is ignored rather than obeyed: a broken
// /TR must not blank the mask.
bad := fn
bad.n_in = 2
m2, err2 := mask_from_bitmap(&src, false, {0, 0, 0}, &bad)
testing.expect_value(t, err2, Error.None)
defer bitmap_destroy(&m2)
testing.expect_value(t, m2.pixels[1], 200)
}
@(test)
test_blend_transfer_table_identity :: proc(t: ^testing.T) {
lut := blend_transfer_table(nil)
for i in 0 ..< 256 {
testing.expect_value(t, lut[i], u8(i))
}
}
// ---------------------------------------------------------------------------
// Quantisation
// ---------------------------------------------------------------------------
@(test)
test_blend_quantize :: proc(t: ^testing.T) {
testing.expect_value(t, blend_quantize(0), 0)
testing.expect_value(t, blend_quantize(1), 255)
testing.expect_value(t, blend_quantize(0.5), 128) // 127.5 rounds up
testing.expect_value(t, blend_quantize(2), 255) // clamped
testing.expect_value(t, blend_quantize(-1), 0) // clamped
testing.expect_value(t, blend_quantize(math.nan_f64()), 0) // NaN must not wrap
testing.expect_value(t, blend_quantize(math.INF_F64), 255)
testing.expect_value(t, blend_quantize(1.0 / 255), 1)
}