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
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

73 changes: 73 additions & 0 deletions crates/pdf-annotation-core/src/appearance_paint.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
//! Source appearances a host paints on the page canvas in place of a native control.
use crate::{
engine::DocumentView, entry::ControlKind, layer_error::AnnotationLayerResult,
models::Annotation, projection, widget_control,
};
use pdf_graphics::rect::Rect;
use pdf_object_reader::pdf_object::PdfObject;
use pdf_optional_content::OptionalContentState;

/// The `/Off` appearance state of an unselected button.
const OFF_STATE: &[u8] = b"Off";

/// One widget's normal appearance, selected by its live state and placed at its rectangle.
#[derive(Clone, Debug)]
pub struct PaintedAppearance {
/// The resolved `/AP /N` entry, or `/D` while pressed: a drawing stream, or a
/// dictionary of state streams.
pub normal: PdfObject,
/// The state to draw from a state dictionary; `None` draws nothing from one.
pub state: Option<Vec<u8>>,
/// The annotation rectangle in PDF page user space.
pub rect: Rect,
}

impl PaintedAppearance {
/// The appearance of a displayed widget whose control is `painted`, or `None`.
///
/// Checkboxes and radio options draw their on-state while live Core state selects
/// them and `Off` otherwise; push buttons draw their source `/AS`. A `pressed`
/// widget draws its down appearance when it has one.
///
/// # Errors
/// Returns an error when a widget's bound field is missing.
pub(crate) fn new(
annotation: &Annotation,
view: &DocumentView<'_>,
optional_content: &OptionalContentState,
pressed: bool,
) -> AnnotationLayerResult<Option<Self>> {
if !projection::displayed(annotation, optional_content) {
return Ok(None);
}
let Some(field) = projection::widget_field(annotation, view)? else {
return Ok(None);
};
let Some(control) = widget_control::control(annotation, field).filter(|c| c.painted) else {
return Ok(None);
};
let Some(source) = annotation.source() else {
return Ok(None);
};
let appearance = source.appearance.as_ref();
let down = appearance
.and_then(|ap| ap.down.clone())
.filter(|_| pressed);
let (Some(normal), Some(rect)) = (
down.or_else(|| appearance.and_then(|ap| ap.normal.clone())),
source.rect,
) else {
return Ok(None);
};
let state = match control.kind {
ControlKind::Button => source.appearance_state.as_deref().map(<[u8]>::to_vec),
_ if control.checked => source.button_on_state().ok().map(<[u8]>::to_vec),
_ => Some(OFF_STATE.to_vec()),
};
Ok(Some(Self {
normal,
state,
rect: rect.normalized(),
}))
}
}
3 changes: 3 additions & 0 deletions crates/pdf-annotation-core/src/entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ pub struct WidgetControl {
pub top_index: u32,
/// Counterclockwise content rotation in degrees from the source appearance.
pub rotation: i32,
/// Whether the host paints the source appearance on the page canvas, leaving the
/// control a transparent hit and input target.
pub painted: bool,
}

/// One Core annotation projected into logical device coordinates for a host to present.
Expand Down
3 changes: 3 additions & 0 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;

/// Source appearances hosts paint on the page canvas.
pub mod appearance_paint;
/// Backend-neutral annotation entries projected into one host viewport.
pub mod entry;
mod import;
Expand All @@ -74,6 +76,7 @@ mod shape_path;
pub mod shapes;
mod widget_control;

pub use appearance_paint::PaintedAppearance;
pub use entry::{AnnotationEntry, ControlKind, ControlTag, WidgetControl};
pub use layer_error::{AnnotationLayerError, AnnotationLayerResult};
pub use overlay::{AnnotationOverlay, PreparedAnnotations};
Expand Down
26 changes: 26 additions & 0 deletions crates/pdf-annotation-core/src/overlay.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! Document-owned Core editing and disposable per-viewport annotation entries.
use crate::{
appearance_paint::PaintedAppearance,
commands::Operation,
engine::DocumentView,
entry::AnnotationEntry,
Expand Down Expand Up @@ -152,6 +153,31 @@ impl AnnotationOverlay {
}
}

/// Returns the source appearances of `page`'s displayed button widgets in
/// presentation order, selected by live field state, for painting on the page canvas.
/// The `pressed` widget draws its down appearance.
///
/// # Errors
/// Rejects absent pages and widgets whose bound field is missing.
pub fn painted_appearances(
&self,
page: u32,
pressed: Option<AnnotationId>,
) -> AnnotationLayerResult<Vec<PaintedAppearance>> {
let view = self.document.engine.view();
if page >= view.data().document.page_count {
return Err(AnnotationLayerError::PageOutOfRange(page));
}
self.page_annotations(&view, page)
.into_iter()
.filter_map(|annotation| {
let pressed = pressed == Some(annotation.id);
PaintedAppearance::new(annotation, &view, &self.optional_content, pressed)
.transpose()
})
.collect()
}

/// Validates a GUI context, clamps interactive dragging, and dispatches one edit.
/// `viewport_revision` and `page_bounds` are required for a mounted target and dragging.
/// Programmatic operations omit the target and retain Core's unclamped semantics.
Expand Down
13 changes: 12 additions & 1 deletion crates/pdf-annotation-core/src/pdf_data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use std::sync::Arc;

use crate::{metadata::AnnotationFlags, models::Quad};
use pdf_graphics::{DashPattern, color::Color, polyline::Polyline, rect::Rect};
use pdf_object_reader::pdf_object::PdfObject;
use pdf_optional_content::{OcgStateEntry, OptionalContentMembership};

