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:
1 parent
0fd48144f9
commit
37750c24e2
3 files changed
+754
-38
No files matched your search
@@ -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<Editor>,
|
||||
@@ -232,11 +269,16 @@ pub struct ImageView {
|
||||
pdf_metadata: Option<pdf_viewer::PdfMetadata>,
|
||||
/// 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<Arc<Vec<u8>>>,
|
||||
pdf_edited_bytes: Option<Arc<Vec<u8>>>,
|
||||
/// Form widgets per page, filled on demand for overlays and hit testing.
|
||||
pdf_form_widgets: RefCell<HashMap<usize, Arc<Vec<pdf_viewer::PdfFormWidget>>>>,
|
||||
form_text_edit: Option<FormTextEdit>,
|
||||
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,
|
||||
pdf_thumbnail_cache: PageLruCache<CachedPageImage>,
|
||||
pdf_page_cache: PageLruCache<CachedPageImage>,
|
||||
@@ -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<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 Some(bounds) = bounds else {
|
||||
return;
|
||||
@@ -1127,12 +1191,23 @@ impl ImageView {
|
||||
}
|
||||
|
||||
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 {
|
||||
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<Arc<Vec<u8>>> {
|
||||
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>,
|
||||
) {
|
||||
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
|
||||
/// 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<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))| {
|
||||
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<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)| {
|
||||
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)
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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<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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
Reference in new issue
Block a user