Integration: track t08

# Conflicts:
#	ROADMAP.md
#	crates/engine/src/tests.rs
#	docs/file-format-parity.md
#	docs/localization-parity.md
This commit is contained in:
aiongg
2026-10-22 03:03:55 +00:00
28 changed files with 886 additions and 60 deletions
+1
View File
@@ -150,6 +150,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-13 | Multi-level category axes: a category range two or more columns wide beside a column of values (year beside month, Excel's `multiLvlStrRef`) draws its innermost labels across the plot and the outer levels in rows under them with group separators (columns beside the labels in bar charts), instead of flattening the two columns into twice as many categories; saving writes the reference back as `multiLvlStrRef` with Excel's level caches, so the axis survives a round trip |
| 2026-10-13 | Charts: a huge trendline forecast no longer collapses the chart: on a category axis it adds at most four times the data in empty slots (at least 32), the line is sampled over the plot only, and forecasts read from XLSX or set by command are clamped, NaN and infinity becoming 0 (#1148)
| 2026-10-13 | XLSX export writes a picture used on several sheets once, as one `xl/media` part every drawing points to (it wrote a copy per drawing: a 16-sheet workbook with one 112 KB logo grew from 0.4 to 1.9 MB) (#985) |
| 2026-10-13 | XLSX: `[Content_Types].xml`, `_rels/.rels`, the workbook rels and `xl/workbook.xml` are the first zip entries (document properties last), so libmagic and PHP `finfo` detect the file type even with large properties (#1090) |
| 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) |
| 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) |
| 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) |
+4 -1
View File
@@ -111,7 +111,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Rotate",
["Page Layout", "Arrange"],
None,
"{kind: shape, id, flip?: horizontal|vertical} (swaps width/height for 90° turns)",
"{kind: shape, id} (swaps width/height for a 90° turn; `flip` is rejected: shapes have no flip state)",
has_doc,
rotate
),
@@ -662,6 +662,9 @@ fn align(s: &mut Session, p: &Json) -> Result<Json> {
}
fn rotate(s: &mut Session, p: &Json) -> Result<Json> {
if p.get("flip").is_some_and(|f| !f.is_null()) {
return Err(bad("arrange.rotate", "`flip` is not supported: shapes have no flip state"));
}
with_shape(s, p, |sh| std::mem::swap(&mut sh.anchor.width, &mut sh.anchor.height))
}
+73 -1
View File
@@ -147,7 +147,12 @@ fn open_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook,
}
// Text named `.xlsx` is read as text.
if sniffed == gridcraft_xlsx::Format::Xlsx || (kind == Some(FileKind::Xlsx) && sniffed != gridcraft_xlsx::Format::Csv) {
let (wb, report) = gridcraft_xlsx::read_xlsx(bytes).map_err(|e| EngineError::Other(format!("We can't open '{name}': {e}")))?;
let (wb, report) = gridcraft_xlsx::read_xlsx(bytes).map_err(|e| {
EngineError::Other(match (&e, gridcraft_xlsx::oversized_part(bytes)) {
(gridcraft_xlsx::IoError::TooLarge(_), Some((part, size))) => part_too_large_message(name, &part, size),
_ => format!("We can't open '{name}': {e}"),
})
})?;
return Ok((wb, report.warnings));
}
match kind {
@@ -176,6 +181,25 @@ fn open_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook,
}
}
/// What to tell the user when one part of an XLSX package unpacks to more than the reader's
/// per-part limit: the sizes, why the limit is there, and what gets the data in. The limit is
/// per part, so smaller sheets fit; the shared strings are one part for every sheet, so only a
/// CSV, which has no parts, helps there.
fn part_too_large_message(name: &str, part: &str, size: u64) -> String {
let limit = gridcraft_xlsx::MAX_PART >> 20;
let mb = size >> 20;
let size = if mb > limit { format!("{mb} MB") } else { format!("more than {limit} MB") };
let remedy = if part.contains("worksheets/") {
"To open it, split the data across several sheets, since the limit applies to each part, or save the sheet as CSV, which keeps only its values."
} else {
"To open it, save the data as CSV, which keeps only its values."
};
format!(
"We can't open '{name}': its part {part} unpacks to {size}, over GridCraft's limit of {limit} MB for one part \
(a guard against damaged or malicious files). {remedy}"
)
}
/// A CSV opens as a sheet named after the file (like Excel).
fn rename_first_sheet(wb: &mut Workbook, path: &str) {
let stem = std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("Sheet1");
@@ -887,6 +911,54 @@ mod tests {
);
}
/// Where `part`'s entry starts in the central directory of `package`.
fn central_entry(package: &[u8], part: &str) -> usize {
let mut at = 0;
while let Some(i) = package.get(at..).and_then(|rest| rest.windows(4).position(|w| w == b"PK\x01\x02")) {
let entry = at + i;
let len = u16::from_le_bytes([package[entry + 28], package[entry + 29]]) as usize;
if &package[entry + 46..entry + 46 + len] == part.as_bytes() {
return entry;
}
at = entry + 46;
}
panic!("{part} is not in the package");
}
/// `package` with `part` declaring `size` unpacked bytes: the reader refuses the part on the
/// declared size, so no real 512 MB part has to be built.
fn declaring(mut package: Vec<u8>, part: &str, size: u32) -> Vec<u8> {
let at = central_entry(&package, part);
package[at + 24..at + 28].copy_from_slice(&size.to_le_bytes());
package
}
#[test]
fn a_part_over_the_size_limit_says_how_to_get_the_data_in() {
let mut wb = Workbook::new();
wb.sheet_mut(0).unwrap().set_value(CellRef::new(0, 0), gridcraft_core::Value::text("big"));
let package = save_bytes(&wb, "big.xlsx").unwrap();
// The size reported in storytold/gridcraft#1242.
let msg = open_bytes("big.xlsx", &declaring(package.clone(), "xl/worksheets/sheet1.xml", 540_278_731)).unwrap_err().to_string();
assert!(msg.starts_with("We can't open 'big.xlsx': "), "{msg}");
assert!(msg.contains("xl/worksheets/sheet1.xml") && msg.contains("515 MB") && msg.contains("512 MB"), "{msg}");
assert!(msg.contains("several sheets") && msg.contains("CSV"), "{msg}");
assert!(!msg.contains("bytes"), "{msg}");
// Splitting sheets doesn't shrink the shared strings, which every sheet uses: only CSV helps.
let msg = open_bytes("big.xlsx", &declaring(package.clone(), "xl/sharedStrings.xml", 540_278_731)).unwrap_err().to_string();
assert!(msg.contains("xl/sharedStrings.xml") && msg.contains("515 MB") && msg.contains("CSV"), "{msg}");
assert!(!msg.contains("several sheets"), "{msg}");
// Just over the limit never reads as a size equal to it.
let msg = open_bytes("big.xlsx", &declaring(package, "xl/worksheets/sheet1.xml", 512 * 1024 * 1024 + 1)).unwrap_err().to_string();
assert!(msg.contains("more than 512 MB"), "{msg}");
// A package that fails for another reason keeps its own error, oversized part or not.
let mut broken = declaring(save_bytes(&wb, "big.xlsx").unwrap(), "xl/worksheets/sheet1.xml", 540_278_731);
let at = central_entry(&broken, "xl/workbook.xml");
broken[at + 46 + "xl/workbook.xml".len() - 1] = b'x';
let msg = open_bytes("big.xlsx", &broken).unwrap_err().to_string();
assert!(msg.starts_with("We can't open 'big.xlsx': ") && !msg.contains("unpacks"), "{msg}");
}
#[test]
fn content_wins_over_a_mismatched_extension() {
let mut wb = Workbook::new();
+28
View File
@@ -3903,3 +3903,31 @@ fn convert_to_number_turns_numbers_stored_as_text_into_numbers() {
assert_eq!(s.execute("formulas.convertToNumber", json!({})).unwrap()["converted"], 1);
assert_eq!(value(&mut s, "A1"), json!("123"));
}
#[test]
fn saving_csv_keeps_leading_empty_columns_and_rows() {
let wb = gridcraft_xlsx::read_csv(b",x\r\n\r\n,,y\r\n", &gridcraft_xlsx::CsvOptions::default()).unwrap();
let out = crate::io::save_bytes(&wb, "a.csv").unwrap();
assert_eq!(String::from_utf8(out).unwrap(), ",x,\r\n,,\r\n,,y\r\n");
}
#[test]
fn rotate_rejects_flip_param_instead_of_swapping_size() {
let mut s = s();
let id = s.execute("insert.shape", json!({"kind": "rectangle"})).unwrap()["shape"].clone();
let size = |s: &Session| {
let a = &s.doc().unwrap().wb.active().unwrap().shapes[0].anchor;
(a.width, a.height)
};
let (w, h) = size(&s);
assert_ne!(w, h);
let before = s.doc().unwrap().wb.clone();
for flip in ["horizontal", "vertical", "sideways"] {
let r = s.execute("arrange.rotate", json!({"kind": "shape", "id": id, "flip": flip}));
assert!(matches!(r, Err(crate::EngineError::BadParams { .. })), "flip {flip}: {r:?}");
assert_eq!(size(&s), (w, h));
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().wb, &before));
}
s.execute("arrange.rotate", json!({"kind": "shape", "id": id})).unwrap();
assert_eq!(size(&s), (h, w));
}
+58 -1
View File
@@ -119,16 +119,42 @@ pub struct Theme {
pub colors: [u32; 12],
pub major_font: String,
pub minor_font: String,
/// The heading font's East Asian typeface (`<a:ea>`); empty when the file did not set one.
#[serde(default)]
pub major_font_ea: String,
/// The body font's East Asian typeface (`<a:ea>`); empty when the file did not set one.
#[serde(default)]
pub minor_font_ea: String,
/// The heading font's complex-script typeface (`<a:cs>`); empty when the file did not set one.
#[serde(default)]
pub major_font_cs: String,
/// The body font's complex-script typeface (`<a:cs>`); empty when the file did not set one.
#[serde(default)]
pub minor_font_cs: String,
/// The heading font's per-script typefaces (`<a:font script="Arab" typeface="…"/>`), as
/// (script, typeface) in file order.
#[serde(default)]
pub major_font_scripts: Vec<(String, String)>,
/// The body font's per-script typefaces, as (script, typeface) in file order.
#[serde(default)]
pub minor_font_scripts: Vec<(String, String)>,
}
impl Default for Theme {
/// GridCraft's own default theme ("Craft"): original palette.
/// GridCraft's own default theme ("Craft"): original palette. The East Asian and
/// complex-script typefaces stay empty: the file did not set one.
fn default() -> Self {
Theme {
name: "Craft".into(),
colors: [0xFFFFFF, 0x000000, 0xE8E8E8, 0x0E2841, 0x156082, 0xE97132, 0x196B24, 0x0F9ED5, 0xA02B93, 0x4EA72E, 0x467886, 0x96607D],
major_font: crate::DEFAULT_FONT.into(),
minor_font: crate::DEFAULT_FONT.into(),
major_font_ea: String::new(),
minor_font_ea: String::new(),
major_font_cs: String::new(),
minor_font_cs: String::new(),
major_font_scripts: Vec::new(),
minor_font_scripts: Vec::new(),
}
}
}
@@ -435,6 +461,14 @@ mod tests {
assert!(!t.get(StyleId(999)).font.bold);
}
#[test]
fn a_theme_saved_before_per_script_fonts_still_opens() {
let mut json = serde_json::to_value(Theme::default()).unwrap();
let fields = json.as_object_mut().unwrap();
assert!(fields.remove("major_font_scripts").is_some() && fields.remove("minor_font_scripts").is_some());
assert_eq!(serde_json::from_value::<Theme>(json).unwrap(), Theme::default());
}
#[test]
fn colors() {
let th = Theme::default();
@@ -444,4 +478,27 @@ mod tests {
assert_eq!(Color::Theme(1, 500).resolve(&th), Some([128, 128, 128]));
assert_eq!(Color::Auto.resolve(&th), None);
}
#[test]
fn theme_json_keeps_ea_and_cs_and_reads_old_json() {
// JSON written before the ea/cs fields existed still deserializes (`serde(default)`).
let mut old = serde_json::to_value(Theme::default()).unwrap();
let obj = old.as_object_mut().unwrap();
for key in ["major_font_ea", "minor_font_ea", "major_font_cs", "minor_font_cs"] {
assert!(obj.remove(key).is_some(), "the default theme serializes {key}");
}
assert_eq!(serde_json::from_value::<Theme>(old).unwrap(), Theme::default());
// Set fields serialize and come back.
let t = Theme {
major_font_ea: "游ゴシック".into(),
minor_font_ea: "MS Pゴシック".into(),
major_font_cs: "Arial".into(),
minor_font_cs: "Times New Roman".into(),
..Theme::default()
};
let json = serde_json::to_string(&t).unwrap();
assert!(json.contains("major_font_ea") && json.contains("游ゴシック"), "{json}");
assert_eq!(serde_json::from_str::<Theme>(&json).unwrap(), t);
}
}
+8 -5
View File
@@ -859,11 +859,14 @@ impl SheetApp {
}
let vba: Vec<&str> = msgs.iter().copied().filter(|m| m.to_lowercase().contains("macro") || m.contains("VBA")).collect();
if !vba.is_empty() {
self.notice = Some(Notice {
text: "This workbook contains VBA macros. GridCraft keeps them intact when you save, but does not run them.".into(),
kind: NoticeKind::Warning,
action: Some(("What this means".into(), "macros".into())),
});
// The reader says "dropped" when the VBA project was missing or unreadable; never promise to keep it then.
let dropped = vba.iter().any(|m| m.contains("dropped"));
let text = if dropped {
"This workbook's VBA macros could not be loaded and were dropped, so they will not be in the saved file. Keep the original file."
} else {
"This workbook contains VBA macros. GridCraft keeps them when you save as .xlsm, but does not run them."
};
self.notice = Some(Notice { text: text.into(), kind: NoticeKind::Warning, action: Some(("What this means".into(), "macros".into())) });
} else {
let text = if msgs.len() == 1 {
msgs[0].to_string()
+13
View File
@@ -842,6 +842,19 @@ fn opening_a_macro_workbook_shows_a_notice() {
assert!(h.state().notice.is_none());
}
#[test]
fn dropped_macro_warning_never_promises_to_keep_macros() {
let mut h = harness(blank());
for w in ["macros (VBA project) reference a missing part and were dropped", "macros (VBA project) could not be read and were dropped: bad zip"] {
h.state_mut().show_open_warnings(&json!({"warnings": [w]}));
let n = h.state().notice.clone().unwrap();
assert_eq!(n.kind, gridcraft_ui_egui::NoticeKind::Warning);
assert!(n.text.contains("dropped") && !n.text.contains("keeps them"), "{}", n.text);
}
h.state_mut().show_open_warnings(&json!({"warnings": ["macros (VBA project) were kept but not executed"]}));
assert!(h.state().notice.clone().unwrap().text.contains("keeps them when you save as .xlsm"));
}
#[test]
fn plain_open_warning_becomes_an_info_notice() {
let mut h = harness(blank());
+8 -1
View File
@@ -172,7 +172,9 @@ impl Scan<'_> {
"INDEX" => {
let row = args.get(1);
let col = args.get(2);
!row.is_some_and(positive) || col.is_some_and(|e| !positive(e)) || (col.is_none() && !args.first().is_some_and(single_column))
let row_positive = row.is_some_and(|e| positive_index(e, shapes.get(1).copied()));
let col_positive = col.is_some_and(|e| positive_index(e, shapes.get(2).copied()));
!row_positive || col.is_some_and(|_| !col_positive) || (col.is_none() && !args.first().is_some_and(single_column))
}
"ROW" | "COLUMN" => {
affected |= first_array && !array_context;
@@ -205,6 +207,11 @@ fn positive(e: &Expr) -> bool {
matches!(e, Expr::Number(n) if *n >= 1.0)
}
fn positive_index(e: &Expr, shape: Option<Shape>) -> bool {
// MATCH returns a one-based position or an error, never INDEX's zero "whole row" selector.
positive(e) || (matches!(e, Expr::Call(name, _) if matches!(name.as_str(), "MATCH" | "XMATCH")) && shape.is_some_and(|s| !s.array))
}
fn one(e: &Expr) -> bool {
matches!(e, Expr::Number(n) if *n == 1.0)
}
+39 -17
View File
@@ -87,8 +87,9 @@ pub fn sniff_delimiter(text: &str, quote: char) -> u8 {
best.0
}
/// Splits CSV text into records of fields.
fn parse_records(text: &str, delim: char, quote: char, mut on_record: impl FnMut(Vec<String>) -> bool) {
/// Splits CSV text into records of fields. A record keeps at most [`MAX_COLS`] fields; the rest are
/// dropped as they are read, so a row of endless delimiters cannot allocate a string per field.
pub(crate) fn parse_records(text: &str, delim: char, quote: char, mut on_record: impl FnMut(Vec<String>) -> bool) {
let mut field = String::new();
let mut record: Vec<String> = vec![];
let mut in_q = false;
@@ -112,13 +113,19 @@ fn parse_records(text: &str, delim: char, quote: char, mut on_record: impl FnMut
in_q = true;
field_started_quoted = true;
} else if c == delim {
record.push(std::mem::take(&mut field));
let f = std::mem::take(&mut field);
if record.len() < MAX_COLS as usize {
record.push(f);
}
field_started_quoted = false;
} else if c == '\r' || c == '\n' {
if c == '\r' && chars.peek() == Some(&'\n') {
chars.next();
}
record.push(std::mem::take(&mut field));
let f = std::mem::take(&mut field);
if record.len() < MAX_COLS as usize {
record.push(f);
}
field_started_quoted = false;
if !on_record(std::mem::take(&mut record)) {
return;
@@ -128,7 +135,9 @@ fn parse_records(text: &str, delim: char, quote: char, mut on_record: impl FnMut
}
}
if !field.is_empty() || !record.is_empty() || field_started_quoted {
record.push(field);
if record.len() < MAX_COLS as usize {
record.push(field);
}
on_record(record);
}
}
@@ -187,15 +196,16 @@ pub fn read_csv(bytes: &[u8], opts: &CsvOptions) -> Result<Workbook, IoError> {
Ok(wb)
}
/// Writes the sheet's used range as CSV (CRLF line ends, UTF-8 without BOM). `cell_text` gives
/// Writes the sheet as CSV from A1 to the end of its used range, so leading empty columns and rows
/// stay as empty fields and every cell keeps its position (CRLF line ends, UTF-8 without BOM). `cell_text` gives
/// each cell's field text: the caller decides how values look (Save As CSV writes them as
/// displayed, number formats applied); quoting is done here.
///
/// The output is capped at [`MAX_CSV_BYTES`]: two cells far apart make the used range, and so the
/// file, as big as the whole rectangle between them (`A1` and `XFD1048576` are 16 GiB of commas).
/// An export that would pass the cap is refused before anything is allocated, or as soon as the
/// text written so far passes it, with [`IoError::TooLarge`]; accepted exports keep every field in
/// its place.
/// The output is capped at [`MAX_CSV_BYTES`]: a cell far from A1 makes the file as big as the whole
/// rectangle between them (`A1` and `XFD1048576` are 16 GiB of commas).
/// An export that would pass the cap is refused before anything is allocated, or at the first
/// field or line end that takes it past the cap (a field is measured before it is written), with
/// [`IoError::TooLarge`]; accepted exports keep every field in its place.
pub fn write_csv(sheet: &Sheet, delimiter: u8, cell_text: impl FnMut(CellRef) -> String) -> Result<Vec<u8>, IoError> {
write_csv_limited(sheet, delimiter, MAX_CSV_BYTES, cell_text)
}
@@ -209,21 +219,33 @@ pub fn write_csv_limited(sheet: &Sheet, delimiter: u8, limit: usize, mut cell_te
|what: String| IoError::TooLarge(format!("the CSV would be {what}, over the limit of {} MiB; export a smaller range", limit >> 20));
// Every row has a delimiter between its fields and a CRLF, whatever the cells hold: a lower
// bound on the output that needs no cell text.
let (rows, cols) = (u64::from(r.end.row - r.start.row) + 1, u64::from(r.end.col - r.start.col) + 1);
let (rows, cols) = (u64::from(r.end.row) + 1, u64::from(r.end.col) + 1);
let per_row = (cols - 1).saturating_mul(delim.len_utf8() as u64).saturating_add(2);
let least = rows.saturating_mul(per_row);
if least > limit as u64 {
return Err(too_large(format!("at least {} MiB ({rows} rows by {cols} columns)", least >> 20)));
}
for row in r.start.row..=r.end.row {
for col in r.start.col..=r.end.col {
if col > r.start.col {
for row in 0..=r.end.row {
for col in 0..=r.end.col {
if col > 0 {
out.push(delim);
}
let t = cell_text(CellRef::new(row, col));
if t.contains(delim) || t.contains('"') || t.contains('\n') || t.contains('\r') {
let quoted = t.contains(delim) || t.contains('"') || t.contains('\n') || t.contains('\r');
// The exact size of the field as written (the delimiter was pushed above), so a row
// never grows past the cap unseen and no doubled-quote copy of the text is built.
let field_len = if quoted { t.len() + t.matches('"').count() + 2 } else { t.len() };
if out.len().saturating_add(field_len) > limit {
return Err(too_large(format!("more than {} MiB (stopped at row {})", limit >> 20, row + 1)));
}
if quoted {
out.push('"');
out.push_str(&t.replace('"', "\"\""));
for ch in t.chars() {
if ch == '"' {
out.push('"');
}
out.push(ch);
}
out.push('"');
} else {
out.push_str(&t);
+7
View File
@@ -47,6 +47,7 @@ use gridcraft_model::Workbook;
pub use crypto::{DecryptError, check_encryption, decrypt};
pub use csv::{MAX_CSV_BYTES, read_csv, write_csv, write_csv_limited};
pub use ods::read_ods;
pub use package::MAX_PART;
pub use passthrough::rebase_links;
pub use xls::read_xls;
pub use xlsb::read_xlsb;
@@ -85,6 +86,12 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
read::read_xlsx(bytes)
}
/// The part of a zip package that declares more than [`MAX_PART`] unpacked bytes, with that size:
/// what an [`IoError::TooLarge`] from reading the package is about when one part is too large.
pub fn oversized_part(bytes: &[u8]) -> Option<(String, u64)> {
package::oversized_part(bytes)
}
/// Which member of the SpreadsheetML family a package is saved as. Only the content type of the
/// workbook part differs (ECMA-376 Part 1 Workbook part; MS-OI29500 adds the macro-enabled types).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+9
View File
@@ -120,6 +120,15 @@ impl<'a> Package<'a> {
}
}
/// The first part whose declared unpacked size is over [`MAX_PART`], with that size.
pub fn oversized_part(bytes: &[u8]) -> Option<(String, u64)> {
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).ok()?;
(0..zip.len().min(MAX_ENTRIES)).find_map(|i| {
let f = zip.by_index_raw(i).ok()?;
(f.size() > MAX_PART).then(|| (f.name().to_string(), f.size()))
})
}
fn prefix(part: &str, e: IoError) -> IoError {
match e {
IoError::Xml(m) => IoError::Xml(format!("{part}: {m}")),
+61 -10
View File
@@ -6,7 +6,8 @@
//! their `<externalReferences>`, data connections, XML maps) and each sheet (query tables, cells
//! mapped to XML elements, form controls, ActiveX controls, OLE objects, the sheet background and
//! printer settings, with the sheet's `<picture>`, `<oleObjects>` and `<controls>` XML, the `r:id`
//! of `<pageSetup>` and the legacy VML shapes those controls need). The workbook also keeps its
//! of `<pageSetup>`, the legacy VML shapes those controls need and the pictures of the headers and
//! footers, `&G`, with their `<legacyDrawingHF>`). The workbook also keeps its
//! `<fileVersion>` and the calcFeatures extension, which Excel needs to trust the saved values.
//!
//! On write, kept parts get fresh part names where needed and new relationship ids; the `r:id`s in
@@ -92,7 +93,7 @@ const FILE_VERSION_ATTRS: &[&str] = &["appName", "lastEdited", "lowestEdited", "
const CALC_FEATURES_URI: &str = "{B58B0392-4F1F-4190-BB64-5DF3571DCE5F}";
const NS_CALC_FEATURES: &str = "http://schemas.microsoft.com/office/spreadsheetml/2018/calcfeatures";
/// Kept sheet elements, in schema order (all after `<legacyDrawing>`, before `<tableParts>`).
const SHEET_SLOTS: &[&str] = &["picture", "oleObjects", "controls"];
const SHEET_SLOTS: &[&str] = &["legacyDrawingHF", "picture", "oleObjects", "controls"];
/// Relationship kinds GridCraft reads and rewrites, or that depend on content it rewrites:
/// kept content whose graph reaches one of them is dropped.
@@ -479,7 +480,10 @@ impl KeptReader {
cx.warn(format!("sheet \"{name}\": {} was dropped: {why}", kind(&r.rel_type)));
}
}
if rels.iter().any(|r| is_kind(r, SHEET_ELEMENT_KINDS)) {
// Notes have one VML drawing per sheet; another one holds the header and footer pictures.
let vml_drawings = rels.iter().filter(|r| is_kind(r, &["vmlDrawing"])).count();
let notes = rels.iter().filter(|r| is_kind(r, &["comments"])).count();
if rels.iter().any(|r| is_kind(r, SHEET_ELEMENT_KINDS)) || vml_drawings > notes {
self.sheet_elements(cx, part, name, &rels, &mut p, &mut seen);
}
(!p.is_empty()).then(|| Arc::new(p))
@@ -503,6 +507,21 @@ impl KeptReader {
Err(why) => cx.warn(format!("sheet \"{name}\": printer settings were dropped: {why}")),
}
}
// Header and footer pictures (`&G`): a VML drawing of their own and the images it shows,
// kept byte for byte (GridCraft writes the header text, not the pictures).
if let Some(id) = scan.legacy_drawing_hf.as_deref()
&& scan.legacy_drawing.as_deref() != Some(id)
&& let Some(r) = rels.iter().find(|r| r.id == id && is_kind(r, &["vmlDrawing"]))
{
match self.keep_through(cx, p, std::slice::from_ref(r), seen, &["vmlDrawing"]) {
Ok(()) => {
let xml = format!("<legacyDrawingHF xmlns:r=\"{NS_REL}\" r:id=\"{}\"/>", esc_attr(&r.id));
p.elements.push(PreservedXml { slot: "legacyDrawingHF".into(), xml });
cx.warn(format!("sheet \"{name}\": pictures in the header and footer are not shown; they are kept for saving"));
}
Err(why) => cx.warn(format!("sheet \"{name}\": header and footer pictures were dropped: {why}")),
}
}
let mut frags = scan.fragments;
// Controls and OLE objects are drawn from shapes in the legacy VML drawing.
if frags.iter().any(|(slot, _)| slot != "picture") {
@@ -581,18 +600,20 @@ struct SheetScan {
/// (slot, XML with the namespace declarations it needs).
fragments: Vec<(String, String)>,
legacy_drawing: Option<String>,
/// The relationship id on `<legacyDrawingHF>` (header and footer pictures).
legacy_drawing_hf: Option<String>,
/// The relationship id on `<pageSetup>` (printer settings).
page_setup: Option<String>,
}
/// Finds the sheet's `<picture>`, `<oleObjects>`, `<controls>` (also inside
/// `<mc:AlternateContent>`) and the `<legacyDrawing>` id. `None` when the XML is damaged.
/// `<mc:AlternateContent>`) and the `<legacyDrawing>` and `<legacyDrawingHF>` ids. `None` when the XML is damaged.
fn scan_sheet(bytes: &[u8]) -> Option<SheetScan> {
let data = xml::to_utf8(bytes);
let text = std::str::from_utf8(&data).ok()?;
let mut reader = Reader::from_str(text);
let mut decls: Vec<(String, String)> = vec![];
let mut out = SheetScan { fragments: vec![], legacy_drawing: None, page_setup: None };
let mut out = SheetScan { fragments: vec![], legacy_drawing: None, legacy_drawing_hf: None, page_setup: None };
let mut in_root = false;
loop {
let start = usize::try_from(reader.buffer_position()).ok()?;
@@ -614,6 +635,7 @@ fn scan_sheet(bytes: &[u8]) -> Option<SheetScan> {
match local.as_slice() {
b"pageSetup" => out.page_setup = rel_id_attr(&e),
b"legacyDrawing" => out.legacy_drawing = rel_id_attr(&e),
b"legacyDrawingHF" => out.legacy_drawing_hf = rel_id_attr(&e),
_ => {}
}
reader.read_to_end(e.name()).ok()?;
@@ -626,12 +648,12 @@ fn scan_sheet(bytes: &[u8]) -> Option<SheetScan> {
}
Event::Empty(e) if in_root => {
let local = e.local_name().as_ref().to_vec();
if local == b"legacyDrawing" || local == b"pageSetup" {
if local == b"legacyDrawing" || local == b"legacyDrawingHF" || local == b"pageSetup" {
let id = rel_id_attr(&e);
if local == b"pageSetup" {
out.page_setup = id;
} else {
out.legacy_drawing = id;
match local.as_slice() {
b"pageSetup" => out.page_setup = id,
b"legacyDrawing" => out.legacy_drawing = id,
_ => out.legacy_drawing_hf = id,
}
} else if local == b"picture"
&& let Some(frag) = text.get(start..usize::try_from(reader.buffer_position()).ok()?)
@@ -738,6 +760,19 @@ fn shape_numbers(vml: &str) -> Vec<u64> {
.collect()
}
/// The highest shape block VML text claims in `<o:idmap data>` or uses in a shape id.
fn vml_block_max(vml: &str) -> u64 {
let used = shape_numbers(vml).into_iter().max().unwrap_or(0) / 1024;
let claimed = vml
.find("<o:idmap")
.and_then(|i| vml.get(i..)?.find("data=\"").map(|d| i + d + "data=\"".len()))
.and_then(|d| Some((d, d + vml.get(d..)?.find('"')?)))
.and_then(|(d, e)| vml.get(d..e))
.map(|list| list.split(',').filter_map(|b| b.trim().parse::<u64>().ok()).max().unwrap_or(0))
.unwrap_or(0);
used.max(claimed)
}
/// Kept VML (controls, OLE objects) plus GridCraft's note shapes: the notes get ids in a fresh
/// shape block so they can't collide with the kept shapes the sheet XML refers to.
fn merge_vml(kept: &str, ours: &str) -> String {
@@ -837,11 +872,27 @@ fn map_start(e: &BytesStart<'_>, prefixes: &mut HashSet<Vec<u8>>, map: &mut dyn
pub struct KeptOut {
used: HashSet<String>,
n: u32,
/// The highest VML shape block a kept header and footer drawing uses: notes go after it.
vml_blocks: u32,
/// Package-root relationships (type, target, external), added when `_rels/.rels` is written.
root: Vec<(String, String, bool)>,
}
impl KeptOut {
/// Reserves the VML shape blocks the kept header and footer drawings of `wb` use, so that the
/// notes drawings GridCraft writes don't reuse their shape ids.
pub fn reserve_vml_blocks(&mut self, wb: &Workbook) {
let vmls =
wb.sheets.iter().filter_map(|s| s.preserved.as_deref()).flat_map(|p| &p.parts).filter(|q| q.content_type.eq_ignore_ascii_case(CT_VML));
let max = vmls.map(|q| vml_block_max(&String::from_utf8_lossy(&q.data))).max().unwrap_or(0);
self.vml_blocks = u32::try_from(max).unwrap_or(u32::MAX);
}
/// The shape block of the `n`th notes drawing.
pub fn notes_block(&self, n: u32) -> u32 {
n.saturating_add(self.vml_blocks)
}
/// A part name for a kept part: its original name when that can't collide with anything
/// written, otherwise a fresh one in the same folder.
fn claim(&mut self, original: &str) -> String {
+12 -4
View File
@@ -38,6 +38,7 @@ struct RowState {
}
const MAX_HYPERLINK_CELLS: u64 = 10_000;
const MAX_COLUMN_ASSIGNMENTS: u64 = 1_000_000;
pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<Sheet, IoError> {
let bytes = cx.pkg.read(part)?.ok_or_else(|| IoError::Format(format!("missing part {part}")))?;
@@ -61,6 +62,7 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<Sheet, IoE
drop(bytes);
let mut fit_to_page = false;
let mut column_assignments_left = MAX_COLUMN_ASSIGNMENTS;
cx.cf_ext_ids.clear();
for e in &root.children {
match e.name.as_str() {
@@ -88,7 +90,7 @@ pub fn read_sheet(cx: &mut Ctx<'_>, part: &str, name: &str) -> Result<Sheet, IoE
sheet.default_col_width = px as f32;
}
}
"cols" => read_cols(cx, e, &mut sheet),
"cols" => read_cols(cx, e, &mut sheet, &mut column_assignments_left)?,
"mergeCells" => {
for m in e.kids("mergeCell") {
if let Some(r) = m.attr("ref").and_then(RangeRef::parse)
@@ -300,7 +302,7 @@ fn read_views(e: &El, sheet: &mut Sheet) {
}
}
fn read_cols(cx: &Ctx<'_>, e: &El, sheet: &mut Sheet) {
fn read_cols(cx: &Ctx<'_>, e: &El, sheet: &mut Sheet, remaining: &mut u64) -> Result<(), IoError> {
for c in e.kids("col") {
let (Some(min), Some(max)) = (c.attr_u32("min"), c.attr_u32("max")) else { continue };
if min == 0 || min > MAX_COLS || max < min {
@@ -324,14 +326,20 @@ fn read_cols(cx: &Ctx<'_>, e: &El, sheet: &mut Sheet) {
info.style = Some(id);
}
}
// A width equal to the default with nothing else set is noise.
if info == LineInfo::default() || (info.size == Some(sheet.default_col_width) && LineInfo { size: None, ..info } == LineInfo::default()) {
// A custom width equal to the default still pins the column (Standard Width leaves it alone).
if info == LineInfo::default() {
continue;
}
// Overlapping declarations repeat work even when the final map stays small.
// Charge the clipped span before expansion, sharing the budget across <cols> blocks.
*remaining = remaining
.checked_sub(u64::from(max - min + 1))
.ok_or_else(|| IoError::TooLarge(format!("sheet \"{}\": more than {MAX_COLUMN_ASSIGNMENTS} expanded column assignments", sheet.name)))?;
for col in (min - 1)..max {
sheet.cols.insert(col, info);
}
}
Ok(())
}
fn read_row(cx: &mut Ctx<'_>, sheet: &mut Sheet, st: &mut RowState, row: &El) {
+1 -1
View File
@@ -549,7 +549,7 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> (
}
out.default_type("vml", "application/vnd.openxmlformats-officedocument.vmlDrawing");
let vml_name = format!("xl/drawings/vmlDrawing{n}.vml");
let vml = kept.notes_vml(vml_xml(sheet, n), &vml_name, out);
let vml = kept.notes_vml(vml_xml(sheet, out.kept.notes_block(n)), &vml_name, out);
out.part(&vml_name, None, vml.into_bytes());
rels.add("comments", &format!("../comments{n}.xml"));
let rid = rels.add("vmlDrawing", &format!("../drawings/vmlDrawing{n}.vml"));
+46
View File
@@ -161,6 +161,47 @@ fn scalar_formulas_constants_and_legacy_cse_do_not_require_dynamic_metadata() {
}
}
#[test]
fn legacy_index_match_formulas_remain_scalar_after_xlsx_roundtrip() {
let source = super::minimal(
r#"<sheetData><row r="1"><c r="A1" t="inlineStr"><is><t>a</t></is></c><c r="B1"><v>0.1</v></c><c r="D1" t="inlineStr"><is><t>c</t></is></c><c r="E1" t="inlineStr"><is><t>x</t></is></c><c r="F1"><f>INDEX($B$1:$B$3,MATCH(D1,$A$1:$A$3,0))</f><v>0.7</v></c><c r="F3" t="str"><f>IF(E1=&quot;&quot;,&quot;&quot;,INDEX($B$1:$B$3,MATCH(D1,$A$1:$A$3,0)))</f><v>0.7</v></c><c r="F5"><f>VLOOKUP(D1,$A$1:$B$3,2,FALSE)</f><v>0.7</v></c><c r="F6"><f>SUM($B$1:$B$3)</f><v>1.2</v></c><c r="F7"><f>MATCH(D1,$A$1:$A$3,0)</f><v>3</v></c></row><row r="2"><c r="A2" t="inlineStr"><is><t>b</t></is></c><c r="B2"><v>0.4</v></c><c r="F2"><f>IFERROR(INDEX($B$1:$B$3,MATCH(D1,$A$1:$A$3,0)),0)</f><v>0.7</v></c></row><row r="3"><c r="A3" t="inlineStr"><is><t>c</t></is></c><c r="B3"><v>0.7</v></c></row></sheetData>"#,
&[],
"",
"",
);
let (opened, _) = read_xlsx(&source).unwrap();
let saved = write_xlsx(&opened).unwrap();
let mut pkg = Package::open(&saved).unwrap();
let sheet = pkg.read_xml("xl/worksheets/sheet1.xml").unwrap().unwrap();
for address in ["F1", "F2", "F3", "F5", "F6", "F7"] {
assert_scalar(&sheet, address);
}
}
#[test]
fn shared_legacy_index_match_formulas_remain_scalar_after_xlsx_roundtrip() {
let mut body = String::from("<sheetData>");
for row in 16..=39 {
let formula = if row == 16 {
format!(r#"<f t="shared" ref="F16:F39" si="0">IF(E{row}="","",IFERROR(INDEX($B$1:$B$3,MATCH(D{row},$A$1:$A$3,0)),0))</f>"#)
} else {
r#"<f t="shared" si="0"/>"#.to_string()
};
body.push_str(&format!(r#"<row r="{row}"><c r="F{row}">{formula}<v>0.7</v></c></row>"#));
}
body.push_str("</sheetData>");
let source = super::minimal(&body, &[], "", "");
let (opened, _) = read_xlsx(&source).unwrap();
let saved = write_xlsx(&opened).unwrap();
let mut pkg = Package::open(&saved).unwrap();
let sheet = pkg.read_xml("xl/worksheets/sheet1.xml").unwrap().unwrap();
for row in 16..=39 {
assert_scalar(&sheet, &format!("F{row}"));
}
}
#[test]
fn calculated_table_column_keeps_formula_and_cell_value() {
let mut wb = Workbook::new();
@@ -304,6 +345,11 @@ fn implicit_intersection_metadata_respects_function_argument_context() {
("SQRT(INDIRECT(A1))", true),
("INDEX(A1:B3,1,1)", false),
("INDEX(A1:A3,1)", false),
("INDEX(A1:A3,MATCH(D1,A1:A3,0))", false),
("IFERROR(INDEX(A1:A3,MATCH(D1,A1:A3,0)),0)", false),
("IF(E1=\"\",\"\",INDEX(A1:A3,MATCH(D1,A1:A3,0)))", false),
("INDEX(A1:B3,MATCH(D1,A1:A3,0),MATCH(E1,A1:B1,0))", false),
("INDEX(A1:B3,MATCH(D1,A1:A3,0))", true),
("SUM(INDEX(A1:B3,0,1))", false),
("SQRT(INDEX(A1:B3,0,1))", true),
("SUM(ROW(A1:A3))", true),
+47 -6
View File
@@ -1,7 +1,7 @@
use gridcraft_core::{CellRef, DateSystem, Value};
use gridcraft_core::{CellRef, DateSystem, MAX_COLS, Value};
use gridcraft_model::{Sheet, Workbook};
use crate::csv::{decode_text, sniff_delimiter};
use crate::csv::{decode_text, parse_records, sniff_delimiter};
use crate::{CsvOptions, Format, IoError, read_csv, sniff, write_csv, write_csv_limited};
fn v(wb: &Workbook, r: u32, c: u32) -> Value {
@@ -110,14 +110,15 @@ fn writing() {
Value::Array(_) => String::new(),
};
let out = String::from_utf8(write_csv(sh, b',', raw).unwrap()).unwrap();
assert_eq!(out, "\"a,b\",\"q\"\"t\",\r\n0.1,FALSE,\r\n#DIV/0!,,\"x\ny\"\r\n");
// The first cell is B2: the file starts at A1, so the leading empty row and column stay.
assert_eq!(out, ",,,\r\n,\"a,b\",\"q\"\"t\",\r\n,0.1,FALSE,\r\n,#DIV/0!,,\"x\ny\"\r\n");
let semi = String::from_utf8(write_csv(sh, b';', raw).unwrap()).unwrap();
assert!(semi.starts_with("a,b;"));
assert!(semi.starts_with(";;;\r\n;a,b;"));
assert!(write_csv(&Sheet::new("E"), b',', raw).unwrap().is_empty());
// Round trip.
let back = read_csv(out.as_bytes(), &CsvOptions::default()).unwrap();
assert_eq!(v(&back, 0, 0), Value::text("a,b"));
assert_eq!(v(&back, 2, 2), Value::text("x\ny"));
assert_eq!(v(&back, 1, 1), Value::text("a,b"));
assert_eq!(v(&back, 3, 3), Value::text("x\ny"));
}
#[test]
@@ -186,7 +187,47 @@ fn the_cap_counts_the_bytes_the_cells_hold_and_accepted_exports_keep_every_posit
let err = write_csv_limited(&long, b',', 1000, text_of(&long)).unwrap_err();
assert!(err.to_string().contains("stopped at row"), "{err}");
assert!(write_csv_limited(&long, b',', 50 * 102, text_of(&long)).is_ok());
// Within one row the cap is checked before each field is written: a wide row of long and
// quote-heavy fields is refused at the field that crosses it, without asking for the rest.
let mut wide = Sheet::new("W");
for c in 0..10u32 {
wide.set_value(CellRef::new(0, c), Value::text("\"".repeat(100)));
}
let mut asked = 0;
let err = write_csv_limited(&wide, b',', 500, |c| {
asked += 1;
text_of(&wide)(c)
})
.unwrap_err();
assert!(err.to_string().contains("stopped at row 1"), "{err}");
assert_eq!(asked, 3, "each field is 202 bytes; the third passes 500");
// Exact boundary: a quoted field of 202 bytes plus CRLF fits in 204 and not in 203.
let mut one = Sheet::new("O");
one.set_value(CellRef::new(0, 0), Value::text("\"".repeat(100)));
assert_eq!(write_csv_limited(&one, b',', 204, text_of(&one)).unwrap().len(), 204);
assert!(write_csv_limited(&one, b',', 203, text_of(&one)).is_err());
// A tab is one byte, like a comma.
let tabs = write_csv_limited(&s, b'\t', 14, text_of(&s)).unwrap();
assert_eq!(tabs.len(), 14);
}
#[test]
fn parser_stops_collecting_fields_at_max_cols() {
let extra = 50;
// MAX_COLS + extra fields on the first row (the last one filled), then a short second row.
let mut text = ",".repeat((MAX_COLS + extra) as usize - 1);
text.push_str("end\nx,y\n");
let mut lens = vec![];
parse_records(&text, ',', '"', |rec| {
lens.push(rec.len());
true
});
assert_eq!(lens, vec![MAX_COLS as usize, 2]);
// An unterminated last row is capped the same way.
let mut lens = vec![];
parse_records(&",".repeat(MAX_COLS as usize + extra as usize), ',', '"', |rec| {
lens.push(rec.len());
true
});
assert_eq!(lens, vec![MAX_COLS as usize]);
}
+22
View File
@@ -577,3 +577,25 @@ fn user_defined_function_prefix_round_trips() {
assert_eq!(cell.child("f").unwrap().text, f, "written {a}");
}
}
/// `customWidth="1"` pins a column even when its width rounds to the default: Standard Width does not
/// move it, and a save must keep its `<col>` (openpyxl writes 9.08 for Excel's 8.43, #1607).
#[test]
fn custom_widths_equal_to_the_default_round_trip() {
let body = r#"<sheetFormatPr baseColWidth="8" defaultRowHeight="15"/>
<cols><col min="4" max="5" width="9.08" customWidth="1"/><col min="6" max="6" width="9.140625" customWidth="1"/><col min="7" max="7" width="9.2" customWidth="1"/><col min="8" max="8" width="9.140625"/></cols>
<sheetData/>"#;
let (wb, _) = read_xlsx(&minimal(body, &[], "", "")).unwrap();
let s = wb.sheet(0).unwrap();
assert_eq!(s.default_col_width, 64.0);
for c in 3..=6 {
assert_eq!(s.cols.get(&c).and_then(|i| i.size), Some(64.0), "read col {c}");
}
assert_eq!(s.cols.get(&7), None, "a width without customWidth equal to the default stays the default");
let bytes = crate::write_xlsx(&wb).unwrap();
let mut pkg = crate::package::Package::open(&bytes).unwrap();
let sheet = pkg.read_xml("xl/worksheets/sheet1.xml").unwrap().unwrap();
let cols: Vec<_> =
sheet.child("cols").unwrap().kids("col").map(|c| (c.attr_u32("min"), c.attr_u32("max"), c.flag("customWidth", false))).collect();
assert_eq!(cols, [(Some(4), Some(7), true)]);
}
+65
View File
@@ -256,3 +256,68 @@ fn hostile_formula_text_does_not_overflow_the_stack() {
assert_eq!(a_len, 1_000_001);
assert_eq!(a_value, gridcraft_core::Value::Number(3.0));
}
#[test]
fn repeated_column_ranges_have_one_worksheet_work_budget() {
// 62 * 16,384 = 1,015,808 assignments, while the map holds at most 16,384 columns.
// This stays small even on the unbounded reader, but exceeds the one-million work cap.
let col = r#"<col min="1" max="16384" width="10" customWidth="1"/>"#;
for (first, second) in [(62, 0), (31, 31)] {
let body = format!("<cols>{}</cols><cols>{}</cols><sheetData/>", col.repeat(first), col.repeat(second));
assert!(matches!(read_xlsx(&minimal(&body, &[], "", "")), Err(IoError::TooLarge(_))), "blocks: {first}, {second}");
}
}
#[test]
fn column_work_budget_counts_only_clipped_nondefault_assignments() {
let full = r#"<col min="1" max="16384" width="10" customWidth="1"/>"#;
// Default-only records and invalid coordinates do not expand into column assignments.
let noise = format!(
"{}{}",
r#"<col min="1" max="16384" hidden="0" style="0"/>"#.repeat(62),
r#"<col min="0" max="16384" width="20"/>
<col min="16385" max="4294967295" width="20"/>
<col min="5" max="4" width="20"/>
<col min="bad" max="16384" width="20"/>"#
);
// The last declaration clips to 576 columns: 61 * 16,384 + 576 = 1,000,000.
let cols = format!("{noise}{}<col min=\"15809\" max=\"4294967295\" width=\"20\"/>", full.repeat(61));
let body = format!("<cols>{cols}</cols><sheetData/>");
let (wb, _) = read_xlsx(&minimal(&body, &[], "", "")).unwrap();
let sheet = wb.sheet(0).unwrap();
assert_eq!(sheet.cols.len(), 16_384);
assert_eq!(sheet.cols.get(&15_807).unwrap().size, Some(crate::tables::col_width_to_px(10.0)));
assert_eq!(sheet.cols.get(&15_808).unwrap().size, Some(crate::tables::col_width_to_px(20.0)));
assert_eq!(sheet.cols.get(&16_383).unwrap().size, Some(crate::tables::col_width_to_px(20.0)));
let body = format!("<cols>{cols}<col min=\"1\" max=\"1\" hidden=\"1\"/></cols><sheetData/>");
assert!(matches!(read_xlsx(&minimal(&body, &[], "", "")), Err(IoError::TooLarge(_))));
}
#[test]
fn ordinary_overlapping_column_ranges_keep_last_declaration() {
let styles = r#"<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<cellXfs count="2"><xf numFmtId="0"/><xf numFmtId="2"/></cellXfs></styleSheet>"#;
let rels = r#"<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>"#;
let body = r#"<cols><col min="1" max="3" width="10" hidden="1" style="1"/></cols>
<cols><col min="2" max="4" width="20" hidden="0" style="0"/>
<col min="5" max="5" hidden="1" style="1"/>
<col min="1" max="5"/></cols><sheetData/>"#;
let (wb, _) = read_xlsx(&minimal(body, &[("xl/styles.xml", styles)], rels, "")).unwrap();
let sheet = wb.sheet(0).unwrap();
assert_eq!(sheet.cols.len(), 5);
let first = sheet.cols.get(&0).unwrap();
assert_eq!(first.size, Some(crate::tables::col_width_to_px(10.0)));
assert!(first.hidden);
assert_eq!(wb.styles.get(first.style.unwrap()).num_fmt.as_str(), "0.00");
for col in 1..4 {
let info = sheet.cols.get(&col).unwrap();
assert_eq!(info.size, Some(crate::tables::col_width_to_px(20.0)));
assert!(!info.hidden);
assert_eq!(info.style, None);
}
let last = sheet.cols.get(&4).unwrap();
assert_eq!(last.size, None);
assert!(last.hidden);
assert_eq!(wb.styles.get(last.style.unwrap()).num_fmt.as_str(), "0.00");
}
+1
View File
@@ -25,6 +25,7 @@ mod smooth_roundtrip;
mod trendline_roundtrip;
mod xls_tests;
mod xlsb_tests;
mod zip_order;
use std::io::Write;
@@ -832,3 +832,97 @@ fn file_version_and_calc_features_survive_a_save() {
assert!(plain.contains(r#"<fileVersion appName="gridcraft"/>"#) && !plain.contains("extLst"), "{plain}");
}
}
const LOGO: &[u8] = b"\x89PNG\r\n\x1a\n letterhead logo";
const SIGNATURE: &[u8] = b"\x89PNG\r\n\x1a\n footer signature";
const HF_VML: &str = r##"<xml xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel"><o:shapelayout v:ext="edit"><o:idmap v:ext="edit" data="1"/></o:shapelayout><v:shapetype id="_x0000_t75" coordsize="21600,21600" o:spt="75" o:preferrelative="t" path="m@4@5l@4@11@9@11@9@5xe" filled="f" stroked="f"/><v:shape id="LH" o:spid="_x0000_s1025" type="#_x0000_t75" style="position:absolute;margin-left:0;margin-top:0;width:120pt;height:40pt;z-index:1"><v:imagedata o:relid="rId1" o:title="logo"/><o:lock v:ext="edit" rotation="t"/></v:shape><v:shape id="CF" o:spid="_x0000_s1026" type="#_x0000_t75" style="position:absolute;margin-left:0;margin-top:0;width:80pt;height:20pt;z-index:2"><v:imagedata o:relid="rId2" o:title="signature"/><o:lock v:ext="edit" rotation="t"/></v:shape></xml>"##;
/// #1257: a letterhead in the header and a picture in the footer (`&G`), drawn from a VML part
/// that `<legacyDrawingHF>` names, next to a note whose VML drawing GridCraft rewrites.
fn header_picture_package() -> Vec<u8> {
let types = 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="png" ContentType="image/png"/><Default Extension="vml" ContentType="application/vnd.openxmlformats-officedocument.vmlDrawing"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/comments1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml"/></Types>"#;
let workbook = format!(
r#"<?xml version="1.0"?><workbook xmlns="{NS_MAIN}" xmlns:r="{NS_R}"><sheets><sheet name="Invoice" sheetId="1" r:id="rId1"/></sheets></workbook>"#
);
let sheet = format!(
r#"<?xml version="1.0"?><worksheet xmlns="{NS_MAIN}" xmlns:r="{NS_R}"><sheetData><row r="1"><c r="A1"><v>1</v></c></row></sheetData><pageMargins left="0.7" right="0.7" top="0.75" bottom="0.75" header="0.3" footer="0.3"/><headerFooter><oddHeader>&amp;L&amp;G&amp;CInvoice</oddHeader><oddFooter>&amp;C&amp;G</oddFooter></headerFooter><legacyDrawing r:id="rId2"/><legacyDrawingHF r:id="rId3"/></worksheet>"#
);
let root_rels = rels(&[("rId1", "officeDocument", "xl/workbook.xml")]);
let wb_rels = rels(&[("rId1", "worksheet", "worksheets/sheet1.xml")]);
let sheet_rels = rels(&[
("rId1", "comments", "../comments1.xml"),
("rId2", "vmlDrawing", "../drawings/vmlDrawing1.vml"),
("rId3", "vmlDrawing", "../drawings/vmlDrawing2.vml"),
]);
let hf_rels = rels(&[("rId1", "image", "../media/image1.png"), ("rId2", "image", "../media/image2.png")]);
make_zip(&[
("[Content_Types].xml", types.as_bytes()),
("_rels/.rels", root_rels.as_bytes()),
("xl/workbook.xml", workbook.as_bytes()),
("xl/_rels/workbook.xml.rels", wb_rels.as_bytes()),
("xl/worksheets/sheet1.xml", sheet.as_bytes()),
("xl/worksheets/_rels/sheet1.xml.rels", sheet_rels.as_bytes()),
("xl/comments1.xml", COMMENTS.as_bytes()),
("xl/drawings/vmlDrawing1.vml", VML.as_bytes()),
("xl/drawings/vmlDrawing2.vml", HF_VML.as_bytes()),
("xl/drawings/_rels/vmlDrawing2.vml.rels", hf_rels.as_bytes()),
("xl/media/image1.png", LOGO),
("xl/media/image2.png", SIGNATURE),
])
}
#[test]
fn header_and_footer_pictures_survive_a_save() {
let (wb, rep) = read_xlsx(&header_picture_package()).unwrap();
assert!(!rep.warnings.iter().any(|w| w.contains("dropped")), "{:?}", rep.warnings);
assert_eq!(wb.sheet(0).unwrap().comments.len(), 1, "the note is modelled");
// Twice: what was kept on the first save is read back and kept again.
let (again, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
for saved in [write_xlsx(&wb).unwrap(), write_xlsx(&again).unwrap()] {
let u = Unpacked::new(&saved);
u.assert_valid();
let sheet = sheet_part(&u, "Invoice");
let sx = u.text(&sheet);
assert!(sx.contains("<oddHeader>&amp;L&amp;G&amp;CInvoice</oddHeader>"), "{sx}");
let (ty, vml) = u.target(&sheet, &attr_of(&sx, "legacyDrawingHF", "r:id"));
assert!(ty.ends_with("/vmlDrawing"), "{ty}");
assert_eq!(u.text(&vml), HF_VML);
let pictures: Vec<(String, Vec<u8>)> = u.rels(&vml).into_iter().map(|r| (r.0, u.parts.get(&r.2).unwrap().clone())).collect();
assert_eq!(pictures, [("rId1".to_string(), LOGO.to_vec()), ("rId2".to_string(), SIGNATURE.to_vec())]);
// The notes keep their own drawing; CT_Worksheet puts legacyDrawingHF right after it.
let notes = u.target(&sheet, &attr_of(&sx, "legacyDrawing", "r:id")).1;
assert_ne!(notes, vml);
assert!(u.text(&notes).contains("ObjectType=\"Note\""));
assert!(sx.find("<headerFooter").unwrap() < sx.find("<legacyDrawing ").unwrap());
assert!(sx.find("<legacyDrawing ").unwrap() < sx.find("<legacyDrawingHF ").unwrap());
}
}
/// The shape blocks a VML drawing claims (`<o:idmap data>`) and the blocks of its shape ids
/// (`_x0000_s1025` is in block 1).
fn vml_blocks(vml: &str) -> (Vec<u64>, Vec<u64>) {
let i = vml.find("<o:idmap").unwrap_or_else(|| panic!("no idmap in {vml}"));
let data = attr_of(&vml[i..], "o:idmap", "data");
let idmap = data.split(',').map(|b| b.trim().parse().unwrap()).collect();
let spids = vml
.match_indices("_x0000_s")
.map(|(j, m)| vml[j + m.len()..].chars().take_while(char::is_ascii_digit).collect::<String>().parse::<u64>().unwrap() / 1024)
.collect();
(idmap, spids)
}
#[test]
fn notes_take_shape_blocks_the_header_pictures_dont_use() {
let (wb, _) = read_xlsx(&header_picture_package()).unwrap();
let u = Unpacked::new(&write_xlsx(&wb).unwrap());
let sheet = sheet_part(&u, "Invoice");
let sx = u.text(&sheet);
let (hf_map, hf_ids) = vml_blocks(&u.text(&u.target(&sheet, &attr_of(&sx, "legacyDrawingHF", "r:id")).1));
let (notes_map, notes_ids) = vml_blocks(&u.text(&u.target(&sheet, &attr_of(&sx, "legacyDrawing", "r:id")).1));
assert_eq!((hf_map.as_slice(), hf_ids.as_slice()), ([1].as_slice(), [1, 1].as_slice()), "the header drawing is kept as it was");
assert!(!notes_ids.is_empty());
for b in notes_map.iter().chain(&notes_ids) {
assert!(!hf_map.contains(b) && !hf_ids.contains(b), "notes reuse shape block {b}: {notes_map:?} {notes_ids:?}");
}
assert!(notes_ids.iter().all(|b| notes_map.contains(b)), "note ids outside their drawing's blocks");
}
+41
View File
@@ -739,6 +739,47 @@ fn empty_workbook_roundtrip() {
assert!(crate::sniff(&bytes) == crate::Format::Xlsx);
}
#[test]
fn cell_text_carriage_returns_survive_xlsx_roundtrip() {
let mut wb = Workbook::new();
let values = [("A1", "Line 1\r\nLine 2"), ("A2", "Part A\rPart B"), ("A3", "Item 3\nItem 4"), ("A4", "literal _x000D_ marker")];
let sheet = wb.sheet_mut(0).unwrap();
for (address, text) in values {
sheet.set_value(at(address), Value::text(text));
}
let mut formula = Cell::formula(Formula::new("A1"));
formula.value = Value::text("Cached\r\ntext");
sheet.cells.set(at("B1"), formula);
sheet.comments.insert(
at("A1"),
Comment {
author: "Tester".into(),
text: "Note\r\ntext".into(),
replies: vec![],
threaded: false,
resolved: false,
visible: false,
dates: vec![],
},
);
let bytes = write_xlsx(&wb).unwrap();
let mut package = Package::open(&bytes).unwrap();
let shared_strings = String::from_utf8(package.read("xl/sharedStrings.xml").unwrap().unwrap()).unwrap();
assert!(shared_strings.contains("Line 1_x000D_\nLine 2"), "CR must be encoded before XML parsing");
assert!(shared_strings.contains("Part A_x000D_Part B"));
assert!(!shared_strings.contains('\r'), "raw CR is normalized to LF by XML parsers");
let sheet_xml = String::from_utf8(package.read("xl/worksheets/sheet1.xml").unwrap().unwrap()).unwrap();
assert!(sheet_xml.contains("Cached_x000D_\ntext"), "formula caches also use ST_Xstring");
let (back, _) = read_xlsx(&bytes).unwrap();
for (address, text) in values {
assert_eq!(back.sheets[0].value(at(address)), Value::text(text), "{address} changed on save/reopen");
}
assert_eq!(back.sheets[0].value(at("B1")), Value::text("Cached\r\ntext"));
assert_eq!(back.sheets[0].comments.get(&at("A1")).map(|c| c.text.as_str()), Some("Note\r\ntext"));
}
#[test]
fn writing_repairs_invalid_models() {
let mut wb = Workbook::new();
+33
View File
@@ -0,0 +1,33 @@
//! libmagic (PHP `finfo`, `file`) detects an OOXML package from the first zip entries (#1090).
use gridcraft_model::Workbook;
use crate::write_xlsx;
fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
hay.windows(needle.len()).position(|w| w == needle)
}
#[test]
fn package_starts_with_content_types_rels_and_workbook_before_any_large_part() {
let mut wb = Workbook::new();
// A long, hard-to-compress description makes docProps/core.xml big.
wb.props.description =
(0..20_000).map(|i| char::from(b'a' + (i * 7 % 26) as u8)).chain((0..20_000).map(|i| char::from(b'0' + (i * i % 10) as u8))).collect();
let bytes = write_xlsx(&wb).unwrap();
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
let names: Vec<String> = (0..zip.len()).filter_map(|i| zip.by_index(i).ok().map(|f| f.name().to_string())).collect();
assert_eq!(names.first().map(String::as_str), Some("[Content_Types].xml"), "{names:?}");
assert_eq!(names.get(1).map(String::as_str), Some("_rels/.rels"), "{names:?}");
assert_eq!(names.get(2).map(String::as_str), Some("xl/_rels/workbook.xml.rels"), "{names:?}");
assert_eq!(names.get(3).map(String::as_str), Some("xl/workbook.xml"), "{names:?}");
// No data descriptor (general-purpose bit 3) on the first local header, entry name at offset 30.
assert_eq!(bytes.get(..4), Some(&b"PK\x03\x04"[..]));
assert_eq!(bytes.get(6).map(|b| b & 0x08), Some(0));
assert_eq!(bytes.get(30..49), Some(&b"[Content_Types].xml"[..]));
// libmagic looks for an `xl/` entry name within the first few KB: keep it well inside that.
let at = find(&bytes, b"xl/_rels/workbook.xml.rels");
assert!(at.is_some_and(|p| p < 2048), "xl/ entry at {at:?}");
}
+130 -4
View File
@@ -9,6 +9,9 @@ use gridcraft_model::Theme;
use crate::xml::{El, esc_attr};
const MAX_SCRIPT_FONTS: usize = 64;
const MAX_SCRIPT_FONT_CHARS: usize = 256;
const SLOTS: [(&str, usize); 12] = [
("dk1", 1),
("lt1", 0),
@@ -41,6 +44,12 @@ fn scheme_rgb(e: &El) -> Option<u32> {
None
}
/// The typeface of `<fontScheme>/<font>/<kind>` (`latin`, `ea` or `cs`), `None` when absent or
/// empty (an empty typeface means "not set" and must not overwrite the default).
fn typeface<'a>(fs: &'a El, font: &str, kind: &str) -> Option<&'a str> {
fs.path(&[font, kind]).and_then(|e| e.attr("typeface")).filter(|f| !f.is_empty())
}
pub fn read_theme(root: &El) -> Theme {
let mut t = Theme::default();
if let Some(n) = root.attr("name") {
@@ -57,16 +66,41 @@ pub fn read_theme(root: &El) -> Theme {
}
}
if let Some(fs) = el.child("fontScheme") {
if let Some(f) = fs.path(&["majorFont", "latin"]).and_then(|l| l.attr("typeface")).filter(|f| !f.is_empty()) {
if let Some(f) = typeface(fs, "majorFont", "latin") {
t.major_font = f.to_string();
}
if let Some(f) = fs.path(&["minorFont", "latin"]).and_then(|l| l.attr("typeface")).filter(|f| !f.is_empty()) {
if let Some(f) = typeface(fs, "minorFont", "latin") {
t.minor_font = f.to_string();
}
if let Some(f) = typeface(fs, "majorFont", "ea") {
t.major_font_ea = f.to_string();
}
if let Some(f) = typeface(fs, "minorFont", "ea") {
t.minor_font_ea = f.to_string();
}
if let Some(f) = typeface(fs, "majorFont", "cs") {
t.major_font_cs = f.to_string();
}
if let Some(f) = typeface(fs, "minorFont", "cs") {
t.minor_font_cs = f.to_string();
}
t.major_font_scripts = fs.child("majorFont").map(script_fonts).unwrap_or_default();
t.minor_font_scripts = fs.child("minorFont").map(script_fonts).unwrap_or_default();
}
t
}
/// The `<a:font script typeface>` entries of a font collection, in file order. A hostile file is
/// held to [`MAX_SCRIPT_FONTS`] entries of at most [`MAX_SCRIPT_FONT_CHARS`] characters each.
fn script_fonts(collection: &El) -> Vec<(String, String)> {
let capped = |v: Option<&str>| v.unwrap_or_default().chars().take(MAX_SCRIPT_FONT_CHARS).collect::<String>();
collection.kids("font").take(MAX_SCRIPT_FONTS).map(|e| (capped(e.attr("script")), capped(e.attr("typeface")))).collect()
}
fn script_fonts_xml(fonts: &[(String, String)]) -> String {
fonts.iter().map(|(script, face)| format!("<a:font script=\"{}\" typeface=\"{}\"/>", esc_attr(script), esc_attr(face))).collect()
}
/// A minimal, schema-complete theme part for `t`.
pub fn write_theme(t: &Theme) -> String {
let mut s = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n");
@@ -80,10 +114,16 @@ pub fn write_theme(t: &Theme) -> String {
s.push_str("</a:clrScheme>");
let _ = write!(
s,
"<a:fontScheme name=\"{}\"><a:majorFont><a:latin typeface=\"{}\"/><a:ea typeface=\"\"/><a:cs typeface=\"\"/></a:majorFont><a:minorFont><a:latin typeface=\"{}\"/><a:ea typeface=\"\"/><a:cs typeface=\"\"/></a:minorFont></a:fontScheme>",
"<a:fontScheme name=\"{}\"><a:majorFont><a:latin typeface=\"{}\"/><a:ea typeface=\"{}\"/><a:cs typeface=\"{}\"/>{}</a:majorFont><a:minorFont><a:latin typeface=\"{}\"/><a:ea typeface=\"{}\"/><a:cs typeface=\"{}\"/>{}</a:minorFont></a:fontScheme>",
esc_attr(name),
esc_attr(&t.major_font),
esc_attr(&t.minor_font)
esc_attr(&t.major_font_ea),
esc_attr(&t.major_font_cs),
script_fonts_xml(&t.major_font_scripts),
esc_attr(&t.minor_font),
esc_attr(&t.minor_font_ea),
esc_attr(&t.minor_font_cs),
script_fonts_xml(&t.minor_font_scripts)
);
// Format scheme: three entries per list, as the schema requires.
s.push_str("<a:fmtScheme name=\"Craft\"><a:fillStyleLst>");
@@ -113,6 +153,9 @@ pub fn write_theme(t: &Theme) -> String {
mod tests {
use super::*;
/// A Japanese Office theme: the heading font sets latin, ea and cs; the body font only ea.
const EA_THEME: &str = r#"<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="Office Theme"><a:themeElements><a:clrScheme name="Office"/><a:fontScheme name="Office"><a:majorFont><a:latin typeface="Calibri"/><a:ea typeface="游ゴシック"/><a:cs typeface="Arial"/></a:majorFont><a:minorFont><a:ea typeface="MS Pゴシック"/></a:minorFont></a:fontScheme></a:themeElements></a:theme>"#;
#[test]
fn roundtrip() {
let mut t = Theme::default();
@@ -125,4 +168,87 @@ mod tests {
let back = read_theme(&crate::xml::parse(x.as_bytes()).unwrap());
assert_eq!(back, t);
}
#[test]
fn ea_and_cs_typefaces_roundtrip() {
let t = read_theme(&crate::xml::parse(EA_THEME.as_bytes()).unwrap());
assert_eq!(t.major_font, "Calibri");
assert_eq!(t.major_font_ea, "游ゴシック");
assert_eq!(t.major_font_cs, "Arial");
// No `<a:latin>` under the minor font: the default stays.
assert_eq!(t.minor_font, Theme::default().minor_font);
assert_eq!(t.minor_font_ea, "MS Pゴシック");
assert!(t.minor_font_cs.is_empty());
let x = write_theme(&t);
assert!(
x.contains(r#"<a:majorFont><a:latin typeface="Calibri"/><a:ea typeface="游ゴシック"/><a:cs typeface="Arial"/></a:majorFont>"#),
"{x}"
);
assert!(
x.contains(r#"<a:minorFont><a:latin typeface="Calibri"/><a:ea typeface="MS Pゴシック"/><a:cs typeface=""/></a:minorFont>"#),
"{x}"
);
assert_eq!(read_theme(&crate::xml::parse(x.as_bytes()).unwrap()), t);
}
#[test]
fn theme_without_ea_or_cs_writes_empty_attributes() {
let t = Theme::default();
let x = write_theme(&t);
assert!(x.contains(r#"<a:majorFont><a:latin typeface="Calibri"/><a:ea typeface=""/><a:cs typeface=""/></a:majorFont>"#), "{x}");
assert!(x.contains(r#"<a:minorFont><a:latin typeface="Calibri"/><a:ea typeface=""/><a:cs typeface=""/></a:minorFont>"#), "{x}");
}
#[test]
fn explicit_empty_ea_and_cs_read_as_unset() {
let xml = EA_THEME
.replace(r#"<a:ea typeface="游ゴシック"/>"#, r#"<a:ea typeface=""/>"#)
.replace(r#"<a:ea typeface="MS Pゴシック"/>"#, r#"<a:ea typeface=""/>"#);
let t = read_theme(&crate::xml::parse(xml.as_bytes()).unwrap());
assert!(t.major_font_ea.is_empty() && t.minor_font_ea.is_empty());
assert_eq!(t.major_font_cs, "Arial");
let x = write_theme(&t);
assert!(x.contains(r#"<a:majorFont><a:latin typeface="Calibri"/><a:ea typeface=""/><a:cs typeface="Arial"/></a:majorFont>"#), "{x}");
assert_eq!(read_theme(&crate::xml::parse(x.as_bytes()).unwrap()), t);
}
#[test]
fn ea_and_cs_typefaces_are_escaped() {
let t = Theme { major_font_ea: "游&ゴシック".into(), minor_font_cs: "A<B>\"C\"".into(), ..Theme::default() };
let x = write_theme(&t);
assert!(x.contains(r#"<a:ea typeface="游&amp;ゴシック"/>"#), "{x}");
assert!(x.contains(r#"<a:cs typeface="A&lt;B&gt;&quot;C&quot;"/>"#), "{x}");
assert_eq!(read_theme(&crate::xml::parse(x.as_bytes()).unwrap()), t);
}
/// An Arabic Office theme: Arabic, Hebrew and Japanese faces in the per-script lists.
const SCRIPT_THEME: &str = r#"<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="Office Theme"><a:themeElements><a:fontScheme name="Office"><a:majorFont><a:latin typeface="Calibri Light"/><a:ea typeface=""/><a:cs typeface="Times New Roman"/><a:font script="Arab" typeface="Times New Roman"/><a:font script="Hebr" typeface="Times New Roman"/><a:font script="Jpan" typeface="游ゴシック Light"/></a:majorFont><a:minorFont><a:latin typeface="Calibri"/><a:ea typeface=""/><a:cs typeface="Arial"/><a:font script="Arab" typeface="Arial"/><a:font script="Hebr" typeface="Arial"/><a:font script="Jpan" typeface="游ゴシック"/></a:minorFont></a:fontScheme></a:themeElements></a:theme>"#;
#[test]
fn per_script_typefaces_roundtrip_in_file_order() {
let t = read_theme(&crate::xml::parse(SCRIPT_THEME.as_bytes()).unwrap());
let pairs = |v: &[(String, String)]| v.iter().map(|(s, f)| (s.clone(), f.clone())).collect::<Vec<_>>();
let expect =
|a: &str, j: &str| vec![("Arab".to_string(), a.to_string()), ("Hebr".to_string(), a.to_string()), ("Jpan".to_string(), j.to_string())];
assert_eq!(pairs(&t.major_font_scripts), expect("Times New Roman", "游ゴシック Light"));
assert_eq!(pairs(&t.minor_font_scripts), expect("Arial", "游ゴシック"));
let x = write_theme(&t);
assert!(
x.contains(r#"<a:cs typeface="Times New Roman"/><a:font script="Arab" typeface="Times New Roman"/><a:font script="Hebr" typeface="Times New Roman"/><a:font script="Jpan" typeface="游ゴシック Light"/></a:majorFont>"#),
"{x}"
);
assert_eq!(read_theme(&crate::xml::parse(x.as_bytes()).unwrap()), t);
}
#[test]
fn hostile_per_script_lists_are_capped() {
let entries: String = (0..100).map(|i| format!(r#"<a:font script="S{i}" typeface="{}"/>"#, "x".repeat(300))).collect();
let xml = format!(
r#"<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:themeElements><a:fontScheme><a:majorFont><a:latin typeface="Calibri"/>{entries}</a:majorFont></a:fontScheme></a:themeElements></a:theme>"#
);
let t = read_theme(&crate::xml::parse(xml.as_bytes()).unwrap());
assert_eq!(t.major_font_scripts.len(), 64);
assert!(t.major_font_scripts.iter().all(|(script, face)| script.chars().count() <= 256 && face.chars().count() <= 256));
}
}
+7 -4
View File
@@ -226,6 +226,7 @@ pub fn write_xlsx(wb: &Workbook, kind: XlsxKind) -> Result<Vec<u8>, IoError> {
cell_pictures: Default::default(),
kept: Default::default(),
};
out.kept.reserve_vml_blocks(wb);
out.default_type("rels", "application/vnd.openxmlformats-package.relationships+xml");
out.default_type("xml", "application/xml");
@@ -481,13 +482,15 @@ pub fn write_xlsx(wb: &Workbook, kind: XlsxKind) -> Result<Vec<u8>, IoError> {
ct.push_str("</Types>");
let mut zip = ZipOut::new();
zip.add("[Content_Types].xml", ct.as_bytes())?;
// Conventional order: rels, docProps, workbook, the rest.
// Excel's order: rels, workbook rels, workbook, the rest, document properties last. libmagic (PHP
// `finfo`, `file`) recognises an OOXML package only when an `xl/` entry name appears within the
// first few KB, so no user-sized part (document properties, a long description) may precede it.
let rank = |n: &str| match n {
"_rels/.rels" => 0,
n if n.starts_with("docProps/") => 1,
"xl/_rels/workbook.xml.rels" => 1,
"xl/workbook.xml" => 2,
"xl/_rels/workbook.xml.rels" => 3,
_ => 4,
n if n.starts_with("docProps/") => 4,
_ => 3,
};
let mut parts = std::mem::take(&mut out.parts);
parts.sort_by_key(|(n, _)| rank(n));
+48 -5
View File
@@ -310,6 +310,7 @@ pub fn esc(s: &str) -> String {
'&' => o.push_str("&amp;"),
'<' => o.push_str("&lt;"),
'>' => o.push_str("&gt;"),
'\r' => o.push_str("&#13;"),
c if is_xml_char(c) => o.push(c),
_ => {}
}
@@ -340,15 +341,16 @@ fn is_xml_char(c: char) -> bool {
matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}')
}
/// ST_Xstring encoding for cell text: characters XML can't carry become `_xHHHH_`, and literal
/// `_xHHHH_` sequences are protected with `_x005F_`. The result still needs [`esc`].
/// ST_Xstring encoding for cell text: characters XML can't carry and carriage returns become
/// `_xHHHH_`, and literal `_xHHHH_` sequences are protected with `_x005F_`. The result still needs
/// [`esc`].
pub fn encode_xstring(s: &str) -> String {
let mut o = String::with_capacity(s.len());
let chars: Vec<char> = s.chars().collect();
for (i, &c) in chars.iter().enumerate() {
if c == '_' && looks_like_escape(&chars, i) {
o.push_str("_x005F_");
} else if !is_xml_char(c) || (c.is_control() && !matches!(c, '\t' | '\n' | '\r')) {
} else if !is_xml_char(c) || (c.is_control() && !matches!(c, '\t' | '\n')) {
o.push_str(&format!("_x{:04X}_", c as u32 & 0xFFFF));
} else {
o.push(c);
@@ -361,6 +363,20 @@ fn looks_like_escape(chars: &[char], i: usize) -> bool {
chars.get(i + 1) == Some(&'x') && chars.get(i + 6) == Some(&'_') && (i + 2..i + 6).all(|k| chars.get(k).is_some_and(|c| c.is_ascii_hexdigit()))
}
fn escape_unit(chars: &[char], i: usize) -> Option<u32> {
let hex: String = chars.get(i + 2..i + 6)?.iter().collect();
u32::from_str_radix(&hex, 16).ok()
}
/// The character for a high surrogate escape at `i` (value `high`) followed directly by a low surrogate escape.
fn surrogate_pair(chars: &[char], i: usize, high: u32) -> Option<char> {
if !(0xD800..=0xDBFF).contains(&high) || chars.get(i + 7) != Some(&'_') || !looks_like_escape(chars, i + 7) {
return None;
}
let low = escape_unit(chars, i + 7).filter(|l| (0xDC00..=0xDFFF).contains(l))?;
char::from_u32(0x10000 + ((high - 0xD800) << 10) + (low - 0xDC00))
}
/// Inverse of [`encode_xstring`].
pub fn decode_xstring(s: &str) -> String {
if !s.contains("_x") {
@@ -371,12 +387,18 @@ pub fn decode_xstring(s: &str) -> String {
let mut i = 0;
while i < chars.len() {
if chars[i] == '_' && looks_like_escape(&chars, i) {
let hex: String = chars[i + 2..i + 6].iter().collect();
if let Some(ch) = u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) {
let unit = escape_unit(&chars, i);
if let Some(ch) = unit.and_then(char::from_u32) {
o.push(ch);
i += 7;
continue;
}
// A high surrogate escape followed by a low surrogate escape is one UTF-16 pair.
if let Some(ch) = unit.and_then(|high| surrogate_pair(&chars, i, high)) {
o.push(ch);
i += 14;
continue;
}
}
o.push(chars[i]);
i += 1;
@@ -542,11 +564,32 @@ mod tests {
#[test]
fn xstring() {
assert_eq!(decode_xstring("a_x000D_b"), "a\rb");
assert_eq!(encode_xstring("a\r\nb\rc"), "a_x000D_\nb_x000D_c");
assert_eq!(decode_xstring(&encode_xstring("a\r\nb\rc")), "a\r\nb\rc");
assert_eq!(encode_xstring("a\u{1}b"), "a_x0001_b");
assert_eq!(decode_xstring(&encode_xstring("_x0041_")), "_x0041_");
assert_eq!(decode_xstring("_x00"), "_x00");
}
#[test]
fn xstring_surrogate_pairs() {
assert_eq!(decode_xstring("a_xD83D__xDE00_b"), "a\u{1F600}b");
assert_eq!(decode_xstring("_xd83d__xde00_"), "\u{1F600}");
// Lone surrogates stay as literal escape text.
assert_eq!(decode_xstring("_xD83D_"), "_xD83D_");
assert_eq!(decode_xstring("_xDE00_"), "_xDE00_");
assert_eq!(decode_xstring("_xD83D_x"), "_xD83D_x");
assert_eq!(decode_xstring("_xD83D__x0041_"), "_xD83D_A");
// A protected literal escape is not a low surrogate.
assert_eq!(decode_xstring("_xD83D__x005F_xDE00_"), "_xD83D__xDE00_");
}
#[test]
fn text_escaping_preserves_carriage_returns() {
assert_eq!(esc("a\rb"), "a&#13;b");
assert_eq!(esc_attr("a\rb"), "a&#13;b");
}
#[test]
fn numbers() {
assert_eq!(num(1.0), "1");
+9
View File
@@ -1,5 +1,14 @@
# Decisions
## Bound repeated XLSX column expansion (#1022)
Limit each worksheet to 1,000,000 nondefault column assignments across all `<cols>` blocks.
This allows more than 60 full-grid passes while bounding repeated work on overlapping spans;
counting only the final map size would not limit that work. Charge each clipped span before
inserting it, after the existing default-record skip. Exceeding the limit returns the existing
`IoError::TooLarge`, which aborts the import instead of silently dropping a partially read sheet.
Keep the current last-wins semantics rather than introduce interval storage or normalization.
## Formula localization uses the existing interface preference
PR #98 builds on the persisted `UiState.language` introduced by #31 and extended by #104.
+18
View File
@@ -11,8 +11,17 @@ documentation (the Office bundle is off limits under this repo's clean-room rule
support comes from `crates/engine/src/io.rs` (`FileKind`), `crates/xlsx/src` and the tests in
`crates/xlsx/src/tests/` (round-trip, fixtures, malformed input, CSV, ODS, XLSB, XLS, PivotTables).
Legacy scalar `INDEX(range, MATCH(...))` formulas remain ordinary formulas on save, including when
shared formulas are expanded during import (`array_export.rs`, #1505).
## Workbook formats
XLSX column declarations are limited to 1,000,000 expanded column assignments per worksheet,
including repeated overlaps across `<cols>` blocks. Spans are clipped to the grid and records
that would be skipped as defaults are excluded before charging the limit. Excess work returns
the existing file-too-large error; accepted overlaps retain their existing last-wins behavior
(#1022).
Ink created with Draw is written as standard, unfilled DrawingML polylines, without visible point
captions or private metadata. Supported numeric polylines reopen as editable ink, retaining points,
colour, integer width and anchor. Curves, filled paths, transforms and unsupported line styles are
@@ -35,6 +44,9 @@ values) is still dropped.
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, shared strings (with their Japanese phonetic runs — ふりがな — kept through open + save: `<rPh>` offsets and readings and `<phoneticPr>` are written back next to the same string; the table holds each text once, so duplicate `<si>` items whose text is the same but whose readings differ keep only the first reading, with a warning on open), styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings, show objects (`workbookPr showObjects`). **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), form controls, ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes), and chart sheets (#727: the sheet, drawing, chart, style and colour parts are written back unchanged in their place, and left out when a sheet they plot was deleted or renamed or a worksheet took their name; they can't be edited or created); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) A picture used on several sheets is written once (#985). | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, shared strings (with their Japanese phonetic runs — ふりがな — kept through open + save: `<rPh>` offsets and readings and `<phoneticPr>` are written back next to the same string; the table holds each text once, so duplicate `<si>` items whose text is the same but whose readings differ keep only the first reading, with a warning on open), styles, themes, merges, CF, validation, tables, notes (with their shown/hidden state) and threaded comments with replies, resolved state and timestamps (`threadedComments` and `persons` parts, #300), hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings, show objects (`workbookPr showObjects`). **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) A picture used on several sheets is written once (#985). | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, shared strings (with their Japanese phonetic runs — ふりがな — kept through open + save: `<rPh>` offsets and readings and `<phoneticPr>` are written back next to the same string; the table holds each text once, so duplicate `<si>` items whose text is the same but whose readings differ keep only the first reading, with a warning on open), styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, Allow Edit Ranges (`protectedRanges` with their password hashes, #307), calc settings, show objects (`workbookPr showObjects`). **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) A picture used on several sheets is written once (#985). | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes (the major/minor `latin`, East Asian `ea` and complex-script `cs` typefaces and the per-script `<a:font script>` lists are kept through a save), merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), form controls, ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes), pictures in headers and footers (`&G`, #1257: their VML drawing and images are written back unchanged; they are not shown, and can't be inserted), and chart sheets (#727: the sheet, drawing, chart, style and colour parts are written back unchanged in their place, and left out when a sheet they plot was deleted or renamed or a worksheet took their name; they can't be edited or created); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes (the major/minor `latin`, East Asian `ea` and complex-script `cs` typefaces and the per-script `<a:font script>` lists are kept through a save), merges, CF, validation, tables, notes (with their shown/hidden state) and threaded comments with replies, resolved state and timestamps (`threadedComments` and `persons` parts, #300), hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes (the major/minor `latin`, East Asian `ea` and complex-script `cs` typefaces and the per-script `<a:font script>` lists are kept through a save), merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, Allow Edit Ranges (`protectedRanges` with their password hashes, #307), calc settings. **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
| `.xlsx` Strict Open XML | R/W | yes | no (writes transitional) | Excel also saves transitional by default | fixtures |
| `.xlsm` Macro-Enabled Workbook | R/W | yes | yes, macros kept, not run | The VBA project is kept byte-for-byte and written back only into `.xlsm`/`.xltm` (#6); macros never run. Saved with the macro-enabled workbook content type | `roundtrip.rs` (`vba_project_roundtrip`, `package_kind_sets_workbook_content_type`), engine `io.rs` (`macros_are_saved_only_into_xlsm`) |
| `.xltx` / `.xltm` templates | R/W | yes | partial | Saved with the template (and macro-enabled template) content type, chosen by extension; `.xltm` keeps a VBA project, `.xltx` drops it; "new from template" behaviour missing | `roundtrip.rs` (`package_kind_sets_workbook_content_type`), engine `io.rs` |
@@ -96,6 +108,8 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
| Date | Change | Summary |
|---|---|---|
| 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`) |
| 2026-10-20 | minor | A workbook theme's major/minor per-script typefaces (`<a:font script="Arab" typeface="…"/>`, also Hebr, Jpan and the rest) are read and written back in file order after `<a:cs>`; they were dropped before. A hostile file is held to 64 entries of 256 characters |
| 2026-10-20 | trivial | A workbook theme's major/minor East Asian (`<a:ea>`) and complex-script (`<a:cs>`) typefaces are read and written back, so a Japanese theme's fonts (e.g. 游ゴシック) survive open + save; they were deleted before because the model had nowhere to keep them. Rendering still resolves fonts without them (follow-up) |
| 2026-10-19 | minor | .xls import skips dialog sheets (WsBool `fDialog`), which share the worksheet BOF type and were read as empty worksheets |
| 2026-10-19 | minor | `.xls` import reads WINDOW2: right-to-left sheets (common in older Arabic and Hebrew files) open right-to-left, and hidden gridlines, headings and zero values and Show Formulas carry over |
| 2026-10-19 | trivial | XLSX text boxes and shapes keep right-to-left paragraph direction (`a:pPr rtl`, ECMA-376 Part 1 §21.1.2.2.7): read from the first paragraph and written on every paragraph; output without it is unchanged |
@@ -104,7 +118,11 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
| 2026-10-19 | minor | HTML (Save as Web Page and clipboard) marks right-to-left sheets and right-to-left cell text with `dir` |
| 2026-10-16 | trivial | XLSX charts: `[0]!Name` (a workbook-scoped defined name) in series references is read as the name, so the series resolve instead of showing `#NAME?`. Names in chart references are written qualified (`[0]!Name`, or `Sheet!Name` for a sheet-scoped name): Excel draws a chart with a bare name blank (#1686) |
| 2026-10-16 | trivial | XLSX reads and writes the workbook's show-objects setting (`workbookPr showObjects`: all, placeholders, none), which Ctrl+6 switches; a file without it is saved without it |
| 2026-10-16 | trivial | XLSX: pictures in headers and footers (`&G`, the VML drawing `<legacyDrawingHF>` names and its images) are kept on save; they are still not shown (#1257) |
| 2026-10-16 | minor | XLSX column imports refuse more than 1,000,000 cumulative expanded assignments per sheet before expanding the next span; repeated overlaps cannot bypass the work limit (#1022) |
| 2026-10-15 | minor | XLSX: formulas and PivotTable sources quote sheet names like Excel (a leading `.`, R1C1-like and cell-like names), so `'.x'!A1` no longer saves as `.x!A1`, which Excel could not open (#1458) |
| 2026-10-15 | minor | XLSX text preserves CR and CRLF across save/reopen; shared strings use `_x000D_` and other XML text uses `&#13;` (#1488) |
| 2026-10-15 | minor | XLSX save keeps legacy `INDEX`/`MATCH` formulas scalar instead of marking them as dynamic arrays (#1505) |
| 2026-10-14 | trivial | Formula parser: text longer than Excel's 8,192 characters, and postfix chains (`1%%%…`, `f(1)(1)…`) past the 256-level nesting limit, are parse errors (the cell keeps its formula text and cached value) instead of trees deep enough to overflow the stack when a hostile file is opened, saved or dropped |
| 2026-10-14 | minor | XLSX formulas read with dynamic-array metadata (`cm`) keep it on save, so Excel does not recalculate them with implicit intersection |
| 2026-10-13 | trivial | XLSX export keeps a picture shared by several sheets as one media part (#985) |
+3
View File
@@ -74,6 +74,7 @@ separators, number/date formats, machine translation with agent review, and scri
| Indonesian | id | 0 (0%) | no | Latin: yes | no | none | 5–8 h |
| Japanese | ja | 268 (~12%) | no | CJK system-font fallback; IME unverified; no furigana display, but phonetic runs (ふりがな) survive an XLSX open + save | no | menus only | 8–12 h |
| Japanese | ja | 943 (~43%) | dialogs and messages | CJK system-font fallback; IME unverified; no furigana display | no | menus, File view, dialogs, messages | 3–5 h |
| Japanese | ja | 268 (~12%) | no | CJK system-font fallback; the theme's East Asian typeface (`<a:ea>`) survives open + save (rendering does not use it yet); IME unverified; no furigana display | no | menus only | 8–12 h |
| German | de | 266 (~12%) | no | Latin: yes | no | menus only | 4–8 h |
| Italian | it | 971 (~44%) | dialogs, tooltips, pivot pane | Latin: yes | no | menus, File view, dialogs, pivot pane | 3–5 h |
| Korean | ko | 266 (~12%) | no | Hangul via the CJK fallback; IME unverified | no | menus only | 6–10 h |
@@ -108,6 +109,8 @@ language (none so far) and of localized function names.
| 2026-10-20 | minor | Japanese: furigana (`rPh` phonetic runs and `<phoneticPr>` in `xl/sharedStrings.xml`) is no longer deleted by an open + save; it is kept on the workbook and written back (a `<phoneticPr>` without runs is kept too, while duplicate texts with different readings keep only the first, reported on open). Display of the readings is still missing (`crates/xlsx/src/tests/phonetic_tests.rs`) |
| 2026-10-20 | minor | Japanese catalog (`i18n/ja.rs`) covers every key the Brazilian Portuguese catalog translates: the 398-label shared core moved verbatim plus 545 dialog, message, Backstage and menu strings — 943 keys, ~12% → ~43%. A test parses `pt.rs`'s source, so the two catalogs can't drift apart again. Portuguese re-measured at 943 (was 923) |
| 2026-10-20 | minor | Dutch (nl) interface catalog (`i18n/nl.rs`, 518 labels, ~24%): the shared key set every language must cover (ribbon, context menus, File view, dialog titles, Options page), matching the German, French and Czech catalogs; `nl-NL`, `nl-BE` locale tags resolve to Dutch |
| 2026-10-20 | minor | XLSX: a theme's per-script typefaces (`<a:font script="Arab">`, Hebr, Jpan…) are kept through open + save, so Arabic Excel's theme fonts for shapes, charts and theme-font cells survive a GridCraft save |
| 2026-10-20 | trivial | XLSX: a workbook theme's major/minor East Asian (`<a:ea>`) and complex-script (`<a:cs>`) typefaces are kept through open + save, so a Japanese workbook keeps its theme's East Asian font; rendering does not resolve them yet (follow-up) |
| 2026-10-19 | minor | Defined-name length counts characters, not bytes: Arabic names up to 255 letters are accepted, and Create from Selection keeps long Arabic headers |
| 2026-10-19 | minor | Table names are limited to 255 Unicode characters |
| 2026-10-19 | minor | Right-to-left sheets in legacy `.xls` files (common in older Arabic workbooks) open right-to-left |