use std::{
collections::{BTreeMap, BTreeSet},
path::{Path, PathBuf},
};
use lopdf::{dictionary, Document, Object};
use rayon::prelude::*;
use crate::{
copy::{
attach_document_metadata, copy_pages_with_context, resolve_page_ids, CopyContext,
CopyOptions, ObjectSource,
},
load::{load_document, map_file},
merge::merge_whole_inputs_streaming,
range::{PageRangeError, PageRangeGroup},
repair::with_repair_retry,
split_template::{SinglePageTemplate, WriteGate},
PdfOpsError, Result,
};
#[derive(Debug, Clone)]
pub struct SplitOutput {
pub range: PageRangeGroup,
pub path: PathBuf,
}
pub fn split(input: &Path, outputs: &[SplitOutput]) -> Result<()> {
split_with_password(input, outputs, None)
}
pub fn split_with_password(
input: &Path,
outputs: &[SplitOutput],
password: Option<&str>,
) -> Result<()> {
let walk_limit = outputs
.iter()
.map(|output| output.range.bounded_max_page())
.collect::<Option<Vec<_>>>()
.and_then(|maxes| maxes.into_iter().max());
if let Some(limit) = walk_limit {
let mmap = map_file(input)?;
return with_repair_retry(&mmap, input, password, |source| {
let ordered_pages = source.page_ids_up_to(limit)?;
if ordered_pages.is_empty() {
return Err(PdfOpsError::Range(PageRangeError::NoPages));
}
let pages: BTreeMap<u32, lopdf::ObjectId> = (1u32..).zip(ordered_pages).collect();
let resolved_outputs = resolve_split_outputs(outputs, &pages, true)?;
reject_duplicate_output_paths(&resolved_outputs)?;
run_split_outputs(source, &resolved_outputs)
});
}
if let [output] = outputs {
if output.range.is_full_document() {
return merge_whole_inputs_streaming(
&[crate::MergeInput::all(input)],
&output.path,
password,
);
}
}
let source = match load_document(input, password) {
Ok(source) => source,
Err(PdfOpsError::Pdf(_)) | Err(PdfOpsError::Range(PageRangeError::NoPages)) => {
return split_via_lazy(input, password, outputs);
}
Err(err) => return Err(err),
};
let pages = source.get_pages();
if eager_page_tree_is_suspect(&source, pages.len()) {
return split_via_lazy(input, password, outputs);
}
let resolved_outputs = resolve_split_outputs(outputs, &pages, false)?;
reject_duplicate_output_paths(&resolved_outputs)?;
run_split_outputs(&source, &resolved_outputs)
}
fn split_via_lazy(input: &Path, password: Option<&str>, outputs: &[SplitOutput]) -> Result<()> {
let mmap = map_file(input)?;
with_repair_retry(&mmap, input, password, |source| {
let ordered_pages = source.page_ids()?;
if ordered_pages.is_empty() {
return Err(PdfOpsError::Range(PageRangeError::NoPages));
}
let pages: BTreeMap<u32, lopdf::ObjectId> = (1u32..).zip(ordered_pages).collect();
let resolved_outputs = resolve_split_outputs(outputs, &pages, false)?;
reject_duplicate_output_paths(&resolved_outputs)?;
run_split_outputs(source, &resolved_outputs)
})
}
fn eager_page_tree_is_suspect(document: &Document, walked: usize) -> bool {
let declared = (|| {
document
.catalog()
.ok()?
.get(b"Pages")
.ok()?
.as_reference()
.ok()
.and_then(|id| document.get_dictionary(id).ok())?
.get(b"Count")
.ok()?
.as_i64()
.ok()
})();
declared.is_some_and(|count| count != walked as i64)
}
fn resolve_split_outputs(
outputs: &[SplitOutput],
pages: &BTreeMap<u32, lopdf::ObjectId>,
subsets_only: bool,
) -> Result<Vec<ResolvedSplitOutput>> {
outputs
.iter()
.map(|output| {
let page_numbers = output.range.resolve(pages.len())?;
let page_ids = resolve_page_ids(pages, &page_numbers)?;
let prune_resources =
subsets_only || page_ids.iter().collect::<BTreeSet<_>>().len() < pages.len();
Ok(ResolvedSplitOutput {
path: output.path.clone(),
page_ids,
prune_resources,
})
})
.collect()
}
#[derive(Debug, Clone)]
pub struct SplitPagesOptions {
pub pages_per_file: usize,
pub password: Option<String>,
}
impl Default for SplitPagesOptions {
fn default() -> Self {
Self {
pages_per_file: 1,
password: None,
}
}
}
pub fn split_pages(input: &Path, output_pattern: &str) -> Result<()> {
split_pages_with_options(input, output_pattern, &SplitPagesOptions::default())
}
pub fn split_pages_with_password(
input: &Path,
output_pattern: &str,
password: Option<&str>,
) -> Result<()> {
split_pages_with_options(
input,
output_pattern,
&SplitPagesOptions {
password: password.map(str::to_owned),
..SplitPagesOptions::default()
},
)
}
pub fn split_pages_with_options(
input: &Path,
output_pattern: &str,
options: &SplitPagesOptions,
) -> Result<()> {
validate_output_pattern(output_pattern)?;
if options.pages_per_file == 0 {
return Err(PdfOpsError::InvalidStructure(
"pages-per-file must be at least 1".into(),
));
}
let mmap = map_file(input)?;
with_repair_retry(&mmap, input, options.password.as_deref(), |source| {
let pages = source.page_ids()?;
let page_count = pages.len();
if page_count == 0 {
return Err(PdfOpsError::Range(PageRangeError::NoPages));
}
let chunk_count = page_count.div_ceil(options.pages_per_file);
let width = chunk_count.to_string().len();
let resolved_outputs = pages
.chunks(options.pages_per_file)
.enumerate()
.map(|(chunk_index, chunk)| {
Ok(ResolvedSplitOutput {
path: render_output_pattern(output_pattern, chunk_index + 1, width)?,
page_ids: chunk.to_vec(),
prune_resources: true,
})
})
.collect::<Result<Vec<_>>>()?;
if options.pages_per_file == 1 {
if let Some(template) = SinglePageTemplate::prepare(source, &pages) {
return run_template_outputs(source, &template, &resolved_outputs);
}
}
run_split_outputs(source, &resolved_outputs)
})
}
const MAX_CONCURRENT_SPLIT_WRITES: usize = 4;
fn run_template_outputs(
source: &(impl ObjectSource + Sync),
template: &SinglePageTemplate,
outputs: &[ResolvedSplitOutput],
) -> Result<()> {
let gate = WriteGate::new(
std::env::var("PDQ_SPLIT_WRITERS")
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(MAX_CONCURRENT_SPLIT_WRITES),
);
let run_one = |buffer: &mut Vec<u8>, output: &ResolvedSplitOutput| -> Result<()> {
template.write_page(source, output.page_ids[0], &output.path, buffer, &gate)
};
let pool = rayon::ThreadPoolBuilder::new().build();
match pool {
Ok(pool) => pool.install(|| {
outputs
.par_iter()
.try_for_each_init(Vec::new, |buffer, output| run_one(buffer, output))
})?,
Err(_) => {
let mut buffer = Vec::new();
outputs
.iter()
.try_for_each(|output| run_one(&mut buffer, output))?
}
}
Ok(())
}
#[derive(Debug, Clone)]
struct ResolvedSplitOutput {
path: PathBuf,
page_ids: Vec<lopdf::ObjectId>,
prune_resources: bool,
}
fn run_split_outputs(
source: &(impl ObjectSource + Sync),
outputs: &[ResolvedSplitOutput],
) -> Result<()> {
let run_one = |output: &ResolvedSplitOutput| -> Result<()> {
let mut target = empty_document();
let options = CopyOptions {
prune_resources: output.prune_resources,
..CopyOptions::default()
};
let mut context = CopyContext::new(options);
let copied_pages =
copy_pages_with_context(source, &mut target, &output.page_ids, &mut context)?;
let metadata = context.copy_document_metadata_objects(source, &mut target);
finish_pages(&mut target, &copied_pages)?;
attach_document_metadata(&mut target, &metadata)?;
target.save(&output.path)?;
Ok(())
};
let pool = rayon::ThreadPoolBuilder::new().build();
match pool {
Ok(pool) => pool.install(|| outputs.par_iter().try_for_each(run_one))?,
Err(_) => outputs.iter().try_for_each(run_one)?,
}
Ok(())
}
fn reject_duplicate_output_paths(outputs: &[ResolvedSplitOutput]) -> Result<()> {
let mut seen = BTreeSet::new();
for output in outputs {
if !seen.insert(&output.path) {
return Err(PdfOpsError::InvalidStructure(format!(
"duplicate split output path: {}",
output.path.display()
)));
}
}
Ok(())
}
pub(crate) fn render_output_pattern(
pattern: &str,
page_number: usize,
width: usize,
) -> Result<PathBuf> {
let page = format!("{page_number:0width$}");
Ok(PathBuf::from(pattern.replacen("%d", &page, 1)))
}
pub(crate) fn validate_output_pattern(pattern: &str) -> Result<()> {
let occurrences = pattern.match_indices("%d").count();
if occurrences != 1 {
return Err(PdfOpsError::InvalidStructure(
"output pattern must contain exactly one %d".into(),
));
}
Ok(())
}
pub(crate) fn empty_document() -> Document {
Document::with_version("1.7")
}
pub(crate) fn finish_pages(target: &mut Document, pages: &[lopdf::ObjectId]) -> Result<()> {
let pages_id = target.new_object_id();
let catalog_id = target.new_object_id();
let kids: Vec<Object> = pages.iter().copied().map(Object::Reference).collect();
target.objects.insert(
pages_id,
dictionary! {
"Type" => "Pages",
"Kids" => Object::Array(kids),
"Count" => pages.len() as i64,
}
.into(),
);
for page_id in pages {
let page = target
.get_object_mut(*page_id)?
.as_dict_mut()
.map_err(|_| PdfOpsError::InvalidStructure("page is not a dictionary".into()))?;
if !page.has_type(b"Page") {
return Err(PdfOpsError::InvalidStructure(
"pages tree kid does not have /Type /Page".into(),
));
}
page.set("Parent", pages_id);
}
target.objects.insert(
catalog_id,
dictionary! {
"Type" => "Catalog",
"Pages" => pages_id,
}
.into(),
);
target.trailer.set("Root", catalog_id);
Ok(())
}