feat: major Acrobat UI parity, crop, and layer fixes

This commit is contained in:
Combiler01 committed 2026-10-14 19:05:51 +05:30
1 parent 293faa0c05
commit 9a5fec0caa
11 files changed
+562 -91

No files matched your search

+172 -61
View File
@@ -660,6 +660,122 @@ pub fn arrange_image(doc: &mut Document, page: usize, index: usize, order: Arran
Ok(())
}
/// Locate the innermost graphics state scope enclosing only `target` (with no other painting operations).
/// Returns `Some((start, end, scope_matrix))` where `start` is the index of `q`, `end` is the index of `Q`,
/// and `scope_matrix` is the CTM in effect immediately before entering that `q`.
fn image_exclusive_scope(ops: &[Op], target: usize) -> Result<Option<(usize, usize, Matrix)>, EditError> {
struct Scope {
start: usize,
matrix: Matrix,
inherited_clip: bool,
inherited_path: bool,
selected: bool,
other_paint: bool,
}
let mut stack: Vec<Scope> = Vec::new();
let mut matrix = Matrix::IDENTITY;
let mut clipped = false;
let mut path = false;
let mut pending_clip = false;
let mut text = false;
let mut at_image = None;
let mut isolated = None;
for (index, op) in ops.iter().enumerate() {
match op.op.as_slice() {
b"q" => {
if stack.len() >= 256 {
return Err(EditError::Invalid("the image graphics state is too deeply nested".into()));
}
stack.push(Scope { start: index, matrix, inherited_clip: clipped, inherited_path: path, selected: false, other_paint: false });
}
b"Q" => {
if let Some(scope) = stack.pop() {
if scope.selected && !scope.other_paint && !scope.inherited_clip && !scope.inherited_path {
isolated.get_or_insert((scope.start, index, scope.matrix));
}
matrix = scope.matrix;
clipped = scope.inherited_clip;
}
}
b"cm" => {
if let Some(m) = op.nums::<6>() {
matrix = Matrix(m).then(&matrix);
}
}
b"BT" => text = true,
b"ET" => text = false,
b"m" | b"l" | b"c" | b"v" | b"y" | b"re" => path = true,
b"W" | b"W*" => pending_clip = true,
_ => {}
}
if index == target {
if text {
return Err(EditError::Invalid("an image inside a text object cannot be cropped".into()));
}
at_image = Some(matrix);
for scope in &mut stack {
scope.selected = true;
}
} else if matches!(
op.op.as_slice(),
b"Do" | b"Tj" | b"TJ" | b"'" | b"\"" | b"sh" | b"BI" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*"
) {
for scope in &mut stack {
scope.other_paint = true;
}
}
if matches!(op.op.as_slice(), b"n" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*") {
clipped |= pending_clip;
pending_clip = false;
path = false;
}
}
if at_image.is_none() {
return Err(EditError::Invalid("the image operation is missing".into()));
}
Ok(isolated)
}
fn extract_image_drawing_ops(ops: &[Op], start: usize, end: usize, scope_matrix: Matrix) -> Vec<Op> {
let mut drawing = Vec::new();
let mut i = start.saturating_add(1);
while i < end {
let op = match ops.get(i) {
Some(o) => o,
None => break,
};
if op.is("cm")
&& scope_matrix != Matrix::IDENTITY
&& let (Some(m), Some(inv)) = (op.nums::<6>(), scope_matrix.invert())
&& Matrix(m) == inv
&& ops.get(i + 1).is_some_and(|next| next.is("re"))
{
i = i.saturating_add(1);
continue;
}
if op.is("re") {
i = i.saturating_add(1);
if ops.get(i).is_some_and(|next| next.is("W") || next.is("W*")) {
i = i.saturating_add(1);
}
if ops.get(i).is_some_and(|next| next.is("n")) {
i = i.saturating_add(1);
}
if scope_matrix != Matrix::IDENTITY
&& ops.get(i).is_some_and(|next| next.is("cm"))
&& let Some(m) = ops.get(i).and_then(|next| next.nums::<6>())
&& Matrix(m) == scope_matrix
{
i = i.saturating_add(1);
}
continue;
}
drawing.push(op.clone());
i = i.saturating_add(1);
}
drawing
}
/// Remove any private clipping path / crop on image `index` on `page`.
pub fn remove_image_crop(doc: &mut Document, page: usize, index: usize) -> Result<(), EditError> {
let images = page_images(doc, page)?;
@@ -682,14 +798,19 @@ pub fn remove_image_crop(doc: &mut Document, page: usize, index: usize) -> Resul
.iter()
.position(|op| op.span.start == span && op.is("Do"))
.ok_or_else(|| EditError::Invalid("the image operation could not be located".into()))?;
if let Some((at, _)) = exclusive_clip(&ops, target) {
let end_idx = at.saturating_add(2);
if let (Some(start_op), Some(end_op)) = (ops.get(at), ops.get(end_idx)) {
let range = start_op.span.start..end_op.span.end;
let changed = pieces.splice([(range, Vec::new())]);
let contents = crate::text::rewritten_contents(doc, &all, changed);
doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?;
}
if let Some((start, end, scope_matrix)) = image_exclusive_scope(&ops, target)? {
let drawing_ops = extract_image_drawing_ops(&ops, start, end, scope_matrix);
let start_op = ops.get(start).ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?;
let end_op = ops.get(end).ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?;
let range = start_op.span.start..end_op.span.end;
let mut unit_ops = Vec::new();
unit_ops.push(Op::new("q", vec![]));
unit_ops.extend(drawing_ops);
unit_ops.push(Op::new("Q", vec![]));
let serialized = serialize_ops(&unit_ops);
let changed = pieces.splice([(range, serialized)]);
let contents = crate::text::rewritten_contents(doc, &all, changed);
doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?;
}
Ok(())
}
@@ -725,62 +846,52 @@ pub fn crop_image(doc: &mut Document, page: usize, index: usize, crop_rect: [f64
.position(|op| op.span.start == span && op.is("Do"))
.ok_or_else(|| EditError::Invalid("the image operation could not be located".into()))?;
let (range, unit_ops) = if let Some((at, _)) = exclusive_clip(&ops, target) {
let op_re = ops.get(at).ok_or_else(|| EditError::Invalid("the crop operation is missing".into()))?;
(
op_re.span.start..op_re.span.end,
vec![Op::new("re", vec![pdfcraft_content::num(x0), pdfcraft_content::num(y0), pdfcraft_content::num(w), pdfcraft_content::num(h)])],
)
} else if target >= 2
&& ops.get(target - 2).is_some_and(|o| o.is("q"))
&& ops.get(target - 1).is_some_and(|o| o.is("cm"))
&& ops.get(target + 1).is_some_and(|o| o.is("Q"))
{
let q_op = &ops[target - 2];
let q_end = &ops[target + 1];
(
q_op.span.start..q_end.span.end,
vec![
Op::new("q", vec![]),
Op::new("re", vec![pdfcraft_content::num(x0), pdfcraft_content::num(y0), pdfcraft_content::num(w), pdfcraft_content::num(h)]),
Op::new("W", vec![]),
Op::new("n", vec![]),
ops[target - 1].clone(),
ops[target].clone(),
Op::new("Q", vec![]),
],
)
} else if target >= 1 && ops.get(target - 1).is_some_and(|o| o.is("cm")) {
let cm_op = &ops[target - 1];
let do_op = &ops[target];
(
cm_op.span.start..do_op.span.end,
vec![
Op::new("q", vec![]),
Op::new("re", vec![pdfcraft_content::num(x0), pdfcraft_content::num(y0), pdfcraft_content::num(w), pdfcraft_content::num(h)]),
Op::new("W", vec![]),
Op::new("n", vec![]),
ops[target - 1].clone(),
ops[target].clone(),
Op::new("Q", vec![]),
],
)
let (start, end, drawing_ops, scope_matrix) = if let Some((start, end, scope_matrix)) = image_exclusive_scope(&ops, target)? {
let drawing_ops = extract_image_drawing_ops(&ops, start, end, scope_matrix);
(start, end, drawing_ops, scope_matrix)
} else {
let do_op = &ops[target];
(
do_op.span.start..do_op.span.end,
vec![
Op::new("q", vec![]),
Op::new("re", vec![pdfcraft_content::num(x0), pdfcraft_content::num(y0), pdfcraft_content::num(w), pdfcraft_content::num(h)]),
Op::new("W", vec![]),
Op::new("n", vec![]),
Op::new("cm", img.draw_matrix.map(pdfcraft_content::num).to_vec()),
ops[target].clone(),
Op::new("Q", vec![]),
],
)
let mut state = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new());
let mut scope_matrix = Matrix::IDENTITY;
let start = if target >= 1 && ops.get(target - 1).is_some_and(|o| o.is("cm")) {
target - 1
} else {
target
};
for (i, o) in ops.iter().enumerate() {
if i == start {
scope_matrix = state.placement.matrix;
break;
}
state.apply(o)?;
}
let drawing_ops = ops.get(start..=target).unwrap_or_default().to_vec();
(start, target, drawing_ops, scope_matrix)
};
let mut unit_ops = Vec::new();
unit_ops.push(Op::new("q", vec![]));
if scope_matrix != Matrix::IDENTITY
&& let Some(inv) = scope_matrix.invert()
{
unit_ops.push(Op::new("cm", inv.0.map(pdfcraft_content::num).to_vec()));
}
unit_ops.push(Op::new("re", vec![
pdfcraft_content::num(x0),
pdfcraft_content::num(y0),
pdfcraft_content::num(w),
pdfcraft_content::num(h),
]));
unit_ops.push(Op::new("W", vec![]));
unit_ops.push(Op::new("n", vec![]));
if scope_matrix != Matrix::IDENTITY {
unit_ops.push(Op::new("cm", scope_matrix.0.map(pdfcraft_content::num).to_vec()));
}
unit_ops.extend(drawing_ops);
unit_ops.push(Op::new("Q", vec![]));
let start_op = ops.get(start).ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?;
let end_op = ops.get(end).ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?;
let range = start_op.span.start..end_op.span.end;
let serialized = serialize_ops(&unit_ops);
let changed = pieces.splice([(range, serialized)]);
let contents = crate::text::rewritten_contents(doc, &all, changed);
+26
View File
@@ -1740,6 +1740,32 @@ fn image_arrange_stacking_order_and_remove_crop() {
let doc = reopen(&doc);
let img_restored = &images::page_images(&doc, 0).unwrap()[0];
assert_eq!(img_restored.visible_rect, Some(img_restored.rect));
// Test cropping an image that was moved (has an outer transform)
let mut doc = image_page_with("q 200 0 0 100 100 100 cm /Im0 Do Q");
let move_transform = [1.0, 0.0, 0.0, 1.0, 50.0, 40.0];
images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(move_transform)).unwrap();
let doc = reopen(&doc);
let img_moved = &images::page_images(&doc, 0).unwrap()[0];
assert_eq!(img_moved.rect, [150.0, 140.0, 350.0, 240.0]);
let mut doc = doc;
images::crop_image(&mut doc, 0, 0, [170.0, 150.0, 300.0, 220.0]).unwrap();
let doc = reopen(&doc);
let img_crop_moved = &images::page_images(&doc, 0).unwrap()[0];
assert!(img_crop_moved.visible_rect.is_some(), "cropped image must not be deleted");
assert!(close(img_crop_moved.visible_rect.unwrap(), [170.0, 150.0, 300.0, 220.0]));
// Adjust crop (re-crop)
let mut doc = doc;
images::crop_image(&mut doc, 0, 0, [180.0, 160.0, 280.0, 210.0]).unwrap();
let doc = reopen(&doc);
let img_recropped = &images::page_images(&doc, 0).unwrap()[0];
assert!(close(img_recropped.visible_rect.unwrap(), [180.0, 160.0, 280.0, 210.0]));
// Remove crop from moved image
let mut doc = doc;
images::remove_image_crop(&mut doc, 0, 0).unwrap();
let doc = reopen(&doc);
let img_restored_moved = &images::page_images(&doc, 0).unwrap()[0];
assert_eq!(img_restored_moved.visible_rect, Some(img_restored_moved.rect));
}
+43 -9
View File
@@ -542,6 +542,8 @@ pub(crate) enum PageMenuAction {
SelectTool,
HandTool,
MarqueeZoom,
Copy,
Paste(usize, [f64; 2]),
AddComment(usize, [f64; 2]),
AddBookmark(usize),
TakeSnapshot,
@@ -558,6 +560,8 @@ pub(crate) fn default_page_context_menu(
ui: &mut egui::Ui,
page: usize,
at: [f64; 2],
can_copy: bool,
can_paste: bool,
action: &mut Option<PageMenuAction>,
) {
// Top horizontal icon bar: Rotate CCW, Print, Select tool, Hand tool, Marquee Zoom
@@ -587,6 +591,17 @@ pub(crate) fn default_page_context_menu(
ui.separator();
if ui.add_enabled(can_copy, egui::Button::new(tl!("Copy")).shortcut_text("Ctrl+C")).clicked() {
*action = Some(PageMenuAction::Copy);
ui.close();
}
if ui.add_enabled(can_paste, egui::Button::new(tl!("Paste")).shortcut_text("Ctrl+V")).clicked() {
*action = Some(PageMenuAction::Paste(page, at));
ui.close();
}
ui.separator();
if ui.button(tl!("Add comment")).clicked() {
*action = Some(PageMenuAction::AddComment(page, at));
ui.close();
@@ -2191,6 +2206,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
let editing_content =
(app.left_open && app.left == crate::LeftPanel::Tool("edit")) || matches!(app.quick_tool, QuickTool::AddText | QuickTool::EditText);
let text_style = app.text_style.clone();
let can_paste_image = app.can_paste_image();
let view = &mut app.views[index];
// Opened without the owner password and something is restricted.
let secured = doc.security_summary().is_some_and(|s| !(s.owner || (s.permissions.modify() && s.permissions.assemble())));
@@ -3227,7 +3243,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
return;
}
if tool == QuickTool::EditText && let Some(sel) = view.image_selection.clone() {
crate::edit_text_ui::image_context_menu(ui, view, doc, sel.page, sel.index, &mut image_action);
crate::edit_text_ui::image_context_menu(ui, view, doc, sel.page, sel.index, can_paste_image, &mut image_action);
return;
}
if view.comments.selected.is_some() {
@@ -3260,11 +3276,14 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
ui.separator();
}
}
let has_copy_image = copy_image.is_some();
if copy_image.is_some() {
canvas_action = copy_image;
}
let (page, at) = view.comments.context_at.unwrap_or((view.current, [0.0, 0.0]));
default_page_context_menu(ui, page, at, &mut page_action);
let can_copy = view.image_selection.is_some() || view.selected_text().is_some() || has_copy_image;
let can_paste = can_paste_image;
default_page_context_menu(ui, page, at, can_copy, can_paste, &mut page_action);
});
(wanted, visible_now)
});
@@ -3531,13 +3550,11 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
Some(crate::edit_text_ui::ImageAction::Copy(page, i)) => app.copy_page_image(page, i),
Some(crate::edit_text_ui::ImageAction::CopyFile(page, i)) => app.copy_page_image_file(page, i),
Some(crate::edit_text_ui::ImageAction::Cut(page, i)) => {
app.copy_page_image(page, i);
app.views[index].pending_edit = Some(pdfcraft_engine::Edit::EditPageImage {
page,
index: i,
change: pdfcraft_engine::ImageEdit::Delete,
});
app.views[index].image_selection = None;
app.cut_page_image(index, page, i);
}
Some(crate::edit_text_ui::ImageAction::Paste(page, rect)) => {
let offset_rect = [rect[0] + 20.0, rect[1] - 20.0, rect[2] + 20.0, rect[3] - 20.0];
app.paste_image(page, Some(offset_rect));
}
Some(crate::edit_text_ui::ImageAction::DrawFreehand) => {
app.quick_tool = crate::QuickTool::Comment(crate::comments::CommentTool::Ink);
@@ -3591,6 +3608,23 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
None => {}
}
match page_action {
Some(PageMenuAction::Copy) => {
if let Some(sel) = app.views[index].image_selection.clone() {
app.copy_page_image(sel.page, sel.index);
} else if let Some(text) = app.views[index].selected_text() {
if let Some(id) = app.views.get(index).map(|v| v.id) && app.may_copy(id) {
ui.ctx().copy_text(text);
}
} else if let Some((page, at)) = app.views[index].comments.context_at
&& let Some(doc) = app.session.get(app.views[index].id)
&& let Some(image) = doc.page_image_at(page, at)
{
app.copy_page_image(page, image);
}
}
Some(PageMenuAction::Paste(page, at)) => {
app.paste_image_at(page, at);
}
Some(PageMenuAction::RotateCCW(page)) => {
app.views[index].pending_edit = Some(pdfcraft_engine::Edit::RotatePages { pages: vec![page], degrees: -90 });
}
+131
View File
@@ -745,6 +745,15 @@ impl crate::PdfCraftApp {
if !self.may_copy(id) {
return;
}
let img_rect = self.session.get(id).and_then(|d| d.page_images(page).into_iter().nth(index).map(|im| im.rect));
self.last_copied_image_rect = img_rect;
if let Some(doc) = self.session.get(id)
&& let Ok((ext, bytes)) = doc.page_image_copy_file(page, index)
{
let stem = crate::image_pick::safe_name(doc.name.rsplit_once('.').map_or(doc.name.as_str(), |(s, _)| s));
let name = format!("{stem} p{} img{}.{ext}", page.saturating_add(1), index.saturating_add(1));
self.last_copied_file = Some((name, bytes));
}
let Some(pixels) = self.session.get(id).map(|d| d.page_image_rgba(page, index)) else { return };
let copied = pixels.and_then(|(w, h, rgba)| {
#[cfg(not(target_arch = "wasm32"))]
@@ -765,6 +774,128 @@ impl crate::PdfCraftApp {
}
}
/// Cut Image: copy image `index` on `page` to the clipboard, then delete it.
pub(crate) fn cut_page_image(&mut self, view_idx: usize, page: usize, index: usize) {
self.copy_page_image(page, index);
if let Some(view) = self.views.get_mut(view_idx) {
view.pending_edit = Some(pdfcraft_engine::Edit::EditPageImage {
page,
index,
change: pdfcraft_engine::ImageEdit::Delete,
});
view.image_selection = None;
}
}
/// Whether there is an image in the in-memory clipboard or system clipboard ready to paste.
pub fn can_paste_image(&self) -> bool {
if self.last_copied_image.is_some() || self.last_copied_file.is_some() {
return true;
}
#[cfg(not(target_arch = "wasm32"))]
{
if self.system_clipboard
&& let Ok(mut cb) = arboard::Clipboard::new()
{
return cb.get_image().is_ok();
}
}
false
}
/// Paste an image from the clipboard onto `page`.
pub fn paste_image(&mut self, page: usize, rect: Option<[f64; 4]>) -> bool {
let Some((i, id)) = self.active_ids() else { return false; };
if let Some(doc) = self.session.get(id)
&& !doc.allows_modification()
{
self.notify_error(tl!("The document's security settings don't allow changes"));
return false;
}
let image_data: Option<(String, Vec<u8>)> = if let Some((name, bytes)) = self.last_copied_file.clone() {
Some((name, bytes))
} else if let Some((w, h, rgba)) = self.last_copied_image.clone() {
crate::create_ui::png_from_rgba(w as usize, h as usize, &rgba).ok().map(|b| ("pasted_image.png".to_string(), b))
} else {
#[cfg(not(target_arch = "wasm32"))]
{
if self.system_clipboard {
arboard::Clipboard::new()
.ok()
.and_then(|mut cb| cb.get_image().ok())
.and_then(|img| {
crate::create_ui::png_from_rgba(img.width, img.height, &img.bytes)
.ok()
.map(|b| ("clipboard_image.png".to_string(), b))
})
} else {
None
}
}
#[cfg(target_arch = "wasm32")]
None
};
let Some((name, bytes)) = image_data else {
self.notify_tr("No image on clipboard to paste");
return false;
};
let target_rect = match rect {
Some(r) => Some(r),
None => {
if let Some(src_rect) = self.last_copied_image_rect {
let w = src_rect[2] - src_rect[0];
let h = src_rect[3] - src_rect[1];
let offset_x = 20.0;
let offset_y = -20.0;
let new_r = [src_rect[0] + offset_x, src_rect[1] + offset_y, src_rect[0] + offset_x + w, src_rect[1] + offset_y + h];
self.last_copied_image_rect = Some(new_r);
Some(new_r)
} else {
None
}
}
};
let before = self.session.get(id).map_or(0, |d| d.page_images(page).len());
if self.apply_edit(pdfcraft_engine::Edit::AddImage {
page,
rect: target_rect,
name,
bytes: std::sync::Arc::new(bytes),
}) {
if let Some(v) = self.views.get_mut(i) {
v.current = page;
v.image_selection = Some(crate::edit_text_ui::ImageSelection {
page,
index: before,
crop_mode: false,
crop_rect: None,
drag: None,
});
}
self.quick_tool = crate::QuickTool::EditText;
self.notify_tr("Image pasted");
true
} else {
false
}
}
/// Paste an image centered at context menu point `at: [f64; 2]` on `page`.
pub fn paste_image_at(&mut self, page: usize, at: [f64; 2]) -> bool {
let rect = if let Some(src_r) = self.last_copied_image_rect {
let w = src_r[2] - src_r[0];
let h = src_r[3] - src_r[1];
Some([at[0] - w * 0.5, at[1] - h * 0.5, at[0] + w * 0.5, at[1] + h * 0.5])
} else {
None
};
self.paste_image(page, rect)
}
/// Click an image field: pick a picture for it.
pub fn choose_field_image(&mut self, name: &str) {
#[cfg(not(target_arch = "wasm32"))]
+1 -1
View File
@@ -66,7 +66,7 @@ fn read_clipboard() -> Option<Clip> {
cb.get_text().ok().filter(|t| !t.trim().is_empty()).map(Clip::Text)
}
fn png_from_rgba(width: usize, height: usize, rgba: &[u8]) -> Result<Vec<u8>, String> {
pub(crate) fn png_from_rgba(width: usize, height: usize, rgba: &[u8]) -> Result<Vec<u8>, String> {
let (w, h) = (u32::try_from(width).map_err(|e| e.to_string())?, u32::try_from(height).map_err(|e| e.to_string())?);
if w == 0 || h == 0 || rgba.len() != width * height * 4 {
return Err("the clipboard image is empty or malformed".into());
+60 -8
View File
@@ -616,7 +616,7 @@ impl ImageSelection {
/// applies after the pointer leaves the image/page while dragging.
pub(crate) fn image_owns_pointer(ui: &egui::Ui, xf: &PageXform, page: usize, info: &DocInfo, view: &DocView) -> bool {
let Some(selection) = view.image_selection.as_ref().filter(|s| s.page == page) else { return false };
if selection.drag.is_some() {
if selection.drag.is_some() || selection.crop_mode {
return true;
}
let Some(image) = view.edit_images.get(&page).and_then(|(_, images)| images.get(selection.index)) else { return false };
@@ -698,6 +698,7 @@ pub(crate) enum ImageAction {
Copy(usize, usize),
CopyFile(usize, usize),
Cut(usize, usize),
Paste(usize, [f64; 4]),
DrawFreehand,
Redact(usize, usize),
CreateLink(usize, usize),
@@ -858,14 +859,22 @@ pub(crate) fn image_input(
painter.line_segment([Pos2::new(active_crop.left(), y2), Pos2::new(active_crop.right(), y2)], Stroke::new(0.75, ACCENT.gamma_multiply(0.5)));
let crop_handles = [
ImageHandle::TopLeft,
ImageHandle::TopCenter,
ImageHandle::TopRight,
ImageHandle::RightCenter,
ImageHandle::BottomRight,
ImageHandle::BottomCenter,
ImageHandle::BottomLeft,
ImageHandle::LeftCenter,
];
for &h in &crop_handles {
let c = handle_pos(active_crop, h);
let sz = if matches!(h, ImageHandle::TopCenter | ImageHandle::BottomCenter) { egui::vec2(16.0, 7.0) } else { egui::vec2(7.0, 16.0) };
let sz = match h {
ImageHandle::TopCenter | ImageHandle::BottomCenter => egui::vec2(16.0, 6.0),
ImageHandle::LeftCenter | ImageHandle::RightCenter => egui::vec2(6.0, 16.0),
_ => egui::vec2(10.0, 10.0),
};
painter.rect(Rect::from_center_size(c, sz), CornerRadius::same(1), Color32::WHITE, Stroke::new(1.5, ACCENT), egui::StrokeKind::Middle);
}
@@ -878,13 +887,21 @@ pub(crate) fn image_input(
}
if allow_interaction {
if ui.input(|inp| inp.key_pressed(egui::Key::Enter)) {
let enter_pressed = ui.input_mut(|inp| inp.consume_key(egui::Modifiers::NONE, egui::Key::Enter));
let dbl_click = resp.double_clicked() && hover_pos.is_some_and(|p| active_crop.contains(p));
if enter_pressed || dbl_click {
if active_crop != b {
view.pending_edit = Some(Edit::EditPageImage {
page,
index: i,
change: pdfcraft_engine::ImageEdit::Crop(user_box(xf, info, page, active_crop)),
});
} else if images.get(i).is_some_and(|im| im.visible_rect.is_some() && im.visible_rect != Some(im.rect)) {
view.pending_edit = Some(Edit::EditPageImage {
page,
index: i,
change: pdfcraft_engine::ImageEdit::RemoveCrop,
});
}
if let Some(s) = view.image_selection.as_mut() {
s.crop_mode = false;
@@ -892,7 +909,7 @@ pub(crate) fn image_input(
}
return true;
}
if ui.input(|inp| inp.key_pressed(egui::Key::Escape)) {
if ui.input_mut(|inp| inp.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
if let Some(s) = view.image_selection.as_mut() {
s.crop_mode = false;
s.crop_rect = None;
@@ -918,9 +935,13 @@ pub(crate) fn image_input(
if is_crop {
let active_crop = view.image_selection.as_ref().and_then(|s| s.crop_rect).unwrap_or(b);
let crop_handles = [
ImageHandle::TopLeft,
ImageHandle::TopCenter,
ImageHandle::TopRight,
ImageHandle::RightCenter,
ImageHandle::BottomRight,
ImageHandle::BottomCenter,
ImageHandle::BottomLeft,
ImageHandle::LeftCenter,
];
let hit_edge = crop_handles.iter().copied().find(|&h| handle_pos(active_crop, h).distance(o) < 8.0);
@@ -955,6 +976,26 @@ pub(crate) fn image_input(
if is_crop {
let mut new_crop = initial;
match drag.target {
DragTarget::CropHandle(ImageHandle::TopLeft) => {
let x = (initial.left() + delta.x).clamp(b.left(), initial.right() - 10.0);
let y = (initial.top() + delta.y).clamp(b.top(), initial.bottom() - 10.0);
new_crop = Rect::from_min_max(Pos2::new(x, y), initial.max);
}
DragTarget::CropHandle(ImageHandle::TopRight) => {
let x = (initial.right() + delta.x).clamp(initial.left() + 10.0, b.right());
let y = (initial.top() + delta.y).clamp(b.top(), initial.bottom() - 10.0);
new_crop = Rect::from_min_max(Pos2::new(initial.left(), y), Pos2::new(x, initial.bottom()));
}
DragTarget::CropHandle(ImageHandle::BottomLeft) => {
let x = (initial.left() + delta.x).clamp(b.left(), initial.right() - 10.0);
let y = (initial.bottom() + delta.y).clamp(initial.top() + 10.0, b.bottom());
new_crop = Rect::from_min_max(Pos2::new(x, initial.top()), Pos2::new(initial.right(), y));
}
DragTarget::CropHandle(ImageHandle::BottomRight) => {
let x = (initial.right() + delta.x).clamp(initial.left() + 10.0, b.right());
let y = (initial.bottom() + delta.y).clamp(initial.top() + 10.0, b.bottom());
new_crop = Rect::from_min_max(initial.min, Pos2::new(x, y));
}
DragTarget::CropHandle(ImageHandle::TopCenter) => {
let y = (initial.top() + delta.y).clamp(b.top(), initial.bottom() - 10.0);
new_crop = Rect::from_min_max(Pos2::new(initial.left(), y), initial.max);
@@ -1072,8 +1113,15 @@ pub(crate) fn image_input(
&& ui.input(|inp| inp.key_pressed(egui::Key::Delete) || inp.key_pressed(egui::Key::Backspace))
&& !ui.ctx().egui_wants_keyboard_input()
{
view.image_selection = None;
view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Delete });
if is_crop {
if let Some(s) = view.image_selection.as_mut() {
s.crop_mode = false;
s.crop_rect = None;
}
} else {
view.image_selection = None;
view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Delete });
}
return true;
}
}
@@ -1083,6 +1131,7 @@ pub(crate) fn image_input(
let Some(p) = hover_pos.filter(|p| xf.rect.contains(*p)) else { return false };
let Some(hit) = boxes.iter().enumerate().rposition(|(i, b)| b.contains(p) && image_contains(i, p)) else {
let over_popup = egui::Popup::is_any_open(ui.ctx())
|| resp.context_menu_opened()
|| ui.ctx().is_pointer_over_egui()
|| ui.ctx().egui_wants_pointer_input();
if (resp.secondary_clicked() || resp.clicked()) && !over_popup {
@@ -1124,6 +1173,7 @@ pub(crate) fn image_context_menu(
doc: &pdfcraft_engine::Document,
page: usize,
hit: usize,
can_paste: bool,
action: &mut Option<ImageAction>,
) {
let images = doc.page_images(page);
@@ -1171,8 +1221,10 @@ pub(crate) fn image_context_menu(
*action = Some(ImageAction::Copy(page, hit));
ui.close();
}
// Paste item: disabled when no item can be pasted into the image layer
ui.add_enabled(false, egui::Button::new(tl!("Paste")).shortcut_text("Ctrl+V"));
if ui.add_enabled(can_paste, egui::Button::new(tl!("Paste")).shortcut_text("Ctrl+V")).clicked() {
*action = Some(ImageAction::Paste(page, img.rect));
ui.close();
}
ui.separator();
+1 -1
View File
@@ -59,7 +59,7 @@ pub(crate) fn paint(painter: &egui::Painter, xf: &PageXform, page: usize, info:
}
/// A file name without characters file systems refuse.
fn safe_name(s: &str) -> String {
pub(crate) fn safe_name(s: &str) -> String {
let cleaned: String = s.chars().map(|c| if c.is_control() || r#"/\:*?"<>|"#.contains(c) { '_' } else { c }).take(80).collect();
let cleaned = cleaned.trim().trim_matches('.').to_string();
if cleaned.is_empty() { "PdfCraft".into() } else { cleaned }
+37 -9
View File
@@ -704,6 +704,7 @@ pub struct PdfCraftApp {
/// The last image copied in its original format (file name, bytes), also put on the
/// system clipboard as a file when `system_clipboard` is on.
pub last_copied_file: Option<(String, Vec<u8>)>,
pub last_copied_image_rect: Option<[f64; 4]>,
pub system_clipboard: bool,
/// Attach file: the file to attach instead of asking (tests, automation).
pub attach_override: Option<(String, Vec<u8>)>,
@@ -992,6 +993,7 @@ impl PdfCraftApp {
last_snapshot: None,
last_copied_image: None,
last_copied_file: None,
last_copied_image_rect: None,
system_clipboard: true,
attach_override: None,
duplicate_draft: None,
@@ -2682,7 +2684,7 @@ impl PdfCraftApp {
object_ui::nudge_keys(ctx, &mut self.views[i], doc);
}
canvas::shortcuts(&mut self.views[i], ctx);
self.copy_key(i, ctx);
self.clipboard_keys(i, ctx);
self.image_pick_keys(i, ctx);
}
}
@@ -2706,16 +2708,42 @@ impl PdfCraftApp {
false
}
/// ⌘C copies the selected text, unless a text field has the keyboard (it copies its own).
fn copy_key(&mut self, i: usize, ctx: &egui::Context) {
// ⌘C arrives as a Copy event on most platforms.
let copy = ctx.input(|input| input.events.iter().any(|e| matches!(e, egui::Event::Copy)));
if !copy || ctx.egui_wants_keyboard_input() {
/// ⌘C / ⌘X / ⌘V copy, cut and paste text or images on canvas (unless a text field has focus).
fn clipboard_keys(&mut self, i: usize, ctx: &egui::Context) {
use egui::{Key, KeyboardShortcut, Modifiers};
if ctx.egui_wants_keyboard_input() || self.views.get(i).is_some_and(|v| v.forms.focus.is_some() || v.organize) {
return;
}
let Some((id, text)) = self.views.get(i).and_then(|v| Some((v.id, v.selected_text()?))) else { return };
if self.may_copy(id) {
ctx.copy_text(text);
let copy = ctx.input(|input| input.events.iter().any(|e| matches!(e, egui::Event::Copy)))
|| ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::C)));
let cut = ctx.input(|input| input.events.iter().any(|e| matches!(e, egui::Event::Cut)))
|| ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::X)));
let paste = ctx.input(|input| input.events.iter().any(|e| matches!(e, egui::Event::Paste(_))))
|| ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::V)));
if copy {
if let Some(text) = self.views.get(i).and_then(|v| v.selected_text())
&& let Some(id) = self.views.get(i).map(|v| v.id)
&& self.may_copy(id)
{
ctx.copy_text(text);
} else if 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)
&& self.may_copy(id)
{
self.copy_page_image(sel.page, sel.index);
}
}
if cut
&& 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)
&& self.may_copy(id)
{
self.cut_page_image(i, sel.page, sel.index);
}
if paste {
let page = self.views.get(i).map_or(0, |v| v.current);
self.paste_image(page, None);
}
}
+3
View File
@@ -160,6 +160,9 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, p
if view.objects.input_blocked {
return true;
}
if resp.context_menu_opened() || egui::Popup::is_any_open(ui.ctx()) {
return false;
}
if view.content.draft.is_some() || view.line_editor.as_ref().is_some_and(|e| e.has_changes()) {
let blocked = view.objects.blocked_page == Some(page);
let modifier_press = ui.input(|i| i.pointer.primary_pressed())
+1 -1
View File
@@ -1039,7 +1039,7 @@ fn hovering_highlight_without_comment_respects_display_author_option() {
assert!(json.contains("\"display_author_hover_no_comment\":false"));
let mut app2 = PdfCraftApp::new();
app2.restore(&json);
assert_eq!(app2.comment_prefs.display_author_hover_no_comment, false);
assert!(!app2.comment_prefs.display_author_hover_no_comment);
}
#[test]
+87 -1
View File
@@ -2654,6 +2654,8 @@ fn default_page_context_menu_when_not_in_edit_mode() {
assert!(h.query_all_by_label("Add comment").next().is_some());
assert!(h.query_all_by_label("Add text").next().is_some());
assert!(h.query_all_by_label("Add image").next().is_some());
assert!(h.query_all_by_label_contains("Copy").next().is_some());
assert!(h.query_all_by_label_contains("Paste").next().is_some());
// Clicking "Edit this PDF" enters edit mode
h.get_by_label("Edit this PDF").click();
@@ -2694,10 +2696,30 @@ fn image_edge_handle_stretch_and_crop_mode() {
h.run_steps(2);
assert!(h.state().views[0].image_selection.as_ref().is_some_and(|s| s.crop_mode));
// Set smaller crop rect
let smaller = egui::Rect::from_center_size(c, egui::vec2(50.0, 50.0));
h.state_mut().views[0].image_selection.as_mut().unwrap().crop_rect = Some(smaller);
// Confirm crop with Enter key
h.key_press(egui::Key::Enter);
h.run_steps(2);
h.run_steps(4);
assert!(!h.state().views[0].image_selection.as_ref().is_some_and(|s| s.crop_mode));
let doc_id = h.state().views[0].id;
let images = h.state().session.get(doc_id).unwrap().page_images(0);
assert_eq!(images.len(), 1, "image still exists: {images:?}");
assert!(images[0].visible_rect.is_some(), "cropped image is visible");
assert_ne!(images[0].visible_rect, Some(images[0].rect), "image is cropped");
// Remove crop via context menu
h.hover_at(c);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
h.get_by_label("Remove crop").click();
h.run_steps(4);
let images = h.state().session.get(doc_id).unwrap().page_images(0);
assert_eq!(images[0].visible_rect, Some(images[0].rect), "crop is removed");
}
#[test]
@@ -4341,3 +4363,67 @@ fn private_new_chinese_keyboard_input_preserves_font_layout_and_saved_pixels() {
assert_eq!(std::fs::read(path).unwrap(), original);
println!("verified Chinese keyboard insertion/deletion and save/reopen rendering on {pages} pages");
}
#[test]
fn image_copy_cut_paste_keyboard_and_context_menu() {
let mut png = Vec::new();
image::RgbImage::from_pixel(60, 40, image::Rgb([180, 50, 50]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.system_clipboard = false;
app.open_bytes("picture.png", None, png.clone()).expect("opens");
app
});
h.run_steps(4);
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let id = h.state().views[0].id;
let initial_count = h.state().session.get(id).unwrap().page_images(0).len();
assert_eq!(initial_count, 1, "starts with 1 image");
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
let c = r.center();
// 1. Select the image
h.hover_at(c);
h.run_steps(1);
h.drag_at(c);
h.run_steps(1);
h.drop_at(c);
h.run_steps(2);
assert!(h.state().views[0].image_selection.is_some(), "image selected");
// 2. Copy via keyboard shortcut (COMMAND+C / Event::Copy)
h.event(egui::Event::Copy);
h.run_steps(2);
assert!(h.state().last_copied_image.is_some(), "image copied to clipboard buffer");
// 3. Paste via keyboard shortcut (COMMAND+V / Event::Paste)
h.event(egui::Event::Paste("".into()));
h.run_steps(3);
let after_paste = h.state().session.get(id).unwrap().page_images(0).len();
assert_eq!(after_paste, 2, "now has 2 images");
assert_eq!(h.state().views[0].image_selection.as_ref().map(|s| s.index), Some(1), "new image is selected");
// 4. Cut via keyboard shortcut (COMMAND+X / Event::Cut)
h.event(egui::Event::Cut);
h.run_steps(3);
let after_cut = h.state().session.get(id).unwrap().page_images(0).len();
assert_eq!(after_cut, 1, "cut deleted the pasted image");
assert!(h.state().views[0].image_selection.is_none(), "selection cleared after cut");
// 5. Paste again via context menu on the page
h.hover_at(c);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
h.event(egui::Event::PointerButton { pos: c, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
h.get_by_label_contains("Paste").click();
h.run_steps(3);
let after_paste2 = h.state().session.get(id).unwrap().page_images(0).len();
assert_eq!(after_paste2, 2, "pasted via context menu");
}