diff --git a/crates/engine/src/clip_html.rs b/crates/engine/src/clip_html.rs index d208155df..e1d217c9c 100644 --- a/crates/engine/src/clip_html.rs +++ b/crates/engine/src/clip_html.rs @@ -267,6 +267,12 @@ fn attr<'a>(attrs: &'a str, name: &str) -> Option<&'a 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 { @@ -275,7 +281,9 @@ fn decode(raw: &str) -> String { 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(';'); + // `.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('&'), @@ -382,6 +390,19 @@ mod tests { 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 [ diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 8a0bb1052..8b2899f27 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -552,9 +552,15 @@ pub fn auto_row_height(wb: &mut Workbook, si: usize, row: u32) { return; } let mut need: f32 = default; + // The cell loop must not rescan every merge per cell: the merges touching this row are + // picked out once, and per cell the candidates are re-checked (normally none or a couple; + // a sheet with many merges and many saved columns would otherwise scan all of them per + // cell, rows × merges × columns). + let row_merges: Vec = + sh.merges.iter().copied().filter(|m| !m.is_single() && m.start.row <= row && row <= m.end.row).collect(); for (c, cell) in sh.cells.row(row, 0, gridcraft_core::MAX_COLS - 1) { // Merged cells never auto-fit their rows, like Excel (column autofit skips them too). - if sh.merge_at(CellRef::new(row, c)).is_some_and(|m| !m.is_single()) { + if row_merges.iter().any(|m| m.contains(CellRef::new(row, c))) { continue; } let st = wb.styles.get(cell.style); diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 4c80f2fa9..1c91518e2 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -784,22 +784,27 @@ fn wrap_lines(text: &str, font: Font, size: f32, width: f32) -> Vec { out.push(std::mem::take(&mut line)); line = word.to_string(); } - // Break a single over-long word by characters. + // Break a single over-long word by characters. The cut that fits is found by binary + // search (each try measures one candidate prefix), so an over-long word costs + // O(n log n) measurements instead of re-measuring the whole cut per character for + // every emitted line (`a` × 100k pasted text would be cubic worst case). while gridcraft_pdf::text_width(font, size, &line) > width && line.chars().count() > 1 { - let mut cut = String::new(); - let mut rest = String::new(); - for ch in line.chars() { - if rest.is_empty() && gridcraft_pdf::text_width(font, size, &format!("{cut}{ch}")) <= width || cut.is_empty() { - cut.push(ch); + let chars: Vec = line.chars().collect(); + let (mut lo, mut hi) = (1usize, chars.len()); + while lo < hi { + let mid = lo + (hi - lo).div_ceil(2); + let cut: String = chars[..mid].iter().collect(); + if gridcraft_pdf::text_width(font, size, &cut) <= width { + lo = mid; } else { - rest.push(ch); + hi = mid - 1; } } - if rest.is_empty() { + if lo == chars.len() { break; } - out.push(cut); - line = rest; + out.push(chars[..lo].iter().collect()); + line = chars[lo..].iter().collect(); } if out.len() > 2000 { return out; @@ -1306,7 +1311,16 @@ fn decode_image(data: &[u8], max_w: u32, max_h: u32) -> Option<(Vec, u32, u3 } /// Draws charts, pictures, shapes and sparklines overlapping the body block of a page. -fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Block) { +/// `pics` memoizes decoded pictures for the whole render (one page at a time draws each +/// picture again, but the decode happens once). +fn draw_objects( + pg: &mut Page, + wb: &Workbook, + si: usize, + sh: &Sheet, + b: &Block, + pics: &mut std::collections::HashMap<(usize, usize), Option<(Vec, u32, u32)>>, +) { let block = b.range(); let (sx0, sy0) = (sh.col_left(block.start.col), sh.row_top(block.start.row)); let (sx1, sy1) = (sh.col_left(block.end.col.saturating_add(1)), sh.row_top(block.end.row.saturating_add(1))); @@ -1316,6 +1330,42 @@ fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Block) let place = |r: (f64, f64, f64, f64)| (bx + ((r.0 - sx0) as f32) * b.k, by + ((r.1 - sy0) as f32) * b.k, r.2 as f32 * b.k, r.3 as f32 * b.k); pg.save(); pg.clip_rect(bx, by, bw, bh); + draw_sparklines(pg, wb, si, sh, b, block); + for (imi, im) in sh.images.iter().enumerate() { + let r = anchor_rect(sh, &im.anchor); + if !visible(r) { + continue; + } + let (x, y, w, h) = place(r); + // About 150 dpi is plenty for print. + let (mw, mh) = (((w / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32, ((h / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32); + let decoded = pics.entry((si, imi)).or_insert_with(|| decode_image(&im.data, mw, mh)); + if let Some((px, pw, ph)) = decoded.as_ref() { + pg.image_rgba(x, y, w, h, px, *pw, *ph); + } + } + for shp in &sh.shapes { + let r = anchor_rect(sh, &shp.anchor); + if !visible(r) { + continue; + } + let (x, y, w, h) = place(r); + draw_shape(pg, wb, shp, b, (x, y, w, h)); + } + for ch in &sh.charts { + let r = anchor_rect(sh, &ch.anchor); + if !visible(r) { + continue; + } + let (x, y, _, _) = place(r); + let data = gridcraft_chart::resolve(wb, si, ch); + let prims = gridcraft_chart::render(ch, &data, r.2 as f32, r.3 as f32, &PdfMeasure); + draw_prims(pg, &prims, Xf { ox: x, oy: y, k: b.k }); + } + pg.restore(); +} + +fn draw_sparklines(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Block, block: RangeRef) { for sp in &sh.sparklines { if !block.contains(sp.cell) { continue; @@ -1330,90 +1380,64 @@ fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Block) let prims = gridcraft_chart::render_sparkline(sp.kind, &vals, [color[0], color[1], color[2], 255], sp.markers, wpx, hpx); draw_prims(pg, &prims, Xf { ox: x, oy: y, k: b.k }); } - for im in &sh.images { - let r = anchor_rect(sh, &im.anchor); - if !visible(r) { - continue; +} + +fn draw_shape(pg: &mut Page, wb: &Workbook, shp: &gridcraft_model::Shape, b: &Block, (x, y, w, h): (f32, f32, f32, f32)) { + let fill = rgb_of(shp.fill, wb); + let line = rgb_of(shp.line, wb).filter(|_| !shp.format.no_line).map(|c| (c, (0.75 * b.k).max(0.25))); + match shp.kind { + ShapeKind::Ellipse => { + let pts: Vec<[f32; 2]> = (0..72) + .map(|i| { + let a = i as f32 / 72.0 * std::f32::consts::TAU; + [x + w / 2.0 + w / 2.0 * a.cos(), y + h / 2.0 + h / 2.0 * a.sin()] + }) + .collect(); + pg.polygon(&pts, fill, line); } - let (x, y, w, h) = place(r); - // About 150 dpi is plenty for print. - let (mw, mh) = (((w / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32, ((h / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32); - if let Some((px, pw, ph)) = decode_image(&im.data, mw, mh) { - pg.image_rgba(x, y, w, h, &px, pw, ph); + ShapeKind::Triangle => pg.polygon(&[[x + w / 2.0, y], [x + w, y + h], [x, y + h]], fill, line), + ShapeKind::Line | ShapeKind::Arrow => { + let c = line.map(|l| l.0).or(fill).unwrap_or([0, 0, 0]); + pg.stroke_line(x, y, x + w, y + h, (1.0 * b.k).max(0.3), c); + if shp.kind == ShapeKind::Arrow { + let ang = h.atan2(w); + let al = 8.0 * b.k; + let p = |da: f32| [x + w - al * (ang + da).cos(), y + h - al * (ang + da).sin()]; + pg.polygon(&[[x + w, y + h], p(0.4), p(-0.4)], Some(c), None); + } + } + _ => { + pg.polygon(&[[x, y], [x + w, y], [x + w, y + h], [x, y + h]], fill, line); } } - for shp in &sh.shapes { - let r = anchor_rect(sh, &shp.anchor); - if !visible(r) { - continue; - } - let (x, y, w, h) = place(r); - let fill = rgb_of(shp.fill, wb); - let line = rgb_of(shp.line, wb).filter(|_| !shp.format.no_line).map(|c| (c, (0.75 * b.k).max(0.25))); - match shp.kind { - ShapeKind::Ellipse => { - let pts: Vec<[f32; 2]> = (0..72) - .map(|i| { - let a = i as f32 / 72.0 * std::f32::consts::TAU; - [x + w / 2.0 + w / 2.0 * a.cos(), y + h / 2.0 + h / 2.0 * a.sin()] - }) - .collect(); - pg.polygon(&pts, fill, line); - } - ShapeKind::Triangle => pg.polygon(&[[x + w / 2.0, y], [x + w, y + h], [x, y + h]], fill, line), - ShapeKind::Line | ShapeKind::Arrow => { - let c = line.map(|l| l.0).or(fill).unwrap_or([0, 0, 0]); - pg.stroke_line(x, y, x + w, y + h, (1.0 * b.k).max(0.3), c); - if shp.kind == ShapeKind::Arrow { - let ang = h.atan2(w); - let al = 8.0 * b.k; - let p = |da: f32| [x + w - al * (ang + da).cos(), y + h - al * (ang + da).sin()]; - pg.polygon(&[[x + w, y + h], p(0.4), p(-0.4)], Some(c), None); - } - } - _ => { - pg.polygon(&[[x, y], [x + w, y], [x + w, y + h], [x, y + h]], fill, line); - } - } - if !shp.text.is_empty() { - use gridcraft_model::{ShapeTextAlign, ShapeTextAnchor}; - let fm = &shp.format; - let text_box = shp.kind == ShapeKind::TextBox; - let size = fm.font_size.unwrap_or(11.0).clamp(1.0, 4000.0) * b.scale; - let font = Font::styled(Font::family_for(fm.font.as_deref().unwrap_or("")), fm.bold, fm.italic); - let color = fm.text_color.and_then(|c| rgb_of(c, wb)).unwrap_or([0, 0, 0]); - let align = fm.align.unwrap_or(if text_box { ShapeTextAlign::Left } else { ShapeTextAlign::Center }); - let anchor = fm.anchor.unwrap_or(if text_box { ShapeTextAnchor::Top } else { ShapeTextAnchor::Middle }); - let lines = wrap_lines(&shp.text, font, size, (w - 4.0 * b.k).max(size)); - let block_h = lines.len().min(500) as f32 * size * 1.2; - let mut yy = match anchor { - ShapeTextAnchor::Top => y + 2.0 * b.k, - ShapeTextAnchor::Middle => y + (h - block_h) / 2.0, - ShapeTextAnchor::Bottom => y + h - 2.0 * b.k - block_h, - } + size * 0.8; - for l in lines.iter().take(500) { - let lw = gridcraft_pdf::text_width(font, size, l); - let lx = match align { - ShapeTextAlign::Left => x + 2.0 * b.k, - ShapeTextAlign::Center => x + (w - lw) / 2.0, - ShapeTextAlign::Right => x + w - 2.0 * b.k - lw, - }; - pg.text(lx, yy, size, font, color, l); - yy += size * 1.2; - } - } + if shp.text.is_empty() { + return; } - for ch in &sh.charts { - let r = anchor_rect(sh, &ch.anchor); - if !visible(r) { - continue; - } - let (x, y, _, _) = place(r); - let data = gridcraft_chart::resolve(wb, si, ch); - let prims = gridcraft_chart::render(ch, &data, r.2 as f32, r.3 as f32, &PdfMeasure); - draw_prims(pg, &prims, Xf { ox: x, oy: y, k: b.k }); + use gridcraft_model::{ShapeTextAlign, ShapeTextAnchor}; + let fm = &shp.format; + let text_box = shp.kind == ShapeKind::TextBox; + let size = fm.font_size.unwrap_or(11.0).clamp(1.0, 4000.0) * b.scale; + let font = Font::styled(Font::family_for(fm.font.as_deref().unwrap_or("")), fm.bold, fm.italic); + let color = fm.text_color.and_then(|c| rgb_of(c, wb)).unwrap_or([0, 0, 0]); + let align = fm.align.unwrap_or(if text_box { ShapeTextAlign::Left } else { ShapeTextAlign::Center }); + let anchor = fm.anchor.unwrap_or(if text_box { ShapeTextAnchor::Top } else { ShapeTextAnchor::Middle }); + let lines = wrap_lines(&shp.text, font, size, (w - 4.0 * b.k).max(size)); + let block_h = lines.len().min(500) as f32 * size * 1.2; + let mut yy = match anchor { + ShapeTextAnchor::Top => y + 2.0 * b.k, + ShapeTextAnchor::Middle => y + (h - block_h) / 2.0, + ShapeTextAnchor::Bottom => y + h - 2.0 * b.k - block_h, + } + size * 0.8; + for l in lines.iter().take(500) { + let lw = gridcraft_pdf::text_width(font, size, l); + let lx = match align { + ShapeTextAlign::Left => x + 2.0 * b.k, + ShapeTextAlign::Center => x + (w - lw) / 2.0, + ShapeTextAlign::Right => x + w - 2.0 * b.k - lw, + }; + pg.text(lx, yy, size, font, color, l); + yy += size * 1.2; } - pg.restore(); } fn draw_headings(pg: &mut Page, b: &Block, col_heads: bool, row_heads: bool, at: (f32, f32), size: (f32, f32)) { @@ -1528,6 +1552,9 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str let mut page_no = 0usize; let mut pending_links = Vec::new(); let mut printed_blocks = Vec::new(); + // Decoded pictures are reused across the pages their anchor covers: a floating picture + // spanning 40 pages was decoded (and pushed into the PDF) 40 times (#2389). + let mut pics: std::collections::HashMap<(usize, usize), Option<(Vec, u32, u32)>> = std::collections::HashMap::new(); for lay in layouts { let Some(sh) = wb.sheet(lay.sheet) else { continue }; let mut cf = if sh.cond_formats.is_empty() { None } else { Some(crate::cf::CfCache::new()) }; @@ -1564,7 +1591,7 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str y += seg_size(sh, *rs, true) * k; } if let Some(b) = &body { - draw_objects(pg, wb, lay.sheet, sh, b); + draw_objects(pg, wb, lay.sheet, sh, b, &mut pics); } if lay.settings.headings { let fill = [0xF2, 0xF2, 0xF2]; diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index b18e8577b..216522603 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -448,6 +448,11 @@ impl std::fmt::Write for ClipboardHtml { /// Most rows Save as Web Page writes. A sheet whose used range is taller fails instead of losing cells. const HTML_EXPORT_MAX_ROWS: u32 = 100_001; +/// Rows × columns of one web-page table. Save as Web Page writes every kept row's full used +/// column span into one growing String, so a sparse sheet with a lone far-right cell (or a +/// tall, wide one) would otherwise be gigabytes of `` before finishing (#2387). +const HTML_EXPORT_MAX_CELLS: u64 = 4_000_000; + /// 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 { @@ -479,6 +484,17 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> Result { r.height() ))); } + // The table is one growing String: every kept row writes a `` for the whole used + // column span (unless the sheet has no row that is that wide). A tall, wide sheet + // (or a sparse one with a lone far-right cell) would be gigabytes before finishing, + // so the rectangle itself is bounded (#2387). + if u64::from(r.height()) * u64::from(r.width()) > HTML_EXPORT_MAX_CELLS { + return Err(EngineError::Other(format!( + "the sheet is too tall × wide for a web page ({} × {} cells, at most {HTML_EXPORT_MAX_CELLS}). Save as .xlsx or CSV instead.", + r.height(), + r.width() + ))); + } if fixed { out.push_str(""); for col in (r.start.col..=r.end.col).filter(|&c| !sh.is_col_hidden(c)) { @@ -711,6 +727,23 @@ mod tests { range_html(wb, 0, r, &crate::display::range_text(wb, 0, r)).unwrap() } + #[test] + fn html_export_refuses_an_outsized_grid_instead_of_growing_forever() { + // #2387: the used rectangle alone bounds the output (every row writes the whole + // column span), so a sparse sheet with one cell at A1 and another far right + // 300 rows down is 4.9M cells → refused before a byte of `` is written. + let mut wb = Workbook::new(); + let sh = wb.sheet_mut(0).unwrap(); + sh.set_value(CellRef::new(0, 0), gridcraft_core::Value::Text("start".into())); + sh.set_value(CellRef::new(300, 16383), gridcraft_core::Value::Text("corner".into())); + let err = to_html(&wb, 0).unwrap_err(); + assert!(err.to_string().contains("too tall × wide"), "{err}"); + // A normal sheet is fine. + let mut ok = Workbook::new(); + ok.sheet_mut(0).unwrap().set_value(CellRef::new(5, 2), gridcraft_core::Value::Text("t".into())); + assert!(to_html(&ok, 0).is_ok()); + } + #[test] fn html_export_embeds_images_as_data_uris() { let mut wb = Workbook::new();