M12: only a link's own underline joins its pieces, and web links need a host

This commit is contained in:
Eric Kaden
2026-10-23 17:19:25 -04:00
parent 3cf6e5f716
commit 3355edad42
3 changed files with 29 additions and 8 deletions
+6 -1
View File
@@ -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() {
+16 -3
View File
@@ -587,10 +587,13 @@ impl Emitter<'_> {
let mut last_link: Option<LinkPiece> = 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
+7 -4
View File
@@ -481,6 +481,7 @@ fn a_rule_between_two_pieces_of_a_link_keeps_them_apart() {
)
};
let rule = r#"<draw name="rule" x="0.21in" y="0in" w="0in" h="0.3in"><value><line><edge/></line></value></draw>"#;
let level_rule = r#"<draw name="dash" x="0.2in" y="0.1in" w="0.04in" h="0in"><value><line><edge/></line></value></draw>"#;
let form = |between: &str, underline: bool| {
format!(
r##"<?xml version="1.0" encoding="UTF-8"?>
@@ -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#"<a href="javascript:app.alert(1)">one</a> <a href="file:///etc/passwd">two</a> <a href="data:text/html,x">three</a> <a href="help.html">four</a> <a href="HTTPS:">five</a> <a href="{long}">six</a> <a href="MailTo:a@example.com">seven</a>"#
r#"<a href="javascript:app.alert(1)">one</a> <a href="file:///etc/passwd">two</a> <a href="data:text/html,x">three</a> <a href="help.html">four</a> <a href="HTTPS:">five</a> <a href="{long}">six</a> <a href="MailTo:a@example.com">seven</a> <a href="https:///path">eight</a> <a href="https://user@:8080/x">nine</a> <a href="https://user@example.com:8443/x">ten</a>"#
);
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::<Vec<_>>(), ["MailTo:a@example.com"]);
assert_eq!(text_links(&doc).into_iter().map(|(u, _)| u).collect::<Vec<_>>(), ["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:?}");