diff --git a/Cargo.lock b/Cargo.lock index a09023d4..521a3683 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2486,6 +2486,7 @@ dependencies = [ name = "pdf-renderer" version = "0.1.0" dependencies = [ + "pdf-annotation-core", "pdf-canvas", "pdf-cmap", "pdf-content-stream", @@ -2494,6 +2495,7 @@ dependencies = [ "pdf-graphics", "pdf-object-reader", "pdf-optional-content", + "pdf-resources", "pdf-text-engine", "pdf-utils", "thiserror 2.0.12", diff --git a/crates/pdf-annotation-core/src/appearance_paint.rs b/crates/pdf-annotation-core/src/appearance_paint.rs new file mode 100644 index 00000000..5082613a --- /dev/null +++ b/crates/pdf-annotation-core/src/appearance_paint.rs @@ -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>, + /// 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> { + 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(), + })) + } +} diff --git a/crates/pdf-annotation-core/src/entry.rs b/crates/pdf-annotation-core/src/entry.rs index c5f53242..71d48f57 100644 --- a/crates/pdf-annotation-core/src/entry.rs +++ b/crates/pdf-annotation-core/src/entry.rs @@ -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. diff --git a/crates/pdf-annotation-core/src/lib.rs b/crates/pdf-annotation-core/src/lib.rs index a8741366..22775a9f 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; +/// 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; @@ -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}; diff --git a/crates/pdf-annotation-core/src/overlay.rs b/crates/pdf-annotation-core/src/overlay.rs index 164b2574..6cb1d453 100644 --- a/crates/pdf-annotation-core/src/overlay.rs +++ b/crates/pdf-annotation-core/src/overlay.rs @@ -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, @@ -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, + ) -> AnnotationLayerResult> { + 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. diff --git a/crates/pdf-annotation-core/src/pdf_data.rs b/crates/pdf-annotation-core/src/pdf_data.rs index a62e6825..c968ada2 100644 --- a/crates/pdf-annotation-core/src/pdf_data.rs +++ b/crates/pdf-annotation-core/src/pdf_data.rs @@ -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! { @@ -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>, + /// 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, + /// 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, } /// Owned FileSpecification data retained from the source PDF. diff --git a/crates/pdf-annotation-core/src/projection.rs b/crates/pdf-annotation-core/src/projection.rs index 66414941..2fe37df4 100644 --- a/crates/pdf-annotation-core/src/projection.rs +++ b/crates/pdf-annotation-core/src/projection.rs @@ -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> { @@ -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<'_>, @@ -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, diff --git a/crates/pdf-annotation-core/src/source_decode.rs b/crates/pdf-annotation-core/src/source_decode.rs index 60b58e5e..c8b019e1 100644 --- a/crates/pdf-annotation-core/src/source_decode.rs +++ b/crates/pdf-annotation-core/src/source_decode.rs @@ -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 { 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, }); } _ => { @@ -157,7 +175,11 @@ impl FromPdfObject for AppearanceDictionary { } } } - Ok(Self { normal_states }) + Ok(Self { + normal_states, + normal: Some(normal), + down, + }) } } diff --git a/crates/pdf-annotation-core/src/widget_control.rs b/crates/pdf-annotation-core/src/widget_control.rs index d61d1970..38978edd 100644 --- a/crates/pdf-annotation-core/src/widget_control.rs +++ b/crates/pdf-annotation-core/src/widget_control.rs @@ -49,6 +49,7 @@ pub(crate) fn control(annotation: &Annotation, field: &Field) -> Option { @@ -129,6 +130,18 @@ pub(crate) fn control(annotation: &Annotation, field: &Field) -> Option 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) } diff --git a/crates/pdf-renderer/Cargo.toml b/crates/pdf-renderer/Cargo.toml index 8f5144c5..42866d96 100644 --- a/crates/pdf-renderer/Cargo.toml +++ b/crates/pdf-renderer/Cargo.toml @@ -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" } diff --git a/crates/pdf-renderer/src/annotation_appearance.rs b/crates/pdf-renderer/src/annotation_appearance.rs new file mode 100644 index 00000000..42299cbb --- /dev/null +++ b/crates/pdf-renderer/src/annotation_appearance.rs @@ -0,0 +1,93 @@ +//! Paints widget appearance streams over rendered page content. +use crate::{PdfRenderer, PdfRendererError}; +use pdf_annotation_core::PaintedAppearance; +use pdf_canvas::{PageViewport, canvas_backend::CanvasBackend, pdf_canvas::PdfCanvas}; +use pdf_graphics::{rect::Rect, transform::Transform}; +use pdf_object_reader::object_variant::ObjectVariant; +use pdf_optional_content::OptionalContentState; +use pdf_resources::form::FormXObject; +use std::sync::Arc; + +impl PdfRenderer { + /// Paints `appearances` over page `page_index`, mapping the page through `viewport`. + /// + /// Call after the page content pass on the same backend. Glyphs are not recorded, + /// so appearance text stays out of the page's selectable text. An appearance whose + /// stream cannot be read is skipped without affecting the others. + /// + /// # Errors + /// Returns an error if the page is missing or drawing an appearance fails. + pub fn render_annotation_appearances( + &self, + canvas_backend: &mut B, + page_index: usize, + viewport: &PageViewport, + optional_content: &OptionalContentState, + appearances: &[PaintedAppearance], + ) -> Result<(), PdfRendererError> { + if appearances.is_empty() { + return Ok(()); + } + let page = self.page(page_index)?; + let mut canvas = PdfCanvas::new_with_viewport( + canvas_backend, + page, + viewport, + Arc::clone(&self.font_system), + )? + .with_image_cache(Arc::clone(&self.image_cache)) + .with_optional_content(optional_content); + for appearance in appearances { + let Some(form) = self.appearance_form(appearance) else { + continue; + }; + let Some(matrix) = appearance_matrix(&form, &appearance.rect) else { + continue; + }; + let resources = form + .resources + .as_ref() + .map(|resources| resources.get()) + .transpose()?; + canvas.render_content_stream( + &form.content_stream, + Some(matrix), + form.bbox.as_ref(), + resources, + None, + )?; + } + Ok(()) + } + + /// Reads the form drawn for `appearance`: its stream, or its state's stream. + fn appearance_form(&self, appearance: &PaintedAppearance) -> Option { + let stream = match appearance.normal.value() { + ObjectVariant::Dictionary(states) => states.get(appearance.state.as_deref()?)?, + normal => normal, + }; + self.document.objects().read::(stream).ok() + } +} + +/// The form matrix composed with the map from its transformed `/BBox` onto `rect`. +/// +/// Returns `None` when the transformed box has no area. +fn appearance_matrix(form: &FormXObject, rect: &Rect) -> Option { + let matrix = form.matrix.unwrap_or_default(); + let bbox = matrix.map_rect(form.bbox.as_ref()?); + let (width, height) = (bbox.width(), bbox.height()); + if width <= 0.0 || height <= 0.0 { + return None; + } + let (sx, sy) = (rect.width() / width, rect.height() / height); + let to_rect = Transform::from_row( + sx, + 0.0, + 0.0, + sy, + rect.left - bbox.left * sx, + rect.top - bbox.top * sy, + ); + Some(to_rect.post_concatenated(&matrix)) +} diff --git a/crates/pdf-renderer/src/lib.rs b/crates/pdf-renderer/src/lib.rs index 7afb0aed..2020b6fa 100644 --- a/crates/pdf-renderer/src/lib.rs +++ b/crates/pdf-renderer/src/lib.rs @@ -9,6 +9,7 @@ use pdf_text_engine::FontSystem; use std::sync::Arc; use thiserror::Error; +mod annotation_appearance; mod document_text_selection; pub mod page_cache; pub mod text_selection; diff --git a/crates/pdf-web/js/annotation_contract.ts b/crates/pdf-web/js/annotation_contract.ts index 5ec57463..8eb49b1a 100644 --- a/crates/pdf-web/js/annotation_contract.ts +++ b/crates/pdf-web/js/annotation_contract.ts @@ -231,7 +231,7 @@ alternate_caption: Array | null, }; */ export type AppearanceDictionary = { /** - * Normal-state names; no drawing resources are retained. + * Normal-state names. */ normal_states: Array>, }; @@ -2109,7 +2109,12 @@ top_index: number, /** * Counterclockwise content rotation in degrees from the source appearance. */ -rotation: number, }; +rotation: number, +/** + * Whether the host paints the source appearance on the page canvas, leaving the + * control a transparent hit and input target. + */ +painted: boolean, }; /** * Owned WidgetFieldValue data retained from the source PDF. diff --git a/crates/pdf-web/js/annotation_controls.js b/crates/pdf-web/js/annotation_controls.js index 3975d329..2dc4fc91 100644 --- a/crates/pdf-web/js/annotation_controls.js +++ b/crates/pdf-web/js/annotation_controls.js @@ -29,11 +29,28 @@ export class FormControl extends AnnotationComponent { if (node instanceof HTMLInputElement && model.input_type) node.type = model.input_type; if (node instanceof HTMLButtonElement) node.type = 'button'; this.bindDraftEvents(); + this.bindPressEvents(); this.listen(node, 'click', event => { if (this.control().kind === ControlKind.Button) { event.preventDefault(); layer.onAction(this.entry); } }); } + /** Reports pointer presses on a canvas-painted widget so the host paints its down appearance. */ + bindPressEvents() { + let pressed = false; + const release = () => { + if (!pressed) return; + pressed = false; + this.layer.onPress(this.entry, false); + }; + this.listen(this.node, 'pointerdown', event => { + if (event.button !== 0 || !this.control().painted || !this.entry.editable && this.control().kind !== ControlKind.Button) return; + pressed = true; + this.layer.onPress(this.entry, true); + }); + for (const name of /** @type {const} */ (['pointerup', 'pointercancel', 'pointerleave'])) this.listen(this.node, name, release); + } + /** @returns {import('./annotation_contract').WidgetControl} */ control() { if (!this.entry.control) throw new Error('Widget lost its resolved field'); @@ -119,6 +136,7 @@ export class FormControl extends AnnotationComponent { update() { const p = this.control(); + this.node.classList.toggle('native-painted', p.painted); this.setPending(this.phase === EditPhase.Committing); if (p.options) this.updateChoice(p); else if (p.input_type === 'checkbox' || p.input_type === 'radio' || p.tag === 'button') this.updateButton(p); @@ -147,7 +165,7 @@ export class FormControl extends AnnotationComponent { // PDF radios in unison may have several checked members; HTML grouping must not override /AS. node.name = `pdf-${this.entry.page}-${this.entry.id}`; } else if (node instanceof HTMLButtonElement) { - node.textContent = p.caption || p.label || 'Button'; + node.textContent = p.painted ? '' : p.caption || p.label || 'Button'; node.title = p.caption || p.label; } } diff --git a/crates/pdf-web/js/annotation_host.ts b/crates/pdf-web/js/annotation_host.ts index b2ffaf9a..81d0bac6 100644 --- a/crates/pdf-web/js/annotation_host.ts +++ b/crates/pdf-web/js/annotation_host.ts @@ -12,5 +12,7 @@ export interface AnnotationLayerCallbacks { /** 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 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_layer.css b/crates/pdf-web/js/annotation_layer.css index abec35fa..46b0adf5 100644 --- a/crates/pdf-web/js/annotation_layer.css +++ b/crates/pdf-web/js/annotation_layer.css @@ -1,9 +1,13 @@ -.native-annotations { position: absolute; inset: 0; pointer-events: none; } +/* Form-field highlight composited over each non-button widget; hosts may set it to `transparent`. */ +.native-annotations { position: absolute; inset: 0; pointer-events: none; --native-form-highlight: rgb(221 228 255 / 0.392); } .native-annotation { position: absolute; left: 0; top: 0; transform-origin: 0 0; box-sizing: border-box; pointer-events: auto; } .native-control { display: block; width: 100%; height: 100%; margin: 0; box-sizing: border-box; padding: 0; overflow: hidden; white-space: pre-wrap; } .native-control:is(input,textarea,select), .native-free-text-editor { user-select: text; touch-action: auto; } -.native-control:is(input,textarea,select,button) { background: white; border: 1px solid #777; border-radius: 0; } -.native-control:is(input[type=radio],input[type=checkbox]) { appearance: auto; } +.native-control:is(input,textarea,select,button) { background-color: white; border: 1px solid #777; border-radius: 0; } +/* Push buttons are not highlighted, so their appearance keeps full color. */ +.native-control:is(input,textarea,select) { background-image: linear-gradient(var(--native-form-highlight), var(--native-form-highlight)); } +.native-control:is(input[type=radio],input[type=checkbox]):not(.native-painted) { appearance: auto; } +.native-control.native-painted { appearance: none; } button.native-control { cursor: pointer; white-space: nowrap; text-align: center; } button.native-control:hover:not(:disabled) { filter: brightness(0.94); } button.native-control:active:not(:disabled) { filter: brightness(0.67); } diff --git a/crates/pdf-web/js/annotation_layer.js b/crates/pdf-web/js/annotation_layer.js index ac90eb6c..47bf63c1 100644 --- a/crates/pdf-web/js/annotation_layer.js +++ b/crates/pdf-web/js/annotation_layer.js @@ -15,8 +15,8 @@ import { VisualComponent } from './annotation_visuals.js'; export class NativeAnnotationLayer { /** @param {HTMLElement} root * @param {import('./annotation_host').AnnotationLayerCallbacks} callbacks */ - constructor(root, { onCommand, toPageDelta, onAction = () => { }, onError = () => { } }) { - this.root = root; this.onCommand = onCommand; this.toPageDelta = toPageDelta; this.onAction = onAction; this.onError = onError; + constructor(root, { onCommand, toPageDelta, onAction = () => { }, onPress = () => { }, onError = () => { } }) { + this.root = root; this.onCommand = onCommand; this.toPageDelta = toPageDelta; this.onAction = onAction; this.onPress = onPress; this.onError = onError; /** @type {Map} */ this.nodes = new Map(); /** @type {import('./annotation_host').AnnotationLayerSnapshot | undefined} */ @@ -83,7 +83,11 @@ export class NativeAnnotationLayer { node.setAttribute('aria-label', entry.control?.label || entry.text || entry.subtype); const font = { fontSize: `${style.font_size}px`, fontFamily: style.font_family, textAlign: style.alignment }; Object.assign(container.style, font); - Object.assign(node.style, { ...font, color: css.color, backgroundColor: css.background, border: css.border }); + // A canvas-painted widget shows its PDF appearance through a transparent control. + const painted = entry.control?.painted; + Object.assign(node.style, painted + ? { ...font, color: 'transparent', backgroundColor: 'transparent', border: 'none' } + : { ...font, color: css.color, backgroundColor: css.background, border: css.border }); component.update(); } /** Notes inherit a linked popup's initial open state once, then every note reconciles. */ diff --git a/crates/pdf-web/src/annotation_overlay.rs b/crates/pdf-web/src/annotation_overlay.rs index ccfa0ca3..85cf9378 100644 --- a/crates/pdf-web/src/annotation_overlay.rs +++ b/crates/pdf-web/src/annotation_overlay.rs @@ -2,7 +2,7 @@ use crate::{WebViewport, annotation_entry::WebAnnotationEntry, error::WebResult}; use pdf_annotation_core::{ AnnotationCommandRequest, AnnotationOverlay, AnnotationReceipt, OptionalContentReceipt, - PreparedAnnotations, pdf_data::AnnotationAction, + PaintedAppearance, PreparedAnnotations, models::AnnotationId, pdf_data::AnnotationAction, }; use pdf_document::{diagnostic::PdfReadDiagnostic, document::PdfDocument}; use pdf_graphics::{rect::Rect, transform::Transform}; @@ -65,6 +65,9 @@ impl PreparedAnnotationOverlay { /// One Core engine per document, independent of page mounts and canvas resources. pub struct WebAnnotationOverlay { overlay: AnnotationOverlay, + /// The widget held down by the pointer, painted with its down appearance. + /// Presentation state only; it never advances the document revision. + pressed: Option, } impl WebAnnotationOverlay { @@ -79,6 +82,7 @@ impl WebAnnotationOverlay { .map(|page| page.annotations.as_deref().unwrap_or_default()); Ok(Self { overlay: AnnotationOverlay::new(pages, document.optional_content.as_ref())?, + pressed: None, }) } @@ -153,6 +157,20 @@ impl WebAnnotationOverlay { Ok(self.overlay.set_optional_content_state(action)?) } + /// Returns the widget appearances painted on `page`'s canvas under live field state, + /// with the pressed widget's down appearance. + /// + /// # Errors + /// Propagates annotation layer errors for absent pages or unbound widgets. + pub fn painted_appearances(&self, page: u32) -> WebResult> { + Ok(self.overlay.painted_appearances(page, self.pressed)?) + } + + /// Marks the widget the pointer holds down, or none; the host repaints its page. + pub fn set_pressed(&mut self, pressed: Option) { + self.pressed = pressed; + } + /// Returns the runtime optional content visibility page content is rendered with. pub(crate) fn optional_content(&self) -> &OptionalContentState { self.overlay.optional_content() diff --git a/crates/pdf-web/src/page_renderer.rs b/crates/pdf-web/src/page_renderer.rs index 2f0d1f55..b0d9c583 100644 --- a/crates/pdf-web/src/page_renderer.rs +++ b/crates/pdf-web/src/page_renderer.rs @@ -39,11 +39,12 @@ impl WebPageRenderer { self.renderer.document() } - /// Renders only page content via PdfRenderer, collects its existing TextGlyph output, - /// and prepares annotations separately through the supplied overlay layer. + /// Renders page content via PdfRenderer, collects its existing TextGlyph output, + /// paints the appearances of widgets whose native controls are transparent, and + /// prepares annotations separately through the supplied overlay layer. /// /// Uses the shared content-and-text pass; annotation preparation produces native - /// models only. Both passes use the supplied viewport. + /// models only. All passes use the supplied viewport. /// Finishes with balanced backend state, retaining no backend borrow in the output. /// `page_index` selects both the document page and the Core page in `overlay`. /// `content_revision` identifies the text layout independently of annotation edits. @@ -75,6 +76,13 @@ impl WebPageRenderer { viewport.page(), overlay.optional_content(), )?); + self.renderer.render_annotation_appearances( + backend, + index, + viewport.page(), + overlay.optional_content(), + &overlay.painted_appearances(page_index)?, + )?; backend.finish_page()?; let annotations = overlay.prepare(page_index, viewport)?; Ok(WebPageOutput { diff --git a/examples/web-canvas/host.js b/examples/web-canvas/host.js index 4a95caae..eea276fd 100644 --- a/examples/web-canvas/host.js +++ b/examples/web-canvas/host.js @@ -226,6 +226,7 @@ class PdfViewer { 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), + onPress: (entry, pressed) => this.pressAnnotation(entry, pressed), onError: error => this.showError(error), }); } @@ -277,6 +278,18 @@ class PdfViewer { this.elements.status.textContent = entry.action ? describeAction(entry.action) : entry.text || ''; } + // Repaints the page with the widget's down appearance while it is held. + pressAnnotation(entry, pressed) { + const page = this.pages.get(entry.page); + if (!page?.snapshot) return; + try { + const snapshot = this.pdf.press(entry.page, page.canvas, pressed ? entry.id : undefined); + page.snapshot.free(); + page.snapshot = snapshot; + this.updateAnnotations(page); + } catch (error) { this.showError(error); } + } + refreshAnnotations(changed) { const revision = this.pdf.annotation_revision(); for (const page of this.pages.values()) page.nativeLayer?.advanceRevision(revision); @@ -284,7 +297,8 @@ class PdfViewer { const page = this.pages.get(index); if (!page) continue; if (!page.snapshot) continue; - const snapshot = this.pdf.annotations(index); + // Widget appearances are painted on the canvas, so committed state repaints it. + const snapshot = this.pdf.repaint(index, page.canvas); page.snapshot.free(); page.snapshot = snapshot; this.updateAnnotations(page); diff --git a/examples/web-canvas/renderer.rs b/examples/web-canvas/renderer.rs index d9ee15df..3ac4930b 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::pdf_data::AnnotationAction; +use pdf_annotation_core::{models::AnnotationId, pdf_data::AnnotationAction}; use pdf_document::{document::PdfDocument, reader::PdfReader}; use pdf_graphics::{size::Size, transform::Transform}; use pdf_graphics_web::{WebCanvasBackend, WebCanvasOptions}; @@ -84,6 +84,38 @@ impl WebDocument { self.render_viewport(page_index, canvas, viewport) } + /// Renders the page again through its mounted viewport, so canvas-painted widget + /// appearances follow committed edits. Keeps the viewport revision, leaving drafts + /// in other controls current. + pub fn repaint( + &mut self, + page_index: u32, + canvas: web_sys::HtmlCanvasElement, + ) -> Result { + let viewport = self + .viewports + .get(&page_index) + .cloned() + .ok_or_else(|| error("unmounted page"))?; + self.render_viewport(page_index, canvas, viewport) + } + + /// Paints the widget `annotation` (a decimal Core id) held down, or releases the + /// pressed widget when `None`, and repaints `page_index`. + pub fn press( + &mut self, + page_index: u32, + canvas: web_sys::HtmlCanvasElement, + annotation: Option, + ) -> Result { + let pressed = annotation + .map(|id| id.parse().map(AnnotationId)) + .transpose() + .map_err(error)?; + self.overlay.set_pressed(pressed); + self.repaint(page_index, canvas) + } + /// Renders the page fitted to exactly `width` x `height` canvas pixels, one CSS pixel /// each, unrotated; the annotations are placed in the same pixels. pub fn render_fitted(