Files
pdfcraft/crates/engine/src/export.rs
T
Morten Husted Lochmann f280ca8e6d M10: exported TIFF and JPEG pages record the resolution they were drawn at (#3332)
* M10: exported TIFF and JPEG pages record the resolution they were drawn at

A page exported as TIFF said 1 x 1 with no unit, whatever the dpi, and
a JPEG export said nothing, so other programs showed and printed them at
a default (a 300-dpi page shown at 72 dpi is four times too big).
encode_image now takes the dpi: TIFF X/YResolution in inches, the JFIF
density in dots per inch.

PNG stays without pHYs. It counts pixels per metre, so 72 dpi would read
back as 72.009, and a page made from an exported PNG (doc_create from
images, as enhance_camera_image_through_tools does) would come out 0.01%
small.

Exporter records the resolution a page was actually drawn at (dpi_used),
so a page too large for the renderer says the lower one. render records
--dpi, since it refuses oversized pages. The TIFF is written with the
tiff crate, already in the graph, because image's TiffEncoder can't set
a resolution.

* M10: TIFF page exports are LZW-compressed, as documented

encode_tiff's comment said "LZW-compressed by the encoder's default",
but the TIFF was written uncompressed: a Letter page at 300 dpi came to
25 MB. It is now LZW without a predictor, the compression TIFF readers
support most widely after none.

* Contributors: add my name for the About window
2026-10-18 17:06:41 -04:00

263 lines
11 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.
//! 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<Vec<u8>>,
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<pdfcraft_create::ImageExport, String> {
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: `<stem>_Page_<n>_Image_<index>.<ext>`.
/// 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<Vec<u8>, 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<Vec<u8>, 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, self.dpi_used(page, dpi))
}
/// The reading-order text of a page.
pub fn text(&mut self, page: usize) -> Result<String, String> {
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<String, String> {
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<Vec<u8>, 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).
pub fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Result<Vec<u8>, 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<u8> {
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<Vec<u8>, String> {
use image::ImageEncoder;
use image::codecs::jpeg::{JpegEncoder, PixelDensity};
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<Vec<u8>, String> {
use tiff::encoder::{Compression, Rational, TiffEncoder, colortype::RGB8};
use tiff::tags::{ResolutionUnit, Tag};
let err = |e: tiff::TiffError| e.to_string();
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::<RGB8>(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");
}
}