use std::{
collections::BTreeSet,
fs::{self, OpenOptions},
path::{Path, PathBuf},
process,
};
use crate::{
copy::{
attach_document_metadata, copy_pages_with_context, CopiedDocumentMetadata, CopyContext,
CopyOptions,
},
lazy::PdfSource,
load::map_file,
range::{PageRangeError, PageRangeGroup},
repair::{is_offset_damage, with_repair_retry},
split::{empty_document, finish_pages},
write::{StreamingCopyContext, StreamingPdfWriter},
PdfOpsError, Result,
};
#[derive(Debug, Clone)]
pub struct MergeInput {
pub path: PathBuf,
pub ranges: Vec<PageRangeGroup>,
}
#[derive(Debug, Clone, Default)]
pub struct MergeOptions {
pub preserve_whole_single_input: bool,
pub password: Option<String>,
}
impl MergeInput {
pub fn all(path: impl Into<PathBuf>) -> Self {
Self {
path: path.into(),
ranges: Vec::new(),
}
}
}
pub fn merge(inputs: &[MergeInput], output: &Path) -> Result<()> {
merge_with_options(inputs, output, MergeOptions::default())
}
pub fn merge_with_options(
inputs: &[MergeInput],
output: &Path,
options: MergeOptions,
) -> Result<()> {
if inputs.is_empty() {
return Err(PdfOpsError::Range(PageRangeError::NoPages));
}
let password = options.password.as_deref();
if options.preserve_whole_single_input {
if let [input] = inputs {
if input.ranges.is_empty() && !same_file(&input.path, output)? {
return copy_whole_input(input, output, password);
}
}
}
if inputs.iter().all(|input| input.ranges.is_empty()) {
return merge_whole_inputs_streaming(inputs, output, password);
}
let mut force_repair: BTreeSet<usize> = BTreeSet::new();
'attempt: loop {
let mut target = empty_document();
let mut merged_pages = Vec::new();
let mut document_metadata = CopiedDocumentMetadata::default();
for (index, input) in inputs.iter().enumerate() {
let mmap = map_file(&input.path)?;
let source =
open_merge_source(&mmap, &input.path, password, force_repair.contains(&index))?;
let mut context = CopyContext::new(CopyOptions {
prune_resources: !input.ranges.is_empty(),
..CopyOptions::default()
});
let copied = (|| {
let pages = source.page_ids()?;
let page_ids = resolve_merge_page_ids(&pages, input)?;
copy_pages_with_context(&source, &mut target, &page_ids, &mut context)
})();
match copied {
Ok(pages) => {
if index == 0 {
document_metadata =
context.copy_document_metadata_objects(&source, &mut target);
}
merged_pages.extend(pages);
}
Err(err) if is_offset_damage(&err) && !source.repaired() => {
force_repair.insert(index);
continue 'attempt;
}
Err(err) => return Err(err),
}
}
finish_pages(&mut target, &merged_pages)?;
attach_document_metadata(&mut target, &document_metadata)?;
target.save(output)?;
return Ok(());
}
}
fn open_merge_source<'a>(
buffer: &'a [u8],
path: &Path,
password: Option<&str>,
force_repair: bool,
) -> Result<PdfSource<'a>> {
if force_repair {
PdfSource::open_repaired(buffer, path).ok_or_else(|| {
PdfOpsError::InvalidStructure(format!(
"{}: damaged cross-reference data; automatic repair failed",
path.display()
))
})
} else {
PdfSource::open(buffer, path, password)
}
}
pub(crate) fn merge_whole_inputs_streaming(
inputs: &[MergeInput],
output: &Path,
password: Option<&str>,
) -> Result<()> {
let mut force_repair: BTreeSet<usize> = BTreeSet::new();
loop {
let mut failed_index = None;
let result = write_streaming_output(output, |writer| {
for (index, input) in inputs.iter().enumerate() {
let mmap = map_file(&input.path)?;
let source =
open_merge_source(&mmap, &input.path, password, force_repair.contains(&index))?;
let appended = (|| {
let page_ids = source.page_ids()?;
if page_ids.is_empty() {
return Err(PdfOpsError::Range(PageRangeError::NoPages));
}
append_whole_source(writer, &source, &page_ids, index == 0)
})();
if let Err(err) = appended {
if !source.repaired() {
failed_index = Some(index);
}
return Err(err);
}
}
Ok(())
});
match result {
Err(err) if is_offset_damage(&err) => match failed_index {
Some(index) if force_repair.insert(index) => continue,
_ => return Err(err),
},
result => return result,
}
}
}
fn write_streaming_output(
output: &Path,
fill: impl FnOnce(&mut StreamingPdfWriter) -> Result<()>,
) -> Result<()> {
let temp_output = temp_output_path(output)?;
let result = (|| {
let mut writer = StreamingPdfWriter::create(&temp_output)?;
fill(&mut writer)?;
writer.finish()
})();
match result {
Ok(()) => {
fs::rename(&temp_output, output)?;
Ok(())
}
Err(err) => {
let _ = fs::remove_file(&temp_output);
Err(err)
}
}
}
fn append_whole_source(
writer: &mut StreamingPdfWriter,
source: &PdfSource<'_>,
page_ids: &[lopdf::ObjectId],
donates_metadata: bool,
) -> Result<()> {
let (copied_pages, metadata) = {
let mut context = StreamingCopyContext::new(
writer,
CopyOptions {
prune_resources: false,
..CopyOptions::default()
},
);
let pages = context.copy_pages(source, page_ids)?;
let metadata = donates_metadata.then(|| context.copy_document_metadata_objects(source));
(pages, metadata)
};
writer.extend_pages(copied_pages);
if let Some(metadata) = metadata {
writer.set_document_metadata(metadata);
}
Ok(())
}
fn temp_output_path(output: &Path) -> Result<PathBuf> {
let directory = output.parent().unwrap_or_else(|| Path::new("."));
let file_name = output
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("pdq-output");
for attempt in 0..1000 {
let candidate = directory.join(format!(".{file_name}.pdq-{}-{attempt}.tmp", process::id()));
match OpenOptions::new()
.write(true)
.create_new(true)
.open(&candidate)
{
Ok(_) => {
fs::remove_file(&candidate)?;
return Ok(candidate);
}
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(err) => return Err(err.into()),
}
}
Err(PdfOpsError::InvalidStructure(format!(
"could not allocate temporary output next to {}",
output.display()
)))
}
fn copy_whole_input(input: &MergeInput, output: &Path, password: Option<&str>) -> Result<()> {
let mmap = map_file(&input.path)?;
with_repair_retry(&mmap, &input.path, password, |source| {
let page_ids = source.page_ids()?;
if page_ids.is_empty() {
return Err(PdfOpsError::Range(PageRangeError::NoPages));
}
if source.was_encrypted() || source.repaired() {
return write_streaming_output(output, |writer| {
append_whole_source(writer, source, &page_ids, true)
});
}
fs::copy(&input.path, output)?;
Ok(())
})
}
#[cfg(unix)]
fn same_file(left: &Path, right: &Path) -> Result<bool> {
use std::os::unix::fs::MetadataExt;
let left = fs::metadata(left)?;
let right = match fs::metadata(right) {
Ok(metadata) => metadata,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(err) => return Err(err.into()),
};
Ok(left.dev() == right.dev() && left.ino() == right.ino())
}
#[cfg(not(unix))]
fn same_file(left: &Path, right: &Path) -> Result<bool> {
let left = fs::canonicalize(left)?;
let right = match fs::canonicalize(right) {
Ok(path) => path,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(err) => return Err(err.into()),
};
Ok(left == right)
}
fn resolve_merge_page_ids(
page_ids: &[lopdf::ObjectId],
input: &MergeInput,
) -> Result<Vec<lopdf::ObjectId>> {
if page_ids.is_empty() {
return Err(PdfOpsError::Range(PageRangeError::NoPages));
}
if input.ranges.is_empty() {
return Ok(page_ids.to_vec());
}
let mut resolved = Vec::new();
for range in &input.ranges {
for page_number in range.resolve(page_ids.len())? {
let page_id = page_ids.get(page_number - 1).copied().ok_or_else(|| {
PdfOpsError::InvalidStructure(format!("missing page {page_number}"))
})?;
resolved.push(page_id);
}
}
Ok(resolved)
}