mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-12 05:12:32 +00:00
engine: index merges and bound the page in HTML export and clipboard HTML (#3266)
* engine: index merges and bound the page in HTML export and clipboard HTML Save as Web Page and the clipboard's HTML scanned every merge of the sheet for every cell, counted a merge's span over all the rows the file gave it, placed each picture by summing every custom row before it, and grew the page without a byte limit. Merges are now indexed once over the exported rectangle (overlapping merges past a few visits per cell are refused), spans use prefix counts clipped to the table, anchors use LineIndex, and the page stops at 256 MiB with an error. * address review: check the web page size after each chart and before returning it * address review: bound the merge index, escape cell text within the page's room, size images before encoding * address review: escape image attributes within the page's room
This commit is contained in:
1 parent
9e9f13114f
commit
55eb394044
2 files changed
+177
-21
No files matched your search
+106
-21
@@ -3,7 +3,7 @@
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use gridcraft_core::{CellRef, RangeRef};
|
||||
use gridcraft_model::{Sheet, Workbook};
|
||||
use gridcraft_model::{LineIndex, Sheet, Workbook};
|
||||
|
||||
use crate::{EngineError, Result};
|
||||
|
||||
@@ -611,6 +611,24 @@ pub(crate) fn esc(s: &str) -> String {
|
||||
s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
|
||||
/// [`esc`], or `None` once the escaped text would pass `limit` bytes (nothing larger is built).
|
||||
fn esc_within(s: &str, limit: usize) -> Option<String> {
|
||||
let mut out = String::with_capacity(s.len().min(limit));
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
c => out.push(c),
|
||||
}
|
||||
if out.len() > limit {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn html_cell_css(wb: &Workbook, sh: &Sheet, c: CellRef, dir: CellDir) -> String {
|
||||
let st = wb.styles.get(sh.style_id(c));
|
||||
let mut css = String::new();
|
||||
@@ -686,6 +704,30 @@ fn table_dir(sh: &Sheet) -> &'static str {
|
||||
const CLIPBOARD_HTML_MAX_BYTES: usize = 4 * 1024 * 1024;
|
||||
const CLIPBOARD_HTML_MAX_CELLS: u64 = 10_000;
|
||||
|
||||
/// Most cells the merge index holds: one entry per merged cell inside the exported range.
|
||||
const MERGE_INDEX_MAX_ENTRIES: usize = 1_000_000;
|
||||
|
||||
/// The merge covering each cell of `rect`, as `Sheet::merge_at` would find it (the first in the
|
||||
/// sheet's list), built once so a table lookup is one probe instead of a scan of every merge.
|
||||
/// `None` when overlapping merges would cost more than a few visits per cell of `rect`, or merged
|
||||
/// cells fill more than [`MERGE_INDEX_MAX_ENTRIES`]: a hostile file's merge list can't multiply the
|
||||
/// work or the memory of a bounded export.
|
||||
fn merge_index(sh: &Sheet, rect: RangeRef) -> Option<std::collections::HashMap<CellRef, RangeRef>> {
|
||||
let mut map = std::collections::HashMap::new();
|
||||
let mut left = rect.count().saturating_mul(4);
|
||||
for m in &sh.merges {
|
||||
let Some(part) = m.intersection(&rect) else { continue };
|
||||
left = left.checked_sub(part.count())?;
|
||||
for c in part.iter() {
|
||||
map.entry(c).or_insert(*m);
|
||||
}
|
||||
if map.len() > MERGE_INDEX_MAX_ENTRIES {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(map)
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
@@ -694,6 +736,7 @@ pub(crate) fn range_html(wb: &Workbook, sheet: usize, r: RangeRef, plain_text: &
|
||||
return None;
|
||||
}
|
||||
let sh = wb.sheet(sheet)?;
|
||||
let merges = merge_index(sh, r)?;
|
||||
let mut out = ClipboardHtml(String::new());
|
||||
write!(out, "<table{} style=\"border-collapse:collapse;font-family:Calibri,Carlito,Arial,sans-serif;font-size:11pt\">", table_dir(sh)).ok()?;
|
||||
for row in r.start.row..=r.end.row {
|
||||
@@ -703,7 +746,7 @@ pub(crate) fn range_html(wb: &Workbook, sheet: usize, r: RangeRef, plain_text: &
|
||||
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 span = if let Some(merged) = merges.get(&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;
|
||||
@@ -766,6 +809,14 @@ const HTML_EXPORT_MAX_ROWS: u32 = 100_001;
|
||||
/// tall, wide one) would otherwise be gigabytes of `<td></td>` before finishing (#2387).
|
||||
const HTML_EXPORT_MAX_CELLS: u64 = 4_000_000;
|
||||
|
||||
/// Most bytes of one web page. Every cell may hold long text (shared across many cells in the
|
||||
/// file), so the cell count alone doesn't bound the page; past this the export fails instead.
|
||||
const HTML_EXPORT_MAX_BYTES: usize = 256 * 1024 * 1024;
|
||||
|
||||
fn html_too_big() -> EngineError {
|
||||
EngineError::Other(format!("the web page would be over {} MiB. Save as .xlsx or CSV instead.", HTML_EXPORT_MAX_BYTES / (1024 * 1024)))
|
||||
}
|
||||
|
||||
/// 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<String> {
|
||||
@@ -808,6 +859,24 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> Result<String> {
|
||||
r.width()
|
||||
)));
|
||||
}
|
||||
let merges = merge_index(sh, r)
|
||||
.ok_or_else(|| EngineError::Other("the sheet's merged cells overlap too much, or cover too many cells, for a web page.".into()))?;
|
||||
// Visible rows and columns of the table before each line, so a merge's span is counted
|
||||
// in constant time whatever the file says its size is.
|
||||
let visible_before = |hidden: &dyn Fn(u32) -> bool, from: u32, to: u32| {
|
||||
let mut v = Vec::with_capacity((to - from) as usize + 2);
|
||||
v.push(0u32);
|
||||
for line in from..=to {
|
||||
v.push(v.last().copied().unwrap_or(0) + u32::from(!hidden(line)));
|
||||
}
|
||||
v
|
||||
};
|
||||
let rows_before = visible_before(&|y| sh.is_row_hidden(y), r.start.row, r.end.row);
|
||||
let cols_before = visible_before(&|x| sh.is_col_hidden(x), r.start.col, r.end.col);
|
||||
let visible = |before: &[u32], from: u32, a: u32, b: u32| {
|
||||
let at = |line: u32| before.get((line - from) as usize).copied().unwrap_or(0);
|
||||
at(b + 1) - at(a)
|
||||
};
|
||||
if fixed {
|
||||
out.push_str("<colgroup>");
|
||||
for col in (r.start.col..=r.end.col).filter(|&c| !sh.is_col_hidden(c)) {
|
||||
@@ -826,27 +895,31 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> Result<String> {
|
||||
}
|
||||
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) || (fixed && sh.is_col_hidden(col)) {
|
||||
let merge = merges.get(&c);
|
||||
if merge.is_some_and(|m| m.start != c) || (fixed && sh.is_col_hidden(col)) {
|
||||
continue;
|
||||
}
|
||||
let text = crate::display::cell_text(wb, sh, c);
|
||||
let dir = CellDir::of(sh, c, &text);
|
||||
let css = html_cell_css(wb, sh, c, dir);
|
||||
let span = sh
|
||||
.merges
|
||||
.iter()
|
||||
.find(|m| m.start == c)
|
||||
let span = merge
|
||||
.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();
|
||||
// Spans count the table's visible lines: the merge is clipped to the table.
|
||||
let rows = visible(&rows_before, r.start.row, m.start.row, m.end.row.min(r.end.row));
|
||||
let cols = visible(&cols_before, r.start.col, m.start.col, m.end.col.min(r.end.col));
|
||||
format!(" rowspan=\"{rows}\" colspan=\"{cols}\"")
|
||||
} else {
|
||||
format!(" rowspan=\"{}\" colspan=\"{}\"", m.height(), m.width())
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let _ = write!(out, "<td style=\"{css}\"{}{span}>{}</td>", dir.attr(), esc(&text));
|
||||
// The text is escaped only as far as the page still has room for it.
|
||||
let Some(escaped) = esc_within(&text, HTML_EXPORT_MAX_BYTES.saturating_sub(out.len())) else { return Err(html_too_big()) };
|
||||
let _ = write!(out, "<td style=\"{css}\"{}{span}>{escaped}</td>", dir.attr());
|
||||
if out.len() > HTML_EXPORT_MAX_BYTES {
|
||||
return Err(html_too_big());
|
||||
}
|
||||
}
|
||||
out.push_str("</tr>");
|
||||
}
|
||||
@@ -854,36 +927,48 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> Result<String> {
|
||||
out.push_str("</table>");
|
||||
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)));
|
||||
// Indexed positions: each anchor would otherwise sum every custom row and column before it.
|
||||
let ri = LineIndex::new(&sh.rows, sh.default_row_height, gridcraft_core::MAX_ROWS);
|
||||
let ci = LineIndex::new(&sh.cols, sh.default_col_width, gridcraft_core::MAX_COLS);
|
||||
let (left, top) = used.map_or((0.0, 0.0), |r| (ci.start(r.start.col), ri.start(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),
|
||||
ci.start(a.cell.col) - left + f64::from(a.dx),
|
||||
ri.start(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,
|
||||
"<img src=\"data:{};base64,{}\" alt=\"{}\" style=\"{}\">",
|
||||
esc(&im.mime),
|
||||
base64_encode(&im.data),
|
||||
esc(&im.alt),
|
||||
place(&im.anchor)
|
||||
);
|
||||
// The base64 text is a third longer than the picture: refused before it is built.
|
||||
if out.len().saturating_add(im.data.len().div_ceil(3).saturating_mul(4)) > HTML_EXPORT_MAX_BYTES {
|
||||
return Err(html_too_big());
|
||||
}
|
||||
// The attributes are escaped only as far as the page has room for them.
|
||||
let room = HTML_EXPORT_MAX_BYTES.saturating_sub(out.len());
|
||||
let (Some(mime), Some(alt)) = (esc_within(&im.mime, room), esc_within(&im.alt, room)) else { return Err(html_too_big()) };
|
||||
let _ = write!(out, "<img src=\"data:{mime};base64,{}\" alt=\"{alt}\" style=\"{}\">", base64_encode(&im.data), place(&im.anchor));
|
||||
if out.len() > HTML_EXPORT_MAX_BYTES {
|
||||
return Err(html_too_big());
|
||||
}
|
||||
}
|
||||
let mut svg_budget = crate::chart_svg::MAX_PAGE_SVG_BYTES;
|
||||
for ch in &sh.charts {
|
||||
if let Some(svg) = crate::chart_svg::chart_svg(wb, sheet, ch, &place(&ch.anchor), svg_budget)? {
|
||||
svg_budget -= svg.len();
|
||||
out.push_str(&svg);
|
||||
if out.len() > HTML_EXPORT_MAX_BYTES {
|
||||
return Err(html_too_big());
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str("</div>");
|
||||
}
|
||||
out.push_str("</body></html>");
|
||||
if out.len() > HTML_EXPORT_MAX_BYTES {
|
||||
return Err(html_too_big());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
|
||||
@@ -810,3 +810,74 @@ fn reading_a_fifo_is_refused_without_blocking() {
|
||||
assert!(err.contains("only regular files"), "{err}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Merges far from what is copied or exported, in a hostile number, are indexed once instead of
|
||||
/// being scanned for every cell; a merge running past the table is clipped to it.
|
||||
#[test]
|
||||
fn html_merge_lookups_and_spans_stay_bounded() {
|
||||
let mut wb = book_with_picture("A1", (0, 0));
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
for i in 0..200_000u32 {
|
||||
sh.merges.push(RangeRef::new(CellRef::new(500_000 + i * 2, 5), CellRef::new(500_000 + i * 2, 6)));
|
||||
}
|
||||
sh.merges.push(RangeRef::new(CellRef::new(0, 0), CellRef::new(gridcraft_core::MAX_ROWS - 1, 1)));
|
||||
sh.set_value(CellRef::new(2, 1), gridcraft_core::Value::Number(2.0));
|
||||
let html = copied(&wb, "A1:C50");
|
||||
assert!(html.contains("rowspan=\"50\" colspan=\"2\""), "{html}");
|
||||
let page = to_html(&wb, 0).unwrap();
|
||||
// The table is A1:B3 (the used range): the whole-height merge spans its three rows only.
|
||||
assert!(page.contains("rowspan=\"3\" colspan=\"2\""), "{page}");
|
||||
// Merges stacked over one another past a few per cell are refused, not walked.
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
sh.merges = vec![RangeRef::new(CellRef::new(0, 0), CellRef::new(2, 1)); 100];
|
||||
assert!(to_html(&wb, 0).is_err());
|
||||
assert!(range_html(&wb, 0, RangeRef::parse("A1:B3").unwrap(), "").is_none());
|
||||
}
|
||||
|
||||
/// Thousands of pictures over thousands of custom rows: each is placed through a row index.
|
||||
#[test]
|
||||
fn html_export_places_many_pictures_over_many_custom_rows() {
|
||||
let mut wb = book_with_picture("A1", (0, 0));
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
for row in 0..50_000 {
|
||||
sh.rows.insert(row, gridcraft_model::LineInfo { size: Some(30.0), ..Default::default() });
|
||||
}
|
||||
let image = sh.images[0].clone();
|
||||
for i in 0..5_000u32 {
|
||||
let mut im = image.clone();
|
||||
im.anchor.cell = CellRef::new(40_000 + i, 0);
|
||||
sh.images.push(im);
|
||||
}
|
||||
let page = to_html(&wb, 0).unwrap();
|
||||
assert!(page.contains(&format!("top:{}px", 30 * 44_999)), "last picture placed by the index");
|
||||
}
|
||||
|
||||
/// One long text shown by many cells: the page fails at its byte limit instead of growing on.
|
||||
#[test]
|
||||
fn html_export_refuses_a_page_past_its_byte_limit() {
|
||||
let mut wb = Workbook::new();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
let text = gridcraft_core::Value::text("x".repeat(32_000));
|
||||
for row in 0..10_000 {
|
||||
sh.set_value(CellRef::new(row, 0), text.clone());
|
||||
}
|
||||
let err = to_html(&wb, 0).unwrap_err();
|
||||
assert!(err.to_string().contains("MiB"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaping_stops_at_the_room_left() {
|
||||
assert_eq!(esc_within("a&b<c>\"d", 100).as_deref(), Some(esc("a&b<c>\"d").as_str()));
|
||||
assert_eq!(esc_within("&&&", 14), None, "three `&` are 15 bytes");
|
||||
assert_eq!(esc_within("&&&", 15).as_deref(), Some("&&&"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_merge_index_holds_a_bounded_number_of_cells() {
|
||||
let mut sh = Sheet::new("S");
|
||||
let rect = RangeRef::new(CellRef::new(0, 0), CellRef::new(1_100_000, 0));
|
||||
sh.merges.push(RangeRef::new(CellRef::new(0, 0), CellRef::new(999_999, 0)));
|
||||
assert_eq!(merge_index(&sh, rect).map(|m| m.len()), Some(1_000_000));
|
||||
sh.merges = vec![RangeRef::new(CellRef::new(0, 0), CellRef::new(1_000_000, 0))];
|
||||
assert!(merge_index(&sh, rect).is_none());
|
||||
}
|
||||
Reference in new issue
Block a user