Describe the Bug
A spectral PCS port at a chain edge gets no relative/absolute conversion.
Interior spectral connections convert correctly against the spectral white point;
the first and last transform in a chain do not.
On master the same port gets something worse — the XYZ media-white affine applied
to spectral samples 0..2, leaving the rest of the spectrum untouched, because
AdjustPCS() reads pixel[0..2] as X, Y, Z regardless of what the port carries.
The correct behaviour
Relative spectra are absolute spectra divided by the spectral white point; absolute
spectra are relative spectra multiplied by it. No conversion is needed when the
tag's absolute-ness matches the rendering intent's — absolute tags at absolute
intent, relative tags at relative intent — and the conversion applies to
reflectance, transmission and radiant spectral PCS. Bidirectional reflectance and
sparse-matrix PCS are excluded: their sample vectors are not spectra, so
element-wise scaling is not dimensionally meaningful.
This is implemented on branch refactor/pcs-adjust-in-pcsxform — see
docs/superpowers/plans/2026-08-26-spectral-pcs-white-point-conversion.md — and is
live at interior connections.
Why the edges miss it
CIccPcsXform::ConnectFirst() and ConnectLast() carry the spectral conversion and
are unit-tested, but they cannot run for a spectral port: their only caller,
CIccCmm::CheckPCSConnections(), gates both chain-edge blocks on
IsSpaceColorimetricPCS(), and a spectral xform's GetSrcSpace()/GetDstSpace()
returns the reduced spectral type, which never satisfies it. The interior loop does
admit spectral ports; the edge blocks do not.
Measured on that branch: pcsXformCount() == 0 for a spectral chain edge — no
CIccPcsXform is constructed there at all.
Build Instructions
git clone https://github.com/InternationalColorConsortium/DemoIccMAX.git
cd DemoIccMAX
cmake --preset vs2022-x64 -B msvc -S Build/Cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
cmake --build msvc -- /m /maxcpucount
Reproduce Bug or Crash
Build a v5 profile declaring pcs = icSigLabData and spectralPCS with a
reflectance signature, give it a DToB1 tag, and apply it at absolute colorimetric
intent with the spectral signature as the CMM destination — so the spectral port is
the trailing edge of the chain rather than an interior connection.
On master, samples 0..2 come back altered and 3..n untouched. A pipeline emitting
the constants 0.20 0.30 0.40 0.50 0.60 0.70 yields:
observed 0.193897 0.301255 0.406660 0.500000 0.600000 0.700000
← altered as if XYZ → ← untouched →
A relative-intent control over the identical fixture emits all six constants exactly,
isolating the adjustment as the cause.
On branch refactor/pcs-adjust-in-pcsxform the samples pass through unmodified — no
longer corrupted, but also not converted.
Root cause shared with #2330
m_bAdjustPCS can be set on an xform with no guarantee its port is XYZ or Lab —
the two IsSpacePCS(m_Header.pcs) setters test the profile header, which stays Lab
or XYZ on a v5 spectral profile, not the port — while AdjustPCS() unconditionally
reads pixel[0..2] as X, Y, Z. A spectral PCS port and an MCS port are two
consequences of that single assumption.
Proposed fix
Widen CheckPCSConnections()'s two chain-edge conditions from
IsSpaceColorimetricPCS() to IsSpacePCS() so spectral ports reach
ConnectFirst()/ConnectLast(), whose spectral branches already implement the
conversion.
Deliberately not done on refactor/pcs-adjust-in-pcsxform: it makes two
previously unreachable functions live for a new port type, and on that branch every
latent defect found lived in exactly such code — ConnectLast()'s adjustment branch,
for one, had been silently halving XYZ values since it was written because nothing
ever executed it. It wants its own change and its own tests rather than riding along.
Describe the Bug
A spectral PCS port at a chain edge gets no relative/absolute conversion.
Interior spectral connections convert correctly against the spectral white point;
the first and last transform in a chain do not.
On
masterthe same port gets something worse — the XYZ media-white affine appliedto spectral samples 0..2, leaving the rest of the spectrum untouched, because
AdjustPCS()readspixel[0..2]as X, Y, Z regardless of what the port carries.The correct behaviour
Relative spectra are absolute spectra divided by the spectral white point; absolute
spectra are relative spectra multiplied by it. No conversion is needed when the
tag's absolute-ness matches the rendering intent's — absolute tags at absolute
intent, relative tags at relative intent — and the conversion applies to
reflectance, transmission and radiant spectral PCS. Bidirectional reflectance and
sparse-matrix PCS are excluded: their sample vectors are not spectra, so
element-wise scaling is not dimensionally meaningful.
This is implemented on branch
refactor/pcs-adjust-in-pcsxform— seedocs/superpowers/plans/2026-08-26-spectral-pcs-white-point-conversion.md— and islive at interior connections.
Why the edges miss it
CIccPcsXform::ConnectFirst()andConnectLast()carry the spectral conversion andare unit-tested, but they cannot run for a spectral port: their only caller,
CIccCmm::CheckPCSConnections(), gates both chain-edge blocks onIsSpaceColorimetricPCS(), and a spectral xform'sGetSrcSpace()/GetDstSpace()returns the reduced spectral type, which never satisfies it. The interior loop does
admit spectral ports; the edge blocks do not.
Measured on that branch:
pcsXformCount() == 0for a spectral chain edge — noCIccPcsXformis constructed there at all.Build Instructions
Reproduce Bug or Crash
Build a v5 profile declaring
pcs = icSigLabDataandspectralPCSwith areflectance signature, give it a
DToB1tag, and apply it at absolute colorimetricintent with the spectral signature as the CMM destination — so the spectral port is
the trailing edge of the chain rather than an interior connection.
On
master, samples 0..2 come back altered and 3..n untouched. A pipeline emittingthe constants
0.20 0.30 0.40 0.50 0.60 0.70yields:A relative-intent control over the identical fixture emits all six constants exactly,
isolating the adjustment as the cause.
On branch
refactor/pcs-adjust-in-pcsxformthe samples pass through unmodified — nolonger corrupted, but also not converted.
Root cause shared with #2330
m_bAdjustPCScan be set on an xform with no guarantee its port is XYZ or Lab —the two
IsSpacePCS(m_Header.pcs)setters test the profile header, which stays Labor XYZ on a v5 spectral profile, not the port — while
AdjustPCS()unconditionallyreads
pixel[0..2]as X, Y, Z. A spectral PCS port and an MCS port are twoconsequences of that single assumption.
Proposed fix
Widen
CheckPCSConnections()'s two chain-edge conditions fromIsSpaceColorimetricPCS()toIsSpacePCS()so spectral ports reachConnectFirst()/ConnectLast(), whose spectral branches already implement theconversion.
Deliberately not done on
refactor/pcs-adjust-in-pcsxform: it makes twopreviously unreachable functions live for a new port type, and on that branch every
latent defect found lived in exactly such code —
ConnectLast()'s adjustment branch,for one, had been silently halving XYZ values since it was written because nothing
ever executed it. It wants its own change and its own tests rather than riding along.