ICC Profile Reader Developer Reference
Maintain the ICC workbench: the header at its published offsets and the version field that was read from the wrong nibbles, the tag table and the sharing clause 7.3.4 permits, the primaries behind the stored colorants, identification with the white point taken out of it, gamut from a CLUT, the conformance rules and the two that were withdrawn.
Overview
The bench is a static page plus four plain scripts: a numeric core that touches no document, a register of what every figure rests on, a page half that draws the two plots, and a views half for the Method, Conformance, Data, Export and Reference tabs. The tag reader underneath was sound. What sat around it was not: a version field read from the wrong nibbles of byte 8, a conformance rule that fired on every ICC profile in existence, a second that called a construction the specification permits corruption, a classifier that could not identify a conformant profile, colorants drawn and labelled primaries, and a gamut figure computed in a diagram that is not uniform.
1. Code map
+| File | Holds | Reaches the DOM |
|---|---|---|
js/tool/ic/ic-core.js |
Every calculation. Installs as AAICCore. |
No — deliberately, so the suite runs the shipped file. |
js/tool/ic/ic-sources.js |
The 20-entry register, as AAICSources. |
No. |
js/tool/icc-profile-parser.js |
State, the two plots, the readouts, exports, tab switching. | Yes. AAICPage. |
js/tool/icc-profile-parser-views.js |
Method, Conformance, Data, Export, Reference, as AAICViews. |
Yes. |
css/icc-profile-shell.css |
The workbench shell plus this route’s blocks under §V. | — |
Load order matters and none of these is a module: ic-core.js
must be present before the page half, which throws a clear error rather
than failing silently if it is not. The route is in the
PAGES list of scripts/extract-anz-shell.mjs.
Clause numbers throughout this page and throughout the code are ISO 15076-1:2010, published by the ICC as ICC.1:2010.
2. What was found, and how
+Seven findings, each measured by running the shipped engine in a sandbox against the three ICC profiles Windows ships — the canonical sRGB profile, a monitor profile, and Agfa’s SWOP press profile — and against an independent reading of the same bytes.
The version was read from the wrong nibbles
Clause 7.2.4 puts the major version in byte 8 whole, the minor
in the high nibble of byte 9 and the bug-fix in its low nibble. The
reader took the major from byte8 >> 4, which is zero
for every ICC profile that has ever existed — byte 8 is
0x02 or 0x04, and neither has a high nibble.
| Profile | bytes 8–9 | ICC 7.2.4 | what it read |
|---|---|---|---|
| sRGB Color Space Profile.icm | 02 10 | 2.1.0 | 0.2.1 |
| RSWOP.icm | 02 00 | 2.0.0 | 0.2 |
| any ICC v4 profile | 04 30 | 4.3.0 | 0.4.3 |
A rule then tested /^2\.|^4\./ against that string, never
matched, and raised a major issue — the tool
flagging its own misreading as a defect in the file. The misread version
also printed in the header panel and in all three exports.
Shared tag data was reported as corruption
Clause 7.3.4 permits more than one tag to reference the same data
element, and it is how a press profile avoids storing a 41 KB table
twice. RSWOP’s A2B0 and A2B2 both sit at
offset 576 with the same size because the tables are identical. The
check was if (tag.offset < previous.end) over tags sorted
by offset, which fires on identical offsets. Those two findings are what
scored a Microsoft-shipped SWOP profile 76 / “Review”.
No conformant profile could be identified
The classifier scored dr + dg + db + dw*2, where
dw is the raw xy distance from the profile’s media
white to the candidate’s. Clause 7.2.16 requires a display
profile’s media white to be the PCS white, D50; four of the five
candidates were defined at D65. So that term was a constant
0.08853 — four times the 0.02 a “High
confidence” badge needed — before a single primary was
compared.
| Profile | winner | score | of which white |
|---|---|---|---|
| sRGB Color Space Profile.icm | sRGB | 0.12682 | 0.08858 (70%) |
| m14t.icm, a real monitor profile | sRGB | 0.08996 | 0.08858 (98%) |
m14t’s colorants sit 0.0014 from sRGB’s primaries — as close as a real profile gets — and 98% of its score was a constant penalty for being conformant. Measured on a hypothetical perfect sRGB profile: a conformant D50 white scores 0.08853 and reads “Low”; only a non-conformant D65 white scores 0.00000 and reads “High”. The badge rewarded a profile for being wrong. ProPhoto RGB, the only D50 space in the table, got a free 0.0885 advantage over every other candidate.
Colorants were plotted as primaries
An ICC profile stores rXYZ, gXYZ and
bXYZ already adapted to D50. They are not the primaries of
the space it describes. On the sRGB profile the stored green sits
0.0213 in xy from the primary it stands for — the
largest of the three, in the part of the diagram the eye reads as most
saturated — and that displacement was then fed to the classifier as
evidence about which space this is.
“% sRGB” was a triangle area in CIE 1931 xy
The 1931 diagram stretches green badly. Measured across the reference spaces, the xy figure overstates Adobe RGB by 18.2 points and Rec. 2020 by 16.8, and understates ProPhoto by 14.4 — not even in a consistent direction. And an area ratio is not coverage, which is what “136% sRGB” reads as; coverage cannot exceed 100%.
A CMYK press profile got four nulls
RSWOP.icm is 218 KB with six transform tags and a gamut tag. The parser
returned null for primaries, gamut, matrices and TRC, and
the gamut panel printed “No primaries — gamut comparison
unavailable”. For the class of profile where a gamut matters most
it was silent, while holding the A2B table that is the gamut.
And two lookup keys could never be reached
fourCC stripped trailing spaces while the lookup tables kept
them, so the keys "Lab " and "SGI " were
unreachable: a CIELAB PCS printed its raw signature where an XYZ PCS
printed a label.
3. The header
+
parseHeader(view) reads the 128-byte header at the offsets
clause 7.2 gives. The fields that bite:
| Field | Bytes | Note |
|---|---|---|
version | 8–11 | byte 8 whole, then two nibbles of byte 9. readVersion()
also reports whether bytes 10–11 are the zero the clause requires. |
deviceClass, dataSpace, pcs |
12, 16, 20 | Four-character signatures verbatim, trailing spaces intact. |
illuminant | 68–79 | The PCS illuminant, which clause 7.2.16 requires to be D50. A rule checks it. |
profileId | 84–99 | Compared against computeProfileId(), which is the MD5 of
clause 7.2.18. |
fourCC returns the four characters as they are. Anything
wanting a tidy string for display trims at the point of display, never
here — that is the whole of the "Lab " defect.
4. The tag table and shared elements
+
parseTagTable(view, byteLength) returns one row per tag with
offset, size, end,
aligned, inBounds, the decoded
type, and sharedWith — the signatures of
any other tags pointing at the same element.
Two tags at one offset is not an overlap. Clause 7.3.4 permits it. The rule that replaced the withdrawn one fires only on a partial overlap: two payloads that intersect without being the same element, which is a real defect and a different thing. When editing that rule, note that identical offset and size is the test for sharing — identical offset alone is not enough.
5. Tag types
+
decodeTag(v, t) dispatches on the type signature and
catches: a malformed tag comes back as
{kind: "unreadable", error} and the rest of the profile
still reads. Anything dropped on this page is by definition a file
somebody has a question about, and half of those questions are
“why does nothing open this?”
| Type | Clause | Read as |
|---|---|---|
curv | 10.5 | identity (count 0), a single u8Fixed8 gamma (count 1), or a sampled table. |
para | 10.16 | Parameter counts by function type: 0 → 1, 1 → 3, 2 → 4, 3 → 5, 4 → 7. |
XYZ | 10.31 | s15Fixed16 — signed. A black point is legitimately negative. |
mft1 / mft2 | 10.10 / 10.11 | Every read is bounded; a short payload gives complete: false
and a shortBy count rather than an exception. |
mpet | 10.14 | Structure only. Element types and count, never evaluated. |
ncl2 | 10.17 | Structure only. Names and count, no colour resolved. |
curveAt(c, x) evaluates any of them at x in
[0,1], including the five parametric branches. A sampled table
interpolates linearly between entries; a table’s
0xFFFF must decode to exactly 1, which means dividing by
65535 and not 65536.
6. Colorants, primaries and adaptation
+
unadaptColorants(colorants, chad, assumedWhiteXY) recovers
the primaries. With a chad tag the inverse does it exactly;
without one the profile does not record the white it was adapted
from, so the white of the matched space is assumed and the
result carries how: "assumed". Reporting an assumption as a
measurement is the class of defect this rebuild is about.
The Bradford inverse is computed with
inv3(), not transcribed. The seven-decimal inverse that gets
copied around is not the exact inverse of the seven-decimal forward
matrix, and the round trip then misses by about 1e-7 — small, but
it means “adapt there and back” is not the identity, which is
the one property that pair has to have.
Which adaptation? The specification says colorants are
adapted to D50; it does not say by what. Bradford is what a conformant
CMM uses; plenty of profiles instead scale XYZ directly, which puts the
colorant sum on D50 while leaving the chromaticities where the device
measured them. identify() tests both and reports the closer,
because the answer is a fact about how the profile was built. Measured on
m14t.icm: 0.0141 under Bradford, 0.0006 under XYZ scaling.
7. Identification
+
spacePrimariesInPCS(space) adapts a candidate’s
primaries into the profile connection space, and
identify() compares there. That is the structural fix: the
white point cannot contribute a difference at all, not a smaller one.
Reweighting the old term would have left the same class of bug available.
Three terms come back separately:
primariesDeltaUv— the worst channel, not the mean. A gamut is as wrong as its worst corner, and a mean hides one badly placed primary behind two good ones.curve— peak and RMS deviation from that space’s published transfer function over 257 samples.adaptation— which hypothesis was closer.
sRGB and Rec. 709 define the same three primaries and the
same white. Any claim to tell them apart has to come from the
transfer function, so a profile with no readable TRC cannot be separated
and indistinguishable lists what it could equally be. Do not
add a tie-break that invents a distinction the file does not carry.
8. Gamut, area and coverage
+
compareGamut(primaries, space) returns four numbers:
relativeAreaUv, relativeAreaXy,
coverage and coveredBy. The first two are
ratios of triangle areas; the last two are real polygon intersections
via clipPoly() and cannot exceed 1.
Both area figures are printed because the xy one is what the industry quotes, and both a ratio and a coverage are printed because “136% sRGB” reads like the second and was the first. A real monitor profile makes the point on its own: m14t is 102.2% of sRGB’s area and covers 97.2% of it.
clutGamut(mft, pcsIsLab) is for a profile with no
colorants. It walks every grid node of an A2B table through the output
curves to CIELAB and takes a hull. Two things to keep: the output curves
are applied (RSWOP’s happen to be identity, so a real
press profile will not catch you skipping them), and the legacy 16-bit
Lab encoding of clause 10.10 needs both the 65535/65280 factor and the
−128 offset on a* and b*. A hull area is translation-invariant, so
dropping the offset shows up only in the position.
9. Conformance
+
RULES is a flat array; each entry carries an
id, a severity, a clause and a
test(profile) that returns a detail string or
null. runConformance() runs them all, catches
anything a rule throws, and returns findings plus
conformant — which means no errors, not no
findings.
Two rules for the price of one invariant:
- a rule with
clause: nullis a house preference and may not be graded abovenote. The suite asserts that. - there is no score. Averaging unlike rules into a number out of 100 produced 76 for a valid SWOP profile on two findings that were both bugs in the reader.
When adding a rule, give it a clause or accept note, and
give it a fixture.
10. UI contract
+Things that will break the page quietly if changed:
<main>must carryclass="anz-main ax-lab". The control styling is scoped to.ax-lab.- Rails are
class="panel ic-rail"and stagesclass="panel ic-stage", both insidediv.ic-band. .ic-row__headand.ic-citeare four-column grids and need exactly four direct children..ic-capis a flex callout expecting a<p>child; plain prose uses.ic-caption.- The Data tab’s count badge is written from
counts().all, never typed — and the span carriesdata-no-i18n, or the tab label and the number become one translation key. .ic-wasmeans “this figure was wrong” and strikes through. The xy area is a comparison, not a withdrawal, so it uses.ic-stat__v--aside.
Setting a <select> value from script needs
setSelectValue(): the site replaces every select with a
picker that follows the native element through a
MutationObserver on its selected attributes,
and assigning .value moves the property only.
11. Exports
+
readingObject() builds the JSON reading and
tagCsv() the tag table. Every derived figure carries its
provenance in the same object: the clause for anything read from the
header, the arithmetic for anything computed, and the register status for
everything else — so a reading pasted into a ticket can be checked
without this page.
Nothing is uploaded. The file is read with FileReader in the
page; there is no network call anywhere in this route.
12. Public API
+window.AAICPage:
| Member | Does |
|---|---|
state | The live state object. |
reading() | The JSON reading, or null. |
setCompare(id) | Selects a reference space; false for an unknown id. |
loadBuffer(buf, name) | Parses bytes directly and renders. Returns the parsed profile. |
refresh() | Re-renders everything. |
window.AAICSources exposes all(),
byId(), byStatus(),
reportable(id), counts() and
statusLabel().
13. Tests
+
npm run test:colorimetry runs the whole colorimetry suite, including
tests/colorimetry/ic-reference.test.js and
ic-sources.test.js — 60 assertions between them.
ic-harness.js loads the shipped core and register
in a vm, and it also builds ICC profiles byte by
byte: buildProfile(), xyzTag(),
curveTag(), paraTag(), mft2Tag().
A parser tested only against files that happen to be on the machine is
tested against whatever those files contain.
Passing the same payload object twice to
buildProfile makes both tags reference one element, which is
how the sharing case is covered. The real Windows profiles are used as
acceptance tests where present and skipped with a printed note where not.
The ones that matter most when changing this file:
- the version comes from byte 8 whole — the regression guard for the defect this rebuild exists for;
- every four-character key in every lookup table is reachable through
fourCC; - shared storage raises nothing and a partial overlap raises an error;
- the candidate is compared in the PCS, so an exact match scores zero whatever the white;
- sRGB and Rec. 709 are separated by the curve, and not at all without one;
- ΔE₀₀ against an independent transcription of CIE 142-2001 over 20,000 random pairs;
- MD5 against all seven RFC 1321 vectors, and the profile ID zeroing exactly the fields clause 7.2.18 names;
- the spectral locus re-derived from
il-tables.js— 39 of its 65 points were wrong the first time they were transcribed by hand.
The suite was proved by injecting 74 defects — the
version nibbles restored, fourCC stripping spaces again,
shared storage flagged as an overlap, the white-point term added back,
only one adaptation hypothesis tested, area computed in xy and reported
as the u′v′ figure, coverage returning the area ratio, the
Bradford inverse transcribed, the mean-hue branch folded, MD5 padding
dropped, a stand-in relabelled verbatim — and all 74 were
caught. Nine survived the first run and three the second; every
one was a gap in the tests, and closing them found a real bug in this
file: a truncated CLUT threw a RangeError out of the middle
of the parse, so a malformed profile showed nothing at all. Every LUT
read is now bounded and every decoder is caught.