diff --git a/crates/engine/src/file_cmds.rs b/crates/engine/src/file_cmds.rs
index 2299aa3e49..248e017689 100644
--- a/crates/engine/src/file_cmds.rs
+++ b/crates/engine/src/file_cmds.rs
@@ -657,7 +657,42 @@ fn xml_escape(s: &str) -> String {
}
fn xml_unescape(s: &str) -> String {
- s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace("&", "&")
+ let mut out = String::with_capacity(s.len());
+ let mut rest = s;
+ while let Some(i) = rest.find('&') {
+ out.push_str(&rest[..i]);
+ rest = &rest[i..];
+ let decoded = rest.find(';').filter(|&e| e <= 12).and_then(|e| {
+ let c = match &rest[1..e] {
+ "lt" => '<',
+ "gt" => '>',
+ "quot" => '"',
+ "apos" => '\'',
+ "amp" => '&',
+ n => {
+ let n = n.strip_prefix('#')?;
+ let code = match n.strip_prefix(['x', 'X']) {
+ Some(h) => u32::from_str_radix(h, 16).ok()?,
+ None => n.parse().ok()?,
+ };
+ char::from_u32(code)?
+ }
+ };
+ Some((c, e + 1))
+ });
+ match decoded {
+ Some((c, len)) => {
+ out.push(c);
+ rest = &rest[len..];
+ }
+ None => {
+ out.push('&');
+ rest = &rest[1..];
+ }
+ }
+ }
+ out.push_str(rest);
+ out
}
/// The element `…` (first occurrence) as (start, end, inner).
diff --git a/crates/engine/tests/xmp_charref.rs b/crates/engine/tests/xmp_charref.rs
new file mode 100644
index 0000000000..5f167e8c4f
--- /dev/null
+++ b/crates/engine/tests/xmp_charref.rs
@@ -0,0 +1,10 @@
+use photocraft_engine::file_cmds::read_file_info;
+
+#[test]
+fn numeric_character_references_are_decoded() {
+ let xmp = "\
+ Line one
Line two © & co A &\
+ ";
+ let info = read_file_info(Some(xmp));
+ assert_eq!(info["description"], "Line one\nLine two © & co A &");
+}