1use crate::rotation::{CoordSystem, DEG2RAD, ViewTransform, view_rotation};
2use crate::{Colormap, NegMode, Scale, get_colormap, validate_scale_config};
3use clap::Parser;
4use image::Rgba;
5use std::str::FromStr;
6
7#[derive(Parser, Debug)]
9#[command(author, version, about)]
10pub struct Args {
11 #[arg(short, long)]
13 pub fits: Option<String>,
14
15 #[arg(short = 'i', long, default_value_t = 0)]
17 pub col: usize,
18
19 #[arg(short = 'c', long, default_value = "planck")]
21 pub cmap: String,
22
23 #[arg(short, long, default_value_t = 1200)]
25 pub width: u32,
26
27 #[arg(short, long)]
29 pub out: Option<String>,
30
31 #[arg(long)]
33 pub no_border: bool,
34
35 #[arg(long)]
37 pub transparent: bool,
38
39 #[arg(long)]
41 pub no_cbar: bool,
42
43 #[arg(long, allow_negative_numbers = true)]
45 pub min: Option<f64>,
46
47 #[arg(long, allow_negative_numbers = true)]
49 pub max: Option<f64>,
50
51 #[arg(long, default_value_t = 1.0)]
53 pub gamma: f64,
54
55 #[arg(long)]
57 pub log: bool,
58
59 #[arg(long)]
61 pub symlog: bool,
62
63 #[arg(long)]
65 pub hist: bool,
66
67 #[arg(long)]
69 pub linthresh: Option<f64>,
70
71 #[arg(long)]
73 pub asinh: bool,
74
75 #[arg(long, default_value = "unseen")]
77 pub neg_mode: String,
78
79 #[arg(long)]
81 pub bad_color: Option<InputColor>,
82
83 #[arg(long)]
85 pub bg_color: Option<InputColor>,
86
87 #[arg(long)]
89 pub planck_log: bool,
90
91 #[arg(long, default_value_t = 1.0)]
93 pub scale: f64,
94
95 #[arg(long)]
97 pub latex: bool,
98
99 #[arg(long)]
101 pub units: Option<String>,
102
103 #[arg(long, default_value = "gal")]
105 pub input_coord: String,
106
107 #[arg(long, default_value = "gal")]
109 pub output_coord: String,
110
111 #[arg(long, value_name = "LON,LAT")]
113 pub rotate_to: Option<String>,
114
115 #[arg(long, default_value_t = 0.0)]
117 pub roll: f64,
118
119 #[arg(long, default_value = "mollweide")]
121 pub projection: String,
122
123 #[arg(long, alias = "gnom-lon", allow_negative_numbers = true)]
125 pub lon: Option<f64>,
126
127 #[arg(long, alias = "gnom-lat", allow_negative_numbers = true)]
129 pub lat: Option<f64>,
130
131 #[arg(long, alias = "gnom-width", default_value_t = 300.0)]
133 pub fov: f64,
134
135 #[arg(long, alias = "gnom-res", default_value_t = 1.0)]
137 pub res: f64,
138
139 #[arg(long, alias = "gnom-graticule")]
141 pub local_graticule: bool,
142
143 #[arg(long, alias = "gnom-grat-dlat", default_value_t = 1.0)]
145 pub local_grat_dlat: f64,
146
147 #[arg(long, alias = "gnom-grat-dlon", default_value_t = 1.0)]
149 pub local_grat_dlon: f64,
150
151 #[arg(long, default_value_t = 1)]
153 pub grat_line_width: u32,
154
155 #[arg(long)]
157 pub no_text: bool,
158
159 #[arg(long)]
161 pub no_title: bool,
162
163 #[arg(long)]
165 pub no_scale_text: bool,
166
167 #[arg(long)]
169 pub title: Option<String>,
170
171 #[arg(long)]
173 pub verbose: bool,
174
175 #[arg(long)]
177 pub no_downgrade: bool,
178
179 #[arg(long)]
181 pub graticule: bool,
182
183 #[arg(long)]
186 pub grat_coord: Option<String>,
187
188 #[arg(long)]
191 pub grat_coord_overlay: Option<String>,
192
193 #[arg(long, default_value = "#FFFF00")]
196 pub grat_overlay_color: InputColor,
197
198 #[arg(long)]
200 pub grat_labels: bool,
201
202 #[arg(long, default_value_t = 15.0)]
204 pub grat_par: f64,
205
206 #[arg(long, default_value_t = 15.0)]
208 pub grat_mer: f64,
209
210 #[arg(long, default_value = "none")]
212 pub extend: String,
213
214 #[arg(long, default_value = "inward")]
216 pub tick_direction: String,
217
218 #[arg(long)]
220 pub tick_font_size: Option<f32>,
221
222 #[arg(long)]
224 pub units_font_size: Option<f32>,
225
226 #[arg(long)]
228 pub llabel: Option<String>,
229
230 #[arg(long)]
232 pub rlabel: Option<String>,
233
234 #[arg(long)]
236 pub label_font_size: Option<f32>,
237
238 #[arg(long)]
240 pub mask_file: Option<String>,
241
242 #[arg(long, allow_negative_numbers = true)]
244 pub mask_below: Option<f64>,
245
246 #[arg(long, allow_negative_numbers = true)]
248 pub mask_above: Option<f64>,
249
250 #[arg(long, default_value = "transparent")]
252 pub maskfill_color: String,
253
254 #[arg(long)]
256 pub mask_coord: Option<String>,
257
258 #[arg(long)]
260 pub fast_render: bool,
261
262 #[arg(long, default_value = "cairo")]
264 pub pdf_backend: String,
265}
266
267#[derive(Clone, Debug, PartialEq)]
269pub enum Extend {
270 None,
271 Min,
272 Max,
273 Both,
274}
275
276impl FromStr for Extend {
277 type Err = String;
278
279 fn from_str(s: &str) -> Result<Self, Self::Err> {
280 match s.to_lowercase().as_str() {
281 "none" => Ok(Extend::None),
282 "min" => Ok(Extend::Min),
283 "max" => Ok(Extend::Max),
284 "both" => Ok(Extend::Both),
285 _ => Err(format!(
286 "Invalid extend option '{}'. Expected: none, min, max, or both",
287 s
288 )),
289 }
290 }
291}
292
293#[derive(Clone, Debug, PartialEq)]
295pub enum TickDirection {
296 Inward, Outward, }
299
300impl FromStr for TickDirection {
301 type Err = String;
302
303 fn from_str(s: &str) -> Result<Self, Self::Err> {
304 match s.to_lowercase().as_str() {
305 "inward" | "in" => Ok(TickDirection::Inward),
306 "outward" | "out" => Ok(TickDirection::Outward),
307 _ => Err(format!(
308 "Invalid tick direction '{}'. Expected: inward (in) or outward (out)",
309 s
310 )),
311 }
312 }
313}
314
315#[derive(Clone, Debug)]
317pub enum InputColor {
318 Gray,
319 Underflow,
320 Overflow,
321 Transparent,
322 Rgba(u8, u8, u8, u8),
323 Hex(String), }
325
326impl FromStr for InputColor {
327 type Err = String;
328
329 fn from_str(s: &str) -> Result<Self, Self::Err> {
330 let s_lower = s.to_lowercase();
331 match s_lower.as_str() {
332 "under" => Ok(InputColor::Underflow),
333 "over" => Ok(InputColor::Overflow),
334 "gray" | "grey" => Ok(InputColor::Gray),
335 "transparent" | "trans" => Ok(InputColor::Transparent),
336 _ => {
337 if s.starts_with('#') || (s.len() == 6 && s.chars().all(|c| c.is_ascii_hexdigit()))
339 {
340 return Ok(InputColor::Hex(s.to_string()));
341 }
342
343 let parts: Vec<_> = s.split(',').collect();
345 if parts.len() == 4 {
346 let vals: Result<Vec<u8>, _> = parts.iter().map(|x| x.trim().parse()).collect();
347 return match vals {
348 Ok(v) => Ok(InputColor::Rgba(v[0], v[1], v[2], v[3])),
349 Err(_) => Err("RGBA values must be 0–255".into()),
350 };
351 }
352
353 Err(format!(
354 "Invalid color format: '{}'. Expected hex (#RRGGBB), RGBA (r,g,b,a), or keyword (gray/transparent)",
355 s
356 ))
357 }
358 }
359 }
360}
361
362pub fn resolve_input_color(
363 input: Option<InputColor>,
364 cmap: &Colormap,
365 transparent: bool,
366) -> Rgba<u8> {
367 match input.unwrap_or(InputColor::Gray) {
368 InputColor::Underflow => {
369 let c = cmap.under();
370 Rgba([c[0], c[1], c[2], if transparent { 0 } else { 255 }])
371 }
372 InputColor::Overflow => {
373 let c = cmap.over();
374 Rgba([c[0], c[1], c[2], if transparent { 0 } else { 255 }])
375 }
376 InputColor::Gray => Rgba([128, 128, 128, if transparent { 0 } else { 255 }]),
377 InputColor::Transparent => Rgba([255, 255, 255, 0]),
378 InputColor::Rgba(r, g, b, a) => Rgba([r, g, b, a]),
379 InputColor::Hex(hex_str) => {
380 match parse_hex_color(&hex_str, if transparent { 0 } else { 255 }) {
381 Ok(color) => color,
382 Err(e) => {
383 eprintln!(
384 "Warning: Failed to parse hex color '{}': {}, using gray",
385 hex_str, e
386 );
387 Rgba([128, 128, 128, if transparent { 0 } else { 255 }])
388 }
389 }
390 }
391 }
392}
393
394pub fn parse_hex_color(hex: &str, alpha: u8) -> Result<Rgba<u8>, String> {
396 let hex = hex.trim_start_matches('#');
397
398 if hex.len() != 6 {
399 return Err(format!("Hex color must be 6 digits, got: {}", hex));
400 }
401
402 let r =
403 u8::from_str_radix(&hex[0..2], 16).map_err(|_| format!("Invalid hex color: {}", hex))?;
404 let g =
405 u8::from_str_radix(&hex[2..4], 16).map_err(|_| format!("Invalid hex color: {}", hex))?;
406 let b =
407 u8::from_str_radix(&hex[4..6], 16).map_err(|_| format!("Invalid hex color: {}", hex))?;
408
409 Ok(Rgba([r, g, b, alpha]))
410}
411
412pub fn resolve_color_with_alpha(color: &InputColor, alpha: u8) -> Result<Rgba<u8>, String> {
415 match color {
416 InputColor::Hex(hex_str) => parse_hex_color(hex_str, alpha),
417 InputColor::Rgba(r, g, b, _) => Ok(Rgba([*r, *g, *b, alpha])), InputColor::Gray => Ok(Rgba([128, 128, 128, alpha])),
419 InputColor::Transparent => Ok(Rgba([255, 255, 255, 0])), InputColor::Underflow => Ok(Rgba([100, 100, 100, alpha])), InputColor::Overflow => Ok(Rgba([200, 200, 200, alpha])),
422 }
423}
424
425pub struct PlotConfig {
427 pub scale: Scale,
428 pub colormap: &'static Colormap,
429 pub neg_mode: NegMode,
430 pub bad_color_rgba: image::Rgba<u8>,
431 pub bg_color_rgba: image::Rgba<u8>,
432 pub latex_rendering: bool,
433 pub units: Option<String>,
434}
435
436impl Args {
437 pub fn validate_projection_args(&self) -> Result<(), String> {
439 let projection = self.projection.to_lowercase();
440
441 let gnom_only_provided = self.fov != 300.0
443 || self.res != 1.0
444 || self.local_graticule
445 || self.local_grat_dlat != 1.0
446 || self.local_grat_dlon != 1.0;
447
448 if gnom_only_provided && projection != "gnomonic" {
449 return Err(
450 "Gnomonic-specific arguments (--fov, --res, --local-graticule, \
451 --local-grat-dlat, --local-grat-dlon) can only be used with \
452 --projection gnomonic"
453 .to_string(),
454 );
455 }
456
457 let mollweide_args_provided = self.graticule;
459
460 if mollweide_args_provided && projection != "mollweide" && projection != "hammer" {
461 return Err(
462 "Mollweide/Hammer projection arguments (--graticule, --grat-coord, --grat-par, \
463 --grat-mer) can only be used with --projection mollweide or hammer"
464 .to_string(),
465 );
466 }
467
468 Ok(())
469 }
470
471 pub fn resolve_config(&self) -> Result<PlotConfig, String> {
472 self.validate_projection_args()?;
474
475 let (scale, cmap_name) = if self.planck_log {
477 (
478 Scale::PlanckLog {
479 linthresh: self.linthresh.unwrap_or(300.0),
480 },
481 "planck-log",
482 )
483 } else {
484 let scale = if self.symlog {
485 Scale::Symlog {
486 linthresh: self.linthresh.unwrap_or(1.0),
487 }
488 } else if self.asinh {
489 Scale::Asinh {
490 scale: self.linthresh.unwrap_or(1.0),
491 }
492 } else if self.log {
493 Scale::Log
494 } else if self.hist {
495 Scale::Histogram
496 } else {
497 Scale::Linear
498 };
499
500 (scale, self.cmap.as_str())
501 };
502
503 validate_scale_config(&scale, self.min, self.max);
505
506 let colormap = get_colormap(cmap_name);
508
509 let neg_mode = match self.neg_mode.as_str() {
511 "zero" => NegMode::Zero,
512 "unseen" => NegMode::Unseen,
513 _ => return Err("--neg-mode must be 'zero' or 'unseen'".to_string()),
514 };
515
516 let bad_color_rgba = resolve_input_color(
518 self.bad_color.clone().or(Some(InputColor::Gray)),
519 colormap,
520 self.transparent,
521 );
522 let bg_color_rgba = resolve_input_color(
523 self.bg_color.clone().or(Some(InputColor::Transparent)),
524 colormap,
525 self.transparent,
526 );
527
528 Ok(PlotConfig {
529 scale,
530 colormap,
531 neg_mode,
532 bad_color_rgba,
533 bg_color_rgba,
534 latex_rendering: self.latex,
535 units: self.units.clone(),
536 })
537 }
538
539 pub fn get_output_filename(&self) -> String {
546 if let Some(ref out) = self.out {
547 out.clone()
548 } else if let Some(ref fits) = self.fits {
549 let path = std::path::Path::new(fits);
550 if let Some(stem) = path.file_stem() {
551 format!("{}.pdf", stem.to_string_lossy())
552 } else {
553 "map2fig_test.pdf".to_string()
554 }
555 } else {
556 "map2fig_test.pdf".to_string()
557 }
558 }
559
560 pub fn describe_coord_transform(&self) -> Option<String> {
564 if self.input_coord != self.output_coord {
565 Some(format!(
566 "Rotating from {} to {} coordinates",
567 self.describe_coord(&self.input_coord),
568 self.describe_coord(&self.output_coord)
569 ))
570 } else {
571 None
572 }
573 }
574
575 fn describe_coord(&self, coord: &str) -> String {
577 match coord.to_lowercase().as_str() {
578 "gal" | "galactic" => "Galactic".to_string(),
579 "eq" | "equatorial" => "Equatorial".to_string(),
580 "ecl" | "ecliptic" => "Ecliptic".to_string(),
581 _ => coord.to_string(),
582 }
583 }
584
585 pub fn resolve_view_transform(&self) -> Result<ViewTransform, String> {
586 let input = CoordSystem::from_str(&self.input_coord)?;
587 let output = CoordSystem::from_str(&self.output_coord)?;
588
589 let view = if let Some(ref s) = self.rotate_to {
593 let parts: Vec<_> = s.split(',').collect();
594 if parts.len() != 2 {
595 return Err("--rotate-to expects lon,lat".into());
596 }
597 let lon = parts[0].parse::<f64>().map_err(|_| "bad lon")? * DEG2RAD;
598 let lat = parts[1].parse::<f64>().map_err(|_| "bad lat")? * DEG2RAD;
599 let roll = self.roll * DEG2RAD;
600 Some(view_rotation(lon, lat, roll))
601 } else {
602 None
603 };
604
605 Ok(ViewTransform::new(input, output, view))
606 }
607}