//! Typed annotation editing. //! //! Phase 8, designed in `REVIEWS/adr/0004-pdf-annotation-editing.md`. //! //! Deliberately the same shape as [`crate::form::DocumentFormEditor`]: typed //! edit commands, validated before anything changes, returning real errors. //! A viewer wiring a drag gesture to `Move` should not have to learn a //! second contract, and a rejected edit must never leave half-applied state. use std::fmt; use nigig_pdf_cos::{ObjRef, PdfDict, PdfObj}; use crate::annotations::{AnnotationFlags, PdfAnnotation}; /// An RGB colour with components in 0.0..=1.0. /// /// PDF annotation colours are `/C` arrays whose length selects the space: /// 1 is grey, 3 is RGB, 4 is CMYK. This normalises to RGB because that is /// what a UI colour picker produces; the length-based forms are read on /// parse and written back as RGB. #[derive(Clone, Copy, Debug, PartialEq)] pub struct AnnotationColor { pub r: f64, pub g: f64, pub b: f64, } impl AnnotationColor { pub fn new(r: f64, g: f64, b: f64) -> Self { Self { r, g, b } } /// Read a `/C` or `/IC` array, converting grey and CMYK to RGB. pub fn from_array(values: &[f64]) -> Option { match values { [grey] => Some(Self::new(*grey, *grey, *grey)), [r, g, b] => Some(Self::new(*r, *g, *b)), [c, m, y, k] => Some(Self::new( (1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k), )), _ => None, } } fn to_obj(self) -> PdfObj { PdfObj::Array(vec![ PdfObj::Real(self.r), PdfObj::Real(self.g), PdfObj::Real(self.b), ]) } /// Whether every component lies in the valid range. fn is_valid(self) -> bool { [self.r, self.g, self.b] .iter() .all(|c| c.is_finite() && (0.0..=1.0).contains(c)) } } /// Why an annotation edit was refused. /// /// Every variant names a condition the caller can act on. None of them is a /// silent no-op. #[derive(Clone, Debug, PartialEq)] pub enum AnnotationError { /// No annotation with that reference is loaded. Unknown(ObjRef), /// The annotation's `/F` marks it read-only. ReadOnly(ObjRef), /// A rectangle with zero or negative extent, or a non-finite value. /// /// Refused rather than normalised: it almost always means a UI bug /// upstream, and silently fixing it hides that (ADR 0004, rule 5). DegenerateRect { got: [f64; 4] }, /// A colour component outside 0.0..=1.0, or not finite. ColorOutOfRange(AnnotationColor), /// A negative or non-finite border width. InvalidBorderWidth(f64), /// An opacity outside 0.0..=1.0. InvalidOpacity(f64), /// The annotation exists only as a direct object, so it has no /// reference to address or to rewrite on save. NotAddressable, } impl fmt::Display for AnnotationError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { AnnotationError::Unknown(r) => write!(f, "no annotation with reference {r}"), AnnotationError::ReadOnly(r) => write!(f, "annotation {r} is read-only"), AnnotationError::DegenerateRect { got } => write!( f, "rectangle [{} {} {} {}] has zero or negative extent", got[0], got[1], got[2], got[3] ), AnnotationError::ColorOutOfRange(c) => { write!(f, "colour ({}, {}, {}) is outside 0..1", c.r, c.g, c.b) } AnnotationError::InvalidBorderWidth(w) => { write!(f, "border width {w} must be finite and not negative") } AnnotationError::InvalidOpacity(o) => { write!(f, "opacity {o} must be between 0 and 1") } AnnotationError::NotAddressable => { write!(f, "annotation is a direct object and cannot be edited") } } } } impl std::error::Error for AnnotationError {} /// A typed edit against one annotation. #[derive(Clone, Debug, PartialEq)] pub enum AnnotationEdit { /// Translate so the rectangle's lower-left corner lands at `to`, /// preserving size. Move { to: (f64, f64), }, /// Replace the rectangle outright. Resize { rect: [f64; 4], }, SetColor(AnnotationColor), /// Interior colour, used by Square, Circle, Line and Polygon. SetInteriorColor(AnnotationColor), SetBorderWidth(f64), SetOpacity(f64), SetContents(String), SetFlags(AnnotationFlags), Delete, } /// One annotation plus the edit state the document owns for it. #[derive(Clone, Debug)] pub struct EditableAnnotation { pub annotation: PdfAnnotation, /// Set once this annotation has been edited in this session. pub dirty: bool, /// Set when the annotation has been deleted; it is kept in the /// collection so the save path knows to drop it from `/Annots`. pub deleted: bool, } impl EditableAnnotation { fn new(annotation: PdfAnnotation) -> Self { Self { annotation, dirty: false, deleted: false, } } /// The annotation's dictionary with every applied edit written into it. pub fn to_dict(&self) -> PdfDict { let mut dict = self.annotation.raw_dict.clone(); dict.set( "Rect", PdfObj::Array( self.annotation .rect .iter() .map(|v| PdfObj::Real(*v)) .collect(), ), ); dict.set( "F", PdfObj::Int(flags_to_bits(&self.annotation.flags) as i64), ); dict } } /// Pack the flag struct back into the `/F` bit field. fn flags_to_bits(flags: &AnnotationFlags) -> u32 { let mut bits = 0u32; if flags.invisible { bits |= 1; } if flags.hidden { bits |= 1 << 1; } if flags.print { bits |= 1 << 2; } if flags.no_zoom { bits |= 1 << 3; } if flags.no_rotate { bits |= 1 << 4; } if flags.no_view { bits |= 1 << 5; } if flags.read_only { bits |= 1 << 6; } if flags.locked { bits |= 1 << 7; } if flags.toggle_no_view { bits |= 1 << 8; } if flags.locked_contents { bits |= 1 << 9; } bits } /// The annotations of one page, owned by the document and editable. #[derive(Clone, Debug, Default)] pub struct PageAnnotations { page_index: usize, items: Vec, } impl PageAnnotations { /// Take ownership of a page's parsed annotations. pub fn new(page_index: usize, annotations: Vec) -> Self { Self { page_index, items: annotations .into_iter() .map(EditableAnnotation::new) .collect(), } } pub fn page_index(&self) -> usize { self.page_index } /// Every annotation that has not been deleted. pub fn live(&self) -> impl Iterator { self.items.iter().filter(|a| !a.deleted) } pub fn len(&self) -> usize { self.live().count() } pub fn is_empty(&self) -> bool { self.len() == 0 } pub fn get(&self, obj_ref: ObjRef) -> Option<&EditableAnnotation> { self.items .iter() .find(|a| a.annotation.obj_ref == Some(obj_ref) && !a.deleted) } /// Annotations changed in this session, including deletions. pub fn dirty(&self) -> impl Iterator { self.items.iter().filter(|a| a.dirty || a.deleted) } /// References of annotations deleted in this session. pub fn deleted_refs(&self) -> Vec { self.items .iter() .filter(|a| a.deleted) .filter_map(|a| a.annotation.obj_ref) .collect() } pub fn has_edits(&self) -> bool { self.items.iter().any(|a| a.dirty || a.deleted) } /// The annotation under a point, topmost last in `/Annots` order. pub fn hit_test(&self, x: f64, y: f64) -> Option<&EditableAnnotation> { // Later entries in /Annots paint over earlier ones, so the last // match is the topmost. `live()` is not double-ended, so collect // the matches and take the last. self.live() .filter(|a| a.annotation.is_visible() && a.annotation.contains_point(x, y)) .last() } } /// Applies typed edits to a page's annotations. pub struct AnnotationEditor<'a> { annotations: &'a mut PageAnnotations, /// Allow editing annotations whose `/F` marks them read-only. /// /// Off by default: the flag is the document author's intent, and an /// editor that ignores it silently is not trustworthy. allow_read_only: bool, } impl<'a> AnnotationEditor<'a> { pub fn new(annotations: &'a mut PageAnnotations) -> Self { Self { annotations, allow_read_only: false, } } /// Permit edits to read-only annotations. An explicit, visible override. pub fn allowing_read_only(mut self) -> Self { self.allow_read_only = true; self } pub fn apply(&mut self, obj_ref: ObjRef, edit: AnnotationEdit) -> Result<(), AnnotationError> { // Validate everything that can be checked without the annotation // first, so an invalid value is refused identically whether or not // the target exists. match &edit { AnnotationEdit::Resize { rect } => validate_rect(*rect)?, AnnotationEdit::Move { to } => { if !to.0.is_finite() || !to.1.is_finite() { return Err(AnnotationError::DegenerateRect { got: [to.0, to.1, to.0, to.1], }); } } AnnotationEdit::SetColor(c) | AnnotationEdit::SetInteriorColor(c) => { if !c.is_valid() { return Err(AnnotationError::ColorOutOfRange(*c)); } } AnnotationEdit::SetBorderWidth(w) => { if !w.is_finite() || *w < 0.0 { return Err(AnnotationError::InvalidBorderWidth(*w)); } } AnnotationEdit::SetOpacity(o) => { if !o.is_finite() || !(0.0..=1.0).contains(o) { return Err(AnnotationError::InvalidOpacity(*o)); } } AnnotationEdit::SetContents(_) | AnnotationEdit::SetFlags(_) | AnnotationEdit::Delete => {} } let allow_read_only = self.allow_read_only; let index = self .annotations .items .iter() .position(|a| a.annotation.obj_ref == Some(obj_ref) && !a.deleted) .ok_or(AnnotationError::Unknown(obj_ref))?; { let target = &self.annotations.items[index]; if target.annotation.obj_ref.is_none() { return Err(AnnotationError::NotAddressable); } // A read-only annotation refuses everything except an explicit // flag change that clears the flag itself. if target.annotation.flags.read_only && !allow_read_only { let clearing = matches!( &edit, AnnotationEdit::SetFlags(f) if !f.read_only ); if !clearing { return Err(AnnotationError::ReadOnly(obj_ref)); } } } // Past this point the edit is known valid and applicable. let target = &mut self.annotations.items[index]; match edit { AnnotationEdit::Move { to } => { let width = target.annotation.width(); let height = target.annotation.height(); target.annotation.rect = [to.0, to.1, to.0 + width, to.1 + height]; } AnnotationEdit::Resize { rect } => { // Store normalised so downstream hit testing and appearance // sizing never see an inverted rectangle. target.annotation.rect = normalise(rect); } AnnotationEdit::SetColor(c) => { target.annotation.raw_dict.set("C", c.to_obj()); } AnnotationEdit::SetInteriorColor(c) => { target.annotation.raw_dict.set("IC", c.to_obj()); } AnnotationEdit::SetBorderWidth(w) => { // /BS is the modern form; /Border is the legacy array. let mut bs = target .annotation .raw_dict .get_dict("BS") .cloned() .unwrap_or_default(); bs.set("W", PdfObj::Real(w)); bs.set("Type", PdfObj::Name("Border".to_string())); target.annotation.raw_dict.set("BS", PdfObj::Dict(bs)); } AnnotationEdit::SetOpacity(o) => { target.annotation.raw_dict.set("CA", PdfObj::Real(o)); } AnnotationEdit::SetContents(text) => { target .annotation .raw_dict .set("Contents", PdfObj::Str(text.into_bytes())); } AnnotationEdit::SetFlags(flags) => { target.annotation.flags = flags; } AnnotationEdit::Delete => { target.deleted = true; target.dirty = true; return Ok(()); } } target.dirty = true; Ok(()) } /// Convenience wrapper for the commonest gesture. pub fn move_to(&mut self, obj_ref: ObjRef, x: f64, y: f64) -> Result<(), AnnotationError> { self.apply(obj_ref, AnnotationEdit::Move { to: (x, y) }) } pub fn resize(&mut self, obj_ref: ObjRef, rect: [f64; 4]) -> Result<(), AnnotationError> { self.apply(obj_ref, AnnotationEdit::Resize { rect }) } pub fn delete(&mut self, obj_ref: ObjRef) -> Result<(), AnnotationError> { self.apply(obj_ref, AnnotationEdit::Delete) } } /// Reject a rectangle that cannot describe a real region. fn validate_rect(rect: [f64; 4]) -> Result<(), AnnotationError> { if !rect.iter().all(|v| v.is_finite()) { return Err(AnnotationError::DegenerateRect { got: rect }); } let width = (rect[2] - rect[0]).abs(); let height = (rect[3] - rect[1]).abs(); if width <= 0.0 || height <= 0.0 { return Err(AnnotationError::DegenerateRect { got: rect }); } Ok(()) } /// Order a rectangle's corners lower-left to upper-right. fn normalise(rect: [f64; 4]) -> [f64; 4] { [ rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3]), ] } #[cfg(test)] mod tests { use super::*; use nigig_pdf_cos::PdfObj; fn dict(pairs: &[(&str, PdfObj)]) -> PdfDict { let mut d = PdfDict::new(); for (k, v) in pairs { d.set(k, v.clone()); } d } fn name(s: &str) -> PdfObj { PdfObj::Name(s.to_string()) } fn rect_obj(x0: f64, y0: f64, x1: f64, y1: f64) -> PdfObj { PdfObj::Array(vec![ PdfObj::Real(x0), PdfObj::Real(y0), PdfObj::Real(x1), PdfObj::Real(y1), ]) } fn annotation(num: u32, flags_bits: i64) -> PdfAnnotation { let d = dict(&[ ("Subtype", name("Square")), ("Rect", rect_obj(100.0, 200.0, 300.0, 260.0)), ("F", PdfObj::Int(flags_bits)), ]); PdfAnnotation::from_dict_with_ref(&d, Some(0), Some(ObjRef { num, gen: 0 })) .expect("annotation parses") } fn page_with(annots: Vec) -> PageAnnotations { PageAnnotations::new(0, annots) } fn r(num: u32) -> ObjRef { ObjRef { num, gen: 0 } } #[test] fn moving_preserves_size() { let mut page = page_with(vec![annotation(1, 0)]); AnnotationEditor::new(&mut page) .move_to(r(1), 10.0, 20.0) .expect("move"); let moved = page.get(r(1)).expect("annotation"); assert_eq!(moved.annotation.rect, [10.0, 20.0, 210.0, 80.0]); assert_eq!(moved.annotation.width(), 200.0); assert_eq!(moved.annotation.height(), 60.0); assert!(moved.dirty); } #[test] fn resizing_replaces_the_rectangle() { let mut page = page_with(vec![annotation(1, 0)]); AnnotationEditor::new(&mut page) .resize(r(1), [0.0, 0.0, 50.0, 50.0]) .expect("resize"); assert_eq!( page.get(r(1)).expect("annotation").annotation.rect, [0.0, 0.0, 50.0, 50.0] ); } #[test] fn an_inverted_resize_is_stored_normalised() { // Downstream hit testing and appearance sizing must never see an // upside-down rectangle. let mut page = page_with(vec![annotation(1, 0)]); AnnotationEditor::new(&mut page) .resize(r(1), [300.0, 260.0, 100.0, 200.0]) .expect("resize"); assert_eq!( page.get(r(1)).expect("annotation").annotation.rect, [100.0, 200.0, 300.0, 260.0] ); } /// ADR 0004 rule 5. #[test] fn a_degenerate_rectangle_is_refused_not_normalised() { let mut page = page_with(vec![annotation(1, 0)]); let before = page.get(r(1)).expect("annotation").annotation.rect; let err = AnnotationEditor::new(&mut page) .resize(r(1), [10.0, 10.0, 10.0, 50.0]) .expect_err("zero width must be refused"); assert!(matches!(err, AnnotationError::DegenerateRect { .. })); assert_eq!( page.get(r(1)).expect("annotation").annotation.rect, before, "a refused edit must change nothing" ); assert!(!page.get(r(1)).expect("annotation").dirty); } #[test] fn a_non_finite_rectangle_is_refused() { let mut page = page_with(vec![annotation(1, 0)]); assert!(AnnotationEditor::new(&mut page) .resize(r(1), [0.0, 0.0, f64::NAN, 10.0]) .is_err()); assert!(AnnotationEditor::new(&mut page) .resize(r(1), [0.0, 0.0, f64::INFINITY, 10.0]) .is_err()); } #[test] fn setting_a_colour_writes_the_c_array() { let mut page = page_with(vec![annotation(1, 0)]); AnnotationEditor::new(&mut page) .apply( r(1), AnnotationEdit::SetColor(AnnotationColor::new(1.0, 0.0, 0.0)), ) .expect("colour"); let stored = page .get(r(1)) .expect("annotation") .annotation .raw_dict .get_array("C") .expect("/C written") .to_vec(); assert_eq!(stored.len(), 3); assert_eq!(stored[0].as_f64(), Some(1.0)); assert_eq!(stored[1].as_f64(), Some(0.0)); } #[test] fn a_colour_outside_the_valid_range_is_refused() { let mut page = page_with(vec![annotation(1, 0)]); let err = AnnotationEditor::new(&mut page) .apply( r(1), AnnotationEdit::SetColor(AnnotationColor::new(1.5, 0.0, 0.0)), ) .expect_err("out of range"); assert!(matches!(err, AnnotationError::ColorOutOfRange(_))); assert!(page .get(r(1)) .expect("annotation") .annotation .raw_dict .get("C") .is_none()); } #[test] fn colour_arrays_convert_from_grey_and_cmyk() { assert_eq!( AnnotationColor::from_array(&[0.5]), Some(AnnotationColor::new(0.5, 0.5, 0.5)) ); assert_eq!( AnnotationColor::from_array(&[1.0, 0.0, 0.0]), Some(AnnotationColor::new(1.0, 0.0, 0.0)) ); // 0 0 0 1 CMYK is black. assert_eq!( AnnotationColor::from_array(&[0.0, 0.0, 0.0, 1.0]), Some(AnnotationColor::new(0.0, 0.0, 0.0)) ); assert_eq!(AnnotationColor::from_array(&[0.1, 0.2]), None); } #[test] fn border_width_is_written_into_bs() { let mut page = page_with(vec![annotation(1, 0)]); AnnotationEditor::new(&mut page) .apply(r(1), AnnotationEdit::SetBorderWidth(3.0)) .expect("border"); let bs = page .get(r(1)) .expect("annotation") .annotation .raw_dict .get_dict("BS") .expect("/BS written") .clone(); assert_eq!(bs.get_f64("W"), Some(3.0)); } #[test] fn a_negative_border_width_is_refused() { let mut page = page_with(vec![annotation(1, 0)]); assert!(matches!( AnnotationEditor::new(&mut page) .apply(r(1), AnnotationEdit::SetBorderWidth(-1.0)) .expect_err("negative"), AnnotationError::InvalidBorderWidth(_) )); } #[test] fn opacity_is_range_checked() { let mut page = page_with(vec![annotation(1, 0)]); let mut editor = AnnotationEditor::new(&mut page); assert!(editor.apply(r(1), AnnotationEdit::SetOpacity(0.5)).is_ok()); assert!(editor.apply(r(1), AnnotationEdit::SetOpacity(1.5)).is_err()); assert!(editor .apply(r(1), AnnotationEdit::SetOpacity(-0.1)) .is_err()); } #[test] fn contents_are_written_as_a_string() { let mut page = page_with(vec![annotation(1, 0)]); AnnotationEditor::new(&mut page) .apply(r(1), AnnotationEdit::SetContents("a note".into())) .expect("contents"); assert_eq!( page.get(r(1)) .expect("annotation") .annotation .raw_dict .get_str("Contents"), Some(&b"a note"[..]) ); } /// ADR 0004 rule 4. #[test] fn a_read_only_annotation_refuses_edits() { // /F bit 7 (value 64) is ReadOnly. let mut page = page_with(vec![annotation(1, 64)]); let before = page.get(r(1)).expect("annotation").annotation.rect; let err = AnnotationEditor::new(&mut page) .move_to(r(1), 0.0, 0.0) .expect_err("read-only"); assert_eq!(err, AnnotationError::ReadOnly(r(1))); assert_eq!(page.get(r(1)).expect("annotation").annotation.rect, before); assert!(!page.get(r(1)).expect("annotation").dirty); } #[test] fn read_only_can_be_overridden_explicitly() { let mut page = page_with(vec![annotation(1, 64)]); AnnotationEditor::new(&mut page) .allowing_read_only() .move_to(r(1), 5.0, 5.0) .expect("explicit override"); assert_eq!(page.get(r(1)).expect("annotation").annotation.rect[0], 5.0); } #[test] fn clearing_the_read_only_flag_is_permitted() { // Otherwise a read-only annotation could never be unlocked. let mut page = page_with(vec![annotation(1, 64)]); let cleared = AnnotationFlags::default(); AnnotationEditor::new(&mut page) .apply(r(1), AnnotationEdit::SetFlags(cleared)) .expect("unlock"); assert!( !page .get(r(1)) .expect("annotation") .annotation .flags .read_only ); // And now ordinary edits work. AnnotationEditor::new(&mut page) .move_to(r(1), 1.0, 1.0) .expect("move after unlock"); } #[test] fn deleting_removes_it_from_the_live_set() { let mut page = page_with(vec![annotation(1, 0), annotation(2, 0)]); assert_eq!(page.len(), 2); AnnotationEditor::new(&mut page) .delete(r(1)) .expect("delete"); assert_eq!(page.len(), 1); assert!(page.get(r(1)).is_none()); assert_eq!(page.deleted_refs(), vec![r(1)]); // Still reported as an edit, because the save must drop it. assert_eq!(page.dirty().count(), 1); } #[test] fn editing_a_deleted_annotation_is_unknown() { let mut page = page_with(vec![annotation(1, 0)]); AnnotationEditor::new(&mut page) .delete(r(1)) .expect("delete"); assert_eq!( AnnotationEditor::new(&mut page) .move_to(r(1), 0.0, 0.0) .expect_err("gone"), AnnotationError::Unknown(r(1)) ); } #[test] fn editing_an_unknown_reference_is_refused() { let mut page = page_with(vec![annotation(1, 0)]); assert_eq!( AnnotationEditor::new(&mut page) .move_to(r(99), 0.0, 0.0) .expect_err("unknown"), AnnotationError::Unknown(r(99)) ); } #[test] fn an_unedited_page_reports_no_edits() { let page = page_with(vec![annotation(1, 0), annotation(2, 0)]); assert!(!page.has_edits()); assert_eq!(page.dirty().count(), 0); assert!(page.deleted_refs().is_empty()); } #[test] fn the_written_dictionary_carries_the_new_geometry_and_flags() { let mut page = page_with(vec![annotation(1, 0)]); let flags = AnnotationFlags { print: true, hidden: true, ..Default::default() }; { let mut editor = AnnotationEditor::new(&mut page); editor.move_to(r(1), 7.0, 8.0).expect("move"); editor .apply(r(1), AnnotationEdit::SetFlags(flags)) .expect("flags"); } let dict = page.get(r(1)).expect("annotation").to_dict(); let rect = dict.get_array("Rect").expect("/Rect"); assert_eq!(rect[0].as_f64(), Some(7.0)); assert_eq!(rect[1].as_f64(), Some(8.0)); // print is bit 2 (4), hidden is bit 1 (2). assert_eq!(dict.get_int("F"), Some(6)); } #[test] fn hit_testing_returns_the_topmost_visible_annotation() { // Later entries in /Annots paint over earlier ones. let mut page = page_with(vec![annotation(1, 0), annotation(2, 0)]); let hit = page.hit_test(150.0, 220.0).expect("a hit"); assert_eq!(hit.annotation.obj_ref, Some(r(2))); // A hidden annotation is not a target. AnnotationEditor::new(&mut page) .apply( r(2), AnnotationEdit::SetFlags(AnnotationFlags { hidden: true, ..Default::default() }), ) .expect("hide"); assert_eq!( page.hit_test(150.0, 220.0) .expect("a hit") .annotation .obj_ref, Some(r(1)) ); } #[test] fn hit_testing_ignores_deleted_annotations() { let mut page = page_with(vec![annotation(1, 0)]); AnnotationEditor::new(&mut page) .delete(r(1)) .expect("delete"); assert!(page.hit_test(150.0, 220.0).is_none()); } #[test] fn a_direct_object_annotation_cannot_be_addressed() { let d = dict(&[ ("Subtype", name("Square")), ("Rect", rect_obj(0.0, 0.0, 10.0, 10.0)), ]); let annot = PdfAnnotation::from_dict_on_page(&d, Some(0)).expect("parses"); assert_eq!(annot.obj_ref, None); let mut page = page_with(vec![annot]); // With no reference there is nothing to look up, so it reads as // unknown rather than silently editing the wrong annotation. assert!(AnnotationEditor::new(&mut page) .move_to(r(1), 0.0, 0.0) .is_err()); } }