Skip to main content

oxideav_cli_convert/
op.rs

1//! IM-style operations supported by `oxideav convert`.
2//!
3//! Each variant carries exactly the data the CLI needs to hand to
4//! [`crate::plan_to_job`], nothing more.
5
6/// Dither strategy used when `-colors N` forces a paletted output.
7#[derive(Clone, Copy, Debug, PartialEq, Eq)]
8pub enum Dither {
9    None,
10    Bayer,
11    FloydSteinberg,
12}
13
14impl Dither {
15    /// Parse the value after `-dither`. Accepts ImageMagick-ish names
16    /// case-insensitively (`None`, `FloydSteinberg`, `floyd_steinberg`,
17    /// `o8x8` / `ordered` → Bayer).
18    pub fn parse(s: &str) -> Result<Dither, String> {
19        match s.to_ascii_lowercase().as_str() {
20            "none" => Ok(Dither::None),
21            "floyd_steinberg" | "floyd-steinberg" | "floydsteinberg" | "fs" => {
22                Ok(Dither::FloydSteinberg)
23            }
24            "bayer" | "ordered" | "o8x8" => Ok(Dither::Bayer),
25            other => Err(format!(
26                "convert: -dither: unknown strategy '{other}' (expected 'none', 'bayer', or 'floyd_steinberg')"
27            )),
28        }
29    }
30}
31
32/// `-alpha SUBCOMMAND`. ImageMagick exposes a small grammar of alpha-channel
33/// edits — we cover the ones with deterministic semantics on RGBA buffers.
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35pub enum AlphaOp {
36    /// `on` / `activate` — enable alpha (no-op when output is already RGBA).
37    On,
38    /// `off` / `deactivate` — strip the alpha channel from the output
39    /// (Rgba → Rgb24).
40    Off,
41    /// `remove` — composite the image over the current `-background`
42    /// colour and force every output pixel's alpha to `255`.
43    Remove,
44    /// `set` / `opaque` — set every alpha sample to `255` without
45    /// compositing.
46    Set,
47    /// `transparent` — set every alpha sample to `0` without touching
48    /// the colour channels.
49    Transparent,
50}
51
52impl AlphaOp {
53    pub fn parse(s: &str) -> Result<AlphaOp, String> {
54        match s.to_ascii_lowercase().as_str() {
55            "on" | "activate" => Ok(AlphaOp::On),
56            "off" | "deactivate" => Ok(AlphaOp::Off),
57            "remove" => Ok(AlphaOp::Remove),
58            "set" | "opaque" => Ok(AlphaOp::Set),
59            "transparent" => Ok(AlphaOp::Transparent),
60            other => Err(format!(
61                "convert: -alpha: unknown subcommand '{other}' (expected 'on'/'off'/'activate'/'deactivate'/'remove'/'set'/'opaque'/'transparent')"
62            )),
63        }
64    }
65}
66
67/// `-resize` / `-thumbnail` geometry-modifier suffix.
68///
69/// ImageMagick's geometry grammar tags the literal `WxH` with a single
70/// trailing character that selects the scaling policy. We honour the
71/// six common ones; everything else (`%@` combos, gravity flags, …)
72/// is a documented follow-up.
73///
74/// The runtime sees the original `WxH` plus this tag and resolves the
75/// final pixel dimensions against the actual source size — most modes
76/// can't be eagerly resolved at parse time because we don't yet know
77/// the source dims.
78#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
79pub enum ResizeMode {
80    /// No suffix — preserve aspect ratio and FIT inside the `WxH` box
81    /// (the larger of the two scale factors wins, so neither output
82    /// dimension exceeds the request). IM's default geometry policy.
83    #[default]
84    Default,
85    /// `WxH!` — IGNORE aspect ratio, force the exact `WxH` dimensions.
86    /// Equivalent to the original `bang = true`.
87    Force,
88    /// `WxH^` — preserve aspect ratio and FILL the `WxH` box (the
89    /// smaller of the two scale factors wins, so both output
90    /// dimensions are at least the request). Common pairing with a
91    /// follow-up `-crop` to land on exact `WxH`.
92    Fill,
93    /// `WxH>` — only resize when the input is LARGER than `WxH`
94    /// (otherwise pass through unchanged). IM's "shrink-only" policy.
95    Shrink,
96    /// `WxH<` — only resize when the input is SMALLER than `WxH`
97    /// (otherwise pass through unchanged). IM's "enlarge-only" policy.
98    Grow,
99    /// `WxH%` — interpret `W` and `H` as percentages of the source
100    /// dimensions (independent X / Y scale). `WxH` are still parsed as
101    /// integer percentages by `parse_wxh`; if only one number is given
102    /// it applies to both axes (handled at parse time).
103    Percent,
104    /// `WxH@` — interpret `W*H` as the TARGET pixel area; both output
105    /// dims are scaled by `sqrt(target_area / source_area)` so the
106    /// aspect ratio is preserved AND the output area matches the
107    /// request.
108    Area,
109}
110
111impl ResizeMode {
112    /// Strip an IM geometry-modifier suffix off `WxH`.
113    /// Returns `(mode, geometry-without-suffix)`. No suffix means
114    /// `Default` plus the input verbatim. Multiple suffix chars
115    /// (e.g. `WxH^!`) are rejected by the args parser layer; here we
116    /// only peel the LAST one and report what we found.
117    pub fn split_suffix(s: &str) -> (ResizeMode, &str) {
118        if let Some(core) = s.strip_suffix('!') {
119            return (ResizeMode::Force, core);
120        }
121        if let Some(core) = s.strip_suffix('^') {
122            return (ResizeMode::Fill, core);
123        }
124        if let Some(core) = s.strip_suffix('>') {
125            return (ResizeMode::Shrink, core);
126        }
127        if let Some(core) = s.strip_suffix('<') {
128            return (ResizeMode::Grow, core);
129        }
130        if let Some(core) = s.strip_suffix('%') {
131            return (ResizeMode::Percent, core);
132        }
133        if let Some(core) = s.strip_suffix('@') {
134            return (ResizeMode::Area, core);
135        }
136        (ResizeMode::Default, s)
137    }
138
139    /// Lowercase tag string used in JSON params handed to the resize
140    /// factory. Stable identifiers — codecs that grow geometry-mode
141    /// awareness can branch off these.
142    pub fn as_tag(self) -> &'static str {
143        match self {
144            ResizeMode::Default => "default",
145            ResizeMode::Force => "force",
146            ResizeMode::Fill => "fill",
147            ResizeMode::Shrink => "shrink",
148            ResizeMode::Grow => "grow",
149            ResizeMode::Percent => "percent",
150            ResizeMode::Area => "area",
151        }
152    }
153
154    /// Resolve the final `(out_w, out_h)` for a request of `(req_w,
155    /// req_h)` against a source size of `(src_w, src_h)`.
156    ///
157    /// Output is always >= 1 on both axes; clipping to source dims for
158    /// `Shrink` / `Grow` modes happens here so the caller can blindly
159    /// hand the result to the resize filter without re-checking.
160    pub fn resolve(self, req_w: u32, req_h: u32, src_w: u32, src_h: u32) -> (u32, u32) {
161        let sw = src_w.max(1) as f64;
162        let sh = src_h.max(1) as f64;
163        let rw = req_w.max(1) as f64;
164        let rh = req_h.max(1) as f64;
165        let (out_w, out_h) = match self {
166            ResizeMode::Default => {
167                // Aspect-preserving fit: pick the smaller scale.
168                let s = (rw / sw).min(rh / sh);
169                ((sw * s).round(), (sh * s).round())
170            }
171            ResizeMode::Force => (rw, rh),
172            ResizeMode::Fill => {
173                let s = (rw / sw).max(rh / sh);
174                ((sw * s).round(), (sh * s).round())
175            }
176            ResizeMode::Shrink => {
177                if (src_w <= req_w) && (src_h <= req_h) {
178                    (sw, sh)
179                } else {
180                    let s = (rw / sw).min(rh / sh);
181                    ((sw * s).round(), (sh * s).round())
182                }
183            }
184            ResizeMode::Grow => {
185                if (src_w >= req_w) && (src_h >= req_h) {
186                    (sw, sh)
187                } else {
188                    let s = (rw / sw).min(rh / sh);
189                    ((sw * s).round(), (sh * s).round())
190                }
191            }
192            ResizeMode::Percent => {
193                // req_w / req_h are percentages: 50 → half, 200 → double.
194                ((sw * rw / 100.0).round(), (sh * rh / 100.0).round())
195            }
196            ResizeMode::Area => {
197                let target_area = rw * rh;
198                let src_area = sw * sh;
199                let s = (target_area / src_area).sqrt();
200                ((sw * s).round(), (sh * s).round())
201            }
202        };
203        ((out_w as i64).max(1) as u32, (out_h as i64).max(1) as u32)
204    }
205}
206
207/// One convert operation.
208///
209/// Operations apply in source order — same as `imagemagick convert`,
210/// even though we don't yet support IM's stack-reset semantics.
211#[derive(Clone, Debug, PartialEq)]
212pub enum Op {
213    /// `-resize WxH[!^<>%@]`. The trailing modifier selects the
214    /// scaling policy via [`ResizeMode`]; absence of any modifier is
215    /// `ResizeMode::Default` (aspect-preserving fit-inside).
216    Resize {
217        width: u32,
218        height: u32,
219        mode: ResizeMode,
220    },
221    /// `-thumbnail WxH[!^<>%@]` — IM convenience flag. Same geometry
222    /// grammar as `-resize`; the runner unrolls it into a Resize plus
223    /// Strip pair (and, eventually, an auto-orient pass) so the
224    /// downstream pipeline doesn't need a dedicated thumbnail node.
225    Thumbnail {
226        width: u32,
227        height: u32,
228        mode: ResizeMode,
229    },
230    /// `-define KEY[=VALUE]` — opaque codec-specific tunable forwarded
231    /// to the sink. Keys keep their literal form (e.g.
232    /// `jpeg:dct-method`); the sink encoder picks up keys it
233    /// recognises and silently ignores the rest, mirroring IM's
234    /// "tolerant of irrelevant options" posture. Bare `-define KEY`
235    /// (no `=VALUE`) sets the key to JSON `true`.
236    Define { key: String, value: Option<String> },
237    /// `-blur RxS`. Sigma defaults to `radius / 2.0` when the `xS`
238    /// portion is omitted (matches IM's convention).
239    Blur { radius: u32, sigma: f32 },
240    /// `-edge R`. Radius ignored by the current Sobel impl but parsed
241    /// for forward-compatibility.
242    Edge { radius: u32 },
243    /// `-colors N` paired with an optional `-dither` preceding it.
244    /// When present, the output is paletted via
245    /// [`oxideav_pixfmt`] before encoding.
246    Colors { count: u32, dither: Dither },
247    /// `-format FMT` — override the container/codec decision
248    /// otherwise derived from the output extension.
249    Format(String),
250    /// `-quality N` — forwarded to the sink codec (e.g. JPEG quality,
251    /// WebP quality). Silently dropped by codecs that don't honour it.
252    Quality(u32),
253    /// `-strip` — request that metadata (EXIF, XMP, ID3, etc.) be
254    /// dropped on write.
255    Strip,
256    /// `-depth N` — output bit depth per channel for still-image
257    /// sinks (`8` or `16`). Caps the encoder input layout the planner
258    /// picks (a 10-bit HEIC → `-depth 8` → 8-bit PNG) or asks for the
259    /// deeper layout (`-depth 16`). Without it the source's own depth
260    /// is kept when the encoder can carry it.
261    Depth(u8),
262    /// `--opt KEY=VALUE` — an encoder option for still-image sinks,
263    /// validated against the target encoder's declared options schema
264    /// (the keys `oxideav info <codec>` lists) and handed to the
265    /// encoder `convert` builds for the output. Repeatable; a later
266    /// value for the same key wins.
267    EncoderOpt { key: String, value: String },
268    /// `-density N` — DPI for vector→raster conversion. PDF / SVG
269    /// pages are measured in PostScript points (1/72 inch); a page at
270    /// 300 DPI rasterises to `points × (300 / 72)` pixels per axis.
271    /// Ignored on raster-only inputs.
272    Density(u32),
273    /// `-background COLOR` — canvas fill colour used by the
274    /// rasteriser and by `-alpha remove`. CSS L3 named colours and
275    /// `#hex` 3/4/6/8 forms accepted (same parser as `xc:`).
276    Background([u8; 4]),
277    /// `-alpha SUBCOMMAND` — see [`AlphaOp`].
278    Alpha(AlphaOp),
279    /// `-rotate N` — rotate by N degrees. Round-1 supports only
280    /// multiples of 90 (90/180/270 and the negatives). Other angles
281    /// are rejected by the args parser with a "only multiples of 90
282    /// supported" message.
283    Rotate { degrees: i32 },
284    /// `-flip` — vertical flip (reverse row order).
285    Flip,
286    /// `-flop` — horizontal flip (reverse column order within each row).
287    Flop,
288    /// `-crop WxH+X+Y` — extract a `WxH` bounding box at offset `(X,Y)`.
289    /// The args parser accepts the IM grammar; runtime checks that the
290    /// bbox fits inside the input dimensions and errors cleanly when
291    /// it doesn't.
292    Crop { x: u32, y: u32, w: u32, h: u32 },
293    /// `-negate` — per-pixel `out = 255 - in` on the colour channels;
294    /// alpha (when present) is unchanged.
295    Negate,
296    /// `-sharpen RxS` — unsharp-mask sharpening with radius / sigma
297    /// (amount defaults to 1.0 in the factory). Sigma defaults to
298    /// `radius / 2.0` when only `R` is given.
299    Sharpen { radius: u32, sigma: f32 },
300    /// `-unsharp RxS+amount+threshold` — full unsharp-mask grammar.
301    /// Amount and threshold each default to a sensible value when
302    /// omitted (`amount = 1.0`, `threshold = 0`).
303    Unsharp {
304        radius: u32,
305        sigma: f32,
306        amount: f32,
307        threshold: u8,
308    },
309    /// `-gamma G` — power-law gamma correction. `G > 0`.
310    Gamma { value: f32 },
311    /// `-brightness-contrast B[,C]` — brightness in `[-100..=100]`,
312    /// contrast in `[-100..=100]` (both percent-of-range). Either
313    /// argument may be omitted; the IM grammar tolerates `B`,
314    /// `Bx`, `BxC`, `B,C`.
315    BrightnessContrast { brightness: f32, contrast: f32 },
316    /// `-contrast` (no value) — IM applies a tiny per-channel contrast
317    /// step. Multiple `-contrast` flags accumulate; we collapse them
318    /// into a single delta carried on the op (positive for `-contrast`,
319    /// negative for `+contrast`, which IM uses for the inverse). The
320    /// factory wires this to `BrightnessContrast::new(0, 5*delta)` —
321    /// a 5-percent step matching IM's "single contrast bump" feel.
322    Contrast { delta: i32 },
323    /// `-sepia THRESHOLD%` — warm-tint mapping. `threshold` is a
324    /// scalar in `0.0..=1.0` (IM expresses it as a percentage of the
325    /// dynamic range; we accept either form on parse).
326    Sepia { threshold: f32 },
327    /// `-modulate B,S,H` — IM's HSL-style triplet. Each component is
328    /// percent-of-base around 100 (so `100,100,0` is identity, `200`
329    /// doubles, `0` zeros, etc.). The hue field is "hue offset
330    /// percent" but image-filter takes degrees; we translate.
331    Modulate {
332        brightness: f32,
333        saturation: f32,
334        hue: f32,
335    },
336    /// `-level B/G/W` — input black point, gamma, white point. Black
337    /// and white are 0..=255; gamma must be `> 0`.
338    Level { black: u8, gamma: f32, white: u8 },
339    /// `-normalize` — stretch the histogram to fill 0..=255.
340    Normalize,
341    /// `-threshold N%` — binarise: pixels below `value` map to 0,
342    /// pixels at or above map to 255.
343    Threshold { value: u8 },
344    /// `-posterize N` — collapse to `N` levels per channel.
345    Posterize { levels: u32 },
346    /// `-solarize N%` — invert pixels above the threshold.
347    Solarize { value: u8 },
348    /// `-colorspace gray|grey|rgb|srgb` — round-1 covers only the
349    /// grayscale conversion (everything else is treated as a
350    /// pass-through, keeping the input colourspace).
351    Colorspace(String),
352    /// `-vignette R+S+X+Y` — Gaussian radial darkening centred at
353    /// `(x, y)`. `radius`/`sigma` are in pixels (`sigma` defaults to
354    /// `radius / 2.0` when omitted, matching IM); `x`/`y` are
355    /// **normalised** image-relative offsets in `[0.0, 1.0]` (the
356    /// image-filter factory takes them this way to stay
357    /// resolution-independent), defaulting to `0.5 / 0.5` (image
358    /// centre) when omitted.
359    Vignette {
360        radius: f32,
361        sigma: f32,
362        x: f32,
363        y: f32,
364    },
365    /// `-colorize C[xC[xC]]/A%` — linear blend toward a target colour
366    /// by `amount`. `color` is `[R, G, B, A]` (alpha defaults to
367    /// `255`); `amount` is a unit scalar in `[0.0, 1.0]` (IM expresses
368    /// it as a percentage of the dynamic range).
369    Colorize { color: [u8; 4], amount: f32 },
370    /// `-equalize` (no value) — per-channel histogram equalisation via
371    /// CDF mapping.
372    Equalize,
373    /// `-auto-gamma` (no value) — auto-gamma: pick a per-channel gamma
374    /// so the geometric mean lands at `0.5`.
375    AutoGamma,
376    /// `-extent WxH[+X+Y]` — re-window the input onto a fixed `WxH`
377    /// canvas. The source is placed at signed offset `(x, y)` (default
378    /// `(0, 0)`, IM's top-left convention); pixels outside the source
379    /// rectangle are painted with `bg` (RGBA). Offsets may be negative
380    /// (`-extent 100x100-10-10`) to translate the source toward the
381    /// upper-left so its right / bottom edge can land inside the
382    /// window. Unlike `Crop` (only shrinks) and `Resize` (only scales),
383    /// `Extent` can both add canvas and discard content depending on
384    /// the requested geometry.
385    ///
386    /// `bg` is captured at args-parse time from the most recent
387    /// `-background` op seen in source order (defaulting to IM's
388    /// opaque white `[255, 255, 255, 255]` when none was set). Pinning
389    /// the colour onto the op preserves source-order semantics — a
390    /// later `-background` does NOT retroactively repaint an earlier
391    /// `-extent`'s padding.
392    Extent {
393        width: u32,
394        height: u32,
395        x: i32,
396        y: i32,
397        bg: [u8; 4],
398    },
399    /// `-trim` — auto-crop to the bounding box of pixels that differ
400    /// from a reference background colour by more than `fuzz` per
401    /// channel. The reference colour is the input's `(0, 0)` pixel
402    /// (matching IM's behaviour when no `-bordercolor`/`-background`
403    /// override is set). `fuzz` defaults to `0` (exact match); a
404    /// preceding `-fuzz N[%]` flag updates the value carried on this
405    /// op at args-parse time, so source order is preserved (`-fuzz 10
406    /// -trim -fuzz 0 -trim` lands as `Trim { fuzz: 10 }, Trim { fuzz:
407    /// 0 }`).
408    Trim { fuzz: u8 },
409    /// `-roll ±X±Y` — circular pixel shift. `dx` shifts columns
410    /// to the right by `dx` pixels (negative = left); `dy` shifts
411    /// rows down by `dy` pixels (negative = up). Pixels that fall
412    /// off one edge wrap around to the opposite edge — width and
413    /// height stay unchanged, and the total pixel count is
414    /// preserved. The IM grammar requires both X and Y signed
415    /// (`-roll +5+10`, `-roll -3+2`); a single offset (`-roll +5`)
416    /// is rejected at parse time.
417    Roll { dx: i32, dy: i32 },
418}
419
420/// A printf-style multi-output template. Detected by the args parser
421/// when the output filename contains `%[0-9]*d`. Used by Scene-shaped
422/// inputs (PDF) to fan out one file per page.
423///
424/// Examples:
425///   `page-%03d.png` → prefix=`page-`, width=3, suffix=`.png` →
426///   `page-000.png`, `page-001.png`, …
427///   `out%d.jpg`     → prefix=`out`, width=0, suffix=`.jpg` →
428///   `out0.jpg`, `out1.jpg`, …
429#[derive(Clone, Debug, PartialEq, Eq)]
430pub struct PrintfTemplate {
431    pub prefix: String,
432    pub width: u8,
433    pub suffix: String,
434}
435
436impl PrintfTemplate {
437    /// Render the template at the given index.
438    pub fn expand(&self, n: usize) -> String {
439        if self.width == 0 {
440            format!("{}{}{}", self.prefix, n, self.suffix)
441        } else {
442            format!(
443                "{}{:0>width$}{}",
444                self.prefix,
445                n,
446                self.suffix,
447                width = self.width as usize
448            )
449        }
450    }
451}
452
453/// One atomic spec inside a [`PageSelector`].
454///
455/// A single index (`5`, `-1`) or an inclusive range (`2-5`, `5--1`).
456/// Indices are signed so the IM convention `-1` = last page,
457/// `-2` = second-to-last, … can ride through unchanged. Resolution
458/// against an actual page count is `PageAtom::resolve`.
459#[derive(Clone, Debug, PartialEq, Eq)]
460pub enum PageAtom {
461    /// Single page index. Negative values count from the end:
462    /// `-1` = last page, `-2` = second-to-last, ….
463    Single(isize),
464    /// Inclusive range `start..=end`. Either endpoint may be negative
465    /// (`5..=-1` = "page 5 through the last page"). After resolution
466    /// against the actual page count, `start <= end` is required.
467    Range(isize, isize),
468}
469
470impl PageAtom {
471    /// Resolve this atom against a known `total_pages` count, appending
472    /// the resulting zero-based indices to `out` in IM-source order
473    /// (range left-to-right). Negative indices `-k` map to
474    /// `total_pages - k`.
475    fn resolve_into(&self, total_pages: usize, out: &mut Vec<usize>) -> Result<(), String> {
476        match self {
477            PageAtom::Single(n) => {
478                let idx = resolve_signed_index(*n, total_pages)?;
479                out.push(idx);
480                Ok(())
481            }
482            PageAtom::Range(a, b) => {
483                let aa = resolve_signed_index(*a, total_pages)?;
484                let bb = resolve_signed_index(*b, total_pages)?;
485                if aa > bb {
486                    return Err(format!(
487                        "page range [{a}-{b}] resolves to inverted [{aa}-{bb}] (start > end after negative-index resolution)"
488                    ));
489                }
490                out.extend(aa..=bb);
491                Ok(())
492            }
493        }
494    }
495}
496
497fn resolve_signed_index(n: isize, total_pages: usize) -> Result<usize, String> {
498    if n >= 0 {
499        let u = n as usize;
500        if u >= total_pages {
501            Err(format!(
502                "page index {n} out of range (input has {total_pages} page(s))"
503            ))
504        } else {
505            Ok(u)
506        }
507    } else {
508        // Negative — count from the end. `-1` = last page.
509        let off = (-n) as usize;
510        if off > total_pages || off == 0 {
511            Err(format!(
512                "negative page index {n} out of range (input has {total_pages} page(s); valid: -1..=-{total_pages})"
513            ))
514        } else {
515            Ok(total_pages - off)
516        }
517    }
518}
519
520/// ImageMagick-style `[N]` / `[N-M]` / `[A,B,…]` page-selection suffix
521/// on an input path. `input.pdf[0]` selects page 0; `input.pdf[2-5]`
522/// selects pages 2, 3, 4, 5 (inclusive on both ends, like IM);
523/// `input.pdf[-1]` selects the last page; `input.pdf[0,2,4]` selects a
524/// comma-separated list (atoms may themselves be ranges, so
525/// `[0-2,5,-1]` picks pages 0, 1, 2, 5, and the last page).
526///
527/// The single-atom variants ([`Single`](Self::Single) /
528/// [`Range`](Self::Range)) are preserved for ergonomics — most callers
529/// only care about the one-atom case — but every comma-separated
530/// invocation lands as a [`List`](Self::List).
531#[derive(Clone, Debug, PartialEq, Eq)]
532pub enum PageSelector {
533    /// Single page index. Negative values count from the end
534    /// (`-1` = last page).
535    Single(isize),
536    /// Inclusive range `start..=end`. Either endpoint may be negative.
537    Range(isize, isize),
538    /// Comma-separated list of atoms. Order is preserved on resolve;
539    /// duplicates are NOT collapsed (IM convention — `[0,0,0]` writes
540    /// the same page three times). Empty list rejected at parse time.
541    List(Vec<PageAtom>),
542}
543
544impl PageSelector {
545    /// Resolve the selector to a list of zero-based page indices,
546    /// validated against `total_pages`. Negative indices `-k` resolve
547    /// to `total_pages - k`. Order matches the IM source order:
548    /// `[0,2,4]` yields `[0, 2, 4]`; `[-1,0]` yields
549    /// `[total_pages-1, 0]`. Duplicates (e.g. `[0,0]`) are preserved.
550    pub fn resolve(&self, total_pages: usize) -> Result<Vec<usize>, String> {
551        let mut out = Vec::new();
552        match self {
553            PageSelector::Single(n) => {
554                PageAtom::Single(*n).resolve_into(total_pages, &mut out)?;
555            }
556            PageSelector::Range(a, b) => {
557                PageAtom::Range(*a, *b).resolve_into(total_pages, &mut out)?;
558            }
559            PageSelector::List(atoms) => {
560                for atom in atoms {
561                    atom.resolve_into(total_pages, &mut out)?;
562                }
563            }
564        }
565        Ok(out)
566    }
567}
568
569/// `-stl-format` value. Selects which STL-on-disk flavour the encoder
570/// emits. Default is [`Binary`](Self::Binary), matching the registry
571/// factory.
572#[derive(Clone, Copy, Debug, PartialEq, Eq)]
573pub enum StlFormatChoice {
574    /// 80-byte header + `uint32` triangle count + `N × 50`-byte triangle
575    /// records. Compact and binary-stable.
576    Binary,
577    /// `solid … endsolid` ASCII grammar. Larger and floating-point
578    /// formatted but human-readable.
579    Ascii,
580}
581
582impl StlFormatChoice {
583    /// Parse the value after `-stl-format`. Accepts ImageMagick-ish
584    /// tokens case-insensitively (`binary`/`bin` → Binary, `ascii`/`text`
585    /// → Ascii).
586    pub fn parse(s: &str) -> Result<StlFormatChoice, String> {
587        match s.to_ascii_lowercase().as_str() {
588            "binary" | "bin" => Ok(StlFormatChoice::Binary),
589            "ascii" | "text" => Ok(StlFormatChoice::Ascii),
590            other => Err(format!(
591                "convert: -stl-format: unknown flavour '{other}' (expected 'binary' or 'ascii')"
592            )),
593        }
594    }
595}
596
597/// `-gltf-format` value. Selects the glTF on-disk container.
598///
599/// `JsonExternal` is documented but not yet implementable today
600/// (the underlying `OutputFlavour::JsonExternal` is missing in
601/// published `oxideav-gltf` 0.0.0); the args parser accepts the
602/// token now and the encoder will reject it cleanly until the
603/// upstream variant lands. See gltf-rN follow-up.
604#[derive(Clone, Copy, Debug, PartialEq, Eq)]
605pub enum GltfFormatChoice {
606    /// `.glb` self-contained binary container.
607    Glb,
608    /// `.gltf` JSON document with the binary buffer inlined as a
609    /// base64 `data:application/octet-stream;base64,…` URI.
610    JsonEmbedded,
611    /// `.gltf` JSON document referencing a sidecar `.bin` file.
612    /// Reserved — emits an "unsupported" error today (gltf-rN).
613    JsonExternal,
614}
615
616impl GltfFormatChoice {
617    /// Parse the value after `-gltf-format`. Accepts case-insensitive
618    /// `glb`, `embedded`, `external`. `binary` is an alias for `glb`
619    /// (mirrors how IM-style flags often accept short-form synonyms).
620    pub fn parse(s: &str) -> Result<GltfFormatChoice, String> {
621        match s.to_ascii_lowercase().as_str() {
622            "glb" | "binary" => Ok(GltfFormatChoice::Glb),
623            "embedded" | "json" | "json-embedded" => Ok(GltfFormatChoice::JsonEmbedded),
624            "external" | "json-external" => Ok(GltfFormatChoice::JsonExternal),
625            other => Err(format!(
626                "convert: -gltf-format: unknown flavour '{other}' (expected 'glb', 'embedded', or 'external')"
627            )),
628        }
629    }
630}
631
632/// `-render MODE` value. Selects which surface model the 3D→raster
633/// renderer uses when the user pairs a 3D input (`.stl`/`.obj`/
634/// `.gltf`/`.glb`/`.usdz`) with a raster output (`.png`/`.jpg`/
635/// `.bmp`/`.webp`).
636///
637/// Default is [`Flat`](Self::Flat) — one constant colour per primitive
638/// derived from the material's `base_color` (or a fallback grey when
639/// no material is attached). [`Gouraud`](Self::Gouraud) and
640/// [`Phong`](Self::Phong) add per-vertex / per-pixel lighting using
641/// either the primitive's stored vertex normals or face normals
642/// computed from the triangle vertices when none are provided.
643/// [`NormalDebug`](Self::NormalDebug) and [`DepthDebug`](Self::DepthDebug)
644/// are diagnostic visualisations that bypass the lighting equation
645/// entirely — useful when normals look wrong, a Z-fighting glitch
646/// shows up, or the camera framing seems off.
647#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
648pub enum Mesh3DRenderMode {
649    /// Flat-shaded triangles, one colour per primitive (no per-pixel
650    /// lighting). Cheapest mode and the default.
651    #[default]
652    Flat,
653    /// Wireframe — only triangle edges are drawn, interior pixels stay
654    /// at the background. Same colour-per-primitive as [`Flat`](Self::Flat).
655    Wireframe,
656    /// Gouraud — light is evaluated at each vertex using the per-vertex
657    /// normal, then the resulting RGB is bilinearly interpolated across
658    /// the triangle. Requires (or computes) one normal per vertex.
659    Gouraud,
660    /// Phong — the per-vertex normal is interpolated across the
661    /// triangle and the lighting equation is evaluated per pixel.
662    /// Smoother than Gouraud at the cost of one normalise + dot per
663    /// fragment.
664    Phong,
665    /// NormalDebug — paint each pixel with the world-space interpolated
666    /// normal mapped into RGB (`[r,g,b] = (n + 1) / 2 * 255`). Light /
667    /// camera / material settings are ignored. Useful for inspecting
668    /// whether per-vertex normals were loaded correctly or whether the
669    /// face-normal fallback is firing.
670    NormalDebug,
671    /// DepthDebug — paint each pixel a grayscale value derived from the
672    /// interpolated NDC z (near = white, far = black). Light / camera
673    /// orbit / material settings are ignored; useful for spotting
674    /// Z-fighting and for picking sane near/far planes.
675    DepthDebug,
676}
677
678impl Mesh3DRenderMode {
679    /// Parse the value after `-render`. Accepts case-insensitive
680    /// `flat` / `wireframe` / `gouraud` / `phong` / `normal-debug` /
681    /// `depth-debug`. `wire` is a short alias for `wireframe`;
682    /// `shaded` aliases `flat`; `normal` / `normals` alias
683    /// `normal-debug`; `depth` / `z` alias `depth-debug`.
684    pub fn parse(s: &str) -> Result<Mesh3DRenderMode, String> {
685        match s.to_ascii_lowercase().as_str() {
686            "flat" | "shaded" => Ok(Mesh3DRenderMode::Flat),
687            "wireframe" | "wire" => Ok(Mesh3DRenderMode::Wireframe),
688            "gouraud" => Ok(Mesh3DRenderMode::Gouraud),
689            "phong" => Ok(Mesh3DRenderMode::Phong),
690            "normal-debug" | "normaldebug" | "normal" | "normals" => {
691                Ok(Mesh3DRenderMode::NormalDebug)
692            }
693            "depth-debug" | "depthdebug" | "depth" | "z" => Ok(Mesh3DRenderMode::DepthDebug),
694            other => Err(format!(
695                "convert: -render: unknown mode '{other}' (expected 'flat', 'wireframe', 'gouraud', 'phong', 'normal-debug', or 'depth-debug')"
696            )),
697        }
698    }
699}
700
701/// `-projection MODE` value. Selects perspective vs. orthographic
702/// projection for the 3D→raster renderer. Default is
703/// [`Perspective`](Self::Perspective) — the IM convention for vector
704/// rasterisation matches what most 3D viewers ship.
705#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
706pub enum ProjectionMode {
707    /// Perspective projection — objects shrink with distance.
708    #[default]
709    Perspective,
710    /// Orthographic projection — parallel rays, no foreshortening.
711    /// Useful for technical drawings / part diagrams.
712    Orthographic,
713}
714
715impl ProjectionMode {
716    /// Parse the value after `-projection`. Case-insensitive synonyms:
717    /// `perspective` / `persp` / `p`, `orthographic` / `ortho` / `o`.
718    pub fn parse(s: &str) -> Result<ProjectionMode, String> {
719        match s.to_ascii_lowercase().as_str() {
720            "perspective" | "persp" | "p" => Ok(ProjectionMode::Perspective),
721            "orthographic" | "ortho" | "o" => Ok(ProjectionMode::Orthographic),
722            other => Err(format!(
723                "convert: -projection: unknown mode '{other}' (expected 'perspective' or 'orthographic')"
724            )),
725        }
726    }
727}
728
729/// `-light AZIMUTH,ELEVATION,INTENSITY` — directional light override
730/// for the 3D→raster renderer.
731///
732/// `azimuth` and `elevation` are in degrees; `intensity` is a unit
733/// scalar in `[0.0, ~]` multiplied into the diffuse term. The
734/// renderer always also applies a small constant ambient term so
735/// back-faces stay visible.
736#[derive(Clone, Copy, Debug, PartialEq)]
737pub struct LightSpec {
738    pub azimuth_deg: f32,
739    pub elevation_deg: f32,
740    pub intensity: f32,
741}
742
743impl LightSpec {
744    /// Default directional light: from the upper-right-front quadrant
745    /// at unit intensity. Matches the renderer baseline so explicit
746    /// `-light` is never required.
747    pub fn default_light() -> Self {
748        Self {
749            azimuth_deg: 45.0,
750            elevation_deg: 45.0,
751            intensity: 1.0,
752        }
753    }
754
755    /// Parse `AZIMUTH,ELEVATION,INTENSITY`. All three components are
756    /// required; intensity is clamped to `>= 0.0`.
757    pub fn parse(s: &str) -> Result<LightSpec, String> {
758        let parts: Vec<&str> = s.split([',', 'x']).collect();
759        if parts.len() != 3 {
760            return Err(format!(
761                "convert: -light: '{s}' must be 'AZIMUTH,ELEVATION,INTENSITY' (e.g. '45,30,1.0')"
762            ));
763        }
764        let az: f32 = parts[0]
765            .trim()
766            .parse()
767            .map_err(|_| format!("convert: -light: azimuth '{}' is not a number", parts[0]))?;
768        let el: f32 = parts[1]
769            .trim()
770            .parse()
771            .map_err(|_| format!("convert: -light: elevation '{}' is not a number", parts[1]))?;
772        let intensity: f32 = parts[2]
773            .trim()
774            .parse()
775            .map_err(|_| format!("convert: -light: intensity '{}' is not a number", parts[2]))?;
776        if !intensity.is_finite() || intensity < 0.0 {
777            return Err(format!(
778                "convert: -light: intensity {intensity} must be >= 0.0 and finite"
779            ));
780        }
781        Ok(LightSpec {
782            azimuth_deg: az,
783            elevation_deg: el,
784            intensity,
785        })
786    }
787}
788
789/// `-camera ELEVATION,AZIMUTH,DISTANCE` — camera override for the
790/// 3D→raster renderer.
791///
792/// `elevation` and `azimuth` are in degrees; `distance` is a positive
793/// multiplier of the scene bounding-sphere radius (`1.0` ≈ scene
794/// touches the framebuffer edge; the auto-framing default is `~1.2`).
795#[derive(Clone, Copy, Debug, PartialEq)]
796pub struct CameraSpec {
797    pub elevation_deg: f32,
798    pub azimuth_deg: f32,
799    pub distance: f32,
800}
801
802impl CameraSpec {
803    /// Parse `ELEVATION,AZIMUTH,DISTANCE`. All three components are
804    /// required; distance must be `> 0`.
805    pub fn parse(s: &str) -> Result<CameraSpec, String> {
806        let parts: Vec<&str> = s.split([',', 'x']).collect();
807        if parts.len() != 3 {
808            return Err(format!(
809                "convert: -camera: '{s}' must be 'ELEVATION,AZIMUTH,DISTANCE' (e.g. '20,30,1.5')"
810            ));
811        }
812        let el: f32 = parts[0]
813            .trim()
814            .parse()
815            .map_err(|_| format!("convert: -camera: elevation '{}' is not a number", parts[0]))?;
816        let az: f32 = parts[1]
817            .trim()
818            .parse()
819            .map_err(|_| format!("convert: -camera: azimuth '{}' is not a number", parts[1]))?;
820        let dist: f32 = parts[2]
821            .trim()
822            .parse()
823            .map_err(|_| format!("convert: -camera: distance '{}' is not a number", parts[2]))?;
824        if !dist.is_finite() || dist <= 0.0 {
825            return Err(format!(
826                "convert: -camera: distance {dist} must be > 0.0 and finite"
827            ));
828        }
829        Ok(CameraSpec {
830            elevation_deg: el,
831            azimuth_deg: az,
832            distance: dist,
833        })
834    }
835}
836
837/// Per-format encoder option flags forwarded to the 3D side-channel.
838///
839/// All fields default to `None` meaning "use the format crate's
840/// registry default" — i.e. the existing convert behaviour is
841/// unchanged when no `-foo-format` flag is supplied.
842#[derive(Clone, Debug, Default, PartialEq)]
843pub struct Mesh3DOptions {
844    /// `-stl-format ascii|binary`. `None` → registry default (binary).
845    pub stl_format: Option<StlFormatChoice>,
846    /// `-gltf-format glb|embedded|external`. `None` → infer from
847    /// output extension (`.glb` → Glb, `.gltf` → JsonEmbedded), which
848    /// is exactly what the registry's by-extension lookup already does.
849    pub gltf_format: Option<GltfFormatChoice>,
850    /// `-render flat|wireframe|gouraud|phong`. `None` → default
851    /// ([`Mesh3DRenderMode::Flat`]). Only consulted by the 3D→raster
852    /// side-channel; ignored on every other input/output combination.
853    pub render_mode: Option<Mesh3DRenderMode>,
854    /// `-light AZIMUTH,ELEVATION,INTENSITY`. `None` → renderer
855    /// default ([`LightSpec::default_light`]). Used by Gouraud / Phong
856    /// shading; Flat/Wireframe ignore the light entirely.
857    pub light: Option<LightSpec>,
858    /// `-camera ELEVATION,AZIMUTH,DISTANCE`. `None` → bbox-fit
859    /// auto-frame.
860    pub camera: Option<CameraSpec>,
861    /// `-projection orthographic|perspective`. `None` →
862    /// [`ProjectionMode::Perspective`].
863    pub projection: Option<ProjectionMode>,
864    /// `-fov DEGREES` — vertical field of view for perspective
865    /// projection. `None` → 60°. Ignored for orthographic projection.
866    /// Must be in `(0, 180)`.
867    pub fov_deg: Option<f32>,
868    /// `-bg COLOR` — background fill for the render canvas. `None` →
869    /// transparent black `[0, 0, 0, 0]`. Distinct from `-background`
870    /// (which is the canvas-fill colour for `-alpha remove` and the
871    /// PDF runner) so callers can keep the two paint colours separate.
872    pub bg: Option<[u8; 4]>,
873    /// `-aa N` — supersampling-anti-aliasing factor. The renderer
874    /// rasterises at `N × output_w` by `N × output_h` then box-filters
875    /// the framebuffer back down to the requested output. `None` or
876    /// `Some(1)` → no supersampling (every pixel is point-sampled,
877    /// matching the round-44 baseline). Values >= 2 trade memory and
878    /// CPU for cleaner triangle edges; 2× and 4× are typical. Hard
879    /// cap of 8 because at 8× a 1024² render is a 16M-pixel
880    /// framebuffer + an 8M f32 z-buffer (~80 MB) and that's already
881    /// further than most users want to push a software renderer.
882    pub aa: Option<u32>,
883}
884
885/// The parsed result of one `oxideav convert` invocation.
886#[derive(Clone, Debug)]
887pub struct ConvertPlan {
888    /// Input URI WITH any `[N]`/`[N-M]` page selector stripped.
889    /// Currently exactly one — IM's multi-input stack is a documented
890    /// follow-up.
891    pub input: String,
892    /// Page selector parsed from the input arg's `[…]` suffix, when
893    /// present. `None` means "all pages" for Scene-shaped inputs and
894    /// is ignored for raster inputs.
895    pub input_pages: Option<PageSelector>,
896    /// Chain of operations in source order.
897    pub ops: Vec<Op>,
898    /// Output path (literal). Empty string when `ping` is on and the
899    /// caller omitted it.
900    pub output: String,
901    /// Parsed printf template if `output` contains `%[0-9]*d`. Used by
902    /// the convert runner to fan out per-page when the input is a
903    /// Scene-shaped source (PDF) and the output codec doesn't natively
904    /// accept Scenes.
905    pub output_template: Option<PrintfTemplate>,
906    /// `--help` / `-help` mode — print the usage synopsis (generated
907    /// from the parser's own flag table, so it cannot drift) to stdout
908    /// and do nothing else. Outranks every other mode; positional
909    /// arguments become optional and are not validated.
910    pub help: bool,
911    /// `-ping` mode — read only the headers, print one IM-format line
912    /// per "image" (page / video stream) to stdout, skip pixel decode
913    /// and any output write. Output positional becomes optional.
914    pub ping: bool,
915    /// `--probe` mode — decode the input far enough to extract
916    /// structural metadata (page count, mesh count, sample rate, …),
917    /// print a compact summary to stdout, and skip any output write.
918    /// Mutually exclusive with an output positional: `--probe in.gltf`
919    /// is the supported shape; `--probe in.gltf out.obj` errors at
920    /// the parser. The summary defaults to a pretty-printed
921    /// `key: value` block; pair with [`probe_json`](Self::probe_json)
922    /// for a single-line JSON object instead.
923    pub probe: bool,
924    /// `--json` mode — emit machine-readable JSON instead of the
925    /// pretty-printed summary. Today this only applies to `--probe`;
926    /// passing `--json` without `--probe` is a parser-level error
927    /// since there's nothing else for it to format.
928    pub probe_json: bool,
929    /// `--watch` mode (paired with `--probe`) — re-run the probe
930    /// whenever the input file's mtime changes. Each re-probe emits a
931    /// fresh summary; in JSON mode each summary is on its own line so
932    /// `convert --probe --json --watch in.png | jq` is well-formed
933    /// JSON-lines. Polls the input mtime once per second; the loop
934    /// runs forever and exits only on Ctrl+C / signal. Useful for
935    /// live-monitoring a render-in-progress.
936    pub probe_watch: bool,
937    /// Per-format encoder options for the 3D side-channel
938    /// (`-stl-format`, `-gltf-format`, …). `Default` = "use the
939    /// registry's default factory for each format" so callers who
940    /// don't pass any of these flags see no behaviour change.
941    pub mesh3d_options: Mesh3DOptions,
942}