Colour management utilities for industrial inkjet printing — measurement file parsing, spectral colorimetry, colour difference, and ink-response analysis.
Vanilla JavaScript ES modules. No build step, no dependencies, no framework.
import { deltaE2000, parseMeasurementFile } from './colour-core/index.js';| Folder | Contents |
|---|---|
colour-core/ |
delta-e.js (CIEDE2000), interpolate.js (monotone PCHIP), conversions.js, contrast.js |
colour-core/measurement/ |
CGATS.17 / IT8 reader and writer, with an alias table for vendor field spellings; DEVCALSTD and MEASUREMENT_SOURCE aware |
colour-core/cxf/ |
CxF3 (ISO 17972-1) reader and writer, including i1Profiler's .rwxf chart layouts. Zero-dependency XML — no DOMParser, same code in Node and the browser |
colour-core/spectral/ |
CIE 1931 2° and 1964 10° observers; A, D50, D55, D65, D75; reflectance → XYZ → Lab |
colour-core/pdc/ |
Ink-ramp segmentation, paper-white estimation, duplicate-read averaging, hook detection |
colour-core/icc/ |
ICC v2 and v4 — read a profile, evaluate its transforms both directions, write one, predict a spot colour against a press |
colour-core/chart/ |
Chart geometry: the grid, the page, and the order a spectro walks it in. Every traversal order was read off a real chart and names which one |
colour-core/pdf/ |
A minimal PDF writer — no dependencies, no build step, runs in the browser. Knows nothing about colour charts on purpose |
colour-core/named-colours/ |
The shape of a named-colour library, and readers for .ase, .aco, CxF3 and delimited text. Schema only — no Pantone or RAL data, ever |
colour-core/reference/ |
An aim printing condition, loaded from a file. Plus a catalogue of the published ones — names, provenance and licence position, never their numbers |
colour-core/conformance/ |
Substrate handling and SCCA as three separate flags, and the report that states all three. No tolerance table is shipped |
colour-core/g7/ |
G7+: the ramps found structurally rather than by patch number, tonality scaled from a reference dataset in CIEXYZ Y, grey balance by tint transparency, High Density Smoothing above 75%. Weighted ΔL* and ΔCh, and no verdict without a sourced tolerance |
resources/ |
Fetcher for sourced reference data — CRPC 1–7, FOGRA datasets, ICC profiles. Nothing licensed is committed |
tools/ |
The three tools this library exists for — the Color Text Wrangler, the Chart Factory and the Evaluation Factory. All three run |
Not a drawer of separate utilities — three tools that hand work to each other:
Color Text Wrangler ──▶ Chart Factory ──▶ [ print · measure ] ──▶ Evaluation Factory
gets the file makes the the press says whether
into shape chart PDF it passed
+ its txt file
| Tool | Job | |
|---|---|---|
tools/color-text-wrangler/ |
Fix, convert, modify, reformat — txt, xml, CxF and the other colour communication formats | live |
tools/chart-factory/ |
Swatch library and printable charts. The chart PDF and the txt file that reads it back are one deliverable | live |
tools/evaluation-factory/ |
Process control. Judge a measurement against an aim and say whether it passes, against which revision | brief |
There is no direct edge between the Wrangler and the Evaluation Factory, and that is not an omission: nothing goes straight from a format problem to a verdict. It has to be printed first.
ARCHITECTURE.md §5 is the structure, §6 the build order.
The reader takes CGATS.17, IT8 and the headerless Lab lists, and normalises the
vendor spellings — LAB_L, L*, CIE_L are one field.
The writer's defaults are measured, not chosen. A survey of 843 real CGATS files on disk settled the delimiter, the line ending, the decimal places, the keyword order — and the one that matters most:
| Spectral field spelling | Share of real files |
|---|---|
SPECTRAL_NM550 — X-Rite |
88% |
NM550 |
11% |
SPECTRAL_NM_550 — what CGATS.17 specifies |
1% |
So the writer emits the 88% form. Writing the specified form would produce a technically correct file that most measuring software does not recognise.
import { parseCGATS, writeCGATS, describeWrite } from './colour-core/index.js';
const parsed = parseCGATS(text);
describeWrite(parsed, { fields: ['id', 'lab.L', 'lab.a', 'lab.b'] });
// willDrop: [
// { what: 'spectral data — 36 bands on 1617 of 1617 rows',
// why: 'the chosen fields carry no spectral columns' },
// { what: '5 columns: SAMPLE_NAME, CMYK_C, CMYK_M, CMYK_Y, CMYK_K',
// why: 'present in the source, not in the chosen fields' },
// ]
writeCGATS(parsed);describeWrite is the point: it reports what a conversion would cost before it
runs, so a tool can put the loss in front of the user rather than behind a
download button.
Round-tripped over 842 corpus files — parse, write, parse — the colorimetry comes back identical, across 176 181 rows carrying spectral data.
Reads and evaluates ICC v2 and v4 profiles: mft1, mft2, mAB, mBA,
matrix/TRC and monochrome, with media-relative and ICC-absolute intents in both
directions. It also writes profiles — two shapes, a matrix/TRC RGB profile
and a Lab identity — deterministically, so a build can be checksummed.
import { openProfile, INTENT } from './colour-core/icc/index.js';
const p = openProfile(bytes);
p.deviceToLab([0, 0, 0, 0], INTENT.absolute); // paper white
p.labToDevice({ L: 50, a: 20, b: -30 }, INTENT.mediaRelative);It is checked against three independent CMMs — littleCMS, ArgyllCMS and Apple's ColorSync — over 23 profiles and 200 patches each, in CIEDE2000. ColorSync is there because ArgyllCMS refuses v4, and ten of the 23 are v4.
| Worst A2B difference vs littleCMS | 0,0082 ΔE00 |
| The two reference engines against each other | 0,1265 ΔE00 |
The second row is the useful one: littleCMS and ArgyllCMS disagree with each other about fifteen times more than either disagrees with ColorCore, because 4-D CLUT interpolation is not mandated by the specification. About 0,1 ΔE00 is the honest noise floor of any ICC A2B prediction.
resources/CONFORMANCE.md is the dated record —
including the four things it reports and deliberately does not count, one of
which is still unexplained. resources/icc-vectors.json freezes 5 400 of the
answers so any machine with node can check the engine is unchanged, without
needing the CMMs or the licensed profiles.
This library implements one colour-difference formula. ΔE*ab (CIE76), CIE94 and CMC l:c are not present and will not be added — not as an option, not as a fallback, not for comparison.
ΔE00 has been obligatory for digital proofs since October 2016 under ISO 12647-7, and every standard published since is written in it. Default parametric factors are kL = kC = kH = 1.
delta-e.js is verified against all 34 reference pairs from Sharma, Wu &
Dalal (2005).
| File | What it covers |
|---|---|
ARCHITECTURE.md |
The structure — three tools on one knowledge base, what each carries, and the order they get built in |
DOMAIN.md |
The printing world this serves, and a list of traps where a plausible default is wrong |
ECOSYSTEM.md |
Five Replit prototypes audited — what they duplicated and what they got wrong. Examples, not code to port |
SNAGS.md |
The running list of loose ends — decisions taken, work parked on purpose, and what is waiting on something outside the repo |
resources/README.md |
Where every reference asset came from, its licence position, and how the profiles are built |
resources/CONFORMANCE.md |
What the four-engine ICC comparison found on 27 August 2026, and what it does not cover |
CLAUDE.md |
Project rules |
npm test
No test framework — each suite is a self-reporting script.
Early, and no longer read-only. The ICC engine writes profiles as well as
reading them, and two of the profiles in resources/profiles/ are its own
output. measurement/write.js — the CGATS and target writer — landed on
28 August 2026, closing Phase 1 of ARCHITECTURE.md's build order and the one
gap that blocked two of the three tools at once. cxf/ — CxF3 read and write,
including i1Profiler's .rwxf chart layouts — landed on 30 August 2026 and
closes Phase 2.
The first tool is live. The Color Text
Wrangler shipped on 30 August 2026, closing Phase 3.
It reads CGATS.17, IT8, CxF3, .rwxf and headerless Lab lists, and writes any
of four formats back out — showing what the conversion costs before the
download button does anything, and proving in CIEDE2000 that the colour that
survived is unchanged.
Building it found five faults in the library that neither module's own test suite could reach, because every one of them lived in the space between two modules — a CxF handed to the CGATS writer, a CGATS written out as a headerless list. That is the argument for building the tools on the shared core rather than beside it: the tool is the integration test.
chart/ and pdf/ landed on 30 August 2026 and close Phase 4 — the geometry a
chart sits on, and a zero-dependency PDF writer to print it with. The six
traversal orders in chart/ were not taken from documentation. Four candidate
orderings were tested against real multi-page .rwxf files of 3108 and 4212
patches until one reproduced both exactly, and ORDERS names the proving file
for each — or says plainly that no file in the corpus uses it.
That work found a hazard in real charts, not in the code: two files in the
corpus reuse grid positions across different patches, one of them 1917 patches
on 640 positions. Any lookup keyed on page/column/row silently returns the
wrong patch, so auditPositions is not optional and its answer is not a
boolean.
named-colours/ closes Phase 5 on the same day. A brand book does not arrive
as a measurement file — it arrives as whatever the designer had open — so the
module reads Illustrator's .ase, Photoshop's .aco, CxF3 and delimited text
into one shape. No colour data is in it and none will be: Pantone's and
RAL's books belong to their publishers, and only the schema generalises.
Both Adobe layouts were established from real files rather than recalled. The
block-length arithmetic reproduced all 156 colour blocks across eight .ase
files exactly, and the channel scales were settled by agreement between two
independent writers — twelve folders held an .ase and an .aco that Adobe
wrote from the same swatches, and every matched entry agreed to .aco's own
hundredths quantisation. That mattered, because .ase stores L* divided by
100 while a* and b* are native: a reader that treats the three channels alike
returns L* = 0.16 for a dark colour and looks like it worked.
Where the corpus was silent, the module says so rather than filling the gap.
No .ase in it carries RGB or Gray and no .aco uses a space other than Lab,
so those scales are flagged as inferred every time they fire, and an unverified
.aco space is not read at all — the space number is reported instead, because
a gap that can be closed by exporting one file is a smaller problem than a
confident wrong number.
The second tool is live. The Chart Factory shipped on 30 August 2026, closing Phase 6. Build a patch list from ramps, greys, overprints or a TAC wedge — or open one you already have — choose a page and a patch size, and get the chart PDF and the file that reads it back, under one base name. Its rule is the whole tool: the PDF's patch order and the txt file's row order are one list, written twice. A patch that cannot be printed leaves the list before layout, so it is missing from both files rather than from one of them, and the page names it rather than quietly dropping it.
What it will not do is as load-bearing as what it will. It generates no
IT8.7/4, no TC1617 and no ECI2002, because those are published patch
definitions and a reconstruction from memory would be worse than not offering
one. It prints four inks, because pdf/ writes no Separation colour space yet
— said on the page rather than discovered on the press. Its ink ceiling
excludes patches and never scales them, because a patch quietly moved to 280%
is a patch whose name no longer describes it. And it writes no measurement
condition, because a chart that has not been under an instrument does not have
one.
Its corpus sweep — every measurement file on disk opened, charted, and checked
for sheet-versus-file agreement — found a fault none of the module test suites
could see, because every fixture in every one of them was a string. Three front
doors in the library documented themselves as accepting Uint8Array and then
ran String() on it, which does not throw and does not warn; it just turns a
CGATS target into the text 67,71,65,84,83,... that no detector recognises. In
a browser a file picker hands back bytes and nothing else, so every real file
would have read as unsupported. colour-core/text.js now decodes at each door,
claiming UTF-8 with a windows-1252 fallback — and explicitly not claiming
UTF-16, because no file in the 215-file corpus is UTF-16 and guessing at an
encoding nobody has seen is how a wrong number gets in.
tools/ holds all three tools, and all three run. That build order was
deliberate: the library gaps came first, because a tool that has to carry its
own parser is exactly the thing this project exists to stop.
Phase 7 closed on 30 August 2026 with the three modules the Evaluation Factory needs. Four decisions in them are worth stating, because each one is a place where the obvious implementation is quietly wrong.
The join key is the patch's device value, not its row. Two files describing
the same target in different layouts hold the same colours in different
positions. Joining on position pairs cyan with magenta and reports the result to
four decimal places. compareSets picks the key that identifies the colour
first and falls back towards the key that identifies the row only when it has
to, and it says in words which it used and why.
An unknown measurement condition blocks the comparison. Not a warning, not
a default to "probably fine". Under a brightened substrate the M0/M1 difference
is the paper's optical brighteners, not the ink, and a ΔE00 computed across the
two is a number about the instrument. DEVCALSTD blocks too — but only when
both files state one, because silence is not evidence of a mismatch.
Substrate handling and SCCA are three flags, never one "Relative ✓" tick.
Four different calculations get called "use the measured white": report the
paper without scoring it, substitute it into the aim, substitute and rescale
everything, or normalise both sides to their own white. They give different
answers, and two certificates carrying that same tick are not comparable. So
every report states substrateHandling, sccaMode and sccaAnchor — and the
anchor has no default, because the Idealliance calculator, Curve4 and G7+
v42 each specify a different one.
No tolerance table is shipped. buildReport takes {deltaE00, source} and
returns full statistics with no verdict when it is not given one. A limit is
only meaningful with the revision it came from, and a ΔE*ab figure from an
older revision is not a ΔE00 limit — the ratio between the two varies across
colour space, so carrying one into the other is wrong by an amount that depends
on which patch failed. A report with no verdict is useful; a report with an
invented verdict is worse than none.
The SCCA implementation is checked against a spreadsheet this machine cannot open. The Idealliance Substrate Relativity Calculator sits behind a file that will not hydrate from the shell, so the test works from the six figures derived from it — three CRPC6 near-blacks, each at two anchors. The substrate those figures were computed on was never published, so it is recovered from them: at the zero anchor the map is a pure per-channel scale, so each two-decimal figure admits a small interval of ratios, and the test first asserts that the three intervals intersect at all — if they did not, no per-channel scale produced those figures and the model would be the wrong model. The single ratio they share is then fitted from one column and used to predict the other, which is a different calculation. All six reproduce exactly.
That work corrected a claim worth correcting. The maxDensity anchor is
described as making inversion impossible — an aim moving the opposite way to
the substrate. It removes it in lightness only. The map runs per channel and
maxDensity is chosen by L*, which is a Y-channel test; a dark chromatic patch
can hold less X or less Z than the darkest-by-L* patch and inverts there. On
CRPC6, twenty-one do. The module counts both at run time and reports them
separately rather than claiming a guarantee it half has.
G7+ is built. colour-core/g7/ implements the method from PRINTING United
Alliance, G7+ Definitions and Algorithms, version 42: tonality scaled into
CIEXYZ Y against a reference dataset, grey-balance aims derived from the
substrate by tint transparency, and High Density Smoothing taking over above
75% where grey balance stops discriminating. Errors are weighted — full weight
to 50%, falling linearly to a quarter weight at the solid — and nothing is
reproduced from that document, because its licence forbids republishing its
equations. The method is described in the module's own words and implemented.
It found the ramps rather than being told where they are. A P2P is located by the shape of its data — a k ramp, a cmy ramp whose triplet the file declares, grey-finder blocks around each step — so a P2P25, a P2P27 and a TR015-derived target all read without a patch-number table, and the neutral triplets are read off the file rather than computed, because P2P generations disagree about their own (0.747 against 0.705 at the light end, and a nominal 75% that sits at 74.9 because the target was built in 8 bits).
It is checked against real measurements, not only against itself. Two Epson
P2P25 files — one raw, one after G7 iteration — score weighted ΔCh of 14.53
average against 0.66, which is the method detecting the thing it exists to
detect. Its own test suite found a real defect on the way: readTV interpolated
linearly while reverseTV used a spline, so the two were not inverses between
patches, and on an 8-bit target every HDS read is between patches. The aims
were out by up to 0.44 tone value units on a press measured against itself.
Both are linear now, which is also what v42 names.
What is deliberately absent. No pass or fail — for either method. The
tolerances live in the G7 Master Pass/Fail document, which is a OneDrive
placeholder that will not hydrate from the shell, and a tolerance transcribed
from memory is not a tolerance. applyLimits scores against limits a caller
supplies and names their source; given none it returns the measurements and no
verdict. No legacy G7: its tone aim is an NPDC defined in CGATS TR015
sections 5.4 and 5.5, and TR015 is not held anywhere, so evaluateLegacyG7
refuses by name rather than being silently missing — the two are separate named
methods with neither a default. ΔTVI is computed but returns
settled: false, because the two sides of the subtraction as v42 prints them
are not the same quantity; a press measured against itself, where every error
is zero by construction, still scores near 95. That is logged as a defect to
report to the Alliance rather than guessed at (SNAGS.md §5). No spectral
SCCA: a per-channel scale in XYZ cannot represent fluorescence, because the
reflected component and the brightener emission attenuate under ink at
different rates and one multiplicative factor cannot carry two decay rates.
Naming a mode that silently ran the tristimulus maths would be worse than not
offering it.
The Evaluation Factory shipped on 30 August 2026 and closed the loop. Putting real files through the finished page found two faults that thirteen module test suites and 1,663 checks could not see, and both have the same shape: a correct module, a correct caller, and an untested join between them.
A row-number join, reported to four decimal places. Two patch sets are
joined on whatever both files carry — device CMYK, device RGB, a name, or last
of all SAMPLE_ID, which is row position. That last one is only sound when the
two files are the same target in the same order, and nothing in either file
says so. A 1,617-patch CRPC6 aim against a 4,183-patch measurement joined on row
number and produced a 37.80 ΔE00 mean with a FAIL available, having paired
patch 1 with patch 1 by where it sat. Eleven of the sixteen FAIL verdicts in a
286-file corpus sweep were that fault rather than a press.
The remedy keeps the last resort — it is genuinely right when two exports of the same chart carry no device values — and separates proof from doubt. Unmatched keys are proof the files are not the same target, and block. Matching keys are unproven rather than disproven, because two charts can both number 1 to N, so the comparison runs and says every number rests on that assumption.
The same fault has a second door. A named-colour library is keyed on the name, and a record with no name falls back to its id — right for CxF, where the identity often sits there, wrong for CGATS, where the id is the row. A characterisation set opened at the library door becomes 1,617 colours called "1", "2", "3". It is caught by provenance, never by looking at the name: a customer's brand palette legitimately names a swatch "485", so a numeric name proves nothing. Every entry now records where its name came from.
The library door had never read a swatch file. readLibrary returns
entries; every caller in the chain read records. Every .ase, .aco and
swatch CxF arrived as "no patches were found in this file" — about files that
were full of them. It had three callers, and fixing the first made the second
worse: the page then showed "22 patches" next to a verdict of "the aim carries
no records" about the same file. Every module was right and fully tested; the
joins between them were tested by none of them, and nothing short of running
the page against real files would have found it. It is fixed at the two library
entry points, and proven both ways round: the same twenty-two colours written
as an .ase and as an .aco agree to 0.003 ΔE00.
MIT — use it, modify it, ship it, commercially or otherwise. Just keep the copyright line.
This covers the code only. The reference data that resources/fetch.js
downloads — FOGRA and ECI characterisation datasets, ICC printing-condition
profiles — is licensed by its publishers, not by me, and is deliberately not
committed to this repository. Fetch it yourself and observe their terms.