Skip to main content

ag_psd/
abr.rs

1/*
2File: crates/ag-psd/src/abr.rs
3
4Purpose:
5чтение кистей Photoshop (.abr).
6
7Source compatibility:
8- порт upstream-файла `test/ag-psd/src/abr.ts`. Upstream READ-ONLY (нет `writeAbr`),
9  поэтому здесь портирован только `read_abr`.
10
11Dependency gaps (портированы локально здесь, должны переехать в свои модули):
12- `read_pattern` / `read_data_rle` (8-бит) — из `psdReader.ts`, ещё не в `reader.rs`.
13- descriptor-хелперы `parsePercent` / `parseAngle` / `parseUnitsToNumber` и enum
14  `BlnM` (descriptor.ts) — здесь как `parse_percent` / `parse_angle` /
15  `parse_units_to_number` / `blnm_decode`.
16- `crate::descriptor::read_version_and_descriptor` всегда читает class id
17  дескриптора (эквивалент upstream `includeClass = true`), поэтому отдельного
18  флага не требуется.
19
20Main responsibilities:
21- разбор ABR версий 6/7/9/10 (minor 1/2): секции '8BIM' samp/desc/patt/phry.
22*/
23
24use crate::descriptor::{Descriptor, DescriptorValue, UnitDoubleValue};
25use crate::psd::{BlendMode, ColorMode, PatternBounds, PatternInfo};
26use crate::reader::{
27    check_signature, read_bytes, read_int16, read_int32, read_pascal_string, read_signature,
28    read_uint16, read_uint32, read_uint8, read_unicode_string, skip_bytes, PsdReader, ReadError,
29    ReadResult,
30};
31
32// ===========================================================================
33// Data model (зеркало интерфейсов abr.ts)
34// ===========================================================================
35
36/// TS `Abr`.
37#[derive(Debug, Clone, Default)]
38pub struct Abr {
39    pub brushes: Vec<Brush>,
40    pub samples: Vec<SampleInfo>,
41    pub patterns: Vec<PatternInfo>,
42}
43
44/// TS `SampleInfo`.
45#[derive(Debug, Clone)]
46pub struct SampleInfo {
47    pub id: String,
48    pub bounds: SampleBounds,
49    pub alpha: Vec<u8>,
50}
51
52#[derive(Debug, Clone, Copy, Default)]
53pub struct SampleBounds {
54    pub x: i32,
55    pub y: i32,
56    pub w: i32,
57    pub h: i32,
58}
59
60/// TS `BrushDynamics`.
61#[derive(Debug, Clone)]
62pub struct BrushDynamics {
63    /// control: 'off' | 'fade' | 'pen pressure' | ...
64    pub control: String,
65    pub steps: f64,
66    pub jitter: f64,
67    pub minimum: f64,
68}
69
70const DYNAMICS_CONTROL: &[&str] = &[
71    "off",
72    "fade",
73    "pen pressure",
74    "pen tilt",
75    "stylus wheel",
76    "initial direction",
77    "direction",
78    "initial rotation",
79    "rotation",
80];
81
82const DYNAMIC_BRUSH_SHAPE_SHAPES: &[&str] = &[
83    "round point",
84    "round blunt",
85    "round curve",
86    "round angle",
87    "round fan",
88    "flat point",
89    "flat blunt",
90    "flat curve",
91    "flat angle",
92    "flat fan",
93];
94
95const TIPS_BRUSH_SHAPE_SHAPES: &[&str] = &[
96    "erodible point",
97    "erodible flat",
98    "erodible round",
99    "erodible square",
100    "erodible triangle",
101    "custom",
102];
103
104/// TS `BrushShape` union.
105#[derive(Debug, Clone)]
106pub enum BrushShape {
107    Computed {
108        size: f64,
109        angle: f64,
110        roundness: f64,
111        hardness: f64,
112        spacing_on: bool,
113        spacing: f64,
114        flip_x: bool,
115        flip_y: bool,
116    },
117    Sampled {
118        name: String,
119        size: f64,
120        angle: f64,
121        roundness: f64,
122        spacing_on: bool,
123        spacing: f64,
124        flip_x: bool,
125        flip_y: bool,
126        sampled_data: String,
127    },
128    Tips {
129        angle: f64,
130        size: f64,
131        shape: String,
132        physics: bool,
133        spacing: f64,
134        spacing_on: bool,
135        flip_x: bool,
136        flip_y: bool,
137        tips_type: String,
138        tips_length_ratio: f64,
139        tips_hardness: f64,
140        tips_grid_size: Option<f64>,
141        tips_erodible_tip_height_map: Option<Vec<u8>>,
142        tips_airbrush_cutoff_angle: f64,
143        tips_airbrush_granularity: f64,
144        tips_airbrush_streakiness: f64,
145        tips_airbrush_splat_size: f64,
146        tips_airbrush_splat_count: f64,
147    },
148    Dynamic {
149        size: f64,
150        angle: f64,
151        shape: String,
152        density: f64,
153        length: f64,
154        clumping: f64,
155        thickness: f64,
156        stiffness: f64,
157        physics: bool,
158        spacing: f64,
159        spacing_on: bool,
160        flip_x: bool,
161        flip_y: bool,
162    },
163}
164
165#[derive(Debug, Clone)]
166pub struct ShapeDynamics {
167    pub size_dynamics: BrushDynamics,
168    pub minimum_diameter: f64,
169    pub tilt_scale: f64,
170    pub angle_dynamics: BrushDynamics,
171    pub roundness_dynamics: BrushDynamics,
172    pub minimum_roundness: f64,
173    pub flip_x: bool,
174    pub flip_y: bool,
175    pub brush_projection: bool,
176}
177
178#[derive(Debug, Clone)]
179pub struct Scatter {
180    pub both_axes: bool,
181    pub scatter_dynamics: BrushDynamics,
182    pub count_dynamics: BrushDynamics,
183    pub count: f64,
184}
185
186#[derive(Debug, Clone)]
187pub struct Texture {
188    pub id: String,
189    pub name: String,
190    pub invert: bool,
191    pub scale: f64,
192    pub brightness: f64,
193    pub contrast: f64,
194    pub blend_mode: BlendMode,
195    pub depth: f64,
196    pub depth_minimum: f64,
197    pub depth_dynamics: BrushDynamics,
198    pub texture_each_tip: bool,
199}
200
201#[derive(Debug, Clone)]
202pub struct DualBrush {
203    pub flip: bool,
204    pub shape: BrushShape,
205    pub blend_mode: BlendMode,
206    pub use_scatter: bool,
207    pub spacing: f64,
208    pub count: f64,
209    pub both_axes: bool,
210    pub count_dynamics: BrushDynamics,
211    pub scatter_dynamics: BrushDynamics,
212}
213
214#[derive(Debug, Clone)]
215pub struct ColorDynamics {
216    pub foreground_background: BrushDynamics,
217    pub hue: f64,
218    pub saturation: f64,
219    pub brightness: f64,
220    pub purity: f64,
221    pub per_tip: bool,
222}
223
224#[derive(Debug, Clone)]
225pub struct Transfer {
226    pub flow_dynamics: BrushDynamics,
227    pub opacity_dynamics: BrushDynamics,
228    pub wetness_dynamics: BrushDynamics,
229    pub mix_dynamics: BrushDynamics,
230}
231
232#[derive(Debug, Clone)]
233pub struct BrushPose {
234    pub override_angle: bool,
235    pub override_tilt_x: bool,
236    pub override_tilt_y: bool,
237    pub override_pressure: bool,
238    pub pressure: f64,
239    pub tilt_x: f64,
240    pub tilt_y: f64,
241    pub angle: f64,
242}
243
244/// TS `Brush.toolOptions.type`.
245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
246pub enum ToolType {
247    Brush,
248    MixerBrush,
249    SmudgeBrush,
250}
251
252#[derive(Debug, Clone)]
253pub struct ToolOptions {
254    pub type_: ToolType,
255    pub brush_preset: bool,
256    pub flow: f64,
257    pub wetness: Option<f64>,
258    pub dryness: Option<f64>,
259    pub mix: Option<f64>,
260    pub smooth: f64,
261    pub mode: BlendMode,
262    pub opacity: f64,
263    pub smoothing: bool,
264    pub smoothing_value: f64,
265    pub smoothing_radius_mode: bool,
266    pub smoothing_catchup: bool,
267    pub smoothing_catchup_at_end: bool,
268    pub smoothing_zoom_compensation: bool,
269    pub pressure_smoothing: bool,
270    pub use_pressure_overrides_size: bool,
271    pub use_pressure_overrides_opacity: bool,
272    pub use_legacy: bool,
273    pub auto_fill: Option<bool>,
274    pub auto_clean: Option<bool>,
275    pub load_solid_color_only: Option<bool>,
276    pub sample_all_layers: Option<bool>,
277    pub flow_dynamics: Option<BrushDynamics>,
278    pub opacity_dynamics: Option<BrushDynamics>,
279    pub size_dynamics: Option<BrushDynamics>,
280    pub smudge_finger_painting: Option<bool>,
281    pub smudge_sample_all_layers: Option<bool>,
282    pub strength: Option<f64>,
283}
284
285/// TS `Brush`.
286#[derive(Debug, Clone)]
287pub struct Brush {
288    pub name: String,
289    pub shape: BrushShape,
290    pub shape_dynamics: Option<ShapeDynamics>,
291    pub scatter: Option<Scatter>,
292    pub texture: Option<Texture>,
293    pub dual_brush: Option<DualBrush>,
294    pub color_dynamics: Option<ColorDynamics>,
295    pub transfer: Option<Transfer>,
296    pub brush_pose: Option<BrushPose>,
297    pub noise: bool,
298    pub wet_edges: bool,
299    pub protect_texture: Option<bool>,
300    pub spacing: f64,
301    pub interpretation: Option<bool>,
302    pub use_brush_size: bool,
303    pub tool_options: Option<ToolOptions>,
304}
305
306// ===========================================================================
307// Descriptor accessors (тонкая обёртка над типизированным деревом)
308// ===========================================================================
309
310fn dget<'a>(d: &'a Descriptor, key: &str) -> Option<&'a DescriptorValue> {
311    d.get(key)
312}
313
314fn as_bool(v: Option<&DescriptorValue>) -> bool {
315    matches!(v, Some(DescriptorValue::Boolean(true)))
316}
317
318fn as_opt_bool(v: Option<&DescriptorValue>) -> Option<bool> {
319    match v {
320        Some(DescriptorValue::Boolean(b)) => Some(*b),
321        _ => None,
322    }
323}
324
325fn as_number(v: Option<&DescriptorValue>) -> f64 {
326    match v {
327        Some(DescriptorValue::Integer(i)) => *i as f64,
328        Some(DescriptorValue::Double(d)) => *d,
329        Some(DescriptorValue::UnitDouble(u)) => u.value,
330        _ => 0.0,
331    }
332}
333
334fn as_opt_number(v: Option<&DescriptorValue>) -> Option<f64> {
335    match v {
336        Some(DescriptorValue::Integer(i)) => Some(*i as f64),
337        Some(DescriptorValue::Double(d)) => Some(*d),
338        Some(DescriptorValue::UnitDouble(u)) => Some(u.value),
339        _ => None,
340    }
341}
342
343fn as_text(v: Option<&DescriptorValue>) -> String {
344    match v {
345        Some(DescriptorValue::Text(s)) => s.clone(),
346        Some(DescriptorValue::Enum(s)) => s.clone(),
347        _ => String::new(),
348    }
349}
350
351fn as_descriptor(v: Option<&DescriptorValue>) -> Option<&Descriptor> {
352    match v {
353        Some(DescriptorValue::Descriptor(d)) => Some(d),
354        _ => None,
355    }
356}
357
358fn as_units<'a>(v: Option<&'a DescriptorValue>) -> Option<&'a UnitDoubleValue> {
359    match v {
360        Some(DescriptorValue::UnitDouble(u)) => Some(u),
361        _ => None,
362    }
363}
364
365fn as_raw(v: Option<&DescriptorValue>) -> Option<&[u8]> {
366    match v {
367        Some(DescriptorValue::RawData(b)) => Some(b),
368        _ => None,
369    }
370}
371
372// ===========================================================================
373// Parse helpers (зеркало descriptor.ts; DEPENDENCY GAP)
374// ===========================================================================
375
376/// Зеркало `parseAngle(x)`: 0 если undefined; иначе требует units == 'Angle'.
377fn parse_angle(v: Option<&DescriptorValue>) -> ReadResult<f64> {
378    match as_units(v) {
379        None => Ok(0.0),
380        Some(u) => {
381            if u.units != "Angle" {
382                return Err(ReadError::StrictViolation(format!(
383                    "Invalid units: {}",
384                    u.units
385                )));
386            }
387            Ok(u.value)
388        }
389    }
390}
391
392/// Зеркало `parsePercent(x)`: 1 если undefined; иначе units == 'Percent', value/100.
393fn parse_percent(v: Option<&DescriptorValue>) -> ReadResult<f64> {
394    match as_units(v) {
395        None => Ok(1.0),
396        Some(u) => {
397            if u.units != "Percent" {
398                return Err(ReadError::StrictViolation(format!(
399                    "Invalid units: {}",
400                    u.units
401                )));
402            }
403            Ok(u.value / 100.0)
404        }
405    }
406}
407
408/// Зеркало `parseUnitsToNumber(x, expectedUnits)`: требует совпадение units.
409fn parse_units_to_number(v: Option<&DescriptorValue>, expected: &str) -> ReadResult<f64> {
410    match as_units(v) {
411        Some(u) if u.units == expected => Ok(u.value),
412        Some(u) => Err(ReadError::StrictViolation(format!(
413            "Invalid units: {}",
414            u.units
415        ))),
416        None => Err(ReadError::StrictViolation(format!(
417            "Invalid units: missing (expected {})",
418            expected
419        ))),
420    }
421}
422
423/// Зеркало `BlnM.decode(code)`: ABR-код режима наложения -> [`BlendMode`].
424/// Маппинг — обратный к `BlnM` enum в descriptor.ts (только коды, релевантные ABR).
425fn blnm_decode(code: &str) -> BlendMode {
426    // code может приходить как "BlnM.Nrml" — берём сегмент после точки.
427    let key = code.split('.').nth(1).unwrap_or(code);
428    match key {
429        "Nrml" => BlendMode::Normal,
430        "Dslv" => BlendMode::Dissolve,
431        "Drkn" => BlendMode::Darken,
432        "Mltp" => BlendMode::Multiply,
433        "CBrn" => BlendMode::ColorBurn,
434        "linearBurn" => BlendMode::LinearBurn,
435        "darkerColor" => BlendMode::DarkerColor,
436        "Lghn" => BlendMode::Lighten,
437        "Scrn" => BlendMode::Screen,
438        "CDdg" => BlendMode::ColorDodge,
439        "linearDodge" => BlendMode::LinearDodge,
440        "lighterColor" => BlendMode::LighterColor,
441        "Ovrl" => BlendMode::Overlay,
442        "SftL" => BlendMode::SoftLight,
443        "HrdL" => BlendMode::HardLight,
444        "vividLight" => BlendMode::VividLight,
445        "linearLight" => BlendMode::LinearLight,
446        "pinLight" => BlendMode::PinLight,
447        "hardMix" => BlendMode::HardMix,
448        "Dfrn" => BlendMode::Difference,
449        "Xclu" => BlendMode::Exclusion,
450        "blendSubtraction" => BlendMode::Subtract,
451        "blendDivide" => BlendMode::Divide,
452        "H   " => BlendMode::Hue,
453        "Strt" => BlendMode::Saturation,
454        "Clr " => BlendMode::Color,
455        "Lmns" => BlendMode::Luminosity,
456        // 'linearHeight'/'Hght'/'Sbtr' используются в ABR, но не имеют отдельного
457        // BlendMode-варианта в crate::psd — отображаем на дефолт 'normal'.
458        _ => BlendMode::Normal,
459    }
460}
461
462fn parse_dynamics(desc: &Descriptor) -> ReadResult<BrushDynamics> {
463    let control_index = as_number(dget(desc, "bVTy")) as usize;
464    Ok(BrushDynamics {
465        control: DYNAMICS_CONTROL
466            .get(control_index)
467            .copied()
468            .unwrap_or("off")
469            .to_string(),
470        steps: as_number(dget(desc, "fStp")),
471        jitter: parse_percent(dget(desc, "jitter"))?,
472        minimum: parse_percent(dget(desc, "Mnm "))?,
473    })
474}
475
476fn parse_dynamics_opt(v: Option<&DescriptorValue>) -> ReadResult<Option<BrushDynamics>> {
477    match as_descriptor(v) {
478        Some(d) => Ok(Some(parse_dynamics(d)?)),
479        None => Ok(None),
480    }
481}
482
483fn parse_heightmap(array: &[u8]) -> Vec<u8> {
484    array.to_vec()
485}
486
487fn parse_brush_shape(desc: &Descriptor) -> ReadResult<BrushShape> {
488    match desc.class_id.as_str() {
489        "computedBrush" => Ok(BrushShape::Computed {
490            size: parse_units_to_number(dget(desc, "Dmtr"), "Pixels")?,
491            angle: parse_angle(dget(desc, "Angl"))?,
492            roundness: parse_percent(dget(desc, "Rndn"))?,
493            spacing_on: as_bool(dget(desc, "Intr")),
494            spacing: parse_percent(dget(desc, "Spcn"))?,
495            flip_x: as_bool(dget(desc, "flipX")),
496            flip_y: as_bool(dget(desc, "flipY")),
497            hardness: parse_percent(dget(desc, "Hrdn"))?,
498        }),
499        "sampledBrush" => Ok(BrushShape::Sampled {
500            size: parse_units_to_number(dget(desc, "Dmtr"), "Pixels")?,
501            angle: parse_angle(dget(desc, "Angl"))?,
502            roundness: parse_percent(dget(desc, "Rndn"))?,
503            spacing_on: as_bool(dget(desc, "Intr")),
504            spacing: parse_percent(dget(desc, "Spcn"))?,
505            flip_x: as_bool(dget(desc, "flipX")),
506            flip_y: as_bool(dget(desc, "flipY")),
507            name: as_text(dget(desc, "Nm  ")),
508            sampled_data: as_text(dget(desc, "sampledData")),
509        }),
510        "dBrush" => Ok(BrushShape::Dynamic {
511            shape: shape_name(DYNAMIC_BRUSH_SHAPE_SHAPES, as_number(dget(desc, "Shp "))),
512            angle: parse_angle(dget(desc, "Angl"))?,
513            size: parse_units_to_number(dget(desc, "Dmtr"), "Pixels")?,
514            density: parse_percent(dget(desc, "Dnst"))?,
515            length: parse_percent(dget(desc, "Lngt"))?,
516            clumping: parse_percent(dget(desc, "clumping"))?,
517            thickness: parse_percent(dget(desc, "thickness"))?,
518            stiffness: parse_percent(dget(desc, "stiffness"))?,
519            physics: as_bool(dget(desc, "physics")),
520            spacing: parse_percent(dget(desc, "Spcn"))?,
521            spacing_on: as_bool(dget(desc, "Intr")),
522            flip_x: as_bool(dget(desc, "flipX")),
523            flip_y: as_bool(dget(desc, "flipY")),
524        }),
525        "dTips" => {
526            let grid_size = as_number(dget(desc, "dtipsGridSize"));
527            let height_map = as_raw(dget(desc, "dtipsErodibleTipHeightMap"));
528            let (tips_grid_size, tips_erodible_tip_height_map) =
529                if grid_size != 0.0 && height_map.is_some() {
530                    (
531                        Some(grid_size),
532                        Some(parse_heightmap(height_map.unwrap())),
533                    )
534                } else {
535                    (None, None)
536                };
537            Ok(BrushShape::Tips {
538                angle: parse_angle(dget(desc, "Angl"))?,
539                size: parse_units_to_number(dget(desc, "Dmtr"), "Pixels")?,
540                shape: shape_name(DYNAMIC_BRUSH_SHAPE_SHAPES, as_number(dget(desc, "Shp "))),
541                physics: as_bool(dget(desc, "physics")),
542                spacing: parse_percent(dget(desc, "Spcn"))?,
543                spacing_on: as_bool(dget(desc, "Intr")),
544                flip_x: as_bool(dget(desc, "flipX")),
545                flip_y: as_bool(dget(desc, "flipY")),
546                tips_type: shape_name(TIPS_BRUSH_SHAPE_SHAPES, as_number(dget(desc, "dtipsType"))),
547                tips_length_ratio: parse_percent(dget(desc, "dtipsLengthRatio"))?,
548                tips_hardness: parse_percent(dget(desc, "dtipsHardness"))?,
549                tips_grid_size,
550                tips_erodible_tip_height_map,
551                tips_airbrush_cutoff_angle: as_number(dget(desc, "dtipsAirbrushCutoffAngle")),
552                tips_airbrush_granularity: parse_percent(dget(desc, "dtipsAirbrushGranularity"))?,
553                tips_airbrush_streakiness: parse_percent(dget(desc, "dtipsAirbrushStreakiness"))?,
554                tips_airbrush_splat_size: parse_percent(dget(desc, "dtipsAirbrushSplatSize"))?,
555                tips_airbrush_splat_count: as_number(dget(desc, "dtipsAirbrushSplatCount")),
556            })
557        }
558        other => Err(ReadError::StrictViolation(format!(
559            "Unknown brush classId: {}",
560            other
561        ))),
562    }
563}
564
565fn shape_name(table: &[&str], index: f64) -> String {
566    table
567        .get(index as usize)
568        .copied()
569        .unwrap_or("")
570        .to_string()
571}
572
573const TO_BRUSH_TYPE: &[(&str, ToolType)] = &[
574    ("_", ToolType::Brush),
575    ("MixB", ToolType::MixerBrush),
576    ("SmTl", ToolType::SmudgeBrush),
577];
578
579fn to_brush_type(class_id: &str) -> ToolType {
580    TO_BRUSH_TYPE
581        .iter()
582        .find(|(k, _)| *k == class_id)
583        .map(|(_, t)| *t)
584        .unwrap_or(ToolType::Brush)
585}
586
587// ===========================================================================
588// RLE + pattern decoding (зеркало psdReader.ts; DEPENDENCY GAP)
589// ===========================================================================
590
591struct PixelData<'a> {
592    data: &'a mut [u8],
593    width: usize,
594    #[allow(dead_code)]
595    height: usize,
596}
597
598/// Зеркало `readDataRLE(reader, pixelData, width, height, _bitDepth, step, offsets, large)`
599/// для случая `large = false` (uint16-длины).
600fn read_data_rle(
601    reader: &mut PsdReader,
602    pixel_data: Option<&mut PixelData>,
603    width: usize,
604    height: usize,
605    _bit_depth: i32,
606    step: usize,
607    offsets: &[usize],
608) -> ReadResult<()> {
609    let mut lengths: Vec<u16> = vec![0; offsets.len() * height];
610    let mut li = 0usize;
611    for _ in 0..offsets.len() {
612        for _ in 0..height {
613            lengths[li] = read_uint16(reader)?;
614            li += 1;
615        }
616    }
617
618    let extra_limit = step.wrapping_sub(1);
619
620    let has_data = pixel_data.is_some();
621    // Чтобы избежать борьбы с заимствованиями, держим Option<&mut [u8]>.
622    let mut data: Option<&mut [u8]> = pixel_data.map(|p| &mut *p.data);
623
624    li = 0;
625    for c in 0..offsets.len() {
626        let offset = offsets[c];
627        let extra = c > extra_limit || offset > extra_limit;
628
629        if !has_data || extra {
630            for _ in 0..height {
631                skip_bytes(reader, lengths[li] as usize);
632                li += 1;
633            }
634        } else {
635            let mut p = offset;
636            for _ in 0..height {
637                let length = lengths[li] as usize;
638                let buffer = read_bytes(reader, length)?;
639                li += 1;
640
641                let buf = data.as_deref_mut().unwrap();
642                let mut i = 0usize;
643                let mut x = 0usize;
644                while i < length {
645                    let mut header = buffer[i] as i32;
646                    if header > 128 {
647                        i += 1;
648                        let value = buffer[i];
649                        header = 256 - header;
650                        let mut j = 0;
651                        while j <= header && x < width {
652                            buf[p] = value;
653                            p += step;
654                            j += 1;
655                            x += 1;
656                        }
657                    } else if header < 128 {
658                        let mut j = 0;
659                        while j <= header && x < width {
660                            i += 1;
661                            buf[p] = buffer[i];
662                            p += step;
663                            j += 1;
664                            x += 1;
665                        }
666                    }
667                    // header == 128: ignore
668                    i += 1;
669                }
670            }
671        }
672    }
673    Ok(())
674}
675
676fn setup_grayscale(data: &mut [u8], width: usize, height: usize) {
677    let size = width * height * 4;
678    let mut i = 0;
679    while i < size {
680        let c = data[i];
681        data[i + 1] = c;
682        data[i + 2] = c;
683        i += 4;
684    }
685}
686
687fn copy_channel_to_rgba(
688    src: &[u8],
689    src_w: usize,
690    src_h: usize,
691    dst: &mut [u8],
692    dst_w: usize,
693    ox: usize,
694    oy: usize,
695    offset: usize,
696) {
697    for y in 0..src_h {
698        for x in 0..src_w {
699            let s = x + y * src_w;
700            let d = (ox + x + (y + oy) * dst_w) * 4;
701            dst[d + offset] = src[s];
702        }
703    }
704}
705
706/// Зеркало `readPattern(reader)` (RGB / Grayscale; Indexed читает палитру, но не
707/// поддерживает данные).
708fn read_pattern(reader: &mut PsdReader) -> ReadResult<PatternInfo> {
709    let mut length = read_uint32(reader)? as usize;
710    while length % 4 != 0 {
711        length += 1;
712    }
713    let end = reader.offset + length;
714    let version = read_uint32(reader)?;
715    if version != 1 {
716        return Err(ReadError::StrictViolation(format!(
717            "Invalid pattern version: {}",
718            version
719        )));
720    }
721
722    let color_mode = read_uint32(reader)? as i32;
723    let x = read_int16(reader)?;
724    let y = read_int16(reader)?;
725
726    let rgb = ColorMode::Rgb as i32;
727    let grayscale = ColorMode::Grayscale as i32;
728    let indexed = ColorMode::Indexed as i32;
729    if color_mode != rgb && color_mode != grayscale && color_mode != indexed {
730        return Err(ReadError::StrictViolation(format!(
731            "Unsupported pattern color mode: {}",
732            color_mode
733        )));
734    }
735
736    let name = read_unicode_string(reader)?;
737    let id = read_pascal_string(reader, 1)?;
738
739    if color_mode == indexed {
740        for _ in 0..256 {
741            read_uint8(reader)?;
742            read_uint8(reader)?;
743            read_uint8(reader)?;
744        }
745        skip_bytes(reader, 4);
746    }
747
748    // virtual memory array list
749    let version2 = read_uint32(reader)?;
750    if version2 != 3 {
751        return Err(ReadError::StrictViolation(format!(
752            "Invalid pattern VMAL version: {}",
753            version2
754        )));
755    }
756
757    read_uint32(reader)?; // length
758    let top = read_uint32(reader)?;
759    let left = read_uint32(reader)?;
760    let bottom = read_uint32(reader)?;
761    let right = read_uint32(reader)?;
762    let channels_count = read_uint32(reader)?;
763    let width = (right - left) as usize;
764    let height = (bottom - top) as usize;
765    let mut data = vec![0u8; width * height * 4];
766
767    let mut i = 3;
768    while i < data.len() {
769        data[i] = 255;
770        i += 4;
771    }
772
773    let mut ch = 0usize;
774    for _ in 0..(channels_count + 2) {
775        let has = read_uint32(reader)?;
776        if has == 0 {
777            continue;
778        }
779
780        let clen = read_uint32(reader)? as usize;
781        let pixel_depth = read_uint32(reader)?;
782        let ctop = read_uint32(reader)?;
783        let cleft = read_uint32(reader)?;
784        let cbottom = read_uint32(reader)?;
785        let cright = read_uint32(reader)?;
786        let pixel_depth2 = read_uint16(reader)?;
787        let compression_mode = read_uint8(reader)?; // 0 - raw, 1 - rle
788        let data_length = clen - (4 + 16 + 2 + 1);
789        let cdata = read_bytes(reader, data_length)?;
790
791        if pixel_depth != 8 || pixel_depth2 != 8 {
792            return Err(ReadError::StrictViolation(
793                "16bit pixel depth not supported for patterns".to_string(),
794            ));
795        }
796
797        let w = (cright - cleft) as usize;
798        let h = (cbottom - ctop) as usize;
799        let ox = (cleft - left) as usize;
800        let oy = (ctop - top) as usize;
801
802        if compression_mode == 0 {
803            if color_mode == rgb && ch < 3 {
804                for yy in 0..h {
805                    for xx in 0..w {
806                        let src = xx + yy * w;
807                        let dst = (ox + xx + (yy + oy) * width) * 4;
808                        data[dst + ch] = cdata[src];
809                    }
810                }
811            }
812            if color_mode == grayscale && ch < 1 {
813                for yy in 0..h {
814                    for xx in 0..w {
815                        let src = xx + yy * w;
816                        let dst = (ox + xx + (yy + oy) * width) * 4;
817                        let value = cdata[src];
818                        data[dst] = value;
819                        data[dst + 1] = value;
820                        data[dst + 2] = value;
821                    }
822                }
823            }
824            if color_mode == indexed {
825                return Err(ReadError::StrictViolation(
826                    "Indexed pattern color mode not implemented".to_string(),
827                ));
828            }
829        } else if compression_mode == 1 {
830            let mut temp = vec![0u8; w * h];
831            let mut cdata_reader = PsdReader::new(&cdata, None, None);
832
833            if color_mode == rgb && ch < 3 {
834                {
835                    let mut pd = PixelData {
836                        data: &mut temp,
837                        width: w,
838                        height: h,
839                    };
840                    read_data_rle(&mut cdata_reader, Some(&mut pd), w, h, 8, 1, &[0])?;
841                }
842                copy_channel_to_rgba(&temp, w, h, &mut data, width, ox, oy, ch);
843            }
844            if color_mode == grayscale && ch < 1 {
845                {
846                    let mut pd = PixelData {
847                        data: &mut temp,
848                        width: w,
849                        height: h,
850                    };
851                    read_data_rle(&mut cdata_reader, Some(&mut pd), w, h, 8, 1, &[0])?;
852                }
853                copy_channel_to_rgba(&temp, w, h, &mut data, width, ox, oy, 0);
854                setup_grayscale(&mut data, width, height);
855            }
856            if color_mode == indexed {
857                return Err(ReadError::StrictViolation(
858                    "Indexed pattern color mode not implemented".to_string(),
859                ));
860            }
861        } else {
862            return Err(ReadError::StrictViolation(
863                "Invalid pattern compression mode".to_string(),
864            ));
865        }
866
867        ch += 1;
868    }
869
870    reader.offset = end;
871
872    Ok(PatternInfo {
873        id,
874        name,
875        x: x as f64,
876        y: y as f64,
877        bounds: PatternBounds {
878            x: left as f64,
879            y: top as f64,
880            w: width as f64,
881            h: height as f64,
882        },
883        data,
884    })
885}
886
887// ===========================================================================
888// Brush descriptor -> Brush
889// ===========================================================================
890
891fn parse_brush(brush: &Descriptor) -> ReadResult<Brush> {
892    let shape_desc = as_descriptor(dget(brush, "Brsh"))
893        .ok_or_else(|| ReadError::StrictViolation("Missing brush shape descriptor".to_string()))?;
894
895    let mut b = Brush {
896        name: as_text(dget(brush, "Nm  ")),
897        shape: parse_brush_shape(shape_desc)?,
898        spacing: parse_percent(dget(brush, "Spcn"))?,
899        wet_edges: as_bool(dget(brush, "Wtdg")),
900        noise: as_bool(dget(brush, "Nose")),
901        use_brush_size: as_bool(dget(brush, "useBrushSize")),
902        shape_dynamics: None,
903        scatter: None,
904        texture: None,
905        dual_brush: None,
906        color_dynamics: None,
907        transfer: None,
908        brush_pose: None,
909        protect_texture: None,
910        interpretation: None,
911        tool_options: None,
912    };
913
914    if let Some(v) = as_opt_bool(dget(brush, "interpretation")) {
915        b.interpretation = Some(v);
916    }
917    if let Some(v) = as_opt_bool(dget(brush, "protectTexture")) {
918        b.protect_texture = Some(v);
919    }
920
921    if as_bool(dget(brush, "useTipDynamics")) {
922        b.shape_dynamics = Some(ShapeDynamics {
923            tilt_scale: parse_percent(dget(brush, "tiltScale"))?,
924            size_dynamics: parse_dynamics_desc(brush, "szVr")?,
925            angle_dynamics: parse_dynamics_desc(brush, "angleDynamics")?,
926            roundness_dynamics: parse_dynamics_desc(brush, "roundnessDynamics")?,
927            flip_x: as_bool(dget(brush, "flipX")),
928            flip_y: as_bool(dget(brush, "flipY")),
929            brush_projection: as_bool(dget(brush, "brushProjection")),
930            minimum_diameter: parse_percent(dget(brush, "minimumDiameter"))?,
931            minimum_roundness: parse_percent(dget(brush, "minimumRoundness"))?,
932        });
933    }
934
935    if as_bool(dget(brush, "useScatter")) {
936        b.scatter = Some(Scatter {
937            count: as_number(dget(brush, "Cnt ")),
938            both_axes: as_bool(dget(brush, "bothAxes")),
939            count_dynamics: parse_dynamics_desc(brush, "countDynamics")?,
940            scatter_dynamics: parse_dynamics_desc(brush, "scatterDynamics")?,
941        });
942    }
943
944    if as_bool(dget(brush, "useTexture")) {
945        if let Some(txtr) = as_descriptor(dget(brush, "Txtr")) {
946            b.texture = Some(Texture {
947                id: as_text(dget(txtr, "Idnt")),
948                name: as_text(dget(txtr, "Nm  ")),
949                blend_mode: blnm_decode(&as_text(dget(brush, "textureBlendMode"))),
950                depth: parse_percent(dget(brush, "textureDepth"))?,
951                depth_minimum: parse_percent(dget(brush, "minimumDepth"))?,
952                depth_dynamics: parse_dynamics_desc(brush, "textureDepthDynamics")?,
953                scale: parse_percent(dget(brush, "textureScale"))?,
954                invert: as_bool(dget(brush, "InvT")),
955                brightness: as_number(dget(brush, "textureBrightness")),
956                contrast: as_number(dget(brush, "textureContrast")),
957                texture_each_tip: as_bool(dget(brush, "TxtC")),
958            });
959        }
960    }
961
962    if let Some(db) = as_descriptor(dget(brush, "dualBrush")) {
963        if as_bool(dget(db, "useDualBrush")) {
964            let db_shape = as_descriptor(dget(db, "Brsh")).ok_or_else(|| {
965                ReadError::StrictViolation("Missing dual brush shape".to_string())
966            })?;
967            b.dual_brush = Some(DualBrush {
968                flip: as_bool(dget(db, "Flip")),
969                shape: parse_brush_shape(db_shape)?,
970                blend_mode: blnm_decode(&as_text(dget(db, "BlnM"))),
971                use_scatter: as_bool(dget(db, "useScatter")),
972                spacing: parse_percent(dget(db, "Spcn"))?,
973                count: as_number(dget(db, "Cnt ")),
974                both_axes: as_bool(dget(db, "bothAxes")),
975                count_dynamics: parse_dynamics_desc(db, "countDynamics")?,
976                scatter_dynamics: parse_dynamics_desc(db, "scatterDynamics")?,
977            });
978        }
979    }
980
981    if as_bool(dget(brush, "useColorDynamics")) {
982        b.color_dynamics = Some(ColorDynamics {
983            foreground_background: parse_dynamics_desc(brush, "clVr")?,
984            hue: parse_percent(dget(brush, "H   "))?,
985            saturation: parse_percent(dget(brush, "Strt"))?,
986            brightness: parse_percent(dget(brush, "Brgh"))?,
987            purity: parse_percent(dget(brush, "purity"))?,
988            per_tip: as_bool(dget(brush, "colorDynamicsPerTip")),
989        });
990    }
991
992    if as_bool(dget(brush, "usePaintDynamics")) {
993        b.transfer = Some(Transfer {
994            flow_dynamics: parse_dynamics_desc(brush, "prVr")?,
995            opacity_dynamics: parse_dynamics_desc(brush, "opVr")?,
996            wetness_dynamics: parse_dynamics_desc(brush, "wtVr")?,
997            mix_dynamics: parse_dynamics_desc(brush, "mxVr")?,
998        });
999    }
1000
1001    if as_bool(dget(brush, "useBrushPose")) {
1002        b.brush_pose = Some(BrushPose {
1003            override_angle: as_bool(dget(brush, "overridePoseAngle")),
1004            override_tilt_x: as_bool(dget(brush, "overridePoseTiltX")),
1005            override_tilt_y: as_bool(dget(brush, "overridePoseTiltY")),
1006            override_pressure: as_bool(dget(brush, "overridePosePressure")),
1007            pressure: parse_percent(dget(brush, "brushPosePressure"))?,
1008            tilt_x: as_number(dget(brush, "brushPoseTiltX")),
1009            tilt_y: as_number(dget(brush, "brushPoseTiltY")),
1010            angle: as_number(dget(brush, "brushPoseAngle")),
1011        });
1012    }
1013
1014    if let Some(to) = as_descriptor(dget(brush, "toolOptions")) {
1015        let mut opts = ToolOptions {
1016            type_: to_brush_type(&to.class_id),
1017            brush_preset: as_bool(dget(to, "brushPreset")),
1018            flow: as_opt_number(dget(to, "flow")).unwrap_or(100.0),
1019            smooth: as_opt_number(dget(to, "Smoo")).unwrap_or(0.0),
1020            mode: blnm_decode(&{
1021                let m = as_text(dget(to, "Md  "));
1022                if m.is_empty() {
1023                    "BlnM.Nrml".to_string()
1024                } else {
1025                    m
1026                }
1027            }),
1028            opacity: as_opt_number(dget(to, "Opct")).unwrap_or(100.0),
1029            smoothing: as_bool(dget(to, "smoothing")),
1030            smoothing_value: as_opt_number(dget(to, "smoothingValue")).unwrap_or(0.0),
1031            smoothing_radius_mode: as_bool(dget(to, "smoothingRadiusMode")),
1032            smoothing_catchup: as_bool(dget(to, "smoothingCatchup")),
1033            smoothing_catchup_at_end: as_bool(dget(to, "smoothingCatchupAtEnd")),
1034            smoothing_zoom_compensation: as_bool(dget(to, "smoothingZoomCompensation")),
1035            pressure_smoothing: as_bool(dget(to, "pressureSmoothing")),
1036            use_pressure_overrides_size: as_bool(dget(to, "usePressureOverridesSize")),
1037            use_pressure_overrides_opacity: as_bool(dget(to, "usePressureOverridesOpacity")),
1038            use_legacy: as_bool(dget(to, "useLegacy")),
1039            wetness: None,
1040            dryness: None,
1041            mix: None,
1042            auto_fill: None,
1043            auto_clean: None,
1044            load_solid_color_only: None,
1045            sample_all_layers: None,
1046            flow_dynamics: None,
1047            opacity_dynamics: None,
1048            size_dynamics: None,
1049            smudge_finger_painting: None,
1050            smudge_sample_all_layers: None,
1051            strength: None,
1052        };
1053
1054        opts.flow_dynamics = parse_dynamics_opt(dget(to, "prVr"))?;
1055        opts.opacity_dynamics = parse_dynamics_opt(dget(to, "opVr"))?;
1056        opts.size_dynamics = parse_dynamics_opt(dget(to, "szVr"))?;
1057        if let Some(v) = as_opt_number(dget(to, "wetness")) {
1058            opts.wetness = Some(v);
1059        }
1060        if let Some(v) = as_opt_number(dget(to, "dryness")) {
1061            opts.dryness = Some(v);
1062        }
1063        if let Some(v) = as_opt_number(dget(to, "mix")) {
1064            opts.mix = Some(v);
1065        }
1066        if let Some(v) = as_opt_bool(dget(to, "autoFill")) {
1067            opts.auto_fill = Some(v);
1068        }
1069        if let Some(v) = as_opt_bool(dget(to, "autoClean")) {
1070            opts.auto_clean = Some(v);
1071        }
1072        if let Some(v) = as_opt_bool(dget(to, "loadSolidColorOnly")) {
1073            opts.load_solid_color_only = Some(v);
1074        }
1075        if let Some(v) = as_opt_bool(dget(to, "sampleAllLayers")) {
1076            opts.sample_all_layers = Some(v);
1077        }
1078        if let Some(v) = as_opt_bool(dget(to, "SmdF")) {
1079            opts.smudge_finger_painting = Some(v);
1080        }
1081        if let Some(v) = as_opt_bool(dget(to, "SmdS")) {
1082            opts.smudge_sample_all_layers = Some(v);
1083        }
1084        if let Some(v) = as_opt_number(dget(to, "Prs ")) {
1085            opts.strength = Some(v);
1086        }
1087
1088        b.tool_options = Some(opts);
1089    }
1090
1091    Ok(b)
1092}
1093
1094fn parse_dynamics_desc(parent: &Descriptor, key: &str) -> ReadResult<BrushDynamics> {
1095    match as_descriptor(dget(parent, key)) {
1096        Some(d) => parse_dynamics(d),
1097        None => Err(ReadError::StrictViolation(format!(
1098            "Missing dynamics descriptor: {}",
1099            key
1100        ))),
1101    }
1102}
1103
1104// ===========================================================================
1105// Main reader
1106// ===========================================================================
1107
1108/// Опции `readAbr`.
1109#[derive(Debug, Clone, Default)]
1110pub struct ReadAbrOptions {
1111    pub log_missing_features: bool,
1112}
1113
1114/// Порт `readAbr(buffer, options)`.
1115pub fn read_abr(buffer: &[u8], _options: &ReadAbrOptions) -> ReadResult<Abr> {
1116    let reader = &mut PsdReader::new(buffer, None, None);
1117    let version = read_int16(reader)?;
1118    let mut samples: Vec<SampleInfo> = Vec::new();
1119    let mut brushes: Vec<Brush> = Vec::new();
1120    let mut patterns: Vec<PatternInfo> = Vec::new();
1121
1122    if version == 1 || version == 2 {
1123        return Err(ReadError::StrictViolation(format!(
1124            "Unsupported ABR version ({})",
1125            version
1126        )));
1127    } else if version == 6 || version == 7 || version == 9 || version == 10 {
1128        let minor_version = read_int16(reader)?;
1129        if minor_version != 1 && minor_version != 2 {
1130            return Err(ReadError::StrictViolation(
1131                "Unsupported ABR minor version".to_string(),
1132            ));
1133        }
1134
1135        while reader.offset < reader.buffer.len() {
1136            check_signature(reader, "8BIM", None)?;
1137            let type_ = read_signature(reader)?;
1138            let mut size = read_uint32(reader)? as usize;
1139            let end = reader.offset + size;
1140
1141            match type_.as_str() {
1142                "samp" => {
1143                    while reader.offset < end {
1144                        let mut brush_length = read_uint32(reader)? as usize;
1145                        while brush_length & 0b11 != 0 {
1146                            brush_length += 1; // pad to 4 byte alignment
1147                        }
1148                        let brush_end = reader.offset + brush_length;
1149
1150                        let id = read_pascal_string(reader, 1)?;
1151
1152                        // v1 - skip Int16 bounds + unknown Int16 (10 bytes)
1153                        // v2 - skip unknown 264 bytes
1154                        skip_bytes(reader, if minor_version == 1 { 10 } else { 264 });
1155
1156                        let y = read_int32(reader)?;
1157                        let x = read_int32(reader)?;
1158                        let h = read_int32(reader)? - y;
1159                        let w = read_int32(reader)? - x;
1160                        if w <= 0 || h <= 0 {
1161                            return Err(ReadError::StrictViolation("Invalid bounds".to_string()));
1162                        }
1163
1164                        let bit_depth = read_int16(reader)?;
1165                        let compression = read_uint8(reader)?; // 0 - raw, 1 - RLE
1166                        let mut alpha = vec![0u8; (w * h) as usize];
1167
1168                        if bit_depth == 8 {
1169                            if compression == 0 {
1170                                let bytes = read_bytes(reader, alpha.len())?;
1171                                alpha.copy_from_slice(&bytes);
1172                            } else if compression == 1 {
1173                                let mut pd = PixelData {
1174                                    data: &mut alpha,
1175                                    width: w as usize,
1176                                    height: h as usize,
1177                                };
1178                                read_data_rle(
1179                                    reader,
1180                                    Some(&mut pd),
1181                                    w as usize,
1182                                    h as usize,
1183                                    bit_depth as i32,
1184                                    1,
1185                                    &[0],
1186                                )?;
1187                            } else {
1188                                return Err(ReadError::StrictViolation(
1189                                    "Invalid compression".to_string(),
1190                                ));
1191                            }
1192                        } else if bit_depth == 16 {
1193                            if compression == 0 {
1194                                for i in 0..alpha.len() {
1195                                    alpha[i] = (read_uint16(reader)? >> 8) as u8; // -> 8bit
1196                                }
1197                            } else if compression == 1 {
1198                                return Err(ReadError::StrictViolation(
1199                                    "not implemented (16bit RLE)".to_string(),
1200                                ));
1201                            } else {
1202                                return Err(ReadError::StrictViolation(
1203                                    "Invalid compression".to_string(),
1204                                ));
1205                            }
1206                        } else {
1207                            return Err(ReadError::StrictViolation("Invalid depth".to_string()));
1208                        }
1209
1210                        samples.push(SampleInfo {
1211                            id,
1212                            bounds: SampleBounds { x, y, w, h },
1213                            alpha,
1214                        });
1215                        reader.offset = brush_end;
1216                    }
1217                }
1218                "desc" => {
1219                    let desc = crate::descriptor::read_version_and_descriptor(reader)?;
1220                    if let Some(DescriptorValue::List(list)) = dget(&desc, "Brsh") {
1221                        for item in list {
1222                            if let DescriptorValue::Descriptor(brush) = item {
1223                                brushes.push(parse_brush(brush)?);
1224                            }
1225                        }
1226                    }
1227                }
1228                "patt" => {
1229                    while reader.offset < end {
1230                        patterns.push(read_pattern(reader)?);
1231                    }
1232                    reader.offset = end;
1233                }
1234                "phry" => {
1235                    // TODO: what is this ? — читаем дескриптор и игнорируем.
1236                    let _desc = crate::descriptor::read_version_and_descriptor(reader)?;
1237                }
1238                other => {
1239                    return Err(ReadError::StrictViolation(format!(
1240                        "Invalid brush type: {}",
1241                        other
1242                    )));
1243                }
1244            }
1245
1246            // align to 4 bytes
1247            while size % 4 != 0 {
1248                reader.offset += 1;
1249                size += 1;
1250            }
1251        }
1252    } else {
1253        return Err(ReadError::StrictViolation(format!(
1254            "Unsupported ABR version ({})",
1255            version
1256        )));
1257    }
1258
1259    Ok(Abr {
1260        samples,
1261        patterns,
1262        brushes,
1263    })
1264}
1265
1266#[cfg(test)]
1267mod tests {
1268    use super::*;
1269    use std::path::PathBuf;
1270
1271    fn fixture_dir() -> PathBuf {
1272        // crates/ag-psd -> repo root -> test/ag-psd/test/abr-read
1273        let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1274        p.pop(); // crates
1275        p.pop(); // repo root
1276        p.push("test/ag-psd/test/abr-read");
1277        p
1278    }
1279
1280    fn find_abr(dir: &std::path::Path) -> Option<PathBuf> {
1281        let entries = std::fs::read_dir(dir).ok()?;
1282        for e in entries.flatten() {
1283            let path = e.path();
1284            if path.extension().map(|x| x == "abr").unwrap_or(false) {
1285                return Some(path);
1286            }
1287        }
1288        None
1289    }
1290
1291    #[test]
1292    fn abr_rejects_v1() {
1293        // version 1 -> unsupported
1294        let bytes = [0u8, 1u8];
1295        assert!(read_abr(&bytes, &ReadAbrOptions::default()).is_err());
1296    }
1297
1298    #[test]
1299    fn abr_rejects_unknown_version() {
1300        let bytes = [0u8, 99u8];
1301        assert!(read_abr(&bytes, &ReadAbrOptions::default()).is_err());
1302    }
1303
1304    #[test]
1305    fn abr_decodes_fixtures_if_present() {
1306        let base = fixture_dir();
1307        if !base.exists() {
1308            eprintln!("abr fixtures not present, skipping");
1309            return;
1310        }
1311
1312        let mut decoded_any = false;
1313        for sub in ["simple", "sample-and-pattern", "tilt", "special"] {
1314            let dir = base.join(sub);
1315            if let Some(abr_path) = find_abr(&dir) {
1316                let data = std::fs::read(&abr_path).expect("read fixture");
1317                match read_abr(&data, &ReadAbrOptions::default()) {
1318                    Ok(abr) => {
1319                        decoded_any = true;
1320                        // sanity: sample bounds positive, brush names valid utf8 (always)
1321                        for s in &abr.samples {
1322                            assert!(s.bounds.w > 0 && s.bounds.h > 0);
1323                            assert_eq!(s.alpha.len(), (s.bounds.w * s.bounds.h) as usize);
1324                        }
1325                        eprintln!(
1326                            "decoded {:?}: {} brushes, {} samples, {} patterns",
1327                            abr_path.file_name().unwrap(),
1328                            abr.brushes.len(),
1329                            abr.samples.len(),
1330                            abr.patterns.len()
1331                        );
1332                    }
1333                    Err(e) => {
1334                        // Surface decode errors so the test is meaningful.
1335                        panic!("failed to decode {:?}: {:?}", abr_path, e);
1336                    }
1337                }
1338            }
1339        }
1340
1341        if !decoded_any {
1342            eprintln!("no .abr fixture files found, smoke-only");
1343        }
1344    }
1345
1346    #[test]
1347    fn abr_simple_fixture_content() {
1348        let path = fixture_dir().join("simple/src.abr");
1349        if !path.exists() {
1350            eprintln!("simple fixture missing, skipping");
1351            return;
1352        }
1353        let data = std::fs::read(&path).unwrap();
1354        let abr = read_abr(&data, &ReadAbrOptions::default()).expect("decode simple");
1355        assert_eq!(abr.brushes.len(), 1);
1356        let b = &abr.brushes[0];
1357        assert_eq!(b.name, "Soft Round");
1358        assert_eq!(b.spacing, 1.0);
1359        assert!(!b.wet_edges);
1360        assert!(!b.noise);
1361        assert!(b.use_brush_size);
1362        match &b.shape {
1363            BrushShape::Computed {
1364                size,
1365                angle,
1366                roundness,
1367                spacing_on,
1368                spacing,
1369                hardness,
1370                ..
1371            } => {
1372                assert_eq!(*size, 30.0);
1373                assert_eq!(*angle, 0.0);
1374                assert_eq!(*roundness, 1.0);
1375                assert!(*spacing_on);
1376                assert_eq!(*spacing, 0.25);
1377                assert_eq!(*hardness, 0.0);
1378            }
1379            other => panic!("expected computed brush, got {:?}", other),
1380        }
1381    }
1382}