//! 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!(" { 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.00Name
2.50 & upx
"#; 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#"
Rp10,000.00
"#; assert_eq!(table_cells(calc, 1, 1), vec![vec![HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) }]]); let quoted_gt = r#"
1500
"#; 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 [ "", "<", "", "
a", "�&", "", "
1