diff --git a/crates/engine/src/clip_html.rs b/crates/engine/src/clip_html.rs index e1d217c9c..fa9822390 100644 --- a/crates/engine/src/clip_html.rs +++ b/crates/engine/src/clip_html.rs @@ -17,11 +17,13 @@ const MAX_ROW_SPAN: usize = gridcraft_core::MAX_ROWS as usize; /// table is ignored: the HTML only refines cells of the tab-separated text, which stays the source. const MAX_HTML_CELLS: usize = 1_000_000; -/// One table cell: its visible text and the number the source recorded for it, if any. +/// One table cell: its visible text, the number the source recorded for it, if any, and whether +/// it is a header cell (``). #[derive(Clone, Debug, Default, PartialEq)] pub(crate) struct HtmlCell { pub text: String, pub num: Option, + pub header: bool, } /// The cells of the first table in `html`, row by row, read no further than `max_rows` rows of @@ -88,7 +90,7 @@ pub(crate) fn table_cells(html: &str, max_rows: usize, max_cols: usize) -> Vec().ok()).filter(|n| n.is_finite()); let colspan = span(attrs, "colspan", MAX_SPAN); let rowspan = span(attrs, "rowspan", MAX_ROW_SPAN); - cell = Some(OpenCell { cell: HtmlCell { text: String::new(), num }, colspan, rowspan }); + cell = Some(OpenCell { cell: HtmlCell { text: String::new(), num, header: name == "th" }, colspan, rowspan }); } ("td" | "th", true) | ("tr", true) => grid.finish(&mut cell), ("br", _) => { @@ -376,18 +378,18 @@ mod tests { 2.50 & upx"#; let rows = table_cells(excel, 2, 3); assert_eq!(rows.len(), 2); - assert_eq!(rows[0][0], HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) }); - assert_eq!(rows[0][1], HtmlCell { text: "Name".into(), num: None }); + assert_eq!(rows[0][0], HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0), ..HtmlCell::default() }); + assert_eq!(rows[0][1], HtmlCell { text: "Name".into(), num: None, ..HtmlCell::default() }); assert_eq!(rows[1].len(), 3, "colspan keeps the next cell in its column"); assert_eq!(rows[1][0].text, "2.50 & up"); assert_eq!(rows[1][2].text, "x"); let calc = r#"
Rp10,000.00
"#; - assert_eq!(table_cells(calc, 1, 1), vec![vec![HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) }]]); + assert_eq!(table_cells(calc, 1, 1), vec![vec![HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0), ..HtmlCell::default() }]]); let quoted_gt = r#"
1500
"#; - assert_eq!(table_cells(quoted_gt, 1, 1), vec![vec![HtmlCell { text: "1500".into(), num: Some(1500.0) }]]); + assert_eq!(table_cells(quoted_gt, 1, 1), vec![vec![HtmlCell { text: "1500".into(), num: Some(1500.0), ..HtmlCell::default() }]]); } #[test] @@ -434,7 +436,7 @@ mod tests { assert!(cells <= MAX_HTML_CELLS, "{cells} cells"); assert!(rows.iter().all(|r| r.len() <= MAX_SPAN)); assert_eq!(rows[0].len(), MAX_SPAN, "the first span fills the sheet's width"); - assert_eq!(rows[0][0], HtmlCell { text: "1".into(), num: Some(1.0) }); + assert_eq!(rows[0][0], HtmlCell { text: "1".into(), num: Some(1.0), ..HtmlCell::default() }); // Bounded by the pasted text's shape, the same markup yields just that shape. let rows = table_cells(&html, 3, 2); diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index ce1fb40b0..9e3c7f7ae 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -291,6 +291,9 @@ fn read_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook, })?; return Ok((wb, report.warnings)); } + if let Some(read) = read_html_table(name, kind, bytes, csv_as_text) { + return read; + } 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}")))?; @@ -346,9 +349,84 @@ fn part_too_large_message(name: &str, part: &str, size: u64) -> String { ) } +/// A web page that holds a table, whatever its extension: a web app's "Export to Excel" is often +/// HTML saved as `.xls` (#3166). Excel opens its table as a sheet (after warning that the format +/// and extension don't match): the cells read like typed input (or as text with `csv_as_text`), +/// header cells (``) are bold and centred, and the sheet takes the name the page gives it +/// (``) or the file's. `None` when the bytes aren't such a page. +fn read_html_table(name: &str, kind: Option, bytes: &[u8], csv_as_text: bool) -> Option)>> { + let text = String::from_utf8_lossy(bytes); + let head: String = text.trim_start_matches('\u{feff}').trim_start().chars().take(16).collect::().to_ascii_lowercase(); + if ![" = row + .iter() + .map(|cell| { + let field = match cell.num { + Some(n) => gridcraft_core::number_to_text(n), + None => cell.text.split_whitespace().collect::>().join(" "), + }; + if field.contains(['\t', '\n', '\r', '"']) { format!("\"{}\"", field.replace('"', "\"\"")) } else { field } + }) + .collect(); + tsv.push_str(&fields.join("\t")); + tsv.push('\n'); + } + let opts = gridcraft_xlsx::CsvOptions { delimiter: b'\t', parse_values: !csv_as_text, ..Default::default() }; + let read = gridcraft_xlsx::read_csv_with_report(tsv.as_bytes(), &opts).map_err(|e| EngineError::Other(e.to_string())); + Some(read.map(|(mut wb, report)| { + wb.csv_delimiter = None; + let header_style = wb.styles.derive(gridcraft_model::StyleId::DEFAULT, |st| { + st.font.bold = true; + st.align.h = gridcraft_model::HAlign::Center; + }); + if let Some(sh) = wb.sheet_mut(0) { + for (r, row) in rows.iter().enumerate() { + for (c, cell) in row.iter().enumerate() { + if cell.header && !cell.text.trim().is_empty() { + sh.set_style(CellRef::new(r as u32, c as u32), header_style); + } + } + } + } + let page_name = find_between(&text, "", "").map(|n| crate::clip_html::normalized(n.trim())); + match page_name.filter(|n| !n.is_empty()) { + Some(sheet) => set_first_sheet_name(&mut wb, &sheet), + None => rename_first_sheet(&mut wb, name), + } + let mut warnings = report.warnings; + if !matches!(kind, Some(FileKind::Html)) { + 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.")); + } + (wb, warnings) + })) +} + +/// The text between the first `open` and the `close` after it, matched case-insensitively. +fn find_between<'a>(text: &'a str, open: &str, close: &str) -> Option<&'a str> { + let lower = text.to_ascii_lowercase(); + let start = lower.find(&open.to_ascii_lowercase())?.checked_add(open.len())?; + let len = lower.get(start..)?.find(&close.to_ascii_lowercase())?; + text.get(start..start.checked_add(len)?) +} + /// 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(std::ffi::OsStr::to_str).unwrap_or("Sheet1"); + set_first_sheet_name(wb, stem); +} + +/// Names the first sheet `name`, without the characters a sheet name can't hold and cut to 31 +/// characters; an empty or reserved result keeps the default name. +fn set_first_sheet_name(wb: &mut Workbook, stem: &str) { let clean: String = stem.chars().filter(|c| !matches!(c, ':' | '\\' | '/' | '?' | '*' | '[' | ']')).take(31).collect(); let clean = clean.trim_matches('\'').to_string(); // "History" is reserved (see `Workbook::check_sheet_name`): keep the default name instead. diff --git a/crates/engine/src/io/tests.rs b/crates/engine/src/io/tests.rs index 9051c5c09..34b1a3c0a 100644 --- a/crates/engine/src/io/tests.rs +++ b/crates/engine/src/io/tests.rs @@ -604,3 +604,35 @@ fn opening_google_sheets_export_unwraps_only_formulas_gridcraft_calculates() { assert_eq!(text("A2").as_deref(), Some("IFERROR(__XLUDF.DUMMYFUNCTION(\"GOOGLETRANSLATE(\"\"hi\"\",\"\"en\"\",\"\"fr\"\")\"),\"salut\")")); assert_eq!(opened.sheets[0].cells.get(CellRef::parse("A2").unwrap()).map(|c| c.value.clone()), Some(Value::text("salut"))); } + +#[test] +fn a_web_page_saved_as_xls_opens_its_table() { + // #3166: a web app's "Export to Excel" is an HTML table saved as .xls; it opened as lines of + // markup. Excel opens the table: values read as typed, bold and centred, names + // the sheet. + let page = "\u{feff}\ + \ + \ + \ +
ItemQtyCode
Kiwi &\n Lime12007
Plum4,50042
"; + let (wb, warnings) = open_bytes("orders.xls", page.as_bytes()).unwrap(); + assert!(warnings.first().is_some_and(|w| w.contains("is a web page (HTML)")), "{warnings:?}"); + let sh = wb.sheet(0).unwrap(); + assert_eq!(sh.name, "Orders"); + let v = |a: &str| sh.value(CellRef::parse(a).unwrap()); + assert_eq!(v("A1"), gridcraft_core::Value::text("Item")); + assert_eq!(v("A2"), gridcraft_core::Value::text("Kiwi & Lime")); + assert_eq!(v("B2"), gridcraft_core::Value::number(12.0)); + assert_eq!(v("C2"), gridcraft_core::Value::number(7.0), "read like typed input, as Excel does"); + assert_eq!(v("B3"), gridcraft_core::Value::number(4.5), "the number the page recorded"); + let style = |a: &str| wb.styles.get(sh.style_id(CellRef::parse(a).unwrap())).clone(); + assert!(style("A1").font.bold && style("A1").align.h == gridcraft_model::HAlign::Center); + assert!(!style("A2").font.bold); + // Opened as text, codes keep their zeros; a page named .html gets no warning. + let (wb, warnings) = open_bytes_with("orders.html", page.as_bytes(), None, true).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(wb.sheet(0).unwrap().value(CellRef::parse("C2").unwrap()), gridcraft_core::Value::text("007")); + // Plain text that merely starts with a tag-like word is still read as CSV. + let (wb, _) = open_bytes("notes.csv", b",1\nx,2\n").unwrap(); + assert_eq!(wb.sheet(0).unwrap().value(CellRef::parse("B2").unwrap()), gridcraft_core::Value::number(2.0)); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c4bc72bae..8e4a8fed7 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-02 · **Change:** minor (Python cells in `.xlsx` as [MS-XLSX] specifies, `_xlfn._xlws.PY(…)` naming code in the Python part, with cached values, and in GridCraft JSON, with limits on open; earlier: equations in xlsx as Office Math with a marker in `descr` so an edit made in Excel reads back, read back only up to the equation source cap, and a brace with a label and a script on its other side keeps both; Symbol font in PDF; earlier: XLSX reads and writes Page Setup's page order, `pageSetup pageOrder`, and PDF export follows it; earlier: chart data tables: native c:dTable import/export; earlier: XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-03 · **Change:** minor (a web page's first table opens as a sheet, also when the page is named `.xls`; earlier: Python cells in `.xlsx` as [MS-XLSX] specifies, `_xlfn._xlws.PY(…)` naming code in the Python part, with cached values, and in GridCraft JSON, with limits on open; earlier: equations in xlsx as Office Math with a marker in `descr` so an edit made in Excel reads back, read back only up to the equation source cap, and a brace with a label and a script on its other side keeps both; Symbol font in PDF; earlier: XLSX reads and writes Page Setup's page order, `pageSetup pageOrder`, and PDF export follows it; earlier: chart data tables: native c:dTable import/export; earlier: XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -107,7 +107,7 @@ past the parser's limit, and a wrapper inside the text. **Saving writes the real | `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`; likely Windows-1256 Arabic detection for invalid UTF-8; per-column day/month date-order detection for `/` and `-` dates) | yes (displayed values) | Text Import Wizard options partly via Text to Columns | | `.prn` formatted text (space-delimited) | R/W | no | no | | | `.dif`, `.slk` (SYLK) | R/W | no | no | Small, legacy | -| `.htm` / `.html` web page | R/W (Windows) | yes, via Get Data ▸ From HTML | yes (one sheet as a table; a used range taller than 100,001 rows fails with an error instead of being cut short) | Formatting not exported. A sheet with pictures or charts is written with the sheet's own column widths and row heights, a picture as a `data:` URI `` and a chart as inline SVG, each placed at its anchor; shapes, text boxes and comments are not exported | +| `.htm` / `.html` web page | R/W (Windows) | yes, via Get Data ▸ From HTML | yes: opening reads the page's first table as a sheet (header cells bold and centred, values read as typed, the sheet named by ``), also when the page is saved as `.xls`/`.xlsx` as web apps' "Export to Excel" does, with a warning that the format and name don't match (#3166); saving writes one sheet as a table; a used range taller than 100,001 rows fails with an error instead of being cut short) | Formatting not exported. A sheet with pictures or charts is written with the sheet's own column widths and row heights, a picture as a `data:` URI `` and a chart as inline SVG, each placed at its anchor; shapes, text boxes and comments are not exported | | `.mht` / `.mhtml` single-file web page | R/W (Windows) | no | no | | | `.json` | via Power Query | GridCraft's own workbook JSON (debug) and JSON import | GridCraft JSON | Not Power Query's JSON shaping; notes and hyperlinks break export (#126) | | `.xml` with XML maps | Developer ▸ Import/Export | no | no | | @@ -155,6 +155,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Opening a web page (HTML) reads its first table as a sheet, whatever the file is called: a web app's "Export to Excel" saved as `.xls` no longer opens as lines of markup. Values read as in a CSV (or as text with `csvAsText`), `` cells are bold and centred, `` names the sheet, and a page not named `.htm`/`.html` opens with a warning (#3166) | | 2026-11-02 | minor | Python cells: `.xlsx` reads and writes `_xlfn._xlws.PY(i,0 or 1)` with the code in the Python part (and reads the string-literal form) with cached values and spills, other formats adopt PY formulas, GridCraft JSON keeps the whole cell, and opening limits code size, result size and spill ranges | | 2026-11-02 | trivial | An equation without Office Math takes its stored source only while its text, paragraphs included, is what the writer wrote for it; a paragraph split that changes no character reads back as a text box with the split text | | 2026-11-02 | trivial | Each equation box carries the marker of its own body in its description, the Office Math box and the plain-text fallback alike, and the reader takes the stored source only while the body it reads matches the marker: the text of an equation without Office Math that another application edited reads back as a text box with the edited text instead of the stale source |