Files
lightcraft/crates/engine/src/pdf.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

111 lines
4.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! A minimal PDF 1.4 writer for image pages: one JPEG XObject per page, explicit byte lengths and
//! a classic cross-reference table. Used by the contact sheet ([`crate::contact_sheet`]) and the
//! slideshow PDF export ([`crate::slideshow_export`]). It streams into any writer, so a long
//! slideshow is never held in memory; the page tree and catalog are written last, once the
//! number of pages is known.
use std::io::Write;
/// Objects 1 and 2 are the catalog and the page tree; pages take three objects each from 3.
const FIRST_PAGE: usize = 3;
pub(crate) struct Pdf<W: Write> {
w: W,
/// Bytes written so far.
pos: usize,
/// Each object's offset, by object number (index 0 unused).
offsets: Vec<usize>,
pages: Vec<usize>,
}
fn io(e: std::io::Error) -> String {
format!("PDF: {e}")
}
impl<W: Write> Pdf<W> {
pub(crate) fn new(w: W) -> Result<Self, String> {
let mut p = Pdf { w, pos: 0, offsets: vec![0; FIRST_PAGE], pages: Vec::new() };
p.put(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")?;
Ok(p)
}
/// Bytes written so far.
pub(crate) fn len(&self) -> usize {
self.pos
}
/// Pages added so far.
pub(crate) fn pages(&self) -> usize {
self.pages.len()
}
fn put(&mut self, b: &[u8]) -> Result<(), String> {
self.w.write_all(b).map_err(io)?;
self.pos = self.pos.saturating_add(b.len());
Ok(())
}
fn object(&mut self, id: usize, body: &[u8]) -> Result<(), String> {
if self.offsets.len() <= id {
self.offsets.resize(id + 1, 0);
}
if let Some(o) = self.offsets.get_mut(id) {
*o = self.pos;
}
self.put(format!("{id} 0 obj\n").as_bytes())?;
self.put(body)?;
self.put(b"\nendobj\n")
}
fn stream(&mut self, id: usize, dictionary: &str, bytes: &[u8]) -> Result<(), String> {
let mut body = format!("<< {dictionary} /Length {} >>\nstream\n", bytes.len()).into_bytes();
body.extend_from_slice(bytes);
body.extend_from_slice(b"\nendstream");
self.object(id, &body)
}
/// A page of `points` (width, height) filled by `jpg` (`width × height` pixels, RGB).
/// `extra` goes into the page dictionary (e.g. a display duration and transition).
pub(crate) fn image_page(&mut self, jpg: &[u8], width: usize, height: usize, points: (f64, f64), extra: &str) -> Result<(), String> {
let id = FIRST_PAGE + self.pages.len() * 3;
let (w, h) = points;
self.object(
id,
format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {w:.2} {h:.2}] /Resources << /XObject << /Im {} 0 R >> >> /Contents {} 0 R{extra} >>",
id + 2,
id + 1
)
.as_bytes(),
)?;
self.stream(id + 1, "", format!("q {w:.2} 0 0 {h:.2} 0 0 cm /Im Do Q").as_bytes())?;
self.stream(
id + 2,
&format!("/Type /XObject /Subtype /Image /Width {width} /Height {height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode"),
jpg,
)?;
self.pages.push(id);
Ok(())
}
/// Write the page tree, the catalog (with `catalog_extra` in its dictionary, e.g. a page
/// mode), the cross-reference table and the trailer.
pub(crate) fn finish(mut self, catalog_extra: &str) -> Result<W, String> {
if self.pages.is_empty() {
return Err("PDF: no pages".into());
}
let kids = self.pages.iter().map(|i| format!("{i} 0 R")).collect::<Vec<_>>().join(" ");
self.object(2, format!("<< /Type /Pages /Count {} /Kids [{kids}] >>", self.pages.len()).as_bytes())?;
self.object(1, format!("<< /Type /Catalog /Pages 2 0 R{catalog_extra} >>").as_bytes())?;
let start = self.pos;
let mut xref = format!("xref\n0 {}\n0000000000 65535 f \n", self.offsets.len()).into_bytes();
for offset in self.offsets.iter().skip(1) {
xref.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
xref.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{start}\n%%EOF\n", self.offsets.len()).as_bytes());
self.put(&xref)?;
self.w.flush().map_err(io)?;
Ok(self.w)
}
}