mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-02 07:06:48 -06:00
+3
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (CSV and text open detects each column's date order, so day-first dates are not silently swapped; earlier: Esc leaves a File-page text field before closing the view; earlier: AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column; macro engine and `macro.*` commands; macro interface on the Automate tab) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-01 · **Change:** trivial (XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged; earlier: CSV and text open detects each column's date order, so day-first dates are not silently swapped) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
|
||||
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
|
||||
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
|
||||
@@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-11-01 | XLSX keeps the first visible workbook window's active sheet and display settings together, falling back to the first view when all are hidden. Tab ratios are normalized to 0–1000 on import and export; invalid numeric text uses 600. Synthetic round trips cover isolated attributes, missing views, and active tabs after sheet deletion/reordering; firstSheet remains separate #2264 work (#3057). |
|
||||
| 2026-10-31 | Esc in File › Info, Print or Options first leaves the text field; the next Esc returns to the sheet (#3035) |
|
||||
| 2026-10-31 | CSV and text open detects each column's date order: a value that reads only day first (`31/05/2026`) makes the column day first, a value that reads only month first (`12/31/2026`) makes it month first, an all-ambiguous column keeps month first with a warning, and a column with both kinds stays text with a warning (#2765) |
|
||||
| 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before |
|
||||
@@ -247,6 +248,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-01 | trivial | Workbook views: selected window settings stay together, tab ratios are bounded, and regressions cover isolated settings and sheet edits; readiness estimates unchanged (#3057) |
|
||||
| 2026-10-31 | minor | File-page text fields keep the view open when Esc cancels an edit (#3035) |
|
||||
| 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column |
|
||||
| 2026-10-24 | minor | Macro interface on the Automate tab; progress log row |
|
||||
|
||||
@@ -2414,6 +2414,36 @@ fn deleting_a_3d_span_end_sheet_keeps_the_rest() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workbook_view_roundtrips_after_sheet_deletion_and_reordering() {
|
||||
let view = gridcraft_model::WorkbookView { show_sheet_tabs: false, show_horizontal_scroll: true, show_vertical_scroll: false, tab_ratio: 350 };
|
||||
for (initial_active, command, params, expected_order, expected_active) in [
|
||||
("Third", "home.deleteSheet", json!({"sheet": "First"}), vec!["Second", "Third"], 1),
|
||||
("Third", "home.deleteSheet", json!({"sheet": "Third"}), vec!["First", "Second"], 1),
|
||||
("First", "home.deleteSheet", json!({"sheet": "Third"}), vec!["First", "Second"], 0),
|
||||
("Third", "sheet.move", json!({"sheet": "Third", "to": 0}), vec!["Third", "First", "Second"], 0),
|
||||
("Third", "sheet.move", json!({"sheet": "First", "to": 2}), vec!["Second", "Third", "First"], 2),
|
||||
] {
|
||||
let mut session = s();
|
||||
session.execute("sheet.rename", json!({"name": "First"})).unwrap();
|
||||
for name in ["Second", "Third"] {
|
||||
session.execute("home.insertSheet", json!({"name": name})).unwrap();
|
||||
}
|
||||
std::sync::Arc::make_mut(&mut session.doc_mut().unwrap().wb).view = view;
|
||||
session.execute("sheet.activate", json!({"sheet": initial_active})).unwrap();
|
||||
session.execute(command, params).unwrap();
|
||||
|
||||
let wb = &session.doc().unwrap().wb;
|
||||
assert_eq!(wb.active_sheet(), expected_active, "{command}");
|
||||
assert_eq!(wb.sheets.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), expected_order, "{command}");
|
||||
let (back, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(wb).unwrap()).unwrap();
|
||||
assert_eq!(back.sheets.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), expected_order, "{command} after reopening");
|
||||
assert_eq!(back.active_sheet(), expected_active, "activeTab follows {command}");
|
||||
assert_eq!(back.active().unwrap().name, expected_order[expected_active], "the active sheet is not a stale index");
|
||||
assert_eq!(back.view, view, "workbook display settings survive {command}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xlsx_roundtrip_through_engine() {
|
||||
let mut s = s();
|
||||
|
||||
@@ -27,7 +27,7 @@ pub use style::{
|
||||
Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, Style, StyleId, StyleTable, Theme, Underline,
|
||||
VAlign, VertAlign,
|
||||
};
|
||||
pub use workbook::{CalcMode, CalcSettings, DefinedName, DocProps, PhoneticProps, PhoneticRun, PhoneticString, ShowObjects, Workbook};
|
||||
pub use workbook::{CalcMode, CalcSettings, DefinedName, DocProps, PhoneticProps, PhoneticRun, PhoneticString, ShowObjects, Workbook, WorkbookView};
|
||||
|
||||
/// Font used for new workbooks. Only a name: renderers substitute an available open font
|
||||
/// (e.g. the metric-compatible Carlito) when it isn't installed.
|
||||
|
||||
@@ -30,6 +30,51 @@ pub enum ShowObjects {
|
||||
None,
|
||||
}
|
||||
|
||||
/// The workbook window's display settings from `<workbookView>` (Excel: File › Options ›
|
||||
/// Advanced, "Display options for this workbook"): whether the window shows the sheet tabs
|
||||
/// and the two scroll bars, and how much of the horizontal bar the tabs take (`tabRatio`,
|
||||
/// thousandths of the bar; the format's default is 600). XLSX I/O normalizes this
|
||||
/// share to 0–1000, including values supplied directly through the model.
|
||||
///
|
||||
/// The values are modelled so an open + save keeps them — a workbook Excel saved with the
|
||||
/// tabs hidden must not come back with them shown and the settings gone. The app does not
|
||||
/// act on them yet and there is no Options control for them; that is the other half of
|
||||
/// storytold/gridcraft#3057.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WorkbookView {
|
||||
#[serde(default = "yes")]
|
||||
pub show_sheet_tabs: bool,
|
||||
#[serde(default = "yes")]
|
||||
pub show_horizontal_scroll: bool,
|
||||
#[serde(default = "yes")]
|
||||
pub show_vertical_scroll: bool,
|
||||
#[serde(default = "default_tab_ratio")]
|
||||
pub tab_ratio: u32,
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_tab_ratio() -> u32 {
|
||||
600
|
||||
}
|
||||
|
||||
impl Default for WorkbookView {
|
||||
fn default() -> Self {
|
||||
WorkbookView { show_sheet_tabs: true, show_horizontal_scroll: true, show_vertical_scroll: true, tab_ratio: default_tab_ratio() }
|
||||
}
|
||||
}
|
||||
|
||||
impl WorkbookView {
|
||||
/// The entire bar, in thousandths: the upper bound used by XLSX I/O.
|
||||
pub const MAX_TAB_RATIO: u32 = 1000;
|
||||
|
||||
fn is_default(&self) -> bool {
|
||||
*self == WorkbookView::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// File › Options › Formulas › Calculation options, stored per workbook (`<calcPr>`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CalcSettings {
|
||||
@@ -150,6 +195,9 @@ pub struct Workbook {
|
||||
pub show_objects: ShowObjects,
|
||||
/// Read and set with [`Workbook::active_sheet`] and [`Workbook::set_active_sheet`].
|
||||
pub active_sheet: ActiveSheet,
|
||||
/// The workbook window's display settings (`<workbookView>`), kept across an open + save.
|
||||
#[serde(default, skip_serializing_if = "WorkbookView::is_default")]
|
||||
pub view: WorkbookView,
|
||||
pub props: DocProps,
|
||||
/// Workbook structure protection.
|
||||
pub protected_structure: bool,
|
||||
@@ -284,6 +332,7 @@ impl Workbook {
|
||||
calc: CalcSettings::default(),
|
||||
show_objects: ShowObjects::All,
|
||||
active_sheet: ActiveSheet::default(),
|
||||
view: WorkbookView::default(),
|
||||
props: DocProps::default(),
|
||||
protected_structure: false,
|
||||
structure_password: None,
|
||||
|
||||
+23
-2
@@ -3,7 +3,9 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::{DateSystem, RangeRef};
|
||||
use gridcraft_model::{CalcMode, CellPicture, DefinedName, PhoneticString, Sheet, ShowObjects, Style, StyleId, StyleTable, Visibility, Workbook};
|
||||
use gridcraft_model::{
|
||||
CalcMode, CellPicture, DefinedName, PhoneticString, Sheet, ShowObjects, Style, StyleId, StyleTable, Visibility, Workbook, WorkbookView,
|
||||
};
|
||||
|
||||
use crate::package::{Package, Rel};
|
||||
use crate::styles::{StylesIn, read_styles};
|
||||
@@ -221,6 +223,13 @@ fn read_workbook_properties(cx: &mut Ctx<'_>, wb: &mut Workbook, xml: &El) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The single window modelled by GridCraft: prefer the first visible window,
|
||||
/// including the format's default visibility, and otherwise retain the first one.
|
||||
fn selected_workbook_view(workbook: &El) -> Option<&El> {
|
||||
let views = workbook.child("bookViews")?;
|
||||
views.kids("workbookView").find(|view| view.attr("visibility").unwrap_or("visible") == "visible").or_else(|| views.child("workbookView"))
|
||||
}
|
||||
|
||||
pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
|
||||
let pkg = Package::open(bytes)?;
|
||||
let mut cx = Ctx {
|
||||
@@ -268,7 +277,19 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
|
||||
}
|
||||
|
||||
read_workbook_properties(&mut cx, &mut wb, &wb_xml);
|
||||
let active_tab = wb_xml.path(&["bookViews", "workbookView"]).and_then(|v| v.attr_u32("activeTab")).unwrap_or(0) as usize;
|
||||
let view_el = selected_workbook_view(&wb_xml);
|
||||
let active_tab = view_el.and_then(|v| v.attr_u32("activeTab")).unwrap_or(0) as usize;
|
||||
if let Some(v) = view_el {
|
||||
// The window's display settings (Excel's "Display options for this workbook").
|
||||
// Absent attributes take the format's defaults, which is also what a workbook
|
||||
// without a <workbookView> gets.
|
||||
wb.view = WorkbookView {
|
||||
show_sheet_tabs: v.flag("showSheetTabs", true),
|
||||
show_horizontal_scroll: v.flag("showHorizontalScroll", true),
|
||||
show_vertical_scroll: v.flag("showVerticalScroll", true),
|
||||
tab_ratio: v.attr_u32("tabRatio").unwrap_or(600).min(WorkbookView::MAX_TAB_RATIO),
|
||||
};
|
||||
}
|
||||
|
||||
// Sheets. `file_to_model[i]` maps the i-th <sheet> to a model sheet index.
|
||||
let mut file_to_model: Vec<Option<usize>> = vec![];
|
||||
|
||||
@@ -1499,3 +1499,177 @@ fn a_partial_sheet_filter_is_not_folded_into_a_table() {
|
||||
assert!(t.filter.is_empty(), "the criteria landed on the wrong table column: {:?}", t.filter);
|
||||
assert!(b.autofilter.is_none(), "a sheet filter inside a table would be invalid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workbook_view_settings_survive_a_round_trip() {
|
||||
// #3057: a workbook Excel saved with the sheet tabs and both scroll bars hidden
|
||||
// (and a non-default tab split) must open with those settings in the model and
|
||||
// save with them still in the file.
|
||||
let bytes = super::minimal(
|
||||
"",
|
||||
&[],
|
||||
"",
|
||||
"<bookViews><workbookView activeTab=\"0\" showSheetTabs=\"0\" showHorizontalScroll=\"0\" showVerticalScroll=\"0\" tabRatio=\"250\"/></bookViews>",
|
||||
);
|
||||
let (wb, _) = read_xlsx(&bytes).unwrap();
|
||||
assert_eq!(
|
||||
wb.view,
|
||||
WorkbookView { show_sheet_tabs: false, show_horizontal_scroll: false, show_vertical_scroll: false, tab_ratio: 250 },
|
||||
"the reader takes the view settings from <workbookView>"
|
||||
);
|
||||
|
||||
let out = write_xlsx(&wb).unwrap();
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(out)).unwrap();
|
||||
let mut xml = String::new();
|
||||
z.by_name("xl/workbook.xml").unwrap().read_to_string(&mut xml).unwrap();
|
||||
for attr in ["showSheetTabs=\"0\"", "showHorizontalScroll=\"0\"", "showVerticalScroll=\"0\"", "tabRatio=\"250\""] {
|
||||
assert!(xml.contains(attr), "the writer keeps {attr} in {xml}");
|
||||
}
|
||||
|
||||
// A workbook with the format's defaults writes the bare element it always has.
|
||||
let plain = write_xlsx(&Workbook::new()).unwrap();
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(plain)).unwrap();
|
||||
let mut xml = String::new();
|
||||
z.by_name("xl/workbook.xml").unwrap().read_to_string(&mut xml).unwrap();
|
||||
assert!(xml.contains("<workbookView activeTab=\"0\"/>"), "defaults stay unwritten: {xml}");
|
||||
}
|
||||
|
||||
/// Three original empty worksheets let view selection be checked through `activeTab`,
|
||||
/// independently of the flags and ratio carried by the same window.
|
||||
fn workbook_with_window_views(views: &str) -> Vec<u8> {
|
||||
let workbook = format!(
|
||||
r#"<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><bookViews>{views}</bookViews><sheets><sheet name="First" sheetId="1" r:id="rId1"/><sheet name="Second" sheetId="2" r:id="rId2"/><sheet name="Third" sheetId="3" r:id="rId3"/></sheets></workbook>"#
|
||||
);
|
||||
let rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet2.xml"/><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet3.xml"/></Relationships>"#;
|
||||
let sheet = br#"<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData/></worksheet>"#;
|
||||
super::make_zip(&[
|
||||
("[Content_Types].xml", super::CT.as_bytes()),
|
||||
("_rels/.rels", super::ROOT_RELS.as_bytes()),
|
||||
("xl/workbook.xml", workbook.as_bytes()),
|
||||
("xl/_rels/workbook.xml.rels", rels.as_bytes()),
|
||||
("xl/worksheets/sheet1.xml", sheet),
|
||||
("xl/worksheets/sheet2.xml", sheet),
|
||||
("xl/worksheets/sheet3.xml", sheet),
|
||||
])
|
||||
}
|
||||
|
||||
fn saved_workbook_view_xml(wb: &Workbook) -> String {
|
||||
let bytes = write_xlsx(wb).unwrap();
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
zip.by_name("xl/workbook.xml").unwrap().read_to_string(&mut xml).unwrap();
|
||||
xml
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workbook_view_attributes_round_trip_independently() {
|
||||
for (attribute, value, expected) in [
|
||||
("showSheetTabs", "0", WorkbookView { show_sheet_tabs: false, ..Default::default() }),
|
||||
("showSheetTabs", "1", WorkbookView::default()),
|
||||
("showHorizontalScroll", "0", WorkbookView { show_horizontal_scroll: false, ..Default::default() }),
|
||||
("showHorizontalScroll", "1", WorkbookView::default()),
|
||||
("showVerticalScroll", "0", WorkbookView { show_vertical_scroll: false, ..Default::default() }),
|
||||
("showVerticalScroll", "1", WorkbookView::default()),
|
||||
("tabRatio", "250", WorkbookView { tab_ratio: 250, ..Default::default() }),
|
||||
("tabRatio", "600", WorkbookView::default()),
|
||||
] {
|
||||
let view = format!("<workbookView {attribute}=\"{value}\"/>");
|
||||
let (wb, _) = read_xlsx(&workbook_with_window_views(&view)).unwrap();
|
||||
assert_eq!(wb.view, expected, "only {attribute} is supplied");
|
||||
let xml = saved_workbook_view_xml(&wb);
|
||||
assert_eq!(xml.contains(&format!("{attribute}=\"{value}\"")), expected != WorkbookView::default(), "{xml}");
|
||||
for other in ["showSheetTabs", "showHorizontalScroll", "showVerticalScroll", "tabRatio"] {
|
||||
if other != attribute {
|
||||
assert!(!xml.contains(&format!("{other}=")), "unrelated defaults stay omitted: {xml}");
|
||||
}
|
||||
}
|
||||
let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
|
||||
assert_eq!(back.view, expected, "{attribute}={value} survives a round trip");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workbook_view_selects_first_visible_window_consistently() {
|
||||
let cases = [
|
||||
(
|
||||
r#"<workbookView visibility="hidden" activeTab="0" showSheetTabs="0" showHorizontalScroll="0" showVerticalScroll="0" tabRatio="111"/><workbookView visibility="veryHidden" activeTab="1" tabRatio="222"/><workbookView visibility="visible" activeTab="2" showSheetTabs="1" showHorizontalScroll="0" showVerticalScroll="1" tabRatio="750"/><workbookView visibility="visible" activeTab="0" tabRatio="250"/>"#,
|
||||
2,
|
||||
WorkbookView { show_sheet_tabs: true, show_horizontal_scroll: false, show_vertical_scroll: true, tab_ratio: 750 },
|
||||
),
|
||||
// Visibility defaults to visible, so this view takes precedence over a later explicit one.
|
||||
(
|
||||
r#"<workbookView visibility="hidden" activeTab="0" showSheetTabs="0" tabRatio="111"/><workbookView activeTab="1" showVerticalScroll="0" tabRatio="350"/><workbookView visibility="visible" activeTab="2" tabRatio="900"/>"#,
|
||||
1,
|
||||
WorkbookView { show_vertical_scroll: false, tab_ratio: 350, ..Default::default() },
|
||||
),
|
||||
// No visible window: retain the first window's settings and active sheet as one unit.
|
||||
(
|
||||
r#"<workbookView visibility="hidden" activeTab="1" showSheetTabs="0" tabRatio="100"/><workbookView visibility="veryHidden" activeTab="2" tabRatio="900"/>"#,
|
||||
1,
|
||||
WorkbookView { show_sheet_tabs: false, tab_ratio: 100, ..Default::default() },
|
||||
),
|
||||
];
|
||||
for (views, active, view) in cases {
|
||||
let (wb, _) = read_xlsx(&workbook_with_window_views(views)).unwrap();
|
||||
assert_eq!(wb.active_sheet(), active, "activeTab must come from the selected view: {views}");
|
||||
assert_eq!(wb.view, view, "all settings must come from the selected view: {views}");
|
||||
let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
|
||||
assert_eq!(back.active_sheet(), active);
|
||||
assert_eq!(back.view, view);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workbook_view_without_a_window_uses_defaults() {
|
||||
for bytes in [super::minimal("", &[], "", ""), workbook_with_window_views("")] {
|
||||
let (wb, _) = read_xlsx(&bytes).unwrap();
|
||||
assert_eq!(wb.active_sheet(), 0);
|
||||
assert_eq!(wb.view, WorkbookView::default());
|
||||
let xml = saved_workbook_view_xml(&wb);
|
||||
assert!(xml.contains("<workbookView activeTab=\"0\"/>"), "missing views keep defaults omitted: {xml}");
|
||||
let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
|
||||
assert_eq!(back.active_sheet(), 0);
|
||||
assert_eq!(back.view, WorkbookView::default());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workbook_view_tab_ratio_normalizes_imported_values() {
|
||||
for (raw, expected) in [
|
||||
(None, 600),
|
||||
(Some("0"), 0),
|
||||
(Some("600"), 600),
|
||||
(Some("1000"), 1000),
|
||||
(Some("1001"), 1000),
|
||||
(Some("4294967295"), 1000),
|
||||
(Some("4294967296"), 600),
|
||||
(Some("-1"), 600),
|
||||
(Some("invalid"), 600),
|
||||
(Some(""), 600),
|
||||
] {
|
||||
let attr = raw.map(|value| format!(" tabRatio=\"{value}\"")).unwrap_or_default();
|
||||
let views = format!("<workbookView{attr}/>");
|
||||
let (wb, _) = read_xlsx(&workbook_with_window_views(&views)).unwrap();
|
||||
assert_eq!(wb.view.tab_ratio, expected, "tabRatio={raw:?}");
|
||||
let xml = saved_workbook_view_xml(&wb);
|
||||
if expected == 600 {
|
||||
assert!(!xml.contains("tabRatio="), "the default is omitted: {xml}");
|
||||
} else {
|
||||
assert!(xml.contains(&format!("tabRatio=\"{expected}\"")), "{xml}");
|
||||
}
|
||||
let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
|
||||
assert_eq!(back.view.tab_ratio, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workbook_view_writer_clamps_out_of_range_model_values() {
|
||||
for raw in [1001, u32::MAX] {
|
||||
let mut wb = Workbook::new();
|
||||
wb.view.tab_ratio = raw;
|
||||
let xml = saved_workbook_view_xml(&wb);
|
||||
assert!(xml.contains("tabRatio=\"1000\""), "an out-of-range model value is bounded on write: {xml}");
|
||||
let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
|
||||
assert_eq!(back.view.tab_ratio, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::fmt::Write as _;
|
||||
|
||||
use gridcraft_core::RangeRef;
|
||||
use gridcraft_formula::quote_sheet;
|
||||
use gridcraft_model::{CalcMode, CalcSettings, PhoneticString, ShowObjects, Style, Visibility, Workbook};
|
||||
use gridcraft_model::{CalcMode, CalcSettings, PhoneticString, ShowObjects, Style, Visibility, Workbook, WorkbookView};
|
||||
|
||||
use crate::package::ZipOut;
|
||||
use crate::xml::{esc, esc_attr};
|
||||
@@ -208,6 +208,28 @@ pub fn write_xlsx(wb: &Workbook, kind: XlsxKind) -> Result<Vec<u8>, IoError> {
|
||||
write_xlsx_named(wb, kind, None)
|
||||
}
|
||||
|
||||
/// The `<workbookView>` attributes beyond `activeTab`: the window's display settings
|
||||
/// that differ from the format's defaults. An ordinary workbook gets none, so its file
|
||||
/// is unchanged; a workbook Excel saved with the sheet tabs or scroll bars hidden
|
||||
/// keeps them that way (#3057).
|
||||
fn workbook_view_attrs(wb: &Workbook) -> String {
|
||||
let mut attrs = String::new();
|
||||
if !wb.view.show_sheet_tabs {
|
||||
attrs.push_str(" showSheetTabs=\"0\"");
|
||||
}
|
||||
if !wb.view.show_horizontal_scroll {
|
||||
attrs.push_str(" showHorizontalScroll=\"0\"");
|
||||
}
|
||||
if !wb.view.show_vertical_scroll {
|
||||
attrs.push_str(" showVerticalScroll=\"0\"");
|
||||
}
|
||||
let tab_ratio = wb.view.tab_ratio.min(WorkbookView::MAX_TAB_RATIO);
|
||||
if tab_ratio != 600 {
|
||||
let _ = write!(attrs, " tabRatio=\"{tab_ratio}\"");
|
||||
}
|
||||
attrs
|
||||
}
|
||||
|
||||
/// Writes the workbook for the file `book_name` (a bare file name). A PivotChart is bound to its
|
||||
/// PivotTable in Excel only when its link starts with the workbook in brackets.
|
||||
pub fn write_xlsx_named(wb: &Workbook, kind: XlsxKind, book_name: Option<&str>) -> Result<Vec<u8>, IoError> {
|
||||
@@ -273,7 +295,9 @@ pub fn write_xlsx_named(wb: &Workbook, kind: XlsxKind, book_name: Option<&str>)
|
||||
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=\"{}\"/></bookViews><sheets>{sheets_xml}</sheets>", kept.file_index(active));
|
||||
let _ = write!(w, "<bookViews><workbookView activeTab=\"{}\"", kept.file_index(active));
|
||||
w.push_str(&workbook_view_attrs(wb));
|
||||
let _ = write!(w, "/></bookViews><sheets>{sheets_xml}</sheets>");
|
||||
w.push_str(&kept.elements);
|
||||
if !names.is_empty() {
|
||||
let _ = write!(w, "<definedNames>{names}</definedNames>");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (`file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765); earlier: `.xls` import reads cell formatting: fonts, fills, borders, alignment, protection, palette, user cell styles; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-01 · **Change:** trivial (XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
|
||||
Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests.
|
||||
Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h**
|
||||
@@ -14,6 +14,27 @@ support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and
|
||||
Legacy scalar `INDEX(range, MATCH(...))` formulas remain ordinary formulas on save, including when
|
||||
shared formulas are expanded during import (`array_export.rs`, #1505).
|
||||
|
||||
## Workbook window settings
|
||||
|
||||
GridCraft keeps one modelled workbook window. On XLSX import it selects the first visible
|
||||
`workbookView` (omitted `visibility` means visible), or the first view when none is visible.
|
||||
The active tab and display options come from that same view and survive the next save.
|
||||
GridCraft normalizes `tabRatio` to 0–1000 on import and export; malformed or overflowing
|
||||
unsigned-integer text uses the default 600. The valid endpoints 0 and 1000 survive, and
|
||||
600 plus the default shown flags stay omitted from output. Synthetic regressions in
|
||||
`crates/xlsx/src/tests/roundtrip.rs` cover each attribute independently, selection, fallback
|
||||
and round trips (#3057). Engine regressions exercise real sheet deletion and reordering
|
||||
commands and check that the saved active tab identifies the current sheet after reopening,
|
||||
with the workbook display options preserved.
|
||||
The format's [workbook-view defaults](https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.spreadsheet.workbookview?view=openxml-3.0.1)
|
||||
include visible windows and a tab ratio of 600. UI controls and rendering of these settings
|
||||
remain separate #3057 work.
|
||||
|
||||
`firstSheet` records the leftmost visible tab, independently of `activeTab`. It is not modelled
|
||||
by this branch. Supporting it needs its own view state and sheet-edit handling, tracked in
|
||||
#2264; these regressions cover the active tab and existing display fields without introducing
|
||||
that separate feature.
|
||||
|
||||
## Workbook formats
|
||||
|
||||
XLSX column declarations are limited to 1,000,000 expanded column assignments per worksheet,
|
||||
@@ -110,6 +131,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-01 | trivial | XLSX selects the first visible workbook view as one unit and bounds imported/exported tab ratios; isolated attributes and sheet-edit round trips covered, with firstSheet left to #2264 (#3057) |
|
||||
| 2026-10-31 | minor | CSV and text open detects each column's date order: a value that reads only day first (`31/05/2026`) makes the column day first, a value that reads only month first (`12/31/2026`) makes it month first, an all-ambiguous column keeps month first with a warning, and a column with both kinds stays text with a warning (#2765) |
|
||||
| 2026-10-21 | minor | HTML (Save as Web Page) writes pictures (`data:` URI `<img>`) and charts (inline SVG from the chart crate's drawing primitives, text included) at their anchors; the table uses the sheet's column widths and row heights when the sheet has either, and output for sheets without pictures or charts is unchanged |
|
||||
| 2026-10-20 | trivial | XLSX shared strings: Japanese phonetic runs (ふりがな, `<rPh sb="…" eb="…">` with `<phoneticPr>`) are read into the workbook and written back next to the string with the same text, so an open + save no longer deletes them; bad offsets and readings are dropped with a warning, a `<phoneticPr>` without runs is kept on its own, and when duplicate `<si>` items share a text but not a reading only the first reading is written (reported on open) (`phonetic_tests.rs`) |
|
||||
|
||||
Reference in New Issue
Block a user