//! 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;
/// Tallest `rowspan` honoured (the sheet's row count).
const MAX_ROW_SPAN: usize = gridcraft_core::MAX_ROWS as usize;
/// Most cells, span placeholders included, one paste reads from the HTML. Past it the rest of the
/// 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.
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct HtmlCell {
pub text: String,
pub num: Option,
}
/// The cells of the first table in `html`, row by row, read no further than `max_rows` rows of
/// `max_cols` cells (the pasted text's shape, itself clamped to the sheet) and [`MAX_HTML_CELLS`]
/// in all. A cell spanning columns is followed by empty cells, and a column covered by a `rowspan`
/// from above holds an empty cell, so positions line up with the tab-separated text. Empty when
/// there is no table.
pub(crate) fn table_cells(html: &str, max_rows: usize, max_cols: usize) -> Vec> {
let mut grid = Grid::new(max_rows, max_cols);
if html.len() > MAX_HTML {
return grid.rows;
}
let mut cell: Option = None;
let mut in_table = false;
let mut rest = html;
while !rest.is_empty() && !grid.full {
let Some(lt) = rest.find('<') else {
if let Some(c) = cell.as_mut() {
c.cell.text.push_str(rest);
}
break;
};
if let Some(c) = cell.as_mut() {
c.cell.text.push_str(rest.get(..lt).unwrap_or_default());
}
rest = rest.get(lt..).unwrap_or_default();
if let Some(after) = rest.strip_prefix("").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) => {
grid.finish(&mut cell);
if !grid.rows.is_empty() {
break;
}
in_table = false;
}
("tr", false) if in_table => {
grid.finish(&mut cell);
grid.start_row();
}
("td" | "th", false) if in_table => {
grid.finish(&mut cell);
let num = attr(attrs, "x:num").or_else(|| attr(attrs, "sdval")).and_then(|v| v.trim().parse::().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 });
}
("td" | "th", true) | ("tr", true) => grid.finish(&mut cell),
("br", _) => {
if let Some(c) = cell.as_mut() {
c.cell.text.push(' ');
}
}
_ => {}
}
}
grid.finish(&mut cell);
grid.rows
}
/// A `colspan`/`rowspan` attribute: at least 1, at most `max` (the sheet's extent), so a hostile
/// "999999999999" is clamped before anything is expanded.
fn span(attrs: &str, name: &str, max: usize) -> usize {
attr(attrs, name).and_then(|v| v.trim().parse::().ok()).unwrap_or(1).clamp(1, max)
}
/// A cell whose closing tag hasn't been seen yet.
struct OpenCell {
cell: HtmlCell,
colspan: usize,
rowspan: usize,
}
/// The table being read, with its size limits.
struct Grid {
rows: Vec>,
max_rows: usize,
max_cols: usize,
/// Cells still allowed before the table is cut short.
budget: usize,
/// Per column: the row index (exclusive) down to which a `rowspan` from above covers it.
covered_until: Vec,
/// The row or cell budget ran out: nothing more is read.
full: bool,
}
impl Grid {
fn new(max_rows: usize, max_cols: usize) -> Self {
let max_rows = max_rows.min(MAX_ROW_SPAN);
let max_cols = max_cols.min(MAX_SPAN);
let budget = MAX_HTML_CELLS.min(max_rows.saturating_mul(max_cols));
Grid { rows: Vec::new(), max_rows, max_cols, budget, covered_until: Vec::new(), full: false }
}
/// Opens a new row, or marks the grid full when the pasted text has no more rows.
fn start_row(&mut self) {
if self.rows.len() >= self.max_rows {
self.full = true;
} else {
self.rows.push(Vec::new());
}
}
/// Appends one cell to the current row; false when the row or the cell budget is used up.
fn push(&mut self, cell: HtmlCell) -> bool {
let Some(row) = self.rows.last_mut() else { return false };
if row.len() >= self.max_cols {
return false;
}
let Some(left) = self.budget.checked_sub(1) else {
self.full = true;
return false;
};
self.budget = left;
row.push(cell);
true
}
/// Closes the open cell (if any) into the current row, decoding its text: first past the
/// columns a `rowspan` above still covers, then the cell, then a placeholder per extra column.
fn finish(&mut self, open: &mut Option) {
let Some(OpenCell { mut cell, colspan, rowspan }) = open.take() else { return };
if self.rows.is_empty() {
self.start_row();
}
let Some(r) = self.rows.len().checked_sub(1) else { return };
while self.covered_until.get(self.col()).is_some_and(|&until| until > r) {
if !self.push(HtmlCell::default()) {
return;
}
}
let start = self.col();
cell.text = decode(&cell.text);
if !self.push(cell) {
return;
}
for _ in 1..colspan {
if !self.push(HtmlCell::default()) {
break;
}
}
if rowspan > 1 {
let (end, until) = (self.col(), r.saturating_add(rowspan));
if self.covered_until.len() < end {
self.covered_until.resize(end, 0);
}
for u in self.covered_until.get_mut(start..end).into_iter().flatten() {
*u = (*u).max(until);
}
}
}
/// The column the next cell of the current row lands in.
fn col(&self) -> usize {
self.rows.last().map_or(0, Vec::len)
}
}
/// 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 {
let mut quote: Option = 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 {
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();
}
}
}
/// Entities are at most `&` + 8 chars + `;` (a decimal code point at most 7 digits deep), so
/// the semicolon is only looked for in the first 12 bytes — and `unwrap_or(rest)` never falls
/// back to scanning the remainder of the text either (that turn re-scans per `&`, quadratic
/// when a paste holds many bare `&`).
const ENTITY_WINDOW: usize = 12;
/// HTML text to plain text: entities decoded, whitespace runs (and `
`) 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();
// `.min(len)` (not an `unwrap_or(rest)`) — a `get(..12)` that fails on a multibyte
// boundary used to fall back to the complete suffix, re-scanning per '&'.
let semi = rest.get(..ENTITY_WINDOW.min(rest.len())).and_then(|w| w.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::));
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::>().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 {
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("e(&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("e(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#"
| Rp10,000.00 | Name |
| 2.50 & up | x |
"#;
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[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#""#;
assert_eq!(table_cells(calc, 1, 1), vec![vec![HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) }]]);
let quoted_gt = r#""#;
assert_eq!(table_cells(quoted_gt, 1, 1), vec![vec![HtmlCell { text: "1500".into(), num: Some(1500.0) }]]);
}
#[test]
fn entities_recognized_only_within_the_twelve_byte_window() {
// The window never scans the rest of the text for a semicolon: a bare '&' followed by
// long plain text stays a bare '&', and one '&' after another is linear.
assert_eq!(decode("a&lifier; b"), "a&lifier; b");
assert_eq!(decode("&ampampampAMP;"), "&ampampampAMP;");
// Inside the window the usual forms still decode, and a trailing bare '&' too.
assert_eq!(decode("&"), "&");
assert_eq!(decode("♪"), "\u{266A}");
assert_eq!(decode("a&b c"), "a&b\u{a0}c");
assert_eq!(decode("amp&"), "amp&");
}
#[test]
fn hostile_markup_does_not_panic() {
for html in [
"",
"<",
" | ",
" |