use crate::charinfo::{CharBox, CharType};
use crate::index::{CharIndex, IndexMap, TextIndex};
use crate::unicode::{is_alnum, is_decimal_digit, lower_string};
use std::ops::Range;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WebLink {
pub url: String,
pub range: Range<CharIndex>,
}
#[must_use]
pub fn extract(chars: &[CharBox], text: &[char], index: &IndexMap) -> Vec<WebLink> {
let mut links = Vec::new();
let mut start = 0usize;
let mut pos = 0usize;
let mut after_hyphen = false;
let mut line_break = false;
let total = chars.len();
while pos < total {
let Some(info) = chars.get(pos) else { break };
if info.char_type != CharType::Generated
&& info.unicode != u32::from(b' ')
&& pos != total - 1
{
after_hyphen = info.char_type == CharType::Hyphen
|| (info.char_type == CharType::Normal && info.unicode == u32::from(b'-'));
pos += 1;
continue;
}
let mut count = pos - start;
if pos == total - 1 {
count += 1;
} else if after_hyphen
&& (info.unicode == u32::from(b'\n') || info.unicode == u32::from(b'\r'))
{
line_break = true;
pos += 1;
continue;
}
let text_start = index.text_index_at_or_after(CharIndex::new(start));
let mut candidate: String = match text_start {
Some(first) if count > 0 => {
let last = index.text_index_end(CharIndex::new(start + count - 1));
substr(text, first.get(), last.get().saturating_sub(first.get()))
}
_ => String::new(),
};
if line_break {
candidate.retain(|ch| ch != '\n' && ch != '\r');
line_break = false;
}
candidate = candidate.replace('\u{00AD}', "-");
if candidate.chars().count() > 5 {
while let Some(last) = candidate.chars().next_back() {
if !matches!(last, ')' | ',' | '>' | '.') {
break;
}
candidate.pop();
count = count.saturating_sub(1);
}
if count > 5 {
if let Some(link) = check_web_link(&candidate) {
let range = char_range(index, text_start, &link.range, start, count);
links.push(WebLink {
url: link.url,
range,
});
} else if let Some(url) = check_mail_link(&candidate) {
links.push(WebLink {
url,
range: CharIndex::new(start)..CharIndex::new(start + count),
});
}
}
}
pos += 1;
start = pos;
}
links
}
fn char_range(
index: &IndexMap,
text_start: Option<TextIndex>,
found: &Range<usize>,
start: usize,
count: usize,
) -> Range<CharIndex> {
let whole = CharIndex::new(start)..CharIndex::new(start + count);
let Some(first) = text_start else {
return whole;
};
let (Some(from), Some(to)) = (
index.char_index(TextIndex::new(first.get() + found.start)),
index.char_index(TextIndex::new(first.get() + found.end.saturating_sub(1))),
) else {
return whole;
};
from..CharIndex::new(to.get() + 1)
}
fn substr(text: &[char], first: usize, count: usize) -> String {
if count == 0 {
return String::new();
}
let Some(last) = first.checked_add(count) else {
return String::new();
};
if last > text.len() {
return String::new();
}
text.get(first..last).unwrap_or_default().iter().collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FoundLink {
pub url: String,
pub range: Range<usize>,
}
#[must_use]
pub fn check_web_link(candidate: &str) -> Option<FoundLink> {
let original: Vec<char> = candidate.chars().collect();
let lower: Vec<char> = lower_string(candidate).chars().collect();
let len = lower.len();
if let Some(start) = find(&lower, "http") {
let mut off = start + 4;
if len > off + 4 {
if lower.get(off) == Some(&'s') {
off += 1;
}
if lower.get(off) == Some(&':')
&& lower.get(off + 1) == Some(&'/')
&& lower.get(off + 2) == Some(&'/')
{
off += 3;
let trimmed = trim_external_brackets(&lower, start, len.saturating_sub(1));
let end = find_web_link_ending(&lower, off, trimmed);
if end > off {
let count = end - start + 1;
return Some(FoundLink {
url: original.get(start..start + count)?.iter().collect(),
range: start..start + count,
});
}
}
}
}
if let Some(start) = find(&lower, "www.") {
let off = start + 4;
if len > off {
let trimmed = trim_external_brackets(&lower, start, len.saturating_sub(1));
let end = find_web_link_ending(&lower, start, trimmed);
if end > off {
let count = end - start + 1;
let text: String = original.get(start..start + count)?.iter().collect();
return Some(FoundLink {
url: format!("http://{text}"),
range: start..start + count,
});
}
}
}
None
}
fn find(haystack: &[char], needle: &str) -> Option<usize> {
let needle: Vec<char> = needle.chars().collect();
let last = haystack.len().checked_sub(needle.len())?;
(0..=last).find(|start| haystack.get(*start..start + needle.len()) == Some(needle.as_slice()))
}
#[must_use]
pub fn find_web_link_ending(text: &[char], start: usize, mut end: usize) -> usize {
if text.get(start..).is_some_and(|rest| rest.contains(&'/')) {
return end;
}
if text.get(start) == Some(&'[') {
let Some(offset) = text
.get(start + 1..)
.and_then(|rest| rest.iter().position(|ch| *ch == ']'))
else {
return end;
};
end = start + 1 + offset;
if end > start + 1 {
let len = text.len();
let mut off = end + 1;
if off < len && text.get(off) == Some(&':') {
off += 1;
while off < len
&& text
.get(off)
.copied()
.is_some_and(|ch| is_decimal_digit(u32::from(ch)))
{
off += 1;
}
if off > end + 2 && off <= len {
end = off - 1;
}
}
}
return end;
}
while end > start
&& text
.get(end)
.copied()
.is_some_and(|ch| u32::from(ch) < 0x80)
{
let Some(&ch) = text.get(end) else { break };
if is_decimal_digit(u32::from(ch)) || ch.is_ascii_lowercase() || ch == '.' {
break;
}
end -= 1;
}
end
}
#[must_use]
pub fn trim_external_brackets(text: &[char], start: usize, mut end: usize) -> usize {
for pos in 0..start {
let closing = match text.get(pos) {
Some('(') => ')',
Some('[') => ']',
Some('{') => '}',
Some('<') => '>',
Some('"') => '"',
Some('\'') => '\'',
_ => continue,
};
trim_backwards_to(text, closing, start, &mut end);
}
end
}
fn trim_backwards_to(text: &[char], target: char, start: usize, end: &mut usize) {
if *end < start {
return;
}
for pos in (start..=*end).rev() {
if text.get(pos) == Some(&target) {
*end = pos.saturating_sub(1);
return;
}
}
}
#[must_use]
pub fn check_mail_link(candidate: &str) -> Option<String> {
let mut text: Vec<char> = candidate.chars().collect();
let at = text.iter().position(|ch| *ch == '@')?;
if at == 0 || at == text.len() - 1 {
return None;
}
let mut marker = at;
for i in (1..=at).rev() {
let Some(&ch) = text.get(i - 1) else { break };
if ch == '_' || ch == '-' || is_alnum(u32::from(ch)) {
continue;
}
if ch != '.' || i == marker || i == 1 {
if i == at {
return None;
}
let removed = if i == marker { i + 1 } else { i };
text = text.get(removed..)?.to_vec();
break;
}
marker = i - 1;
}
let at = text.iter().position(|ch| *ch == '@')?;
if at == 0 {
return None;
}
while text.last() == Some(&'.') {
text.pop();
}
let dot = text.get(at + 1..)?.iter().position(|ch| *ch == '.')? + at + 1;
if dot == at + 1 {
return None;
}
let len = text.len();
let mut marker = 0usize;
for i in (at + 1)..len {
let Some(&ch) = text.get(i) else { break };
if ch == '-' || is_alnum(u32::from(ch)) {
continue;
}
if ch != '.' || i == marker + 1 {
let host_end = if i == marker + 1 {
i.checked_sub(2)
} else {
i.checked_sub(1)
};
let host_end = host_end?;
if marker > 0 && host_end.checked_sub(at).is_some_and(|span| span >= 3) {
text.truncate(host_end + 1);
break;
}
return None;
}
marker = i;
}
let address: String = text.iter().collect();
if address.contains("mailto:") {
Some(address)
} else {
Some(format!("mailto:{address}"))
}
}
#[cfg(test)]
mod tests {
#![allow(
clippy::float_cmp,
clippy::indexing_slicing,
clippy::unreadable_literal,
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
reason = "test fixtures quote oracle vectors verbatim and compare exactly"
)]
use super::*;
#[test]
fn a_candidate_is_cut_in_text_space_and_reported_in_char_space() {
use crate::charinfo::CharType;
use kurbo::{Affine, Point, Rect};
fn boxed(char_type: CharType, ch: char) -> CharBox {
CharBox {
char_type,
unicode: u32::from(ch),
code: Some(pdfrum_font::CharCode(u32::from(ch))),
origin: Point::ZERO,
char_box: Rect::ZERO,
loose_char_box: Rect::ZERO,
matrix: Affine::IDENTITY,
object: None,
font_size: 1.0,
angle: 0.0,
}
}
let mut chars: Vec<CharBox> = vec![
boxed(CharType::NotUnicode, '\u{0002}'),
boxed(CharType::NotUnicode, '\u{0003}'),
];
chars.extend(
"http://a.com "
.chars()
.map(|ch| boxed(CharType::Normal, ch)),
);
let text: Vec<char> = "http://a.com ".chars().collect();
let links = extract(&chars, &text, &crate::index::build(&chars));
assert_eq!(links.len(), 1, "the URL is found");
assert_eq!(links[0].url, "http://a.com");
assert_eq!(links[0].range, CharIndex::new(2)..CharIndex::new(14));
}
fn web(candidate: &str) -> Option<(String, usize, usize)> {
check_web_link(candidate).map(|link| (link.url, link.range.start, link.range.len()))
}
#[test]
fn mail_addresses_that_are_rejected() {
for invalid in [
"",
"peter.pan",
"abc@server",
"abc.@gmail.com",
"abc@xyz&q.org",
"abc@.xyz.org",
"fan@g..com",
] {
assert_eq!(check_mail_link(invalid), None, "{invalid:?}");
}
}
#[test]
fn mail_addresses_that_are_accepted() {
for (input, expected) in [
("peter@abc.d", "mailto:peter@abc.d"),
("red.teddy.b@abc.com", "mailto:red.teddy.b@abc.com"),
("abc_@gmail.com", "mailto:abc_@gmail.com"),
("dummy-hi@gmail.com", "mailto:dummy-hi@gmail.com"),
("a..df@gmail.com", "mailto:df@gmail.com"),
(".john@yahoo.com", "mailto:john@yahoo.com"),
("abc@xyz.org?/", "mailto:abc@xyz.org"),
("fan{abc@xyz.org", "mailto:abc@xyz.org"),
("fan@g.com..", "mailto:fan@g.com"),
("CAP.cap@Gmail.Com", "mailto:CAP.cap@Gmail.Com"),
] {
assert_eq!(
check_mail_link(input).as_deref(),
Some(expected),
"{input:?}"
);
}
}
#[test]
fn web_addresses_that_are_rejected() {
for invalid in [
"",
"http",
"www.",
"https-and-www",
"http:/abc.com",
"http://((()),",
"ftp://example.com",
"http:example.com",
"http//[example.com",
"http//[00:00:00:00:00:00",
"http//[]",
"abc.example.com",
] {
assert_eq!(web(invalid), None, "{invalid:?}");
}
}
#[test]
#[expect(
clippy::too_many_lines,
reason = "the upstream table is one row per case and reads best whole"
)]
fn web_addresses_that_are_accepted() {
for (input, url, start, count) in [
(
"http://www.example.com",
"http://www.example.com",
0usize,
22usize,
),
(
"http://www.example.com:88",
"http://www.example.com:88",
0usize,
25usize,
),
(
"http://test@www.example.com",
"http://test@www.example.com",
0usize,
27usize,
),
(
"http://test:test@example.com",
"http://test:test@example.com",
0usize,
28usize,
),
("http://example", "http://example", 0usize, 14usize),
("http////www.server", "http://www.server", 8usize, 10usize),
("http:/www.abc.com", "http://www.abc.com", 6usize, 11usize),
("www.a.b.c", "http://www.a.b.c", 0usize, 9usize),
("https://a.us", "https://a.us", 0usize, 12usize),
("https://www.t.us", "https://www.t.us", 0usize, 16usize),
(
"www.example-test.com",
"http://www.example-test.com",
0usize,
20usize,
),
(
"www.example.com,",
"http://www.example.com",
0usize,
15usize,
),
(
"www.example.com;(",
"http://www.example.com",
0usize,
15usize,
),
("test:www.abc.com", "http://www.abc.com", 5usize, 11usize),
(
"(http://www.abc.com)",
"http://www.abc.com",
1usize,
18usize,
),
(
"0(http://www.abc.com)0",
"http://www.abc.com",
2usize,
18usize,
),
("0(www.abc.com)0", "http://www.abc.com", 2usize, 11usize),
(
"http://www.abc.com)0",
"http://www.abc.com)0",
0usize,
20usize,
),
(
"{(<http://www.abc.com>)}",
"http://www.abc.com",
3usize,
18usize,
),
(
"[http://www.abc.com/z(1)]",
"http://www.abc.com/z(1)",
1usize,
23usize,
),
(
"(http://www.abc.com/z(1))",
"http://www.abc.com/z(1)",
1usize,
23usize,
),
(
"\"http://www.abc.com\"",
"http://www.abc.com",
1usize,
18usize,
),
("www.g.com..", "http://www.g.com..", 0usize, 11usize),
("http://192.168.0.1", "http://192.168.0.1", 0usize, 18usize),
(
"http://192.168.0.1:80",
"http://192.168.0.1:80",
0usize,
21usize,
),
(
"http://[aa::00:bb::00:cc:00]",
"http://[aa::00:bb::00:cc:00]",
0usize,
28usize,
),
(
"http://[aa::00:bb::00:cc:00]:12",
"http://[aa::00:bb::00:cc:00]:12",
0usize,
31usize,
),
("http://[aa]:12", "http://[aa]:12", 0usize, 14usize),
("http://[aa]:12abc", "http://[aa]:12", 0usize, 14usize),
("http://[aa]:", "http://[aa]", 0usize, 11usize),
(
"www.abc.com/#%%^&&*(",
"http://www.abc.com/#%%^&&*(",
0usize,
20usize,
),
(
"www.a.com/#a=@?q=rr&r=y",
"http://www.a.com/#a=@?q=rr&r=y",
0usize,
23usize,
),
(
"http://a.com/1/2/3/4\u{5}\u{6}",
"http://a.com/1/2/3/4\u{5}\u{6}",
0usize,
22usize,
),
(
"http://www.example.com/foo;bar",
"http://www.example.com/foo;bar",
0usize,
30usize,
),
("http://ex[am]ple", "http://ex[am]ple", 0usize, 16usize),
(
"http://:example.com",
"http://:example.com",
0usize,
19usize,
),
("http://((())/path?", "http://((())/path?", 0usize, 18usize),
(
"http:////abc.server",
"http:////abc.server",
0usize,
19usize,
),
(
"www.\u{6d4b}\u{8bd5}.net",
"http://www.\u{6d4b}\u{8bd5}.net",
0usize,
10usize,
),
(
"www.\u{6d4b}\u{8bd5}\u{3002}net\u{3002}",
"http://www.\u{6d4b}\u{8bd5}\u{3002}net\u{3002}",
0usize,
11usize,
),
(
"www.\u{6d4b}\u{8bd5}.net;",
"http://www.\u{6d4b}\u{8bd5}.net\u{ff1b}",
0usize,
11usize,
),
] {
assert_eq!(
web(input),
Some((url.to_owned(), start, count)),
"{input:?}"
);
}
}
#[test]
fn the_scheme_form_needs_five_characters_after_http() {
assert_eq!(web("http://a"), None);
assert!(web("http://ab").is_some());
}
#[test]
fn a_backwards_trim_from_offset_zero_cannot_underflow() {
let text: Vec<char> = "abc".chars().collect();
let mut end = 2usize;
trim_backwards_to(&text, 'z', 0, &mut end);
assert_eq!(end, 2);
trim_backwards_to(&text, 'b', 0, &mut end);
assert_eq!(end, 0);
}
#[test]
fn a_substring_past_the_end_yields_nothing_rather_than_clamping() {
let text: Vec<char> = "abc".chars().collect();
assert_eq!(substr(&text, 0, 3), "abc");
assert_eq!(substr(&text, 1, 9), "");
assert_eq!(substr(&text, 9, 1), "");
assert_eq!(substr(&text, 0, 0), "");
}
}