nigig-org/crates/apps/pdf/pdf-document/src/annotation_edit.rs
andodeki f04f8ba34e
Some checks failed
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
feat(pdf): implement annotation editing (Phase 8)
Phase 8 feature 2 of 10, designed in
REVIEWS/adr/0004-pdf-annotation-editing.md. Its prerequisites are "document
model, appearance generation, incremental save"; the first two landed in
Phase 3 and the third in ADR 0003, so this was the ready one.

Chosen ahead of the other unblocked feature, AcroForm full support, because
the review lists that one as needing JavaScript actions. Running
document-supplied code is a large new dependency and a security surface that
deserves its own ADR and threat review rather than arriving as a side effect
of finishing a form feature.

Annotations were strictly read-only: the module had public fields and
from_dict, and not one mutator or &mut self method. The viewer could report
a click on a link but could not move a highlight, restyle a square or delete
a stamp.

New pdf-document/src/annotation_edit.rs, deliberately the same shape as
DocumentFormEditor so a caller wiring a drag gesture does not have to learn
a second contract:

- AnnotationEdit covers Move, Resize, SetColor, SetInteriorColor,
  SetBorderWidth, SetOpacity, SetContents, SetFlags and Delete.
- Every edit is validated before anything changes, so a rejected edit leaves
  the annotation untouched. Degenerate and non-finite rectangles, colours
  outside 0..1, negative border widths and out-of-range opacities are all
  refused with typed errors.
- A degenerate rectangle is refused rather than silently normalised: it
  usually means a bug in the UI upstream, and quietly fixing it hides that.
  An inverted but valid rectangle is normalised on store, so hit testing and
  appearance sizing never see one upside down.
- Read-only annotations refuse edits unless the caller opts in through an
  explicit allowing_read_only(), with one exception: clearing the read-only
  flag itself is permitted, or a locked annotation could never be unlocked.
- Colour reading converts the grey and CMYK forms of /C to RGB, since the
  array length selects the space.

Identity: PdfAnnotation gains an obj_ref, because an index into /Annots is
not stable across a save. Populating it exposed a real bug in
page_annotations: it called self.resolve() on the /Annots array, which
recurses and replaced every entry with its dictionary, destroying the
references. It now resolves only the array itself.

Saving: save_annotation_edits appends a revision through the ADR 0003
writer. Deleting rewrites the page dictionary so the reference leaves
/Annots, because an object that stops existing while the page still points
at it produces a file other readers reject.

Out of scope and recorded in the ADR rather than implied: creating new
annotations, appearance generation for types this crate cannot draw (a
Stamp keeps its existing /AP rather than being blanked), applying
redactions, and rich text.

Tests: 23 unit tests plus 13 corpus acceptance tests covering the ADR merge
criteria. The round trips reparse from the written bytes rather than reusing
in-memory state, assert a deleted annotation is gone from the reparsed
page's /Annots and not merely from the model, that unrelated annotations
survive a deletion, that two saves chain, and that editing then saving never
panics on the malformed corpus.

Validation:
  TEST_TARGET=pdf ./tools/test-rust-clean.sh      (354 tests)
  TEST_TARGET=pdf-ui ./tools/test-rust-clean.sh   (398 tests, 6 ignored)
Both rustfmt and clippy -D warnings clean.
2026-07-28 17:12:40 +00:00

860 lines
28 KiB
Rust

//! 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<Self> {
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<EditableAnnotation>,
}
impl PageAnnotations {
/// Take ownership of a page's parsed annotations.
pub fn new(page_index: usize, annotations: Vec<PdfAnnotation>) -> 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<Item = &EditableAnnotation> {
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<Item = &EditableAnnotation> {
self.items.iter().filter(|a| a.dirty || a.deleted)
}
/// References of annotations deleted in this session.
pub fn deleted_refs(&self) -> Vec<ObjRef> {
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<PdfAnnotation>) -> 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());
}
}