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