mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-26 11:08:48 -06:00
* M5: comments without an appearance stream are drawn (display only, never saved) #260 (page 7): a FreeText box with /DA and an orange /C background, a Text note and an Ink stroke, all without /AP, were not drawn; Acrobat, MuPDF, PDFium and Poppler draw them. The renderer only draws /AP (plus the Highlight fallback of #274), and pdfcraft-annot, which already draws all of these for PdfCraft's own comments, can't be used from the renderer (both are L3). pdfcraft_annot::with_missing_appearances returns a copy of the document in which every annotation without /AP /N that appearance::build can draw (FreeText, Ink, Text, FileAttachment, stamps, shapes, lines, markup, …) has the appearance build draws from its dictionary; links, form fields and pop-ups are left alone, as is anything build declines. The engine keeps that copy, written as an in-memory incremental update, in a new Document::display: the page view's render pool, page_render and page images (export_source) read it. Document::bytes, what Save, signatures and the inspector use, is unchanged. display is rebuilt wherever the renderer was (open, refresh, scoped refresh) and is bytes itself when nothing is missing or the document can't be edited. Tests: annot tests::comments_without_appearances_get_one_for_display_only (FreeText, Ink and Text get /AP /N in the copy, an existing /AP is kept, Link, Widget and an unknown subtype aren't touched, the original is untouched, none means no copy) and engine tests::comments_without_appearances_are_drawn_but_not_saved (the page view draws the FreeText background and the Ink stroke, before and after an edit; the working bytes stay the opened file; Save writes no /AP). On main the page view shows white where the FreeText box is. Parity: view.render-annotation-appearances cites the test and notes what's left (Link borders without /AP, printing). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Test the Ink stroke on its apex row: Ink is now a curve through its points (#374) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
192 lines
7.1 KiB
Rust
192 lines
7.1 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,
|
||
}
|
||
|
||
/// 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,
|
||
}
|
||
|
||
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() }
|
||
}
|
||
}
|
||
|
||
/// 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())
|
||
}
|
||
|
||
pub fn from_source(src: ExportSource) -> Self {
|
||
Self { renderer: PageRenderer::new(src.bytes, src.config), pages: src.pages }
|
||
}
|
||
|
||
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` (capped by the renderer's size limits).
|
||
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())
|
||
}
|