mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-14 20:34:56 +00:00
* AcroForm: integrate persistent timesheet script workflows * AcroForm: address review caching, worker stacks and live metadata * M6: address second review of AcroForm script workflows * M6: handle top-level ASI boundaries and verify cyclic calculation results * M6: bound script workers and isolate pending completions across edits * M6: make form resets visible before subsequent script reads * M6: include calculated dependents in reset comparison baselines * M6: bound reset writeback work before native completion * M6: charge expanded reset selections and field name bytes * M6: bound string conversion bytes and charge reset widgets * M6: preserve unread defaults and bound prompt text conversion * M6: bound reset calls early and preserve calculated field identity * AcroForm: address grouped calculations, field identity and widget ownership * AcroForm: retire field instances and address native review edge cases * AcroForm: route comparison Save As and Print to the source document * M6: dispatch text widget mouse-up scripts * M6: honor fit bounds for form button icons * AcroForm: bound calculation work and preserve reset and save integrity * Harden AcroForm script requests and worker recovery * Keep form callbacks and focus consistent across edits * AcroForm: preserve reset ordering and bound edit callbacks * Fix reset appearance ordering and XFA focus after row removal * Keep XFA focus indexes and stopped reset appearances consistent * Document form script execution and reset helpers * Keep script workers bounded and reject partial callback edits * Keep form events available after a document script reaches VM limits * Keep main's first-value Simple calculation and restore the calculateNow helper test AFSimple_Calculate (JavaScript) and the native Simple calculation count each field's first value again, as #5304 landed them; summing every selection of a multiple-selection list is a separate behaviour change. Restores the document-helper calculateNow test and the AFSimple_Calculate doc comment, and adds native and JavaScript multiple-selection cases. * Patch the callback field snapshot in place instead of rebuilding it Each field callback rebuilt all field states (3,129 on the benchmark form), and the retained realm held the previous snapshot, so stamping instance tokens copied it again. The runner now keeps its last snapshot and patches only the values and flags that changed; ScriptSession::stamp_fields stamps it before dispatch, the worker copies only when a token changes, and the realm drops the job's snapshot when the job ends (field identity is checked against the cached keys instead). * An oversized job script is that script's error, not a refused edit Realm::run refused a script too long or too deep to run as an execution refusal, which made the engine reject the whole edit (setting a 300 KiB calculation script failed). As on main it is now a plain script error that keeps the value; the realm is not touched. * Read a leading #! line as a comment in the chain scan; follow main's refusal and conditional limits in tests The nesting guard from #5079 now checks brackets, prefixes and conditionals; the document worker's preflight still bounds arrow-function and right-associative chains and skips a leading #! line as the guard does. A refused script is an error, not a refused edit, so the preflight test now checks the edit goes through with the error reported. --------- Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
1146 lines
52 KiB
Rust
1146 lines
52 KiB
Rust
//! Addresses a document asks to open (links, button URI actions, `app.launchURL`) wait for the
|
||
//! user's permission, and anything but a web or email address is refused (#90, #91).
|
||
|
||
use crate::support;
|
||
|
||
use egui_kittest::Harness;
|
||
use egui_kittest::kittest::Queryable;
|
||
use pdfcraft_ui_egui::{LinkOrigin, PdfCraftApp, PendingLink};
|
||
|
||
use crate::gpu;
|
||
|
||
const LINK_URL: &str = "https://example.org/from-a-link";
|
||
const BUTTON_URL: &str = "https://example.org/from-a-button";
|
||
|
||
/// One 300×400 page with a web link (x 50..250, y 300..350), a push button with a URI action
|
||
/// ("web") and a push button whose JavaScript asks to open a local program ("sneaky").
|
||
fn fixture() -> Vec<u8> {
|
||
let objs = [
|
||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R 6 0 R] /DA (/Helv 0 Tf 0 g) >> >>".to_string(),
|
||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".to_string(),
|
||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R 7 0 R 8 0 R] >>".to_string(),
|
||
format!("<< /Type /Annot /Subtype /Link /Rect [50 300 250 350] /Border [0 0 0] /A << /S /URI /URI ({LINK_URL}) >> >>"),
|
||
format!("<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (web) /Rect [20 200 100 220] /P 3 0 R /A << /S /URI /URI ({BUTTON_URL}) >> >>"),
|
||
"<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (sneaky) /Rect [120 200 200 220] /P 3 0 R /AA << /U << /S /JavaScript /JS (app.launchURL\\(\"file:///C:/Windows/System32/calc.exe\", true\\);) >> >> >>".to_string(),
|
||
];
|
||
let objs = objs
|
||
.into_iter()
|
||
.chain([
|
||
"<< /Type /Annot /Subtype /Link /Rect [50 250 250 280] /A << /S /GoToR /F (target.pdf) /D [0 /Fit] >> >>".to_string(),
|
||
"<< /Type /Annot /Subtype /Link /Rect [50 220 250 240] /A << /S /Launch /F (program) >> >>".to_string(),
|
||
])
|
||
.collect::<Vec<_>>();
|
||
let mut out = b"%PDF-1.7\n".to_vec();
|
||
let mut offs = Vec::new();
|
||
for (i, o) in objs.iter().enumerate() {
|
||
offs.push(out.len());
|
||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||
}
|
||
let x = out.len();
|
||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||
for o in offs {
|
||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||
}
|
||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||
out
|
||
}
|
||
|
||
fn remote_fixture(file: &str, destination: &str) -> Vec<u8> {
|
||
// canonicalize() uses a Windows device prefix; a PDF file specification uses the ordinary drive path.
|
||
#[cfg(windows)]
|
||
let file = file.strip_prefix(r"\\?\").filter(|path| path.as_bytes().get(1) == Some(&b':')).unwrap_or(file);
|
||
let file = file.replace('\\', "\\\\").replace('(', "\\(").replace(')', "\\)");
|
||
let objs = [
|
||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".to_string(),
|
||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>".to_string(),
|
||
format!("<< /Type /Annot /Subtype /Link /Rect [50 300 250 350] /A << /S /GoToR /F ({file}) /D {destination} /NewWindow true >> >>"),
|
||
];
|
||
let mut out = b"%PDF-1.7\n".to_vec();
|
||
let mut offs = Vec::new();
|
||
for (i, o) in objs.iter().enumerate() {
|
||
offs.push(out.len());
|
||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||
}
|
||
let x = out.len();
|
||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||
for o in offs {
|
||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||
}
|
||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||
out
|
||
}
|
||
|
||
fn remote_harness(source_path: Option<String>, file: &str, destination: &str) -> Harness<'static, PdfCraftApp> {
|
||
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("source.pdf", source_path, remote_fixture(file, destination)).unwrap();
|
||
app.set_option("left", "closed").unwrap();
|
||
app.set_option("panel", "none").unwrap();
|
||
app.set_option("zoom", "150").unwrap();
|
||
app
|
||
});
|
||
h.run_steps(8);
|
||
h
|
||
}
|
||
|
||
fn linked_pages(count: usize, password: Option<&str>) -> Vec<u8> {
|
||
use pdfcraft_engine::{Edit, Protection, Session};
|
||
let mut session = Session::new();
|
||
let id = session.open("target.pdf", None, std::sync::Arc::new(fixture()), None).unwrap();
|
||
for page in 1..count {
|
||
session.apply(id, Edit::InsertBlankPage { at: page, width: 300.0, height: 400.0 }).unwrap();
|
||
}
|
||
if let Some(password) = password {
|
||
session.apply(id, Edit::Protect(Protection { open_password: Some(password.into()), ..Default::default() })).unwrap();
|
||
}
|
||
session.save_bytes(id).unwrap().as_ref().clone()
|
||
}
|
||
|
||
fn harness() -> Harness<'static, PdfCraftApp> {
|
||
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.open_bytes("links.pdf", None, fixture()).unwrap();
|
||
app.set_option("left", "closed").unwrap();
|
||
app.set_option("panel", "none").unwrap();
|
||
app.set_option("zoom", "150").unwrap();
|
||
app
|
||
});
|
||
h.run_steps(8);
|
||
h
|
||
}
|
||
|
||
fn click_at(h: &mut Harness<'static, PdfCraftApp>, at: egui::Pos2) {
|
||
h.hover_at(at);
|
||
h.run_steps(2);
|
||
h.drag_at(at);
|
||
h.drop_at(at);
|
||
h.run_steps(4);
|
||
}
|
||
|
||
/// Click the link on page 1, at the middle of its rectangle.
|
||
fn click_link_at(h: &mut Harness<'static, PdfCraftApp>, y: f32) {
|
||
let r = h.state().views[0].page_screen_rect(0).expect("page 1 on screen");
|
||
let at = egui::pos2(r.min.x + r.width() * (150.0 / 300.0), r.min.y + r.height() * (1.0 - y / 400.0));
|
||
click_at(h, at);
|
||
}
|
||
|
||
fn click_remote_link(h: &mut Harness<'static, PdfCraftApp>) {
|
||
click_link(h);
|
||
}
|
||
|
||
fn click_link(h: &mut Harness<'static, PdfCraftApp>) {
|
||
click_link_at(h, 325.0);
|
||
}
|
||
|
||
fn click_field(h: &mut Harness<'static, PdfCraftApp>, name: &str) {
|
||
let at = {
|
||
let s = h.state();
|
||
let doc = s.session.get(s.views[0].id).unwrap();
|
||
let f = doc.form.iter().find(|f| f.name == name).unwrap();
|
||
pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen").center()
|
||
};
|
||
click_at(h, at);
|
||
}
|
||
|
||
fn pending(url: &str, origin: LinkOrigin) -> Option<PendingLink> {
|
||
Some(PendingLink { url: url.into(), origin })
|
||
}
|
||
|
||
const BOOKMARK_URL: &str = "https://example.org/from-a-bookmark";
|
||
|
||
/// Three 300×400 pages whose bookmarks open a web address, go to page 2 of `target.pdf` (a remote
|
||
/// go-to, whose `[1 /Fit]` is the other file's page) and go to page 3 here.
|
||
fn bookmark_fixture() -> Vec<u8> {
|
||
outline_pdf(&[
|
||
("Website", format!("/A << /S /URI /URI ({BOOKMARK_URL}) >>")),
|
||
("Chapter in target", "/A << /S /GoToR /F (target.pdf) /D [1 /Fit] >>".into()),
|
||
("Appendix", "/Dest [5 0 R /Fit]".into()),
|
||
])
|
||
}
|
||
|
||
/// Three 300×400 pages (objects 3–5) and a flat outline of `bookmarks`: each a title and the rest
|
||
/// of its dictionary (its `/Dest` or `/A`).
|
||
fn outline_pdf(bookmarks: &[(&str, String)]) -> Vec<u8> {
|
||
let n = bookmarks.len();
|
||
let mut objs = vec![
|
||
"<< /Type /Catalog /Pages 2 0 R /Outlines 6 0 R >>".to_string(),
|
||
"<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >>".to_string(),
|
||
"<< /Type /Page /Parent 2 0 R >>".to_string(),
|
||
"<< /Type /Page /Parent 2 0 R >>".to_string(),
|
||
"<< /Type /Page /Parent 2 0 R >>".to_string(),
|
||
format!("<< /Type /Outlines /First 7 0 R /Last {} 0 R /Count {n} >>", 6 + n),
|
||
];
|
||
for (k, (title, rest)) in bookmarks.iter().enumerate() {
|
||
let prev = if k == 0 { String::new() } else { format!(" /Prev {} 0 R", 6 + k) };
|
||
let next = if k + 1 == n { String::new() } else { format!(" /Next {} 0 R", 8 + k) };
|
||
objs.push(format!("<< /Title ({title}) /Parent 6 0 R{prev}{next} {rest} >>"));
|
||
}
|
||
let mut out = b"%PDF-1.7\n".to_vec();
|
||
let mut offs = Vec::new();
|
||
for (i, o) in objs.iter().enumerate() {
|
||
offs.push(out.len());
|
||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||
}
|
||
let x = out.len();
|
||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||
for o in offs {
|
||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||
}
|
||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||
out
|
||
}
|
||
|
||
/// The app with [`bookmark_fixture`] open from `source_path` (in memory when `None`) and the
|
||
/// Bookmarks panel showing.
|
||
fn bookmark_harness(source_path: Option<String>) -> Harness<'static, PdfCraftApp> {
|
||
bookmark_harness_with(source_path, bookmark_fixture())
|
||
}
|
||
|
||
/// The app with `pdf` open from `source_path` (in memory when `None`) and the Bookmarks panel
|
||
/// showing.
|
||
fn bookmark_harness_with(source_path: Option<String>, pdf: Vec<u8>) -> Harness<'static, PdfCraftApp> {
|
||
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("source.pdf", source_path, pdf).unwrap();
|
||
app.set_option("left", "closed").unwrap();
|
||
app.set_option("panel", "bookmarks").unwrap();
|
||
app
|
||
});
|
||
h.run_steps(8);
|
||
h
|
||
}
|
||
|
||
/// Click the Bookmarks panel row titled `title`.
|
||
fn click_bookmark(h: &mut Harness<'static, PdfCraftApp>, title: &str) {
|
||
h.get_by_label(title).click();
|
||
h.run_steps(4);
|
||
}
|
||
|
||
/// A bookmark that opens a web address asks first, as a link does, and stays on the page; a
|
||
/// bookmark to a page here still goes there.
|
||
#[test]
|
||
fn a_web_bookmark_asks_first_like_a_link() {
|
||
let _gpu = gpu();
|
||
let mut h = bookmark_harness(None);
|
||
click_bookmark(&mut h, "Website");
|
||
assert_eq!(h.state().pending_link, pending(BOOKMARK_URL, LinkOrigin::Link));
|
||
assert_eq!(h.state().last_opened_url, None, "nothing opens before the user answers");
|
||
assert_eq!(h.state().views[0].current, 0);
|
||
h.get_by_label("Cancel").click();
|
||
h.run_steps(3);
|
||
assert_eq!((h.state().pending_link.clone(), h.state().last_opened_url.clone()), (None, None));
|
||
|
||
click_bookmark(&mut h, "Appendix");
|
||
assert_eq!(h.state().views[0].current, 2);
|
||
}
|
||
|
||
/// A bookmark to another PDF opens that file at its page, as a link does. Its `[1 /Fit]` is the
|
||
/// other file's page 2, never this file's: the source document stays where it was.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
#[test]
|
||
fn a_bookmark_to_another_pdf_opens_it_instead_of_moving_here() {
|
||
let _gpu = gpu();
|
||
let dir = std::env::temp_dir().join(format!("pdfcraft-ui-bookmark-gotor-{}", std::process::id()));
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
std::fs::write(dir.join("target.pdf"), linked_pages(2, None)).unwrap();
|
||
let mut h = bookmark_harness(Some(dir.join("source.pdf").to_string_lossy().into_owned()));
|
||
click_bookmark(&mut h, "Chapter in target");
|
||
support::wait_until(&mut h, "remote document", |app| app.views.len() == 2);
|
||
assert_eq!(h.state().views[0].current, 0, "the source stays on its page");
|
||
assert_eq!(h.state().active, Some(1));
|
||
assert_eq!(h.state().views[1].current, 1, "the other file opens at the bookmark's page");
|
||
}
|
||
|
||
/// Bookmarks go through the link policy, not around it. A script action is named and not run (its
|
||
/// `app.launchURL` asks for nothing), a Launch of a program is named and nothing starts, a
|
||
/// destination that doesn't exist does nothing, and a remote go-to by an absolute path waits for
|
||
/// consent before the file is touched.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
#[test]
|
||
fn bookmarks_go_through_the_link_policy() {
|
||
let _gpu = gpu();
|
||
let dir = std::env::temp_dir().join(format!("pdfcraft-ui-bookmark-policy-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
let target = dir.join("absent.pdf");
|
||
// A PDF string: backslashes and parentheses escaped.
|
||
let file = target.to_str().unwrap().replace('\\', "\\\\").replace('(', "\\(").replace(')', "\\)");
|
||
let pdf = outline_pdf(&[
|
||
("Script", r#"/A << /S /JavaScript /JS (app.launchURL\("https://example.org/from-a-script", true\);) >>"#.into()),
|
||
("Program", "/A << /S /Launch /F (tool.exe) >>".into()),
|
||
("Missing", "/Dest (nowhere)".into()),
|
||
("Absolute", format!("/A << /S /GoToR /F ({file}) /D [0 /Fit] >>")),
|
||
]);
|
||
let mut h = bookmark_harness_with(None, pdf);
|
||
for (title, notice) in [
|
||
("Script", Some("This PDF uses the unsupported JavaScript action.")),
|
||
("Program", Some("This PDF uses the unsupported Launch action.")),
|
||
("Missing", None),
|
||
] {
|
||
h.state_mut().toast = None;
|
||
click_bookmark(&mut h, title);
|
||
let s = h.state();
|
||
assert_eq!(s.toast.as_ref().map(|t| t.0.as_str()), notice, "{title}");
|
||
assert_eq!((&s.pending_link, &s.last_opened_url, s.views.len(), s.views[0].current), (&None, &None, 1, 0), "{title}: nothing opened");
|
||
}
|
||
click_bookmark(&mut h, "Absolute");
|
||
assert_eq!(h.state().pending_remote_link.as_ref().map(|p| p.path.clone()), Some(target));
|
||
assert_eq!(h.state().views.len(), 1);
|
||
assert!(h.state().toast.is_none(), "the file is not touched before consent");
|
||
}
|
||
|
||
#[test]
|
||
fn a_link_asks_first_and_opens_when_allowed() {
|
||
let _gpu = gpu();
|
||
let mut h = harness();
|
||
click_link(&mut h);
|
||
assert_eq!(h.state().pending_link, pending(LINK_URL, LinkOrigin::Link));
|
||
assert_eq!(h.state().last_opened_url, None, "nothing opens before the user answers");
|
||
h.get_by_label("example.org");
|
||
h.get_by_label("Open link").click();
|
||
h.run_steps(3);
|
||
assert_eq!(h.state().last_opened_url.as_deref(), Some(LINK_URL));
|
||
assert_eq!(h.state().pending_link, None);
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
#[test]
|
||
fn a_local_gotor_link_opens_in_a_tab_and_applies_its_destination() {
|
||
let _gpu = gpu();
|
||
let dir = std::env::temp_dir().join(format!("pdfcraft-ui-gotor-{}", std::process::id()));
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
std::fs::write(dir.join("target.pdf"), linked_pages(2, None)).unwrap();
|
||
let mut h = remote_harness(Some(dir.join("source.pdf").to_string_lossy().into_owned()), "target.pdf", "[1 /FitH 200]");
|
||
click_remote_link(&mut h);
|
||
support::wait_until(&mut h, "remote document", |app| app.views.len() == 2);
|
||
assert_eq!(h.state().views.len(), 2);
|
||
assert_eq!(h.state().active, Some(1));
|
||
assert_eq!(h.state().views[1].current, 1, "the remote page destination is applied");
|
||
assert_eq!(h.state().last_opened_url, None, "no URL or external launcher is involved");
|
||
}
|
||
|
||
/// Three 300×400 pages whose catalog /Dests names `there` (page 3) and `here` (page 2).
|
||
fn named_pages() -> Vec<u8> {
|
||
let objs = [
|
||
"<< /Type /Catalog /Pages 2 0 R /Dests << /there [5 0 R /Fit] /here [4 0 R /Fit] >> >>",
|
||
"<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >>",
|
||
"<< /Type /Page /Parent 2 0 R >>",
|
||
"<< /Type /Page /Parent 2 0 R >>",
|
||
"<< /Type /Page /Parent 2 0 R >>",
|
||
];
|
||
let mut out = b"%PDF-1.7\n".to_vec();
|
||
let mut offs = Vec::new();
|
||
for (i, o) in objs.iter().enumerate() {
|
||
offs.push(out.len());
|
||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||
}
|
||
let x = out.len();
|
||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||
for o in offs {
|
||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||
}
|
||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||
out
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
#[test]
|
||
fn a_gotor_link_to_a_named_destination_opens_at_its_page() {
|
||
let _gpu = gpu();
|
||
let dir = std::env::temp_dir().join(format!("pdfcraft-ui-gotor-named-{}", std::process::id()));
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
std::fs::write(dir.join("target.pdf"), named_pages()).unwrap();
|
||
for (destination, page) in [("/there", 2), ("(here)", 1)] {
|
||
let mut h = remote_harness(Some(dir.join("source.pdf").to_string_lossy().into_owned()), "target.pdf", destination);
|
||
click_remote_link(&mut h);
|
||
// The name is looked up off the UI thread, so the tab may open before it moves there.
|
||
support::wait_until(&mut h, "remote document at its destination", |app| app.views.len() == 2 && app.views[1].current == page);
|
||
assert_eq!(h.state().toast, None, "{destination}: applied, so nothing to warn about");
|
||
}
|
||
// A name the target doesn't define opens it at its first page, with the warning.
|
||
let mut h = remote_harness(Some(dir.join("source.pdf").to_string_lossy().into_owned()), "target.pdf", "/elsewhere");
|
||
click_remote_link(&mut h);
|
||
support::wait_until(&mut h, "remote document and the warning", |app| app.views.len() == 2 && app.toast.is_some());
|
||
assert_eq!(h.state().views[1].current, 0);
|
||
assert!(h.state().toast.as_ref().is_some_and(|(text, _)| text.contains("named destination was not applied")), "{:?}", h.state().toast);
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
#[test]
|
||
fn a_remote_destination_survives_password_retries_and_cancel_opens_no_tab() {
|
||
let _gpu = gpu();
|
||
let dir = std::env::temp_dir().join(format!("pdfcraft-ui-gotor-password-{}", std::process::id()));
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
std::fs::write(dir.join("target.pdf"), linked_pages(2, Some("test-password"))).unwrap();
|
||
|
||
let mut h = remote_harness(Some(dir.join("source.pdf").to_string_lossy().into_owned()), "target.pdf", "[1 /Fit]");
|
||
click_remote_link(&mut h);
|
||
support::wait_until(&mut h, "remote password prompt", |app| app.password_prompt.as_ref().is_some_and(|p| p.name == "target.pdf"));
|
||
assert!(h.state().password_prompt.is_some());
|
||
assert_eq!(h.state().views.len(), 1);
|
||
h.state_mut().submit_password(Some("wrong-password".into()));
|
||
support::wait_until(&mut h, "wrong-password reply", |app| {
|
||
app.password_prompt.as_ref().is_some_and(|p| p.name == "target.pdf" && p.error.as_deref() == Some("Incorrect password. Try again."))
|
||
});
|
||
assert!(h.state().password_prompt.as_ref().is_some_and(|p| p.error.is_some()));
|
||
h.state_mut().submit_password(Some("test-password".into()));
|
||
support::wait_until(&mut h, "authenticated remote document", |app| app.views.len() == 2 && app.password_prompt.is_none());
|
||
assert_eq!(h.state().views.len(), 2);
|
||
assert_eq!(h.state().views[1].current, 1, "the destination survives a wrong-password retry");
|
||
|
||
let mut cancelled = remote_harness(Some(dir.join("source.pdf").to_string_lossy().into_owned()), "target.pdf", "[1 /Fit]");
|
||
click_remote_link(&mut cancelled);
|
||
support::wait_until(&mut cancelled, "cancelled target password prompt", |app| {
|
||
app.password_prompt.as_ref().is_some_and(|p| p.name == "target.pdf")
|
||
});
|
||
cancelled.state_mut().submit_password(None);
|
||
assert_eq!(cancelled.state().views.len(), 1, "cancelling does not leave a half-open target tab");
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
#[test]
|
||
fn a_gotor_link_to_a_directory_or_device_is_refused_without_reading_it() {
|
||
let _gpu = gpu();
|
||
let dir = std::env::temp_dir().join(format!("pdfcraft-ui-gotor-not-a-file-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
std::fs::create_dir_all(dir.join("folder.pdf")).unwrap();
|
||
// Only Unix adds a target (a device), so elsewhere the list is never changed.
|
||
#[cfg_attr(not(unix), allow(unused_mut))]
|
||
let mut targets = vec!["folder.pdf"];
|
||
#[cfg(unix)]
|
||
{
|
||
// /dev/zero never ends: reading it would exhaust memory.
|
||
std::os::unix::fs::symlink("/dev/zero", dir.join("zero.pdf")).unwrap();
|
||
targets.push("zero.pdf");
|
||
}
|
||
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}");
|
||
}
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
#[test]
|
||
fn an_in_memory_source_does_not_resolve_a_relative_gotor_against_cwd() {
|
||
let _gpu = gpu();
|
||
let mut h = remote_harness(None, "target.pdf", "[0 /Fit]");
|
||
click_remote_link(&mut h);
|
||
assert_eq!(h.state().views.len(), 1);
|
||
let toast = &h.state().toast.as_ref().unwrap().0;
|
||
assert!(toast.contains("relative link needs a source document path"), "{toast}");
|
||
}
|
||
|
||
#[test]
|
||
fn unsupported_launch_action_is_reported_without_claiming_javascript() {
|
||
let _gpu = gpu();
|
||
let mut h = harness();
|
||
click_link_at(&mut h, 230.0);
|
||
assert_eq!(h.state().pending_link, None);
|
||
assert_eq!(h.state().last_opened_url, None);
|
||
assert_eq!(h.state().views.len(), 1);
|
||
assert_eq!(h.state().views[0].current, 0);
|
||
let toast = h.state().toast.clone().expect("the unsupported action is reported").0;
|
||
assert_eq!(toast, "This PDF uses the unsupported Launch action.");
|
||
}
|
||
|
||
#[test]
|
||
fn cancel_opens_nothing() {
|
||
let _gpu = gpu();
|
||
let mut h = harness();
|
||
click_link(&mut h);
|
||
assert!(h.state().pending_link.is_some());
|
||
h.get_by_label("Cancel").click();
|
||
h.run_steps(3);
|
||
assert_eq!(h.state().pending_link, None);
|
||
assert_eq!(h.state().last_opened_url, None);
|
||
}
|
||
|
||
#[test]
|
||
fn escape_cancels() {
|
||
let _gpu = gpu();
|
||
let mut h = harness();
|
||
click_link(&mut h);
|
||
assert!(h.state().pending_link.is_some());
|
||
h.key_press(egui::Key::Escape);
|
||
h.run_steps(3);
|
||
assert_eq!(h.state().pending_link, None);
|
||
assert_eq!(h.state().last_opened_url, None);
|
||
}
|
||
|
||
#[test]
|
||
fn a_button_uri_action_asks_first() {
|
||
let _gpu = gpu();
|
||
let mut h = harness();
|
||
click_field(&mut h, "web");
|
||
assert_eq!(h.state().pending_link, pending(BUTTON_URL, LinkOrigin::Button));
|
||
assert_eq!(h.state().last_opened_url, None);
|
||
}
|
||
|
||
#[test]
|
||
fn a_button_script_cannot_open_a_local_program() {
|
||
let _gpu = gpu();
|
||
let mut h = harness();
|
||
click_field(&mut h, "sneaky");
|
||
support::wait_until(&mut h, "script link refusal", |app| app.pending_link.is_some() || app.toast.is_some());
|
||
assert_eq!(h.state().pending_link, None, "a file: address is never offered");
|
||
assert_eq!(h.state().last_opened_url, None);
|
||
let toast = h.state().toast.clone().expect("the user is told").0;
|
||
assert!(toast.contains("“file:” address"), "{toast}");
|
||
}
|
||
|
||
#[test]
|
||
fn a_script_asks_first_and_only_once() {
|
||
let _gpu = gpu();
|
||
let mut h = harness();
|
||
let id = h.state().views[0].id;
|
||
let script = "app.launchURL('https://example.org/?q=' + 'first'); app.launchURL('https://example.org/second');";
|
||
h.state_mut().run_button_script(id, "web", script);
|
||
support::wait_until(&mut h, "script link request", |app| app.pending_link.is_some());
|
||
assert_eq!(h.state().pending_link, pending("https://example.org/?q=first", LinkOrigin::Script), "one dialog, for the first request");
|
||
assert_eq!(h.state().last_opened_url, None);
|
||
}
|
||
|
||
#[test]
|
||
fn a_script_cannot_open_other_kinds_of_address() {
|
||
let _gpu = gpu();
|
||
// `mailto://a^b/x` and `https://a^b/` don't parse as URLs, so the browser would be handed them
|
||
// as local file paths.
|
||
for url in [
|
||
"file:///etc/passwd",
|
||
"javascript:alert(1)",
|
||
"ms-settings:privacy",
|
||
"smb://server/share",
|
||
"https://exa\u{202E}gro.elpmaxe",
|
||
"mailto://a^b/x",
|
||
"https://a^b/",
|
||
] {
|
||
let mut h = harness();
|
||
let id = h.state().views[0].id;
|
||
let script = format!("app.launchURL({});", serde_json::to_string(url).unwrap());
|
||
h.state_mut().run_button_script(id, "web", &script);
|
||
support::wait_until(&mut h, "script link decision", |app| app.pending_link.is_some() || app.toast.is_some());
|
||
assert_eq!(h.state().pending_link, None, "{url}");
|
||
assert_eq!(h.state().last_opened_url, None, "{url}");
|
||
assert!(h.state().toast.clone().is_some_and(|t| t.0.contains("won't open")), "{url}: {:?}", h.state().toast);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn an_email_link_cannot_attach_a_local_file() {
|
||
let _gpu = gpu();
|
||
for url in ["mailto:a@example.org?attach=/home/me/.ssh/id_ed25519", "mailto:a@example.org?subject=Hi&%61ttachment=C:/Users/me/x.txt"] {
|
||
let mut h = harness();
|
||
let id = h.state().views[0].id;
|
||
let script = format!("app.launchURL({});", serde_json::to_string(url).unwrap());
|
||
h.state_mut().run_button_script(id, "web", &script);
|
||
support::wait_until(&mut h, "script link decision", |app| app.pending_link.is_some() || app.toast.is_some());
|
||
assert_eq!(h.state().pending_link, None, "{url}");
|
||
assert_eq!(h.state().last_opened_url, None, "{url}");
|
||
let toast = h.state().toast.clone().expect("the user is told").0;
|
||
assert!(toast.contains("attach or insert a file"), "{url}: {toast}");
|
||
}
|
||
}
|
||
|
||
/// Ask to open `url` from a script and return the harness with the prompt showing.
|
||
fn prompt_for(url: &str) -> Harness<'static, PdfCraftApp> {
|
||
let mut h = harness();
|
||
let id = h.state().views[0].id;
|
||
let script = format!("app.launchURL({});", serde_json::to_string(url).unwrap());
|
||
h.state_mut().run_button_script(id, "web", &script);
|
||
assert!(support::step_until(&mut h, |h| h.query_by_label("Open link").is_some()), "script link prompt rendered");
|
||
assert_eq!(h.state().pending_link, pending(url, LinkOrigin::Script));
|
||
h
|
||
}
|
||
|
||
#[test]
|
||
fn a_lookalike_host_is_shown_in_punycode_and_flagged() {
|
||
let _gpu = gpu();
|
||
// `pаypal.com` with a Cyrillic `а` (U+0430).
|
||
let h = prompt_for("https://p\u{0430}ypal.com/login");
|
||
h.get_by_label("xn--pypal-4ve.com");
|
||
assert_eq!(h.query_all_by_label_contains("p\u{0430}ypal.com").count(), 1, "only the full address shows the lookalike form");
|
||
h.get_by_label_contains("mixes letters from different alphabets");
|
||
}
|
||
|
||
#[test]
|
||
fn an_international_host_is_shown_in_punycode_and_marked() {
|
||
let _gpu = gpu();
|
||
let h = prompt_for("https://bücher.example/");
|
||
h.get_by_label("xn--bcher-kva.example");
|
||
assert_eq!(h.query_all_by_label_contains("bücher.example").count(), 1, "only the full address shows the Unicode form");
|
||
h.get_by_label_contains("letters from another alphabet");
|
||
assert!(h.query_by_label_contains("mixes letters").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn a_numeric_host_is_shown_as_the_address_it_goes_to() {
|
||
let _gpu = gpu();
|
||
let h = prompt_for("http://3232235777/admin");
|
||
h.get_by_label("192.168.1.1");
|
||
assert!(h.query_by_label_contains("alphabet").is_none());
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
#[test]
|
||
fn an_absolute_gotor_waits_for_consent_and_keeps_its_destination_across_tabs() {
|
||
let _gpu = gpu();
|
||
let dir = std::env::temp_dir().join(format!("pdfcraft-ui-gotor-consent-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
let target = dir.join("target with spaces.pdf");
|
||
let file = target.to_str().unwrap();
|
||
let mut h = remote_harness(None, file, "[1 /FitH 200]");
|
||
let source = h.state().views[0].id;
|
||
click_remote_link(&mut h);
|
||
let pending = h.state().pending_remote_link.as_ref().unwrap();
|
||
assert_eq!(pending.source, source);
|
||
assert_eq!(pending.path, target);
|
||
h.get_by_label(file);
|
||
h.get_by_label("Allow");
|
||
assert_eq!(h.state().views.len(), 1);
|
||
assert_eq!(h.state().session.docs().len(), 1);
|
||
assert!(h.state().toast.is_none(), "the nonexistent target is not accessed before consent");
|
||
assert!(h.state().password_prompt.is_none());
|
||
// The target only appears AFTER the confirmation is displayed.
|
||
let target_bytes = linked_pages(2, None);
|
||
std::fs::write(&target, &target_bytes).unwrap();
|
||
h.state_mut().open_bytes("other.pdf", None, fixture()).unwrap();
|
||
assert_eq!(h.state().active, Some(1));
|
||
h.run_steps(3);
|
||
h.get_by_label("Allow").click();
|
||
support::wait_until(&mut h, "approved absolute target", |app| app.views.len() == 3);
|
||
assert!(h.state().pending_remote_link.is_none());
|
||
assert_eq!(h.state().views.len(), 3);
|
||
assert_eq!(h.state().views[2].current, 1);
|
||
assert_eq!(h.state().views[2].fit, pdfcraft_ui_egui::canvas::Fit::Width);
|
||
assert_eq!(h.state().active, Some(2));
|
||
let target_doc = h.state().session.get(h.state().views[2].id).unwrap();
|
||
assert_eq!(target_doc.path.as_deref(), Some(file));
|
||
assert_eq!(target_doc.bytes.as_slice(), target_bytes.as_slice());
|
||
assert!(!h.state().session.get(source).unwrap().dirty);
|
||
assert_eq!(h.state().last_opened_url, None);
|
||
std::fs::remove_dir_all(dir).unwrap();
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
#[test]
|
||
fn cancelling_an_absolute_gotor_by_button_escape_or_dismissal_changes_nothing() {
|
||
let _gpu = gpu();
|
||
let target = std::env::temp_dir().join(format!("pdfcraft-ui-gotor-cancel-{}.pdf", std::process::id()));
|
||
for answer in ["button", "escape", "outside"] {
|
||
let mut h = remote_harness(None, target.to_str().unwrap(), "[1 /Fit]");
|
||
let source = h.state().views[0].id;
|
||
let before = h.state().session.get(source).unwrap().bytes.clone();
|
||
click_remote_link(&mut h);
|
||
assert!(h.state().pending_remote_link.is_some());
|
||
match answer {
|
||
"button" => h.get_by_label("Cancel").click(),
|
||
"escape" => h.key_press(egui::Key::Escape),
|
||
_ => click_at(&mut h, egui::pos2(5.0, 5.0)),
|
||
}
|
||
h.run_steps(3);
|
||
assert!(h.state().pending_remote_link.is_none(), "{answer}");
|
||
assert_eq!(h.state().views.len(), 1);
|
||
assert_eq!(h.state().session.docs().len(), 1);
|
||
assert_eq!(h.state().active, Some(0));
|
||
assert_eq!(h.state().views[0].current, 0);
|
||
let doc = h.state().session.get(source).unwrap();
|
||
assert_eq!(doc.bytes, before);
|
||
assert!(!doc.dirty);
|
||
assert!(doc.can_undo().is_none());
|
||
assert!(h.state().toast.is_none());
|
||
assert!(h.state().password_prompt.is_none());
|
||
}
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
#[test]
|
||
fn consenting_to_absolute_gotor_still_validates_the_target() {
|
||
let _gpu = gpu();
|
||
let dir = std::env::temp_dir().join(format!("pdfcraft-ui-gotor-consent-invalid-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
std::fs::create_dir_all(dir.join("folder.pdf")).unwrap();
|
||
std::fs::write(dir.join("notes.txt"), b"text").unwrap();
|
||
std::fs::write(dir.join("broken.pdf"), b"not a PDF").unwrap();
|
||
#[cfg_attr(not(unix), allow(unused_mut))]
|
||
let mut cases = vec![
|
||
("missing.pdf", "Couldn't follow linked PDF: the linked file couldn't be read:"),
|
||
("folder.pdf", "Couldn't follow linked PDF: the linked path is not a regular file"),
|
||
("notes.txt", "Couldn't follow linked PDF: the linked file is not a .pdf file"),
|
||
("broken.pdf", "Couldn't open broken.pdf: the file is not a readable PDF:"),
|
||
];
|
||
#[cfg(unix)]
|
||
{
|
||
std::os::unix::fs::symlink("/dev/zero", dir.join("zero.pdf")).unwrap();
|
||
cases.push(("zero.pdf", "Couldn't follow linked PDF: the linked path is not a regular file"));
|
||
std::fs::File::create(dir.join("large.pdf")).unwrap().set_len(pdfcraft_engine::MAX_REMOTE_DOCUMENT_BYTES + 1).unwrap();
|
||
cases.push(("large.pdf", "Couldn't follow linked PDF: the linked file is too large to open"));
|
||
}
|
||
for (name, expected_error) in cases {
|
||
let target = dir.join(name);
|
||
let mut h = remote_harness(None, target.to_str().unwrap(), "[0 /Fit]");
|
||
let source = h.state().views[0].id;
|
||
let before = h.state().session.get(source).unwrap().bytes.clone();
|
||
click_remote_link(&mut h);
|
||
assert!(h.state().pending_remote_link.is_some());
|
||
assert!(h.state().toast.is_none(), "{name} isn't checked before consent");
|
||
h.get_by_label("Allow").click();
|
||
support::wait_until(&mut h, "approved invalid target refusal", |app| {
|
||
app.toast.as_ref().is_some_and(|(message, _)| message.starts_with(expected_error))
|
||
});
|
||
assert!(h.state().pending_remote_link.is_none());
|
||
assert_eq!(h.state().views.len(), 1, "{name}");
|
||
assert!(h.state().toast.is_some(), "{name} is refused after consent");
|
||
assert_eq!(h.state().session.docs().len(), 1);
|
||
assert_eq!(h.state().active_ids().map(|(_, id)| id), Some(source));
|
||
assert_eq!(h.state().views[0].current, 0);
|
||
let doc = h.state().session.get(source).unwrap();
|
||
assert_eq!(doc.bytes, before);
|
||
assert!(!doc.dirty);
|
||
assert!(doc.can_undo().is_none());
|
||
assert!(h.state().password_prompt.is_none());
|
||
assert!(h.state().last_opened_url.is_none());
|
||
}
|
||
std::fs::remove_dir_all(dir).unwrap();
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
#[test]
|
||
fn an_approved_absolute_gotor_preserves_password_retries_and_cancellation() {
|
||
let _gpu = gpu();
|
||
let dir = std::env::temp_dir().join(format!("pdfcraft-ui-gotor-consent-password-{}", std::process::id()));
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
let target = dir.join("encrypted.pdf");
|
||
std::fs::write(&target, linked_pages(2, Some("test-password"))).unwrap();
|
||
for cancel in [false, true] {
|
||
let mut h = remote_harness(None, target.to_str().unwrap(), "[1 /FitH 200]");
|
||
click_remote_link(&mut h);
|
||
assert!(h.state().password_prompt.is_none());
|
||
h.get_by_label("Allow").click();
|
||
support::wait_until(&mut h, "approved encrypted target prompt", |app| {
|
||
app.password_prompt.as_ref().is_some_and(|p| p.name == "encrypted.pdf")
|
||
});
|
||
assert!(h.state().password_prompt.is_some());
|
||
h.state_mut().submit_password(Some("wrong-password".into()));
|
||
support::wait_until(&mut h, "absolute target wrong-password reply", |app| {
|
||
app.password_prompt.as_ref().is_some_and(|p| p.name == "encrypted.pdf" && p.error.as_deref() == Some("Incorrect password. Try again."))
|
||
});
|
||
assert!(h.state().password_prompt.as_ref().unwrap().error.is_some());
|
||
assert_eq!(h.state().views.len(), 1);
|
||
if cancel {
|
||
h.state_mut().submit_password(None);
|
||
assert!(h.state().password_prompt.is_none());
|
||
assert_eq!(h.state().views.len(), 1);
|
||
} else {
|
||
h.state_mut().submit_password(Some("test-password".into()));
|
||
support::wait_until(&mut h, "authenticated absolute target", |app| app.password_prompt.is_none() && app.views.len() == 2);
|
||
assert_eq!(h.state().views.len(), 2);
|
||
assert_eq!(h.state().views[1].current, 1);
|
||
}
|
||
assert!(h.state().pending_remote_link.is_none());
|
||
}
|
||
std::fs::remove_dir_all(dir).unwrap();
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
#[test]
|
||
fn closing_the_source_invalidates_pending_absolute_gotor_consent() {
|
||
let _gpu = gpu();
|
||
let target = std::env::temp_dir().join(format!("pdfcraft-ui-gotor-closed-source-{}.pdf", std::process::id()));
|
||
let mut h = remote_harness(None, target.to_str().unwrap(), "[0 /Fit]");
|
||
click_remote_link(&mut h);
|
||
h.state_mut().close_tab(0);
|
||
h.state_mut().resolve_pending_remote_link(true);
|
||
assert!(h.state().views.is_empty());
|
||
assert!(h.state().session.docs().is_empty());
|
||
assert!(h.state().pending_remote_link.is_none());
|
||
assert!(h.state().toast.is_none());
|
||
}
|
||
|
||
fn overlapping_annotation_fixture(subtype: &str, internal: bool) -> Vec<u8> {
|
||
overlapping_annotation_fixture_with_form(subtype, internal, false)
|
||
}
|
||
|
||
fn overlapping_annotation_fixture_with_form(subtype: &str, internal: bool, with_form: bool) -> Vec<u8> {
|
||
let target = if internal { "/Dest [6 0 R /Fit]".to_owned() } else { format!("/A << /S /URI /URI ({LINK_URL}) >>") };
|
||
let mut objs = vec![
|
||
if with_form {
|
||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [7 0 R] /DA (/Helv 0 Tf 0 g) >> >>".to_owned()
|
||
} else {
|
||
"<< /Type /Catalog /Pages 2 0 R >>".to_owned()
|
||
},
|
||
"<< /Type /Pages /Kids [3 0 R 6 0 R] /Count 2 /MediaBox [0 0 300 400] >>".to_owned(),
|
||
if with_form {
|
||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 7 0 R] >>".to_owned()
|
||
} else {
|
||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>".to_owned()
|
||
},
|
||
format!("<< /Type /Annot /Subtype /Link /Rect [100 300 250 350] /Border [0 0 0] {target} >>"),
|
||
format!(
|
||
"<< /Type /Annot /Subtype /{subtype} /Rect [30 300 200 350] /QuadPoints [30 350 200 350 30 300 200 300] /C [1 1 0] /Contents (Review this text) /F 4 >>"
|
||
),
|
||
"<< /Type /Page /Parent 2 0 R >>".to_owned(),
|
||
];
|
||
if with_form {
|
||
objs.push(format!("<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (overlap-button) /Rect [100 300 200 350] /P 3 0 R /A << /S /URI /URI ({BUTTON_URL}) >> >>"));
|
||
}
|
||
let mut out = b"%PDF-1.7\n".to_vec();
|
||
let mut offsets = Vec::new();
|
||
for (i, object) in objs.iter().enumerate() {
|
||
offsets.push(out.len());
|
||
out.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
|
||
}
|
||
let xref = out.len();
|
||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||
for offset in offsets {
|
||
out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||
}
|
||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||
out
|
||
}
|
||
|
||
fn overlapping_annotation_harness(bytes: Vec<u8>) -> Harness<'static, PdfCraftApp> {
|
||
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("highlighted-link.pdf", None, bytes).unwrap();
|
||
app.set_option("left", "closed").unwrap();
|
||
app.set_option("panel", "none").unwrap();
|
||
app.set_option("zoom", "150").unwrap();
|
||
app
|
||
});
|
||
h.run_steps(8);
|
||
h
|
||
}
|
||
|
||
#[test]
|
||
fn highlighted_link_still_requires_confirmation() {
|
||
let _gpu = gpu();
|
||
let mut h = overlapping_annotation_harness(overlapping_annotation_fixture("Highlight", false));
|
||
click_link(&mut h);
|
||
assert_eq!(h.state().pending_link, pending(LINK_URL, LinkOrigin::Link));
|
||
assert_eq!(h.state().last_opened_url, None);
|
||
assert_eq!(h.state().views[0].comments.selected, None);
|
||
h.get_by_label("Open link").click();
|
||
h.run_steps(3);
|
||
assert_eq!(h.state().last_opened_url.as_deref(), Some(LINK_URL));
|
||
assert_eq!(h.state().pending_link, None);
|
||
h.run_steps(3);
|
||
assert_eq!(h.state().pending_link, None, "approval must not create another prompt");
|
||
}
|
||
|
||
#[test]
|
||
fn highlighted_link_cancel_and_escape_do_not_open_url() {
|
||
let _gpu = gpu();
|
||
for escape in [false, true] {
|
||
let mut h = overlapping_annotation_harness(overlapping_annotation_fixture("Highlight", false));
|
||
click_link(&mut h);
|
||
assert_eq!(h.state().pending_link, pending(LINK_URL, LinkOrigin::Link));
|
||
if escape {
|
||
h.key_press(egui::Key::Escape);
|
||
} else {
|
||
h.get_by_label("Cancel").click();
|
||
}
|
||
h.run_steps(3);
|
||
assert_eq!(h.state().pending_link, None);
|
||
assert_eq!(h.state().last_opened_url, None);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn highlighted_internal_link_navigates_after_save_and_reopen() {
|
||
let _gpu = gpu();
|
||
let bytes = overlapping_annotation_fixture("Highlight", true);
|
||
let mut session = pdfcraft_engine::Session::new();
|
||
let id = session.open("highlighted-link.pdf", None, std::sync::Arc::new(bytes), None).unwrap();
|
||
let saved = session.save_bytes(id).unwrap().as_ref().clone();
|
||
let mut h = overlapping_annotation_harness(saved);
|
||
assert_eq!(h.state().views[0].current, 0);
|
||
click_link(&mut h);
|
||
assert_eq!(h.state().views[0].current, 1);
|
||
assert_eq!(h.state().pending_link, None);
|
||
assert_eq!(h.state().last_opened_url, None);
|
||
}
|
||
|
||
#[test]
|
||
fn highlight_outside_link_remains_selectable() {
|
||
let _gpu = gpu();
|
||
let mut h = overlapping_annotation_harness(overlapping_annotation_fixture("Highlight", false));
|
||
let rect = h.state().views[0].page_screen_rect(0).unwrap();
|
||
let at = egui::pos2(rect.min.x + rect.width() * (60.0 / 300.0), rect.min.y + rect.height() * (1.0 - 325.0 / 400.0));
|
||
click_at(&mut h, at);
|
||
assert_eq!(h.state().views[0].comments.selected, Some((0, 1)));
|
||
assert_eq!(h.state().pending_link, None);
|
||
assert_eq!(h.state().last_opened_url, None);
|
||
}
|
||
|
||
#[test]
|
||
fn other_annotations_over_links_keep_select_click() {
|
||
let _gpu = gpu();
|
||
for subtype in ["Text", "Square"] {
|
||
let mut h = overlapping_annotation_harness(overlapping_annotation_fixture(subtype, false));
|
||
click_link(&mut h);
|
||
assert_eq!(h.state().views[0].comments.selected, Some((0, 1)), "{subtype}");
|
||
assert_eq!(h.state().pending_link, None, "{subtype}");
|
||
assert_eq!(h.state().last_opened_url, None, "{subtype}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn form_widget_keeps_priority_over_highlighted_link() {
|
||
let _gpu = gpu();
|
||
let mut h = overlapping_annotation_harness(overlapping_annotation_fixture_with_form("Highlight", false, true));
|
||
click_link(&mut h);
|
||
assert_eq!(h.state().pending_link, pending(BUTTON_URL, LinkOrigin::Button));
|
||
assert_eq!(h.state().last_opened_url, None);
|
||
h.get_by_label("Cancel").click();
|
||
h.run_steps(3);
|
||
assert_eq!(h.state().pending_link, None);
|
||
assert_eq!(h.state().last_opened_url, None);
|
||
}
|
||
|
||
/// A comment's gesture keeps the page's input until its button is up: a press held through it and
|
||
/// released on a highlighted link follows no link. (A gesture left over once the button is up
|
||
/// ends: `a_comment_move_whose_release_the_pages_missed_ends_once_the_button_is_up`.)
|
||
#[test]
|
||
fn active_comment_gesture_keeps_highlighted_link_click() {
|
||
let _gpu = gpu();
|
||
let mut h = overlapping_annotation_harness(overlapping_annotation_fixture("Highlight", false));
|
||
let r = h.state().views[0].page_screen_rect(0).unwrap();
|
||
let at = egui::pos2(r.min.x + r.width() * 0.5, r.min.y + r.height() * (1.0 - 325.0 / 400.0));
|
||
h.hover_at(at);
|
||
h.run_steps(2);
|
||
h.drag_at(at);
|
||
h.run_steps(1);
|
||
h.state_mut().views[0].comments.gesture = Some(pdfcraft_ui_egui::comments::Gesture::Move { page: 0, index: 1, from: at });
|
||
h.drop_at(at);
|
||
h.run_steps(4);
|
||
assert_eq!(h.state().pending_link, None);
|
||
assert_eq!(h.state().last_opened_url, None);
|
||
}
|
||
|
||
#[test]
|
||
fn highlighted_link_context_click_selects_comment_without_opening_url() {
|
||
let _gpu = gpu();
|
||
let mut h = overlapping_annotation_harness(overlapping_annotation_fixture("Highlight", false));
|
||
let r = h.state().views[0].page_screen_rect(0).unwrap();
|
||
let at = egui::pos2(r.min.x + r.width() * 0.5, r.min.y + r.height() * (1.0 - 325.0 / 400.0));
|
||
h.hover_at(at);
|
||
h.run_steps(1);
|
||
for pressed in [true, false] {
|
||
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() });
|
||
}
|
||
h.run_steps(3);
|
||
assert_eq!(h.state().views[0].comments.selected, Some((0, 1)));
|
||
assert_eq!(h.state().pending_link, None);
|
||
assert_eq!(h.state().last_opened_url, None);
|
||
}
|
||
|
||
#[test]
|
||
fn highlighted_link_can_be_requested_again_after_cancel() {
|
||
let _gpu = gpu();
|
||
let mut h = overlapping_annotation_harness(overlapping_annotation_fixture("Highlight", false));
|
||
for _ in 0..2 {
|
||
click_link(&mut h);
|
||
assert_eq!(h.state().pending_link, pending(LINK_URL, LinkOrigin::Link));
|
||
assert_eq!(h.state().last_opened_url, None);
|
||
h.get_by_label("Cancel").click();
|
||
h.run_steps(120);
|
||
assert_eq!(h.state().pending_link, None);
|
||
assert_eq!(h.state().last_opened_url, None);
|
||
}
|
||
}
|
||
|
||
/// One 300×400 page of plain text, no link annotations except over the fourth line: a web
|
||
/// address, a `www.` address, an email address, an address under a link annotation, and text
|
||
/// with no address (#4207). Each line starts at x 40, 14 points high, at the y given.
|
||
fn text_links_fixture() -> Vec<u8> {
|
||
let content = [
|
||
(350, "https://example.com/plain-text-url."),
|
||
(300, "www.example.com/no-scheme"),
|
||
(250, "someone@example.com"),
|
||
(200, "https://example.org/covered"),
|
||
(150, "Nothing to open here"),
|
||
]
|
||
.iter()
|
||
.map(|(y, s)| format!("BT /F1 14 Tf 40 {y} Td ({s}) Tj ET\n"))
|
||
.collect::<String>();
|
||
let objs = [
|
||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".to_string(),
|
||
"<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R /Annots [6 0 R] >>".to_string(),
|
||
format!("<< /Length {} >>\nstream\n{content}endstream", content.len()),
|
||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
|
||
format!("<< /Type /Annot /Subtype /Link /Rect [30 195 290 215] /Border [0 0 0] /A << /S /URI /URI ({LINK_URL}) >> >>"),
|
||
];
|
||
let mut out = b"%PDF-1.7\n".to_vec();
|
||
let mut offs = Vec::new();
|
||
for (i, o) in objs.iter().enumerate() {
|
||
offs.push(out.len());
|
||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||
}
|
||
let x = out.len();
|
||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||
for o in offs {
|
||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||
}
|
||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||
out
|
||
}
|
||
|
||
/// Click the text line whose baseline is at `y` until the page's text has arrived and the click
|
||
/// asks to open something, or 30 seconds pass.
|
||
fn click_text_until_pending(h: &mut Harness<'static, PdfCraftApp>, y: f32) -> bool {
|
||
support::step_until(h, |h| {
|
||
click_link_at(h, y + 4.0);
|
||
h.state().pending_link.is_some()
|
||
})
|
||
}
|
||
|
||
/// Web, `www.` and email addresses written as plain page text open like links (#4207): the same
|
||
/// confirmation, `https://` for `www.` and `mailto:` for email; a link annotation over an address
|
||
/// wins, text without an address opens nothing, and the setting turns it off.
|
||
#[test]
|
||
fn addresses_written_in_the_page_text_open_like_links() {
|
||
let _gpu = gpu();
|
||
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.open_bytes("text-links.pdf", None, text_links_fixture()).unwrap();
|
||
app.set_option("left", "closed").unwrap();
|
||
app.set_option("panel", "none").unwrap();
|
||
app.set_option("zoom", "150").unwrap();
|
||
app
|
||
});
|
||
h.run_steps(8);
|
||
for (y, url) in [
|
||
(350.0, "https://example.com/plain-text-url"),
|
||
(300.0, "https://www.example.com/no-scheme"),
|
||
(250.0, "mailto:someone@example.com"),
|
||
(200.0, LINK_URL),
|
||
] {
|
||
assert!(click_text_until_pending(&mut h, y), "a click on the line at {y} asked to open nothing");
|
||
assert_eq!(h.state().pending_link, pending(url, LinkOrigin::Link), "line at {y}");
|
||
assert_eq!(h.state().last_opened_url, None, "nothing opens before the user answers");
|
||
h.get_by_label("Cancel").click();
|
||
h.run_steps(4);
|
||
assert_eq!(h.state().pending_link, None);
|
||
}
|
||
// The page's text is known by now: plain text with no address opens nothing.
|
||
click_link_at(&mut h, 154.0);
|
||
assert_eq!(h.state().pending_link, None, "text without an address");
|
||
// Preferences can turn it off; the link annotation still works.
|
||
h.state_mut().set_option("text-links", "false").unwrap();
|
||
h.run_steps(2);
|
||
click_link_at(&mut h, 354.0);
|
||
assert_eq!(h.state().pending_link, None, "turned off");
|
||
click_link_at(&mut h, 204.0);
|
||
assert_eq!(h.state().pending_link, pending(LINK_URL, LinkOrigin::Link), "a link annotation is unaffected");
|
||
}
|
||
|
||
/// A drag that starts on an address written as page text selects its text, as from any other
|
||
/// text; only a click opens it (#4207, review).
|
||
#[test]
|
||
fn a_drag_from_an_address_in_the_page_text_selects_it() {
|
||
let _gpu = gpu();
|
||
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.open_bytes("text-links.pdf", None, text_links_fixture()).unwrap();
|
||
app.set_option("left", "closed").unwrap();
|
||
app.set_option("panel", "none").unwrap();
|
||
app.set_option("zoom", "150").unwrap();
|
||
app
|
||
});
|
||
h.run_steps(8);
|
||
// Once a click asks to open the address, its text and the address are known.
|
||
assert!(click_text_until_pending(&mut h, 350.0), "the address opens on a click");
|
||
h.get_by_label("Cancel").click();
|
||
h.run_steps(4);
|
||
// Glyphs 2 to 12 of the first line are inside "https://example.com/plain-text-url.".
|
||
let (from, to) = {
|
||
let v = &h.state().views[0];
|
||
(v.glyph_screen_pos(0, 2).expect("text layer"), v.glyph_screen_pos(0, 12).expect("glyph"))
|
||
};
|
||
h.hover_at(from);
|
||
h.run_steps(1);
|
||
h.drag_at(from);
|
||
h.run_steps(1);
|
||
for k in 1..=4 {
|
||
h.hover_at(from + (to - from) * (k as f32 / 4.0));
|
||
h.run_steps(1);
|
||
}
|
||
h.drop_at(to);
|
||
h.run_steps(3);
|
||
let selected = h.state().views[0].selected_text();
|
||
assert!(selected.as_deref().is_some_and(|t| t.starts_with("tp")), "the drag selects the address's text: {selected:?}");
|
||
assert_eq!(h.state().pending_link, None, "a drag opens nothing");
|
||
}
|
||
|
||
/// One 300×400 page with two links (ISO 32000-2 §12.5.6.5, §12.6.4.8): at y 300..350 a relative
|
||
/// address the catalog's `/URI /Base` resolves, and at y 200..250 a link whose `/Rect` spans the
|
||
/// page but whose `/QuadPoints` cover only x 50..120.
|
||
fn base_and_quads_fixture() -> Vec<u8> {
|
||
let objs = [
|
||
"<< /Type /Catalog /Pages 2 0 R /URI << /Base (https://example.org/base/) >> >>",
|
||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>",
|
||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>",
|
||
"<< /Type /Annot /Subtype /Link /Rect [50 300 250 350] /Border [0 0 0] /A << /S /URI /URI (page.html) >> >>",
|
||
"<< /Type /Annot /Subtype /Link /Rect [50 200 250 250] /QuadPoints [50 200 120 200 120 250 50 250] /Border [0 0 0] /A << /S /URI /URI (https://example.org/first-word) >> >>",
|
||
];
|
||
let mut out = b"%PDF-1.7\n".to_vec();
|
||
let mut offs = Vec::new();
|
||
for (i, o) in objs.iter().enumerate() {
|
||
offs.push(out.len());
|
||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||
}
|
||
let x = out.len();
|
||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||
for o in offs {
|
||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||
}
|
||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||
out
|
||
}
|
||
|
||
/// A relative link opens at the address the document's base gives it, through the usual
|
||
/// confirmation; a link is active only in its `/QuadPoints`, not over the rest of its `/Rect`.
|
||
#[test]
|
||
fn relative_links_use_the_base_and_quad_points_limit_where_a_link_is_active() {
|
||
let _gpu = gpu();
|
||
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.open_bytes("base.pdf", None, base_and_quads_fixture()).unwrap();
|
||
app.set_option("left", "closed").unwrap();
|
||
app.set_option("panel", "none").unwrap();
|
||
app.set_option("zoom", "150").unwrap();
|
||
app
|
||
});
|
||
h.run_steps(8);
|
||
click_link(&mut h);
|
||
assert_eq!(h.state().pending_link, pending("https://example.org/base/page.html", LinkOrigin::Link));
|
||
h.get_by_label("Cancel").click();
|
||
h.run_steps(4);
|
||
let point = |h: &Harness<'static, PdfCraftApp>, x: f32, y: f32| {
|
||
let r = h.state().views[0].page_screen_rect(0).expect("page 1 on screen");
|
||
egui::pos2(r.min.x + r.width() * (x / 300.0), r.min.y + r.height() * (1.0 - y / 400.0))
|
||
};
|
||
// Inside /Rect, outside the quadrilateral: nothing.
|
||
let away = point(&h, 200.0, 225.0);
|
||
click_at(&mut h, away);
|
||
assert_eq!(h.state().pending_link, None, "the rest of /Rect is not the link");
|
||
let on = point(&h, 80.0, 225.0);
|
||
click_at(&mut h, on);
|
||
assert_eq!(h.state().pending_link, pending("https://example.org/first-word", LinkOrigin::Link));
|
||
}
|