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