Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions crates/pdf-annotation-core/src/actions.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
//! Triggers that run annotation actions, and the selection of what each one runs.
use serde::{Deserialize, Serialize};

use crate::{
models::Annotation,
pdf_data::{
AnnotationAction, HideTarget, NativeAnnotation, WidgetAdditionalActions, WidgetAnnotation,
},
};
use pdf_object_reader::text_string;

/// A user interaction that runs an annotation's action.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export_to = "annotation_contract.ts"))]
#[serde(rename_all = "snake_case")]
pub enum ActionTrigger {
/// The annotation is activated, such as a click releasing over it: its `/A` action.
Activate,
/// The pointer enters the annotation.
CursorEnter,
/// The pointer leaves the annotation.
CursorExit,
/// A pointer button is pressed inside the annotation.
MouseDown,
/// A pointer button is released inside the annotation.
MouseUp,
/// The annotation receives input focus.
Focus,
/// The annotation loses input focus.
Blur,
}

impl ActionTrigger {
/// Every trigger, in declaration order.
const ALL: [Self; 7] = [
Self::Activate,
Self::CursorEnter,
Self::CursorExit,
Self::MouseDown,
Self::MouseUp,
Self::Focus,
Self::Blur,
];

/// The `/AA` entry this trigger runs; `Activate` runs `/A` instead.
fn additional_action(self, actions: &WidgetAdditionalActions) -> Option<&AnnotationAction> {
match self {
Self::Activate => None,
Self::CursorEnter => actions.cursor_enter.as_ref(),
Self::CursorExit => actions.cursor_exit.as_ref(),
Self::MouseDown => actions.mouse_down.as_ref(),
Self::MouseUp => actions.mouse_up.as_ref(),
Self::Focus => actions.focus.as_ref(),
Self::Blur => actions.blur.as_ref(),
}
}
}

impl Annotation {
/// The action `trigger` runs: the current action on activation, else the retained
/// widget's `/AA` entry. Returning it never executes it.
pub fn trigger_action(&self, trigger: ActionTrigger) -> Option<AnnotationAction> {
match trigger {
ActionTrigger::Activate => self.action(),
trigger => self
.source_widget()
.and_then(|widget| widget.additional_actions.as_ref())
.and_then(|actions| trigger.additional_action(actions))
.cloned(),
}
}

/// The triggers that run an action on this annotation.
pub fn triggers(&self) -> Vec<ActionTrigger> {
ActionTrigger::ALL
.into_iter()
.filter(|trigger| match trigger {
ActionTrigger::Activate => self.action().is_some(),
trigger => self
.source_widget()
.and_then(|widget| widget.additional_actions.as_ref())
.is_some_and(|actions| trigger.additional_action(actions).is_some()),
})
.collect()
}

/// Whether a hide action naming `target` applies to this annotation.
pub(crate) fn is_hide_target(&self, target: &HideTarget) -> bool {
let Some(source) = self.source() else {
return false;
};
let widget = self.source_widget();
match target {
HideTarget::Object { number } => {
source.object_number == Some(*number)
|| widget.is_some_and(|widget| widget.field_id == Some(*number))
}
HideTarget::FieldName(name) => widget.is_some_and(|widget| widget.is_named(name)),
HideTarget::Unresolved => false,
}
}

/// The retained source widget record, if this annotation was adopted from one.
fn source_widget(&self) -> Option<&WidgetAnnotation> {
match &self.source()?.kind {
NativeAnnotation::Widget(widget) => Some(widget),
_ => None,
}
}
}

impl AnnotationAction {
/// The actions run in order: a sequence's members, else this action alone.
pub fn steps(&self) -> &[AnnotationAction] {
match self {
Self::Sequence(actions) => actions,
action => std::slice::from_ref(action),
}
}
}

