This commit is contained in:
2026-10-13 00:55:28 -05:00
parent ee726e8c91
commit fc0b6cb454
3 changed files with 415 additions and 108 deletions
+1
View File
@@ -1 +1,2 @@
/target
report.pdf
+110 -51
View File
@@ -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<String>,
/// Optional title for the lab log (written large in the header)
#[arg(short = 't', long)]
#[arg(short, long)]
title: Option<String>,
/// Concentration (parts water per 100 parts plaster by weight)
@@ -51,14 +53,14 @@ struct Args {
#[arg(short, long)]
density: Option<f64>,
/// 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<String>,
@@ -68,12 +70,36 @@ struct Args {
mix_time: Option<String>,
/// If provided, generate a PDF lab log at this path. If omitted no PDF will be written.
#[arg(long = "pdf-out")]
pdf_out: Option<String>,
#[arg(short, long)]
report: Option<String>,
}
fn parse_volumes(raw_volumes: &[String]) -> Result<Vec<f64>, 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::<f64>()
.map_err(|_| format!("invalid volume `{volume}`"))
})
.collect::<Result<Vec<_>, _>>()?;
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::<Vec<_>>()
});
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::<Vec<_>>()
});
// 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::<Vec<_>>()
.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(),
};
+304 -57
View File
@@ -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<String>,
pub date: String,
pub volume_ml: f64,
pub volumes_ml: Vec<f64>,
pub concentration: f64,
pub density: Option<f64>,
pub quantity: u32,
pub slack_percent: f64,
pub plaster_total: Option<f64>,
pub water_total: Option<f64>,
pub plaster_slack: Option<f64>,
pub water_slack: Option<f64>,
pub single_wet: Option<f64>,
pub single_dry: Option<f64>,
pub cast_wet_weights: Option<Vec<f64>>,
pub cast_dry_weights: Option<Vec<f64>>,
pub soak_time: Option<String>,
pub mix_time: Option<String>,
}
@@ -33,92 +37,335 @@ pub fn generate_lab_log<P: AsRef<Path>>(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::<Vec<_>>()
.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(())
}