Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).

## [Unreleased]

### Added

* **`vcgp` decoder** — Apple `vcgp` (Video Card Gamma Profile) tag now decodes
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CHANGELOG description calls vcgp “Video Card Gamma Profile”, but this PR (and the new decoder) treats it as “Video Card Gamma and Primary”. Updating the wording will reduce confusion with vcgt and align with the tag’s purpose.

Suggested change
* **`vcgp` decoder** — Apple `vcgp` (Video Card Gamma Profile) tag now decodes
* **`vcgp` decoder** — Apple `vcgp` (Video Card Gamma and Primary) tag now decodes

Copilot uses AI. Check for mistakes.
to structured TOML with per-channel fields (`gamma_in`, `gamma_out`, `min`,
`max`) instead of a raw hex dump. All four values are decoded as S15.16
fixed-point, consistent with the encoding used by the related `vcgt` formula
type.
Comment on lines +19 to +22
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CHANGELOG entry does not match the implementation: the decoder outputs per-channel gamma_in, gamma_out, primary_x, and primary_y, where only the gamma fields are S15.16 and the primaries are normalized U32. Please update the text to avoid mentioning min/max and “All four values are decoded as S15.16”.

Suggested change
to structured TOML with per-channel fields (`gamma_in`, `gamma_out`, `min`,
`max`) instead of a raw hex dump. All four values are decoded as S15.16
fixed-point, consistent with the encoding used by the related `vcgt` formula
type.
to structured TOML with per-channel fields (`gamma_in`, `gamma_out`,
`primary_x`, `primary_y`) instead of a raw hex dump. `gamma_in` and
`gamma_out` are decoded as S15.16 fixed-point values, while `primary_x`
and `primary_y` are decoded as normalized U32 values.

Copilot uses AI. Check for mistakes.

## [0.2.0] - 2026-04-24

### Added
Expand Down
4 changes: 3 additions & 1 deletion src/tag/parsed_tag.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use crate::tag::{
multi_localized_unicode::MultiLocalizedUnicodeType, named_color2::NamedColor2Type,
parametric_curve::ParametricCurveType, raw::RawType, s15fixed16array::S15Fixed16ArrayType,
signature::SignatureType, text::TextType, text_description::TextDescriptionType,
vcgt::VcgtType, xyz_array::XYZArrayType,
vcgp::VcgpType, vcgt::VcgtType, xyz_array::XYZArrayType,
},
TagData,
};
Expand Down Expand Up @@ -52,6 +52,7 @@ pub enum ParsedTag {
S15Fixed16Array(S15Fixed16ArrayType),
Text(TextType),
TextDescription(TextDescriptionType),
Vcgp(VcgpType),
Vcgt(VcgtType),
ViewingConditions(crate::tag::tagdata::viewing_conditions::ViewingConditionsType),
XYZArray(XYZArrayType),
Expand Down Expand Up @@ -84,6 +85,7 @@ impl From<&TagData> for ParsedTag {
TagData::Signature(signature) => ParsedTag::Signature(signature.into()),
TagData::Text(text) => ParsedTag::Text(text.into()),
TagData::TextDescription(text_desc) => ParsedTag::TextDescription(text_desc.into()),
TagData::Vcgp(vcgp) => ParsedTag::Vcgp(vcgp.into()),
TagData::Vcgt(vcgt) => ParsedTag::Vcgt(vcgt.into()),
TagData::ViewingConditions(vc) => ParsedTag::ViewingConditions(vc.into()),
TagData::XYZArray(xyz) => ParsedTag::XYZArray(xyz.into()),
Expand Down
261 changes: 236 additions & 25 deletions src/tag/tagdata/vcgp.rs
Original file line number Diff line number Diff line change
@@ -1,40 +1,251 @@
// SPDX-License-Identifier: Apache-2.0 OR MIT
// Copyright (c) 2021-2026, Harbers Bik LLC

#![allow(unused)]
//! Apple `vcgp` (Video Card Gamma and Primary) tag data decoder.
//!
//! `vcgp` is an Apple-private tag that combines two aspects of calibrated display
//! state into one structure. It is not part of the ICC specification.
//!
//! ## Relationship to `vcgt`
//!
//! | Tag | Stands for | Contents |
//! |--------|-----------------------------------|-----------------------------------|
//! | `vcgt` | Video Card Gamma Table | Calibration LUT only |
//! | `vcgp` | Video Card Gamma and Primary | Calibration curves + chromaticity |
//!
//! ## Binary layout
//!
//! ```text
//! Bytes 0– 3 type signature 'vcgp' (0x76636770)
//! Bytes 4– 7 reserved 0
//!
//! ── Calibration section (per-channel gamma, 24 bytes) ──────────────────────
//! Bytes 8–11 red gamma_in S15.16 — reference / source gamma
//! Bytes 12–15 red gamma_out S15.16 — target display gamma
//! Bytes 16–19 green gamma_in S15.16
//! Bytes 20–23 green gamma_out S15.16
//! Bytes 24–27 blue gamma_in S15.16
//! Bytes 28–31 blue gamma_out S15.16
//!
//! ── Chromaticity section (per-channel primaries, 24 bytes) ─────────────────
//! Bytes 32–35 red primary_x U32 normalized (value / 0xFFFF_FFFF → [0, 1])
//! Bytes 36–39 red primary_y U32 normalized
//! Bytes 40–43 green primary_x U32 normalized
//! Bytes 44–47 green primary_y U32 normalized
//! Bytes 48–51 blue primary_x U32 normalized
//! Bytes 52–55 blue primary_y U32 normalized
//! ```
//!
//! ## Field semantics (reverse-engineered)
//!
//! **Calibration section** — same role as `vcgt`. The `gamma_in` field (observed: 3.0)
//! encodes the reference or native panel gamma; `gamma_out` (observed: 2.4) is the
//! target electro-optical transfer function gamma (matching the sRGB TRC). Both are
//! stored as S15.16 big-endian fixed-point.
//!
//! **Chromaticity section** — distinguishes `vcgp` from `vcgt`. Encodes the effective
//! RGB primary chromaticities (x, y) of the display after calibration, using a
//! normalized unsigned 32-bit encoding where `0x00000000` = 0.0 and `0xFFFFFFFF` = 1.0.
//!
//! In the test profiles examined (Color LCD, SyncMaster, LG HDR WQHD) all three
//! channels show the same placeholder values (`x ≈ 0.0`, `y ≈ 0.2`), indicating the
//! chromaticity section is only populated after a full ColorSync calibration.

use serde::Serialize;
use zerocopy::{BigEndian, Immutable, KnownLayout, TryFromBytes, Unaligned, I32, U32};

// Typical structure for Vcgp tag
pub struct VcgpData {
// Header/signature
signature: [u8; 4], // "vcgp"
use crate::{s15fixed16, tag::tagdata::VcgpData};

// Parameters that might include:
gamma: f32, // Gamma value (e.g., 2.2)
black_point: f32, // Black level adjustment
white_point: f32, // White level adjustment
// ── Binary layout ─────────────────────────────────────────────────────────────

// Potentially other parameters for:
// - Contrast
// - Brightness
// - Individual RGB channel controls
/// Fixed-size layout of the `vcgp` payload (56 bytes total including the 8-byte header).
#[derive(TryFromBytes, KnownLayout, Immutable, Unaligned)]
#[repr(C, packed)]
struct Layout {
_type_signature: [u8; 4],
_reserved: [u8; 4],
// Calibration section: per-channel gamma (S15.16)
red_gamma_in: I32<BigEndian>,
red_gamma_out: I32<BigEndian>,
green_gamma_in: I32<BigEndian>,
green_gamma_out: I32<BigEndian>,
blue_gamma_in: I32<BigEndian>,
blue_gamma_out: I32<BigEndian>,
// Chromaticity section: per-channel primaries (U32 normalized)
red_primary_x: U32<BigEndian>,
red_primary_y: U32<BigEndian>,
green_primary_x: U32<BigEndian>,
green_primary_y: U32<BigEndian>,
blue_primary_x: U32<BigEndian>,
blue_primary_y: U32<BigEndian>,
}

/*
use crate::tags::common::*;
use serde::Serialize;
// ── Normalization helper ──────────────────────────────────────────────────────

/// Convert a U32 chromaticity value to `f64` in [0, 1], rounded to 6 decimal places.
///
/// The encoding uses the full 32-bit range: `0x00000000` → 0.0, `0xFFFFFFFF` → 1.0.
/// Rounding to 6 dp removes floating-point noise from the `/ 0xFFFF_FFFF` division while
/// preserving more precision than ICC profiles ever report (4 significant figures).
#[inline]
fn norm_u32(v: u32) -> f64 {
let raw = v as f64 / u32::MAX as f64;
(raw * 1_000_000.0).round() / 1_000_000.0
}

// ── Parsed / serialisable representation ──────────────────────────────────────

/// Per-channel data decoded from an Apple `vcgp` tag.
#[derive(Serialize)]
pub struct VcgpChannel {
/// Reference or source gamma (observed: 3.0), stored as S15.16.
pub gamma_in: f64,
/// Target display gamma (observed: 2.4, matching the sRGB electro-optical TRC),
/// stored as S15.16.
pub gamma_out: f64,
/// Primary chromaticity x coordinate, normalized U32 encoding.
/// Populated after ColorSync calibration; placeholder value ≈ 0.0 otherwise.
pub primary_x: f64,
/// Primary chromaticity y coordinate, normalized U32 encoding.
/// Placeholder value ≈ 0.2 in uncalibrated profiles.
pub primary_y: f64,
}

#[derive(Debug, Serialize, Clone, PartialEq)]
pub struct Vcgp {
tbd: Vec<u8>, // can not find any information about this tag
/// Decoded Apple `vcgp` (Video Card Gamma and Primary) tag.
///
/// Serialises to TOML as three per-channel sections:
///
/// ```toml
/// [vcgp.red]
/// gamma_in = 3.0
/// gamma_out = 2.4
/// primary_x = 0.0
/// primary_y = 0.2
///
/// [vcgp.green]
/// # … identical in uncalibrated profiles
///
/// [vcgp.blue]
/// # … identical in uncalibrated profiles
/// ```
#[derive(Serialize)]
pub struct VcgpType {
pub red: VcgpChannel,
pub green: VcgpChannel,
pub blue: VcgpChannel,
}

impl Vcgp {
pub fn try_new(buf: &mut &[u8]) -> Result<Self> {
Ok(Vcgp {
tbd: read_vec(buf, buf.len())?,
})
// ── Parser ────────────────────────────────────────────────────────────────────

impl From<&VcgpData> for VcgpType {
fn from(data: &VcgpData) -> Self {
let fallback = || VcgpType {
red: VcgpChannel { gamma_in: 0.0, gamma_out: 0.0, primary_x: 0.0, primary_y: 0.0 },
green: VcgpChannel { gamma_in: 0.0, gamma_out: 0.0, primary_x: 0.0, primary_y: 0.0 },
blue: VcgpChannel { gamma_in: 0.0, gamma_out: 0.0, primary_x: 0.0, primary_y: 0.0 },
};

let Some(layout) = Layout::try_ref_from_bytes(data.0.as_slice()).ok() else {
return fallback();
};
Comment on lines +148 to +150
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Layout::try_ref_from_bytes(data.0.as_slice()) will fail if the tag payload is larger than exactly 56 bytes (e.g., if a profile includes trailing padding/extra vendor bytes), causing an unnecessary all-zero fallback. Consider parsing only the first 56 bytes (e.g., via data.0.get(..56) before try_ref_from_bytes, or a *_from_prefix helper) so valid prefixes still decode.

Suggested change
let Some(layout) = Layout::try_ref_from_bytes(data.0.as_slice()).ok() else {
return fallback();
};
let Some(prefix) = data.0.get(..core::mem::size_of::<Layout>()) else {
return fallback();
};
let Some(layout) = Layout::try_ref_from_bytes(prefix).ok() else {
return fallback();
};

Copilot uses AI. Check for mistakes.

VcgpType {
red: VcgpChannel {
gamma_in: s15fixed16(layout.red_gamma_in.get()),
gamma_out: s15fixed16(layout.red_gamma_out.get()),
primary_x: norm_u32(layout.red_primary_x.get()),
primary_y: norm_u32(layout.red_primary_y.get()),
},
green: VcgpChannel {
gamma_in: s15fixed16(layout.green_gamma_in.get()),
gamma_out: s15fixed16(layout.green_gamma_out.get()),
primary_x: norm_u32(layout.green_primary_x.get()),
primary_y: norm_u32(layout.green_primary_y.get()),
},
blue: VcgpChannel {
gamma_in: s15fixed16(layout.blue_gamma_in.get()),
gamma_out: s15fixed16(layout.blue_gamma_out.get()),
primary_x: norm_u32(layout.blue_primary_x.get()),
primary_y: norm_u32(layout.blue_primary_y.get()),
},
}
}
}

*/
// ── Tests ─────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
use super::*;

/// Build a vcgp payload with explicit values for all fields.
fn make_vcgp(
gamma_in: i32,
gamma_out: i32,
primary_x: u32,
primary_y: u32,
) -> VcgpData {
let mut buf = Vec::with_capacity(56);
buf.extend_from_slice(b"vcgp"); // type signature
buf.extend_from_slice(&[0u8; 4]); // reserved
// Calibration: gamma_in, gamma_out interleaved per channel
for _ in 0..3 {
buf.extend_from_slice(&gamma_in.to_be_bytes());
buf.extend_from_slice(&gamma_out.to_be_bytes());
}
// Chromaticity: x, y × 3 channels
for _ in 0..3 {
buf.extend_from_slice(&primary_x.to_be_bytes());
buf.extend_from_slice(&primary_y.to_be_bytes());
}
VcgpData(buf)
}

#[test]
fn parses_real_profile_values() {
// Exact bytes observed in Color LCD, LG HDR WQHD, SyncMaster profiles.
let data = make_vcgp(0x00030000, 0x00026666, 0x00000002, 0x33333400);
let parsed = VcgpType::from(&data);

assert!((parsed.red.gamma_in - 3.0).abs() < 1e-4);
assert!((parsed.red.gamma_out - 2.4).abs() < 1e-3);
// Chromaticity placeholder values: x ≈ 0, y ≈ 0.2
assert!(parsed.red.primary_x < 1e-6);
assert!((parsed.red.primary_y - 0.2).abs() < 1e-4);

// All channels are identical in uncalibrated profiles.
assert_eq!(parsed.red.gamma_in, parsed.green.gamma_in);
assert_eq!(parsed.red.gamma_out, parsed.green.gamma_out);
assert_eq!(parsed.red.gamma_in, parsed.blue.gamma_in);
}

#[test]
fn calibrated_chromaticity_roundtrip() {
// Simulate a calibrated profile with real sRGB primary chromaticities.
// sRGB red: x=0.64, y=0.33 — encoded as U32 normalized.
let x_enc = (0.64_f64 * u32::MAX as f64) as u32;
let y_enc = (0.33_f64 * u32::MAX as f64) as u32;
let data = make_vcgp(0x00030000, 0x00026666, x_enc, y_enc);
let parsed = VcgpType::from(&data);

assert!((parsed.red.primary_x - 0.64).abs() < 1e-6);
assert!((parsed.red.primary_y - 0.33).abs() < 1e-6);
}

#[test]
fn zero_payload_returns_zeros() {
let data = make_vcgp(0, 0, 0, 0);
let parsed = VcgpType::from(&data);
assert_eq!(parsed.red.gamma_in, 0.0);
assert_eq!(parsed.red.gamma_out, 0.0);
assert_eq!(parsed.red.primary_x, 0.0);
assert_eq!(parsed.red.primary_y, 0.0);
}

#[test]
fn malformed_too_short_returns_zeros() {
let data = VcgpData(b"vcgp\x00\x00\x00\x00".to_vec()); // only 8 bytes
let parsed = VcgpType::from(&data);
assert_eq!(parsed.red.gamma_in, 0.0);
}
}
Loading