Shipped 2026-08-15. This page describes how DeviceLink profiles are supported as built. Tests:
__tests__/transform_devicelink.tests.js, with two families of self-validating fixtures:
testbed/profiles/lcms_testbed/(LittleCMS-testbed generated):linlcms2.icc— curves-only linearization link (parametric gamma 3.0, verified as out = in³ exactly);limitlcms2.icc— CMYK→CMYK 150% ink-limit link (lcms's algorithm reproduced: K preserved, CMY scaled into the remaining budget).testbed/profiles/devicelink/(Serendipity Software samples): null CMYK→CMYK and RGB→RGB identities, plus asymmetricsimple-cmyk-to-rgb(4→3) andsimple-rgb-to-cmyk(3→4, K-only grey axis).
A DeviceLink (pClass: 'link') is a complete device→device transform with
no PCS in the middle — the single A2B tag carries the whole conversion.
const dl = new Profile();
dl.load('MyDeviceLink.icc', () => {
const t = new Transform({ dataFormat: 'int8', buildLut: true });
t.create(dl); // the DeviceLink alone — nothing else
const out = t.array(pixels, false, false);
});- The rendering intent comes from the profile header (the spec says the single A2B tag serves whichever intent is declared there); any intent argument is ignored.
- Passing further profiles alongside a DeviceLink throws — there is no PCS to link through.
buildLut: trueworks (the baked LUT carries a single-profile chain); identity detection never collapses a link.
- The
'link'class passes the profile-class gate. - The header PCS field is not a PCS — for links it carries the
output device space (
'CMYK','RGB ','5CLR', …). The Lab/XYZ PCS gate is skipped for links; the output space resolves toprofile.outputChannels, while the header space field (the input side, as usual) resolves toprofile.deviceLinkInputChannels. Asymmetric links (CMYK→RGB, RGB→CMYK) work. - Links have no
wtpttag — the absolute-adaptation precompute is guarded onmediaWhitePointbeing present (absolute intent doesn't apply without a PCS anyway). profile.typereflects the input space (e.g.CMYK); Transform branches onheader.pClass === 'link', not on type.
createMultiStage detects the link, strips the padded intent/output
slots that create(dl) produces, sets input/output channels from the two
header fields, and skips identity detection. createPipeline then calls
createPipeline_DeviceLink for the single-profile chain (the normal
profile-pair linking loop no-ops for a length-1 chain), bracketed by the
standard input/output format conversion stages.
createPipeline_DeviceLink walks the full LUT element structure,
device→device throughout — a bare CLUT stage is not enough in practice:
V2 mft1/mft2: inputCurve → CLUT → outputCurve
V4 mAB: aCurves → CLUT → mCurves → matrix → bCurves
Any element may be absent. Two spec subtleties handled here:
- A curves-only mAB (B-curves, no CLUT) is legal and is exactly what an lcms linearization DeviceLink is. The LUT decoder no longer crashes on CLUT-less tags (stride precompute is guarded), and the builder just emits the curve stage.
- mft1/mft2 tags always contain a 3×3 matrix field, but per ICC spec it applies only when the input side is PCSXYZ — never true for a link — so it is skipped for mft tags. Only mAB's M-matrix is a real stage.
stage_curves_v4 could evaluate parametric curves with a curveFn and
sampled tables, but not pure-gamma curves (para function type 0, or
curv with count 1) — the decoder stores those as an inline gamma
exponent for speed, and no evaluator handled it (NaN). It now applies
Math.pow(x, gamma) when there is no table (sampled curves also carry a
midpoint gamma hint, so the table branch keeps priority).
| Area | Reason |
|---|---|
LUT decoders (mft1, mft2, mAB, mBA) |
A2B decodes normally (plus the CLUT-less guard) |
| Interpolation | Same tuned paths as any other LUT |
| Input/output format conversion | Link type reflects its device spaces — existing stages apply |
detectBlackpoint / BPC / chromatic adaptation |
Already guarded on pClass 'link' / PCS presence |
| Smoke test / validate, LUT bake | Work as-is once the pipeline is built |