Skip to main content

brushkit_preview/
lib.rs

1//! Tip bitmaps for every brush file this workspace reads.
2//!
3//! `preview_abr`, `preview_brush` and `preview_brushset` each take the whole
4//! file as bytes and return one [`PreviewEntry`] per brush in file order,
5//! available or not. A brush whose tip cannot be rendered is reported with a
6//! reason rather than dropped, so a caller can lay out a complete grid. Each
7//! has a `_first_available` twin that returns only the first `n` available
8//! entries and builds no entry after them.
9//!
10//! Every function here is pure over `&[u8]`: no filesystem, no threads, so the
11//! crate builds for `wasm32-unknown-unknown`.
12
13pub mod procreate;
14
15mod bitmap;
16#[cfg(feature = "text")]
17mod sheet;
18mod synth;
19
20pub use bitmap::*;
21#[cfg(feature = "text")]
22pub use sheet::*;
23pub use synth::*;
24
25use brushkit_abr::{parse_abr_all_deferred_without_patterns, DeferredPack, ShapeTipFamily};
26use std::io::Cursor;
27use std::num::NonZeroU32;
28
29#[derive(Debug, Clone, Copy)]
30pub struct PreviewOptions {
31    /// Larger side of every returned tip is at most this many pixels (>= 1).
32    pub max_cell: u32,
33}
34
35#[derive(Debug, Clone)]
36pub struct PreviewSet {
37    pub set_name: Option<String>,
38    pub entries: Vec<PreviewEntry>,
39}
40
41/// File-declared raster dimensions, independent of the returned preview size.
42/// A readable header does not guarantee valid pixels or a safe allocation size.
43#[derive(Debug, Clone, Copy, Eq, PartialEq)]
44pub struct SourceDimensions {
45    width: NonZeroU32,
46    height: NonZeroU32,
47}
48
49impl SourceDimensions {
50    pub fn new(width: u32, height: u32) -> Option<Self> {
51        Some(Self {
52            width: NonZeroU32::new(width)?,
53            height: NonZeroU32::new(height)?,
54        })
55    }
56
57    pub fn width(self) -> u32 {
58        self.width.get()
59    }
60    pub fn height(self) -> u32 {
61        self.height.get()
62    }
63}
64
65#[derive(Debug, Clone)]
66pub struct PreviewEntry {
67    pub index: usize,
68    pub name: String,
69    pub tip: TipPreview,
70    /// None when the brush has no source raster or its dimensions cannot be read.
71    pub source_dimensions: Option<SourceDimensions>,
72}
73
74#[derive(Debug, Clone)]
75pub enum TipPreview {
76    /// A bitmap with a nonzero width and height. A tip that decodes to zero
77    /// area is `Unavailable` with [`UnavailableReason::Corrupt`].
78    Available(GrayscaleBitmap),
79    Unavailable(UnavailableReason),
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub enum UnavailableReason {
84    NoShapePng,
85    UnsupportedTipKind(String),
86    Corrupt(String),
87    /// A side over [`procreate::MAX_PNG_DIMENSION`], or a decode over
88    /// [`procreate::MAX_ENTRY_BYTES`], counted as [`TipImageError::TooLarge`]
89    /// describes.
90    TooLarge {
91        width: u32,
92        height: u32,
93    },
94}
95
96#[derive(Debug)]
97pub struct PreviewError(pub String);
98
99impl std::fmt::Display for PreviewError {
100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101        f.write_str(&self.0)
102    }
103}
104
105impl std::error::Error for PreviewError {}
106
107fn check_max_cell(opts: PreviewOptions) -> Result<u32, PreviewError> {
108    if opts.max_cell == 0 {
109        return Err(PreviewError("max_cell must be at least 1".to_string()));
110    }
111    Ok(opts.max_cell)
112}
113
114/// Which entries a preview returns.
115#[derive(Clone, Copy)]
116enum Take {
117    All,
118    /// The first `n` entries whose tip is available. This stops pulling after
119    /// the `n`th; it saves work only because callers pass a lazy iterator.
120    FirstAvailable(usize),
121}
122
123impl Take {
124    fn collect(self, entries: impl Iterator<Item = PreviewEntry>) -> Vec<PreviewEntry> {
125        match self {
126            Take::All => entries.collect(),
127            Take::FirstAvailable(n) => entries
128                .filter(|entry| matches!(entry.tip, TipPreview::Available(_)))
129                .take(n)
130                .collect(),
131        }
132    }
133}
134
135/// Where an `.abr` preview row came from: an index into `brushes`,
136/// `computed_presets` or `unsupported_tip_presets` of the parsed pack. The
137/// derived order, variant first and then index, orders rows that share a
138/// preset ordinal: sampled first, then computed, then unsupported, each in
139/// file order.
140#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
141enum Source {
142    Sampled(usize),
143    Computed(usize),
144    Unsupported(usize),
145}
146
147struct Row {
148    /// The preset ordinal from the descriptor, `usize::MAX` when unknown.
149    key: usize,
150    source: Source,
151}
152
153/// Tips for a Photoshop `.abr` pack.
154///
155/// Sampled tips are decoded one at a time and downsampled immediately, so the
156/// peak footprint holds one full-size tip rather than the whole pack. Computed
157/// presets are synthesized from their geometry; a preset that declares neither
158/// is reported as an unsupported tip kind. A sampled tip whose pixels fail to
159/// decode is a `Corrupt` entry, not an error for the whole preview. The
160/// exception is a raw v1 or v2 tip whose pixels run past the end of the input,
161/// which fails the whole preview.
162///
163/// Embedded pattern payloads are neither copied nor decoded.
164pub fn preview_abr(bytes: &[u8], opts: PreviewOptions) -> Result<PreviewSet, PreviewError> {
165    abr(bytes, opts, Take::All)
166}
167
168/// The first `n` entries of [`preview_abr`] whose tip is available, in the
169/// same order and with the same `index` they have there, so indices may skip.
170/// Unavailable entries do not count toward `n`, and entries after the `n`th
171/// available one are not built, so their tips are not decoded or downsampled.
172///
173/// The whole pack is still parsed, but the parse decodes no tip.
174pub fn preview_abr_first_available(
175    bytes: &[u8],
176    opts: PreviewOptions,
177    n: usize,
178) -> Result<PreviewSet, PreviewError> {
179    abr(bytes, opts, Take::FirstAvailable(n))
180}
181
182fn abr(bytes: &[u8], opts: PreviewOptions, take: Take) -> Result<PreviewSet, PreviewError> {
183    let max_cell = check_max_cell(opts)?;
184
185    let deferred =
186        parse_abr_all_deferred_without_patterns(bytes).map_err(|e| PreviewError(e.to_string()))?;
187    let pack = &deferred.pack;
188
189    let mut rows: Vec<Row> = Vec::new();
190    rows.extend(pack.brushes.iter().enumerate().map(|(i, brush)| Row {
191        key: brush.preset_index.unwrap_or(usize::MAX),
192        source: Source::Sampled(i),
193    }));
194    rows.extend(
195        pack.computed_presets
196            .iter()
197            .enumerate()
198            .map(|(i, preset)| Row {
199                key: preset.preset_index.unwrap_or(usize::MAX),
200                source: Source::Computed(i),
201            }),
202    );
203    rows.extend(
204        pack.unsupported_tip_presets
205            .iter()
206            .enumerate()
207            .map(|(i, preset)| Row {
208                key: preset.preset_index,
209                source: Source::Unsupported(i),
210            }),
211    );
212
213    rows.sort_by_key(|row| (row.key, row.source));
214
215    let entries = rows
216        .into_iter()
217        .enumerate()
218        .map(|(index, row)| abr_entry(&deferred, index, row.source, max_cell));
219
220    Ok(PreviewSet {
221        set_name: None,
222        entries: take.collect(entries),
223    })
224}
225
226/// Render one `.abr` row. A sampled tip is decoded and downsampled here, so
227/// only the rows a caller takes are decoded.
228fn abr_entry(
229    deferred: &DeferredPack<'_>,
230    index: usize,
231    source: Source,
232    max_cell: u32,
233) -> PreviewEntry {
234    #[cfg(test)]
235    tests::record_entry();
236    let pack = &deferred.pack;
237    let (name, tip, source_dimensions) = match source {
238        Source::Sampled(i) => {
239            let brush = &pack.brushes[i];
240            let name = if brush.name.is_empty() {
241                brush.id.clone()
242            } else {
243                brush.name.clone()
244            };
245            let tip = match deferred.decode_tip(i) {
246                Ok(tip) => tip_preview(&to_grayscale(&tip), max_cell),
247                Err(e) => TipPreview::Unavailable(UnavailableReason::Corrupt(e.to_string())),
248            };
249            (
250                name,
251                tip,
252                SourceDimensions::new(brush.tip.width, brush.tip.height),
253            )
254        }
255        Source::Computed(i) => {
256            let preset = &pack.computed_presets[i];
257            let tip = match preset
258                .descriptor
259                .computed
260                .as_ref()
261                .filter(|geom| can_synthesize(geom))
262                .and_then(synthesize_computed_tip)
263            {
264                Some(bitmap) => tip_preview(&bitmap, max_cell),
265                None => TipPreview::Unavailable(UnavailableReason::UnsupportedTipKind(
266                    "computed".to_string(),
267                )),
268            };
269            (preset.name.clone(), tip, None)
270        }
271        Source::Unsupported(i) => {
272            let preset = &pack.unsupported_tip_presets[i];
273            let kind = match (&preset.tip_shape, &preset.shape_tip_family) {
274                (Some(shape), _) => format!("{shape:?}"),
275                (None, Some(ShapeTipFamily::Bristle)) => "bristle".to_string(),
276                (None, Some(ShapeTipFamily::Erodible)) => "erodible".to_string(),
277                (None, None) => "shape tip".to_string(),
278            };
279            (
280                preset.name.clone(),
281                TipPreview::Unavailable(UnavailableReason::UnsupportedTipKind(kind)),
282                None,
283            )
284        }
285    };
286    PreviewEntry {
287        index,
288        name,
289        tip,
290        source_dimensions,
291    }
292}
293
294/// A full-size tip downsampled to `max_cell`. A zero-area tip is not
295/// drawable, and `downsample` would widen its zero side to 1 when the other
296/// side exceeds `max_cell`.
297fn tip_preview(bitmap: &GrayscaleBitmap, max_cell: u32) -> TipPreview {
298    if bitmap.width == 0 || bitmap.height == 0 {
299        return TipPreview::Unavailable(UnavailableReason::Corrupt(
300            "tip has zero area".to_string(),
301        ));
302    }
303    TipPreview::Available(downsample(bitmap, max_cell))
304}
305
306/// Tips for a Procreate `.brushset`.
307///
308/// With a `brushset.plist` the set name and the member order come from it;
309/// without one the members are the top-level directories that hold a
310/// `Brush.archive`, in zip order, and the set has no name.
311pub fn preview_brushset(bytes: &[u8], opts: PreviewOptions) -> Result<PreviewSet, PreviewError> {
312    brushset(bytes, opts, Take::All)
313}
314
315/// The first `n` entries of [`preview_brushset`] whose tip is available, in
316/// the same order and with the same `index` they have there, so indices may
317/// skip. Unavailable entries do not count toward `n`, and members after the
318/// `n`th available one are not read.
319pub fn preview_brushset_first_available(
320    bytes: &[u8],
321    opts: PreviewOptions,
322    n: usize,
323) -> Result<PreviewSet, PreviewError> {
324    brushset(bytes, opts, Take::FirstAvailable(n))
325}
326
327fn brushset(bytes: &[u8], opts: PreviewOptions, take: Take) -> Result<PreviewSet, PreviewError> {
328    let max_cell = check_max_cell(opts)?;
329    let mut zip = open_zip(bytes)?;
330
331    let (set_name, prefixes) = if zip.by_name("brushset.plist").is_ok() {
332        let buf = procreate::read_zip_entry(&mut zip, "brushset.plist").map_err(PreviewError)?;
333        let (name, uuids) = procreate::parse_brushset_plist(&buf).map_err(PreviewError)?;
334        (name, uuids.into_iter().map(|u| format!("{u}/")).collect())
335    } else {
336        let members: Vec<String> = procreate::members_in_zip_order(&mut zip)
337            .into_iter()
338            .map(|d| format!("{d}/"))
339            .collect();
340        if members.is_empty() {
341            return Err(PreviewError("no brushes found".to_string()));
342        }
343        (None, members)
344    };
345
346    let entries = prefixes
347        .iter()
348        .enumerate()
349        .map(|(index, prefix)| member_entry(&mut zip, index, prefix, max_cell));
350
351    Ok(PreviewSet {
352        set_name,
353        entries: take.collect(entries),
354    })
355}
356
357/// The tip of a single Procreate `.brush`: one entry at index 0, read from the
358/// archive's root rather than from a member directory.
359pub fn preview_brush(bytes: &[u8], opts: PreviewOptions) -> Result<PreviewSet, PreviewError> {
360    brush(bytes, opts, Take::All)
361}
362
363/// [`preview_brush`] limited to available tips: its single entry when the tip
364/// is available and `n >= 1`, otherwise no entries. With `n == 0` the tip is
365/// not decoded.
366pub fn preview_brush_first_available(
367    bytes: &[u8],
368    opts: PreviewOptions,
369    n: usize,
370) -> Result<PreviewSet, PreviewError> {
371    brush(bytes, opts, Take::FirstAvailable(n))
372}
373
374fn brush(bytes: &[u8], opts: PreviewOptions, take: Take) -> Result<PreviewSet, PreviewError> {
375    let max_cell = check_max_cell(opts)?;
376    let mut zip = open_zip(bytes)?;
377
378    if zip.by_name("Brush.archive").is_err() {
379        return Err(PreviewError("Brush.archive not found".to_string()));
380    }
381
382    let entry = std::iter::once_with(|| member_entry(&mut zip, 0, "", max_cell));
383    Ok(PreviewSet {
384        set_name: None,
385        entries: take.collect(entry),
386    })
387}
388
389fn open_zip(bytes: &[u8]) -> Result<zip::ZipArchive<Cursor<&[u8]>>, PreviewError> {
390    zip::ZipArchive::new(Cursor::new(bytes))
391        .map_err(|e| PreviewError(format!("failed to open zip: {e}")))
392}
393
394/// One member of a Procreate archive. `prefix` is `"{uuid}/"` for a
395/// `.brushset` member and `""` for a root-layout `.brush`.
396///
397/// A member is always an entry: an archive that cannot be read names the entry
398/// after its directory and reports why, rather than shifting every index after
399/// it. A readable `Shape.png` reports its size either way.
400fn member_entry(
401    zip: &mut zip::ZipArchive<Cursor<&[u8]>>,
402    index: usize,
403    prefix: &str,
404    max_cell: u32,
405) -> PreviewEntry {
406    #[cfg(test)]
407    tests::record_entry();
408    let fallback_name = if prefix.is_empty() {
409        "Brush".to_string()
410    } else {
411        prefix.trim_end_matches('/').to_string()
412    };
413
414    let archive = procreate::read_zip_entry(zip, &format!("{prefix}Brush.archive"))
415        .and_then(|buf| procreate::brush_name(&buf));
416
417    let shape_path = format!("{prefix}Shape.png");
418    let shape = if zip.by_name(&shape_path).is_ok() {
419        Some(procreate::read_zip_entry(zip, &shape_path))
420    } else {
421        None
422    };
423    let source_dimensions = match &shape {
424        Some(Ok(png)) => bitmap::header_dimensions(png)
425            .and_then(|(width, height)| SourceDimensions::new(width, height)),
426        _ => None,
427    };
428
429    let (name, tip) = match archive {
430        Ok(name) => (name.unwrap_or(fallback_name), shape_tip(shape, max_cell)),
431        Err(msg) => (
432            fallback_name,
433            TipPreview::Unavailable(UnavailableReason::Corrupt(msg)),
434        ),
435    };
436
437    PreviewEntry {
438        index,
439        name,
440        tip,
441        source_dimensions,
442    }
443}
444
445/// The tip for a member's `Shape.png`: `None` when the member has no shape,
446/// otherwise the read result.
447fn shape_tip(shape: Option<Result<Vec<u8>, String>>, max_cell: u32) -> TipPreview {
448    let png = match shape {
449        None => return TipPreview::Unavailable(UnavailableReason::NoShapePng),
450        Some(Err(msg)) => return TipPreview::Unavailable(UnavailableReason::Corrupt(msg)),
451        Some(Ok(png)) => png,
452    };
453    match procreate::decode_tip_png(&png) {
454        Ok(bitmap) => tip_preview(&bitmap, max_cell),
455        Err(procreate::ShapePngError::TooLarge { width, height }) => {
456            TipPreview::Unavailable(UnavailableReason::TooLarge { width, height })
457        }
458        Err(procreate::ShapePngError::Corrupt(msg)) => {
459            TipPreview::Unavailable(UnavailableReason::Corrupt(msg))
460        }
461    }
462}
463
464// The integration tests' fixture builders, shared with the unit tests below.
465#[cfg(test)]
466#[path = "../tests/common/mod.rs"]
467mod common;
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472    use crate::common::{
473        brush_archive, brushset_plist, gray_png, legacy_abr, samp_abr, zip_with, SampTip,
474    };
475    use std::cell::Cell;
476
477    thread_local! {
478        // Thread-local because cargo runs unit tests on parallel threads.
479        static ENTRIES: Cell<usize> = const { Cell::new(0) };
480    }
481
482    /// Called once per entry built, before any of its tip is read or decoded.
483    pub(super) fn record_entry() {
484        ENTRIES.with(|count| count.set(count.get() + 1));
485    }
486
487    /// Entries built by `f` on this thread.
488    fn entries_built<T>(f: impl FnOnce() -> T) -> usize {
489        ENTRIES.with(|count| count.set(0));
490        f();
491        ENTRIES.with(Cell::get)
492    }
493
494    const OPTS: PreviewOptions = PreviewOptions { max_cell: 8 };
495
496    fn tip(corrupt: bool) -> SampTip {
497        SampTip {
498            width: 4,
499            height: 4,
500            fill: 0x80,
501            corrupt,
502        }
503    }
504
505    #[test]
506    fn abr_builds_only_the_entries_it_returns() {
507        let bytes = samp_abr(&[
508            tip(false),
509            tip(false),
510            tip(false),
511            tip(false),
512            tip(false),
513            tip(false),
514        ]);
515        assert_eq!(
516            entries_built(|| preview_abr_first_available(&bytes, OPTS, 2).unwrap()),
517            2
518        );
519        assert_eq!(
520            entries_built(|| preview_abr_first_available(&bytes, OPTS, 0).unwrap()),
521            0
522        );
523        assert_eq!(entries_built(|| preview_abr(&bytes, OPTS).unwrap()), 6);
524    }
525
526    #[test]
527    fn abr_failed_decode_does_not_count_toward_n() {
528        let bytes = samp_abr(&[tip(false), tip(false), tip(true)]);
529        assert!(matches!(
530            preview_abr(&bytes, OPTS).unwrap().entries[0].tip,
531            TipPreview::Unavailable(UnavailableReason::Corrupt(_))
532        ));
533        let mut set = None;
534        assert_eq!(
535            entries_built(|| set = Some(preview_abr_first_available(&bytes, OPTS, 1).unwrap())),
536            2
537        );
538        let entries = set.unwrap().entries;
539        assert_eq!(entries.len(), 1);
540        assert_eq!(entries[0].index, 1);
541    }
542
543    #[test]
544    fn abr_v2_corrupt_tip_is_one_corrupt_entry() {
545        let bytes = legacy_abr(&[tip(false), tip(true), tip(false)]);
546        let entries = preview_abr(&bytes, OPTS).unwrap().entries;
547        let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
548        assert_eq!(names, ["brush_2", "brush_1", "brush_0"]);
549        assert!(matches!(entries[0].tip, TipPreview::Available(_)));
550        assert!(matches!(
551            entries[1].tip,
552            TipPreview::Unavailable(UnavailableReason::Corrupt(_))
553        ));
554        assert!(matches!(entries[2].tip, TipPreview::Available(_)));
555    }
556
557    #[test]
558    fn abr_zero_area_tip_is_unavailable_and_does_not_count_toward_n() {
559        let sized = |width, height| SampTip {
560            width,
561            height,
562            fill: 0x80,
563            corrupt: false,
564        };
565        // Legacy entries are listed in reverse, so the drawable tip comes last.
566        // The 0x20 and 20x0 tips exceed max_cell on one side, which downsample
567        // would widen to 1.
568        let bytes = legacy_abr(&[
569            sized(1, 1),
570            sized(0, 20),
571            sized(0, 1),
572            sized(0, 1),
573            sized(20, 0),
574        ]);
575
576        let entries = preview_abr(&bytes, OPTS).unwrap().entries;
577        assert_eq!(entries.len(), 5);
578        for entry in &entries[..4] {
579            assert!(
580                matches!(
581                    &entry.tip,
582                    TipPreview::Unavailable(UnavailableReason::Corrupt(msg)) if msg == "tip has zero area"
583                ),
584                "{entry:?}"
585            );
586        }
587
588        let entries = preview_abr_first_available(&bytes, OPTS, 4)
589            .unwrap()
590            .entries;
591        assert_eq!(entries.len(), 1);
592        assert_eq!(entries[0].index, 4);
593        assert!(matches!(
594            &entries[0].tip,
595            TipPreview::Available(b) if (b.width, b.height, b.data.as_slice()) == (1, 1, &[0x80][..])
596        ));
597    }
598
599    #[test]
600    fn brushset_reads_only_the_members_it_returns() {
601        let archive = brush_archive("Tip");
602        let shape = gray_png(4, 4, 200);
603        let plist = brushset_plist("Set", &["a", "b", "c", "d"]);
604        let mut files: Vec<(String, &[u8])> = vec![("brushset.plist".into(), &plist)];
605        for member in ["a", "b", "c", "d"] {
606            files.push((format!("{member}/Brush.archive"), &archive));
607            files.push((format!("{member}/Shape.png"), &shape));
608        }
609        let files: Vec<(&str, &[u8])> = files.iter().map(|(p, b)| (p.as_str(), *b)).collect();
610        let bytes = zip_with(&files);
611        assert_eq!(
612            entries_built(|| preview_brushset_first_available(&bytes, OPTS, 1).unwrap()),
613            1
614        );
615        assert_eq!(entries_built(|| preview_brushset(&bytes, OPTS).unwrap()), 4);
616    }
617}