Merge pull request #285 from vangel76/fix/currency-paste

Clipboard: pasted currency cells keep the number from the clipboard HTML (#257)
This commit is contained in:
Brandon Thomas 2026-10-11 11:43:48 -04:00
commit 59deadc4c3
9 changed files with 423 additions and 6 deletions

View File

@ -17,6 +17,9 @@ mod logging;
#[cfg(target_os = "macos")]
mod native_menu;
use std::cell::RefCell;
use std::rc::Rc;
use gridcraft_engine::Session;
use gridcraft_ui_egui::{Services, SheetApp};
use serde_json::json;
@ -116,14 +119,23 @@ fn save_prefs(app: &SheetApp) {
fn services() -> Services {
// Keep the owner alive: X11/Wayland serve clipboard data from this handle.
let mut clipboard: Option<arboard::Clipboard> = None;
let clipboard: Rc<RefCell<Option<arboard::Clipboard>>> = Rc::default();
let paste_clipboard = clipboard.clone();
Services {
copy_html: Some(Box::new(move |html, text| {
let mut clipboard = clipboard.borrow_mut();
if clipboard.is_none() {
clipboard = Some(arboard::Clipboard::new().map_err(|e| e.to_string())?);
*clipboard = Some(arboard::Clipboard::new().map_err(|e| e.to_string())?);
}
clipboard.as_mut().ok_or("clipboard unavailable")?.set_html(html, Some(text)).map_err(|e| e.to_string())
})),
paste_html: Some(Box::new(move || {
let mut clipboard = paste_clipboard.try_borrow_mut().ok()?;
if clipboard.is_none() {
*clipboard = arboard::Clipboard::new().ok();
}
clipboard.as_mut()?.get().html().ok()
})),
pick_open: Some(Box::new(|| {
rfd::FileDialog::new()
.add_filter("Spreadsheets", &["xlsx", "xlsm", "xlsb", "ods", "csv", "tsv", "txt", "json"])

View File

@ -0,0 +1,323 @@
//! Reading the HTML flavour of the system clipboard on paste.
//!
//! Spreadsheets put an HTML table on the clipboard next to the tab-separated text. The text holds
//! what each cell *shows* ("Rp10,000.00"), which en-US input parsing can't always turn back into a
//! number. The HTML of the common sources also carries the underlying number as a cell attribute:
//! `x:num="10000"` (Excel, Excel for the web) and `sdval="10000"` (LibreOffice Calc). Paste uses
//! it to keep such cells numeric (#257). Only the table structure, the cell text and these two
//! attributes are read; everything else in the markup is ignored.
/// Largest clipboard HTML we look at; past this the paste uses the plain text alone.
const MAX_HTML: usize = 16 * 1024 * 1024;
/// Widest `colspan` honoured (the sheet's column count).
const MAX_SPAN: usize = gridcraft_core::MAX_COLS as usize;
/// One table cell: its visible text and the number the source recorded for it, if any.
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct HtmlCell {
pub text: String,
pub num: Option<f64>,
}
/// The cells of the first table in `html`, row by row; a cell spanning columns is followed by
/// empty cells so positions line up with the tab-separated text. Empty when there is no table.
pub(crate) fn table_cells(html: &str) -> Vec<Vec<HtmlCell>> {
let mut rows: Vec<Vec<HtmlCell>> = Vec::new();
if html.len() > MAX_HTML {
return rows;
}
let mut cell: Option<(HtmlCell, usize)> = None;
let mut in_table = false;
let mut rest = html;
while !rest.is_empty() {
let Some(lt) = rest.find('<') else {
if let Some((c, _)) = cell.as_mut() {
c.text.push_str(rest);
}
break;
};
if let Some((c, _)) = cell.as_mut() {
c.text.push_str(rest.get(..lt).unwrap_or_default());
}
rest = rest.get(lt..).unwrap_or_default();
if let Some(after) = rest.strip_prefix("<!--") {
rest = after.find("-->").and_then(|e| after.get(e + 3..)).unwrap_or_default();
continue;
}
let Some(gt) = tag_end(rest) else { break };
let tag = rest.get(1..gt).unwrap_or_default();
rest = rest.get(gt + 1..).unwrap_or_default();
let closing = tag.starts_with('/');
let body = tag.trim_start_matches('/');
let name_end = body.find(|c: char| c.is_whitespace() || c == '/').unwrap_or(body.len());
let name = body.get(..name_end).unwrap_or_default().to_ascii_lowercase();
let attrs = body.get(name_end..).unwrap_or_default();
match (name.as_str(), closing) {
("style" | "script", false) => {
// Raw text: skip to the closing tag.
let end = format!("</{name}");
rest = find_ci(rest, &end).and_then(|i| rest.get(i..)).unwrap_or_default();
}
("table", false) => {
if in_table {
break; // a nested table: the outer one is the clipboard grid, stop here
}
in_table = true;
}
("table", true) => {
finish(&mut rows, &mut cell);
if !rows.is_empty() {
break;
}
in_table = false;
}
("tr", false) if in_table => {
finish(&mut rows, &mut cell);
rows.push(Vec::new());
}
("td" | "th", false) if in_table => {
finish(&mut rows, &mut cell);
if rows.is_empty() {
rows.push(Vec::new());
}
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 span = attr(attrs, "colspan").and_then(|v| v.trim().parse::<usize>().ok()).unwrap_or(1).clamp(1, MAX_SPAN);
cell = Some((HtmlCell { text: String::new(), num }, span));
}
("td" | "th", true) | ("tr", true) => finish(&mut rows, &mut cell),
("br", _) => {
if let Some((c, _)) = cell.as_mut() {
c.text.push(' ');
}
}
_ => {}
}
}
finish(&mut rows, &mut cell);
rows
}
/// Closes the open cell (if any) into the last row, decoding its text.
fn finish(rows: &mut Vec<Vec<HtmlCell>>, cell: &mut Option<(HtmlCell, usize)>) {
let Some((mut c, span)) = cell.take() else { return };
c.text = decode(&c.text);
if rows.is_empty() {
rows.push(Vec::new());
}
if let Some(row) = rows.last_mut() {
row.push(c);
let room = MAX_SPAN.saturating_sub(row.len());
row.extend(std::iter::repeat_n(HtmlCell::default(), span.saturating_sub(1).min(room)));
}
}
/// Byte index of the `>` that closes the tag `rest` starts with; a `>` inside a quoted attribute
/// value (an inline `mso-number-format` condition such as `[>=1000]`) doesn't end it.
fn tag_end(rest: &str) -> Option<usize> {
let mut quote: Option<char> = None;
for (i, c) in rest.char_indices() {
match (quote, c) {
(Some(q), c) if c == q => quote = None,
(Some(_), _) => {}
(None, '"' | '\'') => quote = Some(c),
(None, '>') => return Some(i),
_ => {}
}
}
None
}
/// Case-insensitive ASCII search.
fn find_ci(hay: &str, needle: &str) -> Option<usize> {
let (h, n) = (hay.as_bytes(), needle.as_bytes());
if n.is_empty() || h.len() < n.len() {
return None;
}
(0..=h.len() - n.len()).find(|&i| h.get(i..i + n.len()).is_some_and(|w| w.eq_ignore_ascii_case(n)))
}
/// The value of attribute `name` in a tag's attribute text (quoted or bare). A bare attribute
/// without `=` has no value.
fn attr<'a>(attrs: &'a str, name: &str) -> Option<&'a str> {
let mut s = attrs;
loop {
s = s.trim_start_matches(|c: char| c.is_whitespace() || c == '/');
if s.is_empty() {
return None;
}
let key_end = s.find(|c: char| c.is_whitespace() || c == '=' || c == '/').unwrap_or(s.len());
let key = s.get(..key_end).unwrap_or_default();
s = s.get(key_end..).unwrap_or_default().trim_start();
let mut value = None;
if let Some(after) = s.strip_prefix('=') {
let after = after.trim_start();
let (v, next) = match after.chars().next() {
Some(q @ ('"' | '\'')) => {
let inner = after.get(1..).unwrap_or_default();
let end = inner.find(q).unwrap_or(inner.len());
(inner.get(..end).unwrap_or_default(), inner.get(end + 1..).unwrap_or_default())
}
_ => {
let end = after.find(char::is_whitespace).unwrap_or(after.len());
(after.get(..end).unwrap_or_default(), after.get(end..).unwrap_or_default())
}
};
value = Some(v);
s = next;
}
if key.eq_ignore_ascii_case(name) {
return value;
}
if key.is_empty() && value.is_none() {
// Stray character (e.g. a lone '='): skip it so the loop always advances.
let mut chars = s.chars();
chars.next();
s = chars.as_str();
}
}
}
/// HTML text to plain text: entities decoded, whitespace runs (and `<br>`) collapsed to one
/// space, trimmed. The text only serves to match a cell with the tab-separated text.
fn decode(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
let mut rest = raw;
while let Some(amp) = rest.find('&') {
out.push_str(rest.get(..amp).unwrap_or_default());
rest = rest.get(amp..).unwrap_or_default();
let semi = rest.get(..12).unwrap_or(rest).find(';');
let entity = semi.and_then(|e| rest.get(1..e));
let ch = entity.and_then(|e| match e {
"amp" => Some('&'),
"lt" => Some('<'),
"gt" => Some('>'),
"quot" => Some('"'),
"apos" => Some('\''),
"nbsp" => Some('\u{a0}'),
_ => {
let n = e
.strip_prefix("#x")
.or_else(|| e.strip_prefix("#X"))
.map(|h| u32::from_str_radix(h, 16))
.or_else(|| e.strip_prefix('#').map(str::parse::<u32>));
n.and_then(Result::ok).and_then(char::from_u32)
}
});
match (ch, semi) {
(Some(c), Some(e)) => {
out.push(c);
rest = rest.get(e + 1..).unwrap_or_default();
}
_ => {
out.push('&');
rest = rest.get(1..).unwrap_or_default();
}
}
}
out.push_str(rest);
let mut text = String::with_capacity(out.len());
let mut space = false;
for c in out.chars() {
if c.is_whitespace() && c != '\u{a0}' {
space = true;
continue;
}
if space && !text.is_empty() {
text.push(' ');
}
space = false;
text.push(c);
}
text
}
/// Same-looking text: non-breaking spaces as spaces, whitespace runs collapsed, trimmed.
pub(crate) fn normalized(s: &str) -> String {
s.replace('\u{a0}', " ").split_whitespace().collect::<Vec<_>>().join(" ")
}
/// A number format that shows `n` exactly as `shown` ("Rp10,000.00" for 10000 →
/// `"Rp"#,##0.00`), inferred from the text around the digits. `None` unless the format renders
/// `shown` back character for character.
pub(crate) fn format_for(shown: &str, n: f64) -> Option<String> {
let shown = normalized(shown);
let start = shown.find(|c: char| c.is_ascii_digit())?;
let end = shown.rfind(|c: char| c.is_ascii_digit()).map(|i| i + 1)?;
let core = shown.get(start..end)?;
let mut prefix = shown.get(..start)?.to_string();
let suffix = shown.get(end..)?;
if core.chars().any(|c| !(c.is_ascii_digit() || c == ',' || c == '.')) {
return None;
}
if n < 0.0 {
// A one-section format shows the minus sign itself, in front of everything.
prefix = prefix.strip_prefix('-')?.to_string();
}
let decimals = core.split_once('.').map_or(0, |(_, f)| f.len());
let mut code = String::new();
let quote = |s: &str| if s.is_empty() { String::new() } else { format!("\"{}\"", s.replace('"', "")) };
code.push_str(&quote(&prefix));
code.push_str(if core.contains(',') { "#,##0" } else { "0" });
if decimals > 0 {
code.push('.');
code.push_str(&"0".repeat(decimals.min(30)));
}
code.push_str(&quote(suffix));
let rendered = gridcraft_numfmt::text_function(&gridcraft_core::Value::number(n), &code, gridcraft_core::date::DateSystem::D1900).ok()?;
(normalized(&rendered) == shown).then_some(code)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_excel_and_libreoffice_cell_values() {
let excel = r#"<html><head><style>td{mso-number-format:General}</style></head><body><table>
<!--StartFragment--><tr height=20><td class=xl65 x:num="10000">Rp10,000.00</td><td x:str>Name</td></tr>
<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);
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[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), vec![vec![HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) }]]);
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), vec![vec![HtmlCell { text: "1500".into(), num: Some(1500.0) }]]);
}
#[test]
fn hostile_markup_does_not_panic() {
for html in [
"",
"<",
"<td",
"<table><td x:num=>",
"<table><tr><td colspan=999999999>a",
"&#xFFFFFFFF;&",
"<table><td x:num='1e999'>1</td>",
"<!--",
"<style>",
"<table><table><td>",
] {
let _ = table_cells(html);
}
assert_eq!(table_cells("<table><td x:num='1e999'>1</td></table>")[0][0].num, None);
assert!(table_cells("<table><tr><td colspan=999999999>a</td></tr></table>")[0].len() <= MAX_SPAN);
}
#[test]
fn infers_a_format_that_shows_the_same_text() {
assert_eq!(format_for("Rp10,000.00", 10000.0).as_deref(), Some("\"Rp\"#,##0.00"));
assert_eq!(format_for("1 234 kr", 1234.0), None, "space grouping is not reproduced");
assert_eq!(format_for("10.000,00 €", 10000.0), None, "comma decimals don't render back in en-US");
assert_eq!(format_for("-Rp5", -5.0).as_deref(), Some("\"Rp\"0"));
assert_eq!(format_for("12 EUR", 12.0).as_deref(), Some("0\" EUR\""));
}
}

