Skip to content

Repository files navigation

ColorCore

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';

What's here

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

The three tools

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.

Reading and writing measurements

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_550what 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.

The ICC engine

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.

CIEDE2000 only

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).

Documentation

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

Tests

npm test

No test framework — each suite is a self-reporting script.

Status

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.

Comparing a print against an aim

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.

What driving the finished page found

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.

Licence

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.

About

Core colour management utilities and tools

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages