1pub 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 pub max_cell: u32,
45}
46
47pub const MAX_PREVIEW_BYTES: usize = 256 * 1024 * 1024;
51
52const _: () = 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 pub not_reached: usize,
64}
65
66#[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 pub source_dimensions: Option<SourceDimensions>,
97}
98
99#[derive(Debug, Clone)]
100pub enum TipPreview {
101 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 TooLarge {
116 width: u32,
117 height: u32,
118 },
119 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub enum Format {
146 Abr,
147 Brush,
148 Brushset,
149}
150
151#[derive(Debug, Clone, Copy)]
153pub enum Take {
154 All,
156 FirstAvailable(usize),
161}
162
163impl Take {
164 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 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
203pub 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
228struct Budget(Option<usize>);
231
232impl Budget {
233 fn new(bytes: usize) -> Self {
234 Budget(Some(bytes))
235 }
236
237 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#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
267enum Source {
268 Sampled(usize),
269 Computed(usize),
270 Unsupported(usize),
271}
272
273struct Row {
274 key: usize,
276 source: Source,
277}
278
279pub fn preview_abr(bytes: &[u8], opts: PreviewOptions) -> Result<PreviewSet, PreviewError> {
291 preview(bytes, Format::Abr, opts, Take::All, &mut || true)
292}
293
294pub 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
374fn 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
460fn 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
472pub fn preview_brushset(bytes: &[u8], opts: PreviewOptions) -> Result<PreviewSet, PreviewError> {
478 preview(bytes, Format::Brushset, opts, Take::All, &mut || true)
479}
480
481pub 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 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
553pub fn preview_brush(bytes: &[u8], opts: PreviewOptions) -> Result<PreviewSet, PreviewError> {
556 preview(bytes, Format::Brush, opts, Take::All, &mut || true)
557}
558
559pub 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
611fn 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#[derive(Clone)]
631struct Member {
632 name: String,
633 source_dimensions: Option<SourceDimensions>,
634 tip: MemberTip,
635}
636
637#[derive(Clone)]
638enum MemberTip {
639 Fixed(UnavailableReason),
642 Budgeted(TipPreview),
645}
646
647impl Member {
648 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
672fn 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
725fn 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#[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 static READS: Cell<usize> = const { Cell::new(0) };
764 }
765
766 pub(super) fn record_read() {
769 READS.with(|count| count.set(count.get() + 1));
770 }
771
772 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 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 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 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 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 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 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 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 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 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}