Releases: colourbill-ctrl/profiletool
Release list
v2.3.0 — Analysis: primary-inking paths, ink usage, per-ink separations, N-ink colour
profiletool 2.3.0 — Analysis tab: primary-inking paths, ink usage, per-ink separations, N-ink colour
Four new single-profile QC analyses land in the Analysis tab, plus a colour fix for n-colour profiles and a robustness merge from iccDEV. All computed client-side by the iccviz WebAssembly engine.
New Analysis sections
-
Primary-Inking Paths through Neutral — three in-gamut paths (Cyan→Red, Magenta→Green, Yellow→Blue), each routed through the neutral axis at its endpoints' midpoint lightness and run through the selected
B2Atable. The in-gamut counterpart to Ink Usage in Shadows: because every sample is inside the gamut by construction, a step or reversal in a colorant is a CLUT-smoothness defect, not a gamut-mapping artefact. Black-point compensated for the perceptual/saturation intents. -
Ink Usage Statistics — per-colorant mean coverage and share of ink over the neutral axis: the profile's neutral-build fingerprint, independent of the sample count. (Validated against the GRACoL reference to ~0.5%.) The whole-gamut second table is listed as pending — it needs a gamut-boundary construction not yet built.
-
Per-ink separations in CLUT Image — selecting a
B2A(PCS→device) table now shows a grayscale ink-coverage image for each colorant below the lattice (darker = more ink), sharing the section's rendering-intent selector. A hint at the top of the section points you to them.
Fixes
-
N-colour CLUT images now render in colour. A 5/6/7-colour (nCLR)
B2Atable's CLUT image used to fall back to a single-channel grayscale (no cheap RGB/CMYK preview exists for n-ink). The main image is now colour-managed through the profile's forwardA2B, so it shows the real colours those inkings reproduce. CMYK/RGB previews and the per-ink separations are unchanged. -
iccDEV #1742 voxel-pitch guard merged into the local iccviz engine:
voxelEnclosedVolumenow rejects a non-finite or sub-minimum voxel pitch locally (a defensive fix for the gamut-volume path).
Under the hood
- WASM rebuilt against a clean iccDEV
master, so every IccProfLib-linked module also picks up upstream fixes since 2.2.0. - New UI strings translated across all 12 locales; translation spreadsheets regenerated. (Machine-assisted for the 11 non-English locales — a native review via the
translations/round-trip is welcome.) - User guide (§3.4 Analysis) documents the two new sections, the per-ink separations, and the n-colour colour preview.
v2.2.0 — Round-trip ΔE by lightness
Adds the round-trip error plot from the reference print-QC report, and makes a failure in one analysis stop costing you the whole page.
New: Round-trip ΔE by lightness
Under the ΔE histogram in Analysis → Profile Statistics, a scatter answering the question the summary figures cannot: where in the tone scale the B2A inversion struggles, and how far into the gamut the trouble reaches.
It samples 32 lightness levels. At each level it takes 64 points on the gamut boundary, then repeats them eroded toward the neutral axis at 80 %, 50 % and 20 % of their chroma — 8,192 points in all, plotted individually with dotted separators between levels.
Read it band by band. Inside a band the points run from the gamut surface on the left to neutral on the right, so the error should fall away across the band: colours near the boundary are hardest to invert, colours near neutral easiest. A band whose error stays high all the way to neutral is a profile in trouble at that lightness, and the tallest spikes mark the levels where the gamut boundary itself inverts worst.
Every seed is inside the gamut by construction, so this measures genuine B2A/A2B disagreement rather than colours being clipped for being unreachable.
Two things worth knowing before comparing numbers:
- This plot has its own sampling, deliberately weighted toward the gamut boundary where inversion is hardest. Its mean and maximum are therefore higher than — and not comparable with — the table above it. It follows the rendering intent, but not the round-trip type.
- The lightness range is derived from the inkset (halfway between paper white and Yellow, down to halfway between the media black point and Blue), matching the reference method. It deliberately excludes the deepest shadows, where the gamut has collapsed to a sliver and the errors describe the sliver rather than the profile.
The parts that depend on knowing which channel is which ink are CMYK/CMY only; other profiles fall back to the raw gamut extent, and a profile without a matching AToB/BToA pair simply doesn't get the plot.
More robust
The Analysis tab is now fault-isolated. It runs several independent analyses over untrusted profile data through a WebAssembly engine; previously an unexpected failure in any one of them could take down the whole page, leaving a blank browser tab with nothing to act on. A failure there is now contained to that tab and reported as a readable message, and clears when you load another profile.
Known limitations
- The neutrality (Δa*b*) trace on the neutral axis is computed but still not shown — its definition remains under review.
- Extrema Colorimetry and Ink Usage in Shadows remain CMYK/CMY only for the ink-specific parts.
- Invert Transform remains hidden pending worker isolation.
- ICC.2 (iccMAX) support is partial — no multi-part ICS workflows, no V5 sub-profile selection.
- Float TIFF source decoding is still unsupported (float output works).
v2.1.0 — Print QC analysis: extrema colorimetry, shadow ink paths, tone response
Print QC analysis. Three additions to the Analysis tab, derived from a reference QC harness (doQCpfA.m) and checked numerically against its published report for GRACoL2013_CRPC6.
New: Extrema Colorimetry
The numbers a press operator reads first, in one place.
- White point — the colour of zero colorant, i.e. bare substrate.
- Black point — derived the way the reference does it: push PCS black through the selected
B2Atable to get the inking the profile chooses for black, then read that inking back throughA2B1. It genuinely differs per rendering intent, so it follows the intent selector. - Both in relative and absolute colorimetry. Absolute shows the substrate's own colour; a blue-white paper reads as roughly L* 95, b* −4.
- Inking at black point and TAC — the ink that black costs, and the total-area-coverage figure.
- Full tone vs maximum chroma per ink corner (C M Y R G B K): where each lands in hue/chroma/lightness, and the most chromatic point on the way there. When maximum chroma arrives before full tone the row is flagged — past that point the extra ink has stopped adding chroma and is only darkening.
Output profiles only (it assumes zero colorant means bare substrate). The per-hue table additionally needs a CMYK or CMY device space: an n-colour profile names its channels in any order, so which one is "cyan" cannot be known, and it says so rather than guessing.
New: Ink Usage in Shadows
Four straight paths across the a*b* plane at one constant, deliberately dark lightness, run through the selected B2A table. Every sample on a path shares the same L*, so an abrupt step or reversal in a colorant comes from hue and chroma handling alone — the signature of a shadow gamut-mapping artefact, and the kind of thing that surfaces in print as banding in dark areas.
The lightness plane is picked automatically (halfway between the profile's Blue corner and the darkest of C M Y R G). For the perceptual and saturation tables the lightness is first stretched from the media black point to PCS black, as those tables expect; both the compensated and raw planes are reported.
New: tone response on Neutral Axis Inking
The neutral plot now draws the tone response — where the neutral axis actually lands in lightness after a round trip — over the ink separation. Two things to read from it: sag below the diagonal means greys render darker than asked, and the curve flattens at the darkest lightness the profile can reach. That plateau is the media black point, and it should agree with the Extrema Colorimetry section.
Changed
- Every Analysis section now starts collapsed. Each one costs a full transform pass over the profile, so opening is an explicit choice rather than six analyses firing the moment the tab is shown. Results stay cached, so re-opening is instant.
Verified against the reference
Checked against the published QC report for GRACoL2013_CRPC6:
| Published | This release | |
|---|---|---|
| White LabREL | 100.0 0.000 0.000 |
100.000 0.000 0.000 |
| White LabABS | 95.02 0.980 −4.02 |
95.020 0.979 −4.013 |
| Black LabREL | 10.80 0.070 0.199 |
10.798 0.070 0.205 |
| Black LabABS | 9.65 0.293 −0.734 |
9.647 0.294 −0.727 |
| Inking at black | 0.845 0.751 0.603 1.000 |
identical |
| TAC | 3.20 |
3.200 |
Cyan <h C L> |
231.0 62.41 59.23 |
231.04 62.41 59.23 |
…and likewise for Magenta, Yellow, Red, Green and Blue. Residuals are ≤0.007 — rounding. The shadow plane is an independent check: the reference rule gives 38.335 from the published corner values, and the engine computed 38.33 from its own transforms. A separate cross-check: the tone curve's plateau and the black point are produced by two independent code paths and agree to the digit.
Documentation
The Analysis section of the user guide was rewritten. It had drifted: it still described Profile Statistics and Round-Trip as two sections after they were merged into one, and never documented the CLUT Image or Gamut Image sections at all. 34 new interface strings across all 12 languages.
Under the hood
New analysis APIs in the visualization engine (WhiteBlackPoints, HueExtrema, ShadowInkPaths), plus a general secondary-axis concept in the graph model for plots that mix two physical quantities. All WebAssembly modules were rebuilt from a clean upstream iccDEV master.
Known limitations
- A neutrality (Δa*b*) trace for the neutral axis is computed but not yet shown — its definition is still under review.
- Extrema Colorimetry and Ink Usage in Shadows are CMYK/CMY only for the parts that depend on knowing which channel is which ink.
- Invert Transform remains hidden pending worker isolation.
- ICC.2 (iccMAX) support is partial — no multi-part ICS workflows, no V5 sub-profile selection.
- Float TIFF source decoding is still unsupported (float output works).
v2.0.1 — security + robustness fixes from an adversarial review
A security and robustness release following an adversarial review of 2.0.0. No feature changes; one behavioural fix worth noting if you assemble 16-bit spectral images (see Silent data loss below).
Memory safety
- All three image encoders now bound their own input.
encodeTiffBytes/encodePngBytes/encodeJpegBytescomputed the required buffer size in 32-bitsize_t(WASM is 32-bit) with no ceiling, so a caller could wrap the product to a small value, pass the "buffer big enough?" check, and make the scanline and plane loops read past the real allocation. The size is now computed in 64-bit and capped at the same limit the decoders use — restoring the invariant that every WASM entry point bounds its own input rather than trusting its caller. - Truncated PNGs now fail instead of leaking heap. libpng's read callback must deliver exactly the bytes it asks for; ours silently copied fewer on a truncated or crafted file, leaving the tail of libpng's buffer holding whatever was previously on the heap — which could surface in a decoded image or in an extracted embedded profile. It now raises a proper error.
Supply chain
- libxml2 is pinned to a commit, not the mutable tag
v2.12.6. It is the parser that handles untrusted XML during the ICC round-trip, and a git tag can be retargeted upstream. - CI verifies the committed WASM checksums before building. To be clear about what that does and does not prove: it catches corruption and artifact/manifest drift; it is not protection against a malicious commit, which would simply update both.
Resource limits and responsiveness
- Transform Image now bounds its output, not just its input. The destination channel count comes from the chain's last profile, so a legitimate multichannel DeviceLink combined with float output turned a 64 MP image into a ~3.8 GB allocation — then copied again into the WASM heap. Oversized conversions are now refused up front with the actual size and a suggested remedy.
- The transform no longer freezes the tab. The chunked loop ran as one uninterruptible task, so the page locked for the whole conversion and the "Transforming…" state never even painted. It now yields between chunks and shows a live percentage.
- The spectral assembler is bounded the same way (channel count is user-driven, so the total was previously unbounded).
Silent data loss
- 16-bit spectral planes are preserved. The Spectral assembler previously kept only the high byte of each 16-bit sample, discarding half the measured precision of a spectral scan without any indication. An all-16-bit input set now produces a 16-bit TIFF; mixed bit depths reduce to 8-bit explicitly. If you assembled 16-bit spectral data with 2.0.0 or earlier, re-run it.
- Out-of-range results are reported. Transform Image counts samples the CMM pushed outside the representable range and tells you the percentage, instead of silently shipping clipped or black pixels — the way a degenerate profile or a heavily out-of-gamut absolute-colorimetric run would otherwise present as a clean conversion.
Hardening
- The in-app guide's HTML→React converter now enforces a tag allowlist, drops event-handler attributes, and restricts link schemes. Only repo-generated content reaches it, but it is an injection-shaped sink that the repository's pre-commit check structurally cannot detect, so it defends itself.
- The production build no longer trusts
localhost:3001in the cross-app launch handshake. It previously both accepted profile bytes from, and announced itself to, any co-resident process on that port with no confirmation. Development builds are unchanged. - Dropped colour datasets are size-checked before being read into memory, matching how profile loading already worked.
Documentation
Two claims that overstated what the code delivers were corrected: the XML entity-expansion rationale (upstream iccDEV no longer disables libxml2's own guards, so ours is defence in depth rather than the sole control), and an explicit note on what the pre-commit injection check can and cannot catch.
Not changed, and why
A reported "critical heap overflow" in TIFF decoding was investigated and not patched. The 32-bit overflow is real arithmetic, but crafted TIFFs wrapping the value to both zero and a small non-zero value are rejected by libtiff's own overflow guard before any allocation happens. Adding a redundant check on a path libtiff already closes would have been noise.
Known limitations (unchanged from 2.0.0)
- Invert Transform remains hidden pending worker isolation.
- ICC.2 (iccMAX) support is partial — no multi-part ICS workflows, no V5 sub-profile selection.
- Float TIFF source decoding is still unsupported (float output works).
v2.0.0 — Profile Pool workbench, Combine tab, ICC.2 (iccMAX), iccApplyProfiles output parity
profiletool 2.0.0 turns the single-profile validator into a multi-profile workbench, and now documents support for ICC.2 (iccMAX) alongside ICC.1. Everything still runs entirely client-side — a WebAssembly build of iccDEV's IccProfLib — with no upload and no backend.
ICC.1 and ICC.2 (iccMAX)
ICC.2 profiles load, inspect, validate and round-trip like ICC.1 ones: the validation checklist includes iccMAX-specific checks, spectral PCS and multi-processing-element tags are decoded, and the transform engines are built against the full iccMAX stack. ICC.2 support is partial in this release — see Known limitations below.
The Profile Pool workbench
The app is now built around a Profile Pool: load as many profiles as you like and drag them onto four tabs — Profile, Compare, Combine, Spectral — each with its own accumulator.
Combine — chain profiles and put them to work
The Link Pipeline builds an ordered chain of pooled profiles as a vertical stack, showing each stage's transform (RGB → Lab), the connecting space between stages, and the end-to-end flow. Drag to reorder, flip the chain direction, and set a rendering intent globally or per stage. The chain is validated live by the CMM itself, so an unconnectable stage is reported exactly where it breaks.
From a chain you can:
- Make DeviceLink — bake the chain into one
link-class profile (iccApplyToLink), landing in the pool. - Transform Image — run a TIFF/PNG/JPEG through the chain (
iccApplyProfiles), streamed in bounded chunks so large CMYK rasters don't exhaust the heap. - Transform Data — run a CGATS/IT8, CSV, CxF or JSON dataset through the chain (
iccApplyNamedCmm), with spectral→colorimetry conversion via iccDEV's canonical calculator, selectable observer/illuminant, and duplicate filtering. - V4 Display Maker — build a v4.3 matrix/TRC display profile from a V5 display + V5 observer profile.
Image output options (new in 2.0.0)
Transform Image now exposes the destination knobs from the iccApplyProfiles CLI:
| Option | Choices |
|---|---|
| Encoding | Same as source · 8-bit · 16-bit · Float (32-bit IEEE) |
| Compression | None · LZW · ZIP (Deflate) |
| Planar | Composite · Separated planes |
| CMM interpolation | Tetrahedral · Linear |
| Embed ICC | Tag the output with the chain's output-space profile |
RGB/Gray output stays a PNG by default; selecting a TIFF-only option switches the container to TIFF.
Extended rendering intents
Beyond the four base intents, the per-stage and global listboxes now offer the decimal-coded flavours the CLI accepts — no D2Bx/B2Dx (10–13) and black-point compensation (40–42) — surfaced only for profiles that actually carry the required tables. Both controls carry a tooltip describing the selected intent.
Embedded-profile extraction
Drop an image carrying an embedded ICC and a banner offers to extract it in one click — the profile joins the pool and is placed at the head of the chain.
Compare — gamut comparison
The Compare tab overlays the gamut boundaries of two or more profiles as a 3-D shell plus a 2-D lightness slice, including matrix/TRC profiles.
Spectral
Assemble a set of single-channel spectral images into one multi-channel TIFF (iccSpecSepToTiff). Extensionless raster planes are accepted by content sniffing.
Analysis
A reworked Analysis tab: Profile Statistics, a unified Round-Trip (PRMG) engine with min/mean/max/stddev, cumulative ΔE buckets and a relative-frequency histogram, Neutral Axis Inking, and gamut/CLUT imagery per rendering intent. Tag visualizations are consolidated on Plotly, with a single-point transform evaluator.
Also new since 1.9
- New from .cube — build a DeviceLink from a
.cubeLUT (iccFromCubeparity). - Canonical libtiff / libpng / libjpeg WASM codecs replace the hand-rolled image and embedded-profile readers.
- Tag dumps are capped and lazily rendered;
describeTagmoved to a Web Worker.
Localization
The full 2.x interface is translated: 412 keys across 12 languages (English, Français, Deutsch, Italiano, Español, Português PT/BR, Svenska, 简体中文, 繁體中文, 日本語, 한국어), with the translation spreadsheets regenerated.
Documentation
The user guide gains a Combine tab chapter — chain building, DeviceLink, Transform Image and its output options, Transform Data, the V4 Display maker, and the Compare/Spectral tabs — with a new illustration.
Known limitations
- ICC.2 (iccMAX) support is partial. Not yet covered: multi-part ICS (Interchange Color Space) workflows, selecting a V5 sub-profile when applying a transform, and inverse search on some ICC.2 profiles. A profile using an unsupported ICC.2 construct is reported by the Validation tab rather than silently mis-read.
- Invert Transform (
iccApplySearch) is implemented but hidden in this release: the inverse search can trap under WebAssembly on some v5 iccMAX profiles. It returns once the search is isolated in a disposable worker. - Float TIFF source decoding is not yet supported (float output is); "Same as source" therefore resolves to 8- or 16-bit.
- Per-profile
-PCCconnection conditions and-ENVcalculator variables are not yet exposed.
v1.9.1 — iccviz memory-safety + degenerate-detection (rebuild iccplot WASM)
profiletool 1.9.1 rebuilds the iccplot WASM module so the shipped Profile Plot / Analysis visualizations carry the memory-safety and degenerate-detection fixes surfaced by the iccDEV #1712 Copilot review. No UI or feature changes — this is a correctness/safety patch on the visualization engine.
Fixes
- Use-after-free / double-free in the visualization transform path.
CIccXform::Createdefaults tobOwnsProfile=true, so a failedCreate()deleted the borrowed profile the caller still owned. All 5 call sites inIccVizModelnow passbOwnsProfile=false. This was reachable from the shipped Profile Plot / Analysis code. - Degenerate-gamut detection for Gamut Volume. The enclosed-volume estimate uses morphological closing, which floors a collapsed plane/line at ~1 voxel and reports a non-zero sheet/tube artifact instead of ~0. A new closed-form principal-axis analysis (
iccvizmath::principalStdDevs, symmetric-3×3 eigenvalues) flags a boundary cloud collapsed toward a plane (thinnest extent ≈ 0) so the metric is marked degenerate rather than silently wrong. - Signed-overflow hardening. Two
S + 1index computations promoted to(double)S + 1.0. - Doc-comment corrections — the tag walk uses a fixed canonical signature order, not the profile's tag-table order.
Build provenance
Built against a clean iccDEV origin/master (3c656146). Only iccplot.wasm changes versus 1.9.0 — no IccProfLib drift into the other modules. scripts/build-wasm.sh --verify reproduces the committed SHA256SUMS.
The same source fixes are staged upstream for iccDEV (#1712 successor branch); profiletool ships them now because the bug is reachable in the deployed WASM.
v1.9.0 — Gamut volume accuracy + degenerate warning
Profile Statistics: more accurate gamut volume, reliability warning, and a leaner Analysis tab
Gamut volume — accuracy rework
- The device-cube boundary is now sampled over all facets, so CMYK and higher-ink (N≥4) gamuts are no longer under-captured (the old 2-skeleton missed the interior of the 3-D faces).
- Dilation and erosion are now a matched morphological closing (cube dilate + 26-neighbour erode), removing the previous ~9% convex-corner over-estimate.
Note — reported gamut volumes change. Because the corner over-count is gone, volumes drop at the default resolution — e.g. sRGB (A2B1, voxelSize 2.0) goes 805,728 → ~735,816 ΔE*ab³. This is the accuracy fix, not a regression; the numbers are now dilate-stable and converge as resolution increases.
New: degenerate-gamut warning
When a gamut boundary collapses or comes back mostly undefined, the Analysis panel now shows a ⚠ warning and flags that intent's volume as unreliable instead of printing a misleading tiny number.
Removed: L* tone-reversal analysis
The L* tone-reversal chart has been retired from the Analysis tab.
Under the hood
- Untrusted-input hardening of the gamut-volume and B2A round-trip paths (bounded allocations/loops, overflow-safe indexing).
- Neutral-axis inking reuses IccProfLib's CIELAB↔XYZ conversion.
- All WASM modules rebuilt against clean iccDEV master.
v1.8.2 — Anti-clickjacking guard (security)
Security patch from an antagonistic review of chardata + profiletool.
Fixed — Clickjacking (Medium-Low)
The CSP's frame-ancestors 'none' was delivered via a <meta> tag, where — per the CSP spec — it is silently ignored (that directive, like report-uri/sandbox, is honored only in an HTTP response header). The production subpath (/var/www/profiletool/) is nginx-served outside chardata's helmet and shipped no X-Frame-Options either, so the editor could be framed for a clickjacking overlay.
Fix (defence-in-depth, ships without server access): public/noclickjack.js hides <html> by default and reveals it only when self === top; it busts out if framed and fails closed (stays hidden) if a sandboxed iframe neutralizes the bust-out. Loaded as a synchronous, same-origin classic script — deliberately not inline (script-src omits 'unsafe-inline') and not a CSP hash (Vite could minify inline bytes), served from public/ so it's copied verbatim. The legitimate chardata window.open handoff runs as self === top, so it is unaffected.
Still recommended (the real fix): a true HTTP header on the nginx location /profiletool/ block —
add_header X-Frame-Options "DENY" always;
add_header Content-Security-Policy "frame-ancestors 'none'" always;
This in-app guard is the belt-and-suspenders alongside it.
v1.8.1 — Profile Statistics: full localization
1.8.1 — Profile Statistics: full localization
Translates the Profile Statistics strings — section heading, intro, the
Rendering intent / Gamut volume / Round-trip ΔE column labels, mean/P90/max, and
the Absolute Colorimetric intent name — into all 11 non-English languages, and
regenerates the translation spreadsheets from the i18n dictionary.
v1.8.0 — Profile Statistics (gamut volume + B2A round-trip)
1.8.0 — Profile Statistics (gamut volume + B2A round-trip)
A new Profile Statistics section at the top of the Analysis tab reports, per
rendering intent:
- Gamut volume (ΔE³) enclosed by the device→PCS transform.
- B2A round-trip accuracy — ΔE*ab of a Lab → device → Lab round trip through
the profile, as mean · P90 · max.
Built on two new IccProfLib-based iccviz metrics (GamutVolume, RoundTripDE).
Numbers are decimal-aligned; computed lazily and cached per profile. Algorithm
attribution: Harold Boll (doQCpfA).