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