diff --git a/crates/annot/src/links.rs b/crates/annot/src/links.rs index e2199e4d23..88ac097b74 100644 --- a/crates/annot/src/links.rs +++ b/crates/annot/src/links.rs @@ -503,10 +503,24 @@ fn email_at(chars: &[char], start: usize) -> Option<(usize, String)> { Some((end, format!("mailto:{address}"))) } +/// The longest web address [`find_urls`] reads, in characters (browsers and servers commonly +/// stop near 2,000). Each address it might be is read this far at most, so text whose brackets +/// never close (`https://a/(https://a/(…`, without a space) costs this much per address in it, +/// not a pass over the rest of the page each. +const MAX_URL_CHARS: usize = 2048; + /// Find web addresses in text: `http(s)://…`, `www.…` and bare e-mail addresses become /// `mailto:`. Returns character ranges and the URI for each. +/// +/// No other scheme is ever found, and a `www.` or e-mail address that is part of one written with +/// another scheme (`ftp://www.example.com`, `smb://admin@example.org`) is not taken out of it: +/// it would open with a scheme the text doesn't say. Brackets inside a web address belong to it +/// when they pair up (`…/wiki/Mercury_(planet)`); an unpaired one ends it. pub fn find_urls(chars: &[char]) -> Vec<(std::ops::Range, String)> { let url_char = |c: char| !c.is_whitespace() && !"<>\"'()[]{}".contains(c); + // Characters found inside an address but not between words: a `www.` after one is part of + // a longer address, and so is an e-mail address after a slash. + let inside_address = |i: usize, set: &str| i > 0 && chars.get(i - 1).is_some_and(|c| set.contains(*c)); // Case-insensitive prefix test that never steps past the end. Comparing against the original // characters keeps every offset in `chars`'s own index space: case-folding a copy can change // its length (U+0130 becomes "i" plus a combining dot), so a folded copy must never supply @@ -517,11 +531,38 @@ pub fn find_urls(chars: &[char]) -> Vec<(std::ops::Range, String)> { let mut i = 0; while i < chars.len() { let boundary = i == 0 || chars.get(i - 1).is_some_and(|c| !c.is_alphanumeric()); - if boundary && (starts_with(i, "http://") || starts_with(i, "https://") || starts_with(i, "www.")) { + let www = starts_with(i, "www.") && !inside_address(i, "/\\:@.-_~%+=&#"); + if boundary && (starts_with(i, "http://") || starts_with(i, "https://") || www) { let mut j = i; - while chars.get(j).is_some_and(|c| url_char(*c)) { + // Brackets pair up inside the address: `depth` are open, the outermost at `opened`. + let (mut depth, mut opened) = (0usize, i); + let mut too_long = false; + loop { + if j.saturating_sub(i) >= MAX_URL_CHARS { + too_long = true; + break; + } + match chars.get(j) { + Some('(') => { + if depth == 0 { + opened = j; + } + depth = depth.saturating_add(1); + } + Some(')') if depth > 0 => depth -= 1, + Some(c) if url_char(*c) => {} + _ => break, + } j += 1; } + // A bracket left open isn't the address's: it ends before it. A run of address + // characters longer than any address is none, and the search goes on after it. + if depth > 0 { + j = opened; + } else if too_long { + i = j; + continue; + } // Trailing punctuation belongs to the sentence. while j > i && chars.get(j - 1).is_some_and(|c| ".,;:!?".contains(*c)) { j -= 1; @@ -534,7 +575,10 @@ pub fn find_urls(chars: &[char]) -> Vec<(std::ops::Range, String)> { continue; } } - if boundary && let Some((end, uri)) = email_at(chars, i) { + if boundary + && !inside_address(i, "/\\") + && let Some((end, uri)) = email_at(chars, i) + { out.push((i..end, uri)); i = end; continue; diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index b0220b1023..526cd02770 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1314,6 +1314,44 @@ fn urls_are_found_after_length_changing_lowercase() { assert_eq!(range, 21..39, "range is in source-character indices"); } +/// Review on #4472: an address inside one written with another scheme isn't taken out of it +/// (`ftp://www.example.com` opened as `http://www.example.com`), and brackets that pair up +/// inside an address belong to it. +#[test] +fn urls_keep_their_written_scheme_and_paired_brackets() { + let found = |s: &str| -> Vec { crate::links::find_urls(&s.chars().collect::>()).into_iter().map(|(_, u)| u).collect() }; + for s in + ["ftp://www.example.com/pub", "file://www.example.com/x", "javascript:www.example.com", "ftp://user@example.com/x", "smb://a@example.org/s"] + { + assert_eq!(found(s), Vec::::new(), "{s}"); + } + assert_eq!(found("mailto:someone@example.com and (www.example.com)"), ["mailto:someone@example.com", "http://www.example.com"]); + assert_eq!(found("See https://en.wikipedia.org/wiki/Mercury_(planet)."), ["https://en.wikipedia.org/wiki/Mercury_(planet)"]); + assert_eq!(found("(https://example.com/a_(b)_c)"), ["https://example.com/a_(b)_c"]); + assert_eq!(found("https://example.com/a(b c"), ["https://example.com/a"]); + // Deeply nested or unbalanced brackets end the address without trouble. + let deep = format!("https://example.com/{}x{}", "(".repeat(10_000), ")".repeat(9_999)); + assert_eq!(found(&deep), ["https://example.com/"]); +} + +/// Review on #4472: brackets that never close in text without spaces (`https://a/(` again and +/// again) made each address in it read the rest of the text, then start again one character on, +/// a pass over the page per address. Each is read 2,048 characters at most now, and the +/// addresses around them are still found. A run of address characters longer than that is no +/// address. +#[test] +fn unclosed_brackets_in_text_without_spaces_are_read_once_per_address() { + let found = |s: &str| -> Vec { crate::links::find_urls(&s.chars().collect::>()).into_iter().map(|(_, u)| u).collect() }; + let text = format!("www.before.org {} www.after.org", "https://a/(".repeat(60_000)); + let started = std::time::Instant::now(); + assert_eq!(found(&text), ["http://www.before.org", "http://www.after.org"]); + assert!(started.elapsed().as_secs() < 5, "{:?}", started.elapsed()); + let long = format!("https://example.com/{} www.after.org", "a".repeat(3000)); + assert_eq!(found(&long), ["http://www.after.org"]); + let fits = format!("https://example.com/{}", "a".repeat(2000)); + assert_eq!(found(&fits), std::slice::from_ref(&fits)); +} + #[test] fn url_scheme_and_www_match_case_insensitively() { let t: Vec = "HTTP://Example.COM".chars().collect(); diff --git a/crates/automation/src/links.rs b/crates/automation/src/links.rs index a3bf9cd28b..546413f6fc 100644 --- a/crates/automation/src/links.rs +++ b/crates/automation/src/links.rs @@ -113,7 +113,41 @@ impl Automation { v }) .collect(); - Ok(json!({ "count": items.len(), "links": items })) + let mut out = json!({ "count": items.len(), "links": items }); + // Addresses written as plain page text, which the viewer makes clickable (#4207). Reading + // the pages' text costs more than the list above, so only on request. + if a.opt_bool("text_links")?.unwrap_or(false) { + let id = doc.id; + // One share of the pages at a time (`MAX_TEXT_LINK_PAGES_PER_SCAN`), from the 1-based + // `text_links_from`; `text_links_next_page` says where the next share starts. + let pages = doc.info.pages.len().max(1); + let from = match a.opt_int("text_links_from")? { + None => 0, + Some(p) => usize::try_from(p) + .ok() + .filter(|p| (1..=pages).contains(p)) + .map(|p| p - 1) + .ok_or_else(|| bad(format!("text_links_from must be a page from 1 to {pages}")))?, + }; + let scan = self.session.text_links(id, from); + if let Some(next) = scan.next_page { + out["text_links_next_page"] = json!(next.saturating_add(1)); + } + let found: Vec = scan + .links + .into_iter() + .map(|(page, l)| { + let mut v = json!({ "page": page + 1, "rects": l.rects, "url": l.uri }); + // What the viewer does on a click: the same check every document link gets. + if let Err(e) = pdfcraft_engine::links::document_url(&l.uri) { + v["blocked"] = json!(e.to_string()); + } + v + }) + .collect(); + out["text_links"] = json!(found); + } + Ok(out) } pub(crate) fn link_open(&mut self, a: &Args) -> Result { diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 680759e035..75d0cb0bf7 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -824,9 +824,9 @@ pub fn tools() -> Vec { t("content_delete", "Delete added content", "Delete an added item (page, index from content_list). Undoable.") .destructive() .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 } }), &["doc", "page", "index"])), - t("link_list", "List links", "Every link: page, 1-based index (for link_edit/link_delete), rect (points from the top-left of the page), and where it goes (url, or to_page with its zoom).") + t("link_list", "List links", "Every link: page, 1-based index (for link_edit/link_delete), rect (points from the top-left of the page), and where it goes (url, or to_page with its zoom). With text_links: true, also the web and email addresses written as plain page text with no link over them, which the viewer opens on a click: page, rects (one per line), url, and blocked (why it won't open) when the link check refuses it. Only http, https and mailto addresses are found. At most 100 pages are read per call, from text_links_from (default 1); text_links_next_page then gives the page to continue from.") .ro() - .with(schema(json!({ "doc": doc() }), &["doc"])), + .with(schema(json!({ "doc": doc(), "text_links": { "type": "boolean" }, "text_links_from": { "type": "integer", "minimum": 1 } }), &["doc"])), t("link_open", "Open a linked PDF", "Follow a local GoToR link in PdfCraft: open the referenced PDF as a new document, respecting --root. page and index use link_list numbering. Named destinations are reported but not applied. Absolute file specifications require allow_absolute: true after reviewing the target; relative links need no opt-in.") .with(schema( json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 }, "password": { "type": "string", "description": "Optional password for the linked PDF." }, "allow_absolute": { "type": "boolean", "description": "Explicitly allow an absolute local GoToR target (default false). Existing validation and --root confinement still apply." } }), @@ -857,7 +857,7 @@ pub fn tools() -> Vec { t("link_delete", "Delete a link", "Delete one link (page, index from link_list). Undoable.") .destructive() .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 } }), &["doc", "page", "index"])), - t("links_from_urls", "Create links from URLs", "Find web addresses (http://, https://, www.) in the text of every page and make them clickable links. Returns the URLs. Undoable.") + t("links_from_urls", "Create links from URLs", "Find web addresses (http://, https://, www.) in the text of every page and make them clickable links, leaving out text on a layer the viewer hides (layer_set). Returns the URLs. Undoable.") .cmd("edit.links_from_urls") .with(schema(json!({ "doc": doc() }), &["doc"])), t("links_remove", "Remove all links", "Remove every link in the document (or on the given pages). Undoable.") diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 7bf330ab63..ee9df0492f 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2107,6 +2107,162 @@ fn destinations_are_listed_with_their_position() { assert_eq!((links["links"][2]["url"].clone(), links["links"][2].get("destination")), (json!("https://example.com"), None)); } +/// Web, `www.` and email addresses written as plain page text, which the viewer opens on a click +/// (#4207), are listed by link_list with text_links: true; one under a link annotation is not. +#[test] +fn addresses_written_as_page_text_are_listed_with_text_links() { + let dir = workdir("text-links"); + let content = [ + (700, "Visit https://example.com/plain-text-url."), + (650, "or www.example.com/no-scheme"), + (600, "Write to someone@example.com, please"), + (550, "https://example.org/covered"), + (500, "Version 1.2.3 and file:///etc/passwd"), + ] + .iter() + .map(|(y, s)| format!("BT /F1 14 Tf 72 {y} Td ({s}) Tj ET\n")) + .collect::(); + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(), + "<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R /Annots [6 0 R] >>".into(), + format!("<< /Length {} >>\nstream\n{content}endstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + "<< /Type /Annot /Subtype /Link /Rect [60 545 400 565] /Border [0 0 0] /A << /S /URI /URI (https://example.org/annotation) >> >>".into(), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + std::fs::write(dir.join("text.pdf"), pdf).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "text.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "link_list", json!({ "doc": doc })).get("text_links"), None, "only on request"); + let listed = ok(&mut a, "link_list", json!({ "doc": doc, "text_links": true })); + let found = listed["text_links"].as_array().unwrap(); + let urls: Vec<&str> = found.iter().map(|l| l["url"].as_str().unwrap()).collect(); + assert_eq!(urls, ["https://example.com/plain-text-url", "https://www.example.com/no-scheme", "mailto:someone@example.com"], "{listed}"); + assert!(found.iter().all(|l| l["page"] == 1 && l.get("blocked").is_none()), "{listed}"); + // Rectangles from the top-left of the page: the first line's baseline is 792 − 700 = 92 down. + let r = found[0]["rects"][0].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect::>(); + assert!(r[0] > 100.0 && r[0] < 140.0 && r[1] < 92.0 && r[3] > 80.0, "{r:?}"); +} + +/// One PDF of `pages` pages that all draw `content` in Helvetica. +fn same_text_pages(pages: usize, content: &str) -> Vec { + let kids: String = (0..pages).map(|k| format!("{} 0 R ", k + 5)).collect(); + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + format!("<< /Type /Pages /Kids [{kids}] /Count {pages} /MediaBox [0 0 612 792] >>"), + format!("<< /Length {} >>\nstream\n{content}endstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + ]; + objs.extend((0..pages).map(|_| "<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 4 0 R >> >> /Contents 3 0 R >>".to_string())); + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + pdf +} + +/// Review on #4472: link_list with text_links reads a share of a large document's pages per +/// call and says where to go on, so a 10,000-page document is never read all at once; a call +/// also stops after the page that brings it to 2,000 addresses. +#[test] +fn text_links_of_a_large_document_are_read_a_share_at_a_time() { + let dir = workdir("text-links-large"); + std::fs::write(dir.join("long.pdf"), same_text_pages(10_000, "BT /F1 14 Tf 72 700 Td (See https://example.com/page now) Tj ET\n")).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "long.pdf" }))["doc"].as_u64().unwrap(); + let first = ok(&mut a, "link_list", json!({ "doc": doc, "text_links": true })); + let found = first["text_links"].as_array().unwrap(); + assert_eq!(found.len(), 100, "the first 100 pages"); + assert_eq!((found[0]["page"].clone(), found[99]["page"].clone()), (json!(1), json!(100))); + assert_eq!(first["text_links_next_page"], json!(101)); + let last = ok(&mut a, "link_list", json!({ "doc": doc, "text_links": true, "text_links_from": 9_951 })); + let found = last["text_links"].as_array().unwrap(); + assert_eq!((found.len(), found[0]["page"].clone()), (50, json!(9_951))); + assert_eq!(last.get("text_links_next_page"), None, "nothing after the last page"); + for from in [0, -1, 10_001, i64::MAX] { + err(&mut a, "link_list", json!({ "doc": doc, "text_links": true, "text_links_from": from })); + } + + // 52 lines of 10 addresses: 520 on a page, kept to 512; the fourth page brings a call to + // 2,048, so it stops there. + let line = "x@example.org ".repeat(10); + let content: String = (0..52).map(|k| format!("BT /F1 6 Tf 20 {} Td ({line}) Tj ET\n", 760 - k * 7)).collect(); + std::fs::write(dir.join("dense.pdf"), same_text_pages(6, &content)).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "dense.pdf" }))["doc"].as_u64().unwrap(); + let dense = ok(&mut a, "link_list", json!({ "doc": doc, "text_links": true })); + assert_eq!(dense["text_links"].as_array().unwrap().len(), 4 * 512); + assert_eq!(dense["text_links_next_page"], json!(5)); + let rest = ok(&mut a, "link_list", json!({ "doc": doc, "text_links": true, "text_links_from": 5 })); + assert_eq!((rest["text_links"].as_array().unwrap().len(), rest.get("text_links_next_page")), (2 * 512, None)); +} + +/// An address on a layer the viewer hides is not a text link, and Create links from URLs makes +/// no link there: link_list and links_from_urls read the page text with the layers as layer_set +/// left them (ISO 32000-2 §8.11.3.1: hidden optional content "shall not be drawn"). +#[test] +fn text_links_leave_out_addresses_on_hidden_layers() { + let dir = workdir("text-links-layers"); + let content = + "BT /F1 14 Tf 72 700 Td (https://example.com/shown) Tj ET\n/OC /L1 BDC BT /F1 14 Tf 72 650 Td (https://example.com/layered) Tj ET EMC\n"; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [6 0 R] /D << /Order [6 0 R] >> >> >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(), + "<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 5 0 R >> /Properties << /L1 6 0 R >> >> /Contents 4 0 R >>".into(), + format!("<< /Length {} >>\nstream\n{content}endstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + "<< /Type /OCG /Name (Addresses) >>".into(), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + std::fs::write(dir.join("layers.pdf"), pdf).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "layers.pdf" }))["doc"].as_u64().unwrap(); + let urls = |a: &mut Automation| -> Vec { + let listed = ok(a, "link_list", json!({ "doc": doc, "text_links": true })); + listed["text_links"].as_array().unwrap().iter().map(|l| l["url"].as_str().unwrap().to_owned()).collect() + }; + assert_eq!(urls(&mut a), ["https://example.com/shown", "https://example.com/layered"]); + ok(&mut a, "layer_set", json!({ "doc": doc, "layer": "Addresses", "visible": false })); + assert_eq!(urls(&mut a), ["https://example.com/shown"]); + let created = ok(&mut a, "links_from_urls", json!({ "doc": doc })); + assert_eq!(created["created"], json!(["https://example.com/shown"]), "no link over the hidden address: {created}"); + ok(&mut a, "edit_undo", json!({ "doc": doc })); + ok(&mut a, "layer_set", json!({ "doc": doc, "layer": "Addresses", "visible": true })); + assert_eq!(urls(&mut a), ["https://example.com/shown", "https://example.com/layered"]); + let created = ok(&mut a, "links_from_urls", json!({ "doc": doc })); + assert_eq!(created["created"], json!(["https://example.com/shown", "https://example.com/layered"]), "{created}"); +} + #[test] fn bookmarks_from_structure_through_tools() { let dir = workdir("bookmarks-structure"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 78f87e48f6..0dc7c231cb 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -58,6 +58,7 @@ mod stamp_preview; #[cfg(test)] mod test_hooks; pub mod text_fonts; +pub mod text_links; mod text_preview; pub mod text_script; pub mod text_style_bookmarks; diff --git a/crates/engine/src/session/view.rs b/crates/engine/src/session/view.rs index 711779903a..67f6b47428 100644 --- a/crates/engine/src/session/view.rs +++ b/crates/engine/src/session/view.rs @@ -100,11 +100,11 @@ impl Session { } /// Web addresses in the text of every page (Create links from URLs): (page, one rectangle - /// per line in user space, URI). Text that already has a link over it is skipped. + /// per line in user space, URI). Text that already has a link over it is skipped, and so is + /// text on a layer the viewer hides: a link there would sit over nothing the reader can see. pub fn find_urls(&self, id: DocId) -> Vec<(usize, Vec<[f64; 4]>, String)> { let Some(doc) = self.get(id) else { return Vec::new() }; - let config = RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() }; - let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config); + let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone()); let mut out = Vec::new(); for page in 0..doc.info.pages.len() { let res = r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }); @@ -133,6 +133,40 @@ impl Session { out } + /// The addresses written as plain text that the viewer makes clickable + /// ([`crate::text_links::find_text_links`]), leaving out any a link annotation lies over, from + /// the 0-based page `from` on. One call reads at most + /// [`MAX_TEXT_LINK_PAGES_PER_SCAN`](crate::text_links::MAX_TEXT_LINK_PAGES_PER_SCAN) pages + /// and stops after the page that brings it to + /// [`MAX_TEXT_LINKS_PER_SCAN`](crate::text_links::MAX_TEXT_LINKS_PER_SCAN) addresses; then + /// `next_page` says where to go on. + pub fn text_links(&self, id: DocId, from: usize) -> crate::text_links::TextLinkScan { + use crate::text_links::{MAX_TEXT_LINK_PAGES_PER_SCAN, MAX_TEXT_LINKS_PER_SCAN, TextLinkScan}; + let Some(doc) = self.get(id) else { return TextLinkScan::default() }; + // The viewer's settings, so text on a layer it hides is not a link. + let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone()); + let mut scan = TextLinkScan::default(); + let pages = doc.info.pages.len(); + for (page, info) in doc.info.pages.iter().enumerate().skip(from).take(MAX_TEXT_LINK_PAGES_PER_SCAN) { + let res = r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }); + if let Some(text) = res.text { + let annotations: Vec<_> = doc.info.links.iter().filter(|l| l.page == page).collect(); + for link in crate::text_links::find_text_links(&text) { + if !annotations.iter().any(|l| link.covered_by(info, l.rect)) { + scan.links.push((page, link)); + } + } + } + let next = page.saturating_add(1); + let read = next.saturating_sub(from); + if next < pages && (read >= MAX_TEXT_LINK_PAGES_PER_SCAN || scan.links.len() >= MAX_TEXT_LINKS_PER_SCAN) { + scan.next_page = Some(next); + break; + } + } + scan + } + /// A page of the bookmark list in outline order: up to `limit` bookmarks from position `offset`. /// The document's own structure is read for it, so every bookmark can be reached by paging. pub fn bookmark_page(&self, id: DocId, offset: usize, limit: usize) -> Option { diff --git a/crates/engine/src/text_links.rs b/crates/engine/src/text_links.rs new file mode 100644 index 0000000000..90c587898e --- /dev/null +++ b/crates/engine/src/text_links.rs @@ -0,0 +1,341 @@ +//! Web and email addresses written as plain page text, with no link annotation over them (#4207). +//! +//! The viewer makes them clickable the way link annotations are: the address opens only through +//! [`crate::links::document_url`] and the confirmation, like any link from a document. No clause +//! of ISO 32000-2 covers this (it defines link annotations, not reading addresses out of page +//! text); the detector is the one Create links from URLs uses ([`pdfcraft_annot::links::find_urls`]). + +use pdfcraft_render::{PageInfo, PageText}; + +/// Pages with more glyphs than this aren't searched for addresses: the work is linear, but it is +/// done for each page as it comes into view. +pub const MAX_TEXT_LINK_GLYPHS: usize = 200_000; + +/// At most this many addresses are found on one page. +pub const MAX_TEXT_LINKS_PER_PAGE: usize = 512; + +/// A scan of a document's pages for addresses ([`crate::Session::text_links`]) reads at most this +/// many pages, and says where the next one starts: a 10,000-page document is read a share at a +/// time, never all at once. The viewer reads only the pages in view. +pub const MAX_TEXT_LINK_PAGES_PER_SCAN: usize = 100; + +/// A scan stops after the page on which it has found this many addresses. +pub const MAX_TEXT_LINKS_PER_SCAN: usize = 2_000; + +/// The addresses one scan of a document found ([`crate::Session::text_links`]). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct TextLinkScan { + /// (0-based page, address), in page order. + pub links: Vec<(usize, TextLink)>, + /// The 0-based page the next scan starts on, when the scan stopped before the last page. + pub next_page: Option, +} + +/// An address written in a page's text. +#[derive(Clone, Debug, PartialEq)] +pub struct TextLink { + /// Where it is written: one rectangle per line, in view space (points from the displayed + /// page's top-left, y down, the space of [`PageText`]). + pub rects: Vec<[f32; 4]>, + /// The address it opens: as written for `http://` and `https://`, `https://` added for + /// `www.`, and `mailto:` for an email address. Not yet checked: the opener must + /// ([`crate::links::document_url`]). + pub uri: String, +} + +impl TextLink { + /// Whether the view-space point `(x, y)` is on the address. + pub fn contains(&self, x: f32, y: f32) -> bool { + self.rects.iter().any(|r| x >= r[0] && x <= r[2] && y >= r[1] && y <= r[3]) + } + + /// Whether a link annotation with the user-space rectangle `rect` (normalized) lies over part + /// of the address on `page`: the annotation is what the document asks for there. + pub fn covered_by(&self, page: &PageInfo, rect: [f32; 4]) -> bool { + let (a, b) = (page.user_to_view(rect[0], rect[1]), page.user_to_view(rect[2], rect[3])); + let v = [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]; + self.rects.iter().any(|r| r[0] < v[2] && v[0] < r[2] && r[1] < v[3] && v[1] < r[3]) + } +} + +/// The web addresses (`http://`, `https://`, `www.`) and email addresses written in `text`, in +/// reading order. Punctuation that ends a sentence (`.`, `,`, `;`, `:`, `!`, `?`) and brackets +/// around an address are not part of it. Nothing is found on a page of more than +/// [`MAX_TEXT_LINK_GLYPHS`] glyphs, and at most [`MAX_TEXT_LINKS_PER_PAGE`] addresses are kept. An +/// address that starts or ends inside a glyph carrying several characters is left out. +pub fn find_text_links(text: &PageText) -> Vec { + if text.glyphs.len() > MAX_TEXT_LINK_GLYPHS { + return Vec::new(); + } + // Each address with the text it was found in. + let found: std::cell::RefCell, String)>> = Default::default(); + let hits = text.find_with(|chars| { + let mut urls = pdfcraft_annot::links::find_urls(chars); + urls.truncate(MAX_TEXT_LINKS_PER_PAGE); + let ranges = urls.iter().map(|u| u.0.clone()).collect(); + *found.borrow_mut() = + urls.into_iter().map(|(range, uri)| (chars.get(range.clone()).map(|c| c.iter().collect()), web_uri(chars, range, uri))).collect(); + ranges + }); + let found = found.into_inner(); + // `find_with` drops a range it can't place on glyphs; then the two lists no longer pair up, + // and nothing is offered rather than an address on the wrong text. + if hits.len() != found.len() { + return Vec::new(); + } + hits.into_iter() + .zip(found) + // A glyph that carries more than one character (a ligature, or a font that maps a glyph to + // `m,`) is taken whole: an address that starts or ends inside one would make the text next + // to it clickable, so it is left out. + .filter(|(glyphs, (written, _))| written.as_deref() == Some(text.text_of(glyphs.clone()).as_str())) + .map(|(glyphs, (_, uri))| TextLink { rects: text.line_rects(glyphs), uri }) + .filter(|l| !l.rects.is_empty()) + .collect() +} + +/// A `www.` address opens over `https://`: `find_urls`, which writes links into the document, +/// keeps `http://` for them, but a reader following an address it was never given should use the +/// secure scheme. +fn web_uri(chars: &[char], range: std::ops::Range, uri: String) -> String { + let www = "www.".chars().enumerate().all(|(k, w)| chars.get(range.start.saturating_add(k)).is_some_and(|c| c.eq_ignore_ascii_case(&w))); + match uri.strip_prefix("http://") { + Some(rest) if www => format!("https://{rest}"), + _ => uri, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pdfcraft_render::TextGlyph; + + /// One line of text, a glyph per character, 6 points apart; `\n` starts the next line. + fn page_text(s: &str) -> PageText { + let mut glyphs = Vec::new(); + let (mut line, mut x) = (0u32, 0.0f32); + let mut line_of = Vec::new(); + let mut space_before = Vec::new(); + let mut gap = false; + for c in s.chars() { + if c == '\n' { + (line, x, gap) = (line + 1, 0.0, false); + continue; + } + if c == ' ' { + (x, gap) = (x + 6.0, true); + continue; + } + let y = 10.0 + line as f32 * 20.0; + let rect = [x, y, x + 6.0, y + 12.0]; + glyphs.push(TextGlyph { + text: c.to_string(), + rect, + quad: [[x, y + 12.0], [x + 6.0, y + 12.0], [x + 6.0, y], [x, y]], + direction: [1.0, 0.0], + }); + line_of.push(line); + space_before.push(gap); + (x, gap) = (x + 6.0, false); + } + PageText { glyphs, line_of, space_before } + } + + fn uris(s: &str) -> Vec { + find_text_links(&page_text(s)).into_iter().map(|l| l.uri).collect() + } + + #[test] + fn web_and_email_addresses_in_text_are_found() { + assert_eq!(uris("Visit https://example.com/plain-text-url today"), ["https://example.com/plain-text-url"]); + assert_eq!(uris("or http://example.org/a?b=c#d"), ["http://example.org/a?b=c#d"]); + assert_eq!(uris("Go to www.example.com/no-scheme now"), ["https://www.example.com/no-scheme"]); + assert_eq!(uris("Go to WWW.Example.com"), ["https://WWW.Example.com"]); + assert_eq!(uris("Write to someone@example.com please"), ["mailto:someone@example.com"]); + assert_eq!( + uris("a https://a.example\nb www.b.example\nc c@example.org"), + ["https://a.example", "https://www.b.example", "mailto:c@example.org"] + ); + } + + #[test] + fn sentence_punctuation_and_brackets_are_not_part_of_the_address() { + assert_eq!(uris("See https://example.com."), ["https://example.com"]); + assert_eq!(uris("See https://example.com/x, then"), ["https://example.com/x"]); + assert_eq!(uris("(see https://example.com/x)"), ["https://example.com/x"]); + assert_eq!(uris("See www.example.com!"), ["https://www.example.com"]); + assert_eq!(uris("Mail someone@example.com."), ["mailto:someone@example.com"]); + assert_eq!(uris("Ask (someone@example.com)."), ["mailto:someone@example.com"]); + } + + #[test] + fn text_that_only_looks_like_an_address_is_not_found() { + for s in ["example.com", "http://", "https://x", "www.", "user@localhost", "a@b.c", "notwww.example.com", "Version 1.2.3", "@example.com"] { + assert_eq!(uris(s), Vec::::new(), "{s}"); + } + } + + /// Only `http`, `https` and `mailto` addresses are offered, whatever letter case another + /// scheme is written in: `javascript:`, `file:`, `data:` and any other scheme never become a + /// link. + #[test] + fn only_web_and_email_addresses_are_offered() { + for s in [ + "JaVaScRiPt:alert(1)", + "javascript:void(0)", + "file:///etc/passwd", + "FILE://server/share/report.pdf", + "ftp://ftp.example.com/pub/file.txt", + "data:text/html,example.com", + "data:text/html;base64,PGI+ZXhhbXBsZTwvYj4=", + "myapp://open?doc=example.com", + "vbscript:msgbox(1)", + "tel:+15551234567", + ] { + assert_eq!(uris(s), Vec::::new(), "{s}"); + } + // An address inside one written with another scheme is not taken out of it and opened + // with a scheme the text doesn't say. + for s in [ + "ftp://www.example.com/pub", + "file://www.example.com/share", + "JavaScript:www.example.com", + "ftp://user@example.com/pub", + "smb://admin@example.org/share", + ] { + assert_eq!(uris(s), Vec::::new(), "{s}"); + } + // Every address offered opens with one of the three schemes, and passes the check every + // document link gets. + let offered = uris("a https://a.example b HTTP://B.EXAMPLE c www.c.example d d@example.org"); + assert_eq!(offered.len(), 4, "{offered:?}"); + for uri in offered { + let scheme = uri.split_once(':').map(|(s, _)| s.to_ascii_lowercase()).unwrap_or_default(); + assert!(["http", "https", "mailto"].contains(&scheme.as_str()), "{uri}"); + assert!(crate::links::document_url(&uri).is_ok(), "{uri}"); + } + } + + /// The text the hand appears over is the address that opens: the rectangle covers exactly + /// the characters of the address, the link check passes the address on unchanged, and the + /// only addition is the scheme a `www.` or email address is written without. + #[test] + fn the_address_opened_is_the_address_written() { + for (s, written, opened) in [ + ("Visit https://example.com/a?b=c&d=%20e#f now", "https://example.com/a?b=c&d=%20e#f", "https://example.com/a?b=c&d=%20e#f"), + ("Visit HTTPS://EXAMPLE.COM/Path now", "HTTPS://EXAMPLE.COM/Path", "HTTPS://EXAMPLE.COM/Path"), + ("Go to www.Example.com/X now", "www.Example.com/X", "https://www.Example.com/X"), + ("Mail Some.One+x@Example.org now", "Some.One+x@Example.org", "mailto:Some.One+x@Example.org"), + // Internationalized and punycode host names are kept as written; the confirmation + // shows the host and warns about mixed scripts (`links::display_host`). + ("See https://bücher.example/straße now", "https://bücher.example/straße", "https://bücher.example/straße"), + ("See https://xn--bcher-kva.example/ now", "https://xn--bcher-kva.example/", "https://xn--bcher-kva.example/"), + ] { + let links = find_text_links(&page_text(s)); + assert_eq!(links.len(), 1, "{s}"); + assert_eq!(links[0].uri, opened, "{s}"); + assert!(opened.ends_with(written), "{s}: only a scheme is added"); + assert_eq!(crate::links::document_url(&links[0].uri).ok().as_deref(), Some(opened), "{s}: opened unchanged"); + // `page_text` gives every character, spaces included, 6 points. + let at = s.find(written).and_then(|b| s.get(..b)).map(|p| p.chars().count()).expect("written in s"); + let len = written.chars().count(); + assert_eq!(links[0].rects, vec![[at as f32 * 6.0, 10.0, (at + len) as f32 * 6.0, 22.0]], "{s}"); + } + // An address hiding characters is offered as written, and the link check refuses it. + let hidden = uris("https://exa\u{200B}mple.com/x"); + assert_eq!(hidden, ["https://exa\u{200B}mple.com/x"]); + assert!(crate::links::document_url(&hidden[0]).is_err()); + } + + /// A full stop, comma or closing bracket after an address belongs to the sentence; brackets + /// inside an address (a Wikipedia title) belong to it when they pair up. + #[test] + fn punctuation_after_an_address_is_left_out_and_paired_brackets_kept() { + for (s, uri) in [ + ("See https://example.com/x.", "https://example.com/x"), + ("See https://example.com/x...", "https://example.com/x"), + ("Is it https://example.com/x?", "https://example.com/x"), + ("(see https://example.com/x).", "https://example.com/x"), + ("[https://example.com/x]", "https://example.com/x"), + ("\"https://example.com/x\",", "https://example.com/x"), + ("", "https://example.com/x"), + ("See https://en.wikipedia.org/wiki/Mercury_(planet).", "https://en.wikipedia.org/wiki/Mercury_(planet)"), + ("(see https://en.wikipedia.org/wiki/Mercury_(planet)).", "https://en.wikipedia.org/wiki/Mercury_(planet)"), + ("See www.example.com/a_(b)_c, then", "https://www.example.com/a_(b)_c"), + ("See https://example.com/a(b and more", "https://example.com/a"), + ("See https://example.com/a)b", "https://example.com/a"), + ("(someone@example.com).", "mailto:someone@example.com"), + ] { + assert_eq!(uris(s), [uri], "{s}"); + } + } + + /// An address ends where its line ends: the next line is never joined to it, because the page + /// text can't tell an address wrapped onto it from the start of the next sentence. + #[test] + fn an_address_ends_at_the_end_of_its_line() { + let links = find_text_links(&page_text("Read https://example.com/first\npart and more")); + assert_eq!(links.iter().map(|l| l.uri.as_str()).collect::>(), ["https://example.com/first"]); + assert_eq!(links[0].rects.len(), 1, "one line"); + assert!(!links[0].contains(3.0, 35.0), "nothing on the next line is part of it"); + } + + #[test] + fn an_address_is_found_where_it_is_written() { + let links = find_text_links(&page_text("ab https://example.com cd")); + // "ab " is 3 characters of 6 points; the address is 19 characters on the first line. + assert_eq!(links.len(), 1); + assert_eq!(links[0].rects, vec![[18.0, 10.0, 132.0, 22.0]]); + assert!(links[0].contains(20.0, 15.0) && links[0].contains(131.0, 21.0)); + assert!(!links[0].contains(10.0, 15.0), "the text before it"); + assert!(!links[0].contains(140.0, 15.0), "the text after it"); + assert!(!links[0].contains(20.0, 30.0), "the line below"); + } + + #[test] + fn a_link_annotation_over_the_address_covers_it() { + let page = PageInfo { width: 300.0, height: 300.0, label: String::new(), crop: [0.0, 0.0, 300.0, 300.0], rotation: 0 }; + let link = &find_text_links(&page_text("ab https://example.com cd"))[0]; + // View y 10–22 is user y 278–290. + assert!(link.covered_by(&page, [100.0, 280.0, 120.0, 285.0])); + assert!(!link.covered_by(&page, [100.0, 200.0, 120.0, 250.0]), "a link elsewhere on the page"); + assert!(!link.covered_by(&page, [140.0, 280.0, 160.0, 285.0]), "a link after it on the line"); + } + + /// `text` with glyph `i` and the one after it drawn as one glyph carrying both characters. + fn merged(mut text: PageText, i: usize) -> PageText { + let next = text.glyphs.remove(i + 1); + text.line_of.remove(i + 1); + text.space_before.remove(i + 1); + let g = &mut text.glyphs[i]; + g.text.push_str(&next.text); + g.rect[2] = next.rect[2]; + text + } + + #[test] + fn an_address_ending_inside_a_glyph_is_not_offered() { + // "m," is one glyph: the address ends on its first character, and its rectangle would + // take the comma too. + let s = "See https://example.com, then"; + // Glyph indexes skip the one space before the address. + let m = s.find(',').expect("a comma") - 2; + assert!(find_text_links(&merged(page_text(s), m)).is_empty()); + // A glyph of several characters inside the address is part of it. + let links = find_text_links(&merged(page_text("See https://fi.example.com"), 11)); + assert_eq!(links.iter().map(|l| l.uri.as_str()).collect::>(), ["https://fi.example.com"]); + } + + #[test] + fn work_per_page_is_capped() { + let many = "a@example.org ".repeat(MAX_TEXT_LINKS_PER_PAGE + 5); + assert_eq!(find_text_links(&page_text(&many)).len(), MAX_TEXT_LINKS_PER_PAGE); + let mut big = page_text("https://example.com"); + let filler = big.glyphs[0].clone(); + big.glyphs.resize(MAX_TEXT_LINK_GLYPHS + 1, filler); + big.line_of.resize(MAX_TEXT_LINK_GLYPHS + 1, 0); + big.space_before.resize(MAX_TEXT_LINK_GLYPHS + 1, false); + assert!(find_text_links(&big).is_empty()); + assert!(find_text_links(&PageText::default()).is_empty()); + } +} diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index fc7ae10ad5..010b7f7bd7 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -139,6 +139,7 @@ impl PdfCraftApp { reload_changed_files: true, update_check_at_start: true, show_secured_notice: true, + text_links: true, always_default_view: false, #[cfg(not(target_arch = "wasm32"))] watch: Default::default(), diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index b5c1f02de1..065bd7a228 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -371,6 +371,13 @@ impl PdfCraftApp { _ => return Err("hide-empty-highlight-hover must be true or false".into()), }; } + ("text-links", _) => { + self.text_links = match value { + "true" => true, + "false" => false, + _ => return Err("text-links must be true or false".into()), + }; + } ("ui-scale", _) => self.ui_scale = ui_scale::parse(value)?, // `--secured-notice false`: no notice above the pages that a document is secured. ("secured-notice", _) => { diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index 525bf0ae3f..1ec33884fe 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -170,6 +170,7 @@ impl PdfCraftApp { v["hotbar"] = serde_json::to_value(&self.hotbar).unwrap_or_default(); v["comment_tool_pinned"] = serde_json::Value::Bool(self.comment_prefs.pinned); v["show_secured_notice"] = serde_json::Value::Bool(self.show_secured_notice); + v["text_links"] = serde_json::Value::Bool(self.text_links); v["organize_zoom"] = serde_json::json!(self.organize_zoom); // Canonical names survive changes to the order of PAPERS and do not depend on UI language; // a custom paper is stored by the name it was given. @@ -247,6 +248,9 @@ impl PdfCraftApp { if let Some(on) = v["show_secured_notice"].as_bool() { self.show_secured_notice = on; } + if let Some(on) = v["text_links"].as_bool() { + self.text_links = on; + } self.pinned.restore(&v["pinned_folders"]); if let Some(items) = v.get("pinned_tools").and_then(serde_json::Value::as_array) { let mut tools = Vec::new(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 10e485178d..f20cfca29f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -9,6 +9,7 @@ use std::ops::Range; use std::sync::Arc; use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Stroke, TextureHandle, TextureOptions, Vec2, pos2, vec2}; +use pdfcraft_engine::text_links::TextLink; use pdfcraft_engine::{DocId, Edit}; use pdfcraft_render::{DestView, DocInfo, LayerOp, LinkTarget, PRINT_TAG, PageText, RenderPool, RenderRequest, RequestKind, Tile, device_pixels}; @@ -599,6 +600,8 @@ pub struct DocView { tiles: HashMap<(usize, u64, u32, u32), TextureHandle>, /// Text layers, extracted in the background on demand (selection, find, copy). pub(crate) texts: HashMap>, + /// Addresses written as plain text on a page (#4207), with the text layer they were found in. + text_links: HashMap, Arc<[TextLink]>)>, pub(crate) text_failed: HashSet, pub find: Option, selection: Option, @@ -1045,6 +1048,7 @@ impl DocView { grayscale: false, tiles: HashMap::new(), texts: HashMap::new(), + text_links: HashMap::new(), text_failed: HashSet::new(), find: None, selection: None, @@ -1328,6 +1332,8 @@ struct PageEnv<'a> { table_preview: &'a Option, tag: u64, text_style: &'a pdfcraft_engine::AddedText, + /// Preferences: addresses written as plain page text open like links (#4207). + text_links: bool, tool: QuickTool, units: crate::units::Unit, unobstructed: bool, @@ -1691,6 +1697,10 @@ fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i } let slot = PageSlot { i, r, xf }; + // Addresses written as plain text are found once per text layer, before the tools hit-test them. + if frame.env.text_links { + view.refresh_text_links(i); + } let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; // A cancelled press takes nothing until the button is up, though the page's menu still opens // where a right-click is. @@ -3252,6 +3262,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { table_preview: &table_preview, tag, text_style: &text_style, + text_links: app.text_links, tool, units, unobstructed, diff --git a/crates/ui-egui/src/canvas/cache.rs b/crates/ui-egui/src/canvas/cache.rs index f7c8231b0d..2b2e519f4c 100644 --- a/crates/ui-egui/src/canvas/cache.rs +++ b/crates/ui-egui/src/canvas/cache.rs @@ -1,7 +1,7 @@ use super::find::Follow; use super::{ - Arc, Color32, DocInfo, DocView, HashSet, PRINT_TAG, PT, PageText, Range, RenderPool, RenderRequest, RequestKind, TextureHandle, TextureOptions, - device_pixels, + Arc, Color32, DocInfo, DocView, HashSet, PRINT_TAG, PT, PageText, Range, RenderPool, RenderRequest, RequestKind, TextLink, TextureHandle, + TextureOptions, device_pixels, }; pub(super) const THUMB_TAG: u64 = 1 << 63; @@ -281,6 +281,37 @@ impl DocView { self.texts.get(&page).cloned() } + /// Finds the addresses written as plain text on `page` (#4207): once per text layer, and only + /// once the page's text is known. The addresses of text layers that were dropped or replaced + /// are forgotten. + pub(crate) fn refresh_text_links(&mut self, page: usize) { + let texts = &self.texts; + self.text_links.retain(|p, (t, _)| texts.get(p).is_some_and(|current| Arc::ptr_eq(current, t))); + if self.text_links.contains_key(&page) { + return; + } + if let Some(text) = self.texts.get(&page).cloned() { + let found: Arc<[TextLink]> = pdfcraft_engine::text_links::find_text_links(&text).into(); + self.text_links.insert(page, (text, found)); + } + } + + /// The address written as plain text on `page` under the view-space point `(x, y)` (#4207), + /// with its index among the page's, unless a link annotation lies over it: the annotation is + /// what the document asks for there. Only addresses [`Self::refresh_text_links`] found count. + pub(crate) fn text_link_at(&self, info: &DocInfo, page: usize, x: f32, y: f32) -> Option<(usize, &TextLink)> { + let (_, links) = self.text_links.get(&page)?; + let page_info = info.pages.get(page)?; + let (index, hit) = links.iter().enumerate().find(|(_, l)| l.contains(x, y))?; + let covered = info.links.iter().any(|l| l.page == page && hit.covered_by(page_info, l.rect)); + (!covered).then_some((index, hit)) + } + + /// The address of the `index`-th text link found on `page` (#4207). + pub(crate) fn text_link_uri(&self, page: usize, index: usize) -> Option { + self.text_links.get(&page).and_then(|(_, links)| links.get(index)).map(|l| l.uri.clone()) + } + /// Whether `page`'s text layer has arrived from the renderer. Selecting text, the I-beam and /// find matches on a page all wait for it (tests and automation). pub fn has_page_text(&self, page: usize) -> bool { diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index e253fd6b59..793211fec7 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -2,7 +2,7 @@ //! router, the text layer, and what is drawn over the page after its text. use egui::{Color32, CornerRadius, Pos2, Rect, Stroke}; -use pdfcraft_render::{DocInfo, PageText}; +use pdfcraft_render::{DocInfo, LinkTarget, PageText}; use super::{ DocView, MARK_ICON_SHARE, PageEnv, PageFrame, PageSlot, PageXform, Selection, Span, cached_edit_images, click_unit, mark_anchor, @@ -67,6 +67,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut redact_mark, table_preview, text_style, + text_links, tool, .. } = frame.env; @@ -100,6 +101,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut lines: &lines, images: &images, clip: ui.clip_rect(), + text_links, }; if let Some(o) = ptr.pressed() { // A press with Shift or ⌘ in Edit text is the objects', whatever lies under it @@ -453,6 +455,8 @@ struct PageHits<'a> { images: &'a [pdfcraft_engine::PageImage], /// The pages' visible area (the picture's rotate knob stays inside it). clip: Rect, + /// Addresses written as plain page text open like links (#4207). + text_links: bool, } impl PageHits<'_> { @@ -534,6 +538,7 @@ impl PageHits<'_> { lines, images, clip, + text_links, } = *self; // A press with Shift or ⌘ is the objects' (`object_ui::modified_hit`), off crop mode's page. if let Some(objects) = objects { @@ -589,11 +594,27 @@ impl PageHits<'_> { let added = (editing_content && can_modify && picks_up).then(|| crate::content_ui::hit(xf, info, page, added, view, p)).flatten(); let corner = (!context && tool == QuickTool::EnhanceCamera && can_modify).then(|| crate::camera_ui::hit(xf, page, view, p)).flatten(); let link = crate::link_ui::hit(xf, info, page, doc_links, tool, p); - let comment = if context { comments::context_hit(pcx, p) } else { comments::hit(pcx, view, p, link.is_some()) }; - [link, comment, pictures, paragraphs, added, objects, grips, corner, fields].into_iter().flatten().collect() + let text_link = (text_links && link.is_none()).then(|| text_link_hit(view, info, xf, page, p)).flatten(); + let on_link = link.is_some() || text_link.is_some(); + let comment = if context { comments::context_hit(pcx, p) } else { comments::hit(pcx, view, p, on_link) }; + [link, text_link, comment, pictures, paragraphs, added, objects, grips, corner, fields].into_iter().flatten().collect() } } +/// The web or email address written as plain text under `p` on `page` (#4207), with no link +/// annotation over it: the same hand and tip (its address) as a link annotation's. +fn text_link_hit(view: &DocView, info: &DocInfo, xf: &PageXform, page: usize, p: Pos2) -> Option { + let (vx, vy) = xf.screen_to_view(p); + let (index, link) = view.text_link_at(info, page, vx, vy)?; + let owner = crate::page_router::Owner::TextLink { index }; + // A drag from the address selects its text, as from any other text: only a click opens it. + Some(crate::page_router::Hit { + tip: Some(link.uri.clone()), + declines_drag: true, + ..crate::page_router::Hit::new(owner, Layer::Link, egui::CursorIcon::PointingHand) + }) +} + /// Text layer: find matches, selection, I-beam and drag-to-select. pub(super) fn text_layer(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, input: &PageInput) { let PageEnv { selects_text, tool, .. } = frame.env; @@ -605,9 +626,9 @@ pub(super) fn text_layer(ui: &mut egui::Ui, view: &mut DocView, frame: &mut Page && !input.consumed && let Some(p) = pointer.filter(|p| r.contains(*p)) { - // The I-beam drops where a press would go to a link (ruling E: the tools that select a - // link's text show it there). - let over_link = input.hover.as_ref().is_some_and(|h| matches!(h.owner, Owner::Link { .. })); + // The I-beam drops where a press would go to a link, an address written as page text + // included (#4207) (ruling E: the tools that select a link's text show it there). + let over_link = input.hover.as_ref().is_some_and(|h| matches!(h.owner, Owner::Link { .. } | Owner::TextLink { .. })); let pointer = TextPointer { page: i, xf, rect: r, tool, resp, ptr: ptr.clone(), pointer: p, over_link, text_drag: input.text_drag }; text_pointer_input(ui, view, &text, pointer); } @@ -905,6 +926,19 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P { frame.out.clicked_link = Some(l.target.clone()); } + // A web or email address written as plain text (#4207): the same click as a link annotation, + // and the same check and confirmation on opening. + if unobstructed + && resp.clicked() + && tool == QuickTool::Select + && !input.consumed + && frame.out.clicked_link.is_none() + && let PagePress::Mine(Owner::TextLink { index }) = input.press + && input.released_over + && let Some(uri) = view.text_link_uri(i, index) + { + frame.out.clicked_link = Some(LinkTarget::Uri(uri)); + } // Comments say what they are with any tool, beside its cursor, as Acrobat's do. comments::describe(&pcx, view); for (mp, mr, mc) in &view.compare_marks { diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index 30a3f2126f..5dbe5d4461 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -24,6 +24,8 @@ pub(crate) enum Owner { Comment { index: usize, part: CommentPart }, /// A link, by its index in its page's `/Annots`. Link { index: usize }, + /// A web or email address written as plain page text (#4207), by its index among the page's. + TextLink { index: usize }, /// A form field's widget, by the field's index in the form and the widget's in the field. Field { field: usize, widget: usize }, /// Prepare Form: a field's widget, to select and move. diff --git a/crates/ui-egui/src/dialogs/preferences.rs b/crates/ui-egui/src/dialogs/preferences.rs index ea985f3207..830144277c 100644 --- a/crates/ui-egui/src/dialogs/preferences.rs +++ b/crates/ui-egui/src/dialogs/preferences.rs @@ -741,6 +741,8 @@ fn card_documents_and_view(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, }); ctx.label_secondary(ui, tl!("Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages."), t); ctx.checkbox(ui, &mut app.always_default_view, tl!("Always use my default zoom and page layout"), t); + ctx.checkbox(ui, &mut app.text_links, tl!("Open web and email addresses written in the page text"), t); + ctx.label_secondary(ui, tl!("Only web (http, https) and email addresses open, and PdfCraft asks first, as for other links."), t); ui.add_space(6.0); ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 59ca1d60b7..0d527d5ef5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -425,6 +425,9 @@ pub struct PdfCraftApp { /// Preferences: tell the user, above the pages, that a document is secured (its security /// settings restrict changes). Off only hides that notice (#4035); kept in the settings. pub show_secured_notice: bool, + /// Preferences: web and email addresses written as plain page text open on a click, like + /// links (#4207). + pub text_links: bool, /// Preferences: open every document at Default zoom and Default page display, whatever /// layout or zoom it asks for (#1618). pub always_default_view: bool, diff --git a/crates/ui-egui/tests/it/document_links.rs b/crates/ui-egui/tests/it/document_links.rs index 01660a3a60..982099aaf9 100644 --- a/crates/ui-egui/tests/it/document_links.rs +++ b/crates/ui-egui/tests/it/document_links.rs @@ -912,3 +912,129 @@ fn highlighted_link_can_be_requested_again_after_cancel() { 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 { + 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::(); + 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"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c56232fb41..a1b86cafa0 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1181,6 +1181,18 @@ tools = [] evidence = ["crates/ui-egui/tests/it/viewer.rs::view_history_select_all_and_find_options", "crates/ui-egui/tests/it/control.rs::navigation_history_commands_and_rail_are_per_tab_and_non_editing", "crates/ui-egui/tests/it/control.rs::routine_continuous_scrolling_does_not_add_navigation_history", "crates/ui-egui/tests/it/viewer.rs::alt_arrows_go_back_and_forward_through_the_view_history", "crates/ui-egui/tests/it/editing.rs::alt_arrows_navigate_instead_of_nudging_a_selected_image", "crates/ui-egui/tests/it/comments.rs::alt_arrows_navigate_instead_of_nudging_the_selected_comment"] notes = "Per-tab page history: explicit search-result jumps, page navigation, bookmarks and links; no continuous-scroll history or restart persistence; existing page-step history is retained. Rail buttons, View ▸ Page navigation, Ctrl/⌘[ / Ctrl/⌘] and Alt/⌥+← / →, also with a picture, object or comment selected (the View menu shows only the bracket shortcuts). UI control ui.command exposes view.previous_view / view.next_view; standalone engine sessions have no viewport history. Restores page tops, not exact scroll/zoom positions." +[[feature]] +id = "view.text-links" +title = "Open web and email addresses written as page text" +area = "B" +tier = "P1" +milestone = "M3" +status = "shipped" +commands = [] +tools = ["link_list"] +evidence = ["crates/engine/src/text_links.rs::web_and_email_addresses_in_text_are_found", "crates/engine/src/text_links.rs::sentence_punctuation_and_brackets_are_not_part_of_the_address", "crates/engine/src/text_links.rs::a_link_annotation_over_the_address_covers_it", "crates/engine/src/text_links.rs::only_web_and_email_addresses_are_offered", "crates/engine/src/text_links.rs::the_address_opened_is_the_address_written", "crates/engine/src/text_links.rs::punctuation_after_an_address_is_left_out_and_paired_brackets_kept", "crates/engine/src/text_links.rs::an_address_ends_at_the_end_of_its_line", "crates/automation/tests/it/automation.rs::text_links_of_a_large_document_are_read_a_share_at_a_time", "crates/ui-egui/tests/it/document_links.rs::a_drag_from_an_address_in_the_page_text_selects_it", "crates/automation/tests/it/automation.rs::addresses_written_as_page_text_are_listed_with_text_links", "crates/ui-egui/tests/it/document_links.rs::addresses_written_in_the_page_text_open_like_links"] +notes = "With the Select tool, http(s)://, www. (opened as https://) and email (mailto:) addresses written as plain page text show the link cursor and their address on hover, and a click asks first and opens through the same check as link annotations (#4207). Found once per page text layer with the Create links from URLs detector: http, https and mailto only, never another scheme, nor a www. or email address inside one written with another scheme (ftp://www.…); sentence punctuation and unpaired brackets left out, paired brackets inside kept; an address ends at the end of its line; at most 512 per page, none on a page of more than 200,000 glyphs. The address opened is the one written (a www. address gains https://, an email address mailto:). Text under a link annotation follows the annotation, and a drag from an address selects its text. Preferences: Open web and email addresses written in the page text (default on; control key text-links). link_list text_links: true lists them, 100 pages per call (text_links_from, text_links_next_page), stopping after the page that reaches 2,000. Missing: addresses broken across lines or hyphenated, and following one under a highlight comment." + [[feature]] id = "view.follow-links" title = "Follow links and destinations" @@ -2304,8 +2316,8 @@ milestone = "M7" status = "shipped" commands = ["edit.links_from_urls"] tools = ["links_from_urls"] -evidence = ["crates/engine/src/tests.rs::links_from_urls_and_link_edits", "crates/automation/tests/it/automation.rs::links_through_tools", "crates/annot/src/tests.rs::urls_are_found_in_text"] -notes = "http(s):// and www. addresses; already-linked text skipped" +evidence = ["crates/engine/src/tests.rs::links_from_urls_and_link_edits", "crates/automation/tests/it/automation.rs::links_through_tools", "crates/annot/src/tests.rs::urls_are_found_in_text", "crates/annot/src/tests.rs::urls_keep_their_written_scheme_and_paired_brackets", "crates/automation/tests/it/automation.rs::text_links_leave_out_addresses_on_hidden_layers"] +notes = "http(s):// and www. addresses; already-linked text and text on a hidden layer skipped" [[feature]] id = "edit.remove-all-links"