Files
pdfcraft/crates/engine/src/export.rs
T
306bbcedf9 M3: harden render and CLI failure surfaces (#669)
* M3: harden render and CLI failure surfaces

Make content truncation observable, reject unknown CLI options, expose deterministic render work counters, and scope render failures to exact requests. Refuse silently reduced whole-page export rasters while preserving tiled rendering, and add heavy-vector and CLI regression coverage.

Co-authored-by: Codex <noreply@openai.com>

* Render: remember a failed render by page and kind again

A key with scale and tag made every zoom level and tile a new key: a
pathological page tied up a worker for the watchdog limit per request
(exhausting worker replacements) and was re-parsed each time. The
repeated-parsing check asks for a new generation and scale again.

* Export: pages too large at the chosen dpi export at the most the renderer allows, and say so

#669 made every Exporter refuse oversized whole-page renders, which
reached File > Export to image, doc_export_* and OCR: at 600 dpi any page
side over 13.65 in (Legal, A3, Tabloid) failed instead of exporting. The
exporter clamps again; Exporter::dpi_used reports the resolution a page
is drawn at, which the Export dialog's message and doc_export_png's
lower_dpi list mention. A source with reject_oversize still refuses
(CLI render keeps its strict config).

* doc_export_images: describe lower_dpi

* Render: ContentTruncated is reported once per page; the hayro-interpret patch is recorded

Nested forms, Type 3 glyphs and patterns are interpreted again and each
reported the exhausted budget; pdfcraft-render now keeps one. The warning
is part of hayro-interpret patch (18) (the page content budget): marked
in the code and described in vendor/README.md.

* CLI render: print render warnings (skipped content) to stderr

* CLI: the option check refuses unknown options only; commands report missing values themselves

text's own --page parsing (and its test on main) says what a page value
must be; the generic "needs a value" pre-empted it.

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 22:32:48 -04:00

215 lines
8.3 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 {
/// The current state, for exporting on another thread.
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>`.
pub fn image_file_name(stem: &str, image: &pdfcraft_create::ExtractedImage, index: usize) -> String {
format!("{stem}_Page_{}_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`.
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)
}
/// 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`.
pub fn encode_image(width: u32, height: u32, premultiplied: &[u8], format: ImageFormat) -> Result<Vec<u8>, String> {
match format {
ImageFormat::Png => encode_png(width, height, premultiplied),
ImageFormat::Jpeg { quality } => encode_jpeg(width, height, premultiplied, quality),
ImageFormat::Tiff => encode_tiff(width, height, premultiplied),
}
}
/// 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 (pages are opaque: transparency becomes white paper).
pub fn encode_jpeg(width: u32, height: u32, premultiplied: &[u8], quality: u8) -> Result<Vec<u8>, String> {
use image::ImageEncoder;
let rgb = over_white(premultiplied);
let mut out = Vec::new();
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100))
.write_image(&rgb, width, height, image::ExtendedColorType::Rgb8)
.map_err(|e| e.to_string())?;
Ok(out)
}
/// Premultiplied RGBA → TIFF (RGB, LZW-compressed by the encoder's default).
pub fn encode_tiff(width: u32, height: u32, premultiplied: &[u8]) -> Result<Vec<u8>, String> {
use image::ImageEncoder;
let rgb = over_white(premultiplied);
let mut out = std::io::Cursor::new(Vec::new());
image::codecs::tiff::TiffEncoder::new(&mut out).write_image(&rgb, width, height, image::ExtendedColorType::Rgb8).map_err(|e| e.to_string())?;
Ok(out.into_inner())
}