mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-12 05:42:52 +00:00
## 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 -->
489 lines
28 KiB
Rust
489 lines
28 KiB
Rust
use super::{
|
|
Arc, CombineSource, Combined, DataFormat, DocId, EditError, Hairlines, HairlinesFixed, Hidden, ImageResolution, LOW_RESOLUTION_PRINT_DPI,
|
|
OptimizeReport, SaveOptions, Session, SourceKind, SplitPart, decode_text, guard, open_source, open_source_with, optimize, print,
|
|
print_layer_states, print_raster, set_default_layer_state, source_kind, writable, write_full,
|
|
};
|
|
|
|
impl Session {
|
|
/// The page count of another PDF (Replace Pages, Insert Pages dialogs).
|
|
pub fn page_count_of(&self, name: &str, bytes: &Arc<Vec<u8>>) -> Result<usize, EditError> {
|
|
Ok(pdfcraft_organize::page_count(&open_source(name, bytes)?)?)
|
|
}
|
|
|
|
/// A new blank document (Create ▸ Blank page).
|
|
pub fn create_blank(&self, width: f64, height: f64, pages: usize) -> Result<Arc<Vec<u8>>, EditError> {
|
|
self.write_new(&pdfcraft_create::blank(width, height, pages)?)
|
|
}
|
|
|
|
/// A new document with one page per image (PNG, JPEG).
|
|
pub fn create_from_images(&self, images: &[(String, Vec<u8>)]) -> Result<Arc<Vec<u8>>, EditError> {
|
|
self.write_new(&pdfcraft_create::from_images(images)?)
|
|
}
|
|
|
|
/// Create image pages at embedded resolution or a fixed dpi, without resampling.
|
|
pub fn create_from_images_with_resolution(&self, images: &[(String, Vec<u8>)], resolution: ImageResolution) -> Result<Arc<Vec<u8>>, EditError> {
|
|
self.write_new(&pdfcraft_create::from_images_with_resolution(images, resolution)?)
|
|
}
|
|
|
|
/// A new document from plain text (US Letter, 11 pt Helvetica, or the bundled Inter face for
|
|
/// text with Latin, Greek or Cyrillic letters Helvetica can't draw). Text neither can draw is
|
|
/// refused, naming the line and the character (never set as `?`).
|
|
pub fn create_from_text(&self, title: &str, text: &str) -> Result<Arc<Vec<u8>>, EditError> {
|
|
self.write_new(&pdfcraft_create::from_text(title, text, pdfcraft_create::LETTER, 11.0)?)
|
|
}
|
|
|
|
/// [`Self::create_from_text`] for a report PdfCraft writes from documents (comment summaries,
|
|
/// compare reports), which can quote any language: when the bundled Inter face can't draw it
|
|
/// all, characters Helvetica can't draw are written as `[U+XXXX]`, and the report's first line
|
|
/// says so, rather than the report being refused.
|
|
pub(crate) fn create_report(&self, title: &str, text: &str) -> Result<Arc<Vec<u8>>, EditError> {
|
|
let (doc, _escaped) = pdfcraft_create::from_text_escaped(title, text, pdfcraft_create::LETTER, 11.0)?;
|
|
self.write_new(&doc)
|
|
}
|
|
|
|
/// Convert a file Create understands (an image or plain text) to PDF bytes; a PDF is checked
|
|
/// (it must open and allow copying pages) and returned as it is.
|
|
pub fn convert_to_pdf(&self, name: &str, bytes: &Arc<Vec<u8>>) -> Result<(SourceKind, Arc<Vec<u8>>), EditError> {
|
|
let Some(kind) = source_kind(name, bytes) else {
|
|
return Err(EditError::Source(format!("{name}: this file type can't be converted; use a PDF, an image or a .txt file")));
|
|
};
|
|
let title = name.rsplit_once('.').map_or(name, |(s, _)| s);
|
|
// Image decoders read untrusted bytes: a panic in one must not take the app down.
|
|
let created = guard(|| match kind {
|
|
SourceKind::Pdf => open_source(name, bytes).map(|_| bytes.clone()),
|
|
SourceKind::Image => self.create_from_images(&[(name.to_string(), bytes.to_vec())]),
|
|
SourceKind::Text => self.create_from_text(title, &decode_text(bytes)),
|
|
})
|
|
.map_err(|_| EditError::Source(format!("{name}: the file could not be read")))?;
|
|
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.
|
|
pub fn as_pdf(&self, name: &str, bytes: &Arc<Vec<u8>>) -> Result<Arc<Vec<u8>>, EditError> {
|
|
let pdf = match source_kind(name, bytes) {
|
|
Some(SourceKind::Pdf) => true,
|
|
Some(SourceKind::Image | SourceKind::Text) => false,
|
|
None => name.to_ascii_lowercase().ends_with(".pdf"),
|
|
};
|
|
if pdf { Ok(bytes.clone()) } else { self.convert_to_pdf(name, bytes).map(|(_, pdf)| pdf) }
|
|
}
|
|
|
|
/// Reduce File Size: Acrobat's defaults (colour and grey images above 225 ppi to 150 ppi,
|
|
/// JPEG medium quality; black-and-white averaged down to 300 ppi above 450 ppi, and stored as
|
|
/// CCITT Group 4 whenever smaller; thumbnails dropped), identical resources merged, unused
|
|
/// objects dropped, objects packed into compressed object streams. Returns the bytes, how
|
|
/// many objects were merged and how many images stayed as they were, past what one run may
|
|
/// decode. The open document is not changed (Acrobat saves the reduced copy as a new file).
|
|
pub fn reduced_bytes(&self, id: DocId) -> Result<(Arc<Vec<u8>>, usize, usize), EditError> {
|
|
let (bytes, report) = self.optimized_bytes(id, &optimize::Settings::default(), &[])?;
|
|
Ok((bytes, report.merged, report.optimize.images_refused))
|
|
}
|
|
|
|
/// Optimize PDF ▸ Advanced optimization: `settings` for images and objects, plus Remove
|
|
/// Hidden Information's `discard` categories (user data). A full rewrite: signed documents
|
|
/// are refused. The open document is not changed. This is [`Self::optimize_job`] run in
|
|
/// place, without progress.
|
|
pub fn optimized_bytes(&self, id: DocId, settings: &optimize::Settings, discard: &[Hidden]) -> Result<(Arc<Vec<u8>>, OptimizeReport), EditError> {
|
|
self.optimize_job(id, settings, discard)?.run(|_| true)
|
|
}
|
|
|
|
/// Export comments and/or form data: XFDF and FDF carry either or both; XML, CSV and text
|
|
/// are form data only.
|
|
pub fn export_data(&self, id: DocId, format: DataFormat, comments: bool, fields: bool) -> Result<Vec<u8>, EditError> {
|
|
let doc = self.get(id).ok_or(EditError::NoDocument)?;
|
|
let cos = match doc.editor.as_ref() {
|
|
Some(e) => e.cos.clone(),
|
|
None => {
|
|
pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))?
|
|
}
|
|
};
|
|
// An XFA form's data is its datasets, as Acrobat exports it: XML or an XDP.
|
|
let xfa = doc.info.xfa.is_some();
|
|
if format == DataFormat::Xdp && !xfa {
|
|
return Err(EditError::Invalid("XDP holds XFA form data; this document is not an XFA form (use .xml, .csv or .txt)".into()));
|
|
}
|
|
guard(|| match format {
|
|
DataFormat::Xfdf => Ok(pdfcraft_xfdf::export_xfdf(&cos, comments, fields, &doc.name).into_bytes()),
|
|
DataFormat::Fdf => pdfcraft_xfdf::export_fdf(&cos, comments, fields, &doc.name).map_err(|e| e.to_string()),
|
|
DataFormat::Xml | DataFormat::Xdp if xfa => {
|
|
pdfcraft_xfa::export_data(&cos, format == DataFormat::Xdp, &doc.name).map(String::into_bytes).map_err(|e| e.to_string())
|
|
}
|
|
other => Ok(pdfcraft_xfdf::export_data(&cos, other).into_bytes()),
|
|
})
|
|
.map_err(EditError::Write)?
|
|
.map_err(EditError::Write)
|
|
}
|
|
|
|
/// What Convert colours would change on `pages` of `id`, without changing the document.
|
|
pub fn count_colours(&self, id: DocId, pages: &[usize], settings: &pdfcraft_edit::ConvertColours) -> pdfcraft_edit::ColoursConverted {
|
|
let Some(doc) = self.get(id) else { return Default::default() };
|
|
let Some(editor) = doc.editor.as_ref() else { return Default::default() };
|
|
let mut cos = editor.cos.clone();
|
|
let mut recolour = |doc: &pdfcraft_cos::Document, s: &pdfcraft_cos::Stream, t: pdfcraft_edit::ColourTarget| {
|
|
let target = match t {
|
|
pdfcraft_edit::ColourTarget::Gray => pdfcraft_optimize::ImageTarget::Gray,
|
|
pdfcraft_edit::ColourTarget::Rgb => pdfcraft_optimize::ImageTarget::Rgb,
|
|
pdfcraft_edit::ColourTarget::Cmyk => pdfcraft_optimize::ImageTarget::Cmyk,
|
|
};
|
|
pdfcraft_optimize::recolour_image(doc, s, target)
|
|
};
|
|
// The preview runs the image decoders over the document's own bytes; a panic in one is
|
|
// a count of nothing, not a crash.
|
|
guard(|| pdfcraft_edit::convert_colours(&mut cos, pages, settings, &mut recolour).unwrap_or_default()).unwrap_or_default()
|
|
}
|
|
|
|
/// How many hairlines [`Edit::FixHairlines`] would change on `pages` of `id`, and on how many
|
|
/// pages, without changing the document (the edit itself refuses when there is nothing to fix).
|
|
pub fn count_hairlines(&self, id: DocId, pages: &[usize], settings: &Hairlines) -> HairlinesFixed {
|
|
let Some(doc) = self.get(id) else { return HairlinesFixed::default() };
|
|
let Some(editor) = doc.editor.as_ref() else { return HairlinesFixed::default() };
|
|
let mut cos = editor.cos.clone();
|
|
pdfcraft_edit::fix_hairlines(&mut cos, pages, settings).unwrap_or_default()
|
|
}
|
|
|
|
/// XFDF with these comments (0-based page, position in the page's `/Annots`) and their
|
|
/// replies: what Copy puts on the clipboard; [`Edit::PasteComments`] takes it back in.
|
|
pub fn export_comments(&self, id: DocId, which: &[(usize, usize)]) -> Result<String, EditError> {
|
|
let doc = self.get(id).ok_or(EditError::NoDocument)?;
|
|
let cos = match doc.editor.as_ref() {
|
|
Some(e) => e.cos.clone(),
|
|
None => {
|
|
pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))?
|
|
}
|
|
};
|
|
guard(|| pdfcraft_xfdf::export_xfdf_comments(&cos, which, &doc.name)).map_err(EditError::Write)
|
|
}
|
|
|
|
/// The print-ready PDF for `settings` (sheets laid out for the paper; see `pdfcraft-print`).
|
|
pub fn print_pdf(&self, id: DocId, settings: &print::Settings) -> Result<Vec<u8>, EditError> {
|
|
let doc = self.get(id).ok_or(EditError::NoDocument)?;
|
|
if !doc.allows_printing() {
|
|
return Err(EditError::NotPermitted("printing"));
|
|
}
|
|
// Table 22, bit 12 clear: only a low-level representation may be printed, so the pages
|
|
// always go as pictures, at no more than the low resolution.
|
|
let low_res = doc.permissions().is_some_and(|p| !p.print_high_quality()).then(|| print::Raster {
|
|
dpi: settings.raster.map_or(LOW_RESOLUTION_PRINT_DPI, |r| r.dpi.min(LOW_RESOLUTION_PRINT_DPI)),
|
|
grayscale: settings.raster.is_some_and(|r| r.grayscale),
|
|
});
|
|
// Print as image: the pages used become pictures first, then the picture document is
|
|
// imposed in their place (its page k is the k-th distinct source page used).
|
|
if let Some(raster) = low_res.as_ref().or(settings.raster.as_ref()) {
|
|
let mut distinct: Vec<usize> = Vec::new();
|
|
for &p in &settings.pages {
|
|
if !distinct.contains(&p) {
|
|
distinct.push(p);
|
|
}
|
|
}
|
|
let bytes = print_raster::rasterize(doc, &distinct, raster, settings.content).map_err(EditError::Print)?;
|
|
let pictures = pdfcraft_cos::Document::open(Arc::new(bytes)).map_err(|e| EditError::Print(e.to_string()))?;
|
|
let remapped = print::Settings {
|
|
pages: settings.pages.iter().map(|p| distinct.iter().position(|d| d == p).unwrap_or(0)).collect(),
|
|
content: print::Content::Document,
|
|
raster: None,
|
|
..settings.clone()
|
|
};
|
|
return pdfcraft_print::impose(&pictures, &remapped).map_err(|e| EditError::Print(e.to_string()));
|
|
}
|
|
let mut cos = match doc.editor.as_ref() {
|
|
Some(e) => e.cos.clone(),
|
|
None => {
|
|
pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))?
|
|
}
|
|
};
|
|
// Comments and fields without an appearance print as the page shows them (and as Print
|
|
// as Image already printed them): the vector print drew only appearance streams, so a
|
|
// text box without one was on screen but not on paper. ISO 32000-2 §12.5.3: an
|
|
// annotation with the Print flag is printed.
|
|
if let Some(displayed) = guard(|| crate::document::as_displayed(&cos)).ok().flatten() {
|
|
cos = displayed;
|
|
}
|
|
// #3046: the layers print as they are shown. Once the Layers panel (or a set-layer
|
|
// action) has changed one, the print copy's default configuration says which are on, so a
|
|
// hidden layer stays off on paper too; untouched, the document's own configuration prints.
|
|
// On top of either, the configuration's auto state for the Print event sets the layers it
|
|
// names (`print_layer_states`), as printing does in a viewer (ISO 32000-2 §8.11.4.4).
|
|
if let Some(printed) = print_layer_states(&cos, &doc.info.layers) {
|
|
set_default_layer_state(&mut cos, &printed);
|
|
} else if !doc.config.layers.is_empty() {
|
|
set_default_layer_state(&mut cos, &doc.info.layers);
|
|
}
|
|
// Signature states as the screen shows them, so not when its display copy could not be
|
|
// written. This copy is only printed, never saved.
|
|
if doc.signatures.iter().any(|s| s.verdict_shown) {
|
|
let _ = pdfcraft_sign::show_status_layers(&mut cos, &mut doc.signatures.to_vec(), &doc.captions);
|
|
}
|
|
pdfcraft_print::impose(&cos, settings).map_err(|e| EditError::Print(e.to_string()))
|
|
}
|
|
|
|
/// For each page, whether its text mostly runs bottom-to-top ([`pdfcraft_print::text_reads_up`]);
|
|
/// only `pages` are read, the others are `false`.
|
|
pub fn print_text_up(&self, id: DocId, pages: &[usize]) -> Vec<bool> {
|
|
let Some(doc) = self.get(id) else { return Vec::new() };
|
|
let cos = match doc.editor.as_ref() {
|
|
Some(e) => e.cos.clone(),
|
|
None => match pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()) {
|
|
Ok(c) => c,
|
|
Err(_) => return Vec::new(),
|
|
},
|
|
};
|
|
// The page tree is read once, and each page is looked up, not searched for, in `pages`.
|
|
let mut out = vec![false; doc.info.pages.len()];
|
|
let wanted: Vec<usize> = pages.iter().copied().filter(|&p| p < out.len()).collect::<std::collections::BTreeSet<_>>().into_iter().collect();
|
|
for (p, up) in wanted.iter().zip(pdfcraft_print::texts_read_up(&cos, &wanted)) {
|
|
out[*p] = up;
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Combine whole files, in order, into new PDF bytes (one bookmark per file).
|
|
pub fn combine(&self, sources: &[(String, Arc<Vec<u8>>)]) -> Result<Arc<Vec<u8>>, EditError> {
|
|
let docs = sources.iter().map(|(n, b)| open_source(n, b)).collect::<Result<Vec<_>, _>>()?;
|
|
let named: Vec<(&str, &pdfcraft_cos::Document)> = sources.iter().map(|(n, _)| n.as_str()).zip(docs.iter()).collect();
|
|
let out = pdfcraft_organize::combine(&named)?;
|
|
self.write_new(&out)
|
|
}
|
|
|
|
/// Combine Files with a page range per file ("1-3, 6"; `None` or empty for all pages).
|
|
pub fn combine_ranges(&self, sources: &[CombineSource]) -> Result<Arc<Vec<u8>>, EditError> {
|
|
self.combine_unlocked(sources, &[])
|
|
}
|
|
|
|
/// Combine sources one at a time, in order (Create ▸ Multiple files), to the same result as
|
|
/// [`Self::combine_ranges`]. Each item is a source's bookmark title, its PDF bytes (see
|
|
/// [`Self::convert_to_pdf`]) and its page range (`None` for all pages). Items are pulled one at a
|
|
/// time: a source is opened, its pages are copied into the result, and its bytes are released
|
|
/// before the next item is read. So the number of sources is not limited, and one source's
|
|
/// document is held at a time. An item's error stops the combine and is returned as it is.
|
|
/// `Ok(None)` when there are no sources.
|
|
pub fn combine_each<I, E>(&self, sources: I) -> Result<Option<Arc<Vec<u8>>>, E>
|
|
where
|
|
I: IntoIterator<Item = Result<CombineSource, E>>,
|
|
E: From<EditError>,
|
|
{
|
|
let mut combiner = pdfcraft_organize::Combiner::new();
|
|
let mut added = false;
|
|
for source in sources {
|
|
let (title, bytes, range) = source?;
|
|
let doc = open_source(&title, &bytes)?;
|
|
let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty());
|
|
let pages = match range {
|
|
Some(r) => {
|
|
let n = pdfcraft_organize::page_count(&doc).map_err(EditError::from)?;
|
|
let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false)
|
|
.map_err(|e| EditError::Print(format!("{title}: {e}")))?;
|
|
Some(p)
|
|
}
|
|
None => None,
|
|
};
|
|
combiner.add(&title, &doc, pages.as_deref()).map_err(EditError::from)?;
|
|
added = true;
|
|
}
|
|
if !added {
|
|
return Ok(None);
|
|
}
|
|
let out = combiner.finish().map_err(EditError::from)?;
|
|
Ok(Some(self.write_new(&out)?))
|
|
}
|
|
|
|
/// [`Self::combine_ranges`] with the password each encrypted source is opened with, by
|
|
/// position (missing or `None`: no password). The result is not encrypted.
|
|
pub fn combine_unlocked(&self, sources: &[CombineSource], passwords: &[Option<&str>]) -> Result<Arc<Vec<u8>>, EditError> {
|
|
Ok(self.combine_unlocked_reporting(sources, passwords, pdfcraft_organize::FieldConflicts::Rename)?.bytes)
|
|
}
|
|
|
|
/// The pre-flight over byte sources: what combining them, in order, would rename in each one
|
|
/// (whole files; page ranges are the combine's to apply). A source that cannot be opened
|
|
/// previews as nothing — its row already says why it cannot be combined.
|
|
pub fn combine_field_conflicts(&self, sources: &[CombineSource], passwords: &[Option<&str>]) -> Vec<Vec<(String, String)>> {
|
|
let docs: Vec<Option<pdfcraft_cos::Document>> =
|
|
sources.iter().enumerate().map(|(i, (n, b, _))| open_source_with(n, b, passwords.get(i).copied().flatten()).ok()).collect();
|
|
let refs: Vec<&pdfcraft_cos::Document> = docs.iter().filter_map(Option::as_ref).collect();
|
|
let mut opened = pdfcraft_organize::combine_field_conflicts(&refs).into_iter();
|
|
// One preview per source, in step with it: a file that cannot be opened has none.
|
|
docs.iter().map(|d| if d.is_some() { opened.next().unwrap_or_default() } else { Default::default() }).collect()
|
|
}
|
|
|
|
/// [`Self::combine_unlocked`] reporting what the conflict resolution renamed.
|
|
pub fn combine_unlocked_reporting(
|
|
&self,
|
|
sources: &[CombineSource],
|
|
passwords: &[Option<&str>],
|
|
field_conflicts: pdfcraft_organize::FieldConflicts,
|
|
) -> Result<Combined, EditError> {
|
|
let docs = sources
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, (n, b, _))| open_source_with(n, b, passwords.get(i).copied().flatten()))
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
let mut pages = Vec::with_capacity(docs.len());
|
|
for ((name, _, range), d) in sources.iter().zip(&docs) {
|
|
let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty());
|
|
pages.push(match range {
|
|
Some(r) => {
|
|
let n = pdfcraft_organize::page_count(d)?;
|
|
let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false)
|
|
.map_err(|e| EditError::Print(format!("{name}: {e}")))?;
|
|
Some(p)
|
|
}
|
|
None => None,
|
|
});
|
|
}
|
|
let named: Vec<(&str, &pdfcraft_cos::Document, Option<&[usize]>)> =
|
|
sources.iter().zip(docs.iter()).zip(&pages).map(|(((n, _, _), d), p)| (n.as_str(), d, p.as_deref())).collect();
|
|
let out = pdfcraft_organize::combine_selected_with(&named, field_conflicts)?;
|
|
let bytes = self.write_new(&out.doc)?;
|
|
Ok(Combined { bytes, renamed_fields: out.renamed_fields })
|
|
}
|
|
|
|
/// [`Self::combine_unlocked`] where sources with the same `groups` key (by position) are one
|
|
/// file split into several runs placed apart: each file is opened and copied once, so it keeps
|
|
/// one bookmark, and its links between its own pages and its fields whole across its runs; its
|
|
/// attachments come along when its runs use all of its pages (see
|
|
/// `pdfcraft_organize::combine_grouped`). A group's later sources use its first source's bytes
|
|
/// and password; a source without a key is a file of its own.
|
|
pub fn combine_grouped(&self, sources: &[CombineSource], groups: &[u64], passwords: &[Option<&str>]) -> Result<Arc<Vec<u8>>, EditError> {
|
|
Ok(self.combine_grouped_reporting(sources, groups, passwords, pdfcraft_organize::FieldConflicts::Rename)?.bytes)
|
|
}
|
|
|
|
/// [`Self::combine_grouped`] reporting what the conflict resolution renamed.
|
|
pub fn combine_grouped_reporting(
|
|
&self,
|
|
sources: &[CombineSource],
|
|
groups: &[u64],
|
|
passwords: &[Option<&str>],
|
|
field_conflicts: pdfcraft_organize::FieldConflicts,
|
|
) -> Result<Combined, EditError> {
|
|
let (mut keys, mut docs, mut file): (Vec<Option<u64>>, Vec<pdfcraft_cos::Document>, Vec<usize>) = (Vec::new(), Vec::new(), Vec::new());
|
|
for (i, (name, bytes, _)) in sources.iter().enumerate() {
|
|
let key = groups.get(i).copied();
|
|
let known = key.and_then(|k| keys.iter().position(|g| *g == Some(k)));
|
|
let at = match known {
|
|
Some(at) => at,
|
|
None => {
|
|
docs.push(open_source_with(name, bytes, passwords.get(i).copied().flatten())?);
|
|
keys.push(key);
|
|
docs.len() - 1
|
|
}
|
|
};
|
|
file.push(at);
|
|
}
|
|
let mut pages = Vec::with_capacity(sources.len());
|
|
for ((name, _, range), at) in sources.iter().zip(&file) {
|
|
let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty());
|
|
let Some(d) = docs.get(*at) else { return Err(EditError::Write(format!("{name}: not opened"))) };
|
|
pages.push(match range {
|
|
Some(r) => {
|
|
let n = pdfcraft_organize::page_count(d)?;
|
|
let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false)
|
|
.map_err(|e| EditError::Print(format!("{name}: {e}")))?;
|
|
Some(p)
|
|
}
|
|
None => None,
|
|
});
|
|
}
|
|
let mut runs: Vec<pdfcraft_organize::Run<'_>> = Vec::with_capacity(sources.len());
|
|
for (((name, _, _), at), p) in sources.iter().zip(&file).zip(&pages) {
|
|
let Some(d) = docs.get(*at) else { return Err(EditError::Write(format!("{name}: not opened"))) };
|
|
runs.push((*at, name.as_str(), d, p.as_deref()));
|
|
}
|
|
let out = pdfcraft_organize::combine_grouped_with(&runs, field_conflicts)?;
|
|
let bytes = self.write_new(&out.doc)?;
|
|
Ok(Combined { bytes, renamed_fields: out.renamed_fields })
|
|
}
|
|
|
|
/// 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.extract_pages()) {
|
|
return Err(EditError::NotPermitted("extracting pages"));
|
|
}
|
|
let out = pdfcraft_organize::extract_pages(src, pages)?;
|
|
self.write_new(&out)
|
|
}
|
|
|
|
/// 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.extract_pages()) {
|
|
return Err(EditError::NotPermitted("splitting the document"));
|
|
}
|
|
let n = pdfcraft_organize::page_count(src)?;
|
|
pdfcraft_organize::split_ranges(n, by)
|
|
.into_iter()
|
|
.map(|r| {
|
|
let doc = pdfcraft_organize::extract_pages(src, &r.clone().collect::<Vec<_>>())?;
|
|
Ok((r.start + 1, r.end, self.write_new(&doc)?))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Split into parts of at most `max_bytes` each (Acrobat's "File size"): pages are taken in
|
|
/// order while their size (as single-page files, an over-estimate because shared fonts and
|
|
/// 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.extract_pages()) {
|
|
return Err(EditError::NotPermitted("splitting the document"));
|
|
}
|
|
let n = pdfcraft_organize::page_count(src)?;
|
|
let mut cuts = Vec::new();
|
|
let mut used = 0usize;
|
|
for p in 0..n {
|
|
let size = self.write_unchecked(&pdfcraft_organize::extract_pages(src, &[p])?)?.len();
|
|
if used > 0 && used + size > max_bytes {
|
|
cuts.push(p);
|
|
used = 0;
|
|
}
|
|
used += size;
|
|
}
|
|
self.split(id, &pdfcraft_organize::SplitBy::Before(cuts))
|
|
}
|
|
|
|
fn cos(&self, id: DocId) -> Result<&pdfcraft_cos::Document, EditError> {
|
|
let doc = self.get(id).ok_or(EditError::NoDocument)?;
|
|
doc.editor.as_ref().map(|e| &e.cos).ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))
|
|
}
|
|
|
|
/// A new file (Combine, Extract, Split, Create) as bytes, checked like a save before anyone
|
|
/// writes it (#1698, #2166): it reopens, and shows the pages it was built from.
|
|
pub(crate) fn write_new(&self, doc: &pdfcraft_cos::Document) -> Result<Arc<Vec<u8>>, EditError> {
|
|
let bytes = self.write_unchecked(doc)?;
|
|
let expected = pdfcraft_organize::page_count(doc)?;
|
|
pdfcraft_cos::Document::open(bytes.clone()).map_err(|e| EditError::Reopen(e.to_string()))?;
|
|
pdfcraft_render::check_pages(bytes.clone(), None, expected).map_err(|e| EditError::Reopen(e.to_string()))?;
|
|
Ok(bytes)
|
|
}
|
|
|
|
/// [`Self::write_new`] without the check, for bytes nobody writes: measuring a part's size.
|
|
/// Like a save, a new file never carries a typed Password-field value (#3926): Extract and
|
|
/// Split copy the fields of the pages they take.
|
|
fn write_unchecked(&self, doc: &pdfcraft_cos::Document) -> Result<Arc<Vec<u8>>, EditError> {
|
|
let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() };
|
|
let doc = writable(doc)?;
|
|
write_full(&doc, &opts).map(Arc::new).map_err(|e| EditError::Write(e.to_string()))
|
|
}
|
|
}
|