Files
pdfcraft/crates/engine/src/export.rs
T
PiohrunandClaude Opus 5.5 8f3456f29c fix(engine): PNG export writes opaque pages as RGB at deflate level 3 (3–5× faster, same size) (#2425)
* fix(engine): PNG export writes opaque pages as RGB at deflate level 3

`encode_png` wrote every raster as RGBA at the png crate's default level
(zlib level 6). A rendered page is always opaque, so the alpha channel only
made the file bigger and slower to write. An opaque raster is now written as
RGB, at deflate level 3 with adaptive filtering; a raster with translucent
pixels is still written as RGBA with straight alpha.

The files come out the same size, 3-5x faster (release build, Linux):

  300-dpi scan drawn at 220 dpi   805 -> 167 ms   4.46 -> 4.47 MB
  text page at 220 dpi             70 ->  40 ms   0.85 -> 0.89 MB
  annual-report page at 150 dpi   179 ->  64 ms   1.41 -> 1.38 MB

This is the encoder behind Export > Image (PNG), `pdfcraft-cli render` and
Print as Image; the automation tools (`page_render` and the other page
previews, MCP included) had a copy of it and now call it instead, so png is
only a dev-dependency of pdfcraft-automation. Two automation tests that read
`page_render` output as RGBA now accept the RGB a page renders as.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoEx9Nj8KC4WKDcf5YaokQ

* fix(engine): PNG export rejects a raster of the wrong length

encode_png now checks that the buffer is exactly width × height × 4 bytes
(checked arithmetic) before choosing RGB or RGBA. The opaque path's
as_chunks::<4>() dropped a partial trailing pixel, so encode_png(1, 1,
&[10, 20, 30, 255, 0]) wrote a valid 1×1 PNG where the old RGBA encoder
returned an error.

Regression test export::png_tests::rasters_of_the_wrong_length_are_rejected
covers opaque and translucent buffers with a partial pixel, a pixel short,
a pixel too many, an empty buffer and a size whose byte count overflows; it
fails on the previous head at the reviewer's case.

Review: https://github.com/storytold/pdfcraft/pull/2425#discussion_r4281603405

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoEx9Nj8KC4WKDcf5YaokQ

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 17:34:11 -04:00

339 lines
15 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). 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<Vec<u8>, String> {
// Checked here, not left to the encoder: `as_chunks` below drops a partial trailing pixel,
// and the opaque path would write the rest as a valid image.
let expected = usize::try_from(width).ok().zip(usize::try_from(height).ok()).and_then(|(w, h)| w.checked_mul(h)?.checked_mul(4));
if expected != Some(premultiplied.len()) {
return Err(match expected {
Some(n) => format!("a {width}×{height} raster is {n} bytes of RGBA, got {}", premultiplied.len()),
None => format!("a {width}×{height} raster is too large"),
});
}
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::<Vec<u8>>())
} 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",
}
}
}
/// 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");
}
}
#[cfg(test)]
mod png_tests {
use super::encode_png;
/// The colour type and pixels of a PNG.
fn decode(png: &[u8]) -> (png::ColorType, Vec<u8>) {
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::<Vec<_>>());
}
/// 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, opaque or not. The
/// opaque path used to drop a partial trailing pixel and write the rest as a valid image.
#[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 (what, width, height, data) in cases {
assert!(encode_png(width, height, data).is_err(), "{what}");
}
}
}