impl WidgetAnnotation {
/// Whether `name` is this widget's fully qualified field name or that of a field
/// above it. Names that are not valid text strings match nothing.
fn is_named(&self, name: &[u8]) -> bool {
let Ok(name) = text_string::decode(name) else {
return false;
};
let Some(qualified) = self.qualified_name() else {
return false;
};
qualified
.strip_prefix(name.as_str())
.is_some_and(|rest| rest.is_empty() || rest.starts_with('.'))
}

/// The partial names joined by periods, or `None` when there are none or one is
/// not a valid text string.
fn qualified_name(&self) -> Option<String> {
if self.partial_names.is_empty() {
return None;
}
let names = self
.partial_names
.iter()
.map(|name| text_string::decode(name))
.collect::<Result<Vec<_>, _>>()
.ok()?;
Some(names.join("."))
}
}
7 changes: 7 additions & 0 deletions crates/pdf-annotation-core/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ pub enum Operation {
/// Existing annotation to remove.
id: AnnotationId,
},
/// Set or clear the hidden flag of existing annotations, as a hide action does.
SetHidden {
/// Annotations to hide.
hidden: Vec<AnnotationId>,
/// Annotations to show.
shown: Vec<AnnotationId>,
},
/// Validate and insert a shared field, allocating its identity independently.
CreateField {
/// Fixed configuration and initial value; incomplete required values are valid.
Expand Down
28 changes: 28 additions & 0 deletions crates/pdf-annotation-core/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use crate::error::{CoreError, ValidationError};
use crate::events::{Change, Event};
use crate::fields::{Field, FieldId, FieldIssue, FieldIssueKind, FieldKind};
use crate::kind::AnnotationKind;
use crate::metadata::AnnotationFlags;
use crate::models::{
Annotation, AnnotationId, DocumentData, DocumentDescriptor, PageIndex, Revision, SCHEMA_VERSION,
};
Expand Down Expand Up @@ -382,6 +383,7 @@ impl<S: AnnotationStore> AnnotationEngine<S> {
self.data.fields.retain(|candidate| candidate.id != field);
Change::FieldDeleted(field)
}
Operation::SetHidden { hidden, shown } => self.set_hidden(hidden, shown)?,
operation @ (Operation::SetText { field, .. }
| Operation::SetCheckbox { field, .. }
| Operation::SetCheckboxGroupSelection { field, .. }
Expand Down Expand Up @@ -512,6 +514,32 @@ impl<S: AnnotationStore> AnnotationEngine<S> {
Ok(())
}

/// Sets the hidden flag of `hidden` and clears it on `shown`, once every
/// annotation is known to exist.
fn set_hidden(
&mut self,
hidden: Vec<AnnotationId>,
shown: Vec<AnnotationId>,
) -> Result<Change, CoreError<S::Error>> {
if let Some(id) = hidden
.iter()
.chain(&shown)
.find(|id| self.view().annotation(**id).is_none())
{
return Err(CoreError::MissingAnnotation(*id));
}
for annotation in &mut self.data.annotations {
if hidden.contains(&annotation.id) {
annotation.metadata.flags.insert(AnnotationFlags::HIDDEN);
} else if shown.contains(&annotation.id) {
annotation.metadata.flags.remove(AnnotationFlags::HIDDEN);
}
}
Ok(Change::VisibilityUpdated {
annotations: hidden.into_iter().chain(shown).collect(),
})
}

fn edit_field(
&mut self,
id: FieldId,
Expand Down
3 changes: 3 additions & 0 deletions crates/pdf-annotation-core/src/entry.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! Backend-neutral annotation entries: Core values projected into one viewport.
use crate::shapes::Shape;
use crate::{
actions::ActionTrigger,
fields::{ChoiceOption, FieldId, OptionId},
kind::AnnotationKind,
models::{AnnotationId, Revision},
Expand Down Expand Up @@ -112,6 +113,8 @@ pub struct AnnotationEntry {
pub style: ResolvedStyle,
/// Host-owned action notification; links fall back to their destination.
pub action: Option<AnnotationAction>,
/// Triggers that run an action; the host reports only these through `perform_action`.
pub triggers: Vec<ActionTrigger>,
/// Live Unicode contents.
pub text: String,
/// Displayed subtype name.
Expand Down
5 changes: 5 additions & 0 deletions crates/pdf-annotation-core/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ pub enum Change {
Updated(AnnotationId),
/// The annotation is no longer present in the live document.
Deleted(AnnotationId),
/// The hidden flag of these annotations was set or cleared.
VisibilityUpdated {
/// Every hidden or shown annotation, hidden ones first, in command order.
annotations: Vec<AnnotationId>,
},
/// Core assigned this identity to a new shared field.
FieldCreated(FieldId),
/// An unreferenced field was removed from the document.
Expand Down
7 changes: 4 additions & 3 deletions crates/pdf-annotation-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ mod source_properties;

mod wire;

/// Triggers that run annotation actions.
pub mod actions;
/// Source appearances hosts paint on the page canvas.
pub mod appearance_paint;
/// Backend-neutral annotation entries projected into one host viewport.
Expand All @@ -76,12 +78,11 @@ mod shape_path;
pub mod shapes;
mod widget_control;

pub use actions::ActionTrigger;
pub use appearance_paint::PaintedAppearance;
pub use entry::{AnnotationEntry, ControlKind, ControlTag, WidgetControl};
pub use layer_error::{AnnotationLayerError, AnnotationLayerResult};
pub use overlay::{AnnotationOverlay, PreparedAnnotations};
pub use requests::{
AnnotationCommandRequest, AnnotationReceipt, AnnotationTarget, OptionalContentReceipt,
};
pub use requests::{ActionReceipt, AnnotationCommandRequest, AnnotationReceipt, AnnotationTarget};
pub use session_store::{SessionStorageError, SessionStore};
pub use shapes::{Shape, ShapePaint};
Loading
Loading