mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-05 18:19:50 +00:00
M5: copy and paste comments between documents, select several, finish connected lines from the menu (#3484)
Comments copy and paste, within and between documents. ⌘C with comments selected (and no text
selected) copies them, each with its replies, as XFDF: into the app's clipboard (every tab and
window) and as text onto the system clipboard. ⌘V, Edit ▸ Paste comments or the page's context
menu pastes them onto the current page at their own positions, as new comments under names of
their own, so nothing is replaced and pasting twice gives two sets; the copies are selected.
XFDF with comments from the system clipboard pastes too, so comments travel between processes.
pdfcraft_xfdf::export_xfdf_comments writes a selection with its replies; import_comments takes
a target page and a name prefix and imports copies (the usual import still replaces same names).
Engine: Edit::PasteComments { xfdf, page } (one undo step, comment permission) and
Session::export_comments. Tools: comments_copy (read-only) and comments_paste.
Several comments select at once: ⌘-click (Ctrl on Windows and Linux) or ⇧-click adds one or
takes it out, on the page and in the Comments panel; the one clicked last has the handles.
Delete removes them all as one step, a drag moves them together, the context menu copies them.
Connected lines, polygons and clouds finish from the right-click menu's Complete (with Undo last
point and Cancel drawing), as other editors do, besides Enter and a double-click.
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
This commit is contained in:
44 files changed
+2317
-227
No files matched your search
@@ -349,6 +349,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
|
||||
- **2026-10-20 (M3, #3541/#3620):** Comment hover follows the individual marked lines instead of the enclosing multiline rectangle, and overlapping comments follow paint order. Synthetic UI regressions cover all four text-markup kinds, page/view rotations, neighboring highlights, legacy rectangle fallback and unchanged PDF bytes. Estimates unchanged.
|
||||
- **2026-10-20 (M10, #3532 partial):** Vector printing retains existing widget artwork when its appearance lacks Subtype. A print-owned copy adds Form dispatch metadata. The source stream and all explicit subtypes stay unchanged. Synthetic source/output raster checks cover resources, selected/shared appearances, placement, flags and content modes. Appearance regeneration and physical printer validation remain outside this fix. Estimates unchanged.
|
||||
- **2026-10-20 (M5, several comments):** Comment Properties opened on one of several selected comments, the context menu's Colour and the quick bar's colour change all of them as one undo step, each where the property applies (a font size to text boxes, a fill to shapes, a width to lines and shapes); one that stays locked keeps its look. Copied Fill & Sign ticks, crosses, dots and lines hang on the pointer as themselves rather than as empty boxes, and copied text boxes show their text (`FillMark::strokes` is shared by the appearance and the outline). Estimates unchanged.
|
||||
- **2026-10-19 (M5, comments between documents):** Comments copy and paste, within and between documents: ⌘C with comments selected copies them as XFDF (each with its replies) to the app's and the system clipboard, ⌘V / Edit ▸ Paste comments / the page menu pastes them onto the current page as new comments under names of their own (`Edit::PasteComments`, `pdfcraft_xfdf::import_comments` with a target page and fresh names, so nothing is replaced and pasting twice gives two sets; `export_xfdf_comments` writes a selection with its replies). Several comments select at once (⌘-click, ⇧-click, on the page and in the panel); Delete removes them as one step and a drag moves them together. Connected lines, polygons and clouds finish from the right-click menu's Complete (Undo last point, Cancel drawing beside it), as other editors do. Tools `comments_copy` / `comments_paste`. Estimates unchanged.
|
||||
|
||||
- **2026-10-20 (M5, arrow keys move comments):** With a comment selected, the arrow keys move it a screen pixel and ⌘/Ctrl or Shift and an arrow ten, instead of scrolling the view or turning the page; up is up on screen at any zoom and rotation, and each press is one undo step. Text markup and locked comments stay put. Estimates unchanged.
|
||||
|
||||
|
||||
@@ -876,46 +876,25 @@ fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -
|
||||
let [x0, y0, x1, y1] = draw;
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
let lw = w.min(h) * 0.12;
|
||||
// The strokes come from `FillMark::strokes`, which a copy's outline draws too.
|
||||
let path = |mark: crate::FillMark| {
|
||||
let ops: Vec<String> = mark
|
||||
.strokes(draw)
|
||||
.iter()
|
||||
.flat_map(|s| s.iter().enumerate().map(|(i, p)| format!("{} {} {}", n(p[0]), n(p[1]), if i == 0 { "m" } else { "l" })))
|
||||
.collect();
|
||||
format!("{} S\n", ops.join(" "))
|
||||
};
|
||||
match name {
|
||||
b"PCCheck" => c.push_str(&format!(
|
||||
"{}{} w 1 J 1 j\n{} {} m {} {} l {} {} l S\n",
|
||||
rg_stroke(col),
|
||||
n(lw),
|
||||
n(x0 + w * 0.15),
|
||||
n(y0 + h * 0.5),
|
||||
n(x0 + w * 0.4),
|
||||
n(y0 + h * 0.2),
|
||||
n(x0 + w * 0.88),
|
||||
n(y0 + h * 0.85)
|
||||
)),
|
||||
b"PCCross" => c.push_str(&format!(
|
||||
"{}{} w 1 J\n{} {} m {} {} l {} {} m {} {} l S\n",
|
||||
rg_stroke(col),
|
||||
n(lw),
|
||||
n(x0 + w * 0.18),
|
||||
n(y0 + h * 0.18),
|
||||
n(x1 - w * 0.18),
|
||||
n(y1 - h * 0.18),
|
||||
n(x0 + w * 0.18),
|
||||
n(y1 - h * 0.18),
|
||||
n(x1 - w * 0.18),
|
||||
n(y0 + h * 0.18)
|
||||
)),
|
||||
b"PCCheck" => c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(col), n(lw), path(crate::FillMark::Check))),
|
||||
b"PCCross" => c.push_str(&format!("{}{} w 1 J\n{}", rg_stroke(col), n(lw), path(crate::FillMark::Cross))),
|
||||
b"PCDot" => {
|
||||
let r = w.min(h) * 0.3;
|
||||
c.push_str(&rg(col));
|
||||
c.push_str(&ellipse(x0 + w / 2.0 - r, y0 + h / 2.0 - r, x0 + w / 2.0 + r, y0 + h / 2.0 + r));
|
||||
c.push_str("f\n");
|
||||
}
|
||||
b"PCLine" => c.push_str(&format!(
|
||||
"{}{} w 1 J\n{} {} m {} {} l S\n",
|
||||
rg_stroke(col),
|
||||
n(h.clamp(0.5, 2.0)),
|
||||
n(x0),
|
||||
n(y0 + h / 2.0),
|
||||
n(x1),
|
||||
n(y0 + h / 2.0)
|
||||
)),
|
||||
b"PCLine" => c.push_str(&format!("{}{} w 1 J\n{}", rg_stroke(col), n(h.clamp(0.5, 2.0)), path(crate::FillMark::Line))),
|
||||
other => {
|
||||
// Only stamps PdfCraft made: others keep their own artwork.
|
||||
if !matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) {
|
||||
|
||||
@@ -124,6 +124,28 @@ impl FillMark {
|
||||
let (w, h) = if matches!(rotation.rem_euclid(360), 90 | 270) { (h, w) } else { (w, h) };
|
||||
[at[0] - w / 2.0, at[1] - h / 2.0, at[0] + w / 2.0, at[1] + h / 2.0]
|
||||
}
|
||||
|
||||
/// The mark whose `/Name` this is.
|
||||
pub fn from_name(n: &[u8]) -> Option<Self> {
|
||||
[FillMark::Check, FillMark::Cross, FillMark::Dot, FillMark::Line].into_iter().find(|m| m.name().as_bytes() == n)
|
||||
}
|
||||
|
||||
/// The pen strokes the mark is drawn with in `rect` (user space): what its appearance strokes,
|
||||
/// and what a copy of it shows while it is being placed. The dot is a filled circle (radius
|
||||
/// 0.3 × the shorter side, centred) and has none.
|
||||
pub fn strokes(self, rect: [f64; 4]) -> Vec<Vec<[f64; 2]>> {
|
||||
let [x0, y0, x1, y1] = rect;
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
match self {
|
||||
FillMark::Check => vec![vec![[x0 + w * 0.15, y0 + h * 0.5], [x0 + w * 0.4, y0 + h * 0.2], [x0 + w * 0.88, y0 + h * 0.85]]],
|
||||
FillMark::Cross => vec![
|
||||
vec![[x0 + w * 0.18, y0 + h * 0.18], [x1 - w * 0.18, y1 - h * 0.18]],
|
||||
vec![[x0 + w * 0.18, y1 - h * 0.18], [x1 - w * 0.18, y0 + h * 0.18]],
|
||||
],
|
||||
FillMark::Dot => Vec::new(),
|
||||
FillMark::Line => vec![vec![[x0, y0 + h / 2.0], [x1, y0 + h / 2.0]]],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The stamps of Acrobat's stamp palette (drawn in PdfCraft's own style).
|
||||
|
||||
@@ -746,6 +746,49 @@ impl Automation {
|
||||
let (page, index) = self.comment_target(a)?;
|
||||
self.apply(a, Edit::DeleteAnnotation { page, index })
|
||||
}
|
||||
|
||||
/// The XFDF of the listed comments and their replies (`comments`: 1-based [page, index] pairs).
|
||||
pub(crate) fn comments_copy(&self, a: &Args) -> Result<Value> {
|
||||
let id = self.doc(a)?.id;
|
||||
let all = self.comments(a)?;
|
||||
let shape = || ToolError::InvalidArgs("comments: [[page, index], …] (1-based, as comment_list reports)".into());
|
||||
let list = a.get("comments").and_then(Value::as_array).ok_or_else(shape)?;
|
||||
let mut which = Vec::with_capacity(list.len());
|
||||
for item in list {
|
||||
let pair = item.as_array().filter(|p| p.len() == 2).and_then(|p| Some((p.first()?.as_u64()?, p.get(1)?.as_u64()?)));
|
||||
let Some((page, index)) = pair.filter(|(p, i)| *p >= 1 && *i >= 1) else { return Err(shape()) };
|
||||
let (page, index) = (usize::try_from(page - 1).map_err(|_| shape())?, usize::try_from(index - 1).map_err(|_| shape())?);
|
||||
if !all.iter().any(|c| c.page == page && c.index == index) {
|
||||
return Err(failed(format!("there is no comment {} on page {} (see comment_list)", index + 1, page + 1)));
|
||||
}
|
||||
which.push((page, index));
|
||||
}
|
||||
let xfdf = self.session.export_comments(id, &which).map_err(failed)?;
|
||||
Ok(json!({ "xfdf": xfdf, "comments": which.len() }))
|
||||
}
|
||||
|
||||
/// Add the comments of `xfdf` (from `comments_copy`) to `page` as new comments.
|
||||
pub(crate) fn comments_paste(&mut self, a: &Args) -> Result<Value> {
|
||||
let page = self.page(a)?;
|
||||
let xfdf = a.str("xfdf")?.to_owned();
|
||||
if !pdfcraft_engine::is_xfdf_comments(&xfdf) {
|
||||
return Err(ToolError::InvalidArgs("xfdf: the text comments_copy returned (XFDF with <annots>)".into()));
|
||||
}
|
||||
let offset = match a.nums::<2>("at")? {
|
||||
Some([x, y]) => {
|
||||
let info = self.doc(a)?.info.pages.get(page).cloned().ok_or_else(|| failed(format!("no page {}", page + 1)))?;
|
||||
let [cx, cy] = to_user(&info, x, y);
|
||||
let b =
|
||||
pdfcraft_engine::comments_bounds(&xfdf).ok_or_else(|| ToolError::InvalidArgs("xfdf: no comment with a rect to place".into()))?;
|
||||
Some([cx - (b[0] + b[2]) / 2.0, cy - (b[1] + b[3]) / 2.0])
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let before = self.comments(a)?.len();
|
||||
let mut out = self.apply(a, Edit::PasteComments { xfdf, page, offset })?;
|
||||
out["comments"] = json!(self.comments(a)?.len().saturating_sub(before));
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
impl Automation {
|
||||
|
||||
@@ -867,6 +867,8 @@ impl Automation {
|
||||
"comments_summarize" => self.comments_summarize(&a)?,
|
||||
"comment_edit" => self.comment_edit(&a)?,
|
||||
"comment_delete" => self.comment_delete(&a)?,
|
||||
"comments_copy" => self.comments_copy(&a)?,
|
||||
"comments_paste" => self.comments_paste(&a)?,
|
||||
other => return Err(ToolError::UnknownTool(other.into())),
|
||||
};
|
||||
Ok(vec![Content::Json(out)])
|
||||
|
||||
@@ -1013,6 +1013,25 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t("comment_image_preview", "Preview image signature layers", "Return a PNG of the page without the selected image signature/initials (layer=background, default), or its embedded image with alpha (layer=image). Includes the displayed rectangle, document rotation, image_rotation (how far clockwise the image is shown turned) and annotation opacity. Cache these layers for live placement/resizing; commit once with comment_edit. Does not change the document or undo history.")
|
||||
.ro().with(schema(comment_ref(json!({ "layer": { "type": "string", "enum": ["background", "image"], "default": "background" }, "dpi": { "type": "number", "minimum": 1, "maximum": 600, "default": 96 } })), &["doc"])),
|
||||
t("comment_delete", "Delete a comment", "Delete a comment with its pop-up and replies, or one reply (its id, or page and index, from comment_list) with its pop-up and the replies to it; the comment and its other replies stay. Undoable.").destructive().with(schema(comment_ref(json!({})), &["doc"])),
|
||||
t(
|
||||
"comments_copy",
|
||||
"Copy comments",
|
||||
"The XFDF of these comments, each with its replies: what Copy puts on the clipboard. Paste it into this or another document with comments_paste. `comments`: [[page, index], …], 1-based as comment_list reports them.",
|
||||
)
|
||||
.ro()
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(),
|
||||
"comments": { "type": "array", "items": { "type": "array", "items": { "type": "integer", "minimum": 1 }, "minItems": 2, "maxItems": 2 }, "minItems": 1 }
|
||||
}),
|
||||
&["doc", "comments"],
|
||||
)),
|
||||
t(
|
||||
"comments_paste",
|
||||
"Paste comments",
|
||||
"Add the comments of an XFDF text (from comments_copy) to `page` as new comments: copies under names of their own, so nothing in the document is replaced and pasting twice gives two sets; replies keep their copied parent. `at` [x, y] (points, origin at the top-left of the displayed page, y down, as comment_add takes them) puts the centre of the copies' bounding box there; without it they keep their positions. Undoable.",
|
||||
)
|
||||
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "xfdf": { "type": "string" }, "at": point() }), &["doc", "page", "xfdf"])),
|
||||
t(
|
||||
"doc_protect",
|
||||
"Protect with passwords",
|
||||
|
||||
@@ -10975,3 +10975,49 @@ fn a_bad_bookmark_tree_is_refused_whole_and_names_the_node() {
|
||||
assert!(!out.exists());
|
||||
assert_eq!(ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"], json!([]));
|
||||
}
|
||||
|
||||
/// Copy and paste comments between documents through the tools: the copies carry their replies,
|
||||
/// land on the asked-for page under their own names (a second paste gives a second set), and a
|
||||
/// plain text is refused.
|
||||
#[test]
|
||||
fn comments_copy_and_paste_between_documents_through_tools() {
|
||||
let dir = workdir("comments-clipboard");
|
||||
let mut a = auto(&dir);
|
||||
let src = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "comment_add", json!({ "doc": src, "page": 2, "type": "note", "at": [30, 30], "contents": "Please review", "author": "Ada" }));
|
||||
ok(&mut a, "comment_add", json!({ "doc": src, "page": 2, "type": "rectangle", "rect": [20, 20, 80, 60], "contents": "Box" }));
|
||||
ok(&mut a, "comment_reply", json!({ "doc": src, "page": 2, "index": 1, "text": "Done", "author": "Bob" }));
|
||||
// Positions as comment_list reports them (a note's pop-up takes a position of its own).
|
||||
let listed = ok(&mut a, "comment_list", json!({ "doc": src, "page": 2 }));
|
||||
let pairs: Vec<Value> = listed["comments"].as_array().unwrap().iter().map(|c| json!([c["page"], c["index"]])).collect();
|
||||
assert_eq!(pairs.len(), 2, "{listed}");
|
||||
let copied = ok(&mut a, "comments_copy", json!({ "doc": src, "comments": pairs }));
|
||||
assert_eq!(copied["comments"], 2);
|
||||
let xfdf = copied["xfdf"].as_str().unwrap().to_owned();
|
||||
assert!(xfdf.contains("inreplyto=") && xfdf.contains("<square"), "{xfdf}");
|
||||
let dst = ok(&mut a, "doc_open", json!({ "path": "b.pdf" }))["doc"].as_u64().unwrap();
|
||||
let r = ok(&mut a, "comments_paste", json!({ "doc": dst, "page": 1, "xfdf": xfdf }));
|
||||
assert_eq!(r["comments"], 3, "two comments and the reply: {r}");
|
||||
assert_eq!(r["undo"], "Paste comments");
|
||||
let list = ok(&mut a, "comment_list", json!({ "doc": dst }));
|
||||
let comments = list["comments"].as_array().unwrap();
|
||||
assert_eq!(comments.len(), 2, "the reply is in its thread, not a comment of its own: {list}");
|
||||
assert!(comments.iter().all(|c| c["page"] == 1), "{list}");
|
||||
assert_eq!(comments.iter().filter(|c| c["contents"] == "Please review").count(), 1);
|
||||
// The same again: a second set, not a replacement of the first.
|
||||
ok(&mut a, "comments_paste", json!({ "doc": dst, "page": 1, "xfdf": copied["xfdf"] }));
|
||||
assert_eq!(ok(&mut a, "comment_list", json!({ "doc": dst }))["comments"].as_array().unwrap().len(), 4);
|
||||
// Placed: the copies' box is centred at `at` (top-left page coordinates, like comment_add).
|
||||
let r = ok(&mut a, "comments_paste", json!({ "doc": dst, "page": 2, "xfdf": copied["xfdf"], "at": [100, 150] }));
|
||||
assert_eq!(r["comments"], 3);
|
||||
let placed = ok(&mut a, "comment_list", json!({ "doc": dst, "page": 2 }));
|
||||
let rects: Vec<Vec<f64>> =
|
||||
placed["comments"].as_array().unwrap().iter().map(|c| c["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect()).collect();
|
||||
let (x0, y0, x1, y1) =
|
||||
rects.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |(a, b, c, d), r| (a.min(r[0]), b.min(r[1]), c.max(r[2]), d.max(r[3])));
|
||||
assert!(((x0 + x1) / 2.0 - 100.0).abs() < 1.0 && ((y0 + y1) / 2.0 - 150.0).abs() < 1.0, "centred at [100, 150]: {rects:?}");
|
||||
assert!(matches!(a.call("comments_paste", &json!({ "doc": dst, "page": 1, "xfdf": "hello" })), Err(ToolError::InvalidArgs(_))));
|
||||
assert!(a.call("comments_copy", &json!({ "doc": src, "comments": [[9, 9]] })).is_err(), "no such comment");
|
||||
assert!(matches!(a.call("comments_copy", &json!({ "doc": src, "comments": [[2]] })), Err(ToolError::InvalidArgs(_))));
|
||||
assert_eq!(ok(&mut a, "comment_list", json!({ "doc": src }))["comments"].as_array().unwrap().len(), 2, "the source is untouched");
|
||||
}
|
||||
@@ -220,6 +220,10 @@ pub const COMMANDS: &[CommandSpec] = &[
|
||||
// Alt-drag for moving windows (several Linux window managers).
|
||||
// Copy events stay with the frontend so focused text inputs and page clipboard routing keep ownership.
|
||||
c("edit.copy", "Copy", EDIT, None, Copying, "copy"),
|
||||
// Comments travel between documents as XFDF (M14): ⌘C with comments selected copies them,
|
||||
// ⌘V pastes the copies onto the current page; the strip's Copy/Paste in the menus too.
|
||||
c("comment.copy", "Copy comments", EDIT, None, Document, "copy"),
|
||||
c("comment.paste", "Paste comments", EDIT, None, Annotate, "clipboard-paste"),
|
||||
c("edit.column_select", "Column select", EDIT, None, Document, "text-select"),
|
||||
c("edit.snapshot", "Take a snapshot", EDIT, None, Copying, "camera"),
|
||||
c("view.full_screen", "Full screen mode", VIEW, Some(Shortcut::cmd("L")), Document, "maximize"),
|
||||
|
||||
@@ -570,6 +570,15 @@ pub enum Edit {
|
||||
name: String,
|
||||
bytes: Arc<Vec<u8>>,
|
||||
},
|
||||
/// Paste copied comments (XFDF from [`Session::export_comments`]) onto `page` as new
|
||||
/// comments: copies under names of their own, so nothing is replaced and pasting twice
|
||||
/// gives two sets; replies keep their copied parent.
|
||||
PasteComments {
|
||||
xfdf: String,
|
||||
page: usize,
|
||||
/// Where the copies go: moved by (dx, dy) in user space, or at their own positions.
|
||||
offset: Option<[f64; 2]>,
|
||||
},
|
||||
/// Remove Hidden Information: the chosen categories.
|
||||
RemoveHidden {
|
||||
which: Vec<Hidden>,
|
||||
|
||||
@@ -71,6 +71,7 @@ pub const EDIT_LABELS: &[&str] = &[
|
||||
"Add stamp",
|
||||
"Add 3D model",
|
||||
"Delete comment",
|
||||
"Paste comments",
|
||||
"Edit comment",
|
||||
"Reply",
|
||||
"Set status None",
|
||||
@@ -211,6 +212,7 @@ impl Edit {
|
||||
Edit::AddCustomStamp { .. } => Label::of("Add stamp"),
|
||||
Edit::AddModel3d { .. } => Label::of("Add 3D model"),
|
||||
Edit::DeleteAnnotation { .. } => Label::of("Delete comment"),
|
||||
Edit::PasteComments { .. } => Label::of("Paste comments"),
|
||||
Edit::SetAnnotationContents { .. } => Label::of("Edit comment"),
|
||||
Edit::ReplyToAnnotation { .. } => Label::of("Reply"),
|
||||
// The state's name is part of the wording: a language may need to agree it with the rest.
|
||||
|
||||
@@ -49,6 +49,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re
|
||||
}
|
||||
Edit::AddMeasurement(_)
|
||||
| Edit::AddAnnotation(_)
|
||||
| Edit::PasteComments { .. }
|
||||
| Edit::AddCustomStamp { .. }
|
||||
| Edit::AddModel3d { .. }
|
||||
| Edit::DeleteAnnotation { .. }
|
||||
@@ -496,6 +497,14 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
|
||||
Edit::AddAnnotation(a) => {
|
||||
pdfcraft_annot::add_annotation(doc, a, &cx.meta())?;
|
||||
}
|
||||
Edit::PasteComments { xfdf, page, offset } => {
|
||||
// Names from the edit's own id generator: unique to this paste within the document.
|
||||
let prefix = cx.meta().id;
|
||||
let report = pdfcraft_xfdf::import_comments(doc, xfdf, Some(*page), Some(&prefix), *offset)?;
|
||||
if report.comments == 0 {
|
||||
return Err(EditError::Invalid("the clipboard holds no comments".into()));
|
||||
}
|
||||
}
|
||||
Edit::AddCustomStamp { page, rect, name, file, author } => {
|
||||
let src = mark_source(doc, file)?;
|
||||
let (sw, sh) = (src.size.0.max(1.0), src.size.1.max(1.0));
|
||||
|
||||
@@ -53,6 +53,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope {
|
||||
Edit::AddAnnotation(a) if matches!(a.shape, Shape::Attachment { .. }) => Scope::Full,
|
||||
Edit::AddMeasurement(_)
|
||||
| Edit::AddAnnotation(_)
|
||||
| Edit::PasteComments { .. }
|
||||
| Edit::AddCustomStamp { .. }
|
||||
| Edit::AddModel3d { .. }
|
||||
| Edit::DeleteAnnotation { .. }
|
||||
|
||||
@@ -129,6 +129,7 @@ pub use pdfcraft_redact::sanitize::{HIDDEN, Hidden};
|
||||
pub use pdfcraft_sign as sign;
|
||||
pub use pdfcraft_sign::{SignOptions, SignatureInfo, Status as SignatureStatus, TrustStore};
|
||||
pub use pdfcraft_xfdf::Format as DataFormat;
|
||||
pub use pdfcraft_xfdf::{Outline as CommentOutline, comment_count, comment_outlines, comments_bounds, is_xfdf_comments};
|
||||
|
||||
use bookmarks::outline_rows;
|
||||
pub use bookmarks::{BookmarkPage, BookmarkRow};
|
||||
|
||||
@@ -114,6 +114,19 @@ impl Session {
|
||||
.map_err(EditError::Write)
|
||||
}
|
||||
|
||||
/// XFDF with these comments (0-based page, position in the page's `/Annots`) and their
|
||||
/// replies: what Copy puts on the clipboard; [`Edit::PasteComments`] takes it back in.
|
||||
pub fn export_comments(&self, id: DocId, which: &[(usize, usize)]) -> Result<String, EditError> {
|
||||
let doc = self.get(id).ok_or(EditError::NoDocument)?;
|
||||
let cos = match doc.editor.as_ref() {
|
||||
Some(e) => e.cos.clone(),
|
||||
None => {
|
||||
pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))?
|
||||
}
|
||||
};
|
||||
guard(|| pdfcraft_xfdf::export_xfdf_comments(&cos, which, &doc.name)).map_err(EditError::Write)
|
||||
}
|
||||
|
||||
/// The print-ready PDF for `settings` (sheets laid out for the paper; see `pdfcraft-print`).
|
||||
pub fn print_pdf(&self, id: DocId, settings: &print::Settings) -> Result<Vec<u8>, EditError> {
|
||||
let doc = self.get(id).ok_or(EditError::NoDocument)?;
|
||||
|
||||
@@ -10185,6 +10185,42 @@ fn need_appearances_buttons_are_displayed_from_their_captions() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy and paste: the copies of a comment and its reply land on the asked-for page of another
|
||||
/// document under names of their own (nothing replaced; a second paste gives a second set),
|
||||
/// undo takes a paste away as one step, and a clipboard without comments is refused.
|
||||
#[test]
|
||||
fn pasting_copied_comments_adds_copies_that_undo_as_one_step() {
|
||||
let (mut s, id) = session_with(2);
|
||||
s.apply(id, rect_comment(1, [80.0, 80.0, 120.0, 120.0])).unwrap();
|
||||
s.apply(id, Edit::ReplyToAnnotation { page: 1, index: 0, text: "Done".into(), author: "Ada".into() }).unwrap();
|
||||
let xfdf = s.export_comments(id, &[(1, 0)]).unwrap();
|
||||
assert!(xfdf.contains("<square") && xfdf.contains("inreplyto="), "{xfdf}");
|
||||
let other = s.open("other.pdf", None, Arc::new(fixture(2)), None).expect("opens");
|
||||
s.apply(other, Edit::PasteComments { xfdf: xfdf.clone(), page: 0, offset: None }).unwrap();
|
||||
let a = s.get(other).unwrap().info.annotations.clone();
|
||||
assert_eq!(a.len(), 2, "{a:?}");
|
||||
assert!(a.iter().all(|x| x.page == 0), "on the asked-for page: {a:?}");
|
||||
let parent = a.iter().find(|x| x.in_reply_to.is_none()).unwrap();
|
||||
let reply = a.iter().find(|x| x.in_reply_to.is_some()).unwrap();
|
||||
assert_eq!(reply.in_reply_to, parent.name, "the reply follows its copied parent");
|
||||
assert_ne!(parent.name, s.get(id).unwrap().info.annotations[0].name, "a name of its own");
|
||||
assert_eq!(parent.contents.as_deref(), Some("Look here"));
|
||||
assert_eq!(s.get(other).unwrap().can_undo(), Some("Paste comments"));
|
||||
s.apply(other, Edit::PasteComments { xfdf: xfdf.clone(), page: 1, offset: Some([30.0, -10.0]) }).unwrap();
|
||||
let a = s.get(other).unwrap().info.annotations.clone();
|
||||
assert_eq!(a.len(), 4, "a second set");
|
||||
let placed = a.iter().find(|x| x.page == 1 && x.in_reply_to.is_none()).unwrap();
|
||||
assert!((placed.rect[0] - 110.0).abs() < 0.5 && (placed.rect[1] - 70.0).abs() < 0.5, "moved by the offset: {:?}", placed.rect);
|
||||
assert_eq!(comment_count(&xfdf), 1);
|
||||
assert_eq!(comments_bounds(&xfdf).map(|b| b.map(|v| v.round())), Some([80.0, 80.0, 120.0, 120.0]));
|
||||
s.undo(other).unwrap();
|
||||
assert_eq!(s.get(other).unwrap().info.annotations.len(), 2);
|
||||
assert!(s.apply(other, Edit::PasteComments { xfdf: "hello".into(), page: 0, offset: None }).is_err(), "no comments in it");
|
||||
assert!(s.apply(other, Edit::PasteComments { xfdf, page: 9, offset: None }).is_err(), "no such page");
|
||||
assert_eq!(s.get(other).unwrap().info.annotations.len(), 2, "a refused paste changes nothing");
|
||||
assert_eq!(s.get(id).unwrap().info.annotations.len(), 2, "the source is untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_backdrop_artwork_survives_background_undo_redo_and_both_save_modes() {
|
||||
let white = "1 g";
|
||||
|
||||
@@ -233,6 +233,7 @@ impl PdfCraftApp {
|
||||
scan_clean_run: None,
|
||||
progress_notice: None,
|
||||
page_clipboard: None,
|
||||
comment_clipboard: None,
|
||||
grid_drop: None,
|
||||
last_snapshot: None,
|
||||
last_copied_image: None,
|
||||
|
||||
@@ -11,7 +11,7 @@ pub(crate) fn print_refused() -> &'static str {
|
||||
}
|
||||
|
||||
enum ClipboardPaste {
|
||||
Text,
|
||||
Text(String),
|
||||
Image(std::sync::Arc<egui::ColorImage>),
|
||||
}
|
||||
|
||||
@@ -27,7 +27,8 @@ impl PdfCraftApp {
|
||||
false
|
||||
}
|
||||
|
||||
/// ⌘C / ⌘X / ⌘V copy, cut and paste text or images on canvas (unless a text field has focus).
|
||||
/// ⌘C / ⌘X / ⌘V copy, cut and paste text, images or comments on canvas (unless a text field
|
||||
/// has focus). With no text selected, ⌘C copies the selected comments, each with its replies.
|
||||
pub(super) fn clipboard_keys(&mut self, i: usize, ctx: &egui::Context) {
|
||||
use egui::{Key, KeyboardShortcut, Modifiers};
|
||||
let pastes: Vec<_> = ctx.input(|input| {
|
||||
@@ -35,7 +36,7 @@ impl PdfCraftApp {
|
||||
.events
|
||||
.iter()
|
||||
.filter_map(|event| match event {
|
||||
egui::Event::Paste(_) => Some(ClipboardPaste::Text),
|
||||
egui::Event::Paste(text) => Some(ClipboardPaste::Text(text.clone())),
|
||||
egui::Event::PasteImage(image) => Some(ClipboardPaste::Image(image.clone())),
|
||||
_ => None,
|
||||
})
|
||||
@@ -52,7 +53,9 @@ impl PdfCraftApp {
|
||||
|| ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::X)));
|
||||
let paste = ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::V)));
|
||||
|
||||
let text_selected = copy && self.views.get(i).and_then(|v| v.selected_text()).is_some_and(|t| !t.is_empty());
|
||||
if copy
|
||||
&& (text_selected || !self.copy_selected_comments(i))
|
||||
&& !self.copy_selected_text(i, ctx)
|
||||
&& let Some(sel) = self.views.get(i).and_then(|v| v.image_selection.clone())
|
||||
&& let Some(id) = self.views.get(i).map(|v| v.id)
|
||||
@@ -89,16 +92,21 @@ impl PdfCraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
fn receive_pastes(&mut self, view: usize, pastes: Vec<ClipboardPaste>, images_allowed: bool) {
|
||||
/// `on_canvas`: the paste is the canvas's (no text field, dialog or page grid has it), so
|
||||
/// pictures and copied comments land on the page.
|
||||
fn receive_pastes(&mut self, view: usize, pastes: Vec<ClipboardPaste>, on_canvas: bool) {
|
||||
let page = self.views.get(view).map_or(0, |view| view.current);
|
||||
for paste in pastes {
|
||||
match paste {
|
||||
ClipboardPaste::Text => {
|
||||
ClipboardPaste::Text(text) => {
|
||||
// Text fields may own the paste; the canvas cache still observes its contents.
|
||||
self.last_copied_image = None;
|
||||
self.last_copied_file = None;
|
||||
if on_canvas {
|
||||
self.paste_comment_text(view, text);
|
||||
}
|
||||
}
|
||||
ClipboardPaste::Image(image) if images_allowed => {
|
||||
ClipboardPaste::Image(image) if on_canvas => {
|
||||
self.paste_received_image(page, &image);
|
||||
}
|
||||
ClipboardPaste::Image(_) => {}
|
||||
@@ -106,6 +114,82 @@ impl PdfCraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// A text pasted onto the canvas: XFDF with comments is pasted as comments. Copied comments
|
||||
/// come from the system clipboard when it holds them, from the app's own only when it holds
|
||||
/// nothing legible (a word copied in another app since is that word, not the comments copied
|
||||
/// before it).
|
||||
fn paste_comment_text(&mut self, i: usize, text: String) {
|
||||
if pdfcraft_engine::is_xfdf_comments(&text) {
|
||||
self.paste_comments(i, Some(text), None);
|
||||
} else if text.trim().is_empty() && self.comment_clipboard.is_some() {
|
||||
self.paste_comments(i, None, None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy the selected comments of tab `i` as XFDF, each with its replies: into the app's
|
||||
/// clipboard (for ⌘V in any tab or window) and onto the system clipboard as text. False
|
||||
/// when nothing is selected.
|
||||
pub fn copy_selected_comments(&mut self, i: usize) -> bool {
|
||||
let Some(view) = self.views.get(i) else { return false };
|
||||
let keys = view.comments.selection();
|
||||
if keys.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let id = view.id;
|
||||
match self.session.export_comments(id, &keys) {
|
||||
Ok(xfdf) => {
|
||||
if let Some(ctx) = &self.ctx {
|
||||
ctx.copy_text(xfdf.clone());
|
||||
}
|
||||
self.comment_clipboard = Some(CommentClip { xfdf, count: keys.len() });
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
self.notify_error(e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Paste copied comments (`text`, XFDF from the system clipboard, or else the app's
|
||||
/// clipboard) into tab `i`. With `at` (a page and a user-space point, from the page's menu)
|
||||
/// the bundle is placed at once, centred there; otherwise it hangs on the pointer as one
|
||||
/// bundle until a click places it (`Gesture::Place`), so a copy never lands on its original
|
||||
/// and a bundle from a large page goes where it fits on a small one. The copies are selected
|
||||
/// once placed (see `apply_edit`).
|
||||
pub fn paste_comments(&mut self, i: usize, text: Option<String>, at: Option<(usize, [f64; 2])>) -> bool {
|
||||
let Some(xfdf) = text.or_else(|| self.comment_clipboard.as_ref().map(|c| c.xfdf.clone())) else {
|
||||
self.notify_tr("Copy comments first");
|
||||
return false;
|
||||
};
|
||||
let Some(view) = self.views.get(i) else { return false };
|
||||
let id = view.id;
|
||||
let Some(doc) = self.session.get(id) else { return false };
|
||||
if !doc.allows_annotation() {
|
||||
self.notify_tr("The document's security settings don't allow this change");
|
||||
return false;
|
||||
}
|
||||
let count = pdfcraft_engine::comment_count(&xfdf);
|
||||
let Some(bounds) = pdfcraft_engine::comments_bounds(&xfdf).filter(|_| count > 0) else {
|
||||
self.notify_tr("The clipboard holds no comments");
|
||||
return false;
|
||||
};
|
||||
match at {
|
||||
Some((page, [x, y])) => {
|
||||
let offset = [x - (bounds[0] + bounds[2]) / 2.0, y - (bounds[1] + bounds[3]) / 2.0];
|
||||
self.apply_edit(pdfcraft_engine::Edit::PasteComments { xfdf, page, offset: Some(offset) })
|
||||
}
|
||||
None => {
|
||||
let Some(view) = self.views.get_mut(i) else { return false };
|
||||
view.comments.clear_selection();
|
||||
let outlines = pdfcraft_engine::comment_outlines(&xfdf);
|
||||
view.comments.gesture = Some(comments::Gesture::Place { xfdf, bounds, count, outlines });
|
||||
self.notify_tr("Click where the copied comments go; Escape lets them go");
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared permission gate for copying selected page text from menus and keyboard events.
|
||||
/// After Select All of the document (#2639), pages whose text wasn't read for display are read
|
||||
/// off the UI thread ([`DocumentCopy`]), and the clipboard gets the text when they're done.
|
||||
|
||||
@@ -393,7 +393,7 @@ impl PdfCraftApp {
|
||||
let (p, i) = value.split_once(':').ok_or("comment: PAGE:INDEX")?;
|
||||
let (p, i): (usize, usize) =
|
||||
(p.trim().parse().map_err(|_| "comment: PAGE:INDEX")?, i.trim().parse().map_err(|_| "comment: PAGE:INDEX")?);
|
||||
v.comments.selected = Some((p.saturating_sub(1), i.saturating_sub(1)));
|
||||
v.comments.select_only((p.saturating_sub(1), i.saturating_sub(1)));
|
||||
v.comments.reveal = true;
|
||||
}
|
||||
// `--combine-view list`: Combine files as thumbnails (grid) or as the table (list).
|
||||
|
||||
@@ -787,6 +787,8 @@ pub(crate) enum PageMenuAction {
|
||||
MarqueeZoom,
|
||||
Copy,
|
||||
Paste(usize, [f64; 2]),
|
||||
/// Paste the copied comments, centred at this point of the page.
|
||||
PasteComments(usize, [f64; 2]),
|
||||
AddComment(usize, [f64; 2]),
|
||||
AddBookmark,
|
||||
TakeSnapshot,
|
||||
@@ -808,6 +810,8 @@ pub(crate) struct PageMenuPerms {
|
||||
pub can_copy: bool,
|
||||
pub can_copy_items: bool,
|
||||
pub can_paste: bool,
|
||||
/// Copied comments wait to be pasted, and comments may be added.
|
||||
pub can_paste_comments: bool,
|
||||
pub has_annotations: bool,
|
||||
}
|
||||
|
||||
@@ -849,6 +853,9 @@ pub(crate) fn default_page_context_menu(ui: &mut egui::Ui, page: usize, at: [f64
|
||||
ui,
|
||||
A::Paste(page, at),
|
||||
);
|
||||
if perms.can_paste_comments {
|
||||
pick(ui.button(tl!("Paste comments")), ui, A::PasteComments(page, at));
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
@@ -1156,6 +1163,10 @@ struct PageEnv<'a> {
|
||||
by_line: &'a String,
|
||||
calc_order: bool,
|
||||
can_copy: bool,
|
||||
/// Copied comments wait to be pasted.
|
||||
can_paste_comments: bool,
|
||||
/// Copied comments hang on the pointer (`Gesture::Place`): the click is theirs.
|
||||
placing: bool,
|
||||
can_crop: bool,
|
||||
can_fill: bool,
|
||||
can_modify: bool,
|
||||
@@ -1212,6 +1223,8 @@ struct PageEnv<'a> {
|
||||
struct PageOut {
|
||||
canvas_action: Option<comments::CanvasAction>,
|
||||
clicked_link: Option<LinkTarget>,
|
||||
comment_notice: Option<&'static str>,
|
||||
copy_comments: bool,
|
||||
copy_selected: bool,
|
||||
field_menu: Option<FieldMenu>,
|
||||
field_placed: bool,
|
||||
@@ -1223,6 +1236,7 @@ struct PageOut {
|
||||
open_signature: bool,
|
||||
open_table: Option<(usize, [f64; 4])>,
|
||||
page_action: Option<PageMenuAction>,
|
||||
paste_comments_at: Option<(usize, [f64; 2])>,
|
||||
primary_down: bool,
|
||||
refused: Option<String>,
|
||||
/// The current page or spread where it is drawn, every page of it: one off screen (the second
|
||||
@@ -1603,6 +1617,7 @@ fn draw_pages(
|
||||
coarse_scroll,
|
||||
calc_order,
|
||||
can_copy,
|
||||
can_paste_comments,
|
||||
can_modify,
|
||||
content_h,
|
||||
content_w,
|
||||
@@ -1799,6 +1814,7 @@ fn draw_pages(
|
||||
refused,
|
||||
row_on_screen: _,
|
||||
stamp_placed,
|
||||
..
|
||||
} = page_out;
|
||||
let mut page_action: Option<PageMenuAction> = None;
|
||||
let painter = ui.painter();
|
||||
@@ -1960,8 +1976,18 @@ fn draw_pages(
|
||||
crate::edit_text_ui::image_context_menu(ui, view, &images, sel.page, sel.index, can_paste_image, &mut image_action, at, rotation);
|
||||
return;
|
||||
}
|
||||
if view.comments.selected.is_some() {
|
||||
canvas_action = comments::context_menu(ui, view, info, &|page, index| doc.annotation_deletable(page, index), prefs, allowed, can_modify);
|
||||
// A selected comment, or connected lines being drawn (Complete, Undo last point): the
|
||||
// comment menu.
|
||||
let drawing_points = matches!(&view.comments.gesture, Some(comments::Gesture::Draw { tool, .. }) if tool.clicks_points());
|
||||
if view.comments.selected.is_some() || drawing_points {
|
||||
canvas_action = comments::context_menu(
|
||||
ui,
|
||||
view,
|
||||
info,
|
||||
&|page, index| doc.annotation_deletable(page, index),
|
||||
prefs,
|
||||
comments::MenuRights { allowed, can_paste: can_paste_comments, can_modify },
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Over a picture (and no comment): Copy Image, as in Acrobat's page menu.
|
||||
@@ -2005,6 +2031,7 @@ fn draw_pages(
|
||||
can_copy,
|
||||
can_copy_items,
|
||||
can_paste,
|
||||
can_paste_comments: allowed && can_paste_comments,
|
||||
has_annotations: !info.annotations.is_empty(),
|
||||
};
|
||||
default_page_context_menu(ui, page, at, perms, &mut page_action);
|
||||
@@ -2012,9 +2039,15 @@ fn draw_pages(
|
||||
if !resp.context_menu_opened() {
|
||||
ui.ctx().data_mut(|d| d.insert_temp(crate::edit_text_ui::image_menu_id(), false));
|
||||
}
|
||||
// Copy comments / Paste comments from the page's menu or a Comments panel card.
|
||||
let copy_comments = std::mem::take(&mut view.comments.copy_request);
|
||||
let paste_comments_at = view.comments.paste_request.take();
|
||||
let comment_notice = view.comments.notice.take();
|
||||
*out = PageOut {
|
||||
canvas_action,
|
||||
clicked_link,
|
||||
comment_notice,
|
||||
copy_comments,
|
||||
copy_selected,
|
||||
field_menu,
|
||||
field_placed,
|
||||
@@ -2026,6 +2059,7 @@ fn draw_pages(
|
||||
open_signature,
|
||||
open_table,
|
||||
page_action,
|
||||
paste_comments_at,
|
||||
primary_down: ptr.primary_down,
|
||||
refused,
|
||||
row_on_screen,
|
||||
@@ -2808,6 +2842,15 @@ fn step_glide(ui: &egui::Ui, view: &mut DocView, avail: Rect, cx: &GlideCx) -> V
|
||||
glide
|
||||
}
|
||||
|
||||
/// The tool the page sees this frame, and whether copied comments hang on the pointer. Those
|
||||
/// take the next click whatever tool is on: the page sees the Select tool until the bundle is
|
||||
/// placed or let go, and no form field, added content or field being prepared takes the click
|
||||
/// either.
|
||||
fn page_tool(view: &DocView, chosen: QuickTool) -> (QuickTool, bool) {
|
||||
let placing = matches!(view.comments.gesture, Some(comments::Gesture::Place { .. }));
|
||||
(if placing { QuickTool::Select } else { chosen }, placing)
|
||||
}
|
||||
|
||||
/// Text selection runs for the Select tool and for the markup tools (highlight…).
|
||||
fn tool_selects_text(tool: QuickTool, preparing: bool) -> bool {
|
||||
match tool {
|
||||
@@ -3010,7 +3053,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
let sharp_held = hold_sharp_renders(ui, view, scale);
|
||||
view.current_scale_tag = tag;
|
||||
let hand = app.quick_tool == QuickTool::Hand;
|
||||
let tool = app.quick_tool;
|
||||
let (tool, placing) = page_tool(view, app.quick_tool);
|
||||
// Text selection runs for the Select tool and for the markup tools (highlight…).
|
||||
let selects_text = tool_selects_text(tool, preparing);
|
||||
let prefs = &app.comment_prefs;
|
||||
@@ -3018,6 +3061,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
let units = app.units;
|
||||
let allowed = doc.allows_annotation();
|
||||
let can_copy = doc.allows_copying();
|
||||
let can_paste_comments = app.comment_clipboard.is_some();
|
||||
let comments_hidden = doc.comments_hidden();
|
||||
let form = doc.form.clone();
|
||||
let generation = doc.edit_generation();
|
||||
@@ -3030,11 +3074,12 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
// In Preferences ▸ Date format, or why it can't be stamped into the PDF.
|
||||
let date_text = crate::date_text_for_pdf(&app.session);
|
||||
let by_line = app.session.stamp_by_line(&author);
|
||||
let custom_stamp = match app.quick_tool {
|
||||
let custom_stamp = match tool {
|
||||
QuickTool::CustomStamp(i) => app.custom_stamps.get(i).cloned(),
|
||||
_ => None,
|
||||
};
|
||||
let mut open_props: Option<(usize, usize)> = None;
|
||||
let mut recolour: Option<pdfcraft_engine::Rgb> = None;
|
||||
// The table being set up in its window, shown on its own document's page.
|
||||
let table_preview = app.field_table_draft.as_mut().filter(|d| d.doc == view.id).and_then(|d| Some(d.preview(info.pages.get(d.page)?, &form)));
|
||||
let can_modify = doc.allows_modification();
|
||||
@@ -3066,6 +3111,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
by_line: &by_line,
|
||||
calc_order,
|
||||
can_copy,
|
||||
can_paste_comments,
|
||||
placing,
|
||||
can_crop,
|
||||
can_fill,
|
||||
can_modify,
|
||||
@@ -3123,6 +3170,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
let PageOut {
|
||||
canvas_action,
|
||||
clicked_link,
|
||||
comment_notice,
|
||||
copy_comments,
|
||||
mut copy_selected,
|
||||
field_menu,
|
||||
field_placed,
|
||||
@@ -3134,6 +3183,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
open_signature,
|
||||
open_table,
|
||||
page_action,
|
||||
paste_comments_at,
|
||||
primary_down,
|
||||
mut refused,
|
||||
row_on_screen,
|
||||
@@ -3218,6 +3268,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
app.comments_panel_closed = false;
|
||||
}
|
||||
Some(comments::CanvasAction::Properties(p, i)) => open_props = Some((p, i)),
|
||||
Some(comments::CanvasAction::Colour(c)) => recolour = Some(c),
|
||||
Some(comments::CanvasAction::CopyImage(p, i)) => image_action = Some(crate::edit_text_ui::ImageAction::Copy(p, i)),
|
||||
Some(comments::CanvasAction::CopyImageFile(p, i)) => image_action = Some(crate::edit_text_ui::ImageAction::CopyFile(p, i)),
|
||||
None => {}
|
||||
@@ -3242,6 +3293,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
open_table,
|
||||
form_notice,
|
||||
copy_selected,
|
||||
copy_comments,
|
||||
paste_comments_at,
|
||||
comment_notice,
|
||||
recolour,
|
||||
remote_open,
|
||||
form,
|
||||
generation,
|
||||
@@ -3264,6 +3319,13 @@ struct FrameRequests {
|
||||
open_table: Option<(usize, [f64; 4])>,
|
||||
form_notice: Option<crate::forms_ui::FormNotice>,
|
||||
copy_selected: bool,
|
||||
/// Copy comments / Paste comments from the page's menu or a Comments panel card, and what
|
||||
/// the comment tools have to say.
|
||||
copy_comments: bool,
|
||||
paste_comments_at: Option<(usize, [f64; 2])>,
|
||||
comment_notice: Option<&'static str>,
|
||||
/// A colour picked for every selected comment (Colour in the page's menu).
|
||||
recolour: Option<pdfcraft_engine::Rgb>,
|
||||
remote_open: Option<(pdfcraft_engine::DocId, String, pdfcraft_render::RemoteDestination)>,
|
||||
form: Arc<Vec<pdfcraft_engine::FormField>>,
|
||||
generation: u64,
|
||||
@@ -3286,6 +3348,10 @@ fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui,
|
||||
open_table,
|
||||
form_notice,
|
||||
copy_selected,
|
||||
copy_comments,
|
||||
paste_comments_at,
|
||||
comment_notice,
|
||||
recolour,
|
||||
remote_open,
|
||||
form,
|
||||
generation,
|
||||
@@ -3297,6 +3363,9 @@ fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui,
|
||||
if let Some((p, i)) = open_props {
|
||||
app.open_comment_props(p, i);
|
||||
}
|
||||
if let Some(c) = recolour {
|
||||
app.colour_selected_comments(c);
|
||||
}
|
||||
if let Some((p, i)) = app.views[index].comments.default_request.take() {
|
||||
app.make_comment_default(p, i);
|
||||
}
|
||||
@@ -3392,6 +3461,9 @@ fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui,
|
||||
Some(PageMenuAction::Paste(page, at)) => {
|
||||
app.paste_image_at(page, at);
|
||||
}
|
||||
Some(PageMenuAction::PasteComments(page, at)) => {
|
||||
app.paste_comments(index, None, Some((page, at)));
|
||||
}
|
||||
Some(PageMenuAction::RotateCCW) => {
|
||||
app.views[index].rotate_view(false);
|
||||
}
|
||||
@@ -3480,6 +3552,15 @@ fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui,
|
||||
if copy_selected {
|
||||
app.execute("edit.copy");
|
||||
}
|
||||
if copy_comments {
|
||||
app.execute("comment.copy");
|
||||
}
|
||||
if let Some(at) = paste_comments_at {
|
||||
app.paste_comments(index, None, Some(at));
|
||||
}
|
||||
if let Some(notice) = comment_notice {
|
||||
app.notify_tr(notice);
|
||||
}
|
||||
// Read mode hides the tools (#2646), and full screen is the page and nothing else (#1459);
|
||||
// the quick tools come back with the window.
|
||||
if app.mode != crate::Mode::Read && !app.full_screen {
|
||||
|
||||
@@ -51,6 +51,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut
|
||||
editing_content,
|
||||
form,
|
||||
info,
|
||||
placing,
|
||||
prefs,
|
||||
preparing,
|
||||
redact_mark,
|
||||
@@ -116,14 +117,14 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut
|
||||
let over_mark = matches!(tool, QuickTool::Fill(_))
|
||||
&& (matches!(press, PagePress::Mine(_)) || comments::moving_on(view, i) || !ptr.any_down && top(under.iter().cloned(), tool).is_some());
|
||||
tool_input(ui, view, frame, slot, &pcx, press, over_mark);
|
||||
let on_field = if preparing {
|
||||
let on_field = if preparing && !placing {
|
||||
let o = crate::prepare::page_input(cx, form, field_tool, prepare_allowed, press, view);
|
||||
frame.out.field_props |= o.properties;
|
||||
frame.out.field_placed |= o.placed;
|
||||
frame.out.open_table = frame.out.open_table.or(o.table);
|
||||
o.consumed || field_tool.is_some()
|
||||
} else {
|
||||
tool == QuickTool::Select && crate::forms_ui::page_input(cx, form, can_fill, press, released_over, view)
|
||||
tool == QuickTool::Select && !placing && crate::forms_ui::page_input(cx, form, can_fill, press, released_over, view)
|
||||
};
|
||||
// Redact draws boxes off text; so does Highlight (an area highlight, as in Acrobat).
|
||||
let area_tool = tool == QuickTool::Redact && can_modify || tool == QuickTool::Comment(comments::CommentTool::Highlight) && allowed;
|
||||
@@ -150,7 +151,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut
|
||||
}
|
||||
view.content.selected = None;
|
||||
}
|
||||
let on_content = !on_objects && editing_content && can_modify && {
|
||||
let on_content = !on_objects && !placing && editing_content && can_modify && {
|
||||
source_blocked
|
||||
|| crate::content_ui::page_input(
|
||||
cx,
|
||||
|
||||
@@ -26,6 +26,7 @@ pub(crate) const NEXT_VIEW: Shortcut = Shortcut::cmd("]");
|
||||
pub(crate) const FIND_NEXT: Shortcut = Shortcut::cmd("G");
|
||||
pub(crate) const FIND_PREV: Shortcut = Shortcut::cmd_shift("G");
|
||||
pub(crate) const COPY: Shortcut = Shortcut::cmd("C");
|
||||
pub(crate) const PASTE: Shortcut = Shortcut::cmd("V");
|
||||
pub(crate) const SELECT_ALL: Shortcut = Shortcut::cmd("A");
|
||||
pub(crate) const PAGE_PREV: Shortcut = Shortcut::cmd("←");
|
||||
pub(crate) const PAGE_NEXT: Shortcut = Shortcut::cmd("→");
|
||||
@@ -95,6 +96,8 @@ impl PdfCraftApp {
|
||||
.is_some_and(|v| !v.organize && (v.all_text || v.selected_text().is_some_and(|t| !t.is_empty()))))
|
||||
&& (spec.needs != commands::Needs::TwoPageView || self.active.and_then(|i| self.views.get(i)).is_some_and(crate::DocView::cover_applies))
|
||||
&& match spec.id {
|
||||
"comment.copy" => self.active.and_then(|i| self.views.get(i)).is_some_and(|v| v.comments.selected.is_some()),
|
||||
"comment.paste" => self.comment_clipboard.is_some(),
|
||||
"view.previous_view" => self.active.and_then(|i| self.views.get(i)).is_some_and(|v| !v.back.is_empty()),
|
||||
"view.next_view" => self.active.and_then(|i| self.views.get(i)).is_some_and(|v| !v.forward.is_empty()),
|
||||
_ => true,
|
||||
@@ -570,6 +573,14 @@ impl PdfCraftApp {
|
||||
}
|
||||
return Some(false);
|
||||
}
|
||||
"comment.copy" => {
|
||||
let Some(i) = active else { return Some(false) };
|
||||
return Some(self.copy_selected_comments(i));
|
||||
}
|
||||
"comment.paste" => {
|
||||
let Some(i) = active else { return Some(false) };
|
||||
return Some(self.paste_comments(i, None, None));
|
||||
}
|
||||
"comment.hide_all" => {
|
||||
if let Some(i) = active {
|
||||
let id = self.views[i].id;
|
||||
|
||||
@@ -96,6 +96,8 @@ impl Host for crate::PdfCraftApp {
|
||||
// Where pages are on screen (points), to aim ui.click / ui.drag at page content.
|
||||
"pages_on_screen": v.visible_page_rects().iter().map(|(p, r)| json!({ "page": p + 1, "rect": [r.min.x, r.min.y, r.max.x, r.max.y] })).collect::<Vec<_>>(),
|
||||
"selected_comment": v.comments.selected.map(|(p, i)| json!({ "page": p + 1, "index": i + 1 })),
|
||||
// Every selected comment (⌘-click adds), the one with the handles first.
|
||||
"selected_comments": v.comments.selection().iter().map(|(p, i)| json!({ "page": p + 1, "index": i + 1 })).collect::<Vec<_>>(),
|
||||
"comment_composer_open": v.comments.composer.is_some(),
|
||||
})),
|
||||
// The Combine files tab: how its files show, and each grid thumbnail's state.
|
||||
|
||||
@@ -26,6 +26,9 @@ pub struct PropsDraft {
|
||||
/// Acrobat's Make Properties Default box: on OK, new comments of this kind take the edited
|
||||
/// colour, opacity and line width too.
|
||||
pub make_default: bool,
|
||||
/// The other selected comments, each with its own properties: OK makes the same changes to
|
||||
/// them where they apply (a colour to every one, a font size to text boxes, …).
|
||||
pub others: Vec<(usize, usize, CommentProps)>,
|
||||
}
|
||||
|
||||
const ICONS: [NoteIcon; 14] = NoteIcon::ALL;
|
||||
@@ -121,11 +124,44 @@ impl PdfCraftApp {
|
||||
self.notify_fmt("New {tool} comments will look like this one", &[("tool", &crate::i18n::in_sentence(tl!(tool.label())))]);
|
||||
}
|
||||
|
||||
/// Open Comment Properties for the comment at `(page, index)` of the active document.
|
||||
/// Give every selected comment of the active document `color`, as one undo step.
|
||||
pub fn colour_selected_comments(&mut self, color: pdfcraft_engine::Rgb) {
|
||||
self.colour_selection(color, |_| true);
|
||||
}
|
||||
|
||||
/// Give the selected comments that `only` picks `color`, as one undo step: those whose look can
|
||||
/// change and that aren't locked (a locked one would refuse the whole step).
|
||||
pub(crate) fn colour_selection(&mut self, color: pdfcraft_engine::Rgb, only: impl Fn(&pdfcraft_render::Annotation) -> bool) {
|
||||
let Some((i, id)) = self.active_ids() else { return };
|
||||
let Some(doc) = self.session.get(id) else { return };
|
||||
let keys = self.views.get(i).map(|v| v.comments.selection()).unwrap_or_default();
|
||||
let mut edits: Vec<Edit> = keys
|
||||
.into_iter()
|
||||
.filter(|(p, ix)| doc.info.annotations.iter().any(|a| a.page == *p && a.index == *ix && !a.is_reply() && only(a)))
|
||||
.filter(|(p, ix)| doc.comment_props(*p, *ix).is_some_and(|c| c.restylable && !c.locked))
|
||||
.map(|(page, index)| Edit::StyleAnnotation { page, index, color: Some(color), opacity: None, width: None, endings: None })
|
||||
.collect();
|
||||
match edits.len() {
|
||||
0 => {}
|
||||
1 => {
|
||||
self.apply_edit(edits.remove(0));
|
||||
}
|
||||
_ => {
|
||||
self.apply_edit(Edit::Batch { label: "Change comment properties".into(), edits });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open Comment Properties for the comment at `(page, index)` of the active document. When it
|
||||
/// is one of several selected comments, the changes go to all of them.
|
||||
pub fn open_comment_props(&mut self, page: usize, index: usize) {
|
||||
let Some((_, id)) = self.active_ids() else { return };
|
||||
let Some(p) = self.session.get(id).and_then(|d| d.comment_props(page, index)) else { return };
|
||||
self.comment_props = Some(PropsDraft { page, index, tab: PropsTab::Appearance, original: p.clone(), edited: p, make_default: false });
|
||||
let Some((i, id)) = self.active_ids() else { return };
|
||||
let Some(doc) = self.session.get(id) else { return };
|
||||
let Some(p) = doc.comment_props(page, index) else { return };
|
||||
let selection = self.views.get(i).map(|v| &v.comments).filter(|c| c.is_selected((page, index))).map(|c| c.selection()).unwrap_or_default();
|
||||
let others =
|
||||
selection.into_iter().filter(|k| *k != (page, index)).filter_map(|(pg, ix)| doc.comment_props(pg, ix).map(|c| (pg, ix, c))).collect();
|
||||
self.comment_props = Some(PropsDraft { page, index, tab: PropsTab::Appearance, original: p.clone(), edited: p, make_default: false, others });
|
||||
self.dialog = Some(crate::Dialog::CommentProps);
|
||||
}
|
||||
}
|
||||
@@ -144,48 +180,81 @@ fn has_border(p: &CommentProps) -> bool {
|
||||
p.font_size.is_some() && p.width.is_some_and(|w| w > 0.0)
|
||||
}
|
||||
|
||||
/// The edits that make the comment match the draft (empty when nothing changed).
|
||||
/// The edits that make the comment match the draft (empty when nothing changed), and make the
|
||||
/// same changes to the other selected comments where they apply. Locks come off first and go on
|
||||
/// last, as a locked comment refuses other changes; another comment that stays locked is left
|
||||
/// as it is.
|
||||
pub fn edits(d: &PropsDraft) -> Vec<Edit> {
|
||||
let (o, e) = (&d.original, &d.edited);
|
||||
let targets: Vec<(usize, usize, &CommentProps)> =
|
||||
std::iter::once((d.page, d.index, o)).chain(d.others.iter().map(|(p, i, c)| (*p, *i, c))).collect();
|
||||
let mut out = Vec::new();
|
||||
// A locked comment refuses other changes: unlock first, lock last.
|
||||
if o.locked && !e.locked {
|
||||
out.push(Edit::LockAnnotation { page: d.page, index: d.index, locked: false });
|
||||
// Unticking Locked unlocks every selected comment.
|
||||
let unlocking = o.locked && !e.locked;
|
||||
for &(page, index, t) in &targets {
|
||||
if unlocking && t.locked {
|
||||
out.push(Edit::LockAnnotation { page, index, locked: false });
|
||||
}
|
||||
}
|
||||
for (n, &(page, index, t)) in targets.iter().enumerate() {
|
||||
if n > 0 && t.locked && !unlocking {
|
||||
continue;
|
||||
}
|
||||
changes(o, e, page, index, t, &mut out);
|
||||
}
|
||||
for &(page, index, t) in &targets {
|
||||
if !o.locked && e.locked && !t.locked {
|
||||
out.push(Edit::LockAnnotation { page, index, locked: true });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The changes from `o` to `e` (the draft) for the comment at `(page, index)`, whose own
|
||||
/// properties are `t`: only those its kind has (a line width for shapes and lines, a fill for
|
||||
/// shapes, a font size for text, …).
|
||||
fn changes(o: &CommentProps, e: &CommentProps, page: usize, index: usize, t: &CommentProps, out: &mut Vec<Edit>) {
|
||||
let color = (e.color != o.color).then_some(e.color).flatten();
|
||||
let opacity = ((e.opacity - o.opacity).abs() > 1e-6).then_some(e.opacity);
|
||||
let width = (e.width != o.width).then_some(e.width).flatten();
|
||||
let endings = (e.endings != o.endings).then(|| e.endings.clone()).flatten();
|
||||
if e.restylable && (color.is_some() || opacity.is_some() || width.is_some() || endings.is_some()) {
|
||||
out.push(Edit::StyleAnnotation { page: d.page, index: d.index, color, opacity, width, endings });
|
||||
let width = (e.width != o.width).then_some(e.width).flatten().filter(|_| t.width.is_some());
|
||||
let endings =
|
||||
(e.endings != o.endings).then(|| e.endings.clone()).flatten().filter(|ends| t.endings.as_ref().is_some_and(|own| own.len() == ends.len()));
|
||||
if t.restylable && (color.is_some() || opacity.is_some() || width.is_some() || endings.is_some()) {
|
||||
out.push(Edit::StyleAnnotation { page, index, color, opacity, width, endings });
|
||||
}
|
||||
if e.restylable && e.fillable && e.fill != o.fill {
|
||||
out.push(Edit::FillAnnotation { page: d.page, index: d.index, fill: e.fill });
|
||||
if t.restylable && t.fillable && e.fill != o.fill {
|
||||
out.push(Edit::FillAnnotation { page, index, fill: e.fill });
|
||||
}
|
||||
// After the opacity, which is the whole comment's: the parts shown are set again.
|
||||
let (fill, text) = parts_shown(e);
|
||||
// After the opacity, which is the whole comment's: the parts shown are set again. Which
|
||||
// parts show is the comment's own once the draft's fill and line width are on it, so another
|
||||
// selected comment without a border or a fill keeps one opacity for the whole comment.
|
||||
let mut after = t.clone();
|
||||
if t.fillable && e.fill != o.fill {
|
||||
after.fill = e.fill;
|
||||
}
|
||||
if let Some(w) = width {
|
||||
after.width = Some(w);
|
||||
}
|
||||
let (fill, text) = parts_shown(&after);
|
||||
let moved = |a: Option<f64>, b: Option<f64>| a.zip(b).is_some_and(|(a, b)| (a - b).abs() > 1e-6);
|
||||
if e.restylable
|
||||
if t.restylable
|
||||
&& (fill || text)
|
||||
&& (opacity.is_some() || fill && moved(e.fill_opacity, o.fill_opacity) || text && moved(e.text_opacity, o.text_opacity))
|
||||
{
|
||||
out.push(Edit::PartOpacity { page: d.page, index: d.index, fill: e.fill_opacity.filter(|_| fill), text: e.text_opacity.filter(|_| text) });
|
||||
out.push(Edit::PartOpacity { page, index, fill: e.fill_opacity.filter(|_| fill), text: e.text_opacity.filter(|_| text) });
|
||||
}
|
||||
if e.restylable
|
||||
if t.restylable
|
||||
&& t.font_size.is_some()
|
||||
&& let Some(size) = e.font_size.filter(|s| Some(*s) != o.font_size)
|
||||
{
|
||||
out.push(Edit::FontSizeAnnotation { page: d.page, index: d.index, size });
|
||||
out.push(Edit::FontSizeAnnotation { page, index, size });
|
||||
}
|
||||
let author = (e.author != o.author).then(|| e.author.clone());
|
||||
let subject = (e.subject != o.subject).then(|| e.subject.clone());
|
||||
let icon = (e.icon != o.icon).then_some(e.icon).flatten();
|
||||
let icon = (e.icon != o.icon).then_some(e.icon).flatten().filter(|_| t.icon.is_some());
|
||||
if author.is_some() || subject.is_some() || icon.is_some() {
|
||||
out.push(Edit::SetAnnotationInfo { page: d.page, index: d.index, author, subject, icon });
|
||||
out.push(Edit::SetAnnotationInfo { page, index, author, subject, icon });
|
||||
}
|
||||
if !o.locked && e.locked {
|
||||
out.push(Edit::LockAnnotation { page: d.page, index: d.index, locked: true });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Draw the dialog; returns (apply, cancel).
|
||||
@@ -213,6 +282,10 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo
|
||||
let Some(d) = app.comment_props.as_mut() else { return (false, true) };
|
||||
let kind = tl!(crate::comments_panel::subtype_label(&d.edited.subtype)).to_string();
|
||||
ui.label(egui::RichText::new(crate::i18n::fmt(tl!("{kind} Properties"), &[("kind", &kind)])).font(theme::semibold(18.0)));
|
||||
if !d.others.is_empty() {
|
||||
let n = (d.others.len() + 1).to_string();
|
||||
ui.label(egui::RichText::new(crate::i18n::fmt(tl!("Changes here apply to all {n} selected comments"), &[("n", &n)])).color(t.text_muted));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (tab, label) in
|
||||
|
||||
@@ -28,7 +28,7 @@ fn new_text_typed(v: &crate::DocView) -> bool {
|
||||
}
|
||||
|
||||
/// Keeps the pages (or comment) the user acted on selected, where they now are. `annots_before`
|
||||
/// is a deleted annotation's page's annotations before the delete (empty for other edits).
|
||||
/// is the page's annotations before a delete or a paste of comments (empty for other edits).
|
||||
fn select_after_edit(
|
||||
view: &mut crate::DocView,
|
||||
edit: &Edit,
|
||||
@@ -58,11 +58,12 @@ fn select_after_edit(
|
||||
crate::QuickTool::Comment(crate::comments::CommentTool::Ink | crate::comments::CommentTool::FreehandHighlight)
|
||||
) =>
|
||||
{
|
||||
view.comments.selected = None;
|
||||
view.comments.clear_selection();
|
||||
}
|
||||
Edit::AddAnnotation(a) => {
|
||||
// Select the new comment (appended last among the page's comments).
|
||||
// Select the new comment alone (appended last among the page's comments).
|
||||
let newest = info.annotations.iter().filter(|x| x.page == a.page && !x.is_reply()).map(|x| x.index).max();
|
||||
view.comments.clear_selection();
|
||||
view.comments.selected = newest.map(|n| (a.page, n));
|
||||
view.comments.reveal = true;
|
||||
// A new highlight opens its note for typing straight away, as in Acrobat,
|
||||
@@ -75,6 +76,25 @@ fn select_after_edit(
|
||||
}
|
||||
}
|
||||
Edit::DeleteAnnotation { page, index } => view.comments.annotation_deleted(annots_before, &info.annotations, (*page, *index)),
|
||||
// A group moved together stays selected (its positions don't change), so it
|
||||
// can be moved or copied again; anything else in a batch clears the selection.
|
||||
Edit::Batch { edits, .. } if edits.iter().all(|e| matches!(e, Edit::MoveAnnotation { .. })) => {}
|
||||
Edit::Batch { .. } => view.comments.clear_selection(),
|
||||
// The copies the paste added (and nothing that was there before): select them all.
|
||||
Edit::PasteComments { page, .. } => {
|
||||
let mut new: Vec<(usize, usize)> = info
|
||||
.annotations
|
||||
.iter()
|
||||
.filter(|x| x.page == *page && !x.is_reply() && !annots_before.iter().any(|b| b.name == x.name))
|
||||
.map(|x| (*page, x.index))
|
||||
.collect();
|
||||
view.comments.clear_selection();
|
||||
if !new.is_empty() {
|
||||
view.comments.selected = Some(new.remove(0));
|
||||
view.comments.also = new;
|
||||
view.comments.reveal = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -142,7 +162,10 @@ impl PdfCraftApp {
|
||||
// A deletion moves the comments after it on the page: these say where the selected one,
|
||||
// and the one open in the panel's editor, went.
|
||||
let annots_before = match (&edit, self.session.get(id)) {
|
||||
(Edit::DeleteAnnotation { page, .. }, Some(d)) => d.info.annotations.iter().filter(|a| a.page == *page).cloned().collect(),
|
||||
// A paste selects what it adds: the page's comments before it are the ones it didn't.
|
||||
(Edit::DeleteAnnotation { page, .. } | Edit::PasteComments { page, .. }, Some(d)) => {
|
||||
d.info.annotations.iter().filter(|a| a.page == *page).cloned().collect()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
match self.session.apply(id, edit.clone()) {
|
||||
|
||||
@@ -368,19 +368,12 @@ fn active_tool_controls(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ve
|
||||
}
|
||||
if let QuickTool::Comment(tool) = app.quick_tool {
|
||||
controls_divider(ui, t, vertical);
|
||||
// The selected comments this tool made take the colour too (locked ones keep theirs).
|
||||
if let Some(color) = comments::quick_bar_controls(ui, tool, &mut app.comment_prefs)
|
||||
&& let Some((index, id)) = app.active_ids()
|
||||
&& let Some((page, annotation)) = app.views.get(index).and_then(|view| view.comments.selected)
|
||||
&& app.session.get(id).is_some_and(|doc| {
|
||||
doc.allows_annotation()
|
||||
&& doc
|
||||
.info
|
||||
.annotations
|
||||
.iter()
|
||||
.any(|a| a.page == page && a.index == annotation && !a.is_reply() && !a.locked && comments::made_by(a, tool))
|
||||
})
|
||||
&& let Some((_, id)) = app.active_ids()
|
||||
&& app.session.get(id).is_some_and(|doc| doc.allows_annotation())
|
||||
{
|
||||
app.apply_edit(Edit::StyleAnnotation { page, index: annotation, color: Some(color), opacity: None, width: None, endings: None });
|
||||
app.colour_selection(color, |a| comments::made_by(a, tool));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,8 +147,9 @@ use state::CopyPage;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use state::OpenRequest;
|
||||
pub use state::{
|
||||
CustomDraft, Dialog, DuplicateDraft, LeftPanel, LinkOrigin, Mode, NumberDraft, OpenOutcome, PageClip, PagePropsDraft, PasswordPrompt,
|
||||
PendingLink, PendingRemoteLink, PropsTab, QuickTool, RECENT_FILES_DEFAULT, RECENT_FILES_MAX, RecentFile, RightPanel, UnlockPrompt,
|
||||
CommentClip, CustomDraft, Dialog, DuplicateDraft, LeftPanel, LinkOrigin, Mode, NumberDraft, OpenOutcome, PageClip, PagePropsDraft,
|
||||
PasswordPrompt, PendingLink, PendingRemoteLink, PropsTab, QuickTool, RECENT_FILES_DEFAULT, RECENT_FILES_MAX, RecentFile, RightPanel,
|
||||
UnlockPrompt,
|
||||
};
|
||||
use state::{DocumentCopyJob, GridFile};
|
||||
pub use tools::bulk_fields;
|
||||
@@ -561,6 +562,8 @@ pub struct PdfCraftApp {
|
||||
pub progress_notice: Option<widgets::ProgressNotice>,
|
||||
/// Pages copied or cut in Organize Pages, ready to paste (into any document).
|
||||
pub page_clipboard: Option<PageClip>,
|
||||
/// Copied comments (XFDF), to paste into this or another document (M14).
|
||||
pub comment_clipboard: Option<CommentClip>,
|
||||
/// Files dropped on the page grid, waiting for the pointer to say which gap they go to.
|
||||
grid_drop: Option<GridDrop>,
|
||||
/// The last snapshot (width, height, RGBA); `system_clipboard` also puts it on the
|
||||
|
||||
@@ -141,6 +141,9 @@ const EDIT: &[&str] = &[
|
||||
PASTE,
|
||||
DELETE,
|
||||
SEP,
|
||||
"comment.copy",
|
||||
"comment.paste",
|
||||
SEP,
|
||||
SELECT_ALL,
|
||||
SEP,
|
||||
"edit.find",
|
||||
|
||||
@@ -239,6 +239,9 @@ pub(crate) fn show(
|
||||
{
|
||||
view.comments.collapsed_pages.retain(|&c| c != p);
|
||||
}
|
||||
// The cards in the order shown, for a ⇧-click range (folded pages' cards aren't shown).
|
||||
let order: Vec<(usize, usize)> =
|
||||
roots.iter().filter(|a| !(sort == SortBy::Page && view.comments.collapsed_pages.contains(&a.page))).map(|a| (a.page, a.index)).collect();
|
||||
let mut page = usize::MAX;
|
||||
let mut group: Option<String> = None;
|
||||
// A card (or page header) out of view that was measured at this width only takes up its
|
||||
@@ -287,7 +290,7 @@ pub(crate) fn show(
|
||||
}
|
||||
let rotation = info.pages.get(a.page).map_or(0, |p| p.rotation);
|
||||
let top = ui.cursor().min.y;
|
||||
if let Some(e) = card(ui, view, CardContext { theme: t, annotation: a, rotation, thread, doc, prefs, allowed, nav }) {
|
||||
if let Some(e) = card(ui, view, CardContext { theme: t, annotation: a, rotation, thread, order: &order, doc, prefs, allowed, nav }) {
|
||||
edit = Some(e);
|
||||
}
|
||||
if virtual_list {
|
||||
@@ -376,6 +379,8 @@ struct CardContext<'a> {
|
||||
// The clockwise `/Rotate` of the comment's page.
|
||||
rotation: u16,
|
||||
thread: &'a [&'a Annotation],
|
||||
/// The cards in the order shown, for a ⇧-click range.
|
||||
order: &'a [(usize, usize)],
|
||||
doc: &'a pdfcraft_engine::Document,
|
||||
prefs: &'a CommentPrefs,
|
||||
allowed: bool,
|
||||
@@ -383,11 +388,14 @@ struct CardContext<'a> {
|
||||
}
|
||||
|
||||
fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Option<Edit> {
|
||||
let CardContext { theme: t, annotation: a, rotation, thread, doc, prefs, allowed, nav } = context;
|
||||
let CardContext { theme: t, annotation: a, rotation, thread, order, doc, prefs, allowed, nav } = context;
|
||||
let info = &doc.info;
|
||||
let mut edit = None;
|
||||
let key = (a.page, a.index);
|
||||
let selected = view.comments.selected == Some(key);
|
||||
let selected = view.comments.is_selected(key);
|
||||
// Several comments may be selected; the reply field (one draft) belongs to the one with
|
||||
// the handles.
|
||||
let primary = view.comments.selected == Some(key);
|
||||
let color = a.color.map(|c| color32(c.map(f64::from))).unwrap_or(t.accent);
|
||||
let status = thread.iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.as_deref());
|
||||
let marked = is_marked(thread);
|
||||
@@ -504,7 +512,7 @@ fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Opti
|
||||
}
|
||||
above = me;
|
||||
}
|
||||
if selected && allowed {
|
||||
if primary && allowed {
|
||||
ui.add_space(8.0);
|
||||
egui::Frame::NONE.inner_margin(egui::Margin { left: 34, right: 0, top: 0, bottom: 0 }).show(ui, |ui| {
|
||||
// The field fills its line and Post sits right-aligned under it: a field sized
|
||||
@@ -552,7 +560,16 @@ fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Opti
|
||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
.on_hover_text(crate::i18n::fmt(tl!("{kind} — click to show it on the page"), &[("kind", tl!(kind_label(a)))]));
|
||||
if click.clicked() {
|
||||
view.comments.selected = Some(key);
|
||||
// ⌘/Ctrl-click adds the comment to the selection, as on the page; ⇧-click selects the
|
||||
// cards from the one with the handles to this one, as lists do.
|
||||
let modifiers = ui.input(|i| i.modifiers);
|
||||
if modifiers.shift && !modifiers.command {
|
||||
view.comments.select_range(order, key);
|
||||
} else if modifiers.command {
|
||||
view.comments.toggle(key);
|
||||
} else {
|
||||
view.comments.select_only(key);
|
||||
}
|
||||
view.comments.reply.clear();
|
||||
*nav = Some(Nav::Flash(a.page, a.shown_rect(rotation)));
|
||||
}
|
||||
@@ -603,10 +620,16 @@ fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Opti
|
||||
view.comments.default_request = Some((a.page, a.index));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(tl!("Copy comment")).clicked() {
|
||||
// The card's comment alone (the page's menu copies the whole selection).
|
||||
view.comments.select_only(key);
|
||||
view.comments.copy_request = true;
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
// A locked reply can't be deleted, so it keeps the comment it answers too.
|
||||
if ui.add_enabled(doc.annotation_deletable(a.page, a.index), egui::Button::new(tl!("Delete"))).clicked() {
|
||||
view.comments.selected = None;
|
||||
view.comments.clear_selection();
|
||||
edit = Some(Edit::DeleteAnnotation { page: a.page, index: a.index });
|
||||
ui.close();
|
||||
}
|
||||
|
||||
@@ -233,6 +233,14 @@ pub struct PagePropsDraft {
|
||||
pub tab_order: Option<pdfcraft_engine::TabOrder>,
|
||||
}
|
||||
|
||||
/// Copied comments: their XFDF (each with its replies), for `Edit::PasteComments`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CommentClip {
|
||||
pub xfdf: String,
|
||||
/// How many comments (replies not counted) were copied.
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
/// Copied pages: their document's name and bytes (as it was when copied) and the pages.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PageClip {
|
||||
|
||||
@@ -39,9 +39,9 @@ pub use geometry::{drawn_text_box_rect, text_box_rect};
|
||||
use input::hover_tip;
|
||||
pub(crate) use input::{anchor, context, context_hit, describe, hit, keys, moving_on, nudge_keys, page_after_text, page_input};
|
||||
pub use menu::{CanvasAction, fill_picker, recent_custom_colours, remember_custom_colour, status_badge, swatch_grid};
|
||||
pub(crate) use menu::{MenuRights, attachment_items, context_menu, quick_bar_colour, quick_bar_controls};
|
||||
#[cfg(test)]
|
||||
use menu::{RECENT_CUSTOM, same_colour};
|
||||
pub(crate) use menu::{attachment_items, context_menu, quick_bar_colour, quick_bar_controls};
|
||||
pub(crate) use paint::paint_page;
|
||||
#[cfg(test)]
|
||||
use paint::{changing_points, drawn_shape, edited_in_view};
|
||||
|
||||
@@ -4,7 +4,7 @@ use super::geometry::{
|
||||
};
|
||||
use super::paint::{closes_at, drawn_shape, edit_anchor, erase, new_comment};
|
||||
use super::state::{Composer, ComposerKind, Gesture, PageCx};
|
||||
use super::{Annotation, CommentPrefs, CommentTool, DocInfo, DocView, Edit, Pos2, QuickTool, Rect, Shape, vec2};
|
||||
use super::{Annotation, CommentPrefs, CommentTool, CommentView, DocInfo, DocView, Edit, Pos2, QuickTool, Rect, Shape, vec2};
|
||||
use crate::page_cursor::{Layer, Subject};
|
||||
use crate::page_router::{CommentPart, Hit, Owner, PagePress};
|
||||
|
||||
@@ -31,6 +31,39 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>,
|
||||
cv.gesture = None;
|
||||
return true;
|
||||
}
|
||||
// Copied comments on the pointer: a click places the bundle, centred where it is clicked,
|
||||
// on this page; a right-click lets it go. Nothing else on the page takes the click.
|
||||
if let Some(Gesture::Place { xfdf, bounds, .. }) = cv.gesture.clone() {
|
||||
if over_page {
|
||||
view.cursor.claim(Layer::Drag, egui::CursorIcon::Grabbing);
|
||||
}
|
||||
let cv = &mut view.comments;
|
||||
if resp.secondary_clicked() {
|
||||
cv.gesture = None;
|
||||
return true;
|
||||
}
|
||||
if resp.clicked()
|
||||
&& over_page
|
||||
&& cx.allowed
|
||||
&& let Some(p) = pointer
|
||||
{
|
||||
let [x, y] = cx.to_user(p);
|
||||
let offset = [x - (bounds[0] + bounds[2]) / 2.0, y - (bounds[1] + bounds[3]) / 2.0];
|
||||
// Larger than the page it lands on (an A0 bundle on an A4 page): placed as it is,
|
||||
// and said so.
|
||||
if let Some(page) = cx.info.pages.get(cx.page) {
|
||||
let a = page.user_to_view((bounds[0] + offset[0]) as f32, (bounds[1] + offset[1]) as f32);
|
||||
let b = page.user_to_view((bounds[2] + offset[0]) as f32, (bounds[3] + offset[1]) as f32);
|
||||
let (w, h) = ((a[0] - b[0]).abs(), (a[1] - b[1]).abs());
|
||||
if w > page.width + 1.0 || h > page.height + 1.0 {
|
||||
cv.notice = Some("The copied comments are larger than this page; they were placed as they are");
|
||||
}
|
||||
}
|
||||
cv.gesture = None;
|
||||
view.pending_edit = Some(Edit::PasteComments { xfdf, page: cx.page, offset: Some(offset) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
let moving = moving_on(view, cx.page);
|
||||
let gestures = Gestures { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page, free };
|
||||
match cx.tool {
|
||||
@@ -216,7 +249,20 @@ fn select_input(
|
||||
consumed = true;
|
||||
}
|
||||
CommentPart::Body if !is_markup(&a.subtype) => {
|
||||
cv.selected = Some((cx.page, a.index));
|
||||
// Dragging one of several selected comments moves them all; any other drag starts
|
||||
// a selection of its own.
|
||||
let grabbed = (cx.page, a.index);
|
||||
if !cv.is_selected(grabbed) {
|
||||
cv.select_only(grabbed);
|
||||
} else {
|
||||
// The grabbed one takes the handles; the one that had them stays selected.
|
||||
if let Some(previous) = cv.selected.replace(grabbed)
|
||||
&& previous != grabbed
|
||||
{
|
||||
cv.also.push(previous);
|
||||
}
|
||||
cv.also.retain(|k| *k != grabbed);
|
||||
}
|
||||
cv.reveal = true;
|
||||
if cx.allowed {
|
||||
cv.gesture = Some(Gesture::Move { page: cx.page, index: a.index, from: o });
|
||||
@@ -232,38 +278,9 @@ fn select_input(
|
||||
if resp.drag_stopped()
|
||||
&& let Some(p) = pointer
|
||||
{
|
||||
match cv.gesture.clone() {
|
||||
Some(Gesture::Move { page, index, from }) if page == cx.page => {
|
||||
cv.gesture = None;
|
||||
let (a, b) = (cx.to_user(from), cx.to_user(p));
|
||||
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
|
||||
if from.distance(p) >= 2.0 {
|
||||
view.pending_edit = Some(Edit::MoveAnnotation { page, index, dx, dy });
|
||||
}
|
||||
}
|
||||
Some(Gesture::Resize { page, index, handle, from, aspect_ratio }) if page == cx.page => {
|
||||
cv.gesture = None;
|
||||
if let Some(a) = cx.get(index) {
|
||||
let from_rect = cx.screen_rect(a);
|
||||
let shift = ui.input(|i| i.modifiers.shift);
|
||||
let r = resized(from_rect, handle, p - from, corner_ratio(from_rect, aspect_ratio, shift));
|
||||
let rect = a.rect_for_shown(cx.rect_to_user(r), cx.rotation());
|
||||
if r.width() >= 4.0 && r.height() >= 4.0 {
|
||||
view.pending_edit = Some(Edit::ResizeAnnotation { page, index, rect });
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Gesture::Reshape { page, index, point, points }) if page == cx.page => {
|
||||
cv.gesture = None;
|
||||
let shift = ui.input(|i| i.modifiers.shift);
|
||||
let moved = points.get(point).is_some_and(|q| cx.to_screen(*q).distance(p) >= 2.0);
|
||||
if moved {
|
||||
view.pending_edit = Some(Edit::ReshapeAnnotation { page, index, points: reshaped(&points, point, cx.to_user(p), shift) });
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
finish_select_gesture(ui, cx, view, p);
|
||||
}
|
||||
let cv = &mut view.comments;
|
||||
// (egui clears the press origin on release, so clicks are located by the pointer.)
|
||||
let on_page = pointer.is_some_and(|p| cx.xf.rect.contains(p));
|
||||
if resp.clicked() && on_page {
|
||||
@@ -271,11 +288,16 @@ fn select_input(
|
||||
// form field; added text) leaves comments unselected.
|
||||
match owner {
|
||||
Some((a, _)) => {
|
||||
cv.selected = Some((cx.page, a.index));
|
||||
// ⌘/Ctrl-click (or ⇧-click) adds the comment to the selection, as in Acrobat.
|
||||
if ui.input(|i| i.modifiers.command || i.modifiers.shift) {
|
||||
cv.toggle((cx.page, a.index));
|
||||
} else {
|
||||
cv.select_only((cx.page, a.index));
|
||||
}
|
||||
cv.reveal = true;
|
||||
consumed = true;
|
||||
}
|
||||
None if press != PagePress::Elsewhere => cv.selected = None,
|
||||
None if press != PagePress::Elsewhere => cv.clear_selection(),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
@@ -284,7 +306,7 @@ fn select_input(
|
||||
&& cx.allowed
|
||||
&& let Some((a, _)) = owner
|
||||
{
|
||||
cv.selected = Some((cx.page, a.index));
|
||||
cv.select_only((cx.page, a.index));
|
||||
cv.composer = Some(Composer {
|
||||
page: cx.page,
|
||||
at: edit_anchor(cx.info, a),
|
||||
@@ -297,6 +319,57 @@ fn select_input(
|
||||
consumed
|
||||
}
|
||||
|
||||
/// The end of a move, resize or reshape the Select tool began on this page: the edit it makes,
|
||||
/// for the pointer released at `p`.
|
||||
fn finish_select_gesture(ui: &egui::Ui, cx: &PageCx<'_>, view: &mut DocView, p: Pos2) {
|
||||
match view.comments.gesture.clone() {
|
||||
Some(Gesture::Move { page, index, from }) if page == cx.page => {
|
||||
view.comments.gesture = None;
|
||||
let (a, b) = (cx.to_user(from), cx.to_user(p));
|
||||
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
|
||||
if from.distance(p) >= 2.0 {
|
||||
// The other selected comments on this page (not text markup, which is where
|
||||
// its text is, nor a locked one, which would refuse the whole step) move with
|
||||
// the dragged one, as one undo step.
|
||||
let others: Vec<usize> = view
|
||||
.comments
|
||||
.also
|
||||
.iter()
|
||||
.filter(|(p, i)| *p == page && *i != index && cx.get(*i).is_some_and(|a| !is_markup(&a.subtype) && !a.locked))
|
||||
.map(|(_, i)| *i)
|
||||
.collect();
|
||||
view.pending_edit = Some(if others.is_empty() {
|
||||
Edit::MoveAnnotation { page, index, dx, dy }
|
||||
} else {
|
||||
let edits = std::iter::once(index).chain(others).map(|index| Edit::MoveAnnotation { page, index, dx, dy }).collect();
|
||||
Edit::Batch { label: tl!("Move comments").to_owned(), edits }
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(Gesture::Resize { page, index, handle, from, aspect_ratio }) if page == cx.page => {
|
||||
view.comments.gesture = None;
|
||||
if let Some(a) = cx.get(index) {
|
||||
let from_rect = cx.screen_rect(a);
|
||||
let shift = ui.input(|i| i.modifiers.shift);
|
||||
let r = resized(from_rect, handle, p - from, corner_ratio(from_rect, aspect_ratio, shift));
|
||||
let rect = a.rect_for_shown(cx.rect_to_user(r), cx.rotation());
|
||||
if r.width() >= 4.0 && r.height() >= 4.0 {
|
||||
view.pending_edit = Some(Edit::ResizeAnnotation { page, index, rect });
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Gesture::Reshape { page, index, point, points }) if page == cx.page => {
|
||||
view.comments.gesture = None;
|
||||
let shift = ui.input(|i| i.modifiers.shift);
|
||||
let moved = points.get(point).is_some_and(|q| cx.to_screen(*q).distance(p) >= 2.0);
|
||||
if moved {
|
||||
view.pending_edit = Some(Edit::ReshapeAnnotation { page, index, points: reshaped(&points, point, cx.to_user(p), shift) });
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// After text selection ran: a markup tool turns a finished selection into a comment; Replace
|
||||
/// Text asks for the replacement.
|
||||
pub(crate) fn page_after_text(resp: &egui::Response, cx: &PageCx<'_>, view: &mut DocView) {
|
||||
@@ -421,7 +494,18 @@ impl Gestures<'_> {
|
||||
if over_page {
|
||||
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair);
|
||||
}
|
||||
let finish = resp.double_clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
// The menu's Complete is taken by the page the drawing is on: every page on screen
|
||||
// passes here in a frame, and an earlier one must not swallow it. With no drawing
|
||||
// left, a request is stale.
|
||||
let drawing_here = match &cv.gesture {
|
||||
Some(Gesture::Draw { page, .. }) => *page == cx.page,
|
||||
_ => {
|
||||
cv.finish_request = false;
|
||||
false
|
||||
}
|
||||
};
|
||||
let finish =
|
||||
resp.double_clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) || (drawing_here && std::mem::take(&mut cv.finish_request));
|
||||
// Backspace (or Delete) takes back the last point, e.g. after a wrong click; Undo does too.
|
||||
// Only while a drawing is in progress: otherwise Delete belongs to the selected comment.
|
||||
if matches!(cv.gesture, Some(Gesture::Draw { page, .. }) if page == cx.page)
|
||||
@@ -615,17 +699,36 @@ pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, tool: &mut QuickTool
|
||||
// One step back per press: cancel the gesture, then deselect, then drop the tool (a
|
||||
// comment tool, or a Fill & Sign one: its Text tool would otherwise open a box at every
|
||||
// click with no way out by the keyboard).
|
||||
let cancelled = cv.gesture.take().is_some() || cv.selected.take().is_some();
|
||||
let had_selection = cv.selected.is_some();
|
||||
cv.clear_selection();
|
||||
let cancelled = cv.gesture.take().is_some() || had_selection;
|
||||
if !cancelled && matches!(tool, QuickTool::Comment(_) | QuickTool::Fill(_)) {
|
||||
*tool = QuickTool::Select;
|
||||
}
|
||||
}
|
||||
if allowed
|
||||
&& let Some((page, index)) = cv.selected
|
||||
&& cv.selected.is_some()
|
||||
&& ctx.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace))
|
||||
&& let Some(edit) = delete_selected(cv)
|
||||
{
|
||||
cv.selected = None;
|
||||
view.pending_edit = Some(Edit::DeleteAnnotation { page, index });
|
||||
view.pending_edit = Some(edit);
|
||||
}
|
||||
}
|
||||
|
||||
/// The edit that deletes every selected comment (one undo step for several), and clears the
|
||||
/// selection. Later positions go first, so each deletion leaves the earlier ones where they are.
|
||||
pub(crate) fn delete_selected(cv: &mut CommentView) -> Option<Edit> {
|
||||
let mut keys = cv.selection();
|
||||
cv.clear_selection();
|
||||
keys.sort_unstable_by(|a, b| b.cmp(a));
|
||||
keys.dedup();
|
||||
match keys.as_slice() {
|
||||
[] => None,
|
||||
[(page, index)] => Some(Edit::DeleteAnnotation { page: *page, index: *index }),
|
||||
_ => Some(Edit::Batch {
|
||||
label: tl!("Delete comments").to_owned(),
|
||||
edits: keys.into_iter().map(|(page, index)| Edit::DeleteAnnotation { page, index }).collect(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::input::delete_selected;
|
||||
use super::paint::edit_anchor;
|
||||
use super::state::{Composer, ComposerKind};
|
||||
use super::state::{Composer, ComposerKind, Gesture};
|
||||
use super::{
|
||||
Annotation, Color32, CommentPrefs, CommentTool, DocInfo, DocView, Edit, Rect, ReviewState, Rgb, SELECT_BLUE, SWATCHES, Sense, Stroke, color32,
|
||||
icons, theme, tool_for, vec2,
|
||||
@@ -22,8 +23,18 @@ pub(crate) fn attachment_items(ui: &mut egui::Ui, view: &mut DocView, info: &Doc
|
||||
ui.separator();
|
||||
}
|
||||
|
||||
/// What the canvas context menu may offer.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct MenuRights {
|
||||
/// Comments may be added and changed.
|
||||
pub(crate) allowed: bool,
|
||||
/// Copied comments wait to be pasted.
|
||||
pub(crate) can_paste: bool,
|
||||
/// The document's pages may change (Flatten comments draws into them).
|
||||
pub(crate) can_modify: bool,
|
||||
}
|
||||
|
||||
/// Items of the canvas context menu for the selected comment (or the page).
|
||||
/// `can_modify`: the document's pages may change (Flatten comments draws into them).
|
||||
/// `deletable`: whether Delete would delete the comment at (page, index) (the engine's
|
||||
/// `Document::annotation_deletable`: unlocked, and no locked reply to it).
|
||||
pub(crate) fn context_menu(
|
||||
@@ -32,11 +43,32 @@ pub(crate) fn context_menu(
|
||||
info: &DocInfo,
|
||||
deletable: &dyn Fn(usize, usize) -> bool,
|
||||
prefs: &CommentPrefs,
|
||||
allowed: bool,
|
||||
can_modify: bool,
|
||||
rights: MenuRights,
|
||||
) -> Option<CanvasAction> {
|
||||
let MenuRights { allowed, can_paste, can_modify } = rights;
|
||||
let mut action = None;
|
||||
// Connected lines, a polygon or a cloud being drawn: finish it from the menu, as other
|
||||
// editors do (Enter and a double-click finish it too), take the last point back, or stop.
|
||||
if let Some(Gesture::Draw { tool, points, .. }) = &view.comments.gesture
|
||||
&& tool.clicks_points()
|
||||
{
|
||||
let enough = points.len() >= if *tool == CommentTool::PolyLine { 2 } else { 3 };
|
||||
if ui.add_enabled(enough, egui::Button::new(tl!("Complete"))).clicked() {
|
||||
view.comments.finish_request = true;
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(tl!("Undo last point")).clicked() {
|
||||
view.comments.undo_point();
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(tl!("Cancel drawing")).clicked() {
|
||||
view.comments.gesture = None;
|
||||
ui.close();
|
||||
}
|
||||
return None;
|
||||
}
|
||||
let selected = view.comments.selected.and_then(|(p, i)| info.annotations.iter().find(|a| a.page == p && a.index == i && !a.is_reply()));
|
||||
let several = view.comments.also.len();
|
||||
match selected {
|
||||
Some(a) => {
|
||||
let (page, index) = (a.page, a.index);
|
||||
@@ -69,14 +101,19 @@ pub(crate) fn context_menu(
|
||||
let keep_open = egui::containers::menu::MenuConfig::new().close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside);
|
||||
let _ = egui::containers::menu::SubMenuButton::new(tl!("Colour")).config(keep_open).ui(ui, |ui| {
|
||||
if let Some(c) = swatch_grid(ui, a.color.map(|c| c.map(f64::from))) {
|
||||
action = Some(CanvasAction::Edit(Box::new(Edit::StyleAnnotation {
|
||||
page,
|
||||
index,
|
||||
color: Some(c),
|
||||
opacity: None,
|
||||
width: None,
|
||||
endings: None,
|
||||
})));
|
||||
// Several selected: the app gives each of them the colour.
|
||||
action = Some(if several > 0 {
|
||||
CanvasAction::Colour(c)
|
||||
} else {
|
||||
CanvasAction::Edit(Box::new(Edit::StyleAnnotation {
|
||||
page,
|
||||
index,
|
||||
color: Some(c),
|
||||
opacity: None,
|
||||
width: None,
|
||||
endings: None,
|
||||
}))
|
||||
});
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
@@ -92,10 +129,24 @@ pub(crate) fn context_menu(
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
// The engine's own check: a locked comment, or one with a locked reply, stays.
|
||||
if ui.add_enabled(allowed && deletable(page, index), egui::Button::new(tl!("Delete"))).clicked() {
|
||||
view.comments.selected = None;
|
||||
action = Some(CanvasAction::Edit(Box::new(Edit::DeleteAnnotation { page, index })));
|
||||
// The comments themselves, to paste into this or another document (⌘C / ⌘V too).
|
||||
let copy_key = crate::commands::shortcut_label(ui.ctx(), crate::commands::COPY);
|
||||
if ui.add(egui::Button::new(tl!(if several > 0 { "Copy comments" } else { "Copy comment" })).shortcut_text(copy_key)).clicked() {
|
||||
view.comments.copy_request = true;
|
||||
ui.close();
|
||||
}
|
||||
let paste_key = crate::commands::shortcut_label(ui.ctx(), crate::commands::PASTE);
|
||||
if ui.add_enabled(allowed && can_paste, egui::Button::new(tl!("Paste comments")).shortcut_text(paste_key)).clicked() {
|
||||
let at = view.comments.context_at.unwrap_or((page, [f64::from(a.rect[0]), f64::from(a.rect[1])]));
|
||||
view.comments.paste_request = Some(at);
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
// The engine's own check, for each selected comment: a locked comment, or one with a
|
||||
// locked reply, stays.
|
||||
let deletes = deletable(page, index) && view.comments.also.iter().all(|&(p, i)| deletable(p, i));
|
||||
if ui.add_enabled(allowed && deletes, egui::Button::new(tl!(if several > 0 { "Delete comments" } else { "Delete" }))).clicked() {
|
||||
action = delete_selected(&mut view.comments).map(|e| CanvasAction::Edit(Box::new(e)));
|
||||
ui.close();
|
||||
}
|
||||
if ui.add_enabled(allowed, egui::Button::new(tl!("Properties…"))).clicked() {
|
||||
@@ -140,6 +191,8 @@ pub enum CanvasAction {
|
||||
OpenComments,
|
||||
/// Comment Properties for (page, index).
|
||||
Properties(usize, usize),
|
||||
/// Give every selected comment this colour.
|
||||
Colour(Rgb),
|
||||
/// Copy Image: image (page, index in `page_images`) to the clipboard.
|
||||
CopyImage(usize, usize),
|
||||
/// Copy Image in Original Format: the same image as a file (JPEG as stored, else PNG).
|
||||
|
||||
@@ -2,7 +2,7 @@ use super::geometry::{HANDLES, handle_pos, held, is_markup, reaches_points, resh
|
||||
use super::state::{CommentView, Gesture, PageCx, SETTLE_SECS};
|
||||
use super::{
|
||||
Annotation, Color32, CommentTool, CornerRadius, DocInfo, DocView, Edit, LineEnding, NewAnnotation, Pos2, QuickTool, Rect, SELECT_BLUE, Shape,
|
||||
Stroke, color32, vec2,
|
||||
Stroke, color32, pos2, vec2,
|
||||
};
|
||||
use crate::page_router::{CommentPart, Hit, Owner};
|
||||
|
||||
@@ -38,6 +38,12 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_>
|
||||
painter.rect_stroke(r.expand(2.0), CornerRadius::same(2), Stroke::new(1.0, SELECT_BLUE.gamma_multiply(0.7)), egui::StrokeKind::Outside);
|
||||
}
|
||||
}
|
||||
// The comments selected besides the one with the handles: outlined, not resizable.
|
||||
for a in cv.also.iter().filter(|(p, _)| *p == cx.page).filter_map(|(_, i)| cx.get(*i)) {
|
||||
for r in cx.screen_rects(a) {
|
||||
painter.rect_stroke(r.expand(2.0), CornerRadius::ZERO, Stroke::new(1.0, SELECT_BLUE), egui::StrokeKind::Middle);
|
||||
}
|
||||
}
|
||||
if let Some((page, index)) = cv.selected
|
||||
&& page == cx.page
|
||||
&& let Some(a) = cx.get(index)
|
||||
@@ -93,6 +99,11 @@ pub(super) fn changing_points(
|
||||
}),
|
||||
(_, Some(Edit::ReshapeAnnotation { page: p, index: i, points: to })) if (*p, *i) == (page, index) => Some(to.clone()),
|
||||
(_, Some(Edit::MoveAnnotation { page: p, index: i, dx, dy })) if (*p, *i) == (page, index) => Some(shifted(*dx, *dy)),
|
||||
// Moved with the rest of the selection: its own move in the batch.
|
||||
(_, Some(Edit::Batch { edits, .. })) => edits.iter().find_map(|e| match e {
|
||||
Edit::MoveAnnotation { page: p, index: i, dx, dy } if (*p, *i) == (page, index) => Some(shifted(*dx, *dy)),
|
||||
_ => None,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -128,6 +139,82 @@ fn paint_gesture(painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView) {
|
||||
{
|
||||
paint_draw(painter, cx, tool, points);
|
||||
}
|
||||
// The bundle of copied comments, hanging on the pointer over this page: its box, and each
|
||||
// comment's outline in its colour, so the drop can be aimed to the line.
|
||||
if let Some(Gesture::Place { bounds, count, outlines, .. }) = &view.comments.gesture
|
||||
&& let Some(p) = cx.ptr.pos
|
||||
&& cx.xf.rect.contains(p)
|
||||
{
|
||||
let [x, y] = cx.to_user(p);
|
||||
let (dx, dy) = (x - (bounds[0] + bounds[2]) / 2.0, y - (bounds[1] + bounds[3]) / 2.0);
|
||||
let r = Rect::from_two_pos(cx.to_screen([bounds[0] + dx, bounds[1] + dy]), cx.to_screen([bounds[2] + dx, bounds[3] + dy]));
|
||||
painter.rect_stroke(r.expand(2.0), CornerRadius::same(2), Stroke::new(1.0, SELECT_BLUE.gamma_multiply(0.6)), egui::StrokeKind::Middle);
|
||||
let at = |q: [f64; 2]| cx.to_screen([q[0] + dx, q[1] + dy]);
|
||||
for outline in outlines {
|
||||
paint_outline(painter, outline, &at);
|
||||
}
|
||||
let label = crate::i18n::fmt(tl!("Comments: {n}"), &[("n", &count.to_string())]);
|
||||
painter.text(r.left_top() + vec2(0.0, -4.0), egui::Align2::LEFT_BOTTOM, label, crate::theme::regular(11.0), SELECT_BLUE);
|
||||
}
|
||||
}
|
||||
|
||||
/// One copied comment's outline on screen (`at` maps a user-space point, moved to where the
|
||||
/// bundle hangs), in the comment's colour.
|
||||
fn paint_outline(painter: &egui::Painter, outline: &pdfcraft_engine::CommentOutline, at: &dyn Fn([f64; 2]) -> Pos2) {
|
||||
use pdfcraft_engine::CommentOutline as O;
|
||||
let color = |c: &Option<[f64; 3]>| {
|
||||
c.map(|[r, g, b]| Color32::from_rgb((r.clamp(0.0, 1.0) * 255.0) as u8, (g.clamp(0.0, 1.0) * 255.0) as u8, (b.clamp(0.0, 1.0) * 255.0) as u8))
|
||||
.unwrap_or(SELECT_BLUE)
|
||||
};
|
||||
match outline {
|
||||
O::Rect { rect, round, color: c } => {
|
||||
let stroke = Stroke::new(1.5, color(c));
|
||||
let r = Rect::from_two_pos(at([rect[0], rect[1]]), at([rect[2], rect[3]]));
|
||||
if *round {
|
||||
let (cx, cy, rx, ry) = (r.center().x, r.center().y, r.width() / 2.0, r.height() / 2.0);
|
||||
let points: Vec<Pos2> =
|
||||
(0..48).map(|i| i as f32 / 48.0 * std::f32::consts::TAU).map(|t| pos2(cx + rx * t.cos(), cy + ry * t.sin())).collect();
|
||||
painter.add(egui::Shape::closed_line(points, stroke));
|
||||
} else {
|
||||
painter.rect_stroke(r, CornerRadius::ZERO, stroke, egui::StrokeKind::Middle);
|
||||
}
|
||||
}
|
||||
O::Line { points, color: c } => {
|
||||
let stroke = Stroke::new(1.5, color(c));
|
||||
painter.add(egui::Shape::line(points.iter().map(|p| at(*p)).collect(), stroke));
|
||||
}
|
||||
O::Poly { points, closed, color: c } => {
|
||||
let stroke = Stroke::new(1.5, color(c));
|
||||
let points: Vec<Pos2> = points.iter().map(|p| at(*p)).collect();
|
||||
painter.add(if *closed { egui::Shape::closed_line(points, stroke) } else { egui::Shape::line(points, stroke) });
|
||||
}
|
||||
O::Ink { strokes, color: c } => {
|
||||
let stroke = Stroke::new(1.5, color(c));
|
||||
for s in strokes {
|
||||
painter.add(egui::Shape::line(s.iter().map(|p| at(*p)).collect(), stroke));
|
||||
}
|
||||
}
|
||||
O::Quads { quads, color: c } => {
|
||||
let fill = color(c).gamma_multiply(0.35);
|
||||
for q in quads {
|
||||
// Upper left, upper right, lower right, lower left: a closed shape in that order.
|
||||
let points = vec![at([q[0], q[1]]), at([q[2], q[3]]), at([q[6], q[7]]), at([q[4], q[5]])];
|
||||
painter.add(egui::Shape::convex_polygon(points, fill, Stroke::NONE));
|
||||
}
|
||||
}
|
||||
O::Text { rect, text, size, color: c } => {
|
||||
// At the page's zoom, wrapped in the box; too small to read at this zoom, left out.
|
||||
let r = Rect::from_two_pos(at([rect[0], rect[1]]), at([rect[2], rect[3]]));
|
||||
let zoom = at([1.0, 0.0]).distance(at([0.0, 0.0]));
|
||||
let px = *size as f32 * zoom;
|
||||
if !(px.is_finite() && px >= 3.0) {
|
||||
return;
|
||||
}
|
||||
let pad = 2.0 * zoom;
|
||||
let galley = painter.layout(text.clone(), egui::FontId::proportional(px.min(400.0)), color(c), (r.width() - 2.0 * pad).max(1.0));
|
||||
painter.with_clip_rect(r.intersect(painter.clip_rect())).galley(r.min + vec2(pad, pad), galley, color(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A drawing tool's shape from its points (a live drag, or a released one still settling).
|
||||
|
||||
@@ -29,6 +29,10 @@ pub enum Gesture {
|
||||
/// Dragging point `point` of a line, connected lines, a polygon or a cloud (its `points`
|
||||
/// before the drag, in user space) to where the pointer is.
|
||||
Reshape { page: usize, index: usize, point: usize, points: Vec<[f64; 2]> },
|
||||
/// Copied comments hanging on the pointer as one bundle (their box `bounds` in user space,
|
||||
/// `count` of them, their `outlines` drawn along so the drop can be aimed): a click puts
|
||||
/// their centre there, on the page clicked; Escape or a right-click lets them go.
|
||||
Place { xfdf: String, bounds: [f64; 4], count: usize, outlines: Vec<pdfcraft_engine::CommentOutline> },
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -127,8 +131,21 @@ impl CommentView {
|
||||
/// Per-document commenting state.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct CommentView {
|
||||
/// The selected comment: (page, index in `/Annots`).
|
||||
/// The selected comment: (page, index in `/Annots`). With several selected (`also`), the one
|
||||
/// the handles, the properties and the context menu act on.
|
||||
pub selected: Option<(usize, usize)>,
|
||||
/// The other selected comments (⌘/Ctrl-click or ⇧-click on the page adds one, ⇧-click in the
|
||||
/// Comments panel a range): Delete, Copy, Properties and a drag act on them with `selected`.
|
||||
pub also: Vec<(usize, usize)>,
|
||||
/// Copy the selected comments (set by the menus; the app has the clipboard).
|
||||
pub copy_request: bool,
|
||||
/// Paste the copied comments onto this page, centred at this user-space point (set by the
|
||||
/// page's menu, which knows where it was opened).
|
||||
pub paste_request: Option<(usize, [f64; 2])>,
|
||||
/// A notice for the app to show (the page input has no app).
|
||||
pub notice: Option<&'static str>,
|
||||
/// Finish the connected lines, polygon or cloud being drawn (the context menu's Complete).
|
||||
pub finish_request: bool,
|
||||
pub gesture: Option<Gesture>,
|
||||
/// Drawings just released: shown from their points until the page raster that includes the
|
||||
/// committed annotation arrives, so the stroke never vanishes in between (#1858).
|
||||
@@ -187,6 +204,55 @@ pub(crate) enum EditorFocus {
|
||||
}
|
||||
|
||||
impl CommentView {
|
||||
/// Every selected comment, the one the handles belong to first.
|
||||
pub fn selection(&self) -> Vec<(usize, usize)> {
|
||||
let mut all: Vec<(usize, usize)> = self.selected.into_iter().collect();
|
||||
all.extend(self.also.iter().copied().filter(|k| Some(*k) != self.selected));
|
||||
all
|
||||
}
|
||||
|
||||
/// Whether `key` (page, index) is among the selected comments.
|
||||
pub fn is_selected(&self, key: (usize, usize)) -> bool {
|
||||
self.selected == Some(key) || self.also.contains(&key)
|
||||
}
|
||||
|
||||
/// Select `key` alone.
|
||||
pub fn select_only(&mut self, key: (usize, usize)) {
|
||||
self.selected = Some(key);
|
||||
self.also.clear();
|
||||
}
|
||||
|
||||
/// ⇧-click in a list (`order`, as shown): every comment from the one with the handles to
|
||||
/// `key` is selected, and only those, so another ⇧-click from the same comment changes the
|
||||
/// range. Without a comment with the handles, or with either end not in the list, `key` is
|
||||
/// added as by ⌘/Ctrl-click.
|
||||
pub fn select_range(&mut self, order: &[(usize, usize)], key: (usize, usize)) {
|
||||
let ends = self.selected.and_then(|anchor| Some((order.iter().position(|k| *k == anchor)?, order.iter().position(|k| *k == key)?)));
|
||||
let Some((a, b)) = ends else { return self.toggle(key) };
|
||||
let anchor = self.selected;
|
||||
self.also = order.get(a.min(b)..=a.max(b)).unwrap_or_default().iter().copied().filter(|k| Some(*k) != anchor).collect();
|
||||
}
|
||||
|
||||
/// ⌘/Ctrl-click: add `key` to the selection, or take it out again. The handles go to the
|
||||
/// comment clicked last.
|
||||
pub fn toggle(&mut self, key: (usize, usize)) {
|
||||
if self.selected == Some(key) {
|
||||
self.selected = self.also.pop();
|
||||
} else if let Some(at) = self.also.iter().position(|k| *k == key) {
|
||||
self.also.remove(at);
|
||||
} else {
|
||||
if let Some(previous) = self.selected.replace(key) {
|
||||
self.also.push(previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Nothing selected.
|
||||
pub fn clear_selection(&mut self) {
|
||||
self.selected = None;
|
||||
self.also.clear();
|
||||
}
|
||||
|
||||
/// Takes back the last point of a polygon, cloud or connected lines being drawn; the first
|
||||
/// point's removal cancels the drawing. Returns whether there was such a drawing.
|
||||
pub fn undo_point(&mut self) -> bool {
|
||||
@@ -226,6 +292,7 @@ impl CommentView {
|
||||
/// went too.
|
||||
pub(crate) fn annotation_deleted(&mut self, before: &[Annotation], after: &[Annotation], gone: (usize, usize)) {
|
||||
self.selected = self.selected.and_then(|k| after_delete(before, after, gone, k));
|
||||
self.also = std::mem::take(&mut self.also).into_iter().filter_map(|k| after_delete(before, after, gone, k)).collect();
|
||||
self.editing = self.editing.take().and_then(|(p, i, text)| after_delete(before, after, gone, (p, i)).map(|(p, i)| (p, i, text)));
|
||||
if self.editing.is_none() {
|
||||
self.editor_focus = None;
|
||||
@@ -237,7 +304,7 @@ impl CommentView {
|
||||
/// editor close, its unsaved text with it, rather than Save writing that text over whatever
|
||||
/// took its place.
|
||||
pub(crate) fn history_stepped(&mut self) {
|
||||
self.selected = None;
|
||||
self.clear_selection();
|
||||
self.editing = None;
|
||||
self.editor_focus = None;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,13 @@ fn a_moved_or_reshaped_line_is_drawn_at_its_new_points() {
|
||||
let to = Some(pos2(60.0, 10.0));
|
||||
assert_eq!(changing_points(Some(&dragging), None, (0, 3), &line, to_user, to, false), Some(vec![[10.0, 10.0], [60.0, 10.0]]));
|
||||
assert_eq!(changing_points(None, None, (0, 3), &line, to_user, to, false), None, "not changing");
|
||||
// Released with the rest of the selection: its own move in the batch.
|
||||
let together = Edit::Batch {
|
||||
label: "Move comments".into(),
|
||||
edits: vec![Edit::MoveAnnotation { page: 0, index: 1, dx: 1.0, dy: 1.0 }, Edit::MoveAnnotation { page: 0, index: 3, dx: 5.0, dy: 20.0 }],
|
||||
};
|
||||
assert_eq!(changing_points(None, Some(&together), (0, 3), &line, to_user, None, false), Some(vec![[15.0, 30.0], [55.0, 30.0]]));
|
||||
assert_eq!(changing_points(None, Some(&together), (0, 2), &line, to_user, None, false), None, "not in the batch");
|
||||
}
|
||||
|
||||
/// A sticky note on a page turned a quarter is drawn upright beside its `/Rect`: an edit
|
||||
@@ -407,7 +414,8 @@ fn context_menu_colour_submenu_keeps_its_picker_open_and_a_swatch_closes_the_men
|
||||
let mut h = egui_kittest::Harness::new_ui_state(
|
||||
|ui, (view, actions): &mut (DocView, Vec<CanvasAction>)| {
|
||||
ui.button("Page").context_menu(|ui| {
|
||||
if let Some(a) = context_menu(ui, view, &info, &|_, _| true, &prefs, true, true) {
|
||||
let rights = MenuRights { allowed: true, can_paste: false, can_modify: true };
|
||||
if let Some(a) = context_menu(ui, view, &info, &|_, _| true, &prefs, rights) {
|
||||
actions.push(a);
|
||||
}
|
||||
});
|
||||
@@ -490,6 +498,24 @@ fn drawn_shapes_ignore_slips() {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_click_selects_the_range_from_the_comment_with_the_handles() {
|
||||
let order = [(0, 3), (0, 1), (0, 0), (1, 2), (1, 0)];
|
||||
let mut cv = CommentView::default();
|
||||
cv.select_only((0, 1));
|
||||
cv.select_range(&order, (1, 2));
|
||||
assert_eq!(cv.selection(), vec![(0, 1), (0, 0), (1, 2)], "from the anchor down, the anchor keeps the handles");
|
||||
// Another ⇧-click from the same anchor changes the range, upwards too.
|
||||
cv.select_range(&order, (0, 3));
|
||||
assert_eq!(cv.selection(), vec![(0, 1), (0, 3)]);
|
||||
// Nothing to start from, or a card not in the list: added as by ⌘/Ctrl-click.
|
||||
let mut none = CommentView::default();
|
||||
none.select_range(&order, (0, 0));
|
||||
assert_eq!(none.selection(), vec![(0, 0)]);
|
||||
cv.select_range(&order, (9, 9));
|
||||
assert_eq!(cv.selected, Some((9, 9)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_boxes_fit_their_text() {
|
||||
let r = text_box_rect([100.0, 500.0], "Hi", 12.0, 0);
|
||||
|
||||
@@ -494,7 +494,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, input: PageInput<'_>, free: bo
|
||||
// sees its lost focus, so replacing the box first would drop what was typed. A new box
|
||||
// deselects, as a comment tool's note or text box does: Escape then closes the box and
|
||||
// leaves the tool in two presses.
|
||||
view.comments.selected = None;
|
||||
view.comments.clear_selection();
|
||||
let open = view.fill_text.replace(TypeBox { page, at: top, text: String::new(), size: text_size, color: text_color, focus: true });
|
||||
open.filter(|tb| !tb.text.trim().is_empty()).map(|tb| {
|
||||
FillAction::Edit(Box::new(typed_with_color(
|
||||
|
||||
@@ -578,7 +578,7 @@ fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) {
|
||||
}
|
||||
if let Some((page, annotation, reading, scale)) = select {
|
||||
app.views[index].current = page;
|
||||
app.views[index].comments.selected = Some((page, annotation));
|
||||
app.views[index].comments.select_only((page, annotation));
|
||||
app.views[index].measure.reading = Some(reading);
|
||||
app.views[index].measure.scale = Some(scale);
|
||||
app.quick_tool = QuickTool::Select;
|
||||
|
||||
@@ -100,6 +100,22 @@ fn every_registered_command_is_implemented() {
|
||||
if spec.id == "edit.redo" {
|
||||
app.undo();
|
||||
}
|
||||
// Copying comments needs a selected comment; pasting needs copied ones.
|
||||
if matches!(spec.id, "comment.copy" | "comment.paste") {
|
||||
let shape = pdfcraft_engine::Shape::Rectangle { rect: [10.0, 10.0, 50.0, 50.0] };
|
||||
let style = pdfcraft_engine::Style::default_for(&shape);
|
||||
assert!(app.apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation {
|
||||
page: 0,
|
||||
shape,
|
||||
style,
|
||||
contents: String::new(),
|
||||
author: String::new(),
|
||||
})));
|
||||
app.views[0].comments.select_only((0, 0));
|
||||
if spec.id == "comment.paste" {
|
||||
assert!(app.copy_selected_comments(0));
|
||||
}
|
||||
}
|
||||
if spec.id.starts_with("edit.") && (spec.id.ends_with(".update") || spec.id.ends_with(".remove")) {
|
||||
use pdfcraft_engine::{Background, Edit, HeaderFooter, Watermark};
|
||||
let mut hf = HeaderFooter::default();
|
||||
|
||||
@@ -84,6 +84,19 @@ fn click_pt(h: &mut Harness<'static, PdfCraftApp>, p: (f32, f32)) {
|
||||
let p = at(h, p.0, p.1);
|
||||
click(h, p);
|
||||
}
|
||||
/// Click a page point with modifier keys held (⌘-click adds a comment to the selection).
|
||||
fn click_pt_with(h: &mut Harness<'static, PdfCraftApp>, p: (f32, f32), modifiers: egui::Modifiers) {
|
||||
let p = at(h, p.0, p.1);
|
||||
h.hover_at(p);
|
||||
h.event(egui::Event::ModifiersChanged(modifiers));
|
||||
h.run_steps(1);
|
||||
for pressed in [true, false] {
|
||||
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers });
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
/// A text field by its placeholder ("Add a comment", "Add a reply").
|
||||
fn field<'a>(h: &'a Harness<'static, PdfCraftApp>, placeholder: &'a str) -> egui_kittest::Node<'a> {
|
||||
@@ -3716,3 +3729,442 @@ fn other_edit_composers_keep_trimming_trailing_whitespace() {
|
||||
edit_composer_preserves_only_typewriter_whitespace(kind);
|
||||
}
|
||||
}
|
||||
|
||||
/// ⌘-click (Ctrl on Linux and Windows) adds comments to the selection and takes one out again;
|
||||
/// Delete removes every selected comment as one undo step.
|
||||
#[test]
|
||||
fn ctrl_click_selects_several_comments_and_delete_removes_them_all() {
|
||||
let mut h = harness(|app| app.set_option("quick", "square").unwrap());
|
||||
for (a, b) in [((20.0, 20.0), (60.0, 60.0)), ((100.0, 20.0), (140.0, 60.0)), ((180.0, 20.0), (220.0, 60.0))] {
|
||||
drag_pt(&mut h, a, b);
|
||||
}
|
||||
assert_eq!(comments(&h).len(), 3);
|
||||
h.state_mut().set_option("quick", "select").unwrap();
|
||||
h.run_steps(2);
|
||||
click_pt(&mut h, (40.0, 40.0));
|
||||
assert_eq!(h.state().views[0].comments.selection(), vec![(0, 0)]);
|
||||
click_pt_with(&mut h, (120.0, 40.0), egui::Modifiers::COMMAND);
|
||||
click_pt_with(&mut h, (200.0, 40.0), egui::Modifiers::COMMAND);
|
||||
assert_eq!(h.state().views[0].comments.selection(), vec![(0, 2), (0, 0), (0, 1)], "the handles go to the one clicked last");
|
||||
assert_eq!(h.state().views[0].comments.selected, Some((0, 2)));
|
||||
// ⌘-click on a selected comment takes it out again.
|
||||
click_pt_with(&mut h, (40.0, 40.0), egui::Modifiers::COMMAND);
|
||||
assert_eq!(h.state().views[0].comments.selection(), vec![(0, 2), (0, 1)]);
|
||||
// Delete: both go, as one step.
|
||||
h.key_press(egui::Key::Delete);
|
||||
h.run_steps(3);
|
||||
let left = comments(&h);
|
||||
assert_eq!(left.len(), 1, "{left:?}");
|
||||
assert!((left[0].rect[0] - 20.0).abs() < 1.0, "the unselected first rectangle stays: {:?}", left[0].rect);
|
||||
assert!(h.state().views[0].comments.selection().is_empty());
|
||||
let id = h.state().views[0].id;
|
||||
assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Delete comments"));
|
||||
h.state_mut().undo();
|
||||
assert_eq!(comments(&h).len(), 3);
|
||||
// A plain click selects one alone again.
|
||||
click_pt(&mut h, (120.0, 40.0));
|
||||
assert_eq!(h.state().views[0].comments.selection(), vec![(0, 1)]);
|
||||
// On the page ⇧-click adds one too, and takes it out again (a range is the panel's).
|
||||
click_pt_with(&mut h, (200.0, 40.0), egui::Modifiers::SHIFT);
|
||||
assert_eq!(h.state().views[0].comments.selection().len(), 2, "{:?}", h.state().views[0].comments.selection());
|
||||
click_pt_with(&mut h, (200.0, 40.0), egui::Modifiers::SHIFT);
|
||||
assert_eq!(h.state().views[0].comments.selection(), vec![(0, 1)]);
|
||||
}
|
||||
|
||||
/// Dragging one of several selected comments moves them all, whichever of them is grabbed: the
|
||||
/// grabbed one takes the handles and the one that had them stays selected.
|
||||
#[test]
|
||||
fn dragging_any_selected_comment_moves_the_whole_selection() {
|
||||
let mut h = harness(|app| app.set_option("quick", "square").unwrap());
|
||||
for (a, b) in [((20.0, 20.0), (60.0, 60.0)), ((100.0, 20.0), (140.0, 60.0)), ((180.0, 20.0), (220.0, 60.0))] {
|
||||
drag_pt(&mut h, a, b);
|
||||
}
|
||||
h.state_mut().set_option("quick", "select").unwrap();
|
||||
h.run_steps(2);
|
||||
click_pt(&mut h, (40.0, 40.0));
|
||||
click_pt_with(&mut h, (120.0, 40.0), egui::Modifiers::COMMAND);
|
||||
click_pt_with(&mut h, (200.0, 40.0), egui::Modifiers::COMMAND);
|
||||
assert_eq!(h.state().views[0].comments.selected, Some((0, 2)), "the last one clicked has the handles");
|
||||
// Drag the first one (not the one with the handles) 30 points up.
|
||||
let before = comments(&h);
|
||||
drag_pt(&mut h, (40.0, 40.0), (40.0, 70.0));
|
||||
let after = comments(&h);
|
||||
assert_eq!(after.len(), 3);
|
||||
for (a, b) in before.iter().zip(&after) {
|
||||
assert!((b.rect[1] - a.rect[1] - 30.0).abs() < 1.5, "every selected rectangle moved up 30 pt: {:?} from {:?}", b.rect, a.rect);
|
||||
}
|
||||
let selection = h.state().views[0].comments.selection();
|
||||
assert_eq!(selection.len(), 3, "all three stay selected: {selection:?}");
|
||||
assert_eq!(selection[0], (0, 0), "the grabbed one has the handles");
|
||||
let id = h.state().views[0].id;
|
||||
assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Move comments"));
|
||||
// A locked one in the selection stays where it is; the others still move (it would refuse
|
||||
// the whole step otherwise, and nothing would move).
|
||||
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::LockAnnotation { page: 0, index: 1, locked: true }));
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().views[0].comments.selection().len(), 3);
|
||||
let before = comments(&h);
|
||||
drag_pt(&mut h, (40.0, 70.0), (40.0, 90.0));
|
||||
let after = comments(&h);
|
||||
// By index: the list isn't kept in index order.
|
||||
let bottom = |list: &[Annotation], i: usize| list.iter().find(|a| a.index == i).map(|a| a.rect[1]).expect("still there");
|
||||
for i in 0..3 {
|
||||
let moved = bottom(&after, i) - bottom(&before, i);
|
||||
if i == 1 {
|
||||
assert!(moved.abs() < 0.01, "the locked one stays: moved {moved}");
|
||||
} else {
|
||||
assert!((moved - 20.0).abs() < 1.5, "rectangle {i} moved up 20 pt: moved {moved}");
|
||||
}
|
||||
}
|
||||
assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Move comments"));
|
||||
}
|
||||
|
||||
/// Copied comments on the pointer take the click whatever tool is on: with Fill & Sign's Text
|
||||
/// tool or a stamp tool still on, the click places the bundle and opens no text box or stamp.
|
||||
#[test]
|
||||
fn copied_comments_take_the_click_from_the_tool_that_is_on() {
|
||||
use pdfcraft_ui_egui::fill_sign::FillTool;
|
||||
let mut h = harness(|_| {});
|
||||
let id = h.state().views[0].id;
|
||||
let shape = pdfcraft_engine::Shape::Rectangle { rect: [20.0, 20.0, 60.0, 60.0] };
|
||||
let style = pdfcraft_engine::Style::default_for(&shape);
|
||||
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation {
|
||||
page: 0,
|
||||
shape,
|
||||
style,
|
||||
contents: String::new(),
|
||||
author: "Tester".into(),
|
||||
})));
|
||||
h.run_steps(2);
|
||||
h.state_mut().views[0].comments.select_only((0, 0));
|
||||
assert!(h.state_mut().execute("comment.copy"));
|
||||
let count = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(id).unwrap().info.annotations.len();
|
||||
for (tool, at) in [(QuickTool::Fill(FillTool::Text), (150.0, 100.0)), (QuickTool::Stamp(pdfcraft_engine::StampKind::Approved), (200.0, 160.0))] {
|
||||
h.state_mut().quick_tool = tool;
|
||||
h.run_steps(2);
|
||||
let before = count(&h);
|
||||
assert!(h.state_mut().execute("comment.paste"));
|
||||
click_pt(&mut h, at);
|
||||
assert_eq!(count(&h), before + 1, "{tool:?}: the copy, and nothing else");
|
||||
assert!(h.state().views[0].fill_text.is_none(), "{tool:?}: no text box opens");
|
||||
assert!(h.state().views[0].comments.gesture.is_none());
|
||||
let placed = h.state().session.get(id).unwrap().info.annotations.iter().max_by_key(|a| a.index).map(|a| a.rect).expect("placed");
|
||||
assert!(((placed[0] + placed[2]) / 2.0 - at.0).abs() < 1.0, "{tool:?}: centred on the click: {placed:?}");
|
||||
assert_eq!(h.state().quick_tool, tool, "the tool stays on for the next click");
|
||||
}
|
||||
// Afterwards the tool takes its clicks again.
|
||||
h.state_mut().quick_tool = QuickTool::Fill(FillTool::Text);
|
||||
h.run_steps(2);
|
||||
click_pt(&mut h, (100.0, 200.0));
|
||||
assert!(h.state().views[0].fill_text.is_some(), "Fill & Sign's Text tool opens its box again");
|
||||
}
|
||||
|
||||
/// Two boxes on page 1 of the active document, and a reply to the first.
|
||||
fn add_two_boxes_and_a_reply(h: &mut Harness<'static, PdfCraftApp>) {
|
||||
for rect in [[20.0, 20.0, 60.0, 60.0], [100.0, 20.0, 140.0, 60.0]] {
|
||||
let shape = pdfcraft_engine::Shape::Rectangle { rect };
|
||||
let style = pdfcraft_engine::Style::default_for(&shape);
|
||||
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation {
|
||||
page: 0,
|
||||
shape,
|
||||
style,
|
||||
contents: "Box".into(),
|
||||
author: "Tester".into(),
|
||||
})));
|
||||
}
|
||||
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::ReplyToAnnotation { page: 0, index: 0, text: "Reply".into(), author: "Tester".into() }));
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
/// Copied comments paste into another document on its current page, each with its replies, as
|
||||
/// new comments (a second paste gives a second set); the copies are selected afterwards.
|
||||
#[test]
|
||||
fn copied_comments_paste_into_another_document() {
|
||||
let mut h = harness(|app| {
|
||||
app.open_bytes("other.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens");
|
||||
app.active = Some(0);
|
||||
});
|
||||
let (source, target) = (h.state().views[0].id, h.state().views[1].id);
|
||||
add_two_boxes_and_a_reply(&mut h);
|
||||
// Adding a comment selects it; with nothing selected there is nothing to copy.
|
||||
h.state_mut().views[0].comments.clear_selection();
|
||||
assert!(!h.state_mut().execute("comment.copy"), "nothing selected: nothing to copy");
|
||||
{
|
||||
let cv = &mut h.state_mut().views[0].comments;
|
||||
cv.select_only((0, 0));
|
||||
cv.also.push((0, 1));
|
||||
}
|
||||
assert!(h.state_mut().execute("comment.copy"));
|
||||
assert_eq!(h.state().comment_clipboard.as_ref().map(|c| c.count), Some(2));
|
||||
// Into the other document: Edit ▸ Paste comments hangs the bundle on the pointer, a click
|
||||
// places it, centred there (the two boxes span 20..140 × 20..60: centre 80, 40).
|
||||
h.state_mut().active = Some(1);
|
||||
h.run_steps(2);
|
||||
assert!(h.state_mut().execute("comment.paste"));
|
||||
let count = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(target).unwrap().info.annotations.len();
|
||||
assert_eq!(count(&h), 0, "nothing placed yet");
|
||||
assert!(matches!(h.state().views[1].comments.gesture, Some(pdfcraft_ui_egui::comments::Gesture::Place { count: 2, .. })));
|
||||
click_pt(&mut h, (150.0, 100.0));
|
||||
let pasted = h.state().session.get(target).unwrap().info.annotations.clone();
|
||||
assert_eq!(pasted.len(), 3, "two comments and the reply: {pasted:?}");
|
||||
assert!(pasted.iter().all(|a| a.page == 0), "on the page clicked: {pasted:?}");
|
||||
let parent = pasted.iter().find(|a| a.in_reply_to.is_none() && (a.rect[0] - 90.0).abs() < 1.0).expect("the first box, moved by (70, 60)");
|
||||
assert!((parent.rect[1] - 80.0).abs() < 1.0 && (parent.rect[2] - 130.0).abs() < 1.0, "{:?}", parent.rect);
|
||||
assert_eq!(pasted.iter().find(|a| a.in_reply_to.is_some()).and_then(|r| r.in_reply_to.clone()), parent.name, "the reply follows its copy");
|
||||
assert_eq!(h.state().views[1].comments.selection().len(), 2, "the copies are selected");
|
||||
assert!(h.state().views[1].comments.gesture.is_none());
|
||||
assert_eq!(h.state().session.get(target).unwrap().can_undo(), Some("Paste comments"));
|
||||
assert_eq!(h.state().session.get(source).unwrap().info.annotations.len(), 3, "the source is untouched");
|
||||
// The page menu's Paste comments places the bundle where the menu was opened: page 2 here.
|
||||
h.state_mut().views[1].comments.paste_request = Some((1, [100.0, 100.0]));
|
||||
h.run_steps(3);
|
||||
assert_eq!(count(&h), 6);
|
||||
let second: Vec<_> =
|
||||
h.state().session.get(target).unwrap().info.annotations.iter().filter(|a| a.page == 1 && a.in_reply_to.is_none()).cloned().collect();
|
||||
assert_eq!(second.len(), 2);
|
||||
let centre_x = (second.iter().map(|a| a.rect[0]).fold(f32::MAX, f32::min) + second.iter().map(|a| a.rect[2]).fold(f32::MIN, f32::max)) / 2.0;
|
||||
assert!((centre_x - 100.0).abs() < 1.0, "centred at the menu's point: {second:?}");
|
||||
// ⌘V (a Paste event) hangs the bundle on the pointer again; Escape lets it go.
|
||||
h.event(egui::Event::Paste(String::new()));
|
||||
h.run_steps(3);
|
||||
assert!(h.state().views[1].comments.gesture.is_some());
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(3);
|
||||
assert!(h.state().views[1].comments.gesture.is_none());
|
||||
assert_eq!(count(&h), 6, "nothing placed");
|
||||
h.state_mut().undo();
|
||||
assert_eq!(count(&h), 3);
|
||||
// XFDF arriving from the system clipboard pastes too, even without the app's clipboard.
|
||||
let xfdf = h.state().comment_clipboard.clone().unwrap().xfdf;
|
||||
h.state_mut().comment_clipboard = None;
|
||||
assert!(!h.state_mut().execute("comment.paste"), "nothing copied: disabled");
|
||||
h.event(egui::Event::Paste(xfdf.clone()));
|
||||
h.run_steps(3);
|
||||
click_pt(&mut h, (200.0, 120.0));
|
||||
assert_eq!(count(&h), 6);
|
||||
h.event(egui::Event::Paste("just text".into()));
|
||||
h.run_steps(3);
|
||||
assert!(h.state().views[1].comments.gesture.is_none(), "plain text is not a paste of comments");
|
||||
// Nor when comments were copied earlier: a word copied in another app since is that word.
|
||||
h.state_mut().comment_clipboard = Some(pdfcraft_ui_egui::CommentClip { xfdf, count: 2 });
|
||||
h.event(egui::Event::Paste("a word from elsewhere".into()));
|
||||
h.run_steps(3);
|
||||
assert!(h.state().views[1].comments.gesture.is_none(), "the old comments don't hang on the pointer");
|
||||
// Nothing legible on the system clipboard: the app's own copied comments.
|
||||
h.event(egui::Event::Paste(String::new()));
|
||||
h.run_steps(3);
|
||||
assert!(h.state().views[1].comments.gesture.is_some());
|
||||
}
|
||||
|
||||
/// Connected lines (and polygons) finish from the right-click menu too, as other editors do;
|
||||
/// the menu also takes the last point back and cancels the drawing.
|
||||
#[test]
|
||||
fn connected_lines_are_completed_from_the_context_menu() {
|
||||
let mut h = harness(|app| app.set_option("quick", "polyline").unwrap());
|
||||
for p in [(20.0, 150.0), (60.0, 130.0), (100.0, 150.0)] {
|
||||
click_pt(&mut h, p);
|
||||
}
|
||||
assert_eq!(comments(&h).len(), 0, "still drawing");
|
||||
let right_click = |h: &mut Harness<'static, PdfCraftApp>, p: (f32, f32)| {
|
||||
let p = at(h, p.0, p.1);
|
||||
h.hover_at(p);
|
||||
h.run_steps(1);
|
||||
for pressed in [true, false] {
|
||||
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.run_steps(2);
|
||||
};
|
||||
right_click(&mut h, (120.0, 100.0));
|
||||
h.get_by_label("Complete").click();
|
||||
h.run_steps(3);
|
||||
let c = comments(&h);
|
||||
assert_eq!(c.len(), 1, "{c:?}");
|
||||
assert_eq!(c[0].subtype, "PolyLine");
|
||||
// A polygon needs three points: with two, Complete is greyed; Undo last point and Cancel drawing work.
|
||||
h.state_mut().set_option("quick", "polygon").unwrap();
|
||||
for p in [(150.0, 150.0), (200.0, 150.0)] {
|
||||
click_pt(&mut h, p);
|
||||
}
|
||||
assert_eq!(drawn_points(&h), 2);
|
||||
right_click(&mut h, (220.0, 100.0));
|
||||
assert!(h.get_by_label("Complete").accesskit_node().is_disabled());
|
||||
h.get_by_label("Undo last point").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(drawn_points(&h), 1);
|
||||
right_click(&mut h, (220.0, 100.0));
|
||||
h.get_by_label("Cancel drawing").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(drawn_points(&h), 0);
|
||||
assert!(h.state().views[0].comments.gesture.is_none());
|
||||
assert_eq!(comments(&h).len(), 1, "nothing else was drawn");
|
||||
// On the second page, with the first on screen in the same frame: Complete reaches the page
|
||||
// the drawing is on (the first page passes first and must not take the request).
|
||||
h.state_mut().set_option("quick", "polyline").unwrap();
|
||||
h.state_mut().set_option("zoom", "50").unwrap();
|
||||
crate::support::settle(&mut h);
|
||||
assert!(h.state().views[0].page_screen_rect(0).is_some(), "page 1 is on screen too");
|
||||
let at2 = |h: &Harness<'static, PdfCraftApp>, x: f32, y: f32| {
|
||||
let r = h.state().views[0].page_screen_rect(1).expect("page 2 on screen");
|
||||
pos2(r.left() + x / 300.0 * r.width(), r.top() + (200.0 - y) / 200.0 * r.height())
|
||||
};
|
||||
for (x, y) in [(20.0, 100.0), (60.0, 80.0)] {
|
||||
let p = at2(&h, x, y);
|
||||
click(&mut h, p);
|
||||
}
|
||||
assert_eq!(drawn_points(&h), 2);
|
||||
let p = at2(&h, 150.0, 150.0);
|
||||
h.hover_at(p);
|
||||
h.run_steps(1);
|
||||
for pressed in [true, false] {
|
||||
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Complete").click();
|
||||
h.run_steps(3);
|
||||
let c = comments(&h);
|
||||
assert_eq!(c.len(), 2, "{c:?}");
|
||||
assert!(c.iter().any(|a| a.page == 1 && a.subtype == "PolyLine"), "{c:?}");
|
||||
assert!(h.state().views[0].comments.gesture.is_none());
|
||||
}
|
||||
|
||||
/// Properties on one of several selected comments changes all of them, each where it applies: the
|
||||
/// colour goes to every one, the line width to the shapes, the font size to the text box alone;
|
||||
/// a locked one stays as it is. One undo step. Colouring the selection works the same way.
|
||||
#[test]
|
||||
fn properties_change_every_selected_comment_where_it_applies() {
|
||||
use pdfcraft_engine::{Edit, NewAnnotation, Shape, Style};
|
||||
let mut h = harness(|_| {});
|
||||
let shapes = [
|
||||
Shape::Rectangle { rect: [20.0, 20.0, 60.0, 60.0] },
|
||||
Shape::Oval { rect: [80.0, 20.0, 120.0, 60.0] },
|
||||
Shape::TextBox { rect: [140.0, 20.0, 200.0, 40.0], font_size: 12.0 },
|
||||
Shape::Rectangle { rect: [220.0, 20.0, 260.0, 60.0] },
|
||||
];
|
||||
for shape in shapes {
|
||||
let style = Style::default_for(&shape);
|
||||
assert!(h.state_mut().apply_edit(Edit::AddAnnotation(NewAnnotation {
|
||||
page: 0,
|
||||
shape,
|
||||
style,
|
||||
contents: "x".into(),
|
||||
author: "Tester".into()
|
||||
})));
|
||||
}
|
||||
assert!(h.state_mut().apply_edit(Edit::LockAnnotation { page: 0, index: 3, locked: true }));
|
||||
{
|
||||
let c = &mut h.state_mut().views[0].comments;
|
||||
c.selected = Some((0, 0));
|
||||
c.also = vec![(0, 1), (0, 2), (0, 3)];
|
||||
}
|
||||
h.run_steps(2);
|
||||
h.state_mut().open_comment_props(0, 0);
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Changes here apply to all 4 selected comments");
|
||||
{
|
||||
let d = h.state_mut().comment_props.as_mut().expect("open");
|
||||
assert_eq!(d.others.len(), 3);
|
||||
d.edited.color = Some([0.0, 0.0, 1.0]);
|
||||
d.edited.width = Some(4.0);
|
||||
}
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
let props = |h: &Harness<'static, PdfCraftApp>, index: usize| {
|
||||
let s = h.state();
|
||||
s.session.get(s.views[0].id).unwrap().comment_props(0, index).unwrap()
|
||||
};
|
||||
for index in [0, 1] {
|
||||
let p = props(&h, index);
|
||||
assert_eq!((p.color, p.width), (Some([0.0, 0.0, 1.0]), Some(4.0)), "shape {index}");
|
||||
}
|
||||
assert_eq!(props(&h, 2).color, Some([0.0, 0.0, 1.0]), "the text box's text colour");
|
||||
let locked = props(&h, 3);
|
||||
assert_ne!(locked.color, Some([0.0, 0.0, 1.0]), "the locked rectangle stays as it was");
|
||||
let s = h.state();
|
||||
assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), Some("Change comment properties"), "one undo step");
|
||||
// From the text box: its font size changes, and the shapes, which have none, keep their look.
|
||||
h.state_mut().views[0].comments.selected = Some((0, 2));
|
||||
h.state_mut().views[0].comments.also = vec![(0, 0), (0, 1)];
|
||||
h.state_mut().open_comment_props(0, 2);
|
||||
h.run_steps(2);
|
||||
h.state_mut().comment_props.as_mut().expect("open").edited.font_size = Some(20.0);
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(props(&h, 2).font_size, Some(20.0));
|
||||
assert_eq!((props(&h, 0).font_size, props(&h, 0).width), (None, Some(4.0)));
|
||||
// Colouring the selection (the context menu's Colour with several selected).
|
||||
h.state_mut().colour_selected_comments([1.0, 0.0, 0.0]);
|
||||
h.run_steps(2);
|
||||
for index in [0, 1, 2] {
|
||||
assert_eq!(props(&h, index).color, Some([1.0, 0.0, 0.0]), "comment {index}");
|
||||
}
|
||||
assert_ne!(props(&h, 3).color, Some([1.0, 0.0, 0.0]));
|
||||
}
|
||||
|
||||
/// Comment Properties' text opacity, set with several comments selected, goes to the other text
|
||||
/// boxes whose text shows an opacity of its own (a border or a fill), and leaves a text-only box,
|
||||
/// whose one opacity is the whole comment's, as it was.
|
||||
#[test]
|
||||
fn part_opacity_changes_every_selected_comment_that_shows_the_part() {
|
||||
use pdfcraft_engine::{Edit, NewAnnotation, Shape, Style};
|
||||
let mut h = harness(|_| {});
|
||||
for (n, width) in [1.0, 1.0, 0.0].into_iter().enumerate() {
|
||||
let x = 20.0 + 100.0 * n as f64;
|
||||
let shape = Shape::TextBox { rect: [x, 20.0, x + 80.0, 40.0], font_size: 12.0 };
|
||||
let style = Style { width, ..Style::default_for(&shape) };
|
||||
assert!(h.state_mut().apply_edit(Edit::AddAnnotation(NewAnnotation {
|
||||
page: 0,
|
||||
shape,
|
||||
style,
|
||||
contents: "x".into(),
|
||||
author: "Tester".into()
|
||||
})));
|
||||
}
|
||||
let props = |h: &Harness<'static, PdfCraftApp>, index: usize| {
|
||||
let s = h.state();
|
||||
s.session.get(s.views[0].id).unwrap().comment_props(0, index).unwrap()
|
||||
};
|
||||
let text_only = props(&h, 2);
|
||||
{
|
||||
let c = &mut h.state_mut().views[0].comments;
|
||||
c.selected = Some((0, 0));
|
||||
c.also = vec![(0, 1), (0, 2)];
|
||||
}
|
||||
h.run_steps(2);
|
||||
h.state_mut().open_comment_props(0, 0);
|
||||
h.run_steps(2);
|
||||
h.state_mut().comment_props.as_mut().expect("open").edited.text_opacity = Some(0.4);
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
for index in [0, 1] {
|
||||
let text = props(&h, index).text_opacity.expect("a text box's text opacity");
|
||||
assert!((text - 0.4).abs() < 1e-3, "bordered box {index}: {text}");
|
||||
}
|
||||
let after = props(&h, 2);
|
||||
assert_eq!((after.text_opacity, after.opacity), (text_only.text_opacity, text_only.opacity), "the text-only box keeps its look");
|
||||
}
|
||||
|
||||
/// Escape with a Fill & Sign tool on and several comments selected deselects them all first,
|
||||
/// then drops the tool.
|
||||
#[test]
|
||||
fn escape_with_a_fill_tool_deselects_every_comment_then_drops_the_tool() {
|
||||
use pdfcraft_ui_egui::fill_sign::FillTool;
|
||||
let mut h = harness(|_| {});
|
||||
add_two_boxes_and_a_reply(&mut h);
|
||||
h.state_mut().quick_tool = QuickTool::Fill(FillTool::Text);
|
||||
{
|
||||
let c = &mut h.state_mut().views[0].comments;
|
||||
c.selected = Some((0, 0));
|
||||
c.also = vec![(0, 1)];
|
||||
}
|
||||
h.run_steps(2);
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(2);
|
||||
let c = &h.state().views[0].comments;
|
||||
assert!(c.selected.is_none() && c.also.is_empty(), "the whole selection goes");
|
||||
assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Text), "the tool stays for now");
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().quick_tool, QuickTool::Select);
|
||||
}
|
||||
@@ -29,9 +29,9 @@ pub use fdf::export_fdf;
|
||||
use fdf::{ImportBudget, fdf_body, import_fdf, walk_fdf};
|
||||
#[cfg(test)]
|
||||
use fdf::{MAX_FDF_BYTES, MAX_FDF_OBJECTS};
|
||||
pub use xfdf::export_xfdf;
|
||||
#[cfg(test)]
|
||||
use xfdf::{LOCKED, find_by_name};
|
||||
pub use xfdf::{Outline, comment_count, comment_outlines, comments_bounds, export_xfdf, export_xfdf_comments, import_comments, is_xfdf_comments};
|
||||
use xfdf::{apply_values, import_xfdf};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -4,6 +4,7 @@ use pdfcraft_annot::{Markup, Meta, NewAnnotation, Shape, Style, TextReplacement,
|
||||
use pdfcraft_cos::{SaveOptions, write_full, write_incremental};
|
||||
use pdfcraft_forms::{NewField, add_field, fields, set_value};
|
||||
|
||||
use super::xfdf::PDFCRAFT_NS;
|
||||
use super::*;
|
||||
|
||||
/// Two blank pages with three fields (text, check box, list).
|
||||
@@ -2099,3 +2100,323 @@ fn an_fdf_comment_popup_without_a_rect_is_dropped() {
|
||||
let popups = page.iter().filter(|a| dst.resolve(a).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Popup".as_slice()))).count();
|
||||
assert_eq!(popups, 0);
|
||||
}
|
||||
|
||||
/// Copy: the chosen comments travel with their replies and nothing else.
|
||||
#[test]
|
||||
fn exporting_selected_comments_takes_them_and_their_replies() {
|
||||
let src = filled();
|
||||
let note = summaries(&src).into_iter().find(|s| s.subtype == "Text").unwrap();
|
||||
let xfdf = export_xfdf_comments(&src, &[(note.page, note.index)], "form.pdf");
|
||||
assert!(xfdf.contains("<text page=\"0\""), "{xfdf}");
|
||||
assert!(xfdf.contains("inreplyto=\"n1\""), "the reply comes along: {xfdf}");
|
||||
assert!(!xfdf.contains("<highlight") && !xfdf.contains("<ink"), "other comments stay: {xfdf}");
|
||||
assert!(!xfdf.contains("<fields>"), "no field values");
|
||||
assert!(is_xfdf_comments(&xfdf));
|
||||
assert!(!is_xfdf_comments("<xfdf xmlns=\"x\"><fields/></xfdf>") && !is_xfdf_comments("just text"));
|
||||
// A position that is no comment exports nothing but a valid, empty file.
|
||||
let empty = export_xfdf_comments(&src, &[(7, 7)], "form.pdf");
|
||||
assert!(empty.contains("<annots>\n</annots>"), "{empty}");
|
||||
}
|
||||
|
||||
/// Paste: the copies land on the asked-for page under names of their own, so nothing is
|
||||
/// replaced and a second paste gives a second set; the reply keeps its copied parent.
|
||||
#[test]
|
||||
fn importing_comments_as_copies_adds_them_on_the_page_with_fresh_names() {
|
||||
let src = filled();
|
||||
let note = summaries(&src).into_iter().find(|s| s.subtype == "Text").unwrap();
|
||||
let xfdf = export_xfdf_comments(&src, &[(note.page, note.index)], "form.pdf");
|
||||
let mut dst = blank();
|
||||
let r = import_comments(&mut dst, &xfdf, Some(1), Some("paste1"), None).unwrap();
|
||||
assert_eq!((r.comments, r.fields), (2, 0), "{r:?}");
|
||||
let first: Vec<_> = summaries(&dst);
|
||||
assert!(first.iter().all(|s| s.page == 1), "every copy is on page 2: {first:?}");
|
||||
let parent = first.iter().find(|s| s.subtype == "Text").unwrap();
|
||||
let reply = first.iter().find(|s| s.in_reply_to.is_some()).expect("the reply came along");
|
||||
assert_eq!(reply.in_reply_to.as_deref(), Some("paste1-1"), "and replies to the copied note, by its new name ({parent:?})");
|
||||
// Again: two sets, not one replaced set.
|
||||
import_comments(&mut dst, &xfdf, Some(0), Some("paste2"), None).unwrap();
|
||||
let second = summaries(&dst);
|
||||
assert_eq!(second.len(), 4, "{second:?}");
|
||||
assert_eq!(second.iter().filter(|s| s.page == 0).count(), 2);
|
||||
// The copies' names are their own, not the originals'.
|
||||
let xfdf2 = export_xfdf(&dst, true, false, "dst.pdf");
|
||||
assert!(!xfdf2.contains("name=\"n1\"") && !xfdf2.contains("name=\"r1\""), "{xfdf2}");
|
||||
assert!(xfdf2.contains("name=\"paste1-1\"") && xfdf2.contains("inreplyto=\"paste1-1\""), "{xfdf2}");
|
||||
// A page the document doesn't have is refused, and nothing changes.
|
||||
assert!(import_comments(&mut dst, &xfdf, Some(9), Some("paste3"), None).is_err());
|
||||
assert_eq!(summaries(&dst).len(), 4);
|
||||
// Without a target page the copies keep their own pages; without a prefix the import is
|
||||
// the usual one (same names replace).
|
||||
let mut other = blank();
|
||||
import_comments(&mut other, &xfdf, None, Some("p"), None).unwrap();
|
||||
assert!(summaries(&other).iter().all(|s| s.page == 0));
|
||||
import_comments(&mut other, &xfdf, None, None, None).unwrap();
|
||||
import_comments(&mut other, &xfdf, None, None, None).unwrap();
|
||||
assert_eq!(summaries(&other).len(), 4, "the named import replaced its own earlier copy");
|
||||
}
|
||||
|
||||
/// Other tools write comments straight into the page's `/Annots` array, without an object of
|
||||
/// their own: they copy like any other, and two source comments sharing a name give two copies.
|
||||
#[test]
|
||||
fn comments_written_directly_into_the_page_array_copy_and_shared_names_give_two_copies() {
|
||||
let mut doc = blank();
|
||||
let page = pdfcraft_model::pages(&doc)[0].obj;
|
||||
let mut direct = Dict::new();
|
||||
direct.set(b"Type".to_vec(), Object::name("Annot"));
|
||||
direct.set(b"Subtype".to_vec(), Object::name("Square"));
|
||||
direct.set(b"Rect".to_vec(), arr(&[10.0, 10.0, 60.0, 40.0]));
|
||||
direct.set(b"Contents".to_vec(), PdfString::text("drawn elsewhere"));
|
||||
let mut twin = direct.clone();
|
||||
twin.set(b"NM".to_vec(), PdfString::text("shared"));
|
||||
let mut other_twin = twin.clone();
|
||||
other_twin.set(b"Rect".to_vec(), arr(&[100.0, 10.0, 150.0, 40.0]));
|
||||
doc.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Array(vec![Object::Dict(direct), Object::Dict(twin), Object::Dict(other_twin)])))
|
||||
.unwrap();
|
||||
assert_eq!(summaries(&doc).len(), 3);
|
||||
let xfdf = export_xfdf_comments(&doc, &[(0, 0)], "x.pdf");
|
||||
assert!(xfdf.contains("<square") && xfdf.contains("drawn elsewhere"), "a direct annotation copies: {xfdf}");
|
||||
assert_eq!(comment_count(&xfdf), 1);
|
||||
let both = export_xfdf_comments(&doc, &[(0, 1), (0, 2)], "x.pdf");
|
||||
assert_eq!(comment_count(&both), 2);
|
||||
let mut dst = blank();
|
||||
import_comments(&mut dst, &both, Some(0), Some("p"), None).unwrap();
|
||||
let copies = summaries(&dst);
|
||||
assert_eq!(copies.len(), 2, "two copies, not one replacing the other: {copies:?}");
|
||||
assert_ne!(copies[0].name, copies[1].name);
|
||||
assert!(export_xfdf_comments(&doc, &[(0, 9)], "x.pdf").contains("<annots>\n</annots>"), "a position that is no annotation copies nothing");
|
||||
}
|
||||
|
||||
/// A selected comment without a name of its own (other tools leave /NM out) gets one for the
|
||||
/// export, so its replies still find it when the copies are pasted.
|
||||
#[test]
|
||||
fn replies_to_a_selected_comment_without_a_name_follow_it_into_the_copy() {
|
||||
let meta = |id: &str| Meta { date: None, id: id.into() };
|
||||
let mut doc = blank();
|
||||
let shape = Shape::Rectangle { rect: [10.0, 10.0, 60.0, 40.0] };
|
||||
add_annotation(
|
||||
&mut doc,
|
||||
&NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: "nameless".into(), author: "Ada".into() },
|
||||
&meta("gone"),
|
||||
)
|
||||
.unwrap();
|
||||
let index = summaries(&doc)[0].index;
|
||||
add_reply(&mut doc, 0, index, "Agreed", "Bob", &meta("r1")).unwrap();
|
||||
// The parent loses its /NM, as another tool may have left it.
|
||||
let annots = pdfcraft_model::pages(&doc)[0].dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap();
|
||||
let parent = annots[index].as_ref().unwrap();
|
||||
doc.update_dict(parent, |d| {
|
||||
d.remove(b"NM");
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(threads(&doc)[0].0, None, "{:?}", threads(&doc));
|
||||
// A comment written straight into the page array already carries the name the parent would
|
||||
// get: the parent's export name steers clear of it.
|
||||
let mut direct = Dict::new();
|
||||
direct.set(b"Type".to_vec(), Object::name("Annot"));
|
||||
direct.set(b"Subtype".to_vec(), Object::name("Square"));
|
||||
direct.set(b"Rect".to_vec(), arr(&[100.0, 100.0, 150.0, 140.0]));
|
||||
direct.set(b"NM".to_vec(), PdfString::text(&format!("pdfcraft-0-{index}")));
|
||||
let page = pdfcraft_model::pages(&doc)[0].obj;
|
||||
doc.update_dict(page, |p| {
|
||||
if let Some(Object::Array(annots)) = p.get_mut(b"Annots") {
|
||||
annots.push(Object::Dict(direct));
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
let xfdf = export_xfdf_comments(&doc, &[(0, index)], "x.pdf");
|
||||
let given = format!("pdfcraft-0-{index}x");
|
||||
assert!(xfdf.contains(&format!("name=\"{given}\"")) && xfdf.contains(&format!("inreplyto=\"{given}\"")), "{xfdf}");
|
||||
let mut dst = blank();
|
||||
import_comments(&mut dst, &xfdf, Some(0), Some("p"), None).unwrap();
|
||||
let copies = threads(&dst);
|
||||
assert_eq!(copies.len(), 2, "{copies:?}");
|
||||
let parent_name = copies.iter().find(|(_, irt, _)| irt.is_none()).and_then(|(n, _, _)| n.clone());
|
||||
assert!(parent_name.is_some(), "{copies:?}");
|
||||
let reply_to = copies.iter().find(|(_, irt, _)| irt.is_some()).and_then(|(_, irt, _)| irt.clone());
|
||||
assert_eq!(reply_to, parent_name, "the reply follows its copied parent: {copies:?}");
|
||||
}
|
||||
|
||||
/// A paste placed elsewhere: every copy moves by the offset, rect, quads, ink and vertices
|
||||
/// alike; the bounds and count of a clipboard text say what hangs on the pointer.
|
||||
#[test]
|
||||
fn copies_placed_with_an_offset_move_whole_and_the_text_reports_its_bounds() {
|
||||
let src = filled();
|
||||
let all: Vec<(usize, usize)> = summaries(&src).iter().filter(|s| s.in_reply_to.is_none()).map(|s| (s.page, s.index)).collect();
|
||||
let xfdf = export_xfdf_comments(&src, &all, "form.pdf");
|
||||
assert_eq!(comment_count(&xfdf), 3, "note, highlight, ink (the reply is not counted)");
|
||||
let bounds = comments_bounds(&xfdf).unwrap();
|
||||
assert!(bounds[0] <= 10.0 && bounds[1] <= 10.0 && bounds[2] >= 100.0 && bounds[3] >= 500.0, "{bounds:?}");
|
||||
assert_eq!(comment_count("just text"), 0);
|
||||
assert_eq!(comments_bounds("<xfdf><annots></annots></xfdf>"), None);
|
||||
// A reply with a rect of its own (some tools write one, at 0,0 or away from its parent)
|
||||
// doesn't widen the box the paste is centred on: it has no place on the page.
|
||||
let with_reply = r#"<xfdf xmlns="http://ns.adobe.com/xfdf/"><annots>
|
||||
<square page="0" name="a" rect="100,100,140,120"/>
|
||||
<text page="0" name="r" inreplyto="a" rect="500,600,520,620"><contents>re</contents></text>
|
||||
</annots></xfdf>"#;
|
||||
assert_eq!(comments_bounds(with_reply), Some([100.0, 100.0, 140.0, 120.0]));
|
||||
let mut dst = blank();
|
||||
import_comments(&mut dst, &xfdf, Some(0), Some("p"), Some([100.0, -50.0])).unwrap();
|
||||
let moved = summaries(&dst);
|
||||
let note = moved.iter().find(|s| s.subtype == "Text").unwrap();
|
||||
let original = summaries(&src).into_iter().find(|s| s.subtype == "Text").unwrap();
|
||||
let (a, b) = (note.rect, original.rect);
|
||||
assert!((a[0] - b[0] - 100.0).abs() < 0.01 && (a[1] - b[1] + 50.0).abs() < 0.01, "{a:?} from {b:?}");
|
||||
let again = export_xfdf(&dst, true, false, "dst.pdf");
|
||||
assert!(again.contains("coords=\"110,60,190,60,110,50,190,50\""), "quads moved too: {again}");
|
||||
assert!(again.contains("<gesture>110,-40;120,-20;140,-30</gesture>"), "ink moved too: {again}");
|
||||
// The outlines say what hangs on the pointer: a box for the note, quads for the highlight,
|
||||
// strokes for the ink, each in its colour; the reply draws nothing of its own.
|
||||
let outlines = comment_outlines(&xfdf);
|
||||
assert_eq!(outlines.len(), 3, "{outlines:?}");
|
||||
assert!(outlines.iter().any(|o| matches!(o, Outline::Rect { round: false, .. })));
|
||||
assert!(outlines.iter().any(|o| matches!(o, Outline::Quads { quads, .. } if quads.len() == 1 && quads[0][0] == 10.0)));
|
||||
assert!(outlines.iter().any(|o| matches!(o, Outline::Ink { strokes, .. } if strokes == &vec![vec![[10.0, 10.0], [20.0, 30.0], [40.0, 20.0]]])));
|
||||
assert!(comment_outlines("no xfdf").is_empty());
|
||||
let drawn = "<?xml version=\"1.0\"?><xfdf xmlns=\"http://ns.adobe.com/xfdf/\"><annots>\
|
||||
<line page=\"0\" rect=\"0,0,50,50\" start=\"1,2\" end=\"3,4\" color=\"#FF0000\"/>\
|
||||
<polyline page=\"0\" rect=\"0,0,9,9\"><vertices>1,1;5,5;9,1</vertices></polyline>\
|
||||
<polygon page=\"0\" rect=\"0,0,9,9\"><vertices>1,1;5,5;9,1</vertices></polygon>\
|
||||
<circle page=\"0\" rect=\"10,10,30,20\"/>\
|
||||
<freetext page=\"0\" rect=\"40,40,80,60\" callout=\"20,20,40,50\"/>\
|
||||
<text page=\"0\" rect=\"1,1,2,2\" inreplyto=\"x\"/></annots></xfdf>";
|
||||
let outlines = comment_outlines(drawn);
|
||||
assert_eq!(outlines.len(), 6, "{outlines:?}");
|
||||
assert_eq!(outlines[0], Outline::Line { points: vec![[1.0, 2.0], [3.0, 4.0]], color: Some([1.0, 0.0, 0.0]) });
|
||||
assert!(matches!(&outlines[1], Outline::Poly { closed: false, points, .. } if points.len() == 3));
|
||||
assert!(matches!(&outlines[2], Outline::Poly { closed: true, .. }));
|
||||
assert!(matches!(&outlines[3], Outline::Rect { round: true, .. }));
|
||||
assert!(matches!(&outlines[5], Outline::Line { points, .. } if points == &vec![[20.0, 20.0], [40.0, 50.0]]));
|
||||
// A non-finite offset is ignored rather than written into the document.
|
||||
import_comments(&mut dst, &xfdf, Some(1), Some("q"), Some([f64::NAN, 0.0])).unwrap();
|
||||
assert_eq!(summaries(&dst).iter().filter(|s| s.page == 1 && s.subtype == "Text").map(|s| s.rect[0]).next(), Some(b[0]));
|
||||
}
|
||||
|
||||
/// Fill & Sign marks and text boxes copied: the bundle on the pointer shows the tick, cross, dot
|
||||
/// and line themselves (not empty boxes) and a text box's text, and the pasted copies are drawn
|
||||
/// with their mark.
|
||||
#[test]
|
||||
fn copied_marks_and_text_boxes_show_what_they_are_while_placed_and_once_pasted() {
|
||||
use pdfcraft_annot::FillMark;
|
||||
let mut src = blank();
|
||||
let meta = Meta { date: Some("D:20261020120000Z".into()), id: "m".into() };
|
||||
let marks = [FillMark::Check, FillMark::Cross, FillMark::Dot, FillMark::Line];
|
||||
for (k, mark) in marks.into_iter().enumerate() {
|
||||
let shape = Shape::Mark { rect: [100.0 + 40.0 * k as f64, 600.0, 120.0 + 40.0 * k as f64, 620.0], mark };
|
||||
let new = NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: String::new(), author: "Ada".into() };
|
||||
add_annotation(&mut src, &new, &meta).unwrap();
|
||||
}
|
||||
let text = Shape::TextBox { rect: [100.0, 400.0, 200.0, 430.0], font_size: 14.0 };
|
||||
let new = NewAnnotation { page: 0, style: Style::default_for(&text), shape: text, contents: "Abnahme\nok".into(), author: "Ada".into() };
|
||||
add_annotation(&mut src, &new, &meta).unwrap();
|
||||
let all: Vec<(usize, usize)> = summaries(&src).iter().filter(|s| s.in_reply_to.is_none()).map(|s| (s.page, s.index)).collect();
|
||||
let xfdf = export_xfdf_comments(&src, &all, "protocol.pdf");
|
||||
let outlines = comment_outlines(&xfdf);
|
||||
// The tick is one stroke, the cross two, the dot a circle, the line one stroke: the strokes
|
||||
// the appearance draws, not the stamps' boxes.
|
||||
let check = FillMark::Check.strokes([100.0, 600.0, 120.0, 620.0]);
|
||||
assert!(outlines.iter().any(|o| matches!(o, Outline::Line { points, .. } if *points == check[0])), "{outlines:?}");
|
||||
assert!(!outlines.iter().any(|o| matches!(o, Outline::Rect { rect, round: false, .. } if *rect == [100.0, 600.0, 120.0, 620.0])), "{outlines:?}");
|
||||
let cross = FillMark::Cross.strokes([140.0, 600.0, 160.0, 620.0]);
|
||||
assert!(cross.iter().all(|s| outlines.iter().any(|o| matches!(o, Outline::Line { points, .. } if points == s))), "{outlines:?}");
|
||||
assert!(
|
||||
outlines
|
||||
.iter()
|
||||
.any(|o| matches!(o, Outline::Rect { rect, round: true, .. } if (rect[0] - 184.0).abs() < 1e-9 && (rect[2] - 196.0).abs() < 1e-9)),
|
||||
"{outlines:?}"
|
||||
);
|
||||
assert!(outlines.iter().any(|o| matches!(o, Outline::Line { points, .. } if *points == vec![[220.0, 610.0], [240.0, 610.0]])), "{outlines:?}");
|
||||
assert!(
|
||||
outlines.iter().any(|o| matches!(o, Outline::Text { text, size, .. } if text == "Abnahme\nok" && (*size - 14.0).abs() < 1e-9)),
|
||||
"the text box shows its text at its size: {outlines:?}"
|
||||
);
|
||||
// Pasted into another document, each mark is drawn with its own strokes.
|
||||
let mut dst = blank();
|
||||
import_comments(&mut dst, &xfdf, Some(1), Some("p"), Some([10.0, -20.0])).unwrap();
|
||||
let page = pdfcraft_model::pages(&dst)[1].obj;
|
||||
let annots =
|
||||
dst.get(page).as_dict().and_then(|p| p.get(b"Annots").cloned()).map(|a| dst.resolve(&a).as_array().cloned().unwrap_or_default()).unwrap();
|
||||
let stamps: Vec<Dict> =
|
||||
annots.iter().filter_map(|a| dst.resolve(a).as_dict().cloned()).filter(|d| d.name(b"Subtype") == Some(b"Stamp".as_slice())).collect();
|
||||
assert_eq!(stamps.len(), 4);
|
||||
for d in &stamps {
|
||||
let ap = d.get(b"AP").map(|a| dst.resolve(a)).and_then(|a| a.as_dict().cloned()).and_then(|a| a.get(b"N").cloned()).expect("an appearance");
|
||||
let normal = dst.resolve(&ap);
|
||||
let Object::Stream(s) = normal.as_ref() else { panic!("no appearance stream") };
|
||||
let content = String::from_utf8_lossy(&s.decoded().unwrap()).into_owned();
|
||||
let drawn =
|
||||
if d.name(b"Name") == Some(b"PCDot".as_slice()) { content.contains(" c\n") || content.contains(" c ") } else { content.contains(" S\n") };
|
||||
assert!(drawn, "{:?}: {content}", d.name(b"Name"));
|
||||
}
|
||||
}
|
||||
|
||||
/// A pasted copy is what the original was: typewriter text stays typewriter text (`/IT`) and a
|
||||
/// Fill & Sign item, a cloud keeps its cloudy border, and PdfCraft's own keys (a turned comment's
|
||||
/// `/PCBox` and `/PCAngle`) come along. A reference into the source document stays behind, and
|
||||
/// keys that aren't PdfCraft's, or a malformed attribute, are not taken from the attribute.
|
||||
#[test]
|
||||
fn copies_keep_their_intent_cloud_and_pdfcraft_keys() {
|
||||
let mut src = blank();
|
||||
let meta = |id: &str| Meta { date: Some("D:20261023120000Z".into()), id: id.into() };
|
||||
let typed = Shape::Typewriter { rect: [50.0, 500.0, 150.0, 520.0], font_size: 12.0 };
|
||||
let cloud = Shape::Polygon { vertices: vec![[200.0, 200.0], [300.0, 200.0], [250.0, 280.0]], cloud: true };
|
||||
let square = Shape::Rectangle { rect: [100.0, 100.0, 180.0, 160.0] };
|
||||
for (shape, id) in [(typed, "t1"), (cloud, "c1"), (square, "s1")] {
|
||||
let new = NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: format!("{id} text"), author: "Ada".into() };
|
||||
add_annotation(&mut src, &new, &meta(id)).unwrap();
|
||||
}
|
||||
let annots = |doc: &Document, page: usize| -> Vec<Dict> {
|
||||
let pages = pdfcraft_model::pages(doc);
|
||||
let list = pages[page].dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default();
|
||||
list.iter().filter_map(|a| doc.resolve(a).as_dict().cloned()).filter(|d| !matches!(d.name(b"Subtype"), Some(b"Popup" | b"Widget"))).collect()
|
||||
};
|
||||
let refs: Vec<ObjRef> = {
|
||||
let pages = pdfcraft_model::pages(&src);
|
||||
let list = pages[0].dict.get(b"Annots").and_then(|a| src.resolve(a).as_array().cloned()).unwrap();
|
||||
list.iter().filter_map(Object::as_ref).collect()
|
||||
};
|
||||
let target = src.add(Object::Dict(Dict::new()));
|
||||
let square_ref = *refs.iter().find(|r| src.get(**r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Square".as_slice()))).unwrap();
|
||||
src.update_dict(square_ref, |d| {
|
||||
d.set(b"PCBox".to_vec(), arr(&[110.0, 110.0, 170.0, 150.0]));
|
||||
d.set(b"PCAngle".to_vec(), Object::Real(30.0));
|
||||
d.set(b"PCElsewhere".to_vec(), Object::Ref(target));
|
||||
})
|
||||
.unwrap();
|
||||
let which: Vec<(usize, usize)> = summaries(&src).iter().map(|c| (c.page, c.index)).collect();
|
||||
let xfdf = export_xfdf_comments(&src, &which, "src.pdf");
|
||||
assert!(xfdf.contains("intent=\"FreeTextTypeWriter\"") && xfdf.contains("style=\"cloudy\""), "{xfdf}");
|
||||
let mut dst = blank();
|
||||
import_comments(&mut dst, &xfdf, Some(1), Some("p"), Some([5.0, -5.0])).unwrap();
|
||||
let copies = annots(&dst, 1);
|
||||
assert_eq!(copies.len(), 3, "{copies:?}");
|
||||
let typed = copies.iter().find(|d| d.name(b"Subtype") == Some(b"FreeText".as_slice())).unwrap();
|
||||
assert_eq!(typed.name(b"IT"), Some(b"FreeTextTypeWriter".as_slice()), "{typed:?}");
|
||||
assert!(pdfcraft_annot::is_fill_sign(&dst, typed), "still a Fill & Sign item: {typed:?}");
|
||||
assert!(matches!(typed.get(b"PCFillSign"), Some(Object::Bool(true))), "{typed:?}");
|
||||
let cloud = copies.iter().find(|d| d.name(b"Subtype") == Some(b"Polygon".as_slice())).unwrap();
|
||||
assert_eq!(cloud.name(b"IT"), Some(b"PolygonCloud".as_slice()));
|
||||
let be = cloud.get(b"BE").and_then(Object::as_dict).expect("a cloudy border");
|
||||
assert_eq!(be.name(b"S"), Some(b"C".as_slice()));
|
||||
assert_eq!(be.get(b"I").and_then(Object::as_f64), Some(1.0));
|
||||
let square = copies.iter().find(|d| d.name(b"Subtype") == Some(b"Square".as_slice())).unwrap();
|
||||
assert_eq!(square.get(b"PCAngle").and_then(Object::as_f64), Some(30.0));
|
||||
assert!(square.get(b"PCBox").and_then(Object::as_array).is_some_and(|b| b.len() == 4));
|
||||
assert!(square.get(b"PCElsewhere").is_none(), "a reference into the source stays behind: {square:?}");
|
||||
// File ▸ Export comments writes the standard attributes only.
|
||||
let exported = export_xfdf(&src, true, false, "src.pdf");
|
||||
assert!(exported.contains("intent=\"FreeTextTypeWriter\"") && !exported.contains("pdfcraft:keys"), "{exported}");
|
||||
// A hand-made attribute: only PdfCraft keys with values of their own are taken.
|
||||
let crafted = format!(
|
||||
"<xfdf xmlns=\"http://ns.adobe.com/xfdf/\" xmlns:pdfcraft=\"{PDFCRAFT_NS}\"><annots>\
|
||||
<square page=\"0\" rect=\"10,10,50,50\" pdfcraft:keys=\"<< /PCAngle 45 /Subtype /Ink /PCRef 3 0 R >>\"/>\
|
||||
<square page=\"0\" rect=\"60,10,90,50\" pdfcraft:keys=\"<< /PCAngle\"/></annots></xfdf>"
|
||||
);
|
||||
let mut third = blank();
|
||||
import_comments(&mut third, &crafted, Some(0), Some("q"), None).unwrap();
|
||||
let got = annots(&third, 0);
|
||||
assert_eq!(got.len(), 2, "{got:?}");
|
||||
assert_eq!(got[0].get(b"PCAngle").and_then(Object::as_f64), Some(45.0));
|
||||
assert_eq!(got[0].name(b"Subtype"), Some(b"Square".as_slice()), "not taken from the attribute");
|
||||
assert!(got[0].get(b"PCRef").is_none() && got[1].get(b"PCAngle").is_none(), "{got:?}");
|
||||
}
|
||||
+507
-61
@@ -6,22 +6,188 @@ use super::*;
|
||||
|
||||
// ── export ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Every comment as an XFDF element (one line per comment, replies included).
|
||||
fn xfdf_annots(doc: &Document, out: &mut String) {
|
||||
/// The annotations of `which` (0-based page, position in the page's `/Annots`) as positions,
|
||||
/// with every reply to them (and to those replies), so a thread travels whole. Positions, not
|
||||
/// references: other tools write comments straight into the page's array, without an object of
|
||||
/// their own, and those copy too.
|
||||
fn annots_with_replies(doc: &Document, which: &[(usize, usize)]) -> HashSet<(usize, usize)> {
|
||||
let pages = pdfcraft_model::pages(doc);
|
||||
// NM of every annotation, for inreplyto.
|
||||
let annots = |p: &pdfcraft_model::Page| -> Vec<Object> {
|
||||
p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default()
|
||||
};
|
||||
/// An annotation's position, its reference (when it has one) and its parent's (`/IRT`).
|
||||
struct Entry {
|
||||
pos: (usize, usize),
|
||||
obj: Option<ObjRef>,
|
||||
parent: Option<ObjRef>,
|
||||
}
|
||||
let all: Vec<Entry> = pages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(pi, p)| {
|
||||
annots(p).into_iter().enumerate().map(move |(ai, a)| {
|
||||
let parent = doc.resolve(&a).as_dict().and_then(|d| d.get(b"IRT")).and_then(Object::as_ref);
|
||||
Entry { pos: (pi, ai), obj: a.as_ref(), parent }
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut set: HashSet<(usize, usize)> = which.iter().copied().filter(|w| all.iter().any(|e| e.pos == *w)).collect();
|
||||
// Replies point at their parent through /IRT: a pass per reply depth, bounded by the count.
|
||||
for _ in 0..all.len() {
|
||||
let before = set.len();
|
||||
let parents: HashSet<ObjRef> = all.iter().filter(|e| set.contains(&e.pos)).filter_map(|e| e.obj).collect();
|
||||
for e in &all {
|
||||
if e.parent.is_some_and(|parent| parents.contains(&parent)) {
|
||||
set.insert(e.pos);
|
||||
}
|
||||
}
|
||||
if set.len() == before {
|
||||
break;
|
||||
}
|
||||
}
|
||||
set
|
||||
}
|
||||
|
||||
/// Every comment as an XFDF element (one line per comment, replies included), or only those at
|
||||
/// the positions in `only`.
|
||||
/// The namespace of PdfCraft's own attributes in XFDF (`pdfcraft:keys`): other readers skip them.
|
||||
pub(crate) const PDFCRAFT_NS: &str = "https://github.com/storytold/pdfcraft#xfdf";
|
||||
/// At most this much text in a `pdfcraft:keys` attribute is read.
|
||||
const MAX_OWN_KEYS: usize = 64 * 1024;
|
||||
|
||||
/// A value that means the same in another document: no reference or stream into this one.
|
||||
fn portable(o: &Object, depth: usize) -> bool {
|
||||
match o {
|
||||
Object::Ref(_) | Object::Stream(_) => false,
|
||||
Object::Array(a) => depth < 4 && a.iter().all(|o| portable(o, depth + 1)),
|
||||
Object::Dict(d) => depth < 4 && d.iter().all(|(_, o)| portable(o, depth + 1)),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The annotation's own PdfCraft keys (`/PC…`: a Fill & Sign item, a turned comment's box and
|
||||
/// angle, …) as one PDF dictionary, for a copy that is what the original was. Values that point
|
||||
/// into this document stay behind.
|
||||
fn own_keys(d: &Dict) -> Option<String> {
|
||||
let mut keys = Dict::new();
|
||||
// Each value must also write as text: XML can't hold raw bytes.
|
||||
let writes_as_text = |v: &Object| {
|
||||
let mut out = Vec::new();
|
||||
pdfcraft_cos::serialize(v, &mut out);
|
||||
std::str::from_utf8(&out).is_ok()
|
||||
};
|
||||
for (k, v) in d.iter().filter(|(k, v)| k.starts_with(b"PC") && portable(v, 0) && writes_as_text(v)) {
|
||||
keys.set(k.clone(), v.clone());
|
||||
}
|
||||
if keys.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
pdfcraft_cos::serialize(&Object::Dict(keys), &mut out);
|
||||
String::from_utf8(out).ok()
|
||||
}
|
||||
|
||||
/// The `pdfcraft:keys` of an XFDF comment back as dictionary entries: PdfCraft's keys only, with
|
||||
/// values that mean the same here. Anything malformed is skipped.
|
||||
fn read_own_keys(node: roxmltree::Node, d: &mut Dict) {
|
||||
let Some(text) = node.attribute((PDFCRAFT_NS, "keys")).filter(|t| t.len() <= MAX_OWN_KEYS) else { return };
|
||||
let Ok(Object::Dict(keys)) = pdfcraft_cos::Lexer::new(text.as_bytes(), 0).object() else { return };
|
||||
for (k, v) in keys.iter().filter(|(k, v)| k.starts_with(b"PC") && portable(v, 0)) {
|
||||
if !d.contains(k) {
|
||||
d.set(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The name of every annotation, for `inreplyto`: its /NM, or, for one in `only` without one
|
||||
/// (other tools leave /NM out), a name new to the document for this export, so a reply copied
|
||||
/// with it still finds it.
|
||||
fn annotation_names(doc: &Document, pages: &[pdfcraft_model::Page], only: Option<&HashSet<(usize, usize)>>) -> HashMap<ObjRef, String> {
|
||||
let mut names: HashMap<ObjRef, String> = HashMap::new();
|
||||
for p in &pages {
|
||||
for a in p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() {
|
||||
if let Some(r) = a.as_ref()
|
||||
&& let Some(nm) = doc.get(r).as_dict().and_then(|d| text_of(doc, d, b"NM"))
|
||||
{
|
||||
names.insert(r, nm);
|
||||
let mut unnamed: Vec<(usize, usize, ObjRef)> = Vec::new();
|
||||
// Every name in use, those of annotations written straight into the page array too, so a
|
||||
// given name is new.
|
||||
let mut taken: HashSet<String> = HashSet::new();
|
||||
for (pi, p) in pages.iter().enumerate() {
|
||||
for (ai, a) in p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default().into_iter().enumerate() {
|
||||
let nm = doc.resolve(&a).as_dict().and_then(|d| text_of(doc, d, b"NM"));
|
||||
if let Some(nm) = &nm {
|
||||
taken.insert(nm.clone());
|
||||
}
|
||||
let Some(r) = a.as_ref() else { continue };
|
||||
match nm {
|
||||
Some(nm) => {
|
||||
names.insert(r, nm);
|
||||
}
|
||||
None if only.is_some_and(|only| only.contains(&(pi, ai))) => unnamed.push((pi, ai, r)),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (pi, ai, r) in unnamed {
|
||||
let mut name = format!("pdfcraft-{pi}-{ai}");
|
||||
while taken.contains(&name) {
|
||||
name.push('x');
|
||||
}
|
||||
taken.insert(name.clone());
|
||||
names.insert(r, name);
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// The elements inside a comment's XFDF element: its text, default appearance, ink, vertices
|
||||
/// and pop-up (on page `pi`).
|
||||
fn xfdf_annot_children(doc: &Document, out: &mut String, d: &Dict, pi: usize) {
|
||||
if let Some(c) = text_of(doc, d, b"Contents") {
|
||||
let _ = write!(out, "<contents>{}</contents>", esc(&c));
|
||||
}
|
||||
if let Some(da) = text_of(doc, d, b"DA") {
|
||||
let _ = write!(out, "<defaultappearance>{}</defaultappearance>", esc(&da));
|
||||
}
|
||||
let ink = d.get(b"InkList").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default();
|
||||
if !ink.is_empty() {
|
||||
out.push_str("<inklist>");
|
||||
for g in ink {
|
||||
let v = nums_of(doc, Some(&g));
|
||||
let pts: Vec<String> = v.as_chunks::<2>().0.iter().map(|p| format!("{},{}", n(p[0]), n(p[1]))).collect();
|
||||
let _ = write!(out, "<gesture>{}</gesture>", pts.join(";"));
|
||||
}
|
||||
out.push_str("</inklist>");
|
||||
}
|
||||
// Polygon and polyline points (XFDF `<vertices>`: `x,y;x,y;…`).
|
||||
let vertices = nums_of(doc, d.get(b"Vertices"));
|
||||
let pts: Vec<String> = vertices.as_chunks::<2>().0.iter().map(|p| format!("{},{}", n(p[0]), n(p[1]))).collect();
|
||||
if !pts.is_empty() {
|
||||
let _ = write!(out, "<vertices>{}</vertices>", pts.join(";"));
|
||||
}
|
||||
if let Some(pd) = d.get(b"Popup").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().cloned()) {
|
||||
let r = nums_of(doc, pd.get(b"Rect"));
|
||||
if r.len() == 4 {
|
||||
let open = matches!(pd.get(b"Open"), Some(Object::Bool(true)));
|
||||
let _ = write!(
|
||||
out,
|
||||
"<popup page=\"{pi}\" rect=\"{},{},{},{}\" open=\"{}\"/>",
|
||||
n(r[0]),
|
||||
n(r[1]),
|
||||
n(r[2]),
|
||||
n(r[3]),
|
||||
if open { "yes" } else { "no" }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `own`: write each comment's PdfCraft keys too (see [`own_keys`]), for a copy and paste.
|
||||
fn xfdf_annots(doc: &Document, out: &mut String, only: Option<&HashSet<(usize, usize)>>, own: bool) {
|
||||
let pages = pdfcraft_model::pages(doc);
|
||||
// NM of every annotation, for inreplyto. A selected annotation without one (other tools
|
||||
// leave /NM out) gets a name for this export, so a reply copied with it still finds it.
|
||||
let names = annotation_names(doc, &pages, only);
|
||||
for (pi, p) in pages.iter().enumerate() {
|
||||
for a in p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() {
|
||||
for (ai, a) in p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default().into_iter().enumerate() {
|
||||
if only.is_some_and(|only| !only.contains(&(pi, ai))) {
|
||||
continue;
|
||||
}
|
||||
let Some(d) = doc.resolve(&a).as_dict().cloned() else { continue };
|
||||
let Some(sub) = d.name(b"Subtype") else { continue };
|
||||
let Some((_, tag)) = SUBTYPES.iter().find(|(s, _)| s.as_bytes() == sub) else { continue };
|
||||
@@ -37,9 +203,12 @@ fn xfdf_annots(doc: &Document, out: &mut String) {
|
||||
n(rect[1].max(rect[3]))
|
||||
);
|
||||
}
|
||||
// The name: /NM, or the one given above for this export.
|
||||
if let Some(nm) = text_of(doc, &d, b"NM").or_else(|| a.as_ref().and_then(|r| names.get(&r).cloned())) {
|
||||
let _ = write!(attrs, " name=\"{}\"", esc(&nm));
|
||||
}
|
||||
for (key, attr) in [
|
||||
(&b"NM"[..], "name"),
|
||||
(b"T", "title"),
|
||||
(&b"T"[..], "title"),
|
||||
(b"Subj", "subject"),
|
||||
(b"M", "date"),
|
||||
(b"CreationDate", "creationdate"),
|
||||
@@ -81,7 +250,7 @@ fn xfdf_annots(doc: &Document, out: &mut String) {
|
||||
let name = |o: &Object| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned()).unwrap_or_else(|| "None".into());
|
||||
let _ = write!(attrs, " head=\"{}\" tail=\"{}\"", esc(&name(&le[0])), esc(&name(&le[1])));
|
||||
}
|
||||
// A callout: its leader path, text-box inset, intent and (single-name) line ending.
|
||||
// A callout: its leader path, text-box inset and (single-name) line ending.
|
||||
let cl = nums_of(doc, d.get(b"CL"));
|
||||
if cl.len() == 4 || cl.len() == 6 {
|
||||
let _ = write!(attrs, " callout=\"{}\"", cl.iter().map(|v| n(*v)).collect::<Vec<_>>().join(","));
|
||||
@@ -89,54 +258,31 @@ fn xfdf_annots(doc: &Document, out: &mut String) {
|
||||
if rd.len() == 4 {
|
||||
let _ = write!(attrs, " fringe=\"{}\"", rd.iter().map(|v| n(*v)).collect::<Vec<_>>().join(","));
|
||||
}
|
||||
if let Some(it) = d.get(b"IT").map(|o| doc.resolve(o)).and_then(|o| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned())) {
|
||||
let _ = write!(attrs, " intent=\"{}\"", esc(&it));
|
||||
}
|
||||
if let Some(le) = d.get(b"LE").map(|o| doc.resolve(o)).and_then(|o| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned())) {
|
||||
let _ = write!(attrs, " head=\"{}\"", esc(&le));
|
||||
}
|
||||
}
|
||||
// What the comment is meant as (/IT): typewriter text, a callout, a cloud, an arrow.
|
||||
if let Some(it) = d.get(b"IT").map(|o| doc.resolve(o)).and_then(|o| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned())) {
|
||||
let _ = write!(attrs, " intent=\"{}\"", esc(&it));
|
||||
}
|
||||
// A cloudy border (/BE /S /C) and how far its arcs bulge.
|
||||
if let Some(be) =
|
||||
d.get(b"BE").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).filter(|b| b.name(b"S") == Some(b"C".as_slice()))
|
||||
{
|
||||
attrs.push_str(" style=\"cloudy\"");
|
||||
if let Some(i) = be.get(b"I").and_then(Object::as_f64) {
|
||||
let _ = write!(attrs, " intensity=\"{}\"", n(i));
|
||||
}
|
||||
}
|
||||
if let Some(irt) = d.get(b"IRT").and_then(Object::as_ref).and_then(|r| names.get(&r)) {
|
||||
let _ = write!(attrs, " inreplyto=\"{}\"", esc(irt));
|
||||
}
|
||||
if own && let Some(keys) = own_keys(&d) {
|
||||
let _ = write!(attrs, " pdfcraft:keys=\"{}\"", esc(&keys));
|
||||
}
|
||||
let _ = write!(out, "<{tag}{attrs}>");
|
||||
if let Some(c) = text_of(doc, &d, b"Contents") {
|
||||
let _ = write!(out, "<contents>{}</contents>", esc(&c));
|
||||
}
|
||||
if let Some(da) = text_of(doc, &d, b"DA") {
|
||||
let _ = write!(out, "<defaultappearance>{}</defaultappearance>", esc(&da));
|
||||
}
|
||||
let ink = d.get(b"InkList").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default();
|
||||
if !ink.is_empty() {
|
||||
out.push_str("<inklist>");
|
||||
for g in ink {
|
||||
let v = nums_of(doc, Some(&g));
|
||||
let pts: Vec<String> = v.as_chunks::<2>().0.iter().map(|p| format!("{},{}", n(p[0]), n(p[1]))).collect();
|
||||
let _ = write!(out, "<gesture>{}</gesture>", pts.join(";"));
|
||||
}
|
||||
out.push_str("</inklist>");
|
||||
}
|
||||
// Polygon and polyline points (XFDF `<vertices>`: `x,y;x,y;…`).
|
||||
let vertices = nums_of(doc, d.get(b"Vertices"));
|
||||
let pts: Vec<String> = vertices.as_chunks::<2>().0.iter().map(|p| format!("{},{}", n(p[0]), n(p[1]))).collect();
|
||||
if !pts.is_empty() {
|
||||
let _ = write!(out, "<vertices>{}</vertices>", pts.join(";"));
|
||||
}
|
||||
if let Some(pd) = d.get(b"Popup").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().cloned()) {
|
||||
let r = nums_of(doc, pd.get(b"Rect"));
|
||||
if r.len() == 4 {
|
||||
let open = matches!(pd.get(b"Open"), Some(Object::Bool(true)));
|
||||
let _ = write!(
|
||||
out,
|
||||
"<popup page=\"{pi}\" rect=\"{},{},{},{}\" open=\"{}\"/>",
|
||||
n(r[0]),
|
||||
n(r[1]),
|
||||
n(r[2]),
|
||||
n(r[3]),
|
||||
if open { "yes" } else { "no" }
|
||||
);
|
||||
}
|
||||
}
|
||||
xfdf_annot_children(doc, out, &d, pi);
|
||||
let _ = writeln!(out, "</{tag}>");
|
||||
}
|
||||
}
|
||||
@@ -191,12 +337,235 @@ fn xfdf_fields(doc: &Document, out: &mut String) {
|
||||
}
|
||||
}
|
||||
|
||||
/// XFDF with these comments alone (0-based page and position in the page's `/Annots`), each
|
||||
/// with its replies: what Copy puts on the clipboard, and Export selected comments writes.
|
||||
pub fn export_xfdf_comments(doc: &Document, which: &[(usize, usize)], file: &str) -> String {
|
||||
let only = annots_with_replies(doc, which);
|
||||
let mut out = format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<xfdf xmlns=\"http://ns.adobe.com/xfdf/\" xmlns:pdfcraft=\"{PDFCRAFT_NS}\" xml:space=\"preserve\">\n"
|
||||
);
|
||||
out.push_str("<annots>\n");
|
||||
xfdf_annots(doc, &mut out, Some(&only), true);
|
||||
out.push_str("</annots>\n");
|
||||
let _ = writeln!(out, "<f href=\"{}\"/>", esc(file));
|
||||
out.push_str("</xfdf>\n");
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `text` is XFDF with comments in it (what a paste of copied comments carries).
|
||||
pub fn is_xfdf_comments(text: &str) -> bool {
|
||||
let head: String = text.chars().take(400).collect();
|
||||
head.contains("<xfdf") && text.contains("<annots")
|
||||
}
|
||||
|
||||
/// What a comment of an XFDF text looks like, for drawing a bundle of copied comments before
|
||||
/// it is placed: its geometry in user space and its colour (RGB 0–1).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Outline {
|
||||
/// A box: notes, squares, text boxes, stamps, attachments, carets (`round` for a circle).
|
||||
Rect { rect: [f64; 4], round: bool, color: Option<[f64; 3]> },
|
||||
/// A line or arrow, and a callout's leader.
|
||||
Line { points: Vec<[f64; 2]>, color: Option<[f64; 3]> },
|
||||
/// A polygon (closed) or connected lines.
|
||||
Poly { points: Vec<[f64; 2]>, closed: bool, color: Option<[f64; 3]> },
|
||||
/// Freehand strokes.
|
||||
Ink { strokes: Vec<Vec<[f64; 2]>>, color: Option<[f64; 3]> },
|
||||
/// Text markup: each quad is x1,y1 (upper left), x2,y2 (upper right), x3,y3 (lower left), x4,y4.
|
||||
Quads { quads: Vec<[f64; 8]>, color: Option<[f64; 3]> },
|
||||
/// A text box's text, from the top-left of its box (`rect`, which it wraps in), at its font
|
||||
/// `size` in points and in its text colour.
|
||||
Text { rect: [f64; 4], text: String, size: f64, color: Option<[f64; 3]> },
|
||||
}
|
||||
|
||||
/// Every comment element of an XFDF text, in order (none when it is no XFDF).
|
||||
fn with_comment_nodes(text: &str, mut f: impl FnMut(roxmltree::Node<'_, '_>)) {
|
||||
if check_form_xml_depth(text).is_err() {
|
||||
return;
|
||||
}
|
||||
let Ok(xml) = parse_form_xml(text) else { return };
|
||||
let root = xml.root_element();
|
||||
if root.tag_name().name() != "xfdf" {
|
||||
return;
|
||||
}
|
||||
let Some(annots) = root.children().find(|c| c.has_tag_name("annots")) else { return };
|
||||
for node in annots.children().filter(|c| c.is_element()) {
|
||||
if SUBTYPES.iter().any(|(_, t)| *t == node.tag_name().name().to_ascii_lowercase()) {
|
||||
f(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The comment elements of an XFDF text: tag, `inreplyto`, rect.
|
||||
fn comment_nodes(text: &str) -> Vec<(String, Option<String>, Vec<f64>)> {
|
||||
let mut out = Vec::new();
|
||||
with_comment_nodes(text, |node| {
|
||||
out.push((
|
||||
node.tag_name().name().to_owned(),
|
||||
node.attribute("inreplyto").map(str::to_owned),
|
||||
node.attribute("rect").map(csv).unwrap_or_default(),
|
||||
));
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Pairs `x,y;x,y;…` (XFDF vertices and ink gestures).
|
||||
fn pairs(text: &str) -> Vec<[f64; 2]> {
|
||||
text.split(';').filter_map(|p| if let [x, y] = csv(p).as_slice() { Some([*x, *y]) } else { None }).collect()
|
||||
}
|
||||
|
||||
/// The outlines of the comments in an XFDF text (replies, which have no place of their own,
|
||||
/// left out): what a paste shows hanging on the pointer.
|
||||
pub fn comment_outlines(text: &str) -> Vec<Outline> {
|
||||
let mut out = Vec::new();
|
||||
with_comment_nodes(text, |node| {
|
||||
if node.attribute("inreplyto").is_some() {
|
||||
return;
|
||||
}
|
||||
let tag = node.tag_name().name().to_ascii_lowercase();
|
||||
let color = node.attribute("color").and_then(parse_hex);
|
||||
let rect = node
|
||||
.attribute("rect")
|
||||
.map(csv)
|
||||
.filter(|r| r.len() == 4 && r.iter().all(|v| v.is_finite()))
|
||||
.map(|r| [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]);
|
||||
match tag.as_str() {
|
||||
"line" => {
|
||||
if let (Some(a), Some(b)) = (node.attribute("start").map(csv), node.attribute("end").map(csv))
|
||||
&& let ([ax, ay], [bx, by]) = (a.as_slice(), b.as_slice())
|
||||
{
|
||||
out.push(Outline::Line { points: vec![[*ax, *ay], [*bx, *by]], color });
|
||||
}
|
||||
}
|
||||
"polygon" | "polyline" => {
|
||||
let points = node.children().find(|c| c.has_tag_name("vertices")).and_then(|v| v.text()).map(pairs).unwrap_or_default();
|
||||
if points.len() >= 2 {
|
||||
out.push(Outline::Poly { points, closed: tag == "polygon", color });
|
||||
}
|
||||
}
|
||||
"ink" => {
|
||||
let strokes: Vec<Vec<[f64; 2]>> = node
|
||||
.children()
|
||||
.find(|c| c.has_tag_name("inklist"))
|
||||
.map(|list| {
|
||||
list.children().filter(|g| g.has_tag_name("gesture")).filter_map(|g| g.text()).map(pairs).filter(|s| !s.is_empty()).collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if !strokes.is_empty() {
|
||||
out.push(Outline::Ink { strokes, color });
|
||||
}
|
||||
}
|
||||
"highlight" | "underline" | "strikeout" | "squiggly" | "redact" => {
|
||||
let coords = node.attribute("coords").map(csv).unwrap_or_default();
|
||||
let quads: Vec<[f64; 8]> = coords.as_chunks::<8>().0.to_vec();
|
||||
if quads.is_empty() {
|
||||
if let Some(rect) = rect {
|
||||
out.push(Outline::Rect { rect, round: false, color });
|
||||
}
|
||||
} else {
|
||||
out.push(Outline::Quads { quads, color });
|
||||
}
|
||||
}
|
||||
// A Fill & Sign mark: its tick, cross, dot or line, as its appearance draws it.
|
||||
"stamp"
|
||||
if let Some(rect) = rect
|
||||
&& let Some(mark) = node.attribute("icon").and_then(|i| pdfcraft_annot::FillMark::from_name(i.as_bytes())) =>
|
||||
{
|
||||
if mark == pdfcraft_annot::FillMark::Dot {
|
||||
let r = (rect[2] - rect[0]).min(rect[3] - rect[1]) * 0.3;
|
||||
let (cx, cy) = ((rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0);
|
||||
out.push(Outline::Rect { rect: [cx - r, cy - r, cx + r, cy + r], round: true, color });
|
||||
}
|
||||
for points in mark.strokes(rect) {
|
||||
out.push(Outline::Line { points, color });
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(rect) = rect {
|
||||
out.push(Outline::Rect { rect, round: tag == "circle", color });
|
||||
}
|
||||
// A callout's leader, from the box to what it points at.
|
||||
let callout = node.attribute("callout").map(csv).filter(|c| c.len() == 4 || c.len() == 6);
|
||||
if tag == "freetext"
|
||||
&& let Some(cl) = &callout
|
||||
{
|
||||
out.push(Outline::Line { points: cl.as_chunks::<2>().0.to_vec(), color });
|
||||
}
|
||||
// A text box's text, in its own colour and size, inside the box (a callout's box
|
||||
// is its rect inset by the fringe).
|
||||
if tag == "freetext"
|
||||
&& let Some(rect) = rect
|
||||
&& let Some(text) = node.children().find(|c| c.has_tag_name("contents")).and_then(|c| c.text()).filter(|t| !t.trim().is_empty())
|
||||
{
|
||||
let mut da = Dict::new();
|
||||
if let Some(s) = node.children().find(|c| c.has_tag_name("defaultappearance")).and_then(|c| c.text()) {
|
||||
da.set(b"DA".to_vec(), PdfString::literal(s.as_bytes().to_vec()));
|
||||
}
|
||||
let (text_color, size) = pdfcraft_annot::appearance::parse_da(&da);
|
||||
let fringe = node.attribute("fringe").map(csv).filter(|f| callout.is_some() && f.len() == 4 && f.iter().all(|v| v.is_finite()));
|
||||
let rect = match fringe.as_deref() {
|
||||
Some([l, b, r, t]) => [rect[0] + l, rect[1] + b, rect[2] - r, rect[3] - t],
|
||||
_ => rect,
|
||||
};
|
||||
out.push(Outline::Text { rect, text: text.replace("\r\n", "\n").replace('\r', "\n"), size, color: Some(text_color) });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// How many comments (replies not counted) an XFDF text holds.
|
||||
pub fn comment_count(text: &str) -> usize {
|
||||
comment_nodes(text).iter().filter(|(_, irt, _)| irt.is_none()).count()
|
||||
}
|
||||
|
||||
/// The box around every comment in an XFDF text, in the pages' user space: [x0, y0, x1, y1],
|
||||
/// or `None` without a comment with a rect. What a paste shows hanging on the pointer and centres
|
||||
/// on the point: replies, which have no place of their own (some tools still give them a rect, at
|
||||
/// 0,0 or away from their parent), are left out, as [`comment_outlines`] leaves them out.
|
||||
pub fn comments_bounds(text: &str) -> Option<[f64; 4]> {
|
||||
let mut bounds: Option<[f64; 4]> = None;
|
||||
for (_, in_reply_to, rect) in comment_nodes(text) {
|
||||
if in_reply_to.is_some() {
|
||||
continue;
|
||||
}
|
||||
if let [x0, y0, x1, y1] = rect.as_slice()
|
||||
&& rect.iter().all(|v| v.is_finite())
|
||||
{
|
||||
let r = [x0.min(*x1), y0.min(*y1), x0.max(*x1), y0.max(*y1)];
|
||||
bounds = Some(match bounds {
|
||||
None => r,
|
||||
Some(b) => [b[0].min(r[0]), b[1].min(r[1]), b[2].max(r[2]), b[3].max(r[3])],
|
||||
});
|
||||
}
|
||||
}
|
||||
bounds
|
||||
}
|
||||
|
||||
/// Move an annotation's geometry by (dx, dy) in user space: its rect, quads, vertices, ink,
|
||||
/// line ends and callout leader (what a copy placed elsewhere needs).
|
||||
fn translate(d: &mut Dict, dx: f64, dy: f64) {
|
||||
let shift = |o: &Object| -> Option<Object> {
|
||||
let v: Vec<f64> = o.as_array()?.iter().map(|x| x.as_f64()).collect::<Option<Vec<f64>>>()?;
|
||||
Some(arr(&v.iter().enumerate().map(|(i, x)| if i % 2 == 0 { x + dx } else { x + dy }).collect::<Vec<f64>>()))
|
||||
};
|
||||
for key in [&b"Rect"[..], b"QuadPoints", b"Vertices", b"L", b"CL"] {
|
||||
if let Some(moved) = d.get(key).and_then(shift) {
|
||||
d.set(key.to_vec(), moved);
|
||||
}
|
||||
}
|
||||
if let Some(Object::Array(strokes)) = d.get(b"InkList").cloned() {
|
||||
let moved: Vec<Object> = strokes.iter().map(|s| shift(s).unwrap_or_else(|| s.clone())).collect();
|
||||
d.set(b"InkList".to_vec(), Object::Array(moved));
|
||||
}
|
||||
}
|
||||
|
||||
/// XFDF with the document's comments and/or field values.
|
||||
pub fn export_xfdf(doc: &Document, comments: bool, fields: bool, file: &str) -> String {
|
||||
let mut out = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<xfdf xmlns=\"http://ns.adobe.com/xfdf/\" xml:space=\"preserve\">\n");
|
||||
if comments {
|
||||
out.push_str("<annots>\n");
|
||||
xfdf_annots(doc, &mut out);
|
||||
xfdf_annots(doc, &mut out, None, false);
|
||||
out.push_str("</annots>\n");
|
||||
}
|
||||
if fields {
|
||||
@@ -311,6 +680,16 @@ fn annot_from_xml(node: roxmltree::Node, subtype: &str, page_ref: ObjRef) -> Opt
|
||||
Object::Array(vec![Object::name(node.attribute("head").unwrap_or("None")), Object::name(node.attribute("tail").unwrap_or("None"))]),
|
||||
);
|
||||
}
|
||||
if let Some(it) = node.attribute("intent").filter(|it| !it.is_empty()) {
|
||||
d.set(b"IT".to_vec(), Object::name(it));
|
||||
}
|
||||
if node.attribute("style") == Some("cloudy") {
|
||||
let mut be = Dict::new();
|
||||
be.set(b"S".to_vec(), Object::name("C"));
|
||||
let intensity = node.attribute("intensity").and_then(|i| i.parse::<f64>().ok()).filter(|i| i.is_finite()).unwrap_or(1.0);
|
||||
be.set(b"I".to_vec(), Object::Real(intensity.clamp(0.0, 2.0)));
|
||||
d.set(b"BE".to_vec(), Object::Dict(be));
|
||||
}
|
||||
if let Some(cl) = node.attribute("callout").map(csv).filter(|c| c.len() == 4 || c.len() == 6) {
|
||||
d.set(b"CL".to_vec(), arr(&cl));
|
||||
if let Some(rd) = node.attribute("fringe").map(csv).filter(|r| r.len() == 4) {
|
||||
@@ -352,6 +731,7 @@ fn annot_from_xml(node: roxmltree::Node, subtype: &str, page_ref: ObjRef) -> Opt
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
read_own_keys(node, &mut d);
|
||||
Some(d)
|
||||
}
|
||||
|
||||
@@ -472,7 +852,32 @@ pub(crate) fn add_popup(doc: &mut Document, page: ObjRef, parent: ObjRef, rect:
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Import XFDF comments as copies: every comment lands on `page` (0-based; its own page when
|
||||
/// `None`) and, with `fresh` (a prefix unique to this import), under a new name, so nothing in
|
||||
/// the document is replaced and pasting the same comments twice gives two copies. Replies keep
|
||||
/// their thread among the pasted comments; a reply to a comment that was not copied stands
|
||||
/// alone. Field values in the file are ignored.
|
||||
pub fn import_comments(
|
||||
doc: &mut Document,
|
||||
text: &str,
|
||||
page: Option<usize>,
|
||||
fresh: Option<&str>,
|
||||
offset: Option<[f64; 2]>,
|
||||
) -> Result<Report, DataError> {
|
||||
import_xfdf_with(doc, text, page, fresh, offset.filter(|o| o.iter().all(|v| v.is_finite())))
|
||||
}
|
||||
|
||||
pub(crate) fn import_xfdf(doc: &mut Document, text: &str) -> Result<Report, DataError> {
|
||||
import_xfdf_with(doc, text, None, None, None)
|
||||
}
|
||||
|
||||
fn import_xfdf_with(
|
||||
doc: &mut Document,
|
||||
text: &str,
|
||||
target_page: Option<usize>,
|
||||
fresh: Option<&str>,
|
||||
offset: Option<[f64; 2]>,
|
||||
) -> Result<Report, DataError> {
|
||||
check_form_xml_depth(text)?;
|
||||
let xml = parse_form_xml(text).map_err(|e| DataError::Malformed(e.to_string()))?;
|
||||
let root = xml.root_element();
|
||||
@@ -481,28 +886,65 @@ pub(crate) fn import_xfdf(doc: &mut Document, text: &str) -> Result<Report, Data
|
||||
}
|
||||
let mut report = Report::default();
|
||||
let pages: Vec<ObjRef> = pdfcraft_model::pages(doc).iter().map(|p| p.obj).collect();
|
||||
if let Some(p) = target_page
|
||||
&& p >= pages.len()
|
||||
{
|
||||
return Err(DataError::Malformed(format!("page {} is not in the document", p + 1)));
|
||||
}
|
||||
// Comments: parents first, then replies (whose /IRT needs the parent's reference).
|
||||
let mut by_name: HashMap<String, ObjRef> = HashMap::new();
|
||||
let mut later: HashMap<ObjRef, String> = HashMap::new();
|
||||
// Fresh names: every copy gets one from its own position, and `name` → the first copy's,
|
||||
// so replies find their copied parent (a name two source comments share, as other tools
|
||||
// write, still gives two copies).
|
||||
let mut renamed: HashMap<String, String> = HashMap::new();
|
||||
if let (Some(prefix), Some(annots)) = (fresh, root.children().find(|c| c.has_tag_name("annots"))) {
|
||||
for (n, node) in annots.children().filter(|c| c.is_element()).enumerate() {
|
||||
if let Some(nm) = node.attribute("name") {
|
||||
renamed.entry(nm.to_string()).or_insert_with(|| format!("{prefix}-{}", n + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(annots) = root.children().find(|c| c.has_tag_name("annots")) {
|
||||
for node in annots.children().filter(|c| c.is_element()) {
|
||||
for (n, node) in annots.children().filter(|c| c.is_element()).enumerate() {
|
||||
let tag = node.tag_name().name().to_ascii_lowercase();
|
||||
let Some((subtype, _)) = SUBTYPES.iter().find(|(_, t)| *t == tag) else { continue };
|
||||
let Some(page) = node.attribute("page").and_then(|p| p.parse::<usize>().ok()).and_then(|p| pages.get(p).copied()) else { continue };
|
||||
let Some(d) = annot_from_xml(node, subtype, page) else { continue };
|
||||
let own_page = node.attribute("page").and_then(|p| p.parse::<usize>().ok());
|
||||
let Some(page) = target_page.or(own_page).and_then(|p| pages.get(p).copied()) else { continue };
|
||||
let Some(mut d) = annot_from_xml(node, subtype, page) else { continue };
|
||||
let assigned = fresh.map(|prefix| format!("{prefix}-{}", n + 1));
|
||||
if let Some(name) = &assigned {
|
||||
// A copy under a name of its own (one even where the original had none).
|
||||
d.set(b"NM".to_vec(), PdfString::text(name));
|
||||
}
|
||||
if let Some([dx, dy]) = offset {
|
||||
translate(&mut d, dx, dy);
|
||||
}
|
||||
let r = place(doc, page, d)?;
|
||||
// A later same-name record replaces this comment, including its reply relation.
|
||||
later.remove(&r);
|
||||
if let Some(nm) = node.attribute("name") {
|
||||
by_name.insert(nm.to_string(), r);
|
||||
if let Some(name) = assigned.clone().or_else(|| node.attribute("name").map(str::to_owned)) {
|
||||
by_name.insert(name, r);
|
||||
}
|
||||
if let Some(irt) = node.attribute("inreplyto") {
|
||||
later.insert(r, irt.to_string());
|
||||
match (fresh, renamed.get(irt)) {
|
||||
// The parent is among the copies: the reply follows it.
|
||||
(Some(_), Some(parent)) => later.insert(r, parent.clone()),
|
||||
// A copy's parent was not copied: the reply stands alone rather than
|
||||
// attaching to a same-named comment the document happens to have.
|
||||
(Some(_), None) => None,
|
||||
(None, _) => later.insert(r, irt.to_string()),
|
||||
};
|
||||
}
|
||||
// A pop-up window.
|
||||
if let Some(pop) = node.children().find(|c| c.has_tag_name("popup"))
|
||||
&& let Some(rect) = pop.attribute("rect").map(csv).filter(|r| r.len() == 4)
|
||||
&& let Some(mut rect) = pop.attribute("rect").map(csv).filter(|r| r.len() == 4)
|
||||
{
|
||||
if let Some([dx, dy]) = offset {
|
||||
for (i, v) in rect.iter_mut().enumerate() {
|
||||
*v += if i % 2 == 0 { dx } else { dy };
|
||||
}
|
||||
}
|
||||
add_popup(doc, page, r, &rect, pop.attribute("open") == Some("yes"))?;
|
||||
}
|
||||
report.comments += 1;
|
||||
@@ -513,6 +955,10 @@ pub(crate) fn import_xfdf(doc: &mut Document, text: &str) -> Result<Report, Data
|
||||
doc.update_dict(r, |d| d.set(b"IRT".to_vec(), Object::Ref(parent)))?;
|
||||
}
|
||||
}
|
||||
// Comments placed as copies: the file's field values are not part of the paste.
|
||||
if fresh.is_some() || target_page.is_some() || offset.is_some() {
|
||||
return Ok(report);
|
||||
}
|
||||
// Fields.
|
||||
if let Some(fields) = root.children().find(|c| c.has_tag_name("fields")) {
|
||||
let mut values = Vec::new();
|
||||
|
||||
@@ -77,6 +77,7 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24)
|
||||
| Document Properties (description, security, fonts, initial view, custom, advanced) | yes | all but Custom and XMP | partial |
|
||||
| Field Properties (General, Appearance, Position, Options, Actions, Format, Validate, Calculate, Signed) | yes | all but Signed | partial |
|
||||
| Comment properties and properties bar | yes | yes; a text box's text, border and fill and a shape's line and fill each take an opacity of their own (Acrobat: one per comment) | shipped |
|
||||
| Select several comments; copy and paste comments between documents | yes | yes: ⌘/Ctrl-click adds, Delete, a drag, Properties and Colour act on all, ⌘C/⌘V as XFDF (also across PdfCraft processes) | shipped |
|
||||
| Preferences | ≈ 20 pages | Interface language, Documents and view, Identity, Fill & Sign, JavaScript | partial (gap 19) |
|
||||
|
||||
## Revision history
|
||||
|
||||
@@ -3878,8 +3878,8 @@ milestone = "M5"
|
||||
status = "shipped"
|
||||
commands = ["comment.polyline"]
|
||||
tools = ["comment_add"]
|
||||
evidence = ["crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/ui-egui/tests/it/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/automation/tests/it/automation.rs::drawing_comments_through_tools"]
|
||||
notes = ""
|
||||
evidence = ["crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/ui-egui/tests/it/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/ui-egui/tests/it/comments.rs::connected_lines_are_completed_from_the_context_menu", "crates/automation/tests/it/automation.rs::drawing_comments_through_tools"]
|
||||
notes = "Click the corners; Enter, a double-click or the right-click menu's Complete finishes the lines (the menu also takes the last point back and cancels the drawing), Backspace takes a point back."
|
||||
|
||||
[[feature]]
|
||||
id = "comment.cloud"
|
||||
@@ -4343,11 +4343,35 @@ title = "Export selected comments"
|
||||
area = "E"
|
||||
tier = "P2"
|
||||
milestone = "M5"
|
||||
status = "planned"
|
||||
status = "partial"
|
||||
commands = ["comment.copy"]
|
||||
tools = ["comments_copy"]
|
||||
evidence = ["crates/xfdf/src/tests.rs::exporting_selected_comments_takes_them_and_their_replies", "crates/automation/tests/it/automation.rs::comments_copy_and_paste_between_documents_through_tools", "crates/xfdf/src/tests.rs::copies_keep_their_intent_cloud_and_pdfcraft_keys"]
|
||||
notes = "The selected comments, each with its replies, as XFDF: Copy puts it on the clipboard (desktop) and comments_copy returns it headlessly. Each keeps what it is meant as (`intent`: typewriter text, a cloud, an arrow), a cloudy border (`style`, `intensity`; both also in File ▸ Export comments) and PdfCraft's own keys (`pdfcraft:keys`, in a namespace of its own: a Fill & Sign item, a turned comment's box and angle), so a paste is the same comment. Not yet: a Save as… to a data file from the interface."
|
||||
|
||||
[[feature]]
|
||||
id = "comment.multi-select"
|
||||
title = "Select several comments (⌘-click, ⇧-click)"
|
||||
area = "E"
|
||||
tier = "P1"
|
||||
milestone = "M5"
|
||||
status = "shipped"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
evidence = ["crates/ui-egui/tests/it/comments.rs::ctrl_click_selects_several_comments_and_delete_removes_them_all", "crates/ui-egui/tests/it/comments.rs::properties_change_every_selected_comment_where_it_applies", "crates/ui-egui/tests/it/comments.rs::dragging_any_selected_comment_moves_the_whole_selection", "crates/ui-egui/src/tools/comments/tests.rs::shift_click_selects_the_range_from_the_comment_with_the_handles"]
|
||||
notes = "On the page, ⌘-click (Ctrl on Windows and Linux) or ⇧-click adds a comment to the selection or takes it out again; in the Comments panel ⌘/Ctrl-click does, and ⇧-click selects the cards from the one with the handles to the one clicked. The one clicked last has the handles and is what Reply acts on. Delete (key or menu) removes every selected comment as one undo step, dragging one of them moves them all (text markup stays with its text, a locked one where it is), and the context menu copies them all. Properties (opened on one of them), the context menu's Colour and the quick bar's colour change every selected comment as one undo step, each where the property applies (a font size to text, a fill to shapes, a width to lines and shapes); one that stays locked keeps its look. Escape and a plain click select one alone again. Not yet: a rubber-band (marquee) selection on the page."
|
||||
|
||||
[[feature]]
|
||||
id = "comment.copy-paste"
|
||||
title = "Copy and paste comments, within and between documents"
|
||||
area = "E"
|
||||
tier = "P1"
|
||||
milestone = "M5"
|
||||
status = "shipped"
|
||||
commands = ["comment.copy", "comment.paste"]
|
||||
tools = ["comments_copy", "comments_paste"]
|
||||
evidence = ["crates/ui-egui/tests/it/comments.rs::copied_comments_paste_into_another_document", "crates/ui-egui/tests/it/comments.rs::copied_comments_take_the_click_from_the_tool_that_is_on", "crates/engine/src/tests.rs::pasting_copied_comments_adds_copies_that_undo_as_one_step", "crates/xfdf/src/tests.rs::importing_comments_as_copies_adds_them_on_the_page_with_fresh_names", "crates/xfdf/src/tests.rs::copied_marks_and_text_boxes_show_what_they_are_while_placed_and_once_pasted", "crates/automation/tests/it/automation.rs::comments_copy_and_paste_between_documents_through_tools"]
|
||||
notes = "⌘C with comments selected (and no text selected) copies them as XFDF, each with its replies, to the app's clipboard (every tab and window) and as text to the system clipboard. ⌘V or Edit ▸ Paste comments hangs the copies on the pointer as one bundle (their box, with a count, and each comment's outline in its colour: Fill & Sign ticks, crosses, dots and lines as themselves, text boxes with their text); a click places them, centred there, on the page clicked (whatever tool is on: Fill & Sign's Text tool opens no box, a stamp tool places no stamp), so a copy never lands on its original and a bundle from an A0 page goes where it fits on an A4 one (placed as it is when larger than the page, with a notice); Escape or a right-click lets the bundle go. The page's context menu places the bundle at the point the menu was opened at. The copies are new comments under names of their own (nothing is replaced; pasting twice gives two sets) and are selected afterwards. XFDF with comments on the system clipboard pastes too, so comments travel between PdfCraft processes. The Comments panel's card menu copies that card's comment. Headless: comments_copy / comments_paste (`at` places the bundle's centre), undoable as one step. Not yet: appearance streams (the copies are drawn again), file attachments' data (the attachment comment is copied without its file), pasting in the web build's other tabs through the system clipboard."
|
||||
|
||||
[[feature]]
|
||||
id = "comment.import-from-pdf"
|
||||
|
||||
Reference in new issue
Block a user