mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-02 07:06:48 -06:00
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:
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -21,6 +21,7 @@ mod feature_bags;
|
||||
mod fmla;
|
||||
mod ods;
|
||||
mod package;
|
||||
mod passthrough;
|
||||
mod pivot;
|
||||
mod read;
|
||||
mod richdata;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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")),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" PartName="/x"/><Default Extension="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())));
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) |
|
||||
|
||||
Reference in New Issue
Block a user