//! 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 pdfcraft_render::{PageRenderer, RenderRequest, RequestKind}; use crate::Document; /// Renders and extracts pages of one document state. pub struct Exporter { renderer: PageRenderer, 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 { /// The current state, for exporting on another thread. 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, 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`. pub fn image(&mut self, page: usize, dpi: f64, format: ImageFormat) -> 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_image(r.width, r.height, &r.rgba, format) } /// 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`. pub fn encode_image(width: u32, height: u32, premultiplied: &[u8], format: ImageFormat) -> Result, String> { match format { ImageFormat::Png => encode_png(width, height, premultiplied), ImageFormat::Jpeg { quality } => encode_jpeg(width, height, premultiplied, quality), ImageFormat::Tiff => encode_tiff(width, height, premultiplied), } } /// Premultiplied RGBA → PNG (straight alpha). pub fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Result, String> { 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; } } } let mut out = Vec::new(); let mut enc = png::Encoder::new(&mut out, width, height); enc.set_color(png::ColorType::Rgba); enc.set_depth(png::BitDepth::Eight); let mut w = enc.write_header().map_err(|e| e.to_string())?; w.write_image_data(&rgba).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", } } } /// 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 (pages are opaque: transparency becomes white paper). pub fn encode_jpeg(width: u32, height: u32, premultiplied: &[u8], quality: u8) -> Result, String> { use image::ImageEncoder; let rgb = over_white(premultiplied); let mut out = Vec::new(); image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100)) .write_image(&rgb, width, height, image::ExtendedColorType::Rgb8) .map_err(|e| e.to_string())?; Ok(out) } /// Premultiplied RGBA → TIFF (RGB, LZW-compressed by the encoder's default). pub fn encode_tiff(width: u32, height: u32, premultiplied: &[u8]) -> Result, String> { use image::ImageEncoder; let rgb = over_white(premultiplied); let mut out = std::io::Cursor::new(Vec::new()); image::codecs::tiff::TiffEncoder::new(&mut out).write_image(&rgb, width, height, image::ExtendedColorType::Rgb8).map_err(|e| e.to_string())?; 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"); } }