From 3355edad4200e2abbb9abf75ee5b00fbdbb2ea66 Mon Sep 17 00:00:00 2001 From: Eric Kaden <608341+EricKaden@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:19:25 -0400 Subject: [PATCH] M12: only a link's own underline joins its pieces, and web links need a host --- crates/xfa/src/layout.rs | 7 ++++++- crates/xfa/src/pdf.rs | 19 ++++++++++++++++--- crates/xfa/src/tests.rs | 11 +++++++---- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index 23342168e..02a8e6e6e 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -37,6 +37,11 @@ const MAX_IMAGE_BYTES: usize = 256 << 20; /// Placeholder for the page count in text laid out before the count is known. const PAGE_COUNT_MARK: char = '\u{E000}'; +/// How far below its baseline an underline is drawn, in the text's size: an underline is the +/// level line pushed just before its span, this far below it (the PDF writer tells it apart from +/// a rule by this). +pub(crate) const UNDERLINE_DROP: f64 = 0.1; + #[derive(Clone, Debug, PartialEq)] pub enum Item { Fill { rect: Rect, color: [f64; 3] }, @@ -521,7 +526,7 @@ impl Layouter<'_> { for s in b.spans { if s.face.underline { let w = s.face.width(&s.text); - let y = s.baseline + s.face.size * 0.1; + let y = s.baseline + s.face.size * UNDERLINE_DROP; self.push(Item::Line { from: (s.x, y), to: (s.x + w, y), width: (s.face.size * 0.06).max(0.3), color: s.face.color, dashed: false })?; } if !s.text.trim().is_empty() { diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index a86aaec93..833fae875 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -587,10 +587,13 @@ impl Emitter<'_> { let mut last_link: Option = None; for (i, item) in page.items.iter().enumerate() { // A link's pieces join only across text and the underline drawn just before it: a fill, - // picture, field or a line that isn't level between them ends the link there. + // picture, field or any other line (a rule) between them ends the link there. let keeps_link = match item { Item::Text(_) => true, - Item::Line { from, to, .. } => (from.1 - to.1).abs() < 0.01, + Item::Line { from, to, .. } => matches!(page.items.get(i + 1), Some(Item::Text(s)) + if (from.1 - to.1).abs() < 0.01 + && (from.0 - s.x).abs() < 0.01 + && (from.1 - (s.baseline + s.face.size * crate::layout::UNDERLINE_DROP)).abs() < 0.01), _ => false, }; if !keeps_link { @@ -772,10 +775,20 @@ fn link_target(href: &str) -> Option<&str> { } let (scheme, rest) = href.split_once(':')?; let web = scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https"); - let ok = if web { rest.starts_with("//") && rest.len() > 2 } else { scheme.eq_ignore_ascii_case("mailto") && !rest.is_empty() }; + let ok = if web { rest.strip_prefix("//").is_some_and(has_host) } else { scheme.eq_ignore_ascii_case("mailto") && !rest.is_empty() }; ok.then_some(href) } +/// Whether `after` (a web address past its `//`) names a host: what comes before the path, query +/// or fragment, past any user name and before any port, isn't empty. +fn has_host(after: &str) -> bool { + let authority = after.split(['/', '?', '#']).next().unwrap_or_default(); + let host = authority.rsplit('@').next().unwrap_or_default(); + // An IPv6 host is bracketed and holds colons of its own. + let host = if host.starts_with('[') { host } else { host.split(':').next().unwrap_or_default() }; + !host.is_empty() +} + /// Replace the document's pages with the laid-out form and add its fields to the AcroForm. The /// XFA packets and everything else in the catalog stay. /// The image XObjects earlier passes wrote for pictures, by the SHA-256 each records: those on diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 414d35421..e4a0c410d 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -481,6 +481,7 @@ fn a_rule_between_two_pieces_of_a_link_keeps_them_apart() { ) }; let rule = r#""#; + let level_rule = r#""#; let form = |between: &str, underline: bool| { format!( r##" @@ -500,19 +501,21 @@ fn a_rule_between_two_pieces_of_a_link_keeps_them_apart() { assert_eq!(links(&form("", false)), 1, "side by side, the pieces join"); assert_eq!(links(&form("", true)), 1, "an underline doesn't part them"); assert_eq!(links(&form(rule, false)), 2, "a rule between them does"); + assert_eq!(links(&form(level_rule, false)), 2, "and so does a level one, which isn't an underline"); } -/// Only web and email addresses become links. Scripts, files, data, relative paths and empty -/// or overlong addresses stay plain text (still drawn), with one warning for the form. +/// Only web and email addresses become links. Scripts, files, data, relative paths, web +/// addresses without a host, and empty or overlong addresses stay plain text (still drawn), with +/// one warning for the form. #[test] fn links_to_anything_but_web_and_email_addresses_stay_plain_text() { let long = format!("https://example.com/{}", "a".repeat(2100)); let body = format!( - r#"one two three four five six seven"# + r#"one two three four five six seven eight nine ten"# ); let mut doc = Document::open(Arc::new(shell(&rich_draw_form("7in", &body)))).unwrap(); let report = render_into(&mut doc).unwrap(); - assert_eq!(text_links(&doc).into_iter().map(|(u, _)| u).collect::>(), ["MailTo:a@example.com"]); + assert_eq!(text_links(&doc).into_iter().map(|(u, _)| u).collect::>(), ["MailTo:a@example.com", "https://user@example.com:8443/x"]); let warned: Vec<&String> = report.warnings.iter().filter(|w| w.contains("link")).collect(); assert_eq!(warned.len(), 1, "one warning for the form: {:?}", report.warnings); assert!(warned[0].contains("http, https") && warned[0].contains("mailto"), "{warned:?}");