brushkit-preview 0.7.0

Tip bitmaps for .abr, .brush and .brushset files: sampled tips, synthesized computed tips, Procreate Shape.png and contact sheets
Documentation
use brushkit_preview::{preview_abr, PreviewOptions, PreviewSet, TipPreview};
use std::path::{Path, PathBuf};

fn without_patterns(bytes: &[u8]) -> (Vec<u8>, usize) {
    let version = u16::from_be_bytes(bytes[..2].try_into().unwrap());
    if matches!(version, 1 | 2) {
        return (bytes.to_vec(), 0);
    }
    let mut stripped = bytes[..4].to_vec();
    let mut offset = 4;
    let mut omitted = 0;
    while offset + 12 <= bytes.len() && &bytes[offset..offset + 4] == b"8BIM" {
        let length =
            u32::from_be_bytes(bytes[offset + 8..offset + 12].try_into().unwrap()) as usize;
        let end = offset + 12 + length;
        assert!(
            end <= bytes.len(),
            "the corpus block must fit inside the file"
        );
        let next = end.next_multiple_of(4).min(bytes.len());
        if &bytes[offset + 4..offset + 8] == b"patt" {
            omitted += 1;
        } else {
            stripped.extend_from_slice(&bytes[offset..next]);
        }
        offset = next;
    }
    stripped.extend_from_slice(&bytes[offset..]);
    (stripped, omitted)
}

fn assert_same_preview(actual: &PreviewSet, expected: &PreviewSet) {
    assert_eq!(actual.set_name, expected.set_name);
    assert_eq!(actual.entries.len(), expected.entries.len());
    for (actual, expected) in actual.entries.iter().zip(&expected.entries) {
        assert_eq!(actual.index, expected.index);
        assert_eq!(actual.name, expected.name);
        assert_eq!(actual.source_dimensions, expected.source_dimensions);
        match (&actual.tip, &expected.tip) {
            (TipPreview::Available(actual), TipPreview::Available(expected)) => {
                assert_eq!(actual.width, expected.width);
                assert_eq!(actual.height, expected.height);
                assert_eq!(actual.data, expected.data);
            }
            (TipPreview::Unavailable(actual), TipPreview::Unavailable(expected)) => {
                assert_eq!(actual, expected);
            }
            _ => panic!("preview availability changed for {}", actual.name),
        }
    }
}

fn check_preview(bytes: &[u8]) -> usize {
    let options = PreviewOptions { max_cell: 160 };
    let original = preview_abr(bytes, options).expect("original pack previews");
    let (stripped, omitted) = without_patterns(bytes);
    let without = preview_abr(&stripped, options).expect("pack without patterns previews");
    assert_same_preview(&original, &without);
    omitted
}

#[test]
fn patterns_do_not_change_preview_output() {
    let bytes = include_bytes!("../../../fuzz/corpus/abr_parse/wellformed_v6_patt");
    assert!(!brushkit_abr::parse_abr(bytes).unwrap().patterns.is_empty());
    assert!(check_preview(bytes) > 0);
}

fn abr_files(dir: &Path, files: &mut Vec<PathBuf>) {
    for entry in std::fs::read_dir(dir).expect("corpus directory is readable") {
        let path = entry.expect("corpus entry is readable").path();
        if path.is_dir() {
            abr_files(&path, files);
        } else if path
            .extension()
            .is_some_and(|ext| ext.eq_ignore_ascii_case("abr"))
        {
            files.push(path);
        }
    }
}

#[test]
fn corpus_patterns_do_not_change_preview_output() {
    let Some(root) = std::env::var_os("BRUSHKIT_CORPUS_DIR") else {
        println!("skip: BRUSHKIT_CORPUS_DIR unset");
        return;
    };
    let mut files = Vec::new();
    abr_files(Path::new(&root), &mut files);
    files.sort();
    assert!(
        !files.is_empty(),
        "the configured corpus must contain ABR files"
    );
    let mut patterned = 0;
    for path in &files {
        println!("checking {}", path.display());
        let bytes = std::fs::read(path).expect("corpus file is readable");
        if check_preview(&bytes) > 0 {
            patterned += 1;
        }
    }
    println!(
        "checked {} packs, {patterned} with pattern blocks",
        files.len()
    );
}