Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ This changelog also contains important changes in dependencies.

## [Unreleased]

### Fixed

- Local `<image>` 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.
Expand Down
45 changes: 44 additions & 1 deletion crates/usvg/src/parser/image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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<String> {
if !href.contains('%') {
return None;
}

fn hex_digit(c: u8) -> Option<u8> {
(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,
Expand Down
57 changes: 57 additions & 0 deletions crates/usvg/tests/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,63 @@ fn image_bbox_with_parent_transform() {
);
}

// An external image used by the tests below.
const EXTERNAL_IMAGE: &str = "<svg xmlns='http://www.w3.org/2000/svg' width='10' height='10'>\
<rect width='10' height='10'/>\
</svg>";

// 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 `<image>` with the provided `href` was resolved
// relative to `resources_dir`.
fn is_image_resolved(dir: &std::path::Path, href: &str) -> bool {
let svg = format!(
"<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100'>\
<image href='{}' width='100' height='100'/>\
</svg>",
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 = "
Expand Down
Loading