mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-30 12:47:13 -06:00
- pdf/src/encrypt.rs encrypts a written PDF with the standard security handler: 128-bit RC4 (PDF 1.4, revision 3; PDF 1.5, revision 4), 128-bit AES (1.6) or 256-bit AES (1.7/2.0, revision 6), every string and stream, plaintext XMP metadata when asked, the permission bits (printing none/low/high, five change levels, copy, screen readers). Crypto from RustCrypto (aes, md-5, sha2: MIT OR Apache-2.0, pure Rust, wasm). - PDF/A and PDF/X refuse passwords; equal passwords and passwords the algorithm can't take are refused. - Opening: the permissions password opens RC4/AES-128 files too; editing data comes back from an encrypted PDF opened with its password. - Save PDF › Security: the passwords, permissions and plaintext metadata, the encryption level. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
139 lines
5.7 KiB
Rust
139 lines
5.7 KiB
Rust
//! The pages of a PDF to import: opening with a password, the pages picked, the box each page
|
|
//! is cropped to ([`CropTo`]) and [`info`].
|
|
|
|
use hayro_syntax::object::Dict;
|
|
use hayro_syntax::page::Page;
|
|
use hayro_syntax::{DecryptionError, LoadPdfError, Pdf};
|
|
use kurbo::{Affine, Rect};
|
|
|
|
use crate::{Choice, CropTo, ImportOptions, PdfError};
|
|
|
|
/// What `info` tells about one page.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct PageInfo {
|
|
/// The page's size in points as viewers show it (its crop box, rotated).
|
|
pub width: f64,
|
|
pub height: f64,
|
|
/// The page's rotation in degrees (0, 90, 180 or 270).
|
|
pub rotation: u16,
|
|
/// Every box but Bounding, in PDF user space (y up), clipped to the media box. A missing
|
|
/// bleed, trim or art box is the crop box.
|
|
pub boxes: Vec<(CropTo, Rect)>,
|
|
}
|
|
|
|
/// The pages of a PDF ([`info`]).
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct PdfInfo {
|
|
pub pages: Vec<PageInfo>,
|
|
}
|
|
|
|
/// Is this a PostScript file (plain, or an EPS with a binary header) rather than a PDF?
|
|
pub fn is_postscript(bytes: &[u8]) -> bool {
|
|
bytes.starts_with(b"%!PS") || bytes.starts_with(&[0xC5, 0xD0, 0xD3, 0xC6])
|
|
}
|
|
|
|
/// Read a PDF, decrypting it with `password` (none: the empty user password). The permissions
|
|
/// password opens a file too.
|
|
pub(crate) fn open(bytes: &[u8], password: Option<&str>) -> Result<Pdf, PdfError> {
|
|
if is_postscript(bytes) {
|
|
return Err(PdfError::PostScript);
|
|
}
|
|
let password = password.unwrap_or_default();
|
|
let read = |pw: &str| Pdf::new_with_password(bytes.to_vec(), pw);
|
|
read(password)
|
|
.or_else(|e| match e {
|
|
// The reader takes only the open password of RC4 and 128-bit AES files: the
|
|
// permissions password gives it.
|
|
LoadPdfError::Decryption(DecryptionError::PasswordProtected) if !password.is_empty() => {
|
|
crate::encrypt::user_password(bytes, password).map_or(Err(e), |user| read(&user))
|
|
}
|
|
e => Err(e),
|
|
})
|
|
.map_err(|e| match e {
|
|
LoadPdfError::Decryption(DecryptionError::PasswordProtected) if password.is_empty() => PdfError::NeedsPassword,
|
|
LoadPdfError::Decryption(DecryptionError::PasswordProtected) => PdfError::WrongPassword,
|
|
e => PdfError::Parse(format!("{e:?}")),
|
|
})
|
|
}
|
|
|
|
/// The pages and their boxes. An encrypted PDF needs its `password`
|
|
/// ([`PdfError::NeedsPassword`] / [`PdfError::WrongPassword`]).
|
|
pub fn info(bytes: &[u8], password: Option<&str>) -> Result<PdfInfo, PdfError> {
|
|
let pdf = open(bytes, password)?;
|
|
let pages = pdf
|
|
.pages()
|
|
.iter()
|
|
.map(|page| {
|
|
let (w, h) = page.render_dimensions();
|
|
let rotation = match page.rotation() {
|
|
hayro_syntax::page::Rotation::None => 0,
|
|
hayro_syntax::page::Rotation::Horizontal => 90,
|
|
hayro_syntax::page::Rotation::Flipped => 180,
|
|
hayro_syntax::page::Rotation::FlippedHorizontal => 270,
|
|
};
|
|
let boxes = CropTo::ALL.iter().filter(|c| **c != CropTo::Bounding).map(|c| (*c, page_box(page, *c))).collect();
|
|
PageInfo { width: w as f64, height: h as f64, rotation, boxes }
|
|
})
|
|
.collect();
|
|
Ok(PdfInfo { pages })
|
|
}
|
|
|
|
/// The 0-based pages `opts` pick out of `count`.
|
|
pub(crate) fn picked(opts: &ImportOptions, count: usize) -> Result<Vec<usize>, PdfError> {
|
|
let mut v = match &opts.pages {
|
|
Some(p) => {
|
|
if let Some(bad) = p.iter().find(|i| **i >= count) {
|
|
return Err(PdfError::BadPage(bad.saturating_add(1), count));
|
|
}
|
|
p.clone()
|
|
}
|
|
None => (0..count).collect(),
|
|
};
|
|
if let Some(m) = opts.max_pages {
|
|
v.truncate(m);
|
|
}
|
|
if v.is_empty() {
|
|
return Err(PdfError::NoPages);
|
|
}
|
|
Ok(v)
|
|
}
|
|
|
|
fn rect(r: hayro_syntax::object::Rect) -> Rect {
|
|
Rect::new(r.x0, r.y0, r.x1, r.y1).abs()
|
|
}
|
|
|
|
/// Box `which` of `page` in PDF user space, clipped to the media box (Bounding: the crop box,
|
|
/// which the art's bounds replace after import). A missing or empty box is the crop box.
|
|
pub(crate) fn page_box(page: &Page<'_>, which: CropTo) -> Rect {
|
|
let media = rect(page.media_box());
|
|
let crop = rect(page.intersected_crop_box());
|
|
let key: &[u8] = match which {
|
|
CropTo::Bounding | CropTo::Crop => return crop,
|
|
CropTo::Media if media.area() > 0.0 => return media,
|
|
CropTo::Media => return crop,
|
|
CropTo::Bleed => b"BleedBox",
|
|
CropTo::Trim => b"TrimBox",
|
|
CropTo::Art => b"ArtBox",
|
|
};
|
|
page.raw().get::<hayro_syntax::object::Rect>(key).map(|r| rect(r).intersect(media)).filter(|r| r.area() > 0.0).unwrap_or(crop)
|
|
}
|
|
|
|
/// Where `page` draws on its artboard: the page transform (PDF user space → y-down page space
|
|
/// with the crop box at the origin, rotation applied) and `which` box in that space (a page with
|
|
/// no area gets the size viewers give it).
|
|
pub(crate) fn frame(page: &Page<'_>, which: CropTo) -> (Affine, Rect) {
|
|
let init = Affine::new(page.initial_transform(true).as_coeffs());
|
|
let b = init.transform_rect_bbox(page_box(page, which));
|
|
let (w, h) = page.render_dimensions();
|
|
(init, if b.is_finite() && b.area() > 0.0 { b } else { Rect::new(0.0, 0.0, w as f64, h as f64) })
|
|
}
|
|
|
|
/// Does `page` carry an editor's private data (`PieceInfo` keys starting with `AIPrivateData`)?
|
|
/// A file saved without PDF compatibility keeps its art there and shows a placeholder page.
|
|
pub(crate) fn has_private_data(page: &Page<'_>) -> bool {
|
|
fn walk(d: &Dict<'_>, depth: u32) -> bool {
|
|
depth < 4 && d.keys().any(|k| k.starts_with(b"AIPrivateData") || d.get::<Dict<'_>>(k.as_ref()).is_some_and(|sub| walk(&sub, depth + 1)))
|
|
}
|
|
page.raw().get::<Dict<'_>>(b"PieceInfo").is_some_and(|d| walk(&d, 0))
|
|
}
|