diff --git a/ASSETS.md b/ASSETS.md index 34ff73361..17ab680ff 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -368,6 +368,18 @@ Generated-in-code art is original and has no file to list. This covers the defau | `crates/engine/tests/fixtures/jpeg2000/checker.jp2` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | a 2 × 2 checker encoded with FFmpeg's JPEG 2000 encoder; SHA-256 `3de9c2bb5b8aaec241d939b795002a68eb3d50e41d51c43368000804ad3de057` | | `crates/engine/tests/fixtures/jpeg2000/checker-raw.j2k` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | a 2 × 2 checker encoded with FFmpeg's JPEG 2000 encoder; SHA-256 `76fa51ab8ccbc2534098b26b8e7fe16dd886e2b6298ba52d06a6b1bde9b61d9e` | | `crates/engine/tests/fixtures/jpeg2000/checker-300ppi.jp2` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | a 2 × 2 checker encoded with FFmpeg's JPEG 2000 encoder; SHA-256 `1b1aeed8975d3cdc832da6ecf5855852d07d0c490105121e61a8aefb31af009b` | +| `crates/engine/tests/fixtures/heif/quadrants.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | 32 × 32 red, green, blue and white quadrants encoded with libheif's `heif-enc` 1.23.2 (x265); SHA-256 `122d14963fe899daa736cd216902cf4fc8f2192b3a0ba81f94004adcd71a0ff5` | +| `crates/engine/tests/fixtures/heif/quadrants-alpha.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the quadrants with a transparent right half, encoded with `heif-enc` 1.23.2; SHA-256 `55b5a537401ead742714c3a1bc526559ec8c1ba97cdb7f43ac63f4037167475b` | +| `crates/engine/tests/fixtures/heif/quadrants-10bit.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the quadrants at 10 bits, encoded with `heif-enc` 1.23.2; SHA-256 `11014848c6a838e5799d87e034eff1b06b816712ed70ba451bd582d72741774f` | +| `crates/engine/tests/fixtures/heif/quadrants-grid.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | 128 × 128 quadrants as a 2 × 2 grid of 64 × 64 tiles, encoded with `heif-enc` 1.23.2; SHA-256 `5802f8715110954eb9c58d4bdcc10d1200426a48cf726f1b88dfb60da825ba7a` | +| `crates/engine/tests/fixtures/heif/quadrants-300ppi.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the quadrants with EXIF resolution tags of 300 ppi, encoded with `heif-enc` 1.23.2; SHA-256 `6a344a6de04fc5fceaee46b6e0028b12478c90bdcb6f6f14ed8c86a44cb203d7` | +| `crates/engine/tests/fixtures/heif/wide-rotated.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | 32 × 16 red and blue halves stored with a quarter-turn rotation, encoded with `heif-enc` 1.23.2; SHA-256 `e3a2c3a9beb4066c13d6deb8e1311805c6aac485fde8784f1ee317dd95b89973` | +| `crates/engine/tests/fixtures/heif/wide-rotated-300x150.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the rotated halves with EXIF resolution tags of 300 ppi across and 150 ppi down, encoded with `heif-enc` 1.23.2; SHA-256 `79ad7945cd2c1ea9d3e70ac9b93d9a162fd8ce37541a0f3de44e12f04745921d` | +| `crates/engine/tests/fixtures/heif/tones.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | 32 × 32 quadrants of four mid tones, encoded with `heif-enc` 1.23.2; SHA-256 `29ed7d75fbfbc4c298eb2da3a6b62625a150cd6bfe5b7ff1d847437501b1b4b1` | +| `crates/engine/tests/fixtures/heif/tones-p3-nclx.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the tones labeled Display P3 in an `nclx` color box, encoded with `heif-enc` 1.23.2; SHA-256 `baeb32ffa58ac1ac29a00c9e388b9281f4acb4695c4681e4933d0ad061447101` | +| `crates/engine/tests/fixtures/heif/tones-p3-icc.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the tones with VectorCraft's own Display P3 ICC profile (`vectorcraft_color::cms::icc_bytes`), encoded with `heif-enc` 1.23.2; SHA-256 `472e1a123b9515926a8985a04c3f680387e8c410dbf8f7ff0cfb8cb99c89e93b` | +| `crates/engine/tests/fixtures/heif/tones-p3-icc-only.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | the tones with VectorCraft's own Display P3 ICC profile as their only color box, encoded with `heif-enc` 1.23.2; SHA-256 `2654fdd21e56641107db20b3f4d0a66a86420ac040d42ddf55e20f7e789d5c55` | +| `crates/engine/tests/fixtures/heif/quadrants-grid16.heic` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | 32 × 32 quadrants as a 2 × 2 grid of 16 × 16 tiles, each cropped from a 64 × 64 picture, encoded with `heif-enc` 1.23.2; SHA-256 `bd114ee66cdac10142729dc9dddfa1cd96993a5cefa54c2f25b65496652f007f` | ## Assets embedded by the PDF backend diff --git a/Cargo.lock b/Cargo.lock index 72d591b91..287a23f0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2286,6 +2286,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "heic-rs" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8883c3bfa6b4ff19a40e52a6dc051c5612cbe21bf408fc6d4875e8dfd777cb66" +dependencies = [ + "rayon", +] + [[package]] name = "hermit-abi" version = "0.5.3" @@ -6246,10 +6255,12 @@ dependencies = [ "crc32fast", "flate2", "hayro-jpeg2000", + "heic-rs", "image", "kurbo 0.13.1", "log", "proptest", + "rayon", "roxmltree 0.21.2", "rustix 1.1.6", "ruzstd", diff --git a/apps/vectorcraft-cli/Cargo.toml b/apps/vectorcraft-cli/Cargo.toml index f7faf7a92..376f43190 100644 --- a/apps/vectorcraft-cli/Cargo.toml +++ b/apps/vectorcraft-cli/Cargo.toml @@ -16,7 +16,7 @@ workspace = true [dependencies] vectorcraft-color = { workspace = true } vectorcraft-doc = { workspace = true } -vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] } +vectorcraft-engine = { workspace = true, features = ["bundled-fonts", "heif"] } vectorcraft-format = { workspace = true } vectorcraft-geom = { workspace = true } vectorcraft-svg = { workspace = true } diff --git a/apps/vectorcraft-web/Cargo.toml b/apps/vectorcraft-web/Cargo.toml index ff4bcef86..6e4dbd7f0 100644 --- a/apps/vectorcraft-web/Cargo.toml +++ b/apps/vectorcraft-web/Cargo.toml @@ -14,7 +14,7 @@ name = "vectorcraft-web" path = "src/main.rs" [dependencies] -vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] } +vectorcraft-engine = { workspace = true, features = ["bundled-fonts", "heif"] } vectorcraft-ui-egui = { workspace = true } # The page command API (storytold/craft-libs#413): the request plumbing behind window.vectorcraft. craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" } diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index 170a7e5c7..4ab382d5c 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -15,7 +15,7 @@ wgpu = ["eframe/wgpu", "dep:winsafe"] accessibility = ["eframe/accesskit"] [dependencies] -vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] } +vectorcraft-engine = { workspace = true, features = ["bundled-fonts", "heif"] } vectorcraft-ui-egui = { workspace = true } vectorcraft-text = { workspace = true } # Shared across the Craft suite: decides who draws the window frame per desktop (compositor vs app) diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index eddf966db..0ebcb71c5 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -9,10 +9,14 @@ description = "VectorCraft engine: sessions, history, selection, the command reg workspace = true [features] -default = ["bundled-fonts"] +default = ["bundled-fonts", "heif"] # The fonts vectorcraft-text bundles (see its `bundled-fonts`). Embedders that supply every font at # run time (`vectorcraft_text::FontDb::add_font`) build with `default-features = false`. bundled-fonts = ["vectorcraft-text/bundled-fonts"] +# HEIC and HEIF images open and place, decoded by heic-rs (an HEVC decoder). A build without this +# feature still recognizes the files and refuses them with a message. The apps list it beside +# `bundled-fonts`. +heif = ["dep:heic-rs", "dep:rayon"] [dependencies] # Which paths stored in documents may be opened. @@ -37,6 +41,8 @@ vectorcraft-affinity = { workspace = true } vectorcraft-scriptcheck = { workspace = true } image = { workspace = true } hayro-jpeg2000 = { version = "=0.3.5", default-features = false, features = ["std", "simd"] } +# Pure Rust, no unsafe; pinned at the version photocraft-heif uses. +heic-rs = { version = "=0.1.4", default-features = false, features = ["std"], optional = true } flate2 = { workspace = true } chrono = { workspace = true } # The XML parts of Word documents placed as type (place/docx.rs). @@ -54,6 +60,11 @@ log = { workspace = true } [target.'cfg(unix)'.dependencies] rustix = { workspace = true, features = ["std", "process"] } +# The tiles of a HEIC photo decode in parallel on native; wasm stays single-threaded. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +heic-rs = { version = "=0.1.4", default-features = false, features = ["std", "parallel"], optional = true } +rayon = { version = "1.12", optional = true } + [dev-dependencies] vectorcraft-affinity = { workspace = true, features = ["synth"] } crc32fast = { workspace = true } diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index ae2ecde66..3f881b8e4 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -33,6 +33,7 @@ pub(crate) mod imagemap; mod legacy_ai; mod load; pub(crate) use load::{MAX_RASTER_ALLOC, size_note}; +mod heifread; mod jp2read; mod metafile; mod naming; @@ -203,6 +204,8 @@ mod tests_psd; #[cfg(test)] mod tests_psdread; +#[cfg(test)] +mod tests_heifread; #[cfg(test)] mod tests_jp2read; #[cfg(test)] diff --git a/crates/engine/src/cmd/fileio/formats.rs b/crates/engine/src/cmd/fileio/formats.rs index 33ecfa8ab..ef87ea1b6 100644 --- a/crates/engine/src/cmd/fileio/formats.rs +++ b/crates/engine/src/cmd/fileio/formats.rs @@ -503,6 +503,8 @@ pub const FORMATS: &[Format] = &[ reader("pxr", "Pixar", &["pxr"], "image/x-pixar", true), // JP2 and raw codestreams are decoded to PNG; JPX composition is not supported. reader("jpeg2000", "JPEG 2000", &["jpf", "jpx", "jp2", "j2k", "j2c", "jpc"], "image/jp2", true), + // HEVC-coded HEIF still images, decoded to PNG; image sequences are not read. + reader("heif", "HEIC/HEIF", &["heic", "heif"], "image/heic", true), ]; /// Every extension `document.open` reads (the "All readable files" filter of open dialogs). @@ -537,6 +539,8 @@ pub const OPEN_EXTS: &[&str] = &[ "j2k", "j2c", "jpc", + "heic", + "heif", "psd", "pdd", "psb", @@ -554,11 +558,13 @@ pub const OPEN_EXTS: &[&str] = &[ /// Extensions in [`OPEN_EXTS`] the packaging declares no type for: Photoshop documents belong to /// raster editors, so Finder and file managers don't offer a vector app for them, and there is no -/// `.epsf`, `.ps`, Targa, PCX, Pixar or JPEG 2000 type, nor one for the `.pns`, `.dib` and `.rle` names of +/// `.epsf`, `.ps`, Targa, PCX, Pixar, JPEG 2000 or HEIC/HEIF type, nor one for the `.pns`, `.dib` and `.rle` names of /// PNG and BMP files (File › Open and Place still read them all). On Linux the desktop entry's EPS /// type (shared-mime-info's) also covers `.epsf`, so file managers offer VectorCraft for it. -pub const UNASSOCIATED_EXTS: &[&str] = - &["psd", "pdd", "psb", "epsf", "ps", "pns", "dib", "rle", "tga", "vda", "icb", "vst", "pcx", "pxr", "jpf", "jpx", "jp2", "j2k", "j2c", "jpc"]; +pub const UNASSOCIATED_EXTS: &[&str] = &[ + "psd", "pdd", "psb", "epsf", "ps", "pns", "dib", "rle", "tga", "vda", "icb", "vst", "pcx", "pxr", "jpf", "jpx", "jp2", "j2k", "j2c", "jpc", + "heic", "heif", +]; /// The extension that picks each writable format when exporting (the format's first; PNG-8 shares /// `.png` with PNG, so `.png` comes once), in [`FORMATS`] order. @@ -621,6 +627,8 @@ pub const PLACE_EXTS: &[&str] = &[ "j2k", "j2c", "jpc", + "heic", + "heif", "psd", "pdd", "psb", diff --git a/crates/engine/src/cmd/fileio/heifread.rs b/crates/engine/src/cmd/fileio/heifread.rs new file mode 100644 index 000000000..4867022de --- /dev/null +++ b/crates/engine/src/cmd/fileio/heifread.rs @@ -0,0 +1,627 @@ +//! HEIC and HEIF images for File › Open and File › Place, decoded with heic-rs when the engine's +//! `heif` feature is on (the default). The image is bounded before decoding and converted to RGBA +//! for the common PNG storage path. A build without the feature still recognizes the files and +//! refuses them with a message. + +#[cfg(all(test, feature = "heif"))] +pub(super) use decoder::{active_sps, picture_bytes, sps_signal, sps_size}; +#[cfg(feature = "heif")] +pub(super) use decoder::{decode, quarter_turned, with_exif}; + +/// The brands heic-rs 0.1.4 reads: HEVC-coded images and image sequences (ISO/IEC 23008-12 annex +/// B) and the general image and sequence brands. +const BRANDS: [&[u8; 4]; 7] = [b"heic", b"heix", b"heim", b"heis", b"hevc", b"mif1", b"msf1"]; + +/// Recognize a HEIF file by its `ftyp` box: one of [`BRANDS`] as the major brand or a compatible +/// one, and no AVIF brand (`avif`, `avis`), as heic-rs refuses a file that lists one. The box is +/// read as JP2 boxes are ([`super::jp2read::boxes`]): ISO BMFF sizes, the 64-bit one included. +pub(super) fn is_heif(bytes: &[u8]) -> bool { + let Some((b"ftyp", ftyp)) = super::jp2read::boxes(bytes).next() else { return false }; + let Some(major) = ftyp.get(..4).and_then(|b| <&[u8; 4]>::try_from(b).ok()) else { return false }; + if ftyp.len() < 8 { + return false; + } + let compatible = ftyp.get(8..).unwrap_or_default().as_chunks::<4>().0; + let brands = || std::iter::once(major).chain(compatible); + !brands().any(|brand| matches!(brand, b"avif" | b"avis")) && brands().any(|brand| BRANDS.contains(&brand)) +} + +/// Without the `heif` feature every HEIF file is refused. +#[cfg(not(feature = "heif"))] +pub(super) fn decode(_bytes: &[u8], _max_alloc: u64) -> Result { + Err("HEIC and HEIF images can't be opened in this build".into()) +} + +/// Without the `heif` feature no image is decoded, so none is turned. +#[cfg(not(feature = "heif"))] +pub(super) fn quarter_turned(_bytes: &[u8]) -> bool { + false +} + +/// Without the `heif` feature no resolution is read. +#[cfg(not(feature = "heif"))] +pub(super) fn with_exif(_bytes: &[u8], _read: impl FnOnce(&[u8]) -> Option) -> Option { + None +} + +#[cfg(feature = "heif")] +mod decoder { + use heic_rs::context::Context; + use heic_rs::{DecodeOptions, Error, MatrixCoefficients, Nclx, PixelLayout, Range}; + + /// The message for a file that isn't a readable HEIF image. + const UNREADABLE: &str = "the HEIC or HEIF file can't be read"; + /// The message for an image sequence, an overlay, an image that isn't HEVC-coded, a grid whose + /// tiles are cropped, turned or mirrored, or an HEVC coding tool the decoder doesn't implement. + const UNSUPPORTED: &str = "the HEIC or HEIF file holds a kind of image that can't be read"; + + /// Decode the primary image within `max_alloc` bytes, counting the decoder's planes, the RGBA + /// output and the copy that rotating or cropping makes, with the container's rotation, mirror + /// and crop applied. An embedded RGB ICC profile, or `nclx` Display P3 primaries with the sRGB + /// curve, is converted to sRGB; other `nclx` values are read as sRGB. A panic inside the + /// decoder is reported as an unreadable file. + pub(in super::super) fn decode(bytes: &[u8], max_alloc: u64) -> Result { + crate::guard::catch_panic(|| decode_unguarded(bytes, max_alloc)).unwrap_or_else(|_| Err(UNREADABLE.into())) + } + + /// Whether the container's rotations of the primary image add up to an odd number of quarter + /// turns, so its width and height swap. The decoder applies every `irot` the item lists. + pub(in super::super) fn quarter_turned(bytes: &[u8]) -> bool { + let turned = || { + let ctx = Context::open(bytes).ok()?; + let props = ctx.props(ctx.meta.primary).ok()?; + let quarters = props.transforms.iter().filter(|t| matches!(t, heic_rs::props::Transform::Rotate(r) if r.swaps_axes())).count(); + Some(quarters % 2 == 1) + }; + crate::guard::catch_panic(turned).ok().flatten().unwrap_or(false) + } + + /// `read` of the primary image's EXIF block, a TIFF structure inside `bytes`, for its + /// resolution tags. + pub(in super::super) fn with_exif(bytes: &[u8], read: impl FnOnce(&[u8]) -> Option) -> Option { + let find = || { + let ctx = Context::open(bytes).ok()?; + ctx.exif(ctx.meta.primary).ok().flatten() + }; + crate::guard::catch_panic(find).ok().flatten().and_then(read) + } + + fn decode_unguarded(bytes: &[u8], max_alloc: u64) -> Result { + let refused = |e: Error| match e { + Error::Unsupported(_) | Error::MissingBox("hvcC" | "meta") => UNSUPPORTED.to_string(), + _ => UNREADABLE.to_string(), + }; + let info = heic_rs::probe(bytes).map_err(refused)?; + let ctx = Context::open(bytes).map_err(refused)?; + if unsupported_items(&ctx).ok_or(UNREADABLE)? { + return Err(UNSUPPORTED.into()); + } + // The stored size, before a crop: the size the decoder holds. + let too_large = || format!("an image of {} × {} pixels is too large to draw", info.coded_width, info.coded_height); + let declared = u64::from(info.coded_width).checked_mul(u64::from(info.coded_height)).ok_or_else(too_large)?; + // A grid is composed at the size its own data gives, which need not match `ispe`. + let grid = grid_pixels(&ctx).ok_or(UNREADABLE)?; + // A single picture is converted at the size its stream gives, which need not match `ispe` + // either. + let coded = declared.max(grid).max(stream_pixels(&ctx).ok_or(UNREADABLE)?); + let shown = u64::from(info.width).checked_mul(u64::from(info.height)).ok_or_else(too_large)?; + // The RGBA output, and a second RGBA image when the decoder rotates, mirrors or crops it, + // which is no larger than the first. + let output = coded.checked_mul(8).ok_or_else(too_large)?; + let pictures = picture_bytes_in(&ctx).ok_or(UNREADABLE)?; + // An alpha grid is composed into a frame of 16-bit planes (at most three samples a pixel), + // and so is the image's grid when it is decoded with a color description of its tiles or + // its stream. A single picture is converted in place. + let composed = if grid > 0 { coded.checked_mul(if info.has_alpha { 12 } else { 6 }).ok_or_else(too_large)? } else { 0 }; + if coded == 0 || shown == 0 || output.checked_add(pictures).and_then(|n| n.checked_add(composed)).is_none_or(|n| n > max_alloc) { + return Err(too_large()); + } + let max_pixels = max_alloc / 4; + let decoded = match tile_nclx(&ctx).or_else(|| stream_nclx(&ctx)) { + Some(nclx) => decode_with_nclx(&ctx, nclx, max_pixels), + None => heic_rs::decode(bytes, &DecodeOptions::default().with_layout(PixelLayout::Rgba8).with_max_pixels(Some(max_pixels))), + } + .map_err(|e| match e { + Error::PixelLimit { .. } | Error::BoxTooLarge { .. } => too_large(), + e => refused(e), + })?; + let mut img = image::RgbaImage::from_raw(decoded.width, decoded.height, decoded.data).ok_or(UNREADABLE)?; + if let Some(profile) = source_profile(&ctx) { + for pixel in img.pixels_mut() { + let [r, g, b, a] = pixel.0; + let device = [r, g, b].map(|v| f32::from(v) / 255.0); + if let Some(rgb) = profile.to_srgb_into(&device, vectorcraft_color::cms::Intent::RelativeColorimetric) { + let [r, g, b] = rgb.map(|v| (v * 255.0).round() as u8); + pixel.0 = [r, g, b, a]; + } + } + } + Ok(img) + } + + /// The primary image and its alpha image, when it has one. + fn primary_and_alpha(ctx: &Context<'_>) -> Option> { + let primary = ctx.meta.primary; + Some(std::iter::once(primary).chain(ctx.alpha_item(primary).ok()?)) + } + + /// Whether the primary image or its alpha image is neither an HEVC-coded item (`hvc1`, `hev1`) + /// nor a grid of them, or is a grid with a tile that carries a crop, rotation or mirror. heic-rs + /// 0.1.4 decodes any item with an `hvcC` whatever its type, and composes a grid from its tiles + /// as coded, without their transforms: an encoder that pads tiles smaller than its coding block + /// (64 × 64 for x265) and crops them back with `clap` gets the wrong pixels. + fn unsupported_items(ctx: &Context<'_>) -> Option { + let hevc = |id: u32| ctx.meta.item(id).is_some_and(|info| info.is_hevc()); + for item in primary_and_alpha(ctx)? { + let Some((_, tiles)) = ctx.grid(item).ok()? else { + if !hevc(item) { + return Some(true); + } + continue; + }; + for tile in tiles { + if !hevc(tile) || !ctx.props(tile).ok()?.transforms.is_empty() { + return Some(true); + } + } + } + Some(false) + } + + /// The pixels of the larger of the primary image's grid and its alpha image's grid, at the + /// output size each grid gives; 0 when neither is a grid. + fn grid_pixels(ctx: &Context<'_>) -> Option { + let mut largest = 0u64; + for item in primary_and_alpha(ctx)? { + if let Some((grid, _)) = ctx.grid(item).ok()? { + largest = largest.max(u64::from(grid.output_width) * u64::from(grid.output_height)); + } + } + Some(largest) + } + + /// The pixels of the larger of the primary image and its alpha image when either is a single + /// picture, at the size of its largest sequence parameter set (before its conformance window); + /// 0 when both are grids. `None` when a size can't be read. + fn stream_pixels(ctx: &Context<'_>) -> Option { + let mut largest = 0u64; + for item in primary_and_alpha(ctx)? { + if ctx.grid(item).ok()?.is_some() { + continue; + } + // An item without `hvcC` isn't decoded: heic-rs refuses the file. + let Some(hvcc) = ctx.props(item).ok()?.hvcc else { continue }; + for nal in hvcc.parameter_sets().into_iter().filter(|nal| nal_type(nal) == Some(SPS)) { + let (width, height, _) = sps_size(nal)?; + largest = largest.max(width.checked_mul(height)?); + } + } + Some(largest) + } + + /// The first tile's `nclx` when the primary image is a grid without an `nclx` of its own and + /// its tiles carry one. heic-rs 0.1.4 converts a grid's samples to RGB with the grid item's + /// `nclx` alone, and with its BT.709 limited-range default when the grid has none + /// (tbraun96/heic-rs#6). + fn tile_nclx(ctx: &Context<'_>) -> Option { + let primary = ctx.meta.primary; + if ctx.props(primary).ok()?.nclx.is_some() { + return None; + } + let (_, tiles) = ctx.grid(primary).ok()??; + ctx.props(*tiles.first()?).ok()?.nclx + } + + /// The color description in the HEVC stream's video usability information (ITU-T H.265 + /// E.2.1) of the primary image, or of its grid's first tile, when neither carries an `nclx` + /// (a file whose only color box is an ICC profile, or one without a color box). heic-rs 0.1.4 + /// reads only the container's `nclx` and otherwise converts with BT.709 limited range. + fn stream_nclx(ctx: &Context<'_>) -> Option { + let primary = ctx.meta.primary; + if ctx.props(primary).ok()?.nclx.is_some() { + return None; + } + let coded = match ctx.grid(primary).ok()? { + Some((_, tiles)) => *tiles.first()?, + None => primary, + }; + let props = ctx.props(coded).ok()?; + if props.nclx.is_some() { + return None; + } + let hvcc = props.hvcc?; + let data = ctx.item_data(coded).ok()?; + let slices = hvcc.split_nals(&data).ok()?; + active_sps(&hvcc.parameter_sets(), &slices).and_then(sps_signal) + } + + /// The SPS the first slice of `slices` uses: the one its PPS names (ITU-T H.265 7.4.3.3.1). + pub(in super::super) fn active_sps<'a>(parameter_sets: &[&'a [u8]], slices: &[&[u8]]) -> Option<&'a [u8]> { + let slice = slices.iter().find(|nal| nal_type(nal).is_some_and(|t| t < 32))?; + let bits = &mut Bits { data: &payload(slice), at: 0 }; + // first_slice_segment_in_pic_flag, no_output_of_prior_pics_flag in an IRAP picture (types 16 + // to 23), slice_pic_parameter_set_id. + bits.skip(1)?; + if nal_type(slice).is_some_and(|t| (16..=23).contains(&t)) { + bits.skip(1)?; + } + let pps_id = bits.exp_golomb()?; + // pps_pic_parameter_set_id, pps_seq_parameter_set_id. + let pps_ids = |nal: &[u8]| { + let bits = &mut Bits { data: &payload(nal), at: 0 }; + Some((bits.exp_golomb()?, bits.exp_golomb()?)) + }; + let sps_id = parameter_sets.iter().filter(|nal| nal_type(nal) == Some(PPS)).find_map(|nal| pps_ids(nal).filter(|(id, _)| *id == pps_id))?.1; + let sps_ids = |nal: &[u8]| { + let rbsp = sps_payload(nal)?; + sps_head(&mut Bits { data: &rbsp, at: 0 }).map(|(_, id, ..)| id) + }; + parameter_sets.iter().copied().find(|nal| sps_ids(nal) == Some(sps_id)) + } + + /// The primary image decoded as heic-rs 0.1.4 decodes it (a grid's tiles one after another), + /// with its samples converted to RGBA with `nclx`, the alpha image applied and the image's + /// rotation, mirror and crop applied. + fn decode_with_nclx(ctx: &Context<'_>, nclx: Nclx, max_pixels: u64) -> heic_rs::Result { + use heic_rs::hevc::Frame; + + fn coded(ctx: &Context<'_>, id: u32) -> heic_rs::Result { + let props = ctx.props(id)?; + let hvcc = props.hvcc.as_ref().ok_or(Error::MissingBox("hvcC"))?; + let data = ctx.item_data(id)?; + let frame = heic_rs::hevc::decode_still(&hvcc.parameter_sets(), &hvcc.split_nals(&data)?)?; + frame.validate()?; + Ok(frame) + } + fn item(ctx: &Context<'_>, id: u32, max_pixels: u64) -> heic_rs::Result { + match ctx.grid(id)? { + Some((grid, tiles)) => { + // The tiles decode in parallel on native, as heic-rs's own decode does. + #[cfg(not(target_arch = "wasm32"))] + let frames = { + use rayon::prelude::*; + tiles.par_iter().map(|&t| coded(ctx, t)).collect::>>()? + }; + #[cfg(target_arch = "wasm32")] + let frames = tiles.iter().map(|&t| coded(ctx, t)).collect::>>()?; + heic_rs::grid::compose(&grid, &frames, max_pixels) + } + None => coded(ctx, id), + } + } + + let primary = ctx.meta.primary; + let props = ctx.props(primary)?; + let frame = item(ctx, primary, max_pixels)?; + let alpha = ctx.alpha_item(primary)?.map(|a| item(ctx, a, max_pixels)).transpose()?; + // `None`: rayon's global pool on native, where heic-rs is built with `parallel`. + let image = heic_rs::color::convert(&frame, alpha.as_ref(), nclx, PixelLayout::Rgba8, max_pixels, None)?; + heic_rs::transform::apply_all(image, &props.transforms) + } + + /// The bytes heic-rs 0.1.4 allocates for the coded pictures of `bytes` it decodes (see + /// [`picture_bytes_in`]); `None` when they can't be read. + #[cfg(test)] + pub(in super::super) fn picture_bytes(bytes: &[u8]) -> Option { + picture_bytes_in(&Context::open(bytes).ok()?) + } + + /// The bytes heic-rs 0.1.4 allocates for the coded pictures it decodes: the primary image's, + /// or each tile of its grid, and its alpha image's the same way. Each picture is sized by the + /// largest of its sequence parameter sets (the size the decoder allocates, rounded up to whole + /// 64-sample coding blocks), not by the container, and takes three sets of 16-bit planes: the + /// picture, a filtered copy and the frame returned. `None` when an item's sizes can't be read. + fn picture_bytes_in(ctx: &Context<'_>) -> Option { + let mut total = 0u64; + for item in primary_and_alpha(ctx)? { + let coded = match ctx.grid(item).ok()? { + Some((_, tiles)) => tiles, + None => vec![item], + }; + for id in coded { + let props = ctx.props(id).ok()?; + // An item without `hvcC` isn't decoded: heic-rs refuses the file. + let Some(hvcc) = props.hvcc else { continue }; + // The slices pick their SPS through the PPS, so the largest one is counted. Every + // SPS must be read. + let mut largest = None; + for nal in hvcc.parameter_sets().into_iter().filter(|nal| nal_type(nal) == Some(SPS)) { + let (width, height, chroma) = sps_size(nal)?; + let blocks = |n: u64| n.div_ceil(64) * 64; + // Bytes a sample position takes in one set of planes: luma and the two chroma + // planes. + let planes = match chroma { + 0 => 2, + 1 => 3, + 2 => 4, + _ => 6, + }; + let picture = blocks(width).checked_mul(blocks(height))?.checked_mul(planes * 3)?; + largest = largest.max(Some(picture)); + } + total = total.checked_add(largest?)?; + } + } + Some(total) + } + + /// The NAL unit type of an HEVC sequence parameter set. + const SPS: u8 = 33; + + /// The type of an HEVC NAL unit, from its header. + fn nal_type(nal: &[u8]) -> Option { + nal.first().map(|b| (b >> 1) & 0x3f) + } + + /// The NAL unit type of an HEVC picture parameter set. + const PPS: u8 = 34; + + /// A NAL unit's payload, without its two-byte header and the emulation prevention bytes (the 03 + /// of 00 00 03), up to its first 4,096 bytes. + fn payload(nal: &[u8]) -> Vec { + let mut rbsp = Vec::with_capacity(256); + let mut zeros = 0; + for &b in nal.get(2..).unwrap_or_default().iter().take(4096) { + if b == 3 && zeros >= 2 { + zeros = 0; + continue; + } + zeros = if b == 0 { zeros + 1 } else { 0 }; + rbsp.push(b); + } + rbsp + } + + /// An SPS NAL unit's [`payload`]; `None` for another NAL unit. + fn sps_payload(nal: &[u8]) -> Option> { + (nal_type(nal) == Some(SPS)).then(|| payload(nal)) + } + + /// The fields of an SPS (ITU-T H.265 7.3.2.2) up to the picture height: + /// `sps_max_sub_layers_minus1`, `sps_seq_parameter_set_id`, the width, the height and + /// `chroma_format_idc`. + fn sps_head(bits: &mut Bits<'_>) -> Option<(u64, u64, u64, u64, u64)> { + // sps_video_parameter_set_id, sps_max_sub_layers_minus1, sps_temporal_id_nesting_flag. + bits.skip(4)?; + let sub_layers = bits.read(3)?; + bits.skip(1)?; + // profile_tier_level (7.3.3): the general profile, tier and level, then the sub-layers'. + bits.skip(96)?; + let mut present = Vec::new(); + for _ in 0..sub_layers { + present.push((bits.read(1)?, bits.read(1)?)); + } + if sub_layers > 0 { + bits.skip(2 * (8 - sub_layers as usize))?; + } + for (profile, level) in present { + bits.skip(88 * profile as usize + 8 * level as usize)?; + } + let id = bits.exp_golomb()?; + let chroma = bits.exp_golomb()?; + if chroma == 3 { + bits.skip(1)?; + } + let width = bits.exp_golomb()?; + let height = bits.exp_golomb()?; + Some((sub_layers, id, width, height, chroma)) + } + + /// The picture width, height and `chroma_format_idc` of an HEVC sequence parameter set NAL + /// unit, before its conformance window; `None` for another NAL unit. + pub(in super::super) fn sps_size(nal: &[u8]) -> Option<(u64, u64, u64)> { + let rbsp = sps_payload(nal)?; + let (_, _, width, height, chroma) = sps_head(&mut Bits { data: &rbsp, at: 0 })?; + Some((width, height, chroma)) + } + + /// The video signal type of an HEVC sequence parameter set NAL unit's VUI (ITU-T H.265 E.2.1) + /// as an `nclx`: the full-range flag and, when the VUI describes them, the color primaries, + /// transfer characteristics and matrix coefficients (otherwise 2, unspecified). `None` when the + /// SPS has no VUI, the VUI has no video signal type, or the SPS can't be read that far. + pub(in super::super) fn sps_signal(nal: &[u8]) -> Option { + let rbsp = sps_payload(nal)?; + let bits = &mut Bits { data: &rbsp, at: 0 }; + let (sub_layers, ..) = sps_head(bits)?; + skip_to_vui(bits, sub_layers)?; + // vui_parameters_present_flag, then aspect_ratio_info_present_flag (an Extended_SAR + // aspect_ratio_idc of 255 adds its 32-bit ratio) and overscan_info_present_flag. + if bits.read(1)? == 0 { + return None; + } + if bits.read(1)? == 1 && bits.read(8)? == 255 { + bits.skip(32)?; + } + if bits.read(1)? == 1 { + bits.skip(1)?; + } + // video_signal_type_present_flag: video_format, video_full_range_flag and the color + // description. + if bits.read(1)? == 0 { + return None; + } + bits.skip(3)?; + let range = if bits.read(1)? == 1 { Range::Full } else { Range::Limited }; + let (primaries, transfer, matrix) = if bits.read(1)? == 1 { (bits.read(8)?, bits.read(8)?, bits.read(8)?) } else { (2, 2, 2) }; + let code = |v: u64| u16::try_from(v).ok(); + let matrix_code = code(matrix)?; + Some(Nclx { primaries: code(primaries)?, transfer: code(transfer)?, matrix: MatrixCoefficients::from_code(matrix_code), matrix_code, range }) + } + + /// Skips the fields of an SPS after [`sps_head`], up to `vui_parameters_present_flag`. + fn skip_to_vui(bits: &mut Bits<'_>, sub_layers: u64) -> Option<()> { + // conformance_window_flag and its four offsets. + if bits.read(1)? == 1 { + for _ in 0..4 { + bits.exp_golomb()?; + } + } + // bit_depth_luma_minus8, bit_depth_chroma_minus8. + bits.exp_golomb()?; + bits.exp_golomb()?; + let poc_bits = bits.exp_golomb()?.checked_add(4).filter(|&n| n <= 16)?; + // sps_sub_layer_ordering_info_present_flag, then three values for each sub-layer given. + let ordered = if bits.read(1)? == 1 { sub_layers + 1 } else { 1 }; + for _ in 0..ordered * 3 { + bits.exp_golomb()?; + } + // The coding and transform block sizes and the transform hierarchy depths. + for _ in 0..6 { + bits.exp_golomb()?; + } + // scaling_list_enabled_flag, sps_scaling_list_data_present_flag. + if bits.read(1)? == 1 && bits.read(1)? == 1 { + skip_scaling_lists(bits)?; + } + // amp_enabled_flag, sample_adaptive_offset_enabled_flag. + bits.skip(2)?; + // pcm_enabled_flag: the PCM sample depths, block sizes and loop filter flag. + if bits.read(1)? == 1 { + bits.skip(8)?; + bits.exp_golomb()?; + bits.exp_golomb()?; + bits.skip(1)?; + } + skip_reference_picture_sets(bits)?; + // long_term_ref_pics_present_flag: each picture's POC bits and used flag. + if bits.read(1)? == 1 { + let count = bits.exp_golomb()?; + if count > 32 { + return None; + } + for _ in 0..count { + bits.skip(poc_bits as usize + 1)?; + } + } + // sps_temporal_mvp_enabled_flag, strong_intra_smoothing_enabled_flag. + bits.skip(2) + } + + /// Skips `scaling_list_data()` (ITU-T H.265 7.3.4). + fn skip_scaling_lists(bits: &mut Bits<'_>) -> Option<()> { + for size in 0..4 { + // The 32 × 32 lists are given for matrices 0 and 3 only. + for _ in 0..if size == 3 { 2 } else { 6 } { + if bits.read(1)? == 0 { + // scaling_list_pred_matrix_id_delta. + bits.exp_golomb()?; + } else { + // scaling_list_dc_coef_minus8, then each scaling_list_delta_coef (se(v) codes, + // as long as ue(v) codes). + if size > 1 { + bits.exp_golomb()?; + } + for _ in 0..if size == 0 { 16 } else { 64 } { + bits.exp_golomb()?; + } + } + } + } + Some(()) + } + + /// Skips `num_short_term_ref_pic_sets` and each `st_ref_pic_set()` (ITU-T H.265 7.3.7) of an + /// SPS. A set predicted from the one before it has one or two flags for each of that set's + /// pictures and one more. + fn skip_reference_picture_sets(bits: &mut Bits<'_>) -> Option<()> { + let sets = bits.exp_golomb()?; + if sets > 64 { + return None; + } + // NumDeltaPocs of each set read so far. + let mut pictures: Vec = Vec::new(); + for index in 0..sets { + let predicted = index != 0 && bits.read(1)? == 1; + let count = if predicted { + // delta_rps_sign, abs_delta_rps_minus1. + bits.skip(1)?; + bits.exp_golomb()?; + let mut count = 0; + for _ in 0..=*pictures.last()? { + // used_by_curr_pic_flag, and use_delta_flag when it is 0. + if bits.read(1)? == 1 || bits.read(1)? == 1 { + count += 1; + } + } + count + } else { + let (negative, positive) = (bits.exp_golomb()?, bits.exp_golomb()?); + if negative > 16 || positive > 16 { + return None; + } + // Each picture's delta_poc_s0_minus1 or delta_poc_s1_minus1 and its used flag. + for _ in 0..negative + positive { + bits.exp_golomb()?; + bits.skip(1)?; + } + negative + positive + }; + pictures.push(count); + } + Some(()) + } + + /// A big-endian bit reader over an RBSP. + struct Bits<'a> { + data: &'a [u8], + at: usize, + } + + impl Bits<'_> { + fn bit(&mut self) -> Option { + let byte = self.data.get(self.at / 8)?; + let bit = (byte >> (7 - self.at % 8)) & 1; + self.at += 1; + Some(u64::from(bit)) + } + + fn read(&mut self, n: u32) -> Option { + (0..n).try_fold(0u64, |v, _| Some(v << 1 | self.bit()?)) + } + + fn skip(&mut self, n: usize) -> Option<()> { + self.at = self.at.checked_add(n)?; + (self.at <= self.data.len() * 8).then_some(()) + } + + /// An unsigned Exp-Golomb code, ue(v), of at most 32 leading zeros. + fn exp_golomb(&mut self) -> Option { + let mut zeros = 0; + while self.bit()? == 0 { + zeros += 1; + if zeros > 32 { + return None; + } + } + Some((1u64 << zeros) - 1 + self.read(zeros)?) + } + } + + /// `nclx` color primaries for Display P3 (SMPTE EG 432-1) and transfer characteristics for + /// the sRGB curve (IEC 61966-2-1), ITU-T H.273. + const P3_PRIMARIES: u16 = 12; + const SRGB_TRANSFER: u16 = 13; + + /// The profile the decoded RGB values are in when it isn't sRGB: the primary item's RGB ICC + /// profile, else Display P3 when its `nclx` names P3 primaries with the sRGB curve. A grid + /// without its own `colr` takes its first tile's, as cameras write it on the tiles. A profile + /// that can't be read, or isn't RGB, is left out and the values are read as sRGB. Without any + /// `colr`, the HEVC stream's color description ([`stream_nclx`]) is read the same way. + fn source_profile(ctx: &Context<'_>) -> Option { + use vectorcraft_color::cms::{self, IccProfile, ProfileKind}; + + let id = ctx.meta.primary; + let own = ctx.props(id).ok()?; + let described = |p: &heic_rs::props::ItemProps<'_>| p.icc.is_some() || p.nclx.is_some(); + let tile = || ctx.grid(id).ok().flatten().and_then(|(_, tiles)| tiles.first().and_then(|&t| ctx.props(t).ok())).filter(described); + let props = if described(&own) { Some(own) } else { tile() }; + if let Some(icc) = props.as_ref().and_then(|p| p.icc) { + return IccProfile::from_bytes(None, icc).ok().filter(|p| p.kind == ProfileKind::Rgb); + } + let nclx = match props { + Some(props) => props.nclx?, + None => stream_nclx(ctx)?, + }; + (nclx.primaries == P3_PRIMARIES && nclx.transfer == SRGB_TRANSFER).then(|| cms::icc::builtin_rgb(cms::DISPLAY_P3)).flatten() + } +} diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index ae63ba918..03bfde85b 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -103,6 +103,9 @@ pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> { if super::jp2read::is_jpeg2000(bytes) { return format("jpeg2000"); } + if super::heifread::is_heif(bytes) { + return format("heif"); + } if let Some(f) = image::guess_format(bytes).ok().and_then(image_format) { return Some(f); } @@ -726,6 +729,8 @@ pub(crate) fn size_note(bytes: &[u8]) -> Option { /// [`super::pcxread::decode`], which refuses an image past [`MAX_RASTER_ALLOC`] before decoding. /// A Pixar picture file is recognized by its magic number ([`super::pxrread::is_pxr`]) and read by /// [`super::pxrread::decode`], which does the same. +/// A HEIC or HEIF file is recognized by its `ftyp` brands ([`super::heifread::is_heif`]) and read +/// by [`super::heifread::decode`], which does the same. /// A Targa file, which starts with no signature, is recognized by its header /// ([`super::tgaread::is_targa`]) and becomes RGBA with the transparency it stores /// ([`super::tgaread::fix_alpha`]). An image stored as PNG whose decoded samples, or whose pixels @@ -744,6 +749,11 @@ pub fn raster_image(bytes: &[u8]) -> Result { if super::jp2read::is_jpeg2000(bytes) { return as_png(super::jp2read::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?, ppi); } + if super::heifread::is_heif(bytes) { + // The EXIF resolution describes the stored pixels; a quarter turn swaps its axes. + let ppi = ppi.map(|(x, y)| if super::heifread::quarter_turned(bytes) { (y, x) } else { (x, y) }); + return as_png(super::heifread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?, ppi); + } if super::pxrread::is_pxr(bytes) { return as_png(super::pxrread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?, ppi); } diff --git a/crates/engine/src/cmd/fileio/ppi.rs b/crates/engine/src/cmd/fileio/ppi.rs index 5d6d25f26..9539a81c7 100644 --- a/crates/engine/src/cmd/fileio/ppi.rs +++ b/crates/engine/src/cmd/fileio/ppi.rs @@ -1,6 +1,7 @@ //! Image resolution metadata (pixels per inch): PNG `pHYs`, JPEG JFIF density or EXIF, TIFF and //! WebP EXIF resolution tags, BMP pixels per metre, a Photoshop document's ResolutionInfo, a PCX -//! header's HDpi and VDpi, or JP2 capture and display resolution boxes. Place +//! header's HDpi and VDpi, JP2 capture and display resolution boxes, or a HEIF image's EXIF +//! resolution tags. Place //! sizes an image by it (72 ppi when the file has none); images converted to PNG on import keep it //! as a `pHYs` chunk. @@ -26,6 +27,8 @@ pub fn resolution(bytes: &[u8]) -> Option<(f64, f64)> { super::pcxread::resolution(bytes) } else if super::jp2read::is_jpeg2000(bytes) { jp2(bytes) + } else if super::heifread::is_heif(bytes) { + super::heifread::with_exif(bytes, tiff) } else { None }; diff --git a/crates/engine/src/cmd/fileio/tests_heifread.rs b/crates/engine/src/cmd/fileio/tests_heifread.rs new file mode 100644 index 000000000..9820164da --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_heifread.rs @@ -0,0 +1,620 @@ +//! HEIC and HEIF Open, Place, pixel, color and resolution regressions using original synthetic +//! fixtures (quadrants of four colors, lossy HEVC, so colors are compared within a tolerance). + +#[cfg(feature = "heif")] +use serde_json::json; + +use super::heifread::is_heif; +use super::*; + +const QUADRANTS: &[u8] = include_bytes!("../../../tests/fixtures/heif/quadrants.heic"); +const ALPHA: &[u8] = include_bytes!("../../../tests/fixtures/heif/quadrants-alpha.heic"); +const TEN_BIT: &[u8] = include_bytes!("../../../tests/fixtures/heif/quadrants-10bit.heic"); +const GRID: &[u8] = include_bytes!("../../../tests/fixtures/heif/quadrants-grid.heic"); +const GRID16: &[u8] = include_bytes!("../../../tests/fixtures/heif/quadrants-grid16.heic"); +const PPI300: &[u8] = include_bytes!("../../../tests/fixtures/heif/quadrants-300ppi.heic"); +const ROTATED: &[u8] = include_bytes!("../../../tests/fixtures/heif/wide-rotated.heic"); +const ROTATED_300X150: &[u8] = include_bytes!("../../../tests/fixtures/heif/wide-rotated-300x150.heic"); +const TONES: &[u8] = include_bytes!("../../../tests/fixtures/heif/tones.heic"); +const TONES_P3_NCLX: &[u8] = include_bytes!("../../../tests/fixtures/heif/tones-p3-nclx.heic"); +const TONES_P3_ICC: &[u8] = include_bytes!("../../../tests/fixtures/heif/tones-p3-icc.heic"); +const TONES_P3_ICC_ONLY: &[u8] = include_bytes!("../../../tests/fixtures/heif/tones-p3-icc-only.heic"); + +const EVERY_FIXTURE: [&[u8]; 12] = + [QUADRANTS, ALPHA, TEN_BIT, GRID, GRID16, PPI300, ROTATED, ROTATED_300X150, TONES, TONES_P3_NCLX, TONES_P3_ICC, TONES_P3_ICC_ONLY]; + +/// Red, green, blue and white: the top-left, top-right, bottom-left and bottom-right quadrants. +#[cfg(feature = "heif")] +const COLORS: [[u8; 4]; 4] = [[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [255; 4]]; + +/// The centers of the four quadrants of a square image `side` pixels wide. +#[cfg(feature = "heif")] +fn centers(side: u32) -> [(u32, u32); 4] { + let (a, b) = (side / 4, side * 3 / 4); + [(a, a), (b, a), (a, b), (b, b)] +} + +#[cfg(feature = "heif")] +fn decoded(bytes: &[u8]) -> image::RgbaImage { + let raster = load::raster_image(bytes).unwrap(); + image::load_from_memory(&raster.blob.bytes).unwrap().to_rgba8() +} + +#[cfg(feature = "heif")] +fn close(got: [u8; 4], want: [u8; 4], tolerance: u8) -> bool { + got.iter().zip(want).all(|(g, w)| g.abs_diff(w) <= tolerance) +} + +#[cfg(feature = "heif")] +fn assert_quadrants(img: &image::RgbaImage, side: u32, what: &str) { + assert_eq!(img.dimensions(), (side, side), "{what}"); + for ((x, y), want) in centers(side).into_iter().zip(COLORS) { + let got = img.get_pixel(x, y).0; + assert!(close(got, want, 8), "{what} at ({x}, {y}): {got:?} != {want:?}"); + } +} + +#[cfg(feature = "heif")] +#[test] +fn quadrants_open_and_place_as_their_pixels() { + assert_quadrants(&decoded(QUADRANTS), 32, "quadrants.heic"); + for name in ["photo.heic", "photo.heif", "unknown.bin"] { + let mut s = Session::new(); + let encoded = vectorcraft_format::base64_encode(QUADRANTS); + let opened = s.execute("document.open", &json!({"name": name, "dataBase64": encoded})).unwrap(); + assert_eq!(opened["format"], "heif", "{name}"); + assert_eq!(s.doc().unwrap().doc.artboards[0].rect.size(), vectorcraft_geom::Size::new(32.0, 32.0)); + let blob = s.doc().unwrap().doc.images.values().next().unwrap(); + assert_quadrants(&image::load_from_memory(&blob.bytes).unwrap().to_rgba8(), 32, name); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let placed = s.execute("file.place", &json!({"name": name, "dataBase64": encoded})).unwrap(); + assert_eq!(placed["format"], "heif", "{name}"); + } + assert!(load::texture_image(QUADRANTS).is_ok()); + // The general brand alone, without `heic`. + let mut general = QUADRANTS.to_vec(); + assert_eq!(&QUADRANTS[8..28], b"heic\0\0\0\0mif1heicmiaf"); + general[8..12].copy_from_slice(b"mif1"); + general[20..24].copy_from_slice(b"miaf"); + assert_quadrants(&decoded(&general), 32, "mif1 only"); +} + +#[test] +fn signatures_and_every_extension() { + for bytes in EVERY_FIXTURE { + assert!(is_heif(bytes)); + assert_eq!(load::detect("unknown.bin", bytes).map(|f| f.id), Some("heif")); + } + for ext in ["heic", "heif"] { + assert_eq!(load::detect(&format!("a.{ext}"), b"invalid").map(|f| f.id), Some("heif")); + assert!(OPEN_EXTS.contains(&ext) && PLACE_EXTS.contains(&ext) && UNASSOCIATED_EXTS.contains(&ext), "{ext}"); + } + // A brand only among the compatible ones counts, and so do the general brands alone. + assert!(is_heif(b"\0\0\0\x14ftypabcd\0\0\0\0heic")); + assert!(is_heif(b"\0\0\0\x18ftypmif1\0\0\0\0mif1heix")); + assert!(is_heif(b"\0\0\0\x18ftypmsf1\0\0\0\0msf1hevc")); + assert!(is_heif(b"\0\0\0\x18ftypmif1\0\0\0\0mif1miaf")); + // A 64-bit box size (ISO/IEC 14496-12 4.2). + assert!(is_heif(b"\0\0\0\x01ftyp\0\0\0\0\0\0\0\x18heic\0\0\0\0")); + let mut extended = b"\0\0\0\x01ftyp\0\0\0\0\0\0\0\x18heic\0\0\0\0".to_vec(); + extended.extend_from_slice(&QUADRANTS[QUADRANTS.windows(4).position(|w| w == b"meta").unwrap() - 4..]); + assert_eq!(load::detect("unknown.bin", &extended).map(|f| f.id), Some("heif")); + // AVIF, with its brand major or compatible, and brands heic-rs doesn't read. + assert!(!is_heif(b"\0\0\0\x18ftypavif\0\0\0\0mif1miaf")); + assert!(!is_heif(b"\0\0\0\x1cftypmif1\0\0\0\0mif1avifmiaf")); + assert!(!is_heif(b"\0\0\0\x1cftypheic\0\0\0\0mif1heicavis")); + assert!(!is_heif(b"\0\0\0\x18ftyphevx\0\0\0\0hevxiso8")); + assert!(!is_heif(b"\0\0\0\x18ftypisom\0\0\0\0isomavc1")); + // A box shorter than its brands, longer than the file, or of another type. + assert!(!is_heif(b"\0\0\0\x0cftypheic")); + assert!(!is_heif(b"\0\0\0\x40ftypheic\0\0\0\0mif1")); + assert!(!is_heif(b"\0\0\0\x14styphei\0\0\0\0\0heic")); + assert!(!is_heif(b"")); +} + +#[cfg(feature = "heif")] +#[test] +fn alpha_10_bit_and_grid_images_keep_their_pixels() { + let alpha = decoded(ALPHA); + assert_eq!(alpha.dimensions(), (32, 32)); + for ((x, y), want) in centers(32).into_iter().zip(COLORS) { + let got = alpha.get_pixel(x, y).0; + // The left half is opaque; the right half is transparent. + if x < 16 { + assert!(close(got, want, 8), "({x}, {y}): {got:?} != {want:?}"); + } else { + assert!(got[3] <= 8, "({x}, {y}): {got:?}"); + } + } + assert_quadrants(&decoded(TEN_BIT), 32, "10-bit"); + // Four 64 × 64 tiles, one for each quadrant. + assert_quadrants(&decoded(GRID), 128, "2 × 2 grid"); +} + +#[cfg(feature = "heif")] +#[test] +fn a_grid_of_cropped_tiles_is_refused() { + // 32 × 32 quadrants as a 2 × 2 grid of 16 × 16 tiles, each coded 64 × 64 and cropped back + // with its own clap, which heic-rs 0.1.4 leaves out when it composes the grid. + let error = load::raster_image(GRID16).err().unwrap().to_string(); + assert!(error.contains("kind of image that can't be read"), "{error}"); +} + +#[cfg(feature = "heif")] +#[test] +fn a_grid_without_its_own_color_box_uses_its_tiles() { + // The grid item's associations (ipma) list ispe (1), pixi (5) and colr (3); the tiles carry + // the same colr. Pointing the grid's third association at its ispe leaves the grid without one. + let mut tiles_only = GRID.to_vec(); + assert_eq!(&GRID[565..571], &[0, 1, 3, 1, 0x85, 3], "item 1 with three associations, the last colr"); + tiles_only[570] = 1; + assert_quadrants(&decoded(&tiles_only), 128, "colr on the tiles only"); +} + +#[cfg(feature = "heif")] +#[test] +fn the_containers_rotation_is_applied() { + // A 32 × 16 image, red then blue, stored with a quarter turn clockwise. + let img = decoded(ROTATED); + assert_eq!(img.dimensions(), (16, 32)); + assert!(close(img.get_pixel(8, 4).0, [255, 0, 0, 255], 8), "{:?}", img.get_pixel(8, 4)); + assert!(close(img.get_pixel(8, 27).0, [0, 0, 255, 255], 8), "{:?}", img.get_pixel(8, 27)); + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "a.heic", "dataBase64": vectorcraft_format::base64_encode(ROTATED)})).unwrap(); + assert_eq!(s.doc().unwrap().doc.artboards[0].rect.size(), vectorcraft_geom::Size::new(16.0, 32.0)); +} + +#[cfg(feature = "heif")] +#[test] +fn the_containers_mirror_and_crop_are_applied() { + // The rotated image's irot (property 6) turned to no rotation, and to an imir of the same + // size: mirrored left to right (ISO/IEC 23008-12 6.5.12, axis 0). + assert_eq!(&ROTATED[384..393], b"\0\0\0\x09irot\x03"); + let mut stored = ROTATED.to_vec(); + stored[392] = 0; + let mut mirrored = ROTATED.to_vec(); + mirrored[388..393].copy_from_slice(b"imir\0"); + let (stored, mirrored) = (decoded(&stored), decoded(&mirrored)); + assert_eq!((stored.dimensions(), mirrored.dimensions()), ((32, 16), (32, 16))); + let red = [255, 0, 0, 255]; + assert!(close(stored.get_pixel(8, 8).0, red, 8) && !close(stored.get_pixel(24, 8).0, red, 8), "{:?}", stored.get_pixel(8, 8)); + for (x, y) in [(4, 4), (8, 8), (20, 12), (27, 3)] { + assert!(close(mirrored.get_pixel(x, y).0, stored.get_pixel(31 - x, y).0, 8), "({x}, {y})"); + } + // The quadrants' clap (property 4) takes the top-left 32 × 32 of the 64 × 64 coded picture. + // Rewritten to its top-left 16 × 16: a center offset of -24 (ISO/IEC 14496-12 12.1.4) leaves + // the red quadrant alone. Its edge next to green is left out, as lossy chroma blends there. + assert_eq!(&QUADRANTS[329..337], b"\0\0\0\x28clap"); + let mut cropped = QUADRANTS.to_vec(); + let clap: Vec = [16u32, 1, 16, 1, (-48i32).cast_unsigned(), 2, (-48i32).cast_unsigned(), 2].iter().flat_map(|v| v.to_be_bytes()).collect(); + cropped[337..369].copy_from_slice(&clap); + let cropped = decoded(&cropped); + assert_eq!(cropped.dimensions(), (16, 16)); + for (x, y) in [(0, 0), (8, 8), (12, 0), (0, 12), (12, 12)] { + assert!(close(cropped.get_pixel(x, y).0, red, 8), "({x}, {y}): {:?}", cropped.get_pixel(x, y)); + } +} + +#[cfg(feature = "heif")] +#[test] +fn exif_resolution_sizes_the_image_and_72_ppi_without_it() { + let got = ppi::resolution(PPI300).unwrap(); + assert!((got.0 - 300.0).abs() < 0.01 && (got.1 - 300.0).abs() < 0.01, "{got:?}"); + assert_eq!(ppi::resolution(QUADRANTS), None); + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "a.heic", "dataBase64": vectorcraft_format::base64_encode(PPI300)})).unwrap(); + let size = s.doc().unwrap().doc.artboards[0].rect.size(); + assert!((size.width - 7.68).abs() < 0.001 && (size.height - 7.68).abs() < 0.001, "{size:?}"); + let blob = s.doc().unwrap().doc.images.values().next().unwrap(); + let stored = ppi::resolution(&blob.bytes).unwrap(); + assert!((stored.0 - 300.0).abs() < 0.01 && (stored.1 - 300.0).abs() < 0.01); + // 32 × 16 pixels at 300 ppi across and 150 down, turned a quarter: 16 × 32 pixels at 150 ppi + // across and 300 down, the same 7.68 × 7.68 pt either way. + assert_eq!(ppi::resolution(ROTATED_300X150), Some((300.0, 150.0)), "as stored"); + let img = load::raster_image(ROTATED_300X150).unwrap(); + assert_eq!((img.width, img.height, img.ppi), (16, 32, Some((150.0, 300.0)))); + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "a.heic", "dataBase64": vectorcraft_format::base64_encode(ROTATED_300X150)})).unwrap(); + let size = s.doc().unwrap().doc.artboards[0].rect.size(); + assert!((size.width - 7.68).abs() < 0.001 && (size.height - 7.68).abs() < 0.001, "{size:?}"); +} + +#[cfg(feature = "heif")] +#[test] +fn display_p3_images_are_converted_to_srgb() { + use vectorcraft_color::cms::{DISPLAY_P3, Intent, icc::builtin_rgb}; + let p3 = builtin_rgb(DISPLAY_P3).unwrap(); + let plain = decoded(TONES); + // The ICC-only file's samples are full-range BT.601, which only its HEVC stream says. + for (what, bytes) in [("nclx", TONES_P3_NCLX), ("ICC", TONES_P3_ICC), ("ICC only", TONES_P3_ICC_ONLY)] { + let converted = decoded(bytes); + for (x, y) in centers(32) { + let [r, g, b, a] = plain.get_pixel(x, y).0; + let rgb = p3.to_srgb_into(&[r, g, b].map(|v| f32::from(v) / 255.0), Intent::RelativeColorimetric).unwrap(); + let [r, g, b] = rgb.map(|v| (v * 255.0).round() as u8); + let got = converted.get_pixel(x, y).0; + assert!(close(got, [r, g, b, a], 2), "{what} at ({x}, {y}): {got:?} != {:?}", [r, g, b, a]); + } + } + // The orange quadrant changes, so the conversion ran. + assert!(!close(decoded(TONES_P3_NCLX).get_pixel(8, 8).0, plain.get_pixel(8, 8).0, 4)); +} + +#[cfg(feature = "heif")] +#[test] +fn display_p3_named_by_the_stream_alone_is_converted_to_srgb() { + // The nclx file's item associates hvcC, colr (property 2), ispe, pixi and clap. Pointing the + // colr association at the ispe leaves the item without a color box, and its HEVC stream's + // video signal type, which names Display P3 with the sRGB curve, describes the samples. + let ipma = TONES_P3_NCLX.windows(4).position(|w| w == b"ipma").unwrap(); + let at = ipma + 4 + 4 + 4 + 2 + 1 + 1; + assert_eq!(&TONES_P3_NCLX[at - 4..at + 4], &[0, 1, 5, 0x81, 2, 3, 5, 0x84], "item 1 with five associations, the second colr"); + let mut stream_only = TONES_P3_NCLX.to_vec(); + stream_only[at] = 3; + let want = decoded(TONES_P3_NCLX); + let got = decoded(&stream_only); + for (x, y) in centers(32) { + assert!(close(got.get_pixel(x, y).0, want.get_pixel(x, y).0, 2), "at ({x}, {y}): {:?} != {:?}", got.get_pixel(x, y), want.get_pixel(x, y)); + } +} + +#[cfg(feature = "heif")] +#[test] +fn two_quarter_turns_keep_the_resolution_axes() { + // The rotated image's ipma lists its irot once; a second association with the same irot turns + // it a half turn, so the EXIF resolution keeps its axes. + let ipma = ROTATED_300X150.windows(4).position(|w| w == b"ipma").unwrap(); + let count = ipma + 4 + 4 + 4 + 2; + let irot = ROTATED_300X150.windows(4).position(|w| w == b"irot").unwrap(); + let ipco = ROTATED_300X150.windows(4).position(|w| w == b"ipco").unwrap(); + // The irot's property index: its place among the boxes of ipco, counted from 1. + let mut index = 1u8; + let mut at = ipco + 4; + while at < irot - 4 { + index += 1; + at += u32::from_be_bytes(ROTATED_300X150[at..at + 4].try_into().unwrap()) as usize; + } + assert_eq!(at, irot - 4, "irot is a box of ipco"); + let n = usize::from(ROTATED_300X150[count]); + assert!(ROTATED_300X150[count + 1..count + 1 + n].contains(&(0x80 | index)), "the item's irot association, essential"); + let mut twice = ROTATED_300X150.to_vec(); + twice[count] += 1; + twice.insert(count + 1 + n, 0x80 | index); + // The ipma, iprp and meta boxes each grow by one byte, and the iloc offsets move with them. + let grow = |bytes: &mut Vec, name: &[u8; 4]| { + let at = bytes.windows(4).position(|w| w == name).unwrap() - 4; + let size = u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap()) + 1; + bytes[at..at + 4].copy_from_slice(&size.to_be_bytes()); + }; + for name in [b"ipma", b"iprp", b"meta"] { + grow(&mut twice, name); + } + let iloc = twice.windows(4).position(|w| w == b"iloc").unwrap(); + assert_eq!(&twice[iloc + 4..iloc + 8], &[0, 0, 0, 0], "iloc version 0"); + let sizes = twice[iloc + 8]; + assert_eq!((sizes >> 4, sizes & 0xf), (4, 4), "4-byte offsets and lengths"); + assert_eq!(twice[iloc + 9] >> 4, 4, "4-byte base offsets"); + let items = u16::from_be_bytes([twice[iloc + 10], twice[iloc + 11]]); + // Each item: its id, data reference index and base offset, which moves, then its extents. + let mut at = iloc + 12; + for _ in 0..items { + at += 2 + 2; + let offset = u32::from_be_bytes(twice[at..at + 4].try_into().unwrap()) + 1; + twice[at..at + 4].copy_from_slice(&offset.to_be_bytes()); + at += 4; + let extents = u16::from_be_bytes([twice[at], twice[at + 1]]); + at += 2 + 8 * usize::from(extents); + } + let img = load::raster_image(&twice).unwrap(); + assert_eq!((img.width, img.height, img.ppi), (32, 16, Some((300.0, 150.0))), "a half turn keeps the stored axes"); +} + +#[cfg(feature = "heif")] +#[test] +fn damaged_unsupported_and_oversized_files_are_refused() { + let error = |bytes: &[u8]| load::raster_image(bytes).err().unwrap().to_string(); + for cut in [16, 40, QUADRANTS.len() / 2, QUADRANTS.len() - 5] { + assert!(load::raster_image(&QUADRANTS[..cut]).is_err(), "{cut}"); + } + // An ftyp and nothing else, as an image sequence starts (its `moov` would follow). + assert!(error(b"\0\0\0\x18ftypmsf1\0\0\0\0msf1hevc").contains("kind of image that can't be read")); + assert!(error(b"\0\0\0\x18ftypheic\0\0\0\0mif1heic\0\0\0\x08free").contains("HEIC or HEIF file")); + // The primary item's type (infe) turned from HEVC to AV1, with its hvcC kept, and with its + // hvcC turned into an av1C. + let mut av1 = QUADRANTS.to_vec(); + assert_eq!((&QUADRANTS[137..141], &QUADRANTS[176..180]), (&b"hvc1"[..], &b"hvcC"[..])); + av1[137..141].copy_from_slice(b"av01"); + assert!(error(&av1).contains("kind of image that can't be read"), "{}", error(&av1)); + av1[176..180].copy_from_slice(b"av1C"); + assert!(error(&av1).contains("kind of image that can't be read"), "{}", error(&av1)); + // The image spatial extents (`ispe`) claim 60,000 × 60,000 pixels. + let mut huge = QUADRANTS.to_vec(); + let at = huge.windows(4).position(|w| w == b"ispe").unwrap() + 8; + huge[at..at + 4].copy_from_slice(&60_000u32.to_be_bytes()); + huge[at + 4..at + 8].copy_from_slice(&60_000u32.to_be_bytes()); + assert!(error(&huge).contains("60000 × 60000 pixels is too large to draw"), "{}", error(&huge)); + // A single 64 × 64 coded picture (cropped to 32 × 32) takes its RGBA output and copy, + // 64 × 64 × 8, and its planes, 64 × 64 × 9: no composed frame is counted. + assert!(super::heifread::decode(QUADRANTS, 64 * 64 * 17).is_ok()); + assert!(super::heifread::decode(QUADRANTS, 64 * 64 * 17 - 1).unwrap_err().contains("too large to draw")); + // The same picture with an ispe of 32 × 32 (its crop's size): its stream still decodes + // 64 × 64, and that size is counted. + let mut small = QUADRANTS.to_vec(); + let at = small.windows(4).position(|w| w == b"ispe").unwrap() + 8; + assert_eq!(&small[at..at + 8], b"\0\0\0\x40\0\0\0\x40"); + small[at..at + 4].copy_from_slice(&32u32.to_be_bytes()); + small[at + 4..at + 8].copy_from_slice(&32u32.to_be_bytes()); + let refused = super::heifread::decode(&small, 64 * 64 * 17 - 1).map(|img| img.dimensions()); + assert!(refused.as_ref().is_err_and(|e| e.contains("too large to draw")), "{refused:?}"); + // A 128 × 128 grid of four 64 × 64 tiles adds its composed frame, 128 × 128 × 6. + let grid = 128 * 128 * (8 + 6) + 4 * 64 * 64 * 9; + assert!(super::heifread::decode(GRID, grid).is_ok()); + assert!(super::heifread::decode(GRID, grid - 1).unwrap_err().contains("too large to draw")); + // The grid's ispe (property 1) claims 16 × 16 pixels; its grid data still composes 128 × 128, + // and that size is counted. + let mut understated = GRID.to_vec(); + assert_eq!(&GRID[358..370], b"ispe\0\0\0\0\0\0\0\x80", "property 1 is the grid's 128 × 128 ispe"); + understated[366..370].copy_from_slice(&16u32.to_be_bytes()); + understated[370..374].copy_from_slice(&16u32.to_be_bytes()); + assert!(super::heifread::decode(&understated, grid - 1).unwrap_err().contains("too large to draw")); +} + +/// Appends `value` as `n` bits, most significant first. +#[cfg(feature = "heif")] +fn put(bits: &mut Vec, value: u64, n: u32) { + bits.extend((0..n).rev().map(|i| i < 64 && value >> i & 1 == 1)); +} + +/// Appends `value` as an unsigned Exp-Golomb code. +#[cfg(feature = "heif")] +fn put_ue(bits: &mut Vec, value: u64) { + let len = 64 - (value + 1).leading_zeros(); + put(bits, 0, len - 1); + put(bits, value + 1, len); +} + +/// The fields of SPS 0 of `width` × `height` 4:2:0 samples with `sub_layers` sub-layers, each +/// with its profile and level, up to its height. +#[cfg(feature = "heif")] +fn sps_head(width: u64, height: u64, sub_layers: u64) -> Vec { + sps_head_with_id(width, height, sub_layers, 0) +} + +/// [`sps_head`] for SPS `id`. +#[cfg(feature = "heif")] +fn sps_head_with_id(width: u64, height: u64, sub_layers: u64, id: u64) -> Vec { + let mut bits = Vec::new(); + put(&mut bits, 0, 4); + put(&mut bits, sub_layers, 3); + put(&mut bits, 1, 1); + put(&mut bits, 0, 96); + for _ in 0..sub_layers { + put(&mut bits, 0b11, 2); + } + if sub_layers > 0 { + put(&mut bits, 0, 2 * (8 - sub_layers as u32)); + } + for _ in 0..sub_layers { + put(&mut bits, 0, 88 + 8); + } + put_ue(&mut bits, id); + put_ue(&mut bits, 1); + put_ue(&mut bits, width); + put_ue(&mut bits, height); + bits +} + +/// An SPS NAL unit of `bits` (see [`nal`]). +#[cfg(feature = "heif")] +fn sps_nal(bits: Vec) -> Vec { + nal(33, bits) +} + +/// A NAL unit of type `kind` holding `bits`, with its stop bit, and emulation prevention bytes +/// inserted as a stream carries them. +#[cfg(feature = "heif")] +fn nal(kind: u8, mut bits: Vec) -> Vec { + put(&mut bits, 1, 1); + while !bits.len().is_multiple_of(8) { + bits.push(false); + } + let rbsp: Vec = bits.chunks(8).map(|b| b.iter().fold(0u8, |v, &bit| v << 1 | u8::from(bit))).collect(); + let mut nal = vec![kind << 1, 1]; + let mut zeros = 0; + for b in rbsp { + if zeros >= 2 && b <= 3 { + nal.push(3); + zeros = 0; + } + zeros = if b == 0 { zeros + 1 } else { 0 }; + nal.push(b); + } + nal +} + +#[cfg(feature = "heif")] +fn sps(width: u64, height: u64, sub_layers: u64) -> Vec { + sps_nal(sps_head(width, height, sub_layers)) +} + +/// A 64 × 64 SPS that uses every optional part before the VUI's video signal type: a conformance +/// window, sub-layer ordering for two sub-layers, explicit scaling lists, PCM, a reference picture +/// set predicted from another and a long-term picture, then an Extended_SAR aspect ratio and +/// overscan information. `signal` is the full-range flag and the color description, if any. +#[cfg(feature = "heif")] +fn sps_with_vui(signal: Option<(u64, Option<[u64; 3]>)>) -> Vec { + sps_with_vui_and_id(signal, 0) +} + +/// [`sps_with_vui`] for SPS `id`. +#[cfg(feature = "heif")] +fn sps_with_vui_and_id(signal: Option<(u64, Option<[u64; 3]>)>, id: u64) -> Vec { + let mut bits = sps_head_with_id(64, 64, 1, id); + put(&mut bits, 1, 1); + for offset in [0, 2, 0, 2] { + put_ue(&mut bits, offset); + } + put_ue(&mut bits, 0); + put_ue(&mut bits, 0); + // 8 bits of picture order count. + put_ue(&mut bits, 4); + put(&mut bits, 1, 1); + for _ in 0..2 * 3 { + put_ue(&mut bits, 1); + } + for value in [0, 3, 0, 3, 1, 1] { + put_ue(&mut bits, value); + } + // Scaling lists: matrix 1 of each size given explicitly, the others copied. + put(&mut bits, 0b11, 2); + for size in 0..4 { + for matrix in 0..if size == 3 { 2 } else { 6 } { + if matrix == 1 { + put(&mut bits, 1, 1); + if size > 1 { + put_ue(&mut bits, 5); + } + for _ in 0..if size == 0 { 16 } else { 64 } { + put_ue(&mut bits, 3); + } + } else { + put(&mut bits, 0, 1); + put_ue(&mut bits, 0); + } + } + } + // AMP off, SAO on, PCM with its depths, block sizes and loop filter flag. + put(&mut bits, 0b01, 2); + put(&mut bits, 1, 1); + put(&mut bits, 0x77, 8); + put_ue(&mut bits, 0); + put_ue(&mut bits, 1); + put(&mut bits, 0, 1); + // Two short-term sets: one negative and one positive picture, then a set predicted from it + // with three entries (unused with use_delta_flag, used, unused). + put_ue(&mut bits, 2); + put_ue(&mut bits, 1); + put_ue(&mut bits, 1); + for _ in 0..2 { + put_ue(&mut bits, 0); + put(&mut bits, 1, 1); + } + put(&mut bits, 1, 1); + put(&mut bits, 0, 1); + put_ue(&mut bits, 0); + put(&mut bits, 0b01100, 5); + // One long-term picture: 8 bits of picture order count and its used flag. + put(&mut bits, 1, 1); + put_ue(&mut bits, 1); + put(&mut bits, 0xab, 8); + put(&mut bits, 1, 1); + put(&mut bits, 0b11, 2); + // VUI: Extended_SAR 1:1 and overscan information. + put(&mut bits, 1, 1); + put(&mut bits, 1, 1); + put(&mut bits, 255, 8); + put(&mut bits, 0x0001_0001, 32); + put(&mut bits, 1, 1); + put(&mut bits, 0, 1); + match signal { + Some((full, description)) => { + put(&mut bits, 1, 1); + put(&mut bits, 5, 3); + put(&mut bits, full, 1); + put(&mut bits, u64::from(description.is_some()), 1); + for code in description.into_iter().flatten() { + put(&mut bits, code, 8); + } + } + None => put(&mut bits, 0, 1), + } + // No chroma location, field or display window information, timing or restrictions. + put(&mut bits, 0, 8); + sps_nal(bits) +} + +#[cfg(feature = "heif")] +#[test] +fn picture_sizes_come_from_the_sequence_parameter_sets() { + use super::heifread::{picture_bytes, sps_size}; + for sub_layers in [0, 1, 6] { + for (w, h) in [(32, 32), (4032, 3024), (65535, 65535)] { + let nal = sps(w, h, sub_layers); + assert!(nal.windows(3).any(|w| w == [0, 0, 3]), "the profile's zeros need emulation prevention"); + assert_eq!(sps_size(&nal), Some((w, h, 1)), "{w} × {h}, {sub_layers} sub-layers"); + } + } + let mut vps = sps(32, 32, 0); + vps[0] = 32 << 1; + assert_eq!(sps_size(&vps), None); + assert_eq!(sps_size(&sps(32, 32, 0)[..8]), None); + // 4:2:0 is 3 bytes a sample position in each of three sets of planes, in 64-sample blocks. + assert_eq!(picture_bytes(QUADRANTS), Some(64 * 64 * 9)); + assert_eq!(picture_bytes(GRID), Some(4 * 64 * 64 * 9)); + assert!(picture_bytes(ALPHA).unwrap() > 64 * 64 * 9, "the alpha image is counted"); +} + +#[cfg(feature = "heif")] +#[test] +fn the_color_description_comes_from_the_streams_video_signal_type() { + use super::heifread::{sps_signal, sps_size}; + use heic_rs::{MatrixCoefficients, Range}; + let signal = |nal: &[u8]| sps_signal(nal).map(|n| (n.primaries, n.transfer, n.matrix, n.matrix_code, n.range)); + let described = sps_with_vui(Some((1, Some([12, 13, 6])))); + assert_eq!(sps_size(&described), Some((64, 64, 1))); + assert_eq!(signal(&described), Some((12, 13, MatrixCoefficients::Bt601, 6, Range::Full))); + assert_eq!(signal(&sps_with_vui(Some((0, None)))), Some((2, 2, MatrixCoefficients::Unspecified, 2, Range::Limited))); + assert_eq!(signal(&sps_with_vui(None)), None); + assert_eq!(signal(&sps(64, 64, 0)), None, "cut short before its VUI"); + // The ICC-only fixture's stream: heif-enc writes full-range BT.601. + let ctx = heic_rs::context::Context::open(TONES_P3_ICC_ONLY).unwrap(); + let props = ctx.props(ctx.meta.primary).unwrap(); + assert!(props.nclx.is_none() && props.icc.is_some()); + let sets = props.hvcc.unwrap().parameter_sets(); + let nal = sets.into_iter().find(|nal| nal[0] >> 1 & 0x3f == 33).unwrap(); + assert_eq!(sps_signal(nal).map(|n| (n.matrix, n.range)), Some((MatrixCoefficients::Bt601, Range::Full))); +} + +#[cfg(feature = "heif")] +#[test] +fn the_slices_picture_parameter_set_picks_the_sps() { + use super::heifread::{active_sps, sps_signal}; + use heic_rs::Range; + let full = sps_with_vui_and_id(Some((1, Some([1, 13, 6]))), 0); + let limited = sps_with_vui_and_id(Some((0, Some([1, 13, 1]))), 1); + let pps = |id: u64, sps: u64| { + let mut bits = Vec::new(); + put_ue(&mut bits, id); + put_ue(&mut bits, sps); + nal(34, bits) + }; + // first_slice_segment_in_pic_flag, no_output_of_prior_pics_flag for an IDR picture (type 19), + // slice_pic_parameter_set_id. + let slice = |kind: u8, pps: u64| { + let mut bits = vec![true]; + if kind == 19 { + bits.push(false); + } + put_ue(&mut bits, pps); + nal(kind, bits) + }; + let (pps0, pps3) = (pps(0, 0), pps(3, 1)); + let sets = [full.as_slice(), limited.as_slice(), pps0.as_slice(), pps3.as_slice()]; + let (idr3, trail0, idr5) = (slice(19, 3), slice(1, 0), slice(19, 5)); + assert_eq!(active_sps(&sets, &[&idr3]), Some(limited.as_slice())); + assert_eq!(active_sps(&sets, &[&trail0]), Some(full.as_slice())); + assert_eq!(active_sps(&sets, &[&idr5]), None, "no PPS 5"); + assert_eq!(active_sps(&sets, &[&pps0]), None, "no slice"); + assert_eq!(active_sps(&sets, &[&idr3]).and_then(sps_signal).map(|n| (n.matrix_code, n.range)), Some((1, Range::Limited))); +} + +#[cfg(not(feature = "heif"))] +#[test] +fn a_build_without_the_decoder_refuses_heif_files_with_a_message() { + for bytes in EVERY_FIXTURE { + assert_eq!(load::detect("unknown.bin", bytes).map(|f| f.id), Some("heif")); + let error = load::raster_image(bytes).err().unwrap().to_string(); + assert!(error.contains("can't be opened in this build"), "{error}"); + } + assert_eq!(ppi::resolution(PPI300), None); +} diff --git a/crates/engine/tests/fixtures/heif/quadrants-10bit.heic b/crates/engine/tests/fixtures/heif/quadrants-10bit.heic new file mode 100644 index 000000000..ab588a1d8 Binary files /dev/null and b/crates/engine/tests/fixtures/heif/quadrants-10bit.heic differ diff --git a/crates/engine/tests/fixtures/heif/quadrants-300ppi.heic b/crates/engine/tests/fixtures/heif/quadrants-300ppi.heic new file mode 100644 index 000000000..0e9a72342 Binary files /dev/null and b/crates/engine/tests/fixtures/heif/quadrants-300ppi.heic differ diff --git a/crates/engine/tests/fixtures/heif/quadrants-alpha.heic b/crates/engine/tests/fixtures/heif/quadrants-alpha.heic new file mode 100644 index 000000000..9465f67bc Binary files /dev/null and b/crates/engine/tests/fixtures/heif/quadrants-alpha.heic differ diff --git a/crates/engine/tests/fixtures/heif/quadrants-grid.heic b/crates/engine/tests/fixtures/heif/quadrants-grid.heic new file mode 100644 index 000000000..5997b3dda Binary files /dev/null and b/crates/engine/tests/fixtures/heif/quadrants-grid.heic differ diff --git a/crates/engine/tests/fixtures/heif/quadrants-grid16.heic b/crates/engine/tests/fixtures/heif/quadrants-grid16.heic new file mode 100644 index 000000000..dcbcccacf Binary files /dev/null and b/crates/engine/tests/fixtures/heif/quadrants-grid16.heic differ diff --git a/crates/engine/tests/fixtures/heif/quadrants.heic b/crates/engine/tests/fixtures/heif/quadrants.heic new file mode 100644 index 000000000..ebf7f1d45 Binary files /dev/null and b/crates/engine/tests/fixtures/heif/quadrants.heic differ diff --git a/crates/engine/tests/fixtures/heif/tones-p3-icc-only.heic b/crates/engine/tests/fixtures/heif/tones-p3-icc-only.heic new file mode 100644 index 000000000..9671f346a Binary files /dev/null and b/crates/engine/tests/fixtures/heif/tones-p3-icc-only.heic differ diff --git a/crates/engine/tests/fixtures/heif/tones-p3-icc.heic b/crates/engine/tests/fixtures/heif/tones-p3-icc.heic new file mode 100644 index 000000000..da0c41068 Binary files /dev/null and b/crates/engine/tests/fixtures/heif/tones-p3-icc.heic differ diff --git a/crates/engine/tests/fixtures/heif/tones-p3-nclx.heic b/crates/engine/tests/fixtures/heif/tones-p3-nclx.heic new file mode 100644 index 000000000..6e7bbc62d Binary files /dev/null and b/crates/engine/tests/fixtures/heif/tones-p3-nclx.heic differ diff --git a/crates/engine/tests/fixtures/heif/tones.heic b/crates/engine/tests/fixtures/heif/tones.heic new file mode 100644 index 000000000..3ab9e509b Binary files /dev/null and b/crates/engine/tests/fixtures/heif/tones.heic differ diff --git a/crates/engine/tests/fixtures/heif/wide-rotated-300x150.heic b/crates/engine/tests/fixtures/heif/wide-rotated-300x150.heic new file mode 100644 index 000000000..b18e5b9ee Binary files /dev/null and b/crates/engine/tests/fixtures/heif/wide-rotated-300x150.heic differ diff --git a/crates/engine/tests/fixtures/heif/wide-rotated.heic b/crates/engine/tests/fixtures/heif/wide-rotated.heic new file mode 100644 index 000000000..d6095829e Binary files /dev/null and b/crates/engine/tests/fixtures/heif/wide-rotated.heic differ diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 73c497032..59d079bcd 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -1934,6 +1934,33 @@ proptest! { } } +proptest! { + #![proptest_config(config())] + + /// Mutated HEIF boxes and HEVC data must return errors rather than unwind. + #[test] + fn mutated_heif_files_do_not_panic(which in 0usize..4, cut in 0usize..1300, edits in prop::collection::vec((0usize..1300, any::()), 0..8)) { + let mut bytes = match which { + 0 => include_bytes!("fixtures/heif/quadrants.heic").to_vec(), + 1 => include_bytes!("fixtures/heif/quadrants-alpha.heic").to_vec(), + 2 => include_bytes!("fixtures/heif/quadrants-grid.heic").to_vec(), + // Its stream's VUI gives the color description. + _ => include_bytes!("fixtures/heif/tones-p3-icc-only.heic").to_vec(), + }; + for (at, byte) in edits { + if let Some(slot) = bytes.get_mut(at) { *slot = byte; } + } + bytes.truncate(cut); + let result = catch_quiet(|| { + let mut s = vectorcraft_engine::Session::new(); + let params = json!({"name": "a.heic", "dataBase64": vectorcraft_format::base64_encode(&bytes)}); + let _ = no_panic(s.execute("document.open", ¶ms)); + let _ = no_panic(s.execute("file.place", ¶ms)); + }); + prop_assert!(result.is_ok(), "a.heic: panicked: {:?}", result.err()); + } +} + /// The rich document with a hidden layer, saved as SVG with its editing data. fn saved_svg() -> String { let mut d = rich_doc(); diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 8da20248c..4fd69733d 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -5029,6 +5029,9 @@ imageTrace Outline Obris @msg the Pixar file's image data is damaged slikovni podaci Pixar datoteke su oštećeni @msg the JPEG 2000 file can't be read datoteku JPEG 2000 nije moguće pročitati @msg the JPEG 2000 file has an unsupported color space or JPX content datoteka JPEG 2000 ima nepodržan prostor boja ili JPX sadržaj +@msg the HEIC or HEIF file can't be read datoteku HEIC ili HEIF nije moguće pročitati +@msg the HEIC or HEIF file holds a kind of image that can't be read datoteka HEIC ili HEIF sadrži vrstu slike koju nije moguće pročitati +@msg HEIC and HEIF images can't be opened in this build HEIC i HEIF slike nije moguće otvoriti u ovoj verziji programa @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD slika je prevelika za čitanje ({_1} × {_2} piksela) @msg zipped 32-bit PSD image data with prediction can't be read zipovani 32-bitni PSD podaci slike s predviđanjem ne mogu se pročitati @msg {depth}-bit PSD files in this colour mode can't be read {depth}-bitne PSD datoteke u ovom načinu boja ne mogu se pročitati diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 3ad992e57..f2a467fc5 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -6313,6 +6313,9 @@ breakText Glyphs Glifs @msg the JPEG 2000 file can't be read no es pot llegir el fitxer JPEG 2000 @msg the JPEG 2000 file has an unsupported color space or JPX content el fitxer JPEG 2000 té un espai de color o contingut JPX no compatible +@msg the HEIC or HEIF file can't be read no es pot llegir el fitxer HEIC o HEIF +@msg the HEIC or HEIF file holds a kind of image that can't be read el fitxer HEIC o HEIF conté un tipus d'imatge que no es pot llegir +@msg HEIC and HEIF images can't be opened in this build les imatges HEIC i HEIF no es poden obrir en aquesta compilació @msg You must choose a side pattern. Heu de triar un patró lateral. @msg invalid pattern tile choice opció de mosaic de patró no vàlida @msg no pattern named `{choice}` no hi ha cap patró anomenat `{choice}` diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 8cc2a6fba..15cd33ed0 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5091,6 +5091,9 @@ imageTrace Outline Obrys @msg the Pixar file's image data is damaged obrazová data souboru Pixar jsou poškozená @msg the JPEG 2000 file can't be read soubor JPEG 2000 nelze přečíst @msg the JPEG 2000 file has an unsupported color space or JPX content soubor JPEG 2000 obsahuje nepodporovaný barevný prostor nebo obsah JPX +@msg the HEIC or HEIF file can't be read soubor HEIC nebo HEIF nelze přečíst +@msg the HEIC or HEIF file holds a kind of image that can't be read soubor HEIC nebo HEIF obsahuje druh obrázku, který nelze přečíst +@msg HEIC and HEIF images can't be opened in this build obrázky HEIC a HEIF nelze v tomto sestavení otevřít @msg the PSD image is too large to read ({_1} × {_2} pixels) obraz PSD je příliš velký na přečtení ({_1} × {_2} obrazových bodů) @msg zipped 32-bit PSD image data with prediction can't be read zazipovaná 32bitová obrazová data PSD s predikcí nelze přečíst @msg {depth}-bit PSD files in this colour mode can't be read {depth}bitové soubory PSD v tomto barevném režimu nelze přečíst diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 374c63918..e61e26c81 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4832,6 +4832,9 @@ theme Appearance Erscheinungsbild @msg the Pixar file's image data is damaged die Bilddaten der Pixar-Datei sind beschädigt @msg the JPEG 2000 file can't be read die JPEG-2000-Datei kann nicht gelesen werden @msg the JPEG 2000 file has an unsupported color space or JPX content die JPEG-2000-Datei enthält einen nicht unterstützten Farbraum oder JPX-Inhalt +@msg the HEIC or HEIF file can't be read die HEIC- oder HEIF-Datei kann nicht gelesen werden +@msg the HEIC or HEIF file holds a kind of image that can't be read die HEIC- oder HEIF-Datei enthält eine Bildart, die nicht gelesen werden kann +@msg HEIC and HEIF images can't be opened in this build HEIC- und HEIF-Bilder können in diesem Build nicht geöffnet werden @msg the PSD image is too large to read ({_1} × {_2} pixels) das PSD-Bild ist zu groß zum Lesen ({_1} × {_2} Pixel) @msg zipped 32-bit PSD image data with prediction can't be read ZIP-komprimierte 32-Bit-PSD-Bilddaten mit Vorhersage können nicht gelesen werden @msg {depth}-bit PSD files in this colour mode can't be read {depth}-Bit-PSD-Dateien in diesem Farbmodus können nicht gelesen werden diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index b42c06626..bad5543ba 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4772,6 +4772,9 @@ digits Malayalam Μαλαγιάλαμ @msg the Pixar file's image data is damaged τα δεδομένα εικόνας του αρχείου Pixar είναι κατεστραμμένα @msg the JPEG 2000 file can't be read δεν είναι δυνατή η ανάγνωση του αρχείου JPEG 2000 @msg the JPEG 2000 file has an unsupported color space or JPX content το αρχείο JPEG 2000 έχει μη υποστηριζόμενο χρωματικό χώρο ή περιεχόμενο JPX +@msg the HEIC or HEIF file can't be read δεν είναι δυνατή η ανάγνωση του αρχείου HEIC ή HEIF +@msg the HEIC or HEIF file holds a kind of image that can't be read το αρχείο HEIC ή HEIF περιέχει είδος εικόνας που δεν είναι δυνατό να διαβαστεί +@msg HEIC and HEIF images can't be opened in this build οι εικόνες HEIC και HEIF δεν μπορούν να ανοίξουν σε αυτή την έκδοση @msg the PSD image is too large to read ({_1} × {_2} pixels) η εικόνα PSD είναι πολύ μεγάλη για ανάγνωση ({_1} × {_2} εικονοστοιχεία) @msg zipped 32-bit PSD image data with prediction can't be read τα συμπιεσμένα δεδομένα εικόνας PSD 32 bit με πρόβλεψη δεν διαβάζονται @msg {depth}-bit PSD files in this colour mode can't be read τα αρχεία PSD {depth} bit δεν διαβάζονται σε αυτήν τη λειτουργία χρώματος diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 67a5a84d2..651886e05 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4811,6 +4811,9 @@ axis Both Ambos @msg the Pixar file's image data is damaged los datos de imagen del archivo Pixar están dañados @msg the JPEG 2000 file can't be read no se puede leer el archivo JPEG 2000 @msg the JPEG 2000 file has an unsupported color space or JPX content el archivo JPEG 2000 tiene un espacio de color o contenido JPX no compatible +@msg the HEIC or HEIF file can't be read no se puede leer el archivo HEIC o HEIF +@msg the HEIC or HEIF file holds a kind of image that can't be read el archivo HEIC o HEIF contiene un tipo de imagen que no se puede leer +@msg HEIC and HEIF images can't be opened in this build las imágenes HEIC y HEIF no se pueden abrir en esta compilación @msg the PSD image is too large to read ({_1} × {_2} pixels) la imagen PSD es demasiado grande para leerla ({_1} × {_2} píxeles) @msg zipped 32-bit PSD image data with prediction can't be read no se pueden leer datos de imagen PSD de 32 bits comprimidos en ZIP con predicción @msg {depth}-bit PSD files in this colour mode can't be read los archivos PSD de {depth} bits en este modo de color no se pueden leer diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 3f8658a21..f79d04cea 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4993,6 +4993,9 @@ imageTrace Outline Ääriviiva @msg the Pixar file's image data is damaged Pixar-tiedoston kuvadata on vahingoittunut @msg the JPEG 2000 file can't be read tätä JPEG 2000 -tiedostoa ei voi lukea @msg the JPEG 2000 file has an unsupported color space or JPX content tässä JPEG 2000 -tiedostossa on tukematon väriavaruus tai JPX-sisältö +@msg the HEIC or HEIF file can't be read tätä HEIC- tai HEIF-tiedostoa ei voi lukea +@msg the HEIC or HEIF file holds a kind of image that can't be read tässä HEIC- tai HEIF-tiedostossa on kuvatyyppi, jota ei voi lukea +@msg HEIC and HEIF images can't be opened in this build HEIC- ja HEIF-kuvia ei voi avata tässä versiossa @msg the PSD image is too large to read ({_1} × {_2} pixels) tämä PSD-kuva on liian suuri luettavaksi ({_1} × {_2} pikseliä) @msg zipped 32-bit PSD image data with prediction can't be read ennustusta käyttävää zip-pakattua 32-bittistä PSD-kuvadataa ei voi lukea @msg {depth}-bit PSD files in this colour mode can't be read tässä väritilassa olevia {depth}-bittisiä PSD-tiedostoja ei voi lukea diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 9ba17bcc4..b9c9c1165 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4819,6 +4819,9 @@ axis Both Les deux @msg the Pixar file's image data is damaged les données d'image du fichier Pixar sont endommagées @msg the JPEG 2000 file can't be read le fichier JPEG 2000 ne peut pas être lu @msg the JPEG 2000 file has an unsupported color space or JPX content le fichier JPEG 2000 contient un espace colorimétrique ou un contenu JPX non pris en charge +@msg the HEIC or HEIF file can't be read le fichier HEIC ou HEIF ne peut pas être lu +@msg the HEIC or HEIF file holds a kind of image that can't be read le fichier HEIC ou HEIF contient un type d'image qui ne peut pas être lu +@msg HEIC and HEIF images can't be opened in this build les images HEIC et HEIF ne peuvent pas être ouvertes dans cette version @msg the PSD image is too large to read ({_1} × {_2} pixels) l'image PSD est trop grande pour être lue ({_1} × {_2} pixels) @msg zipped 32-bit PSD image data with prediction can't be read les données d'image PSD 32 bits compressées en ZIP avec prédiction ne peuvent pas être lues @msg {depth}-bit PSD files in this colour mode can't be read les fichiers PSD {depth} bits dans ce mode colorimétrique ne peuvent pas être lus diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index aa3094599..2de1183cc 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5335,6 +5335,9 @@ imageTrace Outline Körvonal @msg the Pixar file's image data is damaged a Pixar-fájl képadatai sérültek @msg the JPEG 2000 file can't be read a JPEG 2000 fájl nem olvasható @msg the JPEG 2000 file has an unsupported color space or JPX content a JPEG 2000 fájl nem támogatott színteret vagy JPX-tartalmat tartalmaz +@msg the HEIC or HEIF file can't be read a HEIC- vagy HEIF-fájl nem olvasható +@msg the HEIC or HEIF file holds a kind of image that can't be read a HEIC- vagy HEIF-fájl olyan képtípust tartalmaz, amely nem olvasható +@msg HEIC and HEIF images can't be opened in this build a HEIC- és HEIF-képek ebben a buildben nem nyithatók meg @msg the PSD image is too large to read ({_1} × {_2} pixels) a PSD-kép túl nagy a beolvasáshoz ({_1} × {_2} pixel) @msg the Save PDF dialog didn't open a PDF mentése párbeszédablak nem nyílt meg @msg the scale is not a usable number a méretarány nem használható szám diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 8f6693d46..49f8130ae 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4853,6 +4853,9 @@ axis Both Entrambi @msg the Pixar file's image data is damaged i dati immagine del file Pixar sono danneggiati @msg the JPEG 2000 file can't be read impossibile leggere il file JPEG 2000 @msg the JPEG 2000 file has an unsupported color space or JPX content il file JPEG 2000 contiene uno spazio colore o un contenuto JPX non supportato +@msg the HEIC or HEIF file can't be read impossibile leggere il file HEIC o HEIF +@msg the HEIC or HEIF file holds a kind of image that can't be read il file HEIC o HEIF contiene un tipo di immagine che non può essere letto +@msg HEIC and HEIF images can't be opened in this build le immagini HEIC e HEIF non possono essere aperte in questa build @msg the PSD image is too large to read ({_1} × {_2} pixels) l'immagine PSD è troppo grande per essere letta ({_1} × {_2} pixel) @msg zipped 32-bit PSD image data with prediction can't be read i dati immagine PSD a 32 bit compressi ZIP con previsione non possono essere letti @msg {depth}-bit PSD files in this colour mode can't be read i file PSD a {depth} bit in questo metodo colore non possono essere letti diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index ea4ffac06..94b2e408a 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4894,6 +4894,9 @@ swatch Bright Violet ビビッドバイオレット @msg the Pixar file's image data is damaged Pixar ファイルの画像データが壊れています @msg the JPEG 2000 file can't be read JPEG 2000 ファイルを読み込めません @msg the JPEG 2000 file has an unsupported color space or JPX content JPEG 2000 ファイルには未対応の色空間または JPX コンテンツがあります +@msg the HEIC or HEIF file can't be read HEIC または HEIF ファイルを読み込めません +@msg the HEIC or HEIF file holds a kind of image that can't be read HEIC または HEIF ファイルには読み込めない種類の画像が含まれています +@msg HEIC and HEIF images can't be opened in this build このビルドでは HEIC と HEIF の画像を開けません @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD 画像が大きすぎて読み込めません({_1} × {_2} ピクセル) @msg zipped 32-bit PSD image data with prediction can't be read 予測付き ZIP 圧縮の 32 bit PSD 画像データは読み込めません @msg {depth}-bit PSD files in this colour mode can't be read このカラーモードの {depth} bit PSD ファイルは読み込めません diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index e64fd2fa3..77b99070f 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4510,6 +4510,9 @@ font-technology Variable ცვლადი @msg the Pixar file's image data is damaged Pixar ფაილის გამოსახულების მონაცემები დაზიანებულია @msg the JPEG 2000 file can't be read JPEG 2000 ფაილის წაკითხვა შეუძლებელია @msg the JPEG 2000 file has an unsupported color space or JPX content JPEG 2000 ფაილს აქვს მხარდაუჭერელი ფერთა სივრცე ან JPX შიგთავსი +@msg the HEIC or HEIF file can't be read HEIC ან HEIF ფაილის წაკითხვა შეუძლებელია +@msg the HEIC or HEIF file holds a kind of image that can't be read HEIC ან HEIF ფაილი შეიცავს გამოსახულების ტიპს, რომლის წაკითხვაც შეუძლებელია +@msg HEIC and HEIF images can't be opened in this build ამ ვერსიაში HEIC და HEIF გამოსახულებების გახსნა შეუძლებელია @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD გამოსახულება ზედმეტად დიდია წასაკითხად ({_1} × {_2} პიქსელი) @msg zipped 32-bit PSD image data with prediction can't be read zip-ით შეკუმშული 32-ბიტიანი PSD გამოსახულების მონაცემების წაკითხვა პროგნოზირებით ვერ ხერხდება @msg {depth}-bit PSD files in this colour mode can't be read ამ ფერის რეჟიმში {depth}-ბიტიანი PSD ფაილების წაკითხვა ვერ ხერხდება diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 336218c32..ca570d3cc 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4821,6 +4821,9 @@ theme Appearance 외관 @msg the Pixar file's image data is damaged Pixar 파일의 이미지 데이터가 손상되었습니다 @msg the JPEG 2000 file can't be read JPEG 2000 파일을 읽을 수 없습니다 @msg the JPEG 2000 file has an unsupported color space or JPX content JPEG 2000 파일에 지원되지 않는 색 공간 또는 JPX 콘텐츠가 있습니다 +@msg the HEIC or HEIF file can't be read HEIC 또는 HEIF 파일을 읽을 수 없습니다 +@msg the HEIC or HEIF file holds a kind of image that can't be read HEIC 또는 HEIF 파일에 읽을 수 없는 종류의 이미지가 있습니다 +@msg HEIC and HEIF images can't be opened in this build 이 빌드에서는 HEIC 및 HEIF 이미지를 열 수 없습니다 @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD 이미지가 너무 커서 읽을 수 없습니다({_1} × {_2}픽셀) @msg zipped 32-bit PSD image data with prediction can't be read 예측이 적용된 ZIP 압축 32비트 PSD 이미지 데이터는 읽을 수 없습니다 @msg {depth}-bit PSD files in this colour mode can't be read 이 색상 모드의 {depth}비트 PSD 파일은 읽을 수 없습니다 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index f9fdfd743..668a55ed7 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4917,6 +4917,9 @@ imageTrace Outline Kontur @msg the Pixar file's image data is damaged dane obrazu pliku Pixar są uszkodzone @msg the JPEG 2000 file can't be read nie można odczytać pliku JPEG 2000 @msg the JPEG 2000 file has an unsupported color space or JPX content plik JPEG 2000 zawiera nieobsługiwaną przestrzeń barw lub zawartość JPX +@msg the HEIC or HEIF file can't be read nie można odczytać pliku HEIC lub HEIF +@msg the HEIC or HEIF file holds a kind of image that can't be read plik HEIC lub HEIF zawiera rodzaj obrazu, którego nie można odczytać +@msg HEIC and HEIF images can't be opened in this build obrazów HEIC i HEIF nie można otworzyć w tej wersji @msg the PSD image is too large to read ({_1} × {_2} pixels) obraz PSD jest zbyt duży, aby go odczytać ({_1} × {_2} pikseli) @msg zipped 32-bit PSD image data with prediction can't be read nie można odczytać spakowanych 32-bitowych danych obrazu PSD z predykcją @msg {depth}-bit PSD files in this colour mode can't be read nie można odczytać {depth}-bitowych plików PSD w tym trybie koloru diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 92b5aa0c1..73cf2c8ee 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4355,6 +4355,9 @@ axis Both Ambos @msg the Pixar file's image data is damaged os dados de imagem do arquivo Pixar estão danificados @msg the JPEG 2000 file can't be read não é possível ler o arquivo JPEG 2000 @msg the JPEG 2000 file has an unsupported color space or JPX content o arquivo JPEG 2000 contém um espaço de cores ou conteúdo JPX sem suporte +@msg the HEIC or HEIF file can't be read não é possível ler o arquivo HEIC ou HEIF +@msg the HEIC or HEIF file holds a kind of image that can't be read o arquivo HEIC ou HEIF contém um tipo de imagem que não pode ser lido +@msg HEIC and HEIF images can't be opened in this build imagens HEIC e HEIF não podem ser abertas nesta versão @msg the PSD image is too large to read ({_1} × {_2} pixels) a imagem PSD é grande demais para ser lida ({_1} × {_2} pixels) @msg zipped 32-bit PSD image data with prediction can't be read dados de imagem PSD de 32 bits compactados em zip com previsão não podem ser lidos @msg {depth}-bit PSD files in this colour mode can't be read arquivos PSD de {depth} bits neste modo de cor não podem ser lidos diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 5d5252ee3..9b462f1b9 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4850,6 +4850,9 @@ axis Both Оба @msg the Pixar file's image data is damaged данные изображения в файле Pixar повреждены @msg the JPEG 2000 file can't be read не удается прочитать файл JPEG 2000 @msg the JPEG 2000 file has an unsupported color space or JPX content файл JPEG 2000 содержит неподдерживаемое цветовое пространство или содержимое JPX +@msg the HEIC or HEIF file can't be read не удается прочитать файл HEIC или HEIF +@msg the HEIC or HEIF file holds a kind of image that can't be read файл HEIC или HEIF содержит изображение такого типа, которое не удается прочитать +@msg HEIC and HEIF images can't be opened in this build изображения HEIC и HEIF нельзя открыть в этой сборке @msg the PSD image is too large to read ({_1} × {_2} pixels) изображение PSD слишком велико для чтения ({_1} × {_2} пикс.) @msg zipped 32-bit PSD image data with prediction can't be read 32-битные данные изображения PSD со сжатием ZIP с предсказанием не читаются @msg {depth}-bit PSD files in this colour mode can't be read {depth}-битные файлы PSD в этом цветовом режиме не читаются diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 6ef144c58..44dfd2563 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4728,6 +4728,9 @@ axis Both Båda @msg the Pixar file's image data is damaged Pixar-filens bilddata är skadade @msg the JPEG 2000 file can't be read JPEG 2000-filen kan inte läsas @msg the JPEG 2000 file has an unsupported color space or JPX content JPEG 2000-filen har en färgrymd eller ett JPX-innehåll som inte stöds +@msg the HEIC or HEIF file can't be read HEIC- eller HEIF-filen kan inte läsas +@msg the HEIC or HEIF file holds a kind of image that can't be read HEIC- eller HEIF-filen innehåller en typ av bild som inte kan läsas +@msg HEIC and HEIF images can't be opened in this build HEIC- och HEIF-bilder kan inte öppnas i den här versionen @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD-bilden är för stor för att läsas ({_1} × {_2} pixlar) @msg zipped 32-bit PSD image data with prediction can't be read zippad 32-bitars PSD-bilddata med prediktion kan inte läsas @msg {depth}-bit PSD files in this colour mode can't be read {depth}-bitars PSD-filer i det här färgläget kan inte läsas diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index d38231b3c..6ce1191c8 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4561,6 +4561,9 @@ axis Both Her İkisi @msg the Pixar file's image data is damaged Pixar dosyasının görüntü verileri bozuk @msg the JPEG 2000 file can't be read JPEG 2000 dosyası okunamıyor @msg the JPEG 2000 file has an unsupported color space or JPX content JPEG 2000 dosyasında desteklenmeyen bir renk alanı veya JPX içeriği var +@msg the HEIC or HEIF file can't be read HEIC veya HEIF dosyası okunamıyor +@msg the HEIC or HEIF file holds a kind of image that can't be read HEIC veya HEIF dosyası okunamayan türde bir görüntü içeriyor +@msg HEIC and HEIF images can't be opened in this build HEIC ve HEIF görüntüleri bu derlemede açılamıyor @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD görüntüsü okunamayacak kadar büyük ({_1} × {_2} piksel) @msg zipped 32-bit PSD image data with prediction can't be read Tahmin içeren sıkıştırılmış 32 bit PSD görüntü verileri okunamıyor @msg {depth}-bit PSD files in this colour mode can't be read Bu renk modunda {depth}-bit PSD dosyaları okunamaz diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 353cbacd8..81d168096 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4837,6 +4837,9 @@ axis Both Обидві @msg the Pixar file's image data is damaged дані зображення у файлі Pixar пошкоджені @msg the JPEG 2000 file can't be read не вдається прочитати файл JPEG 2000 @msg the JPEG 2000 file has an unsupported color space or JPX content файл JPEG 2000 містить непідтримуваний колірний простір або вміст JPX +@msg the HEIC or HEIF file can't be read не вдається прочитати файл HEIC або HEIF +@msg the HEIC or HEIF file holds a kind of image that can't be read файл HEIC або HEIF містить зображення такого типу, яке не вдається прочитати +@msg HEIC and HEIF images can't be opened in this build зображення HEIC і HEIF не можна відкрити в цій збірці @msg the PSD image is too large to read ({_1} × {_2} pixels) зображення PSD завелике для читання ({_1} × {_2} пікс.) @msg zipped 32-bit PSD image data with prediction can't be read 32-бітні дані зображення PSD зі стисненням ZIP із передбаченням не читаються @msg {depth}-bit PSD files in this colour mode can't be read {depth}-бітні файли PSD у цьому колірному режимі не читаються diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f1e49ed9b..3b1169439 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4994,6 +4994,9 @@ theme Appearance Giao diện @msg the Pixar file's image data is damaged dữ liệu ảnh của tệp Pixar bị hỏng @msg the JPEG 2000 file can't be read không thể đọc tệp JPEG 2000 @msg the JPEG 2000 file has an unsupported color space or JPX content tệp JPEG 2000 có không gian màu hoặc nội dung JPX không được hỗ trợ +@msg the HEIC or HEIF file can't be read không thể đọc tệp HEIC hoặc HEIF +@msg the HEIC or HEIF file holds a kind of image that can't be read tệp HEIC hoặc HEIF chứa loại ảnh không thể đọc được +@msg HEIC and HEIF images can't be opened in this build không thể mở ảnh HEIC và HEIF trong bản dựng này @msg the PSD image is too large to read ({_1} × {_2} pixels) ảnh PSD quá lớn để đọc ({_1} × {_2} pixel) @msg the Save PDF dialog didn't open hộp thoại Lưu PDF không mở @msg the scale is not a usable number tỷ lệ không phải là số dùng được diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 8b4599a16..d9fa2f182 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4604,6 +4604,9 @@ imageTrace Outline 轮廓 @msg the Pixar file's image data is damaged Pixar 文件的图像数据已损坏 @msg the JPEG 2000 file can't be read 无法读取 JPEG 2000 文件 @msg the JPEG 2000 file has an unsupported color space or JPX content JPEG 2000 文件包含不受支持的色彩空间或 JPX 内容 +@msg the HEIC or HEIF file can't be read 无法读取 HEIC 或 HEIF 文件 +@msg the HEIC or HEIF file holds a kind of image that can't be read HEIC 或 HEIF 文件包含无法读取的图像类型 +@msg HEIC and HEIF images can't be opened in this build 此版本无法打开 HEIC 和 HEIF 图像 @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD 图像过大,无法读取 ({_1} × {_2} 像素) @msg zipped 32-bit PSD image data with prediction can't be read 无法读取带预测的 ZIP 压缩 32 位 PSD 图像数据 @msg {depth}-bit PSD files in this colour mode can't be read 无法读取此颜色模式下的 {depth} 位 PSD 文件 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 822483936..6a34a843a 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2454,6 +2454,9 @@ file-filter Templates 範本 @msg the Pixar file's image data is damaged Pixar 檔案的影像資料已損毀 @msg the JPEG 2000 file can't be read 無法讀取 JPEG 2000 檔案 @msg the JPEG 2000 file has an unsupported color space or JPX content JPEG 2000 檔案包含不支援的色彩空間或 JPX 內容 +@msg the HEIC or HEIF file can't be read 無法讀取 HEIC 或 HEIF 檔案 +@msg the HEIC or HEIF file holds a kind of image that can't be read HEIC 或 HEIF 檔案包含無法讀取的影像類型 +@msg HEIC and HEIF images can't be opened in this build 此版本無法開啟 HEIC 和 HEIF 影像 @msg the PSD image is too large to read ({_1} × {_2} pixels) PSD 影像過大,無法讀取 ({_1} × {_2} 像素) @msg zipped 32-bit PSD image data with prediction can't be read 無法讀取使用預測的 ZIP 壓縮 32 位元 PSD 影像資料 @msg {depth}-bit PSD files in this colour mode can't be read 無法讀取此色彩模式的 {depth} 位元 PSD 檔案 diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 7c8a4d227..f716d693b 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -17,6 +17,8 @@ from the public specifications, and users' own `.ai` files are used locally only | | Illustrator | VectorCraft | Notes | |---|---:|---:|---| +| Formats opened or placed | ~30 | 30 | missing: FreeHand, CorelDRAW, DWG, PICT, PS | +| Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written PDF-compatible (with an editing copy of the art: layers, groups, paths, colors, artboards) or as legacy version 7 (layers and groups, one artboard per file) | | Formats opened or placed | ~30 | 29 | missing: FreeHand, CorelDRAW, DWG, HEIC/HEIF, PICT, PS | | Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written PDF-compatible (with an editing copy of the art: layers, groups, paths, colors, gradients, patterns, images, type, symbols, artboards, transparency, opacity masks, clipping) or as legacy version 7 (layers and groups, one artboard per file) | | Formats exported | ~16 | 17 | missing: DWG, 3D (OBJ/USDA/glTF); extra: GIF/PNG-8 palette export, DXF up to 2018, layered PSD | @@ -251,7 +253,7 @@ A Scatter brush in the editing data whose last 15 numbers start with 1 comes int | PSB | open | yes (merged image) | no | | | PNG / JPEG / GIF / BMP / TIFF / Targa | open, place, export | yes (PNG: `.png` and `.pns`; BMP: `.bmp`, `.dib` and `.rle`; Targa: `.tga`, `.vda`, `.icb` and `.vst`, raw or run-length encoded, with a 16-bit file's one-bit alpha) | yes; TIFF is also selectable in Export for Screens and Asset Export with its existing options | Targa: `tests_tiffbmp.rs`, `tests/links.rs`; TIFF: grayscale with an alpha channel or other extra channels reads (#1466 in part), and so do files that store each channel as its own plane (PlanarConfiguration 2), CMYK ones with their ink amounts, and 1-bit files compressed with CCITT Group 3 (Compression 2 and 3, one- and two-dimensional rows); the ICC profiles embedded in JPEG and TIFF have 4-byte aligned tags (#1584); PNG export marks its colours as sRGB (`embedIcc`, an `sRGB` chunk; no ICC profile is embedded, #2221) | | WebP | export (Export for Screens) | yes | yes (lossless only; lossy written lossless with a warning) | | -| HEIC / HEIF | open, place | no | no | | +| HEIC / HEIF | open, place | yes (HEVC-coded still images: single pictures and grids of tiles, 8 to 10 bits, with an alpha image; image sequences, other codings and grids of cropped tiles are refused) | no | `.heic`, `.heif`; decoded by heic-rs in the engine's default `heif` feature (a build without it refuses the files with a message); the container's rotation, mirror and crop are applied; without an `nclx` box the HEVC stream's video signal type gives the matrix and range; an RGB ICC profile, or Display P3 with the sRGB curve named by an `nclx` box or, without a color box, by the stream, is converted to sRGB; EXIF resolution tags size the image, or 72 ppi; bounded decoding; `tests_heifread.rs` | | PCX | open, place | yes (1 bit in the header palette's first two colors, black and white without them; 1 bit in two to four planes and 4 bits with up to 16 colors; 2 bits in the CGA colors the header's CGA palette settings pick; 8 bits with the 256-color palette, gray without one; 24-bit color in three planes) | no | the header's HDpi and VDpi size the image; a file of another depth or plane count, cut short or past the pixel allowance is refused with a message; tests in `crates/engine/src/cmd/fileio/tests_pcxread.rs` and `crates/engine/tests/import_fuzz.rs` | | JPEG 2000 | open, place | yes (JP2 and raw codestream images; multiple codestreams and JPX composition or layers are refused) | no | `.jpf`, `.jpx`, `.jp2`, `.j2k`, `.j2c`, `.jpc`; resolution uses the valid `resd` (display) value on each axis, or else the valid `resc` (capture) value, or 72 ppi; bounded decoding; `tests_jp2read.rs` | | PICT | open, place (legacy) | no | no | rarely used | diff --git a/docs/mcp.md b/docs/mcp.md index 65c48aa48..08b0eb8de 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -461,6 +461,8 @@ opens the picker through `run_command` with the remote backend. | `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. Includes UI commands such as `view.*` and `window.*` in remote mode. | | `open_panel` | `{panel}` | Remote only. `panel` is a panel id (`layers`, `swatches`, `colorGuide`, …, as `window.panel` takes) or its display label (`"Color Guide"`), in any case. | | `screenshot` | `{path?, scale?, artboard?, window?, max_side?, traceViews?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `traceViews:true` draws Image Trace objects in the view set with `imageTrace.setView`, as in the app's window; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. | +| `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), HEIC/HEIF (`.heic`/`.heif`), PSD (`.psd`/`.pdd`)/PSB (an image opens as a document of its pixel size; a Photoshop file as its merged image). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). `run_command document.formats` lists the formats. | +| `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version`, `preview` or `precision`). `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warning for a top-level `compress`, `version`, `preview` or `precision`. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. | | `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PNG (`.png`/`.pns`), JPEG, GIF, WebP, TIFF, BMP (`.bmp`/`.dib`/`.rle`), Targa (`.tga`/`.vda`/`.icb`/`.vst`), PCX, Pixar (`.pxr`), JPEG 2000 (`.jpf`/`.jpx`/`.jp2`/`.j2k`/`.j2c`/`.jpc`), PSD (`.psd`/`.pdd`)/PSB (an image opens as a document of its pixel size; a Photoshop file as its merged image). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). `run_command document.formats` lists the formats. | | `save_file` | `{path?, compress?, version?, preview?, precision?, options?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). For a native file, `compress: true` gzips it (default: the `useCompression` preference, Preferences › File Handling › Use Compression), `version` (1 to 4) writes it for an older VectorCraft (1 and 2 are never compressed, and `version: 2` with `compress: true` is an error), `preview: true` embeds a PNG of the first artboard, and `precision` (0 to 9) rounds path anchors and handles to that many decimal places for that save only (colors, transforms and type stay exact). `options` passes the format's save options as `run_command document.formats` lists them. When `compress`, `version`, `preview` or `precision` is given both at the top level and in `options` with different values, the call is an error (`` `compress: true` and `options.compress: false` disagree: give the option once ``) and nothing is written. The save drops each option the written format doesn't read. The reply's `warnings` then name each such option given (for example `` `compress` was not used: SVG files don't read it `` for an `.svg` path, and `` `options.preview` was not used: … `` for a key in `options`; a `.ai` file reads `compress` but not `version`, `preview` or `precision`). For a `.ai` file, these warnings name the kind of `.ai` written: a legacy .ai (version 7) for `aiVersion: "7"`, a non-PDF-compatible .ai for `pdfCompatible: false`, and a PDF-compatible .ai otherwise, including a save that reuses the options the document was last saved with. `run_command document.save` and the other save commands refuse any option given beside the path and in `options` with different values, and give the same warnings for a top-level `compress`, `version`, `preview` or `precision` and for each key in `options` that the format doesn't read. The headless session and the app receive the same `app.save` params, and the app is also sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. | | `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG and SVGZ write one file per chosen artboard. EPS writes one file of the visible art; with `artboard`, `range`, `options.artboards` or `options: {"useArtboards": true}` it writes one file per chosen artboard, after a central file of the visible art when there are two or more, and `useArtboards: false` keeps the one file. Rasters, DXF, EMF and WMF write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `artboards`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | @@ -4417,7 +4419,7 @@ image `object.rasterize` makes with the same options. ## Glass textures Glass (`distort.glass`) takes an image file as its surface in `params.textureImage`, as `{path}` or -`{name?, dataBase64}`: a PNG, JPEG, GIF, WebP, TIFF, BMP, Targa, PSD, PSB, PCX, Pixar or JPEG 2000 file, decoded within the pixel allowance the +`{name?, dataBase64}`: a PNG, JPEG, GIF, WebP, TIFF, BMP, Targa, PSD, PSB, PCX, Pixar, JPEG 2000 or HEIC/HEIF file, decoded within the pixel allowance the renderer draws images in, counted as RGBA. `effect.apply` and `effect.setParams` store the image's gray levels in the effect as a grayscale PNG in base64 and set `texture: "loaded"` unless the params give a `texture`. `effect.list` reports the stored string, and a command given that string stores it unchanged. Any other string, a file that is not an image, or an image past diff --git a/docs/releasing.md b/docs/releasing.md index b28ada6d6..64c177618 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -164,7 +164,7 @@ to the target triple, so host build scripts aren't affected. AppStream metainfo, the MIME type for VectorCraft documents, the icons and the licence files. **Document types.** The desktop entry's `MimeType` lists every format File › Open reads but PSD, -PSB, `.ps` PostScript, Targa, PCX, Pixar and JPEG 2000, so file managers list VectorCraft under Open With for them. +PSB, `.ps` PostScript, Targa, PCX, Pixar, JPEG 2000 and HEIC/HEIF, so file managers list VectorCraft under Open With for them. `ai.storyteller.vectorcraft.mime.xml` declares the types that shared-mime-info lacks: VectorCraft documents and templates, `.ait` templates and Affinity files (`application/x-affinity`). An `.af` file gets the Affinity type by its content, and `.afdesign` and `.afpub` files by their extension. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6a5cf1bb7..3c3087fff 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -624,7 +624,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Artboard deletion keys:** Delete/Backspace delete selected boards in one Undo step, retaining the original first board in the current order when all are selected, as in the public [Delete artboards guide](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/delete-artboards.html). The last board and active drags stay protected; neither key clears selected artwork. Headless shortcut regressions in `tests/distortkeys.rs` cover reordered boards, artwork, saved state and Undo/Redo. - **Locked artboards:** a click in the Artboards panel's lock column locks or unlocks an artboard in one undo step (`artboard.setProps {index, locked}`), and the native format saves the lock. The Artboard tool doesn't move, copy, resize or delete a locked artboard, Rearrange and Fit to Artwork Bounds leave it as it is, and its name isn't edited on the canvas. The art on it (each object whose bounds overlap it, a group as one object) acts as locked art until it is unlocked: clicks, marquees, `select.set`, `select.all` and the tools pass over it, and commands that act on the selection leave it; art drawn or pasted onto it stays selected and editable until the selection leaves it out. Tests in `crates/doc/src/lib.rs`, `tests/panelcmds.rs`, `xform/artboard.rs`, `panels/artboards.rs` and `canvas.rs` cover them. Public references: [Lock artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/lock-artboards.html) and [Managing Artboards in Illustrator](https://creativepro.com/managing-artboards-in-illustrator/). - **Linux RPM packaging:** 64-bit runtime capabilities replace Fedora-specific required package names (#1275); glibc 2.35, libm and libgcc_s are declared alongside dynamically loaded xkbcommon and Vulkan/EGL. Generated x86_64/aarch64 metadata and isolated synthetic-provider resolution checked; real-distro install/start-up QA remains pending. -- **Linux file associations:** the desktop entry lists the MIME type of every format File › Open reads but PSD, PSB, `.ps` PostScript, Targa, PCX, Pixar and JPEG 2000, `.ai`, `.ait` and EPS included, and the MIME package declares the types shared-mime-info lacks (VectorCraft documents and templates, `.ait`, Affinity), so file managers list VectorCraft under Open With for them, as Finder does on macOS. `the_linux_desktop_entry_opens_every_readable_format` in `apps/vectorcraft/src/tests.rs` checks both files against `OPEN_EXTS`. On Debian 13, Ubuntu 24.04 and Fedora 42, `desktop-file-validate` accepts the desktop entry, `update-mime-database` accepts the MIME package, and `gio` registers the app for every one of these types. +- **Linux file associations:** the desktop entry lists the MIME type of every format File › Open reads but PSD, PSB, `.ps` PostScript, Targa, PCX, Pixar, JPEG 2000 and HEIC/HEIF, `.ai`, `.ait` and EPS included, and the MIME package declares the types shared-mime-info lacks (VectorCraft documents and templates, `.ait`, Affinity), so file managers list VectorCraft under Open With for them, as Finder does on macOS. `the_linux_desktop_entry_opens_every_readable_format` in `apps/vectorcraft/src/tests.rs` checks both files against `OPEN_EXTS`. On Debian 13, Ubuntu 24.04 and Fedora 42, `desktop-file-validate` accepts the desktop entry, `update-mime-database` accepts the MIME package, and `gio` registers the app for every one of these types. - **Architecture:** 19+ crates with enforced layering (`cargo xtask layers`). Every action is a command (~400 engine + ~50 UI). Undo is unlimited via structural sharing. `command.batch` runs several commands as one transaction. - **Automation:** @@ -1329,6 +1329,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - PCX files (`.pcx`) open and place, from the ZSoft PCX Technical Reference Manual: 1 bit in the header palette's first two colors (black and white when the version has no palette or both colors are the same), 1 bit in two to four planes and 4 bits with up to 16 colors from the header's palette (the EGA colors for versions 0 and 3), 2 bits in the CGA colors that the header's CGA palette settings pick (background, color burst, palette and intensity), 8 bits with the 256-color palette after the image data (gray levels without one) and 24-bit color in three planes, run-length encoded. The header's HDpi and VDpi size the image. A file of another depth or plane count, one cut short, and one whose pixels as RGBA pass the pixel allowance are refused with a message before its pixels are decoded. Open and Place recognize a PCX file by its extension, and `clipboard.importImage` and Glass's Load Texture… by its header; a PCX file copied in a file manager pastes as a bitmap (`crates/engine/src/cmd/fileio/tests_pcxread.rs`). - Pixar picture files (`.pxr`) open, place and paste, from "The Format of Stored Pictures" in the Pixar Image Computer manuals: any set of the R, G, B and A channels in 8 or 12 bits, dumped or run-length encoded with all four packet kinds and block ends, in tiles of any size (pixels of border tiles past the picture are dropped). R alone is gray, a missing color channel is 0 and a missing A opaque; 12-bit samples are clamped to 0.0 to 1.0; color matted to black becomes straight color; a null tile is transparent when the picture has A and black otherwise. The offsets aren't read, and the image takes the default resolution. Version 2 headers read like version 0. A file cut short, one with a tile whose writing wasn't finished, one with header values or packets the format doesn't define, and one whose pixels as RGBA pass the pixel allowance are refused with a message. Every reader recognizes a Pixar file by its magic number. Tested on synthetic files only (`crates/engine/src/cmd/fileio/tests_pxrread.rs`). - JPEG 2000 JP2 and raw codestream images open and place from `.jpf`, `.jpx`, `.jp2`, `.j2k`, `.j2c` and `.jpc`. Each axis uses the valid display resolution (`resd`), else the valid capture resolution (`resc`), whichever box comes first ([ITU-T T.800](https://www.itu.int/rec/T-REC-T.800/en), I.5.3.7.2); an axis with none uses 72 ppi. Multiple codestreams, JPX composition or layers, and unsupported color spaces are refused with a message. The decoder checks image size before allocating (`crates/engine/src/cmd/fileio/tests_jp2read.rs`). + - HEIC and HEIF images open and place from `.heic` and `.heif`, as the [supported file formats](https://helpx.adobe.com/illustrator/kb/supported-file-formats-illustrator.html) list High Efficiency Image under both Open and Place (and [Fix HEIF file import errors on Windows](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-other-file-types/fix-heif-file-import-errors-on-windows.html) covers "opening or placing a HEIF file"): HEVC-coded single pictures and grids of tiles, 8 to 10 bits, with an alpha image, decoded by heic-rs in the engine's default `heif` feature. The container's rotation, mirror and crop are applied. Without an `nclx` box, the HEVC stream's video signal type gives the samples' matrix and range. An RGB ICC profile, or Display P3 with the sRGB curve named by an `nclx` box or, without a color box, by the stream, is converted to sRGB; the EXIF resolution tags size the image, or 72 ppi. Image sequences, other codings and grids of cropped tiles are refused with a message, and so is every HEIF file in a build without the feature. The decoder checks image size before allocating (`crates/engine/src/cmd/fileio/tests_heifread.rs`). - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; grayscale TIFFs with an alpha channel or other extra channels place, relink and open, the alpha as the image's transparency (#1466 in part); PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546). - TIFFs that store each channel as its own plane (PlanarConfiguration 2) place, relink and open as the same image stored pixel by pixel does, 8 and 16 bits: grayscale with an alpha channel or other extra channels, and CMYK with or without them. A planar CMYK TIFF keeps its ink amounts and is stored as a CMYK TIFF with each pixel's inks together, at the file's resolution. A TIFF whose header claims more strips or tiles than can be counted is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`). - 1-bit TIFFs compressed with CCITT Group 3 (ITU-T T.4) place, relink and open: Compression 2 (modified Huffman) and Compression 3 with one-dimensional rows or, with T4Options bit 0, two-dimensional ones, with or without fill bits, in strips or tiles, with either FillOrder and either polarity. Rows in T.4's uncompressed mode are not read. A file whose pixels would take more than the decoding allowance as RGBA is refused as too large to draw (#1466 in part; `crates/engine/src/cmd/fileio/tests.rs`). @@ -1474,7 +1475,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Moving objects with raster effects (#1257):** during a drag or a dialog's preview, an object whose content only moved draws its cached drop shadows, outer glows and filtered content (Gaussian Blur, Feather and the Photoshop-style filters) from the rasters of an earlier frame at the nearest whole pixel, without running the effects again. The renderer first draws the moved content where the old content lies in its raster and compares the pixels. De-Interlace runs again. The frame after the drag runs the effects again and draws what a fresh renderer draws; on the desktop, the canvas renders that frame on the render worker. `texture_drag_perf` times a drag's frames and the frame after it. -- **Glass's Load Texture:** Effect › Distort › Glass… has a Load Texture… button that reads a PNG, JPEG, GIF, WebP, TIFF, BMP, Targa, PSD, PSB, PCX, Pixar or JPEG 2000 file and adds Loaded Texture to the Texture menu (`textureImage`: `{path}` or `{name?, dataBase64}`, stored in the effect and saved with the document as a grayscale PNG in base64). The texture lies one image pixel per point at Scaling 100%, centered on the object and repeated; each pixel's luma is the surface's height, from 0 for black to 1 pt for white, and a transparent pixel counts as middle gray. Scaling and Invert act on it as on the built-in textures. A Glass saved without `textureImage` draws and saves as before. +- **Glass's Load Texture:** Effect › Distort › Glass… has a Load Texture… button that reads a PNG, JPEG, GIF, WebP, TIFF, BMP, Targa, PSD, PSB, PCX, Pixar, JPEG 2000 or HEIC/HEIF file and adds Loaded Texture to the Texture menu (`textureImage`: `{path}` or `{name?, dataBase64}`, stored in the effect and saved with the document as a grayscale PNG in base64). The texture lies one image pixel per point at Scaling 100%, centered on the object and repeated; each pixel's luma is the surface's height, from 0 for black to 1 pt for white, and a transparent pixel counts as middle gray. Scaling and Invert act on it as on the built-in textures. A Glass saved without `textureImage` draws and saves as before. - **Bounded effect length inputs (#4638):** Effect dialog fields with defined length ranges (such as Crystallize Cell Size 3..300 pt) clamp immediately on commit and reject invalid inputs visibly with an accent highlight without reverting to sub-minimum values. - **Color Halftone in raster pixels (#3260):** Max Radius is 4–127 px of the Document Raster Effects resolution, as public documentation gives it, shown in px whatever the document units; documents from before open with their radii in points converted (document version 3), and a file saved for an older app version holds them in points again.