From 475ba82feaf5ef67ae70963c4922f98331b3095a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Max=20Mittelst=C3=A4dt?=
<62167861+TheGalitube@users.noreply.github.com>
Date: Sun, 8 Nov 2026 18:03:44 -0800
Subject: [PATCH] Track XMP attribute quotes only inside tags (#5271)
---
crates/organize/src/xmp.rs | 40 ++++++++++++++++++++++++++++----------
1 file changed, 30 insertions(+), 10 deletions(-)
diff --git a/crates/organize/src/xmp.rs b/crates/organize/src/xmp.rs
index 5b6f3930c8..60e08571a1 100644
--- a/crates/organize/src/xmp.rs
+++ b/crates/organize/src/xmp.rs
@@ -294,24 +294,31 @@ fn element_span(xmp: &str, name: &str) -> Option<(usize, usize)> {
/// start and end of its value, and its quote character. Only matches inside a start tag.
fn attribute_span(xmp: &str, name: &str) -> Option<(usize, usize, usize, char)> {
let mut from = 0;
- // The last `<` and `>` before the match, found by one pass over the packet rather than by
- // searching back from every match.
- let (mut scanned, mut last_open, mut last_close) = (0, None, None);
+ // Track tag and quote state in one pass rather than searching back from every match.
+ let (mut scanned, mut in_tag, mut quote) = (0, false, None);
while let Some(i) = xmp.get(from..).and_then(|s| s.find(name)).map(|i| i + from) {
from = i + name.len();
let before = xmp.get(..i)?;
let gap = xmp.get(scanned..i)?;
#[cfg(test)]
crate::work(gap.len());
- if let Some(at) = gap.rfind('<') {
- last_open = Some(scanned + at);
- }
- if let Some(at) = gap.rfind('>') {
- last_close = Some(scanned + at);
+ for c in gap.chars() {
+ if let Some(q) = quote {
+ if c == q {
+ quote = None;
+ }
+ } else {
+ match c {
+ '<' => in_tag = true,
+ '>' if in_tag => in_tag = false,
+ '\'' | '\"' if in_tag => quote = Some(c),
+ _ => {}
+ }
+ }
}
scanned = i;
- // Preceded by whitespace, and inside a tag (the last `<` comes after the last `>`).
- if !before.ends_with(char::is_whitespace) || last_open < last_close || last_open.is_none() {
+ // Preceded by whitespace, and inside a start tag.
+ if !before.ends_with(char::is_whitespace) || !in_tag || quote.is_some() {
continue;
}
let rest = xmp.get(from..)?;
@@ -417,6 +424,19 @@ mod tests {
assert!(out.contains("New"), "{out}");
}
+ #[test]
+ fn attribute_like_text_inside_another_quoted_attribute_is_not_a_property() {
+ let packet = "";
+ assert_eq!(attribute_span(packet, "dc:title"), None);
+ }
+
+ #[test]
+ fn apostrophe_in_text_does_not_hide_a_later_attribute() {
+ let packet = "don't";
+ let found = attribute_span(packet, "dc:title").and_then(|(_, start, end, quote)| packet.get(start..end).map(|value| (value, quote)));
+ assert_eq!(found, Some(("real", '\'')));
+ }
+
#[test]
fn hostile_packets_do_not_panic() {
for packet in ["", "<", "", "