Files
lightcraft/crates/engine/src/pdf.rs
T
Jason Baldwin b391307009 LRC-SS-EXPORT: slideshow export to PDF, JPEG sequence and AV1 video
View > Export Slideshow... (and Export PDF / JPEG / Video in Settings >
Slideshow) writes the photos in view as their composed slides, between
the intro and ending screens that are on:

- PDF: a page per slide (1024/1920/3840 px long side, JPEG quality),
  /Dur and /Trans fades per page, optional /PageMode /FullScreen. The
  contact sheet's writer moves to engine/src/pdf.rs and streams to disk.
- JPEG: <name>-001.jpg... in a folder.
- Video: AV1 (rav1e, speed 10, low latency) in MP4 or WebM written by
  our own muxers (codecs/src/video.rs); fades rendered as blended frames,
  held slides one key-frame sample with a long duration; 720p/1080p,
  24/30 fps; no audio; capped at 2 hours.

slideshow.export runs in the background with a row in the activity
stack (activity.cancel stops it); outputs are written as .part and
renamed when complete, so cancel or failure leaves nothing behind. A
photo that can't be rendered is skipped with a warning.
slideshow.exportStatus reports the result.
2026-10-11 10:35:09 +02:00

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)
}
}