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)]
129pub enum UnavailableReason {
130 NoShapePng,
131 UnsupportedTipKind(String),
132 Corrupt(String),
133 MissingTip {
136 uuid: String,
137 },
138 TooLarge {
142 width: u32,
143 height: u32,
144 },
145 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171pub enum Format {
172 Abr,
173 Brush,
174 Brushset,
175}
176
177#[derive(Debug, Clone, Copy)]
179pub enum Take {
180 All,
182 FirstAvailable(usize),
187}
188
189impl Take {
190 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 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
229pub 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
254struct Budget(Option<usize>);
257
258impl Budget {
259 fn new(bytes: usize) -> Self {
260 Budget(Some(bytes))
261 }
262
263 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#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
293enum Source {
294 Sampled(usize),
295 Computed(usize),
296 Unsupported(usize),
297}
298
299struct Row {
300 key: usize,
302 source: Source,
303}
304
305pub fn preview_abr(bytes: &[u8], opts: PreviewOptions) -> Result<PreviewSet, PreviewError> {
318 preview(bytes, Format::Abr, opts, Take::All, &mut || true)
319}
320
321pub 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
401fn 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
491fn 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
503pub fn preview_brushset(bytes: &[u8], opts: PreviewOptions) -> Result<PreviewSet, PreviewError> {
509 preview(bytes, Format::Brushset, opts, Take::All, &mut || true)
510}
511
512pub 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 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
584pub fn preview_brush(bytes: &[u8], opts: PreviewOptions) -> Result<PreviewSet, PreviewError> {
587 preview(bytes, Format::Brush, opts, Take::All, &mut || true)
588}
589
590pub 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
642fn 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#[derive(Clone)]
662struct Member {
663 name: String,
664 source_dimensions: Option<SourceDimensions>,
665 tip: MemberTip,
666}
667
668#[derive(Clone)]
669enum MemberTip {
670 Fixed(UnavailableReason),
673 Budgeted(TipPreview),
676}
677
678impl Member {
679 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
703fn 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
756fn 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#[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 static READS: Cell<usize> = const { Cell::new(0) };
795 }
796
797 pub(super) fn record_read() {
800 READS.with(|count| count.set(count.get() + 1));
801 }
802
803 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 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 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 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 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 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 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 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 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 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}