mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-29 12:38:58 -06:00
* 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>
339 lines
15 KiB
Rust
339 lines
15 KiB
Rust
//! 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}");
|
||
}
|
||
}
|
||
}
|