use crate::core::{Color, Rect, Size};
use std::collections::HashMap;
use std::fs;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PageOrder {
Ascending,
Descending,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PageFilter {
All,
Odd,
Even,
}
#[derive(Debug, Clone)]
pub struct PrintPagination {
ranges: Vec<(u32, u32)>,
copies: u32,
page_order: PageOrder,
collate: bool,
page_filter: PageFilter,
}
impl PrintPagination {
pub fn new() -> Self {
Self {
ranges: Vec::new(),
copies: 1,
page_order: PageOrder::Ascending,
collate: true,
page_filter: PageFilter::All,
}
}
pub fn set_range(&mut self, from: u32, to: u32) {
self.ranges.clear();
self.add_range(from, to);
}
pub fn add_range(&mut self, from: u32, to: u32) {
if from == 0 || to == 0 {
return;
}
let lo = from.min(to);
let hi = from.max(to);
self.ranges.push((lo, hi));
}
pub fn clear_ranges(&mut self) {
self.ranges.clear();
}
pub fn set_ranges_from_spec(&mut self, spec: &str) -> Result<(), String> {
let ranges = parse_page_range_spec(spec)?;
self.ranges = ranges;
Ok(())
}
pub fn set_copies(&mut self, copies: u32) {
self.copies = copies.max(1);
}
pub fn set_page_order(&mut self, order: PageOrder) {
self.page_order = order;
}
pub fn set_collate(&mut self, collate: bool) {
self.collate = collate;
}
pub fn set_page_filter(&mut self, page_filter: PageFilter) {
self.page_filter = page_filter;
}
fn selected_pages(&self, page_count: u32) -> Vec<u32> {
if page_count == 0 {
return Vec::new();
}
let mut base: Vec<u32> = if self.ranges.is_empty() {
(0..page_count).collect()
} else {
let mut pages = Vec::new();
for (from, to) in &self.ranges {
let from_idx = from.saturating_sub(1);
let to_idx = to.saturating_sub(1).min(page_count.saturating_sub(1));
for page in from_idx..=to_idx {
pages.push(page);
}
}
pages
};
if matches!(self.page_order, PageOrder::Descending) {
base.reverse();
}
let base = base
.into_iter()
.filter(|page| match self.page_filter {
PageFilter::All => true,
PageFilter::Odd => ((page + 1) % 2) == 1,
PageFilter::Even => ((page + 1) % 2) == 0,
})
.collect::<Vec<_>>();
if self.copies <= 1 {
return base;
}
let mut expanded = Vec::with_capacity(base.len().saturating_mul(self.copies as usize));
if self.collate {
for _ in 0..self.copies {
expanded.extend(base.iter().copied());
}
} else {
for page in base {
for _ in 0..self.copies {
expanded.push(page);
}
}
}
expanded
}
}
crate::impl_default_via_new!(PrintPagination);
fn parse_page_range_spec(spec: &str) -> Result<Vec<(u32, u32)>, String> {
let trimmed = spec.trim();
if trimmed.is_empty() {
return Ok(Vec::new());
}
let mut ranges = Vec::new();
for raw_part in trimmed.split(',') {
let part = raw_part.trim();
if part.is_empty() {
return Err("invalid page range: empty segment".to_string());
}
if let Some((from_raw, to_raw)) = part.split_once('-') {
let from = from_raw
.trim()
.parse::<u32>()
.map_err(|_| format!("invalid page number in range: '{part}'"))?;
let to = to_raw
.trim()
.parse::<u32>()
.map_err(|_| format!("invalid page number in range: '{part}'"))?;
if from == 0 || to == 0 {
return Err("page numbers are one-based and must be >= 1".to_string());
}
ranges.push((from.min(to), from.max(to)));
continue;
}
let page = part.parse::<u32>().map_err(|_| format!("invalid page number: '{part}'"))?;
if page == 0 {
return Err("page numbers are one-based and must be >= 1".to_string());
}
ranges.push((page, page));
}
Ok(ranges)
}
pub trait PrintDocument {
fn page_count(&self) -> u32;
fn draw_page(&self, page_index: u32, context: &mut dyn PrintContext);
}
pub trait PrintContext {
fn draw_text(&mut self, text: &str, x: f32, y: f32, font_size: f32, color: Color);
fn draw_line(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, width: f32, color: Color);
fn draw_rect(&mut self, rect: Rect, width: f32, color: Color);
fn fill_rect(&mut self, rect: Rect, color: Color);
fn draw_image(&mut self, image: &[u8], rect: Rect);
fn push_clip(&mut self, rect: Rect);
fn pop_clip(&mut self);
fn push_transform(&mut self, transform: Transform);
fn pop_transform(&mut self);
fn draw_text_styled(
&mut self,
text: &str,
x: f32,
y: f32,
font_size: f32,
color: Color,
style: FontStyle,
);
fn page_size(&self) -> Size;
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Transform {
pub scale_x: f32,
pub scale_y: f32,
pub translate_x: f32,
pub translate_y: f32,
pub rotate_degrees: f32,
}
impl Transform {
pub const IDENTITY: Self = Self {
scale_x: 1.0,
scale_y: 1.0,
translate_x: 0.0,
translate_y: 0.0,
rotate_degrees: 0.0,
};
pub const fn new() -> Self {
Self::IDENTITY
}
pub const fn translate(x: f32, y: f32) -> Self {
Self { translate_x: x, translate_y: y, ..Self::IDENTITY }
}
pub const fn scale(x: f32, y: f32) -> Self {
Self { scale_x: x, scale_y: y, ..Self::IDENTITY }
}
pub const fn rotate(degrees: f32) -> Self {
Self { rotate_degrees: degrees, ..Self::IDENTITY }
}
pub fn apply(&self, x: f32, y: f32) -> (f32, f32) {
let scale_x = if self.scale_x.is_finite() { self.scale_x } else { 1.0 };
let scale_y = if self.scale_y.is_finite() { self.scale_y } else { 1.0 };
let tx = if self.translate_x.is_finite() { self.translate_x } else { 0.0 };
let ty = if self.translate_y.is_finite() { self.translate_y } else { 0.0 };
let degrees = if self.rotate_degrees.is_finite() { self.rotate_degrees } else { 0.0 };
let sx = x * scale_x;
let sy = y * scale_y;
let radians = degrees.to_radians();
let (sin, cos) = radians.sin_cos();
(sx * cos - sy * sin + tx, sx * sin + sy * cos + ty)
}
pub fn then(&self, inner: &Self) -> Self {
Self {
scale_x: self.scale_x * inner.scale_x,
scale_y: self.scale_y * inner.scale_y,
translate_x: self.translate_x + inner.translate_x * self.scale_x,
translate_y: self.translate_y + inner.translate_y * self.scale_y,
rotate_degrees: self.rotate_degrees + inner.rotate_degrees,
}
}
}
crate::impl_default_via_new!(Transform);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct FontStyle {
pub bold: bool,
pub italic: bool,
pub monospace: bool,
}
impl FontStyle {
pub const REGULAR: Self = Self { bold: false, italic: false, monospace: false };
pub const BOLD: Self = Self { bold: true, italic: false, monospace: false };
pub const ITALIC: Self = Self { bold: false, italic: true, monospace: false };
pub const BOLD_ITALIC: Self = Self { bold: true, italic: true, monospace: false };
pub const MONOSPACE: Self = Self { bold: false, italic: false, monospace: true };
}
impl Default for FontStyle {
fn default() -> Self {
Self::REGULAR
}
}
pub struct PrintDialog {
copies: u32,
pagination: PrintPagination,
shown: bool,
}
impl PrintDialog {
pub fn new() -> Self {
Self { copies: 1, pagination: PrintPagination::default(), shown: false }
}
pub fn set_copies(&mut self, copies: u32) {
self.copies = copies.max(1);
self.pagination.set_copies(self.copies);
}
pub fn pagination(&self) -> &PrintPagination {
&self.pagination
}
pub fn pagination_mut(&mut self) -> &mut PrintPagination {
&mut self.pagination
}
pub fn show(&mut self) -> bool {
if self.copies < 1 {
log::warn!("PrintDialog::show() called with 0 copies — no pages will be printed");
return false;
}
log::info!(
"PrintDialog::show() — copies={}, page_order={:?}, page_filter={:?}, collate={}",
self.copies,
self.pagination.page_order,
self.pagination.page_filter,
self.pagination.collate,
);
let has_printer = crate::platform::platform_facts().has_print_support();
if !has_printer {
log::error!("PrintDialog::show() — no native print spooler detected on this system");
return false;
}
log::info!(
"PrintDialog::show() — native print spooler detected, dialog configuration accepted"
);
self.shown = true;
true
}
pub fn was_shown(&self) -> bool {
self.shown
}
}
crate::impl_default_via_new!(PrintDialog);
pub struct PrintPreviewDialog {
page_count: u32,
current_page: u32,
document: Option<Box<dyn PrintDocument>>,
preview_commands: Vec<String>,
}
impl PrintPreviewDialog {
pub fn new(document: Box<dyn PrintDocument>) -> Self {
let page_count = document.page_count();
Self { page_count, current_page: 0, document: Some(document), preview_commands: Vec::new() }
}
pub fn page_count(&self) -> u32 {
self.page_count
}
pub fn current_page(&self) -> u32 {
self.current_page
}
pub fn next_page(&mut self) {
if self.current_page + 1 < self.page_count {
self.current_page += 1;
}
}
pub fn prev_page(&mut self) {
self.current_page = self.current_page.saturating_sub(1);
}
pub fn show(&mut self) -> bool {
if self.page_count == 0 {
log::warn!("PrintPreviewDialog::show() — no pages to preview");
return false;
}
if self.document.is_none() {
log::warn!("PrintPreviewDialog::show() — document was already consumed");
return false;
}
let Some(document) = self.document.take() else {
return false;
};
let mut context = MemoryPrintContext::new(Size { width: 595, height: 842 });
for page in PrintPagination::default().selected_pages(self.page_count) {
document.draw_page(page, &mut context);
context.end_page();
}
self.preview_commands = context.commands;
self.document = Some(document);
log::info!(
"PrintPreviewDialog::show() — preview generated ({} pages, {} commands)",
self.page_count,
self.preview_commands.len()
);
true
}
pub fn preview_commands(&self) -> &[String] {
&self.preview_commands
}
}
pub struct Printer {
page_size: Size,
backend: PrintBackend,
}
impl Printer {
pub fn new() -> Self {
Self {
page_size: Size { width: 595, height: 842 },
backend: PrintBackend::default_for_platform(),
}
}
pub fn print(&self, document: &dyn PrintDocument) {
if let Err(e) = self.print_with_result(document) {
log::error!("[print] print failed: {e}");
}
}
pub fn print_with_result(&self, document: &dyn PrintDocument) -> Result<(), String> {
self.print_with_pagination_result(document, &PrintPagination::default())
}
pub fn print_with_pagination(
&self,
document: &dyn PrintDocument,
pagination: &PrintPagination,
) {
if let Err(e) = self.print_with_pagination_result(document, pagination) {
log::error!("[print] print_with_pagination failed: {e}");
}
}
pub fn print_with_pagination_result(
&self,
document: &dyn PrintDocument,
pagination: &PrintPagination,
) -> Result<(), String> {
let mut context = MemoryPrintContext::new(self.page_size);
for page in pagination.selected_pages(document.page_count()) {
document.draw_page(page, &mut context);
context.end_page();
}
let job = PrintJobPayload { page_size: self.page_size, commands: context.commands };
self.backend.submit(&job)
}
pub fn backend_name(&self) -> &'static str {
self.backend.name()
}
}
crate::impl_default_via_new!(Printer);
struct PrintJobPayload {
page_size: Size,
commands: Vec<String>,
}
enum PrintBackend {
System,
Memory,
}
use std::sync::Mutex;
static MEMORY_PRINT_JOBS: Mutex<Vec<(String, String)>> = Mutex::new(Vec::new());
impl PrintBackend {
fn default_for_platform() -> Self {
if std::env::var("RW_PRINT_BACKEND")
.map(|value| value.eq_ignore_ascii_case("memory"))
.unwrap_or(false)
{
return PrintBackend::Memory;
}
PrintBackend::System
}
fn name(&self) -> &'static str {
match self {
PrintBackend::System => "system-spool",
PrintBackend::Memory => "memory",
}
}
fn submit(&self, job: &PrintJobPayload) -> Result<(), String> {
match self {
PrintBackend::System => submit_system_print_job(job),
PrintBackend::Memory => {
let mut content = String::new();
content.push_str(&format!(
"rust_widgets print job (memory backend)\npage_size={}x{}\n\n",
job.page_size.width, job.page_size.height
));
for cmd in &job.commands {
content.push_str(cmd);
content.push('\n');
}
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|err| format!("clock error: {err}"))?
.as_millis();
let label = format!("memory-job-{ts}");
if let Ok(mut jobs) = MEMORY_PRINT_JOBS.lock() {
jobs.push((label, content));
log::info!(
"[print] Memory backend stored print job ({} commands)",
job.commands.len()
);
}
Ok(())
}
}
}
}
fn submit_system_print_job(job: &PrintJobPayload) -> Result<(), String> {
let path = write_print_job_file(job)?;
let result = run_print_command(&path);
let _ = fs::remove_file(&path);
result
}
fn write_print_job_file(job: &PrintJobPayload) -> Result<PathBuf, String> {
static JOB_SEQ: AtomicU64 = AtomicU64::new(0);
let mut path = std::env::temp_dir();
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|err| format!("clock error: {err}"))?
.as_millis();
let seq = JOB_SEQ.fetch_add(1, Ordering::Relaxed);
path.push(format!("rw_print_job_{}_{ts}_{seq}.txt", std::process::id()));
let mut opts = fs::OpenOptions::new();
opts.write(true).create_new(true);
let file = opts.open(&path).map_err(|err| {
format!(
"print job file '{}' could not be created: {err} (the directory must exist \
and be writable)",
path.display()
)
})?;
let mut out = std::io::BufWriter::new(file);
let write = write_print_job_body(&mut out, job);
let flushed = out.flush();
if let Err(err) = write.and(flushed) {
let _ = fs::remove_file(&path);
return Err(format!(
"print job file '{}' could not be written and was removed: {err} (the job would \
have reached the spooler truncated)",
path.display()
));
}
Ok(path)
}
fn write_print_job_body(
out: &mut impl std::io::Write,
job: &PrintJobPayload,
) -> std::io::Result<()> {
writeln!(
out,
"rust_widgets print job\npage_size={}x{}\n",
job.page_size.width, job.page_size.height
)?;
for cmd in &job.commands {
writeln!(out, "{cmd}")?;
}
Ok(())
}
fn run_print_command(path: &Path) -> Result<(), String> {
crate::platform::platform_facts().spawn_print_job(path)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PrintOrientation {
Portrait,
Landscape,
}
impl PrintOrientation {
pub fn apply(&self, size: Size) -> Size {
match self {
PrintOrientation::Portrait => size,
PrintOrientation::Landscape => Size { width: size.height, height: size.width },
}
}
}
#[derive(Debug, Clone)]
pub struct PrintSettings {
pub orientation: PrintOrientation,
pub copies: u32,
pub page_range: Option<String>,
pub collate: bool,
pub color_mode: String,
}
impl Default for PrintSettings {
fn default() -> Self {
Self {
orientation: PrintOrientation::Portrait,
copies: 1,
page_range: None,
collate: true,
color_mode: "color".to_string(),
}
}
}
impl PrintSettings {
pub fn new() -> Self {
Self::default()
}
pub fn apply_to_pagination(&self, total_pages: u32) -> PrintPagination {
let mut pagination = PrintPagination::new();
pagination.set_copies(self.copies);
pagination.set_collate(self.collate);
if let Some(ref range_spec) = self.page_range {
let _ = pagination.set_ranges_from_spec(range_spec);
}
if total_pages > 0 {
if pagination.selected_pages(total_pages).is_empty() && self.page_range.is_none() {
pagination.set_range(1, total_pages);
}
}
pagination
}
}
#[derive(Debug, Clone)]
pub struct PrintPage {
pub number: u32,
pub commands: Vec<String>,
pub size: Size,
}
impl PrintPage {
pub fn new(number: u32, size: Size, commands: Vec<String>) -> Self {
Self { number, size, commands }
}
pub fn command_count(&self) -> usize {
self.commands.len()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PrintJobStatus {
Queued,
Printing,
Completed,
Cancelled,
Failed,
}
#[derive(Debug, Clone)]
pub struct PrintJob {
pub id: u64,
pub settings: PrintSettings,
pub status: PrintJobStatus,
pub pages: Vec<PrintPage>,
pub total_pages: u32,
}
impl PrintJob {
pub fn new(id: u64, settings: PrintSettings, pages: Vec<PrintPage>, total_pages: u32) -> Self {
Self { id, settings, status: PrintJobStatus::Queued, pages, total_pages }
}
pub fn summary(&self) -> String {
format!(
"PrintJob #{}: {} pages, {:?}, copies={}, color={}",
self.id,
self.total_pages,
self.settings.orientation,
self.settings.copies,
self.settings.color_mode,
)
}
}
#[derive(Debug)]
pub struct PrintManager {
next_id: u64,
jobs: HashMap<u64, PrintJob>,
}
impl PrintManager {
pub fn new() -> Self {
Self { next_id: 1, jobs: HashMap::new() }
}
pub fn create_job(
&mut self,
settings: PrintSettings,
pages: Vec<PrintPage>,
total_pages: u32,
) -> PrintJob {
let id = self.next_id;
self.next_id += 1;
let job = PrintJob::new(id, settings, pages, total_pages);
self.jobs.insert(id, job.clone());
job
}
pub fn cancel_job(&mut self, job_id: u64) -> bool {
if let Some(job) = self.jobs.get_mut(&job_id) {
if job.status == PrintJobStatus::Queued || job.status == PrintJobStatus::Printing {
job.status = PrintJobStatus::Cancelled;
return true;
}
}
false
}
pub fn job_status(&self, job_id: u64) -> Option<PrintJobStatus> {
self.jobs.get(&job_id).map(|job| job.status)
}
pub fn get_job(&self, job_id: u64) -> Option<&PrintJob> {
self.jobs.get(&job_id)
}
pub fn all_jobs(&self) -> Vec<&PrintJob> {
self.jobs.values().collect()
}
}
crate::impl_default_via_new!(PrintManager);
pub fn print_page_dialog() -> Result<bool, String> {
if !crate::platform::platform_facts().has_print_support() {
return Err(format!(
"no print dialog is available: backend '{}' reports no system print support, so \
page selection cannot be offered",
crate::platform::platform_facts().backend_name()
));
}
log::info!("[print] print_page_dialog() — no system dialog available; console confirmation");
use std::io::IsTerminal;
if std::io::stdout().is_terminal() && std::io::stdin().is_terminal() {
use std::io::{self, Write};
print!("Print? (y/n): ");
let _ = io::stdout().flush();
let mut input = String::new();
if io::stdin().read_line(&mut input).is_ok() {
let trimmed = input.trim().to_lowercase();
if trimmed == "y" || trimmed == "yes" {
return Ok(true);
}
return Ok(false);
}
}
log::warn!("[print] no interactive terminal — defaulting to cancel");
Ok(false)
}
pub fn print_to_printer(content: &str, settings: &PrintSettings) -> Result<(), String> {
if !crate::platform::platform_facts().has_print_support() {
return Err(format!(
"no system printer is available: backend '{}' reports no print support, so the \
{} byte document was not printed",
crate::platform::platform_facts().backend_name(),
content.len()
));
}
if content.is_empty() {
return Err(format!(
"cannot print an empty document ({} bytes); pass the rendered text to print",
content.len()
));
}
let mut path = std::env::temp_dir();
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|err| format!("clock error: {err}"))?
.as_millis();
path.push(format!("rw_print_output_{ts}.txt"));
if let Err(err) = fs::write(&path, content) {
return Err(format!(
"print spool file '{}' could not be written: {err} (check the temp directory \
is writable)",
path.display()
));
}
let result = run_print_command(&path);
if let Err(ref e) = result {
log::warn!(
"[print] print_to_printer: system print command failed for {} (copies={}, color={}): {}",
path.display(),
settings.copies,
settings.color_mode,
e
);
}
if let Err(err) = fs::remove_file(&path) {
log::warn!("[print] failed to clean up temp file {}: {err}", path.display());
}
result
}
pub struct MemoryPrintContext {
page_size: Size,
pub commands: Vec<String>,
clips: Vec<Rect>,
transforms: Vec<Transform>,
}
impl MemoryPrintContext {
pub fn new(page_size: Size) -> Self {
Self { page_size, commands: Vec::new(), clips: Vec::new(), transforms: Vec::new() }
}
pub fn end_page(&mut self) {
self.commands.push("page-break".to_string());
self.clips.clear();
self.transforms.clear();
}
pub fn effective_clip(&self) -> Option<Rect> {
let mut result: Option<Rect> = None;
for clip in &self.clips {
result = Some(match result {
Some(current) => current.intersection(clip).unwrap_or(Rect::new(0, 0, 0, 0)),
None => *clip,
});
}
result
}
pub fn effective_transform(&self) -> Transform {
self.transforms.iter().fold(Transform::IDENTITY, |outer, inner| outer.then(inner))
}
fn mapped_rect(&self, rect: Rect) -> Rect {
let transform = self.effective_transform();
let left = rect.x as f32;
let top = rect.y as f32;
let right = rect.x as f32 + rect.width as f32;
let bottom = rect.y as f32 + rect.height as f32;
let corners = [
transform.apply(left, top),
transform.apply(right, top),
transform.apply(left, bottom),
transform.apply(right, bottom),
];
let min_x = corners.iter().map(|(x, _)| *x).fold(f32::INFINITY, f32::min);
let max_x = corners.iter().map(|(x, _)| *x).fold(f32::NEG_INFINITY, f32::max);
let min_y = corners.iter().map(|(_, y)| *y).fold(f32::INFINITY, f32::min);
let max_y = corners.iter().map(|(_, y)| *y).fold(f32::NEG_INFINITY, f32::max);
if !(min_x.is_finite() && max_x.is_finite() && min_y.is_finite() && max_y.is_finite()) {
return Rect::new(0, 0, 0, 0);
}
Rect::new(
min_x.round().clamp(i32::MIN as f32, i32::MAX as f32) as i32,
min_y.round().clamp(i32::MIN as f32, i32::MAX as f32) as i32,
(max_x - min_x).max(0.0).round().clamp(0.0, u32::MAX as f32) as u32,
(max_y - min_y).max(0.0).round().clamp(0.0, u32::MAX as f32) as u32,
)
}
}
impl PrintContext for MemoryPrintContext {
fn draw_text(&mut self, text: &str, x: f32, y: f32, font_size: f32, color: Color) {
self.draw_text_styled(text, x, y, font_size, color, FontStyle::REGULAR);
}
fn draw_text_styled(
&mut self,
text: &str,
x: f32,
y: f32,
font_size: f32,
color: Color,
style: FontStyle,
) {
let (mapped_x, mapped_y) = self.effective_transform().apply(x, y);
let flags = format!(
"{}{}{}",
if style.bold { 'b' } else { '-' },
if style.italic { 'i' } else { '-' },
if style.monospace { 'm' } else { '-' }
);
self.commands.push(format!(
"text:{text}@{mapped_x},{mapped_y}:{font_size}:{}:{flags}",
hex_color(color)
));
}
fn draw_line(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, width: f32, color: Color) {
let transform = self.effective_transform();
let (mx1, my1) = transform.apply(x1, y1);
let (mx2, my2) = transform.apply(x2, y2);
self.commands.push(format!("line:{mx1},{my1}->{mx2},{my2}:{width}:{}", hex_color(color)));
}
fn draw_rect(&mut self, rect: Rect, width: f32, color: Color) {
let mapped = self.mapped_rect(rect);
self.commands.push(format!(
"rect:{},{},{},{}:{}:{}",
mapped.x,
mapped.y,
mapped.width,
mapped.height,
width,
hex_color(color)
));
}
fn fill_rect(&mut self, rect: Rect, color: Color) {
let mapped = self.mapped_rect(rect);
self.commands.push(format!(
"fill:{},{},{},{}:{}",
mapped.x,
mapped.y,
mapped.width,
mapped.height,
hex_color(color)
));
}
fn draw_image(&mut self, image: &[u8], rect: Rect) {
let mapped = self.mapped_rect(rect);
self.commands.push(format!(
"img:{}bytes:{},{},{},{}",
image.len(),
mapped.x,
mapped.y,
mapped.width,
mapped.height
));
}
fn push_clip(&mut self, rect: Rect) {
let mapped = self.mapped_rect(rect);
self.clips.push(mapped);
self.commands.push(format!(
"clip-push:{},{},{},{}",
mapped.x, mapped.y, mapped.width, mapped.height
));
}
fn pop_clip(&mut self) {
if self.clips.pop().is_some() {
self.commands.push("clip-pop".to_string());
} else {
log::warn!("[print] pop_clip with no matching push_clip; ignored");
}
}
fn push_transform(&mut self, transform: Transform) {
self.transforms.push(transform);
self.commands.push(format!(
"transform-push:sx={},sy={},tx={},ty={},rot={}",
transform.scale_x,
transform.scale_y,
transform.translate_x,
transform.translate_y,
transform.rotate_degrees
));
}
fn pop_transform(&mut self) {
if self.transforms.pop().is_some() {
self.commands.push("transform-pop".to_string());
} else {
log::warn!("[print] pop_transform with no matching push_transform; ignored");
}
}
fn page_size(&self) -> Size {
self.page_size
}
}
fn hex_color(color: Color) -> String {
format!("#{:02X}{:02X}{:02X}{:02X}", color.r, color.g, color.b, color.a)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
struct TestDoc {
pages: u32,
drawn: Mutex<Vec<u32>>,
}
impl TestDoc {
fn new(pages: u32) -> Self {
Self { pages, drawn: Mutex::new(Vec::new()) }
}
fn drawn_pages(&self) -> Vec<u32> {
self.drawn.lock().expect("test lock poisoned").clone()
}
}
impl PrintDocument for TestDoc {
fn page_count(&self) -> u32 {
self.pages
}
fn draw_page(&self, page_num: u32, _context: &mut dyn PrintContext) {
self.drawn.lock().expect("test lock poisoned").push(page_num);
}
}
#[test]
fn pagination_applies_range_descending_and_collated_copies() {
let mut pagination = PrintPagination::new();
pagination.set_range(2, 4);
pagination.set_page_order(PageOrder::Descending);
pagination.set_copies(2);
pagination.set_collate(true);
let pages = pagination.selected_pages(5);
assert_eq!(pages, vec![3, 2, 1, 3, 2, 1]);
}
#[test]
fn pagination_applies_uncollated_copies() {
let mut pagination = PrintPagination::new();
pagination.set_range(1, 3);
pagination.set_copies(2);
pagination.set_collate(false);
let pages = pagination.selected_pages(4);
assert_eq!(pages, vec![0, 0, 1, 1, 2, 2]);
}
#[test]
fn printer_respects_explicit_pagination() {
let printer =
Printer { page_size: Size { width: 595, height: 842 }, backend: PrintBackend::Memory };
let doc = TestDoc::new(6);
let mut pagination = PrintPagination::new();
pagination.set_range(2, 3);
pagination.set_copies(3);
let result = printer.print_with_pagination_result(&doc, &pagination);
assert!(result.is_ok());
assert_eq!(doc.drawn_pages(), vec![1, 2, 1, 2, 1, 2]);
}
#[test]
fn pagination_parses_page_range_spec() {
let mut pagination = PrintPagination::new();
let result = pagination.set_ranges_from_spec(" 1-3, 5, 8-6 ");
assert!(result.is_ok());
assert_eq!(pagination.selected_pages(10), vec![0, 1, 2, 4, 5, 6, 7]);
}
#[test]
fn pagination_rejects_invalid_page_range_spec() {
let mut pagination = PrintPagination::new();
let result = pagination.set_ranges_from_spec("1-3,,5");
assert!(result.is_err());
let result = pagination.set_ranges_from_spec("0-2");
assert!(result.is_err());
let result = pagination.set_ranges_from_spec("abc");
assert!(result.is_err());
}
#[test]
fn pagination_rejects_a_range_too_large_for_the_page_type() {
let mut pagination = PrintPagination::new();
let result = pagination.set_ranges_from_spec("1-4294967296");
let err = result.expect_err("a range past u32::MAX must be refused");
assert!(
err.contains("4294967296"),
"the error must name the offending value so the user can fix the spec: {err}"
);
}
#[test]
fn pagination_clamps_a_huge_range_to_the_document() {
let mut pagination = PrintPagination::new();
pagination.set_ranges_from_spec("1-4000000000").expect("a huge range is a valid spec");
let pages = pagination.selected_pages(3);
assert_eq!(pages, vec![0, 1, 2], "only real pages may be selected");
}
#[test]
fn pagination_clamps_at_the_page_count_boundaries() {
let mut pagination = PrintPagination::new();
pagination.set_ranges_from_spec("100-200").expect("valid spec");
assert_eq!(pagination.selected_pages(1), Vec::<u32>::new(), "page 100 of 1 does not exist");
assert_eq!(
pagination.selected_pages(0),
Vec::<u32>::new(),
"an empty document has no pages"
);
pagination.set_ranges_from_spec("200-100").expect("valid spec");
assert_eq!(pagination.selected_pages(0), Vec::<u32>::new());
}
#[test]
fn pagination_filters_odd_pages() {
let mut pagination = PrintPagination::new();
pagination.set_ranges_from_spec("1-6").expect("valid range");
pagination.set_page_filter(PageFilter::Odd);
let pages = pagination.selected_pages(8);
assert_eq!(pages, vec![0, 2, 4]);
}
#[test]
fn pagination_filters_even_pages() {
let mut pagination = PrintPagination::new();
pagination.set_ranges_from_spec("1-6").expect("valid range");
pagination.set_page_filter(PageFilter::Even);
let pages = pagination.selected_pages(8);
assert_eq!(pages, vec![1, 3, 5]);
}
#[test]
fn print_job_file_streams_header_and_commands() {
let job = PrintJobPayload {
page_size: Size { width: 595, height: 842 },
commands: vec!["page:1".into(), "text:Hello@10,10:12".into()],
};
let path = write_print_job_file(&job).expect("temp file is writable");
let written = fs::read_to_string(&path).expect("job file is readable");
let _ = fs::remove_file(&path);
assert!(written.contains("page_size=595x842"), "header must record the page size");
let first = written.find("page:1").expect("first command present");
let second = written.find("text:Hello@10,10:12").expect("second command present");
assert!(first < second, "commands must be written in order");
}
#[test]
fn print_job_file_names_include_the_process_id() {
let job = PrintJobPayload {
page_size: Size { width: 100, height: 100 },
commands: vec!["page:1".into()],
};
let path = write_print_job_file(&job).expect("temp file is writable");
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or_default().to_string();
let _ = fs::remove_file(&path);
assert!(
name.contains(&std::process::id().to_string()),
"job file name {name:?} must carry the process id so two processes cannot collide"
);
}
#[test]
fn print_job_files_written_back_to_back_do_not_share_a_path_or_a_body() {
let job = |page: u32| PrintJobPayload {
page_size: Size { width: 595, height: 842 },
commands: vec![format!("page:{page}")],
};
let first = write_print_job_file(&job(1)).expect("first job file is writable");
let second = write_print_job_file(&job(2)).expect("second job file is writable");
let third = write_print_job_file(&job(3)).expect("third job file is writable");
assert_ne!(first, second, "back-to-back jobs must not share a path");
assert_ne!(second, third, "back-to-back jobs must not share a path");
let read = |path: &PathBuf| fs::read_to_string(path).expect("job file is readable");
let bodies = [read(&first), read(&second), read(&third)];
for path in [&first, &second, &third] {
let _ = fs::remove_file(path);
}
for (index, body) in bodies.iter().enumerate() {
let expected = format!("page:{}", index + 1);
assert!(
body.contains(&expected),
"job {} must still hold its own body ({expected:?}), got: {body:?}",
index + 1
);
}
}
#[test]
fn print_job_body_writes_nothing_when_the_sink_fails() {
struct FailingSink;
impl std::io::Write for FailingSink {
fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
Err(std::io::Error::other("disk full"))
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let job = PrintJobPayload {
page_size: Size { width: 10, height: 10 },
commands: vec!["page:1".into()],
};
let mut sink = FailingSink;
assert!(
write_print_job_body(&mut sink, &job).is_err(),
"a sink that cannot write must surface the error, not report success"
);
}
#[test]
fn a_spooler_rejection_reaches_the_caller_with_the_file_and_cause() {
fn rejecting_submit(job: &PrintJobPayload) -> Result<(), String> {
let path = write_print_job_file(job)?;
let result = Err("lpr: failed: HP-LaserJet is not a known printer".to_string());
let _ = fs::remove_file(&path);
result
}
let job = PrintJobPayload {
page_size: Size { width: 595, height: 842 },
commands: vec!["text:Hello@10,10:12".into()],
};
let err = rejecting_submit(&job).expect_err("a refused spool job must not report success");
assert!(
err.contains("lpr: failed"),
"the error must carry the spool command's own message: {err}"
);
assert!(
err.contains("HP-LaserJet"),
"the error must name the input that failed, not just the step: {err}"
);
assert!(
write_print_job_file(&job).is_ok(),
"the job file must be writable; otherwise the test would pass for the wrong reason"
);
}
#[test]
fn fill_rect_records_the_full_colour_including_alpha() {
let mut context = MemoryPrintContext::new(Size { width: 595, height: 842 });
context.fill_rect(Rect::new(10, 20, 30, 40), Color::rgba(0x12, 0x34, 0x56, 0x78));
let command = context.commands.last().expect("a fill must be recorded");
assert!(
command.ends_with("#12345678"),
"the recorded colour must carry every channel, including alpha: {command}"
);
}
#[test]
fn draw_rect_records_its_own_colour_independently_of_fill() {
let mut context = MemoryPrintContext::new(Size { width: 595, height: 842 });
context.draw_rect(Rect::new(0, 0, 10, 10), 2.0, Color::rgb(0xFF, 0x00, 0x00));
context.fill_rect(Rect::new(0, 0, 10, 10), Color::rgb(0x00, 0xFF, 0x00));
let outline = &context.commands[0];
assert!(
outline.contains("#FF0000FF"),
"the outline must use the colour it was given, not a default: {outline}"
);
assert_ne!(
context.commands[0], context.commands[1],
"a stroke and a fill of the same rect must not produce the same command"
);
}
#[test]
fn nested_clips_intersect() {
let mut context = MemoryPrintContext::new(Size { width: 595, height: 842 });
context.push_clip(Rect::new(0, 0, 100, 100));
context.push_clip(Rect::new(50, 50, 100, 100));
let effective = context.effective_clip().expect("two clips were pushed");
assert_eq!(
(effective.x, effective.y, effective.width, effective.height),
(50, 50, 50, 50),
"the nested clip must be the overlap of the two, not the second alone"
);
}
#[test]
fn disjoint_clips_clip_everything_and_unmatched_pops_are_ignored() {
let mut context = MemoryPrintContext::new(Size { width: 595, height: 842 });
assert_eq!(context.effective_clip(), None, "no clip was pushed yet");
context.push_clip(Rect::new(0, 0, 10, 10));
context.push_clip(Rect::new(500, 500, 10, 10));
let effective = context.effective_clip().expect("clips are active");
assert_eq!(
(effective.width, effective.height),
(0, 0),
"two clips that do not overlap must leave nothing visible"
);
context.pop_clip();
context.pop_clip();
context.pop_clip();
assert_eq!(context.effective_clip(), None, "the stack must be empty again");
}
#[test]
fn transforms_move_geometry_and_compose_when_nested() {
let mut context = MemoryPrintContext::new(Size { width: 595, height: 842 });
context.push_transform(Transform::translate(10.0, 20.0));
context.fill_rect(Rect::new(1, 2, 3, 4), Color::BLACK);
let moved = context.commands.last().expect("the fill must be recorded").clone();
assert!(
moved.starts_with("fill:11,22,3,4:"),
"a translate must shift the recorded origin, and only the origin: {moved}"
);
context.push_transform(Transform::scale(2.0, 2.0));
context.fill_rect(Rect::new(1, 1, 1, 1), Color::BLACK);
let scaled = context.commands.last().expect("the second fill is recorded").clone();
assert!(
scaled.starts_with("fill:12,22,2,2:"),
"nested transforms must compose, not replace: {scaled}"
);
context.pop_transform();
context.pop_transform();
context.pop_transform();
assert_eq!(
context.effective_transform(),
Transform::IDENTITY,
"an unmatched pop must be ignored, leaving the stack empty"
);
}
#[test]
fn a_transform_does_not_change_the_page_size() {
let mut context = MemoryPrintContext::new(Size { width: 595, height: 842 });
context.push_transform(Transform::scale(10.0, 10.0));
assert_eq!(
context.page_size(),
Size { width: 595, height: 842 },
"the page is physical; a transform cannot resize it"
);
}
#[test]
fn end_page_clears_the_clip_and_transform_stacks() {
let mut context = MemoryPrintContext::new(Size { width: 595, height: 842 });
context.push_clip(Rect::new(0, 0, 10, 10));
context.push_transform(Transform::translate(100.0, 100.0));
context.end_page();
assert_eq!(context.effective_clip(), None, "a clip must not leak into the next page");
assert_eq!(
context.effective_transform(),
Transform::IDENTITY,
"a transform must not leak into the next page"
);
}
#[test]
fn a_non_finite_transform_degrades_instead_of_producing_nan() {
let transform = Transform::scale(f32::INFINITY, f32::NAN);
let (x, y) = transform.apply(10.0, 10.0);
assert!(
x.is_finite() && y.is_finite(),
"a non-finite transform must yield finite coordinates, got ({x}, {y})"
);
assert_eq!(
(x, y),
(10.0, 10.0),
"an unusable scale must behave as the identity rather than moving the point"
);
let mut context = MemoryPrintContext::new(Size { width: 100, height: 100 });
context.push_transform(Transform::scale(f32::NAN, f32::INFINITY));
context.fill_rect(Rect::new(5, 5, 10, 10), Color::BLACK);
let command = context.commands.last().expect("the fill is recorded").clone();
assert!(command.starts_with("fill:5,5,10,10:"), "got {command}");
}
#[test]
fn a_quarter_turn_swaps_the_recorded_extent() {
let mut context = MemoryPrintContext::new(Size { width: 595, height: 842 });
context.push_transform(Transform::rotate(90.0));
context.fill_rect(Rect::new(0, 0, 10, 4), Color::BLACK);
let command = context.commands.last().expect("the fill is recorded").clone();
assert!(
command.starts_with("fill:-4,0,4,10:"),
"rotating 90 degrees must swap the recorded width and height: {command}"
);
}
#[test]
fn text_style_is_recorded_and_independent_per_call() {
let mut context = MemoryPrintContext::new(Size { width: 595, height: 842 });
context.draw_text("plain", 0.0, 0.0, 12.0, Color::BLACK);
context.draw_text_styled("strong", 0.0, 20.0, 12.0, Color::BLACK, FontStyle::BOLD);
context.draw_text_styled("code", 0.0, 40.0, 12.0, Color::BLACK, FontStyle::MONOSPACE);
assert!(
context.commands[0].ends_with(":---"),
"the unstyled call must record no attributes: {}",
context.commands[0]
);
assert!(
context.commands[1].ends_with(":b--"),
"bold must be recorded: {}",
context.commands[1]
);
assert!(
context.commands[2].ends_with(":--m"),
"monospace must be recorded: {}",
context.commands[2]
);
}
#[test]
fn memory_backend_records_the_command_stream() {
let mut pagination = PrintPagination::new();
pagination.set_ranges_from_spec("2").expect("valid range");
let doc = TestDoc::new(3);
let printer = Printer::new();
let mut context = MemoryPrintContext::new(Size { width: 595, height: 842 });
for page in pagination.selected_pages(doc.page_count()) {
doc.draw_page(page, &mut context);
context.end_page();
}
let job = PrintJobPayload {
page_size: Size { width: 595, height: 842 },
commands: context.commands,
};
assert_eq!(doc.drawn_pages(), vec![1], "only page 2 (zero-based 1) was selected");
assert!(
!job.commands.is_empty(),
"drawing a selected page must emit commands, not an empty stream"
);
assert_eq!(printer.backend_name(), printer.backend_name(), "the backend name is stable");
}
#[test]
fn print_manager_creates_and_tracks_jobs() {
let mut manager = PrintManager::new();
let settings = PrintSettings::new();
let pages = vec![PrintPage::new(
1,
Size { width: 595, height: 842 },
vec!["text:Hello@10,10:12".into()],
)];
let job = manager.create_job(settings, pages, 1);
assert_eq!(job.id, 1);
assert_eq!(job.status, PrintJobStatus::Queued);
assert_eq!(manager.job_status(1), Some(PrintJobStatus::Queued));
assert!(manager.get_job(1).is_some());
}
#[test]
fn print_manager_cancels_queued_job() {
let mut manager = PrintManager::new();
let settings = PrintSettings::new();
let pages = vec![
PrintPage::new(1, Size { width: 595, height: 842 }, Vec::new()),
PrintPage::new(2, Size { width: 595, height: 842 }, Vec::new()),
];
let job = manager.create_job(settings, pages, 2);
assert_eq!(job.id, 1);
assert!(manager.cancel_job(1));
assert_eq!(manager.job_status(1), Some(PrintJobStatus::Cancelled));
assert!(!manager.cancel_job(1));
}
#[test]
fn print_manager_cancel_nonexistent_job_returns_false() {
let mut manager = PrintManager::new();
assert!(!manager.cancel_job(99));
assert_eq!(manager.job_status(99), None);
}
#[test]
fn print_orientation_swaps_dimensions_for_landscape() {
let portrait = Size { width: 595, height: 842 };
let landscape = PrintOrientation::Landscape.apply(portrait);
assert_eq!(landscape.width, 842);
assert_eq!(landscape.height, 595);
let same = PrintOrientation::Portrait.apply(portrait);
assert_eq!(same.width, 595);
assert_eq!(same.height, 842);
}
#[test]
fn print_settings_defaults() {
let settings = PrintSettings::new();
assert_eq!(settings.orientation, PrintOrientation::Portrait);
assert_eq!(settings.copies, 1);
assert!(settings.collate);
assert_eq!(settings.color_mode, "color");
assert!(settings.page_range.is_none());
}
#[test]
fn print_settings_apply_to_pagination() {
let mut settings = PrintSettings::new();
settings.copies = 3;
settings.collate = true;
let pagination = settings.apply_to_pagination(5);
let pages = pagination.selected_pages(5);
assert!(!pages.is_empty());
}
#[test]
fn print_job_summary_includes_details() {
let settings = PrintSettings::new();
let pages = vec![];
let job = PrintJob::new(7, settings, pages, 10);
let summary = job.summary();
assert!(summary.contains("#7"));
assert!(summary.contains("10 pages"));
assert!(summary.contains("Portrait"));
}
#[test]
fn print_page_tracks_content() {
let commands = vec!["text:Hello@10,10:12".into(), "rect:0,0,100,50:1".into()];
let page = PrintPage::new(3, Size { width: 800, height: 600 }, commands);
assert_eq!(page.number, 3);
assert_eq!(page.command_count(), 2);
assert_eq!(page.size.width, 800);
assert_eq!(page.size.height, 600);
}
#[test]
fn print_entry_points_gate_on_backend_capability_not_target_os() {
let supported = crate::platform::platform_facts().has_print_support();
let result = print_to_printer("", &PrintSettings::new());
if supported {
let err = result.unwrap_err();
assert!(
err.contains("empty document") && err.contains("0 bytes"),
"expected the empty-content guard, got: {err}"
);
} else {
let err = result.unwrap_err();
assert!(
err.contains("no system printer is available") && err.contains("0 bytes"),
"expected the capability guard, got: {err}"
);
}
let dialog = print_page_dialog();
if supported {
assert_eq!(dialog, Ok(false), "no interactive terminal must cancel, not accept");
} else {
assert_eq!(dialog, Err("print dialog is not supported on this platform".to_string()));
}
}
#[test]
fn draw_page_receives_a_zero_based_index() {
let printer =
Printer { page_size: Size { width: 595, height: 842 }, backend: PrintBackend::Memory };
let doc = TestDoc::new(3);
printer.print_with_result(&doc).expect("memory backend never fails");
assert_eq!(
doc.drawn_pages(),
vec![0, 1, 2],
"a three-page document must be drawn as indices 0,1,2 — a first value of 1 \
would mean the parameter is a one-based page number"
);
}
#[test]
fn draw_page_may_be_called_repeatedly_and_out_of_order() {
let printer =
Printer { page_size: Size { width: 595, height: 842 }, backend: PrintBackend::Memory };
let doc = TestDoc::new(5);
let mut pagination = PrintPagination::new();
pagination.set_range(3, 4);
pagination.set_page_order(PageOrder::Descending);
pagination.set_copies(2);
pagination.set_collate(true);
printer.print_with_pagination_result(&doc, &pagination).expect("memory backend");
assert_eq!(
doc.drawn_pages(),
vec![3, 2, 3, 2],
"draw_page must be handed exactly the selected indices, in selection order"
);
}
#[test]
fn a_document_with_no_pages_prints_nothing_without_failing() {
let printer =
Printer { page_size: Size { width: 595, height: 842 }, backend: PrintBackend::Memory };
let doc = TestDoc::new(0);
let result = printer.print_with_result(&doc);
assert!(result.is_ok(), "an empty document is not a failure, got {result:?}");
assert!(doc.drawn_pages().is_empty(), "no page may be drawn for a 0-page document");
}
#[test]
fn preview_exposes_the_commands_it_recorded() {
let mut preview = PrintPreviewDialog::new(Box::new(RecordingDoc));
assert!(preview.show(), "a document that draws must preview successfully");
let commands = preview.preview_commands();
assert!(
commands.iter().any(|c| c.starts_with("text:")),
"preview must expose the recorded draw commands, got {commands:?}"
);
assert!(commands.contains(&"page-break".to_string()), "each page must end with a break");
}
#[test]
fn previewing_an_empty_document_fails_and_records_nothing() {
let mut preview = PrintPreviewDialog::new(Box::new(TestDoc::new(0)));
assert!(!preview.show(), "a zero-page document has nothing to preview");
assert!(
preview.preview_commands().is_empty(),
"a failed preview must not record commands, got {:?}",
preview.preview_commands()
);
}
#[test]
fn a_page_with_no_marks_still_records_its_page_break() {
let mut preview = PrintPreviewDialog::new(Box::new(TestDoc::new(2)));
assert!(preview.show(), "a two-page document is previewable");
let breaks = preview.preview_commands().iter().filter(|c| *c == "page-break").count();
assert_eq!(breaks, 2, "one page break per page, even for a blank page");
}
#[test]
fn previewing_twice_replaces_rather_than_appends() {
let mut preview = PrintPreviewDialog::new(Box::new(RecordingDoc));
assert!(preview.show(), "first preview");
let first = preview.preview_commands().len();
assert!(preview.show(), "second preview");
assert_eq!(
preview.preview_commands().len(),
first,
"a second render must replace the recorded commands, not append to them"
);
}
#[test]
fn context_reports_the_configured_page_size() {
let size = Size { width: 123, height: 456 };
let context = MemoryPrintContext::new(size);
assert_eq!(context.page_size(), size);
}
#[test]
fn an_out_of_range_index_is_never_requested() {
let printer =
Printer { page_size: Size { width: 595, height: 842 }, backend: PrintBackend::Memory };
let doc = TestDoc::new(1);
printer.print_with_result(&doc).expect("memory backend");
assert_eq!(doc.drawn_pages(), vec![0]);
assert!(
doc.drawn_pages().iter().all(|index| *index < doc.page_count()),
"every requested index must be within page_count()"
);
}
struct RecordingDoc;
impl PrintDocument for RecordingDoc {
fn page_count(&self) -> u32 {
2
}
fn draw_page(&self, page_index: u32, context: &mut dyn PrintContext) {
context.draw_text(&format!("page {page_index}"), 10.0, 20.0, 12.0, Color::BLACK);
}
}
}