use super::{OperationError, OperationResult, PageRange};
use crate::geometry::{Point, Rectangle};
use crate::graphics::{ExtGState, FormXObject};
use crate::parser::{PdfDocument, PdfReader};
use crate::{Document, Page};
use std::io::{Read, Seek};
use std::path::Path;
#[derive(Debug, Clone, PartialEq)]
pub enum OverlayPosition {
Center,
TopLeft,
TopRight,
BottomLeft,
BottomRight,
Custom(f64, f64),
}
impl Default for OverlayPosition {
fn default() -> Self {
Self::Center
}
}
#[derive(Debug, Clone)]
pub struct OverlayOptions {
pub pages: PageRange,
pub position: OverlayPosition,
pub opacity: f64,
pub scale: f64,
pub repeat: bool,
}
impl Default for OverlayOptions {
fn default() -> Self {
Self {
pages: PageRange::All,
position: OverlayPosition::Center,
opacity: 1.0,
scale: 1.0,
repeat: false,
}
}
}
impl OverlayOptions {
pub fn validate(&self) -> OperationResult<()> {
if self.scale <= 0.0 {
return Err(OperationError::ProcessingError(
"Overlay scale must be greater than 0".to_string(),
));
}
Ok(())
}
fn clamped_opacity(&self) -> f64 {
self.opacity.clamp(0.0, 1.0)
}
}
pub(crate) fn compute_ctm(
base_w: f64,
base_h: f64,
overlay_w: f64,
overlay_h: f64,
scale: f64,
position: &OverlayPosition,
) -> [f64; 6] {
let scaled_w = overlay_w * scale;
let scaled_h = overlay_h * scale;
let (tx, ty) = match position {
OverlayPosition::Center => ((base_w - scaled_w) / 2.0, (base_h - scaled_h) / 2.0),
OverlayPosition::TopLeft => (0.0, base_h - scaled_h),
OverlayPosition::TopRight => (base_w - scaled_w, base_h - scaled_h),
OverlayPosition::BottomLeft => (0.0, 0.0),
OverlayPosition::BottomRight => (base_w - scaled_w, 0.0),
OverlayPosition::Custom(x, y) => (*x, *y),
};
[scale, 0.0, 0.0, scale, tx, ty]
}
fn convert_parser_dict_to_objects_dict<R: Read + Seek>(
parser_dict: &crate::parser::objects::PdfDictionary,
doc: &PdfDocument<R>,
) -> crate::objects::Dictionary {
let mut result = crate::objects::Dictionary::new();
for (key, value) in &parser_dict.0 {
let converted = convert_parser_obj_to_objects_obj(value, doc);
result.set(key.as_str(), converted);
}
result
}
fn convert_parser_obj_to_objects_obj<R: Read + Seek>(
obj: &crate::parser::objects::PdfObject,
doc: &PdfDocument<R>,
) -> crate::objects::Object {
use crate::objects::Object as WObj;
use crate::parser::objects::PdfObject as PObj;
match obj {
PObj::Null => WObj::Null,
PObj::Boolean(b) => WObj::Boolean(*b),
PObj::Integer(i) => WObj::Integer(*i),
PObj::Real(r) => WObj::Real(*r),
PObj::String(s) => WObj::String(String::from_utf8_lossy(s.as_bytes()).to_string()),
PObj::Name(n) => WObj::Name(n.as_str().to_string()),
PObj::Array(arr) => {
let items: Vec<WObj> = arr
.0
.iter()
.map(|item| convert_parser_obj_to_objects_obj(item, doc))
.collect();
WObj::Array(items)
}
PObj::Dictionary(dict) => WObj::Dictionary(convert_parser_dict_to_objects_dict(dict, doc)),
PObj::Stream(stream) => {
let dict = convert_parser_dict_to_objects_dict(&stream.dict, doc);
WObj::Stream(dict, stream.data.clone())
}
PObj::Reference(num, gen) => {
match doc.get_object(*num, *gen as u16) {
Ok(resolved) => convert_parser_obj_to_objects_obj(&resolved, doc),
Err(_) => {
tracing::warn!(
"Could not resolve reference {} {} R from overlay; replacing with Null",
num,
gen
);
WObj::Null
}
}
}
}
}
pub struct PdfOverlay<R: Read + Seek> {
base_doc: PdfDocument<R>,
overlay_doc: PdfDocument<R>,
}
impl<R: Read + Seek> PdfOverlay<R> {
pub fn new(base_doc: PdfDocument<R>, overlay_doc: PdfDocument<R>) -> Self {
Self {
base_doc,
overlay_doc,
}
}
pub fn apply(&self, options: &OverlayOptions) -> OperationResult<Document> {
options.validate()?;
let base_count =
self.base_doc
.page_count()
.map_err(|e| OperationError::ParseError(e.to_string()))? as usize;
if base_count == 0 {
return Err(OperationError::NoPagesToProcess);
}
let overlay_count =
self.overlay_doc
.page_count()
.map_err(|e| OperationError::ParseError(e.to_string()))? as usize;
if overlay_count == 0 {
return Err(OperationError::ProcessingError(
"Overlay PDF has no pages".to_string(),
));
}
let target_indices = options.pages.get_indices(base_count)?;
let clamped_opacity = options.clamped_opacity();
let mut output_doc = Document::new();
for page_idx in 0..base_count {
let parsed_base = self
.base_doc
.get_page(page_idx as u32)
.map_err(|e| OperationError::ParseError(e.to_string()))?;
let mut page = Page::from_parsed_with_content(&parsed_base, &self.base_doc)
.map_err(OperationError::PdfError)?;
if target_indices.contains(&page_idx) {
let target_pos = target_indices
.iter()
.position(|&i| i == page_idx)
.unwrap_or(0);
let overlay_page_idx = if options.repeat || overlay_count == 1 {
target_pos % overlay_count
} else if target_pos < overlay_count {
target_pos
} else {
output_doc.add_page(page);
continue;
};
self.apply_overlay_to_page(
&mut page,
overlay_page_idx,
&parsed_base,
clamped_opacity,
options.scale,
&options.position,
)?;
}
output_doc.add_page(page);
}
Ok(output_doc)
}
fn apply_overlay_to_page(
&self,
page: &mut Page,
overlay_page_idx: usize,
parsed_base: &crate::parser::page_tree::ParsedPage,
opacity: f64,
scale: f64,
position: &OverlayPosition,
) -> OperationResult<()> {
let parsed_overlay = self
.overlay_doc
.get_page(overlay_page_idx as u32)
.map_err(|e| OperationError::ParseError(e.to_string()))?;
let overlay_streams = self
.overlay_doc
.get_page_content_streams(&parsed_overlay)
.map_err(|e| OperationError::ParseError(e.to_string()))?;
let mut overlay_content = Vec::new();
for stream in &overlay_streams {
overlay_content.extend_from_slice(stream);
overlay_content.push(b'\n');
}
let ov_w = parsed_overlay.width();
let ov_h = parsed_overlay.height();
let bbox = Rectangle::new(Point::new(0.0, 0.0), Point::new(ov_w, ov_h));
let mut form = FormXObject::new(bbox).with_content(overlay_content);
if let Some(resources) = parsed_overlay.get_resources() {
let writer_dict = convert_parser_dict_to_objects_dict(resources, &self.overlay_doc);
form = form.with_resources(writer_dict);
}
let xobj_name = format!("Overlay{}", overlay_page_idx);
page.add_form_xobject(&xobj_name, form);
let base_w = parsed_base.width();
let base_h = parsed_base.height();
let ctm = compute_ctm(base_w, base_h, ov_w, ov_h, scale, position);
let mut ops = String::new();
ops.push_str("q\n");
if (opacity - 1.0).abs() > f64::EPSILON {
let mut state = ExtGState::new();
state.alpha_fill = Some(opacity);
state.alpha_stroke = Some(opacity);
let registered_name = page
.graphics()
.extgstate_manager_mut()
.add_state(state)
.map_err(|e| OperationError::ProcessingError(format!("ExtGState error: {e}")))?;
ops.push_str(&format!("/{} gs\n", registered_name));
}
ops.push_str(&format!(
"{} {} {} {} {} {} cm\n",
ctm[0], ctm[1], ctm[2], ctm[3], ctm[4], ctm[5]
));
ops.push_str(&format!("/{} Do\n", xobj_name));
ops.push_str("Q\n");
page.append_raw_content(ops.as_bytes());
Ok(())
}
}
pub fn overlay_pdf<P, Q, R>(
base_path: P,
overlay_path: Q,
output_path: R,
options: OverlayOptions,
) -> OperationResult<()>
where
P: AsRef<Path>,
Q: AsRef<Path>,
R: AsRef<Path>,
{
let base_reader = PdfReader::open(base_path.as_ref())
.map_err(|e| OperationError::ParseError(format!("Failed to open base PDF: {e}")))?;
let base_doc = PdfDocument::new(base_reader);
let overlay_reader = PdfReader::open(overlay_path.as_ref())
.map_err(|e| OperationError::ParseError(format!("Failed to open overlay PDF: {e}")))?;
let overlay_doc = PdfDocument::new(overlay_reader);
let overlay_applicator = PdfOverlay::new(base_doc, overlay_doc);
let mut doc = overlay_applicator.apply(&options)?;
doc.save(output_path)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_overlay_options_default() {
let opts = OverlayOptions::default();
assert_eq!(opts.opacity, 1.0);
assert_eq!(opts.scale, 1.0);
assert!(!opts.repeat);
assert!(matches!(opts.position, OverlayPosition::Center));
assert!(matches!(opts.pages, PageRange::All));
}
#[test]
fn test_overlay_options_validate_ok() {
let opts = OverlayOptions::default();
assert!(opts.validate().is_ok());
}
#[test]
fn test_overlay_options_validate_zero_scale() {
let opts = OverlayOptions {
scale: 0.0,
..Default::default()
};
assert!(opts.validate().is_err());
}
#[test]
fn test_overlay_options_validate_negative_scale() {
let opts = OverlayOptions {
scale: -1.0,
..Default::default()
};
assert!(opts.validate().is_err());
}
#[test]
fn test_overlay_options_validate_high_opacity_ok() {
let opts = OverlayOptions {
opacity: 2.5,
..Default::default()
};
assert!(opts.validate().is_ok());
assert_eq!(opts.clamped_opacity(), 1.0);
}
#[test]
fn test_overlay_options_clamped_opacity() {
assert_eq!(
OverlayOptions {
opacity: -0.5,
..Default::default()
}
.clamped_opacity(),
0.0
);
assert_eq!(
OverlayOptions {
opacity: 0.5,
..Default::default()
}
.clamped_opacity(),
0.5
);
assert_eq!(
OverlayOptions {
opacity: 3.0,
..Default::default()
}
.clamped_opacity(),
1.0
);
}
#[test]
fn test_compute_ctm_center_same_size() {
let ctm = compute_ctm(595.0, 842.0, 595.0, 842.0, 1.0, &OverlayPosition::Center);
assert_eq!(ctm[0], 1.0);
assert_eq!(ctm[3], 1.0);
assert!((ctm[4] - 0.0).abs() < 0.001);
assert!((ctm[5] - 0.0).abs() < 0.001);
}
#[test]
fn test_compute_ctm_center_different_sizes() {
let ctm = compute_ctm(595.0, 842.0, 200.0, 200.0, 1.0, &OverlayPosition::Center);
assert!((ctm[4] - 197.5).abs() < 0.001);
assert!((ctm[5] - 321.0).abs() < 0.001);
}
#[test]
fn test_compute_ctm_with_scale() {
let ctm = compute_ctm(595.0, 842.0, 595.0, 842.0, 0.5, &OverlayPosition::Center);
assert!((ctm[0] - 0.5).abs() < 0.001);
assert!((ctm[3] - 0.5).abs() < 0.001);
assert!((ctm[4] - 148.75).abs() < 0.001);
assert!((ctm[5] - 210.5).abs() < 0.001);
}
#[test]
fn test_compute_ctm_bottom_left() {
let ctm = compute_ctm(
595.0,
842.0,
200.0,
200.0,
1.0,
&OverlayPosition::BottomLeft,
);
assert!((ctm[4]).abs() < 0.001);
assert!((ctm[5]).abs() < 0.001);
}
#[test]
fn test_compute_ctm_bottom_right() {
let ctm = compute_ctm(
595.0,
842.0,
200.0,
200.0,
1.0,
&OverlayPosition::BottomRight,
);
assert!((ctm[4] - 395.0).abs() < 0.001);
assert!((ctm[5]).abs() < 0.001);
}
#[test]
fn test_compute_ctm_top_left() {
let ctm = compute_ctm(595.0, 842.0, 200.0, 200.0, 1.0, &OverlayPosition::TopLeft);
assert!((ctm[4]).abs() < 0.001);
assert!((ctm[5] - 642.0).abs() < 0.001);
}
#[test]
fn test_compute_ctm_top_right() {
let ctm = compute_ctm(595.0, 842.0, 200.0, 200.0, 1.0, &OverlayPosition::TopRight);
assert!((ctm[4] - 395.0).abs() < 0.001);
assert!((ctm[5] - 642.0).abs() < 0.001);
}
#[test]
fn test_compute_ctm_custom_position() {
let ctm = compute_ctm(
595.0,
842.0,
200.0,
200.0,
1.0,
&OverlayPosition::Custom(100.0, 150.0),
);
assert!((ctm[4] - 100.0).abs() < 0.001);
assert!((ctm[5] - 150.0).abs() < 0.001);
}
#[test]
fn test_overlay_position_default() {
assert_eq!(OverlayPosition::default(), OverlayPosition::Center);
}
#[test]
fn test_overlay_position_equality() {
assert_eq!(OverlayPosition::Center, OverlayPosition::Center);
assert_eq!(
OverlayPosition::Custom(1.0, 2.0),
OverlayPosition::Custom(1.0, 2.0)
);
assert_ne!(OverlayPosition::Center, OverlayPosition::TopLeft);
}
#[test]
fn test_unresolvable_reference_degrades_to_null() {
use crate::objects::Object as WObj;
use crate::parser::objects::{PdfDictionary, PdfName, PdfObject as PObj};
let mut dict = PdfDictionary::new();
dict.0
.insert(PdfName::new("SMask".to_string()), PObj::Reference(99999, 0));
dict.0
.insert(PdfName::new("Width".to_string()), PObj::Integer(100));
let mut doc_builder = crate::Document::new();
let page = crate::Page::a4();
doc_builder.add_page(page);
let pdf_bytes = doc_builder.to_bytes().unwrap();
let reader = crate::parser::PdfReader::new(std::io::Cursor::new(pdf_bytes)).unwrap();
let pdf_doc = crate::parser::PdfDocument::new(reader);
let result = convert_parser_dict_to_objects_dict(&dict, &pdf_doc);
let smask_key = "SMask";
let smask_val = result.get(smask_key);
assert!(
matches!(smask_val, Some(WObj::Null)),
"Unresolvable reference should become Null, got: {:?}",
smask_val
);
let width_val = result.get("Width");
assert!(
matches!(width_val, Some(WObj::Integer(100))),
"Normal integer should convert, got: {:?}",
width_val
);
}
}