M7: preserve overlapping text when dragging paragraphs (#594) (#1052)

* M7 #594: preserve ordinary paragraph drags (source-only GGEO candidate) [skip ci]

Route body drags through source-preserving MoveObjects, bind gestures to
the document generation, and keep added-content input ownership with
the Edit panel open or closed. Add nine real-shell source regressions.

Source-only transfer to the owner's fork, explicitly authorized before
validation. Independently source-reviewed; formatting, builds, tests,
rendering and all gates remain pending on ggeo-ai02. No upstream PR.

AI-assisted with OpenAI.

* M7 #594: record validated ordinary paragraph drags

Format the source-reviewed candidate and record successful GGEO validation:
focused UI/engine tests, exact-base source loss, native drag/history/save/reopen,
independent Poppler checks, and all eight repository CI gates.

Credit: @FredApps

* M7: use generic validation wording

* Tests: pin the clock where two saves or combines are compared byte for byte

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: FredApps <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-11 18:25:25 -04:00
1 parent 8ee3673bd4
commit 6091deb829
7 files changed
+423 -12

No files matched your search

+4
View File
@@ -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<Object>` 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%) |
+2 -1
View File
@@ -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<CombineSource> =
vec![("a".into(), a.clone(), Some("1-2".into())), ("b".into(), b, None), ("a".into(), a.clone(), Some("3".into()))];
+5 -3
View File
@@ -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);
+21 -7
View File
@@ -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<Rect> = 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 };
+3
View File
@@ -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);
+385 -1
View File
@@ -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<String> = 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<u8> {
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<u8>, 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<pdfcraft_engine::TextBlock> {
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<u8> {
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);
+3
View File
@@ -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:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>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.<br>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.<br>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.<br>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<br>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.<br>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<br>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. |