mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
* 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:
7 files changed
+423
-12
No files matched your search
@@ -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%) |
|
||||
|
||||
@@ -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()))];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in new issue
Block a user