From 6ecf286f6b8da0948befdf3a350e082820b7f833 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:42:27 +0000 Subject: [PATCH] Drop invalid XML characters from SVG link URLs and aria-labels Found while testing crates/svg/src/export/nodes.rs:492 (Writer::node, `xlink:href` built with xml_escape) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/svg/src/export/nodes.rs | 7 +++++-- crates/svg/src/export/output.rs | 6 ++++++ crates/svg/src/export/text.rs | 4 ++-- crates/svg/tests/it/data_attributes.rs | 14 ++++++++++++++ 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/crates/svg/src/export/nodes.rs b/crates/svg/src/export/nodes.rs index d2b18c39b..7be748391 100644 --- a/crates/svg/src/export/nodes.rs +++ b/crates/svg/src/export/nodes.rs @@ -1,4 +1,4 @@ -use super::output::{sanitize_id, valid_xml_value, xml_attr_escape}; +use super::output::{sanitize_id, valid_xml_value, xml_attr_escape, xml_attr_valid}; use super::paint::{StrokePlan, has_raster, pixel_gradient}; use super::text::OUTLINE_TOLERANCE; use super::{ @@ -489,7 +489,10 @@ impl Writer<'_> { self.layer_nest += layer; match n.url().filter(|_| n.visible && !self.anonymous) { Some(url) => { - self.line(&format!("", xml_escape(url))); + if !valid_xml_value(url) { + self.warn("Link URLs containing invalid XML characters had those characters removed"); + } + self.line(&format!("", xml_attr_valid(url))); self.depth += 1; self.node_body(n); self.depth -= 1; diff --git a/crates/svg/src/export/output.rs b/crates/svg/src/export/output.rs index cc7680e90..01d33c833 100644 --- a/crates/svg/src/export/output.rs +++ b/crates/svg/src/export/output.rs @@ -19,6 +19,12 @@ pub(super) fn xml_text(s: &str) -> String { xml_escape(&s) } +/// `s` without the characters XML 1.0 does not allow, ready for a double-quoted attribute. +pub(super) fn xml_attr_valid(s: &str) -> String { + let s: String = s.chars().filter(|c| valid_xml_value(c.encode_utf8(&mut [0; 4]))).collect(); + xml_attr_escape(&s) +} + pub(super) fn valid_xml_value(s: &str) -> bool { s.chars().all(|c| matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{d7ff}' | '\u{e000}'..='\u{fffd}' | '\u{10000}'..='\u{10ffff}')) } diff --git a/crates/svg/src/export/text.rs b/crates/svg/src/export/text.rs index bab0cf327..5fc015039 100644 --- a/crates/svg/src/export/text.rs +++ b/crates/svg/src/export/text.rs @@ -1,4 +1,4 @@ -use super::output::xml_text; +use super::output::{xml_attr_valid, xml_text}; use super::{ Affine, AppearanceItem, Arc, BTreeSet, BezPath, BlendMode, CharStyle, FillRule, FontDb, FontFace, GradientKind, HashMap, Justify, Node, NodeId, NodeKind, Paint, PathData, Props, Rect, StrokeAlign, Styling, TextKind, TextObject, Writer, base64_encode, css, css_string, fmt_num, @@ -163,7 +163,7 @@ impl Writer<'_> { // Glyphs bent along a path (joined scripts on a curve) can only be outlines: the group // names its text for screen readers and search. let label = if lay.glyphs.iter().any(|g| g.bend.is_some()) { - format!(" role=\"img\" aria-label=\"{}\"", xml_escape(&t.plain_text())) + format!(" role=\"img\" aria-label=\"{}\"", xml_attr_valid(&t.plain_text())) } else { String::new() }; diff --git a/crates/svg/tests/it/data_attributes.rs b/crates/svg/tests/it/data_attributes.rs index 752b8f1c3..d652b9665 100644 --- a/crates/svg/tests/it/data_attributes.rs +++ b/crates/svg/tests/it/data_attributes.rs @@ -277,3 +277,17 @@ fn invalid_symbol_name_alias_does_not_break_xml() { } } } + +#[test] +fn link_url_with_invalid_xml_characters_does_not_break_export() { + let mut doc = import(&svg(r#""#)).unwrap(); + let id = named(&doc, "shape").id; + doc.node_mut(id).unwrap().edit_attrs(|a| a.url = "http://a/\u{1}b\u{ffff}".into()); + for styling in styles() { + let out = export_full(&doc, &ExportOptions { styling, ..Default::default() }, None); + let parsed = xml(&out.svg); + let a = parsed.descendants().find(|n| n.has_tag_name("a")).unwrap(); + assert_eq!(a.attribute(("http://www.w3.org/1999/xlink", "href")), Some("http://a/b")); + assert!(out.warnings.iter().any(|w| w.contains("invalid XML characters"))); + } +}