From 6b185cdc5bf34a7dea4b588cb788d88466c1d798 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:00:45 +0200 Subject: [PATCH] Add permission-aware selected-text Copy to Edit and context menus (#1244) * M3: add selected-text Copy to page and Edit menus (#1225) Register selected page-text Copy with the existing document permission gate and expose it in Edit and the ordinary page context menu. Route floating Copy through the same gate while preserving focused-input and Organize clipboard ownership. Four regressions fail behaviorally on the exact base; five candidate regressions pass. All eight cargo xtask ci stages pass: 2575 tests passed, 12 ignored, and 31 WASM crate checks. Native synthetic checks verify Unicode clipboard output, disabled no-selection/restricted actions, and permissions-password unlock using presented Xvfb/Vulkan software-rendered frames. Physical display and hardware GPU coverage are not claimed; the optional cargo-deny audit was unavailable. Generated with Codex for FredApps. * Tests: scope GoToR case mutation to Unix for Windows Clippy Keep the common directory/non-PDF cases immutable. Shadow them in the Unix-only block so FIFO coverage can append its case without an unused-mut warning on Windows. No assertion or platform coverage is removed. This repairs the exact hosted Windows Clippy failure inherited from upstream. Source review and whitespace checks passed; no local build, test or formatter was run during the pause. Automatically triggered hosted CI will validate this head separately. Generated with Codex for FredApps. * Tests: scope document link target mutation to Unix for Windows Clippy Preserve directory coverage on all native platforms and device coverage on Unix. Static diff, whitespace and conflict checks passed; no local build, test or formatter was run during the pause. Generated with Codex for FredApps. * Tests: escape GoToR fixture file paths as PDF literal strings Windows absolute paths were interpreted as PDF escape sequences, introducing control characters and failing the hosted local GoToR root test. Escape backslashes and parentheses in the fixture helper without changing product behavior or assertions. Static diff and whitespace checks passed; hosted CI will validate the repair. No local builds, tests or formatters ran. Generated with Codex for FredApps. * Tests: use ordinary absolute paths in the GoToR fixture Build the absolute target from the original sandbox base rather than its canonicalized Windows verbatim path. Assert absolute-path form and canonical identity with the sandbox target. Preserve production UNC/device/verbatim rejection and existing containment and destination assertions. Static diff and whitespace checks passed; no local build, test or formatter ran. Generated with Codex for FredApps. --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 2 + crates/engine/src/commands.rs | 2 + crates/ui-egui/src/canvas.rs | 54 +++++-- crates/ui-egui/src/commands.rs | 10 +- crates/ui-egui/src/lib.rs | 15 +- crates/ui-egui/tests/commands.rs | 3 +- crates/ui-egui/tests/ui_group/text_select.rs | 162 ++++++++++++++++++- docs/roadmap.md | 2 +- parity/acrobat-features.toml | 8 +- 9 files changed, 230 insertions(+), 28 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 98936d0999..40f62ab883 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -158,6 +158,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log - **2026-10-13 (G14, Vietnamese localization):** Added Vietnamese (`vi`, Tiếng Việt) as an interface and date language with a 2,591-entry catalog (`plural_none`) covering 100% of the current localizable key union (plus `theme`/`Appearance`). Locale tags `vi` / `vi-VN` / `vi_VN.UTF-8` resolve; calendar months use numeric `Tháng 1`…`12` / `tháng 1`…`12`. Native-speaker community review recorded. The twelve-language localization mean moves from ≈59% to ≈68%; weighted readiness ≈49% → ≈50%. Status stays `partial`: engine/OS details, document contents, filenames, CLI/MCP and external help stay outside the catalog by design. +- 2026-10-12 · M3: selected page text can be copied from Edit and the canvas context menu (#1225). Five focused regressions, full CI and native Unicode clipboard checks pass; floating Copy shares the document permission gate. Estimates unchanged. + - **2026-10-13 (M2, page-label prefixes):** Preserve literal leading and trailing whitespace in page-label prefixes without changing generic metadata normalization. Nine original public-inspection cases cover prefix spaces, encodings and empty-prefix controls. Existing empty-prefix fallback remains a compatibility policy, not a conformance claim. Estimates unchanged. - **2026-10-14 (M14, Italian catalog):** Italian gains the 201 entries the other catalogs had and the code looks up: the sentences the accessibility checker, preflight, signature validation and page-range parser word, certificate and calendar labels, digital-ID dialog messages and five plurals. It now has 2,528 entries. 64 keys other catalogs carry but no code looks up (tooltip texts with a fixed shortcut, older wordings, generic labels only Lithuanian has) were left out. AI-assisted, written from the meaning of the English; native review welcome. Estimates unchanged. - **2026-10-15 (M3, #2166 part 1):** Save asks before writing over a version of the file the document did not come from (something else wrote it since the open or the last save), with Save as… to keep it, and the changed-on-disk notice says what Reload and Save each do; a file that is open already comes to the front instead of opening twice; a running instance's autosaves are locked, so a second instance neither offers nor discards them. Part 2 (every writer checked and atomic) is #2182. diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 414cf7553f..45c58302eb 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -213,6 +213,8 @@ pub const COMMANDS: &[CommandSpec] = &[ // Acrobat's column select: drag a rectangle and select only the text inside it. Alt/Option- // drag with the Select tool does the same; this is the way in where the desktop takes // Alt-drag for moving windows (several Linux window managers). + // Copy events stay with the frontend so focused text inputs and page clipboard routing keep ownership. + c("edit.copy", "Copy", EDIT, None, Copying, "copy"), c("edit.column_select", "Column select", EDIT, None, Document, "text-select"), c("edit.snapshot", "Take a snapshot", EDIT, None, Copying, "camera"), c("view.full_screen", "Full screen mode", VIEW, Some(Shortcut::cmd("L")), Document, "maximize"), diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3920a5dc9a..708d8c1737 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2498,6 +2498,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let redact_mark = (app.redact_prefs.hover_fill, app.redact_prefs.opacity); let units = app.units; let allowed = doc.allows_annotation(); + let can_copy = doc.allows_copying(); + let mut copy_selected = false; let comments_hidden = doc.comments_hidden(); let form = doc.form.clone(); let can_fill = doc.allows_form_filling(); @@ -3359,6 +3361,15 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } return; } + if view.comments.selected.is_none() { + let selected = view.selected_text().is_some_and(|t| !t.is_empty()); + let key = crate::commands::shortcut_label(ui.ctx(), crate::commands::COPY); + if ui.add_enabled(can_copy && selected, egui::Button::new(tl!("Copy")).shortcut_text(key)).clicked() { + copy_selected = true; + ui.close(); + } + ui.separator(); + } // Over a picture (and no comment): Copy Image, as in Acrobat's page menu. let mut copy_image = None; if view.comments.selected.is_none() @@ -3402,7 +3413,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { view.viewport_screen = out.inner_rect; view.auto_scroll.paint(ui, avail); if unobstructed && matches!(tool, QuickTool::Select | QuickTool::ColumnSelect) { - selection_actions(ui, avail, view, info, prefs, allowed && !comments_hidden); + copy_selected |= selection_actions(ui, avail, view, info, prefs, allowed && !comments_hidden, can_copy); } // Collect all main-page demand, including cached surfaces, for byte admission later. @@ -3697,6 +3708,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { if let Some((name, action)) = app.views[index].forms.button.take() { run_button(app, index, &name, action); } + if copy_selected { + app.execute("edit.copy"); + } quick_bar(app, avail, ui); if let Some((source, file, destination)) = remote_open { app.open_remote_link(source, file, destination); @@ -3712,21 +3726,29 @@ enum SelectionAction { } /// Actions on an existing text selection, next to the selected words. -fn selection_actions(ui: &mut egui::Ui, viewport: Rect, view: &mut DocView, info: &DocInfo, prefs: &comments::CommentPrefs, can_comment: bool) { +fn selection_actions( + ui: &mut egui::Ui, + viewport: Rect, + view: &mut DocView, + info: &DocInfo, + prefs: &comments::CommentPrefs, + can_comment: bool, + can_copy: bool, +) -> bool { if ui.input(|input| input.pointer.is_decidedly_dragging()) { - return; + return false; } - let Some(selection) = view.selection else { return }; - let Some(text) = view.texts.get(&selection.page) else { return }; - let Some(xf) = view.page_xform(selection.page) else { return }; - let Some(last) = selection.rects(text).last().copied() else { return }; + let Some(selection) = view.selection else { return false }; + let Some(text) = view.texts.get(&selection.page) else { return false }; + let Some(xf) = view.page_xform(selection.page) else { return false }; + let Some(last) = selection.rects(text).last().copied() else { return false }; let selected = selection.text(text); if selected.is_empty() { - return; + return false; } let rect = xf.view_rect(last); if !viewport.intersects(rect) { - return; + return false; } let x = rect.left().clamp(viewport.left() + 8.0, (viewport.right() - 520.0).max(viewport.left() + 8.0)); let y = if rect.bottom() + 48.0 < viewport.bottom() { rect.bottom() + 6.0 } else { (rect.top() - 44.0).max(viewport.top() + 6.0) }; @@ -3745,18 +3767,17 @@ fn selection_actions(ui: &mut egui::Ui, viewport: Rect, view: &mut DocView, info chosen = Some(action); } } - if ui.button(tl!("Copy")).clicked() { + if ui.add_enabled(can_copy, egui::Button::new(tl!("Copy"))).clicked() { chosen = Some(SelectionAction::Copy); } }); }); }); - let Some(action) = chosen else { return }; + let Some(action) = chosen else { return false }; if let SelectionAction::Copy = action { - ui.ctx().copy_text(selected); - return; + return true; } - let Some((page, quads)) = view.selection_quads(info) else { return }; + let Some((page, quads)) = view.selection_quads(info) else { return false }; let last = quads.last().copied().unwrap_or_default(); let at = [last[2].max(last[6]), last[1].max(last[3])]; view.clear_selection(); @@ -3775,7 +3796,7 @@ fn selection_actions(ui: &mut egui::Ui, viewport: Rect, view: &mut DocView, info } SelectionAction::Markup(tool) => { // Every markup button carries a markup tool; anything else adds nothing. - let Some(kind) = tool.markup() else { return }; + let Some(kind) = tool.markup() else { return false }; view.pending_edit = Some(Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { page, shape: pdfcraft_engine::Shape::TextMarkup { kind, quads }, @@ -3784,9 +3805,10 @@ fn selection_actions(ui: &mut egui::Ui, viewport: Rect, view: &mut DocView, info author: prefs.author.clone(), })); } - // Copied above. + // Copy is dispatched through the app after the view borrow ends. SelectionAction::Copy => {} } + false } /// Run a push button's action (the ones that need no JavaScript engine). diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index a989f60353..18aee27327 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -85,6 +85,8 @@ impl PdfCraftApp { return self.combine_can_undo(spec.needs == commands::Needs::Undo); } commands::is_enabled(spec, &self.session, self.active_ids().map(|(_, id)| id)) + && (spec.id != "edit.copy" + || self.active.and_then(|i| self.views.get(i)).is_some_and(|v| !v.organize && v.selected_text().is_some_and(|t| !t.is_empty()))) && (spec.needs != commands::Needs::TwoPageView || self.active.and_then(|i| self.views.get(i)).is_some_and(crate::DocView::cover_applies)) && match spec.id { "view.previous_view" => self.active.and_then(|i| self.views.get(i)).is_some_and(|v| !v.back.is_empty()), @@ -527,6 +529,12 @@ impl PdfCraftApp { self.quick_tool = crate::QuickTool::Snapshot; self.notify_tr("Drag a rectangle around the area to copy"); } + "edit.copy" => { + if let (Some(i), Some(ctx)) = (active, self.ctx.clone()) { + return self.copy_selected_text(i, &ctx); + } + return false; + } "page.copy" => self.copy_pages(false), "page.cut" => self.copy_pages(true), "page.paste" => self.paste_pages(), @@ -785,7 +793,7 @@ pub(crate) fn registry_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui, menu: &str } continue; } - let shortcut = spec.shortcut.map(|s| shortcut_label(ui.ctx(), s)).unwrap_or_default(); + let shortcut = spec.shortcut.or((spec.id == "edit.copy").then_some(COPY)).map(|s| shortcut_label(ui.ctx(), s)).unwrap_or_default(); let enabled = app.command_enabled(spec); let resp = ui.add_enabled(enabled, egui::Button::new(label).shortcut_text(shortcut)); if resp.clicked() { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 15d60ef4b2..e5e4729794 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2833,10 +2833,19 @@ impl PdfCraftApp { if !copy || ctx.egui_wants_keyboard_input() { return; } - let Some((id, text)) = self.views.get(i).and_then(|v| Some((v.id, v.selected_text()?))) else { return }; - if self.may_copy(id) { - ctx.copy_text(text); + self.copy_selected_text(i, ctx); + } + + /// Shared permission gate for copying selected page text from menus and keyboard events. + pub(crate) fn copy_selected_text(&mut self, i: usize, ctx: &egui::Context) -> bool { + let Some((id, text)) = self.views.get(i).and_then(|v| Some((v.id, v.selected_text()?))).filter(|(_, text)| !text.is_empty()) else { + return false; + }; + if !self.may_copy(id) { + return false; } + ctx.copy_text(text); + true } /// The quick tools' keys, as in Acrobat: V selects, H pans, and holding Space pans until it diff --git a/crates/ui-egui/tests/commands.rs b/crates/ui-egui/tests/commands.rs index 8a313f44ac..5afb4db558 100644 --- a/crates/ui-egui/tests/commands.rs +++ b/crates/ui-egui/tests/commands.rs @@ -71,7 +71,8 @@ const PICKERS: &[&str] = &[ #[test] fn every_registered_command_is_implemented() { for spec in COMMANDS { - if PICKERS.contains(&spec.id) { + // Page-text Copy needs a loaded view and egui context, covered by ui_group::text_select. + if PICKERS.contains(&spec.id) || spec.id == "edit.copy" { continue; } let mut app = PdfCraftApp::new(); diff --git a/crates/ui-egui/tests/ui_group/text_select.rs b/crates/ui-egui/tests/ui_group/text_select.rs index 4b9013a575..9ed4e9d5d4 100644 --- a/crates/ui-egui/tests/ui_group/text_select.rs +++ b/crates/ui-egui/tests/ui_group/text_select.rs @@ -7,7 +7,10 @@ use pdfcraft_ui_egui::PdfCraftApp; /// One 300×200 page with two lines of Helvetica text. fn two_lines() -> Vec { - let content = "BT /F1 14 Tf 20 150 Td (The quick brown fox) Tj 0 -24 Td (jumps over the dog) Tj ET"; + text_pdf("BT /F1 14 Tf 20 150 Td (The quick brown fox) Tj 0 -24 Td (jumps over the dog) Tj ET") +} + +fn text_pdf(content: &str) -> Vec { format!( "%PDF-1.7 1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj @@ -16,7 +19,7 @@ fn two_lines() -> Vec { 4 0 obj << /Length {} >> stream {content} endstream endobj -5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj +5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj trailer << /Root 1 0 R >> %%EOF", content.len() @@ -181,6 +184,161 @@ fn multiclick_selects_on_pointer_press_before_release() { assert_eq!(h.state().views[0].selected_text().as_deref(), Some("The quick brown fox"), "line stays selected on release"); } +// Issue #1225: page-text Copy belongs in both menus and observes PDF permissions. +fn copied(h: &Harness<'static, PdfCraftApp>, want: &str) -> bool { + h.output().platform_output.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(t) if t == want)) +} + +#[test] +fn selected_text_copy_edit_and_context_menus() { + use egui_kittest::kittest::Queryable; + let (mut h, at) = harness(|_| {}); + click(&mut h, at); + click(&mut h, at); + h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::Hand; + h.run_steps(2); // Hide floating Copy so this test can only reach menu entries. + h.get_by_label("Menu").click(); + h.run_steps(2); + h.get_by_label("Edit ⏵").hover(); + h.run_steps(3); + h.get_by_label_contains("Copy").click(); + let mut did_copy = false; + for _ in 0..4 { + h.step(); + did_copy |= copied(&h, "quick"); + } + assert!(did_copy, "Edit Copy emits the selected text"); + h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::Hand; + h.run_steps(2); // Hide the floating bar: the remaining Copy must be in the context menu. + h.event(Event::PointerMoved(at)); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(1); + } + h.run_steps(2); + h.get_by_label_contains("Copy").click(); + let mut did_copy = false; + for _ in 0..4 { + h.step(); + did_copy |= copied(&h, "quick"); + } + assert!(did_copy, "context Copy emits the selected text"); + assert_eq!(h.state().views[0].selected_text().as_deref(), Some("quick")); +} + +#[test] +fn selected_text_copy_command_requires_selection() { + let (mut h, at) = harness(|_| {}); + assert!(!h.state_mut().execute("edit.copy")); + click(&mut h, at); + click(&mut h, at); + assert!(h.state_mut().execute("edit.copy")); + h.step(); + assert!(copied(&h, "quick")); + let mut app = PdfCraftApp::new(); + assert!(!app.execute("edit.copy")); +} + +#[test] +fn selected_text_copy_floating_button_honours_permissions() { + use egui_kittest::kittest::Queryable; + for password in ["pw", "owner"] { + let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(two_lines())).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "pw", + owner_password: "owner", + permissions: -1 ^ 0b1_0000, + encrypt_metadata: true, + seed: [4; 32], + }) + .unwrap(); + let bytes = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("no-copy.pdf", None, bytes).unwrap(); + app.submit_password(Some(password.into())); + app.set_option("left", "closed").unwrap(); + app + }); + let mut loaded = false; + for _ in 0..200 { + h.run_steps(2); + if h.state_mut().views[0].select_all() { + loaded = true; + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + assert!(loaded); + h.run_steps(2); + h.get_by_label("Copy").click(); + let mut did_copy = false; + for _ in 0..4 { + h.step(); + did_copy |= copied(&h, "The quick brown fox\njumps over the dog"); + } + assert_eq!(did_copy, password == "owner", "floating Copy with {password} password"); + assert_eq!(h.state_mut().execute("edit.copy"), password == "owner"); + } +} + +#[test] +fn selected_text_copy_preserves_unicode_and_page_clipboard() { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.system_clipboard = false; + app.open_bytes("unicode.pdf", None, text_pdf(r"BT /F1 14 Tf 20 150 Td (caf\351) Tj ET")).unwrap(); + app + }); + let mut loaded = false; + for _ in 0..200 { + h.run_steps(2); + if h.state_mut().views[0].select_all() { + loaded = true; + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + assert!(loaded); + assert_eq!(h.state().views[0].selected_text().as_deref(), Some("café")); + assert!(h.state_mut().execute("edit.copy")); + h.step(); + assert!(copied(&h, "café")); + assert!(h.state().page_clipboard.is_none()); + assert!(h.state_mut().execute("page.organize")); + h.run_steps(2); + assert!(!h.state_mut().execute("edit.copy"), "Organize retains page clipboard ownership"); + assert!(h.state_mut().execute("page.copy")); + assert_eq!(h.state().page_clipboard.as_ref().map(|c| c.pages.clone()), Some(vec![0])); +} + +#[test] +fn selected_text_copy_keeps_focused_input_clipboard_ownership() { + let (mut h, at) = harness(|_| {}); + click(&mut h, at); + click(&mut h, at); + h.state_mut().views[0].open_find(); + h.state_mut().views[0].find.as_mut().unwrap().query = "widget café".into(); + h.run_steps(3); + h.ctx.memory_mut(|m| m.request_focus(egui::Id::new("find-input"))); + h.run_steps(2); + h.key_press_modifiers(Modifiers::COMMAND, egui::Key::A); + h.run_steps(2); + h.event(Event::Copy); + let mut widget_copy = false; + let mut page_copy = false; + for _ in 0..4 { + h.step(); + widget_copy |= copied(&h, "widget café"); + page_copy |= copied(&h, "quick"); + } + assert!(widget_copy, "the focused text input copies its selected text"); + assert!(!page_copy, "page text must not also consume the Copy event"); +} + /// `n` pages of 300×200, each ending with one line of Helvetica text near its bottom edge. fn bottom_lines(n: usize) -> Vec { let mut objects = String::new(); diff --git a/docs/roadmap.md b/docs/roadmap.md index 3acd0ca8b2..0ea0212daa 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -99,7 +99,7 @@ total matches the parity estimate. | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | -| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. | +| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Later native-open replies stay queued while a modal or picker owns the foreground; regressions cover recipient ownership, completion/cancellation and one-shot wakes. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; Ctrl+Q/Cmd+Q asks before discarding unsaved documents. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation.
Candidate: coordinate control clicks pair press/release across slow frames; coalesced primary presses retain text multi-click selection. Regression cases cover slow-frame button activation, disabled controls and text multi-click selection; estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 957ac5c3e1..7edc529a00 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1536,10 +1536,10 @@ area = "B" tier = "P0" milestone = "M3" status = "shipped" -commands = [] -tools = [] -evidence = ["crates/ui-egui/tests/ui.rs::drag_selects_text_and_copy_returns_it"] -notes = "" +commands = ["edit.copy"] +tools = ["text_extract"] +evidence = ["crates/ui-egui/tests/ui.rs::drag_selects_text_and_copy_returns_it", "crates/ui-egui/tests/ui_group/text_select.rs::selected_text_copy_edit_and_context_menus", "crates/ui-egui/tests/ui_group/text_select.rs::selected_text_copy_floating_button_honours_permissions", "crates/ui-egui/tests/ui_group/text_select.rs::selected_text_copy_keeps_focused_input_clipboard_ownership"] +notes = "Keyboard, Edit-menu, page-context-menu and floating-selection Copy honour document copy permissions. Menus require selected page text; focused text inputs and Organize page copying retain their own clipboard handling. Headless text extraction uses text_extract." [[feature]] id = "view.select-all"