Files
ElyOffice/apps/ely-pdf/src/annotate.rs
T

426 lines
17 KiB
Rust

//! Annotation and form tools on the page (PDF-05, PDF-06).
use std::collections::HashMap;
use ely_gpui_component::theme::ActiveTheme as _;
use ely_pdf_engine::{FieldKind, Marks, NewAnnot, Shape};
use gpui::{AnyElement, App, Context, IntoElement, Pixels, Point, Styled as _, canvas, div, point, px};
use crate::content::{Aim, Tool};
use crate::layout::Placed;
use crate::pages::{rect_on, to_view};
use crate::view::PdfView;
/// How far a click may move, in window pixels.
pub(crate) const CLICK: f32 = 3.0;
/// Pages read for their annotations at once.
const BATCH: usize = 20;
/// What the note panel works on.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Pick {
Annot { page: usize, id: u64 },
Field { page: usize, id: u64 },
NewNote { page: usize, at: (f32, f32) },
NewStamp { page: usize, at: (f32, f32) },
NewField { page: usize, rect: [f32; 4], kind: FieldKind },
Link { page: usize, index: usize },
NewLink { page: usize, rect: [f32; 4] },
}
/// A shape being drawn, in page points.
#[derive(Clone, Debug, PartialEq)]
pub struct Sketch {
pub page: usize,
pub points: Vec<(f32, f32)>,
}
impl Sketch {
/// The box from the first point to the last.
pub fn rect(&self) -> [f32; 4] {
let (a, b) = (self.points[0], self.points[self.points.len() - 1]);
[a.0.min(b.0), a.1.min(b.1), a.0.max(b.0), a.1.max(b.1)]
}
}
/// Annotations and fields by page.
pub type Notes = HashMap<usize, Marks>;
fn inside(rect: &[f32; 4], (x, y): (f32, f32)) -> bool {
x >= rect[0] && x <= rect[2] && y >= rect[1] && y <= rect[3]
}
/// A box around `at`, kept on the page.
pub fn around((x, y): (f32, f32), (w, h): (f32, f32), size: (f32, f32)) -> [f32; 4] {
let left = (x - w / 2.0).clamp(0.0, (size.0 - w).max(0.0));
let top = (y - h / 2.0).clamp(0.0, (size.1 - h).max(0.0));
[left, top, left + w, top + h]
}
/// Picked text as one box per line, by page.
pub fn line_boxes(chars: &[ely_pdf_engine::Char]) -> Vec<[f32; 4]> {
let mut boxes: Vec<(i32, [f32; 4])> = Vec::new();
for c in chars {
match boxes.last_mut() {
Some((line, b)) if *line == c.line => {
*b = [b[0].min(c.rect[0]), b[1].min(c.rect[1]), b[2].max(c.rect[2]), b[3].max(c.rect[3])];
}
_ => boxes.push((c.line, c.rect)),
}
}
boxes.into_iter().map(|(_, b)| b).collect()
}
impl PdfView {
/// Reads the annotations of up to a batch of `pages`.
pub fn ask_notes(&mut self, pages: impl IntoIterator<Item = usize>, cx: &mut Context<Self>) {
if self.reading_notes {
return;
}
let wanted: Vec<usize> = pages.into_iter().filter(|page| !self.notes.contains_key(page)).take(BATCH).collect();
if wanted.is_empty() {
return;
}
self.reading_notes = true;
let (doc, version) = (self.doc, self.version);
self.off(
move |worker| {
(wanted.into_iter())
.map(|page| Ok((page, worker.marks(doc, page)?, worker.links(doc, page)?)))
.collect::<std::io::Result<Vec<_>>>()
},
move |view, read, cx| {
view.reading_notes = false;
if view.version != version {
return cx.notify();
}
match read {
Ok(read) => {
for (page, marks, links) in read {
view.notes.insert(page, marks);
view.links.insert(page, links);
}
}
Err(error) => view.fail("reading annotations", &error, cx),
}
cx.notify();
},
cx,
);
}
/// Reads every field name in the form, once an edit.
pub fn ask_names(&mut self, cx: &mut Context<Self>) {
if self.form_names.is_some() || self.reading_names {
return;
}
self.reading_names = true;
let (doc, version) = (self.doc, self.version);
self.off(
move |worker| worker.field_names(doc),
move |view, read, cx| {
view.reading_names = false;
if view.version != version {
return cx.notify();
}
match read {
Ok(names) => view.form_names = Some(names),
Err(error) => view.fail("reading field names", &error, cx),
}
cx.notify();
},
cx,
);
}
/// Ends a text pick: marks it, or clicks.
pub fn finish_select(&mut self, at: Point<Pixels>, cx: &mut Context<Self>) {
self.select_to(at, cx);
let Some(selection) = self.selection else {
return;
};
// A hand that shakes a little still clicks.
let moved = self.pressed.take().map(|from| f32::from((at.x - from.x).abs() + (at.y - from.y).abs()));
let still = moved.is_some_and(|moved| moved < CLICK);
match self.tool {
Tool::Read if still => {
self.selection = None;
self.click(selection.anchor, cx)
}
Tool::Highlight | Tool::Underline | Tool::StrikeOut | Tool::RedactText if !still => {
self.marking = Some(selection);
self.mark_pending(cx)
}
_ => {}
}
}
/// A click fills a field or picks an annotation.
fn click(&mut self, (page, at): (usize, (f32, f32)), cx: &mut Context<Self>) {
let Some(marks) = self.notes.get(&page) else {
return;
};
let field = marks.fields.iter().rev().find(|f| inside(&f.rect, at) && !f.readonly).cloned();
let annot = marks.annots.iter().rev().find(|a| inside(&a.rect, at)).map(|a| a.id);
self.aim = match (field, annot) {
(Some(field), _) => match field.kind {
FieldKind::Checkbox | FieldKind::Radio => {
let value = if field.checked && field.kind == FieldKind::Checkbox { "Off".to_string() } else { field.on };
if !(field.checked && field.kind == FieldKind::Radio) {
self.settled_edit("filling a field", cx, move |worker, doc| worker.fill(doc, page, field.id, &value));
}
None
}
FieldKind::Text | FieldKind::Choice | FieldKind::List => Some(Aim::Note(Pick::Field { page, id: field.id })),
FieldKind::Signature | FieldKind::Button => None,
},
(None, Some(id)) => Some(Aim::Note(Pick::Annot { page, id })),
(None, None) => {
let link = self.links.get(&page).and_then(|links| links.iter().rev().find(|l| inside(&l.rect, at))).cloned();
if let Some(link) = link {
self.follow(&link, cx);
}
None
}
};
cx.notify();
}
/// Queues `op` unless an edit is pending.
pub fn settled_edit(
&mut self,
what: &'static str,
cx: &mut Context<Self>,
op: impl FnOnce(&mut ely_pdf_engine::Worker, ely_pdf_engine::Doc) -> std::io::Result<String> + Send + 'static,
) {
// A queued edit may move what the press meant.
if !self.settled() {
let text = crate::viewer::Viewer::text(cx, "pdf.object.changed", &[]);
cx.emit(crate::view::Failed(text.to_string()));
return cx.notify();
}
self.edit(what, op, cx);
}
/// Marks the pending pick once its pages' text is read.
pub fn mark_pending(&mut self, cx: &mut Context<Self>) {
let Some(selection) = self.marking else {
return;
};
let (first, last) = (selection.anchor.0.min(selection.head.0), selection.anchor.0.max(selection.head.0));
let missing: Vec<usize> = (first..=last).filter(|page| !self.text.contains_key(page)).collect();
if !missing.is_empty() {
for page in missing {
self.page_text(page, cx);
}
return;
}
self.marking = None;
self.mark_selection(selection, cx);
}
/// The picked text becomes a highlight, underline or strike.
fn mark_selection(&mut self, selection: crate::select::Selection, cx: &mut Context<Self>) {
let Some(span) = selection.span(|page| self.text.get(&page).map(|chars| chars.as_slice())) else {
return;
};
let mut annots = Vec::new();
for page in span.from.0..=span.to.0 {
let Some(chars) = self.text.get(&page) else {
return;
};
let Some(range) = span.on(page, chars.len()).filter(|range| !range.is_empty()) else {
continue;
};
let boxes = line_boxes(&chars[range]);
if self.tool == Tool::RedactText {
self.mark_regions(page, boxes, None, cx);
continue;
}
let shape = match self.tool {
Tool::Highlight => Shape::Highlight(boxes),
Tool::Underline => Shape::Underline(boxes),
_ => Shape::StrikeOut(boxes),
};
annots.push(NewAnnot { page, shape, rgb: None, contents: String::new(), author: String::new() });
}
self.selection = None;
self.annotate("marking text", annots, cx);
}
/// Adds `annots` signed by the account's name.
pub fn annotate(&mut self, what: &'static str, mut annots: Vec<NewAnnot>, cx: &mut Context<Self>) {
if annots.is_empty() {
return;
}
let author = ely_ui::settings::current(cx).author.clone();
self.settled_edit(what, cx, move |worker, doc| {
let name = ely_platform::user::named(&author)?;
annots.iter_mut().for_each(|annot| annot.author = name.clone());
worker.annotate(doc, &annots)
});
}
/// A press with a drawing or form tool.
pub fn sketch_from(&mut self, at: Point<Pixels>, cx: &mut Context<Self>) {
let Some((page, point)) = self.page_at(at) else {
return;
};
self.held = self.aim;
self.grip(page);
let hit = self.notes.get(&page).and_then(|marks| marks.fields.iter().rev().find(|f| inside(&f.rect, point)));
(self.aim, self.sketch) = match (self.tool, hit) {
(Tool::Note, _) => (Some(Aim::Note(Pick::NewNote { page, at: point })), None),
(Tool::Stamp, _) => (Some(Aim::Note(Pick::NewStamp { page, at: point })), None),
(Tool::Field(_), Some(field)) => (Some(Aim::Note(Pick::Field { page, id: field.id })), None),
(Tool::Sign, Some(field)) if field.kind == FieldKind::Signature => {
let object = u32::try_from(field.id).ok();
self.sign_at = Some(crate::sign::SignAt { page, object, rect: field.rect, version: self.version });
(None, None)
}
(Tool::Link, _) if let Some(index) = self.links.get(&page).and_then(|l| l.iter().rposition(|l| inside(&l.rect, point))) => {
(Some(Aim::Note(Pick::Link { page, index })), None)
}
_ => (None, Some(Sketch { page, points: vec![point] })),
};
cx.notify();
}
pub fn sketch_to(&mut self, at: Point<Pixels>, cx: &mut Context<Self>) {
let Some((page, point)) = self.held_at(at) else {
return;
};
let ink = self.tool == Tool::Ink;
if let Some(sketch) = self.sketch.as_mut().filter(|sketch| sketch.page == page) {
match ink || sketch.points.len() < 2 {
true => sketch.points.push(point),
false => *sketch.points.last_mut().expect("two points") = point,
}
cx.notify();
}
}
/// Ends a drawing: a shape lands, a field asks.
pub fn sketch_end(&mut self, cx: &mut Context<Self>) {
let Some(sketch) = self.sketch.take() else {
return;
};
let (page, rect) = (sketch.page, sketch.rect());
let small = rect[2] - rect[0] < 4.0 || rect[3] - rect[1] < 4.0;
let first = sketch.points[0];
let shape = match self.tool {
Tool::Square if !small => Shape::Square(rect),
Tool::Circle if !small => Shape::Circle(rect),
Tool::Line if (rect[2] - rect[0]).max(rect[3] - rect[1]) >= 4.0 => {
let last = sketch.points[sketch.points.len() - 1];
Shape::Line([first.0, first.1, last.0, last.1])
}
Tool::Ink if sketch.points.len() > 1 => Shape::Ink(vec![sketch.points]),
Tool::RedactArea => {
let click = small.then_some(first);
return self.mark_regions(page, if small { Vec::new() } else { vec![rect] }, click, cx);
}
Tool::PlaceSignature => return self.place_stamp(page, rect, small, cx),
Tool::Sign => {
let rect = if small { [first.0, first.1, first.0 + 200.0, first.1 + 60.0] } else { rect };
self.sign_at = Some(crate::sign::SignAt { page, object: None, rect, version: self.version });
return cx.notify();
}
Tool::Link => {
let rect = if small { [first.0, first.1, first.0 + 120.0, first.1 + 20.0] } else { rect };
self.aim = Some(Aim::Note(Pick::NewLink { page, rect }));
return cx.notify();
}
Tool::Field(kind) => {
let rect = match (small, kind) {
(false, _) => rect,
(true, FieldKind::Checkbox | FieldKind::Radio) => [first.0, first.1, first.0 + 14.0, first.1 + 14.0],
(true, _) => [first.0, first.1, first.0 + 150.0, first.1 + 20.0],
};
self.aim = Some(Aim::Note(Pick::NewField { page, rect, kind }));
return cx.notify();
}
_ => return cx.notify(),
};
let annot = NewAnnot { page, shape, rgb: None, contents: String::new(), author: String::new() };
self.annotate("drawing", vec![annot], cx);
}
/// The aimed annotation or field, and a shape being drawn.
pub fn note_marks(&self, place: &Placed, scale: f32, cx: &App) -> Vec<AnyElement> {
let color = cx.theme().colors.focus;
let size = self.sizes[place.page];
let outline = |rect: [f32; 4]| {
let (x, y, w, h) = rect_on(rect, size, self.rotate, scale);
div().absolute().left(px(x)).top(px(y)).w(px(w)).h(px(h)).border_1().border_color(color)
};
let mut marks = Vec::new();
let aimed = match self.aim {
Some(Aim::Note(Pick::Annot { page, id })) if page == place.page => {
self.notes.get(&page).and_then(|m| m.annots.iter().find(|a| a.id == id)).map(|a| a.rect)
}
Some(Aim::Note(Pick::Field { page, id })) if page == place.page => {
self.notes.get(&page).and_then(|m| m.fields.iter().find(|f| f.id == id)).map(|f| f.rect)
}
Some(Aim::Note(Pick::NewField { page, rect, .. } | Pick::NewLink { page, rect })) if page == place.page => Some(rect),
Some(Aim::Note(Pick::Link { page, index })) if page == place.page => {
self.links.get(&page).and_then(|links| links.get(index)).map(|l| l.rect)
}
_ => None,
};
marks.extend(aimed.map(|rect| outline(rect).into_any_element()));
let Some(sketch) = self.sketch.as_ref().filter(|sketch| sketch.page == place.page) else {
return marks;
};
match self.tool {
Tool::Square | Tool::Field(_) | Tool::Link | Tool::RedactArea | Tool::Sign | Tool::PlaceSignature => marks.push(outline(sketch.rect()).into_any_element()),
Tool::Circle => marks.push(outline(sketch.rect()).rounded_full().into_any_element()),
_ => {
let points: Vec<Point<Pixels>> = (sketch.points.iter())
.map(|p| to_view(*p, size, self.rotate))
.map(|(x, y)| point(px(x * scale), px(y * scale)))
.collect();
marks.push(stroke(points, color).into_any_element());
}
}
marks
}
}
/// A line through `points`, over the page.
fn stroke(points: Vec<Point<Pixels>>, color: gpui::Hsla) -> impl IntoElement {
canvas(
|_, _, _| {},
move |bounds, _, window, _| {
let mut path = gpui::PathBuilder::stroke(px(2.0));
for (at, p) in points.iter().enumerate() {
let p = bounds.origin + *p;
if at == 0 { path.move_to(p) } else { path.line_to(p) }
}
match path.build() {
Ok(path) => window.paint_path(path, color),
Err(error) => log::error!("pdf: a stroke would not build: {error}"),
}
},
)
.absolute()
.top_0()
.left_0()
.size_full()
}
#[cfg(test)]
mod tests {
use super::*;
use ely_pdf_engine::Char;
#[test]
fn picked_text_marks_one_box_a_line_and_boxes_stay_on_the_page() {
let c = |line, x: f32, y: f32| Char { line, c: 'a', rect: [x, y, x + 10.0, y + 12.0] };
let chars = [c(0, 0.0, 0.0), c(0, 10.0, 1.0), c(1, 0.0, 20.0)];
assert_eq!(line_boxes(&chars), [[0.0, 0.0, 20.0, 13.0], [0.0, 20.0, 10.0, 32.0]]);
assert_eq!(around((5.0, 5.0), (20.0, 10.0), (100.0, 100.0)), [0.0, 0.0, 20.0, 10.0]);
assert_eq!(around((50.0, 50.0), (20.0, 10.0), (100.0, 100.0)), [40.0, 45.0, 60.0, 55.0]);
}
}