mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-07 10:59:33 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3e6a461fe | ||
|
|
31a821436f |
@@ -433,17 +433,6 @@ pub(crate) fn shape_of(s: &Session, id: ShapeId) -> Result<deckcraft_model::Shap
|
|||||||
pub fn xfrm_of(doc: &deckcraft_model::Presentation, sel: &crate::Selection, shape: &deckcraft_model::Shape) -> Xfrm {
|
pub fn xfrm_of(doc: &deckcraft_model::Presentation, sel: &crate::Selection, shape: &deckcraft_model::Shape) -> Xfrm {
|
||||||
let mut x = shape.xfrm.unwrap_or_else(|| ctx_xfrm(doc, sel, shape));
|
let mut x = shape.xfrm.unwrap_or_else(|| ctx_xfrm(doc, sel, shape));
|
||||||
if let deckcraft_model::ShapeKind::Table(t) = &shape.kind {
|
if let deckcraft_model::ShapeKind::Table(t) = &shape.kind {
|
||||||
// A loaded table may not be normalized yet: measure the bounded grid an edit will
|
|
||||||
// leave, not every cell the file stored.
|
|
||||||
let normalized;
|
|
||||||
let t = if t.is_normalized() {
|
|
||||||
t
|
|
||||||
} else {
|
|
||||||
let mut n = t.clone();
|
|
||||||
n.normalize();
|
|
||||||
normalized = n;
|
|
||||||
&normalized
|
|
||||||
};
|
|
||||||
let drawn: f64 = table_drawn_heights(doc, sel, t).iter().sum();
|
let drawn: f64 = table_drawn_heights(doc, sel, t).iter().sum();
|
||||||
if drawn.is_finite() && drawn > x.h {
|
if drawn.is_finite() && drawn > x.h {
|
||||||
x.h = drawn;
|
x.h = drawn;
|
||||||
|
|||||||
@@ -285,8 +285,6 @@ fn with_table(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut Table) -> R
|
|||||||
};
|
};
|
||||||
let r = edit_shapes(s, &json!({"id": id}), cmd, |sh| match &mut sh.kind {
|
let r = edit_shapes(s, &json!({"id": id}), cmd, |sh| match &mut sh.kind {
|
||||||
ShapeKind::Table(t) => {
|
ShapeKind::Table(t) => {
|
||||||
// Edits walk the grid: bound it first (a loaded table need not be normalized yet).
|
|
||||||
t.normalize();
|
|
||||||
f(t)?;
|
f(t)?;
|
||||||
t.normalize();
|
t.normalize();
|
||||||
let (w, h) = (t.width(), t.height());
|
let (w, h) = (t.width(), t.height());
|
||||||
|
|||||||
@@ -85,14 +85,20 @@ impl AutomationRoots {
|
|||||||
|
|
||||||
/// May the file or folder at `path` be read (or written)? `Err` says why not.
|
/// May the file or folder at `path` be read (or written)? `Err` says why not.
|
||||||
pub fn check(&self, path: &str, access: Access) -> Result<(), String> {
|
pub fn check(&self, path: &str, access: Access) -> Result<(), String> {
|
||||||
|
self.check_path(Path::new(path), access)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Self::check`] for a path that may not be UTF-8: the checked path is the one opened.
|
||||||
|
pub fn check_path(&self, path: &Path, access: Access) -> Result<(), String> {
|
||||||
|
let path_text = path.display();
|
||||||
let roots = match access {
|
let roots = match access {
|
||||||
Access::Read => &self.read,
|
Access::Read => &self.read,
|
||||||
Access::Write => &self.write,
|
Access::Write => &self.write,
|
||||||
};
|
};
|
||||||
if roots.is_empty() {
|
if roots.is_empty() {
|
||||||
return match access {
|
return match access {
|
||||||
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {path}")),
|
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {path_text}")),
|
||||||
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path}")),
|
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path_text}")),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
let resolved = resolve(path)?;
|
let resolved = resolve(path)?;
|
||||||
@@ -101,8 +107,8 @@ impl AutomationRoots {
|
|||||||
}
|
}
|
||||||
let roots = roots.iter().map(|root| root.shown.display().to_string()).collect::<Vec<_>>().join(", ");
|
let roots = roots.iter().map(|root| root.shown.display().to_string()).collect::<Vec<_>>().join(", ");
|
||||||
match access {
|
match access {
|
||||||
Access::Read => Err(format!("automation path rejected: outside the read roots {roots}: {path}")),
|
Access::Read => Err(format!("automation path rejected: outside the read roots {roots}: {path_text}")),
|
||||||
Access::Write => Err(format!("automation path rejected: outside the write roots {roots}: {path}")),
|
Access::Write => Err(format!("automation path rejected: outside the write roots {roots}: {path_text}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,17 +197,23 @@ pub fn check_write(path: &str) -> Result<(), String> {
|
|||||||
current().map_or(Ok(()), |r| r.check(path, Access::Write))
|
current().map_or(Ok(()), |r| r.check(path, Access::Write))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// May `path` be written here? Always, without roots in force. For a path that may not be UTF-8.
|
||||||
|
pub fn check_write_path(path: &Path) -> Result<(), String> {
|
||||||
|
current().map_or(Ok(()), |r| r.check_path(path, Access::Write))
|
||||||
|
}
|
||||||
|
|
||||||
/// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest
|
/// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest
|
||||||
/// existing folder's links followed and the rest appended.
|
/// existing folder's links followed and the rest appended.
|
||||||
fn resolve(path: &str) -> Result<PathBuf, String> {
|
fn resolve(path: &Path) -> Result<PathBuf, String> {
|
||||||
if path.is_empty() {
|
if path.as_os_str().is_empty() {
|
||||||
return Err("automation path rejected: path is empty".into());
|
return Err("automation path rejected: path is empty".into());
|
||||||
}
|
}
|
||||||
|
let shown = path.display();
|
||||||
// Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows.
|
// Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows.
|
||||||
if cfg!(windows) && Path::new(path).components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) {
|
if cfg!(windows) && path.components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) {
|
||||||
return Err(format!("automation path rejected: reserved device names are not allowed: {path}"));
|
return Err(format!("automation path rejected: reserved device names are not allowed: {shown}"));
|
||||||
}
|
}
|
||||||
let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {path}: {e}"))?;
|
let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {shown}: {e}"))?;
|
||||||
// The names below the nearest existing folder, last first.
|
// The names below the nearest existing folder, last first.
|
||||||
let mut missing: Vec<OsString> = vec![];
|
let mut missing: Vec<OsString> = vec![];
|
||||||
let mut at = absolute.as_path();
|
let mut at = absolute.as_path();
|
||||||
@@ -212,11 +224,11 @@ fn resolve(path: &str) -> Result<PathBuf, String> {
|
|||||||
return Ok(found);
|
return Ok(found);
|
||||||
}
|
}
|
||||||
Err(e) => match std::fs::symlink_metadata(at) {
|
Err(e) => match std::fs::symlink_metadata(at) {
|
||||||
Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {path}")),
|
Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {shown}")),
|
||||||
// There, yet its real path can't be had (no permission): nothing to compare.
|
// There, yet its real path can't be had (no permission): nothing to compare.
|
||||||
Ok(_) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")),
|
Ok(_) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")),
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
Err(e) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")),
|
Err(e) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
let parent = at.parent();
|
let parent = at.parent();
|
||||||
@@ -226,12 +238,12 @@ fn resolve(path: &str) -> Result<PathBuf, String> {
|
|||||||
at = parent;
|
at = parent;
|
||||||
}
|
}
|
||||||
(Some(Component::ParentDir), _) => {
|
(Some(Component::ParentDir), _) => {
|
||||||
return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {path}"));
|
return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {shown}"));
|
||||||
}
|
}
|
||||||
// Down to the root (or drive) and it doesn't exist either.
|
// Down to the root (or drive) and it doesn't exist either.
|
||||||
_ => {
|
_ => {
|
||||||
let e = std::io::Error::from(std::io::ErrorKind::NotFound);
|
let e = std::io::Error::from(std::io::ErrorKind::NotFound);
|
||||||
return Err(format!("automation path rejected: cannot resolve {path}: {e}"));
|
return Err(format!("automation path rejected: cannot resolve {shown}: {e}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -668,6 +668,8 @@ pub fn add_user_word(word: &str) -> std::io::Result<()> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let path = user_dictionary_path().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no folder for the user dictionary"))?;
|
let path = user_dictionary_path().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no folder for the user dictionary"))?;
|
||||||
|
// A confined automation client may write only inside its write roots, this file included.
|
||||||
|
crate::file_access::check_write_path(&path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
|
||||||
if let Some(d) = path.parent() {
|
if let Some(d) = path.parent() {
|
||||||
std::fs::create_dir_all(d)?;
|
std::fs::create_dir_all(d)?;
|
||||||
}
|
}
|
||||||
@@ -953,6 +955,20 @@ SFX D Y 1\nSFX D 0 ed/S .\n";
|
|||||||
assert_eq!(w, vec!["check", "done"]);
|
assert_eq!(w, vec!["check", "done"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_confined_client_cannot_write_the_user_dictionary_outside_its_roots() {
|
||||||
|
let root = std::env::temp_dir().join(format!("deckcraft-dict-root-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&root).unwrap();
|
||||||
|
let roots = crate::file_access::AutomationRoots::new(std::slice::from_ref(&root), std::slice::from_ref(&root)).unwrap();
|
||||||
|
// The user dictionary is a file of its own outside `root`, not whatever HOME points at.
|
||||||
|
let outside = std::env::temp_dir().join(format!("deckcraft-dict-outside-{}", std::process::id()));
|
||||||
|
set_user_dictionary_path(outside.join("user.dic"));
|
||||||
|
let r = crate::file_access::confine(roots.as_ref(), || add_user_word("zqxconfinedword"));
|
||||||
|
assert_eq!(r.map_err(|e| e.kind()), Err(std::io::ErrorKind::PermissionDenied));
|
||||||
|
assert!(!outside.exists(), "the refused write created nothing");
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn long_and_numeric_flags_and_latin1() {
|
fn long_and_numeric_flags_and_latin1() {
|
||||||
let d = Dictionary::parse(b"FLAG long\nSFX Aa Y 1\nSFX Aa 0 s .\n", b"1\ncat/Aa\n");
|
let d = Dictionary::parse(b"FLAG long\nSFX Aa Y 1\nSFX Aa 0 s .\n", b"1\ncat/Aa\n");
|
||||||
|
|||||||
+11
-97
@@ -77,12 +77,6 @@ impl Default for Table {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cells a table may hold after [`Table::normalize`] (PowerPoint's own limit is 75 x 75).
|
|
||||||
pub const MAX_CELLS: usize = 100_000;
|
|
||||||
/// Cell visits [`Table::merge`] may spend growing its rectangle over existing merges: a few
|
|
||||||
/// passes over the largest grid, where a real table needs one or two.
|
|
||||||
const MAX_MERGE_WORK: usize = 16 * MAX_CELLS;
|
|
||||||
|
|
||||||
impl Table {
|
impl Table {
|
||||||
pub fn new(rows: usize, cols: usize, width: f64, row_height: f64) -> Self {
|
pub fn new(rows: usize, cols: usize, width: f64, row_height: f64) -> Self {
|
||||||
let rows = rows.clamp(1, 1000);
|
let rows = rows.clamp(1, 1000);
|
||||||
@@ -170,12 +164,8 @@ impl Table {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
/// The merged areas: every uncovered cell spanning more than one cell, clipped to the grid.
|
/// The merged areas: every uncovered cell spanning more than one cell, clipped to the grid.
|
||||||
/// Merges of a well-formed table do not overlap, so together they cover at most the grid; a
|
|
||||||
/// file's overlapping spans past that are dropped, so rebuilding them cannot revisit cells
|
|
||||||
/// without bound.
|
|
||||||
fn merges(&self) -> Vec<Merge> {
|
fn merges(&self) -> Vec<Merge> {
|
||||||
let (nr, nc) = (self.rows.len(), self.cols.len());
|
let (nr, nc) = (self.rows.len(), self.cols.len());
|
||||||
let mut area = nr.saturating_mul(nc);
|
|
||||||
let mut out = vec![];
|
let mut out = vec![];
|
||||||
for (r, row) in self.rows.iter().enumerate() {
|
for (r, row) in self.rows.iter().enumerate() {
|
||||||
for (c, cell) in row.cells.iter().enumerate().take(nc) {
|
for (c, cell) in row.cells.iter().enumerate().take(nc) {
|
||||||
@@ -184,10 +174,7 @@ impl Table {
|
|||||||
}
|
}
|
||||||
let rs = (cell.row_span.max(1) as usize).min(nr - r);
|
let rs = (cell.row_span.max(1) as usize).min(nr - r);
|
||||||
let gs = (cell.grid_span.max(1) as usize).min(nc - c);
|
let gs = (cell.grid_span.max(1) as usize).min(nc - c);
|
||||||
if (rs > 1 || gs > 1)
|
if rs > 1 || gs > 1 {
|
||||||
&& let Some(left) = area.checked_sub(rs.saturating_mul(gs))
|
|
||||||
{
|
|
||||||
area = left;
|
|
||||||
out.push(Merge { r, c, rs, gs });
|
out.push(Merge { r, c, rs, gs });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -240,17 +227,9 @@ impl Table {
|
|||||||
if r1 >= self.rows.len() || c1 >= self.cols.len() || (r0 == r1 && c0 == c1) {
|
if r1 >= self.rows.len() || c1 >= self.cols.len() || (r0 == r1 && c0 == c1) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Grow the rectangle until no existing merge sticks out of it. Each pass reads the whole
|
// Grow the rectangle until no existing merge sticks out of it.
|
||||||
// grid and may grow it by one row, so the passes are charged against
|
|
||||||
// [`MAX_MERGE_WORK`]: past it the merge is refused, before anything changed.
|
|
||||||
let (mut r0, mut c0, mut r1, mut c1) = (r0, c0, r1, c1);
|
let (mut r0, mut c0, mut r1, mut c1) = (r0, c0, r1, c1);
|
||||||
let pass = self.rows.len().saturating_mul(self.cols.len()).max(1);
|
|
||||||
let mut work = 0usize;
|
|
||||||
loop {
|
loop {
|
||||||
work = work.saturating_add(pass);
|
|
||||||
if work > MAX_MERGE_WORK {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let before = (r0, c0, r1, c1);
|
let before = (r0, c0, r1, c1);
|
||||||
for (r, row) in self.rows.iter().enumerate() {
|
for (r, row) in self.rows.iter().enumerate() {
|
||||||
for (c, cell) in row.cells.iter().enumerate().take(self.cols.len()) {
|
for (c, cell) in row.cells.iter().enumerate().take(self.cols.len()) {
|
||||||
@@ -304,31 +283,21 @@ impl Table {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Spans come from the file and may reach past the table; only walk the cells that exist.
|
// Spans come from the file and may reach past the table; only walk the cells that exist.
|
||||||
let end = r.saturating_add(rs).min(self.rows.len());
|
for rr in r..r.saturating_add(rs).min(self.rows.len()) {
|
||||||
let gs = gs.min(self.cols.len().saturating_sub(c));
|
for cc in c..c.saturating_add(gs).min(self.cols.len()) {
|
||||||
for row in self.rows.get_mut(r..end).unwrap_or_default() {
|
if let Some(x) = self.cell_mut(rr, cc) {
|
||||||
for x in row.cells.iter_mut().skip(c).take(gs) {
|
x.h_merge = false;
|
||||||
x.h_merge = false;
|
x.v_merge = false;
|
||||||
x.v_merge = false;
|
x.grid_span = 1;
|
||||||
x.grid_span = 1;
|
x.row_span = 1;
|
||||||
x.row_span = 1;
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
/// Whether the grid is as [`Table::normalize`] leaves it: within [`MAX_CELLS`], every row
|
/// Make every row have exactly `cols.len()` cells (repair after loading).
|
||||||
/// exactly `cols.len()` cells wide.
|
|
||||||
pub fn is_normalized(&self) -> bool {
|
|
||||||
let n = self.cols.len();
|
|
||||||
n <= MAX_CELLS && self.rows.len() <= (MAX_CELLS / n.max(1)).max(1) && self.rows.iter().all(|r| r.cells.len() == n)
|
|
||||||
}
|
|
||||||
/// Make every row have exactly `cols.len()` cells (repair after loading). A grid over
|
|
||||||
/// [`MAX_CELLS`] loses its last rows (and columns) first, so a file naming many rows and many
|
|
||||||
/// columns but few cells cannot make this allocate their product.
|
|
||||||
pub fn normalize(&mut self) {
|
pub fn normalize(&mut self) {
|
||||||
self.cols.truncate(MAX_CELLS);
|
|
||||||
let n = self.cols.len();
|
let n = self.cols.len();
|
||||||
self.rows.truncate((MAX_CELLS / n.max(1)).max(1));
|
|
||||||
for r in &mut self.rows {
|
for r in &mut self.rows {
|
||||||
r.cells.resize_with(n, || Cell::new(""));
|
r.cells.resize_with(n, || Cell::new(""));
|
||||||
for c in &mut r.cells {
|
for c in &mut r.cells {
|
||||||
@@ -544,61 +513,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Each cell spans two rows, uncovered: merging the last two rows would grow the rectangle a
|
|
||||||
/// row per pass over the whole grid, 10,000 passes. The merge is refused at its work bound
|
|
||||||
/// instead, leaving the table as it was; a small merge still works.
|
|
||||||
#[test]
|
|
||||||
fn merge_expansion_stops_at_its_work_bound() {
|
|
||||||
let mut t = Table::new(10_000, 10, 400.0, 30.0);
|
|
||||||
for row in &mut t.rows {
|
|
||||||
for c in &mut row.cells {
|
|
||||||
c.row_span = 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let before = t.clone();
|
|
||||||
assert!(!t.merge(9_998, 0, 9_999, 9));
|
|
||||||
assert_eq!(t, before);
|
|
||||||
let mut small = Table::new(4, 4, 400.0, 30.0);
|
|
||||||
assert!(small.merge(0, 0, 1, 1));
|
|
||||||
assert!(small.is_normalized() && !Table { cols: vec![1.0; 3], rows: vec![Row::default(); 2], ..Default::default() }.is_normalized());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normalize_caps_a_sparse_grid_at_max_cells() {
|
|
||||||
// 1,000 columns and 1,000 empty rows would be a million cells.
|
|
||||||
let mut t = Table { cols: vec![10.0; 1000], rows: vec![Row::default(); 1000], ..Default::default() };
|
|
||||||
t.normalize();
|
|
||||||
assert_eq!(t.cols.len(), 1000);
|
|
||||||
assert_eq!(t.rows.len(), MAX_CELLS / 1000);
|
|
||||||
assert!(t.rows.iter().all(|r| r.cells.len() == 1000));
|
|
||||||
// Columns alone past the cap keep one row of MAX_CELLS cells.
|
|
||||||
let mut t = Table { cols: vec![1.0; MAX_CELLS + 5], rows: vec![Row::default(); 3], ..Default::default() };
|
|
||||||
t.normalize();
|
|
||||||
assert_eq!((t.cols.len(), t.rows.len()), (MAX_CELLS, 1));
|
|
||||||
// A normal table is untouched.
|
|
||||||
let mut t = Table::new(4, 4, 400.0, 30.0);
|
|
||||||
t.normalize();
|
|
||||||
assert_eq!((t.n_rows(), t.n_cols()), (4, 4));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn overlapping_file_spans_rebuild_in_bounded_work() {
|
|
||||||
// Every cell an uncovered anchor spanning to the far corner: their areas add up to about
|
|
||||||
// n^4 / 4, but only what fits the grid is kept, so an edit's rebuild stays near n^2.
|
|
||||||
let n = 200;
|
|
||||||
let mut t = Table::new(n, n, 400.0, 30.0);
|
|
||||||
for row in &mut t.rows {
|
|
||||||
for c in &mut row.cells {
|
|
||||||
c.grid_span = u32::MAX;
|
|
||||||
c.row_span = u32::MAX;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert_eq!(t.merges().len(), 1, "the first anchor already covers the whole grid");
|
|
||||||
t.insert_row(n);
|
|
||||||
assert_eq!(t.n_rows(), n + 1);
|
|
||||||
assert!(t.split(0, 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn merged() -> Table {
|
fn merged() -> Table {
|
||||||
// 4x4 with a 2x2 merge at (1,1) holding "M".
|
// 4x4 with a 2x2 merge at (1,1) holding "M".
|
||||||
let mut t = Table::new(4, 4, 400.0, 30.0);
|
let mut t = Table::new(4, 4, 400.0, 30.0);
|
||||||
|
|||||||
Reference in New Issue
Block a user