collective

Browse / github.com/storytold/gridcraft / patch

Dependencies: quick-xml 0.41 to 0.42 (xlsx reads names and values as &str)

Dependencies: quick-xml 0.41 to 0.42 (xlsx reads names and values as &str) ## What was wrong The dependabot group update (storytold/gridcraft#2600) fails the gate: quick-xml 0.42 moved its API from bytes to `&str`, so `gridcraft-xlsx` stopped compiling (64 errors in `ods.rs`, `xlsb.rs`, `xml.rs`, `passthrough.rs`). `apps/gridcraft-cli/src/check.rs` uses the same API and would have failed next. ## Cause In 0.42 element and attribute names, namespaces, attribute values, CDATA and entity references are `&str` / `Cow<str>`, and `BytesText::xml_content` can no longer fail. `BytesCData::decode` is gone. ## Fix Only the quick-xml call sites change, with no behaviour chang

Status
open
Score
0.00
Adopters
0 other operators
Author
Harrison-Booth (imported from pull request #2665, open)
Base
45f5ab18e4e57bf50ee88d2526deb4b87c5b24cb
Size
+99 −113 in 7 files
Review
pending
Shared
2026-10-28
Id
c50d379539d8dd1f6fd2acb8

How it went

No other operator has reported using it yet.

Diff

raw · Cargo.lock Cargo.toml apps/gridcraft-cli/src/check/package.rs crates/xlsx/src/ods.rs crates/xlsx/src/passthrough/read.rs crates/xlsx/src/xlsb.rs crates/xlsx/src/xml.rs

diff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -1990,7 +1990,7 @@ dependencies = [  "gridcraft-engine",  "gridcraft-mcp",  "log",- "quick-xml 0.41.0",+ "quick-xml 0.42.0",  "serde_json",  "zip 3.0.0", ]@@ -2125,7 +2125,7 @@ dependencies = [  "gridcraft-core",  "gridcraft-formula",  "gridcraft-model",- "quick-xml 0.41.0",+ "quick-xml 0.42.0",  "sha1 0.11.0",  "sha2 0.11.1",  "thiserror 2.0.21",diff --git a/Cargo.toml b/Cargo.toml--- a/Cargo.toml+++ b/Cargo.toml@@ -37,7 +37,7 @@ log = "0.4" chrono = { version = "0.4.45", default-features = false, features = ["std"] } rayon = "1" regex = { version = "1", default-features = false, features = ["std", "unicode", "perf"] }-quick-xml = "0.41"+quick-xml = "0.42" zip = { version = "3", default-features = false, features = ["deflate"] } sha2 = { version = "0.11", default-features = false } # Decrypting password-protected workbooks ([MS-OFFCRYPTO]): AES and SHA-1, pure Rust (wasm-safe).diff --git a/apps/gridcraft-cli/src/check/package.rs b/apps/gridcraft-cli/src/check/package.rs--- a/apps/gridcraft-cli/src/check/package.rs+++ b/apps/gridcraft-cli/src/check/package.rs@@ -12,11 +12,8 @@ pub(super) const REBUILT_KINDS: [&str; 1] = ["calcchain"];  // ---------------------------------------------------------------- package validation -fn local_name(name: &[u8]) -> &[u8] {-    match name.iter().rposition(|&b| b == b':') {-        Some(i) => name.get(i + 1..).unwrap_or(name),-        None => name,-    }+fn local_name(name: &str) -> &str {+    name.rsplit(':').next().unwrap_or(name) }  /// `(element local name, attributes)` for every start/empty element of an XML part.@@ -35,12 +32,12 @@ fn xml_elements(xml: &[u8]) -> Result<Vec<(String, Vec<(String, String)>)>, Stri                     .map(|a| {                         let v = match a.normalized_value(quick_xml::XmlVersion::Explicit1_0) {                             Ok(v) => v.into_owned(),-                            Err(_) => String::from_utf8_lossy(&a.value).into_owned(),+                            Err(_) => a.value.to_string(),                         };-                        (String::from_utf8_lossy(local_name(a.key.as_ref())).into_owned(), v)+                        (local_name(a.key.as_ref()).to_string(), v)                     })                     .collect();-                out.push((String::from_utf8_lossy(local_name(e.name().as_ref())).into_owned(), attrs));+                out.push((local_name(e.name().as_ref()).to_string(), attrs));             }             Ok(Event::Eof) => break,             Ok(_) => {}@@ -301,9 +298,9 @@ fn holds_something_uncached(zip: &mut Zip<'_>, name: &str, marker: &'static str,     let take = f.size().min(MAX_XML_PART).min(*budget);     *budget -= take;     let mut reader = quick_xml::Reader::from_reader(std::io::BufReader::new(f.take(take)));-    let wanted: Option<&'static [u8]> = match marker {-        "comment" => Some(b"comment"),-        "shape" => Some(b"shape"),+    let wanted: Option<&'static str> = match marker {+        "comment" => Some("comment"),+        "shape" => Some("shape"),         _ => None,     };     let mut buf = Vec::new();diff --git a/crates/xlsx/src/ods.rs b/crates/xlsx/src/ods.rs--- a/crates/xlsx/src/ods.rs+++ b/crates/xlsx/src/ods.rs@@ -18,11 +18,11 @@ use crate::package::{MAX_ENTRIES, Package}; use crate::{IoError, ReadReport};  const MIME: &[u8] = b"application/vnd.oasis.opendocument.spreadsheet";-const OFFICE: &[u8] = b"urn:oasis:names:tc:opendocument:xmlns:office:1.0";-const TABLE: &[u8] = b"urn:oasis:names:tc:opendocument:xmlns:table:1.0";-const TEXT: &[u8] = b"urn:oasis:names:tc:opendocument:xmlns:text:1.0";-const CALC_EXT: &[u8] = b"urn:org:documentfoundation:names:experimental:calc:xmlns:calcext:1.0";-const MANIFEST: &[u8] = b"urn:oasis:names:tc:opendocument:xmlns:manifest:1.0";+const OFFICE: &str = "urn:oasis:names:tc:opendocument:xmlns:office:1.0";+const TABLE: &str = "urn:oasis:names:tc:opendocument:xmlns:table:1.0";+const TEXT: &str = "urn:oasis:names:tc:opendocument:xmlns:text:1.0";+const CALC_EXT: &str = "urn:org:documentfoundation:names:experimental:calc:xmlns:calcext:1.0";+const MANIFEST: &str = "urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"; const MAX_CELLS: u64 = 1_000_000; const MAX_MERGES: u64 = 100_000; const MAX_CELL_TEXT: usize = 1024 * 1024;@@ -64,11 +64,11 @@ pub fn read_ods(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {     Ok((import.wb, import.report)) } -fn is_ns(ns: ResolveResult<'_>, expected: &[u8]) -> bool {+fn is_ns(ns: ResolveResult<'_>, expected: &str) -> bool {     matches!(ns, ResolveResult::Bound(n) if n.as_ref() == expected) } -fn attr(reader: &mut NsReader<&[u8]>, e: &BytesStart<'_>, ns: &[u8], name: &[u8]) -> Result<Option<String>, IoError> {+fn attr(reader: &mut NsReader<&[u8]>, e: &BytesStart<'_>, ns: &str, name: &str) -> Result<Option<String>, IoError> {     let mut found = None;     for a in e.attributes() {         let a = a.map_err(|e| IoError::Xml(e.to_string()))?;@@ -108,11 +108,11 @@ fn check_manifest(bytes: &[u8]) -> Result<(), IoError> {         match &event {             Event::Start(e) | Event::Empty(e) => {                 let (ns, name) = reader.resolver_mut().resolve_element(e.name());-                if is_ns(ns.clone(), MANIFEST) && name.as_ref() == b"encryption-data" {+                if is_ns(ns.clone(), MANIFEST) && name.as_ref() == "encryption-data" {                     return Err(IoError::Format("encrypted ODS files are not supported".into()));                 }                 if depth == 0 {-                    if root_seen || !is_ns(ns, MANIFEST) || name.as_ref() != b"manifest" {+                    if root_seen || !is_ns(ns, MANIFEST) || name.as_ref() != "manifest" {                         return Err(xml_error("expected one manifest:manifest root"));                     }                     root_seen = true;@@ -159,29 +159,29 @@ fn tag(reader: &mut NsReader<&[u8]>, e: &BytesStart<'_>) -> Tag {     let local = name.as_ref();     if is_ns(ns.clone(), OFFICE) {         return match local {-            b"document-content" => Tag::Document,-            b"body" => Tag::Body,-            b"spreadsheet" => Tag::Spreadsheet,+            "document-content" => Tag::Document,+            "body" => Tag::Body,+            "spreadsheet" => Tag::Spreadsheet,             _ => Tag::Other,         };     }     if is_ns(ns.clone(), TABLE) {         return match local {-            b"table" => Tag::Table,-            b"table-row-group" | b"table-header-rows" | b"table-rows" => Tag::Rows,-            b"table-row" => Tag::Row,-            b"table-cell" => Tag::Cell,-            b"covered-table-cell" => Tag::Covered,+            "table" => Tag::Table,+            "table-row-group" | "table-header-rows" | "table-rows" => Tag::Rows,+            "table-row" => Tag::Row,+            "table-cell" => Tag::Cell,+            "covered-table-cell" => Tag::Covered,             _ => Tag::Other,         };     }     if is_ns(ns, TEXT) {         return match local {-            b"p" | b"h" => Tag::Paragraph,-            b"span" | b"a" | b"meta" | b"meta-field" | b"ruby" | b"ruby-base" => Tag::Inline,-            b"s" => Tag::Space,-            b"tab" => Tag::Tab,-            b"line-break" => Tag::Break,+            "p" | "h" => Tag::Paragraph,+            "span" | "a" | "meta" | "meta-field" | "ruby" | "ruby-base" => Tag::Inline,+            "s" => Tag::Space,+            "tab" => Tag::Tab,+            "line-break" => Tag::Break,             _ => Tag::Other,         };     }@@ -270,22 +270,22 @@ impl Import {                 }                 Event::End(_) => self.end()?,                 Event::Text(t) => {-                    let text = t.xml_content(XmlVersion::Explicit1_0).map_err(xml_error)?;+                    let text = t.xml_content(XmlVersion::Explicit1_0);                     if self.stack.is_empty() && !text.trim().is_empty() {                         return Err(xml_error("text outside the document"));                     }                     self.append(&text)?;                 }-                Event::CData(t) => self.append(&t.decode().map_err(xml_error)?)?,+                Event::CData(t) => self.append(&t)?,                 Event::GeneralRef(r) => {                     let ch = match r.resolve_char_ref().map_err(xml_error)? {                         Some(c) => c,                         None => match r.as_ref() {-                            b"amp" => '&',-                            b"lt" => '<',-                            b"gt" => '>',-                            b"apos" => '\'',-                            b"quot" => '"',+                            "amp" => '&',+                            "lt" => '<',+                            "gt" => '>',+                            "apos" => '\'',+                            "quot" => '"',                             _ => return Err(xml_error("unsupported XML entity")),                         },                     };@@ -316,7 +316,7 @@ impl Import {                 if self.wb.sheets.len() >= MAX_SHEETS {                     return Err(IoError::TooLarge("ODS contains more than 1024 sheets".into()));                 }-                let original = attr(reader, e, TABLE, b"name")?.unwrap_or_default();+                let original = attr(reader, e, TABLE, "name")?.unwrap_or_default();                 let mut name = crate::read::unique_name(&self.wb, original.trim_matches('\''), self.wb.sheets.len());                 if self.wb.check_sheet_name(&name, None).is_err() {                     name = self.wb.next_sheet_name();@@ -328,30 +328,30 @@ impl Import {             }             Tag::Row if self.sheet.as_ref().is_some_and(|s| self.stack.get(s.depth + 1..).is_some_and(|p| p.iter().all(|t| *t == Tag::Rows))) => {                 self.row =-                    Some(RowDraft { depth, repeat: positive(attr(reader, e, TABLE, b"number-rows-repeated")?, 1, u32::MAX)?, col: 0, cells: vec![] });+                    Some(RowDraft { depth, repeat: positive(attr(reader, e, TABLE, "number-rows-repeated")?, 1, u32::MAX)?, col: 0, cells: vec![] });             }             Tag::Cell | Tag::Covered if self.row.as_ref().is_some_and(|r| depth == r.depth + 1) => {-                let kind = if attr(reader, e, CALC_EXT, b"value-type")?.as_deref() == Some("error") {+                let kind = if attr(reader, e, CALC_EXT, "value-type")?.as_deref() == Some("error") {                     Some("error".into())                 } else {-                    attr(reader, e, OFFICE, b"value-type")?+                    attr(reader, e, OFFICE, "value-type")?                 };-                let key: &[u8] = match kind.as_deref() {-                    Some("boolean") => b"boolean-value",-                    Some("date") => b"date-value",-                    Some("time") => b"time-value",-                    Some("string") => b"string-value",-                    _ => b"value",+                let key: &str = match kind.as_deref() {+                    Some("boolean") => "boolean-value",+                    Some("date") => "date-value",+                    Some("time") => "time-value",+                    Some("string") => "string-value",+                    _ => "value",                 };                 self.cell = Some(CellDraft {                     depth,-                    repeat: positive(attr(reader, e, TABLE, b"number-columns-repeated")?, 1, u32::MAX)?,-                    width: positive(attr(reader, e, TABLE, b"number-columns-spanned")?, 1, MAX_COLS)?,-                    height: positive(attr(reader, e, TABLE, b"number-rows-spanned")?, 1, MAX_ROWS)?,+                    repeat: positive(attr(reader, e, TABLE, "number-columns-repeated")?, 1, u32::MAX)?,+                    width: positive(attr(reader, e, TABLE, "number-columns-spanned")?, 1, MAX_COLS)?,+                    height: positive(attr(reader, e, TABLE, "number-rows-spanned")?, 1, MAX_ROWS)?,                     covered: t == Tag::Covered,                     kind,                     raw: attr(reader, e, OFFICE, key)?,-                    formula: attr(reader, e, TABLE, b"formula")?.is_some(),+                    formula: attr(reader, e, TABLE, "formula")?.is_some(),                     text: String::new(),                     paragraph: None,                     paragraphs: 0,@@ -372,7 +372,7 @@ impl Import {             }             Tag::Space | Tag::Tab | Tag::Break if self.captures_text() => {                 let count = if t == Tag::Space {-                    let n = attr(reader, e, TEXT, b"c")?.map(|s| s.parse::<u32>()).transpose().map_err(xml_error)?.unwrap_or(1);+                    let n = attr(reader, e, TEXT, "c")?.map(|s| s.parse::<u32>()).transpose().map_err(xml_error)?.unwrap_or(1);                     if n as usize > MAX_CELL_TEXT {                         return Err(IoError::TooLarge("ODS repeated spaces exceed 1 MiB".into()));                     }diff --git a/crates/xlsx/src/passthrough/read.rs b/crates/xlsx/src/passthrough/read.rs--- a/crates/xlsx/src/passthrough/read.rs+++ b/crates/xlsx/src/passthrough/read.rs@@ -444,40 +444,39 @@ fn scan_sheet(bytes: &[u8]) -> Option<SheetScan> {             Event::Start(e) if !in_root => {                 in_root = true;                 for a in e.attributes().with_checks(false).flatten() {-                    if let Some(p) = a.key.as_ref().strip_prefix(b"xmlns:")-                        && let (Ok(p), Ok(v)) = (std::str::from_utf8(p), std::str::from_utf8(&a.value))-                        && !v.contains('"')+                    if let Some(p) = a.key.as_ref().strip_prefix("xmlns:")+                        && !a.value.contains('"')                     {-                        decls.push((p.to_string(), v.to_string()));+                        decls.push((p.to_string(), a.value.to_string()));                     }                 }             }             Event::Start(e) => {-                let local = e.local_name().as_ref().to_vec();-                match local.as_slice() {-                    b"pageSetup" => out.page_setup = rel_id_attr(&e),-                    b"legacyDrawing" => out.legacy_drawing = rel_id_attr(&e),-                    b"legacyDrawingHF" => out.legacy_drawing_hf = rel_id_attr(&e),+                let local = e.local_name().as_ref().to_string();+                match local.as_str() {+                    "pageSetup" => out.page_setup = rel_id_attr(&e),+                    "legacyDrawing" => out.legacy_drawing = rel_id_attr(&e),+                    "legacyDrawingHF" => out.legacy_drawing_hf = rel_id_attr(&e),                     _ => {}                 }                 reader.read_to_end(e.name()).ok()?;                 let end = usize::try_from(reader.buffer_position()).ok()?;-                let wanted = matches!(local.as_slice(), b"picture" | b"oleObjects" | b"controls" | b"AlternateContent");+                let wanted = matches!(local.as_str(), "picture" | "oleObjects" | "controls" | "AlternateContent");                 if wanted && end.saturating_sub(start) <= MAX_FRAGMENT {                     let frag = text.get(start..end)?;                     push_fragment(&mut out, frag, &local, &decls);                 }             }             Event::Empty(e) if in_root => {-                let local = e.local_name().as_ref().to_vec();-                if local == b"legacyDrawing" || local == b"legacyDrawingHF" || local == b"pageSetup" {+                let local = e.local_name().as_ref().to_string();+                if local == "legacyDrawing" || local == "legacyDrawingHF" || local == "pageSetup" {                     let id = rel_id_attr(&e);-                    match local.as_slice() {-                        b"pageSetup" => out.page_setup = id,-                        b"legacyDrawing" => out.legacy_drawing = id,+                    match local.as_str() {+                        "pageSetup" => out.page_setup = id,+                        "legacyDrawing" => out.legacy_drawing = id,                         _ => out.legacy_drawing_hf = id,                     }-                } else if local == b"picture"+                } else if local == "picture"                     && let Some(frag) = text.get(start..usize::try_from(reader.buffer_position()).ok()?)                 {                     push_fragment(&mut out, frag, &local, &decls);@@ -492,16 +491,12 @@ fn scan_sheet(bytes: &[u8]) -> Option<SheetScan> {  /// The `r:id` of an element (an `id` attribute with a namespace prefix). fn rel_id_attr(e: &BytesStart<'_>) -> Option<String> {-    e.attributes()-        .with_checks(false)-        .flatten()-        .find(|a| a.key.local_name().as_ref() == b"id" && a.key.prefix().is_some())-        .and_then(|a| String::from_utf8(a.value.to_vec()).ok())+    e.attributes().with_checks(false).flatten().find(|a| a.key.local_name().as_ref() == "id" && a.key.prefix().is_some()).map(|a| a.value.to_string()) } -fn push_fragment(out: &mut SheetScan, frag: &str, local: &[u8], decls: &[(String, String)]) {+fn push_fragment(out: &mut SheetScan, frag: &str, local: &str, decls: &[(String, String)]) {     let frag = with_decls(frag, decls);-    let slot = if local == b"AlternateContent" {+    let slot = if local == "AlternateContent" {         // The kept element is the one the first choice holds.         let Ok(el) = xml::parse(frag.as_bytes()) else { return };         match el.child("Choice").and_then(|c| c.children.first()) {@@ -509,7 +504,7 @@ fn push_fragment(out: &mut SheetScan, frag: &str, local: &[u8], decls: &[(String             None => return,         }     } else {-        String::from_utf8_lossy(local).into_owned()+        local.to_string()     };     if SHEET_SLOTS.contains(&slot.as_str()) && !out.fragments.iter().any(|(s, _)| *s == slot) {         out.fragments.push((slot, frag));@@ -634,7 +629,7 @@ pub(super) fn merge_vml(kept: &str, ours: &str) -> String { pub fn map_rel_ids(x: &str, map: &mut dyn FnMut(&str) -> Option<String>) -> Option<String> {     let mut reader = Reader::from_str(x);     let mut writer = Writer::new(Vec::with_capacity(x.len()));-    let mut prefixes: HashSet<Vec<u8>> = HashSet::new();+    let mut prefixes: HashSet<String> = HashSet::new();     let mut depth = 0usize;     loop {         match reader.read_event().ok()? {@@ -661,25 +656,23 @@ pub fn map_rel_ids(x: &str, map: &mut dyn FnMut(&str) -> Option<String>) -> Opti     String::from_utf8(writer.into_inner()).ok() } -fn map_start(e: &BytesStart<'_>, prefixes: &mut HashSet<Vec<u8>>, map: &mut dyn FnMut(&str) -> Option<String>) -> Option<BytesStart<'static>> {+fn map_start(e: &BytesStart<'_>, prefixes: &mut HashSet<String>, map: &mut dyn FnMut(&str) -> Option<String>) -> Option<BytesStart<'static>> {     for a in e.attributes().with_checks(false) {         let a = a.ok()?;-        if let Some(p) = a.key.as_ref().strip_prefix(b"xmlns:")-            && (a.value.as_ref() == NS_REL.as_bytes() || a.value.as_ref() == NS_REL_STRICT.as_bytes())+        if let Some(p) = a.key.as_ref().strip_prefix("xmlns:")+            && (a.value == NS_REL || a.value == NS_REL_STRICT)         {-            prefixes.insert(p.to_vec());+            prefixes.insert(p.to_string());         }     }-    let name = String::from_utf8(e.name().as_ref().to_vec()).ok()?;-    let mut out = BytesStart::new(name);+    let mut out = BytesStart::new(e.name().as_ref().to_string());     for a in e.attributes().with_checks(false) {         let a = a.ok()?;         let key = a.key.as_ref();-        let is_rel = !key.starts_with(b"xmlns") && a.key.prefix().is_some_and(|p| prefixes.contains(p.as_ref()));+        let is_rel = !key.starts_with("xmlns") && a.key.prefix().is_some_and(|p| prefixes.contains(p.as_ref()));         if is_rel {-            let old = std::str::from_utf8(&a.value).ok()?;-            let new = map(old)?;-            out.push_attribute(Attribute { key: QName(key), value: Cow::Owned(esc_attr(&new).into_bytes()) });+            let new = map(&a.value)?;+            out.push_attribute(Attribute { key: QName(key), value: Cow::Owned(esc_attr(&new)) });         } else {             out.push_attribute(Attribute { key: QName(key), value: a.value.clone() });         }diff --git a/crates/xlsx/src/xlsb.rs b/crates/xlsx/src/xlsb.rs--- a/crates/xlsx/src/xlsb.rs+++ b/crates/xlsx/src/xlsb.rs@@ -18,8 +18,8 @@ const MAIN: &str = "application/vnd.ms-excel.main"; const BINARY_MAIN: &str = "application/vnd.ms-excel.sheet.binary.macroEnabled.main"; const WORKSHEET: &str = "application/vnd.ms-excel.worksheet"; const STRINGS: &str = "application/vnd.ms-excel.sharedStrings";-const CT_NS: &[u8] = b"http://schemas.openxmlformats.org/package/2006/content-types";-const REL_NS: &[u8] = b"http://schemas.openxmlformats.org/package/2006/relationships";+const CT_NS: &str = "http://schemas.openxmlformats.org/package/2006/content-types";+const REL_NS: &str = "http://schemas.openxmlformats.org/package/2006/relationships"; const REL_TYPE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/"; const STRICT_REL_TYPE: &str = "http://purl.oclc.org/ooxml/officeDocument/relationships/"; const MS_REL_TYPE: &str = "http://schemas.microsoft.com/office/2006/relationships/";@@ -152,7 +152,7 @@ impl Entry {  /// The two package metadata parts are flat, namespace-qualified XML documents. Keep only /// direct child entries, and reject truncated XML instead of guessing a partial relationship.-fn metadata(bytes: &[u8], ns: &[u8], root_name: &[u8]) -> Result<Vec<Entry>, IoError> {+fn metadata(bytes: &[u8], ns: &str, root_name: &str) -> Result<Vec<Entry>, IoError> {     let mut reader = NsReader::from_reader(bytes);     let mut depth = 0usize;     let mut seen = false;@@ -172,14 +172,14 @@ fn metadata(bytes: &[u8], ns: &[u8], root_name: &[u8]) -> Result<Vec<Entry>, IoE                     let mut attrs = vec![];                     for a in e.attributes() {                         let a = a.map_err(|e| IoError::Xml(e.to_string()))?;-                        if !a.key.as_ref().contains(&b':') {+                        if !a.key.as_ref().contains(':') {                             attrs.push((-                                String::from_utf8_lossy(a.key.as_ref()).into_owned(),+                                a.key.as_ref().to_string(),                                 a.normalized_value(XmlVersion::Explicit1_0).map_err(|e| IoError::Xml(e.to_string()))?.into_owned(),                             ));                         }                     }-                    entries.push(Entry { name: String::from_utf8_lossy(local.as_ref()).into_owned(), attrs });+                    entries.push(Entry { name: local.as_ref().to_string(), attrs });                 }                 if matches!(event, Event::Start(_)) {                     depth += 1;@@ -189,7 +189,7 @@ fn metadata(bytes: &[u8], ns: &[u8], root_name: &[u8]) -> Result<Vec<Entry>, IoE                 }             }             Event::End(_) => depth = depth.checked_sub(1).ok_or_else(|| invalid("unexpected metadata end"))?,-            Event::Text(t) if depth == 0 && !t.xml_content(XmlVersion::Explicit1_0).map_err(|e| IoError::Xml(e.to_string()))?.trim().is_empty() => {+            Event::Text(t) if depth == 0 && !t.xml_content(XmlVersion::Explicit1_0).trim().is_empty() => {                 return Err(invalid("text outside package metadata"));             }             Event::DocType(_) => return Err(invalid("DOCTYPE in package metadata is unsupported")),@@ -205,7 +205,7 @@ fn metadata(bytes: &[u8], ns: &[u8], root_name: &[u8]) -> Result<Vec<Entry>, IoE  fn content_types(package: &mut Package<'_>) -> Result<Vec<Entry>, IoError> {     let data = package.read_limited("[Content_Types].xml", MAX_META)?.ok_or_else(|| invalid("missing content types"))?;-    metadata(&data, CT_NS, b"Types")+    metadata(&data, CT_NS, "Types") }  fn check_type(types: &[Entry], path: &str, expected: &str) -> Result<(), IoError> {@@ -226,7 +226,7 @@ fn relationships(package: &mut Package<'_>, path: &str) -> Result<Vec<Rel>, IoEr     let relpath = if dir.is_empty() { format!("_rels/{file}.rels") } else { format!("{dir}/_rels/{file}.rels") };     let data = package.read_limited(&relpath, MAX_META)?.ok_or_else(|| invalid(format!("missing relationships for {path}")))?;     let mut out: Vec<Rel> = vec![];-    for e in metadata(&data, REL_NS, b"Relationships")? {+    for e in metadata(&data, REL_NS, "Relationships")? {         if e.name != "Relationship" {             continue;         }diff --git a/crates/xlsx/src/xml.rs b/crates/xlsx/src/xml.rs--- a/crates/xlsx/src/xml.rs+++ b/crates/xlsx/src/xml.rs@@ -172,12 +172,8 @@ pub(crate) fn to_utf8(xml: &[u8]) -> std::borrow::Cow<'_, [u8]> {     std::borrow::Cow::Borrowed(xml) } -fn local(name: &[u8]) -> String {-    let n = match name.iter().rposition(|&b| b == b':') {-        Some(i) => name.get(i + 1..).unwrap_or(name),-        None => name,-    };-    String::from_utf8_lossy(n).into_owned()+fn local(name: &str) -> String {+    name.rsplit(':').next().unwrap_or(name).to_string() }  fn start_el(e: &BytesStart<'_>) -> El {@@ -185,12 +181,12 @@ fn start_el(e: &BytesStart<'_>) -> El {     for a in e.attributes().with_checks(false).flatten() {         let key = a.key.as_ref();         // Namespace declarations carry no data for us.-        if key == b"xmlns" || key.starts_with(b"xmlns:") {+        if key == "xmlns" || key.starts_with("xmlns:") {             continue;         }         let v = match a.normalized_value(XmlVersion::Explicit1_0) {             Ok(v) => v.into_owned(),-            Err(_) => String::from_utf8_lossy(&a.value).into_owned(),+            Err(_) => a.value.to_string(),         };         el.attrs.push((local(key), v));     }@@ -281,13 +277,13 @@ pub fn parse_streaming(xml: &[u8], split: &[&str], cb: &mut dyn FnMut(El) -> Res             }             Event::Text(t) => {                 if let Some(top) = stack.last_mut() {-                    let s = t.xml_content(XmlVersion::Explicit1_0).map_err(|e| IoError::Xml(e.to_string()))?;+                    let s = t.xml_content(XmlVersion::Explicit1_0);                     top.text.push_str(&s);                 }             }             Event::CData(t) => {                 if let Some(top) = stack.last_mut() {-                    top.text.push_str(&String::from_utf8_lossy(&t));+                    top.text.push_str(&t);                 }             }             Event::GeneralRef(r) => {@@ -295,7 +291,7 @@ pub fn parse_streaming(xml: &[u8], split: &[&str], cb: &mut dyn FnMut(El) -> Res                     match r.resolve_char_ref() {                         Ok(Some(c)) => top.text.push(c),                         _ => {-                            let name = String::from_utf8_lossy(&r).into_owned();+                            let name = r.to_string();                             match named_entity(&name) {                                 Some(c) => top.text.push(c),                                 None => {