Skip to content
Open
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ This changelog also contains important changes in dependencies.

This release has an MSRV of 1.87.0 for `usvg` and `resvg` and the C API.

### Fixed
- The unprefixed `href` attribute now takes precedence over the deprecated `xlink:href` when both are present, as required by SVG 2. (#1015)

## [0.47.0] 2026-02-05

This release has an MSRV of 1.87.0 for `usvg` and `resvg` and the C API.
Expand Down
36 changes: 34 additions & 2 deletions crates/usvg/src/parser/svgtree/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,25 @@ pub(crate) fn parse_svg_element<'input>(
continue;
}

// SVG 2: when both `href` and `xlink:href` are present, the unprefixed
// `href` takes precedence and the XLink one must be ignored, regardless
// of their order in the source document.
// https://www.w3.org/TR/SVG2/linking.html
if aid == AId::Href {
let is_xlink = attr.namespace() == Some(XLINK_NS);
if let Some(existing) = doc.attrs[attrs_start_idx..]
.iter_mut()
.find(|a| a.name == AId::Href)
{
// An `href` was already stored. Only an unprefixed `href` is
// allowed to override it; an `xlink:href` is ignored.
if !is_xlink {
existing.value = attr.value_storage().clone();
}
continue;
}
}

append_attribute(
parent_id,
tag_name,
Expand Down Expand Up @@ -534,9 +553,22 @@ fn resolve_href<'a, 'input: 'a>(
node: roxmltree::Node<'a, 'input>,
id_map: &HashMap<&str, roxmltree::Node<'a, 'input>>,
) -> Option<roxmltree::Node<'a, 'input>> {
// SVG 2 mandates that the unprefixed `href` takes precedence over the
// deprecated `xlink:href` when both are present, regardless of their order
// in the source document.
// https://www.w3.org/TR/SVG2/linking.html
//
// Note: `roxmltree::Node::attribute("href")` matches by local name only and
// would return whichever `href`/`xlink:href` comes first, so we have to
// filter by namespace explicitly.
let link_value = node
.attribute((XLINK_NS, "href"))
.or_else(|| node.attribute("href"))?;
.attributes()
.find(|a| a.name() == "href" && a.namespace().is_none())
.or_else(|| {
node.attributes()
.find(|a| a.name() == "href" && a.namespace() == Some(XLINK_NS))
})
.map(|a| a.value())?;

let link_id = svgtypes::IRI::from_str(link_value).ok()?.0;

Expand Down
79 changes: 79 additions & 0 deletions crates/usvg/tests/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,85 @@ fn image_bbox_with_parent_transform() {
);
}

// SVG 2 mandates that the unprefixed `href` takes precedence over the
// deprecated `xlink:href` when both are present, regardless of their order.
// https://www.w3.org/TR/SVG2/linking.html
// https://github.com/linebender/resvg/issues/1015
fn use_href_precedence(use_attrs: &str) {
let svg = format!(
"<svg width='200' height='200' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'>
<defs>
<rect id='red' x='0' y='0' width='100' height='100' fill='red'/>
<rect id='green' x='0' y='0' width='100' height='100' fill='green'/>
</defs>
<use {use_attrs}/>
</svg>"
);

let tree = usvg::Tree::from_str(&svg, &usvg::Options::default()).unwrap();
let usvg::Node::Group(group) = &tree.root().children()[0] else {
unreachable!()
};
let usvg::Node::Path(path) = &group.children()[0] else {
unreachable!()
};

// `href` points to `#green`, so the result must be green.
assert_eq!(
path.fill().unwrap().paint(),
&usvg::Paint::Color(Color::new_rgb(0, 128, 0))
);
}

#[test]
fn use_href_takes_precedence_over_xlink_href() {
use_href_precedence("href='#green' xlink:href='#red'");
}

#[test]
fn use_href_takes_precedence_over_xlink_href_reordered() {
// Precedence must not depend on the source order of the attributes.
use_href_precedence("xlink:href='#red' href='#green'");
}

#[test]
fn use_falls_back_to_xlink_href() {
// The legacy `xlink:href` must still work when `href` is absent.
use_href_precedence("xlink:href='#green'");
}

#[test]
fn gradient_href_takes_precedence_over_xlink_href() {
// The same precedence rule applies to attribute references resolved via
// the stored `href` attribute (gradients, `image`, `textPath`, …).
let svg = "<svg width='100' height='100' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'>
<defs>
<linearGradient id='green'>
<stop offset='0' stop-color='green'/>
<stop offset='1' stop-color='green'/>
</linearGradient>
<linearGradient id='red'>
<stop offset='0' stop-color='red'/>
<stop offset='1' stop-color='red'/>
</linearGradient>
<linearGradient id='g' xlink:href='#red' href='#green'/>
</defs>
<rect width='100' height='100' fill='url(#g)'/>
</svg>";

let tree = usvg::Tree::from_str(svg, &usvg::Options::default()).unwrap();
let usvg::Node::Path(path) = &tree.root().children()[0] else {
unreachable!()
};

let usvg::Paint::LinearGradient(gradient) = path.fill().unwrap().paint() else {
unreachable!()
};

// The gradient must inherit the green stops referenced via `href`.
assert_eq!(gradient.stops()[0].color(), Color::new_rgb(0, 128, 0));
}

#[test]
fn no_text_nodes() {
let svg = "
Expand Down
Loading