1use std::fmt::Write as _;
4
5use crate::base::{CellRef, RangeRef};
6use crate::model::{Sheet, Workbook};
7
8use crate::engine::{EngineError, Result};
9
10mod sylk;
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum FileKind {
15 Xlsx,
16 Xls,
18 Xlsb,
19 Ods,
20 Csv,
21 Tsv,
22 Json,
23 Html,
24 Sylk,
26}
27
28impl FileKind {
29 pub fn from_path(path: &str) -> Option<FileKind> {
30 let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase();
31 Some(match ext.as_str() {
32 "xlsx" | "xlsm" | "xltx" | "xltm" => FileKind::Xlsx,
33 "xls" | "xlt" => FileKind::Xls,
34 "xlsb" => FileKind::Xlsb,
35 "ods" => FileKind::Ods,
36 "csv" => FileKind::Csv,
37 "tsv" | "tab" | "txt" => FileKind::Tsv,
38 "json" | "scjson" => FileKind::Json,
39 "html" | "htm" => FileKind::Html,
40 "slk" => FileKind::Sylk,
41 _ => return None,
42 })
43 }
44}
45
46pub const PASSWORD_REQUIRED: &str = "is password-protected. Enter its password to open it.";
49pub const WRONG_PASSWORD: &str = "The password is incorrect.";
51pub const DECRYPTED_NOTICE: &str = "This workbook was password-protected. GridCraft saves it without the password.";
53
54pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
57 open_bytes_with_password(name, bytes, None)
58}
59
60pub fn open_bytes_with_password(name: &str, bytes: &[u8], password: Option<&str>) -> Result<(Workbook, Vec<String>)> {
63 open_bytes_with(name, bytes, password, false)
64}
65
66pub fn open_bytes_with(name: &str, bytes: &[u8], password: Option<&str>, csv_as_text: bool) -> Result<(Workbook, Vec<String>)> {
70 if crate::xlsx::sniff(bytes) != crate::xlsx::Format::Encrypted {
71 let (mut wb, warnings) = open_plain(name, bytes, csv_as_text)?;
72 fit_imported_rows(&mut wb);
73 return Ok((wb, warnings));
74 }
75 let decrypt_error = |e: crate::xlsx::DecryptError| EngineError::Other(decrypt_message(name, e));
77 let Some(password) = password else {
78 crate::xlsx::check_encryption(bytes).map_err(decrypt_error)?;
79 return Err(EngineError::Other(format!("'{name}' {PASSWORD_REQUIRED}")));
80 };
81 let plain = crate::xlsx::decrypt(bytes, password).map_err(decrypt_error)?;
82 if crate::xlsx::sniff(&plain) == crate::xlsx::Format::Encrypted {
83 return Err(EngineError::Other(format!("We can't open '{name}': the encrypted file is damaged (it holds another encrypted file).")));
84 }
85 let (mut wb, mut warnings) = open_plain(name, &plain, csv_as_text)?;
86 fit_imported_rows(&mut wb);
87 warnings.insert(0, DECRYPTED_NOTICE.into());
88 Ok((wb, warnings))
89}
90
91fn fit_imported_rows(wb: &mut Workbook) {
96 for si in 0..wb.sheets.len() {
97 let Some(sh) = wb.sheet(si) else { continue };
98 let mut rows: Vec<u32> = sh.cells.iter().map(|(c, _)| c.row).collect();
99 rows.sort_unstable();
100 rows.dedup();
101 for row in rows {
102 let before = wb.sheet(si).and_then(|sh| sh.rows.get(&row)).and_then(|i| i.size);
103 crate::engine::cmd::auto_row_height(wb, si, row);
104 let Some(before) = before else { continue };
105 let after = wb.sheet(si).and_then(|sh| sh.rows.get(&row)).and_then(|i| i.size);
106 if after.is_none_or(|a| before > a + 0.5)
107 && let Some(sh) = wb.sheet_mut(si)
108 {
109 sh.rows.entry(row).or_default().size = Some(before);
110 }
111 }
112 }
113}
114
115fn decrypt_message(name: &str, e: crate::xlsx::DecryptError) -> String {
117 match e {
118 crate::xlsx::DecryptError::WrongPassword => format!("{WRONG_PASSWORD} Check that Caps Lock is off and try again."),
119 crate::xlsx::DecryptError::Unsupported(scheme) => {
120 format!("'{name}' uses {scheme} encryption, which GridCraft can't open yet. Remove the password in Excel and try again.")
121 }
122 crate::xlsx::DecryptError::Malformed(detail) => format!("We can't open '{name}': the encrypted file is damaged ({detail})."),
123 }
124}
125
126fn open_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook, Vec<String>)> {
130 let (mut wb, mut warnings) = read_plain(name, bytes, csv_as_text)?;
131 warnings.extend(crate::engine::macros::load(&mut wb));
132 crate::engine::cmd::controls::load_action_names(&mut wb);
133 warnings.extend(adopt_python(&mut wb));
134 warnings.extend(enforce_python_limits(&mut wb));
135 Ok((wb, warnings))
136}
137
138pub const MAX_ADOPTED_RESULT_CELLS: u64 = 4_000_000;
141
142pub fn adopt_python(wb: &mut Workbook) -> Vec<String> {
148 let mut budget = MAX_ADOPTED_RESULT_CELLS;
149 let mut skipped = 0usize;
150 for si in 0..wb.sheets.len() {
151 let Some(sh) = wb.sheets.get(si) else { continue };
152 let formulas: Vec<(CellRef, crate::model::PyCode)> = sh
153 .cells
154 .iter()
155 .filter_map(|(c, cell)| cell.formula.as_ref().and_then(|f| crate::model::PyCode::from_formula(&f.text)).map(|p| (c, p)))
156 .collect();
157 let max = crate::model::python::MAX_RESULT_CELLS as u64;
158 let spills: Vec<(CellRef, RangeRef)> = sh
159 .spill_ranges
160 .iter()
161 .filter(|(a, r)| r.count() <= max && sh.cell(**a).and_then(|c| c.py.as_ref()).is_some_and(|p| p.output.is_none()))
162 .map(|(a, r)| (*a, *r))
163 .collect();
164 if formulas.is_empty() && spills.is_empty() {
165 continue;
166 }
167 let Some(sh) = wb.sheet_mut(si) else { continue };
168 for (c, py) in formulas {
169 if let Some(cell) = sh.cells.get_mut(c) {
170 cell.formula = None;
171 cell.py = Some(std::sync::Arc::new(py));
172 }
173 }
174 for (anchor, range) in spills {
175 let Some(left) = budget.checked_sub(range.count()) else {
176 skipped += 1;
177 sh.settle_spill(anchor);
180 continue;
181 };
182 budget = left;
183 let data: Vec<crate::base::Value> =
184 range.iter().map(|c| if c == anchor { sh.cell(c).map(|x| x.value.clone()).unwrap_or_default() } else { sh.value(c) }).collect();
185 let Some(array) = crate::base::Array::new(range.height() as usize, range.width() as usize, data) else { continue };
186 if let Some(py) = sh.cells.get_mut(anchor).and_then(|x| x.py.as_mut()) {
187 std::sync::Arc::make_mut(py).output =
188 Some(crate::model::PyOutput { value: array.into(), type_name: "list".into(), preview: String::new(), convertible: true });
189 }
190 }
191 }
192 if skipped == 0 {
193 return Vec::new();
194 }
195 vec![format!(
196 "The file's Python results are larger than {MAX_ADOPTED_RESULT_CELLS} cells in all, so {skipped} Python cell(s) have no result until they run again; the values they spilled were kept as plain values."
197 )]
198}
199
200pub fn enforce_python_limits(wb: &mut Workbook) -> Vec<String> {
206 let mut long = Vec::new();
207 for si in 0..wb.sheets.len() {
208 let Some(sh) = wb.sheets.get(si) else { continue };
209 let mut over = Vec::new();
210 let mut bad_output = Vec::new();
211 for (c, cell) in sh.cells.iter() {
212 let Some(py) = &cell.py else { continue };
213 if !py.fits() {
214 long.push(format!("{}!{}", sh.name, c.a1()));
215 over.push(c);
216 } else if py.output.as_ref().is_some_and(|o| !sane_python_output(&o.value)) {
217 bad_output.push(c);
218 }
219 }
220 if over.is_empty() && bad_output.is_empty() {
221 continue;
222 }
223 let dropped: Vec<CellRef> = over.iter().chain(&bad_output).copied().collect();
224 let Some(sh) = wb.sheet_mut(si) else { continue };
225 for c in over {
226 if let Some(cell) = sh.cells.get_mut(c) {
227 cell.py = None;
228 }
229 }
230 for c in bad_output {
231 if let Some(py) = sh.cells.get_mut(c).and_then(|x| x.py.as_mut()) {
232 std::sync::Arc::make_mut(py).output = None;
233 }
234 }
235 for c in dropped {
238 sh.settle_spill(c);
239 }
240 }
241 let Some(first) = long.first() else { return vec![] };
242 let more = if long.len() > 1 { format!(" (and {} more)", long.len() - 1) } else { String::new() };
243 vec![format!(
244 "A Python cell's code is longer than Excel's {}-character formula limit, so {first}{more} kept only its last calculated value.",
245 crate::model::python::MAX_FORMULA_CHARS
246 )]
247}
248
249fn sane_python_output(v: &crate::base::Value) -> bool {
252 match v {
253 crate::base::Value::Array(a) => {
254 a.rows >= 1 && a.cols >= 1 && a.rows.checked_mul(a.cols).is_some_and(|n| n == a.data.len() && n <= crate::model::python::MAX_RESULT_CELLS)
255 }
256 _ => true,
257 }
258}
259
260fn read_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook, Vec<String>)> {
262 let kind = FileKind::from_path(name);
263 let sniffed = crate::xlsx::sniff(bytes);
264 if sniffed == crate::xlsx::Format::Xls {
265 let (wb, report) = crate::xlsx::read_xls(bytes).map_err(|e| EngineError::Other(format!("We can't open '{name}': {e}")))?;
266 return Ok((wb, report.warnings));
267 }
268 if sniffed == crate::xlsx::Format::LegacyBinary {
269 return Err(EngineError::Other(format!(
270 "'{name}' is a legacy binary file but not an Excel workbook (no workbook stream). GridCraft can't open it."
271 )));
272 }
273 let import = match sniffed {
276 crate::xlsx::Format::Ods => Some(FileKind::Ods),
277 crate::xlsx::Format::Xlsb => Some(FileKind::Xlsb),
278 crate::xlsx::Format::Xlsx => None,
280 _ => kind.filter(|k| matches!(k, FileKind::Ods | FileKind::Xlsb)),
281 };
282 if let Some(import) = import {
283 let read = if import == FileKind::Ods { crate::xlsx::read_ods } else { crate::xlsx::read_xlsb };
284 let (wb, report) = read(bytes).map_err(|e| EngineError::Other(format!("We can't import '{name}': {e}")))?;
285 return Ok((wb, report.warnings));
286 }
287 if sniffed == crate::xlsx::Format::Xlsx || (kind == Some(FileKind::Xlsx) && sniffed != crate::xlsx::Format::Csv) {
289 let (wb, report) = crate::xlsx::read_xlsx_with(bytes, &crate::calc::is_known_function).map_err(|e| {
291 EngineError::Other(match (&e, crate::xlsx::oversized_part(bytes)) {
292 (crate::xlsx::IoError::TooLarge(_), Some((part, size))) => part_too_large_message(name, &part, size),
293 _ => format!("We can't open '{name}': {e}"),
294 })
295 })?;
296 return Ok((wb, report.warnings));
297 }
298 if let Some(read) = read_html_table(name, kind, bytes, csv_as_text) {
299 return read;
300 }
301 match kind {
302 Some(FileKind::Json) => {
303 let mut wb: Workbook = serde_json::from_slice(bytes).map_err(|e| EngineError::Other(format!("not a GridCraft JSON workbook: {e}")))?;
304 wb.styles.rebuild_index();
305 if wb.sheets.is_empty() {
306 let macros = std::mem::take(&mut wb.macros_read);
308 wb = Workbook::new();
309 wb.macros_read = macros;
310 }
311 let dropped: usize = wb.sheets.iter_mut().map(|sh| std::sync::Arc::make_mut(sh).cells.rebuild()).sum();
314 let mut warnings = Vec::new();
315 if dropped > 0 {
316 warnings.push(format!("{name}: {dropped} cells outside the 1,048,576 × 16,384 grid were dropped"));
317 }
318 Ok((wb, warnings))
319 }
320 Some(FileKind::Sylk) => {
321 let (mut wb, warnings) = sylk::read(name, bytes)?;
322 rename_first_sheet(&mut wb, name);
323 Ok((wb, warnings))
324 }
325 Some(FileKind::Tsv) => {
326 let opts = crate::xlsx::CsvOptions { delimiter: b'\t', parse_values: !csv_as_text, ..Default::default() };
327 let (mut wb, report) = crate::xlsx::read_csv_with_report(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
328 wb.csv_delimiter = None;
330 rename_first_sheet(&mut wb, name);
331 Ok((wb, report.warnings))
332 }
333 _ => {
334 let opts = crate::xlsx::CsvOptions { delimiter: 0, parse_values: !csv_as_text, ..Default::default() };
335 let (mut wb, report) = crate::xlsx::read_csv_with_report(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
336 rename_first_sheet(&mut wb, name);
337 Ok((wb, report.warnings))
338 }
339 }
340}
341
342fn part_too_large_message(name: &str, part: &str, size: u64) -> String {
347 let limit = crate::xlsx::MAX_PART >> 20;
348 let mb = size >> 20;
349 let size = if mb > limit { format!("{mb} MB") } else { format!("more than {limit} MB") };
350 let remedy = if part.contains("worksheets/") {
351 "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."
352 } else {
353 "To open it, save the data as CSV, which keeps only its values."
354 };
355 format!(
356 "We can't open '{name}': its part {part} unpacks to {size}, over GridCraft's limit of {limit} MB for one part \
357 (a guard against damaged or malicious files). {remedy}"
358 )
359}
360
361const HTML_SNIFF_BYTES: usize = 64 << 10;
363
364fn read_html_table(name: &str, kind: Option<FileKind>, bytes: &[u8], csv_as_text: bool) -> Option<Result<(Workbook, Vec<String>)>> {
370 if matches!(kind, Some(FileKind::Json)) || bytes.len() > crate::xlsx::MAX_CSV_BYTES {
373 return None;
374 }
375 let start = crate::xlsx::decode_text(bytes.get(..HTML_SNIFF_BYTES).unwrap_or(bytes));
376 let mut rest = start.trim_start_matches('\u{feff}').trim_start();
378 while let Some(after) = rest.strip_prefix("<!--") {
379 match after.find("-->").and_then(|end| after.get(end + 3..)) {
380 Some(more) => rest = more.trim_start(),
381 None => break,
382 }
383 }
384 let head: String = rest.chars().take(16).collect::<String>().to_ascii_lowercase();
385 if !["<!doctype html", "<html", "<table", "<head", "<body", "<meta"].iter().any(|t| head.starts_with(t)) {
386 return None;
387 }
388 let text = crate::xlsx::decode_text(bytes);
390 let rows = crate::engine::clip_html::table_cells(&text, crate::base::MAX_ROWS as usize, crate::base::MAX_COLS as usize);
391 if rows.iter().all(Vec::is_empty) {
392 return None;
393 }
394 let mut tsv = String::new();
396 for row in &rows {
397 let fields: Vec<String> = row
398 .iter()
399 .map(|cell| {
400 let field = match cell.num.filter(|_| !csv_as_text) {
403 Some(n) => crate::base::number_to_text(n),
404 None => cell.text.clone(),
406 };
407 if field.contains(['\t', '\n', '\r', '"']) { format!("\"{}\"", field.replace('"', "\"\"")) } else { field }
408 })
409 .collect();
410 tsv.push_str(&fields.join("\t"));
411 tsv.push('\n');
412 }
413 let opts = crate::xlsx::CsvOptions { delimiter: b'\t', parse_values: !csv_as_text, ..Default::default() };
414 let read = crate::xlsx::read_csv_with_report(tsv.as_bytes(), &opts).map_err(|e| EngineError::Other(e.to_string()));
415 Some(read.map(|(mut wb, report)| {
416 wb.csv_delimiter = None;
417 let headers: Vec<CellRef> = rows
420 .iter()
421 .enumerate()
422 .flat_map(|(r, row)| row.iter().enumerate().filter(|(_, cell)| cell.header && !cell.text.trim().is_empty()).map(move |(c, _)| CellRef::new(r as u32, c as u32)))
423 .collect();
424 let mut bold: std::collections::HashMap<crate::model::StyleId, crate::model::StyleId> = std::collections::HashMap::new();
425 for at in headers {
426 let Some(base) = wb.sheet(0).map(|sh| sh.style_id(at)) else { continue };
427 let id = *bold.entry(base).or_insert_with(|| {
428 wb.styles.derive(base, |st| {
429 st.font.bold = true;
430 st.align.h = crate::model::HAlign::Center;
431 })
432 });
433 if let Some(sh) = wb.sheet_mut(0) {
434 sh.set_style(at, id);
435 }
436 }
437 let page_name = find_between(&text, "<x:Name>", "</x:Name>").map(|n| crate::engine::clip_html::normalized(n.trim()));
438 match page_name.filter(|n| !n.is_empty()) {
439 Some(sheet) => set_first_sheet_name(&mut wb, &sheet),
440 None => rename_first_sheet(&mut wb, name),
441 }
442 let mut warnings = report.warnings;
443 if !matches!(kind, Some(FileKind::Html)) {
444 warnings.insert(0, format!("'{name}' is a web page (HTML), not a workbook in the format its name says: its table was opened as a sheet. Save as .xlsx to keep your changes."));
445 }
446 (wb, warnings)
447 }))
448}
449
450fn find_between<'a>(text: &'a str, open: &str, close: &str) -> Option<&'a str> {
452 let lower = text.to_ascii_lowercase();
453 let start = lower.find(&open.to_ascii_lowercase())?.checked_add(open.len())?;
454 let len = lower.get(start..)?.find(&close.to_ascii_lowercase())?;
455 text.get(start..start.checked_add(len)?)
456}
457
458fn rename_first_sheet(wb: &mut Workbook, path: &str) {
460 let stem = std::path::Path::new(path).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Sheet1");
461 set_first_sheet_name(wb, stem);
462}
463
464fn set_first_sheet_name(wb: &mut Workbook, stem: &str) {
467 let clean: String = stem.chars().filter(|c| !matches!(c, ':' | '\\' | '/' | '?' | '*' | '[' | ']')).take(31).collect();
468 let clean = clean.trim_matches('\'').to_string();
469 if !clean.is_empty()
471 && !clean.eq_ignore_ascii_case("History")
472 && let Some(sh) = wb.sheet_mut(0)
473 {
474 sh.name = clean;
475 }
476}
477
478pub fn save_bytes(wb: &Workbook, path: &str) -> Result<Vec<u8>> {
480 let Some(kind) = FileKind::from_path(path) else {
483 let name = std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(path);
484 return Err(EngineError::Other(format!("{name}: unknown file format. Save as .xlsx, .csv, .tsv, .json or .html.")));
485 };
486 save_bytes_as(wb, path, kind)
487}
488
489pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result<Vec<u8>> {
491 let sheet = wb.active_sheet();
492 match kind {
493 FileKind::Xlsx => {
497 let named = crate::engine::macros::with_names(wb)?;
498 let named = match crate::engine::cmd::controls::with_action_names(named.as_ref().unwrap_or(wb))? {
499 Some(w) => Some(w),
500 None => named,
501 };
502 let book = std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or_default();
503 crate::xlsx::write_xlsx_named(named.as_ref().unwrap_or(wb), crate::xlsx::XlsxKind::from_path(path), book)
504 .map_err(|e| EngineError::Other(e.to_string()))
505 }
506 FileKind::Xls => {
507 Err(EngineError::Other("Excel 97-2003 (.xls) is supported for import only. Save as .xlsx or another supported export format.".into()))
508 }
509 FileKind::Xlsb => Err(EngineError::Other("XLSB is supported for data import only. Save as .xlsx or another supported export format.".into())),
510 FileKind::Ods => Err(EngineError::Other("ODS is supported for data import only. Save as .xlsx or another supported export format.".into())),
511 FileKind::Sylk => {
512 Err(EngineError::Other("SYLK (.slk) is supported for import only. Save as .xlsx or another supported export format.".into()))
513 }
514 FileKind::Csv => wb.sheet(sheet).map(|sh| csv(wb, sh, wb.csv_delimiter.unwrap_or(b','))).transpose().map(Option::unwrap_or_default),
516 FileKind::Tsv => wb.sheet(sheet).map(|sh| csv(wb, sh, b'\t')).transpose().map(Option::unwrap_or_default),
517 FileKind::Json => crate::engine::macros::json_bytes(wb),
518 FileKind::Html => to_html(wb, sheet).map(String::into_bytes),
519 }
520}
521
522pub fn save_file_bytes(wb: &Workbook, path: &str) -> Result<Vec<u8>> {
525 match source_dir(path).and_then(|f| crate::xlsx::rebase_links(wb, &f)) {
526 Some(rebased) => save_bytes(&rebased, path),
527 None => save_bytes(wb, path),
528 }
529}
530
531pub fn source_dir(path: &str) -> Option<std::path::PathBuf> {
533 std::path::absolute(path).ok()?.parent().map(std::path::Path::to_path_buf)
534}
535
536pub fn source_path(path: &str) -> Option<std::path::PathBuf> {
539 std::path::absolute(path).ok()
540}
541
542fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Result<Vec<u8>> {
546 crate::xlsx::write_csv(sh, delimiter, |c| {
547 if wb.styles.get(sh.style_id(c)).num_fmt.as_str().eq_ignore_ascii_case("General")
550 && let crate::base::Value::Number(number) = sh.value(c)
551 && number.is_finite()
552 {
553 number.to_string()
554 } else {
555 crate::engine::display::value_text(wb, sh, c)
556 }
557 })
558 .map_err(|e| EngineError::Other(e.to_string()))
559}
560
561pub fn csv_values_bytes(wb: &Workbook) -> Result<Vec<u8>> {
565 let Some(sh) = wb.sheet(wb.active_sheet()) else { return Ok(Vec::new()) };
566 crate::xlsx::write_csv(sh, wb.csv_delimiter.unwrap_or(b','), |c| match sh.value(c) {
567 crate::base::Value::Number(n) if n.is_finite() => {
568 let shape = crate::engine::display::number_format(wb.styles.get(sh.style_id(c)).num_fmt.as_str()).date_shape(n);
569 match shape.zip(crate::base::date::datetime_from_serial(wb.date_system, n)) {
570 Some(((date, time), d)) => iso(&d, date, time),
571 None => n.to_string(),
572 }
573 }
574 crate::base::Value::Text(t) => t.to_string(),
575 v => crate::engine::display::value_text_as(wb, &v, "General"),
576 })
577 .map_err(|e| EngineError::Other(e.to_string()))
578}
579
580fn iso(d: &crate::base::date::DateTime, date: bool, time: bool) -> String {
582 let day = format!("{:04}-{:02}-{:02}", d.year, d.month, d.day);
583 let mut clock = format!("{:02}:{:02}:{:02}", d.hour, d.minute, d.second);
584 if d.milli != 0 {
585 clock.push_str(&format!(".{:03}", d.milli));
586 }
587 match (date, time) {
588 (true, true) => format!("{day}T{clock}"),
589 (false, true) => clock,
590 _ => day,
591 }
592}
593
594pub(crate) fn esc(s: &str) -> String {
595 s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
596}
597
598fn html_cell_css(wb: &Workbook, sh: &Sheet, c: CellRef, dir: CellDir) -> String {
599 let st = wb.styles.get(sh.style_id(c));
600 let mut css = String::new();
601 if st.font.bold {
602 css.push_str("font-weight:bold;");
603 }
604 if st.font.italic {
605 css.push_str("font-style:italic;");
606 }
607 if let Some(h) = st.font.color.hex(&wb.theme) {
608 let _ = write!(css, "color:{h};");
609 }
610 if st.fill.pattern != crate::model::PatternType::None
611 && let Some(h) = st.fill.fg.hex(&wb.theme)
612 {
613 let _ = write!(css, "background:{h};");
614 }
615 let v = sh.value(c);
616 let align = match st.align.h {
617 crate::model::HAlign::Center => Some("center"),
618 crate::model::HAlign::Right => Some("right"),
619 crate::model::HAlign::Left => Some("left"),
620 _ if v.is_number() => Some(if sh.right_to_left { "left" } else { "right" }),
621 _ if dir == CellDir::Auto => None,
622 _ => Some("left"),
623 };
624 if let Some(align) = align {
625 let _ = write!(css, "text-align:{align};");
626 }
627 css
628}
629
630#[derive(Clone, Copy, PartialEq, Eq)]
635enum CellDir {
636 Inherit,
637 Auto,
638 Ltr,
639}
640
641impl CellDir {
642 fn of(sh: &Sheet, c: CellRef, text: &str) -> CellDir {
643 if sh.value(c).is_number() {
644 if sh.right_to_left { CellDir::Ltr } else { CellDir::Inherit }
645 } else if sh.right_to_left || text.chars().any(is_right_to_left_letter) {
646 CellDir::Auto
647 } else {
648 CellDir::Inherit
649 }
650 }
651
652 fn attr(self) -> &'static str {
653 match self {
654 CellDir::Inherit => "",
655 CellDir::Auto => " dir=\"auto\"",
656 CellDir::Ltr => " dir=\"ltr\"",
657 }
658 }
659}
660
661fn is_right_to_left_letter(c: char) -> bool {
663 matches!(c, '\u{0590}'..='\u{08FF}' | '\u{FB1D}'..='\u{FDFF}' | '\u{FE70}'..='\u{FEFF}')
664}
665
666fn table_dir(sh: &Sheet) -> &'static str {
667 if sh.right_to_left { " dir=\"rtl\"" } else { "" }
668}
669
670const CLIPBOARD_HTML_MAX_BYTES: usize = 4 * 1024 * 1024;
671const CLIPBOARD_HTML_MAX_CELLS: u64 = 10_000;
672
673pub(crate) fn range_html(wb: &Workbook, sheet: usize, r: RangeRef, plain_text: &str) -> Option<String> {
676 let r = crate::engine::display::clipboard_range(r);
677 if r.count() > CLIPBOARD_HTML_MAX_CELLS || plain_text.len() > CLIPBOARD_HTML_MAX_BYTES {
678 return None;
679 }
680 let sh = wb.sheet(sheet)?;
681 let mut out = ClipboardHtml(String::new());
682 write!(out, "<table{} style=\"border-collapse:collapse;font-family:Calibri,Carlito,Arial,sans-serif;font-size:11pt\">", table_dir(sh)).ok()?;
683 for row in r.start.row..=r.end.row {
684 if sh.is_row_hidden(row) {
685 continue;
686 }
687 out.write_str("<tr>").ok()?;
688 for col in r.start.col..=r.end.col {
689 let c = CellRef::new(row, col);
690 let span = if let Some(merged) = sh.merges.iter().find(|m| m.contains(c)).and_then(|m| m.intersection(&r)) {
691 let first_row = (merged.start.row..=merged.end.row).find(|&row| !sh.is_row_hidden(row))?;
692 if c != CellRef::new(first_row, merged.start.col) {
693 continue;
694 }
695 let rows = (first_row..=merged.end.row).filter(|&row| !sh.is_row_hidden(row)).count();
696 format!(" rowspan=\"{rows}\" colspan=\"{}\"", merged.width())
697 } else {
698 String::new()
699 };
700 let text = crate::engine::display::cell_text(wb, sh, c);
703 let dir = CellDir::of(sh, c, &text);
704 let css = html_cell_css(wb, sh, c, dir);
705 write!(out, "<td style=\"border:1px solid #d4d4d4;padding:2px 4px;white-space:pre-wrap;{css}\"{}{span}>", dir.attr()).ok()?;
706 let mut chars = text.chars().peekable();
707 while let Some(ch) = chars.next() {
708 match ch {
709 '&' => out.write_str("&"),
710 '<' => out.write_str("<"),
711 '>' => out.write_str(">"),
712 '"' => out.write_str("""),
713 '\r' => {
714 if chars.peek() == Some(&'\n') {
715 chars.next();
716 }
717 out.write_str("<br>")
718 }
719 '\n' => out.write_str("<br>"),
720 _ => out.write_char(ch),
721 }
722 .ok()?;
723 }
724 out.write_str("</td>").ok()?;
725 }
726 out.write_str("</tr>").ok()?;
727 }
728 out.write_str("</table>").ok()?;
729 Some(out.0)
730}
731
732struct ClipboardHtml(String);
734
735impl std::fmt::Write for ClipboardHtml {
736 fn write_str(&mut self, s: &str) -> std::fmt::Result {
737 if s.len() > CLIPBOARD_HTML_MAX_BYTES.saturating_sub(self.0.len()) {
738 return Err(std::fmt::Error);
739 }
740 self.0.push_str(s);
741 Ok(())
742 }
743}
744
745const HTML_EXPORT_MAX_ROWS: u32 = 100_001;
747
748const HTML_EXPORT_MAX_CELLS: u64 = 4_000_000;
752
753pub fn to_html(wb: &Workbook, sheet: usize) -> Result<String> {
756 let Some(sh) = wb.sheet(sheet) else { return Ok(String::new()) };
757 let fixed = !(sh.images.is_empty() && sh.charts.is_empty());
758 let used = sh.used_range();
759 let mut out = String::new();
760 let _ = write!(
761 out,
762 "<!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:{};white-space:nowrap{}}}</style></head><body>",
763 esc(&sh.name),
764 if fixed { ";table-layout:fixed" } else { "" },
765 if fixed { "0 4px" } else { "2px 4px" },
766 if fixed { ";overflow:hidden;line-height:1" } else { "" }
767 );
768 if fixed {
769 out.push_str("<div style=\"position:relative\">");
770 }
771 let _ = write!(out, "<table{}", table_dir(sh));
772 if let (true, Some(r)) = (fixed, used) {
773 let width: f32 = (r.start.col..=r.end.col).map(|c| sh.col_width(c)).sum();
774 let _ = write!(out, " style=\"width:{width}px\"");
775 }
776 out.push('>');
777 if let Some(r) = used {
778 if r.height() > HTML_EXPORT_MAX_ROWS {
779 return Err(EngineError::Other(format!(
780 "the sheet is too tall for a web page ({} rows, at most {HTML_EXPORT_MAX_ROWS}). Save as .xlsx or CSV instead.",
781 r.height()
782 )));
783 }
784 if u64::from(r.height()) * u64::from(r.width()) > HTML_EXPORT_MAX_CELLS {
789 return Err(EngineError::Other(format!(
790 "the sheet is too tall × wide for a web page ({} × {} cells, at most {HTML_EXPORT_MAX_CELLS}). Save as .xlsx or CSV instead.",
791 r.height(),
792 r.width()
793 )));
794 }
795 if fixed {
796 out.push_str("<colgroup>");
797 for col in (r.start.col..=r.end.col).filter(|&c| !sh.is_col_hidden(c)) {
798 let _ = write!(out, "<col style=\"width:{}px\">", sh.col_width(col));
799 }
800 out.push_str("</colgroup>");
801 }
802 for row in r.start.row..=r.end.row {
803 if sh.is_row_hidden(row) {
804 continue;
805 }
806 if fixed {
807 let _ = write!(out, "<tr style=\"height:{}px\">", sh.row_height(row));
808 } else {
809 out.push_str("<tr>");
810 }
811 for col in r.start.col..=r.end.col {
812 let c = CellRef::new(row, col);
813 if sh.merges.iter().any(|m| m.contains(c) && m.start != c) || (fixed && sh.is_col_hidden(col)) {
814 continue;
815 }
816 let text = crate::engine::display::cell_text(wb, sh, c);
817 let dir = CellDir::of(sh, c, &text);
818 let css = html_cell_css(wb, sh, c, dir);
819 let span = sh
820 .merges
821 .iter()
822 .find(|m| m.start == c)
823 .map(|m| {
824 if fixed {
825 let rows = (m.start.row..=m.end.row).filter(|&y| !sh.is_row_hidden(y)).count();
826 let cols = (m.start.col..=m.end.col).filter(|&x| !sh.is_col_hidden(x)).count();
827 format!(" rowspan=\"{rows}\" colspan=\"{cols}\"")
828 } else {
829 format!(" rowspan=\"{}\" colspan=\"{}\"", m.height(), m.width())
830 }
831 })
832 .unwrap_or_default();
833 let _ = write!(out, "<td style=\"{css}\"{}{span}>{}</td>", dir.attr(), esc(&text));
834 }
835 out.push_str("</tr>");
836 }
837 }
838 out.push_str("</table>");
839 if fixed {
840 let (left, top) = used.map_or((0.0, 0.0), |r| (sh.col_left(r.start.col), sh.row_top(r.start.row)));
842 let place = |a: &crate::model::Anchor| {
843 format!(
844 "position:absolute;left:{}px;top:{}px;width:{}px;height:{}px",
845 sh.col_left(a.cell.col) - left + f64::from(a.dx),
846 sh.row_top(a.cell.row) - top + f64::from(a.dy),
847 a.width.max(0.0),
848 a.height.max(0.0)
849 )
850 };
851 for im in &sh.images {
852 let _ = write!(
853 out,
854 "<img src=\"data:{};base64,{}\" alt=\"{}\" style=\"{}\">",
855 esc(&im.mime),
856 base64_encode(&im.data),
857 esc(&im.alt),
858 place(&im.anchor)
859 );
860 }
861 for ch in &sh.charts {
862 if let Some(svg) = crate::engine::chart_svg::chart_svg(wb, sheet, ch, &place(&ch.anchor)) {
863 out.push_str(&svg);
864 }
865 }
866 out.push_str("</div>");
867 }
868 out.push_str("</body></html>");
869 Ok(out)
870}
871
872thread_local! {
873 static SCRIPT_SANDBOX: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
875}
876
877pub(crate) struct ScriptSandbox(());
881
882impl ScriptSandbox {
883 pub(crate) fn enter() -> Self {
884 let _ = SCRIPT_SANDBOX.try_with(|c| c.set(c.get().saturating_add(1)));
885 ScriptSandbox(())
886 }
887}
888
889impl Drop for ScriptSandbox {
890 fn drop(&mut self) {
891 let _ = SCRIPT_SANDBOX.try_with(|c| c.set(c.get().saturating_sub(1)));
892 }
893}
894
895fn script_sandbox_check(path: &str) -> Result<()> {
898 if SCRIPT_SANDBOX.try_with(|c| c.get() > 0).unwrap_or(true) {
899 return Err(EngineError::Other(format!("{path}: scripts, and macro steps without the permission, can't read or write files")));
900 }
901 Ok(())
902}
903
904#[cfg(not(target_arch = "wasm32"))]
907const MAX_READ_BYTES: u64 = 2 * 1024 * 1024 * 1024;
908
909#[cfg(not(target_arch = "wasm32"))]
910pub fn read_file(path: &str) -> Result<Vec<u8>> {
911 use std::io::Read;
912 script_sandbox_check(path)?;
913 let f = std::fs::File::open(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
914 if !f.metadata().map_err(|e| EngineError::Other(format!("{path}: {e}")))?.is_file() {
917 return Err(EngineError::Other(format!("{path}: only regular files can be opened")));
918 }
919 let mut buf = Vec::new();
920 let mut limited = (&f).take(MAX_READ_BYTES + 1);
921 limited.read_to_end(&mut buf).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
922 if buf.len() as u64 > MAX_READ_BYTES {
923 return Err(EngineError::Other("files larger than 2 GB aren't supported".into()));
924 }
925 Ok(buf)
926}
927
928#[cfg(target_arch = "wasm32")]
929pub fn read_file(path: &str) -> Result<Vec<u8>> {
930 script_sandbox_check(path)?;
931 Err(EngineError::Other(format!("{path}: no file system in the browser")))
932}
933
934#[cfg(not(target_arch = "wasm32"))]
935pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
936 use std::io::Write;
937 script_sandbox_check(path)?;
938 let tmp = format!("{path}.sctmp");
944 let prior = std::fs::metadata(path).ok().map(|m| m.permissions());
945 let mut opts = std::fs::OpenOptions::new();
946 opts.write(true).create_new(true);
947 #[cfg(unix)]
948 if let Some(p) = &prior {
949 use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
950 opts.mode(p.mode());
951 }
952 let mut f = opts.open(&tmp).map_err(|e| {
953 let _ = std::fs::remove_file(&tmp);
954 EngineError::Other(format!("{path}: {e}"))
955 })?;
956 #[cfg(not(unix))]
957 if let Some(p) = &prior {
958 let _ = f.set_permissions(p.clone());
959 }
960 f.write_all(bytes).and_then(|()| f.flush()).map_err(|e| {
961 let _ = std::fs::remove_file(&tmp);
962 EngineError::Other(format!("{path}: {e}"))
963 })?;
964 std::fs::rename(&tmp, path).map_err(|e| {
965 let _ = std::fs::remove_file(&tmp);
968 EngineError::Other(format!("{path}: {e}"))
969 })
970}
971
972#[cfg(target_arch = "wasm32")]
973pub fn write_file(path: &str, _bytes: &[u8]) -> Result<()> {
974 script_sandbox_check(path)?;
975 Err(EngineError::Other(format!("{path}: no file system in the browser")))
976}
977
978const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
979
980pub fn base64_encode(data: &[u8]) -> String {
981 let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
982 for chunk in data.chunks(3) {
983 let b = [chunk.first().copied().unwrap_or(0), chunk.get(1).copied().unwrap_or(0), chunk.get(2).copied().unwrap_or(0)];
984 let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;
985 for i in 0..4 {
986 if i <= chunk.len() {
987 out.push(B64[((n >> (18 - 6 * i)) & 63) as usize] as char);
988 } else {
989 out.push('=');
990 }
991 }
992 }
993 out
994}
995
996pub fn base64_decode(s: &str) -> Option<Vec<u8>> {
997 let s = s.split_once(',').filter(|(h, _)| h.starts_with("data:")).map(|(_, b)| b).unwrap_or(s);
998 let mut out = Vec::with_capacity(s.len() / 4 * 3);
999 let mut buf = 0u32;
1000 let mut bits = 0;
1001 for c in s.bytes() {
1002 if c == b'=' || c.is_ascii_whitespace() {
1003 continue;
1004 }
1005 let v = B64.iter().position(|&x| x == c)? as u32;
1006 buf = (buf << 6) | v;
1007 bits += 6;
1008 if bits >= 8 {
1009 bits -= 8;
1010 out.push((buf >> bits) as u8);
1011 buf &= (1 << bits) - 1;
1012 }
1013 }
1014 Some(out)
1015}
1016
1017pub fn image_size(data: &[u8]) -> Option<(u32, u32)> {
1019 if data.starts_with(&[0x89, b'P', b'N', b'G']) {
1020 let w = u32::from_be_bytes(data.get(16..20)?.try_into().ok()?);
1021 let h = u32::from_be_bytes(data.get(20..24)?.try_into().ok()?);
1022 return Some((w, h));
1023 }
1024 if data.starts_with(&[0xFF, 0xD8]) {
1025 let mut i = 2;
1026 while i + 9 < data.len() {
1027 if *data.get(i)? != 0xFF {
1028 i += 1;
1029 continue;
1030 }
1031 let marker = *data.get(i + 1)?;
1032 let len = u16::from_be_bytes([*data.get(i + 2)?, *data.get(i + 3)?]) as usize;
1033 if (0xC0..=0xCF).contains(&marker) && !matches!(marker, 0xC4 | 0xC8 | 0xCC) {
1034 let h = u16::from_be_bytes([*data.get(i + 5)?, *data.get(i + 6)?]) as u32;
1035 let w = u16::from_be_bytes([*data.get(i + 7)?, *data.get(i + 8)?]) as u32;
1036 return Some((w, h));
1037 }
1038 i += 2 + len;
1039 }
1040 }
1041 None
1042}
1043
1044#[cfg(test)]
1045mod tests;