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")));
+ }
+}