// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
// Changing how pixels are spelled without changing what they say.
//
// A conversion here is one of five moves: widening 8 bits to 16 or to a float,
// narrowing back, adding an alpha channel, dropping one, and turning grey into
// equal parts red, green and blue. Nothing is gamma-corrected, because nothing
// here knows what gamma the image means; nothing is premultiplied, unless asked.
//
// Narrowing rounds rather than truncates. Truncation costs half a step of
// brightness on average, which over a photograph is a visible darkening, and
// costs nothing to avoid.
///|
/// The same image, in another sample format.
///
/// Colour is preserved as far as the target can hold it: a grey image becomes
/// grey, an image without alpha becomes opaque, and narrowing rounds to nearest.
pub fn Image::convert(self : Image, format : PixelFormat) -> Image {
if self.format == format {
return self
}
let stride = self.width * format.bytes()
let buffer : FixedArray[Byte] = FixedArray::make(
if self.height == 0 {
0
} else {
stride * self.height
},
b'\x00',
)
let from = self.format.bytes()
let to = format.bytes()
for y in 0.. Image {
if self.premultiplied || !self.format.has_alpha() {
return self
}
self.map_alpha(true)
}
///|
/// The same image with alpha divided back out.
///
/// The inverse of `to_premultiplied` up to rounding, which is why a round trip
/// is only exact for images whose colour is already premultiplied.
pub fn Image::to_straight(self : Image) -> Image {
if !self.premultiplied {
return self
}
self.map_alpha(false)
}
///|
/// Every colour channel multiplied by alpha, or divided by it, on a copy.
///
/// The arithmetic is done on samples, not through `Pixel`, because dividing by
/// alpha is the one case where rounding through a float can land on the wrong
/// side of a half: a byte that came out of `colour × alpha` divides back to the
/// byte it started as only when the two roundings agree.
fn Image::map_alpha(self : Image, multiply : Bool) -> Image {
let buffer : FixedArray[Byte] = FixedArray::make(
self.pixels.length(),
b'\x00',
)
buffer.blit_from_bytesview(0, self.pixels)
let bytes = self.format.bytes()
for y in 0..
buffer[at] = scale_alpha_byte(
buffer[at].to_int(),
buffer[at + 1].to_int(),
multiply,
)
Rgba8 => {
let a = buffer[at + 3].to_int()
buffer[at] = scale_alpha_byte(buffer[at].to_int(), a, multiply)
buffer[at + 1] = scale_alpha_byte(
buffer[at + 1].to_int(),
a,
multiply,
)
buffer[at + 2] = scale_alpha_byte(
buffer[at + 2].to_int(),
a,
multiply,
)
}
GrayAlpha16 =>
put_word(
buffer,
at,
scale_alpha_word(
word_of(buffer, at),
word_of(buffer, at + 2),
multiply,
),
)
Rgba16 => {
let a = word_of(buffer, at + 6)
put_word(
buffer,
at,
scale_alpha_word(word_of(buffer, at), a, multiply),
)
put_word(
buffer,
at + 2,
scale_alpha_word(word_of(buffer, at + 2), a, multiply),
)
put_word(
buffer,
at + 4,
scale_alpha_word(word_of(buffer, at + 4), a, multiply),
)
}
RgbaF32 => {
let a = float_in(buffer, at + 12).to_double()
for channel in 0..<3 {
let offset = at + channel * 4
let value = float_in(buffer, offset).to_double()
let scaled = if multiply {
value * a
} else if a > 0.0 {
value / a
} else {
0.0
}
put_float(buffer, offset, unit_to_float(scaled))
}
}
_ => ()
}
}
}
assemble(
self.width,
self.height,
self.format,
buffer,
multiply,
self.metadata,
)
}
///|
fn word_of(data : FixedArray[Byte], offset : Int32) -> Int32 {
data[offset].to_int() * 256 + data[offset + 1].to_int()
}
///|
/// `value × alpha / 255`, or the division back, rounded.
fn scale_alpha_byte(value : Int32, alpha : Int32, multiply : Bool) -> Byte {
let result = if multiply {
(value * alpha + 127) / 255
} else if alpha == 0 {
0
} else {
(value * 255 + alpha / 2) / alpha
}
(if result > 255 { 255 } else { result }).to_byte()
}
///|
/// The same for sixteen-bit samples.
fn scale_alpha_word(value : Int32, alpha : Int32, multiply : Bool) -> Int32 {
let result = if multiply {
(value * alpha + 32767) / 65535
} else if alpha == 0 {
0
} else {
(value * 65535 + alpha / 2) / alpha
}
if result > 65535 {
65535
} else {
result
}
}