mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-24 10:46:50 -06:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ad62d04fa |
@@ -248,10 +248,11 @@ impl Discovery {
|
||||
/// (`~$…`) are skipped.
|
||||
fn collect_inputs(inputs: &[String]) -> Discovery {
|
||||
let mut out = Discovery::default();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for i in inputs {
|
||||
let p = PathBuf::from(i);
|
||||
if p.is_dir() {
|
||||
if !walk(&p, 0, &mut out) {
|
||||
if !walk(&p, 0, &mut out, &mut seen) {
|
||||
break;
|
||||
}
|
||||
} else if !out.add_file(p) {
|
||||
@@ -268,7 +269,20 @@ fn collect_inputs(inputs: &[String]) -> Discovery {
|
||||
}
|
||||
|
||||
/// Returns false only when the global file budget stops traversal.
|
||||
fn walk(dir: &Path, depth: usize, out: &mut Discovery) -> bool {
|
||||
fn walk(dir: &Path, depth: usize, out: &mut Discovery, seen: &mut std::collections::HashSet<u64>) -> bool {
|
||||
// A directory is visited once by identity (dev+inode on unix): several links to the same
|
||||
// directory are aliases, not exponential branches of the scan. Other platforms keep the
|
||||
// depth cap only.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt as _;
|
||||
if let Ok(m) = std::fs::metadata(dir)
|
||||
&& let Some(id) = m.dev().checked_mul(1 << 60).and_then(|d| d.checked_add(m.ino()))
|
||||
&& !seen.insert(id)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if depth > MAX_DEPTH {
|
||||
out.fail(dir, format!("directory depth limit of {MAX_DEPTH} exceeded; this subtree was not checked"));
|
||||
return true;
|
||||
@@ -301,7 +315,7 @@ fn walk(dir: &Path, depth: usize, out: &mut Discovery) -> bool {
|
||||
}
|
||||
};
|
||||
if metadata.is_dir() {
|
||||
if !walk(&p, depth + 1, out) {
|
||||
if !walk(&p, depth + 1, out, seen) {
|
||||
return false;
|
||||
}
|
||||
} else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("xlsx") || e.eq_ignore_ascii_case("xlsm"))
|
||||
@@ -981,6 +995,12 @@ fn relationship_kinds(bytes: &[u8]) -> Result<BTreeMap<String, String>, String>
|
||||
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("not a zip package: {e}"))?;
|
||||
let names = zip_names(&mut zip);
|
||||
let by_key: HashMap<String, &String> = names.iter().map(|n| (part_key(n), n)).collect();
|
||||
// Several relationships name the same part; each fresh check decompressed it again, so
|
||||
// repeated references to one highly compressible part multiplied the work. The verdict is
|
||||
// remembered per (part, named content) and the decompression the package can cause is
|
||||
// budgeted as a whole (finding: repeated decompression).
|
||||
let mut verdicts: HashMap<(String, &'static str), bool> = HashMap::new();
|
||||
let mut budget: u64 = 512 * 1024 * 1024;
|
||||
let mut kinds = BTreeMap::new();
|
||||
for n in names.iter().filter(|n| part_key(n).ends_with(".rels")) {
|
||||
let Some(src_dir) = rels_source_dir(&part_key(n)) else { continue };
|
||||
@@ -994,7 +1014,7 @@ fn relationship_kinds(bytes: &[u8]) -> Result<BTreeMap<String, String>, String>
|
||||
let external = attr(attrs, "TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External"));
|
||||
if !external
|
||||
&& let Some(part) = by_key.get(&resolve_target(&src_dir, target).to_ascii_lowercase())
|
||||
&& !holds_something(&mut zip, part, kind)
|
||||
&& !holds_something(&mut zip, part, kind, &mut verdicts, &mut budget)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -1013,15 +1033,41 @@ fn rels_source_dir(key: &str) -> Option<String> {
|
||||
|
||||
/// Whether a part has content: an element under its root, or for comments (whose authors list
|
||||
/// comes first) a comment, for VML a shape. Read only as far as needed; an unreadable part counts.
|
||||
fn holds_something(zip: &mut Zip<'_>, name: &str, kind: &str) -> bool {
|
||||
use quick_xml::events::Event;
|
||||
let Ok(f) = zip.by_name(name) else { return true };
|
||||
let mut reader = quick_xml::Reader::from_reader(std::io::BufReader::new(f.take(MAX_XML_PART)));
|
||||
let wanted: Option<&[u8]> = match kind.to_ascii_lowercase().as_str() {
|
||||
/// `verdicts` remembers the answer per (part, named content); `budget` is the decompression this
|
||||
/// package's remaining checks may cause, and once it is spent the part counts as having content.
|
||||
fn holds_something(zip: &mut Zip<'_>, name: &str, kind: &str, verdicts: &mut HashMap<(String, &'static str), bool>, budget: &mut u64) -> bool {
|
||||
let wanted: Option<&'static [u8]> = match kind.to_ascii_lowercase().as_str() {
|
||||
"comments" => Some(b"comment"),
|
||||
"vmldrawing" => Some(b"shape"),
|
||||
_ => None,
|
||||
};
|
||||
let marker = match wanted {
|
||||
Some(b"comment") => "comment",
|
||||
Some(_) => "shape",
|
||||
None => "any",
|
||||
};
|
||||
if let Some(v) = verdicts.get(&(name.to_ascii_lowercase(), marker)) {
|
||||
return *v;
|
||||
}
|
||||
let v = holds_something_uncached(zip, name, marker, budget);
|
||||
verdicts.insert((name.to_ascii_lowercase(), marker), v);
|
||||
v
|
||||
}
|
||||
|
||||
fn holds_something_uncached(zip: &mut Zip<'_>, name: &str, marker: &'static str, budget: &mut u64) -> bool {
|
||||
use quick_xml::events::Event;
|
||||
let Ok(f) = zip.by_name(name) else { return true };
|
||||
if *budget == 0 {
|
||||
return true;
|
||||
}
|
||||
let take = f.size().min(MAX_XML_PART).min(*budget);
|
||||
*budget -= take;
|
||||
let mut reader = quick_xml::Reader::from_reader(std::io::BufReader::new(f.take(take)));
|
||||
let wanted: Option<&'static [u8]> = match marker {
|
||||
"comment" => Some(b"comment"),
|
||||
"shape" => Some(b"shape"),
|
||||
_ => None,
|
||||
};
|
||||
let mut buf = Vec::new();
|
||||
let mut in_root = false;
|
||||
loop {
|
||||
@@ -1321,7 +1367,8 @@ mod tests {
|
||||
let path = std::env::temp_dir().join(format!("gridcraft-check-not-a-directory-{}", std::process::id()));
|
||||
std::fs::write(&path, "not a directory").unwrap();
|
||||
let mut discovery = Discovery::default();
|
||||
assert!(walk(&path, 0, &mut discovery));
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
assert!(walk(&path, 0, &mut discovery, &mut seen));
|
||||
assert!(discovery.files.is_empty());
|
||||
assert_eq!(discovery.failures.len(), 1);
|
||||
assert_eq!(discovery.failures[0].path, path.to_string_lossy());
|
||||
|
||||
@@ -214,7 +214,9 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
if let Some(w) = web_sys::window()
|
||||
&& let gridcraft_engine::links::LinkKind::External(url) = gridcraft_engine::links::classify(url)
|
||||
{
|
||||
let _ = w.open_with_url_and_target(&url, "_blank");
|
||||
// With `noopener` the new browsing context can't hold a reference back to the
|
||||
// editor window and navigate it while the user reads the linked page.
|
||||
let _ = w.open_with_url_and_target(&url, "_blank,noopener");
|
||||
}
|
||||
})),
|
||||
inbox: Some(inbox),
|
||||
|
||||
@@ -160,21 +160,28 @@ fn stroke_paths(pts: &[[f32; 2]], width: f32, dash: bool, closed: bool) -> Vec<P
|
||||
segs.push((*a, *b));
|
||||
}
|
||||
if dash {
|
||||
// The per-segment dash cap bounds each input line, but a caller-supplied vector of many
|
||||
// long dashed lines multiplies: together they would eagerly allocate megabytes of
|
||||
// geometry even when nothing of it reaches the canvas, so the total is capped too and
|
||||
// the line is drawn solid once the budget is gone.
|
||||
let (on, off) = (width * 4.0, width * 3.0);
|
||||
let mut out = Vec::new();
|
||||
let mut budget = 65_536usize;
|
||||
for (a, b) in segs {
|
||||
if budget == 0 {
|
||||
return vec![vec![pts.to_vec()]];
|
||||
}
|
||||
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
|
||||
let len = (dx * dx + dy * dy).sqrt();
|
||||
if !(len.is_finite() && len > 0.0) {
|
||||
continue;
|
||||
}
|
||||
let mut t = 0.0;
|
||||
let mut n = 0;
|
||||
while t < len && n < 10_000 {
|
||||
while t < len && budget > 0 {
|
||||
let e = (t + on).min(len);
|
||||
out.push(([a[0] + dx * t / len, a[1] + dy * t / len], [a[0] + dx * e / len, a[1] + dy * e / len]));
|
||||
t += on + off;
|
||||
n += 1;
|
||||
budget -= 1;
|
||||
}
|
||||
}
|
||||
segs = out;
|
||||
@@ -262,3 +269,23 @@ pub fn rasterize(prims: &[Prim], w: u32, h: u32, background: Rgba) -> Vec<u8> {
|
||||
}
|
||||
cv.buf
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dashed_geometry_stays_within_the_dash_budget() {
|
||||
// Forty very long dashed lines, each allowed 10_000 dashes per segment: before the
|
||||
// shared cap they eagerly allocated ~1.6 MB of segment pairs × points and a like
|
||||
// number of quads before any of it reached a 4-pixel canvas (finding: dash expansion).
|
||||
let prims: Vec<Prim> =
|
||||
(0..40).map(|i| Prim::Line { pts: vec![[0.0, i as f32], [900.0, i as f32]], color: [0, 0, 0, 255], width: 1.0, dash: true }).collect();
|
||||
let img = rasterize(&prims, 2, 2, [255, 255, 255, 255]);
|
||||
assert_eq!(img.len(), 2 * 2 * 4);
|
||||
// And the geometry itself is bounded even for one huge dashed polyline.
|
||||
assert!(stroke_paths(&[[0.0, 0.0], [1e6, 0.0]], 1.0, true, false).len() <= 65_536);
|
||||
let prim = [Prim::Line { pts: vec![[0.0, 0.0], [10.0, 0.0]], color: [0, 0, 0, 255], width: 1.0, dash: true }];
|
||||
assert_eq!(rasterize(&prim, 1, 1, [0, 0, 0, 255]).len(), 4);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,12 +267,6 @@ 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 `<br>`) collapsed to one
|
||||
/// space, trimmed. The text only serves to match a cell with the tab-separated text.
|
||||
fn decode(raw: &str) -> String {
|
||||
@@ -281,9 +275,7 @@ 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();
|
||||
// `.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 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('&'),
|
||||
@@ -390,19 +382,6 @@ 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 [
|
||||
|
||||
@@ -552,15 +552,9 @@ 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<gridcraft_core::RangeRef> =
|
||||
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 row_merges.iter().any(|m| m.contains(CellRef::new(row, c))) {
|
||||
if sh.merge_at(CellRef::new(row, c)).is_some_and(|m| !m.is_single()) {
|
||||
continue;
|
||||
}
|
||||
let st = wb.styles.get(cell.style);
|
||||
|
||||
+90
-117
@@ -784,27 +784,22 @@ fn wrap_lines(text: &str, font: Font, size: f32, width: f32) -> Vec<String> {
|
||||
out.push(std::mem::take(&mut line));
|
||||
line = word.to_string();
|
||||
}
|
||||
// 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).
|
||||
// Break a single over-long word by characters.
|
||||
while gridcraft_pdf::text_width(font, size, &line) > width && line.chars().count() > 1 {
|
||||
let chars: Vec<char> = 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;
|
||||
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);
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
rest.push(ch);
|
||||
}
|
||||
}
|
||||
if lo == chars.len() {
|
||||
if rest.is_empty() {
|
||||
break;
|
||||
}
|
||||
out.push(chars[..lo].iter().collect());
|
||||
line = chars[lo..].iter().collect();
|
||||
out.push(cut);
|
||||
line = rest;
|
||||
}
|
||||
if out.len() > 2000 {
|
||||
return out;
|
||||
@@ -1311,16 +1306,7 @@ fn decode_image(data: &[u8], max_w: u32, max_h: u32) -> Option<(Vec<u8>, u32, u3
|
||||
}
|
||||
|
||||
/// Draws charts, pictures, shapes and sparklines overlapping the body block of a page.
|
||||
/// `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<u8>, u32, u32)>>,
|
||||
) {
|
||||
fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Block) {
|
||||
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)));
|
||||
@@ -1330,42 +1316,6 @@ fn draw_objects(
|
||||
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;
|
||||
@@ -1380,64 +1330,90 @@ fn draw_sparklines(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Bloc
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
for im in &sh.images {
|
||||
let r = anchor_rect(sh, &im.anchor);
|
||||
if !visible(r) {
|
||||
continue;
|
||||
}
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
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;
|
||||
}
|
||||
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;
|
||||
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_headings(pg: &mut Page, b: &Block, col_heads: bool, row_heads: bool, at: (f32, f32), size: (f32, f32)) {
|
||||
@@ -1552,9 +1528,6 @@ 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<u8>, 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()) };
|
||||
@@ -1591,7 +1564,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, &mut pics);
|
||||
draw_objects(pg, wb, lay.sheet, sh, b);
|
||||
}
|
||||
if lay.settings.headings {
|
||||
let fill = [0xF2, 0xF2, 0xF2];
|
||||
|
||||
@@ -448,11 +448,6 @@ 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 `<td></td>` 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<String> {
|
||||
@@ -484,17 +479,6 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> Result<String> {
|
||||
r.height()
|
||||
)));
|
||||
}
|
||||
// The table is one growing String: every kept row writes a `<td>` 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("<colgroup>");
|
||||
for col in (r.start.col..=r.end.col).filter(|&c| !sh.is_col_hidden(c)) {
|
||||
@@ -727,23 +711,6 @@ 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 `<td>` 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();
|
||||
|
||||
@@ -95,6 +95,13 @@ impl Cache {
|
||||
}
|
||||
None => (None, u32::MAX),
|
||||
};
|
||||
// Eviction only frees entries used before this frame, so a workbook covered in same-frame
|
||||
// pictures can leave the cache unable to make room. Then draw the texture without
|
||||
// retaining it: the handle is dropped at the end of the frame and the GPU memory goes
|
||||
// with it, instead of the budget being outflanked (finding: cache admission).
|
||||
if self.entries.len() >= MAX_CACHE_ITEMS || self.bytes.saturating_add(bytes) > MAX_CACHE_BYTES {
|
||||
return texture;
|
||||
}
|
||||
self.bytes = self.bytes.saturating_add(bytes);
|
||||
self.entries.insert(key, Entry { texture: texture.clone(), bytes, side, used: frame });
|
||||
texture
|
||||
@@ -212,13 +219,24 @@ mod tests {
|
||||
}
|
||||
assert!(cache.bytes <= MAX_CACHE_BYTES, "{} bytes kept", cache.bytes);
|
||||
assert!(cache.entries.len() < data.len());
|
||||
// Many on screen at once, over the budget: every one keeps its texture, frame after frame.
|
||||
// Many on screen at once, over the budget: each still gets a texture this frame, but the
|
||||
// cache cannot grow past its budget whatever the workbook shows. Eight of the 16 MB
|
||||
// textures fit the 128 MB budget, and those stay pinned; the rest re-upload each frame
|
||||
// instead of being retained over it (finding: cache admission).
|
||||
// Two frames pass so the rehearsal's entries are old enough to be evictable.
|
||||
frame(&ctx);
|
||||
frame(&ctx);
|
||||
let shown: Vec<_> = data.iter().enumerate().take(12).map(|(i, d)| cache.texture(&ctx, key(1, i as u32, d), d, 2048).unwrap().id()).collect();
|
||||
for _ in 0..3 {
|
||||
frame(&ctx);
|
||||
for (i, d) in data.iter().enumerate().take(12) {
|
||||
assert_eq!(cache.texture(&ctx, key(1, i as u32, d), d, 2048).map(|t| t.id()), Some(shown[i]));
|
||||
let id = cache.texture(&ctx, key(1, i as u32, d), d, 2048).unwrap().id();
|
||||
if i < 8 {
|
||||
assert_eq!(id, shown[i], "a picture that fits keeps its texture");
|
||||
}
|
||||
}
|
||||
assert!(cache.bytes <= MAX_CACHE_BYTES, "{} bytes kept", cache.bytes);
|
||||
assert!(cache.entries.len() <= 8, "{} entries kept", cache.entries.len());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,12 +17,16 @@ if [[ -z "${APPLE_CERTIFICATE:-}" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
TMP="${RUNNER_TEMP:-${TMPDIR:-/tmp}}"
|
||||
KEYCHAIN="$TMP/gridcraft-signing.keychain-db"
|
||||
CERT="$TMP/gridcraft-signing.p12"
|
||||
BASE="${RUNNER_TEMP:-${TMPDIR:-/tmp}}"
|
||||
# A private directory created exclusively, not predictable names in a possibly shared temp
|
||||
# root: another local user's symlink at `gridcraft-signing.p12` would otherwise select the
|
||||
# write target and capture the decoded certificate.
|
||||
WORK="$(mktemp -d "$BASE/gridcraft-signing.XXXXXXXX")"
|
||||
KEYCHAIN="$WORK/gridcraft-signing.keychain-db"
|
||||
CERT="$WORK/gridcraft-signing.p12"
|
||||
KC_PASS="${KEYCHAIN_PASSWORD:-$(openssl rand -hex 24)}"
|
||||
|
||||
trap 'rm -f "$CERT"' EXIT
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
printf '%s' "$APPLE_CERTIFICATE" | base64 --decode >"$CERT"
|
||||
security create-keychain -p "$KC_PASS" "$KEYCHAIN"
|
||||
security set-keychain-settings -lut 21600 "$KEYCHAIN"
|
||||
|
||||
+24
-3
@@ -2,6 +2,9 @@
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::MetadataExt as _;
|
||||
|
||||
#[derive(Default, Clone, Copy)]
|
||||
struct Counts {
|
||||
files: usize,
|
||||
@@ -10,7 +13,24 @@ struct Counts {
|
||||
proptests: usize,
|
||||
}
|
||||
|
||||
fn walk(dir: &Path, c: &mut Counts) {
|
||||
fn walk(dir: &Path, c: &mut Counts, seen: &mut std::collections::HashSet<u64>, budget: &mut usize) {
|
||||
if *budget == 0 {
|
||||
// Hard stop for pathological trees: two directory symlinks pointing at each other
|
||||
// would otherwise be walked exponentially.
|
||||
return;
|
||||
}
|
||||
// Directory identities (dev+inode on unix) make a symlink to a visited directory a
|
||||
// no-op, not another exponential branch of the scan.
|
||||
#[cfg(unix)]
|
||||
let id = std::fs::metadata(dir).ok().and_then(|m| m.dev().checked_mul(1 << 60).and_then(|d| d.checked_add(m.ino())));
|
||||
#[cfg(not(unix))]
|
||||
let id: Option<u64> = None;
|
||||
if let Some(id) = id
|
||||
&& !seen.insert(id)
|
||||
{
|
||||
return;
|
||||
}
|
||||
*budget -= 1;
|
||||
let Ok(rd) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
@@ -18,10 +38,11 @@ fn walk(dir: &Path, c: &mut Counts) {
|
||||
let p = e.path();
|
||||
let name = e.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
// A symlinked directory is descended only if its identity is new (checked above).
|
||||
if p.is_dir() {
|
||||
// Nested fuzz workspaces and build output are not part of the crate.
|
||||
if name != "target" && name != "fuzz" && !name.starts_with('.') {
|
||||
walk(&p, c);
|
||||
walk(&p, c, seen, budget);
|
||||
}
|
||||
} else if name.ends_with(".rs")
|
||||
&& let Ok(s) = std::fs::read_to_string(&p)
|
||||
@@ -71,7 +92,7 @@ pub fn run(_root: &Path, exact: bool) -> Result<(), String> {
|
||||
continue;
|
||||
};
|
||||
let mut c = Counts::default();
|
||||
walk(&dir, &mut c);
|
||||
walk(&dir, &mut c, &mut std::collections::HashSet::new(), &mut 10_000);
|
||||
let ex = if exact { exact_tests(&name) } else { None };
|
||||
rows.push((name, c, ex));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user