XLSX: keep chart sheets on open and save (#727)
Chart sheets (a sheet that holds one chart; Move Chart > New sheet, F11) were skipped on open, and their chart sheet, drawing, chart, style and colour parts were dropped with them because the pass-through treats those kinds as content GridCraft rewrites. Saving a workbook lost every chart sheet. They are now kept as they were: the <sheet> entry (name, visibility and its place among the worksheets), the chartsheet part and everything behind it, byte for byte, written back in place. The workbook's other sheet positions (active tab, print areas, scoped names) are computed with the chart sheets counted. A chart sheet is left out on save when the chart is no longer valid for the workbook: a sheet it plots was deleted or renamed, or a worksheet took its name.
This commit is contained in:
parent
fc45b7f6d8
commit
c5c1763774
|
|
@ -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};
|
||||
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)]
|
||||
|
|
@ -42,6 +44,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)]
|
||||
|
|
@ -57,11 +72,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 {
|
||||
|
|
|
|||
|
|
@ -26,7 +26,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;
|
||||
|
|
@ -131,6 +131,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] = &[
|
||||
|
|
@ -195,6 +199,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,
|
||||
|
|
@ -251,11 +270,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![];
|
||||
|
|
@ -285,7 +305,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));
|
||||
|
|
@ -299,10 +319,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);
|
||||
}
|
||||
|
|
@ -317,8 +349,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");
|
||||
}
|
||||
|
|
@ -345,6 +381,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> =
|
||||
|
|
@ -839,18 +894,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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds the kept package-root relationships (custom properties…) to `_rels/.rels`.
|
||||
|
|
|
|||
|
|
@ -201,6 +201,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);
|
||||
|
|
@ -327,7 +332,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;
|
||||
|
|
|
|||
|
|
@ -483,3 +483,183 @@ fn kept_vml_drawings_are_charged_to_the_kept_total() {
|
|||
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())));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -199,9 +199,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);
|
||||
|
|
@ -217,8 +223,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();
|
||||
|
|
@ -234,7 +242,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\"");
|
||||
|
|
@ -255,7 +263,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 {
|
||||
|
|
@ -265,7 +279,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(","))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -275,8 +294,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>");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue