ph-color-bake 0.1.0

Host-side generator for auditable ph-color matrices, fixed-point LUTs, and golden vectors
Documentation
//! Emit Rust `const` source matching `ph-color` constructors.

/// Emit a `Matrix3::from_q428` initializer for `Src` → `Dst` type paths.
#[must_use]
pub fn matrix_from_q428(name: &str, src_ty: &str, dst_ty: &str, coefs: [[i32; 3]; 3]) -> String {
    format!(
        "pub const {name}: ::ph_color::Matrix3<{src_ty}, {dst_ty}> = ::ph_color::Matrix3::from_q428(::ph_color::Q4_28::mat3_from_raw({coefs:?}));\n"
    )
}

/// Emit an `InterpLut::from_knots` initializer plus a companion `MAX_ERR_LSB` const.
#[must_use]
pub fn interp_lut<const N: usize>(name: &str, knots: [u16; N], max_err_lsb: u16) -> String {
    let err_name = format!("{name}_MAX_ERR_LSB");
    format!(
        "pub const {err_name}: u16 = {max_err_lsb};\npub const {name}: ::ph_color::InterpLut<{N}> = ::ph_color::InterpLut::from_knots(::ph_color::Q0_16::array_from_raw({knots:?}), {err_name});\n"
    )
}

/// Emit a typed sRGB OETF wrapper (`encode` accepts only `Color<Srgb, Linear>`).
#[must_use]
pub fn srgb_oetf(name: &str, knots: [u16; 257], max_err_lsb: u16) -> String {
    let err_name = format!("{name}_MAX_ERR_LSB");
    format!(
        "pub const {err_name}: u16 = {max_err_lsb};\npub const {name}: ::ph_color::SrgbOetf = ::ph_color::SrgbOetf::from_knots(::ph_color::Q0_16::array_from_raw({knots:?}), {err_name});\n"
    )
}

/// Emit Oklab Q4.28 matrices, affine offset, and the 257-knot cube-root LUT.
#[must_use]
pub fn oklab_tables() -> String {
    let cbrt = crate::oklab::bake_cbrt257();
    let mut src = interp_lut("CBRT", cbrt.knots, cbrt.max_err_lsb);
    src.push_str(&format!(
        "pub const OKLAB_M1: [[::ph_color::Q4_28; 3]; 3] = ::ph_color::Q4_28::mat3_from_raw({:?});\n",
        crate::oklab::to_q428_mat(crate::oklab::m1())
    ));
    src.push_str(&format!(
        "pub const OKLAB_M2: [[::ph_color::Q4_28; 3]; 3] = ::ph_color::Q4_28::mat3_from_raw({:?});\n",
        crate::oklab::to_q428_mat(crate::oklab::m2())
    ));
    src.push_str(&format!(
        "pub const OKLAB_M1_INV: [[::ph_color::Q4_28; 3]; 3] = ::ph_color::Q4_28::mat3_from_raw({:?});\n",
        crate::oklab::to_q428_mat(crate::oklab::m1_inv())
    ));
    src.push_str(&format!(
        "pub const OKLAB_M2_INV: [[::ph_color::Q4_28; 3]; 3] = ::ph_color::Q4_28::mat3_from_raw({:?});\n",
        crate::oklab::to_q428_mat(crate::oklab::m2_inv())
    ));
    src.push_str(&format!(
        "pub const OKLAB_M2_INV_OFFSET: [::ph_color::Q4_28; 3] = ::ph_color::Q4_28::row_from_raw({:?});\n",
        crate::oklab::m2_inv_offset_q428()
    ));
    src
}

