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 => {
