EMF export: pattern tiles stop at 250,000 in all

One pattern fill tiles at most 250,000 times (PatternDef::offsets_covering),
but Scene::fill writes each tile's own pattern fills again, down to the
nesting limit of 8, with nothing bounding the total. A pattern whose tile
art is filled with another pattern (or with itself) multiplies the tile
count at every level: a small document makes EMF export (the engine's
"emf" format, also reachable over MCP and the control channel) run out
of memory or never finish, and the unsaved document is lost with it.

The scene now keeps one tile budget for the whole walk. A fill whose
tiles don't fit in what is left, and every pattern fill after it, is
written as one colour like WMF writes patterns, with a warning.

The test tiles squares filled with dots (over half a million fills before)
and a pattern filled with itself, and checks both stay under the budget.

Reported-by: BS.ehlas.cz
This commit is contained in:
Martin Vlach
2026-10-20 14:26:00 +02:00
committed by Matej Vrzala
parent 9e7929ee89
commit dd7991cb20
2 changed files with 57 additions and 7 deletions
+23 -6
View File
@@ -17,6 +17,9 @@ use crate::dib::Rgba;
/// How deep symbols, brush art and pattern tiles may nest in one another (deeper art is left out).
const MAX_NEST: u32 = 8;
/// Most pattern tiles written in all. One fill tiles at most 250,000 (`PatternDef::offsets_covering`),
/// but a tile whose art is filled with a pattern again multiplies that at every level of nesting.
const MAX_TILES: usize = 250_000;
/// Most pixels along a side of a gradient's image (its colours are smooth).
const MAX_GRADIENT_PX: f64 = 512.0;
/// Opacity at or above this is written as opaque.
@@ -29,6 +32,7 @@ const RASTER_FX: &str = "raster effects (shadows, glows, blurs, feathering) are
const CLIPPING: &str = "clipping masks are not applied: the art they clip is written whole";
const FLAT_GRADIENTS: &str = "gradients are written as one colour, the average of their stops";
const FLAT_PATTERNS: &str = "pattern fills are written as one colour";
const TOO_MANY_TILES: &str = "pattern fills past 250,000 tiles in all are written as one colour";
/// A stroke as a pen draws it.
#[derive(Clone, Debug, PartialEq)]
@@ -115,6 +119,8 @@ pub(crate) struct Scene<'a> {
opacity: f32,
/// Symbols, brush art and pattern tiles being written inside one another.
nest: u32,
/// Pattern tiles left to write ([`MAX_TILES`]).
tiles_left: usize,
}
impl<'a> Scene<'a> {
@@ -130,6 +136,7 @@ impl<'a> Scene<'a> {
brushes: None,
opacity: 1.0,
nest: 0,
tiles_left: MAX_TILES,
}
}
@@ -296,9 +303,16 @@ impl<'a> Scene<'a> {
match paint {
Paint::None => {}
Paint::Gradient(g) if self.clips => self.gradient(bp, rule, g, opacity, bounds),
Paint::Pattern { pattern, xf } if self.clips && self.nest < MAX_NEST && self.doc.pattern(pattern).is_some() => {
Paint::Pattern { pattern, xf } if self.clips && self.nest < MAX_NEST && self.tiles_left > 0 && self.doc.pattern(pattern).is_some() => {
let area = bp.bounding_box().intersect(self.region);
let tiles = self.doc.pattern(pattern).map(|def| def.instances_in(*xf, area)).unwrap_or_default();
if tiles.len() > self.tiles_left {
self.tiles_left = 0;
self.warn(TOO_MANY_TILES);
self.flat_fill(bp, rule, paint, opacity);
return;
}
self.tiles_left -= tiles.len();
let outer = self.opacity;
self.opacity *= opacity.clamp(0.0, 1.0);
self.ops.push(Op::Clip { path: bp.clone(), rule });
@@ -308,14 +322,17 @@ impl<'a> Scene<'a> {
self.ops.push(Op::Unclip);
self.opacity = outer;
}
p => {
let Some(rgb) = self.flat(p) else { return };
self.check_opacity(opacity);
self.ops.push(Op::Fill { path: bp.clone(), rule, rgb });
}
p => self.flat_fill(bp, rule, p, opacity),
}
}
/// Fill `bp` with one colour for `paint` at `opacity`.
fn flat_fill(&mut self, bp: &BezPath, rule: FillRule, paint: &Paint, opacity: f32) {
let Some(rgb) = self.flat(paint) else { return };
self.check_opacity(opacity);
self.ops.push(Op::Fill { path: bp.clone(), rule, rgb });
}
/// One colour for a paint: a solid colour, the average of a gradient's stops, or a pattern's
/// first solid fill.
fn flat(&mut self, p: &Paint) -> Option<[u8; 3]> {
+34 -1
View File
@@ -4,7 +4,9 @@
use std::sync::Arc;
use vectorcraft_color::{Color, Gradient, GradientPaint, Paint};
use vectorcraft_doc::{Appearance, CharStyle, Dash, Document, ImageBlob, ImageObject, LineCap, LineJoin, Node, NodeId, NodeKind, TextObject};
use vectorcraft_doc::{
Appearance, CharStyle, Dash, Document, ImageBlob, ImageObject, LineCap, LineJoin, Node, NodeId, NodeKind, PatternDef, TextObject,
};
use vectorcraft_geom::{Affine, PathData, Point, Rect, shapes};
use crate::*;
@@ -340,3 +342,34 @@ fn an_instance_whose_art_reaches_the_artboard_is_written() {
let k = kinds(&emf_records(&emf(&d).bytes));
assert!(k.contains(&39), "a brush fills the art: {k:?}");
}
/// A pattern `name` repeating `art` every point.
fn point_tiles(name: &str, art: Node) -> PatternDef {
let mut def = PatternDef::new(name, vec![Arc::new(art)]);
def.tile = Rect::new(0.0, 0.0, 1.0, 1.0);
def
}
fn pattern(name: &str) -> Paint {
Paint::Pattern { pattern: name.into(), xf: Affine::IDENTITY }
}
#[test]
fn patterns_inside_pattern_tiles_share_one_tile_budget() {
// About 3,700 tiles of a 12 pt square, each filled with about 140 tiles of a dot: over half a
// million fills when every tile is written.
let square = path(shapes::rectangle(Rect::new(0.0, 0.0, 12.0, 12.0)), pattern("dots"), Paint::None, 0.0);
let mut d = doc_with(50.0, 50.0, vec![path(shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), pattern("squares"), Paint::None, 0.0)]);
d.patterns.push(point_tiles("dots", path(shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), red(), Paint::None, 0.0)));
d.patterns.push(point_tiles("squares", square));
let (ops, warnings) = crate::scene::Scene::new(&d, d.artboards[0].rect, true).run();
assert!(ops.len() < 300_000, "{} operations", ops.len());
assert!(warnings.iter().any(|w| w.contains("one colour")), "{warnings:?}");
// A tile filled with its own pattern multiplies at every level down to the nesting limit.
let square = path(shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 4.0)), pattern("self"), Paint::None, 0.0);
let mut d = doc_with(10.0, 10.0, vec![path(shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), pattern("self"), Paint::None, 0.0)]);
d.patterns.push(point_tiles("self", square));
let (ops, _) = crate::scene::Scene::new(&d, d.artboards[0].rect, true).run();
assert!(ops.len() < 300_000, "{} operations", ops.len());
}