mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-26 02:58:32 -06:00
* fix(ui): validated signature display, snapshot tab reuse Signature widgets with layered appearances baked the unverified state into the page, so a valid signature kept reading 'Signature Not Verified' while the banner and panel said valid. Validated verdicts are now drawn into the icon and caption layers in the display copy only: the signed bytes are untouched, saving keeps the original appearance, and files without those layers or without a valid verdict render exactly as before. Other valid signatures still get the verdict mark over their widget, sharing one style with the Signatures panel, and hide it again once the appearance itself shows the verdict. View signed version and View revision opened a new tab on every click, and every tab showed the document title, so repeats piled up identical tabs. Repeats now reuse the snapshot tab, snapshot tabs read like their source plus the suffix in the tab strip, window title and Combine picker, and carry a notice bar naming the revision with a Close that asks about saving first. Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * Address review comments on validated appearances and trust refresh Count every field when checking shared appearance streams, so a caption shared with an unvalidated field is never rewritten for the valid one alone. Pick the icon slot by drawing instead of length, and only when a single layer qualifies. Bare rectangles never count as the icon, and ambiguous or missing slots fall back to the overlay badge. Revalidate and invalidate together through one helper, so trusting a certificate always drops rendered pages. Trust alone still marks nothing dirty. Tests: mixed-verdict sharing, longer decorative borders, competing and doubled layers, capture-before preservation, trust invalidation without zooming, and signed Session round trips with rendering, saving and passwords. Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * Prove the icon slot geometrically and cover real layered signatures A lone line drawing is still not the icon: frames and borders span the box while the status mark lives in a corner of it. The icon now has to sit strictly inside the appearance frame and cover at most half of it; anything else falls back to the overlay badge. Widgets without a measurable frame veto the rewrite the same way. Tests: parsed extents and inhabitant checks as units, both reported border repros plus a frameless widget, and real layered signatures built with cms::sign_detached through Session open, trust, rendering, save and reopen, plain and password-protected. Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * Identify the icon by shape instead of geometry A lone line drawing is still not the icon, but size and position were the wrong signals: the real status mark is large, and geometry blocked it. Frames, borders and panels are rectangles by nature while the mark is not, so a drawing made only of axis-aligned rectangles is decoration however it is stroked. Singularity, shared-stream and template checks are unchanged; anything ambiguous still falls back to the overlay badge. Tests: rectangularity as a unit, nested line-drawn frames, and a triangular fixture mark in place of the rectangular one. Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * Budget the appearance walk and keep the marks with the bytes The walk that finds a signature's icon and caption capped depth, layers and fields, but not the work: a dictionary whose entries all point at the next one is visited once per entry per level, so a small hostile file never finishes opening. The walk now counts its visits and gives up, and a field whose tree cannot be read to the end stops the whole pass, because a layer we never read may be the one two fields share and a rewritten shared layer puts one field's verdict on another field's page. The sharing count is exact again with that: two widgets of one field pointing at the same appearance claim it once, or the field vetoes its own rewrite. A mark used to outlive the bytes it described. A failed display write left the page showing the file's own caption while the verdict still claimed its state was drawn; a metadata edit dropped the mark while the page kept drawing the state, so the badge landed on top of it. The write fallback now drops the marks and logs the loss, and metadata carries them over. Tests: a self-referential appearance tree, which never finished before the budget, two widgets sharing one appearance, a metadata edit that keeps its mark, a save that goes through the writer instead of short-circuiting on the opened file, and the snapshot bar's Close button pressed for real. Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * Cite the new regressions in parity, restore main's installer note The appearance entry now cites the bounded walk and the save through the writer, and says what a tree that cannot be read to the end does. The installer entry carried main's older text, which still claims signed installers are planned; main's newer note is taken verbatim. Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> * M9: preserve rounded decoration beside raster status glyphs * M9: place appearance progress beside signature history * M9: retain appearances with ambiguous curved layers * M9: keep signature verdicts in viewer overlays Defer generic appearance rewriting: caption text and a unique drawing do not establish an icon role. Preserve original artwork for saves, image and text exports, and both print modes while retaining snapshot reuse and trust refresh. Cover chamfered borders and mixed signer/caption streams through signed Session round trips. --------- Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com> Co-authored-by: skylar taylor-barrick <hi@skylar.cc>
247 lines
9.8 KiB
Rust
247 lines
9.8 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`.
|
||
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())
|
||
}
|
||
|
||
#[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");
|
||
}
|
||
}
|