Skip to main content

ravenrs_sheet/engine/
io.rs

1//! Opening and saving files: XLSX, legacy XLS import, XLSB and ODS data import, SYLK import, CSV/TSV, JSON (debug), HTML export.
2
3use 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/// File formats GridCraft reads or writes.
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum FileKind {
15    Xlsx,
16    /// Excel 97-2003 workbook (import only).
17    Xls,
18    Xlsb,
19    Ods,
20    Csv,
21    Tsv,
22    Json,
23    Html,
24    /// SYLK symbolic link text (import only).
25    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
46/// The error for an encrypted workbook opened without a password contains this. The interface
47/// asks for the password when it sees it; programmatic callers pass `password` instead.
48pub const PASSWORD_REQUIRED: &str = "is password-protected. Enter its password to open it.";
49/// The error for a wrong password contains this.
50pub const WRONG_PASSWORD: &str = "The password is incorrect.";
51/// The first open warning of a workbook decrypted with its password.
52pub const DECRYPTED_NOTICE: &str = "This workbook was password-protected. GridCraft saves it without the password.";
53
54/// Parses a file's bytes into a workbook. `name` picks the format by extension when the bytes
55/// don't say. An encrypted workbook needs [`open_bytes_with_password`].
56pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
57    open_bytes_with_password(name, bytes, None)
58}
59
60/// [`open_bytes`], first decrypting a password-protected workbook with `password`. Errors never
61/// quote the password.
62pub 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
66/// [`open_bytes_with_password`], and with `csv_as_text` a CSV/TSV/TXT file keeps every field as
67/// the text it holds, with no number, date or boolean conversion (identifiers such as GTINs, SKUs
68/// and ZIP codes survive). It does not apply to other formats.
69pub 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    // A scheme no password opens is said straight away, rather than after asking for one.
76    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
91/// Excel auto-fits rows to their tallest font when opening a file: rows without a user-set
92/// height grow to fit their content. Rows the file made taller keep their height — our
93/// line-height estimate can differ from Excel's, and shrinking files that already look
94/// right would be a visible regression. Custom-height rows are left alone entirely.
95fn 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
115/// What to tell the user when an encrypted workbook can't be decrypted. Never quotes the password.
116fn 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
126/// Parses bytes that aren't encrypted, then takes the workbook's macros, and the button actions
127/// xlsx has no place for, out of their hidden names, and adopts the Python cells they hold (see
128/// [`adopt_python`]) within the limits of [`enforce_python_limits`].
129fn 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
138/// The most result cells [`adopt_python`] rebuilds from a file's cached spills, over all its
139/// Python cells (each one is also limited to [`crate::model::python::MAX_RESULT_CELLS`]).
140pub const MAX_ADOPTED_RESULT_CELLS: u64 = 4_000_000;
141
142/// Python cells from a file: a cell whose formula is `PY("…",0|1)` (a format that kept it as a
143/// formula) becomes a Python cell, and a Python cell whose spilled values the file cached gets
144/// them back as its result, so it shows them until it runs again. Results are rebuilt for at most
145/// [`MAX_ADOPTED_RESULT_CELLS`] cells in all; the rest keep only their own cached value, and the
146/// values they spilled stay as plain values. Returns the warnings.
147pub 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                // The cell keeps only its own value; the values it spilled stay as plain values, so
178                // nothing shows as a spill that no result holds, and nothing the file showed is lost.
179                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
200/// A file is somebody else's work: a Python cell whose code doesn't fit in Excel's
201/// 8,192-character formula keeps only its cached value, and a kept result that is not a
202/// well-formed array of at most [`crate::model::python::MAX_RESULT_CELLS`] cells is dropped
203/// (the cell keeps its value). Either way the values the cell spilled stay as plain values
204/// ([`crate::model::Sheet::settle_spill`]). Returns the warnings.
205pub 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        // A dropped result (or code dropped to its value) leaves the values it spilled as plain
236        // values, as the file showed them, with no live spill.
237        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
249/// A Python result a file may hold: a scalar, or an array whose size matches its data and has at
250/// most [`crate::model::python::MAX_RESULT_CELLS`] cells.
251fn 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
260/// Parses bytes that aren't encrypted, as they are.
261fn 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    // Content wins over the extension: an ODS or XLSB package is imported as what it is,
274    // whatever it is called; the extension only decides when the bytes don't say.
275    let import = match sniffed {
276        crate::xlsx::Format::Ods => Some(FileKind::Ods),
277        crate::xlsx::Format::Xlsb => Some(FileKind::Xlsb),
278        // An XLSX package is never read as ODS/XLSB just because of its name.
279        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    // Text named `.xlsx` is read as text.
288    if sniffed == crate::xlsx::Format::Xlsx || (kind == Some(FileKind::Xlsx) && sniffed != crate::xlsx::Format::Csv) {
289        // Google Sheets' export wrapper is read as the formula inside when GridCraft calculates it (#2871).
290        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                // The file's macros are read after this (`macros::load`), so they come along.
307                let macros = std::mem::take(&mut wb.macros_read);
308                wb = Workbook::new();
309                wb.macros_read = macros;
310            }
311            // The file's band placement and cell count are derived state: rebuild them from the
312            // cells themselves, dropping what is outside the sheet grid (#1008).
313            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            // Tab is what makes it a .tsv/.txt; saved as .csv it gets the CSV default.
329            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
342/// What to tell the user when one part of an XLSX package unpacks to more than the reader's
343/// per-part limit: the sizes, why the limit is there, and what gets the data in. The limit is
344/// per part, so smaller sheets fit; the shared strings are one part for every sheet, so only a
345/// CSV, which has no parts, helps there.
346fn 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
361/// How much of a text file is read to tell whether it is a web page.
362const HTML_SNIFF_BYTES: usize = 64 << 10;
363
364/// A web page that holds a table, whatever its extension: a web app's "Export to Excel" is often
365/// HTML saved as `.xls` (#3166). Excel opens its table as a sheet (after warning that the format
366/// and extension don't match): the cells read like typed input (or as text with `csv_as_text`),
367/// header cells (`<th>`) are bold and centred, and the sheet takes the name the page gives it
368/// (`<x:Name>`) or the file's. `None` when the bytes aren't such a page.
369fn read_html_table(name: &str, kind: Option<FileKind>, bytes: &[u8], csv_as_text: bool) -> Option<Result<(Workbook, Vec<String>)>> {
370    // Every other text file (CSV, TSV, JSON) passes through here: look at the start only, and decode
371    // the whole file once it is known to be a page within the size a text import takes.
372    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    // Comments an exporter writes before the page (`<!-- ... -->`) don't make it something else.
377    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    // Decoded as a CSV is (BOM, UTF-8, else Windows-1252/1256), so a legacy page keeps its letters.
389    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    // The cells go through the CSV reader, so numbers, dates and text read exactly as they do there.
395    let mut tsv = String::new();
396    for row in &rows {
397        let fields: Vec<String> = row
398            .iter()
399            .map(|cell| {
400                // The number the page recorded (`x:num`) only when values are read; as text, the
401                // cell keeps the text it shows.
402                let field = match cell.num.filter(|_| !csv_as_text) {
403                    Some(n) => crate::base::number_to_text(n),
404                    // Whitespace is already collapsed; non-breaking spaces stay, as in Excel.
405                    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        // Header cells are bold and centred on top of the style the CSV reader gave them, so a
418        // date in a header keeps its date format.
419        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
450/// The text between the first `open` and the `close` after it, matched case-insensitively.
451fn 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
458/// A CSV opens as a sheet named after the file (like Excel).
459fn 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
464/// Names the first sheet `name`, without the characters a sheet name can't hold and cut to 31
465/// characters; an empty or reserved result keeps the default name.
466fn 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    // "History" is reserved (see `Workbook::check_sheet_name`): keep the default name instead.
470    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
478/// Encodes a workbook in the format chosen by `path`'s extension.
479pub fn save_bytes(wb: &Workbook, path: &str) -> Result<Vec<u8>> {
480    // An unknown or missing extension is refused rather than written as an XLSX under that name:
481    // a typo like `.cvs` would otherwise produce a file no other app recognises.
482    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
489/// Encodes a workbook as `kind`, whatever `path`'s extension says.
490pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result<Vec<u8>> {
491    let sheet = wb.active_sheet();
492    match kind {
493        // The extension picks the package kind; only the macro-enabled kinds (.xlsm, .xltm) carry
494        // the VBA project, so any other name saves without it, as Excel does.
495        // Macros, and button actions xlsx has no place for, travel in hidden names.
496        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        // A workbook read from CSV keeps the separator it was read with (a `;` file stays `;`).
515        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
522/// [`save_bytes`] for the file at `path`: relative paths to linked workbooks are rewritten for
523/// its folder, so they still name the same files.
524pub 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
531/// The folder of the file at `path`, which relative paths to linked workbooks are relative to.
532pub 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
536/// The file at `path` as `CELL("filename")` reports it: absolute, so the folder is the one the
537/// user sees. A workbook that arrived as bytes has no path and keeps only its name.
538pub fn source_path(path: &str) -> Option<std::path::PathBuf> {
539    std::path::absolute(path).ok()
540}
541
542/// Save As CSV/Text writes "the text and values as they are displayed in cells" with "all
543/// characters in each cell" (Microsoft Support, *Excel formatting and features that are not
544/// transferred to other file formats*): number formats applied, never column-width `#####`.
545fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Result<Vec<u8>> {
546    crate::xlsx::write_csv(sh, delimiter, |c| {
547        // General's grid-width formatting rounds large identifiers to scientific notation.
548        // Delimited files have no column width, so preserve the whole numeric value instead.
549        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
561/// The active sheet as CSV of raw cell values, for automation: numbers at full precision, dates and
562/// times as ISO 8601, booleans `TRUE`/`FALSE`, errors as their text, text as stored. Save As CSV
563/// writes displayed text instead ([`save_bytes`]) and stays the default.
564pub 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
580/// `date` and `time` say which parts the cell's format shows; a format with neither is not a date.
581fn 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('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
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/// How a cell marks its direction in HTML. Text on a right-to-left sheet, or text with
631/// right-to-left letters, follows its first strong character (`dir="auto"`), so General
632/// alignment puts Arabic and Hebrew on the right. Numbers on a right-to-left sheet stay left to
633/// right so `-5` doesn't read `5-`.
634#[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
661/// Hebrew, Arabic, Syriac, Thaana, N'Ko and their presentation forms.
662fn 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
673/// An HTML fragment for the same range as `plain_text`, previously produced by
674/// `display::range_text`. Large copies keep plain text only; never return a partial table.
675pub(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            // Use the visible selected cell, even when the merge's original anchor
701            // is outside the range or hidden. This matches the plain-text copy.
702            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("&amp;"),
710                    '<' => out.write_str("&lt;"),
711                    '>' => out.write_str("&gt;"),
712                    '"' => out.write_str("&quot;"),
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
732/// Enforce the budget while escaping, before HTML expansion can allocate a large string.
733struct 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
745/// Most rows Save as Web Page writes. A sheet whose used range is taller fails instead of losing cells.
746const HTML_EXPORT_MAX_ROWS: u32 = 100_001;
747
748/// Rows × columns of one web-page table. Save as Web Page writes every kept row's full used
749/// column span into one growing String, so a sparse sheet with a lone far-right cell (or a
750/// tall, wide one) would otherwise be gigabytes of `<td></td>` before finishing (#2387).
751const HTML_EXPORT_MAX_CELLS: u64 = 4_000_000;
752
753/// A sheet as a standalone HTML table (Save as Web Page). A sheet with pictures gets the sheet's
754/// own column widths and row heights so each picture and chart (inline SVG) can sit at its anchor.
755pub 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        // The table is one growing String: every kept row writes a `<td>` for the whole used
785        // column span (unless the sheet has no row that is that wide). A tall, wide sheet
786        // (or a sparse one with a lone far-right cell) would be gigabytes before finishing,
787        // so the rectangle itself is bounded (#2387).
788        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        // Sheet units are CSS pixels; the table starts at the used range, not at A1.
841        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    /// How many [`ScriptSandbox`]es are alive on this thread.
874    static SCRIPT_SANDBOX: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
875}
876
877/// While one is alive, [`read_file`] and [`write_file`] on this thread refuse every path: a
878/// stored script's steps run inside one (#2178), so no command reaches the file system from a
879/// script whatever its parameters are called. Dropping it (also on unwind) lifts the refusal.
880pub(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
895/// Refuses file access while a [`ScriptSandbox`] is alive (and, failing closed, while the
896/// thread is being torn down).
897fn 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/// The largest native file the reader still touches: its own cap plus one, used to stop
905/// reads whose bytes arrive faster than metadata can claim (device files, growing files).
906#[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    // A symlink to an endless device reports length 0 but streams forever, so metadata
915    // alone never bounds the read: require a real file and cap the bytes actually taken.
916    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    // Write to a temporary file, then rename: a crash never leaves a half-written workbook.
939    // The temporary file is created exclusively, so an existing file or symlink at that
940    // path (planted by another local user) makes the save fail instead of truncating the
941    // link's target; and it inherits the destination's access restrictions, so saving a
942    // private workbook doesn't widen who can read it.
943    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        // A write or rename that fails (full disk, the workbook locked by another app) leaves
966        // no temporary file beside the user's workbook.
967        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
1017/// Pixel size of a PNG or JPEG from its header.
1018pub 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;