M4.85: PDF security: encryption at every compatibility level, permissions, either password opens
- 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>
This commit is contained in:
parent
b379f02527
commit
d8d1d472cf
|
|
@ -121,6 +121,17 @@ version = "2.0.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
|
|
@ -458,6 +469,15 @@ version = "2.13.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.5.1"
|
||||
|
|
@ -611,6 +631,16 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.1"
|
||||
|
|
@ -714,6 +744,15 @@ dependencies = [
|
|||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
|
|
@ -763,6 +802,16 @@ version = "0.2.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cursor-icon"
|
||||
version = "1.2.0"
|
||||
|
|
@ -775,6 +824,16 @@ version = "0.3.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dispatch"
|
||||
version = "0.2.0"
|
||||
|
|
@ -1309,6 +1368,16 @@ dependencies = [
|
|||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gethostname"
|
||||
version = "1.1.0"
|
||||
|
|
@ -1802,6 +1871,15 @@ dependencies = [
|
|||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.15.0"
|
||||
|
|
@ -2073,6 +2151,16 @@ version = "0.4.34"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
|
|
@ -3370,6 +3458,17 @@ dependencies = [
|
|||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
|
|
@ -3902,6 +4001,12 @@ dependencies = [
|
|||
"rustc-hash 2.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.2.1"
|
||||
|
|
@ -4266,14 +4371,17 @@ dependencies = [
|
|||
name = "vectorcraft-pdf"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"hayro-interpret",
|
||||
"hayro-syntax",
|
||||
"image",
|
||||
"krilla",
|
||||
"kurbo 0.13.1",
|
||||
"log",
|
||||
"md-5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"smallvec",
|
||||
"thiserror 2.0.21",
|
||||
"vectorcraft-brush",
|
||||
|
|
|
|||
|
|
@ -136,7 +136,12 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
|||
}
|
||||
"pdf" | "ai" | "ait" => {
|
||||
// Saved with Preserve Editing: the document it carries, unless some pages are picked.
|
||||
let editing = opts.pages.is_none().then(|| vectorcraft_pdf::editing(bytes)).flatten().map(|e| (e.intact, move || Some(e.data)));
|
||||
let editing = opts
|
||||
.pages
|
||||
.is_none()
|
||||
.then(|| vectorcraft_pdf::editing_with(bytes, opts.password.as_deref()))
|
||||
.flatten()
|
||||
.map(|e| (e.intact, move || Some(e.data)));
|
||||
restore_or_import(editing, || super::pdfimport::import(bytes, opts))?
|
||||
}
|
||||
_ if format.raster => (raster_doc(&title, bytes)?, vec![], false),
|
||||
|
|
|
|||
|
|
@ -710,6 +710,8 @@ mod tests_text;
|
|||
#[cfg(test)]
|
||||
mod tests_dxf;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_pdfsecurity;
|
||||
#[cfg(test)]
|
||||
mod tests_svgenc;
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -48,7 +48,12 @@ pub(super) const OPTIONS: &[FormatOption] = &[
|
|||
},
|
||||
FormatOption { name: "output", ty: "object", default: "null", description: "colour conversion and output intent (see document.exportPdf)" },
|
||||
FormatOption { name: "advanced", ty: "object", default: "null", description: "{fontSubsetPercent, outlineText, overprint}" },
|
||||
FormatOption { name: "security", ty: "object", default: "null", description: "passwords (not supported yet) and permissions" },
|
||||
FormatOption {
|
||||
name: "security",
|
||||
ty: "object",
|
||||
default: "null",
|
||||
description: "passwords and permissions (the file is encrypted: RC4 128-bit at 1.4–1.5, AES-128 at 1.6, AES-256 at 1.7/2.0)",
|
||||
},
|
||||
FormatOption {
|
||||
name: "includeNonPrinting",
|
||||
ty: "boolean",
|
||||
|
|
@ -64,7 +69,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Export PDF",
|
||||
[],
|
||||
None,
|
||||
"{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a|pdfX3|pdfX4, compatibility?: 1.4|1.5|1.6|1.7 (default)|2.0, preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, PDF/A refuses it), thumbnails?, fastWebView?, viewAfterSaving? (the app opens the written file), createLayers?, includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none|destination|preserveNumbers, destination, profiles: none|all|destination|taggedSource, outputIntent, outputCondition, outputConditionId, registry, trapped}, advanced?: {fontSubsetPercent: 100, outlineText: true, overprint: preserve|discard}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; PDF/X, passwords and PDF/A-2b at 2.0 are refused. document.export {format: pdf} takes the same options",
|
||||
"{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a|pdfX3|pdfX4, compatibility?: 1.4|1.5|1.6|1.7 (default)|2.0, preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, PDF/A refuses it), thumbnails?, fastWebView?, viewAfterSaving? (the app opens the written file), createLayers?, includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none|destination|preserveNumbers, destination, profiles: none|all|destination|taggedSource, outputIntent, outputCondition, outputConditionId, registry, trapped}, advanced?: {fontSubsetPercent: 100, outlineText: true, overprint: preserve|discard}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; PDF/X, PDF/A-2b at 2.0 and a password with a PDF/A or PDF/X standard are refused. document.export {format: pdf} takes the same options",
|
||||
has_doc,
|
||||
export_pdf
|
||||
),
|
||||
|
|
|
|||
|
|
@ -70,7 +70,8 @@ fn bad_options_are_refused() {
|
|||
json!({"standard": "pdfA2b", "compatibility": "2.0"}),
|
||||
json!({"compression": {"color": {"ppi": 0}}}),
|
||||
json!({"compression": "zip"}),
|
||||
json!({"security": {"openPassword": "x"}}),
|
||||
json!({"standard": "pdfA2b", "security": {"openPassword": "x"}}),
|
||||
json!({"security": {"openPassword": "x", "permissionsPassword": "x"}}),
|
||||
] {
|
||||
let e = s.execute("document.exportPdf", &p).unwrap_err();
|
||||
assert!(matches!(e, crate::EngineError::BadParams { .. }), "{p}: {e}");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
//! Opening password-protected PDFs: the open password or the permissions password unlocks them.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_testkit::pdf::{PdfPage, pdf};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_permissions_password_opens_a_protected_pdf_too() {
|
||||
let mut s = Session::new();
|
||||
// The test file's permissions (owner) password is its open password followed by `-owner`.
|
||||
let locked = vectorcraft_format::base64_encode(&pdf(&[PdfPage::new(80.0, 40.0, "0 g 0 0 10 10 re f")], Some("pw")));
|
||||
let info = |s: &mut Session, pw: &str| s.execute("document.pdfInfo", &json!({"dataBase64": locked, "password": pw})).unwrap();
|
||||
assert_eq!(info(&mut s, "pw-owner")["pages"], 1);
|
||||
assert_eq!(info(&mut s, "pw-owners")["wrongPassword"], true);
|
||||
let open = |s: &mut Session, pw: &str| s.execute("document.open", &json!({"name": "locked.pdf", "dataBase64": locked, "password": pw}));
|
||||
let e = open(&mut s, "nope").unwrap_err().to_string();
|
||||
assert!(e.contains("password is wrong"), "{e}");
|
||||
open(&mut s, "pw-owner").unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.artboards[0].rect.width(), 80.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_with_passwords_writes_a_pdf_that_only_its_passwords_open() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap();
|
||||
for compatibility in ["1.4", "1.5", "1.6", "1.7", "2.0"] {
|
||||
let p = json!({"compatibility": compatibility, "security": {"openPassword": "open", "permissionsPassword": "owner", "printing": "none"}});
|
||||
let out = s.execute("document.exportPdf", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
|
||||
let data = out["dataBase64"].as_str().unwrap().to_string();
|
||||
let bytes = vectorcraft_format::base64_decode(&data).unwrap();
|
||||
assert!(String::from_utf8_lossy(&bytes).contains("/Encrypt"), "{compatibility}: not encrypted");
|
||||
let info = |s: &mut Session, p: Value| s.execute("document.pdfInfo", &p).unwrap();
|
||||
assert_eq!(info(&mut s, json!({"dataBase64": data}))["needsPassword"], true, "{compatibility}");
|
||||
assert_eq!(info(&mut s, json!({"dataBase64": data, "password": "nope"}))["wrongPassword"], true, "{compatibility}");
|
||||
for pw in ["open", "owner"] {
|
||||
assert_eq!(info(&mut s, json!({"dataBase64": data, "password": pw}))["pages"], 1, "{compatibility} {pw}");
|
||||
}
|
||||
// The open password restores the editable document (Preserve Editing is on by default).
|
||||
let mut r = Session::new();
|
||||
r.execute("document.open", &json!({"name": "locked.pdf", "dataBase64": data, "password": "open"})).unwrap();
|
||||
assert!(r.doc().unwrap().doc.node_count() >= 2, "{compatibility}: the art came back");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permissions_without_a_password_warn_and_a_password_applies_them() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap();
|
||||
let warns = |v: &Value| v["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("permissions need a password"));
|
||||
let open = s.execute("document.exportPdf", &json!({"security": {"printing": "none"}})).unwrap();
|
||||
assert!(warns(&open), "{open}");
|
||||
let locked = s.execute("document.exportPdf", &json!({"security": {"permissionsPassword": "owner", "printing": "none"}})).unwrap();
|
||||
assert!(!warns(&locked), "{locked}");
|
||||
let bytes = vectorcraft_format::base64_decode(locked["dataBase64"].as_str().unwrap()).unwrap();
|
||||
assert!(String::from_utf8_lossy(&bytes).contains("/Encrypt"));
|
||||
}
|
||||
|
|
@ -857,3 +857,30 @@ proptest! {
|
|||
survive("mutated EPS file", || vectorcraft_eps::native(&bytes).and_then(|n| vectorcraft_format::load(&n).ok()))?;
|
||||
}
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(config())]
|
||||
|
||||
/// Damaged password-protected PDFs, opened with the open password, the permissions password
|
||||
/// (which reads the encryption dictionary to find the open password) or a wrong one.
|
||||
#[test]
|
||||
fn mutated_encrypted_pdfs_never_panic(
|
||||
cut in 0usize..2_000,
|
||||
edits in prop::collection::vec((0usize..2_000, any::<u8>()), 0..12),
|
||||
password in prop::sample::select(vec!["pw", "pw-owner", "wrong", ""]),
|
||||
) {
|
||||
use vectorcraft_testkit::pdf::{PdfPage, pdf};
|
||||
let bytes = pdf(&[PdfPage::new(100.0, 100.0, "0 0 1 rg 20 20 50 50 re f")], Some("pw"));
|
||||
let b64 = mutated_b64(bytes, cut, &edits);
|
||||
let r = catch_quiet(|| {
|
||||
let mut s = vectorcraft_engine::Session::new();
|
||||
let p = json!({"name": "x.pdf", "dataBase64": b64, "password": password});
|
||||
let _ = s.execute("document.pdfInfo", &p);
|
||||
if s.execute("document.open", &p).is_ok() {
|
||||
let d = (*s.doc().unwrap().doc).clone();
|
||||
survive("encrypted pdf", || Some(d)).unwrap();
|
||||
}
|
||||
});
|
||||
r.map_err(|msg| TestCaseError::fail(format!("{password}: panicked: {msg}")))?;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@ hayro-interpret = { version = "0.7.0", default-features = false, features = ["em
|
|||
log = { workspace = true }
|
||||
smallvec = "1"
|
||||
serde = { workspace = true }
|
||||
# PDF security (encrypt.rs): pure Rust, MIT OR Apache-2.0, builds for wasm.
|
||||
aes = "0.8"
|
||||
md-5 = { version = "0.10", default-features = false }
|
||||
sha2 = { version = "0.10", default-features = false }
|
||||
|
||||
[dev-dependencies]
|
||||
vectorcraft-testkit = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -63,7 +63,13 @@ pub(crate) fn seal(pdf: Vec<u8>) -> Result<Vec<u8>, PdfError> {
|
|||
|
||||
/// The native document `bytes` (a PDF) carries, if any, and whether its pages changed since.
|
||||
pub fn editing(bytes: &[u8]) -> Option<Editing> {
|
||||
editing_in(&Pdf::new(bytes.to_vec()).ok()?)
|
||||
editing_with(bytes, None)
|
||||
}
|
||||
|
||||
/// [`editing`] of a PDF that may be encrypted, opened with `password` (its open or permissions
|
||||
/// password).
|
||||
pub fn editing_with(bytes: &[u8], password: Option<&str>) -> Option<Editing> {
|
||||
editing_in(&crate::pages::open(bytes, password).ok()?)
|
||||
}
|
||||
|
||||
/// [`editing`] of a parsed PDF.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -92,6 +92,7 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportRep
|
|||
w.absorb(ex);
|
||||
let (bytes, more) = w.finish()?;
|
||||
let bytes = if native.is_some() { crate::editing::seal(bytes)? } else { bytes };
|
||||
let bytes = crate::encrypt::protect(bytes, set)?;
|
||||
warnings.extend(more);
|
||||
warnings.dedup();
|
||||
Ok(ExportReport { bytes, warnings })
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ pub(crate) fn rfind(hay: &[u8], needle: &[u8]) -> Option<usize> {
|
|||
}
|
||||
|
||||
/// The offset `startxref` gives, when it points at a cross-reference table.
|
||||
fn xref_offset(pdf: &[u8]) -> Option<usize> {
|
||||
pub(crate) fn xref_offset(pdf: &[u8]) -> Option<usize> {
|
||||
let sx = rfind(pdf, b"startxref")?;
|
||||
let off: usize = std::str::from_utf8(&pdf[sx + 9..]).ok()?.split_whitespace().next()?.parse().ok()?;
|
||||
pdf.get(off..)?.starts_with(b"xref").then_some(off)
|
||||
|
|
|
|||
|
|
@ -28,9 +28,13 @@
|
|||
//! - Presets: named settings, the built-in ones generated in code ([`builtin_presets`]).
|
||||
//! - [`print()`] lays a document out on paper as File → Print does ([`PrintSettings`]) and writes
|
||||
//! the job as a print-ready PDF: composite or one page per ink.
|
||||
//! - Security: the open and permissions passwords encrypt the written file with the standard
|
||||
//! security handler, the algorithm following the compatibility ([`Encryption`]); either password
|
||||
//! opens an encrypted file.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod editing;
|
||||
mod encrypt;
|
||||
mod export;
|
||||
mod images;
|
||||
mod import;
|
||||
|
|
@ -46,7 +50,8 @@ mod presets;
|
|||
mod print;
|
||||
mod settings;
|
||||
|
||||
pub use editing::{EDITING_FILE, Editing, LEGACY_EDITING_FILE, editing};
|
||||
pub use editing::{EDITING_FILE, Editing, LEGACY_EDITING_FILE, editing, editing_with};
|
||||
pub use encrypt::Encryption;
|
||||
pub use export::{export, export_with_report};
|
||||
pub use import::{import, import_with_report};
|
||||
pub use pages::{PageInfo, PdfInfo, info, is_postscript};
|
||||
|
|
@ -193,6 +198,8 @@ mod tests_dashalign;
|
|||
#[cfg(test)]
|
||||
mod tests_editing;
|
||||
#[cfg(test)]
|
||||
mod tests_encrypt;
|
||||
#[cfg(test)]
|
||||
mod tests_focal;
|
||||
#[cfg(test)]
|
||||
mod tests_fx;
|
||||
|
|
|
|||
|
|
@ -32,17 +32,28 @@ 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).
|
||||
/// 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();
|
||||
Pdf::new_with_password(bytes.to_vec(), password).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:?}")),
|
||||
})
|
||||
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`
|
||||
|
|
|
|||
|
|
@ -391,15 +391,12 @@ pub(crate) fn within(name: &str, v: f64, lo: f64, hi: f64, unit: &str) -> Result
|
|||
}
|
||||
|
||||
impl PdfSettings {
|
||||
/// Refuse settings the writer can't honour: an unsupported standard, a password (the file
|
||||
/// would not be protected), or what [`Self::check_values`] refuses.
|
||||
/// Refuse settings the writer can't honour: an unsupported standard, or what
|
||||
/// [`Self::check_values`] refuses.
|
||||
pub fn check(&self) -> Result<(), PdfError> {
|
||||
if !self.standard.supported() {
|
||||
return Err(PdfError::Unsupported(format!("{} output is not supported yet", self.standard.label())));
|
||||
}
|
||||
if !self.security.open_password.is_empty() || !self.security.permissions_password.is_empty() {
|
||||
return Err(PdfError::Unsupported("password protection is not supported yet (the PDF would not be encrypted)".into()));
|
||||
}
|
||||
self.check_values()
|
||||
}
|
||||
|
||||
|
|
@ -407,6 +404,7 @@ impl PdfSettings {
|
|||
/// produce yet): a standard with a PDF version it doesn't allow, PDF/A with editing data, or
|
||||
/// an out-of-range value.
|
||||
pub fn check_values(&self) -> Result<(), PdfError> {
|
||||
self.security.check(self.standard, self.compatibility)?;
|
||||
if !self.standard.allows(self.compatibility) {
|
||||
return Err(PdfError::BadSetting(format!("{} files can't be {}", self.standard.label(), self.compatibility.label())));
|
||||
}
|
||||
|
|
@ -452,17 +450,19 @@ impl PdfSettings {
|
|||
),
|
||||
(!self.advanced.outline_text, "text is exported as outlines: real, selectable text is not written yet"),
|
||||
(
|
||||
s.printing != d.security.printing
|
||||
|| s.changes != d.security.changes
|
||||
|| s.copy != d.security.copy
|
||||
|| s.screen_reader != d.security.screen_reader
|
||||
|| s.plaintext_metadata != d.security.plaintext_metadata,
|
||||
"permissions need a permissions password, which is not supported yet: they are not applied",
|
||||
!s.protected()
|
||||
&& (s.printing != d.security.printing
|
||||
|| s.changes != d.security.changes
|
||||
|| s.copy != d.security.copy
|
||||
|| s.screen_reader != d.security.screen_reader
|
||||
|| s.plaintext_metadata != d.security.plaintext_metadata),
|
||||
"permissions need a password: without one they are not applied",
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|(on, _)| *on)
|
||||
.map(|(_, w)| w.to_string())
|
||||
.chain(crate::encrypt::warnings(self))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,220 @@
|
|||
//! PDF security: password-protected files open only with a password, carry the permissions asked
|
||||
//! for, and refuse what a PDF standard forbids.
|
||||
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
|
||||
use crate::encrypt::protect;
|
||||
use crate::lab_spot::find;
|
||||
use crate::*;
|
||||
|
||||
const VERSIONS: [Compatibility; 5] = [Compatibility::Pdf14, Compatibility::Pdf15, Compatibility::Pdf16, Compatibility::Pdf17, Compatibility::Pdf20];
|
||||
|
||||
fn doc() -> Document {
|
||||
let mut d = Document::new(200.0, 100.0);
|
||||
d.title = "Tïtle (x)".into();
|
||||
let mut n = Node::path(
|
||||
NodeId(0),
|
||||
shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 40.0)),
|
||||
Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0),
|
||||
);
|
||||
n.id = d.alloc_id();
|
||||
let layer = d.default_layer().unwrap();
|
||||
d.insert(Some(layer), 0, n).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn security(open: &str, permissions: &str) -> SecuritySettings {
|
||||
SecuritySettings { open_password: open.into(), permissions_password: permissions.into(), ..Default::default() }
|
||||
}
|
||||
|
||||
/// `doc()` exported at `version` (with Preserve Editing when `native`), then protected by `sec`.
|
||||
fn protected(version: Compatibility, sec: SecuritySettings, native: bool) -> Vec<u8> {
|
||||
let mut opts = PdfOptions::default();
|
||||
opts.settings.compatibility = version;
|
||||
opts.settings.preserve_editing = native;
|
||||
opts.native = native.then(|| b"{\"native\": true}".to_vec());
|
||||
let plain = export(&doc(), &opts).unwrap();
|
||||
opts.settings.security = sec;
|
||||
protect(plain, &opts.settings).unwrap()
|
||||
}
|
||||
|
||||
fn shapes_of(bytes: &[u8], password: Option<&str>) -> usize {
|
||||
let d = import_with_report(bytes, &ImportOptions { password: password.map(str::to_string), ..Default::default() }).unwrap().document;
|
||||
let NodeKind::Layer { children, .. } = &d.layers[0].kind else { panic!("a layer") };
|
||||
children.len()
|
||||
}
|
||||
|
||||
/// The encryption dictionary's text.
|
||||
fn encrypt_dict(bytes: &[u8]) -> String {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
let at = text.find("/Filter/Standard").unwrap();
|
||||
text[at..at + text[at..].find("\nendobj").unwrap()].to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_level_opens_only_with_a_password() {
|
||||
for (v, (head, cfm)) in VERSIONS.into_iter().zip([
|
||||
("/V 2/R 3/Length 128", ""),
|
||||
("/V 4/R 4/Length 128", "/CFM/V2"),
|
||||
("/V 4/R 4/Length 128", "/CFM/AESV2"),
|
||||
("/V 5/R 6/Length 256", "/CFM/AESV3"),
|
||||
("/V 5/R 6/Length 256", "/CFM/AESV3"),
|
||||
]) {
|
||||
let bytes = protected(v, security("open sesame", "owner"), false);
|
||||
let dict = encrypt_dict(&bytes);
|
||||
assert!(dict.contains(head) && dict.contains(cfm), "{v:?}: {dict}");
|
||||
assert!(bytes.starts_with(format!("%PDF-{}", v.id()).as_bytes()), "{v:?} keeps its version");
|
||||
assert_eq!(info(&bytes, None).unwrap_err(), PdfError::NeedsPassword, "{v:?}");
|
||||
assert_eq!(info(&bytes, Some("guess")).unwrap_err(), PdfError::WrongPassword, "{v:?}");
|
||||
// The open password and the permissions password both open it, and the art decrypts.
|
||||
assert_eq!(shapes_of(&bytes, Some("open sesame")), 1, "{v:?}");
|
||||
assert_eq!(shapes_of(&bytes, Some("owner")), 1, "{v:?}: the permissions password opens it too");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strings_and_streams_are_encrypted() {
|
||||
let mut sec = security("pw", "");
|
||||
sec.plaintext_metadata = false;
|
||||
let bytes = protected(Compatibility::Pdf17, sec, true);
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
for plain in ["VectorCraft", "native", "xmpmeta", "FEFF0054"] {
|
||||
assert!(!text.contains(plain), "`{plain}` is readable");
|
||||
}
|
||||
// The trailer names the dictionary; the ID stays as it was.
|
||||
let trailer = &text[text.rfind("trailer").unwrap()..];
|
||||
assert!(trailer.contains("/Encrypt ") && trailer.contains("/ID["), "{trailer}");
|
||||
// 256-bit AES in a PDF 1.7 file is declared in the catalog.
|
||||
assert!(text.contains("/Extensions<</ADBE<</BaseVersion/1.7/ExtensionLevel 8>>>>"));
|
||||
assert!(!String::from_utf8_lossy(&protected(Compatibility::Pdf20, security("pw", ""), false)).contains("/Extensions"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plaintext_metadata_stays_readable() {
|
||||
for v in [Compatibility::Pdf15, Compatibility::Pdf16, Compatibility::Pdf17] {
|
||||
let bytes = protected(v, security("pw", ""), false);
|
||||
assert!(find(&bytes, b"<x:xmpmeta", 0).is_some(), "{v:?}: the XMP metadata is plain");
|
||||
assert!(encrypt_dict(&bytes).contains("/EncryptMetadata false"), "{v:?}");
|
||||
assert_eq!(shapes_of(&bytes, Some("pw")), 1, "{v:?}");
|
||||
}
|
||||
// PDF 1.4 encrypts it anyway, and says so.
|
||||
let bytes = protected(Compatibility::Pdf14, security("pw", ""), false);
|
||||
assert!(find(&bytes, b"<x:xmpmeta", 0).is_none());
|
||||
let set = PdfSettings { compatibility: Compatibility::Pdf14, security: security("pw", ""), ..Default::default() };
|
||||
assert!(set.warnings().iter().any(|w| w.contains("plaintext metadata")), "{:?}", set.warnings());
|
||||
let set = PdfSettings { compatibility: Compatibility::Pdf15, ..set };
|
||||
assert!(set.warnings().is_empty(), "{:?}", set.warnings());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_permissions_password_alone_opens_without_a_password_and_restricts() {
|
||||
for v in VERSIONS {
|
||||
let sec = SecuritySettings { printing: Printing::Low, changes: Changes::Comments, copy: false, ..security("", "owner only") };
|
||||
let bytes = protected(v, sec.clone(), false);
|
||||
assert_eq!(shapes_of(&bytes, None), 1, "{v:?} opens without a password");
|
||||
assert!(encrypt_dict(&bytes).contains(&format!("/P {}", sec.permission_bits())), "{v:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_bits_follow_the_options() {
|
||||
let bits = |sec: SecuritySettings| sec.permission_bits() as u32;
|
||||
let bit = |n: u32| 1u32 << (n - 1);
|
||||
let reserved = 0xFFFF_F0C0;
|
||||
// Without a permissions password everything is allowed.
|
||||
assert_eq!(bits(SecuritySettings { printing: Printing::None, copy: false, ..security("open", "") }), 0xFFFF_FFFC);
|
||||
// Everything allowed: print (3), modify (4), copy (5), annotate (6), forms (9), accessibility
|
||||
// (10), assemble (11), high-quality print (12).
|
||||
assert_eq!(bits(security("", "p")), reserved | [3, 4, 5, 6, 9, 10, 11, 12].iter().map(|n| bit(*n)).sum::<u32>());
|
||||
let none = SecuritySettings { printing: Printing::None, changes: Changes::None, copy: false, screen_reader: false, ..security("", "p") };
|
||||
assert_eq!(bits(none.clone()), reserved);
|
||||
assert_eq!(bits(SecuritySettings { printing: Printing::Low, ..none.clone() }), reserved | bit(3));
|
||||
assert_eq!(bits(SecuritySettings { printing: Printing::High, ..none.clone() }), reserved | bit(3) | bit(12));
|
||||
for (changes, set) in [
|
||||
(Changes::Pages, bit(11)),
|
||||
(Changes::Forms, bit(9)),
|
||||
(Changes::Comments, bit(6) | bit(9)),
|
||||
(Changes::Any, bit(4) | bit(6) | bit(9) | bit(11)),
|
||||
] {
|
||||
assert_eq!(bits(SecuritySettings { changes, ..none.clone() }), reserved | set, "{changes:?}");
|
||||
}
|
||||
assert_eq!(bits(SecuritySettings { screen_reader: true, ..none.clone() }), reserved | bit(10));
|
||||
assert_eq!(bits(SecuritySettings { copy: true, ..none }), reserved | bit(5) | bit(10), "copying lets screen readers read");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standards_and_bad_passwords_are_refused() {
|
||||
let set = |standard, compatibility, sec| PdfSettings { standard, compatibility, security: sec, ..Default::default() };
|
||||
for standard in [Standard::PdfA2b, Standard::PdfX1a, Standard::PdfX3, Standard::PdfX4] {
|
||||
let e = set(standard, Compatibility::Pdf17, security("pw", "")).check_values().unwrap_err();
|
||||
assert!(matches!(&e, PdfError::BadSetting(m) if m.contains("password-protected")), "{standard:?}: {e}");
|
||||
let e = set(standard, Compatibility::Pdf17, security("", "pw")).check_values().unwrap_err();
|
||||
assert!(e.to_string().contains("password-protected"), "{standard:?}: {e}");
|
||||
assert!(set(standard, Compatibility::Pdf17, security("", "")).check_values().is_ok(), "{standard:?} without passwords");
|
||||
}
|
||||
let bad = |c, sec| set(Standard::None, c, sec).check_values().unwrap_err().to_string();
|
||||
assert!(bad(Compatibility::Pdf17, security("same", "same")).contains("must differ"));
|
||||
assert!(bad(Compatibility::Pdf16, security("pässword", "")).contains("ASCII"));
|
||||
assert!(bad(Compatibility::Pdf14, security(&"x".repeat(33), "")).contains("32"));
|
||||
assert!(bad(Compatibility::Pdf17, security(&"x".repeat(128), "")).contains("127"));
|
||||
assert!(set(Standard::None, Compatibility::Pdf17, security("pässwörd", "ünïcode")).check_values().is_ok());
|
||||
assert!(set(Standard::None, Compatibility::Pdf14, security(&"x".repeat(32), "")).check_values().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_passwords_open_aes256_files() {
|
||||
let bytes = protected(Compatibility::Pdf20, security("pässwörd", "ünïcode"), false);
|
||||
assert_eq!(info(&bytes, Some("pässwörd")).unwrap().pages.len(), 1);
|
||||
assert_eq!(info(&bytes, Some("ünïcode")).unwrap().pages.len(), 1);
|
||||
assert_eq!(info(&bytes, Some("passwort")).unwrap_err(), PdfError::WrongPassword);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_data_comes_back_with_the_password() {
|
||||
// The editing file is found by its name (a string) and read from its stream: both decrypt.
|
||||
for v in VERSIONS {
|
||||
let bytes = protected(v, security("pw", "own"), true);
|
||||
assert_eq!(editing(&bytes), None, "{v:?}: locked");
|
||||
for pw in ["pw", "own"] {
|
||||
let e = editing_with(&bytes, Some(pw)).unwrap();
|
||||
assert_eq!((e.data.as_slice(), e.intact), (&b"{\"native\": true}"[..], true), "{v:?} {pw}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_password_nothing_changes() {
|
||||
let mut opts = PdfOptions::default();
|
||||
let plain = export(&doc(), &opts).unwrap();
|
||||
opts.settings.security = SecuritySettings { printing: Printing::None, ..Default::default() };
|
||||
assert_eq!(protect(plain.clone(), &opts.settings).unwrap(), plain);
|
||||
assert_eq!(opts.settings.encryption(), None);
|
||||
opts.settings.security.open_password = "x".into();
|
||||
assert_eq!(opts.settings.encryption(), Some(Encryption::Aes256));
|
||||
for (c, e) in VERSIONS.into_iter().zip([Encryption::Rc4, Encryption::Rc4, Encryption::Aes128, Encryption::Aes256, Encryption::Aes256]) {
|
||||
assert_eq!(Encryption::for_compatibility(c), e);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_permissions_password_opens_files_of_other_writers() {
|
||||
use vectorcraft_testkit::pdf::{PdfPage, pdf};
|
||||
// Revision 2 (40-bit RC4): the owner password is the user password followed by `-owner`.
|
||||
let bytes = pdf(&[PdfPage::new(50.0, 50.0, "0 g 0 0 10 10 re f")], Some("pw"));
|
||||
assert_eq!(info(&bytes, Some("pw-owner")).unwrap().pages.len(), 1);
|
||||
assert_eq!(info(&bytes, Some("pw-other")).unwrap_err(), PdfError::WrongPassword);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_files_never_panic_when_reading_the_owner_password() {
|
||||
let bytes = protected(Compatibility::Pdf15, security("pw", "own"), false);
|
||||
for cut in (0..bytes.len()).step_by(97) {
|
||||
let _ = crate::encrypt::user_password(&bytes[..cut], "own");
|
||||
let _ = info(&bytes[cut..], Some("own"));
|
||||
}
|
||||
for junk in [&b""[..], b"startxref", b"startxref\n99999999999999999999", b"trailer<</Encrypt<</R 3/O()>>>>startxref 0"] {
|
||||
assert_eq!(crate::encrypt::user_password(junk, "own"), None);
|
||||
}
|
||||
}
|
||||
|
|
@ -54,8 +54,10 @@ fn checks_refuse_what_the_writer_cant_honour() {
|
|||
assert_eq!(PdfSettings::default().check(), Ok(()));
|
||||
let refused = |v: serde_json::Value| settings(v).check().unwrap_err();
|
||||
assert!(matches!(refused(json!({"standard": "pdfX4"})), PdfError::Unsupported(_)));
|
||||
assert!(matches!(refused(json!({"security": {"openPassword": "x"}})), PdfError::Unsupported(_)));
|
||||
assert!(matches!(refused(json!({"security": {"permissionsPassword": "x"}})), PdfError::Unsupported(_)));
|
||||
// Passwords are written (the file is encrypted); a password with a standard is not.
|
||||
assert_eq!(settings(json!({"security": {"openPassword": "x"}})).check(), Ok(()));
|
||||
assert_eq!(settings(json!({"security": {"permissionsPassword": "x"}})).check(), Ok(()));
|
||||
assert!(matches!(refused(json!({"standard": "pdfA2b", "security": {"openPassword": "x"}})), PdfError::BadSetting(_)));
|
||||
assert!(matches!(refused(json!({"compression": {"color": {"ppi": 5}}})), PdfError::BadSetting(m) if m.contains("compression.color.ppi")));
|
||||
assert!(matches!(refused(json!({"bleed": {"left": 100}})), PdfError::BadSetting(m) if m.contains("bleed.left")));
|
||||
assert!(matches!(refused(json!({"marks": {"weight": 0}})), PdfError::BadSetting(_)));
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ use serde_json::{Map, Value, json};
|
|||
use vectorcraft_doc::Unit;
|
||||
use vectorcraft_engine::cmd::fileio::{SaveMode, pdf};
|
||||
use vectorcraft_pdf::{
|
||||
Changes, Choice, ColorConversion, Compatibility, Downsample, ImageCodec, JpegQuality, MarkKind, MonoCodec, Overprint, PdfSettings, Printing,
|
||||
ProfileInclusion, Standard,
|
||||
Changes, Choice, ColorConversion, Compatibility, Downsample, Encryption, ImageCodec, JpegQuality, MarkKind, MonoCodec, Overprint, PdfSettings,
|
||||
Printing, ProfileInclusion, Standard,
|
||||
};
|
||||
|
||||
use super::{DialogSpec, form};
|
||||
|
|
@ -311,8 +311,11 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
|||
set(d, "compatibility", json!(Compatibility::Pdf17.id()));
|
||||
}
|
||||
if picked && standard != Standard::None {
|
||||
// Files of a standard don't carry the editing data.
|
||||
// Files of a standard don't carry the editing data, nor passwords.
|
||||
set(d, "preserveEditing", json!(false));
|
||||
for p in [OPEN_PASSWORD, PERMISSIONS_PASSWORD] {
|
||||
set(d, p, json!(""));
|
||||
}
|
||||
}
|
||||
ui.add_space(16.0);
|
||||
ui.label(egui::RichText::new("Compatibility:").color(t.text));
|
||||
|
|
@ -569,19 +572,42 @@ fn advanced(ui: &mut egui::Ui, d: &mut Dialog) {
|
|||
});
|
||||
}
|
||||
|
||||
/// The password fields (never stored in presets).
|
||||
const OPEN_PASSWORD: &str = "security.openPassword";
|
||||
const PERMISSIONS_PASSWORD: &str = "security.permissionsPassword";
|
||||
|
||||
/// A checkbox turning a password on (UI-only `flag`) or off (clearing it); checked while the
|
||||
/// password is set. Returns whether the password applies.
|
||||
fn password_check(ui: &mut egui::Ui, d: &mut Dialog, path: &str, flag: &str, label: &str, enabled: bool) -> bool {
|
||||
let on = d.bool(flag) || get(d, path).as_str().is_some_and(|s| !s.is_empty());
|
||||
if widgets::check(ui, label, on, enabled) {
|
||||
d.fields.insert(flag.into(), json!(!on));
|
||||
if on {
|
||||
set(d, path, json!(""));
|
||||
}
|
||||
}
|
||||
on && enabled
|
||||
}
|
||||
|
||||
fn password_field(ui: &mut egui::Ui, d: &mut Dialog, path: &str, enabled: bool) {
|
||||
let mut s = get(d, path).as_str().unwrap_or_default().to_string();
|
||||
if ui.add_enabled(enabled, egui::TextEdit::singleline(&mut s).password(true).desired_width(200.0)).changed() {
|
||||
set(d, path, json!(s));
|
||||
}
|
||||
}
|
||||
|
||||
fn security(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
note(ui, "Password protection is not available yet, so these options are off.");
|
||||
let plain = choice::<Standard>(d, "standard").unwrap_or_default() == Standard::None;
|
||||
if !plain {
|
||||
note(ui, "Files of a PDF standard can't be password-protected.");
|
||||
}
|
||||
heading(ui, "Document open password");
|
||||
widgets::check(ui, "Require a password to open the document", false, false);
|
||||
row(ui, "Password:", |ui| {
|
||||
ui.add_enabled(false, egui::TextEdit::singleline(&mut String::new()).password(true).desired_width(200.0));
|
||||
});
|
||||
let open = password_check(ui, d, OPEN_PASSWORD, "__requireOpenPassword", "Require a password to open the document", plain);
|
||||
row(ui, "Open password:", |ui| password_field(ui, d, OPEN_PASSWORD, open));
|
||||
heading(ui, "Permissions");
|
||||
widgets::check(ui, "Restrict printing, editing and other tasks", false, false);
|
||||
row(ui, "Permissions password:", |ui| {
|
||||
ui.add_enabled(false, egui::TextEdit::singleline(&mut String::new()).password(true).desired_width(200.0));
|
||||
});
|
||||
ui.add_enabled_ui(false, |ui| {
|
||||
let restrict = password_check(ui, d, PERMISSIONS_PASSWORD, "__restrictPermissions", "Restrict printing, editing and other tasks", plain);
|
||||
row(ui, "Permissions password:", |ui| password_field(ui, d, PERMISSIONS_PASSWORD, restrict));
|
||||
ui.add_enabled_ui(restrict, |ui| {
|
||||
row(ui, "Printing allowed:", |ui| {
|
||||
pick::<Printing>(ui, d, "security.printing", 260.0, |_| true);
|
||||
});
|
||||
|
|
@ -589,9 +615,18 @@ fn security(ui: &mut egui::Ui, d: &mut Dialog) {
|
|||
pick::<Changes>(ui, d, "security.changes", 260.0, |_| true);
|
||||
});
|
||||
});
|
||||
flag(ui, d, "security.copy", "Enable copying of text, images and other content", false);
|
||||
flag(ui, d, "security.screenReader", "Enable text access for screen readers", false);
|
||||
flag(ui, d, "security.plaintextMetadata", "Enable plaintext metadata", false);
|
||||
flag(ui, d, "security.copy", "Enable copying of text, images and other content", restrict);
|
||||
let reader = "Enable text access for screen readers";
|
||||
if get(d, "security.copy").as_bool() == Some(true) {
|
||||
// What can be copied can be read out.
|
||||
widgets::check(ui, reader, true, false);
|
||||
} else {
|
||||
flag(ui, d, "security.screenReader", reader, restrict);
|
||||
}
|
||||
let compatibility = choice::<Compatibility>(d, "compatibility").unwrap_or_default();
|
||||
flag(ui, d, "security.plaintextMetadata", "Enable plaintext metadata", (open || restrict) && compatibility != Compatibility::Pdf14);
|
||||
ui.add_space(6.0);
|
||||
note(ui, &format!("Encryption: {} (set by Compatibility)", Encryption::for_compatibility(compatibility).label()));
|
||||
}
|
||||
|
||||
/// The section a changed option belongs to (for the Summary's order).
|
||||
|
|
@ -841,4 +876,25 @@ mod tests {
|
|||
assert_eq!(section_of("bleed.top"), 2);
|
||||
assert_eq!(SECTIONS[section_of("standard")], "General");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_security_section_takes_passwords_that_presets_drop() {
|
||||
let (mut app, _, _) = app();
|
||||
app.run("ui.savePdfDialog", json!({})).unwrap();
|
||||
set_field(&mut app, "__section", json!("Security"));
|
||||
set_field(&mut app, OPEN_PASSWORD, json!("open"));
|
||||
set_field(&mut app, PERMISSIONS_PASSWORD, json!("own"));
|
||||
set_field(&mut app, "security.copy", json!(false));
|
||||
frame(&mut app);
|
||||
let p = params(app.ui.dialog.as_ref().unwrap());
|
||||
assert_eq!((p["security"]["openPassword"].as_str(), p["security"]["permissionsPassword"].as_str()), (Some("open"), Some("own")));
|
||||
assert_eq!(p["security"]["copy"], false);
|
||||
// With a standard the section still draws (greyed); a preset brings its own security.
|
||||
set_field(&mut app, "standard", json!(Standard::PdfA2b.id()));
|
||||
frame(&mut app);
|
||||
let mut d = app.ui.dialog.clone().unwrap();
|
||||
apply_preset(&mut app, &mut d, pdf::DEFAULT_PRESET);
|
||||
assert_eq!(get(&d, OPEN_PASSWORD), &Value::Null, "presets carry no passwords");
|
||||
assert_eq!(get(&d, "security.copy"), true);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -215,7 +215,9 @@ Long-tail commands:
|
|||
PDF files take the Save PDF dialog's options: `preset`, `standard`, `compatibility`, the General toggles and the
|
||||
`compression`, `marks`, `bleed`, `output`, `advanced` and `security` sections (`list_commands` with filter `exportPdf`
|
||||
documents every field). `document.exportPdf` and `export` (format `pdf`, the options in `options`) return `warnings`:
|
||||
options accepted but not applied yet, and features approximated or left out. PDF/X, passwords and PDF/A-2b at 2.0 are refused.
|
||||
options accepted but not applied yet, and features approximated or left out. PDF/X and PDF/A-2b at 2.0 are refused.
|
||||
A `security` password encrypts the file (RC4 128-bit at PDF 1.4–1.5, AES-128 at 1.6, AES-256 at 1.7 and 2.0) with its
|
||||
permissions; either password opens it (`document.open {password}`), and a password with PDF/A or PDF/X is refused.
|
||||
Pattern fills and strokes are written as their tiles clipped to the area they paint (a stroke's outline, with its
|
||||
dashes, caps, profile, arrowheads and alignment), and freeform gradients as an image of their colour field at the
|
||||
document's raster effects resolution, clipped the same way.
|
||||
|
|
|
|||
Loading…
Reference in New Issue