Ely Office: six offline-first desktop apps in Rust and GPUI

ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git),
ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow.
This commit is contained in:
ZacharyZhang-NY committed 2026-10-08 21:20:08 -04:00
commit b7fca0146d
817 files changed
+143101

No files matched your search

+424
View File
@@ -0,0 +1,424 @@
//! 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.
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;
}
self.settled_edit(what, cx, move |worker, doc| {
let name = ely_platform::user::name()?;
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]);
}
}
+286
View File
@@ -0,0 +1,286 @@
//! Crop, extract and split (PDF-02).
use std::{
io::{self, Write as _},
path::{Path, PathBuf},
};
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
data_display::DescriptionList,
forms::NumberInput,
overlays::Dialog as Card,
primitives::Severity,
};
use ely_office::{Closing as _, Saving as _, Tabs as _, in_window};
use gpui::{AnyElement, App, Context, Entity, IntoElement, ParentElement as _, TaskExt as _, Window};
use crate::viewer::Viewer;
/// Points in a millimetre.
const MM: f32 = 72.0 / 25.4;
/// The viewer's own dialogs.
pub enum Ask {
/// Pages to crop, margins in mm: top, right, bottom, left.
Crop { tab: u64, pages: Vec<usize>, margins: [f32; 4] },
/// Pages in each file.
Split { tab: u64, every: usize },
}
/// Files of `every` pages from `count`: (first, last) each.
pub fn parts(count: usize, every: usize) -> Vec<(usize, usize)> {
(0..count).step_by(every.max(1)).map(|first| (first, (first + every).min(count) - 1)).collect()
}
/// Margins as seen through a view turned `rotate` degrees, unturned.
pub fn unturn(seen: [f32; 4], rotate: i32) -> [f32; 4] {
let turns = (rotate.rem_euclid(360) / 90) as usize;
std::array::from_fn(|side| seen[(side + turns) % 4])
}
/// A file name's stem.
fn stem(name: &str) -> &str {
name.rsplit_once('.').map_or(name, |(stem, _)| stem)
}
impl Viewer {
pub fn ask_crop(&mut self, _: &mut Window, cx: &mut Context<Self>) {
let (Some(tab), Some(view)) = (self.core.active, self.active_view()) else {
return self.no_document(cx);
};
let pages = view.read(cx).chosen_pages();
self.ask = Some(Ask::Crop { tab, pages, margins: [0.0; 4] });
cx.notify();
}
pub fn ask_split(&mut self, _: &mut Window, cx: &mut Context<Self>) {
let Some(tab) = self.core.active else {
return self.no_document(cx);
};
self.ask = Some(Ask::Split { tab, every: 1 });
cx.notify();
}
pub(crate) fn crop(&mut self, cx: &mut Context<Self>) {
let Some(Ask::Crop { tab, pages, margins }) = self.ask.take() else {
return;
};
let Some(view) = self.tab(tab).map(|tab| tab.view.clone()) else {
return;
};
let turned = unturn(margins, view.read(cx).rotate);
let points = turned.map(|side| side * MM);
view.update(cx, |view, cx| {
view.edit("cropping pages", move |worker, doc| worker.crop_pages(doc, &pages, points), cx)
});
cx.notify();
}
/// Asks where, then writes the chosen pages there.
pub fn extract(&mut self, cx: &mut Context<Self>) {
let (Some(tab), Some(view)) = (self.active_tab(), self.active_view()) else {
return self.no_document(cx);
};
let (id, pages) = (tab.id, view.read(cx).chosen_pages());
let Some(dir) = ely_office::folder(tab) else {
return self.panel_failed("no documents folder", cx);
};
let name = format!("{}-{}.pdf", stem(&tab.name), pages[0] + 1);
let chosen = cx.prompt_for_new_path(&dir, Some(&name));
cx.spawn(async move |this, cx| {
let chosen = match chosen.await {
Ok(Ok(path)) => Ok(path),
Ok(Err(error)) => Err(error.to_string()),
Err(error) => Err(error.to_string()),
};
in_window(&this, cx, |viewer, _, cx| match chosen {
Ok(Some(path)) => viewer.write_pages(id, pages, path, cx),
Ok(None) => log::info!("pdf: the extract panel was cancelled"),
Err(error) => viewer.panel_failed(&error, cx),
})
})
.detach_and_log_err(cx);
}
/// Writes `pages` to `target` once tab `id` settles.
pub(crate) fn write_pages(&mut self, id: u64, pages: Vec<usize>, target: PathBuf, cx: &mut Context<Self>) {
if !self.settled(id, cx) {
return self.when_settled(id, move |viewer, _, cx| viewer.write_pages(id, pages, target, cx));
}
let Some(tab) = self.tab(id) else {
return;
};
let (name, opened) = (ely_office::files::file_name(&target), target.clone());
match self.holder(&target) {
Ok(None) => {}
Ok(Some(_)) => return self.toast(Self::text(cx, "pdf.extract.taken", &[("name", &name)]), Severity::Danger, cx),
Err(error) => return self.failed_write(&name, &error, cx),
}
let (work, open, revision) = (tab.work.join("extract.pdf"), tab.session.path().map(PathBuf::from), tab.session.revision());
log::info!("pdf: extracts {} pages", pages.len());
self.read_in(
id,
move |worker, doc| {
worker.get()?.extract(*doc, &pages, &work)?;
let bytes = std::fs::read(&work)?;
std::fs::remove_file(&work)?;
ely_files::export(&target, open.as_deref(), &bytes, revision).map(|_| ())
},
move |viewer, done, _, cx| match done {
Ok(()) => viewer.toast_output(Self::text(cx, "pdf.extract.done", &[("name", &name)]), opened.clone(), cx),
Err(error) => viewer.failed_write(&name, &error, cx),
},
cx,
);
}
fn failed_write(&self, name: &str, error: &io::Error, cx: &mut App) {
if error.kind() == io::ErrorKind::PermissionDenied {
return self.failed(error, cx);
}
let text = Self::text(cx, "office.error.save", &[("name", name), ("error", &error.to_string())]);
self.toast(text, Severity::Danger, cx);
}
/// Asks for a folder, then writes the parts there.
fn split(&mut self, cx: &mut Context<Self>) {
let Some(Ask::Split { tab, every }) = self.ask.take() else {
return;
};
cx.notify();
self.pick(true, cx, move |viewer, folder, cx| viewer.write_parts(tab, every, folder, cx));
}
pub(crate) fn write_parts(&mut self, id: u64, every: usize, folder: PathBuf, cx: &mut Context<Self>) {
if !self.settled(id, cx) {
return self.when_settled(id, move |viewer, _, cx| viewer.write_parts(id, every, folder, cx));
}
let (Some(tab), Some(view)) = (self.tab(id), self.tab(id).map(|tab| tab.view.clone())) else {
return;
};
let parts = parts(view.read(cx).sizes.len(), every);
let stem = stem(&tab.name).to_string();
let targets: Vec<PathBuf> = (1..=parts.len()).map(|n| folder.join(format!("{stem}-{n}.pdf"))).collect();
let opened = folder.clone();
let work = tab.work.join("part.pdf");
log::info!("pdf: splits into {} files", parts.len());
self.read_in(
id,
move |worker, doc| {
let claimed = ely_files::claim_all(&targets)?;
let written = (parts.iter().zip(claimed)).try_for_each(|((first, last), file)| {
let pages: Vec<usize> = (*first..=*last).collect();
worker.get()?.extract(*doc, &pages, &work)?;
copy_into(&work, file)
});
// A failure leaves none of this run's files.
if let Err(error) = &written
&& let Err(left) = targets.iter().try_for_each(std::fs::remove_file)
{
return Err(io::Error::other(format!("{error}; then {left}")));
}
written.map(|()| targets.len())
},
move |viewer, done, _, cx| match done {
Ok(count) => viewer.toast_output(Self::text(cx, "pdf.split.done", &[("count", &count.to_string())]), opened.clone(), cx),
Err(error) => viewer.failed_write(&stem, &error, cx),
},
cx,
);
}
/// The open dialog of the viewer's own, if any.
pub fn ask_element(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let this = cx.entity();
let label = |key: &str| Self::text(cx, key, &[]);
let dismiss = {
let this = this.clone();
move |_: &mut Window, cx: &mut App| {
this.update(cx, |viewer, cx| {
viewer.ask = None;
cx.notify();
})
}
};
let card = match self.ask.as_ref()? {
Ask::Crop { pages, margins, .. } => {
let count = pages.len().to_string();
let title = Self::text(cx, "pdf.crop.title", &[("count", &count)]);
let sides = ["top", "right", "bottom", "left"];
let rows = sides.iter().enumerate().fold(DescriptionList::new(), |rows, (at, side)| {
let name = label(&format!("pdf.crop.{side}"));
rows.item(name.clone(), margin(&this, at, margins[at], name))
});
let go = label("pdf.crop.go");
Card::new("pdf-crop", title, dismiss).child(rows).action(ely_office::cancel(cx)).action(move |_| {
Button::new("pdf-crop-go", go)
.variant(ButtonVariant::Primary)
.on_click(move |_, _, cx| this.update(cx, |viewer, cx| viewer.crop(cx)))
})
}
Ask::Split { every, .. } => {
let changed = this.clone();
let field = NumberInput::new("pdf-split-every", *every as f64)
.range(1.0, 100_000.0)
.label(label("pdf.split.every"))
.on_commit(move |value, _, cx| {
changed.update(cx, |viewer, _| {
if let Some(Ask::Split { every, .. }) = viewer.ask.as_mut() {
*every = value.round() as usize;
}
})
});
let rows = DescriptionList::new().item(label("pdf.split.every"), field);
let go = label("pdf.split.go");
Card::new("pdf-split", label("pdf.split.title"), dismiss).child(rows).action(ely_office::cancel(cx)).action(move |_| {
Button::new("pdf-split-go", go)
.variant(ButtonVariant::Primary)
.on_click(move |_, _, cx| this.update(cx, |viewer, cx| viewer.split(cx)))
})
}
};
Some(card.into_any_element())
}
}
/// A margin field in millimetres.
fn margin(this: &Entity<Viewer>, at: usize, value: f32, name: gpui::SharedString) -> NumberInput {
let this = this.clone();
NumberInput::new(("pdf-crop", at), f64::from(value))
.range(0.0, 1000.0)
.suffix("mm")
.label(name)
.on_commit(move |value, _, cx| {
this.update(cx, |viewer, _| {
if let Some(Ask::Crop { margins, .. }) = viewer.ask.as_mut() {
margins[at] = value as f32;
}
})
})
}
/// Copies a written part into its claimed file.
pub(crate) fn copy_into(from: &Path, mut to: std::fs::File) -> io::Result<()> {
to.write_all(&std::fs::read(from)?)?;
to.sync_all()?;
std::fs::remove_file(from)
}
#[cfg(test)]
mod tests {
#[test]
fn parts_cover_every_page_once() {
assert_eq!(super::parts(5, 2), [(0, 1), (2, 3), (4, 4)]);
assert_eq!(super::parts(3, 5), [(0, 2)]);
assert_eq!(super::parts(2, 1), [(0, 0), (1, 1)]);
}
#[test]
fn a_turned_view_crops_the_side_it_shows() {
// Turned right, the page's left side shows on top.
assert_eq!(super::unturn([1.0, 2.0, 3.0, 4.0], 90), [2.0, 3.0, 4.0, 1.0]);
assert_eq!(super::unturn([1.0, 2.0, 3.0, 4.0], 0), [1.0, 2.0, 3.0, 4.0]);
assert_eq!(super::unturn([1.0, 2.0, 3.0, 4.0], 270), [4.0, 1.0, 2.0, 3.0]);
}
}
+190
View File
@@ -0,0 +1,190 @@
//! Attachments: listed beside the pages, saved, added, removed (PDF-07).
use std::path::PathBuf;
use ely_gpui_component::{
feedback::EmptyState,
lists::{ListItem, SelectableList},
menus::{ContextMenu, Menu as Popup, MenuItem as PopupItem},
primitives::{IconName, Severity},
};
use ely_office::{Closing as _, Saving as _, Tabs as _, in_window};
use gpui::{
Action, AnyElement, App, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _,
StatefulInteractiveElement as _, Styled as _, TaskExt as _, div,
};
use crate::document::{RemoveAttachment, SaveAttachment};
use crate::view::PdfView;
use crate::viewer::Viewer;
/// A size as people read it.
pub fn size(bytes: u64) -> String {
match bytes {
0..1024 => format!("{bytes} B"),
1024..1_048_576 => format!("{:.1} KB", bytes as f64 / 1024.0),
_ => format!("{:.1} MB", bytes as f64 / 1_048_576.0),
}
}
impl PdfView {
/// Reads the attachments once an edit.
pub fn ask_files(&mut self, cx: &mut Context<Self>) {
if self.files.is_some() || self.reading_files {
return;
}
self.reading_files = true;
let (doc, version) = (self.doc, self.version);
self.off(
move |worker| worker.attachments(doc),
move |view, read, cx| {
view.reading_files = false;
if view.version != version {
return cx.notify();
}
match read {
Ok(files) => view.files = Some(files),
Err(error) => view.fail("reading attachments", &error, cx),
}
cx.notify();
},
cx,
);
}
}
/// The attachments of `view`; a right press saves or removes.
pub fn list(view: &Entity<PdfView>, cx: &mut App) -> AnyElement {
view.update(cx, |view, cx| view.ask_files(cx));
let read = view.read(cx);
let Some(files) = read.files.as_ref() else {
return div().into_any_element();
};
if files.is_empty() {
return EmptyState::new("pdf-files-none", IconName::File, Viewer::text(cx, "pdf.files.none", &[])).into_any_element();
}
let total = files.len();
let rows = files.iter().enumerate().fold(SelectableList::new("pdf-files"), |list, (at, file)| {
let mut about = size(file.size);
if !file.mime.is_empty() {
about = format!("{about} · {}", file.mime);
}
if let Some(page) = file.page {
about = format!("{about} · {}", Viewer::text(cx, "pdf.page.label", &[("n", &(page + 1).to_string())]));
}
let mut item = ListItem::new(("pdf-file", at), file.name.clone()).description(about).place(at + 1, total);
if !file.note.is_empty() {
item = item.detail(file.note.clone());
}
list.row(file.key.clone(), item)
});
let picked = view.clone();
let rows = rows
.selected(read.file_pick.clone())
.label(Viewer::text(cx, "pdf.side.files", &[]))
.on_change(move |keys, _, cx| {
let name = keys.first().map(|key| key.to_string());
picked.update(cx, |view, cx| {
view.file_pick = name;
cx.notify();
})
});
let item = |key: &str, action: Box<dyn Action>| {
PopupItem::new(Viewer::text(cx, key, &[])).on_click(move |window, cx| window.dispatch_action(action.boxed_clone(), cx))
};
let popup = Popup::new().item(item("pdf.file.save", Box::new(SaveAttachment))).item(item("pdf.file.remove", Box::new(RemoveAttachment)));
let body = div().id("pdf-files-scroll").size_full().overflow_y_scroll().child(rows);
ContextMenu::new("pdf-files-menu", popup).size_full().child(body).into_any_element()
}
impl Viewer {
/// Asks for a file, then embeds it by name.
pub fn attach_file(&mut self, cx: &mut Context<Self>) {
let Some(view) = self.active_view() else {
return self.no_document(cx);
};
self.pick(false, cx, move |_, path: PathBuf, cx| {
let name = ely_office::files::file_name(&path);
log::info!("pdf: attaches a file");
view.update(cx, |view, cx| view.edit("attaching a file", move |worker, doc| worker.attach(doc, &name, &path), cx));
});
}
pub fn remove_attachment(&mut self, cx: &mut Context<Self>) {
let Some(view) = self.active_view() else {
return self.no_document(cx);
};
let Some(key) = view.read(cx).file_pick.clone() else {
return;
};
view.update(cx, |view, cx| {
view.file_pick = None;
view.edit("removing an attachment", move |worker, doc| worker.detach(doc, &key), cx)
});
}
/// Asks where, then writes the picked attachment there.
pub fn save_attachment(&mut self, cx: &mut Context<Self>) {
let (Some(tab), Some(view)) = (self.active_tab(), self.active_view()) else {
return self.no_document(cx);
};
let read = view.read(cx);
let picked = read.file_pick.as_ref().and_then(|key| read.files.as_ref()?.iter().find(|file| &file.key == key));
let Some((key, name)) = picked.map(|file| (file.key.clone(), file.name.clone())) else {
return;
};
let Some(dir) = ely_office::folder(tab) else {
return self.panel_failed("no documents folder", cx);
};
let id = tab.id;
let chosen = cx.prompt_for_new_path(&dir, Some(&name));
cx.spawn(async move |this, cx| {
let chosen = match chosen.await {
Ok(Ok(path)) => Ok(path),
Ok(Err(error)) => Err(error.to_string()),
Err(error) => Err(error.to_string()),
};
in_window(&this, cx, |viewer, _, cx| match chosen {
Ok(Some(target)) => viewer.write_attachment(id, key, target, cx),
Ok(None) => log::info!("pdf: the save panel was cancelled"),
Err(error) => viewer.panel_failed(&error, cx),
})
})
.detach_and_log_err(cx);
}
pub(crate) fn write_attachment(&mut self, id: u64, key: String, target: PathBuf, cx: &mut Context<Self>) {
let Some(tab) = self.tab(id) else {
return;
};
let (shown, opened) = (ely_office::files::file_name(&target), target.clone());
match self.holder(&target) {
Ok(None) => {}
Ok(Some(_)) => return self.toast(Self::text(cx, "pdf.extract.taken", &[("name", &shown)]), Severity::Danger, cx),
Err(error) => return self.failed(&error, cx),
}
let (open, revision) = (tab.session.path().map(PathBuf::from), tab.session.revision());
self.read_in(
id,
move |worker, doc| {
let bytes = worker.get()?.attachment(*doc, &key)?;
ely_files::export(&target, open.as_deref(), &bytes, revision).map(|_| ())
},
move |viewer, done, _, cx| match done {
Ok(()) => viewer.toast_output(Self::text(cx, "pdf.extract.done", &[("name", &shown)]), opened.clone(), cx),
Err(error) => viewer.failed(&error, cx),
},
cx,
);
}
}
#[cfg(test)]
mod tests {
#[test]
fn sizes_read_in_their_unit() {
assert_eq!(super::size(33), "33 B");
assert_eq!(super::size(2048), "2.0 KB");
assert_eq!(super::size(3 * 1_048_576), "3.0 MB");
}
}
+273
View File
@@ -0,0 +1,273 @@
//! Text blocks and drawn objects, picked and dragged (PDF-03, PDF-04).
use std::collections::HashMap;
use ely_gpui_component::theme::ActiveTheme as _;
use ely_pdf_engine::{Drawn, TextBlock};
use gpui::{AnyElement, App, Context, IntoElement, Pixels, Point, Styled as _, div, px};
use crate::layout::Placed;
use crate::pages::rect_on;
use crate::view::PdfView;
/// What a press on a page does.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Tool {
#[default]
Read,
Edit,
AddText,
Highlight,
Underline,
StrikeOut,
Note,
Square,
Circle,
Line,
Ink,
Stamp,
Field(ely_pdf_engine::FieldKind),
Link,
/// Marks picked text, or drawn areas, to remove.
RedactText,
RedactArea,
/// Places a signature signed with a certificate.
Sign,
/// Places a visible signature made in its dialog.
PlaceSignature,
}
impl Tool {
/// Presses pick text, as in reading.
pub fn selects(self) -> bool {
matches!(self, Tool::Read | Tool::Highlight | Tool::Underline | Tool::StrikeOut | Tool::RedactText)
}
/// Presses aim at text blocks and objects.
pub fn edits(self) -> bool {
matches!(self, Tool::Edit | Tool::AddText)
}
}
/// A page's text blocks and drawn objects.
#[derive(Clone, Debug, Default)]
pub struct Layer {
pub blocks: Vec<TextBlock>,
pub drawn: Vec<Drawn>,
}
/// What the edit panel works on.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Aim {
Text { page: usize, index: usize },
Object { page: usize, index: usize },
/// New text whose box starts here, in page points.
New { page: usize, at: (f32, f32) },
/// An annotation or form field.
Note(crate::annotate::Pick),
}
/// An object being moved, or scaled from its corner.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Drag {
pub page: usize,
pub index: usize,
pub rect: [f32; 4],
pub from: (f32, f32),
pub now: (f32, f32),
pub scale: bool,
}
/// The corner handle's reach, in page points.
const HANDLE: f32 = 6.0;
impl Drag {
/// The page-space change the drag makes.
pub fn change(&self) -> [f32; 6] {
let ([x0, y0, x1, y1], (dx, dy)) = (self.rect, (self.now.0 - self.from.0, self.now.1 - self.from.1));
match self.scale {
false => [1.0, 0.0, 0.0, 1.0, dx, dy],
true => {
// Aspect kept, the top left fixed.
let s = ((x1 - x0 + dx) / (x1 - x0)).max((y1 - y0 + dy) / (y1 - y0)).max(0.05);
[s, 0.0, 0.0, s, x0 - s * x0, y0 - s * y0]
}
}
}
/// The object's box as the drag has it now.
pub fn shown(&self) -> [f32; 4] {
let [a, _, _, d, e, f] = self.change();
let [x0, y0, x1, y1] = self.rect;
[a * x0 + e, d * y0 + f, a * x1 + e, d * y1 + f]
}
}
fn inside(rect: &[f32; 4], (x, y): (f32, f32)) -> bool {
x >= rect[0] && x <= rect[2] && y >= rect[1] && y <= rect[3]
}
impl PdfView {
/// A drag or drawing is under way.
pub fn gesturing(&self) -> bool {
self.drag.is_some() || self.sketch.is_some()
}
/// What the side panel shows; held still mid-gesture.
pub fn panel_aim(&self) -> Option<Aim> {
if self.gesturing() { self.held } else { self.aim }
}
/// Changes the tool; what was aimed at is let go.
pub fn set_tool(&mut self, tool: Tool, cx: &mut Context<Self>) {
log::info!("pdf: the {tool:?} tool");
(self.tool, self.aim, self.drag, self.sketch, self.selection, self.marking) = (tool, None, None, None, None, None);
cx.notify();
}
/// Reads the layers of `pages`, one page at a time.
pub fn ask_layers(&mut self, pages: &[usize], cx: &mut Context<Self>) {
if !self.tool.edits() || self.reading_layer {
return;
}
let Some(page) = pages.iter().copied().find(|page| !self.layers.contains_key(page)) else {
return;
};
self.reading_layer = true;
let (doc, version) = (self.doc, self.version);
self.off(
move |worker| Ok(Layer { blocks: worker.text_blocks(doc, page)?, drawn: worker.objects(doc, page)? }),
move |view, layer, cx| {
// Cleared either way; reads from before an edit retry.
view.reading_layer = false;
if view.version != version {
return cx.notify();
}
match layer {
Ok(layer) => {
view.layers.insert(page, layer);
}
Err(error) => view.fail("reading what the page holds", &error, cx),
}
cx.notify();
},
cx,
);
}
/// A press aims at an object, text, or new text.
pub fn press(&mut self, at: Point<Pixels>, cx: &mut Context<Self>) {
let Some((page, point)) = self.page_at(at) else {
return;
};
(self.drag, self.held) = (None, self.aim);
self.grip(page);
if self.tool == Tool::AddText {
self.aim = Some(Aim::New { page, at: point });
return cx.notify();
}
let Some(layer) = self.layers.get(&page) else {
return;
};
let corner = |rect: &[f32; 4]| (point.0 - rect[2]).abs() <= HANDLE && (point.1 - rect[3]).abs() <= HANDLE;
let held = match self.aim {
Some(Aim::Object { page: on, index }) if on == page => layer.drawn.get(index).filter(|d| corner(&d.rect)).map(|_| (index, true)),
_ => None,
};
let hit = held.or_else(|| layer.drawn.iter().rposition(|d| inside(&d.rect, point)).map(|index| (index, false)));
self.aim = match hit {
Some((index, scale)) => {
let rect = layer.drawn[index].rect;
self.drag = Some(Drag { page, index, rect, from: point, now: point, scale });
Some(Aim::Object { page, index })
}
None => (layer.blocks.iter())
.position(|b| b.editable && inside(&b.rect, point))
.map(|index| Aim::Text { page, index }),
};
cx.notify();
}
pub fn drag_to(&mut self, at: Point<Pixels>, cx: &mut Context<Self>) {
let Some((page, point)) = self.held_at(at) else {
return;
};
if let Some(drag) = self.drag.as_mut().filter(|drag| drag.page == page) {
drag.now = point;
cx.notify();
}
}
/// Ends a drag; a real move is one edit.
pub fn release(&mut self, cx: &mut Context<Self>) {
let Some(drag) = self.drag.take() else {
return;
};
if (drag.now.0 - drag.from.0).abs() < 1.0 && (drag.now.1 - drag.from.1).abs() < 1.0 {
return cx.notify();
}
// A queued edit may renumber what was dragged.
if !self.settled() {
let text = crate::viewer::Viewer::text(cx, "pdf.object.changed", &[]);
cx.emit(crate::view::Failed(text.to_string()));
return cx.notify();
}
let (page, index, change) = (drag.page, drag.index, drag.change());
self.edit(
if drag.scale { "scaling an object" } else { "moving an object" },
move |worker, doc| worker.place(doc, page, index, change),
cx,
);
}
/// Outlines of what edits on `place`; the aimed one bold.
pub fn edit_marks(&self, place: &Placed, scale: f32, cx: &App) -> Vec<AnyElement> {
let Some(layer) = self.layers.get(&place.page).filter(|_| self.tool == Tool::Edit) else {
return Vec::new();
};
let colors = &cx.theme().colors;
let size = self.sizes[place.page];
let outline = |rect: [f32; 4], strong: bool| {
let (x, y, w, h) = rect_on(rect, size, self.rotate, scale);
let color = if strong { colors.focus } else { colors.focus.opacity(0.35) };
div().absolute().left(px(x)).top(px(y)).w(px(w)).h(px(h)).border_1().border_color(color).into_any_element()
};
let aimed = |object: bool, index: usize| match self.aim {
Some(Aim::Object { page, index: at }) => object && page == place.page && at == index,
Some(Aim::Text { page, index: at }) => !object && page == place.page && at == index,
_ => false,
};
let mut marks: Vec<AnyElement> = (layer.blocks.iter().enumerate())
.filter(|(_, b)| b.editable)
.map(|(index, b)| outline(b.rect, aimed(false, index)))
.collect();
for (index, drawn) in layer.drawn.iter().enumerate() {
let dragged = self.drag.filter(|d| d.page == place.page && d.index == index);
let rect = dragged.map_or(drawn.rect, |d| d.shown());
marks.push(outline(rect, aimed(true, index)));
if aimed(true, index) {
let (x, y, w, h) = rect_on(rect, size, self.rotate, scale);
let corner = div().absolute().left(px(x + w - 4.0)).top(px(y + h - 4.0)).size(px(8.0)).bg(colors.focus);
marks.push(corner.into_any_element());
}
}
marks
}
}
/// Layers by page, dropped with each edit.
pub type Layers = HashMap<usize, Layer>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_drag_moves_or_scales_from_the_top_left() {
let mut drag = Drag { page: 0, index: 0, rect: [10.0, 20.0, 110.0, 70.0], from: (50.0, 40.0), now: (60.0, 45.0), scale: false };
assert_eq!(drag.change(), [1.0, 0.0, 0.0, 1.0, 10.0, 5.0]);
assert_eq!(drag.shown(), [20.0, 25.0, 120.0, 75.0]);
drag = Drag { from: (110.0, 70.0), now: (210.0, 80.0), scale: true, ..drag };
assert_eq!(drag.shown(), [10.0, 20.0, 210.0, 120.0], "twice as wide, twice as tall");
}
}
+82
View File
@@ -0,0 +1,82 @@
//! The Document menu (PDF-07 to PDF-12).
use gpui::{App, Context, Div, InteractiveElement as _, Menu, MenuItem, actions};
use crate::content::Tool;
use crate::viewer::Viewer;
actions!(
pdf,
[
DocumentProperties,
RecognizeText,
AddBookmark,
RenameBookmark,
DeleteBookmark,
AddLink,
AttachFile,
SaveAttachment,
RemoveAttachment,
RedactText,
RedactArea,
ClearRedactions,
ApplyRedactions,
CleanDocument,
OwnerPassword,
ProtectDocument,
SignDocument,
AddVisibleSignature,
ShowSignatures,
CloseSignatures,
ShowRepairReport
]
);
pub fn menu(cx: &App) -> Menu {
let label = |key: &str| Viewer::text(cx, key, &[]);
Menu::new(label("menu.document")).items([
MenuItem::action(label("pdf.props.open"), DocumentProperties),
MenuItem::action(label("pdf.ocr.open"), RecognizeText),
MenuItem::separator(),
MenuItem::action(label("pdf.mark.add"), AddBookmark),
MenuItem::action(label("pdf.tool.link"), AddLink),
MenuItem::action(label("pdf.file.attach"), AttachFile),
MenuItem::separator(),
MenuItem::action(label("pdf.redact.text"), RedactText),
MenuItem::action(label("pdf.redact.area"), RedactArea),
MenuItem::action(label("pdf.redact.clear"), ClearRedactions),
MenuItem::action(label("pdf.redact.apply"), ApplyRedactions),
MenuItem::action(label("pdf.clean.open"), CleanDocument),
MenuItem::separator(),
MenuItem::action(label("pdf.owner.open"), OwnerPassword),
MenuItem::action(label("pdf.protect.open"), ProtectDocument),
MenuItem::action(label("pdf.sign.show"), ShowSignatures),
MenuItem::action(label("pdf.sign.open"), SignDocument),
MenuItem::action(label("pdf.visible.open"), AddVisibleSignature),
])
}
/// Wires the Document actions onto the root.
pub fn on(root: Div, cx: &mut Context<Viewer>) -> Div {
root.on_action(cx.listener(|v, _: &DocumentProperties, window, cx| v.ask_properties(window, cx)))
.on_action(cx.listener(|v, _: &RecognizeText, window, cx| v.ask_ocr(window, cx)))
.on_action(cx.listener(|v, _: &AddBookmark, window, cx| v.ask_bookmark(true, window, cx)))
.on_action(cx.listener(|v, _: &RenameBookmark, window, cx| v.ask_bookmark(false, window, cx)))
.on_action(cx.listener(|v, _: &DeleteBookmark, _, cx| v.delete_bookmark(cx)))
.on_action(cx.listener(|v, _: &AddLink, _, cx| v.toggle_tool(Tool::Link, cx)))
.on_action(cx.listener(|v, _: &AttachFile, _, cx| v.attach_file(cx)))
.on_action(cx.listener(|v, _: &SaveAttachment, _, cx| v.save_attachment(cx)))
.on_action(cx.listener(|v, _: &RemoveAttachment, _, cx| v.remove_attachment(cx)))
.on_action(cx.listener(|v, _: &RedactText, _, cx| v.toggle_tool(Tool::RedactText, cx)))
.on_action(cx.listener(|v, _: &RedactArea, _, cx| v.toggle_tool(Tool::RedactArea, cx)))
.on_action(cx.listener(|v, _: &ClearRedactions, _, cx| v.clear_redactions(cx)))
.on_action(cx.listener(|v, _: &ApplyRedactions, _, cx| v.ask_redact(cx)))
.on_action(cx.listener(|v, _: &CleanDocument, _, cx| v.ask_clean(cx)))
.on_action(cx.listener(|v, _: &OwnerPassword, window, cx| v.ask_owner(window, cx)))
.on_action(cx.listener(|v, _: &ProtectDocument, window, cx| v.ask_protect(window, cx)))
.on_action(cx.listener(|v, _: &SignDocument, window, cx| v.ask_sign(window, cx)))
.on_action(cx.listener(|v, _: &AddVisibleSignature, window, cx| v.ask_visible(window, cx)))
.on_action(cx.listener(|v, _: &ShowSignatures, _, cx| v.show_signatures(true, cx)))
.on_action(cx.listener(|v, _: &CloseSignatures, _, cx| v.show_signatures(false, cx)))
.on_action(cx.listener(|v, _: &ShowRepairReport, _, cx| v.show_repair(true, cx)))
}
+232
View File
@@ -0,0 +1,232 @@
//! The edit panel: text, font, size, colour; objects (PDF-03, PDF-04).
use std::sync::Arc;
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
data_display::DescriptionList,
forms::{Choice, ColorPalette, Input, NumberInput, Select, TextInput},
theme::ControlSize,
};
use ely_office::Tabs as _;
use ely_pdf_engine::Writing;
use gpui::{
AppContext as _, Context, Entity, Hsla, IntoElement, ParentElement as _, Render, RenderImage, SharedString,
Styled as _, WeakEntity, Window, div, rgb,
};
use crate::content::Aim;
use crate::viewer::Viewer;
/// Built-in fonts by value and label key.
pub(crate) const FONTS: [(&str, &str); 8] = [
("original", "pdf.font.original"),
("helvetica", "pdf.font.helvetica"),
("times", "pdf.font.times"),
("courier", "pdf.font.courier"),
("cjk-sc", "pdf.font.sc"),
("cjk-tc", "pdf.font.tc"),
("cjk-jp", "pdf.font.jp"),
("cjk-kr", "pdf.font.kr"),
];
/// A change that alters layout, shown before it is made.
pub struct Preview {
pub tab: u64,
/// The view's version when asked; any edit since voids it.
pub version: u64,
pub page: usize,
pub writing: Writing,
pub image: Arc<RenderImage>,
}
pub struct EditPanel {
viewer: WeakEntity<Viewer>,
/// The tab, view version and aim the fields show.
shown: Option<(u64, u64, Aim)>,
/// Made on the first draw, which has a window.
pub(crate) field: Option<Entity<TextInput>>,
pub font: SharedString,
pub size: f32,
pub rgb: u32,
/// The text box width, in points.
pub width: f32,
/// Why the last write did not happen.
pub note: Option<SharedString>,
}
impl EditPanel {
pub fn new(viewer: WeakEntity<Viewer>) -> Self {
EditPanel { viewer, shown: None, field: None, font: "original".into(), size: 12.0, rgb: 0, width: 200.0, note: None }
}
/// Fills the fields for what the view aims at now.
fn follow(&mut self, aimed: (u64, u64, Aim), cx: &mut Context<Self>) {
if self.shown == Some(aimed) {
return;
}
let Some(viewer) = self.viewer.upgrade() else {
return;
};
let Some(view) = viewer.read(cx).active_view() else {
return;
};
let view = view.read(cx);
let (text, font, size, rgb, width) = match aimed.2 {
Aim::Text { page, index } => match view.layers.get(&page).and_then(|layer| layer.blocks.get(index)) {
Some(b) => (b.text.clone(), "original", b.size, b.rgb, b.rect[2] - b.rect[0]),
None => return,
},
Aim::New { page, at } => (String::new(), "helvetica", 12.0, 0, (view.sizes[page].0 - at.0 - 36.0).max(72.0)),
Aim::Object { .. } => (String::new(), "original", 12.0, 0, 200.0),
Aim::Note(_) => return,
};
(self.shown, self.font, self.size, self.rgb, self.width, self.note) = (Some(aimed), font.into(), size, rgb, width, None);
if let Some(field) = &self.field {
field.update(cx, |field, cx| field.set_text(&text, cx));
}
}
/// The text to write for `aim` on `view`.
pub fn writing(&self, view: &crate::view::PdfView, aim: Aim, cx: &gpui::App) -> Option<(usize, Writing)> {
let text = self.field.as_ref()?.read(cx).text().to_string();
let (page, block, expected, bx) = match aim {
Aim::Text { page, index } => {
let b = view.layers.get(&page)?.blocks.get(index)?;
(page, Some(index), b.text.clone(), [b.rect[0], b.rect[1], self.width])
}
Aim::New { page, at } => (page, None, String::new(), [at.0, at.1, self.width]),
Aim::Object { .. } | Aim::Note(_) => return None,
};
let writing = Writing { block, expected, text, font: self.font.to_string(), size: self.size, rgb: self.rgb, bx };
Some((page, writing))
}
}
/// A row's label, as the viewer says it.
fn label(cx: &gpui::App, key: &str) -> SharedString {
Viewer::text(cx, key, &[])
}
impl Render for EditPanel {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
if self.field.is_none() {
self.field = Some(cx.new(|cx| TextInput::new(window, cx).multi_line(3, 12)));
}
let Some(viewer) = self.viewer.upgrade() else {
return div();
};
let Some((tab, view)) = viewer.read(cx).active_tab().map(|tab| (tab.id, tab.view.clone())) else {
return div();
};
let Some(aim) = view.read(cx).aim else {
return div();
};
self.follow((tab, view.read(cx).version, aim), cx);
let this = cx.entity();
let buttons = div().flex().gap_2();
let body = match aim {
Aim::Object { .. } => {
let image = matches!(aim, Aim::Object { page, index } if view.read(cx).layers.get(&page).and_then(|l| l.drawn.get(index)).is_some_and(|d| !d.form));
let (swap, gone) = (viewer.downgrade(), viewer.downgrade());
buttons
.children(image.then(|| {
Button::new("pdf-object-replace", label(cx, "pdf.object.replace"))
.size(ControlSize::Sm)
.on_click(move |_, _, cx| {
swap.update(cx, |viewer, cx| viewer.replace_image(cx)).ok();
})
}))
.child(
Button::new("pdf-object-delete", label(cx, "pdf.object.delete"))
.size(ControlSize::Sm)
.variant(ButtonVariant::Danger)
.on_click(move |_, _, cx| {
gone.update(cx, |viewer, cx| viewer.delete_object(cx)).ok();
}),
)
.into_any_element()
}
_ => self.text_rows(&this, &viewer, aim, cx),
};
div().p_3().flex().flex_col().gap_3().child(body)
}
}
impl EditPanel {
fn text_rows(&self, this: &Entity<Self>, viewer: &Entity<Viewer>, aim: Aim, cx: &mut Context<Self>) -> gpui::AnyElement {
let set = |this: &Entity<Self>, change: fn(&mut EditPanel, f64)| {
let this = this.clone();
move |value: f64, _: &mut Window, cx: &mut gpui::App| this.update(cx, |panel, cx| {
change(panel, value);
cx.notify();
})
};
let fonts = FONTS
.iter()
.filter(|(value, _)| *value != "original" || matches!(aim, Aim::Text { .. }))
.map(|(value, key)| Choice::new(*value, label(cx, key)));
let picked = this.clone();
let font = Select::new("pdf-text-font", fonts.collect::<Vec<_>>())
.selected(self.font.clone())
.label(label(cx, "pdf.text.font"))
.on_change(move |value, _, cx| picked.update(cx, |panel, cx| {
(panel.font, panel.note) = (value.clone(), None);
cx.notify();
}));
let size = NumberInput::new("pdf-text-size", f64::from(self.size))
.range(1.0, 400.0)
.label(label(cx, "pdf.text.size"))
.on_commit(set(this, |panel, value| panel.size = value as f32));
let width = NumberInput::new("pdf-text-width", f64::from(self.width))
.range(10.0, 2000.0)
.suffix("pt")
.label(label(cx, "pdf.text.width"))
.on_commit(set(this, |panel, value| panel.width = value as f32));
let swatches = ely_office::panel::COLORS.map(|color| (format!("#{color:06X}"), Hsla::from(rgb(color))));
let painted = this.clone();
let mut color = ColorPalette::new("pdf-text-color", swatches.clone()).columns(5).on_change(move |color, _, cx| {
let at = swatches.iter().position(|(_, swatch)| *swatch == color).expect("the palette hands back its own colors");
painted.update(cx, |panel, cx| {
panel.rgb = ely_office::panel::COLORS[at];
cx.notify();
})
});
if ely_office::panel::COLORS.contains(&self.rgb) {
color = color.selected(rgb(self.rgb));
}
let rows = DescriptionList::new()
.item(label(cx, "pdf.text.font"), font)
.item(label(cx, "pdf.text.size"), size)
.item(label(cx, "pdf.text.color"), color)
.item(label(cx, "pdf.text.width"), width);
let (apply, erase) = (viewer.downgrade(), viewer.downgrade());
let mut buttons = div().flex().gap_2().child(
Button::new("pdf-text-apply", label(cx, "pdf.text.apply"))
.size(ControlSize::Sm)
.variant(ButtonVariant::Primary)
.on_click(move |_, _, cx| {
apply.update(cx, |viewer, cx| viewer.write_text(false, cx)).ok();
}),
);
if matches!(aim, Aim::Text { .. }) {
buttons = buttons.child(
Button::new("pdf-text-erase", label(cx, "pdf.text.erase"))
.size(ControlSize::Sm)
.variant(ButtonVariant::Danger)
.on_click(move |_, _, cx| {
erase.update(cx, |viewer, cx| viewer.write_text(true, cx)).ok();
}),
);
}
div()
.flex()
.flex_col()
.gap_3()
.children(self.field.as_ref().map(Input::new))
.child(rows)
.children(self.note.clone().map(|note| div().text_sm().child(note)))
.child(buttons)
.into_any_element()
}
}
+149
View File
@@ -0,0 +1,149 @@
//! Edits one at a time; the view rereads after (PDF-02).
use std::{collections::BTreeSet, io};
use ely_pdf_engine::{Bookmark, Doc, Worker};
use gpui::Context;
use crate::engine::MEASURE;
use crate::view::{PdfView, Reported, sizes};
/// One edit: it answers with the history mark after it.
pub type Op = Box<dyn FnOnce(&mut Worker, Doc) -> io::Result<String> + Send>;
/// A queued edit: name, pages picked after, state seen.
pub struct Queued {
what: &'static str,
op: Op,
pick: Option<BTreeSet<usize>>,
doc: Doc,
}
/// The document as an edit left it.
pub struct Fresh {
doc: Doc,
mark: String,
pages: usize,
first: Vec<[f32; 4]>,
marks: Vec<Bookmark>,
}
impl PdfView {
/// Queues `op`; `what` names it if it fails.
pub fn edit(
&mut self,
what: &'static str,
op: impl FnOnce(&mut Worker, Doc) -> io::Result<String> + Send + 'static,
cx: &mut Context<Self>,
) {
self.edit_picking(what, None, op, cx);
}
/// Queues `op`; once it lands, `pick` is picked.
pub fn edit_picking(
&mut self,
what: &'static str,
pick: Option<BTreeSet<usize>>,
op: impl FnOnce(&mut Worker, Doc) -> io::Result<String> + Send + 'static,
cx: &mut Context<Self>,
) {
log::info!("pdf: queues {what}");
// Aimed at what is shown now; later states refuse it.
self.queue.push_back(Queued { what, op: Box::new(op), pick, doc: self.doc });
self.next_edit(cx);
}
fn next_edit(&mut self, cx: &mut Context<Self>) {
if self.editing {
return;
}
let Some(Queued { what, op, pick, doc }) = self.queue.pop_front() else {
return;
};
self.editing = true;
self.off(
move |worker| {
let mark = op(worker, doc)?;
let doc = doc.at(&mark)?;
let pages = worker.pages(doc)?;
let first = worker.bounds(doc, 0, pages.min(MEASURE))?;
Ok(Fresh { doc, mark, pages, first, marks: worker.outline(doc)? })
},
move |view, fresh, cx| {
view.editing = false;
match fresh {
Ok(fresh) => {
view.refresh(fresh, cx);
if let Some(pick) = pick {
view.anchor = pick.first().copied();
view.picked = pick;
}
}
Err(error) => {
view.fail(what, &error, cx);
// Waiting saves and closes learn the queue moved.
cx.emit(Reported);
}
}
view.next_edit(cx);
cx.notify();
},
cx,
);
}
/// Everything read before the edit goes.
fn refresh(&mut self, fresh: Fresh, cx: &mut Context<Self>) {
self.version += 1;
self.edited += 1;
(self.doc, self.mark) = (fresh.doc, fresh.mark);
self.sizes = sizes(fresh.pages, &fresh.first);
self.marks = fresh.marks;
self.retire_tiles();
self.failed.clear();
self.text.clear();
self.asking_text.clear();
for (_, image) in self.thumbs.drain() {
self.retired.push(image);
}
self.thumbs_failed.clear();
// Indices shift with any edit; aims start over.
(self.layers, self.aim, self.drag) = (Default::default(), None, None);
(self.notes, self.sketch, self.marking, self.form_names) = (Default::default(), None, None, None);
self.links.clear();
(self.files, self.mark_pick) = (None, None);
self.sign_at = None;
// Page numbers may have moved under the marks.
self.redactions.clear();
(self.found, self.chosen, self.selection) = (Vec::new(), None, None);
// Page numbers changed; a range pick starts over.
self.picked.retain(|page| *page < fresh.pages);
self.anchor = None;
self.current = self.current.min(fresh.pages - 1);
self.measure(fresh.first.len(), cx);
cx.emit(Reported);
}
/// The file now holds history `mark`.
pub fn saved(&mut self, mark: &str, cx: &mut Context<Self>) {
self.saved = mark.to_string();
// The file changed; read it again.
self.signatures = None;
cx.emit(Reported);
}
/// Changed since the load or the last save.
pub fn modified(&self) -> bool {
self.mark != self.saved
}
/// Every edit asked for has landed.
pub fn settled(&self) -> bool {
self.queue.is_empty() && !self.editing
}
pub fn undo(&mut self, redo: bool, cx: &mut Context<Self>) {
let what = if redo { "redo" } else { "undo" };
self.edit(what, move |worker, doc| worker.undo(doc, redo), cx);
}
}
+191
View File
@@ -0,0 +1,191 @@
//! MuPDF as the shell's engine (PDF-01, PERF-01).
use std::{
convert::Infallible,
io,
path::Path,
sync::{Arc, Mutex},
};
use ely_office::{Engine, Host, Load};
use ely_pdf_engine::{Bookmark, Doc, Worker};
use gpui::{App, AppContext as _, Context, Entity, FocusHandle, Window};
use crate::view::{Failed, PdfView, Reported};
/// Pages measured with the load.
pub const MEASURE: usize = 100;
/// The worker, started on the first open.
#[derive(Default)]
pub struct Mupdf(Option<Worker>);
impl Mupdf {
/// A holder around a running worker, for tests.
#[cfg(test)]
pub fn with(worker: Worker) -> Self {
Mupdf(Some(worker))
}
#[cfg(test)]
pub fn started(&self) -> bool {
self.0.is_some()
}
/// The worker, started now if need be.
pub fn get(&mut self) -> io::Result<&mut Worker> {
if self.0.is_none() {
log::info!("pdf: starts the worker");
self.0 = Some(Worker::spawn()?);
}
Ok(self.0.as_mut().expect("just started"))
}
}
/// A load: the page count, first sizes, bookmarks, history.
pub struct Loaded {
pub doc: Doc,
pub pages: usize,
pub first: Vec<[f32; 4]>,
pub marks: Vec<Bookmark>,
pub mark: String,
/// `proto::may` bits.
pub allowed: u32,
/// Fields holding a signature.
pub signed: usize,
/// MuPDF's words when it had to repair the file.
pub repaired: Option<String>,
}
/// The MuPDF engine.
pub struct Pdf;
/// What a tab needs; locked or empty files fail.
fn read(worker: &mut Worker, doc: Doc, locked: bool, password: &str) -> io::Result<Loaded> {
if locked && !worker.unlock(doc, password)? {
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "a password is needed"));
}
let pages = worker.pages(doc)?;
if pages == 0 {
return Err(io::Error::new(io::ErrorKind::InvalidData, "no pages to show"));
}
let first = worker.bounds(doc, 0, pages.min(MEASURE))?;
let (marks, mark, allowed) = (worker.outline(doc)?, worker.history(doc)?, worker.permissions(doc)?);
let (signed, repaired) = (worker.signed(doc)?, worker.repair_report(doc)?);
Ok(Loaded { doc, pages, first, marks, mark, allowed, signed, repaired })
}
impl Engine for Pdf {
type Worker = Mupdf;
type Doc = Doc;
type View = PdfView;
type Loaded = Loaded;
type Import = Infallible;
fn load(worker: &mut Mupdf, from: Load<Infallible>) -> io::Result<Loaded> {
let (path, password) = match from {
Load::File(path, password) => (path, password),
Load::Import(_, never) => match *never {},
};
let worker = worker.get()?;
let opened = worker.open(path)?;
read(worker, opened.doc, opened.locked, password).inspect_err(|_| {
// A file not shown leaves nothing open.
if let Err(error) = worker.close(opened.doc) {
log::error!("pdf: closing a file not shown: {error}");
}
})
}
fn open(
worker: &Arc<Mutex<Mupdf>>,
loaded: Loaded,
_: &mut Window,
cx: &mut App,
) -> io::Result<(Doc, Entity<PdfView>, String)> {
let (doc, mark) = (loaded.doc, loaded.mark.clone());
let view = cx.new(|cx| PdfView::new(Arc::clone(worker), loaded, cx));
Ok((doc, view, mark))
}
fn risk(loaded: &Loaded) -> Option<ely_office::Risk> {
match (&loaded.repaired, loaded.signed) {
(Some(_), _) => Some(ely_office::Risk::Repaired),
(None, 0) => None,
(None, _) => Some(ely_office::Risk::Signed),
}
}
fn unsound(worker: &mut Mupdf, doc: &Doc) -> io::Result<Option<ely_office::Risk>> {
Ok(worker.get()?.check_pictures(*doc)?.then_some(ely_office::Risk::Repaired))
}
fn history(worker: &mut Mupdf, doc: &Doc) -> io::Result<String> {
worker.get()?.history(*doc)
}
fn write(worker: &mut Mupdf, doc: &Doc, path: &Path, format: &str) -> io::Result<()> {
match format {
"pdf" => worker.get()?.save(*doc, path),
other => Err(io::Error::new(io::ErrorKind::Unsupported, format!("writing .{other}"))),
}
}
/// MuPDF's save writes a snapshot; the document is untouched.
fn snapshot(worker: &mut Mupdf, doc: &Doc, path: &Path, format: &str) -> io::Result<()> {
Self::write(worker, doc, path, format)
}
fn close(worker: &mut Mupdf, doc: Doc) -> io::Result<()> {
worker.get()?.close(doc)
}
fn retire(view: &Entity<PdfView>, cx: &mut App) -> ely_gpui_component::primitives::Retired {
view.update(cx, |view, _| view.retire_all())
}
fn ended(worker: &mut Mupdf) -> Option<String> {
worker.0.as_mut()?.ended()
}
/// The next load starts a new worker.
fn restart(_: ely_platform::AppId, worker: &mut Mupdf) -> io::Result<()> {
worker.0 = None;
Ok(())
}
fn settled(view: &PdfView) -> bool {
view.settled()
}
fn edited(view: &PdfView) -> u64 {
view.edited
}
fn edit_pending(view: &PdfView) -> bool {
!view.settled()
}
fn focus(view: &PdfView) -> FocusHandle {
view.focus.clone()
}
fn saved(view: &Entity<PdfView>, mark: &str, cx: &mut App) {
view.update(cx, |view, cx| view.saved(mark, cx));
}
fn watch<H: Host<Engine = Self>>(
view: &Entity<PdfView>,
cx: &mut Context<H>,
reported: impl Fn(&mut H, &Entity<PdfView>, bool, &mut Context<H>) + 'static,
failed: impl Fn(&mut H, &str, &mut Context<H>) + 'static,
) {
cx.subscribe(view, move |host, _, error: &Failed, cx| failed(host, &error.0, cx))
.detach();
cx.subscribe(view, move |host, view, _: &Reported, cx| {
let modified = view.read(cx).modified();
reported(host, &view, modified, cx)
})
.detach();
}
}
+147
View File
@@ -0,0 +1,147 @@
//! Menus and keys of the viewer (PDF-01).
use ely_ui::commands;
use gpui::{
App, Context, Div, InteractiveElement as _, KeyBinding, Menu, MenuItem, Window, actions,
};
use crate::layout::{ACTUAL, Mode, Zoom};
use crate::view::PdfView;
use crate::viewer::Viewer;
actions!(
pdf,
[
Continuous,
SinglePage,
TwoPages,
ZoomIn,
ZoomOut,
FitWidth,
FitPage,
ActualSize,
RotateLeft,
RotateRight,
NextPage,
PreviousPage,
FirstPage,
LastPage,
CopyText,
Find
]
);
/// Zoom steps, in pixels a point.
const STEPS: [f32; 9] = [0.5, 0.67, 0.8, 1.0, ACTUAL, 1.6, 2.0, 3.0, 4.0];
pub fn bind(cx: &mut App) {
crate::organize::bind(cx);
crate::writes::bind(cx);
let at = Some(commands::CONTEXT);
cx.bind_keys([
KeyBinding::new("secondary-=", ZoomIn, at),
KeyBinding::new("secondary--", ZoomOut, at),
KeyBinding::new("secondary-0", ActualSize, at),
KeyBinding::new("secondary-]", RotateRight, at),
KeyBinding::new("secondary-[", RotateLeft, at),
KeyBinding::new("pagedown", NextPage, at),
KeyBinding::new("pageup", PreviousPage, at),
KeyBinding::new("home", FirstPage, at),
KeyBinding::new("end", LastPage, at),
KeyBinding::new("secondary-c", CopyText, at),
KeyBinding::new("secondary-f", Find, at),
]);
}
pub fn menus(cx: &App) -> Vec<Menu> {
let label = |key: &str| Viewer::text(cx, key, &[]);
vec![
Menu::new(label("menu.edit")).items(crate::organize::history_items(cx).into_iter().chain([
MenuItem::separator(),
MenuItem::action(label("pdf.copy"), CopyText),
MenuItem::action(label("pdf.find"), Find),
MenuItem::separator(),
]).chain(crate::writes::items(cx))),
Menu::new(label("menu.view")).items([
MenuItem::action(label("pdf.view.continuous"), Continuous),
MenuItem::action(label("pdf.view.single"), SinglePage),
MenuItem::action(label("pdf.view.two"), TwoPages),
MenuItem::separator(),
MenuItem::action(label("pdf.view.zoom_in"), ZoomIn),
MenuItem::action(label("pdf.view.zoom_out"), ZoomOut),
MenuItem::action(label("pdf.view.fit_width"), FitWidth),
MenuItem::action(label("pdf.view.fit_page"), FitPage),
MenuItem::action(label("pdf.view.actual"), ActualSize),
MenuItem::separator(),
MenuItem::action(label("pdf.view.rotate_left"), RotateLeft),
MenuItem::action(label("pdf.view.rotate_right"), RotateRight),
]),
crate::organize::menu(cx),
]
.into_iter()
.chain(crate::marking::menus(cx))
.chain([crate::document::menu(cx)])
.collect()
}
/// The next zoom step up or down from `now`.
pub fn stepped(now: f32, up: bool) -> f32 {
match up {
true => STEPS.into_iter().find(|step| *step > now + 0.001).unwrap_or(STEPS[STEPS.len() - 1]),
false => STEPS.into_iter().rev().find(|step| *step < now - 0.001).unwrap_or(STEPS[0]),
}
}
/// Wires every viewer action onto the root.
pub fn on(root: Div, cx: &mut Context<Viewer>) -> Div {
let change = |change: fn(&mut PdfView, f32)| {
move |viewer: &mut Viewer, cx: &mut Context<Viewer>| viewer.on_view(|view, cx| view.change(change, cx), cx)
};
let turn = |by: isize| move |viewer: &mut Viewer, cx: &mut Context<Viewer>| viewer.on_view(|view, cx| view.turn(by, cx), cx);
let (continuous, single, two) = (
change(|view, _| view.mode = Mode::Continuous),
change(|view, _| view.mode = Mode::Single),
change(|view, _| view.mode = Mode::Two),
);
let (width, page, actual) = (
change(|view, _| view.zoom = Zoom::Width),
change(|view, _| view.zoom = Zoom::Page),
change(|view, _| view.zoom = Zoom::Scale(ACTUAL)),
);
let (zoom_in, zoom_out) = (
change(|view, now| view.zoom = Zoom::Scale(stepped(now, true))),
change(|view, now| view.zoom = Zoom::Scale(stepped(now, false))),
);
let (right, left) = (
change(|view, _| view.rotate = (view.rotate + 90) % 360),
change(|view, _| view.rotate = (view.rotate + 270) % 360),
);
let (next, previous, first, last) = (turn(1), turn(-1), turn(isize::MIN / 2), turn(isize::MAX / 2));
root.on_action(cx.listener(move |v, _: &Continuous, _, cx| continuous(v, cx)))
.on_action(cx.listener(move |v, _: &SinglePage, _, cx| single(v, cx)))
.on_action(cx.listener(move |v, _: &TwoPages, _, cx| two(v, cx)))
.on_action(cx.listener(move |v, _: &FitWidth, _, cx| width(v, cx)))
.on_action(cx.listener(move |v, _: &FitPage, _, cx| page(v, cx)))
.on_action(cx.listener(move |v, _: &ActualSize, _, cx| actual(v, cx)))
.on_action(cx.listener(move |v, _: &ZoomIn, _, cx| zoom_in(v, cx)))
.on_action(cx.listener(move |v, _: &ZoomOut, _, cx| zoom_out(v, cx)))
.on_action(cx.listener(move |v, _: &RotateRight, _, cx| right(v, cx)))
.on_action(cx.listener(move |v, _: &RotateLeft, _, cx| left(v, cx)))
.on_action(cx.listener(move |v, _: &NextPage, _, cx| next(v, cx)))
.on_action(cx.listener(move |v, _: &PreviousPage, _, cx| previous(v, cx)))
.on_action(cx.listener(move |v, _: &FirstPage, _, cx| first(v, cx)))
.on_action(cx.listener(move |v, _: &LastPage, _, cx| last(v, cx)))
.on_action(cx.listener(|v, _: &CopyText, _, cx| v.on_view(|view, cx| view.copy(cx), cx)))
.on_action(cx.listener(|v: &mut Viewer, _: &Find, window: &mut Window, cx| v.open_find(window, cx)))
}
#[cfg(test)]
mod tests {
#[test]
fn zoom_steps_go_up_and_down_and_stop_at_the_ends() {
assert_eq!(super::stepped(1.0, true), super::ACTUAL);
assert_eq!(super::stepped(1.2, false), 1.0, "from between steps");
assert_eq!(super::stepped(4.0, true), 4.0);
assert_eq!(super::stepped(0.5, false), 0.5);
}
}
+139
View File
@@ -0,0 +1,139 @@
//! Where pages sit for a mode, zoom and turn (PDF-01).
/// How pages follow one another.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Mode {
Continuous,
Single,
Two,
}
/// How big pages are drawn.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Zoom {
Width,
Page,
/// Pixels a point; 96 / 72 is actual size.
Scale(f32),
}
/// Points to pixels at actual size.
pub const ACTUAL: f32 = 96.0 / 72.0;
/// Space around and between pages, in pixels.
pub const GAP: f32 = 16.0;
/// One page placed, in pixels from the top left.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Placed {
pub page: usize,
pub x: f32,
pub y: f32,
pub w: f32,
pub h: f32,
}
/// The pages placed, the whole size, the scale used.
#[derive(Clone, Debug, PartialEq)]
pub struct Layout {
pub placed: Vec<Placed>,
pub size: (f32, f32),
pub scale: f32,
}
/// A page's size in points once turned.
pub fn turned((w, h): (f32, f32), rotate: i32) -> (f32, f32) {
match rotate.rem_euclid(360) {
90 | 270 => (h, w),
_ => (w, h),
}
}
/// Lays `sizes` out in `view` pixels; `current` shows alone.
pub fn layout(sizes: &[(f32, f32)], rotate: i32, (mode, zoom): (Mode, Zoom), view: (f32, f32), current: usize) -> Layout {
let sizes: Vec<(f32, f32)> = sizes.iter().map(|size| turned(*size, rotate)).collect();
let shown: Vec<usize> = match mode {
Mode::Single => sizes.len().checked_sub(1).map(|last| current.min(last)).into_iter().collect(),
_ => (0..sizes.len()).collect(),
};
let across = if mode == Mode::Two { 2.0 } else { 1.0 };
// Rows of one or two pages, as wide as theirs.
let rows: Vec<Vec<usize>> = match mode {
Mode::Two => shown.chunks(2).map(<[usize]>::to_vec).collect(),
_ => shown.iter().map(|page| vec![*page]).collect(),
};
let widest = (rows.iter())
.map(|row| row.iter().map(|page| sizes[*page].0).sum::<f32>())
.fold(0.0, f32::max)
.max(1.0);
let room = (view.0 - GAP * (1.0 + across)).max(1.0);
let scale = match zoom {
Zoom::Scale(scale) => scale,
Zoom::Width => room / widest,
Zoom::Page => {
let tallest = shown.iter().map(|page| sizes[*page].1).fold(0.0, f32::max).max(1.0);
(room / widest).min((view.1 - 2.0 * GAP).max(1.0) / tallest)
}
};
let width = (widest * scale + GAP * (1.0 + across)).max(view.0);
let (mut placed, mut y) = (Vec::new(), GAP);
for row in rows {
let row_w = row.iter().map(|page| sizes[*page].0 * scale).sum::<f32>() + GAP * (row.len() as f32 - 1.0);
let row_h = row.iter().map(|page| sizes[*page].1 * scale).fold(0.0, f32::max);
let mut x = (width - row_w) / 2.0;
for page in row {
let (w, h) = (sizes[page].0 * scale, sizes[page].1 * scale);
placed.push(Placed { page, x, y: y + (row_h - h) / 2.0, w, h });
x += w + GAP;
}
y += row_h + GAP;
}
Layout { placed, size: (width, y.max(view.1)), scale }
}
/// Places on screen between `top` and `bottom`.
pub fn visible(layout: &Layout, top: f32, bottom: f32) -> impl Iterator<Item = &Placed> {
layout.placed.iter().filter(move |place| place.y < bottom && place.y + place.h > top)
}
#[cfg(test)]
mod tests {
use super::*;
const LETTER: (f32, f32) = (612.0, 792.0);
#[test]
fn continuous_fits_the_width_and_stacks_with_gaps() {
let out = layout(&[LETTER, LETTER], 0, (Mode::Continuous, Zoom::Width), (644.0, 500.0), 0);
assert_eq!(out.scale, 1.0, "612 points in 612 pixels of room");
assert_eq!(out.placed[0], Placed { page: 0, x: 16.0, y: 16.0, w: 612.0, h: 792.0 });
assert_eq!(out.placed[1].y, 16.0 + 792.0 + 16.0);
assert_eq!(out.size, (644.0, 16.0 + 2.0 * (792.0 + 16.0)));
}
#[test]
fn single_shows_one_page_and_two_pairs_them() {
let pages = [LETTER, LETTER, LETTER];
let one = layout(&pages, 0, (Mode::Single, Zoom::Scale(1.0)), (800.0, 600.0), 2);
assert_eq!(one.placed.iter().map(|p| p.page).collect::<Vec<_>>(), [2]);
let two = layout(&pages, 0, (Mode::Two, Zoom::Scale(1.0)), (800.0, 600.0), 0);
let rows: Vec<(usize, f32)> = two.placed.iter().map(|p| (p.page, p.y)).collect();
assert_eq!(rows, [(0, 16.0), (1, 16.0), (2, 824.0)]);
assert_eq!(two.placed[1].x - two.placed[0].x, 612.0 + 16.0, "side by side");
}
#[test]
fn a_quarter_turn_swaps_sides_and_page_zoom_fits_the_height() {
let out = layout(&[LETTER], 90, (Mode::Continuous, Zoom::Page), (1000.0, 644.0), 0);
assert_eq!((out.placed[0].w / out.scale, out.placed[0].h / out.scale), (792.0, 612.0));
assert!((out.placed[0].h - 612.0).abs() < 0.01, "the height fits");
let shown: Vec<usize> = visible(&out, 0.0, 10.0).map(|p| p.page).collect();
assert!(shown.is_empty(), "above the first page");
}
#[test]
fn no_pages_lay_out_as_nothing() {
for mode in [Mode::Continuous, Mode::Single, Mode::Two] {
assert!(layout(&[], 0, (mode, Zoom::Page), (800.0, 600.0), 0).placed.is_empty());
}
}
}
+63
View File
@@ -0,0 +1,63 @@
//! Links: followed, drawn, retargeted, deleted (PDF-07).
use ely_pdf_engine::{Link, Target};
use gpui::Context;
use crate::view::{Failed, PdfView};
/// Addresses that may leave the document.
pub fn outward(uri: &str) -> bool {
["https://", "http://", "mailto:"].iter().any(|scheme| uri.starts_with(scheme))
}
/// A target as typed: a page number or an address.
pub fn parse(typed: &str, pages: usize) -> Option<Target> {
let typed = typed.trim();
match typed.parse::<usize>() {
Ok(n) => (1..=pages).contains(&n).then(|| Target::Page(n - 1, 0.0)),
Err(_) => outward(typed).then(|| Target::Web(typed.to_string())),
}
}
/// A link's target as a person types it.
pub fn typed(link: &Link) -> String {
match link.page {
Some(page) => (page + 1).to_string(),
None => link.uri.clone(),
}
}
impl PdfView {
/// Goes where `link` points; only web and mail leave.
pub fn follow(&mut self, link: &Link, cx: &mut Context<Self>) {
match link.page {
Some(page) => {
log::info!("pdf: follows a link to page {}", page + 1);
self.show(page, link.y, cx)
}
None if outward(&link.uri) => {
log::info!("pdf: opens a link outside");
cx.open_url(&link.uri)
}
None => {
let text = crate::viewer::Viewer::text(cx, "pdf.link.refused", &[]);
cx.emit(Failed(text.to_string()))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_target_is_a_page_in_range_or_a_web_or_mail_address() {
assert_eq!(parse(" 3 ", 5), Some(Target::Page(2, 0.0)));
assert_eq!(parse("9", 5), None);
assert_eq!(parse("0", 5), None);
assert_eq!(parse("https://a.b", 5), Some(Target::Web("https://a.b".into())));
assert_eq!(parse("javascript:x", 5), None);
assert_eq!(parse("file:///etc", 5), None);
}
}
+39
View File
@@ -0,0 +1,39 @@
mod annotate;
mod ask;
mod attach;
mod content;
mod document;
mod editing;
mod edits;
mod engine;
mod keys;
mod layout;
mod links;
mod marking;
mod marks;
mod notelist;
mod notes;
mod ocr;
mod organize;
mod output;
mod pages;
mod props;
mod protect;
mod redact;
mod search;
mod select;
mod side;
mod sign;
mod view;
mod visible;
mod viewer;
mod writes;
use ely_platform::AppId;
fn main() {
ely_ui::run(AppId::Pdf, viewer::open);
}
#[cfg(test)]
mod tests;
+176
View File
@@ -0,0 +1,176 @@
//! Annotation and form menus, and the panel's changes (PDF-05, PDF-06).
use ely_gpui_component::primitives::Severity;
use ely_office::Tabs as _;
use ely_pdf_engine::{FieldKind, LinkChange, NewAnnot, NewField, Shape};
use gpui::{App, Context, Div, InteractiveElement as _, Menu, MenuItem, actions};
use crate::annotate::{Pick, around};
use crate::content::Tool;
use crate::viewer::Viewer;
actions!(
pdf,
[
Highlight,
Underline,
StrikeOut,
AddNote,
DrawRectangle,
DrawEllipse,
DrawLine,
DrawFreehand,
AddStamp,
AddTextField,
AddCheckbox,
AddRadioButton,
AddChoice
]
);
/// A stamp's size, in points.
const STAMP: (f32, f32) = (190.0, 50.0);
/// What the note panel asks for.
#[derive(Clone, Debug, PartialEq)]
pub enum Change {
Note(String),
Stamp(String),
Contents(String),
Color(u32),
Delete,
/// A field's name and options.
Design(String, Vec<String>),
Fill(String),
/// A link target as typed.
Target(String),
}
/// The Annotate and Form menus.
pub fn menus(cx: &App) -> [Menu; 2] {
let label = |key: &str| Viewer::text(cx, key, &[]);
[
Menu::new(label("menu.annotate")).items([
MenuItem::action(label("pdf.tool.highlight"), Highlight),
MenuItem::action(label("pdf.tool.underline"), Underline),
MenuItem::action(label("pdf.tool.strikeout"), StrikeOut),
MenuItem::separator(),
MenuItem::action(label("pdf.tool.note"), AddNote),
MenuItem::action(label("pdf.tool.stamp"), AddStamp),
MenuItem::separator(),
MenuItem::action(label("pdf.tool.square"), DrawRectangle),
MenuItem::action(label("pdf.tool.circle"), DrawEllipse),
MenuItem::action(label("pdf.tool.line"), DrawLine),
MenuItem::action(label("pdf.tool.ink"), DrawFreehand),
]),
Menu::new(label("menu.form")).items([
MenuItem::action(label("pdf.tool.field_text"), AddTextField),
MenuItem::action(label("pdf.tool.field_check"), AddCheckbox),
MenuItem::action(label("pdf.tool.field_radio"), AddRadioButton),
MenuItem::action(label("pdf.tool.field_choice"), AddChoice),
]),
]
}
impl Viewer {
/// Makes `change` if tab `tab` is still at `version`.
pub fn change_note(&mut self, (tab, version, pick): (u64, u64, Pick), change: Change, cx: &mut Context<Self>) {
let Some(view) = self.tab(tab).map(|tab| tab.view.clone()) else {
let text = Self::text(cx, "pdf.object.changed", &[]);
return self.toast(text, Severity::Warning, cx);
};
self.on_same(&view, version, cx, move |view, cx| {
let note = |page, shape, contents| NewAnnot { page, shape, rgb: None, contents, author: String::new() };
match (pick, change) {
(Pick::NewNote { page, at }, Change::Note(text)) => view.annotate("adding a note", vec![note(page, Shape::Note(at), text)], cx),
(Pick::NewStamp { page, at }, Change::Stamp(icon)) => {
let rect = around(at, STAMP, view.sizes[page]);
view.annotate("adding a stamp", vec![note(page, Shape::Stamp(rect, icon), String::new())], cx)
}
(Pick::Annot { page, id }, Change::Contents(text)) => {
view.edit("changing an annotation", move |w, doc| w.change_annot(doc, page, id, None, Some(&text)), cx)
}
(Pick::Annot { page, id }, Change::Color(rgb)) => {
view.edit("colouring an annotation", move |w, doc| w.change_annot(doc, page, id, Some(rgb), None), cx)
}
(Pick::Annot { page, id }, Change::Delete) => view.edit("deleting an annotation", move |w, doc| w.delete_annot(doc, page, id), cx),
(Pick::Field { page, id }, Change::Delete) => view.edit("deleting a field", move |w, doc| w.delete_field(doc, page, id), cx),
(Pick::Field { page, id }, Change::Fill(value)) => view.edit("filling a field", move |w, doc| w.fill(doc, page, id, &value), cx),
(Pick::Field { page, id }, Change::Design(name, options)) => {
let Some(field) = view.notes.get(&page).and_then(|m| m.fields.iter().find(|f| f.id == id)) else {
return log::error!("pdf: a picked field is not read");
};
let renamed = (field.leaf() != name).then_some(name);
let choice = matches!(field.kind, FieldKind::Choice | FieldKind::List);
let listed = (choice && field.options != options).then_some(options);
if renamed.is_none() && listed.is_none() {
return log::info!("pdf: the field is as it was");
}
view.edit(
"changing a field",
move |w, doc| w.change_field(doc, page, id, renamed.as_deref(), listed.as_deref()),
cx,
)
}
(Pick::NewField { page, rect, kind }, Change::Design(name, options)) => {
let field = NewField { kind, name, rect, options };
view.edit("adding a field", move |w, doc| w.add_field(doc, page, &field), cx)
}
(Pick::NewLink { page, rect }, Change::Target(typed)) => {
let Some(to) = crate::links::parse(&typed, view.sizes.len()) else {
return bad_target(cx);
};
view.edit("adding a link", move |w, doc| w.change_link(doc, page, &LinkChange::Add { rect, to }), cx)
}
(Pick::Link { page, index }, change) => {
let Some(expect) = view.links.get(&page).and_then(|links| links.get(index)).map(|link| link.uri.clone()) else {
return log::error!("pdf: a picked link is not read");
};
let change = match change {
Change::Delete => LinkChange::Delete { index, expect },
Change::Target(typed) => match crate::links::parse(&typed, view.sizes.len()) {
Some(to) => LinkChange::Edit { index, expect, to },
None => return bad_target(cx),
},
other => return log::error!("pdf: {other:?} does not fit a link"),
};
view.edit("changing a link", move |w, doc| w.change_link(doc, page, &change), cx)
}
(pick, change) => log::error!("pdf: {change:?} does not fit {pick:?}"),
}
});
}
}
/// Says a typed link target is neither page nor address.
fn bad_target(cx: &mut Context<crate::view::PdfView>) {
let text = Viewer::text(cx, "pdf.link.bad", &[]);
cx.emit(crate::view::Failed(text.to_string()));
}
/// Wires the annotation and form actions onto the root.
pub fn on(root: Div, cx: &mut Context<Viewer>) -> Div {
let tool = |tool: Tool| move |viewer: &mut Viewer, cx: &mut Context<Viewer>| viewer.toggle_tool(tool, cx);
let (highlight, underline, strike, note, stamp) =
(tool(Tool::Highlight), tool(Tool::Underline), tool(Tool::StrikeOut), tool(Tool::Note), tool(Tool::Stamp));
let (square, circle, line, ink) = (tool(Tool::Square), tool(Tool::Circle), tool(Tool::Line), tool(Tool::Ink));
let (text, check, radio, choice) = (
tool(Tool::Field(FieldKind::Text)),
tool(Tool::Field(FieldKind::Checkbox)),
tool(Tool::Field(FieldKind::Radio)),
tool(Tool::Field(FieldKind::Choice)),
);
root.on_action(cx.listener(move |v, _: &Highlight, _, cx| highlight(v, cx)))
.on_action(cx.listener(move |v, _: &Underline, _, cx| underline(v, cx)))
.on_action(cx.listener(move |v, _: &StrikeOut, _, cx| strike(v, cx)))
.on_action(cx.listener(move |v, _: &AddNote, _, cx| note(v, cx)))
.on_action(cx.listener(move |v, _: &AddStamp, _, cx| stamp(v, cx)))
.on_action(cx.listener(move |v, _: &DrawRectangle, _, cx| square(v, cx)))
.on_action(cx.listener(move |v, _: &DrawEllipse, _, cx| circle(v, cx)))
.on_action(cx.listener(move |v, _: &DrawLine, _, cx| line(v, cx)))
.on_action(cx.listener(move |v, _: &DrawFreehand, _, cx| ink(v, cx)))
.on_action(cx.listener(move |v, _: &AddTextField, _, cx| text(v, cx)))
.on_action(cx.listener(move |v, _: &AddCheckbox, _, cx| check(v, cx)))
.on_action(cx.listener(move |v, _: &AddRadioButton, _, cx| radio(v, cx)))
.on_action(cx.listener(move |v, _: &AddChoice, _, cx| choice(v, cx)))
}
+202
View File
@@ -0,0 +1,202 @@
//! Bookmarks: jump, add, rename, delete (PDF-01, PDF-07).
use ely_gpui_component::{
feedback::EmptyState,
forms::TextInput,
lists::Outline,
menus::{ContextMenu, Menu as Popup, MenuItem as PopupItem},
overlays::PromptDialog,
primitives::IconName,
};
use ely_office::{Closing as _, Tabs as _};
use ely_pdf_engine::{Bookmark, BookmarkChange};
use gpui::{Action, AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Styled as _, Window, div};
use crate::document::{DeleteBookmark, RenameBookmark};
use crate::view::PdfView;
use crate::viewer::Viewer;
/// One bookmark: title, level, target, and where it sits.
#[derive(Clone, Debug, PartialEq)]
pub struct Flat {
pub title: String,
pub level: usize,
pub to: Option<(usize, f32)>,
/// Child indices from the top.
pub path: Vec<usize>,
}
/// Bookmarks depth first.
pub fn flat(marks: &[Bookmark], above: &[usize], out: &mut Vec<Flat>) {
for (at, mark) in marks.iter().enumerate() {
let path: Vec<usize> = above.iter().copied().chain([at]).collect();
out.push(Flat { title: mark.title.clone(), level: path.len(), to: mark.page.map(|page| (page, mark.y)), path: path.clone() });
flat(&mark.down, &path, out);
}
}
/// A new bookmark's place: after the pick, else last.
pub fn place_for(marks: &[Bookmark], picked: Option<&Flat>) -> Vec<usize> {
match picked {
Some(flat) => {
let mut path = flat.path.clone();
*path.last_mut().expect("a path has a step") += 1;
path
}
None => vec![marks.len()],
}
}
/// A bookmark title being typed.
pub struct Naming {
pub tab: u64,
pub version: u64,
pub field: Entity<TextInput>,
/// None adds at `path`; Some renames the bookmark titled so.
pub expect: Option<String>,
pub path: Vec<usize>,
pub page: usize,
}
impl PdfView {
/// The picked bookmark, if it is still there.
pub fn picked_mark(&self) -> Option<Flat> {
let mut all = Vec::new();
flat(&self.marks, &[], &mut all);
self.mark_pick.and_then(|at| all.get(at).cloned())
}
}
/// The bookmarks of `view`; a press picks and jumps.
pub fn outline(view: &Entity<PdfView>, cx: &App) -> AnyElement {
let mut items = Vec::new();
flat(&view.read(cx).marks, &[], &mut items);
if items.is_empty() {
return EmptyState::new("pdf-marks-none", IconName::File, Viewer::text(cx, "pdf.marks.none", &[])).into_any_element();
}
let targets: Vec<Option<(usize, f32)>> = items.iter().map(|flat| flat.to).collect();
let picked = view.read(cx).mark_pick;
let view = view.clone();
let mut outline = (items.into_iter().enumerate())
.fold(Outline::new("pdf-marks"), |outline, (at, flat)| outline.item(at.to_string(), flat.title, flat.level))
.on_select(move |key, _, cx| {
let Ok(at) = key.parse::<usize>() else {
return;
};
view.update(cx, |view, cx| {
view.mark_pick = Some(at);
match targets.get(at).copied().flatten() {
Some((page, y)) => view.show(page, y, cx),
None => cx.notify(),
}
});
});
if let Some(at) = picked {
outline = outline.current(at.to_string());
}
let item = |key: &str, action: Box<dyn Action>| {
PopupItem::new(Viewer::text(cx, key, &[])).on_click(move |window, cx| window.dispatch_action(action.boxed_clone(), cx))
};
let popup = Popup::new().item(item("pdf.mark.rename", Box::new(RenameBookmark))).item(item("pdf.mark.delete", Box::new(DeleteBookmark)));
ContextMenu::new("pdf-marks-menu", popup).size_full().child(div().p_2().child(outline)).into_any_element()
}
impl Viewer {
/// Asks a title for a new or picked bookmark.
pub fn ask_bookmark(&mut self, add: bool, window: &mut Window, cx: &mut Context<Self>) {
let (Some(tab), Some(view)) = (self.core.active, self.active_view()) else {
return self.no_document(cx);
};
let read = view.read(cx);
let picked = read.picked_mark();
let (title, expect, path, page) = match (add, picked) {
(true, picked) => {
let n = (read.current + 1).to_string();
(Self::text(cx, "pdf.page.label", &[("n", &n)]).to_string(), None, place_for(&read.marks, picked.as_ref()), read.current)
}
(false, Some(flat)) => (flat.title.clone(), Some(flat.title), flat.path, read.current),
(false, None) => return,
};
let version = read.version;
let field = cx.new(|cx| {
let mut field = TextInput::new(window, cx);
field.set_text(title, cx);
field
});
self.naming = Some(Naming { tab, version, field, expect, path, page });
cx.notify();
}
pub(crate) fn name_bookmark(&mut self, title: String, cx: &mut Context<Self>) {
let Some(Naming { tab, version, expect, path, page, .. }) = self.naming.take() else {
return;
};
cx.notify();
let Some(view) = self.tab(tab).map(|tab| tab.view.clone()) else {
return;
};
let change = match expect {
None => BookmarkChange::Add { path, title, page, y: 0.0 },
Some(expect) => BookmarkChange::Edit { path, expect, title: Some(title), to: None },
};
self.on_same(&view, version, cx, |view, cx| {
view.edit("changing bookmarks", move |worker, doc| worker.change_bookmark(doc, &change), cx)
});
}
pub fn delete_bookmark(&mut self, cx: &mut Context<Self>) {
let Some(view) = self.active_view() else {
return self.no_document(cx);
};
let (picked, version) = (view.read(cx).picked_mark(), view.read(cx).version);
let Some(flat) = picked else {
return;
};
let change = BookmarkChange::Delete { path: flat.path, expect: flat.title };
self.on_same(&view, version, cx, |view, cx| {
view.mark_pick = None;
view.edit("deleting a bookmark", move |worker, doc| worker.change_bookmark(doc, &change), cx)
});
}
/// The bookmark title prompt, while open.
pub fn naming_element(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let naming = self.naming.as_ref()?;
let (closed, sent) = (cx.entity(), cx.entity());
let key = if naming.expect.is_some() { "pdf.mark.rename" } else { "pdf.mark.add" };
let prompt = PromptDialog::new("pdf-mark-name", Self::text(cx, key, &[]), &naming.field, move |_, cx| {
closed.update(cx, |viewer, cx| {
viewer.naming = None;
cx.notify();
})
})
.label(Self::text(cx, "pdf.mark.title", &[]))
.submit(Self::text(cx, "pdf.text.apply", &[]))
.check({
let empty = Self::text(cx, "pdf.mark.empty", &[]);
move |title| if title.trim().is_empty() { Err(empty.clone()) } else { Ok(()) }
})
.on_submit(move |title, _, cx| sent.update(cx, |viewer, cx| viewer.name_bookmark(title.trim().to_string(), cx)));
Some(prompt.into_any_element())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn mark(title: &str, page: Option<usize>, down: Vec<Bookmark>) -> Bookmark {
Bookmark { title: title.into(), page, y: 0.0, down }
}
#[test]
fn bookmarks_flatten_with_levels_and_paths() {
let marks = [mark("A", Some(0), vec![mark("A1", Some(1), vec![])]), mark("Web", None, vec![])];
let mut out = Vec::new();
flat(&marks, &[], &mut out);
let short: Vec<(&str, usize, Vec<usize>)> = out.iter().map(|f| (f.title.as_str(), f.level, f.path.clone())).collect();
assert_eq!(short, [("A", 1, vec![0]), ("A1", 2, vec![0, 0]), ("Web", 1, vec![1])]);
assert_eq!(place_for(&marks, Some(&out[1])), [0, 1], "after the picked one");
assert_eq!(place_for(&marks, None), [2], "else last");
}
}
+86
View File
@@ -0,0 +1,86 @@
//! The annotation list in the side panel (PDF-05).
use ely_gpui_component::{
feedback::EmptyState,
lists::{ListItem, SelectableList},
primitives::IconName,
};
use gpui::{
AnyElement, App, Entity, InteractiveElement as _, IntoElement, ParentElement as _, StatefulInteractiveElement as _,
Styled as _, div,
};
use crate::annotate::Pick;
use crate::content::Aim;
use crate::notes::kind_label;
use crate::view::PdfView;
use crate::viewer::Viewer;
fn key(page: usize, id: u64) -> String {
format!("{page}:{id}")
}
fn parse(key: &str) -> Option<(usize, u64)> {
let (page, id) = key.split_once(':')?;
Some((page.parse().ok()?, id.parse().ok()?))
}
/// Every annotation, page by page; unread pages are asked.
pub fn list(view: &Entity<PdfView>, cx: &mut App) -> AnyElement {
let count = view.read(cx).sizes.len();
view.update(cx, |view, cx| view.ask_notes(0..count, cx));
let read = view.read(cx);
let selected = match read.aim {
Some(Aim::Note(Pick::Annot { page, id })) => Some(key(page, id)),
_ => None,
};
let mut rows = Vec::new();
for (page, marks) in (0..count).filter_map(|page| Some((page, read.notes.get(&page)?))) {
for annot in &marks.annots {
let place = Viewer::text(cx, "pdf.page.label", &[("n", &(page + 1).to_string())]);
let mut item = ListItem::new(("pdf-note", annot.id as usize), kind_label(cx, &annot.kind)).detail(place);
if let Some(first) = annot.contents.lines().find(|line| !line.trim().is_empty()) {
item = item.description(first.to_string());
}
rows.push((key(page, annot.id), item));
}
}
let all_read = (0..count).all(|page| read.notes.contains_key(&page));
if rows.is_empty() && all_read {
return EmptyState::new("pdf-notes-none", IconName::File, Viewer::text(cx, "pdf.notes.none", &[])).into_any_element();
}
let total = rows.len();
let picked = view.clone();
let list = (rows.into_iter().enumerate())
.fold(SelectableList::new("pdf-notes"), |list, (at, (key, item))| list.row(key, item.place(at + 1, total)))
.selected(selected)
.label(Viewer::text(cx, "pdf.side.notes", &[]))
.on_change(move |keys, _, cx| {
let Some((page, id)) = keys.first().and_then(|key| parse(key)) else {
return;
};
picked.update(cx, |view, cx| view.pick_note(page, id, cx))
});
div().id("pdf-notes-scroll").size_full().overflow_y_scroll().child(list).into_any_element()
}
impl PdfView {
/// Picks annotation `id` and shows where it is.
pub fn pick_note(&mut self, page: usize, id: u64, cx: &mut gpui::Context<Self>) {
// Gone with an edit since the list was drawn.
let Some(top) = self.notes.get(&page).and_then(|m| m.annots.iter().find(|a| a.id == id)).map(|a| a.rect[1]) else {
return;
};
self.aim = Some(Aim::Note(Pick::Annot { page, id }));
self.show(page, top, cx);
}
}
#[cfg(test)]
mod tests {
#[test]
fn row_keys_name_page_and_object() {
assert_eq!(super::parse(&super::key(3, 41)), Some((3, 41)));
assert_eq!(super::parse("3"), None);
}
}
+320
View File
@@ -0,0 +1,320 @@
//! The note panel: annotations, notes, stamps, fields (PDF-05, PDF-06).
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
data_display::DescriptionList,
forms::{Choice, ColorPalette, Input, Select, TextInput},
theme::ControlSize,
};
use ely_office::Tabs as _;
use ely_pdf_engine::{Annot, Field, FieldKind};
use gpui::{
AnyElement, App, AppContext as _, Context, Entity, Hsla, IntoElement, ParentElement as _, Render, SharedString,
Styled as _, WeakEntity, Window, div, rgb,
};
use crate::annotate::Pick;
use crate::content::{Aim, Tool};
use crate::marking::Change;
use crate::viewer::Viewer;
/// The standard stamps, by PDF name.
pub const STAMPS: [&str; 14] = [
"Approved",
"AsIs",
"Confidential",
"Departmental",
"Draft",
"Experimental",
"Expired",
"Final",
"ForComment",
"ForPublicRelease",
"NotApproved",
"NotForPublicRelease",
"Sold",
"TopSecret",
];
/// Annotation kinds with a name of their own.
const KINDS: [&str; 15] = [
"Highlight", "Underline", "StrikeOut", "Squiggly", "Text", "FreeText", "Square", "Circle", "Line", "Polygon",
"PolyLine", "Ink", "Stamp", "Caret", "FileAttachment",
];
/// An annotation kind's name; unknown kinds as written.
pub fn kind_label(cx: &App, kind: &str) -> SharedString {
match KINDS.contains(&kind) {
true => Viewer::text(cx, &format!("pdf.annot.{kind}"), &[]),
false => SharedString::from(kind.to_string()),
}
}
fn label(cx: &App, key: &str) -> SharedString {
Viewer::text(cx, key, &[])
}
pub struct NotePanel {
viewer: WeakEntity<Viewer>,
/// The tab, view version and pick the fields show.
pub shown: Option<(u64, u64, Pick)>,
/// Contents, value or name; made when first drawn.
pub(crate) text: Option<Entity<TextInput>>,
/// A field's name, and a choice's options, one a line.
pub(crate) name: Option<Entity<TextInput>>,
pub(crate) options: Option<Entity<TextInput>>,
pub stamp: SharedString,
}
impl NotePanel {
pub fn new(viewer: WeakEntity<Viewer>) -> Self {
NotePanel { viewer, shown: None, text: None, name: None, options: None, stamp: STAMPS[0].into() }
}
/// What the view's notes say of `pick`.
fn found(view: &crate::view::PdfView, pick: Pick) -> (Option<&Annot>, Option<&Field>) {
match pick {
Pick::Annot { page, id } => (view.notes.get(&page).and_then(|m| m.annots.iter().find(|a| a.id == id)), None),
Pick::Field { page, id } => (None, view.notes.get(&page).and_then(|m| m.fields.iter().find(|f| f.id == id))),
_ => (None, None),
}
}
/// Fills the inputs for what the view picks now.
fn follow(&mut self, aimed: (u64, u64, Pick), view: &Entity<crate::view::PdfView>, cx: &mut Context<Self>) {
if self.shown == Some(aimed) {
return;
}
let view = view.read(cx);
let (annot, field) = Self::found(view, aimed.2);
let designing = matches!(view.tool, Tool::Field(_));
let (text, name, options) = match aimed.2 {
Pick::Annot { .. } => (annot.map_or_else(String::new, |a| a.contents.clone()), String::new(), String::new()),
Pick::Field { .. } => match field {
Some(f) if designing => (String::new(), f.leaf().to_string(), f.options.join("\n")),
Some(f) => (f.value.clone(), String::new(), String::new()),
None => (String::new(), String::new(), String::new()),
},
Pick::NewField { page, kind, .. } => (String::new(), suggested(view, page, kind), String::new()),
Pick::Link { page, index } => {
let link = view.links.get(&page).and_then(|links| links.get(index));
(String::new(), link.map_or_else(String::new, crate::links::typed), String::new())
}
Pick::NewNote { .. } | Pick::NewStamp { .. } | Pick::NewLink { .. } => (String::new(), String::new(), String::new()),
};
// Inputs are named for assistive tech.
let text_name = match (aimed.2, field) {
(Pick::Field { .. }, Some(f)) => SharedString::from(f.name.clone()),
_ => label(cx, "pdf.note.contents"),
};
(self.shown, self.stamp) = (Some(aimed), STAMPS[0].into());
let name_name = match aimed.2 {
Pick::Link { .. } | Pick::NewLink { .. } => label(cx, "pdf.link.target"),
_ => label(cx, "pdf.field.name"),
};
let named = [text_name, name_name, label(cx, "pdf.field.options")];
let inputs = [(&self.text, text), (&self.name, name), (&self.options, options)];
for ((input, value), named) in inputs.into_iter().zip(named) {
if let Some(input) = input {
input.update(cx, |input, cx| {
input.set_text(value, cx);
input.set_label(named, cx);
});
}
}
}
/// Asks the viewer to make `change` on what is shown.
fn send(this: &Entity<Self>, change: Change, cx: &mut App) {
let (Some(shown), Some(viewer)) = (this.read(cx).shown, this.read(cx).viewer.upgrade()) else {
return;
};
viewer.update(cx, |viewer, cx| viewer.change_note(shown, change, cx));
}
fn typed(input: &Option<Entity<TextInput>>, cx: &App) -> String {
input.as_ref().map_or_else(String::new, |input| input.read(cx).text().to_string())
}
fn button(id: &'static str, text: SharedString, this: &Entity<Self>, make: fn(&NotePanel, &App) -> Change) -> Button {
let this = this.clone();
Button::new(id, text).size(ControlSize::Sm).on_click(move |_, _, cx| {
let change = make(this.read(cx), cx);
Self::send(&this, change, cx);
})
}
}
/// A free name for a new field.
fn suggested(view: &crate::view::PdfView, page: usize, kind: FieldKind) -> String {
let on_page = view.notes.get(&page).map_or(&[][..], |m| m.fields.as_slice());
// A new radio joins the page's last top group.
if kind == FieldKind::Radio
&& let Some(last) = on_page.iter().rev().find(|f| f.kind == FieldKind::Radio && !f.name.contains('.'))
{
return last.name.clone();
}
let stem = match kind {
FieldKind::Checkbox => "Check",
FieldKind::Radio => "Group",
FieldKind::Choice | FieldKind::List => "Choice",
_ => "Text",
};
let taken = view.form_names.as_deref().expect("names are read first");
(1..).map(|n| format!("{stem}{n}")).find(|name| !taken.contains(name)).expect("a free name")
}
impl Render for NotePanel {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
if self.text.is_none() {
self.text = Some(cx.new(|cx| TextInput::new(window, cx).multi_line(3, 12)));
self.name = Some(cx.new(|cx| TextInput::new(window, cx)));
self.options = Some(cx.new(|cx| TextInput::new(window, cx).multi_line(3, 8)));
}
let Some(viewer) = self.viewer.upgrade() else {
return div();
};
let Some((tab, view)) = viewer.read(cx).active_tab().map(|tab| (tab.id, tab.view.clone())) else {
return div();
};
let Some(Aim::Note(pick)) = view.read(cx).aim else {
return div();
};
// A new field's name is free in the whole form.
if matches!(pick, Pick::NewField { .. }) && view.read(cx).form_names.is_none() {
view.update(cx, |view, cx| view.ask_names(cx));
return div();
}
self.follow((tab, view.read(cx).version, pick), &view, cx);
let this = cx.entity();
let designing = matches!(view.read(cx).tool, Tool::Field(_));
let (annot, field) = Self::found(view.read(cx), pick);
let (annot, field) = (annot.cloned(), field.cloned());
let body = match (pick, annot, field) {
(Pick::Annot { .. }, Some(annot), _) => self.annot_rows(&this, &annot, cx),
(Pick::Field { .. }, _, Some(field)) if designing => self.design_rows(&this, field.kind, cx),
(Pick::Field { .. }, _, Some(field)) => self.fill_rows(&this, &field, cx),
(Pick::NewField { kind, .. }, _, _) => self.design_rows(&this, kind, cx),
(Pick::Link { .. } | Pick::NewLink { .. }, _, _) => self.link_rows(&this, matches!(pick, Pick::NewLink { .. }), cx),
(Pick::NewNote { .. }, _, _) => div()
.flex()
.flex_col()
.gap_3()
.children(self.text.as_ref().map(Input::new))
.child(div().flex().child(Self::button("pdf-note-add", label(cx, "pdf.note.add"), &this, |p, cx| Change::Note(Self::typed(&p.text, cx))).variant(ButtonVariant::Primary)))
.into_any_element(),
(Pick::NewStamp { .. }, _, _) => {
let picked = this.clone();
let stamps = STAMPS.map(|name| Choice::new(name, label(cx, &format!("pdf.stamp.{name}"))));
div()
.flex()
.flex_col()
.gap_3()
.child(
Select::new("pdf-stamp", stamps.to_vec())
.selected(self.stamp.clone())
.label(label(cx, "pdf.tool.stamp"))
.on_change(move |value, _, cx| picked.update(cx, |panel, cx| {
panel.stamp = value.clone();
cx.notify();
})),
)
.child(div().flex().child(Self::button("pdf-stamp-add", label(cx, "pdf.note.add"), &this, |p, _| Change::Stamp(p.stamp.to_string())).variant(ButtonVariant::Primary)))
.into_any_element()
}
_ => div().into_any_element(),
};
div().p_3().flex().flex_col().gap_3().child(body)
}
}
impl NotePanel {
fn annot_rows(&self, this: &Entity<Self>, annot: &Annot, cx: &mut Context<Self>) -> AnyElement {
let swatches = ely_office::panel::COLORS.map(|color| (format!("#{color:06X}"), Hsla::from(rgb(color))));
let painted = this.clone();
let mut color = ColorPalette::new("pdf-note-color", swatches.clone()).columns(5).on_change(move |color, _, cx| {
let at = swatches.iter().position(|(_, swatch)| *swatch == color).expect("the palette hands back its own colors");
Self::send(&painted, Change::Color(ely_office::panel::COLORS[at]), cx);
});
if let Some(now) = annot.rgb.filter(|rgb| ely_office::panel::COLORS.contains(rgb)) {
color = color.selected(rgb(now));
}
let mut rows = DescriptionList::new().item(label(cx, "pdf.note.kind"), kind_label(cx, &annot.kind));
if !annot.author.is_empty() {
rows = rows.item(label(cx, "pdf.note.author"), annot.author.clone());
}
div()
.flex()
.flex_col()
.gap_3()
.child(rows.item(label(cx, "pdf.text.color"), color))
.children(self.text.as_ref().map(Input::new))
.child(self.actions(this, ("pdf-note-apply", "pdf-note-delete"), |p, cx| Change::Contents(Self::typed(&p.text, cx)), cx))
.into_any_element()
}
/// Name and options of a field, new or picked.
fn design_rows(&self, this: &Entity<Self>, kind: FieldKind, cx: &mut Context<Self>) -> AnyElement {
let choice = matches!(kind, FieldKind::Choice | FieldKind::List);
let make = |p: &NotePanel, cx: &App| {
let options: Vec<String> = Self::typed(&p.options, cx).lines().map(str::trim).filter(|o| !o.is_empty()).map(String::from).collect();
Change::Design(Self::typed(&p.name, cx).trim().to_string(), options)
};
let new = matches!(self.shown, Some((_, _, Pick::NewField { .. })));
let buttons = match new {
true => div().flex().child(Self::button("pdf-field-add", label(cx, "pdf.note.add"), this, make).variant(ButtonVariant::Primary)),
false => self.actions(this, ("pdf-field-apply", "pdf-field-delete"), make, cx),
};
let mut rows = DescriptionList::new();
if let Some(name) = &self.name {
rows = rows.item(label(cx, "pdf.field.name"), div().w_full().child(Input::new(name)));
}
if let Some(options) = self.options.as_ref().filter(|_| choice) {
rows = rows.item(label(cx, "pdf.field.options"), div().w_full().child(Input::new(options)));
}
div().flex().flex_col().gap_3().child(rows).child(buttons).into_any_element()
}
/// A field's value: typed, or picked from its options.
fn fill_rows(&self, this: &Entity<Self>, field: &Field, cx: &mut Context<Self>) -> AnyElement {
if field.kind == FieldKind::Text {
let apply = Self::button("pdf-fill-apply", label(cx, "pdf.text.apply"), this, |p, cx| Change::Fill(Self::typed(&p.text, cx)));
return div()
.flex()
.flex_col()
.gap_3()
.child(div().text_sm().child(field.name.clone()))
.children(self.text.as_ref().map(Input::new))
.child(div().flex().child(apply.variant(ButtonVariant::Primary)))
.into_any_element();
}
let choices: Vec<Choice> = (field.options.iter().zip(&field.labels)).map(|(value, shown)| Choice::new(value.clone(), shown.clone())).collect();
let picked = this.clone();
Select::new("pdf-fill-choice", choices)
.selected(SharedString::from(field.value.clone()))
.label(SharedString::from(field.name.clone()))
.on_change(move |value, _, cx| Self::send(&picked, Change::Fill(value.to_string()), cx))
.into_any_element()
}
/// A link's target: a page number or an address.
fn link_rows(&self, this: &Entity<Self>, new: bool, cx: &mut Context<Self>) -> AnyElement {
let make = |p: &NotePanel, cx: &App| Change::Target(Self::typed(&p.name, cx));
let buttons = match new {
true => div().flex().child(Self::button("pdf-link-add", label(cx, "pdf.note.add"), this, make).variant(ButtonVariant::Primary)),
false => self.actions(this, ("pdf-link-apply", "pdf-link-delete"), make, cx),
};
let mut rows = DescriptionList::new();
if let Some(name) = &self.name {
rows = rows.item(label(cx, "pdf.link.target"), div().w_full().child(Input::new(name)));
}
div().flex().flex_col().gap_3().child(rows).child(buttons).into_any_element()
}
/// Apply and Delete for what is picked.
fn actions(&self, this: &Entity<Self>, (apply, delete): (&'static str, &'static str), make: fn(&NotePanel, &App) -> Change, cx: &App) -> gpui::Div {
let apply = Self::button(apply, label(cx, "pdf.text.apply"), this, make);
let delete = Self::button(delete, label(cx, "pdf.object.delete"), this, |_, _| Change::Delete);
div().flex().gap_2().child(apply.variant(ButtonVariant::Primary)).child(delete.variant(ButtonVariant::Danger))
}
}
+286
View File
@@ -0,0 +1,286 @@
//! OCR: asked, then read page by page (PDF-08).
use std::path::PathBuf;
use ely_gpui_component::{
buttons::{Button, ButtonVariant, SegmentedControl},
data_display::DescriptionList,
forms::{CheckboxGroup, Choice, NumberInput, RadioGroup},
motion::ProgressBar,
overlays::Dialog as Card,
primitives::Severity,
};
use ely_office::{Closing as _, Tabs as _};
use ely_pdf_engine::OcrLine;
use gpui::{AnyElement, App, Context, IntoElement, ParentElement as _, SharedString, Window, div};
use crate::viewer::Viewer;
/// Pixels an inch Tesseract reads at.
const DPI: f32 = 300.0;
/// The languages, by Tesseract name and label key.
const LANGS: [(&str, &str); 3] = [("eng", "pdf.ocr.eng"), ("chi_sim", "pdf.ocr.sim"), ("chi_tra", "pdf.ocr.tra")];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Scope {
All,
Current,
Range,
}
/// What was asked, and the reading under way.
pub struct Ocr {
pub tab: u64,
pub scope: Scope,
/// Pages from one.
pub from: usize,
pub to: usize,
pub langs: Vec<SharedString>,
/// Read pages with text again, dropping Ely's layer.
pub replace: bool,
pub run: Option<Run>,
}
pub struct Run {
/// Counts runs; a page back from another is dropped.
id: u64,
version: u64,
pages: Vec<usize>,
at: usize,
read: Vec<(usize, Vec<OcrLine>)>,
skipped: usize,
data: PathBuf,
}
/// The pages `scope` names, from zero.
pub fn pages(scope: Scope, current: usize, (from, to): (usize, usize), count: usize) -> Vec<usize> {
match scope {
Scope::All => (0..count).collect(),
Scope::Current => vec![current],
Scope::Range => (from.max(1) - 1..to.min(count)).collect(),
}
}
impl Viewer {
pub fn ask_ocr(&mut self, _: &mut Window, cx: &mut Context<Self>) {
let (Some(tab), Some(view)) = (self.core.active, self.active_view()) else {
return self.no_document(cx);
};
let count = view.read(cx).sizes.len();
let langs = vec!["eng".into(), "chi_sim".into()];
self.ocr = Some(Ocr { tab, scope: Scope::All, from: 1, to: count, langs, replace: false, run: None });
cx.notify();
}
pub(crate) fn start_ocr(&mut self, cx: &mut Context<Self>) {
let Some(ocr) = self.ocr.as_mut() else {
return;
};
let Some(view) = self.core.tabs.iter().find(|tab| tab.id == ocr.tab).map(|tab| tab.view.clone()) else {
return;
};
let data = match ely_pdf_engine::tessdata() {
Ok(data) => data,
Err(error) => return self.failed(&error, cx),
};
let read = view.read(cx);
let pages = pages(ocr.scope, read.current, (ocr.from, ocr.to), read.sizes.len());
if pages.is_empty() || ocr.langs.is_empty() {
return;
}
log::info!("pdf: OCR of {} pages in {} from {}", pages.len(), ocr.langs.join("+"), data.display());
self.ocr_runs += 1;
ocr.run = Some(Run { id: self.ocr_runs, version: read.version, pages, at: 0, read: Vec::new(), skipped: 0, data });
self.ocr_step(cx);
}
/// Reads the next page, or lays what was read.
fn ocr_step(&mut self, cx: &mut Context<Self>) {
let Some(Ocr { tab, langs, replace, run: Some(run), .. }) = self.ocr.as_ref() else {
return;
};
let Some(view) = self.tab(*tab).map(|tab| tab.view.clone()) else {
self.ocr = None;
return cx.notify();
};
if view.read(cx).version != run.version {
self.ocr = None;
let text = Self::text(cx, "pdf.object.changed", &[]);
return self.toast(text, Severity::Warning, cx);
}
let Some(page) = run.pages.get(run.at).copied() else {
return self.lay_text(cx);
};
let (langs, replace, data, tab, id) = (langs.join("+"), *replace, run.data.clone(), *tab, run.id);
let viewer = cx.weak_entity();
view.update(cx, |view, cx| {
let doc = view.doc;
view.off(
move |worker| match !replace && !worker.text(doc, page)?.is_empty() {
true => Ok(None),
false => worker.ocr_page(doc, page, &langs, &data, DPI).map(Some),
},
move |_, read, cx| {
cx.defer(move |cx| {
if let Some(viewer) = viewer.upgrade() {
viewer.update(cx, |viewer, cx| viewer.ocr_read((tab, id, page), read, cx));
}
})
},
cx,
);
});
cx.notify();
}
/// One page back; a cancelled or other run drops it.
pub(crate) fn ocr_read(&mut self, (tab, id, page): (u64, u64, usize), read: std::io::Result<Option<Vec<OcrLine>>>, cx: &mut Context<Self>) {
let Some(run) = self.ocr.as_mut().filter(|ocr| ocr.tab == tab).and_then(|ocr| ocr.run.as_mut()) else {
return log::info!("pdf: an OCR page came back after its run");
};
if run.id != id || run.pages.get(run.at) != Some(&page) {
return log::info!("pdf: an OCR page came back to another run");
}
match read {
Ok(Some(lines)) => run.read.push((page, lines)),
Ok(None) => run.skipped += 1,
Err(error) => {
self.ocr = None;
return self.failed(&error, cx);
}
}
run.at += 1;
self.ocr_step(cx);
}
/// Lays every page read in hidden text, one step.
fn lay_text(&mut self, cx: &mut Context<Self>) {
let Some(Ocr { tab, replace, run: Some(run), .. }) = self.ocr.take() else {
return;
};
cx.notify();
let pages: Vec<(usize, Vec<OcrLine>)> = run.read.into_iter().filter(|(_, lines)| replace || !lines.is_empty()).collect();
let (read, skipped) = (pages.len().to_string(), run.skipped.to_string());
let summary = Self::text(cx, "pdf.ocr.done", &[("read", &read), ("skipped", &skipped)]);
let Some(view) = self.tab(tab).map(|tab| tab.view.clone()).filter(|_| !pages.is_empty()) else {
return self.toast(summary, Severity::Info, cx);
};
self.on_same(&view, run.version, cx, |view, cx| {
view.edit("adding recognised text", move |worker, doc| worker.text_layer(doc, &pages, replace), cx)
});
self.toast(summary, Severity::Success, cx);
}
/// The OCR dialog: the ask, then the progress.
pub fn ocr_element(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let ocr = self.ocr.as_ref()?;
let this = cx.entity();
let label = |key: &str| Self::text(cx, key, &[]);
let closed = this.clone();
let dismiss = move |_: &mut Window, cx: &mut App| {
closed.update(cx, |viewer, cx| {
viewer.ocr = None;
cx.notify();
})
};
let card = Card::new("pdf-ocr", label("pdf.ocr.title"), dismiss);
if let Some(run) = &ocr.run {
let (n, count) = ((run.at + 1).min(run.pages.len()).to_string(), run.pages.len().to_string());
let progress = ProgressBar::new("pdf-ocr-progress", run.at as f32 / run.pages.len() as f32);
let card = card.detail(Self::text(cx, "pdf.ocr.reading", &[("n", &n), ("count", &count)])).child(progress);
return Some(card.action(ely_office::cancel(cx)).into_any_element());
}
let go = label("pdf.ocr.go");
let ready = !ocr.langs.is_empty();
Some(
card.child(self.ocr_rows(ocr, &this, cx))
.action(ely_office::cancel(cx))
.action(move |_| {
Button::new("pdf-ocr-go", go)
.variant(ButtonVariant::Primary)
.disabled(!ready)
.on_click(move |_, _, cx| this.update(cx, |viewer, cx| viewer.start_ocr(cx)))
})
.into_any_element(),
)
}
fn ocr_rows(&self, ocr: &Ocr, this: &gpui::Entity<Self>, cx: &mut Context<Self>) -> AnyElement {
let label = |key: &str| Self::text(cx, key, &[]);
let change = |this: &gpui::Entity<Self>, change: fn(&mut Ocr, &str)| {
let this = this.clone();
move |value: &SharedString, _: &mut Window, cx: &mut App| {
this.update(cx, |viewer, cx| {
if let Some(ocr) = viewer.ocr.as_mut() {
change(ocr, value);
}
cx.notify();
})
}
};
let scope = match ocr.scope {
Scope::All => "all",
Scope::Current => "current",
Scope::Range => "range",
};
let scopes = SegmentedControl::new("pdf-ocr-scope", scope)
.segment("all", label("pdf.ocr.all"), None)
.segment("current", label("pdf.ocr.current"), None)
.segment("range", label("pdf.ocr.range"), None)
.on_change(change(this, |ocr, value| {
ocr.scope = match value {
"current" => Scope::Current,
"range" => Scope::Range,
_ => Scope::All,
}
}));
let langs = CheckboxGroup::new("pdf-ocr-langs", LANGS.map(|(value, key)| Choice::new(value, label(key))))
.selected(ocr.langs.clone())
.on_change({
let this = this.clone();
move |values: &[SharedString], _: &mut Window, cx: &mut App| {
this.update(cx, |viewer, cx| {
if let Some(ocr) = viewer.ocr.as_mut() {
ocr.langs = values.to_vec();
}
cx.notify();
})
}
});
let policy = RadioGroup::new("pdf-ocr-policy", [Choice::new("skip", label("pdf.ocr.skip")), Choice::new("replace", label("pdf.ocr.replace"))])
.selected(if ocr.replace { "replace" } else { "skip" })
.on_change(change(this, |ocr, value| ocr.replace = value == "replace"));
let mut rows = DescriptionList::new().item(label("pdf.ocr.pages"), scopes);
if ocr.scope == Scope::Range {
let bound = |id: &'static str, value: usize, set: fn(&mut Ocr, usize)| {
let this = this.clone();
NumberInput::new(id, value as f64).range(1.0, 100_000.0).on_change(move |value, _, cx| {
this.update(cx, |viewer, cx| {
if let Some(ocr) = viewer.ocr.as_mut() {
set(ocr, value.round() as usize);
}
cx.notify();
})
})
};
let from = bound("pdf-ocr-from", ocr.from, |ocr, value| ocr.from = value).label(label("pdf.ocr.from"));
let to = bound("pdf-ocr-to", ocr.to, |ocr, value| ocr.to = value).label(label("pdf.ocr.to"));
rows = rows.item(label("pdf.ocr.from"), from).item(label("pdf.ocr.to"), to);
}
let rows = rows.item(label("pdf.ocr.langs"), langs).item(label("pdf.ocr.policy"), policy);
div().child(rows).into_any_element()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_scope_names_its_pages() {
assert_eq!(pages(Scope::All, 1, (1, 1), 3), [0, 1, 2]);
assert_eq!(pages(Scope::Current, 1, (1, 1), 3), [1]);
assert_eq!(pages(Scope::Range, 0, (2, 9), 4), [1, 2, 3], "the end stops at the last page");
assert!(pages(Scope::Range, 0, (3, 2), 4).is_empty());
}
}
+177
View File
@@ -0,0 +1,177 @@
//! Page organizing: insert, delete, move, turn, undo (PDF-02).
use std::path::PathBuf;
use ely_gpui_component::primitives::Severity;
use ely_office::{Closing as _, Tabs as _};
use gpui::{App, Context, Div, InteractiveElement as _, KeyBinding, Menu, MenuItem, actions};
use crate::view::PdfView;
use crate::viewer::Viewer;
actions!(
pdf,
[
Undo,
Redo,
InsertBlankPage,
InsertPagesFromFile,
DeletePages,
RotatePagesLeft,
RotatePagesRight,
MovePagesUp,
MovePagesDown,
MovePagesFirst,
MovePagesLast,
CropPages,
ExtractPages,
SplitDocument
]
);
pub fn bind(cx: &mut App) {
let at = Some(ely_ui::commands::CONTEXT);
cx.bind_keys([
KeyBinding::new("secondary-z", Undo, at),
KeyBinding::new("secondary-shift-z", Redo, at),
KeyBinding::new("backspace", DeletePages, Some(crate::side::PAGES)),
KeyBinding::new("delete", DeletePages, Some(crate::side::PAGES)),
]);
}
/// The Pages menu.
pub fn menu(cx: &App) -> Menu {
let label = |key: &str| Viewer::text(cx, key, &[]);
Menu::new(label("menu.pages")).items([
MenuItem::action(label("pdf.pages.blank"), InsertBlankPage),
MenuItem::action(label("pdf.pages.file"), InsertPagesFromFile),
MenuItem::action(label("pdf.pages.delete"), DeletePages),
MenuItem::separator(),
MenuItem::action(label("pdf.pages.rotate_left"), RotatePagesLeft),
MenuItem::action(label("pdf.pages.rotate_right"), RotatePagesRight),
MenuItem::action(label("pdf.pages.crop"), CropPages),
MenuItem::separator(),
MenuItem::action(label("pdf.pages.up"), MovePagesUp),
MenuItem::action(label("pdf.pages.down"), MovePagesDown),
MenuItem::action(label("pdf.pages.first"), MovePagesFirst),
MenuItem::action(label("pdf.pages.last"), MovePagesLast),
MenuItem::separator(),
MenuItem::action(label("pdf.pages.extract"), ExtractPages),
MenuItem::action(label("pdf.pages.split"), SplitDocument),
])
}
/// The Edit menu's undo and redo.
pub fn history_items(cx: &App) -> [MenuItem; 2] {
let label = |key: &str| Viewer::text(cx, key, &[]);
[MenuItem::action(label("pdf.undo"), Undo), MenuItem::action(label("pdf.redo"), Redo)]
}
impl PdfView {
/// The pages an organizing step acts on: picked, else current.
pub fn chosen_pages(&self) -> Vec<usize> {
match self.picked.is_empty() {
true => vec![self.current],
false => self.picked.iter().copied().collect(),
}
}
/// Moves the chosen pages before page `to`; they stay picked.
pub fn move_to(&mut self, to: usize, cx: &mut Context<Self>) {
let pages = self.chosen_pages();
let before = pages.iter().filter(|page| **page < to).count();
let landed = (to - before..to - before + pages.len()).collect();
self.edit_picking("moving pages", Some(landed), move |worker, doc| worker.move_pages(doc, &pages, to), cx);
}
}
impl Viewer {
/// Runs `step` on the active view; with none, says so.
fn on_pages(&mut self, cx: &mut Context<Self>, step: impl FnOnce(&mut PdfView, &mut Context<PdfView>)) {
match self.active_view() {
Some(view) => view.update(cx, step),
None => self.no_document(cx),
}
}
pub(crate) fn delete(&mut self, cx: &mut Context<Self>) {
let Some(view) = self.active_view() else {
return self.no_document(cx);
};
let (pages, count) = (view.read(cx).chosen_pages(), view.read(cx).sizes.len());
if pages.len() == count {
let text = Self::text(cx, "pdf.pages.keep_one", &[]);
return self.toast(text, Severity::Warning, cx);
}
view.update(cx, |view, cx| {
view.picked.clear();
view.edit("deleting pages", move |worker, doc| worker.delete_pages(doc, &pages), cx);
});
}
fn turn_pages(&mut self, by: i32, cx: &mut Context<Self>) {
self.on_pages(cx, |view, cx| {
let pages = view.chosen_pages();
view.edit("turning pages", move |worker, doc| worker.rotate_pages(doc, &pages, by), cx);
});
}
/// Moves the chosen pages by `by`, or to an end.
fn shift(&mut self, by: isize, cx: &mut Context<Self>) {
self.on_pages(cx, |view, cx| {
let (pages, count) = (view.chosen_pages(), view.sizes.len() as isize);
let to = match by {
isize::MIN => 0,
isize::MAX => count,
-1 => pages[0] as isize - 1,
_ => *pages.last().expect("one page at least") as isize + 2,
};
if (0..=count).contains(&to) {
view.move_to(to as usize, cx);
}
});
}
fn blank(&mut self, cx: &mut Context<Self>) {
self.on_pages(cx, |view, cx| {
let at = view.chosen_pages().last().copied().expect("one page at least");
let size = view.sizes[at];
view.edit("inserting a page", move |worker, doc| worker.insert_blank(doc, at + 1, size), cx);
});
}
/// Asks for a PDF; its pages follow the chosen.
fn insert_file(&mut self, cx: &mut Context<Self>) {
if self.active_view().is_none() {
return self.no_document(cx);
}
self.pick(false, cx, |viewer, path: PathBuf, cx| {
viewer.on_pages(cx, |view, cx| {
let at = view.chosen_pages().last().copied().expect("one page at least") + 1;
view.edit(
"inserting pages",
move |worker, doc| worker.insert_file(doc, at, &path).map(|(_, mark)| mark),
cx,
);
})
});
}
}
/// Wires the organizing actions onto the root.
pub fn on(root: Div, cx: &mut Context<Viewer>) -> Div {
root.on_action(cx.listener(|v, _: &Undo, _, cx| v.on_pages(cx, |view, cx| view.undo(false, cx))))
.on_action(cx.listener(|v, _: &Redo, _, cx| v.on_pages(cx, |view, cx| view.undo(true, cx))))
.on_action(cx.listener(|v, _: &DeletePages, _, cx| v.delete(cx)))
.on_action(cx.listener(|v, _: &RotatePagesLeft, _, cx| v.turn_pages(-90, cx)))
.on_action(cx.listener(|v, _: &RotatePagesRight, _, cx| v.turn_pages(90, cx)))
.on_action(cx.listener(|v, _: &MovePagesUp, _, cx| v.shift(-1, cx)))
.on_action(cx.listener(|v, _: &MovePagesDown, _, cx| v.shift(1, cx)))
.on_action(cx.listener(|v, _: &MovePagesFirst, _, cx| v.shift(isize::MIN, cx)))
.on_action(cx.listener(|v, _: &MovePagesLast, _, cx| v.shift(isize::MAX, cx)))
.on_action(cx.listener(|v, _: &InsertBlankPage, _, cx| v.blank(cx)))
.on_action(cx.listener(|v, _: &InsertPagesFromFile, _, cx| v.insert_file(cx)))
.on_action(cx.listener(|v, _: &CropPages, window, cx| v.ask_crop(window, cx)))
.on_action(cx.listener(|v, _: &ExtractPages, _, cx| v.extract(cx)))
.on_action(cx.listener(|v, _: &SplitDocument, window, cx| v.ask_split(window, cx)))
}
+373
View File
@@ -0,0 +1,373 @@
//! Export, print, and the repaired copy's report (PDF-13, PDF-14).
use std::{io, path::PathBuf};
use ely_gpui_component::{
buttons::{Button, ButtonVariant, SegmentedControl},
data_display::DescriptionList,
feedback::Banner,
forms::{Checkbox, NumberInput},
overlays::Dialog as Card,
primitives::Severity,
theme::ControlSize,
};
use ely_office::{Chrome as _, Closing as _, Saving as _, Stopping as _, Tabs as _, in_window};
use ely_pdf_engine::Optimizing;
use ely_ui::commands::{Export, Print};
use gpui::{AnyElement, App, Context, IntoElement, ParentElement as _, Styled as _, TaskExt as _, Window, div};
use crate::view::{PdfView, Reported};
use crate::viewer::Viewer;
impl PdfView {
/// A draw met damage; read what MuPDF has said.
pub fn found_damage(&mut self, cx: &mut Context<Self>) {
let doc = self.doc;
self.off(
move |worker| worker.repair_report(doc),
|view, read, cx| match read {
Ok(report) => {
(view.repair, view.damage_seen) = (report, true);
cx.emit(Reported);
}
Err(error) => view.fail("reading the damage report", &error, cx),
},
cx,
);
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Images,
Text,
Optimized,
}
/// What the export dialog asks for.
pub struct Exporting {
tab: u64,
pub kind: Kind,
dpi: u32,
/// Every page, or the picked ones.
all: bool,
pub how: Optimizing,
}
/// `stem-N.png` for each page, numbers padded alike.
pub fn image_names(stem: &str, pages: &[usize], count: usize) -> Vec<String> {
let width = count.to_string().len();
pages.iter().map(|page| format!("{stem}-{:0width$}.png", page + 1)).collect()
}
fn stem(name: &str) -> String {
name.rsplit_once('.').map_or(name, |(stem, _)| stem).to_string()
}
impl Viewer {
pub fn on_export(&mut self, _: &Export, _: &mut Window, cx: &mut Context<Self>) {
let Some(tab) = self.core.active else {
return self.no_document(cx);
};
let how = Optimizing { compress: true, from: 300, to: 150 };
self.exporting = Some(Exporting { tab, kind: Kind::Images, dpi: 150, all: true, how });
cx.notify();
}
pub fn on_print(&mut self, _: &Print, _: &mut Window, cx: &mut Context<Self>) {
let Some(tab) = self.core.active else {
return self.no_document(cx);
};
self.print(tab, cx);
}
fn print(&mut self, id: u64, cx: &mut Context<Self>) {
if !self.settled(id, cx) {
return self.when_settled(id, move |viewer, _, cx| viewer.print(id, cx));
}
let Some(tab) = self.tab(id) else {
return;
};
let printing = tab.work.join("print.pdf");
let out = printing.clone();
log::info!("pdf: prints");
self.read_in(
id,
move |worker, doc| worker.get()?.print_copy(*doc, &out),
move |viewer, done, _, cx| match done {
Ok(()) => ely_office::print_later(printing, cx, |viewer, error, cx| viewer.panel_failed(&error, cx)),
Err(error) => viewer.failed(&error, cx),
},
cx,
);
}
/// Asks where, then writes what the dialog asks for.
fn choose_export(&mut self, cx: &mut Context<Self>) {
let Some(asked) = self.exporting.take() else {
return;
};
cx.notify();
let (Some(tab), Some(view)) = (self.tab(asked.tab), self.tab(asked.tab).map(|tab| tab.view.clone())) else {
return;
};
let Some(dir) = ely_office::folder(tab) else {
return self.panel_failed("no documents folder", cx);
};
let (id, stem) = (asked.tab, stem(&tab.name));
if asked.kind == Kind::Images {
let read = view.read(cx);
let pages = if asked.all { (0..read.sizes.len()).collect() } else { read.chosen_pages() };
let names = image_names(&stem, &pages, read.sizes.len());
let (dpi, version) = (asked.dpi as f32, read.version);
return self.pick(true, cx, move |viewer, folder, cx| {
let targets = names.iter().map(|name| folder.join(name)).collect();
viewer.write_images(id, (pages, version), dpi, targets, cx)
});
}
let name = match asked.kind {
Kind::Text => format!("{stem}.txt"),
_ => format!("{stem}-{}.pdf", Self::text(cx, "pdf.export.tail", &[])),
};
let chosen = cx.prompt_for_new_path(&dir, Some(&name));
cx.spawn(async move |this, cx| {
let chosen = match chosen.await {
Ok(Ok(path)) => Ok(path),
Ok(Err(error)) => Err(error.to_string()),
Err(error) => Err(error.to_string()),
};
in_window(&this, cx, |viewer, _, cx| match chosen {
Ok(Some(target)) => viewer.write_export(id, asked.kind, asked.how, target, cx),
Ok(None) => log::info!("pdf: the export panel was cancelled"),
Err(error) => viewer.panel_failed(&error, cx),
})
})
.detach_and_log_err(cx);
}
/// Page numbers hold for `version` only; an edit since refuses.
pub(crate) fn write_images(&mut self, id: u64, (pages, version): (Vec<usize>, u64), dpi: f32, targets: Vec<PathBuf>, cx: &mut Context<Self>) {
if !self.settled(id, cx) {
return self.when_settled(id, move |viewer, _, cx| viewer.write_images(id, (pages, version), dpi, targets, cx));
}
let Some(tab) = self.tab(id) else {
return;
};
if tab.view.read(cx).version != version {
return self.toast(Self::text(cx, "pdf.object.changed", &[]), Severity::Warning, cx);
}
let work = tab.work.join("page.png");
let folder = targets.first().and_then(|target| target.parent()).map(PathBuf::from);
log::info!("pdf: exports {} page images", pages.len());
self.read_in(
id,
move |worker, doc| {
let claimed = ely_files::claim_all(&targets)?;
let written = pages.iter().zip(claimed).try_for_each(|(page, file)| {
worker.get()?.export_image(*doc, *page, dpi, &work)?;
crate::ask::copy_into(&work, file)
});
// A failure leaves none of this run's files.
if let Err(error) = &written
&& let Err(left) = targets.iter().try_for_each(std::fs::remove_file)
{
return Err(io::Error::other(format!("{error}; then {left}")));
}
written.map(|()| targets.len())
},
move |viewer, done, _, cx| match done {
Ok(count) => {
let text = Self::text(cx, "pdf.export.images_done", &[("count", &count.to_string())]);
match folder.clone() {
Some(folder) => viewer.toast_output(text, folder, cx),
None => viewer.toast(text, Severity::Success, cx),
}
}
Err(error) => viewer.failed(&error, cx),
},
cx,
);
}
pub(crate) fn write_export(&mut self, id: u64, kind: Kind, how: Optimizing, target: PathBuf, cx: &mut Context<Self>) {
if !self.settled(id, cx) {
return self.when_settled(id, move |viewer, _, cx| viewer.write_export(id, kind, how, target, cx));
}
let Some(tab) = self.tab(id) else {
return;
};
let shown = ely_office::files::file_name(&target);
match self.holder(&target) {
Ok(None) => {}
Ok(Some(_)) => return self.toast(Self::text(cx, "pdf.extract.taken", &[("name", &shown)]), Severity::Danger, cx),
Err(error) => return self.failed(&error, cx),
}
let (work, path, revision) = (tab.work.join("export.out"), tab.session.path().map(PathBuf::from), tab.session.revision());
let opened = target.clone();
log::info!("pdf: exports {}", if kind == Kind::Text { "text" } else { "an optimized copy" });
self.read_in(
id,
move |worker, doc| {
match kind {
Kind::Text => worker.get()?.export_text(*doc, &work)?,
_ => worker.get()?.optimize(*doc, &work, how)?,
}
let bytes = std::fs::read(&work)?;
std::fs::remove_file(&work)?;
let before = path.as_deref().map(std::fs::metadata).transpose()?.map(|meta| meta.len());
ely_files::export(&target, path.as_deref(), &bytes, revision)?;
Ok((before, bytes.len() as u64))
},
move |viewer, done, _, cx| match done {
Ok((Some(before), after)) if kind == Kind::Optimized => {
let sizes = [("name", shown.as_ref()), ("before", &crate::attach::size(before)), ("after", &crate::attach::size(after))];
viewer.toast_output(Self::text(cx, "pdf.export.optimized_done", &sizes), opened.clone(), cx)
}
Ok(_) => viewer.toast_output(Self::text(cx, "pdf.export.done", &[("name", &shown)]), opened.clone(), cx),
Err(error) => viewer.failed(&error, cx),
},
cx,
);
}
/// The shell's warning, with its details a press away.
pub fn file_banner(&self, cx: &mut Context<Self>) -> Option<Banner> {
if let Some(stopped) = self.stopped_banner(cx) {
return Some(stopped);
}
let banner = self.risk_banner(cx)?;
let (key, action): (&str, Box<dyn gpui::Action>) = match self.active_tab()?.risk? {
ely_office::Risk::Signed => ("pdf.sign.show", Box::new(crate::document::ShowSignatures)),
ely_office::Risk::Repaired => ("pdf.repair.show", Box::new(crate::document::ShowRepairReport)),
ely_office::Risk::Macros => return Some(banner),
};
let label = Self::text(cx, key, &[]);
Some(banner.action(label, move |window, cx| window.dispatch_action(action.boxed_clone(), cx)))
}
pub fn show_repair(&mut self, open: bool, cx: &mut Context<Self>) {
self.repair_open = open;
cx.notify();
}
/// MuPDF's words on what it found broken.
pub fn repair_element(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let report = self.active_view().filter(|_| self.repair_open)?.read(cx).repair.clone()?;
let name = self.active_tab()?.name.clone();
let this = cx.entity();
let dismiss = move |_: &mut Window, cx: &mut App| this.update(cx, |viewer, cx| viewer.show_repair(false, cx));
let lines = report.lines().map(|line| div().child(line.to_string())).collect::<Vec<_>>();
let done = Self::text(cx, "pdf.sign.close", &[]);
let close = move |close: ely_gpui_component::overlays::Close| {
Button::new("pdf-repair-close", done).variant(ButtonVariant::Primary).on_click(move |_, window, cx| close(window, cx))
};
let card = Card::new("pdf-repair", Self::text(cx, "pdf.repair.title", &[("name", &name)]), dismiss)
.detail(Self::text(cx, "pdf.repair.detail", &[]))
.child(div().text_sm().max_h(gpui::px(320.0)).overflow_hidden().children(lines))
.action(close);
Some(card.into_any_element())
}
fn set_exporting(&mut self, cx: &mut Context<Self>, change: impl FnOnce(&mut Exporting)) {
if let Some(exporting) = self.exporting.as_mut() {
change(exporting);
}
cx.notify();
}
/// The export dialog, while open.
pub fn export_element(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let asked = self.exporting.as_ref()?;
let optimized = (asked.kind == Kind::Optimized).then(|| self.optimize_rows(asked.how, cx));
let this = cx.entity();
let label = |key: &str| Self::text(cx, key, &[]);
let segments = |id: &'static str, selected: &str, choices: &[(&str, &str)], set: fn(&mut Exporting, &str)| {
let changed = this.clone();
choices
.iter()
.fold(SegmentedControl::new(id, selected.to_string()).size(ControlSize::Sm), |control, (value, key)| {
control.segment(value.to_string(), Self::text(cx, key, &[]), None)
})
.on_change(move |value, _, cx| {
let value = value.to_string();
changed.update(cx, |viewer, cx| viewer.set_exporting(cx, |asked| set(asked, &value)))
})
};
let kind = match asked.kind {
Kind::Images => "images",
Kind::Text => "text",
Kind::Optimized => "optimized",
};
let kinds = [("images", "pdf.export.images"), ("text", "pdf.export.text"), ("optimized", "pdf.export.optimized")];
let mut body = div().flex().flex_col().gap_3().child(segments("pdf-export-kind", kind, &kinds, |asked, value| {
asked.kind = match value {
"text" => Kind::Text,
"optimized" => Kind::Optimized,
_ => Kind::Images,
}
}));
match asked.kind {
Kind::Images => {
let dpi = asked.dpi.to_string();
let dpis = [("72", "pdf.export.dpi72"), ("150", "pdf.export.dpi150"), ("300", "pdf.export.dpi300")];
let pages = if asked.all { "all" } else { "picked" };
let rows = DescriptionList::new()
.item(label("pdf.export.resolution"), segments("pdf-export-dpi", &dpi, &dpis, |asked, value| asked.dpi = value.parse().expect("a listed dpi")))
.item(
label("pdf.export.pages"),
segments("pdf-export-pages", pages, &[("all", "pdf.export.all"), ("picked", "pdf.export.picked")], |asked, value| asked.all = value == "all"),
);
body = body.child(rows);
}
Kind::Text => {}
Kind::Optimized => body = body.children(optimized),
}
let closed = this.clone();
let dismiss = move |_: &mut Window, cx: &mut App| {
closed.update(cx, |viewer, cx| {
viewer.exporting = None;
cx.notify();
})
};
let go = label("pdf.export.go");
// MuPDF only shrinks pictures; `to` must stay below `from`.
let ready = !(asked.kind == Kind::Optimized && asked.how.to > 0 && asked.how.from <= asked.how.to);
let card = Card::new("pdf-export", label("pdf.export.title"), dismiss).child(body).action(ely_office::cancel(cx)).action(move |_| {
Button::new("pdf-export-go", go)
.variant(ButtonVariant::Primary)
.disabled(!ready)
.on_click(move |_, _, cx| this.update(cx, |viewer, cx| viewer.choose_export(cx)))
});
Some(card.into_any_element())
}
fn optimize_rows(&self, how: Optimizing, cx: &mut Context<Self>) -> AnyElement {
let this = cx.entity();
let set = |change: fn(&mut Optimizing, f64)| {
let this = this.clone();
move |value: f64, _: &mut Window, cx: &mut App| this.update(cx, |viewer, cx| viewer.set_exporting(cx, |asked| change(&mut asked.how, value)))
};
let toggled = |change: fn(&mut Optimizing, bool)| {
let this = this.clone();
move |on: bool, _: &mut Window, cx: &mut App| this.update(cx, |viewer, cx| viewer.set_exporting(cx, |asked| change(&mut asked.how, on)))
};
let shrink = how.to > 0;
let mut rows = div().flex().flex_col().gap_2().child(
Checkbox::new("pdf-export-compress", how.compress).label(Self::text(cx, "pdf.export.compress", &[])).on_change(toggled(|how, on| how.compress = on)),
);
rows = rows.child(
Checkbox::new("pdf-export-shrink", shrink)
.label(Self::text(cx, "pdf.export.shrink", &[]))
.on_change(toggled(|how, on| (how.from, how.to) = if on { (300, 150) } else { (300, 0) })),
);
if shrink {
let from = NumberInput::new("pdf-export-from", f64::from(how.from)).range(72.0, 2400.0).suffix("dpi").label(Self::text(cx, "pdf.export.above", &[]));
let to = NumberInput::new("pdf-export-to", f64::from(how.to)).range(36.0, 1200.0).suffix("dpi").label(Self::text(cx, "pdf.export.to", &[]));
let list = DescriptionList::new()
.item(Self::text(cx, "pdf.export.above", &[]), from.on_change(set(|how, value| how.from = value.round() as u32)))
.item(Self::text(cx, "pdf.export.to", &[]), to.on_change(set(|how, value| how.to = value.round() as u32)));
rows = rows.child(list);
}
rows.into_any_element()
}
}
+327
View File
@@ -0,0 +1,327 @@
//! Only the pages on screen, drawn in tiles (PDF-01, PERF-05).
use std::{collections::HashSet, sync::Arc};
use ely_gpui_component::theme::ActiveTheme as _;
use gpui::{
AnyElement, Context, InteractiveElement as _, IntoElement, MouseButton, ParentElement as _,
Pixels, Point, RenderImage, StatefulInteractiveElement as _, Styled as _, Window, canvas, div,
img, point, px,
};
use image::{Frame, ImageBuffer, Rgba};
use crate::layout::{GAP, Layout, Mode, Placed, layout, visible};
use crate::view::{PdfView, Reported};
/// A tile's side in device pixels.
pub const TILE: i32 = 512;
/// A page tile at one scale, turn and theme.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct TileKey {
pub page: usize,
/// Device pixels a point, times a thousand.
pub scale: u32,
pub rotate: i32,
pub dark: bool,
pub tx: i32,
pub ty: i32,
}
/// A page point in its turned frame, still in points.
pub fn to_view((x, y): (f32, f32), (w, h): (f32, f32), rotate: i32) -> (f32, f32) {
match rotate.rem_euclid(360) {
90 => (h - y, x),
180 => (w - x, h - y),
270 => (y, w - x),
_ => (x, y),
}
}
/// The page point under a turned-frame point.
pub fn from_view((x, y): (f32, f32), (w, h): (f32, f32), rotate: i32) -> (f32, f32) {
match rotate.rem_euclid(360) {
90 => (y, h - x),
180 => (w - x, h - y),
270 => (w - y, x),
_ => (x, y),
}
}
/// A page rectangle on screen, relative to its placed page.
pub fn rect_on(rect: [f32; 4], size: (f32, f32), rotate: i32, scale: f32) -> (f32, f32, f32, f32) {
let a = to_view((rect[0], rect[1]), size, rotate);
let b = to_view((rect[2], rect[3]), size, rotate);
let (x0, y0) = (a.0.min(b.0), a.1.min(b.1));
let (x1, y1) = (a.0.max(b.0), a.1.max(b.1));
(x0 * scale, y0 * scale, (x1 - x0) * scale, (y1 - y0) * scale)
}
/// Tiles covering `[from, to)` view pixels of a `side`-pixel page.
fn span(from: f32, to: f32, sf: f32, side: i32) -> std::ops::RangeInclusive<i32> {
let first = (from.max(0.0) * sf).floor() as i32 / TILE;
let last = ((to * sf).ceil() as i32 - 1).min(side - 1).max(0) / TILE;
first..=last
}
/// A tile as an image; GPUI takes BGRA.
pub(crate) fn picture((w, h): (u32, u32), bgra: Vec<u8>) -> Option<Arc<RenderImage>> {
let image = ImageBuffer::<Rgba<u8>, _>::from_raw(w, h, bgra)?;
Some(Arc::new(RenderImage::new([Frame::new(image)])))
}
impl PdfView {
/// The layout in the room last painted.
pub fn laid_out(&self) -> Layout {
let room = (f32::from(self.room.size.width), f32::from(self.room.size.height));
layout(&self.sizes, self.rotate, (self.mode, self.zoom), room, self.current)
}
/// A window point as (page, point on it), if any.
pub fn page_at(&self, at: Point<Pixels>) -> Option<(usize, (f32, f32))> {
let laid = self.laid_out();
let offset = self.scroll.offset();
let x = f32::from(at.x - self.room.origin.x - offset.x);
let y = f32::from(at.y - self.room.origin.y - offset.y);
let place = laid.placed.iter().find(|p| x >= p.x && x < p.x + p.w && y >= p.y && y < p.y + p.h)?;
let local = ((x - place.x) / laid.scale, (y - place.y) / laid.scale);
Some((place.page, from_view(local, self.sizes[place.page], self.rotate)))
}
/// Freezes `page`'s place in the window for a gesture.
pub fn grip(&mut self, page: usize) {
let (laid, offset) = (self.laid_out(), self.scroll.offset());
self.grip = laid.placed.iter().find(|p| p.page == page).map(|place| {
let origin = point(self.room.origin.x + offset.x + px(place.x), self.room.origin.y + offset.y + px(place.y));
(page, origin, laid.scale)
});
}
/// A window point on the gripped page, as pressed.
pub fn held_at(&self, at: Point<Pixels>) -> Option<(usize, (f32, f32))> {
let (page, origin, scale) = self.grip?;
let local = (f32::from(at.x - origin.x) / scale, f32::from(at.y - origin.y) / scale);
Some((page, from_view(local, self.sizes[page], self.rotate)))
}
/// Pages in view and their tiles; missing ones asked.
pub fn page_view(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
let (sf, dark) = (window.scale_factor(), cx.theme().is_dark());
let (sunken, paper) = {
let colors = &cx.theme().colors;
(colors.sunken, if dark { colors.surface } else { colors.paper })
};
let laid = self.laid_out();
let (room_w, room_h) = (f32::from(self.room.size.width), f32::from(self.room.size.height));
if let Some((x, y)) = self.go_to.take() {
self.scroll.set_offset(point(px(-x), px(-y)));
}
let (left, top) = (-f32::from(self.scroll.offset().x), -f32::from(self.scroll.offset().y));
let shown: Vec<Placed> = visible(&laid, top, top + room_h)
.filter(|p| p.x < left + room_w && p.x + p.w > left)
.copied()
.collect();
// The current page: first showing more than a sliver.
if let Some(first) = shown.iter().find(|p| p.y + p.h > top + GAP * 3.0) {
let paired = shown.iter().any(|p| p.page == self.current) && first.page / 2 == self.current / 2;
let now = match self.mode {
Mode::Single => self.current,
// Either page of a pair in view may be current.
Mode::Two if paired => self.current,
_ => first.page,
};
if now != self.current {
self.current = now;
cx.emit(Reported);
}
}
let scale = (laid.scale * sf * 1000.0).round() as u32;
let (mut wanted, mut pages, mut seen) = (Vec::new(), Vec::new(), HashSet::new());
for place in &shown {
let (dw, dh) = ((place.w * sf).ceil() as i32, (place.h * sf).ceil() as i32);
let (ys, xs) = (
span(top - place.y, top + room_h - place.y, sf, dh),
span(left - place.x, left + room_w - place.x, sf, dw),
);
let mut tiles = Vec::new();
for ty in ys {
for tx in xs.clone() {
let key = TileKey { page: place.page, scale, rotate: self.rotate, dark, tx, ty };
let rect = [tx * TILE, ty * TILE, TILE.min(dw - tx * TILE), TILE.min(dh - ty * TILE)];
seen.insert(key);
match self.tiles.get(&key) {
Some(image) => tiles.push(
img(image.clone())
.absolute()
.left(px(rect[0] as f32 / sf))
.top(px(rect[1] as f32 / sf))
.w(px(rect[2] as f32 / sf))
.h(px(rect[3] as f32 / sf))
.into_any_element(),
),
None if !self.failed.contains(&key) => wanted.push((key, rect)),
None => {}
}
}
}
pages.push((*place, tiles));
}
// Tiles out of view go.
let retired = &mut self.retired;
self.tiles.retain(|key, image| {
let kept = seen.contains(key);
if !kept {
retired.push(image.clone());
}
kept
});
let pages_shown: Vec<usize> = shown.iter().map(|place| place.page).collect();
self.ask_layers(&pages_shown, cx);
self.ask_notes(pages_shown.iter().copied(), cx);
self.paint(wanted, cx);
let pages: Vec<AnyElement> = pages
.into_iter()
.map(|(place, tiles)| {
let mut marks = self.page_marks(&place, laid.scale, cx);
marks.extend(self.edit_marks(&place, laid.scale, cx));
marks.extend(self.region_marks(&place, laid.scale, cx));
marks.extend(self.note_marks(&place, laid.scale, cx));
div()
.absolute()
.left(px(place.x))
.top(px(place.y))
.w(px(place.w))
.h(px(place.h))
.bg(paper)
.children(tiles)
.children(marks)
.into_any_element()
})
.collect();
let this = cx.entity();
let (down, moved, up) = (this.clone(), this.clone(), this.clone());
let pages = div()
.id("pdf-pages")
.size_full()
.overflow_scroll()
.track_scroll(&self.scroll)
.bg(sunken)
.on_mouse_down(MouseButton::Left, move |event, window, cx| {
down.update(cx, |view, cx| {
window.focus(&view.focus, cx);
match view.tool {
tool if tool.selects() => view.select_from(event.position, cx),
tool if tool.edits() => view.press(event.position, cx),
_ => view.sketch_from(event.position, cx),
}
})
})
.on_mouse_move(move |event, _, cx| {
if event.dragging() {
moved.update(cx, |view, cx| match view.tool {
tool if tool.selects() => view.select_to(event.position, cx),
tool if tool.edits() => view.drag_to(event.position, cx),
_ => view.sketch_to(event.position, cx),
})
}
})
.on_mouse_up(MouseButton::Left, move |event, _, cx| {
up.update(cx, |view, cx| match view.tool {
tool if tool.selects() => view.finish_select(event.position, cx),
tool if tool.edits() => view.release(cx),
_ => view.sketch_end(cx),
})
})
.on_scroll_wheel(cx.listener(|_, _, _, cx| cx.notify()))
.child(div().relative().w(px(laid.size.0)).h(px(laid.size.1)).children(pages));
// Measures the view itself, which does not scroll.
let measure = canvas(
move |bounds, _, cx| {
this.update(cx, |view, cx| {
if view.room != bounds {
view.room = bounds;
cx.notify();
}
})
},
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full();
div().relative().size_full().child(pages).child(measure).into_any_element()
}
/// Asks for the first missing tile, one at a time.
fn paint(&mut self, wanted: Vec<(TileKey, [i32; 4])>, cx: &mut Context<Self>) {
let Some((key, rect)) = wanted.first().copied() else {
return;
};
if self.painting {
return;
}
self.painting = true;
let (doc, scale, version) = (self.doc, key.scale as f32 / 1000.0, self.version);
self.off(
move |worker| worker.render(doc, key.page, (scale, key.rotate), rect, key.dark),
move |view, painted, cx| {
view.painting = false;
if view.version != version {
log::info!("pdf: a tile from before an edit is dropped");
return cx.notify();
}
let painted = painted.map(|tile| {
if tile.damaged {
view.found_damage(cx);
}
tile.bgra
});
match painted.map(|bgra| picture((rect[2] as u32, rect[3] as u32), bgra)) {
Ok(Some(image)) => {
log::debug!("pdf: a tile of page {} drawn", key.page);
if let Some(old) = view.tiles.insert(key, image) {
view.retired.push(old);
}
}
Ok(None) => log::error!("pdf: a tile came back the wrong size"),
Err(error) if ely_pdf_engine::is_stale(&error) => log::info!("pdf: a tile from before an edit is dropped"),
Err(error) => {
// A failed tile is not asked again; once said.
let first = view.failed.is_empty();
view.failed.insert(key);
if first {
view.fail("drawing a page", &error, cx);
}
}
}
cx.notify();
},
cx,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn turning_and_back_lands_on_the_same_point() {
let size = (612.0, 792.0);
for rotate in [0, 90, 180, 270, -90] {
let point = (100.0, 50.0);
let there = to_view(point, size, rotate);
assert_eq!(from_view(there, size, rotate), point, "{rotate}");
}
assert_eq!(to_view((0.0, 0.0), size, 90), (792.0, 0.0), "the top left goes to the right");
assert_eq!(rect_on([0.0, 0.0, 10.0, 20.0], size, 90, 2.0), (1544.0, 0.0, 40.0, 20.0));
}
#[test]
fn a_view_edge_on_a_tile_edge_takes_no_tile_beyond() {
assert_eq!(span(0.0, 512.0, 1.0, 2000), 0..=0);
assert_eq!(span(256.0, 1024.0, 2.0, 4000), 1..=3);
assert_eq!(span(0.0, 9000.0, 1.0, 2000), 0..=3, "the page ends first");
}
}
+152
View File
@@ -0,0 +1,152 @@
//! The document properties dialog (PDF-07).
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
data_display::DescriptionList,
forms::{Input, TextInput},
overlays::Dialog as Card,
};
use ely_office::{Closing as _, Tabs as _};
use ely_pdf_engine::Properties;
use gpui::{AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, SharedString, Styled as _, Window, div};
use crate::viewer::Viewer;
/// The keys a person may change, with their label keys.
const EDITABLE: [(&str, &str); 4] =
[("Title", "pdf.props.title"), ("Author", "pdf.props.author"), ("Subject", "pdf.props.subject"), ("Keywords", "pdf.props.keywords")];
/// The properties as read, and their fields.
pub struct Props {
pub tab: u64,
pub read: Properties,
pub fields: [Entity<TextInput>; 4],
}
/// A PDF date (D:YYYYMMDDHHmmSS±HH'mm') as `YYYY-MM-DD HH:MM ±HH:MM`.
pub fn date(raw: &str) -> String {
let body = raw.trim_start_matches("D:");
let digits: String = body.chars().take_while(char::is_ascii_digit).collect();
if digits.len() < 12 {
return raw.to_string();
}
let shown = format!("{}-{}-{} {}:{}", &digits[0..4], &digits[4..6], &digits[6..8], &digits[8..10], &digits[10..12]);
let zone = &body[digits.len()..];
let hours: String = zone.chars().skip(1).filter(char::is_ascii_digit).collect();
match zone.chars().next() {
Some('Z') => format!("{shown} UTC"),
Some(sign @ ('+' | '-')) if hours.len() == 4 => format!("{shown} {sign}{}:{}", &hours[0..2], &hours[2..4]),
_ => shown,
}
}
fn now(read: &Properties, key: &str) -> Option<String> {
match key {
"Title" => read.title.clone(),
"Author" => read.author.clone(),
"Subject" => read.subject.clone(),
_ => read.keywords.clone(),
}
}
impl Viewer {
pub fn ask_properties(&mut self, _: &mut Window, cx: &mut Context<Self>) {
let Some(tab) = self.core.active else {
return self.no_document(cx);
};
self.read_in(
tab,
|worker, doc| worker.get()?.properties(*doc),
move |viewer, read, window, cx| match read {
Ok(read) => {
let fields = EDITABLE.map(|(key, _)| {
let value = now(&read, key).unwrap_or_default();
cx.new(|cx| {
let mut field = TextInput::new(window, cx);
field.set_text(value, cx);
field
})
});
viewer.props = Some(Props { tab, read, fields });
cx.notify();
}
Err(error) => viewer.failed(&error, cx),
},
cx,
);
}
/// Writes the fields that changed, one step.
pub(crate) fn set_properties(&mut self, cx: &mut Context<Self>) {
let Some(Props { tab, read, fields }) = self.props.take() else {
return;
};
cx.notify();
let changed: Vec<(&'static str, String)> = (EDITABLE.iter().zip(&fields))
.map(|((key, _), field)| (*key, field.read(cx).text().to_string()))
.filter(|(key, typed)| now(&read, key).unwrap_or_default() != *typed)
.collect();
let Some(view) = self.tab(tab).map(|tab| tab.view.clone()).filter(|_| !changed.is_empty()) else {
return log::info!("pdf: the properties are as they were");
};
view.update(cx, |view, cx| {
view.edit(
"setting properties",
move |worker, doc| {
let pairs: Vec<(&str, &str)> = changed.iter().map(|(key, value)| (*key, value.as_str())).collect();
worker.set_properties(doc, &pairs)
},
cx,
)
});
}
/// The properties dialog, while open.
pub fn props_element(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let props = self.props.as_ref()?;
let this = cx.entity();
let label = |key: &str| Self::text(cx, key, &[]);
let mut rows = DescriptionList::new();
for ((_, key), field) in EDITABLE.iter().zip(&props.fields) {
rows = rows.item(label(key), div().w_full().child(Input::new(field)));
}
let shown = |value: &Option<String>, dated: bool| -> SharedString {
match value {
Some(value) if dated => date(value).into(),
Some(value) => value.clone().into(),
None => label("pdf.props.none"),
}
};
let read = &props.read;
rows = rows
.item(label("pdf.props.creator"), shown(&read.creator, false))
.item(label("pdf.props.producer"), shown(&read.producer, false))
.item(label("pdf.props.created"), shown(&read.created, true))
.item(label("pdf.props.modified"), shown(&read.modified, true));
let closed = this.clone();
let dismiss = move |_: &mut Window, cx: &mut App| {
closed.update(cx, |viewer, cx| {
viewer.props = None;
cx.notify();
})
};
let go = label("pdf.text.apply");
let card = Card::new("pdf-props", label("pdf.props.heading"), dismiss).child(rows).action(ely_office::cancel(cx)).action(move |_| {
Button::new("pdf-props-go", go)
.variant(ButtonVariant::Primary)
.on_click(move |_, _, cx| this.update(cx, |viewer, cx| viewer.set_properties(cx)))
});
Some(card.into_any_element())
}
}
#[cfg(test)]
mod tests {
#[test]
fn pdf_dates_read_as_day_and_minute() {
assert_eq!(super::date("D:20261006153000+08'00'"), "2026-10-06 15:30 +08:00");
assert_eq!(super::date("D:20261006225959Z"), "2026-10-06 22:59 UTC");
assert_eq!(super::date("D:202610061530"), "2026-10-06 15:30", "no zone given");
assert_eq!(super::date("yesterday"), "yesterday", "as written when not a date");
}
}
+271
View File
@@ -0,0 +1,271 @@
//! Owner access and protected copies (PDF-11).
use std::{io, path::PathBuf};
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
data_display::DescriptionList,
forms::{Checkbox, Input, TextInput},
overlays::Dialog as Card,
primitives::Severity,
};
use ely_office::{Closing as _, Saving as _, Tabs as _, in_window};
use ely_pdf_engine::{Lock, proto::may};
use gpui::{AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Styled as _, TaskExt as _, Window, div};
use crate::redact::beside;
use crate::viewer::Viewer;
/// What a protected copy may allow, with label keys.
const ALLOW: [(u32, &str); 6] = [
(may::PRINT, "pdf.protect.print"),
(may::COPY, "pdf.protect.copy"),
(may::EDIT, "pdf.protect.edit"),
(may::ANNOTATE, "pdf.protect.annotate"),
(may::FILL, "pdf.protect.fill"),
(may::ASSEMBLE, "pdf.protect.assemble"),
];
pub enum Secure {
Owner { tab: u64, field: Entity<TextInput> },
Protect { tab: u64, on: bool, user: Entity<TextInput>, owner: Entity<TextInput>, allow: u32 },
}
impl Secure {
fn tab(&self) -> u64 {
match self {
Secure::Owner { tab, .. } | Secure::Protect { tab, .. } => *tab,
}
}
}
fn masked(window: &mut Window, cx: &mut Context<Viewer>) -> Entity<TextInput> {
cx.new(|cx| TextInput::new(window, cx).masked())
}
impl Viewer {
/// A refusal names what is missing.
pub(crate) fn failed(&self, error: &io::Error, cx: &mut App) {
match error.kind() {
io::ErrorKind::PermissionDenied => self.toast(crate::view::restricted(&error.to_string(), cx), Severity::Warning, cx),
_ => self.panel_failed(&error.to_string(), cx),
}
}
fn allowed(&self, cx: &App) -> Option<(u64, u32)> {
let tab = self.active_tab()?;
Some((tab.id, tab.view.read(cx).allowed))
}
pub fn ask_owner(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some((tab, allowed)) = self.allowed(cx) else {
return self.no_document(cx);
};
if allowed & may::OWNER != 0 {
return self.toast(Self::text(cx, "pdf.owner.already", &[]), Severity::Info, cx);
}
self.secure = Some(Secure::Owner { tab, field: masked(window, cx) });
cx.notify();
}
pub fn ask_protect(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some((tab, allowed)) = self.allowed(cx) else {
return self.no_document(cx);
};
if allowed & may::OWNER == 0 {
return self.toast(crate::view::restricted("owner", cx), Severity::Warning, cx);
}
let all = ALLOW.iter().fold(0, |bits, (bit, _)| bits | bit);
let (user, owner) = (masked(window, cx), masked(window, cx));
self.secure = Some(Secure::Protect { tab, on: true, user, owner, allow: all });
cx.notify();
}
pub(crate) fn try_owner(&mut self, cx: &mut Context<Self>) {
let Some(Secure::Owner { tab, field }) = self.secure.as_ref() else {
return;
};
let (tab, password, asked) = (*tab, field.read(cx).text().to_string(), field.clone());
let name = self.tab(tab).map(|open| open.name.to_string()).unwrap_or_default();
self.read_in(
tab,
move |worker, doc| {
let worker = worker.get()?;
Ok(worker.owner(*doc, &password)?.then_some(worker.permissions(*doc)?))
},
move |viewer, done, _, cx| match done {
Ok(Some(bits)) => {
// Only this dialog closes; a newer one stays.
if matches!(&viewer.secure, Some(Secure::Owner { field, .. }) if *field == asked) {
viewer.secure = None;
}
if let Some(open) = viewer.tab(tab) {
open.view.update(cx, |view, cx| {
view.allowed = bits;
cx.notify();
});
}
viewer.toast(Self::text(cx, "pdf.owner.done", &[("name", &name)]), Severity::Success, cx);
}
Ok(None) => viewer.toast(Self::text(cx, "pdf.owner.wrong", &[]), Severity::Warning, cx),
Err(error) => viewer.failed(&error, cx),
},
cx,
);
}
/// The lock asked for, or None for no protection.
pub(crate) fn lock(&self, cx: &App) -> Option<Option<Lock>> {
let Some(Secure::Protect { on, user, owner, allow, .. }) = self.secure.as_ref() else {
return None;
};
let (user, owner) = (user.read(cx).text().to_string(), owner.read(cx).text().to_string());
match on {
false => Some(None),
true if owner.is_empty() || owner == user => None,
true => Some(Some(Lock { user, owner, allow: *allow })),
}
}
/// Asks where the protected copy goes, then writes it.
fn choose_protected(&mut self, cx: &mut Context<Self>) {
let Some(lock) = self.lock(cx) else {
return;
};
let Some(tab) = self.secure.take().map(|secure| secure.tab()) else {
return;
};
cx.notify();
let Some(open) = self.tab(tab) else {
return;
};
let Some(dir) = ely_office::folder(open) else {
return self.panel_failed("no documents folder", cx);
};
let tail = Self::text(cx, if lock.is_some() { "pdf.protect.tail" } else { "pdf.protect.bare" }, &[]);
let chosen = cx.prompt_for_new_path(&dir, Some(&beside(&open.name, &tail)));
cx.spawn(async move |this, cx| {
let chosen = match chosen.await {
Ok(Ok(path)) => Ok(path),
Ok(Err(error)) => Err(error.to_string()),
Err(error) => Err(error.to_string()),
};
in_window(&this, cx, |viewer, _, cx| match chosen {
Ok(Some(target)) => viewer.write_protected(tab, lock, target, cx),
Ok(None) => log::info!("pdf: the protect panel was cancelled"),
Err(error) => viewer.panel_failed(&error, cx),
})
})
.detach_and_log_err(cx);
}
pub(crate) fn write_protected(&mut self, tab: u64, lock: Option<Lock>, target: PathBuf, cx: &mut Context<Self>) {
if !self.settled(tab, cx) {
return self.when_settled(tab, move |viewer, _, cx| viewer.write_protected(tab, lock, target, cx));
}
let Some(open) = self.tab(tab) else {
return;
};
let (shown, opened) = (ely_office::files::file_name(&target), target.clone());
match self.holder(&target) {
Ok(None) => {}
Ok(Some(_)) => return self.toast(Self::text(cx, "pdf.extract.taken", &[("name", &shown)]), Severity::Danger, cx),
Err(error) => return self.failed(&error, cx),
}
let (work, path, revision) = (open.work.join("protect.pdf"), open.session.path().map(PathBuf::from), open.session.revision());
log::info!("pdf: writes a {} copy", if lock.is_some() { "protected" } else { "unprotected" });
self.read_in(
tab,
move |worker, doc| {
worker.get()?.protect(*doc, &work, lock.as_ref())?;
let bytes = std::fs::read(&work)?;
std::fs::remove_file(&work)?;
ely_files::export(&target, path.as_deref(), &bytes, revision).map(|_| ())
},
move |viewer, done, _, cx| match done {
Ok(()) => viewer.toast_output(Self::text(cx, "pdf.protect.done", &[("name", &shown)]), opened.clone(), cx),
Err(error) => viewer.failed(&error, cx),
},
cx,
);
}
/// The owner or protect dialog, while open.
pub fn secure_element(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let secure = self.secure.as_ref()?;
let this = cx.entity();
let name = self.tab(secure.tab())?.name.to_string();
let label = |key: &str| Self::text(cx, key, &[]);
let closed = this.clone();
let dismiss = move |_: &mut Window, cx: &mut App| {
closed.update(cx, |viewer, cx| {
viewer.secure = None;
cx.notify();
})
};
let card = match secure {
Secure::Owner { field, .. } => {
let go = label("pdf.owner.go");
let rows = DescriptionList::new().item(label("pdf.owner.field"), div().w_full().child(Input::new(field)));
Card::new("pdf-owner", Self::text(cx, "pdf.owner.title", &[("name", &name)]), dismiss).child(rows).action(ely_office::cancel(cx)).action(
move |_| {
Button::new("pdf-owner-go", go)
.variant(ButtonVariant::Primary)
.on_click(move |_, _, cx| this.update(cx, |viewer, cx| viewer.try_owner(cx)))
},
)
}
Secure::Protect { on, user, owner, allow, .. } => {
let ready = self.lock(cx).is_some();
let go = label("pdf.removal.go");
let detail = label(if ready { "pdf.protect.detail" } else { "pdf.protect.needs_owner" });
let title = Self::text(cx, "pdf.protect.title", &[("name", &name)]);
let body = self.protect_rows(*on, user, owner, *allow, &this, cx);
let card = Card::new("pdf-protect", title, dismiss)
.detail(detail)
.child(body);
card.action(ely_office::cancel(cx)).action(move |_| {
Button::new("pdf-protect-go", go)
.variant(ButtonVariant::Primary)
.disabled(!ready)
.on_click(move |_, _, cx| this.update(cx, |viewer, cx| viewer.choose_protected(cx)))
})
}
};
Some(card.into_any_element())
}
fn protect_rows(&self, on: bool, user: &Entity<TextInput>, owner: &Entity<TextInput>, allow: u32, this: &Entity<Self>, cx: &mut Context<Self>) -> AnyElement {
let set = |change: Box<dyn Fn(&mut bool, &mut u32, bool)>| {
let this = this.clone();
move |value: bool, _: &mut Window, cx: &mut App| {
this.update(cx, |viewer, cx| {
if let Some(Secure::Protect { on, allow, .. }) = viewer.secure.as_mut() {
change(on, allow, value);
}
cx.notify();
})
}
};
let mut body = div().flex().flex_col().gap_2().child(
Checkbox::new("pdf-protect-on", on)
.label(Self::text(cx, "pdf.protect.on", &[]))
.on_change(set(Box::new(|on, _, value| *on = value))),
);
if on {
let rows = DescriptionList::new()
.item(Self::text(cx, "pdf.protect.user", &[]), div().w_full().child(Input::new(user)))
.item(Self::text(cx, "pdf.protect.owner", &[]), div().w_full().child(Input::new(owner)));
body = body.child(rows);
for (at, (bit, key)) in ALLOW.iter().enumerate() {
let bit = *bit;
body = body.child(
Checkbox::new(("pdf-protect-allow", at), allow & bit != 0)
.label(Self::text(cx, key, &[]))
.on_change(set(Box::new(move |_, allow, value| *allow = if value { *allow | bit } else { *allow & !bit }))),
);
}
}
body.into_any_element()
}
}
+265
View File
@@ -0,0 +1,265 @@
//! Removal and cleaning into new files (PDF-09, PDF-10).
use std::path::PathBuf;
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
forms::Checkbox,
overlays::Dialog as Card,
primitives::Severity,
theme::ActiveTheme as _,
};
use ely_office::{Closing as _, Saving as _, Tabs as _, in_window};
use ely_pdf_engine::{Cleaning, Reach, Region};
use gpui::{AnyElement, App, Context, IntoElement, ParentElement as _, Styled as _, TaskExt as _, Window, div, px};
use crate::layout::Placed;
use crate::pages::rect_on;
use crate::view::PdfView;
use crate::viewer::Viewer;
/// A removal or cleaning, bound to its asked version.
pub enum Removal {
/// The marks as confirmed.
Redact { tab: u64, version: u64, regions: Vec<Region> },
/// What cleaning reaches, once scanned, and what is chosen.
Clean { tab: u64, version: u64, reach: Reach, what: Cleaning },
}
impl Removal {
fn bound(&self) -> (u64, u64) {
match self {
Removal::Redact { tab, version, .. } | Removal::Clean { tab, version, .. } => (*tab, *version),
}
}
}
fn inside(rect: &[f32; 4], (x, y): (f32, f32)) -> bool {
x >= rect[0] && x <= rect[2] && y >= rect[1] && y <= rect[3]
}
impl PdfView {
/// Marks regions; a click on one unmarks it.
pub fn mark_regions(&mut self, page: usize, boxes: Vec<[f32; 4]>, click: Option<(f32, f32)>, cx: &mut Context<Self>) {
match click {
Some(at) => self.redactions.retain(|r| !(r.page == page && inside(&r.rect, at))),
None => self.redactions.extend(boxes.into_iter().map(|rect| Region { page, rect })),
}
log::info!("pdf: {} regions marked to remove", self.redactions.len());
cx.notify();
}
/// The regions on `place`, drawn as they will come out.
pub fn region_marks(&self, place: &Placed, scale: f32, cx: &App) -> Vec<AnyElement> {
let colors = &cx.theme().colors;
let size = self.sizes[place.page];
(self.redactions.iter().filter(|r| r.page == place.page))
.map(|r| {
let (x, y, w, h) = rect_on(r.rect, size, self.rotate, scale);
let box_ = div().absolute().left(px(x)).top(px(y)).w(px(w)).h(px(h));
box_.bg(gpui::black().opacity(0.8)).border_1().border_color(colors.danger).into_any_element()
})
.collect()
}
}
/// A name beside the open one, as `stem-tail.pdf`.
pub(crate) fn beside(name: &str, tail: &str) -> String {
let stem = name.rsplit_once('.').map_or(name, |(stem, _)| stem);
format!("{stem}-{tail}.pdf")
}
impl Viewer {
pub fn ask_redact(&mut self, cx: &mut Context<Self>) {
let (Some(tab), Some(view)) = (self.core.active, self.active_view()) else {
return self.no_document(cx);
};
let read = view.read(cx);
if read.redactions.is_empty() {
let text = Self::text(cx, "pdf.redact.none", &[]);
return self.toast(text, Severity::Info, cx);
}
self.removal = Some(Removal::Redact { tab, version: read.version, regions: read.redactions.clone() });
cx.notify();
}
pub fn clear_redactions(&mut self, cx: &mut Context<Self>) {
self.on_view(|view, cx| {
view.redactions.clear();
cx.notify();
}, cx);
}
pub fn ask_clean(&mut self, cx: &mut Context<Self>) {
let (Some(open), Some(view)) = (self.active_tab(), self.active_view()) else {
return self.no_document(cx);
};
let (tab, version, scratch) = (open.id, view.read(cx).version, open.work.join("scan.pdf"));
self.ask_in(
tab,
move |worker, doc| worker.get()?.clean_scan(*doc, &scratch),
move |viewer, read, _, cx| match read {
// An edit since makes the counts stale.
Ok(_) if viewer.tab(tab).is_none_or(|open| open.view.read(cx).version != version) => {
log::info!("pdf: a cleaning scan came back after an edit")
}
Ok(reach) => {
viewer.removal = Some(Removal::Clean { tab, version, reach, what: Cleaning::default() });
cx.notify();
}
Err(error) => viewer.failed(&error, cx),
},
cx,
);
}
/// Asks where the new file goes, then writes it.
fn choose_output(&mut self, cx: &mut Context<Self>) {
let Some(removal) = self.removal.take() else {
return;
};
cx.notify();
let (tab, _) = removal.bound();
let Some(open) = self.tab(tab) else {
return;
};
let Some(dir) = ely_office::folder(open) else {
return self.panel_failed("no documents folder", cx);
};
let tail = match removal {
Removal::Redact { .. } => Self::text(cx, "pdf.redact.tail", &[]),
Removal::Clean { .. } => Self::text(cx, "pdf.clean.tail", &[]),
};
let chosen = cx.prompt_for_new_path(&dir, Some(&beside(&open.name, &tail)));
cx.spawn(async move |this, cx| {
let chosen = match chosen.await {
Ok(Ok(path)) => Ok(path),
Ok(Err(error)) => Err(error.to_string()),
Err(error) => Err(error.to_string()),
};
in_window(&this, cx, |viewer, _, cx| match chosen {
Ok(Some(target)) => viewer.write_removal(removal, target, cx),
Ok(None) => log::info!("pdf: the output panel was cancelled"),
Err(error) => viewer.panel_failed(&error, cx),
})
})
.detach_and_log_err(cx);
}
/// Writes, checks, then hands the new file over.
pub(crate) fn write_removal(&mut self, removal: Removal, target: PathBuf, cx: &mut Context<Self>) {
let (tab, version) = removal.bound();
let (Some(open), Some(view)) = (self.tab(tab), self.tab(tab).map(|tab| tab.view.clone())) else {
return;
};
// What was confirmed is what is written, or nothing.
let read = view.read(cx);
let changed = match &removal {
Removal::Redact { regions, .. } => read.redactions != *regions,
Removal::Clean { .. } => false,
};
if read.version != version || !read.settled() || changed {
let text = Self::text(cx, "pdf.object.changed", &[]);
return self.toast(text, Severity::Warning, cx);
}
let (shown, opened) = (ely_office::files::file_name(&target), target.clone());
match self.holder(&target) {
Ok(None) => {}
Ok(Some(_)) => return self.toast(Self::text(cx, "pdf.extract.taken", &[("name", &shown)]), Severity::Danger, cx),
Err(error) => return self.failed(&error, cx),
}
let (work, path, revision) = (open.work.join("removal.pdf"), open.session.path().map(PathBuf::from), open.session.revision());
log::info!("pdf: writes a {} copy", if matches!(removal, Removal::Redact { .. }) { "redacted" } else { "cleaned" });
self.read_in(
tab,
move |worker, doc| {
let report = match removal {
Removal::Redact { regions, .. } => worker.get()?.redact(*doc, &regions, &work).map(|r| r.regions)?,
Removal::Clean { what, .. } => worker.get()?.clean(*doc, what, &work).map(|_| 0)?,
};
let bytes = std::fs::read(&work)?;
// The work copy goes; a plain delete, not a wipe.
std::fs::remove_file(&work)?;
ely_files::export(&target, path.as_deref(), &bytes, revision)?;
Ok(report)
},
move |viewer, done, _, cx| match done {
Ok(0) => viewer.toast_output(Self::text(cx, "pdf.clean.done", &[("name", &shown)]), opened.clone(), cx),
Ok(n) => viewer.toast_output(Self::text(cx, "pdf.redact.done", &[("name", &shown), ("count", &n.to_string())]), opened.clone(), cx),
Err(error) => viewer.failed(&error, cx),
},
cx,
);
}
/// The removal or cleaning dialog, while open.
pub fn removal_element(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let removal = self.removal.as_ref()?;
let this = cx.entity();
let name = self.tab(removal.bound().0)?.name.to_string();
let (title, body) = match removal {
Removal::Clean { reach, what, .. } => ("pdf.clean.title", self.clean_rows(reach, *what, &this, cx)),
Removal::Redact { regions, .. } => {
let count = regions.len().to_string();
("pdf.redact.title", div().child(Self::text(cx, "pdf.redact.count", &[("count", &count)])).into_any_element())
}
};
let title = Self::text(cx, title, &[("name", &name)]);
let label = |key: &str| Self::text(cx, key, &[]);
let closed = this.clone();
let dismiss = move |_: &mut Window, cx: &mut App| {
closed.update(cx, |viewer, cx| {
viewer.removal = None;
cx.notify();
})
};
let ready = !matches!(removal, Removal::Clean { what, .. } if *what == Cleaning::default());
let go = label("pdf.removal.go");
let card = Card::new("pdf-removal", title, dismiss)
.detail(label("pdf.removal.warning"))
.child(body)
.action(ely_office::cancel(cx))
.action(move |_| {
Button::new("pdf-removal-go", go)
.variant(ButtonVariant::Primary)
.disabled(!ready)
.on_click(move |_, _, cx| this.update(cx, |viewer, cx| viewer.choose_output(cx)))
});
Some(card.into_any_element())
}
fn clean_rows(&self, reach: &Reach, what: Cleaning, this: &gpui::Entity<Self>, cx: &mut Context<Self>) -> AnyElement {
let row = |at: usize, key: &str, count: usize, on: bool, set: fn(&mut Cleaning, bool)| {
let this = this.clone();
Checkbox::new(("pdf-clean", at), on)
.label(Self::text(cx, key, &[("count", &count.to_string())]))
.disabled(count == 0)
.on_change(move |value, _, cx| {
this.update(cx, |viewer, cx| {
if let Some(Removal::Clean { what, .. }) = viewer.removal.as_mut() {
set(what, value);
}
cx.notify();
})
})
};
div()
.flex()
.flex_col()
.gap_2()
.child(row(0, "pdf.clean.meta", reach.meta, what.meta, |w, on| w.meta = on))
.child(row(1, "pdf.clean.annots", reach.annots, what.annots, |w, on| w.annots = on))
.child(row(2, "pdf.clean.files", reach.files, what.files, |w, on| w.files = on))
.child(row(3, "pdf.clean.hidden", reach.hidden + reach.scripts + reach.hidden_annots, what.hidden, |w, on| w.hidden = on))
.into_any_element()
}
}
#[cfg(test)]
mod tests {
#[test]
fn a_new_file_sits_beside_the_old_by_name() {
assert_eq!(super::beside("report.pdf", "redacted"), "report-redacted.pdf");
assert_eq!(super::beside("notes", "cleaned"), "notes-cleaned.pdf");
}
}
+208
View File
@@ -0,0 +1,208 @@
//! Find in the whole document, pages in batches (PDF-01).
use ely_gpui_component::{
editor::FindWidget,
forms::{InputEvent, TextInput},
primitives::Severity,
};
use ely_office::Tabs as _;
use gpui::{
AnyElement, AppContext as _, Context, Entity, Focusable as _, IntoElement, ParentElement as _,
Styled as _, Subscription, Window, div,
};
use crate::view::PdfView;
use crate::viewer::Viewer;
/// Pages searched in one worker call.
const BATCH: usize = 20;
/// The find box; its hits live in the tab's view.
pub struct Find {
/// Each find box is new; late answers check it.
pub id: u64,
pub tab: u64,
pub query: Entity<TextInput>,
/// Pages searched so far.
pub done: usize,
/// Counts searches; an older batch is dropped.
pub round: u64,
/// The view's version searched; an edit starts over.
pub version: u64,
_query: Subscription,
}
/// A quad's bounding box: x0, y0, x1, y1.
pub fn bounds(quad: &[f32; 8]) -> [f32; 4] {
let xs = [quad[0], quad[2], quad[4], quad[6]];
let ys = [quad[1], quad[3], quad[5], quad[7]];
let min = |v: [f32; 4]| v.into_iter().fold(f32::MAX, f32::min);
let max = |v: [f32; 4]| v.into_iter().fold(f32::MIN, f32::max);
[min(xs), min(ys), max(xs), max(ys)]
}
impl Viewer {
/// The view of tab `id`.
fn view_of(&self, id: u64) -> Option<Entity<PdfView>> {
self.tab(id).map(|tab| tab.view.clone())
}
pub fn open_find(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(tab) = self.core.active else {
let text = Self::text(cx, "command.blocked.no_document", &[]);
return self.toast(text, Severity::Warning, cx);
};
if let Some(find) = self.find.as_ref().filter(|find| find.tab == tab) {
return window.focus(&find.query.focus_handle(cx), cx);
}
self.drop_find(cx);
let query = cx.new(|cx| TextInput::new(window, cx));
let subscription = cx.subscribe(&query, |viewer, _, event, cx| match event {
InputEvent::Changed => viewer.search(cx),
InputEvent::Submit => viewer.step(true, cx),
_ => {}
});
window.focus(&query.focus_handle(cx), cx);
self.finds += 1;
let id = self.finds;
self.find = Some(Find { id, tab, query, done: 0, round: 0, version: 0, _query: subscription });
cx.notify();
}
/// Closes the find box; its marks go.
pub fn drop_find(&mut self, cx: &mut Context<Self>) {
if let Some(view) = self.find.take().and_then(|find| self.view_of(find.tab)) {
view.update(cx, |view, cx| {
(view.found, view.chosen) = (Vec::new(), None);
cx.notify();
});
}
}
/// Starts over for the query now typed.
pub fn search(&mut self, cx: &mut Context<Self>) {
let Some(view) = self.find.as_ref().and_then(|find| self.view_of(find.tab)) else {
return;
};
let version = view.read(cx).version;
view.update(cx, |view, _| (view.found, view.chosen) = (Vec::new(), None));
if let Some(find) = self.find.as_mut() {
(find.round, find.done, find.version) = (find.round + 1, 0, version);
}
self.search_more(cx);
cx.notify();
}
/// Searches the next batch of pages.
fn search_more(&mut self, cx: &mut Context<Self>) {
let Some(find) = self.find.as_ref() else {
return;
};
let needle = find.query.read(cx).text().to_string();
let (finder, id, round, from) = (find.id, find.tab, find.round, find.done);
let Some(view) = self.view_of(id) else {
return;
};
let (doc, pages) = (view.read(cx).doc, view.read(cx).sizes.len());
if needle.is_empty() || from >= pages {
return;
}
let to = (from + BATCH).min(pages);
let this = cx.weak_entity();
view.update(cx, |view, cx| {
view.read(
move |worker| {
(from..to)
.map(|page| Ok(worker.search(doc, page, &needle)?.into_iter().map(move |quad| (page, quad))))
.collect::<std::io::Result<Vec<_>>>()
.map(|pages| pages.into_iter().flatten().collect::<Vec<_>>())
},
move |view, found, cx| {
let live = move |viewer: &Viewer| viewer.find.as_ref().is_some_and(|find| (find.id, find.round) == (finder, round));
if !this.upgrade().is_some_and(|viewer| live(viewer.read(cx))) {
return;
}
let hits = match found {
Ok(hits) => hits,
Err(error) => return view.fail("searching", &error, cx),
};
view.found.extend(hits);
let first = view.chosen.is_none() && !view.found.is_empty();
cx.notify();
// The viewer reads this view; wait until it is free.
cx.defer(move |cx| {
let Some(viewer) = this.upgrade() else {
return;
};
viewer.update(cx, |viewer, cx| {
if !live(viewer) {
return;
}
if let Some(find) = viewer.find.as_mut() {
find.done = to;
}
if first {
viewer.step(true, cx);
}
viewer.search_more(cx);
cx.notify();
})
});
},
cx,
)
});
}
/// Goes to the next or previous hit.
pub fn step(&mut self, forward: bool, cx: &mut Context<Self>) {
let Some(find) = self.find.as_ref() else {
return;
};
let Some(view) = self.view_of(find.tab) else {
return;
};
view.update(cx, |view, cx| {
let count = view.found.len();
if count == 0 {
return;
}
let at = match (view.chosen, forward) {
(None, _) => 0,
(Some(at), true) => (at + 1) % count,
(Some(at), false) => (at + count - 1) % count,
};
view.chosen = Some(at);
let (page, y) = (view.found[at].0, bounds(&view.found[at].1[0])[1]);
view.show(page, y, cx);
});
cx.notify();
}
/// The find box over the pages, while open.
pub fn find_bar(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let find = self.find.as_ref().filter(|find| Some(find.tab) == self.core.active)?;
let view = self.view_of(find.tab)?;
let (chosen, count) = (view.read(cx).chosen, view.read(cx).found.len());
let (step, close) = (cx.entity(), cx.entity());
let widget = FindWidget::new("pdf-find", &find.query, chosen, count)
.on_step(move |forward, _, cx| step.update(cx, |viewer, cx| viewer.step(forward, cx)))
.on_close(move |window, cx| {
close.update(cx, |viewer, cx| {
viewer.drop_find(cx);
window.focus(&view.read(cx).focus.clone(), cx);
cx.notify();
})
});
Some(div().flex().justify_end().px_3().py_1().child(widget).into_any_element())
}
}
#[cfg(test)]
mod tests {
#[test]
fn a_quad_bounds_its_four_corners() {
let quad = [10.0, 20.0, 30.0, 20.0, 10.0, 32.0, 30.0, 32.0];
assert_eq!(super::bounds(&quad), [10.0, 20.0, 30.0, 32.0]);
}
}
+224
View File
@@ -0,0 +1,224 @@
//! Text picked with the mouse, across pages, and copied (PDF-01).
use std::sync::Arc;
use ely_gpui_component::theme::ActiveTheme as _;
use ely_pdf_engine::Char;
use gpui::{AnyElement, App, ClipboardItem, Context, IntoElement, Pixels, Point, Styled as _, div, px};
use crate::layout::Placed;
use crate::pages::rect_on;
use crate::search::bounds;
use crate::view::PdfView;
/// Drag start and end, as (page, point in points).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Selection {
pub anchor: (usize, (f32, f32)),
pub head: (usize, (f32, f32)),
}
/// Picked characters from one (page, index) to another.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Span {
pub from: (usize, usize),
pub to: (usize, usize),
}
impl Selection {
/// The characters picked, once both ends' pages are read.
pub fn span<'a>(&self, text: impl Fn(usize) -> Option<&'a [Char]>) -> Option<Span> {
// A press that has not moved picks nothing.
if self.anchor == self.head {
return None;
}
let end = |(page, point): (usize, (f32, f32))| Some((page, nearest(text(page)?, point)?));
let (a, b) = (end(self.anchor)?, end(self.head)?);
Some(Span { from: a.min(b), to: a.max(b) })
}
}
impl Span {
/// The picked characters of `page`, if any.
pub fn on(&self, page: usize, count: usize) -> Option<std::ops::Range<usize>> {
let ((p0, i0), (p1, i1)) = (self.from, self.to);
if page < p0 || page > p1 || count == 0 {
return None;
}
let from = if page == p0 { i0 } else { 0 };
let to = if page == p1 { i1 + 1 } else { count };
Some(from.min(count)..to.min(count))
}
}
/// The character nearest `at`, in page points.
pub fn nearest(chars: &[Char], (x, y): (f32, f32)) -> Option<usize> {
let gap = |c: &Char| {
let dx = (c.rect[0] - x).max(0.0).max(x - c.rect[2]);
let dy = (c.rect[1] - y).max(0.0).max(y - c.rect[3]);
// Lines count more than columns.
dx + dy * 4.0
};
(0..chars.len()).min_by(|a, b| gap(&chars[*a]).total_cmp(&gap(&chars[*b])))
}
/// Characters as text: a line change is a new line.
pub fn as_text(chars: &[Char]) -> String {
let mut text = String::new();
for (at, c) in chars.iter().enumerate() {
if at > 0 && chars[at - 1].line != c.line {
text.push('\n');
}
text.push(c.c);
}
text
}
impl PdfView {
/// The text of `page`, asked for when missing.
pub fn page_text(&mut self, page: usize, cx: &mut Context<Self>) -> Option<Arc<Vec<Char>>> {
if let Some(text) = self.text.get(&page) {
return Some(text.clone());
}
if self.asking_text.insert(page) {
let doc = self.doc;
self.read(
move |worker| worker.text(doc, page),
move |view, read, cx| {
view.asking_text.remove(&page);
match read {
Ok(chars) => {
view.text.insert(page, Arc::new(chars));
view.mark_pending(cx);
cx.notify();
}
Err(error) => {
view.marking = None;
view.fail("reading the text", &error, cx)
}
}
},
cx,
);
}
None
}
/// The page point under `at`; its text is asked for.
fn point_at(&mut self, at: Point<Pixels>, cx: &mut Context<Self>) -> Option<(usize, (f32, f32))> {
let (page, point) = self.page_at(at)?;
self.page_text(page, cx);
Some((page, point))
}
pub fn select_from(&mut self, at: Point<Pixels>, cx: &mut Context<Self>) {
(self.marking, self.pressed) = (None, Some(at));
self.selection = self.point_at(at, cx).map(|end| Selection { anchor: end, head: end });
cx.notify();
}
pub fn select_to(&mut self, at: Point<Pixels>, cx: &mut Context<Self>) {
let Some(head) = self.point_at(at, cx) else {
return;
};
if let Some(selection) = self.selection.as_mut()
&& selection.head != head
{
selection.head = head;
cx.notify();
}
}
/// Picked text and found words drawn over a page.
pub fn page_marks(&self, place: &Placed, scale: f32, cx: &App) -> Vec<AnyElement> {
let colors = &cx.theme().colors;
let size = self.sizes[place.page];
let mark = |rect: [f32; 4], color| {
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)).bg(color).into_any_element()
};
let mut marks = Vec::new();
let span = self.selection.and_then(|selection| selection.span(|page| self.text.get(&page).map(|chars| chars.as_slice())));
if let (Some(span), Some(chars)) = (span, self.text.get(&place.page)) {
for c in span.on(place.page, chars.len()).map_or(&[][..], |range| &chars[range]) {
marks.push(mark(c.rect, colors.selection));
}
}
for (at, (_, quads)) in self.found.iter().enumerate().filter(|(_, (page, _))| *page == place.page) {
let color = match self.chosen == Some(at) {
true => colors.warning.opacity(0.5),
false => colors.warning.opacity(0.25),
};
marks.extend(quads.iter().map(|quad| mark(bounds(quad), color)));
}
marks
}
/// Copies the picked text, reading missing pages first.
pub fn copy(&mut self, cx: &mut Context<Self>) {
let Some(selection) = self.selection else {
return;
};
if self.allowed & ely_pdf_engine::proto::may::COPY == 0 {
return cx.emit(crate::view::Failed(crate::view::restricted("copy", cx).to_string()));
}
let (first, last) = (selection.anchor.0.min(selection.head.0), selection.anchor.0.max(selection.head.0));
let (doc, have) = (self.doc, self.text.clone());
self.read(
move |worker| {
(first..=last)
.map(|page| match have.get(&page) {
Some(chars) => Ok((**chars).clone()),
None => worker.text(doc, page),
})
.collect::<std::io::Result<Vec<_>>>()
},
move |view, read, cx| match read {
Ok(pages) => {
let Some(span) = selection.span(|page| pages.get(page.checked_sub(first)?).map(Vec::as_slice)) else {
return log::info!("pdf: nothing picked to copy");
};
let parts: Vec<String> = (pages.iter().enumerate())
.filter_map(|(at, chars)| {
let range = span.on(first + at, chars.len())?;
Some(as_text(&chars[range]))
})
.collect();
log::info!("pdf: copies text from {} pages", parts.len());
cx.write_to_clipboard(ClipboardItem::new_string(parts.join("\n")));
}
Err(error) => view.fail("copying", &error, cx),
},
cx,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn line(line: i32, text: &str, y: f32) -> Vec<Char> {
(text.chars().enumerate())
.map(|(at, c)| Char { line, c, rect: [at as f32 * 10.0, y, at as f32 * 10.0 + 10.0, y + 12.0] })
.collect()
}
#[test]
fn a_drag_picks_from_either_end_and_copies_lines() {
let mut chars = line(0, "Hello", 0.0);
chars.extend(line(1, "World", 20.0));
assert_eq!(nearest(&chars, (25.0, 5.0)), Some(2), "inside the third");
assert_eq!(nearest(&chars, (500.0, 25.0)), Some(9), "past the end of line two");
let text = |page: usize| (page == 0).then_some(chars.as_slice());
let backwards = Selection { anchor: (0, (25.0, 25.0)), head: (0, (25.0, 5.0)) };
let span = backwards.span(text).expect("both ends read");
assert_eq!(as_text(&chars[span.on(0, chars.len()).expect("on page zero")]), "llo\nWor");
let still = Selection { anchor: (0, (25.0, 5.0)), head: (0, (25.0, 5.0)) };
assert_eq!(still.span(text), None, "a click picks nothing");
let unread = Selection { anchor: (0, (25.0, 5.0)), head: (1, (5.0, 5.0)) };
assert_eq!(unread.span(text), None, "page two is not read yet");
let across = Span { from: (1, 3), to: (3, 1) };
assert_eq!((across.on(1, 5), across.on(2, 5), across.on(3, 5), across.on(4, 5)), (Some(3..5), Some(0..5), Some(0..2), None));
}
}
+278
View File
@@ -0,0 +1,278 @@
//! Page pictures, and bookmarks if any (PDF-01, PDF-02).
use std::{collections::BTreeSet, ops::Range};
use ely_gpui_component::{
buttons::SegmentedControl,
menus::{ContextMenu, Menu as Popup, MenuItem as PopupItem},
primitives::{DragGhost, FocusRing as _, IconName},
theme::{ActiveTheme as _, ControlSize},
};
use gpui::{
Action, App, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, Render, Role,
StatefulInteractiveElement as _, Styled as _, WeakEntity, Window, div, img, prelude::FluentBuilder as _, px,
uniform_list,
};
use crate::organize::{CropPages, DeletePages, ExtractPages, InsertBlankPage, InsertPagesFromFile, RotatePagesLeft, RotatePagesRight};
use crate::view::PdfView;
use crate::viewer::Viewer;
/// The key context of the page list.
pub const PAGES: &str = "PdfPages";
/// A picture's width in pixels, and its row's height.
const WIDTH: f32 = 96.0;
const ROW: f32 = 152.0;
#[derive(Clone, Copy, PartialEq)]
pub(crate) enum Pane {
Pages,
Marks,
Notes,
Files,
}
/// Pages dragged in the list.
#[derive(Clone)]
struct Dragged {
view: gpui::EntityId,
pages: BTreeSet<usize>,
}
pub struct Side {
viewer: WeakEntity<Viewer>,
pub(crate) pane: Pane,
/// The view shown, watched for new pictures and edits.
watched: Option<(gpui::EntityId, gpui::Subscription)>,
}
impl Side {
pub fn new(viewer: &Entity<Viewer>, cx: &mut Context<Self>) -> Self {
cx.observe(viewer, |_, _, cx| cx.notify()).detach();
Side { viewer: viewer.downgrade(), pane: Pane::Pages, watched: None }
}
/// Follows `view`, dropping the one followed before.
fn watch(&mut self, view: &Entity<PdfView>, cx: &mut Context<Self>) {
if self.watched.as_ref().is_some_and(|(id, _)| *id == view.entity_id()) {
return;
}
let watching = cx.observe(view, |_, _, cx| cx.notify());
self.watched = Some((view.entity_id(), watching));
}
}
/// The page commands on a right click.
fn popup(cx: &App) -> Popup {
let item = |key: &str, action: Box<dyn Action>| {
PopupItem::new(Viewer::text(cx, key, &[]))
.on_click(move |window, cx| window.dispatch_action(action.boxed_clone(), cx))
};
Popup::new()
.item(item("pdf.pages.blank", Box::new(InsertBlankPage)))
.item(item("pdf.pages.file", Box::new(InsertPagesFromFile)))
.item(item("pdf.pages.delete", Box::new(DeletePages)))
.separator()
.item(item("pdf.pages.rotate_left", Box::new(RotatePagesLeft)))
.item(item("pdf.pages.rotate_right", Box::new(RotatePagesRight)))
.item(item("pdf.pages.crop", Box::new(CropPages)))
.item(item("pdf.pages.extract", Box::new(ExtractPages)))
}
impl PdfView {
/// Picks `page` as a click with these keys does.
pub fn pick(&mut self, page: usize, (range, toggle): (bool, bool), cx: &mut Context<Self>) {
match (range, self.anchor) {
(true, Some(anchor)) => self.picked = (anchor.min(page)..=anchor.max(page)).collect(),
_ if toggle => {
if !self.picked.remove(&page) {
self.picked.insert(page);
}
self.anchor = Some(page);
}
_ => {
self.picked = BTreeSet::from([page]);
self.anchor = Some(page);
self.show(page, 0.0, cx);
}
}
cx.notify();
}
/// Asks for the first missing picture in `rows`.
fn ask_thumb(&mut self, rows: Range<usize>, (sf, dark): (f32, bool), cx: &mut Context<Self>) {
if self.thumbing {
return;
}
let Some(page) = rows.clone().find(|page| !self.thumbs.contains_key(&(*page, dark)) && !self.thumbs_failed.contains(page)) else {
return;
};
let (w, h) = self.sizes[page];
let scale = WIDTH * sf / w;
let tile = [0, 0, (w * scale).ceil() as i32, (h * scale).ceil() as i32];
let (doc, version) = (self.doc, self.version);
self.thumbing = true;
self.off(
move |worker| worker.render(doc, page, (scale, 0), tile, dark),
move |view, painted, cx| {
view.thumbing = false;
if view.version != version {
return cx.notify();
}
let painted = painted.map(|drawn| {
if drawn.damaged {
view.found_damage(cx);
}
drawn.bgra
});
match painted.map(|bgra| crate::pages::picture((tile[2] as u32, tile[3] as u32), bgra)) {
Ok(Some(image)) => {
if let Some(old) = view.thumbs.insert((page, dark), image) {
view.retired.push(old);
}
}
Ok(None) => log::error!("pdf: a page picture came back the wrong size"),
Err(error) if ely_pdf_engine::is_stale(&error) => log::info!("pdf: a page picture from before an edit is dropped"),
Err(error) => {
view.thumbs_failed.insert(page);
view.fail("drawing a page picture", &error, cx);
}
}
cx.notify();
},
cx,
);
}
}
/// One row: the page number and its picture.
fn row(view: &Entity<PdfView>, page: usize, dark: bool, cx: &App) -> gpui::AnyElement {
let read = view.read(cx);
let colors = &cx.theme().colors;
let (w, h) = read.sizes[page];
let picked = read.picked.contains(&page);
let picture = read.thumbs.get(&(page, dark)).cloned();
let count = read.sizes.len();
let label = Viewer::text(cx, "pdf.page.label", &[("n", &(page + 1).to_string())]);
let (clicked, pressed, dropped) = (view.clone(), view.clone(), view.clone());
let dragged = Dragged {
view: view.entity_id(),
pages: if picked { read.picked.clone() } else { BTreeSet::from([page]) },
};
let ghost = label.clone();
let height = (h * WIDTH / w).min(ROW - 24.0);
div()
.id(("pdf-thumb", page))
.w_full()
.h(px(ROW))
.tab_index(0)
.focus_ring(cx)
.flex()
.flex_col()
.items_center()
.gap_1()
.pt_2()
.cursor_pointer()
.on_click(move |event, _, cx| {
let keys = event.modifiers();
clicked.update(cx, |view, cx| view.pick(page, (keys.shift, keys.secondary()), cx))
})
.on_mouse_down(gpui::MouseButton::Right, move |_, _, cx| {
pressed.update(cx, |view, cx| {
if !view.picked.contains(&page) {
view.pick(page, (false, false), cx);
}
})
})
.on_drag(dragged, move |_, _, _, cx| DragGhost::new(ghost.clone(), None, cx))
.on_drop(move |drag: &Dragged, _, cx| {
if drag.view != dropped.entity_id() {
return;
}
let pages = drag.pages.clone();
dropped.update(cx, |view, cx| {
view.picked = pages;
view.move_to(page, cx);
})
})
.child(
div()
.w(px(WIDTH))
.h(px(height))
.border_1()
.border_color(if picked { colors.focus } else { colors.border })
.bg(if dark { colors.surface } else { colors.paper })
.when_some(picture, |frame, picture| frame.child(img(picture).size_full())),
)
.child(div().text_xs().text_color(colors.fg_subtle).child((page + 1).to_string()))
.role(Role::ListItem)
.aria_label(label)
.aria_selected(picked)
.aria_position_in_set(page + 1)
.aria_size_of_set(count)
.into_any_element()
}
impl Render for Side {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let Some(view) = self.viewer.upgrade().and_then(|viewer| viewer.read(cx).active_view()) else {
return div().into_any_element();
};
self.watch(&view, cx);
let pane = self.pane;
let this = cx.entity();
let value = match pane {
Pane::Pages => "pages",
Pane::Marks => "marks",
Pane::Notes => "notes",
Pane::Files => "files",
};
let head = SegmentedControl::new("pdf-side-pane", value)
.size(ControlSize::Sm)
.segment("pages", Viewer::text(cx, "pdf.side.pages", &[]), Some(IconName::FileText))
.segment("marks", Viewer::text(cx, "pdf.side.marks", &[]), Some(IconName::Bookmark))
.segment("notes", Viewer::text(cx, "pdf.side.notes", &[]), Some(IconName::StickyNote))
.segment("files", Viewer::text(cx, "pdf.side.files", &[]), Some(IconName::Paperclip))
.on_change(move |value, _, cx| {
this.update(cx, |side, cx| {
side.pane = match value.as_ref() {
"marks" => Pane::Marks,
"notes" => Pane::Notes,
"files" => Pane::Files,
_ => Pane::Pages,
};
cx.notify();
})
});
let body = match pane {
Pane::Marks => crate::marks::outline(&view, cx).into_any_element(),
Pane::Notes => crate::notelist::list(&view, cx),
Pane::Files => crate::attach::list(&view, cx),
Pane::Pages => {
let shown = (window.scale_factor(), cx.theme().is_dark());
let count = view.read(cx).sizes.len();
let rows = view.clone();
let list = uniform_list("pdf-thumbs", count, move |range, _, cx| {
rows.update(cx, |view, cx| view.ask_thumb(range.clone(), shown, cx));
range.map(|page| row(&rows, page, shown.1, cx)).collect()
})
.size_full();
div()
.id("pdf-pages-list")
.size_full()
.role(Role::List)
.aria_label(Viewer::text(cx, "pdf.side.pages", &[]))
.key_context(PAGES)
.child(ContextMenu::new("pdf-thumbs-menu", popup(cx)).size_full().child(list))
.into_any_element()
}
};
div()
.size_full()
.flex()
.flex_col()
.child(div().p_2().child(head))
.child(div().flex_1().min_h_0().child(body))
.into_any_element()
}
}
+337
View File
@@ -0,0 +1,337 @@
//! Signatures: checked beside the pages, and signed copies (PDF-12, QA-15).
use std::{io, path::PathBuf};
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
data_display::DescriptionList,
feedback::EmptyState,
forms::{Input, TextInput},
overlays::Dialog as Card,
primitives::{IconName, Severity},
theme::ActiveTheme as _,
};
use ely_office::{Closing as _, Saving as _, Tabs as _, in_window};
use ely_pdf_engine::{Digest, Signature, Signed, Signing};
use ely_platform::trust::{self, Trust};
use gpui::{AnyElement, App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, StatefulInteractiveElement as _, Styled as _, TaskExt as _, Window, div};
use crate::redact::beside;
use crate::view::PdfView;
use crate::viewer::Viewer;
/// A signature field and the system's word on its certificate.
pub struct Checked {
pub field: Signature,
pub trust: Option<Result<Trust, String>>,
}
/// Where a new signature goes, set by the sign tool.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SignAt {
pub page: usize,
/// An empty field's object, or a new field.
pub object: Option<u32>,
pub rect: [f32; 4],
pub version: u64,
}
/// The certificate and words for a new signature.
pub struct Signer {
pub pfx: Option<PathBuf>,
pub password: Entity<TextInput>,
pub reason: Entity<TextInput>,
pub location: Entity<TextInput>,
}
impl PdfView {
/// Reads and checks the signatures of the file at `path`.
pub fn ask_signatures(&mut self, path: PathBuf, cx: &mut Context<Self>) {
if self.signatures.as_ref().is_some_and(|(read, _)| *read == path) || self.reading_signatures {
return;
}
self.reading_signatures = true;
let (doc, asked) = (self.doc, path.clone());
self.off(
move |worker| {
let fields = worker.signatures(doc, &path)?;
Ok(fields.into_iter().map(|field| {
let chain = field.signed.as_ref().map(|signed| signed.certs.clone()).filter(|certs| !certs.is_empty());
let trust = chain.map(|chain| trust::check(&chain).map_err(|error| error.to_string()));
Checked { field, trust }
}))
.map(Iterator::collect::<Vec<_>>)
},
move |view, read: io::Result<Vec<Checked>>, cx| {
view.reading_signatures = false;
match read {
Ok(read) => view.signatures = Some((asked, read)),
Err(error) => view.fail("reading signatures", &error, cx),
}
cx.notify();
},
cx,
);
}
}
fn integrity(signed: &Signed, cx: &App) -> gpui::SharedString {
let key = match (signed.digest, signed.later, signed.broken) {
(Digest::Unreadable, ..) => return Viewer::text(cx, "pdf.sign.unreadable", &[("why", signed.error.as_deref().unwrap_or(""))]),
(Digest::Changed, ..) => "pdf.sign.changed",
(Digest::Intact, false, _) => "pdf.sign.intact",
(Digest::Intact, true, false) => "pdf.sign.later",
(Digest::Intact, true, true) => "pdf.sign.broken",
};
Viewer::text(cx, key, &[])
}
fn trusted(trust: &Option<Result<Trust, String>>, cx: &App) -> gpui::SharedString {
match trust {
Some(Ok(Trust::Trusted)) => Viewer::text(cx, "pdf.sign.trusted", &[]),
Some(Ok(Trust::Untrusted(why))) => Viewer::text(cx, "pdf.sign.untrusted", &[("why", why)]),
Some(Err(why)) => Viewer::text(cx, "pdf.sign.unchecked", &[("why", why)]),
None => Viewer::text(cx, "pdf.sign.no_certificate", &[]),
}
}
/// One signature's facts, each on its own row.
fn facts(checked: &Checked, at: usize, view: &Entity<PdfView>, cx: &App) -> AnyElement {
let (field, label) = (&checked.field, |key: &str| Viewer::text(cx, key, &[]));
let mut rows = DescriptionList::new();
let who = match &field.signed {
None => {
rows = rows.item(label("pdf.sign.state"), label("pdf.sign.empty"));
field.name.clone()
}
Some(signed) => {
rows = rows
.item(label("pdf.sign.integrity"), integrity(signed, cx))
.item(label("pdf.sign.trust"), trusted(&checked.trust, cx))
.item(label("pdf.sign.revoked"), label("pdf.sign.revocation"))
.item(label("pdf.sign.when"), crate::props::date(&signed.date));
for (key, value) in [("pdf.sign.reason", &signed.reason), ("pdf.sign.location", &signed.location)] {
if !value.is_empty() {
rows = rows.item(label(key), value.clone());
}
}
if signed.common.is_empty() { field.name.clone() } else { signed.common.clone() }
}
};
let go = field.page.map(|page| {
let shown = view.clone();
Button::new(("pdf-sign-page", at), Viewer::text(cx, "pdf.page.label", &[("n", &(page + 1).to_string())]))
.variant(ButtonVariant::Ghost)
.on_click(move |_, window, cx| {
shown.update(cx, |view, cx| view.show(page, 0.0, cx));
window.dispatch_action(Box::new(crate::document::CloseSignatures), cx);
})
});
div()
.flex()
.flex_col()
.gap_1()
.child(div().flex().items_center().justify_between().child(div().font_weight(gpui::FontWeight::SEMIBOLD).text_color(cx.theme().colors.fg).child(who)).children(go))
.child(rows)
.into_any_element()
}
/// The signatures of `view`: integrity, trust, revocation, apart.
pub fn list(view: &Entity<PdfView>, path: Option<PathBuf>, cx: &mut App) -> AnyElement {
let Some(path) = path else {
return EmptyState::new("pdf-signs-none", IconName::File, Viewer::text(cx, "pdf.sign.none", &[])).into_any_element();
};
view.update(cx, |view, cx| view.ask_signatures(path.clone(), cx));
let read = view.read(cx);
let Some(fields) = read.signatures.as_ref().filter(|(read, _)| *read == path).map(|(_, fields)| fields) else {
return div().into_any_element();
};
if fields.is_empty() {
return EmptyState::new("pdf-signs-none", IconName::File, Viewer::text(cx, "pdf.sign.none", &[])).into_any_element();
}
let blocks: Vec<AnyElement> = fields.iter().enumerate().map(|(at, checked)| facts(checked, at, view, cx)).collect();
div().id("pdf-signs").flex().flex_col().gap_4().overflow_y_scroll().children(blocks).into_any_element()
}
impl Viewer {
/// The signatures dialog, while open.
pub fn signs_element(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let (view, tab) = (self.active_view().filter(|_| self.signs_open)?, self.active_tab()?);
let title = Self::text(cx, "pdf.sign.list", &[("name", &tab.name)]);
let this = cx.entity();
let dismiss = move |_: &mut Window, cx: &mut App| this.update(cx, |viewer, cx| viewer.show_signatures(false, cx));
let path = tab.session.path().map(PathBuf::from);
let body = div().max_h(gpui::px(480.0)).overflow_hidden().child(list(&view, path, cx));
let done = Self::text(cx, "pdf.sign.close", &[]);
let close = move |close: ely_gpui_component::overlays::Close| {
Button::new("pdf-signs-close", done).variant(ButtonVariant::Primary).on_click(move |_, window, cx| close(window, cx))
};
Some(Card::new("pdf-signs", title, dismiss).child(body).action(close).into_any_element())
}
pub fn show_signatures(&mut self, open: bool, cx: &mut Context<Self>) {
let Some(view) = self.active_view().filter(|_| open) else {
self.signs_open = false;
return if open { self.no_document(cx) } else { cx.notify() };
};
// Each opening checks the file as it is now.
view.update(cx, |view, _| view.signatures = None);
self.signs_open = open;
cx.notify();
}
/// Turns the sign tool on with a fresh certificate form.
pub fn ask_sign(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let input = |masked: bool, window: &mut Window, cx: &mut Context<Self>| {
cx.new(|cx| if masked { TextInput::new(window, cx).masked() } else { TextInput::new(window, cx) })
};
let (password, reason, location) = (input(true, window, cx), input(false, window, cx), input(false, window, cx));
self.signer = Some(Signer { pfx: None, password, reason, location });
self.toggle_tool(crate::content::Tool::Sign, cx);
}
fn choose_certificate(&mut self, cx: &mut Context<Self>) {
self.pick(false, cx, |viewer, path, cx| {
if let Some(signer) = viewer.signer.as_mut() {
signer.pfx = Some(path);
}
cx.notify();
});
}
fn cancel_sign(&mut self, cx: &mut Context<Self>) {
if let Some(view) = self.active_view() {
view.update(cx, |view, cx| {
view.sign_at = None;
cx.notify();
});
}
}
/// Asks where the signed copy goes, then writes it.
fn choose_signed(&mut self, cx: &mut Context<Self>) {
let (Some(open), Some(view)) = (self.active_tab(), self.active_view()) else {
return;
};
let Some(at) = view.read(cx).sign_at else {
return;
};
let Some(signer) = self.signer.as_ref() else {
return;
};
let Some(pfx) = signer.pfx.as_ref().and_then(|path| path.to_str()).map(str::to_string) else {
return;
};
let text = |field: &Entity<TextInput>| field.read(cx).text().to_string();
let how = Signing {
page: at.page,
object: at.object,
rect: at.rect,
name: String::new(),
pfx,
pfx_password: text(&signer.password),
reason: text(&signer.reason),
location: text(&signer.location),
};
let Some(dir) = ely_office::folder(open) else {
return self.panel_failed("no documents folder", cx);
};
let tab = open.id;
let chosen = cx.prompt_for_new_path(&dir, Some(&beside(&open.name, &Self::text(cx, "pdf.sign.tail", &[]))));
cx.spawn(async move |this, cx| {
let chosen = match chosen.await {
Ok(Ok(path)) => Ok(path),
Ok(Err(error)) => Err(error.to_string()),
Err(error) => Err(error.to_string()),
};
in_window(&this, cx, |viewer, _, cx| match chosen {
Ok(Some(target)) => viewer.write_signed(tab, at, how, target, cx),
Ok(None) => log::info!("pdf: the sign panel was cancelled"),
Err(error) => viewer.panel_failed(&error, cx),
})
})
.detach_and_log_err(cx);
}
/// Signs into `target` if nothing changed since the drag.
pub(crate) fn write_signed(&mut self, tab: u64, at: SignAt, mut how: Signing, target: PathBuf, cx: &mut Context<Self>) {
let (Some(open), Some(view)) = (self.tab(tab), self.tab(tab).map(|tab| tab.view.clone())) else {
return;
};
let read = view.read(cx);
if read.version != at.version || !read.settled() || read.sign_at != Some(at) {
return self.toast(Self::text(cx, "pdf.object.changed", &[]), Severity::Warning, cx);
}
let (shown, opened) = (ely_office::files::file_name(&target), target.clone());
match self.holder(&target) {
Ok(None) => {}
Ok(Some(_)) => return self.toast(Self::text(cx, "pdf.extract.taken", &[("name", &shown)]), Severity::Danger, cx),
Err(error) => return self.failed(&error, cx),
}
let (work, path, revision) = (open.work.join("signed.pdf"), open.session.path().map(PathBuf::from), open.session.revision());
log::info!("pdf: writes a signed copy");
let placed = view.clone();
self.read_in(
tab,
move |worker, doc| {
let worker = worker.get()?;
if how.object.is_none() {
let names = worker.field_names(*doc)?;
how.name = (1..).map(|n| format!("Signature{n}")).find(|name| !names.contains(name)).expect("a free name");
}
worker.sign(*doc, &work, &how)?;
let bytes = std::fs::read(&work)?;
std::fs::remove_file(&work)?;
ely_files::export(&target, path.as_deref(), &bytes, revision).map(|_| ())
},
move |viewer, done, _, cx| match done {
Ok(()) => {
placed.update(cx, |view, cx| {
if view.sign_at == Some(at) {
view.sign_at = None;
cx.notify();
}
});
viewer.toast_output(Self::text(cx, "pdf.sign.done", &[("name", &shown)]), opened.clone(), cx);
}
Err(error) => viewer.failed(&error, cx),
},
cx,
);
}
/// The signing form, once a place is chosen.
pub fn sign_element(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let signer = self.signer.as_ref()?;
self.active_view()?.read(cx).sign_at?;
let this = cx.entity();
let label = |key: &str| Self::text(cx, key, &[]);
let chosen = signer.pfx.as_ref().map_or_else(|| label("pdf.sign.no_file"), |path| ely_office::files::file_name(path));
let pick = this.clone();
let rows = DescriptionList::new()
.item(
label("pdf.sign.certificate"),
div().flex().gap_2().items_center().child(div().flex_1().child(chosen)).child(
Button::new("pdf-sign-pick", label("pdf.sign.choose"))
.on_click(move |_, _, cx| pick.update(cx, |viewer, cx| viewer.choose_certificate(cx))),
),
)
.item(label("pdf.sign.password"), div().w_full().child(Input::new(&signer.password)))
.item(label("pdf.sign.reason"), div().w_full().child(Input::new(&signer.reason)))
.item(label("pdf.sign.location"), div().w_full().child(Input::new(&signer.location)));
let closed = this.clone();
let dismiss = move |_: &mut Window, cx: &mut App| closed.update(cx, |viewer, cx| viewer.cancel_sign(cx));
let (ready, go) = (signer.pfx.is_some(), label("pdf.removal.go"));
let card = Card::new("pdf-sign", label("pdf.sign.title"), dismiss)
.detail(label("pdf.sign.detail"))
.child(rows)
.action(ely_office::cancel(cx))
.action(move |_| {
Button::new("pdf-sign-go", go)
.variant(ButtonVariant::Primary)
.disabled(!ready)
.on_click(move |_, _, cx| this.update(cx, |viewer, cx| viewer.choose_signed(cx)))
});
Some(card.into_any_element())
}
}
+200
View File
@@ -0,0 +1,200 @@
//! The viewer against the real worker and generated PDFs (C11.1).
use std::sync::{Arc, Mutex};
use ely_office::{Opening as _, Tabs as _};
use ely_pdf_engine::{Worker, sample::pdf};
use gpui::{Entity, TestAppContext, VisualTestContext};
use crate::engine::Mupdf;
use crate::layout::Mode;
use crate::select::Selection;
use crate::view::PdfView;
use crate::viewer::Viewer;
mod editing;
mod input;
mod notes;
mod organize;
mod output;
mod protect;
mod removal;
mod sign;
mod structure;
mod view;
mod visible;
/// Steps and redraws until `done`, at most ten seconds.
pub(crate) fn settle(cx: &mut VisualTestContext, done: impl Fn(&gpui::App) -> bool) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while std::time::Instant::now() < deadline {
// Bounded: a busy loop must fail here, not hang.
for _ in 0..100 {
if !cx.executor().tick() {
break;
}
}
cx.update(|window, _| window.refresh());
if cx.update(|_, app| done(app)) {
return;
}
}
panic!("the state never settled");
}
/// A few redraws with the work they start.
pub(crate) fn frames(cx: &mut VisualTestContext) {
for _ in 0..5 {
cx.update(|window, _| window.refresh());
for _ in 0..20 {
cx.executor().tick();
}
}
}
pub(crate) fn file(dir: &std::path::Path, name: &str, pages: &[(f32, f32, &[&str])]) -> std::path::PathBuf {
let path = dir.join(name);
std::fs::write(&path, pdf(pages, &[])).expect("writes");
path
}
/// A viewer drawn in a window, `paths` opened in order.
pub(crate) fn windowed<'a>(
cx: &'a mut TestAppContext,
dir: &std::path::Path,
paths: &[std::path::PathBuf],
worker: bool,
) -> (Entity<Viewer>, &'a mut VisualTestContext) {
cx.update(ely_gpui_component::init_for_tests);
cx.update(|cx| {
ely_ui::settings::install_defaults(cx);
ely_ui::i18n::install(cx);
});
cx.update(crate::keys::bind);
let held = match worker {
true => Mupdf::with(Worker::spawn().expect("the worker")),
false => Mupdf::default(),
};
let work = dir.join("work");
std::fs::create_dir_all(&work).expect("a work dir");
let (viewer, vcx) = cx.add_window_view(move |window, cx| {
let mut viewer = Viewer::new(Arc::new(Mutex::new(held)), work, cx);
viewer.core.window = Some(window.window_handle());
viewer
});
for path in paths {
let count = vcx.update(|_, app| viewer.read(app).core.tabs.len());
vcx.update(|_, app| viewer.update(app, |viewer, cx| viewer.open_file(path.clone(), cx)));
settle(vcx, |app| viewer.read(app).core.tabs.len() == count + 1);
}
(viewer, vcx)
}
/// The active tab's view.
pub(crate) fn shown(cx: &mut VisualTestContext, viewer: &Entity<Viewer>) -> Entity<PdfView> {
cx.update(|_, app| viewer.read(app).active_view().expect("a tab"))
}
/// Three pages with "alpha" on one and three, and bookmarks.
fn opened<'a>(cx: &'a mut TestAppContext, dir: &std::path::Path) -> (Entity<Viewer>, &'a mut VisualTestContext) {
let pages: &[(f32, f32, &[&str])] = &[
(612.0, 792.0, &["alpha one", "second line"]),
(612.0, 792.0, &["middle page"]),
(612.0, 792.0, &["alpha two alpha"]),
];
let path = dir.join("three.pdf");
std::fs::write(&path, pdf(pages, &[("Start", 0, &[("Inside", 1)]), ("End", 2, &[])])).expect("writes");
windowed(cx, dir, &[path], true)
}
#[test]
fn a_pdf_opens_with_its_pages_and_bookmarks() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx) = opened(&mut cx, dir.path());
let view = shown(cx, &viewer);
let (sizes, marks) = cx.update(|_, app| {
let view = view.read(app);
(view.sizes.clone(), view.marks.iter().map(|m| (m.title.clone(), m.down.len())).collect::<Vec<_>>())
});
assert_eq!(sizes, [(612.0, 792.0); 3]);
assert_eq!(marks, [("Start".to_string(), 1), ("End".to_string(), 0)]);
}
#[test]
fn find_steps_through_every_hit_across_pages() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx) = opened(&mut cx, dir.path());
let view = shown(cx, &viewer);
cx.update(|window, app| viewer.update(app, |viewer, cx| viewer.open_find(window, cx)));
cx.simulate_keystrokes("a l p h a");
settle(cx, |app| view.read(app).found.len() == 3 && viewer.read(app).find.as_ref().is_some_and(|find| find.done == 3));
let at = |cx: &mut VisualTestContext| cx.update(|_, app| {
let view = view.read(app);
(view.chosen, view.found[view.chosen.expect("chosen")].0)
});
assert_eq!(at(cx), (Some(0), 0), "the first hit is chosen");
cx.simulate_keystrokes("enter");
cx.simulate_keystrokes("enter");
assert_eq!(at(cx), (Some(2), 2), "on to page three");
cx.simulate_keystrokes("escape");
assert!(cx.update(|_, app| viewer.read(app).find.is_none()), "escape closes it");
assert!(cx.update(|_, app| view.read(app).found.is_empty()), "its marks go");
}
#[test]
fn a_selection_across_pages_copies_its_lines() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx) = opened(&mut cx, dir.path());
let view = shown(cx, &viewer);
// Ends at real characters: page two's eighth, page one's seventh.
cx.update(|_, app| view.update(app, |view, cx| {
view.page_text(0, cx);
view.page_text(1, cx);
}));
settle(cx, |app| view.read(app).text.len() == 2);
let centre = |cx: &mut VisualTestContext, page: usize, at: usize| cx.update(|_, app| {
let r = view.read(app).text[&page][at].rect;
(page, ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0))
});
let (anchor, head) = (centre(cx, 1, 7), centre(cx, 0, 6));
cx.update(|_, app| view.update(app, |view, cx| {
view.selection = Some(Selection { anchor, head });
view.copy(cx);
}));
settle(cx, |app| app.read_from_clipboard().is_some());
let text = cx.update(|_, app| app.read_from_clipboard().and_then(|item| item.text()));
assert_eq!(text.as_deref(), Some("one\nsecond line\nmiddle p"), "both ends included");
}
#[test]
fn a_view_change_keeps_the_page_being_read() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx) = opened(&mut cx, dir.path());
let view = shown(cx, &viewer);
cx.update(|_, app| view.update(app, |view, cx| view.show(2, 0.0, cx)));
settle(cx, |app| view.read(app).current == 2);
cx.update(|_, app| view.update(app, |view, cx| view.change(|view, _| view.mode = Mode::Single, cx)));
let pages = cx.update(|_, app| view.read(app).laid_out().placed.iter().map(|p| p.page).collect::<Vec<_>>());
assert_eq!(pages, [2], "single page shows the page read");
}
#[test]
fn a_file_with_no_pages_is_refused_and_left_closed() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx) = windowed(&mut cx, dir.path(), &[], true);
let path = file(dir.path(), "empty.pdf", &[]);
let worker = cx.update(|_, app| viewer.read(app).core.worker.clone());
let failed = <crate::engine::Pdf as ely_office::Engine>::load(
&mut worker.lock().expect("the lock"),
ely_office::Load::File(&path, ""),
);
assert_eq!(failed.err().map(|error| error.kind()), Some(std::io::ErrorKind::InvalidData));
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.open_file(path.clone(), cx)));
settle(cx, |app| viewer.read(app).core.loading.is_empty());
assert!(cx.update(|_, app| viewer.read(app).active_tab().is_none()));
}
+341
View File
@@ -0,0 +1,341 @@
//! Editing text and objects from the app (C11.3).
use ely_pdf_engine::sample::{Extras, pdf_with};
use gpui::{Entity, Modifiers, MouseButton, TestAppContext, VisualTestContext};
use super::input::over;
use super::{frames, settle, shown, windowed};
use crate::content::{Aim, Tool};
use crate::view::PdfView;
use crate::viewer::Viewer;
/// Two lines, a stamp, a picture; the edit tool on.
fn editing<'a>(cx: &'a mut TestAppContext, dir: &std::path::Path) -> (Entity<Viewer>, &'a mut VisualTestContext, Entity<PdfView>) {
let path = dir.join("doc.pdf");
let extras = Extras { stamp: Some("STAMP"), picture: true, ..Extras::default() };
std::fs::write(&path, pdf_with(&[(612.0, 792.0, &["Hello world", "Second line"])], &[], &extras)).expect("writes");
let (viewer, cx) = windowed(cx, dir, &[path], true);
let view = shown(cx, &viewer);
settle(cx, |app| view.read(app).room.size.width > gpui::px(100.0));
// At actual points the whole page is in view.
cx.update(|_, app| view.update(app, |view, cx| {
view.zoom = crate::layout::Zoom::Scale(1.0);
view.set_tool(Tool::Edit, cx)
}));
settle(cx, |app| view.read(app).layers.contains_key(&0));
(viewer, cx, view)
}
/// Presses at page point `at`, with no drag.
fn click(cx: &mut VisualTestContext, view: &Entity<PdfView>, at: (f32, f32)) {
let point = over(cx, view, 0, at);
cx.simulate_mouse_down(point, MouseButton::Left, Modifiers::none());
cx.simulate_mouse_up(point, MouseButton::Left, Modifiers::none());
}
/// The page's blocks once the layer is read again.
fn blocks(cx: &mut VisualTestContext, view: &Entity<PdfView>) -> Vec<String> {
settle(cx, |app| view.read(app).settled() && view.read(app).layers.contains_key(&0));
cx.update(|_, app| view.read(app).layers[&0].blocks.iter().map(|b| b.text.clone()).collect())
}
/// Puts `text` in the panel's field.
fn type_in(cx: &mut VisualTestContext, viewer: &Entity<Viewer>, text: &str) {
frames(cx);
cx.update(|_, app| {
let field = viewer.read(app).edit_panel.read(app).field.clone().expect("drawn");
field.update(app, |field, cx| field.set_text(text, cx));
});
}
#[test]
fn a_block_edits_in_its_own_font_without_a_preview() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx, view) = editing(&mut cx, dir.path());
// Hello world's first line sits 72 points down.
click(cx, &view, (80.0, 66.0));
assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::Text { .. })));
type_in(cx, &viewer, "Hi world\nSecond line");
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.write_text(false, cx)));
let now = blocks(cx, &view);
assert!(now.iter().any(|b| b.starts_with("Hi world")) && !now.iter().any(|b| b.contains("Hello")), "{now:?}");
assert!(cx.update(|_, app| viewer.read(app).preview.is_none()), "same font, same space: no preview");
}
#[test]
fn another_font_shows_the_change_first_and_a_missing_glyph_says_so() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx, view) = editing(&mut cx, dir.path());
click(cx, &view, (80.0, 66.0));
type_in(cx, &viewer, "你好");
cx.update(|_, app| viewer.update(app, |viewer, cx| {
viewer.edit_panel.update(cx, |panel, _| panel.font = "helvetica".into());
viewer.write_text(false, cx)
}));
settle(cx, |app| viewer.read(app).edit_panel.read(app).note.is_some());
assert!(cx.update(|_, app| view.read(app).edited) == 0, "nothing changed");
cx.update(|_, app| viewer.update(app, |viewer, cx| {
viewer.edit_panel.update(cx, |panel, _| panel.font = "cjk-sc".into());
viewer.write_text(false, cx)
}));
settle(cx, |app| viewer.read(app).preview.is_some());
cx.update(|_, app| viewer.update(app, |viewer, cx| {
let preview = viewer.preview.take().expect("shown");
viewer.commit_text((preview.tab, preview.version, preview.page), preview.writing, cx)
}));
assert!(blocks(cx, &view).iter().any(|b| b.contains("你好")));
}
#[test]
fn an_image_drags_and_scales_from_its_corner() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (_, cx, view) = editing(&mut cx, dir.path());
let rect = |cx: &mut VisualTestContext| cx.update(|_, app| view.read(app).layers[&0].drawn[1].rect);
let (from, to) = (over(cx, &view, 0, (122.0, 267.0)), over(cx, &view, 0, (172.0, 287.0)));
cx.simulate_mouse_down(from, MouseButton::Left, Modifiers::none());
cx.simulate_mouse_move(to, MouseButton::Left, Modifiers::none());
cx.simulate_mouse_up(to, MouseButton::Left, Modifiers::none());
settle(cx, |app| view.read(app).settled() && view.read(app).layers.contains_key(&0));
let moved = rect(cx);
assert!((moved[0] - 122.0).abs() < 0.5 && (moved[1] - 262.0).abs() < 0.5, "{moved:?}");
// Edits drop the aim; aim, then pull the corner.
click(cx, &view, (150.0, 280.0));
let (corner, out) = (over(cx, &view, 0, (222.0, 312.0)), over(cx, &view, 0, (322.0, 362.0)));
cx.simulate_mouse_down(corner, MouseButton::Left, Modifiers::none());
cx.simulate_mouse_move(out, MouseButton::Left, Modifiers::none());
cx.simulate_mouse_up(out, MouseButton::Left, Modifiers::none());
settle(cx, |app| view.read(app).settled() && view.read(app).layers.contains_key(&0));
let scaled = rect(cx);
assert!((scaled[2] - 322.0).abs() < 0.5 && (scaled[3] - 362.0).abs() < 0.5, "{scaled:?}");
}
#[test]
fn new_text_lands_where_pressed_and_objects_delete() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx, view) = editing(&mut cx, dir.path());
cx.update(|_, app| view.update(app, |view, cx| view.set_tool(Tool::AddText, cx)));
click(cx, &view, (100.0, 150.0));
assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::New { page: 0, .. })));
type_in(cx, &viewer, "Added");
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.write_text(false, cx)));
settle(cx, |app| view.read(app).edited == 1);
cx.update(|_, app| view.update(app, |view, cx| view.set_tool(Tool::Edit, cx)));
assert!(blocks(cx, &view).iter().any(|b| b == "Added"));
click(cx, &view, (310.0, 370.0));
assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::Object { index: 0, .. })), "the stamp");
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.delete_object(cx)));
assert!(!blocks(cx, &view).iter().any(|b| b == "STAMP"));
}
#[test]
fn escape_leaves_the_edit_tool() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (_, cx, view) = editing(&mut cx, dir.path());
cx.update(|window, app| {
let focus = view.read(app).focus.clone();
window.focus(&focus, app)
});
cx.simulate_keystrokes("escape");
assert_eq!(cx.update(|_, app| view.read(app).tool), Tool::Read);
}
#[test]
fn a_replaced_image_stays_with_its_own_tab() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx, a) = editing(&mut cx, dir.path());
click(cx, &a, (120.0, 267.0));
let other = super::file(dir.path(), "b.pdf", &[(612.0, 792.0, &["b"])]);
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.replace_image(cx)));
cx.update(|_, app| viewer.update(app, |viewer, cx| ely_office::Opening::open_file(viewer, other.clone(), cx)));
settle(cx, |app| viewer.read(app).core.tabs.len() == 2);
let b = shown(cx, &viewer);
assert_ne!(a, b, "B is in front when the picker answers");
let square = dir.path().join("square.pnm");
std::fs::write(&square, b"P6\n2 2\n255\n\xff\xff\xff\x00\x00\x00\x00\x00\x00\xff\xff\xff").expect("writes");
cx.cx.simulate_path_prompt_response(move |_| Some(vec![square]));
settle(cx, |app| a.read(app).edited == 1);
assert_eq!(cx.update(|_, app| b.read(app).edited), 0);
}
#[test]
fn a_layer_read_from_before_an_edit_is_read_again() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (_, cx, view) = editing(&mut cx, dir.path());
// The read starts before the edit lands, returns after.
cx.update(|_, app| view.update(app, |view, cx| {
view.edit("turning", |worker, doc| worker.rotate_pages(doc, &[0], 90), cx);
view.layers.clear();
view.ask_layers(&[0], cx);
assert!(view.reading_layer);
}));
settle(cx, |app| view.read(app).settled() && view.read(app).layers.contains_key(&0) && !view.read(app).reading_layer);
}
#[test]
fn escape_from_the_text_field_leaves_the_tool() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx, view) = editing(&mut cx, dir.path());
click(cx, &view, (80.0, 66.0));
frames(cx);
cx.update(|window, app| {
let field = viewer.read(app).edit_panel.read(app).field.clone().expect("drawn");
let focus = gpui::Focusable::focus_handle(field.read(app), app);
window.focus(&focus, app)
});
cx.simulate_keystrokes("escape");
assert_eq!(cx.update(|_, app| view.read(app).tool), Tool::Read);
}
#[test]
fn a_preview_goes_once_anything_else_lands() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
// Twin pages: a stale aim would hit the wrong one.
let same: &[(f32, f32, &[&str])] = &[(612.0, 792.0, &["Hello world", "Second line"]), (612.0, 792.0, &["Hello world", "Second line"])];
let (viewer, cx) = windowed(&mut cx, dir.path(), &[super::file(dir.path(), "twins.pdf", same)], true);
let view = shown(cx, &viewer);
settle(cx, |app| view.read(app).room.size.width > gpui::px(100.0));
cx.update(|_, app| view.update(app, |view, cx| {
view.zoom = crate::layout::Zoom::Scale(1.0);
view.set_tool(Tool::Edit, cx)
}));
settle(cx, |app| view.read(app).layers.contains_key(&0));
click(cx, &view, (80.0, 66.0));
type_in(cx, &viewer, "Hi world\nSecond line");
cx.update(|_, app| viewer.update(app, |viewer, cx| {
viewer.edit_panel.update(cx, |panel, _| panel.font = "times".into());
viewer.write_text(false, cx)
}));
settle(cx, |app| viewer.read(app).preview.is_some());
let stale = cx.update(|_, app| {
let preview = viewer.read(app).preview.as_ref().expect("shown");
((preview.tab, preview.version, preview.page), preview.writing.clone())
});
cx.update(|_, app| view.update(app, |view, cx| view.edit("deleting", |worker, doc| worker.delete_pages(doc, &[0]), cx)));
settle(cx, |app| view.read(app).edited == 1 && viewer.read(app).preview.is_none());
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.commit_text(stale.0, stale.1, cx)));
frames(cx);
assert_eq!(cx.update(|_, app| view.read(app).edited), 1, "the old preview writes nothing");
}
#[test]
fn nothing_aimed_before_a_queued_edit_is_done() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx, view) = editing(&mut cx, dir.path());
let version = cx.update(|_, app| view.read(app).version);
// An edit is queued; old numbers may not hold.
cx.update(|_, app| view.update(app, |view, _| view.editing = true));
let ran = std::rc::Rc::new(std::cell::Cell::new(false));
cx.update(|_, app| viewer.update(app, |viewer, cx| {
let ran = ran.clone();
viewer.on_same(&view, version, cx, move |_, _| ran.set(true))
}));
assert!(!ran.get());
}
#[test]
fn a_second_delete_before_the_first_lands_does_nothing() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx, view) = editing(&mut cx, dir.path());
click(cx, &view, (310.0, 370.0));
cx.update(|_, app| viewer.update(app, |viewer, cx| {
viewer.delete_object(cx);
viewer.delete_object(cx);
}));
settle(cx, |app| view.read(app).settled() && view.read(app).layers.contains_key(&0));
assert_eq!(cx.update(|_, app| view.read(app).layers[&0].drawn.len()), 1, "the picture stays");
}
#[test]
fn a_drag_while_an_edit_is_queued_does_nothing() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (_, cx, view) = editing(&mut cx, dir.path());
let (from, to) = (over(cx, &view, 0, (122.0, 267.0)), over(cx, &view, 0, (172.0, 287.0)));
cx.simulate_mouse_down(from, MouseButton::Left, Modifiers::none());
cx.simulate_mouse_move(to, MouseButton::Left, Modifiers::none());
cx.update(|_, app| view.update(app, |view, _| view.editing = true));
cx.simulate_mouse_up(to, MouseButton::Left, Modifiers::none());
assert!(cx.update(|_, app| view.read(app).queue.is_empty()), "nothing queued with old numbers");
}
#[test]
fn the_panel_waits_for_the_drag_so_the_page_stays_put() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (_, cx, view) = editing(&mut cx, dir.path());
let wide = cx.update(|_, app| view.read(app).room.size.width);
let (from, to) = (over(cx, &view, 0, (122.0, 267.0)), over(cx, &view, 0, (140.0, 267.0)));
cx.simulate_mouse_down(from, MouseButton::Left, Modifiers::none());
cx.simulate_mouse_move(to, MouseButton::Left, Modifiers::none());
frames(cx);
assert_eq!(cx.update(|_, app| view.read(app).room.size.width), wide, "no panel mid-drag");
cx.simulate_mouse_up(to, MouseButton::Left, Modifiers::none());
settle(cx, |app| view.read(app).settled());
frames(cx);
cx.update(|_, app| view.update(app, |view, cx| view.press(from, cx)));
cx.update(|_, app| view.update(app, |view, cx| view.release(cx)));
frames(cx);
assert!(cx.update(|_, app| view.read(app).room.size.width) < wide, "the panel opens after");
}
#[test]
fn an_open_panel_stays_open_through_a_second_drag() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (_, cx, view) = editing(&mut cx, dir.path());
let wide = cx.update(|_, app| view.read(app).room.size.width);
click(cx, &view, (122.0, 267.0));
// The panel slides open; wait until the view stops narrowing.
let mut narrow = wide;
for _ in 0..40 {
cx.executor().advance_clock(std::time::Duration::from_millis(50));
frames(cx);
let now = cx.update(|_, app| view.read(app).room.size.width);
if now < wide && now == narrow {
break;
}
narrow = now;
}
assert!(narrow < wide, "the panel opened");
// Pull the picture's corner 50 points right.
let (from, to) = (over(cx, &view, 0, (172.0, 292.0)), over(cx, &view, 0, (222.0, 292.0)));
cx.simulate_mouse_down(from, MouseButton::Left, Modifiers::none());
cx.executor().advance_clock(std::time::Duration::from_millis(500));
frames(cx);
cx.simulate_mouse_move(to, MouseButton::Left, Modifiers::none());
cx.executor().advance_clock(std::time::Duration::from_millis(500));
frames(cx);
assert_eq!(cx.update(|_, app| view.read(app).room.size.width), narrow, "the panel stays mid-drag");
cx.simulate_mouse_up(to, MouseButton::Left, Modifiers::none());
settle(cx, |app| view.read(app).settled() && view.read(app).layers.contains_key(&0));
let rect = cx.update(|_, app| view.read(app).layers[&0].drawn[1].rect);
assert!((rect[2] - 222.0).abs() < 1.0 && (rect[3] - 317.0).abs() < 1.0, "{rect:?}");
}
#[test]
fn a_drag_follows_the_hand_when_the_page_shifts_under_it() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (_, cx, view) = editing(&mut cx, dir.path());
let from = over(cx, &view, 0, (122.0, 267.0));
cx.simulate_mouse_down(from, MouseButton::Left, Modifiers::none());
// The view shifts right, as a panel pushes it.
cx.update(|_, app| view.update(app, |view, _| view.room.origin.x += gpui::px(100.0)));
let to = gpui::point(from.x + gpui::px(50.0), from.y);
cx.simulate_mouse_move(to, MouseButton::Left, Modifiers::none());
cx.simulate_mouse_up(to, MouseButton::Left, Modifiers::none());
settle(cx, |app| view.read(app).settled() && view.read(app).layers.contains_key(&0));
let rect = cx.update(|_, app| view.read(app).layers[&0].drawn[1].rect);
assert!((rect[0] - 122.0).abs() < 0.5, "moved as far as the hand: {rect:?}");
}
+187
View File
@@ -0,0 +1,187 @@
//! Mouse, jumps, search across tabs, failures, tiles (C11.1).
use std::collections::HashSet;
use ely_office::Tabs as _;
use gpui::{Entity, Modifiers, MouseButton, TestAppContext, VisualTestContext, point, px};
use super::{file, settle, shown, windowed};
use crate::layout::Zoom;
use crate::view::PdfView;
use crate::viewer::Viewer;
/// The window point over page points `at` of `page`.
pub(super) fn over(cx: &mut VisualTestContext, view: &Entity<PdfView>, page: usize, at: (f32, f32)) -> gpui::Point<gpui::Pixels> {
cx.update(|_, app| {
let view = view.read(app);
let laid = view.laid_out();
let place = laid.placed.iter().find(|p| p.page == page).expect("placed");
let (offset, origin) = (view.scroll.offset(), view.room.origin);
point(
origin.x + offset.x + px(place.x + at.0 * laid.scale),
origin.y + offset.y + px(place.y + at.1 * laid.scale),
)
})
}
#[test]
fn a_first_drag_picks_text_before_the_page_is_read() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let path = file(dir.path(), "one.pdf", &[(612.0, 792.0, &["alpha beta gamma"])]);
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
let view = shown(cx, &viewer);
settle(cx, |app| view.read(app).room.size.width > px(100.0));
// Glyphs sit near 72 points down; none read yet.
let (from, to) = (over(cx, &view, 0, (74.0, 62.0)), over(cx, &view, 0, (150.0, 62.0)));
cx.simulate_mouse_down(from, MouseButton::Left, Modifiers::none());
cx.simulate_mouse_move(to, MouseButton::Left, Modifiers::none());
cx.simulate_mouse_up(to, MouseButton::Left, Modifiers::none());
cx.update(|_, app| view.update(app, |view, cx| view.copy(cx)));
settle(cx, |app| app.read_from_clipboard().is_some());
let text = cx.update(|_, app| app.read_from_clipboard().and_then(|item| item.text())).expect("copied");
assert!(text.starts_with("alpha"), "{text}");
}
#[test]
fn a_jump_makes_its_page_the_one_being_read() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let pages: &[(f32, f32, &[&str])] = &[(612.0, 792.0, &["one"]), (612.0, 792.0, &["two"]), (612.0, 792.0, &["three"])];
let (viewer, cx) = windowed(&mut cx, dir.path(), &[file(dir.path(), "three.pdf", pages)], true);
let view = shown(cx, &viewer);
settle(cx, |app| view.read(app).room.size.width > px(100.0));
cx.update(|_, app| view.update(app, |view, cx| view.show(1, 0.0, cx)));
super::frames(cx);
assert_eq!(cx.update(|_, app| view.read(app).current), 1);
}
/// A: the word on page three only. B: three pages.
fn two_tabs<'a>(cx: &'a mut TestAppContext, dir: &std::path::Path) -> (Entity<Viewer>, &'a mut VisualTestContext, u64, u64) {
let a = file(dir, "a.pdf", &[(612.0, 792.0, &["one"]), (612.0, 792.0, &["two"]), (612.0, 792.0, &["three alpha"])]);
let b = file(dir, "b.pdf", &[(612.0, 792.0, &["zzz"]), (612.0, 792.0, &["b2"]), (612.0, 792.0, &["b3"])]);
let (viewer, cx) = windowed(cx, dir, &[a, b], true);
let (first, second) = cx.update(|_, app| (viewer.read(app).core.tabs[0].id, viewer.read(app).core.tabs[1].id));
(viewer, cx, first, second)
}
/// Opens find on `tab` and searches `query` at once.
fn find_in(cx: &mut VisualTestContext, viewer: &Entity<Viewer>, tab: u64, query: &str) {
cx.update(|window, app| viewer.update(app, |viewer, cx| {
viewer.core.active = Some(tab);
viewer.open_find(window, cx);
let field = viewer.find.as_ref().expect("open").query.clone();
field.update(cx, |field, cx| field.set_text(query, cx));
}));
}
fn view_of(cx: &mut VisualTestContext, viewer: &Entity<Viewer>, tab: u64) -> Entity<PdfView> {
cx.update(|_, app| viewer.read(app).tab(tab).expect("open").view.clone())
}
#[test]
fn a_hit_found_late_never_moves_another_tab() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx, first, second) = two_tabs(&mut cx, dir.path());
find_in(cx, &viewer, first, "alpha");
// Before the batch comes back, the reader turns to B.
cx.update(|_, app| viewer.update(app, |viewer, _| viewer.core.active = Some(second)));
settle(cx, |app| viewer.read(app).find.as_ref().is_some_and(|find| find.done == 3));
let (a, b) = (view_of(cx, &viewer, first), view_of(cx, &viewer, second));
let state = cx.update(|_, app| (a.read(app).chosen, b.read(app).current));
assert_eq!(state, (Some(0), 0), "the hit is chosen; B stays on its page");
}
#[test]
fn a_new_find_box_takes_no_answers_meant_for_the_old() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx, first, second) = two_tabs(&mut cx, dir.path());
find_in(cx, &viewer, first, "alpha");
find_in(cx, &viewer, second, "zzz");
settle(cx, |app| viewer.read(app).find.as_ref().is_some_and(|find| find.done == 3));
super::frames(cx);
let (a, b) = (view_of(cx, &viewer, first), view_of(cx, &viewer, second));
let pages = cx.update(|_, app| (a.read(app).found.len(), b.read(app).found.iter().map(|h| h.0).collect::<Vec<_>>()));
assert_eq!(pages, (0, vec![0]), "only B's own hit");
}
#[test]
fn a_tile_that_fails_is_not_asked_again() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx) = windowed(&mut cx, dir.path(), &[file(dir.path(), "one.pdf", &[(612.0, 792.0, &["x"])])], true);
let view = shown(cx, &viewer);
// The document goes away behind the viewer's back.
let (worker, doc) = cx.update(|_, app| (viewer.read(app).core.worker.clone(), view.read(app).doc));
worker.lock().expect("the lock").get().expect("started").close(doc).expect("closes");
cx.update(|_, app| view.update(app, |view, cx| {
view.tiles.clear();
cx.notify();
}));
settle(cx, |app| !view.read(app).failed.is_empty());
// Bounded steps: a retry loop would never go quiet.
for _ in 0..10 {
super::frames(cx);
}
assert!(!cx.update(|_, app| view.read(app).painting), "no new tries, nothing waiting");
}
#[test]
fn tiles_scrolled_off_a_long_page_go() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx) = windowed(&mut cx, dir.path(), &[file(dir.path(), "long.pdf", &[(612.0, 14400.0, &["top"])])], true);
let view = shown(cx, &viewer);
cx.update(|_, app| view.update(app, |view, cx| {
view.zoom = Zoom::Scale(1.0);
cx.notify();
}));
settle(cx, |app| view.read(app).tiles.keys().any(|key| key.ty == 0));
cx.update(|_, app| view.update(app, |view, cx| {
view.go_to = Some((0.0, 4000.0));
cx.notify();
}));
settle(cx, |app| !view.read(app).tiles.is_empty() && view.read(app).tiles.keys().all(|key| key.ty > 0));
}
#[test]
fn no_worker_runs_before_a_file_opens() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx) = windowed(&mut cx, dir.path(), &[], false);
let started = |cx: &mut VisualTestContext| cx.update(|_, app| viewer.read(app).core.worker.lock().expect("the lock").started());
assert!(!started(cx));
let path = file(dir.path(), "plain.pdf", &[(612.0, 792.0, &["no marks"])]);
cx.update(|_, app| viewer.update(app, |viewer, cx| ely_office::Opening::open_file(viewer, path.clone(), cx)));
settle(cx, |app| viewer.read(app).active_tab().is_some());
assert!(started(cx));
}
#[test]
fn tiles_beside_the_view_are_neither_asked_nor_kept() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx) = windowed(&mut cx, dir.path(), &[file(dir.path(), "wide.pdf", &[(612.0, 792.0, &["x"])])], true);
let view = shown(cx, &viewer);
settle(cx, |app| view.read(app).room.size.width > px(100.0));
cx.update(|_, app| view.update(app, |view, cx| {
view.zoom = Zoom::Scale(4.0);
cx.notify();
}));
let columns = |app: &gpui::App| view.read(app).tiles.keys().map(|key| key.tx).collect::<HashSet<_>>();
let last = cx.update(|window, app| {
let laid = view.read(app).laid_out();
((laid.placed[0].w * window.scale_factor()).ceil() as i32 - 1) / crate::pages::TILE
});
settle(cx, |app| columns(app).contains(&0) && !view.read(app).painting);
assert!(!cx.update(|_, app| columns(app)).contains(&last), "the right edge is off screen");
cx.update(|_, app| view.update(app, |view, cx| {
let wide = view.laid_out().size.0 - f32::from(view.room.size.width);
let y = view.scroll.offset().y;
view.scroll.set_offset(point(px(-wide), y));
cx.notify();
}));
settle(cx, |app| columns(app).contains(&last) && !columns(app).contains(&0));
}
+317
View File
@@ -0,0 +1,317 @@
//! Annotations and forms from the app (C11.4).
use ely_pdf_engine::{FieldKind, Marks, sample::{Extras, pdf_with}};
use gpui::{Entity, Modifiers, MouseButton, TestAppContext, VisualTestContext};
use super::input::over;
use super::{frames, settle, shown, windowed};
use crate::annotate::Pick;
use crate::content::{Aim, Tool};
use crate::marking::Change;
use crate::view::PdfView;
use crate::viewer::Viewer;
/// Two lines and a text field, actual size.
fn marking<'a>(cx: &'a mut TestAppContext, dir: &std::path::Path) -> (Entity<Viewer>, &'a mut VisualTestContext, Entity<PdfView>) {
let path = dir.join("doc.pdf");
let extras = Extras { fields: &[0], ..Extras::default() };
std::fs::write(&path, pdf_with(&[(612.0, 792.0, &["Hello world", "Second line"])], &[], &extras)).expect("writes");
let (viewer, cx) = windowed(cx, dir, &[path], true);
let view = shown(cx, &viewer);
settle(cx, |app| view.read(app).room.size.width > gpui::px(100.0));
cx.update(|_, app| view.update(app, |view, cx| {
view.zoom = crate::layout::Zoom::Scale(1.0);
cx.notify();
}));
settle(cx, |app| view.read(app).notes.contains_key(&0));
(viewer, cx, view)
}
fn tool(cx: &mut VisualTestContext, view: &Entity<PdfView>, tool: Tool) {
cx.update(|_, app| view.update(app, |view, cx| view.set_tool(tool, cx)));
}
/// Presses at `from`, moves through `through`, lets go.
fn drag(cx: &mut VisualTestContext, view: &Entity<PdfView>, from: (f32, f32), through: &[(f32, f32)]) {
// A hand presses what the last frame shows.
frames(cx);
let start = over(cx, view, 0, from);
cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::none());
let mut end = start;
for at in through {
end = over(cx, view, 0, *at);
cx.simulate_mouse_move(end, MouseButton::Left, Modifiers::none());
}
cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::none());
}
fn click(cx: &mut VisualTestContext, view: &Entity<PdfView>, at: (f32, f32)) {
drag(cx, view, at, &[]);
}
/// Page one's notes once every edit has landed.
fn notes(cx: &mut VisualTestContext, view: &Entity<PdfView>) -> Marks {
settle(cx, |app| view.read(app).settled() && view.read(app).notes.contains_key(&0));
cx.update(|_, app| view.read(app).notes[&0].clone())
}
/// What the panel shows now, sent `change`.
fn send(cx: &mut VisualTestContext, viewer: &Entity<Viewer>, change: Change) {
frames(cx);
cx.update(|_, app| viewer.update(app, |viewer, cx| {
let shown = viewer.note_panel.read(cx).shown.expect("the panel follows the pick");
viewer.change_note(shown, change, cx)
}));
}
fn kinds(marks: &Marks) -> Vec<String> {
marks.annots.iter().map(|a| a.kind.clone()).collect()
}
#[test]
fn picked_text_highlights_in_one_step() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (_, cx, view) = marking(&mut cx, dir.path());
tool(cx, &view, Tool::Highlight);
drag(cx, &view, (74.0, 66.0), &[(140.0, 66.0)]);
let marks = notes(cx, &view);
assert_eq!(kinds(&marks), ["Highlight"]);
assert!(!marks.annots[0].author.is_empty(), "signed by the account");
assert!(cx.update(|_, app| view.read(app).selection.is_none()), "the pick is spent");
cx.update(|_, app| view.update(app, |view, cx| view.undo(false, cx)));
assert!(notes(cx, &view).annots.is_empty());
}
#[test]
fn shapes_and_ink_land_where_they_were_drawn() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (_, cx, view) = marking(&mut cx, dir.path());
tool(cx, &view, Tool::Square);
drag(cx, &view, (100.0, 400.0), &[(150.0, 420.0), (200.0, 450.0)]);
let square = notes(cx, &view).annots[0].rect;
assert!((square[0] - 100.0).abs() < 3.0 && (square[3] - 450.0).abs() < 3.0, "{square:?}");
tool(cx, &view, Tool::Ink);
drag(cx, &view, (300.0, 500.0), &[(320.0, 520.0), (340.0, 500.0), (360.0, 530.0)]);
tool(cx, &view, Tool::Circle);
click(cx, &view, (400.0, 400.0));
assert_eq!(kinds(&notes(cx, &view)), ["Square", "Ink"], "a click draws no ellipse");
}
#[test]
fn notes_and_stamps_ask_first_then_change_and_go() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx, view) = marking(&mut cx, dir.path());
tool(cx, &view, Tool::Note);
click(cx, &view, (300.0, 300.0));
assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::Note(Pick::NewNote { .. }))));
send(cx, &viewer, Change::Note("Check this".into()));
notes(cx, &view);
tool(cx, &view, Tool::Stamp);
click(cx, &view, (300.0, 600.0));
send(cx, &viewer, Change::Stamp("Draft".into()));
let marks = notes(cx, &view);
assert_eq!((kinds(&marks), marks.annots[0].contents.as_str(), marks.annots[1].icon.as_str()), (vec!["Text".into(), "Stamp".into()], "Check this", "Draft"));
tool(cx, &view, Tool::Read);
click(cx, &view, (305.0, 305.0));
assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::Note(Pick::Annot { .. }))), "a click picks the note");
send(cx, &viewer, Change::Color(0x0000ff));
let marks = notes(cx, &view);
assert_eq!((marks.annots[0].rgb, marks.annots[0].contents.as_str()), (Some(0x0000ff), "Check this"));
click(cx, &view, (305.0, 305.0));
send(cx, &viewer, Change::Delete);
assert_eq!(kinds(&notes(cx, &view)), ["Stamp"]);
}
/// Draws a field of `kind` over `at`, named `name`.
fn add_field(cx: &mut VisualTestContext, viewer: &Entity<Viewer>, view: &Entity<PdfView>, kind: FieldKind, at: [(f32, f32); 2], name: &str, options: &[&str]) {
tool(cx, view, Tool::Field(kind));
drag(cx, view, at[0], &[at[1]]);
assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::Note(Pick::NewField { .. }))));
send(cx, viewer, Change::Design(name.into(), options.iter().map(|o| o.to_string()).collect()));
notes(cx, view);
}
#[test]
fn drawn_fields_fill_from_the_page() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx, view) = marking(&mut cx, dir.path());
add_field(cx, &viewer, &view, FieldKind::Checkbox, [(72.0, 300.0), (72.0, 300.0)], "Agree", &[]);
add_field(cx, &viewer, &view, FieldKind::Choice, [(72.0, 340.0), (200.0, 360.0)], "Color", &["Red", "Blue"]);
tool(cx, &view, Tool::Read);
click(cx, &view, (78.0, 306.0));
assert!(notes(cx, &view).fields[1].checked, "a click checks");
click(cx, &view, (78.0, 306.0));
assert!(!notes(cx, &view).fields[1].checked, "and clears");
click(cx, &view, (100.0, 350.0));
assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::Note(Pick::Field { .. }))));
send(cx, &viewer, Change::Fill("Blue".into()));
notes(cx, &view);
// The old field sits 652 points down.
click(cx, &view, (100.0, 660.0));
send(cx, &viewer, Change::Fill("Zoe".into()));
let values: Vec<String> = notes(cx, &view).fields.iter().map(|f| f.value.clone()).collect();
assert_eq!(values, ["Zoe", "Off", "Blue"]);
}
#[test]
fn a_second_radio_joins_the_group_and_one_stays_on() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx, view) = marking(&mut cx, dir.path());
add_field(cx, &viewer, &view, FieldKind::Radio, [(72.0, 300.0), (72.0, 300.0)], "Size", &[]);
tool(cx, &view, Tool::Field(FieldKind::Radio));
click(cx, &view, (100.0, 300.0));
frames(cx);
let typed = cx.update(|_, app| viewer.read(app).note_panel.read(app).name.clone().expect("drawn").read(app).text().to_string());
assert_eq!(typed, "Size", "suggests the page's group");
send(cx, &viewer, Change::Design(typed, Vec::new()));
notes(cx, &view);
tool(cx, &view, Tool::Read);
click(cx, &view, (78.0, 306.0));
notes(cx, &view);
click(cx, &view, (106.0, 306.0));
let fields = notes(cx, &view).fields;
assert_eq!((fields[1].checked, fields[2].checked), (false, true));
}
#[test]
fn a_click_on_a_box_while_an_edit_is_queued_does_nothing() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx, view) = marking(&mut cx, dir.path());
add_field(cx, &viewer, &view, FieldKind::Checkbox, [(72.0, 300.0), (72.0, 300.0)], "Agree", &[]);
tool(cx, &view, Tool::Read);
cx.update(|_, app| view.update(app, |view, _| view.editing = true));
click(cx, &view, (78.0, 306.0));
assert!(cx.update(|_, app| view.read(app).queue.is_empty()), "nothing queued with old numbers");
}
#[test]
fn the_list_reads_every_page_and_jumps_to_a_pick() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let path = dir.path().join("three.pdf");
let page: (f32, f32, &[&str]) = (612.0, 792.0, &["Hello"]);
std::fs::write(&path, pdf_with(&[page, page, page], &[], &Extras::default())).expect("writes");
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
let view = shown(cx, &viewer);
settle(cx, |app| view.read(app).room.size.width > gpui::px(100.0));
let note = ely_pdf_engine::NewAnnot { page: 2, shape: ely_pdf_engine::Shape::Note((100.0, 500.0)), rgb: None, contents: "Last".into(), author: String::new() };
cx.update(|_, app| view.update(app, |view, cx| view.annotate("adding a note", vec![note], cx)));
settle(cx, |app| view.read(app).settled());
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.side.update(cx, |side, cx| {
side.pane = crate::side::Pane::Notes;
cx.notify();
})));
settle(cx, |app| (0..3).all(|page| view.read(app).notes.contains_key(&page)));
let id = cx.update(|_, app| view.read(app).notes[&2].annots[0].id);
cx.update(|_, app| view.update(app, |view, cx| view.pick_note(2, id, cx)));
frames(cx);
assert_eq!(cx.update(|_, app| (view.read(app).current, view.read(app).aim)), (2, Some(Aim::Note(Pick::Annot { page: 2, id }))));
}
#[test]
fn a_field_takes_a_new_name_and_options_and_no_change_is_no_step() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx, view) = marking(&mut cx, dir.path());
add_field(cx, &viewer, &view, FieldKind::Choice, [(72.0, 340.0), (200.0, 360.0)], "Color", &["Red", "Blue"]);
let edited = cx.update(|_, app| view.read(app).edited);
click(cx, &view, (100.0, 350.0));
assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::Note(Pick::Field { .. }))), "the form tool picks a field");
send(cx, &viewer, Change::Design("Color".into(), vec!["Red".into(), "Blue".into()]));
frames(cx);
assert_eq!(cx.update(|_, app| view.read(app).edited), edited, "as it was");
send(cx, &viewer, Change::Design("Shade".into(), vec!["Green".into()]));
let field = notes(cx, &view).fields[1].clone();
assert_eq!((field.name.as_str(), field.options), ("Shade", vec!["Green".to_string()]));
}
#[test]
fn a_highlight_before_the_text_is_read_lands_when_it_arrives() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (_, cx, view) = marking(&mut cx, dir.path());
tool(cx, &view, Tool::Highlight);
// A read seems in flight, so none starts yet.
cx.update(|_, app| view.update(app, |view, _| {
view.text.clear();
view.asking_text.insert(0);
}));
drag(cx, &view, (74.0, 66.0), &[(140.0, 66.0)]);
assert!(cx.update(|_, app| view.read(app).marking.is_some()), "waits for the text");
assert!(notes(cx, &view).annots.is_empty());
cx.update(|_, app| view.update(app, |view, cx| {
view.asking_text.clear();
view.page_text(0, cx);
}));
settle(cx, |app| view.read(app).edited == 1);
assert_eq!(kinds(&notes(cx, &view)), ["Highlight"]);
}
#[test]
fn a_new_field_on_another_page_gets_a_free_name() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let path = dir.path().join("two.pdf");
let page: (f32, f32, &[&str]) = (612.0, 792.0, &["Hello"]);
std::fs::write(&path, pdf_with(&[page, page], &[], &Extras::default())).expect("writes");
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
let view = shown(cx, &viewer);
settle(cx, |app| view.read(app).room.size.width > gpui::px(100.0));
let first = ely_pdf_engine::NewField { kind: FieldKind::Text, name: "Text1".into(), rect: [72.0, 300.0, 272.0, 320.0], options: Vec::new() };
cx.update(|_, app| view.update(app, |view, cx| view.edit("adding a field", move |w, doc| w.add_field(doc, 0, &first), cx)));
settle(cx, |app| view.read(app).settled());
cx.update(|_, app| view.update(app, |view, cx| {
view.tool = Tool::Field(FieldKind::Text);
view.aim = Some(Aim::Note(Pick::NewField { page: 1, rect: [72.0, 300.0, 272.0, 320.0], kind: FieldKind::Text }));
cx.notify();
}));
settle(cx, |app| viewer.read(app).note_panel.read(app).shown.is_some());
let typed = cx.update(|_, app| viewer.read(app).note_panel.read(app).name.clone().expect("drawn").read(app).text().to_string());
assert_eq!(typed, "Text2", "page one holds Text1");
}
#[test]
fn a_default_name_avoids_a_named_parent() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let path = dir.path().join("nested.pdf");
let extras = Extras { fields: &[0], parent: Some("Text1"), ..Extras::default() };
std::fs::write(&path, pdf_with(&[(612.0, 792.0, &["Hello"])], &[], &extras)).expect("writes");
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
let view = shown(cx, &viewer);
cx.update(|_, app| view.update(app, |view, cx| {
view.tool = Tool::Field(FieldKind::Text);
view.aim = Some(Aim::Note(Pick::NewField { page: 0, rect: [72.0, 300.0, 272.0, 320.0], kind: FieldKind::Text }));
cx.notify();
}));
settle(cx, |app| viewer.read(app).note_panel.read(app).shown.is_some());
let typed = cx.update(|_, app| viewer.read(app).note_panel.read(app).name.clone().expect("drawn").read(app).text().to_string());
assert_eq!(typed, "Text2", "Text1 holds Text1.field0");
}
#[test]
fn a_default_name_avoids_a_field_with_no_widget() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let path = dir.path().join("bare.pdf");
let extras = Extras { bare: Some(("Text1", "Child")), ..Extras::default() };
std::fs::write(&path, pdf_with(&[(612.0, 792.0, &["Hello"])], &[], &extras)).expect("writes");
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
let view = shown(cx, &viewer);
cx.update(|_, app| view.update(app, |view, cx| {
view.tool = Tool::Field(FieldKind::Text);
view.aim = Some(Aim::Note(Pick::NewField { page: 0, rect: [72.0, 300.0, 272.0, 320.0], kind: FieldKind::Text }));
cx.notify();
}));
settle(cx, |app| viewer.read(app).note_panel.read(app).shown.is_some());
let typed = cx.update(|_, app| viewer.read(app).note_panel.read(app).name.clone().expect("drawn").read(app).text().to_string());
assert_eq!(typed, "Text2", "Text1 has no widget but holds the name");
send(cx, &viewer, Change::Design(typed, Vec::new()));
assert_eq!(notes(cx, &view).fields.len(), 1, "the field lands");
}
+248
View File
@@ -0,0 +1,248 @@
//! Organizing, undo, saving and output from the app (C11.2).
use ely_office::{Closing as _, Dialog, Saving as _, Tabs as _};
use ely_pdf_engine::Worker;
use gpui::{Entity, TestAppContext, VisualTestContext};
use super::{file, frames, settle, shown, windowed};
use crate::view::PdfView;
use crate::viewer::Viewer;
/// Three Letter pages: one, two, three.
fn three<'a>(cx: &'a mut TestAppContext, dir: &std::path::Path) -> (Entity<Viewer>, &'a mut VisualTestContext, Entity<PdfView>) {
let pages: &[(f32, f32, &[&str])] = &[(612.0, 792.0, &["one"]), (612.0, 792.0, &["two"]), (612.0, 792.0, &["three"])];
let (viewer, cx) = windowed(cx, dir, &[file(dir, "three.pdf", pages)], true);
let view = shown(cx, &viewer);
(viewer, cx, view)
}
/// Each page's text, read by a worker of its own.
fn texts(path: &std::path::Path) -> Vec<String> {
let mut worker = Worker::spawn().expect("a worker");
let doc = worker.open(path).expect("opens").doc;
let count = worker.pages(doc).expect("counts");
(0..count).map(|page| worker.text(doc, page).expect("reads").iter().map(|c| c.c).collect()).collect()
}
fn dirty(cx: &mut VisualTestContext, viewer: &Entity<Viewer>) -> bool {
cx.update(|_, app| viewer.read(app).active_tab().expect("a tab").dirty(app))
}
/// Runs `step` on the view, then waits for its edits.
fn edit(cx: &mut VisualTestContext, view: &Entity<PdfView>, step: impl FnOnce(&mut PdfView, &mut gpui::Context<PdfView>)) {
cx.update(|_, app| view.update(app, step));
settle(cx, |app| view.read(app).settled());
}
#[test]
fn a_delete_marks_the_tab_and_undo_clears_it() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx, view) = three(&mut cx, dir.path());
assert!(!dirty(cx, &viewer));
edit(cx, &view, |view, cx| {
view.picked = [1].into();
view.edit("deleting", |worker, doc| worker.delete_pages(doc, &[1]), cx);
});
frames(cx);
assert_eq!(cx.update(|_, app| view.read(app).sizes.len()), 2);
assert!(dirty(cx, &viewer), "an edit is unsaved");
edit(cx, &view, |view, cx| view.undo(false, cx));
frames(cx);
assert_eq!(cx.update(|_, app| view.read(app).sizes.len()), 3);
assert!(!dirty(cx, &viewer), "undone to the file as loaded");
}
#[test]
fn a_save_writes_the_edits_and_undo_after_it_is_unsaved_again() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx, view) = three(&mut cx, dir.path());
edit(cx, &view, |view, cx| {
view.picked = [2].into();
let to = 0;
view.move_to(to, cx);
});
assert_eq!(cx.update(|_, app| view.read(app).picked.iter().copied().collect::<Vec<_>>()), [0], "the moved page stays picked");
let id = cx.update(|_, app| viewer.read(app).core.active.expect("a tab"));
cx.update(|window, app| viewer.update(app, |viewer, cx| viewer.save(id, None, window, cx)));
settle(cx, |app| !viewer.read(app).active_tab().expect("a tab").dirty(app));
assert_eq!(texts(&dir.path().join("three.pdf")), ["three", "one", "two"]);
edit(cx, &view, |view, cx| view.undo(false, cx));
frames(cx);
assert!(dirty(cx, &viewer), "the file still holds the move");
edit(cx, &view, |view, cx| view.undo(true, cx));
frames(cx);
assert!(!dirty(cx, &viewer), "redone to what was saved");
}
#[test]
fn an_edit_queued_behind_another_is_refused() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (_, cx, view) = three(&mut cx, dir.path());
cx.update(|_, app| view.update(app, |view, cx| {
view.edit("turning", |worker, doc| worker.rotate_pages(doc, &[0], 90), cx);
view.edit("deleting", |worker, doc| worker.delete_pages(doc, &[0]), cx);
}));
// The worker sees one edit at a time.
assert_eq!(cx.update(|_, app| (view.read(app).editing, view.read(app).queue.len())), (true, 1));
settle(cx, |app| view.read(app).settled());
// The delete aimed at the state before the turn.
let sizes = cx.update(|_, app| view.read(app).sizes.clone());
assert_eq!(sizes, [(792.0, 612.0), (612.0, 792.0), (612.0, 792.0)], "only the turn landed");
}
#[test]
fn a_read_from_before_an_edit_is_dropped() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (_, cx, view) = three(&mut cx, dir.path());
cx.update(|_, app| view.update(app, |view, cx| {
view.page_text(0, cx);
// As an edit landing first does.
view.version += 1;
}));
frames(cx);
assert!(cx.update(|_, app| view.read(app).text.is_empty()), "page one's old text is not kept");
}
#[test]
fn closing_an_edited_tab_asks_first() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx, view) = three(&mut cx, dir.path());
edit(cx, &view, |view, cx| view.edit("turning", |worker, doc| worker.rotate_pages(doc, &[0], 90), cx));
let id = cx.update(|_, app| viewer.read(app).core.active.expect("a tab"));
cx.update(|window, app| viewer.update(app, |viewer, cx| viewer.request_close(id, ely_office::After::Tab, window, cx)));
settle(cx, |app| matches!(viewer.read(app).core.dialog, Some(Dialog::Unsaved { .. })));
}
#[test]
fn extract_and_split_write_new_files_and_refuse_to_overwrite() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx, view) = three(&mut cx, dir.path());
let id = cx.update(|_, app| viewer.read(app).core.active.expect("a tab"));
let part = dir.path().join("part.pdf");
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.write_pages(id, vec![0, 2], part.clone(), cx)));
settle(cx, |_| part.exists());
frames(cx);
assert_eq!(texts(&part), ["one", "three"]);
// The open file itself is refused.
let open = dir.path().join("three.pdf");
let before = std::fs::read(&open).expect("reads");
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.write_pages(id, vec![0], open.clone(), cx)));
frames(cx);
assert_eq!(std::fs::read(&open).expect("reads"), before);
let out = dir.path().join("out");
std::fs::create_dir(&out).expect("a folder");
std::fs::write(out.join("three-2.pdf"), b"keep").expect("writes");
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.write_parts(id, 1, out.clone(), cx)));
frames(cx);
let mut names: Vec<String> = std::fs::read_dir(&out).expect("lists").map(|entry| entry.expect("an entry").file_name().to_string_lossy().into_owned()).collect();
names.sort();
assert_eq!(names, ["three-2.pdf"], "a taken name writes nothing");
std::fs::remove_file(out.join("three-2.pdf")).expect("removes");
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.write_parts(id, 2, out.clone(), cx)));
settle(cx, |_| out.join("three-2.pdf").exists());
frames(cx);
assert_eq!((texts(&out.join("three-1.pdf")), texts(&out.join("three-2.pdf"))), (vec!["one".into(), "two".into()], vec!["three".into()]));
assert_eq!(cx.update(|_, app| view.read(app).sizes.len()), 3, "the document stays");
}
#[test]
fn crop_margins_are_millimetres() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx, view) = three(&mut cx, dir.path());
cx.update(|window, app| viewer.update(app, |viewer, cx| viewer.ask_crop(window, cx)));
cx.update(|_, app| viewer.update(app, |viewer, cx| {
if let Some(crate::ask::Ask::Crop { margins, .. }) = viewer.ask.as_mut() {
*margins = [10.0, 0.0, 0.0, 25.4];
}
viewer.crop(cx);
}));
settle(cx, |app| view.read(app).edited == 1);
let size = cx.update(|_, app| view.read(app).sizes[0]);
let mm = 72.0 / 25.4;
assert!((size.0 - (612.0 - 72.0)).abs() < 0.1 && (size.1 - (792.0 - 10.0 * mm)).abs() < 0.1, "{size:?}");
}
#[test]
fn picks_follow_plain_range_and_toggle_clicks() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (_, cx, view) = three(&mut cx, dir.path());
let picked = |cx: &mut VisualTestContext| cx.update(|_, app| view.read(app).picked.iter().copied().collect::<Vec<_>>());
cx.update(|_, app| view.update(app, |view, cx| view.pick(0, (false, false), cx)));
cx.update(|_, app| view.update(app, |view, cx| view.pick(2, (true, false), cx)));
assert_eq!(picked(cx), [0, 1, 2]);
cx.update(|_, app| view.update(app, |view, cx| view.pick(1, (false, true), cx)));
assert_eq!(picked(cx), [0, 2]);
assert_eq!(cx.update(|_, app| view.read(app).chosen_pages()), [0, 2]);
}
#[test]
fn a_range_pick_starts_over_after_pages_move() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx, view) = three(&mut cx, dir.path());
cx.update(|_, app| view.update(app, |view, cx| view.pick(2, (false, false), cx)));
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.delete(cx)));
settle(cx, |app| view.read(app).sizes.len() == 2);
cx.update(|_, app| view.update(app, |view, cx| view.pick(0, (true, false), cx)));
assert_eq!(cx.update(|_, app| view.read(app).chosen_pages()), [0], "no range from a page that went");
edit(cx, &view, |view, cx| view.edit("blank", |worker, doc| worker.insert_blank(doc, 1, (100.0, 100.0)), cx));
assert_eq!(cx.update(|_, app| view.read(app).sizes.len()), 3);
}
#[test]
fn a_failed_edit_lets_a_waiting_save_go() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx, view) = three(&mut cx, dir.path());
let id = cx.update(|_, app| viewer.read(app).core.active.expect("a tab"));
let ran = std::rc::Rc::new(std::cell::Cell::new(false));
cx.update(|_, app| view.update(app, |view, cx| view.edit("cropping", |worker, doc| worker.crop_pages(doc, &[0], [900.0; 4]), cx)));
cx.update(|_, app| viewer.update(app, |viewer, _| {
let ran = ran.clone();
viewer.when_settled(id, move |_, _, _| ran.set(true));
}));
settle(cx, |_| ran.get());
}
#[test]
fn a_turned_view_crops_what_is_seen() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx, view) = three(&mut cx, dir.path());
cx.update(|_, app| view.update(app, |view, _| view.rotate = 90));
cx.update(|window, app| viewer.update(app, |viewer, cx| viewer.ask_crop(window, cx)));
cx.update(|_, app| viewer.update(app, |viewer, cx| {
if let Some(crate::ask::Ask::Crop { margins, .. }) = viewer.ask.as_mut() {
*margins = [25.4, 0.0, 0.0, 0.0];
}
viewer.crop(cx);
}));
settle(cx, |app| view.read(app).edited == 1);
let (w, h) = cx.update(|_, app| view.read(app).sizes[0]);
assert!((w - 540.0).abs() < 0.1 && (h - 792.0).abs() < 0.1, "the page's left went: {w} x {h}");
}
#[test]
fn the_window_closes_its_tabs_off_the_window() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx, view) = three(&mut cx, dir.path());
assert!(!cx.update(|window, app| viewer.update(app, |viewer, cx| viewer.should_close(window, cx))), "not at once");
drop(view);
// Once its tab closes, the window itself goes.
for _ in 0..1000 {
cx.executor().tick();
if cx.cx.update(|app| app.windows().is_empty()) {
return;
}
}
panic!("the window stayed");
}
+164
View File
@@ -0,0 +1,164 @@
//! Exports and the repaired copy (C11.8).
use std::path::PathBuf;
use ely_office::{Saving as _, Tabs as _};
use ely_pdf_engine::Optimizing;
use gpui::TestAppContext;
use super::{file, settle, shown, windowed};
use crate::output::{Kind, image_names};
#[test]
fn image_names_pad_alike() {
assert_eq!(image_names("doc", &[0, 9], 12), ["doc-01.png", "doc-10.png"]);
}
#[test]
fn pages_export_as_images_and_text() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let path = file(dir.path(), "doc.pdf", &[(612.0, 792.0, &["First"]), (612.0, 792.0, &["Second"])]);
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
shown(cx, &viewer);
let id = cx.update(|_, app| viewer.read(app).core.tabs[0].id);
let targets: Vec<PathBuf> = ["doc-1.png", "doc-2.png"].iter().map(|name| dir.path().join(name)).collect();
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.write_images(id, (vec![0, 1], 0), 72.0, targets.clone(), cx)));
settle(cx, |_| targets.iter().all(|target| std::fs::metadata(target).is_ok_and(|meta| meta.len() > 100)));
let txt = dir.path().join("doc.txt");
let how = Optimizing { compress: true, from: 300, to: 0 };
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.write_export(id, Kind::Text, how, txt.clone(), cx)));
settle(cx, |_| txt.is_file());
let text = std::fs::read_to_string(&txt).expect("UTF-8");
assert!(text.contains("First") && text.contains("Second"), "{text}");
}
#[test]
fn a_damaged_file_opens_repaired_and_saves_as_a_new_file() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let path = file(dir.path(), "broken.pdf", &[(612.0, 792.0, &["Hello"])]);
let mut bytes = std::fs::read(&path).expect("reads");
let at = bytes.windows(9).rposition(|w| w == b"startxref").expect("the trailer");
bytes.truncate(at);
bytes.extend_from_slice(b"startxref\n9\n%%EOF\n");
std::fs::write(&path, &bytes).expect("writes");
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path.clone()], true);
let view = shown(cx, &viewer);
assert_eq!(cx.update(|_, app| viewer.read(app).active_tab().and_then(|tab| tab.risk)), Some(ely_office::Risk::Repaired));
assert!(cx.update(|_, app| view.read(app).repair.as_ref().is_some_and(|report| !report.is_empty())));
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.show_repair(true, cx)));
super::frames(cx);
let id = cx.update(|_, app| viewer.read(app).core.tabs[0].id);
cx.update(|window, app| viewer.update(app, |viewer, cx| viewer.write_as(id, path.clone(), None, window, cx)));
super::frames(cx);
assert_eq!(std::fs::read(&path).expect("reads"), bytes, "Save As refuses the damaged original");
let fixed = dir.path().join("fixed.pdf");
cx.update(|window, app| viewer.update(app, |viewer, cx| viewer.write_as(id, fixed.clone(), None, window, cx)));
settle(cx, |app| fixed.is_file() && viewer.read(app).active_tab().is_some_and(|tab| tab.risk.is_none() && tab.view != view));
}
#[test]
fn an_edit_made_while_saving_stays_and_the_tab_reopens_after_the_next_save() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let path = file(dir.path(), "broken.pdf", &[(612.0, 792.0, &["Hello"])]);
let mut bytes = std::fs::read(&path).expect("reads");
let at = bytes.windows(9).rposition(|w| w == b"startxref").expect("the trailer");
bytes.truncate(at);
bytes.extend_from_slice(b"startxref\n9\n%%EOF\n");
std::fs::write(&path, &bytes).expect("writes");
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
let view = shown(cx, &viewer);
let (id, fixed) = (cx.update(|_, app| viewer.read(app).core.tabs[0].id), dir.path().join("fixed.pdf"));
cx.update(|window, app| viewer.update(app, |viewer, cx| {
viewer.write_as(id, fixed.clone(), None, window, cx);
// Lands after the save took its snapshot.
view.update(cx, |view, cx| view.edit("rotating", |w, doc| w.rotate_pages(doc, &[0], 90), cx));
}));
settle(cx, |app| fixed.is_file() && view.read(app).edited == 1 && viewer.read(app).active_tab().is_some_and(|tab| !tab.exporting));
for _ in 0..10 {
super::frames(cx);
}
let tab = cx.update(|_, app| viewer.read(app).active_tab().map(|tab| (tab.view == view, tab.dirty(app))));
assert_eq!(tab, Some((true, true)), "the edit stays, unsaved");
assert_eq!(cx.update(|_, app| view.read(app).sizes[0]), (792.0, 612.0));
cx.update(|window, app| viewer.update(app, |viewer, cx| viewer.save(id, None, window, cx)));
settle(cx, |app| viewer.read(app).active_tab().is_some_and(|tab| tab.view != view && !tab.reopen));
let reopened = shown(cx, &viewer);
assert_eq!(cx.update(|_, app| reopened.read(app).sizes[0]), (792.0, 612.0), "the reopened file holds the edit");
}
#[test]
fn picked_pages_from_before_an_edit_export_nothing() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let path = file(dir.path(), "doc.pdf", &[(100.0, 100.0, &["One"]), (200.0, 100.0, &["Two"]), (300.0, 100.0, &["Three"])]);
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
let view = shown(cx, &viewer);
let id = cx.update(|_, app| viewer.read(app).core.tabs[0].id);
let target = dir.path().join("doc-2.png");
cx.update(|_, app| viewer.update(app, |viewer, cx| {
let version = view.read(cx).version;
view.update(cx, |view, cx| view.edit("deleting", |w, doc| w.delete_pages(doc, &[0]), cx));
viewer.write_images(id, (vec![1], version), 72.0, vec![target.clone()], cx);
}));
settle(cx, |app| view.read(app).edited == 1 && view.read(app).settled());
for _ in 0..10 {
super::frames(cx);
}
assert!(!target.exists(), "page 2 is another page now");
}
/// A page drawing `/Im`, a picture that is no zlib.
fn broken_picture(dir: &std::path::Path, height: f32, y: f32) -> (PathBuf, Vec<u8>) {
let picture = "<< /Type /XObject /Subtype /Image /Width 4 /Height 4 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /FlateDecode /Length 12 >>\nstream\nnot a zlib!!\nendstream".to_string();
let page = format!(" /MediaBox [0 0 612 {height}] /Resources << /XObject << /Im 5 0 R >> >>");
let bytes = ely_pdf_engine::sample::one_page("", &page, &format!("q 100 0 0 100 72 {y} cm /Im Do Q"), &[picture]);
let path = dir.join("broken.pdf");
std::fs::write(&path, &bytes).expect("writes");
(path, bytes)
}
#[test]
fn a_picture_that_breaks_as_it_draws_stops_an_overwrite() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (path, bytes) = broken_picture(dir.path(), 792.0, 600.0);
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path.clone()], true);
shown(cx, &viewer);
settle(cx, |app| viewer.read(app).active_tab().and_then(|tab| tab.risk) == Some(ely_office::Risk::Repaired));
let id = cx.update(|_, app| viewer.read(app).core.tabs[0].id);
cx.update(|window, app| viewer.update(app, |viewer, cx| viewer.save(id, None, window, cx)));
for _ in 0..10 {
super::frames(cx);
}
assert_eq!(std::fs::read(&path).expect("reads"), bytes);
}
#[test]
fn a_picture_never_drawn_is_checked_before_an_overwrite() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (one, _) = broken_picture(dir.path(), 792.0, 600.0);
// Thirty blank pages first: the picture's page is never drawn.
let mut worker = ely_pdf_engine::Worker::spawn().expect("the worker");
let mut doc = worker.open(&one).expect("opens").doc;
for _ in 0..30 {
let mark = worker.insert_blank(doc, 0, (612.0, 792.0)).expect("inserts");
doc = doc.at(&mark).expect("the new state");
}
let path = dir.path().join("long.pdf");
worker.save(doc, &path).expect("saves");
let bytes = std::fs::read(&path).expect("reads");
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path.clone()], true);
shown(cx, &viewer);
for _ in 0..10 {
super::frames(cx);
}
let id = cx.update(|_, app| viewer.read(app).core.tabs[0].id);
assert_eq!(cx.update(|_, app| viewer.read(app).active_tab().and_then(|tab| tab.risk)), None, "nothing drawn yet");
cx.update(|window, app| viewer.update(app, |viewer, cx| viewer.save(id, None, window, cx)));
settle(cx, |app| viewer.read(app).active_tab().and_then(|tab| tab.risk) == Some(ely_office::Risk::Repaired));
assert_eq!(std::fs::read(&path).expect("reads"), bytes, "Save turned into Save As");
}
+112
View File
@@ -0,0 +1,112 @@
//! Limits, the owner password and protected copies (C11.7).
use ely_pdf_engine::{Lock, Worker, proto::may, sample::{Extras, pdf_with}};
use gpui::{Entity, TestAppContext, VisualTestContext};
use super::{frames, settle, shown, windowed};
use crate::protect::Secure;
use crate::select::Selection;
use crate::view::PdfView;
use crate::viewer::Viewer;
/// Opens a copy with no open password allowing only `allow`.
fn limited<'a>(cx: &'a mut TestAppContext, dir: &std::path::Path, allow: u32) -> (Entity<Viewer>, &'a mut VisualTestContext, Entity<PdfView>) {
let plain = dir.join("plain.pdf");
std::fs::write(&plain, pdf_with(&[(612.0, 792.0, &["Hello world"])], &[], &Extras::default())).expect("writes");
let mut worker = Worker::spawn().expect("the worker");
let doc = worker.open(&plain).expect("opens").doc;
let path = dir.join("limited.pdf");
let lock = Lock { user: String::new(), owner: "owner".into(), allow };
worker.protect(doc, &path, Some(&lock)).expect("protects");
let (viewer, cx) = windowed(cx, dir, &[path], true);
let view = shown(cx, &viewer);
(viewer, cx, view)
}
fn type_in(cx: &mut VisualTestContext, viewer: &Entity<Viewer>, owner: bool, text: &str) {
cx.update(|_, app| {
let field = match viewer.read(app).secure.as_ref() {
Some(Secure::Owner { field, .. }) => field.clone(),
Some(Secure::Protect { user, owner: field, .. }) => if owner { field.clone() } else { user.clone() },
None => panic!("no dialog"),
};
field.update(app, |field, cx| field.set_text(text.to_string(), cx));
});
}
#[test]
fn limits_hold_until_the_owner_password_lifts_them() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx, view) = limited(&mut cx, dir.path(), may::PRINT);
assert_eq!(cx.update(|_, app| view.read(app).allowed) & (may::ASSEMBLE | may::OWNER), 0);
cx.update(|_, app| view.update(app, |view, cx| view.edit("rotating", |w, doc| w.rotate_pages(doc, &[0], 90), cx)));
settle(cx, |app| view.read(app).settled());
assert_eq!(cx.update(|_, app| view.read(app).edited), 0, "refused");
cx.update(|window, app| viewer.update(app, |viewer, cx| viewer.ask_owner(window, cx)));
type_in(cx, &viewer, true, "wrong");
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.try_owner(cx)));
frames(cx);
assert!(cx.update(|_, app| viewer.read(app).secure.is_some()), "asks again");
type_in(cx, &viewer, true, "owner");
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.try_owner(cx)));
settle(cx, |app| view.read(app).allowed & may::OWNER != 0);
assert!(cx.update(|_, app| viewer.read(app).secure.is_none()));
cx.update(|_, app| view.update(app, |view, cx| view.edit("rotating", |w, doc| w.rotate_pages(doc, &[0], 90), cx)));
settle(cx, |app| view.read(app).edited == 1);
}
#[test]
fn copying_is_refused_where_the_file_forbids_it() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (_, cx, view) = limited(&mut cx, dir.path(), may::PRINT);
cx.update(|_, app| view.update(app, |view, cx| view.page_text(0, cx)));
settle(cx, |app| view.read(app).text.contains_key(&0));
cx.update(|_, app| view.update(app, |view, cx| {
view.selection = Some(Selection { anchor: (0, (70.0, 60.0)), head: (0, (140.0, 75.0)) });
view.copy(cx);
}));
frames(cx);
assert!(cx.update(|_, app| app.read_from_clipboard()).is_none());
}
#[test]
fn protecting_needs_a_distinct_owner_password_and_writes_a_locked_copy() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let path = super::file(dir.path(), "doc.pdf", &[(612.0, 792.0, &["Hello"])]);
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
shown(cx, &viewer);
cx.update(|window, app| viewer.update(app, |viewer, cx| viewer.ask_protect(window, cx)));
type_in(cx, &viewer, false, "same");
type_in(cx, &viewer, true, "same");
assert!(cx.update(|_, app| viewer.read(app).lock(app)).is_none(), "owner equals open");
type_in(cx, &viewer, true, "owner");
let lock = cx.update(|_, app| viewer.read(app).lock(app)).flatten().expect("a lock");
let tab = cx.update(|_, app| viewer.read(app).core.tabs[0].id);
let target = dir.path().join("locked.pdf");
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.write_protected(tab, Some(lock), target.clone(), cx)));
settle(cx, |_| target.is_file());
let mut worker = Worker::spawn().expect("the worker");
let opened = worker.open(&target).expect("opens");
assert!(opened.locked);
assert!(worker.unlock(opened.doc, "same").expect("tries"));
}
#[test]
fn an_owner_reply_closes_only_its_own_dialog() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx, view) = limited(&mut cx, dir.path(), may::PRINT);
cx.update(|window, app| viewer.update(app, |viewer, cx| viewer.ask_owner(window, cx)));
type_in(cx, &viewer, true, "owner");
cx.update(|window, app| viewer.update(app, |viewer, cx| {
viewer.try_owner(cx);
// A newer dialog opens before the answer.
viewer.secure = None;
viewer.ask_owner(window, cx);
}));
settle(cx, |app| view.read(app).allowed & may::OWNER != 0);
assert!(cx.update(|_, app| viewer.read(app).secure.is_some()), "the newer dialog stays");
}
+144
View File
@@ -0,0 +1,144 @@
//! Marking, removing and cleaning from the app (C11.6).
use ely_pdf_engine::{Cleaning, Worker, sample::{Extras, pdf_with}};
use gpui::{Entity, Modifiers, MouseButton, TestAppContext, VisualTestContext};
use super::input::over;
use super::{frames, settle, shown, windowed};
use crate::content::Tool;
use crate::redact::Removal;
use crate::view::PdfView;
use crate::viewer::Viewer;
/// Two lines and a title, actual size.
fn opened<'a>(cx: &'a mut TestAppContext, dir: &std::path::Path) -> (Entity<Viewer>, &'a mut VisualTestContext, Entity<PdfView>) {
let path = dir.join("doc.pdf");
std::fs::write(&path, pdf_with(&[(612.0, 792.0, &["Hello world", "Second line"])], &[], &Extras::default())).expect("writes");
let (viewer, cx) = windowed(cx, dir, &[path], true);
let view = shown(cx, &viewer);
settle(cx, |app| view.read(app).room.size.width > gpui::px(100.0));
cx.update(|_, app| view.update(app, |view, cx| {
view.zoom = crate::layout::Zoom::Scale(1.0);
cx.notify();
}));
frames(cx);
(viewer, cx, view)
}
fn drag(cx: &mut VisualTestContext, view: &Entity<PdfView>, from: (f32, f32), to: (f32, f32)) {
frames(cx);
let (a, b) = (over(cx, view, 0, from), over(cx, view, 0, to));
cx.simulate_mouse_down(a, MouseButton::Left, Modifiers::none());
cx.simulate_mouse_move(b, MouseButton::Left, Modifiers::none());
cx.simulate_mouse_up(b, MouseButton::Left, Modifiers::none());
}
fn text_of(path: &std::path::Path) -> String {
let mut worker = Worker::spawn().expect("the worker");
let doc = worker.open(path).expect("opens").doc;
worker.text(doc, 0).expect("text").iter().map(|c| c.c).collect()
}
#[test]
fn areas_mark_and_a_click_unmarks() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (_, cx, view) = opened(&mut cx, dir.path());
cx.update(|_, app| view.update(app, |view, cx| view.set_tool(Tool::RedactArea, cx)));
drag(cx, &view, (100.0, 300.0), (200.0, 350.0));
assert_eq!(cx.update(|_, app| view.read(app).redactions.len()), 1);
drag(cx, &view, (150.0, 320.0), (150.0, 320.0));
assert!(cx.update(|_, app| view.read(app).redactions.is_empty()), "a click inside takes it back");
}
#[test]
fn marked_text_is_removed_into_a_new_file() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx, view) = opened(&mut cx, dir.path());
cx.update(|_, app| view.update(app, |view, cx| view.set_tool(Tool::RedactText, cx)));
drag(cx, &view, (73.0, 66.0), (110.0, 66.0));
settle(cx, |app| !view.read(app).redactions.is_empty());
let out = dir.path().join("out.pdf");
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.ask_redact(cx)));
let asked = cx.update(|_, app| viewer.update(app, |viewer, _| viewer.removal.take().expect("asked")));
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.write_removal(asked, out.clone(), cx)));
settle(cx, |_| out.is_file());
let left = text_of(&out);
assert!(!left.contains("Hello") && left.contains("Second line"), "{left}");
assert_eq!(cx.update(|_, app| view.read(app).edited), 0, "the open document is untouched");
}
#[test]
fn an_edit_drops_the_marks_and_none_asks_nothing() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx, view) = opened(&mut cx, dir.path());
cx.update(|_, app| view.update(app, |view, cx| view.mark_regions(0, vec![[72.0, 60.0, 120.0, 80.0]], None, cx)));
cx.update(|_, app| view.update(app, |view, cx| view.edit("rotating", |w, doc| w.rotate_pages(doc, &[0], 90), cx)));
settle(cx, |app| view.read(app).edited == 1);
assert!(cx.update(|_, app| view.read(app).redactions.is_empty()), "pages may have moved");
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.ask_redact(cx)));
assert!(cx.update(|_, app| viewer.read(app).removal.is_none()));
}
#[test]
fn cleaning_counts_first_then_writes_a_clean_file() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx, view) = opened(&mut cx, dir.path());
cx.update(|_, app| view.update(app, |view, cx| view.edit("titling", |w, doc| w.set_properties(doc, &[("Title", "Plan")]), cx)));
settle(cx, |app| view.read(app).edited == 1);
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.ask_clean(cx)));
settle(cx, |app| viewer.read(app).removal.is_some());
let (tab, version, reach) = cx.update(|_, app| match viewer.read(app).removal.as_ref() {
Some(Removal::Clean { tab, version, reach, .. }) => (*tab, *version, reach.clone()),
_ => panic!("a cleaning"),
});
assert!(reach.meta >= 1, "{reach:?}");
let out = dir.path().join("clean.pdf");
let what = Cleaning { meta: true, ..Cleaning::default() };
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.write_removal(Removal::Clean { tab, version, reach, what }, out.clone(), cx)));
settle(cx, |_| out.is_file());
let mut worker = Worker::spawn().expect("the worker");
let doc = worker.open(&out).expect("opens").doc;
assert_eq!(worker.properties(doc).expect("reads").title, None);
}
#[test]
fn marks_changed_after_asking_write_nothing() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx, view) = opened(&mut cx, dir.path());
cx.update(|_, app| view.update(app, |view, cx| view.mark_regions(0, vec![[72.0, 52.0, 120.0, 78.0]], None, cx)));
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.ask_redact(cx)));
let asked = cx.update(|_, app| viewer.update(app, |viewer, _| viewer.removal.take().expect("asked")));
cx.update(|_, app| view.update(app, |view, cx| view.mark_regions(0, vec![[72.0, 76.0, 200.0, 102.0]], None, cx)));
let out = dir.path().join("out.pdf");
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.write_removal(asked, out.clone(), cx)));
for _ in 0..10 {
frames(cx);
}
assert!(!out.exists(), "the marks are not what was confirmed");
}
#[test]
fn a_scan_for_a_tab_left_behind_opens_nothing() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (a, b) = (dir.path().join("a.pdf"), dir.path().join("b.pdf"));
for path in [&a, &b] {
std::fs::write(path, pdf_with(&[(612.0, 792.0, &["Hello"])], &[], &Extras::default())).expect("writes");
}
let (viewer, cx) = windowed(&mut cx, dir.path(), &[a, b], true);
let first = cx.update(|_, app| viewer.read(app).core.tabs[0].id);
cx.update(|_, app| viewer.update(app, |viewer, cx| {
viewer.core.active = Some(first);
viewer.ask_clean(cx);
viewer.core.active = Some(viewer.core.tabs[1].id);
}));
for _ in 0..20 {
frames(cx);
}
assert!(cx.update(|_, app| viewer.read(app).removal.is_none()), "another tab is in front");
}
+230
View File
@@ -0,0 +1,230 @@
//! Signatures beside the pages, signed copies, signed files (C11.7).
use std::path::{Path, PathBuf};
use ely_office::{Saving as _, Tabs as _};
use ely_pdf_engine::{Digest, Signing, Worker, sample::{Extras, pdf_with}};
use ely_platform::trust::Trust;
use gpui::{Entity, TestAppContext, VisualTestContext};
use crate::view::PdfView;
use crate::viewer::Viewer;
use super::{settle, shown, windowed};
use crate::sign::SignAt;
fn signing(name: &str) -> Signing {
let pfx = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/pdf/signer.p12");
Signing {
page: 0,
object: None,
rect: [72.0, 600.0, 272.0, 660.0],
name: name.into(),
pfx: pfx.to_str().expect("UTF-8").into(),
pfx_password: "ely".into(),
reason: String::new(),
location: String::new(),
}
}
fn plain(dir: &Path) -> PathBuf {
let path = dir.join("plain.pdf");
std::fs::write(&path, pdf_with(&[(612.0, 792.0, &["Hello world"])], &[], &Extras::default())).expect("writes");
path
}
fn signed(dir: &Path) -> PathBuf {
let mut worker = Worker::spawn().expect("the worker");
let doc = worker.open(&plain(dir)).expect("opens").doc;
let path = dir.join("signed.pdf");
worker.sign(doc, &path, &signing("Signature1")).expect("signs");
path
}
#[test]
fn a_signed_file_lists_its_checks_and_saves_as_a_copy() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let path = signed(dir.path());
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
let view = shown(cx, &viewer);
assert_eq!(cx.update(|_, app| viewer.read(app).active_tab().and_then(|tab| tab.risk)), Some(ely_office::Risk::Signed));
let path = tab_path(cx, &viewer);
cx.update(|_, app| view.update(app, |view, cx| view.ask_signatures(path, cx)));
settle(cx, |app| view.read(app).signatures.is_some());
cx.update(|_, app| {
let read = view.read(app);
let checked = &read.signatures.as_ref().expect("read").1[0];
assert_eq!(checked.field.signed.as_ref().map(|s| s.digest), Some(Digest::Intact));
assert!(matches!(checked.trust, Some(Ok(Trust::Untrusted(_)))), "self-signed");
});
// The dialog draws every fact.
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.show_signatures(true, cx)));
super::frames(cx);
assert!(cx.update(|_, app| viewer.read(app).signs_open));
}
#[test]
fn signing_writes_a_signed_copy_and_leaves_the_document() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx) = windowed(&mut cx, dir.path(), &[plain(dir.path())], true);
let view = shown(cx, &viewer);
let at = SignAt { page: 0, object: None, rect: [72.0, 600.0, 272.0, 660.0], version: 0 };
cx.update(|_, app| view.update(app, |view, _| view.sign_at = Some(at)));
let (tab, target) = (cx.update(|_, app| viewer.read(app).core.tabs[0].id), dir.path().join("out.pdf"));
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.write_signed(tab, at, signing(""), target.clone(), cx)));
settle(cx, |app| target.is_file() && view.read(app).sign_at.is_none());
let mut worker = Worker::spawn().expect("the worker");
let doc = worker.open(&target).expect("opens").doc;
let found = worker.signatures(doc, &target).expect("reads");
assert_eq!((found.len(), found[0].name.as_str()), (1, "Signature1"));
assert_eq!(cx.update(|_, app| view.read(app).edited), 0);
}
#[test]
fn a_place_from_before_an_edit_signs_nothing() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx) = windowed(&mut cx, dir.path(), &[plain(dir.path())], true);
let view = shown(cx, &viewer);
let at = SignAt { page: 0, object: None, rect: [72.0, 600.0, 272.0, 660.0], version: 0 };
cx.update(|_, app| view.update(app, |view, cx| {
view.sign_at = Some(at);
view.edit("rotating", |w, doc| w.rotate_pages(doc, &[0], 90), cx)
}));
settle(cx, |app| view.read(app).edited == 1);
let (tab, target) = (cx.update(|_, app| viewer.read(app).core.tabs[0].id), dir.path().join("out.pdf"));
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.write_signed(tab, at, signing(""), target.clone(), cx)));
super::frames(cx);
assert!(!target.exists());
}
#[test]
fn a_finished_signing_clears_only_its_own_place() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (a, b) = (plain(dir.path()), dir.path().join("b.pdf"));
std::fs::copy(&a, &b).expect("copies");
let (viewer, cx) = windowed(&mut cx, dir.path(), &[a, b], true);
let (first, second) = cx.update(|_, app| (viewer.read(app).core.tabs[0].view.clone(), viewer.read(app).core.tabs[1].view.clone()));
let at = SignAt { page: 0, object: None, rect: [72.0, 600.0, 272.0, 660.0], version: 0 };
cx.update(|_, app| {
first.update(app, |view, _| view.sign_at = Some(at));
second.update(app, |view, _| view.sign_at = Some(at));
});
let (tab, target) = (cx.update(|_, app| viewer.read(app).core.tabs[0].id), dir.path().join("out.pdf"));
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.write_signed(tab, at, signing(""), target.clone(), cx)));
settle(cx, |app| target.is_file() && first.read(app).sign_at.is_none());
assert_eq!(cx.update(|_, app| second.read(app).sign_at), Some(at), "the other tab keeps its place");
}
fn tab_path(cx: &mut VisualTestContext, viewer: &Entity<Viewer>) -> PathBuf {
cx.update(|_, app| viewer.read(app).active_tab().and_then(|tab| tab.session.path().map(PathBuf::from)).expect("a file"))
}
/// Whether the tab's file lists a signature.
fn shows_signed(cx: &mut VisualTestContext, viewer: &Entity<Viewer>, view: &Entity<PdfView>) -> bool {
let path = tab_path(cx, viewer);
cx.update(|_, app| view.update(app, |view, cx| view.ask_signatures(path.clone(), cx)));
settle(cx, |app| view.read(app).signatures.as_ref().is_some_and(|(read, _)| *read == path));
cx.update(|_, app| view.read(app).signatures.as_ref().expect("read").1.iter().any(|c| c.field.signed.is_some()))
}
/// Saves the signed tab as `copy`; waits for the reopen.
fn save_signed_as(cx: &mut VisualTestContext, viewer: &Entity<Viewer>, old: &Entity<PdfView>, copy: &Path) -> Entity<PdfView> {
let id = cx.update(|_, app| viewer.read(app).core.tabs[0].id);
let target = copy.to_path_buf();
std::fs::write(copy, b"").expect("a place");
let copy = std::fs::canonicalize(copy).expect("the real path");
std::fs::remove_file(&copy).expect("clears the place");
cx.update(|window, app| viewer.update(app, |viewer, cx| viewer.write_as(id, target, None, window, cx)));
settle(cx, |app| {
let read = viewer.read(app);
read.core.tabs.len() == 1 && read.active_tab().is_some_and(|tab| tab.session.path() == Some(copy.as_path()) && tab.view != *old)
});
shown(cx, viewer)
}
#[test]
fn a_signed_file_saved_as_a_copy_reopens_as_the_copy() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let path = signed(dir.path());
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
let view = shown(cx, &viewer);
assert!(shows_signed(cx, &viewer, &view));
cx.update(|_, app| view.update(app, |view, cx| view.edit("rotating", |w, doc| w.rotate_pages(doc, &[0], 90), cx)));
settle(cx, |app| view.read(app).edited == 1);
let copy = dir.path().join("copy.pdf");
let reopened = save_signed_as(cx, &viewer, &view, &copy);
let tab = cx.update(|_, app| (viewer.read(app).active_tab().map(|tab| (tab.risk, tab.dirty(app)))));
assert_eq!(tab, Some((None, false)), "an unsigned, saved copy");
assert!(!shows_signed(cx, &viewer, &reopened), "the copy holds no signature");
assert_eq!(cx.update(|_, app| reopened.read(app).sizes[0]), (792.0, 612.0), "the edit is in the copy");
}
#[test]
fn signing_after_save_as_signs_the_copy() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let path = signed(dir.path());
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
let view = shown(cx, &viewer);
let copy = dir.path().join("copy.pdf");
let reopened = save_signed_as(cx, &viewer, &view, &copy);
let mut worker = Worker::spawn().expect("the worker");
let read = worker.open(&copy).expect("opens").doc;
let field = worker.signatures(read, &copy).expect("reads")[0].clone();
assert!(field.signed.is_none(), "the copy's field is empty");
let at = SignAt { page: 0, object: Some(field.object), rect: field.rect, version: cx.update(|_, app| reopened.read(app).version) };
cx.update(|_, app| reopened.update(app, |view, _| view.sign_at = Some(at)));
let (tab, target) = (cx.update(|_, app| viewer.read(app).core.tabs[0].id), dir.path().join("resigned.pdf"));
let mut how = signing("");
how.object = Some(field.object);
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.write_signed(tab, at, how, target.clone(), cx)));
settle(cx, |_| target.is_file());
let doc = worker.open(&target).expect("opens").doc;
let found = worker.signatures(doc, &target).expect("reads");
assert_eq!(found.len(), 1, "only the copy's one field");
assert_eq!(found[0].signed.as_ref().map(|s| (s.digest, s.later)), Some((Digest::Intact, false)));
}
#[test]
fn a_failed_save_as_keeps_the_signed_file_and_its_signatures() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let path = signed(dir.path());
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
let view = shown(cx, &viewer);
let (id, blocked) = (cx.update(|_, app| viewer.read(app).core.tabs[0].id), dir.path().join("directory.pdf"));
std::fs::create_dir(&blocked).expect("a directory in the way");
cx.update(|window, app| viewer.update(app, |viewer, cx| viewer.write_as(id, blocked.clone(), None, window, cx)));
for _ in 0..20 {
super::frames(cx);
}
assert_eq!(cx.update(|_, app| viewer.read(app).active_tab().and_then(|tab| tab.risk)), Some(ely_office::Risk::Signed));
assert!(shows_signed(cx, &viewer, &view), "the signed file and its list are as they were");
}
#[test]
fn reopening_the_dialog_checks_the_file_as_it_is_now() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let path = signed(dir.path());
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path.clone()], true);
let view = shown(cx, &viewer);
let digest = |cx: &mut VisualTestContext| {
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.show_signatures(true, cx)));
settle(cx, |app| view.read(app).signatures.is_some());
let digest = cx.update(|_, app| view.read(app).signatures.as_ref().expect("read").1[0].field.signed.as_ref().map(|s| s.digest));
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.show_signatures(false, cx)));
digest
};
assert_eq!(digest(cx), Some(Digest::Intact));
let mut bytes = std::fs::read(&path).expect("reads");
let at = bytes.windows(5).position(|w| w == b"Hello").expect("the text");
bytes[at] = b'J';
std::fs::write(&path, bytes).expect("writes");
assert_eq!(digest(cx), Some(Digest::Changed));
}
+236
View File
@@ -0,0 +1,236 @@
//! Links, bookmarks, properties, attachments, OCR from the app (C11.5).
use ely_pdf_engine::{
Worker,
sample::{Extras, pdf, pdf_with, scan},
};
use gpui::{Entity, Modifiers, MouseButton, TestAppContext, VisualTestContext};
use super::input::over;
use super::{frames, settle, shown, windowed};
use crate::annotate::Pick;
use crate::content::{Aim, Tool};
use crate::marking::Change;
use crate::view::PdfView;
use crate::viewer::Viewer;
/// Two pages, a bookmark, a link to page two.
fn opened<'a>(cx: &'a mut TestAppContext, dir: &std::path::Path) -> (Entity<Viewer>, &'a mut VisualTestContext, Entity<PdfView>) {
let path = dir.join("doc.pdf");
let pages: [(f32, f32, &[&str]); 2] = [(612.0, 792.0, &["Page one"]), (612.0, 792.0, &["Page two"])];
std::fs::write(&path, pdf_with(&pages, &[("One", 0, &[])], &Extras { links: &[(0, 1)], ..Extras::default() })).expect("writes");
let (viewer, cx) = windowed(cx, dir, &[path], true);
let view = shown(cx, &viewer);
settle(cx, |app| view.read(app).room.size.width > gpui::px(100.0));
cx.update(|_, app| view.update(app, |view, cx| {
view.zoom = crate::layout::Zoom::Scale(1.0);
view.mode = crate::layout::Mode::Single;
cx.notify();
}));
settle(cx, |app| view.read(app).links.contains_key(&0));
(viewer, cx, view)
}
fn press(cx: &mut VisualTestContext, view: &Entity<PdfView>, from: (f32, f32), to: (f32, f32)) {
frames(cx);
let (a, b) = (over(cx, view, 0, from), over(cx, view, 0, to));
cx.simulate_mouse_down(a, MouseButton::Left, Modifiers::none());
cx.simulate_mouse_move(b, MouseButton::Left, Modifiers::none());
cx.simulate_mouse_up(b, MouseButton::Left, Modifiers::none());
}
fn landed(cx: &mut VisualTestContext, view: &Entity<PdfView>, edits: u64) {
settle(cx, |app| view.read(app).edited == edits && view.read(app).settled() && view.read(app).links.contains_key(&0));
}
fn send(cx: &mut VisualTestContext, viewer: &Entity<Viewer>, change: Change) {
frames(cx);
cx.update(|_, app| viewer.update(app, |viewer, cx| {
let shown = viewer.note_panel.read(cx).shown.expect("the panel follows the pick");
viewer.change_note(shown, change, cx)
}));
}
#[test]
fn a_click_follows_a_link_and_the_link_tool_adds_retargets_and_deletes() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx, view) = opened(&mut cx, dir.path());
// The sample link covers 72..200 across, 692..720 down.
press(cx, &view, (100.0, 700.0), (100.0, 700.0));
settle(cx, |app| view.read(app).current == 1);
cx.update(|_, app| view.update(app, |view, cx| view.show(0, 0.0, cx)));
cx.update(|_, app| view.update(app, |view, cx| view.set_tool(Tool::Link, cx)));
press(cx, &view, (72.0, 300.0), (200.0, 320.0));
assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::Note(Pick::NewLink { .. }))));
send(cx, &viewer, Change::Target("javascript:x".into()));
frames(cx);
assert_eq!(cx.update(|_, app| view.read(app).edited), 0, "a bad target is refused");
send(cx, &viewer, Change::Target("https://example.com/".into()));
landed(cx, &view, 1);
let links = cx.update(|_, app| view.read(app).links[&0].clone());
assert_eq!((links.len(), links[1].uri.as_str()), (2, "https://example.com/"));
press(cx, &view, (100.0, 310.0), (100.0, 310.0));
assert!(matches!(cx.update(|_, app| view.read(app).aim), Some(Aim::Note(Pick::Link { index: 1, .. }))));
send(cx, &viewer, Change::Target("2".into()));
landed(cx, &view, 2);
assert_eq!(cx.update(|_, app| view.read(app).links[&0][1].page), Some(1));
press(cx, &view, (100.0, 310.0), (100.0, 310.0));
send(cx, &viewer, Change::Delete);
landed(cx, &view, 3);
assert_eq!(cx.update(|_, app| view.read(app).links[&0].len()), 1);
}
#[test]
fn bookmarks_add_after_the_pick_rename_and_delete() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx, view) = opened(&mut cx, dir.path());
let titles = |cx: &mut VisualTestContext| cx.update(|_, app| view.read(app).marks.iter().map(|m| m.title.clone()).collect::<Vec<_>>());
cx.update(|window, app| viewer.update(app, |viewer, cx| viewer.ask_bookmark(true, window, cx)));
let typed = cx.update(|_, app| viewer.read(app).naming.as_ref().expect("asked").field.read(app).text().to_string());
let label = cx.update(|_, app| Viewer::text(app, "pdf.page.label", &[("n", "1")]).to_string());
assert_eq!(typed, label, "named for the page");
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.name_bookmark("Start".into(), cx)));
landed(cx, &view, 1);
assert_eq!(titles(cx), ["One", "Start"]);
cx.update(|_, app| view.update(app, |view, _| view.mark_pick = Some(0)));
cx.update(|window, app| viewer.update(app, |viewer, cx| viewer.ask_bookmark(false, window, cx)));
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.name_bookmark("First".into(), cx)));
landed(cx, &view, 2);
assert_eq!(titles(cx), ["First", "Start"]);
cx.update(|_, app| view.update(app, |view, _| view.mark_pick = Some(1)));
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.delete_bookmark(cx)));
landed(cx, &view, 3);
assert_eq!(titles(cx), ["First"]);
}
#[test]
fn properties_show_and_set_what_changed() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx, view) = opened(&mut cx, dir.path());
cx.update(|window, app| viewer.update(app, |viewer, cx| viewer.ask_properties(window, cx)));
settle(cx, |app| viewer.read(app).props.is_some());
cx.update(|_, app| viewer.update(app, |viewer, cx| {
let field = viewer.props.as_ref().expect("open").fields[0].clone();
field.update(cx, |field, cx| field.set_text("Report", cx));
viewer.set_properties(cx)
}));
landed(cx, &view, 1);
cx.update(|window, app| viewer.update(app, |viewer, cx| viewer.ask_properties(window, cx)));
settle(cx, |app| viewer.read(app).props.is_some());
assert_eq!(cx.update(|_, app| viewer.read(app).props.as_ref().expect("open").read.title.clone()), Some("Report".into()));
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.set_properties(cx)));
frames(cx);
assert_eq!(cx.update(|_, app| view.read(app).edited), 1, "nothing changed, no step");
}
#[test]
fn attachments_list_save_and_remove() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx, view) = opened(&mut cx, dir.path());
let file = dir.path().join("notes.txt");
std::fs::write(&file, "notes").expect("writes");
cx.update(|_, app| view.update(app, |view, cx| view.edit("attaching", move |w, doc| w.attach(doc, "notes.txt", &file), cx)));
landed(cx, &view, 1);
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.side.update(cx, |side, cx| {
side.pane = crate::side::Pane::Files;
cx.notify();
})));
settle(cx, |app| view.read(app).files.as_ref().is_some_and(|files| files.len() == 1));
let out = dir.path().join("saved.txt");
let id = cx.update(|_, app| viewer.read(app).core.tabs[0].id);
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.write_attachment(id, "n:notes.txt".into(), out.clone(), cx)));
settle(cx, |_| out.is_file());
assert_eq!(std::fs::read(&out).expect("reads"), b"notes");
cx.update(|_, app| view.update(app, |view, _| view.file_pick = Some("n:notes.txt".into())));
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.remove_attachment(cx)));
settle(cx, |app| view.read(app).files.as_ref().is_some_and(Vec::is_empty));
}
/// A scan of two English lines.
fn scanned(dir: &std::path::Path) -> std::path::PathBuf {
let source = dir.join("text.pdf");
std::fs::write(&source, pdf(&[(612.0, 792.0, &["Hello world", "Second line"])], &[])).expect("writes");
let mut worker = Worker::spawn().expect("the worker");
let doc = worker.open(&source).expect("opens").doc;
let s: f32 = 200.0 / 72.0;
let (w, h) = ((612.0 * s).ceil() as i32, (792.0 * s).ceil() as i32);
let bgra = worker.render(doc, 0, (s, 0), [0, 0, w, h], false).expect("draws").bgra;
let gray: Vec<u8> = bgra.chunks_exact(4).map(|p| ((u16::from(p[0]) + u16::from(p[1]) + u16::from(p[2])) / 3) as u8).collect();
let path = dir.join("scan.pdf");
std::fs::write(&path, scan((612.0, 792.0), (w as u32, h as u32), &gray)).expect("writes");
path
}
#[test]
fn ocr_reads_the_pages_then_lays_their_text_in_one_step() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let path = scanned(dir.path());
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
let view = shown(cx, &viewer);
cx.update(|window, app| viewer.update(app, |viewer, cx| {
viewer.ask_ocr(window, cx);
viewer.start_ocr(cx);
}));
assert!(cx.update(|_, app| viewer.read(app).ocr.as_ref().is_some_and(|ocr| ocr.run.is_some())), "reading");
settle(cx, |app| viewer.read(app).ocr.is_none() && view.read(app).edited == 1);
let text = cx.update(|_, app| {
let doc = view.read(app).doc;
viewer.read(app).core.worker.lock().expect("the worker lock").get().expect("started").text(doc, 0).expect("text")
});
let read: String = text.iter().map(|c| c.c).collect();
assert!(read.contains("Hello world"), "{read}");
// A second run skips the page that has text now.
cx.update(|window, app| viewer.update(app, |viewer, cx| {
viewer.ask_ocr(window, cx);
viewer.start_ocr(cx);
}));
settle(cx, |app| viewer.read(app).ocr.is_none());
frames(cx);
assert_eq!(cx.update(|_, app| view.read(app).edited), 1, "nothing to lay");
}
#[test]
fn a_cancelled_ocr_lays_nothing() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let path = scanned(dir.path());
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
let view = shown(cx, &viewer);
cx.update(|window, app| viewer.update(app, |viewer, cx| {
viewer.ask_ocr(window, cx);
viewer.start_ocr(cx);
viewer.ocr = None;
}));
for _ in 0..20 {
frames(cx);
}
assert_eq!(cx.update(|_, app| view.read(app).edited), 0);
}
#[test]
fn a_cancelled_run_cannot_touch_the_next() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let path = scanned(dir.path());
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
let view = shown(cx, &viewer);
let tab = cx.update(|_, app| viewer.read(app).core.tabs[0].id);
let first = cx.update(|window, app| viewer.update(app, |viewer, cx| {
viewer.ask_ocr(window, cx);
viewer.start_ocr(cx);
viewer.ocr = None;
viewer.ask_ocr(window, cx);
viewer.start_ocr(cx);
viewer.ocr_runs - 1
}));
cx.update(|_, app| viewer.update(app, |viewer, cx| {
viewer.ocr_read((tab, first, 0), Err(std::io::Error::other("from the first run")), cx)
}));
assert!(cx.update(|_, app| viewer.read(app).ocr.is_some()), "the second run goes on");
settle(cx, |app| viewer.read(app).ocr.is_none() && view.read(app).edited == 1);
}
+115
View File
@@ -0,0 +1,115 @@
//! Password prompts, zoomed pairs, jumps across (C11.1).
use ely_office::{Dialog, Dialogs as _, Tabs as _};
use gpui::{Entity, TestAppContext, VisualTestContext, point, px};
use super::{file, frames, settle, shown, windowed};
use crate::layout::{Mode, Zoom};
use crate::view::PdfView;
use crate::viewer::Viewer;
/// The file the open prompt asks for.
fn asking(app: &gpui::App, viewer: &Entity<Viewer>) -> Option<std::path::PathBuf> {
match &viewer.read(app).core.dialog {
Some(Dialog::Password { path, .. }) => Some(path.clone()),
_ => None,
}
}
/// Two files asked for at once, as locked loads do.
fn asked<'a>(cx: &'a mut TestAppContext, dir: &std::path::Path) -> (Entity<Viewer>, &'a mut VisualTestContext, [std::path::PathBuf; 2]) {
let (viewer, cx) = windowed(cx, dir, &[], true);
let paths = [file(dir, "a.pdf", &[(612.0, 792.0, &["a"])]), file(dir, "b.pdf", &[(612.0, 792.0, &["b"])])];
cx.update(|window, app| viewer.update(app, |viewer, cx| {
for path in &paths {
viewer.ask_password(path.clone(), false, false, window, cx);
}
}));
frames(cx);
(viewer, cx, paths)
}
#[test]
fn a_typed_password_opens_the_file() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx, paths) = asked(&mut cx, dir.path());
// An open file takes any password.
cx.simulate_input("secret");
cx.simulate_keystrokes("enter");
settle(cx, |app| viewer.read(app).core.tabs.len() == 1);
assert_eq!(cx.update(|_, app| asking(app, &viewer)), Some(paths[1].clone()), "the next asks");
}
#[test]
fn escape_gives_up_each_queued_file_in_turn() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (viewer, cx, paths) = asked(&mut cx, dir.path());
assert_eq!(cx.update(|_, app| asking(app, &viewer)), Some(paths[0].clone()));
cx.simulate_keystrokes("escape");
settle(cx, |app| asking(app, &viewer) == Some(paths[1].clone()));
frames(cx);
cx.simulate_keystrokes("escape");
settle(cx, |app| viewer.read(app).core.dialog.is_none());
assert!(cx.update(|_, app| viewer.read(app).active_tab().is_none()));
}
/// Two zoomed Letter pages side by side.
fn zoomed_pair<'a>(cx: &'a mut TestAppContext, dir: &std::path::Path) -> (Entity<PdfView>, &'a mut VisualTestContext) {
let pages: &[(f32, f32, &[&str])] = &[(612.0, 792.0, &["one"]), (612.0, 792.0, &["two"])];
let (viewer, cx) = windowed(cx, dir, &[file(dir, "pair.pdf", pages)], true);
let view = shown(cx, &viewer);
settle(cx, |app| view.read(app).room.size.width > px(100.0));
cx.update(|_, app| view.update(app, |view, cx| {
(view.mode, view.zoom) = (Mode::Two, Zoom::Scale(4.0));
cx.notify();
}));
frames(cx);
(view, cx)
}
#[test]
fn two_pages_keep_the_right_hand_page() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let pages: &[(f32, f32, &[&str])] = &[(612.0, 792.0, &["one"]), (612.0, 792.0, &["two"]), (612.0, 792.0, &["three"])];
let (viewer, cx) = windowed(&mut cx, dir.path(), &[file(dir.path(), "three.pdf", pages)], true);
let view = shown(cx, &viewer);
settle(cx, |app| view.read(app).room.size.width > px(100.0));
cx.update(|_, app| view.update(app, |view, cx| {
view.mode = Mode::Two;
view.show(1, 0.0, cx);
}));
frames(cx);
assert_eq!(cx.update(|_, app| view.read(app).current), 1);
}
#[test]
fn scrolling_across_to_the_right_page_makes_it_current() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (view, cx) = zoomed_pair(&mut cx, dir.path());
cx.update(|_, app| view.update(app, |view, cx| {
let right = view.laid_out().placed[1].x;
let y = view.scroll.offset().y;
view.scroll.set_offset(point(px(-right), y));
cx.notify();
}));
frames(cx);
assert_eq!(cx.update(|_, app| view.read(app).current), 1);
}
#[test]
fn a_jump_to_the_right_page_brings_it_across() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (view, cx) = zoomed_pair(&mut cx, dir.path());
cx.update(|_, app| view.update(app, |view, cx| view.show(1, 0.0, cx)));
frames(cx);
let (x, left, wide) = cx.update(|_, app| {
let view = view.read(app);
(view.laid_out().placed[1].x, -f32::from(view.scroll.offset().x), f32::from(view.room.size.width))
});
assert!(x >= left && x < left + wide, "page at {x}, view {left}..{}", left + wide);
}
+103
View File
@@ -0,0 +1,103 @@
//! Visible signatures placed as ink, text or a picture (C11.7).
use std::path::Path;
use gpui::{Entity, TestAppContext, VisualTestContext};
use super::{settle, shown, windowed};
use crate::content::Tool;
use crate::view::PdfView;
use crate::visible::{Stamp, fitted, placed};
fn opened<'a>(cx: &'a mut TestAppContext, dir: &Path) -> (&'a mut VisualTestContext, Entity<PdfView>) {
let path = super::file(dir, "doc.pdf", &[(612.0, 792.0, &["Hello"])]);
let (viewer, cx) = windowed(cx, dir, &[path], true);
let view = shown(cx, &viewer);
(cx, view)
}
fn place(cx: &mut VisualTestContext, view: &Entity<PdfView>, stamp: Stamp) {
cx.update(|_, app| view.update(app, |view, cx| {
view.set_tool(Tool::PlaceSignature, cx);
view.stamp = Some(stamp);
view.place_stamp(0, [100.0, 500.0, 100.0, 500.0], true, cx);
}));
settle(cx, |app| view.read(app).edited == 1);
assert_eq!(cx.update(|_, app| view.read(app).tool), Tool::Read, "one place, then reading");
}
#[test]
fn strokes_fit_by_their_longer_side_and_a_click_places_a_set_width() {
let (strokes, aspect) = fitted(&[vec![(10.0, 10.0), (110.0, 60.0)]]).expect("a stroke");
assert_eq!((strokes[0][1], aspect), ((1.0, 0.5), 0.5));
assert!(fitted(&[vec![(1.0, 1.0)]]).is_none(), "a dot is not a signature");
assert_eq!(placed(0.5, [10.0, 20.0, 10.0, 20.0], true), [10.0, 20.0, 170.0, 100.0]);
assert_eq!(placed(0.5, [0.0, 0.0, 300.0, 50.0], false), [0.0, 0.0, 100.0, 50.0], "fits the drag");
}
#[test]
fn a_drawn_signature_lands_as_ink() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (cx, view) = opened(&mut cx, dir.path());
place(cx, &view, Stamp::Ink { strokes: vec![vec![(0.0, 0.0), (1.0, 0.3)]], aspect: 0.3 });
cx.update(|_, app| view.update(app, |view, cx| view.ask_notes([0], cx)));
settle(cx, |app| view.read(app).notes.contains_key(&0));
let kinds: Vec<String> = cx.update(|_, app| view.read(app).notes[&0].annots.iter().map(|a| a.kind.clone()).collect());
assert_eq!(kinds, vec!["Ink".to_string()]);
}
#[test]
fn a_typed_signature_lands_as_text() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (cx, view) = opened(&mut cx, dir.path());
place(cx, &view, Stamp::Typed { text: "Ada Lovelace".into(), font: "script".into() });
cx.update(|_, app| view.update(app, |view, cx| view.page_text(0, cx)));
settle(cx, |app| view.read(app).text.contains_key(&0));
let text: String = cx.update(|_, app| view.read(app).text[&0].iter().map(|c| c.c).collect());
assert!(text.contains("Ada Lovelace"), "{text}");
}
#[test]
fn a_picture_lands_in_its_own_proportions() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let (cx, view) = opened(&mut cx, dir.path());
let picture = dir.path().join("sig.png");
image::RgbImage::from_pixel(40, 10, image::Rgb([0, 0, 0])).save(&picture).expect("writes");
place(cx, &view, Stamp::Picture { path: picture, aspect: 0.25 });
cx.update(|_, app| view.update(app, |view, cx| {
view.set_tool(Tool::Edit, cx);
view.ask_layers(&[0], cx);
}));
settle(cx, |app| view.read(app).layers.contains_key(&0));
let drawn = cx.update(|_, app| view.read(app).layers[&0].drawn.clone());
assert_eq!(drawn.len(), 1);
let r = drawn[0].rect;
assert!(((r[2] - r[0]) - 160.0).abs() < 0.5 && ((r[3] - r[1]) - 40.0).abs() < 0.5, "{r:?}");
}
#[test]
fn the_dialog_hands_a_drawing_to_the_place_tool() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let path = super::file(dir.path(), "doc.pdf", &[(612.0, 792.0, &["Hello"])]);
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
let view = shown(cx, &viewer);
cx.update(|window, app| viewer.update(app, |viewer, cx| viewer.ask_visible(window, cx)));
cx.update(|_, app| viewer.update(app, |viewer, cx| {
let dot = vec![gpui::point(gpui::px(5.0), gpui::px(5.0))];
viewer.making.as_mut().expect("open").strokes = vec![dot];
viewer.ready_stamp(cx);
}));
assert!(cx.update(|_, app| viewer.read(app).making.is_some()), "a dot is not ready");
cx.update(|_, app| viewer.update(app, |viewer, cx| {
viewer.making.as_mut().expect("open").strokes = vec![vec![gpui::point(gpui::px(0.0), gpui::px(0.0)), gpui::point(gpui::px(80.0), gpui::px(20.0))]];
viewer.ready_stamp(cx);
}));
assert!(cx.update(|_, app| viewer.read(app).making.is_none()));
let (tool, stamp) = cx.update(|_, app| (view.read(app).tool, view.read(app).stamp.clone()));
assert_eq!(tool, Tool::PlaceSignature);
assert!(matches!(stamp, Some(Stamp::Ink { aspect, .. }) if (aspect - 0.25).abs() < 1e-6));
}
+338
View File
@@ -0,0 +1,338 @@
//! One open PDF: its pages, tiles, text and picks (PDF-01).
use std::{
collections::{BTreeSet, HashMap, HashSet, VecDeque},
io,
sync::{Arc, Mutex},
};
use ely_pdf_engine::{Bookmark, Char, Doc, Worker};
use gpui::{
Context, EventEmitter, FocusHandle, InteractiveElement as _, IntoElement, ParentElement as _,
Render, RenderImage, ScrollHandle, Styled as _, Window, div,
};
use crate::engine::{Loaded, MEASURE, Mupdf};
use crate::layout::{Mode, Zoom};
use crate::pages::{TileKey, rect_on};
use crate::select::Selection;
/// A failure to show; the window toasts it.
pub struct Failed(pub String);
/// The document changed what it shows.
pub struct Reported;
pub struct PdfView {
worker: Arc<Mutex<Mupdf>>,
pub doc: Doc,
/// Page sizes in points; unmeasured ones copy the first.
pub sizes: Vec<(f32, f32)>,
pub mode: Mode,
pub zoom: Zoom,
pub rotate: i32,
/// The page at the top, or the one shown.
pub current: usize,
pub scroll: ScrollHandle,
pub tiles: HashMap<TileKey, Arc<RenderImage>>,
/// Images no longer drawn, freed at the next render.
pub retired: ely_gpui_component::primitives::Retired,
/// Tiles the worker could not draw; not asked again.
pub failed: HashSet<TileKey>,
pub text: HashMap<usize, Arc<Vec<Char>>>,
pub asking_text: HashSet<usize>,
pub marks: Vec<Bookmark>,
pub selection: Option<Selection>,
/// Found words by page, each with its quads.
pub found: Vec<(usize, Vec<[f32; 8]>)>,
/// The found word chosen.
pub chosen: Option<usize>,
/// Where to scroll once laid out, (x, y) content pixels.
pub go_to: Option<(f32, f32)>,
/// A tile is on its way from the worker.
pub painting: bool,
/// The view's size and place, as last painted.
pub room: gpui::Bounds<gpui::Pixels>,
pub focus: FocusHandle,
/// Counts edits; replies from before one are dropped.
pub version: u64,
/// Edits landed so far.
pub edited: u64,
/// Edits waiting their turn, and whether one runs.
pub queue: VecDeque<crate::edits::Queued>,
pub editing: bool,
/// History marks: now, and at the last load or save.
pub mark: String,
pub saved: String,
/// Page pictures for the side panel, by page and darkness.
pub thumbs: HashMap<(usize, bool), Arc<RenderImage>>,
pub thumbs_failed: HashSet<usize>,
/// A picture is on its way from the worker.
pub thumbing: bool,
/// Pages picked in the side panel, and the anchor.
pub picked: BTreeSet<usize>,
pub anchor: Option<usize>,
pub tool: crate::content::Tool,
/// What pages hold, read while editing.
pub layers: crate::content::Layers,
pub reading_layer: bool,
pub aim: Option<crate::content::Aim>,
pub drag: Option<crate::content::Drag>,
/// The gesture's page, its window origin and scale.
pub grip: Option<(usize, gpui::Point<gpui::Pixels>, f32)>,
/// The aim when a gesture began; the panel keeps it.
pub held: Option<crate::content::Aim>,
/// Annotations and fields by page, for every tool.
pub notes: crate::annotate::Notes,
pub reading_notes: bool,
/// Links by page, read with the notes.
pub links: HashMap<usize, Vec<ely_pdf_engine::Link>>,
/// Every field name in the form, read for new fields.
pub form_names: Option<Vec<String>>,
pub reading_names: bool,
/// The bookmark picked in the side panel, depth first.
pub mark_pick: Option<usize>,
/// Attachments, read when listed, and the one picked.
pub files: Option<Vec<ely_pdf_engine::Attachment>>,
pub reading_files: bool,
pub file_pick: Option<String>,
/// Regions marked to remove; never in the document.
pub redactions: Vec<ely_pdf_engine::Region>,
pub sketch: Option<crate::annotate::Sketch>,
/// A pick to mark once its text is read.
pub marking: Option<crate::select::Selection>,
/// Where the last text pick was pressed, in the window.
pub pressed: Option<gpui::Point<gpui::Pixels>>,
/// What the document allows, `proto::may` bits.
pub allowed: u32,
/// The file read, and its checked signature fields.
pub signatures: Option<(std::path::PathBuf, Vec<crate::sign::Checked>)>,
pub reading_signatures: bool,
pub sign_at: Option<crate::sign::SignAt>,
/// A visible signature waiting for its place.
pub stamp: Option<crate::visible::Stamp>,
/// MuPDF's words, when it repaired the file.
pub repair: Option<String>,
/// A draw met damage; the tab hears once.
pub damage_seen: bool,
}
/// Why a request was refused, by what it needs.
pub fn restricted(needed: &str, cx: &gpui::App) -> gpui::SharedString {
let what = ely_ui::i18n::text(cx, &format!("pdf.may.{needed}"), &[]);
ely_ui::i18n::text(cx, "pdf.restricted", &[("what", &what)])
}
/// Page sizes in points; unmeasured pages copy the first.
pub fn sizes(pages: usize, first: &[[f32; 4]]) -> Vec<(f32, f32)> {
let size = |b: &[f32; 4]| (b[2] - b[0], b[3] - b[1]);
let mut sizes = vec![size(&first[0]); pages];
for (at, bounds) in first.iter().enumerate() {
sizes[at] = size(bounds);
}
sizes
}
impl EventEmitter<Failed> for PdfView {}
impl EventEmitter<Reported> for PdfView {}
impl PdfView {
/// All its images, for a closing tab to free.
pub fn retire_all(&mut self) -> ely_gpui_component::primitives::Retired {
let mut images = std::mem::take(&mut self.retired);
let tiles = self.tiles.drain().map(|(_, image)| image);
for image in tiles.chain(self.thumbs.drain().map(|(_, image)| image)) {
images.push(image);
}
images
}
pub fn new(worker: Arc<Mutex<Mupdf>>, loaded: Loaded, cx: &mut Context<Self>) -> Self {
let sizes = sizes(loaded.pages, &loaded.first);
log::info!("pdf: a view of {} pages", loaded.pages);
let measured = loaded.first.len();
let mut view = PdfView {
worker,
doc: loaded.doc,
sizes,
mode: Mode::Continuous,
zoom: Zoom::Width,
rotate: 0,
current: 0,
scroll: ScrollHandle::new(),
tiles: HashMap::new(),
retired: Default::default(),
failed: HashSet::new(),
text: HashMap::new(),
asking_text: HashSet::new(),
marks: loaded.marks,
selection: None,
found: Vec::new(),
chosen: None,
go_to: None,
painting: false,
room: gpui::Bounds::default(),
focus: cx.focus_handle(),
version: 0,
edited: 0,
queue: VecDeque::new(),
editing: false,
saved: loaded.mark.clone(),
mark: loaded.mark,
thumbs: HashMap::new(),
thumbs_failed: HashSet::new(),
thumbing: false,
picked: BTreeSet::new(),
anchor: None,
tool: crate::content::Tool::Read,
layers: HashMap::new(),
reading_layer: false,
aim: None,
drag: None,
held: None,
grip: None,
notes: HashMap::new(),
reading_notes: false,
links: HashMap::new(),
form_names: None,
reading_names: false,
mark_pick: None,
files: None,
reading_files: false,
file_pick: None,
redactions: Vec::new(),
sketch: None,
marking: None,
pressed: None,
allowed: loaded.allowed,
signatures: None,
reading_signatures: false,
sign_at: None,
stamp: None,
repair: loaded.repaired.clone(),
damage_seen: false,
};
view.measure(measured, cx);
view
}
/// Says `what` failed; the window shows it.
pub fn fail(&self, what: &str, error: &io::Error, cx: &mut Context<Self>) {
// Engine text may quote the document: the user sees it.
log::error!("pdf: {what} failed ({:?})", error.kind());
let text = match error.kind() {
_ if ely_pdf_engine::is_stale(error) => ely_ui::i18n::text(cx, "pdf.object.changed", &[]),
io::ErrorKind::PermissionDenied => restricted(&error.to_string(), cx),
_ => ely_ui::i18n::text(cx, "pdf.failed", &[("what", what), ("error", &error.to_string())]),
};
cx.emit(Failed(text.to_string()));
}
/// Runs `work` off the window; edits between drop `then`.
pub fn read<T: Send + 'static>(
&self,
work: impl FnOnce(&mut Worker) -> io::Result<T> + Send + 'static,
then: impl FnOnce(&mut Self, io::Result<T>, &mut Context<Self>) + 'static,
cx: &mut Context<Self>,
) {
let version = self.version;
self.off(
work,
move |view, done, cx| match view.version == version {
true => then(view, done, cx),
false => log::info!("pdf: a read from before an edit is dropped"),
},
cx,
);
}
/// Runs `work` off the window, then `then` here.
pub fn off<T: Send + 'static>(
&self,
work: impl FnOnce(&mut Worker) -> io::Result<T> + Send + 'static,
then: impl FnOnce(&mut Self, io::Result<T>, &mut Context<Self>) + 'static,
cx: &mut Context<Self>,
) {
let worker = self.worker.clone();
cx.spawn(async move |this, cx| {
let done = cx
.background_executor()
.spawn(async move { work(worker.lock().expect("the worker lock").get()?) })
.await;
if this.update(cx, |view, cx| then(view, done, cx)).is_err() {
log::info!("pdf: a reply outlived its view");
}
})
.detach();
}
/// Measures the other pages, a batch at a time.
pub fn measure(&mut self, from: usize, cx: &mut Context<Self>) {
let (doc, count) = (self.doc, (self.sizes.len() - from).min(MEASURE));
if count == 0 {
return;
}
self.read(
move |worker| worker.bounds(doc, from, count),
move |view, read, cx| match read {
Ok(boxes) => {
for (at, b) in boxes.iter().enumerate() {
view.sizes[from + at] = (b[2] - b[0], b[3] - b[1]);
}
cx.emit(Reported);
cx.notify();
view.measure(from + count, cx);
}
Err(error) => view.fail("page sizes", &error, cx),
},
cx,
);
}
/// Scrolls `page` into view, `y` points near the top.
pub fn show(&mut self, page: usize, y: f32, cx: &mut Context<Self>) {
self.current = page;
let laid = self.laid_out();
if let Some(place) = laid.placed.iter().find(|place| place.page == page) {
let (_, top, _, _) = rect_on([0.0, y, 0.0, y], self.sizes[page], self.rotate, laid.scale);
// Across: stay unless the page's left is out of view.
let (left, wide) = (-f32::from(self.scroll.offset().x), f32::from(self.room.size.width));
let x = match place.x >= left && place.x < left + wide {
true => left,
false => (place.x - crate::layout::GAP).max(0.0),
};
self.go_to = Some((x, (place.y + top - crate::layout::GAP * 2.0).max(0.0)));
}
cx.notify();
}
/// Every page tile goes; images are freed next render.
pub fn retire_tiles(&mut self) {
for (_, image) in self.tiles.drain() {
self.retired.push(image);
}
}
/// Changes how pages show; the page read stays in view.
pub fn change(&mut self, change: impl FnOnce(&mut Self, f32), cx: &mut Context<Self>) {
let scale = self.laid_out().scale;
change(self, scale);
self.retire_tiles();
self.show(self.current, 0.0, cx);
}
/// Moves `by` pages; continuous views scroll there.
pub fn turn(&mut self, by: isize, cx: &mut Context<Self>) {
let last = self.sizes.len().saturating_sub(1) as isize;
let page = (self.current as isize + by).clamp(0, last) as usize;
self.show(page, 0.0, cx);
}
}
impl Render for PdfView {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
self.retired.release(window);
let pages = self.page_view(window, cx);
div().size_full().track_focus(&self.focus).child(pages)
}
}
+269
View File
@@ -0,0 +1,269 @@
//! PDFs in the shared shell, one worker behind (PDF-01).
use std::{
path::PathBuf,
sync::{Arc, Mutex},
};
use ely_gpui_component::{
feedback::EmptyState,
primitives::IconName,
shell::{StatusBar, StatusBarItem},
};
use ely_office::{Core, Host, Kind, prelude::*};
use ely_platform::AppId;
use ely_ui::{Root, Shell, commands};
use gpui::{
App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement,
ParentElement as _, PathPromptOptions, Render, SharedString, Styled as _, Window, div,
};
use crate::engine::{Mupdf, Pdf};
use crate::view::PdfView;
pub struct Viewer {
pub core: Core<Viewer>,
pub side: Entity<crate::side::Side>,
/// Crop or split, while asked.
pub ask: Option<crate::ask::Ask>,
pub edit_panel: Entity<crate::editing::EditPanel>,
pub note_panel: Entity<crate::notes::NotePanel>,
/// A layout change shown before it is made.
pub preview: Option<crate::editing::Preview>,
/// Preview pictures no longer drawn, freed next render.
pub retired: ely_gpui_component::primitives::Retired,
pub find: Option<crate::search::Find>,
/// A bookmark title being asked.
pub naming: Option<crate::marks::Naming>,
pub props: Option<crate::props::Props>,
pub ocr: Option<crate::ocr::Ocr>,
/// OCR runs started so far.
pub ocr_runs: u64,
pub removal: Option<crate::redact::Removal>,
pub secure: Option<crate::protect::Secure>,
pub signer: Option<crate::sign::Signer>,
pub making: Option<crate::visible::Making>,
pub signs_open: bool,
pub exporting: Option<crate::output::Exporting>,
pub repair_open: bool,
/// Find boxes opened so far.
pub finds: u64,
}
impl Host for Viewer {
type Extra = ();
type Engine = Pdf;
const KIND: Kind = Kind {
app: AppId::Pdf,
kept: &["pdf"],
save_as: &["pdf"],
macros: None,
};
fn core(&self) -> &Core<Self> {
&self.core
}
fn core_mut(&mut self) -> &mut Core<Self> {
&mut self.core
}
fn read_at(&self, tab: u64, cx: &gpui::App) -> Option<ely_pdf_engine::Doc> {
self.tab(tab).map(|tab| tab.view.read(cx).doc.clone())
}
fn observed(&mut self, id: u64, view: &Entity<PdfView>, cx: &mut Context<Self>) {
// Damage found while drawing: no overwrite from now on.
if view.read(cx).damage_seen {
view.update(cx, |view, _| view.damage_seen = false);
if let Some(tab) = self.tab_mut(id) {
log::warn!("pdf: tab {id} turned out damaged");
tab.risk = Some(ely_office::Risk::Repaired);
}
}
// Words found before an edit are searched again.
let version = view.read(cx).version;
if self.find.as_ref().is_some_and(|find| find.tab == id && find.version != version) {
self.search(cx);
}
// A preview from before an edit is void.
if self.preview.as_ref().is_some_and(|preview| preview.tab == id && preview.version != version) {
self.end_preview();
}
cx.notify();
}
}
/// The window root with no document.
pub fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<Root> {
crate::keys::bind(cx);
let work = ely_platform::data_dir(app)
.map_err(std::io::Error::other)
.and_then(|data| ely_office::fresh_work(&data));
let work = match work {
Ok(work) => work,
Err(error) => ely_platform::startup::fail(app, &format!("{error}")),
};
// The worker starts with the first file (PERF-01).
let viewer = cx.new(|cx| {
let mut host = Viewer::new(Arc::default(), work, cx);
// Recovery points, crash return, autosave (§6.2, §6.3).
ely_office::Recovering::start_recovery(&mut host, cx);
<Viewer as ely_office::Autosaving>::start_autosave(cx);
host
});
let root = ely_ui::document_root(app, viewer.clone().into(), &commands::FILE, crate::keys::menus(cx), window, cx);
viewer.update(cx, |viewer, cx| {
viewer.core.root = root.downgrade();
viewer.core.window = Some(window.window_handle());
window.focus(&viewer.core.focus, cx);
});
for path in ely_ui::launch_files(cx) {
viewer.update(cx, |viewer, cx| viewer.open_file(path, cx));
}
let opening = viewer.clone();
ely_ui::on_open(cx, move |path, cx| {
opening.update(cx, |viewer, cx| viewer.open_file(path, cx))
});
let closing = viewer.clone();
window.on_window_should_close(cx, move |window, cx| {
closing.update(cx, |viewer, cx| viewer.should_close(window, cx))
});
root
}
impl Viewer {
pub fn new(worker: Arc<Mutex<Mupdf>>, work: PathBuf, cx: &mut Context<Self>) -> Self {
let (this, weak) = (cx.entity(), cx.weak_entity());
Viewer {
core: Core::new(worker, work, cx),
side: cx.new(|cx| crate::side::Side::new(&this, cx)),
ask: None,
edit_panel: cx.new(|_| crate::editing::EditPanel::new(weak.clone())),
note_panel: cx.new(|_| crate::notes::NotePanel::new(weak.clone())),
preview: None,
retired: Default::default(),
find: None,
naming: None,
props: None,
ocr: None,
ocr_runs: 0,
removal: None,
secure: None,
signer: None,
making: None,
signs_open: false,
exporting: None,
repair_open: false,
finds: 0,
}
}
pub fn text(cx: &App, key: &str, args: &[(&str, &str)]) -> SharedString {
ely_ui::i18n::text(cx, key, args)
}
/// The active tab's view.
pub fn active_view(&self) -> Option<Entity<PdfView>> {
self.active_tab().map(|tab| tab.view.clone())
}
/// Runs `change` on the active view.
pub fn on_view(&self, change: impl FnOnce(&mut PdfView, &mut Context<PdfView>), cx: &mut App) {
if let Some(view) = self.active_view() {
view.update(cx, change);
}
}
/// Asks for files; each opens in its own tab.
pub fn open_picked(&mut self, _: &commands::Open, _: &mut Window, cx: &mut Context<Self>) {
let picked = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: true,
prompt: None,
});
cx.spawn(async move |this, cx| {
let picked = match picked.await {
Ok(Ok(paths)) => Ok(paths),
Ok(Err(error)) => Err(error.to_string()),
Err(error) => Err(error.to_string()),
};
let done = this.update(cx, |viewer, cx| match picked {
Ok(Some(paths)) => paths.into_iter().for_each(|path| viewer.open_file(path, cx)),
Ok(None) => log::info!("pdf: the open panel was cancelled"),
Err(error) => viewer.panel_failed(&error, cx),
});
if done.is_err() {
log::info!("pdf: the viewer closed before the panel");
}
})
.detach();
}
fn status(&self, cx: &mut Context<Self>) -> StatusBar {
let state = StatusBarItem::new("pdf-state").label(self.save_state(cx));
let Some(view) = self.active_view() else {
return StatusBar::new().left(state);
};
let view = view.read(cx);
let (n, count) = ((view.current + 1).to_string(), view.sizes.len().to_string());
let page = Self::text(cx, "pdf.status.page", &[("n", &n), ("count", &count)]);
StatusBar::new().left(state).left(StatusBarItem::new("pdf-page").label(page))
}
}
impl Render for Viewer {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
self.retired.release(window);
let title = self.active_tab().map_or_else(|| AppId::Pdf.name().into(), |tab| tab.name.clone());
let body = match self.active_view() {
Some(view) => view.into_any_element(),
None => EmptyState::new("pdf-empty", IconName::File, Self::text(cx, "command.blocked.no_document", &[])).into_any_element(),
};
let shell = Shell::new(title, &self.core.focus)
.tabs(self.tab_strip(cx))
.left(self.active_view().map(|_| self.side.clone().into()))
.right(self.active_view().and_then(|view| view.read(cx).panel_aim()).map(|aim| match aim {
crate::content::Aim::Note(_) => self.note_panel.clone().into(),
_ => self.edit_panel.clone().into(),
}))
.status(self.status(cx))
.children(self.file_banner(cx))
.children(self.find_bar(cx))
.child(body);
// Esc leaves a tool from pages and panel alike.
let editing = self.active_view().is_some_and(|view| view.read(cx).tool != crate::content::Tool::Read);
let context = match editing {
true => format!("{} {}", commands::CONTEXT, crate::writes::EDITING),
false => commands::CONTEXT.to_string(),
};
let root = div()
.size_full()
.key_context(context.as_str())
.track_focus(&self.core.focus)
.on_action(cx.listener(Self::open_picked))
.on_action(cx.listener(Self::on_save))
.on_action(cx.listener(Self::on_save_as))
.on_action(cx.listener(Self::on_export))
.on_action(cx.listener(Self::on_print))
.on_action(cx.listener(Self::on_quit))
.on_action(cx.listener(Self::on_close_tab))
.child(shell)
.children(self.core_dialog(cx))
.children(self.ask_element(cx))
.children(self.preview_element(cx))
.children(self.naming_element(cx))
.children(self.props_element(cx))
.children(self.ocr_element(cx))
.children(self.removal_element(cx))
.children(self.secure_element(cx))
.children(self.sign_element(cx))
.children(self.visible_element(cx))
.children(self.signs_element(cx))
.children(self.export_element(cx))
.children(self.repair_element(cx));
let root = crate::writes::on(crate::organize::on(crate::keys::on(root, cx), cx), cx);
crate::document::on(crate::marking::on(root, cx), cx)
}
}
+256
View File
@@ -0,0 +1,256 @@
//! Visible signatures: drawn, typed or pictured (PDF-12).
use std::{io, path::PathBuf};
use ely_gpui_component::{
buttons::{Button, ButtonVariant, SegmentedControl},
data_display::DescriptionList,
forms::{Choice, Input, Select, SignaturePad, Stroke, TextInput},
overlays::Dialog as Card,
theme::ControlSize,
};
use ely_office::Closing as _;
use ely_pdf_engine::{NewAnnot, Shape, Writing, Wrote};
use gpui::{AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, SharedString, Styled as _, Window, div};
use crate::content::Tool;
use crate::view::PdfView;
use crate::viewer::Viewer;
/// The width a click places, in points.
const WIDTH: f32 = 160.0;
/// A visible signature ready to place, in its own units.
#[derive(Clone, Debug, PartialEq)]
pub enum Stamp {
/// Strokes scaled to fit a 1×`aspect` box.
Ink { strokes: Vec<Vec<(f32, f32)>>, aspect: f32 },
Typed { text: String, font: SharedString },
Picture { path: PathBuf, aspect: f32 },
}
#[derive(Clone, Copy, PartialEq)]
enum Mode {
Draw,
Type,
Picture,
}
/// The dialog's state while it is open.
pub struct Making {
mode: Mode,
pub(crate) strokes: Vec<Stroke>,
name: Entity<TextInput>,
font: SharedString,
picture: Option<(PathBuf, f32)>,
}
/// Strokes moved to the corner, the longer side 1.
pub fn fitted(strokes: &[Vec<(f32, f32)>]) -> Option<(Vec<Vec<(f32, f32)>>, f32)> {
if strokes.iter().all(|stroke| stroke.len() < 2) {
return None;
}
let points = || strokes.iter().flatten();
let (x0, y0) = points().fold((f32::MAX, f32::MAX), |(x, y), p| (x.min(p.0), y.min(p.1)));
let (x1, y1) = points().fold((f32::MIN, f32::MIN), |(x, y), p| (x.max(p.0), y.max(p.1)));
// The longer side spans 1, so tall strokes fit too.
let width = (x1 - x0).max(y1 - y0).max(1.0);
let fitted = strokes.iter().map(|stroke| stroke.iter().map(|p| ((p.0 - x0) / width, (p.1 - y0) / width)).collect()).collect();
Some((fitted, (y1 - y0) / width))
}
/// A stamp's box: fitted to a drag, or click sized.
pub fn placed(aspect: f32, rect: [f32; 4], click: bool) -> [f32; 4] {
let (w, h) = (rect[2] - rect[0], rect[3] - rect[1]);
let width = if click { WIDTH } else { w.min(h / aspect.max(0.01)) };
[rect[0], rect[1], rect[0] + width, rect[1] + width * aspect]
}
impl PdfView {
/// Places the ready stamp at `rect` on `page`.
pub fn place_stamp(&mut self, page: usize, rect: [f32; 4], click: bool, cx: &mut Context<Self>) {
let Some(stamp) = self.stamp.take() else {
return;
};
self.set_tool(Tool::Read, cx);
match stamp {
Stamp::Ink { strokes, aspect } => {
let at = placed(aspect, rect, click);
let scale = at[2] - at[0];
let strokes = strokes.iter().map(|stroke| stroke.iter().map(|p| (at[0] + p.0 * scale, at[1] + p.1 * scale)).collect()).collect();
let annot = NewAnnot { page, shape: Shape::Ink(strokes), rgb: Some(0), contents: String::new(), author: String::new() };
self.annotate("placing a drawn signature", vec![annot], cx);
}
Stamp::Typed { text, font } => {
let at = placed(0.35, rect, click);
let writing = Writing {
block: None,
expected: String::new(),
text,
font: font.to_string(),
size: (at[3] - at[1]) * 0.8,
rgb: 0,
bx: [at[0], at[1], self.sizes[page].0 - at[0]],
};
self.settled_edit("placing a typed signature", cx, move |worker, doc| match worker.put_text(doc, page, &writing, 0.0)? {
Wrote::Done(mark) => Ok(mark),
Wrote::Missing(c) => Err(io::Error::new(io::ErrorKind::InvalidInput, format!("the font has no “{c}”"))),
Wrote::Preview(..) => Err(io::Error::other("a preview where a write was asked")),
});
}
Stamp::Picture { path, aspect } => {
let at = placed(aspect, rect, click);
self.settled_edit("placing a signature picture", cx, move |worker, doc| worker.add_image(doc, page, &path, at));
}
}
}
}
impl Viewer {
pub fn ask_visible(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.active_view().is_none() {
return self.no_document(cx);
}
let name = cx.new(|cx| TextInput::new(window, cx));
self.making = Some(Making { mode: Mode::Draw, strokes: Vec::new(), name, font: "script".into(), picture: None });
cx.notify();
}
fn choose_picture(&mut self, cx: &mut Context<Self>) {
self.pick(false, cx, |viewer, path, cx| {
match image::image_dimensions(&path) {
Ok((w, h)) if w > 0 => {
if let Some(making) = viewer.making.as_mut() {
making.picture = Some((path, h as f32 / w as f32));
}
}
Ok(_) => viewer.panel_failed("a picture with no pixels", cx),
Err(error) => viewer.panel_failed(&error.to_string(), cx),
}
cx.notify();
});
}
/// The stamp the dialog describes, if it describes one.
fn stamp(&self, cx: &App) -> Option<Stamp> {
let making = self.making.as_ref()?;
match making.mode {
Mode::Draw => {
let strokes: Vec<Vec<(f32, f32)>> =
making.strokes.iter().map(|stroke| stroke.iter().map(|p| (f32::from(p.x), f32::from(p.y))).collect()).collect();
let (strokes, aspect) = fitted(&strokes)?;
Some(Stamp::Ink { strokes, aspect })
}
Mode::Type => {
let text = making.name.read(cx).text().trim().to_string();
(!text.is_empty()).then(|| Stamp::Typed { text, font: making.font.clone() })
}
Mode::Picture => making.picture.clone().map(|(path, aspect)| Stamp::Picture { path, aspect }),
}
}
/// Hands the stamp to the view's next press.
pub(crate) fn ready_stamp(&mut self, cx: &mut Context<Self>) {
let (Some(stamp), Some(view)) = (self.stamp(cx), self.active_view()) else {
return;
};
self.making = None;
view.update(cx, |view, cx| {
view.set_tool(Tool::PlaceSignature, cx);
view.stamp = Some(stamp);
});
cx.notify();
}
fn set_making(&mut self, cx: &mut Context<Self>, change: impl FnOnce(&mut Making)) {
if let Some(making) = self.making.as_mut() {
change(making);
}
cx.notify();
}
/// The visible signature dialog, while open.
pub fn visible_element(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let making = self.making.as_ref()?;
let this = cx.entity();
let label = |key: &str| Self::text(cx, key, &[]);
let switch = this.clone();
let mode = match making.mode {
Mode::Draw => "draw",
Mode::Type => "type",
Mode::Picture => "picture",
};
let modes = SegmentedControl::new("pdf-visible-mode", mode)
.size(ControlSize::Sm)
.segment("draw", label("pdf.visible.draw"), None)
.segment("type", label("pdf.visible.type"), None)
.segment("picture", label("pdf.visible.picture"), None)
.on_change(move |value, _, cx| {
let mode = match value.as_ref() {
"type" => Mode::Type,
"picture" => Mode::Picture,
_ => Mode::Draw,
};
switch.update(cx, |viewer, cx| viewer.set_making(cx, |making| making.mode = mode))
});
let body = match making.mode {
Mode::Draw => {
let drawn = this.clone();
SignaturePad::new("pdf-visible-pad", making.strokes.clone())
.on_change(move |strokes, _, cx| {
let strokes = strokes.to_vec();
drawn.update(cx, |viewer, cx| viewer.set_making(cx, |making| making.strokes = strokes))
})
.into_any_element()
}
Mode::Type => {
let fonts = std::iter::once(("script", "pdf.font.script"))
.chain(crate::editing::FONTS.iter().copied().filter(|(value, _)| *value != "original"))
.map(|(value, key)| Choice::new(value, label(key)))
.collect::<Vec<_>>();
let picked = this.clone();
let font = Select::new("pdf-visible-font", fonts)
.selected(making.font.clone())
.label(label("pdf.text.font"))
.on_change(move |value, _, cx| {
let value = value.clone();
picked.update(cx, |viewer, cx| viewer.set_making(cx, |making| making.font = value))
});
DescriptionList::new()
.item(label("pdf.visible.name"), div().w_full().child(Input::new(&making.name)))
.item(label("pdf.text.font"), font)
.into_any_element()
}
Mode::Picture => {
let chosen = making.picture.as_ref().map_or_else(|| label("pdf.visible.no_picture"), |(path, _)| ely_office::files::file_name(path));
let pick = this.clone();
div()
.flex()
.gap_2()
.items_center()
.child(div().flex_1().child(chosen))
.child(Button::new("pdf-visible-pick", label("pdf.sign.choose")).on_click(move |_, _, cx| pick.update(cx, |viewer, cx| viewer.choose_picture(cx))))
.into_any_element()
}
};
let closed = this.clone();
let dismiss = move |_: &mut Window, cx: &mut App| {
closed.update(cx, |viewer, cx| {
viewer.making = None;
cx.notify();
})
};
let (ready, go) = (self.stamp(cx).is_some(), label("pdf.visible.place"));
let card = Card::new("pdf-visible", label("pdf.visible.title"), dismiss)
.detail(label("pdf.visible.detail"))
.child(div().flex().flex_col().gap_3().child(modes).child(body))
.action(ely_office::cancel(cx))
.action(move |_| {
Button::new("pdf-visible-go", go)
.variant(ButtonVariant::Primary)
.disabled(!ready)
.on_click(move |_, _, cx| this.update(cx, |viewer, cx| viewer.ready_stamp(cx)))
});
Some(card.into_any_element())
}
}
+264
View File
@@ -0,0 +1,264 @@
//! Writing text, previews, objects and new images (PDF-03, PDF-04).
use std::{io, path::PathBuf, sync::Arc};
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
overlays::Dialog as Card,
primitives::Severity,
};
use ely_office::{Closing as _, Tabs as _};
use ely_pdf_engine::{Writing, Wrote};
use gpui::{
AnyElement, App, Context, IntoElement, KeyBinding, MenuItem, ParentElement as _, Styled as _, Window, actions,
div, img, px,
};
use crate::content::{Aim, Tool};
use crate::editing::Preview;
use crate::viewer::Viewer;
actions!(pdf, [EditContent, AddText, AddImage, StopEditing]);
/// The key context while an editing tool is on.
pub const EDITING: &str = "PdfEditing";
pub fn bind(cx: &mut App) {
cx.bind_keys([KeyBinding::new("escape", StopEditing, Some(EDITING))]);
}
/// The editing items of the Edit menu.
pub fn items(cx: &App) -> [MenuItem; 3] {
let label = |key: &str| Viewer::text(cx, key, &[]);
[
MenuItem::action(label("pdf.tool.edit"), EditContent),
MenuItem::action(label("pdf.tool.text"), AddText),
MenuItem::action(label("pdf.tool.image"), AddImage),
]
}
/// Pixels a point in a preview.
const PREVIEW: f32 = 2.0;
impl Viewer {
/// Turns `tool` on, or off when it is on.
pub(crate) fn toggle_tool(&mut self, tool: Tool, cx: &mut Context<Self>) {
let Some(view) = self.active_view() else {
return self.no_document(cx);
};
view.update(cx, |view, cx| {
let now = if view.tool == tool { Tool::Read } else { tool };
view.set_tool(now, cx)
});
}
/// Writes the panel's text, or empties the block when `erase`.
pub fn write_text(&mut self, erase: bool, cx: &mut Context<Self>) {
let (Some(tab), Some(view)) = (self.core.active, self.active_view()) else {
return;
};
let Some(aim) = view.read(cx).aim else {
return;
};
let Some((page, mut writing)) = self.edit_panel.read(cx).writing(view.read(cx), aim, cx) else {
return;
};
if erase {
writing.text.clear();
}
// A replacement that changes the layout is shown first.
let (replacing, version) = (matches!(aim, Aim::Text { .. }), view.read(cx).version);
let (asked, viewer) = (writing.clone(), cx.weak_entity());
view.update(cx, |view, cx| {
let doc = view.doc;
view.read(
move |worker| worker.put_text(doc, page, &asked, PREVIEW),
move |_, answer, cx| {
// The viewer edits this view; wait until it is free.
cx.defer(move |cx| {
if let Some(viewer) = viewer.upgrade() {
viewer.update(cx, |viewer, cx| viewer.previewed((tab, version, page), writing, answer, replacing, cx));
}
})
},
cx,
);
});
}
/// Shows a layout change, a missing glyph, or writes.
fn previewed(
&mut self,
(tab, version, page): (u64, u64, usize),
writing: Writing,
answer: io::Result<Wrote>,
replacing: bool,
cx: &mut Context<Self>,
) {
match answer {
Ok(Wrote::Missing(c)) => {
let note = Self::text(cx, "pdf.text.missing", &[("char", &c.to_string())]);
self.edit_panel.update(cx, |panel, cx| {
panel.note = Some(note);
cx.notify();
});
}
Ok(Wrote::Preview(pixels, size, grew)) if replacing && (grew || writing.font != "original") => {
let Some(image) = crate::pages::picture(size, pixels) else {
return log::error!("pdf: a preview came back the wrong size");
};
self.end_preview();
self.preview = Some(Preview { tab, version, page, writing, image });
cx.notify();
}
Ok(_) => self.commit_text((tab, version, page), writing, cx),
Err(error) => self.failed_read("previewing text", &error, cx),
}
}
/// Writes for real if tab `tab` is still at `version`.
pub fn commit_text(&mut self, (tab, version, page): (u64, u64, usize), writing: Writing, cx: &mut Context<Self>) {
let Some(view) = self.tab(tab).map(|tab| tab.view.clone()) else {
let text = Self::text(cx, "pdf.object.changed", &[]);
return self.toast(text, Severity::Warning, cx);
};
self.on_same(&view, version, cx, |view, cx| {
view.edit(
"writing text",
move |worker, doc| match worker.put_text(doc, page, &writing, 0.0)? {
Wrote::Done(mark) => Ok(mark),
Wrote::Missing(c) => Err(io::Error::other(format!("the font has no {c}"))),
Wrote::Preview(..) => Err(io::Error::other("a write came back as a preview")),
},
cx,
)
});
}
fn failed_read(&self, what: &str, error: &io::Error, cx: &mut App) {
log::error!("pdf: {what} failed ({:?})", error.kind());
let text = Self::text(cx, "pdf.failed", &[("what", what), ("error", &error.to_string())]);
self.toast(text, Severity::Danger, cx);
}
/// The aimed object: its page and index.
fn aimed_object(&self, cx: &App) -> Option<(usize, usize)> {
match self.active_view()?.read(cx).aim? {
Aim::Object { page, index } => Some((page, index)),
_ => None,
}
}
pub fn delete_object(&mut self, cx: &mut Context<Self>) {
let (Some((page, index)), Some(view)) = (self.aimed_object(cx), self.active_view()) else {
return;
};
let version = view.read(cx).version;
self.on_same(&view, version, cx, |view, cx| {
view.edit("deleting an object", move |worker, doc| worker.delete_object(doc, page, index), cx)
});
}
/// Asks for a picture to take the aimed image's place.
pub fn replace_image(&mut self, cx: &mut Context<Self>) {
let (Some((page, index)), Some(view)) = (self.aimed_object(cx), self.active_view()) else {
return;
};
let version = view.read(cx).version;
self.pick(false, cx, move |viewer, path: PathBuf, cx| {
viewer.on_same(&view, version, cx, |view, cx| {
view.edit("replacing an image", move |worker, doc| worker.replace_image(doc, page, index, &path), cx)
})
});
}
/// Runs `step` on `view` if no edit came since `version`.
pub(crate) fn on_same(
&self,
view: &gpui::Entity<crate::view::PdfView>,
version: u64,
cx: &mut Context<Self>,
step: impl FnOnce(&mut crate::view::PdfView, &mut Context<crate::view::PdfView>),
) {
// Landed or queued, an edit since may move the target.
let open = self.core.tabs.iter().any(|tab| tab.view == *view);
if !open || view.read(cx).version != version || !view.read(cx).settled() {
let text = Self::text(cx, "pdf.object.changed", &[]);
return self.toast(text, Severity::Warning, cx);
}
view.update(cx, step);
}
/// Asks for a picture; it lands mid-page.
fn add_image(&mut self, cx: &mut Context<Self>) {
let Some(view) = self.active_view() else {
return self.no_document(cx);
};
let (version, page) = (view.read(cx).version, view.read(cx).current);
self.pick(false, cx, move |viewer, path: PathBuf, cx| {
let (w, h) = match image::image_dimensions(&path) {
Ok(size) => size,
Err(error) => return viewer.failed_read("reading the picture", &io::Error::other(error), cx),
};
viewer.on_same(&view, version, cx, |view, cx| {
let (pw, ph) = view.sizes[page];
// Pixels as points, shrunk to half the page.
let fit = (pw * 0.5 / w as f32).min(ph * 0.5 / h as f32).min(0.75);
let (iw, ih) = (w as f32 * fit, h as f32 * fit);
let rect = [(pw - iw) / 2.0, (ph - ih) / 2.0, (pw + iw) / 2.0, (ph + ih) / 2.0];
view.edit("adding an image", move |worker, doc| worker.add_image(doc, page, &path, rect), cx)
});
});
}
/// The preview of a layout change, while open.
pub fn preview_element(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let preview = self.preview.as_ref()?;
let this = cx.entity();
let dismiss = {
let this = this.clone();
move |_: &mut Window, cx: &mut App| {
this.update(cx, |viewer, cx| {
viewer.end_preview();
cx.notify();
})
}
};
let go = Self::text(cx, "pdf.text.apply", &[]);
let picture = img(Arc::clone(&preview.image)).max_w(px(560.0)).max_h(px(420.0));
let card = Card::new("pdf-preview", Self::text(cx, "pdf.preview.title", &[]), dismiss)
.detail(Self::text(cx, "pdf.preview.detail", &[]))
.child(div().flex().justify_center().child(picture))
.action(ely_office::cancel(cx))
.action(move |_| {
Button::new("pdf-preview-go", go).variant(ButtonVariant::Primary).on_click(move |_, _, cx| {
this.update(cx, |viewer, cx| {
if let Some(Preview { tab, version, page, writing, image }) = viewer.preview.take() {
viewer.retired.push(image);
viewer.commit_text((tab, version, page), writing, cx);
}
cx.notify();
})
})
});
Some(card.into_any_element())
}
}
/// Wires the editing actions onto the root.
pub fn on(root: gpui::Div, cx: &mut Context<Viewer>) -> gpui::Div {
use gpui::InteractiveElement as _;
root.on_action(cx.listener(|v, _: &EditContent, _, cx| v.toggle_tool(Tool::Edit, cx)))
.on_action(cx.listener(|v, _: &AddText, _, cx| v.toggle_tool(Tool::AddText, cx)))
.on_action(cx.listener(|v, _: &AddImage, _, cx| v.add_image(cx)))
.on_action(cx.listener(|v, _: &StopEditing, _, cx| v.on_view(|view, cx| view.set_tool(Tool::Read, cx), cx)))
}
impl Viewer {
/// Drops the text preview; its picture is freed next render.
pub fn end_preview(&mut self) {
if let Some(preview) = self.preview.take() {
self.retired.push(preview.image);
}
}
}