diff --git a/Cargo.lock b/Cargo.lock index e8f70c57..e50487fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,12 +8,126 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -35,12 +149,96 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-deque" version = "0.8.8" @@ -66,12 +264,38 @@ version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "either" version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "errno" version = "0.3.14" @@ -88,6 +312,38 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + [[package]] name = "fnv" version = "1.0.7" @@ -117,18 +373,341 @@ dependencies = [ "r-efi 6.0.0", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "gif", + "image-webp", + "moxcms 0.8.1", + "num-traits", + "png", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error 2.0.1", +] + +[[package]] +name = "imgref" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e44b0a4eaa4c82f441d50a963f2d5f05a787240aeee097597033e72accfd22f" + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "jpeg-decoder" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" +dependencies = [ + "rayon", +] + +[[package]] +name = "jpeg-encoder" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0370574b86f7eca156b9f298392b5e69a23f8c86f3f865add60bbc2e79467a6" + +[[package]] +name = "jxl-bitstream" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b480e752277e29eb4054f69546887a9b84656fe78c08f54ba5850ced98a378fe" +dependencies = [ + "tracing", +] + +[[package]] +name = "jxl-coding" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd972bcd125e776f1eb241ac50e39f956095a1c2770c64736c968f8946bd9a3c" +dependencies = [ + "jxl-bitstream", + "tracing", +] + +[[package]] +name = "jxl-color" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f316b1358c1711755b3ee8e8cb5c4a1dad12e796233088a7a513440782de80b2" +dependencies = [ + "jxl-bitstream", + "jxl-coding", + "jxl-grid", + "jxl-image", + "jxl-oxide-common", + "jxl-threadpool", + "tracing", +] + +[[package]] +name = "jxl-frame" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d967c6fd669c7c01060b5022d8835fa82fd46b06ffc98b549f17600a097c2b3" +dependencies = [ + "jxl-bitstream", + "jxl-coding", + "jxl-grid", + "jxl-image", + "jxl-modular", + "jxl-oxide-common", + "jxl-threadpool", + "jxl-vardct", + "tracing", +] + +[[package]] +name = "jxl-grid" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01671307879a033bfa52e6e8784b941aca770b3f3a7d33830b455b6844f793fb" +dependencies = [ + "tracing", +] + +[[package]] +name = "jxl-image" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5f752d62577c702a94dbbce4045caf08cb58639e8a4d56464b40ecf33ffe565" +dependencies = [ + "jxl-bitstream", + "jxl-grid", + "jxl-oxide-common", + "tracing", +] + +[[package]] +name = "jxl-jbr" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35d032bcec660647828527ff42c6f5776d2fd44b8357f9f6d9ac6dc07218e46" +dependencies = [ + "brotli-decompressor", + "jxl-bitstream", + "jxl-frame", + "jxl-grid", + "jxl-image", + "jxl-modular", + "jxl-oxide-common", + "jxl-threadpool", + "jxl-vardct", + "tracing", +] + +[[package]] +name = "jxl-modular" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2f045b24c738dd91d482be385512b512721ae08a671bd4b27bf1c47f215235" +dependencies = [ + "jxl-bitstream", + "jxl-coding", + "jxl-grid", + "jxl-oxide-common", + "jxl-threadpool", + "tracing", +] + +[[package]] +name = "jxl-oxide" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d36c662923f47586880211f3bc7c0d83fb3a9b410d278c7bde93450748abeef3" +dependencies = [ + "brotli-decompressor", + "jxl-bitstream", + "jxl-color", + "jxl-frame", + "jxl-grid", + "jxl-image", + "jxl-jbr", + "jxl-oxide-common", + "jxl-render", + "jxl-threadpool", + "tracing", +] + +[[package]] +name = "jxl-oxide-common" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62394c5021b3a9e7e0dbb2d639d555d019090c9946c39f6d3b09d390db4157b" +dependencies = [ + "jxl-bitstream", +] + +[[package]] +name = "jxl-render" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d34386bfdb6a19b5a30cc9beb4d475d537422c31ae8c39bb69640fcce3fcaf19" +dependencies = [ + "bytemuck", + "jxl-bitstream", + "jxl-coding", + "jxl-color", + "jxl-frame", + "jxl-grid", + "jxl-image", + "jxl-modular", + "jxl-oxide-common", + "jxl-threadpool", + "jxl-vardct", + "tracing", +] + +[[package]] +name = "jxl-threadpool" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f15eb830aa77a7f21148d72e153562a26bfe570139bd4922eab1908dd499d3" +dependencies = [ + "tracing", +] + +[[package]] +name = "jxl-vardct" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce72a18c6d3a47172ab6c479be2bdb56f22066b5d7092663f03b4490820b4511" +dependencies = [ + "jxl-bitstream", + "jxl-coding", + "jxl-grid", + "jxl-modular", + "jxl-oxide-common", + "jxl-threadpool", + "tracing", +] + [[package]] name = "libc" version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "lightcraft-catalog" +version = "0.1.0" +dependencies = [ + "lightcraft-develop", + "proptest", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "lightcraft-codecs" +version = "0.1.0" +dependencies = [ + "bytemuck", + "image", + "image-webp", + "jpeg-decoder", + "jpeg-encoder", + "jxl-oxide", + "lightcraft-color", + "lightcraft-geom", + "lightcraft-raster", + "moxcms 0.9.1", + "png", + "proptest", + "ravif", + "rayon", + "serde", + "thiserror", + "tiff", + "zune-core", + "zune-jpeg", +] + [[package]] name = "lightcraft-color" version = "0.1.0" @@ -138,6 +717,34 @@ dependencies = [ "serde", ] +[[package]] +name = "lightcraft-develop" +version = "0.1.0" +dependencies = [ + "lightcraft-color", + "lightcraft-geom", + "proptest", + "serde", + "serde_json", +] + +[[package]] +name = "lightcraft-engine" +version = "0.1.0" +dependencies = [ + "lightcraft-catalog", + "lightcraft-color", + "lightcraft-develop", + "lightcraft-geom", + "lightcraft-pipeline", + "lightcraft-raster", + "lightcraft-scenes", + "log", + "serde", + "serde_json", + "thiserror", +] + [[package]] name = "lightcraft-geom" version = "0.1.0" @@ -158,6 +765,21 @@ dependencies = [ "serde_json", ] +[[package]] +name = "lightcraft-pipeline" +version = "0.1.0" +dependencies = [ + "image", + "lightcraft-color", + "lightcraft-develop", + "lightcraft-geom", + "lightcraft-raster", + "lightcraft-scenes", + "proptest", + "rayon", + "serde", +] + [[package]] name = "lightcraft-raster" version = "0.1.0" @@ -178,13 +800,24 @@ dependencies = [ "lightcraft-meta", "lightcraft-raster", "lightcraft-tiff", - "miniz_oxide", + "miniz_oxide 0.8.9", "proptest", "rayon", "serde", "thiserror", ] +[[package]] +name = "lightcraft-scenes" +version = "0.1.0" +dependencies = [ + "image", + "lightcraft-color", + "lightcraft-raster", + "rayon", + "serde", +] + [[package]] name = "lightcraft-tiff" version = "0.1.0" @@ -200,6 +833,30 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", +] + [[package]] name = "memchr" version = "2.8.3" @@ -213,6 +870,108 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "moxcms" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396077b717181ce3f9a6b66739b269b82d756be5ec0f76be0bc40a7c51775830" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", ] [[package]] @@ -230,6 +989,37 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -248,6 +1038,25 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn 2.0.119", +] + [[package]] name = "proptest" version = "1.11.0" @@ -267,12 +1076,24 @@ dependencies = [ "unarray", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + [[package]] name = "quick-error" version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.38.4" @@ -341,6 +1162,55 @@ dependencies = [ "rand_core", ] +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand", + "rand_chacha", + "simd_helpers", + "thiserror", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error 2.0.1", + "rav1e", + "rgb", +] + [[package]] name = "rayon" version = "1.12.0" @@ -367,6 +1237,12 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + [[package]] name = "rustix" version = "1.1.5" @@ -380,6 +1256,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "rusty-fork" version = "0.3.1" @@ -387,7 +1269,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" dependencies = [ "fnv", - "quick-error", + "quick-error 1.2.3", "tempfile", "wait-timeout", ] @@ -435,6 +1317,33 @@ dependencies = [ "zmij", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "syn" version = "2.0.119" @@ -490,6 +1399,39 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error 2.0.1", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + [[package]] name = "unarray" version = "0.1.4" @@ -502,6 +1444,17 @@ version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + [[package]] name = "wait-timeout" version = "0.2.1" @@ -520,6 +1473,57 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "windows-link" version = "0.2.1" @@ -548,6 +1552,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + [[package]] name = "zerocopy" version = "0.8.59" @@ -568,8 +1578,29 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index 4344166a..00eca32c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,16 @@ miniz_oxide = "0.8" # codecs image = { version = "0.25", default-features = false, features = ["png", "jpeg", "tiff", "webp"] } +zune-jpeg = { version = "0.5", default-features = false, features = ["std", "x86", "neon"] } +zune-core = { version = "0.5", default-features = false, features = ["std"] } +jpeg-decoder = { version = "0.3", default-features = false } +jpeg-encoder = { version = "0.7", default-features = false, features = ["std"] } +png = "0.18" +tiff = "0.11" +image-webp = "0.2" +moxcms = { version = "0.9", default-features = false, features = ["lut"] } +jxl-oxide = { version = "0.12", default-features = false } +ravif = { version = "0.13", default-features = false } # ui egui = "0.36" diff --git a/crates/codecs/Cargo.toml b/crates/codecs/Cargo.toml new file mode 100644 index 00000000..68d5e8cd --- /dev/null +++ b/crates/codecs/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "lightcraft-codecs" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "LightCraft standard image codecs: sniffing, decode to linear light, ICC, encoders (L1)" + +[lints] +workspace = true + +[dependencies] +lightcraft-geom = { workspace = true } +lightcraft-color = { workspace = true } +lightcraft-raster = { workspace = true } +serde = { workspace = true } +bytemuck = { workspace = true } +thiserror = { workspace = true } +rayon = { workspace = true, optional = true } +image = { workspace = true, features = ["gif", "bmp"] } +zune-jpeg = { workspace = true } +zune-core = { workspace = true } +jpeg-decoder = { workspace = true } +jpeg-encoder = { workspace = true } +png = { workspace = true } +tiff = { workspace = true } +image-webp = { workspace = true } +moxcms = { workspace = true } +jxl-oxide = { workspace = true, optional = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +ravif = { workspace = true, optional = true } + +[features] +default = ["parallel", "jxl", "avif"] +parallel = ["dep:rayon", "lightcraft-raster/parallel", "jpeg-decoder/rayon"] +jxl = ["dep:jxl-oxide"] +## AVIF encoding via ravif/rav1e (pure Rust, BSD). Native targets only; a no-op on wasm32. +avif = ["dep:ravif"] + +[dev-dependencies] +proptest = { workspace = true } diff --git a/crates/codecs/src/convert.rs b/crates/codecs/src/convert.rs new file mode 100644 index 00000000..93023486 --- /dev/null +++ b/crates/codecs/src/convert.rs @@ -0,0 +1,334 @@ +//! Shared tail of every decoder: interpret device samples (with ICC / container hints), linearize, +//! split alpha, and optionally downscale in linear light. + +use crate::icc::{self, IccColorModel, IccKind}; +use crate::space::{NamedSpace, SourceSpace, SpaceOrigin, Trc}; +use crate::{DecodeOptions, Decoded, Error, Format, Result, exif}; +use lightcraft_raster::resample::{Filter, fit}; +use lightcraft_raster::{Plane, Rgb32f, par_rows}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Model { + Gray, + Rgb, + /// Subtractive CMYK, 0 = no ink. + Cmyk, +} + +impl Model { + pub fn channels(self) -> usize { + match self { + Model::Gray => 1, + Model::Rgb => 3, + Model::Cmyk => 4, + } + } +} + +pub(crate) enum Buf { + U8(Vec), + U16(Vec), + F32(Vec), +} + +impl Buf { + fn len(&self) -> usize { + match self { + Buf::U8(v) => v.len(), + Buf::U16(v) => v.len(), + Buf::F32(v) => v.len(), + } + } + #[inline] + fn norm(&self, i: usize) -> f32 { + match self { + Buf::U8(v) => v[i] as f32 / 255.0, + Buf::U16(v) => v[i] as f32 / 65535.0, + Buf::F32(v) => v[i], + } + } +} + +/// Interleaved device samples as produced by a format decoder. +pub(crate) struct Raw { + pub width: usize, + pub height: usize, + pub model: Model, + /// One extra (last) channel of alpha. + pub alpha: bool, + /// Alpha is premultiplied into the colour channels. + pub premultiplied: bool, + pub buf: Buf, + pub bit_depth: u8, +} + +/// Metadata and colour hints gathered from the container. +#[derive(Default)] +pub(crate) struct Meta { + pub icc: Option>, + pub exif: Option>, + pub xmp: Option, + /// Orientation from the container itself (e.g. TIFF tag); EXIF blob is consulted otherwise. + pub orientation: Option, + /// Container-level colour description (PNG cHRM/gAMA/sRGB/cICP, JXL enum encoding, …). + pub hint: Option, + /// `image` already went through a CMS/transform producing this space (skip interpretation). + pub already_linear: Option, +} + +pub(crate) fn check_size(format: Format, w: u64, h: u64, opts: &DecodeOptions) -> Result<()> { + if w == 0 || h == 0 { + return Err(Error::Malformed(format, "zero dimension".into())); + } + if w.saturating_mul(h) > opts.max_pixels { + return Err(Error::TooLarge(w, h)); + } + Ok(()) +} + +pub(crate) fn finish(format: Format, raw: Raw, meta: Meta, source_dims: (u32, u32), opts: &DecodeOptions) -> Result { + let n = raw.width * raw.height; + let ch = raw.model.channels() + raw.alpha as usize; + if n == 0 || raw.buf.len() < n * ch { + return Err(Error::Malformed(format, "sample buffer too short".into())); + } + let float = matches!(raw.buf, Buf::F32(_)); + + // Alpha plane (straight). + let alpha = raw.alpha.then(|| { + let mut a = Plane::new(raw.width, raw.height); + for (i, o) in a.data.iter_mut().enumerate() { + *o = raw.buf.norm(i * ch + ch - 1).clamp(0.0, 1.0); + } + a + }); + + let parsed_icc = meta.icc.as_deref().and_then(icc::parse); + let (image, space) = if let Some(space) = meta.already_linear.clone() { + (to_rgb_passthrough(&raw, ch), space) + } else if raw.model == Model::Cmyk { + cmyk(&raw, ch, meta.icc.as_deref(), parsed_icc.as_ref()) + } else { + rgb_or_gray(&raw, ch, &meta, parsed_icc.as_ref(), float) + }; + + let mut image = image; + if raw.premultiplied + && let Some(a) = &alpha + { + for (p, &av) in image.data.iter_mut().zip(&a.data) { + if av > 0.0 { + *p = p.map(|c| c / av); + } + } + } + + let summary = meta.exif.as_deref().map(exif::summarize).unwrap_or_default(); + let orientation = meta.orientation.or(summary.orientation).filter(|o| (1..=8).contains(o)).unwrap_or(1); + + let (image, alpha) = match opts.max_size { + Some((mw, mh)) if (image.width as u32 > mw || image.height as u32 > mh) && mw > 0 && mh > 0 => { + let factor = (image.width as f64 / mw as f64).max(image.height as f64 / mh as f64); + let f = if factor >= 2.0 { Filter::Box } else { Filter::Mitchell }; + let img = fit(&image, mw as usize, mh as usize, f); + let alpha = alpha.map(|a| fit(&a, mw as usize, mh as usize, f)); + (img, alpha) + } + _ => (image, alpha), + }; + + Ok(Decoded { + format, + width: image.width as u32, + height: image.height as u32, + image, + has_alpha: alpha.is_some(), + alpha, + space, + bit_depth: raw.bit_depth, + float, + grayscale: raw.model == Model::Gray, + cmyk: raw.model == Model::Cmyk, + orientation, + exif: meta.exif, + xmp: meta.xmp, + icc: meta.icc, + source_width: source_dims.0, + source_height: source_dims.1, + }) +} + +fn to_rgb_passthrough(raw: &Raw, ch: usize) -> Rgb32f { + let gray = raw.model == Model::Gray; + let mut img = Rgb32f::new(raw.width, raw.height); + par_rows(&mut img.data, raw.width, |y, row| { + for (x, o) in row.iter_mut().enumerate() { + let i = (y * raw.width + x) * ch; + *o = if gray { + let v = raw.buf.norm(i); + [v, v, v] + } else { + [raw.buf.norm(i), raw.buf.norm(i + 1), raw.buf.norm(i + 2)] + }; + } + }); + img +} + +/// Per-channel linearization via LUTs (8/16-bit) or direct evaluation (float). +fn linearize(raw: &Raw, ch: usize, trc: &[Trc; 3]) -> Rgb32f { + let gray = raw.model == Model::Gray; + let mut img = Rgb32f::new(raw.width, raw.height); + let w = raw.width; + match &raw.buf { + Buf::U8(v) => { + let luts: Vec> = trc.iter().map(|t| t.lut8()).collect(); + par_rows(&mut img.data, w, |y, row| { + for (x, o) in row.iter_mut().enumerate() { + let i = (y * w + x) * ch; + *o = if gray { + let l = luts[1][v[i] as usize]; + [l, l, l] + } else { + [luts[0][v[i] as usize], luts[1][v[i + 1] as usize], luts[2][v[i + 2] as usize]] + }; + } + }); + } + Buf::U16(v) => { + let same = trc[0] == trc[1] && trc[1] == trc[2]; + let l0 = trc[1].lut16(); + let (l1, l2) = if same || gray { (None, None) } else { (Some(trc[0].lut16()), Some(trc[2].lut16())) }; + let lr = l1.as_ref().unwrap_or(&l0); + let lb = l2.as_ref().unwrap_or(&l0); + par_rows(&mut img.data, w, |y, row| { + for (x, o) in row.iter_mut().enumerate() { + let i = (y * w + x) * ch; + *o = if gray { + let l = l0[v[i] as usize]; + [l, l, l] + } else { + [lr[v[i] as usize], l0[v[i + 1] as usize], lb[v[i + 2] as usize]] + }; + } + }); + } + Buf::F32(v) => { + let lin = trc.iter().all(|t| t.is_linear()); + par_rows(&mut img.data, w, |y, row| { + for (x, o) in row.iter_mut().enumerate() { + let i = (y * w + x) * ch; + let s = |c: usize| { + let val = v[i + c]; + if val.is_finite() { val } else { 0.0 } + }; + *o = if gray { + let l = if lin { s(0) } else { trc[1].to_linear(s(0)) }; + [l, l, l] + } else if lin { + [s(0), s(1), s(2)] + } else { + [trc[0].to_linear(s(0)), trc[1].to_linear(s(1)), trc[2].to_linear(s(2))] + }; + } + }); + } + } + img +} + +fn device_samples(raw: &Raw, ch: usize, n_color: usize) -> Vec { + let n = raw.width * raw.height; + let mut out = Vec::with_capacity(n * n_color); + for p in 0..n { + for c in 0..n_color { + out.push(raw.buf.norm(p * ch + c).clamp(0.0, 1.0)); + } + } + out +} + +fn from_vec(w: usize, h: usize, data: Vec<[f32; 3]>) -> Rgb32f { + Rgb32f { width: w, height: h, data } +} + +fn rgb_or_gray(raw: &Raw, ch: usize, meta: &Meta, info: Option<&icc::IccInfo>, float: bool) -> (Rgb32f, SourceSpace) { + let gray = raw.model == Model::Gray; + if let (Some(bytes), Some(info)) = (meta.icc.as_deref(), info) { + match (&info.kind, gray) { + (IccKind::MatrixTrc { to_xyz_d50, trc }, false) => { + let space = SourceSpace { named: info.named, to_xyz_d50: *to_xyz_d50, trc: Some(trc.clone()), origin: SpaceOrigin::IccMatrixTrc }; + return (linearize(raw, ch, trc), space); + } + (IccKind::MatrixTrc { to_xyz_d50, trc }, true) => { + // Gray data with an RGB profile: neutral axis only needs the green curve. + let t = [trc[1].clone(), trc[1].clone(), trc[1].clone()]; + let space = SourceSpace { named: info.named, to_xyz_d50: *to_xyz_d50, trc: Some(t.clone()), origin: SpaceOrigin::IccMatrixTrc }; + return (linearize(raw, ch, &t), space); + } + (IccKind::Gray { trc }, true) => { + let t = [trc.clone(), trc.clone(), trc.clone()]; + let mut space = SourceSpace::named(NamedSpace::Srgb, SpaceOrigin::IccMatrixTrc); + space.trc = Some(t.clone()); + return (linearize(raw, ch, &t), space); + } + (IccKind::NeedsCms, _) => { + let wanted = if gray { IccColorModel::Gray } else { IccColorModel::Rgb }; + if info.model == wanted { + let nc = raw.model.channels(); + let samples = device_samples(raw, ch, nc); + if let Some(px) = icc::cms_to_linear_rec2020(bytes, &samples, nc) { + let mut s = SourceSpace::named(NamedSpace::Rec2020, SpaceOrigin::IccCms); + s.trc = None; + return (from_vec(raw.width, raw.height, px), s); + } + } + } + _ => {} + } + } + if meta.icc.is_some() { + // Present but unusable (malformed, mismatched model, CMS failure): assume sRGB. + let s = SourceSpace::named(NamedSpace::Srgb, SpaceOrigin::IccUnsupported); + let t = NamedSpace::Srgb.trc(); + return (linearize(raw, ch, &[t.clone(), t.clone(), t]), s); + } + if let Some(h) = &meta.hint { + let t = h.trc.clone().unwrap_or([Trc::Srgb, Trc::Srgb, Trc::Srgb]); + return (linearize(raw, ch, &t), h.clone()); + } + let adobe = meta.exif.as_deref().map(exif::summarize).is_some_and(|s| s.adobe_rgb_hint); + let mut s = if adobe { + SourceSpace::named(NamedSpace::AdobeRgb, SpaceOrigin::Container) + } else { + SourceSpace::named(NamedSpace::Srgb, SpaceOrigin::Untagged) + }; + if float { + // Untagged float data is conventionally scene-linear. + s.trc = Some([Trc::Linear, Trc::Linear, Trc::Linear]); + } + let t = s.trc.clone().unwrap_or([Trc::Srgb, Trc::Srgb, Trc::Srgb]); + (linearize(raw, ch, &t), s) +} + +fn cmyk(raw: &Raw, ch: usize, icc_bytes: Option<&[u8]>, info: Option<&icc::IccInfo>) -> (Rgb32f, SourceSpace) { + let samples = device_samples(raw, ch, 4); + if let (Some(bytes), Some(info)) = (icc_bytes, info) + && info.model == IccColorModel::Cmyk + && let Some(px) = icc::cms_to_linear_rec2020(bytes, &samples, 4) + { + let mut s = SourceSpace::named(NamedSpace::Rec2020, SpaceOrigin::IccCms); + s.trc = None; + return (from_vec(raw.width, raw.height, px), s); + } + // Naive: RGB = (1 - C)(1 - K) … treated as sRGB-encoded. + let px = samples + .chunks_exact(4) + .map(|c| { + let k = 1.0 - c[3]; + [(1.0 - c[0]) * k, (1.0 - c[1]) * k, (1.0 - c[2]) * k].map(lightcraft_color::transfer::srgb_to_linear) + }) + .collect(); + let origin = if icc_bytes.is_some() { SpaceOrigin::IccUnsupported } else { SpaceOrigin::Naive }; + (from_vec(raw.width, raw.height, px), SourceSpace::named(NamedSpace::Srgb, origin)) +} diff --git a/crates/codecs/src/encode.rs b/crates/codecs/src/encode.rs new file mode 100644 index 00000000..03127b32 --- /dev/null +++ b/crates/codecs/src/encode.rs @@ -0,0 +1,301 @@ +//! Encoders: JPEG (`jpeg-encoder`), PNG (`png`), TIFF (`tiff`), lossless WebP (`image-webp`) and, +//! on native targets with the `avif` feature, AVIF (`ravif`/`rav1e`). Metadata (ICC/EXIF/XMP) is +//! embedded where the format allows. + +use crate::{Error, Result}; +use lightcraft_raster::Rgba8; +use serde::{Deserialize, Serialize}; + +/// Interleaved sample data. Integer samples are display-encoded (0..=MAX); float samples are +/// written as-is. +#[derive(Clone, Copy, Debug)] +pub enum Samples<'a> { + U8(&'a [u8]), + U16(&'a [u16]), + F32(&'a [f32]), +} + +/// An image to encode: `channels` = 1 (gray), 2 (gray + alpha), 3 (RGB) or 4 (RGBA), straight alpha. +#[derive(Clone, Copy, Debug)] +pub struct EncodeImage<'a> { + pub width: u32, + pub height: u32, + pub channels: u8, + pub samples: Samples<'a>, +} + +impl<'a> EncodeImage<'a> { + pub fn rgba8(img: &'a Rgba8) -> Self { + EncodeImage { width: img.width as u32, height: img.height as u32, channels: 4, samples: Samples::U8(bytemuck::cast_slice(&img.data)) } + } + pub fn new(width: u32, height: u32, channels: u8, samples: Samples<'a>) -> Self { + EncodeImage { width, height, channels, samples } + } + + fn validate(&self) -> Result<()> { + if !(1..=4).contains(&self.channels) { + return Err(Error::Encode(format!("unsupported channel count {}", self.channels))); + } + if self.width == 0 || self.height == 0 { + return Err(Error::Encode("zero dimension".into())); + } + let need = self.width as u64 * self.height as u64 * self.channels as u64; + let have = match self.samples { + Samples::U8(s) => s.len(), + Samples::U16(s) => s.len(), + Samples::F32(s) => s.len(), + } as u64; + if have < need { + return Err(Error::Encode(format!("sample buffer too short: {have} < {need}"))); + } + Ok(()) + } + + fn has_alpha(&self) -> bool { + self.channels == 2 || self.channels == 4 + } +} + +/// Metadata to embed. +#[derive(Clone, Copy, Debug, Default)] +pub struct EncodeMeta<'a> { + pub icc: Option<&'a [u8]>, + /// TIFF-structured EXIF (starting at `II`/`MM`, no `Exif\0\0` prefix). + pub exif: Option<&'a [u8]>, + pub xmp: Option<&'a str>, +} + +/// JPEG chroma subsampling. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum ChromaSubsampling { + /// No subsampling (best quality). + #[default] + S444, + S422, + S420, +} + +const XMP_NS: &[u8] = b"http://ns.adobe.com/xap/1.0/\0"; + +/// Encode baseline JPEG (8-bit; alpha is dropped). `quality` 1..=100. +pub fn encode_jpeg(img: &EncodeImage, quality: u8, subsampling: ChromaSubsampling, meta: &EncodeMeta) -> Result> { + img.validate()?; + let Samples::U8(data) = img.samples else { + return Err(Error::Encode("JPEG needs 8-bit samples".into())); + }; + if img.width > u16::MAX as u32 || img.height > u16::MAX as u32 { + return Err(Error::Encode("JPEG dimensions are limited to 65535".into())); + } + let n = img.width as usize * img.height as usize; + let (data, ct): (std::borrow::Cow<[u8]>, jpeg_encoder::ColorType) = match img.channels { + 1 => (data[..n].into(), jpeg_encoder::ColorType::Luma), + 2 => (data.chunks_exact(2).take(n).map(|c| c[0]).collect::>().into(), jpeg_encoder::ColorType::Luma), + 3 => (data[..n * 3].into(), jpeg_encoder::ColorType::Rgb), + _ => (data[..n * 4].into(), jpeg_encoder::ColorType::Rgba), + }; + let mut out = Vec::new(); + let mut enc = jpeg_encoder::Encoder::new(&mut out, quality.clamp(1, 100)); + enc.set_sampling_factor(match subsampling { + ChromaSubsampling::S444 => jpeg_encoder::SamplingFactor::R_4_4_4, + ChromaSubsampling::S422 => jpeg_encoder::SamplingFactor::R_4_2_2, + ChromaSubsampling::S420 => jpeg_encoder::SamplingFactor::R_4_2_0, + }); + // Standard tables: jpeg-encoder's optimized-table mode writes non-interleaved scans, which some + // decoders (incl. zune-jpeg 0.5) mishandle. Interleaved baseline is universally supported. + let e = |e: jpeg_encoder::EncodingError| Error::Encode(e.to_string()); + if let Some(exif) = meta.exif { + if exif.len() > 65_527 { + return Err(Error::Encode("EXIF larger than one APP1 segment (64 KiB)".into())); + } + enc.add_exif_metadata(exif).map_err(e)?; + } + if let Some(xmp) = meta.xmp { + let mut seg = XMP_NS.to_vec(); + seg.extend_from_slice(xmp.as_bytes()); + if seg.len() > 65_533 { + return Err(Error::Encode("XMP larger than one APP1 segment (extended XMP not supported)".into())); + } + enc.add_app_segment(1, seg).map_err(e)?; + } + if let Some(icc) = meta.icc { + enc.add_icc_profile(icc).map_err(e)?; + } + enc.encode(&data, img.width as u16, img.height as u16, ct).map_err(e)?; + Ok(out) +} + +/// Encode PNG (8- or 16-bit; float input is rejected — quantize first). +pub fn encode_png(img: &EncodeImage, meta: &EncodeMeta) -> Result> { + img.validate()?; + let n = img.width as usize * img.height as usize * img.channels as usize; + let (bytes, depth): (Vec, png::BitDepth) = match img.samples { + Samples::U8(s) => (s[..n].to_vec(), png::BitDepth::Eight), + Samples::U16(s) => (s[..n].iter().flat_map(|v| v.to_be_bytes()).collect(), png::BitDepth::Sixteen), + Samples::F32(_) => return Err(Error::Encode("PNG needs 8- or 16-bit samples".into())), + }; + let mut info = png::Info::with_size(img.width, img.height); + info.color_type = match img.channels { + 1 => png::ColorType::Grayscale, + 2 => png::ColorType::GrayscaleAlpha, + 3 => png::ColorType::Rgb, + _ => png::ColorType::Rgba, + }; + info.bit_depth = depth; + info.icc_profile = meta.icc.map(|b| b.to_vec().into()); + info.exif_metadata = meta.exif.map(|b| b.to_vec().into()); + let mut out = Vec::new(); + let e = |e: png::EncodingError| Error::Encode(e.to_string()); + { + let mut enc = png::Encoder::with_info(&mut out, info).map_err(e)?; + enc.set_compression(png::Compression::Balanced); + if let Some(xmp) = meta.xmp { + enc.add_itxt_chunk("XML:com.adobe.xmp".into(), xmp.into()).map_err(e)?; + } + let mut w = enc.write_header().map_err(e)?; + w.write_image_data(&bytes).map_err(e)?; + w.finish().map_err(e)?; + } + Ok(out) +} + +/// TIFF compression. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum TiffCompression { + None, + Lzw, + #[default] + Deflate, + PackBits, +} + +/// Encode TIFF: 8/16-bit integer or 32-bit float, gray/RGB with optional (unassociated) alpha. +/// ICC and XMP are embedded; EXIF is not (write it through the TIFF writer in `lightcraft-tiff`). +pub fn encode_tiff(img: &EncodeImage, compression: TiffCompression, meta: &EncodeMeta) -> Result> { + use tiff::encoder::{Compression, DeflateLevel, TiffEncoder, colortype}; + use tiff::tags::Predictor; + img.validate()?; + let e = |e: tiff::TiffError| Error::Encode(e.to_string()); + let mut cur = std::io::Cursor::new(Vec::new()); + let comp = match compression { + TiffCompression::None => Compression::Uncompressed, + TiffCompression::Lzw => Compression::Lzw, + TiffCompression::Deflate => Compression::Deflate(DeflateLevel::Balanced), + TiffCompression::PackBits => Compression::Packbits, + }; + let float = matches!(img.samples, Samples::F32(_)); + let predictor = + if !float && matches!(compression, TiffCompression::Lzw | TiffCompression::Deflate) { Predictor::Horizontal } else { Predictor::None }; + let mut enc = TiffEncoder::new(&mut cur).map_err(e)?.with_compression(comp).with_predictor(predictor); + let (w, h) = (img.width, img.height); + let n = w as usize * h as usize; + // Gray + alpha is stored as RGBA (the encoder has no GrayA colour type). + let ch = img.channels as usize; + + macro_rules! write { + ($ct:ty, $data:expr, $alpha:expr) => {{ + let mut im = enc.new_image::<$ct>(w, h).map_err(e)?; + if let Some(icc) = meta.icc { + im.encoder().write_tag(tiff::tags::Tag::IccProfile, icc).map_err(e)?; + } + if let Some(xmp) = meta.xmp { + im.encoder().write_tag(tiff::tags::Tag::from_u16_exhaustive(700), xmp.as_bytes()).map_err(e)?; + } + if $alpha { + im.encoder().write_tag(tiff::tags::Tag::ExtraSamples, &[2u16][..]).map_err(e)?; + } + im.write_data($data).map_err(e)?; + }}; + } + fn expand_ga(s: &[T], n: usize) -> Vec { + s.chunks_exact(2).take(n).flat_map(|c| [c[0], c[0], c[0], c[1]]).collect() + } + match img.samples { + Samples::U8(s) => match ch { + 1 => write!(colortype::Gray8, &s[..n], false), + 2 => write!(colortype::RGBA8, &expand_ga(s, n), true), + 3 => write!(colortype::RGB8, &s[..n * 3], false), + _ => write!(colortype::RGBA8, &s[..n * 4], true), + }, + Samples::U16(s) => match ch { + 1 => write!(colortype::Gray16, &s[..n], false), + 2 => write!(colortype::RGBA16, &expand_ga(s, n), true), + 3 => write!(colortype::RGB16, &s[..n * 3], false), + _ => write!(colortype::RGBA16, &s[..n * 4], true), + }, + Samples::F32(s) => match ch { + 1 => write!(colortype::Gray32Float, &s[..n], false), + 2 => write!(colortype::RGBA32Float, &expand_ga(s, n), true), + 3 => write!(colortype::RGB32Float, &s[..n * 3], false), + _ => write!(colortype::RGBA32Float, &s[..n * 4], true), + }, + } + Ok(cur.into_inner()) +} + +/// Encode lossless WebP (8-bit). ICC/EXIF/XMP are embedded in the extended (VP8X) container. +pub fn encode_webp_lossless(img: &EncodeImage, meta: &EncodeMeta) -> Result> { + img.validate()?; + let Samples::U8(s) = img.samples else { + return Err(Error::Encode("WebP needs 8-bit samples".into())); + }; + let n = img.width as usize * img.height as usize * img.channels as usize; + let ct = match img.channels { + 1 => image_webp::ColorType::L8, + 2 => image_webp::ColorType::La8, + 3 => image_webp::ColorType::Rgb8, + _ => image_webp::ColorType::Rgba8, + }; + let mut out = Vec::new(); + let mut enc = image_webp::WebPEncoder::new(&mut out); + if let Some(icc) = meta.icc { + enc.set_icc_profile(icc.to_vec()); + } + if let Some(exif) = meta.exif { + enc.set_exif_metadata(exif.to_vec()); + } + if let Some(xmp) = meta.xmp { + enc.set_xmp_metadata(xmp.as_bytes().to_vec()); + } + enc.encode(&s[..n], img.width, img.height, ct).map_err(|e| Error::Encode(e.to_string()))?; + Ok(out) +} + +/// Encode AVIF (8-bit sRGB, 4:4:4) with `ravif` (rav1e). `quality` 1..=100, `speed` 1 (slow) ..= 10. +/// EXIF is embedded; ICC is not supported by the muxer (the file is tagged sRGB via `nclx`). +/// Returns [`Error::Encode`] on wasm32 or when built without the `avif` feature. +pub fn encode_avif(img: &EncodeImage, quality: u8, speed: u8, meta: &EncodeMeta) -> Result> { + img.validate()?; + #[cfg(all(feature = "avif", not(target_arch = "wasm32")))] + { + let Samples::U8(s) = img.samples else { + return Err(Error::Encode("AVIF encoder needs 8-bit samples".into())); + }; + let n = img.width as usize * img.height as usize; + let ch = img.channels as usize; + let px: Vec = s + .chunks_exact(ch) + .take(n) + .map(|c| match ch { + 1 => ravif::RGBA8::new(c[0], c[0], c[0], 255), + 2 => ravif::RGBA8::new(c[0], c[0], c[0], c[1]), + 3 => ravif::RGBA8::new(c[0], c[1], c[2], 255), + _ => ravif::RGBA8::new(c[0], c[1], c[2], c[3]), + }) + .collect(); + let mut enc = ravif::Encoder::new() + .with_quality(quality.clamp(1, 100) as f32) + .with_alpha_quality(quality.clamp(1, 100) as f32) + .with_speed(speed.clamp(1, 10)); + if let Some(exif) = meta.exif { + enc = enc.with_exif(exif.to_vec()); + } + let r = enc.encode_rgba(ravif::Img::new(&px[..], img.width as usize, img.height as usize)).map_err(|e| Error::Encode(e.to_string()))?; + let _ = img.has_alpha(); + Ok(r.avif_file) + } + #[cfg(not(all(feature = "avif", not(target_arch = "wasm32"))))] + { + let _ = (quality, speed, meta, img.has_alpha()); + Err(Error::Encode("AVIF encoding is not available in this build".into())) + } +} diff --git a/crates/codecs/src/exif.rs b/crates/codecs/src/exif.rs new file mode 100644 index 00000000..2e1d2998 --- /dev/null +++ b/crates/codecs/src/exif.rs @@ -0,0 +1,166 @@ +//! Minimal, bounds-checked TIFF/EXIF walking: just what decoding needs (orientation, embedded +//! thumbnail, colour-space hint). Full EXIF parsing lives in `lightcraft-meta`. + +/// Byte-order aware reader over a TIFF-structured blob. +#[derive(Clone, Copy)] +pub(crate) struct Tiff<'a> { + pub data: &'a [u8], + le: bool, +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct Entry { + pub tag: u16, + pub typ: u16, + pub count: u32, + /// Offset of the 4-byte value/offset field within `data`. + pub value_pos: usize, +} + +impl<'a> Tiff<'a> { + pub fn new(data: &'a [u8]) -> Option> { + let le = match data.get(0..2)? { + b"II" => true, + b"MM" => false, + _ => return None, + }; + Some(Tiff { data, le }) + } + pub fn u16_at(&self, pos: usize) -> Option { + let b = self.data.get(pos..pos.checked_add(2)?)?; + Some(if self.le { u16::from_le_bytes([b[0], b[1]]) } else { u16::from_be_bytes([b[0], b[1]]) }) + } + pub fn u32_at(&self, pos: usize) -> Option { + let b = self.data.get(pos..pos.checked_add(4)?)?; + let a = [b[0], b[1], b[2], b[3]]; + Some(if self.le { u32::from_le_bytes(a) } else { u32::from_be_bytes(a) }) + } + pub fn first_ifd(&self) -> Option { + let magic = self.u16_at(2)?; + if magic != 42 { + return None; + } + Some(self.u32_at(4)? as usize) + } + /// Entries of the IFD at `pos` and the offset of the next IFD (0 = none). + pub fn ifd(&self, pos: usize) -> Option<(Vec, usize)> { + let n = self.u16_at(pos)? as usize; + if n > 4096 { + return None; + } + let mut v = Vec::with_capacity(n); + for i in 0..n { + let e = pos + 2 + i * 12; + v.push(Entry { tag: self.u16_at(e)?, typ: self.u16_at(e + 2)?, count: self.u32_at(e + 4)?, value_pos: e + 8 }); + } + let next = self.u32_at(pos + 2 + n * 12).unwrap_or(0) as usize; + Some((v, next)) + } + /// First value of a SHORT/LONG entry. + pub fn uint(&self, e: &Entry) -> Option { + match e.typ { + 3 => self.u16_at(e.value_pos).map(u32::from), + 4 | 13 => self.u32_at(e.value_pos), + 1 | 7 => self.data.get(e.value_pos).map(|b| *b as u32), + _ => None, + } + } + /// Raw bytes of an entry's value (for ASCII/UNDEFINED/BYTE types). + pub fn bytes(&self, e: &Entry) -> Option<&'a [u8]> { + let size = match e.typ { + 1 | 2 | 6 | 7 => 1usize, + 3 | 8 => 2, + 4 | 9 | 11 | 13 => 4, + 5 | 10 | 12 => 8, + _ => return None, + }; + let len = size.checked_mul(e.count as usize)?; + let start = if len <= 4 { e.value_pos } else { self.u32_at(e.value_pos)? as usize }; + self.data.get(start..start.checked_add(len)?) + } +} + +/// What the decoder cares about from an EXIF (TIFF-structured) blob. +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct ExifSummary { + pub orientation: Option, + /// (offset, length) of the IFD1 JPEG thumbnail within the blob. + pub thumbnail: Option<(usize, usize)>, + /// EXIF ColorSpace = 0xFFFF with InteropIndex "R03" (Adobe RGB (1998) "option" files). + pub adobe_rgb_hint: bool, +} + +pub(crate) fn summarize(blob: &[u8]) -> ExifSummary { + let mut s = ExifSummary::default(); + let Some(t) = Tiff::new(blob) else { return s }; + let Some(ifd0) = t.first_ifd() else { return s }; + let Some((entries, next)) = t.ifd(ifd0) else { return s }; + let mut exif_ifd = None; + for e in &entries { + match e.tag { + 0x0112 => s.orientation = t.uint(e).map(|v| v as u16).filter(|v| (1..=8).contains(v)), + 0x8769 => exif_ifd = t.uint(e).map(|v| v as usize), + _ => {} + } + } + if next != 0 + && next != ifd0 + && let Some((e1, _)) = t.ifd(next) + { + let off = e1.iter().find(|e| e.tag == 0x0201).and_then(|e| t.uint(e)); + let len = e1.iter().find(|e| e.tag == 0x0202).and_then(|e| t.uint(e)); + if let (Some(o), Some(l)) = (off, len) { + let (o, l) = (o as usize, l as usize); + if l > 4 && o.checked_add(l).is_some_and(|end| end <= blob.len()) && blob[o..o + 2] == [0xFF, 0xD8] { + s.thumbnail = Some((o, l)); + } + } + } + if let Some(p) = exif_ifd + && let Some((ee, _)) = t.ifd(p) + { + let cs = ee.iter().find(|e| e.tag == 0xA001).and_then(|e| t.uint(e)); + let interop = ee.iter().find(|e| e.tag == 0xA005).and_then(|e| t.uint(e)); + if cs == Some(0xFFFF) + && let Some((ie, _)) = interop.and_then(|p| t.ifd(p as usize)) + { + s.adobe_rgb_hint = ie.iter().any(|e| e.tag == 1 && t.bytes(e).is_some_and(|b| b.starts_with(b"R03"))); + } + } + s +} + +/// Build a tiny EXIF blob (little-endian TIFF) with just an orientation tag. Used by tests and by +/// callers that need to stamp orientation on export when no source EXIF exists. +pub fn minimal_exif(orientation: u16) -> Vec { + let mut v = Vec::with_capacity(26); + v.extend_from_slice(b"II"); + v.extend_from_slice(&42u16.to_le_bytes()); + v.extend_from_slice(&8u32.to_le_bytes()); + v.extend_from_slice(&1u16.to_le_bytes()); + v.extend_from_slice(&0x0112u16.to_le_bytes()); + v.extend_from_slice(&3u16.to_le_bytes()); + v.extend_from_slice(&1u32.to_le_bytes()); + v.extend_from_slice(&orientation.to_le_bytes()); + v.extend_from_slice(&[0, 0]); + v.extend_from_slice(&0u32.to_le_bytes()); + v +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn orientation_roundtrip() { + for o in 1..=8 { + assert_eq!(summarize(&minimal_exif(o)).orientation, Some(o)); + } + } + + #[test] + fn garbage_ok() { + assert_eq!(summarize(b"II*\0\xff\xff\xff\xff"), ExifSummary::default()); + assert_eq!(summarize(&[]), ExifSummary::default()); + } +} diff --git a/crates/codecs/src/icc.rs b/crates/codecs/src/icc.rs new file mode 100644 index 00000000..c6ccff7c --- /dev/null +++ b/crates/codecs/src/icc.rs @@ -0,0 +1,252 @@ +//! ICC profiles: parsing (via `moxcms`), matrix/TRC extraction, CMS fallback for LUT/CMYK profiles, +//! and writing matrix/TRC profiles for export embedding. + +use crate::space::{NamedSpace, Trc, rgb_to_xyz_d50}; +use lightcraft_color::{D50, Mat3, RgbSpace, bradford}; +use moxcms::{ + ColorProfile, DataColorSpace, Layout, LocalizableString, Matrix3d, ProfileText, RenderingIntent, ToneReprCurve, TransformOptions, Xyzd, +}; +use serde::{Deserialize, Serialize}; + +/// Colour model of the profile's device space. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum IccColorModel { + Rgb, + Gray, + Cmyk, + Other, +} + +/// How a profile can be applied. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum IccKind { + /// RGB matrix/TRC (shaper) profile: exact, applied by us. + MatrixTrc { to_xyz_d50: Mat3, trc: [Trc; 3] }, + /// Grayscale profile with a `kTRC`. + Gray { trc: Trc }, + /// LUT-based (A2B), CMYK or otherwise needs a full CMS conversion. + NeedsCms, +} + +/// Summary of a parsed ICC profile. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct IccInfo { + pub model: IccColorModel, + pub kind: IccKind, + /// Recognised well-known RGB space (by colorants), if any. + pub named: Option, + pub description: Option, +} + +/// Parse an ICC profile. Returns `None` for malformed or unsupported data (never panics). +pub fn parse(bytes: &[u8]) -> Option { + let p = std::panic::catch_unwind(|| ColorProfile::new_from_slice(bytes)).ok()?.ok()?; + Some(info_of(&p)) +} + +fn info_of(p: &ColorProfile) -> IccInfo { + let description = p.description.as_ref().and_then(text_of); + let model = match p.color_space { + DataColorSpace::Rgb => IccColorModel::Rgb, + DataColorSpace::Gray => IccColorModel::Gray, + DataColorSpace::Cmyk => IccColorModel::Cmyk, + _ => IccColorModel::Other, + }; + let has_lut = p.lut_a_to_b_perceptual.is_some() || p.lut_a_to_b_colorimetric.is_some(); + let kind = if model == IccColorModel::Rgb && p.is_matrix_shaper() && p.pcs == DataColorSpace::Xyz { + let c = [p.red_colorant, p.green_colorant, p.blue_colorant]; + let to_xyz_d50 = Mat3([[c[0].x, c[1].x, c[2].x], [c[0].y, c[1].y, c[2].y], [c[0].z, c[1].z, c[2].z]]); + let trcs = [&p.red_trc, &p.green_trc, &p.blue_trc]; + let trc = trcs.map(|t| t.as_ref().map(trc_from_icc).unwrap_or(Trc::Srgb)); + if to_xyz_d50.determinant().abs() > 1e-6 && trc.iter().all(valid_trc) { IccKind::MatrixTrc { to_xyz_d50, trc } } else { IccKind::NeedsCms } + } else if model == IccColorModel::Gray && p.gray_trc.is_some() && !has_lut { + let trc = p.gray_trc.as_ref().map(trc_from_icc).unwrap_or(Trc::Srgb); + if valid_trc(&trc) { IccKind::Gray { trc } } else { IccKind::NeedsCms } + } else { + IccKind::NeedsCms + }; + let named = match &kind { + IccKind::MatrixTrc { to_xyz_d50, .. } => NamedSpace::recognize(to_xyz_d50), + _ => None, + }; + IccInfo { model, kind, named, description } +} + +fn valid_trc(t: &Trc) -> bool { + match t { + Trc::Parametric(p) => matches!(p.len(), 1 | 3 | 4 | 5 | 7) && p.iter().all(|v| v.is_finite()), + Trc::Gamma(g) => g.is_finite() && *g > 0.0, + _ => true, + } +} + +fn text_of(t: &ProfileText) -> Option { + let s = match t { + ProfileText::PlainString(s) => s.clone(), + ProfileText::Localizable(v) => v.first().map(|l| l.value.clone())?, + ProfileText::Description(d) => { + if d.unicode_string.is_empty() { + d.ascii_string.clone() + } else { + d.unicode_string.clone() + } + } + }; + let s = s.trim_end_matches('\0').trim().to_string(); + if s.is_empty() { None } else { Some(s) } +} + +fn trc_from_icc(t: &ToneReprCurve) -> Trc { + match t { + ToneReprCurve::Lut(v) if v.is_empty() => Trc::Linear, + ToneReprCurve::Lut(v) if v.len() == 1 => Trc::Gamma(v[0] as f32 / 256.0), + ToneReprCurve::Lut(v) => Trc::Table(v.clone()), + ToneReprCurve::Parametric(p) => Trc::Parametric(p.clone()), + } +} + +fn trc_to_icc(t: &Trc) -> ToneReprCurve { + match t { + Trc::Linear => ToneReprCurve::Parametric(vec![1.0]), + Trc::Srgb => ToneReprCurve::Parametric(vec![2.4, 1.0 / 1.055, 0.055 / 1.055, 1.0 / 12.92, 0.04045]), + Trc::Gamma(g) => ToneReprCurve::Parametric(vec![*g]), + Trc::Rec709 => ToneReprCurve::Parametric(vec![1.0 / 0.45, 1.0 / 1.099, 0.099 / 1.099, 1.0 / 4.5, 0.081]), + Trc::Parametric(p) => ToneReprCurve::Parametric(p.clone()), + Trc::Table(v) => ToneReprCurve::Lut(v.clone()), + } +} + +fn xyzd(v: [f64; 3]) -> Xyzd { + Xyzd { x: v[0], y: v[1], z: v[2] } +} + +/// ICC PCS illuminant (D50) as stored in profiles. +const ICC_D50: [f64; 3] = [0.9642, 1.0, 0.8249]; + +/// Build an ICC v4 display-class matrix/TRC profile for `space` with the given encoding curve +/// (D50 PCS, Bradford `chad`), for embedding in exported files. +pub fn write_matrix_trc(space: &RgbSpace, trc: &Trc) -> Vec { + let m = rgb_to_xyz_d50(space).0; + let mut p = ColorProfile::new_srgb(); + p.cicp = None; + p.red_colorant = xyzd([m[0][0], m[1][0], m[2][0]]); + p.green_colorant = xyzd([m[0][1], m[1][1], m[2][1]]); + p.blue_colorant = xyzd([m[0][2], m[1][2], m[2][2]]); + let curve = trc_to_icc(trc); + p.red_trc = Some(curve.clone()); + p.green_trc = Some(curve.clone()); + p.blue_trc = Some(curve); + p.white_point = xyzd(ICC_D50); + p.media_white_point = Some(xyzd(ICC_D50)); + p.chromatic_adaptation = Some(Matrix3d { v: bradford(space.white, D50).0 }); + p.rendering_intent = RenderingIntent::Perceptual; + let desc = match trc { + t if t.is_linear() => format!("{} (linear)", space.name), + _ => space.name.to_string(), + }; + p.description = Some(ProfileText::Localizable(vec![LocalizableString::new("en".into(), "US".into(), desc)])); + p.copyright = Some(ProfileText::Localizable(vec![LocalizableString::new("en".into(), "US".into(), "No copyright, use freely".into())])); + p.encode().unwrap_or_default() +} + +/// A profile for one of the well-known spaces with its standard curve. +pub fn write_named(n: NamedSpace) -> Vec { + write_matrix_trc(&n.rgb_space(), &n.trc()) +} + +fn linear_rec2020_profile() -> ColorProfile { + let mut p = ColorProfile::new_bt2020(); + p.cicp = None; + let lin = ToneReprCurve::Parametric(vec![1.0]); + p.red_trc = Some(lin.clone()); + p.green_trc = Some(lin.clone()); + p.blue_trc = Some(lin); + p +} + +/// Convert device samples (0..1, interleaved, `channels` = 1, 3 or 4 matching the profile's colour +/// model) to linear Rec.2020 through a full CMS transform. `None` if the profile cannot be applied. +pub(crate) fn cms_to_linear_rec2020(icc: &[u8], samples: &[f32], channels: usize) -> Option> { + let src = ColorProfile::new_from_slice(icc).ok()?; + let layout = match (src.color_space, channels) { + (DataColorSpace::Rgb, 3) => Layout::Rgb, + (DataColorSpace::Gray, 1) => Layout::Gray, + (DataColorSpace::Cmyk, 4) => Layout::Rgba, + _ => return None, + }; + let dst = linear_rec2020_profile(); + let opts = TransformOptions { rendering_intent: RenderingIntent::RelativeColorimetric, ..TransformOptions::default() }; + let run = || -> Option> { + let t = src.create_transform_f32(layout, &dst, Layout::Rgb, opts).ok()?; + let n = samples.len() / channels; + let mut out = vec![0f32; n * 3]; + // Chunk to bound temporary memory and allow parallelism. + const ROWS: usize = 1 << 16; + let src_chunks = samples.chunks(ROWS * channels); + let dst_chunks = out.chunks_mut(ROWS * 3); + #[cfg(feature = "parallel")] + { + use rayon::prelude::*; + let pairs: Vec<_> = src_chunks.zip(dst_chunks).collect(); + let ok = pairs.into_par_iter().all(|(s, d)| t.transform(s, d).is_ok()); + if !ok { + return None; + } + } + #[cfg(not(feature = "parallel"))] + for (s, d) in src_chunks.zip(dst_chunks) { + t.transform(s, d).ok()?; + } + Some(out.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect()) + }; + std::panic::catch_unwind(run).ok().flatten() +} + +#[cfg(test)] +mod tests { + use super::*; + use lightcraft_color::{DISPLAY_P3, PROPHOTO, SRGB}; + + #[test] + fn write_then_parse_recognises() { + for n in NamedSpace::ALL { + let bytes = write_named(n); + assert!(bytes.len() > 128, "{n:?}"); + let info = parse(&bytes).expect("parses"); + assert_eq!(info.named, Some(n), "{n:?}"); + match info.kind { + IccKind::MatrixTrc { trc, .. } => assert!(trc[0].approx_eq(&n.trc(), 2e-4), "{n:?} {:?}", trc[0]), + k => panic!("{k:?}"), + } + } + } + + #[test] + fn linear_profile() { + let bytes = write_matrix_trc(&PROPHOTO, &Trc::Linear); + let info = parse(&bytes).unwrap(); + assert_eq!(info.named, Some(NamedSpace::ProPhoto)); + let IccKind::MatrixTrc { trc, .. } = info.kind else { panic!() }; + assert!(trc[1].is_linear()); + } + + #[test] + fn cms_path_matches_matrix_path() { + let bytes = write_named(NamedSpace::DisplayP3); + let px = [0.2f32, 0.5, 0.8]; + let out = cms_to_linear_rec2020(&bytes, &px, 3).unwrap()[0]; + let lin = px.map(|v| Trc::Srgb.to_linear(v)); + let m = DISPLAY_P3.to_space(&lightcraft_color::REC2020); + let want = m.apply_f32(lin); + for i in 0..3 { + assert!((out[i] - want[i]).abs() < 2e-3, "{out:?} vs {want:?}"); + } + let _ = SRGB; + } + + #[test] + fn garbage_is_none() { + assert!(parse(&[]).is_none()); + assert!(parse(&[0u8; 200]).is_none()); + } +} diff --git a/crates/codecs/src/jpeg.rs b/crates/codecs/src/jpeg.rs new file mode 100644 index 00000000..1e2595e7 --- /dev/null +++ b/crates/codecs/src/jpeg.rs @@ -0,0 +1,256 @@ +//! JPEG: marker parsing (EXIF/XMP/ICC/Adobe/MPF), full decode via `zune-jpeg`, DCT-scaled and +//! CMYK/YCCK decode via `jpeg-decoder`, embedded-preview thumbnails. + +use crate::convert::{Buf, Meta, Model, Raw, check_size, finish}; +use crate::{DecodeOptions, Decoded, Error, Format, Result, Thumbnail, ThumbnailSource, exif}; + +const F: Format = Format::Jpeg; + +/// What we learn from walking the marker segments before the first scan. +#[derive(Default, Debug)] +pub(crate) struct Markers { + pub width: u32, + pub height: u32, + pub components: u8, + pub precision: u8, + /// SOFn marker byte (0xC0 baseline, 0xC1 extended, 0xC2 progressive, …). + pub sof: u8, + /// Number of components in the first scan (< `components` for non-interleaved sequential files). + pub first_scan_components: u8, + pub exif: Option>, + /// Offset of the EXIF TIFF header within the file. + pub exif_offset: usize, + pub xmp: Option, + pub icc: Option>, + pub adobe_transform: Option, + /// Offset of the MPF TIFF header within the file and its bytes. + pub mpf: Option<(usize, Vec)>, +} + +pub(crate) fn parse_markers(b: &[u8]) -> Option { + if !b.starts_with(&[0xFF, 0xD8]) { + return None; + } + let mut m = Markers::default(); + let mut icc_chunks: Vec<(u8, &[u8])> = Vec::new(); + let mut p = 2; + while p + 4 <= b.len() { + if b[p] != 0xFF { + // Tolerate garbage between segments: resync on the next 0xFF. + p += 1; + continue; + } + let marker = b[p + 1]; + if marker == 0xFF { + p += 1; + continue; + } + if marker == 0xD8 || (0xD0..=0xD7).contains(&marker) || marker == 0x01 { + p += 2; + continue; + } + if marker == 0xD9 { + break; + } + if marker == 0xDA { + m.first_scan_components = b.get(p + 4).copied().unwrap_or(0); + break; + } + let len = u16::from_be_bytes([b[p + 2], b[p + 3]]) as usize; + if len < 2 { + return None; + } + let start = p + 4; + let end = (p + 2 + len).min(b.len()); + let seg = &b[start..end]; + match marker { + 0xC0..=0xCF if marker != 0xC4 && marker != 0xC8 && marker != 0xCC => { + if seg.len() >= 6 && m.components == 0 { + m.precision = seg[0]; + m.sof = marker; + m.height = u16::from_be_bytes([seg[1], seg[2]]) as u32; + m.width = u16::from_be_bytes([seg[3], seg[4]]) as u32; + m.components = seg[5]; + } + } + 0xE1 => { + if seg.starts_with(b"Exif\0\0") && m.exif.is_none() { + m.exif = Some(seg[6..].to_vec()); + m.exif_offset = start + 6; + } else if let Some(x) = seg.strip_prefix(b"http://ns.adobe.com/xap/1.0/\0") + && m.xmp.is_none() + { + m.xmp = Some(String::from_utf8_lossy(x).trim_end_matches('\0').to_string()); + } + } + 0xE2 => { + if seg.starts_with(b"ICC_PROFILE\0") && seg.len() >= 14 { + icc_chunks.push((seg[12], &seg[14..])); + } else if seg.starts_with(b"MPF\0") && m.mpf.is_none() { + m.mpf = Some((start + 4, seg[4..].to_vec())); + } + } + 0xEE if seg.starts_with(b"Adobe") && seg.len() >= 12 => { + m.adobe_transform = Some(seg[11]); + } + _ => {} + } + p += 2 + len; + } + if !icc_chunks.is_empty() { + icc_chunks.sort_by_key(|c| c.0); + m.icc = Some(icc_chunks.iter().flat_map(|c| c.1.iter().copied()).collect()); + } + Some(m) +} + +pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { + let m = parse_markers(bytes).ok_or_else(|| Error::Malformed(F, "missing SOI".into()))?; + if m.components == 0 { + return Err(Error::Malformed(F, "no frame header".into())); + } + check_size(F, m.width as u64, m.height as u64, opts)?; + let scale_to = opts.max_size.filter(|&(mw, mh)| mw > 0 && mh > 0 && (m.width >= 2 * mw || m.height >= 2 * mh)); + // zune-jpeg handles the common cases fastest; jpeg-decoder covers DCT scaling, CMYK/YCCK, + // 12-bit and non-interleaved sequential scans (which zune-jpeg 0.5 mis-decodes with subsampling). + let non_interleaved = matches!(m.sof, 0xC0 | 0xC1) && m.first_scan_components < m.components; + let raw = if m.components == 4 || scale_to.is_some() || m.precision > 8 || non_interleaved { + decode_jpeg_decoder(bytes, &m, scale_to)? + } else { + decode_zune(bytes, &m)? + }; + let meta = Meta { icc: m.icc, exif: m.exif, xmp: m.xmp, ..Default::default() }; + finish(F, raw, meta, (m.width, m.height), opts) +} + +fn decode_zune(bytes: &[u8], m: &Markers) -> Result { + use zune_core::bytestream::ZCursor; + use zune_core::colorspace::ColorSpace; + use zune_core::options::DecoderOptions; + let gray = m.components == 1; + let opts = DecoderOptions::default().set_max_width(1 << 16).set_max_height(1 << 16).set_strict_mode(false).jpeg_set_out_colorspace(if gray { + ColorSpace::Luma + } else { + ColorSpace::RGB + }); + let mut d = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(bytes), opts); + let px = d.decode().map_err(|e| Error::Malformed(F, e.to_string()))?; + let info = d.info().ok_or_else(|| Error::Malformed(F, "no info".into()))?; + let (w, h) = (info.width as usize, info.height as usize); + let model = if gray { Model::Gray } else { Model::Rgb }; + if px.len() < w * h * model.channels() { + return Err(Error::Malformed(F, "short output".into())); + } + Ok(Raw { width: w, height: h, model, alpha: false, premultiplied: false, buf: Buf::U8(px), bit_depth: 8 }) +} + +fn decode_jpeg_decoder(bytes: &[u8], m: &Markers, scale_to: Option<(u32, u32)>) -> Result { + use jpeg_decoder::PixelFormat; + let mut d = jpeg_decoder::Decoder::new(bytes); + d.read_info().map_err(|e| Error::Malformed(F, e.to_string()))?; + if let Some((mw, mh)) = scale_to { + // Ask for the smallest DCT scale still covering the target box. + let (sw, sh) = scaled_request(m.width, m.height, mw, mh); + d.scale(sw, sh).map_err(|e| Error::Malformed(F, e.to_string()))?; + } + let px = d.decode().map_err(|e| Error::Malformed(F, e.to_string()))?; + let info = d.info().ok_or_else(|| Error::Malformed(F, "no info".into()))?; + let (w, h) = (info.width as usize, info.height as usize); + let (model, buf, depth) = match info.pixel_format { + PixelFormat::L8 => (Model::Gray, Buf::U8(px), 8), + PixelFormat::L16 => { + let v = px.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); + (Model::Gray, Buf::U16(v), 16) + } + PixelFormat::RGB24 => (Model::Rgb, Buf::U8(px), 8), + PixelFormat::CMYK32 => (Model::Cmyk, Buf::U8(px), 8), + }; + Ok(Raw { width: w, height: h, model, alpha: false, premultiplied: false, buf, bit_depth: depth }) +} + +/// The box to request from `jpeg-decoder::scale` so the decoded image still covers `mw × mh` once +/// fitted with the source aspect ratio. +fn scaled_request(w: u32, h: u32, mw: u32, mh: u32) -> (u16, u16) { + let s = (mw as f64 / w as f64).min(mh as f64 / h as f64).min(1.0); + let tw = ((w as f64 * s).ceil() as u32).clamp(1, u16::MAX as u32); + let th = ((h as f64 * s).ceil() as u32).clamp(1, u16::MAX as u32); + (tw as u16, th as u16) +} + +/// Fast path: an embedded JPEG preview (EXIF IFD1 thumbnail, or the largest MPF preview) whose long +/// edge is ≥ `max_edge`, decoded with DCT scaling and fitted. +pub(crate) fn embedded_thumbnail(bytes: &[u8], max_edge: u32) -> Option { + let m = parse_markers(bytes)?; + let summary = m.exif.as_deref().map(exif::summarize).unwrap_or_default(); + let orientation = summary.orientation.unwrap_or(1); + let mut candidates: Vec<(&[u8], ThumbnailSource)> = Vec::new(); + if let (Some(ex), Some((o, l))) = (m.exif.as_deref(), summary.thumbnail) { + candidates.push((&ex[o..o + l], ThumbnailSource::ExifThumbnail)); + } + for (o, l) in mpf_images(&m) { + if let Some(s) = bytes.get(o..o.saturating_add(l)) { + candidates.push((s, ThumbnailSource::MpfPreview)); + } + } + // Smallest adequate preview wins. + let mut best: Option<(u32, &[u8], ThumbnailSource)> = None; + for (data, src) in candidates { + let Some(pm) = parse_markers(data) else { continue }; + let long = pm.width.max(pm.height); + // Previews must have the main image's aspect ratio (within 2%), else they are letterboxed/cropped. + let aspect_ok = m.width > 0 + && m.height > 0 + && pm.height > 0 + && ((pm.width as f64 / pm.height as f64) / (m.width as f64 / m.height as f64) - 1.0).abs() < 0.02; + if long >= max_edge && aspect_ok && best.as_ref().is_none_or(|b| long < b.0) { + best = Some((long, data, src)); + } + } + let (_, data, source) = best?; + let d = decode(data, &DecodeOptions::fit(max_edge, max_edge)).ok()?; + Some(Thumbnail { image: d.to_srgb8(), orientation, source, source_width: m.width, source_height: m.height }) +} + +/// (file offset, length) of MPF-listed images other than the primary. +fn mpf_images(m: &Markers) -> Vec<(usize, usize)> { + let Some((base, data)) = &m.mpf else { return vec![] }; + let Some(t) = exif::Tiff::new(data) else { return vec![] }; + let Some(ifd) = t.first_ifd() else { return vec![] }; + let Some((entries, _)) = t.ifd(ifd) else { return vec![] }; + let Some(e) = entries.iter().find(|e| e.tag == 0xB002) else { return vec![] }; + let Some(list) = t.bytes(e) else { return vec![] }; + let mut out = Vec::new(); + for (i, rec) in list.chunks_exact(16).enumerate().take(16) { + if i == 0 { + continue; + } + let rd = |o: usize| { + let a = [rec[o], rec[o + 1], rec[o + 2], rec[o + 3]]; + if data.starts_with(b"II") { u32::from_le_bytes(a) } else { u32::from_be_bytes(a) } + }; + let (size, off) = (rd(4) as usize, rd(8) as usize); + if size > 0 && off > 0 { + out.push((base + off, size)); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scaled_request_covers() { + assert_eq!(scaled_request(6000, 4000, 256, 256), (256, 171)); + assert_eq!(scaled_request(100, 100, 256, 256), (100, 100)); + } + + #[test] + fn markers_on_garbage() { + assert!(parse_markers(b"").is_none()); + assert!(parse_markers(&[0xFF, 0xD8, 0xFF, 0xE1, 0, 1]).is_none()); + let m = parse_markers(&[0xFF, 0xD8, 0xFF, 0xE1, 0, 200, 1, 2]).unwrap(); + assert!(m.exif.is_none()); + } +} diff --git a/crates/codecs/src/jxl.rs b/crates/codecs/src/jxl.rs new file mode 100644 index 00000000..00a25fab --- /dev/null +++ b/crates/codecs/src/jxl.rs @@ -0,0 +1,76 @@ +//! JPEG XL decode via `jxl-oxide` (pure Rust). Enum-encoded images are rendered straight into linear +//! Rec.2020; ICC-tagged ones are rendered in their own space and interpreted with our ICC path. +//! jxl-oxide applies the codestream orientation itself, so `orientation` is reported as 1. + +use crate::convert::{Buf, Meta, Model, Raw, check_size, finish}; +use crate::space::{NamedSpace, SourceSpace, SpaceOrigin, Trc}; +use crate::{DecodeOptions, Decoded, Error, Format, Result}; +use jxl_oxide::color::{ColourSpace, EnumColourEncoding, Primaries, RenderingIntent, TransferFunction, WhitePoint}; +use jxl_oxide::{AuxBoxData, JxlImage}; + +const F: Format = Format::Jxl; + +fn err(e: impl std::fmt::Display) -> Error { + Error::Malformed(F, e.to_string()) +} + +pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { + let mut img = JxlImage::builder().read(std::io::Cursor::new(bytes)).map_err(err)?; + let (w, h) = (img.width(), img.height()); + check_size(F, w as u64, h as u64, opts)?; + let header = img.image_header(); + let bits = header.metadata.bit_depth.bits_per_sample() as u8; + let float = matches!(header.metadata.bit_depth, jxl_oxide::image::BitDepth::FloatSample { .. }); + let gray = header.metadata.grayscale(); + let icc = img.original_icc().map(|v| v.to_vec()); + let mut meta = Meta { orientation: Some(1), ..Default::default() }; + if icc.is_none() { + img.request_color_encoding(EnumColourEncoding { + colour_space: if gray { ColourSpace::Grey } else { ColourSpace::Rgb }, + white_point: WhitePoint::D65, + primaries: Primaries::Bt2100, + tf: TransferFunction::Linear, + rendering_intent: RenderingIntent::Relative, + }); + let mut s = SourceSpace::named(NamedSpace::Rec2020, SpaceOrigin::Container); + s.trc = Some([Trc::Linear, Trc::Linear, Trc::Linear]); + meta.already_linear = Some(s); + } else { + meta.icc = icc; + } + if let Ok(AuxBoxData::Data(ex)) = img.aux_boxes().first_exif() { + meta.exif = Some(ex.payload().to_vec()); + } + if let AuxBoxData::Data(x) = img.aux_boxes().first_xml() { + meta.xmp = Some(String::from_utf8_lossy(x).trim_end_matches('\0').to_string()); + } + let render = img.render_frame(0).map_err(err)?; + let fb = render.image_all_channels(); + let (fw, fh, ch) = (fb.width(), fb.height(), fb.channels()); + let n_color = if gray { 1 } else { 3 }; + let has_alpha = img.pixel_format().has_alpha(); + if ch < n_color { + return Err(err("too few channels")); + } + let keep = n_color + has_alpha as usize; + let src = fb.buf(); + let mut v = Vec::with_capacity(fw * fh * keep); + for px in src.chunks_exact(ch) { + v.extend_from_slice(&px[..keep.min(ch)]); + if keep > ch { + v.push(1.0); + } + } + let raw = Raw { + width: fw, + height: fh, + model: if gray { Model::Gray } else { Model::Rgb }, + alpha: has_alpha, + premultiplied: false, + buf: Buf::F32(v), + bit_depth: bits, + }; + let mut d = finish(F, raw, meta, (w, h), opts)?; + d.float = float; + Ok(d) +} diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs new file mode 100644 index 00000000..d7570199 --- /dev/null +++ b/crates/codecs/src/lib.rs @@ -0,0 +1,226 @@ +//! Standard image codecs for LightCraft (layer L1). +//! +//! - [`sniff`] detects the container format (and flags camera raws for `lightcraft-raw`). +//! - [`decode`] decodes JPEG, PNG, TIFF, WebP, GIF, BMP, PSD (merged composite) and JPEG XL into +//! **linear-light** [`Rgb32f`] in the source's own primaries, described by [`SourceSpace`]; ICC +//! profiles (matrix/TRC exactly, LUT/CMYK via the `moxcms` CMS), 16-bit and float precision are +//! preserved. EXIF/XMP/ICC blobs are passed through untouched; orientation is reported, never applied. +//! - [`to_working`] converts a decode result to linear Rec.2020 D65 (the pipeline working space). +//! - [`decode_thumbnail`] is the fast path for grid thumbnails (EXIF thumbnail or DCT-scaled decode). +//! - [`encode`] writes JPEG, PNG, TIFF, lossless WebP and (native, feature `avif`) AVIF, embedding +//! ICC/EXIF/XMP. [`icc::write_matrix_trc`] builds profiles for export. +//! +//! No decoder panics on malformed input (property-tested). +#![forbid(unsafe_code)] + +mod convert; +pub mod encode; +pub mod exif; +pub mod icc; +mod jpeg; +#[cfg(feature = "jxl")] +mod jxl; +mod other; +mod png_codec; +mod psd; +mod sniff; +pub mod space; +mod tiff_codec; +mod webp; + +pub use encode::{ + ChromaSubsampling, EncodeImage, EncodeMeta, Samples, TiffCompression, encode_avif, encode_jpeg, encode_png, encode_tiff, encode_webp_lossless, +}; +pub use sniff::{Format, sniff}; +pub use space::{NamedSpace, SourceSpace, SpaceOrigin, Trc}; + +use lightcraft_raster::{Plane, Rgb32f, Rgba8}; +use serde::{Deserialize, Serialize}; + +/// Codec errors. +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("unrecognised image format")] + UnknownFormat, + #[error("{0:?} decoding is not supported: {1}")] + Unsupported(Format, &'static str), + #[error("malformed {0:?} data: {1}")] + Malformed(Format, String), + #[error("image too large: {0}×{1}")] + TooLarge(u64, u64), + #[error("encode error: {0}")] + Encode(String), +} + +pub type Result = std::result::Result; + +/// Decode options. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct DecodeOptions { + /// Fit the result within this box (never upscales). JPEG uses DCT-domain scaling (1/2, 1/4, + /// 1/8) before a final linear-light resample; other formats decode fully then resample. + pub max_size: Option<(u32, u32)>, + /// Refuse images with more pixels than this (default 1 Gpx). + pub max_pixels: u64, +} + +impl Default for DecodeOptions { + fn default() -> Self { + DecodeOptions { max_size: None, max_pixels: 1 << 30 } + } +} + +impl DecodeOptions { + pub fn fit(w: u32, h: u32) -> Self { + DecodeOptions { max_size: Some((w, h)), ..Default::default() } + } +} + +/// A decoded image. +#[derive(Clone, Debug)] +pub struct Decoded { + pub format: Format, + /// Linear light, in the primaries described by [`Decoded::space`]. + pub image: Rgb32f, + /// Straight (unassociated) alpha 0..1, when the source has transparency. + pub alpha: Option, + pub space: SourceSpace, + /// Bits per sample in the file (8, 16, 32, …). + pub bit_depth: u8, + /// Sample format in the file was floating point. + pub float: bool, + pub has_alpha: bool, + /// The source is grayscale (expanded to RGB in `image`). + pub grayscale: bool, + /// The source was CMYK (converted). + pub cmyk: bool, + /// EXIF orientation value 1..=8 (1 when absent). **Not applied** to `image`. + pub orientation: u16, + /// TIFF-structured EXIF bytes (starting at the `II`/`MM` byte-order mark), if embedded. + /// For TIFF files the file itself is the TIFF structure and this is `None`. + pub exif: Option>, + pub xmp: Option, + pub icc: Option>, + /// Dimensions of `image`. + pub width: u32, + pub height: u32, + /// Full-resolution dimensions of the source (differs from `width`/`height` when scaled). + pub source_width: u32, + pub source_height: u32, +} + +impl Decoded { + /// Pixels converted to linear Rec.2020 D65. + pub fn to_working(&self) -> Rgb32f { + to_working(self) + } + + /// Display-encoded 8-bit sRGB (gamut clipped), with alpha. + pub fn to_srgb8(&self) -> Rgba8 { + let m = self.space.to_space(&lightcraft_color::SRGB); + let ident = m == lightcraft_color::Mat3::IDENTITY; + let m = m.to_f32(); + let enc = lightcraft_color::transfer::encode_srgb8; + let mut out = Rgba8::new(self.image.width, self.image.height); + let alpha = self.alpha.as_ref(); + lightcraft_raster::par_rows(&mut out.data, self.image.width, |y, row| { + let src = self.image.row(y); + let a = alpha.map(|a| a.row(y)); + for (x, (o, p)) in row.iter_mut().zip(src).enumerate() { + let q = if ident { *p } else { mat_apply(&m, *p) }; + let al = a.map(|a| (a[x].clamp(0.0, 1.0) * 255.0 + 0.5) as u8).unwrap_or(255); + *o = [enc(q[0]), enc(q[1]), enc(q[2]), al]; + } + }); + out + } +} + +#[inline] +pub(crate) fn mat_apply(m: &[[f32; 3]; 3], p: [f32; 3]) -> [f32; 3] { + [ + m[0][0] * p[0] + m[0][1] * p[1] + m[0][2] * p[2], + m[1][0] * p[0] + m[1][1] * p[1] + m[1][2] * p[2], + m[2][0] * p[0] + m[2][1] * p[1] + m[2][2] * p[2], + ] +} + +/// Decode any supported format (see [`Format::can_decode`]). +/// +/// Malformed input yields an error, never a panic: on unwinding targets a panic inside a third-party +/// decoder is caught and reported as [`Error::Malformed`]. +pub fn decode(bytes: &[u8], opts: DecodeOptions) -> Result { + let format = sniff(bytes).ok_or(Error::UnknownFormat)?; + std::panic::catch_unwind(|| decode_unguarded(bytes, format, &opts)).unwrap_or_else(|_| Err(Error::Malformed(format, "decoder panicked".into()))) +} + +/// [`decode`] without the panic guard (for fuzzing our own code paths). +#[doc(hidden)] +pub fn decode_unguarded(bytes: &[u8], format: Format, opts: &DecodeOptions) -> Result { + let opts = *opts; + match format { + Format::Jpeg => jpeg::decode(bytes, &opts), + Format::Png => png_codec::decode(bytes, &opts), + Format::Tiff => tiff_codec::decode(bytes, &opts), + Format::WebP => webp::decode(bytes, &opts), + Format::Gif | Format::Bmp => other::decode(bytes, format, &opts), + Format::Psd => psd::decode(bytes, &opts), + #[cfg(feature = "jxl")] + Format::Jxl => jxl::decode(bytes, &opts), + #[cfg(not(feature = "jxl"))] + Format::Jxl => Err(Error::Unsupported(format, "built without the `jxl` feature")), + Format::Avif => Err(Error::Unsupported(format, "no pure-Rust, permissively licensed AV1 decoder yet")), + Format::Heif => Err(Error::Unsupported(format, "no pure-Rust, permissively licensed HEVC decoder yet")), + Format::RawTiffLike | Format::RawOther => Err(Error::Unsupported(format, "camera raw: decode with lightcraft-raw")), + } +} + +/// Convert a decode result to linear Rec.2020 D65 (Bradford-adapted), the develop working space. +pub fn to_working(d: &Decoded) -> Rgb32f { + if d.space.is_working() { + return d.image.clone(); + } + let m = d.space.to_working().to_f32(); + d.image.map(|p| mat_apply(&m, p)) +} + +/// Where a thumbnail came from. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ThumbnailSource { + /// The JPEG thumbnail embedded in EXIF (IFD1). + ExifThumbnail, + /// An MPF (CIPA DC-007) preview image embedded in a JPEG. + MpfPreview, + /// A (DCT-)scaled decode of the main image. + Scaled, +} + +/// A small display-ready image. +#[derive(Clone, Debug)] +pub struct Thumbnail { + /// 8-bit sRGB with alpha, fitted within `max_edge` (orientation **not** applied). + pub image: Rgba8, + pub orientation: u16, + pub source: ThumbnailSource, + pub source_width: u32, + pub source_height: u32, +} + +/// Fast thumbnail: an embedded JPEG preview that is at least `max_edge` on its long side if one exists, +/// else a scaled decode. The result fits within `max_edge × max_edge`. +pub fn decode_thumbnail(bytes: &[u8], max_edge: u32) -> Result { + let max_edge = max_edge.max(1); + if sniff(bytes) == Some(Format::Jpeg) + && let Some(t) = jpeg::embedded_thumbnail(bytes, max_edge) + { + return Ok(t); + } + let d = decode(bytes, DecodeOptions::fit(max_edge, max_edge))?; + Ok(Thumbnail { + image: d.to_srgb8(), + orientation: d.orientation, + source: ThumbnailSource::Scaled, + source_width: d.source_width, + source_height: d.source_height, + }) +} diff --git a/crates/codecs/src/other.rs b/crates/codecs/src/other.rs new file mode 100644 index 00000000..d5659de9 --- /dev/null +++ b/crates/codecs/src/other.rs @@ -0,0 +1,38 @@ +//! GIF (first frame) and BMP via the `image` crate's pure-Rust decoders. + +use crate::convert::{Buf, Meta, Model, Raw, check_size, finish}; +use crate::{DecodeOptions, Decoded, Error, Format, Result}; +use image::{DynamicImage, ImageDecoder}; + +pub(crate) fn decode(bytes: &[u8], format: Format, opts: &DecodeOptions) -> Result { + let err = |e: image::ImageError| Error::Malformed(format, e.to_string()); + let cursor = std::io::Cursor::new(bytes); + let (img, icc) = match format { + Format::Gif => { + let mut d = image::codecs::gif::GifDecoder::new(cursor).map_err(err)?; + let (w, h) = d.dimensions(); + check_size(format, w as u64, h as u64, opts)?; + let icc = d.icc_profile().ok().flatten(); + (DynamicImage::from_decoder(d).map_err(err)?, icc) + } + Format::Bmp => { + let mut d = image::codecs::bmp::BmpDecoder::new(cursor).map_err(err)?; + let (w, h) = d.dimensions(); + check_size(format, w as u64, h as u64, opts)?; + let icc = d.icc_profile().ok().flatten(); + (DynamicImage::from_decoder(d).map_err(err)?, icc) + } + _ => return Err(Error::Unsupported(format, "not handled by this decoder")), + }; + let (w, h) = (img.width(), img.height()); + let alpha = img.color().has_alpha(); + let gray = !img.color().has_color(); + let (model, alpha, buf) = match (gray, alpha) { + (true, false) => (Model::Gray, false, img.into_luma8().into_raw()), + (true, true) => (Model::Gray, true, img.into_luma_alpha8().into_raw()), + (false, false) => (Model::Rgb, false, img.into_rgb8().into_raw()), + (false, true) => (Model::Rgb, true, img.into_rgba8().into_raw()), + }; + let raw = Raw { width: w as usize, height: h as usize, model, alpha, premultiplied: false, buf: Buf::U8(buf), bit_depth: 8 }; + finish(format, raw, Meta { icc, ..Default::default() }, (w, h), opts) +} diff --git a/crates/codecs/src/png_codec.rs b/crates/codecs/src/png_codec.rs new file mode 100644 index 00000000..a6704495 --- /dev/null +++ b/crates/codecs/src/png_codec.rs @@ -0,0 +1,116 @@ +//! PNG decode via the `png` crate (8/16-bit, palette, tRNS, iCCP, eXIf, iTXt XMP, sRGB/cICP/cHRM/gAMA). + +use crate::convert::{Buf, Meta, Model, Raw, check_size, finish}; +use crate::space::{NamedSpace, SourceSpace, SpaceOrigin, Trc}; +use crate::{DecodeOptions, Decoded, Error, Format, Result}; +use lightcraft_color::{Mat3, RgbSpace, Xy}; + +const F: Format = Format::Png; + +fn err(e: impl std::fmt::Display) -> Error { + Error::Malformed(F, e.to_string()) +} + +pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { + let mut dec = png::Decoder::new(std::io::Cursor::new(bytes)); + dec.set_transformations(png::Transformations::EXPAND); + dec.set_limits(png::Limits { bytes: opts.max_pixels.saturating_mul(8).min(usize::MAX as u64) as usize }); + let mut reader = dec.read_info().map_err(err)?; + let (w, h) = { + let i = reader.info(); + (i.width, i.height) + }; + check_size(F, w as u64, h as u64, opts)?; + let size = reader.output_buffer_size().ok_or_else(|| err("buffer size overflow"))?; + let mut buf = vec![0u8; size]; + let out = reader.next_frame(&mut buf).map_err(err)?; + // Text chunks after IDAT land in `info` once the stream is finished; ignore trailing errors. + let _ = reader.finish(); + let info = reader.info(); + + let (model, alpha) = match out.color_type { + png::ColorType::Grayscale => (Model::Gray, false), + png::ColorType::GrayscaleAlpha => (Model::Gray, true), + png::ColorType::Rgb => (Model::Rgb, false), + png::ColorType::Rgba => (Model::Rgb, true), + png::ColorType::Indexed => return Err(err("palette not expanded")), + }; + buf.truncate(out.buffer_size()); + let (samples, depth) = match out.bit_depth { + png::BitDepth::Sixteen => (Buf::U16(buf.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect()), 16), + _ => (Buf::U8(buf), info.bit_depth as u8), + }; + let raw = Raw { width: out.width as usize, height: out.height as usize, model, alpha, premultiplied: false, buf: samples, bit_depth: depth }; + + let xmp = info + .utf8_text + .iter() + .find(|t| t.keyword == "XML:com.adobe.xmp") + .and_then(|t| t.get_text().ok()) + .or_else(|| info.uncompressed_latin1_text.iter().find(|t| t.keyword == "XML:com.adobe.xmp").map(|t| t.text.clone())); + let exif = info.exif_metadata.as_ref().map(|e| { + let e = e.to_vec(); + // Some writers keep the JPEG "Exif\0\0" prefix. + if e.starts_with(b"Exif\0\0") { e[6..].to_vec() } else { e } + }); + let meta = Meta { icc: info.icc_profile.as_ref().map(|c| c.to_vec()), exif, xmp, hint: container_hint(info), ..Default::default() }; + finish(F, raw, meta, (w, h), opts) +} + +fn container_hint(info: &png::Info) -> Option { + if let Some(c) = &info.coding_independent_code_points { + let named = match c.color_primaries { + 1 => Some(NamedSpace::Srgb), + 9 => Some(NamedSpace::Rec2020), + 12 => Some(NamedSpace::DisplayP3), + _ => None, + }; + let trc = match c.transfer_function { + 13 => Some(Trc::Srgb), + 1 | 6 | 14 | 15 => Some(Trc::Rec709), + 8 => Some(Trc::Linear), + 4 => Some(Trc::Gamma(2.2)), + _ => None, + }; + if let (Some(n), Some(t)) = (named, trc) { + let mut s = SourceSpace::named(n, SpaceOrigin::Container); + s.trc = Some([t.clone(), t.clone(), t]); + return Some(s); + } + } + if info.srgb.is_some() { + return Some(SourceSpace::named(NamedSpace::Srgb, SpaceOrigin::Container)); + } + let gamma = info.gama_chunk.or(info.source_gamma).map(|g| g.into_value()).filter(|g| *g > 0.0 && g.is_finite()); + let chrm = info.chrm_chunk.or(info.source_chromaticities); + if gamma.is_none() && chrm.is_none() { + return None; + } + let trc = match gamma { + Some(g) if (g - 1.0).abs() < 1e-3 => Trc::Linear, + Some(g) => Trc::Gamma(1.0 / g), + None => Trc::Srgb, + }; + let mut s = SourceSpace::named(NamedSpace::Srgb, SpaceOrigin::Container); + if let Some(c) = chrm { + let xy = |p: (png::ScaledFloat, png::ScaledFloat)| Xy::new(p.0.into_value() as f64, p.1.into_value() as f64); + let space = RgbSpace { name: "PNG cHRM", r: xy(c.red), g: xy(c.green), b: xy(c.blue), white: xy(c.white) }; + let valid = [space.r, space.g, space.b, space.white].iter().all(|p| p.y > 1e-4 && p.x >= 0.0 && p.x + p.y <= 1.0 + 1e-6); + let m = if valid { rgb_to_xyz_d50_checked(&space) } else { None }; + if let Some(m) = m { + s.to_xyz_d50 = m; + s.named = NamedSpace::recognize(&m); + } + } + s.trc = Some([trc.clone(), trc.clone(), trc]); + Some(s) +} + +fn rgb_to_xyz_d50_checked(space: &RgbSpace) -> Option { + let p = [space.r.to_xyz(), space.g.to_xyz(), space.b.to_xyz()]; + let m = Mat3([[p[0][0], p[1][0], p[2][0]], [p[0][1], p[1][1], p[2][1]], [p[0][2], p[1][2], p[2][2]]]); + if m.determinant().abs() < 1e-9 { + return None; + } + Some(crate::space::rgb_to_xyz_d50(space)) +} diff --git a/crates/codecs/src/psd.rs b/crates/codecs/src/psd.rs new file mode 100644 index 00000000..de668915 --- /dev/null +++ b/crates/codecs/src/psd.rs @@ -0,0 +1,244 @@ +//! Photoshop PSD/PSB merged-composite reader (clean-room, from the public file-format description): +//! 8/16/32-bit gray, RGB, CMYK, indexed, duotone (as gray); raw or PackBits/RLE image data; +//! ICC (resource 1039), EXIF (1058), XMP (1060); transparency when the layer count is negative. + +use crate::convert::{Buf, Meta, Model, Raw, check_size, finish}; +use crate::{DecodeOptions, Decoded, Error, Format, Result}; + +const F: Format = Format::Psd; + +fn err(s: &str) -> Error { + Error::Malformed(F, s.to_string()) +} + +struct Rd<'a> { + b: &'a [u8], + p: usize, +} + +impl<'a> Rd<'a> { + fn take(&mut self, n: usize) -> Result<&'a [u8]> { + let end = self.p.checked_add(n).filter(|&e| e <= self.b.len()).ok_or_else(|| err("truncated"))?; + let s = &self.b[self.p..end]; + self.p = end; + Ok(s) + } + fn u16(&mut self) -> Result { + let s = self.take(2)?; + Ok(u16::from_be_bytes([s[0], s[1]])) + } + fn u32(&mut self) -> Result { + let s = self.take(4)?; + Ok(u32::from_be_bytes([s[0], s[1], s[2], s[3]])) + } + fn u64(&mut self) -> Result { + let s = self.take(8)?; + Ok(u64::from_be_bytes([s[0], s[1], s[2], s[3], s[4], s[5], s[6], s[7]])) + } +} + +pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { + let mut r = Rd { b: bytes, p: 0 }; + if r.take(4)? != b"8BPS" { + return Err(err("bad signature")); + } + let version = r.u16()?; + let psb = match version { + 1 => false, + 2 => true, + _ => return Err(err("bad version")), + }; + r.take(6)?; + let channels = r.u16()? as usize; + let height = r.u32()? as usize; + let width = r.u32()? as usize; + let depth = r.u16()?; + let mode = r.u16()?; + check_size(F, width as u64, height as u64, opts)?; + if channels == 0 || channels > 56 { + return Err(err("bad channel count")); + } + + // Colour mode data (palette for indexed). + let cm_len = r.u32()? as usize; + let color_mode_data = r.take(cm_len)?; + + // Image resources. + let res_len = r.u32()? as usize; + let res = r.take(res_len)?; + let (icc, exif, xmp) = resources(res); + + // Layer and mask information: only the layer count sign matters (merged transparency). + let lm_len = if psb { r.u64()? as usize } else { r.u32()? as usize }; + let lm = r.take(lm_len)?; + let merged_transparency = if lm.len() >= if psb { 10 } else { 6 } { + let off = if psb { 8 } else { 4 }; + let count = i16::from_be_bytes([lm[off], lm[off + 1]]); + count < 0 + } else { + false + }; + + // Image data. + let compression = r.u16()?; + let bpc = match depth { + 8 => 1usize, + 16 => 2, + 32 => 4, + 1 => return Err(Error::Unsupported(F, "1-bit bitmap PSD")), + _ => return Err(err("bad depth")), + }; + let plane = width * height * bpc; + let row_bytes = width * bpc; + let mut planes: Vec> = Vec::with_capacity(channels); + match compression { + 0 => { + for _ in 0..channels { + planes.push(r.take(plane)?.to_vec()); + } + } + 1 => { + let counts_len = channels * height; + let mut counts = Vec::with_capacity(counts_len); + for _ in 0..counts_len { + counts.push(if psb { r.u32()? as usize } else { r.u16()? as usize }); + } + for c in 0..channels { + let mut out = Vec::with_capacity(plane); + for y in 0..height { + let src = r.take(counts[c * height + y])?; + unpackbits(src, row_bytes, &mut out); + } + planes.push(out); + } + } + _ => return Err(Error::Unsupported(F, "ZIP-compressed merged image data")), + } + + let (model, n_color) = match mode { + 1 | 8 => (Model::Gray, 1), + 2 => (Model::Rgb, 1), + 3 => (Model::Rgb, 3), + 4 => (Model::Cmyk, 4), + 7 => (Model::Gray, 1), + 9 => return Err(Error::Unsupported(F, "Lab PSD")), + _ => return Err(err("unknown colour mode")), + }; + if channels < n_color { + return Err(err("too few channels")); + } + let alpha = merged_transparency && channels > n_color && mode != 2; + let used = n_color + alpha as usize; + let n = width * height; + + // Interleave to device samples. + let buf = if mode == 2 { + // Indexed → 8-bit RGB via the 768-byte palette. + if color_mode_data.len() < 768 || depth != 8 { + return Err(err("bad indexed palette")); + } + let mut v = Vec::with_capacity(n * 3); + for &i in &planes[0] { + let i = i as usize; + v.extend_from_slice(&[color_mode_data[i], color_mode_data[256 + i], color_mode_data[512 + i]]); + } + Buf::U8(v) + } else { + match bpc { + 1 => { + let mut v = vec![0u8; n * used]; + for (c, p) in planes.iter().take(used).enumerate() { + for i in 0..n { + let x = p[i]; + // PSD CMYK stores 255 = no ink; our CMYK convention is 0 = no ink. + v[i * used + c] = if model == Model::Cmyk && c < 4 { 255 - x } else { x }; + } + } + Buf::U8(v) + } + 2 => { + let mut v = vec![0u16; n * used]; + for (c, p) in planes.iter().take(used).enumerate() { + for i in 0..n { + let x = u16::from_be_bytes([p[2 * i], p[2 * i + 1]]); + v[i * used + c] = if model == Model::Cmyk && c < 4 { 65535 - x } else { x }; + } + } + Buf::U16(v) + } + _ => { + let mut v = vec![0f32; n * used]; + for (c, p) in planes.iter().take(used).enumerate() { + for i in 0..n { + let x = f32::from_be_bytes([p[4 * i], p[4 * i + 1], p[4 * i + 2], p[4 * i + 3]]); + v[i * used + c] = if model == Model::Cmyk && c < 4 { 1.0 - x } else { x }; + } + } + Buf::F32(v) + } + } + }; + let raw = Raw { width, height, model, alpha, premultiplied: false, buf, bit_depth: depth as u8 }; + finish(F, raw, Meta { icc, exif, xmp, ..Default::default() }, (width as u32, height as u32), opts) +} + +/// PackBits: append exactly `row_bytes` bytes (zero-padded if the run data is short). +fn unpackbits(mut src: &[u8], row_bytes: usize, out: &mut Vec) { + let target = out.len() + row_bytes; + while out.len() < target && !src.is_empty() { + let h = src[0] as i8; + src = &src[1..]; + if h >= 0 { + let n = (h as usize + 1).min(src.len()).min(target - out.len()); + out.extend_from_slice(&src[..n]); + src = &src[(h as usize + 1).min(src.len())..]; + } else if h != -128 { + let n = ((1 - h as isize) as usize).min(target - out.len()); + if let Some(&b) = src.first() { + out.extend(std::iter::repeat_n(b, n)); + src = &src[1..]; + } + } + } + out.resize(target, 0); +} + +fn resources(mut b: &[u8]) -> (Option>, Option>, Option) { + let (mut icc, mut exif, mut xmp) = (None, None, None); + while b.len() >= 12 && &b[0..4] == b"8BIM" { + let id = u16::from_be_bytes([b[4], b[5]]); + let name_len = b[6] as usize; + let name_total = (1 + name_len + 1) & !1; + let p = 6 + name_total; + if p + 4 > b.len() { + break; + } + let size = u32::from_be_bytes([b[p], b[p + 1], b[p + 2], b[p + 3]]) as usize; + let start = p + 4; + let Some(data) = start.checked_add(size).and_then(|end| b.get(start..end)) else { break }; + match id { + 1039 => icc = Some(data.to_vec()), + 1058 => exif = Some(if data.starts_with(b"Exif\0\0") { data[6..].to_vec() } else { data.to_vec() }), + 1060 => xmp = Some(String::from_utf8_lossy(data).trim_end_matches('\0').to_string()), + _ => {} + } + let next = start + ((size + 1) & !1); + b = b.get(next..).unwrap_or(&[]); + } + (icc, exif, xmp) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn packbits() { + let mut out = Vec::new(); + unpackbits(&[0xFE, 0xAA, 0x02, 0x80, 0x00, 0x2A], 6, &mut out); + assert_eq!(out, vec![0xAA, 0xAA, 0xAA, 0x80, 0x00, 0x2A]); + let mut out = Vec::new(); + unpackbits(&[0x05, 1], 4, &mut out); + assert_eq!(out, vec![1, 0, 0, 0]); + } +} diff --git a/crates/codecs/src/sniff.rs b/crates/codecs/src/sniff.rs new file mode 100644 index 00000000..d3162904 --- /dev/null +++ b/crates/codecs/src/sniff.rs @@ -0,0 +1,236 @@ +//! Format detection from leading bytes. + +use crate::exif::Tiff; +use serde::{Deserialize, Serialize}; + +/// Image container formats LightCraft recognises. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Format { + Jpeg, + Png, + /// Ordinary (non-camera-raw) TIFF, incl. BigTIFF. + Tiff, + WebP, + Avif, + /// HEIC / HEIF (ISO-BMFF with HEVC). + Heif, + /// JPEG XL (bare codestream or ISO-BMFF container). + Jxl, + Gif, + Bmp, + /// Photoshop PSD / PSB (merged composite). + Psd, + /// TIFF-structured camera raw (DNG, CR2, NEF, ARW, PEF, ORF, RW2, SRW, 3FR, IIQ, ERF, …): + /// route to `lightcraft-raw`. + RawTiffLike, + /// Non-TIFF camera raw containers (CR3, RAF, CRW, MRW, X3F): route to `lightcraft-raw`. + RawOther, +} + +impl Format { + /// Canonical lowercase file extension. + pub fn extension(self) -> &'static str { + match self { + Format::Jpeg => "jpg", + Format::Png => "png", + Format::Tiff => "tif", + Format::WebP => "webp", + Format::Avif => "avif", + Format::Heif => "heic", + Format::Jxl => "jxl", + Format::Gif => "gif", + Format::Bmp => "bmp", + Format::Psd => "psd", + Format::RawTiffLike => "dng", + Format::RawOther => "raw", + } + } + + pub fn mime(self) -> &'static str { + match self { + Format::Jpeg => "image/jpeg", + Format::Png => "image/png", + Format::Tiff => "image/tiff", + Format::WebP => "image/webp", + Format::Avif => "image/avif", + Format::Heif => "image/heic", + Format::Jxl => "image/jxl", + Format::Gif => "image/gif", + Format::Bmp => "image/bmp", + Format::Psd => "image/vnd.adobe.photoshop", + Format::RawTiffLike | Format::RawOther => "image/x-raw", + } + } + + /// Whether [`crate::decode`] can decode this format in this build. + pub fn can_decode(self) -> bool { + match self { + Format::Jxl => cfg!(feature = "jxl"), + Format::Avif | Format::Heif | Format::RawTiffLike | Format::RawOther => false, + _ => true, + } + } + + /// Camera raw formats (decode with `lightcraft-raw`). + pub fn is_raw(self) -> bool { + matches!(self, Format::RawTiffLike | Format::RawOther) + } +} + +/// Detect the format from the first bytes of a file (a few KiB suffices; TIFF raw detection walks IFDs +/// when more of the file is available). +pub fn sniff(b: &[u8]) -> Option { + if b.len() < 4 { + return None; + } + if b.starts_with(&[0xFF, 0xD8, 0xFF]) { + return Some(Format::Jpeg); + } + if b.starts_with(b"\x89PNG\r\n\x1a\n") { + return Some(Format::Png); + } + if b.starts_with(b"GIF87a") || b.starts_with(b"GIF89a") { + return Some(Format::Gif); + } + if b.len() >= 12 && &b[0..4] == b"RIFF" && &b[8..12] == b"WEBP" { + return Some(Format::WebP); + } + if b.starts_with(&[0xFF, 0x0A]) || b.starts_with(&[0, 0, 0, 0x0C, b'J', b'X', b'L', b' ', 0x0D, 0x0A, 0x87, 0x0A]) { + return Some(Format::Jxl); + } + if b.starts_with(b"8BPS") && b.len() >= 6 && matches!(u16::from_be_bytes([b[4], b[5]]), 1 | 2) { + return Some(Format::Psd); + } + if b.starts_with(b"FUJIFILMCCD-RAW") || b.starts_with(b"\0MRM") || b.starts_with(b"FOVb") { + return Some(Format::RawOther); + } + if b.len() >= 14 && &b[0..2] == b"II" && &b[6..14] == b"HEAPCCDR" { + return Some(Format::RawOther); + } + if b.len() >= 12 && &b[4..8] == b"ftyp" { + return sniff_bmff(b); + } + if b.starts_with(b"IIRO") || b.starts_with(b"IIRS") || b.starts_with(b"MMOR") || b.starts_with(b"IIU\0") { + // Olympus ORF / Panasonic RW2: TIFF-structured with a private magic. + return Some(Format::RawTiffLike); + } + if b.starts_with(b"II*\0") || b.starts_with(b"MM\0*") { + return Some(if tiff_is_raw(b) { Format::RawTiffLike } else { Format::Tiff }); + } + if b.starts_with(b"II+\0") || b.starts_with(b"MM\0+") { + return Some(Format::Tiff); + } + if b.len() >= 26 && b.starts_with(b"BM") { + let dib = u32::from_le_bytes([b[14], b[15], b[16], b[17]]); + if matches!(dib, 12 | 16 | 40 | 52 | 56 | 64 | 108 | 124) { + return Some(Format::Bmp); + } + } + None +} + +fn sniff_bmff(b: &[u8]) -> Option { + let size = u32::from_be_bytes([b[0], b[1], b[2], b[3]]) as usize; + let end = size.clamp(12, b.len().min(4096)); + let mut brands: Vec<&[u8]> = vec![&b[8..12]]; + let mut p = 16; + while p + 4 <= end { + brands.push(&b[p..p + 4]); + p += 4; + } + let has = |x: &[u8]| brands.contains(&x); + if has(b"crx ") { + return Some(Format::RawOther); + } + if has(b"jxl ") { + return Some(Format::Jxl); + } + if has(b"avif") || has(b"avis") { + return Some(Format::Avif); + } + if [&b"heic"[..], b"heix", b"hevc", b"hevx", b"heim", b"heis", b"hevm", b"hevs", b"mif1", b"msf1"].iter().any(|x| has(x)) { + return Some(Format::Heif); + } + None +} + +/// Heuristic camera-raw detection for classic TIFF: DNGVersion, the CR2 signature, CFA/LinearRaw +/// photometric or raw-only compressions in IFD0 or its SubIFDs, or a camera Make with 10–16-bit data +/// and no ordinary photometric. +fn tiff_is_raw(b: &[u8]) -> bool { + if b.len() >= 10 && &b[8..10] == b"CR" { + return true; + } + let Some(t) = Tiff::new(b) else { return false }; + let Some(ifd0) = t.first_ifd() else { return false }; + let mut stack = vec![ifd0]; + let mut seen = 0; + while let Some(pos) = stack.pop() { + seen += 1; + if seen > 16 { + break; + } + let Some((entries, _)) = t.ifd(pos) else { continue }; + for e in &entries { + match e.tag { + 0xC612 => return true, // DNGVersion + 0x0106 if matches!(t.uint(e), Some(32803 | 34892)) => return true, + 0x0103 if matches!(t.uint(e), Some(32767 | 32769 | 32770 | 34316 | 34713 | 65000 | 65535)) => return true, + 0x014A => { + // SubIFDs (LONG or IFD type): walk them. + let n = e.count.min(8) as usize; + if n == 1 { + if let Some(p) = t.uint(e) { + stack.push(p as usize); + } + } else if let Some(off) = t.u32_at(e.value_pos) { + for i in 0..n { + if let Some(p) = t.u32_at(off as usize + 4 * i) { + stack.push(p as usize); + } + } + } + } + _ => {} + } + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn magic_numbers() { + assert_eq!(sniff(b"\xFF\xD8\xFF\xE0\0\x10JFIF"), Some(Format::Jpeg)); + assert_eq!(sniff(b"\x89PNG\r\n\x1a\n...."), Some(Format::Png)); + assert_eq!(sniff(b"GIF89a......"), Some(Format::Gif)); + assert_eq!(sniff(b"RIFF\0\0\0\0WEBPVP8 "), Some(Format::WebP)); + assert_eq!(sniff(b"\0\0\0\x1cftypavif\0\0\0\0avifmif1miaf"), Some(Format::Avif)); + assert_eq!(sniff(b"\0\0\0\x18ftypheic\0\0\0\0mif1heic"), Some(Format::Heif)); + assert_eq!(sniff(b"\0\0\0\x18ftypcrx \0\0\0\x01crx isom"), Some(Format::RawOther)); + assert_eq!(sniff(&[0xFF, 0x0A, 0xFA, 0x7F]), Some(Format::Jxl)); + assert_eq!(sniff(b"8BPS\0\x01\0\0\0\0\0\0"), Some(Format::Psd)); + assert_eq!(sniff(b"FUJIFILMCCD-RAW 0201"), Some(Format::RawOther)); + assert_eq!(sniff(b"II*\0\x08\0\0\0\0\0"), Some(Format::Tiff)); + assert_eq!(sniff(b"II*\0\x10\0\0\0CR\x02\0"), Some(Format::RawTiffLike)); + assert_eq!(sniff(b"IIRO\x08\0\0\0"), Some(Format::RawTiffLike)); + assert_eq!(sniff(b"nope"), None); + assert_eq!(sniff(b""), None); + } + + #[test] + fn dng_detected() { + // II*, IFD at 8 with a single DNGVersion entry. + let mut b = b"II*\0\x08\0\0\0".to_vec(); + b.extend_from_slice(&1u16.to_le_bytes()); + b.extend_from_slice(&0xC612u16.to_le_bytes()); + b.extend_from_slice(&1u16.to_le_bytes()); + b.extend_from_slice(&4u32.to_le_bytes()); + b.extend_from_slice(&[1, 4, 0, 0]); + b.extend_from_slice(&0u32.to_le_bytes()); + assert_eq!(sniff(&b), Some(Format::RawTiffLike)); + } +} diff --git a/crates/codecs/src/space.rs b/crates/codecs/src/space.rs new file mode 100644 index 00000000..a945fe72 --- /dev/null +++ b/crates/codecs/src/space.rs @@ -0,0 +1,321 @@ +//! Source colour spaces, transfer curves and the conversion to the working space. + +use lightcraft_color::{ADOBE_RGB, D50, DISPLAY_P3, Mat3, PROPHOTO, REC2020, RgbSpace, SRGB, WORKING, bradford}; +use serde::{Deserialize, Serialize}; + +/// A transfer curve (encoded value → linear light), as found in ICC `curv`/`para` tags and container +/// colour descriptions. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum Trc { + /// Identity (already linear). + Linear, + /// IEC 61966-2-1 sRGB curve (also used by Display P3). + Srgb, + /// Pure power law `y = x^g`. + Gamma(f32), + /// ITU-R BT.709 / BT.2020 OETF inverse. + Rec709, + /// ICC parametric curve parameters `[g, a, b, c, d, e, f]` (1, 3, 4, 5 or 7 values; ICC function types 0–4). + Parametric(Vec), + /// Sampled curve over `[0, 1]` (ICC `curv` with ≥ 2 entries), linearly interpolated. + Table(Vec), +} + +impl Trc { + /// Encoded value → linear. Negative values are mirrored and values above 1 are extrapolated, + /// so extended-range float data survives. + pub fn to_linear(&self, v: f32) -> f32 { + if v < 0.0 { + return -self.to_linear(-v); + } + match self { + Trc::Linear => v, + Trc::Srgb => { + if v <= 0.04045 { + v / 12.92 + } else { + ((v + 0.055) / 1.055).powf(2.4) + } + } + Trc::Gamma(g) => v.powf(*g), + Trc::Rec709 => { + if v < 0.081 { + v / 4.5 + } else { + ((v + 0.099) / 1.099).powf(1.0 / 0.45) + } + } + Trc::Parametric(p) => eval_parametric(p, v), + Trc::Table(t) => eval_table(t, v), + } + } + + /// Linear → encoded value (inverse of [`Trc::to_linear`]); tables are inverted numerically. + pub fn from_linear(&self, v: f32) -> f32 { + if v < 0.0 { + return -self.from_linear(-v); + } + match self { + Trc::Linear => v, + Trc::Srgb => lightcraft_color::transfer::linear_to_srgb(v), + Trc::Gamma(g) => { + if *g > 0.0 { + v.powf(1.0 / g) + } else { + v + } + } + Trc::Rec709 => { + if v < 0.018 { + v * 4.5 + } else { + 1.099 * v.powf(0.45) - 0.099 + } + } + Trc::Parametric(_) | Trc::Table(_) => { + // Monotone curves: bisection on [0, max(1, v)]. + let (mut lo, mut hi) = (0.0f32, 1.0f32); + while self.to_linear(hi) < v && hi < 1e6 { + hi *= 2.0; + } + for _ in 0..40 { + let mid = 0.5 * (lo + hi); + if self.to_linear(mid) < v { + lo = mid; + } else { + hi = mid; + } + } + 0.5 * (lo + hi) + } + } + } + + /// True for the identity curve (including degenerate ICC encodings of it). + pub fn is_linear(&self) -> bool { + match self { + Trc::Linear => true, + Trc::Gamma(g) => (*g - 1.0).abs() < 1e-4, + Trc::Parametric(p) => p.len() == 1 && (p[0] - 1.0).abs() < 1e-4, + Trc::Table(t) => t.is_empty(), + _ => false, + } + } + + /// Whether two curves agree to within `tol` on a sample grid. + pub fn approx_eq(&self, other: &Trc, tol: f32) -> bool { + (0..=32).all(|i| { + let x = i as f32 / 32.0; + (self.to_linear(x) - other.to_linear(x)).abs() <= tol + }) + } + + /// 256-entry linearization table for 8-bit data. + pub(crate) fn lut8(&self) -> Vec { + (0..256).map(|i| self.to_linear(i as f32 / 255.0)).collect() + } + + /// 65536-entry linearization table for 16-bit data. + pub(crate) fn lut16(&self) -> Vec { + if self.is_linear() { + return (0..65536).map(|i| i as f32 / 65535.0).collect(); + } + (0..65536).map(|i| self.to_linear(i as f32 / 65535.0)).collect() + } +} + +fn eval_parametric(p: &[f32], x: f32) -> f32 { + let pw = |b: f32, g: f32| if b > 0.0 { b.powf(g) } else { 0.0 }; + match p.len() { + 1 => pw(x, p[0]), + 3 => { + let (g, a, b) = (p[0], p[1], p[2]); + if a != 0.0 && x >= -b / a { pw(a * x + b, g) } else { 0.0 } + } + 4 => { + let (g, a, b, c) = (p[0], p[1], p[2], p[3]); + if a != 0.0 && x >= -b / a { pw(a * x + b, g) + c } else { c } + } + 5 => { + let (g, a, b, c, d) = (p[0], p[1], p[2], p[3], p[4]); + if x >= d { pw(a * x + b, g) } else { c * x } + } + 7 => { + let (g, a, b, c, d, e, f) = (p[0], p[1], p[2], p[3], p[4], p[5], p[6]); + if x >= d { pw(a * x + b, g) + e } else { c * x + f } + } + _ => x, + } +} + +fn eval_table(t: &[u16], x: f32) -> f32 { + match t.len() { + 0 => x, + 1 => x.powf(t[0] as f32 / 256.0), + n => { + let pos = x * (n - 1) as f32; + let i = (pos.floor() as usize).min(n - 2); + let f = pos - i as f32; + let a = t[i] as f32 / 65535.0; + let b = t[i + 1] as f32 / 65535.0; + a + (b - a) * f + } + } +} + +/// Well-known RGB spaces we recognise (from ICC colorants or container tags). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum NamedSpace { + Srgb, + DisplayP3, + AdobeRgb, + ProPhoto, + Rec2020, +} + +impl NamedSpace { + pub const ALL: [NamedSpace; 5] = [NamedSpace::Srgb, NamedSpace::DisplayP3, NamedSpace::AdobeRgb, NamedSpace::ProPhoto, NamedSpace::Rec2020]; + + pub fn rgb_space(self) -> RgbSpace { + match self { + NamedSpace::Srgb => SRGB, + NamedSpace::DisplayP3 => DISPLAY_P3, + NamedSpace::AdobeRgb => ADOBE_RGB, + NamedSpace::ProPhoto => PROPHOTO, + NamedSpace::Rec2020 => REC2020, + } + } + + /// The space's standard encoding curve. + pub fn trc(self) -> Trc { + match self { + NamedSpace::Srgb | NamedSpace::DisplayP3 => Trc::Srgb, + NamedSpace::AdobeRgb => Trc::Gamma(563.0 / 256.0), + NamedSpace::ProPhoto => Trc::Gamma(1.8), + NamedSpace::Rec2020 => Trc::Rec709, + } + } + + /// Linear RGB → XYZ (D50 PCS, Bradford-adapted), as an ICC matrix/TRC profile would store it. + pub fn to_xyz_d50(self) -> Mat3 { + rgb_to_xyz_d50(&self.rgb_space()) + } + + pub fn name(self) -> &'static str { + self.rgb_space().name + } + + /// Recognise a space from its D50-adapted colorant matrix (tolerant to s15Fixed16 rounding and + /// the small variations between vendors' profiles). + pub fn recognize(to_xyz_d50: &Mat3) -> Option { + NamedSpace::ALL.into_iter().find(|n| { + let m = n.to_xyz_d50(); + (0..3).all(|r| (0..3).all(|c| (m.0[r][c] - to_xyz_d50.0[r][c]).abs() < 0.004)) + }) + } +} + +/// `space`'s linear RGB → XYZ relative to D50 (Bradford), the ICC PCS. +pub fn rgb_to_xyz_d50(space: &RgbSpace) -> Mat3 { + bradford(space.white, D50).mul(&space.to_xyz()) +} + +/// Where a decoded image's colour interpretation came from. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum SpaceOrigin { + /// No colour information in the file: assumed sRGB. + Untagged, + /// A container-level description (PNG `sRGB`/`cHRM`/`gAMA`, JPEG XL enum colour encoding, …). + Container, + /// An embedded ICC matrix/TRC profile, applied exactly. + IccMatrixTrc, + /// An embedded LUT-based (or CMYK/Lab) ICC profile, converted by the CMS to linear Rec.2020. + IccCms, + /// An embedded ICC profile we could not apply (malformed/unsupported): **fell back to sRGB**. + IccUnsupported, + /// A naive conversion without a profile (e.g. CMYK → RGB without an ICC profile), then sRGB. + Naive, +} + +/// The colour space of [`crate::Decoded::image`]: linear RGB with these primaries. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SourceSpace { + /// Recognised well-known space, if the primaries match one. + pub named: Option, + /// Linear image RGB → CIE XYZ relative to D50 (ICC PCS; white Y = 1). + pub to_xyz_d50: Mat3, + /// The encoding curves that were removed during decode (R, G, B), if known. + pub trc: Option<[Trc; 3]>, + pub origin: SpaceOrigin, +} + +impl SourceSpace { + pub fn named(n: NamedSpace, origin: SpaceOrigin) -> SourceSpace { + let t = n.trc(); + SourceSpace { named: Some(n), to_xyz_d50: n.to_xyz_d50(), trc: Some([t.clone(), t.clone(), t]), origin } + } + + /// Linear source RGB → linear `dst` RGB (Bradford from D50 to `dst`'s white). + pub fn to_space(&self, dst: &RgbSpace) -> Mat3 { + if let Some(n) = self.named { + let s = n.rgb_space(); + if s == *dst { + return Mat3::IDENTITY; + } + return s.to_space(dst); + } + dst.from_xyz().mul(&bradford(D50, dst.white)).mul(&self.to_xyz_d50) + } + + /// Linear source RGB → linear Rec.2020 D65 (the pipeline working space). + pub fn to_working(&self) -> Mat3 { + self.to_space(&WORKING) + } + + /// True when the pixels need a matrix to reach the working space. + pub fn is_working(&self) -> bool { + self.named == Some(NamedSpace::Rec2020) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn named_recognition_roundtrip() { + for n in NamedSpace::ALL { + assert_eq!(NamedSpace::recognize(&n.to_xyz_d50()), Some(n)); + } + } + + #[test] + fn trc_inverse() { + let curves = [ + Trc::Srgb, + Trc::Gamma(2.2), + Trc::Rec709, + Trc::Parametric(vec![2.4, 1.0 / 1.055, 0.055 / 1.055, 1.0 / 12.92, 0.04045]), + Trc::Table((0..1024).map(|i| ((i as f32 / 1023.0).powf(2.2) * 65535.0) as u16).collect()), + ]; + for c in curves { + for i in 0..=20 { + let x = i as f32 / 20.0; + let y = c.to_linear(x); + assert!((c.from_linear(y) - x).abs() < 2e-3, "{c:?} {x}"); + } + } + assert!(Trc::Srgb.approx_eq(&Trc::Parametric(vec![2.4, 1.0 / 1.055, 0.055 / 1.055, 1.0 / 12.92, 0.04045]), 1e-5)); + } + + #[test] + fn working_matrix_identity_for_rec2020() { + let s = SourceSpace::named(NamedSpace::Rec2020, SpaceOrigin::Container); + assert_eq!(s.to_working(), Mat3::IDENTITY); + let p3 = SourceSpace::named(NamedSpace::DisplayP3, SpaceOrigin::Container); + let w = p3.to_working().apply([1.0, 1.0, 1.0]); + for c in w { + assert!((c - 1.0).abs() < 1e-6); + } + } +} diff --git a/crates/codecs/src/tiff_codec.rs b/crates/codecs/src/tiff_codec.rs new file mode 100644 index 00000000..3166c2dc --- /dev/null +++ b/crates/codecs/src/tiff_codec.rs @@ -0,0 +1,179 @@ +//! TIFF decode via the `tiff` crate: 8/16/32-bit integer, 16/32/64-bit float, gray/RGB/CMYK/palette, +//! alpha (associated or not), ICC (34675), XMP (700), orientation (274). First image only. + +use crate::convert::{Buf, Meta, Model, Raw, check_size, finish}; +use crate::{DecodeOptions, Decoded, Error, Format, Result}; +use tiff::ColorType; +use tiff::decoder::{Decoder, DecodingResult, Limits}; +use tiff::tags::Tag; + +const F: Format = Format::Tiff; + +fn err(e: impl std::fmt::Display) -> Error { + Error::Malformed(F, e.to_string()) +} + +pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { + let mut limits = Limits::default(); + limits.decoding_buffer_size = opts.max_pixels.saturating_mul(4 * 8).min(usize::MAX as u64) as usize; + limits.ifd_value_size = 32 << 20; + let mut d = Decoder::new(std::io::Cursor::new(bytes)).map_err(err)?.with_limits(limits); + let (w, h) = d.dimensions().map_err(err)?; + check_size(F, w as u64, h as u64, opts)?; + let ct = d.colortype().map_err(err)?; + + let icc = tag_bytes(&mut d, Tag::IccProfile); + let xmp = tag_bytes(&mut d, Tag::from_u16_exhaustive(700)).map(|v| String::from_utf8_lossy(&v).trim_end_matches('\0').to_string()); + let orientation = d.get_tag_u32(Tag::Orientation).ok().map(|v| v as u16); + let photometric = d.get_tag_u32(Tag::PhotometricInterpretation).ok(); + let planar = d.get_tag_u32(Tag::PlanarConfiguration).ok() == Some(2); + let extra = d.get_tag_u16_vec(Tag::ExtraSamples).ok().unwrap_or_default(); + let colormap = d.get_tag_u16_vec(Tag::ColorMap).ok(); + let compression = d.get_tag_u32(Tag::Compression).ok(); + + let (model, n_color, n_samples, bits) = match ct { + ColorType::Gray(b) => (Model::Gray, 1, 1, b), + ColorType::GrayA(b) => (Model::Gray, 1, 2, b), + ColorType::RGB(b) => (Model::Rgb, 3, 3, b), + ColorType::RGBA(b) => (Model::Rgb, 3, 4, b), + ColorType::CMYK(b) => (Model::Cmyk, 4, 4, b), + ColorType::CMYKA(b) => (Model::Cmyk, 4, 5, b), + ColorType::Palette(b) => (Model::Rgb, 1, 1, b), + // JPEG-compressed YCbCr is converted to RGB by the JPEG decoder. + ColorType::YCbCr(b) if compression == Some(7) => (Model::Rgb, 3, 3, b), + ColorType::Multiband { bit_depth, num_samples } if num_samples >= 3 => (Model::Rgb, 3, num_samples as usize, bit_depth), + ColorType::Multiband { bit_depth, num_samples } if num_samples >= 1 => (Model::Gray, 1, num_samples as usize, bit_depth), + other => return Err(Error::Unsupported(F, unsupported_name(other))), + }; + let has_alpha = n_samples > n_color && !matches!(ct, ColorType::Palette(_)); + let premultiplied = has_alpha && extra.first() == Some(&1); + + let res = d.read_image().map_err(err)?; + let (wu, hu) = (w as usize, h as usize); + let n = wu * hu; + let mut buf = match res { + DecodingResult::U8(v) => Buf::U8(v), + DecodingResult::U16(v) => Buf::U16(v), + // Rare integer widths: keep 16 significant bits (plenty for display-referred data). + DecodingResult::U32(v) => Buf::U16(v.into_iter().map(|x| (x >> 16) as u16).collect()), + DecodingResult::U64(v) => Buf::U16(v.into_iter().map(|x| (x >> 48) as u16).collect()), + DecodingResult::I8(v) => Buf::U8(v.into_iter().map(|x| (x.max(0) as u8) << 1).collect()), + DecodingResult::I16(v) => Buf::U16(v.into_iter().map(|x| (x.max(0) as u16) << 1).collect()), + DecodingResult::I32(v) => Buf::U16(v.into_iter().map(|x| (x.max(0) >> 15) as u16).collect()), + DecodingResult::I64(v) => Buf::U16(v.into_iter().map(|x| (x.max(0) >> 47) as u16).collect()), + DecodingResult::F16(v) => Buf::F32(v.into_iter().map(|x| x.to_f32()).collect()), + DecodingResult::F32(v) => Buf::F32(v), + DecodingResult::F64(v) => Buf::F32(v.into_iter().map(|x| x as f32).collect()), + }; + let bit_depth = bits; + + if planar && n_samples > 1 { + buf = interleave(buf, n, n_samples); + } + + // Palette → 16-bit RGB. + if let ColorType::Palette(b) = ct { + let map = colormap.ok_or_else(|| err("palette image without ColorMap"))?; + let entries = 1usize << b.min(16); + if map.len() < 3 * entries { + return Err(err("short ColorMap")); + } + let idx: Vec = match &buf { + Buf::U8(v) => v.iter().map(|&i| i as usize).collect(), + Buf::U16(v) => v.iter().map(|&i| i as usize).collect(), + Buf::F32(_) => return Err(err("float palette")), + }; + let mut out = Vec::with_capacity(n * 3); + for &i in idx.iter().take(n) { + let i = i.min(entries - 1); + out.extend_from_slice(&[map[i], map[entries + i], map[2 * entries + i]]); + } + buf = Buf::U16(out); + let raw = Raw { width: wu, height: hu, model: Model::Rgb, alpha: false, premultiplied: false, buf, bit_depth }; + let meta = Meta { icc, xmp, orientation, ..Default::default() }; + return finish(F, raw, meta, (w, h), opts); + } + + // WhiteIsZero → invert. + if photometric == Some(0) { + match &mut buf { + Buf::U8(v) => v.iter_mut().for_each(|x| *x = 255 - *x), + Buf::U16(v) => v.iter_mut().for_each(|x| *x = 65535 - *x), + Buf::F32(v) => v.iter_mut().for_each(|x| *x = 1.0 - *x), + } + } + + // Drop extra samples beyond colour + one alpha. + let keep = n_color + has_alpha as usize; + if n_samples != keep { + buf = select_channels(buf, n, n_samples, keep); + } + let raw = Raw { width: wu, height: hu, model, alpha: has_alpha, premultiplied, buf, bit_depth }; + let meta = Meta { icc, xmp, orientation, ..Default::default() }; + finish(F, raw, meta, (w, h), opts) +} + +/// A BYTE/UNDEFINED tag as bytes (the decoder may surface unknown byte tags as unsigned values). +fn tag_bytes(d: &mut Decoder, tag: Tag) -> Option> { + use tiff::decoder::ifd::Value; + fn byte(v: &Value) -> Option { + match v { + Value::Byte(b) => Some(*b), + Value::Unsigned(u) => u8::try_from(*u).ok(), + Value::Short(u) => u8::try_from(*u).ok(), + Value::SignedByte(b) => Some(*b as u8), + _ => None, + } + } + match d.find_tag(tag).ok()?? { + Value::List(v) => v.iter().map(byte).collect(), + Value::Ascii(s) => Some(s.into_bytes()), + v => byte(&v).map(|b| vec![b]), + } +} + +fn unsupported_name(ct: ColorType) -> &'static str { + match ct { + ColorType::Lab(_) => "CIE L*a*b* TIFF", + ColorType::YCbCr(_) => "uncompressed YCbCr TIFF", + _ => "colour type", + } +} + +fn interleave(buf: Buf, n: usize, s: usize) -> Buf { + fn go(v: Vec, n: usize, s: usize) -> Vec { + if v.len() < n * s { + return v; + } + let mut out = vec![T::default(); n * s]; + for c in 0..s { + for i in 0..n { + out[i * s + c] = v[c * n + i]; + } + } + out + } + match buf { + Buf::U8(v) => Buf::U8(go(v, n, s)), + Buf::U16(v) => Buf::U16(go(v, n, s)), + Buf::F32(v) => Buf::F32(go(v, n, s)), + } +} + +fn select_channels(buf: Buf, n: usize, s: usize, keep: usize) -> Buf { + fn go(v: Vec, n: usize, s: usize, keep: usize) -> Vec { + if v.len() < n * s { + return v; + } + let mut out = Vec::with_capacity(n * keep); + for px in v.chunks_exact(s).take(n) { + out.extend_from_slice(&px[..keep]); + } + out + } + match buf { + Buf::U8(v) => Buf::U8(go(v, n, s, keep)), + Buf::U16(v) => Buf::U16(go(v, n, s, keep)), + Buf::F32(v) => Buf::F32(go(v, n, s, keep)), + } +} diff --git a/crates/codecs/src/webp.rs b/crates/codecs/src/webp.rs new file mode 100644 index 00000000..ba4cd0c7 --- /dev/null +++ b/crates/codecs/src/webp.rs @@ -0,0 +1,26 @@ +//! WebP decode via `image-webp` (lossy + lossless, alpha, ICCP/EXIF/XMP chunks; first frame of +//! animations). + +use crate::convert::{Buf, Meta, Model, Raw, check_size, finish}; +use crate::{DecodeOptions, Decoded, Error, Format, Result}; + +const F: Format = Format::WebP; + +fn err(e: impl std::fmt::Display) -> Error { + Error::Malformed(F, e.to_string()) +} + +pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { + let mut d = image_webp::WebPDecoder::new(std::io::Cursor::new(bytes)).map_err(err)?; + let (w, h) = d.dimensions(); + check_size(F, w as u64, h as u64, opts)?; + let alpha = d.has_alpha(); + let size = d.output_buffer_size().ok_or_else(|| err("buffer size overflow"))?; + let mut buf = vec![0u8; size]; + d.read_image(&mut buf).map_err(err)?; + let icc = d.icc_profile().ok().flatten(); + let exif = d.exif_metadata().ok().flatten().map(|e| if e.starts_with(b"Exif\0\0") { e[6..].to_vec() } else { e }); + let xmp = d.xmp_metadata().ok().flatten().map(|x| String::from_utf8_lossy(&x).trim_end_matches('\0').to_string()); + let raw = Raw { width: w as usize, height: h as usize, model: Model::Rgb, alpha, premultiplied: false, buf: Buf::U8(buf), bit_depth: 8 }; + finish(F, raw, Meta { icc, exif, xmp, ..Default::default() }, (w, h), opts) +} diff --git a/crates/codecs/tests/roundtrip.rs b/crates/codecs/tests/roundtrip.rs new file mode 100644 index 00000000..bec61dca --- /dev/null +++ b/crates/codecs/tests/roundtrip.rs @@ -0,0 +1,452 @@ +//! Encode → decode round trips on generated fixtures. + +use lightcraft_codecs::exif::minimal_exif; +use lightcraft_codecs::icc::{self, write_named}; +use lightcraft_codecs::*; +use lightcraft_color::{DISPLAY_P3, REC2020, transfer::srgb_to_linear}; +use lightcraft_raster::Rgba8; + +fn gradient(w: usize, h: usize) -> Rgba8 { + Rgba8::from_fn(w, h, |x, y| [(x * 255 / w.max(2).saturating_sub(1).max(1)) as u8, (y * 255 / h.max(2)) as u8, ((x + y) % 256) as u8, 255]) +} + +fn smooth(w: usize, h: usize) -> Rgba8 { + Rgba8::from_fn(w, h, |x, y| { + let fx = x as f32 / w as f32; + let fy = y as f32 / h as f32; + [(fx * 255.0) as u8, (fy * 255.0) as u8, ((1.0 - fx) * 200.0) as u8, (((fx + fy) * 0.5) * 255.0) as u8] + }) +} + +fn psnr8(a: &Rgba8, b: &Rgba8) -> f64 { + let mut se = 0.0f64; + for (p, q) in a.data.iter().zip(&b.data) { + for c in 0..3 { + let d = p[c] as f64 - q[c] as f64; + se += d * d; + } + } + let mse = se / (a.data.len() * 3) as f64; + if mse == 0.0 { f64::INFINITY } else { 10.0 * (255.0 * 255.0 / mse).log10() } +} + +#[test] +fn jpeg_roundtrip_with_metadata() { + let img = smooth(97, 61); + let icc = write_named(NamedSpace::Srgb); + let exif = minimal_exif(6); + let xmp = r#""#; + let meta = EncodeMeta { icc: Some(&icc), exif: Some(&exif), xmp: Some(xmp) }; + for sub in [ChromaSubsampling::S444, ChromaSubsampling::S422, ChromaSubsampling::S420] { + let bytes = encode_jpeg(&EncodeImage::rgba8(&img), 95, sub, &meta).unwrap(); + assert_eq!(sniff(&bytes), Some(Format::Jpeg)); + let d = decode(&bytes, DecodeOptions::default()).unwrap(); + assert_eq!((d.width, d.height), (97, 61)); + assert_eq!(d.orientation, 6, "orientation passes through, not applied"); + assert_eq!(d.exif.as_deref(), Some(&exif[..])); + assert_eq!(d.xmp.as_deref(), Some(xmp)); + assert_eq!(d.icc.as_deref(), Some(&icc[..])); + assert_eq!(d.space.named, Some(NamedSpace::Srgb)); + assert_eq!(d.space.origin, SpaceOrigin::IccMatrixTrc); + assert!(!d.has_alpha); + let back = d.to_srgb8(); + let p = psnr8(&img, &back); + assert!(p > 32.0, "{sub:?} psnr {p}"); + } +} + +#[test] +fn jpeg_gray_and_scaled_decode() { + let g: Vec = (0..128 * 96).map(|i| (i % 128 * 2) as u8).collect(); + let bytes = encode_jpeg(&EncodeImage::new(128, 96, 1, Samples::U8(&g)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap(); + let d = decode(&bytes, DecodeOptions::default()).unwrap(); + assert!(d.grayscale); + assert_eq!(d.space.origin, SpaceOrigin::Untagged); + let p = d.image.get(64, 10); + assert!((p[0] - p[1]).abs() < 1e-6 && (p[1] - p[2]).abs() < 1e-6); + + let big = smooth(1000, 600); + let bytes = encode_jpeg(&EncodeImage::rgba8(&big), 85, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap(); + let d = decode(&bytes, DecodeOptions::fit(100, 100)).unwrap(); + assert_eq!((d.width, d.height), (100, 60)); + assert_eq!((d.source_width, d.source_height), (1000, 600)); + // Colour of the scaled decode matches a box-filtered full decode. + let full = decode(&bytes, DecodeOptions::default()).unwrap(); + let a = d.image.get(50, 30); + let b = full.image.get(505, 305); + for c in 0..3 { + assert!((a[c] - b[c]).abs() < 0.03, "{a:?} {b:?}"); + } +} + +#[test] +fn jpeg_cmyk_converts() { + // 0 = no ink: white, cyan, black. + let px: Vec = [[0u8, 0, 0, 0], [255, 0, 0, 0], [0, 0, 0, 255]].iter().flat_map(|p| p.repeat(16 * 16)).collect(); + let img: Vec = px; + let mut out = Vec::new(); + let enc = jpeg_encoder::Encoder::new(&mut out, 95); + enc.encode(&img, 16, 48, jpeg_encoder::ColorType::Cmyk).unwrap(); + let d = decode(&out, DecodeOptions::default()).unwrap(); + assert!(d.cmyk); + assert_eq!(d.space.origin, SpaceOrigin::Naive); + let white = d.image.get(8, 8); + let cyan = d.image.get(8, 24); + let black = d.image.get(8, 40); + assert!(white.iter().all(|&v| v > 0.9), "{white:?}"); + assert!(cyan[0] < 0.05 && cyan[1] > 0.8 && cyan[2] > 0.8, "{cyan:?}"); + assert!(black.iter().all(|&v| v < 0.02), "{black:?}"); +} + +#[test] +fn png_8_and_16_bit_exact() { + let img = gradient(33, 17); + let bytes = encode_png(&EncodeImage::rgba8(&img), &EncodeMeta::default()).unwrap(); + let d = decode(&bytes, DecodeOptions::default()).unwrap(); + assert!(d.has_alpha); + assert_eq!(d.bit_depth, 8); + assert_eq!(d.to_srgb8(), img); + + // 16-bit: precision preserved exactly through linearization. + let (w, h) = (64u32, 8u32); + let data: Vec = (0..w * h * 3).map(|i| (i * 1021 % 65536) as u16).collect(); + let bytes = encode_png(&EncodeImage::new(w, h, 3, Samples::U16(&data)), &EncodeMeta::default()).unwrap(); + let d = decode(&bytes, DecodeOptions::default()).unwrap(); + assert_eq!(d.bit_depth, 16); + for (i, p) in d.image.data.iter().enumerate() { + for c in 0..3 { + let want = Trc::Srgb.to_linear(data[i * 3 + c] as f32 / 65535.0); + assert!((p[c] - want).abs() < 1e-6); + } + } + // Two adjacent 16-bit codes stay distinct. + let data = [30000u16, 30000, 30000, 30001, 30001, 30001]; + let bytes = encode_png(&EncodeImage::new(2, 1, 3, Samples::U16(&data)), &EncodeMeta::default()).unwrap(); + let d = decode(&bytes, DecodeOptions::default()).unwrap(); + assert!(d.image.get(1, 0)[0] > d.image.get(0, 0)[0]); +} + +#[test] +fn png_metadata_and_gray_alpha() { + let data: Vec = (0..20 * 10).flat_map(|i| [(i % 256) as u8, 128]).collect(); + let exif = minimal_exif(3); + let meta = EncodeMeta { icc: None, exif: Some(&exif), xmp: Some("") }; + let bytes = encode_png(&EncodeImage::new(20, 10, 2, Samples::U8(&data)), &meta).unwrap(); + let d = decode(&bytes, DecodeOptions::default()).unwrap(); + assert!(d.grayscale && d.has_alpha); + assert_eq!(d.orientation, 3); + assert_eq!(d.xmp.as_deref(), Some("<xmp/>")); + let a = d.alpha.as_ref().unwrap().get(0, 0); + assert!((a - 128.0 / 255.0).abs() < 1e-6); +} + +#[test] +fn p3_icc_to_working() { + let icc = write_named(NamedSpace::DisplayP3); + let data = [255u8, 0, 0, 0, 255, 0, 128, 128, 128]; + let bytes = encode_png(&EncodeImage::new(3, 1, 3, Samples::U8(&data)), &EncodeMeta { icc: Some(&icc), ..Default::default() }).unwrap(); + let d = decode(&bytes, DecodeOptions::default()).unwrap(); + assert_eq!(d.space.named, Some(NamedSpace::DisplayP3)); + assert_eq!(d.space.origin, SpaceOrigin::IccMatrixTrc); + let wk = to_working(&d); + let m = DISPLAY_P3.to_space(&REC2020); + for (i, px) in [[1.0f32, 0.0, 0.0], [0.0, 1.0, 0.0], [srgb_to_linear(128.0 / 255.0); 3]].iter().enumerate() { + let want = m.apply_f32(*px); + let got = wk.get(i, 0); + for c in 0..3 { + assert!((got[c] - want[c]).abs() < 1e-3, "{i}: {got:?} vs {want:?}"); + } + } + // Gray stays neutral in the working space. + let g = wk.get(2, 0); + assert!((g[0] - g[1]).abs() < 1e-4 && (g[1] - g[2]).abs() < 1e-4); +} + +#[test] +fn custom_matrix_icc_is_exact() { + // A non-standard space: goes through the generic matrix/TRC path, not the named one. + let space = lightcraft_color::RgbSpace { + name: "Custom", + r: lightcraft_color::Xy::new(0.66, 0.32), + g: lightcraft_color::Xy::new(0.25, 0.65), + b: lightcraft_color::Xy::new(0.14, 0.07), + white: lightcraft_color::D65, + }; + let icc = icc::write_matrix_trc(&space, &Trc::Gamma(2.0)); + let data = [255u8, 0, 0]; + let bytes = encode_png(&EncodeImage::new(1, 1, 3, Samples::U8(&data)), &EncodeMeta { icc: Some(&icc), ..Default::default() }).unwrap(); + let d = decode(&bytes, DecodeOptions::default()).unwrap(); + assert_eq!(d.space.named, None); + let got = to_working(&d).get(0, 0); + let want = space.to_space(&REC2020).apply_f32([1.0, 0.0, 0.0]); + for c in 0..3 { + assert!((got[c] - want[c]).abs() < 2e-3, "{got:?} vs {want:?}"); + } +} + +#[test] +fn tiff_depths_and_compressions() { + let (w, h) = (40u32, 24u32); + let n = (w * h) as usize; + let u8s: Vec<u8> = (0..n * 3).map(|i| (i * 7 % 256) as u8).collect(); + let u16s: Vec<u16> = (0..n * 4).map(|i| (i * 977 % 65536) as u16).collect(); + let f32s: Vec<f32> = (0..n * 3).map(|i| i as f32 / 100.0 - 3.0).collect(); + let icc = write_named(NamedSpace::AdobeRgb); + for comp in [TiffCompression::None, TiffCompression::Lzw, TiffCompression::Deflate, TiffCompression::PackBits] { + let b = encode_tiff(&EncodeImage::new(w, h, 3, Samples::U8(&u8s)), comp, &EncodeMeta::default()).unwrap(); + assert_eq!(sniff(&b), Some(Format::Tiff)); + let d = decode(&b, DecodeOptions::default()).unwrap(); + assert_eq!(d.bit_depth, 8); + for (i, p) in d.image.data.iter().enumerate() { + assert!((p[1] - srgb_to_linear(u8s[i * 3 + 1] as f32 / 255.0)).abs() < 1e-6); + } + + let meta = EncodeMeta { icc: Some(&icc), xmp: Some("<x/>"), ..Default::default() }; + let b = encode_tiff(&EncodeImage::new(w, h, 4, Samples::U16(&u16s)), comp, &meta).unwrap(); + let d = decode(&b, DecodeOptions::default()).unwrap(); + assert_eq!(d.bit_depth, 16); + assert!(d.has_alpha); + assert_eq!(d.space.named, Some(NamedSpace::AdobeRgb)); + assert_eq!(d.xmp.as_deref(), Some("<x/>")); + let a = d.alpha.as_ref().unwrap(); + for i in 0..n { + assert!((a.data[i] - u16s[i * 4 + 3] as f32 / 65535.0).abs() < 1e-6); + let want = Trc::Gamma(563.0 / 256.0).to_linear(u16s[i * 4] as f32 / 65535.0); + assert!((d.image.data[i][0] - want).abs() < 1e-4, "{} vs {want}", d.image.data[i][0]); + } + + // Float: exact (untagged float is taken as linear), including out-of-range values. + let b = encode_tiff(&EncodeImage::new(w, h, 3, Samples::F32(&f32s)), comp, &EncodeMeta::default()).unwrap(); + let d = decode(&b, DecodeOptions::default()).unwrap(); + assert!(d.float); + assert_eq!(d.bit_depth, 32); + for (i, p) in d.image.data.iter().enumerate() { + assert_eq!(p[2], f32s[i * 3 + 2]); + } + } +} + +#[test] +fn tiff_cmyk_and_orientation() { + use tiff::encoder::{TiffEncoder, colortype}; + let mut cur = std::io::Cursor::new(Vec::new()); + { + let mut enc = TiffEncoder::new(&mut cur).unwrap(); + let mut im = enc.new_image::<colortype::CMYK8>(2, 1).unwrap(); + im.encoder().write_tag(tiff::tags::Tag::Orientation, 8u16).unwrap(); + im.write_data(&[0u8, 0, 0, 0, 0, 255, 255, 0]).unwrap(); + } + let d = decode(&cur.into_inner(), DecodeOptions::default()).unwrap(); + assert!(d.cmyk); + assert_eq!(d.orientation, 8); + let white = d.image.get(0, 0); + let red = d.image.get(1, 0); + assert!(white.iter().all(|&v| (v - 1.0).abs() < 1e-5), "{white:?}"); + assert!(red[0] > 0.99 && red[1] < 0.01 && red[2] < 0.01, "{red:?}"); +} + +#[test] +fn webp_lossless_exact() { + let img = gradient(31, 19); + let icc = write_named(NamedSpace::Srgb); + let exif = minimal_exif(2); + let meta = EncodeMeta { icc: Some(&icc), exif: Some(&exif), xmp: Some("<w/>") }; + let bytes = encode_webp_lossless(&EncodeImage::rgba8(&img), &meta).unwrap(); + assert_eq!(sniff(&bytes), Some(Format::WebP)); + let d = decode(&bytes, DecodeOptions::default()).unwrap(); + assert_eq!(d.to_srgb8(), img); + assert_eq!(d.orientation, 2); + assert_eq!(d.icc.as_deref(), Some(&icc[..])); + assert_eq!(d.xmp.as_deref(), Some("<w/>")); +} + +#[test] +fn gif_and_bmp_decode() { + use image::ImageEncoder; + let img = gradient(16, 8); + let rgba = img.as_bytes(); + let mut bmp = Vec::new(); + image::codecs::bmp::BmpEncoder::new(&mut bmp).write_image(&rgba, 16, 8, image::ExtendedColorType::Rgba8).unwrap(); + assert_eq!(sniff(&bmp), Some(Format::Bmp)); + let d = decode(&bmp, DecodeOptions::default()).unwrap(); + assert_eq!(d.to_srgb8(), img); + + let mut gif = Vec::new(); + { + let mut enc = image::codecs::gif::GifEncoder::new(&mut gif); + let flat = Rgba8::filled(16, 8, [10, 200, 30, 255]); + let buf = image::RgbaImage::from_raw(16, 8, flat.as_bytes()).unwrap(); + enc.encode_frame(image::Frame::new(buf)).unwrap(); + } + assert_eq!(sniff(&gif), Some(Format::Gif)); + let d = decode(&gif, DecodeOptions::default()).unwrap(); + let p = d.to_srgb8().get(3, 3); + assert!((p[0] as i32 - 10).abs() <= 2 && (p[1] as i32 - 200).abs() <= 2, "{p:?}"); +} + +/// Hand-built PSD: RGB 8-bit, raw and RLE merged data, ICC resource, negative layer count (alpha). +fn build_psd(rle: bool, with_alpha: bool, icc: &[u8]) -> Vec<u8> { + let (w, h) = (3u32, 2u32); + let channels: u16 = if with_alpha { 4 } else { 3 }; + let mut b = b"8BPS".to_vec(); + b.extend_from_slice(&1u16.to_be_bytes()); + b.extend_from_slice(&[0; 6]); + b.extend_from_slice(&channels.to_be_bytes()); + b.extend_from_slice(&h.to_be_bytes()); + b.extend_from_slice(&w.to_be_bytes()); + b.extend_from_slice(&8u16.to_be_bytes()); + b.extend_from_slice(&3u16.to_be_bytes()); + b.extend_from_slice(&0u32.to_be_bytes()); // colour mode data + // resources: ICC + let mut res = b"8BIM".to_vec(); + res.extend_from_slice(&1039u16.to_be_bytes()); + res.extend_from_slice(&[0, 0]); // empty pascal name, padded + res.extend_from_slice(&(icc.len() as u32).to_be_bytes()); + res.extend_from_slice(icc); + if icc.len() % 2 == 1 { + res.push(0); + } + b.extend_from_slice(&(res.len() as u32).to_be_bytes()); + b.extend_from_slice(&res); + // layer & mask info: layer info length + negative layer count + if with_alpha { + let mut lm = Vec::new(); + lm.extend_from_slice(&2u32.to_be_bytes()); + lm.extend_from_slice(&(-1i16).to_be_bytes()); + b.extend_from_slice(&(lm.len() as u32).to_be_bytes()); + b.extend_from_slice(&lm); + } else { + b.extend_from_slice(&0u32.to_be_bytes()); + } + let planes: Vec<[u8; 6]> = vec![[255, 0, 0, 10, 20, 30], [0, 255, 0, 40, 50, 60], [0, 0, 255, 70, 80, 90], [255, 255, 128, 0, 64, 255]]; + let planes = &planes[..channels as usize]; + if rle { + b.extend_from_slice(&1u16.to_be_bytes()); + // Each row: literal run of 3 bytes = [2, a, b, c] (4 bytes) + for _ in 0..channels as usize * h as usize { + b.extend_from_slice(&4u16.to_be_bytes()); + } + for p in planes { + for row in p.chunks(3) { + b.push(2); + b.extend_from_slice(row); + } + } + } else { + b.extend_from_slice(&0u16.to_be_bytes()); + for p in planes { + b.extend_from_slice(p); + } + } + b +} + +#[test] +fn psd_composite() { + let icc = write_named(NamedSpace::ProPhoto); + for rle in [false, true] { + for alpha in [false, true] { + let b = build_psd(rle, alpha, &icc); + assert_eq!(sniff(&b), Some(Format::Psd)); + let d = decode(&b, DecodeOptions::default()).unwrap(); + assert_eq!((d.width, d.height), (3, 2)); + assert_eq!(d.has_alpha, alpha); + assert_eq!(d.space.named, Some(NamedSpace::ProPhoto)); + let p = d.image.get(0, 0); + assert!((p[0] - 1.0).abs() < 1e-5 && p[1].abs() < 1e-6 && p[2].abs() < 1e-6); + let q = d.image.get(1, 1); + assert!((q[0] - Trc::Gamma(1.8).to_linear(20.0 / 255.0)).abs() < 1e-4); + if alpha { + assert!((d.alpha.as_ref().unwrap().get(2, 1) - 1.0).abs() < 1e-6); + } + } + } +} + +#[test] +fn thumbnail_prefers_embedded_exif() { + // Main image 800x600 with a 200x150 EXIF thumbnail of a different colour. + let main = Rgba8::filled(800, 600, [200, 30, 30, 255]); + let thumb = Rgba8::filled(200, 150, [30, 30, 200, 255]); + let thumb_jpeg = encode_jpeg(&EncodeImage::rgba8(&thumb), 80, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap(); + let exif = exif_with_thumbnail(&thumb_jpeg, 6); + let bytes = + encode_jpeg(&EncodeImage::rgba8(&main), 80, ChromaSubsampling::S420, &EncodeMeta { exif: Some(&exif), ..Default::default() }).unwrap(); + + let t = decode_thumbnail(&bytes, 128).unwrap(); + assert_eq!(t.source, ThumbnailSource::ExifThumbnail); + assert_eq!(t.orientation, 6); + assert_eq!((t.image.width, t.image.height), (128, 96)); + assert!(t.image.get(10, 10)[2] > 150); + assert_eq!((t.source_width, t.source_height), (800, 600)); + + // Asking for more than the thumbnail offers falls back to a scaled decode. + let t = decode_thumbnail(&bytes, 256).unwrap(); + assert_eq!(t.source, ThumbnailSource::Scaled); + assert_eq!((t.image.width, t.image.height), (256, 192)); + assert!(t.image.get(10, 10)[0] > 150); +} + +/// Little-endian EXIF with IFD0 (orientation) → IFD1 (JPEGInterchangeFormat/Length) + the thumbnail. +fn exif_with_thumbnail(jpeg: &[u8], orientation: u16) -> Vec<u8> { + let mut v = b"II*\0".to_vec(); + v.extend_from_slice(&8u32.to_le_bytes()); + // IFD0 at 8: 1 entry, next IFD at 26 + v.extend_from_slice(&1u16.to_le_bytes()); + v.extend_from_slice(&0x0112u16.to_le_bytes()); + v.extend_from_slice(&3u16.to_le_bytes()); + v.extend_from_slice(&1u32.to_le_bytes()); + v.extend_from_slice(&orientation.to_le_bytes()); + v.extend_from_slice(&[0, 0]); + v.extend_from_slice(&26u32.to_le_bytes()); + // IFD1 at 26: 2 entries (30 bytes), data at 56 + v.extend_from_slice(&2u16.to_le_bytes()); + v.extend_from_slice(&0x0201u16.to_le_bytes()); + v.extend_from_slice(&4u16.to_le_bytes()); + v.extend_from_slice(&1u32.to_le_bytes()); + v.extend_from_slice(&56u32.to_le_bytes()); + v.extend_from_slice(&0x0202u16.to_le_bytes()); + v.extend_from_slice(&4u16.to_le_bytes()); + v.extend_from_slice(&1u32.to_le_bytes()); + v.extend_from_slice(&(jpeg.len() as u32).to_le_bytes()); + v.extend_from_slice(&0u32.to_le_bytes()); + assert_eq!(v.len(), 56); + v.extend_from_slice(jpeg); + v +} + +#[test] +fn working_space_identity_for_srgb_white() { + let img = Rgba8::filled(4, 4, [255, 255, 255, 255]); + let b = encode_png(&EncodeImage::rgba8(&img), &EncodeMeta::default()).unwrap(); + let d = decode(&b, DecodeOptions::default()).unwrap(); + let w = to_working(&d).get(1, 1); + for c in w { + assert!((c - 1.0).abs() < 1e-5); + } +} + +#[test] +fn avif_encode_native() { + let img = smooth(64, 48); + let r = encode_avif(&EncodeImage::rgba8(&img), 70, 10, &EncodeMeta::default()); + if cfg!(feature = "avif") { + let b = r.unwrap(); + assert_eq!(sniff(&b), Some(Format::Avif)); + assert!(matches!(decode(&b, DecodeOptions::default()), Err(Error::Unsupported(Format::Avif, _)))); + } else { + assert!(r.is_err()); + } +} + +#[test] +fn raw_routing() { + let mut b = b"II*\0\x08\0\0\0".to_vec(); + b.extend_from_slice(&1u16.to_le_bytes()); + b.extend_from_slice(&0xC612u16.to_le_bytes()); + b.extend_from_slice(&1u16.to_le_bytes()); + b.extend_from_slice(&4u32.to_le_bytes()); + b.extend_from_slice(&[1, 4, 0, 0]); + b.extend_from_slice(&0u32.to_le_bytes()); + assert!(matches!(decode(&b, DecodeOptions::default()), Err(Error::Unsupported(Format::RawTiffLike, _)))); +}