Phase 4 of REVIEWS/DART_PDF_VS_MAKEPAD_PDF_GAP_ANALYSIS.md. The previous commit added the interaction logic but left it an orphan: nothing routed Makepad events into it, which is the exact defect the review names when it says PdfPageView declares is_interactive() -> false and an empty handle_event. New pdf-makepad/src/page_view.rs, PdfPageWidget: - is_interactive() returns true and handle_event routes real events. - Step 4.1 ordering: form field events, then annotation hit testing, then text selection. The order is enforced inside InteractionState::click so it cannot drift between the widget and the tests. - Step 4.2: link underlines and form widget borders are drawn, and the cursor changes over links and text fields. - Step 4.3: field values draw inside their widget rects, a focused field is highlighted and shows its uncommitted buffer. - Step 4.4: navigation is emitted as PdfPageAction to the host. The widget never opens a URL (rule 5). - Key focus loss commits the pending edit, so an edit is never silently lost, while Escape still abandons it. - Only keys the editor handles are consumed; everything else falls through to the host instead of being swallowed. The widget keeps no decision logic of its own: it syncs the viewport from its on-screen rect and delegates. That keeps the part that needs a live Cx as small as possible, because it is the part that cannot be unit tested. Also adds Selection to interaction.rs so drag-to-select and copy read through the Phase 5 PageText API rather than re-deriving positions. Tests: new tests/phase4_exit_criterion.rs drives the exit criterion against the real two-page AcroForm fixture, not synthetic dictionaries. Click a link gives OpenUri; click a field, type, commit, and the value changes, the appearance regenerates with the new text and the draw list shows it. It also asserts an abandoned edit changes nothing, the checkbox uses the /On state declared in the file, and form fields route before annotations. Validation: TEST_TARGET=pdf-ui ./tools/test-rust-clean.sh 202 tests pass; rustfmt and clippy -D warnings clean.
285 lines
9.7 KiB
Rust
285 lines
9.7 KiB
Rust
//! Phase 4 exit criterion, end to end against the real AcroForm fixture.
|
|
//!
|
|
//! > Click a link in a real PDF → host receives `OpenUri`. Click a text field
|
|
//! > → type text → field value updates → appearance regenerates → visual
|
|
//! > change visible.
|
|
//!
|
|
//! These tests drive the same `InteractionState` the widget's `handle_event`
|
|
//! delegates to, using a document parsed from bytes on disk rather than
|
|
//! synthetic dictionaries. What the widget adds on top is Makepad event
|
|
//! delivery, which needs a live `Cx`; the routing *order* it depends on is
|
|
//! asserted here so the untested surface stays as small as possible.
|
|
|
|
use nigig_pdf_document::appearance::regenerate_dirty;
|
|
use nigig_pdf_document::form::FieldValue;
|
|
use nigig_pdf_document::PdfDocument;
|
|
use nigig_pdf_graphics::content::parse_content_stream;
|
|
use nigig_pdf_graphics::recording::RecordingDevice;
|
|
use nigig_pdf_graphics::text::PageText;
|
|
use nigig_pdf_makepad::interaction::{
|
|
render_fields, HoverTarget, InteractionState, KeyInput, PdfAction, Viewport,
|
|
};
|
|
use nigig_pdf_makepad::page_view::{cursor_for, key_input_from_code};
|
|
|
|
/// The two-page AcroForm fixture added for Phase 3. Everything interactive
|
|
/// is on page index 1, so any code defaulting to page 0 fails here.
|
|
const FIXTURE: &[u8] = include_bytes!("../../pdf-document/tests/acroform.pdf");
|
|
|
|
const PAGE: usize = 1;
|
|
|
|
fn viewport() -> Viewport {
|
|
Viewport {
|
|
origin_x: 0.0,
|
|
origin_y: 0.0,
|
|
zoom: 1.0,
|
|
page_height: 792.0,
|
|
}
|
|
}
|
|
|
|
fn state() -> InteractionState {
|
|
InteractionState::new(PAGE, viewport())
|
|
}
|
|
|
|
/// Screen position of a PDF-space point under the test viewport.
|
|
fn screen(x: f64, y: f64) -> (f64, f64) {
|
|
viewport().to_screen(x, y)
|
|
}
|
|
|
|
#[test]
|
|
fn clicking_a_link_in_a_real_pdf_emits_open_uri_to_the_host() {
|
|
let mut doc = PdfDocument::parse(FIXTURE).expect("fixture parses");
|
|
let annots = doc.page_annotations(PAGE).expect("annotations");
|
|
let mut state = state();
|
|
|
|
// The fixture's link occupies 100,500 to 200,520.
|
|
let (sx, sy) = screen(150.0, 510.0);
|
|
let action = state.click(sx, sy, &annots, None);
|
|
|
|
assert_eq!(
|
|
action,
|
|
Some(PdfAction::OpenUri("https://example.org/".into())),
|
|
"the viewer reports the intent; the host opens the URL"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_full_edit_cycle_updates_the_value_and_regenerates_the_appearance() {
|
|
let mut doc = PdfDocument::parse(FIXTURE).expect("fixture parses");
|
|
let annots = doc.page_annotations(PAGE).expect("annotations");
|
|
let mut form = doc.acroform().expect("acroform").expect("present");
|
|
let field_ref = form.find_field("personal.name").expect("field").obj_ref;
|
|
let mut state = state();
|
|
|
|
// 1. Click the text field. Its widget is 100,600 to 300,620.
|
|
let (sx, sy) = screen(200.0, 610.0);
|
|
let action = state.click(sx, sy, &annots, Some(&mut form));
|
|
assert_eq!(action, None, "focusing a field is not itself a host action");
|
|
assert_eq!(state.focus.field, Some(field_ref));
|
|
assert_eq!(state.focus.buffer, "John", "the existing value is loaded");
|
|
|
|
// 2. Type: clear the field, then enter a new value.
|
|
for _ in 0..4 {
|
|
state.key(KeyInput::Backspace, &mut form);
|
|
}
|
|
for c in "Jane".chars() {
|
|
state.key(KeyInput::Char(c), &mut form);
|
|
}
|
|
|
|
// 3. The document must not have changed yet.
|
|
assert_eq!(
|
|
form.field(field_ref).expect("field").value,
|
|
FieldValue::Text("John".into()),
|
|
"typing must not mutate the document before it is committed"
|
|
);
|
|
|
|
// 4. Commit.
|
|
let action = state.key(KeyInput::Enter, &mut form);
|
|
assert_eq!(
|
|
action,
|
|
Some(PdfAction::FieldChanged {
|
|
field: field_ref,
|
|
invalidated_pages: vec![PAGE],
|
|
}),
|
|
"the host is told which pages to repaint"
|
|
);
|
|
assert_eq!(
|
|
form.field(field_ref).expect("field").value,
|
|
FieldValue::Text("Jane".into())
|
|
);
|
|
|
|
// 5. The appearance regenerates with the new value.
|
|
let (generated, errors) = regenerate_dirty(&form);
|
|
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
|
|
assert_eq!(generated.len(), 1);
|
|
let content = generated[0].1.content_str();
|
|
assert!(content.contains("(Jane) Tj"), "got: {content}");
|
|
assert!(!content.contains("(John)"), "the stale value must be gone");
|
|
|
|
// 6. The visual change is observable through the draw list.
|
|
let renders = render_fields(&form, PAGE, &viewport(), &state.focus);
|
|
let drawn = renders
|
|
.iter()
|
|
.find(|r| r.text == "Jane")
|
|
.expect("the new value must appear in the draw list");
|
|
assert!(drawn.rect[2] > 0.0 && drawn.rect[3] > 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn an_abandoned_edit_leaves_the_real_document_untouched() {
|
|
let mut doc = PdfDocument::parse(FIXTURE).expect("fixture parses");
|
|
let mut form = doc.acroform().expect("acroform").expect("present");
|
|
let field_ref = form.find_field("personal.name").expect("field").obj_ref;
|
|
let mut state = state();
|
|
|
|
let (sx, sy) = screen(200.0, 610.0);
|
|
state.click(sx, sy, &[], Some(&mut form));
|
|
for c in "XYZ".chars() {
|
|
state.key(KeyInput::Char(c), &mut form);
|
|
}
|
|
state.key(KeyInput::Escape, &mut form);
|
|
|
|
assert_eq!(
|
|
form.field(field_ref).expect("field").value,
|
|
FieldValue::Text("John".into())
|
|
);
|
|
assert!(!form.field(field_ref).expect("field").dirty);
|
|
let (generated, _) = regenerate_dirty(&form);
|
|
assert!(
|
|
generated.is_empty(),
|
|
"nothing is dirty, so nothing regenerates"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn clicking_the_checkbox_uses_the_state_declared_in_the_file() {
|
|
let mut doc = PdfDocument::parse(FIXTURE).expect("fixture parses");
|
|
let mut form = doc.acroform().expect("acroform").expect("present");
|
|
let field_ref = form.find_field("agree").expect("checkbox").obj_ref;
|
|
let mut state = state();
|
|
|
|
// The fixture's checkbox is 100,560 to 114,574 with /On and /Off states.
|
|
let (sx, sy) = screen(107.0, 567.0);
|
|
let action = state.click(sx, sy, &[], Some(&mut form));
|
|
|
|
assert!(matches!(action, Some(PdfAction::FieldChanged { .. })));
|
|
assert_eq!(
|
|
form.field(field_ref).expect("field").value,
|
|
FieldValue::State("On".into()),
|
|
"the on state comes from the widget's /AP, not an assumed /Yes"
|
|
);
|
|
assert!(form.field(field_ref).expect("field").is_checked());
|
|
}
|
|
|
|
#[test]
|
|
fn hovering_reports_the_cursor_the_host_should_show() {
|
|
let mut doc = PdfDocument::parse(FIXTURE).expect("fixture parses");
|
|
let annots = doc.page_annotations(PAGE).expect("annotations");
|
|
let form = doc.acroform().expect("acroform").expect("present");
|
|
let state = state();
|
|
|
|
let (fx, fy) = screen(200.0, 610.0);
|
|
assert_eq!(
|
|
state.hover_at(fx, fy, &annots, Some(&form)),
|
|
HoverTarget::TextField
|
|
);
|
|
|
|
let (lx, ly) = screen(150.0, 510.0);
|
|
assert_eq!(
|
|
state.hover_at(lx, ly, &annots, Some(&form)),
|
|
HoverTarget::Link
|
|
);
|
|
|
|
let (ex, ey) = screen(450.0, 300.0);
|
|
assert_eq!(
|
|
state.hover_at(ex, ey, &annots, Some(&form)),
|
|
HoverTarget::None
|
|
);
|
|
|
|
// And the widget layer turns those into real cursors.
|
|
assert_ne!(
|
|
cursor_for(HoverTarget::Link),
|
|
cursor_for(HoverTarget::TextField)
|
|
);
|
|
}
|
|
|
|
/// The review's step 4.1 lists form-field events before annotation hit
|
|
/// testing. The fixture has a field and a link on the same page, so the
|
|
/// ordering is observable.
|
|
#[test]
|
|
fn form_fields_are_routed_before_annotations() {
|
|
let mut doc = PdfDocument::parse(FIXTURE).expect("fixture parses");
|
|
let annots = doc.page_annotations(PAGE).expect("annotations");
|
|
let mut form = doc.acroform().expect("acroform").expect("present");
|
|
let mut state = state();
|
|
|
|
// Click the field: no navigation action, focus taken instead.
|
|
let (fx, fy) = screen(200.0, 610.0);
|
|
assert_eq!(state.click(fx, fy, &annots, Some(&mut form)), None);
|
|
assert!(state.focus.is_focused());
|
|
|
|
// Click the link: focus is dropped and the action is emitted.
|
|
let (lx, ly) = screen(150.0, 510.0);
|
|
let action = state.click(lx, ly, &annots, Some(&mut form));
|
|
assert_eq!(
|
|
action,
|
|
Some(PdfAction::OpenUri("https://example.org/".into()))
|
|
);
|
|
assert!(!state.focus.is_focused(), "clicking away drops focus");
|
|
}
|
|
|
|
#[test]
|
|
fn text_selection_reads_through_the_extracted_page_text() {
|
|
let mut doc = PdfDocument::parse(FIXTURE).expect("fixture parses");
|
|
let page = doc.page(PAGE).expect("page");
|
|
let ops = parse_content_stream(&page.content_data).expect("content parses");
|
|
let commands = RecordingDevice::from_ops(&ops);
|
|
let page_text = PageText::from_commands(&commands);
|
|
|
|
assert!(
|
|
page_text.plain_text().contains("Page two"),
|
|
"extracted text was {:?}",
|
|
page_text.plain_text()
|
|
);
|
|
|
|
let mut state = state();
|
|
assert_eq!(
|
|
state.selected_text(&page_text),
|
|
"",
|
|
"an empty selection yields no text"
|
|
);
|
|
|
|
let (ax, ay) = screen(0.0, 0.0);
|
|
state.begin_selection(ax, ay);
|
|
assert!(state.selection.active);
|
|
state.end_selection();
|
|
assert!(!state.selection.active);
|
|
}
|
|
|
|
#[test]
|
|
fn every_key_the_widget_forwards_is_one_the_editor_handles() {
|
|
// The widget only forwards keys that map to a KeyInput; anything else
|
|
// must fall through to the host rather than being swallowed.
|
|
use makepad_widgets::KeyCode;
|
|
for code in [
|
|
KeyCode::Backspace,
|
|
KeyCode::Delete,
|
|
KeyCode::ArrowLeft,
|
|
KeyCode::ArrowRight,
|
|
KeyCode::Home,
|
|
KeyCode::End,
|
|
KeyCode::ReturnKey,
|
|
KeyCode::Escape,
|
|
KeyCode::Tab,
|
|
] {
|
|
assert!(
|
|
key_input_from_code(code).is_some(),
|
|
"{code:?} should be handled"
|
|
);
|
|
}
|
|
for code in [KeyCode::F5, KeyCode::ArrowUp, KeyCode::KeyZ] {
|
|
assert!(
|
|
key_input_from_code(code).is_none(),
|
|
"{code:?} must fall through to the host"
|
|
);
|
|
}
|
|
}
|