#![allow(dead_code)] use hcie_engine_api::brush::{BrushStyle, BrushTip}; use hcie_engine_api::BrushPreset; use std::io::{Cursor, Read, Seek, SeekFrom}; /// Parse an ABR brush file and return a list of `BrushPreset` entries. /// /// The ABR format stores sampled bitmap brushes in a `samp` resource block and /// parametric/computed brushes in `computedB` markers. Brush names are extracted /// from the `desc` block (which contains `Nm TEXT` tags) when available. pub fn import_abr(data: &[u8]) -> Vec { let mut presets = Vec::new(); let mut cursor = Cursor::new(data); let mut buf = [0u8; 4]; if cursor.read_exact(&mut buf).is_err() { return presets; } let mut pos = 0; while pos + 8 < data.len() { if &data[pos..pos + 4] == b"8BIM" { let tag = String::from_utf8_lossy(&data[pos + 4..pos + 8]).to_string(); let _ = cursor.seek(SeekFrom::Start(pos as u64 + 8)); let size = read_u32(&mut cursor); let block_end = (cursor.position() + size as u64).min(data.len() as u64); if tag == "samp" { let start = cursor.position() as usize; let end = block_end as usize; if end > start && end <= data.len() { presets.extend(harvest_sampled_brushes(&data[start..end], start)); } } else if tag == "Brs2" || tag == "brst" { let start = cursor.position() as usize; let end = block_end as usize; if end > start && end <= data.len() { presets.extend(harvest_brs2_sampled_brushes(&data[start..end], start)); } } pos = (block_end as usize + 1) & !1; } else { pos += 1; } } presets.extend(harvest_computed_brushes(data)); let desc_names = extract_desc_block_names(data); apply_desc_names(&mut presets, &desc_names); presets } fn safe_read_i32(data: &[u8], pos: usize) -> Option { if pos + 4 > data.len() { return None; } Some(i32::from_be_bytes([ data[pos], data[pos + 1], data[pos + 2], data[pos + 3], ])) } fn harvest_sampled_brushes(data: &[u8], base_pos: usize) -> Vec { let mut presets = Vec::new(); let mut i = 0; while i + 20 < data.len() { if data[i] == 8 && data[i + 1] == 1 { let sig_pos = if i > 0 && data[i - 1] == 0 { i - 1 } else { i }; let mut found = false; if sig_pos >= 16 { let t = safe_read_i32(data, sig_pos - 16); let l = safe_read_i32(data, sig_pos - 12); let b = safe_read_i32(data, sig_pos - 8); let r = safe_read_i32(data, sig_pos - 4); if let (Some(t), Some(l), Some(b), Some(r)) = (t, l, b, r) { let h = (b as i64 - t as i64).abs() as u32; let w = (r as i64 - l as i64).abs() as u32; if w > 0 && h > 0 && w < 10000 && h < 10000 { let (final_w, final_h) = if w > 1024 || h > 1024 { let scale = 1024.0 / (w.max(h) as f32); ((w as f32 * scale) as u32, (h as f32 * scale) as u32) } else { (w, h) }; if let Some(decoded) = try_decode_and_scale( &data[i + 2..], w as usize, h as usize, final_w, final_h, ) { presets.push(BrushPreset { id: format!("abr_v6_{}_{}x{}", base_pos + i, final_w, final_h), name: format!("Brush {}x{}", final_w, final_h), category: "Imported (ABR)".to_string(), tip: BrushTip { style: BrushStyle::Bitmap, size: final_w.max(final_h) as f32, opacity: 1.0, hardness: 1.0, spacing: 0.1, jitter_amount: 0.0, scatter_amount: 0.0, angle: 0.0, roundness: 1.0, spray_particle_size: 2.0, spray_density: 100, bitmap_pixels: decoded, bitmap_w: final_w, bitmap_h: final_h, flow: 1.0, color_variant: false, variant_amount: 0.0, density: 1.0, drawing_angle: false, rotation_random: 0.0, }, style: BrushStyle::Bitmap, pressure_size: true, pressure_opacity: true, pressure_flow: true, jitter_position: 0.0, jitter_angle: 0.0, }); found = true; } } } } if found { i += 20; continue; } } i += 1; } presets } fn try_decode_and_scale( data: &[u8], src_w: usize, src_h: usize, dst_w: u32, dst_h: u32, ) -> Option> { // Some ABR files store the width as (right - left) but the PackBits stream // contains one extra padding column/row. Accept decoded data that is at least // src_w * src_h and truncate any surplus instead of rejecting the brush. let mut decoded = decode_packbits_lenient(data, src_w * src_h); if decoded.len() < src_w * src_h { return None; } if decoded.len() > src_w * src_h { decoded.truncate(src_w * src_h); } if src_w as u32 == dst_w && src_h as u32 == dst_h { return Some(decoded); } let mut scaled = Vec::with_capacity((dst_w * dst_h) as usize); for y in 0..dst_h { for x in 0..dst_w { let src_x = (x as f32 * (src_w as f32 / dst_w as f32)) as usize; let src_y = (y as f32 * (src_h as f32 / dst_h as f32)) as usize; scaled.push(decoded[src_y * src_w + src_x]); } } Some(scaled) } fn decode_packbits_lenient(data: &[u8], expected_size: usize) -> Vec { let mut out = Vec::with_capacity(expected_size); let mut i = 0; while i < data.len() && out.len() < expected_size { let n = data[i] as i8; i += 1; if n >= 0 { let count = n as usize + 1; if i + count <= data.len() { out.extend_from_slice(&data[i..i + count]); i += count; } else { break; } } else if n != -128 { let count = (-n) as usize + 1; if i < data.len() { let val = data[i]; i += 1; for _ in 0..count { out.push(val); } } else { break; } } } if out.is_empty() { return out; } while out.len() < expected_size { out.push(0); } if out.len() > expected_size { out.truncate(expected_size); } out } /// Heuristic extraction for sampled brushes embedded in a `Brs2` / `brst` /// resource block. Older ABR versions lay out the data as: /// [count u32] for each brush [l t r b i32] [depth?] [name pascal] /// [encoding byte?] [PackBits image data] /// This is intentionally lenient so we do not crash on partially unknown formats. fn harvest_brs2_sampled_brushes(data: &[u8], base_pos: usize) -> Vec { let mut presets = Vec::new(); if data.len() < 4 { return presets; } let count = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize; if count == 0 || count > 1000 { return presets; } let mut i = 4usize; for _ in 0..count { if i + 16 > data.len() { break; } let l = i32::from_be_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]); let t = i32::from_be_bytes([data[i + 4], data[i + 5], data[i + 6], data[i + 7]]); let r = i32::from_be_bytes([data[i + 8], data[i + 9], data[i + 10], data[i + 11]]); let b = i32::from_be_bytes([data[i + 12], data[i + 13], data[i + 14], data[i + 15]]); i += 16; let w = (r as i64 - l as i64).abs() as u32; let h = (b as i64 - t as i64).abs() as u32; if w == 0 || h == 0 || w > 10000 || h > 10000 { continue; } // Skip depth/name/etc until we find an encoding signature or raw data. // Try to locate a PackBits-like run near expected data offset. let data_start = i; let data_end = (i + (w * h * 2) as usize + 1024).min(data.len()); let slice = &data[data_start..data_end]; let (final_w, final_h) = if w > 1024 || h > 1024 { let scale = 1024.0 / (w.max(h) as f32); ((w as f32 * scale) as u32, (h as f32 * scale) as u32) } else { (w, h) }; if let Some(decoded) = try_decode_and_scale(slice, w as usize, h as usize, final_w, final_h) { presets.push(BrushPreset { id: format!("abr_brs2_{}_{}x{}", base_pos + i, final_w, final_h), name: format!("Brush {}x{}", final_w, final_h), category: "Imported (ABR)".to_string(), tip: BrushTip { style: BrushStyle::Bitmap, size: final_w.max(final_h) as f32, opacity: 1.0, hardness: 1.0, spacing: 0.1, jitter_amount: 0.0, scatter_amount: 0.0, angle: 0.0, roundness: 1.0, spray_particle_size: 2.0, spray_density: 100, bitmap_pixels: decoded, bitmap_w: final_w, bitmap_h: final_h, flow: 1.0, color_variant: false, variant_amount: 0.0, density: 1.0, drawing_angle: false, rotation_random: 0.0, }, style: BrushStyle::Bitmap, pressure_size: true, pressure_opacity: true, pressure_flow: true, jitter_position: 0.0, jitter_angle: 0.0, }); } i = data_end; } presets } fn harvest_computed_brushes(data: &[u8]) -> Vec { let mut presets = Vec::new(); let marker = b"computedB"; let mut search_pos = 0; while search_pos + marker.len() < data.len() { if &data[search_pos..search_pos + marker.len()] == marker { let window_end = (search_pos + 4096).min(data.len()); let window = &data[search_pos..window_end]; let name = parse_nm_text(window).unwrap_or_else(|| "Computed Brush".to_string()); let diameter = parse_f64_field(window, b"Dmtr").unwrap_or(10.0); let hardness = parse_f64_field(window, b"Hrdn").unwrap_or(100.0); let spacing = parse_f64_field(window, b"Spcn").unwrap_or(25.0); if diameter > 0.0 && diameter < 5000.0 { presets.push(BrushPreset { id: format!("abr_comp_{:x}", search_pos), name, category: "Imported (ABR)".to_string(), tip: BrushTip { style: BrushStyle::Round, size: diameter as f32, opacity: 1.0, hardness: (hardness / 100.0).clamp(0.0, 1.0) as f32, spacing: (spacing / 100.0).clamp(0.01, 5.0) as f32, jitter_amount: 0.0, scatter_amount: 0.0, angle: 0.0, roundness: 1.0, spray_particle_size: 2.0, spray_density: 100, bitmap_pixels: Vec::new(), bitmap_w: 0, bitmap_h: 0, flow: 1.0, color_variant: false, variant_amount: 0.0, density: 1.0, drawing_angle: false, rotation_random: 0.0, }, style: BrushStyle::Round, pressure_size: true, pressure_opacity: true, pressure_flow: true, jitter_position: 0.0, jitter_angle: 0.0, }); } search_pos += marker.len(); } else { search_pos += 1; } } presets } fn parse_nm_text(data: &[u8]) -> Option { let tag = b"Nm TEXT"; let pos = find_bytes(data, tag)?; let p = pos + tag.len(); if p + 4 > data.len() { return None; } let nchars = u32::from_be_bytes([data[p], data[p + 1], data[p + 2], data[p + 3]]) as usize; let p = p + 4; if p + nchars * 2 > data.len() { return None; } let chars: Vec = data[p..p + nchars * 2] .chunks_exact(2) .map(|b| u16::from_be_bytes([b[0], b[1]])) .collect(); let full = String::from_utf16_lossy(&chars); let name = full .split('=') .last() .unwrap_or(&full) .trim_matches('\x00') .to_string(); if name.is_empty() { None } else { Some(name) } } fn parse_f64_field(data: &[u8], tag: &[u8]) -> Option { let pos = find_bytes(data, tag)?; let p = pos + tag.len() + 8; if p + 8 > data.len() { return None; } Some(f64::from_be_bytes([ data[p], data[p + 1], data[p + 2], data[p + 3], data[p + 4], data[p + 5], data[p + 6], data[p + 7], ])) } fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option { haystack.windows(needle.len()).position(|w| w == needle) } fn read_u32(cursor: &mut Cursor<&[u8]>) -> u32 { let mut buf = [0u8; 4]; let _ = cursor.read_exact(&mut buf); u32::from_be_bytes(buf) } /// Extract brush names from the `desc` block's `Nm TEXT` tags. /// /// ABR files store a `desc` resource block that contains `BrshVlLs` (Brush /// Value Lists). Each entry has a `Nm TEXT` field with the brush name in /// UTF-16 encoding. The names appear in the same order as the brushes in the /// `samp` block, so they can be associated by index. fn extract_desc_block_names(data: &[u8]) -> Vec { let mut names = Vec::new(); let desc_pos = match find_bytes(data, b"8BIMdesc") { Some(p) => p, None => return names, }; let size_pos = desc_pos + 8; if size_pos + 4 > data.len() { return names; } let size = u32::from_be_bytes([ data[size_pos], data[size_pos + 1], data[size_pos + 2], data[size_pos + 3], ]) as usize; let block_start = size_pos + 4; let block_end = (block_start + size).min(data.len()); if block_end <= block_start { return names; } let block = &data[block_start..block_end]; let mut idx = 0; while idx < block.len() - 8 { if &block[idx..idx + 8] == b"Nm TEXT" { let p = idx + 8; if p + 4 > block.len() { break; } let nchars = u32::from_be_bytes([block[p], block[p + 1], block[p + 2], block[p + 3]]) as usize; let text_start = p + 4; if nchars == 0 || text_start + nchars * 2 > block.len() { idx += 1; continue; } let chars: Vec = block[text_start..text_start + nchars * 2] .chunks_exact(2) .map(|b| u16::from_be_bytes([b[0], b[1]])) .collect(); let full = String::from_utf16_lossy(&chars); let name = full .split('=') .last() .unwrap_or(&full) .trim_matches('\x00') .to_string(); if !name.is_empty() { names.push(name); } idx = text_start + nchars * 2; } else { idx += 1; } } names } /// Apply names extracted from the `desc` block to presets. /// /// The desc block names appear in the same order as sampled brushes were /// collected. Computed brushes already have their names from `Nm TEXT` tags /// inside the computed brush data, so only sampled presets (Bitmap style) /// get updated. fn apply_desc_names(presets: &mut [BrushPreset], desc_names: &[String]) { if desc_names.is_empty() { return; } let mut name_idx = 0; for preset in presets.iter_mut() { if preset.style == BrushStyle::Bitmap && name_idx < desc_names.len() { let new_name = &desc_names[name_idx]; if !new_name.is_empty() { preset.name = new_name.clone(); } name_idx += 1; } } } /// Parse an ABR file and prefix generated IDs/names so imports from different /// files do not collide. pub fn import_abr_with_prefix(data: &[u8], prefix: &str) -> Vec { let mut presets = import_abr(data); let safe_prefix = prefix.replace(' ', "_").replace('.', "_"); for preset in &mut presets { preset.id = format!("{}_{}", safe_prefix, preset.id); preset.name = format!("{} ({})", preset.name, safe_prefix); } presets } /// Parse a Krita brush preset (`.kpp`). /// /// A `.kpp` file is a ZIP archive. This implementation extracts the `preview.png` /// thumbnail, converts it to a grayscale alpha mask, and returns a single /// `BrushStyle::Bitmap` preset. pub fn import_kpp(data: &[u8], name_hint: &str) -> Option { use std::io::Cursor; use zip::ZipArchive; let cursor = Cursor::new(data); let mut zip = ZipArchive::new(cursor).ok()?; let mut preview_bytes: Option> = None; for i in 0..zip.len() { let mut entry = zip.by_index(i).ok()?; let entry_name = entry.name().to_lowercase(); if entry_name.ends_with("preview.png") || entry_name == "preview.png" { let mut buf = Vec::new(); if entry.read_to_end(&mut buf).is_ok() && !buf.is_empty() { preview_bytes = Some(buf); break; } } } let preview_bytes = preview_bytes?; let img = image::load_from_memory(&preview_bytes).ok()?; let rgba = img.to_rgba8(); let (w, h) = rgba.dimensions(); if w == 0 || h == 0 { return None; } // Downscale huge previews so GPU brush stamps stay cheap. let (final_w, final_h) = if w > 512 || h > 512 { let scale = 512.0 / (w.max(h) as f32); ((w as f32 * scale) as u32, (h as f32 * scale) as u32) } else { (w, h) }; let mut gray = Vec::with_capacity((final_w * final_h) as usize); for y in 0..final_h { for x in 0..final_w { let src_x = (x as f32 * (w as f32 / final_w as f32)) as u32; let src_y = (y as f32 * (h as f32 / final_h as f32)) as u32; let px = rgba.get_pixel(src_x.min(w - 1), src_y.min(h - 1)); // Use perceived luminance from RGB and keep full alpha range. let l = (0.299 * px[0] as f32 + 0.587 * px[1] as f32 + 0.114 * px[2] as f32) .clamp(0.0, 255.0) as u8; let a = px[3]; // Mask value = luminance scaled by alpha. gray.push((l as u16 * a as u16 / 255) as u8); } } Some(BrushPreset { id: format!( "kpp_imported_{}_{}x{}", name_hint.replace(' ', "_"), final_w, final_h ), name: format!("{} (KPP)", name_hint), category: "Imported".to_string(), tip: BrushTip { style: BrushStyle::Bitmap, size: final_w.max(final_h) as f32, opacity: 1.0, hardness: 1.0, spacing: 0.1, jitter_amount: 0.0, scatter_amount: 0.0, angle: 0.0, roundness: 1.0, spray_particle_size: 2.0, spray_density: 100, bitmap_pixels: gray, bitmap_w: final_w, bitmap_h: final_h, flow: 1.0, color_variant: false, variant_amount: 0.0, density: 1.0, drawing_angle: false, rotation_random: 0.0, }, style: BrushStyle::Bitmap, pressure_size: true, pressure_opacity: true, pressure_flow: true, jitter_position: 0.0, jitter_angle: 0.0, }) } #[cfg(test)] mod tests { use super::*; #[test] fn test_extract_desc_block_names() { let path = "/mnt/data/Downloads/Square Brushes.abr"; let data = match std::fs::read(path) { Ok(d) => d, Err(_) => { eprintln!("Skipping test: {} not found", path); return; } }; let names = extract_desc_block_names(&data); assert!(!names.is_empty(), "Should extract names from desc block"); assert!( names[0].contains("Hard Square"), "First name should be descriptive, got: {}", names[0] ); } #[test] fn test_abr_import_with_names() { let path = "/mnt/data/Downloads/Square Brushes.abr"; let data = match std::fs::read(path) { Ok(d) => d, Err(_) => { eprintln!("Skipping test: {} not found", path); return; } }; let presets = import_abr(&data); let bitmap: Vec<_> = presets .iter() .filter(|p| p.style == BrushStyle::Bitmap) .collect(); assert!(!bitmap.is_empty(), "Should have bitmap presets"); let has_names = bitmap.iter().any(|p| !p.name.starts_with("Brush ")); assert!(has_names, "Bitmap presets should have descriptive names"); } }