mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-26 11:46:49 -06:00
Merge pull request #36 from harrisonoconnorhover/fix/rich-table-clipboard
Fix copying selected cells as rich HTML tables
This commit is contained in:
@@ -40,8 +40,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "edit.goToSpecial", "Go To Special…", ["Home", "Editing", "Find & Select"], None, "{kind: blanks|constants|formulas|comments|lastCell|currentRegion|visible|conditionalFormats|dataValidation|errors|numbers|text}", has_doc, go_to_special),
|
||||
cmd!(noundo "edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{steps?: 1}", can_undo, undo),
|
||||
cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Y"), "{steps?: 1}", can_redo, redo),
|
||||
cmd!(noundo "edit.copy", "Copy", ["Home", "Clipboard"], Some("Cmd+C"), "{range?}", has_doc, copy),
|
||||
cmd!(noundo "edit.cut", "Cut", ["Home", "Clipboard"], Some("Cmd+X"), "{range?}", has_doc, cut),
|
||||
cmd!(noundo "edit.copy", "Copy", ["Home", "Clipboard"], Some("Cmd+C"), "{range?, html?: bool} → {range, text, html?} (html: a styled HTML table, only when requested and small enough)", has_doc, copy),
|
||||
cmd!(noundo "edit.cut", "Cut", ["Home", "Clipboard"], Some("Cmd+X"), "{range?, html?: bool} → {range, text, html?} (html: a styled HTML table, only when requested and small enough)", has_doc, cut),
|
||||
cmd!(
|
||||
"edit.paste",
|
||||
"Paste",
|
||||
@@ -611,9 +611,16 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result<Json> {
|
||||
range
|
||||
};
|
||||
let text = crate::display::range_text(&d.wb, sheet, range);
|
||||
// The HTML rendering is for the host clipboard (UI); it can reach 4 MB, so agents and
|
||||
// scripts get it only when they ask for it.
|
||||
let html = if p.get("html").and_then(Json::as_bool) == Some(true) { crate::io::range_html(&d.wb, sheet, range, &text) } else { None };
|
||||
let clip = Clipboard { wb: d.wb.clone(), sheet, range, cut, doc_uid: d.uid, text: text.clone() };
|
||||
s.clipboard = Some(clip);
|
||||
Ok(json!({"range": range.a1(), "text": text}))
|
||||
let mut result = json!({"range": range.a1(), "text": text});
|
||||
if let Some(html) = html {
|
||||
result["html"] = Json::String(html);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn copy(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
|
||||
@@ -74,7 +74,7 @@ pub fn cell_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String {
|
||||
pub fn range_text(wb: &Workbook, sheet: usize, r: RangeRef) -> String {
|
||||
let Some(sh) = wb.sheet(sheet) else { return String::new() };
|
||||
let mut out = String::new();
|
||||
let r = if r.count() > 2_000_000 { RangeRef::new(r.start, r.start.offset_clamped(1999, 999)) } else { r };
|
||||
let r = clipboard_range(r);
|
||||
for row in r.start.row..=r.end.row {
|
||||
if sh.is_row_hidden(row) {
|
||||
continue;
|
||||
@@ -99,6 +99,11 @@ pub fn range_text(wb: &Workbook, sheet: usize, r: RangeRef) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// The existing plain-text clipboard limit, shared with its HTML representation.
|
||||
pub(crate) fn clipboard_range(r: RangeRef) -> RangeRef {
|
||||
if r.count() > 2_000_000 { RangeRef::new(r.start, r.start.offset_clamped(1999, 999)) } else { r }
|
||||
}
|
||||
|
||||
/// Status-bar statistics for a selection: average, count (non-empty), numerical count, min,
|
||||
/// max, sum.
|
||||
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize)]
|
||||
|
||||
+106
-27
@@ -2,8 +2,8 @@
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use gridcraft_core::CellRef;
|
||||
use gridcraft_model::Workbook;
|
||||
use gridcraft_core::{CellRef, RangeRef};
|
||||
use gridcraft_model::{Sheet, Workbook};
|
||||
|
||||
use crate::{EngineError, Result};
|
||||
|
||||
@@ -110,6 +110,109 @@ fn esc(s: &str) -> String {
|
||||
s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
|
||||
fn html_cell_css(wb: &Workbook, sh: &Sheet, c: CellRef) -> String {
|
||||
let st = wb.styles.get(sh.style_id(c));
|
||||
let mut css = String::new();
|
||||
if st.font.bold {
|
||||
css.push_str("font-weight:bold;");
|
||||
}
|
||||
if st.font.italic {
|
||||
css.push_str("font-style:italic;");
|
||||
}
|
||||
if let Some(h) = st.font.color.hex(&wb.theme) {
|
||||
let _ = write!(css, "color:{h};");
|
||||
}
|
||||
if st.fill.pattern != gridcraft_model::PatternType::None
|
||||
&& let Some(h) = st.fill.fg.hex(&wb.theme)
|
||||
{
|
||||
let _ = write!(css, "background:{h};");
|
||||
}
|
||||
let v = sh.value(c);
|
||||
let align = match st.align.h {
|
||||
gridcraft_model::HAlign::Center => "center",
|
||||
gridcraft_model::HAlign::Right => "right",
|
||||
gridcraft_model::HAlign::Left => "left",
|
||||
_ if v.is_number() => "right",
|
||||
_ => "left",
|
||||
};
|
||||
let _ = write!(css, "text-align:{align};");
|
||||
css
|
||||
}
|
||||
|
||||
const CLIPBOARD_HTML_MAX_BYTES: usize = 4 * 1024 * 1024;
|
||||
const CLIPBOARD_HTML_MAX_CELLS: u64 = 10_000;
|
||||
|
||||
/// An HTML fragment for the same range as `plain_text`, previously produced by
|
||||
/// `display::range_text`. Large copies keep plain text only; never return a partial table.
|
||||
pub(crate) fn range_html(wb: &Workbook, sheet: usize, r: RangeRef, plain_text: &str) -> Option<String> {
|
||||
let r = crate::display::clipboard_range(r);
|
||||
if r.count() > CLIPBOARD_HTML_MAX_CELLS || plain_text.len() > CLIPBOARD_HTML_MAX_BYTES {
|
||||
return None;
|
||||
}
|
||||
let sh = wb.sheet(sheet)?;
|
||||
let mut out = ClipboardHtml(String::new());
|
||||
out.write_str("<table style=\"border-collapse:collapse;font-family:Calibri,Carlito,Arial,sans-serif;font-size:11pt\">").ok()?;
|
||||
for row in r.start.row..=r.end.row {
|
||||
if sh.is_row_hidden(row) {
|
||||
continue;
|
||||
}
|
||||
out.write_str("<tr>").ok()?;
|
||||
for col in r.start.col..=r.end.col {
|
||||
let c = CellRef::new(row, col);
|
||||
let span = if let Some(merged) = sh.merges.iter().find(|m| m.contains(c)).and_then(|m| m.intersection(&r)) {
|
||||
let first_row = (merged.start.row..=merged.end.row).find(|&row| !sh.is_row_hidden(row))?;
|
||||
if c != CellRef::new(first_row, merged.start.col) {
|
||||
continue;
|
||||
}
|
||||
let rows = (first_row..=merged.end.row).filter(|&row| !sh.is_row_hidden(row)).count();
|
||||
format!(" rowspan=\"{rows}\" colspan=\"{}\"", merged.width())
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let css = html_cell_css(wb, sh, c);
|
||||
write!(out, "<td style=\"border:1px solid #d4d4d4;padding:2px 4px;white-space:pre-wrap;{css}\"{span}>").ok()?;
|
||||
// Use the visible selected cell, even when the merge's original anchor
|
||||
// is outside the range or hidden. This matches the plain-text copy.
|
||||
let text = crate::display::cell_text(wb, sh, c);
|
||||
let mut chars = text.chars().peekable();
|
||||
while let Some(ch) = chars.next() {
|
||||
match ch {
|
||||
'&' => out.write_str("&"),
|
||||
'<' => out.write_str("<"),
|
||||
'>' => out.write_str(">"),
|
||||
'"' => out.write_str("""),
|
||||
'\r' => {
|
||||
if chars.peek() == Some(&'\n') {
|
||||
chars.next();
|
||||
}
|
||||
out.write_str("<br>")
|
||||
}
|
||||
'\n' => out.write_str("<br>"),
|
||||
_ => out.write_char(ch),
|
||||
}
|
||||
.ok()?;
|
||||
}
|
||||
out.write_str("</td>").ok()?;
|
||||
}
|
||||
out.write_str("</tr>").ok()?;
|
||||
}
|
||||
out.write_str("</table>").ok()?;
|
||||
Some(out.0)
|
||||
}
|
||||
|
||||
/// Enforce the budget while escaping, before HTML expansion can allocate a large string.
|
||||
struct ClipboardHtml(String);
|
||||
|
||||
impl std::fmt::Write for ClipboardHtml {
|
||||
fn write_str(&mut self, s: &str) -> std::fmt::Result {
|
||||
if s.len() > CLIPBOARD_HTML_MAX_BYTES.saturating_sub(self.0.len()) {
|
||||
return Err(std::fmt::Error);
|
||||
}
|
||||
self.0.push_str(s);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A sheet as a standalone HTML table (Save as Web Page).
|
||||
pub fn to_html(wb: &Workbook, sheet: usize) -> String {
|
||||
let Some(sh) = wb.sheet(sheet) else { return String::new() };
|
||||
@@ -130,31 +233,7 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> String {
|
||||
if sh.merges.iter().any(|m| m.contains(c) && m.start != c) {
|
||||
continue;
|
||||
}
|
||||
let st = wb.styles.get(sh.style_id(c));
|
||||
let mut css = String::new();
|
||||
if st.font.bold {
|
||||
css.push_str("font-weight:bold;");
|
||||
}
|
||||
if st.font.italic {
|
||||
css.push_str("font-style:italic;");
|
||||
}
|
||||
if let Some(h) = st.font.color.hex(&wb.theme) {
|
||||
let _ = write!(css, "color:{h};");
|
||||
}
|
||||
if st.fill.pattern != gridcraft_model::PatternType::None
|
||||
&& let Some(h) = st.fill.fg.hex(&wb.theme)
|
||||
{
|
||||
let _ = write!(css, "background:{h};");
|
||||
}
|
||||
let v = sh.value(c);
|
||||
let align = match st.align.h {
|
||||
gridcraft_model::HAlign::Center => "center",
|
||||
gridcraft_model::HAlign::Right => "right",
|
||||
gridcraft_model::HAlign::Left => "left",
|
||||
_ if v.is_number() => "right",
|
||||
_ => "left",
|
||||
};
|
||||
let _ = write!(css, "text-align:{align};");
|
||||
let css = html_cell_css(wb, sh, c);
|
||||
let span = sh
|
||||
.merges
|
||||
.iter()
|
||||
|
||||
@@ -306,6 +306,103 @@ fn copy_paste_shifts_formulas() {
|
||||
assert_eq!(f["formula"], "=E5+B1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rich_clipboard_copies_selected_displayed_cells_and_styles() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "outside selection"})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "B2", "values": [[0.125, "<tag>&\"\nnext"], [0.25, "last"]]})).unwrap();
|
||||
s.execute("home.numberFormat", json!({"range": "B2:B3", "code": "0.0%"})).unwrap();
|
||||
s.execute("home.bold", json!({"range": "B2", "on": true})).unwrap();
|
||||
s.execute("home.italic", json!({"range": "B2", "on": true})).unwrap();
|
||||
s.execute("home.fontColor", json!({"range": "B2", "color": "#123456"})).unwrap();
|
||||
s.execute("home.fillColor", json!({"range": "B2", "color": "#FEDCBA"})).unwrap();
|
||||
let copied = s.execute("edit.copy", json!({"range": "B2:C3", "html": true})).unwrap();
|
||||
let html = copied["html"].as_str().expect("copy publishes HTML as well as plain text");
|
||||
assert_eq!(copied["text"], "12.5%\t\"<tag>&\"\"\nnext\"\n25.0%\tlast\n");
|
||||
assert!(html.starts_with("<table "));
|
||||
assert!(html.ends_with("</table>"));
|
||||
assert_eq!(html.matches("<tr>").count(), 2);
|
||||
assert_eq!(html.matches("<td ").count(), 4);
|
||||
assert!(html.contains("<tag>&"<br>next"));
|
||||
for content in ["12.5%", "25.0%", "font-weight:bold;", "font-style:italic;", "color:#123456;", "background:#FEDCBA;", "text-align:right;"] {
|
||||
assert!(html.contains(content), "missing {content}");
|
||||
}
|
||||
assert!(!html.contains("outside selection"));
|
||||
assert!(!html.contains("<html"));
|
||||
|
||||
let cut = s.execute("edit.cut", json!({"range": "B2:C3", "html": true})).unwrap();
|
||||
assert_eq!(cut["html"], copied["html"]);
|
||||
assert_eq!(cut["text"], copied["text"]);
|
||||
s.execute("edit.paste", json!({"at": "E5", "text": cut["text"]})).unwrap();
|
||||
assert_eq!(v(&s, "E5"), Value::Number(0.125));
|
||||
assert_eq!(v(&s, "B2"), Value::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_and_cut_return_html_only_when_requested() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "x"})).unwrap();
|
||||
for cmd in ["edit.copy", "edit.cut"] {
|
||||
for params in [json!({"range": "A1"}), json!({"range": "A1", "html": false})] {
|
||||
let r = s.execute(cmd, params).unwrap();
|
||||
assert_eq!(r["text"], "x\n");
|
||||
assert!(r.get("html").is_none(), "{cmd} must not bloat programmatic responses with HTML");
|
||||
}
|
||||
let r = s.execute(cmd, json!({"range": "A1", "html": true})).unwrap();
|
||||
assert!(r["html"].as_str().is_some_and(|h| h.contains(">x</td>")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rich_clipboard_clips_merges_and_skips_hidden_rows() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "B2", "input": "merged"})).unwrap();
|
||||
s.execute("home.mergeCenter", json!({"range": "B2:D5"})).unwrap();
|
||||
s.execute("home.hideRows", json!({"rows": "3:3"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "E4", "input": "side"})).unwrap();
|
||||
let copied = s.execute("edit.copy", json!({"range": "B2:C4", "html": true})).unwrap();
|
||||
let html = copied["html"].as_str().expect("merged copy publishes HTML");
|
||||
assert_eq!(copied["text"], "merged\t\n\t\n");
|
||||
assert_eq!(html.matches("<tr>").count(), 2);
|
||||
assert_eq!(html.matches("<td ").count(), 1);
|
||||
assert!(html.contains("rowspan=\"2\" colspan=\"2\""));
|
||||
assert!(html.contains(">merged</td>"));
|
||||
|
||||
// A selection beginning inside a merge must keep its shape without copying
|
||||
// the original anchor's value from outside the selected rectangle.
|
||||
let copied = s.execute("edit.copy", json!({"range": "C3:E5", "html": true})).unwrap();
|
||||
let html = copied["html"].as_str().expect("partially selected merge publishes HTML");
|
||||
assert_eq!(html.matches("<tr>").count(), 2);
|
||||
assert_eq!(html.matches("<td ").count(), 3);
|
||||
assert!(html.contains("rowspan=\"2\" colspan=\"2\""));
|
||||
assert!(!html.contains("merged"));
|
||||
assert!(html.contains(">side</td>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rich_clipboard_omits_html_for_large_ranges_without_truncating_text() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A10001", "input": "last cell"})).unwrap();
|
||||
let copied = s.execute("edit.copy", json!({"range": "A1:A10001", "html": true})).unwrap();
|
||||
assert!(copied.get("html").is_none(), "oversized HTML must be omitted, not partially copied");
|
||||
let text = copied["text"].as_str().unwrap();
|
||||
assert_eq!(text.lines().count(), 10_001);
|
||||
assert!(text.ends_with("last cell\n"));
|
||||
let copied = s.execute("edit.copy", json!({"range": "A10001", "html": true})).unwrap();
|
||||
assert!(copied["html"].as_str().is_some_and(|html| html.contains("last cell")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rich_clipboard_omits_html_when_escaping_exceeds_byte_budget() {
|
||||
let mut s = s();
|
||||
let text = "&".repeat(1_000_000);
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[text]]})).unwrap();
|
||||
let copied = s.execute("edit.copy", json!({"range": "A1", "html": true})).unwrap();
|
||||
assert!(copied.get("html").is_none(), "an oversized escaped value must not produce partial HTML");
|
||||
assert_eq!(copied["text"].as_str().unwrap().len(), 1_000_001);
|
||||
assert!(copied["text"].as_str().unwrap().ends_with("&\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_delete_rows_adjust() {
|
||||
let mut s = s();
|
||||
|
||||
Reference in New Issue
Block a user