diff --git a/ROADMAP.md b/ROADMAP.md index 11eaf3422..23bba1a45 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,7 @@ **Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (#594 paragraph drags validated in the Linux test environment; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (architectural paper controls from #599; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** M14 #307 Plan 3 uses one marker traversal on open and full refresh, and the combined scanner borrows metadata instead of cloning scan-only containers; includes a behavior-preserving `organize` binding for the workspace Clippy gate · earlier: minor (localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (closed issues synced: #774, #756 and the #799–#821 batch; earlier: localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -139,6 +140,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md Newest first: the date, what moved, and the new overall estimate. +- **2026-10-11 (M7, #594):** Ordinary paragraph body drags now use the existing source-preserving object translation path; edge drags still rewrap. An in-progress drag is discarded if the document generation changes, and added-content gestures keep their own edit handler. Added real-shell source regressions for overlapping and identical labels, history, save/reopen, partial overlap, kerning, rotation/crop/UserUnit and atomic refusal. Linux validation passed: focused UI/engine regressions, exact-base source-loss failures, native drag/undo/redo/save/reopen with independent Poppler renders, and all eight CI gates. Text replacement and resizing over unrelated overlapping content remain outside this repair. Estimates unchanged. + - **2026-10-10 (community, M7, Thai Add Text):** Optional craft-fonts Thai face (Noto Sans Thai) for the UI and shaped Add Text through the engine, CLI and MCP. Stacked marks and Sara Am are kept through Type 3 clusters with ToUnicode; regular style, and wrapping at cluster boundaries (no dictionary word segmentation). The craft-fonts pin for CI and releases moves to the revision with the Thai face. Forms and Fill & Sign are outside this change. Estimates unchanged. - **2026-10-11 (#995, #1077, permissions password after opening):** A PDF with only a permissions password opens without asking, restricted, and nothing let the user enter that password afterwards: the notice and the hint pointed at reopening with it. Now the security notice and Properties ▸ Security offer Unlock…, and Change settings / Remove security on a restricted document ask for the permissions password first and then carry on (as Acrobat does). The open password is refused there. `Session::unlock` upgrades the open document in place (owner and user passwords unlock the same file key, so nothing is re-read), unlocks the undo and redo states and reopens saves as owner; it is not an edit and doesn't mark the document unsaved. New command `protect.unlock`; the new strings are in all 15 full catalogs. Tests cover crypt, cos, engine (undo, redo, save, separate open password) and the UI. Still missing: an automation tool for it. Estimates unchanged. - **2026-10-11 (M14, #307 Plan 3):** Engine open and full refresh use one page/content traversal to populate marks and added content. The independent public scans remain available; the combined scanner borrows direct objects and holds referenced objects through `Arc` instead of cloning scan-only `/Contents` arrays or `/PCAdded` dictionaries. Differential tests cover an indirect `/PCAdded` dictionary as well as ordering, malformed/direct/indirect/repeated entries and `/UserUnit`. Exact `bbbe600`→final release CLI measurements used 10 alternating pairs: wall-time medians were 183.645→174.440 ms on 10,000 marker-free pages (10/10 wins), 33.587→32.752 ms on 1,500 mixed pages (8/10), and 24.331→20.133 ms on the 100,000-entry array stress fixture (10/10). The stress fixture's maximum-RSS median fell 28,655,616→19,038,208 B; peak-footprint medians were 14,747,136→14,673,408 B. From P0, the follow-up stress run was 20.886→20.201 ms (10/10), with RSS 23,953,408→19,087,360 B and peak footprint 14,706,144→14,697,952 B. `doc_open` stdout hashes matched every pair. The ancillary `organize` binding preserves the prior `prune_legacy` update condition. Estimates unchanged. @@ -349,6 +352,7 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | M7: #594 paragraph-drag candidate and source regressions prepared; subsequent Linux validation passed all eight CI gates. Estimates unchanged. | | 2026-10-10 | minor | Recorded the remaining System appearance repair after #881; estimates unchanged | | 2026-10-10 | minor | Readiness table with hours per audience; full number restated as the additive weighted sum over dimensions (47% → 49%; method aligned with the standard, no new evidence) | | 2026-10-10 | minor | Essentials-user score added (≈ 66%) | diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6b99cec4b..b043cdd93 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -525,7 +525,8 @@ fn create_multiple_reads_each_source_only_after_the_previous_one_is_released() { #[test] fn create_multiple_gives_the_bytes_of_combine_ranges_and_refuses_a_bad_source() { - let s = Session::new(); + // The two combines are compared byte for byte, and each stamps the clock: pin it. + let s = Session::new().with_clock(|| 1_700_000_000); let (a, b) = (Arc::new(fixture(3)), Arc::new(fixture(2))); let sources: Vec = vec![("a".into(), a.clone(), Some("1-2".into())), ("b".into(), b, None), ("a".into(), a.clone(), Some("3".into()))]; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3306b6d33..f829c9a3b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2018,7 +2018,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // The Prepare a form panel is open (or a field tool is picked): fields are edited, not filled. let preparing = app.is_preparing(); // Edit a PDF: added text and images can be selected, moved and edited. - let editing_content = (app.left_open && app.left == crate::LeftPanel::Tool("edit")) || app.quick_tool == QuickTool::AddText; + 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 view = &mut app.views[index]; // Opened without the owner password and something is restricted. @@ -2627,7 +2628,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { crate::content_ui::page_input(ui, &resp, &xf, i, info, &added, tool == QuickTool::AddText, &text_style, view) || tool == QuickTool::AddText }; - let on_edit_text = !on_objects && tool == QuickTool::EditText && can_modify && { + // Added content owns its gesture; its source text must not queue a second edit. + let on_edit_text = !on_objects && !on_content && tool == QuickTool::EditText && can_modify && { let generation = doc.edit_generation(); let lines = match view.edit_lines.get(&i) { Some((g, l)) if *g == generation => l.clone(), @@ -2647,7 +2649,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { }; // Images first (they can sit under text boxes' corners); then paragraphs. crate::edit_text_ui::image_input(ui, &resp, &xf, i, info, &images, view, &mut image_action) - || crate::edit_text_ui::page_input(ui, &resp, &xf, i, info, &lines, view) + || crate::edit_text_ui::page_input(ui, &resp, &xf, i, doc, &lines, view) }; let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(ui, &resp, &xf, i, info, &doc_links, view); let consumed = on_objects || on_edit_text || on_link || on_content || boxing || on_field || comments::page_input(ui, &resp, &pcx, view); diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 6dbe70b88..bf807146a 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -5,7 +5,7 @@ //! its proportions), right-click for rotate, flip, replace, save and delete; Delete removes it. use egui::{Color32, CornerRadius, FontFamily, FontId, Pos2, Rect, Stroke}; -use pdfcraft_engine::Edit; +use pdfcraft_engine::{Edit, ObjectKind, ObjectTarget}; use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; @@ -223,6 +223,8 @@ enum Grip { pub struct BlockDrag { page: usize, block: usize, + /// A paragraph index belongs to the document generation where the press was resolved. + generation: u64, /// Where the drag started (screen). start: Pos2, grip: Grip, @@ -427,10 +429,15 @@ pub(crate) fn page_input( resp: &egui::Response, xf: &PageXform, page: usize, - info: &DocInfo, + doc: &pdfcraft_engine::Document, lines: &[pdfcraft_engine::TextBlock], view: &mut DocView, ) -> bool { + let info = &doc.info; + if view.block_drag.is_some_and(|d| d.generation != doc.edit_generation()) { + view.block_drag = None; + return true; + } let boxes: Vec = lines.iter().map(|l| xf.user_rect(info, page, l.rect.map(|v| v as f32)).expand(2.0)).collect(); let painter = ui.painter(); let active = view.line_editor.as_ref().filter(|e| e.page == page).map(|e| e.block); @@ -470,18 +477,25 @@ pub(crate) fn page_input( if resp.drag_stopped() || !ui.input(|i| i.pointer.any_down()) { view.block_drag = None; let (from, to) = (user_box(xf, info, page, b), user_box(xf, info, page, preview)); - let style = if resizing { + let edit = if resizing { // The new width in user space: the box's change, added to the paragraph's own. A // moved left side (in user space; on an upside-down page that's the screen's right // edge) moves the paragraph with it. let width = (l.rect[2] - l.rect[0]) + (to[2] - to[0]) - (from[2] - from[0]); let shift = to[0] - from[0]; - pdfcraft_engine::BlockStyle { width: Some(width), offset: (shift != 0.0).then_some([shift, 0.0]), ..Default::default() } + let style = pdfcraft_engine::BlockStyle { width: Some(width), offset: (shift != 0.0).then_some([shift, 0.0]), ..Default::default() }; + Edit::EditTextBlock { page, block: d.block, text: l.text.clone(), style } } else { - pdfcraft_engine::BlockStyle { offset: Some([to[0] - from[0], to[1] - from[1]]), ..Default::default() } + // Translation must keep the selected source operators. Rewriting unchanged text + // would also remove unrelated paragraphs that now overlap this one (#594). + Edit::MoveObjects { + page, + objects: vec![ObjectTarget { kind: ObjectKind::Text, index: d.block }], + offset: [to[0] - from[0], to[1] - from[1]], + } }; if preview != b { - view.pending_edit = Some(Edit::EditTextBlock { page, block: d.block, text: l.text.clone(), style }); + view.pending_edit = Some(edit); } } return true; @@ -495,7 +509,7 @@ pub(crate) fn page_input( && let Some(o) = ui.input(|i| i.pointer.press_origin()) && let Some((block, grip)) = grab_at(&boxes, o, upright) { - view.block_drag = Some(BlockDrag { page, block, start: o, grip }); + view.block_drag = Some(BlockDrag { page, block, generation: doc.edit_generation(), start: o, grip }); return true; } let Some(p) = ui.input(|i| i.pointer.hover_pos()).filter(|p| xf.rect.contains(*p)) else { return false }; diff --git a/crates/ui-egui/tests/bookmarks.rs b/crates/ui-egui/tests/bookmarks.rs index 846ba59f1..6d94fb633 100644 --- a/crates/ui-egui/tests/bookmarks.rs +++ b/crates/ui-egui/tests/bookmarks.rs @@ -165,6 +165,9 @@ fn search_finds_collapsed_bookmarks_and_keeps_original_paths() { h.run_steps(3); assert!(h.query_by_label("Résumé results").is_none()); let id = h.state().views[0].id; + // A full save stamps /ModDate from the clock: pin it, or two saves a second apart differ. + let session = std::mem::take(&mut h.state_mut().session); + h.state_mut().session = session.with_clock(|| 1_700_000_000); let before = h.state().session.save_full_bytes(id).unwrap(); h.get_by_label("Search").click(); h.run_steps(1); diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 4acc49902..ba4bbefa0 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -2269,7 +2269,7 @@ fn dragging_a_paragraph_moves_it_and_its_edge_rewraps_it() { assert_eq!(moved.text, "Page 1"); let near = |a: f64, b: f64| (a - b).abs() < 1.5; assert!(near(moved.rect[0], before.rect[0] + 30.0) && near(moved.rect[1], before.rect[1] + 50.0), "{:?} → {:?}", before.rect, moved.rect); - assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Edit text")); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Move object")); // Drag the handle on its right edge in to about 45 pt wide: "Page" and "1" rewrap onto two lines. let edge = screen(moved.rect[2], (moved.rect[1] + moved.rect[3]) / 2.0) + egui::vec2(2.0, 0.0); let narrower = (moved.rect[2] - moved.rect[0] - 45.0) as f32 * k; @@ -2279,9 +2279,393 @@ fn dragging_a_paragraph_moves_it_and_its_edge_rewraps_it() { let lines: Vec = doc.text_lines(0).iter().map(|l| l.text.clone()).collect(); assert_eq!(lines, ["Page", "1"], "rewrapped to the narrower box"); assert!(near(doc.text_lines(0)[0].rect[0], moved.rect[0]), "it keeps its place"); + assert_eq!(doc.can_undo(), Some("Edit text"), "edge drags still rewrap text"); assert!(h.state().views[0].line_editor.is_none()); } +/// Contributor-original text, with an off-origin CropBox. Keep the moving paragraph last in +/// source order so an ordinary hit picks it again when two independent boxes fully overlap. +fn paragraph_drag_fixture(content: &str, rotation: i64, unit: i64) -> Vec { + use pdfcraft_cos::{Dict, Document, Object, Stream}; + let mut doc = Document::open(std::sync::Arc::new(fixture(1))).unwrap(); + let page = pdfcraft_model::pages(&doc)[0].obj; + let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), content.as_bytes()))); + doc.update_dict(page, |d| { + d.set(b"Contents".to_vec(), Object::Ref(contents)); + d.set(b"MediaBox".to_vec(), Object::Array([0, 0, 400, 500].map(Object::Int).to_vec())); + d.set(b"CropBox".to_vec(), Object::Array([20, 30, 380, 470].map(Object::Int).to_vec())); + d.set(b"Rotate".to_vec(), Object::Int(rotation)); + d.set(b"UserUnit".to_vec(), Object::Int(unit)); + }) + .unwrap(); + pdfcraft_cos::write_incremental(&doc, &Default::default()).unwrap() +} + +fn paragraph_drag_harness(bytes: Vec, view_rotation: u16) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1100.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.session = pdfcraft_engine::Session::new().with_clock(|| 1_700_000_000); + app.set_option("language", "en").unwrap(); + app.open_bytes("paragraphs.pdf", None, bytes).unwrap(); + app.set_option("zoom", "75").unwrap(); + app.views[0].rotation = view_rotation; + assert!(app.execute("edit.edit_text")); + app + }); + h.run_steps(4); + h +} + +fn dragged_paragraphs(h: &Harness<'static, PdfCraftApp>) -> Vec { + let app = h.state(); + app.session.get(app.views[0].id).unwrap().text_blocks(0) +} + +/// Resolve the current inventory and transform for every gesture, never an old block index +/// or cached screen position. With identical labels, choose the last (topmost) source box. +fn paragraph_drag_points(h: &Harness<'static, PdfCraftApp>, text: &str, offset: [f64; 2]) -> (egui::Pos2, egui::Pos2) { + let app = h.state(); + let view = &app.views[0]; + let doc = app.session.get(view.id).unwrap(); + let b = doc.text_blocks(0).into_iter().rfind(|b| b.text == text).unwrap(); + let xf = pdfcraft_ui_egui::canvas::PageXform { + rect: view.page_screen_rect(0).unwrap(), + rot: view.rotation, + pw: doc.info.pages[0].width, + ph: doc.info.pages[0].height, + }; + let moved = std::array::from_fn(|i| (b.rect[i] + offset[i % 2]) as f32); + (xf.user_rect(&doc.info, 0, b.rect.map(|v| v as f32)).center(), xf.user_rect(&doc.info, 0, moved).center()) +} + +fn drag_paragraph(h: &mut Harness<'static, PdfCraftApp>, text: &str, offset: [f64; 2]) { + let (from, to) = paragraph_drag_points(h, text, offset); + h.get_by_label("Shift-click to select several objects. Drag empty space to select an area."); + assert!(h.state().views[0].line_editor.is_none()); + drag(h, from, to); + assert!(h.state().views[0].line_editor.is_none(), "a body drag must not open the typing editor"); + h.get_by_label("Shift-click to select several objects. Drag empty space to select an area."); +} + +fn assert_dragged_paragraphs(actual: &[pdfcraft_engine::TextBlock], expected: &[pdfcraft_engine::TextBlock]) { + assert_eq!(actual.len(), expected.len(), "independent paragraphs must survive"); + // Source order is itself an invariant, including when text and geometry are identical. + for (a, e) in actual.iter().zip(expected) { + assert_eq!((&a.text, &a.base_font, a.size, a.bold, a.italic, a.color), (&e.text, &e.base_font, e.size, e.bold, e.italic, e.color)); + assert_eq!(a.lines.len(), e.lines.len(), "moving must not rewrap"); + assert!(a.rect.iter().zip(e.rect).all(|(a, e)| (a - e).abs() < 0.01), "{a:?} != {e:?}"); + } +} + +/// Read only the current page contents, not stale revisions or the renderer's deduplicated +/// text layer. These synthetic fixtures contain each literal exactly once per source draw. +fn paragraph_drag_content(h: &Harness<'static, PdfCraftApp>) -> String { + let app = h.state(); + let doc = pdfcraft_cos::Document::open(app.session.get(app.views[0].id).unwrap().bytes.clone()).unwrap(); + let page = pdfcraft_model::pages(&doc)[0].clone(); + let contents = doc.resolve(page.dict.get(b"Contents").unwrap()); + let pieces = match contents.as_array() { + Some(a) => a.clone(), + None => vec![contents.as_ref().clone()], + }; + let mut bytes = Vec::new(); + for piece in pieces { + let object = doc.resolve(&piece); + let pdfcraft_cos::Object::Stream(stream) = object.as_ref() else { panic!("fixture content must be a stream") }; + bytes.extend(stream.decoded().unwrap()); + } + String::from_utf8(bytes).unwrap() +} + +fn save_paragraph_drag(h: &mut Harness<'static, PdfCraftApp>, name: &str, original: &[u8]) -> Vec { + let out = temp_path(name); + let id = h.state().views[0].id; + h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); + h.key_press_modifiers(Modifiers::COMMAND, Key::S); + h.run_steps(4); + let saved = std::fs::read(&out).unwrap(); + assert!(saved.starts_with(original), "Save keeps the opened/saved revisions"); + assert!(!h.state().session.get(id).unwrap().dirty); + assert_eq!(h.state().session.get(id).unwrap().bytes.as_slice(), saved.as_slice()); + std::fs::remove_file(out).unwrap(); + saved +} + +fn paragraph_drag_shot(h: &mut Harness<'static, PdfCraftApp>, name: &str) { + if let Ok(directory) = std::env::var("PDFCRAFT_PARAGRAPH_DRAG_SHOTS") { + for _ in 0..60 { + h.run_steps(2); + if !h.state().render_pending() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(!h.state().render_pending(), "the screenshot must show the current document generation"); + let directory = std::path::Path::new(&directory); + std::fs::create_dir_all(directory).unwrap(); + h.render().unwrap().save(directory.join(name)).unwrap(); + } +} + +fn check_paragraph_overlap_drag(identical: bool, reopen_between: bool) { + let (stationary, moving) = if identical { ("Repeated", "Repeated") } else { ("Stationary beta", "Moving alpha") }; + let content = + format!("BT /F1 12 Tf 60 220 Td ({stationary}) Tj ET BT /F1 12 Tf 250 100 Td (Keep here) Tj ET BT /F1 12 Tf 60 320 Td ({moving}) Tj ET"); + let mut saved_base = paragraph_drag_fixture(&content, 0, 1); + let mut h = paragraph_drag_harness(saved_base.clone(), 0); + let before = dragged_paragraphs(&h); + assert_eq!(before.len(), 3); + paragraph_drag_shot(&mut h, &format!("before-{identical}-{reopen_between}.png")); + let moving_index = before.iter().rposition(|b| b.text == moving).unwrap(); + let mut overlap = before.clone(); + for i in [1, 3] { + overlap[moving_index].rect[i] -= 100.0; + } + drag_paragraph(&mut h, moving, [0.0, -100.0]); + assert_dragged_paragraphs(&dragged_paragraphs(&h), &overlap); + paragraph_drag_shot(&mut h, &format!("overlap-{identical}-{reopen_between}.png")); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(4); + assert_dragged_paragraphs(&dragged_paragraphs(&h), &before); + assert!(h.state().session.get(h.state().views[0].id).unwrap().can_undo().is_none(), "the first release was exactly one step"); + h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z); + h.run_steps(4); + assert_dragged_paragraphs(&dragged_paragraphs(&h), &overlap); + + if reopen_between { + let bytes = save_paragraph_drag(&mut h, &format!("paragraph-overlap-{identical}.pdf"), &saved_base); + saved_base = bytes.clone(); + h = paragraph_drag_harness(bytes, 0); + assert_dragged_paragraphs(&dragged_paragraphs(&h), &overlap); + } + // A rewrite here used to remove the stationary box, even if its text was different. + drag_paragraph(&mut h, moving, [150.0, 0.0]); + let mut after = overlap.clone(); + for i in [0, 2] { + after[moving_index].rect[i] += 150.0; + } + assert_dragged_paragraphs(&dragged_paragraphs(&h), &after); + paragraph_drag_shot(&mut h, &format!("after-{identical}-{reopen_between}.png")); + let current = paragraph_drag_content(&h); + assert_eq!(current.matches(&format!("({stationary})")).count(), if identical { 2 } else { 1 }); + assert_eq!(current.matches(&format!("({moving})")).count(), if identical { 2 } else { 1 }); + assert_eq!(current.matches("(Keep here)").count(), 1); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Move object")); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(4); + assert_dragged_paragraphs(&dragged_paragraphs(&h), &overlap); + if !reopen_between { + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(4); + assert_dragged_paragraphs(&dragged_paragraphs(&h), &before); + } + assert!(h.state().session.get(h.state().views[0].id).unwrap().can_undo().is_none(), "each release was exactly one step"); + if !reopen_between { + h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z); + h.run_steps(4); + assert_dragged_paragraphs(&dragged_paragraphs(&h), &overlap); + } + h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z); + h.run_steps(4); + assert_dragged_paragraphs(&dragged_paragraphs(&h), &after); + assert!(h.state().session.get(h.state().views[0].id).unwrap().can_redo().is_none()); + let bytes = save_paragraph_drag(&mut h, &format!("paragraph-separated-{identical}-{reopen_between}.pdf"), &saved_base); + let reopened = paragraph_drag_harness(bytes, 0); + assert_dragged_paragraphs(&dragged_paragraphs(&reopened), &after); + assert_eq!(paragraph_drag_content(&reopened), current); +} + +#[test] +fn ordinary_paragraph_drags_preserve_overlapping_text_and_history() { + check_paragraph_overlap_drag(false, false); +} + +#[test] +fn ordinary_paragraph_drags_preserve_identical_overlapping_labels() { + check_paragraph_overlap_drag(true, false); +} + +#[test] +fn ordinary_paragraph_drags_preserve_overlap_after_save_and_reopen() { + check_paragraph_overlap_drag(false, true); + check_paragraph_overlap_drag(true, true); +} + +#[test] +fn ordinary_paragraph_drags_preserve_partial_overlap_and_touching_labels() { + let content = "BT /F1 12 Tf 60 220 Td (Repeated) Tj ET BT /F1 12 Tf 60 320 Td (Repeated) Tj ET"; + for fraction in [0.25, 0.75, 1.0] { + let mut h = paragraph_drag_harness(paragraph_drag_fixture(content, 0, 1), 0); + let mut expected = dragged_paragraphs(&h); + assert_eq!(expected.len(), 2); + let width = expected[1].rect[2] - expected[1].rect[0]; + // 75% overlap, 25% overlap, then exact touching of the original source rectangles. + for offset in [[width * fraction, -100.0], [150.0, -40.0]] { + drag_paragraph(&mut h, "Repeated", offset); + for (i, coordinate) in expected[1].rect.iter_mut().enumerate() { + *coordinate += offset[i % 2]; + } + assert_dragged_paragraphs(&dragged_paragraphs(&h), &expected); + assert_eq!(paragraph_drag_content(&h).matches("(Repeated)").count(), 2); + } + } +} + +#[test] +fn ordinary_paragraph_drags_preserve_multiline_kerning_strings() { + let content = + concat!("BT /F1 12 Tf 60 220 Td (Keep here) Tj ET ", "BT /F1 12 Tf 14 TL 60 320 Td [(First) -120 ( line)] TJ T* (Second line) Tj ET"); + let mut h = paragraph_drag_harness(paragraph_drag_fixture(content, 0, 1), 0); + let mut expected = dragged_paragraphs(&h); + assert_eq!(expected.len(), 2); + assert_eq!(expected[1].lines.len(), 2); + let text = expected[1].text.clone(); + for offset in [[0.0, -100.0], [150.0, 0.0]] { + drag_paragraph(&mut h, &text, offset); + for (i, coordinate) in expected[1].rect.iter_mut().enumerate() { + *coordinate += offset[i % 2]; + } + assert_dragged_paragraphs(&dragged_paragraphs(&h), &expected); + let current = paragraph_drag_content(&h); + assert!(current.contains("[(First) -120 ( line)] TJ"), "original encoded strings and kerning stay byte-identical"); + for literal in ["(Keep here)", "(Second line)"] { + assert_eq!(current.matches(literal).count(), 1); + } + } +} + +#[test] +fn ordinary_paragraph_drag_respects_rotations_crop_and_user_unit() { + let content = "BT /F1 12 Tf 60 220 Td (Keep here) Tj ET BT /F1 12 Tf 60 320 Td (Moving alpha) Tj ET"; + for rotation in [0, 90, 180, 270] { + for view_rotation in [0, 90, 180, 270] { + let mut h = paragraph_drag_harness(paragraph_drag_fixture(content, rotation, 2), view_rotation); + let mut expected = dragged_paragraphs(&h); + let moving = expected.iter_mut().find(|b| b.text == "Moving alpha").unwrap(); + for (i, coordinate) in moving.rect.iter_mut().enumerate() { + *coordinate += [35.0, -27.0][i % 2]; + } + drag_paragraph(&mut h, "Moving alpha", [35.0, -27.0]); + assert_dragged_paragraphs(&dragged_paragraphs(&h), &expected); + } + } +} + +#[test] +fn an_ordinary_added_text_drag_keeps_its_content_edit() { + for panel_open in [true, false] { + check_added_text_drag(panel_open); + } +} + +fn check_added_text_drag(panel_open: bool) { + let content = "BT /F1 12 Tf 60 220 Td (Keep here) Tj ET"; + let mut h = paragraph_drag_harness(paragraph_drag_fixture(content, 0, 1), 0); + let id = h.state().views[0].id; + h.state_mut() + .session + .apply( + id, + pdfcraft_engine::Edit::AddText { + page: 0, + text: pdfcraft_engine::AddedText { text: "Added note".into(), rect: [100.0, 330.0, 230.0, 354.0], ..Default::default() }, + }, + ) + .unwrap(); + let original = h.state().session.save_bytes(id).unwrap(); + h = paragraph_drag_harness(original.as_ref().clone(), 0); + h.state_mut().left_open = panel_open; + h.run_steps(4); + let added = |h: &Harness<'static, PdfCraftApp>| { + let app = h.state(); + app.session.get(app.views[0].id).unwrap().added.clone() + }; + let before_added = added(&h); + assert_eq!(before_added.len(), 1); + let before = dragged_paragraphs(&h); + assert_eq!(before.len(), 2, "added text is also visible in the raw paragraph list"); + let mut expected = before.clone(); + let moved = expected.iter_mut().find(|b| b.text == "Added note").unwrap(); + for (i, coordinate) in moved.rect.iter_mut().enumerate() { + *coordinate += [30.0, -20.0][i % 2]; + } + // The same pointer hits added content and its source glyphs. Only content_ui may own it: + // a Text target would be excluded from MoveObjects and overwrite the valid UpdateContent. + let (from, to) = paragraph_drag_points(&h, "Added note", [30.0, -20.0]); + drag(&mut h, from, to); + assert!(h.state().views[0].line_editor.is_none()); + assert!(h.state().views[0].block_drag.is_none()); + assert_dragged_paragraphs(&dragged_paragraphs(&h), &expected); + let after_added = added(&h); + assert_eq!(after_added.len(), 1); + let (old_rect, new_rect) = (before_added[0].content.rect(), after_added[0].content.rect()); + for i in 0..4 { + assert!((new_rect[i] - old_rect[i] - [30.0, -20.0][i % 2]).abs() < 0.01); + } + assert_eq!(after_added[0].content, before_added[0].content.with_rect(new_rect)); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Edit content")); + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(4); + assert_eq!(added(&h), before_added); + assert_dragged_paragraphs(&dragged_paragraphs(&h), &before); + assert!(h.state().session.get(h.state().views[0].id).unwrap().can_undo().is_none(), "one content edit, no paragraph rewrite"); + h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z); + h.run_steps(4); + assert_eq!(added(&h), after_added); + assert_dragged_paragraphs(&dragged_paragraphs(&h), &expected); + let bytes = save_paragraph_drag(&mut h, &format!("paragraph-added-content-{panel_open}.pdf"), original.as_slice()); + let reopened = paragraph_drag_harness(bytes, 0); + assert_eq!(added(&reopened), after_added); + assert_dragged_paragraphs(&dragged_paragraphs(&reopened), &expected); +} + +#[test] +fn an_ordinary_paragraph_drag_refusal_never_rewrites_the_text() { + let content = "q BT 4 Tr /F1 12 Tf 60 320 Td (Clipping text) Tj ET Q"; + let mut h = paragraph_drag_harness(paragraph_drag_fixture(content, 0, 1), 0); + let before = dragged_paragraphs(&h); + let id = h.state().views[0].id; + let bytes = h.state().session.get(id).unwrap().bytes.clone(); + let generation = h.state().session.get(id).unwrap().edit_generation(); + drag_paragraph(&mut h, "Clipping text", [30.0, -20.0]); + let doc = h.state().session.get(id).unwrap(); + assert_eq!(doc.bytes, bytes); + assert_eq!(doc.edit_generation(), generation); + assert!(doc.can_undo().is_none()); + assert!(!doc.dirty); + assert_dragged_paragraphs(&dragged_paragraphs(&h), &before); + assert!(h.state().toast.as_ref().unwrap().0.contains("Text used as a clipping path cannot be moved with this selection.")); +} + +#[test] +fn an_ordinary_paragraph_drag_cannot_reuse_an_index_after_an_edit() { + let content = "BT /F1 12 Tf 60 120 Td (Discard) Tj ET BT /F1 12 Tf 60 320 Td (Moving alpha) Tj ET BT /F1 12 Tf 60 220 Td (Keep here) Tj ET"; + let mut h = paragraph_drag_harness(paragraph_drag_fixture(content, 0, 1), 0); + let (from, to) = paragraph_drag_points(&h, "Moving alpha", [30.0, -20.0]); + h.hover_at(from); + h.run_steps(1); + h.drag_at(from); + h.run_steps(1); + h.hover_at(to); + h.run_steps(2); + assert!(h.state().views[0].block_drag.is_some()); + let id = h.state().views[0].id; + h.state_mut() + .session + .apply(id, pdfcraft_engine::Edit::EditTextBlock { page: 0, block: 0, text: String::new(), style: Default::default() }) + .unwrap(); + let after_edit = dragged_paragraphs(&h); + assert_eq!(after_edit.len(), 2, "the surviving paragraphs get different indexes"); + assert_eq!(after_edit[1].text, "Keep here", "the old target index now names an unrelated paragraph"); + let generation = h.state().session.get(id).unwrap().edit_generation(); + h.run_steps(2); + h.drop_at(to); + h.run_steps(4); + assert!(h.state().views[0].block_drag.is_none()); + assert_eq!(h.state().session.get(id).unwrap().edit_generation(), generation, "the stale release adds no edit"); + assert_dragged_paragraphs(&dragged_paragraphs(&h), &after_edit); +} + /// A quick flick: press at `from`, and the next frame already finds the pointer at `to`. fn flick(h: &mut Harness<'static, PdfCraftApp>, from: egui::Pos2, to: egui::Pos2) { h.hover_at(from); diff --git a/docs/roadmap.md b/docs/roadmap.md index fc8cf7857..4fde3d4a3 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,6 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (#594 paragraph drags validated in the Linux test environment; localized shortcut labels, #496; alpha gate added; convert-and-share re-judged with the cross-app rule → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (architectural paper controls from #599; M6: respect existing form-font encodings; retain source fonts on appearance fallback; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (direct Organize overview and page actions; localized shortcut labels, #496; alpha gate added; convert-and-share re-judged with the cross-app rule → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (closed issues synced: #774, #756, #817–#821, #809, #819, #816, #844; earlier: localized shortcut labels, #496; alpha gate added; convert-and-share re-judged with the cross-app rule → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -79,6 +80,7 @@ total matches the parity estimate. | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export; flatten; Fill & Sign; agent tools.
Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA | +| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. | @@ -135,6 +137,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | M7: #594 ordinary paragraph drags validated in the Linux test environment with source-loss baseline failures, UI/engine regressions, native save/reopen and all eight CI gates. Estimates unchanged. | | 2026-10-11 | minor | M4: direct Organize overview, checkbox selection and individual page actions. Estimates unchanged. | | 2026-10-11 | minor | Synced with closed issues: encrypted output opens in Acrobat (#774, #836), so gap 1 left rank 0 and convert and share (32–55 h, the gap's 8–15 h removed); organize, XFDF and multi-object move fixes (#817, #821, #809, #819, #844); the #816 panic and the #799–#821 batch fixed; rank 1 names web printing (Windows shipped, #756). Stage and readiness numbers not re-measured | | 2026-10-11 | minor | M6: respect existing form-font encodings; retain source fonts on appearance fallback. Estimates unchanged. |