pub mod procreate;
mod bitmap;
#[cfg(feature = "text")]
mod sheet;
mod synth;
pub use bitmap::*;
#[cfg(feature = "text")]
pub use sheet::*;
pub use synth::*;
use brushkit_abr::{parse_abr_all_deferred_without_patterns, DeferredPack, ShapeTipFamily};
use std::collections::HashMap;
use std::io::Cursor;
use std::num::NonZeroU32;
#[derive(Debug, Clone, Copy)]
pub struct PreviewOptions {
pub max_cell: u32,
}
pub const MAX_PREVIEW_BYTES: usize = 256 * 1024 * 1024;
const _: () = assert!(MAX_PREVIEW_BYTES >= (procreate::MAX_PNG_DIMENSION as usize).pow(2));
#[derive(Debug, Clone)]
pub struct PreviewSet {
pub set_name: Option<String>,
pub entries: Vec<PreviewEntry>,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub struct SourceDimensions {
width: NonZeroU32,
height: NonZeroU32,
}
impl SourceDimensions {
pub fn new(width: u32, height: u32) -> Option<Self> {
Some(Self {
width: NonZeroU32::new(width)?,
height: NonZeroU32::new(height)?,
})
}
pub fn width(self) -> u32 {
self.width.get()
}
pub fn height(self) -> u32 {
self.height.get()
}
}
#[derive(Debug, Clone)]
pub struct PreviewEntry {
pub index: usize,
pub name: String,
pub tip: TipPreview,
pub source_dimensions: Option<SourceDimensions>,
}
#[derive(Debug, Clone)]
pub enum TipPreview {
Available(GrayscaleBitmap),
Unavailable(UnavailableReason),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UnavailableReason {
NoShapePng,
UnsupportedTipKind(String),
Corrupt(String),
TooLarge {
width: u32,
height: u32,
},
OverBudget,
}
#[derive(Debug)]
pub struct PreviewError(pub String);
impl std::fmt::Display for PreviewError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for PreviewError {}
fn check_max_cell(opts: PreviewOptions) -> Result<u32, PreviewError> {
if opts.max_cell == 0 {
return Err(PreviewError("max_cell must be at least 1".to_string()));
}
Ok(opts.max_cell)
}
#[derive(Clone, Copy)]
enum Take {
All,
FirstAvailable(usize),
}
impl Take {
fn collect(self, entries: impl Iterator<Item = PreviewEntry>) -> Vec<PreviewEntry> {
match self {
Take::All => entries.collect(),
Take::FirstAvailable(n) => entries
.take_while(|entry| {
!matches!(
entry.tip,
TipPreview::Unavailable(UnavailableReason::OverBudget)
)
})
.filter(|entry| matches!(entry.tip, TipPreview::Available(_)))
.take(n)
.collect(),
}
}
}
struct Budget(Option<usize>);
impl Budget {
fn new(bytes: usize) -> Self {
Budget(Some(bytes))
}
fn render(&mut self, render: impl FnOnce() -> TipPreview) -> TipPreview {
let over = TipPreview::Unavailable(UnavailableReason::OverBudget);
let Some(left) = self.0 else {
return over;
};
match render() {
TipPreview::Available(bitmap) => match left.checked_sub(bitmap.data.len()) {
Some(rest) => {
self.0 = Some(rest);
TipPreview::Available(bitmap)
}
None => {
self.0 = None;
over
}
},
unavailable => unavailable,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum Source {
Sampled(usize),
Computed(usize),
Unsupported(usize),
}
struct Row {
key: usize,
source: Source,
}
pub fn preview_abr(bytes: &[u8], opts: PreviewOptions) -> Result<PreviewSet, PreviewError> {
abr(bytes, opts, Take::All, MAX_PREVIEW_BYTES)
}
pub fn preview_abr_first_available(
bytes: &[u8],
opts: PreviewOptions,
n: usize,
) -> Result<PreviewSet, PreviewError> {
abr(bytes, opts, Take::FirstAvailable(n), MAX_PREVIEW_BYTES)
}
fn abr(
bytes: &[u8],
opts: PreviewOptions,
take: Take,
budget_bytes: usize,
) -> Result<PreviewSet, PreviewError> {
let max_cell = check_max_cell(opts)?;
let deferred =
parse_abr_all_deferred_without_patterns(bytes).map_err(|e| PreviewError(e.to_string()))?;
let pack = &deferred.pack;
let mut rows: Vec<Row> = Vec::new();
rows.extend(pack.brushes.iter().enumerate().map(|(i, brush)| Row {
key: brush.preset_index.unwrap_or(usize::MAX),
source: Source::Sampled(i),
}));
rows.extend(
pack.computed_presets
.iter()
.enumerate()
.map(|(i, preset)| Row {
key: preset.preset_index.unwrap_or(usize::MAX),
source: Source::Computed(i),
}),
);
rows.extend(
pack.unsupported_tip_presets
.iter()
.enumerate()
.map(|(i, preset)| Row {
key: preset.preset_index,
source: Source::Unsupported(i),
}),
);
rows.sort_by_key(|row| (row.key, row.source));
let mut budget = Budget::new(budget_bytes);
let entries = rows
.into_iter()
.enumerate()
.map(|(index, row)| abr_entry(&deferred, index, row.source, max_cell, &mut budget));
Ok(PreviewSet {
set_name: None,
entries: take.collect(entries),
})
}
fn abr_entry(
deferred: &DeferredPack<'_>,
index: usize,
source: Source,
max_cell: u32,
budget: &mut Budget,
) -> PreviewEntry {
#[cfg(test)]
tests::record_read();
let pack = &deferred.pack;
let (name, tip, source_dimensions) = match source {
Source::Sampled(i) => {
let brush = &pack.brushes[i];
let name = if brush.name.is_empty() {
brush.id.clone()
} else {
brush.name.clone()
};
let tip = budget.render(|| match deferred.decode_tip(i) {
Ok(tip) => tip_preview(&to_grayscale(&tip), max_cell),
Err(e) => TipPreview::Unavailable(UnavailableReason::Corrupt(e.to_string())),
});
(
name,
tip,
SourceDimensions::new(brush.tip.width, brush.tip.height),
)
}
Source::Computed(i) => {
let preset = &pack.computed_presets[i];
let unsupported = || {
TipPreview::Unavailable(UnavailableReason::UnsupportedTipKind(
"computed".to_string(),
))
};
let tip = match preset
.descriptor
.computed
.as_ref()
.filter(|geom| can_synthesize(geom))
{
Some(geom) => budget.render(|| {
synthesize_computed_tip(geom)
.map_or_else(unsupported, |bitmap| tip_preview(&bitmap, max_cell))
}),
None => unsupported(),
};
(preset.name.clone(), tip, None)
}
Source::Unsupported(i) => {
let preset = &pack.unsupported_tip_presets[i];
let kind = match (&preset.tip_shape, &preset.shape_tip_family) {
(Some(shape), _) => format!("{shape:?}"),
(None, Some(ShapeTipFamily::Bristle)) => "bristle".to_string(),
(None, Some(ShapeTipFamily::Erodible)) => "erodible".to_string(),
(None, None) => "shape tip".to_string(),
};
(
preset.name.clone(),
TipPreview::Unavailable(UnavailableReason::UnsupportedTipKind(kind)),
None,
)
}
};
PreviewEntry {
index,
name,
tip,
source_dimensions,
}
}
fn tip_preview(bitmap: &GrayscaleBitmap, max_cell: u32) -> TipPreview {
if bitmap.width == 0 || bitmap.height == 0 {
return TipPreview::Unavailable(UnavailableReason::Corrupt(
"tip has zero area".to_string(),
));
}
TipPreview::Available(downsample(bitmap, max_cell))
}
pub fn preview_brushset(bytes: &[u8], opts: PreviewOptions) -> Result<PreviewSet, PreviewError> {
brushset(bytes, opts, Take::All, MAX_PREVIEW_BYTES)
}
pub fn preview_brushset_first_available(
bytes: &[u8],
opts: PreviewOptions,
n: usize,
) -> Result<PreviewSet, PreviewError> {
brushset(bytes, opts, Take::FirstAvailable(n), MAX_PREVIEW_BYTES)
}
fn brushset(
bytes: &[u8],
opts: PreviewOptions,
take: Take,
budget_bytes: usize,
) -> Result<PreviewSet, PreviewError> {
let max_cell = check_max_cell(opts)?;
let mut zip = open_zip(bytes)?;
let (set_name, prefixes) = if zip.by_name("brushset.plist").is_ok() {
let buf = procreate::read_zip_entry(&mut zip, "brushset.plist").map_err(PreviewError)?;
let (name, uuids) = procreate::parse_brushset_plist(&buf).map_err(PreviewError)?;
(name, uuids.into_iter().map(|u| format!("{u}/")).collect())
} else {
let members: Vec<String> = procreate::members_in_zip_order(&mut zip)
.into_iter()
.map(|d| format!("{d}/"))
.collect();
if members.is_empty() {
return Err(PreviewError("no brushes found".to_string()));
}
(None, members)
};
let mut last: HashMap<&str, usize> = HashMap::new();
for (index, prefix) in prefixes.iter().enumerate() {
last.insert(prefix, index);
}
let mut budget = Budget::new(budget_bytes);
let mut kept: HashMap<&str, Member> = HashMap::new();
let entries = prefixes.iter().enumerate().map(|(index, prefix)| {
let member = match kept.remove(prefix.as_str()) {
Some(member) => member.again(&mut budget),
None => read_member(&mut zip, prefix, max_cell, &mut budget),
};
if last[prefix.as_str()] > index {
kept.insert(prefix, member.clone());
}
member.entry(index)
});
Ok(PreviewSet {
set_name,
entries: take.collect(entries),
})
}
pub fn preview_brush(bytes: &[u8], opts: PreviewOptions) -> Result<PreviewSet, PreviewError> {
brush(bytes, opts, Take::All, MAX_PREVIEW_BYTES)
}
pub fn preview_brush_first_available(
bytes: &[u8],
opts: PreviewOptions,
n: usize,
) -> Result<PreviewSet, PreviewError> {
brush(bytes, opts, Take::FirstAvailable(n), MAX_PREVIEW_BYTES)
}
fn brush(
bytes: &[u8],
opts: PreviewOptions,
take: Take,
budget_bytes: usize,
) -> Result<PreviewSet, PreviewError> {
let max_cell = check_max_cell(opts)?;
let mut zip = open_zip(bytes)?;
if zip.by_name("Brush.archive").is_err() {
return Err(PreviewError("Brush.archive not found".to_string()));
}
let mut budget = Budget::new(budget_bytes);
let entry = std::iter::once_with(|| read_member(&mut zip, "", max_cell, &mut budget).entry(0));
Ok(PreviewSet {
set_name: None,
entries: take.collect(entry),
})
}
fn open_zip(bytes: &[u8]) -> Result<zip::ZipArchive<Cursor<&[u8]>>, PreviewError> {
let fail = |e: zip::result::ZipError| PreviewError(format!("failed to open zip: {e}"));
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(fail)?;
if has_overlapping_entries(&mut zip).map_err(fail)? {
return Err(fail(zip::result::ZipError::InvalidArchive(
"entries overlap",
)));
}
Ok(zip)
}
fn has_overlapping_entries(
zip: &mut zip::ZipArchive<Cursor<&[u8]>>,
) -> zip::result::ZipResult<bool> {
let mut ranges = Vec::with_capacity(zip.len());
for i in 0..zip.len() {
let entry = zip.by_index_raw(i)?;
let end = entry.data_start().saturating_add(entry.compressed_size());
ranges.push((entry.header_start(), end));
}
ranges.sort_unstable();
Ok(ranges.windows(2).any(|pair| pair[1].0 < pair[0].1))
}
#[derive(Clone)]
struct Member {
name: String,
source_dimensions: Option<SourceDimensions>,
tip: MemberTip,
}
#[derive(Clone)]
enum MemberTip {
Fixed(UnavailableReason),
Budgeted(TipPreview),
}
impl Member {
fn again(self, budget: &mut Budget) -> Member {
let tip = match self.tip {
MemberTip::Budgeted(tip) => MemberTip::Budgeted(budget.render(|| tip)),
fixed => fixed,
};
Member { tip, ..self }
}
fn entry(self, index: usize) -> PreviewEntry {
PreviewEntry {
index,
name: self.name,
tip: match self.tip {
MemberTip::Fixed(reason) => TipPreview::Unavailable(reason),
MemberTip::Budgeted(tip) => tip,
},
source_dimensions: self.source_dimensions,
}
}
}
fn read_member(
zip: &mut zip::ZipArchive<Cursor<&[u8]>>,
prefix: &str,
max_cell: u32,
budget: &mut Budget,
) -> Member {
#[cfg(test)]
tests::record_read();
let fallback_name = if prefix.is_empty() {
"Brush".to_string()
} else {
prefix.trim_end_matches('/').to_string()
};
let archive = procreate::read_zip_entry(zip, &format!("{prefix}Brush.archive"))
.and_then(|buf| procreate::brush_name(&buf));
let shape_path = format!("{prefix}Shape.png");
let shape = if zip.by_name(&shape_path).is_ok() {
Some(procreate::read_zip_entry(zip, &shape_path))
} else {
None
};
let source_dimensions = match &shape {
Some(Ok(png)) => bitmap::header_dimensions(png)
.and_then(|(width, height)| SourceDimensions::new(width, height)),
_ => None,
};
let (name, tip) = match archive {
Ok(name) => (
name.unwrap_or(fallback_name),
shape_tip(shape, max_cell, budget),
),
Err(msg) => (
fallback_name,
MemberTip::Fixed(UnavailableReason::Corrupt(msg)),
),
};
Member {
name,
source_dimensions,
tip,
}
}
fn shape_tip(
shape: Option<Result<Vec<u8>, String>>,
max_cell: u32,
budget: &mut Budget,
) -> MemberTip {
let png = match shape {
None => return MemberTip::Fixed(UnavailableReason::NoShapePng),
Some(Err(msg)) => return MemberTip::Fixed(UnavailableReason::Corrupt(msg)),
Some(Ok(png)) => png,
};
MemberTip::Budgeted(budget.render(|| match procreate::decode_tip_png(&png) {
Ok(bitmap) => tip_preview(&bitmap, max_cell),
Err(procreate::ShapePngError::TooLarge { width, height }) => {
TipPreview::Unavailable(UnavailableReason::TooLarge { width, height })
}
Err(procreate::ShapePngError::Corrupt(msg)) => {
TipPreview::Unavailable(UnavailableReason::Corrupt(msg))
}
}))
}
#[cfg(test)]
#[path = "../tests/common/mod.rs"]
mod common;
#[cfg(test)]
mod tests {
use super::*;
use crate::common::{
brush_archive, brushset_plist, gray_png, legacy_abr, samp_abr, zip_with, SampTip,
};
use std::cell::Cell;
thread_local! {
static READS: Cell<usize> = const { Cell::new(0) };
}
pub(super) fn record_read() {
READS.with(|count| count.set(count.get() + 1));
}
fn reads<T>(f: impl FnOnce() -> T) -> usize {
READS.with(|count| count.set(0));
f();
READS.with(Cell::get)
}
const OPTS: PreviewOptions = PreviewOptions { max_cell: 8 };
fn tip(corrupt: bool) -> SampTip {
SampTip {
width: 4,
height: 4,
fill: 0x80,
corrupt,
}
}
#[test]
fn abr_builds_only_the_entries_it_returns() {
let bytes = samp_abr(&[
tip(false),
tip(false),
tip(false),
tip(false),
tip(false),
tip(false),
]);
assert_eq!(
reads(|| preview_abr_first_available(&bytes, OPTS, 2).unwrap()),
2
);
assert_eq!(
reads(|| preview_abr_first_available(&bytes, OPTS, 0).unwrap()),
0
);
assert_eq!(reads(|| preview_abr(&bytes, OPTS).unwrap()), 6);
}
#[test]
fn abr_failed_decode_does_not_count_toward_n() {
let bytes = samp_abr(&[tip(false), tip(false), tip(true)]);
assert!(matches!(
preview_abr(&bytes, OPTS).unwrap().entries[0].tip,
TipPreview::Unavailable(UnavailableReason::Corrupt(_))
));
let mut set = None;
assert_eq!(
reads(|| set = Some(preview_abr_first_available(&bytes, OPTS, 1).unwrap())),
2
);
let entries = set.unwrap().entries;
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].index, 1);
}
#[test]
fn abr_v2_corrupt_tip_is_one_corrupt_entry() {
let bytes = legacy_abr(&[tip(false), tip(true), tip(false)]);
let entries = preview_abr(&bytes, OPTS).unwrap().entries;
let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, ["brush_2", "brush_1", "brush_0"]);
assert!(matches!(entries[0].tip, TipPreview::Available(_)));
assert!(matches!(
entries[1].tip,
TipPreview::Unavailable(UnavailableReason::Corrupt(_))
));
assert!(matches!(entries[2].tip, TipPreview::Available(_)));
}
#[test]
fn abr_zero_area_tip_is_unavailable_and_does_not_count_toward_n() {
let sized = |width, height| SampTip {
width,
height,
fill: 0x80,
corrupt: false,
};
let bytes = legacy_abr(&[
sized(1, 1),
sized(0, 20),
sized(0, 1),
sized(0, 1),
sized(20, 0),
]);
let entries = preview_abr(&bytes, OPTS).unwrap().entries;
assert_eq!(entries.len(), 5);
for entry in &entries[..4] {
assert!(
matches!(
&entry.tip,
TipPreview::Unavailable(UnavailableReason::Corrupt(msg)) if msg == "tip has zero area"
),
"{entry:?}"
);
}
let entries = preview_abr_first_available(&bytes, OPTS, 4)
.unwrap()
.entries;
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].index, 4);
assert!(matches!(
&entries[0].tip,
TipPreview::Available(b) if (b.width, b.height, b.data.as_slice()) == (1, 1, &[0x80][..])
));
}
#[test]
fn brushset_reads_only_the_members_it_returns() {
let archive = brush_archive("Tip");
let shape = gray_png(4, 4, 200);
let plist = brushset_plist("Set", &["a", "b", "c", "d"]);
let mut files: Vec<(String, &[u8])> = vec![("brushset.plist".into(), &plist)];
for member in ["a", "b", "c", "d"] {
files.push((format!("{member}/Brush.archive"), &archive));
files.push((format!("{member}/Shape.png"), &shape));
}
let files: Vec<(&str, &[u8])> = files.iter().map(|(p, b)| (p.as_str(), *b)).collect();
let bytes = zip_with(&files);
assert_eq!(
reads(|| preview_brushset_first_available(&bytes, OPTS, 1).unwrap()),
1
);
assert_eq!(reads(|| preview_brushset(&bytes, OPTS).unwrap()), 4);
}
#[test]
fn a_member_listed_many_times_is_read_once() {
let bytes = brushset_of(&["a"; 1000]);
let mut set = None;
assert_eq!(
reads(|| set = Some(preview_brushset(&bytes, OPTS).unwrap())),
1
);
let entries = set.unwrap().entries;
assert_eq!(entries.len(), 1000);
for (i, entry) in entries.iter().enumerate() {
assert_eq!((entry.index, entry.name.as_str()), (i, "Tip"));
assert!(matches!(&entry.tip, TipPreview::Available(b) if b.data == [200; 16]));
}
}
#[test]
fn interleaved_members_are_each_read_once() {
let bytes = brushset_of(&["c", "a", "n", "c", "a", "n"]);
let mut set = None;
assert_eq!(
reads(|| set = Some(preview_brushset(&bytes, OPTS).unwrap())),
3
);
let reasons: Vec<Option<UnavailableReason>> = set
.unwrap()
.entries
.into_iter()
.map(|entry| match entry.tip {
TipPreview::Available(_) => None,
TipPreview::Unavailable(reason) => Some(reason),
})
.collect();
assert!(matches!(
reasons[..3],
[
Some(UnavailableReason::Corrupt(_)),
None,
Some(UnavailableReason::NoShapePng)
]
));
assert_eq!(reasons[..3], reasons[3..]);
}
fn sized(width: u32, height: u32) -> SampTip {
SampTip {
width,
height,
fill: 0x80,
corrupt: false,
}
}
fn brushset_of(members: &[&str]) -> Vec<u8> {
let archive = brush_archive("Tip");
let shape = gray_png(4, 4, 200);
let plist = brushset_plist("Set", members);
zip_with(&[
("brushset.plist", &plist),
("a/Brush.archive", &archive),
("a/Shape.png", &shape),
("c/Brush.archive", &archive),
("c/Shape.png", b"not a png"),
("n/Brush.archive", &archive),
("x/Brush.archive", b"not a plist"),
("x/Shape.png", &shape),
])
}
fn assert_bounded(full: &PreviewSet, bounded: &PreviewSet, fit: usize, budget: usize) {
assert_eq!(bounded.entries.len(), full.entries.len());
let mut bytes = 0;
for (i, (got, want)) in bounded.entries.iter().zip(&full.entries).enumerate() {
assert_eq!(
(got.index, &got.name, got.source_dimensions),
(i, &want.name, want.source_dimensions)
);
match &got.tip {
TipPreview::Available(bitmap) if i < fit => bytes += bitmap.data.len(),
TipPreview::Unavailable(UnavailableReason::OverBudget) if i >= fit => {}
tip => panic!("entry {i}: {tip:?}"),
}
}
assert!(bytes <= budget, "{bytes} bytes over a budget of {budget}");
}
#[test]
fn brushset_tips_past_the_budget_are_over_budget() {
let bytes = brushset_of(&["a"; 6]);
let full = brushset(&bytes, OPTS, Take::All, MAX_PREVIEW_BYTES).unwrap();
let bounded = brushset(&bytes, OPTS, Take::All, 50).unwrap();
assert_bounded(&full, &bounded, 3, 50);
}
#[test]
fn abr_tips_past_the_first_that_does_not_fit_are_over_budget() {
let bytes = samp_abr(&[sized(2, 2), sized(6, 6), sized(4, 4), sized(4, 4)]);
let full = abr(&bytes, OPTS, Take::All, MAX_PREVIEW_BYTES).unwrap();
let bounded = abr(&bytes, OPTS, Take::All, 40).unwrap();
assert_bounded(&full, &bounded, 2, 40);
}
#[test]
fn entries_unavailable_for_their_own_reason_keep_it_past_the_budget() {
let bytes = brushset_of(&["c", "a", "a", "n", "x", "c", "a"]);
let reasons: Vec<Option<UnavailableReason>> = brushset(&bytes, OPTS, Take::All, 20)
.unwrap()
.entries
.into_iter()
.map(|entry| match entry.tip {
TipPreview::Available(_) => None,
TipPreview::Unavailable(reason) => Some(reason),
})
.collect();
assert!(matches!(
reasons.as_slice(),
[
Some(UnavailableReason::Corrupt(_)),
None,
Some(UnavailableReason::OverBudget),
Some(UnavailableReason::NoShapePng),
Some(UnavailableReason::Corrupt(_)),
Some(UnavailableReason::OverBudget),
Some(UnavailableReason::OverBudget),
]
));
}
#[test]
fn first_available_stops_at_the_first_over_budget_entry() {
let bytes = samp_abr(&std::array::from_fn::<_, 6, _>(|_| tip(false)));
let mut set = None;
assert_eq!(
reads(|| set = Some(abr(&bytes, OPTS, Take::FirstAvailable(5), 40).unwrap())),
3
);
let indices: Vec<usize> = set.unwrap().entries.iter().map(|e| e.index).collect();
assert_eq!(indices, [0, 1]);
}
}