Skip to main content

docling_core/
hyperlink.rs

1//! Hyperlink destinations as the serializers write them (#860; docling-core
2//! 2.104.0's `is_safe_hyperlink` / `markdown_link_destination`).
3//!
4//! A destination comes from the converted document, where any string is a
5//! valid `hyperlink` — a DOCX relationship, an HTML `href`, a PDF link
6//! annotation. One with an active scheme (`javascript:`, `data:`,
7//! `vbscript:`) must not reach a reader as a clickable link, and one with a
8//! space or a `)` must not end a Markdown inline link early and leave its
9//! tail to be read as Markdown or raw HTML. The backends bake links into an
10//! item's text as `[text](url)` (the flat chain), so the sanitizing lives in
11//! one helper every bake site and every serializer goes through: an unsafe
12//! destination keeps the link text and drops the link; a safe one is written
13//! percent-escaped. The JSON export keeps the raw URI, as docling's model
14//! does — a consumer of the JSON decides for itself.
15
16/// URI schemes that are written as live hyperlinks; any other scheme keeps
17/// only the link text (docling-core's `SAFE_HYPERLINK_SCHEMES`).
18pub const SAFE_HYPERLINK_SCHEMES: &[&str] = &[
19    "http", "https", "ftp", "ftps", "sftp", "mailto", "tel", "file",
20];
21
22/// Whether a hyperlink destination may be rendered as a live link.
23///
24/// The scheme is read the way browsers do: control characters and spaces
25/// ahead of it are ignored and tabs / newlines are removed anywhere, so
26/// `" java\nscript:alert(1)"` is `javascript:`. A destination without a
27/// scheme is a relative reference, and a one-letter scheme is a Windows
28/// drive (`C:\…`) — both are safe.
29pub fn is_safe_hyperlink(uri: &str) -> bool {
30    let cleaned: String = uri
31        .chars()
32        .filter(|c| !matches!(c, '\t' | '\r' | '\n'))
33        .skip_while(|&c| (c as u32) < 0x21)
34        .collect();
35    let mut chars = cleaned.chars();
36    let Some(first) = chars.next().filter(char::is_ascii_alphabetic) else {
37        return true;
38    };
39    let mut scheme = first.to_string();
40    for c in chars {
41        if c == ':' {
42            return scheme.len() == 1
43                || SAFE_HYPERLINK_SCHEMES.contains(&scheme.to_ascii_lowercase().as_str());
44        }
45        if c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '-') {
46            scheme.push(c);
47        } else {
48            // Not a scheme at all (`a b:c`, `my/page:1`) — a relative reference.
49            return true;
50        }
51    }
52    true
53}
54
55/// The destination as it can stand inside `[text](…)`: control characters,
56/// whitespace, parentheses, angle brackets, double quotes and backslashes are
57/// percent-encoded — each would otherwise end the destination or open raw
58/// HTML — and an `&` that starts an entity (`&amp;`, `&#39;`) becomes
59/// `&amp;`, since Markdown renderers decode entities in a destination. Every
60/// other byte, an existing `%XX` included, is kept as it is.
61pub fn markdown_link_destination(uri: &str) -> String {
62    let mut out = String::with_capacity(uri.len());
63    let bytes = uri.as_bytes();
64    let mut i = 0;
65    while i < bytes.len() {
66        let b = bytes[i];
67        match b {
68            b'&' if starts_entity(&bytes[i + 1..]) => out.push_str("&amp;"),
69            0x00..=0x20 | 0x7f | b'(' | b')' | b'<' | b'>' | b'"' | b'\\' => {
70                out.push_str(&format!("%{b:02X}"));
71            }
72            _ => {
73                // Copy a whole UTF-8 sequence; only ASCII is ever escaped.
74                let len = utf8_len(b);
75                out.push_str(&uri[i..i + len]);
76                i += len;
77                continue;
78            }
79        }
80        i += 1;
81    }
82    out
83}
84
85/// `&` followed by `#?\w+;` — the regex docling-core matches an entity with
86/// (`\w` there is Unicode-aware; an entity name is ASCII in practice).
87fn starts_entity(rest: &[u8]) -> bool {
88    let rest = rest.strip_prefix(b"#").unwrap_or(rest);
89    let n = rest
90        .iter()
91        .take_while(|b| b.is_ascii_alphanumeric() || **b == b'_')
92        .count();
93    n > 0 && rest.get(n) == Some(&b';')
94}
95
96fn utf8_len(first: u8) -> usize {
97    match first {
98        0x00..=0x7f => 1,
99        0xc0..=0xdf => 2,
100        0xe0..=0xef => 3,
101        _ => 4,
102    }
103}
104
105/// A Markdown inline link `[text](destination)` around `text` — or `text`
106/// alone when the destination is not [safe](is_safe_hyperlink), the way
107/// docling-core's Markdown serializer writes an item's `hyperlink`.
108pub fn markdown_link(text: &str, uri: &str) -> String {
109    if is_safe_hyperlink(uri) {
110        format!("[{text}]({})", markdown_link_destination(uri))
111    } else {
112        text.to_string()
113    }
114}
115
116/// The destination escaped for an HTML `href="…"` attribute (Python's
117/// `html.escape`: `&`, `<`, `>`, `"`, `'`), for a [safe](is_safe_hyperlink)
118/// destination — the caller leaves the `<a>` out for an unsafe one.
119pub fn html_href(uri: &str) -> String {
120    let mut out = String::with_capacity(uri.len());
121    for c in uri.chars() {
122        match c {
123            '&' => out.push_str("&amp;"),
124            '<' => out.push_str("&lt;"),
125            '>' => out.push_str("&gt;"),
126            '"' => out.push_str("&quot;"),
127            '\'' => out.push_str("&#x27;"),
128            c => out.push(c),
129        }
130    }
131    out
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn scheme_allow_list_read_like_a_browser() {
140        // (destination, safe) — docling-core 2.104.0's table, plus the
141        // browser-style readings the ticket lists.
142        let cases: &[(&str, bool)] = &[
143            ("https://example.org/a", true),
144            ("HTTP://EXAMPLE.ORG", true),
145            ("mailto:a@b.c", true),
146            ("tel:+1-555", true),
147            ("ftp://host/x", true),
148            ("sftp://host/x", true),
149            ("file:///tmp/x", true),
150            ("page.html#frag", true),
151            ("#section", true),
152            ("../up/there", true),
153            ("", true),
154            ("C:\\Users\\x\\doc.pdf", true),
155            ("c:/x", true),
156            ("my page: notes", true),
157            ("javascript:alert(1)", false),
158            ("JAVASCRIPT:alert(1)", false),
159            ("JaVaScRiPt:alert(1)", false),
160            (" javascript:alert(1)", false),
161            ("\u{0}javascript:alert(1)", false),
162            ("\u{1}\u{2} \tjavascript:alert(1)", false),
163            ("java\nscript:alert(1)", false),
164            ("java\tscript:alert(1)", false),
165            ("java\r\nscript:alert(1)", false),
166            ("data:text/html;base64,PHNjcmlwdD4=", false),
167            ("vbscript:MsgBox", false),
168            ("ms-word:ofe|u|https://x", false),
169            ("x-custom+1.a:thing", false),
170        ];
171        for (uri, safe) in cases {
172            assert_eq!(is_safe_hyperlink(uri), *safe, "{uri:?}");
173        }
174    }
175
176    #[test]
177    fn destination_escaping() {
178        let cases: &[(&str, &str)] = &[
179            ("https://x.org/a(b)%20c d", "https://x.org/a%28b%29%20c%20d"),
180            ("https://x.org/?a=1&b=2", "https://x.org/?a=1&b=2"),
181            (
182                "https://x.org/?a=1&amp;b=2",
183                "https://x.org/?a=1&amp;amp;b=2",
184            ),
185            ("https://x.org/&#39;", "https://x.org/&amp;#39;"),
186            (
187                "https://x.org/<b>\"q\"\\",
188                "https://x.org/%3Cb%3E%22q%22%5C",
189            ),
190            ("https://x.org/\u{7f}\u{1}", "https://x.org/%7F%01"),
191            ("https://x.org/ünïcödé/é", "https://x.org/ünïcödé/é"),
192            ("Duck_(film)", "Duck_%28film%29"),
193        ];
194        for (uri, want) in cases {
195            assert_eq!(markdown_link_destination(uri), *want, "{uri:?}");
196        }
197        assert_eq!(
198            markdown_link("paren", "https://x.org/a(b)%20c d"),
199            "[paren](https://x.org/a%28b%29%20c%20d)"
200        );
201        assert_eq!(markdown_link("click", "javascript:alert(1)"), "click");
202        assert_eq!(markdown_link("click", "#top"), "[click](#top)");
203    }
204
205    #[test]
206    fn href_escaping() {
207        assert_eq!(
208            html_href("https://x.org/?a=1&b=\"2\"<'>"),
209            "https://x.org/?a=1&amp;b=&quot;2&quot;&lt;&#x27;&gt;"
210        );
211    }
212}