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_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    Available(GrayscaleBitmap),
77    Unavailable(UnavailableReason),
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub enum UnavailableReason {
82    NoShapePng,
83    UnsupportedTipKind(String),
84    Corrupt(String),
85    TooLarge { width: u32, height: u32 },
86}
87
88#[derive(Debug)]
89pub struct PreviewError(pub String);
90
91impl std::fmt::Display for PreviewError {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        f.write_str(&self.0)
94    }
95}
96
97impl std::error::Error for PreviewError {}
98
99fn check_max_cell(opts: PreviewOptions) -> Result<u32, PreviewError> {
100    if opts.max_cell == 0 {
101        return Err(PreviewError("max_cell must be at least 1".to_string()));
102    }
103    Ok(opts.max_cell)
104}
105
106/// Which entries a preview returns.
107#[derive(Clone, Copy)]
108enum Take {
109    All,
110    /// The first `n` entries whose tip is available. This stops pulling after
111    /// the `n`th; it saves work only because callers pass a lazy iterator.
112    FirstAvailable(usize),
113}
114
115impl Take {
116    fn collect(self, entries: impl Iterator<Item = PreviewEntry>) -> Vec<PreviewEntry> {
117        match self {
118            Take::All => entries.collect(),
119            Take::FirstAvailable(n) => entries
120                .filter(|entry| matches!(entry.tip, TipPreview::Available(_)))
121                .take(n)
122                .collect(),
123        }
124    }
125}
126
127/// Where an `.abr` preview row came from: an index into `brushes`,
128/// `computed_presets` or `unsupported_tip_presets` of the parsed pack. The
129/// derived order, variant first and then index, orders rows that share a
130/// preset ordinal: sampled first, then computed, then unsupported, each in
131/// file order.
132#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
133enum Source {
134    Sampled(usize),
135    Computed(usize),
136    Unsupported(usize),
137}
138
139struct Row {
140    /// The preset ordinal from the descriptor, `usize::MAX` when unknown.
141    key: usize,
142    source: Source,
143}
144
145/// Tips for a Photoshop `.abr` pack.
146///
147/// Sampled tips are decoded one at a time and downsampled immediately, so the
148/// peak footprint holds one full-size tip rather than the whole pack. Computed
149/// presets are synthesized from their geometry; a preset that declares neither
150/// is reported as an unsupported tip kind.
151///
152/// Embedded pattern payloads are neither copied nor decoded.
153pub fn preview_abr(bytes: &[u8], opts: PreviewOptions) -> Result<PreviewSet, PreviewError> {
154    abr(bytes, opts, Take::All)
155}
156
157/// The first `n` entries of [`preview_abr`] whose tip is available, in the
158/// same order and with the same `index` they have there, so indices may skip.
159/// Unavailable entries do not count toward `n`, and entries after the `n`th
160/// available one are not built, so their tips are not decoded or downsampled.
161///
162/// The whole pack is still parsed, and the parser decodes some tips itself:
163/// every tip of a v1 or v2 pack, and in newer packs the tips that a preset
164/// uses as its dual brush (see [`brushkit_abr::DeferredPack`]).
165pub fn preview_abr_first_available(
166    bytes: &[u8],
167    opts: PreviewOptions,
168    n: usize,
169) -> Result<PreviewSet, PreviewError> {
170    abr(bytes, opts, Take::FirstAvailable(n))
171}
172
173fn abr(bytes: &[u8], opts: PreviewOptions, take: Take) -> Result<PreviewSet, PreviewError> {
174    let max_cell = check_max_cell(opts)?;
175
176    let deferred =
177        parse_abr_deferred_without_patterns(bytes).map_err(|e| PreviewError(e.to_string()))?;
178    let pack = &deferred.pack;
179
180    let mut rows: Vec<Row> = Vec::new();
181    rows.extend(pack.brushes.iter().enumerate().map(|(i, brush)| Row {
182        key: brush.preset_index.unwrap_or(usize::MAX),
183        source: Source::Sampled(i),
184    }));
185    rows.extend(
186        pack.computed_presets
187            .iter()
188            .enumerate()
189            .map(|(i, preset)| Row {
190                key: preset.preset_index.unwrap_or(usize::MAX),
191                source: Source::Computed(i),
192            }),
193    );
194    rows.extend(
195        pack.unsupported_tip_presets
196            .iter()
197            .enumerate()
198            .map(|(i, preset)| Row {
199                key: preset.preset_index,
200                source: Source::Unsupported(i),
201            }),
202    );
203
204    rows.sort_by_key(|row| (row.key, row.source));
205
206    let entries = rows
207        .into_iter()
208        .enumerate()
209        .map(|(index, row)| abr_entry(&deferred, index, row.source, max_cell));
210
211    Ok(PreviewSet {
212        set_name: None,
213        entries: take.collect(entries),
214    })
215}
216
217/// Render one `.abr` row. A sampled tip is decoded and downsampled here, so
218/// only the rows a caller takes are decoded.
219fn abr_entry(deferred: &DeferredPack, index: usize, source: Source, max_cell: u32) -> PreviewEntry {
220    #[cfg(test)]
221    tests::record_entry();
222    let pack = &deferred.pack;
223    let (name, tip, source_dimensions) = match source {
224        Source::Sampled(i) => {
225            let brush = &pack.brushes[i];
226            let name = if brush.name.is_empty() {
227                brush.id.clone()
228            } else {
229                brush.name.clone()
230            };
231            let tip = match deferred.decode_tip(i) {
232                Ok(tip) => TipPreview::Available(downsample(&to_grayscale(&tip), max_cell)),
233                Err(e) => TipPreview::Unavailable(UnavailableReason::Corrupt(e.to_string())),
234            };
235            (
236                name,
237                tip,
238                SourceDimensions::new(brush.tip.width, brush.tip.height),
239            )
240        }
241        Source::Computed(i) => {
242            let preset = &pack.computed_presets[i];
243            let tip = match preset
244                .descriptor
245                .computed
246                .as_ref()
247                .filter(|geom| can_synthesize(geom))
248                .and_then(synthesize_computed_tip)
249            {
250                Some(bitmap) => TipPreview::Available(downsample(&bitmap, max_cell)),
251                None => TipPreview::Unavailable(UnavailableReason::UnsupportedTipKind(
252                    "computed".to_string(),
253                )),
254            };
255            (preset.name.clone(), tip, None)
256        }
257        Source::Unsupported(i) => {
258            let preset = &pack.unsupported_tip_presets[i];
259            let kind = match (&preset.tip_shape, &preset.shape_tip_family) {
260                (Some(shape), _) => format!("{shape:?}"),
261                (None, Some(ShapeTipFamily::Bristle)) => "bristle".to_string(),
262                (None, Some(ShapeTipFamily::Erodible)) => "erodible".to_string(),
263                (None, None) => "shape tip".to_string(),
264            };
265            (
266                preset.name.clone(),
267                TipPreview::Unavailable(UnavailableReason::UnsupportedTipKind(kind)),
268                None,
269            )
270        }
271    };
272    PreviewEntry {
273        index,
274        name,
275        tip,
276        source_dimensions,
277    }
278}
279
280/// Tips for a Procreate `.brushset`.
281///
282/// With a `brushset.plist` the set name and the member order come from it;
283/// without one the members are the top-level directories that hold a
284/// `Brush.archive`, in zip order, and the set has no name.
285pub fn preview_brushset(bytes: &[u8], opts: PreviewOptions) -> Result<PreviewSet, PreviewError> {
286    brushset(bytes, opts, Take::All)
287}
288
289/// The first `n` entries of [`preview_brushset`] whose tip is available, in
290/// the same order and with the same `index` they have there, so indices may
291/// skip. Unavailable entries do not count toward `n`, and members after the
292/// `n`th available one are not read.
293pub fn preview_brushset_first_available(
294    bytes: &[u8],
295    opts: PreviewOptions,
296    n: usize,
297) -> Result<PreviewSet, PreviewError> {
298    brushset(bytes, opts, Take::FirstAvailable(n))
299}
300
301fn brushset(bytes: &[u8], opts: PreviewOptions, take: Take) -> Result<PreviewSet, PreviewError> {
302    let max_cell = check_max_cell(opts)?;
303    let mut zip = open_zip(bytes)?;
304
305    let (set_name, prefixes) = if zip.by_name("brushset.plist").is_ok() {
306        let buf = procreate::read_zip_entry(&mut zip, "brushset.plist").map_err(PreviewError)?;
307        let (name, uuids) = procreate::parse_brushset_plist(&buf).map_err(PreviewError)?;
308        (name, uuids.into_iter().map(|u| format!("{u}/")).collect())
309    } else {
310        let members: Vec<String> = procreate::members_in_zip_order(&mut zip)
311            .into_iter()
312            .map(|d| format!("{d}/"))
313            .collect();
314        if members.is_empty() {
315            return Err(PreviewError("no brushes found".to_string()));
316        }
317        (None, members)
318    };
319
320    let entries = prefixes
321        .iter()
322        .enumerate()
323        .map(|(index, prefix)| member_entry(&mut zip, index, prefix, max_cell));
324
325    Ok(PreviewSet {
326        set_name,
327        entries: take.collect(entries),
328    })
329}
330
331/// The tip of a single Procreate `.brush`: one entry at index 0, read from the
332/// archive's root rather than from a member directory.
333pub fn preview_brush(bytes: &[u8], opts: PreviewOptions) -> Result<PreviewSet, PreviewError> {
334    brush(bytes, opts, Take::All)
335}
336
337/// [`preview_brush`] limited to available tips: its single entry when the tip
338/// is available and `n >= 1`, otherwise no entries. With `n == 0` the tip is
339/// not decoded.
340pub fn preview_brush_first_available(
341    bytes: &[u8],
342    opts: PreviewOptions,
343    n: usize,
344) -> Result<PreviewSet, PreviewError> {
345    brush(bytes, opts, Take::FirstAvailable(n))
346}
347
348fn brush(bytes: &[u8], opts: PreviewOptions, take: Take) -> Result<PreviewSet, PreviewError> {
349    let max_cell = check_max_cell(opts)?;
350    let mut zip = open_zip(bytes)?;
351
352    if zip.by_name("Brush.archive").is_err() {
353        return Err(PreviewError("Brush.archive not found".to_string()));
354    }
355
356    let entry = std::iter::once_with(|| member_entry(&mut zip, 0, "", max_cell));
357    Ok(PreviewSet {
358        set_name: None,
359        entries: take.collect(entry),
360    })
361}
362
363fn open_zip(bytes: &[u8]) -> Result<zip::ZipArchive<Cursor<&[u8]>>, PreviewError> {
364    zip::ZipArchive::new(Cursor::new(bytes))
365        .map_err(|e| PreviewError(format!("failed to open zip: {e}")))
366}
367
368/// One member of a Procreate archive. `prefix` is `"{uuid}/"` for a
369/// `.brushset` member and `""` for a root-layout `.brush`.
370///
371/// A member is always an entry: an archive that cannot be read names the entry
372/// after its directory and reports why, rather than shifting every index after
373/// it. A readable `Shape.png` reports its size either way.
374fn member_entry(
375    zip: &mut zip::ZipArchive<Cursor<&[u8]>>,
376    index: usize,
377    prefix: &str,
378    max_cell: u32,
379) -> PreviewEntry {
380    #[cfg(test)]
381    tests::record_entry();
382    let fallback_name = if prefix.is_empty() {
383        "Brush".to_string()
384    } else {
385        prefix.trim_end_matches('/').to_string()
386    };
387
388    let archive = procreate::read_zip_entry(zip, &format!("{prefix}Brush.archive"))
389        .and_then(|buf| procreate::brush_name(&buf));
390
391    let shape_path = format!("{prefix}Shape.png");
392    let shape = if zip.by_name(&shape_path).is_ok() {
393        Some(procreate::read_zip_entry(zip, &shape_path))
394    } else {
395        None
396    };
397    let source_dimensions = match &shape {
398        Some(Ok(png)) => procreate::header_dimensions(png)
399            .and_then(|(width, height)| SourceDimensions::new(width, height)),
400        _ => None,
401    };
402
403    let (name, tip) = match archive {
404        Ok(name) => (name.unwrap_or(fallback_name), shape_tip(shape, max_cell)),
405        Err(msg) => (
406            fallback_name,
407            TipPreview::Unavailable(UnavailableReason::Corrupt(msg)),
408        ),
409    };
410
411    PreviewEntry {
412        index,
413        name,
414        tip,
415        source_dimensions,
416    }
417}
418
419/// The tip for a member's `Shape.png`: `None` when the member has no shape,
420/// otherwise the read result.
421fn shape_tip(shape: Option<Result<Vec<u8>, String>>, max_cell: u32) -> TipPreview {
422    let png = match shape {
423        None => return TipPreview::Unavailable(UnavailableReason::NoShapePng),
424        Some(Err(msg)) => return TipPreview::Unavailable(UnavailableReason::Corrupt(msg)),
425        Some(Ok(png)) => png,
426    };
427    match procreate::decode_tip_png(&png) {
428        Ok(bitmap) => TipPreview::Available(downsample(&bitmap, max_cell)),
429        Err(procreate::ShapePngError::TooLarge { width, height }) => {
430            TipPreview::Unavailable(UnavailableReason::TooLarge { width, height })
431        }
432        Err(procreate::ShapePngError::Corrupt(msg)) => {
433            TipPreview::Unavailable(UnavailableReason::Corrupt(msg))
434        }
435    }
436}
437
438// The integration tests' fixture builders, shared with the unit tests below.
439#[cfg(test)]
440#[path = "../tests/common/mod.rs"]
441mod common;
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446    use crate::common::{brush_archive, brushset_plist, gray_png, samp_abr, zip_with, SampTip};
447    use std::cell::Cell;
448
449    thread_local! {
450        // Thread-local because cargo runs unit tests on parallel threads.
451        static ENTRIES: Cell<usize> = const { Cell::new(0) };
452    }
453
454    /// Called once per entry built, before any of its tip is read or decoded.
455    pub(super) fn record_entry() {
456        ENTRIES.with(|count| count.set(count.get() + 1));
457    }
458
459    /// Entries built by `f` on this thread.
460    fn entries_built<T>(f: impl FnOnce() -> T) -> usize {
461        ENTRIES.with(|count| count.set(0));
462        f();
463        ENTRIES.with(Cell::get)
464    }
465
466    const OPTS: PreviewOptions = PreviewOptions { max_cell: 8 };
467
468    fn tip(corrupt: bool) -> SampTip {
469        SampTip {
470            width: 4,
471            height: 4,
472            fill: 0x80,
473            corrupt,
474        }
475    }
476
477    #[test]
478    fn abr_builds_only_the_entries_it_returns() {
479        let bytes = samp_abr(&[
480            tip(false),
481            tip(false),
482            tip(false),
483            tip(false),
484            tip(false),
485            tip(false),
486        ]);
487        assert_eq!(
488            entries_built(|| preview_abr_first_available(&bytes, OPTS, 2).unwrap()),
489            2
490        );
491        assert_eq!(
492            entries_built(|| preview_abr_first_available(&bytes, OPTS, 0).unwrap()),
493            0
494        );
495        assert_eq!(entries_built(|| preview_abr(&bytes, OPTS).unwrap()), 6);
496    }
497
498    #[test]
499    fn abr_failed_decode_does_not_count_toward_n() {
500        let bytes = samp_abr(&[tip(false), tip(false), tip(true)]);
501        assert!(matches!(
502            preview_abr(&bytes, OPTS).unwrap().entries[0].tip,
503            TipPreview::Unavailable(UnavailableReason::Corrupt(_))
504        ));
505        let mut set = None;
506        assert_eq!(
507            entries_built(|| set = Some(preview_abr_first_available(&bytes, OPTS, 1).unwrap())),
508            2
509        );
510        let entries = set.unwrap().entries;
511        assert_eq!(entries.len(), 1);
512        assert_eq!(entries[0].index, 1);
513    }
514
515    #[test]
516    fn brushset_reads_only_the_members_it_returns() {
517        let archive = brush_archive("Tip");
518        let shape = gray_png(4, 4, 200);
519        let plist = brushset_plist("Set", &["a", "b", "c", "d"]);
520        let mut files: Vec<(String, &[u8])> = vec![("brushset.plist".into(), &plist)];
521        for member in ["a", "b", "c", "d"] {
522            files.push((format!("{member}/Brush.archive"), &archive));
523            files.push((format!("{member}/Shape.png"), &shape));
524        }
525        let files: Vec<(&str, &[u8])> = files.iter().map(|(p, b)| (p.as_str(), *b)).collect();
526        let bytes = zip_with(&files);
527        assert_eq!(
528            entries_built(|| preview_brushset_first_available(&bytes, OPTS, 1).unwrap()),
529            1
530        );
531        assert_eq!(entries_built(|| preview_brushset(&bytes, OPTS).unwrap()), 4);
532    }
533}