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 pub engine_version: String,
59 pub table_version: String,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq)]
66pub enum FontSizePreset {
67 Small,
68 Medium,
69 Large,
70}
71
72impl FontSizePreset {
73 pub fn scale(&self) -> f32 {
74 match self {
75 Self::Small => 0.8,
76 Self::Medium => 1.0,
77 Self::Large => 1.4,
78 }
79 }
80
81 #[allow(clippy::should_implement_trait)]
87 pub fn from_str(s: &str) -> Option<Self> {
88 match s.to_lowercase().as_str() {
89 "small" | "s" => Some(Self::Small),
90 "medium" | "m" => Some(Self::Medium),
91 "large" | "l" => Some(Self::Large),
92 _ => None,
93 }
94 }
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum RangeUnit {
102 Yards,
103 Meters,
104}
105
106impl RangeUnit {
107 fn label(&self) -> &'static str {
108 match self {
109 RangeUnit::Yards => "Yd",
110 RangeUnit::Meters => "M",
111 }
112 }
113}
114
115pub const FONT_SCALE_RANGE: std::ops::RangeInclusive<f32> = 0.5..=3.0;
119
120const PAGE_WIDTH: f32 = 215.9;
122const PAGE_HEIGHT: f32 = 279.4;
123const MARGIN: f32 = 10.0;
124
125const HEADER_FONT_SIZE: f32 = 9.0;
127const TABLE_FONT_SIZE: f32 = 8.0;
128const FOOTER_FONT_SIZE: f32 = 8.0;
129
130const ROW_HEIGHT: f32 = 4.5;
132const COL_WIDTH: f32 = 24.0; const COLOR_BLACK: (f32, f32, f32) = (0.0, 0.0, 0.0);
136const COLOR_RED: (f32, f32, f32) = (0.78, 0.0, 0.0);
137const COLOR_GREEN: (f32, f32, f32) = (0.0, 0.5, 0.0);
138const COLOR_BLUE: (f32, f32, f32) = (0.0, 0.0, 0.78);
139const COLOR_STRIPE: (f32, f32, f32) = (0.94, 0.94, 0.94); const INHG_TO_HPA: f64 = 33.863_886_666_667;
141
142pub fn calculate_density_altitude(_altitude_ft: f64, pressure_inhg: f64, temp_f: f64) -> f64 {
164 let pressure_hpa = pressure_inhg * INHG_TO_HPA;
167 let pressure_alt = 145_366.45 * (1.0 - (pressure_hpa / 1013.25).powf(0.190_284));
168
169 let isa_temp_f = 59.0 - (pressure_alt / 1000.0) * 3.57;
171
172 pressure_alt + (120.0 * 5.0 / 9.0) * (temp_f - isa_temp_f)
175}
176
177fn find_font_file(font_name: &str) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
180 let ttf = format!("{}.ttf", font_name);
181
182 if let Ok(exe_path) = std::env::current_exe() {
184 if let Some(exe_dir) = exe_path.parent() {
185 let font_path = exe_dir.join("fonts").join(&ttf);
186 if font_path.exists() {
187 return Ok(std::fs::read(font_path)?);
188 }
189 }
190 }
191
192 if let Some(home) = dirs::home_dir() {
194 let font_path = home.join(".ballistics").join("fonts").join(&ttf);
195 if font_path.exists() {
196 return Ok(std::fs::read(font_path)?);
197 }
198 }
199
200 for prefix in &["./fonts", "../fonts"] {
202 let font_path = std::path::Path::new(prefix).join(&ttf);
203 if font_path.exists() {
204 return Ok(std::fs::read(font_path)?);
205 }
206 }
207
208 #[cfg(target_os = "linux")]
210 {
211 for dir in &["/usr/share/fonts", "/usr/local/share/fonts"] {
212 if let Some(path) = find_in_directory(dir, &ttf) {
213 return Ok(std::fs::read(path)?);
214 }
215 }
216 }
217
218 #[cfg(target_os = "macos")]
219 {
220 for dir in &["/Library/Fonts", "/System/Library/Fonts"] {
221 let font_path = std::path::Path::new(dir).join(&ttf);
222 if font_path.exists() {
223 return Ok(std::fs::read(font_path)?);
224 }
225 }
226 }
227
228 #[cfg(target_os = "windows")]
229 {
230 if let Ok(windir) = std::env::var("WINDIR") {
231 let font_path = std::path::Path::new(&windir).join("Fonts").join(&ttf);
232 if font_path.exists() {
233 return Ok(std::fs::read(font_path)?);
234 }
235 }
236 }
237
238 match font_name {
240 "LiberationSans-Regular" => Ok(FONT_REGULAR.to_vec()),
241 "LiberationSans-Bold" => Ok(FONT_BOLD.to_vec()),
242 _ => Err(format!("Font {} not found", font_name).into()),
243 }
244}
245
246#[cfg(target_os = "linux")]
248fn find_in_directory(dir: &str, filename: &str) -> Option<std::path::PathBuf> {
249 let dir_path = std::path::Path::new(dir);
250 if !dir_path.is_dir() {
251 return None;
252 }
253 for entry in std::fs::read_dir(dir_path).ok()?.flatten() {
254 let path = entry.path();
255 if path.is_file() && path.file_name().is_some_and(|n| n == filename) {
256 return Some(path);
257 }
258 if path.is_dir() {
259 if let Some(found) = find_in_directory(path.to_str()?, filename) {
260 return Some(found);
261 }
262 }
263 }
264 None
265}
266
267pub const UNPRINTABLE_SUBSTITUTE: char = '?';
277
278pub fn unprintable_chars(text: &str) -> String {
287 let Ok(bytes) = find_font_file("LiberationSans-Regular") else {
288 return String::new();
289 };
290 let mut warnings = Vec::new();
291 let Some(font) = ParsedFont::from_bytes(&bytes, 0, &mut warnings) else {
292 return String::new();
293 };
294 let mut out = String::new();
295 for c in text.chars() {
296 if font.lookup_glyph_index(c as u32).is_none() && !out.contains(c) {
297 out.push(c);
298 }
299 }
300 out
301}
302
303fn substitute_unprintable(font: &ParsedFont, text: &str) -> String {
305 text.chars()
306 .map(|c| {
307 if font.lookup_glyph_index(c as u32).is_none() {
308 UNPRINTABLE_SUBSTITUTE
309 } else {
310 c
311 }
312 })
313 .collect()
314}
315
316fn substituting_unprintable(config: &DopeCardConfig, font: &ParsedFont) -> DopeCardConfig {
322 let sub = |text: &str| substitute_unprintable(font, text);
323 DopeCardConfig {
324 rifle_name: sub(&config.rifle_name),
325 location: sub(&config.location),
326 solver_mode: sub(&config.solver_mode),
327 powder: sub(&config.powder),
328 bullet: sub(&config.bullet),
329 drag_model: sub(&config.drag_model),
330 elevation_unit_label: sub(&config.elevation_unit_label),
331 windage_unit_label: sub(&config.windage_unit_label),
332 engine_version: sub(&config.engine_version),
333 table_version: sub(&config.table_version),
334 ..config.clone()
335 }
336}
337
338fn truncate_for_header(s: &str, max_chars: usize) -> String {
340 if s.chars().count() <= max_chars {
344 s.to_string()
345 } else if max_chars <= 3 {
346 s.chars().take(max_chars).collect()
347 } else {
348 let head: String = s.chars().take(max_chars - 3).collect();
349 format!("{head}...")
350 }
351}
352
353fn draw_separator_line(ops: &mut Vec<Op>, y: f32) {
355 ops.push(Op::SetOutlineColor {
356 col: Color::Rgb(Rgb::new(0.7, 0.7, 0.7, None)),
357 });
358 ops.push(Op::SetOutlineThickness { pt: Pt(0.3) });
359 ops.push(Op::DrawLine {
360 line: Line {
361 points: vec![
362 LinePoint {
363 p: Point::new(Mm(MARGIN), Mm(y)),
364 bezier: false,
365 },
366 LinePoint {
367 p: Point::new(Mm(PAGE_WIDTH - MARGIN), Mm(y)),
368 bezier: false,
369 },
370 ],
371 is_closed: false,
372 },
373 });
374}
375
376pub fn dope_card_rows_per_page(font_scale: f32) -> usize {
386 let row_height = ROW_HEIGHT * font_scale.clamp(*FONT_SCALE_RANGE.start(), *FONT_SCALE_RANGE.end());
387 let usable_height = PAGE_HEIGHT - (2.0 * MARGIN) - 36.0;
389 let visual_rows_per_page = ((usable_height / row_height) as usize).clamp(1, 52);
393 visual_rows_per_page * 2
395}
396
397pub fn dope_card_page_count(row_count: usize, font_scale: f32) -> usize {
402 row_count.div_ceil(dope_card_rows_per_page(font_scale))
403}
404
405pub fn generate_dope_card_pdf(
411 config: &DopeCardConfig,
412 rows: &[CardRow],
413 range_unit: RangeUnit,
414) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
415 if rows.is_empty() {
416 return Err("generate_dope_card_pdf: rows must not be empty".into());
417 }
418 let mut doc = PdfDocument::new(&format!("{} Dope Card", config.rifle_name));
419
420 let font_data = find_font_file("LiberationSans-Regular")?;
422 let mut font_warnings = Vec::new();
423 let parsed_font = ParsedFont::from_bytes(&font_data, 0, &mut font_warnings)
424 .ok_or("Failed to parse LiberationSans-Regular font")?;
425 let font = doc.add_font(&parsed_font);
426
427 let font_bold_data = find_font_file("LiberationSans-Bold")?;
428 let mut font_bold_warnings = Vec::new();
429 let parsed_font_bold = ParsedFont::from_bytes(&font_bold_data, 0, &mut font_bold_warnings)
430 .ok_or("Failed to parse LiberationSans-Bold font")?;
431 let font_bold = doc.add_font(&parsed_font_bold);
432
433 let config = &substituting_unprintable(config, &parsed_font);
438
439 let font_scale = config
442 .font_scale
443 .clamp(*FONT_SCALE_RANGE.start(), *FONT_SCALE_RANGE.end());
444 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 data_rows_per_page = dope_card_rows_per_page(config.font_scale);
453 let total_pages = dope_card_page_count(rows.len(), config.font_scale);
454
455 let mut pages = Vec::with_capacity(total_pages);
456
457 for page_num in 0..total_pages {
458 let start_idx = page_num * data_rows_per_page;
459 let end_idx = std::cmp::min(start_idx + data_rows_per_page, rows.len());
460 let page_rows = &rows[start_idx..end_idx];
461
462 let mut ops = Vec::new();
463
464 render_page(
465 &mut ops,
466 &font,
467 &font_bold,
468 config,
469 page_rows,
470 range_unit,
471 page_num + 1,
472 total_pages,
473 header_size,
474 table_size,
475 footer_size,
476 row_height,
477 font_scale,
478 config.bold_data,
479 );
480
481 pages.push(PdfPage::new(Mm(PAGE_WIDTH), Mm(PAGE_HEIGHT), ops));
482 }
483
484 let mut save_warnings = Vec::new();
485 let bytes = doc
486 .with_pages(pages)
487 .save(&PdfSaveOptions::default(), &mut save_warnings);
488 Ok(bytes)
489}
490
491#[allow(clippy::too_many_arguments)] fn render_page(
493 ops: &mut Vec<Op>,
494 font: &FontId,
495 font_bold: &FontId,
496 config: &DopeCardConfig,
497 rows: &[CardRow],
498 range_unit: RangeUnit,
499 page: usize,
500 _total_pages: usize,
501 header_size: f32,
502 table_size: f32,
503 footer_size: f32,
504 row_height: f32,
505 font_scale: f32,
506 bold_data: bool,
507) {
508 let mut y = PAGE_HEIGHT - MARGIN;
509
510 let header1 = truncate_for_header(
512 &format!(
513 "{} Loc: {} DA:{:.0} ft Pressure:{:.2}/{:.0} Temp:{:.0} Alt:{:.0} Wind:{:.0} Mph",
514 config.rifle_name,
515 config.location,
516 config.density_altitude_ft,
517 config.pressure_inhg,
518 config.pressure_hpa,
519 config.temperature_f,
520 config.altitude_ft,
521 config.wind_speed_mph
522 ),
523 77,
524 );
525 draw_centered_text(ops, font, header_size, y, &header1, COLOR_BLACK);
526 y -= 4.0;
527
528 let header2 = format!(
530 "TargetSpeed:{:.0} Solver: {} - Pg {}",
531 config.target_speed_mph, config.solver_mode, page
532 );
533 draw_centered_text(ops, font, header_size, y, &header2, COLOR_BLACK);
534 y -= 1.0;
535
536 draw_separator_line(ops, y);
538 y -= 5.0;
539
540 let table_x = (PAGE_WIDTH - (8.0 * COL_WIDTH)) / 2.0;
542
543 draw_table_header(
545 ops,
546 font_bold,
547 table_x,
548 y,
549 table_size,
550 font_scale,
551 range_unit,
552 &config.elevation_unit_label,
553 &config.windage_unit_label,
554 );
555 y -= row_height;
556
557 let mid = rows.len().div_ceil(2);
559 let (left_rows, right_rows) = rows.split_at(mid);
560
561 let data_font = if bold_data { font_bold } else { font };
563
564 for (i, left) in left_rows.iter().enumerate() {
566 let right = right_rows.get(i);
567
568 if i % 2 == 1 {
570 draw_row_stripe(ops, table_x, y, 8.0 * COL_WIDTH, row_height);
571 }
572
573 draw_data_row(
575 ops, data_font, table_x, y, left, true, table_size, font_scale,
576 &config.elevation_unit_label, &config.windage_unit_label,
577 );
578
579 if let Some(r) = right {
581 draw_data_row(
582 ops,
583 data_font,
584 table_x + 4.0 * COL_WIDTH,
585 y,
586 r,
587 false,
588 table_size,
589 font_scale,
590 &config.elevation_unit_label,
591 &config.windage_unit_label,
592 );
593 }
594
595 y -= row_height;
596 }
597
598 draw_separator_line(ops, y - 1.0);
600 y -= 5.0;
601
602 let footer1 = format!(
604 "Powder:{} Bullet:{} Weight:{:.0}gr BC:{:.3} ({}) Vel:{:.0}fps",
605 config.powder,
606 config.bullet,
607 config.weight_gr,
608 config.bc,
609 config.drag_model.to_lowercase(),
610 config.velocity_fps,
611 );
612 draw_centered_text(ops, font, footer_size, y, &footer1, COLOR_BLACK);
613 y -= 4.0;
614
615 let mut footer2 = get_timestamp();
619 if !config.engine_version.is_empty() {
620 footer2.push_str(&format!(
621 " Engine:{}",
622 truncate_for_header(&config.engine_version, 24)
623 ));
624 }
625 if !config.table_version.is_empty() {
626 footer2.push_str(&format!(
627 " Table:{}",
628 truncate_for_header(&config.table_version, 24)
629 ));
630 }
631 draw_centered_text(ops, font, footer_size, y, &footer2, COLOR_BLACK);
632}
633
634fn draw_row_stripe(ops: &mut Vec<Op>, x: f32, y: f32, width: f32, height: f32) {
635 let points = vec![
636 LinePoint {
637 p: Point::new(Mm(x), Mm(y)),
638 bezier: false,
639 },
640 LinePoint {
641 p: Point::new(Mm(x + width), Mm(y)),
642 bezier: false,
643 },
644 LinePoint {
645 p: Point::new(Mm(x + width), Mm(y - height)),
646 bezier: false,
647 },
648 LinePoint {
649 p: Point::new(Mm(x), Mm(y - height)),
650 bezier: false,
651 },
652 ];
653
654 ops.push(Op::SetFillColor {
655 col: Color::Rgb(Rgb::new(
656 COLOR_STRIPE.0,
657 COLOR_STRIPE.1,
658 COLOR_STRIPE.2,
659 None,
660 )),
661 });
662 ops.push(Op::DrawPolygon {
663 polygon: Polygon {
664 rings: vec![PolygonRing { points }],
665 mode: PaintMode::Fill,
666 winding_order: WindingOrder::NonZero,
667 },
668 });
669}
670
671#[allow(clippy::too_many_arguments)] fn draw_table_header(
673 ops: &mut Vec<Op>,
674 font: &FontId,
675 x: f32,
676 y: f32,
677 table_size: f32,
678 font_scale: f32,
679 range_unit: RangeUnit,
680 elevation_unit: &str,
681 windage_unit: &str,
682) {
683 let headers = [
684 ("Range", COLOR_BLACK),
685 ("Drop", COLOR_RED),
686 ("Wind", COLOR_GREEN),
687 ("Lead", COLOR_BLUE),
688 ("Range", COLOR_BLACK),
689 ("Drop", COLOR_RED),
690 ("Wind", COLOR_GREEN),
691 ("Lead", COLOR_BLUE),
692 ];
693 let range_label = range_unit.label();
696 let sub_headers = [
697 range_label,
698 elevation_unit,
699 windage_unit,
700 windage_unit,
701 range_label,
702 elevation_unit,
703 windage_unit,
704 windage_unit,
705 ];
706
707 for (i, ((header, color), sub)) in headers.iter().zip(sub_headers.iter()).enumerate() {
708 let col_x = x + (i as f32 * COL_WIDTH) + (COL_WIDTH / 2.0);
709 draw_text(ops, font, table_size, col_x, y, header, *color, true);
710 draw_text(
711 ops,
712 font,
713 table_size - 1.0,
714 col_x,
715 y - 3.0 * font_scale,
716 sub,
717 *color,
718 true,
719 );
720 }
721}
722
723fn format_adjustment(value: f64, unit_label: &str) -> String {
742 if unit_label.eq_ignore_ascii_case("clicks") {
743 format!("{:.0}", value)
744 } else {
745 format!("{:.1}", value)
746 }
747}
748
749fn format_adjustment_cell(value: Option<f64>, unit_label: &str) -> String {
758 match value {
759 Some(v) => format_adjustment(v, unit_label),
760 None => "—".to_string(),
761 }
762}
763
764fn format_range(range: f64) -> String {
776 let rounded = range.round();
777 if (range - rounded).abs() < 0.05 {
778 format!("{:.0}", rounded)
779 } else {
780 format!("{:.1}", range)
781 }
782}
783
784#[allow(clippy::too_many_arguments)] fn draw_data_row(
786 ops: &mut Vec<Op>,
787 font: &FontId,
788 x: f32,
789 y: f32,
790 row: &CardRow,
791 _is_left: bool,
792 table_size: f32,
793 font_scale: f32,
794 elevation_unit: &str,
795 windage_unit: &str,
796) {
797 let values = [
798 (format_range(row.range), COLOR_BLACK),
799 (format_adjustment_cell(row.drop_adj, elevation_unit), COLOR_RED),
800 (format_adjustment_cell(row.wind_adj, windage_unit), COLOR_GREEN),
801 (format_adjustment_cell(row.lead_adj, windage_unit), COLOR_BLUE),
802 ];
803
804 for (i, (value, color)) in values.iter().enumerate() {
805 let col_x = x + (i as f32 * COL_WIDTH) + (COL_WIDTH / 2.0);
806 draw_text(
807 ops,
808 font,
809 table_size,
810 col_x,
811 y - 2.5 * font_scale,
812 value,
813 *color,
814 true,
815 );
816 }
817}
818
819fn draw_text_ops(
823 ops: &mut Vec<Op>,
824 font: &FontId,
825 size: f32,
826 x: f32,
827 y: f32,
828 text: &str,
829 color: (f32, f32, f32),
830) {
831 ops.push(Op::StartTextSection);
832 ops.push(Op::SetFillColor {
833 col: Color::Rgb(Rgb::new(color.0, color.1, color.2, None)),
834 });
835 ops.push(Op::SetFont {
836 font: PdfFontHandle::External(font.clone()),
837 size: Pt(size),
838 });
839 ops.push(Op::SetLineHeight { lh: Pt(size) });
840 ops.push(Op::SetTextCursor {
841 pos: Point::new(Mm(x), Mm(y)),
842 });
843 ops.push(Op::ShowText {
844 items: vec![TextItem::Text(text.to_string())],
845 });
846 ops.push(Op::EndTextSection);
847}
848
849#[allow(clippy::too_many_arguments)] fn draw_text(
851 ops: &mut Vec<Op>,
852 font: &FontId,
853 size: f32,
854 x: f32,
855 y: f32,
856 text: &str,
857 color: (f32, f32, f32),
858 center: bool,
859) {
860 let text_width = if center {
864 text.len() as f32 * size * 0.3 } else {
866 0.0
867 };
868
869 draw_text_ops(ops, font, size, x - text_width / 2.0, y, text, color);
870}
871
872fn draw_centered_text(
873 ops: &mut Vec<Op>,
874 font: &FontId,
875 size: f32,
876 y: f32,
877 text: &str,
878 color: (f32, f32, f32),
879) {
880 let text_width = text.len() as f32 * size * 0.28;
882 let x = (PAGE_WIDTH - text_width) / 2.0;
883
884 draw_text_ops(ops, font, size, x, y, text, color);
885}
886
887fn get_timestamp() -> String {
888 use std::time::{SystemTime, UNIX_EPOCH};
889
890 let now = SystemTime::now()
891 .duration_since(UNIX_EPOCH)
892 .unwrap_or_default()
893 .as_secs();
894
895 let secs_per_day = 86400u64;
896 let secs_per_hour = 3600u64;
897 let secs_per_min = 60u64;
898
899 let days_since_epoch = now / secs_per_day;
900 let time_of_day = now % secs_per_day;
901
902 let hours = time_of_day / secs_per_hour;
903 let minutes = (time_of_day % secs_per_hour) / secs_per_min;
904 let seconds = time_of_day % secs_per_min;
905
906 let mut year = 1970;
907 let mut remaining_days = days_since_epoch as i64;
908
909 loop {
910 let days_in_year = if is_leap_year(year) { 366 } else { 365 };
911 if remaining_days < days_in_year {
912 break;
913 }
914 remaining_days -= days_in_year;
915 year += 1;
916 }
917
918 let days_in_months: [i64; 12] = if is_leap_year(year) {
919 [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
920 } else {
921 [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
922 };
923
924 let month_names = [
925 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
926 ];
927 let day_names = ["Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed"];
928
929 let mut month = 0;
930 for (i, &days) in days_in_months.iter().enumerate() {
931 if remaining_days < days {
932 month = i;
933 break;
934 }
935 remaining_days -= days;
936 }
937
938 let day = remaining_days + 1;
939 let day_of_week = (days_since_epoch % 7) as usize;
941
942 let (hour_12, am_pm) = if hours == 0 {
943 (12, "AM")
944 } else if hours < 12 {
945 (hours, "AM")
946 } else if hours == 12 {
947 (12, "PM")
948 } else {
949 (hours - 12, "PM")
950 };
951
952 format!(
953 "{} {} {:02} {:02}:{:02}:{:02} {} UTC {}",
954 day_names[day_of_week], month_names[month], day, hour_12, minutes, seconds, am_pm, year
955 )
956}
957
958fn is_leap_year(year: i64) -> bool {
959 (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
960}
961
962#[cfg(test)]
963mod tests {
964 use super::*;
965
966 #[test]
967 fn test_density_altitude() {
968 let da = calculate_density_altitude(2500.0, 27.32, 55.0);
972 assert!(
973 da > 2500.0 && da < 3500.0,
974 "DA should be ~3000 ft for near-standard conditions, got {}",
975 da
976 );
977
978 let da_hot = calculate_density_altitude(2500.0, 27.32, 95.0);
980 assert!(da_hot > da, "Higher temp should increase DA");
981
982 let da_low_press = calculate_density_altitude(2500.0, 25.0, 55.0);
984 assert!(da_low_press > da, "Lower pressure should increase DA");
985
986 let da_standard = calculate_density_altitude(0.0, 29.92, 59.0);
988 assert!(
989 da_standard.abs() < 100.0,
990 "Standard conditions should give DA near 0, got {}",
991 da_standard
992 );
993 }
994
995 #[test]
999 fn format_adjustment_drops_the_decimal_for_clicks_only() {
1000 assert_eq!(format_adjustment(5.0, "CLICKS"), "5");
1001 assert_eq!(format_adjustment(-3.0, "CLICKS"), "-3");
1002 assert_eq!(format_adjustment(5.0, "clicks"), "5", "case-insensitive");
1003 assert_eq!(format_adjustment(2.34, "MIL"), "2.3");
1004 assert_eq!(format_adjustment(2.34, "MOA"), "2.3");
1005 assert_eq!(format_adjustment(2.34, "SMOA"), "2.3");
1006 assert_eq!(format_adjustment(2.34, "IPHY"), "2.3");
1007 }
1008
1009 #[test]
1018 fn format_adjustment_pins_one_decimal_place_including_the_near_ties() {
1019 for (value, expected) in [
1020 (0.0_f64, "0.0"),
1021 (0.65, "0.7"),
1022 (1.4551, "1.5"),
1023 (2.4478, "2.4"),
1024 (3.575, "3.6"),
1025 (4.8412, "4.8"),
1026 (-0.105, "-0.1"),
1027 (-0.21, "-0.2"),
1028 (-0.315, "-0.3"),
1029 (-0.42, "-0.4"),
1030 (-0.55, "-0.6"),
1031 (-0.65, "-0.7"),
1032 (-0.75, "-0.8"),
1033 (6.25, "6.2"),
1034 (7.35, "7.3"),
1035 (0.15, "0.1"),
1036 (2.35, "2.4"),
1037 ] {
1038 assert_eq!(format_adjustment(value, "MIL"), expected, "{value:?}");
1039 assert_eq!(format_adjustment(value, "MOA"), expected, "{value:?}");
1040 }
1041 }
1042
1043 #[test]
1048 fn format_adjustment_cell_renders_none_as_an_em_dash_not_a_fake_zero() {
1049 assert_eq!(format_adjustment_cell(Some(2.34), "MIL"), "2.3");
1050 assert_eq!(format_adjustment_cell(Some(5.0), "CLICKS"), "5");
1051 assert_eq!(format_adjustment_cell(None, "MIL"), "—");
1052 assert_eq!(format_adjustment_cell(None, "CLICKS"), "—");
1053 }
1054
1055 #[test]
1056 fn density_altitude_uses_published_nws_pressure_altitude_equation() {
1057 let pressure_inhg = 20.670_988_150_011_322;
1060 let density_altitude = calculate_density_altitude(0.0, pressure_inhg, 55.0);
1061
1062 assert!((density_altitude - 11_962.774_017_764_264).abs() < 1e-6);
1063
1064 let standard_pressure_inhg = 29.921_255_347_141_39;
1065 let standard_density_altitude =
1066 calculate_density_altitude(0.0, standard_pressure_inhg, 59.0);
1067 assert!(standard_density_altitude.abs() < 1e-9);
1068 }
1069
1070 #[test]
1079 fn format_range_pinned_examples() {
1080 assert_eq!(format_range(400.0), "400");
1081 assert_eq!(format_range(417.3), "417.3");
1082 }
1083
1084 #[test]
1089 fn format_range_absorbs_floating_point_noise_around_a_whole_number() {
1090 assert_eq!(format_range(400.02), "400");
1091 assert_eq!(format_range(399.98), "400");
1092 assert_eq!(format_range(100.0 + 1e-9), "100");
1093 assert_eq!(format_range(400.04), "400");
1097 assert_eq!(format_range(399.96), "400");
1098 assert_eq!(format_range(0.0), "0");
1099 }
1100
1101 #[test]
1102 fn format_range_renders_one_decimal_outside_the_noise_band() {
1103 assert_eq!(format_range(417.3), "417.3");
1104 assert_eq!(format_range(100.5), "100.5");
1105 assert_eq!(format_range(400.08), "400.1");
1106 assert_eq!(format_range(400.05), "400.1");
1110 }
1111
1112 fn test_config() -> DopeCardConfig {
1113 DopeCardConfig {
1114 rifle_name: "Test Rifle".to_string(),
1115 location: "Test Range".to_string(),
1116 density_altitude_ft: 1500.0,
1117 pressure_inhg: 29.92,
1118 pressure_hpa: 1013.25,
1119 temperature_f: 59.0,
1120 altitude_ft: 1000.0,
1121 wind_speed_mph: 5.0,
1122 target_speed_mph: 0.0,
1123 solver_mode: "offline".to_string(),
1124 powder: "Test Powder".to_string(),
1125 bullet: "Test Bullet".to_string(),
1126 weight_gr: 175.0,
1127 bc: 0.5,
1128 drag_model: "g7".to_string(),
1129 velocity_fps: 2700.0,
1130 font_scale: 1.0,
1131 bold_data: false,
1132 elevation_unit_label: "MIL".to_string(),
1133 windage_unit_label: "MIL".to_string(),
1134 engine_version: "0.0.0-test".to_string(),
1135 table_version: String::new(),
1136 }
1137 }
1138
1139 fn test_row(range: f64) -> CardRow {
1140 CardRow {
1141 range,
1142 drop_linear: None,
1143 drop_adj: Some(1.0),
1144 come_up: None,
1145 wind_linear: None,
1146 wind_adj: Some(0.5),
1147 velocity: None,
1148 energy: None,
1149 time: None,
1150 lead_adj: Some(0.2),
1151 wind_columns: Vec::new(),
1152 }
1153 }
1154
1155 fn assert_valid_pdf_bytes(bytes: &[u8]) {
1156 assert!(!bytes.is_empty(), "PDF output must not be empty");
1157 assert_eq!(&bytes[..5], b"%PDF-", "output must start with a PDF header");
1158 }
1159
1160 #[test]
1161 fn generate_dope_card_pdf_is_non_empty_for_a_single_row() {
1162 let config = test_config();
1163 let rows = vec![test_row(100.0)];
1164 let bytes = generate_dope_card_pdf(&config, &rows, RangeUnit::Yards)
1165 .expect("single-row dope card should generate");
1166 assert_valid_pdf_bytes(&bytes);
1167 }
1168
1169 #[test]
1173 fn generate_dope_card_pdf_is_non_empty_for_120_rows_pagination() {
1174 let config = test_config();
1175 let rows: Vec<CardRow> = (0..120)
1176 .map(|i| test_row(100.0 + i as f64 * 25.0))
1177 .collect();
1178 let bytes = generate_dope_card_pdf(&config, &rows, RangeUnit::Meters)
1179 .expect("120-row dope card should generate and paginate");
1180 assert_valid_pdf_bytes(&bytes);
1181 }
1182
1183 #[test]
1190 fn generate_dope_card_pdf_succeeds_when_lead_adj_is_none() {
1191 let config = test_config();
1192 let mut row = test_row(100.0);
1193 row.lead_adj = None;
1194 let bytes = generate_dope_card_pdf(&config, &[row], RangeUnit::Yards)
1195 .expect("a row missing lead_adj must still render, not error");
1196 assert_valid_pdf_bytes(&bytes);
1197 }
1198
1199 #[test]
1204 fn generate_dope_card_pdf_rejects_empty_rows() {
1205 let config = test_config();
1206 let err = generate_dope_card_pdf(&config, &[], RangeUnit::Yards)
1207 .expect_err("an empty row set must not silently produce a PDF");
1208 assert!(err.to_string().contains("rows"), "{err}");
1209 }
1210
1211 #[test]
1216 fn dope_card_rows_per_page_pins_the_preset_scales() {
1217 assert_eq!(dope_card_rows_per_page(FontSizePreset::Small.scale()), 104);
1223 assert_eq!(dope_card_rows_per_page(FontSizePreset::Medium.scale()), 98);
1224 assert_eq!(dope_card_rows_per_page(FontSizePreset::Large.scale()), 70);
1225 assert_eq!(dope_card_rows_per_page(0.5), 104);
1226 assert_eq!(dope_card_rows_per_page(0.01), dope_card_rows_per_page(0.5));
1228 assert_eq!(dope_card_rows_per_page(99.0), dope_card_rows_per_page(3.0));
1229 }
1230
1231 #[test]
1232 fn dope_card_page_count_rounds_up_and_reports_zero_for_no_rows() {
1233 let per_page = dope_card_rows_per_page(1.0);
1234 assert_eq!(dope_card_page_count(0, 1.0), 0);
1235 assert_eq!(dope_card_page_count(1, 1.0), 1);
1236 assert_eq!(dope_card_page_count(per_page, 1.0), 1);
1237 assert_eq!(dope_card_page_count(per_page + 1, 1.0), 2);
1238 assert_eq!(dope_card_page_count(per_page * 3, 1.0), 3);
1239 }
1240
1241 #[test]
1245 fn generated_pdf_grows_when_page_count_grows() {
1246 let config = test_config();
1247 let per_page = dope_card_rows_per_page(config.font_scale);
1248 let rows: Vec<CardRow> = (0..per_page).map(|i| test_row(100.0 + i as f64)).collect();
1249 let one_page = generate_dope_card_pdf(&config, &rows, RangeUnit::Yards).unwrap();
1250 assert_eq!(dope_card_page_count(rows.len(), config.font_scale), 1);
1251
1252 let mut rows_plus_one = rows.clone();
1253 rows_plus_one.push(test_row(100.0 + per_page as f64));
1254 let two_pages = generate_dope_card_pdf(&config, &rows_plus_one, RangeUnit::Yards).unwrap();
1255 assert_eq!(
1256 dope_card_page_count(rows_plus_one.len(), config.font_scale),
1257 2
1258 );
1259 assert!(
1260 two_pages.len() > one_page.len(),
1261 "a second page must add bytes: {} vs {}",
1262 one_page.len(),
1263 two_pages.len()
1264 );
1265 }
1266
1267 #[test]
1270 fn range_unit_label_matches_its_variant() {
1271 assert_eq!(RangeUnit::Yards.label(), "Yd");
1272 assert_eq!(RangeUnit::Meters.label(), "M");
1273 }
1274}