From 46ebc6dcf7358ab255b36be2e8a55c229f23cf5f Mon Sep 17 00:00:00 2001 From: Charan Rathore <180254320+charan-rathore@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:55:31 +0530 Subject: [PATCH 1/3] fix(annot): percent-encode non-ASCII bytes written into link /URI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A link annotation whose target contains non-ASCII characters (for example https://example.com/ação) was written into the PDF /URI string as raw UTF-8 bytes, but URIs in PDF are 7-bit ASCII strings (ISO 32000 §12.6.4.8). The saved file then carried an out-of-spec URI that strict readers can reject or misparse. Percent-encode every byte outside the printable ASCII range when writing the /URI entry. Bytes already inside 0x21..=0x7E pass through unchanged, so existing percent-escapes like %20 are preserved. Adds a regression test that round-trips a non-ASCII URL and an already-escaped URL through save/reload and asserts the stored URIs are pure ASCII. Signed-off-by: Charan Rathore <180254320+charan-rathore@users.noreply.github.com> --- crates/annot/src/links.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/annot/src/links.rs b/crates/annot/src/links.rs index 2e45b4e4d4..4d3eef632b 100644 --- a/crates/annot/src/links.rs +++ b/crates/annot/src/links.rs @@ -178,6 +178,19 @@ fn write_style(d: &mut Dict, s: &LinkStyle) { d.set(b"H".to_vec(), Object::name(s.highlight.code())); } +/// Bytes of `u` as a 7-bit ASCII URI string (§12.6.4.8). +fn uri_bytes(u: &str) -> Vec { + let mut out = Vec::with_capacity(u.len()); + for &b in u.as_bytes() { + if (0x21..=0x7e).contains(&b) { + out.push(b); + } else { + out.extend_from_slice(format!("%{b:02X}").as_bytes()); + } + } + out +} + fn write_action(doc: &Document, d: &mut Dict, a: &LinkAction) -> Result<(), AnnotError> { match a { LinkAction::Page(p) => { @@ -194,7 +207,7 @@ fn write_action(doc: &Document, d: &mut Dict, a: &LinkAction) -> Result<(), Anno let mut act = Dict::new(); act.set(b"S".to_vec(), Object::name("URI")); // URIs are 7-bit ASCII strings (§12.6.4.8). - act.set(b"URI".to_vec(), PdfString::literal(u.as_bytes().to_vec())); + act.set(b"URI".to_vec(), PdfString::literal(uri_bytes(u))); d.set(b"A".to_vec(), Object::Dict(act)); } LinkAction::Other(_) => {} From 8947465af75a11b1016d99b3fc10163e4686c019 Mon Sep 17 00:00:00 2001 From: Charan Rathore <180254320+charan-rathore@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:56:26 +0530 Subject: [PATCH 2/3] test(annot): cover 7-bit ASCII percent-encoding of link /URI Regression test for the /URI percent-encoding fix: round-trips a non-ASCII URL and an already-escaped URL through save/reload and asserts the stored URIs are pure ASCII with existing escapes preserved. Signed-off-by: Charan Rathore <180254320+charan-rathore@users.noreply.github.com> --- crates/annot/src/tests.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index faed409abb..63acc5fc6b 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -588,6 +588,25 @@ fn links_are_added_edited_listed_and_removed() { assert!(links::add(&mut doc, 0, [0.0, 0.0, 1.0, 1.0], &LinkAction::Page(0), &LinkStyle::default()).is_err(), "too small"); } +#[test] +fn link_uri_is_percent_encoded_to_7_bit_ascii() { + use crate::links::{self, LinkAction, LinkStyle}; + let mut doc = fixture(); + for url in ["https://example.com/ação", "https://example.com/a%20b"] { + links::add(&mut doc, 0, [10.0, 10.0, 110.0, 30.0], &LinkAction::Uri(url.into()), &LinkStyle::default()).unwrap(); + } + let doc = reopen(&doc); + let urls: Vec = links::list(&doc) + .into_iter() + .filter_map(|link| match link.action { + LinkAction::Uri(url) => Some(url), + _ => None, + }) + .collect(); + assert_eq!(urls, ["https://example.com/a%C3%A7%C3%A3o", "https://example.com/a%20b"]); + assert!(urls.iter().all(|url| url.is_ascii())); +} + #[test] fn link_edit_rejects_non_finite_and_overflowed_rectangles_without_mutating_the_pdf() { use crate::links::{self, LinkAction, LinkStyle}; @@ -1307,4 +1326,4 @@ fn text_box_font_size_changes_the_da_and_grows_the_box() { assert!(set_font_size(&mut doc, 0, i, 12.0, &meta("")).is_err()); assert_eq!(props(&doc, 0, i).unwrap().font_size, None); } -} + } From 53425fc60602688fb556829d1b71be23caab5c7c Mon Sep 17 00:00:00 2001 From: Charan Rathore <180254320+charan-rathore@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:57:38 +0530 Subject: [PATCH 3/3] test(annot): fix final-line indentation Corrects the closing-brace indentation on the last line so cargo fmt --check stays clean. Signed-off-by: Charan Rathore <180254320+charan-rathore@users.noreply.github.com> --- crates/annot/src/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 63acc5fc6b..d2105984d1 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1326,4 +1326,4 @@ fn text_box_font_size_changes_the_da_and_grows_the_box() { assert!(set_font_size(&mut doc, 0, i, 12.0, &meta("")).is_err()); assert_eq!(props(&doc, 0, i).unwrap().font_size, None); } - } +}