Skip to main content

shuyi_assets/
image.rs

1//! The image table: every PNG in a [`Bundle`] the game names, by bundle
2//! path, with its size. Screens name a picture by an [`ImageId`] from this
3//! table; the platform layer decodes and draws it. Only each file's header
4//! is read here, never its pixels.
5//!
6//! A one-bit screen's bundle may hold a one-bit picture file
7//! ([`crate::bitmap`]) at a PNG's path in place of the PNG: the table
8//! then gives the size of the PNG it was made from (its source size), so
9//! the image's id and size are the same as with the PNG.
10//!
11//! As formats (ADR-0007): an image's size is the [`ImageInfo`] kind, read
12//! by [`PngInfo`] and [`OneBitInfo`](crate::bitmap::OneBitInfo), and
13//! [`ImageTable::load_with`] builds the table with every image format a
14//! game registers. Its pixels are the [`Image`] kind, which `Png` reads
15//! with the `png` feature. A game that draws through `shuyi-app` needs
16//! none: the platform decodes a PNG's pixels when it uploads the picture.
17use crate::prelude::*;
18use serde::{Deserialize, Serialize};
19
20use alloc::collections::BTreeMap;
21
22use crate::bitmap::Bitmap;
23use crate::bundle::Bundle;
24use crate::error::ContentError;
25use crate::format::{AssetKind, AssetPath, DecodeCx, Format, Formats};
26
27/// The longest side an image may have, in pixels: what every graphics card
28/// the engine runs on (WebGL included) takes as one texture.
29pub const MAX_SIDE: u32 = 4096;
30
31/// An image's size in pixels. Build it with [`ImageInfo::new`] or
32/// [`ImageInfo::checked`] (ADR-0039).
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
34#[non_exhaustive]
35pub struct ImageInfo {
36    /// Width in pixels.
37    pub width: u32,
38    /// Height in pixels.
39    pub height: u32,
40}
41
42impl ImageInfo {
43    /// The size `width × height`, unchecked: [`checked`](Self::checked)
44    /// says whether an image may have it.
45    #[must_use]
46    pub const fn new(width: u32, height: u32) -> Self {
47        Self { width, height }
48    }
49
50    /// The size `width × height`, if an image may have it: neither side
51    /// 0, nor over [`MAX_SIDE`]. Otherwise what is wrong, in plain words.
52    pub fn checked(width: u32, height: u32) -> Result<Self, String> {
53        if width == 0 || height == 0 {
54            return Err(format!(
55                "image is {width}×{height} px; both sides must be greater than 0"
56            ));
57        }
58        if width > MAX_SIDE || height > MAX_SIDE {
59            return Err(format!(
60                "image is {width}×{height} px; neither side may be over {MAX_SIDE}"
61            ));
62        }
63        Ok(Self { width, height })
64    }
65}
66
67impl AssetKind for ImageInfo {
68    const NAME: &'static str = "image";
69}
70
71/// An image's pixels: four bytes each (red, green, blue, alpha), row by
72/// row from the top.
73#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
74pub struct Image {
75    /// Width in pixels.
76    pub width: u32,
77    /// Height in pixels.
78    pub height: u32,
79    /// `width × height × 4` bytes.
80    pub rgba: Vec<u8>,
81}
82
83impl Image {
84    /// A `width × height` picture of `color` (red, green, blue, alpha)
85    /// in every pixel. An empty size gives no bytes.
86    pub fn filled(width: u32, height: u32, color: impl Into<[u8; 4]>) -> Self {
87        let color = color.into();
88        Self {
89            width,
90            height,
91            rgba: color.repeat(width as usize * height as usize),
92        }
93    }
94
95    /// The pixel at `(x, y)`, or `None` outside the picture.
96    pub fn pixel(&self, x: u32, y: u32) -> Option<[u8; 4]> {
97        if x >= self.width || y >= self.height {
98            return None;
99        }
100        let i = (y as usize * self.width as usize + x as usize) * 4;
101        self.rgba.get(i..i + 4)?.try_into().ok()
102    }
103
104    /// Its size.
105    pub fn info(&self) -> ImageInfo {
106        ImageInfo {
107            width: self.width,
108            height: self.height,
109        }
110    }
111}
112
113impl AssetKind for Image {
114    const NAME: &'static str = "image pixels";
115}
116
117/// The first eight bytes of every PNG file.
118pub const PNG_SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n";
119
120/// The most bytes a PNG may have: 64 MiB, what a [`MAX_SIDE`] square of
121/// 8-bit RGBA pixels takes without compression.
122pub const MAX_PNG_BYTES: usize = 64 << 20;
123
124/// PNG read for its size (`png`, `.png`): only its header, never its
125/// pixels. The size must be one [`ImageInfo::checked`] allows.
126#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
127pub struct PngInfo;
128
129impl Format for PngInfo {
130    type Asset = ImageInfo;
131
132    fn id(&self) -> &'static str {
133        "png"
134    }
135
136    fn extensions(&self) -> &'static [&'static str] {
137        &["png"]
138    }
139
140    fn sniff(&self, bytes: &[u8]) -> bool {
141        bytes.starts_with(PNG_SIGNATURE)
142    }
143
144    fn max_bytes(&self) -> usize {
145        MAX_PNG_BYTES
146    }
147
148    fn decode(
149        &self,
150        file: &AssetPath,
151        bytes: &[u8],
152        _cx: &DecodeCx<'_>,
153    ) -> Result<ImageInfo, Vec<ContentError>> {
154        png_info(bytes).map_err(|message| vec![ContentError::new(file.shown(), message)])
155    }
156}
157
158/// The checked size in PNG file `bytes`, or what is wrong with it.
159fn png_info(bytes: &[u8]) -> Result<ImageInfo, String> {
160    let (width, height) = png_size(bytes).ok_or("not a valid PNG file")?;
161    ImageInfo::checked(width, height)
162}
163
164/// PNG read for its pixels (`png`, `.png`), with the `png` feature. Its
165/// size is checked first, as [`PngInfo`] does, so a picture too big is
166/// refused before its pixels are decoded.
167#[cfg(feature = "png")]
168#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
169pub struct Png;
170
171#[cfg(feature = "png")]
172impl Format for Png {
173    type Asset = Image;
174
175    fn id(&self) -> &'static str {
176        "png"
177    }
178
179    fn extensions(&self) -> &'static [&'static str] {
180        &["png"]
181    }
182
183    fn sniff(&self, bytes: &[u8]) -> bool {
184        bytes.starts_with(PNG_SIGNATURE)
185    }
186
187    fn max_bytes(&self) -> usize {
188        MAX_PNG_BYTES
189    }
190
191    fn decode(
192        &self,
193        file: &AssetPath,
194        bytes: &[u8],
195        _cx: &DecodeCx<'_>,
196    ) -> Result<Image, Vec<ContentError>> {
197        let error = |message: String| vec![ContentError::new(file.shown(), message)];
198        png_info(bytes).map_err(error)?;
199        let (width, height, rgba) = crate::bitmap::convert::png_pixels(bytes).map_err(error)?;
200        Ok(Image {
201            width,
202            height,
203            rgba,
204        })
205    }
206}
207
208/// Names one image of an [`ImageTable`]: its interned bundle path. Cheap to
209/// copy and compare. Only [`ImageTable::id`] makes one, so an id always
210/// names an image some table holds.
211#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
212pub struct ImageId(&'static str);
213
214impl ImageId {
215    /// The image's path in the bundle, e.g. `images/test_card.png`.
216    pub const fn path(self) -> &'static str {
217        self.0
218    }
219}
220
221/// A rectangle of an image, in image pixels.
222#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
223pub struct ImageRect {
224    /// Left edge.
225    pub x: u32,
226    /// Top edge.
227    pub y: u32,
228    /// Width.
229    pub w: u32,
230    /// Height.
231    pub h: u32,
232}
233
234/// A picture: a rectangle of an image.
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
236pub struct Picture {
237    /// The image.
238    pub image: ImageId,
239    /// Where in it.
240    pub rect: ImageRect,
241}
242
243/// Every image the game can draw, by bundle path (`/`-separated). Build it
244/// with [`ImageTable::new`], [`Default`] or [`ImageTable::load`]
245/// (ADR-0039).
246#[derive(Debug, Clone, PartialEq, Eq, Default)]
247#[non_exhaustive]
248pub struct ImageTable {
249    /// Size by path.
250    pub images: BTreeMap<&'static str, ImageInfo>,
251}
252
253impl ImageTable {
254    /// The table of `images`, their sizes by path.
255    #[must_use]
256    pub const fn new(images: BTreeMap<&'static str, ImageInfo>) -> Self {
257        Self { images }
258    }
259
260    /// Reads the size of every PNG in `bundle` but those at the paths in
261    /// `skip` (e.g. a font atlas, checked on its own), reporting every bad
262    /// file.
263    pub fn load(bundle: &Bundle, skip: &[&str]) -> Result<Self, Vec<ContentError>> {
264        let files = bundle
265            .files_under("")
266            .into_iter()
267            .filter(|path| is_png(path) && !skip.contains(path))
268            .map(|path| (path, bundle.bytes(path).unwrap_or_default()));
269        Self::from_files(bundle, files)
270    }
271
272    /// Like [`ImageTable::load`], with the image formats of `formats`:
273    /// every file in `bundle` whose extension one of them claims
274    /// ([`Formats::claims`]), but those at the paths in `skip`, read by
275    /// them ([`ImageTable::from_files_with`]). With
276    /// [`Formats::standard`] it reads the files `load` reads.
277    pub fn load_with(
278        bundle: &Bundle,
279        formats: &Formats,
280        skip: &[&str],
281    ) -> Result<Self, Vec<ContentError>> {
282        let files = bundle
283            .files_under("")
284            .into_iter()
285            .filter(|path| formats.claims::<ImageInfo>(path) && !skip.contains(path))
286            .map(|path| (path, bundle.bytes(path).unwrap_or_default()));
287        Self::from_files_with(bundle, formats, files)
288    }
289
290    /// Like [`ImageTable::from_files`], each file read by the image
291    /// formats of `formats` ([`Formats::decode`]). With
292    /// [`Formats::standard`] it gives what `from_files` gives for files
293    /// named `.png`.
294    pub fn from_files_with<'a>(
295        bundle: &Bundle,
296        formats: &Formats,
297        files: impl IntoIterator<Item = (&'static str, &'a [u8])>,
298    ) -> Result<Self, Vec<ContentError>> {
299        let cx = DecodeCx::in_bundle(bundle).with_limits(formats.limits);
300        let mut images = BTreeMap::new();
301        let mut errors = Vec::new();
302        for (path, bytes) in files {
303            let file = AssetPath::in_bundle(bundle, path);
304            match formats.decode::<ImageInfo>(&file, bytes, &cx) {
305                Ok(info) => {
306                    images.insert(path, info);
307                }
308                Err(e) => errors.extend(e),
309            }
310        }
311        if errors.is_empty() {
312            Ok(Self { images })
313        } else {
314            Err(errors)
315        }
316    }
317
318    /// The table of `files` (bundle path and contents), or every problem
319    /// found: a file that isn't a PNG (or a one-bit picture in a PNG's
320    /// place), an empty image, or one with a side
321    /// over [`MAX_SIDE`]. Errors name each file as `bundle` shows it.
322    pub fn from_files<'a>(
323        bundle: &Bundle,
324        files: impl IntoIterator<Item = (&'static str, &'a [u8])>,
325    ) -> Result<Self, Vec<ContentError>> {
326        let mut images = BTreeMap::new();
327        let mut errors = Vec::new();
328        for (path, bytes) in files {
329            match read_info(bytes) {
330                Ok(info) => {
331                    images.insert(path, info);
332                }
333                Err(message) => {
334                    errors.push(ContentError::new(bundle.display_path(path), message));
335                }
336            }
337        }
338        if errors.is_empty() {
339            Ok(Self { images })
340        } else {
341            Err(errors)
342        }
343    }
344
345    /// The id of the image at `path`, or `None` if the table lacks it.
346    pub fn id(&self, path: &str) -> Option<ImageId> {
347        self.images
348            .get_key_value(path)
349            .map(|(&path, _)| ImageId(path))
350    }
351
352    /// The size of image `id`, or `None` if it is another table's.
353    pub fn info(&self, id: ImageId) -> Option<ImageInfo> {
354        self.images.get(id.path()).copied()
355    }
356
357    /// Every image's id, in path order.
358    pub fn ids(&self) -> impl Iterator<Item = ImageId> + '_ {
359        self.images.keys().map(|&path| ImageId(path))
360    }
361}
362
363/// Whether `path` is named as a PNG (`.png`, any case).
364fn is_png(path: &str) -> bool {
365    path.rsplit_once('.').is_some_and(|(stem, ext)| {
366        !stem.is_empty() && !stem.ends_with('/') && ext.eq_ignore_ascii_case("png")
367    })
368}
369
370/// The size in PNG file `bytes`, or in one-bit picture file `bytes` the
371/// size of the PNG it was made from, or what is wrong with it in plain
372/// words.
373fn read_info(bytes: &[u8]) -> Result<ImageInfo, String> {
374    if Bitmap::is_bitmap(bytes) {
375        crate::bitmap::source_info(bytes)
376    } else {
377        png_info(bytes)
378    }
379}
380
381/// Width and height from a PNG's header, or `None` if `bytes` isn't a PNG.
382pub fn png_size(bytes: &[u8]) -> Option<(u32, u32)> {
383    // Signature, then the IHDR chunk: length (4), type (4), width (4), height (4).
384    let header = bytes.get(..24)?;
385    if &header[..8] != PNG_SIGNATURE || &header[12..16] != b"IHDR" {
386        return None;
387    }
388    let be = |at: usize| {
389        u32::from_be_bytes([header[at], header[at + 1], header[at + 2], header[at + 3]])
390    };
391    Some((be(16), be(20)))
392}
393
394/// The start of a `w × h` PNG file: enough for [`png_size`].
395#[cfg(test)]
396pub(crate) fn png_header(w: u32, h: u32) -> Vec<u8> {
397    let mut v = b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR".to_vec();
398    v.extend(w.to_be_bytes());
399    v.extend(h.to_be_bytes());
400    v.extend([8, 6, 0, 0, 0]);
401    v
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407    use crate::bundle::Layer;
408
409    /// A bundle of `files`, shown as `base/...`.
410    fn bundle(files: &'static [(&'static str, &'static [u8])]) -> Bundle {
411        Bundle::new(vec![Layer::table("base", files)])
412    }
413
414    fn leaked(bytes: Vec<u8>) -> &'static [u8] {
415        Box::leak(bytes.into_boxed_slice())
416    }
417
418    fn messages(result: Result<ImageTable, Vec<ContentError>>) -> Vec<String> {
419        result
420            .err()
421            .unwrap_or_default()
422            .iter()
423            .map(ToString::to_string)
424            .collect()
425    }
426
427    #[test]
428    fn png_size_reads_header() {
429        assert_eq!(png_size(&png_header(256, 288)), Some((256, 288)));
430        // Distinct bytes, so any byte-order slip shows.
431        assert_eq!(
432            png_size(&png_header(0x0102_0304, 0x0506_0708)),
433            Some((0x0102_0304, 0x0506_0708))
434        );
435        assert_eq!(png_size(&png_header(256, 288)[..23]), None);
436        let mut bad = png_header(1, 1);
437        bad[0] = 0;
438        assert_eq!(png_size(&bad), None);
439        let mut bad = png_header(1, 1);
440        bad[12] = b'X';
441        assert_eq!(png_size(&bad), None);
442    }
443
444    #[test]
445    fn is_png_goes_by_the_extension() {
446        assert!(is_png("images/a.png"));
447        assert!(is_png("a.PNG"));
448        assert!(!is_png("images/a.ron"));
449        assert!(!is_png("images/png"));
450        assert!(!is_png("images/.png"));
451        assert!(!is_png("a.png.txt"));
452    }
453
454    #[test]
455    fn from_files_keeps_each_size_by_path() {
456        // The biggest image allowed is `MAX_SIDE` each way.
457        let (a, b) = (png_header(16, 24), png_header(MAX_SIDE, MAX_SIDE));
458        let files = [("x/b.png", &b[..]), ("a.png", &a[..])];
459        let table = ImageTable::from_files(&bundle(&[]), files).unwrap_or_default();
460        let sizes: Vec<_> = table.images.iter().collect();
461        assert_eq!(
462            sizes,
463            [
464                (
465                    &"a.png",
466                    &ImageInfo {
467                        width: 16,
468                        height: 24
469                    }
470                ),
471                (
472                    &"x/b.png",
473                    &ImageInfo {
474                        width: MAX_SIDE,
475                        height: MAX_SIDE
476                    }
477                ),
478            ]
479        );
480        assert_eq!(
481            ImageTable::from_files(&bundle(&[]), []),
482            Ok(ImageTable::default())
483        );
484    }
485
486    #[test]
487    fn ids_name_images_by_path() {
488        let png = png_header(16, 24);
489        let files = [("a.png", &png[..]), ("b.png", &png[..])];
490        let table = ImageTable::from_files(&bundle(&[]), files).unwrap_or_default();
491        let a = table.id("a.png");
492        assert_eq!(a.map(ImageId::path), Some("a.png"));
493        assert_ne!(a, table.id("b.png"));
494        assert_eq!(table.id("c.png"), None);
495        let size = ImageInfo {
496            width: 16,
497            height: 24,
498        };
499        assert_eq!(a.and_then(|id| table.info(id)), Some(size));
500        let paths: Vec<_> = table.ids().map(ImageId::path).collect();
501        assert_eq!(paths, ["a.png", "b.png"]);
502        // An id of another table's image has no size here.
503        assert_eq!(a.and_then(|id| ImageTable::default().info(id)), None);
504    }
505
506    #[test]
507    fn a_one_bit_picture_in_a_pngs_place_has_the_pngs_size() {
508        // 4 × 2 pixels, made from a 2 × 1 PNG.
509        let bits = crate::bitmap::encode((4, 2), (2, 1), &[&[0, 0]], None).unwrap();
510        let mut broken = bits.clone();
511        broken.pop();
512        let files = [("a.png", &bits[..]), ("b.png", &broken[..])];
513        let table = ImageTable::from_files(&bundle(&[]), files[..1].iter().copied());
514        let a = table.as_ref().ok().and_then(|t| t.id("a.png"));
515        let size = a.and_then(|id| table.as_ref().ok()?.info(id));
516        assert_eq!(
517            size,
518            Some(ImageInfo {
519                width: 2,
520                height: 1
521            })
522        );
523        assert_eq!(
524            messages(ImageTable::from_files(&bundle(&[]), files)),
525            ["base/b.png: a one-bit picture's size doesn't match its header"]
526        );
527    }
528
529    #[test]
530    fn every_bad_file_is_reported_by_name() {
531        let ok = png_header(1, 1);
532        let (wide, tall) = (png_header(MAX_SIDE + 1, 8), png_header(8, MAX_SIDE + 1));
533        let (flat, thin) = (png_header(8, 0), png_header(0, 8));
534        let files = [
535            ("images/ok.png", &ok[..]),
536            ("images/text.png", &b"not an image"[..]),
537            ("images/wide.png", &wide[..]),
538            ("images/tall.png", &tall[..]),
539            ("images/flat.png", &flat[..]),
540            ("images/thin.png", &thin[..]),
541        ];
542        assert_eq!(
543            messages(ImageTable::from_files(&bundle(&[]), files)),
544            [
545                "base/images/text.png: not a valid PNG file",
546                "base/images/wide.png: image is 4097×8 px; neither side may be over 4096",
547                "base/images/tall.png: image is 8×4097 px; neither side may be over 4096",
548                "base/images/flat.png: image is 8×0 px; both sides must be greater than 0",
549                "base/images/thin.png: image is 0×8 px; both sides must be greater than 0",
550            ]
551        );
552    }
553
554    #[test]
555    fn load_reads_every_png_of_the_bundle_but_the_skipped() {
556        let files: &'static [(&'static str, &'static [u8])] = Box::leak(Box::new([
557            ("data/a.ron", &b"()"[..]),
558            ("fonts/atlas.png", leaked(png_header(128, 64))),
559            ("images/card.png", leaked(png_header(16, 16))),
560            ("units/Big.PNG", leaked(png_header(32, 48))),
561        ]));
562        let bundle = bundle(files);
563        let table = ImageTable::load(&bundle, &["fonts/atlas.png"]).unwrap_or_default();
564        let paths: Vec<_> = table.ids().map(ImageId::path).collect();
565        assert_eq!(paths, ["images/card.png", "units/Big.PNG"]);
566        let all = ImageTable::load(&bundle, &[]).unwrap_or_default();
567        assert_eq!(all.images.len(), 3);
568        let broken: &'static [(&'static str, &'static [u8])] =
569            &[("images/bad.png", b"not an image")];
570        assert_eq!(
571            messages(ImageTable::load(&self::bundle(broken), &[])),
572            ["base/images/bad.png: not a valid PNG file"]
573        );
574    }
575
576    #[test]
577    fn filled_gives_the_colour_in_every_pixel() {
578        assert_eq!(Image::filled(0, 0, [1, 2, 3, 4]), Image::default());
579        let image = Image::filled(3, 2, [1, 2, 3, 4]);
580        assert_eq!((image.width, image.height), (3, 2));
581        assert_eq!(image.rgba, [1, 2, 3, 4].repeat(6));
582        // A picture with no rows has no bytes, however wide.
583        assert_eq!(Image::filled(5, 0, [9; 4]).rgba, Vec::<u8>::new());
584    }
585
586    #[test]
587    fn pixel_reads_inside_and_nothing_outside() {
588        let mut image = Image::filled(3, 2, [0; 4]);
589        image.rgba[4..8].copy_from_slice(&[1, 2, 3, 4]);
590        image.rgba[20..24].copy_from_slice(&[5, 6, 7, 8]);
591        assert_eq!(image.pixel(1, 0), Some([1, 2, 3, 4]));
592        assert_eq!(image.pixel(0, 0), Some([0; 4]));
593        // The last pixel.
594        assert_eq!(image.pixel(2, 1), Some([5, 6, 7, 8]));
595        // One past the right and the bottom side.
596        assert_eq!(image.pixel(3, 0), None);
597        assert_eq!(image.pixel(0, 2), None);
598        // One past the left and top sides wraps to the far end of a `u32`.
599        assert_eq!(image.pixel(u32::MAX, 0), None);
600        assert_eq!(image.pixel(0, u32::MAX), None);
601        // Bytes short of the size read as nothing, never a panic.
602        image.rgba.truncate(20);
603        assert_eq!(image.pixel(2, 1), None);
604    }
605
606    /// A size and a table as games build theirs, in `const` items.
607    const SIZE: ImageInfo = ImageInfo::new(16, 8);
608    const NONE: ImageTable = ImageTable::new(BTreeMap::new());
609
610    #[test]
611    fn the_constructors_give_the_literals() {
612        assert_eq!(
613            SIZE,
614            ImageInfo {
615                width: 16,
616                height: 8,
617            }
618        );
619        assert_eq!(ImageInfo::checked(16, 8), Ok(SIZE));
620        assert_eq!(NONE, ImageTable::default());
621        assert_eq!(
622            ImageTable::new([("a.png", SIZE)].into()),
623            ImageTable {
624                images: [("a.png", SIZE)].into(),
625            }
626        );
627    }
628}