Merge PR #2185: fix(docbin): preserve external URLs with sublocations in doc hyperlink fields

This commit is contained in:
aiongg
2026-10-22 01:19:47 +00:00
2 changed files with 76 additions and 17 deletions
+67 -17
View File
@@ -581,30 +581,80 @@ fn finish_field(pb: &mut ParaBuild, f: FieldBuild) {
/// `HYPERLINK "url"` / `HYPERLINK url \l "bookmark"` → the model link target
/// (`#bookmark` for internal links); `None` when not a hyperlink.
fn hyperlink_target(instr: &str) -> Option<String> {
let rest = instr.strip_prefix("HYPERLINK").or_else(|| instr.strip_prefix("hyperlink"))?;
let rest = rest.strip_prefix(' ').unwrap_or(rest);
let t = instr.trim();
let rest = if t.get(..9).is_some_and(|h| h.eq_ignore_ascii_case("hyperlink")) {
t[9..].trim()
} else {
return None;
};
if rest.is_empty() {
return None;
}
let mut quoted = None;
let mut local = None;
let mut tokens = rest.split_whitespace().peekable();
while let Some(tok) = tokens.next() {
match tok {
"\\l" => {
local = Some(tokens.next().unwrap_or_default().trim_matches('"').to_string());
let mut url: Option<String> = None;
let mut local: Option<String> = None;
let mut switch: Option<&str> = None;
let mut i = 0;
let bytes = rest.as_bytes();
while i < bytes.len() {
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i >= bytes.len() {
break;
}
if bytes[i] == b'\\' {
let start = i;
i += 1;
while i < bytes.len() && (bytes[i].is_ascii_alphabetic() || bytes[i] == b'*') {
i += 1;
}
"\\o" | "\\m" | "\\n" | "\\t" => {
let _ = tokens.next();
switch = Some(&rest[start..i]);
continue;
}
let arg = if bytes[i] == b'"' {
i += 1;
let start = i;
while i < bytes.len() && bytes[i] != b'"' {
i += 1;
}
let val = &rest[start..i];
if i < bytes.len() && bytes[i] == b'"' {
i += 1;
}
val.to_string()
} else {
let start = i;
while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
i += 1;
}
rest[start..i].to_string()
};
let clean_arg = arg.replace("%22", "\"");
match switch.take() {
Some("\\l") => local = Some(clean_arg),
Some(_) => {}
None => {
if url.is_none() {
url = Some(clean_arg);
}
}
_ if quoted.is_none() => quoted = Some(tok.trim_matches('"').to_string()),
_ => {}
}
}
Some(match local {
Some(name) if !name.is_empty() => format!("#{name}"),
_ => quoted.unwrap_or_default(),
})
match (url, local) {
(Some(u), Some(l)) if !u.is_empty() && !l.is_empty() => {
let frag = l.trim_start_matches('#');
if u.contains('#') { Some(u) } else { Some(format!("{u}#{frag}")) }
}
(Some(u), _) if !u.is_empty() => Some(u),
(None, Some(l)) if !l.is_empty() => {
let frag = l.trim_start_matches('#');
Some(format!("#{frag}"))
}
_ => None,
}
}
/// `sprmCPicLocation` from a CHPX grpprl: the fc of the picture in the Data stream.
+9
View File
@@ -1354,6 +1354,15 @@ fn fields_and_hyperlinks() {
assert_eq!(result, "3");
}
#[test]
fn hyperlink_target_sublocation_and_quotes() {
assert_eq!(super::hyperlink_target(r#"HYPERLINK "http://example.com" \l "heading""#), Some("http://example.com#heading".into()));
assert_eq!(super::hyperlink_target(r#"HYPERLINK \l "heading""#), Some("#heading".into()));
assert_eq!(super::hyperlink_target(r#"Hyperlink "http://example.com/a path""#), Some("http://example.com/a path".into()));
assert_eq!(super::hyperlink_target(r#"HYPERLINK "http://example.com" \o "Help text""#), Some("http://example.com".into()));
assert_eq!(super::hyperlink_target(r#"HYPERLINK \l "bm" \o "tip""#), Some("#bm".into()));
}
#[test]
fn bookmarks_become_objects() {
let text = "Hello world\r";