Skip to main content

fastpack_core/imaging/
loader.rs

1use std::hash::Hasher;
2use std::path::{Path, PathBuf};
3
4use image::DynamicImage;
5use rayon::prelude::*;
6use rustc_hash::FxHasher;
7
8use crate::{
9    error::CoreError,
10    types::{rect::Size, sprite::Sprite},
11};
12
13/// Load a single image file and return a [`Sprite`] with normalized RGBA8 pixel data.
14///
15/// Supports PNG, JPEG, BMP, TGA, WebP, and TIFF natively via the `image` crate.
16/// With the `svg` feature enabled, SVG files are rasterized at their natural viewport size.
17/// With the `psd` feature enabled, PSDs are flattened to a single RGBA layer.
18///
19/// The returned `Sprite` has `trim_rect`, `polygon`, `nine_patch`, `pivot`, and
20/// `alias_of` all set to `None`. Those fields are filled by later pipeline stages.
21pub fn load(path: &Path, id: impl Into<String>) -> Result<Sprite, CoreError> {
22    let id = id.into();
23    let image = decode(path)?;
24    let rgba = image.into_rgba8();
25    let (w, h) = rgba.dimensions();
26    let content_hash = hash_rgba(&rgba);
27    Ok(Sprite {
28        id,
29        source_path: path.to_path_buf(),
30        image: DynamicImage::ImageRgba8(rgba),
31        trim_rect: None,
32        original_size: Size { w, h },
33        polygon: None,
34        nine_patch: None,
35        pivot: None,
36        content_hash,
37        extrude: 0,
38        alias_of: None,
39    })
40}
41
42/// Load multiple sprites in parallel using rayon.
43///
44/// Each entry is `(absolute_path, sprite_id)`. Results are returned in input order.
45/// Failed loads are `Err` entries; callers decide whether to abort or skip them.
46pub fn load_many(paths: &[(PathBuf, String)]) -> Vec<Result<Sprite, CoreError>> {
47    paths
48        .par_iter()
49        .map(|(path, id)| load(path, id.clone()))
50        .collect()
51}
52
53fn decode(path: &Path) -> Result<DynamicImage, CoreError> {
54    let ext = path
55        .extension()
56        .and_then(|s| s.to_str())
57        .unwrap_or("")
58        .to_lowercase();
59
60    match ext.as_str() {
61        #[cfg(feature = "svg")]
62        "svg" => decode_svg(path),
63        #[cfg(feature = "psd")]
64        "psd" => decode_psd(path),
65        _ => image::open(path).map_err(|source| CoreError::ImageLoad {
66            path: path.to_path_buf(),
67            source,
68        }),
69    }
70}
71
72fn hash_rgba(img: &image::RgbaImage) -> u64 {
73    let mut h = FxHasher::default();
74    h.write(img.as_raw());
75    h.finish()
76}
77
78#[cfg(feature = "svg")]
79fn decode_svg(path: &Path) -> Result<DynamicImage, CoreError> {
80    let data = std::fs::read(path).map_err(CoreError::Io)?;
81    let opts = resvg::usvg::Options::default();
82    let tree = resvg::usvg::Tree::from_data(&data, &opts).map_err(|e| {
83        CoreError::Io(std::io::Error::new(
84            std::io::ErrorKind::InvalidData,
85            e.to_string(),
86        ))
87    })?;
88    let w = tree.size().width().ceil() as u32;
89    let h = tree.size().height().ceil() as u32;
90    if w == 0 || h == 0 {
91        return Err(CoreError::Io(std::io::Error::new(
92            std::io::ErrorKind::InvalidData,
93            "SVG has zero-size viewport",
94        )));
95    }
96    let mut pixmap = resvg::tiny_skia::Pixmap::new(w, h).ok_or_else(|| {
97        CoreError::Io(std::io::Error::new(
98            std::io::ErrorKind::InvalidData,
99            "could not allocate SVG pixmap",
100        ))
101    })?;
102    resvg::render(
103        &tree,
104        resvg::tiny_skia::Transform::default(),
105        &mut pixmap.as_mut(),
106    );
107    // tiny_skia produces premultiplied RGBA; convert to straight alpha for consistency.
108    let mut raw = pixmap.take();
109    demultiply_alpha(&mut raw);
110    image::RgbaImage::from_raw(w, h, raw)
111        .map(DynamicImage::ImageRgba8)
112        .ok_or_else(|| {
113            CoreError::Io(std::io::Error::new(
114                std::io::ErrorKind::InvalidData,
115                "SVG pixmap buffer size mismatch",
116            ))
117        })
118}
119
120#[cfg(feature = "svg")]
121fn demultiply_alpha(data: &mut [u8]) {
122    for pixel in data.chunks_exact_mut(4) {
123        let a = pixel[3];
124        if a == 0 {
125            pixel[0] = 0;
126            pixel[1] = 0;
127            pixel[2] = 0;
128        } else if a < 255 {
129            let inv = 255.0 / a as f32;
130            pixel[0] = (pixel[0] as f32 * inv).round().min(255.0) as u8;
131            pixel[1] = (pixel[1] as f32 * inv).round().min(255.0) as u8;
132            pixel[2] = (pixel[2] as f32 * inv).round().min(255.0) as u8;
133        }
134    }
135}
136
137#[cfg(feature = "psd")]
138fn decode_psd(path: &Path) -> Result<DynamicImage, CoreError> {
139    let data = std::fs::read(path).map_err(CoreError::Io)?;
140    let psd_file = psd::Psd::from_bytes(&data).map_err(|e| {
141        CoreError::Io(std::io::Error::new(
142            std::io::ErrorKind::InvalidData,
143            e.to_string(),
144        ))
145    })?;
146    let w = psd_file.width();
147    let h = psd_file.height();
148    image::RgbaImage::from_raw(w, h, psd_file.rgba())
149        .map(DynamicImage::ImageRgba8)
150        .ok_or_else(|| {
151            CoreError::Io(std::io::Error::new(
152                std::io::ErrorKind::InvalidData,
153                "PSD buffer size mismatch",
154            ))
155        })
156}