Merge pull request #856 from Silvertree2010:fix/xlsx-chart-sheets
This commit is contained in:
commit
84ad5a23cb
|
|
@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
|||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-12 | XLSX chart sheets are kept on open and save: the sheet, its drawing and chart (with style and colours) are written back unchanged in their place among the worksheets, and left out when the sheet they plot is deleted or renamed or a worksheet took their name (#727) |
|
||||
| 2026-10-12 | CSV and tab-separated export is capped at 512 MiB: a sheet whose used range would expand to more (two cells far apart, such as A1 and XFD1048576, are 16 GiB of commas) is refused with the rows, columns and limit, before anything is allocated or written (#528) |
|
||||
| 2026-10-12 | PDF writer hardening: text width cannot overflow, all-zero dash arrays and negative or NaN stroke widths are sanitised, non-finite sizes and positions draw nothing (#812) |
|
||||
| 2026-10-12 | PDF export writes the document title and author as UTF-16BE when they are not ASCII, so viewers show accents, currency signs and non-Latin text correctly (#813) |
|
||||
|
|
@ -187,6 +188,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | trivial | Progress log: XLSX chart sheets kept on save (#727) |
|
||||
| 2026-10-12 | trivial | Progress log: CSV export size cap (#528) |
|
||||
| 2026-10-12 | minor | Progress log: margins dragged on the Page Layout rulers (#792) |
|
||||
| 2026-10-12 | minor | Ink preservation across XLSX save/reopen (ink portion of #551) |
|
||||
|
|
|
|||
|
|
@ -18,7 +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 preserved::{Preserved, PreservedPart, PreservedRel, PreservedSheet, PreservedXml};
|
||||
pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, SheetViewMode, Visibility, points_to_chars};
|
||||
pub use store::CellStore;
|
||||
pub use style::{
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::sheet::Visibility;
|
||||
|
||||
/// 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)]
|
||||
|
|
@ -43,6 +45,19 @@ pub struct PreservedXml {
|
|||
pub xml: String,
|
||||
}
|
||||
|
||||
/// A chart sheet (a sheet that holds one chart and no cells) kept as it was in the source file. Its
|
||||
/// part, drawing, chart, style and colour parts are in [`Preserved::parts`]; the workbook
|
||||
/// relationship to it is in [`Preserved::rels`].
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct PreservedSheet {
|
||||
pub name: String,
|
||||
pub visibility: Visibility,
|
||||
/// The id of the workbook relationship to the chart sheet part, in the source file.
|
||||
pub rel_id: String,
|
||||
/// How many worksheets come before it: its position among the model's sheets.
|
||||
pub before: usize,
|
||||
}
|
||||
|
||||
/// Unmodelled content kept for one owner (the workbook, which also holds package-root content,
|
||||
/// or one sheet).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
|
|
@ -58,11 +73,18 @@ pub struct Preserved {
|
|||
/// 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>,
|
||||
/// Chart sheets, in file order (workbook owner only).
|
||||
pub sheets: Vec<PreservedSheet>,
|
||||
}
|
||||
|
||||
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()
|
||||
self.package_rels.is_empty()
|
||||
&& self.rels.is_empty()
|
||||
&& self.parts.is_empty()
|
||||
&& self.elements.is_empty()
|
||||
&& self.vml.is_none()
|
||||
&& self.sheets.is_empty()
|
||||
}
|
||||
/// Total bytes held.
|
||||
pub fn byte_len(&self) -> usize {
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ 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 gridcraft_model::{Preserved, PreservedPart, PreservedRel, PreservedSheet, PreservedXml, Sheet, Visibility, Workbook};
|
||||
use quick_xml::events::attributes::Attribute;
|
||||
use quick_xml::events::{BytesStart, Event};
|
||||
use quick_xml::name::QName;
|
||||
|
|
@ -137,6 +137,10 @@ const REWRITTEN: &[&str] = &[
|
|||
"powerpivotdata",
|
||||
];
|
||||
|
||||
/// Relationship kinds a chart sheet reaches that are otherwise rewritten: its drawing, chart and
|
||||
/// their style, colour and shape parts are kept byte for byte (the chart sheet is not modelled).
|
||||
const CHART_SHEET_GRAPH: &[&str] = &["chartsheet", "drawing", "chart", "chartex", "chartstyle", "chartcolorstyle", "chartusershapes"];
|
||||
|
||||
/// 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] = &[
|
||||
|
|
@ -201,6 +205,21 @@ fn raw_rels(cx: &mut Ctx<'_>, part: &str) -> Result<Vec<PreservedRel>, String> {
|
|||
|
||||
// ---------------------------------------------------------------- reading
|
||||
|
||||
/// The `<sheet>` entries of the workbook that are chart sheets, each with the number of worksheets
|
||||
/// (model sheets) before it.
|
||||
fn chart_sheet_entries<'a>(wb_xml: &'a xml::El, wb_rels: &[PreservedRel], file_to_model: &[Option<usize>]) -> Vec<(usize, &'a xml::El)> {
|
||||
let mut out = vec![];
|
||||
let mut worksheets = 0usize;
|
||||
for (i, se) in wb_xml.child("sheets").into_iter().flat_map(|s| s.kids("sheet")).enumerate() {
|
||||
if file_to_model.get(i).copied().flatten().is_some() {
|
||||
worksheets += 1;
|
||||
} else if se.attr("id").and_then(|id| wb_rels.iter().find(|r| r.id == id)).is_some_and(|r| is_kind(r, &["chartsheet"])) {
|
||||
out.push((worksheets, se));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Collects kept content while a package is read.
|
||||
pub struct KeptReader {
|
||||
on: bool,
|
||||
|
|
@ -257,11 +276,12 @@ impl KeptReader {
|
|||
|
||||
/// 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> {
|
||||
fn closure(&mut self, cx: &mut Ctx<'_>, root: &PreservedRel, seen: &HashSet<String>, through: &[&str]) -> Result<Vec<PreservedPart>, String> {
|
||||
if root.external {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
if rewritten(root) {
|
||||
let blocked = |r: &PreservedRel| rewritten(r) && !through.iter().any(|k| k.eq_ignore_ascii_case(kind(&r.rel_type)));
|
||||
if blocked(root) {
|
||||
return Err(format!("it refers to {}", kind(&root.rel_type)));
|
||||
}
|
||||
let mut parts = vec![];
|
||||
|
|
@ -291,7 +311,7 @@ impl KeptReader {
|
|||
}
|
||||
let rels = raw_rels(cx, &name)?;
|
||||
for r in rels.iter().filter(|r| !r.external) {
|
||||
if rewritten(r) {
|
||||
if blocked(r) {
|
||||
return Err(format!("it refers to {}", kind(&r.rel_type)));
|
||||
}
|
||||
queue.push_back((r.target.clone(), depth + 1));
|
||||
|
|
@ -305,10 +325,22 @@ impl KeptReader {
|
|||
|
||||
/// 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> {
|
||||
self.keep_through(cx, p, roots, seen, &[])
|
||||
}
|
||||
|
||||
/// [`Self::keep`] for roots whose graph may reach the rewritten kinds in `through`.
|
||||
fn keep_through(
|
||||
&mut self,
|
||||
cx: &mut Ctx<'_>,
|
||||
p: &mut Preserved,
|
||||
roots: &[PreservedRel],
|
||||
seen: &mut HashSet<String>,
|
||||
through: &[&str],
|
||||
) -> 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)?;
|
||||
let parts = self.closure(cx, r, &scratch, through)?;
|
||||
scratch.extend(parts.iter().map(|q| normalize(&q.name)));
|
||||
new_parts.extend(parts);
|
||||
}
|
||||
|
|
@ -323,8 +355,12 @@ impl KeptReader {
|
|||
}
|
||||
|
||||
/// Package-root and workbook-level content.
|
||||
pub fn workbook(&mut self, cx: &mut Ctx<'_>, wb_part: &str, wb_xml: &xml::El) -> Option<Arc<Preserved>> {
|
||||
pub fn workbook(&mut self, cx: &mut Ctx<'_>, wb_part: &str, wb_xml: &xml::El, file_to_model: &[Option<usize>]) -> Option<Arc<Preserved>> {
|
||||
if !self.on {
|
||||
let wb_rels = raw_rels(cx, wb_part).unwrap_or_default();
|
||||
for (_, se) in chart_sheet_entries(wb_xml, &wb_rels, file_to_model) {
|
||||
cx.warn(format!("chart sheet \"{}\" was skipped and is not kept on save", se.attr("name").unwrap_or("")));
|
||||
}
|
||||
if self.strict {
|
||||
cx.warn("unmodelled content of a Strict Open XML workbook (custom XML, links, controls…) is not kept on save");
|
||||
}
|
||||
|
|
@ -351,6 +387,25 @@ impl KeptReader {
|
|||
cx.warn(format!("{} part {} was dropped: {why}", kind(&r.rel_type), r.target));
|
||||
}
|
||||
}
|
||||
// Chart sheets: the sheet entry (name, state, position) plus the chart sheet part and the
|
||||
// drawing, chart, style and colour parts behind it.
|
||||
for (worksheets, se) in chart_sheet_entries(wb_xml, &wb_rels, file_to_model) {
|
||||
let name = se.attr("name").unwrap_or("").to_string();
|
||||
let Some(r) = se.attr("id").and_then(|id| wb_rels.iter().find(|r| r.id == id)) else { continue };
|
||||
match self.keep_through(cx, &mut p, std::slice::from_ref(r), &mut seen, CHART_SHEET_GRAPH) {
|
||||
Ok(()) => p.sheets.push(PreservedSheet {
|
||||
name,
|
||||
visibility: match se.attr("state") {
|
||||
Some("hidden") => Visibility::Hidden,
|
||||
Some("veryHidden") => Visibility::VeryHidden,
|
||||
_ => Visibility::Visible,
|
||||
},
|
||||
rel_id: r.id.clone(),
|
||||
before: worksheets,
|
||||
}),
|
||||
Err(why) => cx.warn(format!("chart sheet \"{name}\" was dropped: {why}")),
|
||||
}
|
||||
}
|
||||
// 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> =
|
||||
|
|
@ -869,18 +924,165 @@ 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);
|
||||
/// A kept chart sheet being written: its `<sheet>` entry's fields and its new relationship id.
|
||||
pub struct KeptSheet {
|
||||
before: usize,
|
||||
name: String,
|
||||
visibility: Visibility,
|
||||
rid: String,
|
||||
}
|
||||
|
||||
/// The workbook's kept content as written.
|
||||
#[derive(Default)]
|
||||
pub struct WorkbookKept {
|
||||
/// Workbook XML elements that go right after `<sheets>` (`<externalReferences>`).
|
||||
pub elements: String,
|
||||
sheets: Vec<KeptSheet>,
|
||||
}
|
||||
|
||||
impl WorkbookKept {
|
||||
/// Where the model's sheet `i` is in the file's sheet list: chart sheets come between worksheets.
|
||||
pub fn file_index(&self, i: usize) -> usize {
|
||||
i + self.sheets.iter().filter(|s| s.before <= i).count()
|
||||
}
|
||||
|
||||
/// The `<sheet>` entries of the chart sheets that go right before the model's sheet `i`
|
||||
/// (`i` = the number of sheets for those that come last); `id` numbers them from `first_id`.
|
||||
pub fn sheet_entries(&self, i: usize, last: bool, mut id: usize) -> (String, usize) {
|
||||
let mut x = String::new();
|
||||
for s in self.sheets.iter().filter(|s| if last { s.before >= i } else { s.before == i }) {
|
||||
let state = match s.visibility {
|
||||
Visibility::Visible => "",
|
||||
Visibility::Hidden => " state=\"hidden\"",
|
||||
Visibility::VeryHidden => " state=\"veryHidden\"",
|
||||
};
|
||||
let name: String = s.name.chars().take(31).collect();
|
||||
let _ = write!(x, "<sheet name=\"{}\" sheetId=\"{id}\"{state} r:id=\"{}\"/>", esc_attr(&name), esc_attr(&s.rid));
|
||||
id += 1;
|
||||
}
|
||||
(x, id)
|
||||
}
|
||||
}
|
||||
|
||||
/// The part names reachable from `root` through kept parts' relationships (normalized).
|
||||
fn reachable(p: &Preserved, root: &PreservedRel) -> HashSet<String> {
|
||||
let by_name: HashMap<String, &PreservedPart> = p.parts.iter().map(|q| (normalize(&q.name), q)).collect();
|
||||
let mut seen = HashSet::new();
|
||||
let mut queue = VecDeque::from([normalize(&root.target)]);
|
||||
while let Some(n) = queue.pop_front() {
|
||||
if !seen.insert(n.clone()) {
|
||||
continue;
|
||||
}
|
||||
if let Some(q) = by_name.get(&n) {
|
||||
queue.extend(q.rels.iter().filter(|r| !r.external).map(|r| normalize(&r.target)));
|
||||
}
|
||||
}
|
||||
seen
|
||||
}
|
||||
|
||||
/// Every formula (`<c:f>`) of a chart part, depth first.
|
||||
fn chart_formulas(el: &xml::El, out: &mut Vec<String>) {
|
||||
if el.name == "f" && !el.text.trim().is_empty() {
|
||||
out.push(el.text.trim().to_string());
|
||||
}
|
||||
for c in &el.children {
|
||||
chart_formulas(c, out);
|
||||
}
|
||||
}
|
||||
|
||||
/// The sheet names a formula such as `'My Sheet'!$A$1:$A$9,Data!$B$1` refers to. References to
|
||||
/// other workbooks (`[1]Sheet!A1`) are not sheets of this one and are left out.
|
||||
pub(crate) fn sheet_names_in(formula: &str) -> Vec<String> {
|
||||
let mut names = vec![];
|
||||
let chars: Vec<char> = formula.chars().collect();
|
||||
let at = |i: usize| chars.get(i).copied();
|
||||
let mut i = 0;
|
||||
while let Some(c) = at(i) {
|
||||
let mut name = String::new();
|
||||
let start = i;
|
||||
if c == '\'' {
|
||||
i += 1;
|
||||
while let Some(c) = at(i) {
|
||||
if c == '\'' {
|
||||
if at(i + 1) == Some('\'') {
|
||||
name.push('\'');
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
name.push(c);
|
||||
i += 1;
|
||||
}
|
||||
i += 1;
|
||||
} else {
|
||||
while let Some(c) = at(i).filter(|c| !matches!(c, '!' | ',' | '(' | ')' | '\'' | ' ')) {
|
||||
name.push(c);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
if at(i) == Some('!') {
|
||||
if !name.is_empty() && !name.starts_with('[') {
|
||||
names.push(name);
|
||||
}
|
||||
i += 1;
|
||||
} else if i == start {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// Whether a kept chart sheet can be written back: its chart's series must still refer to sheets
|
||||
/// that exist under the same names (the chart is kept as it was, so a series on a deleted or
|
||||
/// renamed sheet would be left pointing at nothing), and no worksheet may have taken its name.
|
||||
fn chart_sheet_usable(wb: &Workbook, p: &Preserved, s: &PreservedSheet) -> bool {
|
||||
let same = |a: &str, b: &str| a.chars().take(31).collect::<String>().eq_ignore_ascii_case(&b.chars().take(31).collect::<String>());
|
||||
if wb.sheets.iter().any(|w| same(&w.name, &s.name)) {
|
||||
return false;
|
||||
}
|
||||
let Some(root) = p.rels.iter().find(|r| r.id == s.rel_id) else { return false };
|
||||
let parts = reachable(p, root);
|
||||
let mut formulas = vec![];
|
||||
for q in p.parts.iter().filter(|q| parts.contains(&normalize(&q.name)) && q.content_type.contains("chart")) {
|
||||
if let Ok(x) = xml::parse(&q.data) {
|
||||
chart_formulas(&x, &mut formulas);
|
||||
}
|
||||
}
|
||||
formulas.iter().flat_map(|f| sheet_names_in(f)).all(|n| wb.sheets.iter().any(|w| same(&w.name, &n)))
|
||||
}
|
||||
|
||||
/// Writes the workbook's kept parts and relationships. Chart sheets that can't be written back
|
||||
/// (see [`chart_sheet_usable`]) are left out with the parts only they reach.
|
||||
pub fn write_workbook(wb: &Workbook, wb_rels: &mut Rels, out: &mut Out) -> WorkbookKept {
|
||||
let Some(p) = wb.preserved.as_deref() else { return WorkbookKept::default() };
|
||||
let (live, dropped): (Vec<&PreservedSheet>, Vec<&PreservedSheet>) = p.sheets.iter().partition(|s| chart_sheet_usable(wb, p, s));
|
||||
let root_of = |s: &PreservedSheet| p.rels.iter().find(|r| r.id == s.rel_id);
|
||||
let dropped_ids: HashSet<&str> = dropped.iter().map(|s| s.rel_id.as_str()).collect();
|
||||
// Parts reached only by dropped chart sheets go with them.
|
||||
let mut gone: HashSet<String> = dropped.iter().filter_map(|s| root_of(s)).flat_map(|r| reachable(p, r)).collect();
|
||||
if !gone.is_empty() {
|
||||
let others = p.rels.iter().filter(|r| !dropped_ids.contains(r.id.as_str())).flat_map(|r| reachable(p, r));
|
||||
for n in others {
|
||||
gone.remove(&n);
|
||||
}
|
||||
}
|
||||
let parts: Vec<PreservedPart> = p.parts.iter().filter(|q| !gone.contains(&normalize(&q.name))).cloned().collect();
|
||||
let rels: Vec<PreservedRel> = p.rels.iter().filter(|r| !dropped_ids.contains(r.id.as_str())).cloned().collect();
|
||||
let names = write_parts(out, &parts);
|
||||
let ids = attach(wb_rels, &rels, &names);
|
||||
for r in &p.package_rels {
|
||||
if let Some(target) = rel_target(r, &names) {
|
||||
out.kept.root.push((r.rel_type.clone(), target, r.external));
|
||||
}
|
||||
}
|
||||
p.elements.iter().filter(|e| e.slot == "externalReferences").filter_map(|e| remap(&e.xml, &ids)).collect()
|
||||
WorkbookKept {
|
||||
elements: p.elements.iter().filter(|e| e.slot == "externalReferences").filter_map(|e| remap(&e.xml, &ids)).collect(),
|
||||
sheets: live
|
||||
.iter()
|
||||
.filter_map(|s| Some(KeptSheet { before: s.before, name: s.name.clone(), visibility: s.visibility, rid: ids.get(&s.rel_id)?.clone() }))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The workbook's kept elements of `slot` that refer to no part (`fileVersion`, `ext`).
|
||||
|
|
|
|||
|
|
@ -208,6 +208,11 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
|
|||
file_to_model.push(None);
|
||||
continue;
|
||||
};
|
||||
if rel.kind == "chartsheet" {
|
||||
// Kept as it is and written back in place (see `passthrough`); nothing to model.
|
||||
file_to_model.push(None);
|
||||
continue;
|
||||
}
|
||||
if rel.kind != "worksheet" {
|
||||
cx.warn(format!("sheet \"{name}\" is a {} and was skipped", rel.kind));
|
||||
file_to_model.push(None);
|
||||
|
|
@ -334,7 +339,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
|
|||
Err(e) => cx.warn(format!("macros (VBA project) could not be read and were dropped: {e}")),
|
||||
}
|
||||
}
|
||||
wb.preserved = kept.workbook(&mut cx, &wb_part, &wb_xml);
|
||||
wb.preserved = kept.workbook(&mut cx, &wb_part, &wb_xml, &file_to_model);
|
||||
// Calls of LAMBDA names are spelled like the names (the parser upper-cases them).
|
||||
wb.apply_name_call_case();
|
||||
wb.styles = cx.styles;
|
||||
|
|
|
|||
|
|
@ -484,6 +484,184 @@ fn kept_vml_drawings_are_charged_to_the_kept_total() {
|
|||
assert!(wb.sheets.iter().all(|s| s.preserved.as_ref().is_some_and(|p| p.vml.is_some())));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ chart sheets (#727)
|
||||
|
||||
const NS_C: &str = "http://schemas.openxmlformats.org/drawingml/2006/chart";
|
||||
const CT_PREFIX: &str = "application/vnd.openxmlformats-officedocument";
|
||||
|
||||
fn chart_xml(formula: &str) -> String {
|
||||
format!(
|
||||
r#"<?xml version="1.0"?><c:chartSpace xmlns:c="{NS_C}"><c:chart><c:plotArea><c:barChart><c:ser><c:val><c:numRef><c:f>{formula}</c:f></c:numRef></c:val></c:ser></c:barChart></c:plotArea></c:chart></c:chartSpace>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn chartsheet_xml(note: &str) -> String {
|
||||
format!(
|
||||
r#"<?xml version="1.0"?><chartsheet xmlns="{NS_MAIN}" xmlns:r="{NS_R}"><sheetViews><sheetView workbookViewId="0"/></sheetViews><pageSetup r:id="rId2"/><drawing r:id="rId1"/><!-- {note} --></chartsheet>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn drawing_xml(note: &str) -> String {
|
||||
format!(
|
||||
r#"<?xml version="1.0"?><xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing"><!-- {note} --></xdr:wsDr>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn worksheet_xml(value: &str) -> String {
|
||||
format!(
|
||||
r#"<?xml version="1.0"?><worksheet xmlns="{NS_MAIN}"><sheetData><row r="1"><c r="A1" t="inlineStr"><is><t>{value}</t></is></c></row></sheetData></worksheet>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Sheets in file order: Data, Chart1 (a chart sheet on Data), More, Spare, Last (a hidden chart
|
||||
/// sheet on More, last in the list). "More" has a print area; the active tab is "More".
|
||||
fn chartsheet_package() -> Vec<u8> {
|
||||
let workbook = format!(
|
||||
r#"<?xml version="1.0"?><workbook xmlns="{NS_MAIN}" xmlns:r="{NS_R}"><bookViews><workbookView activeTab="2"/></bookViews><sheets><sheet name="Data" sheetId="1" r:id="rId1"/><sheet name="Chart1" sheetId="2" r:id="rId2"/><sheet name="More" sheetId="3" r:id="rId3"/><sheet name="Spare" sheetId="4" r:id="rId4"/><sheet name="Last" sheetId="9" state="hidden" r:id="rId5"/></sheets><definedNames><definedName name="_xlnm.Print_Area" localSheetId="2">More!$A$1:$B$2</definedName></definedNames></workbook>"#
|
||||
);
|
||||
let content_types = format!(
|
||||
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="{CT_PREFIX}.spreadsheetml.printerSettings"/><Override PartName="/xl/workbook.xml" ContentType="{CT_PREFIX}.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="{CT_PREFIX}.spreadsheetml.worksheet+xml"/><Override PartName="/xl/worksheets/sheet2.xml" ContentType="{CT_PREFIX}.spreadsheetml.worksheet+xml"/><Override PartName="/xl/worksheets/sheet3.xml" ContentType="{CT_PREFIX}.spreadsheetml.worksheet+xml"/><Override PartName="/xl/chartsheets/sheet1.xml" ContentType="{CT_PREFIX}.spreadsheetml.chartsheet+xml"/><Override PartName="/xl/chartsheets/sheet2.xml" ContentType="{CT_PREFIX}.spreadsheetml.chartsheet+xml"/><Override PartName="/xl/drawings/drawing1.xml" ContentType="{CT_PREFIX}.drawing+xml"/><Override PartName="/xl/drawings/drawing2.xml" ContentType="{CT_PREFIX}.drawing+xml"/><Override PartName="/xl/charts/chart1.xml" ContentType="{CT_PREFIX}.drawingml.chart+xml"/><Override PartName="/xl/charts/chart2.xml" ContentType="{CT_PREFIX}.drawingml.chart+xml"/></Types>"#
|
||||
);
|
||||
let (cs1, cs2) = (chartsheet_xml("first"), chartsheet_xml("last"));
|
||||
let (d1, d2) = (drawing_xml("first"), drawing_xml("last"));
|
||||
let (c1, c2) = (chart_xml("Data!$A$1:$A$3"), chart_xml("'More'!$A$1"));
|
||||
let (s1, s2, s3) = (worksheet_xml("data"), worksheet_xml("more"), worksheet_xml("spare"));
|
||||
make_zip(&[
|
||||
("[Content_Types].xml", content_types.as_bytes()),
|
||||
("_rels/.rels", rels(&[("rId1", "officeDocument", "xl/workbook.xml")]).as_bytes()),
|
||||
("xl/workbook.xml", workbook.as_bytes()),
|
||||
(
|
||||
"xl/_rels/workbook.xml.rels",
|
||||
rels(&[
|
||||
("rId1", "worksheet", "worksheets/sheet1.xml"),
|
||||
("rId2", "chartsheet", "chartsheets/sheet1.xml"),
|
||||
("rId3", "worksheet", "worksheets/sheet2.xml"),
|
||||
("rId4", "worksheet", "worksheets/sheet3.xml"),
|
||||
("rId5", "chartsheet", "chartsheets/sheet2.xml"),
|
||||
])
|
||||
.as_bytes(),
|
||||
),
|
||||
("xl/worksheets/sheet1.xml", s1.as_bytes()),
|
||||
("xl/worksheets/sheet2.xml", s2.as_bytes()),
|
||||
("xl/worksheets/sheet3.xml", s3.as_bytes()),
|
||||
("xl/chartsheets/sheet1.xml", cs1.as_bytes()),
|
||||
("xl/chartsheets/sheet2.xml", cs2.as_bytes()),
|
||||
(
|
||||
"xl/chartsheets/_rels/sheet1.xml.rels",
|
||||
rels(&[("rId1", "drawing", "../drawings/drawing1.xml"), ("rId2", "printerSettings", "../printerSettings/printerSettings1.bin")])
|
||||
.as_bytes(),
|
||||
),
|
||||
("xl/chartsheets/_rels/sheet2.xml.rels", rels(&[("rId1", "drawing", "../drawings/drawing2.xml")]).as_bytes()),
|
||||
("xl/printerSettings/printerSettings1.bin", PRINTER),
|
||||
("xl/drawings/drawing1.xml", d1.as_bytes()),
|
||||
("xl/drawings/drawing2.xml", d2.as_bytes()),
|
||||
("xl/drawings/_rels/drawing1.xml.rels", rels(&[("rId1", "chart", "../charts/chart1.xml")]).as_bytes()),
|
||||
("xl/drawings/_rels/drawing2.xml.rels", rels(&[("rId1", "chart", "../charts/chart2.xml")]).as_bytes()),
|
||||
("xl/charts/chart1.xml", c1.as_bytes()),
|
||||
("xl/charts/chart2.xml", c2.as_bytes()),
|
||||
])
|
||||
}
|
||||
|
||||
/// The sheets of a written workbook: (name, state) in file order.
|
||||
fn written_sheets(u: &Unpacked) -> Vec<(String, String)> {
|
||||
let wb = xml::parse(u.parts.get("xl/workbook.xml").unwrap()).unwrap();
|
||||
wb.child("sheets").unwrap().kids("sheet").map(|s| (s.attr("name").unwrap().to_string(), s.attr("state").unwrap_or("").to_string())).collect()
|
||||
}
|
||||
|
||||
/// The part a written chart sheet is in, found through the workbook relationship; its drawing and
|
||||
/// that drawing's chart, followed through relationships.
|
||||
fn chart_sheet_chain(u: &Unpacked, name: &str) -> (String, String, String) {
|
||||
let wb = xml::parse(u.parts.get("xl/workbook.xml").unwrap()).unwrap();
|
||||
let id = wb.child("sheets").unwrap().kids("sheet").find(|s| s.attr("name") == Some(name)).unwrap().attr("id").unwrap().to_string();
|
||||
let (ty, part) = u.target("xl/workbook.xml", &id);
|
||||
assert!(ty.ends_with("/chartsheet"), "{ty}");
|
||||
let drawing = u.rels(&part).into_iter().find(|r| r.1.ends_with("/drawing")).unwrap().2;
|
||||
let chart = u.rels(&drawing).into_iter().find(|r| r.1.ends_with("/chart")).unwrap().2;
|
||||
(part, drawing, chart)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chart_sheets_survive_a_round_trip_in_place() {
|
||||
let (wb, rep) = read_xlsx(&chartsheet_package()).unwrap();
|
||||
assert!(!rep.warnings.iter().any(|w| w.contains("skipped") || w.contains("dropped")), "{:?}", rep.warnings);
|
||||
let names: Vec<&str> = wb.sheets.iter().map(|s| s.name.as_str()).collect();
|
||||
assert_eq!(names, ["Data", "More", "Spare"], "chart sheets are not worksheets");
|
||||
|
||||
let out = write_xlsx(&wb).unwrap();
|
||||
let u = Unpacked::new(&out);
|
||||
u.assert_valid();
|
||||
// Same order, same visibility, the way Excel shows the tabs.
|
||||
assert_eq!(
|
||||
written_sheets(&u),
|
||||
[("Data", ""), ("Chart1", ""), ("More", ""), ("Spare", ""), ("Last", "hidden")].map(|(a, b)| (a.to_string(), b.to_string())),
|
||||
);
|
||||
// Sheet ids are unique.
|
||||
let wbx = u.text("xl/workbook.xml");
|
||||
let mut ids: Vec<String> = wbx.match_indices("<sheet ").map(|(i, _)| attr_of(&wbx[i..], "sheet", "sheetId")).collect();
|
||||
ids.sort();
|
||||
ids.dedup();
|
||||
assert_eq!(ids.len(), 5, "{wbx}");
|
||||
// Positions in the file, not in the model, for the active tab and the print area.
|
||||
assert_eq!(attr_of(&wbx, "workbookView", "activeTab"), "2", "More is the third tab");
|
||||
assert_eq!(attr_of(&wbx, "definedName", "localSheetId"), "2", "{wbx}");
|
||||
|
||||
// Every part of both chart sheets is in the output, byte for byte, behind the same chain.
|
||||
for (name, cs, drawing, chart) in [("Chart1", "first", "first", "Data!$A$1:$A$3"), ("Last", "last", "last", "'More'!$A$1")] {
|
||||
let (cs_part, d_part, c_part) = chart_sheet_chain(&u, name);
|
||||
assert_eq!(u.text(&cs_part), chartsheet_xml(cs), "{name}");
|
||||
assert_eq!(u.text(&d_part), drawing_xml(drawing), "{name}");
|
||||
assert_eq!(u.text(&c_part), chart_xml(chart), "{name}");
|
||||
}
|
||||
// The first chart sheet's printer settings follow its `<pageSetup r:id>`.
|
||||
let (cs_part, _, _) = chart_sheet_chain(&u, "Chart1");
|
||||
let ps = u.target(&cs_part, &attr_of(&u.text(&cs_part), "pageSetup", "r:id"));
|
||||
assert!(ps.0.ends_with("/printerSettings"));
|
||||
assert_eq!(u.parts.get(&ps.1).unwrap(), PRINTER);
|
||||
|
||||
// Reading the output again finds the same two chart sheets, and a second save is stable.
|
||||
let (wb2, rep2) = read_xlsx(&out).unwrap();
|
||||
assert!(!rep2.warnings.iter().any(|w| w.contains("skipped") || w.contains("dropped")), "{:?}", rep2.warnings);
|
||||
let again = Unpacked::new(&write_xlsx(&wb2).unwrap());
|
||||
again.assert_valid();
|
||||
assert_eq!(written_sheets(&again), written_sheets(&u));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chart_sheet_whose_data_sheet_was_renamed_is_dropped_with_its_parts() {
|
||||
let (mut wb, _) = read_xlsx(&chartsheet_package()).unwrap();
|
||||
wb.sheet_mut(0).unwrap().name = "Renamed".into();
|
||||
let u = Unpacked::new(&write_xlsx(&wb).unwrap());
|
||||
u.assert_valid();
|
||||
// "Chart1" plots Data, which is gone under that name; "Last" plots More, which is not.
|
||||
assert_eq!(written_sheets(&u).iter().map(|s| s.0.as_str()).collect::<Vec<_>>(), ["Renamed", "More", "Spare", "Last"]);
|
||||
assert!(u.find(chart_xml("Data!$A$1:$A$3").as_bytes()).is_empty());
|
||||
assert!(u.find(drawing_xml("first").as_bytes()).is_empty());
|
||||
assert!(u.find(PRINTER).is_empty());
|
||||
chart_sheet_chain(&u, "Last");
|
||||
let wbx = u.text("xl/workbook.xml");
|
||||
assert_eq!(attr_of(&wbx, "workbookView", "activeTab"), "1");
|
||||
assert_eq!(attr_of(&wbx, "definedName", "localSheetId"), "1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chart_sheet_whose_name_a_worksheet_took_is_dropped() {
|
||||
let (mut wb, _) = read_xlsx(&chartsheet_package()).unwrap();
|
||||
wb.sheet_mut(2).unwrap().name = "LAST".into();
|
||||
let u = Unpacked::new(&write_xlsx(&wb).unwrap());
|
||||
u.assert_valid();
|
||||
assert_eq!(written_sheets(&u).iter().map(|s| s.0.as_str()).collect::<Vec<_>>(), ["Data", "Chart1", "More", "LAST"]);
|
||||
assert!(u.find(chart_xml("'More'!$A$1").as_bytes()).is_empty());
|
||||
chart_sheet_chain(&u, "Chart1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formulas_name_the_sheets_a_chart_plots() {
|
||||
use crate::passthrough::sheet_names_in;
|
||||
assert_eq!(sheet_names_in("Data!$A$1:$A$3"), ["Data"]);
|
||||
assert_eq!(sheet_names_in("'My ''Sheet'!$A$1,Data!$B$1"), ["My 'Sheet", "Data"]);
|
||||
assert_eq!(sheet_names_in("SUM(Data!A1:A3,'Other sheet'!B2)"), ["Data", "Other sheet"]);
|
||||
assert!(sheet_names_in("[1]Ext!A1").is_empty() && sheet_names_in("[1]Ext!A1:A3").is_empty());
|
||||
assert!(sheet_names_in("Name").is_empty() && sheet_names_in("").is_empty());
|
||||
#[test]
|
||||
fn file_version_and_calc_features_survive_a_save() {
|
||||
// Excel trusts the saved cell values only when workbook.xml carries both <fileVersion> and the
|
||||
|
|
|
|||
|
|
@ -224,9 +224,15 @@ pub fn write_xlsx(wb: &Workbook, kind: XlsxKind) -> Result<Vec<u8>, IoError> {
|
|||
};
|
||||
|
||||
let mut wb_rels = Rels::default();
|
||||
// Kept content first: the chart sheets among the worksheets need their relationship ids.
|
||||
let kept = crate::passthrough::write_workbook(wb, &mut wb_rels, &mut out);
|
||||
let mut pivots = crate::pivot::PivotWriter::default();
|
||||
let mut sheets_xml = String::new();
|
||||
let mut sheet_id = 1usize;
|
||||
for (i, sheet) in wb.sheets.iter().enumerate() {
|
||||
let (before, next) = kept.sheet_entries(i, false, sheet_id);
|
||||
sheets_xml.push_str(&before);
|
||||
sheet_id = next;
|
||||
let (xml, mut rels) = crate::sheet_write::write_sheet(wb, i, i == active, &mut out);
|
||||
pivots.add_sheet(wb, i, &mut out, &mut rels);
|
||||
let name = format!("xl/worksheets/sheet{}.xml", i + 1);
|
||||
|
|
@ -242,8 +248,10 @@ pub fn write_xlsx(wb: &Workbook, kind: XlsxKind) -> Result<Vec<u8>, IoError> {
|
|||
Visibility::VeryHidden => " state=\"veryHidden\"",
|
||||
};
|
||||
let name: String = sheet.name.chars().take(31).collect();
|
||||
let _ = write!(sheets_xml, "<sheet name=\"{}\" sheetId=\"{}\"{state} r:id=\"{rid}\"/>", esc_attr(&name), i + 1);
|
||||
let _ = write!(sheets_xml, "<sheet name=\"{}\" sheetId=\"{sheet_id}\"{state} r:id=\"{rid}\"/>", esc_attr(&name));
|
||||
sheet_id += 1;
|
||||
}
|
||||
sheets_xml.push_str(&kept.sheet_entries(wb.sheets.len(), true, sheet_id).0);
|
||||
|
||||
// Defined names, then print areas and titles.
|
||||
let mut names = String::new();
|
||||
|
|
@ -259,7 +267,7 @@ pub fn write_xlsx(wb: &Workbook, kind: XlsxKind) -> Result<Vec<u8>, IoError> {
|
|||
}
|
||||
let _ = write!(names, "<definedName name=\"{}\"", esc_attr(&n.name));
|
||||
if let Some(sc) = n.scope {
|
||||
let _ = write!(names, " localSheetId=\"{sc}\"");
|
||||
let _ = write!(names, " localSheetId=\"{}\"", kept.file_index(sc));
|
||||
}
|
||||
if n.hidden {
|
||||
names.push_str(" hidden=\"1\"");
|
||||
|
|
@ -280,7 +288,13 @@ pub fn write_xlsx(wb: &Workbook, kind: XlsxKind) -> Result<Vec<u8>, IoError> {
|
|||
for (i, sheet) in wb.sheets.iter().enumerate() {
|
||||
let q = quote_sheet(&sheet.name);
|
||||
if let Some(pa) = &sheet.print.print_area {
|
||||
let _ = write!(names, "<definedName name=\"_xlnm.Print_Area\" localSheetId=\"{i}\">{}!{}</definedName>", esc(&q), abs_range(pa));
|
||||
let _ = write!(
|
||||
names,
|
||||
"<definedName name=\"_xlnm.Print_Area\" localSheetId=\"{}\">{}!{}</definedName>",
|
||||
kept.file_index(i),
|
||||
esc(&q),
|
||||
abs_range(pa)
|
||||
);
|
||||
}
|
||||
let mut titles = vec![];
|
||||
if let Some((c0, c1)) = sheet.print.title_cols {
|
||||
|
|
@ -290,7 +304,12 @@ pub fn write_xlsx(wb: &Workbook, kind: XlsxKind) -> Result<Vec<u8>, IoError> {
|
|||
titles.push(format!("{q}!{}", abs_range(&RangeRef::rows(r0.min(r1), r0.max(r1)))));
|
||||
}
|
||||
if !titles.is_empty() {
|
||||
let _ = write!(names, "<definedName name=\"_xlnm.Print_Titles\" localSheetId=\"{i}\">{}</definedName>", esc(&titles.join(",")));
|
||||
let _ = write!(
|
||||
names,
|
||||
"<definedName name=\"_xlnm.Print_Titles\" localSheetId=\"{}\">{}</definedName>",
|
||||
kept.file_index(i),
|
||||
esc(&titles.join(","))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -301,8 +320,8 @@ pub fn write_xlsx(wb: &Workbook, kind: XlsxKind) -> Result<Vec<u8>, IoError> {
|
|||
let pw = wb.structure_password.as_ref().map(|h| crate::sheet_write::password_attrs(h, "workbook")).unwrap_or_default();
|
||||
let _ = write!(w, "<workbookProtection{pw} 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));
|
||||
let _ = write!(w, "<bookViews><workbookView activeTab=\"{}\"/></bookViews><sheets>{sheets_xml}</sheets>", kept.file_index(active));
|
||||
w.push_str(&kept.elements);
|
||||
if !names.is_empty() {
|
||||
let _ = write!(w, "<definedNames>{names}</definedNames>");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Chart parity
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (trendlines: model, XLSX round-trip, drawing, Add Chart Element) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** trivial (chart sheets are kept on save, still not editable (#727); previously minor: trendlines: model, XLSX round-trip, drawing, Add Chart Element) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
|
||||
Excel's chart types and chart elements against GridCraft. Part of
|
||||
[target-app-parity.md](target-app-parity.md). **Breadth ~50%, ready ~30%, 40–60 h** (estimated).
|
||||
|
|
@ -48,7 +48,7 @@ open PRs #158, #161, and issue #190.
|
|||
| Data table under the chart | yes | **missing** |
|
||||
| Per-series / per-point fill, line, marker, effects | Format pane for every element | series colour only; Format Chart pane covers chart-level options |
|
||||
| Chart styles and colour palettes | ~16 styles × colour sets | style number stored, own palettes |
|
||||
| Chart sheets | yes | **missing** (skipped with a warning on open) |
|
||||
| Chart sheets | yes | **kept, not editable**: not shown, and written back unchanged in their place on save (#727); left out when a sheet they plot is deleted or renamed. No F11 / Move Chart > New sheet |
|
||||
| Chart templates (`.crtx`) | yes | **missing** |
|
||||
| Recommended Charts | yes | dialog with recommendations from the data's shape (own heuristic) and an All Charts list, live previews (#215) |
|
||||
| Switch Row/Column, Select Data | yes | yes (Switch on charts read from files landed in #116) |
|
||||
|
|
@ -80,6 +80,7 @@ back.
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | trivial | chart sheets are kept on save, still not editable (#727) |
|
||||
| 2026-10-11 | minor | Trendlines: all six types modelled, round-tripped through `c:trendline`, drawn with forecast and equation/R² labels; `chart.addElement` `trendline` |
|
||||
| 2026-10-11 | minor | PivotCharts (`insert.pivotChart`) |
|
||||
| 2026-10-11 | minor | Recommended Charts dialog with previews (#215) |
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
# File-format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** trivial (XLSX chart sheets are kept on save (#727); previously minor: CSV save keeps the separator it was opened with) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** trivial (CSV and TSV export is capped at 512 MiB and refuses a larger used range with an error (#528); previously minor: CSV save keeps the separator it was opened with) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (`.xls` import reads the calculation settings) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (editable ink round-trips as DrawingML geometry) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
|
|
@ -26,7 +27,7 @@ and repair-free opening remain unverified.
|
|||
|
||||
| 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, 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), and chart sheets (#727: the sheet, drawing, chart, style and colour parts are written back unchanged in their place, and left out when a sheet they plot was deleted or renamed or a worksheet took their name; they can't be edited or created); 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 |
|
||||
|
|
@ -89,6 +90,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | trivial | XLSX chart sheets are kept on save (#727) |
|
||||
| 2026-10-12 | trivial | CSV and TSV export is capped at 512 MiB and refuses a larger used range with an error (#528) |
|
||||
| 2026-10-12 | minor | `.xls` import reads the calculation settings (CalcMode, CalcIter, CalcCount, CalcDelta, CalcPrecision), so circular models calculate instead of showing `#CIRC!` (#724) |
|
||||
| 2026-10-12 | minor | Ink paths and editing survive XLSX save/reopen; strict polyline import and malformed-input controls |
|
||||
|
|
|
|||
Loading…
Reference in New Issue