View File

@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Paste",
["Home", "Clipboard"],
Some("Cmd+V"),
"{at?: \"C3\", text?: \"tab-separated text from the system clipboard\"}",
"{at?: \"C3\", text?: \"tab-separated text from the system clipboard\", html?: \"the clipboard's HTML flavour; cells the source recorded as numbers (x:num, sdval) stay numbers\"}",
has_doc,
paste
),
@ -674,13 +674,18 @@ fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let at = cell_param(p, "at").unwrap_or(s.doc()?.selection.current().start);
let rows: Vec<Vec<String>> = parse_tsv(text);
// The HTML flavour, when the host passed it: the number behind each cell's shown text.
let html = str_param(p, "html").map(crate::clip_html::table_cells).unwrap_or_default();
let h = rows.len();
let w = rows.iter().map(Vec::len).max().unwrap_or(0);
edit(s, |cx| {
for (ri, row) in rows.iter().enumerate() {
for (ci, v) in row.iter().enumerate() {
let Some(c) = at.offset(ri as i64, ci as i64) else { continue };
let cell = input_to_cell(v, sheet, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(v.as_str()))));
let mut cell = input_to_cell(v, sheet, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(v.as_str()))));
if let Some(cl) = cell.as_mut() {
numeric_from_html(cl, v, html.get(ri).and_then(|r| r.get(ci)), &mut cx.wb);
}
let sh = cx.sheet_mut(sheet)?;
match cell {
Some(cell) => sh.set_cell(c, cell),
@ -700,6 +705,27 @@ fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result<Json> {
Ok(json!({"rows": h, "cols": w}))
}
/// A pasted cell that came out as text ("Rp10,000.00" isn't en-US input) becomes the number the
/// source recorded for it in its HTML, with a format that shows the same text when one can be
/// inferred and the cell has none. The HTML cell must show the same text as the tab-separated
/// field, so HTML that belongs to another copy (or a table laid out differently) changes nothing.
fn numeric_from_html(cell: &mut Cell, field: &str, html: Option<&crate::clip_html::HtmlCell>, wb: &mut gridcraft_model::Workbook) {
let Some(hc) = html else { return };
let Some(n) = hc.num else { return };
if cell.formula.is_some() || !matches!(cell.value, Value::Text(_)) || wb.styles.get(cell.style).num_fmt.as_str() == "@" {
return;
}
if crate::clip_html::normalized(&hc.text) != crate::clip_html::normalized(field) {
return;
}
cell.value = Value::number(n);
if wb.styles.get(cell.style).num_fmt.as_str() == "General"
&& let Some(code) = crate::clip_html::format_for(field, n)
{
cell.style = wb.styles.derive(cell.style, |s| s.num_fmt = gridcraft_model::NumFmt::new(&code));
}
}
/// Tab-separated text with Excel-style quoting.
pub(crate) fn parse_tsv(text: &str) -> Vec<Vec<String>> {
let mut rows = Vec::new();

View File

@ -8,6 +8,7 @@
pub mod catalog;
pub mod cf;
mod clip_html;
pub mod cmd;
pub mod display;
pub mod fill;

View File

@ -52,6 +52,32 @@ fn entering_empty_cells_preserves_whole_sheet_formatting() {
}
}
#[test]
fn pasted_currency_text_keeps_the_number_from_the_clipboard_html() {
// #257: "Rp10,000.00" copied from Excel for the web / LibreOffice Calc pasted as text. Their
// clipboard HTML records the number (x:num, sdval); paste keeps it and shows the same text.
let mut s = s();
let text = "Rp10,000.00\tRp2,500.50\tLabel\n";
let html =
r#"<table><tr><td x:num="10000">Rp10,000.00</td><td sdval="2500.5" sdnum="1033;0;x"><font>Rp2,500.50</font></td><td>Label</td></tr></table>"#;
s.execute("edit.paste", json!({"at": "A1", "text": text, "html": html})).unwrap();
assert_eq!(v(&s, "A1"), Value::number(10000.0));
assert_eq!(v(&s, "B1"), Value::number(2500.5));
assert_eq!(v(&s, "C1"), Value::text("Label"));
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
assert_eq!(crate::display::cell_text(&d.wb, sh, CellRef::parse("A1").unwrap()), "Rp10,000.00");
assert_eq!(crate::display::cell_text(&d.wb, sh, CellRef::parse("B1").unwrap()), "Rp2,500.50");
// HTML that doesn't show the same text (left over from another copy) changes nothing; without
// HTML the text stays text, as typing it would in an en-US locale.
let stale = r#"<table><tr><td x:num="7">Rp7.00</td></tr></table>"#;
s.execute("edit.paste", json!({"at": "A3", "text": "Rp10,000.00", "html": stale})).unwrap();
s.execute("edit.paste", json!({"at": "A4", "text": "Rp10,000.00"})).unwrap();
assert_eq!(v(&s, "A3"), Value::text("Rp10,000.00"));
assert_eq!(v(&s, "A4"), Value::text("Rp10,000.00"));
}
#[test]
fn entry_and_text_paste_use_inherited_formats_and_cell_overrides() {
let mut s = s();

View File

@ -1657,7 +1657,7 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo:
egui::Event::Cut => {
app.copy_to_clipboard(ctx, "edit.cut");
}
egui::Event::Paste(text) => app.run_or_alert("edit.paste", json!({"text": text})),
egui::Event::Paste(text) => app.paste_external(text),
egui::Event::Text(text) => {
if text.chars().all(|c| !c.is_control()) && !text.is_empty() {
app.begin_edit(Some(text), false);

View File

@ -115,6 +115,10 @@ pub struct Services {
/// Publish HTML and its plain-text alternative together. `Ok` means the host owns the
/// write (including any asynchronous fallback); an immediate error uses egui's text path.
pub copy_html: Option<Box<dyn FnMut(&str, &str) -> Result<(), String>>>,
/// Read the HTML flavour of the system clipboard when the user pastes text, so cells the
/// source spreadsheet recorded as numbers stay numbers ("Rp10,000.00" → 10000). `None` from
/// the host pastes the plain text alone.
pub paste_html: Option<Box<dyn FnMut() -> Option<String>>>,
/// Web: deliver a file to the user (name, bytes).
pub download: Option<Box<dyn Fn(&str, &[u8])>>,
/// Web: start an asynchronous file pick; the bytes arrive later in `inbox`.
@ -263,6 +267,17 @@ impl SheetApp {
ctx.copy_text(text.to_string());
}
/// Paste text that came from the system clipboard, with the clipboard's HTML flavour when the
/// host can read it.
pub fn paste_external(&mut self, text: String) {
let html = self.services.paste_html.as_mut().and_then(|read| read());
let params = match html {
Some(html) => json!({"text": text, "html": html}),
None => json!({"text": text}),
};
self.run_or_alert("edit.paste", params);
}
/// Web: `file.exportPdf` without a path returns the PDF as base64; hand it to the browser as a download.
fn deliver_pdf(&self, result: &Json) {
if result.get("path").is_some() {

View File

@ -326,7 +326,7 @@ fn home(app: &mut SheetApp, ui: &mut Ui) {
}
if paste.clicked() {
if let Some(text) = app.grid.system_clipboard.take() {
act(app, "edit.paste", json!({"text": text}));
app.paste_external(text);
} else {
act(app, "edit.paste", json!({}));
}

View File

@ -83,3 +83,17 @@ fn programmatic_copy_does_not_write_to_the_system_clipboard() {
assert_eq!(*calls.borrow(), 0);
assert!(!h.output().platform_output.commands.iter().any(|c| matches!(c, OutputCommand::CopyText(_))));
}
#[test]
fn pasting_reads_the_clipboard_html_for_the_numbers_behind_the_text() {
// #257: currency text from another spreadsheet stays a number when the host offers the HTML.
let mut h = harness(Services {
paste_html: Some(Box::new(|| Some(r#"<table><tr><td x:num="10000">Rp10,000.00</td></tr></table>"#.to_string()))),
..Default::default()
});
h.state_mut().run("selection.set", json!({"cell": "D5"})).unwrap();
h.input_mut().events.push(Event::Paste("Rp10,000.00".into()));
h.run_steps(2);
let value = h.state().session.active().and_then(|d| d.wb.active().map(|s| s.value(gridcraft_engine::core::CellRef::new(4, 3))));
assert_eq!(value, Some(gridcraft_engine::core::Value::number(10000.0)));
}