Fixes the bug regarding duplicate images

This commit is contained in:
Combiler01
2026-10-21 16:33:31 +05:30
parent d4a739c3b5
commit b3b555ff5e
15 changed files with 689 additions and 130 deletions
Generated
+2
View File
@@ -4098,6 +4098,7 @@ dependencies = [
"pdfcraft-fonts",
"pdfcraft-model",
"pdfcraft-render",
"proptest",
"thiserror 2.0.21",
"unicode-bidi",
]
@@ -4385,6 +4386,7 @@ dependencies = [
"pdfcraft-xfa",
"png 0.18.1",
"pollster 1.0.1",
"proptest",
"rfd",
"rustix 1.1.5",
"serde",
+1
View File
@@ -23,3 +23,4 @@ unicode-bidi = "0.3"
[dev-dependencies]
hayro-syntax = { workspace = true }
pdfcraft-render = { workspace = true }
proptest = { workspace = true }
+50 -12
View File
@@ -156,9 +156,20 @@ impl ImageState {
Self { placement: Placement { matrix, clip, clips }, stack: Vec::new(), path: BezPath::new(), pending_clip: None }
}
fn has_current_point(&self) -> bool {
matches!(
self.path.elements().last(),
Some(kurbo::PathEl::MoveTo(_) | kurbo::PathEl::LineTo(_) | kurbo::PathEl::QuadTo(_, _) | kurbo::PathEl::CurveTo(_, _, _))
)
}
fn point(&self, x: f64, y: f64) -> Point {
let (x, y) = self.placement.matrix.apply(x, y);
Point::new(x, y)
if x.is_finite() && y.is_finite() {
Point::new(x, y)
} else {
Point::ZERO
}
}
fn apply(&mut self, op: &Op) -> Result<(), EditError> {
@@ -179,7 +190,10 @@ impl ImageState {
}
b"cm" => {
if let Some(m) = op.nums::<6>() {
self.placement.matrix = Matrix(m).then(&self.placement.matrix);
let next_mat = Matrix(m).then(&self.placement.matrix);
if next_mat.0.iter().all(|v| v.is_finite()) {
self.placement.matrix = next_mat;
}
}
}
b"m" => {
@@ -189,28 +203,52 @@ impl ImageState {
}
b"l" => {
if let Some([x, y]) = op.nums::<2>() {
self.path.line_to(self.point(x, y));
let pt = self.point(x, y);
if !self.has_current_point() {
self.path.move_to(pt);
} else {
self.path.line_to(pt);
}
}
}
b"c" => {
if let Some([x1, y1, x2, y2, x3, y3]) = op.nums::<6>() {
self.path.curve_to(self.point(x1, y1), self.point(x2, y2), self.point(x3, y3));
let p1 = self.point(x1, y1);
let p2 = self.point(x2, y2);
let p3 = self.point(x3, y3);
if !self.has_current_point() {
self.path.move_to(p1);
}
self.path.curve_to(p1, p2, p3);
}
}
b"v" => {
if let Some([x1, y1, x2, y2]) = op.nums::<4>()
&& let Some(current) = self.path.elements().last().and_then(|e| e.end_point())
{
self.path.curve_to(current, self.point(x1, y1), self.point(x2, y2));
if let Some([x1, y1, x2, y2]) = op.nums::<4>() {
let p1 = self.point(x1, y1);
let p2 = self.point(x2, y2);
if let Some(current) = self.path.elements().last().and_then(|e| e.end_point()) {
self.path.curve_to(current, p1, p2);
} else {
self.path.move_to(p1);
self.path.curve_to(p1, p1, p2);
}
}
}
b"y" => {
if let Some([x1, y1, x2, y2]) = op.nums::<4>() {
let p1 = self.point(x1, y1);
let end = self.point(x2, y2);
self.path.curve_to(self.point(x1, y1), end, end);
if !self.has_current_point() {
self.path.move_to(p1);
}
self.path.curve_to(p1, end, end);
}
}
b"h" => {
if self.has_current_point() {
self.path.close_path();
}
}
b"h" => self.path.close_path(),
b"re" => {
if let Some([x, y, w, h]) = op.nums::<4>() {
self.path.move_to(self.point(x, y));
@@ -494,7 +532,7 @@ fn clip_matrix(draw: Matrix, cm: Matrix, t: Matrix) -> Option<Matrix> {
Some(outer.then(&t).then(&outer.invert()?))
}
fn is_entire_page_wrapped(ops: &[Op]) -> bool {
pub(crate) 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;
}
@@ -512,7 +550,7 @@ fn is_entire_page_wrapped(ops: &[Op]) -> bool {
depth == 0
}
fn content_units(ops: &[Op], scan_start: usize, scan_end: usize) -> Vec<std::ops::Range<usize>> {
pub(crate) fn content_units(ops: &[Op], scan_start: usize, scan_end: usize) -> Vec<std::ops::Range<usize>> {
let mut units = Vec::new();
let mut i = scan_start;
while i < scan_end {
+82
View File
@@ -5930,3 +5930,85 @@ fn a_clipping_copy_on_the_same_baseline_is_refused() {
assert!(matches!(&block, Err(EditError::Invalid(m)) if m.contains("clipping path")), "{block:?}");
assert_eq!(text::text_lines(&doc, 0).unwrap().len(), 2, "refused means untouched");
}
#[cfg(test)]
mod proptests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn prop_crop_ctm_finite_for_valid_inputs(
a in -1000.0f64..1000.0,
b in -1000.0f64..1000.0,
c in -1000.0f64..1000.0,
d in -1000.0f64..1000.0,
e in -1000.0f64..1000.0,
f in -1000.0f64..1000.0,
x1 in -1000.0f64..1000.0,
y1 in -1000.0f64..1000.0,
w in 1.0f64..1000.0,
h in 1.0f64..1000.0,
) {
let ctm = [a, b, c, d, e, f];
let crop = [x1, y1, x1 + w, y1 + h];
let res = images::crop_ctm(ctm, crop);
for v in res {
prop_assert!(v.is_finite());
}
}
#[test]
fn prop_is_entire_page_wrapped_arbitrary_bytes(bytes in proptest::collection::vec(any::<u8>(), 0..2048)) {
let pieces = pdfcraft_content::Pieces::join(&[&bytes]);
let ops = pieces.parse().ops;
let _ = images::is_entire_page_wrapped(&ops);
}
#[test]
fn prop_content_units_arbitrary_bytes(
bytes in proptest::collection::vec(any::<u8>(), 0..2048),
start in 0usize..50,
end in 0usize..50,
) {
let pieces = pdfcraft_content::Pieces::join(&[&bytes]);
let ops = pieces.parse().ops;
let scan_start = start.min(ops.len());
let scan_end = end.min(ops.len()).max(scan_start);
let units = images::content_units(&ops, scan_start, scan_end);
for u in units {
prop_assert!(u.start <= u.end);
prop_assert!(u.end <= ops.len());
}
}
#[test]
fn prop_arrange_image_arbitrary_malformed(
index in 0usize..10,
order_idx in 0u8..4,
bytes in proptest::collection::vec(any::<u8>(), 0..1024),
) {
let order = match order_idx {
0 => images::ArrangeOrder::BringToFront,
1 => images::ArrangeOrder::SendToBack,
2 => images::ArrangeOrder::BringForward,
_ => images::ArrangeOrder::SendBackward,
};
let mut doc = image_page_with(&String::from_utf8_lossy(&bytes));
let _ = images::arrange_image(&mut doc, 0, index, order);
}
#[test]
fn prop_crop_image_malformed_inputs(
x1 in -500.0f64..500.0,
y1 in -500.0f64..500.0,
x2 in -500.0f64..500.0,
y2 in -500.0f64..500.0,
bytes in proptest::collection::vec(any::<u8>(), 0..1024),
) {
let mut doc = image_page_with(&String::from_utf8_lossy(&bytes));
let _ = images::crop_image(&mut doc, 0, 0, [x1, y1, x2, y2]);
let _ = images::remove_image_crop(&mut doc, 0, 0);
}
}
}
+37 -1
View File
@@ -2580,6 +2580,37 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
ImageEdit::RemoveCrop => pdfcraft_edit::ImageChange::RemoveCrop,
};
pdfcraft_edit::change_image(doc, *page, *index, &c)?;
if let Some(target_ref) = img.object {
let added_items: Vec<(usize, Added)> =
pdfcraft_edit::list_added(doc).into_iter().filter(|a| a.page == *page).enumerate().collect();
if let Some((added_idx, added_item)) =
added_items.into_iter().find(|(_, a)| matches!(&a.content, AddedContent::Image(ai) if ai.image == target_ref))
&& let AddedContent::Image(mut ai) = added_item.content
{
match change {
ImageEdit::Rotate(q) => {
let k = q.rem_euclid(4) as u8;
ai.rotation = (ai.rotation + k) % 4;
let r = ai.rect;
let (cx, cy) = ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0);
if k % 2 == 1 {
let (w, h) = (r[2] - r[0], r[3] - r[1]);
ai.rect = [cx - h / 2.0, cy - w / 2.0, cx + h / 2.0, cy + w / 2.0];
}
let _ = pdfcraft_edit::update_content(doc, *page, added_idx, &AddedContent::Image(ai));
}
ImageEdit::Flip { horizontal: true } => {
ai.flip_h = !ai.flip_h;
let _ = pdfcraft_edit::update_content(doc, *page, added_idx, &AddedContent::Image(ai));
}
ImageEdit::Flip { horizontal: false } => {
ai.flip_v = !ai.flip_v;
let _ = pdfcraft_edit::update_content(doc, *page, added_idx, &AddedContent::Image(ai));
}
_ => {}
}
}
}
}
Edit::MoveObjects { page, objects, offset } => pdfcraft_edit::move_objects(doc, *page, objects, *offset)?,
Edit::EditTextBlock { page, block, text, style } => {
@@ -3995,7 +4026,12 @@ impl Session {
let touches_added = edit.is_none_or(|e| {
matches!(
e,
Edit::AddText { .. } | Edit::AddImage { .. } | Edit::UpdateContent { .. } | Edit::DeleteContent { .. } | Edit::ReplaceImage { .. }
Edit::AddText { .. }
| Edit::AddImage { .. }
| Edit::UpdateContent { .. }
| Edit::DeleteContent { .. }
| Edit::ReplaceImage { .. }
| Edit::EditPageImage { .. }
) || matches!(e, Edit::MoveObjects { objects, .. } if objects.iter().any(|o| o.kind == ObjectKind::Added))
});
let touches_marks = edit.is_none_or(|e| {
+90 -10
View File
@@ -65,19 +65,34 @@ pub fn discover(roots: &[PathBuf]) -> Vec<FontFile> {
if read.is_err() {
continue;
}
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 });
}
if pdfcraft_fonts::embedding::validate_document_input(&bytes).is_err() {
continue;
}
if let Ok((family, face)) = pdfcraft_fonts::embedding::names(&bytes) {
let key = font_content_key(&bytes);
files.push((FontFile { path, family, face }, key));
}
}
}
files.sort_by(|a, b| a.face.cmp(&b.face).then(a.path.cmp(&b.path)));
files.dedup_by(|a, b| a.path == b.path || (a.face == b.face && std::fs::read(&a.path).ok() == std::fs::read(&b.path).ok()));
files
files.sort_by(|(a, _), (b, _)| a.face.cmp(&b.face).then(a.path.cmp(&b.path)));
let mut seen_face_content = std::collections::HashSet::new();
let mut deduped = Vec::new();
for (file, key) in files {
if seen_face_content.insert((file.face.clone(), key)) {
deduped.push(file);
}
}
deduped
}
fn font_content_key(bytes: &[u8]) -> (usize, u64, u64) {
use std::hash::{DefaultHasher, Hasher};
let mut h1 = DefaultHasher::new();
h1.write(bytes);
let mut h2 = DefaultHasher::new();
h2.write_u8(0xff);
h2.write(bytes);
(bytes.len(), h1.finish(), h2.finish())
}
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"))
@@ -92,4 +107,69 @@ mod tests {
let dirs = installed_directories();
assert!(!dirs.is_empty());
}
#[test]
fn test_font_extension() {
assert!(font_extension(Path::new("Arial.ttf")));
assert!(font_extension(Path::new("Noto.otf")));
assert!(font_extension(Path::new("CUSTOM.TTF")));
assert!(font_extension(Path::new("ROBOTO.OTF")));
assert!(!font_extension(Path::new("document.pdf")));
assert!(!font_extension(Path::new("font.woff")));
assert!(!font_extension(Path::new("font.woff2")));
assert!(!font_extension(Path::new("no_extension")));
}
#[test]
fn test_font_content_key() {
let empty = font_content_key(&[]);
assert_eq!(empty.0, 0);
let a = font_content_key(b"TrueType font sample data bytes");
let b = font_content_key(b"TrueType font sample data bytes");
let c = font_content_key(b"Different font sample data bytes");
assert_eq!(a, b, "identical bytes must produce identical keys");
assert_ne!(a, c, "differing bytes must produce distinct keys");
assert_eq!(a.0, b"TrueType font sample data bytes".len());
}
#[test]
fn test_discover_empty_and_nonexistent_roots() {
let empty = discover(&[]);
assert!(empty.is_empty());
let nonexistent = discover(&[PathBuf::from("Z:\\nonexistent_font_path_xyz_12345")]);
assert!(nonexistent.is_empty());
}
#[test]
fn test_discover_deduplicates_non_adjacent_same_content() {
let root = std::env::temp_dir().join(format!("pdfcraft-font-dedup-test-{}", std::process::id()));
let dir_a = root.join("a");
let dir_b = root.join("b");
let dir_c = root.join("c");
let _ = std::fs::create_dir_all(&dir_a);
let _ = std::fs::create_dir_all(&dir_b);
let _ = std::fs::create_dir_all(&dir_c);
let inter_reg = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/Inter-Regular.ttf");
let inter_med = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/Inter-Medium.ttf");
if inter_reg.exists() && inter_med.exists() {
let _ = std::fs::copy(&inter_reg, dir_a.join("FontA.ttf"));
let _ = std::fs::copy(&inter_med, dir_b.join("FontB.ttf"));
let _ = std::fs::copy(&inter_reg, dir_c.join("FontC.ttf"));
let discovered = discover(std::slice::from_ref(&root));
let _ = std::fs::remove_dir_all(&root);
assert_eq!(discovered.len(), 2, "identical content in non-adjacent paths is deduplicated");
let faces: Vec<&str> = discovered.iter().map(|f| f.face.as_str()).collect();
assert!(faces.contains(&"Inter-Regular"));
assert!(faces.contains(&"Inter-Medium"));
} else {
let _ = std::fs::remove_dir_all(&root);
}
}
}
+1
View File
@@ -79,6 +79,7 @@ egui_kittest = { workspace = true, features = ["eframe", "wgpu"] }
image = { version = "0.25", default-features = false, features = ["png"] }
# Glyph-coverage test of the interface fonts; the same skrifa pdfcraft-fonts already uses.
skrifa = "0.44"
proptest = { workspace = true }
[build-dependencies]
serde_json = { workspace = true }
+16 -4
View File
@@ -3203,9 +3203,20 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
});
// Each document keeps its own scroll position when switching tabs (#189).
scroll = scroll.id_salt(("page-view", view.id));
// Every scroll here asks for no animation. An animated area would still queue a zero-length
// offset target for the next frame, which overwrites the jump a page turn just set.
scroll = scroll.animated(false);
// When a page jump or programmatic reposition occurs, or in non-continuous page display
// where single-page wheel turns jump to the page edge, disable animation so egui's
// queued target does not overwrite or ease the jump. For continuous page scrolling,
// animate if motion effects are enabled.
let has_jump = !view.shown
|| edge_turned
|| view.goto.is_some()
|| view.goto_point.is_some()
|| view.zoom_anchor.is_some()
|| view.sync_scroll.is_some()
|| (app.quick_tool == QuickTool::Hand
&& !middle_gesture
&& view.page_widget.is_some_and(|id| ui.ctx().is_being_dragged(id)));
scroll = scroll.animated(view.continuous && animates && !has_jump);
// A newly opened document starts at the top. eframe saves egui's memory with the settings
// and document ids start again at 1 every launch, so the offset stored under this id can be
// where an earlier session left another document.
@@ -3391,7 +3402,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
let wheel_delta = if selection_wheel { Vec2::ZERO } else { precise_scroll + boost_extra };
let delta = auto_delta + edge_delta - vec2(0.0, key_scroll) + wheel_delta + glide;
if delta != Vec2::ZERO && delta.is_finite() {
ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none());
let anim = if animates && key_scroll != 0.0 { ui.style().scroll_animation } else { egui::style::ScrollAnimation::none() };
ui.scroll_with_delta_animation(delta, anim);
}
let (resp_rect, resp) = ui.allocate_exact_size(vec2(content_w, content_h), Sense::click_and_drag());
view.page_widget = Some(resp.id);
+39 -69
View File
@@ -7,7 +7,7 @@ use egui::{Color32, CornerRadius, Pos2, Rect, Stroke};
use pdfcraft_engine::{Added, AddedContent, AddedText, Edit, FontFamily, TextAlign};
use pdfcraft_render::DocInfo;
use crate::canvas::{DocView, ImageObb, PageXform, PainterExt};
use crate::canvas::{DocView, PageXform};
use crate::theme::Tokens;
use crate::widgets;
@@ -253,7 +253,10 @@ fn on_page(added: &[Added], page: usize) -> Vec<(usize, &Added)> {
}
pub(crate) fn hits_added(xf: &PageXform, info: &DocInfo, page: usize, added: &[Added], point: Pos2) -> bool {
on_page(added, page).iter().any(|(_, a)| screen_rect(xf, info, page, a.content.rect()).expand(2.0).contains(point))
on_page(added, page)
.iter()
.filter(|(_, a)| matches!(a.content, AddedContent::Text(_)))
.any(|(_, a)| screen_rect(xf, info, page, a.content.rect()).expand(2.0).contains(point))
}
fn handles(r: Rect) -> [(Pos2, bool, bool); 4] {
@@ -287,7 +290,7 @@ fn dragged(r: Rect, grab: Grab, d: egui::Vec2, keep_aspect: bool) -> Rect {
}
}
/// Added text and images keep pointer ownership over a form field while editing content.
/// Added text keeps pointer ownership over a form field while editing content.
pub(crate) fn select_grabs(ui: &egui::Ui, xf: &PageXform, page: usize, info: &DocInfo, added: &[Added], view: &DocView) -> bool {
let cv = &view.content;
if cv.grab.is_some_and(|(grab_page, _, _)| grab_page == page) {
@@ -296,7 +299,7 @@ pub(crate) fn select_grabs(ui: &egui::Ui, xf: &PageXform, page: usize, info: &Do
let Some(p) = ui.input(|i| i.pointer.hover_pos().or(i.pointer.interact_pos())).filter(|p| xf.rect.contains(*p)) else {
return false;
};
on_page(added, page).into_iter().any(|(index, item)| {
on_page(added, page).into_iter().filter(|(_, a)| matches!(a.content, AddedContent::Text(_))).any(|(index, item)| {
let rect = screen_rect(xf, info, page, item.content.rect());
rect.expand(2.0).contains(p)
|| cv.selected == Some((page, index)) && handles(rect).into_iter().any(|(corner, _, _)| corner.distance(p) <= 6.0)
@@ -328,13 +331,14 @@ pub(crate) fn page_input(
if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) {
cv.grab = None;
let end = pointer.unwrap_or(start);
let items: Vec<(usize, &Added)> =
on_page(added, page).into_iter().filter(|(_, a)| matches!(a.content, AddedContent::Text(_))).collect();
if let Some((sp, si)) = cv.selected
&& sp == page
&& let Some((_, a)) = on_page(added, page).into_iter().find(|(i, _)| *i == si)
&& let Some((_, a)) = items.iter().find(|(i, _)| *i == si)
&& (end - start).length() >= 1.0
{
let keep = matches!(a.content, AddedContent::Image(_));
let r = dragged(screen_rect(xf, info, page, a.content.rect()), grab, end - start, keep);
let r = dragged(screen_rect(xf, info, page, a.content.rect()), grab, end - start, false);
let (p0, p1) = (to_display(xf, info, page, r.left_bottom()), to_display(xf, info, page, r.right_top()));
let mut rect = [p0[0], p0[1], p1[0], p1[1]];
if let AddedContent::Text(t) = &a.content
@@ -349,7 +353,8 @@ pub(crate) fn page_input(
return consumed;
}
let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return consumed };
let items = on_page(added, page);
let items: Vec<(usize, &Added)> =
on_page(added, page).into_iter().filter(|(_, a)| matches!(a.content, AddedContent::Text(_))).collect();
let hit = items.iter().rev().find(|(_, a)| screen_rect(xf, info, page, a.content.rect()).expand(2.0).contains(p)).map(|(i, a)| (*i, *a));
let selected_rect = cv
.selected
@@ -436,6 +441,9 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform,
let cv = &view.content;
let pointer = ui.input(|i| i.pointer.hover_pos());
for (i, a) in on_page(added, page) {
if !matches!(a.content, AddedContent::Text(_)) {
continue;
}
if cv.draft.as_ref().is_some_and(|d| d.page == page && d.index == Some(i)) {
continue;
}
@@ -445,42 +453,13 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform,
&& let (Some((gp, g, start)), Some(p)) = (cv.grab, pointer)
&& gp == page
{
r = dragged(r, g, p - start, matches!(a.content, AddedContent::Image(_)));
r = dragged(r, g, p - start, false);
}
let is_rotated_image = match &a.content {
AddedContent::Image(img) => img.rotation != 0,
_ => false,
};
if is_rotated_image {
let theta = match &a.content {
AddedContent::Image(img) => (img.rotation as f32) * std::f32::consts::FRAC_PI_2,
_ => 0.0,
};
let obb = ImageObb::from_center_size_angle(r.center(), r.width(), r.height(), theta);
if selected {
painter.polygon(obb.corners().to_vec(), Color32::TRANSPARENT, Stroke::new(1.5, SELECT_BLUE));
for (_, c) in obb.handles() {
painter.circle(c, 3.5, Color32::WHITE, Stroke::new(1.5, SELECT_BLUE));
}
} else if pointer.is_some_and(|p| r.expand(2.0).contains(p)) {
painter.polygon(obb.corners().to_vec(), Color32::TRANSPARENT, Stroke::new(1.0, SELECT_BLUE.gamma_multiply(0.7)));
}
} else if selected {
if selected {
painter.rect_stroke(r.expand(1.0), CornerRadius::ZERO, Stroke::new(1.5, SELECT_BLUE), egui::StrokeKind::Outside);
let er = r.expand(1.0);
if matches!(a.content, AddedContent::Image(_)) {
let (tl, tr, br, bl) = (er.left_top(), er.right_top(), er.right_bottom(), er.left_bottom());
let tm = Pos2::new((tl.x + tr.x) * 0.5, tl.y);
let rm = Pos2::new(tr.x, (tr.y + br.y) * 0.5);
let bm = Pos2::new((bl.x + br.x) * 0.5, bl.y);
let lm = Pos2::new(tl.x, (tl.y + bl.y) * 0.5);
for c in [tl, tm, tr, rm, br, bm, bl, lm] {
painter.circle(c, 3.5, Color32::WHITE, Stroke::new(1.5, SELECT_BLUE));
}
} else {
for (c, ..) in handles(er) {
painter.circle(c, 3.5, Color32::WHITE, Stroke::new(1.5, SELECT_BLUE));
}
for (c, ..) in handles(er) {
painter.circle(c, 3.5, Color32::WHITE, Stroke::new(1.5, SELECT_BLUE));
}
} else if pointer.is_some_and(|p| r.expand(2.0).contains(p)) {
painter.rect_stroke(r.expand(1.0), CornerRadius::ZERO, Stroke::new(1.0, SELECT_BLUE.gamma_multiply(0.7)), egui::StrokeKind::Outside);
@@ -504,12 +483,12 @@ pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView) {
/// After an add: select the new item.
pub(crate) fn after_refresh(view: &mut DocView, added: &[Added]) {
if let Some((page, before)) = view.content.select_added.take()
&& on_page(added, page).len() > before
&& on_page(added, page).get(before).is_some_and(|(_, a)| matches!(a.content, AddedContent::Text(_)))
{
view.content.selected = Some((page, before));
}
if let Some((p, i)) = view.content.selected
&& on_page(added, p).len() <= i
&& on_page(added, p).get(i).is_none_or(|(_, a)| !matches!(a.content, AddedContent::Text(_)))
{
view.content.selected = None;
}
@@ -1034,9 +1013,16 @@ impl crate::PdfCraftApp {
pub fn add_image(&mut self, name: String, bytes: Vec<u8>) {
let Some((i, id)) = self.active_ids() else { return };
let page = self.views[i].current;
let before = self.session.get(id).map_or(0, |d| d.added().iter().filter(|a| a.page == page).count());
let before = self.session.get(id).map_or(0, |d| d.page_images(page).len());
if self.apply_edit(Edit::AddImage { page, rect: None, name, bytes: std::sync::Arc::new(bytes) }) {
self.views[i].content.select_added = Some((page, before));
if let Some(v) = self.views.get_mut(i) {
v.current = page;
v.content.selected = None;
v.content.select_added = None;
v.image_selection =
Some(crate::edit_text_ui::ImageSelection { page, index: before, crop_mode: false, crop_rect: None, drag: None, crop: None });
}
self.quick_tool = crate::QuickTool::EditText;
self.left = crate::LeftPanel::Tool("edit");
self.left_open = true;
}
@@ -1044,6 +1030,7 @@ impl crate::PdfCraftApp {
}
/// What the image tools ask for.
#[cfg(test)]
pub(crate) enum ImageAction {
Update(pdfcraft_engine::AddedContent),
Replace,
@@ -1051,11 +1038,14 @@ pub(crate) enum ImageAction {
// Lucide names a flip icon after the axis it draws, Acrobat names the flip after the direction the
// picture moves: Flip horizontal (left and right swap) shows Lucide's vertical axis.
const FLIP_HORIZONTAL_ICON: &str = "flip-vertical-2";
const FLIP_VERTICAL_ICON: &str = "flip-horizontal-2";
#[cfg(test)]
pub(crate) const FLIP_HORIZONTAL_ICON: &str = "flip-vertical-2";
#[cfg(test)]
pub(crate) const FLIP_VERTICAL_ICON: &str = "flip-horizontal-2";
/// Edit image: rotate, flip, crop, replace (shown while an added image is selected).
pub(crate) fn image_panel(ui: &mut egui::Ui, t: &Tokens, img: &pdfcraft_engine::AddedImage) -> Option<ImageAction> {
/// Edit image: rotate, flip, replace (shown while an added image is selected).
#[cfg(test)]
pub(crate) fn image_panel(ui: &mut egui::Ui, _t: &Tokens, img: &pdfcraft_engine::AddedImage) -> Option<ImageAction> {
let mut out = None;
widgets::section_title(ui, tl!("Edit image"));
ui.horizontal(|ui| {
@@ -1087,26 +1077,6 @@ pub(crate) fn image_panel(ui: &mut egui::Ui, t: &Tokens, img: &pdfcraft_engine::
out = Some(ImageAction::Replace);
}
});
ui.label(egui::RichText::new(tl!("Crop (% trimmed from each side)")).small().color(t.text_muted));
let mut crop = img.crop.map(|v| (v * 100.0).round());
let mut changed = false;
ui.horizontal(|ui| {
for (k, label) in ["L", "B", "R", "T"].into_iter().enumerate() {
ui.label(label);
// Applied when the drag ends, so a drag is one undo step.
let r = ui.add(egui::DragValue::new(&mut crop[k]).range(0.0..=95.0).speed(0.5).suffix("%"));
changed |= r.drag_stopped() || (r.changed() && !r.dragged());
}
});
if changed {
let mut i = img.clone();
let new_crop = crop.map(|v| (v / 100.0).clamp(0.0, 0.95));
if new_crop != img.crop {
update_image_crop(&mut i, new_crop);
ui.ctx().request_repaint();
out = Some(ImageAction::Update(AddedContent::Image(i)));
}
}
out
}
+3
View File
@@ -810,6 +810,9 @@ fn screen_to_user(xf: &PageXform, info: &DocInfo, page: usize, pt: Pos2) -> (f64
}
pub(crate) fn quad_contains(quad: [Pos2; 4], p: Pos2) -> bool {
if !p.x.is_finite() || !p.y.is_finite() || quad.iter().any(|pt| !pt.x.is_finite() || !pt.y.is_finite()) {
return false;
}
let mut sign = 0;
for i in 0..4 {
let p1 = quad[i];
+49 -7
View File
@@ -45,7 +45,19 @@ impl PdfCraftApp {
let label = edit.label();
let target_arrange_image = match &edit {
Edit::EditPageImage { page, index, change: pdfcraft_engine::ImageEdit::Arrange(_) } => {
self.session.get(id).and_then(|doc| doc.page_images(*page).get(*index).cloned())
let doc = self.session.get(id);
let target = doc.and_then(|d| d.page_images(*page).get(*index).cloned());
let ambiguous = doc.is_some_and(|d| {
if let Some(t) = &target {
d.page_images(*page)
.iter()
.filter(|img| img.name == t.name && img.object == t.object && img.rect == t.rect)
.count() > 1
} else {
false
}
});
target.map(|t| (t, ambiguous))
}
_ => None,
};
@@ -171,13 +183,43 @@ impl PdfCraftApp {
Edit::DeleteAnnotation { .. } => view.comments.selected = None,
_ => {}
}
if let (Some(target), Edit::EditPageImage { page, .. }) = (target_arrange_image, &edit) {
let new_idx = doc
.page_images(*page)
if let (Some((target, ambiguous_before)), Edit::EditPageImage { page, .. }) = (target_arrange_image, &edit) {
let page_images = doc.page_images(*page);
let exact_matches: Vec<usize> = page_images
.iter()
.position(|img| img.name == target.name && img.object == target.object && img.rect == target.rect)
.or_else(|| doc.page_images(*page).iter().position(|img| img.name == target.name && img.object == target.object))
.or_else(|| doc.page_images(*page).iter().position(|img| img.name == target.name));
.enumerate()
.filter(|(_, img)| img.name == target.name && img.object == target.object && img.rect == target.rect)
.map(|(idx, _)| idx)
.collect();
let new_idx = if !ambiguous_before && exact_matches.len() == 1 {
Some(exact_matches[0])
} else if !ambiguous_before && exact_matches.is_empty() {
let name_obj_matches: Vec<usize> = page_images
.iter()
.enumerate()
.filter(|(_, img)| img.name == target.name && img.object == target.object)
.map(|(idx, _)| idx)
.collect();
if name_obj_matches.len() == 1 {
Some(name_obj_matches[0])
} else if name_obj_matches.is_empty() {
let name_matches: Vec<usize> = page_images
.iter()
.enumerate()
.filter(|(_, img)| img.name == target.name)
.map(|(idx, _)| idx)
.collect();
if name_matches.len() == 1 {
Some(name_matches[0])
} else {
None
}
} else {
None
}
} else {
None
};
if let Some(s) = view.image_selection.as_mut().filter(|s| s.page == *page) {
if let Some(idx) = new_idx {
s.index = idx;
+138 -3
View File
@@ -139,7 +139,7 @@ pub const MIN_RECT_SIZE: f32 = 10.0;
/// - Mid-edge handles: The opposite edge coordinate is fixed in 1D. The perpendicular axis is
/// completely unaffected.
pub fn apply_resize(image_rect: Rect, handle: HandleId, delta: Vec2) -> Rect {
if !delta.x.is_finite() || !delta.y.is_finite() {
if !image_rect.is_finite() || !delta.x.is_finite() || !delta.y.is_finite() {
return image_rect;
}
let mut min = image_rect.min;
@@ -235,7 +235,7 @@ pub fn apply_resize(image_rect: Rect, handle: HandleId, delta: Vec2) -> Rect {
/// width and height uniformly from the locked anchor point, clamped to `>= MIN_RECT_SIZE`.
/// For mid-edge handles, delegates to `apply_resize`.
pub fn apply_resize_proportional(image_rect: Rect, handle: HandleId, delta: Vec2) -> Rect {
if !delta.x.is_finite() || !delta.y.is_finite() {
if !image_rect.is_finite() || !delta.x.is_finite() || !delta.y.is_finite() {
return image_rect;
}
let w0 = image_rect.width().max(1.0);
@@ -411,7 +411,7 @@ pub fn apply_crop(image_rect: Rect, uv_rect: Rect, handle: HandleId, delta: Vec2
/// Resets the crop boundary back to [0.0..1.0] and expands `image_rect`
/// to reveal the full image at the current screen pixel scale without shifting existing pixels.
pub fn reset_crop(image_rect: Rect, uv_rect: Rect) -> (Rect, Rect) {
if uv_rect.width() <= 0.0 || uv_rect.height() <= 0.0 || !uv_rect.width().is_finite() || !uv_rect.height().is_finite() {
if !image_rect.is_finite() || uv_rect.width() <= 0.0 || uv_rect.height() <= 0.0 || !uv_rect.width().is_finite() || !uv_rect.height().is_finite() {
return (image_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)));
}
@@ -642,4 +642,139 @@ mod tests {
assert_eq!(resized.width(), 120.0);
assert_eq!(resized.height(), 240.0);
}
#[cfg(test)]
mod proptests {
use super::*;
use proptest::prelude::*;
fn arb_handle() -> impl Strategy<Value = HandleId> {
prop_oneof![
Just(HandleId::TopLeft),
Just(HandleId::TopMid),
Just(HandleId::TopRight),
Just(HandleId::RightMid),
Just(HandleId::BottomRight),
Just(HandleId::BottomMid),
Just(HandleId::BottomLeft),
Just(HandleId::LeftMid),
]
}
proptest! {
#[test]
fn prop_apply_resize_never_inverts_and_respects_min_size(
min_x in -5000.0f32..5000.0,
min_y in -5000.0f32..5000.0,
w in 10.0f32..2000.0,
h in 10.0f32..2000.0,
handle in arb_handle(),
dx in -10000.0f32..10000.0,
dy in -10000.0f32..10000.0,
) {
let rect = Rect::from_min_size(pos2(min_x, min_y), vec2(w, h));
let res = apply_resize(rect, handle, vec2(dx, dy));
prop_assert!(res.is_finite());
prop_assert!(res.width() >= MIN_RECT_SIZE - 1e-3);
prop_assert!(res.height() >= MIN_RECT_SIZE - 1e-3);
prop_assert!(res.min.x <= res.max.x);
prop_assert!(res.min.y <= res.max.y);
}
#[test]
fn prop_apply_resize_proportional_preserves_aspect_ratio(
min_x in -2000.0f32..2000.0,
min_y in -2000.0f32..2000.0,
w in 20.0f32..1000.0,
h in 20.0f32..1000.0,
handle in arb_handle(),
dx in -5000.0f32..5000.0,
dy in -5000.0f32..5000.0,
) {
let rect = Rect::from_min_size(pos2(min_x, min_y), vec2(w, h));
let res = apply_resize_proportional(rect, handle, vec2(dx, dy));
prop_assert!(res.is_finite());
prop_assert!(res.width() >= MIN_RECT_SIZE - 1e-3);
prop_assert!(res.height() >= MIN_RECT_SIZE - 1e-3);
let is_corner = matches!(handle, HandleId::TopLeft | HandleId::TopRight | HandleId::BottomLeft | HandleId::BottomRight);
if is_corner {
let orig_ratio = w / h;
let new_ratio = res.width() / res.height();
prop_assert!((orig_ratio - new_ratio).abs() < 1e-3, "orig: {orig_ratio}, new: {new_ratio}");
}
}
#[test]
fn prop_apply_crop_uv_clamped(
min_x in 0.0f32..1000.0,
min_y in 0.0f32..1000.0,
w in 50.0f32..500.0,
h in 50.0f32..500.0,
uv_u0 in 0.0f32..0.4,
uv_v0 in 0.0f32..0.4,
uv_w in 0.3f32..0.5,
uv_h in 0.3f32..0.5,
handle in arb_handle(),
dx in -2000.0f32..2000.0,
dy in -2000.0f32..2000.0,
) {
let screen = Rect::from_min_size(pos2(min_x, min_y), vec2(w, h));
let uv = Rect::from_min_size(pos2(uv_u0, uv_v0), vec2(uv_w, uv_h));
let (new_screen, new_uv) = apply_crop(screen, uv, handle, vec2(dx, dy));
prop_assert!(new_screen.is_finite());
prop_assert!(new_uv.is_finite());
prop_assert!(new_uv.min.x >= 0.0 - 1e-5);
prop_assert!(new_uv.min.y >= 0.0 - 1e-5);
prop_assert!(new_uv.max.x <= 1.0 + 1e-5);
prop_assert!(new_uv.max.y <= 1.0 + 1e-5);
prop_assert!(new_uv.width() > 0.0);
prop_assert!(new_uv.height() > 0.0);
let scale_x_orig = w / uv_w;
let scale_x_new = new_screen.width() / new_uv.width();
prop_assert!((scale_x_orig - scale_x_new).abs() < 1e-2);
}
#[test]
fn prop_reset_crop_invariants(
min_x in -1000.0f32..1000.0,
min_y in -1000.0f32..1000.0,
w in 50.0f32..500.0,
h in 50.0f32..500.0,
uv_u0 in 0.0f32..0.4,
uv_v0 in 0.0f32..0.4,
uv_w in 0.2f32..0.5,
uv_h in 0.2f32..0.5,
) {
let screen = Rect::from_min_size(pos2(min_x, min_y), vec2(w, h));
let uv = Rect::from_min_size(pos2(uv_u0, uv_v0), vec2(uv_w, uv_h));
let (full_screen, full_uv) = reset_crop(screen, uv);
prop_assert_eq!(full_uv, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)));
prop_assert!(full_screen.is_finite());
prop_assert!(full_screen.width() >= screen.width());
prop_assert!(full_screen.height() >= screen.height());
}
#[test]
fn prop_quad_contains_never_panics(
q0_x in -1000.0f32..1000.0,
q0_y in -1000.0f32..1000.0,
q1_x in -1000.0f32..1000.0,
q1_y in -1000.0f32..1000.0,
q2_x in -1000.0f32..1000.0,
q2_y in -1000.0f32..1000.0,
q3_x in -1000.0f32..1000.0,
q3_y in -1000.0f32..1000.0,
px in -2000.0f32..2000.0,
py in -2000.0f32..2000.0,
) {
let quad = [pos2(q0_x, q0_y), pos2(q1_x, q1_y), pos2(q2_x, q2_y), pos2(q3_x, q3_y)];
let p = pos2(px, py);
let _ = crate::edit_text_ui::quad_contains(quad, p);
}
}
}
}
-20
View File
@@ -572,26 +572,6 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
ui.separator();
return;
}
let image = app.active_ids().and_then(|(i, id)| {
let (page, index) = app.views[i].content.selected?;
let doc = app.session.get(id)?;
match &doc.added().iter().filter(|a| a.page == page).nth(index)?.content {
pdfcraft_engine::AddedContent::Image(img) => Some((page, index, img.clone())),
_ => None,
}
});
if let Some((page, index, img)) = image {
match crate::content_ui::image_panel(ui, t, &img) {
Some(crate::content_ui::ImageAction::Update(content)) => {
app.apply_edit(pdfcraft_engine::Edit::UpdateContent { page, index, content });
}
Some(crate::content_ui::ImageAction::Replace) => app.replace_image_dialog(page, index),
None => {}
}
ui.add_space(6.0);
ui.separator();
return;
}
let selected = app.active_ids().and_then(|(i, id)| {
let (page, index) = app.views[i].content.selected?;
let doc = app.session.get(id)?;
+51 -4
View File
@@ -262,19 +262,66 @@ fn typing_moving_styling_and_deleting_added_content() {
let a = added(&h);
assert_eq!(a.len(), 1);
assert_eq!(a[0].content.rect(), [135.0, 190.0, 165.0, 210.0]);
assert_eq!(h.state().views[0].content.selected, Some((0, 0)));
// Edit image: rotate clockwise turns the box around its centre.
h.get_by_label("Rotate clockwise").click();
assert!(h.state().views[0].image_selection.is_some());
assert_eq!(h.state().views[0].content.selected, None);
// Right-click image to rotate clockwise via refactored context menu
let c = at(&h, 150.0, 200.0);
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("Rotate Clockwise").click();
h.run_steps(3);
let a = added(&h);
let pdfcraft_engine::AddedContent::Image(img) = &a[0].content else { panic!() };
assert_eq!((img.rotation, img.rect), (3, [140.0, 185.0, 160.0, 215.0]));
assert_eq!((img.rotation, img.rect), (1, [140.0, 185.0, 160.0, 215.0]));
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("Flip horizontal").click();
h.run_steps(3);
let pdfcraft_engine::AddedContent::Image(img) = &added(&h)[0].content else { panic!() };
assert!(img.flip_h);
}
#[test]
fn add_image_activates_refactored_image_selection_and_crop() {
let mut h = harness();
let mut png = Vec::new();
{
let mut enc = png::Encoder::new(&mut png, 40, 40);
enc.set_color(png::ColorType::Rgb);
let mut w = enc.write_header().unwrap();
w.write_image_data(&[128u8; 4800]).unwrap();
}
h.state_mut().add_image("test_crop.png".into(), png);
h.run_steps(3);
// Verify image_selection is activated and content.selected is None
let sel = h.state().views[0].image_selection.clone().expect("image_selection should be active");
assert_eq!(h.state().views[0].content.selected, None);
assert!(!sel.crop_mode);
// Right-click image to open context menu and click Crop
let c = at(&h, 150.0, 200.0);
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("Crop").click();
h.run_steps(3);
// Verify refactored crop mode is now active
let sel_after = h.state().views[0].image_selection.clone().expect("image_selection active");
assert!(sel_after.crop_mode, "crop_mode must be true");
assert!(sel_after.crop.is_some(), "crop must be initialized");
}
/// #125: text the standard fonts can't draw stays in the editor with a warning instead of being
/// written to the page as `?` (or lost).
#[test]
+130
View File
@@ -5581,6 +5581,8 @@ fn test_cropped_image_selection_and_bounding_box() {
app
});
h.run_steps(4);
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let doc_id = h.state().views[0].id;
let full_rect = h.state().session.get(doc_id).unwrap().page_images(0)[0].rect;
@@ -5662,3 +5664,131 @@ fn read_mode_shortcut_applies_the_open_paragraph() {
assert!(s.views[0].line_editor.is_none(), "no editor stays open in Read mode");
assert_eq!(s.session.get(s.views[0].id).unwrap().text_lines(0)[0].text, "Chapter One", "the edit is applied");
}
#[test]
fn arrange_duplicate_image_clears_selection_when_ambiguous() {
use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream, write_incremental};
let mut doc = Document::open(std::sync::Arc::new(fixture(1))).unwrap();
let page = pdfcraft_model::pages(&doc)[0].clone();
let mut form = Dict::new();
form.set(b"Subtype".to_vec(), Object::name("Form"));
form.set(b"BBox".to_vec(), Object::Array([0, 0, 200, 300].map(Object::Int).to_vec()));
let artwork = doc.add(Object::Stream(Stream::flate(form, b"0.2 0.5 0.9 rg 20 20 160 260 re f")));
// Paint two occurrences of the same figure at the exact same position
let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), b"q 1 0 0 1 0 0 cm /Figure Do Q q 1 0 0 1 0 0 cm /Figure Do Q")));
doc.update_dict(page.obj, |d| {
d.set(b"Contents".to_vec(), Object::Ref(contents));
let mut xo = Dict::new();
xo.set(b"Figure".to_vec(), Object::Ref(artwork));
let mut resources = Dict::new();
resources.set(b"XObject".to_vec(), Object::Dict(xo));
d.set(b"Resources".to_vec(), Object::Dict(resources));
})
.unwrap();
let bytes = write_incremental(&doc, &SaveOptions::default()).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("dup.pdf", None, bytes.clone()).expect("opens");
app
});
h.run_steps(4);
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let doc_id = h.state().views[0].id;
let images = h.state().session.get(doc_id).unwrap().page_images(0);
assert_eq!(images.len(), 2, "two duplicate image occurrences exist");
assert_eq!(images[0].rect, images[1].rect);
assert_eq!(images[0].name, images[1].name);
// Select the second occurrence (index 1)
h.state_mut().views[0].image_selection = Some(pdfcraft_ui_egui::edit_text_ui::ImageSelection {
page: 0,
index: 1,
drag: None,
crop: None,
crop_mode: false,
crop_rect: None,
});
h.run_steps(2);
// Arrange send to back
let applied = h.state_mut().apply_edit(pdfcraft_engine::Edit::EditPageImage {
page: 0,
index: 1,
change: pdfcraft_engine::ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::SendToBack),
});
assert!(applied);
h.run_steps(2);
// Because there are duplicate occurrences matching name, object, and rect,
// selection must be cleared rather than picking an arbitrary occurrence.
assert!(
h.state().views[0].image_selection.is_none(),
"selection is safely cleared when reordering ambiguous duplicate images"
);
}
#[test]
fn arrange_unique_image_preserves_selection() {
use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream, write_incremental};
let mut doc = Document::open(std::sync::Arc::new(fixture(1))).unwrap();
let page = pdfcraft_model::pages(&doc)[0].clone();
let mut form = Dict::new();
form.set(b"Subtype".to_vec(), Object::name("Form"));
form.set(b"BBox".to_vec(), Object::Array([0, 0, 200, 300].map(Object::Int).to_vec()));
let artwork1 = doc.add(Object::Stream(Stream::flate(form.clone(), b"0.2 0.5 0.9 rg 20 20 160 260 re f")));
let artwork2 = doc.add(Object::Stream(Stream::flate(form, b"0.9 0.2 0.5 rg 40 40 100 100 re f")));
let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), b"q 1 0 0 1 0 0 cm /FigA Do Q q 1 0 0 1 0 0 cm /FigB Do Q")));
doc.update_dict(page.obj, |d| {
d.set(b"Contents".to_vec(), Object::Ref(contents));
let mut xo = Dict::new();
xo.set(b"FigA".to_vec(), Object::Ref(artwork1));
xo.set(b"FigB".to_vec(), Object::Ref(artwork2));
let mut resources = Dict::new();
resources.set(b"XObject".to_vec(), Object::Dict(xo));
d.set(b"Resources".to_vec(), Object::Dict(resources));
})
.unwrap();
let bytes = write_incremental(&doc, &SaveOptions::default()).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("unique.pdf", None, bytes.clone()).expect("opens");
app
});
h.run_steps(4);
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let doc_id = h.state().views[0].id;
let images = h.state().session.get(doc_id).unwrap().page_images(0);
assert_eq!(images.len(), 2);
assert_eq!(images[0].name, "FigA");
assert_eq!(images[1].name, "FigB");
// Select FigA at index 0 and bring it to front (should move after FigB, becoming index 1)
h.state_mut().views[0].image_selection = Some(pdfcraft_ui_egui::edit_text_ui::ImageSelection {
page: 0,
index: 0,
drag: None,
crop: None,
crop_mode: false,
crop_rect: None,
});
h.run_steps(2);
let applied = h.state_mut().apply_edit(pdfcraft_engine::Edit::EditPageImage {
page: 0,
index: 0,
change: pdfcraft_engine::ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::BringToFront),
});
assert!(applied);
h.run_steps(2);
let sel = h.state().views[0].image_selection.as_ref().expect("selection is preserved for unique image");
assert_eq!(sel.index, 1, "selection index tracks the unique image's new position");
}