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.
This commit is contained in:
yenru0 committed 2026-09-24 04:53:22 +09:00
1 parent 0fd48144f9
commit 37750c24e2
3 files changed
+754 -38

No files matched your search

+424 -35
View File
@@ -85,6 +85,8 @@ actions!(
RotateClockwise, RotateClockwise,
/// Rotate the pages a quarter turn counterclockwise (PDF). /// Rotate the pages a quarter turn counterclockwise (PDF).
RotateCounterclockwise, RotateCounterclockwise,
/// Toggle the annotation tool palette (PDF).
ToggleAnnotationTools,
/// Save a copy of the PDF to a chosen location (PDF). /// Save a copy of the PDF to a chosen location (PDF).
SaveCopyAs, SaveCopyAs,
/// Open the find-in-document bar (PDF). /// 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_PANEL_WIDTH: f32 = 220.0;
const OUTLINE_INDENT: f32 = 12.0; const OUTLINE_INDENT: f32 = 12.0;
const PROPERTIES_PANEL_WIDTH: f32 = 220.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_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. const MAX_NEAREST_DISTANCE_SQUARED: f32 = 0.0064; // 8% of a page, for margin clicks.
@@ -171,6 +197,17 @@ struct NormRect {
height: f32, 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. /// Inline editor for the text form field currently being filled in.
struct FormTextEdit { struct FormTextEdit {
editor: Entity<Editor>, editor: Entity<Editor>,
@@ -232,11 +269,16 @@ pub struct ImageView {
pdf_metadata: Option<pdf_viewer::PdfMetadata>, pdf_metadata: Option<pdf_viewer::PdfMetadata>,
/// Document bytes with form edits applied; preferred over the file's own bytes for /// Document bytes with form edits applied; preferred over the file's own bytes for
/// rendering and saving once the user fills anything in. /// rendering and saving once the user fills anything in.
pdf_form_bytes: Option<Arc<Vec<u8>>>, pdf_edited_bytes: Option<Arc<Vec<u8>>>,
/// Form widgets per page, filled on demand for overlays and hit testing. /// Form widgets per page, filled on demand for overlays and hit testing.
pdf_form_widgets: RefCell<HashMap<usize, Arc<Vec<pdf_viewer::PdfFormWidget>>>>, pdf_form_widgets: RefCell<HashMap<usize, Arc<Vec<pdf_viewer::PdfFormWidget>>>>,
form_text_edit: Option<FormTextEdit>, form_text_edit: Option<FormTextEdit>,
form_option_menu: Option<(Point<Pixels>, pdf_viewer::PdfFormWidget)>, form_option_menu: Option<(Point<Pixels>, 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, thumbnail_scroll_handle: gpui::ScrollHandle,
pdf_thumbnail_cache: PageLruCache<CachedPageImage>, pdf_thumbnail_cache: PageLruCache<CachedPageImage>,
pdf_page_cache: PageLruCache<CachedPageImage>, pdf_page_cache: PageLruCache<CachedPageImage>,
@@ -377,10 +419,14 @@ impl ImageView {
page_rotation: 0, page_rotation: 0,
pdf_outline: None, pdf_outline: None,
pdf_metadata: None, pdf_metadata: None,
pdf_form_bytes: None, pdf_edited_bytes: None,
pdf_form_widgets: RefCell::new(HashMap::default()), pdf_form_widgets: RefCell::new(HashMap::default()),
form_text_edit: None, form_text_edit: None,
form_option_menu: 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(), thumbnail_scroll_handle: gpui::ScrollHandle::new(),
pdf_thumbnail_cache: PageLruCache::new(DEFAULT_MEMORY_BUDGET_BYTES), pdf_thumbnail_cache: PageLruCache::new(DEFAULT_MEMORY_BUDGET_BYTES),
pdf_page_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_outline = None;
self.pdf_metadata = None; self.pdf_metadata = None;
self.pdf_char_cache.borrow_mut().clear(); 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.pdf_form_widgets.borrow_mut().clear();
self.form_text_edit = None; self.form_text_edit = None;
self.form_option_menu = None; self.form_option_menu = None;
@@ -425,7 +471,7 @@ impl ImageView {
self.pdf_outline = None; self.pdf_outline = None;
self.pdf_metadata = None; self.pdf_metadata = None;
self.pdf_char_cache.borrow_mut().clear(); 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.pdf_form_widgets.borrow_mut().clear();
self.form_text_edit = None; self.form_text_edit = None;
self.form_option_menu = None; self.form_option_menu = None;
@@ -1054,6 +1100,14 @@ impl ImageView {
if !self.pdf_mouse_down { if !self.pdf_mouse_down {
return; 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.pdf_drag_position = Some(cursor_pos);
self.update_pdf_drag_selection(cursor_pos, cx); self.update_pdf_drag_selection(cursor_pos, cx);
self.check_pdf_autoscroll(cx); self.check_pdf_autoscroll(cx);
@@ -1066,6 +1120,16 @@ impl ImageView {
click_count: usize, click_count: usize,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
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 bounds = self.page_bounds.borrow().get(&page_index).copied();
let Some(bounds) = bounds else { let Some(bounds) = bounds else {
return; return;
@@ -1127,12 +1191,23 @@ impl ImageView {
} }
fn handle_pdf_mouse_up(&mut self, cx: &mut Context<Self>) { fn handle_pdf_mouse_up(&mut self, cx: &mut Context<Self>) {
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 { if self.pdf_mouse_down {
self.pdf_mouse_down = false; self.pdf_mouse_down = false;
self.pdf_drag_position = None; self.pdf_drag_position = None;
self.pdf_autoscroll_task = None; self.pdf_autoscroll_task = None;
self.ensure_pdf_selection_text(cx); 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 /// Bytes used for rendering and saving: the form-edited copy when the user has filled
/// in fields, otherwise the file's own bytes. /// in fields, otherwise the file's own bytes.
fn active_pdf_bytes(&self, cx: &App) -> Option<Arc<Vec<u8>>> { fn active_pdf_bytes(&self, cx: &App) -> Option<Arc<Vec<u8>>> {
self.pdf_form_bytes.clone().or_else(|| { self.pdf_edited_bytes.clone().or_else(|| {
self.image_item self.image_item
.read(cx) .read(cx)
.pdf_info .pdf_info
@@ -2005,6 +2080,166 @@ impl ImageView {
.into_any_element() .into_any_element()
} }
fn toggle_annotation_tools(
&mut self,
_: &ToggleAnnotationTools,
_window: &mut Window,
cx: &mut Context<Self>,
) {
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>) {
self.annotation_tool = tool;
self.pen_stroke = None;
cx.notify();
}
fn set_annotation_color(
&mut self,
color: pdf_viewer::PdfAnnotationColor,
cx: &mut Context<Self>,
) {
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<Pixels>) -> 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<u8>, cx: &mut Context<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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 /// 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. /// when empty so documents without forms do not re-parse on every frame.
fn form_widgets_for_page( fn form_widgets_for_page(
@@ -2084,7 +2319,7 @@ impl ImageView {
match PdfiumEngine::new().apply_form_edits(&bytes, std::slice::from_ref(&edit)) { match PdfiumEngine::new().apply_form_edits(&bytes, std::slice::from_ref(&edit)) {
Ok(edited) => { 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_page_cache.clear();
self.pdf_thumbnail_cache.clear(); self.pdf_thumbnail_cache.clear();
self.pdf_form_widgets.borrow_mut().clear(); self.pdf_form_widgets.borrow_mut().clear();
@@ -2780,10 +3015,14 @@ impl Item for ImageView {
page_rotation: 0, page_rotation: 0,
pdf_outline: None, pdf_outline: None,
pdf_metadata: None, pdf_metadata: None,
pdf_form_bytes: None, pdf_edited_bytes: None,
pdf_form_widgets: RefCell::new(HashMap::default()), pdf_form_widgets: RefCell::new(HashMap::default()),
form_text_edit: None, form_text_edit: None,
form_option_menu: 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(), thumbnail_scroll_handle: gpui::ScrollHandle::new(),
pdf_thumbnail_cache: PageLruCache::new(DEFAULT_MEMORY_BUDGET_BYTES), pdf_thumbnail_cache: PageLruCache::new(DEFAULT_MEMORY_BUDGET_BYTES),
pdf_page_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_clockwise))
.on_action(cx.listener(Self::rotate_counterclockwise)) .on_action(cx.listener(Self::rotate_counterclockwise))
.on_action(cx.listener(Self::save_copy_as)) .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::select_all))
.on_action(cx.listener(Self::open_pdf_find)) .on_action(cx.listener(Self::open_pdf_find))
.on_action(cx.listener(Self::find_next_match)) .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_h = px(page_h_val);
let page_idx = page.page_index; let page_idx = page.page_index;
let links = page.links; 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_widgets = self.form_widgets_for_page(&pdf_bytes, page_idx);
let form_editor = self.form_text_edit.as_ref().and_then(|edit| { let form_editor = self.form_text_edit.as_ref().and_then(|edit| {
(edit.page_index == page_idx).then(|| { (edit.page_index == page_idx).then(|| {
@@ -3240,6 +3484,7 @@ impl Render for ImageView {
div() div()
.id(("pdf-link", page_idx * 100_000 + link_idx)) .id(("pdf-link", page_idx * 100_000 + link_idx))
.absolute() .absolute()
.when(!interaction_enabled, |this| this.cursor_default())
.left(link_left) .left(link_left)
.top(link_top) .top(link_top)
.w(link_w) .w(link_w)
@@ -3252,17 +3497,19 @@ impl Render for ImageView {
.rounded_xs() .rounded_xs()
}) })
.tooltip(Tooltip::text(link_tooltip)) .tooltip(Tooltip::text(link_tooltip))
.on_mouse_down( .when(interaction_enabled, |this| {
MouseButton::Left, this.on_mouse_down(
cx.listener(move |this, _event: &MouseDownEvent, _window, cx| { MouseButton::Left,
cx.stop_propagation(); cx.listener(move |this, _event: &MouseDownEvent, _window, cx| {
match link_dest_page { cx.stop_propagation();
Some(page) => this.jump_to_pdf_page(page, cx), match link_dest_page {
None if Self::is_safe_pdf_url(&link_url) => cx.open_url(&link_url), Some(page) => this.jump_to_pdf_page(page, cx),
None => log::warn!("Blocked opening potentially unsafe PDF link: {}", link_url), 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)| { .children(form_widgets.iter().enumerate().map(|(widget_index, widget)| {
let (widget_x, widget_y, widget_w, widget_h) = let (widget_x, widget_y, widget_w, widget_h) =
@@ -3290,22 +3537,72 @@ impl Render for ImageView {
.w(px(widget_w * page_w_val)) .w(px(widget_w * page_w_val))
.h(px(widget_h * page_h_val)) .h(px(widget_h * page_h_val))
.cursor(cursor) .cursor(cursor)
.on_mouse_down( .when(interaction_enabled, |this| {
MouseButton::Left, this.on_mouse_down(
cx.listener(move |this, event: &MouseDownEvent, window, cx| { MouseButton::Left,
cx.stop_propagation(); cx.listener(move |this, event: &MouseDownEvent, window, cx| {
window.focus(&this.focus_handle, cx); cx.stop_propagation();
this.begin_form_widget_edit( window.focus(&this.focus_handle, cx);
page_idx, this.begin_form_widget_edit(
&widget, page_idx,
event.position, &widget,
window, event.position,
cx, window,
); cx,
}), );
) }),
)
})
.tooltip(Tooltip::text(tooltip)) .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<Pixels>, _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))| { .children(form_editor.map(|(editor, (wx, wy, ww, wh))| {
div() div()
.id(("pdf-form-editor", page_idx)) .id(("pdf-form-editor", page_idx))
@@ -3344,6 +3641,71 @@ impl Render for ImageView {
window, window,
cx, 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<Self>| {
let is_active = self.annotation_tool == tool;
let selected_label: Option<SharedString> =
is_active.then(|| SharedString::from(label(tool)));
Button::new(("pdf-annotation-tool", tool as usize), label(tool))
.label_size(LabelSize::XSmall)
.selected_label::<SharedString>(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)| { .children(self.form_option_menu.as_ref().map(|(position, widget)| {
let options = widget.options.clone(); let options = widget.options.clone();
let selected = widget.value.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 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 item = image_item.read(cx);
let view = image_view.read(cx); let view = image_view.read(cx);
let show_text = view.show_text_panel; let show_text = view.show_text_panel;
let show_annotation_tools = view.show_annotation_tools;
let current_page = view let current_page = view
.visible_pdf_page(cx) .visible_pdf_page(cx)
.min(item.total_pages().saturating_sub(1)); .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() h_flex()
@@ -3704,6 +4073,7 @@ impl Render for ImageViewToolbarControls {
let image_view_outline = image_view.downgrade(); let image_view_outline = image_view.downgrade();
let image_view_presentation = image_view.downgrade(); let image_view_presentation = image_view.downgrade();
let image_view_properties = 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_ccw = image_view.downgrade();
let image_view_rotate_cw = image_view.downgrade(); let image_view_rotate_cw = image_view.downgrade();
this.child( 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( .child(
IconButton::new("rotate-counterclockwise", IconName::RotateCcw) IconButton::new("rotate-counterclockwise", IconName::RotateCcw)
.icon_size(IconSize::Small) .icon_size(IconSize::Small)
+3 -2
View File
@@ -22,8 +22,9 @@ pub mod watcher;
pub use cache::{CacheKey, CacheablePage, DEFAULT_MEMORY_BUDGET_BYTES, PageLruCache, RenderedPage}; pub use cache::{CacheKey, CacheablePage, DEFAULT_MEMORY_BUDGET_BYTES, PageLruCache, RenderedPage};
pub use document::{PageDimensions, PdfDocument}; pub use document::{PageDimensions, PdfDocument};
pub use pdfium::{ pub use pdfium::{
PdfCharBox, PdfDocumentDetails, PdfFormEdit, PdfFormWidget, PdfFormWidgetKind, PdfAnnotationColor, PdfCharBox, PdfDocumentDetails, PdfFormEdit, PdfFormWidget,
PdfLinkAnnotation, PdfMetadata, PdfOutlineItem, PdfPageDetails, PdfTextSegment, PdfiumEngine, PdfFormWidgetKind, PdfLinkAnnotation, PdfMetadata, PdfOutlineItem, PdfPageDetails,
PdfTextSegment, PdfiumEngine,
}; };
pub use rasterizer::{LuminosityToneMapper, PageRasterizer, RasterizerOptions}; pub use rasterizer::{LuminosityToneMapper, PageRasterizer, RasterizerOptions};
pub use settings::{DefaultZoomPolicy, PageLayoutMode, PdfViewerSettings}; pub use settings::{DefaultZoomPolicy, PageLayoutMode, PdfViewerSettings};
+327 -1
View File
@@ -6,7 +6,7 @@
use anyhow::{Context as _, Result}; use anyhow::{Context as _, Result};
use parking_lot::Mutex; use parking_lot::Mutex;
use pdfium_render::prelude::*; 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::path::{Path, PathBuf};
use std::sync::Arc; 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<Vec<u8>> {
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<Vec<u8>> {
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<Vec<FS_POINTF>> = 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;