Files
gridcraft/crates/xlsx/src/sheet_write.rs
T
prxnvt dcefd523eb Merge w/objects (082e3794, main 25881a8e) into w/form-controls
ROADMAP.md: both header changes (form controls first, then main's chart data tables), the form-controls progress-log and revision rows on top of main's rows.
docs/file-format-parity.md: both header changes (form controls first, then main's page order and chart data tables), the form-controls revision row on top of main's rows.
docs/macros.md: both header changes (form buttons first, then main's step-editor close rule).
docs/target-app-parity.md: both header changes (form controls and catalog 280 / 296 first, then main's ToolPak, page order and data tables), the form-controls revision row on top of main's rows.
docs/ui-parity.md: both header changes (form controls first, then main's Trace Error and page order; catalog count 280 / 296), the form-controls revision row on top of main's rows.
crates/model/src/sheet.rs: the push_object test helper (w/objects, b874f586) gets main's new Chart field, data_table: None, so the model crate's tests compile.
crates/ui-egui/tests/it/chart_data_table.rs: main's new test selects its chart through select_object_id, the one selection of w/objects, instead of the selected_chart field that w/objects replaced.
2026-11-02 01:47:07 -05:00

839 lines
36 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Writing worksheet parts and the parts they own (tables, comments, drawings, charts).
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::fmt::Write as _;
use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value};
use gridcraft_model::{
Cell, CfOperator, CfRule, CfValueKind, Color, CondFormat, ErrorStyle, FilterCriterion, LineInfo, NoteBox, NoteCorner, Orientation, PageOrder,
Sheet, SparklineKind, Style, Table, TotalsFn, Validation, ValidationKind, Workbook, default_icon_thresholds, icon_count,
};
use crate::drawing::{Obj, image_content_type, image_ext};
use crate::fmla::text_to_file;
use crate::styles::{color_attrs, dxf_id};
use crate::tables::{paper_id, px_to_col_width, px_to_pt};
use crate::write::{
CT_CHART, CT_COMMENTS, CT_DRAWING, CT_TABLE, CT_THREADED_COMMENTS, NS_MAIN, NS_REL, NS_THREADED, Out, REL_THREADED_COMMENT, Rels, XML_DECL,
};
use crate::xml::{encode_xstring, esc, esc_attr, num, push_a1, push_u64};
mod parts;
mod rules;
pub(crate) use parts::password_attrs;
use parts::{autofilter_xml, comments_xml, protected_ranges_xml, table_columns, table_name, table_xml, thread_ids, threaded_xml, vml_xml};
use rules::{cf_rule_xml, solid_bar_id, validation_xml, x14_cfvo};
fn sqref(ranges: &[RangeRef]) -> String {
ranges.iter().map(|r| r.a1()).collect::<Vec<_>>().join(" ")
}
pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> (String, Rels) {
let mut rels = Rels::default();
let Some(sheet) = wb.sheet(si) else { return (String::new(), rels) };
let nstyles = wb.styles.len() as u32;
let sid = |s: gridcraft_model::StyleId| if s.0 < nstyles { s.0 } else { 0 };
// A table's dropdown filter is kept in `sheet.autofilter`, not in `Table::filter` (the
// engine's only live filter state, which the reader also fills). Where the two coincide,
// fold the sheet filter's criteria into the table so they reach the file: the stored
// `colId` offsets are relative to the range's first column, and both ranges start at the
// same column, so they carry over verbatim. A filter that only partially covers a table
// has nowhere valid to go, so it keeps being suppressed below.
let folded: Vec<Table> = match &sheet.autofilter {
Some(af) if !af.criteria.is_empty() => sheet
.tables
.iter()
.cloned()
.map(|mut t| {
if t.header_row
&& t.filter_button
&& t.filter.is_empty()
&& t.range.contains_range(&af.range)
&& t.range.start.col == af.range.start.col
{
t.filter = af.criteria.clone();
}
t
})
.collect(),
_ => sheet.tables.clone(),
};
// Table header cells must hold the column names.
let mut overrides: BTreeMap<CellRef, String> = BTreeMap::new();
let tables: Vec<(&Table, Vec<String>)> = folded
.iter()
.filter(|t| t.range.height() > t.header_row as u32 + t.totals_row as u32 || (t.header_row && t.range.height() > t.totals_row as u32))
.map(|t| (t, table_columns(t)))
.collect();
for (t, cols) in &tables {
if t.header_row {
for (i, name) in cols.iter().enumerate() {
overrides.insert(CellRef::new(t.range.start.row, t.range.start.col + i as u32), name.clone());
}
}
}
// Dynamic-array spills: anchor → range, and the spilled values to cache.
let dynamic: BTreeMap<CellRef, RangeRef> = sheet
.spill_ranges
.iter()
.filter(|(a, r)| !r.is_single() && sheet.cells.get(**a).is_some_and(|c| c.formula.as_ref().is_some_and(|f| f.array.is_none())))
.map(|(a, r)| (*a, *r))
.collect();
let mut extra: BTreeMap<CellRef, Value> = BTreeMap::new();
// Values inside spills and legacy array ranges.
for (a, r) in sheet.spill_ranges.iter().filter(|(_, r)| !r.is_single()) {
for c in r.iter().take(1_000_000) {
if c != *a
&& let Some(v) = sheet.spill.get(&c)
&& sheet.cells.get(c).is_none_or(|x| x.formula.is_none() && x.value.is_empty())
{
extra.insert(c, v.clone());
}
}
}
let mut s = format!("{XML_DECL}<worksheet xmlns=\"{NS_MAIN}\" xmlns:r=\"{NS_REL}\">");
// sheetPr
let fit = sheet.print.fit_to.is_some();
let summary = !sheet.summary_below || !sheet.summary_right;
if sheet.tab_color.is_some() || fit || summary {
s.push_str("<sheetPr>");
if let Some(c) = &sheet.tab_color {
let _ = write!(s, "<tabColor {}/>", color_attrs(c));
}
if summary {
s.push_str("<outlinePr");
for (key, on) in [("summaryBelow", sheet.summary_below), ("summaryRight", sheet.summary_right)] {
if !on {
let _ = write!(s, " {key}=\"0\"");
}
}
s.push_str("/>");
}
if fit {
s.push_str("<pageSetUpPr fitToPage=\"1\"/>");
}
s.push_str("</sheetPr>");
}
let mut used = sheet.cells.used_range();
for c in extra.keys().chain(overrides.keys()) {
let r = RangeRef::cell(*c);
used = Some(used.map_or(r, |u| u.union(&r)));
}
let _ = write!(s, "<dimension ref=\"{}\"/>", used.map(|u| u.a1()).unwrap_or_else(|| "A1".into()));
write_sheet_views(&mut s, sheet, selected);
// sheetFormatPr
let max_row_outline = sheet.rows.values().map(|r| r.outline).max().unwrap_or(0);
let max_col_outline = sheet.cols.values().map(|r| r.outline).max().unwrap_or(0);
s.push_str("<sheetFormatPr");
if (sheet.default_col_width - gridcraft_model::DEFAULT_COL_WIDTH).abs() > 0.01 {
let _ = write!(s, " defaultColWidth=\"{}\"", num(px_to_col_width(sheet.default_col_width)));
}
let _ = write!(s, " defaultRowHeight=\"{}\"", num(px_to_pt(sheet.default_row_height)));
if (sheet.default_row_height - gridcraft_model::DEFAULT_ROW_HEIGHT).abs() > 0.01 {
s.push_str(" customHeight=\"1\"");
}
if max_row_outline > 0 {
let _ = write!(s, " outlineLevelRow=\"{max_row_outline}\"");
}
if max_col_outline > 0 {
let _ = write!(s, " outlineLevelCol=\"{max_col_outline}\"");
}
s.push_str("/>");
// cols: runs of identical settings.
if !sheet.cols.is_empty() {
s.push_str("<cols>");
let mut run: Option<(u32, u32, LineInfo)> = None;
let flush = |s: &mut String, r: (u32, u32, LineInfo)| {
let (a, b, i) = r;
let w = i.size.unwrap_or(sheet.default_col_width);
let _ = write!(s, "<col min=\"{}\" max=\"{}\" width=\"{}\"", a + 1, b + 1, num(px_to_col_width(w)));
if let Some(st) = i.style {
let _ = write!(s, " style=\"{}\"", sid(st));
}
if i.hidden {
s.push_str(" hidden=\"1\"");
}
if i.size.is_some() {
s.push_str(" customWidth=\"1\"");
}
if i.outline > 0 {
let _ = write!(s, " outlineLevel=\"{}\"", i.outline.min(7));
}
if i.collapsed {
s.push_str(" collapsed=\"1\"");
}
s.push_str("/>");
};
for (&c, info) in &sheet.cols {
if c >= gridcraft_core::MAX_COLS {
continue;
}
run = match run {
Some((a, b, i)) if b + 1 == c && i == *info => Some((a, c, i)),
Some(r) => {
flush(&mut s, r);
Some((c, c, *info))
}
None => Some((c, c, *info)),
};
}
if let Some(r) = run {
flush(&mut s, r);
}
s.push_str("</cols>");
}
write_sheet_data(&mut s, SheetCellData { workbook: wb, index: si, sheet, overrides: &overrides, extra: &extra, dynamic: &dynamic }, &sid, out);
let prio = write_sheet_rules(&mut s, sheet, &mut rels, out);
let kept = write_sheet_printing(&mut s, sheet, &mut rels, out);
write_sheet_drawing(&mut s, wb, si, sheet, &mut rels, out);
write_sheet_notes(&mut s, sheet, si, &kept, &mut rels, out);
// Tables.
if !tables.is_empty() {
let mut parts = String::new();
let mut used_names: HashSet<String> = HashSet::new();
for (t, cols) in &tables {
out.tables += 1;
out.table_id += 1;
let n = out.tables;
let name = table_name(&t.name, &mut used_names, out.table_id);
let xml = table_xml(t, cols, &name, out.table_id, out);
out.part(&format!("xl/tables/table{n}.xml"), Some(CT_TABLE), xml.into_bytes());
let rid = rels.add("table", &format!("../tables/table{n}.xml"));
let _ = write!(parts, "<tablePart r:id=\"{rid}\"/>");
}
let _ = write!(s, "<tableParts count=\"{}\">{parts}</tableParts>", tables.len());
}
write_sheet_extensions(&mut s, sheet, &prio);
s.push_str("</worksheet>");
(s, rels)
}
struct CellWriteInput<'a> {
reference: CellRef,
cell: Option<&'a Cell>,
picture: Option<&'a std::sync::Arc<gridcraft_model::CellPicture>>,
header_override: Option<&'a String>,
spill_value: Option<&'a Value>,
dynamic_range: Option<&'a RangeRef>,
}
fn write_sheet_extensions(s: &mut String, sheet: &Sheet, prio: &[u32]) {
// Excel 2010 extensions: solid data bars, then sparklines.
let solid_bars: Vec<(&CondFormat, u32)> = sheet
.cond_formats
.iter()
.enumerate()
.filter(|(_, cf)| !cf.ranges.is_empty() && matches!(cf.rule, CfRule::DataBar { gradient: false, .. }))
.map(|(i, cf)| (cf, prio.get(i).copied().unwrap_or(1)))
.collect();
if !solid_bars.is_empty() || !sheet.sparklines.is_empty() {
s.push_str("<extLst>");
}
if !solid_bars.is_empty() {
let _ = write!(
s,
"<ext uri=\"{{78C0D931-6437-407d-A8A1-0F90B4350D7D}}\" xmlns:x14=\"{X14_NS}\" xmlns:xm=\"{XM_NS}\"><x14:conditionalFormattings>"
);
for (cf, p) in &solid_bars {
if let CfRule::DataBar { min, max, .. } = &cf.rule {
let (id, lo, hi) = (solid_bar_id(*p), x14_cfvo(min), x14_cfvo(max));
let _ = write!(s, "<x14:conditionalFormatting><x14:cfRule type=\"dataBar\" id=\"{id}\"><x14:dataBar gradient=\"0\">{lo}{hi}");
let _ = write!(s, "</x14:dataBar></x14:cfRule><xm:sqref>{}</xm:sqref></x14:conditionalFormatting>", sqref(&cf.ranges));
}
}
s.push_str("</x14:conditionalFormattings></ext>");
}
if !sheet.sparklines.is_empty() {
let _ = write!(s, "<ext uri=\"{{05C60535-1F16-4fd2-B633-F4F36F0B64E0}}\" xmlns:x14=\"{X14_NS}\"><x14:sparklineGroups xmlns:xm=\"{XM_NS}\">");
for sp in &sheet.sparklines {
let ty = match sp.kind {
SparklineKind::Line => "",
SparklineKind::Column => " type=\"column\"",
SparklineKind::WinLoss => " type=\"stacked\"",
};
let src =
if sp.source.contains('!') { sp.source.clone() } else { format!("{}!{}", gridcraft_formula::quote_sheet(&sheet.name), sp.source) };
let color = if sp.color == Color::Auto { "rgb=\"FF376092\"".to_string() } else { color_attrs(&sp.color) };
let _ = write!(
s,
"<x14:sparklineGroup displayEmptyCellsAs=\"gap\"{ty}{}><x14:colorSeries {color}/><x14:sparklines><x14:sparkline><xm:f>{}</xm:f><xm:sqref>{}</xm:sqref></x14:sparkline></x14:sparklines></x14:sparklineGroup>",
if sp.markers { " markers=\"1\"" } else { "" },
esc(&src),
sp.cell.a1()
);
}
s.push_str("</x14:sparklineGroups></ext>");
}
if !solid_bars.is_empty() || !sheet.sparklines.is_empty() {
s.push_str("</extLst>");
}
}
fn write_sheet_notes(s: &mut String, sheet: &Sheet, si: usize, kept: &crate::passthrough::SheetKept<'_>, rels: &mut Rels, out: &mut Out) {
// Notes and form controls share one legacy VML drawing: notes need it for Excel to show them,
// and controls carry their `x:ClientData` there. Shape ids come from 1024-id blocks listed in
// the drawing's idmap (after the blocks kept header and footer drawings use), notes first, so
// a control's `shapeId` names its VML shape. Threaded comments also get a legacy note (its
// text with the replies as lines, for readers without threaded comments) linked by id.
let mut controls = String::new();
if !sheet.comments.is_empty() || !sheet.controls.is_empty() {
let shapes = (sheet.comments.len() as u64).saturating_add(sheet.controls.len() as u64);
let blocks = u32::try_from(shapes.saturating_add(1).div_ceil(1024)).unwrap_or(u32::MAX).max(1);
let first = out.kept.notes_block(out.vml_block.saturating_add(1));
out.vml_block = out.vml_block.saturating_add(blocks);
let idmap = (first..first.saturating_add(blocks)).map(|b| b.to_string()).collect::<Vec<_>>().join(",");
// With a kept drawing, our shapes join it in a block of their own.
let base = kept.shape_base().unwrap_or(u64::from(first) * 1024);
let notes = sheet.comments.len() as u64;
let mut control_shapes = String::new();
if !sheet.controls.is_empty() {
let w = crate::controls::write(out, rels, sheet, |i| base + 1 + notes + i as u64);
controls = w.entries;
control_shapes = format!("{}{}", crate::controls::SHAPETYPE, w.vml);
}
if !sheet.comments.is_empty() {
out.comments += 1;
let n = out.comments;
let ids = thread_ids(sheet, si);
out.part(&format!("xl/comments{n}.xml"), Some(CT_COMMENTS), comments_xml(sheet, &ids).into_bytes());
if !ids.is_empty() {
let x = threaded_xml(sheet, &ids, &mut out.persons);
out.part(&format!("xl/threadedComments/threadedComment{n}.xml"), Some(CT_THREADED_COMMENTS), x.into_bytes());
rels.add_uri(REL_THREADED_COMMENT, &format!("../threadedComments/threadedComment{n}.xml"));
}
rels.add("comments", &format!("../comments{n}.xml"));
}
out.vmls += 1;
let v = out.vmls;
out.default_type("vml", "application/vnd.openxmlformats-officedocument.vmlDrawing");
let vml_name = format!("xl/drawings/vmlDrawing{v}.vml");
let vml = kept.notes_vml(vml_xml(sheet, &idmap, base, &control_shapes), &vml_name, out);
out.part(&vml_name, None, vml.into_bytes());
let rid = rels.add("vmlDrawing", &format!("../drawings/vmlDrawing{v}.vml"));
let _ = write!(s, "<legacyDrawing r:id=\"{rid}\"/>");
} else if let Some(rid) = kept.lone_vml(rels, out) {
let _ = write!(s, "<legacyDrawing r:id=\"{rid}\"/>");
}
s.push_str(&kept.elements(&controls));
}
fn write_sheet_drawing(s: &mut String, wb: &Workbook, si: usize, sheet: &Sheet, rels: &mut Rels, out: &mut Out) {
// Drawing: pictures, charts, shapes.
if !sheet.images.is_empty() || !sheet.charts.is_empty() || !sheet.shapes.is_empty() {
out.drawings += 1;
let dn = out.drawings;
let mut drels = Rels::default();
let mut objs: Vec<Obj<'_>> = vec![];
for img in &sheet.images {
if img.data.is_empty() {
continue;
}
let ext = image_ext(&img.mime, &img.data);
out.default_type(ext, image_content_type(ext));
let file = out.image_part(ext, &img.data);
let rid = drels.add("image", &format!("../media/{file}"));
objs.push(Obj::Image(img, rid));
}
for ch in &sheet.charts {
out.charts += 1;
let rid = if crate::chartex::is_chartex(ch.kind) {
let n = out.charts;
let xml = crate::chartex::write_chartex(wb, si, ch, &mut out.chart_names);
out.part(&format!("xl/charts/chartEx{n}.xml"), Some(crate::chartex::CT_CHARTEX), xml.into_bytes());
// Excel expects a chartex part's style and colour parts.
out.part(&format!("xl/charts/style{n}.xml"), Some(crate::chartex::CT_STYLE), crate::chartex::style_xml().into_bytes());
out.part(&format!("xl/charts/colors{n}.xml"), Some(crate::chartex::CT_COLORS), crate::chartex::colors_xml().into_bytes());
let mut crels = Rels::default();
crels.add_uri(crate::chartex::REL_STYLE, &format!("style{n}.xml"));
crels.add_uri(crate::chartex::REL_COLORS, &format!("colors{n}.xml"));
out.part(&format!("xl/charts/_rels/chartEx{n}.xml.rels"), None, crels.xml().into_bytes());
drels.add_uri(crate::chartex::REL_CHARTEX, &format!("../charts/chartEx{n}.xml"))
} else {
let name = format!("xl/charts/chart{}.xml", out.charts);
out.part(&name, Some(CT_CHART), crate::chart::write_chart(wb, si, ch, &out.book_name).into_bytes());
drels.add("chart", &format!("../charts/chart{}.xml", out.charts))
};
objs.push(Obj::Chart(ch, rid));
}
for sh in &sheet.shapes {
objs.push(Obj::Shape(sh));
}
out.part(&format!("xl/drawings/drawing{dn}.xml"), Some(CT_DRAWING), crate::drawing::write_drawing(sheet, &wb.theme, &objs).into_bytes());
if !drels.is_empty() {
out.part(&format!("xl/drawings/_rels/drawing{dn}.xml.rels"), None, drels.xml().into_bytes());
}
let rid = rels.add("drawing", &format!("../drawings/drawing{dn}.xml"));
let _ = write!(s, "<drawing r:id=\"{rid}\"/>");
}
}
fn write_sheet_printing<'a>(s: &mut String, sheet: &'a Sheet, rels: &mut Rels, out: &mut Out) -> crate::passthrough::SheetKept<'a> {
// Printing.
let p = &sheet.print;
if p.gridlines || p.headings || p.center_h || p.center_v {
s.push_str("<printOptions");
for (a, v) in [("horizontalCentered", p.center_h), ("verticalCentered", p.center_v), ("headings", p.headings), ("gridLines", p.gridlines)] {
if v {
let _ = write!(s, " {a}=\"1\"");
}
}
s.push_str("/>");
}
// Unmodelled content kept from the source file (printer settings, controls, OLE objects…).
let kept = crate::passthrough::SheetKept::write(sheet, 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));
s.push_str("<pageSetup");
if let Some(id) = paper_id(&p.paper)
&& id != 1
{
let _ = write!(s, " paperSize=\"{id}\"");
}
if p.scale != 100 && (10..=400).contains(&p.scale) {
let _ = write!(s, " scale=\"{}\"", p.scale);
}
if let Some((w, h)) = p.fit_to {
let _ = write!(s, " fitToWidth=\"{w}\" fitToHeight=\"{h}\"");
}
// Only "over, then down" is written, as Excel does: `downThenOver` is the schema default.
if p.page_order != PageOrder::DownThenOver {
let _ = write!(s, " pageOrder=\"{}\"", p.page_order.id());
}
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.has_header_footer() || p.different_first || p.different_odd_even || !p.hf_scale_with_doc || !p.hf_align_with_margins {
s.push_str("<headerFooter");
for (attr, on, default) in [
("differentOddEven", p.different_odd_even, false),
("differentFirst", p.different_first, false),
("scaleWithDoc", p.hf_scale_with_doc, true),
("alignWithMargins", p.hf_align_with_margins, true),
] {
if on != default {
let _ = write!(s, " {attr}=\"{}\"", u8::from(on));
}
}
s.push('>');
// CT_HeaderFooter's element order.
for (tag, text) in [
("oddHeader", &p.header),
("oddFooter", &p.footer),
("evenHeader", &p.even_header),
("evenFooter", &p.even_footer),
("firstHeader", &p.first_header),
("firstFooter", &p.first_footer),
] {
if !text.is_empty() {
let _ = write!(s, "<{tag}>{}</{tag}>", esc(text));
}
}
s.push_str("</headerFooter>");
}
for (tag, list, max) in [("rowBreaks", &p.row_breaks, 16383u32), ("colBreaks", &p.col_breaks, 1048575)] {
let list: Vec<u32> = list.iter().copied().filter(|&b| b > 0).collect();
if !list.is_empty() {
let _ = write!(s, "<{tag} count=\"{0}\" manualBreakCount=\"{0}\">", list.len());
for b in list {
let _ = write!(s, "<brk id=\"{b}\" max=\"{max}\" man=\"1\"/>");
}
let _ = write!(s, "</{tag}>");
}
}
kept
}
fn write_sheet_rules(s: &mut String, sheet: &Sheet, rels: &mut Rels, out: &mut Out) -> Vec<u32> {
// sheetProtection
if let Some(p) = &sheet.protection {
s.push_str("<sheetProtection sheet=\"1\" objects=\"1\" scenarios=\"1\"");
for (attr, allowed) in [
("formatCells", p.format_cells),
("formatColumns", p.format_columns),
("formatRows", p.format_rows),
("insertColumns", p.insert_columns),
("insertRows", p.insert_rows),
("deleteColumns", p.delete_columns),
("deleteRows", p.delete_rows),
("sort", p.sort),
("autoFilter", p.autofilter),
] {
if allowed {
let _ = write!(s, " {attr}=\"0\"");
}
}
if !p.select_locked {
s.push_str(" selectLockedCells=\"1\"");
}
if !p.select_unlocked {
s.push_str(" selectUnlockedCells=\"1\"");
}
if let Some(h) = &p.password {
s.push_str(&password_attrs(h, ""));
}
s.push_str("/>");
}
s.push_str(&protected_ranges_xml(sheet));
// autoFilter (a sheet filter overlapping a table would be invalid).
if let Some(af) = &sheet.autofilter
&& !sheet.tables.iter().any(|t| t.range.intersects(&af.range))
{
s.push_str(&autofilter_xml(af.range, &af.criteria, out));
}
if !sheet.merges.is_empty() {
let merges: Vec<&RangeRef> = sheet.merges.iter().filter(|m| !m.is_single()).collect();
let _ = write!(s, "<mergeCells count=\"{}\">", merges.len());
for m in merges {
let _ = write!(s, "<mergeCell ref=\"{}\"/>", m.a1());
}
s.push_str("</mergeCells>");
}
// Conditional formats, priorities renumbered 1..n in priority order.
let mut order: Vec<usize> = (0..sheet.cond_formats.len()).collect();
order.sort_by_key(|&i| sheet.cond_formats.get(i).map(|c| c.priority).unwrap_or(0));
let mut prio = vec![0u32; sheet.cond_formats.len()];
for (rank, &i) in order.iter().enumerate() {
if let Some(p) = prio.get_mut(i) {
*p = rank as u32 + 1;
}
}
for (i, cf) in sheet.cond_formats.iter().enumerate() {
if cf.ranges.is_empty() {
continue;
}
let _ = write!(
s,
"<conditionalFormatting sqref=\"{}\">{}</conditionalFormatting>",
sqref(&cf.ranges),
cf_rule_xml(cf, prio.get(i).copied().unwrap_or(1), out)
);
}
let vals: Vec<&Validation> = sheet.validations.iter().filter(|v| !v.ranges.is_empty()).collect();
if !vals.is_empty() {
let _ = write!(s, "<dataValidations count=\"{}\">", vals.len());
for v in vals {
s.push_str(&validation_xml(v));
}
s.push_str("</dataValidations>");
}
if !sheet.hyperlinks.is_empty() {
s.push_str("<hyperlinks>");
for (c, h) in &sheet.hyperlinks {
let t = h.target.trim();
if t.is_empty() {
continue;
}
// `external` is what the file said, or what Insert › Link chose; the scheme test covers
// links made before the flag existed.
let external = h.external
|| t.contains("://")
|| t.to_ascii_lowercase().starts_with("mailto:")
|| t.starts_with("\\\\")
|| t.to_ascii_lowercase().starts_with("file:");
let _ = write!(s, "<hyperlink ref=\"{}\"", c.a1());
if external {
let (url, loc) = match t.split_once('#') {
Some((u, l)) if !u.is_empty() => (u, Some(l)),
_ => (t, None),
};
let id = rels.add_external("hyperlink", url);
let _ = write!(s, " r:id=\"{id}\"");
if let Some(l) = loc {
let _ = write!(s, " location=\"{}\"", esc_attr(l));
}
} else {
let _ = write!(s, " location=\"{}\" display=\"{}\"", esc_attr(t.trim_start_matches('#')), esc_attr(t.trim_start_matches('#')));
}
if let Some(tt) = &h.tooltip {
let _ = write!(s, " tooltip=\"{}\"", esc_attr(tt));
}
s.push_str("/>");
}
s.push_str("</hyperlinks>");
}
prio
}
struct SheetCellData<'a> {
workbook: &'a Workbook,
index: usize,
sheet: &'a Sheet,
overrides: &'a BTreeMap<CellRef, String>,
extra: &'a BTreeMap<CellRef, Value>,
dynamic: &'a BTreeMap<CellRef, RangeRef>,
}
fn write_sheet_data(s: &mut String, data: SheetCellData<'_>, sid: &dyn Fn(gridcraft_model::StyleId) -> u32, out: &mut Out) {
let SheetCellData { workbook, index, sheet, overrides, extra, dynamic } = data;
// Cell storage is already row-major; sort only when extra cells need merging.
let mut keys: Vec<(CellRef, Option<&Cell>)> = Vec::with_capacity(sheet.cells.len());
keys.extend(sheet.cells.iter().map(|(c, cell)| (c, Some(cell))));
if !sheet.cell_pictures.is_empty() || !extra.is_empty() || !overrides.is_empty() || !keys.is_sorted_by_key(|(c, _)| *c) {
let mut all: BTreeSet<CellRef> = keys.iter().map(|(c, _)| *c).collect();
all.extend(sheet.cell_pictures.keys().copied());
all.extend(extra.keys().copied());
all.extend(overrides.keys().copied());
keys = all.into_iter().map(|c| (c, sheet.cells.get(c))).collect();
}
s.reserve(keys.len().saturating_mul(32));
s.push_str("<sheetData>");
let mut row_infos = sheet.rows.iter().peekable();
let mut next = 0;
loop {
let cell_row = keys.get(next).map(|(cell, _)| cell.row);
let info_row = row_infos.peek().map(|(row, _)| **row);
let row = match (cell_row, info_row) {
(Some(a), Some(b)) => a.min(b),
(Some(a), None) => a,
(None, Some(b)) => b,
(None, None) => break,
};
let info = match row_infos.next_if(|(entry_row, _)| **entry_row == row) {
Some((_, info)) => *info,
None => LineInfo::default(),
};
let first = next;
while keys.get(next).is_some_and(|(cell, _)| cell.row == row) {
next += 1;
}
let cells = keys.get(first..next).unwrap_or_default();
if cells.is_empty() && info == LineInfo::default() {
continue;
}
s.push_str("<row r=\"");
push_u64(s, u64::from(row) + 1);
s.push('"');
if let Some(st) = info.style {
let _ = write!(s, " s=\"{}\" customFormat=\"1\"", sid(st));
}
if let Some(h) = info.size {
let _ = write!(s, " ht=\"{}\"{}", num(px_to_pt(h)), if info.custom { " customHeight=\"1\"" } else { "" });
}
if info.hidden {
s.push_str(" hidden=\"1\"");
}
if info.outline > 0 {
let _ = write!(s, " outlineLevel=\"{}\"", info.outline.min(7));
}
if info.collapsed {
s.push_str(" collapsed=\"1\"");
}
if cells.is_empty() {
s.push_str("/>");
continue;
}
s.push('>');
for &(cell_ref, cell) in cells {
let ov = overrides.get(&cell_ref);
let ex = extra.get(&cell_ref);
let array = dynamic.get(&cell_ref).copied().or_else(|| {
let f = cell?.formula.as_ref()?;
(f.array.is_none() && crate::array_formula::needs_array(workbook, index, cell_ref, f)).then_some(RangeRef::cell(cell_ref))
});
write_cell(
s,
CellWriteInput {
reference: cell_ref,
cell,
picture: sheet.cell_pictures.get(&cell_ref),
header_override: ov,
spill_value: ex,
dynamic_range: array.as_ref(),
},
sid,
out,
);
}
s.push_str("</row>");
}
s.push_str("</sheetData>");
}
fn write_sheet_views(s: &mut String, sheet: &Sheet, selected: bool) {
s.push_str("<sheetViews><sheetView");
if selected {
s.push_str(" tabSelected=\"1\"");
}
if !sheet.show_gridlines {
s.push_str(" showGridLines=\"0\"");
}
if !sheet.show_headings {
s.push_str(" showRowColHeaders=\"0\"");
}
if sheet.show_formulas {
s.push_str(" showFormulas=\"1\"");
}
if !sheet.show_zeros {
s.push_str(" showZeros=\"0\"");
}
if !sheet.show_outline_symbols {
s.push_str(" showOutlineSymbols=\"0\"");
}
if sheet.right_to_left {
s.push_str(" rightToLeft=\"1\"");
}
if sheet.view != gridcraft_model::SheetViewMode::Normal {
let _ = write!(s, " view=\"{}\"", sheet.view.as_str());
}
if sheet.zoom != 100 && (10..=400).contains(&sheet.zoom) {
let _ = write!(s, " zoomScale=\"{0}\" zoomScaleNormal=\"{0}\"", sheet.zoom);
}
if sheet.view_top_left != CellRef::default() && sheet.freeze.is_none() {
let _ = write!(s, " topLeftCell=\"{}\"", sheet.view_top_left.a1());
}
s.push_str(" workbookViewId=\"0\">");
let active = sheet.view_active.a1();
match sheet.freeze {
Some((rows, cols)) if rows > 0 || cols > 0 => {
let pane = match (rows > 0, cols > 0) {
(true, true) => "bottomRight",
(true, false) => "bottomLeft",
_ => "topRight",
};
s.push_str("<pane");
if cols > 0 {
let _ = write!(s, " xSplit=\"{cols}\"");
}
if rows > 0 {
let _ = write!(s, " ySplit=\"{rows}\"");
}
let _ = write!(
s,
" topLeftCell=\"{}\" activePane=\"{pane}\" state=\"frozen\"/>",
CellRef::new(
rows.max(sheet.view_top_left.row).min(gridcraft_core::MAX_ROWS - 1),
cols.max(sheet.view_top_left.col).min(gridcraft_core::MAX_COLS - 1)
)
.a1()
);
let _ = write!(s, "<selection pane=\"{pane}\" activeCell=\"{active}\" sqref=\"{active}\"/>");
}
_ => {
let _ = write!(s, "<selection activeCell=\"{active}\" sqref=\"{active}\"/>");
}
}
s.push_str("</sheetView></sheetViews>");
}
fn write_cell(s: &mut String, input: CellWriteInput<'_>, sid: &dyn Fn(gridcraft_model::StyleId) -> u32, out: &mut Out) {
let CellWriteInput { reference: c, cell, picture, header_override: ov, spill_value: extra, dynamic_range: dynamic } = input;
let style = cell.map(|x| sid(x.style)).unwrap_or(0);
s.push_str("<c r=\"");
push_a1(s, c);
s.push('"');
if style != 0 {
let _ = write!(s, " s=\"{style}\"");
}
if let Some(picture) = picture {
let vm = out.cell_pictures.index(picture);
let _ = write!(s, " t=\"e\" vm=\"{vm}\"><v>#VALUE!</v></c>");
return;
}
let formula = if ov.is_some() { None } else { cell.and_then(|x| x.formula.as_ref()) };
let value: Value = match (ov, extra) {
(Some(t), _) => Value::text(t.as_str()),
(None, Some(v)) => v.clone(),
_ => cell.map(|x| x.value.scalar()).unwrap_or(Value::Empty),
};
let value = match value {
Value::Array(a) => a.get(0, 0).cloned().unwrap_or(Value::Empty),
v => v,
};
let value = match value {
Value::Error(CellError::Circ) => Value::Number(0.0),
// #SPILL! and #CALC! are not error codes of the file format (Excel refuses a file that
// caches them); Excel itself caches them as #VALUE!.
Value::Error(CellError::Spill | CellError::Calc) => Value::Error(CellError::Value),
v => v,
};
// A formula read with dynamic-array metadata keeps it, even where the shape analysis would
// not need it: Excel decided the formula is evaluated as an array.
let own = formula.filter(|f| f.dynamic && f.array.is_none()).map(|_| RangeRef::cell(c));
let dynamic = dynamic.or(own.as_ref());
// A non-spilling formula may still need array evaluation to avoid implicit intersection.
if dynamic.is_some() && formula.is_some_and(|f| f.array.is_none()) {
s.push_str(" cm=\"1\"");
out.dynamic_arrays = true;
}
if let Some(f) = formula {
let t = match &value {
Value::Text(_) => " t=\"str\"",
Value::Bool(_) => " t=\"b\"",
Value::Error(_) => " t=\"e\"",
_ => "",
};
s.push_str(t);
s.push('>');
let text = esc(&crate::fmla::to_file(f));
match (f.array, dynamic) {
(Some(r), _) => {
let _ = write!(s, "<f t=\"array\" ref=\"{}\">{text}</f>", r.a1());
}
(None, Some(r)) => {
let _ = write!(s, "<f t=\"array\" ref=\"{}\">{text}</f>", r.a1());
}
_ => {
let _ = write!(s, "<f>{text}</f>");
}
}
match &value {
Value::Number(n) => {
let _ = write!(s, "<v>{}</v>", num(*n));
}
Value::Text(t) => {
let _ = write!(s, "<v>{}</v>", esc(&encode_xstring(t)));
}
Value::Bool(b) => {
let _ = write!(s, "<v>{}</v>", *b as u8);
}
Value::Error(e) => {
let _ = write!(s, "<v>{}</v>", e.as_str());
}
_ => out.needs_calc = true,
}
s.push_str("</c>");
return;
}
match &value {
Value::Empty | Value::Array(_) => s.push_str("/>"),
Value::Number(n) => {
let _ = write!(s, "><v>{}</v></c>", num(*n));
}
Value::Text(t) => {
let i = out.sst.index(t);
let _ = write!(s, " t=\"s\"><v>{i}</v></c>");
}
Value::Bool(b) => {
let _ = write!(s, " t=\"b\"><v>{}</v></c>", *b as u8);
}
Value::Error(e) => {
let _ = write!(s, " t=\"e\"><v>{}</v></c>", e.as_str());
}
}
}
const X14_NS: &str = "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main";
const XM_NS: &str = "http://schemas.microsoft.com/office/excel/2006/main";
/// The box a note made here gets: two columns right of its cell, four rows tall, 108 x 59.25 pt.
/// A box read from a file that equals it is kept as `None`, so a note's model survives a save.
pub(crate) const DEFAULT_NOTE_BOX: NoteBox = NoteBox {
from: NoteCorner { col: 1, col_px: 15, row: 0, row_px: 2 },
to: NoteCorner { col: 3, col_px: 15, row: 4, row_px: 16 },
size_pt: Some(DEFAULT_NOTE_SIZE_PT),
};
const DEFAULT_NOTE_SIZE_PT: (f64, f64) = (108.0, 59.25);