Skip to main content

ballistics_engine/
pdf_dope_card.rs

1//! PDF Dope Card Generation Module
2//!
3//! Generates printable dope cards in Glenn's proven field-ready format.
4//! Format: Two-column layout with Range (yd or m) and Drop/Wind/Lead in MIL or MOA
5//! (selected via DopeCardConfig::unit_label; values pre-converted by the caller)
6//! Color coding: Black=Range, Red=Drop, Green=Wind, Blue=Lead
7//! Row striping for improved readability.
8//!
9//! 0.33.0 decision-support Plan B Task 10: moved from a `ballistics`-binary-private module
10//! (`mod pdf_dope_card;` in `main.rs`) into this library behind the `pdf` feature, and
11//! rewritten to consume `&[crate::card::CardRow]` instead of this module's own
12//! `DopeCardRow { range_yd: u32, .. }` -- the u32-yards field couldn't express the
13//! non-integer ranges Task 11's adaptive card engine (`crate::card`) produces. The caller
14//! now states the row range's unit explicitly via `RangeUnit`; see its doc comment.
15
16use crate::card::CardRow;
17use printpdf::*;
18
19// Embed Liberation Sans fonts directly into the binary (SIL Open Font License)
20static FONT_REGULAR: &[u8] = include_bytes!("../fonts/LiberationSans-Regular.ttf");
21static FONT_BOLD: &[u8] = include_bytes!("../fonts/LiberationSans-Bold.ttf");
22
23/// Configuration for the dope card PDF
24#[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    /// Angular unit label shown in the Drop column sub-header ("MIL", "MOA", "SMOA",
45    /// "IPHY", or "CLICKS"). `CardRow::drop_adj` is already expressed in this unit
46    /// (MBA-1410: independent from `windage_unit_label` below).
47    pub elevation_unit_label: String,
48    /// Angular unit label shown in the Wind/Lead column sub-headers. `CardRow::
49    /// wind_adj`/`lead_adj` are already expressed in this unit -- may differ from
50    /// `elevation_unit_label` (MBA-1410 independent elevation/windage unit selection).
51    pub windage_unit_label: String,
52}
53
54/// Preset font size profiles for dope cards
55#[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    // Promoting this module to `pub` in the library (Task 10) makes this method part of
72    // the crate's exported API for the first time, which is why clippy only starts
73    // flagging it here: it returns `Option<Self>`, not `Result<Self, Self::Err>`, so it
74    // doesn't fit std::str::FromStr's contract, and FontSizePreset's shape is preserved
75    // verbatim rather than reworked to satisfy the lint.
76    #[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/// Which unit `CardRow::range` is expressed in for this card. Selects only the Range
88/// column's sub-header text ("Yd" / "M") -- row values are never converted here, same
89/// "the caller already converted it" convention `CardRow` itself documents.
90#[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
105// Page dimensions (Letter size in mm)
106const PAGE_WIDTH: f32 = 215.9;
107const PAGE_HEIGHT: f32 = 279.4;
108const MARGIN: f32 = 10.0;
109
110// Font sizes
111const HEADER_FONT_SIZE: f32 = 9.0;
112const TABLE_FONT_SIZE: f32 = 8.0;
113const FOOTER_FONT_SIZE: f32 = 8.0;
114
115// Table layout
116const ROW_HEIGHT: f32 = 4.5;
117const COL_WIDTH: f32 = 24.0; // Width per column (8 columns total)
118
119// Colors (RGB 0.0-1.0)
120const 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); // Light gray for alternating rows
125const INHG_TO_HPA: f64 = 33.863_886_666_667;
126
127// The angular conversion (drop_yd/range_yd -> MIL or MOA) and moving-target lead now
128// live in main.rs::drop_to_adjustment, so both card units share one code path; the
129// caller fills CardRow's drop_adj/wind_adj/lead_adj (as Some(..)) already in the chosen
130// unit. Fix-round I-1: a None on any of those three renders as an em-dash (see
131// `format_adjustment_cell`), never a fake 0.0 -- Task 11's adaptive engine, the stated
132// reason this module takes CardRow at all, emits `lead_adj: None` on every row it
133// produces, and a plausible-looking dialed zero would be dangerously wrong there.
134
135/// Calculate density altitude from environmental conditions
136///
137/// MBA-643: Fixed to interpret pressure as STATION PRESSURE (actual local pressure),
138/// not altimeter setting (sea-level corrected). This matches how weather stations
139/// and most ballistic tools report pressure.
140///
141/// Pressure altitude follows the published NWS station-pressure equation:
142/// PA = 145366.45 * (1 - (P_hPa/1013.25)^0.190284)
143/// <https://www.weather.gov/media/epz/wxcalc/pressureAltitude.pdf>
144///
145/// ```text
146/// DA = PA + 66.7 * (OAT_F - ISA_temp_F)
147/// ```
148pub fn calculate_density_altitude(_altitude_ft: f64, pressure_inhg: f64, temp_f: f64) -> f64 {
149    // The NWS equation is defined in hPa (equivalently millibars), so convert before
150    // applying its matched coefficient, reference pressure, and exponent.
151    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    // ISA temperature at pressure altitude (lapse rate: 3.57°F per 1000 ft)
155    let isa_temp_f = 59.0 - (pressure_alt / 1000.0) * 3.57;
156
157    // Density altitude = pressure altitude + temperature correction.
158    // The common 120 ft/degree rule is per degree Celsius; these values are Fahrenheit.
159    pressure_alt + (120.0 * 5.0 / 9.0) * (temp_f - isa_temp_f)
160}
161
162/// Find font file - tries external locations first (for user overrides),
163/// then falls back to embedded fonts compiled into the binary.
164fn find_font_file(font_name: &str) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
165    let ttf = format!("{}.ttf", font_name);
166
167    // Try exe directory
168    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    // Try home directory
178    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    // Try working directory
186    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    // Try system font directories
194    #[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    // Fall back to embedded fonts
224    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/// Recursively search a directory for a font file by name
232#[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
252/// Truncate a string for header display, appending "..." if too long
253fn truncate_for_header(s: &str, max_chars: usize) -> String {
254    // Count/truncate by CHARACTERS, not bytes. The header concatenates user-controlled
255    // rifle/location names; byte-slicing a multi-byte UTF-8 string at an offset that isn't a
256    // char boundary panics. Identical output for ASCII (byte len == char count).
257    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
267/// Draw a light gray separator line across the page width
268fn 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
290/// Generate a dope card PDF matching Glenn's format with row striping.
291///
292/// `rows` is display-ready per `CardRow`'s convention (already converted to the card's
293/// chosen angular unit, and to `range_unit`); `range_unit` only selects the Range
294/// column's sub-header text ("Yd" or "M") -- it does not convert `row.range`.
295pub 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    // Load and register fonts
306    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    // Only scale the data table — header/footer stay at base size
319    // so they don't overflow or consume disproportionate page space
320    let font_scale = config.font_scale.clamp(0.5, 3.0);
321    let header_size = HEADER_FONT_SIZE; // UNSCALED
322    let table_size = TABLE_FONT_SIZE * font_scale; // SCALED
323    let footer_size = FOOTER_FONT_SIZE; // UNSCALED
324    let row_height = ROW_HEIGHT * font_scale; // SCALED
325
326    // Calculate visual rows per page (accounting for header and footer)
327    // Each visual row shows 2 data points (left + right columns)
328    let usable_height = PAGE_HEIGHT - (2.0 * MARGIN) - 36.0; // Leave space for header/footer + separators
329    let visual_rows_per_page = ((usable_height / row_height) as usize).min(52);
330    let data_rows_per_page = visual_rows_per_page * 2; // Two-column layout
331    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)] // Fixed-layout renderer keeps its page metrics explicit.
370fn 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    // Header line 1 (auto-truncate long text)
389    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    // Header line 2
407    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    // Separator line after header
415    draw_separator_line(ops, y);
416    y -= 5.0;
417
418    // Table start position
419    let table_x = (PAGE_WIDTH - (8.0 * COL_WIDTH)) / 2.0;
420
421    // Draw table header
422    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    // Split rows into left and right columns
436    let mid = rows.len().div_ceil(2);
437    let (left_rows, right_rows) = rows.split_at(mid);
438
439    // Select font for data rows (bold or regular)
440    let data_font = if bold_data { font_bold } else { font };
441
442    // Draw data rows with striping
443    for (i, left) in left_rows.iter().enumerate() {
444        let right = right_rows.get(i);
445
446        // Draw stripe background for alternating rows
447        if i % 2 == 1 {
448            draw_row_stripe(ops, table_x, y, 8.0 * COL_WIDTH, row_height);
449        }
450
451        // Draw left side data
452        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        // Draw right side data
458        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    // Separator line before footer
477    draw_separator_line(ops, y - 1.0);
478    y -= 5.0;
479
480    // Footer line 1: load data
481    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    // Footer line 2: timestamp
494    let timestamp = get_timestamp();
495    draw_centered_text(ops, font, footer_size, y, &timestamp, 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)] // Drawing primitive mirrors the PDF text/layout parameters.
536fn 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    // Range columns use the card's range unit (Yd/M); Drop is the elevation unit,
558    // Wind/Lead the (possibly different, MBA-1410) windage unit.
559    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
587/// Formats one Drop/Wind/Lead cell value for its column's angular unit (MBA-1410 fold-in
588/// of an MBA-1355 backlog minor): whole turret clicks are integers, so `unit_label ==
589/// "CLICKS"` prints with no decimal point -- every other unit (MIL/MOA/SMOA/IPHY) keeps
590/// the pre-existing one-decimal-place format. Before this fix, a clicks dope card printed
591/// e.g. "5.0" instead of "5" for every cell.
592fn 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
600/// Fix-round I-1: renders an `Option<f64>` dope-card cell honestly. `Some(v)` delegates to
601/// `format_adjustment` exactly as before; `None` renders as an em-dash rather than a
602/// plausible-looking (but fake) `0.0`. `DopeCardRow` made drop/wind/lead mandatory, so
603/// this distinction didn't exist before Task 10 promoted the card onto `CardRow`, whose
604/// equivalent fields are `Option`. It matters because Task 11's adaptive card engine --
605/// the reason this module accepts `CardRow` at all -- always emits `lead_adj: None`; an
606/// `unwrap_or(0.0)` would print a full Lead column of confident-looking zeroes for a card
607/// that carries no lead data whatsoever.
608fn 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
615/// Formats `CardRow::range` for the dope card's Range column. The pre-Task-10
616/// `DopeCardRow` stored range as `u32` yards -- always a bare integer. `CardRow::range`
617/// is `f64`: the `trajectory -o pdf` call site now explicitly rounds to whole yards
618/// before building each row (`main.rs::dope_card_row_from_sample`, fix-round C-1 --
619/// sampled ranges are exact multiples of a metre-denominated sample interval, so their
620/// yard conversion is genuinely fractional and would NOT fall into the noise band below
621/// on its own), so its rows always land in the integer branch here; a future caller with
622/// genuinely fractional ranges (Task 11/12's adaptive cards) renders with one decimal
623/// instead, e.g. `417.3` -> "417.3". The `< 0.05` band exists only to absorb ordinary
624/// floating-point rounding noise around an already-whole number, not to "round" a
625/// meaningfully fractional value down to the nearest integer.
626fn 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)] // Drawing primitive mirrors the PDF text/layout parameters.
636fn 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
670/// Push the PDF ops for a run of text, wrapped in its own text section.
671///
672/// `x`/`y` are the baseline origin in mm (matches printpdf 0.7's `use_text` convention).
673fn 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)] // Drawing primitive mirrors the PDF text/layout parameters.
701fn 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    // Approximate centering by estimating text width (printpdf 0.10 has no simple
712    // string-width lookup for an arbitrary font+size, so keep the same heuristic
713    // the 0.7-era code used rather than reaching for glyph-level metrics).
714    let text_width = if center {
715        text.len() as f32 * size * 0.3 // Rough approximation
716    } 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    // Center on page (same width approximation as draw_text)
732    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    // The Unix epoch (1970-01-01) was a Thursday and day_names is Thursday-first, so no offset.
791    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        // MBA-643: Test with Glenn's reference conditions
820        // altitude=2500ft, pressure=27.32inHg (station), temp=55°F
821        // Glenn's tool: DA ≈ 2835 ft
822        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        // Higher temp should increase DA
830        let da_hot = calculate_density_altitude(2500.0, 27.32, 95.0);
831        assert!(da_hot > da, "Higher temp should increase DA");
832
833        // Lower pressure (thinner air) should increase DA
834        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        // Standard conditions at sea level: DA ≈ 0
838        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    /// MBA-1410 fold-in: a clicks dope-card cell must print as a bare integer ("5"), not
847    /// "5.0" -- the trailing-`.0` bug tracked as an MBA-1355 backlog minor. Every other
848    /// unit keeps its pre-existing one-decimal-place format.
849    #[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    /// Fix-round I-1: a missing column must render as an honest em-dash, never a
861    /// plausible-looking fake `0.0` -- pinned for both a decimal unit and a clicks unit,
862    /// since a naive fix might special-case `None` only inside one branch of
863    /// `format_adjustment`'s clicks/non-clicks split.
864    #[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        // 20.670988150011322 inHg is exactly 700 hPa under the standard conversion.
875        // Keep the public-unit fixture literal independent of the production conversion constant.
876        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    // -----------------------------------------------------------------------------------
888    // Task 10 (0.33.0 decision-support Plan B): CardRow-based range formatting + PDF
889    // generation smoke tests.
890    // -----------------------------------------------------------------------------------
891
892    /// Pinned brief examples: a legacy-integer range renders with no decimal point, a
893    /// genuinely fractional one (as Task 11's adaptive engine can now produce) renders
894    /// with exactly one.
895    #[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    /// The pre-Task-10 call site rounded to `u32` before storing, so its yard conversion
902    /// was never exactly integral (floating-point noise). The new `f64`-range path must
903    /// still collapse that noise to the same bare integer -- this is the `< 0.05`
904    /// tolerance's whole reason to exist.
905    #[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        // The band rule now exists in two copies (this function and
911        // `format_adaptive_card_range` in `main.rs`), so these fixtures are the sync
912        // mechanism between them -- more coverage right at and near the +-0.05 edge.
913        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        // 400.05 is exactly the band's edge in decimal, but not in binary: the nearest f64 to
924        // 400.05 is a hair above it, so `(range - rounded).abs() < 0.05` is false and this
925        // renders with one decimal rather than collapsing to "400".
926        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    /// 120 rows forces the two-column, ~98-rows-per-page layout past a single page
985    /// (`data_rows_per_page` is well under 120 at the default font scale), exercising the
986    /// pagination path `generate_dope_card_pdf` walks via `total_pages`/`page_rows`.
987    #[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    /// Fix-round I-1: Task 11's adaptive card engine -- the reason this module accepts
999    /// `CardRow` at all -- always emits `lead_adj: None`. The full renderer must still
1000    /// produce a valid PDF for that row shape (via `format_adjustment_cell`'s em-dash,
1001    /// not `unwrap_or(0.0)`'s fake zero); this exercises `draw_data_row` end to end,
1002    /// while `format_adjustment_cell_renders_none_as_an_em_dash_not_a_fake_zero` above
1003    /// pins the exact string.
1004    #[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    /// A zero-page PDF is not a useful answer from a public library API: both in-tree callers
1015    /// (`trajectory -o pdf`, `adaptive-card -o pdf`) already guard against empty rows
1016    /// themselves, but a binding calling this function directly should get a named error, not
1017    /// a silently-empty document.
1018    #[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    /// `RangeUnit::label()` untested was Task 10 review Minor #3: a swapped `Yd`/`M` would
1027    /// ship silently on `adaptive-card -o pdf`'s Range column sub-header.
1028    #[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}