#3166: open a web page's table as a sheet, also when it is named .xls

Web apps' "Export to Excel" often writes an HTML table and names it
.xls. GridCraft read such a file as CSV text, so it opened as lines of
markup in column A. Excel opens the table (after warning that the
format and the extension don't match).

Opening a file whose bytes are a web page with a table now reads the
first table as a sheet, through the clipboard HTML table reader: the
cells go through the CSV reader, so values read as they do in a CSV
(and stay text with csvAsText); <th> cells are bold and centred; the
sheet takes the page's <x:Name> or the file's name. A page that isn't
named .htm/.html opens with a warning saying what it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hector VillarrealandClaude Opus 5.5 committed 2026-11-03 00:27:49 -06:00
1 parent 178f3eb745
commit 44460f23cf
4 files changed
+122 -9

No files matched your search

+9 -7
View File
@@ -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 (`<th>`).
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct HtmlCell {
pub text: String,
pub num: Option<f64>,
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<V
let num = attr(attrs, "x:num").or_else(|| attr(attrs, "sdval")).and_then(|v| v.trim().parse::<f64>().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 {
<tr><td colspan=2 x:num="2.5">2.50 &amp; up</td><td>x</td></tr><!--EndFragment--></table></body></html>"#;
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#"<table cellspacing="0" border="0"><colgroup width="85"></colgroup><tr>
<td height="17" align="right" sdval="10000" sdnum="1033;0;[$Rp-421]#,##0.00"><font face="Liberation Sans">Rp10,000.00</font></td></tr></table>"#;
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#"<table><tr><td style='mso-number-format:"\[>=1000\]0"' x:num="1500">1500</td></tr></table>"#;
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);
+78
View File
@@ -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 (`<th>`) are bold and centred, and the sheet takes the name the page gives it
/// (`<x:Name>`) or the file's. `None` when the bytes aren't such a page.
fn read_html_table(name: &str, kind: Option<FileKind>, bytes: &[u8], csv_as_text: bool) -> Option<Result<(Workbook, Vec<String>)>> {
let text = String::from_utf8_lossy(bytes);
let head: String = text.trim_start_matches('\u{feff}').trim_start().chars().take(16).collect::<String>().to_ascii_lowercase();
if !["<!doctype html", "<html", "<table", "<head", "<body", "<meta"].iter().any(|t| head.starts_with(t)) {
return None;
}
let rows = crate::clip_html::table_cells(&text, gridcraft_core::MAX_ROWS as usize, gridcraft_core::MAX_COLS as usize);
if rows.iter().all(Vec::is_empty) {
return None;
}
// The cells go through the CSV reader, so numbers, dates and text read exactly as they do there.
let mut tsv = String::new();
for row in &rows {
let fields: Vec<String> = row
.iter()
.map(|cell| {
let field = match cell.num {
Some(n) => gridcraft_core::number_to_text(n),
None => cell.text.split_whitespace().collect::<Vec<_>>().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, "<x:Name>", "</x:Name>").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.
+32
View File
@@ -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, <th> bold and centred, <x:Name> names
// the sheet.
let page = "\u{feff}<html xmlns:x=\"urn:schemas-microsoft-com:office:excel\"><head><meta charset=\"UTF-8\">\
<!--[if gte mso 9]><xml><x:ExcelWorksheets><x:ExcelWorksheet><x:Name>Orders</x:Name></x:ExcelWorksheet></x:ExcelWorksheets></xml><![endif]-->\
</head><body><table><thead><tr><th>Item</th><th>Qty</th><th>Code</th></tr></thead><tbody>\
<tr><td>Kiwi &amp;\n Lime</td><td>12</td><td>007</td></tr><tr><td>Plum</td><td x:num=\"4.5\">4,50</td><td>042</td></tr>\
</tbody></table></body></html>";
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"<b>,1\nx,2\n").unwrap();
assert_eq!(wb.sheet(0).unwrap().value(CellRef::parse("B2").unwrap()), gridcraft_core::Value::number(2.0));
}
+3 -2
View File
@@ -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 `<img>` 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 `<x:Name>`), 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 `<img>` 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`), `<th>` cells are bold and centred, `<x:Name>` 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 |