mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-05 01:58:58 +00:00
Merge PR #3223
This commit is contained in:
commit
d906bcbb6f
5 files changed
+174
-10
No files matched your search
@@ -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 & 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);
|
||||
|
||||
@@ -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,103 @@ 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>)>> {
|
||||
// Decoded as a CSV is (BOM, UTF-8, else Windows-1252/1256), so a legacy page keeps its letters.
|
||||
let text = gridcraft_xlsx::decode_text(bytes);
|
||||
// Comments an exporter writes before the page (`<!-- ... -->`) don't make it something else.
|
||||
let mut rest = text.trim_start_matches('\u{feff}').trim_start();
|
||||
while let Some(after) = rest.strip_prefix("<!--") {
|
||||
match after.find("-->").and_then(|end| after.get(end + 3..)) {
|
||||
Some(more) => rest = more.trim_start(),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
let head: String = rest.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| {
|
||||
// The number the page recorded (`x:num`) only when values are read; as text, the
|
||||
// cell keeps the text it shows.
|
||||
let field = match cell.num.filter(|_| !csv_as_text) {
|
||||
Some(n) => gridcraft_core::number_to_text(n),
|
||||
// Whitespace is already collapsed; non-breaking spaces stay, as in Excel.
|
||||
None => cell.text.clone(),
|
||||
};
|
||||
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;
|
||||
// Header cells are bold and centred on top of the style the CSV reader gave them, so a
|
||||
// date in a header keeps its date format.
|
||||
let headers: Vec<CellRef> = rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.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)))
|
||||
.collect();
|
||||
let mut bold: std::collections::HashMap<gridcraft_model::StyleId, gridcraft_model::StyleId> = std::collections::HashMap::new();
|
||||
for at in headers {
|
||||
let Some(base) = wb.sheet(0).map(|sh| sh.style_id(at)) else { continue };
|
||||
let id = *bold.entry(base).or_insert_with(|| {
|
||||
wb.styles.derive(base, |st| {
|
||||
st.font.bold = true;
|
||||
st.align.h = gridcraft_model::HAlign::Center;
|
||||
})
|
||||
});
|
||||
if let Some(sh) = wb.sheet_mut(0) {
|
||||
sh.set_style(at, id);
|
||||
}
|
||||
}
|
||||
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.
|
||||
|
||||
@@ -604,3 +604,67 @@ 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 &\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));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_web_page_table_keeps_its_characters_text_and_header_formats() {
|
||||
// A page saved in Windows-1252 keeps its accented letters (it read as replacement characters).
|
||||
let mut page = b"<html><body><table><tr><th>Caf".to_vec();
|
||||
page.push(0xE9);
|
||||
page.extend_from_slice(b"</th><th>2024-01-15</th></tr><tr><td>Plum</td><td x:num=\"4.5\">4,50</td></tr></table></body></html>");
|
||||
let (wb, _) = open_bytes("menu.xls", &page).unwrap();
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
assert_eq!(sh.value(CellRef::parse("A1").unwrap()), gridcraft_core::Value::text("Café"));
|
||||
// A date in a header cell stays a date, bold and centred (its format was replaced).
|
||||
let b1 = wb.styles.get(sh.style_id(CellRef::parse("B1").unwrap()));
|
||||
assert!(matches!(sh.value(CellRef::parse("B1").unwrap()), gridcraft_core::Value::Number(_)));
|
||||
assert!(b1.font.bold && b1.align.h == gridcraft_model::HAlign::Center);
|
||||
assert_ne!(b1.num_fmt.as_str(), "General", "the date keeps its date format");
|
||||
assert_eq!(sh.value(CellRef::parse("B2").unwrap()), gridcraft_core::Value::number(4.5));
|
||||
// Opened as text, a cell keeps the text it shows, not the number the page recorded for it.
|
||||
let (wb, _) = open_bytes_with("menu.xls", &page, None, true).unwrap();
|
||||
assert_eq!(wb.sheet(0).unwrap().value(CellRef::parse("B2").unwrap()), gridcraft_core::Value::text("4,50"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_web_page_table_opens_after_a_leading_comment_and_keeps_nonbreaking_spaces() {
|
||||
// An exporter's comment before <html> still makes a web page; ` ` stays a non-breaking
|
||||
// space, as Excel keeps it, while ordinary whitespace collapses.
|
||||
let page = "<!-- exported by a web app -->\n<html><body><table><tr><td>New York</td><td>a \n b</td></tr></table></body></html>";
|
||||
let (wb, warnings) = open_bytes("cities.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.value(CellRef::parse("A1").unwrap()), gridcraft_core::Value::text("New\u{a0}\u{a0}York"));
|
||||
assert_eq!(sh.value(CellRef::parse("B1").unwrap()), gridcraft_core::Value::text("a b"));
|
||||
}
|
||||
@@ -47,7 +47,7 @@ use gridcraft_core::DateSystem;
|
||||
use gridcraft_model::Workbook;
|
||||
|
||||
pub use crypto::{DecryptError, check_encryption, decrypt};
|
||||
pub use csv::{MAX_CSV_BYTES, read_csv, read_csv_with_report, write_csv, write_csv_limited};
|
||||
pub use csv::{MAX_CSV_BYTES, decode_text, read_csv, read_csv_with_report, write_csv, write_csv_limited};
|
||||
pub use ods::read_ods;
|
||||
pub use package::MAX_PART;
|
||||
pub use passthrough::rebase_links;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity
|
||||
|
||||
> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-03 · **Change:** minor (`workbookPr filterPrivacy` kept, and honoured on save; earlier: a workbook's R1C1 reference style, `calcPr refMode`, is kept through open and save; earlier: XLSX page margins are written as their decimals, `0.3` rather than `0.30000001192092896`; 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
|
||||
> **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: `workbookPr filterPrivacy` kept, and honoured on save; earlier: a workbook's R1C1 reference style, `calcPr refMode`, is kept through open and save; earlier: XLSX page margins are written as their decimals, `0.3` rather than `0.30000001192092896`; 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-03 | minor | XLSX `<workbookPr filterPrivacy="1"/>` (Remove personal information from file properties on save) is read and written back, and while it is on GridCraft saves no author (`dc:creator`) and no company, as Excel leaves them empty (#3089, file part) |
|
||||
| 2026-11-03 | minor | XLSX `<calcPr refMode="R1C1"/>` (a workbook saved in R1C1 reference style) is read into the calculation settings and written back; GridCraft itself still shows A1 references (#2809, file part) |
|
||||
| 2026-11-03 | minor | XLSX `pageMargins` are written as the decimals they were read or set as (`0.3`, as Excel writes them), not as the f64 widening of the stored f32 (`0.30000001192092896`) (#1609 item 7) |
|
||||
|
||||
Reference in new issue
Block a user