Files
pdfcraft/crates/engine/src/export.rs
T
NetterSilverandClaude Opus 5.5 37bf4ee986 perf(engine): export page images on worker threads
Export > Image spent its time in the PNG, JPEG and TIFF encoders, one
page after another: on a 55-page manual at 150 dpi, drawing every page
took about 0.34 s and encoding it 1.1 s (PNG) to 2.0 s (JPEG).

Exporter::images exports a list of pages, drawing and encoding several
at once on worker threads, each with its own PageRenderer (as
automation's extract_parallel does), and hands the files to a sink in
page order on the calling thread. Workers draw at most one page ahead
of the sink and fewer run when 1 GiB can't hold that many of the
largest page. The first page or sink error stops the export with every
earlier page handed over, as before. A page panic becomes that page's
error, and a panicking sink stops the workers. One page, one core or
wasm keeps the per-page loop. Export > Image and the doc_export_images
tool use it.

Files are byte-identical. One core is left for the interface (at most
8 workers). With examples/export_bench, on a Ryzen with 16 threads
(shared), mean of two medians of 21 runs:
  PNG 150 dpi   1874 -> 340 ms  (-82%)
  JPEG 150 dpi  2106 -> 340 ms  (-84%)
  TIFF 150 dpi  2032 -> 375 ms  (-82%)
  PNG 300 dpi   3804 -> 590 ms  (-84%)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:41:38 +01:00

546 lines
25 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 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<Vec<u8>, 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<usize>, 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<Vec<u8>>,
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<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.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<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 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<u8>) -> 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<u8>) -> 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<f64> = 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<Vec<u8>, 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<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> {
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::<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",
}
}
}
/// 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<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};
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<Vec<u8>, 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::<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 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<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 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());
}
}
}
}