Files
photocraft/crates/codecs/src/format.rs
T
1a4f867958 TIFF: read and write CIE L*a*b* (CIELab, ICCLab); Lab documents stay Lab (#4949)
* TIFF: read and write CIE L*a*b* (CIELab, ICCLab); Lab documents stay Lab

Photoshop saves Lab documents as CIELab TIFFs (PhotometricInterpretation
8, a* and b* signed), which PhotoCraft could not open, and PhotoCraft
saved Lab documents as sRGB TIFFs.

- codecs: a Lab channel layout (Lab, LabA) holding a* and b* as
  PhotoCraft's Lab documents do ((a + 128) / 255; 16 bits like the PSD
  importer), with its own Lab <-> sRGB conversion (same formulas as
  photocraft-color; codecs stays standalone) for formats without Lab,
  which now warn LabConverted.
- TIFF read: CIELab and ICCLab (8 and 16 bits, alpha) are handed to the
  tiff crate as RGB and labelled Lab; a* and b* are converted from the
  file's encoding (signed a / 256a, or offset).
- TIFF write: Lab and LabA as CIELab, as Photoshop writes it; float Lab
  is written with 16 bits (DepthReduced).
- io: a Lab TIFF opens as a Lab document; a Lab document with one plain
  layer is written to TIFF in Lab (other formats still use sRGB).

Photoshop 2025's Lab TIFFs open with the colours Photoshop shows (its
own RGB conversion matches within one level at 8 bits and exactly at 16
bits), and Photoshop reads PhotoCraft's Lab TIFFs back with the same
L*a*b* values.

Fixes #4936

* Raw: measure the Nikon D90 image area (4288x2848 at (10, 10))

Aligning the decoded D90 sensor plane with the camera's own JPEG puts the
image at (10, 10) in the 4352x2868 sensor plane, giving the camera's
4288x2848 frame (10-pixel margins; the right border reads the camera's flat
255 from column 4310). Add the measured ImageArea so a D90 NEF opens on the
camera's frame instead of the uncropped plane, and make a measured profile
leave a file that carries its own colour calibration (a DNG) untouched.

* Codecs: open Cineon (.cin) film frames read-only

Cineon is the 10-bit logarithmic film-scan format DPX grew from. Read it from
the same Kodak header family: the generic header (big-endian, orientation at
192, channel count at 193, the channel-1 bits/dimensions), one or three
channels, three 10-bit samples packed into each 32-bit word on a word
boundary. The 10-bit code values open as U16 (round(c x 65535 / 1023)); the
file's log black/white points are a display choice and are not applied. A
depth other than 10 bits or a channel count other than 1/3 is Unsupported;
truncation errors rather than panicking. Read-only (no Cineon writer), so
.cin joins the open filters and Cineon read closes part of #1049's G10 gap.

* PSD: write tiff blocks without copies, inflate merged ZIP once

- ImageSourceData::to_bytes writes the global mask and global blocks
  straight from the model with 4-byte alignment instead of cloning each
  block (data included) to set its padding. Same bytes.
- The tiff transcoder's nested regions and blocks write into the parent's
  buffer (taken and handed back, error or not) instead of building their
  own output and copying it up per nesting level. Bodies that fail or are
  not transcoded are truncated before being copied as stored or dropped.
  Same bytes.
