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.
7//!
8//! Every function here is pure over `&[u8]`: no filesystem, no threads, so the
9//! crate builds for `wasm32-unknown-unknown`.
10
11pub mod procreate;
12
13mod bitmap;
14#[cfg(feature = "text")]
15mod sheet;
16mod synth;
17
18pub use bitmap::*;
19#[cfg(feature = "text")]
20pub use sheet::*;
21pub use synth::*;
22
23use brushkit_abr::{parse_abr_deferred_without_patterns, ShapeTipFamily};
24use std::io::Cursor;
25
26#[derive(Debug, Clone, Copy)]
27pub struct PreviewOptions {
28    /// Larger side of every returned tip is at most this many pixels (>= 1).
29    pub max_cell: u32,
30}
31
32#[derive(Debug, Clone)]
33pub struct PreviewSet {
34    pub set_name: Option<String>,
35    pub entries: Vec<PreviewEntry>,
36}
37
38#[derive(Debug, Clone)]
39pub struct PreviewEntry {
40    pub index: usize,
41    pub name: String,
42    pub tip: TipPreview,
43}
44
45#[derive(Debug, Clone)]
46pub enum TipPreview {
47    Available(GrayscaleBitmap),
48    Unavailable(UnavailableReason),
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum UnavailableReason {
53    NoShapePng,
54    UnsupportedTipKind(String),
55    Corrupt(String),
56    TooLarge { width: u32, height: u32 },
57}
58
59#[derive(Debug)]
60pub struct PreviewError(pub String);
61
62impl std::fmt::Display for PreviewError {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        f.write_str(&self.0)
65    }
66}
67
68impl std::error::Error for PreviewError {}
69
70fn check_max_cell(opts: PreviewOptions) -> Result<u32, PreviewError> {
71    if opts.max_cell == 0 {
72        return Err(PreviewError("max_cell must be at least 1".to_string()));
73    }
74    Ok(opts.max_cell)
75}
76
77/// Where an `.abr` preview row came from. Orders rows that share a preset
78/// ordinal: sampled first, then computed, then unsupported.
79#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
80enum Source {
81    Sampled,
82    Computed,
83    Unsupported,
84}
85
86struct Row {
87    /// The preset ordinal from the descriptor, `usize::MAX` when unknown.
88    key: usize,
89    source: Source,
90    name: String,
91    tip: TipPreview,
92}
93
94/// Tips for a Photoshop `.abr` pack.
95///
96/// Sampled tips are decoded one at a time and downsampled immediately, so the
97/// peak footprint holds one full-size tip rather than the whole pack. Computed
98/// presets are synthesized from their geometry; a preset that declares neither
99/// is reported as an unsupported tip kind.
100///
101/// Embedded pattern payloads are neither copied nor decoded.
102pub fn preview_abr(bytes: &[u8], opts: PreviewOptions) -> Result<PreviewSet, PreviewError> {
103    let max_cell = check_max_cell(opts)?;
104
105    let deferred =
106        parse_abr_deferred_without_patterns(bytes).map_err(|e| PreviewError(e.to_string()))?;
107
108    let mut rows: Vec<Row> = Vec::new();
109
110    for i in 0..deferred.pack.brushes.len() {
111        let brush = &deferred.pack.brushes[i];
112        let key = brush.preset_index.unwrap_or(usize::MAX);
113        let name = if brush.name.is_empty() {
114            brush.id.clone()
115        } else {
116            brush.name.clone()
117        };
118        let tip = match deferred.decode_tip(i) {
119            Ok(tip) => TipPreview::Available(downsample(&to_grayscale(&tip), max_cell)),
120            Err(e) => TipPreview::Unavailable(UnavailableReason::Corrupt(e.to_string())),
121        };
122        rows.push(Row {
123            key,
124            source: Source::Sampled,
125            name,
126            tip,
127        });
128    }
129
130    for preset in &deferred.pack.computed_presets {
131        let key = preset.preset_index.unwrap_or(usize::MAX);
132        let tip = match preset
133            .descriptor
134            .computed
135            .as_ref()
136            .filter(|geom| can_synthesize(geom))
137            .and_then(synthesize_computed_tip)
138        {
139            Some(bitmap) => TipPreview::Available(downsample(&bitmap, max_cell)),
140            None => TipPreview::Unavailable(UnavailableReason::UnsupportedTipKind(
141                "computed".to_string(),
142            )),
143        };
144        rows.push(Row {
145            key,
146            source: Source::Computed,
147            name: preset.name.clone(),
148            tip,
149        });
150    }
151
152    for preset in &deferred.pack.unsupported_tip_presets {
153        let kind = match (&preset.tip_shape, &preset.shape_tip_family) {
154            (Some(shape), _) => format!("{shape:?}"),
155            (None, Some(ShapeTipFamily::Bristle)) => "bristle".to_string(),
156            (None, Some(ShapeTipFamily::Erodible)) => "erodible".to_string(),
157            (None, None) => "shape tip".to_string(),
158        };
159        rows.push(Row {
160            key: preset.preset_index,
161            source: Source::Unsupported,
162            name: preset.name.clone(),
163            tip: TipPreview::Unavailable(UnavailableReason::UnsupportedTipKind(kind)),
164        });
165    }
166
167    rows.sort_by_key(|row| (row.key, row.source));
168
169    let entries = rows
170        .into_iter()
171        .enumerate()
172        .map(|(index, row)| PreviewEntry {
173            index,
174            name: row.name,
175            tip: row.tip,
176        })
177        .collect();
178
179    Ok(PreviewSet {
180        set_name: None,
181        entries,
182    })
183}
184
185/// Tips for a Procreate `.brushset`.
186///
187/// With a `brushset.plist` the set name and the member order come from it;
188/// without one the members are the top-level directories that hold a
189/// `Brush.archive`, in zip order, and the set has no name.
190pub fn preview_brushset(bytes: &[u8], opts: PreviewOptions) -> Result<PreviewSet, PreviewError> {
191    let max_cell = check_max_cell(opts)?;
192    let mut zip = open_zip(bytes)?;
193
194    let (set_name, prefixes) = if zip.by_name("brushset.plist").is_ok() {
195        let buf = procreate::read_zip_entry(&mut zip, "brushset.plist").map_err(PreviewError)?;
196        let (name, uuids) = procreate::parse_brushset_plist(&buf).map_err(PreviewError)?;
197        (name, uuids.into_iter().map(|u| format!("{u}/")).collect())
198    } else {
199        let members: Vec<String> = procreate::members_in_zip_order(&mut zip)
200            .into_iter()
201            .map(|d| format!("{d}/"))
202            .collect();
203        if members.is_empty() {
204            return Err(PreviewError("no brushes found".to_string()));
205        }
206        (None, members)
207    };
208
209    let entries = prefixes
210        .iter()
211        .enumerate()
212        .map(|(index, prefix)| member_entry(&mut zip, index, prefix, max_cell))
213        .collect();
214
215    Ok(PreviewSet { set_name, entries })
216}
217
218/// The tip of a single Procreate `.brush`: one entry at index 0, read from the
219/// archive's root rather than from a member directory.
220pub fn preview_brush(bytes: &[u8], opts: PreviewOptions) -> Result<PreviewSet, PreviewError> {
221    let max_cell = check_max_cell(opts)?;
222    let mut zip = open_zip(bytes)?;
223
224    if zip.by_name("Brush.archive").is_err() {
225        return Err(PreviewError("Brush.archive not found".to_string()));
226    }
227
228    Ok(PreviewSet {
229        set_name: None,
230        entries: vec![member_entry(&mut zip, 0, "", max_cell)],
231    })
232}
233
234fn open_zip(bytes: &[u8]) -> Result<zip::ZipArchive<Cursor<&[u8]>>, PreviewError> {
235    zip::ZipArchive::new(Cursor::new(bytes))
236        .map_err(|e| PreviewError(format!("failed to open zip: {e}")))
237}
238
239/// One member of a Procreate archive. `prefix` is `"{uuid}/"` for a
240/// `.brushset` member and `""` for a root-layout `.brush`.
241///
242/// A member is always an entry: an archive that cannot be read names the entry
243/// after its directory and reports why, rather than shifting every index after
244/// it.
245fn member_entry(
246    zip: &mut zip::ZipArchive<Cursor<&[u8]>>,
247    index: usize,
248    prefix: &str,
249    max_cell: u32,
250) -> PreviewEntry {
251    let fallback_name = if prefix.is_empty() {
252        "Brush".to_string()
253    } else {
254        prefix.trim_end_matches('/').to_string()
255    };
256
257    let archive = procreate::read_zip_entry(zip, &format!("{prefix}Brush.archive"))
258        .and_then(|buf| procreate::brush_name(&buf));
259    let name = match archive {
260        Ok(name) => name.unwrap_or(fallback_name),
261        Err(msg) => {
262            return PreviewEntry {
263                index,
264                name: fallback_name,
265                tip: TipPreview::Unavailable(UnavailableReason::Corrupt(msg)),
266            }
267        }
268    };
269
270    let shape_path = format!("{prefix}Shape.png");
271    if zip.by_name(&shape_path).is_err() {
272        return PreviewEntry {
273            index,
274            name,
275            tip: TipPreview::Unavailable(UnavailableReason::NoShapePng),
276        };
277    }
278
279    let tip = match procreate::read_zip_entry(zip, &shape_path) {
280        Err(msg) => TipPreview::Unavailable(UnavailableReason::Corrupt(msg)),
281        Ok(png) => match procreate::decode_tip_png(&png) {
282            Ok(bitmap) => TipPreview::Available(downsample(&bitmap, max_cell)),
283            Err(procreate::ShapePngError::TooLarge { width, height }) => {
284                TipPreview::Unavailable(UnavailableReason::TooLarge { width, height })
285            }
286            Err(procreate::ShapePngError::Corrupt(msg)) => {
287                TipPreview::Unavailable(UnavailableReason::Corrupt(msg))
288            }
289        },
290    };
291
292    PreviewEntry { index, name, tip }
293}