1use crate::card::CardRow;
17use printpdf::*;
18
19static FONT_REGULAR: &[u8] = include_bytes!("../fonts/LiberationSans-Regular.ttf");
21static FONT_BOLD: &[u8] = include_bytes!("../fonts/LiberationSans-Bold.ttf");
22
23#[derive(Debug, Clone)]
25pub struct DopeCardConfig {
26 pub rifle_name: String,
27 pub location: String,
28 pub density_altitude_ft: f64,
29 pub pressure_inhg: f64,
30 pub pressure_hpa: f64,
31 pub temperature_f: f64,
32 pub altitude_ft: f64,
33 pub wind_speed_mph: f64,
34 pub target_speed_mph: f64,
35 pub solver_mode: String,
36 pub powder: String,
37 pub bullet: String,
38 pub weight_gr: f64,
39 pub bc: f64,
40 pub drag_model: String,
41 pub velocity_fps: f64,
42 pub font_scale: f32,
43 pub bold_data: bool,
44 pub elevation_unit_label: String,
48 pub windage_unit_label: String,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq)]
56pub enum FontSizePreset {
57 Small,
58 Medium,
59 Large,
60}
61
62impl FontSizePreset {
63 pub fn scale(&self) -> f32 {
64 match self {
65 Self::Small => 0.8,
66 Self::Medium => 1.0,
67 Self::Large => 1.4,
68 }
69 }
70
71 #[allow(clippy::should_implement_trait)]
77 pub fn from_str(s: &str) -> Option<Self> {
78 match s.to_lowercase().as_str() {
79 "small" | "s" => Some(Self::Small),
80 "medium" | "m" => Some(Self::Medium),
81 "large" | "l" => Some(Self::Large),
82 _ => None,
83 }
84 }
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum RangeUnit {
92 Yards,
93 Meters,
94}
95
96impl RangeUnit {
97 fn label(&self) -> &'static str {
98 match self {
99 RangeUnit::Yards => "Yd",
100 RangeUnit::Meters => "M",
101 }
102 }
103}
104
105const PAGE_WIDTH: f32 = 215.9;
107const PAGE_HEIGHT: f32 = 279.4;
108const MARGIN: f32 = 10.0;
109
110const HEADER_FONT_SIZE: f32 = 9.0;
112const TABLE_FONT_SIZE: f32 = 8.0;
113const FOOTER_FONT_SIZE: f32 = 8.0;
114
115const ROW_HEIGHT: f32 = 4.5;
117const COL_WIDTH: f32 = 24.0; const COLOR_BLACK: (f32, f32, f32) = (0.0, 0.0, 0.0);
121const COLOR_RED: (f32, f32, f32) = (0.78, 0.0, 0.0);
122const COLOR_GREEN: (f32, f32, f32) = (0.0, 0.5, 0.0);
123const COLOR_BLUE: (f32, f32, f32) = (0.0, 0.0, 0.78);
124const COLOR_STRIPE: (f32, f32, f32) = (0.94, 0.94, 0.94); const INHG_TO_HPA: f64 = 33.863_886_666_667;
126
127pub fn calculate_density_altitude(_altitude_ft: f64, pressure_inhg: f64, temp_f: f64) -> f64 {
149 let pressure_hpa = pressure_inhg * INHG_TO_HPA;
152 let pressure_alt = 145_366.45 * (1.0 - (pressure_hpa / 1013.25).powf(0.190_284));
153
154 let isa_temp_f = 59.0 - (pressure_alt / 1000.0) * 3.57;
156
157 pressure_alt + (120.0 * 5.0 / 9.0) * (temp_f - isa_temp_f)
160}
161
162fn find_font_file(font_name: &str) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
165 let ttf = format!("{}.ttf", font_name);
166
167 if let Ok(exe_path) = std::env::current_exe() {
169 if let Some(exe_dir) = exe_path.parent() {
170 let font_path = exe_dir.join("fonts").join(&ttf);
171 if font_path.exists() {
172 return Ok(std::fs::read(font_path)?);
173 }
174 }
175 }
176
177 if let Some(home) = dirs::home_dir() {
179 let font_path = home.join(".ballistics").join("fonts").join(&ttf);
180 if font_path.exists() {
181 return Ok(std::fs::read(font_path)?);
182 }
183 }
184
185 for prefix in &["./fonts", "../fonts"] {
187 let font_path = std::path::Path::new(prefix).join(&ttf);
188 if font_path.exists() {
189 return Ok(std::fs::read(font_path)?);
190 }
191 }
192
193 #[cfg(target_os = "linux")]
195 {
196 for dir in &["/usr/share/fonts", "/usr/local/share/fonts"] {
197 if let Some(path) = find_in_directory(dir, &ttf) {
198 return Ok(std::fs::read(path)?);
199 }
200 }
201 }
202
203 #[cfg(target_os = "macos")]
204 {
205 for dir in &["/Library/Fonts", "/System/Library/Fonts"] {
206 let font_path = std::path::Path::new(dir).join(&ttf);
207 if font_path.exists() {
208 return Ok(std::fs::read(font_path)?);
209 }
210 }
211 }
212
213 #[cfg(target_os = "windows")]
214 {
215 if let Ok(windir) = std::env::var("WINDIR") {
216 let font_path = std::path::Path::new(&windir).join("Fonts").join(&ttf);
217 if font_path.exists() {
218 return Ok(std::fs::read(font_path)?);
219 }
220 }
221 }
222
223 match font_name {
225 "LiberationSans-Regular" => Ok(FONT_REGULAR.to_vec()),
226 "LiberationSans-Bold" => Ok(FONT_BOLD.to_vec()),
227 _ => Err(format!("Font {} not found", font_name).into()),
228 }
229}
230
231#[cfg(target_os = "linux")]
233fn find_in_directory(dir: &str, filename: &str) -> Option<std::path::PathBuf> {
234 let dir_path = std::path::Path::new(dir);
235 if !dir_path.is_dir() {
236 return None;
237 }
238 for entry in std::fs::read_dir(dir_path).ok()?.flatten() {
239 let path = entry.path();
240 if path.is_file() && path.file_name().is_some_and(|n| n == filename) {
241 return Some(path);
242 }
243 if path.is_dir() {
244 if let Some(found) = find_in_directory(path.to_str()?, filename) {
245 return Some(found);
246 }
247 }
248 }
249 None
250}
251
252fn truncate_for_header(s: &str, max_chars: usize) -> String {
254 if s.chars().count() <= max_chars {
258 s.to_string()
259 } else if max_chars <= 3 {
260 s.chars().take(max_chars).collect()
261 } else {
262 let head: String = s.chars().take(max_chars - 3).collect();
263 format!("{head}...")
264 }
265}
266
267fn draw_separator_line(ops: &mut Vec<Op>, y: f32) {
269 ops.push(Op::SetOutlineColor {
270 col: Color::Rgb(Rgb::new(0.7, 0.7, 0.7, None)),
271 });
272 ops.push(Op::SetOutlineThickness { pt: Pt(0.3) });
273 ops.push(Op::DrawLine {
274 line: Line {
275 points: vec![
276 LinePoint {
277 p: Point::new(Mm(MARGIN), Mm(y)),
278 bezier: false,
279 },
280 LinePoint {
281 p: Point::new(Mm(PAGE_WIDTH - MARGIN), Mm(y)),
282 bezier: false,
283 },
284 ],
285 is_closed: false,
286 },
287 });
288}
289
290pub fn generate_dope_card_pdf(
296 config: &DopeCardConfig,
297 rows: &[CardRow],
298 range_unit: RangeUnit,
299) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
300 if rows.is_empty() {
301 return Err("generate_dope_card_pdf: rows must not be empty".into());
302 }
303 let mut doc = PdfDocument::new(&format!("{} Dope Card", config.rifle_name));
304
305 let font_data = find_font_file("LiberationSans-Regular")?;
307 let mut font_warnings = Vec::new();
308 let parsed_font = ParsedFont::from_bytes(&font_data, 0, &mut font_warnings)
309 .ok_or("Failed to parse LiberationSans-Regular font")?;
310 let font = doc.add_font(&parsed_font);
311
312 let font_bold_data = find_font_file("LiberationSans-Bold")?;
313 let mut font_bold_warnings = Vec::new();
314 let parsed_font_bold = ParsedFont::from_bytes(&font_bold_data, 0, &mut font_bold_warnings)
315 .ok_or("Failed to parse LiberationSans-Bold font")?;
316 let font_bold = doc.add_font(&parsed_font_bold);
317
318 let font_scale = config.font_scale.clamp(0.5, 3.0);
321 let header_size = HEADER_FONT_SIZE; let table_size = TABLE_FONT_SIZE * font_scale; let footer_size = FOOTER_FONT_SIZE; let row_height = ROW_HEIGHT * font_scale; let usable_height = PAGE_HEIGHT - (2.0 * MARGIN) - 36.0; let visual_rows_per_page = ((usable_height / row_height) as usize).min(52);
330 let data_rows_per_page = visual_rows_per_page * 2; let total_pages = rows.len().div_ceil(data_rows_per_page);
332
333 let mut pages = Vec::with_capacity(total_pages);
334
335 for page_num in 0..total_pages {
336 let start_idx = page_num * data_rows_per_page;
337 let end_idx = std::cmp::min(start_idx + data_rows_per_page, rows.len());
338 let page_rows = &rows[start_idx..end_idx];
339
340 let mut ops = Vec::new();
341
342 render_page(
343 &mut ops,
344 &font,
345 &font_bold,
346 config,
347 page_rows,
348 range_unit,
349 page_num + 1,
350 total_pages,
351 header_size,
352 table_size,
353 footer_size,
354 row_height,
355 font_scale,
356 config.bold_data,
357 );
358
359 pages.push(PdfPage::new(Mm(PAGE_WIDTH), Mm(PAGE_HEIGHT), ops));
360 }
361
362 let mut save_warnings = Vec::new();
363 let bytes = doc
364 .with_pages(pages)
365 .save(&PdfSaveOptions::default(), &mut save_warnings);
366 Ok(bytes)
367}
368
369#[allow(clippy::too_many_arguments)] fn render_page(
371 ops: &mut Vec<Op>,
372 font: &FontId,
373 font_bold: &FontId,
374 config: &DopeCardConfig,
375 rows: &[CardRow],
376 range_unit: RangeUnit,
377 page: usize,
378 _total_pages: usize,
379 header_size: f32,
380 table_size: f32,
381 footer_size: f32,
382 row_height: f32,
383 font_scale: f32,
384 bold_data: bool,
385) {
386 let mut y = PAGE_HEIGHT - MARGIN;
387
388 let header1 = truncate_for_header(
390 &format!(
391 "{} Loc: {} DA:{:.0} ft Pressure:{:.2}/{:.0} Temp:{:.0} Alt:{:.0} Wind:{:.0} Mph",
392 config.rifle_name,
393 config.location,
394 config.density_altitude_ft,
395 config.pressure_inhg,
396 config.pressure_hpa,
397 config.temperature_f,
398 config.altitude_ft,
399 config.wind_speed_mph
400 ),
401 77,
402 );
403 draw_centered_text(ops, font, header_size, y, &header1, COLOR_BLACK);
404 y -= 4.0;
405
406 let header2 = format!(
408 "TargetSpeed:{:.0} Solver: {} - Pg {}",
409 config.target_speed_mph, config.solver_mode, page
410 );
411 draw_centered_text(ops, font, header_size, y, &header2, COLOR_BLACK);
412 y -= 1.0;
413
414 draw_separator_line(ops, y);
416 y -= 5.0;
417
418 let table_x = (PAGE_WIDTH - (8.0 * COL_WIDTH)) / 2.0;
420
421 draw_table_header(
423 ops,
424 font_bold,
425 table_x,
426 y,
427 table_size,
428 font_scale,
429 range_unit,
430 &config.elevation_unit_label,
431 &config.windage_unit_label,
432 );
433 y -= row_height;
434
435 let mid = rows.len().div_ceil(2);
437 let (left_rows, right_rows) = rows.split_at(mid);
438
439 let data_font = if bold_data { font_bold } else { font };
441
442 for (i, left) in left_rows.iter().enumerate() {
444 let right = right_rows.get(i);
445
446 if i % 2 == 1 {
448 draw_row_stripe(ops, table_x, y, 8.0 * COL_WIDTH, row_height);
449 }
450
451 draw_data_row(
453 ops, data_font, table_x, y, left, true, table_size, font_scale,
454 &config.elevation_unit_label, &config.windage_unit_label,
455 );
456
457 if let Some(r) = right {
459 draw_data_row(
460 ops,
461 data_font,
462 table_x + 4.0 * COL_WIDTH,
463 y,
464 r,
465 false,
466 table_size,
467 font_scale,
468 &config.elevation_unit_label,
469 &config.windage_unit_label,
470 );
471 }
472
473 y -= row_height;
474 }
475
476 draw_separator_line(ops, y - 1.0);
478 y -= 5.0;
479
480 let footer1 = format!(
482 "Powder:{} Bullet:{} Weight:{:.0}gr BC:{:.3} ({}) Vel:{:.0}fps",
483 config.powder,
484 config.bullet,
485 config.weight_gr,
486 config.bc,
487 config.drag_model.to_lowercase(),
488 config.velocity_fps,
489 );
490 draw_centered_text(ops, font, footer_size, y, &footer1, COLOR_BLACK);
491 y -= 4.0;
492
493 let timestamp = get_timestamp();
495 draw_centered_text(ops, font, footer_size, y, ×tamp, COLOR_BLACK);
496}
497
498fn draw_row_stripe(ops: &mut Vec<Op>, x: f32, y: f32, width: f32, height: f32) {
499 let points = vec![
500 LinePoint {
501 p: Point::new(Mm(x), Mm(y)),
502 bezier: false,
503 },
504 LinePoint {
505 p: Point::new(Mm(x + width), Mm(y)),
506 bezier: false,
507 },
508 LinePoint {
509 p: Point::new(Mm(x + width), Mm(y - height)),
510 bezier: false,
511 },
512 LinePoint {
513 p: Point::new(Mm(x), Mm(y - height)),
514 bezier: false,
515 },
516 ];
517
518 ops.push(Op::SetFillColor {
519 col: Color::Rgb(Rgb::new(
520 COLOR_STRIPE.0,
521 COLOR_STRIPE.1,
522 COLOR_STRIPE.2,
523 None,
524 )),
525 });
526 ops.push(Op::DrawPolygon {
527 polygon: Polygon {
528 rings: vec![PolygonRing { points }],
529 mode: PaintMode::Fill,
530 winding_order: WindingOrder::NonZero,
531 },
532 });
533}
534
535#[allow(clippy::too_many_arguments)] fn draw_table_header(
537 ops: &mut Vec<Op>,
538 font: &FontId,
539 x: f32,
540 y: f32,
541 table_size: f32,
542 font_scale: f32,
543 range_unit: RangeUnit,
544 elevation_unit: &str,
545 windage_unit: &str,
546) {
547 let headers = [
548 ("Range", COLOR_BLACK),
549 ("Drop", COLOR_RED),
550 ("Wind", COLOR_GREEN),
551 ("Lead", COLOR_BLUE),
552 ("Range", COLOR_BLACK),
553 ("Drop", COLOR_RED),
554 ("Wind", COLOR_GREEN),
555 ("Lead", COLOR_BLUE),
556 ];
557 let range_label = range_unit.label();
560 let sub_headers = [
561 range_label,
562 elevation_unit,
563 windage_unit,
564 windage_unit,
565 range_label,
566 elevation_unit,
567 windage_unit,
568 windage_unit,
569 ];
570
571 for (i, ((header, color), sub)) in headers.iter().zip(sub_headers.iter()).enumerate() {
572 let col_x = x + (i as f32 * COL_WIDTH) + (COL_WIDTH / 2.0);
573 draw_text(ops, font, table_size, col_x, y, header, *color, true);
574 draw_text(
575 ops,
576 font,
577 table_size - 1.0,
578 col_x,
579 y - 3.0 * font_scale,
580 sub,
581 *color,
582 true,
583 );
584 }
585}
586
587fn format_adjustment(value: f64, unit_label: &str) -> String {
593 if unit_label.eq_ignore_ascii_case("clicks") {
594 format!("{:.0}", value)
595 } else {
596 format!("{:.1}", value)
597 }
598}
599
600fn format_adjustment_cell(value: Option<f64>, unit_label: &str) -> String {
609 match value {
610 Some(v) => format_adjustment(v, unit_label),
611 None => "—".to_string(),
612 }
613}
614
615fn format_range(range: f64) -> String {
627 let rounded = range.round();
628 if (range - rounded).abs() < 0.05 {
629 format!("{:.0}", rounded)
630 } else {
631 format!("{:.1}", range)
632 }
633}
634
635#[allow(clippy::too_many_arguments)] fn draw_data_row(
637 ops: &mut Vec<Op>,
638 font: &FontId,
639 x: f32,
640 y: f32,
641 row: &CardRow,
642 _is_left: bool,
643 table_size: f32,
644 font_scale: f32,
645 elevation_unit: &str,
646 windage_unit: &str,
647) {
648 let values = [
649 (format_range(row.range), COLOR_BLACK),
650 (format_adjustment_cell(row.drop_adj, elevation_unit), COLOR_RED),
651 (format_adjustment_cell(row.wind_adj, windage_unit), COLOR_GREEN),
652 (format_adjustment_cell(row.lead_adj, windage_unit), COLOR_BLUE),
653 ];
654
655 for (i, (value, color)) in values.iter().enumerate() {
656 let col_x = x + (i as f32 * COL_WIDTH) + (COL_WIDTH / 2.0);
657 draw_text(
658 ops,
659 font,
660 table_size,
661 col_x,
662 y - 2.5 * font_scale,
663 value,
664 *color,
665 true,
666 );
667 }
668}
669
670fn draw_text_ops(
674 ops: &mut Vec<Op>,
675 font: &FontId,
676 size: f32,
677 x: f32,
678 y: f32,
679 text: &str,
680 color: (f32, f32, f32),
681) {
682 ops.push(Op::StartTextSection);
683 ops.push(Op::SetFillColor {
684 col: Color::Rgb(Rgb::new(color.0, color.1, color.2, None)),
685 });
686 ops.push(Op::SetFont {
687 font: PdfFontHandle::External(font.clone()),
688 size: Pt(size),
689 });
690 ops.push(Op::SetLineHeight { lh: Pt(size) });
691 ops.push(Op::SetTextCursor {
692 pos: Point::new(Mm(x), Mm(y)),
693 });
694 ops.push(Op::ShowText {
695 items: vec![TextItem::Text(text.to_string())],
696 });
697 ops.push(Op::EndTextSection);
698}
699
700#[allow(clippy::too_many_arguments)] fn draw_text(
702 ops: &mut Vec<Op>,
703 font: &FontId,
704 size: f32,
705 x: f32,
706 y: f32,
707 text: &str,
708 color: (f32, f32, f32),
709 center: bool,
710) {
711 let text_width = if center {
715 text.len() as f32 * size * 0.3 } else {
717 0.0
718 };
719
720 draw_text_ops(ops, font, size, x - text_width / 2.0, y, text, color);
721}
722
723fn draw_centered_text(
724 ops: &mut Vec<Op>,
725 font: &FontId,
726 size: f32,
727 y: f32,
728 text: &str,
729 color: (f32, f32, f32),
730) {
731 let text_width = text.len() as f32 * size * 0.28;
733 let x = (PAGE_WIDTH - text_width) / 2.0;
734
735 draw_text_ops(ops, font, size, x, y, text, color);
736}
737
738fn get_timestamp() -> String {
739 use std::time::{SystemTime, UNIX_EPOCH};
740
741 let now = SystemTime::now()
742 .duration_since(UNIX_EPOCH)
743 .unwrap_or_default()
744 .as_secs();
745
746 let secs_per_day = 86400u64;
747 let secs_per_hour = 3600u64;
748 let secs_per_min = 60u64;
749
750 let days_since_epoch = now / secs_per_day;
751 let time_of_day = now % secs_per_day;
752
753 let hours = time_of_day / secs_per_hour;
754 let minutes = (time_of_day % secs_per_hour) / secs_per_min;
755 let seconds = time_of_day % secs_per_min;
756
757 let mut year = 1970;
758 let mut remaining_days = days_since_epoch as i64;
759
760 loop {
761 let days_in_year = if is_leap_year(year) { 366 } else { 365 };
762 if remaining_days < days_in_year {
763 break;
764 }
765 remaining_days -= days_in_year;
766 year += 1;
767 }
768
769 let days_in_months: [i64; 12] = if is_leap_year(year) {
770 [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
771 } else {
772 [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
773 };
774
775 let month_names = [
776 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
777 ];
778 let day_names = ["Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed"];
779
780 let mut month = 0;
781 for (i, &days) in days_in_months.iter().enumerate() {
782 if remaining_days < days {
783 month = i;
784 break;
785 }
786 remaining_days -= days;
787 }
788
789 let day = remaining_days + 1;
790 let day_of_week = (days_since_epoch % 7) as usize;
792
793 let (hour_12, am_pm) = if hours == 0 {
794 (12, "AM")
795 } else if hours < 12 {
796 (hours, "AM")
797 } else if hours == 12 {
798 (12, "PM")
799 } else {
800 (hours - 12, "PM")
801 };
802
803 format!(
804 "{} {} {:02} {:02}:{:02}:{:02} {} UTC {}",
805 day_names[day_of_week], month_names[month], day, hour_12, minutes, seconds, am_pm, year
806 )
807}
808
809fn is_leap_year(year: i64) -> bool {
810 (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
811}
812
813#[cfg(test)]
814mod tests {
815 use super::*;
816
817 #[test]
818 fn test_density_altitude() {
819 let da = calculate_density_altitude(2500.0, 27.32, 55.0);
823 assert!(
824 da > 2500.0 && da < 3500.0,
825 "DA should be ~3000 ft for near-standard conditions, got {}",
826 da
827 );
828
829 let da_hot = calculate_density_altitude(2500.0, 27.32, 95.0);
831 assert!(da_hot > da, "Higher temp should increase DA");
832
833 let da_low_press = calculate_density_altitude(2500.0, 25.0, 55.0);
835 assert!(da_low_press > da, "Lower pressure should increase DA");
836
837 let da_standard = calculate_density_altitude(0.0, 29.92, 59.0);
839 assert!(
840 da_standard.abs() < 100.0,
841 "Standard conditions should give DA near 0, got {}",
842 da_standard
843 );
844 }
845
846 #[test]
850 fn format_adjustment_drops_the_decimal_for_clicks_only() {
851 assert_eq!(format_adjustment(5.0, "CLICKS"), "5");
852 assert_eq!(format_adjustment(-3.0, "CLICKS"), "-3");
853 assert_eq!(format_adjustment(5.0, "clicks"), "5", "case-insensitive");
854 assert_eq!(format_adjustment(2.34, "MIL"), "2.3");
855 assert_eq!(format_adjustment(2.34, "MOA"), "2.3");
856 assert_eq!(format_adjustment(2.34, "SMOA"), "2.3");
857 assert_eq!(format_adjustment(2.34, "IPHY"), "2.3");
858 }
859
860 #[test]
865 fn format_adjustment_cell_renders_none_as_an_em_dash_not_a_fake_zero() {
866 assert_eq!(format_adjustment_cell(Some(2.34), "MIL"), "2.3");
867 assert_eq!(format_adjustment_cell(Some(5.0), "CLICKS"), "5");
868 assert_eq!(format_adjustment_cell(None, "MIL"), "—");
869 assert_eq!(format_adjustment_cell(None, "CLICKS"), "—");
870 }
871
872 #[test]
873 fn density_altitude_uses_published_nws_pressure_altitude_equation() {
874 let pressure_inhg = 20.670_988_150_011_322;
877 let density_altitude = calculate_density_altitude(0.0, pressure_inhg, 55.0);
878
879 assert!((density_altitude - 11_962.774_017_764_264).abs() < 1e-6);
880
881 let standard_pressure_inhg = 29.921_255_347_141_39;
882 let standard_density_altitude =
883 calculate_density_altitude(0.0, standard_pressure_inhg, 59.0);
884 assert!(standard_density_altitude.abs() < 1e-9);
885 }
886
887 #[test]
896 fn format_range_pinned_examples() {
897 assert_eq!(format_range(400.0), "400");
898 assert_eq!(format_range(417.3), "417.3");
899 }
900
901 #[test]
906 fn format_range_absorbs_floating_point_noise_around_a_whole_number() {
907 assert_eq!(format_range(400.02), "400");
908 assert_eq!(format_range(399.98), "400");
909 assert_eq!(format_range(100.0 + 1e-9), "100");
910 assert_eq!(format_range(400.04), "400");
914 assert_eq!(format_range(399.96), "400");
915 assert_eq!(format_range(0.0), "0");
916 }
917
918 #[test]
919 fn format_range_renders_one_decimal_outside_the_noise_band() {
920 assert_eq!(format_range(417.3), "417.3");
921 assert_eq!(format_range(100.5), "100.5");
922 assert_eq!(format_range(400.08), "400.1");
923 assert_eq!(format_range(400.05), "400.1");
927 }
928
929 fn test_config() -> DopeCardConfig {
930 DopeCardConfig {
931 rifle_name: "Test Rifle".to_string(),
932 location: "Test Range".to_string(),
933 density_altitude_ft: 1500.0,
934 pressure_inhg: 29.92,
935 pressure_hpa: 1013.25,
936 temperature_f: 59.0,
937 altitude_ft: 1000.0,
938 wind_speed_mph: 5.0,
939 target_speed_mph: 0.0,
940 solver_mode: "offline".to_string(),
941 powder: "Test Powder".to_string(),
942 bullet: "Test Bullet".to_string(),
943 weight_gr: 175.0,
944 bc: 0.5,
945 drag_model: "g7".to_string(),
946 velocity_fps: 2700.0,
947 font_scale: 1.0,
948 bold_data: false,
949 elevation_unit_label: "MIL".to_string(),
950 windage_unit_label: "MIL".to_string(),
951 }
952 }
953
954 fn test_row(range: f64) -> CardRow {
955 CardRow {
956 range,
957 drop_linear: None,
958 drop_adj: Some(1.0),
959 come_up: None,
960 wind_linear: None,
961 wind_adj: Some(0.5),
962 velocity: None,
963 energy: None,
964 time: None,
965 lead_adj: Some(0.2),
966 wind_columns: Vec::new(),
967 }
968 }
969
970 fn assert_valid_pdf_bytes(bytes: &[u8]) {
971 assert!(!bytes.is_empty(), "PDF output must not be empty");
972 assert_eq!(&bytes[..5], b"%PDF-", "output must start with a PDF header");
973 }
974
975 #[test]
976 fn generate_dope_card_pdf_is_non_empty_for_a_single_row() {
977 let config = test_config();
978 let rows = vec![test_row(100.0)];
979 let bytes = generate_dope_card_pdf(&config, &rows, RangeUnit::Yards)
980 .expect("single-row dope card should generate");
981 assert_valid_pdf_bytes(&bytes);
982 }
983
984 #[test]
988 fn generate_dope_card_pdf_is_non_empty_for_120_rows_pagination() {
989 let config = test_config();
990 let rows: Vec<CardRow> = (0..120)
991 .map(|i| test_row(100.0 + i as f64 * 25.0))
992 .collect();
993 let bytes = generate_dope_card_pdf(&config, &rows, RangeUnit::Meters)
994 .expect("120-row dope card should generate and paginate");
995 assert_valid_pdf_bytes(&bytes);
996 }
997
998 #[test]
1005 fn generate_dope_card_pdf_succeeds_when_lead_adj_is_none() {
1006 let config = test_config();
1007 let mut row = test_row(100.0);
1008 row.lead_adj = None;
1009 let bytes = generate_dope_card_pdf(&config, &[row], RangeUnit::Yards)
1010 .expect("a row missing lead_adj must still render, not error");
1011 assert_valid_pdf_bytes(&bytes);
1012 }
1013
1014 #[test]
1019 fn generate_dope_card_pdf_rejects_empty_rows() {
1020 let config = test_config();
1021 let err = generate_dope_card_pdf(&config, &[], RangeUnit::Yards)
1022 .expect_err("an empty row set must not silently produce a PDF");
1023 assert!(err.to_string().contains("rows"), "{err}");
1024 }
1025
1026 #[test]
1029 fn range_unit_label_matches_its_variant() {
1030 assert_eq!(RangeUnit::Yards.label(), "Yd");
1031 assert_eq!(RangeUnit::Meters.label(), "M");
1032 }
1033}