//! Export a PDF ▸ Image and Text (execution plan M10.3, the first formats). //! //! [`Exporter`] renders pages to PNG, JPEG or TIFF at a resolution and extracts reading-order //! text, from a //! document's working file (so unsaved edits and hidden layers are respected, as on screen). use std::collections::BTreeMap; use std::panic::{AssertUnwindSafe, catch_unwind}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Condvar, Mutex, PoisonError, mpsc}; use pdfcraft_render::{PageRenderer, RenderRequest, RequestKind}; use crate::Document; /// At most this many pages are drawn at once by [`Exporter::images`]. const MAX_IMAGE_WORKERS: usize = 8; /// Bytes of rasters [`Exporter::images`] may hold at once: 1 GiB, about 16 Letter pages at 300 dpi. /// Each worker holds one page (the raster, its RGB copy and the encoded file), so large pages run /// on fewer workers; a page over the cap still exports, on one worker, as it did before. const IMAGE_MEMORY: f64 = 1024.0 * 1024.0 * 1024.0; /// Page `page` drawn at `dpi` and encoded as `format`, saying it was drawn at `used` dpi. fn draw_image(renderer: &mut PageRenderer, page: usize, dpi: f64, format: ImageFormat, used: f64) -> Result, String> { let r = renderer.render(RenderRequest { page, scale: (dpi.clamp(18.0, 1200.0) / 72.0) as f32, ..Default::default() }); if let Some(e) = r.error { return Err(format!("page {}: {e}", page + 1)); } encode_image(r.width, r.height, &r.rgba, format, used) } /// Tells the workers of [`Exporter::images`] to stop when dropped. struct StopWorkers<'a> { stop: &'a AtomicBool, handed_over: &'a (Mutex, Condvar), } impl Drop for StopWorkers<'_> { fn drop(&mut self) { self.stop.store(true, Ordering::Relaxed); // Taken under the lock, so a worker between its check and its wait still sees it. drop(self.handed_over.0.lock().unwrap_or_else(PoisonError::into_inner)); self.handed_over.1.notify_all(); } } /// Renders and extracts pages of one document state. pub struct Exporter { renderer: PageRenderer, /// What the renderer draws, for the extra renderers [`Exporter::images`] starts. bytes: std::sync::Arc>, config: pdfcraft_render::RenderConfig, pages: usize, /// Each page's displayed size in points, for [`Exporter::dpi_used`]. sizes: Vec<(f32, f32)>, } /// What an export needs from a document, as plain values that can move to a worker thread. #[derive(Clone)] pub struct ExportSource { pub bytes: std::sync::Arc>, pub config: pdfcraft_render::RenderConfig, pub pages: usize, /// Each page's displayed size in points (after `/Rotate` and `/UserUnit`). pub sizes: Vec<(f32, f32)>, } impl Document { /// What an export reads, for exporting on another thread. These are the displayed /// bytes, so they may carry missing comment appearances PdfCraft drew. Signature /// artwork stays original; viewer validation badges are not document content and /// do not enter image/text exports or Print as Image. Saving uses the working file. pub fn export_source(&self) -> ExportSource { ExportSource { bytes: self.display.clone(), config: self.config.clone(), pages: self.info.pages.len(), sizes: self.info.pages.iter().map(|p| (p.width, p.height)).collect(), } } } /// Export all images: the images `pages` (0-based) use, each once, skipping those under /// `min_side` pixels on their shorter side. JPEGs come out unchanged, other images as PNG. pub fn extract_images(src: &ExportSource, pages: &[usize], min_side: u32) -> Result { let doc = pdfcraft_cos::Document::open_with_password(src.bytes.clone(), src.config.password.as_deref()).map_err(|e| e.to_string())?; Ok(pdfcraft_create::extract_images(&doc, pages, min_side)) } /// The file name for the `index`-th (1-based) exported image: `_Page__Image_.`. /// The page number has leading zeros for as many digits as the document's `page_count` has, so the /// files sort in page order (#2208): page 7 of 120 is `Page_007`. pub fn image_file_name(stem: &str, image: &pdfcraft_create::ExtractedImage, index: usize, page_count: usize) -> String { let width = page_count.max(1).to_string().len(); format!("{stem}_Page_{:0width$}_Image_{index:04}.{}", image.page + 1, image.extension) } impl Exporter { pub fn new(doc: &Document) -> Self { Self::from_source(doc.export_source()) } /// An exporter that draws a page too large for the renderer's size limits at the largest /// resolution they allow, as the interactive Export does; [`Exporter::dpi_used`] says at what. /// A source whose `config.reject_oversize` is set refuses such pages instead. pub fn from_source(src: ExportSource) -> Self { Self { renderer: PageRenderer::new(src.bytes.clone(), src.config.clone()), bytes: src.bytes, config: src.config, pages: src.pages, sizes: src.sizes, } } /// The resolution page `page` is actually exported at when asked for `dpi`: lower than asked /// when the page is too large for the renderer's size limits at that resolution. pub fn dpi_used(&self, page: usize, dpi: f64) -> f64 { let dpi = dpi.clamp(18.0, 1200.0); match self.sizes.get(page) { Some(&(w, h)) => f64::from(pdfcraft_render::effective_scale(w, h, (dpi / 72.0) as f32)) * 72.0, None => dpi, } } fn check(&self, page: usize) -> Result<(), String> { if page < self.pages { Ok(()) } else { Err(format!("page {} does not exist", page + 1)) } } /// Page `page` (0-based) as a PNG at `dpi`, at most the renderer's size limits allow (see /// [`Exporter::dpi_used`]); refused instead when the source asked for `reject_oversize`. pub fn png(&mut self, page: usize, dpi: f64) -> Result, String> { self.check(page)?; let r = self.renderer.render(RenderRequest { page, scale: (dpi.clamp(18.0, 1200.0) / 72.0) as f32, ..Default::default() }); if let Some(e) = r.error { return Err(format!("page {}: {e}", page + 1)); } encode_png(r.width, r.height, &r.rgba) } /// Page `page` as an image file of `format`, which says the resolution it was drawn at. pub fn image(&mut self, page: usize, dpi: f64, format: ImageFormat) -> Result, String> { self.check(page)?; let used = self.dpi_used(page, dpi); draw_image(&mut self.renderer, page, dpi, format, used) } /// Pages `pages` as image files of `format`, handed to `sink` in order with their page /// number: the same files as [`Exporter::image`] for each page in turn. Encoding a page costs /// several times what drawing it does, so several pages are drawn and encoded at once on /// worker threads, each with its own renderer, while `sink` runs on the calling thread. /// Workers draw at most one page each ahead of `sink`, and fewer run when the pages are /// large, so memory stays bounded. Stops at the first page that fails or the first error `sink` /// returns, and returns it; every page before it has been handed to `sink`. pub fn images( &mut self, pages: &[usize], dpi: f64, format: ImageFormat, sink: impl FnMut(usize, Vec) -> Result<(), String>, ) -> Result<(), String> { let workers = self.image_workers(pages, dpi); self.images_on(workers, pages, dpi, format, sink) } /// [`Exporter::images`] with `workers` pages drawn at once (one: in turn, on this thread). pub(crate) fn images_on( &mut self, workers: usize, pages: &[usize], dpi: f64, format: ImageFormat, mut sink: impl FnMut(usize, Vec) -> Result<(), String>, ) -> Result<(), String> { if workers <= 1 { for &p in pages { let img = self.image(p, dpi, format)?; sink(p, img)?; } return Ok(()); } let used: Vec = pages.iter().map(|p| self.dpi_used(*p, dpi)).collect(); let count = self.pages; let (bytes, config) = (&self.bytes, &self.config); let next = AtomicUsize::new(0); let stop = AtomicBool::new(false); // How many pages `sink` has taken; a worker waits while its page is `workers` past it. let handed_over = (Mutex::new(0usize), Condvar::new()); let (tx, rx) = mpsc::channel::<(usize, Result, String>)>(); let mut first = Some(&mut self.renderer); std::thread::scope(|s| { let handles: Vec<_> = (0..workers) .map(|_| { let tx = tx.clone(); let (first, used, next, stop, handed_over) = (first.take(), &used, &next, &stop, &handed_over); s.spawn(move || { let mut own = None; let renderer = match first { Some(r) => r, None => own.insert(PageRenderer::new(bytes.clone(), config.clone())), }; loop { let i = next.fetch_add(1, Ordering::Relaxed); let (Some(&page), Some(&used)) = (pages.get(i), used.get(i)) else { break }; let mut taken = handed_over.0.lock().unwrap_or_else(PoisonError::into_inner); while i >= taken.saturating_add(workers) && !stop.load(Ordering::Relaxed) { taken = handed_over.1.wait(taken).unwrap_or_else(PoisonError::into_inner); } drop(taken); if stop.load(Ordering::Relaxed) { break; } let img = if page < count { catch_unwind(AssertUnwindSafe(|| draw_image(renderer, page, dpi, format, used))) .unwrap_or_else(|_| Err(format!("page {}: the export crashed", page + 1))) } else { Err(format!("page {} does not exist", page + 1)) }; if tx.send((i, img)).is_err() { break; } } }) }) .collect(); drop(tx); // Stops the workers however this ends, a panicking `sink` included: waiting ones // would otherwise keep the scope from returning. let stopper = StopWorkers { stop: &stop, handed_over: &handed_over }; let mut ready = BTreeMap::new(); let mut result = Ok(()); let mut taken = 0; while let Some(&page) = pages.get(taken) { let Some(img) = ready.remove(&taken) else { match rx.recv() { Ok((i, img)) => { ready.insert(i, img); } Err(_) => { result = Err(format!("page {}: the export stopped unexpectedly", page + 1)); break; } } continue; }; if let Err(e) = img.and_then(|img| sink(page, img)) { result = Err(e); break; } taken += 1; *handed_over.0.lock().unwrap_or_else(PoisonError::into_inner) = taken; handed_over.1.notify_all(); } drop(stopper); // Joined here, so a worker that panicked outside a page is not re-raised by the scope. for h in handles { let _ = h.join(); } result }) } /// How many pages [`Exporter::images`] draws at once: one per core but one, up to /// [`MAX_IMAGE_WORKERS`], as many as [`IMAGE_MEMORY`] holds of the largest page, and one /// when there is nothing to share or no threads (wasm). fn image_workers(&self, pages: &[usize], dpi: f64) -> usize { if cfg!(target_arch = "wasm32") || pages.len() < 2 { return 1; } // One core is left for the interface, which keeps drawing while an export runs. let cores = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get).saturating_sub(1).min(MAX_IMAGE_WORKERS); // The raster (RGBA), the RGB copy the encoders take, and room for the encoded file. let largest = pages .iter() .filter_map(|p| { let (w, h) = *self.sizes.get(*p)?; let k = self.dpi_used(*p, dpi) / 72.0; Some((f64::from(w) * k).ceil() * (f64::from(h) * k).ceil() * 8.0) }) .fold(0.0, f64::max); // A float-to-integer cast saturates (and NaN is 0). let by_memory = if largest > 0.0 { (IMAGE_MEMORY / largest) as usize } else { cores }; cores.min(by_memory).min(pages.len()).max(1) } /// The reading-order text of a page. pub fn text(&mut self, page: usize) -> Result { self.check(page)?; let r = self.renderer.render(RenderRequest { page, kind: RequestKind::Text, scale: 1.0, ..Default::default() }); match r.text { Some(t) => Ok(t.plain_text()), None => Err(format!("page {}: {}", page + 1, r.error.unwrap_or_else(|| "no text layer".into()))), } } /// The text of several pages, separated by form feeds (as `pdftotext` does). pub fn text_of(&mut self, pages: &[usize]) -> Result { let mut out = String::new(); for (k, p) in pages.iter().enumerate() { if k > 0 { out.push('\u{c}'); } out.push_str(self.text(*p)?.trim_end()); out.push('\n'); } Ok(out) } } /// Premultiplied RGBA → an image file of `format`. A JPEG or TIFF says it is `dpi` dots per inch, /// so other programs show and print it at the page's size. A PNG says nothing: its `pHYs` counts /// pixels per metre, so 72 dpi would read back as 72.009 and a page made from it would shrink. pub fn encode_image(width: u32, height: u32, premultiplied: &[u8], format: ImageFormat, dpi: f64) -> Result, String> { match format { ImageFormat::Png => encode_png(width, height, premultiplied), ImageFormat::Jpeg { quality } => encode_jpeg(width, height, premultiplied, quality, dpi), ImageFormat::Tiff => encode_tiff(width, height, premultiplied, dpi), } } /// Premultiplied RGBA → PNG (straight alpha). A raster with no translucent pixel (a rendered /// page always is one: it is drawn on white) is written as RGB. /// /// Deflate level 3 with adaptive filtering: on rendered pages it writes files the size of the /// `png` crate's default (level 6) in a third to a fifth of the time. Measured with RGB at /// level 3 against RGBA at the default: a 300-dpi scan drawn at 220 dpi took 805 → 167 ms /// (4.46 → 4.47 MB), a text page at 220 dpi 70 → 40 ms (0.85 → 0.89 MB), and an annual /// report page at 150 dpi 179 → 64 ms (1.41 → 1.38 MB). pub fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Result, String> { check_raster(width, height, premultiplied)?; let pixels = premultiplied.as_chunks::<4>().0; let opaque = pixels.iter().all(|px| px[3] == 255); let (color, data) = if opaque { (png::ColorType::Rgb, pixels.iter().flat_map(|px| [px[0], px[1], px[2]]).collect::>()) } else { let mut rgba = premultiplied.to_vec(); for px in rgba.as_chunks_mut::<4>().0 { let a = u32::from(px[3]); if a != 0 && a != 255 { for c in &mut px[..3] { *c = ((u32::from(*c) * 255 + a / 2) / a).min(255) as u8; } } } (png::ColorType::Rgba, rgba) }; let mut out = Vec::new(); let mut enc = png::Encoder::new(&mut out, width, height); enc.set_color(color); enc.set_depth(png::BitDepth::Eight); enc.set_deflate_compression(png::DeflateCompression::Level(3)); enc.set_filter(png::Filter::Adaptive); let mut w = enc.write_header().map_err(|e| e.to_string())?; w.write_image_data(&data).map_err(|e| e.to_string())?; w.finish().map_err(|e| e.to_string())?; Ok(out) } /// Image file formats for Export ▸ Image. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ImageFormat { Png, /// Quality 1–100. Jpeg { quality: u8, }, Tiff, } impl ImageFormat { pub fn extension(self) -> &'static str { match self { ImageFormat::Png => "png", ImageFormat::Jpeg { .. } => "jpg", ImageFormat::Tiff => "tif", } } pub fn label(self) -> &'static str { match self { ImageFormat::Png => "PNG", ImageFormat::Jpeg { .. } => "JPEG", ImageFormat::Tiff => "TIFF", } } } /// Checks that `premultiplied` is exactly `width × height` RGBA pixels, and at least one, /// before any encoder sees it: `as_chunks` drops a partial trailing pixel, so the rest would be /// written as a valid image, and the JPEG and TIFF encoders panic on a buffer that doesn't fit /// the size. fn check_raster(width: u32, height: u32, premultiplied: &[u8]) -> Result<(), String> { if width == 0 || height == 0 { return Err(format!("a {width}×{height} raster has no pixels")); } let expected = usize::try_from(width).ok().zip(usize::try_from(height).ok()).and_then(|(w, h)| w.checked_mul(h)?.checked_mul(4)); match expected { Some(n) if n == premultiplied.len() => Ok(()), Some(n) => Err(format!("a {width}×{height} raster is {n} bytes of RGBA, got {}", premultiplied.len())), None => Err(format!("a {width}×{height} raster is too large")), } } /// Premultiplied RGBA composited over white → RGB. fn over_white(premultiplied: &[u8]) -> Vec { premultiplied .as_chunks::<4>() .0 .iter() .flat_map(|p| { let k = 255 - p[3]; [p[0].saturating_add(k), p[1].saturating_add(k), p[2].saturating_add(k)] }) .collect() } /// Premultiplied RGBA → baseline JPEG at `dpi` (pages are opaque: transparency becomes white paper). pub fn encode_jpeg(width: u32, height: u32, premultiplied: &[u8], quality: u8, dpi: f64) -> Result, String> { use image::ImageEncoder; use image::codecs::jpeg::{JpegEncoder, PixelDensity}; check_raster(width, height, premultiplied)?; let rgb = over_white(premultiplied); let mut out = Vec::new(); let mut enc = JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100)); // The JFIF density is whole dots per inch. enc.set_pixel_density(PixelDensity::dpi(dpi.round() as u16)); enc.write_image(&rgb, width, height, image::ExtendedColorType::Rgb8).map_err(|e| e.to_string())?; Ok(out) } /// Premultiplied RGBA → TIFF (RGB, LZW-compressed) at `dpi`. Written with `tiff` directly: /// `image`'s `TiffEncoder` always says 1 × 1 with no unit and leaves the strips uncompressed. /// No predictor: LZW alone is the compression TIFF readers support most widely after none. pub fn encode_tiff(width: u32, height: u32, premultiplied: &[u8], dpi: f64) -> Result, String> { use tiff::encoder::{Compression, Rational, TiffEncoder, colortype::RGB8}; use tiff::tags::{ResolutionUnit, Tag}; let err = |e: tiff::TiffError| e.to_string(); check_raster(width, height, premultiplied)?; let rgb = over_white(premultiplied); let mut out = std::io::Cursor::new(Vec::new()); let mut tiff = TiffEncoder::new(&mut out).map_err(err)?.with_compression(Compression::Lzw); let mut image = tiff.new_image::(width, height).map_err(err)?; // In hundredths: a page drawn at a lowered resolution (`Exporter::dpi_used`) is rarely a whole number. let resolution = Rational { n: (dpi * 100.0).round() as u32, d: 100 }; let tags = image.encoder(); tags.write_tag(Tag::ResolutionUnit, ResolutionUnit::Inch).map_err(err)?; tags.write_tag(Tag::XResolution, resolution.clone()).map_err(err)?; tags.write_tag(Tag::YResolution, resolution).map_err(err)?; image.write_data(&rgb).map_err(err)?; Ok(out.into_inner()) } #[cfg(test)] mod image_name_tests { use super::image_file_name; fn image(page: usize) -> pdfcraft_create::ExtractedImage { pdfcraft_create::ExtractedImage { page, object: pdfcraft_cos::ObjRef { num: 1, generation: 0 }, width: 1, height: 1, extension: "png", data: Vec::new(), } } #[test] fn page_number_is_padded_to_the_page_count() { assert_eq!(image_file_name("doc", &image(0), 1, 1), "doc_Page_1_Image_0001.png"); assert_eq!(image_file_name("doc", &image(0), 1, 9), "doc_Page_1_Image_0001.png"); assert_eq!(image_file_name("doc", &image(6), 2, 20), "doc_Page_07_Image_0002.png"); assert_eq!(image_file_name("doc", &image(6), 3, 120), "doc_Page_007_Image_0003.png"); assert_eq!(image_file_name("doc", &image(119), 4, 120), "doc_Page_120_Image_0004.png"); assert_eq!(image_file_name("doc", &image(0), 1, 0), "doc_Page_1_Image_0001.png"); } } #[cfg(test)] mod raster_tests { use super::{ImageFormat, encode_image, encode_png}; const FORMATS: [ImageFormat; 3] = [ImageFormat::Png, ImageFormat::Jpeg { quality: 90 }, ImageFormat::Tiff]; /// The colour type and pixels of a PNG. fn decode(png: &[u8]) -> (png::ColorType, Vec) { let mut reader = png::Decoder::new(std::io::Cursor::new(png)).read_info().unwrap(); let mut pixels = vec![0; reader.output_buffer_size().unwrap()]; let frame = reader.next_frame(&mut pixels).unwrap(); pixels.truncate(frame.buffer_size()); (frame.color_type, pixels) } /// A rendered page has no translucent pixel: it is written as RGB, each pixel exactly as /// rendered (the alpha channel would only make the file bigger and slower to write). #[test] fn opaque_rasters_are_written_as_rgb_with_their_exact_pixels() { let rgba = [[255, 255, 255, 255], [0, 0, 0, 255], [200, 30, 90, 255], [1, 2, 3, 255], [17, 250, 128, 255], [90, 90, 90, 255]]; let (color, pixels) = decode(&encode_png(3, 2, rgba.as_flattened()).unwrap()); assert_eq!(color, png::ColorType::Rgb); assert_eq!(pixels, rgba.iter().flat_map(|p| [p[0], p[1], p[2]]).collect::>()); } /// A raster with translucent pixels keeps its alpha, with the colours un-premultiplied. #[test] fn translucent_rasters_keep_straight_alpha() { let rgba = [[64, 0, 32, 128], [0, 0, 0, 0], [10, 20, 30, 255]]; let (color, pixels) = decode(&encode_png(3, 1, rgba.as_flattened()).unwrap()); assert_eq!(color, png::ColorType::Rgba); assert_eq!(pixels, [128, 0, 64, 128, 0, 0, 0, 0, 10, 20, 30, 255]); } /// A buffer that is not exactly `width × height` RGBA pixels is an error in every format, /// opaque or not. A partial trailing pixel used to be dropped and the rest written as a /// valid image, and a buffer too short for its size panicked in the JPEG and TIFF encoders. #[test] fn rasters_of_the_wrong_length_are_rejected() { let cases: [(&str, u32, u32, &[u8]); 7] = [ ("opaque, a partial pixel after", 1, 1, &[10, 20, 30, 255, 0]), ("opaque, three bytes after", 1, 1, &[10, 20, 30, 255, 0, 0, 0]), ("translucent, a partial pixel after", 1, 1, &[10, 20, 30, 128, 0]), ("opaque, a pixel short", 2, 1, &[10, 20, 30, 255]), ("translucent, a pixel too many", 1, 1, &[10, 20, 30, 128, 1, 2, 3, 4]), ("empty", 1, 1, &[]), ("a size whose byte count overflows", u32::MAX, u32::MAX, &[10, 20, 30, 255]), ]; for format in FORMATS { for (what, width, height, data) in cases { assert!(encode_image(width, height, data, format, 72.0).is_err(), "{}, {what}", format.label()); } } } /// A raster of no pixels is the right length (no bytes), and refused before any encoder sees /// it, in every format. #[test] fn rasters_of_no_pixels_are_an_error() { for format in FORMATS { for (width, height) in [(0, 0), (0, 3), (3, 0)] { let refused = encode_image(width, height, &[], format, 72.0).err(); assert_eq!(refused, Some(format!("a {width}×{height} raster has no pixels")), "{}", format.label()); } } } }