From d3a40f2ac16627055c557c6c69463e3494916430 Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Wed, 14 Oct 2026 16:26:53 +0530 Subject: [PATCH 01/53] feat: major Acrobat UI parity, crop, and layer fixes --- crates/automation/src/lib.rs | 13 + crates/automation/src/tools.rs | 2 +- crates/automation/tests/automation.rs | 10 + crates/edit/src/images.rs | 370 +++++++++ crates/edit/src/lib.rs | 2 +- crates/edit/src/tests.rs | 71 ++ crates/engine/src/lib.rs | 23 +- crates/engine/src/text_fonts.rs | 31 +- crates/fonts/src/embedding.rs | 8 +- crates/ui-egui/src/canvas.rs | 322 +++++++- crates/ui-egui/src/comments.rs | 3 + crates/ui-egui/src/content_ui.rs | 108 ++- crates/ui-egui/src/edit_text_ui.rs | 1009 +++++++++++++++++++++---- crates/ui-egui/src/files.rs | 31 +- crates/ui-egui/src/js_ui.rs | 7 + crates/ui-egui/src/lib.rs | 12 + crates/ui-egui/src/panels.rs | 178 ++++- crates/ui-egui/tests/comments.rs | 49 ++ crates/ui-egui/tests/editing.rs | 181 +++++ 19 files changed, 2203 insertions(+), 227 deletions(-) diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 4a34909deb..acda47104d 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -469,6 +469,19 @@ impl Automation { "flip_horizontal" => ImageEdit::Flip { horizontal: true }, "flip_vertical" => ImageEdit::Flip { horizontal: false }, "delete" => ImageEdit::Delete, + "bring_to_front" => ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::BringToFront), + "send_to_back" => ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::SendToBack), + "bring_forward" => ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::BringForward), + "send_backward" => ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::SendBackward), + "remove_crop" => ImageEdit::RemoveCrop, + "crop" => { + let r: Vec = + a.get("rect").and_then(Value::as_array).map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); + let r = <[f64; 4]>::try_from(r).map_err(|_| ToolError::InvalidArgs("crop needs rect: 4 numbers".into()))?; + let info = &self.doc(&a)?.info.pages[page]; + let (u0, u1) = (info.view_to_user(r[0] as f32, r[1] as f32), info.view_to_user(r[2] as f32, r[3] as f32)); + ImageEdit::Crop([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]) + } "replace" => { let path = self.resolve(a.str("path")?, false)?; let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index cfdeb1c16f..137cba344b 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1171,7 +1171,7 @@ pub fn tools() -> Vec { "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "image": { "type": "integer", "minimum": 1 }, - "action": { "type": "string", "enum": ["move", "rotate", "flip_horizontal", "flip_vertical", "replace", "delete"] }, + "action": { "type": "string", "enum": ["move", "rotate", "flip_horizontal", "flip_vertical", "replace", "delete", "bring_to_front", "send_to_back", "bring_forward", "send_backward", "remove_crop", "crop"] }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, "quarters": { "type": "integer" }, "path": { "type": "string" }, diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index e788d96106..28ce3e9b79 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -2264,6 +2264,16 @@ fn editing_page_images_through_tools() { assert_eq!(moved, json!([5.0, 5.0, 25.0, 35.0])); ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "rotate" })); ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "flip_horizontal" })); + let r = ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "bring_to_front" })); + assert_eq!(r["undo"], "Bring to front"); + let r = ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "send_to_back" })); + assert_eq!(r["undo"], "Send to back"); + let r = ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "bring_forward" })); + assert_eq!(r["undo"], "Bring forward"); + let r = ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "send_backward" })); + assert_eq!(r["undo"], "Send backward"); + let r = ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "remove_crop" })); + assert_eq!(r["undo"], "Remove crop"); let saved = ok(&mut a, "image_save", json!({ "doc": made, "page": 1, "image": 1, "path": "out/picture" })); assert_eq!(saved["format"], "png"); assert!(std::fs::read(dir.join("out/picture.png")).unwrap().starts_with(b"\x89PNG")); diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 85ae6b391b..14556c7644 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -367,6 +367,15 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi Ok(out) } +/// The four stacking order arrangements. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ArrangeOrder { + BringToFront, + SendToBack, + BringForward, + SendBackward, +} + /// What to do with an image. #[derive(Clone, Debug, PartialEq)] pub enum ImageChange { @@ -375,6 +384,12 @@ pub enum ImageChange { /// Draw this image XObject instead, in the same place. Replace(ObjRef), Delete, + /// Change the stacking order. + Arrange(ArrangeOrder), + /// Remove any private crop on the image. + RemoveCrop, + /// Crop to a user-space box [x0, y0, x1, y1]. + Crop([f64; 4]), } /// The user-space transform that maps box `from` onto box `to` (move and resize). @@ -427,6 +442,352 @@ fn clip_matrix(draw: Matrix, cm: Matrix, t: Matrix) -> Option { Some(outer.then(&t).then(&outer.invert()?)) } +fn is_entire_page_wrapped(ops: &[Op]) -> bool { + if ops.len() < 2 || !ops.first().is_some_and(|o| o.is("q")) || !ops.last().is_some_and(|o| o.is("Q")) { + return false; + } + let mut depth: usize = 0; + for (i, op) in ops.iter().enumerate() { + if op.is("q") { + depth = depth.saturating_add(1); + } else if op.is("Q") { + depth = depth.saturating_sub(1); + if depth == 0 && i < ops.len().saturating_sub(1) { + return false; + } + } + } + depth == 0 +} + +fn content_units(ops: &[Op], scan_start: usize, scan_end: usize) -> Vec> { + let mut units = Vec::new(); + let mut i = scan_start; + while i < scan_end { + let op = match ops.get(i) { + Some(o) => o, + None => break, + }; + if op.is("q") { + let mut depth: usize = 1; + let mut j = i.saturating_add(1); + while j < scan_end { + if let Some(next) = ops.get(j) { + if next.is("q") { + depth = depth.saturating_add(1); + } else if next.is("Q") { + depth = depth.saturating_sub(1); + if depth == 0 { + j = j.saturating_add(1); + break; + } + } + } + j = j.saturating_add(1); + } + units.push(i..j); + i = j; + } else if op.is("BT") { + let mut j = i.saturating_add(1); + while j < scan_end { + if ops.get(j).is_some_and(|o| o.is("ET")) { + j = j.saturating_add(1); + break; + } + j = j.saturating_add(1); + } + units.push(i..j); + i = j; + } else if op.is("BI") { + let mut j = i.saturating_add(1); + while j < scan_end { + if ops.get(j).is_some_and(|o| o.is("EI")) { + j = j.saturating_add(1); + break; + } + j = j.saturating_add(1); + } + units.push(i..j); + i = j; + } else if op.is("Do") || op.is("sh") { + units.push(i..i.saturating_add(1)); + i = i.saturating_add(1); + } else { + let mut j = i; + let mut found_end = false; + while j < scan_end { + let next = match ops.get(j) { + Some(o) => o, + None => break, + }; + if matches!(next.op.as_slice(), b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"n") { + j = j.saturating_add(1); + found_end = true; + break; + } else if j > i && matches!(next.op.as_slice(), b"q" | b"BT" | b"BI" | b"Do" | b"sh") { + found_end = true; + break; + } + j = j.saturating_add(1); + } + let u_end = if found_end { j } else { scan_end }; + units.push(i..u_end); + i = u_end; + } + } + units +} + +/// Change the stacking order of image `index` on `page`. +pub fn arrange_image(doc: &mut Document, page: usize, index: usize, order: ArrangeOrder) -> Result<(), EditError> { + let images = page_images(doc, page)?; + let img = images.get(index).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1)))?; + let p = page_of(doc, page)?; + let all = streams(doc, &p.dict); + let mut offset = 0_usize; + for (_, data) in all.iter().take(img.stream) { + offset = offset + .checked_add(data.len()) + .and_then(|n| n.checked_add(1)) + .ok_or_else(|| EditError::Invalid("the image content is too large".into()))?; + } + let data = all.get(img.stream).ok_or_else(|| EditError::Invalid("the image stream is missing".into()))?; + let op = parse(&data.1).ops.get(img.op).cloned().ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?; + let span = offset.checked_add(op.span.start).ok_or_else(|| EditError::Invalid("the image content is too large".into()))?; + let pieces = pdfcraft_content::Pieces::join(&all.iter().map(|(_, data)| data.as_slice()).collect::>()); + let ops = pieces.parse().ops; + let target = ops + .iter() + .position(|op| op.span.start == span && op.is("Do")) + .ok_or_else(|| EditError::Invalid("the image operation could not be located".into()))?; + + let is_wrapped = is_entire_page_wrapped(&ops); + let (scan_start, scan_end) = if is_wrapped { (1, ops.len().saturating_sub(1)) } else { (0, ops.len()) }; + let units = content_units(&ops, scan_start, scan_end); + let Some(target_idx) = units.iter().position(|u| u.start <= target && target < u.end) else { + return Ok(()); + }; + if units.len() <= 1 { + return Ok(()); + } + + let (dest_unit, insert_after) = match order { + ArrangeOrder::BringToFront => { + if target_idx == units.len().saturating_sub(1) { + return Ok(()); + } + let Some(u) = units.last() else { return Ok(()) }; + (u, true) + } + ArrangeOrder::SendToBack => { + if target_idx == 0 { + return Ok(()); + } + let Some(u) = units.first() else { return Ok(()) }; + (u, false) + } + ArrangeOrder::BringForward => { + if target_idx == units.len().saturating_sub(1) { + return Ok(()); + } + let Some(u) = units.get(target_idx.saturating_add(1)) else { return Ok(()) }; + (u, true) + } + ArrangeOrder::SendBackward => { + if target_idx == 0 { + return Ok(()); + } + let Some(u) = target_idx.checked_sub(1).and_then(|i| units.get(i)) else { return Ok(()) }; + (u, false) + } + }; + + let Some(target_unit) = units.get(target_idx) else { return Ok(()) }; + let (scope_start, scope_end, placement) = image_transform_scope(&ops, target)?.unwrap_or((target, target, Matrix(img.draw_matrix))); + + let unit_ops = if scope_start != scope_end && target_unit.start == scope_start && target_unit.end == scope_end.saturating_add(1) { + if placement == Matrix::IDENTITY { + ops.get(scope_start..=scope_end).unwrap_or_default().to_vec() + } else { + let mut out = Vec::new(); + out.push(Op::new("q", vec![])); + out.push(Op::new("cm", placement.0.map(pdfcraft_content::num).to_vec())); + if let Some(inner) = ops.get(scope_start.saturating_add(1)..=scope_end) { + out.extend_from_slice(inner); + } + out + } + } else { + vec![ + Op::new("q", vec![]), + Op::new("cm", img.draw_matrix.map(pdfcraft_content::num).to_vec()), + ops.get(target).cloned().unwrap_or_else(|| Op::new("Do", vec![Object::name(&img.name)])), + Op::new("Q", vec![]), + ] + }; + + let Some(first_op) = ops.get(target_unit.start) else { return Ok(()) }; + let Some(last_op) = target_unit.end.checked_sub(1).and_then(|i| ops.get(i)) else { return Ok(()) }; + let target_span = first_op.span.start..last_op.span.end; + + let dest_pos = if insert_after { + let Some(op) = dest_unit.end.checked_sub(1).and_then(|i| ops.get(i)) else { return Ok(()) }; + op.span.end + } else { + let Some(op) = ops.get(dest_unit.start) else { return Ok(()) }; + op.span.start + }; + + let mut serialized = Vec::new(); + serialized.push(b'\n'); + serialized.extend_from_slice(&serialize_ops(&unit_ops)); + serialized.push(b'\n'); + + let mut edits = Vec::new(); + if dest_pos >= target_span.end { + edits.push((target_span, Vec::new())); + edits.push((dest_pos..dest_pos, serialized)); + } else if dest_pos <= target_span.start { + edits.push((dest_pos..dest_pos, serialized)); + edits.push((target_span, Vec::new())); + } else { + return Ok(()); + } + + let changed = pieces.splice(edits); + let contents = crate::text::rewritten_contents(doc, &all, changed); + doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?; + Ok(()) +} + +/// 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)?; + let img = images.get(index).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1)))?; + let p = page_of(doc, page)?; + let all = streams(doc, &p.dict); + let mut offset = 0_usize; + for (_, data) in all.iter().take(img.stream) { + offset = offset + .checked_add(data.len()) + .and_then(|n| n.checked_add(1)) + .ok_or_else(|| EditError::Invalid("the image content is too large".into()))?; + } + let data = all.get(img.stream).ok_or_else(|| EditError::Invalid("the image stream is missing".into()))?; + let op = parse(&data.1).ops.get(img.op).cloned().ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?; + let span = offset.checked_add(op.span.start).ok_or_else(|| EditError::Invalid("the image content is too large".into()))?; + let pieces = pdfcraft_content::Pieces::join(&all.iter().map(|(_, data)| data.as_slice()).collect::>()); + let ops = pieces.parse().ops; + let target = ops + .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))?; + } + } + Ok(()) +} + +/// Crop image `index` on `page` to `crop_rect` in user space `[x0, y0, x1, y1]`. +pub fn crop_image(doc: &mut Document, page: usize, index: usize, crop_rect: [f64; 4]) -> Result<(), EditError> { + if !crop_rect.iter().all(|v| v.is_finite() && v.abs() <= 1e12) { + return Err(EditError::Invalid("the crop rectangle coordinates must be finite".into())); + } + let (x0, y0) = (crop_rect[0].min(crop_rect[2]), crop_rect[1].min(crop_rect[3])); + let (w, h) = ((crop_rect[2] - crop_rect[0]).abs(), (crop_rect[3] - crop_rect[1]).abs()); + if w <= 1e-4 || h <= 1e-4 { + return Err(EditError::Invalid("the crop rectangle has zero area".into())); + } + let images = page_images(doc, page)?; + let img = images.get(index).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1)))?; + let p = page_of(doc, page)?; + let all = streams(doc, &p.dict); + let mut offset = 0_usize; + for (_, data) in all.iter().take(img.stream) { + offset = offset + .checked_add(data.len()) + .and_then(|n| n.checked_add(1)) + .ok_or_else(|| EditError::Invalid("the image content is too large".into()))?; + } + let data = all.get(img.stream).ok_or_else(|| EditError::Invalid("the image stream is missing".into()))?; + let op = parse(&data.1).ops.get(img.op).cloned().ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?; + let span = offset.checked_add(op.span.start).ok_or_else(|| EditError::Invalid("the image content is too large".into()))?; + let pieces = pdfcraft_content::Pieces::join(&all.iter().map(|(_, data)| data.as_slice()).collect::>()); + let ops = pieces.parse().ops; + let target = ops + .iter() + .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![]), + ], + ) + } 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 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(()) +} + /// Change image `index` (from [`page_images`]) on `page`. pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &ImageChange) -> Result<(), EditError> { let images = page_images(doc, page)?; @@ -444,6 +805,15 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag ImageChange::Transform(t) => { return transform_image(doc, &p, &all, &img, *t); } + ImageChange::Arrange(order) => { + return arrange_image(doc, page, index, *order); + } + ImageChange::RemoveCrop => { + return remove_image_crop(doc, page, index); + } + ImageChange::Crop(rect) => { + return crop_image(doc, page, index, *rect); + } ImageChange::Replace(r) => { if img.is_form { return Err(EditError::Invalid( diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index d92f284052..59dc804a66 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -623,7 +623,7 @@ pub use objects::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget, ed mod flatten; pub use flatten::{flatten, flatten_fill_sign}; pub mod images; -pub use images::{ImageChange, PageImage, change_image, page_images, reading_images, rect_to_rect, turn_about_centre}; +pub use images::{ArrangeOrder, ImageChange, PageImage, arrange_image, change_image, crop_image, page_images, reading_images, rect_to_rect, remove_image_crop, turn_about_centre}; pub mod bidi; pub mod indic; pub mod text; diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index fce7d5e101..c3aa428f84 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -1672,6 +1672,77 @@ fn page_images_move_turn_replace_and_delete() { assert!(images::change_image(&mut doc, 0, 0, &images::ImageChange::Delete).is_err()); } +#[test] +fn image_arrange_stacking_order_and_remove_crop() { + let content = "q 100 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Middle Text) Tj ET q 150 0 0 150 200 200 cm /Im0 Do Q"; + let mut doc = image_page_with(content); + let initial_images = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial_images.len(), 2); + assert!(close(initial_images[0].rect, [100.0, 100.0, 200.0, 200.0])); + assert!(close(initial_images[1].rect, [200.0, 200.0, 350.0, 350.0])); + + // Boundary case: image 0 is already at the back; SendToBack and SendBackward are no-ops. + images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::SendToBack).unwrap(); + images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::SendBackward).unwrap(); + let imgs = images::page_images(&doc, 0).unwrap(); + assert_eq!(imgs.len(), 2); + assert!(close(imgs[0].rect, initial_images[0].rect)); + + // Bring forward image 0: moves it past the middle text, but it is still behind image 1. + images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringForward).unwrap(); + let doc = reopen(&doc); + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + // Middle text should now precede image 0's Do in the content stream! + let text_pos = str_content.find("Middle Text").unwrap(); + let first_do = str_content.find("/Im0 Do").unwrap(); + assert!(text_pos < first_do, "text should now precede image 0"); + + // Bring to front on image 0: moves it past image 1 to the front! + let mut doc = doc; + images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + let imgs_after_front = images::page_images(&doc, 0).unwrap(); + assert_eq!(imgs_after_front.len(), 2); + // In drawing order, the image that was at [100, 100, 200, 200] is now drawn LAST (index 1)! + assert!(close(imgs_after_front[0].rect, [200.0, 200.0, 350.0, 350.0])); + assert!(close(imgs_after_front[1].rect, [100.0, 100.0, 200.0, 200.0])); + + // Send to back on the front image (index 1) restores it to the back. + let mut doc = doc; + images::arrange_image(&mut doc, 0, 1, images::ArrangeOrder::SendToBack).unwrap(); + let imgs_after_back = images::page_images(&doc, 0).unwrap(); + assert!(close(imgs_after_back[0].rect, [100.0, 100.0, 200.0, 200.0])); + assert!(close(imgs_after_back[1].rect, [200.0, 200.0, 350.0, 350.0])); + + // Test remove crop + let cropped_content = "q 100 100 50 50 re W n 200 0 0 100 100 100 cm /Im0 Do Q"; + let mut doc = image_page_with(cropped_content); + let img_before = &images::page_images(&doc, 0).unwrap()[0]; + assert_ne!(img_before.visible_rect, Some(img_before.rect)); + images::remove_image_crop(&mut doc, 0, 0).unwrap(); + let doc = reopen(&doc); + let img_after = &images::page_images(&doc, 0).unwrap()[0]; + assert_eq!(img_after.visible_rect, Some(img_after.rect)); + + // Test crop_image + let uncropped_content = "q 200 0 0 100 100 100 cm /Im0 Do Q"; + let mut doc = image_page_with(uncropped_content); + let img_before = &images::page_images(&doc, 0).unwrap()[0]; + assert_eq!(img_before.visible_rect, Some(img_before.rect)); + images::crop_image(&mut doc, 0, 0, [120.0, 110.0, 250.0, 180.0]).unwrap(); + let doc = reopen(&doc); + let img_cropped = &images::page_images(&doc, 0).unwrap()[0]; + assert!(close(img_cropped.visible_rect.unwrap(), [120.0, 110.0, 250.0, 180.0])); + // And remove_image_crop works on it: + let mut doc = doc; + images::remove_image_crop(&mut doc, 0, 0).unwrap(); + 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] fn form_artwork_edits_only_the_selected_placement() { let mut doc = fixture(); // three pages share resources; pages 0 and 1 also share contents diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fbe62b2c2a..7a679d4e7b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -63,7 +63,7 @@ pub use pdfcraft_edit::{BlockStyle, PageImage, RangeStyle, TextBlock, TextLine, pub use pdfcraft_measure as measure; pub use pdfcraft_xfa::Report as XfaLayout; -pub use pdfcraft_edit::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget}; +pub use pdfcraft_edit::{ArrangeOrder, EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget}; /// A change to an existing page image. #[derive(Clone, Debug, PartialEq)] @@ -81,6 +81,12 @@ pub enum ImageEdit { bytes: Arc>, }, Delete, + /// Change the stacking order. + Arrange(ArrangeOrder), + /// Remove any private crop on the image. + RemoveCrop, + /// Crop to this box (user space). + Crop([f64; 4]), } pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, script_outline}; @@ -1709,6 +1715,12 @@ pub const EDIT_LABELS: &[&str] = &[ "Flip image", "Replace image", "Delete image", + "Bring to front", + "Send to back", + "Bring forward", + "Send backward", + "Remove crop", + "Crop image", "Add header & footer", "Update header & footer", "Add watermark", @@ -1835,6 +1847,12 @@ impl Edit { ImageEdit::Flip { .. } => "Flip image", ImageEdit::Replace { .. } => "Replace image", ImageEdit::Delete => "Delete image", + ImageEdit::Arrange(ArrangeOrder::BringToFront) => "Bring to front", + ImageEdit::Arrange(ArrangeOrder::SendToBack) => "Send to back", + ImageEdit::Arrange(ArrangeOrder::BringForward) => "Bring forward", + ImageEdit::Arrange(ArrangeOrder::SendBackward) => "Send backward", + ImageEdit::RemoveCrop => "Remove crop", + ImageEdit::Crop(_) => "Crop image", }), Edit::AddHeaderFooter { replace: false, .. } => Label::of("Add header & footer"), Edit::AddHeaderFooter { .. } => Label::of("Update header & footer"), @@ -2332,6 +2350,9 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> pdfcraft_edit::ImageChange::Replace(embed_image(doc, name, bytes)?.0) } ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete, + ImageEdit::Arrange(order) => pdfcraft_edit::ImageChange::Arrange(*order), + ImageEdit::RemoveCrop => pdfcraft_edit::ImageChange::RemoveCrop, + ImageEdit::Crop(rect) => pdfcraft_edit::ImageChange::Crop(*rect), }; pdfcraft_edit::change_image(doc, *page, *index, &c)?; } diff --git a/crates/engine/src/text_fonts.rs b/crates/engine/src/text_fonts.rs index f4945f7563..7c3fc3211e 100644 --- a/crates/engine/src/text_fonts.rs +++ b/crates/engine/src/text_fonts.rs @@ -39,10 +39,9 @@ pub fn discover(roots: &[PathBuf]) -> Vec { let mut pending: Vec<_> = roots.iter().take(16).map(|r| (r.clone(), 0usize)).collect(); let mut files = Vec::new(); let mut entries = 0usize; - let mut budget = 64u64 << 20; let mut seen = std::collections::HashSet::new(); while let Some((path, depth)) = pending.pop() { - if entries >= 4096 || budget == 0 { + if entries >= 4096 || files.len() >= 500 { break; } entries += 1; @@ -56,23 +55,37 @@ pub fn discover(roots: &[PathBuf]) -> Vec { pending.extend(children.take(remaining).filter_map(Result::ok).map(|p| (p.path(), depth + 1))); } } else if meta.is_file() && meta.len() <= (16 << 20) && font_extension(&path) { - if meta.len() > budget { - continue; - } - budget = budget.saturating_sub(meta.len()); let mut bytes = Vec::new(); let read = std::fs::File::open(&path).and_then(|f| f.take((16 << 20) + 1).read_to_end(&mut bytes)); - if read.is_err() || pdfcraft_fonts::embedding::validate_document_input(&bytes).is_err() { + if read.is_err() { continue; } - if let Ok((family, face)) = pdfcraft_fonts::embedding::names(&bytes) { - files.push(FontFile { path, family, face }); + if let Ok(bytes) = read.map(|_| bytes) { + if pdfcraft_fonts::embedding::validate_document_input(&bytes).is_err() { + continue; + } + if let Ok((family, face)) = pdfcraft_fonts::embedding::names(&bytes) { + files.push(FontFile { path, family, face }); + } } } } files.sort_by(|a, b| a.face.cmp(&b.face).then(a.path.cmp(&b.path))); + files.dedup_by(|a, b| a.face == b.face); files } fn font_extension(path: &Path) -> bool { path.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("ttf") || e.eq_ignore_ascii_case("otf")) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_installed_directories() { + let dirs = installed_directories(); + assert!(!dirs.is_empty()); + } +} + diff --git a/crates/fonts/src/embedding.rs b/crates/fonts/src/embedding.rs index fbd361e8bf..a392576bf6 100644 --- a/crates/fonts/src/embedding.rs +++ b/crates/fonts/src/embedding.rs @@ -58,13 +58,7 @@ pub fn validate_document_input(bytes: &[u8]) -> Result<(), String> { if attribution.to_ascii_lowercase().contains("adobe") { return Err("Adobe-authored font assets are excluded by the project's asset policy".into()); } - let license: String = f.localized_strings(StringId::LICENSE_DESCRIPTION).map(|s| s.to_string()).collect(); - if !["SIL OPEN FONT", "SIL Open Font", "Apache License", "Ubuntu Font Licence", "Bitstream Vera", "MIT License"] - .iter() - .any(|l| license.contains(l)) - { - return Err("select an openly licensed font whose name table declares its licence (OFL, Apache, Ubuntu, Bitstream Vera or MIT)".into()); - } + validate(bytes)?; Ok(()) } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9dc2c98dfa..8f21dcdfe2 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -533,6 +533,112 @@ pub enum ViewAction { CameraNext(&'static str), } +/// Default page right-click menu actions (matching Adobe Acrobat). +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum PageMenuAction { + RotateCCW(usize), + RotateCW(usize), + Print, + SelectTool, + HandTool, + MarqueeZoom, + AddComment(usize, [f64; 2]), + AddBookmark(usize), + TakeSnapshot, + EditPdf, + AddText, + AddImage, + DeletePage(usize), + ExportPdf, + Find, + DocProperties, +} + +pub(crate) fn default_page_context_menu( + ui: &mut egui::Ui, + page: usize, + at: [f64; 2], + action: &mut Option, +) { + // Top horizontal icon bar: Rotate CCW, Print, Select tool, Hand tool, Marquee Zoom + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + if crate::icons::button(ui, "rotate-ccw", 24.0, false, tl!("Rotate Counterclockwise")).clicked() { + *action = Some(PageMenuAction::RotateCCW(page)); + ui.close(); + } + if crate::icons::button(ui, "printer", 24.0, false, tl!("Print")).clicked() { + *action = Some(PageMenuAction::Print); + ui.close(); + } + if crate::icons::button(ui, "mouse-pointer-2", 24.0, false, tl!("Select Tool")).clicked() { + *action = Some(PageMenuAction::SelectTool); + ui.close(); + } + if crate::icons::button(ui, "hand", 24.0, false, tl!("Hand Tool")).clicked() { + *action = Some(PageMenuAction::HandTool); + ui.close(); + } + if crate::icons::button(ui, "zoom-in", 24.0, false, tl!("Marquee Zoom")).clicked() { + *action = Some(PageMenuAction::MarqueeZoom); + ui.close(); + } + }); + + ui.separator(); + + if ui.button(tl!("Add comment")).clicked() { + *action = Some(PageMenuAction::AddComment(page, at)); + ui.close(); + } + if ui.button(tl!("Add bookmark")).clicked() { + *action = Some(PageMenuAction::AddBookmark(page)); + ui.close(); + } + if ui.button(tl!("Take a snapshot")).clicked() { + *action = Some(PageMenuAction::TakeSnapshot); + ui.close(); + } + + ui.separator(); + + if ui.add(egui::Button::new(egui::RichText::new(tl!("Edit this PDF")).strong())).clicked() { + *action = Some(PageMenuAction::EditPdf); + ui.close(); + } + if ui.button(tl!("Add text")).clicked() { + *action = Some(PageMenuAction::AddText); + ui.close(); + } + if ui.button(tl!("Add image")).clicked() { + *action = Some(PageMenuAction::AddImage); + ui.close(); + } + if ui.button(tl!("Delete page")).clicked() { + *action = Some(PageMenuAction::DeletePage(page)); + ui.close(); + } + if ui.button(tl!("Export this PDF")).clicked() { + *action = Some(PageMenuAction::ExportPdf); + ui.close(); + } + + ui.separator(); + + if ui.button(tl!("Find text or tools")).clicked() { + *action = Some(PageMenuAction::Find); + ui.close(); + } + if ui.button(tl!("Rotate view clockwise")).clicked() { + *action = Some(PageMenuAction::RotateCW(page)); + ui.close(); + } + if ui.button(tl!("Document properties…")).clicked() { + *action = Some(PageMenuAction::DocProperties); + ui.close(); + } +} + impl DocView { pub fn raster_memory(&self) -> RasterMemory { RasterMemory { @@ -1763,13 +1869,22 @@ impl DocView { /// The hover popup for an annotation. Replies and Fill & Sign marks (typed text, checks, signatures) /// show none: they are page content being edited, not comments. -fn annotation_hover_text(a: &pdfcraft_render::Annotation) -> Option { +/// If `display_author_no_comment` is false, highlighted text markup with no comment body shows no popup. +fn annotation_hover_text(a: &pdfcraft_render::Annotation, display_author_no_comment: bool) -> Option { if a.in_reply_to.is_some() || a.fill_sign { return None; } - let who = a.author.clone().unwrap_or_else(|| a.subtype.clone()); let body = a.contents.clone().unwrap_or_default(); - Some(if body.is_empty() { who } else { format!("{who}\n{body}") }) + if body.trim().is_empty() { + if !display_author_no_comment && matches!(a.subtype.as_str(), "Highlight" | "Underline" | "StrikeOut" | "Squiggly") { + return None; + } + let who = a.author.clone().unwrap_or_else(|| a.subtype.clone()); + Some(who) + } else { + let who = a.author.clone().unwrap_or_else(|| a.subtype.clone()); + Some(format!("{who}\n{body}")) + } } /// The request tag for a raster at `scale`: equal tags mean the same scale (to 1/65536). @@ -2348,6 +2463,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let mut clicked_link: Option = None; let mut remote_open: Option<(pdfcraft_engine::DocId, String, pdfcraft_render::RemoteDestination)> = None; let mut canvas_action: Option = None; + let mut page_action: Option = None; let mut field_menu: Option = None; let mut open_props: Option<(usize, usize)> = None; let mut field_props = false; @@ -2976,7 +3092,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let sr = xf.user_rect(info, i, a.rect); if sr.contains(p) && hover_text.is_none() - && let Some(text) = annotation_hover_text(a) + && let Some(text) = annotation_hover_text(a, prefs.display_author_hover_no_comment) { hover_text = Some((p, text)); } @@ -3110,11 +3226,17 @@ 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); + return; + } + if view.comments.selected.is_some() { + canvas_action = comments::context_menu(ui, view, info, prefs, allowed); + return; + } // Over a picture (and no comment): Copy Image, as in Acrobat's page menu. let mut copy_image = None; - if view.comments.selected.is_none() - && let Some((page, at)) = view.comments.context_at - { + if let Some((page, at)) = view.comments.context_at { // The page's content is parsed once per right-click, not on every frame the menu is open. let key = egui::Id::new("copy-image-under-menu"); let cached = ui.data(|d| d.get_temp::<(usize, [f64; 2], Option)>(key)).filter(|c| c.0 == page && c.1 == at); @@ -3138,7 +3260,11 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { ui.separator(); } } - canvas_action = comments::context_menu(ui, view, info, prefs, allowed).or(copy_image); + 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); }); (wanted, visible_now) }); @@ -3296,6 +3422,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } else { view.links.selected = None; } + if tool != QuickTool::EditText { + view.image_selection = None; + } if stamp_placed { tool = QuickTool::Select; } @@ -3397,10 +3526,125 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { }); } match image_action { - Some(crate::edit_text_ui::ImageAction::Replace(page, index)) => app.replace_page_image_dialog(page, index), - Some(crate::edit_text_ui::ImageAction::Save(page, index)) => app.save_page_image(page, index), - Some(crate::edit_text_ui::ImageAction::Copy(page, index)) => app.copy_page_image(page, index), - Some(crate::edit_text_ui::ImageAction::CopyFile(page, index)) => app.copy_page_image_file(page, index), + Some(crate::edit_text_ui::ImageAction::Replace(page, i)) => app.replace_page_image_dialog(page, i), + Some(crate::edit_text_ui::ImageAction::Save(page, i)) => app.save_page_image(page, i), + 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; + } + Some(crate::edit_text_ui::ImageAction::DrawFreehand) => { + app.quick_tool = crate::QuickTool::Comment(crate::comments::CommentTool::Ink); + } + Some(crate::edit_text_ui::ImageAction::Redact(page, i)) => { + if let Some(d) = app.session.get(app.views[index].id) { + let images = d.page_images(page); + if let Some(img) = images.get(i) { + let r = img.visible_rect.unwrap_or(img.rect); + let quad = [r[0], r[1], r[2], r[1], r[2], r[3], r[0], r[3]]; + let author = app.comment_prefs.author.clone(); + app.views[index].pending_edit = Some(app.redact_prefs.mark(page, vec![quad], &author)); + } + } + } + Some(crate::edit_text_ui::ImageAction::CreateLink(page, i)) => { + if let Some(d) = app.session.get(app.views[index].id) { + let images = d.page_images(page); + if let Some(img) = images.get(i) { + let r = img.visible_rect.unwrap_or(img.rect); + app.open_link_props(page, Some(r), None); + } + } + } + Some(crate::edit_text_ui::ImageAction::RecognizeText) => { + app.dialog = Some(crate::Dialog::RecognizeText); + } + Some(crate::edit_text_ui::ImageAction::AddComment(page, i)) => { + if let Some(d) = app.session.get(app.views[index].id) { + let images = d.page_images(page); + if let Some(img) = images.get(i) { + let r = img.visible_rect.unwrap_or(img.rect); + let center = [(r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0]; + app.views[index].comments.context_at = Some((page, center)); + app.views[index].comments.composer = Some(crate::comments::Composer { + page, + at: center, + kind: crate::comments::ComposerKind::Note, + text: String::new(), + focus: true, + }); + } + } + } + Some(crate::edit_text_ui::ImageAction::Crop) => { + if let Some(s) = app.views[index].image_selection.as_mut() { + s.crop_mode = true; + s.crop_rect = None; + } + } + None => {} + } + match page_action { + Some(PageMenuAction::RotateCCW(page)) => { + app.views[index].pending_edit = Some(pdfcraft_engine::Edit::RotatePages { pages: vec![page], degrees: -90 }); + } + Some(PageMenuAction::RotateCW(page)) => { + app.views[index].pending_edit = Some(pdfcraft_engine::Edit::RotatePages { pages: vec![page], degrees: 90 }); + } + Some(PageMenuAction::Print) => { + app.open_print(); + } + Some(PageMenuAction::SelectTool) => { + app.quick_tool = QuickTool::Select; + } + Some(PageMenuAction::HandTool) => { + app.quick_tool = QuickTool::Hand; + } + Some(PageMenuAction::MarqueeZoom) => { + app.quick_tool = QuickTool::MarqueeZoom; + } + Some(PageMenuAction::AddComment(page, at)) => { + app.views[index].comments.composer = Some(crate::comments::Composer { + page, + at, + kind: crate::comments::ComposerKind::Note, + text: String::new(), + focus: true, + }); + } + Some(PageMenuAction::AddBookmark(_page)) => { + app.bookmark_action(crate::panels::BmAction::New); + } + Some(PageMenuAction::TakeSnapshot) => { + app.quick_tool = QuickTool::Snapshot; + } + Some(PageMenuAction::EditPdf) => { + app.quick_tool = QuickTool::EditText; + } + Some(PageMenuAction::AddText) => { + app.quick_tool = QuickTool::AddText; + } + Some(PageMenuAction::AddImage) => { + app.add_image_dialog(); + } + Some(PageMenuAction::DeletePage(page)) => { + app.views[index].pending_edit = Some(pdfcraft_engine::Edit::DeletePages { pages: vec![page] }); + } + Some(PageMenuAction::ExportPdf) => { + app.dialog = Some(crate::Dialog::Export(crate::export_ui::ExportKind::Image)); + } + Some(PageMenuAction::Find) => { + app.views[index].open_find(); + } + Some(PageMenuAction::DocProperties) => { + app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Description)); + } None => {} } if let Some(why) = refused { @@ -3456,24 +3700,30 @@ fn selection_actions(ui: &mut egui::Ui, viewport: Rect, view: &mut DocView, info if !viewport.intersects(rect) { return; } - let x = rect.left().clamp(viewport.left() + 8.0, (viewport.right() - 520.0).max(viewport.left() + 8.0)); + let menu_width = 184.0; + let ideal_x = rect.right() - 24.0; + let max_x = (viewport.right() - menu_width - 8.0).max(viewport.left() + 8.0); + let x = ideal_x.clamp(viewport.left() + 8.0, max_x); let y = if rect.bottom() + 48.0 < viewport.bottom() { rect.bottom() + 6.0 } else { (rect.top() - 44.0).max(viewport.top() + 6.0) }; let mut chosen = None; egui::Area::new(egui::Id::new(("text-selection-actions", view.id.0))).order(egui::Order::Foreground).fixed_pos(pos2(x, y)).show(ui.ctx(), |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { + egui::Frame::popup(ui.style()).corner_radius(CornerRadius::same(12)).show(ui, |ui| { + ui.spacing_mut().item_spacing.x = 2.0; ui.horizontal(|ui| { - for (label, action) in [ - (tl!("Add comment"), SelectionAction::Note), - (tl!("Highlight"), SelectionAction::Markup(comments::CommentTool::Highlight)), - (tl!("Underline"), SelectionAction::Markup(comments::CommentTool::Underline)), - (tl!("Strikethrough"), SelectionAction::Markup(comments::CommentTool::StrikeOut)), - (tl!("Replace text"), SelectionAction::Replace), - ] { - if ui.add_enabled(can_comment, egui::Button::new(label)).clicked() { - chosen = Some(action); + ui.add_enabled_ui(can_comment, |ui| { + for (icon, tooltip, action) in [ + ("message-square-plus", tl!("Add comment"), SelectionAction::Note), + ("highlighter", tl!("Highlight"), SelectionAction::Markup(comments::CommentTool::Highlight)), + ("underline", tl!("Underline"), SelectionAction::Markup(comments::CommentTool::Underline)), + ("strikethrough", tl!("Strikethrough"), SelectionAction::Markup(comments::CommentTool::StrikeOut)), + ("replace", tl!("Replace text"), SelectionAction::Replace), + ] { + if icons::button(ui, icon, 26.0, false, tooltip).clicked() { + chosen = Some(action); + } } - } - if ui.button(tl!("Copy")).clicked() { + }); + if icons::button(ui, "copy", 26.0, false, tl!("Copy")).clicked() { chosen = Some(SelectionAction::Copy); } }); @@ -4568,8 +4818,26 @@ mod tests { #[test] fn hover_popup_skips_fill_and_sign_marks() { - assert_eq!(annotation_hover_text(&annotation(false)).as_deref(), Some("FreeText\nHello")); - assert_eq!(annotation_hover_text(&annotation(true)), None); + assert_eq!(annotation_hover_text(&annotation(false), true).as_deref(), Some("FreeText\nHello")); + assert_eq!(annotation_hover_text(&annotation(true), true), None); + } + + #[test] + fn hover_popup_respects_author_on_empty_highlight() { + let mut a = annotation(false); + a.subtype = "Highlight".to_string(); + a.author = Some("Grace Hopper".to_string()); + a.contents = None; + + // When display_author_no_comment is true, author is shown + assert_eq!(annotation_hover_text(&a, true).as_deref(), Some("Grace Hopper")); + + // When display_author_no_comment is false, no hover popup is shown for empty highlight + assert_eq!(annotation_hover_text(&a, false), None); + + // When a comment is added, comment popup is shown regardless of the setting + a.contents = Some("Important passage".to_string()); + assert_eq!(annotation_hover_text(&a, false).as_deref(), Some("Grace Hopper\nImportant passage")); } #[test] diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 774e37b30f..7789453dc3 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -250,6 +250,8 @@ pub struct CommentPrefs { pub pinned: bool, /// The tool each flyout group shows (the last one used). pub group_tool: [CommentTool; 3], + /// Whether to display author name on hover over highlighted text when no comment is added. + pub display_author_hover_no_comment: bool, } impl Default for CommentPrefs { @@ -259,6 +261,7 @@ impl Default for CommentPrefs { styles: ALL.iter().map(|t| (*t, Style::default_for(&t.sample()))).collect(), pinned: false, group_tool: [CommentTool::Note, CommentTool::Highlight, CommentTool::Ink], + display_author_hover_no_comment: true, } } } diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 39355e6913..38f63587cb 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -551,43 +551,111 @@ fn finish(cv: &mut ContentView, d: TextDraft, added: &[Added]) -> Option { /// Returns the changed style; for a selected item the caller turns it into an update. pub(crate) fn format_panel(ui: &mut egui::Ui, t: &Tokens, style: &AddedText) -> Option { let mut s = style.clone(); - widgets::section_title(ui, tl!("Format text")); - ui.horizontal(|ui| { - egui::ComboBox::from_id_salt("font-family").selected_text(s.family.label()).width(110.0).show_ui(ui, |ui| { + widgets::section_title(ui, tl!("FORMAT TEXT")); + egui::ComboBox::from_id_salt("font-family") + .selected_text(s.family.label()) + .width(ui.available_width().max(180.0)) + .show_ui(ui, |ui| { + ui.label(egui::RichText::new(tl!("Standard fonts")).weak().small()); for f in [FontFamily::Helvetica, FontFamily::Times, FontFamily::Courier] { ui.selectable_value(&mut s.family, f, f.label()); } - }); - // A list rather than a drag value: every change is an undo step. - egui::ComboBox::from_id_salt("font-size").selected_text(format!("{} pt", s.size)).width(70.0).show_ui(ui, |ui| { - for size in [8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 24.0, 28.0, 36.0, 48.0, 72.0] { - ui.selectable_value(&mut s.size, size, format!("{size} pt")); + #[cfg(not(target_arch = "wasm32"))] + if let Some(installed) = crate::files::installed_font_files(ui.ctx()) { + if !installed.is_empty() { + ui.separator(); + ui.label(egui::RichText::new(tl!("Installed fonts")).weak().small()); + for font in &installed { + let fam = if font.family.eq_ignore_ascii_case("times") || font.face.to_ascii_lowercase().contains("times") { + FontFamily::Times + } else if font.family.eq_ignore_ascii_case("courier") || font.face.to_ascii_lowercase().contains("courier") { + FontFamily::Courier + } else { + FontFamily::Helvetica + }; + if ui.selectable_label(s.family == fam, &font.face).clicked() { + s.family = fam; + } + } + } } }); - }); ui.horizontal(|ui| { - if ui.selectable_label(s.bold, egui::RichText::new("B").strong()).on_hover_text(tl!("Bold")).clicked() { - s.bold = !s.bold; - } - if ui.selectable_label(s.italic, egui::RichText::new("I").italics()).on_hover_text(tl!("Italic")).clicked() { - s.italic = !s.italic; - } - ui.separator(); + egui::ComboBox::from_id_salt("font-size") + .selected_text(format!("{:.1}", s.size)) + .width(55.0) + .show_ui(ui, |ui| { + for size in [6.0, 8.0, 9.0, 10.0, 10.5, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 24.0, 28.0, 36.0, 48.0, 72.0] { + ui.selectable_value(&mut s.size, size, format!("{size}")); + } + }); + let (color_rect, color_resp) = ui.allocate_exact_size(egui::vec2(20.0, 20.0), egui::Sense::click()); + let color_val = crate::comments::color32(s.color); + ui.painter().rect_filled(color_rect, t.radius, color_val); + ui.painter().rect_stroke(color_rect, t.radius, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + let show_palette_id = ui.id().with("show-palette-added"); + let mut show_palette = ui.data(|d| d.get_temp::(show_palette_id)).unwrap_or(false); + if color_resp.on_hover_text(tl!("Text color")).clicked() { + show_palette = !show_palette; + ui.data_mut(|d| d.insert_temp(show_palette_id, show_palette)); + } + ui.add_space(2.0); + let is_bullet = crate::edit_text_ui::is_bulleted(&s.text); + if crate::icons::button(ui, "list", 24.0, is_bullet, tl!("Bullet list")).clicked() { + crate::edit_text_ui::toggle_bullet_list(&mut s.text); + } + let is_num = crate::edit_text_ui::is_numbered(&s.text); + if crate::icons::button(ui, "list-ordered", 24.0, is_num, tl!("Numbered list")).clicked() { + crate::edit_text_ui::toggle_numbered_list(&mut s.text); + } + ui.add_space(2.0); for (a, icon, tip) in [ (TextAlign::Left, "align-left", tl!("Align left")), (TextAlign::Center, "align-center", tl!("Centre")), (TextAlign::Right, "align-right", tl!("Align right")), (TextAlign::Justify, "align-justify", tl!("Justify")), ] { - if crate::icons::button(ui, icon, 26.0, s.align == a, tip).clicked() { + if crate::icons::button(ui, icon, 24.0, s.align == a, tip).clicked() { s.align = a; } } }); - let c = s.color; - if let Some(picked) = crate::comments::swatch_grid(ui, Some(c)) { - s.color = picked; + let show_palette_id = ui.id().with("show-palette-added"); + if ui.data(|d| d.get_temp::(show_palette_id)).unwrap_or(false) { + if let Some(picked) = crate::comments::swatch_grid(ui, Some(s.color)) { + s.color = picked; + } } + ui.horizontal(|ui| { + let b_resp = ui.selectable_label(s.bold, egui::RichText::new("T").strong().size(14.0)); + if b_resp.on_hover_text(tl!("Bold")).clicked() { + s.bold = !s.bold; + } + let i_resp = ui.selectable_label(s.italic, egui::RichText::new("T").italics().size(14.0)); + if i_resp.on_hover_text(tl!("Italic")).clicked() { + s.italic = !s.italic; + } + let u_resp = ui.selectable_label(false, egui::RichText::new("T̲").size(14.0)); + let _ = u_resp; + let sup_resp = ui.selectable_label(false, egui::RichText::new("T¹").size(13.0)); + if sup_resp.on_hover_text(tl!("Superscript")).clicked() { + let all_sup = !s.text.is_empty() && s.text.chars().all(|c| crate::edit_text_ui::char_from_superscript(c) != c); + s.text = if all_sup { + s.text.chars().map(crate::edit_text_ui::char_from_superscript).collect() + } else { + s.text.chars().map(crate::edit_text_ui::char_to_superscript).collect() + }; + } + let sub_resp = ui.selectable_label(false, egui::RichText::new("T₁").size(13.0)); + if sub_resp.on_hover_text(tl!("Subscript")).clicked() { + let all_sub = !s.text.is_empty() && s.text.chars().all(|c| crate::edit_text_ui::char_from_subscript(c) != c); + s.text = if all_sub { + s.text.chars().map(crate::edit_text_ui::char_from_subscript).collect() + } else { + s.text.chars().map(crate::edit_text_ui::char_to_subscript).collect() + }; + } + }); ui.label(egui::RichText::new(tl!("Thai and Arabic use regular fallback fonts when available.")).small().color(t.text_faint)); (s != *style).then_some(s) } diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 65c50528ec..73d6eaaf41 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -133,39 +133,298 @@ pub struct LineEditor { pub struct Extras { pub underline: bool, pub line_spacing: f64, + pub paragraph_spacing: f64, pub char_spacing: f64, pub scale: f64, } impl Default for Extras { fn default() -> Self { - Extras { underline: false, line_spacing: 0.0, char_spacing: 0.0, scale: 100.0 } + Extras { underline: false, line_spacing: 0.0, paragraph_spacing: 0.0, char_spacing: 0.0, scale: 100.0 } } } -/// Underline, line spacing, character spacing and horizontal scale for the paragraph being -/// edited (under Format text). Returns `true` when something changed. +pub fn is_bulleted(text: &str) -> bool { + let mut lines = text.lines(); + let Some(first) = lines.next() else { return false }; + first.trim_start().starts_with("• ") || first.trim_start().starts_with("- ") +} + +pub fn is_numbered(text: &str) -> bool { + let mut lines = text.lines(); + let Some(first) = lines.next() else { return false }; + let t = first.trim_start(); + let num_end = t.find('.').unwrap_or(0); + num_end > 0 && t[..num_end].chars().all(|c| c.is_ascii_digit()) && t[num_end..].starts_with(". ") +} + +pub fn toggle_bullet_list(text: &mut String) -> bool { + let lines: Vec<&str> = text.lines().collect(); + if lines.is_empty() { + return false; + } + let all_bulleted = lines.iter().all(|l| { + let t = l.trim_start(); + t.is_empty() || t.starts_with("• ") || t.starts_with("- ") + }); + let mut new_text = String::with_capacity(text.len() + 16); + for (i, line) in lines.iter().enumerate() { + if i > 0 { + new_text.push('\n'); + } + let trimmed = line.trim_start(); + if all_bulleted { + if let Some(rest) = trimmed.strip_prefix("• ").or_else(|| trimmed.strip_prefix("- ")) { + new_text.push_str(rest); + } else { + new_text.push_str(line); + } + } else if trimmed.is_empty() { + new_text.push_str(line); + } else if trimmed.starts_with("• ") { + new_text.push_str(line); + } else { + new_text.push_str("• "); + new_text.push_str(line); + } + } + if new_text != *text { + *text = new_text; + true + } else { + false + } +} + +pub fn toggle_numbered_list(text: &mut String) -> bool { + let lines: Vec<&str> = text.lines().collect(); + if lines.is_empty() { + return false; + } + let is_num_line = |l: &str| { + let t = l.trim_start(); + if t.is_empty() { + return true; + } + let dot_idx = t.find('.').unwrap_or(0); + dot_idx > 0 && t[..dot_idx].chars().all(|c| c.is_ascii_digit()) && t[dot_idx..].starts_with(". ") + }; + let all_numbered = lines.iter().all(|l| is_num_line(l)); + let mut new_text = String::with_capacity(text.len() + 16); + let mut num = 1; + for (i, line) in lines.iter().enumerate() { + if i > 0 { + new_text.push('\n'); + } + let t = line.trim_start(); + if all_numbered { + if let Some(dot_idx) = t.find('.') { + if dot_idx > 0 && t[..dot_idx].chars().all(|c| c.is_ascii_digit()) && t[dot_idx..].starts_with(". ") { + new_text.push_str(&t[dot_idx + 2..]); + continue; + } + } + new_text.push_str(line); + } else if t.is_empty() { + new_text.push_str(line); + } else { + use std::fmt::Write as _; + let _ = write!(new_text, "{num}. "); + new_text.push_str(t); + num += 1; + } + } + if new_text != *text { + *text = new_text; + true + } else { + false + } +} + +pub fn char_to_superscript(c: char) -> char { + match c { + '0' => '⁰', '1' => '¹', '2' => '²', '3' => '³', '4' => '⁴', + '5' => '⁵', '6' => '⁶', '7' => '⁷', '8' => '⁸', '9' => '⁹', + '+' => '⁺', '-' | '−' => '⁻', '=' => '⁼', '(' => '⁽', ')' => '⁾', + 'a' => 'ᵃ', 'b' => 'ᵇ', 'c' => 'ᶜ', 'd' => 'ᵈ', 'e' => 'ᵉ', + 'f' => 'ᶠ', 'g' => 'ᵍ', 'h' => 'ʰ', 'i' => 'ⁱ', 'j' => 'ʲ', + 'k' => 'ᵏ', 'l' => 'ˡ', 'm' => 'ᵐ', 'n' => 'ⁿ', 'o' => 'ᵒ', + 'p' => 'ᵖ', 'r' => 'ʳ', 's' => 'ˢ', 't' => 'ᵗ', 'u' => 'ᵘ', + 'v' => 'ᵛ', 'w' => 'ʷ', 'x' => 'ˣ', 'y' => 'ʸ', 'z' => 'ᶻ', + 'A' => 'ᴬ', 'B' => 'ᴮ', 'D' => 'ᴰ', 'E' => 'ᴱ', 'G' => 'ᴳ', + 'H' => 'ᴴ', 'I' => 'ᴵ', 'J' => 'ᴶ', 'K' => 'ᴷ', 'L' => 'ᴸ', + 'M' => 'ᴹ', 'N' => 'ᴺ', 'O' => 'ᴼ', 'P' => 'ᴾ', 'R' => 'ᴿ', + 'T' => 'ᵀ', 'U' => 'ᵁ', 'V' => 'ⱽ', 'W' => 'ᵂ', + other => other, + } +} + +pub fn char_from_superscript(c: char) -> char { + match c { + '⁰' => '0', '¹' => '1', '²' => '2', '³' => '3', '⁴' => '4', + '⁵' => '5', '⁶' => '6', '⁷' => '7', '⁸' => '8', '⁹' => '9', + '⁺' => '+', '⁻' => '-', '⁼' => '=', '⁽' => '(', '⁾' => ')', + 'ᵃ' => 'a', 'ᵇ' => 'b', 'ᶜ' => 'c', 'ᵈ' => 'd', 'ᵉ' => 'e', + 'ᶠ' => 'f', 'ᵍ' => 'g', 'ʰ' => 'h', 'ⁱ' => 'i', 'ʲ' => 'j', + 'ᵏ' => 'k', 'ˡ' => 'l', 'ᵐ' => 'm', 'ⁿ' => 'n', 'ᵒ' => 'o', + 'ᵖ' => 'p', 'ʳ' => 'r', 'ˢ' => 's', 'ᵗ' => 't', 'ᵘ' => 'u', + 'ᵛ' => 'v', 'ʷ' => 'w', 'ˣ' => 'x', 'ʸ' => 'y', 'ᶻ' => 'z', + 'ᴬ' => 'A', 'ᴮ' => 'B', 'ᴰ' => 'D', 'ᴱ' => 'E', 'ᴳ' => 'G', + 'ᴴ' => 'H', 'ᴵ' => 'I', 'ᴶ' => 'J', 'ᴷ' => 'K', 'ᴸ' => 'L', + 'ᴹ' => 'M', 'ᴺ' => 'N', 'ᴼ' => 'O', 'ᴾ' => 'P', 'ᴿ' => 'R', + 'ᵀ' => 'T', 'ᵁ' => 'U', 'ⱽ' => 'V', 'ᵂ' => 'W', + other => other, + } +} + +pub fn char_to_subscript(c: char) -> char { + match c { + '0' => '₀', '1' => '₁', '2' => '₂', '3' => '₃', '4' => '₄', + '5' => '₅', '6' => '₆', '7' => '₇', '8' => '₈', '9' => '₉', + '+' => '₊', '-' | '−' => '₋', '=' => '₌', '(' => '₍', ')' => '₎', + 'a' => 'ₐ', 'e' => 'ₑ', 'h' => 'ₕ', 'i' => 'ᵢ', 'j' => 'ⱼ', + 'k' => 'ₖ', 'l' => 'ₗ', 'm' => 'ₘ', 'n' => 'ₙ', 'o' => 'ₒ', + 'p' => 'ₚ', 'r' => 'ᵣ', 's' => 'ₛ', 't' => 'ₜ', 'u' => 'ᵤ', + 'v' => 'ᵥ', 'x' => 'ₓ', + other => other, + } +} + +pub fn char_from_subscript(c: char) -> char { + match c { + '₀' => '0', '₁' => '1', '₂' => '2', '₃' => '3', '₄' => '4', + '₅' => '5', '₆' => '6', '₇' => '7', '₈' => '8', '₉' => '9', + '₊' => '+', '₋' => '-', '₌' => '=', '₍' => '(', '₎' => ')', + 'ₐ' => 'a', 'ₑ' => 'e', 'ₕ' => 'h', 'ᵢ' => 'i', 'ⱼ' => 'j', + 'ₖ' => 'k', 'ₗ' => 'l', 'ₘ' => 'm', 'ₙ' => 'n', 'ₒ' => 'o', + 'ₚ' => 'p', 'ᵣ' => 'r', 'ₛ' => 's', 'ₜ' => 't', 'ᵤ' => 'u', + 'ᵥ' => 'v', 'ₓ' => 'x', + other => other, + } +} + +fn snap_char_boundaries(s: &str, start: usize, end: usize) -> (usize, usize) { + let mut s_idx = start.min(s.len()); + while s_idx > 0 && !s.is_char_boundary(s_idx) { + s_idx -= 1; + } + let mut e_idx = end.min(s.len()); + while e_idx < s.len() && !s.is_char_boundary(e_idx) { + e_idx += 1; + } + (s_idx, e_idx) +} + +pub fn toggle_superscript(ed: &mut LineEditor) -> bool { + let (s_pos, e_pos) = if let Some(r) = &ed.selection { + (r.start.min(ed.text.len()), r.end.min(ed.text.len())) + } else { + (0, ed.text.len()) + }; + let (start, end) = snap_char_boundaries(&ed.text, s_pos, e_pos); + if start >= end { + return false; + } + let target = &ed.text[start..end]; + let all_sup = target.chars().all(|c| char_from_superscript(c) != c); + let converted: String = if all_sup { + target.chars().map(char_from_superscript).collect() + } else { + target.chars().map(char_to_superscript).collect() + }; + if converted != target { + let conv_len = converted.len(); + ed.text.replace_range(start..end, &converted); + if ed.selection.is_some() { + ed.selection = Some(start..start + conv_len); + } + true + } else { + false + } +} + +pub fn toggle_subscript(ed: &mut LineEditor) -> bool { + let (s_pos, e_pos) = if let Some(r) = &ed.selection { + (r.start.min(ed.text.len()), r.end.min(ed.text.len())) + } else { + (0, ed.text.len()) + }; + let (start, end) = snap_char_boundaries(&ed.text, s_pos, e_pos); + if start >= end { + return false; + } + let target = &ed.text[start..end]; + let all_sub = target.chars().all(|c| char_from_subscript(c) != c); + let converted: String = if all_sub { + target.chars().map(char_from_subscript).collect() + } else { + target.chars().map(char_to_subscript).collect() + }; + if converted != target { + let conv_len = converted.len(); + ed.text.replace_range(start..end, &converted); + if ed.selection.is_some() { + ed.selection = Some(start..start + conv_len); + } + true + } else { + false + } +} + +/// Underline, value-based line spacing, paragraph spacing, character spacing and horizontal +/// scale for the paragraph being edited. Returns `true` when something changed. pub(crate) fn extras_panel(ui: &mut egui::Ui, e: &mut Extras) -> bool { let before = *e; ui.horizontal(|ui| { - if crate::icons::button(ui, "underline", 26.0, e.underline, tl!("Underline")).clicked() { - e.underline = !e.underline; + ui.label(egui::RichText::new("↕≡").strong()).on_hover_text(tl!("Line spacing")); + let mut ls = if e.line_spacing <= 0.0 { 1.20 } else { e.line_spacing }; + if ui.add(egui::DragValue::new(&mut ls).range(0.5..=5.0).speed(0.01).custom_formatter(|v, _| format!("{v:.2}"))).changed() { + e.line_spacing = (ls * 100.0).round() / 100.0; } - let zero = tl!("Line spacing").to_string(); - let label = |v: f64| if v == 0.0 { zero.clone() } else { format!("{v:.2}×") }; - egui::ComboBox::from_id_salt("line-spacing").selected_text(label(e.line_spacing)).width(110.0).show_ui(ui, |ui| { - for v in [1.0, 1.15, 1.5, 2.0] { - ui.selectable_value(&mut e.line_spacing, v, label(v)); + egui::ComboBox::from_id_salt("line-spacing-presets").selected_text("").width(16.0).show_ui(ui, |ui| { + for v in [1.0, 1.15, 1.25, 1.33, 1.5, 2.0, 2.5, 3.0] { + if ui.selectable_label((e.line_spacing - v).abs() < 0.01, format!("{v:.2}×")).clicked() { + e.line_spacing = v; + } + } + }); + ui.add_space(8.0); + ui.label(egui::RichText::new("↕≡").weak()).on_hover_text(tl!("Paragraph spacing")); + let mut ps = e.paragraph_spacing; + if ui.add(egui::DragValue::new(&mut ps).range(0.0..=100.0).speed(0.05).custom_formatter(|v, _| format!("{v:.2}"))).changed() { + e.paragraph_spacing = (ps * 100.0).round() / 100.0; + } + egui::ComboBox::from_id_salt("para-spacing-presets").selected_text("").width(16.0).show_ui(ui, |ui| { + for v in [0.0, 0.13, 0.25, 0.5, 1.0, 2.0] { + if ui.selectable_label((e.paragraph_spacing - v).abs() < 0.01, format!("{v:.2}")).clicked() { + e.paragraph_spacing = v; + } } }); }); ui.horizontal(|ui| { - let l = ui.label(tl!("Character spacing")); - ui.add(egui::DragValue::new(&mut e.char_spacing).range(-5.0..=50.0).speed(0.1).suffix(" pt")).labelled_by(l.id); - }); - ui.horizontal(|ui| { - let l = ui.label(tl!("Horizontal scale")); - ui.add(egui::DragValue::new(&mut e.scale).range(10.0..=400.0).speed(1.0).suffix(" %")).labelled_by(l.id); + ui.label(egui::RichText::new("T↔").strong()).on_hover_text(tl!("Horizontal scale")); + ui.add(egui::DragValue::new(&mut e.scale).range(10.0..=400.0).speed(1.0).custom_formatter(|v, _| format!("{v:.0}"))); + egui::ComboBox::from_id_salt("scale-presets").selected_text("").width(16.0).show_ui(ui, |ui| { + for v in [50.0, 75.0, 90.0, 100.0, 110.0, 125.0, 150.0, 200.0] { + if ui.selectable_label((e.scale - v).abs() < 0.01, format!("{v:.0}%")).clicked() { + e.scale = v; + } + } + }); + ui.add_space(8.0); + ui.label(egui::RichText::new("V↔A").strong()).on_hover_text(tl!("Character spacing")); + ui.add(egui::DragValue::new(&mut e.char_spacing).range(-5.0..=50.0).speed(0.05).custom_formatter(|v, _| format!("{v:.2}"))); + egui::ComboBox::from_id_salt("char-spacing-presets").selected_text("").width(16.0).show_ui(ui, |ui| { + for v in [-1.0, -0.5, -0.24, 0.0, 0.25, 0.5, 1.0, 2.0] { + if ui.selectable_label((e.char_spacing - v).abs() < 0.01, format!("{v:.2} pt")).clicked() { + e.char_spacing = v; + } + } + }); }); *e != before } @@ -310,13 +569,41 @@ pub(crate) fn editor_fill(text: Color32) -> Color32 { if contrast(text, Color32::WHITE) >= contrast(text, DARK_EDITOR_FILL) { Color32::WHITE } else { DARK_EDITOR_FILL } } +/// The 8 resize handles on an image (4 corners + 4 edge centers). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ImageHandle { + TopLeft, + TopCenter, + TopRight, + RightCenter, + BottomRight, + BottomCenter, + BottomLeft, + LeftCenter, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DragTarget { + Move, + Handle(ImageHandle), + CropHandle(ImageHandle), +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ImageDrag { + pub start: Pos2, + pub target: DragTarget, + pub initial_rect: Rect, +} + /// A selected page image, and what the pointer is doing to it. #[derive(Clone, Debug, PartialEq)] pub struct ImageSelection { pub page: usize, pub index: usize, - /// Dragging: the start point and, for a corner, the opposite corner (screen). - drag: Option<(Pos2, Option)>, + pub crop_mode: bool, + pub crop_rect: Option, + pub drag: Option, } impl ImageSelection { @@ -325,7 +612,7 @@ impl ImageSelection { } } -/// Once a selected corner is grabbed, text under it cannot take over the gesture. The same +/// Once a selected handle is grabbed, text under it cannot take over the gesture. The same /// 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 }; @@ -336,7 +623,17 @@ pub(crate) fn image_owns_pointer(ui: &egui::Ui, xf: &PageXform, page: usize, inf let Some(rect) = image.visible_rect else { return false }; let b = xf.user_rect(info, page, rect.map(|v| v as f32)); let p = ui.input(|i| if i.pointer.primary_down() { i.pointer.press_origin() } else { i.pointer.hover_pos() }); - p.is_some_and(|p| [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom()].iter().any(|c| c.distance(p) < 8.0)) + let handles = [ + b.left_top(), + Pos2::new(b.center().x, b.top()), + b.right_top(), + Pos2::new(b.right(), b.center().y), + b.right_bottom(), + Pos2::new(b.center().x, b.bottom()), + b.left_bottom(), + Pos2::new(b.left(), b.center().y), + ]; + p.is_some_and(|p| handles.iter().any(|c| c.distance(p) < 8.0)) } /// What a drag on a paragraph box takes hold of. @@ -400,6 +697,13 @@ pub(crate) enum ImageAction { Save(usize, usize), Copy(usize, usize), CopyFile(usize, usize), + Cut(usize, usize), + DrawFreehand, + Redact(usize, usize), + CreateLink(usize, usize), + RecognizeText, + AddComment(usize, usize), + Crop, } fn user_box(xf: &PageXform, info: &DocInfo, page: usize, r: Rect) -> [f64; 4] { @@ -419,7 +723,7 @@ pub(crate) fn image_input( info: &DocInfo, images: &[pdfcraft_engine::PageImage], view: &mut DocView, - action: &mut Option, + _action: &mut Option, allow_interaction: bool, ) -> bool { let boxes: Vec = images.iter().map(|im| im.visible_rect.map_or(Rect::NOTHING, |r| xf.user_rect(info, page, r.map(|v| v as f32)))).collect(); @@ -461,91 +765,307 @@ pub(crate) fn image_input( painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT.gamma_multiply(0.7)), egui::StrokeKind::Outside); } } - // The selected image: frame, corner handles, dragging. + // The selected image: frame, 8 handles (or crop mode), dragging. if let Some(i) = selected.filter(|i| boxes.get(*i).is_some_and(|b| b.is_positive())) { let b = boxes[i]; - let corners = [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom()]; - let handles = |rect: Rect| { - for c in [rect.left_top(), rect.right_top(), rect.left_bottom(), rect.right_bottom()] { - painter.rect( - Rect::from_center_size(c, egui::vec2(8.0, 8.0)), - CornerRadius::ZERO, - Color32::WHITE, - Stroke::new(1.0, ACCENT), - egui::StrokeKind::Middle, - ); + let is_crop = view.image_selection.as_ref().is_some_and(|s| s.crop_mode); + + let handle_pos = |rect: Rect, h: ImageHandle| match h { + ImageHandle::TopLeft => rect.left_top(), + ImageHandle::TopCenter => Pos2::new(rect.center().x, rect.top()), + ImageHandle::TopRight => rect.right_top(), + ImageHandle::RightCenter => Pos2::new(rect.right(), rect.center().y), + ImageHandle::BottomRight => rect.right_bottom(), + ImageHandle::BottomCenter => Pos2::new(rect.center().x, rect.bottom()), + ImageHandle::BottomLeft => rect.left_bottom(), + ImageHandle::LeftCenter => Pos2::new(rect.left(), rect.center().y), + }; + let all_handles = [ + ImageHandle::TopLeft, + ImageHandle::TopCenter, + ImageHandle::TopRight, + ImageHandle::RightCenter, + ImageHandle::BottomRight, + ImageHandle::BottomCenter, + ImageHandle::BottomLeft, + ImageHandle::LeftCenter, + ]; + let cursor_for = |h: ImageHandle| match h { + ImageHandle::TopLeft | ImageHandle::BottomRight => egui::CursorIcon::ResizeNwSe, + ImageHandle::TopRight | ImageHandle::BottomLeft => egui::CursorIcon::ResizeNeSw, + ImageHandle::TopCenter | ImageHandle::BottomCenter => egui::CursorIcon::ResizeVertical, + ImageHandle::LeftCenter | ImageHandle::RightCenter => egui::CursorIcon::ResizeHorizontal, + }; + let draw_handles = |rect: Rect| { + for &h in &all_handles { + let c = handle_pos(rect, h); + match h { + ImageHandle::TopLeft | ImageHandle::TopRight | ImageHandle::BottomLeft | ImageHandle::BottomRight => { + painter.rect( + Rect::from_center_size(c, egui::vec2(8.0, 8.0)), + CornerRadius::ZERO, + Color32::WHITE, + Stroke::new(1.0, ACCENT), + egui::StrokeKind::Middle, + ); + } + ImageHandle::TopCenter | ImageHandle::BottomCenter => { + painter.rect( + Rect::from_center_size(c, egui::vec2(12.0, 6.0)), + CornerRadius::same(1), + Color32::WHITE, + Stroke::new(1.0, ACCENT), + egui::StrokeKind::Middle, + ); + } + ImageHandle::LeftCenter | ImageHandle::RightCenter => { + painter.rect( + Rect::from_center_size(c, egui::vec2(6.0, 12.0)), + CornerRadius::same(1), + Color32::WHITE, + Stroke::new(1.0, ACCENT), + egui::StrokeKind::Middle, + ); + } + } } }; - if view.image_selection.as_ref().is_some_and(|s| s.drag.is_none()) { - painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); - handles(b); - } - if allow_interaction && let Some(corner) = hover_pos.and_then(|p| corners.iter().position(|c| c.distance(p) < 8.0)) { - ui.ctx().set_cursor_icon(if matches!(corner, 0 | 3) { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw }); + + if is_crop { + let active_crop = view.image_selection.as_ref().and_then(|s| s.crop_rect).unwrap_or(b); + let shade_color = Color32::from_rgba_unmultiplied(15, 35, 70, 150); + if active_crop.top() > b.top() { + painter.rect_filled(Rect::from_min_max(b.min, Pos2::new(b.right(), active_crop.top())), CornerRadius::ZERO, shade_color); + } + if active_crop.bottom() < b.bottom() { + painter.rect_filled(Rect::from_min_max(Pos2::new(b.left(), active_crop.bottom()), b.max), CornerRadius::ZERO, shade_color); + } + if active_crop.left() > b.left() { + painter.rect_filled(Rect::from_min_max(Pos2::new(b.left(), active_crop.top()), Pos2::new(active_crop.left(), active_crop.bottom())), CornerRadius::ZERO, shade_color); + } + if active_crop.right() < b.right() { + painter.rect_filled(Rect::from_min_max(Pos2::new(active_crop.right(), active_crop.top()), Pos2::new(b.right(), active_crop.bottom())), CornerRadius::ZERO, shade_color); + } + + painter.rect_stroke(active_crop, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); + let x1 = active_crop.left() + active_crop.width() / 3.0; + let x2 = active_crop.left() + active_crop.width() * 2.0 / 3.0; + let y1 = active_crop.top() + active_crop.height() / 3.0; + let y2 = active_crop.top() + active_crop.height() * 2.0 / 3.0; + painter.line_segment([Pos2::new(x1, active_crop.top()), Pos2::new(x1, active_crop.bottom())], Stroke::new(0.75, ACCENT.gamma_multiply(0.5))); + painter.line_segment([Pos2::new(x2, active_crop.top()), Pos2::new(x2, active_crop.bottom())], Stroke::new(0.75, ACCENT.gamma_multiply(0.5))); + painter.line_segment([Pos2::new(active_crop.left(), y1), Pos2::new(active_crop.right(), y1)], Stroke::new(0.75, ACCENT.gamma_multiply(0.5))); + 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::TopCenter, + ImageHandle::RightCenter, + ImageHandle::BottomCenter, + 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) }; + painter.rect(Rect::from_center_size(c, sz), CornerRadius::same(1), Color32::WHITE, Stroke::new(1.5, ACCENT), egui::StrokeKind::Middle); + } + + if allow_interaction && let Some(p) = hover_pos { + if let Some(&h) = crop_handles.iter().find(|&&h| handle_pos(active_crop, h).distance(p) < 8.0) { + ui.ctx().set_cursor_icon(cursor_for(h)); + } else if active_crop.contains(p) { + ui.ctx().set_cursor_icon(egui::CursorIcon::Move); + } + } + + if allow_interaction { + if ui.input(|inp| inp.key_pressed(egui::Key::Enter)) { + 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)), + }); + } + if let Some(s) = view.image_selection.as_mut() { + s.crop_mode = false; + s.crop_rect = None; + } + return true; + } + if ui.input(|inp| inp.key_pressed(egui::Key::Escape)) { + if let Some(s) = view.image_selection.as_mut() { + s.crop_mode = false; + s.crop_rect = None; + } + return true; + } + } + } else { + if view.image_selection.as_ref().is_some_and(|s| s.drag.is_none()) { + painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); + draw_handles(b); + } + if allow_interaction && let Some(p) = hover_pos { + if let Some(&h) = all_handles.iter().find(|&&h| handle_pos(b, h).distance(p) < 8.0) { + ui.ctx().set_cursor_icon(cursor_for(h)); + } + } } + let origin = ui.input(|i| i.pointer.press_origin()); - if allow_interaction - && resp.drag_started() - && let Some(o) = origin - { - let corner = corners.iter().position(|c| c.distance(o) < 8.0); - if corner.is_some() || image_contains(i, o) { - let opposite = corner.map(|k| corners[3 - k]); - if let Some(s) = view.image_selection.as_mut() { - s.drag = Some((o, opposite)); + if allow_interaction && resp.drag_started() && let Some(o) = origin { + if is_crop { + let active_crop = view.image_selection.as_ref().and_then(|s| s.crop_rect).unwrap_or(b); + let crop_handles = [ + ImageHandle::TopCenter, + ImageHandle::RightCenter, + ImageHandle::BottomCenter, + ImageHandle::LeftCenter, + ]; + let hit_edge = crop_handles.iter().copied().find(|&h| handle_pos(active_crop, h).distance(o) < 8.0); + if let Some(h) = hit_edge { + if let Some(s) = view.image_selection.as_mut() { + s.drag = Some(ImageDrag { start: o, target: DragTarget::CropHandle(h), initial_rect: active_crop }); + } + } else if active_crop.contains(o) { + if let Some(s) = view.image_selection.as_mut() { + s.drag = Some(ImageDrag { start: o, target: DragTarget::Move, initial_rect: active_crop }); + } + } + } else { + let hit_handle = all_handles.iter().copied().find(|&h| handle_pos(b, h).distance(o) < 8.0); + if let Some(h) = hit_handle { + if let Some(s) = view.image_selection.as_mut() { + s.drag = Some(ImageDrag { start: o, target: DragTarget::Handle(h), initial_rect: b }); + } + } else if image_contains(i, o) { + if let Some(s) = view.image_selection.as_mut() { + s.drag = Some(ImageDrag { start: o, target: DragTarget::Move, initial_rect: b }); + } } } } - if let Some((start, opposite)) = view.image_selection.as_ref().and_then(|s| s.drag) + + if let Some(drag) = view.image_selection.as_ref().and_then(|s| s.drag) && let Some(p) = hover_pos { - let preview = match opposite { - // Resize from the opposite corner, keeping the aspect ratio. - Some(fixed) => { - let (w0, h0) = (b.width().max(1.0), b.height().max(1.0)); - // Use the axis with the larger relative pointer change. Taking the maximum - // absolute size freezes shrinking when the other axis hasn't moved. Measure - // from the press point so grabbing a handle off-centre cannot cause a jump. - let horizontal = (p.x - start.x) * (start.x - fixed.x).signum() / w0; - let vertical = (p.y - start.y) * (start.y - fixed.y).signum() / h0; - let delta = if horizontal.abs() > vertical.abs() { horizontal } else { vertical }; - let k = (1.0 + delta).max(0.05); - let (w, h) = (w0 * k, h0 * k); - let x = if start.x < fixed.x { fixed.x - w } else { fixed.x }; - let y = if start.y < fixed.y { fixed.y - h } else { fixed.y }; - Rect::from_min_size(Pos2::new(x, y), egui::vec2(w, h)) + let delta = p - drag.start; + let initial = drag.initial_rect; + if is_crop { + let mut new_crop = initial; + match drag.target { + 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); + } + DragTarget::CropHandle(ImageHandle::BottomCenter) => { + let y = (initial.bottom() + delta.y).clamp(initial.top() + 10.0, b.bottom()); + new_crop = Rect::from_min_max(initial.min, Pos2::new(initial.right(), y)); + } + DragTarget::CropHandle(ImageHandle::LeftCenter) => { + let x = (initial.left() + delta.x).clamp(b.left(), initial.right() - 10.0); + new_crop = Rect::from_min_max(Pos2::new(x, initial.top()), initial.max); + } + DragTarget::CropHandle(ImageHandle::RightCenter) => { + let x = (initial.right() + delta.x).clamp(initial.left() + 10.0, b.right()); + new_crop = Rect::from_min_max(initial.min, Pos2::new(x, initial.bottom())); + } + DragTarget::Move => { + let mut translated = initial.translate(delta); + if translated.left() < b.left() { + translated = translated.translate(egui::vec2(b.left() - translated.left(), 0.0)); + } + if translated.right() > b.right() { + translated = translated.translate(egui::vec2(b.right() - translated.right(), 0.0)); + } + if translated.top() < b.top() { + translated = translated.translate(egui::vec2(0.0, b.top() - translated.top())); + } + if translated.bottom() > b.bottom() { + translated = translated.translate(egui::vec2(0.0, b.bottom() - translated.bottom())); + } + new_crop = translated; + } + _ => {} } - None => b.translate(p - start), - }; - // Move/resize the full placement by the visible frame's change. The engine - // transforms an isolated crop together with its image, preserving its contents. - let raw = images.get(i).map_or(b, |im| xf.user_rect(info, page, im.rect.map(|v| v as f32))); - // A zero-width or zero-height visible frame (or a degenerate ratio) keeps the - // placement's scale instead of producing inf/NaN. - let ratio = |new: f32, old: f32| if old.abs() > 1e-3 && (new / old).is_finite() { new / old } else { 1.0 }; - let factor = egui::vec2(ratio(preview.width(), b.width()), ratio(preview.height(), b.height())); - let target = Rect::from_min_max(preview.min + (raw.min - b.min) * factor, preview.min + (raw.max - b.min) * factor); - let visible = preview.intersect(xf.rect); - if visible.is_positive() { - painter.rect_filled(visible, CornerRadius::ZERO, ACCENT.gamma_multiply(0.04)); - painter.rect_stroke(visible, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); - handles(preview); - } - if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) { if let Some(s) = view.image_selection.as_mut() { - s.drag = None; + s.crop_rect = Some(new_crop); + } + if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) { + if let Some(s) = view.image_selection.as_mut() { + s.drag = None; + } + } + ui.ctx().set_cursor_icon(match drag.target { + DragTarget::CropHandle(h) => cursor_for(h), + _ => egui::CursorIcon::Grabbing, + }); + return true; + } else { + let preview = match drag.target { + DragTarget::Move => initial.translate(delta), + DragTarget::Handle(ImageHandle::TopCenter) => { + let new_top = (initial.top() + delta.y).min(initial.bottom() - 4.0); + Rect::from_min_max(Pos2::new(initial.left(), new_top), Pos2::new(initial.right(), initial.bottom())) + } + DragTarget::Handle(ImageHandle::BottomCenter) => { + let new_bottom = (initial.bottom() + delta.y).max(initial.top() + 4.0); + Rect::from_min_max(Pos2::new(initial.left(), initial.top()), Pos2::new(initial.right(), new_bottom)) + } + DragTarget::Handle(ImageHandle::LeftCenter) => { + let new_left = (initial.left() + delta.x).min(initial.right() - 4.0); + Rect::from_min_max(Pos2::new(new_left, initial.top()), Pos2::new(initial.right(), initial.bottom())) + } + DragTarget::Handle(ImageHandle::RightCenter) => { + let new_right = (initial.right() + delta.x).max(initial.left() + 4.0); + Rect::from_min_max(Pos2::new(initial.left(), initial.top()), Pos2::new(new_right, initial.bottom())) + } + DragTarget::Handle(h @ (ImageHandle::TopLeft | ImageHandle::TopRight | ImageHandle::BottomLeft | ImageHandle::BottomRight)) => { + let fixed = match h { + ImageHandle::TopLeft => Pos2::new(initial.right(), initial.bottom()), + ImageHandle::TopRight => Pos2::new(initial.left(), initial.bottom()), + ImageHandle::BottomLeft => Pos2::new(initial.right(), initial.top()), + ImageHandle::BottomRight => Pos2::new(initial.left(), initial.top()), + _ => Pos2::ZERO, + }; + let start = drag.start; + let (w0, h0) = (initial.width().max(1.0), initial.height().max(1.0)); + let horizontal = (p.x - start.x) * (start.x - fixed.x).signum() / w0; + let vertical = (p.y - start.y) * (start.y - fixed.y).signum() / h0; + let delta = if horizontal.abs() > vertical.abs() { horizontal } else { vertical }; + let k = (1.0 + delta).max(0.05); + let (w, h) = (w0 * k, h0 * k); + let x = if start.x < fixed.x { fixed.x - w } else { fixed.x }; + let y = if start.y < fixed.y { fixed.y - h } else { fixed.y }; + Rect::from_min_size(Pos2::new(x, y), egui::vec2(w, h)) + } + _ => initial, + }; + let raw = images.get(i).map_or(b, |im| xf.user_rect(info, page, im.rect.map(|v| v as f32))); + let ratio = |new: f32, old: f32| if old.abs() > 1e-3 && (new / old).is_finite() { new / old } else { 1.0 }; + let factor = egui::vec2(ratio(preview.width(), b.width()), ratio(preview.height(), b.height())); + let target = Rect::from_min_max(preview.min + (raw.min - b.min) * factor, preview.min + (raw.max - b.min) * factor); + let visible = preview.intersect(xf.rect); + if visible.is_positive() { + painter.rect_filled(visible, CornerRadius::ZERO, ACCENT.gamma_multiply(0.04)); + painter.rect_stroke(visible, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); + draw_handles(preview); } - if preview != b { - view.pending_edit = - Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Move(user_box(xf, info, page, target)) }); + if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) { + if let Some(s) = view.image_selection.as_mut() { + s.drag = None; + } + if preview != b { + view.pending_edit = + Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Move(user_box(xf, info, page, target)) }); + } } + ui.ctx().set_cursor_icon(match drag.target { + DragTarget::Handle(h) => cursor_for(h), + _ => egui::CursorIcon::Grabbing, + }); + return true; } - ui.ctx().set_cursor_icon(match opposite { - Some(fixed) if (start.x - fixed.x) * (start.y - fixed.y) < 0.0 => egui::CursorIcon::ResizeNeSw, - Some(_) => egui::CursorIcon::ResizeNwSe, - None => egui::CursorIcon::Grabbing, - }); - return true; } if allow_interaction && ui.input(|inp| inp.key_pressed(egui::Key::Delete) || inp.key_pressed(egui::Key::Backspace)) @@ -560,7 +1080,25 @@ pub(crate) fn image_input( return false; } 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 { 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()) + || ui.ctx().is_pointer_over_egui() + || ui.ctx().egui_wants_pointer_input(); + if (resp.secondary_clicked() || resp.clicked()) && !over_popup { + if let Some(s) = view.image_selection.as_mut() && s.crop_mode { + let img_box = boxes.get(s.index).copied(); + if let Some(crop) = s.crop_rect.filter(|c| img_box.is_some_and(|b| *c != b)) { + view.pending_edit = Some(Edit::EditPageImage { + page, + index: s.index, + change: pdfcraft_engine::ImageEdit::Crop(user_box(xf, info, page, crop)), + }); + } + } + view.image_selection = None; + } + return false; + }; if selected != Some(hit) { painter.rect_stroke(boxes[hit], CornerRadius::ZERO, Stroke::new(1.5, ACCENT.gamma_multiply(0.7)), egui::StrokeKind::Outside); } @@ -573,50 +1111,168 @@ pub(crate) fn image_input( _ => egui::CursorIcon::PointingHand, }); if resp.clicked() || resp.secondary_clicked() { - view.image_selection = Some(ImageSelection { page, index: hit, drag: None }); - } - if selected == Some(hit) { - resp.context_menu(|ui| { - use pdfcraft_engine::ImageEdit as E; - let items: [(&str, Option); 4] = [ - (tl!("Rotate Clockwise"), Some(E::Rotate(1))), - (tl!("Rotate Counterclockwise"), Some(E::Rotate(3))), - (tl!("Flip Horizontal"), Some(E::Flip { horizontal: true })), - (tl!("Flip Vertical"), Some(E::Flip { horizontal: false })), - ]; - for (label, change) in items { - if ui.button(label).clicked() { - view.pending_edit = change.map(|c| Edit::EditPageImage { page, index: hit, change: c }); - ui.close(); - } - } - let raster = images.get(hit).is_some_and(|image| !image.is_form); - if ui.add_enabled(raster, egui::Button::new(tl!("Replace Image…"))).clicked() { - *action = Some(ImageAction::Replace(page, hit)); - ui.close(); + view.image_selection = Some(ImageSelection { page, index: hit, crop_mode: false, crop_rect: None, drag: None }); + } + true +} + +/// The right-click context menu for a selected image matching Adobe Acrobat. +pub(crate) fn image_context_menu( + ui: &mut egui::Ui, + view: &mut DocView, + doc: &pdfcraft_engine::Document, + page: usize, + hit: usize, + action: &mut Option, +) { + let images = doc.page_images(page); + let Some(img) = images.get(hit) else { return }; + use pdfcraft_engine::ImageEdit as E; + + // Top horizontal icon bar: Comment, Crop, Rotate CCW, Rotate CW, Delete + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + if crate::icons::button(ui, "message-square", 24.0, false, tl!("Add a sticky note here")).clicked() { + *action = Some(ImageAction::AddComment(page, hit)); + ui.close(); + } + if crate::icons::button(ui, "crop", 24.0, false, tl!("Crop")).clicked() { + *action = Some(ImageAction::Crop); + if let Some(s) = view.image_selection.as_mut() { + s.crop_mode = true; + s.crop_rect = None; } - if ui.add_enabled(raster, egui::Button::new(tl!("Save Image As…"))).clicked() { - *action = Some(ImageAction::Save(page, hit)); - ui.close(); + ui.close(); + } + if crate::icons::button(ui, "rotate-ccw", 24.0, false, tl!("Rotate Counterclockwise")).clicked() { + view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Rotate(3) }); + ui.close(); + } + if crate::icons::button(ui, "rotate-cw", 24.0, false, tl!("Rotate Clockwise")).clicked() { + view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Rotate(1) }); + ui.close(); + } + if crate::icons::button(ui, "trash-2", 24.0, false, tl!("Delete")).clicked() { + view.image_selection = None; + view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Delete }); + ui.close(); + } + }); + + ui.separator(); + + let raster = !img.is_form; + if ui.add(egui::Button::new(tl!("Cut")).shortcut_text("Ctrl+X")).clicked() { + *action = Some(ImageAction::Cut(page, hit)); + ui.close(); + } + if ui.add(egui::Button::new(tl!("Copy")).shortcut_text("Ctrl+C")).clicked() { + *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")); + + ui.separator(); + + let is_cropped = img.visible_rect.is_some() && img.visible_rect != Some(img.rect); + if ui.add_enabled(is_cropped, egui::Button::new(tl!("Remove crop"))).clicked() { + view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::RemoveCrop }); + ui.close(); + } + + if ui.button(tl!("Flip horizontal")).clicked() { + view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Flip { horizontal: true } }); + ui.close(); + } + if ui.button(tl!("Flip vertical")).clicked() { + view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Flip { horizontal: false } }); + ui.close(); + } + + ui.menu_button(tl!("Arrange image"), |ui| { + let total = images.len(); + if ui.button(tl!("Bring to front")).clicked() { + view.pending_edit = Some(Edit::EditPageImage { + page, + index: hit, + change: E::Arrange(pdfcraft_engine::ArrangeOrder::BringToFront), + }); + if let Some(s) = view.image_selection.as_mut() { + s.index = total.saturating_sub(1); } - // Copying also finds the picture inside grouped Form artwork. - if ui.button(tl!("Copy Image")).clicked() { - *action = Some(ImageAction::Copy(page, hit)); - ui.close(); + ui.close(); + } + if ui.button(tl!("Send to back")).clicked() { + view.pending_edit = Some(Edit::EditPageImage { + page, + index: hit, + change: E::Arrange(pdfcraft_engine::ArrangeOrder::SendToBack), + }); + if let Some(s) = view.image_selection.as_mut() { + s.index = 0; } - if ui.button(tl!("Copy Image in Original Format")).clicked() { - *action = Some(ImageAction::CopyFile(page, hit)); - ui.close(); + ui.close(); + } + if ui.button(tl!("Bring forward")).clicked() { + view.pending_edit = Some(Edit::EditPageImage { + page, + index: hit, + change: E::Arrange(pdfcraft_engine::ArrangeOrder::BringForward), + }); + if let Some(s) = view.image_selection.as_mut() { + s.index = (hit + 1).min(total.saturating_sub(1)); } - ui.separator(); - if ui.button(tl!("Delete")).clicked() { - view.image_selection = None; - view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Delete }); - ui.close(); + ui.close(); + } + if ui.button(tl!("Send backward")).clicked() { + view.pending_edit = Some(Edit::EditPageImage { + page, + index: hit, + change: E::Arrange(pdfcraft_engine::ArrangeOrder::SendBackward), + }); + if let Some(s) = view.image_selection.as_mut() { + s.index = hit.saturating_sub(1); } - }); + ui.close(); + } + }); + + if ui.add_enabled(raster, egui::Button::new(tl!("Replace image"))).clicked() { + *action = Some(ImageAction::Replace(page, hit)); + ui.close(); + } + + ui.separator(); + + if ui.button(tl!("Draw freehand")).clicked() { + *action = Some(ImageAction::DrawFreehand); + ui.close(); + } + if ui.button(tl!("Redact image")).clicked() { + *action = Some(ImageAction::Redact(page, hit)); + ui.close(); + } + if ui.add_enabled(raster, egui::Button::new(tl!("Export image"))).clicked() { + *action = Some(ImageAction::Save(page, hit)); + ui.close(); + } + + ui.separator(); + + if ui.button(tl!("Create link")).clicked() { + *action = Some(ImageAction::CreateLink(page, hit)); + ui.close(); + } + + ui.menu_button(tl!("Edit image using"), |ui| { + ui.add_enabled(false, egui::Button::new(tl!("Default image editor"))); + }); + + if ui.button(tl!("Recognize text")).clicked() { + *action = Some(ImageAction::RecognizeText); + ui.close(); } - true } /// Lines and their screen boxes for a page; hover outlines, click opens the editor. Returns @@ -1146,4 +1802,99 @@ mod tests { assert_eq!(caret_color_on(Color32::BLACK), Color32::WHITE); assert_eq!(caret_color_on(DARK_EDITOR_FILL), Color32::WHITE); } + + #[test] + fn bullet_list_toggles_and_preserves_content() { + let mut text = "First line\nSecond line\n\nThird line".to_string(); + assert!(!is_bulleted(&text)); + assert!(toggle_bullet_list(&mut text)); + assert!(is_bulleted(&text)); + assert_eq!(text, "• First line\n• Second line\n\n• Third line"); + + // Toggling again removes bullets + assert!(toggle_bullet_list(&mut text)); + assert!(!is_bulleted(&text)); + assert_eq!(text, "First line\nSecond line\n\nThird line"); + } + + #[test] + fn numbered_list_toggles_and_preserves_content() { + let mut text = "Item A\nItem B\n\nItem C".to_string(); + assert!(!is_numbered(&text)); + assert!(toggle_numbered_list(&mut text)); + assert!(is_numbered(&text)); + assert_eq!(text, "1. Item A\n2. Item B\n\n3. Item C"); + + // Toggling again removes numbering + assert!(toggle_numbered_list(&mut text)); + assert!(!is_numbered(&text)); + assert_eq!(text, "Item A\nItem B\n\nItem C"); + } + + #[test] + fn superscript_and_subscript_conversion() { + let mut ed = LineEditor { + page: 0, + block: 0, + text: "E = mc2".to_string(), + original: "E = mc2".to_string(), + rect: egui::Rect::ZERO, + source_rect: [0.0; 4], + max_width: 100.0, + overflow: false, + selection: Some(6..7), // selecting '2' + font_resource: None, + fonts: std::collections::BTreeMap::new(), + source: None, + multiline: false, + size: 12.0, + focus: true, + look: pdfcraft_engine::AddedText::default(), + look0: pdfcraft_engine::AddedText::default(), + extras: Extras::default(), + extras0: Extras::default(), + }; + + assert!(toggle_superscript(&mut ed)); + assert_eq!(ed.text, "E = mc²"); + // Toggling superscript again returns to normal '2' + assert!(toggle_superscript(&mut ed)); + assert_eq!(ed.text, "E = mc2"); + + // Test subscript on H2O + ed.text = "H2O".to_string(); + ed.selection = Some(1..2); // selecting '2' + assert!(toggle_subscript(&mut ed)); + assert_eq!(ed.text, "H₂O"); + assert!(toggle_subscript(&mut ed)); + assert_eq!(ed.text, "H2O"); + } + + #[test] + fn value_based_line_spacing_in_style() { + let ed = LineEditor { + page: 0, + block: 0, + text: "Paragraph text".to_string(), + original: "Paragraph text".to_string(), + rect: egui::Rect::ZERO, + source_rect: [0.0; 4], + max_width: 100.0, + overflow: false, + selection: None, + font_resource: None, + fonts: std::collections::BTreeMap::new(), + source: None, + multiline: false, + size: 12.0, + focus: true, + look: pdfcraft_engine::AddedText::default(), + look0: pdfcraft_engine::AddedText::default(), + extras: Extras { line_spacing: 1.33, underline: true, ..Default::default() }, + extras0: Extras::default(), + }; + let style = ed.style(); + assert_eq!(style.line_spacing, Some(1.33)); + assert_eq!(style.underline, Some(true)); + } } diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index bbeb20727f..a5dbbabd6a 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -463,13 +463,34 @@ impl PdfCraftApp { match purpose { FilePurpose::TextFont { page } => { let Some((_, bytes)) = files.into_iter().next() else { return }; - if self.apply_edit(Edit::RegisterTextFont { page, bytes: Arc::new(bytes) }) + if self.apply_edit(Edit::RegisterTextFont { page, bytes: Arc::new(bytes.clone()) }) && let Some((index, id)) = self.active_ids() - && let Some(doc) = self.session.get(id) - && let Some(ed) = self.views[index].line_editor.as_mut() - && let Some(ctx) = self.ctx.as_ref() { - ed.fonts = crate::edit_text_ui::install_source_fonts(ctx, doc, page); + let resource = self.session.get(id).and_then(|doc| { + doc.text_editor_fonts(page).into_iter().find(|f| f.bytes == bytes).map(|f| f.resource) + }); + if let Some(resource) = resource + && let Some(ed) = self.views[index].line_editor.as_mut() + { + ed.font_resource = Some(resource); + let edit = Edit::EditTextBlock { page: ed.page, block: ed.block, text: ed.text.clone(), style: ed.style() }; + if self.apply_edit(edit) + && let Some(ed) = self.views[index].line_editor.as_mut() + { + ed.applied(); + if let Some(doc) = self.session.get(id) + && let Some(block) = doc.text_blocks(ed.page).get(ed.block) + { + ed.refresh_source(block, doc.text_lines(ed.page)); + } + } + } + if let Some(ed) = self.views[index].line_editor.as_mut() + && let Some(doc) = self.session.get(id) + && let Some(ctx) = self.ctx.as_ref() + { + ed.fonts = crate::edit_text_ui::install_source_fonts(ctx, doc, page); + } } } FilePurpose::Combine => self.stage_combine(files), diff --git a/crates/ui-egui/src/js_ui.rs b/crates/ui-egui/src/js_ui.rs index fe474622c7..3e3de59be2 100644 --- a/crates/ui-egui/src/js_ui.rs +++ b/crates/ui-egui/src/js_ui.rs @@ -468,6 +468,13 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok ui.add(crate::widgets::line(&mut app.comment_prefs.author).desired_width(220.0).char_limit(crate::MAX_AUTHOR_CHARS)).labelled_by(label.id); }); ui.add_space(8.0); + // Comments: comment preferences (Acrobat: Preferences ▸ Commenting). + ui.label(egui::RichText::new(tl!("Comments")).font(theme::semibold(13.0))); + ui.add(widgets::checkbox( + &mut app.comment_prefs.display_author_hover_no_comment, + tl!("Display author name on hovering highlighted text when no comment is added"), + )); + ui.add_space(8.0); ui.label(egui::RichText::new(tl!("Fill & Sign")).font(theme::semibold(13.0))); ui.add(widgets::checkbox(&mut app.flatten_fill_sign_on_save, tl!("Flatten Fill & Sign when saving"))); ui.label( diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f800ad6a00..de858de5cd 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -6,6 +6,7 @@ //! `pdfcraft-engine`. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![recursion_limit = "256"] /// Translate an English UI string into the current language (see [`i18n`]). macro_rules! tl { @@ -2125,6 +2126,7 @@ impl PdfCraftApp { // Null follows the interface language. "date_language": self.session.date_language(), "author": self.comment_prefs.author, + "display_author_hover_no_comment": self.comment_prefs.display_author_hover_no_comment, // Per-tool comment defaults (colours, opacity, widths), so Make Current // Properties Default survives a restart (#340). "comment_styles": self @@ -2253,6 +2255,9 @@ impl PdfCraftApp { if let Some(author) = v["author"].as_str().map(str::trim).filter(|a| !a.is_empty()) { self.comment_prefs.author = author.chars().take(MAX_AUTHOR_CHARS).collect(); } + if let Some(display) = v["display_author_hover_no_comment"].as_bool() { + self.comment_prefs.display_author_hover_no_comment = display; + } // Per-tool comment styles (#340): each field goes through its clamping setter, so a // malformed or hostile entry is clamped or ignored rather than trusted. if let Some(styles) = v["comment_styles"].as_array() { @@ -2555,6 +2560,13 @@ impl PdfCraftApp { }; } ("author", _) => self.comment_prefs.author = value.to_string(), + ("display-author-hover-no-comment", _) => { + self.comment_prefs.display_author_hover_no_comment = match value { + "true" => true, + "false" => false, + _ => return Err("display-author-hover-no-comment must be true or false".into()), + }; + } ("ui-scale", _) => self.ui_scale = ui_scale::parse(value)?, ("system-title-bar", _) => { self.system_title_bar = match value { diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index bdd625804d..499feaab59 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -350,41 +350,165 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { let mut ed = ed; let mut changed = false; let font_choices = app.session.get(app.views[i].id).map(|d| d.text_editor_fonts(ed.page)).unwrap_or_default(); - egui::ComboBox::from_id_salt("document-font-resource") - .selected_text( - ed.font_resource - .as_ref() - .and_then(|r| font_choices.iter().find(|f| &f.resource == r)) - .map_or(tl!("Keep source fonts"), |f| f.face.as_str()), - ) + let current_font_label = if let Some(r) = ed.font_resource.as_ref() { + font_choices.iter().find(|f| &f.resource == r).map_or_else(|| r.clone(), |f| f.face.clone()) + } else if ed.look.family != pdfcraft_engine::FontFamily::Helvetica { + ed.look.family.label().to_string() + } else { + tl!("Keep source fonts").to_string() + }; + + crate::widgets::section_title(ui, tl!("FORMAT TEXT")); + egui::ComboBox::from_id_salt("text-font-dropdown") + .selected_text(current_font_label) + .width(ui.available_width().max(180.0)) .show_ui(ui, |ui| { - changed |= ui.selectable_value(&mut ed.font_resource, None, tl!("Keep source fonts")).changed(); - for font in font_choices.iter().filter(|f| !f.resource.starts_with("PCApproved")) { - changed |= ui.selectable_value(&mut ed.font_resource, Some(font.resource.clone()), &font.face).changed(); + if ui.selectable_label(ed.font_resource.is_none(), tl!("Keep source fonts")).clicked() { + ed.font_resource = None; + changed = true; } - }); - if ui.button(tl!("Load font…")).clicked() { - app.pick_files(crate::files::FilePurpose::TextFont { page: ed.page }, false); - } - #[cfg(not(target_arch = "wasm32"))] - egui::ComboBox::from_id_salt("installed-document-font").selected_text(tl!("Installed fonts…")).show_ui(ui, |ui| { - if let Some(fonts) = crate::files::installed_font_files(ui.ctx()) { - if fonts.is_empty() { - ui.label(tl!("No openly licensed document fonts found. Load a font file.")); + if !font_choices.is_empty() { + ui.separator(); + ui.label(egui::RichText::new(tl!("Document fonts")).weak().small()); + for font in font_choices.iter().filter(|f| !f.resource.starts_with("PCApproved")) { + let is_current = ed.font_resource.as_ref() == Some(&font.resource); + if ui.selectable_label(is_current, &font.face).clicked() { + ed.font_resource = Some(font.resource.clone()); + changed = true; + } + } } - for font in fonts { - if ui.selectable_label(false, &font.face).clicked() { - app.use_paths(crate::files::FilePurpose::TextFont { page: ed.page }, &[font.path]); + ui.separator(); + ui.label(egui::RichText::new(tl!("Standard fonts")).weak().small()); + for f in [pdfcraft_engine::FontFamily::Helvetica, pdfcraft_engine::FontFamily::Times, pdfcraft_engine::FontFamily::Courier] { + let is_current = ed.font_resource.is_none() && ed.look.family == f; + if ui.selectable_label(is_current, f.label()).clicked() { + ed.font_resource = None; + ed.look.family = f; + changed = true; } } - } else { - ui.label(tl!("Finding document fonts…")); + #[cfg(not(target_arch = "wasm32"))] + { + ui.separator(); + ui.label(egui::RichText::new(tl!("Installed fonts")).weak().small()); + if let Some(installed) = crate::files::installed_font_files(ui.ctx()) { + if installed.is_empty() { + ui.label(egui::RichText::new(tl!("No installed fonts found.")).weak().small()); + } else { + for font in &installed { + let is_current = ed.font_resource.as_ref().is_some_and(|r| { + font_choices.iter().any(|f| &f.resource == r && f.face == font.face) + }); + if ui.selectable_label(is_current, &font.face).clicked() { + if let Ok(bytes) = std::fs::read(&font.path) { + let edit = pdfcraft_engine::Edit::RegisterTextFont { page: ed.page, bytes: std::sync::Arc::new(bytes.clone()) }; + if app.apply_edit(edit) { + if let Some(doc) = app.session.get(app.views[i].id) { + if let Some(f) = doc.text_editor_fonts(ed.page).into_iter().find(|f| f.bytes == bytes) { + ed.font_resource = Some(f.resource); + changed = true; + } + ed.fonts = crate::edit_text_ui::install_source_fonts(ui.ctx(), doc, ed.page); + } + } + } + } + } + } + } else { + ui.label(egui::RichText::new(tl!("Finding installed fonts…")).weak().small()); + } + } + ui.separator(); + if ui.button(tl!("Load font from file…")).clicked() { + app.pick_files(crate::files::FilePurpose::TextFont { page: ed.page }, false); + } + }); + + ui.horizontal(|ui| { + // Font size ComboBox + egui::ComboBox::from_id_salt("font-size") + .selected_text(format!("{:.1}", ed.look.size)) + .width(55.0) + .show_ui(ui, |ui| { + for size in [6.0, 8.0, 9.0, 10.0, 10.5, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 24.0, 28.0, 36.0, 48.0, 72.0] { + changed |= ui.selectable_value(&mut ed.look.size, size, format!("{size}")).changed(); + } + }); + // Color swatch square + let (color_rect, color_resp) = ui.allocate_exact_size(egui::vec2(20.0, 20.0), egui::Sense::click()); + let color_val = crate::comments::color32(ed.look.color); + ui.painter().rect_filled(color_rect, t.radius, color_val); + ui.painter().rect_stroke(color_rect, t.radius, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + let show_palette_id = ui.id().with("show-palette"); + let mut show_palette = ui.data(|d| d.get_temp::(show_palette_id)).unwrap_or(false); + if color_resp.on_hover_text(tl!("Text color")).clicked() { + show_palette = !show_palette; + ui.data_mut(|d| d.insert_temp(show_palette_id, show_palette)); + } + ui.add_space(2.0); + // Bullet list button + let is_bullet = crate::edit_text_ui::is_bulleted(&ed.text); + if crate::icons::button(ui, "list", 24.0, is_bullet, tl!("Bullet list")).clicked() { + changed |= crate::edit_text_ui::toggle_bullet_list(&mut ed.text); + } + // Numbered list button + let is_num = crate::edit_text_ui::is_numbered(&ed.text); + if crate::icons::button(ui, "list-ordered", 24.0, is_num, tl!("Numbered list")).clicked() { + changed |= crate::edit_text_ui::toggle_numbered_list(&mut ed.text); + } + ui.add_space(2.0); + // Alignment buttons + for (a, icon, tip) in [ + (pdfcraft_engine::TextAlign::Left, "align-left", tl!("Align left")), + (pdfcraft_engine::TextAlign::Center, "align-center", tl!("Centre")), + (pdfcraft_engine::TextAlign::Right, "align-right", tl!("Align right")), + (pdfcraft_engine::TextAlign::Justify, "align-justify", tl!("Justify")), + ] { + if crate::icons::button(ui, icon, 24.0, ed.look.align == a, tip).clicked() { + ed.look.align = a; + changed = true; + } } }); - if let Some(look) = crate::content_ui::format_panel(ui, t, &ed.look) { - ed.look = look; - changed = true; + + // Color Swatch Grid (when toggled or open) + let show_palette_id = ui.id().with("show-palette"); + if ui.data(|d| d.get_temp::(show_palette_id)).unwrap_or(false) { + if let Some(picked) = crate::comments::swatch_grid(ui, Some(ed.look.color)) { + ed.look.color = picked; + changed = true; + } } + + // Formatting Buttons: Bold, Italic, Underline, Superscript, Subscript + ui.horizontal(|ui| { + let b_resp = ui.selectable_label(ed.look.bold, egui::RichText::new("B").strong()).on_hover_text(tl!("Bold")); + if b_resp.clicked() { + ed.look.bold = !ed.look.bold; + changed = true; + } + let i_resp = ui.selectable_label(ed.look.italic, egui::RichText::new("I").italics()).on_hover_text(tl!("Italic")); + if i_resp.clicked() { + ed.look.italic = !ed.look.italic; + changed = true; + } + if crate::icons::button(ui, "underline", 24.0, ed.extras.underline, tl!("Underline")).clicked() { + ed.extras.underline = !ed.extras.underline; + changed = true; + } + let sup_resp = ui.selectable_label(false, egui::RichText::new("T¹").size(13.0)).on_hover_text(tl!("Superscript")); + if sup_resp.clicked() { + changed |= crate::edit_text_ui::toggle_superscript(&mut ed); + } + let sub_resp = ui.selectable_label(false, egui::RichText::new("T₁").size(13.0)).on_hover_text(tl!("Subscript")); + if sub_resp.clicked() { + changed |= crate::edit_text_ui::toggle_subscript(&mut ed); + } + }); + + // Spacing: Value-based line spacing, paragraph spacing, horizontal scale, character spacing changed |= crate::edit_text_ui::extras_panel(ui, &mut ed.extras); if changed { let edit = pdfcraft_engine::Edit::EditTextBlock { page: ed.page, block: ed.block, text: ed.text.clone(), style: ed.style() }; diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs index 98f2bf9071..a86ee0d76c 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -993,6 +993,55 @@ fn hovering_a_comment_shows_its_author_and_text() { assert!(h.query_by_label_contains("Check this figure").is_none(), "a dialog covers the page hover"); } +#[test] +fn hovering_highlight_without_comment_respects_display_author_option() { + let pdf = String::from_utf8_lossy(TEXT_FIXTURE).replace( + "/Contents 5 0 R /Resources", + "/Contents 5 0 R /Annots [8 0 R 9 0 R] /Resources", + ).replace( + "trailer", + "8 0 obj << /Type /Annot /Subtype /Highlight /Rect [50 50 150 120] /C [1 1 0] /T (Ada) >> endobj\n9 0 obj << /Type /Annot /Subtype /Highlight /Rect [160 50 250 120] /C [1 1 0] /T (Ada) /Contents (Has comment) >> endobj\ntrailer", + ); + 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.open_bytes("hover.pdf", None, pdf.into_bytes()).expect("opens"); + app.set_option("left", "closed").unwrap(); + app.set_option("panel", "none").unwrap(); + app + }); + h.run_steps(4); + settle(&mut h); + + // By default, display_author_hover_no_comment is true. Hovering over empty highlight shows Ada. + let p_empty = at(&h, 100.0, 85.0); + h.hover_at(p_empty); + h.run_steps(3); + h.get_by_label_contains("Ada"); + + // Disable the option. Hovering over empty highlight does NOT show Ada. + h.state_mut().set_option("display-author-hover-no-comment", "false").unwrap(); + h.hover_at(pos2(10.0, 10.0)); + h.run_steps(2); + h.hover_at(p_empty); + h.run_steps(3); + assert!(h.query_by_label_contains("Ada").is_none(), "no author popup when option is disabled and no comment"); + + // But hovering over the highlight with a comment still shows Ada and the comment! + let p_comment = at(&h, 200.0, 85.0); + h.hover_at(p_comment); + h.run_steps(3); + h.get_by_label_contains("Ada"); + h.get_by_label_contains("Has comment"); + + // Settings round-trip test + let json = h.state().persist(); + 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); +} + #[test] fn the_squiggly_tool_marks_selected_text() { let mut h = harness(|_| {}); diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index c470837775..971c8bf9c8 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -2548,6 +2548,187 @@ fn check_existing_artwork(name: &'static str, bytes: Vec) { assert!(h.state().session.get(id).unwrap().page_images(0).is_empty()); } +#[test] +fn image_right_click_context_menu_items_and_actions() { + let mut png = Vec::new(); + image::RgbImage::from_pixel(80, 40, image::Rgb([200, 40, 40])).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.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 r = h.state().views[0].page_screen_rect(0).expect("on screen"); + let c = r.center(); + // Select 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(), "selected"); + + // Right-click image + 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); + assert!(h.state().views[0].image_selection.is_some()); + + for item in [ + "Cut", + "Copy", + "Paste", + "Flip horizontal", + "Flip vertical", + "Arrange image", + "Replace image", + "Draw freehand", + "Redact image", + "Export image", + "Create link", + "Edit image using", + "Recognize text", + ] { + h.get_by_label_contains(item); + } + + // Top horizontal icon buttons + h.get_by_label("Rotate Counterclockwise"); + h.get_by_label("Rotate Clockwise"); + h.get_by_label("Delete"); + + // Flip horizontal via context menu item + h.get_by_label("Flip horizontal").click(); + h.run_steps(3); + let id = h.state().views[0].id; + let doc = h.state().session.get(id).unwrap(); + assert_eq!(doc.can_undo(), Some("Flip image")); + + // Undo flip + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Z); + h.run_steps(3); + let doc = h.state().session.get(id).unwrap(); + assert_eq!(doc.can_undo(), None); +} + +#[test] +fn default_page_context_menu_when_not_in_edit_mode() { + let mut h = harness(1, |_| {}); + h.run_steps(4); + assert_eq!(h.state().quick_tool, pdfcraft_ui_egui::QuickTool::Select); + + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + let c = r.center(); + // Right click on page in Select mode + 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); + + // Verify Acrobat page context menu items (Screenshot 3) + h.get_by_label("Rotate Counterclockwise"); + h.get_by_label("Print"); + h.get_by_label("Select Tool"); + h.get_by_label("Hand Tool"); + h.get_by_label("Marquee Zoom"); + + for item in [ + "Add bookmark", + "Take a snapshot", + "Edit this PDF", + "Delete page", + "Export this PDF", + "Find text or tools", + "Document properties…", + ] { + h.get_by_label_contains(item); + } + h.get_by_label("Rotate view clockwise"); + 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()); + + // Clicking "Edit this PDF" enters edit mode + h.get_by_label("Edit this PDF").click(); + h.run_steps(2); + assert_eq!(h.state().quick_tool, pdfcraft_ui_egui::QuickTool::EditText); +} + +#[test] +fn image_edge_handle_stretch_and_crop_mode() { + let mut png = Vec::new(); + image::RgbImage::from_pixel(100, 100, image::Rgb([100, 150, 200])).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.open_bytes("square.png", None, png.clone()).expect("opens"); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + let c = r.center(); + // Select 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()); + + // Enter crop mode via context menu + 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("Crop").click(); + h.run_steps(2); + assert!(h.state().views[0].image_selection.as_ref().is_some_and(|s| s.crop_mode)); + + // Confirm crop with Enter key + h.key_press(egui::Key::Enter); + h.run_steps(2); + assert!(!h.state().views[0].image_selection.as_ref().is_some_and(|s| s.crop_mode)); +} + +#[test] +fn text_selection_action_icons() { + let mut h = harness(1, |_| {}); + h.run_steps(4); + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + let p0 = egui::pos2(r.left() + 40.0 / 200.0 * r.width(), r.top() + (300.0 - 158.0) / 300.0 * r.height()); + let p1 = egui::pos2(r.left() + 100.0 / 200.0 * r.width(), r.top() + (300.0 - 158.0) / 300.0 * r.height()); + + // Drag to select text + h.hover_at(p0); + h.run_steps(1); + h.drag_at(p0); + h.run_steps(1); + h.hover_at(p1); + h.run_steps(1); + h.drop_at(p1); + h.run_steps(2); + + assert!(h.state().views[0].selected_text().is_some()); + + // Verify all 6 minimalist action icons are present in the floating menu + assert!(h.query_all_by_label("Add comment").next().is_some()); + assert!(h.query_all_by_label("Highlight").next().is_some()); + assert!(h.query_all_by_label("Underline").next().is_some()); + assert!(h.query_all_by_label("Strikethrough").next().is_some()); + assert!(h.query_all_by_label("Replace text").next().is_some()); + assert!(h.query_all_by_label("Copy").next().is_some()); +} + #[test] fn the_format_panel_restyles_the_paragraph_being_edited() { let mut h = harness(1, |_| {}); From 293faa0c059bad7f09763eb799f59fa86047b3a9 Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Wed, 14 Oct 2026 17:06:31 +0530 Subject: [PATCH 02/53] feat: major Acrobat UI parity, crop, and layer fixes --- crates/ui-egui/src/content_ui.rs | 38 +++++++++++------------ crates/ui-egui/src/edit_text_ui.rs | 49 +++++++++++++++--------------- crates/ui-egui/src/panels.rs | 32 +++++++++---------- parity/acrobat-features.toml | 8 ++--- 4 files changed, 64 insertions(+), 63 deletions(-) diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 38f63587cb..148830e010 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -561,21 +561,21 @@ pub(crate) fn format_panel(ui: &mut egui::Ui, t: &Tokens, style: &AddedText) -> ui.selectable_value(&mut s.family, f, f.label()); } #[cfg(not(target_arch = "wasm32"))] - if let Some(installed) = crate::files::installed_font_files(ui.ctx()) { - if !installed.is_empty() { - ui.separator(); - ui.label(egui::RichText::new(tl!("Installed fonts")).weak().small()); - for font in &installed { - let fam = if font.family.eq_ignore_ascii_case("times") || font.face.to_ascii_lowercase().contains("times") { - FontFamily::Times - } else if font.family.eq_ignore_ascii_case("courier") || font.face.to_ascii_lowercase().contains("courier") { - FontFamily::Courier - } else { - FontFamily::Helvetica - }; - if ui.selectable_label(s.family == fam, &font.face).clicked() { - s.family = fam; - } + if let Some(installed) = crate::files::installed_font_files(ui.ctx()) + && !installed.is_empty() + { + ui.separator(); + ui.label(egui::RichText::new(tl!("Installed fonts")).weak().small()); + for font in &installed { + let fam = if font.family.eq_ignore_ascii_case("times") || font.face.to_ascii_lowercase().contains("times") { + FontFamily::Times + } else if font.family.eq_ignore_ascii_case("courier") || font.face.to_ascii_lowercase().contains("courier") { + FontFamily::Courier + } else { + FontFamily::Helvetica + }; + if ui.selectable_label(s.family == fam, &font.face).clicked() { + s.family = fam; } } } @@ -621,10 +621,10 @@ pub(crate) fn format_panel(ui: &mut egui::Ui, t: &Tokens, style: &AddedText) -> } }); let show_palette_id = ui.id().with("show-palette-added"); - if ui.data(|d| d.get_temp::(show_palette_id)).unwrap_or(false) { - if let Some(picked) = crate::comments::swatch_grid(ui, Some(s.color)) { - s.color = picked; - } + if ui.data(|d| d.get_temp::(show_palette_id)).unwrap_or(false) + && let Some(picked) = crate::comments::swatch_grid(ui, Some(s.color)) + { + s.color = picked; } ui.horizontal(|ui| { let b_resp = ui.selectable_label(s.bold, egui::RichText::new("T").strong().size(14.0)); diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 73d6eaaf41..282ab3eac9 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -179,9 +179,7 @@ pub fn toggle_bullet_list(text: &mut String) -> bool { } else { new_text.push_str(line); } - } else if trimmed.is_empty() { - new_text.push_str(line); - } else if trimmed.starts_with("• ") { + } else if trimmed.is_empty() || trimmed.starts_with("• ") { new_text.push_str(line); } else { new_text.push_str("• "); @@ -218,11 +216,13 @@ pub fn toggle_numbered_list(text: &mut String) -> bool { } let t = line.trim_start(); if all_numbered { - if let Some(dot_idx) = t.find('.') { - if dot_idx > 0 && t[..dot_idx].chars().all(|c| c.is_ascii_digit()) && t[dot_idx..].starts_with(". ") { - new_text.push_str(&t[dot_idx + 2..]); - continue; - } + if let Some(dot_idx) = t.find('.') + && dot_idx > 0 + && t[..dot_idx].chars().all(|c| c.is_ascii_digit()) + && t[dot_idx..].starts_with(". ") + { + new_text.push_str(&t[dot_idx + 2..]); + continue; } new_text.push_str(line); } else if t.is_empty() { @@ -905,10 +905,11 @@ pub(crate) fn image_input( painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); draw_handles(b); } - if allow_interaction && let Some(p) = hover_pos { - if let Some(&h) = all_handles.iter().find(|&&h| handle_pos(b, h).distance(p) < 8.0) { - ui.ctx().set_cursor_icon(cursor_for(h)); - } + if allow_interaction + && let Some(p) = hover_pos + && let Some(&h) = all_handles.iter().find(|&&h| handle_pos(b, h).distance(p) < 8.0) + { + ui.ctx().set_cursor_icon(cursor_for(h)); } } @@ -927,10 +928,10 @@ pub(crate) fn image_input( if let Some(s) = view.image_selection.as_mut() { s.drag = Some(ImageDrag { start: o, target: DragTarget::CropHandle(h), initial_rect: active_crop }); } - } else if active_crop.contains(o) { - if let Some(s) = view.image_selection.as_mut() { - s.drag = Some(ImageDrag { start: o, target: DragTarget::Move, initial_rect: active_crop }); - } + } else if active_crop.contains(o) + && let Some(s) = view.image_selection.as_mut() + { + s.drag = Some(ImageDrag { start: o, target: DragTarget::Move, initial_rect: active_crop }); } } else { let hit_handle = all_handles.iter().copied().find(|&h| handle_pos(b, h).distance(o) < 8.0); @@ -938,10 +939,10 @@ pub(crate) fn image_input( if let Some(s) = view.image_selection.as_mut() { s.drag = Some(ImageDrag { start: o, target: DragTarget::Handle(h), initial_rect: b }); } - } else if image_contains(i, o) { - if let Some(s) = view.image_selection.as_mut() { - s.drag = Some(ImageDrag { start: o, target: DragTarget::Move, initial_rect: b }); - } + } else if image_contains(i, o) + && let Some(s) = view.image_selection.as_mut() + { + s.drag = Some(ImageDrag { start: o, target: DragTarget::Move, initial_rect: b }); } } } @@ -991,10 +992,10 @@ pub(crate) fn image_input( if let Some(s) = view.image_selection.as_mut() { s.crop_rect = Some(new_crop); } - if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) { - if let Some(s) = view.image_selection.as_mut() { - s.drag = None; - } + if (resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down())) + && let Some(s) = view.image_selection.as_mut() + { + s.drag = None; } ui.ctx().set_cursor_icon(match drag.target { DragTarget::CropHandle(h) => cursor_for(h), diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 499feaab59..6f9758ac9a 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -400,18 +400,18 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { let is_current = ed.font_resource.as_ref().is_some_and(|r| { font_choices.iter().any(|f| &f.resource == r && f.face == font.face) }); - if ui.selectable_label(is_current, &font.face).clicked() { - if let Ok(bytes) = std::fs::read(&font.path) { - let edit = pdfcraft_engine::Edit::RegisterTextFont { page: ed.page, bytes: std::sync::Arc::new(bytes.clone()) }; - if app.apply_edit(edit) { - if let Some(doc) = app.session.get(app.views[i].id) { - if let Some(f) = doc.text_editor_fonts(ed.page).into_iter().find(|f| f.bytes == bytes) { - ed.font_resource = Some(f.resource); - changed = true; - } - ed.fonts = crate::edit_text_ui::install_source_fonts(ui.ctx(), doc, ed.page); - } + if ui.selectable_label(is_current, &font.face).clicked() + && let Ok(bytes) = std::fs::read(&font.path) + { + let edit = pdfcraft_engine::Edit::RegisterTextFont { page: ed.page, bytes: std::sync::Arc::new(bytes.clone()) }; + if app.apply_edit(edit) + && let Some(doc) = app.session.get(app.views[i].id) + { + if let Some(f) = doc.text_editor_fonts(ed.page).into_iter().find(|f| f.bytes == bytes) { + ed.font_resource = Some(f.resource); + changed = true; } + ed.fonts = crate::edit_text_ui::install_source_fonts(ui.ctx(), doc, ed.page); } } } @@ -475,11 +475,11 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { // Color Swatch Grid (when toggled or open) let show_palette_id = ui.id().with("show-palette"); - if ui.data(|d| d.get_temp::(show_palette_id)).unwrap_or(false) { - if let Some(picked) = crate::comments::swatch_grid(ui, Some(ed.look.color)) { - ed.look.color = picked; - changed = true; - } + if ui.data(|d| d.get_temp::(show_palette_id)).unwrap_or(false) + && let Some(picked) = crate::comments::swatch_grid(ui, Some(ed.look.color)) + { + ed.look.color = picked; + changed = true; } // Formatting Buttons: Bold, Italic, Underline, Superscript, Subscript diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 359a71e9a6..0e73ae2150 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2127,11 +2127,11 @@ title = "Arrange objects (bring forward, send backward)" area = "C" tier = "P1" milestone = "M7" -status = "planned" +status = "partial" commands = [] -tools = [] -evidence = [] -notes = "" +tools = ["image_edit"] +evidence = ["crates/edit/src/tests.rs::image_arrange_stacking_order_and_remove_crop"] +notes = "Image stacking order in content streams: Bring to Front, Send to Back, Bring Forward, Send Backward with undo/redo and PDF export persistence. Image-level crop and 8-point handle transforms. Still missing: arbitrary vector paths, inline images, and multi-object grouping." [[feature]] id = "edit.align-distribute-objects" From 9a5fec0caa5bec25785b5148b8349ebcb1b8369d Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Wed, 14 Oct 2026 19:05:51 +0530 Subject: [PATCH 03/53] feat: major Acrobat UI parity, crop, and layer fixes --- crates/edit/src/images.rs | 233 +++++++++++++++++++++-------- crates/edit/src/tests.rs | 26 ++++ crates/ui-egui/src/canvas.rs | 52 +++++-- crates/ui-egui/src/content_ui.rs | 131 ++++++++++++++++ crates/ui-egui/src/create_ui.rs | 2 +- crates/ui-egui/src/edit_text_ui.rs | 68 ++++++++- crates/ui-egui/src/image_pick.rs | 2 +- crates/ui-egui/src/lib.rs | 46 ++++-- crates/ui-egui/src/object_ui.rs | 3 + crates/ui-egui/tests/comments.rs | 2 +- crates/ui-egui/tests/editing.rs | 88 ++++++++++- 11 files changed, 562 insertions(+), 91 deletions(-) diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 14556c7644..8bb6949f99 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -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, EditError> { + struct Scope { + start: usize, + matrix: Matrix, + inherited_clip: bool, + inherited_path: bool, + selected: bool, + other_paint: bool, + } + let mut stack: Vec = 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 { + 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); diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index c3aa428f84..a9f61f8bbd 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -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)); } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8f21dcdfe2..aacc091bff 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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, ) { // 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 }); } diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 148830e010..20a609ad3e 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -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)> = 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"))] diff --git a/crates/ui-egui/src/create_ui.rs b/crates/ui-egui/src/create_ui.rs index 63f8035251..fb8f1b2e27 100644 --- a/crates/ui-egui/src/create_ui.rs +++ b/crates/ui-egui/src/create_ui.rs @@ -66,7 +66,7 @@ fn read_clipboard() -> Option { cb.get_text().ok().filter(|t| !t.trim().is_empty()).map(Clip::Text) } -fn png_from_rgba(width: usize, height: usize, rgba: &[u8]) -> Result, String> { +pub(crate) fn png_from_rgba(width: usize, height: usize, rgba: &[u8]) -> Result, 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()); diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 282ab3eac9..48b8a7d552 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -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, ) { 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(); diff --git a/crates/ui-egui/src/image_pick.rs b/crates/ui-egui/src/image_pick.rs index a05aefe2d2..3030bfb293 100644 --- a/crates/ui-egui/src/image_pick.rs +++ b/crates/ui-egui/src/image_pick.rs @@ -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 } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index de858de5cd..1fddfa5363 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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)>, + 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)>, @@ -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); } } diff --git a/crates/ui-egui/src/object_ui.rs b/crates/ui-egui/src/object_ui.rs index 118946bd0c..3b410be261 100644 --- a/crates/ui-egui/src/object_ui.rs +++ b/crates/ui-egui/src/object_ui.rs @@ -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()) diff --git a/crates/ui-egui/tests/comments.rs b/crates/ui-egui/tests/comments.rs index a86ee0d76c..b570a050e1 100644 --- a/crates/ui-egui/tests/comments.rs +++ b/crates/ui-egui/tests/comments.rs @@ -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] diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 971c8bf9c8..daa2c36744 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -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"); +} From bf4c238eba431781ef111d81ed1c421fa940968c Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Wed, 14 Oct 2026 20:40:13 +0530 Subject: [PATCH 04/53] Fixes As Requested [Arranging works,Cropping fix, Small size issue,fonts,image override] --- crates/edit/src/images.rs | 95 ++++++++++++++++++++---------- crates/edit/src/tests.rs | 37 ++++++++++++ crates/ui-egui/src/content_ui.rs | 72 ++++++++++------------ crates/ui-egui/src/edit_text_ui.rs | 56 ++++++++++++++---- crates/ui-egui/src/lib.rs | 3 +- crates/ui-egui/tests/editing.rs | 54 ++++++++++++++++- 6 files changed, 232 insertions(+), 85 deletions(-) diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 8bb6949f99..be963244e0 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -603,33 +603,72 @@ pub fn arrange_image(doc: &mut Document, page: usize, index: usize, order: Arran }; let Some(target_unit) = units.get(target_idx) else { return Ok(()) }; - let (scope_start, scope_end, placement) = image_transform_scope(&ops, target)?.unwrap_or((target, target, Matrix(img.draw_matrix))); + let has_other_paint = ops.iter().enumerate().any(|(i, op)| { + i >= target_unit.start + && i < target_unit.end + && i != target + && 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*" + ) + }); - let unit_ops = if scope_start != scope_end && target_unit.start == scope_start && target_unit.end == scope_end.saturating_add(1) { - if placement == Matrix::IDENTITY { - ops.get(scope_start..=scope_end).unwrap_or_default().to_vec() + let (target_span, unit_ops) = if has_other_paint { + if let Ok(Some((sub_start, sub_end, sub_placement))) = image_exclusive_scope(&ops, target) + && sub_start >= target_unit.start + && sub_end < target_unit.end + { + let u_ops = if sub_placement == Matrix::IDENTITY { + ops.get(sub_start..=sub_end).unwrap_or_default().to_vec() + } else { + let mut out = Vec::new(); + out.push(Op::new("q", vec![])); + out.push(Op::new("cm", sub_placement.0.map(pdfcraft_content::num).to_vec())); + if let Some(inner) = ops.get(sub_start.saturating_add(1)..=sub_end) { + out.extend_from_slice(inner); + } + out + }; + let start_op = ops.get(sub_start).unwrap_or(&ops[target]); + let end_op = ops.get(sub_end).unwrap_or(&ops[target]); + (start_op.span.start..end_op.span.end, u_ops) } else { - let mut out = Vec::new(); - out.push(Op::new("q", vec![])); - out.push(Op::new("cm", placement.0.map(pdfcraft_content::num).to_vec())); - if let Some(inner) = ops.get(scope_start.saturating_add(1)..=scope_end) { - out.extend_from_slice(inner); - } - out + let u_ops = vec![ + Op::new("q", vec![]), + Op::new("cm", img.draw_matrix.map(pdfcraft_content::num).to_vec()), + ops.get(target).cloned().unwrap_or_else(|| Op::new("Do", vec![Object::name(&img.name)])), + Op::new("Q", vec![]), + ]; + let target_op = &ops[target]; + (target_op.span.start..target_op.span.end, u_ops) } } else { - vec![ - Op::new("q", vec![]), - Op::new("cm", img.draw_matrix.map(pdfcraft_content::num).to_vec()), - ops.get(target).cloned().unwrap_or_else(|| Op::new("Do", vec![Object::name(&img.name)])), - Op::new("Q", vec![]), - ] + let (scope_start, scope_end, placement) = image_transform_scope(&ops, target)?.unwrap_or((target, target, Matrix(img.draw_matrix))); + let u_ops = if scope_start != scope_end && target_unit.start == scope_start && target_unit.end == scope_end.saturating_add(1) { + if placement == Matrix::IDENTITY { + ops.get(scope_start..=scope_end).unwrap_or_default().to_vec() + } else { + let mut out = Vec::new(); + out.push(Op::new("q", vec![])); + out.push(Op::new("cm", placement.0.map(pdfcraft_content::num).to_vec())); + if let Some(inner) = ops.get(scope_start.saturating_add(1)..=scope_end) { + out.extend_from_slice(inner); + } + out + } + } else { + vec![ + Op::new("q", vec![]), + Op::new("cm", img.draw_matrix.map(pdfcraft_content::num).to_vec()), + ops.get(target).cloned().unwrap_or_else(|| Op::new("Do", vec![Object::name(&img.name)])), + Op::new("Q", vec![]), + ] + }; + let Some(first_op) = ops.get(target_unit.start) else { return Ok(()) }; + let Some(last_op) = target_unit.end.checked_sub(1).and_then(|i| ops.get(i)) else { return Ok(()) }; + (first_op.span.start..last_op.span.end, u_ops) }; - let Some(first_op) = ops.get(target_unit.start) else { return Ok(()) }; - let Some(last_op) = target_unit.end.checked_sub(1).and_then(|i| ops.get(i)) else { return Ok(()) }; - let target_span = first_op.span.start..last_op.span.end; - let dest_pos = if insert_after { let Some(op) = dest_unit.end.checked_sub(1).and_then(|i| ops.get(i)) else { return Ok(()) }; op.span.end @@ -851,21 +890,15 @@ pub fn crop_image(doc: &mut Document, page: usize, index: usize, crop_rect: [f64 (start, end, drawing_ops, scope_matrix) } else { 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; + if i == target { break; } state.apply(o)?; } - let drawing_ops = ops.get(start..=target).unwrap_or_default().to_vec(); - (start, target, drawing_ops, scope_matrix) + let scope_matrix = state.placement.matrix; + let drawing_ops = vec![ops.get(target).cloned().unwrap_or_else(|| Op::new("Do", vec![Object::name(&img.name)]))]; + (target, target, drawing_ops, scope_matrix) }; let mut unit_ops = Vec::new(); diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index a9f61f8bbd..9d74024daa 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -1768,6 +1768,43 @@ fn image_arrange_stacking_order_and_remove_crop() { assert_eq!(img_restored_moved.visible_rect, Some(img_restored_moved.rect)); } +#[test] +fn image_arrange_preserves_shared_group_drawings() { + let content = "q 100 0 0 100 100 100 cm /Im0 Do /Im0 Do Q BT /F1 12 Tf 72 700 Td (End Text) Tj ET"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 2, "both images initially present in shared group"); + + // Bring image 0 to front: it must not delete image 1 that shares the same q...Q group! + images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 2, "both images must be preserved after BringToFront"); + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + assert!(str_content.contains("End Text"), "text in page must be preserved"); +} + +#[test] +fn image_crop_preserves_outer_cm_for_subsequent_drawings() { + let content = "100 0 0 100 50 50 cm /Im0 Do /Im0 Do"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 2); + assert_eq!(initial[0].rect, [50.0, 50.0, 150.0, 150.0]); + assert_eq!(initial[1].rect, [50.0, 50.0, 150.0, 150.0]); + + // Crop image 0 + images::crop_image(&mut doc, 0, 0, [60.0, 60.0, 120.0, 120.0]).unwrap(); + let doc = reopen(&doc); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 2, "both images must remain"); + // Image 0 is cropped: + assert!(close(after[0].visible_rect.unwrap(), [60.0, 60.0, 120.0, 120.0])); + // Crucial: Image 1 must still have its rect at [50, 50, 150, 150], proving outer cm was not destroyed! + assert_eq!(after[1].rect, [50.0, 50.0, 150.0, 150.0], "subsequent image must retain global cm"); +} + #[test] fn form_artwork_edits_only_the_selected_placement() { diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 20a609ad3e..1b625e1290 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -560,25 +560,6 @@ pub(crate) fn format_panel(ui: &mut egui::Ui, t: &Tokens, style: &AddedText) -> for f in [FontFamily::Helvetica, FontFamily::Times, FontFamily::Courier] { ui.selectable_value(&mut s.family, f, f.label()); } - #[cfg(not(target_arch = "wasm32"))] - if let Some(installed) = crate::files::installed_font_files(ui.ctx()) - && !installed.is_empty() - { - ui.separator(); - ui.label(egui::RichText::new(tl!("Installed fonts")).weak().small()); - for font in &installed { - let fam = if font.family.eq_ignore_ascii_case("times") || font.face.to_ascii_lowercase().contains("times") { - FontFamily::Times - } else if font.family.eq_ignore_ascii_case("courier") || font.face.to_ascii_lowercase().contains("courier") { - FontFamily::Courier - } else { - FontFamily::Helvetica - }; - if ui.selectable_label(s.family == fam, &font.face).clicked() { - s.family = fam; - } - } - } }); ui.horizontal(|ui| { egui::ComboBox::from_id_salt("font-size") @@ -789,18 +770,16 @@ impl crate::PdfCraftApp { /// 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() + && cb.get_image().is_ok() { - return cb.get_image().is_ok(); + return true; } } - false + self.last_copied_image.is_some() || self.last_copied_file.is_some() } /// Paste an image from the clipboard onto `page`. @@ -813,27 +792,40 @@ impl crate::PdfCraftApp { return false; } - let image_data: Option<(String, Vec)> = 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"))] + #[cfg(not(target_arch = "wasm32"))] + let os_png = if self.system_clipboard { + if let Ok(mut cb) = arboard::Clipboard::new() + && let Ok(img) = cb.get_image() { - 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)) - }) + let matches_internal = self.last_copied_image.as_ref().is_some_and(|(w, h, rgba)| { + *w as usize == img.width && *h as usize == img.height && rgba.as_slice() == img.bytes.as_ref() + }); + if !matches_internal { + crate::create_ui::png_from_rgba(img.width, img.height, &img.bytes).ok().map(|bytes| { + (img.width as u32, img.height as u32, img.bytes.to_vec(), bytes) + }) } else { None } + } else { + None } - #[cfg(target_arch = "wasm32")] + } else { + None + }; + #[cfg(target_arch = "wasm32")] + let os_png: Option<(u32, u32, Vec, Vec)> = None; + + let image_data: Option<(String, Vec)> = if let Some((w, h, rgba, png_bytes)) = os_png { + self.last_copied_image = Some((w, h, rgba)); + self.last_copied_file = Some(("clipboard_image.png".to_string(), png_bytes.clone())); + self.last_copied_image_rect = None; + Some(("clipboard_image.png".to_string(), png_bytes)) + } else 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 { None }; diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 48b8a7d552..7af7b1091e 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -975,41 +975,73 @@ pub(crate) fn image_input( let initial = drag.initial_rect; if is_crop { let mut new_crop = initial; + let img_screen_w = b.width().max(0.0); + let img_screen_h = b.height().max(0.0); + let safe_min_w = 10.0_f32.min(img_screen_w).min(initial.width().max(0.0)).max(0.0); + let safe_min_h = 10.0_f32.min(img_screen_h).min(initial.height().max(0.0)).max(0.0); 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); + let min_x = b.left(); + let max_x = (initial.right() - safe_min_w).max(min_x); + let x = (initial.left() + delta.x).clamp(min_x, max_x); + + let min_y = b.top(); + let max_y = (initial.bottom() - safe_min_h).max(min_y); + let y = (initial.top() + delta.y).clamp(min_y, max_y); 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); + let min_x = (initial.left() + safe_min_w).min(b.right()); + let max_x = b.right().max(min_x); + let x = (initial.right() + delta.x).clamp(min_x, max_x); + + let min_y = b.top(); + let max_y = (initial.bottom() - safe_min_h).max(min_y); + let y = (initial.top() + delta.y).clamp(min_y, max_y); 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()); + let min_x = b.left(); + let max_x = (initial.right() - safe_min_w).max(min_x); + let x = (initial.left() + delta.x).clamp(min_x, max_x); + + let min_y = (initial.top() + safe_min_h).min(b.bottom()); + let max_y = b.bottom().max(min_y); + let y = (initial.bottom() + delta.y).clamp(min_y, max_y); 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()); + let min_x = (initial.left() + safe_min_w).min(b.right()); + let max_x = b.right().max(min_x); + let x = (initial.right() + delta.x).clamp(min_x, max_x); + + let min_y = (initial.top() + safe_min_h).min(b.bottom()); + let max_y = b.bottom().max(min_y); + let y = (initial.bottom() + delta.y).clamp(min_y, max_y); 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); + let min_y = b.top(); + let max_y = (initial.bottom() - safe_min_h).max(min_y); + let y = (initial.top() + delta.y).clamp(min_y, max_y); new_crop = Rect::from_min_max(Pos2::new(initial.left(), y), initial.max); } DragTarget::CropHandle(ImageHandle::BottomCenter) => { - let y = (initial.bottom() + delta.y).clamp(initial.top() + 10.0, b.bottom()); + let min_y = (initial.top() + safe_min_h).min(b.bottom()); + let max_y = b.bottom().max(min_y); + let y = (initial.bottom() + delta.y).clamp(min_y, max_y); new_crop = Rect::from_min_max(initial.min, Pos2::new(initial.right(), y)); } DragTarget::CropHandle(ImageHandle::LeftCenter) => { - let x = (initial.left() + delta.x).clamp(b.left(), initial.right() - 10.0); + let min_x = b.left(); + let max_x = (initial.right() - safe_min_w).max(min_x); + let x = (initial.left() + delta.x).clamp(min_x, max_x); new_crop = Rect::from_min_max(Pos2::new(x, initial.top()), initial.max); } DragTarget::CropHandle(ImageHandle::RightCenter) => { - let x = (initial.right() + delta.x).clamp(initial.left() + 10.0, b.right()); + let min_x = (initial.left() + safe_min_w).min(b.right()); + let max_x = b.right().max(min_x); + let x = (initial.right() + delta.x).clamp(min_x, max_x); new_crop = Rect::from_min_max(initial.min, Pos2::new(x, initial.bottom())); } DragTarget::Move => { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1fddfa5363..49fb3b5fcb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -73,7 +73,8 @@ mod autoscroll; mod bidi; pub mod bulk_fields; mod dialogs; -mod edit_text_ui; +pub mod edit_text_ui; +pub use edit_text_ui::{DragTarget, ImageDrag, ImageHandle, ImageSelection}; mod editing; #[cfg(not(target_arch = "wasm32"))] pub mod file_watch; diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index daa2c36744..f7630f955d 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -2717,11 +2717,63 @@ fn image_edge_handle_stretch_and_crop_mode() { 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] +fn tiny_image_crop_handle_drag_does_not_panic() { + let mut png = Vec::new(); + image::RgbImage::from_pixel(5, 5, image::Rgb([100, 150, 200])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(800.0, 600.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("tiny.png", None, png.clone()).expect("opens"); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + + // Enter crop mode on image 0 + let view = &mut h.state_mut().views[0]; + view.image_selection = Some(pdfcraft_ui_egui::ImageSelection { + page: 0, + index: 0, + drag: None, + crop_mode: true, + crop_rect: None, + }); + h.run_steps(1); + + // Simulate drag on TopLeft crop handle with an initial_rect of 5.0 x 5.0 + let initial = egui::Rect::from_min_size(egui::pos2(100.0, 100.0), egui::vec2(5.0, 5.0)); + let view = &mut h.state_mut().views[0]; + if let Some(s) = view.image_selection.as_mut() { + s.drag = Some(pdfcraft_ui_egui::ImageDrag { + start: egui::pos2(100.0, 100.0), + target: pdfcraft_ui_egui::DragTarget::CropHandle(pdfcraft_ui_egui::ImageHandle::TopLeft), + initial_rect: initial, + }); + } + + // Hover/drag to a new position, triggering the clamp calculation + h.hover_at(egui::pos2(105.0, 105.0)); + h.run_steps(2); + + // Also test BottomRight handle + let view = &mut h.state_mut().views[0]; + if let Some(s) = view.image_selection.as_mut() { + s.drag = Some(pdfcraft_ui_egui::ImageDrag { + start: egui::pos2(105.0, 105.0), + target: pdfcraft_ui_egui::DragTarget::CropHandle(pdfcraft_ui_egui::ImageHandle::BottomRight), + initial_rect: initial, + }); + } + h.hover_at(egui::pos2(95.0, 95.0)); + h.run_steps(2); +} + #[test] fn text_selection_action_icons() { let mut h = harness(1, |_| {}); From fde5abb1230f218a93d400ffdb0b1c1f2002a9a8 Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Wed, 14 Oct 2026 21:16:41 +0530 Subject: [PATCH 05/53] Fixes As Requested [image transformer,Image extraction,stale cached image] --- crates/edit/src/images.rs | 110 +++++++++++++++++-------------- crates/edit/src/tests.rs | 41 ++++++++++++ crates/ui-egui/src/content_ui.rs | 52 ++++++++------- crates/ui-egui/tests/editing.rs | 24 +++++++ 4 files changed, 154 insertions(+), 73 deletions(-) diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index be963244e0..b54ecd5bf5 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -613,69 +613,81 @@ pub fn arrange_image(doc: &mut Document, page: usize, index: usize, order: Arran ) }); - let (target_span, unit_ops) = if has_other_paint { - if let Ok(Some((sub_start, sub_end, sub_placement))) = image_exclusive_scope(&ops, target) + let (dest_pos, scan_target) = if insert_after { + let Some(op) = dest_unit.end.checked_sub(1).and_then(|i| ops.get(i)) else { return Ok(()) }; + (op.span.end, dest_unit.end) + } else { + let Some(op) = ops.get(dest_unit.start) else { return Ok(()) }; + (op.span.start, dest_unit.start) + }; + + let mut state = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); + for (i, op) in ops.iter().enumerate() { + if i == scan_target { + break; + } + state.apply(op)?; + } + let dest_ctm = state.placement.matrix; + let Some(dest_inv) = dest_ctm.invert() else { return Ok(()); }; + + let target_span = if has_other_paint { + if let Ok(Some((sub_start, sub_end, _))) = image_exclusive_scope(&ops, target) && sub_start >= target_unit.start && sub_end < target_unit.end { - let u_ops = if sub_placement == Matrix::IDENTITY { - ops.get(sub_start..=sub_end).unwrap_or_default().to_vec() - } else { - let mut out = Vec::new(); - out.push(Op::new("q", vec![])); - out.push(Op::new("cm", sub_placement.0.map(pdfcraft_content::num).to_vec())); - if let Some(inner) = ops.get(sub_start.saturating_add(1)..=sub_end) { - out.extend_from_slice(inner); - } - out - }; let start_op = ops.get(sub_start).unwrap_or(&ops[target]); let end_op = ops.get(sub_end).unwrap_or(&ops[target]); - (start_op.span.start..end_op.span.end, u_ops) + start_op.span.start..end_op.span.end } else { - let u_ops = vec![ - Op::new("q", vec![]), - Op::new("cm", img.draw_matrix.map(pdfcraft_content::num).to_vec()), - ops.get(target).cloned().unwrap_or_else(|| Op::new("Do", vec![Object::name(&img.name)])), - Op::new("Q", vec![]), - ]; let target_op = &ops[target]; - (target_op.span.start..target_op.span.end, u_ops) + target_op.span.start..target_op.span.end } } else { - let (scope_start, scope_end, placement) = image_transform_scope(&ops, target)?.unwrap_or((target, target, Matrix(img.draw_matrix))); - let u_ops = if scope_start != scope_end && target_unit.start == scope_start && target_unit.end == scope_end.saturating_add(1) { - if placement == Matrix::IDENTITY { - ops.get(scope_start..=scope_end).unwrap_or_default().to_vec() - } else { - let mut out = Vec::new(); - out.push(Op::new("q", vec![])); - out.push(Op::new("cm", placement.0.map(pdfcraft_content::num).to_vec())); - if let Some(inner) = ops.get(scope_start.saturating_add(1)..=scope_end) { - out.extend_from_slice(inner); - } - out - } - } else { - vec![ - Op::new("q", vec![]), - Op::new("cm", img.draw_matrix.map(pdfcraft_content::num).to_vec()), - ops.get(target).cloned().unwrap_or_else(|| Op::new("Do", vec![Object::name(&img.name)])), - Op::new("Q", vec![]), - ] - }; let Some(first_op) = ops.get(target_unit.start) else { return Ok(()) }; let Some(last_op) = target_unit.end.checked_sub(1).and_then(|i| ops.get(i)) else { return Ok(()) }; - (first_op.span.start..last_op.span.end, u_ops) + first_op.span.start..last_op.span.end }; - let dest_pos = if insert_after { - let Some(op) = dest_unit.end.checked_sub(1).and_then(|i| ops.get(i)) else { return Ok(()) }; - op.span.end + let is_clipped = img.visible_rect.is_some() && img.visible_rect != Some(img.rect); + if has_other_paint && is_clipped && img.clips.len() > 1 { + // Safely decline extraction when complex multi-path clipping cannot be preserved + return Ok(()); + } + + let mut unit_ops = Vec::new(); + unit_ops.push(Op::new("q", vec![])); + if is_clipped { + if dest_ctm != Matrix::IDENTITY { + unit_ops.push(Op::new("cm", dest_inv.0.map(pdfcraft_content::num).to_vec())); + } + if let Some(clip) = img.visible_rect { + let cx0 = clip[0]; + let cy0 = clip[1]; + let cw = clip[2] - clip[0]; + let ch = clip[3] - clip[1]; + if cw > 0.0 && ch > 0.0 { + unit_ops.push(Op::new("re", vec![ + pdfcraft_content::num(cx0), + pdfcraft_content::num(cy0), + pdfcraft_content::num(cw), + pdfcraft_content::num(ch), + ])); + unit_ops.push(Op::new("W", vec![])); + unit_ops.push(Op::new("n", vec![])); + } + } + if Matrix(img.draw_matrix) != Matrix::IDENTITY { + unit_ops.push(Op::new("cm", img.draw_matrix.map(pdfcraft_content::num).to_vec())); + } } else { - let Some(op) = ops.get(dest_unit.start) else { return Ok(()) }; - op.span.start - }; + let local = Matrix(img.draw_matrix).then(&dest_inv); + if local != Matrix::IDENTITY { + unit_ops.push(Op::new("cm", local.0.map(pdfcraft_content::num).to_vec())); + } + } + unit_ops.push(ops.get(target).cloned().unwrap_or_else(|| Op::new("Do", vec![Object::name(&img.name)]))); + unit_ops.push(Op::new("Q", vec![])); let mut serialized = Vec::new(); serialized.push(b'\n'); diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 9d74024daa..af3c6c3908 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -1805,6 +1805,47 @@ fn image_crop_preserves_outer_cm_for_subsequent_drawings() { assert_eq!(after[1].rect, [50.0, 50.0, 150.0, 150.0], "subsequent image must retain global cm"); } +#[test] +fn image_arrange_preserves_position_under_active_ctm() { + let content = "100 0 0 100 50 50 cm /Im0 Do /Im0 Do"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 2); + assert_eq!(initial[0].rect, [50.0, 50.0, 150.0, 150.0]); + assert_eq!(initial[1].rect, [50.0, 50.0, 150.0, 150.0]); + + // Move first image to front: must not apply destination CTM twice! + images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 2); + assert_eq!(after[0].rect, [50.0, 50.0, 150.0, 150.0], "first image position preserved"); + assert_eq!(after[1].rect, [50.0, 50.0, 150.0, 150.0], "second image position preserved"); +} + +#[test] +fn image_arrange_preserves_shared_group_clipping_bounds() { + let content = "q 120 120 40 40 re W n 100 0 0 100 100 100 cm /Im0 Do /Im0 Do Q BT /F1 12 Tf 72 700 Td (External Text) Tj ET"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 2); + assert_eq!(initial[0].rect, [100.0, 100.0, 200.0, 200.0]); + assert_eq!(initial[0].visible_rect, Some([120.0, 120.0, 160.0, 160.0])); + assert_eq!(initial[1].rect, [100.0, 100.0, 200.0, 200.0]); + assert_eq!(initial[1].visible_rect, Some([120.0, 120.0, 160.0, 160.0])); + + // Bring image 0 to front: moves it past the external text while preserving clipping bounds + images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 2, "both images preserved"); + assert_eq!(after[0].visible_rect, Some([120.0, 120.0, 160.0, 160.0]), "image 0 retains clip"); + assert_eq!(after[1].visible_rect, Some([120.0, 120.0, 160.0, 160.0]), "image 1 retains clip"); + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + assert!(str_content.contains("External Text"), "external text preserved"); +} + #[test] fn form_artwork_edits_only_the_selected_placement() { diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 1b625e1290..1865032206 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -774,9 +774,8 @@ impl crate::PdfCraftApp { { if self.system_clipboard && let Ok(mut cb) = arboard::Clipboard::new() - && cb.get_image().is_ok() { - return true; + return cb.get_image().is_ok(); } } self.last_copied_image.is_some() || self.last_copied_file.is_some() @@ -793,34 +792,39 @@ impl crate::PdfCraftApp { } #[cfg(not(target_arch = "wasm32"))] - let os_png = if self.system_clipboard { - if let Ok(mut cb) = arboard::Clipboard::new() - && let Ok(img) = cb.get_image() - { - let matches_internal = self.last_copied_image.as_ref().is_some_and(|(w, h, rgba)| { - *w as usize == img.width && *h as usize == img.height && rgba.as_slice() == img.bytes.as_ref() - }); - if !matches_internal { - crate::create_ui::png_from_rgba(img.width, img.height, &img.bytes).ok().map(|bytes| { - (img.width as u32, img.height as u32, img.bytes.to_vec(), bytes) - }) - } else { - None - } + let (has_system_cb, os_image_res) = if self.system_clipboard { + if let Ok(mut cb) = arboard::Clipboard::new() { + (true, Some(cb.get_image())) } else { - None + (false, None) } } else { - None + (false, None) }; #[cfg(target_arch = "wasm32")] - let os_png: Option<(u32, u32, Vec, Vec)> = None; + let (has_system_cb, os_image_res): (bool, Option, arboard::Error>>) = (false, None); - let image_data: Option<(String, Vec)> = if let Some((w, h, rgba, png_bytes)) = os_png { - self.last_copied_image = Some((w, h, rgba)); - self.last_copied_file = Some(("clipboard_image.png".to_string(), png_bytes.clone())); - self.last_copied_image_rect = None; - Some(("clipboard_image.png".to_string(), png_bytes)) + let image_data: Option<(String, Vec)> = if has_system_cb { + match os_image_res { + Some(Ok(img)) => { + let matches_internal = self.last_copied_image.as_ref().is_some_and(|(w, h, rgba)| { + *w as usize == img.width && *h as usize == img.height && rgba.as_slice() == img.bytes.as_ref() + }); + if matches_internal && let Some((name, bytes)) = self.last_copied_file.clone() { + Some((name, bytes)) + } else if let Ok(png_bytes) = crate::create_ui::png_from_rgba(img.width, img.height, &img.bytes) { + self.last_copied_image = Some((img.width as u32, img.height as u32, img.bytes.to_vec())); + self.last_copied_file = Some(("clipboard_image.png".to_string(), png_bytes.clone())); + self.last_copied_image_rect = None; + Some(("clipboard_image.png".to_string(), png_bytes)) + } else { + None + } + } + // When system clipboard is active and accessible but contains text or non-image data: + // Do NOT fall back to stale in-memory cached image! + _ => None, + } } else 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() { diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index f7630f955d..9055414169 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -2717,6 +2717,7 @@ fn image_edge_handle_stretch_and_crop_mode() { 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"); } @@ -4479,3 +4480,26 @@ fn image_copy_cut_paste_keyboard_and_context_menu() { let after_paste2 = h.state().session.get(id).unwrap().page_images(0).len(); assert_eq!(after_paste2, 2, "pasted via context menu"); } + +#[test] +fn stale_clipboard_plain_text_does_not_paste_image() { + let mut h = harness(1, |_| {}); + h.run_steps(4); + // Put an image into internal cache + h.state_mut().last_copied_image = Some((10, 10, vec![0; 400])); + h.state_mut().last_copied_file = Some(("cached.png".to_string(), vec![1, 2, 3])); + h.state_mut().system_clipboard = true; + + // Set actual system clipboard to plain text + #[cfg(not(target_arch = "wasm32"))] + if let Ok(mut cb) = arboard::Clipboard::new() { + cb.set_text("just plain text").ok(); + } + + // can_paste_image should now be false because the system clipboard has text, not an image! + assert!(!h.state().can_paste_image(), "cannot paste image when system clipboard has text"); + + // paste_image should fail and return false without pasting + assert!(!h.state_mut().paste_image(0, None), "must not paste stale cached image"); +} + From 0ca27d3145c7da5c0c20399309c3ed761967c58b Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Wed, 14 Oct 2026 21:57:52 +0530 Subject: [PATCH 06/53] Fixes As Requested [WASM Build Failure, Clipping Path Destruction, Graphics State(opacity)] --- crates/edit/src/images.rs | 147 ++++++++++++++++++++++++++----- crates/edit/src/tests.rs | 40 +++++++++ crates/ui-egui/src/content_ui.rs | 23 ++--- 3 files changed, 179 insertions(+), 31 deletions(-) diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index b54ecd5bf5..9b73dddac9 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -538,6 +538,109 @@ fn content_units(ops: &[Op], scan_start: usize, scan_end: usize) -> Vec, + state_ops: Vec, +} + +fn extract_scope_state(ops: &[Op], start: usize, target: usize) -> Result, EditError> { + if start >= target || target >= ops.len() { + return Ok(None); + } + let mut clip_ops = Vec::new(); + let mut state_ops = Vec::new(); + let mut pending_path: Vec = Vec::new(); + let mut pending_clip = false; + let mut active_path = false; + let mut depth: usize = 0; + let mut matrix = Matrix::IDENTITY; + let mut matrix_at_clip = None; + + let scan_start = if ops.get(start).is_some_and(|o| o.is("q")) { + start.saturating_add(1) + } else { + start + }; + + for i in scan_start..target { + let Some(op) = ops.get(i) else { return Ok(None); }; + match op.op.as_slice() { + b"q" => { + if depth >= 256 { + return Err(EditError::Invalid("the image graphics state is too deeply nested".into())); + } + depth = depth.saturating_add(1); + } + b"Q" => { + if depth == 0 { + return Ok(None); + } + depth = depth.saturating_sub(1); + } + _ if depth > 0 => {} + b"cm" => { + if active_path { + return Ok(None); + } + if let Some(m) = op.nums::<6>() { + matrix = Matrix(m).then(&matrix); + } + } + b"m" | b"l" | b"c" | b"v" | b"y" | b"re" | b"h" => { + active_path = true; + pending_path.push(op.clone()); + } + b"W" | b"W*" => { + if !active_path { + return Ok(None); + } + pending_clip = true; + pending_path.push(op.clone()); + } + b"n" => { + if pending_clip { + if matrix_at_clip.is_none() { + matrix_at_clip = Some(matrix); + } else if matrix_at_clip != Some(matrix) { + return Ok(None); + } + clip_ops.extend(std::mem::take(&mut pending_path)); + clip_ops.push(op.clone()); + pending_clip = false; + active_path = false; + } else { + pending_path.clear(); + active_path = false; + } + } + b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" => { + if pending_clip { + return Ok(None); + } + pending_path.clear(); + active_path = false; + } + b"gs" | b"CS" | b"cs" | b"SC" | b"sc" | b"SCN" | b"scn" | b"G" | b"g" | b"RG" | b"rg" | b"K" | b"k" | b"ri" | b"i" => { + state_ops.push(op.clone()); + } + b"BT" => { + return Ok(None); + } + _ => {} + } + } + + if depth != 0 || active_path || pending_clip { + return Ok(None); + } + + if !clip_ops.is_empty() && matrix_at_clip != Some(Matrix::IDENTITY) { + return Ok(None); + } + + Ok(Some(ExtractedScopeState { clip_ops, state_ops })) +} + /// Change the stacking order of image `index` on `page`. pub fn arrange_image(doc: &mut Document, page: usize, index: usize, order: ArrangeOrder) -> Result<(), EditError> { let images = page_images(doc, page)?; @@ -649,34 +752,37 @@ pub fn arrange_image(doc: &mut Document, page: usize, index: usize, order: Arran first_op.span.start..last_op.span.end }; - let is_clipped = img.visible_rect.is_some() && img.visible_rect != Some(img.rect); - if has_other_paint && is_clipped && img.clips.len() > 1 { - // Safely decline extraction when complex multi-path clipping cannot be preserved + let scope_start = if has_other_paint { + if let Ok(Some((sub_start, sub_end, _))) = image_exclusive_scope(&ops, target) + && sub_start >= target_unit.start + && sub_end < target_unit.end + { + sub_start + } else { + target_unit.start + } + } else { + target_unit.start + }; + + let is_clipped = (img.visible_rect.is_some() && img.visible_rect != Some(img.rect)) || !img.clips.is_empty(); + + let extracted = extract_scope_state(&ops, scope_start, target)?; + let Some(extracted) = extracted else { + return Ok(()); + }; + + if is_clipped && extracted.clip_ops.is_empty() { return Ok(()); } let mut unit_ops = Vec::new(); unit_ops.push(Op::new("q", vec![])); - if is_clipped { + if !extracted.clip_ops.is_empty() { if dest_ctm != Matrix::IDENTITY { unit_ops.push(Op::new("cm", dest_inv.0.map(pdfcraft_content::num).to_vec())); } - if let Some(clip) = img.visible_rect { - let cx0 = clip[0]; - let cy0 = clip[1]; - let cw = clip[2] - clip[0]; - let ch = clip[3] - clip[1]; - if cw > 0.0 && ch > 0.0 { - unit_ops.push(Op::new("re", vec![ - pdfcraft_content::num(cx0), - pdfcraft_content::num(cy0), - pdfcraft_content::num(cw), - pdfcraft_content::num(ch), - ])); - unit_ops.push(Op::new("W", vec![])); - unit_ops.push(Op::new("n", vec![])); - } - } + unit_ops.extend(extracted.clip_ops); if Matrix(img.draw_matrix) != Matrix::IDENTITY { unit_ops.push(Op::new("cm", img.draw_matrix.map(pdfcraft_content::num).to_vec())); } @@ -686,6 +792,7 @@ pub fn arrange_image(doc: &mut Document, page: usize, index: usize, order: Arran unit_ops.push(Op::new("cm", local.0.map(pdfcraft_content::num).to_vec())); } } + unit_ops.extend(extracted.state_ops); unit_ops.push(ops.get(target).cloned().unwrap_or_else(|| Op::new("Do", vec![Object::name(&img.name)]))); unit_ops.push(Op::new("Q", vec![])); diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index af3c6c3908..782cd3acd9 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -1846,6 +1846,46 @@ fn image_arrange_preserves_shared_group_clipping_bounds() { assert!(str_content.contains("External Text"), "external text preserved"); } +#[test] +fn image_arrange_preserves_extended_graphics_state_opacity() { + let content = "q /GS0 gs 100 0 0 100 50 50 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (External Text) Tj ET"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 1); + assert_eq!(initial[0].rect, [50.0, 50.0, 150.0, 150.0]); + + images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 1, "image preserved"); + assert_eq!(after[0].rect, [50.0, 50.0, 150.0, 150.0]); + + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + assert!(str_content.contains("/GS0 gs"), "extended graphics state /GS0 gs is retained"); + let gs_pos = str_content.rfind("/GS0 gs").unwrap(); + let do_pos = str_content.rfind("/Im0 Do").unwrap(); + assert!(gs_pos < do_pos, "/GS0 gs must precede /Im0 Do in the new unit"); +} + +#[test] +fn image_arrange_preserves_exact_non_rectangular_clipping_path() { + let content = "q 120 120 m 160 120 l 140 160 l h W* n 100 0 0 100 100 100 cm /Im0 Do /Im0 Do Q BT /F1 12 Tf 72 700 Td (External Text) Tj ET"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 2); + + images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 2, "both images preserved"); + + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + assert!(str_content.contains("W*"), "even-odd W* fill rule must be preserved"); + assert!(str_content.contains("120 120 m 160 120 l 140 160 l h W* n"), "exact non-rectangular clip path preserved"); +} + #[test] fn form_artwork_edits_only_the_selected_placement() { diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 1865032206..70c03d0a1a 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -792,9 +792,10 @@ impl crate::PdfCraftApp { } #[cfg(not(target_arch = "wasm32"))] - let (has_system_cb, os_image_res) = if self.system_clipboard { + let (has_system_cb, os_image) = if self.system_clipboard { if let Ok(mut cb) = arboard::Clipboard::new() { - (true, Some(cb.get_image())) + let img_opt = cb.get_image().ok().map(|img| (img.width, img.height, img.bytes.to_vec())); + (true, img_opt) } else { (false, None) } @@ -802,18 +803,18 @@ impl crate::PdfCraftApp { (false, None) }; #[cfg(target_arch = "wasm32")] - let (has_system_cb, os_image_res): (bool, Option, arboard::Error>>) = (false, None); + let (has_system_cb, os_image): (bool, Option<(usize, usize, Vec)>) = (false, None); let image_data: Option<(String, Vec)> = if has_system_cb { - match os_image_res { - Some(Ok(img)) => { + match os_image { + Some((width, height, bytes)) => { let matches_internal = self.last_copied_image.as_ref().is_some_and(|(w, h, rgba)| { - *w as usize == img.width && *h as usize == img.height && rgba.as_slice() == img.bytes.as_ref() + *w as usize == width && *h as usize == height && rgba.as_slice() == bytes.as_slice() }); - if matches_internal && let Some((name, bytes)) = self.last_copied_file.clone() { - Some((name, bytes)) - } else if let Ok(png_bytes) = crate::create_ui::png_from_rgba(img.width, img.height, &img.bytes) { - self.last_copied_image = Some((img.width as u32, img.height as u32, img.bytes.to_vec())); + if matches_internal && let Some((name, file_bytes)) = self.last_copied_file.clone() { + Some((name, file_bytes)) + } else if let Ok(png_bytes) = crate::create_ui::png_from_rgba(width, height, &bytes) { + self.last_copied_image = Some((width as u32, height as u32, bytes)); self.last_copied_file = Some(("clipboard_image.png".to_string(), png_bytes.clone())); self.last_copied_image_rect = None; Some(("clipboard_image.png".to_string(), png_bytes)) @@ -823,7 +824,7 @@ impl crate::PdfCraftApp { } // When system clipboard is active and accessible but contains text or non-image data: // Do NOT fall back to stale in-memory cached image! - _ => None, + None => None, } } else if let Some((name, bytes)) = self.last_copied_file.clone() { Some((name, bytes)) From 8bd57468e892fbd9fe220e1b5ac6ceec0a17ce19 Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Wed, 14 Oct 2026 22:36:44 +0530 Subject: [PATCH 07/53] Fixes As Requested [Some images not moving, Opacity Mistakes] --- crates/edit/src/images.rs | 110 ++++++++++++++++++------- crates/edit/src/tests.rs | 164 ++++++++++++++++++++++++++++++++++---- 2 files changed, 228 insertions(+), 46 deletions(-) diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 9b73dddac9..311f17a4a2 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -543,10 +543,45 @@ struct ExtractedScopeState { state_ops: Vec, } +#[derive(Clone, Default)] +struct ExtendedGStateTracker { + stack: Vec>, + current_gs: Option, +} + +impl ExtendedGStateTracker { + fn step(&mut self, op: &Op) -> Result<(), EditError> { + match op.op.as_slice() { + b"q" => { + if self.stack.len() >= 256 { + return Err(EditError::Invalid("the image graphics state is too deeply nested".into())); + } + self.stack.push(self.current_gs.clone()); + } + b"Q" => { + if let Some(prev) = self.stack.pop() { + self.current_gs = prev; + } + } + b"gs" => { + self.current_gs = Some(op.clone()); + } + _ => {} + } + Ok(()) + } +} + fn extract_scope_state(ops: &[Op], start: usize, target: usize) -> Result, EditError> { - if start >= target || target >= ops.len() { + if target >= ops.len() || start > target { return Ok(None); } + if start == target { + return Ok(Some(ExtractedScopeState { + clip_ops: Vec::new(), + state_ops: Vec::new(), + })); + } let mut clip_ops = Vec::new(); let mut state_ops = Vec::new(); let mut pending_path: Vec = Vec::new(); @@ -725,55 +760,68 @@ pub fn arrange_image(doc: &mut Document, page: usize, index: usize, order: Arran }; let mut state = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); + let mut tracker = ExtendedGStateTracker::default(); + let mut source_effective_gs = None; + let mut dest_effective_gs = None; + let mut dest_ctm = Matrix::IDENTITY; + + let max_scan = scan_target.max(target.saturating_add(1)); for (i, op) in ops.iter().enumerate() { + if i == target { + source_effective_gs = tracker.current_gs.clone(); + } if i == scan_target { + dest_effective_gs = tracker.current_gs.clone(); + dest_ctm = state.placement.matrix; + } + if i >= max_scan { break; } state.apply(op)?; + tracker.step(op)?; + } + if scan_target >= ops.len() && dest_effective_gs.is_none() { + dest_effective_gs = tracker.current_gs.clone(); + dest_ctm = state.placement.matrix; } - let dest_ctm = state.placement.matrix; let Some(dest_inv) = dest_ctm.invert() else { return Ok(()); }; - let target_span = if has_other_paint { - if let Ok(Some((sub_start, sub_end, _))) = image_exclusive_scope(&ops, target) - && sub_start >= target_unit.start - && sub_end < target_unit.end - { - let start_op = ops.get(sub_start).unwrap_or(&ops[target]); - let end_op = ops.get(sub_end).unwrap_or(&ops[target]); - start_op.span.start..end_op.span.end - } else { - let target_op = &ops[target]; - target_op.span.start..target_op.span.end - } + let (target_span, scope_start) = if let Ok(Some((sub_start, sub_end, _))) = image_exclusive_scope(&ops, target) + && sub_start >= target_unit.start + && sub_end < target_unit.end + { + let start_op = ops.get(sub_start).unwrap_or(&ops[target]); + let end_op = ops.get(sub_end).unwrap_or(&ops[target]); + (start_op.span.start..end_op.span.end, sub_start) + } else if has_other_paint { + let target_op = &ops[target]; + (target_op.span.start..target_op.span.end, target_unit.start) } else { let Some(first_op) = ops.get(target_unit.start) else { return Ok(()) }; let Some(last_op) = target_unit.end.checked_sub(1).and_then(|i| ops.get(i)) else { return Ok(()) }; - first_op.span.start..last_op.span.end - }; - - let scope_start = if has_other_paint { - if let Ok(Some((sub_start, sub_end, _))) = image_exclusive_scope(&ops, target) - && sub_start >= target_unit.start - && sub_end < target_unit.end - { - sub_start - } else { - target_unit.start - } - } else { - target_unit.start + (first_op.span.start..last_op.span.end, target_unit.start) }; let is_clipped = (img.visible_rect.is_some() && img.visible_rect != Some(img.rect)) || !img.clips.is_empty(); let extracted = extract_scope_state(&ops, scope_start, target)?; - let Some(extracted) = extracted else { - return Ok(()); + let Some(mut extracted) = extracted else { + return Err(EditError::Invalid("unsupported graphics state: unable to cleanly extract image scope state".into())); }; if is_clipped && extracted.clip_ops.is_empty() { - return Ok(()); + return Err(EditError::Invalid("unsupported graphics state: inherited clipping path cannot be cleanly preserved".into())); + } + + if source_effective_gs.is_none() && dest_effective_gs.is_some() { + return Err(EditError::Invalid("unsupported graphics state: moving an opaque image into an active transparency scope is not supported".into())); + } + + if let Some(src_gs) = source_effective_gs { + let already_has_gs = extracted.state_ops.iter().any(|o| o.is("gs")); + if !already_has_gs { + extracted.state_ops.push(src_gs); + } } let mut unit_ops = Vec::new(); diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 782cd3acd9..dae7d30411 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -1817,10 +1817,22 @@ fn image_arrange_preserves_position_under_active_ctm() { // Move first image to front: must not apply destination CTM twice! images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); let doc = reopen(&doc); + + // 1. Position: check order of operators in the content stream + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + let first_do = str_content.find("/Im0 Do").unwrap(); + let last_do = str_content.rfind("/Im0 Do").unwrap(); + assert!(first_do < last_do, "Position: multiple image Do calls exist and order was updated"); + + // 2. Appearance: both images' positions and sizes are preserved let after = images::page_images(&doc, 0).unwrap(); assert_eq!(after.len(), 2); - assert_eq!(after[0].rect, [50.0, 50.0, 150.0, 150.0], "first image position preserved"); - assert_eq!(after[1].rect, [50.0, 50.0, 150.0, 150.0], "second image position preserved"); + assert_eq!(after[0].rect, [50.0, 50.0, 150.0, 150.0], "Appearance: first image position preserved"); + assert_eq!(after[1].rect, [50.0, 50.0, 150.0, 150.0], "Appearance: second image position preserved"); + + // 3. Preservation: outer CTM is preserved + assert!(str_content.contains("100 0 0 100 50 50 cm"), "Preservation: initial CTM preserved"); } #[test] @@ -1837,13 +1849,23 @@ fn image_arrange_preserves_shared_group_clipping_bounds() { // Bring image 0 to front: moves it past the external text while preserving clipping bounds images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); let doc = reopen(&doc); - let after = images::page_images(&doc, 0).unwrap(); - assert_eq!(after.len(), 2, "both images preserved"); - assert_eq!(after[0].visible_rect, Some([120.0, 120.0, 160.0, 160.0]), "image 0 retains clip"); - assert_eq!(after[1].visible_rect, Some([120.0, 120.0, 160.0, 160.0]), "image 1 retains clip"); + + // 1. Position: moved image Do now follows External Text in AST let bytes = page_content_bytes(&doc, 0); let str_content = String::from_utf8_lossy(&bytes); - assert!(str_content.contains("External Text"), "external text preserved"); + let text_pos = str_content.find("External Text").unwrap(); + let moved_do_pos = str_content.rfind("/Im0 Do").unwrap(); + assert!(text_pos < moved_do_pos, "Position: moved image Do follows External Text"); + + // 2. Appearance: both images retain exact clipping bounds and geometry + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 2, "both images preserved"); + assert_eq!(after[0].visible_rect, Some([120.0, 120.0, 160.0, 160.0]), "Appearance: image 0 retains clip"); + assert_eq!(after[1].visible_rect, Some([120.0, 120.0, 160.0, 160.0]), "Appearance: image 1 retains clip"); + + // 3. Preservation: External Text and original group drawing preserved + assert!(str_content.contains("External Text"), "Preservation: external text preserved"); + assert!(str_content.contains("120 120 40 40 re W n"), "Preservation: clipping path operators preserved"); } #[test] @@ -1856,16 +1878,24 @@ fn image_arrange_preserves_extended_graphics_state_opacity() { images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); let doc = reopen(&doc); - let after = images::page_images(&doc, 0).unwrap(); - assert_eq!(after.len(), 1, "image preserved"); - assert_eq!(after[0].rect, [50.0, 50.0, 150.0, 150.0]); + // 1. Position: /Im0 Do moved past External Text let bytes = page_content_bytes(&doc, 0); let str_content = String::from_utf8_lossy(&bytes); - assert!(str_content.contains("/GS0 gs"), "extended graphics state /GS0 gs is retained"); - let gs_pos = str_content.rfind("/GS0 gs").unwrap(); + let text_pos = str_content.find("External Text").unwrap(); let do_pos = str_content.rfind("/Im0 Do").unwrap(); - assert!(gs_pos < do_pos, "/GS0 gs must precede /Im0 Do in the new unit"); + assert!(text_pos < do_pos, "Position: /Im0 Do moved past External Text"); + + // 2. Appearance: /GS0 gs is retained and directly precedes /Im0 Do + let gs_pos = str_content.rfind("/GS0 gs").unwrap(); + assert!(text_pos < gs_pos, "Appearance: /GS0 gs is in the new moved unit"); + assert!(gs_pos < do_pos, "Appearance: /GS0 gs directly precedes /Im0 Do"); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 1, "image preserved"); + assert_eq!(after[0].rect, [50.0, 50.0, 150.0, 150.0], "Appearance: rect preserved"); + + // 3. Preservation: External Text is preserved + assert!(str_content.contains("BT /F1 12 Tf 72 700 Td (External Text) Tj ET"), "Preservation: text preserved"); } #[test] @@ -1877,13 +1907,117 @@ fn image_arrange_preserves_exact_non_rectangular_clipping_path() { images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); let doc = reopen(&doc); + + // 1. Position: moved image follows External Text in AST + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + let text_pos = str_content.find("External Text").unwrap(); + let last_do = str_content.rfind("/Im0 Do").unwrap(); + assert!(text_pos < last_do, "Position: moved image follows External Text"); + + // 2. Appearance: polygon geometry and even-odd rule are preserved + assert!(str_content.contains("W*"), "Appearance: even-odd W* fill rule preserved"); + assert!(str_content.contains("120 120 m 160 120 l 140 160 l h W* n"), "Appearance: exact polygon clip preserved"); let after = images::page_images(&doc, 0).unwrap(); assert_eq!(after.len(), 2, "both images preserved"); + // 3. Preservation: External Text and remaining original group content preserved + assert!(str_content.contains("External Text"), "Preservation: external text preserved"); +} + +#[test] +fn unwrapped_image_moves_successfully() { + let content = "100 0 0 100 50 50 cm /Im0 Do BT /F1 12 Tf 72 700 Td (Middle Text) Tj ET"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 1); + assert_eq!(initial[0].rect, [50.0, 50.0, 150.0, 150.0]); + + // Initial order: /Im0 Do precedes Middle Text + let initial_bytes = page_content_bytes(&doc, 0); + let initial_str = String::from_utf8_lossy(&initial_bytes); + let init_do = initial_str.find("/Im0 Do").unwrap(); + let init_text = initial_str.find("Middle Text").unwrap(); + assert!(init_do < init_text, "/Im0 Do initially precedes Middle Text"); + + // Move unwrapped image to front (past Middle Text) + images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + + // 1. Position: /Im0 Do now follows Middle Text in the content stream AST let bytes = page_content_bytes(&doc, 0); let str_content = String::from_utf8_lossy(&bytes); - assert!(str_content.contains("W*"), "even-odd W* fill rule must be preserved"); - assert!(str_content.contains("120 120 m 160 120 l 140 160 l h W* n"), "exact non-rectangular clip path preserved"); + let after_text = str_content.find("Middle Text").unwrap(); + let after_do = str_content.rfind("/Im0 Do").unwrap(); + assert!(after_text < after_do, "Position: /Im0 Do actually moved after Middle Text"); + + // 2. Appearance: image rect and visibility are unchanged + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 1); + assert_eq!(after[0].rect, [50.0, 50.0, 150.0, 150.0], "Appearance: image coordinates preserved"); + assert_eq!(after[0].visible_rect, Some([50.0, 50.0, 150.0, 150.0])); + + // 3. Preservation: Middle Text and CTM remain intact + assert!(str_content.contains("BT /F1 12 Tf 72 700 Td (Middle Text) Tj ET"), "Preservation: text operation preserved"); + assert!(str_content.contains("100 0 0 100 50 50 cm"), "Preservation: original cm preserved"); +} + +#[test] +fn inherited_source_opacity_is_preserved() { + // Outer q sets /GS0 gs; inner q draws /Im0 Do; outer Q restores state before External Text + let content = "q /GS0 gs q 100 0 0 100 50 50 cm /Im0 Do Q Q BT /F1 12 Tf 72 700 Td (External Text) Tj ET"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 1); + assert_eq!(initial[0].rect, [50.0, 50.0, 150.0, 150.0]); + + // Initial order: /Im0 Do precedes External Text + let initial_bytes = page_content_bytes(&doc, 0); + let initial_str = String::from_utf8_lossy(&initial_bytes); + let init_do = initial_str.find("/Im0 Do").unwrap(); + let init_text = initial_str.find("External Text").unwrap(); + assert!(init_do < init_text, "/Im0 Do initially precedes External Text"); + + // Move image to front (past External Text, outside its original outer q...Q scope) + images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + + // 1. Position: /Im0 Do now follows External Text in the AST + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + let after_text = str_content.find("External Text").unwrap(); + let after_do = str_content.rfind("/Im0 Do").unwrap(); + assert!(after_text < after_do, "Position: /Im0 Do moved past External Text"); + + // 2. Appearance: effective appearance state (/GS0 gs) is injected and precedes /Im0 Do in the new unit + let after_gs = str_content.rfind("/GS0 gs").unwrap(); + assert!(after_text < after_gs, "Appearance: /GS0 gs injected into the new unit at destination"); + assert!(after_gs < after_do, "Appearance: /GS0 gs directly precedes /Im0 Do in the moved unit"); + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 1); + assert_eq!(after[0].rect, [50.0, 50.0, 150.0, 150.0], "Appearance: rect preserved"); + + // 3. Preservation: External Text and original outer scope structure are preserved + assert!(str_content.contains("BT /F1 12 Tf 72 700 Td (External Text) Tj ET"), "Preservation: external text preserved"); +} + +#[test] +fn destination_opacity_pollution_is_prevented() { + // /Im0 Do is fully opaque at the start; destination has active top-level /GS_translucent gs + let content = "100 0 0 100 50 50 cm /Im0 Do /GS_translucent gs BT /F1 12 Tf 72 700 Td (Translucent Text) Tj ET"; + let mut doc = image_page_with(content); + let initial_bytes = page_content_bytes(&doc, 0); + + // Attempting to move opaque image 0 to front into active transparency scope must safely bail out with Err + let res = images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront); + assert!(res.is_err(), "must return an explicit Err rather than silently corrupting opacity"); + let err_msg = res.unwrap_err().to_string(); + assert!(err_msg.contains("unsupported graphics state"), "error describes unsupported graphics state: {err_msg}"); + + // Preservation: the document contents are completely unmodified + let doc = reopen(&doc); + let after_bytes = page_content_bytes(&doc, 0); + assert_eq!(initial_bytes, after_bytes, "Preservation: content stream remained completely unmodified"); } From 8be74fdd16f06b16ddb8d9e9a25e495279037f21 Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Wed, 14 Oct 2026 23:18:24 +0530 Subject: [PATCH 08/53] Fixes As Requested [GS state, Pollution Guard]t --- crates/edit/src/images.rs | 132 ++++++++++++++++++++++++++++++++------ crates/edit/src/tests.rs | 56 +++++++++++++++- 2 files changed, 167 insertions(+), 21 deletions(-) diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 311f17a4a2..56d6ea56d7 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -543,28 +543,99 @@ struct ExtractedScopeState { state_ops: Vec, } +#[derive(Clone, Debug, Default, PartialEq)] +struct EffectiveGState { + ca: Option, + ca_stroke: Option, + bm: Option>, + smask: Option>, +} + +impl EffectiveGState { + fn effective_ca(&self) -> f64 { + self.ca.unwrap_or(1.0) + } + + fn effective_ca_stroke(&self) -> f64 { + self.ca_stroke.unwrap_or(1.0) + } + + fn effective_bm(&self) -> &[u8] { + self.bm.as_deref().unwrap_or(b"Normal") + } + + fn effective_smask(&self) -> Option<&[u8]> { + match self.smask.as_deref() { + Some(b"None") | None => None, + Some(other) => Some(other), + } + } + + fn visually_differs(&self, other: &EffectiveGState) -> bool { + (self.effective_ca() - other.effective_ca()).abs() > 1e-4 + || (self.effective_ca_stroke() - other.effective_ca_stroke()).abs() > 1e-4 + || self.effective_bm() != other.effective_bm() + || self.effective_smask() != other.effective_smask() + } + + fn merge_dict(&mut self, dict: &Dict, doc: &Document) { + if let Some(val) = dict.get(b"ca").map(|v| doc.resolve(v)).and_then(|v| v.as_f64()) { + self.ca = Some(val); + } + if let Some(val) = dict.get(b"CA").map(|v| doc.resolve(v)).and_then(|v| v.as_f64()) { + self.ca_stroke = Some(val); + } + if let Some(bm_obj) = dict.get(b"BM").map(|v| doc.resolve(v)) { + if let Some(name) = bm_obj.as_name() { + self.bm = Some(name.to_vec()); + } else if let Some(arr) = bm_obj.as_array() + && let Some(first_name) = arr.first().map(|v| doc.resolve(v)).and_then(|v| v.as_name().map(|n| n.to_vec())) + { + self.bm = Some(first_name); + } + } + if let Some(smask_obj) = dict.get(b"SMask").map(|v| doc.resolve(v)) { + if let Some(name) = smask_obj.as_name() { + self.smask = Some(name.to_vec()); + } else if smask_obj.as_dict().is_some() { + self.smask = Some(b"CustomSMask".to_vec()); + } + } + } +} + #[derive(Clone, Default)] struct ExtendedGStateTracker { - stack: Vec>, - current_gs: Option, + stack: Vec<(EffectiveGState, Vec)>, + current_state: EffectiveGState, + active_gs_ops: Vec, } impl ExtendedGStateTracker { - fn step(&mut self, op: &Op) -> Result<(), EditError> { + fn step(&mut self, op: &Op, ext_gstates: &Dict, doc: &Document) -> Result<(), EditError> { match op.op.as_slice() { b"q" => { if self.stack.len() >= 256 { return Err(EditError::Invalid("the image graphics state is too deeply nested".into())); } - self.stack.push(self.current_gs.clone()); + self.stack.push((self.current_state.clone(), self.active_gs_ops.clone())); } b"Q" => { - if let Some(prev) = self.stack.pop() { - self.current_gs = prev; + if let Some((prev_state, prev_ops)) = self.stack.pop() { + self.current_state = prev_state; + self.active_gs_ops = prev_ops; } } b"gs" => { - self.current_gs = Some(op.clone()); + if let Some(name) = op.name(0) { + if let Some(dict) = ext_gstates.get(name).map(|v| doc.resolve(v)).and_then(|v| v.as_dict().cloned()) { + self.current_state.merge_dict(&dict, doc); + } else if self.current_state.ca.is_none() { + self.current_state.ca = Some(0.5); + } + self.active_gs_ops.retain(|o| o.name(0) != Some(name)); + self.active_gs_ops.push(op.clone()); + } } _ => {} } @@ -759,29 +830,34 @@ pub fn arrange_image(doc: &mut Document, page: usize, index: usize, order: Arran (op.span.start, dest_unit.start) }; + let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let ext_gstates = res.get(b"ExtGState").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).unwrap_or_default(); + let mut state = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); let mut tracker = ExtendedGStateTracker::default(); - let mut source_effective_gs = None; - let mut dest_effective_gs = None; + let mut source_effective_state = EffectiveGState::default(); + let mut source_gs_ops = Vec::new(); + let mut dest_effective_state = EffectiveGState::default(); let mut dest_ctm = Matrix::IDENTITY; let max_scan = scan_target.max(target.saturating_add(1)); for (i, op) in ops.iter().enumerate() { if i == target { - source_effective_gs = tracker.current_gs.clone(); + source_effective_state = tracker.current_state.clone(); + source_gs_ops = tracker.active_gs_ops.clone(); } if i == scan_target { - dest_effective_gs = tracker.current_gs.clone(); + dest_effective_state = tracker.current_state.clone(); dest_ctm = state.placement.matrix; } if i >= max_scan { break; } state.apply(op)?; - tracker.step(op)?; + tracker.step(op, &ext_gstates, doc)?; } - if scan_target >= ops.len() && dest_effective_gs.is_none() { - dest_effective_gs = tracker.current_gs.clone(); + if scan_target >= ops.len() { + dest_effective_state = tracker.current_state.clone(); dest_ctm = state.placement.matrix; } let Some(dest_inv) = dest_ctm.invert() else { return Ok(()); }; @@ -813,17 +889,33 @@ pub fn arrange_image(doc: &mut Document, page: usize, index: usize, order: Arran return Err(EditError::Invalid("unsupported graphics state: inherited clipping path cannot be cleanly preserved".into())); } - if source_effective_gs.is_none() && dest_effective_gs.is_some() { - return Err(EditError::Invalid("unsupported graphics state: moving an opaque image into an active transparency scope is not supported".into())); + for src_gs in &source_gs_ops { + let name = src_gs.name(0); + let already_present = extracted.state_ops.iter().any(|o| o.name(0) == name); + if !already_present { + extracted.state_ops.push(src_gs.clone()); + } } - if let Some(src_gs) = source_effective_gs { - let already_has_gs = extracted.state_ops.iter().any(|o| o.is("gs")); - if !already_has_gs { - extracted.state_ops.push(src_gs); + let mut reconstructed_state = dest_effective_state.clone(); + for op in &extracted.state_ops { + if op.is("gs") + && let Some(name) = op.name(0) + { + if let Some(dict) = ext_gstates.get(name).map(|v| doc.resolve(v)).and_then(|v| v.as_dict().cloned()) { + reconstructed_state.merge_dict(&dict, doc); + } else if reconstructed_state.ca.is_none() { + reconstructed_state.ca = Some(0.5); + } } } + if reconstructed_state.visually_differs(&source_effective_state) { + return Err(EditError::Invalid( + "unsupported graphics state: moving the image would alter effective opacity, blend mode, or soft mask".into(), + )); + } + let mut unit_ops = Vec::new(); unit_ops.push(Op::new("q", vec![])); if !extracted.clip_ops.is_empty() { diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index dae7d30411..7cf9242cc2 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -1563,7 +1563,7 @@ fn image_page_with(content: &str) -> Document { let objs: Vec = vec![ "<< /Type /Catalog /Pages 2 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), - "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> >> >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> /ExtGState << /GS0 << /Type /ExtGState /ca 0.5 /CA 0.5 >> /GSalpha << /Type /ExtGState /ca 0.6 >> /GSblend << /Type /ExtGState /BM /Multiply >> /GS_translucent << /Type /ExtGState /ca 0.5 >> >> >> >>".into(), format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), "<< /Type /XObject /Subtype /Image /Width 4 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 8 >>\nstream\n\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\u{0}\nendstream".into(), @@ -2020,6 +2020,60 @@ fn destination_opacity_pollution_is_prevented() { assert_eq!(initial_bytes, after_bytes, "Preservation: content stream remained completely unmodified"); } +#[test] +fn sequential_gs_operators_merging_properties_are_preserved() { + // Sequential gs calls: /GSalpha sets ca=0.6, /GSblend sets BM=/Multiply (without touching ca). + let content = "q /GSalpha gs /GSblend gs q 100 0 0 100 50 50 cm /Im0 Do Q Q BT /F1 12 Tf 72 700 Td (External Text) Tj ET"; + let mut doc = image_page_with(content); + let initial = images::page_images(&doc, 0).unwrap(); + assert_eq!(initial.len(), 1); + assert_eq!(initial[0].rect, [50.0, 50.0, 150.0, 150.0]); + + images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let doc = reopen(&doc); + + // 1. Position: /Im0 Do now follows External Text in the AST + let bytes = page_content_bytes(&doc, 0); + let str_content = String::from_utf8_lossy(&bytes); + let after_text = str_content.find("External Text").unwrap(); + let after_do = str_content.rfind("/Im0 Do").unwrap(); + assert!(after_text < after_do, "Position: /Im0 Do moved past External Text"); + + // 2. Appearance: Both merged state operators (/GSalpha and /GSblend) are preserved and precede /Im0 Do + let after_alpha = str_content.rfind("/GSalpha gs").unwrap(); + let after_blend = str_content.rfind("/GSblend gs").unwrap(); + assert!(after_text < after_alpha, "Appearance: /GSalpha gs injected into moved unit"); + assert!(after_text < after_blend, "Appearance: /GSblend gs injected into moved unit"); + assert!(after_alpha < after_do, "Appearance: /GSalpha gs precedes /Im0 Do"); + assert!(after_blend < after_do, "Appearance: /GSblend gs precedes /Im0 Do"); + + let after = images::page_images(&doc, 0).unwrap(); + assert_eq!(after.len(), 1); + assert_eq!(after[0].rect, [50.0, 50.0, 150.0, 150.0], "Appearance: rect preserved"); + + // 3. Preservation: External Text is intact + assert!(str_content.contains("BT /F1 12 Tf 72 700 Td (External Text) Tj ET"), "Preservation: external text preserved"); +} + +#[test] +fn destination_differing_blend_mode_pollution_is_prevented() { + // /Im0 Do has default Normal blend mode; destination has active top-level /GSblend gs (BM = /Multiply) + let content = "100 0 0 100 50 50 cm /Im0 Do /GSblend gs BT /F1 12 Tf 72 700 Td (Blended Section) Tj ET"; + let mut doc = image_page_with(content); + let initial_bytes = page_content_bytes(&doc, 0); + + // Attempting to move Normal-blend image to front into active Multiply-blend scope must safely bail out with Err + let res = images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront); + assert!(res.is_err(), "must return an explicit Err rather than silently altering blend mode"); + let err_msg = res.unwrap_err().to_string(); + assert!(err_msg.contains("unsupported graphics state"), "error describes unsupported graphics state: {err_msg}"); + + // Preservation: document contents are completely unmodified + let doc = reopen(&doc); + let after_bytes = page_content_bytes(&doc, 0); + assert_eq!(initial_bytes, after_bytes, "Preservation: content stream remained completely unmodified"); +} + #[test] fn form_artwork_edits_only_the_selected_placement() { From 60925a85b69999aecb34100728cc15ee8ee03539 Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Thu, 15 Oct 2026 00:24:17 +0530 Subject: [PATCH 09/53] fix: address reviewer feedback regarding [GS operator related] --- Cargo.lock | 1 + crates/edit/Cargo.toml | 1 + crates/edit/src/images.rs | 83 ++++++++++++++++++++++++++-------- crates/edit/src/lib.rs | 2 +- crates/edit/src/tests.rs | 95 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 163 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index df58c57d21..52485e1e32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4027,6 +4027,7 @@ dependencies = [ "pdfcraft-cos", "pdfcraft-fonts", "pdfcraft-model", + "pdfcraft-render", "thiserror 2.0.21", "unicode-bidi", ] diff --git a/crates/edit/Cargo.toml b/crates/edit/Cargo.toml index 4eb2d6132b..f04f5ba17e 100644 --- a/crates/edit/Cargo.toml +++ b/crates/edit/Cargo.toml @@ -22,3 +22,4 @@ unicode-bidi = "0.3" [dev-dependencies] hayro-syntax = { workspace = true } +pdfcraft-render = { workspace = true } diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 56d6ea56d7..fdc62b026f 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -10,6 +10,27 @@ use std::sync::Arc; use crate::EditError; +/// The identity of a soft mask (`/SMask`) in the PDF graphics state. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum MaskIdentity { + /// Indirect reference to a mask dictionary or stream. + Ref(u32, u16), + /// Indirect reference to the transparency group (`/G`) in a mask dictionary. + Group(u32, u16), + /// Explicit named mask or resource name. + Named(Vec), +} + +impl std::fmt::Display for MaskIdentity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MaskIdentity::Ref(num, generation) => write!(f, "{num} {generation} R"), + MaskIdentity::Group(num, generation) => write!(f, "group {num} {generation} R"), + MaskIdentity::Named(name) => write!(f, "{}", String::from_utf8_lossy(name)), + } + } +} + /// An image drawn on a page. #[derive(Clone, Debug, PartialEq)] pub struct PageImage { @@ -30,6 +51,8 @@ pub struct PageImage { /// Pixel size; zero for vector/grouped Form artwork. pub width: u32, pub height: u32, + /// Active soft mask identity at the image placement, if any. + pub smask: Option, stream: usize, op: usize, /// CTM before `Do`, excluding a Form's own Matrix. @@ -213,12 +236,16 @@ impl ImageState { pub fn page_images(doc: &Document, page: usize) -> Result, EditError> { let p = page_of(doc, page)?; let xo = xobjects(doc, &p.dict); + let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let ext_gstates = res.get(b"ExtGState").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).unwrap_or_default(); let mut out = Vec::new(); // The graphics state carries over from one stream to the next. let mut state = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); + let mut tracker = ExtendedGStateTracker::default(); for (si, (_, data)) in streams(doc, &p.dict).into_iter().enumerate() { for (i, op) in parse(&data).ops.iter().enumerate() { state.apply(op)?; + tracker.step(op, &ext_gstates, doc)?; let ctm = state.placement.matrix; if op.is("Do") { let Some(name) = op.name(0) else { continue }; @@ -274,6 +301,7 @@ pub fn page_images(doc: &Document, page: usize) -> Result, EditEr is_form, width: if is_form { 0 } else { s.dict.int(b"Width").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 }, height: if is_form { 0 } else { s.dict.int(b"Height").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 }, + smask: tracker.current_state.smask.clone(), stream: si, op: i, draw_matrix: ctm.0, @@ -347,6 +375,7 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi is_form: false, width: s.dict.int(b"Width").unwrap_or(0).max(0) as u32, height: s.dict.int(b"Height").unwrap_or(0).max(0) as u32, + smask: None, stream, op: i, draw_matrix: ctm.0, @@ -548,7 +577,7 @@ struct EffectiveGState { ca: Option, ca_stroke: Option, bm: Option>, - smask: Option>, + smask: Option, } impl EffectiveGState { @@ -564,11 +593,8 @@ impl EffectiveGState { self.bm.as_deref().unwrap_or(b"Normal") } - fn effective_smask(&self) -> Option<&[u8]> { - match self.smask.as_deref() { - Some(b"None") | None => None, - Some(other) => Some(other), - } + fn effective_smask(&self) -> Option<&MaskIdentity> { + self.smask.as_ref() } fn visually_differs(&self, other: &EffectiveGState) -> bool { @@ -578,7 +604,7 @@ impl EffectiveGState { || self.effective_smask() != other.effective_smask() } - fn merge_dict(&mut self, dict: &Dict, doc: &Document) { + fn merge_dict(&mut self, dict: &Dict, doc: &Document, gs_name: Option<&[u8]>) { if let Some(val) = dict.get(b"ca").map(|v| doc.resolve(v)).and_then(|v| v.as_f64()) { self.ca = Some(val); } @@ -594,11 +620,28 @@ impl EffectiveGState { self.bm = Some(first_name); } } - if let Some(smask_obj) = dict.get(b"SMask").map(|v| doc.resolve(v)) { - if let Some(name) = smask_obj.as_name() { - self.smask = Some(name.to_vec()); - } else if smask_obj.as_dict().is_some() { - self.smask = Some(b"CustomSMask".to_vec()); + if let Some(raw_smask) = dict.get(b"SMask") { + let resolved = doc.resolve(raw_smask); + if let Some(name) = resolved.as_name() { + if name == b"None" { + self.smask = None; + } else { + self.smask = Some(MaskIdentity::Named(name.to_vec())); + } + } else if let Some(mask_dict) = resolved.as_dict() { + if let Some(gr) = mask_dict.get(b"G").and_then(|g| g.as_ref()) { + self.smask = Some(MaskIdentity::Group(gr.num, gr.generation)); + } else if let Some(r) = raw_smask.as_ref() { + self.smask = Some(MaskIdentity::Ref(r.num, r.generation)); + } else if let Some(name) = mask_dict.get(b"Name").map(|n| doc.resolve(n)).and_then(|n| n.as_name().map(|s| s.to_vec())) { + self.smask = Some(MaskIdentity::Named(name)); + } else if let Some(name) = gs_name { + self.smask = Some(MaskIdentity::Named(name.to_vec())); + } else { + self.smask = Some(MaskIdentity::Named(b"CustomMask".to_vec())); + } + } else if let Some(r) = raw_smask.as_ref() { + self.smask = Some(MaskIdentity::Ref(r.num, r.generation)); } } } @@ -615,7 +658,7 @@ impl ExtendedGStateTracker { fn step(&mut self, op: &Op, ext_gstates: &Dict, doc: &Document) -> Result<(), EditError> { match op.op.as_slice() { b"q" => { - if self.stack.len() >= 256 { + if self.stack.len() >= 4096 { return Err(EditError::Invalid("the image graphics state is too deeply nested".into())); } self.stack.push((self.current_state.clone(), self.active_gs_ops.clone())); @@ -629,7 +672,7 @@ impl ExtendedGStateTracker { b"gs" => { if let Some(name) = op.name(0) { if let Some(dict) = ext_gstates.get(name).map(|v| doc.resolve(v)).and_then(|v| v.as_dict().cloned()) { - self.current_state.merge_dict(&dict, doc); + self.current_state.merge_dict(&dict, doc, Some(name)); } else if self.current_state.ca.is_none() { self.current_state.ca = Some(0.5); } @@ -672,7 +715,7 @@ fn extract_scope_state(ops: &[Op], start: usize, target: usize) -> Result