mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-24 10:46:50 -06:00
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:
@@ -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
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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}")),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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="","",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),
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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)]);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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>&L&G&CInvoice</oddHeader><oddFooter>&C&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>&L&G&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(¬es).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(¬es_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");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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="游&ゴシック"/>"#), "{x}");
|
||||
assert!(x.contains(r#"<a:cs typeface="A<B>"C""/>"#), "{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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -310,6 +310,7 @@ pub fn esc(s: &str) -> String {
|
||||
'&' => o.push_str("&"),
|
||||
'<' => o.push_str("<"),
|
||||
'>' => o.push_str(">"),
|
||||
'\r' => o.push_str(" "),
|
||||
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 b");
|
||||
assert_eq!(esc_attr("a\rb"), "a b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers() {
|
||||
assert_eq!(num(1.0), "1");
|
||||
|
||||
Reference in New Issue
Block a user