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