/// Emit the complete checked-in Oklab generated module used by `ph-color`.
#[must_use]
pub fn oklab_module() -> String {
    let cbrt = crate::oklab::bake_cbrt257();
    let mut src = String::from(
        "// @generated by `cargo xtask generate`; do not edit by hand.\n\
         // Formulas: crates/color-bake/src/oklab.rs\n\n\
         use crate::fixed::{Q0_16, Q4_28};\n\
         use crate::interp::InterpLut;\n\n",
    );

    push_scalar(
        &mut src,
        "/// Q4.28 encoding of 0.5, used to bias Oklab a/b into UQ0.16.",
        "A_B_BIAS_Q428",
        "Q4_28",
        &format!("Q4_28::from_raw({})", crate::matrix::to_q428(0.5)),
    );
    push_matrix(
        &mut src,
        "/// Linear sRGB to LMS, Q4.28.",
        "OKLAB_M1",
        crate::oklab::to_q428_mat(crate::oklab::m1()),
    );
    push_matrix(
        &mut src,
        "/// LMS-prime to Lab, Q4.28.",
        "OKLAB_M2",
        crate::oklab::to_q428_mat(crate::oklab::m2()),
    );
    push_matrix(
        &mut src,
        "/// LMS to linear sRGB, Q4.28.",
        "OKLAB_M1_INV",
        crate::oklab::to_q428_mat(crate::oklab::m1_inv()),
    );
    push_matrix(
        &mut src,
        "/// Lab to LMS-prime, Q4.28; expects offset a/b.",
        "OKLAB_M2_INV",
        crate::oklab::to_q428_mat(crate::oklab::m2_inv()),
    );
    push_row(
        &mut src,
        "/// Affine addend compensating for the stored +0.5 a/b bias.",
        "OKLAB_M2_INV_OFFSET",
        crate::oklab::m2_inv_offset_q428(),
    );
    push_scalar(
        &mut src,
        "/// Maximum absolute cube-root LUT error in UQ0.16 LSBs.",
        "CBRT_MAX_ERR_LSB",
        "u16",
        &cbrt.max_err_lsb.to_string(),
    );
    src.push_str("/// Segmented cube-root table on the unit interval, 257 knots.\n");
    src.push_str("#[rustfmt::skip]\n");
    src.push_str(
        "pub const CBRT: InterpLut<257> = InterpLut::from_knots(Q0_16::array_from_raw([\n",
    );
    for knot in cbrt.knots {
        src.push_str(&format!("    {knot},\n"));
    }
    src.push_str("]), CBRT_MAX_ERR_LSB);\n");
    src
}

fn push_scalar(src: &mut String, docs: &str, name: &str, ty: &str, value: &str) {
    src.push_str(docs);
    src.push('\n');
    src.push_str(&format!("pub const {name}: {ty} = {value};\n\n"));
}

fn push_matrix(src: &mut String, docs: &str, name: &str, rows: [[i32; 3]; 3]) {
    src.push_str(docs);
    src.push('\n');
    src.push_str("#[rustfmt::skip]\n");
    src.push_str(&format!(
        "pub const {name}: [[Q4_28; 3]; 3] = Q4_28::mat3_from_raw([\n"
    ));
    for row in rows {
        src.push_str(&format!("    [{}, {}, {}],\n", row[0], row[1], row[2]));
    }
    src.push_str("]);\n\n");
}

fn push_row(src: &mut String, docs: &str, name: &str, row: [i32; 3]) {
    src.push_str(docs);
    src.push('\n');
    src.push_str("#[rustfmt::skip]\n");
    src.push_str(&format!(
        "pub const {name}: [Q4_28; 3] = Q4_28::row_from_raw([{}, {}, {}]);\n\n",
        row[0], row[1], row[2]
    ));
}

