Keep page labels and focused navigation consistent across reloads (#1667)

* M3: await remote-link refusal in the UI regression

Wait for the asynchronous notification before checking the existing no-tab and refusal-message assertions.

Co-authored-by: Codex <codex@openai.com>

* M3: keep page label input consistent across reloads

Co-authored-by: Codex <codex@openai.com>

---------

Co-authored-by: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com>
Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
sanyamsingh001
2026-10-15 02:19:13 -04:00
committed by GitHub
co-authored by Codex sanyamsingh001 Brandon Thomas
parent 2fee7af241
commit 9bbd765ea4
8 changed files with 497 additions and 20 deletions
+1
View File
@@ -154,6 +154,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-13 (M2, page-unit transform precision):** Preserve small transform coefficients in page marks, added content and print output. Regression cases cover accepted page units of 4096 and 75000; existing page-unit interpretation and estimates are unchanged.
- **2026-10-14 (M5, PR #1457 branch refresh):** Merged upstream main and reconciled roadmap entries while preserving comment submission shortcuts. Estimates unchanged.
- **2026-10-12 (M3, page-label navigation):** The page box displays logical labels and keeps the current page when its unchanged value is submitted, including duplicate labels. Editable-control regressions also cover numeric fallback, invalid input, active typing and focused same-document replacement with changed labels or fewer pages. Clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Estimates unchanged.
- **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged.
- **2026-10-12 (M10, Windows printing without PowerShell, #1107):** craft-print now prints on Windows in-process: printers from EnumPrinters/GetDefaultPrinter, jobs drawn as vectors by Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling), so printing works where App Control for Business or AppLocker run PowerShell in Constrained Language Mode. Portrait and landscape sheets still print as one job (turned with /Rotate); a sheet size the driver lacks is refused rather than shrunk. Pins craft-libs f03ec27 (craft-libs#28); deny.toml allows the storytold/winprint.rs fork and adds a webpki-root-certs licence exception and a RUSTSEC-2024-0370 ignore for winprint's build-dependencies. An earlier craft-print revision was checked in Windows Sandbox under enforced WDAC: printers listed, CLI and desktop jobs spooled, and no powershell.exe in the 4688 audit. The final pinned revision passed craft-libs Windows CI, including a real paused-queue job; that Sandbox run has not been repeated. Estimates unchanged.
- **2026-10-12 (M4, #1310, #1311, #1312):** Undo back to the opened file left it marked unsaved, so recovery and close treated unchanged work as dirty. After a save, Revert followed the restored snapshot's older base and the next save dropped the latest saved revision. The last saved file is remembered on its own: undo and redo are clean only when they land on it, Revert opens it, and a later save appends to it. Estimates unchanged.
+24 -8
View File
@@ -299,6 +299,8 @@ pub struct DocView {
pub organize: bool,
pub highlight_fields: bool,
pub page_input: String,
/// Select the replacement page label once if its editor remains focused.
pub(crate) page_input_reselect: bool,
pub notice_dismissed: bool,
/// The user has been told that the form's scripts were turned off (it is said once).
pub scripts_off_noted: bool,
@@ -593,6 +595,7 @@ impl DocView {
organize: false,
highlight_fields: defaults.highlight_fields,
page_input: "1".into(),
page_input_reselect: false,
notice_dismissed: false,
scripts_off_noted: false,
disk_changed: false,
@@ -695,7 +698,6 @@ impl DocView {
self.page_heights = info.pages.iter().map(|p| p.height).collect();
let last = self.page_count.saturating_sub(1);
self.current = self.current.min(last);
self.page_input = (self.current + 1).to_string();
self.selected.retain(|p| *p <= last);
// Text being typed on a page that no longer exists goes with the page.
if self.content.draft.as_ref().is_some_and(|d| d.page > last || self.page_count == 0) {
@@ -722,6 +724,7 @@ impl DocView {
fresh.zoom = self.zoom;
fresh.fit = self.fit;
fresh.layout = self.layout;
fresh.continuous = self.continuous;
fresh.rotation = self.rotation;
fresh.current = self.current;
fresh.organize = self.organize;
@@ -742,6 +745,8 @@ impl DocView {
*self = fresh;
// Marks the kept images out of date and clamps the page to the new page count.
self.document_changed(info);
self.page_input = self.current_page_label(info.pages.get(self.current).map(|page| page.label.as_str()));
self.page_input_reselect = true;
}
/// Pages an organize command acts on: the selection, or the current page.
@@ -1027,7 +1032,6 @@ impl DocView {
self.wheel.clear_motion();
self.goto = Some((page, 0.0));
self.current = page;
self.page_input = (page + 1).to_string();
}
/// Go to a destination, a bookmark's or a link's (ISO 32000-2 §12.3.2.2): its page, scrolled
@@ -1112,9 +1116,25 @@ impl DocView {
self.go_to_page(target);
}
/// Go to the page typed in the page box: a page label (logical page numbers, as Acrobat
/// does), else a page number. `false` when it names no page.
/// Page-editor focus and drafts belong to a document, even while another tab is active.
pub(crate) fn page_input_id(&self) -> egui::Id {
egui::Id::new(("page-input", self.id.0))
}
/// The idle page box and unchanged-input lookup share the same label fallback.
pub(crate) fn current_page_label(&self, label: Option<&str>) -> String {
label.filter(|l| !l.is_empty()).map(str::to_owned).unwrap_or_else(|| (self.current + 1).to_string())
}
/// Retain the current page when `typed` exactly matches its displayed label, even if
/// another page has that label. Otherwise resolve a trimmed label (case-insensitive),
/// then a physical page number. `false` when it names no page.
pub fn go_to_typed(&mut self, typed: &str, labels: &[String]) -> bool {
// Compare the displayed value before trimming: a label may itself contain spaces.
// Repeated labels occur in restarted sections and prefix-only ranges.
if self.current < self.page_count && self.current_page_label(labels.get(self.current).map(String::as_str)) == typed {
return true;
}
let t = typed.trim();
let page = labels
.iter()
@@ -1137,7 +1157,6 @@ impl DocView {
let page = page.min(self.page_count.saturating_sub(1));
self.goto = Some((page, 0.0));
self.current = page;
self.page_input = (page + 1).to_string();
true
}
@@ -3046,9 +3065,6 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
}
if view.continuous {
view.current = current;
if !ui.memory(|m| m.has_focus(egui::Id::new("page-input"))) {
view.page_input = (current + 1).to_string();
}
}
resp.context_menu(|ui| {
// Preparing a form: the selected field's menu.
+20 -6
View File
@@ -696,8 +696,21 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
view.step_page(false);
}
ui.label(egui::RichText::new(page_count.to_string()).font(theme::regular(11.0)).color(t.text_muted));
let page_input_id = view.page_input_id();
// Consume replacement selection once, including when the editor has lost focus.
let reselect = std::mem::take(&mut view.page_input_reselect);
// Navigation updates the idle display, never the text being edited.
if !ui.memory(|m| m.has_focus(page_input_id)) {
view.page_input = view.current_page_label(labels.get(view.current).map(String::as_str));
} else if reselect {
// The replacement changed the label while egui retained this editor's cursor.
let mut state = egui::TextEdit::load_state(ui.ctx(), page_input_id).unwrap_or_default();
let end = egui::text::CCursor::new(view.page_input.chars().count());
state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), end)));
state.store(ui.ctx(), page_input_id);
}
let edit = egui::TextEdit::singleline(&mut view.page_input)
.id(egui::Id::new("page-input"))
.id(page_input_id)
.desired_width(34.0)
.horizontal_align(Align::Center)
.font(theme::medium(12.0))
@@ -709,12 +722,13 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
.show(ui, |ui| ui.add(edit))
.inner
.on_hover_text(tl!("Current page — type a page number or label (such as iv) and press Enter"));
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
// A page label first (logical page numbers, as Acrobat), then a number.
let typed = view.page_input.clone();
if !view.go_to_typed(&typed, &labels) {
view.page_input = (view.current + 1).to_string();
if r.lost_focus() {
if ui.input(|i| i.key_pressed(egui::Key::Enter)) {
// A page label first (logical page numbers, as Acrobat), then a number.
let typed = view.page_input.clone();
view.go_to_typed(&typed, &labels);
}
view.page_input = view.current_page_label(labels.get(view.current).map(String::as_str));
}
});
if let Some(id) = page_display {
+9 -3
View File
@@ -214,9 +214,15 @@ impl PdfCraftApp {
}
}
"view.focus_page_input" => {
if let (Some(ctx), Some(view)) = (self.ctx.clone(), active.and_then(|i| self.views.get(i))) {
// The page box in the toolbar (chrome.rs); select its number so typing replaces it.
let id = egui::Id::new("page-input");
if let (Some(ctx), Some(view)) = (self.ctx.clone(), active.and_then(|i| self.views.get_mut(i))) {
// The page box in the toolbar (chrome.rs); select its label so typing replaces it.
let id = view.page_input_id();
if !ctx.memory(|m| m.has_focus(id))
&& let Some(doc) = self.session.get(view.id)
{
// Navigation and this command can arrive before the next rail frame.
view.page_input = view.current_page_label(doc.info.pages.get(view.current).map(|p| p.label.as_str()));
}
ctx.memory_mut(|m| m.request_focus(id));
let mut state = egui::TextEdit::load_state(&ctx, id).unwrap_or_default();
let len = view.page_input.chars().count();
+1
View File
@@ -235,6 +235,7 @@ fn a_gotor_link_to_a_directory_or_device_is_refused_without_reading_it() {
for target in targets {
let mut h = remote_harness(Some(dir.join("source.pdf").to_string_lossy().into_owned()), target, "[0 /Fit]");
click_remote_link(&mut h);
support::wait_until(&mut h, "relative invalid target refusal", |app| app.toast.is_some());
assert_eq!(h.state().views.len(), 1, "{target} opens no tab");
let toast = &h.state().toast.as_ref().unwrap().0;
assert!(toast.contains("not a regular file"), "{target}: {toast}");
+87 -1
View File
@@ -6,7 +6,7 @@ use std::path::PathBuf;
use std::time::{Duration, SystemTime};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use egui_kittest::kittest::{By, Queryable};
use pdfcraft_ui_egui::{PdfCraftApp, SaveTarget};
/// An `n`-page document with a proper xref table; page `i` shows "Page i+1", and with `notes`
@@ -119,6 +119,92 @@ fn a_rebuilt_file_reloads_in_place_keeping_the_page_and_zoom() {
assert_eq!((app.views[0].current, app.views[0].zoom), (2, 1.5), "at the same page and zoom");
}
#[test]
fn a_watched_label_replacement_keeps_the_display_mode_and_focused_page() {
use pdfcraft_engine::{Edit, LabelStyle, Session};
use pdfcraft_ui_egui::canvas::display_token;
use std::sync::Arc;
let labelled = |ranges: &[(usize, usize, LabelStyle)]| {
let mut source = Session::new().with_clock(|| 1_700_000_000);
let id = source.open("labels.pdf", None, Arc::new(fixture(5)), None).unwrap();
for &(from, to, style) in ranges {
source.apply(id, Edit::NumberPages { from, to, style, prefix: String::new(), first: 1 }).unwrap();
}
source.save_bytes(id).unwrap()
};
let original = labelled(&[(0, 1, LabelStyle::LowerRoman), (2, 4, LabelStyle::Decimal)]);
let replacement = labelled(&[(0, 4, LabelStyle::LowerRoman)]);
for display in ["single", "continuous"] {
let file = TempPdf::new(&format!("focused-label-{display}"), &original);
let mut h = harness(&file.path);
let id = h.state().views[0].id;
h.state_mut().set_option("layout", display).unwrap();
h.state_mut().set_option("zoom", "100").unwrap();
h.state_mut().set_option("page", "4").unwrap();
h.run_steps(3);
let view = &h.state().views[0];
assert_eq!((view.current, display_token(view.layout, view.continuous())), (3, display));
let doc = h.state().session.get(id).unwrap();
assert_eq!(doc.bytes, original);
assert!(!doc.dirty);
assert_eq!((doc.can_undo(), doc.can_redo()), (None, None));
assert!(h.state_mut().execute("view.focus_page_input"));
h.run_steps(3);
h.get(By::new().role(egui::accesskit::Role::TextInput).value("2"));
let editor = h.ctx.memory(|m| m.focused()).expect("the page editor is focused");
// A build atomically publishes a whole, clean PDF. The real watcher detects it.
let next = file.dir.join("next.pdf");
std::fs::write(&next, replacement.as_slice()).unwrap();
let published = std::fs::File::options().write(true).open(&next).unwrap();
published.set_modified(SystemTime::now() + Duration::from_secs(60)).unwrap();
drop(published);
std::fs::rename(next, &file.path).unwrap();
for _ in 0..8 {
h.run_steps(1);
if h.state().session.get(id).unwrap().bytes == replacement {
break;
}
}
h.run_steps(2);
let app = h.state();
let view = &app.views[0];
let doc = app.session.get(id).unwrap();
assert_eq!((app.views.len(), app.active, view.id), (1, Some(0), id));
assert_eq!(doc.bytes, replacement, "the watcher loaded the replacement before checking navigation");
assert_eq!((doc.info.pages.len(), doc.info.pages[3].label.as_str()), (5, "iv"));
// First causal negative: a single-page reader must not become a continuous reader.
assert_eq!((view.current, display_token(view.layout, view.continuous())), (3, display), "reload retains the physical page and display mode");
assert_eq!(view.zoom, 1.0);
assert_eq!(view.page_input, "iv");
assert_eq!(h.ctx.memory(|m| m.focused()), Some(editor));
h.get(By::new().role(egui::accesskit::Role::TextInput).value("iv"));
// Keep the replacement editor's selection and then its caret, without re-focusing it.
h.event(egui::Event::Text("2".into()));
h.run_steps(2);
assert_eq!((h.state().views[0].current, h.state().views[0].page_input.as_str()), (3, "2"));
h.event(egui::Event::Text("x".into()));
h.run_steps(1);
assert_eq!(h.state().views[0].page_input, "2x");
h.key_press(egui::Key::Backspace);
h.run_steps(1);
assert_eq!(h.state().views[0].page_input, "2");
h.key_press(egui::Key::Enter);
h.run_steps(3);
let app = h.state();
assert_eq!((app.views[0].id, app.views[0].current, app.views[0].page_input.as_str()), (id, 1, "ii"));
let doc = app.session.get(id).unwrap();
assert_eq!(doc.path.as_deref(), Some(file.path.as_str()));
assert!(!doc.dirty);
assert_eq!((doc.can_undo(), doc.can_redo()), (None, None));
assert_eq!(doc.bytes, replacement);
assert_eq!(app.session.save_bytes(id).unwrap(), replacement);
assert_eq!(std::fs::read(&file.path).unwrap(), replacement.as_ref().clone());
}
}
#[test]
fn a_half_written_file_waits_until_the_pdf_is_whole() {
let file = TempPdf::new("partial", &fixture(2));
+354 -1
View File
@@ -2,7 +2,7 @@
//! cover page, a scroll position per tab.
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use egui_kittest::kittest::{By, Queryable};
use pdfcraft_ui_egui::{Dialog, PdfCraftApp};
/// `n` pages of 200×300; page i says "Page i+1" plus "page" and "Pages" for find tests.
@@ -302,6 +302,359 @@ fn layouts_fit_height_labels_and_system_theme() {
assert_eq!(h.state().theme_preference, pdfcraft_ui_egui::theme::ThemePreference::System);
}
fn page_input_focused(h: &Harness<'static, PdfCraftApp>) -> bool {
let focused = h.ctx.memory(|m| m.focused());
let view = &h.state().views[h.state().active.unwrap()];
// Also identifies the original editor when running this test-only patch on the baseline.
focused == Some(egui::Id::new(("page-input", view.id.0))) || focused == Some(egui::Id::new("page-input"))
}
/// Exercise the real navigation editor, including its focus and Enter handling.
fn focus_page_input(h: &mut Harness<'static, PdfCraftApp>) {
let text = h.state().views[h.state().active.unwrap()].page_input.clone();
h.get(By::new().role(egui::accesskit::Role::TextInput).value(&text)).focus();
h.run_steps(1);
assert!(page_input_focused(h), "the actual page editor has focus");
}
fn type_page_input(h: &mut Harness<'static, PdfCraftApp>, text: &str) {
focus_page_input(h);
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
h.run_steps(1);
let old = h.state().views[h.state().active.unwrap()].page_input.clone();
h.get(By::new().role(egui::accesskit::Role::TextInput).value(&old)).type_text(text);
h.run_steps(2);
}
fn restart_page_labels(h: &mut Harness<'static, PdfCraftApp>) {
use pdfcraft_engine::{Edit, LabelStyle};
let session = std::mem::take(&mut h.state_mut().session);
h.state_mut().session = session.with_clock(|| 1_700_000_000);
h.state_mut().active = Some(0);
for (from, to, style, first) in [(0, 1, LabelStyle::LowerRoman, 1), (2, 4, LabelStyle::Decimal, 1)] {
h.state_mut().apply_edit(Edit::NumberPages { from, to, style, prefix: String::new(), first });
}
h.run_steps(3);
}
#[test]
fn page_label_input_keeps_current_page_when_submitted_unchanged() {
let mut h = harness();
restart_page_labels(&mut h);
// Physical page 3 has logical label 1. The old editor showed 3, then interpreted it as
// logical label 3 (physical page 5) when Enter was pressed without changing anything.
h.state_mut().views[0].go_to_page(2);
h.run_steps(3);
focus_page_input(&mut h);
h.key_press(egui::Key::Enter);
h.run_steps(3);
assert_eq!(h.state().views[0].current, 2, "unchanged Enter must stay on the current physical page");
let id = h.state().views[0].id;
let before = h.state().session.get(id).unwrap();
let undo = before.can_undo().map(str::to_owned);
let dirty = before.dirty;
let before_bytes = h.state().session.save_bytes(id).unwrap();
for (page, label) in ["i", "ii", "1", "2", "3"].into_iter().enumerate() {
h.state_mut().views[0].go_to_page(page);
h.run_steps(3);
assert_eq!(h.state().views[0].page_input, label);
focus_page_input(&mut h);
h.key_press(egui::Key::Enter);
h.run_steps(3);
assert_eq!(h.state().views[0].current, page, "{label}");
assert_eq!(h.state().views[0].page_input, label);
}
let after = h.state().session.get(id).unwrap();
assert_eq!(after.can_undo().map(str::to_owned), undo);
assert_eq!(after.dirty, dirty, "navigation does not edit the PDF");
assert_eq!(h.state().session.save_bytes(id).unwrap(), before_bytes, "navigation preserves saved PDF bytes");
let mut empty = pdfcraft_ui_egui::canvas::DocView::new(pdfcraft_engine::DocId(0), &Default::default(), Default::default());
assert!(!empty.go_to_typed("1", &[]), "an empty view has no current page to retain");
}
#[test]
fn page_label_input_resolves_labels_numbers_and_restores_invalid_input() {
use pdfcraft_engine::{Edit, LabelStyle};
let mut h = harness();
restart_page_labels(&mut h);
h.state_mut().apply_edit(Edit::NumberPages { from: 4, to: 4, style: LabelStyle::Decimal, prefix: "A-".into(), first: 8 });
h.run_steps(3);
for (typed, page, displayed) in [("ii", 1, "ii"), ("2", 3, "2"), ("A-8", 4, "A-8"), ("3", 2, "1"), ("missing", 2, "1")] {
type_page_input(&mut h, typed);
h.key_press(egui::Key::Enter);
h.run_steps(3);
assert_eq!(h.state().views[0].current, page, "{typed}");
assert_eq!(h.state().views[0].page_input, displayed, "{typed}");
}
// Scripted page selection remains physical and one-based even when a numeric label differs.
h.state_mut().set_option("page", "2").unwrap();
h.run_steps(3);
assert_eq!((h.state().views[0].current, h.state().views[0].page_input.as_str()), (1, "ii"));
h.state_mut().apply_edit(Edit::NumberPages { from: 0, to: 1, style: LabelStyle::None, prefix: "Appendix".into(), first: 1 });
h.state_mut().views[0].go_to_page(1);
h.run_steps(3);
assert_eq!(h.state().views[0].page_input, "Appendix");
focus_page_input(&mut h);
h.key_press(egui::Key::Enter);
h.run_steps(3);
assert_eq!(h.state().views[0].current, 1, "a duplicate current label must not resolve to its first occurrence");
h.state_mut().views[0].go_to_page(2);
h.run_steps(3);
type_page_input(&mut h, "Appendix");
h.key_press(egui::Key::Enter);
h.run_steps(3);
assert_eq!(h.state().views[0].current, 0, "a changed label keeps the existing first-match lookup");
h.state_mut().views[0].go_to_page(0);
h.state_mut().apply_edit(Edit::NumberPages { from: 0, to: 0, style: LabelStyle::None, prefix: String::new(), first: 1 });
h.run_steps(3);
assert_eq!(h.state().views[0].page_input, "1", "an empty label falls back to the physical number");
focus_page_input(&mut h);
h.key_press(egui::Key::Enter);
h.run_steps(3);
assert_eq!(h.state().views[0].current, 0, "the current fallback number must not jump to another page labelled 1");
}
#[test]
fn page_label_input_preserves_literal_whitespace_before_trimmed_lookup() {
use pdfcraft_render::{DocInfo, PageInfo};
use pdfcraft_ui_egui::canvas::DocView;
// The importer trims label prefixes, so supply literal labels directly to the view.
for label in [" 1", "1 "] {
let info = DocInfo {
pages: [label, "ii", "1"]
.into_iter()
.map(|label| PageInfo { width: 200.0, height: 300.0, label: label.into(), crop: [0.0, 0.0, 200.0, 300.0], rotation: 0 })
.collect(),
..Default::default()
};
let labels: Vec<String> = info.pages.iter().map(|page| page.label.clone()).collect();
let mut view = DocView::new(pdfcraft_engine::DocId(0), &info, Default::default());
view.document_replaced(&info);
assert_eq!(view.page_input, label, "the displayed label retains its literal whitespace");
let unchanged = view.page_input.clone();
assert!(view.go_to_typed(&unchanged, &labels));
assert_eq!(view.current, 0, "unchanged spaces in {label:?} belong to the label");
assert!(view.go_to_typed("1", &labels));
assert_eq!(view.current, 2, "changed input keeps the existing trimmed label lookup");
}
}
#[test]
fn page_label_input_focus_command_refreshes_same_frame_navigation_and_preserves_a_draft() {
let mut h = harness();
restart_page_labels(&mut h);
assert_eq!(h.state().views[0].page_input, "i");
// No frame between navigation and the real command: it must not focus the old label.
h.state_mut().set_option("page", "3").unwrap();
assert!(h.state_mut().execute("view.focus_page_input"));
h.run_steps(3);
assert!(page_input_focused(&h));
assert_eq!(h.state().views[0].page_input, "1");
h.key_press(egui::Key::Enter);
h.run_steps(3);
assert_eq!(h.state().views[0].current, 2, "the focus command selects the new current label");
type_page_input(&mut h, "draft");
h.state_mut().set_option("page", "4").unwrap();
assert!(h.state_mut().execute("view.focus_page_input"));
h.run_steps(3);
assert_eq!(h.state().views[0].current, 3);
assert_eq!(h.state().views[0].page_input, "draft", "re-focusing keeps text already being edited");
}
#[test]
fn page_label_input_new_document_does_not_inherit_or_commit_the_previous_draft() {
use pdfcraft_engine::{Edit, LabelStyle, Session};
let mut h = harness();
restart_page_labels(&mut h);
h.state_mut().views[0].go_to_page(3);
h.run_steps(3);
type_page_input(&mut h, "ii");
let original_id = h.state().views[0].id;
let original_bytes = h.state().session.save_bytes(original_id).unwrap();
let mut source = Session::new().with_clock(|| 1_700_000_000);
let source_id = source.open("new-labels.pdf", None, std::sync::Arc::new(fixture(5)), None).unwrap();
for (from, to, style) in [(0, 1, LabelStyle::LowerRoman), (2, 4, LabelStyle::Decimal)] {
source.apply(source_id, Edit::NumberPages { from, to, style, prefix: String::new(), first: 1 }).unwrap();
}
h.state_mut().open_bytes("new-labels.pdf", None, source.save_bytes(source_id).unwrap().as_ref().clone()).unwrap();
// Enter arriving with the switch must not submit the previous editor into this document.
h.key_press(egui::Key::Enter);
h.run_steps(3);
let next = h.state().active.unwrap();
assert_ne!(h.state().views[next].id, original_id);
assert_eq!((h.state().views[next].current, h.state().views[next].page_input.as_str()), (0, "i"));
assert!(!page_input_focused(&h), "the new document's editor starts idle");
assert_eq!((h.state().views[0].current, h.state().views[0].page_input.as_str()), (3, "ii"), "the hidden valid draft is not committed");
focus_page_input(&mut h);
h.key_press(egui::Key::Enter);
h.run_steps(3);
assert_eq!(h.state().views[next].current, 0, "unchanged Enter stays on the new document's first page");
h.get_by_label("a.pdf (edited)").click();
h.run_steps(3);
assert_eq!(h.state().active, Some(0));
assert_eq!((h.state().views[0].current, h.state().views[0].page_input.as_str()), (3, "2"));
assert_eq!(h.state().session.save_bytes(original_id).unwrap(), original_bytes, "tab switching never edits the original PDF");
}
#[test]
fn page_label_input_preserves_typing_and_refreshes_after_navigation_and_tab_changes() {
let mut h = harness();
restart_page_labels(&mut h);
h.state_mut().views[0].go_to_page(0);
h.run_steps(3);
type_page_input(&mut h, "draft");
h.state_mut().views[0].go_to_page(3);
h.run_steps(3);
assert_eq!(h.state().views[0].page_input, "draft", "paging must not overwrite the focused editor");
let editor_id = h.ctx.memory(|m| m.focused()).expect("focused page editor");
h.ctx.memory_mut(|m| m.surrender_focus(editor_id));
h.run_steps(3);
assert_eq!(h.state().views[0].page_input, "2", "an idle editor follows the current page");
type_page_input(&mut h, "unsent");
h.get_by_label("b.pdf").click();
h.run_steps(3);
assert_eq!(h.state().active, Some(1));
assert_eq!(h.state().views[0].page_input, "unsent", "switching tabs keeps the hidden document's draft");
assert_eq!(h.state().views[1].page_input, "1", "the new document displays its own label");
h.state_mut().set_option("page", "2").unwrap();
h.run_steps(3);
focus_page_input(&mut h);
h.key_press(egui::Key::Enter);
h.run_steps(3);
assert_eq!((h.state().views[1].current, h.state().views[1].page_input.as_str()), (1, "2"), "default labels still work");
h.get_by_label("a.pdf (edited)").click();
h.run_steps(3);
assert_eq!(h.state().views[0].page_input, "2", "returning to a document refreshes its idle label");
assert!(h.state_mut().views[0].view_history(false));
h.run_steps(3);
assert_eq!(h.state().views[0].page_input, "i", "history navigation refreshes the label");
}
#[test]
fn page_label_input_same_document_replacement_refreshes_the_focused_label() {
use pdfcraft_engine::{Edit, LabelStyle, Session};
use std::sync::Arc;
for (pages, roman, page, label) in [(5, false, 3, "4"), (5, true, 3, "iv"), (3, false, 2, "3")] {
let mut h = harness();
h.state_mut().active = Some(0);
h.state_mut().views[0].go_to_page(3);
h.run_steps(3);
assert_eq!((h.state().views[0].current, h.state().views[0].page_input.as_str()), (3, "4"));
focus_page_input(&mut h);
let editor_id = h.ctx.memory(|m| m.focused()).expect("focused page editor");
let id = h.state().views[0].id;
let before = h.state().session.get(id).unwrap();
assert!(!before.dirty);
assert_eq!((before.can_undo(), before.can_redo()), (None, None));
let replacement = if roman {
let mut source = Session::new().with_clock(|| 1_700_000_000);
let source_id = source.open("replacement.pdf", None, Arc::new(fixture(pages)), None).unwrap();
source
.apply(source_id, Edit::NumberPages { from: 0, to: pages - 1, style: LabelStyle::LowerRoman, prefix: String::new(), first: 1 })
.unwrap();
source.save_bytes(source_id).unwrap()
} else {
Arc::new(fixture(pages))
};
// The file watcher uses this same reload/replacement path without changing DocId.
let app = h.state_mut();
app.session.reload(id, replacement.clone()).unwrap();
let doc = app.session.get(id).unwrap();
app.views[0].document_replaced(&doc.info);
h.run_steps(3);
assert_eq!(h.state().views[0].id, id);
assert_eq!(h.ctx.memory(|m| m.focused()), Some(editor_id), "reload retains the actual editor's focus");
assert!(page_input_focused(&h));
assert_eq!((h.state().views[0].current, h.state().views[0].page_input.as_str()), (page, label), "replacement labels and page-count clamping");
h.key_press(egui::Key::Enter);
h.run_steps(3);
assert_eq!(
(h.state().views[0].current, h.state().views[0].page_input.as_str()),
(page, label),
"unchanged Enter retains the replacement's physical page"
);
assert_eq!(h.state().views[0].id, id);
let after = h.state().session.get(id).unwrap();
assert!(!after.dirty, "navigation does not edit the replacement");
assert_eq!((after.can_undo(), after.can_redo()), (None, None));
assert_eq!(h.state().session.save_bytes(id).unwrap(), replacement, "navigation preserves replacement PDF bytes");
}
}
#[test]
fn page_label_input_same_document_replacement_keeps_full_label_selected_for_typing() {
use pdfcraft_engine::{Edit, LabelStyle, Session};
use std::sync::Arc;
for settling_frames in [3, 0] {
let mut h = harness();
h.state_mut().active = Some(0);
h.state_mut().views[0].go_to_page(3);
h.run_steps(3);
assert_eq!((h.state().views[0].current, h.state().views[0].page_input.as_str()), (3, "4"));
assert!(h.state_mut().execute("view.focus_page_input"));
h.run_steps(3);
assert!(page_input_focused(&h), "the real focus command focuses the page editor");
let editor_id = h.ctx.memory(|m| m.focused()).expect("focused page editor");
let range = egui::TextEdit::load_state(&h.ctx, editor_id).unwrap().cursor.char_range().unwrap();
let ends = [range.primary.index, range.secondary.index];
assert!(ends.contains(&egui::text::CCursor::new(0).index), "the old label selection starts at zero");
assert!(ends.contains(&egui::text::CCursor::new(1).index), "the entire old one-character label is selected");
let id = h.state().views[0].id;
let original = h.state().session.get(id).unwrap();
assert!(!original.dirty);
assert_eq!((original.can_undo(), original.can_redo()), (None, None));
let mut source = Session::new().with_clock(|| 1_700_000_000);
let source_id = source.open("replacement.pdf", None, Arc::new(fixture(5)), None).unwrap();
source.apply(source_id, Edit::NumberPages { from: 0, to: 4, style: LabelStyle::LowerRoman, prefix: String::new(), first: 1 }).unwrap();
let replacement = source.save_bytes(source_id).unwrap();
let app = h.state_mut();
app.session.reload(id, replacement.clone()).unwrap();
let doc = app.session.get(id).unwrap();
app.views[0].document_replaced(&doc.info);
// Cover the reproduced settled case and typing before the replacement's first frame.
if settling_frames > 0 {
h.run_steps(settling_frames);
}
assert_eq!(h.state().views[0].id, id);
assert_eq!(h.ctx.memory(|m| m.focused()), Some(editor_id), "same-DocId reload retains the actual focused editor");
assert!(page_input_focused(&h));
assert_eq!((h.state().views[0].current, h.state().views[0].page_input.as_str()), (3, "iv"));
// Ordinary typing must replace the whole refreshed label without another focus or select-all.
h.event(egui::Event::Text("2".into()));
h.run_steps(if settling_frames == 0 { 1 } else { 2 });
assert!(page_input_focused(&h));
assert_eq!(h.state().views[0].current, 3, "typing has not submitted navigation yet");
assert_eq!(h.state().views[0].page_input, "2", "typing replaces the entire replacement label");
// Selection synchronization is consumed once: later input appends at the caret.
h.event(egui::Event::Text("x".into()));
h.run_steps(1);
assert!(page_input_focused(&h));
assert_eq!(h.state().views[0].page_input, "2x", "a second character appends without selecting the label again");
h.key_press(egui::Key::Backspace);
h.run_steps(1);
assert_eq!(h.state().views[0].page_input, "2", "Backspace removes only the second character");
h.key_press(egui::Key::Enter);
h.run_steps(3);
assert_eq!((h.state().views[0].current, h.state().views[0].page_input.as_str()), (1, "ii"), "Enter goes to physical page two");
assert_eq!(h.state().views[0].id, id);
let after = h.state().session.get(id).unwrap();
assert!(!after.dirty, "navigation does not edit the replacement PDF");
assert_eq!((after.can_undo(), after.can_redo()), (None, None));
assert_eq!(h.state().session.save_bytes(id).unwrap(), replacement, "typing and navigation preserve replacement PDF bytes");
}
}
#[test]
fn two_page_view_steps_a_spread_at_a_time() {
// #70: ⌘→ went to the right-hand page of the same spread, so the view never moved.
+1 -1
View File
@@ -93,7 +93,7 @@ total matches the parity estimate.
| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 80% | 20–40 | Done:<br>- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;<br>- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.<br>Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.<br>Full saves now pack objects into compressed object streams. An unchanged incremental save of a classic table writes a zero-length cross-reference subsection instead of a section with no subsections (#1339). Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs |
| 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 |
| 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<br>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. |
| 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<br>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. |
| 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. A custom text match that ends on a reconstructed separator stops before the next glyph (#1315). Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings |
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); Lab Bradford adaptation uses LMS white-point ratios, covered by independent neutral/chromatic and direct/Indexed render oracles; 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<br>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). |