diff --git a/.gitignore b/.gitignore index ea8c4bf..c881395 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +report.pdf diff --git a/src/main.rs b/src/main.rs index c4a5d26..fdb4cca 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,19 +1,20 @@ #[cfg(feature = "pdf")] pub mod pdf; -use clap::{Parser, ValueEnum}; -use colored::*; use chrono::Local; +use clap::{CommandFactory, Parser, ValueEnum, error::ErrorKind}; +use colored::*; use std::path::PathBuf; /// Concentration of plaster slurry /// in parts of water per 100 parts plaster by weight const DEFAULT_CONCENTRATION: f64 = 70.0; -/// Density of plaster slurry (g/L). +/// Density of plaster slurry (g/L) at the [`DEFAULT_CONCENTRATION`]. const DEFAULT_DENSITY: f64 = 1674.0; +const ML_PER_US_GALLON: f64 = 3785.411784; const PERCENT: f64 = 100.0; -/// The amount of slack added -const SLACK: f64 = 0.10; +/// The default amount of slack added +const DEFAULT_SLACK_PERCENT: f64 = 10.0; /// The multiplier for the water that becomes part of the mold /// /// 18 parts water for every part plaster becomes part @@ -36,11 +37,12 @@ enum ColorChoice { #[derive(Parser, Debug)] #[command(author, version, about = "Calculates plaster mixing weights", long_about = None)] struct Args { - /// Volume in cm³ or ml - volume: f64, + /// Volumes in cm³ or ml, separated by spaces or commas + #[arg(num_args = 1.., required = true)] + volumes: Vec, /// Optional title for the lab log (written large in the header) - #[arg(short = 't', long)] + #[arg(short, long)] title: Option, /// Concentration (parts water per 100 parts plaster by weight) @@ -51,14 +53,14 @@ struct Args { #[arg(short, long)] density: Option, + /// Extra mixing amount to add, as a percentage + #[arg(short, long, default_value_t = DEFAULT_SLACK_PERCENT)] + slack: f64, + /// Control terminal color output #[arg(long, default_value = "auto")] color: ColorChoice, - /// Number of casts to make simultaneously - #[arg(short = 'n', long, default_value_t = 1)] - quantity: u32, - /// Soak time (for the sheet). Will be recorded; provide as text (e.g. "10m"). #[arg(long)] soak_time: Option, @@ -68,12 +70,36 @@ struct Args { mix_time: Option, /// If provided, generate a PDF lab log at this path. If omitted no PDF will be written. - #[arg(long = "pdf-out")] - pdf_out: Option, + #[arg(short, long)] + report: Option, +} + +fn parse_volumes(raw_volumes: &[String]) -> Result, String> { + let volumes = raw_volumes + .iter() + .flat_map(|volume| volume.split(|c: char| c == ',' || c.is_whitespace())) + .filter(|volume| !volume.is_empty()) + .map(|volume| { + volume + .parse::() + .map_err(|_| format!("invalid volume `{volume}`")) + }) + .collect::, _>>()?; + + if volumes.is_empty() { + Err("provide at least one volume".to_string()) + } else { + Ok(volumes) + } } fn main() { let args = Args::parse(); + let volumes = parse_volumes(&args.volumes).unwrap_or_else(|err| { + Args::command() + .error(ErrorKind::ValueValidation, err) + .exit(); + }); // Configure color output globally based on the CLI argument match args.color { @@ -82,7 +108,7 @@ fn main() { ColorChoice::Auto => {} // Respects standard stream capabilities (default) } - // Determine density and target wet weight for a SINGLE cast + // Determine density and target weights for the requested casts let density = args.density.or_else(|| { if (args.concentration - DEFAULT_CONCENTRATION).abs() < f64::EPSILON { Some(DEFAULT_DENSITY) @@ -91,8 +117,14 @@ fn main() { } }); - let single_wet_weight = density.map(|d| (args.volume / 1000.0) * d); - let total_wet_weight = single_wet_weight.map(|w| w * args.quantity as f64); + let total_volume: f64 = volumes.iter().sum(); + let cast_wet_weights = density.map(|d| { + volumes + .iter() + .map(|volume| (volume / 1000.0) * d) + .collect::>() + }); + let total_wet_weight = density.map(|d| (total_volume / 1000.0) * d); let (plaster_total, water_total) = match total_wet_weight { Some(wet_weight) => { @@ -103,21 +135,36 @@ fn main() { None => (None, None), }; + let slack_fraction = args.slack / PERCENT; + // precompute slack and single dry weight as Option values so we can pass them - let plaster_slack = plaster_total.map(|p| p * (1.0 + SLACK)); - let water_slack = water_total.map(|w| w * (1.0 + SLACK)); - let single_plaster = plaster_total.map(|p| p / args.quantity as f64); - let single_dry_weight_opt = single_plaster.map(|sp| sp * DRY_WEIGHT_MULTIPLIER); + let plaster_slack = plaster_total.map(|p| p * (1.0 + slack_fraction)); + let water_slack = water_total.map(|w| w * (1.0 + slack_fraction)); + let cast_dry_weights = density.map(|d| { + volumes + .iter() + .map(|volume| { + let wet_weight = (volume / 1000.0) * d; + let plaster = wet_weight / (1.0 + (args.concentration / PERCENT)); + plaster * DRY_WEIGHT_MULTIPLIER + }) + .collect::>() + }); // Output Header println!("--- Plaster Calculation Parameters ---"); println!( - "Volume (per cast): {}ml", - format!("{}", args.volume).color(COLOR_PARAM).bold() + "Volumes: {}ml", + volumes + .iter() + .map(|volume| format!("{}", volume).color(COLOR_PARAM).bold().to_string()) + .collect::>() + .join(", ") ); println!( - "Quantity: {}", - format!("{}", args.quantity).color(COLOR_PARAM).bold() + "Total volume: {}ml ({:.2} gal)", + format!("{}", total_volume).color(COLOR_PARAM).bold(), + total_volume / ML_PER_US_GALLON ); println!( "Concentration: {}g water per 100g plaster", @@ -128,18 +175,23 @@ fn main() { format!("{}g/L", format!("{}", d).color(COLOR_PARAM).bold()) }); println!("Density: {}", density_str); + println!( + "Slack: {}%", + format!("{}", args.slack).color(COLOR_PARAM).bold() + ); println!("--------------------------------------"); // Output Mixing Amounts and Target Weights if let (Some(p_tot), Some(w_tot)) = (plaster_total, water_total) { - let p_slack = plaster_slack.unwrap_or(p_tot * (1.0 + SLACK)); - let w_slack = water_slack.unwrap_or(w_tot * (1.0 + SLACK)); + let p_slack = plaster_slack.unwrap_or(p_tot * (1.0 + slack_fraction)); + let w_slack = water_slack.unwrap_or(w_tot * (1.0 + slack_fraction)); - let single_dry_weight = single_dry_weight_opt.unwrap_or((p_tot / args.quantity as f64) * DRY_WEIGHT_MULTIPLIER); - let total_dry_weight = single_dry_weight * args.quantity as f64; + let cast_wet_weights = cast_wet_weights.as_ref().unwrap(); + let cast_dry_weights = cast_dry_weights.as_ref().unwrap(); + let total_dry_weight: f64 = cast_dry_weights.iter().sum(); - if args.quantity > 1 { - println!("Combined Mixing Amounts (for {} casts):", args.quantity); + if volumes.len() > 1 { + println!("Combined Mixing Amounts (for {} casts):", volumes.len()); println!(" Nominal Total:"); } else { println!("Mixing Amounts:"); @@ -148,49 +200,56 @@ fn main() { println!( " Plaster: {}g", - format!("{:.1}", p_tot).color(COLOR_PLASTER) + format!("{:.0}", p_tot).color(COLOR_PLASTER) ); println!( " Water: {}g", - format!("{:.1}", w_tot).color(COLOR_WATER) + format!("{:.0}", w_tot).color(COLOR_WATER) ); - if args.quantity > 1 { - println!(" {:2}% Slack Total:", SLACK * PERCENT); + if volumes.len() > 1 { + println!(" {}% Slack Total:", args.slack); } else { - println!(" {:2}% Slack:", SLACK * PERCENT); + println!(" {}% Slack:", args.slack); } println!( " Plaster: {}g", - format!("{:.1}", p_slack).color(COLOR_PLASTER).bold() + format!("{:.0}", p_slack).color(COLOR_PLASTER).bold() ); println!( " Water: {}g", - format!("{:.1}", w_slack).color(COLOR_WATER).bold() + format!("{:.0}", w_slack).color(COLOR_WATER).bold() ); println!("\nTarget Weights:"); - if args.quantity > 1 { - println!(" Individual Cast:"); - } + for (index, ((volume, wet_weight), dry_weight)) in volumes + .iter() + .zip(cast_wet_weights.iter()) + .zip(cast_dry_weights.iter()) + .enumerate() + { + if volumes.len() > 1 { + println!(" Cast {} ({}ml):", index + 1, volume); + } println!( " Wet Cast: {}g", - format!("{:.1}", single_wet_weight.unwrap()).color(COLOR_TARGET).bold() + format!("{:.0}", wet_weight).color(COLOR_TARGET).bold() ); println!( " Dry Cast: {}g", - format!("{:.1}", single_dry_weight).color(COLOR_TARGET).bold() + format!("{:.0}", dry_weight).color(COLOR_TARGET).bold() ); - if args.quantity > 1 { + } + if volumes.len() > 1 { println!(" Total Batch:"); println!( " Wet Cast: {}g", - format!("{:.1}", total_wet_weight.unwrap()).color(COLOR_TARGET) + format!("{:.0}", total_wet_weight.unwrap()).color(COLOR_TARGET) ); println!( " Dry Cast: {}g", - format!("{:.1}", total_dry_weight).color(COLOR_TARGET) + format!("{:.0}", total_dry_weight).color(COLOR_TARGET) ); } } else { @@ -201,21 +260,21 @@ fn main() { // If user requested a PDF output path, generate a lab log PDF (or placeholder) #[cfg(feature = "pdf")] - if let Some(pdf_path) = args.pdf_out { + if let Some(pdf_path) = args.report { let out_path = PathBuf::from(pdf_path); let data = pdf::LabLogData { title: args.title.clone(), date: Local::now().format("%Y-%m-%d").to_string(), - volume_ml: args.volume, + volumes_ml: volumes.clone(), concentration: args.concentration, density, - quantity: args.quantity, + slack_percent: args.slack, plaster_total, water_total, plaster_slack, water_slack, - single_wet: single_wet_weight, - single_dry: single_dry_weight_opt, + cast_wet_weights, + cast_dry_weights, soak_time: args.soak_time.clone(), mix_time: args.mix_time.clone(), }; diff --git a/src/pdf.rs b/src/pdf.rs index 59ed77b..9dbc268 100644 --- a/src/pdf.rs +++ b/src/pdf.rs @@ -2,26 +2,30 @@ // This module exposes a `LabLogData` struct and a `generate_lab_log` function. // The actual PDF implementation uses `krilla` when the `pdf` feature is enabled. +use krilla::color::rgb; +use krilla::document::Document; +use krilla::geom::{PathBuilder, Point, Size}; +use krilla::num::NormalizedF32; +use krilla::page::PageSettings; +use krilla::paint::Fill; +use krilla::text::{Font, TextDirection}; use std::path::Path; use std::sync::Arc; -use krilla::geom::{Point, Size}; -use krilla::page::PageSettings; -use krilla::text::{Font, TextDirection}; #[derive(Debug, Clone)] pub struct LabLogData { pub title: Option, pub date: String, - pub volume_ml: f64, + pub volumes_ml: Vec, pub concentration: f64, pub density: Option, - pub quantity: u32, + pub slack_percent: f64, pub plaster_total: Option, pub water_total: Option, pub plaster_slack: Option, pub water_slack: Option, - pub single_wet: Option, - pub single_dry: Option, + pub cast_wet_weights: Option>, + pub cast_dry_weights: Option>, pub soak_time: Option, pub mix_time: Option, } @@ -33,92 +37,335 @@ pub fn generate_lab_log>(out_path: P, data: &LabLogData) -> Resul generate_lab_log_inner(out_path.as_ref(), data) } +const PAGE_WIDTH: f32 = 612.0; +const PAGE_HEIGHT: f32 = 792.0; +const MARGIN: f32 = 36.0; + // When the `pdf` feature is enabled we provide a krilla-based implementation. // The code below is compiled only when the feature is active. fn generate_lab_log_inner(out_path: &Path, data: &LabLogData) -> Result<(), String> { - // Use krilla to build a PDF layout using the correct surface.draw_text() API. - use krilla::document::Document; - // Create document and a single page let mut doc = Document::new(); - let mut page = doc.start_page_with(PageSettings::new(Size::from_wh(612.0, 792.0).unwrap())); // US Letter + let mut page = doc.start_page_with(PageSettings::new( + Size::from_wh(PAGE_WIDTH, PAGE_HEIGHT).unwrap(), + )); // US Letter + let mut surface = page.surface(); + // Load font + let noto_font = Font::new( + Arc::new(std::fs::read("font/NotoSans-Regular.ttf").unwrap()).into(), + 0, + ) + .unwrap(); - { - let mut surface = page.surface(); + let height = 80.0; + let rect = { + let mut pb = PathBuilder::new(); + pb.move_to(MARGIN, MARGIN); + pb.line_to(MARGIN, MARGIN + height); + pb.line_to(PAGE_WIDTH - MARGIN, MARGIN + height); + pb.line_to(PAGE_WIDTH - MARGIN, MARGIN); + pb.close(); - // Load font - let noto_font = Font::new( - Arc::new(std::fs::read("font/NotoSans-Regular.ttf").unwrap()).into(), - 0, - ).unwrap(); + pb.finish().unwrap() + }; + surface.set_fill(Some(Fill { + paint: rgb::Color::new(230, 230, 230).into(), + opacity: NormalizedF32::new(1.0).unwrap(), + rule: Default::default(), + })); + + surface.draw_path(&rect); + surface.set_fill(None); + + let today = chrono::Local::now().date_naive(); + let date_string = today.format("%b %d %Y").to_string(); + + surface.draw_text( + Point::from_xy(MARGIN, MARGIN + 30.0), + noto_font.clone(), + 28.0, + &date_string, + false, + TextDirection::Auto, + ); + + if true { // Header: Title (large, left) and date (top-right) + surface.set_fill(Some(Fill { + paint: rgb::Color::new(255, 0, 0).into(), + opacity: NormalizedF32::new(0.5).unwrap(), + rule: Default::default(), + })); if let Some(title) = &data.title { - surface.draw_text(Point::from_xy(40.0, 820.0), noto_font.clone(), 28.0, title, false, TextDirection::Auto); + surface.draw_text( + Point::from_xy(40.0, 700.0), + noto_font.clone(), + 28.0, + title, + false, + TextDirection::Auto, + ); } else { - surface.draw_text(Point::from_xy(40.0, 820.0), noto_font.clone(), 28.0, "Title: __________________________", false, TextDirection::Auto); + surface.draw_text( + Point::from_xy(40.0, 700.0), + noto_font.clone(), + 28.0, + "Title: __________________________", + false, + TextDirection::Auto, + ); } - surface.draw_text(Point::from_xy(420.0, 800.0), noto_font.clone(), 12.0, &data.date, false, TextDirection::Auto); + surface.draw_text( + Point::from_xy(420.0, 800.0), + noto_font.clone(), + 12.0, + &data.date, + false, + TextDirection::Auto, + ); + surface.set_fill(None); // Subheading: volume, concentration, density - let density_str = data.density.map(|d| format!("{} g/L", d)).unwrap_or_else(|| "".to_string()); - let sub = format!("Volume: {} ml Concentration: {} Density: {}", data.volume_ml, data.concentration, density_str); - surface.draw_text(Point::from_xy(40.0, 760.0), noto_font.clone(), 16.0, &sub, false, TextDirection::Auto); + let density_str = data + .density + .map(|d| format!("{} g/L", d)) + .unwrap_or_else(|| "".to_string()); + let volumes = data + .volumes_ml + .iter() + .map(|volume| format!("{volume}")) + .collect::>() + .join(", "); + let total_volume: f64 = data.volumes_ml.iter().sum(); + let sub = if data.volumes_ml.len() > 1 { + format!( + "Volumes: {} ml Total: {} ml Concentration: {} Density: {}", + volumes, total_volume, data.concentration, density_str + ) + } else { + format!( + "Volume: {} ml Concentration: {} Density: {}", + volumes, data.concentration, density_str + ) + }; + surface.draw_text( + Point::from_xy(40.0, 760.0), + noto_font.clone(), + 16.0, + &sub, + false, + TextDirection::Auto, + ); // Mixing amounts section header - surface.draw_text(Point::from_xy(40.0, 720.0), noto_font.clone(), 14.0, "Mixing Amounts:", false, TextDirection::Auto); + surface.draw_text( + Point::from_xy(40.0, 720.0), + noto_font.clone(), + 14.0, + "Mixing Amounts:", + false, + TextDirection::Auto, + ); // Nominal amounts - let plaster = data.plaster_total.map(|p| format!("Plaster: {:.1} g", p)).unwrap_or_else(|| "Plaster: ______".to_string()); - let water = data.water_total.map(|w| format!("Water: {:.1} g", w)).unwrap_or_else(|| "Water: ______".to_string()); - surface.draw_text(Point::from_xy(60.0, 700.0), noto_font.clone(), 12.0, &plaster, false, TextDirection::Auto); - surface.draw_text(Point::from_xy(60.0, 684.0), noto_font.clone(), 12.0, &water, false, TextDirection::Auto); + let plaster = data + .plaster_total + .map(|p| format!("Plaster: {:.1} g", p)) + .unwrap_or_else(|| "Plaster: ______".to_string()); + let water = data + .water_total + .map(|w| format!("Water: {:.1} g", w)) + .unwrap_or_else(|| "Water: ______".to_string()); + surface.draw_text( + Point::from_xy(60.0, 700.0), + noto_font.clone(), + 12.0, + &plaster, + false, + TextDirection::Auto, + ); + surface.draw_text( + Point::from_xy(60.0, 684.0), + noto_font.clone(), + 12.0, + &water, + false, + TextDirection::Auto, + ); // Slack amounts + spot for actual amounts to the right - let p_slack = data.plaster_slack.map(|p| format!("{:.1} g", p)).unwrap_or_else(|| "____".to_string()); - let w_slack = data.water_slack.map(|w| format!("{:.1} g", w)).unwrap_or_else(|| "____".to_string()); - let slack_plaster_text = format!("10% Slack (Plaster): {}", p_slack); - let slack_water_text = format!("10% Slack (Water): {}", w_slack); - surface.draw_text(Point::from_xy(60.0, 650.0), noto_font.clone(), 12.0, &slack_plaster_text, false, TextDirection::Auto); - surface.draw_text(Point::from_xy(60.0, 634.0), noto_font.clone(), 12.0, &slack_water_text, false, TextDirection::Auto); - surface.draw_text(Point::from_xy(300.0, 650.0), noto_font.clone(), 12.0, "Actual: ______________", false, TextDirection::Auto); + let p_slack = data + .plaster_slack + .map(|p| format!("{:.1} g", p)) + .unwrap_or_else(|| "____".to_string()); + let w_slack = data + .water_slack + .map(|w| format!("{:.1} g", w)) + .unwrap_or_else(|| "____".to_string()); + let slack_plaster_text = format!("{}% Slack (Plaster): {}", data.slack_percent, p_slack); + let slack_water_text = format!("{}% Slack (Water): {}", data.slack_percent, w_slack); + surface.draw_text( + Point::from_xy(60.0, 650.0), + noto_font.clone(), + 12.0, + &slack_plaster_text, + false, + TextDirection::Auto, + ); + surface.draw_text( + Point::from_xy(60.0, 634.0), + noto_font.clone(), + 12.0, + &slack_water_text, + false, + TextDirection::Auto, + ); + surface.draw_text( + Point::from_xy(300.0, 650.0), + noto_font.clone(), + 12.0, + "Actual: ______________", + false, + TextDirection::Auto, + ); // Timings - surface.draw_text(Point::from_xy(40.0, 600.0), noto_font.clone(), 12.0, "Soak Time: __________ (actual: ", false, TextDirection::Auto); + surface.draw_text( + Point::from_xy(40.0, 600.0), + noto_font.clone(), + 12.0, + "Soak Time: __________ (actual: ", + false, + TextDirection::Auto, + ); if let Some(soak) = &data.soak_time { - surface.draw_text(Point::from_xy(220.0, 600.0), noto_font.clone(), 12.0, soak, false, TextDirection::Auto); + surface.draw_text( + Point::from_xy(220.0, 600.0), + noto_font.clone(), + 12.0, + soak, + false, + TextDirection::Auto, + ); } - surface.draw_text(Point::from_xy(280.0, 600.0), noto_font.clone(), 12.0, ")", false, TextDirection::Auto); + surface.draw_text( + Point::from_xy(280.0, 600.0), + noto_font.clone(), + 12.0, + ")", + false, + TextDirection::Auto, + ); - surface.draw_text(Point::from_xy(40.0, 580.0), noto_font.clone(), 12.0, "Mix Time: __________ (actual: ", false, TextDirection::Auto); + surface.draw_text( + Point::from_xy(40.0, 580.0), + noto_font.clone(), + 12.0, + "Mix Time: __________ (actual: ", + false, + TextDirection::Auto, + ); if let Some(mix) = &data.mix_time { - surface.draw_text(Point::from_xy(220.0, 580.0), noto_font.clone(), 12.0, mix, false, TextDirection::Auto); + surface.draw_text( + Point::from_xy(220.0, 580.0), + noto_font.clone(), + 12.0, + mix, + false, + TextDirection::Auto, + ); } - surface.draw_text(Point::from_xy(280.0, 580.0), noto_font.clone(), 12.0, ")", false, TextDirection::Auto); + surface.draw_text( + Point::from_xy(280.0, 580.0), + noto_font.clone(), + 12.0, + ")", + false, + TextDirection::Auto, + ); - surface.draw_text(Point::from_xy(40.0, 560.0), noto_font.clone(), 12.0, "Set Time: __________", false, TextDirection::Auto); + surface.draw_text( + Point::from_xy(40.0, 560.0), + noto_font.clone(), + 12.0, + "Set Time: __________", + false, + TextDirection::Auto, + ); // Target weights - surface.draw_text(Point::from_xy(40.0, 520.0), noto_font.clone(), 14.0, "Target Cast Weight:", false, TextDirection::Auto); - if let Some(w) = data.single_wet { - let wet_text = format!("Wet: {:.1} g", w); - surface.draw_text(Point::from_xy(60.0, 500.0), noto_font.clone(), 12.0, &wet_text, false, TextDirection::Auto); + surface.draw_text( + Point::from_xy(40.0, 520.0), + noto_font.clone(), + 14.0, + "Target Cast Weight:", + false, + TextDirection::Auto, + ); + if let (Some(wet_weights), Some(dry_weights)) = + (&data.cast_wet_weights, &data.cast_dry_weights) + { + for (index, ((volume, wet), dry)) in data + .volumes_ml + .iter() + .zip(wet_weights.iter()) + .zip(dry_weights.iter()) + .take(4) + .enumerate() + { + let y = 500.0 - (index as f32 * 32.0); + let label = if data.volumes_ml.len() > 1 { + format!("Cast {} ({} ml):", index + 1, volume) + } else { + "Cast:".to_string() + }; + let wet_text = format!("{} Wet {:.1} g", label, wet); + let dry_text = format!("Dry (theoretical): {:.1} g", dry); + surface.draw_text( + Point::from_xy(60.0, y), + noto_font.clone(), + 12.0, + &wet_text, + false, + TextDirection::Auto, + ); + surface.draw_text( + Point::from_xy(60.0, y - 16.0), + noto_font.clone(), + 12.0, + &dry_text, + false, + TextDirection::Auto, + ); + } } - if let Some(d) = data.single_dry { - let dry_text = format!("Dry (theoretical): {:.1} g", d); - surface.draw_text(Point::from_xy(60.0, 484.0), noto_font.clone(), 12.0, &dry_text, false, TextDirection::Auto); - } - surface.draw_text(Point::from_xy(300.0, 500.0), noto_font.clone(), 12.0, "Actual cast weight: ________", false, TextDirection::Auto); + surface.draw_text( + Point::from_xy(300.0, 500.0), + noto_font.clone(), + 12.0, + "Actual cast weight: ________", + false, + TextDirection::Auto, + ); // Notes label - surface.draw_text(Point::from_xy(44.0, 420.0), noto_font.clone(), 12.0, "Notes:", false, TextDirection::Auto); + surface.draw_text( + Point::from_xy(44.0, 420.0), + noto_font.clone(), + 12.0, + "Notes:", + false, + TextDirection::Auto, + ); } - // surface is dropped at the end of the brace scope - drop(page); // explicitly drop page so it releases the borrow on doc + surface.finish(); + page.finish(); // Finish document and write to file - let bytes = doc.finish().map_err(|e| format!("failed to finish document: {}", e))?; + let bytes = doc + .finish() + .map_err(|e| format!("failed to finish document: {}", e))?; std::fs::write(out_path, bytes).map_err(|e| format!("failed to write pdf: {}", e))?; Ok(()) } -