gridcraft/crates/engine/src/io.rs

473 lines
20 KiB
Rust

//! Opening and saving files: XLSX, legacy XLS import, XLSB and ODS data import, CSV/TSV, JSON (debug), HTML export.
use std::fmt::Write as _;
use gridcraft_core::{CellRef, RangeRef};
use gridcraft_model::{Sheet, Workbook};
use crate::{EngineError, Result};
/// File formats GridCraft reads or writes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FileKind {
Xlsx,
/// Excel 97-2003 workbook (import only).
Xls,
Xlsb,
Ods,
Csv,
Tsv,
Json,
Html,
}
impl FileKind {
pub fn from_path(path: &str) -> Option<FileKind> {
let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase();
Some(match ext.as_str() {
"xlsx" | "xlsm" | "xltx" | "xltm" => FileKind::Xlsx,
"xls" | "xlt" => FileKind::Xls,
"xlsb" => FileKind::Xlsb,
"ods" => FileKind::Ods,
"csv" => FileKind::Csv,
"tsv" | "tab" | "txt" => FileKind::Tsv,
"json" | "scjson" => FileKind::Json,
"html" | "htm" => FileKind::Html,
_ => return None,
})
}
}
/// Parses a file's bytes into a workbook. `name` picks the format by extension when the bytes
/// don't say.
pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
let kind = FileKind::from_path(name);
let sniffed = gridcraft_xlsx::sniff(bytes);
if sniffed == gridcraft_xlsx::Format::Encrypted {
return Err(EngineError::Other(format!(
"'{name}' is password-protected. GridCraft can't open encrypted workbooks yet; remove the password in Excel and try again."
)));
}
if sniffed == gridcraft_xlsx::Format::Xls {
let (wb, report) = gridcraft_xlsx::read_xls(bytes).map_err(|e| EngineError::Other(format!("We can't open '{name}': {e}")))?;
return Ok((wb, report.warnings));
}
if sniffed == gridcraft_xlsx::Format::LegacyBinary {
return Err(EngineError::Other(format!(
"'{name}' is a legacy binary file but not an Excel workbook (no workbook stream). GridCraft can't open it."
)));
}
// Content wins over the extension: an ODS or XLSB package is imported as what it is,
// whatever it is called; the extension only decides when the bytes don't say.
let import = match sniffed {
gridcraft_xlsx::Format::Ods => Some(FileKind::Ods),
gridcraft_xlsx::Format::Xlsb => Some(FileKind::Xlsb),
_ => kind.filter(|k| matches!(k, FileKind::Ods | FileKind::Xlsb)),
};
if let Some(import) = import {
let read = if import == FileKind::Ods { gridcraft_xlsx::read_ods } else { gridcraft_xlsx::read_xlsb };
let (wb, report) = read(bytes).map_err(|e| EngineError::Other(format!("We can't import '{name}': {e}")))?;
return Ok((wb, report.warnings));
}
if sniffed == gridcraft_xlsx::Format::Xlsx || kind == Some(FileKind::Xlsx) {
let (wb, report) = gridcraft_xlsx::read_xlsx(bytes).map_err(|e| EngineError::Other(format!("We can't open '{name}': {e}")))?;
return Ok((wb, report.warnings));
}
match kind {
Some(FileKind::Json) => {
let mut wb: Workbook = serde_json::from_slice(bytes).map_err(|e| EngineError::Other(format!("not a GridCraft JSON workbook: {e}")))?;
wb.styles.rebuild_index();
if wb.sheets.is_empty() {
wb = Workbook::new();
}
Ok((wb, vec![]))
}
Some(FileKind::Tsv) => {
let opts = gridcraft_xlsx::CsvOptions { delimiter: b'\t', ..Default::default() };
let mut wb = gridcraft_xlsx::read_csv(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
// Tab is what makes it a .tsv/.txt; saved as .csv it gets the CSV default.
wb.csv_delimiter = None;
rename_first_sheet(&mut wb, name);
Ok((wb, vec![]))
}
_ => {
let opts = gridcraft_xlsx::CsvOptions { delimiter: 0, ..Default::default() };
let mut wb = gridcraft_xlsx::read_csv(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
rename_first_sheet(&mut wb, name);
Ok((wb, vec![]))
}
}
}
/// 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");
let clean: String = stem.chars().filter(|c| !matches!(c, ':' | '\\' | '/' | '?' | '*' | '[' | ']')).take(31).collect();
let clean = clean.trim_matches('\'').to_string();
if !clean.is_empty()
&& let Some(sh) = wb.sheet_mut(0)
{
sh.name = clean;
}
}
/// Encodes a workbook in the format chosen by `path`'s extension.
pub fn save_bytes(wb: &Workbook, path: &str) -> Result<Vec<u8>> {
let sheet = wb.active_sheet;
match FileKind::from_path(path).unwrap_or(FileKind::Xlsx) {
// A macro-free .xlsx can't hold a VBA project (Excel refuses such a file), so only a .xlsm
// keeps the macros; any other name saves without them, as Excel does.
FileKind::Xlsx if wb.vba_project.is_some() && !path.to_ascii_lowercase().ends_with(".xlsm") => {
let mut plain = wb.clone();
plain.vba_project = None;
gridcraft_xlsx::write_xlsx(&plain).map_err(|e| EngineError::Other(e.to_string()))
}
FileKind::Xlsx => gridcraft_xlsx::write_xlsx(wb).map_err(|e| EngineError::Other(e.to_string())),
FileKind::Xls => {
Err(EngineError::Other("Excel 97-2003 (.xls) is supported for import only. Save as .xlsx or another supported export format.".into()))
}
FileKind::Xlsb => Err(EngineError::Other("XLSB is supported for data import only. Save as .xlsx or another supported export format.".into())),
FileKind::Ods => Err(EngineError::Other("ODS is supported for data import only. Save as .xlsx or another supported export format.".into())),
// A workbook read from CSV keeps the separator it was read with (a `;` file stays `;`).
FileKind::Csv => Ok(wb.sheet(sheet).map(|sh| gridcraft_xlsx::write_csv(sh, wb, wb.csv_delimiter.unwrap_or(b','))).unwrap_or_default()),
FileKind::Tsv => Ok(wb.sheet(sheet).map(|sh| gridcraft_xlsx::write_csv(sh, wb, b'\t')).unwrap_or_default()),
FileKind::Json => serde_json::to_vec_pretty(wb).map_err(|e| EngineError::Other(e.to_string())),
FileKind::Html => Ok(to_html(wb, sheet).into_bytes()),
}
}
fn esc(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
fn html_cell_css(wb: &Workbook, sh: &Sheet, c: CellRef) -> String {
let st = wb.styles.get(sh.style_id(c));
let mut css = String::new();
if st.font.bold {
css.push_str("font-weight:bold;");
}
if st.font.italic {
css.push_str("font-style:italic;");
}
if let Some(h) = st.font.color.hex(&wb.theme) {
let _ = write!(css, "color:{h};");
}
if st.fill.pattern != gridcraft_model::PatternType::None
&& let Some(h) = st.fill.fg.hex(&wb.theme)
{
let _ = write!(css, "background:{h};");
}
let v = sh.value(c);
let align = match st.align.h {
gridcraft_model::HAlign::Center => "center",
gridcraft_model::HAlign::Right => "right",
gridcraft_model::HAlign::Left => "left",
_ if v.is_number() => "right",
_ => "left",
};
let _ = write!(css, "text-align:{align};");
css
}
const CLIPBOARD_HTML_MAX_BYTES: usize = 4 * 1024 * 1024;
const CLIPBOARD_HTML_MAX_CELLS: u64 = 10_000;
/// An HTML fragment for the same range as `plain_text`, previously produced by
/// `display::range_text`. Large copies keep plain text only; never return a partial table.
pub(crate) fn range_html(wb: &Workbook, sheet: usize, r: RangeRef, plain_text: &str) -> Option<String> {
let r = crate::display::clipboard_range(r);
if r.count() > CLIPBOARD_HTML_MAX_CELLS || plain_text.len() > CLIPBOARD_HTML_MAX_BYTES {
return None;
}
let sh = wb.sheet(sheet)?;
let mut out = ClipboardHtml(String::new());
out.write_str("<table style=\"border-collapse:collapse;font-family:Calibri,Carlito,Arial,sans-serif;font-size:11pt\">").ok()?;
for row in r.start.row..=r.end.row {
if sh.is_row_hidden(row) {
continue;
}
out.write_str("<tr>").ok()?;
for col in r.start.col..=r.end.col {
let c = CellRef::new(row, col);
let span = if let Some(merged) = sh.merges.iter().find(|m| m.contains(c)).and_then(|m| m.intersection(&r)) {
let first_row = (merged.start.row..=merged.end.row).find(|&row| !sh.is_row_hidden(row))?;
if c != CellRef::new(first_row, merged.start.col) {
continue;
}
let rows = (first_row..=merged.end.row).filter(|&row| !sh.is_row_hidden(row)).count();
format!(" rowspan=\"{rows}\" colspan=\"{}\"", merged.width())
} else {
String::new()
};
let css = html_cell_css(wb, sh, c);
write!(out, "<td style=\"border:1px solid #d4d4d4;padding:2px 4px;white-space:pre-wrap;{css}\"{span}>").ok()?;
// Use the visible selected cell, even when the merge's original anchor
// is outside the range or hidden. This matches the plain-text copy.
let text = crate::display::cell_text(wb, sh, c);
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'&' => out.write_str("&amp;"),
'<' => out.write_str("&lt;"),
'>' => out.write_str("&gt;"),
'"' => out.write_str("&quot;"),
'\r' => {
if chars.peek() == Some(&'\n') {
chars.next();
}
out.write_str("<br>")
}
'\n' => out.write_str("<br>"),
_ => out.write_char(ch),
}
.ok()?;
}
out.write_str("</td>").ok()?;
}
out.write_str("</tr>").ok()?;
}
out.write_str("</table>").ok()?;
Some(out.0)
}
/// Enforce the budget while escaping, before HTML expansion can allocate a large string.
struct ClipboardHtml(String);
impl std::fmt::Write for ClipboardHtml {
fn write_str(&mut self, s: &str) -> std::fmt::Result {
if s.len() > CLIPBOARD_HTML_MAX_BYTES.saturating_sub(self.0.len()) {
return Err(std::fmt::Error);
}
self.0.push_str(s);
Ok(())
}
}
/// A sheet as a standalone HTML table (Save as Web Page).
pub fn to_html(wb: &Workbook, sheet: usize) -> String {
let Some(sh) = wb.sheet(sheet) else { return String::new() };
let mut out = String::new();
let _ = write!(
out,
"<!doctype html><html><head><meta charset=\"utf-8\"><title>{}</title><style>table{{border-collapse:collapse;font-family:Calibri,Carlito,Arial,sans-serif;font-size:11pt}}td{{border:1px solid #d4d4d4;padding:2px 4px;white-space:nowrap}}</style></head><body><table>",
esc(&sh.name)
);
if let Some(r) = sh.used_range() {
for row in r.start.row..=r.end.row.min(r.start.row + 100_000) {
if sh.is_row_hidden(row) {
continue;
}
out.push_str("<tr>");
for col in r.start.col..=r.end.col {
let c = CellRef::new(row, col);
if sh.merges.iter().any(|m| m.contains(c) && m.start != c) {
continue;
}
let css = html_cell_css(wb, sh, c);
let span = sh
.merges
.iter()
.find(|m| m.start == c)
.map(|m| format!(" rowspan=\"{}\" colspan=\"{}\"", m.height(), m.width()))
.unwrap_or_default();
let _ = write!(out, "<td style=\"{css}\"{span}>{}</td>", esc(&crate::display::cell_text(wb, sh, c)));
}
out.push_str("</tr>");
}
}
out.push_str("</table></body></html>");
out
}
#[cfg(not(target_arch = "wasm32"))]
pub fn read_file(path: &str) -> Result<Vec<u8>> {
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if meta.len() > 2 * 1024 * 1024 * 1024 {
return Err(EngineError::Other("files larger than 2 GB aren't supported".into()));
}
std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
pub fn read_file(path: &str) -> Result<Vec<u8>> {
Err(EngineError::Other(format!("{path}: no file system in the browser")))
}
#[cfg(not(target_arch = "wasm32"))]
pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
// Write to a temporary file, then rename: a crash never leaves a half-written workbook.
let tmp = format!("{path}.sctmp");
std::fs::write(&tmp, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
std::fs::rename(&tmp, path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
pub fn write_file(path: &str, _bytes: &[u8]) -> Result<()> {
Err(EngineError::Other(format!("{path}: no file system in the browser")))
}
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
pub fn base64_encode(data: &[u8]) -> String {
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
for chunk in data.chunks(3) {
let b = [chunk.first().copied().unwrap_or(0), chunk.get(1).copied().unwrap_or(0), chunk.get(2).copied().unwrap_or(0)];
let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;
for i in 0..4 {
if i <= chunk.len() {
out.push(B64[((n >> (18 - 6 * i)) & 63) as usize] as char);
} else {
out.push('=');
}
}
}
out
}
pub fn base64_decode(s: &str) -> Option<Vec<u8>> {
let s = s.split_once(',').filter(|(h, _)| h.starts_with("data:")).map(|(_, b)| b).unwrap_or(s);
let mut out = Vec::with_capacity(s.len() / 4 * 3);
let mut buf = 0u32;
let mut bits = 0;
for c in s.bytes() {
if c == b'=' || c.is_ascii_whitespace() {
continue;
}
let v = B64.iter().position(|&x| x == c)? as u32;
buf = (buf << 6) | v;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((buf >> bits) as u8);
buf &= (1 << bits) - 1;
}
}
Some(out)
}
/// Pixel size of a PNG or JPEG from its header.
pub fn image_size(data: &[u8]) -> Option<(u32, u32)> {
if data.starts_with(&[0x89, b'P', b'N', b'G']) {
let w = u32::from_be_bytes(data.get(16..20)?.try_into().ok()?);
let h = u32::from_be_bytes(data.get(20..24)?.try_into().ok()?);
return Some((w, h));
}
if data.starts_with(&[0xFF, 0xD8]) {
let mut i = 2;
while i + 9 < data.len() {
if *data.get(i)? != 0xFF {
i += 1;
continue;
}
let marker = *data.get(i + 1)?;
let len = u16::from_be_bytes([*data.get(i + 2)?, *data.get(i + 3)?]) as usize;
if (0xC0..=0xCF).contains(&marker) && !matches!(marker, 0xC4 | 0xC8 | 0xCC) {
let h = u16::from_be_bytes([*data.get(i + 5)?, *data.get(i + 6)?]) as u32;
let w = u16::from_be_bytes([*data.get(i + 7)?, *data.get(i + 8)?]) as u32;
return Some((w, h));
}
i += 2 + len;
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn macros_are_saved_only_into_xlsm() {
let mut wb = Workbook::new();
wb.vba_project = Some(b"vba".to_vec());
let has_macros = |path: &str| {
let bytes = save_bytes(&wb, path).unwrap();
gridcraft_xlsx::read_xlsx(&bytes).unwrap().0.vba_project.is_some()
};
assert!(has_macros("book.xlsm"));
assert!(has_macros("BOOK.XLSM"));
assert!(!has_macros("book.xlsx"), "an .xlsx never carries a VBA project");
assert!(wb.vba_project.is_some(), "the open workbook keeps its macros");
}
/// A minimal compound (CFB/OLE2) file — FAT in sector 0, directory in sector 1 — holding a
/// root entry and the given stream names.
fn cfb(streams: &[&str]) -> Vec<u8> {
let mut b = vec![0u8; 512 * 3];
let mut put = |at: usize, v: &[u8]| b[at..at + v.len()].copy_from_slice(v);
put(0, &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]);
put(30, &9u16.to_le_bytes());
put(44, &1u32.to_le_bytes());
put(48, &1u32.to_le_bytes());
put(68, &0xFFFF_FFFEu32.to_le_bytes());
for i in 1..109 {
put(76 + i * 4, &0xFFFF_FFFFu32.to_le_bytes());
}
put(512, &0xFFFF_FFFDu32.to_le_bytes());
put(516, &0xFFFF_FFFEu32.to_le_bytes());
for (k, name) in std::iter::once("Root Entry").chain(streams.iter().copied()).enumerate() {
let units: Vec<u8> = name.encode_utf16().flat_map(u16::to_le_bytes).collect();
put(1024 + k * 128, &units);
put(1024 + k * 128 + 64, &(units.len() as u16 + 2).to_le_bytes());
}
b
}
/// Cut-list rows as a CAD/CAM program exports them: `;`-separated, CRLF, part codes in the last
/// two columns, a part name containing a comma.
const CUT_LIST: &str = "1;511;184;P3_L1_led;2162PE;16;1MM_2162PE;1MM_2162PE;;;1244121406FB;\r\n\
1;562.5;510;Komoda, lijevo;2162PE;19;1MM_2162PE;;;;12441214071A;124412140710\r\n";
#[test]
fn csv_save_keeps_the_separator_it_was_opened_with() {
let (wb, _) = open_bytes("cut list.csv", CUT_LIST.as_bytes()).unwrap();
assert_eq!(wb.csv_delimiter, Some(b';'));
assert_eq!(String::from_utf8(save_bytes(&wb, "cut list.csv").unwrap()).unwrap(), CUT_LIST);
// Save As another .csv name keeps it too; a comma file stays a comma file (quoting only
// fields that contain the separator in use).
assert_eq!(String::from_utf8(save_bytes(&wb, "copy.CSV").unwrap()).unwrap(), CUT_LIST);
let commas = "\"Komoda, lijevo\",b\r\n1,x;y\r\n";
let (wb, _) = open_bytes("plain.csv", commas.as_bytes()).unwrap();
assert_eq!(String::from_utf8(save_bytes(&wb, "plain.csv").unwrap()).unwrap(), commas);
// A tab-separated file named .csv round-trips with tabs.
let tabs = "a\tb\r\n1\t2\r\n";
let (wb, _) = open_bytes("tabs.csv", tabs.as_bytes()).unwrap();
assert_eq!(String::from_utf8(save_bytes(&wb, "tabs.csv").unwrap()).unwrap(), tabs);
}
#[test]
fn csv_save_of_a_workbook_not_read_from_csv_uses_commas() {
let mut wb = Workbook::new();
if let Some(sh) = wb.sheet_mut(0) {
sh.set_value(CellRef::new(0, 0), gridcraft_core::Value::text("a"));
sh.set_value(CellRef::new(0, 1), gridcraft_core::Value::Number(1.0));
}
assert_eq!(save_bytes(&wb, "new.csv").unwrap(), b"a,1\r\n");
// Through .xlsx the CSV origin is gone, as in Excel.
let (from_csv, _) = open_bytes("cut list.csv", CUT_LIST.as_bytes()).unwrap();
let (reopened, _) = open_bytes("book.xlsx", &save_bytes(&from_csv, "book.xlsx").unwrap()).unwrap();
assert_eq!(reopened.csv_delimiter, None);
assert!(String::from_utf8(save_bytes(&reopened, "again.csv").unwrap()).unwrap().starts_with("1,511,184,"));
// A .tsv/.txt saved as .csv gets commas; as .tsv it keeps tabs.
let (tsv, _) = open_bytes("list.txt", b"a\tb\r\n").unwrap();
assert_eq!(save_bytes(&tsv, "list.csv").unwrap(), b"a,b\r\n");
assert_eq!(save_bytes(&tsv, "list.tsv").unwrap(), b"a\tb\r\n");
}
#[test]
fn encrypted_workbook_is_reported_not_misread() {
let err = open_bytes("secret.xlsx", &cfb(&["EncryptionInfo", "EncryptedPackage"])).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("password-protected"), "got: {msg}");
assert!(!msg.contains("zip"), "must not surface the zip error: {msg}");
}
#[test]
fn other_compound_files_are_not_called_encrypted() {
let err = open_bytes("old.doc", &cfb(&["WordDocument", "\u{5}SummaryInformation"])).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("not an Excel workbook"), "got: {msg}");
assert!(!msg.contains("password"), "got: {msg}");
}
}