- PsdFile::from_bytes_and_merged decodes the merged image with the parse
  when asked: a ZIP stream is inflated once (the decode's stream check is
  the parse's); the parse-only check runs only when the decode fails, to
  keep from_bytes' errors. PSD import uses it with merged_needed.
- PsdBuilder::composite documents that Gray/CMYK composites stay straight.

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

* Content-Aware Fill: measure a thin hole by its thickness, not its bounds (#1366)

Removing long, thin objects (coiled guitar strings, wires) left their
outline as darker and lighter blotches and copied pieces of other strings
into the fill. The pyramid shrank until the hole's bounding box was four
patches across: for a string across the image that is a level where the
hole has swallowed its surroundings, so the matches found there copy
whatever looks like the blur, and the finer levels (refine-only for wide
bounds) keep them.

A hole at most a quarter as thick as its bounds are long now counts as
thin: the pyramid stops while it is still about two patches thick, and
that level starts from a membrane across it (over a few pixels, the
surroundings themselves) instead of the onion peel, whose exhaustive
search is what made a shallow pyramid unaffordable. Compact holes are
unchanged bit for bit.

Refs #1366

* perf(compose,gpu): effect caches reserve tile addresses instead of pinning layers

Effect map cache entries kept a clone of their layer (a deep clone for groups) so the tile addresses the cache key hashes couldn't be reused. That kept an edited or deleted layer's pixels alive, uncounted, until the entry was evicted. Keep a Weak to each hashed tile instead: it reserves the address without keeping the pixels. The GPU compositor's styled-group pins and shape-source tile snapshots do the same. Part of #4530.

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

* fix(history): count snapshot-only tiles against the history budget

History panel snapshots outlived every budget: a Merged Layers snapshot is a full flattened copy that neither the history budget, the GPU budget's document bytes nor Purge accounted for. Trim now counts the tiles only snapshots hold as fixed cost, the GPU budget includes them, and Purge > Histories no longer reports tiles a kept snapshot still holds as freed. Part of #4530.

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

* Content-Aware Fill: every part of a thin selection keeps its surroundings (#1366)

Two more causes of ghosts when removing strings and the small knobs
selected with them:

* Auto sampling summarises the selection's surroundings by a few
  clusters. Around a long selection those come from the strings'
  surroundings, so a small part elsewhere (a tuning knob in a differently
  coloured corner) lost its own surroundings and was filled from far away
  in another colour. Around a thin selection the Auto area now always
  reaches as far as the selection is thick.
* The membrane that starts a thin hole now starts a patch radius out, past
  the anti-aliased fringe or halo that a selection drawn close to the
  object leaves outside it.

Refs #1366

* Content-Aware Fill: peel a thin hole where that stays affordable (#1366)

Starting every thin hole from a membrane smoothed Spot Healing on film
grain (spot_healing_keeps_film_grain_through_overlapping_strokes: grain
9.0 against 12.1 around). The onion peel copies the grain; it was only
given up because its exhaustive search is too slow on the big level a
long thin hole stops at. It now starts the thin hole whenever hole cells
times sources stay within 2^28 comparisons, and the membrane only beyond.

Refs #1366

* Select Subject: keep subject parts coloured like the backdrop, leave enclosed gaps open

Users on #2788 reported Select Subject dropping clothes that are close in
colour to a plain wall and bridging the gap between an arm and the torso.
Both come from the same place: the saliency pass labels low-contrast parts
of the subject as background, the background colour model then learns
them and explains them away, and the final blind hole fill closes every
enclosed gap up to 5 % of the mask.

Two changes in crates/algo:

- A second GrabCut pass whose background model only knows the backdrop. A
  colour model fitted to the image frame (minus a margin around the
  salient blob, in case the subject runs out of the picture) marks every
  pixel it explains as probable background; the blob and whatever the
  backdrop cannot account for start as probable foreground. Only
  components that touch the first pass's result survive.
- Hole filling that consults the colour models: specks (<= 0.2 % of the
  mask) always fill, larger holes up to 5 % fill only when their pixels
  fit the foreground model better than the background's.

Tests: two regressions (a cream-shouldered figure on a near-white wall
keeps its shoulders; a ring keeps its backdrop-coloured hole open), plus
ignored timing and picture-dump helpers. The 6 MP first run goes from
about 100 ms to about 200 ms on one core; the P29 repeat path is untouched.

* Keyboard shortcut sets: Set menu, New Set, Save, Delete, .kys Export and Summarize (K2)

Edit › Keyboard Shortcuts gets Photoshop's set management. A set is a saved copy of the
bindings (Preferences.shortcutSets, the one in use in shortcutSet; "" is PhotoCraft Defaults),
and the Set menu shows "(modified)" when the bindings differ from it. New Set…, Save and
Delete wait for OK like the rest of the dialog. Export… writes the dialog's bindings as a .kys
file (commands then tools, Photoshop's macOS key spelling), each row carrying our id as pc-id
so PhotoCraft imports its own sets back exactly, whatever the label; Summarize… writes
Photoshop's HTML list of every bound command by menu.

edit.keyboardShortcuts takes useSet (before the call's edits), saveSet, deleteSet, sets and
activeSet (after them), exportKys and summarize; every reply reports the sets, the set in use
and whether it is modified. Bad names, keys or counts are errors that change nothing.

* .kys import: tool keys and sets saved on Windows (K3)

Photoshop's <tool> rows now import: each matches our tool by name (our tool names are
Photoshop's) or by pc-id in sets PhotoCraft exported, and a letter other than the tool's own
becomes its override, while its own letter keeps the tool cycling with the others (#5036).

A set saved on Windows spells the primary modifier Ctrl where a Mac set writes Cmd and keeps
Control for the Control key. A set with no Cmd or Opt anywhere but some Ctrl is read as a
Windows set, its Ctrl as our Cmd: Ctrl+L is the Cmd+L default, not a Control-key override that
would also mean Control on a Mac. The import reports `windows`.

* cargo fmt the extension lists

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

---------

Co-authored-by: Vikram Bhaskaran <vikram.bhaskaran@outlook.com>
Co-authored-by: vangel76 <vangel1976@googlemail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Finn Newick <finnnk@gmail.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Diego Chagas <drochachagas@gmail.com>
Co-authored-by: Tanish Manku <tmanku.dev@gmail.com>
2026-10-15 00:48:20 -04:00

395 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Format identification and capability declarations.
use crate::image::{ChannelLayout, SampleType};
/// Supported (or known) raster file formats.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Format {
Png,
Jpeg,
Tiff,
WebP,
Gif,
Bmp,
Tga,
Ico,
/// Netpbm family: PBM/PGM/PPM (P1–P6), PAM (P7) and PFM (`PF`/`Pf`).
Pnm,
/// ZSoft Paintbrush: 1/2/4-bit planar palettes, 8-bit indexed and 24-bit RGB.
Pcx,
/// X PixMap icons (XPM2 plain text and XPM3 C-string arrays).
Xpm,
/// DirectDraw Surface: the top mip level of a 2D texture, uncompressed or BC1–BC3.
Dds,
Qoi,
OpenExr,
/// Radiance RGBE.
Hdr,
/// Encode-only behind the `avif` feature; see [`ASYMMETRIC_EXCEPTIONS`].
Avif,
/// HEIF/HEIC (HEVC-coded): read-only; see [`ASYMMETRIC_EXCEPTIONS`].
Heif,
/// FITS (Flexible Image Transport System), the astronomy format: gray or RGB image data.
Fits,
/// DPX (SMPTE 268M Digital Picture Exchange): luma, RGB or RGBA film and video frames.
Dpx,
/// Kodak Cineon (`.cin`, the 10-bit film-scan format DPX grew from): read-only.
Cineon,
}
/// What a format can hold **and** what this crate reads/writes for it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FormatCaps {
/// Decoding is available in this build.
pub read: bool,
/// Encoding is available in this build.
pub write: bool,
/// Sample types stored without conversion.
pub depths: &'static [SampleType],
/// Layouts stored *and read back* without conversion.
pub layouts: &'static [ChannelLayout],
pub alpha: bool,
pub icc: bool,
pub exif: bool,
pub xmp: bool,
/// Physical resolution (DPI) is preserved.
pub dpi: bool,
/// Free-form key/value text is preserved.
pub text: bool,
/// The container can hold animation/multiple frames. This crate only
/// decodes the first frame and encodes a single frame.
pub animation: bool,
/// Our encoder is lossy (quantization or lossy compression).
pub lossy: bool,
}
/// Formats that are enabled but intentionally not symmetric, with the reason.
/// The test-suite asserts that every other enabled format is read+write.
pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[
(Format::Xpm, "XPM is a legacy Unix icon interchange format. Reading existing XPM icons is supported; writing modern image assets should use PNG instead."),
(
Format::Pcx,
"PCX is supported for opening legacy paint and clipart assets. New files should use PNG; writing obsolete indexed and planar variants is intentionally not provided.",
),
(
Format::Dds,
"DDS is supported for opening game and 3D textures. Writing one well means choosing a block compression and generating mip levels, which belongs in a texture export, not Save As.",
),
(
Format::Avif,
"AVIF encode uses ravif (pure Rust) but decoding requires dav1d (C); read stays unsupported \
until a pure-Rust AV1 decoder is viable. Only enabled with the non-default `avif` feature.",
),
(
Format::Cineon,
"Cineon frames are imported for film work; this crate has no Cineon writer, so save an \
imported frame as DPX, TIFF or EXR instead.",
),
(
Format::Heif,
"HEIC decode uses heic-rs (pure Rust, in the optional photocraft-heif crate behind the \
non-default `heif` feature, which official builds enable), so iPhone and Mac photos open; \
writing needs an HEVC encoder, and the mature ones (x265, libheif) are C, so write stays \
unsupported. Without the feature HEIF is detected but neither read nor written.",
),
];
use ChannelLayout as L;
use SampleType as S;
const RGB_GRAY: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba];
const ALL_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::Cmyk, L::CmykA];
/// TIFF also stores CIE L*a*b* (CIELab).
const TIFF_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::Cmyk, L::CmykA, L::Lab, L::LabA];
impl Format {
/// Every format known to the crate (enabled or not).
pub const ALL: [Format; 20] = [
Format::Png,
Format::Jpeg,
Format::Tiff,
Format::WebP,
Format::Gif,
Format::Bmp,
Format::Tga,
Format::Ico,
Format::Pnm,
Format::Pcx,
Format::Xpm,
Format::Dds,
Format::Qoi,
Format::OpenExr,
Format::Hdr,
Format::Avif,
Format::Heif,
Format::Fits,
Format::Dpx,
Format::Cineon,
];
pub fn caps(self) -> FormatCaps {
caps(self)
}
/// Readable or writable in this build.
pub fn is_enabled(self) -> bool {
let c = caps(self);
c.read || c.write
}
pub fn name(self) -> &'static str {
match self {
Format::Png => "PNG",
Format::Jpeg => "JPEG",
Format::Tiff => "TIFF",
Format::WebP => "WebP",
Format::Gif => "GIF",
Format::Bmp => "BMP",
Format::Tga => "TGA",
Format::Ico => "ICO",
Format::Pnm => "Netpbm",
Format::Pcx => "PCX",
Format::Xpm => "XPM",
Format::Dds => "DDS",
Format::Qoi => "QOI",
Format::OpenExr => "OpenEXR",
Format::Hdr => "Radiance HDR",
Format::Avif => "AVIF",
Format::Heif => "HEIF",
Format::Fits => "FITS",
Format::Dpx => "DPX",
Format::Cineon => "Cineon",
}
}
/// Recognized file extensions (lowercase, without dot); the first one is
/// the preferred extension for writing.
pub fn extensions(self) -> &'static [&'static str] {
match self {
Format::Png => &["png", "apng"],
Format::Jpeg => &["jpg", "jpeg", "jpe", "jfif"],
Format::Tiff => &["tif", "tiff"],
Format::WebP => &["webp"],
Format::Gif => &["gif"],
Format::Bmp => &["bmp", "dib"],
Format::Tga => &["tga", "icb", "vda", "vst"],
Format::Ico => &["ico"],
Format::Pnm => &["pnm", "pbm", "pgm", "ppm", "pam", "pfm"],
Format::Pcx => &["pcx"],
Format::Xpm => &["xpm"],
Format::Dds => &["dds"],
Format::Qoi => &["qoi"],
Format::OpenExr => &["exr"],
Format::Hdr => &["hdr"],
Format::Avif => &["avif"],
Format::Heif => &["heic", "heif", "hif"],
Format::Fits => &["fits", "fit", "fts"],
Format::Dpx => &["dpx"],
Format::Cineon => &["cin"],
}
}
pub fn mime_type(self) -> &'static str {
match self {
Format::Png => "image/png",
Format::Jpeg => "image/jpeg",
Format::Tiff => "image/tiff",
Format::WebP => "image/webp",
Format::Gif => "image/gif",
Format::Bmp => "image/bmp",
Format::Tga => "image/x-tga",
Format::Ico => "image/x-icon",
Format::Pnm => "image/x-portable-anymap",
Format::Pcx => "image/x-pcx",
Format::Xpm => "image/x-xpixmap",
Format::Dds => "image/vnd-ms.dds",
Format::Qoi => "image/qoi",
Format::OpenExr => "image/x-exr",
Format::Hdr => "image/vnd.radiance",
Format::Avif => "image/avif",
Format::Heif => "image/heif",
Format::Fits => "image/fits",
Format::Dpx => "image/x-dpx",
Format::Cineon => "image/x-cin",
}
}
/// Maximum encodable dimensions, if the format has a hard limit.
pub fn max_dimensions(self) -> Option<(u32, u32)> {
match self {
Format::Jpeg | Format::Gif => Some((65535, 65535)),
Format::WebP => Some((16384, 16384)),
Format::Ico => Some((256, 256)),
_ => None,
}
}
}
/// Capability table. `read`/`write` reflect the current build's features.
pub fn caps(format: Format) -> FormatCaps {
let base = FormatCaps {
read: true,
write: true,
depths: &[S::U8],
layouts: &[L::Rgb, L::Rgba],
alpha: true,
icc: false,
exif: false,
xmp: false,
dpi: false,
text: false,
animation: false,
lossy: false,
};
match format {
Format::Png => {
FormatCaps { depths: &[S::U8, S::U16], layouts: RGB_GRAY, icc: true, exif: true, xmp: true, dpi: true, text: true, animation: true, ..base }
}
Format::Jpeg => FormatCaps { layouts: &[L::Gray, L::Rgb, L::Cmyk], alpha: false, icc: true, exif: true, xmp: true, dpi: true, lossy: true, ..base },
Format::Tiff => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: TIFF_LAYOUTS, icc: true, exif: true, xmp: true, dpi: true, text: true, ..base },
Format::WebP => FormatCaps { icc: true, exif: true, xmp: true, animation: true, ..base },
Format::Gif => FormatCaps { layouts: &[L::Rgba], animation: true, lossy: true, ..base },
Format::Bmp => base,
Format::Tga => FormatCaps { layouts: RGB_GRAY, ..base },
Format::Ico => FormatCaps { layouts: &[L::Rgba], ..base },
Format::Pnm => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: ALL_LAYOUTS, ..base },
Format::Pcx => FormatCaps { read: true, write: false, layouts: &[L::Rgb], alpha: false, ..base },
Format::Xpm => FormatCaps { read: true, write: false, layouts: &[L::Rgba], ..base },
Format::Dds => FormatCaps { read: true, write: false, layouts: RGB_GRAY, ..base },
Format::Qoi => base,
Format::OpenExr => FormatCaps { depths: &[S::F16, S::F32], layouts: RGB_GRAY, ..base },
Format::Hdr => FormatCaps { depths: &[S::F32], layouts: &[L::Rgb], alpha: false, lossy: true, ..base },
Format::Avif => FormatCaps { read: false, write: cfg!(feature = "avif"), lossy: true, ..base },
Format::Heif => {
FormatCaps { read: cfg!(feature = "heif"), write: false, depths: &[S::U8, S::U16], icc: true, exif: true, xmp: true, lossy: true, ..base }
}
Format::Fits => FormatCaps { depths: &[S::U8, S::U16, S::F32], layouts: &[L::Gray, L::Rgb], alpha: false, text: true, ..base },
// `text`: the DPX header fields only (see `fidelity_warnings_with`).
Format::Dpx => FormatCaps { depths: &[S::U8, S::U16], layouts: &[L::Gray, L::Rgb, L::Rgba], text: true, ..base },
Format::Cineon => FormatCaps { read: true, write: false, depths: &[S::U16], layouts: &[L::Gray, L::Rgb], alpha: false, ..base },
}
}
/// Identify a format from its leading bytes (magic numbers). TGA has no
/// magic and is recognized last, by header plausibility.
pub fn detect(bytes: &[u8]) -> Option<Format> {
let b = bytes;
if b.starts_with(b"\x89PNG\r\n\x1a\n") {
return Some(Format::Png);
}
if b.starts_with(&[0xFF, 0xD8, 0xFF]) {
return Some(Format::Jpeg);
}
if b.starts_with(b"II*\0") || b.starts_with(b"MM\0*") || b.starts_with(b"II+\0") || b.starts_with(b"MM\0+") {
return Some(Format::Tiff);
}
if b.len() >= 12 && &b[0..4] == b"RIFF" && &b[8..12] == b"WEBP" {
return Some(Format::WebP);
}
if b.starts_with(b"GIF87a") || b.starts_with(b"GIF89a") {
return Some(Format::Gif);
}
if b.starts_with(b"qoif") {
return Some(Format::Qoi);
}
if b.starts_with(b"DDS ") {
return Some(Format::Dds);
}
if b.starts_with(&[0x76, 0x2F, 0x31, 0x01]) {
return Some(Format::OpenExr);
}
if b.starts_with(b"#?RADIANCE") || b.starts_with(b"#?RGBE") {
return Some(Format::Hdr);
}
if b.len() >= 12 && &b[4..8] == b"ftyp" {
if is_avif_ftyp(b) {
return Some(Format::Avif);
}
if is_heif_ftyp(b) {
return Some(Format::Heif);
}
}
if b.len() >= 14 && b.starts_with(b"BM") {
return Some(Format::Bmp);
}
if b.len() >= 6 && b[0..4] == [0, 0, 1, 0] && (b[4] != 0 || b[5] != 0) {
return Some(Format::Ico);
}
if b.len() >= 3 && b[0] == b'P' && matches!(b[1], b'1'..=b'7' | b'F' | b'f') && b[2].is_ascii_whitespace() {
return Some(Format::Pnm);
}
if b.starts_with(b"SDPX") || b.starts_with(b"XPDS") {
return Some(Format::Dpx);
}
if crate::codecs::dpx::is_cineon(b) {
return Some(Format::Cineon);
}
if b.starts_with(b"SIMPLE =") {
return Some(Format::Fits);
}
if b.starts_with(b"/* XPM */") || b.starts_with(b"! XPM2\n") || b.starts_with(b"! XPM2\r\n") {
return Some(Format::Xpm);
}
if crate::codecs::pcx::looks_like_pcx(b) {
return Some(Format::Pcx);
}
if looks_like_tga(b) {
return Some(Format::Tga);
}
None
}
/// The `ftyp` brands: the major brand, then the compatible ones (the minor version between them is skipped).
fn ftyp_brands(b: &[u8]) -> impl Iterator<Item = &[u8; 4]> {
let size = u32::from_be_bytes([b[0], b[1], b[2], b[3]]) as usize;
let end = size.clamp(12, b.len().min(64));
b[8..end].as_chunks::<4>().0.iter().enumerate().filter(|(i, _)| *i != 1).map(|(_, c)| c)
}
fn is_avif_ftyp(b: &[u8]) -> bool {
ftyp_brands(b).any(|c| c == b"avif" || c == b"avis")
}
/// HEVC-coded HEIF (`heic`, `heix`, ...) or a generic HEIF file (`mif1`, `msf1`, `miaf`) that is
/// not AVIF (checked first). Sequence-only files (`hevc`, `msf1`) are recognised so that opening
/// one says what is unsupported instead of "unrecognized format".
fn is_heif_ftyp(b: &[u8]) -> bool {
ftyp_brands(b).any(|c| matches!(c, b"heic" | b"heix" | b"heim" | b"heis" | b"hevc" | b"hevx" | b"mif1" | b"msf1" | b"miaf"))
}
fn looks_like_tga(b: &[u8]) -> bool {
if b.len() < 18 {
return false;
}
if b.len() >= 26 && &b[b.len() - 18..] == b"TRUEVISION-XFILE.\0" {
return true;
}
let cmap_type = b[1];
let img_type = b[2];
let cmap_len = u16::from_le_bytes([b[5], b[6]]);
let cmap_depth = b[7];
let w = u16::from_le_bytes([b[12], b[13]]);
let h = u16::from_le_bytes([b[14], b[15]]);
let depth = b[16];
let desc = b[17];
let type_ok = matches!(img_type, 1 | 2 | 3 | 9 | 10 | 11);
let cmap_ok = match cmap_type {
0 => cmap_len == 0 && cmap_depth == 0 && !matches!(img_type, 1 | 9),
1 => matches!(img_type, 1 | 9) && cmap_len > 0 && matches!(cmap_depth, 15 | 16 | 24 | 32),
_ => false,
};
let depth_ok = match img_type {
1 | 9 => depth == 8,
3 | 11 => matches!(depth, 8 | 16),
_ => matches!(depth, 15 | 16 | 24 | 32),
};
type_ok && cmap_ok && depth_ok && w > 0 && h > 0 && desc & 0xC0 == 0
}
/// Look up a format by file extension (case-insensitive, with or without a
/// leading dot, or a full file name/path).
pub fn from_extension(ext: &str) -> Option<Format> {
let ext = ext.rsplit(['.', '/', '\\']).next().unwrap_or(ext).to_ascii_lowercase();
Format::ALL.into_iter().find(|f| f.extensions().contains(&ext.as_str()))
}