bitflags::bitflags! {
Expand Down Expand Up @@ -204,8 +205,18 @@ pub struct AppearanceCharacteristics {
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "typescript", ts(export_to = "annotation_contract.ts"))]
pub struct AppearanceDictionary {
/// Normal-state names; no drawing resources are retained.
/// Normal-state names.
pub normal_states: Vec<Vec<u8>>,
/// The resolved `/N` entry, a drawing stream or a dictionary of state streams,
/// kept unparsed for hosts that paint the appearance. Not serialized.
#[serde(skip)]
#[cfg_attr(feature = "typescript", ts(skip))]
pub normal: Option<PdfObject>,
/// The resolved `/D` entry shown while the annotation is pressed, kept unparsed
/// like `normal`. Not serialized.
#[serde(skip)]
#[cfg_attr(feature = "typescript", ts(skip))]
pub down: Option<PdfObject>,
}

/// Owned FileSpecification data retained from the source PDF.
Expand Down
21 changes: 13 additions & 8 deletions crates/pdf-annotation-core/src/projection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use pdf_graphics::{point::Point, rect::Rect, size::Size, viewport::PageViewport}
use pdf_optional_content::OptionalContentState;

/// The shared field a widget is bound to; `None` for other kinds.
fn widget_field<'a>(
pub(crate) fn widget_field<'a>(
annotation: &Annotation,
view: &DocumentView<'a>,
) -> AnnotationLayerResult<Option<&'a Field>> {
Expand Down Expand Up @@ -58,6 +58,17 @@ fn device_regions(
Ok(regions)
}

/// Whether `annotation` is shown under its flags and `optional_content`.
pub(crate) fn displayed(annotation: &Annotation, optional_content: &OptionalContentState) -> bool {
// Optional content hides an annotation without altering its flags, so the two
// conditions are independent: either one alone suppresses display.
annotation.is_visible()
&& annotation
.source()
.and_then(|source| source.optional_content.as_ref())
.is_none_or(|content| optional_content.is_visible(content))
}

pub(crate) fn project(
annotation: &Annotation,
view: &DocumentView<'_>,
Expand All @@ -73,13 +84,7 @@ pub(crate) fn project(
let field = widget_field(annotation, view)?;
let style = annotation.style()?;
let action = annotation.action();
// Optional content hides an annotation without altering its flags, so the two
// conditions are independent: either one alone suppresses display.
let visible = annotation.is_visible()
&& annotation
.source()
.and_then(|source| source.optional_content.as_ref())
.is_none_or(|content| optional_content.is_visible(content));
let visible = displayed(annotation, optional_content);
Ok(AnnotationEntry {
id: annotation.id,
page: annotation.page.0,
Expand Down
28 changes: 25 additions & 3 deletions crates/pdf-annotation-core/src/source_decode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,20 +119,38 @@ impl FromPdfObject for PageAnnotations {
}
}

/// Reads `/AP` normal-state names; drawing streams are never decoded.
/// Reads `/AP` normal-state names and keeps the resolved `/N` and `/D` entries unparsed.
impl FromPdfObject for AppearanceDictionary {
fn from_pdf_object(context: ObjectContext<'_, impl ObjectAccess + ?Sized>) -> ReadResult<Self> {
let mut appearance = context.dictionary()?;
// A malformed down appearance only loses the pressed look.
let down = appearance.resolve_entry(b"D")?.filter(|down| {
matches!(
down.value(),
ObjectVariant::Stream(_) | ObjectVariant::Dictionary(_)
)
});
let Some(normal) = appearance.resolve_entry(b"N")? else {
return Ok(Self {
normal_states: Vec::new(),
normal: None,
down,
});
};
let states = match normal.value() {
ObjectVariant::Dictionary(states) => states,
ObjectVariant::Stream(_) | ObjectVariant::Null => {
ObjectVariant::Stream(_) => {
return Ok(Self {
normal_states: Vec::new(),
normal: Some(normal),
down,
});
}
ObjectVariant::Null => {
return Ok(Self {
normal_states: Vec::new(),
normal: None,
down,
});
}
_ => {
Expand All @@ -157,7 +175,11 @@ impl FromPdfObject for AppearanceDictionary {
}
}
}
Ok(Self { normal_states })
Ok(Self {
normal_states,
normal: Some(normal),
down,
})
}
}

Expand Down
13 changes: 13 additions & 0 deletions crates/pdf-annotation-core/src/widget_control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ pub(crate) fn control(annotation: &Annotation, field: &Field) -> Option<WidgetCo
.and_then(|w| w.appearance_characteristics.as_ref())
.and_then(|mk| mk.rotation)
.unwrap_or(0),
painted: false,
};
match &definition.content {
FieldKind::Text(text) => {
Expand Down Expand Up @@ -129,6 +130,18 @@ pub(crate) fn control(annotation: &Annotation, field: &Field) -> Option<WidgetCo
}
_ => return None,
}
model.painted = matches!(
model.kind,
ControlKind::Checkbox
| ControlKind::CheckboxGroup
| ControlKind::Radio
| ControlKind::Button
) && annotation
.metadata
.source
.as_deref()
.and_then(|source| source.appearance.as_ref())
.is_some_and(|appearance| appearance.normal.is_some());
Some(model)
}

Expand Down
2 changes: 2 additions & 0 deletions crates/pdf-renderer/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ pdf-content-stream = { path = "../pdf-content-stream" }
pdf-document = { path = "../pdf-document" }
pdf-object-reader = { path = "../pdf-object-reader" }
pdf-canvas = { path = "../pdf-canvas" }
pdf-annotation-core = { path = "../pdf-annotation-core" }
pdf-resources = { path = "../pdf-resources" }
pdf-graphics = { path = "../pdf-graphics" }
pdf-font = { path = "../pdf-font" }
pdf-text-engine = { path = "../pdf-text-engine" }
Expand Down
Loading
Loading