/// Emit a typed sRGB EOTF wrapper (`decode` accepts only `Color<Srgb, Encoded>`).
#[must_use]
pub fn srgb_eotf(name: &str, knots: [u16; 257], max_err_lsb: u16) -> String {
    let err_name = format!("{name}_MAX_ERR_LSB");
    format!(
        "pub const {err_name}: u16 = {max_err_lsb};\npub const {name}: ::ph_color::SrgbEotf = ::ph_color::SrgbEotf::from_knots(::ph_color::Q0_16::array_from_raw({knots:?}), {err_name});\n"
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::Primaries;
    use crate::lut::{bake_lut, identity};
    use crate::matrix::{identity_q428, rgb_to_rgb};

    #[test]
    fn emitted_matrix_constructs() {
        let coefs = rgb_to_rgb(Primaries::srgb(), Primaries::srgb()).expect("identity");
        assert_eq!(coefs, identity_q428());
        let src = matrix_from_q428("IDENTITY", "::ph_color::Srgb", "::ph_color::Srgb", coefs);
        assert!(src.contains("Matrix3::from_q428"));
        let m = ::ph_color::Matrix3::<::ph_color::Srgb, ::ph_color::Srgb>::from_q428(
            ::ph_color::Q4_28::mat3_from_raw(coefs),
        );
        let c = ::ph_color::Color::<::ph_color::Srgb, ::ph_color::Linear>::new(
            ::ph_color::Q0_16::array_from_raw([9, 8, 7]),
        );
        assert_eq!(m.apply(c).ch, ::ph_color::Q0_16::array_from_raw([9, 8, 7]));
    }

    #[test]
    fn emitted_lut_constructs_with_max_err() {
        let baked = bake_lut::<17>(identity);
        let src = interp_lut("ID17", baked.knots, baked.max_err_lsb);
        assert!(src.contains("InterpLut::from_knots"));
        assert!(src.contains("ID17_MAX_ERR_LSB"));
        let lut = ::ph_color::InterpLut::<17>::from_knots(
            ::ph_color::Q0_16::array_from_raw(baked.knots),
            baked.max_err_lsb,
        );
        assert_eq!(lut.max_err_lsb(), baked.max_err_lsb);
        assert_eq!(lut.lookup(::ph_color::Q0_16::ZERO), ::ph_color::Q0_16::ZERO);
        assert_eq!(lut.lookup(::ph_color::Q0_16::ONE), ::ph_color::Q0_16::ONE);
    }

    #[test]
    fn emitted_srgb_tables_are_typed_wrappers() {
        let o = bake_lut::<257>(crate::srgb::oetf);
        let e = bake_lut::<257>(crate::srgb::eotf);
        let o_src = srgb_oetf("SRGB_OETF", o.knots, o.max_err_lsb);
        let e_src = srgb_eotf("SRGB_EOTF", e.knots, e.max_err_lsb);
        assert!(o_src.contains("SrgbOetf::from_knots"));
        assert!(!o_src.contains("InterpLut<257>"));
        assert!(e_src.contains("SrgbEotf::from_knots"));
        assert!(!e_src.contains("InterpLut<257>"));
        let oetf = ::ph_color::SrgbOetf::from_knots(
            ::ph_color::Q0_16::array_from_raw(o.knots),
            o.max_err_lsb,
        );
        let eotf = ::ph_color::SrgbEotf::from_knots(
            ::ph_color::Q0_16::array_from_raw(e.knots),
            e.max_err_lsb,
        );
        assert_eq!(oetf.max_err_lsb(), o.max_err_lsb);
        assert_eq!(eotf.max_err_lsb(), e.max_err_lsb);
    }

    #[test]
    fn emitted_oklab_tables_match_shipped_constants() {
        let src = oklab_tables();
        assert!(src.contains("InterpLut::from_knots"));
        assert!(src.contains("CBRT_MAX_ERR_LSB"));
        assert!(src.contains(&format!(
            "pub const OKLAB_M1: [[::ph_color::Q4_28; 3]; 3] = ::ph_color::Q4_28::mat3_from_raw({:?});",
            crate::oklab::to_q428_mat(crate::oklab::m1())
        )));
        assert!(src.contains(&format!(
            "pub const OKLAB_M2_INV_OFFSET: [::ph_color::Q4_28; 3] = ::ph_color::Q4_28::row_from_raw({:?});",
            crate::oklab::m2_inv_offset_q428()
        )));
        let baked = crate::oklab::bake_cbrt257();
        let lut = ::ph_color::InterpLut::<257>::from_knots(
            ::ph_color::Q0_16::array_from_raw(baked.knots),
            baked.max_err_lsb,
        );
        assert_eq!(lut.max_err_lsb(), ::ph_color::CBRT_MAX_ERR_LSB);
        assert_eq!(lut.lookup(::ph_color::Q0_16::ZERO), ::ph_color::Q0_16::ZERO);
        assert_eq!(lut.lookup(::ph_color::Q0_16::ONE), ::ph_color::Q0_16::ONE);
    }
}