Merge pull request #333 from vangel76/feat/xlsx-passthrough

XLSX: keep unmodelled parts on save (#330)

# Conflicts:
#	ROADMAP.md
#	crates/model/src/lib.rs
#	crates/model/src/sheet.rs
#	docs/file-format-parity.md
#	docs/gaps.md
This commit is contained in:
Brandon Thomas
2026-10-11 13:51:59 -04:00
17 changed files with 1538 additions and 17 deletions
+1
View File
@@ -133,6 +133,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
| 2026-10-11 | Legacy Excel 97-2003 `.xls` (BIFF8) import with formulas, number formats, merges, sizes and names (#121) |
| 2026-10-11 | XLSX pass-through: custom XML, external links, connections, query tables, printer settings, form/ActiveX controls and OLE objects are kept on save (#330, #324) |
| 2026-10-11 | Startup no longer panics on a system font egui can't parse: fonts are validated and skipped (#226) |
| 2026-10-11 | Ctrl+drag on the fill handle swaps copying and extending a series (#264) |
| 2026-10-11 | Odd-period bond functions ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD: Financial 55 / 55 (#275) |
+1
View File
@@ -704,6 +704,7 @@ fn move_sheet(s: &mut Session, p: &Json) -> Result<Json> {
let name = (2..).map(|k| format!("{base} ({k})")).find(|nm| cx.wb.sheet_index(nm).is_none()).unwrap_or(base.clone());
sh.name = name.chars().take(31).collect();
sh.tables.clear(); // table names must be unique
sh.preserved = None; // kept file content (form controls, OLE objects…) stays with the original
let at = to.min(n);
// The copy gets its own copies of the sheet's names, referring to the copy.
let copies: Vec<gridcraft_model::DefinedName> = cx
+2
View File
@@ -9,6 +9,7 @@ pub mod cell;
mod cellmap;
pub mod features;
pub mod password;
pub mod preserved;
pub mod sheet;
pub mod store;
pub mod style;
@@ -17,6 +18,7 @@ pub mod workbook;
pub use cell::{Cell, CellPicture, Formula};
pub use features::*;
pub use password::PasswordHash;
pub use preserved::{Preserved, PreservedPart, PreservedRel, PreservedXml};
pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, SheetViewMode, Visibility};
pub use store::CellStore;
pub use style::{
+70
View File
@@ -0,0 +1,70 @@
//! File content GridCraft does not model, carried through unchanged so that saving a workbook
//! never silently drops it (XLSX pass-through: custom XML, external links, connections, query
//! tables, form controls, OLE objects…).
//!
//! The data is opaque to everything except the file-format crate: the model only holds it, so a
//! sheet's kept content disappears with the sheet. It is not part of the JSON format.
use std::sync::Arc;
/// A relationship from an owning part (the package root, the workbook or a sheet, or a kept part)
/// to a kept part or to an external target.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct PreservedRel {
/// The id in the source file (`rId7`). Ids of an owner GridCraft writes are reassigned on save.
pub id: String,
/// The full relationship type URI.
pub rel_type: String,
/// The original part name of the target (no leading `/`), or the raw target of an external
/// relationship. External targets are never opened.
pub target: String,
pub external: bool,
}
/// A kept package part.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct PreservedPart {
/// Part name in the source file (no leading `/`).
pub name: String,
pub content_type: String,
pub data: Arc<[u8]>,
/// The part's own relationships, ids unchanged (the part's bytes refer to them).
pub rels: Vec<PreservedRel>,
}
/// A raw XML element of an owning part that refers to kept parts, re-emitted on save with its
/// relationship ids rewritten.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct PreservedXml {
/// Which slot of the owner it belongs in (`externalReferences`, `oleObjects`, `controls`).
pub slot: String,
/// The element, namespace declarations it needs included.
pub xml: String,
}
/// Unmodelled content kept for one owner (the workbook, which also holds package-root content,
/// or one sheet).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Preserved {
/// Relationships of the package root (`_rels/.rels`) to kept parts (workbook owner only).
pub package_rels: Vec<PreservedRel>,
/// Relationships of the owning workbook or sheet part to kept parts.
pub rels: Vec<PreservedRel>,
/// Every kept part reachable from `package_rels` and `rels`.
pub parts: Vec<PreservedPart>,
/// Owner XML elements that refer to kept parts.
pub elements: Vec<PreservedXml>,
/// The sheet's legacy VML drawing when kept elements need its shapes (form controls, OLE
/// objects). Its own relationships point into `parts`.
pub vml: Option<PreservedPart>,
}
impl Preserved {
pub fn is_empty(&self) -> bool {
self.package_rels.is_empty() && self.rels.is_empty() && self.parts.is_empty() && self.elements.is_empty() && self.vml.is_none()
}
/// Total bytes held.
pub fn byte_len(&self) -> usize {
self.parts.iter().chain(self.vml.iter()).fold(0usize, |n, p| n.saturating_add(p.data.len()))
}
}
+5
View File
@@ -104,6 +104,10 @@ pub struct Sheet {
/// Normal, Page Layout or Page Break Preview.
#[serde(default)]
pub view: SheetViewMode,
/// Unmodelled file content of this sheet kept for saving (form controls, OLE objects, query
/// tables). Goes away with the sheet; not in JSON.
#[serde(skip)]
pub preserved: Option<Arc<crate::preserved::Preserved>>,
}
/// Default width of a column (points) and height of a row (points) for the default font.
@@ -149,6 +153,7 @@ impl Sheet {
view_active: CellRef::default(),
view_top_left: CellRef::default(),
view: SheetViewMode::Normal,
preserved: None,
}
}
+4
View File
@@ -71,6 +71,9 @@ pub struct Workbook {
pub custom_lists: Vec<Vec<String>>,
/// Named cell styles (Cell Styles gallery): name → style.
pub cell_styles: Vec<(String, crate::style::Style)>,
/// Unmodelled file content kept for saving (package root and workbook level). Not in JSON.
#[serde(skip)]
pub preserved: Option<Arc<crate::preserved::Preserved>>,
}
impl Default for Workbook {
@@ -94,6 +97,7 @@ impl Workbook {
protected_structure: false,
custom_lists: vec![],
cell_styles: vec![],
preserved: None,
}
}
pub fn sheet(&self, i: usize) -> Option<&Sheet> {
+1
View File
@@ -21,6 +21,7 @@ mod feature_bags;
mod fmla;
mod ods;
mod package;
mod passthrough;
mod pivot;
mod read;
mod richdata;
+924
View File
@@ -0,0 +1,924 @@
//! Pass-through of package content GridCraft does not model, so that open → edit → save keeps it.
//!
//! On read, the raw bytes of unmodelled parts are kept with their content type and their
//! relationship graph, per owner: the package root (custom document properties, ribbon
//! customisation, add-in task panes), the workbook (custom XML, links to external workbooks with
//! their `<externalReferences>`, data connections) and each sheet (query tables, form controls,
//! ActiveX controls, OLE objects, the sheet background and printer settings, with the sheet's
//! `<picture>`, `<oleObjects>` and `<controls>` XML, the `r:id` of `<pageSetup>` and the legacy
//! VML shapes those controls need).
//!
//! On write, kept parts get fresh part names where needed and new relationship ids; the `r:id`s in
//! kept XML are rewritten to match. Kept content is conservative:
//! - Anything whose relationship graph reaches a part GridCraft reads and rewrites (worksheets,
//! drawings, charts, tables, PivotTables, comments, slicers…) is not kept: it would point at
//! content that no longer exists in the same form.
//! - External relationship targets (the path of a linked workbook) are kept as text and never
//! opened, followed or refreshed.
//! - Sheet content lives on the sheet: deleting the sheet drops it, Copy Sheet doesn't copy it.
//! Cell references inside kept parts (a control's linked cell, a query table's range) are kept
//! as they were, not adjusted to inserted or deleted rows.
//! - Strict Open XML files are not passed through (their namespaces differ from what we write).
//! - Sizes are bounded: per part and per workbook.
use std::borrow::Cow;
use std::collections::{HashMap, HashSet, VecDeque};
use std::fmt::Write as _;
use std::sync::Arc;
use gridcraft_model::{Preserved, PreservedPart, PreservedRel, PreservedXml, Sheet, Workbook};
use quick_xml::events::attributes::Attribute;
use quick_xml::events::{BytesStart, Event};
use quick_xml::name::QName;
use quick_xml::{Reader, Writer};
use crate::IoError;
use crate::package::{self, normalize};
use crate::read::Ctx;
use crate::write::{NS_REL, Out, Rels, XML_DECL};
use crate::xml::{self, esc_attr};
/// Most bytes kept for one workbook.
pub const MAX_KEPT_TOTAL: usize = 256 * 1024 * 1024;
/// Largest single kept part.
pub const MAX_KEPT_PART: u64 = 64 * 1024 * 1024;
/// Most parts kept for one workbook.
const MAX_KEPT_PARTS: usize = 4096;
/// Most relationships read from one rels part.
const MAX_RELS: usize = 10_000;
/// Largest relationships, content-types or VML part read here.
const MAX_META: u64 = 16 * 1024 * 1024;
/// Longest chain of relationships followed from an owner.
const MAX_CHAIN: usize = 16;
/// Largest kept XML fragment of a sheet.
const MAX_FRAGMENT: usize = 4 * 1024 * 1024;
/// Deepest element nesting accepted in a kept fragment.
const MAX_FRAGMENT_DEPTH: usize = 256;
#[cfg(test)]
thread_local! {
/// A smaller kept-bytes allowance for tests on this thread.
pub(crate) static KEPT_TOTAL_FOR_TEST: std::cell::Cell<usize> = const { std::cell::Cell::new(MAX_KEPT_TOTAL) };
}
fn kept_total() -> usize {
#[cfg(test)]
{
KEPT_TOTAL_FOR_TEST.with(std::cell::Cell::get)
}
#[cfg(not(test))]
{
MAX_KEPT_TOTAL
}
}
const NS_REL_STRICT: &str = "http://purl.oclc.org/ooxml/officeDocument/relationships";
const CT_VML: &str = "application/vnd.openxmlformats-officedocument.vmlDrawing";
/// Relationship kinds (last segment of the type) kept from the package root.
const PACKAGE_ROOTS: &[&str] = &["custom-properties", "extensibility", "webextensiontaskpanes"];
/// Relationship kinds kept from the workbook without a workbook element.
const WORKBOOK_ROOTS: &[&str] = &["customXml", "connections"];
/// Relationship kinds kept from a sheet without a sheet element.
const SHEET_ROOTS: &[&str] = &["queryTable"];
/// Sheet relationship kinds that mean the sheet XML may hold elements worth keeping.
const SHEET_ELEMENT_KINDS: &[&str] = &["oleObject", "ctrlProp", "control", "image", "package", "printerSettings"];
/// Kept sheet elements, in schema order (all after `<legacyDrawing>`, before `<tableParts>`).
const SHEET_SLOTS: &[&str] = &["picture", "oleObjects", "controls"];
/// Relationship kinds GridCraft reads and rewrites, or that depend on content it rewrites:
/// kept content whose graph reaches one of them is dropped.
const REWRITTEN: &[&str] = &[
"officedocument",
"core-properties",
"extended-properties",
"worksheet",
"chartsheet",
"dialogsheet",
"xlmacrosheet",
"drawing",
"chart",
"chartex",
"chartstyle",
"chartcolorstyle",
"chartusershapes",
"table",
"pivottable",
"pivotcachedefinition",
"pivotcacherecords",
"comments",
"vmldrawing",
"threadedcomment",
"person",
"sharedstrings",
"styles",
"theme",
"sheetmetadata",
"calcchain",
"vbaproject",
"rdrichvalue",
"rdrichvaluestructure",
"rdrichvaluetypes",
"rdrichvaluewebimage",
"rdarray",
"rdsupportingpropertybag",
"rdsupportingpropertybagstructure",
"richvaluerel",
"slicer",
"slicercache",
"timeline",
"timelinecache",
"powerpivotdata",
];
/// Part-name prefixes (lower case) GridCraft's writer never produces: kept parts there keep their
/// original names. Anything else is renamed so it can't collide with written parts.
const OWN_DIRS: &[&str] = &[
"customxml/",
"docprops/custom.xml",
"customui/",
"xl/externallinks/",
"xl/connections.xml",
"xl/querytables/",
"xl/ctrlprops/",
"xl/embeddings/",
"xl/activex/",
"xl/webextensions/",
"xl/printersettings/",
];
fn kind(rel_type: &str) -> &str {
rel_type.rsplit('/').next().unwrap_or("")
}
fn is_kind(r: &PreservedRel, kinds: &[&str]) -> bool {
let k = kind(&r.rel_type);
kinds.iter().any(|x| x.eq_ignore_ascii_case(k))
}
fn rewritten(r: &PreservedRel) -> bool {
!r.external && REWRITTEN.contains(&kind(&r.rel_type).to_ascii_lowercase().as_str())
}
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<Vec<PreservedRel>, 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
/// Collects kept content while a package is read.
pub struct KeptReader {
on: bool,
strict: bool,
defaults: HashMap<String, String>,
overrides: HashMap<String, String>,
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<String> {
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<String>) -> Result<Vec<PreservedPart>, String> {
if root.external {
return Ok(vec![]);
}
if rewritten(root) {
return Err(format!("it refers to {}", kind(&root.rel_type)));
}
let mut parts = vec![];
let mut bytes = 0usize;
let mut local: HashSet<String> = 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 rewritten(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<String>) -> Result<(), String> {
let mut new_parts: Vec<PreservedPart> = vec![];
let mut scratch = seen.clone();
for r in roots {
let parts = self.closure(cx, r, &scratch)?;
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) -> Option<Arc<Preserved>> {
if !self.on {
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));
}
}
// Links to external workbooks: `[1]` in formulas is the position in <externalReferences>,
// 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<Vec<PreservedRel>> =
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!("<externalReferences xmlns:r=\"{NS_REL}\">");
for id in &ids {
let _ = write!(x, "<externalReference r:id=\"{}\"/>", esc_attr(id));
}
x.push_str("</externalReferences>");
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}")),
}
}
(!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<Arc<Preserved>> {
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)));
}
}
if rels.iter().any(|r| is_kind(r, SHEET_ELEMENT_KINDS)) {
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<String>) {
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 <pageSetup r:id>.
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}")),
}
}
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<Vec<PreservedRel>> = 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<String>,
) -> Result<(PreservedPart, Vec<PreservedPart>), 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<PreservedRel> = 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<String>,
/// The relationship id on `<pageSetup>` (printer settings).
page_setup: Option<String>,
}
/// Finds the sheet's `<picture>`, `<oleObjects>`, `<controls>` (also inside
/// `<mc:AlternateContent>`) and the `<legacyDrawing>` id. `None` when the XML is damaged.
fn scan_sheet(bytes: &[u8]) -> Option<SheetScan> {
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, 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),
_ => {}
}
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"pageSetup" {
let id = rel_id_attr(&e);
if local == b"pageSetup" {
out.page_setup = id;
} else {
out.legacy_drawing = 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<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())
}
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 `<v:shape …>…</v:shape>` (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("<v:shape")).map(|i| i + from) {
let after = i + "<v:shape".len();
let is_shape = vml.get(after..).and_then(|s| s.chars().next()).is_some_and(|c| c.is_whitespace() || c == '>' || 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("</v:shape>")) {
Some(j) => tag_end + j + "</v:shape>".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<u64> {
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()
}
/// 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("</xml>") 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("<v:shapetype")
&& let Some(j) = ours.get(i..).and_then(|s| s.find("</v:shapetype>"))
{
add.push_str(ours.get(i..i + j + "</v:shapetype>".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("<o:idmap")
&& let Some(d) = head.get(i..).and_then(|s| s.find("data=\"")).map(|d| i + d + "data=\"".len())
&& let Some(e) = head.get(d..).and_then(|s| s.find('"')).map(|e| d + e)
{
head.insert_str(e, &format!(",{block}"));
}
format!("{head}{add}{}", kept.get(close..).unwrap_or(""))
}
/// Rewrites relationship-id attributes (`r:id`, `r:embed`…: any attribute in the relationships
/// namespace) of an XML fragment through `map`. `None` when the fragment is damaged or `map` has
/// no id for one of them.
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 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<Vec<u8>>, 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())
{
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<String>,
n: u32,
/// Package-root relationships (type, target, external), added when `_rels/.rels` is written.
root: Vec<(String, String, bool)>,
}
impl KeptOut {
/// 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<String, String>;
fn rel_target(r: &PreservedRel, names: &NameMap) -> Option<String> {
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}<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">");
for r in rels {
let Some(target) = rel_target(r, names) else { continue };
let _ = write!(
s,
"<Relationship Id=\"{}\" Type=\"{}\" Target=\"{}\"{}/>",
esc_attr(&r.id),
esc_attr(&r.rel_type),
esc_attr(&target),
if r.external { " TargetMode=\"External\"" } else { "" }
);
}
s.push_str("</Relationships>");
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<String, String> {
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<String, String>) -> Option<String> {
map_rel_ids(x, &mut |id| ids.get(id).cloned())
}
/// Writes the workbook's kept parts and relationships; returns the workbook XML elements that
/// go right after `<sheets>` (`<externalReferences>`).
pub fn write_workbook(wb: &Workbook, wb_rels: &mut Rels, out: &mut Out) -> String {
let Some(p) = wb.preserved.as_deref() else { return String::new() };
let names = write_parts(out, &p.parts);
let ids = attach(wb_rels, &p.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));
}
}
p.elements.iter().filter(|e| e.slot == "externalReferences").filter_map(|e| remap(&e.xml, &ids)).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<String, String>,
}
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<String> {
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 `<pageSetup r:id>` (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
}
}
+3 -3
View File
@@ -98,6 +98,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
return Err(IoError::Format(format!("unexpected root element <{}> in workbook part", wb_xml.name)));
}
let wb_rels = cx.pkg.rels(&wb_part)?;
let mut kept = crate::passthrough::KeptReader::new(&mut cx);
cx.cell_pictures = match crate::richdata::read(&mut cx, &wb_rels) {
Ok(pictures) => pictures,
Err(error) => {
@@ -218,6 +219,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
Err(IoError::TooLarge(m)) => return Err(IoError::TooLarge(m)),
Err(e) => cx.warn(format!("PivotTables on sheet \"{name}\" could not be read: {e}")),
}
sheet.preserved = kept.sheet(&mut cx, &rel.target, &name);
sheet.visibility = match se.attr("state") {
Some("hidden") => Visibility::Hidden,
Some("veryHidden") => Visibility::VeryHidden,
@@ -314,9 +316,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
if wb_rels.iter().any(|r| r.kind == "vbaProject") {
cx.warn("macros (VBA project) are not supported and were dropped");
}
if wb_rels.iter().any(|r| r.kind == "externalLink") {
cx.warn("links to external workbooks are not supported");
}
wb.preserved = kept.workbook(&mut cx, &wb_part, &wb_xml);
// Calls of LAMBDA names are spelled like the names (the parser upper-cases them).
wb.apply_name_call_case();
wb.styles = cx.styles;
-1
View File
@@ -182,7 +182,6 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<Sheet, IoE
}
"extLst" => read_ext(cx, e, &mut sheet),
"protectedRanges" => read_protected_ranges(e, &mut sheet),
"oleObjects" | "controls" => cx.warn(format!("sheet \"{name}\": embedded objects and form controls are not supported")),
_ => {}
}
}
+13 -2
View File
@@ -420,6 +420,8 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> (
}
s.push_str("/>");
}
// Unmodelled content kept from the source file (printer settings, controls, OLE objects…).
let kept = crate::passthrough::SheetKept::write(sheet, &mut rels, out);
let m = |i: usize| num(p.margins.get(i).copied().unwrap_or(0.75) as f64);
let _ =
write!(s, "<pageMargins left=\"{}\" right=\"{}\" top=\"{}\" bottom=\"{}\" header=\"{}\" footer=\"{}\"/>", m(0), m(1), m(2), m(3), m(4), m(5));
@@ -435,7 +437,11 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> (
if let Some((w, h)) = p.fit_to {
let _ = write!(s, " fitToWidth=\"{w}\" fitToHeight=\"{h}\"");
}
let _ = write!(s, " orientation=\"{}\"/>", if p.orientation == Orientation::Landscape { "landscape" } else { "portrait" });
let _ = write!(s, " orientation=\"{}\"", if p.orientation == Orientation::Landscape { "landscape" } else { "portrait" });
if let Some(id) = kept.page_setup_id() {
let _ = write!(s, " r:id=\"{id}\"");
}
s.push_str("/>");
if !p.header.is_empty() || !p.footer.is_empty() {
s.push_str("<headerFooter>");
if !p.header.is_empty() {
@@ -519,11 +525,16 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> (
rels.add_uri(REL_THREADED_COMMENT, &format!("../threadedComments/threadedComment{n}.xml"));
}
out.default_type("vml", "application/vnd.openxmlformats-officedocument.vmlDrawing");
out.part(&format!("xl/drawings/vmlDrawing{n}.vml"), None, vml_xml(sheet, n).into_bytes());
let vml_name = format!("xl/drawings/vmlDrawing{n}.vml");
let vml = kept.notes_vml(vml_xml(sheet, n), &vml_name, out);
out.part(&vml_name, None, vml.into_bytes());
rels.add("comments", &format!("../comments{n}.xml"));
let rid = rels.add("vmlDrawing", &format!("../drawings/vmlDrawing{n}.vml"));
let _ = write!(s, "<legacyDrawing r:id=\"{rid}\"/>");
} else if let Some(rid) = kept.lone_vml(&mut rels, out) {
let _ = write!(s, "<legacyDrawing r:id=\"{rid}\"/>");
}
s.push_str(&kept.elements());
// Tables.
if !tables.is_empty() {
let mut parts = String::new();
+1
View File
@@ -5,6 +5,7 @@ mod csv_tests;
mod fixtures;
mod malformed;
mod ods_tests;
mod passthrough_tests;
mod pivot_tests;
mod richdata_tests;
mod roundtrip;
+485
View File
@@ -0,0 +1,485 @@
//! Pass-through of unmodelled package content (custom XML, external links, connections, query
//! tables, printer settings, form controls, OLE objects). Packages are built in code.
use std::collections::HashMap;
use std::io::Read;
use std::sync::Arc;
use gridcraft_model::Sheet;
use super::make_zip;
use crate::package::{normalize, resolve, split_dir};
use crate::xml;
use crate::{read_xlsx, write_xlsx};
const NS_R: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
const NS_MAIN: &str = "http://schemas.openxmlformats.org/spreadsheetml/2006/main";
const PRINTER: &[u8] = &[0, 1, 2, 3, 250, 251, 252, 253, 9, 9, 9];
const OLE: &[u8] = b"\xD0\xCF\x11\xE0 not really a compound file";
const EMF: &[u8] = b"\x01\x00\x00\x00 preview";
const CUSTOM_XML: &str = r#"<?xml version="1.0"?><root xmlns="urn:example:data"><v>42</v></root>"#;
const CUSTOM_PROPS: &str = r#"<?xml version="1.0"?><Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/custom-properties"/>"#;
fn rels(items: &[(&str, &str, &str)]) -> String {
let mut s = String::from(r#"<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">"#);
for (id, ty, target) in items {
let ty = if ty.starts_with("http") { ty.to_string() } else { format!("{NS_R}/{ty}") };
let ext = if target.contains("://") { r#" TargetMode="External""# } else { "" };
s.push_str(&format!(r#"<Relationship Id="{id}" Type="{ty}" Target="{target}"{ext}/>"#));
}
s.push_str("</Relationships>");
s
}
const CONTENT_TYPES: &str = r#"<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="bin" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.printerSettings"/><Default Extension="emf" ContentType="image/x-emf"/><Default Extension="vml" ContentType="application/vnd.openxmlformats-officedocument.vmlDrawing"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/worksheets/sheet2.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/embeddings/oleObject1.bin" ContentType="application/vnd.openxmlformats-officedocument.oleObject"/><Override PartName="/customXml/itemProps1.xml" ContentType="application/vnd.openxmlformats-officedocument.customXmlProperties+xml"/><Override PartName="/xl/externalLinks/externalLink1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.externalLink+xml"/><Override PartName="/xl/connections.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.connections+xml"/><Override PartName="/xl/queryTables/queryTable1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.queryTable+xml"/><Override PartName="/xl/ctrlProps/ctrlProp1.xml" ContentType="application/vnd.ms-excel.controlproperties+xml"/><Override PartName="/docProps/custom.xml" ContentType="application/vnd.openxmlformats-officedocument.custom-properties+xml"/><Override PartName="/xl/comments1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml"/></Types>"#;
const EXTERNAL_LINK: &str = r#"<?xml version="1.0"?><externalLink xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><externalBook r:id="rId1"><sheetNames><sheetName val="Sheet1"/></sheetNames><sheetDataSet><sheetData sheetId="0"><row r="1"><cell r="A1"><v>7</v></cell></row></sheetData></sheetDataSet></externalBook></externalLink>"#;
const CONNECTIONS: &str = r#"<?xml version="1.0"?><connections xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><connection id="1" name="Query" type="5" refreshedVersion="6"><dbPr connection="Provider=Example" command="SELECT 1"/></connection></connections>"#;
const QUERY_TABLE: &str = r#"<?xml version="1.0"?><queryTable xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" name="ExternalData_1" connectionId="1" autoFormatId="16" applyNumberFormats="0" applyBorderFormats="0" applyFontFormats="0" applyPatternFormats="0" applyAlignmentFormats="0" applyWidthHeightFormats="0"/>"#;
const CTRL_PROP: &str = r#"<?xml version="1.0"?><formControlPr xmlns="http://schemas.microsoft.com/office/spreadsheetml/2009/9/main" objectType="CheckBox" fmlaLink="$C$1" lockText="1"/>"#;
const COMMENTS: &str = r#"<?xml version="1.0"?><comments xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><authors><author>A</author></authors><commentList><comment ref="A1" authorId="0"><text><t>note</t></text></comment></commentList></comments>"#;
const VML: &str = r##"<xml xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel"><o:shapelayout v:ext="edit"><o:idmap v:ext="edit" data="1"/></o:shapelayout><v:shapetype id="_x0000_t75" coordsize="21600,21600" o:spt="75"/><v:shape id="_x0000_s1025" type="#_x0000_t75" style="position:absolute"><v:imagedata o:relid="rId1" o:title=""/><x:ClientData ObjectType="Pict"><x:Anchor>1, 0, 1, 0, 3, 0, 5, 0</x:Anchor><x:CF>Pict</x:CF></x:ClientData></v:shape><v:shape id="_x0000_s1026" type="#_x0000_t201" style="position:absolute"><x:ClientData ObjectType="Checkbox"><x:Anchor>4, 0, 1, 0, 6, 0, 2, 0</x:Anchor><x:FmlaLink>$C$1</x:FmlaLink></x:ClientData></v:shape><v:shape id="_x0000_s1027" type="#_x0000_t202" style="position:absolute"><v:textbox><div>old note<br></div></v:textbox><x:ClientData ObjectType="Note"><x:Row>0</x:Row><x:Column>0</x:Column></x:ClientData></v:shape></xml>"##;
/// A workbook with every kind of kept content: package root custom properties, custom XML, an
/// external link (with formulas using it), a connection, and on sheet "Data" a query table,
/// printer settings, an OLE object (in `mc:AlternateContent`), a form control, a note and the VML
/// drawing all of them share.
fn rich_package() -> Vec<u8> {
rich_package_with(CONTENT_TYPES, false)
}
/// [`rich_package`] with other content types; `twin` makes sheet "Other" a copy of "Data" that
/// shares its VML drawing and embedded parts.
fn rich_package_with(content_types: &str, twin: bool) -> Vec<u8> {
let workbook = format!(
r#"<?xml version="1.0"?><workbook xmlns="{NS_MAIN}" xmlns:r="{NS_R}"><sheets><sheet name="Data" sheetId="1" r:id="rId1"/><sheet name="Other" sheetId="2" r:id="rId2"/></sheets><externalReferences><externalReference r:id="rId3"/></externalReferences><definedNames><definedName name="ExternalData_1" localSheetId="0" hidden="1">Data!$E$1:$E$2</definedName></definedNames></workbook>"#
);
let sheet1 = format!(
r#"<?xml version="1.0"?><worksheet xmlns="{NS_MAIN}" xmlns:r="{NS_R}" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:x14="http://schemas.microsoft.com/office/spreadsheetml/2009/9/main" xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" mc:Ignorable="x14"><sheetData><row r="1"><c r="A1"><f>[1]Sheet1!$A$1</f><v>7</v></c><c r="B1"><f>SUM([1]Sheet1!A1:A3)*2</f><v>14</v></c></row></sheetData><pageMargins left="0.7" right="0.7" top="0.75" bottom="0.75" header="0.3" footer="0.3"/><pageSetup orientation="landscape" r:id="rId1"/><legacyDrawing r:id="rId2"/><mc:AlternateContent><mc:Choice Requires="x14"><oleObjects><mc:AlternateContent><mc:Choice Requires="x14"><oleObject progId="Package" shapeId="1025" r:id="rId3"><objectPr defaultSize="0" r:id="rId4"><anchor moveWithCells="1"><from><xdr:col>1</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>1</xdr:row><xdr:rowOff>0</xdr:rowOff></from><to><xdr:col>3</xdr:col><xdr:colOff>0</xdr:colOff><xdr:row>5</xdr:row><xdr:rowOff>0</xdr:rowOff></to></anchor></objectPr></oleObject></mc:Choice><mc:Fallback><oleObject progId="Package" shapeId="1025" r:id="rId3"/></mc:Fallback></mc:AlternateContent></oleObjects></mc:Choice></mc:AlternateContent><controls><control shapeId="1026" r:id="rId5" name="Check Box 1"/></controls></worksheet>"#
);
let sheet2 = if twin {
sheet1.clone()
} else {
format!(r#"<?xml version="1.0"?><worksheet xmlns="{NS_MAIN}"><sheetData><row r="1"><c r="A1"><v>1</v></c></row></sheetData></worksheet>"#)
};
let root_rels = rels(&[("rId1", "officeDocument", "xl/workbook.xml"), ("rId2", "custom-properties", "docProps/custom.xml")]);
let wb_rels = rels(&[
("rId1", "worksheet", "worksheets/sheet1.xml"),
("rId2", "worksheet", "worksheets/sheet2.xml"),
("rId3", "externalLink", "externalLinks/externalLink1.xml"),
("rId4", "customXml", "../customXml/item1.xml"),
("rId5", "connections", "connections.xml"),
]);
let sheet1_rels = rels(&[
("rId1", "printerSettings", "../printerSettings/printerSettings1.bin"),
("rId2", "vmlDrawing", "../drawings/vmlDrawing1.vml"),
("rId3", "oleObject", "../embeddings/oleObject1.bin"),
("rId4", "image", "../media/image1.emf"),
("rId5", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/ctrlProp", "../ctrlProps/ctrlProp1.xml"),
("rId6", "queryTable", "../queryTables/queryTable1.xml"),
("rId7", "comments", "../comments1.xml"),
]);
let custom_rels = rels(&[("rId1", "customXmlProps", "itemProps1.xml")]);
let link_rels = rels(&[("rId1", "externalLinkPath", "file:///C:/never/opened/Book2.xlsx")]);
let vml_rels = rels(&[("rId1", "image", "../media/image1.emf")]);
let item_props = r#"<?xml version="1.0"?><ds:datastoreItem ds:itemID="{11111111-2222-3333-4444-555555555555}" xmlns:ds="http://schemas.openxmlformats.org/officeDocument/2006/customXml"/>"#;
let mut files: Vec<(&str, &[u8])> = vec![
("[Content_Types].xml", content_types.as_bytes()),
("_rels/.rels", root_rels.as_bytes()),
("docProps/custom.xml", CUSTOM_PROPS.as_bytes()),
("xl/workbook.xml", workbook.as_bytes()),
("xl/_rels/workbook.xml.rels", wb_rels.as_bytes()),
("xl/worksheets/sheet1.xml", sheet1.as_bytes()),
("xl/worksheets/sheet2.xml", sheet2.as_bytes()),
("xl/worksheets/_rels/sheet1.xml.rels", sheet1_rels.as_bytes()),
("xl/printerSettings/printerSettings1.bin", PRINTER),
("xl/drawings/vmlDrawing1.vml", VML.as_bytes()),
("xl/drawings/_rels/vmlDrawing1.vml.rels", vml_rels.as_bytes()),
("xl/embeddings/oleObject1.bin", OLE),
("xl/media/image1.emf", EMF),
("xl/ctrlProps/ctrlProp1.xml", CTRL_PROP.as_bytes()),
("xl/queryTables/queryTable1.xml", QUERY_TABLE.as_bytes()),
("xl/comments1.xml", COMMENTS.as_bytes()),
("customXml/item1.xml", CUSTOM_XML.as_bytes()),
("customXml/_rels/item1.xml.rels", custom_rels.as_bytes()),
("customXml/itemProps1.xml", item_props.as_bytes()),
("xl/externalLinks/externalLink1.xml", EXTERNAL_LINK.as_bytes()),
("xl/externalLinks/_rels/externalLink1.xml.rels", link_rels.as_bytes()),
("xl/connections.xml", CONNECTIONS.as_bytes()),
];
if twin {
files.push(("xl/worksheets/_rels/sheet2.xml.rels", sheet1_rels.as_bytes()));
}
make_zip(&files)
}
/// An unpacked package: parts by name, content types, resolved relationships.
struct Unpacked {
parts: HashMap<String, Vec<u8>>,
}
/// (id, type, resolved target, external)
type RelList = Vec<(String, String, String, bool)>;
impl Unpacked {
fn new(bytes: &[u8]) -> Unpacked {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut parts = HashMap::new();
for i in 0..z.len() {
let mut f = z.by_index(i).unwrap();
let mut v = vec![];
f.read_to_end(&mut v).unwrap();
parts.insert(f.name().to_string(), v);
}
Unpacked { parts }
}
fn text(&self, name: &str) -> String {
String::from_utf8(self.parts.get(name).unwrap_or_else(|| panic!("no part {name}")).clone()).unwrap()
}
fn rels(&self, part: &str) -> RelList {
let (dir, file) = split_dir(part);
let name = if dir.is_empty() { format!("_rels/{file}.rels") } else { format!("{dir}/_rels/{file}.rels") };
let Some(b) = self.parts.get(&name) else { return vec![] };
let root = xml::parse(b).unwrap();
root.kids("Relationship")
.map(|r| {
let ext = r.attr("TargetMode") == Some("External");
let t = r.attr("Target").unwrap();
(r.attr("Id").unwrap().into(), r.attr("Type").unwrap().into(), if ext { t.into() } else { resolve(dir, t) }, ext)
})
.collect()
}
fn target(&self, part: &str, id: &str) -> (String, String) {
let r = self.rels(part).into_iter().find(|r| r.0 == id).unwrap_or_else(|| panic!("{part}: no relationship {id}"));
(r.1, r.2)
}
/// Every part has a content type, every internal relationship target exists, rel ids are
/// unique per part.
fn assert_valid(&self) {
let types = xml::parse(self.parts.get("[Content_Types].xml").unwrap()).unwrap();
let defaults: Vec<String> = types.kids("Default").map(|d| d.attr("Extension").unwrap().to_ascii_lowercase()).collect();
let overrides: Vec<String> = types.kids("Override").map(|d| normalize(d.attr("PartName").unwrap())).collect();
let mut seen_override = std::collections::HashSet::new();
for o in &overrides {
assert!(seen_override.insert(o.clone()), "duplicate override {o}");
assert!(self.parts.keys().any(|p| normalize(p) == *o), "override for a missing part {o}");
}
for name in self.parts.keys().filter(|n| *n != "[Content_Types].xml") {
let ext = name.rsplit('.').next().unwrap().to_ascii_lowercase();
assert!(overrides.contains(&normalize(name)) || defaults.contains(&ext), "{name} has no content type");
if name.ends_with(".rels") {
let source = name.replace("_rels/", "").trim_end_matches(".rels").to_string();
let rels = self.rels(&source);
let mut ids = std::collections::HashSet::new();
for (id, _, target, ext) in rels {
assert!(ids.insert(id.clone()), "{name}: duplicate id {id}");
if !ext {
assert!(self.parts.keys().any(|p| normalize(p) == normalize(&target)), "{name}: {id} → missing {target}");
}
}
}
}
}
/// The part holding exactly `data`.
fn find(&self, data: &[u8]) -> Vec<&String> {
self.parts.iter().filter(|(_, v)| v.as_slice() == data).map(|(k, _)| k).collect()
}
}
/// The value of attribute `attr` of the first `<tag …>` in `x` (`r:id` of `<pageSetup`).
fn attr_of(x: &str, tag: &str, attr: &str) -> String {
let i = x.find(&format!("<{tag} ")).unwrap_or_else(|| panic!("no <{tag}> in {x}"));
let head = &x[i..i + x[i..].find('>').unwrap()];
let j = head.find(&format!(" {attr}=\"")).unwrap_or_else(|| panic!("no {attr} in {head}")) + attr.len() + 3;
head[j..j + head[j..].find('"').unwrap()].to_string()
}
fn sheet_part(u: &Unpacked, sheet: &str) -> String {
let wb = u.text("xl/workbook.xml");
let i = wb.find(&format!("<sheet name=\"{sheet}\"")).unwrap();
let id = attr_of(&wb[i..], "sheet", "r:id");
u.target("xl/workbook.xml", &id).1
}
#[test]
fn unmodelled_parts_survive_a_round_trip() {
let (wb, rep) = read_xlsx(&rich_package()).unwrap();
assert!(wb.preserved.is_some(), "{:?}", rep.warnings);
let data = wb.sheet(0).unwrap();
assert!(data.preserved.is_some(), "{:?}", rep.warnings);
assert!(!rep.warnings.iter().any(|w| w.contains("dropped")), "{:?}", rep.warnings);
assert_eq!(data.comments.len(), 1, "the note is modelled");
let out = write_xlsx(&wb).unwrap();
let u = Unpacked::new(&out);
u.assert_valid();
// Every kept part is in the output byte for byte.
for d in [
PRINTER,
OLE,
EMF,
CUSTOM_XML.as_bytes(),
CUSTOM_PROPS.as_bytes(),
EXTERNAL_LINK.as_bytes(),
CONNECTIONS.as_bytes(),
QUERY_TABLE.as_bytes(),
CTRL_PROP.as_bytes(),
] {
assert_eq!(u.find(d).len(), 1, "kept part missing or duplicated: {}", String::from_utf8_lossy(d));
}
// Package root: custom properties.
assert!(u.rels("").iter().any(|r| r.1.ends_with("/custom-properties") && u.parts.get(&r.2).is_some_and(|b| b == CUSTOM_PROPS.as_bytes())));
// Workbook: external link in <externalReferences>, its path kept as an external target.
let wbx = u.text("xl/workbook.xml");
let link_id = attr_of(&wbx, "externalReference", "r:id");
let (ty, link) = u.target("xl/workbook.xml", &link_id);
assert!(ty.ends_with("/externalLink"));
assert_eq!(u.parts.get(&link).unwrap(), EXTERNAL_LINK.as_bytes());
let path = u.rels(&link);
assert_eq!(path.len(), 1);
assert_eq!((path[0].0.as_str(), path[0].2.as_str(), path[0].3), ("rId1", "file:///C:/never/opened/Book2.xlsx", true));
assert!(wbx.find("</sheets>").unwrap() < wbx.find("<externalReferences").unwrap());
assert!(wbx.find("<externalReferences").unwrap() < wbx.find("<definedNames").unwrap());
// The custom XML item keeps its properties part.
let item = u.find(CUSTOM_XML.as_bytes())[0].clone();
assert!(u.rels(&item).iter().any(|r| r.1.ends_with("/customXmlProps")));
// Formulas using the link are written as they were.
let sheet = sheet_part(&u, "Data");
let sx = u.text(&sheet);
assert!(sx.contains("<f>[1]Sheet1!$A$1</f>"), "{sx}");
assert!(sx.contains("<f>SUM([1]Sheet1!A1:A3)*2</f>"), "{sx}");
// Printer settings (#324).
let ps = attr_of(&sx, "pageSetup", "r:id");
let (ty, target) = u.target(&sheet, &ps);
assert!(ty.ends_with("/printerSettings"));
assert_eq!(u.parts.get(&target).unwrap(), PRINTER);
// The OLE object and its preview image, re-linked.
let ole = attr_of(&sx, "oleObject", "r:id");
assert_eq!(u.parts.get(&u.target(&sheet, &ole).1).unwrap(), OLE);
let preview = attr_of(&sx, "objectPr", "r:id");
assert_eq!(u.parts.get(&u.target(&sheet, &preview).1).unwrap(), EMF);
// Namespace declarations travel with the fragment.
assert_eq!(attr_of(&sx, "mc:AlternateContent", "xmlns:mc"), "http://schemas.openxmlformats.org/markup-compatibility/2006");
assert_eq!(attr_of(&sx, "mc:AlternateContent", "xmlns:x14"), "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main");
// The form control.
let ctrl = attr_of(&sx, "control", "r:id");
assert_eq!(u.parts.get(&u.target(&sheet, &ctrl).1).unwrap(), CTRL_PROP.as_bytes());
// Schema order: pageSetup < legacyDrawing < oleObjects < controls.
let pos = |t: &str| sx.find(t).unwrap_or_else(|| panic!("no {t}"));
assert!(pos("<pageSetup") < pos("<legacyDrawing") && pos("<legacyDrawing") < pos("<oleObjects") && pos("<oleObjects") < pos("<controls"));
// Query table: a sheet relationship only.
assert!(u.rels(&sheet).iter().any(|r| r.1.ends_with("/queryTable") && u.parts.get(&r.2).is_some_and(|b| b == QUERY_TABLE.as_bytes())));
// One VML drawing: the kept control shapes plus GridCraft's note, in a fresh shape block.
let vml_id = attr_of(&sx, "legacyDrawing", "r:id");
let vml_part = u.target(&sheet, &vml_id).1;
let vml = u.text(&vml_part);
assert!(vml.contains("_x0000_s1025") && vml.contains("_x0000_s1026"));
assert!(!vml.contains("old note") && !vml.contains("_x0000_s1027"), "the source's note shape is replaced");
assert_eq!(vml.matches("ObjectType=\"Note\"").count(), 1);
assert!(vml.contains("_x0000_s2049") && vml.contains("data=\"1,2\""), "{vml}");
let img = u.rels(&vml_part);
assert_eq!(img.len(), 1);
assert_eq!(u.parts.get(&img[0].2).unwrap(), EMF);
// Reading the output keeps the same content again.
let (wb2, rep2) = read_xlsx(&out).unwrap();
assert!(!rep2.warnings.iter().any(|w| w.contains("dropped")), "{:?}", rep2.warnings);
let a = wb.sheet(0).unwrap().preserved.as_deref().unwrap();
let b = wb2.sheet(0).unwrap().preserved.as_deref().unwrap();
let mut da: Vec<&[u8]> = a.parts.iter().map(|p| &*p.data).collect();
let mut db: Vec<&[u8]> = b.parts.iter().map(|p| &*p.data).collect();
da.sort();
db.sort();
assert_eq!(da, db);
assert_eq!(a.elements.len(), b.elements.len());
assert_eq!(wb.preserved.as_deref().unwrap().parts.len(), wb2.preserved.as_deref().unwrap().parts.len());
u.assert_valid();
}
#[test]
fn edited_workbooks_keep_consistent_relationships() {
let (mut wb, _) = read_xlsx(&rich_package()).unwrap();
// Insert a sheet before, rename the sheet with controls, drop its note, edit a cell.
wb.sheets.insert(0, Arc::new(Sheet::new("New")));
let s = wb.sheet_mut(1).unwrap();
s.name = "Renamed".into();
s.comments.clear();
s.set_value(gridcraft_core::CellRef::new(5, 5), gridcraft_core::Value::Number(3.0));
let u = Unpacked::new(&write_xlsx(&wb).unwrap());
u.assert_valid();
let sheet = sheet_part(&u, "Renamed");
assert_eq!(sheet, "xl/worksheets/sheet2.xml");
let sx = u.text(&sheet);
assert_eq!(u.parts.get(&u.target(&sheet, &attr_of(&sx, "pageSetup", "r:id")).1).unwrap(), PRINTER);
assert_eq!(u.parts.get(&u.target(&sheet, &attr_of(&sx, "control", "r:id")).1).unwrap(), CTRL_PROP.as_bytes());
// Without notes the kept VML is written on its own, still without the old note.
let vml = u.text(&u.target(&sheet, &attr_of(&sx, "legacyDrawing", "r:id")).1);
assert!(vml.contains("_x0000_s1026") && !vml.contains("ObjectType=\"Note\""));
assert!(!u.text("xl/worksheets/sheet1.xml").contains("pageSetup orientation=\"landscape\" r:id"));
// Deleting the sheet drops its content; workbook-level content stays.
wb.sheets.remove(1);
wb.names.clear();
let u = Unpacked::new(&write_xlsx(&wb).unwrap());
u.assert_valid();
for d in [PRINTER, OLE, EMF, QUERY_TABLE.as_bytes(), CTRL_PROP.as_bytes()] {
assert!(u.find(d).is_empty());
}
for d in [CUSTOM_XML.as_bytes(), EXTERNAL_LINK.as_bytes(), CONNECTIONS.as_bytes(), CUSTOM_PROPS.as_bytes()] {
assert_eq!(u.find(d).len(), 1);
}
}
/// #324: an openpyxl-style workbook whose sheet carries printer settings.
#[test]
fn printer_settings_and_page_setup_id_survive_save() {
let ct = r#"<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="bin" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.printerSettings"/><Default Extension="xml" ContentType="application/xml"/><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/></Types>"#;
let bin: Vec<u8> = (0..=255u8).cycle().take(1024).collect();
let pkg = super::minimal(
r#"<sheetData><row r="1"><c r="A1"><v>42.1</v></c></row></sheetData><pageMargins left="0.75" right="0.75" top="1" bottom="1" header="0.5" footer="0.5"/><pageSetup orientation="landscape" r:id="rIdPs1"/>"#,
&[("xl/worksheets/_rels/sheet1.xml.rels", &rels(&[("rIdPs1", "printerSettings", "../printerSettings/printerSettings1.bin")]))],
"",
"",
);
// Swap in content types that know `.bin`, and add the binary part.
let mut u = Unpacked::new(&pkg);
u.parts.insert("[Content_Types].xml".into(), ct.as_bytes().to_vec());
u.parts.insert("xl/printerSettings/printerSettings1.bin".into(), bin.clone());
let list: Vec<(&str, &[u8])> = u.parts.iter().map(|(k, v)| (k.as_str(), v.as_slice())).collect();
let (wb, rep) = read_xlsx(&make_zip(&list)).unwrap();
assert!(rep.warnings.iter().all(|w| !w.contains("printer")), "{:?}", rep.warnings);
let out = Unpacked::new(&write_xlsx(&wb).unwrap());
out.assert_valid();
let sx = out.text("xl/worksheets/sheet1.xml");
assert!(sx.contains("orientation=\"landscape\""));
let (ty, target) = out.target("xl/worksheets/sheet1.xml", &attr_of(&sx, "pageSetup", "r:id"));
assert!(ty.ends_with("/printerSettings"));
assert_eq!(target, "xl/printerSettings/printerSettings1.bin");
assert_eq!(out.parts.get(&target).unwrap(), &bin);
}
#[test]
fn damaged_kept_content_is_dropped_without_failing_the_read() {
let ct = CONTENT_TYPES.replace("</Types>", r#"<Override PartName="/customXml/item2.xml" ContentType="application/xml"/></Types>"#);
let workbook = format!(
r#"<?xml version="1.0"?><workbook xmlns="{NS_MAIN}" xmlns:r="{NS_R}"><sheets><sheet name="Data" sheetId="1" r:id="rId1"/></sheets><externalReferences><externalReference r:id="rId3"/><externalReference r:id="rId99"/></externalReferences></workbook>"#
);
let sheet = format!(
r#"<?xml version="1.0"?><worksheet xmlns="{NS_MAIN}" xmlns:r="{NS_R}"><sheetData/><pageSetup r:id="rId1"/><controls><control shapeId="1" r:id="rId404" name="Broken"/></controls></worksheet>"#
);
let wb_rels = rels(&[
("rId1", "worksheet", "worksheets/sheet1.xml"),
("rId3", "externalLink", "externalLinks/externalLink1.xml"),
// A missing part.
("rId4", "customXml", "../customXml/missing.xml"),
// A cycle: item1 → itemProps1 → item1.
("rId5", "customXml", "../customXml/item1.xml"),
// Kept content that points into a part GridCraft rewrites.
("rId6", "customXml", "../customXml/item2.xml"),
// Damaged rels behind a kept part.
("rId7", "connections", "connections.xml"),
]);
let cyclic = rels(&[("rId1", "customXmlProps", "itemProps1.xml")]);
let back = rels(&[("rId1", "customXml", "item1.xml")]);
let into_sheet = rels(&[("rId1", "worksheet", "../xl/worksheets/sheet1.xml")]);
let sheet_rels = rels(&[("rId1", "printerSettings", "../printerSettings/missing.bin")]);
let bytes = make_zip(&[
("[Content_Types].xml", ct.as_bytes()),
("_rels/.rels", super::ROOT_RELS.as_bytes()),
("xl/workbook.xml", workbook.as_bytes()),
("xl/_rels/workbook.xml.rels", wb_rels.as_bytes()),
("xl/worksheets/sheet1.xml", sheet.as_bytes()),
("xl/worksheets/_rels/sheet1.xml.rels", sheet_rels.as_bytes()),
("xl/externalLinks/externalLink1.xml", EXTERNAL_LINK.as_bytes()),
("customXml/item1.xml", CUSTOM_XML.as_bytes()),
("customXml/_rels/item1.xml.rels", cyclic.as_bytes()),
("customXml/itemProps1.xml", b"<p/>"),
("customXml/_rels/itemProps1.xml.rels", back.as_bytes()),
("customXml/item2.xml", b"<q/>"),
("customXml/_rels/item2.xml.rels", into_sheet.as_bytes()),
("xl/connections.xml", CONNECTIONS.as_bytes()),
("xl/_rels/connections.xml.rels", b"<Relationships"),
]);
let (wb, rep) = read_xlsx(&bytes).unwrap();
let w = rep.warnings.join("\n");
assert!(w.contains("customXml/missing.xml is missing"), "{w}");
assert!(w.contains("refers to worksheet"), "{w}");
assert!(w.contains("links to external workbooks were dropped"), "a link without a part drops all links: {w}");
assert!(w.contains("printer settings were dropped"), "{w}");
assert!(w.contains("dropped: the sheet has no VML drawing"), "{w}");
let p = wb.preserved.as_deref().unwrap();
// Only the cyclic custom XML pair is consistent enough to keep.
let mut names: Vec<&str> = p.parts.iter().map(|q| q.name.as_str()).collect();
names.sort();
assert_eq!(names, ["customXml/item1.xml", "customXml/itemProps1.xml"]);
assert!(p.elements.is_empty());
assert!(wb.sheet(0).unwrap().preserved.is_none());
let u = Unpacked::new(&write_xlsx(&wb).unwrap());
u.assert_valid();
assert!(!u.text("xl/workbook.xml").contains("externalReferences"));
}
#[test]
fn oversized_kept_parts_are_dropped() {
let big = vec![0u8; crate::passthrough::MAX_KEPT_PART as usize + 1];
let ct = CONTENT_TYPES;
let wb_rels = rels(&[("rId1", "worksheet", "worksheets/sheet1.xml"), ("rId2", "customXml", "../customXml/item1.xml")]);
let workbook = format!(
r#"<?xml version="1.0"?><workbook xmlns="{NS_MAIN}" xmlns:r="{NS_R}"><sheets><sheet name="A" sheetId="1" r:id="rId1"/></sheets></workbook>"#
);
let sheet = format!(r#"<?xml version="1.0"?><worksheet xmlns="{NS_MAIN}"><sheetData/></worksheet>"#);
let bytes = make_zip(&[
("[Content_Types].xml", ct.as_bytes()),
("_rels/.rels", super::ROOT_RELS.as_bytes()),
("xl/workbook.xml", workbook.as_bytes()),
("xl/_rels/workbook.xml.rels", wb_rels.as_bytes()),
("xl/worksheets/sheet1.xml", sheet.as_bytes()),
("customXml/item1.xml", &big),
]);
drop(big);
let (wb, rep) = read_xlsx(&bytes).unwrap();
assert!(wb.preserved.is_none());
assert!(rep.warnings.iter().any(|w| w.contains("customXml") && w.contains("dropped")), "{:?}", rep.warnings);
}
#[test]
fn kept_content_types_are_escaped_on_save() {
// A content type holding `"` and `<` (legal once unescaped by the XML reader) must not break
// out of the attribute in the saved [Content_Types].xml.
let hostile = r#"application/x-test&quot; PartName=&quot;/x&quot;/&gt;&lt;Default Extension=&quot;evil"#;
let ct = CONTENT_TYPES.replace("application/vnd.openxmlformats-officedocument.customXmlProperties+xml", hostile);
let (wb, rep) = read_xlsx(&rich_package_with(&ct, false)).unwrap();
assert!(wb.preserved.is_some(), "{:?}", rep.warnings);
let u = Unpacked::new(&write_xlsx(&wb).unwrap());
let types = xml::parse(u.text("[Content_Types].xml").as_bytes()).unwrap();
assert!(!types.kids("Default").any(|d| d.attr("Extension") == Some("evil")));
let item = types.kids("Override").find(|o| o.attr("PartName") == Some("/customXml/itemProps1.xml")).unwrap();
assert_eq!(item.attr("ContentType"), Some(r#"application/x-test" PartName="/x"/><Default Extension="evil"#));
// And the saved file reads back.
read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
}
#[test]
fn kept_vml_drawings_are_charged_to_the_kept_total() {
// How much one sheet's kept content costs, with the default allowance.
let one = {
let (wb, rep) = read_xlsx(&rich_package()).unwrap();
assert!(!rep.warnings.iter().any(|w| w.contains("dropped")), "{:?}", rep.warnings);
wb.preserved.as_ref().map_or(0, |p| p.byte_len()) + wb.sheets.iter().filter_map(|s| s.preserved.as_ref()).map(|p| p.byte_len()).sum::<usize>()
};
// Two sheets share one VML drawing: each would keep its own copy. With room for one sheet's
// content only, the second sheet's VML (and the controls drawn from it) is dropped.
crate::passthrough::KEPT_TOTAL_FOR_TEST.with(|c| c.set(one + VML.len() / 2));
let read = read_xlsx(&rich_package_with(CONTENT_TYPES, true));
crate::passthrough::KEPT_TOTAL_FOR_TEST.with(|c| c.set(crate::passthrough::MAX_KEPT_TOTAL));
let (wb, rep) = read.unwrap();
let kept = wb.preserved.as_ref().map_or(0, |p| p.byte_len())
+ wb.sheets.iter().filter_map(|s| s.preserved.as_ref()).map(|p| p.byte_len()).sum::<usize>();
assert!(kept <= one + VML.len() / 2, "kept {kept} bytes");
assert!(wb.sheet(0).unwrap().preserved.as_ref().is_some_and(|p| p.vml.is_some()));
assert!(!wb.sheet(1).unwrap().preserved.as_ref().is_some_and(|p| p.vml.is_some()));
assert!(rep.warnings.iter().any(|w| w.contains("\"Other\"") && w.contains("too large")), "{:?}", rep.warnings);
// Both sheets keep their drawings when there is room.
let (wb, _) = read_xlsx(&rich_package_with(CONTENT_TYPES, true)).unwrap();
assert!(wb.sheets.iter().all(|s| s.preserved.as_ref().is_some_and(|p| p.vml.is_some())));
}
+14 -2
View File
@@ -55,6 +55,12 @@ impl Rels {
self.items.push((id.clone(), ty.to_string(), target.to_string(), false));
id
}
/// Adds a relationship with its complete type URI, internal or external (kept content).
pub fn add_raw(&mut self, ty: &str, target: &str, external: bool) -> String {
let id = format!("rId{}", self.items.len() + 1);
self.items.push((id.clone(), crate::xml::esc_attr(ty), target.to_string(), external));
id
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
@@ -114,6 +120,8 @@ pub struct Out {
pub dynamic_arrays: bool,
pub needs_calc: bool,
pub cell_pictures: crate::richdata::PicturesOut,
/// Kept (pass-through) parts written so far.
pub kept: crate::passthrough::KeptOut,
}
impl Out {
@@ -173,6 +181,7 @@ pub fn write_xlsx(wb: &Workbook) -> Result<Vec<u8>, IoError> {
dynamic_arrays: false,
needs_calc: false,
cell_pictures: Default::default(),
kept: Default::default(),
};
out.default_type("rels", "application/vnd.openxmlformats-package.relationships+xml");
out.default_type("xml", "application/xml");
@@ -262,6 +271,7 @@ pub fn write_xlsx(wb: &Workbook) -> Result<Vec<u8>, IoError> {
w.push_str("<workbookProtection lockStructure=\"1\"/>");
}
let _ = write!(w, "<bookViews><workbookView activeTab=\"{active}\"/></bookViews><sheets>{sheets_xml}</sheets>");
w.push_str(&crate::passthrough::write_workbook(wb, &mut wb_rels, &mut out));
if !names.is_empty() {
let _ = write!(w, "<definedNames>{names}</definedNames>");
}
@@ -359,15 +369,17 @@ pub fn write_xlsx(wb: &Workbook) -> Result<Vec<u8>, IoError> {
root.add("officeDocument", "xl/workbook.xml");
root.add("core-properties", "docProps/core.xml");
root.add("extended-properties", "docProps/app.xml");
crate::passthrough::write_package_rels(&mut root, &mut out);
out.part("_rels/.rels", None, root.xml().into_bytes());
// Content types first, then the parts in creation order.
let mut ct = format!("{XML_DECL}<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">");
for (ext, t) in &out.defaults {
let _ = write!(ct, "<Default Extension=\"{ext}\" ContentType=\"{t}\"/>");
// Kept parts bring their content types (and extensions) from the input file: escape them.
let _ = write!(ct, "<Default Extension=\"{}\" ContentType=\"{}\"/>", esc_attr(ext), esc_attr(t));
}
for (part, t) in &out.overrides {
let _ = write!(ct, "<Override PartName=\"{}\" ContentType=\"{t}\"/>", esc_attr(part));
let _ = write!(ct, "<Override PartName=\"{}\" ContentType=\"{}\"/>", esc_attr(part), esc_attr(t));
}
ct.push_str("</Types>");
let mut zip = ZipOut::new();
+1 -1
View File
@@ -83,7 +83,7 @@ pub fn rich_text(e: &El) -> String {
}
/// Converts input bytes to UTF-8 XML text (handles BOMs and UTF-16).
fn to_utf8(xml: &[u8]) -> std::borrow::Cow<'_, [u8]> {
pub(crate) fn to_utf8(xml: &[u8]) -> std::borrow::Cow<'_, [u8]> {
if let Some(rest) = xml.strip_prefix(&[0xEF, 0xBB, 0xBF]) {
return std::borrow::Cow::Borrowed(rest);
}
+3 -1
View File
@@ -15,6 +15,7 @@ support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and
| Format | Excel | GridCraft read | GridCraft write | Fidelity / notes | Tests |
|---|---|---|---|---|---|
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), form controls, ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes, merges, CF, validation, tables, notes (with their shown/hidden state) and threaded comments with replies, resolved state and timestamps (`threadedComments` and `persons` parts, #300), hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, Allow Edit Ranges (`protectedRanges` with their password hashes, #307), calc settings. **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
| `.xlsx` Strict Open XML | R/W | yes | no (writes transitional) | Excel also saves transitional by default | fixtures |
@@ -63,7 +64,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
| Work | Hours |
|---|---|
| Real-world XLSX corpus run (craftrules test-corpora standard), and confirm Excel opens our output with no repair prompt (owner runs Excel) | 15–25 |
| Preserve unmodelled XLSX parts and extensions on round trip (pass-through with relationship fix-up) | 10–15 |
| Preserve the rest of the unmodelled XLSX content (slicers, timelines, data model, table query tables, `extLst` entries); pass-through landed for the rest (#330) | 6–10 |
| Streaming XLSX reader for very large files (#175), part-size limits that degrade instead of failing | 10–15 |
| Legacy `.xls` (BIFF8): styles (fonts, fills, borders, alignment), comments, CF, validation, pictures and charts on import; corpus run; then write | 10–15 |
| Encrypted workbooks: open with password prompt, save with password (#11) | 8–12 |
@@ -76,6 +77,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | `.xls` (BIFF8) import: values, formulas, number formats, merges, sizes, names (#121) |
| 2026-10-11 | minor | XLSX pass-through: custom XML, external links, connections, query tables, printer settings, form/ActiveX controls, OLE objects, sheet backgrounds kept on save (#330, #324) |
| 2026-10-11 | minor | XLSX: threaded comments and persons parts written (they came back as notes before); note visibility read from VML (#300) |
| 2026-10-11 | minor | XLSX: Allow Edit Ranges (`protectedRanges`) read and written with their password hashes (#307) |
| 2026-10-10 | minor | Encrypted and `.xls` files are now recognised and refused with a message (#17) |
+10 -7
View File
@@ -1,6 +1,6 @@
# Gaps
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (§15: the ODD* bond functions landed) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (§3 narrowed by XLSX pass-through, #330) · **Target:** Microsoft Excel (Microsoft 365)
Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone
using GridCraft for real work first). This is the work list: pick from the top, check the open
@@ -33,12 +33,14 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
- **Impact:** finance and data users routinely have big workbooks.
- **Estimate:** 25–40 h. [target-app-parity.md](target-app-parity.md#performance), [hardware-parity.md](hardware-parity.md)
### 3. Unmodelled XLSX content is dropped on save (FF)
- **Missing:** pass-through of parts and extensions we don't model (form controls, slicers,
timelines, external links, OLE objects, custom XML, query tables, data model, chart details).
- **Evidence:** reader warns and skips (`crates/xlsx/src/read.rs`); external links "not supported".
- **Impact:** open-edit-save silently simplifies colleagues' workbooks.
- **Estimate:** 10–15 h. [file-format-parity.md](file-format-parity.md)
### 3. Some unmodelled XLSX content is dropped on save (FF)
- **Missing:** slicers and their caches, timelines, the data model, query tables attached to
tables, unmodelled `extLst` entries, chart details. Pass-through (#330) keeps custom XML,
external links, connections, sheet query tables, printer settings, form and ActiveX controls,
OLE objects and sheet backgrounds; never checked in Excel (repair prompts unknown).
- **Evidence:** `crates/xlsx/src/passthrough.rs` (what is kept, and why the rest isn't).
- **Impact:** open-edit-save still simplifies workbooks with slicers, timelines or Power Pivot.
- **Estimate:** 6–10 h. [file-format-parity.md](file-format-parity.md)
### 4. Charts lose type and options on round trip; shallow chart model (F, FF)
- **Missing:** kinds that survive XLSX (stock, combo, histogram, box, waterfall, funnel, treemap,
@@ -159,6 +161,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
|---|---|---|
| 2026-10-11 | minor | §16: comment navigation, Show Comments, Show All Notes and Convert to Comments landed (#281) |
| 2026-10-11 | minor | §7: `.xls` (BIFF8) import of values, formulas and number formats (#121) |
| 2026-10-11 | minor | §3: XLSX pass-through keeps custom XML, external links, connections, query tables, printer settings, controls and OLE objects (#330, #324) |
| 2026-10-11 | minor | §15: ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD added (18 functions missing) |
| 2026-10-11 | minor | §15: PERCENTOF and EUROCONVERT added (20 functions missing) |
| 2026-10-11 | minor | §15: BAHTTEXT added (21 functions missing) |