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//! [`preview`] is the function all six call. It also takes a `keep_going`
11//! callback that can stop the call between entries, for a host that shows
12//! previews under a time limit.
13//!
14//! The available tips of one call hold at most [`MAX_PREVIEW_BYTES`] of bitmap
15//! data together. Entries past that point are
16//! [`UnavailableReason::OverBudget`] and their tips are not decoded.
17//!
18//! Every function here is pure over `&[u8]`: no filesystem, no threads, so the
19//! crate builds for `wasm32-unknown-unknown`.
20
21pub mod procreate;
22
23mod bitmap;
24#[cfg(feature = "text")]
25mod sheet;
26mod synth;
27
28pub use bitmap::*;
29#[cfg(feature = "text")]
30pub use sheet::*;
31pub use synth::*;
32
33use brushkit_abr::{
34    parse_abr_all_deferred_without_patterns, DeferredPack, SampledBrush, ShapeTipFamily,
35    UnavailableTip,
36};
37use std::collections::HashMap;
38use std::io::Cursor;
39use std::num::NonZeroU32;
40
41#[derive(Debug, Clone, Copy)]
42pub struct PreviewOptions {
43    /// Larger side of every returned tip is at most this many pixels (>= 1).
44    pub max_cell: u32,
45}
46
47/// The most bytes of bitmap data ([`GrayscaleBitmap::data`]) the `Available`
48/// tips of one [`preview`] call, or one call of a function built on it, hold
49/// together.
50pub const MAX_PREVIEW_BYTES: usize = 256 * 1024 * 1024;
51
52// A `Shape.png` at the largest size the reader accepts must fit on its own, or
53// such a tip could never be available.
54const _: () = assert!(MAX_PREVIEW_BYTES >= (procreate::MAX_PNG_DIMENSION as usize).pow(2));
55
56#[derive(Debug, Clone)]
57pub struct PreviewSet {
58    pub set_name: Option<String>,
59    pub entries: Vec<PreviewEntry>,
60    /// Entries of the file that were not built because the `keep_going`
61    /// callback of [`preview`] returned `false`. 0 when the call ran to its
62    /// end.
63    pub not_reached: usize,
64}
65
66/// File-declared raster dimensions, independent of the returned preview size.
67/// A readable header does not guarantee valid pixels or a safe allocation size.
68#[derive(Debug, Clone, Copy, Eq, PartialEq)]
69pub struct SourceDimensions {
70    width: NonZeroU32,
71    height: NonZeroU32,
72}
73
74impl SourceDimensions {
75    pub fn new(width: u32, height: u32) -> Option<Self> {
76        Some(Self {
77            width: NonZeroU32::new(width)?,
78            height: NonZeroU32::new(height)?,
79        })
80    }
81
82    pub fn width(self) -> u32 {
83        self.width.get()
84    }
85    pub fn height(self) -> u32 {
86        self.height.get()
87    }
88}
89
90#[derive(Debug, Clone)]
91pub struct PreviewEntry {
92    pub index: usize,
93    pub name: String,
94    pub tip: TipPreview,
95    /// None when the brush has no source raster or its dimensions cannot be read.
96    pub source_dimensions: Option<SourceDimensions>,
97}
98
99#[derive(Debug, Clone)]
100pub enum TipPreview {
101    /// A bitmap with a nonzero width and height. A tip that decodes to zero
102    /// area is `Unavailable` with [`UnavailableReason::Corrupt`].
103    Available(GrayscaleBitmap),
104    Unavailable(UnavailableReason),
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub enum UnavailableReason {
109    NoShapePng,
110    UnsupportedTipKind(String),
111    Corrupt(String),
112    /// A side over [`procreate::MAX_PNG_DIMENSION`], or a decode over
113    /// [`procreate::MAX_ENTRY_BYTES`], counted as [`TipImageError::TooLarge`]
114    /// describes.
115    TooLarge {
116        width: u32,
117        height: u32,
118    },
119    /// The tip was not returned because it would take the call's available
120    /// tips past [`MAX_PREVIEW_BYTES`]. Every later entry that has a tip to
121    /// render is `OverBudget` too, and its tip is not decoded.
122    OverBudget,
123}
124
125#[derive(Debug)]
126pub struct PreviewError(pub String);
127
128impl std::fmt::Display for PreviewError {
129    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130        f.write_str(&self.0)
131    }
132}
133
134impl std::error::Error for PreviewError {}
135
136fn check_max_cell(opts: PreviewOptions) -> Result<u32, PreviewError> {
137    if opts.max_cell == 0 {
138        return Err(PreviewError("max_cell must be at least 1".to_string()));
139    }
140    Ok(opts.max_cell)
141}
142
143/// The kind of brush file [`preview`] reads.
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub enum Format {
146    Abr,
147    Brush,
148    Brushset,
149}
150
151/// Which entries a [`preview`] returns.
152#[derive(Debug, Clone, Copy)]
153pub enum Take {
154    /// Every entry, available or not.
155    All,
156    /// The first `n` entries whose tip is available, with the `index` they
157    /// have under `All`, so indices may skip. No entry is built after the
158    /// `n`th available one or after the first `OverBudget` one, since no
159    /// later entry can be available.
160    FirstAvailable(usize),
161}
162
163impl Take {
164    /// The entries to return and how many were not reached because
165    /// `keep_going` returned `false`. `entries` must be lazy, as it is pulled
166    /// only as far as needed.
167    fn collect(
168        self,
169        mut entries: impl ExactSizeIterator<Item = PreviewEntry>,
170        keep_going: &mut dyn FnMut() -> bool,
171    ) -> (Vec<PreviewEntry>, usize) {
172        let mut stopped = false;
173        let gated = std::iter::from_fn(|| {
174            // Checked first so `keep_going` is not asked about an entry that
175            // does not exist.
176            if entries.len() == 0 {
177                return None;
178            }
179            if !keep_going() {
180                stopped = true;
181                return None;
182            }
183            entries.next()
184        });
185        let taken = match self {
186            Take::All => gated.collect(),
187            Take::FirstAvailable(n) => gated
188                .take_while(|entry| {
189                    !matches!(
190                        entry.tip,
191                        TipPreview::Unavailable(UnavailableReason::OverBudget)
192                    )
193                })
194                .filter(|entry| matches!(entry.tip, TipPreview::Available(_)))
195                .take(n)
196                .collect(),
197        };
198        let not_reached = if stopped { entries.len() } else { 0 };
199        (taken, not_reached)
200    }
201}
202
203/// Tips for a brush file of `format`, as `take` selects them.
204///
205/// `keep_going` is called before each entry is built. The first `false` stops
206/// the call: it returns the entries `take` selected so far and counts the
207/// entries not built in [`PreviewSet::not_reached`]. The work before the
208/// first entry, such as opening a zip or parsing the `.abr` index, and the
209/// entry being built are not interrupted. A call that ends because `take` has
210/// all it needs is not stopped, and `keep_going` is not called after that.
211///
212/// The entries of a file are the rows of an `.abr` preview, the members a
213/// `.brushset` lists, or the single brush of a `.brush`.
214pub fn preview(
215    bytes: &[u8],
216    format: Format,
217    opts: PreviewOptions,
218    take: Take,
219    keep_going: &mut dyn FnMut() -> bool,
220) -> Result<PreviewSet, PreviewError> {
221    match format {
222        Format::Abr => abr(bytes, opts, take, keep_going, MAX_PREVIEW_BYTES),
223        Format::Brush => brush(bytes, opts, take, keep_going, MAX_PREVIEW_BYTES),
224        Format::Brushset => brushset(bytes, opts, take, keep_going, MAX_PREVIEW_BYTES),
225    }
226}
227
228/// What is left of the bitmap byte budget of one preview call, `None` once a
229/// tip did not fit.
230struct Budget(Option<usize>);
231
232impl Budget {
233    fn new(bytes: usize) -> Self {
234        Budget(Some(bytes))
235    }
236
237    /// Runs `render` and keeps an available tip only if it fits in what is
238    /// left. The first tip that does not fit spends the budget for good, so
239    /// no later tip is rendered.
240    fn render(&mut self, render: impl FnOnce() -> TipPreview) -> TipPreview {
241        let over = TipPreview::Unavailable(UnavailableReason::OverBudget);
242        let Some(left) = self.0 else {
243            return over;
244        };
245        match render() {
246            TipPreview::Available(bitmap) => match left.checked_sub(bitmap.data.len()) {
247                Some(rest) => {
248                    self.0 = Some(rest);
249                    TipPreview::Available(bitmap)
250                }
251                None => {
252                    self.0 = None;
253                    over
254                }
255            },
256            unavailable => unavailable,
257        }
258    }
259}
260
261/// Where an `.abr` preview row came from: an index into `sampled_brushes`,
262/// `computed_presets` or `unsupported_tip_presets` of the parsed pack. The
263/// derived order, variant first and then index, orders rows that share a
264/// preset ordinal: sampled first, then computed, then unsupported, each in
265/// file order.
266#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
267enum Source {
268    Sampled(usize),
269    Computed(usize),
270    Unsupported(usize),
271}
272
273struct Row {
274    /// The preset ordinal from the descriptor, `usize::MAX` when unknown.
275    key: usize,
276    source: Source,
277}
278
279/// Tips for a Photoshop `.abr` pack.
280///
281/// Sampled tips are decoded one at a time and downsampled immediately, so the
282/// peak footprint holds one full-size tip rather than the whole pack. Computed
283/// presets are synthesized from their geometry; a preset that declares neither
284/// is reported as an unsupported tip kind. A sampled tip that is missing, or
285/// whose samp record or pixels fail to decode, is a `Corrupt` entry, not an
286/// error for the whole preview. The exception is a raw v1 or v2 tip whose
287/// pixels run past the end of the input, which fails the whole preview.
288///
289/// Embedded pattern payloads are neither copied nor decoded.
290pub fn preview_abr(bytes: &[u8], opts: PreviewOptions) -> Result<PreviewSet, PreviewError> {
291    preview(bytes, Format::Abr, opts, Take::All, &mut || true)
292}
293
294/// The first `n` entries of [`preview_abr`] whose tip is available, in the
295/// same order and with the same `index` they have there, so indices may skip.
296/// Unavailable entries do not count toward `n`, and entries after the `n`th
297/// available one or after the first `OverBudget` one are not built, so their
298/// tips are not decoded or downsampled.
299///
300/// The whole pack is still parsed, but the parse decodes no tip.
301pub fn preview_abr_first_available(
302    bytes: &[u8],
303    opts: PreviewOptions,
304    n: usize,
305) -> Result<PreviewSet, PreviewError> {
306    preview(
307        bytes,
308        Format::Abr,
309        opts,
310        Take::FirstAvailable(n),
311        &mut || true,
312    )
313}
314
315fn abr(
316    bytes: &[u8],
317    opts: PreviewOptions,
318    take: Take,
319    keep_going: &mut dyn FnMut() -> bool,
320    budget_bytes: usize,
321) -> Result<PreviewSet, PreviewError> {
322    let max_cell = check_max_cell(opts)?;
323
324    let deferred =
325        parse_abr_all_deferred_without_patterns(bytes).map_err(|e| PreviewError(e.to_string()))?;
326    let pack = &deferred.pack;
327
328    let mut rows: Vec<Row> = Vec::new();
329    rows.extend(pack.sampled_brushes.iter().enumerate().map(|(j, sampled)| {
330        let preset_index = match sampled {
331            SampledBrush::Readable(i) => pack.brushes[*i].preset_index,
332            SampledBrush::Unavailable(brush) => brush.preset_index,
333        };
334        Row {
335            key: preset_index.unwrap_or(usize::MAX),
336            source: Source::Sampled(j),
337        }
338    }));
339    rows.extend(
340        pack.computed_presets
341            .iter()
342            .enumerate()
343            .map(|(i, preset)| Row {
344                key: preset.preset_index.unwrap_or(usize::MAX),
345                source: Source::Computed(i),
346            }),
347    );
348    rows.extend(
349        pack.unsupported_tip_presets
350            .iter()
351            .enumerate()
352            .map(|(i, preset)| Row {
353                key: preset.preset_index,
354                source: Source::Unsupported(i),
355            }),
356    );
357
358    rows.sort_by_key(|row| (row.key, row.source));
359
360    let mut budget = Budget::new(budget_bytes);
361    let entries = rows
362        .into_iter()
363        .enumerate()
364        .map(|(index, row)| abr_entry(&deferred, index, row.source, max_cell, &mut budget));
365
366    let (entries, not_reached) = take.collect(entries, keep_going);
367    Ok(PreviewSet {
368        set_name: None,
369        entries,
370        not_reached,
371    })
372}
373
374/// Render one `.abr` row. A sampled tip is decoded and downsampled here, so
375/// only the rows a caller takes are decoded.
376fn abr_entry(
377    deferred: &DeferredPack<'_>,
378    index: usize,
379    source: Source,
380    max_cell: u32,
381    budget: &mut Budget,
382) -> PreviewEntry {
383    #[cfg(test)]
384    tests::record_read();
385    let pack = &deferred.pack;
386    let (name, tip, source_dimensions) = match source {
387        Source::Sampled(j) => match &pack.sampled_brushes[j] {
388            &SampledBrush::Readable(i) => {
389                let brush = &pack.brushes[i];
390                let tip = budget.render(|| match deferred.decode_tip(i) {
391                    Ok(tip) => tip_preview(&to_grayscale(&tip), max_cell),
392                    Err(e) => TipPreview::Unavailable(UnavailableReason::Corrupt(e.to_string())),
393                });
394                (
395                    name_or_id(&brush.name, &brush.id),
396                    tip,
397                    SourceDimensions::new(brush.tip.width, brush.tip.height),
398                )
399            }
400            SampledBrush::Unavailable(brush) => {
401                let text = match &brush.cause {
402                    UnavailableTip::Missing { uuid } => format!("sampled tip {uuid} is missing"),
403                    UnavailableTip::Unreadable(message) => message.clone(),
404                };
405                (
406                    name_or_id(&brush.name, &brush.id),
407                    TipPreview::Unavailable(UnavailableReason::Corrupt(text)),
408                    None,
409                )
410            }
411        },
412        Source::Computed(i) => {
413            let preset = &pack.computed_presets[i];
414            let unsupported = || {
415                TipPreview::Unavailable(UnavailableReason::UnsupportedTipKind(
416                    "computed".to_string(),
417                ))
418            };
419            let tip = match preset
420                .descriptor
421                .computed
422                .as_ref()
423                .filter(|geom| can_synthesize(geom))
424            {
425                Some(geom) => budget.render(|| {
426                    synthesize_computed_tip(geom)
427                        .map_or_else(unsupported, |bitmap| tip_preview(&bitmap, max_cell))
428                }),
429                None => unsupported(),
430            };
431            (preset.name.clone(), tip, None)
432        }
433        Source::Unsupported(i) => {
434            let preset = &pack.unsupported_tip_presets[i];
435            let kind = match (&preset.tip_shape, &preset.shape_tip_family) {
436                (Some(shape), _) => format!("{shape:?}"),
437                (None, Some(ShapeTipFamily::Bristle)) => "bristle".to_string(),
438                (None, Some(ShapeTipFamily::Erodible)) => "erodible".to_string(),
439                (None, None) => "shape tip".to_string(),
440            };
441            (
442                preset.name.clone(),
443                TipPreview::Unavailable(UnavailableReason::UnsupportedTipKind(kind)),
444                None,
445            )
446        }
447    };
448    PreviewEntry {
449        index,
450        name,
451        tip,
452        source_dimensions,
453    }
454}
455
456fn name_or_id(name: &str, id: &str) -> String {
457    if name.is_empty() { id } else { name }.to_string()
458}
459
460/// A full-size tip downsampled to `max_cell`. A zero-area tip is not
461/// drawable, and `downsample` would widen its zero side to 1 when the other
462/// side exceeds `max_cell`.
463fn tip_preview(bitmap: &GrayscaleBitmap, max_cell: u32) -> TipPreview {
464    if bitmap.width == 0 || bitmap.height == 0 {
465        return TipPreview::Unavailable(UnavailableReason::Corrupt(
466            "tip has zero area".to_string(),
467        ));
468    }
469    TipPreview::Available(downsample(bitmap, max_cell))
470}
471
472/// Tips for a Procreate `.brushset`.
473///
474/// With a `brushset.plist` the set name and the member order come from it;
475/// without one the members are the top-level directories that hold a
476/// `Brush.archive`, in zip order, and the set has no name.
477pub fn preview_brushset(bytes: &[u8], opts: PreviewOptions) -> Result<PreviewSet, PreviewError> {
478    preview(bytes, Format::Brushset, opts, Take::All, &mut || true)
479}
480
481/// The first `n` entries of [`preview_brushset`] whose tip is available, in
482/// the same order and with the same `index` they have there, so indices may
483/// skip. Unavailable entries do not count toward `n`, and members after the
484/// `n`th available one or after the first `OverBudget` one are not read.
485pub fn preview_brushset_first_available(
486    bytes: &[u8],
487    opts: PreviewOptions,
488    n: usize,
489) -> Result<PreviewSet, PreviewError> {
490    preview(
491        bytes,
492        Format::Brushset,
493        opts,
494        Take::FirstAvailable(n),
495        &mut || true,
496    )
497}
498
499fn brushset(
500    bytes: &[u8],
501    opts: PreviewOptions,
502    take: Take,
503    keep_going: &mut dyn FnMut() -> bool,
504    budget_bytes: usize,
505) -> Result<PreviewSet, PreviewError> {
506    let max_cell = check_max_cell(opts)?;
507    let mut zip = open_zip(bytes)?;
508
509    let (set_name, prefixes) = if zip.by_name("brushset.plist").is_ok() {
510        let buf = procreate::read_zip_plist(&mut zip, "brushset.plist").map_err(PreviewError)?;
511        let (name, uuids) = procreate::parse_brushset_plist(&buf).map_err(PreviewError)?;
512        (name, uuids.into_iter().map(|u| format!("{u}/")).collect())
513    } else {
514        let members: Vec<String> = procreate::members_in_zip_order(&mut zip)
515            .into_iter()
516            .map(|d| format!("{d}/"))
517            .collect();
518        if members.is_empty() {
519            return Err(PreviewError("no brushes found".to_string()));
520        }
521        (None, members)
522    };
523
524    // The last index that lists each member. A member listed again later is
525    // kept after its entry instead of read again. Inserted one at a time, as
526    // `collect` would size the map for every reference, not every member.
527    let mut last: HashMap<&str, usize> = HashMap::new();
528    for (index, prefix) in prefixes.iter().enumerate() {
529        last.insert(prefix, index);
530    }
531
532    let mut budget = Budget::new(budget_bytes);
533    let mut kept: HashMap<&str, Member> = HashMap::new();
534    let entries = prefixes.iter().enumerate().map(|(index, prefix)| {
535        let member = match kept.remove(prefix.as_str()) {
536            Some(member) => member.again(&mut budget),
537            None => read_member(&mut zip, prefix, max_cell, &mut budget),
538        };
539        if last[prefix.as_str()] > index {
540            kept.insert(prefix, member.clone());
541        }
542        member.entry(index)
543    });
544
545    let (entries, not_reached) = take.collect(entries, keep_going);
546    Ok(PreviewSet {
547        set_name,
548        entries,
549        not_reached,
550    })
551}
552
553/// The tip of a single Procreate `.brush`: one entry at index 0, read from the
554/// archive's root rather than from a member directory.
555pub fn preview_brush(bytes: &[u8], opts: PreviewOptions) -> Result<PreviewSet, PreviewError> {
556    preview(bytes, Format::Brush, opts, Take::All, &mut || true)
557}
558
559/// [`preview_brush`] limited to available tips: its single entry when the tip
560/// is available and `n >= 1`, otherwise no entries. With `n == 0` the tip is
561/// not decoded.
562pub fn preview_brush_first_available(
563    bytes: &[u8],
564    opts: PreviewOptions,
565    n: usize,
566) -> Result<PreviewSet, PreviewError> {
567    preview(
568        bytes,
569        Format::Brush,
570        opts,
571        Take::FirstAvailable(n),
572        &mut || true,
573    )
574}
575
576fn brush(
577    bytes: &[u8],
578    opts: PreviewOptions,
579    take: Take,
580    keep_going: &mut dyn FnMut() -> bool,
581    budget_bytes: usize,
582) -> Result<PreviewSet, PreviewError> {
583    let max_cell = check_max_cell(opts)?;
584    let mut zip = open_zip(bytes)?;
585
586    if zip.by_name("Brush.archive").is_err() {
587        return Err(PreviewError("Brush.archive not found".to_string()));
588    }
589
590    let mut budget = Budget::new(budget_bytes);
591    let entry = std::iter::once_with(|| read_member(&mut zip, "", max_cell, &mut budget).entry(0));
592    let (entries, not_reached) = take.collect(entry, keep_going);
593    Ok(PreviewSet {
594        set_name: None,
595        entries,
596        not_reached,
597    })
598}
599
600fn open_zip(bytes: &[u8]) -> Result<zip::ZipArchive<Cursor<&[u8]>>, PreviewError> {
601    let fail = |e: zip::result::ZipError| PreviewError(format!("failed to open zip: {e}"));
602    let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(fail)?;
603    if has_overlapping_entries(&mut zip).map_err(fail)? {
604        return Err(fail(zip::result::ZipError::InvalidArchive(
605            "entries overlap",
606        )));
607    }
608    Ok(zip)
609}
610
611/// The zip format lets many central-directory names point at one local header,
612/// or place one entry's header inside another entry's data, and the `zip` crate
613/// rejects neither. Both let two names read the same stored bytes. Disjoint
614/// `[header_start, data_start + compressed_size)` ranges keep the bytes one call
615/// reads within the file size. See <https://github.com/pmwoz/brushkit/issues/154>.
616fn has_overlapping_entries(
617    zip: &mut zip::ZipArchive<Cursor<&[u8]>>,
618) -> zip::result::ZipResult<bool> {
619    let mut ranges = Vec::with_capacity(zip.len());
620    for i in 0..zip.len() {
621        let entry = zip.by_index_raw(i)?;
622        let end = entry.data_start().saturating_add(entry.compressed_size());
623        ranges.push((entry.header_start(), end));
624    }
625    ranges.sort_unstable();
626    Ok(ranges.windows(2).any(|pair| pair[1].0 < pair[0].1))
627}
628
629/// One member of a Procreate archive, as the entry for one reference to it.
630#[derive(Clone)]
631struct Member {
632    name: String,
633    source_dimensions: Option<SourceDimensions>,
634    tip: MemberTip,
635}
636
637#[derive(Clone)]
638enum MemberTip {
639    /// A reason found without decoding the tip, which holds for every
640    /// reference.
641    Fixed(UnavailableReason),
642    /// The tip after the budget, which a later reference passes through the
643    /// budget again.
644    Budgeted(TipPreview),
645}
646
647impl Member {
648    /// The member for a later reference: its tip goes through the budget
649    /// again, so a repeated member still spends budget and is `OverBudget`
650    /// after the stop, as it would be if read again.
651    fn again(self, budget: &mut Budget) -> Member {
652        let tip = match self.tip {
653            MemberTip::Budgeted(tip) => MemberTip::Budgeted(budget.render(|| tip)),
654            fixed => fixed,
655        };
656        Member { tip, ..self }
657    }
658
659    fn entry(self, index: usize) -> PreviewEntry {
660        PreviewEntry {
661            index,
662            name: self.name,
663            tip: match self.tip {
664                MemberTip::Fixed(reason) => TipPreview::Unavailable(reason),
665                MemberTip::Budgeted(tip) => tip,
666            },
667            source_dimensions: self.source_dimensions,
668        }
669    }
670}
671
672/// Reads one member of a Procreate archive. `prefix` is `"{uuid}/"` for a
673/// `.brushset` member and `""` for a root-layout `.brush`.
674///
675/// A member is always an entry: an archive that cannot be read names the entry
676/// after its directory and reports why, rather than shifting every index after
677/// it. A readable `Shape.png` reports its size either way.
678fn read_member(
679    zip: &mut zip::ZipArchive<Cursor<&[u8]>>,
680    prefix: &str,
681    max_cell: u32,
682    budget: &mut Budget,
683) -> Member {
684    #[cfg(test)]
685    tests::record_read();
686    let fallback_name = if prefix.is_empty() {
687        "Brush".to_string()
688    } else {
689        prefix.trim_end_matches('/').to_string()
690    };
691
692    let archive = procreate::read_zip_plist(zip, &format!("{prefix}Brush.archive"))
693        .and_then(|buf| procreate::brush_name(&buf));
694
695    let shape_path = format!("{prefix}Shape.png");
696    let shape = if zip.by_name(&shape_path).is_ok() {
697        Some(procreate::read_zip_entry(zip, &shape_path))
698    } else {
699        None
700    };
701    let source_dimensions = match &shape {
702        Some(Ok(png)) => bitmap::header_dimensions(png)
703            .and_then(|(width, height)| SourceDimensions::new(width, height)),
704        _ => None,
705    };
706
707    let (name, tip) = match archive {
708        Ok(name) => (
709            name.unwrap_or(fallback_name),
710            shape_tip(shape, max_cell, budget),
711        ),
712        Err(msg) => (
713            fallback_name,
714            MemberTip::Fixed(UnavailableReason::Corrupt(msg)),
715        ),
716    };
717
718    Member {
719        name,
720        source_dimensions,
721        tip,
722    }
723}
724
725/// The tip for a member's `Shape.png`: `None` when the member has no shape,
726/// otherwise the read result.
727fn shape_tip(
728    shape: Option<Result<Vec<u8>, String>>,
729    max_cell: u32,
730    budget: &mut Budget,
731) -> MemberTip {
732    let png = match shape {
733        None => return MemberTip::Fixed(UnavailableReason::NoShapePng),
734        Some(Err(msg)) => return MemberTip::Fixed(UnavailableReason::Corrupt(msg)),
735        Some(Ok(png)) => png,
736    };
737    MemberTip::Budgeted(budget.render(|| match procreate::decode_tip_png(&png) {
738        Ok(bitmap) => tip_preview(&bitmap, max_cell),
739        Err(procreate::ShapePngError::TooLarge { width, height }) => {
740            TipPreview::Unavailable(UnavailableReason::TooLarge { width, height })
741        }
742        Err(procreate::ShapePngError::Corrupt(msg)) => {
743            TipPreview::Unavailable(UnavailableReason::Corrupt(msg))
744        }
745    }))
746}
747
748// The integration tests' fixture builders, shared with the unit tests below.
749#[cfg(test)]
750#[path = "../tests/common/mod.rs"]
751mod common;
752
753#[cfg(test)]
754mod tests {
755    use super::*;
756    use crate::common::{
757        brush_archive, brushset_plist, gray_png, legacy_abr, samp_abr, zip_with, SampTip,
758    };
759    use std::cell::Cell;
760
761    thread_local! {
762        // Thread-local because cargo runs unit tests on parallel threads.
763        static READS: Cell<usize> = const { Cell::new(0) };
764    }
765
766    /// Called once per `.abr` row or Procreate member read, before any of
767    /// its tip is read or decoded.
768    pub(super) fn record_read() {
769        READS.with(|count| count.set(count.get() + 1));
770    }
771
772    /// Rows and members read by `f` on this thread.
773    fn reads<T>(f: impl FnOnce() -> T) -> usize {
774        READS.with(|count| count.set(0));
775        f();
776        READS.with(Cell::get)
777    }
778
779    const OPTS: PreviewOptions = PreviewOptions { max_cell: 8 };
780
781    fn tip(corrupt: bool) -> SampTip {
782        SampTip {
783            width: 4,
784            height: 4,
785            fill: 0x80,
786            corrupt,
787        }
788    }
789
790    #[test]
791    fn abr_builds_only_the_entries_it_returns() {
792        let bytes = samp_abr(&[
793            tip(false),
794            tip(false),
795            tip(false),
796            tip(false),
797            tip(false),
798            tip(false),
799        ]);
800        assert_eq!(
801            reads(|| preview_abr_first_available(&bytes, OPTS, 2).unwrap()),
802            2
803        );
804        assert_eq!(
805            reads(|| preview_abr_first_available(&bytes, OPTS, 0).unwrap()),
806            0
807        );
808        assert_eq!(reads(|| preview_abr(&bytes, OPTS).unwrap()), 6);
809    }
810
811    #[test]
812    fn abr_failed_decode_does_not_count_toward_n() {
813        let bytes = samp_abr(&[tip(false), tip(false), tip(true)]);
814        assert!(matches!(
815            preview_abr(&bytes, OPTS).unwrap().entries[0].tip,
816            TipPreview::Unavailable(UnavailableReason::Corrupt(_))
817        ));
818        let mut set = None;
819        assert_eq!(
820            reads(|| set = Some(preview_abr_first_available(&bytes, OPTS, 1).unwrap())),
821            2
822        );
823        let entries = set.unwrap().entries;
824        assert_eq!(entries.len(), 1);
825        assert_eq!(entries[0].index, 1);
826    }
827
828    #[test]
829    fn abr_v2_corrupt_tip_is_one_corrupt_entry() {
830        let bytes = legacy_abr(&[tip(false), tip(true), tip(false)]);
831        let entries = preview_abr(&bytes, OPTS).unwrap().entries;
832        let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
833        assert_eq!(names, ["brush_2", "brush_1", "brush_0"]);
834        assert!(matches!(entries[0].tip, TipPreview::Available(_)));
835        assert!(matches!(
836            entries[1].tip,
837            TipPreview::Unavailable(UnavailableReason::Corrupt(_))
838        ));
839        assert!(matches!(entries[2].tip, TipPreview::Available(_)));
840    }
841
842    #[test]
843    fn abr_zero_area_tip_is_unavailable_and_does_not_count_toward_n() {
844        let sized = |width, height| SampTip {
845            width,
846            height,
847            fill: 0x80,
848            corrupt: false,
849        };
850        // Legacy entries are listed in reverse, so the drawable tip comes last.
851        // The 0x20 and 20x0 tips exceed max_cell on one side, which downsample
852        // would widen to 1.
853        let bytes = legacy_abr(&[
854            sized(1, 1),
855            sized(0, 20),
856            sized(0, 1),
857            sized(0, 1),
858            sized(20, 0),
859        ]);
860
861        let entries = preview_abr(&bytes, OPTS).unwrap().entries;
862        assert_eq!(entries.len(), 5);
863        for entry in &entries[..4] {
864            assert!(
865                matches!(
866                    &entry.tip,
867                    TipPreview::Unavailable(UnavailableReason::Corrupt(msg)) if msg == "tip has zero area"
868                ),
869                "{entry:?}"
870            );
871        }
872
873        let entries = preview_abr_first_available(&bytes, OPTS, 4)
874            .unwrap()
875            .entries;
876        assert_eq!(entries.len(), 1);
877        assert_eq!(entries[0].index, 4);
878        assert!(matches!(
879            &entries[0].tip,
880            TipPreview::Available(b) if (b.width, b.height, b.data.as_slice()) == (1, 1, &[0x80][..])
881        ));
882    }
883
884    #[test]
885    fn brushset_reads_only_the_members_it_returns() {
886        let archive = brush_archive("Tip");
887        let shape = gray_png(4, 4, 200);
888        let plist = brushset_plist("Set", &["a", "b", "c", "d"]);
889        let mut files: Vec<(String, &[u8])> = vec![("brushset.plist".into(), &plist)];
890        for member in ["a", "b", "c", "d"] {
891            files.push((format!("{member}/Brush.archive"), &archive));
892            files.push((format!("{member}/Shape.png"), &shape));
893        }
894        let files: Vec<(&str, &[u8])> = files.iter().map(|(p, b)| (p.as_str(), *b)).collect();
895        let bytes = zip_with(&files);
896        assert_eq!(
897            reads(|| preview_brushset_first_available(&bytes, OPTS, 1).unwrap()),
898            1
899        );
900        assert_eq!(reads(|| preview_brushset(&bytes, OPTS).unwrap()), 4);
901    }
902
903    #[test]
904    fn a_member_listed_many_times_is_read_once() {
905        let bytes = brushset_of(&["a"; 1000]);
906        let mut set = None;
907        assert_eq!(
908            reads(|| set = Some(preview_brushset(&bytes, OPTS).unwrap())),
909            1
910        );
911        let entries = set.unwrap().entries;
912        assert_eq!(entries.len(), 1000);
913        for (i, entry) in entries.iter().enumerate() {
914            assert_eq!((entry.index, entry.name.as_str()), (i, "Tip"));
915            assert!(matches!(&entry.tip, TipPreview::Available(b) if b.data == [200; 16]));
916        }
917    }
918
919    #[test]
920    fn interleaved_members_are_each_read_once() {
921        let bytes = brushset_of(&["c", "a", "n", "c", "a", "n"]);
922        let mut set = None;
923        assert_eq!(
924            reads(|| set = Some(preview_brushset(&bytes, OPTS).unwrap())),
925            3
926        );
927        let reasons: Vec<Option<UnavailableReason>> = set
928            .unwrap()
929            .entries
930            .into_iter()
931            .map(|entry| match entry.tip {
932                TipPreview::Available(_) => None,
933                TipPreview::Unavailable(reason) => Some(reason),
934            })
935            .collect();
936        assert!(matches!(
937            reasons[..3],
938            [
939                Some(UnavailableReason::Corrupt(_)),
940                None,
941                Some(UnavailableReason::NoShapePng)
942            ]
943        ));
944        assert_eq!(reasons[..3], reasons[3..]);
945    }
946
947    fn sized(width: u32, height: u32) -> SampTip {
948        SampTip {
949            width,
950            height,
951            fill: 0x80,
952            corrupt: false,
953        }
954    }
955
956    /// A `.brushset` whose plist lists `members` in order, each a member
957    /// directory. Member `a` has a 4x4 `Shape.png`, `c` has a `Shape.png` that
958    /// fails to decode, `n` has none and `x` has an unreadable `Brush.archive`.
959    fn brushset_of(members: &[&str]) -> Vec<u8> {
960        let archive = brush_archive("Tip");
961        let shape = gray_png(4, 4, 200);
962        let plist = brushset_plist("Set", members);
963        zip_with(&[
964            ("brushset.plist", &plist),
965            ("a/Brush.archive", &archive),
966            ("a/Shape.png", &shape),
967            ("c/Brush.archive", &archive),
968            ("c/Shape.png", b"not a png"),
969            ("n/Brush.archive", &archive),
970            ("x/Brush.archive", b"not a plist"),
971            ("x/Shape.png", &shape),
972        ])
973    }
974
975    /// Asserts that `bounded` is `full` with every tip from index `fit` on
976    /// `OverBudget`, and that the tips it keeps hold at most `budget` bytes.
977    fn assert_bounded(full: &PreviewSet, bounded: &PreviewSet, fit: usize, budget: usize) {
978        assert_eq!(bounded.entries.len(), full.entries.len());
979        let mut bytes = 0;
980        for (i, (got, want)) in bounded.entries.iter().zip(&full.entries).enumerate() {
981            assert_eq!(
982                (got.index, &got.name, got.source_dimensions),
983                (i, &want.name, want.source_dimensions)
984            );
985            match &got.tip {
986                TipPreview::Available(bitmap) if i < fit => bytes += bitmap.data.len(),
987                TipPreview::Unavailable(UnavailableReason::OverBudget) if i >= fit => {}
988                tip => panic!("entry {i}: {tip:?}"),
989            }
990        }
991        assert!(bytes <= budget, "{bytes} bytes over a budget of {budget}");
992    }
993
994    #[test]
995    fn brushset_tips_past_the_budget_are_over_budget() {
996        let bytes = brushset_of(&["a"; 6]);
997        let full = brushset(&bytes, OPTS, Take::All, &mut || true, MAX_PREVIEW_BYTES).unwrap();
998        // Each 4x4 tip is 16 bytes, so three fit in 50.
999        let bounded = brushset(&bytes, OPTS, Take::All, &mut || true, 50).unwrap();
1000        assert_bounded(&full, &bounded, 3, 50);
1001    }
1002
1003    #[test]
1004    fn abr_tips_past_the_first_that_does_not_fit_are_over_budget() {
1005        // Listed in reverse: 16, 16, 36 and 4 bytes. The 4-byte tip would fit
1006        // after the 36-byte one does not.
1007        let bytes = samp_abr(&[sized(2, 2), sized(6, 6), sized(4, 4), sized(4, 4)]);
1008        let full = abr(&bytes, OPTS, Take::All, &mut || true, MAX_PREVIEW_BYTES).unwrap();
1009        let bounded = abr(&bytes, OPTS, Take::All, &mut || true, 40).unwrap();
1010        assert_bounded(&full, &bounded, 2, 40);
1011    }
1012
1013    #[test]
1014    fn entries_unavailable_for_their_own_reason_keep_it_past_the_budget() {
1015        // `c` is `Corrupt` only when decoded, so `OverBudget` after the stop
1016        // shows its tip was not decoded.
1017        let bytes = brushset_of(&["c", "a", "a", "n", "x", "c", "a"]);
1018        let reasons: Vec<Option<UnavailableReason>> =
1019            brushset(&bytes, OPTS, Take::All, &mut || true, 20)
1020                .unwrap()
1021                .entries
1022                .into_iter()
1023                .map(|entry| match entry.tip {
1024                    TipPreview::Available(_) => None,
1025                    TipPreview::Unavailable(reason) => Some(reason),
1026                })
1027                .collect();
1028        assert!(matches!(
1029            reasons.as_slice(),
1030            [
1031                Some(UnavailableReason::Corrupt(_)),
1032                None,
1033                Some(UnavailableReason::OverBudget),
1034                Some(UnavailableReason::NoShapePng),
1035                Some(UnavailableReason::Corrupt(_)),
1036                Some(UnavailableReason::OverBudget),
1037                Some(UnavailableReason::OverBudget),
1038            ]
1039        ));
1040    }
1041
1042    #[test]
1043    fn first_available_stops_at_the_first_over_budget_entry() {
1044        let bytes = samp_abr(&std::array::from_fn::<_, 6, _>(|_| tip(false)));
1045        let mut set = None;
1046        assert_eq!(
1047            reads(|| set =
1048                Some(abr(&bytes, OPTS, Take::FirstAvailable(5), &mut || true, 40).unwrap())),
1049            3
1050        );
1051        let indices: Vec<usize> = set.unwrap().entries.iter().map(|e| e.index).collect();
1052        assert_eq!(indices, [0, 1]);
1053    }
1054
1055    /// A `keep_going` that returns `true` for its first `k` calls and `false`
1056    /// after that.
1057    fn stop_after(k: usize) -> impl FnMut() -> bool {
1058        let mut calls = 0;
1059        move || {
1060            calls += 1;
1061            calls <= k
1062        }
1063    }
1064
1065    fn debug(entries: &[PreviewEntry]) -> Vec<String> {
1066        entries.iter().map(|entry| format!("{entry:?}")).collect()
1067    }
1068
1069    fn brush_file() -> Vec<u8> {
1070        zip_with(&[
1071            ("Brush.archive", &brush_archive("Tip")),
1072            ("Shape.png", &gray_png(4, 4, 200)),
1073        ])
1074    }
1075
1076    #[test]
1077    fn a_stop_returns_the_entries_built_before_it() {
1078        let files = [
1079            (
1080                Format::Abr,
1081                samp_abr(&[tip(false), tip(true), tip(false), tip(false), tip(true)]),
1082            ),
1083            (
1084                Format::Brushset,
1085                brushset_of(&["a", "c", "n", "a", "x", "a"]),
1086            ),
1087        ];
1088        for (format, bytes) in files {
1089            let mut calls = 0;
1090            let full = preview(&bytes, format, OPTS, Take::All, &mut || {
1091                calls += 1;
1092                true
1093            })
1094            .unwrap();
1095            let total = full.entries.len();
1096            assert_eq!(calls, total, "{format:?}");
1097            for k in 0..=total {
1098                let all = preview(&bytes, format, OPTS, Take::All, &mut stop_after(k)).unwrap();
1099                assert_eq!(all.set_name, full.set_name);
1100                assert_eq!(
1101                    debug(&all.entries),
1102                    debug(&full.entries[..k]),
1103                    "{format:?} {k}"
1104                );
1105                assert_eq!(all.not_reached, total - k, "{format:?} {k}");
1106
1107                let take = Take::FirstAvailable(total);
1108                let first = preview(&bytes, format, OPTS, take, &mut stop_after(k)).unwrap();
1109                let available: Vec<PreviewEntry> = full.entries[..k]
1110                    .iter()
1111                    .filter(|entry| matches!(entry.tip, TipPreview::Available(_)))
1112                    .cloned()
1113                    .collect();
1114                assert_eq!(debug(&first.entries), debug(&available), "{format:?} {k}");
1115                assert_eq!(first.not_reached, total - k, "{format:?} {k}");
1116            }
1117        }
1118    }
1119
1120    #[test]
1121    fn a_stop_on_the_first_call_builds_no_entry() {
1122        let files = [
1123            (
1124                Format::Abr,
1125                samp_abr(&[tip(false), tip(false), tip(false)]),
1126                3,
1127            ),
1128            (Format::Brushset, brushset_of(&["a", "n", "a", "c"]), 4),
1129            (Format::Brush, brush_file(), 1),
1130        ];
1131        for (format, bytes, total) in files {
1132            for take in [Take::All, Take::FirstAvailable(total)] {
1133                let mut set = None;
1134                assert_eq!(
1135                    reads(|| set = Some(preview(&bytes, format, OPTS, take, &mut || false))),
1136                    0,
1137                    "{format:?} {take:?}"
1138                );
1139                let set = set.unwrap().unwrap();
1140                assert!(set.entries.is_empty(), "{format:?} {take:?}");
1141                assert_eq!(set.not_reached, total, "{format:?} {take:?}");
1142            }
1143        }
1144    }
1145
1146    #[test]
1147    fn a_stopped_brushset_does_not_read_later_members() {
1148        // `c` is `Corrupt` only once its `Shape.png` is read and decoded.
1149        let bytes = brushset_of(&["a", "c"]);
1150        let full = preview_brushset(&bytes, OPTS).unwrap();
1151        assert!(matches!(
1152            full.entries[1].tip,
1153            TipPreview::Unavailable(UnavailableReason::Corrupt(_))
1154        ));
1155
1156        let mut set = None;
1157        let stop = || {
1158            preview(
1159                &bytes,
1160                Format::Brushset,
1161                OPTS,
1162                Take::All,
1163                &mut stop_after(1),
1164            )
1165        };
1166        assert_eq!(reads(|| set = Some(stop())), 1);
1167        let set = set.unwrap().unwrap();
1168        assert!(set.entries.iter().all(|entry| !matches!(
1169            entry.tip,
1170            TipPreview::Unavailable(UnavailableReason::Corrupt(_))
1171        )));
1172        assert_eq!(set.not_reached, 1);
1173    }
1174
1175    #[test]
1176    fn first_available_that_takes_all_it_needs_is_not_stopped() {
1177        let bytes = samp_abr(&std::array::from_fn::<_, 6, _>(|_| tip(false)));
1178        let set = abr(
1179            &bytes,
1180            OPTS,
1181            Take::FirstAvailable(1),
1182            &mut stop_after(1),
1183            MAX_PREVIEW_BYTES,
1184        )
1185        .unwrap();
1186        assert_eq!((set.entries.len(), set.not_reached), (1, 0));
1187
1188        // Two 16-byte tips fit in 40, so the third entry is `OverBudget`.
1189        let set = abr(
1190            &bytes,
1191            OPTS,
1192            Take::FirstAvailable(5),
1193            &mut stop_after(3),
1194            40,
1195        )
1196        .unwrap();
1197        assert_eq!((set.entries.len(), set.not_reached), (2, 0));
1198    }
1199
1200    #[test]
1201    fn the_preview_functions_run_to_the_end() {
1202        let abr_bytes = samp_abr(&[tip(false), tip(false), tip(false)]);
1203        let set_bytes = brushset_of(&["a", "a", "a"]);
1204        let brush_bytes = brush_file();
1205        let sets = [
1206            preview_abr(&abr_bytes, OPTS),
1207            preview_abr_first_available(&abr_bytes, OPTS, 1),
1208            preview_brushset(&set_bytes, OPTS),
1209            preview_brushset_first_available(&set_bytes, OPTS, 1),
1210            preview_brush(&brush_bytes, OPTS),
1211            preview_brush_first_available(&brush_bytes, OPTS, 0),
1212        ];
1213        for set in sets {
1214            assert_eq!(set.unwrap().not_reached, 0);
1215        }
1216    }
1217}