Commit Graph
19 Commits
Author SHA1 Message Date
Dylan M. Taylor 628a235ae5 craft-cms: the changes PhotoCraft made to its copy since extraction (#858)
* craft-cms: the changes PhotoCraft made to its copy since extraction

PhotoCraft kept editing crates/cms after craft-cms was extracted from it at
storytold/photocraft@b3222ee8. This ports its 41 commits up to
storytold/photocraft@0143b148 with a three-way merge per file, keeping the
changes craft-cms made on its own (Result-returning API, white preservation,
FOGRA39, named colours, profile ID).

Ported: the .3dl shaper, Mesh header, UTF-16 and .look fixes in lutfile; the
checked ICC offsets and the per-profile curve sample budget; same_bytes and the
byte-confirmed transform cache; the blocked matrix/TRC path and the 8-bit
first-stage table; GpuMatrixShaper; cicp_rgb; the ACEScg and Dot Gain 20 %
built-ins (the latter now the default gray); bisection for a table's flat ends;
PhotoCraft spelled with a capital C in the built-in descriptions, with the
shipped coated CMYK profile's bytes as PhotoCraft regenerated them.

Not taken, because craft-cms already does it another way: Curve::inverse_table
(inverse64_many), the effective-dimension loop in multilinear interpolation,
the curve-count and mluc checks, and the .cube DOMAIN writer.

The 8-bit first-stage table checks device white as the exact path does.

Refs #779

* craft-cms: Lut3d::from_fn

PhotoCraft's proof and HDR display tables are built from a chain of several
transforms, which it wrote into Lut3d's fields. Those are private here, so
from_fn samples any RGB function, with the same size bounds as from_transform.

Refs #779

* craft-cms: README names Lut3d::from_fn
2026-11-11 18:37:23 -05:00
Dylan M. Taylor 725ce2bdf2 craft-cms: read mAB/mBA elements through one checked slice (#823) 2026-11-11 14:41:43 -05:00
Dylan M. Taylor 4c6fe7c864 craft-cms: confirm ICC identity by bytes, not the 64-bit hash alone (#822) 2026-11-11 13:53:39 -05:00
Dylan M. Taylor b59aeeb10a craft-cms: per-profile budgets for decoded curve samples and CLUT values (#815) 2026-11-11 13:31:08 -05:00
139a39e94c cms: write the profile ID into encoded profiles (#749)
encode() stamps the header profile ID: the MD5 of the profile with the
flags, rendering-intent and ID fields zeroed (ICC.1:2022 7.2.18).
profile_id(bytes) computes it for any profile. Adds md-5 (RustCrypto,
MIT OR Apache-2.0, no default features); digest was already in the lock.

Closes #748.

Co-authored-by: KenM76 <KenM76>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 07:17:22 -05:00
a9608afe43 cms: read named-colour (ncl2) profiles and convert their colours (#747)
* cms: read named-colour (ncl2) profiles and convert their colours

A named-colour profile's ncl2 tag (ICC.1:2022 §10.17) was never read, so
a spot-colour library could not be used. Profile::named_colors() returns
its entries: the name joined from prefix, root and suffix, the PCS value
and any device coordinates. NamedColors::to_device(name, dst) converts
one into a destination profile, relative colorimetric, through the Lab
identity profile.

Lab in ncl2 uses the legacy 16-bit encoding (0xFF00 = L* 100) in every
profile version; reading it as v4 puts L* about 0.4 low. Counts are
checked against the tag size before allocating, and more than 15 device
coordinates is refused.

Adapted from icccer's named_color module (MIT, same author), rewritten
onto craft-cms types.

Refs #746

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* cms: cap the ncl2 tag-table walk; doc fixes

Cap the tag count at 1024, as Profile::parse does, so the table offsets
cannot overflow on a 32-bit target. Correct the named_colors doc and add
one for is_empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* cms: README notes named-colour tables are readable

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: KenM76 <KenM76>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 07:12:16 -05:00
Dylan M. Taylor 2e40c0329f Merge pull request #533 from storytold/fix/sec-cms-inverse-edges
cms: find a sampled inverse curve's flat ends once per pipeline
2026-11-02 22:46:39 -05:00
Dylan M. Taylor 0535bc87f1 Merge pull request #146 from midasmatterhorn/fix/r4-cms-cube-domain
cms: write_cube keeps a non-default input domain
2026-11-02 22:01:55 -05:00
Dylan M. Taylor ce37850488 cms: find a sampled inverse curve's flat ends once per pipeline
Inverting a sampled curve scanned the whole table for its flat start and
end runs whenever the value fell at or beyond an end. A constant table
falls there for every input, so each pixel the exact pipeline evaluated
read the whole curve again. inverse64_many already cached the ends for
output tables; a Pipeline now caches them for every inverse-curve stage,
recomputed on extend and optimize.
2026-11-02 21:09:04 -05:00
Dylan M. Taylor bc8fe0eb53 cms: keep NaN propagation in multilinear interpolation
A NaN input gives a NaN fraction, which the active-dimension check dropped, returning a
finite node value. Keep any nonzero fraction, NaN included (CodeRabbit review).
2026-10-22 22:22:39 -04:00
Dylan M. Taylor c900c69553 security: bound cms CLUT interpolation and inverse-curve table construction
- Clut::multilinear enumerates corners only for dimensions between two grid points, so
  singleton dimensions no longer multiply per-pixel work (CodeRabbit CWE-407, clut.rs:238)
- Transform::from_pipeline finds a sampled curve's flat ends once per curve via
  Curve::inverse64_many instead of rescanning the table for each of the 4096 output entries
  (CodeRabbit CWE-407, curve.rs:276)
2026-10-22 21:39:20 -04:00
midasmatterhorn 8b71728482 cms: the mluc record is read as one checked 12-byte slice
The eager tuple computed o + 8 even when the first read had failed,
overflowing a 32-bit usize at offsets near u32::MAX (n = 2 with
size = u32::MAX - 20 reaches it: o fits, o + 8 does not). The record is
now bounds-checked in one slice and the fields read inside it, the same
complete-record pattern the profile reader uses elsewhere.
2026-10-16 14:46:23 +03:00
midasmatterhorn 1db56c92d2 cms: bound the ICC curve count before it is multiplied, and read mluc record offsets checked
parse_curve computed 12 + n * 2 with n straight from the file and only
checked the 1 << 20 bound afterwards, inside the table arm — on a 32-bit
(wasm32) target, which craft-cms ships branches for, a trC curve count of
0xFFFFFFFF overflows with overflow checks on and panics before the guard
could reject it. The mluc reader computed 16 + k * size unchecked, where
wrapping reads another record's bytes as the language, length and offset
(wrong text, no error) instead of stopping.

Hoist the curve-count guard above the arithmetic and compute the record
offset with checked_mul/checked_add, breaking out when it does not fit.
This ports the app-side fix accepted as PhotoCraft #4976 / PR #5227,
which was never carried into craft-cms. On a 64-bit host both sites are
benign (the arithmetic fits), so the two new tests pass before and after
there; they bite on a 32-bit/overflow-check build, which deserves a
wasm32 CI job — craft-cms has none yet.
2026-10-15 17:58:13 +03:00
midasmatterhorn 6688e05c55 cms: write_cube keeps a non-default input domain
The parser reads and validates DOMAIN_MIN/DOMAIN_MAX and the type
documents them as meaningful ('the input domain the table expects'), but
the writer never emitted them: a custom-domain .cube re-saved through
parse → write_cube came back with the default domain, passed the
domain_is_default gate that used to reject it, and every row silently
mapped colours over the wrong input range.

Emit DOMAIN_MIN/DOMAIN_MAX when the domain is not the default (parse_cube
matches keywords line by line and accepts them anywhere; {} on f32 prints
the shortest roundtripping form). Regression test:
cube_domain_survives_a_write — fails today (the written table loses the
domain), round-trips exactly with the emission.
2026-10-15 17:51:42 +03:00
aiongg 9e2b2b03d8 craft-cms: separate exact profile-name matches from stand-ins
Builtin::from_profile_name matches only a built-in's own description.
Builtin::stand_in_for adds the FOGRA39 approximations (Coated FOGRA39, ISO
Coated v2) for documents that name a profile without carrying its bytes.
ISO Coated v2 300% (ECI) has no stand-in: the built-in separates to up to
330 % total ink. The README example parses the document's ICC bytes first
and handles a missing stand-in explicitly.
2026-10-13 03:22:39 +00:00
aiongg 1f0d815477 craft-cms: map device white to device white on every path
With a LUT destination, sRGB white came out as 0.3-0.5 % C/M/Y through
Transform::eval and precise float conversions: the legacy Lab encoding
puts L* 100 at 0.996 and a* = 0 at 0.50001, between the BToA grid
nodes, and fix_white only patched the device-link node, so only the
table paths were right.

Transform: for every intent but absolute colorimetric (and proofs
without paper simulation), a pixel equal to the source's device white
gives exactly the destination's device white. fix_white still sets the
device-link node; the paths that do not read that node exactly (the
exact pipeline, float interpolation, and 8-bit lookups when white is no
node) compare each pixel with white once. No other colour changes.
device_white(Lab) had a* = b* = 0.5; Lab white is 128/255 in the v4
encoding, which is no node of the 33-point grid, so Lab sources take
the per-pixel check there too.

Profile: synth::CmykParams::white_on_node maps no ink to exactly
L* 100, a* = b* = 0 in the AToB and gives the BToA tables input curves
that put that white on a node holding no ink. The Coated FOGRA39
profile is regenerated with it and separates PCS white to 2e-5 ink
(was 0.5 %), so the step into white is as smooth as its neighbours.
The default parameters and the Photocraft profile are unchanged; its
white still falls between nodes, so its last grey step into white is
up to about 0.4 % C larger than the one before, as with lcms's
white-node fix, and the test allows that for this profile.

FOGRA39 accuracy against 16a66e1: AToB against the patches unchanged
(mean 0.07, p95 0.18, max 0.53 ΔE00), reference sRGB values unchanged
(max 0.76), Lab -> CMYK -> Lab p95 1.04 -> 1.07, max 2.54 -> 2.55
(mean 0.24).
2026-10-13 01:50:14 +00:00
aiongg d0107d1130 craft-cms: return errors instead of panicking on caller input
The crate denies panics, but asserts and slice indexing were reachable
from arguments: pixel strides, buffers that are not whole pixels or a
destination shorter than the source, single-colour slices shorter than
the channel count, CLUT and pipeline dimensions, and LUT sizes.

Every such check now returns CmsError::InvalidArgument. Buffer
conversions validate once per call, before any pixel is written, so the
per-pixel loops carry no extra checks; internal callers that already
guarantee lengths use crate-private unchecked evaluation.

Pipeline, Clut and Lut3d keep their fields private (read through
accessors) so a validated value cannot be changed into one that panics.
Pipeline::new checks matrix shapes and channel counts; Clut::new and
Clut::sample check grid, outputs and the table size limit.

API changes (unreleased crate):
- Transform::eval, eval_fast, convert_u8/u16/f32, apply,
  convert_bytes_many and from_pipeline return Result.
- Pipeline::new, extend and eval, Clut::new, sample, eval,
  eval_trilinear and eval_multilinear return Result.
- GamutCheck::delta_e and out_of_gamut, Lut3d::from_transform,
  LutFile::identity and from_fn, synth::cmyk_profile and coated_cmyk
  return Result.
- Pipeline, Clut and Lut3d fields are read through methods.
2026-10-13 01:33:07 +00:00
aiongg f97ed953f4 craft-cms: built-in Coated FOGRA39 profile fitted to Fogra's data
Add Builtin::CoatedFogra39 ("Coated FOGRA39 (fitted, craft-cms)",
profiles/craft-coated-fogra39.icc: AToB 11^4, BToA 33^3, 330% TAC,
medium GCR). It is fitted to Fogra's FOGRA39 characterisation data
(tests/data/FOGRA39.txt, unmodified, 1617 patches): a Yule-Nielsen
Neugebauer model with free overprints, per-ink tone curves and n, plus
a Gaussian-kernel ridge correction of the residuals. Against the
patches, relative colorimetric through the AToB: mean dE00 0.07,
p95 0.18, max 0.53. The BToA tables invert the AToB table
(synth::cmyk_profile_from_model); the Photocraft coated profile and its
synth defaults are unchanged.

Builtin::from_profile_name maps the names documents carry for
FOGRA39-based profiles (Coated FOGRA39, ISO Coated v2 (ECI)) to the new
profile and every built-in by its description. Adds math::delta_e2000.

Refs #36
2026-10-13 01:23:45 +00:00
+2 dd9e3f1bc3 Add craft-cms: ICC colour management extracted from PhotoCraft
Move PhotoCraft's crates/cms (storytold/photocraft@b3222ee8e7)
into crates/cms as craft-cms: ICC v2/v4 profile parsing and writing, transforms, rendering
intents, black point compensation, soft proofing, gamut checks, synthetic CMYK, 3D LUTs, LUT
files and built-in profiles, with all tests.

The Bradford inverse and the built-in RGB colorant matrices are computed in constants, so the
build rejects a singular matrix and the two expect calls are gone; the values are bit-identical
to the runtime results. Built-in profile descriptions and copyright text keep their PhotoCraft
wording so the profile bytes do not change. rayon joins the workspace dependencies.

Refs #36

Co-authored-by: Alex Gorevski <agorevski@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Elvis33LE <67311580+Elvis33LE@users.noreply.github.com>
Co-authored-by: F4LCON <107556180+itsF4LCON@users.noreply.github.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: HEOJUNFO <100739943+HEOJUNFO@users.noreply.github.com>
Co-authored-by: Shehan Kavishka <kavishkashehan726@gmail.com>
Co-authored-by: Valentin Klein <vangel1976@googlemail.com>
Co-authored-by: ljkx <177671075+ljkx@users.noreply.github.com>
Co-authored-by: storybored379 <storybored379@gmail.com>
Co-authored-by: wstock <w.stock@yahoo.com>
2026-10-13 01:23:45 +00:00