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    /// Footer text after `BC:`, in place of the `bc` figure and its drag-model group
42    /// (MBA-1601). EMPTY prints `{bc:.3} ({drag_model lowercased})` exactly as before.
43    ///
44    /// Pre-formatted, like `truncation_note`: supply [`ladder_bc_label`] when the rows flew a
45    /// BC ladder. Under a ladder `bc` is at most the interior-gap fallback, so printing it
46    /// alone states a BC the rows may never have flown.
47    pub bc_label: String,
48    pub velocity_fps: f64,
49    pub font_scale: f32,
50    pub bold_data: bool,
51    /// Angular unit label shown in the Drop column sub-header ("MIL", "MOA", "SMOA",
52    /// "IPHY", or "CLICKS"). `CardRow::drop_adj` is already expressed in this unit
53    /// (MBA-1410: independent from `windage_unit_label` below).
54    pub elevation_unit_label: String,
55    /// Angular unit label shown in the Wind/Lead column sub-headers. `CardRow::
56    /// wind_adj`/`lead_adj` are already expressed in this unit -- may differ from
57    /// `elevation_unit_label` (MBA-1410 independent elevation/windage unit selection).
58    pub windage_unit_label: String,
59    /// Engine version that produced these rows, printed in the footer as `Engine:<v>`.
60    ///
61    /// A card in a shooter's pocket is otherwise impossible to reconcile with a screen: the
62    /// rows are a function of the engine build and of the correction table, and both move.
63    /// An EMPTY string prints nothing at all -- the same rule the apps' provenance line
64    /// follows, because a placeholder ("unknown") on a printed card is worse than silence.
65    pub engine_version: String,
66    /// Correction-table version these rows were solved against, printed as `Table:<v>`.
67    /// Empty prints nothing, which is the honest rendering of "no correction table".
68    pub table_version: String,
69    /// One line stating that these rows stop short of the card that was asked for, drawn in
70    /// the footer under the provenance line (MBA-1477). Empty prints nothing.
71    ///
72    /// Supply [`crate::hold_curve::CardTruncation::printed_note`] — the paper form of the
73    /// one wording every truncated card surface uses. Pre-formatted rather than structured
74    /// for the same reason `engine_version` and `table_version` are: this module renders a
75    /// card, it does not decide what a card has to say about itself.
76    ///
77    /// A truncated card is otherwise indistinguishable on paper from a complete one. The
78    /// notice reaches a CLI reader on stderr and an app through a response field, and a
79    /// printed card carries neither: it is the artifact most likely to be taken to a range
80    /// with no screen beside it, so a card that stopped short of the range it was asked for
81    /// has to say so in the shooter's hand.
82    pub truncation_note: String,
83}
84
85/// The footer's `BC:` text for rows that flew a velocity-keyed BC ladder (MBA-1601), for
86/// [`DopeCardConfig::bc_label`].
87///
88/// Under a ladder the scalar BC flies only in an interior gap between bands (and, blended,
89/// within up to 25 fps either side of a gap edge), and never under a gapless ladder (every
90/// ladder a correction table builds), so it is not the card's BC. This states the range of the
91/// band BCs instead, widened to include `fallback_bc` when `interior_gap` says the scalar can
92/// fly too, to three decimals:
93/// `0.420-0.450 (g1 ladder)`, `0.400-0.450 (g1 ladder+gap)`, or `0.250 (g7 ladder)` when the
94/// range prints as one figure. No bands returns an empty string, which prints the scalar
95/// exactly as before.
96pub fn ladder_bc_label(
97    drag_model: &str,
98    band_bcs: &[f64],
99    fallback_bc: f64,
100    interior_gap: bool,
101) -> String {
102    if band_bcs.is_empty() {
103        return String::new();
104    }
105    let (lo, hi) = band_bcs
106        .iter()
107        .copied()
108        .chain(interior_gap.then_some(fallback_bc))
109        .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), bc| {
110            (lo.min(bc), hi.max(bc))
111        });
112    let (lo, hi) = (format!("{lo:.3}"), format!("{hi:.3}"));
113    let figure = if lo == hi { lo } else { format!("{lo}-{hi}") };
114    let kind = if interior_gap { "ladder+gap" } else { "ladder" };
115    format!("{figure} ({} {kind})", drag_model.to_lowercase())
116}
117
118/// Preset font size profiles for dope cards
119#[derive(Debug, Clone, Copy, PartialEq)]
120pub enum FontSizePreset {
121    Small,
122    Medium,
123    Large,
124}
125
126impl FontSizePreset {
127    pub fn scale(&self) -> f32 {
128        match self {
129            Self::Small => 0.8,
130            Self::Medium => 1.0,
131            Self::Large => 1.4,
132        }
133    }
134
135    // Promoting this module to `pub` in the library (Task 10) makes this method part of
136    // the crate's exported API for the first time, which is why clippy only starts
137    // flagging it here: it returns `Option<Self>`, not `Result<Self, Self::Err>`, so it
138    // doesn't fit std::str::FromStr's contract, and FontSizePreset's shape is preserved
139    // verbatim rather than reworked to satisfy the lint.
140    #[allow(clippy::should_implement_trait)]
141    pub fn from_str(s: &str) -> Option<Self> {
142        match s.to_lowercase().as_str() {
143            "small" | "s" => Some(Self::Small),
144            "medium" | "m" => Some(Self::Medium),
145            "large" | "l" => Some(Self::Large),
146            _ => None,
147        }
148    }
149}
150
151/// Which unit `CardRow::range` is expressed in for this card. Selects only the Range
152/// column's sub-header text ("Yd" / "M") -- row values are never converted here, same
153/// "the caller already converted it" convention `CardRow` itself documents.
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub enum RangeUnit {
156    Yards,
157    Meters,
158}
159
160impl RangeUnit {
161    fn label(&self) -> &'static str {
162        match self {
163            RangeUnit::Yards => "Yd",
164            RangeUnit::Meters => "M",
165        }
166    }
167}
168
169/// The `font_scale` band [`generate_dope_card_pdf`] honours; anything outside it is
170/// clamped into it (see [`dope_card_rows_per_page`], which clamps identically so a page
171/// count and the document it describes cannot disagree).
172pub const FONT_SCALE_RANGE: std::ops::RangeInclusive<f32> = 0.5..=3.0;
173
174// Page dimensions (Letter size in mm)
175const PAGE_WIDTH: f32 = 215.9;
176const PAGE_HEIGHT: f32 = 279.4;
177const MARGIN: f32 = 10.0;
178
179// Font sizes
180const HEADER_FONT_SIZE: f32 = 9.0;
181const TABLE_FONT_SIZE: f32 = 8.0;
182const FOOTER_FONT_SIZE: f32 = 8.0;
183/// Character budget for the footer's truncation note (MBA-1477).
184///
185/// `draw_centered_text` approximates a glyph at `size * 0.28` mm, so at the unscaled footer
186/// size the printable width between the margins is `(PAGE_WIDTH - 2*MARGIN) / (8 * 0.28)`
187/// ~= 87 characters. This is that with a margin of error, and it is a GUARD rather than a
188/// budget the wording works to: `CardTruncation::printed_note` is ~71 characters at its
189/// longest realistic ranges, so nothing a card actually prints reaches it.
190const FOOTER_NOTE_MAX_CHARS: usize = 80;
191
192// Table layout
193const ROW_HEIGHT: f32 = 4.5;
194const COL_WIDTH: f32 = 24.0; // Width per column (8 columns total)
195
196// Colors (RGB 0.0-1.0)
197const COLOR_BLACK: (f32, f32, f32) = (0.0, 0.0, 0.0);
198const COLOR_RED: (f32, f32, f32) = (0.78, 0.0, 0.0);
199const COLOR_GREEN: (f32, f32, f32) = (0.0, 0.5, 0.0);
200const COLOR_BLUE: (f32, f32, f32) = (0.0, 0.0, 0.78);
201const COLOR_STRIPE: (f32, f32, f32) = (0.94, 0.94, 0.94); // Light gray for alternating rows
202const INHG_TO_HPA: f64 = 33.863_886_666_667;
203
204// The angular conversion (drop_yd/range_yd -> MIL or MOA) and moving-target lead now
205// live in main.rs::drop_to_adjustment, so both card units share one code path; the
206// caller fills CardRow's drop_adj/wind_adj/lead_adj (as Some(..)) already in the chosen
207// unit. Fix-round I-1: a None on any of those three renders as an em-dash (see
208// `format_adjustment_cell`), never a fake 0.0 -- Task 11's adaptive engine, the stated
209// reason this module takes CardRow at all, emits `lead_adj: None` on every row it
210// produces, and a plausible-looking dialed zero would be dangerously wrong there.
211
212/// Calculate density altitude from environmental conditions
213///
214/// MBA-643: Fixed to interpret pressure as STATION PRESSURE (actual local pressure),
215/// not altimeter setting (sea-level corrected). This matches how weather stations
216/// and most ballistic tools report pressure.
217///
218/// Pressure altitude follows the published NWS station-pressure equation:
219/// PA = 145366.45 * (1 - (P_hPa/1013.25)^0.190284)
220/// <https://www.weather.gov/media/epz/wxcalc/pressureAltitude.pdf>
221///
222/// ```text
223/// DA = PA + 66.7 * (OAT_F - ISA_temp_F)
224/// ```
225///
226/// # `_altitude_ft` is ignored, deliberately
227///
228/// The first parameter is unused and has been since MBA-643 made `pressure_inhg`
229/// a station pressure: station pressure already encodes the altitude, so taking one
230/// again would be taking it twice. The parameter survives only because removing it
231/// is source-breaking for callers. **Passing a different altitude changes nothing**,
232/// which is worth knowing before spending time on a discrepancy it cannot explain.
233///
234/// # Humidity is not an input, for compatibility
235///
236/// ⚠️ NOT AN OVERSIGHT. This is the FAA-rule figure the engine has always printed, and
237/// [`crate::atmosphere::resolve_atmosphere_for_density_altitude`] (MBA-1366) is its exact
238/// algebraic inverse — the entry mode that lets a shooter type a single density altitude
239/// instead of altitude + pressure + temperature. That entry mode takes a temperature but no
240/// humidity (the run's humidity is applied separately), so a humidity term here would change
241/// what every previously entered density altitude resolves to, as well as every printed card.
242/// It is not a mathematical necessity: a humidity-aware forward could be inverted given the
243/// run's humidity, the way temperature already is. `main.rs`'s
244/// `density_altitude_round_trips_through_the_dope_card_formula*` tests are what pin the pair
245/// together; a change to the maths here must keep them passing.
246///
247/// The TRAJECTORY is unaffected by this: the solver's air density comes from
248/// [`crate::atmosphere::calculate_air_density_cimp`] (CIPM-2007) and its speed of
249/// sound from [`crate::atmosphere::moist_speed_of_sound`], both of which do take
250/// humidity. This function feeds a READOUT, never the integrator.
251///
252/// # Why it disagrees with some other tools
253///
254/// Tools that fold humidity into the reported figure — the National Weather Service's own
255/// density-altitude calculator among them — answer a different question: "which ISA altitude
256/// has the air density I actually have" rather than "what does the FAA rule say". On a humid
257/// day they read HIGHER. At 15 °C and 1013.25 hPa, 50% RH makes the air about 0.32% less
258/// dense than dry air, which is 33.1 m (109 ft) of ISA altitude; this function reports 0
259/// there, by construction. Both quantities get called "density altitude" in the field.
260/// Reported externally 2026-09.
261///
262/// The engine computes that second quantity too, as
263/// [`crate::atmosphere::density_matched_altitude_m`], and the `atmosphere.density_altitude`
264/// bridge command reports the two side by side under separate names.
265pub fn calculate_density_altitude(_altitude_ft: f64, pressure_inhg: f64, temp_f: f64) -> f64 {
266    // The NWS equation is defined in hPa (equivalently millibars), so convert before
267    // applying its matched coefficient, reference pressure, and exponent. The formula itself
268    // lives in the unconditional `atmosphere` module so a build without the `pdf` feature
269    // (wasm32, or any build that leaves it out) can still report density altitude; this is
270    // bit-identical to the arithmetic that used to be written out here.
271    crate::atmosphere::faa_rule_density_altitude_ft(pressure_inhg * INHG_TO_HPA, temp_f)
272}
273
274/// hPa per inHg as every header call site has always converted it (`main.rs` uses 33.8639 on
275/// both CLI PDF branches, and `card.pdf` copies it), NOT [`INHG_TO_HPA`], which
276/// only the density-altitude formula uses. Kept so a restated header matches the shipped one.
277const HEADER_HPA_PER_INHG: f64 = 33.8639;
278
279/// The atmosphere fields of a dope card's header line, `DA:… ft Pressure:…/… Temp:…`, for the
280/// station air the card's rows were solved in (MBA-1593).
281///
282/// A solve does not always fly the air it is handed: it swaps ICAO air at the site's altitude
283/// in for an omitted pressure or temperature, and reduces a declared QNH to station pressure
284/// ([`crate::atmosphere::resolve_station_conditions_with_presence`]; a typed value flies as
285/// typed since 0.49.0). Before
286/// 0.49.0 every card surface printed the air it was HANDED, so a card at 1500 m with no
287/// pressure or temperature read `DA:0 ft Pressure:29.92/1013 Temp:59` over rows that flew
288/// 846 hPa and 5.25 °C. Each surface now states its air with [`Self::new`] and restates it
289/// with [`Self::as_flown`].
290#[derive(Debug, Clone, Copy, PartialEq)]
291pub struct HeaderAtmosphere {
292    pub density_altitude_ft: f64,
293    pub pressure_inhg: f64,
294    pub pressure_hpa: f64,
295    pub temperature_f: f64,
296}
297
298impl HeaderAtmosphere {
299    /// The header for air given in the header's own units, with that air's density altitude
300    /// from [`calculate_density_altitude`]. Both pressure figures are taken as given, since
301    /// the surfaces have always derived the second from the first in their own ways.
302    pub fn new(temperature_f: f64, pressure_inhg: f64, pressure_hpa: f64) -> Self {
303        Self {
304            density_altitude_ft: calculate_density_altitude(0.0, pressure_inhg, temperature_f),
305            pressure_inhg,
306            pressure_hpa,
307            temperature_f,
308        }
309    }
310
311    /// This header, restated for the air the solve actually flew.
312    ///
313    /// `supplied` is the `(temperature °C, pressure hPa)` pair the solve was handed, which is
314    /// the air `self` states, and `flown` is the pair it flew: what
315    /// [`crate::atmosphere::resolve_station_conditions_with_presence`] made of `supplied`.
316    /// A field the solve flew as supplied keeps `self`'s figure exactly, so sea-level and fully
317    /// explicit station air print as they always have (an inHg reading is not round-tripped
318    /// through hPa). A field the solve substituted is restated from `flown`, and the density
319    /// altitude is recomputed from the result.
320    pub fn as_flown(self, supplied: (f64, f64), flown: (f64, f64)) -> Self {
321        let temperature_f = if flown.0 == supplied.0 {
322            self.temperature_f
323        } else {
324            flown.0 * 9.0 / 5.0 + 32.0
325        };
326        let (pressure_inhg, pressure_hpa) = if flown.1 == supplied.1 {
327            (self.pressure_inhg, self.pressure_hpa)
328        } else {
329            (flown.1 / HEADER_HPA_PER_INHG, flown.1)
330        };
331        Self::new(temperature_f, pressure_inhg, pressure_hpa)
332    }
333}
334
335/// Find font file - tries external locations first (for user overrides),
336/// then falls back to embedded fonts compiled into the binary.
337fn find_font_file(font_name: &str) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
338    let ttf = format!("{}.ttf", font_name);
339
340    // Try exe directory
341    if let Ok(exe_path) = std::env::current_exe() {
342        if let Some(exe_dir) = exe_path.parent() {
343            let font_path = exe_dir.join("fonts").join(&ttf);
344            if font_path.exists() {
345                return Ok(std::fs::read(font_path)?);
346            }
347        }
348    }
349
350    // Try home directory
351    if let Some(home) = dirs::home_dir() {
352        let font_path = home.join(".ballistics").join("fonts").join(&ttf);
353        if font_path.exists() {
354            return Ok(std::fs::read(font_path)?);
355        }
356    }
357
358    // Try working directory
359    for prefix in &["./fonts", "../fonts"] {
360        let font_path = std::path::Path::new(prefix).join(&ttf);
361        if font_path.exists() {
362            return Ok(std::fs::read(font_path)?);
363        }
364    }
365
366    // Try system font directories
367    #[cfg(target_os = "linux")]
368    {
369        for dir in &["/usr/share/fonts", "/usr/local/share/fonts"] {
370            if let Some(path) = find_in_directory(dir, &ttf) {
371                return Ok(std::fs::read(path)?);
372            }
373        }
374    }
375
376    #[cfg(target_os = "macos")]
377    {
378        for dir in &["/Library/Fonts", "/System/Library/Fonts"] {
379            let font_path = std::path::Path::new(dir).join(&ttf);
380            if font_path.exists() {
381                return Ok(std::fs::read(font_path)?);
382            }
383        }
384    }
385
386    #[cfg(target_os = "windows")]
387    {
388        if let Ok(windir) = std::env::var("WINDIR") {
389            let font_path = std::path::Path::new(&windir).join("Fonts").join(&ttf);
390            if font_path.exists() {
391                return Ok(std::fs::read(font_path)?);
392            }
393        }
394    }
395
396    // Fall back to embedded fonts
397    match font_name {
398        "LiberationSans-Regular" => Ok(FONT_REGULAR.to_vec()),
399        "LiberationSans-Bold" => Ok(FONT_BOLD.to_vec()),
400        _ => Err(format!("Font {} not found", font_name).into()),
401    }
402}
403
404/// Recursively search a directory for a font file by name
405#[cfg(target_os = "linux")]
406fn find_in_directory(dir: &str, filename: &str) -> Option<std::path::PathBuf> {
407    let dir_path = std::path::Path::new(dir);
408    if !dir_path.is_dir() {
409        return None;
410    }
411    for entry in std::fs::read_dir(dir_path).ok()?.flatten() {
412        let path = entry.path();
413        if path.is_file() && path.file_name().is_some_and(|n| n == filename) {
414            return Some(path);
415        }
416        if path.is_dir() {
417            if let Some(found) = find_in_directory(path.to_str()?, filename) {
418                return Some(found);
419            }
420        }
421    }
422    None
423}
424
425/// What a character the card font cannot draw is printed as.
426///
427/// A dropped glyph is INVISIBLE: printpdf emits nothing at all for a codepoint the embedded
428/// font has no entry for. Liberation Sans covers Latin, Latin-1, Cyrillic, Greek and the
429/// usual punctuation, and nothing else — so a card renamed "射撃カード 308" used to print a
430/// header reading "308", and an all-Arabic or all-Thai name printed a BLANK header, with no
431/// error and a normal byte length. A visible stand-in at least tells the shooter that
432/// something stood there; [`unprintable_chars`] reports WHAT, so the caller can warn before
433/// the paper leaves the printer.
434pub const UNPRINTABLE_SUBSTITUTE: char = '?';
435
436/// The distinct characters of `text` this card's font has no glyph for, in order of first
437/// use; empty when every character prints.
438///
439/// Resolved against the SAME face [`generate_dope_card_pdf`] draws with (the system copy of
440/// Liberation Sans when one is installed, the embedded copy otherwise), so this cannot
441/// disagree with the document. A font that will not parse at all reports nothing — that
442/// failure surfaces from the generator itself, as an error, rather than being recast here as
443/// "every character is unprintable".
444pub fn unprintable_chars(text: &str) -> String {
445    let Ok(bytes) = find_font_file("LiberationSans-Regular") else {
446        return String::new();
447    };
448    let mut warnings = Vec::new();
449    let Some(font) = ParsedFont::from_bytes(&bytes, 0, &mut warnings) else {
450        return String::new();
451    };
452    let mut out = String::new();
453    for c in text.chars() {
454        if font.lookup_glyph_index(c as u32).is_none() && !out.contains(c) {
455            out.push(c);
456        }
457    }
458    out
459}
460
461/// `text` with every character `font` cannot draw replaced by [`UNPRINTABLE_SUBSTITUTE`].
462fn substitute_unprintable(font: &ParsedFont, text: &str) -> String {
463    text.chars()
464        .map(|c| {
465            if font.lookup_glyph_index(c as u32).is_none() {
466                UNPRINTABLE_SUBSTITUTE
467            } else {
468                c
469            }
470        })
471        .collect()
472}
473
474/// Every caller-supplied string in `config`, with the characters this font cannot draw
475/// substituted. Applied to ALL of them — the title is the one an app sets from the card's
476/// name, but location, powder, bullet and the two provenance strings are user- or
477/// peer-supplied too, and a silently blank footer is the same defect as a silently blank
478/// header.
479fn substituting_unprintable(config: &DopeCardConfig, font: &ParsedFont) -> DopeCardConfig {
480    let sub = |text: &str| substitute_unprintable(font, text);
481    DopeCardConfig {
482        rifle_name: sub(&config.rifle_name),
483        location: sub(&config.location),
484        solver_mode: sub(&config.solver_mode),
485        powder: sub(&config.powder),
486        bullet: sub(&config.bullet),
487        drag_model: sub(&config.drag_model),
488        bc_label: sub(&config.bc_label),
489        elevation_unit_label: sub(&config.elevation_unit_label),
490        windage_unit_label: sub(&config.windage_unit_label),
491        engine_version: sub(&config.engine_version),
492        table_version: sub(&config.table_version),
493        truncation_note: sub(&config.truncation_note),
494        ..config.clone()
495    }
496}
497
498/// Truncate a string for header display, appending "..." if too long
499fn truncate_for_header(s: &str, max_chars: usize) -> String {
500    // Count/truncate by CHARACTERS, not bytes. The header concatenates user-controlled
501    // rifle/location names; byte-slicing a multi-byte UTF-8 string at an offset that isn't a
502    // char boundary panics. Identical output for ASCII (byte len == char count).
503    if s.chars().count() <= max_chars {
504        s.to_string()
505    } else if max_chars <= 3 {
506        s.chars().take(max_chars).collect()
507    } else {
508        let head: String = s.chars().take(max_chars - 3).collect();
509        format!("{head}...")
510    }
511}
512
513/// Draw a light gray separator line across the page width
514fn draw_separator_line(ops: &mut Vec<Op>, y: f32) {
515    ops.push(Op::SetOutlineColor {
516        col: Color::Rgb(Rgb::new(0.7, 0.7, 0.7, None)),
517    });
518    ops.push(Op::SetOutlineThickness { pt: Pt(0.3) });
519    ops.push(Op::DrawLine {
520        line: Line {
521            points: vec![
522                LinePoint {
523                    p: Point::new(Mm(MARGIN), Mm(y)),
524                    bezier: false,
525                },
526                LinePoint {
527                    p: Point::new(Mm(PAGE_WIDTH - MARGIN), Mm(y)),
528                    bezier: false,
529                },
530            ],
531            is_closed: false,
532        },
533    });
534}
535
536/// Data rows the two-column table fits on one page at `font_scale`.
537///
538/// Split out of [`generate_dope_card_pdf`] (which now calls it, so there is exactly one
539/// copy of this arithmetic) for callers that must report a page count without holding the
540/// document: the bridge's `card.pdf` returns `page_count` in its response, and a second,
541/// independent copy of the layout maths there could silently drift from the pagination the
542/// generator actually performed.
543///
544/// `font_scale` is clamped to [`FONT_SCALE_RANGE`] exactly as the generator clamps it.
545pub fn dope_card_rows_per_page(font_scale: f32) -> usize {
546    let row_height =
547        ROW_HEIGHT * font_scale.clamp(*FONT_SCALE_RANGE.start(), *FONT_SCALE_RANGE.end());
548    // Leave space for header/footer + separators
549    let usable_height = PAGE_HEIGHT - (2.0 * MARGIN) - 36.0;
550    // The clamp above bounds row_height to 2.25..=13.5 mm against a ~223 mm usable
551    // height, so this is 16..=52 in practice; `.max(1)` only guarantees the caller's
552    // `div_ceil` below can never divide by zero if those page constants are ever edited.
553    let visual_rows_per_page = ((usable_height / row_height) as usize).clamp(1, 52);
554    // Each visual row shows 2 data points (left + right columns)
555    visual_rows_per_page * 2
556}
557
558/// Pages [`generate_dope_card_pdf`] emits for `row_count` rows at `font_scale`.
559///
560/// `0` rows is `0` pages — that call errors rather than producing an empty document, so a
561/// zero here is a caller's row set to reject, not a document to describe.
562pub fn dope_card_page_count(row_count: usize, font_scale: f32) -> usize {
563    row_count.div_ceil(dope_card_rows_per_page(font_scale))
564}
565
566/// Generate a dope card PDF matching Glenn's format with row striping.
567///
568/// `rows` is display-ready per `CardRow`'s convention (already converted to the card's
569/// chosen angular unit, and to `range_unit`); `range_unit` only selects the Range
570/// column's sub-header text ("Yd" or "M") -- it does not convert `row.range`.
571pub fn generate_dope_card_pdf(
572    config: &DopeCardConfig,
573    rows: &[CardRow],
574    range_unit: RangeUnit,
575) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
576    if rows.is_empty() {
577        return Err("generate_dope_card_pdf: rows must not be empty".into());
578    }
579    let mut doc = PdfDocument::new(&format!("{} Dope Card", config.rifle_name));
580
581    // Load and register fonts
582    let font_data = find_font_file("LiberationSans-Regular")?;
583    let mut font_warnings = Vec::new();
584    let parsed_font = ParsedFont::from_bytes(&font_data, 0, &mut font_warnings)
585        .ok_or("Failed to parse LiberationSans-Regular font")?;
586    let font = doc.add_font(&parsed_font);
587
588    let font_bold_data = find_font_file("LiberationSans-Bold")?;
589    let mut font_bold_warnings = Vec::new();
590    let parsed_font_bold = ParsedFont::from_bytes(&font_bold_data, 0, &mut font_bold_warnings)
591        .ok_or("Failed to parse LiberationSans-Bold font")?;
592    let font_bold = doc.add_font(&parsed_font_bold);
593
594    // A character this face cannot draw is dropped silently by printpdf, so substitute a
595    // VISIBLE stand-in for it (see `UNPRINTABLE_SUBSTITUTE`). The regular face is the
596    // authority: the bold face is the same family with the same coverage, and the header and
597    // footer — where the caller's own strings go — are drawn in the regular one.
598    let config = &substituting_unprintable(config, &parsed_font);
599
600    // Only scale the data table — header/footer stay at base size
601    // so they don't overflow or consume disproportionate page space
602    let font_scale = config
603        .font_scale
604        .clamp(*FONT_SCALE_RANGE.start(), *FONT_SCALE_RANGE.end());
605    let header_size = HEADER_FONT_SIZE; // UNSCALED
606    let table_size = TABLE_FONT_SIZE * font_scale; // SCALED
607    let footer_size = FOOTER_FONT_SIZE; // UNSCALED
608    let row_height = ROW_HEIGHT * font_scale; // SCALED
609
610    // Pagination lives in `dope_card_rows_per_page`/`dope_card_page_count` so a caller
611    // that must report the page count (the bridge's `card.pdf`) reads the same numbers
612    // this loop paginates by, instead of reimplementing them.
613    let data_rows_per_page = dope_card_rows_per_page(config.font_scale);
614    let total_pages = dope_card_page_count(rows.len(), config.font_scale);
615
616    let mut pages = Vec::with_capacity(total_pages);
617
618    for page_num in 0..total_pages {
619        let start_idx = page_num * data_rows_per_page;
620        let end_idx = std::cmp::min(start_idx + data_rows_per_page, rows.len());
621        let page_rows = &rows[start_idx..end_idx];
622
623        let mut ops = Vec::new();
624
625        render_page(
626            &mut ops,
627            &font,
628            &font_bold,
629            config,
630            page_rows,
631            range_unit,
632            page_num + 1,
633            total_pages,
634            header_size,
635            table_size,
636            footer_size,
637            row_height,
638            font_scale,
639            config.bold_data,
640        );
641
642        pages.push(PdfPage::new(Mm(PAGE_WIDTH), Mm(PAGE_HEIGHT), ops));
643    }
644
645    let mut save_warnings = Vec::new();
646    let bytes = doc
647        .with_pages(pages)
648        .save(&PdfSaveOptions::default(), &mut save_warnings);
649    Ok(bytes)
650}
651
652#[allow(clippy::too_many_arguments)] // Fixed-layout renderer keeps its page metrics explicit.
653fn render_page(
654    ops: &mut Vec<Op>,
655    font: &FontId,
656    font_bold: &FontId,
657    config: &DopeCardConfig,
658    rows: &[CardRow],
659    range_unit: RangeUnit,
660    page: usize,
661    _total_pages: usize,
662    header_size: f32,
663    table_size: f32,
664    footer_size: f32,
665    row_height: f32,
666    font_scale: f32,
667    bold_data: bool,
668) {
669    let mut y = PAGE_HEIGHT - MARGIN;
670
671    // Header line 1 (auto-truncate long text)
672    let header1 = truncate_for_header(
673        &format!(
674            "{} Loc: {} DA:{:.0} ft Pressure:{:.2}/{:.0} Temp:{:.0} Alt:{:.0} Wind:{:.0} Mph",
675            config.rifle_name,
676            config.location,
677            config.density_altitude_ft,
678            config.pressure_inhg,
679            config.pressure_hpa,
680            config.temperature_f,
681            config.altitude_ft,
682            config.wind_speed_mph
683        ),
684        77,
685    );
686    draw_centered_text(ops, font, header_size, y, &header1, COLOR_BLACK);
687    y -= 4.0;
688
689    // Header line 2
690    let header2 = format!(
691        "TargetSpeed:{:.0} Solver: {} - Pg {}",
692        config.target_speed_mph, config.solver_mode, page
693    );
694    draw_centered_text(ops, font, header_size, y, &header2, COLOR_BLACK);
695    y -= 1.0;
696
697    // Separator line after header
698    draw_separator_line(ops, y);
699    y -= 5.0;
700
701    // Table start position
702    let table_x = (PAGE_WIDTH - (8.0 * COL_WIDTH)) / 2.0;
703
704    // Draw table header
705    draw_table_header(
706        ops,
707        font_bold,
708        table_x,
709        y,
710        table_size,
711        font_scale,
712        range_unit,
713        &config.elevation_unit_label,
714        &config.windage_unit_label,
715    );
716    y -= row_height;
717
718    // Split rows into left and right columns
719    let mid = rows.len().div_ceil(2);
720    let (left_rows, right_rows) = rows.split_at(mid);
721
722    // Select font for data rows (bold or regular)
723    let data_font = if bold_data { font_bold } else { font };
724
725    // Draw data rows with striping
726    for (i, left) in left_rows.iter().enumerate() {
727        let right = right_rows.get(i);
728
729        // Draw stripe background for alternating rows
730        if i % 2 == 1 {
731            draw_row_stripe(ops, table_x, y, 8.0 * COL_WIDTH, row_height);
732        }
733
734        // Draw left side data
735        draw_data_row(
736            ops,
737            data_font,
738            table_x,
739            y,
740            left,
741            true,
742            table_size,
743            font_scale,
744            &config.elevation_unit_label,
745            &config.windage_unit_label,
746        );
747
748        // Draw right side data
749        if let Some(r) = right {
750            draw_data_row(
751                ops,
752                data_font,
753                table_x + 4.0 * COL_WIDTH,
754                y,
755                r,
756                false,
757                table_size,
758                font_scale,
759                &config.elevation_unit_label,
760                &config.windage_unit_label,
761            );
762        }
763
764        y -= row_height;
765    }
766
767    // Separator line before footer
768    draw_separator_line(ops, y - 1.0);
769    y -= 5.0;
770
771    // Footer line 1: load data. A ladder card's `bc_label` replaces the scalar BC group
772    // (MBA-1601); an empty one prints exactly the old text.
773    let bc_group = if config.bc_label.is_empty() {
774        format!("{:.3} ({})", config.bc, config.drag_model.to_lowercase())
775    } else {
776        config.bc_label.clone()
777    };
778    let footer1 = format!(
779        "Powder:{} Bullet:{} Weight:{:.0}gr BC:{} Vel:{:.0}fps",
780        config.powder, config.bullet, config.weight_gr, bc_group, config.velocity_fps,
781    );
782    draw_centered_text(ops, font, footer_size, y, &footer1, COLOR_BLACK);
783    y -= 4.0;
784
785    // Footer line 2: timestamp, plus the provenance of the numbers above it. Truncated
786    // because both strings are caller-supplied and drawn on every page (the same reason the
787    // header truncates), and omitted entirely when empty rather than printing a placeholder.
788    let mut footer2 = get_timestamp();
789    if !config.engine_version.is_empty() {
790        footer2.push_str(&format!(
791            " Engine:{}",
792            truncate_for_header(&config.engine_version, 24)
793        ));
794    }
795    if !config.table_version.is_empty() {
796        footer2.push_str(&format!(
797            " Table:{}",
798            truncate_for_header(&config.table_version, 24)
799        ));
800    }
801    draw_centered_text(ops, font, footer_size, y, &footer2, COLOR_BLACK);
802
803    // Footer line 3: this card stops short of what was asked for (MBA-1477). Drawn on EVERY
804    // page for the same reason the provenance is — pages get separated, and the page in a
805    // shooter's hand is the one that has to be self-describing. Bold and red because it is
806    // not provenance: it is the difference between "the card ends here" and "the bullet ends
807    // here", and a field card is read in bad light. Omitted entirely when the card ran to its
808    // requested end, so an untruncated card is laid out exactly as before — which is also why
809    // the extra line is NOT reserved in `dope_card_rows_per_page`: reserving it would move
810    // every complete card's pagination to make room for a line it never prints. The line fits
811    // inside the bottom margin at every shipped font scale (>=16.9 mm at 0.5/0.8/1.0/1.4/2.0/
812    // 3.0) and stays on the page for every `--font-scale` in between (>=9.9 mm at the worst,
813    // ~2.92).
814    if !config.truncation_note.is_empty() {
815        y -= 4.0;
816        draw_centered_text(
817            ops,
818            font_bold,
819            footer_size,
820            y,
821            &truncate_for_header(&config.truncation_note, FOOTER_NOTE_MAX_CHARS),
822            COLOR_RED,
823        );
824    }
825}
826
827fn draw_row_stripe(ops: &mut Vec<Op>, x: f32, y: f32, width: f32, height: f32) {
828    let points = vec![
829        LinePoint {
830            p: Point::new(Mm(x), Mm(y)),
831            bezier: false,
832        },
833        LinePoint {
834            p: Point::new(Mm(x + width), Mm(y)),
835            bezier: false,
836        },
837        LinePoint {
838            p: Point::new(Mm(x + width), Mm(y - height)),
839            bezier: false,
840        },
841        LinePoint {
842            p: Point::new(Mm(x), Mm(y - height)),
843            bezier: false,
844        },
845    ];
846
847    ops.push(Op::SetFillColor {
848        col: Color::Rgb(Rgb::new(
849            COLOR_STRIPE.0,
850            COLOR_STRIPE.1,
851            COLOR_STRIPE.2,
852            None,
853        )),
854    });
855    ops.push(Op::DrawPolygon {
856        polygon: Polygon {
857            rings: vec![PolygonRing { points }],
858            mode: PaintMode::Fill,
859            winding_order: WindingOrder::NonZero,
860        },
861    });
862}
863
864#[allow(clippy::too_many_arguments)] // Drawing primitive mirrors the PDF text/layout parameters.
865fn draw_table_header(
866    ops: &mut Vec<Op>,
867    font: &FontId,
868    x: f32,
869    y: f32,
870    table_size: f32,
871    font_scale: f32,
872    range_unit: RangeUnit,
873    elevation_unit: &str,
874    windage_unit: &str,
875) {
876    let headers = [
877        ("Range", COLOR_BLACK),
878        ("Drop", COLOR_RED),
879        ("Wind", COLOR_GREEN),
880        ("Lead", COLOR_BLUE),
881        ("Range", COLOR_BLACK),
882        ("Drop", COLOR_RED),
883        ("Wind", COLOR_GREEN),
884        ("Lead", COLOR_BLUE),
885    ];
886    // Range columns use the card's range unit (Yd/M); Drop is the elevation unit,
887    // Wind/Lead the (possibly different, MBA-1410) windage unit.
888    let range_label = range_unit.label();
889    let sub_headers = [
890        range_label,
891        elevation_unit,
892        windage_unit,
893        windage_unit,
894        range_label,
895        elevation_unit,
896        windage_unit,
897        windage_unit,
898    ];
899
900    for (i, ((header, color), sub)) in headers.iter().zip(sub_headers.iter()).enumerate() {
901        let col_x = x + (i as f32 * COL_WIDTH) + (COL_WIDTH / 2.0);
902        draw_text(ops, font, table_size, col_x, y, header, *color, true);
903        draw_text(
904            ops,
905            font,
906            table_size - 1.0,
907            col_x,
908            y - 3.0 * font_scale,
909            sub,
910            *color,
911            true,
912        );
913    }
914}
915
916/// Formats one Drop/Wind/Lead cell value for its column's angular unit (MBA-1410 fold-in
917/// of an MBA-1355 backlog minor): whole turret clicks are integers, so `unit_label ==
918/// "CLICKS"` prints with no decimal point -- every other unit (MIL/MOA/SMOA/IPHY) keeps
919/// the pre-existing one-decimal-place format. Before this fix, a clicks dope card printed
920/// e.g. "5.0" instead of "5" for every cell.
921///
922/// ONE decimal place is the contract for an angular adjustment on EVERY surface that shows
923/// these rows, screen included: a turret's resolution is 0.1, and a second decimal is a
924/// precision the shooter cannot dial. It is also how a printed card and a screen came to
925/// disagree -- 2.4478 MIL read `2.45` on screen against `2.4` on paper, half a click apart
926/// on a 0.1-mil turret. Linear columns are unaffected; they keep their own precision.
927///
928/// The rounding is Rust's: correct rounding of the value's exact binary expansion, with
929/// ties-to-even. That differs from rounding the SHORTEST DECIMAL spelling of the same
930/// double, which is what most platform number formatters do -- 7.35 is just below the tie
931/// and prints `7.3` here, while a decimal half-up formatter prints `7.4`. Any client that
932/// must agree with the paper has to match this, which is why
933/// `format_adjustment_pins_one_decimal_place_including_the_near_ties` pins the vectors.
934fn format_adjustment(value: f64, unit_label: &str) -> String {
935    if unit_label.eq_ignore_ascii_case("clicks") {
936        format!("{:.0}", value)
937    } else {
938        format!("{:.1}", value)
939    }
940}
941
942/// Fix-round I-1: renders an `Option<f64>` dope-card cell honestly. `Some(v)` delegates to
943/// `format_adjustment` exactly as before; `None` renders as an em-dash rather than a
944/// plausible-looking (but fake) `0.0`. `DopeCardRow` made drop/wind/lead mandatory, so
945/// this distinction didn't exist before Task 10 promoted the card onto `CardRow`, whose
946/// equivalent fields are `Option`. It matters because Task 11's adaptive card engine --
947/// the reason this module accepts `CardRow` at all -- always emits `lead_adj: None`; an
948/// `unwrap_or(0.0)` would print a full Lead column of confident-looking zeroes for a card
949/// that carries no lead data whatsoever.
950fn format_adjustment_cell(value: Option<f64>, unit_label: &str) -> String {
951    match value {
952        Some(v) => format_adjustment(v, unit_label),
953        None => "—".to_string(),
954    }
955}
956
957/// Formats `CardRow::range` for the dope card's Range column. The pre-Task-10
958/// `DopeCardRow` stored range as `u32` yards -- always a bare integer. `CardRow::range`
959/// is `f64`: the `trajectory -o pdf` call site now explicitly rounds to whole yards
960/// before building each row (`main.rs::dope_card_row_from_sample`, fix-round C-1 --
961/// sampled ranges are exact multiples of a metre-denominated sample interval, so their
962/// yard conversion is genuinely fractional and would NOT fall into the noise band below
963/// on its own), so its rows always land in the integer branch here; a future caller with
964/// genuinely fractional ranges (Task 11/12's adaptive cards) renders with one decimal
965/// instead, e.g. `417.3` -> "417.3". The `< 0.05` band exists only to absorb ordinary
966/// floating-point rounding noise around an already-whole number, not to "round" a
967/// meaningfully fractional value down to the nearest integer.
968fn format_range(range: f64) -> String {
969    let rounded = range.round();
970    if (range - rounded).abs() < 0.05 {
971        format!("{:.0}", rounded)
972    } else {
973        format!("{:.1}", range)
974    }
975}
976
977#[allow(clippy::too_many_arguments)] // Drawing primitive mirrors the PDF text/layout parameters.
978fn draw_data_row(
979    ops: &mut Vec<Op>,
980    font: &FontId,
981    x: f32,
982    y: f32,
983    row: &CardRow,
984    _is_left: bool,
985    table_size: f32,
986    font_scale: f32,
987    elevation_unit: &str,
988    windage_unit: &str,
989) {
990    let values = [
991        (format_range(row.range), COLOR_BLACK),
992        (
993            format_adjustment_cell(row.drop_adj, elevation_unit),
994            COLOR_RED,
995        ),
996        (
997            format_adjustment_cell(row.wind_adj, windage_unit),
998            COLOR_GREEN,
999        ),
1000        (
1001            format_adjustment_cell(row.lead_adj, windage_unit),
1002            COLOR_BLUE,
1003        ),
1004    ];
1005
1006    for (i, (value, color)) in values.iter().enumerate() {
1007        let col_x = x + (i as f32 * COL_WIDTH) + (COL_WIDTH / 2.0);
1008        draw_text(
1009            ops,
1010            font,
1011            table_size,
1012            col_x,
1013            y - 2.5 * font_scale,
1014            value,
1015            *color,
1016            true,
1017        );
1018    }
1019}
1020
1021/// Push the PDF ops for a run of text, wrapped in its own text section.
1022///
1023/// `x`/`y` are the baseline origin in mm (matches printpdf 0.7's `use_text` convention).
1024fn draw_text_ops(
1025    ops: &mut Vec<Op>,
1026    font: &FontId,
1027    size: f32,
1028    x: f32,
1029    y: f32,
1030    text: &str,
1031    color: (f32, f32, f32),
1032) {
1033    ops.push(Op::StartTextSection);
1034    ops.push(Op::SetFillColor {
1035        col: Color::Rgb(Rgb::new(color.0, color.1, color.2, None)),
1036    });
1037    ops.push(Op::SetFont {
1038        font: PdfFontHandle::External(font.clone()),
1039        size: Pt(size),
1040    });
1041    ops.push(Op::SetLineHeight { lh: Pt(size) });
1042    ops.push(Op::SetTextCursor {
1043        pos: Point::new(Mm(x), Mm(y)),
1044    });
1045    ops.push(Op::ShowText {
1046        items: vec![TextItem::Text(text.to_string())],
1047    });
1048    ops.push(Op::EndTextSection);
1049}
1050
1051#[allow(clippy::too_many_arguments)] // Drawing primitive mirrors the PDF text/layout parameters.
1052fn draw_text(
1053    ops: &mut Vec<Op>,
1054    font: &FontId,
1055    size: f32,
1056    x: f32,
1057    y: f32,
1058    text: &str,
1059    color: (f32, f32, f32),
1060    center: bool,
1061) {
1062    // Approximate centering by estimating text width (printpdf 0.10 has no simple
1063    // string-width lookup for an arbitrary font+size, so keep the same heuristic
1064    // the 0.7-era code used rather than reaching for glyph-level metrics).
1065    let text_width = if center {
1066        text.len() as f32 * size * 0.3 // Rough approximation
1067    } else {
1068        0.0
1069    };
1070
1071    draw_text_ops(ops, font, size, x - text_width / 2.0, y, text, color);
1072}
1073
1074fn draw_centered_text(
1075    ops: &mut Vec<Op>,
1076    font: &FontId,
1077    size: f32,
1078    y: f32,
1079    text: &str,
1080    color: (f32, f32, f32),
1081) {
1082    // Center on page (same width approximation as draw_text)
1083    let text_width = text.len() as f32 * size * 0.28;
1084    let x = (PAGE_WIDTH - text_width) / 2.0;
1085
1086    draw_text_ops(ops, font, size, x, y, text, color);
1087}
1088
1089fn get_timestamp() -> String {
1090    use std::time::{SystemTime, UNIX_EPOCH};
1091
1092    let now = SystemTime::now()
1093        .duration_since(UNIX_EPOCH)
1094        .unwrap_or_default()
1095        .as_secs();
1096
1097    let secs_per_day = 86400u64;
1098    let secs_per_hour = 3600u64;
1099    let secs_per_min = 60u64;
1100
1101    let days_since_epoch = now / secs_per_day;
1102    let time_of_day = now % secs_per_day;
1103
1104    let hours = time_of_day / secs_per_hour;
1105    let minutes = (time_of_day % secs_per_hour) / secs_per_min;
1106    let seconds = time_of_day % secs_per_min;
1107
1108    let mut year = 1970;
1109    let mut remaining_days = days_since_epoch as i64;
1110
1111    loop {
1112        let days_in_year = if is_leap_year(year) { 366 } else { 365 };
1113        if remaining_days < days_in_year {
1114            break;
1115        }
1116        remaining_days -= days_in_year;
1117        year += 1;
1118    }
1119
1120    let days_in_months: [i64; 12] = if is_leap_year(year) {
1121        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
1122    } else {
1123        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
1124    };
1125
1126    let month_names = [
1127        "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
1128    ];
1129    let day_names = ["Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed"];
1130
1131    let mut month = 0;
1132    for (i, &days) in days_in_months.iter().enumerate() {
1133        if remaining_days < days {
1134            month = i;
1135            break;
1136        }
1137        remaining_days -= days;
1138    }
1139
1140    let day = remaining_days + 1;
1141    // The Unix epoch (1970-01-01) was a Thursday and day_names is Thursday-first, so no offset.
1142    let day_of_week = (days_since_epoch % 7) as usize;
1143
1144    let (hour_12, am_pm) = if hours == 0 {
1145        (12, "AM")
1146    } else if hours < 12 {
1147        (hours, "AM")
1148    } else if hours == 12 {
1149        (12, "PM")
1150    } else {
1151        (hours - 12, "PM")
1152    };
1153
1154    format!(
1155        "{} {} {:02} {:02}:{:02}:{:02} {} UTC {}",
1156        day_names[day_of_week], month_names[month], day, hour_12, minutes, seconds, am_pm, year
1157    )
1158}
1159
1160fn is_leap_year(year: i64) -> bool {
1161    (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
1162}
1163
1164#[cfg(test)]
1165mod tests {
1166    use super::*;
1167
1168    #[test]
1169    fn test_density_altitude() {
1170        // MBA-643: Test with Glenn's reference conditions
1171        // altitude=2500ft, pressure=27.32inHg (station), temp=55°F
1172        // Glenn's tool: DA ≈ 2835 ft
1173        let da = calculate_density_altitude(2500.0, 27.32, 55.0);
1174        assert!(
1175            da > 2500.0 && da < 3500.0,
1176            "DA should be ~3000 ft for near-standard conditions, got {}",
1177            da
1178        );
1179
1180        // Higher temp should increase DA
1181        let da_hot = calculate_density_altitude(2500.0, 27.32, 95.0);
1182        assert!(da_hot > da, "Higher temp should increase DA");
1183
1184        // Lower pressure (thinner air) should increase DA
1185        let da_low_press = calculate_density_altitude(2500.0, 25.0, 55.0);
1186        assert!(da_low_press > da, "Lower pressure should increase DA");
1187
1188        // Standard conditions at sea level: DA ≈ 0
1189        let da_standard = calculate_density_altitude(0.0, 29.92, 59.0);
1190        assert!(
1191            da_standard.abs() < 100.0,
1192            "Standard conditions should give DA near 0, got {}",
1193            da_standard
1194        );
1195    }
1196
1197    /// MBA-1410 fold-in: a clicks dope-card cell must print as a bare integer ("5"), not
1198    /// "5.0" -- the trailing-`.0` bug tracked as an MBA-1355 backlog minor. Every other
1199    /// unit keeps its pre-existing one-decimal-place format.
1200    #[test]
1201    fn format_adjustment_drops_the_decimal_for_clicks_only() {
1202        assert_eq!(format_adjustment(5.0, "CLICKS"), "5");
1203        assert_eq!(format_adjustment(-3.0, "CLICKS"), "-3");
1204        assert_eq!(format_adjustment(5.0, "clicks"), "5", "case-insensitive");
1205        assert_eq!(format_adjustment(2.34, "MIL"), "2.3");
1206        assert_eq!(format_adjustment(2.34, "MOA"), "2.3");
1207        assert_eq!(format_adjustment(2.34, "SMOA"), "2.3");
1208        assert_eq!(format_adjustment(2.34, "IPHY"), "2.3");
1209    }
1210
1211    /// The one-decimal contract, pinned as vectors any other surface can be held to.
1212    ///
1213    /// Every client that renders these same rows -- the on-screen card in both mobile apps
1214    /// -- must produce these strings from these doubles, or a shooter's screen and his paper
1215    /// disagree. The last four are the cases that catch a mismatch: `6.25` is an exact binary
1216    /// tie (ties-to-even, so `6.2`), `7.35` and `0.15` are just BELOW their ties (`7.3`,
1217    /// `0.1`) while `2.35` is just above (`2.4`). A formatter that rounds the shortest
1218    /// decimal spelling half-up prints `6.3`, `7.4` and `0.2` for the first three.
1219    #[test]
1220    fn format_adjustment_pins_one_decimal_place_including_the_near_ties() {
1221        for (value, expected) in [
1222            (0.0_f64, "0.0"),
1223            (0.65, "0.7"),
1224            (1.4551, "1.5"),
1225            (2.4478, "2.4"),
1226            (3.575, "3.6"),
1227            (4.8412, "4.8"),
1228            (-0.105, "-0.1"),
1229            (-0.21, "-0.2"),
1230            (-0.315, "-0.3"),
1231            (-0.42, "-0.4"),
1232            (-0.55, "-0.6"),
1233            (-0.65, "-0.7"),
1234            (-0.75, "-0.8"),
1235            (6.25, "6.2"),
1236            (7.35, "7.3"),
1237            (0.15, "0.1"),
1238            (2.35, "2.4"),
1239        ] {
1240            assert_eq!(format_adjustment(value, "MIL"), expected, "{value:?}");
1241            assert_eq!(format_adjustment(value, "MOA"), expected, "{value:?}");
1242        }
1243    }
1244
1245    /// Fix-round I-1: a missing column must render as an honest em-dash, never a
1246    /// plausible-looking fake `0.0` -- pinned for both a decimal unit and a clicks unit,
1247    /// since a naive fix might special-case `None` only inside one branch of
1248    /// `format_adjustment`'s clicks/non-clicks split.
1249    #[test]
1250    fn format_adjustment_cell_renders_none_as_an_em_dash_not_a_fake_zero() {
1251        assert_eq!(format_adjustment_cell(Some(2.34), "MIL"), "2.3");
1252        assert_eq!(format_adjustment_cell(Some(5.0), "CLICKS"), "5");
1253        assert_eq!(format_adjustment_cell(None, "MIL"), "—");
1254        assert_eq!(format_adjustment_cell(None, "CLICKS"), "—");
1255    }
1256
1257    #[test]
1258    fn density_altitude_uses_published_nws_pressure_altitude_equation() {
1259        // 20.670988150011322 inHg is exactly 700 hPa under the standard conversion.
1260        // Keep the public-unit fixture literal independent of the production conversion constant.
1261        let pressure_inhg = 20.670_988_150_011_322;
1262        let density_altitude = calculate_density_altitude(0.0, pressure_inhg, 55.0);
1263
1264        assert!((density_altitude - 11_962.774_017_764_264).abs() < 1e-6);
1265
1266        let standard_pressure_inhg = 29.921_255_347_141_39;
1267        let standard_density_altitude =
1268            calculate_density_altitude(0.0, standard_pressure_inhg, 59.0);
1269        assert!(standard_density_altitude.abs() < 1e-9);
1270    }
1271
1272    /// MBA-1593: a header restated for the air a solve flew. Where the solve substituted (the
1273    /// sea-level defaults at 1500 m), the header is the flown air's; where it flew what it was
1274    /// handed, every field is the caller's own figure, bit for bit, density altitude included.
1275    #[test]
1276    fn header_atmosphere_states_the_flown_air_and_keeps_what_was_flown_as_supplied() {
1277        use crate::atmosphere::{
1278            resolve_station_conditions_with_pressure_mode, PressureReferenceMode,
1279        };
1280        let flown_at = |temperature_c: f64, pressure_hpa: f64, altitude_m: f64| {
1281            resolve_station_conditions_with_pressure_mode(
1282                temperature_c,
1283                pressure_hpa,
1284                altitude_m,
1285                PressureReferenceMode::Absolute,
1286            )
1287        };
1288
1289        let stated = HeaderAtmosphere::new(59.0, 1013.25 / 33.8639, 1013.25);
1290        let flown = flown_at(15.0, 1013.25, 1500.0);
1291        let header = stated.as_flown((15.0, 1013.25), flown);
1292        assert_eq!(header.temperature_f, flown.0 * 9.0 / 5.0 + 32.0);
1293        assert_eq!(header.pressure_hpa, flown.1);
1294        assert_eq!(header.pressure_inhg, flown.1 / 33.8639);
1295        assert_eq!(
1296            header.density_altitude_ft,
1297            calculate_density_altitude(0.0, header.pressure_inhg, header.temperature_f)
1298        );
1299        assert_eq!(
1300            format!(
1301                "{:.0} {:.2}/{:.0} {:.0}",
1302                header.density_altitude_ft,
1303                header.pressure_inhg,
1304                header.pressure_hpa,
1305                header.temperature_f
1306            ),
1307            "4919 24.97/846 41"
1308        );
1309
1310        // Explicit pressure, default temperature: only the temperature is substituted.
1311        let stated = HeaderAtmosphere::new(59.0, 845.6 / 33.8639, 845.6);
1312        let header = stated.as_flown((15.0, 845.6), flown_at(15.0, 845.6, 1500.0));
1313        assert_eq!(header.pressure_inhg, stated.pressure_inhg);
1314        assert_eq!(header.pressure_hpa, stated.pressure_hpa);
1315        assert_ne!(header.temperature_f, stated.temperature_f);
1316
1317        // Flown as supplied: unchanged, including a figure whose hPa round trip would not
1318        // reproduce it ((30.245 * 33.8639) / 33.8639 is 30.244999...).
1319        let supplied = ((59.5 - 32.0) * 5.0 / 9.0, 30.245 * 33.8639);
1320        let stated = HeaderAtmosphere::new(59.5, 30.245, 30.245 * 33.8639);
1321        let header = stated.as_flown(supplied, flown_at(supplied.0, supplied.1, 0.0));
1322        assert_eq!(header, stated);
1323        assert_ne!(supplied.1 / 33.8639, 30.245);
1324    }
1325
1326    // -----------------------------------------------------------------------------------
1327    // Task 10 (0.33.0 decision-support Plan B): CardRow-based range formatting + PDF
1328    // generation smoke tests.
1329    // -----------------------------------------------------------------------------------
1330
1331    /// Pinned brief examples: a legacy-integer range renders with no decimal point, a
1332    /// genuinely fractional one (as Task 11's adaptive engine can now produce) renders
1333    /// with exactly one.
1334    #[test]
1335    fn format_range_pinned_examples() {
1336        assert_eq!(format_range(400.0), "400");
1337        assert_eq!(format_range(417.3), "417.3");
1338    }
1339
1340    /// The pre-Task-10 call site rounded to `u32` before storing, so its yard conversion
1341    /// was never exactly integral (floating-point noise). The new `f64`-range path must
1342    /// still collapse that noise to the same bare integer -- this is the `< 0.05`
1343    /// tolerance's whole reason to exist.
1344    #[test]
1345    fn format_range_absorbs_floating_point_noise_around_a_whole_number() {
1346        assert_eq!(format_range(400.02), "400");
1347        assert_eq!(format_range(399.98), "400");
1348        assert_eq!(format_range(100.0 + 1e-9), "100");
1349        // The band rule now exists in two copies (this function and
1350        // `format_adaptive_card_range` in `main.rs`), so these fixtures are the sync
1351        // mechanism between them -- more coverage right at and near the +-0.05 edge.
1352        assert_eq!(format_range(400.04), "400");
1353        assert_eq!(format_range(399.96), "400");
1354        assert_eq!(format_range(0.0), "0");
1355    }
1356
1357    #[test]
1358    fn format_range_renders_one_decimal_outside_the_noise_band() {
1359        assert_eq!(format_range(417.3), "417.3");
1360        assert_eq!(format_range(100.5), "100.5");
1361        assert_eq!(format_range(400.08), "400.1");
1362        // 400.05 is exactly the band's edge in decimal, but not in binary: the nearest f64 to
1363        // 400.05 is a hair above it, so `(range - rounded).abs() < 0.05` is false and this
1364        // renders with one decimal rather than collapsing to "400".
1365        assert_eq!(format_range(400.05), "400.1");
1366    }
1367
1368    #[test]
1369    fn ladder_bc_label_states_the_band_range() {
1370        assert_eq!(
1371            ladder_bc_label("G1", &[0.45, 0.42], 0.4, false),
1372            "0.420-0.450 (g1 ladder)"
1373        );
1374        assert_eq!(
1375            ladder_bc_label("G1", &[0.45, 0.42], 0.4, true),
1376            "0.400-0.450 (g1 ladder+gap)"
1377        );
1378        assert_eq!(
1379            ladder_bc_label("G7", &[0.25], 0.243, false),
1380            "0.250 (g7 ladder)"
1381        );
1382        // Bands that differ only past the printed precision print as one figure.
1383        assert_eq!(
1384            ladder_bc_label("G7", &[0.2504, 0.2496], 0.243, false),
1385            "0.250 (g7 ladder)"
1386        );
1387        assert_eq!(ladder_bc_label("G1", &[], 0.4, false), "");
1388    }
1389
1390    fn test_config() -> DopeCardConfig {
1391        DopeCardConfig {
1392            rifle_name: "Test Rifle".to_string(),
1393            location: "Test Range".to_string(),
1394            density_altitude_ft: 1500.0,
1395            pressure_inhg: 29.92,
1396            pressure_hpa: 1013.25,
1397            temperature_f: 59.0,
1398            altitude_ft: 1000.0,
1399            wind_speed_mph: 5.0,
1400            target_speed_mph: 0.0,
1401            solver_mode: "offline".to_string(),
1402            powder: "Test Powder".to_string(),
1403            bullet: "Test Bullet".to_string(),
1404            weight_gr: 175.0,
1405            bc: 0.5,
1406            drag_model: "g7".to_string(),
1407            bc_label: String::new(),
1408            velocity_fps: 2700.0,
1409            font_scale: 1.0,
1410            bold_data: false,
1411            elevation_unit_label: "MIL".to_string(),
1412            windage_unit_label: "MIL".to_string(),
1413            engine_version: "0.0.0-test".to_string(),
1414            table_version: String::new(),
1415            truncation_note: String::new(),
1416        }
1417    }
1418
1419    fn test_row(range: f64) -> CardRow {
1420        CardRow {
1421            range,
1422            drop_linear: None,
1423            drop_adj: Some(1.0),
1424            come_up: None,
1425            wind_linear: None,
1426            wind_adj: Some(0.5),
1427            velocity: None,
1428            energy: None,
1429            time: None,
1430            lead_adj: Some(0.2),
1431            wind_columns: Vec::new(),
1432        }
1433    }
1434
1435    fn assert_valid_pdf_bytes(bytes: &[u8]) {
1436        assert!(!bytes.is_empty(), "PDF output must not be empty");
1437        assert_eq!(&bytes[..5], b"%PDF-", "output must start with a PDF header");
1438    }
1439
1440    #[test]
1441    fn generate_dope_card_pdf_is_non_empty_for_a_single_row() {
1442        let config = test_config();
1443        let rows = vec![test_row(100.0)];
1444        let bytes = generate_dope_card_pdf(&config, &rows, RangeUnit::Yards)
1445            .expect("single-row dope card should generate");
1446        assert_valid_pdf_bytes(&bytes);
1447    }
1448
1449    /// 120 rows forces the two-column, ~98-rows-per-page layout past a single page
1450    /// (`data_rows_per_page` is well under 120 at the default font scale), exercising the
1451    /// pagination path `generate_dope_card_pdf` walks via `total_pages`/`page_rows`.
1452    #[test]
1453    fn generate_dope_card_pdf_is_non_empty_for_120_rows_pagination() {
1454        let config = test_config();
1455        let rows: Vec<CardRow> = (0..120)
1456            .map(|i| test_row(100.0 + i as f64 * 25.0))
1457            .collect();
1458        let bytes = generate_dope_card_pdf(&config, &rows, RangeUnit::Meters)
1459            .expect("120-row dope card should generate and paginate");
1460        assert_valid_pdf_bytes(&bytes);
1461    }
1462
1463    /// Fix-round I-1: Task 11's adaptive card engine -- the reason this module accepts
1464    /// `CardRow` at all -- always emits `lead_adj: None`. The full renderer must still
1465    /// produce a valid PDF for that row shape (via `format_adjustment_cell`'s em-dash,
1466    /// not `unwrap_or(0.0)`'s fake zero); this exercises `draw_data_row` end to end,
1467    /// while `format_adjustment_cell_renders_none_as_an_em_dash_not_a_fake_zero` above
1468    /// pins the exact string.
1469    #[test]
1470    fn generate_dope_card_pdf_succeeds_when_lead_adj_is_none() {
1471        let config = test_config();
1472        let mut row = test_row(100.0);
1473        row.lead_adj = None;
1474        let bytes = generate_dope_card_pdf(&config, &[row], RangeUnit::Yards)
1475            .expect("a row missing lead_adj must still render, not error");
1476        assert_valid_pdf_bytes(&bytes);
1477    }
1478
1479    /// A zero-page PDF is not a useful answer from a public library API: both in-tree callers
1480    /// (`trajectory -o pdf`, `adaptive-card -o pdf`) already guard against empty rows
1481    /// themselves, but a binding calling this function directly should get a named error, not
1482    /// a silently-empty document.
1483    #[test]
1484    fn generate_dope_card_pdf_rejects_empty_rows() {
1485        let config = test_config();
1486        let err = generate_dope_card_pdf(&config, &[], RangeUnit::Yards)
1487            .expect_err("an empty row set must not silently produce a PDF");
1488        assert!(err.to_string().contains("rows"), "{err}");
1489    }
1490
1491    /// The pagination `generate_dope_card_pdf` uses is now a public function (the bridge's
1492    /// `card.pdf` reports a page count from it), so pin its numbers: they are part of that
1493    /// response contract, and a silent change here would silently mislabel every PDF the
1494    /// bridge returns.
1495    #[test]
1496    fn dope_card_rows_per_page_pins_the_preset_scales() {
1497        // 279.4 - 20 - 36 = 223.4 mm usable / (4.5 * scale) mm per visual row, floored,
1498        // capped at 52 visual rows, doubled for the two-column layout.
1499        // The 52-visual-row cap bites below ~0.955 scale (223.4 / 4.5 / 52), so Small and
1500        // every scale under it are cap-limited rather than height-limited — which is why
1501        // Small and the 0.5 floor agree.
1502        assert_eq!(dope_card_rows_per_page(FontSizePreset::Small.scale()), 104);
1503        assert_eq!(dope_card_rows_per_page(FontSizePreset::Medium.scale()), 98);
1504        assert_eq!(dope_card_rows_per_page(FontSizePreset::Large.scale()), 70);
1505        assert_eq!(dope_card_rows_per_page(0.5), 104);
1506        // Out-of-band scales are clamped, exactly as the generator clamps them.
1507        assert_eq!(dope_card_rows_per_page(0.01), dope_card_rows_per_page(0.5));
1508        assert_eq!(dope_card_rows_per_page(99.0), dope_card_rows_per_page(3.0));
1509    }
1510
1511    #[test]
1512    fn dope_card_page_count_rounds_up_and_reports_zero_for_no_rows() {
1513        let per_page = dope_card_rows_per_page(1.0);
1514        assert_eq!(dope_card_page_count(0, 1.0), 0);
1515        assert_eq!(dope_card_page_count(1, 1.0), 1);
1516        assert_eq!(dope_card_page_count(per_page, 1.0), 1);
1517        assert_eq!(dope_card_page_count(per_page + 1, 1.0), 2);
1518        assert_eq!(dope_card_page_count(per_page * 3, 1.0), 3);
1519    }
1520
1521    /// The generator must paginate by the same function it reports: a PDF built from
1522    /// `per_page + 1` rows has to carry a second page's worth of ops, which shows up as a
1523    /// materially larger document than the single-page case.
1524    #[test]
1525    fn generated_pdf_grows_when_page_count_grows() {
1526        let config = test_config();
1527        let per_page = dope_card_rows_per_page(config.font_scale);
1528        let rows: Vec<CardRow> = (0..per_page).map(|i| test_row(100.0 + i as f64)).collect();
1529        let one_page = generate_dope_card_pdf(&config, &rows, RangeUnit::Yards).unwrap();
1530        assert_eq!(dope_card_page_count(rows.len(), config.font_scale), 1);
1531
1532        let mut rows_plus_one = rows.clone();
1533        rows_plus_one.push(test_row(100.0 + per_page as f64));
1534        let two_pages = generate_dope_card_pdf(&config, &rows_plus_one, RangeUnit::Yards).unwrap();
1535        assert_eq!(
1536            dope_card_page_count(rows_plus_one.len(), config.font_scale),
1537            2
1538        );
1539        assert!(
1540            two_pages.len() > one_page.len(),
1541            "a second page must add bytes: {} vs {}",
1542            one_page.len(),
1543            two_pages.len()
1544        );
1545    }
1546
1547    /// `RangeUnit::label()` untested was Task 10 review Minor #3: a swapped `Yd`/`M` would
1548    /// ship silently on `adaptive-card -o pdf`'s Range column sub-header.
1549    #[test]
1550    fn range_unit_label_matches_its_variant() {
1551        assert_eq!(RangeUnit::Yards.label(), "Yd");
1552        assert_eq!(RangeUnit::Meters.label(), "M");
1553    }
1554}