mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 18:33:22 +00:00
M12: only a link's own underline joins its pieces, and web links need a host
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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:?}");
|
||||
|
||||
Reference in New Issue
Block a user