mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-27 11:37:13 -06:00
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.
111 lines
4.2 KiB
Rust
111 lines
4.2 KiB
Rust
//! 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)
|
||
}
|
||
}
|