Skip to main content
Auric Artisan · Documentation

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.

Published: June 4, 2026 Updated: June 4, 2026 Category: Reference Author: Chirag Bansal
Back to Documentation Auric Artisan Home

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.

Table of contents

  1. 1. Code map
  2. 2. What was found, and how
  3. 3. The header
  4. 4. The tag table and shared elements
  5. 5. Tag types
  6. 6. Colorants, primaries and adaptation
  7. 7. Identification
  8. 8. Gamut, area and coverage
  9. 9. Conformance
  10. 10. UI contract
  11. 11. Exports
  12. 12. Public API
  13. 13. Tests

1. Code map

+
FileHoldsReaches 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.

Profilebytes 8–9ICC 7.2.4what it read
sRGB Color Space Profile.icm02 102.1.00.2.1
RSWOP.icm02 002.0.00.2
any ICC v4 profile04 304.3.00.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.

Profilewinnerscoreof which white
sRGB Color Space Profile.icmsRGB0.126820.08858 (70%)
m14t.icm, a real monitor profilesRGB0.089960.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:

FieldBytesNote
version8–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.
illuminant68–79 The PCS illuminant, which clause 7.2.16 requires to be D50. A rule checks it.
profileId84–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?”

TypeClauseRead as
curv10.5 identity (count 0), a single u8Fixed8 gamma (count 1), or a sampled table.
para10.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 / mft210.10 / 10.11 Every read is bounded; a short payload gives complete: false and a shortBy count rather than an exception.
mpet10.14 Structure only. Element types and count, never evaluated.
ncl210.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: null is a house preference and may not be graded above note. 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 carry class="anz-main ax-lab". The control styling is scoped to .ax-lab.
  • Rails are class="panel ic-rail" and stages class="panel ic-stage", both inside div.ic-band.
  • .ic-row__head and .ic-cite are four-column grids and need exactly four direct children.
  • .ic-cap is 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 carries data-no-i18n, or the tab label and the number become one translation key.
  • .ic-was means “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:

MemberDoes
stateThe 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.