mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-11 10:46:28 +00:00
fix(crypt, engine): refuse page extraction when Protect allows any change except extracting pages (#5081)
## What this changes Adds `Permissions::extract_pages()`: `assemble()` **and** copying (bit 5), as #5081 asks, since extracted pages carry their text and graphics into a file without the source's protection. It is used for Extract, Split, Split by size and every Combine/Insert/Replace/paste source check (`sources::source_document`). Changes within the document (rotate, delete, move, insert) keep `assemble()`. Since this PR was opened, #6309 made `assemble()` revision-aware on `main`: bit 11 alone at revision 3 and later, bit 4 at revision 2. That already refuses "Any except extracting pages" at revision 3+. This PR builds on it: - At revision 2, `assemble()` reads bit 4, which "Any except extracting pages" sets, so such a file could still be extracted and split into unprotected copies. Requiring copying closes that. - New `Needs::PageExtraction` greys out Extract and Split up front. It reuses the existing security-refusal string, so no catalogue changes. - `Document::allows_page_extraction()`, and an `extract_pages` key in the automation security JSON. - `page_extract` with `separate: true` extracts and writes one page at a time (`extract_separate`), so it no longer buffers every page. The first page is extracted before `out_dir` is created, so a refused extraction leaves no empty directory. If a later page fails, the files this call wrote are removed, and `out_dir` too if this call created it (review on this PR). - Insert Pages checks a PDF through the same path as the insert itself: new `Session::insert_source(dest, name, bytes)`, which the UI's insert now calls (review on this PR). - The `organize.combine` parity notes now say "page extraction". Fixes #5081 ## How you verified it `cargo fmt --all` is clean. Regression tests (adopted from Harrison-Booth's review patch, plus CodeRabbit's follow-ups): - `crates/crypt/src/tests.rs::page_extraction_needs_assembly_and_copying`: unit test for `extract_pages()`. - `crates/engine/src/tests.rs::extraction_permissions_cover_revision_three_to_six_and_leave_no_partial_output`: AnyExceptExtract on RC4-128, AES-128 and AES-256 refuses Extract, Split, Split by size and Combine/Insert/Replace sources without the permissions password, leaves source and destination untouched, and the owner can do everything. The authorized combine output has 2 pages, and a differently encrypted or unprotected destination is refused. - `crates/engine/src/tests.rs::page_extraction_needs_the_copy_permission_on_every_revision`: a file that withholds only copying (R2 RC4-40 and AES-256) refuses extraction; the owner password lifts it. - `crates/engine/src/tests.rs::pages_under_the_same_protection_are_inserted_without_page_extraction`: a separately built file under matching protection is inserted, and the result stays restricted. - `crates/automation/tests/it/automation.rs::refused_extraction_writes_no_split_or_page_output`, and the `extract_pages` checks in `protecting_through_tools`, which now cover three presets, including a user (not the owner) for whom extraction is allowed. **Only the crypt unit test has been run locally, and it passes.** The engine and automation tests timed out while compiling on this machine and have not been run, nor seen failing without the fix. The one `pdfcraft-ui-egui` call-site change has not been compiled. CI is the first place these build. - [x] `cargo fmt --check` - [ ] clippy with `-D warnings` on the crates I changed (not re-run after the rebase) - [ ] tests pass on the crates I changed (crypt only) ## Anything a reviewer should look at first - **Rebased onto #6309.** The tests no longer expect "Any except extracting pages" to allow assembly at revision 3+. The same-protection paste tests now use the "Inserting, deleting and rotating pages" preset without copying. - **Copy (bit 5) is now required**, as the issue suggests. `crates/ui-egui/tests/it/editing.rs::protected_files_that_can_be_combined_are_flagged` used a copy-withheld file as "protected but combinable"; it now uses a print-withheld file, since a no-copy file is no longer a Combine source without the permissions password. - **Same-document paste**: Paste re-opens the document's own bytes as an Insert source, which the new check would refuse. `sources::open_source_into` accepts a source that withholds extraction but allows assembly only when the destination will be saved under exactly the same protection (file key and `/Encrypt` values), so cut/copy and paste within one protected document still works. Pasting into an unprotected or differently protected document is refused. One limit: after changing security in the same session without saving, a cut can't be pasted back until you save. - **Not in this PR:** CodeRabbit's `disabled_reason` suggestion for read-only documents predates this change and covers other commands too, so it belongs in a separate UI change. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> <!-- coderabbit-ai-generated:v1 --> ## Summary by CodeRabbit - **New Features** - Page extraction and splitting now follow a dedicated permission check, distinct from page assembly. - Pages can be inserted from certain protected PDFs when their protection settings match the destination document. - **Bug Fixes** - Separate-page extraction now cleans up files created during a failed operation and avoids creating an output directory when the first page cannot be extracted. - Security details now report whether page extraction is permitted. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -239,6 +239,8 @@ impl Automation {
|
||||
Ok(json!({ "files": out }))
|
||||
}
|
||||
|
||||
/// `page_extract`: the chosen pages as one new PDF, or with `separate` one file per page in
|
||||
/// `out_dir`; with `delete` the pages are then removed from the original.
|
||||
pub(crate) fn page_extract(&mut self, a: &Args) -> Result<Value> {
|
||||
let doc = self.doc(a)?;
|
||||
let (id, name) = (doc.id, format!("{} (extract)", doc.name));
|
||||
@@ -246,16 +248,8 @@ impl Automation {
|
||||
let pages = self.pages(a, "pages")?;
|
||||
let mut out = if a.opt_bool("separate")?.unwrap_or(false) {
|
||||
// Each page as its own file.
|
||||
let dir = self.resolve(a.str("out_dir").map_err(|_| ToolError::InvalidArgs("separate files need out_dir".into()))?, true)?;
|
||||
std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?;
|
||||
let mut files = Vec::new();
|
||||
for &p in &pages {
|
||||
let bytes = self.session.extract(id, &[p]).map_err(failed)?;
|
||||
let path = child(&dir, &format!("{stem}-page{}.pdf", p + 1));
|
||||
write_atomic(&path, &bytes)?;
|
||||
files.push(path.to_string_lossy().into_owned());
|
||||
}
|
||||
json!({ "files": files })
|
||||
let out_dir = a.str("out_dir").map_err(|_| ToolError::InvalidArgs("separate files need out_dir".into()))?;
|
||||
json!({ "files": self.extract_separate(id, &pages, out_dir, &stem)? })
|
||||
} else {
|
||||
let bytes = self.session.extract(id, &pages).map_err(failed)?;
|
||||
self.deliver(a, &name, bytes)?
|
||||
@@ -267,6 +261,45 @@ impl Automation {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Each of `pages` as its own `<stem>-page<n>.pdf` in `out_dir`, extracted and written one at a
|
||||
/// time, so only one page's PDF is held at once. The first page is extracted before `out_dir` is
|
||||
/// resolved and made, so a refusal (the permissions refuse every page alike, #5081) leaves nothing
|
||||
/// behind; if a later page fails, the files this call created, and the directory if it made it,
|
||||
/// are removed.
|
||||
fn extract_separate(&self, id: pdfcraft_engine::DocId, pages: &[usize], out_dir: &str, stem: &str) -> Result<Vec<String>> {
|
||||
let Some(&first) = pages.first() else { return Ok(Vec::new()) };
|
||||
let mut first_bytes = Some(self.session.extract(id, &[first]).map_err(failed)?);
|
||||
let dir = self.resolve(out_dir, true)?;
|
||||
let made_dir = !dir.exists();
|
||||
std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?;
|
||||
let (mut files, mut created) = (Vec::new(), Vec::new());
|
||||
let outcome = pages.iter().try_for_each(|&p| -> Result<()> {
|
||||
let bytes = match first_bytes.take() {
|
||||
Some(bytes) => bytes,
|
||||
None => self.session.extract(id, &[p]).map_err(failed)?,
|
||||
};
|
||||
let path = child(&dir, &format!("{stem}-page{}.pdf", p.saturating_add(1)));
|
||||
let existed = path.exists();
|
||||
write_atomic(&path, &bytes)?;
|
||||
if !existed {
|
||||
created.push(path.clone());
|
||||
}
|
||||
files.push(path.to_string_lossy().into_owned());
|
||||
Ok(())
|
||||
});
|
||||
if let Err(e) = outcome {
|
||||
// Best effort: whatever can't be removed stays, and the error reported is the extraction's.
|
||||
for path in &created {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
if made_dir {
|
||||
let _ = std::fs::remove_dir(&dir);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
pub(crate) fn doc_combine(&mut self, a: &Args) -> Result<Value> {
|
||||
let paths = a.strs("paths")?;
|
||||
if paths.len() < 2 {
|
||||
|
||||
@@ -256,7 +256,7 @@ pub(crate) fn security(d: &Document) -> Value {
|
||||
"method": s.method,
|
||||
"pending": s.pending,
|
||||
"printing": if !p.print() { "none" } else if p.print_high_quality() { "high" } else { "low" },
|
||||
"modify": p.modify(), "assemble": p.assemble(), "copy": p.copy(), "annotate": p.annotate(),
|
||||
"modify": p.modify(), "assemble": p.assemble(), "extract_pages": p.extract_pages(), "copy": p.copy(), "annotate": p.annotate(),
|
||||
"fill_forms": p.fill_forms(), "accessibility": p.extract_for_accessibility(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3040,6 +3040,8 @@ fn replace_text_lists_its_replacement() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Protect through the tools, then reopen: the open password is required, the restrictions
|
||||
/// (including page extraction) hold for the user, and the permissions password lifts them.
|
||||
#[test]
|
||||
fn protecting_through_tools() {
|
||||
let dir = workdir("protect");
|
||||
@@ -3057,12 +3059,14 @@ fn protecting_through_tools() {
|
||||
(r["security"]["printing"].as_str(), r["security"]["annotate"].as_bool(), r["security"]["copy"].as_bool()),
|
||||
(Some("low"), Some(true), Some(false))
|
||||
);
|
||||
assert_eq!(r["security"]["extract_pages"], false);
|
||||
assert!(!r.to_string().contains("boss"), "passwords are never echoed");
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "locked.pdf" }));
|
||||
// Another session: the open password is required, and the restrictions hold.
|
||||
let mut b = auto(&dir);
|
||||
assert!(matches!(b.call("doc_open", &json!({ "path": "locked.pdf" })), Err(ToolError::Failed(_))));
|
||||
let re = ok(&mut b, "doc_open", json!({ "path": "locked.pdf", "password": "open" }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(ok(&mut b, "doc_info", json!({ "doc": re }))["security"]["extract_pages"], false);
|
||||
assert!(matches!(b.call("page_delete", &json!({ "doc": re, "pages": [1] })), Err(ToolError::Failed(_))));
|
||||
assert!(matches!(b.call("doc_unprotect", &json!({ "doc": re })), Err(ToolError::Failed(_))));
|
||||
ok(&mut b, "comment_add", json!({ "doc": re, "page": 1, "type": "note", "at": [10, 10], "contents": "allowed" }));
|
||||
@@ -3086,6 +3090,7 @@ fn protecting_through_tools() {
|
||||
assert_eq!(ok(&mut b, "text_find", json!({ "doc": re, "query": "Page" }))["count"], 3);
|
||||
// With the permissions password everything is possible, including removing security.
|
||||
let owner = ok(&mut b, "doc_open", json!({ "path": "locked.pdf", "password": "boss" }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(ok(&mut b, "doc_info", json!({ "doc": owner }))["security"]["extract_pages"], true);
|
||||
assert!(ok(&mut b, "text_extract", json!({ "doc": owner }))["pages"][0]["text"].as_str().is_some_and(|t| t.contains("Page")));
|
||||
assert_eq!(ok(&mut b, "doc_unprotect", json!({ "doc": owner }))["security"]["protected"], false);
|
||||
ok(&mut b, "doc_save", json!({ "doc": owner, "path": "open.pdf" }));
|
||||
@@ -3093,6 +3098,45 @@ fn protecting_through_tools() {
|
||||
ok(&mut c, "doc_open", json!({ "path": "open.pdf" }));
|
||||
}
|
||||
|
||||
/// A refused `doc_split` or separate-file `page_extract` leaves no output directory behind
|
||||
/// (#5081), whether the protection allows no page changes ("Any except extracting pages") or
|
||||
/// page changes without copying; with copying allowed as well, the user may extract.
|
||||
#[test]
|
||||
fn refused_extraction_writes_no_split_or_page_output() {
|
||||
let dir = workdir("refused-extraction-output");
|
||||
// (changes, copy, assemble, extract_pages) as the user sees them.
|
||||
let cases = [("any-except-extract", false, false, false), ("pages", false, true, false), ("pages", true, true, true)];
|
||||
for (i, (changes, copy, assemble, extract)) in cases.into_iter().enumerate() {
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
let protect = json!({ "doc": doc, "open_password": "open", "permissions_password": "boss", "changes": changes, "copy": copy });
|
||||
ok(&mut a, "doc_protect", protect);
|
||||
let path = format!("restricted-{i}.pdf");
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": path }));
|
||||
|
||||
let mut b = auto(&dir);
|
||||
let restricted = ok(&mut b, "doc_open", json!({ "path": path, "password": "open" }))["doc"].as_u64().unwrap();
|
||||
let security = ok(&mut b, "doc_info", json!({ "doc": restricted }))["security"].clone();
|
||||
assert_eq!((security["assemble"].as_bool(), security["extract_pages"].as_bool()), (Some(assemble), Some(extract)), "{changes}, copy {copy}");
|
||||
let (split_dir, extract_dir) = (format!("split-{i}"), format!("extract-{i}"));
|
||||
let split = b.call("doc_split", &json!({ "doc": restricted, "every": 1, "out_dir": split_dir }));
|
||||
let pages = b.call("page_extract", &json!({ "doc": restricted, "pages": [1, 2], "separate": true, "out_dir": extract_dir }));
|
||||
if extract {
|
||||
assert!(split.is_ok() && dir.join(&split_dir).exists(), "{changes}, copy {copy}");
|
||||
let files = match pages.as_deref() {
|
||||
Ok([Content::Json(v), ..]) => v["files"].as_array().map(Vec::len),
|
||||
other => panic!("{changes}, copy {copy}: page_extract: {other:?}"),
|
||||
};
|
||||
assert_eq!(files, Some(2), "{changes}, copy {copy}");
|
||||
} else {
|
||||
assert!(matches!(split, Err(ToolError::Failed(_))), "{changes}, copy {copy}");
|
||||
assert!(!dir.join(&split_dir).exists(), "refused split creates no output directory");
|
||||
assert!(matches!(pages, Err(ToolError::Failed(_))), "{changes}, copy {copy}");
|
||||
assert!(!dir.join(&extract_dir).exists(), "refused extraction creates no output directory");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A document that allows changes but not copying: the tools that return a page's text refuse,
|
||||
/// or leave the text out where the tool also changes the page (a restyled paragraph, recognized
|
||||
/// words).
|
||||
|
||||
@@ -127,6 +127,13 @@ impl Permissions {
|
||||
pub fn assemble(&self) -> bool {
|
||||
if self.is_revision_3_or_later() { self.bit(11) } else { self.bit(4) }
|
||||
}
|
||||
/// Take pages out into another document (Extract, Split, a source of Combine or Insert
|
||||
/// Pages): [`Self::assemble`] and copying (bit 5), since the pages carry their text and
|
||||
/// graphics into a new file without the document's protection (#5081). "Any except
|
||||
/// extracting pages" sets bit 4 but not bit 11, so at revision 3 and later it grants neither.
|
||||
pub fn extract_pages(&self) -> bool {
|
||||
self.assemble() && self.copy()
|
||||
}
|
||||
/// Revision 3 and later need bit 3 as well as bit 12. Table 22 defines bit 12 for those
|
||||
/// revisions only: at revision 2 the print flag (bit 3) is all there is.
|
||||
pub fn print_high_quality(&self) -> bool {
|
||||
|
||||
@@ -248,6 +248,23 @@ fn permission_bits_follow_table_22() {
|
||||
assert!(p.modify(), "owners ignore /P");
|
||||
}
|
||||
|
||||
/// Page extraction (#5081) is assembly as the revision reads it, and copying (bit 5).
|
||||
#[test]
|
||||
fn page_extraction_needs_assembly_and_copying() {
|
||||
let bit = |n: u32| 1i32 << (n - 1);
|
||||
let user = |bits: i32, revision: i64| Permissions { bits, owner: false, revision };
|
||||
// "Any except extracting pages" (bits 4, 6, 9; copying off) at revision 3 and later.
|
||||
assert!(!user(bit(4) | bit(6) | bit(9), 4).extract_pages());
|
||||
// Pages may change in place, but copying is withheld.
|
||||
assert!(user(bit(11), 6).assemble() && !user(bit(11), 6).extract_pages());
|
||||
assert!(user(bit(11) | bit(5), 6).extract_pages());
|
||||
// Revision 2: bit 4 covers assembly, and its reserved bit 11 grants nothing.
|
||||
assert!(user(bit(4), 2).assemble() && !user(bit(4) | bit(11), 2).extract_pages(), "copying withheld");
|
||||
assert!(user(bit(4) | bit(5), 2).extract_pages());
|
||||
assert!(!user(bit(5) | bit(11), 2).extract_pages(), "no assembly without bit 4 at revision 2");
|
||||
assert!(Permissions { bits: 0, owner: true, revision: 2 }.extract_pages(), "owners ignore /P");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perms_limits_what_an_edited_p_grants() {
|
||||
let made = new(Algorithm::Aes256, "user pw", "owner pw", 0b0100); // print only
|
||||
|
||||
@@ -18,8 +18,10 @@ pub enum Needs {
|
||||
Nothing,
|
||||
/// A document is open.
|
||||
Document,
|
||||
/// The open document's security allows page changes (insert, delete, rotate, extract…).
|
||||
/// The open document's security allows page changes (insert, delete, rotate…).
|
||||
Assembly,
|
||||
/// The open document's security allows taking pages out into new files (extract, split).
|
||||
PageExtraction,
|
||||
/// The open document's security allows content and metadata changes.
|
||||
Modification,
|
||||
/// The open document's security allows adding and changing comments.
|
||||
@@ -416,8 +418,8 @@ pub const COMMANDS: &[CommandSpec] = &[
|
||||
c("page.boxes", "Set page boxes…", PAGES, None, Assembly, "square-dashed-mouse-pointer"),
|
||||
c("page.insert", "Insert pages from file…", PAGES, None, Assembly, "file-input"),
|
||||
c("page.replace", "Replace pages…", PAGES, None, Assembly, "replace"),
|
||||
c("page.extract", "Extract pages…", PAGES, None, Assembly, "file-output"),
|
||||
c("page.split", "Split document…", PAGES, None, Assembly, "scissors"),
|
||||
c("page.extract", "Extract pages…", PAGES, None, PageExtraction, "file-output"),
|
||||
c("page.split", "Split document…", PAGES, None, PageExtraction, "scissors"),
|
||||
c("page.number", "Number pages…", PAGES, None, Assembly, "hash"),
|
||||
c("prepress.marks", "Add printer marks…", None, None, Modification, "ruler"),
|
||||
c("prepress.marks.remove", "Remove printer marks", None, None, Marks(crate::MarkKind::PrinterMarks), "ruler"),
|
||||
@@ -449,6 +451,7 @@ pub fn is_enabled(spec: &CommandSpec, session: &Session, active: Option<DocId>)
|
||||
Nothing => true,
|
||||
Document | TwoPageView => doc.is_some(),
|
||||
Assembly => doc.is_some_and(|d| d.allows_assembly()),
|
||||
PageExtraction => doc.is_some_and(|d| d.allows_page_extraction()),
|
||||
Modification => doc.is_some_and(|d| d.allows_modification()),
|
||||
Annotate => doc.is_some_and(|d| d.allows_annotation()),
|
||||
Copying => doc.is_some_and(|d| d.allows_copying()),
|
||||
|
||||
@@ -554,11 +554,17 @@ impl Document {
|
||||
if self.allows_copying() { Ok(()) } else { Err(EditError::NotPermitted("copying text and images")) }
|
||||
}
|
||||
|
||||
/// Page changes (insert, delete, rotate, move, extract) are allowed.
|
||||
/// Page changes (insert, delete, rotate, move) are allowed.
|
||||
pub fn allows_assembly(&self) -> bool {
|
||||
self.editable() && self.permissions().is_none_or(|p| p.assemble())
|
||||
}
|
||||
|
||||
/// Pages may be taken out into new files (Extract, Split): the page changes above and copying,
|
||||
/// since the new files don't keep the document's protection (#5081).
|
||||
pub fn allows_page_extraction(&self) -> bool {
|
||||
self.editable() && self.permissions().is_none_or(|p| p.extract_pages())
|
||||
}
|
||||
|
||||
/// Redaction marks waiting to be applied.
|
||||
pub fn redaction_marks(&self) -> usize {
|
||||
self.info.annotations.iter().filter(|a| a.subtype == "Redact").count()
|
||||
|
||||
@@ -6,8 +6,8 @@ use crate::{
|
||||
AddedContent, AddedText, Background, BorderShape, BoxSpec, EditError, FieldProps, FieldValue, FillMark, Hairlines, HeaderFooter, Hidden,
|
||||
ImageEdit, InitialView, Label, LineEnding, LinkAction, LinkStyle, MarkFile, MarkKind, Markup, NewAnnotation, NewField, NoteIcon, ObjectTarget,
|
||||
PageBox, PrinterMarks, Protection, ReviewState, Rgb, Shape, Standard, Style, TabOrder, TransitionSpec, Watermark, a11y, copy_image,
|
||||
decode_data_file, embed_image, field_table, fit_picture, image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster,
|
||||
open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change,
|
||||
decode_data_file, embed_image, field_table, fit_picture, image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, strip_xfa,
|
||||
unrestricted, xfa, xfa_after_data_change,
|
||||
};
|
||||
|
||||
mod labels;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::{
|
||||
AddedContent, Arc, Edit, EditError, FieldValue, ImageEdit, NewAnnotation, Shape, a11y, copy_image, decode_data_file, embed_image, fit_picture,
|
||||
image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change,
|
||||
image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, strip_xfa, unrestricted, xfa, xfa_after_data_change,
|
||||
};
|
||||
use crate::SourceProblem;
|
||||
use crate::sources::{comment_source, import_pdf_comments};
|
||||
@@ -531,13 +531,13 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
|
||||
Edit::SetInfo { key, value } => pdfcraft_organize::set_info(doc, key, value)?,
|
||||
Edit::DuplicatePages { pages, at } => pdfcraft_organize::duplicate_pages(doc, pages, *at)?,
|
||||
Edit::ReplacePages { pages, name, bytes, src_pages } => {
|
||||
let src = open_source(name, bytes)?;
|
||||
let src = crate::sources::open_source_into(name, bytes, doc)?;
|
||||
pdfcraft_organize::replace_pages(doc, pages, &src, src_pages)?;
|
||||
}
|
||||
Edit::SetPageBox { pages, which, spec } => pdfcraft_organize::set_page_box(doc, pages, *which, *spec)?,
|
||||
Edit::SetTransition { pages, spec } => pdfcraft_organize::set_transition(doc, pages, spec)?,
|
||||
Edit::InsertPagesFrom { name, bytes, pages, at, field_conflicts } => {
|
||||
let src = open_source(name, bytes)?;
|
||||
let src = crate::sources::open_source_into(name, bytes, doc)?;
|
||||
let pages = match pages {
|
||||
Some(p) => p.clone(),
|
||||
None => (0..pdfcraft_organize::page_count(&src)?).collect(),
|
||||
|
||||
@@ -58,6 +58,26 @@ impl Session {
|
||||
Ok((kind, created?))
|
||||
}
|
||||
|
||||
/// A file for Insert Pages into `dest`, as PDF bytes and its page count: an image or text
|
||||
/// file is converted, and a PDF is checked as the insert itself checks it, so a file under
|
||||
/// `dest`'s own protection that withholds only page extraction is accepted, as pasted pages
|
||||
/// are (#5081).
|
||||
pub fn insert_source(&self, dest: DocId, name: &str, bytes: &Arc<Vec<u8>>) -> Result<(usize, Arc<Vec<u8>>), EditError> {
|
||||
if !matches!(source_kind(name, bytes), Some(SourceKind::Pdf)) {
|
||||
let (_, pdf) = self.convert_to_pdf(name, bytes)?;
|
||||
return Ok((self.page_count_of(name, &pdf)?, pdf));
|
||||
}
|
||||
let src = crate::sources::open_source_into(name, bytes, self.cos(dest)?)?;
|
||||
Ok((pdfcraft_organize::page_count(&src)?, bytes.clone()))
|
||||
}
|
||||
|
||||
/// The page count of a PDF for Replace Pages in `dest`, checked as the replace itself checks
|
||||
/// it: as with [`Self::insert_source`], a file under `dest`'s own protection that withholds
|
||||
/// only page extraction is accepted (#5081).
|
||||
pub fn replace_source(&self, dest: DocId, name: &str, bytes: &Arc<Vec<u8>>) -> Result<usize, EditError> {
|
||||
Ok(pdfcraft_organize::page_count(&crate::sources::open_source_into(name, bytes, self.cos(dest)?)?)?)
|
||||
}
|
||||
|
||||
/// A file for Combine as PDF bytes: an image or text file is converted; a PDF is returned as
|
||||
/// it is, unchecked (Combine reads it itself, with its password), as is a file named .pdf
|
||||
/// without a `%PDF-` header, which the reader may still open.
|
||||
@@ -397,7 +417,7 @@ impl Session {
|
||||
/// New PDF bytes containing copies of `pages` of the document (Extract Pages).
|
||||
pub fn extract(&self, id: DocId, pages: &[usize]) -> Result<Arc<Vec<u8>>, EditError> {
|
||||
let src = self.cos(id)?;
|
||||
if src.permissions().is_some_and(|p| !p.assemble()) {
|
||||
if src.permissions().is_some_and(|p| !p.extract_pages()) {
|
||||
return Err(EditError::NotPermitted("extracting pages"));
|
||||
}
|
||||
let out = pdfcraft_organize::extract_pages(src, pages)?;
|
||||
@@ -407,7 +427,7 @@ impl Session {
|
||||
/// Split the document into several new PDFs.
|
||||
pub fn split(&self, id: DocId, by: &pdfcraft_organize::SplitBy) -> Result<Vec<SplitPart>, EditError> {
|
||||
let src = self.cos(id)?;
|
||||
if src.permissions().is_some_and(|p| !p.assemble()) {
|
||||
if src.permissions().is_some_and(|p| !p.extract_pages()) {
|
||||
return Err(EditError::NotPermitted("splitting the document"));
|
||||
}
|
||||
let n = pdfcraft_organize::page_count(src)?;
|
||||
@@ -425,7 +445,7 @@ impl Session {
|
||||
/// images count once per part) fits; a page larger than the limit is a part of its own.
|
||||
pub fn split_by_size(&self, id: DocId, max_bytes: usize) -> Result<Vec<SplitPart>, EditError> {
|
||||
let src = self.cos(id)?;
|
||||
if src.permissions().is_some_and(|p| !p.assemble()) {
|
||||
if src.permissions().is_some_and(|p| !p.extract_pages()) {
|
||||
return Err(EditError::NotPermitted("splitting the document"));
|
||||
}
|
||||
let n = pdfcraft_organize::page_count(src)?;
|
||||
|
||||
@@ -25,6 +25,19 @@ pub(crate) fn open_source_with(name: &str, bytes: &Arc<Vec<u8>>, password: Optio
|
||||
source_document(bytes, password).map_err(|p| EditError::Source(format!("{name}: {p}")))
|
||||
}
|
||||
|
||||
/// [`open_source`] for pages inserted into `dest`. A source that withholds page extraction but
|
||||
/// allows assembly is accepted when `dest` will be saved under the very same protection (file
|
||||
/// key and `/Encrypt` values), as when pages cut or copied from a document are pasted back into
|
||||
/// it: they never leave its protection (#5081).
|
||||
pub(crate) fn open_source_into(name: &str, bytes: &Arc<Vec<u8>>, dest: &pdfcraft_cos::Document) -> Result<pdfcraft_cos::Document, EditError> {
|
||||
let same_protection = |src: &pdfcraft_cos::Document| match (src.security(), dest.output_handler()) {
|
||||
(Some(s), Some(d)) => s.file_key() == d.file_key() && s.dict() == d.dict(),
|
||||
_ => false,
|
||||
};
|
||||
let permitted = |src: &pdfcraft_cos::Document| src.permissions().is_none_or(|p| p.extract_pages() || (p.assemble() && same_protection(src)));
|
||||
source_document_if(bytes, None, permitted).map_err(|p| EditError::Source(format!("{name}: {p}")))
|
||||
}
|
||||
|
||||
/// Import the comments of `src` onto the pages of `doc` with the same numbers.
|
||||
pub(crate) fn import_pdf_comments(doc: &mut pdfcraft_cos::Document, src: &pdfcraft_cos::Document) -> Result<(), EditError> {
|
||||
if pdfcraft_organize::import_comments(doc, src)? == 0 {
|
||||
@@ -44,8 +57,17 @@ pub(crate) fn comment_source(bytes: &Arc<Vec<u8>>) -> Result<pdfcraft_cos::Docum
|
||||
}
|
||||
|
||||
fn source_document(bytes: &Arc<Vec<u8>>, password: Option<&str>) -> Result<pdfcraft_cos::Document, SourceProblem> {
|
||||
source_document_if(bytes, password, |d| d.permissions().is_none_or(|p| p.extract_pages()))
|
||||
}
|
||||
|
||||
/// Open `bytes` as a page source, refused as [`SourceProblem::NotPermitted`] unless `permitted`.
|
||||
fn source_document_if(
|
||||
bytes: &Arc<Vec<u8>>,
|
||||
password: Option<&str>,
|
||||
permitted: impl Fn(&pdfcraft_cos::Document) -> bool,
|
||||
) -> Result<pdfcraft_cos::Document, SourceProblem> {
|
||||
match std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)) {
|
||||
Ok(Ok(d)) if d.permissions().is_some_and(|p| !p.assemble()) => Err(SourceProblem::NotPermitted),
|
||||
Ok(Ok(d)) if !permitted(&d) => Err(SourceProblem::NotPermitted),
|
||||
Ok(Ok(d)) => Ok(d),
|
||||
Ok(Err(pdfcraft_cos::CosError::NeedsPassword)) => Err(SourceProblem::Password),
|
||||
Ok(Err(pdfcraft_cos::CosError::WrongPassword)) => Err(SourceProblem::WrongPassword),
|
||||
|
||||
@@ -1615,6 +1615,206 @@ fn protected(user: &str, owner: &str, permissions: i32) -> Arc<Vec<u8>> {
|
||||
Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap())
|
||||
}
|
||||
|
||||
/// Two pages protected with `algorithm` under the permissions password "boss" (no open
|
||||
/// password), restricting changes to `changes`.
|
||||
fn protected_with_changes(algorithm: pdfcraft_cos::Algorithm, changes: Changes) -> Arc<Vec<u8>> {
|
||||
protected_pages(2, algorithm, changes, 8)
|
||||
}
|
||||
|
||||
/// [`protected_with_changes`] with `pages` pages; `seed` gives the file key and salts, so two
|
||||
/// files with the same arguments but `pages` share their protection exactly.
|
||||
fn protected_pages(pages: usize, algorithm: pdfcraft_cos::Algorithm, changes: Changes, seed: u8) -> Arc<Vec<u8>> {
|
||||
let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(pages))).unwrap();
|
||||
let protection = Protection { permissions_password: Some("boss".into()), changes, algorithm, ..Protection::default() };
|
||||
doc.set_encryption(&pdfcraft_cos::NewEncryption {
|
||||
algorithm,
|
||||
user_password: "",
|
||||
owner_password: "boss",
|
||||
permissions: protection.permission_bits(),
|
||||
encrypt_metadata: true,
|
||||
seed: [seed; 32],
|
||||
})
|
||||
.unwrap();
|
||||
Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap())
|
||||
}
|
||||
|
||||
/// "Any except extracting pages" (#5081) withholds page extraction on every handler revision. At
|
||||
/// revision 3 and later the preset (bit 4 without bit 11) allows no page changes either; at
|
||||
/// revision 2, where bit 4 covers assembly, extraction, splitting and being a page source are
|
||||
/// still refused because copying is withheld.
|
||||
#[test]
|
||||
fn extraction_permissions_refuse_any_except_extract_on_every_revision() {
|
||||
use pdfcraft_cos::Algorithm::{Aes128, Aes256, Rc4_40, Rc4_128};
|
||||
|
||||
for algorithm in [Rc4_128, Aes128, Aes256] {
|
||||
let bytes = protected_with_changes(algorithm, Changes::AnyExceptExtract);
|
||||
let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), None).unwrap();
|
||||
let permissions = doc.permissions().unwrap();
|
||||
assert!(!permissions.extract_pages(), "{algorithm:?}, /P={:#x}", permissions.bits);
|
||||
assert!(!permissions.assemble(), "{algorithm:?}: bit 4 without bit 11 allows no page changes");
|
||||
}
|
||||
let r2 = protected_with_changes(Rc4_40, Changes::AnyExceptExtract);
|
||||
let mut session = Session::new();
|
||||
let r2_id = session.open("r2.pdf", None, r2.clone(), None).unwrap();
|
||||
let permissions = session.get(r2_id).unwrap().permissions().unwrap();
|
||||
assert!(permissions.assemble() && !permissions.extract_pages(), "revision 2, /P={:#x}", permissions.bits);
|
||||
assert_eq!(session.extract(r2_id, &[0]), Err(EditError::NotPermitted("extracting pages")));
|
||||
assert_eq!(session.split(r2_id, &pdfcraft_organize::SplitBy::PageCount(1)), Err(EditError::NotPermitted("splitting the document")));
|
||||
assert_eq!(crate::combine_source_check(&r2, None), Err(crate::SourceProblem::NotPermitted));
|
||||
}
|
||||
|
||||
/// "Any except extracting pages" (#5081): Extract, Split and Combine refuse the file without the
|
||||
/// permissions password and leave it unchanged; Combine takes it with that password.
|
||||
#[test]
|
||||
fn extraction_permissions_cover_revision_three_to_six_and_leave_no_partial_output() {
|
||||
let bytes = protected_with_changes(pdfcraft_cos::Algorithm::Aes256, Changes::AnyExceptExtract);
|
||||
assert_eq!(crate::combine_source_check(&bytes, None), Err(crate::SourceProblem::NotPermitted));
|
||||
assert_eq!(crate::combine_source_check(&bytes, Some("boss")), Ok(()));
|
||||
let mut restricted = Session::new();
|
||||
let id = restricted.open("restricted.pdf", None, bytes.clone(), None).unwrap();
|
||||
let before = restricted.get(id).unwrap().bytes.clone();
|
||||
assert_eq!(restricted.extract(id, &[0]), Err(EditError::NotPermitted("extracting pages")));
|
||||
assert_eq!(restricted.split(id, &pdfcraft_organize::SplitBy::PageCount(1)), Err(EditError::NotPermitted("splitting the document")));
|
||||
assert_eq!(restricted.split_by_size(id, 1), Err(EditError::NotPermitted("splitting the document")));
|
||||
let doc = restricted.get(id).unwrap();
|
||||
assert_eq!(doc.info.pages.len(), 2);
|
||||
assert!(!doc.dirty);
|
||||
assert_eq!(doc.bytes, before, "a refused extraction leaves the source untouched");
|
||||
|
||||
let sources = vec![("restricted.pdf".into(), bytes.clone(), None)];
|
||||
assert!(matches!(restricted.combine_unlocked(&sources, &[None]), Err(EditError::Source(_))));
|
||||
let combined = restricted.combine_unlocked(&sources, &[Some("boss")]).unwrap();
|
||||
assert_eq!(pdfcraft_organize::page_count(&pdfcraft_cos::Document::open(combined).unwrap()).unwrap(), 2);
|
||||
}
|
||||
|
||||
/// A file under "Any except extracting pages" (#5081) is refused as an Insert Pages or Replace
|
||||
/// Pages source for an unprotected document, which is left as it was, with nothing to undo.
|
||||
#[test]
|
||||
fn extraction_permissions_refuse_a_restricted_page_source() {
|
||||
let bytes = protected_with_changes(pdfcraft_cos::Algorithm::Aes256, Changes::AnyExceptExtract);
|
||||
let plain = Arc::new(fixture(1));
|
||||
let mut destination = Session::new();
|
||||
let dest = destination.open("destination.pdf", None, plain.clone(), None).unwrap();
|
||||
let insert = Edit::InsertPagesFrom {
|
||||
name: "restricted.pdf".into(),
|
||||
bytes: bytes.clone(),
|
||||
pages: None,
|
||||
at: 1,
|
||||
field_conflicts: pdfcraft_organize::FieldConflicts::Rename,
|
||||
};
|
||||
assert!(matches!(destination.apply(dest, insert), Err(EditError::Source(_))));
|
||||
assert_eq!(destination.get(dest).unwrap().bytes, plain, "a refused insert leaves the destination untouched");
|
||||
assert_eq!(destination.get(dest).unwrap().info.pages.len(), 1);
|
||||
assert_eq!(destination.get(dest).unwrap().can_undo(), None);
|
||||
assert!(matches!(
|
||||
destination.apply(dest, Edit::ReplacePages { pages: vec![0], name: "restricted.pdf".into(), bytes: bytes.clone(), src_pages: vec![0] }),
|
||||
Err(EditError::Source(_))
|
||||
));
|
||||
assert_eq!(destination.get(dest).unwrap().bytes, plain, "a refused replace leaves the destination untouched");
|
||||
}
|
||||
|
||||
/// Opened with the permissions password, a file under "Any except extracting pages" (#5081) can
|
||||
/// be extracted from and split.
|
||||
#[test]
|
||||
fn extraction_permissions_yield_to_the_owner_password() {
|
||||
let bytes = protected_with_changes(pdfcraft_cos::Algorithm::Aes256, Changes::AnyExceptExtract);
|
||||
let mut owner = Session::new();
|
||||
let owner_id = owner.open("restricted.pdf", None, bytes, Some("boss")).unwrap();
|
||||
assert!(owner.get(owner_id).unwrap().permissions().unwrap().extract_pages());
|
||||
assert!(owner.extract(owner_id, &[0]).is_ok());
|
||||
assert_eq!(owner.split(owner_id, &pdfcraft_organize::SplitBy::PageCount(1)).unwrap().len(), 2);
|
||||
assert_eq!(owner.split_by_size(owner_id, 1).unwrap().len(), 2);
|
||||
}
|
||||
|
||||
/// A document that allows page changes but not copying, so not page extraction (#5081), takes
|
||||
/// pages back from a file under exactly its own protection (a cut and paste, or another file
|
||||
/// made with the same key and `/Encrypt` values), through Insert Pages' file check as well, and
|
||||
/// through Replace Pages and its check, and keeps the restriction; the same file is refused as a
|
||||
/// source for an unprotected or a differently protected document.
|
||||
#[test]
|
||||
fn pages_under_the_same_protection_are_inserted_without_page_extraction() {
|
||||
use pdfcraft_cos::Algorithm::Aes256;
|
||||
|
||||
let bytes = protected_pages(2, Aes256, Changes::Pages, 8);
|
||||
let mut same = Session::new();
|
||||
let same_id = same.open("same.pdf", None, bytes.clone(), None).unwrap();
|
||||
let permissions = same.get(same_id).unwrap().permissions().unwrap();
|
||||
assert!(permissions.assemble() && !permissions.extract_pages(), "/P={:#x}", permissions.bits);
|
||||
let insert = |name: &str, bytes: &Arc<Vec<u8>>, at| Edit::InsertPagesFrom {
|
||||
name: name.into(),
|
||||
bytes: bytes.clone(),
|
||||
pages: None,
|
||||
at,
|
||||
field_conflicts: pdfcraft_organize::FieldConflicts::Rename,
|
||||
};
|
||||
// Its own bytes, as Paste inserts them after a cut or copy.
|
||||
same.apply(same_id, insert("same.pdf", &bytes, 2)).unwrap();
|
||||
assert_eq!(same.get(same_id).unwrap().info.pages.len(), 4);
|
||||
assert!(same.get(same_id).unwrap().dirty);
|
||||
|
||||
// A separate file made with the same key and /Encrypt values, and other pages.
|
||||
let other = protected_pages(3, Aes256, Changes::Pages, 8);
|
||||
let (a, b) = (pdfcraft_cos::Document::open(bytes.clone()).unwrap(), pdfcraft_cos::Document::open(other.clone()).unwrap());
|
||||
let (a, b) = (a.security().unwrap(), b.security().unwrap());
|
||||
assert!(a.file_key() == b.file_key() && a.dict() == b.dict(), "the two files share their protection");
|
||||
assert!(matches!(same.page_count_of("other.pdf", &other), Err(EditError::Source(_))), "a plain page source must allow extraction");
|
||||
assert_eq!(same.insert_source(same_id, "other.pdf", &other).map(|(n, _)| n), Ok(3));
|
||||
same.apply(same_id, insert("other.pdf", &other, 4)).unwrap();
|
||||
// Replace Pages takes them on the same terms.
|
||||
let replace = |bytes: &Arc<Vec<u8>>| Edit::ReplacePages { pages: vec![0], name: "other.pdf".into(), bytes: bytes.clone(), src_pages: vec![2] };
|
||||
assert_eq!(same.replace_source(same_id, "other.pdf", &other), Ok(3));
|
||||
same.apply(same_id, replace(&other)).unwrap();
|
||||
assert_eq!(same.get(same_id).unwrap().info.pages.len(), 7);
|
||||
let saved = same.save_bytes(same_id).unwrap();
|
||||
let reopened = pdfcraft_cos::Document::open_with_password(saved, None).unwrap();
|
||||
assert_eq!(pdfcraft_organize::page_count(&reopened).unwrap(), 7);
|
||||
assert!(!reopened.permissions().unwrap().extract_pages(), "same-protection assembly keeps the extraction restriction");
|
||||
|
||||
// Neither an unprotected document nor one under other protection takes them.
|
||||
for destination in [Arc::new(fixture(1)), protected_pages(1, Aes256, Changes::Pages, 9)] {
|
||||
let mut session = Session::new();
|
||||
let dest = session.open("destination.pdf", None, destination, None).unwrap();
|
||||
let before = session.get(dest).unwrap().bytes.clone();
|
||||
assert!(matches!(session.insert_source(dest, "same.pdf", &bytes), Err(EditError::Source(_))));
|
||||
assert!(matches!(session.apply(dest, insert("same.pdf", &bytes, 1)), Err(EditError::Source(_))));
|
||||
assert!(matches!(session.replace_source(dest, "same.pdf", &bytes), Err(EditError::Source(_))));
|
||||
assert!(matches!(session.apply(dest, replace(&bytes)), Err(EditError::Source(_))));
|
||||
assert_eq!(session.get(dest).unwrap().bytes, before, "a refused insert or replace leaves the destination untouched");
|
||||
assert_eq!(session.get(dest).unwrap().info.pages.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Taking pages out also needs copying (bit 5, #5081): a document that withholds only copying
|
||||
/// refuses Extract, Split and being a page source, on a revision-2 handler too, whose reserved
|
||||
/// bit 11 is always set. The owner password still lifts it.
|
||||
#[test]
|
||||
fn page_extraction_needs_the_copy_permission_on_every_revision() {
|
||||
for algorithm in [pdfcraft_cos::Algorithm::Rc4_40, pdfcraft_cos::Algorithm::Aes256] {
|
||||
let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(2))).unwrap();
|
||||
doc.set_encryption(&pdfcraft_cos::NewEncryption {
|
||||
algorithm,
|
||||
user_password: "",
|
||||
owner_password: "boss",
|
||||
permissions: -1 ^ 0b1_0000, // everything but copying
|
||||
encrypt_metadata: true,
|
||||
seed: [9; 32],
|
||||
})
|
||||
.unwrap();
|
||||
let bytes = Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap());
|
||||
let mut s = Session::new();
|
||||
let id = s.open("no-copy.pdf", None, bytes.clone(), None).unwrap();
|
||||
let permissions = s.get(id).unwrap().permissions().unwrap();
|
||||
assert!(permissions.assemble() && !permissions.extract_pages(), "{algorithm:?}");
|
||||
assert!(s.get(id).unwrap().allows_assembly() && !s.get(id).unwrap().allows_page_extraction(), "{algorithm:?}");
|
||||
assert_eq!(s.extract(id, &[0]), Err(EditError::NotPermitted("extracting pages")), "{algorithm:?}");
|
||||
assert_eq!(s.split(id, &pdfcraft_organize::SplitBy::PageCount(1)), Err(EditError::NotPermitted("splitting the document")));
|
||||
assert_eq!(crate::combine_source_check(&bytes, None), Err(crate::SourceProblem::NotPermitted), "{algorithm:?}");
|
||||
assert_eq!(crate::combine_source_check(&bytes, Some("boss")), Ok(()), "{algorithm:?}");
|
||||
let owner = s.open("no-copy.pdf", None, bytes, Some("boss")).unwrap();
|
||||
assert!(s.extract(owner, &[0]).is_ok(), "{algorithm:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_documents_open_edit_and_save_encrypted() {
|
||||
let bytes = protected("pw", "owner", -1);
|
||||
|
||||
@@ -156,7 +156,9 @@ impl PdfCraftApp {
|
||||
}
|
||||
}
|
||||
commands::Needs::Unlock if self.active.is_some() => tl!("This document has no restrictions to lift").to_string(),
|
||||
commands::Needs::Assembly | commands::Needs::Modification | commands::Needs::Annotate if self.active.is_some() => {
|
||||
commands::Needs::Assembly | commands::Needs::PageExtraction | commands::Needs::Modification | commands::Needs::Annotate
|
||||
if self.active.is_some() =>
|
||||
{
|
||||
tl!("The document's security settings don't allow this change").to_string()
|
||||
}
|
||||
commands::Needs::Copying if self.active.is_some() => crate::copy_refused().to_string(),
|
||||
|
||||
@@ -619,9 +619,9 @@ impl PdfCraftApp {
|
||||
|
||||
/// Open the Replace Pages dialog for `bytes`, replacing the selection (or the current page).
|
||||
pub fn start_replace(&mut self, name: String, bytes: Vec<u8>) {
|
||||
let Some(i) = self.active else { return };
|
||||
let Some((i, id)) = self.active_ids() else { return };
|
||||
let bytes = Arc::new(bytes);
|
||||
let src_pages = match self.session.page_count_of(&name, &bytes) {
|
||||
let src_pages = match self.session.replace_source(id, &name, &bytes) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
self.notify_fmt("Couldn't use {name}: {e}", &[("name", &name), ("e", &e.to_string())]);
|
||||
@@ -666,9 +666,7 @@ impl PdfCraftApp {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let converted =
|
||||
self.session.convert_to_pdf(&name, &Arc::new(bytes)).and_then(|(_, pdf)| Ok((self.session.page_count_of(&name, &pdf)?, pdf)));
|
||||
match converted {
|
||||
match self.session.insert_source(id, &name, &Arc::new(bytes)) {
|
||||
Ok((pages, bytes)) => {
|
||||
if self.apply_edit(Edit::InsertPagesFrom {
|
||||
name,
|
||||
|
||||
@@ -1723,18 +1723,20 @@ fn undo_and_redo_restore_the_combine_list() {
|
||||
assert!(h.state().session.get(h.state().views[0].id).unwrap().can_undo().is_none());
|
||||
}
|
||||
|
||||
/// A protected file that still allows page extraction (only printing is withheld) is listed
|
||||
/// for Combine with a warning that the result won't keep its security, not as a problem.
|
||||
#[test]
|
||||
fn protected_files_that_can_be_combined_are_flagged() {
|
||||
let mut h = harness(1, |app| {
|
||||
app.set_option("combine-view", "list").unwrap();
|
||||
app.use_files(
|
||||
pdfcraft_ui_egui::FilePurpose::Combine,
|
||||
vec![("one.pdf".into(), fixture(1)), ("no-copy.pdf".into(), protected("", "owner", -1 ^ 16))],
|
||||
vec![("one.pdf".into(), fixture(1)), ("no-print.pdf".into(), protected("", "owner", -1 ^ 4))],
|
||||
);
|
||||
});
|
||||
h.run_steps(3);
|
||||
let app = h.state();
|
||||
assert!(app.combine_draft[1].problem.is_none(), "copying text is withheld, assembling pages isn't");
|
||||
assert!(app.combine_draft[1].problem.is_none(), "printing is withheld, extracting pages isn't");
|
||||
h.get_by_label("Protected: the combined file won't keep its security settings");
|
||||
assert!(!h.get_by_label("Combine").accesskit_node().is_disabled(), "a warning, not a blocker");
|
||||
}
|
||||
|
||||
@@ -3047,7 +3047,7 @@ status = "shipped"
|
||||
commands = ["page.combine"]
|
||||
tools = ["doc_combine"]
|
||||
evidence = ["crates/organize/src/tests.rs::combine_concatenates_with_a_bookmark_per_file", "crates/ui-egui/tests/it/editing.rs::combining_files_opens_a_new_unsaved_tab", "crates/ui-egui/tests/it/ui.rs::files_to_combine_from_the_operating_system", "apps/pdfcraft/src/single_instance.rs::a_second_launch_hands_its_files_to_the_first", "crates/ui-egui/tests/it/editing.rs::combine_files_opens_in_its_own_tab", "crates/ui-egui/tests/it/editing.rs::combine_lists_size_and_warns_before_combining", "crates/ui-egui/tests/it/editing.rs::a_password_protected_file_is_unlocked_with_its_password_and_combined", "crates/ui-egui/tests/it/editing.rs::a_folder_adds_its_pdfs_images_and_text_in_order_and_optionally_its_subfolders", "crates/ui-egui/tests/it/editing.rs::images_and_text_files_join_the_combine_list_as_pdfs", "crates/ui-egui/tests/it/editing.rs::files_that_cannot_be_read_are_listed_and_left_out_once_the_user_agrees", "crates/ui-egui/tests/it/ui.rs::pdfs_dropped_on_the_combine_tab_join_its_list", "crates/ui-egui/tests/it/ui.rs::files_dropped_on_the_combine_tab_join_the_end_of_its_list_in_the_order_dropped", "crates/ui-egui/tests/it/ui.rs::a_file_combine_does_not_take_is_left_out_of_a_drop_and_named", "crates/ui-egui/tests/it/ui.rs::a_pdf_dropped_on_a_document_opens_while_the_combine_tab_waits_behind_it", "crates/automation/tests/it/automation.rs::combine_extract_and_split", "crates/automation/tests/it/automation.rs::combine_opens_protected_files_with_their_passwords", "crates/automation/tests/it/automation.rs::combine_takes_images_and_text_files", "crates/engine/src/tests.rs::files_for_combine_are_made_into_pdfs_and_pdfs_pass_through", "crates/organize/src/tests.rs::combine_keeps_pdfx_when_every_source_agrees", "crates/organize/src/tests.rs::combine_claims_no_pdfx_when_the_sources_disagree"]
|
||||
notes = "Combine files is its own tab: a table of the files (name, pages, size, modified, warnings) with column sorting, resizing and moving; files, folders (with subfolders) and open documents can be added, or dropped on the tab (desktop and web): a drop joins the end of the list in the order dropped, as one undo step, not at the place under the pointer; files of a kind Combine does not take are left out and named. Dropped elsewhere, files open as before. File Explorer's \"Combine files in PdfCraft\" and `pdfcraft --combine files…` add files from outside, to the running PdfCraft's tab (#585). Dropping adds no engine behaviour, so it has no tool of its own (doc_combine takes the files). Images (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000) and .txt files become PDFs as they are added (#569; also in doc_combine). A file that can't be read stays in the list with why, and Combine asks before leaving it out; Office documents, HTML and other formats are not converted yet. Problems show before Combine (password, security settings that withhold page assembly, bad page ranges, duplicates); protected files are unlocked with their open or permissions password (also doc_combine passwords). The result is never encrypted, and is not yet offered for protection in the same step. A PDF/X result (output intent, GTS_PDFXVersion, Trapped, XMP pdfxid) only when every source declares the same version and output intent (#263). When they differ the result claims none, and nothing reports that yet; Acrobat behaviour not observed."
|
||||
notes = "Combine files is its own tab: a table of the files (name, pages, size, modified, warnings) with column sorting, resizing and moving; files, folders (with subfolders) and open documents can be added, or dropped on the tab (desktop and web): a drop joins the end of the list in the order dropped, as one undo step, not at the place under the pointer; files of a kind Combine does not take are left out and named. Dropped elsewhere, files open as before. File Explorer's \"Combine files in PdfCraft\" and `pdfcraft --combine files…` add files from outside, to the running PdfCraft's tab (#585). Dropping adds no engine behaviour, so it has no tool of its own (doc_combine takes the files). Images (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000) and .txt files become PDFs as they are added (#569; also in doc_combine). A file that can't be read stays in the list with why, and Combine asks before leaving it out; Office documents, HTML and other formats are not converted yet. Problems show before Combine (password, security settings that withhold page extraction, bad page ranges, duplicates); protected files are unlocked with their open or permissions password (also doc_combine passwords). The result is never encrypted, and is not yet offered for protection in the same step. A PDF/X result (output intent, GTS_PDFXVersion, Trapped, XMP pdfxid) only when every source declares the same version and output intent (#263). When they differ the result claims none, and nothing reports that yet; Acrobat behaviour not observed."
|
||||
|
||||
[[feature]]
|
||||
id = "organize.combine-bookmarks"
|
||||
|
||||
Reference in New Issue
Block a user