Files
lightcraft/crates/engine/src/print/mod.rs
T
Atmik Shetty 65775b4716 LRC-PRINT-JOB: colour-managed print PDFs (printer profiles, intent, black point compensation, print adjustment, print sharpening)
print.exportPdf now converts each composed page to the job's colour target and writes it into a
print-ready PDF:

- PrintJob gains `color` (PrintColorSettings): Managed by Printer (sRGB / Adobe RGB, profile
  embedded, no output intent) or a printer / paper .icc / .icm converted here and named as the
  document's output intent; perceptual or relative colorimetric with black point compensation;
  Print Adjustment brightness / contrast; print sharpening Low / Standard / High for Matte or
  Glossy; 16 or 8 bits per component. print.set / print.get and print templates carry it;
  out-of-range values are refused when set.
- Each photo is sharpened for the paper right after it renders at its print size.
- print/output.rs converts and Flate-compresses one page at a time (memory holds one page's device
  samples) into ICC-tagged images through engine::pdf; the PDF is 1.6. The 256 MiB cap counts the
  compressed pages.
- A printer profile that can't be used (missing, too large, not a profile) fails the print before
  anything renders, naming the file.
- contact_sheet's PDF writer goes back to private: only the contact sheet uses it.

Tests: a printer profile reaches the PDF as ICCBased images plus an output intent, a junk profile
fails naming the file, colour settings validate with the job, sharpening and media round-trip
through print.set / print.get, a missing profile fails before rendering with no activity row;
the existing print and CLI tests follow the PDF 1.6 output. Docs: LRC-PRINT-JOB and gap 15.
2026-10-12 15:16:32 +05:30

92 lines
3.8 KiB
Rust

//! The Print module: page layout, composition of pages and PDF output.
//!
//! [`layout`] is the pure page model (paper, margins, cells); [`compose`] renders a layout and its
//! photos to [`PageRaster`]s; [`template`] holds the saved settings; [`output`] writes the pages
//! as a PDF.
pub mod compose;
pub mod layout;
pub mod output;
pub mod template;
use serde::{Deserialize, Serialize};
/// Lowest and highest print resolution, pixels per inch.
pub const MIN_PPI: f32 = 72.0;
pub const MAX_PPI: f32 = 720.0;
/// Pages a single print may have.
pub const MAX_JOB_PAGES: usize = 100;
/// Most pixels one page may have (about 100 megapixels: A3 at 720 ppi is 60).
pub const MAX_PAGE_PIXELS: u64 = 100_000_000;
/// Most pixels a print job may hold across all its pages (6 bytes each in memory).
pub const MAX_JOB_PIXELS: u64 = 200_000_000;
/// One composed page, ready for output: row-major interleaved RGB, 16 bits per sample, encoded in
/// Rec. 2020 ([`lightcraft_pipeline::OutputSpace::Rec2020`]; the transfer curve is that space's).
/// The page covers its paper edge to edge: `width / ppi` by `height / ppi` inches.
#[derive(Clone, Debug, PartialEq)]
pub struct PageRaster {
pub width: u32,
pub height: u32,
pub ppi: f32,
pub rgb: Vec<u16>,
}
/// How a print job is produced (Lightroom's Print Job panel): resolution, and the colour
/// management, print adjustment, print sharpening and depth of [`crate::print_color`].
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
pub struct PrintJob {
/// Print resolution in pixels per inch, 72 to 720.
pub ppi: f32,
/// Profile, rendering intent, black point compensation, print adjustment, print sharpening.
pub color: crate::print_color::PrintColorSettings,
}
impl Default for PrintJob {
fn default() -> Self {
Self { ppi: 240.0, color: crate::print_color::PrintColorSettings::default() }
}
}
impl PrintJob {
/// The resolution to render at: [`Self::ppi`] clamped to 72..=720 (240 for NaN).
pub fn effective_ppi(&self) -> f32 {
if self.ppi.is_finite() { self.ppi.clamp(MIN_PPI, MAX_PPI) } else { 240.0 }
}
/// Rejects a resolution outside the supported range (commands and templates, which should
/// say so rather than silently clamp).
pub fn validate(&self) -> Result<(), String> {
if !(self.ppi.is_finite() && (MIN_PPI..=MAX_PPI).contains(&self.ppi)) {
return Err(format!("ppi must be a number from {MIN_PPI} to {MAX_PPI}"));
}
self.color.validate()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ppi_is_clamped_and_validated() {
assert_eq!(PrintJob::default().effective_ppi(), 240.0);
for (v, e) in [(10.0, 72.0), (5000.0, 720.0), (f32::NAN, 240.0), (f32::INFINITY, 240.0), (300.0, 300.0)] {
assert_eq!(PrintJob { ppi: v, ..Default::default() }.effective_ppi(), e);
}
assert!(PrintJob { ppi: 300.0, ..Default::default() }.validate().is_ok());
for bad in [0.0, 71.0, 721.0, f32::NAN, f32::NEG_INFINITY] {
assert!(PrintJob { ppi: bad, ..Default::default() }.validate().is_err());
}
let j: PrintJob = serde_json::from_str("{}").unwrap();
assert_eq!(j, PrintJob::default());
assert!(serde_json::from_str::<PrintJob>(r#"{"dpi": 1}"#).is_err());
// the colour settings ride along, defaults filled in, and are validated with the job
let j: PrintJob = serde_json::from_str(r#"{"ppi": 300, "color": {"intent": "relative", "brightness": 20}}"#).unwrap();
assert_eq!(j.color.intent, crate::print_color::PrintIntent::Relative);
assert!(j.color.black_point_compensation && j.validate().is_ok());
let j: PrintJob = serde_json::from_str(r#"{"color": {"contrast": 500}}"#).unwrap();
assert!(j.validate().is_err());
}
}