1use crate::op::{
10 AlphaOp, CameraSpec, ConvertPlan, Dither, GltfFormatChoice, LightSpec, Mesh3DOptions,
11 Mesh3DRenderMode, Op, PageAtom, PageSelector, PrintfTemplate, ProjectionMode, ResizeMode,
12 StlFormatChoice,
13};
14use oxideav_core::Error;
15
16const KNOWN_FLAG_NAMES: &[&str] = &[
31 "aa",
32 "alpha",
33 "auto-gamma",
34 "background",
35 "bg",
36 "blur",
37 "brightness-contrast",
38 "camera",
39 "colorize",
40 "colors",
41 "colorspace",
42 "contrast",
43 "crop",
44 "define",
45 "density",
46 "dither",
47 "edge",
48 "equalize",
49 "extent",
50 "flip",
51 "flop",
52 "format",
53 "fov",
54 "fuzz",
55 "gamma",
56 "gltf-format",
57 "json",
58 "level",
59 "light",
60 "modulate",
61 "monochrome",
62 "negate",
63 "normalize",
64 "ping",
65 "posterize",
66 "probe",
67 "projection",
68 "quality",
69 "render",
70 "resize",
71 "roll",
72 "rotate",
73 "sepia",
74 "sharpen",
75 "solarize",
76 "stl-format",
77 "strip",
78 "thumbnail",
79 "threshold",
80 "trim",
81 "unsharp",
82 "vignette",
83 "watch",
84];
85
86pub fn parse(args: &[String]) -> Result<ConvertPlan, Error> {
92 if args.is_empty() {
93 return Err(Error::invalid(
94 "convert: no input file given (usage: convert [-op VALUE]... INPUT [-op VALUE]... OUTPUT)",
95 ));
96 }
97
98 let mut ops: Vec<Op> = Vec::new();
107 let mut positionals: Vec<String> = Vec::new();
108 let mut pending_dither = Dither::None;
109 let mut ping = false;
110 let mut probe = false;
111 let mut probe_json = false;
112 let mut probe_watch = false;
113 let mut mesh3d_options = Mesh3DOptions::default();
114 let mut current_bg: [u8; 4] = [255, 255, 255, 255];
119 let mut current_fuzz: u8 = 0;
127
128 let mut i = 0;
129 while i < args.len() {
130 let flag = &args[i];
131 let val = |k: usize| -> Result<&str, Error> {
132 args.get(k)
133 .map(|s| s.as_str())
134 .ok_or_else(|| Error::invalid(format!("convert: {flag}: missing value")))
135 };
136
137 if !flag.starts_with('-') {
139 positionals.push(flag.clone());
140 i += 1;
141 continue;
142 }
143
144 match flag.as_str() {
145 "-resize" => {
146 let v = val(i + 1)?;
147 let (mode, core) = ResizeMode::split_suffix(v);
148 let (w, h) = parse_resize_geometry(core, mode)
149 .map_err(|e| Error::invalid(format!("convert: -resize: {e}")))?;
150 ops.push(Op::Resize {
151 width: w,
152 height: h,
153 mode,
154 });
155 i += 2;
156 }
157 "-thumbnail" => {
165 let v = val(i + 1)?;
166 let (mode, core) = ResizeMode::split_suffix(v);
167 let (w, h) = parse_resize_geometry(core, mode)
168 .map_err(|e| Error::invalid(format!("convert: -thumbnail: {e}")))?;
169 ops.push(Op::Thumbnail {
170 width: w,
171 height: h,
172 mode,
173 });
174 i += 2;
175 }
176 "-define" => {
183 let v = val(i + 1)?;
184 let (key, value) = match v.split_once('=') {
185 Some((k, val)) => (k.to_string(), Some(val.to_string())),
186 None => (v.to_string(), None),
187 };
188 if key.is_empty() {
189 return Err(Error::invalid(format!(
190 "convert: -define: '{v}' has an empty KEY (expected KEY[=VALUE])"
191 )));
192 }
193 ops.push(Op::Define { key, value });
194 i += 2;
195 }
196 "-blur" => {
197 let v = val(i + 1)?;
198 let (radius, sigma) =
199 parse_blur(v).map_err(|e| Error::invalid(format!("convert: -blur: {e}")))?;
200 ops.push(Op::Blur { radius, sigma });
201 i += 2;
202 }
203 "-edge" => {
204 let v = val(i + 1)?;
205 let r: u32 = v.parse().map_err(|_| {
206 Error::invalid(format!(
207 "convert: -edge: '{v}' is not a non-negative integer"
208 ))
209 })?;
210 ops.push(Op::Edge { radius: r });
211 i += 2;
212 }
213 "-colors" => {
214 let v = val(i + 1)?;
215 let n: u32 = v.parse().map_err(|_| {
216 Error::invalid(format!(
217 "convert: -colors: '{v}' is not a non-negative integer"
218 ))
219 })?;
220 if !(2..=256).contains(&n) {
221 return Err(Error::invalid(format!(
222 "convert: -colors {n} out of range (2..=256)"
223 )));
224 }
225 ops.push(Op::Colors {
226 count: n,
227 dither: pending_dither,
228 });
229 i += 2;
230 }
231 "-dither" => {
232 let v = val(i + 1)?;
233 pending_dither = Dither::parse(v).map_err(Error::invalid)?;
234 i += 2;
235 }
236 "-monochrome" => {
250 ops.push(Op::Colorspace("gray".to_string()));
251 ops.push(Op::Colors {
252 count: 2,
253 dither: Dither::FloydSteinberg,
254 });
255 i += 1;
256 }
257 "-format" => {
258 let v = val(i + 1)?;
259 ops.push(Op::Format(v.to_string()));
260 i += 2;
261 }
262 "-quality" => {
263 let v = val(i + 1)?;
264 let q: u32 = v.parse().map_err(|_| {
265 Error::invalid(format!(
266 "convert: -quality: '{v}' is not a non-negative integer"
267 ))
268 })?;
269 if q > 100 {
281 return Err(Error::invalid(format!(
282 "convert: -quality: {q} out of range (must be in 0..=100; common values: 75 default, 85 web, 95 archival)"
283 )));
284 }
285 ops.push(Op::Quality(q));
286 i += 2;
287 }
288 "-strip" => {
289 ops.push(Op::Strip);
290 i += 1;
291 }
292 "-ping" => {
293 ping = true;
294 i += 1;
295 }
296 "--probe" => {
303 probe = true;
304 i += 1;
305 }
306 "--json" => {
312 probe_json = true;
313 i += 1;
314 }
315 "--watch" => {
322 probe_watch = true;
323 i += 1;
324 }
325 "-density" => {
326 let v = val(i + 1)?;
327 let n: u32 = v.parse().map_err(|_| {
328 Error::invalid(format!(
329 "convert: -density: '{v}' is not a non-negative integer"
330 ))
331 })?;
332 if n == 0 {
333 return Err(Error::invalid("convert: -density must be > 0"));
334 }
335 ops.push(Op::Density(n));
336 i += 2;
337 }
338 "-background" => {
339 let v = val(i + 1)?;
340 let rgba = parse_color(v).map_err(Error::invalid)?;
341 current_bg = rgba;
345 ops.push(Op::Background(rgba));
346 i += 2;
347 }
348 "-alpha" => {
349 let v = val(i + 1)?;
350 let a = AlphaOp::parse(v).map_err(Error::invalid)?;
351 ops.push(Op::Alpha(a));
352 i += 2;
353 }
354 "-rotate" => {
355 let v = val(i + 1)?;
356 let n: i32 = v.parse().map_err(|_| {
357 Error::invalid(format!("convert: -rotate: '{v}' is not an integer"))
358 })?;
359 if !matches!(n, 90 | 180 | 270 | -90 | -180 | -270) {
364 return Err(Error::invalid(format!(
365 "convert: -rotate: only multiples of 90 supported (got {n})"
366 )));
367 }
368 ops.push(Op::Rotate { degrees: n });
369 i += 2;
370 }
371 "-flip" => {
372 ops.push(Op::Flip);
373 i += 1;
374 }
375 "-flop" => {
376 ops.push(Op::Flop);
377 i += 1;
378 }
379 "-negate" => {
380 ops.push(Op::Negate);
381 i += 1;
382 }
383 "-crop" => {
384 let v = val(i + 1)?;
385 let (w, h, x, y) =
386 parse_crop(v).map_err(|e| Error::invalid(format!("convert: -crop: {e}")))?;
387 ops.push(Op::Crop { x, y, w, h });
388 i += 2;
389 }
390 "-extent" => {
391 let v = val(i + 1)?;
398 let (w, h, x, y) = parse_extent(v)
399 .map_err(|e| Error::invalid(format!("convert: -extent: {e}")))?;
400 ops.push(Op::Extent {
401 width: w,
402 height: h,
403 x,
404 y,
405 bg: current_bg,
406 });
407 i += 2;
408 }
409 "-fuzz" => {
415 let v = val(i + 1)?;
416 current_fuzz = parse_threshold_pct(v)
417 .map_err(|e| Error::invalid(format!("convert: -fuzz: {e}")))?;
418 i += 2;
419 }
420 "-trim" => {
426 ops.push(Op::Trim { fuzz: current_fuzz });
427 i += 1;
428 }
429 "-roll" => {
435 let v = val(i + 1)?;
436 let (dx, dy) =
437 parse_roll(v).map_err(|e| Error::invalid(format!("convert: -roll: {e}")))?;
438 ops.push(Op::Roll { dx, dy });
439 i += 2;
440 }
441 "-sharpen" => {
444 let v = val(i + 1)?;
445 let (radius, sigma) =
446 parse_blur(v).map_err(|e| Error::invalid(format!("convert: -sharpen: {e}")))?;
447 ops.push(Op::Sharpen { radius, sigma });
448 i += 2;
449 }
450 "-unsharp" => {
451 let v = val(i + 1)?;
452 let (radius, sigma, amount, threshold) = parse_unsharp(v)
453 .map_err(|e| Error::invalid(format!("convert: -unsharp: {e}")))?;
454 ops.push(Op::Unsharp {
455 radius,
456 sigma,
457 amount,
458 threshold,
459 });
460 i += 2;
461 }
462 "-gamma" => {
463 let v = val(i + 1)?;
464 let g: f32 = v.parse().map_err(|_| {
465 Error::invalid(format!("convert: -gamma: '{v}' is not a finite float"))
466 })?;
467 if !g.is_finite() || g <= 0.0 {
468 return Err(Error::invalid(format!(
469 "convert: -gamma: value must be > 0 (got {g})"
470 )));
471 }
472 ops.push(Op::Gamma { value: g });
473 i += 2;
474 }
475 "-brightness-contrast" => {
476 let v = val(i + 1)?;
477 let (b, c) = parse_brightness_contrast(v)
478 .map_err(|e| Error::invalid(format!("convert: -brightness-contrast: {e}")))?;
479 ops.push(Op::BrightnessContrast {
480 brightness: b,
481 contrast: c,
482 });
483 i += 2;
484 }
485 "-contrast" => {
486 ops.push(Op::Contrast { delta: 1 });
489 i += 1;
490 }
491 "-sepia" => {
492 let v = val(i + 1)?;
493 let t = parse_percent_or_unit(v)
494 .map_err(|e| Error::invalid(format!("convert: -sepia: {e}")))?;
495 ops.push(Op::Sepia { threshold: t });
496 i += 2;
497 }
498 "-modulate" => {
499 let v = val(i + 1)?;
500 let (b, s, h) = parse_modulate(v)
501 .map_err(|e| Error::invalid(format!("convert: -modulate: {e}")))?;
502 ops.push(Op::Modulate {
503 brightness: b,
504 saturation: s,
505 hue: h,
506 });
507 i += 2;
508 }
509 "-level" => {
510 let v = val(i + 1)?;
511 let (b, g, w) =
512 parse_level(v).map_err(|e| Error::invalid(format!("convert: -level: {e}")))?;
513 ops.push(Op::Level {
514 black: b,
515 gamma: g,
516 white: w,
517 });
518 i += 2;
519 }
520 "-normalize" => {
521 ops.push(Op::Normalize);
522 i += 1;
523 }
524 "-threshold" => {
525 let v = val(i + 1)?;
526 let n = parse_threshold_pct(v)
527 .map_err(|e| Error::invalid(format!("convert: -threshold: {e}")))?;
528 ops.push(Op::Threshold { value: n });
529 i += 2;
530 }
531 "-posterize" => {
532 let v = val(i + 1)?;
533 let n: u32 = v.parse().map_err(|_| {
534 Error::invalid(format!(
535 "convert: -posterize: '{v}' is not a non-negative integer"
536 ))
537 })?;
538 if n < 2 {
539 return Err(Error::invalid(format!(
540 "convert: -posterize: levels must be >= 2 (got {n})"
541 )));
542 }
543 ops.push(Op::Posterize { levels: n });
544 i += 2;
545 }
546 "-solarize" => {
547 let v = val(i + 1)?;
548 let n = parse_threshold_pct(v)
549 .map_err(|e| Error::invalid(format!("convert: -solarize: {e}")))?;
550 ops.push(Op::Solarize { value: n });
551 i += 2;
552 }
553 "-vignette" => {
554 let v = val(i + 1)?;
555 let (radius, sigma, x, y) = parse_vignette(v)
556 .map_err(|e| Error::invalid(format!("convert: -vignette: {e}")))?;
557 ops.push(Op::Vignette {
558 radius,
559 sigma,
560 x,
561 y,
562 });
563 i += 2;
564 }
565 "-colorize" => {
566 let v = val(i + 1)?;
567 let (color, amount) = parse_colorize(v)
568 .map_err(|e| Error::invalid(format!("convert: -colorize: {e}")))?;
569 ops.push(Op::Colorize { color, amount });
570 i += 2;
571 }
572 "-equalize" => {
573 ops.push(Op::Equalize);
574 i += 1;
575 }
576 "-auto-gamma" => {
577 ops.push(Op::AutoGamma);
578 i += 1;
579 }
580 "-colorspace" => {
581 let v = val(i + 1)?;
582 let cs = v.trim().to_string();
583 let lower = cs.to_ascii_lowercase();
587 if !matches!(lower.as_str(), "gray" | "grey" | "rgb" | "srgb") {
588 return Err(Error::unsupported(format!(
589 "convert: -colorspace '{cs}' is not yet wired (round-1 supports gray/grey/rgb/srgb)"
590 )));
591 }
592 ops.push(Op::Colorspace(cs));
593 i += 2;
594 }
595 "-stl-format" => {
605 let v = val(i + 1)?;
606 let choice = StlFormatChoice::parse(v).map_err(Error::invalid)?;
607 mesh3d_options.stl_format = Some(choice);
608 i += 2;
609 }
610 "-gltf-format" => {
611 let v = val(i + 1)?;
612 let choice = GltfFormatChoice::parse(v).map_err(Error::invalid)?;
613 mesh3d_options.gltf_format = Some(choice);
614 i += 2;
615 }
616 "-render" => {
622 let v = val(i + 1)?;
623 let mode = Mesh3DRenderMode::parse(v).map_err(Error::invalid)?;
624 mesh3d_options.render_mode = Some(mode);
625 i += 2;
626 }
627 "-light" => {
633 let v = val(i + 1)?;
634 let spec = LightSpec::parse(v).map_err(Error::invalid)?;
635 mesh3d_options.light = Some(spec);
636 i += 2;
637 }
638 "-camera" => {
641 let v = val(i + 1)?;
642 let spec = CameraSpec::parse(v).map_err(Error::invalid)?;
643 mesh3d_options.camera = Some(spec);
644 i += 2;
645 }
646 "-projection" => {
649 let v = val(i + 1)?;
650 let mode = ProjectionMode::parse(v).map_err(Error::invalid)?;
651 mesh3d_options.projection = Some(mode);
652 i += 2;
653 }
654 "-fov" => {
658 let v = val(i + 1)?;
659 let fov: f32 = v
660 .parse()
661 .map_err(|_| Error::invalid(format!("convert: -fov: '{v}' is not a number")))?;
662 if !fov.is_finite() || fov <= 0.0 || fov >= 180.0 {
663 return Err(Error::invalid(format!(
664 "convert: -fov: {fov} must be in (0, 180) degrees"
665 )));
666 }
667 mesh3d_options.fov_deg = Some(fov);
668 i += 2;
669 }
670 "-bg" => {
674 let v = val(i + 1)?;
675 let colour = parse_color(v).map_err(Error::invalid)?;
676 mesh3d_options.bg = Some(colour);
677 i += 2;
678 }
679 "-aa" => {
689 let v = val(i + 1)?;
690 let factor: u32 = v.parse().map_err(|_| {
691 Error::invalid(format!("convert: -aa: '{v}' is not a positive integer"))
692 })?;
693 if !(1..=8).contains(&factor) {
694 return Err(Error::invalid(format!(
695 "convert: -aa: factor {factor} out of range (must be in 1..=8)"
696 )));
697 }
698 mesh3d_options.aa = Some(factor);
699 i += 2;
700 }
701 other => {
702 let (lead, bare) = if let Some(rest) = other.strip_prefix("--") {
715 ("--", rest)
716 } else {
717 ("-", other.strip_prefix('-').unwrap_or(other))
718 };
719 let hint = crate::suggest::closest_match(bare, KNOWN_FLAG_NAMES)
720 .map(|s| format!(" (did you mean '{lead}{s}'?)"))
721 .unwrap_or_default();
722 return Err(Error::invalid(format!(
723 "convert: unknown flag '{other}'{hint}"
724 )));
725 }
726 }
727 }
728
729 if positionals.is_empty() {
730 return Err(Error::invalid(
731 "convert: no input file given (usage: convert [-op VALUE]... INPUT [-op VALUE]... OUTPUT)",
732 ));
733 }
734 if probe && positionals.len() >= 2 {
740 return Err(Error::invalid(format!(
741 "convert: --probe cannot be combined with an output file ('{}'); use either --probe OR an output file, not both",
742 positionals[1]
743 )));
744 }
745 if positionals.len() < 2 && !ping && !probe {
746 return Err(Error::invalid(
747 "convert: no output file given (usage: convert [-op VALUE]... INPUT [-op VALUE]... OUTPUT)",
748 ));
749 }
750 if positionals.len() > 2 {
751 return Err(Error::unsupported(format!(
752 "convert: {} positional arguments given but multi-input is not yet supported (round-2 follow-up); pass exactly INPUT OUTPUT",
753 positionals.len()
754 )));
755 }
756 if probe_json && !probe {
760 return Err(Error::invalid(
761 "convert: --json requires --probe (today --json only formats the probe summary)",
762 ));
763 }
764 if probe_watch && !probe {
768 return Err(Error::invalid(
769 "convert: --watch requires --probe (today --watch only re-runs the probe on input mtime change)",
770 ));
771 }
772
773 let (raw_input, input_pages) = split_input_selector(&positionals[0])?;
774 let input = translate_input_shorthand(raw_input);
775 let output = positionals.get(1).cloned().unwrap_or_default();
776 let output_template = if output.is_empty() {
777 None
778 } else {
779 parse_printf_template(&output)?
780 };
781
782 Ok(ConvertPlan {
783 input,
784 input_pages,
785 ops,
786 output,
787 output_template,
788 ping,
789 probe,
790 probe_json,
791 probe_watch,
792 mesh3d_options,
793 })
794}
795
796pub(crate) fn split_input_selector(s: &str) -> Result<(&str, Option<PageSelector>), Error> {
812 if !s.ends_with(']') {
813 return Ok((s, None));
814 }
815 let open = match s.rfind('[') {
816 Some(i) => i,
817 None => {
818 return Err(Error::invalid(format!(
819 "convert: input '{s}' has a closing `]` with no matching `[`"
820 )));
821 }
822 };
823 let body = &s[open + 1..s.len() - 1];
824 if body.is_empty() {
825 return Err(Error::invalid(format!(
826 "convert: input '{s}' has an empty `[]` page selector"
827 )));
828 }
829 let sel = parse_page_selector_body(s, body)?;
830 Ok((&s[..open], Some(sel)))
831}
832
833fn parse_page_selector_body(input: &str, body: &str) -> Result<PageSelector, Error> {
838 let pieces: Vec<&str> = body.split(',').collect();
841 let mut atoms: Vec<PageAtom> = Vec::with_capacity(pieces.len());
842 for piece in &pieces {
843 let trimmed = piece.trim();
844 if trimmed.is_empty() {
845 return Err(Error::invalid(format!(
846 "convert: input '{input}': page selector '{body}' has an empty atom (between commas)"
847 )));
848 }
849 atoms.push(parse_page_atom(input, trimmed)?);
850 }
851 Ok(if atoms.len() == 1 {
852 match atoms.into_iter().next().unwrap() {
856 PageAtom::Single(n) => PageSelector::Single(n),
857 PageAtom::Range(a, b) => PageSelector::Range(a, b),
858 }
859 } else {
860 PageSelector::List(atoms)
861 })
862}
863
864fn parse_page_atom(input: &str, atom: &str) -> Result<PageAtom, Error> {
874 let (first_sign, rest) = if let Some(rest) = atom.strip_prefix('-') {
876 ("-", rest)
877 } else {
878 ("", atom)
879 };
880 if rest.is_empty() {
881 return Err(Error::invalid(format!(
882 "convert: input '{input}': page selector atom '{atom}' is just a sign with no digits"
883 )));
884 }
885 match rest.find('-') {
888 None => {
889 let n: isize = format!("{first_sign}{rest}").parse().map_err(|_| {
891 Error::invalid(format!(
892 "convert: input '{input}': page selector atom '{atom}' is not an integer page index"
893 ))
894 })?;
895 Ok(PageAtom::Single(n))
896 }
897 Some(sep) => {
898 let left = format!("{first_sign}{}", &rest[..sep]);
900 let right_raw = &rest[sep + 1..];
901 if left.is_empty() || left == "-" {
902 return Err(Error::invalid(format!(
903 "convert: input '{input}': page selector atom '{atom}' has no left endpoint"
904 )));
905 }
906 if right_raw.is_empty() {
907 return Err(Error::invalid(format!(
908 "convert: input '{input}': page selector atom '{atom}' has no right endpoint"
909 )));
910 }
911 let a: isize = left.parse().map_err(|_| {
916 Error::invalid(format!(
917 "convert: input '{input}': page selector atom '{atom}': left endpoint '{left}' is not an integer"
918 ))
919 })?;
920 let b: isize = right_raw.parse().map_err(|_| {
921 Error::invalid(format!(
922 "convert: input '{input}': page selector atom '{atom}': right endpoint '{right_raw}' is not an integer (expected `N-M`, `-N-M`, or `N--M`)"
923 ))
924 })?;
925 Ok(PageAtom::Range(a, b))
926 }
927 }
928}
929
930pub(crate) fn parse_printf_template(s: &str) -> Result<Option<PrintfTemplate>, Error> {
939 let bytes = s.as_bytes();
940 let mut i = 0usize;
941 let mut found: Option<(usize, u8, usize)> = None; while i < bytes.len() {
943 if bytes[i] != b'%' {
944 i += 1;
945 continue;
946 }
947 let start = i;
948 i += 1;
949 let mut width: u8 = 0;
950 while i < bytes.len() && bytes[i].is_ascii_digit() {
951 let d = (bytes[i] - b'0') as u32;
953 let next = (width as u32) * 10 + d;
954 if next > 20 {
955 return Err(Error::invalid(format!(
956 "convert: output template '{s}' has an unreasonable width specifier"
957 )));
958 }
959 width = next as u8;
960 i += 1;
961 }
962 if i >= bytes.len() {
963 return Err(Error::invalid(format!(
964 "convert: output template '{s}' has a `%` with no format specifier"
965 )));
966 }
967 match bytes[i] {
968 b'd' => {
969 if found.is_some() {
970 return Err(Error::invalid(format!(
971 "convert: output template '{s}' has more than one `%d` token (expected exactly one)"
972 )));
973 }
974 found = Some((start, width, i + 1));
975 i += 1;
976 }
977 other => {
978 return Err(Error::invalid(format!(
979 "convert: output template '{s}' uses unsupported format specifier '%{}' (only `%d`, `%03d`, etc. are accepted)",
980 other as char
981 )));
982 }
983 }
984 }
985 Ok(found.map(|(start, width, end)| PrintfTemplate {
986 prefix: s[..start].to_string(),
987 width,
988 suffix: s[end..].to_string(),
989 }))
990}
991
992fn parse_color(s: &str) -> Result<[u8; 4], String> {
997 let s = s.trim();
998 if let Some(hex) = s.strip_prefix('#') {
999 return parse_hex_color(hex);
1000 }
1001 match s.to_ascii_lowercase().as_str() {
1002 "transparent" | "none" => Ok([0, 0, 0, 0]),
1003 "black" => Ok([0, 0, 0, 255]),
1004 "white" => Ok([255, 255, 255, 255]),
1005 "red" => Ok([255, 0, 0, 255]),
1006 "green" => Ok([0, 128, 0, 255]),
1007 "lime" => Ok([0, 255, 0, 255]),
1008 "blue" => Ok([0, 0, 255, 255]),
1009 "yellow" => Ok([255, 255, 0, 255]),
1010 "cyan" | "aqua" => Ok([0, 255, 255, 255]),
1011 "magenta" | "fuchsia" => Ok([255, 0, 255, 255]),
1012 "gray" | "grey" => Ok([128, 128, 128, 255]),
1013 "silver" => Ok([192, 192, 192, 255]),
1014 "maroon" => Ok([128, 0, 0, 255]),
1015 "olive" => Ok([128, 128, 0, 255]),
1016 "purple" => Ok([128, 0, 128, 255]),
1017 "teal" => Ok([0, 128, 128, 255]),
1018 "navy" => Ok([0, 0, 128, 255]),
1019 "orange" => Ok([255, 165, 0, 255]),
1020 other => Err(format!(
1021 "convert: unknown colour '{other}' (try a `#hex` value or one of: black/white/red/green/blue/yellow/cyan/magenta/gray/transparent/…)"
1022 )),
1023 }
1024}
1025
1026fn parse_hex_color(hex: &str) -> Result<[u8; 4], String> {
1027 fn hex_digit(c: u8) -> Result<u8, String> {
1028 match c {
1029 b'0'..=b'9' => Ok(c - b'0'),
1030 b'a'..=b'f' => Ok(c - b'a' + 10),
1031 b'A'..=b'F' => Ok(c - b'A' + 10),
1032 _ => Err(format!("'{}' is not a hex digit", c as char)),
1033 }
1034 }
1035 let h = hex.as_bytes();
1036 let pair = |a: u8, b: u8| -> Result<u8, String> { Ok(hex_digit(a)? * 16 + hex_digit(b)?) };
1037 let dup = |a: u8| -> Result<u8, String> {
1038 let d = hex_digit(a)?;
1039 Ok(d * 16 + d)
1040 };
1041 match h.len() {
1042 3 => Ok([dup(h[0])?, dup(h[1])?, dup(h[2])?, 255]),
1043 4 => Ok([dup(h[0])?, dup(h[1])?, dup(h[2])?, dup(h[3])?]),
1044 6 => Ok([pair(h[0], h[1])?, pair(h[2], h[3])?, pair(h[4], h[5])?, 255]),
1045 8 => Ok([
1046 pair(h[0], h[1])?,
1047 pair(h[2], h[3])?,
1048 pair(h[4], h[5])?,
1049 pair(h[6], h[7])?,
1050 ]),
1051 _ => Err(format!(
1052 "'#{hex}' is not a valid hex colour (expected 3/4/6/8 hex digits)"
1053 )),
1054 }
1055}
1056
1057#[cfg(feature = "generator")]
1063fn translate_input_shorthand(input: &str) -> String {
1064 oxideav_generator::shorthand::translate(input)
1065}
1066
1067#[cfg(not(feature = "generator"))]
1068fn translate_input_shorthand(input: &str) -> String {
1069 input.to_string()
1070}
1071
1072fn parse_resize_geometry(s: &str, mode: ResizeMode) -> Result<(u32, u32), String> {
1087 if matches!(mode, ResizeMode::Percent) {
1088 let s = s.trim();
1090 let (w_str, h_str) = match s.split_once(['x', 'X']) {
1091 None => (s, s), Some((w, h)) => {
1093 let w = if w.is_empty() { h } else { w };
1094 let h = if h.is_empty() { w } else { h };
1095 (w, h)
1096 }
1097 };
1098 let w: u32 = w_str
1099 .parse()
1100 .map_err(|_| format!("'{w_str}' is not a non-negative integer percent"))?;
1101 let h: u32 = h_str
1102 .parse()
1103 .map_err(|_| format!("'{h_str}' is not a non-negative integer percent"))?;
1104 if w == 0 || h == 0 {
1105 return Err("percent values must both be > 0".into());
1106 }
1107 return Ok((w, h));
1108 }
1109 parse_wxh(s)
1110}
1111
1112fn parse_wxh(s: &str) -> Result<(u32, u32), String> {
1115 let (w, h) = s
1116 .split_once(['x', 'X'])
1117 .ok_or_else(|| format!("'{s}' is not in WxH form"))?;
1118 let w: u32 = w
1119 .parse()
1120 .map_err(|_| format!("'{w}' is not a non-negative integer"))?;
1121 let h: u32 = h
1122 .parse()
1123 .map_err(|_| format!("'{h}' is not a non-negative integer"))?;
1124 if w == 0 || h == 0 {
1125 return Err("width and height must both be positive".into());
1126 }
1127 Ok((w, h))
1128}
1129
1130fn parse_crop(s: &str) -> Result<(u32, u32, u32, u32), String> {
1137 for mark in ['%', '!', '^', '<', '>', '@'] {
1140 if s.contains(mark) {
1141 return Err(format!(
1142 "'{s}' uses IM modifier '{mark}' (round-1 supports plain WxH+X+Y only)"
1143 ));
1144 }
1145 }
1146 let mut parts = s.split('+');
1149 let wh = parts
1150 .next()
1151 .ok_or_else(|| format!("'{s}' is missing the WxH component"))?;
1152 let x_str = parts
1153 .next()
1154 .ok_or_else(|| format!("'{s}' is missing the +X offset (expected WxH+X+Y)"))?;
1155 let y_str = parts
1156 .next()
1157 .ok_or_else(|| format!("'{s}' is missing the +Y offset (expected WxH+X+Y)"))?;
1158 if parts.next().is_some() {
1159 return Err(format!("'{s}' has more than two `+` separators"));
1160 }
1161 let (w, h) = parse_wxh(wh)?;
1162 let x: u32 = x_str
1163 .parse()
1164 .map_err(|_| format!("'{x_str}' is not a non-negative integer X offset"))?;
1165 let y: u32 = y_str
1166 .parse()
1167 .map_err(|_| format!("'{y_str}' is not a non-negative integer Y offset"))?;
1168 Ok((w, h, x, y))
1169}
1170
1171fn parse_extent(s: &str) -> Result<(u32, u32, i32, i32), String> {
1185 for mark in ['%', '!', '^', '<', '>', '@'] {
1186 if s.contains(mark) {
1187 return Err(format!(
1188 "'{s}' uses IM modifier '{mark}' (-extent currently supports plain WxH[±X±Y] only)"
1189 ));
1190 }
1191 }
1192 let head_end = s
1199 .char_indices()
1200 .skip(1)
1201 .find(|(_, c)| *c == '+' || *c == '-')
1202 .map(|(i, _)| i)
1203 .unwrap_or(s.len());
1204 let head = &s[..head_end];
1205 let tail = &s[head_end..];
1206 let (w, h) = parse_wxh(head)?;
1207 let (x, y) = parse_extent_offsets(tail)?;
1208 Ok((w, h, x, y))
1209}
1210
1211fn parse_extent_offsets(s: &str) -> Result<(i32, i32), String> {
1216 if s.is_empty() {
1217 return Ok((0, 0));
1218 }
1219 let bytes = s.as_bytes();
1223 if bytes[0] != b'+' && bytes[0] != b'-' {
1224 return Err(format!(
1225 "'{s}' offset section must start with `+` or `-` (expected WxH+X+Y, WxH-X-Y, or mixed)"
1226 ));
1227 }
1228 let mut split = None;
1229 for (i, b) in bytes.iter().enumerate().skip(1) {
1230 if *b == b'+' || *b == b'-' {
1231 split = Some(i);
1232 break;
1233 }
1234 }
1235 let split = split
1236 .ok_or_else(|| format!("'{s}' has a single offset; expected both X and Y (WxH±X±Y)"))?;
1237 let x_str = &s[..split];
1238 let y_str = &s[split..];
1239 if y_str[1..].contains('+') || y_str[1..].contains('-') {
1241 return Err(format!("'{s}' has more than two signed offsets"));
1242 }
1243 let x: i32 = x_str
1244 .parse()
1245 .map_err(|_| format!("'{x_str}' is not a signed integer X offset"))?;
1246 let y: i32 = y_str
1247 .parse()
1248 .map_err(|_| format!("'{y_str}' is not a signed integer Y offset"))?;
1249 Ok((x, y))
1250}
1251
1252fn parse_roll(s: &str) -> Result<(i32, i32), String> {
1260 if s.is_empty() {
1261 return Err("empty offset (expected ±X±Y, e.g. +5+10)".to_string());
1262 }
1263 parse_extent_offsets(s)
1264}
1265
1266fn parse_blur(s: &str) -> Result<(u32, f32), String> {
1270 let (radius_str, sigma_str) = match s.split_once(['x', 'X']) {
1271 Some((r, s)) => (r, Some(s)),
1272 None => (s, None),
1273 };
1274 let radius: u32 = radius_str
1275 .parse()
1276 .map_err(|_| format!("'{radius_str}' is not a non-negative integer"))?;
1277 let sigma: f32 = match sigma_str {
1278 Some(s) => s
1279 .parse()
1280 .map_err(|_| format!("'{s}' is not a non-negative float"))?,
1281 None => (radius as f32) / 2.0,
1282 };
1283 Ok((radius, sigma))
1284}
1285
1286fn parse_unsharp(s: &str) -> Result<(u32, f32, f32, u8), String> {
1292 let mut parts = s.splitn(3, '+');
1295 let head = parts.next().unwrap_or(s);
1296 let amount_str = parts.next();
1297 let threshold_str = parts.next();
1298 let (radius, sigma) = parse_blur(head)?;
1299 let amount: f32 = match amount_str {
1300 Some(a) => a
1301 .parse()
1302 .map_err(|_| format!("'{a}' is not a finite float"))?,
1303 None => 1.0,
1304 };
1305 let threshold: u8 = match threshold_str {
1306 Some(t) => parse_threshold_pct(t)?,
1309 None => 0,
1310 };
1311 Ok((radius, sigma, amount, threshold))
1312}
1313
1314fn parse_brightness_contrast(s: &str) -> Result<(f32, f32), String> {
1318 let (bs, cs) = match s.split_once(['x', 'X', ',']) {
1319 Some((a, b)) => (a, Some(b)),
1320 None => (s, None),
1321 };
1322 let b: f32 = bs
1323 .parse()
1324 .map_err(|_| format!("'{bs}' is not a finite float"))?;
1325 let c: f32 = match cs {
1326 Some(s) if !s.is_empty() => s
1327 .parse()
1328 .map_err(|_| format!("'{s}' is not a finite float"))?,
1329 _ => 0.0,
1330 };
1331 if !(-100.0..=100.0).contains(&b) {
1332 return Err(format!("brightness {b} out of range (-100..=100)"));
1333 }
1334 if !(-100.0..=100.0).contains(&c) {
1335 return Err(format!("contrast {c} out of range (-100..=100)"));
1336 }
1337 Ok((b, c))
1338}
1339
1340fn parse_percent_or_unit(s: &str) -> Result<f32, String> {
1343 let s = s.trim();
1344 if let Some(p) = s.strip_suffix('%') {
1345 let v: f32 = p
1346 .parse()
1347 .map_err(|_| format!("'{p}' is not a finite float"))?;
1348 if !(0.0..=100.0).contains(&v) {
1349 return Err(format!("'{s}' is out of range (0%..=100%)"));
1350 }
1351 return Ok(v / 100.0);
1352 }
1353 let v: f32 = s
1354 .parse()
1355 .map_err(|_| format!("'{s}' is not a finite float"))?;
1356 if !(0.0..=1.0).contains(&v) {
1357 return Err(format!("'{s}' is out of range (0.0..=1.0)"));
1358 }
1359 Ok(v)
1360}
1361
1362fn parse_modulate(s: &str) -> Result<(f32, f32, f32), String> {
1365 let mut parts = s.split(',').map(|p| p.trim());
1366 let b = parts
1367 .next()
1368 .ok_or_else(|| format!("'{s}' is missing the brightness value"))?;
1369 let s_field = parts.next();
1370 let h_field = parts.next();
1371 if parts.next().is_some() {
1372 return Err(format!(
1373 "'{s}' has more than 3 components (expected B[,S[,H]])"
1374 ));
1375 }
1376 let bv: f32 = b
1377 .parse()
1378 .map_err(|_| format!("'{b}' is not a finite float"))?;
1379 let sv: f32 = match s_field {
1380 Some(t) if !t.is_empty() => t
1381 .parse()
1382 .map_err(|_| format!("'{t}' is not a finite float"))?,
1383 _ => 100.0,
1384 };
1385 let hv: f32 = match h_field {
1386 Some(t) if !t.is_empty() => t
1387 .parse()
1388 .map_err(|_| format!("'{t}' is not a finite float"))?,
1389 _ => 100.0,
1390 };
1391 Ok((bv, sv, hv))
1392}
1393
1394fn parse_level(s: &str) -> Result<(u8, f32, u8), String> {
1398 let parts: Vec<&str> = s.split(['/', ',']).map(|p| p.trim()).collect();
1399 if parts.is_empty() || parts.len() > 3 {
1400 return Err(format!("'{s}' is not in B[/G[/W]] form (1..=3 components)"));
1401 }
1402 let black = parse_byte_or_percent(parts[0])?;
1403 let gamma: f32 = if parts.len() >= 2 && !parts[1].is_empty() {
1404 let g: f32 = parts[1]
1405 .parse()
1406 .map_err(|_| format!("'{}' is not a finite float", parts[1]))?;
1407 if !g.is_finite() || g <= 0.0 {
1408 return Err(format!("gamma {g} must be > 0"));
1409 }
1410 g
1411 } else {
1412 1.0
1413 };
1414 let white = if parts.len() == 3 && !parts[2].is_empty() {
1415 parse_byte_or_percent(parts[2])?
1416 } else {
1417 255
1418 };
1419 if black > white {
1420 return Err(format!(
1421 "black point ({black}) must be <= white point ({white})"
1422 ));
1423 }
1424 Ok((black, gamma, white))
1425}
1426
1427fn parse_byte_or_percent(s: &str) -> Result<u8, String> {
1429 if let Some(p) = s.strip_suffix('%') {
1430 let v: f32 = p
1431 .parse()
1432 .map_err(|_| format!("'{p}' is not a finite float"))?;
1433 if !(0.0..=100.0).contains(&v) {
1434 return Err(format!("'{s}' is out of range (0%..=100%)"));
1435 }
1436 return Ok((v * 2.55).round().clamp(0.0, 255.0) as u8);
1437 }
1438 let n: u32 = s
1439 .parse()
1440 .map_err(|_| format!("'{s}' is not a non-negative integer"))?;
1441 if n > 255 {
1442 return Err(format!("'{s}' is out of range (0..=255)"));
1443 }
1444 Ok(n as u8)
1445}
1446
1447fn parse_threshold_pct(s: &str) -> Result<u8, String> {
1450 parse_byte_or_percent(s.trim())
1451}
1452
1453fn parse_vignette(s: &str) -> Result<(f32, f32, f32, f32), String> {
1465 let mut parts = s.split('+');
1466 let r_str = parts
1467 .next()
1468 .ok_or_else(|| format!("'{s}' is missing the radius component"))?;
1469 let radius: f32 = r_str
1470 .parse()
1471 .map_err(|_| format!("'{r_str}' is not a finite float radius"))?;
1472 if !radius.is_finite() || radius < 0.0 {
1473 return Err(format!("radius {radius} must be >= 0"));
1474 }
1475 let s_part = parts.next();
1476 let sigma: f32 = match s_part {
1477 Some(t) if !t.is_empty() => t
1478 .parse()
1479 .map_err(|_| format!("'{t}' is not a finite float sigma"))?,
1480 _ => radius / 2.0,
1481 };
1482 if !sigma.is_finite() || sigma < 0.0 {
1483 return Err(format!("sigma {sigma} must be >= 0"));
1484 }
1485 let x: f32 = match parts.next() {
1486 Some(t) if !t.is_empty() => t
1487 .parse()
1488 .map_err(|_| format!("'{t}' is not a finite float x"))?,
1489 _ => 0.5,
1490 };
1491 let y: f32 = match parts.next() {
1492 Some(t) if !t.is_empty() => t
1493 .parse()
1494 .map_err(|_| format!("'{t}' is not a finite float y"))?,
1495 _ => 0.5,
1496 };
1497 if parts.next().is_some() {
1498 return Err(format!("'{s}' has more than 4 components"));
1499 }
1500 if !x.is_finite() || !y.is_finite() {
1501 return Err(format!("x/y must be finite (got x={x}, y={y})"));
1502 }
1503 Ok((radius, sigma, x, y))
1504}
1505
1506fn parse_colorize(s: &str) -> Result<([u8; 4], f32), String> {
1515 let s = s.trim();
1516 let (color_part, amount_part) = match s.split_once('/') {
1517 Some((c, a)) => (c, a),
1518 None => ("", s),
1521 };
1522 let color = if color_part.is_empty() {
1523 [255, 255, 255, 255]
1524 } else if color_part.contains('x') || color_part.contains('X') {
1525 let mut parts = color_part.split(['x', 'X']);
1527 let r_str = parts
1528 .next()
1529 .ok_or_else(|| format!("'{color_part}' is missing the R component"))?;
1530 let r: u32 = r_str
1531 .parse()
1532 .map_err(|_| format!("'{r_str}' is not a non-negative integer"))?;
1533 if r > 255 {
1534 return Err(format!("R component {r} out of range (0..=255)"));
1535 }
1536 let g: u32 = match parts.next() {
1537 Some(t) if !t.is_empty() => t
1538 .parse()
1539 .map_err(|_| format!("'{t}' is not a non-negative integer"))?,
1540 _ => r,
1541 };
1542 if g > 255 {
1543 return Err(format!("G component {g} out of range (0..=255)"));
1544 }
1545 let b: u32 = match parts.next() {
1546 Some(t) if !t.is_empty() => t
1547 .parse()
1548 .map_err(|_| format!("'{t}' is not a non-negative integer"))?,
1549 _ => g,
1550 };
1551 if b > 255 {
1552 return Err(format!("B component {b} out of range (0..=255)"));
1553 }
1554 if parts.next().is_some() {
1555 return Err(format!(
1556 "'{color_part}' has more than 3 components (expected R[xG[xB]])"
1557 ));
1558 }
1559 [r as u8, g as u8, b as u8, 255]
1560 } else {
1561 parse_color(color_part)?
1563 };
1564 let amount = parse_percent_or_unit(amount_part)?;
1565 Ok((color, amount))
1566}
1567
1568#[cfg(test)]
1569mod tests {
1570 use super::*;
1571
1572 fn to_vec(s: &[&str]) -> Vec<String> {
1573 s.iter().map(|x| x.to_string()).collect()
1574 }
1575
1576 #[test]
1577 fn minimal_input_output() {
1578 let p = parse(&to_vec(&["in.png", "out.jpg"])).unwrap();
1579 assert_eq!(p.input, "in.png");
1580 assert_eq!(p.output, "out.jpg");
1581 assert!(p.ops.is_empty());
1582 }
1583
1584 #[test]
1585 fn resize_bilinear_basic() {
1586 let p = parse(&to_vec(&["a.png", "-resize", "800x600", "b.jpg"])).unwrap();
1587 assert_eq!(
1588 p.ops,
1589 vec![Op::Resize {
1590 width: 800,
1591 height: 600,
1592 mode: ResizeMode::Default,
1593 }]
1594 );
1595 }
1596
1597 #[test]
1598 fn resize_bang_flag() {
1599 let p = parse(&to_vec(&["a.png", "-resize", "64x32!", "b.jpg"])).unwrap();
1600 assert_eq!(
1601 p.ops,
1602 vec![Op::Resize {
1603 width: 64,
1604 height: 32,
1605 mode: ResizeMode::Force,
1606 }]
1607 );
1608 }
1609
1610 #[test]
1611 fn resize_fill_caret_suffix() {
1612 let p = parse(&to_vec(&["a.png", "-resize", "200x100^", "b.jpg"])).unwrap();
1613 assert_eq!(
1614 p.ops,
1615 vec![Op::Resize {
1616 width: 200,
1617 height: 100,
1618 mode: ResizeMode::Fill,
1619 }]
1620 );
1621 }
1622
1623 #[test]
1624 fn resize_shrink_only_suffix() {
1625 let p = parse(&to_vec(&["a.png", "-resize", "100x100>", "b.jpg"])).unwrap();
1626 assert_eq!(
1627 p.ops,
1628 vec![Op::Resize {
1629 width: 100,
1630 height: 100,
1631 mode: ResizeMode::Shrink,
1632 }]
1633 );
1634 }
1635
1636 #[test]
1637 fn resize_grow_only_suffix() {
1638 let p = parse(&to_vec(&["a.png", "-resize", "1024x1024<", "b.jpg"])).unwrap();
1639 assert_eq!(
1640 p.ops,
1641 vec![Op::Resize {
1642 width: 1024,
1643 height: 1024,
1644 mode: ResizeMode::Grow,
1645 }]
1646 );
1647 }
1648
1649 #[test]
1650 fn resize_percent_two_axis() {
1651 let p = parse(&to_vec(&["a.png", "-resize", "50x200%", "b.jpg"])).unwrap();
1652 assert_eq!(
1653 p.ops,
1654 vec![Op::Resize {
1655 width: 50,
1656 height: 200,
1657 mode: ResizeMode::Percent,
1658 }]
1659 );
1660 }
1661
1662 #[test]
1663 fn resize_percent_single_value_replicates() {
1664 let p = parse(&to_vec(&["a.png", "-resize", "75%", "b.jpg"])).unwrap();
1666 assert_eq!(
1667 p.ops,
1668 vec![Op::Resize {
1669 width: 75,
1670 height: 75,
1671 mode: ResizeMode::Percent,
1672 }]
1673 );
1674 }
1675
1676 #[test]
1677 fn resize_area_at_suffix() {
1678 let p = parse(&to_vec(&["a.png", "-resize", "640x480@", "b.jpg"])).unwrap();
1679 assert_eq!(
1680 p.ops,
1681 vec![Op::Resize {
1682 width: 640,
1683 height: 480,
1684 mode: ResizeMode::Area,
1685 }]
1686 );
1687 }
1688
1689 #[test]
1690 fn thumbnail_with_geometry_modifier() {
1691 let p = parse(&to_vec(&["a.png", "-thumbnail", "128x128^", "b.jpg"])).unwrap();
1692 assert_eq!(
1693 p.ops,
1694 vec![Op::Thumbnail {
1695 width: 128,
1696 height: 128,
1697 mode: ResizeMode::Fill,
1698 }]
1699 );
1700 }
1701
1702 #[test]
1703 fn thumbnail_default_mode() {
1704 let p = parse(&to_vec(&["a.png", "-thumbnail", "100x100", "b.jpg"])).unwrap();
1705 assert_eq!(
1706 p.ops,
1707 vec![Op::Thumbnail {
1708 width: 100,
1709 height: 100,
1710 mode: ResizeMode::Default,
1711 }]
1712 );
1713 }
1714
1715 #[test]
1716 fn define_with_value_round_trips_namespaced_key() {
1717 let p = parse(&to_vec(&[
1718 "a.png",
1719 "-define",
1720 "jpeg:dct-method=float",
1721 "b.jpg",
1722 ]))
1723 .unwrap();
1724 assert_eq!(
1725 p.ops,
1726 vec![Op::Define {
1727 key: "jpeg:dct-method".into(),
1728 value: Some("float".into()),
1729 }]
1730 );
1731 }
1732
1733 #[test]
1734 fn define_bare_key_no_value() {
1735 let p = parse(&to_vec(&[
1737 "a.png",
1738 "-define",
1739 "png:strip-comments",
1740 "b.png",
1741 ]))
1742 .unwrap();
1743 assert_eq!(
1744 p.ops,
1745 vec![Op::Define {
1746 key: "png:strip-comments".into(),
1747 value: None,
1748 }]
1749 );
1750 }
1751
1752 #[test]
1753 fn define_empty_key_rejected() {
1754 let err = parse(&to_vec(&["a.png", "-define", "=value", "b.png"])).unwrap_err();
1755 assert!(
1756 format!("{err:?}").contains("empty KEY"),
1757 "unexpected error: {err:?}"
1758 );
1759 }
1760
1761 #[test]
1762 fn blur_sigma_defaults_to_half_radius() {
1763 let p = parse(&to_vec(&["a.png", "-blur", "4", "b.jpg"])).unwrap();
1764 assert_eq!(
1765 p.ops,
1766 vec![Op::Blur {
1767 radius: 4,
1768 sigma: 2.0
1769 }]
1770 );
1771 }
1772
1773 #[test]
1774 fn blur_explicit_sigma() {
1775 let p = parse(&to_vec(&["a.png", "-blur", "3x1.5", "b.jpg"])).unwrap();
1776 match p.ops.as_slice() {
1777 [Op::Blur { radius: 3, sigma }] => assert!((sigma - 1.5).abs() < 1e-6),
1778 other => panic!("unexpected ops: {other:?}"),
1779 }
1780 }
1781
1782 #[test]
1783 fn colors_picks_up_prior_dither() {
1784 let p = parse(&to_vec(&[
1785 "a.png",
1786 "-dither",
1787 "floyd_steinberg",
1788 "-colors",
1789 "64",
1790 "b.gif",
1791 ]))
1792 .unwrap();
1793 assert_eq!(
1794 p.ops,
1795 vec![Op::Colors {
1796 count: 64,
1797 dither: Dither::FloydSteinberg
1798 }]
1799 );
1800 }
1801
1802 #[test]
1812 fn monochrome_expands_to_gray_2_colors_floyd_steinberg() {
1813 let p = parse(&to_vec(&["a.png", "-monochrome", "b.png"])).unwrap();
1814 assert_eq!(
1815 p.ops,
1816 vec![
1817 Op::Colorspace("gray".to_string()),
1818 Op::Colors {
1819 count: 2,
1820 dither: Dither::FloydSteinberg
1821 },
1822 ]
1823 );
1824 }
1825
1826 #[test]
1830 fn monochrome_is_valueless() {
1831 let p = parse(&to_vec(&["a.png", "-monochrome", "b.png"])).unwrap();
1832 assert_eq!(p.input, "a.png");
1833 assert_eq!(p.output, "b.png");
1834 }
1835
1836 #[test]
1842 fn monochrome_ignores_prior_pending_dither() {
1843 let p = parse(&to_vec(&[
1844 "a.png",
1845 "-dither",
1846 "none",
1847 "-monochrome",
1848 "b.png",
1849 ]))
1850 .unwrap();
1851 assert_eq!(
1852 p.ops,
1853 vec![
1854 Op::Colorspace("gray".to_string()),
1855 Op::Colors {
1856 count: 2,
1857 dither: Dither::FloydSteinberg
1858 },
1859 ]
1860 );
1861 }
1862
1863 #[test]
1869 fn monochrome_keeps_source_order_with_neighbours() {
1870 let p = parse(&to_vec(&[
1871 "a.png",
1872 "-resize",
1873 "100x100",
1874 "-monochrome",
1875 "-strip",
1876 "b.png",
1877 ]))
1878 .unwrap();
1879 assert_eq!(
1880 p.ops,
1881 vec![
1882 Op::Resize {
1883 width: 100,
1884 height: 100,
1885 mode: ResizeMode::Default,
1886 },
1887 Op::Colorspace("gray".to_string()),
1888 Op::Colors {
1889 count: 2,
1890 dither: Dither::FloydSteinberg
1891 },
1892 Op::Strip,
1893 ]
1894 );
1895 }
1896
1897 #[test]
1898 fn rotate_90_parses() {
1899 let p = parse(&to_vec(&["a.png", "-rotate", "90", "b.png"])).unwrap();
1900 assert_eq!(p.ops, vec![Op::Rotate { degrees: 90 }]);
1901 }
1902
1903 #[test]
1904 fn rotate_negative_270_parses() {
1905 let p = parse(&to_vec(&["a.png", "-rotate", "-270", "b.png"])).unwrap();
1906 assert_eq!(p.ops, vec![Op::Rotate { degrees: -270 }]);
1907 }
1908
1909 #[test]
1910 fn rotate_45_rejected() {
1911 let err = parse(&to_vec(&["a.png", "-rotate", "45", "b.png"])).unwrap_err();
1912 let msg = format!("{err:?}");
1913 assert!(
1914 msg.contains("only multiples of 90 supported"),
1915 "unexpected message: {msg}"
1916 );
1917 assert!(msg.contains("got 45"));
1918 }
1919
1920 #[test]
1921 fn rotate_non_integer_rejected() {
1922 let err = parse(&to_vec(&["a.png", "-rotate", "abc", "b.png"])).unwrap_err();
1923 assert!(format!("{err:?}").contains("not an integer"));
1924 }
1925
1926 #[test]
1927 fn flip_and_flop_are_valueless() {
1928 let p = parse(&to_vec(&["a.png", "-flip", "-flop", "b.png"])).unwrap();
1929 assert_eq!(p.ops, vec![Op::Flip, Op::Flop]);
1930 }
1931
1932 #[test]
1933 fn negate_is_valueless() {
1934 let p = parse(&to_vec(&["a.png", "-negate", "b.png"])).unwrap();
1935 assert_eq!(p.ops, vec![Op::Negate]);
1936 }
1937
1938 #[test]
1939 fn crop_basic() {
1940 let p = parse(&to_vec(&["a.png", "-crop", "10x20+3+4", "b.png"])).unwrap();
1941 assert_eq!(
1942 p.ops,
1943 vec![Op::Crop {
1944 x: 3,
1945 y: 4,
1946 w: 10,
1947 h: 20
1948 }]
1949 );
1950 }
1951
1952 #[test]
1953 fn crop_zero_offset() {
1954 let p = parse(&to_vec(&["a.png", "-crop", "32x32+0+0", "b.png"])).unwrap();
1955 assert_eq!(
1956 p.ops,
1957 vec![Op::Crop {
1958 x: 0,
1959 y: 0,
1960 w: 32,
1961 h: 32
1962 }]
1963 );
1964 }
1965
1966 #[test]
1967 fn crop_missing_offset_rejected() {
1968 let err = parse(&to_vec(&["a.png", "-crop", "32x32", "b.png"])).unwrap_err();
1969 assert!(format!("{err:?}").contains("missing the +X offset"));
1970 }
1971
1972 #[test]
1973 fn crop_im_modifier_rejected() {
1974 let err = parse(&to_vec(&["a.png", "-crop", "50%+0+0", "b.png"])).unwrap_err();
1975 assert!(format!("{err:?}").contains("IM modifier"));
1976 }
1977
1978 #[test]
1984 fn extent_default_offset_and_background() {
1985 let p = parse(&to_vec(&["a.png", "-extent", "100x80", "b.png"])).unwrap();
1986 assert_eq!(
1987 p.ops,
1988 vec![Op::Extent {
1989 width: 100,
1990 height: 80,
1991 x: 0,
1992 y: 0,
1993 bg: [255, 255, 255, 255],
1994 }]
1995 );
1996 }
1997
1998 #[test]
2000 fn extent_positive_offsets() {
2001 let p = parse(&to_vec(&["a.png", "-extent", "120x90+10+5", "b.png"])).unwrap();
2002 match p.ops.as_slice() {
2003 [Op::Extent {
2004 width: 120,
2005 height: 90,
2006 x: 10,
2007 y: 5,
2008 bg: _,
2009 }] => {}
2010 other => panic!("unexpected ops: {other:?}"),
2011 }
2012 }
2013
2014 #[test]
2017 fn extent_negative_offsets() {
2018 let p = parse(&to_vec(&["a.png", "-extent", "200x150-25-30", "b.png"])).unwrap();
2019 match p.ops.as_slice() {
2020 [Op::Extent {
2021 width: 200,
2022 height: 150,
2023 x: -25,
2024 y: -30,
2025 bg: _,
2026 }] => {}
2027 other => panic!("unexpected ops: {other:?}"),
2028 }
2029 }
2030
2031 #[test]
2034 fn extent_mixed_signs() {
2035 let p = parse(&to_vec(&["a.png", "-extent", "64x64+10-5", "b.png"])).unwrap();
2036 match p.ops.as_slice() {
2037 [Op::Extent {
2038 width: 64,
2039 height: 64,
2040 x: 10,
2041 y: -5,
2042 bg: _,
2043 }] => {}
2044 other => panic!("unexpected ops: {other:?}"),
2045 }
2046 }
2047
2048 #[test]
2051 fn extent_picks_up_prior_background() {
2052 let p = parse(&to_vec(&[
2053 "a.png",
2054 "-background",
2055 "red",
2056 "-extent",
2057 "50x50",
2058 "b.png",
2059 ]))
2060 .unwrap();
2061 assert_eq!(p.ops.len(), 2);
2064 assert!(matches!(p.ops[0], Op::Background([255, 0, 0, 255])));
2065 match &p.ops[1] {
2066 Op::Extent { bg, .. } => assert_eq!(*bg, [255, 0, 0, 255]),
2067 other => panic!("expected Extent, got {other:?}"),
2068 }
2069 }
2070
2071 #[test]
2074 fn extent_ignores_subsequent_background() {
2075 let p = parse(&to_vec(&[
2076 "a.png",
2077 "-extent",
2078 "50x50",
2079 "-background",
2080 "red",
2081 "b.png",
2082 ]))
2083 .unwrap();
2084 match &p.ops[0] {
2086 Op::Extent { bg, .. } => assert_eq!(*bg, [255, 255, 255, 255]),
2087 other => panic!("expected Extent, got {other:?}"),
2088 }
2089 }
2090
2091 #[test]
2094 fn extent_two_calls_capture_independent_backgrounds() {
2095 let p = parse(&to_vec(&[
2096 "a.png",
2097 "-background",
2098 "red",
2099 "-extent",
2100 "50x50",
2101 "-background",
2102 "blue",
2103 "-extent",
2104 "60x60",
2105 "b.png",
2106 ]))
2107 .unwrap();
2108 let extents: Vec<&Op> = p
2109 .ops
2110 .iter()
2111 .filter(|o| matches!(o, Op::Extent { .. }))
2112 .collect();
2113 assert_eq!(extents.len(), 2);
2114 match extents[0] {
2115 Op::Extent { width: 50, bg, .. } => assert_eq!(*bg, [255, 0, 0, 255]),
2116 other => panic!("unexpected first extent: {other:?}"),
2117 }
2118 match extents[1] {
2119 Op::Extent { width: 60, bg, .. } => assert_eq!(*bg, [0, 0, 255, 255]),
2120 other => panic!("unexpected second extent: {other:?}"),
2121 }
2122 }
2123
2124 #[test]
2127 fn extent_single_offset_rejected() {
2128 let err = parse(&to_vec(&["a.png", "-extent", "64x64+10", "b.png"])).unwrap_err();
2129 assert!(
2130 format!("{err:?}").contains("single offset"),
2131 "unexpected error: {err:?}"
2132 );
2133 }
2134
2135 #[test]
2138 fn extent_im_modifier_rejected() {
2139 let err = parse(&to_vec(&["a.png", "-extent", "50%+0+0", "b.png"])).unwrap_err();
2140 assert!(format!("{err:?}").contains("IM modifier"));
2141 }
2142
2143 #[test]
2144 fn sharpen_basic() {
2145 let p = parse(&to_vec(&["a.png", "-sharpen", "1x0.5", "b.png"])).unwrap();
2146 match p.ops.as_slice() {
2147 [Op::Sharpen { radius: 1, sigma }] => assert!((sigma - 0.5).abs() < 1e-6),
2148 other => panic!("unexpected ops: {other:?}"),
2149 }
2150 }
2151
2152 #[test]
2153 fn sharpen_radius_only_defaults_sigma() {
2154 let p = parse(&to_vec(&["a.png", "-sharpen", "4", "b.png"])).unwrap();
2155 assert_eq!(
2157 p.ops,
2158 vec![Op::Sharpen {
2159 radius: 4,
2160 sigma: 2.0
2161 }]
2162 );
2163 }
2164
2165 #[test]
2166 fn unsharp_full_grammar() {
2167 let p = parse(&to_vec(&["a.png", "-unsharp", "2x1.5+0.8+5", "b.png"])).unwrap();
2168 match p.ops.as_slice() {
2169 [Op::Unsharp {
2170 radius: 2,
2171 sigma,
2172 amount,
2173 threshold: 5,
2174 }] => {
2175 assert!((sigma - 1.5).abs() < 1e-6);
2176 assert!((amount - 0.8).abs() < 1e-6);
2177 }
2178 other => panic!("unexpected ops: {other:?}"),
2179 }
2180 }
2181
2182 #[test]
2183 fn unsharp_partial_defaults() {
2184 let p = parse(&to_vec(&["a.png", "-unsharp", "3", "b.png"])).unwrap();
2185 match p.ops.as_slice() {
2186 [Op::Unsharp {
2187 radius: 3,
2188 sigma,
2189 amount,
2190 threshold: 0,
2191 }] => {
2192 assert!((sigma - 1.5).abs() < 1e-6, "sigma = {sigma}");
2193 assert!((amount - 1.0).abs() < 1e-6, "amount = {amount}");
2194 }
2195 other => panic!("unexpected ops: {other:?}"),
2196 }
2197 }
2198
2199 #[test]
2200 fn gamma_basic() {
2201 let p = parse(&to_vec(&["a.png", "-gamma", "1.8", "b.png"])).unwrap();
2202 match p.ops.as_slice() {
2203 [Op::Gamma { value }] => assert!((value - 1.8).abs() < 1e-6),
2204 other => panic!("unexpected ops: {other:?}"),
2205 }
2206 }
2207
2208 #[test]
2209 fn gamma_zero_rejected() {
2210 let err = parse(&to_vec(&["a.png", "-gamma", "0", "b.png"])).unwrap_err();
2211 assert!(format!("{err:?}").contains("must be > 0"));
2212 }
2213
2214 #[test]
2215 fn gamma_negative_rejected() {
2216 let err = parse(&to_vec(&["a.png", "-gamma", "-1.5", "b.png"])).unwrap_err();
2217 assert!(format!("{err:?}").contains("must be > 0"));
2218 }
2219
2220 #[test]
2221 fn brightness_contrast_basic_comma() {
2222 let p = parse(&to_vec(&["a.png", "-brightness-contrast", "10,5", "b.png"])).unwrap();
2223 assert_eq!(
2224 p.ops,
2225 vec![Op::BrightnessContrast {
2226 brightness: 10.0,
2227 contrast: 5.0
2228 }]
2229 );
2230 }
2231
2232 #[test]
2233 fn brightness_contrast_basic_x_separator() {
2234 let p = parse(&to_vec(&[
2235 "a.png",
2236 "-brightness-contrast",
2237 "20x-15",
2238 "b.png",
2239 ]))
2240 .unwrap();
2241 assert_eq!(
2242 p.ops,
2243 vec![Op::BrightnessContrast {
2244 brightness: 20.0,
2245 contrast: -15.0
2246 }]
2247 );
2248 }
2249
2250 #[test]
2251 fn brightness_contrast_brightness_only() {
2252 let p = parse(&to_vec(&["a.png", "-brightness-contrast", "30", "b.png"])).unwrap();
2253 assert_eq!(
2254 p.ops,
2255 vec![Op::BrightnessContrast {
2256 brightness: 30.0,
2257 contrast: 0.0
2258 }]
2259 );
2260 }
2261
2262 #[test]
2263 fn brightness_contrast_out_of_range_rejected() {
2264 let err = parse(&to_vec(&[
2265 "a.png",
2266 "-brightness-contrast",
2267 "150,0",
2268 "b.png",
2269 ]))
2270 .unwrap_err();
2271 assert!(format!("{err:?}").contains("out of range"));
2272 }
2273
2274 #[test]
2275 fn contrast_step_default_one() {
2276 let p = parse(&to_vec(&["a.png", "-contrast", "b.png"])).unwrap();
2277 assert_eq!(p.ops, vec![Op::Contrast { delta: 1 }]);
2278 }
2279
2280 #[test]
2281 fn contrast_repeated_accumulates_via_chain() {
2282 let p = parse(&to_vec(&["a.png", "-contrast", "-contrast", "b.png"])).unwrap();
2285 assert_eq!(
2286 p.ops,
2287 vec![Op::Contrast { delta: 1 }, Op::Contrast { delta: 1 }]
2288 );
2289 }
2290
2291 #[test]
2292 fn sepia_percent_form() {
2293 let p = parse(&to_vec(&["a.png", "-sepia", "80%", "b.png"])).unwrap();
2294 match p.ops.as_slice() {
2295 [Op::Sepia { threshold }] => assert!((threshold - 0.8).abs() < 1e-6),
2296 other => panic!("unexpected ops: {other:?}"),
2297 }
2298 }
2299
2300 #[test]
2301 fn sepia_unit_form() {
2302 let p = parse(&to_vec(&["a.png", "-sepia", "0.5", "b.png"])).unwrap();
2303 match p.ops.as_slice() {
2304 [Op::Sepia { threshold }] => assert!((threshold - 0.5).abs() < 1e-6),
2305 other => panic!("unexpected ops: {other:?}"),
2306 }
2307 }
2308
2309 #[test]
2310 fn modulate_full_triplet() {
2311 let p = parse(&to_vec(&["a.png", "-modulate", "120,80,150", "b.png"])).unwrap();
2312 assert_eq!(
2313 p.ops,
2314 vec![Op::Modulate {
2315 brightness: 120.0,
2316 saturation: 80.0,
2317 hue: 150.0,
2318 }]
2319 );
2320 }
2321
2322 #[test]
2323 fn modulate_brightness_only_defaults_other_components() {
2324 let p = parse(&to_vec(&["a.png", "-modulate", "150", "b.png"])).unwrap();
2325 assert_eq!(
2326 p.ops,
2327 vec![Op::Modulate {
2328 brightness: 150.0,
2329 saturation: 100.0,
2330 hue: 100.0,
2331 }]
2332 );
2333 }
2334
2335 #[test]
2336 fn level_full_triplet() {
2337 let p = parse(&to_vec(&["a.png", "-level", "10/1.2/250", "b.png"])).unwrap();
2338 match p.ops.as_slice() {
2339 [Op::Level {
2340 black: 10,
2341 gamma,
2342 white: 250,
2343 }] => assert!((gamma - 1.2).abs() < 1e-6),
2344 other => panic!("unexpected ops: {other:?}"),
2345 }
2346 }
2347
2348 #[test]
2349 fn level_percent_endpoints() {
2350 let p = parse(&to_vec(&["a.png", "-level", "10%/1.0/90%", "b.png"])).unwrap();
2351 match p.ops.as_slice() {
2352 [Op::Level {
2353 black,
2354 gamma: _,
2355 white,
2356 }] => {
2357 assert_eq!(*black, 26);
2359 assert_eq!(*white, 230);
2360 }
2361 other => panic!("unexpected ops: {other:?}"),
2362 }
2363 }
2364
2365 #[test]
2366 fn level_inverted_endpoints_rejected() {
2367 let err = parse(&to_vec(&["a.png", "-level", "200/1.0/100", "b.png"])).unwrap_err();
2368 assert!(format!("{err:?}").contains("must be <="));
2369 }
2370
2371 #[test]
2372 fn normalize_is_valueless() {
2373 let p = parse(&to_vec(&["a.png", "-normalize", "b.png"])).unwrap();
2374 assert_eq!(p.ops, vec![Op::Normalize]);
2375 }
2376
2377 #[test]
2378 fn threshold_percent() {
2379 let p = parse(&to_vec(&["a.png", "-threshold", "50%", "b.png"])).unwrap();
2380 assert_eq!(p.ops, vec![Op::Threshold { value: 128 }]);
2382 }
2383
2384 #[test]
2385 fn threshold_byte() {
2386 let p = parse(&to_vec(&["a.png", "-threshold", "200", "b.png"])).unwrap();
2387 assert_eq!(p.ops, vec![Op::Threshold { value: 200 }]);
2388 }
2389
2390 #[test]
2391 fn posterize_basic() {
2392 let p = parse(&to_vec(&["a.png", "-posterize", "4", "b.png"])).unwrap();
2393 assert_eq!(p.ops, vec![Op::Posterize { levels: 4 }]);
2394 }
2395
2396 #[test]
2397 fn posterize_one_rejected() {
2398 let err = parse(&to_vec(&["a.png", "-posterize", "1", "b.png"])).unwrap_err();
2399 assert!(format!("{err:?}").contains("levels must be >= 2"));
2400 }
2401
2402 #[test]
2403 fn solarize_percent() {
2404 let p = parse(&to_vec(&["a.png", "-solarize", "100", "b.png"])).unwrap();
2405 assert_eq!(p.ops, vec![Op::Solarize { value: 100 }]);
2406 }
2407
2408 #[test]
2409 fn colorspace_gray_recognised() {
2410 let p = parse(&to_vec(&["a.png", "-colorspace", "Gray", "b.png"])).unwrap();
2411 assert_eq!(p.ops, vec![Op::Colorspace("Gray".into())]);
2412 }
2413
2414 #[test]
2415 fn colorspace_rgb_recognised() {
2416 let p = parse(&to_vec(&["a.png", "-colorspace", "rgb", "b.png"])).unwrap();
2417 assert_eq!(p.ops, vec![Op::Colorspace("rgb".into())]);
2418 }
2419
2420 #[test]
2421 fn colorspace_unsupported_rejected() {
2422 let err = parse(&to_vec(&["a.png", "-colorspace", "CMYK", "b.png"])).unwrap_err();
2423 assert!(format!("{err:?}").contains("not yet wired"));
2424 }
2425
2426 #[test]
2427 fn unknown_flag_errors() {
2428 let err = parse(&to_vec(&["a.png", "-fnord", "42", "b.png"])).unwrap_err();
2429 let msg = format!("{err:?}");
2430 assert!(msg.contains("unknown flag"));
2431 assert!(
2433 !msg.contains("did you mean"),
2434 "distant token should not get a hint, got: {msg}"
2435 );
2436 }
2437
2438 #[test]
2439 fn unknown_flag_typo_suggests_close_match() {
2440 let err = parse(&to_vec(&["a.png", "-quailty", "90", "b.jpg"])).unwrap_err();
2442 let msg = format!("{err:?}");
2443 assert!(msg.contains("unknown flag"), "got: {msg}");
2444 assert!(
2445 msg.contains("did you mean '-quality'?"),
2446 "expected quality suggestion, got: {msg}"
2447 );
2448 }
2449
2450 #[test]
2451 fn unknown_flag_typo_preserves_single_vs_double_dash() {
2452 let err = parse(&to_vec(&["--prbe", "in.png"])).unwrap_err();
2455 let msg = format!("{err:?}");
2456 assert!(msg.contains("unknown flag"), "got: {msg}");
2457 assert!(
2458 msg.contains("did you mean '--probe'?"),
2459 "expected --probe suggestion with two dashes, got: {msg}"
2460 );
2461 }
2462
2463 #[test]
2464 fn unknown_flag_typo_resize_transposition() {
2465 let err = parse(&to_vec(&["a.png", "-reszie", "100x100", "b.png"])).unwrap_err();
2466 let msg = format!("{err:?}");
2467 assert!(
2468 msg.contains("did you mean '-resize'?"),
2469 "expected resize suggestion, got: {msg}"
2470 );
2471 }
2472
2473 #[test]
2474 fn missing_output_errors() {
2475 let err = parse(&to_vec(&["in.png"])).unwrap_err();
2476 assert!(format!("{err:?}").contains("no output file given"));
2477 }
2478
2479 #[test]
2480 fn quality_strip() {
2481 let p = parse(&to_vec(&["a.png", "-quality", "85", "-strip", "b.jpg"])).unwrap();
2482 assert_eq!(p.ops, vec![Op::Quality(85), Op::Strip]);
2483 }
2484
2485 #[test]
2486 fn quality_zero_and_hundred_accepted() {
2487 let p0 = parse(&to_vec(&["a.png", "-quality", "0", "b.jpg"])).unwrap();
2490 assert_eq!(p0.ops, vec![Op::Quality(0)]);
2491 let p100 = parse(&to_vec(&["a.png", "-quality", "100", "b.jpg"])).unwrap();
2492 assert_eq!(p100.ops, vec![Op::Quality(100)]);
2493 }
2494
2495 #[test]
2496 fn quality_out_of_range_rejected() {
2497 let err = parse(&to_vec(&["a.png", "-quality", "1000", "b.jpg"])).unwrap_err();
2500 let msg = format!("{err:?}");
2501 assert!(
2502 msg.contains("-quality") && msg.contains("out of range"),
2503 "got: {msg}"
2504 );
2505 assert!(
2506 msg.contains("0..=100"),
2507 "must point at the legal range; got: {msg}"
2508 );
2509 let err = parse(&to_vec(&["a.png", "-quality", "150", "b.jpg"])).unwrap_err();
2511 assert!(format!("{err:?}").contains("out of range"));
2512 }
2513
2514 #[test]
2515 fn quality_non_numeric_rejected() {
2516 let err = parse(&to_vec(&["a.png", "-quality", "-5", "b.jpg"])).unwrap_err();
2519 assert!(format!("{err:?}").contains("not a non-negative integer"));
2520 let err = parse(&to_vec(&["a.png", "-quality", "high", "b.jpg"])).unwrap_err();
2521 assert!(format!("{err:?}").contains("not a non-negative integer"));
2522 }
2523
2524 #[test]
2525 fn density_background_alpha_chain() {
2526 let p = parse(&to_vec(&[
2527 "in.pdf",
2528 "-density",
2529 "300",
2530 "-background",
2531 "white",
2532 "-alpha",
2533 "remove",
2534 "-alpha",
2535 "off",
2536 "page-%03d.png",
2537 ]))
2538 .unwrap();
2539 assert_eq!(p.input, "in.pdf");
2540 assert_eq!(p.output, "page-%03d.png");
2541 assert_eq!(
2542 p.ops,
2543 vec![
2544 Op::Density(300),
2545 Op::Background([255, 255, 255, 255]),
2546 Op::Alpha(AlphaOp::Remove),
2547 Op::Alpha(AlphaOp::Off),
2548 ]
2549 );
2550 }
2551
2552 #[test]
2553 fn density_zero_rejected() {
2554 let err = parse(&to_vec(&["a.png", "-density", "0", "b.png"])).unwrap_err();
2555 assert!(format!("{err:?}").contains("-density must be > 0"));
2556 }
2557
2558 #[test]
2559 fn alpha_unknown_subcommand_rejected() {
2560 let err = parse(&to_vec(&["a.png", "-alpha", "fnord", "b.png"])).unwrap_err();
2561 assert!(format!("{err:?}").contains("unknown subcommand"));
2562 }
2563
2564 #[test]
2565 fn background_hex_color() {
2566 let p = parse(&to_vec(&["a.png", "-background", "#ff8000", "b.png"])).unwrap();
2567 assert_eq!(p.ops, vec![Op::Background([255, 128, 0, 255])]);
2568 }
2569
2570 #[test]
2571 fn background_hex_with_alpha() {
2572 let p = parse(&to_vec(&["a.png", "-background", "#80808040", "b.png"])).unwrap();
2573 assert_eq!(p.ops, vec![Op::Background([128, 128, 128, 64])]);
2574 }
2575
2576 #[test]
2577 fn printf_template_zero_padded() {
2578 let t = parse_printf_template("page-%03d.png").unwrap().unwrap();
2579 assert_eq!(t.prefix, "page-");
2580 assert_eq!(t.width, 3);
2581 assert_eq!(t.suffix, ".png");
2582 }
2583
2584 #[test]
2585 fn printf_template_unpadded() {
2586 let t = parse_printf_template("out%d.jpg").unwrap().unwrap();
2587 assert_eq!(t.prefix, "out");
2588 assert_eq!(t.width, 0);
2589 assert_eq!(t.suffix, ".jpg");
2590 }
2591
2592 #[test]
2593 fn printf_template_absent_returns_none() {
2594 assert!(parse_printf_template("out.png").unwrap().is_none());
2595 assert!(parse_printf_template("/some/dir/out.jpg")
2596 .unwrap()
2597 .is_none());
2598 }
2599
2600 #[test]
2601 fn printf_template_multiple_d_rejected() {
2602 let err = parse_printf_template("page-%d-frame-%d.png").unwrap_err();
2603 assert!(format!("{err:?}").contains("more than one"));
2604 }
2605
2606 #[test]
2607 fn printf_template_unsupported_specifier_rejected() {
2608 let err = parse_printf_template("out-%s.png").unwrap_err();
2609 assert!(format!("{err:?}").contains("unsupported format specifier"));
2610 }
2611
2612 #[test]
2613 fn parse_populates_output_template_when_present() {
2614 let p = parse(&to_vec(&["in.pdf", "page-%02d.png"])).unwrap();
2615 let t = p.output_template.unwrap();
2616 assert_eq!(t.prefix, "page-");
2617 assert_eq!(t.width, 2);
2618 assert_eq!(t.suffix, ".png");
2619 }
2620
2621 #[test]
2622 fn parse_leaves_output_template_none_for_literal_path() {
2623 let p = parse(&to_vec(&["in.png", "out.jpg"])).unwrap();
2624 assert!(p.output_template.is_none());
2625 }
2626
2627 #[test]
2628 fn ping_flag_off_by_default() {
2629 let p = parse(&to_vec(&["in.png", "out.jpg"])).unwrap();
2630 assert!(!p.ping);
2631 }
2632
2633 #[test]
2634 fn ping_flag_set_when_present() {
2635 let p = parse(&to_vec(&["-ping", "in.png", "out.jpg"])).unwrap();
2636 assert!(p.ping);
2637 }
2638
2639 #[test]
2640 fn ping_makes_output_optional() {
2641 let p = parse(&to_vec(&["-ping", "in.png"])).unwrap();
2642 assert!(p.ping);
2643 assert_eq!(p.output, "");
2644 assert!(p.output_template.is_none());
2645 }
2646
2647 #[test]
2648 fn missing_output_without_ping_still_errors() {
2649 let err = parse(&to_vec(&["in.png"])).unwrap_err();
2650 assert!(format!("{err:?}").contains("no output file"));
2652 }
2653
2654 #[test]
2655 fn input_selector_single_page() {
2656 let (path, sel) = split_input_selector("input.pdf[0]").unwrap();
2657 assert_eq!(path, "input.pdf");
2658 assert_eq!(sel, Some(PageSelector::Single(0)));
2659 }
2660
2661 #[test]
2662 fn input_selector_range() {
2663 let (path, sel) = split_input_selector("foo.pdf[2-5]").unwrap();
2664 assert_eq!(path, "foo.pdf");
2665 assert_eq!(sel, Some(PageSelector::Range(2, 5)));
2666 }
2667
2668 #[test]
2669 fn input_selector_absent_passes_through() {
2670 let (path, sel) = split_input_selector("plain.pdf").unwrap();
2671 assert_eq!(path, "plain.pdf");
2672 assert_eq!(sel, None);
2673 }
2674
2675 #[test]
2676 fn input_selector_empty_brackets_rejected() {
2677 assert!(split_input_selector("foo.pdf[]").is_err());
2678 }
2679
2680 #[test]
2681 fn input_selector_non_numeric_rejected() {
2682 assert!(split_input_selector("foo.pdf[abc]").is_err());
2683 assert!(split_input_selector("foo.pdf[1-x]").is_err());
2684 }
2685
2686 #[test]
2687 fn input_selector_extra_dash_rejected() {
2688 assert!(split_input_selector("foo.pdf[1-2-3]").is_err());
2689 }
2690
2691 #[test]
2692 fn parse_pulls_selector_into_input_pages() {
2693 let p = parse(&to_vec(&["in.pdf[0]", "out.png"])).unwrap();
2694 assert_eq!(p.input, "in.pdf");
2695 assert_eq!(p.input_pages, Some(PageSelector::Single(0)));
2696 }
2697
2698 #[test]
2699 fn parse_pulls_range_selector_into_input_pages() {
2700 let p = parse(&to_vec(&["in.pdf[2-4]", "page-%02d.png"])).unwrap();
2701 assert_eq!(p.input, "in.pdf");
2702 assert_eq!(p.input_pages, Some(PageSelector::Range(2, 4)));
2703 assert!(p.output_template.is_some());
2704 }
2705
2706 #[test]
2709 fn input_selector_negative_index_parses() {
2710 let (path, sel) = split_input_selector("foo.pdf[-1]").unwrap();
2711 assert_eq!(path, "foo.pdf");
2712 assert_eq!(sel, Some(PageSelector::Single(-1)));
2713 }
2714
2715 #[test]
2716 fn input_selector_negative_range_endpoint_parses() {
2717 let (path, sel) = split_input_selector("foo.pdf[5--1]").unwrap();
2719 assert_eq!(path, "foo.pdf");
2720 assert_eq!(sel, Some(PageSelector::Range(5, -1)));
2721 }
2722
2723 #[test]
2724 fn input_selector_negative_range_both_endpoints() {
2725 let (path, sel) = split_input_selector("foo.pdf[-3--1]").unwrap();
2727 assert_eq!(path, "foo.pdf");
2728 assert_eq!(sel, Some(PageSelector::Range(-3, -1)));
2729 }
2730
2731 #[test]
2732 fn input_selector_comma_list_of_singles() {
2733 let (path, sel) = split_input_selector("foo.pdf[0,2,4]").unwrap();
2734 assert_eq!(path, "foo.pdf");
2735 assert_eq!(
2736 sel,
2737 Some(PageSelector::List(vec![
2738 PageAtom::Single(0),
2739 PageAtom::Single(2),
2740 PageAtom::Single(4),
2741 ]))
2742 );
2743 }
2744
2745 #[test]
2746 fn input_selector_comma_list_with_ranges_and_negatives() {
2747 let (path, sel) = split_input_selector("foo.pdf[0-2,5,-1]").unwrap();
2749 assert_eq!(path, "foo.pdf");
2750 assert_eq!(
2751 sel,
2752 Some(PageSelector::List(vec![
2753 PageAtom::Range(0, 2),
2754 PageAtom::Single(5),
2755 PageAtom::Single(-1),
2756 ]))
2757 );
2758 }
2759
2760 #[test]
2761 fn input_selector_single_atom_list_collapses_to_single_variant() {
2762 let (_, sel) = split_input_selector("foo.pdf[3]").unwrap();
2765 assert!(matches!(sel, Some(PageSelector::Single(3))));
2766 }
2767
2768 #[test]
2769 fn input_selector_empty_atom_in_list_rejected() {
2770 assert!(split_input_selector("foo.pdf[0,,1]").is_err());
2771 assert!(split_input_selector("foo.pdf[,0]").is_err());
2772 assert!(split_input_selector("foo.pdf[0,]").is_err());
2773 }
2774
2775 #[test]
2776 fn input_selector_bare_dash_rejected() {
2777 assert!(split_input_selector("foo.pdf[-]").is_err());
2778 }
2779
2780 #[test]
2781 fn page_selector_negative_single_resolves_to_last_page() {
2782 let s = PageSelector::Single(-1);
2783 assert_eq!(s.resolve(5).unwrap(), vec![4]);
2784 let s = PageSelector::Single(-2);
2785 assert_eq!(s.resolve(5).unwrap(), vec![3]);
2786 assert!(PageSelector::Single(-6).resolve(5).is_err());
2788 }
2789
2790 #[test]
2791 fn page_selector_negative_range_endpoint_resolves() {
2792 let s = PageSelector::Range(5, -1);
2794 assert_eq!(s.resolve(8).unwrap(), vec![5, 6, 7]);
2795 let s = PageSelector::Range(-3, -1);
2797 assert_eq!(s.resolve(8).unwrap(), vec![5, 6, 7]);
2798 }
2799
2800 #[test]
2801 fn page_selector_list_resolves_in_source_order() {
2802 let s = PageSelector::List(vec![
2803 PageAtom::Single(0),
2804 PageAtom::Single(2),
2805 PageAtom::Range(4, 5),
2806 PageAtom::Single(-1),
2807 ]);
2808 assert_eq!(s.resolve(8).unwrap(), vec![0, 2, 4, 5, 7]);
2809 }
2810
2811 #[test]
2812 fn page_selector_list_preserves_duplicates() {
2813 let s = PageSelector::List(vec![
2815 PageAtom::Single(0),
2816 PageAtom::Single(0),
2817 PageAtom::Single(0),
2818 ]);
2819 assert_eq!(s.resolve(3).unwrap(), vec![0, 0, 0]);
2820 }
2821
2822 #[test]
2823 fn page_selector_list_propagates_out_of_range() {
2824 let s = PageSelector::List(vec![PageAtom::Single(0), PageAtom::Single(99)]);
2825 assert!(s.resolve(3).is_err());
2826 }
2827
2828 #[test]
2831 fn vignette_radius_only_defaults_sigma_and_centre() {
2832 let p = parse(&to_vec(&["a.png", "-vignette", "50", "b.png"])).unwrap();
2833 match p.ops.as_slice() {
2834 [Op::Vignette {
2835 radius,
2836 sigma,
2837 x,
2838 y,
2839 }] => {
2840 assert!((radius - 50.0).abs() < 1e-6);
2841 assert!((sigma - 25.0).abs() < 1e-6);
2843 assert!((x - 0.5).abs() < 1e-6);
2845 assert!((y - 0.5).abs() < 1e-6);
2846 }
2847 other => panic!("unexpected ops: {other:?}"),
2848 }
2849 }
2850
2851 #[test]
2852 fn vignette_radius_and_sigma() {
2853 let p = parse(&to_vec(&["a.png", "-vignette", "50+10", "b.png"])).unwrap();
2854 match p.ops.as_slice() {
2855 [Op::Vignette {
2856 radius,
2857 sigma,
2858 x,
2859 y,
2860 }] => {
2861 assert!((radius - 50.0).abs() < 1e-6);
2862 assert!((sigma - 10.0).abs() < 1e-6);
2863 assert!((x - 0.5).abs() < 1e-6);
2864 assert!((y - 0.5).abs() < 1e-6);
2865 }
2866 other => panic!("unexpected ops: {other:?}"),
2867 }
2868 }
2869
2870 #[test]
2871 fn vignette_full_grammar_with_xy() {
2872 let p = parse(&to_vec(&["a.png", "-vignette", "50+10+0.25+0.75", "b.png"])).unwrap();
2873 match p.ops.as_slice() {
2874 [Op::Vignette {
2875 radius,
2876 sigma,
2877 x,
2878 y,
2879 }] => {
2880 assert!((radius - 50.0).abs() < 1e-6);
2881 assert!((sigma - 10.0).abs() < 1e-6);
2882 assert!((x - 0.25).abs() < 1e-6);
2883 assert!((y - 0.75).abs() < 1e-6);
2884 }
2885 other => panic!("unexpected ops: {other:?}"),
2886 }
2887 }
2888
2889 #[test]
2890 fn vignette_too_many_components_rejected() {
2891 let err = parse(&to_vec(&["a.png", "-vignette", "1+2+3+4+5", "b.png"])).unwrap_err();
2892 assert!(format!("{err:?}").contains("more than 4"));
2893 }
2894
2895 #[test]
2896 fn vignette_negative_radius_rejected() {
2897 let err = parse(&to_vec(&["a.png", "-vignette", "-5", "b.png"])).unwrap_err();
2898 let msg = format!("{err:?}");
2899 assert!(msg.contains("not a finite float radius") || msg.contains("must be >= 0"));
2900 }
2901
2902 #[test]
2903 fn colorize_named_color_with_percent_amount() {
2904 let p = parse(&to_vec(&["a.png", "-colorize", "red/40%", "b.png"])).unwrap();
2905 match p.ops.as_slice() {
2906 [Op::Colorize { color, amount }] => {
2907 assert_eq!(*color, [255, 0, 0, 255]);
2908 assert!((amount - 0.4).abs() < 1e-6);
2909 }
2910 other => panic!("unexpected ops: {other:?}"),
2911 }
2912 }
2913
2914 #[test]
2915 fn colorize_hex_color() {
2916 let p = parse(&to_vec(&["a.png", "-colorize", "#ff8000/50%", "b.png"])).unwrap();
2917 match p.ops.as_slice() {
2918 [Op::Colorize { color, amount }] => {
2919 assert_eq!(*color, [255, 128, 0, 255]);
2920 assert!((amount - 0.5).abs() < 1e-6);
2921 }
2922 other => panic!("unexpected ops: {other:?}"),
2923 }
2924 }
2925
2926 #[test]
2927 fn colorize_per_channel_triplet() {
2928 let p = parse(&to_vec(&["a.png", "-colorize", "200x100x50/25%", "b.png"])).unwrap();
2929 match p.ops.as_slice() {
2930 [Op::Colorize { color, amount }] => {
2931 assert_eq!(*color, [200, 100, 50, 255]);
2932 assert!((amount - 0.25).abs() < 1e-6);
2933 }
2934 other => panic!("unexpected ops: {other:?}"),
2935 }
2936 }
2937
2938 #[test]
2939 fn colorize_single_channel_replicated() {
2940 let err = parse(&to_vec(&["a.png", "-colorize", "100/50%", "b.png"])).unwrap_err();
2945 let msg = format!("{err:?}");
2946 assert!(msg.contains("unknown colour") || msg.contains("not a valid hex"));
2947 }
2948
2949 #[test]
2950 fn colorize_per_channel_one_value_replicates() {
2951 let p = parse(&to_vec(&["a.png", "-colorize", "100x100x100/30%", "b.png"])).unwrap();
2953 match p.ops.as_slice() {
2954 [Op::Colorize { color, amount: _ }] => {
2955 assert_eq!(*color, [100, 100, 100, 255]);
2956 }
2957 other => panic!("unexpected ops: {other:?}"),
2958 }
2959 }
2960
2961 #[test]
2962 fn colorize_amount_only_defaults_white() {
2963 let p = parse(&to_vec(&["a.png", "-colorize", "/40%", "b.png"])).unwrap();
2965 match p.ops.as_slice() {
2966 [Op::Colorize { color, amount }] => {
2967 assert_eq!(*color, [255, 255, 255, 255]);
2968 assert!((amount - 0.4).abs() < 1e-6);
2969 }
2970 other => panic!("unexpected ops: {other:?}"),
2971 }
2972 }
2973
2974 #[test]
2975 fn colorize_amount_unit_scalar() {
2976 let p = parse(&to_vec(&["a.png", "-colorize", "red/0.6", "b.png"])).unwrap();
2978 match p.ops.as_slice() {
2979 [Op::Colorize { color, amount }] => {
2980 assert_eq!(*color, [255, 0, 0, 255]);
2981 assert!((amount - 0.6).abs() < 1e-6);
2982 }
2983 other => panic!("unexpected ops: {other:?}"),
2984 }
2985 }
2986
2987 #[test]
2988 fn colorize_out_of_range_channel_rejected() {
2989 let err = parse(&to_vec(&["a.png", "-colorize", "300x0x0/50%", "b.png"])).unwrap_err();
2990 assert!(format!("{err:?}").contains("out of range"));
2991 }
2992
2993 #[test]
2994 fn equalize_is_valueless() {
2995 let p = parse(&to_vec(&["a.png", "-equalize", "b.png"])).unwrap();
2996 assert_eq!(p.ops, vec![Op::Equalize]);
2997 }
2998
2999 #[test]
3000 fn auto_gamma_is_valueless() {
3001 let p = parse(&to_vec(&["a.png", "-auto-gamma", "b.png"])).unwrap();
3002 assert_eq!(p.ops, vec![Op::AutoGamma]);
3003 }
3004
3005 #[test]
3006 fn vignette_rejects_negative_sigma() {
3007 let err = parse(&to_vec(&["a.png", "-vignette", "5+-1", "b.png"])).unwrap_err();
3008 let msg = format!("{err:?}");
3009 assert!(
3010 msg.contains("not a finite float sigma") || msg.contains("must be >= 0"),
3011 "unexpected message: {msg}"
3012 );
3013 }
3014
3015 #[test]
3022 fn stl_format_default_is_none() {
3023 let p = parse(&to_vec(&["in.stl", "out.stl"])).unwrap();
3024 assert!(p.mesh3d_options.stl_format.is_none());
3025 assert!(p.ops.is_empty(), "no -stl-format → no Op pushed");
3026 }
3027
3028 #[test]
3029 fn stl_format_ascii_parses() {
3030 let p = parse(&to_vec(&["in.stl", "-stl-format", "ascii", "out.stl"])).unwrap();
3031 assert_eq!(p.mesh3d_options.stl_format, Some(StlFormatChoice::Ascii));
3032 }
3033
3034 #[test]
3035 fn stl_format_binary_parses() {
3036 let p = parse(&to_vec(&["in.stl", "-stl-format", "binary", "out.stl"])).unwrap();
3037 assert_eq!(p.mesh3d_options.stl_format, Some(StlFormatChoice::Binary));
3038 }
3039
3040 #[test]
3041 fn stl_format_aliases_parse() {
3042 let p = parse(&to_vec(&["in.stl", "-stl-format", "bin", "out.stl"])).unwrap();
3044 assert_eq!(p.mesh3d_options.stl_format, Some(StlFormatChoice::Binary));
3045 let p = parse(&to_vec(&["in.stl", "-stl-format", "text", "out.stl"])).unwrap();
3046 assert_eq!(p.mesh3d_options.stl_format, Some(StlFormatChoice::Ascii));
3047 }
3048
3049 #[test]
3050 fn stl_format_case_insensitive() {
3051 let p = parse(&to_vec(&["in.stl", "-stl-format", "ASCII", "out.stl"])).unwrap();
3052 assert_eq!(p.mesh3d_options.stl_format, Some(StlFormatChoice::Ascii));
3053 }
3054
3055 #[test]
3056 fn stl_format_unknown_value_rejected() {
3057 let err = parse(&to_vec(&["in.stl", "-stl-format", "xml", "out.stl"])).unwrap_err();
3058 let msg = format!("{err:?}");
3059 assert!(
3060 msg.contains("unknown flavour 'xml'") && msg.contains("expected 'binary'"),
3061 "unexpected message: {msg}"
3062 );
3063 }
3064
3065 #[test]
3066 fn stl_format_missing_value_rejected() {
3067 let err = parse(&to_vec(&["in.stl", "-stl-format"])).unwrap_err();
3068 assert!(format!("{err:?}").contains("-stl-format: missing value"));
3069 }
3070
3071 #[test]
3072 fn gltf_format_default_is_none() {
3073 let p = parse(&to_vec(&["in.stl", "out.gltf"])).unwrap();
3074 assert!(p.mesh3d_options.gltf_format.is_none());
3075 }
3076
3077 #[test]
3078 fn gltf_format_glb_parses() {
3079 let p = parse(&to_vec(&["in.stl", "-gltf-format", "glb", "out.glb"])).unwrap();
3080 assert_eq!(p.mesh3d_options.gltf_format, Some(GltfFormatChoice::Glb));
3081 }
3082
3083 #[test]
3084 fn gltf_format_embedded_parses() {
3085 let p = parse(&to_vec(&["in.stl", "-gltf-format", "embedded", "out.gltf"])).unwrap();
3086 assert_eq!(
3087 p.mesh3d_options.gltf_format,
3088 Some(GltfFormatChoice::JsonEmbedded)
3089 );
3090 }
3091
3092 #[test]
3093 fn gltf_format_external_parses_even_though_runtime_rejects() {
3094 let p = parse(&to_vec(&["in.stl", "-gltf-format", "external", "out.gltf"])).unwrap();
3098 assert_eq!(
3099 p.mesh3d_options.gltf_format,
3100 Some(GltfFormatChoice::JsonExternal)
3101 );
3102 }
3103
3104 #[test]
3105 fn gltf_format_aliases_parse() {
3106 let p = parse(&to_vec(&["in.stl", "-gltf-format", "binary", "out.glb"])).unwrap();
3108 assert_eq!(p.mesh3d_options.gltf_format, Some(GltfFormatChoice::Glb));
3109 let p = parse(&to_vec(&[
3111 "in.stl",
3112 "-gltf-format",
3113 "json-embedded",
3114 "out.gltf",
3115 ]))
3116 .unwrap();
3117 assert_eq!(
3118 p.mesh3d_options.gltf_format,
3119 Some(GltfFormatChoice::JsonEmbedded)
3120 );
3121 }
3122
3123 #[test]
3124 fn gltf_format_unknown_value_rejected() {
3125 let err = parse(&to_vec(&["in.stl", "-gltf-format", "obj", "out.gltf"])).unwrap_err();
3126 let msg = format!("{err:?}");
3127 assert!(
3128 msg.contains("unknown flavour 'obj'") && msg.contains("expected 'glb'"),
3129 "unexpected message: {msg}"
3130 );
3131 }
3132
3133 #[test]
3134 fn render_gouraud_parses() {
3135 let p = parse(&to_vec(&["in.stl", "-render", "gouraud", "out.png"])).unwrap();
3136 assert_eq!(
3137 p.mesh3d_options.render_mode,
3138 Some(Mesh3DRenderMode::Gouraud)
3139 );
3140 }
3141
3142 #[test]
3143 fn render_phong_parses() {
3144 let p = parse(&to_vec(&["in.stl", "-render", "phong", "out.png"])).unwrap();
3145 assert_eq!(p.mesh3d_options.render_mode, Some(Mesh3DRenderMode::Phong));
3146 }
3147
3148 #[test]
3149 fn render_wire_alias_parses() {
3150 let p = parse(&to_vec(&["in.stl", "-render", "wire", "out.png"])).unwrap();
3151 assert_eq!(
3152 p.mesh3d_options.render_mode,
3153 Some(Mesh3DRenderMode::Wireframe)
3154 );
3155 }
3156
3157 #[test]
3158 fn render_unknown_mode_rejected_lists_all_options() {
3159 let err = parse(&to_vec(&["in.stl", "-render", "raytrace", "out.png"])).unwrap_err();
3160 let msg = format!("{err:?}");
3161 assert!(
3162 msg.contains("unknown mode 'raytrace'")
3163 && msg.contains("'gouraud'")
3164 && msg.contains("'phong'"),
3165 "unexpected message: {msg}"
3166 );
3167 }
3168
3169 #[test]
3170 fn light_parses_three_components() {
3171 let p = parse(&to_vec(&["in.stl", "-light", "30,45,1.5", "out.png"])).unwrap();
3172 let light = p.mesh3d_options.light.expect("-light should populate");
3173 assert_eq!(light.azimuth_deg, 30.0);
3174 assert_eq!(light.elevation_deg, 45.0);
3175 assert_eq!(light.intensity, 1.5);
3176 }
3177
3178 #[test]
3179 fn light_rejects_negative_intensity() {
3180 let err = parse(&to_vec(&["in.stl", "-light", "0,0,-1", "out.png"])).unwrap_err();
3181 assert!(format!("{err:?}").contains("intensity"));
3182 }
3183
3184 #[test]
3185 fn light_rejects_two_component_input() {
3186 let err = parse(&to_vec(&["in.stl", "-light", "30,45", "out.png"])).unwrap_err();
3187 let msg = format!("{err:?}");
3188 assert!(
3189 msg.contains("AZIMUTH,ELEVATION,INTENSITY"),
3190 "expected grammar hint, got: {msg}"
3191 );
3192 }
3193
3194 #[test]
3195 fn camera_parses_three_components() {
3196 let p = parse(&to_vec(&["in.stl", "-camera", "20,30,2.0", "out.png"])).unwrap();
3197 let cam = p.mesh3d_options.camera.expect("-camera should populate");
3198 assert_eq!(cam.elevation_deg, 20.0);
3199 assert_eq!(cam.azimuth_deg, 30.0);
3200 assert_eq!(cam.distance, 2.0);
3201 }
3202
3203 #[test]
3204 fn camera_rejects_zero_distance() {
3205 let err = parse(&to_vec(&["in.stl", "-camera", "0,0,0", "out.png"])).unwrap_err();
3206 assert!(format!("{err:?}").contains("distance"));
3207 }
3208
3209 #[test]
3210 fn projection_orthographic_parses() {
3211 let p = parse(&to_vec(&[
3212 "in.stl",
3213 "-projection",
3214 "orthographic",
3215 "out.png",
3216 ]))
3217 .unwrap();
3218 assert_eq!(
3219 p.mesh3d_options.projection,
3220 Some(ProjectionMode::Orthographic)
3221 );
3222 }
3223
3224 #[test]
3225 fn projection_perspective_default_is_unset() {
3226 let p = parse(&to_vec(&["in.stl", "out.png"])).unwrap();
3227 assert!(p.mesh3d_options.projection.is_none());
3228 }
3229
3230 #[test]
3231 fn projection_alias_parses() {
3232 let p = parse(&to_vec(&["in.stl", "-projection", "ortho", "out.png"])).unwrap();
3233 assert_eq!(
3234 p.mesh3d_options.projection,
3235 Some(ProjectionMode::Orthographic)
3236 );
3237 }
3238
3239 #[test]
3240 fn projection_unknown_rejected() {
3241 let err = parse(&to_vec(&["in.stl", "-projection", "isometric", "out.png"])).unwrap_err();
3242 assert!(format!("{err:?}").contains("unknown mode 'isometric'"));
3243 }
3244
3245 #[test]
3246 fn fov_parses_within_range() {
3247 let p = parse(&to_vec(&["in.stl", "-fov", "45", "out.png"])).unwrap();
3248 assert_eq!(p.mesh3d_options.fov_deg, Some(45.0));
3249 }
3250
3251 #[test]
3252 fn fov_rejects_zero() {
3253 let err = parse(&to_vec(&["in.stl", "-fov", "0", "out.png"])).unwrap_err();
3254 assert!(format!("{err:?}").contains("(0, 180)"));
3255 }
3256
3257 #[test]
3258 fn fov_rejects_180_or_above() {
3259 let err = parse(&to_vec(&["in.stl", "-fov", "180", "out.png"])).unwrap_err();
3260 assert!(format!("{err:?}").contains("(0, 180)"));
3261 }
3262
3263 #[test]
3264 fn bg_named_colour_parses() {
3265 let p = parse(&to_vec(&["in.stl", "-bg", "black", "out.png"])).unwrap();
3266 assert_eq!(p.mesh3d_options.bg, Some([0, 0, 0, 255]));
3267 }
3268
3269 #[test]
3270 fn bg_hex_colour_parses() {
3271 let p = parse(&to_vec(&["in.stl", "-bg", "#abcdef", "out.png"])).unwrap();
3272 assert_eq!(p.mesh3d_options.bg, Some([0xab, 0xcd, 0xef, 255]));
3273 }
3274
3275 #[test]
3276 fn bg_transparent_parses() {
3277 let p = parse(&to_vec(&["in.stl", "-bg", "transparent", "out.png"])).unwrap();
3278 assert_eq!(p.mesh3d_options.bg, Some([0, 0, 0, 0]));
3279 }
3280
3281 #[test]
3282 fn mesh3d_flags_can_appear_before_input() {
3283 let p = parse(&to_vec(&[
3286 "-stl-format",
3287 "ascii",
3288 "-gltf-format",
3289 "glb",
3290 "in.stl",
3291 "out.stl",
3292 ]))
3293 .unwrap();
3294 assert_eq!(p.mesh3d_options.stl_format, Some(StlFormatChoice::Ascii));
3295 assert_eq!(p.mesh3d_options.gltf_format, Some(GltfFormatChoice::Glb));
3296 assert_eq!(p.input, "in.stl");
3297 assert_eq!(p.output, "out.stl");
3298 }
3299
3300 #[test]
3303 fn render_normal_debug_parses() {
3304 let p = parse(&to_vec(&["in.stl", "-render", "normal-debug", "out.png"])).unwrap();
3305 assert_eq!(
3306 p.mesh3d_options.render_mode,
3307 Some(Mesh3DRenderMode::NormalDebug)
3308 );
3309 }
3310
3311 #[test]
3312 fn render_depth_debug_parses() {
3313 let p = parse(&to_vec(&["in.stl", "-render", "depth-debug", "out.png"])).unwrap();
3314 assert_eq!(
3315 p.mesh3d_options.render_mode,
3316 Some(Mesh3DRenderMode::DepthDebug)
3317 );
3318 }
3319
3320 #[test]
3321 fn render_normal_alias_parses() {
3322 let p = parse(&to_vec(&["in.stl", "-render", "normal", "out.png"])).unwrap();
3325 assert_eq!(
3326 p.mesh3d_options.render_mode,
3327 Some(Mesh3DRenderMode::NormalDebug)
3328 );
3329 let p = parse(&to_vec(&["in.stl", "-render", "normals", "out.png"])).unwrap();
3330 assert_eq!(
3331 p.mesh3d_options.render_mode,
3332 Some(Mesh3DRenderMode::NormalDebug)
3333 );
3334 }
3335
3336 #[test]
3337 fn render_depth_alias_parses() {
3338 let p = parse(&to_vec(&["in.stl", "-render", "depth", "out.png"])).unwrap();
3339 assert_eq!(
3340 p.mesh3d_options.render_mode,
3341 Some(Mesh3DRenderMode::DepthDebug)
3342 );
3343 let p = parse(&to_vec(&["in.stl", "-render", "z", "out.png"])).unwrap();
3344 assert_eq!(
3345 p.mesh3d_options.render_mode,
3346 Some(Mesh3DRenderMode::DepthDebug)
3347 );
3348 }
3349
3350 #[test]
3351 fn render_unknown_mode_lists_debug_modes() {
3352 let err = parse(&to_vec(&["in.stl", "-render", "raytrace", "out.png"])).unwrap_err();
3355 let msg = format!("{err:?}");
3356 assert!(
3357 msg.contains("normal-debug") && msg.contains("depth-debug"),
3358 "expected new debug-mode names in error, got: {msg}"
3359 );
3360 }
3361
3362 #[test]
3363 fn aa_default_is_unset() {
3364 let p = parse(&to_vec(&["in.stl", "out.png"])).unwrap();
3365 assert!(p.mesh3d_options.aa.is_none());
3366 }
3367
3368 #[test]
3369 fn aa_factor_parses() {
3370 let p = parse(&to_vec(&["in.stl", "-aa", "4", "out.png"])).unwrap();
3371 assert_eq!(p.mesh3d_options.aa, Some(4));
3372 }
3373
3374 #[test]
3375 fn aa_factor_one_is_allowed_meaning_no_supersampling() {
3376 let p = parse(&to_vec(&["in.stl", "-aa", "1", "out.png"])).unwrap();
3380 assert_eq!(p.mesh3d_options.aa, Some(1));
3381 }
3382
3383 #[test]
3384 fn aa_factor_zero_rejected() {
3385 let err = parse(&to_vec(&["in.stl", "-aa", "0", "out.png"])).unwrap_err();
3386 let msg = format!("{err:?}");
3387 assert!(
3388 msg.contains("out of range") && msg.contains("1..=8"),
3389 "expected range hint, got: {msg}"
3390 );
3391 }
3392
3393 #[test]
3394 fn aa_factor_above_cap_rejected() {
3395 let err = parse(&to_vec(&["in.stl", "-aa", "16", "out.png"])).unwrap_err();
3396 assert!(format!("{err:?}").contains("1..=8"));
3397 }
3398
3399 #[test]
3400 fn aa_non_integer_rejected() {
3401 let err = parse(&to_vec(&["in.stl", "-aa", "2.5", "out.png"])).unwrap_err();
3402 assert!(format!("{err:?}").contains("not a positive integer"));
3403 }
3404
3405 #[test]
3410 fn trim_default_fuzz_is_zero() {
3411 let p = parse(&to_vec(&["a.png", "-trim", "b.png"])).unwrap();
3412 assert_eq!(p.ops, vec![Op::Trim { fuzz: 0 }]);
3413 }
3414
3415 #[test]
3418 fn fuzz_byte_then_trim_captures_value() {
3419 let p = parse(&to_vec(&["a.png", "-fuzz", "12", "-trim", "b.png"])).unwrap();
3420 assert_eq!(p.ops, vec![Op::Trim { fuzz: 12 }]);
3421 }
3422
3423 #[test]
3426 fn fuzz_percent_then_trim_captures_scaled_value() {
3427 let p = parse(&to_vec(&["a.png", "-fuzz", "10%", "-trim", "b.png"])).unwrap();
3428 assert_eq!(p.ops, vec![Op::Trim { fuzz: 26 }]);
3429 let p = parse(&to_vec(&["a.png", "-fuzz", "100%", "-trim", "b.png"])).unwrap();
3430 assert_eq!(p.ops, vec![Op::Trim { fuzz: 255 }]);
3431 }
3432
3433 #[test]
3438 fn fuzz_after_trim_only_affects_following_trim() {
3439 let p = parse(&to_vec(&[
3440 "a.png", "-fuzz", "5", "-trim", "-fuzz", "20", "-trim", "b.png",
3441 ]))
3442 .unwrap();
3443 assert_eq!(p.ops, vec![Op::Trim { fuzz: 5 }, Op::Trim { fuzz: 20 }]);
3444 }
3445
3446 #[test]
3449 fn fuzz_without_trim_is_no_op() {
3450 let p = parse(&to_vec(&["a.png", "-fuzz", "30", "b.png"])).unwrap();
3451 assert_eq!(p.ops, vec![]);
3452 }
3453
3454 #[test]
3456 fn fuzz_missing_value_rejected() {
3457 let err = parse(&to_vec(&["a.png", "-fuzz"])).unwrap_err();
3458 assert!(format!("{err:?}").contains("missing value"));
3459 }
3460
3461 #[test]
3463 fn fuzz_garbage_rejected() {
3464 let err = parse(&to_vec(&["a.png", "-fuzz", "xyz", "-trim", "b.png"])).unwrap_err();
3465 assert!(format!("{err:?}").contains("-fuzz"));
3466 }
3467
3468 #[test]
3471 fn fuzz_percent_out_of_range_rejected() {
3472 let err = parse(&to_vec(&["a.png", "-fuzz", "150%", "-trim", "b.png"])).unwrap_err();
3473 assert!(format!("{err:?}").contains("out of range"));
3474 }
3475
3476 #[test]
3479 fn fuzz_byte_above_255_rejected() {
3480 let err = parse(&to_vec(&["a.png", "-fuzz", "300", "-trim", "b.png"])).unwrap_err();
3481 assert!(format!("{err:?}").contains("out of range"));
3482 }
3483
3484 #[test]
3489 fn trim_keeps_source_order_with_neighbours() {
3490 let p = parse(&to_vec(&[
3491 "a.png", "-resize", "100x100", "-fuzz", "8", "-trim", "-strip", "b.png",
3492 ]))
3493 .unwrap();
3494 assert_eq!(
3495 p.ops,
3496 vec![
3497 Op::Resize {
3498 width: 100,
3499 height: 100,
3500 mode: ResizeMode::Default,
3501 },
3502 Op::Trim { fuzz: 8 },
3503 Op::Strip,
3504 ]
3505 );
3506 }
3507
3508 #[test]
3513 fn roll_positive_offsets() {
3514 let p = parse(&to_vec(&["a.png", "-roll", "+5+10", "b.png"])).unwrap();
3515 assert_eq!(p.ops, vec![Op::Roll { dx: 5, dy: 10 }]);
3516 }
3517
3518 #[test]
3522 fn roll_negative_offsets() {
3523 let p = parse(&to_vec(&["a.png", "-roll", "-3-7", "b.png"])).unwrap();
3524 assert_eq!(p.ops, vec![Op::Roll { dx: -3, dy: -7 }]);
3525 }
3526
3527 #[test]
3530 fn roll_mixed_signs() {
3531 let p = parse(&to_vec(&["a.png", "-roll", "+5-10", "b.png"])).unwrap();
3532 assert_eq!(p.ops, vec![Op::Roll { dx: 5, dy: -10 }]);
3533 }
3534
3535 #[test]
3538 fn roll_single_offset_rejected() {
3539 let err = parse(&to_vec(&["a.png", "-roll", "+5", "b.png"])).unwrap_err();
3540 assert!(
3541 format!("{err:?}").contains("single offset"),
3542 "unexpected error: {err:?}"
3543 );
3544 }
3545
3546 #[test]
3549 fn roll_empty_value_rejected() {
3550 let err = parse(&to_vec(&["a.png", "-roll", "", "b.png"])).unwrap_err();
3551 assert!(
3552 format!("{err:?}").contains("empty offset"),
3553 "unexpected error: {err:?}"
3554 );
3555 }
3556
3557 #[test]
3561 fn roll_unsigned_value_rejected() {
3562 let err = parse(&to_vec(&["a.png", "-roll", "5+10", "b.png"])).unwrap_err();
3563 let s = format!("{err:?}");
3564 assert!(
3565 s.contains("must start with `+` or `-`") || s.contains("not a signed integer"),
3566 "unexpected error: {err:?}"
3567 );
3568 }
3569
3570 #[test]
3573 fn roll_keeps_source_order_with_neighbours() {
3574 let p = parse(&to_vec(&[
3575 "a.png", "-resize", "100x100", "-roll", "+5+5", "-strip", "b.png",
3576 ]))
3577 .unwrap();
3578 assert_eq!(
3579 p.ops,
3580 vec![
3581 Op::Resize {
3582 width: 100,
3583 height: 100,
3584 mode: ResizeMode::Default,
3585 },
3586 Op::Roll { dx: 5, dy: 5 },
3587 Op::Strip,
3588 ]
3589 );
3590 }
3591}