diff --git a/CHANGELOG.md b/CHANGELOG.md index e5f95ecac..0a518f3dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ This changelog also contains important changes in dependencies. ## [Unreleased] +### Fixed + +- Local `` hrefs are now percent-decoded when the raw href doesn't point to an existing file. (#1073) + ## [0.48.1] 2026-08-02 This release has an MSRV of 1.85.0 for `usvg` and `resvg` and the C API. diff --git a/crates/usvg/src/parser/image.rs b/crates/usvg/src/parser/image.rs index 35a908b9b..82aa41076 100644 --- a/crates/usvg/src/parser/image.rs +++ b/crates/usvg/src/parser/image.rs @@ -84,7 +84,17 @@ impl ImageHrefResolver<'_> { /// [Options::resources_dir](crate::Options::resources_dir). pub fn default_string_resolver() -> ImageHrefStringResolverFn<'static> { Box::new(move |href: &str, opts: &Options| { - let path = opts.get_abs_path(std::path::Path::new(href)); + let mut path = opts.get_abs_path(std::path::Path::new(href)); + + // `href` is an IRI and therefore can be percent-encoded. + // Editors like Inkscape store non-ASCII file names this way. + // A file name can contain a literal `%` as well, + // so the raw path is checked first. + if !path.exists() { + if let Some(decoded) = percent_decode(href) { + path = opts.get_abs_path(std::path::Path::new(&decoded)); + } + } if path.exists() { let data = match std::fs::read(&path) { @@ -120,6 +130,39 @@ impl std::fmt::Debug for ImageHrefResolver<'_> { } } +/// Decodes percent-encoded characters in an IRI. +/// +/// Returns `None` when there is nothing to decode +/// or when the decoded data is not a valid UTF-8 string. +/// Invalid escape sequences are preserved as is. +fn percent_decode(href: &str) -> Option { + if !href.contains('%') { + return None; + } + + fn hex_digit(c: u8) -> Option { + (c as char).to_digit(16).map(|d| d as u8) + } + + let src = href.as_bytes(); + let mut dst = Vec::with_capacity(src.len()); + let mut i = 0; + while i < src.len() { + if src[i] == b'%' && i + 2 < src.len() { + if let (Some(h), Some(l)) = (hex_digit(src[i + 1]), hex_digit(src[i + 2])) { + dst.push(h * 16 + l); + i += 3; + continue; + } + } + + dst.push(src[i]); + i += 1; + } + + String::from_utf8(dst).ok() +} + #[derive(Clone, Copy, PartialEq, Debug)] enum ImageFormat { PNG, diff --git a/crates/usvg/tests/parser.rs b/crates/usvg/tests/parser.rs index f517fd998..2b9b1af41 100644 --- a/crates/usvg/tests/parser.rs +++ b/crates/usvg/tests/parser.rs @@ -572,6 +572,63 @@ fn image_bbox_with_parent_transform() { ); } +// An external image used by the tests below. +const EXTERNAL_IMAGE: &str = "\ + \ +"; + +// Creates an empty directory for external image tests. +fn resources_dir(name: &str) -> std::path::PathBuf { + let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join(name); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +// Checks that an `` with the provided `href` was resolved +// relative to `resources_dir`. +fn is_image_resolved(dir: &std::path::Path, href: &str) -> bool { + let svg = format!( + "\ + \ + ", + href + ); + + let opt = usvg::Options { + resources_dir: Some(dir.to_path_buf()), + ..usvg::Options::default() + }; + + let tree = usvg::Tree::from_str(&svg, &opt).unwrap(); + let Some(usvg::Node::Group(group)) = tree.root().children().first() else { + return false; + }; + matches!(group.children().first(), Some(usvg::Node::Image(_))) +} + +#[test] +fn percent_encoded_image_href() { + let dir = resources_dir("percent-encoded-image-href"); + std::fs::create_dir(dir.join("images")).unwrap(); + std::fs::write(dir.join("images").join("细节3-mine.svg"), EXTERNAL_IMAGE).unwrap(); + + // Editors like Inkscape store non-ASCII file names percent-encoded. + assert!(is_image_resolved( + &dir, + "images/%E7%BB%86%E8%8A%823-mine.svg" + )); +} + +#[test] +fn image_href_with_literal_percent() { + let dir = resources_dir("image-href-with-literal-percent"); + // `%41` is a valid escape sequence for `A`, but here it's just a file name. + std::fs::write(dir.join("%41.svg"), EXTERNAL_IMAGE).unwrap(); + + assert!(is_image_resolved(&dir, "%41.svg")); +} + #[test] fn no_text_nodes() { let svg = "