diff --git a/crates/pdf-annotation-core/src/actions.rs b/crates/pdf-annotation-core/src/actions.rs new file mode 100644 index 00000000..78739256 --- /dev/null +++ b/crates/pdf-annotation-core/src/actions.rs @@ -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 { + 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::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 { + if self.partial_names.is_empty() { + return None; + } + let names = self + .partial_names + .iter() + .map(|name| text_string::decode(name)) + .collect::, _>>() + .ok()?; + Some(names.join(".")) + } +} diff --git a/crates/pdf-annotation-core/src/commands.rs b/crates/pdf-annotation-core/src/commands.rs index abdc86c0..d71833a6 100644 --- a/crates/pdf-annotation-core/src/commands.rs +++ b/crates/pdf-annotation-core/src/commands.rs @@ -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, + /// Annotations to show. + shown: Vec, + }, /// Validate and insert a shared field, allocating its identity independently. CreateField { /// Fixed configuration and initial value; incomplete required values are valid. diff --git a/crates/pdf-annotation-core/src/engine.rs b/crates/pdf-annotation-core/src/engine.rs index 1343185b..31397e91 100644 --- a/crates/pdf-annotation-core/src/engine.rs +++ b/crates/pdf-annotation-core/src/engine.rs @@ -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, }; @@ -382,6 +383,7 @@ impl AnnotationEngine { 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, .. } @@ -512,6 +514,32 @@ impl AnnotationEngine { 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, + shown: Vec, + ) -> Result> { + 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, diff --git a/crates/pdf-annotation-core/src/entry.rs b/crates/pdf-annotation-core/src/entry.rs index 71d48f57..7acdaca5 100644 --- a/crates/pdf-annotation-core/src/entry.rs +++ b/crates/pdf-annotation-core/src/entry.rs @@ -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}, @@ -112,6 +113,8 @@ pub struct AnnotationEntry { pub style: ResolvedStyle, /// Host-owned action notification; links fall back to their destination. pub action: Option, + /// Triggers that run an action; the host reports only these through `perform_action`. + pub triggers: Vec, /// Live Unicode contents. pub text: String, /// Displayed subtype name. diff --git a/crates/pdf-annotation-core/src/events.rs b/crates/pdf-annotation-core/src/events.rs index 3898aeb2..ff0327b5 100644 --- a/crates/pdf-annotation-core/src/events.rs +++ b/crates/pdf-annotation-core/src/events.rs @@ -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, + }, /// Core assigned this identity to a new shared field. FieldCreated(FieldId), /// An unreferenced field was removed from the document. diff --git a/crates/pdf-annotation-core/src/lib.rs b/crates/pdf-annotation-core/src/lib.rs index 22775a9f..39b9d737 100644 --- a/crates/pdf-annotation-core/src/lib.rs +++ b/crates/pdf-annotation-core/src/lib.rs @@ -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. @@ -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}; diff --git a/crates/pdf-annotation-core/src/overlay.rs b/crates/pdf-annotation-core/src/overlay.rs index 6cb1d453..34311831 100644 --- a/crates/pdf-annotation-core/src/overlay.rs +++ b/crates/pdf-annotation-core/src/overlay.rs @@ -1,19 +1,19 @@ //! Document-owned Core editing and disposable per-viewport annotation entries. use crate::{ + actions::ActionTrigger, appearance_paint::PaintedAppearance, - commands::Operation, + commands::{AnnotationCommand, Operation}, engine::DocumentView, entry::AnnotationEntry, events::{Change, Event}, import::{self, ImportedDocument, LayoutHint}, kind::AnnotationKind, layer_error::{AnnotationLayerError, AnnotationLayerResult}, + metadata::AnnotationFlags, models::{Annotation, AnnotationId, Point, Revision}, - pdf_data::{AnnotationAction, SourceAnnotation}, + pdf_data::{AnnotationAction, HideTarget, SourceAnnotation}, projection, - requests::{ - AnnotationCommandRequest, AnnotationReceipt, AnnotationTarget, OptionalContentReceipt, - }, + requests::{ActionReceipt, AnnotationCommandRequest, AnnotationReceipt, AnnotationTarget}, }; use pdf_graphics::{rect::Rect, viewport::PageViewport}; use pdf_object_reader::diagnostic::PdfReadDiagnostic; @@ -213,37 +213,121 @@ impl AnnotationOverlay { }) } - /// Applies a `SetOCGState` action to runtime optional content visibility and - /// drops the prepared presentation of every page it can affect. + /// Runs the action `trigger` selects on annotation `id`, step by step in order. /// - /// Visibility is presentation state: this never mutates an annotation, never - /// dirties the sidecar, and never advances the document revision. The returned - /// pages are the ones the host must request again. + /// Hide steps fold into one net change per target, committed as a single Core + /// edit that sets or clears each target's hidden flag; targets already in that + /// state are left alone, so a repeated action advances no revision. Optional + /// content steps change presentation state only. Every other step is returned + /// for the host, which owns navigation and other side effects. /// /// The action's `/PreserveRB` is accepted and ignored, because `/RBGroups` /// radio relationships are not modelled; a group is turned on without turning /// off the siblings a full reader would. /// /// # Errors - /// Rejects an action that is not `SetOCGState`. - pub fn set_optional_content_state( + /// Rejects an unknown annotation, and propagates Core errors from the hide edit. + pub fn perform_action( &mut self, - action: &AnnotationAction, - ) -> AnnotationLayerResult { - let AnnotationAction::SetOCGState { state, .. } = action else { - return Err(AnnotationLayerError::InvalidInput( - "optional content action", - )); + id: AnnotationId, + trigger: ActionTrigger, + ) -> AnnotationLayerResult { + let action = self + .document + .engine + .view() + .annotation(id) + .ok_or(AnnotationLayerError::InvalidInput("action annotation"))? + .trigger_action(trigger); + let Some(action) = action else { + return Ok(ActionReceipt::default()); }; - let pages = self.apply_optional_content(state); - for page in &pages { - self.pages.remove(page); + let mut visibility = Vec::new(); + let mut pages = BTreeSet::new(); + let mut host_actions = Vec::new(); + for step in action.steps() { + match step { + AnnotationAction::Hide { targets, hide } => { + self.collect_hide_targets(targets, *hide, &mut visibility); + } + AnnotationAction::SetOCGState { state, .. } => { + let changed = self.apply_optional_content(state); + for page in &changed { + self.pages.remove(page); + } + pages.extend(changed); + } + other => host_actions.push(other.clone()), + } } - Ok(OptionalContentReceipt { + let revision = match self.set_hidden(&visibility)? { + Some(event) => { + let changed = self.record_change(&event, None); + self.invalidate(&changed, event.revision); + pages.extend(changed); + Some(event.revision) + } + None => None, + }; + Ok(ActionReceipt { + revision, pages: pages.into_iter().collect(), + host_actions, }) } + /// Records `hide` for every annotation a hide step names, replacing the state an + /// earlier step recorded for the same annotation. + fn collect_hide_targets( + &self, + targets: &[HideTarget], + hide: bool, + visibility: &mut Vec<(AnnotationId, bool)>, + ) { + let view = self.document.engine.view(); + let matched = view + .data() + .annotations + .iter() + .filter(|annotation| targets.iter().any(|t| annotation.is_hide_target(t))); + for annotation in matched { + match visibility.iter_mut().find(|(id, _)| *id == annotation.id) { + Some((_, state)) => *state = hide, + None => visibility.push((annotation.id, hide)), + } + } + } + + /// Commits the hidden flags in `visibility` that differ from the live ones, or + /// returns `None` when every annotation is already in its requested state. + fn set_hidden( + &mut self, + visibility: &[(AnnotationId, bool)], + ) -> AnnotationLayerResult> { + let (mut hidden, mut shown) = (Vec::new(), Vec::new()); + { + let view = self.document.engine.view(); + for (id, hide) in visibility { + let is_hidden = view + .annotation(*id) + .is_some_and(|a| a.metadata.flags.contains(AnnotationFlags::HIDDEN)); + match (hide, is_hidden) { + (true, false) => hidden.push(*id), + (false, true) => shown.push(*id), + _ => {} + } + } + } + if hidden.is_empty() && shown.is_empty() { + return Ok(None); + } + let event = self.document.engine.dispatch(AnnotationCommand { + expected_revision: self.revision(), + operation: Operation::SetHidden { hidden, shown }, + })?; + Ok(Some(event)) + } + /// Applies `state` and reports the pages holding an annotation that depends on /// a group whose visibility changed. The engine view is released before the /// caller evicts the snapshots those pages own. @@ -430,8 +514,9 @@ impl AnnotationOverlay { self.document.hints.remove(&id.0); self.modified.remove(&id.0); } - Change::FieldUpdated { widgets, .. } => { - for id in widgets { + Change::FieldUpdated { widgets: ids, .. } + | Change::VisibilityUpdated { annotations: ids } => { + for id in ids { pages.extend(self.page_of(*id)); self.modified.insert(id.0, event.revision); } diff --git a/crates/pdf-annotation-core/src/pdf_data.rs b/crates/pdf-annotation-core/src/pdf_data.rs index c968ada2..e49f2483 100644 --- a/crates/pdf-annotation-core/src/pdf_data.rs +++ b/crates/pdf-annotation-core/src/pdf_data.rs @@ -363,6 +363,43 @@ pub enum ExplicitDestination { }, } +/// One `/T` entry of a hide action. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "typescript", ts(export_to = "annotation_contract.ts"))] +pub enum HideTarget { + /// The fully qualified name of a field, whose widgets and descendants are targeted. + FieldName(Arc<[u8]>), + /// The indirect object number of an annotation or field dictionary. + Object { + /// The object number. + #[serde(with = "crate::wire")] + #[cfg_attr(feature = "typescript", ts(type = "string"))] + number: u64, + }, + /// A direct dictionary, which has no identity to match an annotation by. + Unresolved, +} + +/// Actions a widget's `/AA` dictionary runs on pointer and focus triggers. +#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "typescript", ts(export_to = "annotation_contract.ts"))] +pub struct WidgetAdditionalActions { + /// `/E`: the pointer enters the annotation. + pub cursor_enter: Option, + /// `/X`: the pointer leaves the annotation. + pub cursor_exit: Option, + /// `/D`: a pointer button is pressed inside the annotation. + pub mouse_down: Option, + /// `/U`: a pointer button is released inside the annotation. + pub mouse_up: Option, + /// `/Fo`: the annotation receives input focus. + pub focus: Option, + /// `/Bl`: the annotation loses input focus. + pub blur: Option, +} + /// Owned AnnotationAction data retained from the source PDF. #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] @@ -445,6 +482,15 @@ pub enum AnnotationAction { }, /// A GoTo3DView action; the view dictionary is not retained. GoTo3DView, + /// A hide action that hides or shows annotations and the widgets of fields. + Hide { + /// The annotations and fields the action applies to. + targets: Vec, + /// Whether the targets are hidden; `false` shows them. + hide: bool, + }, + /// An action followed by its `/Next` successors, flattened into execution order. + Sequence(Vec), /// A vendor or future action type. Unknown { /// The action subtype name. @@ -453,11 +499,14 @@ pub enum AnnotationAction { } impl AnnotationAction { - /// Trimmed URI of a `Uri` action when it is an absolute `http`, `https`, or - /// `mailto` target a host may navigate to; other schemes and actions yield `None`. + /// Trimmed URI of a `Uri` action, or of the first action of a sequence, when it + /// is an absolute `http`, `https`, or `mailto` target a host may navigate to; + /// other schemes and actions yield `None`. pub fn href(&self) -> Option { - let Self::Uri { uri, .. } = self else { - return None; + let uri = match self { + Self::Uri { uri, .. } => uri, + Self::Sequence(actions) => return actions.first().and_then(Self::href), + _ => return None, }; let uri = String::from_utf8_lossy(uri).trim().to_owned(); let lower = uri.to_ascii_lowercase(); @@ -776,6 +825,11 @@ pub struct WidgetAnnotation { pub field_type: Option>, /// The field name. pub field_name: Option>, + /// The partial `/T` names along the `/Parent` chain, outermost first; joined by + /// periods they form the fully qualified field name. Defaulted on load so older + /// sidecars still deserialize. + #[serde(default)] + pub partial_names: Vec>, /// The alternate field name. pub alternate_name: Option>, /// The mapping name. @@ -811,6 +865,11 @@ pub struct WidgetAnnotation { pub border_style: Option, /// The action dictionary. pub action: Option, + /// The `/AA` pointer and focus trigger actions. Defaulted on load so older + /// sidecars still deserialize. + #[serde(default)] + #[cfg_attr(feature = "typescript", ts(optional))] + pub additional_actions: Option, } /// Native PDF annotation subtype data. diff --git a/crates/pdf-annotation-core/src/projection.rs b/crates/pdf-annotation-core/src/projection.rs index 9b7adfa8..e9c36252 100644 --- a/crates/pdf-annotation-core/src/projection.rs +++ b/crates/pdf-annotation-core/src/projection.rs @@ -91,6 +91,7 @@ pub(crate) fn project( content: annotation.content.clone(), href: action.as_ref().and_then(AnnotationAction::href), action, + triggers: annotation.triggers(), text: annotation.text()?, subtype: annotation.content.subtype_name().into_owned(), unsupported: unsupported_reason(annotation, field), diff --git a/crates/pdf-annotation-core/src/requests.rs b/crates/pdf-annotation-core/src/requests.rs index 9c6c80f9..1b47eb19 100644 --- a/crates/pdf-annotation-core/src/requests.rs +++ b/crates/pdf-annotation-core/src/requests.rs @@ -1,5 +1,10 @@ //! Typed browser envelopes around Core's existing semantic commands. -use crate::{commands::AnnotationCommand, events::Event, models::AnnotationId}; +use crate::{ + commands::AnnotationCommand, + events::Event, + models::{AnnotationId, Revision}, + pdf_data::AnnotationAction, +}; use serde::{Deserialize, Serialize}; /// Captured mounted control context, separate from the document revision. @@ -40,15 +45,19 @@ pub struct AnnotationReceipt { pub pages: Vec, } -/// Acknowledgement of an applied optional content visibility change. +/// Outcome of running an annotation's action for one trigger. /// -/// Carries no revision: optional content visibility is host presentation state, -/// so applying it never edits an annotation and never advances the document -/// revision. Only the listed pages need their presentation refreshed. -#[derive(Clone, Debug, Serialize, Deserialize)] +/// Hide actions commit one Core edit and report its revision. Optional content +/// changes are presentation state and advance no revision. Actions Core does not +/// run are returned for the host, in order. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", ts(export_to = "annotation_contract.ts"))] -pub struct OptionalContentReceipt { +pub struct ActionReceipt { + /// Document revision after the committed hide edit; `None` when nothing was edited. + pub revision: Option, /// Pages whose annotation presentation must be refreshed. pub pages: Vec, + /// Actions the host performs itself, such as navigation, in execution order. + pub host_actions: Vec, } diff --git a/crates/pdf-annotation-core/src/source_decode_actions.rs b/crates/pdf-annotation-core/src/source_decode_actions.rs index 437a9367..88333081 100644 --- a/crates/pdf-annotation-core/src/source_decode_actions.rs +++ b/crates/pdf-annotation-core/src/source_decode_actions.rs @@ -2,7 +2,7 @@ use crate::error::SourceDecodeError; use crate::pdf_data::{ AnnotationAction, AnnotationDestination, DestinationTarget, ExplicitDestination, - FileSpecification, FileSpecificationDictionary, + FileSpecification, FileSpecificationDictionary, HideTarget, WidgetAdditionalActions, }; use pdf_object_reader::pdf_name::PdfName; use pdf_object_reader::{ @@ -11,12 +11,14 @@ use pdf_object_reader::{ }; use pdf_optional_content::{OcgStateEntry, OcgTarget}; -/// Reads an action dictionary, such as a link's or widget's `/A` entry. +/// Reads an action dictionary, such as a link's or widget's `/A` entry, together +/// with its `/Next` successors. A chain is flattened into one `Sequence` in execution +/// order; a `/Next` chain that loops back ends at the repeated action. impl FromPdfObject for AnnotationAction { fn from_pdf_object(context: ObjectContext<'_, impl ObjectAccess + ?Sized>) -> ReadResult { let mut action = context.dictionary()?; let action_type = action.required::(b"S")?; - Ok(match action_type.as_ref() { + let head = match action_type.as_ref() { b"GoTo" => Self::GoTo { destination: action.required(b"D")?, }, @@ -66,9 +68,59 @@ impl FromPdfObject for AnnotationAction { duration: action.optional(b"D")?, }, b"GoTo3DView" => Self::GoTo3DView, + b"Hide" => Self::Hide { + targets: action.optional_list(b"T")?, + hide: action.optional(b"H")?.unwrap_or(true), + }, other => Self::Unknown { action_type: Vec::from(other), }, + }; + let next = match action.optional_list::(b"Next") { + Err(error) if error.is_cyclic_reference() => Vec::new(), + result => result?, + }; + if next.is_empty() { + return Ok(head); + } + let mut sequence = vec![head]; + for action in next { + match action { + Self::Sequence(actions) => sequence.extend(actions), + action => sequence.push(action), + } + } + Ok(Self::Sequence(sequence)) + } +} + +/// Reads one `/T` entry of a hide action: a field name, or an annotation or field +/// dictionary identified by the indirect object holding it. +impl FromPdfObject for HideTarget { + fn from_pdf_object(context: ObjectContext<'_, impl ObjectAccess + ?Sized>) -> ReadResult { + if !matches!(context.object(), ObjectVariant::Dictionary(_)) { + return Ok(Self::FieldName(context.decode()?)); + } + Ok(match context.dictionary()?.object_number() { + Some(number) => Self::Object { + number: u64::try_from(number).map_err(|_| SourceDecodeError::ResourceLimit)?, + }, + None => Self::Unresolved, + }) + } +} + +/// Reads the pointer and focus triggers of a widget's `/AA` dictionary. +impl FromPdfObject for WidgetAdditionalActions { + fn from_pdf_object(context: ObjectContext<'_, impl ObjectAccess + ?Sized>) -> ReadResult { + let mut actions = context.dictionary()?; + Ok(Self { + cursor_enter: actions.optional(b"E")?, + cursor_exit: actions.optional(b"X")?, + mouse_down: actions.optional(b"D")?, + mouse_up: actions.optional(b"U")?, + focus: actions.optional(b"Fo")?, + blur: actions.optional(b"Bl")?, }) } } diff --git a/crates/pdf-annotation-core/src/source_decode_widget.rs b/crates/pdf-annotation-core/src/source_decode_widget.rs index 1b3dfd7c..fdb8e1fb 100644 --- a/crates/pdf-annotation-core/src/source_decode_widget.rs +++ b/crates/pdf-annotation-core/src/source_decode_widget.rs @@ -23,6 +23,7 @@ impl FromPdfObject for WidgetAnnotation { field_id, field_type: fields.field_type, field_name: fields.field_name, + partial_names: fields.partial_names, alternate_name: fields.alternate_name, mapping_name: fields.mapping_name, field_flags: fields.field_flags, @@ -37,6 +38,7 @@ impl FromPdfObject for WidgetAnnotation { appearance_characteristics: dictionary.optional(b"MK")?, border_style: dictionary.optional(b"BS")?, action: dictionary.optional(b"A")?, + additional_actions: dictionary.optional(b"AA")?, }) } } @@ -46,6 +48,7 @@ impl FromPdfObject for WidgetAnnotation { struct FieldProperties { field_type: Option>, field_name: Option>, + partial_names: Vec>, alternate_name: Option>, mapping_name: Option>, field_flags: Option, @@ -69,10 +72,14 @@ impl FromPdfObject for FieldProperties { result => result?, } .unwrap_or_default(); + let field_name = dictionary.optional::>(b"T")?; + let mut partial_names = parent.partial_names; + partial_names.extend(field_name.clone()); Ok(Self { field_type: dictionary.optional(b"FT")?.or(parent.field_type), - field_name: dictionary.optional(b"T")?.or(parent.field_name), + partial_names, + field_name: field_name.or(parent.field_name), alternate_name: dictionary.optional(b"TU")?.or(parent.alternate_name), mapping_name: dictionary.optional(b"TM")?.or(parent.mapping_name), field_flags: dictionary diff --git a/crates/pdf-web/js/annotation_contract.ts b/crates/pdf-web/js/annotation_contract.ts index 8eb49b1a..a44388f2 100644 --- a/crates/pdf-web/js/annotation_contract.ts +++ b/crates/pdf-web/js/annotation_contract.ts @@ -1,5 +1,31 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +/** + * Outcome of running an annotation's action for one trigger. + * + * Hide actions commit one Core edit and report its revision. Optional content + * changes are presentation state and advance no revision. Actions Core does not + * run are returned for the host, in order. + */ +export type ActionReceipt = { +/** + * Document revision after the committed hide edit; `None` when nothing was edited. + */ +revision: Revision | null, +/** + * Pages whose annotation presentation must be refreshed. + */ +pages: Array, +/** + * Actions the host performs itself, such as navigation, in execution order. + */ +host_actions: Array, }; + +/** + * A user interaction that runs an annotation's action. + */ +export type ActionTrigger = "activate" | "cursor_enter" | "cursor_exit" | "mouse_down" | "mouse_up" | "focus" | "blur"; + /** * Horizontal alignment of annotation text. */ @@ -85,7 +111,15 @@ operation: number | null, } } | { "Trans": { /** * The duration. */ -duration: number | null, } } | "GoTo3DView" | { "Unknown": { +duration: number | null, } } | "GoTo3DView" | { "Hide": { +/** + * The annotations and fields the action applies to. + */ +targets: Array, +/** + * Whether the targets are hidden; `false` shows them. + */ +hide: boolean, } } | { "Sequence": Array } | { "Unknown": { /** * The action subtype name. */ @@ -339,7 +373,11 @@ export type CaretSymbolStyle = "P" | "None" | { "Unknown": Array }; /** * Invalidation information for the GUI's annotation presentation. */ -export type Change = { "change": "created", "data": AnnotationId } | { "change": "updated", "data": AnnotationId } | { "change": "deleted", "data": AnnotationId } | { "change": "field_created", "data": FieldId } | { "change": "field_deleted", "data": FieldId } | { "change": "field_updated", "data": { +export type Change = { "change": "created", "data": AnnotationId } | { "change": "updated", "data": AnnotationId } | { "change": "deleted", "data": AnnotationId } | { "change": "visibility_updated", "data": { +/** + * Every hidden or shown annotation, hidden ones first, in command order. + */ +annotations: Array, } } | { "change": "field_created", "data": FieldId } | { "change": "field_deleted", "data": FieldId } | { "change": "field_updated", "data": { /** * Shared field whose value command was accepted. */ @@ -738,6 +776,15 @@ insets: Rect, */ alignment: Alignment, }; +/** + * One `/T` entry of a hide action. + */ +export type HideTarget = { "FieldName": Array } | { "Object": { +/** + * The object number. + */ +number: string, } } | "Unresolved"; + /** * Selected regions supplied by the host's text-selection system. */ @@ -1080,7 +1127,15 @@ content: AnnotationKind, } | { "operation": "delete", /** * Existing annotation to remove. */ -id: AnnotationId, } | { "operation": "create_field", +id: AnnotationId, } | { "operation": "set_hidden", +/** + * Annotations to hide. + */ +hidden: Array, +/** + * Annotations to show. + */ +shown: Array, } | { "operation": "create_field", /** * Fixed configuration and initial value; incomplete required values are valid. */ @@ -1186,19 +1241,6 @@ visibility_expression: VisibilityExpression | null, }; */ export type OptionalContentPolicy = "AnyOn" | "AllOn" | "AnyOff" | "AllOff"; -/** - * Acknowledgement of an applied optional content visibility change. - * - * Carries no revision: optional content visibility is host presentation state, - * so applying it never edits an annotation and never advances the document - * revision. Only the listed pages need their presentation refreshed. - */ -export type OptionalContentReceipt = { -/** - * Pages whose annotation presentation must be refreshed. - */ -pages: Array, }; - /** * Zero-based index into the host document's pages. */ @@ -1806,6 +1848,10 @@ style: ResolvedStyle, * Host-owned action notification; links fall back to their destination. */ action: AnnotationAction | null, +/** + * Triggers that run an action; the host reports only these through `perform_action`. + */ +triggers: Array, /** * Live Unicode contents. */ @@ -1924,6 +1970,35 @@ bounds: Rect, */ binding: WidgetBinding, }; +/** + * Actions a widget's `/AA` dictionary runs on pointer and focus triggers. + */ +export type WidgetAdditionalActions = { +/** + * `/E`: the pointer enters the annotation. + */ +cursor_enter: AnnotationAction | null, +/** + * `/X`: the pointer leaves the annotation. + */ +cursor_exit: AnnotationAction | null, +/** + * `/D`: a pointer button is pressed inside the annotation. + */ +mouse_down: AnnotationAction | null, +/** + * `/U`: a pointer button is released inside the annotation. + */ +mouse_up: AnnotationAction | null, +/** + * `/Fo`: the annotation receives input focus. + */ +focus: AnnotationAction | null, +/** + * `/Bl`: the annotation loses input focus. + */ +blur: AnnotationAction | null, }; + /** * Owned WidgetAnnotation data retained from the source PDF. */ @@ -1943,6 +2018,12 @@ field_type: Array | null, * The field name. */ field_name: Array | null, +/** + * The partial `/T` names along the `/Parent` chain, outermost first; joined by + * periods they form the fully qualified field name. Defaulted on load so older + * sidecars still deserialize. + */ +partial_names: Array>, /** * The alternate field name. */ @@ -1998,7 +2079,12 @@ border_style: BorderStyle | null, /** * The action dictionary. */ -action: AnnotationAction | null, }; +action: AnnotationAction | null, +/** + * The `/AA` pointer and focus trigger actions. Defaulted on load so older + * sidecars still deserialize. + */ +additional_actions?: WidgetAdditionalActions, }; /** * Binding to shared state; a radio widget additionally names its option. diff --git a/crates/pdf-web/js/annotation_controls.js b/crates/pdf-web/js/annotation_controls.js index 2dc4fc91..8a1dee7f 100644 --- a/crates/pdf-web/js/annotation_controls.js +++ b/crates/pdf-web/js/annotation_controls.js @@ -5,6 +5,13 @@ import { EditPhase } from './annotation_sessions.js'; /** @type {Record} */ const TAGS = { input: 'input', text_area: 'textarea', select: 'select', button: 'button' }; +/** DOM events that run a widget's pointer and focus trigger actions. + * @type {Array<[keyof HTMLElementEventMap, import('./annotation_contract').ActionTrigger]>} */ +const TRIGGER_EVENTS = [ + ['pointerenter', 'cursor_enter'], ['pointerleave', 'cursor_exit'], ['pointerdown', 'mouse_down'], + ['pointerup', 'mouse_up'], ['focus', 'focus'], ['blur', 'blur'], +]; + /** Owns form drafts, composition, option nodes, and control listeners for a Core-derived control. */ export class FormControl extends AnnotationComponent { /** @param {import('./annotation_contract').WebAnnotationEntry} entry @@ -30,11 +37,19 @@ export class FormControl extends AnnotationComponent { if (node instanceof HTMLButtonElement) node.type = 'button'; this.bindDraftEvents(); this.bindPressEvents(); + this.bindTriggerEvents(); this.listen(node, 'click', event => { - if (this.control().kind === ControlKind.Button) { event.preventDefault(); layer.onAction(this.entry); } + if (this.control().kind === ControlKind.Button) { event.preventDefault(); layer.onAction(this.entry, 'activate'); } }); } + /** Reports the pointer and focus triggers the entry has an action for. */ + bindTriggerEvents() { + for (const [name, trigger] of TRIGGER_EVENTS) { + this.listen(this.node, name, () => { if (this.entry.triggers.includes(trigger)) this.layer.onAction(this.entry, trigger); }); + } + } + /** Reports pointer presses on a canvas-painted widget so the host paints its down appearance. */ bindPressEvents() { let pressed = false; @@ -121,6 +136,8 @@ export class FormControl extends AnnotationComponent { this.setPending(true); const accepted = await this.layer.submit(this, command, this.revision, this.viewportRevision); if (this.abort.signal.aborted) return; + // A committed value activates the widget, running its action as a button click does. + if (accepted && this.entry.triggers.includes('activate')) this.layer.onAction(this.entry, 'activate'); this.phase = accepted || !['set_text', 'set_combo_value'].includes(command.operation) ? EditPhase.Clean : EditPhase.Rejected; this.revision = this.layer.snapshot?.annotationRevision; this.viewportRevision = this.layer.snapshot?.viewportRevision; diff --git a/crates/pdf-web/js/annotation_host.ts b/crates/pdf-web/js/annotation_host.ts index 81d0bac6..f9a30488 100644 --- a/crates/pdf-web/js/annotation_host.ts +++ b/crates/pdf-web/js/annotation_host.ts @@ -1,5 +1,5 @@ // Browser-owned lifecycle types. Wire fields come exclusively from the generated contract. -import type { Operation, WebAnnotationEntry } from './annotation_contract'; +import type { ActionTrigger, Operation, WebAnnotationEntry } from './annotation_contract'; export interface AnnotationLayerSnapshot { entries: WebAnnotationEntry[]; @@ -11,7 +11,9 @@ export interface AnnotationLayerCallbacks { onCommand(entry: WebAnnotationEntry, command: Operation, revision: string, viewportRevision: number): void | Promise; /** Converts a container CSS displacement into a PDF page displacement for drags. */ toPageDelta(entry: WebAnnotationEntry, dx: number, dy: number): [number, number]; - onAction?(entry: WebAnnotationEntry): void; + /** Reports an interaction that runs the entry's action; only listed `entry.triggers` + * are reported, except `activate`, which buttons and links always report. */ + onAction?(entry: WebAnnotationEntry, trigger: ActionTrigger): void; /** Reports a canvas-painted widget held down (`true`) or released (`false`). */ onPress?(entry: WebAnnotationEntry, pressed: boolean): void; onError?(error: unknown): void; diff --git a/crates/pdf-web/js/annotation_visuals.js b/crates/pdf-web/js/annotation_visuals.js index 5d7c7f3d..be5d68f8 100644 --- a/crates/pdf-web/js/annotation_visuals.js +++ b/crates/pdf-web/js/annotation_visuals.js @@ -58,7 +58,7 @@ export class VisualComponent extends AnnotationComponent { return; } if (!this.node.hasAttribute('href')) event.preventDefault(); - layer.onAction(this.entry); + layer.onAction(this.entry, 'activate'); }); } update() { diff --git a/crates/pdf-web/src/annotation_overlay.rs b/crates/pdf-web/src/annotation_overlay.rs index 85cf9378..fe73dd90 100644 --- a/crates/pdf-web/src/annotation_overlay.rs +++ b/crates/pdf-web/src/annotation_overlay.rs @@ -1,8 +1,8 @@ //! Browser adapter around Core's backend-neutral annotation overlay. use crate::{WebViewport, annotation_entry::WebAnnotationEntry, error::WebResult}; use pdf_annotation_core::{ - AnnotationCommandRequest, AnnotationOverlay, AnnotationReceipt, OptionalContentReceipt, - PaintedAppearance, PreparedAnnotations, models::AnnotationId, pdf_data::AnnotationAction, + ActionReceipt, ActionTrigger, AnnotationCommandRequest, AnnotationOverlay, AnnotationReceipt, + PaintedAppearance, PreparedAnnotations, models::AnnotationId, }; use pdf_document::{diagnostic::PdfReadDiagnostic, document::PdfDocument}; use pdf_graphics::{rect::Rect, transform::Transform}; @@ -146,15 +146,18 @@ impl WebAnnotationOverlay { .accept_event(command, viewport.map(WebViewport::revision), page_bounds)?) } - /// Applies a `SetOCGState` action to runtime optional content visibility. + /// Runs the action `trigger` selects on annotation `id`: hide steps commit one + /// Core edit, optional content steps change visibility, and the rest return to + /// the host. /// /// # Errors - /// Returns an error when the action is not a `SetOCGState` action. - pub fn set_optional_content_state( + /// Rejects an unknown annotation and propagates Core errors from the hide edit. + pub fn perform_action( &mut self, - action: &AnnotationAction, - ) -> WebResult { - Ok(self.overlay.set_optional_content_state(action)?) + id: AnnotationId, + trigger: ActionTrigger, + ) -> WebResult { + Ok(self.overlay.perform_action(id, trigger)?) } /// Returns the widget appearances painted on `page`'s canvas under live field state, diff --git a/examples/web-canvas/host.js b/examples/web-canvas/host.js index eea276fd..dfd953f5 100644 --- a/examples/web-canvas/host.js +++ b/examples/web-canvas/host.js @@ -58,6 +58,7 @@ const pdfText = bytes => bytes[0] === 0xfe && bytes[1] === 0xff // Describes an annotation action for the status bar without executing it. function describeAction(action) { if (typeof action === 'string') return `Annotation action: ${action}`; + if (action.Sequence) return action.Sequence.map(describeAction).join('; '); const remote = action.GoToRemote; if (!remote) return `Annotation action: ${Object.keys(action)[0]}`; const spec = remote.file_specification; @@ -225,7 +226,7 @@ class PdfViewer { page.nativeLayer = new NativeAnnotationLayer(page.annotations, { onCommand: (entry, command, revision, viewportRevision) => this.sendAnnotationEvent(entry, command, revision, viewportRevision), toPageDelta: (entry, dx, dy) => Array.from(this.pdf.page_delta(entry.page, dx, dy)), - onAction: entry => this.handleAnnotationAction(entry), + onAction: (entry, trigger) => this.handleAnnotationAction(entry, trigger), onPress: (entry, pressed) => this.pressAnnotation(entry, pressed), onError: error => this.showError(error), }); @@ -263,19 +264,18 @@ class PdfViewer { return receipt; } - // Applies a SetOCGState action to optional content visibility, else reports the - // action. Toggling a group only changes presentation, so the pages the receipt - // names are re-requested without any revision advancing. - handleAnnotationAction(entry) { - if (entry.action && typeof entry.action === 'object' && 'SetOCGState' in entry.action) { - try { - const receipt = JSON.parse(this.pdf.set_optional_content_state(JSON.stringify(entry.action))); - this.refreshAnnotations(receipt.pages); - this.elements.status.textContent = `Optional content updated (${receipt.pages.length} page(s))`; - } catch (error) { this.showError(error); } - return; - } - this.elements.status.textContent = entry.action ? describeAction(entry.action) : entry.text || ''; + // Runs the action the trigger selects. Hide and optional content steps run in the + // document, so the pages the receipt names are re-requested; the remaining steps, + // such as navigation, are only reported. Clicks without an action show the text. + handleAnnotationAction(entry, trigger) { + let receipt; + try { + receipt = JSON.parse(this.pdf.perform_action(entry.id, JSON.stringify(trigger))); + } catch (error) { this.showError(error); return; } + try { this.refreshAnnotations(receipt.pages); } + catch (error) { this.showError(`Action committed; presentation refresh failed: ${error}`); } + if (receipt.host_actions.length) this.elements.status.textContent = receipt.host_actions.map(describeAction).join('; '); + else if (trigger === 'activate' && !entry.triggers.includes('activate')) this.elements.status.textContent = entry.text || ''; } // Repaints the page with the widget's down appearance while it is held. diff --git a/examples/web-canvas/renderer.rs b/examples/web-canvas/renderer.rs index 3ac4930b..410153c5 100644 --- a/examples/web-canvas/renderer.rs +++ b/examples/web-canvas/renderer.rs @@ -1,5 +1,5 @@ //! A small document owner demonstrating the browser crate's host-facing APIs. -use pdf_annotation_core::{models::AnnotationId, pdf_data::AnnotationAction}; +use pdf_annotation_core::{ActionTrigger, models::AnnotationId}; use pdf_document::{document::PdfDocument, reader::PdfReader}; use pdf_graphics::{size::Size, transform::Transform}; use pdf_graphics_web::{WebCanvasBackend, WebCanvasOptions}; @@ -218,17 +218,13 @@ impl WebDocument { self.accept_event(WebAnnotationEvent::new(json)?) } - /// Applies a `SetOCGState` link action to runtime optional content visibility, - /// returning the pages the host must request again as JSON. - /// - /// `action_json` is the same `action` value the host received on an annotation - /// entry, so there is no second request schema to keep in sync. - pub fn set_optional_content_state(&mut self, action_json: &str) -> Result { - let action: AnnotationAction = serde_json::from_str(action_json).map_err(error)?; - let receipt = self - .overlay - .set_optional_content_state(&action) - .map_err(error)?; + /// Runs the action `trigger` (a JSON `ActionTrigger` string) selects on + /// `annotation` (a decimal Core id), returning the `ActionReceipt` as JSON: the + /// pages the host must request again and the actions it performs itself. + pub fn perform_action(&mut self, annotation: &str, trigger: &str) -> Result { + let id = AnnotationId(annotation.parse().map_err(error)?); + let trigger: ActionTrigger = serde_json::from_str(trigger).map_err(error)?; + let receipt = self.overlay.perform_action(id, trigger).map_err(error)?; serde_json::to_string(&receipt).map_err(error) } diff --git a/xtask/src/annotation_types.rs b/xtask/src/annotation_types.rs index edf82783..d959a576 100644 --- a/xtask/src/annotation_types.rs +++ b/xtask/src/annotation_types.rs @@ -1,6 +1,6 @@ //! Reproducible annotation declarations. Export is explicit, never a generated test. use anyhow::{Context, Result, bail}; -use pdf_annotation_core::{AnnotationCommandRequest, AnnotationReceipt, OptionalContentReceipt}; +use pdf_annotation_core::{ActionReceipt, AnnotationCommandRequest, AnnotationReceipt}; use pdf_web::WebAnnotationEntry; use std::path::Path; use ts_rs::TS; @@ -23,7 +23,7 @@ pub(crate) fn generate(check: bool) -> Result<()> { WebAnnotationEntry::export_all(&config)?; AnnotationCommandRequest::export_all(&config)?; AnnotationReceipt::export_all(&config)?; - OptionalContentReceipt::export_all(&config)?; + ActionReceipt::export_all(&config)?; // ts-rs emits spaces before newlines; normalize deterministically for review. let generated_text = std::fs::read_to_string(&generated)?; let contents = (generated_text