diff --git a/contributors/people.toml b/contributors/people.toml index be84b5b01..d5ea71883 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -68,3 +68,6 @@ sync_github_name = false [people.Shnrqpdr] sync_github_name = true + +[people.bilalmughal] +sync_github_name = true diff --git a/crates/engine/src/chart_svg.rs b/crates/engine/src/chart_svg.rs new file mode 100644 index 000000000..be9e6d227 --- /dev/null +++ b/crates/engine/src/chart_svg.rs @@ -0,0 +1,201 @@ +//! Charts as inline SVG for Save as Web Page: the chart crate's drawing primitives, text included. + +use std::fmt::Write as _; + +use gridcraft_chart::{HAlign, Prim, Rgba, VAlign}; +use gridcraft_model::{Chart, Workbook}; + +use crate::io::esc; + +/// Widest or tallest chart written; larger anchors are clamped. +const MAX_SIDE: f32 = 16_384.0; + +struct HelveticaMeasure; + +impl gridcraft_chart::Measure for HelveticaMeasure { + fn text_width(&self, text: &str, size: f32, bold: bool) -> f32 { + gridcraft_pdf::text_width(if bold { gridcraft_pdf::Font::HelveticaBold } else { gridcraft_pdf::Font::Helvetica }, size, text) + } +} + +/// `chart` of sheet `sheet` as an `` element placed by `style`, or `None` for a degenerate size. +pub(crate) fn chart_svg(wb: &Workbook, sheet: usize, chart: &Chart, style: &str) -> Option { + let (w, h) = (chart.anchor.width.min(MAX_SIDE), chart.anchor.height.min(MAX_SIDE)); + if !(w >= 1.0 && h >= 1.0) { + return None; + } + let data = gridcraft_chart::resolve(wb, sheet, chart); + let prims = gridcraft_chart::render(chart, &data, w, h, &HelveticaMeasure); + Some(prims_svg(&prims, w, h, style)) +} + +fn prims_svg(prims: &[Prim], w: f32, h: f32, style: &str) -> String { + let mut out = String::new(); + let _ = write!( + out, + "" + ); + for p in prims { + match p { + Prim::Rect { x, y, w, h, fill, stroke, radius } => { + let _ = write!(out, " 0.0 { + let _ = write!(out, " rx=\"{}\"", num(*radius)); + } + paint(&mut out, *fill, *stroke); + out.push_str("/>"); + } + Prim::Line { pts, color, width, dash } => { + let _ = write!(out, ""); + } + Prim::Polygon { pts, fill, stroke } => { + let _ = write!(out, ""); + } + Prim::Wedge { cx, cy, r_outer, r_inner, a0, a1, fill, stroke } => { + let _ = write!(out, ""); + } + Prim::Circle { cx, cy, r, fill, stroke } => { + let _ = write!(out, ""); + } + Prim::Text { x, y, text, size, color, bold, align, valign, rotation } => { + // Baseline offsets as in the PDF export. + let dy = match valign { + VAlign::Top => size * 0.78, + VAlign::Middle => size * 0.35, + VAlign::Bottom => -size * 0.21, + }; + let anchor = match align { + HAlign::Left => "start", + HAlign::Center => "middle", + HAlign::Right => "end", + }; + let _ = write!(out, "{}", hex(*color), opacity("fill", *color), esc(text)); + } + } + } + out.push_str(""); + out +} + +/// Fill and stroke attributes of an open shape element; the caller closes it. +fn paint(out: &mut String, fill: Option, stroke: Option<(Rgba, f32)>) { + match fill { + Some(c) => { + let _ = write!(out, " fill=\"{}\"{}", hex(c), opacity("fill", c)); + } + None => out.push_str(" fill=\"none\""), + } + if let Some((c, w)) = stroke { + let _ = write!(out, " stroke=\"{}\" stroke-width=\"{}\"{}", hex(c), num(w), opacity("stroke", c)); + } +} + +/// A pie or doughnut sector. Angles run clockwise from 12 o'clock, like the chart crate's. +fn wedge_path(cx: f32, cy: f32, r_outer: f32, r_inner: f32, a0: f32, a1: f32) -> String { + let span = (a1 - a0).clamp(-std::f32::consts::TAU + 0.001, std::f32::consts::TAU - 0.001); + let a1 = a0 + span; + let at = |a: f32, r: f32| format!("{} {}", num(cx + r * a.sin()), num(cy - r * a.cos())); + let large = u8::from(span.abs() > std::f32::consts::PI); + let sweep = u8::from(span >= 0.0); + let mut d = format!("M{} A{r} {r} 0 {large} {sweep} {}", at(a0, r_outer), at(a1, r_outer), r = num(r_outer)); + if r_inner > 0.0 { + let _ = write!(d, " L{} A{r} {r} 0 {large} {} {} Z", at(a1, r_inner), 1 - sweep, at(a0, r_inner), r = num(r_inner)); + } else { + let _ = write!(d, " L{} {} Z", num(cx), num(cy)); + } + d +} + +fn points(pts: &[[f32; 2]]) -> String { + pts.iter().map(|p| format!("{},{}", num(p[0]), num(p[1]))).collect::>().join(" ") +} + +fn hex(c: Rgba) -> String { + format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2]) +} + +fn opacity(attr: &str, c: Rgba) -> String { + if c[3] == 255 { String::new() } else { format!(" {attr}-opacity=\"{:.2}\"", f32::from(c[3]) / 255.0) } +} + +/// A coordinate with two decimals; a non-finite value is 0 so the SVG stays well-formed. +fn num(v: f32) -> String { + if !v.is_finite() { + return "0".into(); + } + let s = format!("{v:.2}"); + s.trim_end_matches('0').trim_end_matches('.').to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_primitive_becomes_an_svg_element() { + let red = [255, 0, 0, 255]; + let prims = vec![ + Prim::Rect { x: 1.0, y: 2.0, w: 3.0, h: 4.0, fill: Some(red), stroke: None, radius: 0.0 }, + Prim::Line { pts: vec![[0.0, 0.0], [5.0, 5.5]], color: red, width: 1.0, dash: true }, + Prim::Polygon { pts: vec![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]], fill: red, stroke: Some(([0, 0, 0, 255], 1.0)) }, + Prim::Wedge { cx: 10.0, cy: 10.0, r_outer: 5.0, r_inner: 2.0, a0: 0.0, a1: 1.0, fill: red, stroke: None }, + Prim::Circle { cx: 1.0, cy: 1.0, r: 2.0, fill: Some([0, 0, 255, 128]), stroke: None }, + Prim::Text { + x: 5.0, + y: 6.0, + text: "A", + "", + "A<B", + ] { + assert!(svg.contains(part), "{part} in {svg}"); + } + assert!(svg.starts_with("") && svg.contains("style=\"left:1px\""), "{svg}"); + } + + #[test] + fn non_finite_numbers_do_not_break_the_markup() { + let prims = [Prim::Circle { cx: f32::NAN, cy: f32::INFINITY, r: 1.0, fill: None, stroke: None }]; + assert!(prims_svg(&prims, 10.0, 10.0, "").contains("")); + } + + #[test] + fn a_full_circle_wedge_keeps_a_drawable_arc() { + let d = wedge_path(0.0, 0.0, 10.0, 0.0, 0.0, std::f32::consts::TAU); + assert!(d.contains("A10 10 0 1 1 ") && d.ends_with("L0 0 Z"), "{d}"); + } +} diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 14717a3df..287722547 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -254,7 +254,7 @@ fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Result> { .map_err(|e| EngineError::Other(e.to_string())) } -fn esc(s: &str) -> String { +pub(crate) fn esc(s: &str) -> String { s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) } @@ -408,31 +408,56 @@ impl std::fmt::Write for ClipboardHtml { /// Most rows Save as Web Page writes. A sheet whose used range is taller fails instead of losing cells. const HTML_EXPORT_MAX_ROWS: u32 = 100_001; -/// A sheet as a standalone HTML table (Save as Web Page). +/// A sheet as a standalone HTML table (Save as Web Page). A sheet with pictures gets the sheet's +/// own column widths and row heights so each picture and chart (inline SVG) can sit at its anchor. pub fn to_html(wb: &Workbook, sheet: usize) -> Result { let Some(sh) = wb.sheet(sheet) else { return Ok(String::new()) }; + let fixed = !(sh.images.is_empty() && sh.charts.is_empty()); + let used = sh.used_range(); let mut out = String::new(); let _ = write!( out, - "{}", + "{}", esc(&sh.name), - table_dir(sh) + if fixed { ";table-layout:fixed" } else { "" }, + if fixed { "0 4px" } else { "2px 4px" }, + if fixed { ";overflow:hidden;line-height:1" } else { "" } ); - if let Some(r) = sh.used_range() { + if fixed { + out.push_str("
"); + } + let _ = write!(out, "'); + if let Some(r) = used { if r.height() > HTML_EXPORT_MAX_ROWS { return Err(EngineError::Other(format!( "the sheet is too tall for a web page ({} rows, at most {HTML_EXPORT_MAX_ROWS}). Save as .xlsx or CSV instead.", r.height() ))); } + if fixed { + out.push_str(""); + for col in (r.start.col..=r.end.col).filter(|&c| !sh.is_col_hidden(c)) { + let _ = write!(out, "", sh.col_width(col)); + } + out.push_str(""); + } for row in r.start.row..=r.end.row { if sh.is_row_hidden(row) { continue; } - out.push_str(""); + if fixed { + let _ = write!(out, "", sh.row_height(row)); + } else { + out.push_str(""); + } for col in r.start.col..=r.end.col { let c = CellRef::new(row, col); - if sh.merges.iter().any(|m| m.contains(c) && m.start != c) { + if sh.merges.iter().any(|m| m.contains(c) && m.start != c) || (fixed && sh.is_col_hidden(col)) { continue; } let text = crate::display::cell_text(wb, sh, c); @@ -442,14 +467,52 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> Result { .merges .iter() .find(|m| m.start == c) - .map(|m| format!(" rowspan=\"{}\" colspan=\"{}\"", m.height(), m.width())) + .map(|m| { + if fixed { + let rows = (m.start.row..=m.end.row).filter(|&y| !sh.is_row_hidden(y)).count(); + let cols = (m.start.col..=m.end.col).filter(|&x| !sh.is_col_hidden(x)).count(); + format!(" rowspan=\"{rows}\" colspan=\"{cols}\"") + } else { + format!(" rowspan=\"{}\" colspan=\"{}\"", m.height(), m.width()) + } + }) .unwrap_or_default(); let _ = write!(out, "{}", dir.attr(), esc(&text)); } out.push_str(""); } } - out.push_str(""); + out.push_str(""); + if fixed { + // Sheet units are CSS pixels; the table starts at the used range, not at A1. + let (left, top) = used.map_or((0.0, 0.0), |r| (sh.col_left(r.start.col), sh.row_top(r.start.row))); + let place = |a: &gridcraft_model::Anchor| { + format!( + "position:absolute;left:{}px;top:{}px;width:{}px;height:{}px", + sh.col_left(a.cell.col) - left + f64::from(a.dx), + sh.row_top(a.cell.row) - top + f64::from(a.dy), + a.width.max(0.0), + a.height.max(0.0) + ) + }; + for im in &sh.images { + let _ = write!( + out, + "\"{}\"", + esc(&im.mime), + base64_encode(&im.data), + esc(&im.alt), + place(&im.anchor) + ); + } + for ch in &sh.charts { + if let Some(svg) = crate::chart_svg::chart_svg(wb, sheet, ch, &place(&ch.anchor)) { + out.push_str(&svg); + } + } + out.push_str("
"); + } + out.push_str(""); Ok(out) } @@ -572,6 +635,88 @@ mod tests { range_html(wb, 0, r, &crate::display::range_text(wb, 0, r)).unwrap() } + #[test] + fn html_export_embeds_images_as_data_uris() { + let mut wb = Workbook::new(); + wb.sheet_mut(0).unwrap().images.push(gridcraft_model::Image { + id: 1, + anchor: gridcraft_model::Anchor { cell: CellRef::new(0, 0), dx: 0.0, dy: 0.0, width: 72.0, height: 36.0, mode: Default::default() }, + data: vec![1, 2, 3], + mime: "image/png".into(), + alt: "a \"logo\"".into(), + }); + let html = to_html(&wb, 0).unwrap(); + assert!( + html.contains("\"a"), + "{html}" + ); + assert!(html.contains("
"); + } + + #[test] + fn html_export_places_images_at_their_anchor_using_sheet_geometry() { + let mut wb = Workbook::new(); + let sh = wb.sheet_mut(0).unwrap(); + sh.cols.insert(1, gridcraft_model::LineInfo { size: Some(100.0), ..Default::default() }); + sh.rows.insert(1, gridcraft_model::LineInfo { size: Some(30.0), ..Default::default() }); + sh.images.push(gridcraft_model::Image { + id: 1, + anchor: gridcraft_model::Anchor { cell: CellRef::new(3, 2), dx: 5.0, dy: 7.0, width: 10.0, height: 20.0, mode: Default::default() }, + data: vec![1], + mime: "image/png".into(), + alt: String::new(), + }); + sh.set_value(CellRef::new(1, 1), gridcraft_core::Value::Number(1.0)); + let html = to_html(&wb, 0).unwrap(); + // The table starts at B2; the picture is at C4: one 100 px column and two rows (30 + 20) further on. + assert!(html.contains(""), "{html}"); + assert!(html.contains(""), "{html}"); + assert!(html.contains("left:105px;top:57px;width:10px;height:20px"), "{html}"); + } + + fn book_with_picture(anchor: &str, cell_at: (u32, u32)) -> Workbook { + let mut wb = Workbook::new(); + let sh = wb.sheet_mut(0).unwrap(); + sh.set_value(CellRef::new(cell_at.0, cell_at.1), gridcraft_core::Value::Number(1.0)); + sh.images.push(gridcraft_model::Image { + id: 1, + anchor: gridcraft_model::Anchor { + cell: CellRef::parse(anchor).unwrap(), + dx: 0.0, + dy: 0.0, + width: 10.0, + height: 10.0, + mode: Default::default(), + }, + data: vec![1], + mime: "image/png".into(), + alt: String::new(), + }); + wb + } + + #[test] + fn html_export_places_images_past_hidden_columns_and_rows() { + let mut wb = book_with_picture("C3", (0, 0)); + let sh = wb.sheet_mut(0).unwrap(); + sh.set_value(CellRef::new(2, 2), gridcraft_core::Value::Number(2.0)); + sh.cols.insert(1, gridcraft_model::LineInfo { hidden: true, ..Default::default() }); + sh.rows.insert(1, gridcraft_model::LineInfo { hidden: true, ..Default::default() }); + let html = to_html(&wb, 0).unwrap(); + // Column B and row 2 take no room: C3 is one default column and row from A1. + assert_eq!(html.matches("", "at": "D2", "width": 300, "height": 200})) + .unwrap(); + let html = crate::io::to_html(&s.doc().unwrap().wb, 0).unwrap(); + assert!(html.contains("Quarterly <Sales>"), "the title is drawn as escaped text: {html}"); + assert!(html.contains("position:absolute;left:"), "{html}"); + assert!(html.contains("= 2, "one wedge per slice: {html}"); + assert!(html.contains(">North") && html.contains(">South"), "the legend names the slices: {html}"); +} + +#[test] +fn html_export_without_pictures_or_charts_has_no_fixed_geometry() { + let mut s = s(); + s.execute("range.setValues", json!({"range": "A1", "values": [["a", "b"]]})).unwrap(); + let html = crate::io::to_html(&s.doc().unwrap().wb, 0).unwrap(); + assert!(!html.contains("") && !html.contains("table-layout") && !html.contains(" **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-19 · **Change:** minor (.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) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** minor (.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; HTML export places pictures and charts, as inline SVG, at their anchors) · **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** @@ -47,7 +47,7 @@ and repair-free opening remain unverified. | `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`) | 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 | +| `.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 | | `.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 | | @@ -85,6 +85,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | HTML (Save as Web Page) writes pictures (`data:` URI ``) and charts (inline SVG from the chart crate's drawing primitives, text included) at their anchors; the table uses the sheet's column widths and row heights when the sheet has either, and output for sheets without pictures or charts is unchanged | | 2026-10-19 | minor | .xls import skips dialog sheets (WsBool `fDialog`), which share the worksheet BOF type and were read as empty worksheets | | 2026-10-19 | minor | `.xls` import reads WINDOW2: right-to-left sheets (common in older Arabic and Hebrew files) open right-to-left, and hidden gridlines, headings and zero values and Show Formulas carry over | | 2026-10-19 | trivial | XLSX text boxes and shapes keep right-to-left paragraph direction (`a:pPr rtl`, ECMA-376 Part 1 §21.1.2.2.7): read from the first paragraph and written on every paragraph; output without it is unchanged |