use kurbo::{Affine, Point, Rect};
use pdfrum_common::{Diagnostics, PageIndex};
use pdfrum_object::{Name, Resolve};
use pdfrum_page::{BuildContext, PageObject};
use pdfrum_parser::PageDict;
use crate::edit::transform_object;
use crate::page::build_graph;
use crate::{
Color, DocEdit, EmbeddedImage, ImageBuilder, PageEdit, Result, StandardFont, TextBuilder,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum StampPosition {
#[default]
Center,
TopLeft,
TopRight,
BottomLeft,
BottomRight,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("not a stamp position: {0}")]
pub struct UnknownStampPosition(String);
impl std::fmt::Display for StampPosition {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
StampPosition::Center => "center",
StampPosition::TopLeft => "top-left",
StampPosition::TopRight => "top-right",
StampPosition::BottomLeft => "bottom-left",
StampPosition::BottomRight => "bottom-right",
})
}
}
impl std::str::FromStr for StampPosition {
type Err = UnknownStampPosition;
fn from_str(s: &str) -> core::result::Result<StampPosition, UnknownStampPosition> {
match s {
"center" => Ok(StampPosition::Center),
"top-left" => Ok(StampPosition::TopLeft),
"top-right" => Ok(StampPosition::TopRight),
"bottom-left" => Ok(StampPosition::BottomLeft),
"bottom-right" => Ok(StampPosition::BottomRight),
other => Err(UnknownStampPosition(other.to_owned())),
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct StampOptions {
pub position: StampPosition,
pub margin: f64,
pub angle: f64,
pub opacity: f32,
pub font: StandardFont,
pub font_size: f32,
pub color: Color,
}
impl Default for StampOptions {
fn default() -> Self {
Self {
position: StampPosition::Center,
margin: 36.0,
angle: 0.0,
opacity: 1.0,
font: StandardFont::Helvetica,
font_size: 36.0,
color: Color::BLACK,
}
}
}
#[derive(Debug, Clone, PartialEq, Default)]
#[must_use]
pub struct StampOptionsBuilder(StampOptions);
impl StampOptionsBuilder {
pub fn position(mut self, position: StampPosition) -> Self {
self.0.position = position;
self
}
pub fn margin(mut self, margin: f64) -> Self {
self.0.margin = margin;
self
}
pub fn angle(mut self, angle: f64) -> Self {
self.0.angle = angle;
self
}
pub fn opacity(mut self, opacity: f32) -> Self {
self.0.opacity = opacity;
self
}
pub fn font(mut self, font: StandardFont) -> Self {
self.0.font = font;
self
}
pub fn font_size(mut self, size: f32) -> Self {
self.0.font_size = size;
self
}
pub fn color(mut self, color: Color) -> Self {
self.0.color = color;
self
}
#[must_use]
pub fn build(self) -> StampOptions {
self.0
}
}
impl StampOptions {
pub fn builder() -> StampOptionsBuilder {
StampOptionsBuilder::default()
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
struct Placement {
center: Point,
angle: f64,
}
struct SessionPage {
edit: PageEdit,
crop: Rect,
rotate: u32,
}
impl Placement {
fn of(crop: Rect, rotate: u32, width: f64, height: f64, options: &StampOptions) -> Self {
let (shown_width, shown_height) = if rotate.is_multiple_of(180) {
(crop.width(), crop.height())
} else {
(crop.height(), crop.width())
};
let margin = options.margin;
let shown = match options.position {
StampPosition::Center => Point::new(shown_width / 2.0, shown_height / 2.0),
StampPosition::TopLeft => {
Point::new(margin + width / 2.0, shown_height - margin - height / 2.0)
}
StampPosition::TopRight => Point::new(
shown_width - margin - width / 2.0,
shown_height - margin - height / 2.0,
),
StampPosition::BottomLeft => Point::new(margin + width / 2.0, margin + height / 2.0),
StampPosition::BottomRight => {
Point::new(shown_width - margin - width / 2.0, margin + height / 2.0)
}
};
let center = match rotate {
90 => Point::new(crop.x1 - shown.y, crop.y0 + shown.x),
180 => Point::new(crop.x1 - shown.x, crop.y1 - shown.y),
270 => Point::new(crop.x0 + shown.y, crop.y1 - shown.x),
_ => Point::new(crop.x0 + shown.x, crop.y0 + shown.y),
};
Self {
center,
angle: options.angle + f64::from(rotate),
}
}
fn rotation(&self) -> Affine {
if self.angle == 0.0 {
return Affine::IDENTITY;
}
let about = self.center.to_vec2();
Affine::translate(about)
* Affine::rotate(self.angle.to_radians())
* Affine::translate(-about)
}
}
fn with_opacity(color: Color, opacity: f32) -> Color {
let [r, g, b, a] = color.components;
Color::new([r, g, b, a * opacity.clamp(0.0, 1.0)])
}
struct TextExtent {
width: f64,
ascent: f64,
descent: f64,
}
impl DocEdit<'_> {
pub fn stamp_text(&mut self, text: &str, options: &StampOptions) -> Result<()> {
let font = self.inner.standard_font(options.font)?;
let codes = font.encode(text);
let extent = self.text_extent(font.object(), &codes, options);
let height = extent.ascent - extent.descent;
let shared = pdfrum_edit::shared_objects(&self.inner);
for index in 0..self.doc.page_count() {
let Some(mut page) = self.session_page(index.into())? else {
continue;
};
let place = Placement::of(page.crop, page.rotate, extent.width, height, options);
let baseline = Point::new(
place.center.x - extent.width / 2.0,
place.center.y - height / 2.0 - extent.descent,
);
let mut object = TextBuilder {
position: baseline,
fill: with_opacity(options.color, options.opacity),
..TextBuilder::new(codes.clone(), font.object(), options.font_size)
}
.build();
transform_object(&mut object, place.rotation());
page.edit.push(object);
self.apply_page(&page.edit, &shared)?;
}
Ok(())
}
pub fn stamp_image(
&mut self,
image: &EmbeddedImage,
width: f64,
options: &StampOptions,
) -> Result<()> {
if !width.is_finite() || width <= 0.0 || image.width() == 0 {
return Err(pdfrum_edit::Error::EmptyImage.into());
}
let height = width * f64::from(image.height()) / f64::from(image.width());
let shared = pdfrum_edit::shared_objects(&self.inner);
for index in 0..self.doc.page_count() {
let Some(mut page) = self.session_page(index.into())? else {
continue;
};
let place = Placement::of(page.crop, page.rotate, width, height, options);
let rect = Rect::from_center_size(place.center, (width, height));
let mut object = ImageBuilder::at(image.object(), rect).build();
if let PageObject::Image(content) = &mut object {
content.state.general.fill_alpha = options.opacity.clamp(0.0, 1.0);
}
transform_object(&mut object, place.rotation());
page.edit.push(object);
self.apply_page(&page.edit, &shared)?;
}
Ok(())
}
fn session_page(&self, index: PageIndex) -> Result<Option<SessionPage>> {
let Some((reference, dict, _)) = self.page_state(index)? else {
return Ok(None);
};
let page = PageDict {
dict,
reference: Some(reference),
};
let mut diags = Diagnostics::default();
let (_, crop) = pdfrum_page::derive_boxes(
&page.dict,
|key| page.inherited(key, &self.inner),
&self.inner,
&mut diags,
);
let rotate = pdfrum_page::Rotation::from_degrees(
page.inherited(&Name::from("Rotate"), &self.inner)
.as_ref()
.and_then(pdfrum_object::Object::as_int)
.unwrap_or(0),
);
self.doc.note(&diags);
let graph = build_graph(self.doc, &page, &self.inner, &mut BuildContext::new());
Ok(Some(SessionPage {
edit: PageEdit { index, page: graph },
crop,
rotate: rotate.degrees(),
}))
}
fn text_extent(
&self,
font: pdfrum_object::ObjRef,
codes: &[u8],
options: &StampOptions,
) -> TextExtent {
let size = f64::from(options.font_size);
let mut diags = Diagnostics::default();
let loaded = self
.inner
.fetch(font)
.ok()
.as_deref()
.and_then(pdfrum_object::Object::as_dict)
.and_then(|dict| {
pdfrum_font::load(
dict,
&self.inner,
&pdfrum_font::FontCache::new(),
&self.doc.limits,
&mut diags,
)
});
let per_em = |units: f32| f64::from(units) / 1000.0 * size;
match loaded {
Some(metrics) if metrics.ascent() > 0.0 => TextExtent {
width: per_em(metrics.string_width(codes)),
ascent: per_em(metrics.ascent()),
descent: per_em(metrics.descent()),
},
_ => TextExtent {
width: 0.5 * size * f64::from(u32::try_from(codes.len()).unwrap_or(u32::MAX)),
ascent: 0.75 * size,
descent: -0.25 * size,
},
}
}
}