From 37750c24e2f13cb46805c59d7bd8a456707d239a Mon Sep 17 00:00:00 2001 From: yenru0 Date: Thu, 24 Sep 2026 04:53:22 +0900 Subject: [PATCH] image_viewer: Highlight and draw on PDFs - A toolbar toggle opens an annotation palette with select, highlight, and pen tools plus four highlight colours. - The highlight tool turns a text drag into highlight annotations, one per page the selection spans, and clears the selection afterwards. - The pen tool records a freehand stroke while dragging and commits it as an ink annotation on release; the live stroke paints over the page. - Links and form fields only take clicks while the select tool is armed, and committed annotations share the edited-bytes path, so they travel into the saved copy and into the rasterized pages. - The engine layer appends quad points through pdfium's attachment-point API and lets pdfium generate the highlight and ink appearances, which keeps them rendering correctly at any zoom. --- crates/image_viewer/src/image_viewer.rs | 459 ++++++++++++++++++++++-- crates/pdf_viewer/src/pdf_viewer.rs | 5 +- crates/pdf_viewer/src/pdfium.rs | 328 ++++++++++++++++- 3 files changed, 754 insertions(+), 38 deletions(-) diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index 703ffc031c..f4b380767b 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -85,6 +85,8 @@ actions!( RotateClockwise, /// Rotate the pages a quarter turn counterclockwise (PDF). RotateCounterclockwise, + /// Toggle the annotation tool palette (PDF). + ToggleAnnotationTools, /// Save a copy of the PDF to a chosen location (PDF). SaveCopyAs, /// Open the find-in-document bar (PDF). @@ -119,6 +121,30 @@ const THUMBNAIL_PANEL_WIDTH: f32 = 150.0; const OUTLINE_PANEL_WIDTH: f32 = 220.0; const OUTLINE_INDENT: f32 = 12.0; const PROPERTIES_PANEL_WIDTH: f32 = 220.0; +const HIGHLIGHT_COLORS: [pdf_viewer::PdfAnnotationColor; 4] = [ + pdf_viewer::PdfAnnotationColor { + red: 255, + green: 235, + blue: 59, + }, + pdf_viewer::PdfAnnotationColor { + red: 129, + green: 199, + blue: 132, + }, + pdf_viewer::PdfAnnotationColor { + red: 244, + green: 143, + blue: 177, + }, + pdf_viewer::PdfAnnotationColor { + red: 100, + green: 181, + blue: 246, + }, +]; +const DEFAULT_HIGHLIGHT_COLOR: pdf_viewer::PdfAnnotationColor = HIGHLIGHT_COLORS[0]; +const PEN_LINE_WIDTH: f32 = 2.0; const MAX_SAME_LINE_DISTANCE: f32 = 0.15; // Page-width fraction for line-end fallbacks. const MAX_NEAREST_DISTANCE_SQUARED: f32 = 0.0064; // 8% of a page, for margin clicks. @@ -171,6 +197,17 @@ struct NormRect { height: f32, } +/// Which annotation tool the viewer is armed with. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PdfAnnotationTool { + /// Text selection and form filling. + Select, + /// Drag over text to highlight it. + Highlight, + /// Freehand ink strokes. + Pen, +} + /// Inline editor for the text form field currently being filled in. struct FormTextEdit { editor: Entity, @@ -232,11 +269,16 @@ pub struct ImageView { pdf_metadata: Option, /// Document bytes with form edits applied; preferred over the file's own bytes for /// rendering and saving once the user fills anything in. - pdf_form_bytes: Option>>, + pdf_edited_bytes: Option>>, /// Form widgets per page, filled on demand for overlays and hit testing. pdf_form_widgets: RefCell>>>, form_text_edit: Option, form_option_menu: Option<(Point, pdf_viewer::PdfFormWidget)>, + show_annotation_tools: bool, + annotation_tool: PdfAnnotationTool, + annotation_color: pdf_viewer::PdfAnnotationColor, + /// Ink stroke being drawn: the page it belongs to and its page-space points. + pen_stroke: Option<(usize, Vec<(f32, f32)>)>, thumbnail_scroll_handle: gpui::ScrollHandle, pdf_thumbnail_cache: PageLruCache, pdf_page_cache: PageLruCache, @@ -377,10 +419,14 @@ impl ImageView { page_rotation: 0, pdf_outline: None, pdf_metadata: None, - pdf_form_bytes: None, + pdf_edited_bytes: None, pdf_form_widgets: RefCell::new(HashMap::default()), form_text_edit: None, form_option_menu: None, + show_annotation_tools: false, + annotation_tool: PdfAnnotationTool::Select, + annotation_color: DEFAULT_HIGHLIGHT_COLOR, + pen_stroke: None, thumbnail_scroll_handle: gpui::ScrollHandle::new(), pdf_thumbnail_cache: PageLruCache::new(DEFAULT_MEMORY_BUDGET_BYTES), pdf_page_cache: PageLruCache::new(DEFAULT_MEMORY_BUDGET_BYTES), @@ -408,7 +454,7 @@ impl ImageView { self.pdf_outline = None; self.pdf_metadata = None; self.pdf_char_cache.borrow_mut().clear(); - self.pdf_form_bytes = None; + self.pdf_edited_bytes = None; self.pdf_form_widgets.borrow_mut().clear(); self.form_text_edit = None; self.form_option_menu = None; @@ -425,7 +471,7 @@ impl ImageView { self.pdf_outline = None; self.pdf_metadata = None; self.pdf_char_cache.borrow_mut().clear(); - self.pdf_form_bytes = None; + self.pdf_edited_bytes = None; self.pdf_form_widgets.borrow_mut().clear(); self.form_text_edit = None; self.form_option_menu = None; @@ -1054,6 +1100,14 @@ impl ImageView { if !self.pdf_mouse_down { return; } + + if let Some((page_index, _)) = self.pen_stroke.as_ref().map(|(page, _)| (*page, ())) { + if let Some(stroke_point) = self.page_space_point(page_index, cursor_pos) { + self.extend_pen_stroke(page_index, stroke_point, cx); + } + return; + } + self.pdf_drag_position = Some(cursor_pos); self.update_pdf_drag_selection(cursor_pos, cx); self.check_pdf_autoscroll(cx); @@ -1066,6 +1120,16 @@ impl ImageView { click_count: usize, cx: &mut Context, ) { + if self.annotation_tool == PdfAnnotationTool::Pen { + if let Some(page_point) = self.page_space_point(page_index, click_pos) { + self.pdf_mouse_down = true; + self.pdf_drag_position = None; + self.begin_pen_stroke(page_index, page_point); + cx.notify(); + } + return; + } + let bounds = self.page_bounds.borrow().get(&page_index).copied(); let Some(bounds) = bounds else { return; @@ -1127,12 +1191,23 @@ impl ImageView { } fn handle_pdf_mouse_up(&mut self, cx: &mut Context) { + if self.pen_stroke.is_some() { + self.pdf_mouse_down = false; + self.pdf_drag_position = None; + self.finish_pen_stroke(cx); + return; + } + if self.pdf_mouse_down { self.pdf_mouse_down = false; self.pdf_drag_position = None; self.pdf_autoscroll_task = None; self.ensure_pdf_selection_text(cx); - cx.notify(); + if self.annotation_tool == PdfAnnotationTool::Highlight { + self.highlight_current_selection(cx); + } else { + cx.notify(); + } } } @@ -1868,7 +1943,7 @@ impl ImageView { /// Bytes used for rendering and saving: the form-edited copy when the user has filled /// in fields, otherwise the file's own bytes. fn active_pdf_bytes(&self, cx: &App) -> Option>> { - self.pdf_form_bytes.clone().or_else(|| { + self.pdf_edited_bytes.clone().or_else(|| { self.image_item .read(cx) .pdf_info @@ -2005,6 +2080,166 @@ impl ImageView { .into_any_element() } + fn toggle_annotation_tools( + &mut self, + _: &ToggleAnnotationTools, + _window: &mut Window, + cx: &mut Context, + ) { + self.show_annotation_tools = !self.show_annotation_tools; + if !self.show_annotation_tools { + self.annotation_tool = PdfAnnotationTool::Select; + self.pen_stroke = None; + } + cx.notify(); + } + + fn set_annotation_tool(&mut self, tool: PdfAnnotationTool, cx: &mut Context) { + self.annotation_tool = tool; + self.pen_stroke = None; + cx.notify(); + } + + fn set_annotation_color( + &mut self, + color: pdf_viewer::PdfAnnotationColor, + cx: &mut Context, + ) { + self.annotation_color = color; + cx.notify(); + } + + /// Page-space point for a window position, honouring the current page rotation. + fn page_space_point(&self, page_index: usize, position: Point) -> Option<(f32, f32)> { + let bounds = self.page_bounds.borrow().get(&page_index).copied()?; + let width: f32 = bounds.size.width.into(); + let height: f32 = bounds.size.height.into(); + if width <= 0.0 || height <= 0.0 { + return None; + } + + let norm_x = (f32::from(position.x) - f32::from(bounds.origin.x)) / width; + let norm_y = (f32::from(position.y) - f32::from(bounds.origin.y)) / height; + Some(Self::unrotate_normalized_point( + norm_x.clamp(0.0, 1.0), + norm_y.clamp(0.0, 1.0), + self.page_rotation, + )) + } + + /// Swaps the renders over to the rewritten document after an annotation edit. + fn apply_annotation_bytes(&mut self, edited: Vec, cx: &mut Context) { + self.pdf_edited_bytes = Some(Arc::new(edited)); + self.pdf_page_cache.clear(); + self.pdf_thumbnail_cache.clear(); + cx.notify(); + } + + /// Turns the current text selection into highlight annotations, one page at a time. + fn highlight_current_selection(&mut self, cx: &mut Context) { + let Some(selection) = self.pdf_selection.clone() else { + return; + }; + let (min_page, min_char) = selection.min_endpoint(); + let (max_page, max_char) = selection.max_endpoint(); + + let mut edited = None; + for page_index in min_page..=max_page { + let Some(bytes) = edited + .clone() + .or_else(|| self.active_pdf_bytes(cx).map(|bytes| bytes.to_vec())) + else { + return; + }; + let Some(boxes) = self.char_boxes_for_page(&bytes, page_index) else { + continue; + }; + if boxes.is_empty() { + continue; + } + + let first = if page_index == min_page { + min_char.min(boxes.len() - 1) + } else { + 0 + }; + let last = if page_index == max_page { + max_char.min(boxes.len() - 1) + } else { + boxes.len() - 1 + }; + if first > last { + continue; + } + + let rects: Vec<(f32, f32, f32, f32)> = Self::merge_char_runs(&boxes, first, last) + .into_iter() + .map(|rect| (rect.x, rect.y, rect.width, rect.height)) + .collect(); + if rects.is_empty() { + continue; + } + + match PdfiumEngine::new().add_highlight_annotation( + &bytes, + page_index, + &rects, + self.annotation_color, + ) { + Ok(updated) => edited = Some(updated), + Err(error) => { + log::error!("Failed to add PDF highlight: {error:#}"); + return; + } + } + } + + if let Some(edited) = edited { + self.pdf_selection = None; + self.apply_annotation_bytes(edited, cx); + } + } + + fn begin_pen_stroke(&mut self, page_index: usize, point: (f32, f32)) { + self.pdf_selection = None; + self.pen_stroke = Some((page_index, vec![point])); + } + + fn extend_pen_stroke(&mut self, page_index: usize, point: (f32, f32), cx: &mut Context) { + if let Some((stroke_page, points)) = self.pen_stroke.as_mut() { + if *stroke_page != page_index { + return; + } + points.push(point); + cx.notify(); + } + } + + /// Commits the in-progress ink stroke into the document. + fn finish_pen_stroke(&mut self, cx: &mut Context) { + let Some((page_index, points)) = self.pen_stroke.take() else { + return; + }; + if points.len() < 2 { + cx.notify(); + return; + } + + let Some(bytes) = self.active_pdf_bytes(cx).map(|bytes| bytes.to_vec()) else { + return; + }; + match PdfiumEngine::new().add_ink_annotation( + &bytes, + page_index, + std::slice::from_ref(&points), + self.annotation_color, + PEN_LINE_WIDTH, + ) { + Ok(edited) => self.apply_annotation_bytes(edited, cx), + Err(error) => log::error!("Failed to add PDF ink annotation: {error:#}"), + } + } + /// Form widgets of a page, extracted through the engine on first use. Cached even /// when empty so documents without forms do not re-parse on every frame. fn form_widgets_for_page( @@ -2084,7 +2319,7 @@ impl ImageView { match PdfiumEngine::new().apply_form_edits(&bytes, std::slice::from_ref(&edit)) { Ok(edited) => { - self.pdf_form_bytes = Some(Arc::new(edited)); + self.pdf_edited_bytes = Some(Arc::new(edited)); self.pdf_page_cache.clear(); self.pdf_thumbnail_cache.clear(); self.pdf_form_widgets.borrow_mut().clear(); @@ -2780,10 +3015,14 @@ impl Item for ImageView { page_rotation: 0, pdf_outline: None, pdf_metadata: None, - pdf_form_bytes: None, + pdf_edited_bytes: None, pdf_form_widgets: RefCell::new(HashMap::default()), form_text_edit: None, form_option_menu: None, + show_annotation_tools: false, + annotation_tool: PdfAnnotationTool::Select, + annotation_color: DEFAULT_HIGHLIGHT_COLOR, + pen_stroke: None, thumbnail_scroll_handle: gpui::ScrollHandle::new(), pdf_thumbnail_cache: PageLruCache::new(DEFAULT_MEMORY_BUDGET_BYTES), pdf_page_cache: PageLruCache::new(DEFAULT_MEMORY_BUDGET_BYTES), @@ -2954,6 +3193,7 @@ impl Render for ImageView { .on_action(cx.listener(Self::rotate_clockwise)) .on_action(cx.listener(Self::rotate_counterclockwise)) .on_action(cx.listener(Self::save_copy_as)) + .on_action(cx.listener(Self::toggle_annotation_tools)) .on_action(cx.listener(Self::select_all)) .on_action(cx.listener(Self::open_pdf_find)) .on_action(cx.listener(Self::find_next_match)) @@ -3045,6 +3285,10 @@ impl Render for ImageView { let page_h = px(page_h_val); let page_idx = page.page_index; let links = page.links; + // While a drawing tool is armed, clicks belong to the tool + // rather than to links or form fields. + let interaction_enabled = + self.annotation_tool == PdfAnnotationTool::Select; let form_widgets = self.form_widgets_for_page(&pdf_bytes, page_idx); let form_editor = self.form_text_edit.as_ref().and_then(|edit| { (edit.page_index == page_idx).then(|| { @@ -3240,6 +3484,7 @@ impl Render for ImageView { div() .id(("pdf-link", page_idx * 100_000 + link_idx)) .absolute() + .when(!interaction_enabled, |this| this.cursor_default()) .left(link_left) .top(link_top) .w(link_w) @@ -3252,17 +3497,19 @@ impl Render for ImageView { .rounded_xs() }) .tooltip(Tooltip::text(link_tooltip)) - .on_mouse_down( - MouseButton::Left, - cx.listener(move |this, _event: &MouseDownEvent, _window, cx| { - cx.stop_propagation(); - match link_dest_page { - Some(page) => this.jump_to_pdf_page(page, cx), - None if Self::is_safe_pdf_url(&link_url) => cx.open_url(&link_url), - None => log::warn!("Blocked opening potentially unsafe PDF link: {}", link_url), - } - }), - ) + .when(interaction_enabled, |this| { + this.on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _event: &MouseDownEvent, _window, cx| { + cx.stop_propagation(); + match link_dest_page { + Some(page) => this.jump_to_pdf_page(page, cx), + None if Self::is_safe_pdf_url(&link_url) => cx.open_url(&link_url), + None => log::warn!("Blocked opening potentially unsafe PDF link: {}", link_url), + } + }), + ) + }) })) .children(form_widgets.iter().enumerate().map(|(widget_index, widget)| { let (widget_x, widget_y, widget_w, widget_h) = @@ -3290,22 +3537,72 @@ impl Render for ImageView { .w(px(widget_w * page_w_val)) .h(px(widget_h * page_h_val)) .cursor(cursor) - .on_mouse_down( - MouseButton::Left, - cx.listener(move |this, event: &MouseDownEvent, window, cx| { - cx.stop_propagation(); - window.focus(&this.focus_handle, cx); - this.begin_form_widget_edit( - page_idx, - &widget, - event.position, - window, - cx, - ); - }), - ) + .when(interaction_enabled, |this| { + this.on_mouse_down( + MouseButton::Left, + cx.listener(move |this, event: &MouseDownEvent, window, cx| { + cx.stop_propagation(); + window.focus(&this.focus_handle, cx); + this.begin_form_widget_edit( + page_idx, + &widget, + event.position, + window, + cx, + ); + }), + ) + }) .tooltip(Tooltip::text(tooltip)) })) + .children(self.pen_stroke.as_ref().filter(|(stroke_page, _)| { + *stroke_page == page_idx + }).map(|(_, points)| { + let points: Vec<(f32, f32)> = points.clone(); + let color = self.annotation_color; + canvas( + |_, _, _| {}, + move |bounds: Bounds, _state: (), window: &mut Window, _cx: &mut App| { + if points.len() < 2 { + return; + } + let origin = bounds.origin; + let width = f32::from(bounds.size.width); + let height = f32::from(bounds.size.height); + let mut builder = gpui::PathBuilder::stroke(px(PEN_LINE_WIDTH * 2.0)); + let rotated: Vec<(f32, f32)> = points + .iter() + .map(|(x, y)| { + let (rx, ry, ..) = + ImageView::rotate_normalized_box(*x, *y, 0.0, 0.0, rotation); + (rx, ry) + }) + .collect(); + builder.move_to(point( + origin.x + px(rotated[0].0 * width), + origin.y + px(rotated[0].1 * height), + )); + for (x, y) in rotated.iter().skip(1) { + builder.line_to(point( + origin.x + px(x * width), + origin.y + px(y * height), + )); + } + if let Ok(path) = builder.build() { + window.paint_path( + path, + gpui::rgb( + ((color.red as u32) << 16) + | ((color.green as u32) << 8) + | color.blue as u32, + ), + ); + } + }, + ) + .absolute() + .size_full() + })) .children(form_editor.map(|(editor, (wx, wy, ww, wh))| { div() .id(("pdf-form-editor", page_idx)) @@ -3344,6 +3641,71 @@ impl Render for ImageView { window, cx, ) + .children(self.show_annotation_tools.then(|| { + let label = |tool: PdfAnnotationTool| match tool { + PdfAnnotationTool::Select => "Select", + PdfAnnotationTool::Highlight => "Highlight", + PdfAnnotationTool::Pen => "Pen", + }; + let tool_button = |tool: PdfAnnotationTool, cx: &mut Context| { + let is_active = self.annotation_tool == tool; + let selected_label: Option = + is_active.then(|| SharedString::from(label(tool))); + Button::new(("pdf-annotation-tool", tool as usize), label(tool)) + .label_size(LabelSize::XSmall) + .selected_label::(selected_label) + .on_click(cx.listener(move |this, _, _, cx| { + this.set_annotation_tool(tool, cx); + })) + }; + + h_flex() + .id("pdf-annotation-tools") + .absolute() + .top_2() + .left_2() + .occlude() + .items_center() + .gap_1() + .px_2() + .py_1() + .rounded_md() + .border_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().elevated_surface_background) + .shadow_lg() + .child(tool_button(PdfAnnotationTool::Select, cx)) + .child(tool_button(PdfAnnotationTool::Highlight, cx)) + .child(tool_button(PdfAnnotationTool::Pen, cx)) + .child(Divider::vertical()) + .children(HIGHLIGHT_COLORS.into_iter().map(|color| { + let is_active = self.annotation_color == color; + div() + .id(( + "pdf-annotation-color", + color.red as usize * 65_536 + + color.green as usize * 256 + + color.blue as usize, + )) + .w(px(14.0)) + .h(px(14.0)) + .rounded_sm() + .cursor_pointer() + .bg(gpui::rgb( + ((color.red as u32) << 16) + | ((color.green as u32) << 8) + | color.blue as u32, + )) + .when(is_active, |this| { + this.border_2() + .border_color(cx.theme().colors().border_focused) + }) + .on_click(cx.listener(move |this, _, _, cx| { + this.set_annotation_color(color, cx); + })) + })) + .into_any_element() + })) .children(self.form_option_menu.as_ref().map(|(position, widget)| { let options = widget.options.clone(); let selected = widget.value.clone(); @@ -3643,14 +4005,21 @@ impl Render for ImageViewToolbarControls { }; let image_item = image_view.read(cx).image_item.clone(); - let (is_pdf, current_page, total_pages, show_text_panel) = { + let (is_pdf, current_page, total_pages, show_text_panel, show_annotation_tools) = { let item = image_item.read(cx); let view = image_view.read(cx); let show_text = view.show_text_panel; + let show_annotation_tools = view.show_annotation_tools; let current_page = view .visible_pdf_page(cx) .min(item.total_pages().saturating_sub(1)); - (item.is_pdf(), current_page, item.total_pages(), show_text) + ( + item.is_pdf(), + current_page, + item.total_pages(), + show_text, + show_annotation_tools, + ) }; h_flex() @@ -3704,6 +4073,7 @@ impl Render for ImageViewToolbarControls { let image_view_outline = image_view.downgrade(); let image_view_presentation = image_view.downgrade(); let image_view_properties = image_view.downgrade(); + let image_view_annotations = image_view.downgrade(); let image_view_rotate_ccw = image_view.downgrade(); let image_view_rotate_cw = image_view.downgrade(); this.child( @@ -3752,6 +4122,25 @@ impl Render for ImageViewToolbarControls { } }), ) + .child( + IconButton::new("toggle-annotations", IconName::Pencil) + .icon_size(IconSize::Small) + .toggle_state(show_annotation_tools) + .tooltip(|_window, cx| { + Tooltip::for_action("Annotate", &ToggleAnnotationTools, cx) + }) + .on_click(move |_, window, cx| { + if let Some(view) = image_view_annotations.upgrade() { + view.update(cx, |this, cx| { + this.toggle_annotation_tools( + &ToggleAnnotationTools, + window, + cx, + ); + }); + } + }), + ) .child( IconButton::new("rotate-counterclockwise", IconName::RotateCcw) .icon_size(IconSize::Small) diff --git a/crates/pdf_viewer/src/pdf_viewer.rs b/crates/pdf_viewer/src/pdf_viewer.rs index 4c1b81b5b7..2460348034 100644 --- a/crates/pdf_viewer/src/pdf_viewer.rs +++ b/crates/pdf_viewer/src/pdf_viewer.rs @@ -22,8 +22,9 @@ pub mod watcher; pub use cache::{CacheKey, CacheablePage, DEFAULT_MEMORY_BUDGET_BYTES, PageLruCache, RenderedPage}; pub use document::{PageDimensions, PdfDocument}; pub use pdfium::{ - PdfCharBox, PdfDocumentDetails, PdfFormEdit, PdfFormWidget, PdfFormWidgetKind, - PdfLinkAnnotation, PdfMetadata, PdfOutlineItem, PdfPageDetails, PdfTextSegment, PdfiumEngine, + PdfAnnotationColor, PdfCharBox, PdfDocumentDetails, PdfFormEdit, PdfFormWidget, + PdfFormWidgetKind, PdfLinkAnnotation, PdfMetadata, PdfOutlineItem, PdfPageDetails, + PdfTextSegment, PdfiumEngine, }; pub use rasterizer::{LuminosityToneMapper, PageRasterizer, RasterizerOptions}; pub use settings::{DefaultZoomPolicy, PageLayoutMode, PdfViewerSettings}; diff --git a/crates/pdf_viewer/src/pdfium.rs b/crates/pdf_viewer/src/pdfium.rs index edc900dce6..f2f02b79c0 100644 --- a/crates/pdf_viewer/src/pdfium.rs +++ b/crates/pdf_viewer/src/pdfium.rs @@ -6,7 +6,7 @@ use anyhow::{Context as _, Result}; use parking_lot::Mutex; use pdfium_render::prelude::*; -use std::os::raw::{c_int, c_ulong, c_void}; +use std::os::raw::{c_int, c_uint, c_ulong, c_void}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -1988,6 +1988,332 @@ impl PdfiumEngine { } } +// --------------------------------------------------------------------------- +// Markup annotations (highlights and ink) +// --------------------------------------------------------------------------- +// +// As with the form layer above, pdfium-render's high-level API cannot create annotations, so the +// two creators below go through the same FPDF C API, the same RAII guards and the same save +// helper. They write only the entries the specification defines for these subtypes - `/Rect`, +// `/QuadPoints`, `/InkList`, `/C` and the border width - and deliberately author no appearance +// stream: pdfium builds one on demand from exactly those entries when it draws the annotation +// (`CPDF_Annot::GenerateAPIfNeeded` calls `CPDF_GenerateAP::GenerateHighlightAP` or +// `GenerateInkAP`, which read `/QuadPoints`, `/InkList`, `/C` and the border width), and other +// viewers generate the same appearance the same way, while a hand-written stream would instead +// replace that machinery and freeze the markup at the zoom it was authored for. + +/// An RGB color for a highlight or ink annotation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PdfAnnotationColor { + pub red: u8, + pub green: u8, + pub blue: u8, +} + +/// `FPDF_ANNOT_HIGHLIGHT` annotation subtype from pdfium's `public/fpdf_annot.h`. +const FPDF_ANNOT_HIGHLIGHT: c_int = 9; + +/// `FPDF_ANNOT_INK` annotation subtype from pdfium's `public/fpdf_annot.h`. +const FPDF_ANNOT_INK: c_int = 15; + +/// `FPDFANNOT_COLORTYPE_Color`: the annotation's `/C` entry, as opposed to its interior color. +const FPDFANNOT_COLORTYPE_COLOR: FPDFANNOT_COLORTYPE = 0; + +/// Opacity handed to `FPDFAnnot_SetColor`, which writes it into `/CA` as `A / 255`. The colors a +/// viewer picks for markup are fully opaque. +const ANNOT_COLOR_OPACITY: c_uint = 255; + +/// Converts one viewer-normalized rectangle into pdfium page-space coordinates. +/// +/// Normalized rectangles are fractions of the page's unrotated width and height measured from the +/// top-left corner, the convention [`normalize_coordinates`] produces for [`PdfCharBox`], +/// [`PdfLinkAnnotation`] and [`PdfFormWidget`]; this is that conversion inverted, flipping `y` +/// back into pdfium's bottom-left origin. The edges are ordered so that `left <= right` and +/// `bottom <= top`, the order both `/Rect` and the quadpoints built from it have to be in. +fn fs_rect_from_normalized( + (x, y, width, height): (f32, f32, f32, f32), + page_width: f32, + page_height: f32, +) -> FS_RECTF { + let left = x * page_width; + let right = (x + width) * page_width; + let top = page_height - y * page_height; + let bottom = page_height - (y + height) * page_height; + + FS_RECTF { + left: left.min(right), + top: top.max(bottom), + right: left.max(right), + bottom: top.min(bottom), + } +} + +/// Converts one viewer-normalized point into pdfium page-space coordinates, using the same +/// convention as [`fs_rect_from_normalized`]. +fn fs_point_from_normalized((x, y): (f32, f32), page_width: f32, page_height: f32) -> FS_POINTF { + FS_POINTF { + x: x * page_width, + y: page_height - y * page_height, + } +} + +/// Returns the smallest rectangle containing both inputs. +fn union_rect(a: FS_RECTF, b: FS_RECTF) -> FS_RECTF { + FS_RECTF { + left: a.left.min(b.left), + top: a.top.max(b.top), + right: a.right.max(b.right), + bottom: a.bottom.min(b.bottom), + } +} + +/// Reads the unrotated page dimensions pdfium reports, rejecting a page without usable +/// dimensions so that the normalized-to-point conversions above can never produce garbage. +fn positive_page_dimensions( + bindings: &dyn PdfiumLibraryBindings, + page: FPDF_PAGE, + page_index: usize, +) -> Result<(f32, f32)> { + let width = bindings.FPDF_GetPageWidthF(page); + let height = bindings.FPDF_GetPageHeightF(page); + if width <= 0.0 || height <= 0.0 { + anyhow::bail!("Page {page_index} reports no usable dimensions ({width} x {height})"); + } + Ok((width, height)) +} + +/// Writes an annotation's `/C` entry and its opacity through `FPDFAnnot_SetColor`. +/// +/// The call only fails for a null annotation, an out-of-range color component, or an annotation +/// that already carries an appearance stream; none of those can happen for the annotations built +/// here, and failing loudly keeps a silently colorless annotation from reaching the document. +fn set_annotation_color( + bindings: &dyn PdfiumLibraryBindings, + annot: FPDF_ANNOTATION, + color: PdfAnnotationColor, +) -> Result<()> { + let applied = bindings.FPDFAnnot_SetColor( + annot, + FPDFANNOT_COLORTYPE_COLOR, + color.red as c_uint, + color.green as c_uint, + color.blue as c_uint, + ANNOT_COLOR_OPACITY, + ); + if !bindings.is_true(applied) { + anyhow::bail!("Pdfium failed to set an annotation color"); + } + Ok(()) +} + +impl PdfiumEngine { + /// Adds a highlight annotation covering `rects` to the zero-indexed page `page_index`, and + /// returns the serialized document. + /// + /// `rects` are `(x, y, width, height)` rectangles in the same normalized page coordinates as + /// [`PdfCharBox`], [`PdfLinkAnnotation`] and [`PdfFormWidget`]: fractions of the page's + /// unrotated width and height measured from the top-left corner. Each rectangle becomes one + /// set of quadpoints, and the annotation's `/Rect` is the bounding box of all of them - + /// the box pdfium's generated appearance carries as its `BBox` and scales onto `/Rect`, so the + /// two have to agree for the highlight to land where it was drawn. The returned bytes are a + /// complete rewrite of the document (see [`save_document_to_bytes`]). An empty `rects`, and an + /// engine without a native pdfium library, return the input bytes untouched. + pub fn add_highlight_annotation( + &self, + bytes: &[u8], + page_index: usize, + rects: &[(f32, f32, f32, f32)], + color: PdfAnnotationColor, + ) -> Result> { + if rects.is_empty() { + return Ok(bytes.to_vec()); + } + + let guard = self.inner.lock(); + let Some(pdfium) = guard.0.as_ref() else { + return Ok(bytes.to_vec()); + }; + let bindings = pdfium.bindings(); + + let document = bindings.FPDF_LoadMemDocument64(bytes, None); + if document.is_null() { + anyhow::bail!("Pdfium failed to load PDF document for annotation editing"); + } + // Declared before the page guard below, so that is released first: pdfium requires the + // pages to be closed before the document that owns them. + let _document_guard = PdfiumDocumentGuard { bindings, document }; + + let page = bindings.FPDF_LoadPage(document, page_index as c_int); + if page.is_null() { + anyhow::bail!("Requested page index {page_index} out of bounds for annotation editing"); + } + // No form fill environment is involved: a markup annotation is written straight into the + // page's annotation array and owns no widget object. + let _page_guard = PdfiumPageGuard { + bindings, + page, + form: std::ptr::null_mut(), + }; + + let (page_width, page_height) = positive_page_dimensions(bindings, page, page_index)?; + + let annot = bindings.FPDFPage_CreateAnnot(page, FPDF_ANNOT_HIGHLIGHT); + if annot.is_null() { + anyhow::bail!("Pdfium failed to create a highlight annotation"); + } + // Held for its `Drop`; every path out of this function closes the annotation. + let _annot_guard = PdfiumAnnotGuard { bindings, annot }; + + let mut bounds = fs_rect_from_normalized(rects[0], page_width, page_height); + for rect in rects { + let rect = fs_rect_from_normalized(*rect, page_width, page_height); + bounds = union_rect(bounds, rect); + + // One set of quadpoints per rectangle, in the order the specification defines them: + // upper-left, upper-right, lower-left, lower-right. + let quad_points = FS_QUADPOINTSF { + x1: rect.left, + y1: rect.top, + x2: rect.right, + y2: rect.top, + x3: rect.left, + y3: rect.bottom, + x4: rect.right, + y4: rect.bottom, + }; + // `FPDFAnnot_AppendAttachmentPoints` creates the `/QuadPoints` array on the first call + // and appends a further eight numbers to it on each call after that. + let appended = bindings.FPDFAnnot_AppendAttachmentPoints(annot, &quad_points); + if !bindings.is_true(appended) { + anyhow::bail!("Pdfium failed to add the quadpoints of a highlight annotation"); + } + } + + let rect_set = bindings.FPDFAnnot_SetRect(annot, &bounds); + if !bindings.is_true(rect_set) { + anyhow::bail!("Pdfium failed to set the rectangle of a highlight annotation"); + } + set_annotation_color(bindings, annot, color)?; + + save_document_to_bytes(bindings, document) + } + + /// Adds an ink (freehand) annotation made of `strokes` to the zero-indexed page `page_index`, + /// and returns the serialized document. + /// + /// Each entry of `strokes` is one path of points in the same normalized page coordinates as + /// [`PdfCharBox`], [`PdfLinkAnnotation`] and [`PdfFormWidget`], and becomes one `/InkList` + /// stroke. `line_width` is the stroke width in page points; the annotation's `/Rect` is the + /// bounding box of every point of every stroke. Paths without points carry no geometry and are + /// dropped, so a `strokes` that contributes nothing returns the input bytes untouched, as does + /// an engine without a native pdfium library. The returned bytes are a complete rewrite of the + /// document (see [`save_document_to_bytes`]). + pub fn add_ink_annotation( + &self, + bytes: &[u8], + page_index: usize, + strokes: &[Vec<(f32, f32)>], + color: PdfAnnotationColor, + line_width: f32, + ) -> Result> { + let paths: Vec<&Vec<(f32, f32)>> = strokes.iter().filter(|path| !path.is_empty()).collect(); + if paths.is_empty() { + return Ok(bytes.to_vec()); + } + // pdfium's ink appearance generator bails out on a stroke width of zero or less + // (`CPDF_GenerateAP::GenerateInkAP` returns false when `GetBorderWidth()` is not positive) + // and the annotation would then never render, so a width that cannot draw is an error + // rather than a document change with no visible effect. + if !line_width.is_finite() || line_width <= 0.0 { + anyhow::bail!("Ink annotation line width must be positive, got {line_width}"); + } + + let guard = self.inner.lock(); + let Some(pdfium) = guard.0.as_ref() else { + return Ok(bytes.to_vec()); + }; + let bindings = pdfium.bindings(); + + let document = bindings.FPDF_LoadMemDocument64(bytes, None); + if document.is_null() { + anyhow::bail!("Pdfium failed to load PDF document for annotation editing"); + } + // Declared before the page guard below, so that is released first: pdfium requires the + // pages to be closed before the document that owns them. + let _document_guard = PdfiumDocumentGuard { bindings, document }; + + let page = bindings.FPDF_LoadPage(document, page_index as c_int); + if page.is_null() { + anyhow::bail!("Requested page index {page_index} out of bounds for annotation editing"); + } + // No form fill environment is involved: a markup annotation is written straight into the + // page's annotation array and owns no widget object. + let _page_guard = PdfiumPageGuard { + bindings, + page, + form: std::ptr::null_mut(), + }; + + let (page_width, page_height) = positive_page_dimensions(bindings, page, page_index)?; + + let converted: Vec> = paths + .iter() + .map(|path| { + path.iter() + .map(|point| fs_point_from_normalized(*point, page_width, page_height)) + .collect() + }) + .collect(); + + // The annotation's rectangle is the bounding box of every point of every stroke, in the + // page space the strokes themselves are stored in. + let bounds = converted + .iter() + .flatten() + .map(|point| FS_RECTF { + left: point.x, + top: point.y, + right: point.x, + bottom: point.y, + }) + .reduce(union_rect) + .context("Ink annotation carries no points to bound")?; + + let annot = bindings.FPDFPage_CreateAnnot(page, FPDF_ANNOT_INK); + if annot.is_null() { + anyhow::bail!("Pdfium failed to create an ink annotation"); + } + // Held for its `Drop`; every path out of this function closes the annotation. + let _annot_guard = PdfiumAnnotGuard { bindings, annot }; + + for points in &converted { + // The stroke is stored in `/InkList` verbatim: pdfium's own generator draws those + // numbers in page space, the same space `/Rect` is expressed in. + let added = bindings.FPDFAnnot_AddInkStroke(annot, points.as_ptr(), points.len()); + if added < 0 { + anyhow::bail!("Pdfium failed to add a stroke to an ink annotation"); + } + } + + let rect_set = bindings.FPDFAnnot_SetRect(annot, &bounds); + if !bindings.is_true(rect_set) { + anyhow::bail!("Pdfium failed to set the rectangle of an ink annotation"); + } + set_annotation_color(bindings, annot, color)?; + + // `FPDFAnnot_SetBorder` writes the stroke width pdfium's ink appearance generator reads. + // It stores the legacy `/Border` array `[horizontal_radius vertical_radius border_width]` + // and removes any appearance stream, which is exactly the entry `GetBorderWidth()` falls + // back to once `/BS` is absent. + let border_set = bindings.FPDFAnnot_SetBorder(annot, 0.0, 0.0, line_width); + if !bindings.is_true(border_set) { + anyhow::bail!("Pdfium failed to set the border of an ink annotation"); + } + + save_document_to_bytes(bindings, document) + } +} + #[cfg(test)] mod tests { use super::*;