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 pub truncation_note: String,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq)]
80pub enum FontSizePreset {
81 Small,
82 Medium,
83 Large,
84}
85
86impl FontSizePreset {
87 pub fn scale(&self) -> f32 {
88 match self {
89 Self::Small => 0.8,
90 Self::Medium => 1.0,
91 Self::Large => 1.4,
92 }
93 }
94
95 #[allow(clippy::should_implement_trait)]
101 pub fn from_str(s: &str) -> Option<Self> {
102 match s.to_lowercase().as_str() {
103 "small" | "s" => Some(Self::Small),
104 "medium" | "m" => Some(Self::Medium),
105 "large" | "l" => Some(Self::Large),
106 _ => None,
107 }
108 }
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum RangeUnit {
116 Yards,
117 Meters,
118}
119
120impl RangeUnit {
121 fn label(&self) -> &'static str {
122 match self {
123 RangeUnit::Yards => "Yd",
124 RangeUnit::Meters => "M",
125 }
126 }
127}
128
129pub const FONT_SCALE_RANGE: std::ops::RangeInclusive<f32> = 0.5..=3.0;
133
134const PAGE_WIDTH: f32 = 215.9;
136const PAGE_HEIGHT: f32 = 279.4;
137const MARGIN: f32 = 10.0;
138
139const HEADER_FONT_SIZE: f32 = 9.0;
141const TABLE_FONT_SIZE: f32 = 8.0;
142const FOOTER_FONT_SIZE: f32 = 8.0;
143const FOOTER_NOTE_MAX_CHARS: usize = 80;
151
152const ROW_HEIGHT: f32 = 4.5;
154const COL_WIDTH: f32 = 24.0; const COLOR_BLACK: (f32, f32, f32) = (0.0, 0.0, 0.0);
158const COLOR_RED: (f32, f32, f32) = (0.78, 0.0, 0.0);
159const COLOR_GREEN: (f32, f32, f32) = (0.0, 0.5, 0.0);
160const COLOR_BLUE: (f32, f32, f32) = (0.0, 0.0, 0.78);
161const COLOR_STRIPE: (f32, f32, f32) = (0.94, 0.94, 0.94); const INHG_TO_HPA: f64 = 33.863_886_666_667;
163
164pub fn calculate_density_altitude(_altitude_ft: f64, pressure_inhg: f64, temp_f: f64) -> f64 {
186 let pressure_hpa = pressure_inhg * INHG_TO_HPA;
189 let pressure_alt = 145_366.45 * (1.0 - (pressure_hpa / 1013.25).powf(0.190_284));
190
191 let isa_temp_f = 59.0 - (pressure_alt / 1000.0) * 3.57;
193
194 pressure_alt + (120.0 * 5.0 / 9.0) * (temp_f - isa_temp_f)
197}
198
199fn find_font_file(font_name: &str) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
202 let ttf = format!("{}.ttf", font_name);
203
204 if let Ok(exe_path) = std::env::current_exe() {
206 if let Some(exe_dir) = exe_path.parent() {
207 let font_path = exe_dir.join("fonts").join(&ttf);
208 if font_path.exists() {
209 return Ok(std::fs::read(font_path)?);
210 }
211 }
212 }
213
214 if let Some(home) = dirs::home_dir() {
216 let font_path = home.join(".ballistics").join("fonts").join(&ttf);
217 if font_path.exists() {
218 return Ok(std::fs::read(font_path)?);
219 }
220 }
221
222 for prefix in &["./fonts", "../fonts"] {
224 let font_path = std::path::Path::new(prefix).join(&ttf);
225 if font_path.exists() {
226 return Ok(std::fs::read(font_path)?);
227 }
228 }
229
230 #[cfg(target_os = "linux")]
232 {
233 for dir in &["/usr/share/fonts", "/usr/local/share/fonts"] {
234 if let Some(path) = find_in_directory(dir, &ttf) {
235 return Ok(std::fs::read(path)?);
236 }
237 }
238 }
239
240 #[cfg(target_os = "macos")]
241 {
242 for dir in &["/Library/Fonts", "/System/Library/Fonts"] {
243 let font_path = std::path::Path::new(dir).join(&ttf);
244 if font_path.exists() {
245 return Ok(std::fs::read(font_path)?);
246 }
247 }
248 }
249
250 #[cfg(target_os = "windows")]
251 {
252 if let Ok(windir) = std::env::var("WINDIR") {
253 let font_path = std::path::Path::new(&windir).join("Fonts").join(&ttf);
254 if font_path.exists() {
255 return Ok(std::fs::read(font_path)?);
256 }
257 }
258 }
259
260 match font_name {
262 "LiberationSans-Regular" => Ok(FONT_REGULAR.to_vec()),
263 "LiberationSans-Bold" => Ok(FONT_BOLD.to_vec()),
264 _ => Err(format!("Font {} not found", font_name).into()),
265 }
266}
267
268#[cfg(target_os = "linux")]
270fn find_in_directory(dir: &str, filename: &str) -> Option<std::path::PathBuf> {
271 let dir_path = std::path::Path::new(dir);
272 if !dir_path.is_dir() {
273 return None;
274 }
275 for entry in std::fs::read_dir(dir_path).ok()?.flatten() {
276 let path = entry.path();
277 if path.is_file() && path.file_name().is_some_and(|n| n == filename) {
278 return Some(path);
279 }
280 if path.is_dir() {
281 if let Some(found) = find_in_directory(path.to_str()?, filename) {
282 return Some(found);
283 }
284 }
285 }
286 None
287}
288
289pub const UNPRINTABLE_SUBSTITUTE: char = '?';
299
300pub fn unprintable_chars(text: &str) -> String {
309 let Ok(bytes) = find_font_file("LiberationSans-Regular") else {
310 return String::new();
311 };
312 let mut warnings = Vec::new();
313 let Some(font) = ParsedFont::from_bytes(&bytes, 0, &mut warnings) else {
314 return String::new();
315 };
316 let mut out = String::new();
317 for c in text.chars() {
318 if font.lookup_glyph_index(c as u32).is_none() && !out.contains(c) {
319 out.push(c);
320 }
321 }
322 out
323}
324
325fn substitute_unprintable(font: &ParsedFont, text: &str) -> String {
327 text.chars()
328 .map(|c| {
329 if font.lookup_glyph_index(c as u32).is_none() {
330 UNPRINTABLE_SUBSTITUTE
331 } else {
332 c
333 }
334 })
335 .collect()
336}
337
338fn substituting_unprintable(config: &DopeCardConfig, font: &ParsedFont) -> DopeCardConfig {
344 let sub = |text: &str| substitute_unprintable(font, text);
345 DopeCardConfig {
346 rifle_name: sub(&config.rifle_name),
347 location: sub(&config.location),
348 solver_mode: sub(&config.solver_mode),
349 powder: sub(&config.powder),
350 bullet: sub(&config.bullet),
351 drag_model: sub(&config.drag_model),
352 elevation_unit_label: sub(&config.elevation_unit_label),
353 windage_unit_label: sub(&config.windage_unit_label),
354 engine_version: sub(&config.engine_version),
355 table_version: sub(&config.table_version),
356 truncation_note: sub(&config.truncation_note),
357 ..config.clone()
358 }
359}
360
361fn truncate_for_header(s: &str, max_chars: usize) -> String {
363 if s.chars().count() <= max_chars {
367 s.to_string()
368 } else if max_chars <= 3 {
369 s.chars().take(max_chars).collect()
370 } else {
371 let head: String = s.chars().take(max_chars - 3).collect();
372 format!("{head}...")
373 }
374}
375
376fn draw_separator_line(ops: &mut Vec<Op>, y: f32) {
378 ops.push(Op::SetOutlineColor {
379 col: Color::Rgb(Rgb::new(0.7, 0.7, 0.7, None)),
380 });
381 ops.push(Op::SetOutlineThickness { pt: Pt(0.3) });
382 ops.push(Op::DrawLine {
383 line: Line {
384 points: vec![
385 LinePoint {
386 p: Point::new(Mm(MARGIN), Mm(y)),
387 bezier: false,
388 },
389 LinePoint {
390 p: Point::new(Mm(PAGE_WIDTH - MARGIN), Mm(y)),
391 bezier: false,
392 },
393 ],
394 is_closed: false,
395 },
396 });
397}
398
399pub fn dope_card_rows_per_page(font_scale: f32) -> usize {
409 let row_height =
410 ROW_HEIGHT * font_scale.clamp(*FONT_SCALE_RANGE.start(), *FONT_SCALE_RANGE.end());
411 let usable_height = PAGE_HEIGHT - (2.0 * MARGIN) - 36.0;
413 let visual_rows_per_page = ((usable_height / row_height) as usize).clamp(1, 52);
417 visual_rows_per_page * 2
419}
420
421pub fn dope_card_page_count(row_count: usize, font_scale: f32) -> usize {
426 row_count.div_ceil(dope_card_rows_per_page(font_scale))
427}
428
429pub fn generate_dope_card_pdf(
435 config: &DopeCardConfig,
436 rows: &[CardRow],
437 range_unit: RangeUnit,
438) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
439 if rows.is_empty() {
440 return Err("generate_dope_card_pdf: rows must not be empty".into());
441 }
442 let mut doc = PdfDocument::new(&format!("{} Dope Card", config.rifle_name));
443
444 let font_data = find_font_file("LiberationSans-Regular")?;
446 let mut font_warnings = Vec::new();
447 let parsed_font = ParsedFont::from_bytes(&font_data, 0, &mut font_warnings)
448 .ok_or("Failed to parse LiberationSans-Regular font")?;
449 let font = doc.add_font(&parsed_font);
450
451 let font_bold_data = find_font_file("LiberationSans-Bold")?;
452 let mut font_bold_warnings = Vec::new();
453 let parsed_font_bold = ParsedFont::from_bytes(&font_bold_data, 0, &mut font_bold_warnings)
454 .ok_or("Failed to parse LiberationSans-Bold font")?;
455 let font_bold = doc.add_font(&parsed_font_bold);
456
457 let config = &substituting_unprintable(config, &parsed_font);
462
463 let font_scale = config
466 .font_scale
467 .clamp(*FONT_SCALE_RANGE.start(), *FONT_SCALE_RANGE.end());
468 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);
477 let total_pages = dope_card_page_count(rows.len(), config.font_scale);
478
479 let mut pages = Vec::with_capacity(total_pages);
480
481 for page_num in 0..total_pages {
482 let start_idx = page_num * data_rows_per_page;
483 let end_idx = std::cmp::min(start_idx + data_rows_per_page, rows.len());
484 let page_rows = &rows[start_idx..end_idx];
485
486 let mut ops = Vec::new();
487
488 render_page(
489 &mut ops,
490 &font,
491 &font_bold,
492 config,
493 page_rows,
494 range_unit,
495 page_num + 1,
496 total_pages,
497 header_size,
498 table_size,
499 footer_size,
500 row_height,
501 font_scale,
502 config.bold_data,
503 );
504
505 pages.push(PdfPage::new(Mm(PAGE_WIDTH), Mm(PAGE_HEIGHT), ops));
506 }
507
508 let mut save_warnings = Vec::new();
509 let bytes = doc
510 .with_pages(pages)
511 .save(&PdfSaveOptions::default(), &mut save_warnings);
512 Ok(bytes)
513}
514
515#[allow(clippy::too_many_arguments)] fn render_page(
517 ops: &mut Vec<Op>,
518 font: &FontId,
519 font_bold: &FontId,
520 config: &DopeCardConfig,
521 rows: &[CardRow],
522 range_unit: RangeUnit,
523 page: usize,
524 _total_pages: usize,
525 header_size: f32,
526 table_size: f32,
527 footer_size: f32,
528 row_height: f32,
529 font_scale: f32,
530 bold_data: bool,
531) {
532 let mut y = PAGE_HEIGHT - MARGIN;
533
534 let header1 = truncate_for_header(
536 &format!(
537 "{} Loc: {} DA:{:.0} ft Pressure:{:.2}/{:.0} Temp:{:.0} Alt:{:.0} Wind:{:.0} Mph",
538 config.rifle_name,
539 config.location,
540 config.density_altitude_ft,
541 config.pressure_inhg,
542 config.pressure_hpa,
543 config.temperature_f,
544 config.altitude_ft,
545 config.wind_speed_mph
546 ),
547 77,
548 );
549 draw_centered_text(ops, font, header_size, y, &header1, COLOR_BLACK);
550 y -= 4.0;
551
552 let header2 = format!(
554 "TargetSpeed:{:.0} Solver: {} - Pg {}",
555 config.target_speed_mph, config.solver_mode, page
556 );
557 draw_centered_text(ops, font, header_size, y, &header2, COLOR_BLACK);
558 y -= 1.0;
559
560 draw_separator_line(ops, y);
562 y -= 5.0;
563
564 let table_x = (PAGE_WIDTH - (8.0 * COL_WIDTH)) / 2.0;
566
567 draw_table_header(
569 ops,
570 font_bold,
571 table_x,
572 y,
573 table_size,
574 font_scale,
575 range_unit,
576 &config.elevation_unit_label,
577 &config.windage_unit_label,
578 );
579 y -= row_height;
580
581 let mid = rows.len().div_ceil(2);
583 let (left_rows, right_rows) = rows.split_at(mid);
584
585 let data_font = if bold_data { font_bold } else { font };
587
588 for (i, left) in left_rows.iter().enumerate() {
590 let right = right_rows.get(i);
591
592 if i % 2 == 1 {
594 draw_row_stripe(ops, table_x, y, 8.0 * COL_WIDTH, row_height);
595 }
596
597 draw_data_row(
599 ops,
600 data_font,
601 table_x,
602 y,
603 left,
604 true,
605 table_size,
606 font_scale,
607 &config.elevation_unit_label,
608 &config.windage_unit_label,
609 );
610
611 if let Some(r) = right {
613 draw_data_row(
614 ops,
615 data_font,
616 table_x + 4.0 * COL_WIDTH,
617 y,
618 r,
619 false,
620 table_size,
621 font_scale,
622 &config.elevation_unit_label,
623 &config.windage_unit_label,
624 );
625 }
626
627 y -= row_height;
628 }
629
630 draw_separator_line(ops, y - 1.0);
632 y -= 5.0;
633
634 let footer1 = format!(
636 "Powder:{} Bullet:{} Weight:{:.0}gr BC:{:.3} ({}) Vel:{:.0}fps",
637 config.powder,
638 config.bullet,
639 config.weight_gr,
640 config.bc,
641 config.drag_model.to_lowercase(),
642 config.velocity_fps,
643 );
644 draw_centered_text(ops, font, footer_size, y, &footer1, COLOR_BLACK);
645 y -= 4.0;
646
647 let mut footer2 = get_timestamp();
651 if !config.engine_version.is_empty() {
652 footer2.push_str(&format!(
653 " Engine:{}",
654 truncate_for_header(&config.engine_version, 24)
655 ));
656 }
657 if !config.table_version.is_empty() {
658 footer2.push_str(&format!(
659 " Table:{}",
660 truncate_for_header(&config.table_version, 24)
661 ));
662 }
663 draw_centered_text(ops, font, footer_size, y, &footer2, COLOR_BLACK);
664
665 if !config.truncation_note.is_empty() {
677 y -= 4.0;
678 draw_centered_text(
679 ops,
680 font_bold,
681 footer_size,
682 y,
683 &truncate_for_header(&config.truncation_note, FOOTER_NOTE_MAX_CHARS),
684 COLOR_RED,
685 );
686 }
687}
688
689fn draw_row_stripe(ops: &mut Vec<Op>, x: f32, y: f32, width: f32, height: f32) {
690 let points = vec![
691 LinePoint {
692 p: Point::new(Mm(x), Mm(y)),
693 bezier: false,
694 },
695 LinePoint {
696 p: Point::new(Mm(x + width), Mm(y)),
697 bezier: false,
698 },
699 LinePoint {
700 p: Point::new(Mm(x + width), Mm(y - height)),
701 bezier: false,
702 },
703 LinePoint {
704 p: Point::new(Mm(x), Mm(y - height)),
705 bezier: false,
706 },
707 ];
708
709 ops.push(Op::SetFillColor {
710 col: Color::Rgb(Rgb::new(
711 COLOR_STRIPE.0,
712 COLOR_STRIPE.1,
713 COLOR_STRIPE.2,
714 None,
715 )),
716 });
717 ops.push(Op::DrawPolygon {
718 polygon: Polygon {
719 rings: vec![PolygonRing { points }],
720 mode: PaintMode::Fill,
721 winding_order: WindingOrder::NonZero,
722 },
723 });
724}
725
726#[allow(clippy::too_many_arguments)] fn draw_table_header(
728 ops: &mut Vec<Op>,
729 font: &FontId,
730 x: f32,
731 y: f32,
732 table_size: f32,
733 font_scale: f32,
734 range_unit: RangeUnit,
735 elevation_unit: &str,
736 windage_unit: &str,
737) {
738 let headers = [
739 ("Range", COLOR_BLACK),
740 ("Drop", COLOR_RED),
741 ("Wind", COLOR_GREEN),
742 ("Lead", COLOR_BLUE),
743 ("Range", COLOR_BLACK),
744 ("Drop", COLOR_RED),
745 ("Wind", COLOR_GREEN),
746 ("Lead", COLOR_BLUE),
747 ];
748 let range_label = range_unit.label();
751 let sub_headers = [
752 range_label,
753 elevation_unit,
754 windage_unit,
755 windage_unit,
756 range_label,
757 elevation_unit,
758 windage_unit,
759 windage_unit,
760 ];
761
762 for (i, ((header, color), sub)) in headers.iter().zip(sub_headers.iter()).enumerate() {
763 let col_x = x + (i as f32 * COL_WIDTH) + (COL_WIDTH / 2.0);
764 draw_text(ops, font, table_size, col_x, y, header, *color, true);
765 draw_text(
766 ops,
767 font,
768 table_size - 1.0,
769 col_x,
770 y - 3.0 * font_scale,
771 sub,
772 *color,
773 true,
774 );
775 }
776}
777
778fn format_adjustment(value: f64, unit_label: &str) -> String {
797 if unit_label.eq_ignore_ascii_case("clicks") {
798 format!("{:.0}", value)
799 } else {
800 format!("{:.1}", value)
801 }
802}
803
804fn format_adjustment_cell(value: Option<f64>, unit_label: &str) -> String {
813 match value {
814 Some(v) => format_adjustment(v, unit_label),
815 None => "—".to_string(),
816 }
817}
818
819fn format_range(range: f64) -> String {
831 let rounded = range.round();
832 if (range - rounded).abs() < 0.05 {
833 format!("{:.0}", rounded)
834 } else {
835 format!("{:.1}", range)
836 }
837}
838
839#[allow(clippy::too_many_arguments)] fn draw_data_row(
841 ops: &mut Vec<Op>,
842 font: &FontId,
843 x: f32,
844 y: f32,
845 row: &CardRow,
846 _is_left: bool,
847 table_size: f32,
848 font_scale: f32,
849 elevation_unit: &str,
850 windage_unit: &str,
851) {
852 let values = [
853 (format_range(row.range), COLOR_BLACK),
854 (
855 format_adjustment_cell(row.drop_adj, elevation_unit),
856 COLOR_RED,
857 ),
858 (
859 format_adjustment_cell(row.wind_adj, windage_unit),
860 COLOR_GREEN,
861 ),
862 (
863 format_adjustment_cell(row.lead_adj, windage_unit),
864 COLOR_BLUE,
865 ),
866 ];
867
868 for (i, (value, color)) in values.iter().enumerate() {
869 let col_x = x + (i as f32 * COL_WIDTH) + (COL_WIDTH / 2.0);
870 draw_text(
871 ops,
872 font,
873 table_size,
874 col_x,
875 y - 2.5 * font_scale,
876 value,
877 *color,
878 true,
879 );
880 }
881}
882
883fn draw_text_ops(
887 ops: &mut Vec<Op>,
888 font: &FontId,
889 size: f32,
890 x: f32,
891 y: f32,
892 text: &str,
893 color: (f32, f32, f32),
894) {
895 ops.push(Op::StartTextSection);
896 ops.push(Op::SetFillColor {
897 col: Color::Rgb(Rgb::new(color.0, color.1, color.2, None)),
898 });
899 ops.push(Op::SetFont {
900 font: PdfFontHandle::External(font.clone()),
901 size: Pt(size),
902 });
903 ops.push(Op::SetLineHeight { lh: Pt(size) });
904 ops.push(Op::SetTextCursor {
905 pos: Point::new(Mm(x), Mm(y)),
906 });
907 ops.push(Op::ShowText {
908 items: vec![TextItem::Text(text.to_string())],
909 });
910 ops.push(Op::EndTextSection);
911}
912
913#[allow(clippy::too_many_arguments)] fn draw_text(
915 ops: &mut Vec<Op>,
916 font: &FontId,
917 size: f32,
918 x: f32,
919 y: f32,
920 text: &str,
921 color: (f32, f32, f32),
922 center: bool,
923) {
924 let text_width = if center {
928 text.len() as f32 * size * 0.3 } else {
930 0.0
931 };
932
933 draw_text_ops(ops, font, size, x - text_width / 2.0, y, text, color);
934}
935
936fn draw_centered_text(
937 ops: &mut Vec<Op>,
938 font: &FontId,
939 size: f32,
940 y: f32,
941 text: &str,
942 color: (f32, f32, f32),
943) {
944 let text_width = text.len() as f32 * size * 0.28;
946 let x = (PAGE_WIDTH - text_width) / 2.0;
947
948 draw_text_ops(ops, font, size, x, y, text, color);
949}
950
951fn get_timestamp() -> String {
952 use std::time::{SystemTime, UNIX_EPOCH};
953
954 let now = SystemTime::now()
955 .duration_since(UNIX_EPOCH)
956 .unwrap_or_default()
957 .as_secs();
958
959 let secs_per_day = 86400u64;
960 let secs_per_hour = 3600u64;
961 let secs_per_min = 60u64;
962
963 let days_since_epoch = now / secs_per_day;
964 let time_of_day = now % secs_per_day;
965
966 let hours = time_of_day / secs_per_hour;
967 let minutes = (time_of_day % secs_per_hour) / secs_per_min;
968 let seconds = time_of_day % secs_per_min;
969
970 let mut year = 1970;
971 let mut remaining_days = days_since_epoch as i64;
972
973 loop {
974 let days_in_year = if is_leap_year(year) { 366 } else { 365 };
975 if remaining_days < days_in_year {
976 break;
977 }
978 remaining_days -= days_in_year;
979 year += 1;
980 }
981
982 let days_in_months: [i64; 12] = if is_leap_year(year) {
983 [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
984 } else {
985 [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
986 };
987
988 let month_names = [
989 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
990 ];
991 let day_names = ["Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed"];
992
993 let mut month = 0;
994 for (i, &days) in days_in_months.iter().enumerate() {
995 if remaining_days < days {
996 month = i;
997 break;
998 }
999 remaining_days -= days;
1000 }
1001
1002 let day = remaining_days + 1;
1003 let day_of_week = (days_since_epoch % 7) as usize;
1005
1006 let (hour_12, am_pm) = if hours == 0 {
1007 (12, "AM")
1008 } else if hours < 12 {
1009 (hours, "AM")
1010 } else if hours == 12 {
1011 (12, "PM")
1012 } else {
1013 (hours - 12, "PM")
1014 };
1015
1016 format!(
1017 "{} {} {:02} {:02}:{:02}:{:02} {} UTC {}",
1018 day_names[day_of_week], month_names[month], day, hour_12, minutes, seconds, am_pm, year
1019 )
1020}
1021
1022fn is_leap_year(year: i64) -> bool {
1023 (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
1024}
1025
1026#[cfg(test)]
1027mod tests {
1028 use super::*;
1029
1030 #[test]
1031 fn test_density_altitude() {
1032 let da = calculate_density_altitude(2500.0, 27.32, 55.0);
1036 assert!(
1037 da > 2500.0 && da < 3500.0,
1038 "DA should be ~3000 ft for near-standard conditions, got {}",
1039 da
1040 );
1041
1042 let da_hot = calculate_density_altitude(2500.0, 27.32, 95.0);
1044 assert!(da_hot > da, "Higher temp should increase DA");
1045
1046 let da_low_press = calculate_density_altitude(2500.0, 25.0, 55.0);
1048 assert!(da_low_press > da, "Lower pressure should increase DA");
1049
1050 let da_standard = calculate_density_altitude(0.0, 29.92, 59.0);
1052 assert!(
1053 da_standard.abs() < 100.0,
1054 "Standard conditions should give DA near 0, got {}",
1055 da_standard
1056 );
1057 }
1058
1059 #[test]
1063 fn format_adjustment_drops_the_decimal_for_clicks_only() {
1064 assert_eq!(format_adjustment(5.0, "CLICKS"), "5");
1065 assert_eq!(format_adjustment(-3.0, "CLICKS"), "-3");
1066 assert_eq!(format_adjustment(5.0, "clicks"), "5", "case-insensitive");
1067 assert_eq!(format_adjustment(2.34, "MIL"), "2.3");
1068 assert_eq!(format_adjustment(2.34, "MOA"), "2.3");
1069 assert_eq!(format_adjustment(2.34, "SMOA"), "2.3");
1070 assert_eq!(format_adjustment(2.34, "IPHY"), "2.3");
1071 }
1072
1073 #[test]
1082 fn format_adjustment_pins_one_decimal_place_including_the_near_ties() {
1083 for (value, expected) in [
1084 (0.0_f64, "0.0"),
1085 (0.65, "0.7"),
1086 (1.4551, "1.5"),
1087 (2.4478, "2.4"),
1088 (3.575, "3.6"),
1089 (4.8412, "4.8"),
1090 (-0.105, "-0.1"),
1091 (-0.21, "-0.2"),
1092 (-0.315, "-0.3"),
1093 (-0.42, "-0.4"),
1094 (-0.55, "-0.6"),
1095 (-0.65, "-0.7"),
1096 (-0.75, "-0.8"),
1097 (6.25, "6.2"),
1098 (7.35, "7.3"),
1099 (0.15, "0.1"),
1100 (2.35, "2.4"),
1101 ] {
1102 assert_eq!(format_adjustment(value, "MIL"), expected, "{value:?}");
1103 assert_eq!(format_adjustment(value, "MOA"), expected, "{value:?}");
1104 }
1105 }
1106
1107 #[test]
1112 fn format_adjustment_cell_renders_none_as_an_em_dash_not_a_fake_zero() {
1113 assert_eq!(format_adjustment_cell(Some(2.34), "MIL"), "2.3");
1114 assert_eq!(format_adjustment_cell(Some(5.0), "CLICKS"), "5");
1115 assert_eq!(format_adjustment_cell(None, "MIL"), "—");
1116 assert_eq!(format_adjustment_cell(None, "CLICKS"), "—");
1117 }
1118
1119 #[test]
1120 fn density_altitude_uses_published_nws_pressure_altitude_equation() {
1121 let pressure_inhg = 20.670_988_150_011_322;
1124 let density_altitude = calculate_density_altitude(0.0, pressure_inhg, 55.0);
1125
1126 assert!((density_altitude - 11_962.774_017_764_264).abs() < 1e-6);
1127
1128 let standard_pressure_inhg = 29.921_255_347_141_39;
1129 let standard_density_altitude =
1130 calculate_density_altitude(0.0, standard_pressure_inhg, 59.0);
1131 assert!(standard_density_altitude.abs() < 1e-9);
1132 }
1133
1134 #[test]
1143 fn format_range_pinned_examples() {
1144 assert_eq!(format_range(400.0), "400");
1145 assert_eq!(format_range(417.3), "417.3");
1146 }
1147
1148 #[test]
1153 fn format_range_absorbs_floating_point_noise_around_a_whole_number() {
1154 assert_eq!(format_range(400.02), "400");
1155 assert_eq!(format_range(399.98), "400");
1156 assert_eq!(format_range(100.0 + 1e-9), "100");
1157 assert_eq!(format_range(400.04), "400");
1161 assert_eq!(format_range(399.96), "400");
1162 assert_eq!(format_range(0.0), "0");
1163 }
1164
1165 #[test]
1166 fn format_range_renders_one_decimal_outside_the_noise_band() {
1167 assert_eq!(format_range(417.3), "417.3");
1168 assert_eq!(format_range(100.5), "100.5");
1169 assert_eq!(format_range(400.08), "400.1");
1170 assert_eq!(format_range(400.05), "400.1");
1174 }
1175
1176 fn test_config() -> DopeCardConfig {
1177 DopeCardConfig {
1178 rifle_name: "Test Rifle".to_string(),
1179 location: "Test Range".to_string(),
1180 density_altitude_ft: 1500.0,
1181 pressure_inhg: 29.92,
1182 pressure_hpa: 1013.25,
1183 temperature_f: 59.0,
1184 altitude_ft: 1000.0,
1185 wind_speed_mph: 5.0,
1186 target_speed_mph: 0.0,
1187 solver_mode: "offline".to_string(),
1188 powder: "Test Powder".to_string(),
1189 bullet: "Test Bullet".to_string(),
1190 weight_gr: 175.0,
1191 bc: 0.5,
1192 drag_model: "g7".to_string(),
1193 velocity_fps: 2700.0,
1194 font_scale: 1.0,
1195 bold_data: false,
1196 elevation_unit_label: "MIL".to_string(),
1197 windage_unit_label: "MIL".to_string(),
1198 engine_version: "0.0.0-test".to_string(),
1199 table_version: String::new(),
1200 truncation_note: String::new(),
1201 }
1202 }
1203
1204 fn test_row(range: f64) -> CardRow {
1205 CardRow {
1206 range,
1207 drop_linear: None,
1208 drop_adj: Some(1.0),
1209 come_up: None,
1210 wind_linear: None,
1211 wind_adj: Some(0.5),
1212 velocity: None,
1213 energy: None,
1214 time: None,
1215 lead_adj: Some(0.2),
1216 wind_columns: Vec::new(),
1217 }
1218 }
1219
1220 fn assert_valid_pdf_bytes(bytes: &[u8]) {
1221 assert!(!bytes.is_empty(), "PDF output must not be empty");
1222 assert_eq!(&bytes[..5], b"%PDF-", "output must start with a PDF header");
1223 }
1224
1225 #[test]
1226 fn generate_dope_card_pdf_is_non_empty_for_a_single_row() {
1227 let config = test_config();
1228 let rows = vec![test_row(100.0)];
1229 let bytes = generate_dope_card_pdf(&config, &rows, RangeUnit::Yards)
1230 .expect("single-row dope card should generate");
1231 assert_valid_pdf_bytes(&bytes);
1232 }
1233
1234 #[test]
1238 fn generate_dope_card_pdf_is_non_empty_for_120_rows_pagination() {
1239 let config = test_config();
1240 let rows: Vec<CardRow> = (0..120)
1241 .map(|i| test_row(100.0 + i as f64 * 25.0))
1242 .collect();
1243 let bytes = generate_dope_card_pdf(&config, &rows, RangeUnit::Meters)
1244 .expect("120-row dope card should generate and paginate");
1245 assert_valid_pdf_bytes(&bytes);
1246 }
1247
1248 #[test]
1255 fn generate_dope_card_pdf_succeeds_when_lead_adj_is_none() {
1256 let config = test_config();
1257 let mut row = test_row(100.0);
1258 row.lead_adj = None;
1259 let bytes = generate_dope_card_pdf(&config, &[row], RangeUnit::Yards)
1260 .expect("a row missing lead_adj must still render, not error");
1261 assert_valid_pdf_bytes(&bytes);
1262 }
1263
1264 #[test]
1269 fn generate_dope_card_pdf_rejects_empty_rows() {
1270 let config = test_config();
1271 let err = generate_dope_card_pdf(&config, &[], RangeUnit::Yards)
1272 .expect_err("an empty row set must not silently produce a PDF");
1273 assert!(err.to_string().contains("rows"), "{err}");
1274 }
1275
1276 #[test]
1281 fn dope_card_rows_per_page_pins_the_preset_scales() {
1282 assert_eq!(dope_card_rows_per_page(FontSizePreset::Small.scale()), 104);
1288 assert_eq!(dope_card_rows_per_page(FontSizePreset::Medium.scale()), 98);
1289 assert_eq!(dope_card_rows_per_page(FontSizePreset::Large.scale()), 70);
1290 assert_eq!(dope_card_rows_per_page(0.5), 104);
1291 assert_eq!(dope_card_rows_per_page(0.01), dope_card_rows_per_page(0.5));
1293 assert_eq!(dope_card_rows_per_page(99.0), dope_card_rows_per_page(3.0));
1294 }
1295
1296 #[test]
1297 fn dope_card_page_count_rounds_up_and_reports_zero_for_no_rows() {
1298 let per_page = dope_card_rows_per_page(1.0);
1299 assert_eq!(dope_card_page_count(0, 1.0), 0);
1300 assert_eq!(dope_card_page_count(1, 1.0), 1);
1301 assert_eq!(dope_card_page_count(per_page, 1.0), 1);
1302 assert_eq!(dope_card_page_count(per_page + 1, 1.0), 2);
1303 assert_eq!(dope_card_page_count(per_page * 3, 1.0), 3);
1304 }
1305
1306 #[test]
1310 fn generated_pdf_grows_when_page_count_grows() {
1311 let config = test_config();
1312 let per_page = dope_card_rows_per_page(config.font_scale);
1313 let rows: Vec<CardRow> = (0..per_page).map(|i| test_row(100.0 + i as f64)).collect();
1314 let one_page = generate_dope_card_pdf(&config, &rows, RangeUnit::Yards).unwrap();
1315 assert_eq!(dope_card_page_count(rows.len(), config.font_scale), 1);
1316
1317 let mut rows_plus_one = rows.clone();
1318 rows_plus_one.push(test_row(100.0 + per_page as f64));
1319 let two_pages = generate_dope_card_pdf(&config, &rows_plus_one, RangeUnit::Yards).unwrap();
1320 assert_eq!(
1321 dope_card_page_count(rows_plus_one.len(), config.font_scale),
1322 2
1323 );
1324 assert!(
1325 two_pages.len() > one_page.len(),
1326 "a second page must add bytes: {} vs {}",
1327 one_page.len(),
1328 two_pages.len()
1329 );
1330 }
1331
1332 #[test]
1335 fn range_unit_label_matches_its_variant() {
1336 assert_eq!(RangeUnit::Yards.label(), "Yd");
1337 assert_eq!(RangeUnit::Meters.label(), "M");
1338 }
1339}