use kurbo::{Point, Rect};
use pdfrum_common::{Diagnostics, PageIndex};
use pdfrum_doc::{AnnotFlags, Subtype, vt::Alignment};
use pdfrum_object::{
Array, ByteSpan, Dict, Name, ObjRef, Object, PdfString, Resolve, Stream, encode_text,
};
use peniko::Color;
use crate::doc::EditDoc;
use crate::error::Error;
use crate::names;
type Result<T> = core::result::Result<T, Error>;
const FLAG_PRINT: i64 = 4;
pub const DEFAULT_DA: &str = "0 0 0 rg /Helvetica 12 Tf";
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Quad {
pub top_left: Point,
pub top_right: Point,
pub bottom_left: Point,
pub bottom_right: Point,
}
impl Quad {
#[must_use]
pub fn from_rect(rect: Rect) -> Self {
let rect = rect.abs();
Self {
top_left: Point::new(rect.x0, rect.y1),
top_right: Point::new(rect.x1, rect.y1),
bottom_left: Point::new(rect.x0, rect.y0),
bottom_right: Point::new(rect.x1, rect.y0),
}
}
}
impl From<Rect> for Quad {
fn from(rect: Rect) -> Self {
Self::from_rect(rect)
}
}
pub type AnnotBorderStyle = pdfrum_doc::ap::BorderStyle;
pub trait BorderStyleName {
fn as_bytes(&self) -> &'static [u8];
}
impl BorderStyleName for AnnotBorderStyle {
fn as_bytes(&self) -> &'static [u8] {
match self {
Self::Solid => b"S",
Self::Dash => b"D",
Self::Beveled => b"B",
Self::Inset => b"I",
Self::Underline => b"U",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
#[non_exhaustive]
pub enum LineEndingStyle {
#[default]
None,
Square,
Circle,
Diamond,
OpenArrow,
ClosedArrow,
Butt,
ROpenArrow,
RClosedArrow,
Slash,
}
impl LineEndingStyle {
#[must_use]
pub const fn as_bytes(self) -> &'static [u8] {
match self {
Self::None => b"None",
Self::Square => b"Square",
Self::Circle => b"Circle",
Self::Diamond => b"Diamond",
Self::OpenArrow => b"OpenArrow",
Self::ClosedArrow => b"ClosedArrow",
Self::Butt => b"Butt",
Self::ROpenArrow => b"ROpenArrow",
Self::RClosedArrow => b"RClosedArrow",
Self::Slash => b"Slash",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub struct AnnotBorder {
pub width: f32,
pub style: AnnotBorderStyle,
pub dash: Option<[i64; 3]>,
}
impl Default for AnnotBorder {
fn default() -> Self {
Self {
width: 2.0,
style: AnnotBorderStyle::Solid,
dash: None,
}
}
}
impl AnnotBorder {
#[must_use]
pub fn solid(width: f32) -> Self {
Self {
width,
style: AnnotBorderStyle::Solid,
dash: None,
}
}
#[must_use]
pub fn with_style(mut self, style: AnnotBorderStyle) -> Self {
self.style = style;
self
}
#[must_use]
pub fn with_dash(mut self, dash: [i64; 3]) -> Self {
self.dash = Some(dash);
self
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub enum AnnotGoToView {
Fit,
Xyz {
left: Option<f32>,
top: Option<f32>,
zoom: Option<f32>,
},
FitH {
top: Option<f32>,
},
FitV {
left: Option<f32>,
},
FitR {
left: f32,
bottom: f32,
right: f32,
top: f32,
},
FitB,
FitBH {
top: Option<f32>,
},
FitBV {
left: Option<f32>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
#[non_exhaustive]
pub enum AnnotLinkHighlight {
None,
#[default]
Invert,
Outline,
Push,
}
impl AnnotLinkHighlight {
#[must_use]
pub const fn as_bytes(self) -> &'static [u8] {
match self {
Self::None => b"N",
Self::Invert => b"I",
Self::Outline => b"O",
Self::Push => b"P",
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum AnnotRemoteDest {
Page {
page: i64,
view: AnnotGoToView,
},
Named(String),
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum AnnotLinkAction {
Uri(String),
GoTo {
page: pdfrum_object::ObjRef,
view: AnnotGoToView,
},
Named {
name: String,
page: pdfrum_object::ObjRef,
view: AnnotGoToView,
},
NamedExisting {
name: String,
},
GoToR {
file: String,
dest: AnnotRemoteDest,
new_window: Option<bool>,
},
Launch {
file: String,
},
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum AnnotSpec {
#[non_exhaustive]
Highlight {
rect: Rect,
color: Color,
quads: Vec<Quad>,
contents: Option<String>,
},
#[non_exhaustive]
Text {
rect: Rect,
color: Color,
contents: Option<String>,
icon: Name,
open: bool,
},
#[non_exhaustive]
Square {
rect: Rect,
color: Color,
contents: Option<String>,
border: AnnotBorder,
interior: Option<Color>,
},
#[non_exhaustive]
Underline {
rect: Rect,
color: Color,
quads: Vec<Quad>,
contents: Option<String>,
},
#[non_exhaustive]
StrikeOut {
rect: Rect,
color: Color,
quads: Vec<Quad>,
contents: Option<String>,
},
#[non_exhaustive]
Squiggly {
rect: Rect,
color: Color,
quads: Vec<Quad>,
contents: Option<String>,
},
#[non_exhaustive]
Ink {
rect: Rect,
color: Color,
strokes: Vec<Vec<Point>>,
contents: Option<String>,
border: AnnotBorder,
},
#[non_exhaustive]
FreeText {
rect: Rect,
color: Color,
contents: String,
da: String,
align: Option<Alignment>,
},
#[non_exhaustive]
Circle {
rect: Rect,
color: Color,
contents: Option<String>,
border: AnnotBorder,
interior: Option<Color>,
},
#[non_exhaustive]
Line {
rect: Rect,
color: Color,
start: Point,
end: Point,
contents: Option<String>,
border: AnnotBorder,
line_endings: Option<(LineEndingStyle, LineEndingStyle)>,
interior: Option<Color>,
},
#[non_exhaustive]
Link {
rect: Rect,
action: AnnotLinkAction,
contents: Option<String>,
color: Option<Color>,
border: AnnotBorder,
highlight: AnnotLinkHighlight,
},
#[non_exhaustive]
Caret {
rect: Rect,
color: Color,
contents: Option<String>,
},
}
impl AnnotSpec {
#[must_use]
#[allow(
clippy::too_many_lines,
reason = "one arm per AnnotSpec variant; stays exhaustive as subtypes grow"
)]
pub fn with_contents(self, contents: impl Into<String>) -> Self {
let contents = Some(contents.into());
match self {
Self::Highlight {
rect, color, quads, ..
} => Self::Highlight {
rect,
color,
quads,
contents,
},
Self::Text {
rect,
color,
icon,
open,
..
} => Self::Text {
rect,
color,
contents,
icon,
open,
},
Self::Square {
rect,
color,
border,
interior,
..
} => Self::Square {
rect,
color,
contents,
border,
interior,
},
Self::Underline {
rect, color, quads, ..
} => Self::Underline {
rect,
color,
quads,
contents,
},
Self::StrikeOut {
rect, color, quads, ..
} => Self::StrikeOut {
rect,
color,
quads,
contents,
},
Self::Squiggly {
rect, color, quads, ..
} => Self::Squiggly {
rect,
color,
quads,
contents,
},
Self::Ink {
rect,
color,
strokes,
border,
..
} => Self::Ink {
rect,
color,
strokes,
contents,
border,
},
Self::Circle {
rect,
color,
border,
interior,
..
} => Self::Circle {
rect,
color,
contents,
border,
interior,
},
Self::Line {
rect,
color,
start,
end,
border,
line_endings,
interior,
..
} => Self::Line {
rect,
color,
start,
end,
contents,
border,
line_endings,
interior,
},
Self::Link {
rect,
action,
color,
border,
highlight,
..
} => Self::Link {
rect,
action,
contents,
color,
border,
highlight,
},
Self::Caret { rect, color, .. } => Self::Caret {
rect,
color,
contents,
},
other @ Self::FreeText { .. } => other,
}
}
#[must_use]
pub fn with_meta(self, meta: AnnotMeta) -> AnnotWrite {
AnnotWrite { spec: self, meta }
}
#[must_use]
pub fn with_author(self, author: impl Into<String>) -> AnnotWrite {
self.with_meta(AnnotMeta::default().with_author(author))
}
#[must_use]
pub fn with_name(self, name: impl Into<String>) -> AnnotWrite {
self.with_meta(AnnotMeta::default().with_name(name))
}
#[must_use]
pub fn with_modified(self, modified: impl Into<String>) -> AnnotWrite {
self.with_meta(AnnotMeta::default().with_modified(modified))
}
#[must_use]
pub fn with_flags(self, flags: AnnotFlags) -> AnnotWrite {
self.with_meta(AnnotMeta::default().with_flags(flags))
}
#[must_use]
pub fn with_opacity(self, opacity: f32) -> AnnotWrite {
self.with_meta(AnnotMeta::default().with_opacity(opacity))
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct AnnotMeta {
pub author: Option<String>,
pub name: Option<String>,
pub modified: Option<String>,
pub flags: Option<AnnotFlags>,
pub opacity: Option<f32>,
}
impl AnnotMeta {
#[must_use]
pub fn with_author(mut self, author: impl Into<String>) -> Self {
self.author = Some(author.into());
self
}
#[must_use]
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
#[must_use]
pub fn with_modified(mut self, modified: impl Into<String>) -> Self {
self.modified = Some(modified.into());
self
}
#[must_use]
pub fn with_flags(mut self, flags: AnnotFlags) -> Self {
self.flags = Some(flags);
self
}
#[must_use]
pub fn with_opacity(mut self, opacity: f32) -> Self {
self.opacity = Some(opacity);
self
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct AnnotWrite {
pub spec: AnnotSpec,
pub meta: AnnotMeta,
}
impl From<AnnotSpec> for AnnotWrite {
fn from(spec: AnnotSpec) -> Self {
Self {
spec,
meta: AnnotMeta::default(),
}
}
}
impl AnnotWrite {
#[must_use]
pub fn with_contents(mut self, contents: impl Into<String>) -> Self {
self.spec = self.spec.with_contents(contents);
self
}
#[must_use]
pub fn with_author(mut self, author: impl Into<String>) -> Self {
self.meta.author = Some(author.into());
self
}
#[must_use]
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.meta.name = Some(name.into());
self
}
#[must_use]
pub fn with_modified(mut self, modified: impl Into<String>) -> Self {
self.meta.modified = Some(modified.into());
self
}
#[must_use]
pub fn with_meta(mut self, meta: AnnotMeta) -> Self {
self.meta = meta;
self
}
#[must_use]
pub fn with_flags(mut self, flags: AnnotFlags) -> Self {
self.meta.flags = Some(flags);
self
}
#[must_use]
pub fn with_opacity(mut self, opacity: f32) -> Self {
self.meta.opacity = Some(opacity);
self
}
#[must_use]
pub fn contents(self, contents: impl Into<String>) -> Self {
self.with_contents(contents)
}
#[must_use]
pub fn author(self, author: impl Into<String>) -> Self {
self.with_author(author)
}
#[must_use]
pub fn name(self, name: impl Into<String>) -> Self {
self.with_name(name)
}
#[must_use]
pub fn modified(self, modified: impl Into<String>) -> Self {
self.with_modified(modified)
}
#[must_use]
pub fn flags(self, flags: AnnotFlags) -> Self {
self.with_flags(flags)
}
#[must_use]
pub fn meta(self, meta: AnnotMeta) -> Self {
self.with_meta(meta)
}
}
pub fn add_annotation(
edit: &mut EditDoc<'_>,
page: impl Into<PageIndex>,
write: impl Into<AnnotWrite>,
) -> Result<ObjRef> {
let AnnotWrite { spec, meta } = write.into();
let page = page.into();
let Some((page_ref, mut page_dict, _)) = edit.page_state(page)? else {
return Err(Error::InlinePage(page));
};
register_named_dest_from_spec(edit, &spec)?;
let mut dict = build_dict(spec, page_ref)?;
apply_meta(&mut dict, &meta);
attach_appearance(edit, &mut dict);
let annot_ref = edit.add(Object::Dict(dict));
attach_to_page(edit, page_ref, &mut page_dict, annot_ref);
Ok(annot_ref)
}
pub fn update_annotation(
edit: &mut EditDoc<'_>,
page: impl Into<PageIndex>,
annot: ObjRef,
write: impl Into<AnnotWrite>,
) -> Result<()> {
let AnnotWrite { spec, meta } = write.into();
let page = page.into();
let Some((page_ref, page_dict, _)) = edit.page_state(page)? else {
return Err(Error::InlinePage(page));
};
if !page_lists_annot(edit, &page_dict, annot) {
return Err(Error::AnnotNotOnPage(annot, page));
}
register_named_dest_from_spec(edit, &spec)?;
let mut dict = build_dict(spec, page_ref)?;
apply_meta(&mut dict, &meta);
attach_appearance(edit, &mut dict);
edit.replace(annot, Object::Dict(dict));
Ok(())
}
pub fn delete_annotation(
edit: &mut EditDoc<'_>,
page: impl Into<PageIndex>,
annot: ObjRef,
) -> Result<bool> {
let page = page.into();
let Some((page_ref, mut page_dict, _)) = edit.page_state(page)? else {
return Err(Error::InlinePage(page));
};
if !detach_from_page(edit, page_ref, &mut page_dict, annot) {
return Ok(false);
}
edit.remove(annot);
Ok(true)
}
pub fn update_annotation_at(
edit: &mut EditDoc<'_>,
page: impl Into<PageIndex>,
index: usize,
write: impl Into<AnnotWrite>,
) -> Result<()> {
let page = page.into();
let annot = ensure_annot_ref_at(edit, page, index)?;
update_annotation(edit, page, annot, write)
}
pub fn delete_annotation_at(
edit: &mut EditDoc<'_>,
page: impl Into<PageIndex>,
index: usize,
) -> Result<bool> {
let page = page.into();
let Some((page_ref, mut page_dict, _)) = edit.page_state(page)? else {
return Err(Error::InlinePage(page));
};
match annots_entry_at(edit, &page_dict, page, index)? {
Object::Ref(annot) => delete_annotation(edit, page, annot),
Object::Dict(_) => Ok(remove_annots_index(edit, page_ref, &mut page_dict, index)),
_ => Err(Error::AnnotIndexOutOfRange(index, page)),
}
}
fn ensure_annot_ref_at(edit: &mut EditDoc<'_>, page: PageIndex, index: usize) -> Result<ObjRef> {
let Some((page_ref, mut page_dict, _)) = edit.page_state(page)? else {
return Err(Error::InlinePage(page));
};
match annots_entry_at(edit, &page_dict, page, index)? {
Object::Ref(annot) => Ok(annot),
Object::Dict(dict) => {
let annot = edit.add(Object::Dict(dict));
replace_annots_index(
edit,
page_ref,
&mut page_dict,
page,
index,
Object::Ref(annot),
)?;
Ok(annot)
}
_ => Err(Error::AnnotIndexOutOfRange(index, page)),
}
}
fn annots_entry_at(
edit: &EditDoc<'_>,
page_dict: &Dict,
page: PageIndex,
index: usize,
) -> Result<Object> {
let array = annots_array(edit, page_dict).ok_or(Error::AnnotIndexOutOfRange(index, page))?;
array
.raw_at(index)
.cloned()
.ok_or(Error::AnnotIndexOutOfRange(index, page))
}
fn annots_array(edit: &EditDoc<'_>, page_dict: &Dict) -> Option<Array> {
match page_dict.raw(names::ANNOTS) {
Some(Object::Ref(array_ref)) => edit
.fetch(*array_ref)
.ok()
.as_deref()
.and_then(Object::as_array)
.cloned(),
Some(Object::Array(array)) => Some(array.clone()),
_ => None,
}
}
fn replace_annots_index(
edit: &mut EditDoc<'_>,
page_ref: ObjRef,
page_dict: &mut Dict,
page: PageIndex,
index: usize,
value: Object,
) -> Result<()> {
let annots_key = names::ANNOTS.clone();
match page_dict.raw(&annots_key).cloned() {
Some(Object::Ref(array_ref)) => {
let mut array = edit
.fetch(array_ref)
.ok()
.as_deref()
.and_then(Object::as_array)
.cloned()
.ok_or(Error::AnnotIndexOutOfRange(index, page))?;
if index >= array.len() {
return Err(Error::AnnotIndexOutOfRange(index, page));
}
array.remove(index);
array.insert(index, value);
edit.replace(array_ref, Object::Array(array));
Ok(())
}
Some(Object::Array(mut array)) => {
if index >= array.len() {
return Err(Error::AnnotIndexOutOfRange(index, page));
}
array.remove(index);
array.insert(index, value);
page_dict.insert(annots_key, Object::Array(array));
edit.replace(page_ref, Object::Dict(page_dict.clone()));
Ok(())
}
_ => Err(Error::AnnotIndexOutOfRange(index, page)),
}
}
fn remove_annots_index(
edit: &mut EditDoc<'_>,
page_ref: ObjRef,
page_dict: &mut Dict,
index: usize,
) -> bool {
let annots_key = names::ANNOTS.clone();
match page_dict.raw(&annots_key).cloned() {
Some(Object::Ref(array_ref)) => {
let Some(mut array) = edit
.fetch(array_ref)
.ok()
.as_deref()
.and_then(Object::as_array)
.cloned()
else {
return false;
};
if array.remove(index).is_none() {
return false;
}
edit.replace(array_ref, Object::Array(array));
true
}
Some(Object::Array(mut array)) => {
if array.remove(index).is_none() {
return false;
}
page_dict.insert(annots_key, Object::Array(array));
edit.replace(page_ref, Object::Dict(page_dict.clone()));
true
}
_ => false,
}
}
fn apply_meta(dict: &mut Dict, meta: &AnnotMeta) {
if let Some(author) = meta.author.as_deref().filter(|s| !s.is_empty()) {
dict.insert(names::T.clone(), Object::Str(pdf_string(author)));
}
if let Some(name) = meta.name.as_deref().filter(|s| !s.is_empty()) {
dict.insert(names::NM.clone(), Object::Str(pdf_string(name)));
}
if let Some(modified) = meta.modified.as_deref().filter(|s| !s.is_empty()) {
dict.insert(names::M.clone(), Object::Str(pdf_string(modified)));
}
let flags = meta.flags.unwrap_or(AnnotFlags::PRINT);
dict.insert(names::F.clone(), Object::Int(flags.bits()));
if let Some(opacity) = meta.opacity {
dict.insert(Name::from("CA"), Object::Real(opacity.clamp(0.0, 1.0)));
}
}
fn attach_appearance(edit: &mut EditDoc<'_>, dict: &mut Dict) {
let catalog = edit.base().catalog().unwrap_or_default();
let mut build = pdfrum_page::BuildContext::new();
let fonts = pdfrum_doc::ap::FormFonts::load(&catalog, edit, &mut build);
let mut diags = Diagnostics::default();
let page = Dict::from_pairs([(
Name::from("Annots"),
Object::Array(Array::of([Object::Dict(dict.clone())])),
)]);
let overlay = pdfrum_doc::ap::generate_appearances_with_text(
&page,
&catalog,
Some(&fonts),
edit,
&mut diags,
);
let Some(generated) = overlay.get(0) else {
return;
};
if generated.stream.is_empty() {
return;
}
if let Some(rect) = generated.rect_override {
dict.insert(names::RECT.clone(), rect_object(rect));
}
let stream = Stream::new(
pdfrum_doc::ap::stream_dict(generated),
ByteSpan::from(generated.stream.clone()),
);
let ap_ref = edit.add(Object::Stream(Box::new(stream)));
let mut ap = Dict::new();
ap.insert(Name::from("N"), Object::Ref(ap_ref));
dict.insert(Name::from("AP"), Object::Dict(ap));
}
pub fn set_named_destination(
edit: &mut EditDoc<'_>,
name: impl Into<String>,
page: ObjRef,
view: AnnotGoToView,
) -> Result<()> {
let name = name.into();
let dest = Object::Array(goto_dest_array(page, view));
crate::dests::upsert_named_dest(edit, &name, dest)
}
pub fn delete_named_destination(edit: &mut EditDoc<'_>, name: &str) -> Result<bool> {
crate::dests::delete_named_dest(edit, name)
}
pub fn named_destinations(edit: &EditDoc<'_>) -> Result<Vec<String>> {
crate::dests::named_dest_names(edit)
}
pub fn ensure_named_destination(
edit: &mut EditDoc<'_>,
name: impl Into<String>,
page: ObjRef,
view: AnnotGoToView,
) -> Result<bool> {
let name = name.into();
let dest = Object::Array(goto_dest_array(page, view));
crate::dests::ensure_named_dest(edit, &name, dest)
}
fn register_named_dest_from_spec(edit: &mut EditDoc<'_>, spec: &AnnotSpec) -> Result<()> {
if let AnnotSpec::Link {
action: AnnotLinkAction::Named { name, page, view },
..
} = spec
{
set_named_destination(edit, name.clone(), *page, *view)?;
}
Ok(())
}
#[allow(
clippy::too_many_lines,
reason = "one arm per AnnotSpec variant; stays exhaustive as subtypes grow"
)]
fn build_dict(spec: AnnotSpec, page_ref: ObjRef) -> Result<Dict> {
match spec {
AnnotSpec::Highlight {
rect,
color,
quads,
contents,
} => markup_dict(
Subtype::Highlight,
rect,
color,
&quads,
contents.as_deref(),
page_ref,
),
AnnotSpec::Underline {
rect,
color,
quads,
contents,
} => markup_dict(
Subtype::Underline,
rect,
color,
&quads,
contents.as_deref(),
page_ref,
),
AnnotSpec::StrikeOut {
rect,
color,
quads,
contents,
} => markup_dict(
Subtype::StrikeOut,
rect,
color,
&quads,
contents.as_deref(),
page_ref,
),
AnnotSpec::Squiggly {
rect,
color,
quads,
contents,
} => markup_dict(
Subtype::Squiggly,
rect,
color,
&quads,
contents.as_deref(),
page_ref,
),
AnnotSpec::Text {
rect,
color,
contents,
icon,
open,
} => {
let mut dict = common(Subtype::Text, rect, color, page_ref);
dict.insert(names::NAME.clone(), Object::Name(icon));
dict.insert(names::OPEN.clone(), Object::Bool(open));
insert_contents(&mut dict, contents.as_deref());
Ok(dict)
}
AnnotSpec::Square {
rect,
color,
contents,
border,
interior,
} => {
let mut dict = common(Subtype::Square, rect, color, page_ref);
dict.insert(
names::BS.clone(),
Object::Dict(border_style_dict(border, true)),
);
if let Some(interior) = interior {
dict.insert(names::IC.clone(), color_object(interior));
}
insert_contents(&mut dict, contents.as_deref());
Ok(dict)
}
AnnotSpec::Ink {
rect,
color,
strokes,
contents,
border,
} => {
let mut dict = common(Subtype::Ink, rect, color, page_ref);
dict.insert(names::INK_LIST.clone(), Object::Array(ink_list(&strokes)));
dict.insert(
names::BS.clone(),
Object::Dict(border_style_dict(border, false)),
);
insert_contents(&mut dict, contents.as_deref());
Ok(dict)
}
AnnotSpec::FreeText {
rect,
color,
contents,
da,
align,
} => {
let mut dict = common(Subtype::FreeText, rect, color, page_ref);
dict.insert(names::CONTENTS.clone(), Object::Str(pdf_string(&contents)));
dict.insert(names::DA.clone(), Object::Str(pdf_string(&da)));
if let Some(align) = align {
dict.insert(names::Q.clone(), Object::Int(align.to_quadding()));
}
Ok(dict)
}
AnnotSpec::Circle {
rect,
color,
contents,
border,
interior,
} => {
let mut dict = common(Subtype::Circle, rect, color, page_ref);
dict.insert(
names::BS.clone(),
Object::Dict(border_style_dict(border, true)),
);
if let Some(interior) = interior {
dict.insert(names::IC.clone(), color_object(interior));
}
insert_contents(&mut dict, contents.as_deref());
Ok(dict)
}
AnnotSpec::Line {
rect,
color,
start,
end,
contents,
border,
line_endings,
interior,
} => {
let mut dict = common(Subtype::Line, rect, color, page_ref);
dict.insert(
names::L.clone(),
Object::Array(Array::of([
Object::Real(as_f32(start.x)),
Object::Real(as_f32(start.y)),
Object::Real(as_f32(end.x)),
Object::Real(as_f32(end.y)),
])),
);
dict.insert(
names::BS.clone(),
Object::Dict(border_style_dict(border, false)),
);
if let Some((start_style, end_style)) = line_endings {
dict.insert(
names::LE.clone(),
Object::Array(Array::of([
Object::Name(Name::from(start_style.as_bytes())),
Object::Name(Name::from(end_style.as_bytes())),
])),
);
}
if let Some(interior) = interior {
dict.insert(names::IC.clone(), color_object(interior));
}
insert_contents(&mut dict, contents.as_deref());
Ok(dict)
}
AnnotSpec::Link {
rect,
action,
contents,
color,
border,
highlight,
} => {
let mut dict = Dict::new();
dict.insert(names::TYPE.clone(), Object::Name(names::ANNOT.clone()));
dict.insert(
names::SUBTYPE.clone(),
Object::Name(Name::from(Subtype::Link.as_bytes())),
);
dict.insert(names::RECT.clone(), rect_object(rect));
dict.insert(names::F.clone(), Object::Int(FLAG_PRINT));
dict.insert(names::P.clone(), Object::Ref(page_ref));
dict.insert(names::A.clone(), Object::Dict(link_action_dict(&action)));
if let Some(color) = color {
dict.insert(names::C.clone(), color_object(color));
}
dict.insert(
names::BS.clone(),
Object::Dict(border_style_dict(border, false)),
);
dict.insert(
Name::from("H"),
Object::Name(Name::from(highlight.as_bytes())),
);
insert_contents(&mut dict, contents.as_deref());
Ok(dict)
}
AnnotSpec::Caret {
rect,
color,
contents,
} => {
let mut dict = common(Subtype::Caret, rect, color, page_ref);
insert_contents(&mut dict, contents.as_deref());
Ok(dict)
}
}
}
fn markup_dict(
subtype: Subtype,
rect: Rect,
color: Color,
quads: &[Quad],
contents: Option<&str>,
page_ref: ObjRef,
) -> Result<Dict> {
if quads.is_empty() {
return Err(Error::EmptyQuadPoints);
}
let mut dict = common(subtype, rect, color, page_ref);
dict.insert(
names::QUAD_POINTS.clone(),
Object::Array(quad_points(quads)),
);
insert_contents(&mut dict, contents);
Ok(dict)
}
fn common(subtype: Subtype, rect: Rect, color: Color, page_ref: ObjRef) -> Dict {
let mut dict = Dict::new();
dict.insert(names::TYPE.clone(), Object::Name(names::ANNOT.clone()));
dict.insert(
names::SUBTYPE.clone(),
Object::Name(Name::from(subtype.as_bytes())),
);
dict.insert(names::RECT.clone(), rect_object(rect));
dict.insert(names::C.clone(), color_object(color));
dict.insert(names::F.clone(), Object::Int(FLAG_PRINT));
dict.insert(names::P.clone(), Object::Ref(page_ref));
dict
}
fn page_lists_annot(edit: &EditDoc<'_>, page_dict: &Dict, annot: ObjRef) -> bool {
match page_dict.raw(names::ANNOTS) {
Some(Object::Ref(array_ref)) => edit
.fetch(*array_ref)
.ok()
.as_deref()
.and_then(Object::as_array)
.is_some_and(|array| array_contains_ref(array, annot)),
Some(Object::Array(array)) => array_contains_ref(array, annot),
_ => false,
}
}
fn array_contains_ref(array: &Array, annot: ObjRef) -> bool {
array
.iter()
.any(|obj| matches!(obj, Object::Ref(r) if *r == annot))
}
fn detach_from_page(
edit: &mut EditDoc<'_>,
page_ref: ObjRef,
page_dict: &mut Dict,
annot_ref: ObjRef,
) -> bool {
let annots_key = names::ANNOTS.clone();
match page_dict.raw(&annots_key).cloned() {
Some(Object::Ref(array_ref)) => {
let Some(array) = edit
.fetch(array_ref)
.ok()
.as_deref()
.and_then(Object::as_array)
.cloned()
else {
return false;
};
let filtered = filter_annot_ref(&array, annot_ref);
if filtered.len() == array.len() {
return false;
}
edit.replace(array_ref, Object::Array(filtered));
true
}
Some(Object::Array(array)) => {
let filtered = filter_annot_ref(&array, annot_ref);
if filtered.len() == array.len() {
return false;
}
page_dict.insert(annots_key, Object::Array(filtered));
edit.replace(page_ref, Object::Dict(page_dict.clone()));
true
}
_ => false,
}
}
fn filter_annot_ref(array: &Array, annot_ref: ObjRef) -> Array {
Array::of(
array
.iter()
.filter(|obj| !matches!(obj, Object::Ref(r) if *r == annot_ref))
.cloned(),
)
}
fn attach_to_page(
edit: &mut EditDoc<'_>,
page_ref: ObjRef,
page_dict: &mut Dict,
annot_ref: ObjRef,
) {
let annots_key = names::ANNOTS.clone();
match page_dict.raw(&annots_key).cloned() {
Some(Object::Ref(array_ref)) => {
let mut array = edit
.fetch(array_ref)
.ok()
.as_deref()
.and_then(Object::as_array)
.cloned()
.unwrap_or_default();
array.push(Object::Ref(annot_ref));
edit.replace(array_ref, Object::Array(array));
}
Some(Object::Array(mut array)) => {
array.push(Object::Ref(annot_ref));
page_dict.insert(annots_key, Object::Array(array));
edit.replace(page_ref, Object::Dict(page_dict.clone()));
}
_ => {
page_dict.insert(
annots_key,
Object::Array(Array::of([Object::Ref(annot_ref)])),
);
edit.replace(page_ref, Object::Dict(page_dict.clone()));
}
}
}
fn insert_contents(dict: &mut Dict, contents: Option<&str>) {
if let Some(text) = contents.filter(|t| !t.is_empty()) {
dict.insert(names::CONTENTS.clone(), Object::Str(pdf_string(text)));
}
}
fn pdf_string(text: &str) -> PdfString {
PdfString::literal(encode_text(text))
}
fn filespec_object(path: &str) -> Object {
let mut dict = Dict::new();
dict.insert(names::TYPE.clone(), Object::Name(names::FILESPEC.clone()));
let s = Object::Str(pdf_string(path));
dict.insert(names::F.clone(), s.clone());
dict.insert(names::UF.clone(), s);
Object::Dict(dict)
}
fn rect_object(rect: Rect) -> Object {
let rect = rect.abs();
Object::Array(Array::of([
Object::Real(as_f32(rect.x0)),
Object::Real(as_f32(rect.y0)),
Object::Real(as_f32(rect.x1)),
Object::Real(as_f32(rect.y1)),
]))
}
fn color_object(color: Color) -> Object {
let [r, g, b, _] = color.components;
Object::Array(Array::of([
Object::Real(r.clamp(0.0, 1.0)),
Object::Real(g.clamp(0.0, 1.0)),
Object::Real(b.clamp(0.0, 1.0)),
]))
}
fn quad_points(quads: &[Quad]) -> Array {
let mut out = Array::new();
for quad in quads {
for point in [
quad.top_left,
quad.top_right,
quad.bottom_left,
quad.bottom_right,
] {
out.push(Object::Real(as_f32(point.x)));
out.push(Object::Real(as_f32(point.y)));
}
}
out
}
fn ink_list(strokes: &[Vec<Point>]) -> Array {
let mut out = Array::new();
for stroke in strokes {
let mut points = Array::new();
for point in stroke {
points.push(Object::Real(as_f32(point.x)));
points.push(Object::Real(as_f32(point.y)));
}
out.push(Object::Array(points));
}
out
}
fn link_action_dict(action: &AnnotLinkAction) -> Dict {
let mut a = Dict::new();
a.insert(names::TYPE.clone(), Object::Name(Name::from("Action")));
match action {
AnnotLinkAction::Uri(uri) => {
a.insert(names::S.clone(), Object::Name(names::URI.clone()));
a.insert(names::URI.clone(), Object::Str(pdf_string(uri)));
}
AnnotLinkAction::GoTo { page, view } => {
a.insert(names::S.clone(), Object::Name(names::GO_TO.clone()));
a.insert(
names::D.clone(),
Object::Array(goto_dest_array(*page, *view)),
);
}
AnnotLinkAction::Named { name, .. } | AnnotLinkAction::NamedExisting { name } => {
a.insert(names::S.clone(), Object::Name(names::GO_TO.clone()));
a.insert(names::D.clone(), Object::Str(pdf_string(name)));
}
AnnotLinkAction::GoToR {
file,
dest,
new_window,
} => {
a.insert(names::S.clone(), Object::Name(names::GO_TO_R.clone()));
a.insert(names::F.clone(), filespec_object(file));
match dest {
AnnotRemoteDest::Page { page, view } => {
a.insert(
names::D.clone(),
Object::Array(remote_goto_dest_array(*page, *view)),
);
}
AnnotRemoteDest::Named(name) => {
a.insert(names::D.clone(), Object::Str(pdf_string(name)));
}
}
if let Some(new_window) = *new_window {
a.insert(names::NEW_WINDOW.clone(), Object::Bool(new_window));
}
}
AnnotLinkAction::Launch { file } => {
a.insert(names::S.clone(), Object::Name(names::LAUNCH.clone()));
a.insert(names::F.clone(), filespec_object(file));
}
}
a
}
pub(crate) fn goto_dest_array(page: pdfrum_object::ObjRef, view: AnnotGoToView) -> Array {
dest_array_with_page(Object::Ref(page), view)
}
fn remote_goto_dest_array(page: i64, view: AnnotGoToView) -> Array {
dest_array_with_page(Object::Int(page), view)
}
fn dest_array_with_page(page: Object, view: AnnotGoToView) -> Array {
match view {
AnnotGoToView::Fit => Array::of([page, Object::Name(names::FIT.clone())]),
AnnotGoToView::Xyz { left, top, zoom } => Array::of([
page,
Object::Name(names::XYZ.clone()),
optional_dest_number(left, false),
optional_dest_number(top, false),
optional_dest_number(zoom, true),
]),
AnnotGoToView::FitH { top } => Array::of([
page,
Object::Name(names::FIT_H.clone()),
optional_dest_number(top, false),
]),
AnnotGoToView::FitV { left } => Array::of([
page,
Object::Name(names::FIT_V.clone()),
optional_dest_number(left, false),
]),
AnnotGoToView::FitR {
left,
bottom,
right,
top,
} => Array::of([
page,
Object::Name(names::FIT_R.clone()),
Object::Real(left),
Object::Real(bottom),
Object::Real(right),
Object::Real(top),
]),
AnnotGoToView::FitB => Array::of([page, Object::Name(names::FIT_B.clone())]),
AnnotGoToView::FitBH { top } => Array::of([
page,
Object::Name(names::FIT_BH.clone()),
optional_dest_number(top, false),
]),
AnnotGoToView::FitBV { left } => Array::of([
page,
Object::Name(names::FIT_BV.clone()),
optional_dest_number(left, false),
]),
}
}
fn optional_dest_number(value: Option<f32>, zero_means_null: bool) -> Object {
match value {
None => Object::Null,
Some(n) if zero_means_null && n == 0.0 => Object::Null,
Some(n) => Object::Real(n),
}
}
fn border_style_dict(border: AnnotBorder, with_type: bool) -> Dict {
let mut bs = Dict::new();
if with_type {
bs.insert(names::TYPE.clone(), Object::Name(names::BORDER.clone()));
}
bs.insert(names::W.clone(), Object::Real(border.width));
bs.insert(
names::S.clone(),
Object::Name(Name::from(border.style.as_bytes())),
);
if let (AnnotBorderStyle::Dash, Some([on, gap, phase])) = (border.style, border.dash) {
bs.insert(
names::D.clone(),
Object::Array(Array::of([
Object::Int(on),
Object::Int(gap),
Object::Int(phase),
])),
);
}
bs
}
#[expect(
clippy::cast_possible_truncation,
reason = "PDF reals are f32; page-space points fit"
)]
fn as_f32(value: f64) -> f32 {
value as f32
}