mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-24 19:06:56 -06:00
security: budget the rendering pipeline against DoS geometry (CWE-400/407/789) (#1849)
* security: budget the rendering pipeline against DoS geometry (CWE-400/407/789) Several render paths let crafted drawing data spin the rendering thread or allocate without bound: - Hatch dash sweeping reset its per-span step counter and used emitted geometry as its only limit, so gap-only families (which emit nothing) could multiply CPU work. Skip families that cannot draw and charge every dash step against a shared per-hatch work budget (row 10). - Hatch polygon deduplication compared loops with an O(n²) cyclic match and every loop against every kept one. Charge vertex comparisons against a shared budget and stop deduplicating when spent (row 12). - Table coverage marked every merge rectangle, so overlapping merges re-marked covered cells quadratically. Bound marking to a small multiple of the cell count (row 11). - Broken-table part layout expanded sparse rows to full width before checking whether detailed layout is allowed. Only expand when within the cell limit; otherwise leave cells sparse and let layout draw an outline (row 17). - Dimension text substitution copied the base for every `<>` placeholder with no size bound. Estimate the expansion and cap it at a fixed text budget (row 32). - Linetype range generation cycled all-gap patterns forever (nothing emitted, so the emit limit never tripped). Return continuous for all-gap patterns and bound visited pattern elements (row 38). - Viewport contents built with a fresh expansion counter, so a layout could far exceed the documented block-expansion limit. Share one budget across the whole build, seeded with what the enclosing list already spent (row 22). New tests for the all-gap linetype path; all render tests pass, clippy clean. Addresses findings rows 10, 11, 12, 17, 22, 32, 38. * fix: preserve bounded render fallbacks and text output --------- Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com>
This commit is contained in:
co-authored by
skylartaylor
parent
7c72ec45a9
commit
2dbc7699a0
@@ -414,7 +414,14 @@ fn tol_height_code(st: &DimStyle) -> String {
|
||||
}
|
||||
|
||||
fn post(template: &str, meas: &str) -> String {
|
||||
if template.contains("<>") { template.replace("<>", meas) } else { format!("{meas}{template}") }
|
||||
if template.contains("<>") {
|
||||
bounded_replace(template, meas)
|
||||
} else {
|
||||
let mut out = String::new();
|
||||
append_dim_text(&mut out, meas);
|
||||
append_dim_text(&mut out, template);
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_override(base: &str, d: &Dimension) -> String {
|
||||
@@ -423,10 +430,40 @@ fn apply_override(base: &str, d: &Dimension) -> String {
|
||||
} else if d.text.trim().is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
d.text.replace("<>", base)
|
||||
bounded_replace(&d.text, base)
|
||||
}
|
||||
}
|
||||
|
||||
/// Largest dimension text produced by substituting the measurement into style/override text. Two
|
||||
/// modest strings can otherwise expand into gigabytes when the template holds many `<>` markers
|
||||
/// (CWE-789); past this the output is truncated rather than allocated unbounded.
|
||||
const MAX_DIM_TEXT: usize = 64 * 1024;
|
||||
|
||||
/// Replace every `<>` in `template` with `value`, with the expanded length checked against
|
||||
/// [`MAX_DIM_TEXT`] before allocating so a many-placeholder template cannot blow up memory.
|
||||
fn bounded_replace(template: &str, value: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut rest = template;
|
||||
while let Some(i) = rest.find("<>") {
|
||||
if !append_dim_text(&mut out, &rest[..i]) || !append_dim_text(&mut out, value) {
|
||||
return out;
|
||||
}
|
||||
rest = &rest[i + 2..];
|
||||
}
|
||||
append_dim_text(&mut out, rest);
|
||||
out
|
||||
}
|
||||
|
||||
// Returns false when truncation consumed the remaining UTF-8-safe space.
|
||||
fn append_dim_text(out: &mut String, text: &str) -> bool {
|
||||
let mut end = text.len().min(MAX_DIM_TEXT.saturating_sub(out.len()));
|
||||
while !text.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
out.push_str(&text[..end]);
|
||||
end == text.len()
|
||||
}
|
||||
|
||||
fn ext_line(origin: Vec2, foot: Vec2, st: &DimStyle, k: f64) -> Option<Vec<Vec2>> {
|
||||
let v = foot - origin;
|
||||
let len = v.len();
|
||||
@@ -1322,3 +1359,21 @@ mod tests {
|
||||
assert!(g.lines.is_empty() && g.fills.is_empty(), "{:?}", g.lines);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod text_budget_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn bounds_post_without_markers() {
|
||||
let text = post(&"x".repeat(MAX_DIM_TEXT * 2), "12");
|
||||
assert_eq!(text.len(), MAX_DIM_TEXT);
|
||||
assert!(text.starts_with("12x"));
|
||||
}
|
||||
#[test]
|
||||
fn oversized_value_is_substituted_and_truncated_at_a_character_boundary() {
|
||||
let text = bounded_replace("prefix <> mm", &"é".repeat(MAX_DIM_TEXT));
|
||||
assert!(text.starts_with("prefix é"));
|
||||
assert!(!text.contains("<>"));
|
||||
assert!(text.len() <= MAX_DIM_TEXT && text.len() >= MAX_DIM_TEXT - 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,18 +80,47 @@ fn same_polygon(a: &[Vec2], b: &[Vec2]) -> bool {
|
||||
}
|
||||
|
||||
fn dedup_polygons(loops: &mut Vec<Vec<Vec2>>) {
|
||||
// `same_polygon` is O(n²) in a polygon's vertex count (every cyclic alignment, both windings),
|
||||
// and each loop is compared against every unique one kept so far, so adversarial boundary
|
||||
// geometry could force quadratic-on-quadratic work. Charge every vertex comparison against a
|
||||
// shared budget; once it is spent, stop deduplicating and keep the remaining loops as-is
|
||||
// (CWE-407).
|
||||
let mut budget: u64 = MAX_DEDUP_COMPARISONS;
|
||||
let mut unique: Vec<Vec<Vec2>> = Vec::with_capacity(loops.len());
|
||||
for l in loops.drain(..) {
|
||||
if l.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
if !unique.iter().any(|u| same_polygon(u, &l)) {
|
||||
let mut dup = false;
|
||||
for u in &unique {
|
||||
if u.len() != l.len() {
|
||||
continue;
|
||||
}
|
||||
// Each `same_polygon` call costs about 2·len² comparisons; stop if that would overrun.
|
||||
let cost = 2u64.saturating_mul((u.len() as u64).saturating_mul(l.len() as u64));
|
||||
match budget.checked_sub(cost) {
|
||||
Some(left) => budget = left,
|
||||
None => {
|
||||
// Out of budget: keep `l` without further comparison.
|
||||
budget = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if same_polygon(u, &l) {
|
||||
dup = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !dup {
|
||||
unique.push(l);
|
||||
}
|
||||
}
|
||||
*loops = unique;
|
||||
}
|
||||
|
||||
/// Vertex comparisons `dedup_polygons` may perform before it stops deduplicating.
|
||||
const MAX_DEDUP_COMPARISONS: u64 = 50_000_000;
|
||||
|
||||
/// The tessellated boundary loops that take part in the fill under the hatch's island
|
||||
/// detection style (DXF group 75), filled with odd parity: 0 normal keeps every loop (islands
|
||||
/// within islands alternate), 1 outer keeps the outermost loops and their first islands, 2
|
||||
@@ -261,6 +290,9 @@ const MAX_PATTERN_LINES: usize = 1024;
|
||||
/// (a hostile or mis-scaled pattern can't exhaust memory). A view-limited build makes at most the
|
||||
/// runs of a full build, so it never leaves out runs that a full build draws.
|
||||
const MAX_RUNS: usize = 1_000_000;
|
||||
/// Shared budget on dash-processing steps across all of a hatch's families and spans, counted
|
||||
/// independently of emitted geometry so gap-only patterns cannot spin without bound (CWE-400).
|
||||
const MAX_HATCH_WORK: u64 = 50_000_000;
|
||||
/// Upper bound on the lines one family sweeps over the whole hatch; past it the family is left
|
||||
/// out (a full-detail build of a pattern far too fine for its area).
|
||||
const MAX_FAMILY_LINES: i64 = 20_000;
|
||||
@@ -351,7 +383,16 @@ pub(crate) fn pattern_lines_until(
|
||||
let scale = scale_of(h);
|
||||
let px = if px.is_finite() && px > 0.0 { px } else { 0.0 };
|
||||
let mut out = Vec::new();
|
||||
// Shared work budget across all families and spans, counting dash-processing steps even when
|
||||
// they emit no geometry: gap-only families produce no runs, so `out.len()` alone cannot bound
|
||||
// their cost (CWE-400).
|
||||
let mut work: u64 = MAX_HATCH_WORK;
|
||||
for fam in &lines {
|
||||
// A family with dashes but no positive (ink) element can never draw: skip it so its gaps
|
||||
// are not swept at all.
|
||||
if !fam.dashes.is_empty() && !fam.dashes.iter().any(|d| *d >= 0.0) {
|
||||
continue;
|
||||
}
|
||||
let ang = fam.angle.to_radians() + h.angle;
|
||||
let origin = h.origin + Vec2::new(fam.origin.0, fam.origin.1).rotate(h.angle) * scale;
|
||||
let delta = Vec2::new(fam.delta.0, fam.delta.1).rotate(ang) * scale;
|
||||
@@ -360,8 +401,8 @@ pub(crate) fn pattern_lines_until(
|
||||
continue;
|
||||
}
|
||||
let dashes: Vec<f64> = fam.dashes.iter().map(|d| d * scale).collect();
|
||||
Family { origin, ang, delta, spacing, dashes: &dashes }.sweep(loops, px, view, cancel, &mut out);
|
||||
if out.len() >= MAX_RUNS || is_set(cancel) {
|
||||
Family { origin, ang, delta, spacing, dashes: &dashes }.sweep(loops, px, view, cancel, &mut out, &mut work);
|
||||
if out.len() >= MAX_RUNS || work == 0 || is_set(cancel) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -422,7 +463,7 @@ impl Family<'_> {
|
||||
self.origin + p.rotate(self.ang)
|
||||
}
|
||||
|
||||
fn sweep(&self, loops: &[Vec<Vec2>], px: f64, view: Option<&Bounds2>, cancel: Option<&AtomicBool>, out: &mut Vec<Vec<Vec2>>) {
|
||||
fn sweep(&self, loops: &[Vec<Vec2>], px: f64, view: Option<&Bounds2>, cancel: Option<&AtomicBool>, out: &mut Vec<Vec<Vec2>>, work: &mut u64) {
|
||||
let local: Vec<Vec<Vec2>> = loops.iter().map(|l| l.iter().map(|p| self.to_local(*p)).collect()).collect();
|
||||
let bb = Bounds2::from_points(local.iter().flatten().copied());
|
||||
if bb.is_empty() {
|
||||
@@ -456,7 +497,7 @@ impl Family<'_> {
|
||||
let draws = self.dashes.iter().any(|d| *d >= 0.0);
|
||||
let solid_line = self.dashes.is_empty() || pat_len < 1e-12 || (px > 0.0 && pat_len < 2.0 * px && draws);
|
||||
for k in k0..=k1 {
|
||||
if out.len() >= MAX_RUNS || is_set(cancel) {
|
||||
if out.len() >= MAX_RUNS || *work == 0 || is_set(cancel) {
|
||||
return;
|
||||
}
|
||||
let y = k as f64 * self.spacing;
|
||||
@@ -484,21 +525,22 @@ impl Family<'_> {
|
||||
if solid_line {
|
||||
out.push(vec![self.to_world(Vec2::new(x0, y)), self.to_world(Vec2::new(x1, y))]);
|
||||
} else {
|
||||
self.dash(x0, x1, y, offset, pat_len, out);
|
||||
self.dash(x0, x1, y, offset, pat_len, out, work);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The dashes and dots of the span `x0..x1` of the line at `y`, phase anchored at x = `offset`.
|
||||
fn dash(&self, x0: f64, x1: f64, y: f64, offset: f64, pat_len: f64, out: &mut Vec<Vec<Vec2>>) {
|
||||
fn dash(&self, x0: f64, x1: f64, y: f64, offset: f64, pat_len: f64, out: &mut Vec<Vec<Vec2>>, work: &mut u64) {
|
||||
let start_rep = ((x0 - offset) / pat_len).floor();
|
||||
let mut x = offset + start_rep * pat_len;
|
||||
let mut guard = 0;
|
||||
'outer: while x < x1 {
|
||||
for d in self.dashes {
|
||||
guard += 1;
|
||||
if guard > 100_000 || out.len() >= MAX_RUNS {
|
||||
*work = work.saturating_sub(1);
|
||||
if guard > 100_000 || out.len() >= MAX_RUNS || *work == 0 {
|
||||
break 'outer;
|
||||
}
|
||||
let len = d.abs();
|
||||
@@ -560,3 +602,18 @@ mod tests {
|
||||
assert!((area - 100.0).abs() < 1e-6, "triangulated area must be 100.0, not zero");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod dedup_budget_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn unequal_loops_do_not_spend_the_comparison_budget() {
|
||||
// The old estimate exhausted the budget on this unequal pair before seeing the triangle.
|
||||
let a = vec![Vec2::ZERO; 5_000];
|
||||
let b = vec![Vec2::ZERO; 5_001];
|
||||
let tri = vec![Vec2::ZERO, Vec2::new(1.0, 0.0), Vec2::new(0.0, 1.0)];
|
||||
let mut loops = vec![a, b, tri.clone(), tri];
|
||||
dedup_polygons(&mut loops);
|
||||
assert_eq!(loops.len(), 3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -735,13 +735,17 @@ fn contents(b: &mut Builder, d: &Drawing, vp: &cadcraft_doc::Viewport, area: Bou
|
||||
let eb = cadcraft_doc::entity_bounds(d, me, 0);
|
||||
eb.is_empty() || eb.intersects(&win)
|
||||
};
|
||||
// One expansion budget for the whole build: seed it with what the enclosing list has already
|
||||
// spent so a viewport's contents cannot start over with a fresh allowance and exceed the
|
||||
// documented limit (CWE-400). Both the serial sub-builder and the parallel chunks share it.
|
||||
let budget = std::sync::atomic::AtomicUsize::new(b.list.expanded);
|
||||
let mut sub = Builder {
|
||||
list: DisplayList::default(),
|
||||
opts: b.opts,
|
||||
plotting: b.plotting,
|
||||
styles: b.styles,
|
||||
named: None,
|
||||
shared_budget: None,
|
||||
shared_budget: Some(&budget),
|
||||
transparency: 0,
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -754,7 +758,6 @@ fn contents(b: &mut Builder, d: &Drawing, vp: &cadcraft_doc::Viewport, area: Bou
|
||||
// As `build_parallel`: chunks built side by side, joined in drawing order.
|
||||
use rayon::prelude::*;
|
||||
let ents: Vec<&std::sync::Arc<Entity>> = d.model.iter().collect();
|
||||
let budget = std::sync::atomic::AtomicUsize::new(0);
|
||||
let opts = b.opts;
|
||||
let parts: Vec<DisplayList> = ents
|
||||
.par_chunks(PARALLEL_CHUNK)
|
||||
|
||||
@@ -94,8 +94,16 @@ pub fn ranges(len: f64, lt: &Linetype, scale: f64, min_pattern: f64) -> Option<V
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut pos = 0.0;
|
||||
// Gap-only patterns draw nothing, matching `apply`, without walking every repeat.
|
||||
if !lt.pattern.iter().any(|d| d.length >= 0.0) {
|
||||
return Some(Vec::new());
|
||||
}
|
||||
// Bound the pattern elements visited, independent of emitted ranges, so a pattern that emits
|
||||
// little still cannot cycle hundreds of millions of times.
|
||||
let mut visits: u64 = 0;
|
||||
for d in lt.pattern.iter().map(|d| d.length * scale).cycle() {
|
||||
if pos >= len || out.len() > 2_000_000 {
|
||||
visits += 1;
|
||||
if pos >= len || out.len() > 2_000_000 || visits > 4_000_000 {
|
||||
break;
|
||||
}
|
||||
if d >= 0.0 {
|
||||
@@ -110,6 +118,13 @@ pub fn ranges(len: f64, lt: &Linetype, scale: f64, min_pattern: f64) -> Option<V
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_all_gap_pattern_draws_nothing_without_spinning() {
|
||||
let lt = Linetype::simple("GAPS", "", &[-0.001, -0.002]);
|
||||
assert_eq!(ranges(1.0, <, 1.0, 0.0), Some(Vec::new()));
|
||||
assert!(apply(&[Vec2::ZERO, Vec2::new(1.0, 0.0)], <, 1.0, 0.0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dashes_split_line() {
|
||||
let lt = Linetype::simple("DASHED", "", &[0.5, -0.25]);
|
||||
|
||||
+70
-16
@@ -53,17 +53,30 @@ pub struct TableGeom {
|
||||
|
||||
/// Which cells are covered by a merge (and so not drawn on their own).
|
||||
pub fn table_covered(t: &Table) -> Vec<Vec<bool>> {
|
||||
coverage(t).unwrap_or_else(|partial| partial)
|
||||
}
|
||||
|
||||
// No partial coverage may reach owner assignment: it would revisit unbounded merge rectangles.
|
||||
fn coverage(t: &Table) -> Result<Vec<Vec<bool>>, Vec<Vec<bool>>> {
|
||||
let rows = t.row_heights.len().min(10_000);
|
||||
let cols = t.col_widths.len().min(10_000);
|
||||
if rows.saturating_mul(cols) > MAX_LAYOUT_CELLS {
|
||||
return vec![Vec::new(); rows];
|
||||
return Err(vec![Vec::new(); rows]);
|
||||
}
|
||||
let mut cov = vec![vec![false; cols]; rows];
|
||||
// Coverage marking visits each merge's whole rectangle. Overlapping merges re-mark cells that
|
||||
// are already covered, so bound the total marking steps to a small multiple of the cell count
|
||||
// rather than letting overlapping declarations cause quadratic work (CWE-407).
|
||||
let mut budget: u64 = (rows as u64).saturating_mul(cols as u64).saturating_mul(4).saturating_add(1024);
|
||||
for (r, row) in t.cells.iter().enumerate().take(rows) {
|
||||
for (c, cell) in row.iter().enumerate().take(cols) {
|
||||
if let Some((rs, cs)) = cell.merged {
|
||||
for rr in r..(r + rs.max(1) as usize).min(rows) {
|
||||
for cc in c..(c + cs.max(1) as usize).min(cols) {
|
||||
budget = budget.saturating_sub(1);
|
||||
if budget == 0 {
|
||||
return Err(cov);
|
||||
}
|
||||
if (rr, cc) != (r, c)
|
||||
&& let Some(x) = cov.get_mut(rr).and_then(|v| v.get_mut(cc))
|
||||
{
|
||||
@@ -74,7 +87,7 @@ pub fn table_covered(t: &Table) -> Vec<Vec<bool>> {
|
||||
}
|
||||
}
|
||||
}
|
||||
cov
|
||||
Ok(cov)
|
||||
}
|
||||
|
||||
/// The table style a table uses (Standard defaults when missing).
|
||||
@@ -134,19 +147,25 @@ pub fn table_geometry(d: &Drawing, t: &Table) -> TableGeom {
|
||||
/// One part of a broken table as a table of its own: its rows (with their cell styles) at
|
||||
/// `origin`; merges are cut at the part's edge.
|
||||
fn part_table(t: &Table, rows: &[usize], origin: Vec2) -> Table {
|
||||
let cols = t.col_widths.len();
|
||||
let mut cells: Vec<Vec<cadcraft_doc::TableCell>> = rows.iter().map(|r| t.cells.get(*r).cloned().unwrap_or_default()).collect();
|
||||
for (i, row) in cells.iter_mut().enumerate() {
|
||||
row.resize_with(cols, Default::default);
|
||||
for cell in row.iter_mut() {
|
||||
if let Some((rs, cs)) = cell.merged {
|
||||
// Rows of the merge that follow in this part.
|
||||
let start = rows.get(i).copied().unwrap_or(0);
|
||||
let mut n = 1u32;
|
||||
while n < rs && rows.get(i + n as usize).is_some_and(|r| *r == start + n as usize) {
|
||||
n += 1;
|
||||
let cols = t.col_widths.len().min(10_000);
|
||||
let mut cells: Vec<Vec<cadcraft_doc::TableCell>> =
|
||||
rows.iter().take(10_000).map(|r| t.cells.get(*r).map(|row| row.iter().take(cols).cloned().collect()).unwrap_or_default()).collect();
|
||||
// Only expand sparse rows to the full column count when detailed layout is actually permitted.
|
||||
// Otherwise `layout` renders an outline, so materialising millions of empty cells here would be
|
||||
// wasted work an attacker can trigger with a compact dimension array (CWE-789).
|
||||
if rows.len().min(10_000).saturating_mul(cols) <= MAX_LAYOUT_CELLS {
|
||||
for (i, row) in cells.iter_mut().enumerate() {
|
||||
row.resize_with(cols, Default::default);
|
||||
for cell in row.iter_mut() {
|
||||
if let Some((rs, cs)) = cell.merged {
|
||||
// Rows of the merge that follow in this part.
|
||||
let start = rows.get(i).copied().unwrap_or(0);
|
||||
let mut n = 1u32;
|
||||
while n < rs && rows.get(i + n as usize).is_some_and(|r| *r == start + n as usize) {
|
||||
n += 1;
|
||||
}
|
||||
cell.merged = (n > 1 || cs > 1).then_some((n, cs));
|
||||
}
|
||||
cell.merged = (n > 1 || cs > 1).then_some((n, cs));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,7 +199,8 @@ fn layout(d: &Drawing, t: &Table) -> TableGeom {
|
||||
if rows == 0 || cols == 0 {
|
||||
return g;
|
||||
}
|
||||
if rows.saturating_mul(cols) > MAX_LAYOUT_CELLS {
|
||||
let cov = coverage(t);
|
||||
if rows.saturating_mul(cols) > MAX_LAYOUT_CELLS || cov.is_err() {
|
||||
let (w, h) = (x_at(cols), y_at(rows));
|
||||
let corners = [p(0.0, 0.0), p(w, 0.0), p(w, h), p(0.0, h), p(0.0, 0.0)];
|
||||
for s in corners.windows(2) {
|
||||
@@ -209,7 +229,7 @@ fn layout(d: &Drawing, t: &Table) -> TableGeom {
|
||||
i
|
||||
};
|
||||
// Anchors and the anchor owning each grid cell.
|
||||
let cov = table_covered(t);
|
||||
let cov = cov.unwrap_or_default();
|
||||
let mut anchors: Vec<Anchor> = Vec::new();
|
||||
let mut owner = vec![usize::MAX; rows * cols];
|
||||
for r in 0..rows {
|
||||
@@ -369,3 +389,37 @@ pub(crate) fn draw(b: &mut Builder, ctx: &Ctx, t: &Table, rgb: Ink, lw: f32) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod budget_tests {
|
||||
use super::*;
|
||||
use cadcraft_doc::TableCell;
|
||||
|
||||
#[test]
|
||||
fn overlapping_merges_use_outline_fallback() {
|
||||
let t = Table {
|
||||
row_heights: vec![1.0; 40],
|
||||
col_widths: vec![1.0; 40],
|
||||
cells: vec![vec![TableCell { merged: Some((40, 40)), ..Default::default() }; 40]; 40],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(coverage(&t).is_err());
|
||||
let g = layout(&Drawing::default(), &t);
|
||||
assert_eq!(g.lines.len(), 4);
|
||||
assert!(g.fills.is_empty() && g.texts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn part_clips_merges_using_displayed_column_count() {
|
||||
let t = Table {
|
||||
row_heights: vec![1.0; 102],
|
||||
col_widths: vec![1.0; 10_001],
|
||||
cells: vec![vec![TableCell { merged: Some((100, 1)), ..Default::default() }]],
|
||||
..Default::default()
|
||||
};
|
||||
let mut rows: Vec<_> = (0..99).collect();
|
||||
rows.push(101);
|
||||
let part = part_table(&t, &rows, Vec2::ZERO);
|
||||
assert_eq!(part.cells[0][0].merged, Some((99, 1)));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user