Files
pdfcraft/crates/engine/src/export.rs
T
Chiranjeevi U adbf1dc01f M9: signed appearances show their real validation state (#79) (#2808)
* M9: redraw named-layer signature status layers

- /N drawing one Form that holds n1 and n3: n1, n3 and n4 redrawn for the state
- copies made once per original and state; originals never edited
- Lucide icons; captions in Helvetica, embedded Inter, or craft-fonts outlines

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* M9: draw signature states in the display copy, exports and print

- marks follow the bytes shown; a changed verdict redraws after a metadata edit
- background opens pick up the trust and captions current at commit

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* M9: status captions in the interface language

- the three captions in all 22 catalogues
- Signatures panel colours from sign::status_rgb

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* M9: signature badge in the widget's corner

- a quarter of the shorter side, 48pt cap; outline only under 32pt
- dropped for a drawn valid state; bottom-right where the appearance draws one
- sign_list reports verdict_shown; parity and roadmap

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* M9: keep Arabic signature captions in logical order

Pass raw RTL catalogue text to the signature appearance shaper so UAX #9 is applied exactly once. This keeps the rendered word order and exported /ActualText consistent.

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* M2: read /ActualText over painted paths and pictures

- A replacement that marks no glyph reads where it paints, cut to what is visible and along the way it is drawn.
- A span the page content leaves open ends before the annotations.
- OCR and scan field detection still count such a page as having no text of its own.

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* M8: redaction strips /ActualText around paint it clips

- A path or shading written again under a clip counts as changed, so a partly redacted word drawn as outlines leaves no name in the file.

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* M9: recognise only layer forms that draw n1 and n3

- Settle on the one form holding both first, then read its content once per call.
- Skip an /N that is its own layer form; copies keep the layer's /OC.
- A verdict counts as drawn when any visible widget shows it.

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* M9: keep the signature badge and redraw wherever a state changes

- The badge stays for every state; its mark sits bottom-right as the widget lies on the page.
- An edit that changes a verdict redraws every page; captions apply before startup files open.
- Print, the CLI's render and the repair log follow the display copy.

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* M9: restore the attribution usage the rebase dropped

- Usage lines for the three icons, Inter and the craft-fonts input; ATTRIBUTION.md rebuilt from them.
- Roadmap and localization notes for this round.

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* M9: pin signature field names on a nested form

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* M9: bound the signature field walk

- kids share their parent's name and type instead of each copying them
- a widget stays listed under every field that claims it, as one shared copy
- a field written in place in a shared /Kids array is walked once
- caps of 20,000 fields, 100,000 entries and 4096-byte names; no states drawn past them

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* M9: keep a document signed when the field walk stops early

- list_cached adds a signed row of unknown validity for the fields the walk didn't read
- Save As and actions then append, and Optimize, Sanitize and full rewrites are refused, as for any signed file

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* M8: removing hidden content no longer strips the names of shadings

- With no regions to clip, a shading is left alone instead of wrapped and marked clipped
- clean_replacements skips scopes already marked, so deep nesting stays linear

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* M2: read a picture's /ActualText over the whole picture

- draw_image's transform maps pixels, not a unit square, so the text sat on one pixel in the corner; the test now draws a 2x2 picture
- Painted grows in place instead of being copied through an Option

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* M9: cap the bytes of names the signature field walk keeps

- A kid's name repeats its parent's, so one long name over 20,000 kids came to 80 MB; the walk now stops at 4 MiB of names and counts the rest as unread
- Cover the widget written into a shared /Kids array and the field cap among kids, which no test caught
- sig_fields is private again, and the cap docs say what happens past each cap

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* M9: skip status-layer work that changes nothing; fix tests that could not fail

- show_status_layers and has_status_layers return before reading the form when nothing is signed, so an unsigned form no longer walks every field on each edit
- revalidate and draw_captions leave documents with no drawn state alone, and no longer copy the signature list to update it
- Scan form detection reads the page's text once; comment and badge tips share one hover rule
- Tests: drop a table row that repeated another, stop taking an expectation from the code under test, stop comparing an Arc with itself, give bare asserts messages

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* M9: test the signature field name cap at the engine boundary

- A 4,096-byte name is read and blocks a full rewrite; at 4,097 bytes the list holds the unread-fields row and a full rewrite is still refused

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* M9: test signature states beside fields the viewer draws

- One display copy holds both a field's drawn appearance and the signature's state.

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* M9: draw Thai and Devanagari status captions in their own script

- script_of names Thai and Devanagari, so a craft-fonts face meant for either draws the caption as outlines; without one it stays English.
- Localization, parity and the craft-fonts usage line list the new scripts.

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* M2: paint left out by an exclusion mixes its /ActualText replacement

- A replacement placed by outlines or pictures, some of them excluded, fails filtered extraction as one with excluded glyphs does.
- Glyphs still place a replacement that has them.

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* M9: a long-named field that can't hold a signature leaves the walk whole

- A leaf of another type passed over for its name is no signature field and has none below it, so an unsigned form stays unsigned.
- A signature field, or any field with kids, still counts as unread.

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* M9: status captions use document faces only

- Each script takes the face PdfCraft writes it into documents with, so Chinese stays English although the pinned craft-fonts' interface-only Chinese face is meant for Hani.
- The craft-fonts usage line names Devanagari: the pinned revision has a face for it, and Hindi captions are drawn in it.

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* M9: refuse a new signature field the field walk can't read back

- Signing adds a field only when the walk reads the whole form with it: past a cap the signature would be listed as unread fields, and its name could repeat an unread field's.
- A field the walk already reads is signed as before.

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* M9: refuse a new signature field with a blank name

- The walk lists no field without a name, so signing now also requires the walk to list the new field: one named only with spaces or NULs would be a signature nothing shows.

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* M9: an unnamed signed field or an unreadable form counts as unread

- A signed field with no usable /T, or an /AcroForm or /Fields of the wrong type, cuts the field walk short: the document keeps its unread-fields row and stays signed.
- Signing refuses a blank new field name before it adds the field, so the refusal still says why.

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* M9: no display copy when no signature state is drawn

- display_bytes_for makes a copy only when it added an appearance or drew a state, as the app does.

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* M9: the verdict mark stays on the page

- A widget past the crop box takes its mark in the part on the page, or none when too little of it is there.

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* M9: typed values stay drawn while a signature redraws every page

- A form edit that changes a signature's state puts its values back in unbaked after the pages are invalidated.

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* M9: the action, Save As and shading tests check their effects

- The signed action and Save As tests reopen the output for the title and the comment; the shading test checks its clip.

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* M9: values typed earlier stay drawn while a signature redraws every page

- A form edit that changes a signature's state keeps every value still waiting on its page, not only its own.

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* M9: comment and content edits keep typed values drawn too

- An edit to one page's comments or content that changes a signature's state moves no page, so the values still waiting on their pages stay pending, as after a form edit.
- The pending-value test runs a field deletion and a comment after a fill, under a certification that allows the fill; its messages say what it checks.

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* M9: the language-change test skips without catalogs

- Like the other language tests, it returns early when the build has no craft-i18n catalogs.

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* M9: signed fields behind kids without a name count

- A kid with no /T but kids of its own is walked as a field node, so a signed field under it is listed by its full name.
- An unnamed kid claiming a signature its field doesn't have cuts the walk short, so the document stays signed and keeps its unread-fields row.

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

---------

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>
2026-11-07 20:37:17 -06:00

547 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 the copy a document displays ([`Document::export_source`]): its working file, so
//! unsaved edits and hidden layers are respected, with what is drawn for display only.
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 appearances PdfCraft drew that the file itself does not hold:
/// missing comment appearances, and the signature states of named-layer signature
/// appearances. Exports and Print as Image follow the page as shown; the viewer's badges
/// are not document content and never enter them. 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());
}
}
}
}