use crate::config::Config;
use crate::geometry::LabelMm;
use crate::label::{QrLabelOptions, TextAlign, TextLabelOptions, TextSide};
use crate::profile::PrinterProfile;
use crate::transport::Transport;
use crate::{Density, Threshold};
use anyhow::{Context, Result, bail, ensure};
use image::GrayImage;
use serde_json::json;
use std::fs::{self, File};
use std::path::Path;
use crate::font::LabelFont;
use crate::prepared::{MAX_PAGE_BYTES, Manifest, Page, PreparedJob, digest};
use crate::session::{Session, combine_job_and_close};
mod journal;
pub use journal::{Event, JobReport, Journal, Outcome};
#[derive(Debug, Clone, Default)]
pub struct FontSelection {
pub path: Option<std::path::PathBuf>,
pub name: Option<String>,
pub size: Option<f32>,
}
#[derive(Debug, Clone)]
pub struct BatchOptions {
pub csv: std::path::PathBuf,
pub template: Option<std::path::PathBuf>,
pub preview_dir: std::path::PathBuf,
pub start_at: u32,
pub font: FontSelection,
pub label: Option<String>,
pub density: Option<Density>,
pub task: Option<crate::PrintTask>,
}
impl Default for BatchOptions {
fn default() -> Self {
Self {
csv: Default::default(),
template: None,
preview_dir: Default::default(),
start_at: 1,
font: Default::default(),
label: None,
density: None,
task: None,
}
}
}
const MAX_INPUT_BYTES: u64 = 8 * 1024 * 1024;
const MAX_ROWS: usize = 1000;
mod input;
pub use input::{Kind, Layout, Row, load_layout, load_rows};
pub fn prepare(cfg: &Config, args: &BatchOptions, profile: &PrinterProfile) -> Result<PreparedJob> {
let layout = load_layout(cfg, args)?;
let rows = load_rows(&args.csv, layout.template.kind)?;
prepare_rows(cfg, args, &rows, &layout, profile)
}
fn prepare_rows(
cfg: &Config,
args: &BatchOptions,
rows: &[Row],
layout: &Layout,
profile: &PrinterProfile,
) -> Result<PreparedJob> {
ensure!(
!rows.is_empty()
&& rows.len() <= MAX_ROWS
&& args.start_at >= 1
&& args.start_at as usize <= rows.len(),
"--start-at exceeds the {} data rows",
rows.len()
);
ensure!(
!args.preview_dir.exists(),
"create fresh preview directory {} (use a new path for each run)",
args.preview_dir.display()
);
let parent = args
.preview_dir
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or(Path::new("."));
fs::create_dir_all(parent)?;
let staging = tempfile::Builder::new()
.prefix(".thermark-prepare-")
.tempdir_in(parent)?;
let label = layout
.label
.to_pixels(profile.max_width_px, profile.pixels_per_mm());
ensure!(
label.width_px as u64 * label.height_px as u64 <= MAX_PAGE_BYTES as u64,
"rendered page exceeds 64 MiB"
);
let safe = cfg.resolve_safe_area(profile.pixels_per_mm());
let font = match (&layout.template.font, &layout.template.font_name) {
(Some(path), _) => LabelFont::load(path)?,
(_, Some(name)) => LabelFont::load_named(name)?,
_ => LabelFont::load_default()?,
};
let mut pages = Vec::with_capacity(rows.len());
for (index, row) in rows.iter().enumerate() {
let gray = (|| -> Result<GrayImage> {
let image = if let Some(url) = &row.url {
crate::label::make_qr_label_with_font(
&QrLabelOptions {
url: url.clone(),
side_text: row.text.clone(),
label,
safe,
text_side: layout.text_side,
border: layout.template.border,
font_path: None,
font_name: None,
font_size: layout.template.font_size,
},
&font,
)?
} else {
crate::label::make_text_label_with_font(
&TextLabelOptions {
text: row.text.clone(),
label,
safe,
align: layout.align,
border: layout.template.border,
font_path: None,
font_name: None,
font_size: layout.template.font_size,
},
&font,
)?
};
Ok(crate::image_encode::render_print_preview(
&image,
profile.max_width_px,
Threshold::DEFAULT.get(),
false,
)?)
})()
.with_context(|| format!("render CSV data row {}", index + 1))?;
let row = index as u32 + 1;
let path = staging.path().join(format!("label-{row:05}.png"));
gray.save(&path)?;
File::open(&path)?.sync_all()?;
pages.push(Page {
row,
sha256: digest(&fs::read(&path)?),
width_px: gray.width(),
height_px: gray.height(),
});
}
let input = rows
.iter()
.map(|r| json!({"text":r.text,"url":r.url}))
.collect::<Vec<_>>();
let layout_json = json!({"label":[label.width_px,label.height_px],"safe":[safe.top,safe.bottom,safe.left,safe.right],"font_size":layout.template.font_size,"align":format!("{:?}",layout.align),"text_side":format!("{:?}",layout.text_side),"border":layout.template.border});
PreparedJob::seal(
staging.path(),
Manifest {
schema_version: 2,
job_id: String::new(),
model: profile.model.to_string(),
dpi: profile.dpi,
density: layout.density.get(),
print_task: args.task.or(profile.default_task).map(|t| t.to_string()),
total: pages.len(),
start_at: args.start_at,
width_px: label.width_px,
height_px: label.height_px,
font_sha256: font.fingerprint(),
font_face: font.index(),
input_sha256: digest(&serde_json::to_vec(&input)?),
layout_sha256: digest(&serde_json::to_vec(&layout_json)?),
pages,
},
)?;
File::open(staging.path().join("manifest.json"))?.sync_all()?;
#[cfg(unix)]
File::open(staging.path())?.sync_all()?;
fs::create_dir(&args.preview_dir).context("reserve fresh job directory")?;
if let Err(error) = fs::rename(staging.path(), &args.preview_dir) {
let _ = fs::remove_dir(&args.preview_dir);
return Err(error.into());
}
#[cfg(unix)]
File::open(parent)?.sync_all()?;
PreparedJob::open(&args.preview_dir)
}
pub async fn execute<T: Transport>(
session: &mut Session<T>,
job: &PreparedJob,
start_at: u32,
resume: bool,
acknowledge_uncertain: bool,
mut progress: impl FnMut(u32),
) -> Result<()> {
session.ensure_print_allowed()?;
job.verify_profile(session.profile())?;
ensure!(
job.manifest().print_task.as_deref() == Some(session.print_task().to_string().as_str()),
"connected print task differs from prepared job"
);
ensure!(
start_at >= 1 && start_at as usize <= job.manifest().total,
"start row outside job"
);
job.verify()?;
let (mut journal, report) = Journal::create(job, resume, acknowledge_uncertain)?;
let mut confirmed = 0usize;
for row in start_at..=job.manifest().total as u32 {
if resume && report.rows[row as usize - 1].outcome == Some(Outcome::Confirmed) {
continue;
}
let image = job.page(row)?;
journal.record(Outcome::Uncertain, Some(row), None)?;
if let Err(error) = session
.print_gray_with_progress(&image, job.density(), |stage| {
journal
.record(Outcome::Stage, Some(row), Some(stage))
.map_err(|error| crate::Error::msg(format!("record print stage: {error:#}")))
})
.await
{
bail!(
"batch stopped: {confirmed} labels confirmed in this run; row {row} uncertain; later rows not sent. Inspect the printed labels and journal.jsonl before resuming. Cause: {error:#}"
);
}
journal.record(Outcome::Confirmed,Some(row),None).with_context(||format!("row {row} printed but confirmation could not be recorded; inspect output before resuming"))?;
confirmed += 1;
progress(row);
}
Ok(())
}
pub async fn prepare_and_print<T: Transport>(
mut session: Session<T>,
cfg: &Config,
args: &BatchOptions,
) -> Result<()> {
let result = async {
session.ensure_print_allowed()?;
let mut resolved = args.clone();
resolved.task = Some(session.print_task());
let job = prepare(cfg, &resolved, session.profile())?;
execute(&mut session, &job, args.start_at, false, false, |_| {}).await
}
.await;
let close = session.finish().await;
combine_job_and_close(result, close)
}
#[cfg(test)]
async fn run_connected<T: Transport>(
mut session: Session<T>,
cfg: &Config,
args: &BatchOptions,
rows: &[Row],
layout: &Layout,
) -> Result<()> {
let result = async {
session.ensure_print_allowed()?;
let job = prepare_rows(cfg, args, rows, layout, session.profile())?;
execute(&mut session, &job, args.start_at, false, false, |_| {}).await
}
.await;
let close = session.finish().await;
combine_job_and_close(result, close)
}
#[cfg(test)]
mod tests;