From d1c8de48e7ac948081aa447c4020dad12faaf6e8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 20:11:18 +0000 Subject: [PATCH] refactor(xlsx): move passthrough.rs items to passthrough/ --- crates/xlsx/src/passthrough.rs | 1216 +------------------------ crates/xlsx/src/passthrough/read.rs | 688 ++++++++++++++ crates/xlsx/src/passthrough/rebase.rs | 199 ++++ crates/xlsx/src/passthrough/write.rs | 325 +++++++ 4 files changed, 1222 insertions(+), 1206 deletions(-) create mode 100644 crates/xlsx/src/passthrough/read.rs create mode 100644 crates/xlsx/src/passthrough/rebase.rs create mode 100644 crates/xlsx/src/passthrough/write.rs diff --git a/crates/xlsx/src/passthrough.rs b/crates/xlsx/src/passthrough.rs index 056fc6114..04f757d3a 100644 --- a/crates/xlsx/src/passthrough.rs +++ b/crates/xlsx/src/passthrough.rs @@ -42,6 +42,16 @@ use crate::read::Ctx; use crate::write::{NS_REL, Out, Rels, XML_DECL}; use crate::xml::{self, esc_attr}; +mod read; +mod rebase; +mod write; + +pub use read::KeptReader; +pub use rebase::{SheetKept, rebase_links}; +#[cfg(test)] +pub(crate) use write::sheet_names_in; +pub use write::{KeptOut, SheetIds, workbook_elements, write_package_rels, write_workbook}; + /// Most bytes kept for one workbook. pub const MAX_KEPT_TOTAL: usize = 256 * 1024 * 1024; /// Largest single kept part. @@ -178,1209 +188,3 @@ fn rels_part_name(part: &str) -> String { let (dir, file) = package::split_dir(part); if dir.is_empty() { format!("_rels/{file}.rels") } else { format!("{dir}/_rels/{file}.rels") } } - -/// A part's relationships with full type URIs. `Ok(vec![])` when it has none; an error when the -/// rels part is damaged or too large (its part then can't be kept faithfully). -fn raw_rels(cx: &mut Ctx<'_>, part: &str) -> Result, String> { - let name = rels_part_name(part); - let bytes = match cx.pkg.read_limited(&name, MAX_META) { - Ok(Some(b)) => b, - Ok(None) => return Ok(vec![]), - Err(e) => return Err(e.to_string()), - }; - let root = xml::parse(&bytes).map_err(|e| format!("{name}: {e}"))?; - let (dir, _) = package::split_dir(part); - let mut out = vec![]; - for r in root.kids("Relationship") { - if out.len() >= MAX_RELS { - return Err(format!("{name}: too many relationships")); - } - let (Some(id), Some(target)) = (r.attr("Id"), r.attr("Target")) else { continue }; - let external = r.attr("TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External")); - out.push(PreservedRel { - id: id.to_string(), - rel_type: r.attr("Type").unwrap_or("").to_string(), - target: if external { target.to_string() } else { package::resolve(dir, target) }, - external, - }); - } - Ok(out) -} - -// ---------------------------------------------------------------- reading - -/// The `` entries of the workbook that are chart sheets, each with the number of worksheets -/// (model sheets) before it. -fn chart_sheet_entries<'a>(wb_xml: &'a xml::El, wb_rels: &[PreservedRel], file_to_model: &[Option]) -> Vec<(usize, &'a xml::El)> { - let mut out = vec![]; - let mut worksheets = 0usize; - for (i, se) in wb_xml.child("sheets").into_iter().flat_map(|s| s.kids("sheet")).enumerate() { - if file_to_model.get(i).copied().flatten().is_some() { - worksheets += 1; - } else if se.attr("id").and_then(|id| wb_rels.iter().find(|r| r.id == id)).is_some_and(|r| is_kind(r, &["chartsheet"])) { - out.push((worksheets, se)); - } - } - out -} - -/// Collects kept content while a package is read. -pub struct KeptReader { - on: bool, - strict: bool, - defaults: HashMap, - overrides: HashMap, - budget: usize, - parts_left: usize, -} - -impl KeptReader { - pub fn new(cx: &mut Ctx<'_>) -> KeptReader { - let mut k = KeptReader { - on: true, - strict: false, - defaults: HashMap::new(), - overrides: HashMap::new(), - budget: kept_total(), - parts_left: MAX_KEPT_PARTS, - }; - if let Ok(root) = raw_rels(cx, "") - && root.iter().any(|r| r.rel_type.starts_with("http://purl.oclc.org/")) - { - k.on = false; - k.strict = true; - } - match cx.pkg.read_limited("[Content_Types].xml", MAX_META).ok().flatten().and_then(|b| xml::parse(&b).ok()) { - Some(types) => { - for d in types.kids("Default") { - if let (Some(e), Some(t)) = (d.attr("Extension"), d.attr("ContentType")) { - k.defaults.insert(e.to_ascii_lowercase(), t.to_string()); - } - } - for o in types.kids("Override") { - if let (Some(p), Some(t)) = (o.attr("PartName"), o.attr("ContentType")) { - k.overrides.insert(normalize(p), t.to_string()); - } - } - } - None => k.on = false, - } - k - } - - fn content_type(&self, part: &str) -> Option { - let key = normalize(part); - if let Some(t) = self.overrides.get(&key) { - return Some(t.clone()); - } - let (_, file) = package::split_dir(&key); - let ext = file.rsplit_once('.').map(|(_, e)| e)?; - self.defaults.get(ext).cloned() - } - - /// The parts reachable from `root` that aren't in `seen` yet. Fails (and keeps nothing) when the - /// graph reaches rewritten content, a missing or damaged part, or a size limit. - fn closure(&mut self, cx: &mut Ctx<'_>, root: &PreservedRel, seen: &HashSet, through: &[&str]) -> Result, String> { - if root.external { - return Ok(vec![]); - } - let blocked = |r: &PreservedRel| rewritten(r) && !through.iter().any(|k| k.eq_ignore_ascii_case(kind(&r.rel_type))); - if blocked(root) { - return Err(format!("it refers to {}", kind(&root.rel_type))); - } - let mut parts = vec![]; - let mut bytes = 0usize; - let mut local: HashSet = HashSet::new(); - let mut queue = VecDeque::from([(root.target.clone(), 0usize)]); - while let Some((name, depth)) = queue.pop_front() { - let key = normalize(&name); - if seen.contains(&key) || !local.insert(key) { - continue; - } - if depth > MAX_CHAIN { - return Err("its relationships are nested too deeply".into()); - } - if parts.len() >= self.parts_left { - return Err("it has too many parts".into()); - } - let content_type = self.content_type(&name).ok_or_else(|| format!("{name} has no content type"))?; - let data = match cx.pkg.read_limited(&name, MAX_KEPT_PART) { - Ok(Some(d)) => d, - Ok(None) => return Err(format!("{name} is missing")), - Err(e) => return Err(e.to_string()), - }; - bytes = bytes.saturating_add(data.len()); - if bytes > self.budget { - return Err("it is too large".into()); - } - let rels = raw_rels(cx, &name)?; - for r in rels.iter().filter(|r| !r.external) { - if blocked(r) { - return Err(format!("it refers to {}", kind(&r.rel_type))); - } - queue.push_back((r.target.clone(), depth + 1)); - } - parts.push(PreservedPart { name, content_type, data: Arc::from(data), rels }); - } - self.budget = self.budget.saturating_sub(bytes); - self.parts_left = self.parts_left.saturating_sub(parts.len()); - Ok(parts) - } - - /// Keeps a set of relationships (all or nothing) and the parts behind them. - fn keep(&mut self, cx: &mut Ctx<'_>, p: &mut Preserved, roots: &[PreservedRel], seen: &mut HashSet) -> Result<(), String> { - self.keep_through(cx, p, roots, seen, &[]) - } - - /// [`Self::keep`] for roots whose graph may reach the rewritten kinds in `through`. - fn keep_through( - &mut self, - cx: &mut Ctx<'_>, - p: &mut Preserved, - roots: &[PreservedRel], - seen: &mut HashSet, - through: &[&str], - ) -> Result<(), String> { - let mut new_parts: Vec = vec![]; - let mut scratch = seen.clone(); - for r in roots { - let parts = self.closure(cx, r, &scratch, through)?; - scratch.extend(parts.iter().map(|q| normalize(&q.name))); - new_parts.extend(parts); - } - *seen = scratch; - p.parts.extend(new_parts); - for r in roots { - if !p.rels.iter().any(|x| x.id == r.id) { - p.rels.push(r.clone()); - } - } - Ok(()) - } - - /// Package-root and workbook-level content. - pub fn workbook(&mut self, cx: &mut Ctx<'_>, wb_part: &str, wb_xml: &xml::El, file_to_model: &[Option]) -> Option> { - if !self.on { - let wb_rels = raw_rels(cx, wb_part).unwrap_or_default(); - for (_, se) in chart_sheet_entries(wb_xml, &wb_rels, file_to_model) { - cx.warn(format!("chart sheet \"{}\" was skipped and is not kept on save", se.attr("name").unwrap_or(""))); - } - if self.strict { - cx.warn("unmodelled content of a Strict Open XML workbook (custom XML, links, controls…) is not kept on save"); - } - return None; - } - let mut p = Preserved::default(); - let mut seen = HashSet::new(); - // Package root: custom document properties, ribbon customisation, task panes. - if let Ok(root) = raw_rels(cx, "") { - for r in root.iter().filter(|r| is_kind(r, PACKAGE_ROOTS)) { - let mut q = Preserved::default(); - match self.keep(cx, &mut q, std::slice::from_ref(r), &mut seen) { - Ok(()) => { - p.parts.extend(q.parts); - p.package_rels.extend(q.rels); - } - Err(why) => cx.warn(format!("{} was dropped: {why}", kind(&r.rel_type))), - } - } - } - let wb_rels = raw_rels(cx, wb_part).unwrap_or_default(); - for r in wb_rels.iter().filter(|r| is_kind(r, WORKBOOK_ROOTS)) { - if let Err(why) = self.keep(cx, &mut p, std::slice::from_ref(r), &mut seen) { - cx.warn(format!("{} part {} was dropped: {why}", kind(&r.rel_type), r.target)); - } - } - // Chart sheets: the sheet entry (name, state, position) plus the chart sheet part and the - // drawing, chart, style and colour parts behind it. - for (worksheets, se) in chart_sheet_entries(wb_xml, &wb_rels, file_to_model) { - let name = se.attr("name").unwrap_or("").to_string(); - let Some(r) = se.attr("id").and_then(|id| wb_rels.iter().find(|r| r.id == id)) else { continue }; - match self.keep_through(cx, &mut p, std::slice::from_ref(r), &mut seen, CHART_SHEET_GRAPH) { - Ok(()) => p.sheets.push(PreservedSheet { - name, - visibility: match se.attr("state") { - Some("hidden") => Visibility::Hidden, - Some("veryHidden") => Visibility::VeryHidden, - _ => Visibility::Visible, - }, - rel_id: r.id.clone(), - before: worksheets, - sheet_id: se.attr_u32("sheetId").unwrap_or(0), - }), - Err(why) => cx.warn(format!("chart sheet \"{name}\" was dropped: {why}")), - } - } - // Links to external workbooks: `[1]` in formulas is the position in , - // so they are kept all together or not at all. - let ids: Vec<&str> = - wb_xml.child("externalReferences").map(|e| e.kids("externalReference").filter_map(|x| x.attr("id")).collect()).unwrap_or_default(); - if !ids.is_empty() { - let roots: Option> = - ids.iter().map(|id| wb_rels.iter().find(|r| r.id == *id && is_kind(r, &["externalLink"])).cloned()).collect(); - let kept = match roots { - Some(roots) => self.keep(cx, &mut p, &roots, &mut seen), - None => Err("a link has no part".into()), - }; - match kept { - Ok(()) => { - let mut x = format!(""); - for id in &ids { - let _ = write!(x, "", esc_attr(id)); - } - x.push_str(""); - p.elements.push(PreservedXml { slot: "externalReferences".into(), xml: x }); - cx.warn("links to external workbooks were kept; they are not followed or refreshed"); - } - Err(why) => cx.warn(format!("links to external workbooks were dropped: {why}")), - } - } - // Excel trusts the saved cell values only when both are present; without them it - // recalculates everything on open, and links into closed workbooks come back as errors. - if let Some(v) = wb_xml.child("fileVersion") { - let mut x = String::from(""); - p.elements.push(PreservedXml { slot: "fileVersion".into(), xml: x }); - } - let calc_features = wb_xml - .child("extLst") - .and_then(|l| l.kids("ext").find(|e| e.attr("uri").is_some_and(|u| u.eq_ignore_ascii_case(CALC_FEATURES_URI)))) - .and_then(|e| e.child("calcFeatures")); - if let Some(c) = calc_features { - let mut x = format!(""); - for name in c.kids("feature").filter_map(|f| f.attr("name")) { - let _ = write!(x, "", esc_attr(name)); - } - x.push_str(""); - p.elements.push(PreservedXml { slot: "ext".into(), xml: x }); - } - (!p.is_empty()).then(|| Arc::new(p)) - } - - /// Sheet-level content of the sheet part `part`. - pub fn sheet(&mut self, cx: &mut Ctx<'_>, part: &str, name: &str) -> Option> { - if !self.on { - return None; - } - let rels = match raw_rels(cx, part) { - Ok(r) => r, - Err(e) => { - cx.warn(format!("sheet \"{name}\": relationships could not be read ({e}); unmodelled content was dropped")); - return None; - } - }; - let mut p = Preserved::default(); - let mut seen = HashSet::new(); - for r in rels.iter().filter(|r| is_kind(r, SHEET_ROOTS)) { - if let Err(why) = self.keep(cx, &mut p, std::slice::from_ref(r), &mut seen) { - cx.warn(format!("sheet \"{name}\": {} was dropped: {why}", kind(&r.rel_type))); - } - } - // Notes have one VML drawing per sheet; another one holds the header and footer pictures. - let vml_drawings = rels.iter().filter(|r| is_kind(r, &["vmlDrawing"])).count(); - let notes = rels.iter().filter(|r| is_kind(r, &["comments"])).count(); - if rels.iter().any(|r| is_kind(r, SHEET_ELEMENT_KINDS)) || vml_drawings > notes { - self.sheet_elements(cx, part, name, &rels, &mut p, &mut seen); - } - (!p.is_empty()).then(|| Arc::new(p)) - } - - fn sheet_elements(&mut self, cx: &mut Ctx<'_>, part: &str, name: &str, rels: &[PreservedRel], p: &mut Preserved, seen: &mut HashSet) { - let scan = match cx.pkg.read(part) { - Ok(Some(bytes)) => scan_sheet(&bytes), - _ => None, - }; - let Some(scan) = scan else { - cx.warn(format!("sheet \"{name}\": controls and embedded objects could not be read and were dropped")); - return; - }; - // Printer settings (a binary part per sheet) hang off . - if let Some(id) = scan.page_setup.as_deref() - && let Some(r) = rels.iter().find(|r| r.id == id && is_kind(r, &["printerSettings"])) - { - match self.keep(cx, p, std::slice::from_ref(r), seen) { - Ok(()) => p.elements.push(PreservedXml { slot: "pageSetup".into(), xml: r.id.clone() }), - Err(why) => cx.warn(format!("sheet \"{name}\": printer settings were dropped: {why}")), - } - } - // Header and footer pictures (`&G`): a VML drawing of their own and the images it shows, - // kept byte for byte (GridCraft writes the header text, not the pictures). - if let Some(id) = scan.legacy_drawing_hf.as_deref() - && scan.legacy_drawing.as_deref() != Some(id) - && let Some(r) = rels.iter().find(|r| r.id == id && is_kind(r, &["vmlDrawing"])) - { - match self.keep_through(cx, p, std::slice::from_ref(r), seen, &["vmlDrawing"]) { - Ok(()) => { - let xml = format!("", esc_attr(&r.id)); - p.elements.push(PreservedXml { slot: "legacyDrawingHF".into(), xml }); - cx.warn(format!("sheet \"{name}\": pictures in the header and footer are not shown; they are kept for saving")); - } - Err(why) => cx.warn(format!("sheet \"{name}\": header and footer pictures were dropped: {why}")), - } - } - let mut frags = scan.fragments; - // Controls and OLE objects are drawn from shapes in the legacy VML drawing. - if frags.iter().any(|(slot, _)| slot != "picture") { - match self.vml(cx, rels, scan.legacy_drawing.as_deref(), seen) { - Ok((vml, parts)) => { - p.parts.extend(parts); - p.vml = Some(vml); - } - Err(why) => { - cx.warn(format!("sheet \"{name}\": controls and embedded objects were dropped: {why}")); - frags.retain(|(slot, _)| slot == "picture"); - } - } - } - for (slot, x) in frags { - let mut ids = vec![]; - let ok = map_rel_ids(&x, &mut |id| { - ids.push(id.to_string()); - Some(id.to_string()) - }) - .is_some(); - let roots: Option> = ids.iter().map(|id| rels.iter().find(|r| r.id == *id).cloned()).collect(); - let kept = match (ok, roots) { - (true, Some(roots)) => self.keep(cx, p, &roots, seen), - (false, _) => Err("its XML is damaged".into()), - (_, None) => Err("it refers to a missing relationship".into()), - }; - match kept { - Ok(()) => p.elements.push(PreservedXml { slot, xml: x }), - Err(why) => cx.warn(format!("sheet \"{name}\": {slot} were dropped: {why}")), - } - } - if p.elements.iter().any(|e| e.slot == "oleObjects" || e.slot == "controls") { - cx.warn(format!("sheet \"{name}\": embedded objects and form controls are not shown; they are kept for saving")); - } else { - p.vml = None; - } - } - - /// The sheet's VML drawing without its notes (GridCraft writes those), and the parts its - /// shapes refer to (preview images). - fn vml( - &mut self, - cx: &mut Ctx<'_>, - rels: &[PreservedRel], - rid: Option<&str>, - seen: &mut HashSet, - ) -> Result<(PreservedPart, Vec), String> { - let rel = rid.and_then(|id| rels.iter().find(|r| r.id == id && !r.external)).ok_or("the sheet has no VML drawing")?; - // Charged to the kept total like any other part: many sheets can point at one highly - // compressed VML drawing, and each keeps its own copy. - if self.budget == 0 { - return Err("it is too large".into()); - } - let limit = MAX_META.min(u64::try_from(self.budget).unwrap_or(u64::MAX)); - let data = match cx.pkg.read_limited(&rel.target, limit) { - Ok(Some(d)) => d, - Ok(None) => return Err(format!("{} is missing", rel.target)), - Err(IoError::TooLarge(_)) => return Err("it is too large".into()), - Err(e) => return Err(e.to_string()), - }; - self.budget = self.budget.saturating_sub(data.len()); - let text = String::from_utf8(data).map_err(|_| "the VML drawing is not UTF-8".to_string())?; - let text = strip_notes(&text); - let vrels = raw_rels(cx, &rel.target)?; - let mut q = Preserved::default(); - let roots: Vec = vrels.iter().filter(|r| !r.external).cloned().collect(); - self.keep(cx, &mut q, &roots, seen)?; - let vml = PreservedPart { name: rel.target.clone(), content_type: CT_VML.into(), data: Arc::from(text.into_bytes()), rels: vrels }; - Ok((vml, q.parts)) - } -} - -/// What a sheet part holds for pass-through. -struct SheetScan { - /// (slot, XML with the namespace declarations it needs). - fragments: Vec<(String, String)>, - legacy_drawing: Option, - /// The relationship id on `` (header and footer pictures). - legacy_drawing_hf: Option, - /// The relationship id on `` (printer settings). - page_setup: Option, -} - -/// Finds the sheet's ``, ``, `` (also inside -/// ``) and the `` and `` ids. `None` when the XML is damaged. -fn scan_sheet(bytes: &[u8]) -> Option { - let data = xml::to_utf8(bytes); - let text = std::str::from_utf8(&data).ok()?; - let mut reader = Reader::from_str(text); - let mut decls: Vec<(String, String)> = vec![]; - let mut out = SheetScan { fragments: vec![], legacy_drawing: None, legacy_drawing_hf: None, page_setup: None }; - let mut in_root = false; - loop { - let start = usize::try_from(reader.buffer_position()).ok()?; - match reader.read_event().ok()? { - Event::Eof => break, - 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('"') - { - decls.push((p.to_string(), v.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), - _ => {} - } - 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"); - 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 id = rel_id_attr(&e); - match local.as_slice() { - b"pageSetup" => out.page_setup = id, - b"legacyDrawing" => out.legacy_drawing = id, - _ => out.legacy_drawing_hf = id, - } - } else if local == b"picture" - && let Some(frag) = text.get(start..usize::try_from(reader.buffer_position()).ok()?) - { - push_fragment(&mut out, frag, &local, &decls); - } - } - Event::End(_) => break, - _ => {} - } - } - Some(out) -} - -/// The `r:id` of an element (an `id` attribute with a namespace prefix). -fn rel_id_attr(e: &BytesStart<'_>) -> Option { - 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()) -} - -fn push_fragment(out: &mut SheetScan, frag: &str, local: &[u8], decls: &[(String, String)]) { - let frag = with_decls(frag, decls); - let slot = if local == b"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()) { - Some(c) => c.name.clone(), - None => return, - } - } else { - String::from_utf8_lossy(local).into_owned() - }; - if SHEET_SLOTS.contains(&slot.as_str()) && !out.fragments.iter().any(|(s, _)| *s == slot) { - out.fragments.push((slot, frag)); - } -} - -/// Adds the root's namespace declarations a fragment doesn't declare itself. -fn with_decls(frag: &str, decls: &[(String, String)]) -> String { - let Some(name_end) = frag.char_indices().skip(1).find(|(_, c)| c.is_whitespace() || *c == '/' || *c == '>').map(|(i, _)| i) else { - return frag.to_string(); - }; - let head = frag.get(..frag.find('>').unwrap_or(frag.len())).unwrap_or(""); - let mut add = String::new(); - for (p, v) in decls { - if !head.contains(&format!("xmlns:{p}=")) { - let _ = write!(add, " xmlns:{p}=\"{v}\""); - } - } - format!("{}{add}{}", frag.get(..name_end).unwrap_or(""), frag.get(name_end..).unwrap_or("")) -} - -/// Byte ranges of the `…` (or self-closed) elements in VML text. -fn shape_spans(vml: &str) -> Vec<(usize, usize)> { - let mut spans = vec![]; - let mut from = 0; - while let Some(i) = vml.get(from..).and_then(|s| s.find("' || c == '/'); - if !is_shape { - from = after; - continue; - } - let Some(tag_end) = vml.get(i..).and_then(|s| s.find('>')).map(|j| i + j + 1) else { break }; - let end = if vml.get(..tag_end).is_some_and(|t| t.ends_with("/>")) { - tag_end - } else { - match vml.get(tag_end..).and_then(|s| s.find("")) { - Some(j) => tag_end + j + "".len(), - None => break, - } - }; - spans.push((i, end)); - from = end; - } - spans -} - -/// VML without its note shapes (`ObjectType="Note"`). -fn strip_notes(vml: &str) -> String { - let mut out = String::with_capacity(vml.len()); - let mut last = 0; - for (a, b) in shape_spans(vml) { - let shape = vml.get(a..b).unwrap_or(""); - if shape.contains("ObjectType=\"Note\"") { - out.push_str(vml.get(last..a).unwrap_or("")); - last = b; - } - } - out.push_str(vml.get(last..).unwrap_or("")); - out -} - -/// Shape numbers (`_x0000_s1025` → 1025) used in VML text. -fn shape_numbers(vml: &str) -> Vec { - vml.match_indices("_x0000_s") - .filter_map(|(i, m)| { - let digits: String = vml.get(i + m.len()..)?.chars().take_while(char::is_ascii_digit).take(18).collect(); - digits.parse().ok() - }) - .collect() -} - -/// The highest shape block VML text claims in `` or uses in a shape id. -fn vml_block_max(vml: &str) -> u64 { - let used = shape_numbers(vml).into_iter().max().unwrap_or(0) / 1024; - let claimed = vml - .find("().ok()).max().unwrap_or(0)) - .unwrap_or(0); - used.max(claimed) -} - -/// Kept VML (controls, OLE objects) plus GridCraft's note shapes: the notes get ids in a fresh -/// shape block so they can't collide with the kept shapes the sheet XML refers to. -fn merge_vml(kept: &str, ours: &str) -> String { - let Some(close) = kept.rfind("") else { return ours.to_string() }; - let block = shape_numbers(kept).into_iter().max().unwrap_or(0) / 1024 + 1; - let mut add = String::new(); - if !kept.contains("id=\"_x0000_t202\"") - && let Some(i) = ours.find("")) - { - add.push_str(ours.get(i..i + j + "".len()).unwrap_or("")); - } - for (k, (a, b)) in shape_spans(ours).into_iter().enumerate() { - let shape = ours.get(a..b).unwrap_or(""); - let id = block.saturating_mul(1024).saturating_add(1 + k as u64); - match (shape.find("id=\"_x0000_s"), shape.find("id=\"_x0000_s").and_then(|i| shape.get(i + 4..)?.find('"').map(|j| i + 4 + j))) { - (Some(i), Some(j)) => { - let _ = write!(add, "{}id=\"_x0000_s{id}\"{}", shape.get(..i).unwrap_or(""), shape.get(j + 1..).unwrap_or("")); - } - _ => add.push_str(shape), - } - } - let mut head = kept.get(..close).unwrap_or("").to_string(); - // Claim the block in the drawing's id map. - if let Some(i) = head.find(" Option) -> Option { - let mut reader = Reader::from_str(x); - let mut writer = Writer::new(Vec::with_capacity(x.len())); - let mut prefixes: HashSet> = HashSet::new(); - let mut depth = 0usize; - loop { - match reader.read_event().ok()? { - Event::Eof => break, - Event::Start(e) => { - depth += 1; - if depth > MAX_FRAGMENT_DEPTH { - return None; - } - let e = map_start(&e, &mut prefixes, map)?; - writer.write_event(Event::Start(e)).ok()?; - } - Event::Empty(e) => { - let e = map_start(&e, &mut prefixes, map)?; - writer.write_event(Event::Empty(e)).ok()?; - } - Event::End(e) => { - depth = depth.saturating_sub(1); - writer.write_event(Event::End(e)).ok()?; - } - ev => writer.write_event(ev).ok()?, - } - } - String::from_utf8(writer.into_inner()).ok() -} - -fn map_start(e: &BytesStart<'_>, prefixes: &mut HashSet>, map: &mut dyn FnMut(&str) -> Option) -> Option> { - 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()) - { - prefixes.insert(p.to_vec()); - } - } - let name = String::from_utf8(e.name().as_ref().to_vec()).ok()?; - let mut out = BytesStart::new(name); - 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())); - 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()) }); - } else { - out.push_attribute(Attribute { key: QName(key), value: a.value.clone() }); - } - } - Some(out) -} - -// ---------------------------------------------------------------- writing - -/// Names given to kept parts while a package is written. -#[derive(Default)] -pub struct KeptOut { - used: HashSet, - n: u32, - /// The highest VML shape block a kept header and footer drawing uses: notes go after it. - vml_blocks: u32, - /// Package-root relationships (type, target, external), added when `_rels/.rels` is written. - root: Vec<(String, String, bool)>, -} - -impl KeptOut { - /// Reserves the VML shape blocks the kept header and footer drawings of `wb` use, so that the - /// notes drawings GridCraft writes don't reuse their shape ids. - pub fn reserve_vml_blocks(&mut self, wb: &Workbook) { - let vmls = - wb.sheets.iter().filter_map(|s| s.preserved.as_deref()).flat_map(|p| &p.parts).filter(|q| q.content_type.eq_ignore_ascii_case(CT_VML)); - let max = vmls.map(|q| vml_block_max(&String::from_utf8_lossy(&q.data))).max().unwrap_or(0); - self.vml_blocks = u32::try_from(max).unwrap_or(u32::MAX); - } - - /// The shape block of the `n`th notes drawing. - pub fn notes_block(&self, n: u32) -> u32 { - n.saturating_add(self.vml_blocks) - } - - /// A part name for a kept part: its original name when that can't collide with anything - /// written, otherwise a fresh one in the same folder. - fn claim(&mut self, original: &str) -> String { - let lower = original.to_ascii_lowercase(); - let own = OWN_DIRS.iter().any(|d| lower.starts_with(d)); - if own && self.used.insert(lower) { - return original.to_string(); - } - let (dir, file) = package::split_dir(original); - let (stem, ext) = match file.rsplit_once('.') { - Some((s, e)) => (s, format!(".{e}")), - None => (file, String::new()), - }; - let stem = if own { format!("{stem}_") } else { "kept".to_string() }; - loop { - self.n = self.n.saturating_add(1); - let file = format!("{stem}{}{ext}", self.n); - let cand = if dir.is_empty() { file } else { format!("{dir}/{file}") }; - if self.used.insert(cand.to_ascii_lowercase()) || self.n == u32::MAX { - return cand; - } - } - } -} - -/// Original part name (normalized) → written part name. -type NameMap = HashMap; - -fn rel_target(r: &PreservedRel, names: &NameMap) -> Option { - if r.external { Some(r.target.clone()) } else { names.get(&normalize(&r.target)).map(|n| format!("/{n}")) } -} - -/// A kept part's relationships, ids unchanged, targets renamed. -fn part_rels_xml(rels: &[PreservedRel], names: &NameMap) -> String { - let mut s = format!("{XML_DECL}"); - for r in rels { - let Some(target) = rel_target(r, names) else { continue }; - let _ = write!( - s, - "", - esc_attr(&r.id), - esc_attr(&r.rel_type), - esc_attr(&target), - if r.external { " TargetMode=\"External\"" } else { "" } - ); - } - s.push_str(""); - s -} - -fn write_parts(out: &mut Out, parts: &[PreservedPart]) -> NameMap { - let mut names = NameMap::new(); - for p in parts { - names.entry(normalize(&p.name)).or_insert_with(|| out.kept.claim(&p.name)); - } - let mut written = HashSet::new(); - for p in parts { - let Some(new) = names.get(&normalize(&p.name)).cloned() else { continue }; - if !written.insert(new.clone()) { - continue; - } - out.part(&new, Some(&p.content_type), p.data.to_vec()); - if !p.rels.is_empty() { - out.part(&rels_part_name(&new), None, part_rels_xml(&p.rels, &names).into_bytes()); - } - } - names -} - -/// Adds the owner's relationships; returns old id → new id. -fn attach(rels: &mut Rels, list: &[PreservedRel], names: &NameMap) -> HashMap { - let mut ids = HashMap::new(); - for r in list { - if let Some(target) = rel_target(r, names) { - let id = rels.add_raw(&r.rel_type, &target, r.external); - ids.insert(r.id.clone(), id); - } - } - ids -} - -fn remap(x: &str, ids: &HashMap) -> Option { - map_rel_ids(x, &mut |id| ids.get(id).cloned()) -} - -/// A kept chart sheet being written: its `` entry's fields and its new relationship id. -pub struct KeptSheet { - before: usize, - name: String, - visibility: Visibility, - rid: String, - sheet_id: u32, -} - -/// The `sheetId`s of the written sheets: each keeps the one the file gave it, unless another -/// sheet took it first (a copied sheet) or it has none (a new sheet), and those get the next id -/// that no sheet asks for. -pub struct SheetIds { - wanted: HashSet, - given: HashSet, - next: u32, -} - -impl SheetIds { - pub fn new(wb: &Workbook, kept: &WorkbookKept) -> SheetIds { - let wanted: HashSet = wb.sheets.iter().map(|s| s.file_id).chain(kept.sheets.iter().map(|s| s.sheet_id)).filter(|&id| id != 0).collect(); - SheetIds { wanted, given: HashSet::new(), next: 1 } - } - - pub fn take(&mut self, wanted: u32) -> u32 { - if wanted != 0 && self.given.insert(wanted) { - return wanted; - } - while self.wanted.contains(&self.next) || self.given.contains(&self.next) { - self.next += 1; - } - self.given.insert(self.next); - self.next - } -} - -/// The workbook's kept content as written. -#[derive(Default)] -pub struct WorkbookKept { - /// Workbook XML elements that go right after `` (``). - pub elements: String, - sheets: Vec, -} - -impl WorkbookKept { - /// Where the model's sheet `i` is in the file's sheet list: chart sheets come between worksheets. - pub fn file_index(&self, i: usize) -> usize { - i + self.sheets.iter().filter(|s| s.before <= i).count() - } - - /// The `` entries of the chart sheets that go right before the model's sheet `i` - /// (`i` = the number of sheets for those that come last), with their ids from `ids`. - pub fn sheet_entries(&self, i: usize, last: bool, ids: &mut SheetIds) -> String { - let mut x = String::new(); - for s in self.sheets.iter().filter(|s| if last { s.before >= i } else { s.before == i }) { - let state = match s.visibility { - Visibility::Visible => "", - Visibility::Hidden => " state=\"hidden\"", - Visibility::VeryHidden => " state=\"veryHidden\"", - }; - let name: String = s.name.chars().take(31).collect(); - let id = ids.take(s.sheet_id); - let _ = write!(x, "", esc_attr(&name), esc_attr(&s.rid)); - } - x - } -} - -/// The part names reachable from `root` through kept parts' relationships (normalized). -fn reachable(p: &Preserved, root: &PreservedRel) -> HashSet { - let by_name: HashMap = p.parts.iter().map(|q| (normalize(&q.name), q)).collect(); - let mut seen = HashSet::new(); - let mut queue = VecDeque::from([normalize(&root.target)]); - while let Some(n) = queue.pop_front() { - if !seen.insert(n.clone()) { - continue; - } - if let Some(q) = by_name.get(&n) { - queue.extend(q.rels.iter().filter(|r| !r.external).map(|r| normalize(&r.target))); - } - } - seen -} - -/// Every formula (``) of a chart part, depth first. -fn chart_formulas(el: &xml::El, out: &mut Vec) { - if el.name == "f" && !el.text.trim().is_empty() { - out.push(el.text.trim().to_string()); - } - for c in &el.children { - chart_formulas(c, out); - } -} - -/// The sheet names a formula such as `'My Sheet'!$A$1:$A$9,Data!$B$1` refers to. References to -/// other workbooks (`[1]Sheet!A1`) are not sheets of this one and are left out. -pub(crate) fn sheet_names_in(formula: &str) -> Vec { - let mut names = vec![]; - let chars: Vec = formula.chars().collect(); - let at = |i: usize| chars.get(i).copied(); - let mut i = 0; - while let Some(c) = at(i) { - let mut name = String::new(); - let start = i; - if c == '\'' { - i += 1; - while let Some(c) = at(i) { - if c == '\'' { - if at(i + 1) == Some('\'') { - name.push('\''); - i += 2; - continue; - } - break; - } - name.push(c); - i += 1; - } - i += 1; - } else { - while let Some(c) = at(i).filter(|c| !matches!(c, '!' | ',' | '(' | ')' | '\'' | ' ')) { - name.push(c); - i += 1; - } - } - if at(i) == Some('!') { - if !name.is_empty() && !name.starts_with('[') { - names.push(name); - } - i += 1; - } else if i == start { - i += 1; - } - } - names -} - -/// Whether a kept chart sheet can be written back: its chart's series must still refer to sheets -/// that exist under the same names (the chart is kept as it was, so a series on a deleted or -/// renamed sheet would be left pointing at nothing), and no worksheet may have taken its name. -fn chart_sheet_usable(wb: &Workbook, p: &Preserved, s: &PreservedSheet) -> bool { - let same = |a: &str, b: &str| a.chars().take(31).collect::().eq_ignore_ascii_case(&b.chars().take(31).collect::()); - if wb.sheets.iter().any(|w| same(&w.name, &s.name)) { - return false; - } - let Some(root) = p.rels.iter().find(|r| r.id == s.rel_id) else { return false }; - let parts = reachable(p, root); - let mut formulas = vec![]; - for q in p.parts.iter().filter(|q| parts.contains(&normalize(&q.name)) && q.content_type.contains("chart")) { - if let Ok(x) = xml::parse(&q.data) { - chart_formulas(&x, &mut formulas); - } - } - formulas.iter().flat_map(|f| sheet_names_in(f)).all(|n| wb.sheets.iter().any(|w| same(&w.name, &n))) -} - -/// Writes the workbook's kept parts and relationships. Chart sheets that can't be written back -/// (see [`chart_sheet_usable`]) are left out with the parts only they reach. -pub fn write_workbook(wb: &Workbook, wb_rels: &mut Rels, out: &mut Out) -> WorkbookKept { - let Some(p) = wb.preserved.as_deref() else { return WorkbookKept::default() }; - let (live, dropped): (Vec<&PreservedSheet>, Vec<&PreservedSheet>) = p.sheets.iter().partition(|s| chart_sheet_usable(wb, p, s)); - let root_of = |s: &PreservedSheet| p.rels.iter().find(|r| r.id == s.rel_id); - let dropped_ids: HashSet<&str> = dropped.iter().map(|s| s.rel_id.as_str()).collect(); - // Parts reached only by dropped chart sheets go with them. - let mut gone: HashSet = dropped.iter().filter_map(|s| root_of(s)).flat_map(|r| reachable(p, r)).collect(); - if !gone.is_empty() { - let others = p.rels.iter().filter(|r| !dropped_ids.contains(r.id.as_str())).flat_map(|r| reachable(p, r)); - for n in others { - gone.remove(&n); - } - } - let parts: Vec = p.parts.iter().filter(|q| !gone.contains(&normalize(&q.name))).cloned().collect(); - let rels: Vec = p.rels.iter().filter(|r| !dropped_ids.contains(r.id.as_str())).cloned().collect(); - let names = write_parts(out, &parts); - let ids = attach(wb_rels, &rels, &names); - for r in &p.package_rels { - if let Some(target) = rel_target(r, &names) { - out.kept.root.push((r.rel_type.clone(), target, r.external)); - } - } - WorkbookKept { - elements: p.elements.iter().filter(|e| e.slot == "externalReferences").filter_map(|e| remap(&e.xml, &ids)).collect(), - sheets: live - .iter() - .filter_map(|s| { - Some(KeptSheet { - before: s.before, - name: s.name.clone(), - visibility: s.visibility, - rid: ids.get(&s.rel_id)?.clone(), - sheet_id: s.sheet_id, - }) - }) - .collect(), - } -} - -/// The workbook's kept elements of `slot` that refer to no part (`fileVersion`, `ext`). -pub fn workbook_elements(wb: &Workbook, slot: &str) -> String { - let Some(p) = wb.preserved.as_deref() else { return String::new() }; - p.elements.iter().filter(|e| e.slot == slot).map(|e| e.xml.as_str()).collect() -} - -/// Adds the kept package-root relationships (custom properties…) to `_rels/.rels`. -pub fn write_package_rels(root: &mut Rels, out: &mut Out) { - for (ty, target, external) in std::mem::take(&mut out.kept.root) { - root.add_raw(&ty, &target, external); - } -} - -/// A sheet's kept content being written. -pub struct SheetKept<'a> { - p: Option<&'a Preserved>, - names: NameMap, - ids: HashMap, -} - -impl<'a> SheetKept<'a> { - /// Writes the sheet's kept parts and adds their relationships to the sheet's. - pub fn write(sheet: &'a Sheet, rels: &mut Rels, out: &mut Out) -> SheetKept<'a> { - let Some(p) = sheet.preserved.as_deref() else { return SheetKept { p: None, names: NameMap::new(), ids: HashMap::new() } }; - let names = write_parts(out, &p.parts); - let ids = attach(rels, &p.rels, &names); - SheetKept { p: Some(p), names, ids } - } - - fn vml(&self) -> Option<&'a PreservedPart> { - self.p?.vml.as_ref() - } - - fn write_vml_rels(&self, vml: &PreservedPart, name: &str, out: &mut Out) { - if !vml.rels.is_empty() { - out.part(&rels_part_name(name), None, part_rels_xml(&vml.rels, &self.names).into_bytes()); - } - } - - /// GridCraft's notes VML for part `name`, merged with the kept control and object shapes. - pub fn notes_vml(&self, ours: String, name: &str, out: &mut Out) -> String { - let Some(vml) = self.vml() else { return ours }; - self.write_vml_rels(vml, name, out); - merge_vml(&String::from_utf8_lossy(&vml.data), &ours) - } - - /// The kept VML drawing of a sheet without notes; its new relationship id. - pub fn lone_vml(&self, rels: &mut Rels, out: &mut Out) -> Option { - let vml = self.vml()?; - let name = out.kept.claim(&vml.name); - out.part(&name, Some(CT_VML), vml.data.to_vec()); - self.write_vml_rels(vml, &name, out); - Some(rels.add("vmlDrawing", &format!("/{name}"))) - } - - /// The new relationship id for `` (printer settings). - pub fn page_setup_id(&self) -> Option<&str> { - let old = &self.p?.elements.iter().find(|e| e.slot == "pageSetup")?.xml; - self.ids.get(old).map(String::as_str) - } - - /// The kept sheet elements, in schema order. - pub fn elements(&self) -> String { - let Some(p) = self.p else { return String::new() }; - let mut s = String::new(); - for slot in SHEET_SLOTS { - for e in p.elements.iter().filter(|e| e.slot == *slot) { - if let Some(x) = remap(&e.xml, &self.ids) { - s.push_str(&x); - } - } - } - s - } -} - -// ---------------------------------------------------------------- linked workbooks - -/// `wb` as it is saved into the folder `to` (absolute): the relative paths of its linked -/// workbooks, relative to the folder it was read from, are rewritten relative to `to`, as Excel -/// does on Save As, or made absolute when no relative path exists (another drive). `None` when -/// nothing changes. -pub fn rebase_links(wb: &Workbook, to: &Path) -> Option { - let p = wb.preserved.as_deref()?; - let from = Folder::of(wb.source_dir.as_deref()?)?; - let to = Folder::of(to)?; - if from == to { - return None; - } - let mut parts = p.parts.clone(); - let mut changed = false; - for r in parts.iter_mut().flat_map(|part| part.rels.iter_mut()) { - if r.external - && is_kind(r, &["externalLinkPath"]) - && let Some(target) = rebase(&r.target, &from, &to) - { - r.target = target; - changed = true; - } - } - if !changed { - return None; - } - let mut out = wb.clone(); - out.preserved = Some(Arc::new(Preserved { parts, ..p.clone() })); - Some(out) -} - -/// An absolute folder, lexically normalized. -#[derive(PartialEq)] -struct Folder { - /// Drive or share and root separator (`/`, `C:\`, `\\server\share\`). - root: String, - names: Vec, -} - -impl Folder { - fn of(dir: &Path) -> Option { - if !dir.is_absolute() { - return None; - } - let mut f = Folder { root: String::new(), names: vec![] }; - for c in dir.components() { - match c { - Component::Prefix(_) | Component::RootDir => f.root.push_str(&c.as_os_str().to_string_lossy()), - Component::CurDir => {} - Component::ParentDir => { - f.names.pop(); - } - Component::Normal(n) => f.names.push(n.to_string_lossy().into_owned()), - } - } - Some(f) - } -} - -/// A relative link target read from `from`, rewritten for `to`. `None` keeps it as it is: an -/// absolute path or URL, or a path that climbs above the root. -fn rebase(target: &str, from: &Folder, to: &Folder) -> Option { - if !is_relative_path(target) { - return None; - } - // Each name as the file system knows it, and as it is written in a target. Names taken from - // the original target keep their spelling. - let mut path: Vec<(String, String)> = from.names.iter().map(|n| (n.clone(), encode_name(n))).collect(); - for seg in target.split(['/', '\\']) { - match seg { - "" | "." => {} - ".." => { - path.pop()?; - } - _ => path.push((decode_name(seg), seg.to_string())), - } - } - let folders = path.len().checked_sub(1)?; - if !from.root.eq_ignore_ascii_case(&to.root) { - let sep = if from.root.contains('\\') { "\\" } else { "/" }; - let names: Vec<&str> = path.iter().map(|(name, _)| name.as_str()).collect(); - return Some(format!("file:///{}{}", from.root, names.join(sep))); - } - let common = to.names.iter().zip(&path).take(folders).take_while(|(a, (b, _))| *a == b).count(); - let up = "../".repeat(to.names.len().saturating_sub(common)); - let down: Vec<&str> = path.iter().skip(common).map(|(_, written)| written.as_str()).collect(); - Some(format!("{up}{}", down.join("/"))) -} - -/// Not empty, no root, no scheme or drive (`file:`, `http:`, `C:`). -fn is_relative_path(target: &str) -> bool { - let scheme = target.split_once(':').is_some_and(|(s, _)| { - s.starts_with(|c: char| c.is_ascii_alphabetic()) && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) - }); - !target.is_empty() && !target.starts_with(['/', '\\']) && !scheme -} - -/// A folder name as a segment of a relationship target (a URI): ASCII characters a URI path -/// can't hold are percent-encoded, other characters are written as they are. -fn encode_name(name: &str) -> String { - let mut s = String::with_capacity(name.len()); - for c in name.chars() { - if !c.is_ascii() || c.is_ascii_alphanumeric() || "-._~!$&'()+,;=@".contains(c) { - s.push(c); - } else { - let _ = write!(s, "%{:02X}", c as u32); - } - } - s -} - -/// A target segment with its percent-escapes decoded; as written when they don't decode to UTF-8. -fn decode_name(seg: &str) -> String { - let b = seg.as_bytes(); - let mut out = Vec::with_capacity(b.len()); - let mut i = 0; - while let Some(&c) = b.get(i) { - let hex = b.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok()); - match hex { - Some(byte) if c == b'%' => { - out.push(byte); - i += 3; - } - _ => { - out.push(c); - i += 1; - } - } - } - String::from_utf8(out).unwrap_or_else(|_| seg.to_string()) -} diff --git a/crates/xlsx/src/passthrough/read.rs b/crates/xlsx/src/passthrough/read.rs new file mode 100644 index 000000000..6be20d76d --- /dev/null +++ b/crates/xlsx/src/passthrough/read.rs @@ -0,0 +1,688 @@ +use super::*; + +/// A part's relationships with full type URIs. `Ok(vec![])` when it has none; an error when the +/// rels part is damaged or too large (its part then can't be kept faithfully). +fn raw_rels(cx: &mut Ctx<'_>, part: &str) -> Result, String> { + let name = rels_part_name(part); + let bytes = match cx.pkg.read_limited(&name, MAX_META) { + Ok(Some(b)) => b, + Ok(None) => return Ok(vec![]), + Err(e) => return Err(e.to_string()), + }; + let root = xml::parse(&bytes).map_err(|e| format!("{name}: {e}"))?; + let (dir, _) = package::split_dir(part); + let mut out = vec![]; + for r in root.kids("Relationship") { + if out.len() >= MAX_RELS { + return Err(format!("{name}: too many relationships")); + } + let (Some(id), Some(target)) = (r.attr("Id"), r.attr("Target")) else { continue }; + let external = r.attr("TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External")); + out.push(PreservedRel { + id: id.to_string(), + rel_type: r.attr("Type").unwrap_or("").to_string(), + target: if external { target.to_string() } else { package::resolve(dir, target) }, + external, + }); + } + Ok(out) +} + +// ---------------------------------------------------------------- reading + +/// The `` entries of the workbook that are chart sheets, each with the number of worksheets +/// (model sheets) before it. +fn chart_sheet_entries<'a>(wb_xml: &'a xml::El, wb_rels: &[PreservedRel], file_to_model: &[Option]) -> Vec<(usize, &'a xml::El)> { + let mut out = vec![]; + let mut worksheets = 0usize; + for (i, se) in wb_xml.child("sheets").into_iter().flat_map(|s| s.kids("sheet")).enumerate() { + if file_to_model.get(i).copied().flatten().is_some() { + worksheets += 1; + } else if se.attr("id").and_then(|id| wb_rels.iter().find(|r| r.id == id)).is_some_and(|r| is_kind(r, &["chartsheet"])) { + out.push((worksheets, se)); + } + } + out +} + +/// Collects kept content while a package is read. +pub struct KeptReader { + on: bool, + strict: bool, + defaults: HashMap, + overrides: HashMap, + budget: usize, + parts_left: usize, +} + +impl KeptReader { + pub fn new(cx: &mut Ctx<'_>) -> KeptReader { + let mut k = KeptReader { + on: true, + strict: false, + defaults: HashMap::new(), + overrides: HashMap::new(), + budget: kept_total(), + parts_left: MAX_KEPT_PARTS, + }; + if let Ok(root) = raw_rels(cx, "") + && root.iter().any(|r| r.rel_type.starts_with("http://purl.oclc.org/")) + { + k.on = false; + k.strict = true; + } + match cx.pkg.read_limited("[Content_Types].xml", MAX_META).ok().flatten().and_then(|b| xml::parse(&b).ok()) { + Some(types) => { + for d in types.kids("Default") { + if let (Some(e), Some(t)) = (d.attr("Extension"), d.attr("ContentType")) { + k.defaults.insert(e.to_ascii_lowercase(), t.to_string()); + } + } + for o in types.kids("Override") { + if let (Some(p), Some(t)) = (o.attr("PartName"), o.attr("ContentType")) { + k.overrides.insert(normalize(p), t.to_string()); + } + } + } + None => k.on = false, + } + k + } + + fn content_type(&self, part: &str) -> Option { + let key = normalize(part); + if let Some(t) = self.overrides.get(&key) { + return Some(t.clone()); + } + let (_, file) = package::split_dir(&key); + let ext = file.rsplit_once('.').map(|(_, e)| e)?; + self.defaults.get(ext).cloned() + } + + /// The parts reachable from `root` that aren't in `seen` yet. Fails (and keeps nothing) when the + /// graph reaches rewritten content, a missing or damaged part, or a size limit. + fn closure(&mut self, cx: &mut Ctx<'_>, root: &PreservedRel, seen: &HashSet, through: &[&str]) -> Result, String> { + if root.external { + return Ok(vec![]); + } + let blocked = |r: &PreservedRel| rewritten(r) && !through.iter().any(|k| k.eq_ignore_ascii_case(kind(&r.rel_type))); + if blocked(root) { + return Err(format!("it refers to {}", kind(&root.rel_type))); + } + let mut parts = vec![]; + let mut bytes = 0usize; + let mut local: HashSet = HashSet::new(); + let mut queue = VecDeque::from([(root.target.clone(), 0usize)]); + while let Some((name, depth)) = queue.pop_front() { + let key = normalize(&name); + if seen.contains(&key) || !local.insert(key) { + continue; + } + if depth > MAX_CHAIN { + return Err("its relationships are nested too deeply".into()); + } + if parts.len() >= self.parts_left { + return Err("it has too many parts".into()); + } + let content_type = self.content_type(&name).ok_or_else(|| format!("{name} has no content type"))?; + let data = match cx.pkg.read_limited(&name, MAX_KEPT_PART) { + Ok(Some(d)) => d, + Ok(None) => return Err(format!("{name} is missing")), + Err(e) => return Err(e.to_string()), + }; + bytes = bytes.saturating_add(data.len()); + if bytes > self.budget { + return Err("it is too large".into()); + } + let rels = raw_rels(cx, &name)?; + for r in rels.iter().filter(|r| !r.external) { + if blocked(r) { + return Err(format!("it refers to {}", kind(&r.rel_type))); + } + queue.push_back((r.target.clone(), depth + 1)); + } + parts.push(PreservedPart { name, content_type, data: Arc::from(data), rels }); + } + self.budget = self.budget.saturating_sub(bytes); + self.parts_left = self.parts_left.saturating_sub(parts.len()); + Ok(parts) + } + + /// Keeps a set of relationships (all or nothing) and the parts behind them. + fn keep(&mut self, cx: &mut Ctx<'_>, p: &mut Preserved, roots: &[PreservedRel], seen: &mut HashSet) -> Result<(), String> { + self.keep_through(cx, p, roots, seen, &[]) + } + + /// [`Self::keep`] for roots whose graph may reach the rewritten kinds in `through`. + fn keep_through( + &mut self, + cx: &mut Ctx<'_>, + p: &mut Preserved, + roots: &[PreservedRel], + seen: &mut HashSet, + through: &[&str], + ) -> Result<(), String> { + let mut new_parts: Vec = vec![]; + let mut scratch = seen.clone(); + for r in roots { + let parts = self.closure(cx, r, &scratch, through)?; + scratch.extend(parts.iter().map(|q| normalize(&q.name))); + new_parts.extend(parts); + } + *seen = scratch; + p.parts.extend(new_parts); + for r in roots { + if !p.rels.iter().any(|x| x.id == r.id) { + p.rels.push(r.clone()); + } + } + Ok(()) + } + + /// Package-root and workbook-level content. + pub fn workbook(&mut self, cx: &mut Ctx<'_>, wb_part: &str, wb_xml: &xml::El, file_to_model: &[Option]) -> Option> { + if !self.on { + let wb_rels = raw_rels(cx, wb_part).unwrap_or_default(); + for (_, se) in chart_sheet_entries(wb_xml, &wb_rels, file_to_model) { + cx.warn(format!("chart sheet \"{}\" was skipped and is not kept on save", se.attr("name").unwrap_or(""))); + } + if self.strict { + cx.warn("unmodelled content of a Strict Open XML workbook (custom XML, links, controls…) is not kept on save"); + } + return None; + } + let mut p = Preserved::default(); + let mut seen = HashSet::new(); + // Package root: custom document properties, ribbon customisation, task panes. + if let Ok(root) = raw_rels(cx, "") { + for r in root.iter().filter(|r| is_kind(r, PACKAGE_ROOTS)) { + let mut q = Preserved::default(); + match self.keep(cx, &mut q, std::slice::from_ref(r), &mut seen) { + Ok(()) => { + p.parts.extend(q.parts); + p.package_rels.extend(q.rels); + } + Err(why) => cx.warn(format!("{} was dropped: {why}", kind(&r.rel_type))), + } + } + } + let wb_rels = raw_rels(cx, wb_part).unwrap_or_default(); + for r in wb_rels.iter().filter(|r| is_kind(r, WORKBOOK_ROOTS)) { + if let Err(why) = self.keep(cx, &mut p, std::slice::from_ref(r), &mut seen) { + cx.warn(format!("{} part {} was dropped: {why}", kind(&r.rel_type), r.target)); + } + } + // Chart sheets: the sheet entry (name, state, position) plus the chart sheet part and the + // drawing, chart, style and colour parts behind it. + for (worksheets, se) in chart_sheet_entries(wb_xml, &wb_rels, file_to_model) { + let name = se.attr("name").unwrap_or("").to_string(); + let Some(r) = se.attr("id").and_then(|id| wb_rels.iter().find(|r| r.id == id)) else { continue }; + match self.keep_through(cx, &mut p, std::slice::from_ref(r), &mut seen, CHART_SHEET_GRAPH) { + Ok(()) => p.sheets.push(PreservedSheet { + name, + visibility: match se.attr("state") { + Some("hidden") => Visibility::Hidden, + Some("veryHidden") => Visibility::VeryHidden, + _ => Visibility::Visible, + }, + rel_id: r.id.clone(), + before: worksheets, + sheet_id: se.attr_u32("sheetId").unwrap_or(0), + }), + Err(why) => cx.warn(format!("chart sheet \"{name}\" was dropped: {why}")), + } + } + // Links to external workbooks: `[1]` in formulas is the position in , + // so they are kept all together or not at all. + let ids: Vec<&str> = + wb_xml.child("externalReferences").map(|e| e.kids("externalReference").filter_map(|x| x.attr("id")).collect()).unwrap_or_default(); + if !ids.is_empty() { + let roots: Option> = + ids.iter().map(|id| wb_rels.iter().find(|r| r.id == *id && is_kind(r, &["externalLink"])).cloned()).collect(); + let kept = match roots { + Some(roots) => self.keep(cx, &mut p, &roots, &mut seen), + None => Err("a link has no part".into()), + }; + match kept { + Ok(()) => { + let mut x = format!(""); + for id in &ids { + let _ = write!(x, "", esc_attr(id)); + } + x.push_str(""); + p.elements.push(PreservedXml { slot: "externalReferences".into(), xml: x }); + cx.warn("links to external workbooks were kept; they are not followed or refreshed"); + } + Err(why) => cx.warn(format!("links to external workbooks were dropped: {why}")), + } + } + // Excel trusts the saved cell values only when both are present; without them it + // recalculates everything on open, and links into closed workbooks come back as errors. + if let Some(v) = wb_xml.child("fileVersion") { + let mut x = String::from(""); + p.elements.push(PreservedXml { slot: "fileVersion".into(), xml: x }); + } + let calc_features = wb_xml + .child("extLst") + .and_then(|l| l.kids("ext").find(|e| e.attr("uri").is_some_and(|u| u.eq_ignore_ascii_case(CALC_FEATURES_URI)))) + .and_then(|e| e.child("calcFeatures")); + if let Some(c) = calc_features { + let mut x = format!(""); + for name in c.kids("feature").filter_map(|f| f.attr("name")) { + let _ = write!(x, "", esc_attr(name)); + } + x.push_str(""); + p.elements.push(PreservedXml { slot: "ext".into(), xml: x }); + } + (!p.is_empty()).then(|| Arc::new(p)) + } + + /// Sheet-level content of the sheet part `part`. + pub fn sheet(&mut self, cx: &mut Ctx<'_>, part: &str, name: &str) -> Option> { + if !self.on { + return None; + } + let rels = match raw_rels(cx, part) { + Ok(r) => r, + Err(e) => { + cx.warn(format!("sheet \"{name}\": relationships could not be read ({e}); unmodelled content was dropped")); + return None; + } + }; + let mut p = Preserved::default(); + let mut seen = HashSet::new(); + for r in rels.iter().filter(|r| is_kind(r, SHEET_ROOTS)) { + if let Err(why) = self.keep(cx, &mut p, std::slice::from_ref(r), &mut seen) { + cx.warn(format!("sheet \"{name}\": {} was dropped: {why}", kind(&r.rel_type))); + } + } + // Notes have one VML drawing per sheet; another one holds the header and footer pictures. + let vml_drawings = rels.iter().filter(|r| is_kind(r, &["vmlDrawing"])).count(); + let notes = rels.iter().filter(|r| is_kind(r, &["comments"])).count(); + if rels.iter().any(|r| is_kind(r, SHEET_ELEMENT_KINDS)) || vml_drawings > notes { + self.sheet_elements(cx, part, name, &rels, &mut p, &mut seen); + } + (!p.is_empty()).then(|| Arc::new(p)) + } + + fn sheet_elements(&mut self, cx: &mut Ctx<'_>, part: &str, name: &str, rels: &[PreservedRel], p: &mut Preserved, seen: &mut HashSet) { + let scan = match cx.pkg.read(part) { + Ok(Some(bytes)) => scan_sheet(&bytes), + _ => None, + }; + let Some(scan) = scan else { + cx.warn(format!("sheet \"{name}\": controls and embedded objects could not be read and were dropped")); + return; + }; + // Printer settings (a binary part per sheet) hang off . + if let Some(id) = scan.page_setup.as_deref() + && let Some(r) = rels.iter().find(|r| r.id == id && is_kind(r, &["printerSettings"])) + { + match self.keep(cx, p, std::slice::from_ref(r), seen) { + Ok(()) => p.elements.push(PreservedXml { slot: "pageSetup".into(), xml: r.id.clone() }), + Err(why) => cx.warn(format!("sheet \"{name}\": printer settings were dropped: {why}")), + } + } + // Header and footer pictures (`&G`): a VML drawing of their own and the images it shows, + // kept byte for byte (GridCraft writes the header text, not the pictures). + if let Some(id) = scan.legacy_drawing_hf.as_deref() + && scan.legacy_drawing.as_deref() != Some(id) + && let Some(r) = rels.iter().find(|r| r.id == id && is_kind(r, &["vmlDrawing"])) + { + match self.keep_through(cx, p, std::slice::from_ref(r), seen, &["vmlDrawing"]) { + Ok(()) => { + let xml = format!("", esc_attr(&r.id)); + p.elements.push(PreservedXml { slot: "legacyDrawingHF".into(), xml }); + cx.warn(format!("sheet \"{name}\": pictures in the header and footer are not shown; they are kept for saving")); + } + Err(why) => cx.warn(format!("sheet \"{name}\": header and footer pictures were dropped: {why}")), + } + } + let mut frags = scan.fragments; + // Controls and OLE objects are drawn from shapes in the legacy VML drawing. + if frags.iter().any(|(slot, _)| slot != "picture") { + match self.vml(cx, rels, scan.legacy_drawing.as_deref(), seen) { + Ok((vml, parts)) => { + p.parts.extend(parts); + p.vml = Some(vml); + } + Err(why) => { + cx.warn(format!("sheet \"{name}\": controls and embedded objects were dropped: {why}")); + frags.retain(|(slot, _)| slot == "picture"); + } + } + } + for (slot, x) in frags { + let mut ids = vec![]; + let ok = map_rel_ids(&x, &mut |id| { + ids.push(id.to_string()); + Some(id.to_string()) + }) + .is_some(); + let roots: Option> = ids.iter().map(|id| rels.iter().find(|r| r.id == *id).cloned()).collect(); + let kept = match (ok, roots) { + (true, Some(roots)) => self.keep(cx, p, &roots, seen), + (false, _) => Err("its XML is damaged".into()), + (_, None) => Err("it refers to a missing relationship".into()), + }; + match kept { + Ok(()) => p.elements.push(PreservedXml { slot, xml: x }), + Err(why) => cx.warn(format!("sheet \"{name}\": {slot} were dropped: {why}")), + } + } + if p.elements.iter().any(|e| e.slot == "oleObjects" || e.slot == "controls") { + cx.warn(format!("sheet \"{name}\": embedded objects and form controls are not shown; they are kept for saving")); + } else { + p.vml = None; + } + } + + /// The sheet's VML drawing without its notes (GridCraft writes those), and the parts its + /// shapes refer to (preview images). + fn vml( + &mut self, + cx: &mut Ctx<'_>, + rels: &[PreservedRel], + rid: Option<&str>, + seen: &mut HashSet, + ) -> Result<(PreservedPart, Vec), String> { + let rel = rid.and_then(|id| rels.iter().find(|r| r.id == id && !r.external)).ok_or("the sheet has no VML drawing")?; + // Charged to the kept total like any other part: many sheets can point at one highly + // compressed VML drawing, and each keeps its own copy. + if self.budget == 0 { + return Err("it is too large".into()); + } + let limit = MAX_META.min(u64::try_from(self.budget).unwrap_or(u64::MAX)); + let data = match cx.pkg.read_limited(&rel.target, limit) { + Ok(Some(d)) => d, + Ok(None) => return Err(format!("{} is missing", rel.target)), + Err(IoError::TooLarge(_)) => return Err("it is too large".into()), + Err(e) => return Err(e.to_string()), + }; + self.budget = self.budget.saturating_sub(data.len()); + let text = String::from_utf8(data).map_err(|_| "the VML drawing is not UTF-8".to_string())?; + let text = strip_notes(&text); + let vrels = raw_rels(cx, &rel.target)?; + let mut q = Preserved::default(); + let roots: Vec = vrels.iter().filter(|r| !r.external).cloned().collect(); + self.keep(cx, &mut q, &roots, seen)?; + let vml = PreservedPart { name: rel.target.clone(), content_type: CT_VML.into(), data: Arc::from(text.into_bytes()), rels: vrels }; + Ok((vml, q.parts)) + } +} + +/// What a sheet part holds for pass-through. +struct SheetScan { + /// (slot, XML with the namespace declarations it needs). + fragments: Vec<(String, String)>, + legacy_drawing: Option, + /// The relationship id on `` (header and footer pictures). + legacy_drawing_hf: Option, + /// The relationship id on `` (printer settings). + page_setup: Option, +} + +/// Finds the sheet's ``, ``, `` (also inside +/// ``) and the `` and `` ids. `None` when the XML is damaged. +fn scan_sheet(bytes: &[u8]) -> Option { + let data = xml::to_utf8(bytes); + let text = std::str::from_utf8(&data).ok()?; + let mut reader = Reader::from_str(text); + let mut decls: Vec<(String, String)> = vec![]; + let mut out = SheetScan { fragments: vec![], legacy_drawing: None, legacy_drawing_hf: None, page_setup: None }; + let mut in_root = false; + loop { + let start = usize::try_from(reader.buffer_position()).ok()?; + match reader.read_event().ok()? { + Event::Eof => break, + 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('"') + { + decls.push((p.to_string(), v.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), + _ => {} + } + 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"); + 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 id = rel_id_attr(&e); + match local.as_slice() { + b"pageSetup" => out.page_setup = id, + b"legacyDrawing" => out.legacy_drawing = id, + _ => out.legacy_drawing_hf = id, + } + } else if local == b"picture" + && let Some(frag) = text.get(start..usize::try_from(reader.buffer_position()).ok()?) + { + push_fragment(&mut out, frag, &local, &decls); + } + } + Event::End(_) => break, + _ => {} + } + } + Some(out) +} + +/// The `r:id` of an element (an `id` attribute with a namespace prefix). +fn rel_id_attr(e: &BytesStart<'_>) -> Option { + 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()) +} + +fn push_fragment(out: &mut SheetScan, frag: &str, local: &[u8], decls: &[(String, String)]) { + let frag = with_decls(frag, decls); + let slot = if local == b"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()) { + Some(c) => c.name.clone(), + None => return, + } + } else { + String::from_utf8_lossy(local).into_owned() + }; + if SHEET_SLOTS.contains(&slot.as_str()) && !out.fragments.iter().any(|(s, _)| *s == slot) { + out.fragments.push((slot, frag)); + } +} + +/// Adds the root's namespace declarations a fragment doesn't declare itself. +fn with_decls(frag: &str, decls: &[(String, String)]) -> String { + let Some(name_end) = frag.char_indices().skip(1).find(|(_, c)| c.is_whitespace() || *c == '/' || *c == '>').map(|(i, _)| i) else { + return frag.to_string(); + }; + let head = frag.get(..frag.find('>').unwrap_or(frag.len())).unwrap_or(""); + let mut add = String::new(); + for (p, v) in decls { + if !head.contains(&format!("xmlns:{p}=")) { + let _ = write!(add, " xmlns:{p}=\"{v}\""); + } + } + format!("{}{add}{}", frag.get(..name_end).unwrap_or(""), frag.get(name_end..).unwrap_or("")) +} + +/// Byte ranges of the `…` (or self-closed) elements in VML text. +fn shape_spans(vml: &str) -> Vec<(usize, usize)> { + let mut spans = vec![]; + let mut from = 0; + while let Some(i) = vml.get(from..).and_then(|s| s.find("' || c == '/'); + if !is_shape { + from = after; + continue; + } + let Some(tag_end) = vml.get(i..).and_then(|s| s.find('>')).map(|j| i + j + 1) else { break }; + let end = if vml.get(..tag_end).is_some_and(|t| t.ends_with("/>")) { + tag_end + } else { + match vml.get(tag_end..).and_then(|s| s.find("")) { + Some(j) => tag_end + j + "".len(), + None => break, + } + }; + spans.push((i, end)); + from = end; + } + spans +} + +/// VML without its note shapes (`ObjectType="Note"`). +fn strip_notes(vml: &str) -> String { + let mut out = String::with_capacity(vml.len()); + let mut last = 0; + for (a, b) in shape_spans(vml) { + let shape = vml.get(a..b).unwrap_or(""); + if shape.contains("ObjectType=\"Note\"") { + out.push_str(vml.get(last..a).unwrap_or("")); + last = b; + } + } + out.push_str(vml.get(last..).unwrap_or("")); + out +} + +/// Shape numbers (`_x0000_s1025` → 1025) used in VML text. +fn shape_numbers(vml: &str) -> Vec { + vml.match_indices("_x0000_s") + .filter_map(|(i, m)| { + let digits: String = vml.get(i + m.len()..)?.chars().take_while(char::is_ascii_digit).take(18).collect(); + digits.parse().ok() + }) + .collect() +} + +/// The highest shape block VML text claims in `` or uses in a shape id. +pub(super) fn vml_block_max(vml: &str) -> u64 { + let used = shape_numbers(vml).into_iter().max().unwrap_or(0) / 1024; + let claimed = vml + .find("().ok()).max().unwrap_or(0)) + .unwrap_or(0); + used.max(claimed) +} + +/// Kept VML (controls, OLE objects) plus GridCraft's note shapes: the notes get ids in a fresh +/// shape block so they can't collide with the kept shapes the sheet XML refers to. +pub(super) fn merge_vml(kept: &str, ours: &str) -> String { + let Some(close) = kept.rfind("") else { return ours.to_string() }; + let block = shape_numbers(kept).into_iter().max().unwrap_or(0) / 1024 + 1; + let mut add = String::new(); + if !kept.contains("id=\"_x0000_t202\"") + && let Some(i) = ours.find("")) + { + add.push_str(ours.get(i..i + j + "".len()).unwrap_or("")); + } + for (k, (a, b)) in shape_spans(ours).into_iter().enumerate() { + let shape = ours.get(a..b).unwrap_or(""); + let id = block.saturating_mul(1024).saturating_add(1 + k as u64); + match (shape.find("id=\"_x0000_s"), shape.find("id=\"_x0000_s").and_then(|i| shape.get(i + 4..)?.find('"').map(|j| i + 4 + j))) { + (Some(i), Some(j)) => { + let _ = write!(add, "{}id=\"_x0000_s{id}\"{}", shape.get(..i).unwrap_or(""), shape.get(j + 1..).unwrap_or("")); + } + _ => add.push_str(shape), + } + } + let mut head = kept.get(..close).unwrap_or("").to_string(); + // Claim the block in the drawing's id map. + if let Some(i) = head.find(" Option) -> Option { + let mut reader = Reader::from_str(x); + let mut writer = Writer::new(Vec::with_capacity(x.len())); + let mut prefixes: HashSet> = HashSet::new(); + let mut depth = 0usize; + loop { + match reader.read_event().ok()? { + Event::Eof => break, + Event::Start(e) => { + depth += 1; + if depth > MAX_FRAGMENT_DEPTH { + return None; + } + let e = map_start(&e, &mut prefixes, map)?; + writer.write_event(Event::Start(e)).ok()?; + } + Event::Empty(e) => { + let e = map_start(&e, &mut prefixes, map)?; + writer.write_event(Event::Empty(e)).ok()?; + } + Event::End(e) => { + depth = depth.saturating_sub(1); + writer.write_event(Event::End(e)).ok()?; + } + ev => writer.write_event(ev).ok()?, + } + } + String::from_utf8(writer.into_inner()).ok() +} + +fn map_start(e: &BytesStart<'_>, prefixes: &mut HashSet>, map: &mut dyn FnMut(&str) -> Option) -> Option> { + 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()) + { + prefixes.insert(p.to_vec()); + } + } + let name = String::from_utf8(e.name().as_ref().to_vec()).ok()?; + let mut out = BytesStart::new(name); + 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())); + 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()) }); + } else { + out.push_attribute(Attribute { key: QName(key), value: a.value.clone() }); + } + } + Some(out) +} diff --git a/crates/xlsx/src/passthrough/rebase.rs b/crates/xlsx/src/passthrough/rebase.rs new file mode 100644 index 000000000..d758f86d6 --- /dev/null +++ b/crates/xlsx/src/passthrough/rebase.rs @@ -0,0 +1,199 @@ +use super::read::merge_vml; +use super::write::{NameMap, attach, part_rels_xml, remap, write_parts}; +use super::*; + +/// A sheet's kept content being written. +pub struct SheetKept<'a> { + p: Option<&'a Preserved>, + names: NameMap, + ids: HashMap, +} + +impl<'a> SheetKept<'a> { + /// Writes the sheet's kept parts and adds their relationships to the sheet's. + pub fn write(sheet: &'a Sheet, rels: &mut Rels, out: &mut Out) -> SheetKept<'a> { + let Some(p) = sheet.preserved.as_deref() else { return SheetKept { p: None, names: NameMap::new(), ids: HashMap::new() } }; + let names = write_parts(out, &p.parts); + let ids = attach(rels, &p.rels, &names); + SheetKept { p: Some(p), names, ids } + } + + fn vml(&self) -> Option<&'a PreservedPart> { + self.p?.vml.as_ref() + } + + fn write_vml_rels(&self, vml: &PreservedPart, name: &str, out: &mut Out) { + if !vml.rels.is_empty() { + out.part(&rels_part_name(name), None, part_rels_xml(&vml.rels, &self.names).into_bytes()); + } + } + + /// GridCraft's notes VML for part `name`, merged with the kept control and object shapes. + pub fn notes_vml(&self, ours: String, name: &str, out: &mut Out) -> String { + let Some(vml) = self.vml() else { return ours }; + self.write_vml_rels(vml, name, out); + merge_vml(&String::from_utf8_lossy(&vml.data), &ours) + } + + /// The kept VML drawing of a sheet without notes; its new relationship id. + pub fn lone_vml(&self, rels: &mut Rels, out: &mut Out) -> Option { + let vml = self.vml()?; + let name = out.kept.claim(&vml.name); + out.part(&name, Some(CT_VML), vml.data.to_vec()); + self.write_vml_rels(vml, &name, out); + Some(rels.add("vmlDrawing", &format!("/{name}"))) + } + + /// The new relationship id for `` (printer settings). + pub fn page_setup_id(&self) -> Option<&str> { + let old = &self.p?.elements.iter().find(|e| e.slot == "pageSetup")?.xml; + self.ids.get(old).map(String::as_str) + } + + /// The kept sheet elements, in schema order. + pub fn elements(&self) -> String { + let Some(p) = self.p else { return String::new() }; + let mut s = String::new(); + for slot in SHEET_SLOTS { + for e in p.elements.iter().filter(|e| e.slot == *slot) { + if let Some(x) = remap(&e.xml, &self.ids) { + s.push_str(&x); + } + } + } + s + } +} + +// ---------------------------------------------------------------- linked workbooks + +/// `wb` as it is saved into the folder `to` (absolute): the relative paths of its linked +/// workbooks, relative to the folder it was read from, are rewritten relative to `to`, as Excel +/// does on Save As, or made absolute when no relative path exists (another drive). `None` when +/// nothing changes. +pub fn rebase_links(wb: &Workbook, to: &Path) -> Option { + let p = wb.preserved.as_deref()?; + let from = Folder::of(wb.source_dir.as_deref()?)?; + let to = Folder::of(to)?; + if from == to { + return None; + } + let mut parts = p.parts.clone(); + let mut changed = false; + for r in parts.iter_mut().flat_map(|part| part.rels.iter_mut()) { + if r.external + && is_kind(r, &["externalLinkPath"]) + && let Some(target) = rebase(&r.target, &from, &to) + { + r.target = target; + changed = true; + } + } + if !changed { + return None; + } + let mut out = wb.clone(); + out.preserved = Some(Arc::new(Preserved { parts, ..p.clone() })); + Some(out) +} + +/// An absolute folder, lexically normalized. +#[derive(PartialEq)] +struct Folder { + /// Drive or share and root separator (`/`, `C:\`, `\\server\share\`). + root: String, + names: Vec, +} + +impl Folder { + fn of(dir: &Path) -> Option { + if !dir.is_absolute() { + return None; + } + let mut f = Folder { root: String::new(), names: vec![] }; + for c in dir.components() { + match c { + Component::Prefix(_) | Component::RootDir => f.root.push_str(&c.as_os_str().to_string_lossy()), + Component::CurDir => {} + Component::ParentDir => { + f.names.pop(); + } + Component::Normal(n) => f.names.push(n.to_string_lossy().into_owned()), + } + } + Some(f) + } +} + +/// A relative link target read from `from`, rewritten for `to`. `None` keeps it as it is: an +/// absolute path or URL, or a path that climbs above the root. +fn rebase(target: &str, from: &Folder, to: &Folder) -> Option { + if !is_relative_path(target) { + return None; + } + // Each name as the file system knows it, and as it is written in a target. Names taken from + // the original target keep their spelling. + let mut path: Vec<(String, String)> = from.names.iter().map(|n| (n.clone(), encode_name(n))).collect(); + for seg in target.split(['/', '\\']) { + match seg { + "" | "." => {} + ".." => { + path.pop()?; + } + _ => path.push((decode_name(seg), seg.to_string())), + } + } + let folders = path.len().checked_sub(1)?; + if !from.root.eq_ignore_ascii_case(&to.root) { + let sep = if from.root.contains('\\') { "\\" } else { "/" }; + let names: Vec<&str> = path.iter().map(|(name, _)| name.as_str()).collect(); + return Some(format!("file:///{}{}", from.root, names.join(sep))); + } + let common = to.names.iter().zip(&path).take(folders).take_while(|(a, (b, _))| *a == b).count(); + let up = "../".repeat(to.names.len().saturating_sub(common)); + let down: Vec<&str> = path.iter().skip(common).map(|(_, written)| written.as_str()).collect(); + Some(format!("{up}{}", down.join("/"))) +} + +/// Not empty, no root, no scheme or drive (`file:`, `http:`, `C:`). +fn is_relative_path(target: &str) -> bool { + let scheme = target.split_once(':').is_some_and(|(s, _)| { + s.starts_with(|c: char| c.is_ascii_alphabetic()) && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) + }); + !target.is_empty() && !target.starts_with(['/', '\\']) && !scheme +} + +/// A folder name as a segment of a relationship target (a URI): ASCII characters a URI path +/// can't hold are percent-encoded, other characters are written as they are. +fn encode_name(name: &str) -> String { + let mut s = String::with_capacity(name.len()); + for c in name.chars() { + if !c.is_ascii() || c.is_ascii_alphanumeric() || "-._~!$&'()+,;=@".contains(c) { + s.push(c); + } else { + let _ = write!(s, "%{:02X}", c as u32); + } + } + s +} + +/// A target segment with its percent-escapes decoded; as written when they don't decode to UTF-8. +fn decode_name(seg: &str) -> String { + let b = seg.as_bytes(); + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while let Some(&c) = b.get(i) { + let hex = b.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok()); + match hex { + Some(byte) if c == b'%' => { + out.push(byte); + i += 3; + } + _ => { + out.push(c); + i += 1; + } + } + } + String::from_utf8(out).unwrap_or_else(|_| seg.to_string()) +} diff --git a/crates/xlsx/src/passthrough/write.rs b/crates/xlsx/src/passthrough/write.rs new file mode 100644 index 000000000..8b8d24591 --- /dev/null +++ b/crates/xlsx/src/passthrough/write.rs @@ -0,0 +1,325 @@ +use super::read::{map_rel_ids, vml_block_max}; +use super::*; + +// ---------------------------------------------------------------- writing + +/// Names given to kept parts while a package is written. +#[derive(Default)] +pub struct KeptOut { + used: HashSet, + n: u32, + /// The highest VML shape block a kept header and footer drawing uses: notes go after it. + vml_blocks: u32, + /// Package-root relationships (type, target, external), added when `_rels/.rels` is written. + root: Vec<(String, String, bool)>, +} + +impl KeptOut { + /// Reserves the VML shape blocks the kept header and footer drawings of `wb` use, so that the + /// notes drawings GridCraft writes don't reuse their shape ids. + pub fn reserve_vml_blocks(&mut self, wb: &Workbook) { + let vmls = + wb.sheets.iter().filter_map(|s| s.preserved.as_deref()).flat_map(|p| &p.parts).filter(|q| q.content_type.eq_ignore_ascii_case(CT_VML)); + let max = vmls.map(|q| vml_block_max(&String::from_utf8_lossy(&q.data))).max().unwrap_or(0); + self.vml_blocks = u32::try_from(max).unwrap_or(u32::MAX); + } + + /// The shape block of the `n`th notes drawing. + pub fn notes_block(&self, n: u32) -> u32 { + n.saturating_add(self.vml_blocks) + } + + /// A part name for a kept part: its original name when that can't collide with anything + /// written, otherwise a fresh one in the same folder. + pub(super) fn claim(&mut self, original: &str) -> String { + let lower = original.to_ascii_lowercase(); + let own = OWN_DIRS.iter().any(|d| lower.starts_with(d)); + if own && self.used.insert(lower) { + return original.to_string(); + } + let (dir, file) = package::split_dir(original); + let (stem, ext) = match file.rsplit_once('.') { + Some((s, e)) => (s, format!(".{e}")), + None => (file, String::new()), + }; + let stem = if own { format!("{stem}_") } else { "kept".to_string() }; + loop { + self.n = self.n.saturating_add(1); + let file = format!("{stem}{}{ext}", self.n); + let cand = if dir.is_empty() { file } else { format!("{dir}/{file}") }; + if self.used.insert(cand.to_ascii_lowercase()) || self.n == u32::MAX { + return cand; + } + } + } +} + +/// Original part name (normalized) → written part name. +pub(super) type NameMap = HashMap; + +fn rel_target(r: &PreservedRel, names: &NameMap) -> Option { + if r.external { Some(r.target.clone()) } else { names.get(&normalize(&r.target)).map(|n| format!("/{n}")) } +} + +/// A kept part's relationships, ids unchanged, targets renamed. +pub(super) fn part_rels_xml(rels: &[PreservedRel], names: &NameMap) -> String { + let mut s = format!("{XML_DECL}"); + for r in rels { + let Some(target) = rel_target(r, names) else { continue }; + let _ = write!( + s, + "", + esc_attr(&r.id), + esc_attr(&r.rel_type), + esc_attr(&target), + if r.external { " TargetMode=\"External\"" } else { "" } + ); + } + s.push_str(""); + s +} + +pub(super) fn write_parts(out: &mut Out, parts: &[PreservedPart]) -> NameMap { + let mut names = NameMap::new(); + for p in parts { + names.entry(normalize(&p.name)).or_insert_with(|| out.kept.claim(&p.name)); + } + let mut written = HashSet::new(); + for p in parts { + let Some(new) = names.get(&normalize(&p.name)).cloned() else { continue }; + if !written.insert(new.clone()) { + continue; + } + out.part(&new, Some(&p.content_type), p.data.to_vec()); + if !p.rels.is_empty() { + out.part(&rels_part_name(&new), None, part_rels_xml(&p.rels, &names).into_bytes()); + } + } + names +} + +/// Adds the owner's relationships; returns old id → new id. +pub(super) fn attach(rels: &mut Rels, list: &[PreservedRel], names: &NameMap) -> HashMap { + let mut ids = HashMap::new(); + for r in list { + if let Some(target) = rel_target(r, names) { + let id = rels.add_raw(&r.rel_type, &target, r.external); + ids.insert(r.id.clone(), id); + } + } + ids +} + +pub(super) fn remap(x: &str, ids: &HashMap) -> Option { + map_rel_ids(x, &mut |id| ids.get(id).cloned()) +} + +/// A kept chart sheet being written: its `` entry's fields and its new relationship id. +pub struct KeptSheet { + before: usize, + name: String, + visibility: Visibility, + rid: String, + sheet_id: u32, +} + +/// The `sheetId`s of the written sheets: each keeps the one the file gave it, unless another +/// sheet took it first (a copied sheet) or it has none (a new sheet), and those get the next id +/// that no sheet asks for. +pub struct SheetIds { + wanted: HashSet, + given: HashSet, + next: u32, +} + +impl SheetIds { + pub fn new(wb: &Workbook, kept: &WorkbookKept) -> SheetIds { + let wanted: HashSet = wb.sheets.iter().map(|s| s.file_id).chain(kept.sheets.iter().map(|s| s.sheet_id)).filter(|&id| id != 0).collect(); + SheetIds { wanted, given: HashSet::new(), next: 1 } + } + + pub fn take(&mut self, wanted: u32) -> u32 { + if wanted != 0 && self.given.insert(wanted) { + return wanted; + } + while self.wanted.contains(&self.next) || self.given.contains(&self.next) { + self.next += 1; + } + self.given.insert(self.next); + self.next + } +} + +/// The workbook's kept content as written. +#[derive(Default)] +pub struct WorkbookKept { + /// Workbook XML elements that go right after `` (``). + pub elements: String, + sheets: Vec, +} + +impl WorkbookKept { + /// Where the model's sheet `i` is in the file's sheet list: chart sheets come between worksheets. + pub fn file_index(&self, i: usize) -> usize { + i + self.sheets.iter().filter(|s| s.before <= i).count() + } + + /// The `` entries of the chart sheets that go right before the model's sheet `i` + /// (`i` = the number of sheets for those that come last), with their ids from `ids`. + pub fn sheet_entries(&self, i: usize, last: bool, ids: &mut SheetIds) -> String { + let mut x = String::new(); + for s in self.sheets.iter().filter(|s| if last { s.before >= i } else { s.before == i }) { + let state = match s.visibility { + Visibility::Visible => "", + Visibility::Hidden => " state=\"hidden\"", + Visibility::VeryHidden => " state=\"veryHidden\"", + }; + let name: String = s.name.chars().take(31).collect(); + let id = ids.take(s.sheet_id); + let _ = write!(x, "", esc_attr(&name), esc_attr(&s.rid)); + } + x + } +} + +/// The part names reachable from `root` through kept parts' relationships (normalized). +fn reachable(p: &Preserved, root: &PreservedRel) -> HashSet { + let by_name: HashMap = p.parts.iter().map(|q| (normalize(&q.name), q)).collect(); + let mut seen = HashSet::new(); + let mut queue = VecDeque::from([normalize(&root.target)]); + while let Some(n) = queue.pop_front() { + if !seen.insert(n.clone()) { + continue; + } + if let Some(q) = by_name.get(&n) { + queue.extend(q.rels.iter().filter(|r| !r.external).map(|r| normalize(&r.target))); + } + } + seen +} + +/// Every formula (``) of a chart part, depth first. +fn chart_formulas(el: &xml::El, out: &mut Vec) { + if el.name == "f" && !el.text.trim().is_empty() { + out.push(el.text.trim().to_string()); + } + for c in &el.children { + chart_formulas(c, out); + } +} + +/// The sheet names a formula such as `'My Sheet'!$A$1:$A$9,Data!$B$1` refers to. References to +/// other workbooks (`[1]Sheet!A1`) are not sheets of this one and are left out. +pub(crate) fn sheet_names_in(formula: &str) -> Vec { + let mut names = vec![]; + let chars: Vec = formula.chars().collect(); + let at = |i: usize| chars.get(i).copied(); + let mut i = 0; + while let Some(c) = at(i) { + let mut name = String::new(); + let start = i; + if c == '\'' { + i += 1; + while let Some(c) = at(i) { + if c == '\'' { + if at(i + 1) == Some('\'') { + name.push('\''); + i += 2; + continue; + } + break; + } + name.push(c); + i += 1; + } + i += 1; + } else { + while let Some(c) = at(i).filter(|c| !matches!(c, '!' | ',' | '(' | ')' | '\'' | ' ')) { + name.push(c); + i += 1; + } + } + if at(i) == Some('!') { + if !name.is_empty() && !name.starts_with('[') { + names.push(name); + } + i += 1; + } else if i == start { + i += 1; + } + } + names +} + +/// Whether a kept chart sheet can be written back: its chart's series must still refer to sheets +/// that exist under the same names (the chart is kept as it was, so a series on a deleted or +/// renamed sheet would be left pointing at nothing), and no worksheet may have taken its name. +fn chart_sheet_usable(wb: &Workbook, p: &Preserved, s: &PreservedSheet) -> bool { + let same = |a: &str, b: &str| a.chars().take(31).collect::().eq_ignore_ascii_case(&b.chars().take(31).collect::()); + if wb.sheets.iter().any(|w| same(&w.name, &s.name)) { + return false; + } + let Some(root) = p.rels.iter().find(|r| r.id == s.rel_id) else { return false }; + let parts = reachable(p, root); + let mut formulas = vec![]; + for q in p.parts.iter().filter(|q| parts.contains(&normalize(&q.name)) && q.content_type.contains("chart")) { + if let Ok(x) = xml::parse(&q.data) { + chart_formulas(&x, &mut formulas); + } + } + formulas.iter().flat_map(|f| sheet_names_in(f)).all(|n| wb.sheets.iter().any(|w| same(&w.name, &n))) +} + +/// Writes the workbook's kept parts and relationships. Chart sheets that can't be written back +/// (see [`chart_sheet_usable`]) are left out with the parts only they reach. +pub fn write_workbook(wb: &Workbook, wb_rels: &mut Rels, out: &mut Out) -> WorkbookKept { + let Some(p) = wb.preserved.as_deref() else { return WorkbookKept::default() }; + let (live, dropped): (Vec<&PreservedSheet>, Vec<&PreservedSheet>) = p.sheets.iter().partition(|s| chart_sheet_usable(wb, p, s)); + let root_of = |s: &PreservedSheet| p.rels.iter().find(|r| r.id == s.rel_id); + let dropped_ids: HashSet<&str> = dropped.iter().map(|s| s.rel_id.as_str()).collect(); + // Parts reached only by dropped chart sheets go with them. + let mut gone: HashSet = dropped.iter().filter_map(|s| root_of(s)).flat_map(|r| reachable(p, r)).collect(); + if !gone.is_empty() { + let others = p.rels.iter().filter(|r| !dropped_ids.contains(r.id.as_str())).flat_map(|r| reachable(p, r)); + for n in others { + gone.remove(&n); + } + } + let parts: Vec = p.parts.iter().filter(|q| !gone.contains(&normalize(&q.name))).cloned().collect(); + let rels: Vec = p.rels.iter().filter(|r| !dropped_ids.contains(r.id.as_str())).cloned().collect(); + let names = write_parts(out, &parts); + let ids = attach(wb_rels, &rels, &names); + for r in &p.package_rels { + if let Some(target) = rel_target(r, &names) { + out.kept.root.push((r.rel_type.clone(), target, r.external)); + } + } + WorkbookKept { + elements: p.elements.iter().filter(|e| e.slot == "externalReferences").filter_map(|e| remap(&e.xml, &ids)).collect(), + sheets: live + .iter() + .filter_map(|s| { + Some(KeptSheet { + before: s.before, + name: s.name.clone(), + visibility: s.visibility, + rid: ids.get(&s.rel_id)?.clone(), + sheet_id: s.sheet_id, + }) + }) + .collect(), + } +} + +/// The workbook's kept elements of `slot` that refer to no part (`fileVersion`, `ext`). +pub fn workbook_elements(wb: &Workbook, slot: &str) -> String { + let Some(p) = wb.preserved.as_deref() else { return String::new() }; + p.elements.iter().filter(|e| e.slot == slot).map(|e| e.xml.as_str()).collect() +} + +/// Adds the kept package-root relationships (custom properties…) to `_rels/.rels`. +pub fn write_package_rels(root: &mut Rels, out: &mut Out) { + for (ty, target, external) in std::mem::take(&mut out.kept.root) { + root.add_raw(&ty, &target, external); + } +}