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,
|
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)
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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::*;
|
||||||
|
|||||||
Reference in new issue
Block a user