Stroke: custom arrowheads cap subpaths, file size and preview size

- A custom arrowhead path allows at most 4,096 subpaths; stroke.set
  refuses a longer one and reading drops it.
- A symbol whose shapes cannot be united or clipped is listed in
  skipped instead of becoming a head that misses part of its art.
- The arrowheads file is refused when its document unpacks to more
  than 64 MB.
- Edit Arrowheads creates the starter file only when none exists and
  otherwise opens the file unchanged.
- After a save to the arrowheads file, the heads are read back from
  the written file.
- The plug-in argument check accepts path under startArrow.Custom and
  endArrow.Custom in stroke.set.
- A custom head's menu preview is scaled to fit its box, and rows
  outside the visible part of the menu do not render previews.
This commit is contained in:
dotCooCoo
2026-11-12 05:00:15 -08:00
parent b42a3d4869
commit 0a3abb8534
12 changed files with 310 additions and 62 deletions
+8 -6
View File
@@ -84,24 +84,26 @@ impl From<CustomArrowheadData> for CustomArrowhead {
}
impl CustomArrowhead {
/// The most anchors a custom head's outline may have.
/// The most anchors a custom head's outline may have, and the most subpaths.
pub const MAX_ANCHORS: usize = 4096;
/// How far from the tip a custom head's outline may reach, in head units.
pub const MAX_UNITS: f64 = 64.0;
/// Head `name` with outline `path` (in head units). An outline with more than
/// [`Self::MAX_ANCHORS`] anchors, a point that is not finite, or a point farther than
/// [`Self::MAX_UNITS`] from the tip is dropped: the head then draws nothing.
/// [`Self::MAX_ANCHORS`] anchors or subpaths, a point that is not finite, or a point farther
/// than [`Self::MAX_UNITS`] from the tip is dropped: the head then draws nothing.
pub fn new(name: String, path: PathData) -> Self {
let path = if Self::fits(&path) { path } else { PathData::default() };
CustomArrowhead { name, path }
}
/// Whether outline `path` is within [`Self::MAX_ANCHORS`] and [`Self::MAX_UNITS`], with finite
/// points only.
/// Whether outline `path` has at most [`Self::MAX_ANCHORS`] anchors and as many subpaths, with
/// finite points within [`Self::MAX_UNITS`] only.
pub fn fits(path: &PathData) -> bool {
let anchors = path.subpaths.iter().map(|s| s.anchors.len()).sum::<usize>();
let near = |p: vectorcraft_geom::Point| p.x.is_finite() && p.y.is_finite() && p.x.abs() <= Self::MAX_UNITS && p.y.abs() <= Self::MAX_UNITS;
anchors <= Self::MAX_ANCHORS && path.subpaths.iter().flat_map(|s| &s.anchors).all(|a| near(a.p) && near(a.h_in) && near(a.h_out))
anchors <= Self::MAX_ANCHORS
&& path.subpaths.len() <= Self::MAX_ANCHORS
&& path.subpaths.iter().flat_map(|s| &s.anchors).all(|a| near(a.p) && near(a.h_in) && near(a.h_out))
}
fn bounds(&self) -> Option<Rect> {
self.path.control_bounds()
+7
View File
@@ -387,6 +387,13 @@ fn custom_arrowheads_out_of_bounds_draw_nothing() {
let anchors = vec![vectorcraft_geom::Anchor::corner(vectorcraft_geom::Point::new(1.0, 0.0)); CustomArrowhead::MAX_ANCHORS + 1];
let many = CustomArrowhead::new("Many".into(), vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::new(anchors, true)));
assert!(many.path.subpaths.is_empty(), "too many anchors");
// Empty subpaths add no anchors, and still count.
let mut hollow = c.path.clone();
hollow.subpaths.extend(std::iter::repeat_n(vectorcraft_geom::SubPath::new(vec![], false), CustomArrowhead::MAX_ANCHORS));
assert!(!CustomArrowhead::fits(&hollow), "too many subpaths");
let json = serde_json::to_value(CustomArrowhead { name: "Hollow".into(), path: hollow }).unwrap();
let hollow: CustomArrowhead = serde_json::from_value(json).unwrap();
assert!(hollow.path.subpaths.is_empty(), "too many subpaths, read back");
}
#[test]
+37 -16
View File
@@ -22,7 +22,8 @@ use super::{CommandSpec, Result, Session, always, bad, cmd};
/// The arrowheads file's name, in the folder of the preferences.
pub const FILE_NAME: &str = "Arrowheads.vectorcraft";
/// The largest arrowheads file read.
/// The largest arrowheads file read, and the most its document or its images may unpack to when
/// compressed.
pub const MAX_FILE_BYTES: u64 = 64 << 20;
/// The most heads read from the file; symbols past it are skipped.
pub const MAX_HEADS: usize = 256;
@@ -121,6 +122,7 @@ pub fn heads_of(doc: &Document) -> Read {
/// Symbol `sym` of `d` as a head.
fn head(d: &mut Document, brushes: &[vectorcraft_brush::Brush], sym: &Symbol) -> std::result::Result<CustomArrowhead, &'static str> {
const TOO_DETAILED: &str = "its art is too long or too detailed for a head";
const UNITED: &str = "its shapes could not be combined into one";
if art_anchors(&sym.art) > MAX_ART_ANCHORS {
return Err(TOO_DETAILED);
}
@@ -130,7 +132,10 @@ fn head(d: &mut Document, brushes: &[vectorcraft_brush::Brush], sym: &Symbol) ->
if shapes.too_detailed {
return Err(TOO_DETAILED);
}
let area = shapes.united();
if shapes.failed {
return Err(UNITED);
}
let area = shapes.united().ok_or(UNITED)?;
let b = area.bounds().filter(|b| b.height() > 1e-9 && b.is_finite()).ok_or("its art paints nothing")?;
let tip = b.center() + d.symbol_registration(&sym.name).offset_from_center(b.width(), b.height());
// Head units: x back from the tip, y across to the right of +x, 4 units to the art's height.
@@ -172,6 +177,8 @@ struct Shapes {
anchors: usize,
/// More anchors were asked for than fit; the shapes past the limit were not made.
too_detailed: bool,
/// A clip group's art could not be combined or clipped, so a shape it paints is missing.
failed: bool,
}
impl Shapes {
@@ -186,9 +193,10 @@ impl Shapes {
self.list.push(shape);
}
}
fn united(&self) -> PathData {
/// The shapes united, or `None` when one of them could not be (the union would miss it).
fn united(&self) -> Option<PathData> {
let refs: Vec<(&PathData, FillRule)> = self.list.iter().map(|(p, r)| (p, *r)).collect();
vectorcraft_pathops::unite_all(&refs)
vectorcraft_pathops::try_unite_all(&refs).ok()
}
/// The shapes of `inside` clipped to `outline` (filled under `rule`), added as one.
fn push_clipped(&mut self, inside: Shapes, outline: &PathData, rule: FillRule) {
@@ -196,7 +204,14 @@ impl Shapes {
self.too_detailed = true;
return;
}
let clipped = vectorcraft_pathops::boolean(&inside.united(), FillRule::NonZero, outline, rule, BoolOp::Intersect);
self.failed |= inside.failed;
let clip = |art: PathData| {
vectorcraft_pathops::try_boolean(&art, FillRule::NonZero, outline, rule, BoolOp::Intersect, vectorcraft_pathops::DEFAULT_PRECISION).ok()
};
let Some(clipped) = inside.united().and_then(clip) else {
self.failed = true;
return;
};
if !clipped.subpaths.is_empty() {
self.push((clipped, FillRule::NonZero));
}
@@ -345,11 +360,11 @@ impl Session {
}
}
/// Document `doc` was saved to `path`: when that is the arrowheads file, its heads are the
/// ones listed now.
pub(crate) fn arrowheads_saved(&mut self, path: &str, doc: &Document) {
/// A document was saved to `path`: when that is the arrowheads file, its heads are read back
/// from what was written.
pub(crate) fn arrowheads_saved(&mut self, path: &str) {
if self.arrowheads.path.as_deref().is_some_and(|a| same_file(a, path)) {
self.arrowheads.set(heads_of(doc));
self.reload_arrowheads();
}
}
}
@@ -371,7 +386,9 @@ fn read(path: &str) -> std::result::Result<Option<Document>, String> {
return Err(format!("{path}: larger than {} MB", MAX_FILE_BYTES >> 20));
}
let bytes = crate::file_access::unconfined(|| super::fileio::read_file(path)).map_err(|e| e.to_string())?;
let parse = || super::fileio::native_file(&bytes).map(|(doc, ..)| doc).map_err(|e| e.to_string());
// A compressed file's document, and its images, may unpack to no more than an uncompressed
// file may hold.
let parse = || vectorcraft_format::load_file_capped(&bytes, MAX_FILE_BYTES).map(|f| f.doc).map_err(|e| format!("{path}: {e}"));
crate::guard::catch_panic(parse).map_err(|_| format!("{path}: could not be read"))?.map(Some)
}
@@ -400,13 +417,17 @@ fn reload(s: &mut Session, _: &Value) -> Result<Value> {
fn edit(s: &mut Session, _: &Value) -> Result<Value> {
const C: &str = "stroke.arrowheads.edit";
let path = s.arrowheads.path.clone().ok_or_else(|| bad(C, "no arrowheads file here"))?;
let created = !std::path::Path::new(&path).exists();
if let Some(dir) = std::path::Path::new(&path).parent() {
std::fs::create_dir_all(dir).map_err(|e| bad(C, format!("{}: {e}", dir.display())))?;
}
// Made only when no file is there, also when another run makes one at the same moment.
let bytes = vectorcraft_format::save_file(&starter());
let created = match crate::file_access::unconfined(|| super::fileio::write_new_file(&path, &bytes)) {
Ok(()) => true,
Err(_) if std::path::Path::new(&path).exists() => false,
Err(e) => return Err(bad(C, e.to_string())),
};
if created {
if let Some(dir) = std::path::Path::new(&path).parent() {
std::fs::create_dir_all(dir).map_err(|e| bad(C, format!("{}: {e}", dir.display())))?;
}
let bytes = vectorcraft_format::save_file(&starter());
crate::file_access::unconfined(|| super::fileio::write_file(&path, &bytes)).map_err(|e| bad(C, e.to_string()))?;
s.reload_arrowheads();
}
// Already open: that document comes to the front.
+3 -3
View File
@@ -1006,7 +1006,7 @@ impl SaveJob {
// The other open documents that place this one show the new version.
let written = self.plan.path.clone().filter(|_| matches!(self.plan.format.id, "vectorcraft" | "template"));
// A save of the arrowheads file lists its heads in the arrowhead menus.
let native = (self.plan.format.id == "vectorcraft").then(|| self.snapshot.clone());
let native = self.plan.format.id == "vectorcraft";
if let Some(st) = s.document_mut(uid) {
self.finish(st);
}
@@ -1014,8 +1014,8 @@ impl SaveJob {
crate::cmd::recovery::forget(s, uid);
}
if let Some(path) = written {
if let Some(doc) = native {
s.arrowheads_saved(&path, &doc);
if native {
s.arrowheads_saved(&path);
}
crate::cmd::links::refresh_placed(s, uid, &path);
}
+33 -7
View File
@@ -121,6 +121,10 @@ pub(crate) const NOT_FILES: &[(&str, &str)] = &[
("addon.cleanup.rejoin", "paths"),
];
/// Commands and the keys from the top of their params down to a key in [`FILE_KEYS`] that, there,
/// names part of the document, not a file: a custom arrowhead's outline in `stroke.set`.
const NESTED_NOT_FILES: &[(&str, &[&str])] = &[("stroke.set", &["startArrow", "Custom", "path"]), ("stroke.set", &["endArrow", "Custom", "path"])];
/// Is command id `id` one a plug-in may never call, from its id alone ([`DENIED_PREFIXES`],
/// [`FILE_WORDS`])?
fn denied_id(id: &str) -> bool {
@@ -184,20 +188,32 @@ pub(crate) fn names_key(text: &str, key: &str) -> bool {
/// Does `args`, the arguments of a call of command `id`, carry a key in [`FILE_KEYS`] at any depth
/// (in objects and in arrays), such as an effect's `textureImage.path`? Only a key [`NOT_FILES`]
/// names for `id`, at the top level, isn't counted. The walk keeps its own stack, so deep
/// arguments can't overflow the thread's.
/// names for `id`, at the top level, and one at the end of a [`NESTED_NOT_FILES`] key list for
/// `id`, aren't counted. The walk keeps its own stack, so deep arguments can't overflow the
/// thread's.
fn names_file(id: &str, args: &Value) -> bool {
let mut stack = vec![(args, true)];
while let Some((v, top)) = stack.pop() {
// Each value, whether it is the params themselves, and the keys of a NESTED_NOT_FILES list
// still to follow below it.
let mut stack: Vec<(&Value, bool, Option<&[&str]>)> = vec![(args, true, None)];
while let Some((v, top, trail)) = stack.pop() {
match v {
Value::Array(a) => stack.extend(a.iter().map(|x| (x, false))),
Value::Array(a) => stack.extend(a.iter().map(|x| (x, false, None))),
Value::Object(o) => {
for (k, x) in o {
let k = k.as_str();
if FILE_KEYS.contains(&k) && !(top && NOT_FILES.contains(&(id, k))) {
let rest = if top {
NESTED_NOT_FILES
.iter()
.find(|(c, keys)| *c == id && keys.first() == Some(&k))
.map(|(_, keys)| keys.get(1..).unwrap_or_default())
} else {
trail.filter(|keys| keys.first() == Some(&k)).map(|keys| keys.get(1..).unwrap_or_default())
};
let document = (top && NOT_FILES.contains(&(id, k))) || rest.is_some_and(<[&str]>::is_empty);
if FILE_KEYS.contains(&k) && !document {
return true;
}
stack.push((x, false));
stack.push((x, false, rest.filter(|keys| !keys.is_empty())));
}
}
_ => {}
@@ -536,6 +552,16 @@ mod tests {
assert!(!names_file("object.setLiveShape", &json!({"path": [[0, 0], [1, 1]]})));
assert!(names_file("object.setLiveShape", &json!({"options": {"path": "/tmp/x"}})));
assert!(names_file("object.move", &json!({"path": [[0, 0]]})));
// A custom arrowhead's outline in stroke.set, only where stroke.set takes one.
let outline = json!({"subpaths": []});
assert!(!names_file("stroke.set", &json!({"endArrow": {"Custom": {"name": "A", "path": outline}}})));
assert!(!names_file("stroke.set", &json!({"startArrow": {"Custom": {"name": "A", "path": outline}}, "endArrow": "Triangle"})));
assert!(names_file("stroke.set", &json!({"endArrow": {"Custom": {"name": "A", "path": outline, "file": "/tmp/x"}}})));
assert!(names_file("stroke.set", &json!({"endArrow": {"Custom": {"path": {"path": "/tmp/x"}}}})));
assert!(names_file("stroke.set", &json!({"endArrow": {"path": "/tmp/x"}})));
assert!(names_file("stroke.set", &json!({"options": {"endArrow": {"Custom": {"path": "/tmp/x"}}}})));
assert!(names_file("stroke.set", &json!({"endArrow": [{"Custom": {"path": "/tmp/x"}}]})));
assert!(names_file("object.move", &json!({"endArrow": {"Custom": {"path": "/tmp/x"}}})));
// Deep nesting is walked without recursion.
let mut deep = json!({"file": "/tmp/x"});
for _ in 0..10_000 {
+2 -2
View File
@@ -22,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Stroke Options",
["Window", "Stroke"],
None,
"{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}",
"{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors, more than 4096 subpaths or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}",
has_doc,
stroke_set
),
@@ -245,7 +245,7 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(
C,
format!(
"{k}: a custom arrowhead's path needs an area to fill and at most {most} anchors, each within {far} head units of its tip"
"{k}: a custom arrowhead's path needs an area to fill and at most {most} anchors and {most} subpaths, each anchor within {far} head units of its tip"
),
));
}
+65
View File
@@ -283,3 +283,68 @@ fn an_unreadable_arrowheads_file_lists_no_heads_and_says_why() {
assert!(bare.execute("stroke.arrowheads.edit", &json!({})).is_err());
assert_eq!(bare.execute("stroke.arrowheads.list", &json!({})).unwrap()["file"], serde_json::Value::Null);
}
#[test]
fn the_heads_listed_after_a_save_are_read_from_the_file_and_editing_keeps_it() {
let dir = folder("saved");
let file = dir.join(arrowheads::FILE_NAME).to_string_lossy().to_string();
let (mut s, _) = session_with_line();
s.attach_arrowheads_file(file.clone());
assert_eq!(s.execute("stroke.arrowheads.edit", &json!({})).unwrap()["created"], true);
// A speck that saving rounds to a point is a head only before the save.
let st = s.doc_mut().unwrap();
add_symbol(Arc::make_mut(&mut st.doc), "Speck", polygon(&[(0.0, 0.0), (0.3, 0.1), (0.0, 0.2)], filled()));
assert!(heads_of(&s.doc().unwrap().doc).heads.iter().any(|h| h.name == "Speck"), "a head before rounding");
s.execute("document.save", &json!({"precision": 0})).unwrap();
let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap();
let names: Vec<&str> = list["arrowheads"].as_array().unwrap().iter().filter_map(|h| h["name"].as_str()).collect();
assert_eq!(names, ["Example Arrowhead"]);
assert_eq!(list["skipped"][0]["name"], "Speck", "{list}");
// Editing again leaves the file as it is.
let saved = std::fs::read(&file).unwrap();
s.execute("document.activate", &json!({"index": 0})).unwrap();
assert_eq!(s.execute("stroke.arrowheads.edit", &json!({})).unwrap()["created"], false);
assert_eq!(std::fs::read(&file).unwrap(), saved, "the file is left as it was");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_arrowheads_file_that_unpacks_past_the_size_limit_is_not_read() {
let dir = folder("unpacked");
let file = dir.join(arrowheads::FILE_NAME);
let mut d = arrowheads::starter();
d.title = "a".repeat(arrowheads::MAX_FILE_BYTES as usize);
let bytes = vectorcraft_format::save_with(&d, &vectorcraft_format::SaveOptions { compress: true, ..Default::default() }).unwrap();
assert!((bytes.len() as u64) < arrowheads::MAX_FILE_BYTES / 16, "compressed small: {}", bytes.len());
std::fs::write(&file, &bytes).unwrap();
let mut s = Session::new();
s.attach_arrowheads_file(file.to_string_lossy().to_string());
let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap();
assert!(list["arrowheads"].as_array().unwrap().is_empty(), "{}", list["arrowheads"]);
assert!(list["error"].is_string(), "{}", list["error"]);
// The same head in a file of an ordinary size is read.
std::fs::write(&file, vectorcraft_format::save_file(&arrowheads::starter())).unwrap();
assert_eq!(s.execute("stroke.arrowheads.reload", &json!({})).unwrap()["count"], 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_symbol_whose_shapes_cannot_be_combined_is_skipped() {
let wedge = || Arc::new(polygon(&[(0.0, -10.0), (20.0, 0.0), (0.0, 10.0)], filled()));
// Too far out for the shape operations to measure: the union would leave it out.
let far = || Arc::new(polygon(&[(0.0, 0.0), (1e200, 0.0), (0.0, 1e200)], filled()));
let mut d = Document::new(100.0, 100.0);
add_symbol(&mut d, "Wedge", (*wedge()).clone());
add_symbol(&mut d, "Broken", Node::group(NodeId(0), vec![wedge(), far()]));
// The same as clipped art.
let mut mask = polygon(&[(0.0, -10.0), (20.0, -10.0), (20.0, 10.0), (0.0, 10.0)], Appearance::default());
if let NodeKind::Path { clipping, .. } = &mut mask.kind {
*clipping = true;
}
add_symbol(&mut d, "Broken Clip", Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(mask), wedge(), far()], clip: true }));
let read = heads_of(&d);
let names: Vec<&str> = read.heads.iter().map(|h| h.name.as_str()).collect();
assert_eq!(names, ["Wedge"]);
let skipped: Vec<&str> = read.skipped.iter().map(|(n, _)| n.as_str()).collect();
assert_eq!(skipped, ["Broken", "Broken Clip"], "{:?}", read.skipped);
}
+37 -1
View File
@@ -504,11 +504,23 @@ pub fn load_file(bytes: &[u8]) -> Result<NativeFile, FormatError> {
load_file_within(bytes, vectorcraft_doc::node::MAX_READ_NODES, vectorcraft_doc::node::MAX_BUFFERED_VALUES)
}
/// [`load_file`] with a compressed file refused when its JSON, or the data after it that the
/// document uses (images, profiles), unpacks to more than `max` bytes (at most [`MAX_JSON`] and
/// [`MAX_BLOBS`]), or, when it lacks the marker after its JSON, when all of it does.
pub fn load_file_capped(bytes: &[u8], max: u64) -> Result<NativeFile, FormatError> {
let file = unpack::Unpacked::within_capped(bytes, max.min(MAX_JSON), max.min(MAX_UNPACKED), max.min(MAX_BLOBS))?;
load_unpacked(file, vectorcraft_doc::node::MAX_READ_NODES, vectorcraft_doc::node::MAX_BUFFERED_VALUES)
}
/// [`load_file`] reading at most `max_nodes` objects and buffering at most `max_values` values
/// before objects' types, so a test can exercise the budgets without documents that large
/// ([`vectorcraft_doc::node::MAX_READ_NODES`], [`vectorcraft_doc::node::MAX_BUFFERED_VALUES`]).
fn load_file_within(bytes: &[u8], max_nodes: usize, max_values: usize) -> Result<NativeFile, FormatError> {
let file = unpack::Unpacked::new(bytes)?;
load_unpacked(unpack::Unpacked::new(bytes)?, max_nodes, max_values)
}
/// The document of `file`, within the budgets of [`load_file_within`].
fn load_unpacked(file: unpack::Unpacked<'_>, max_nodes: usize, max_values: usize) -> Result<NativeFile, FormatError> {
// The JSON reader stops at 128 levels (a document's objects use three each), the paths read
// at `MAX_READ_ANCHORS` anchors together, the document at `MAX_READ_NODES` objects, and the
// fields read before an object's type at `MAX_BUFFERED_VALUES` values. The preview and the
@@ -1186,6 +1198,30 @@ mod tests {
assert_eq!(back.node(id).unwrap().path_data(), d.node(id).unwrap().path_data());
}
/// A capped read refuses a compressed file whose images unpack past the cap, before reading
/// them, and reads the same file under a cap it fits.
#[test]
fn a_capped_load_caps_the_data_after_the_json() {
use std::io::Write as _;
let mut d = Document::new(100.0, 100.0);
let l = d.layers[0].id;
d.images.insert("k".into(), ImageBlob::new("image/png", vec![7; 1 << 16]));
let img = d.alloc_id();
let im = ImageObject { key: "k".into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None };
d.insert(Some(l), 0, Node::new(img, NodeKind::Image(Box::new(im)))).unwrap();
// The file gzipped whole, image and all, so the image unpacks from a few bytes.
let raw = save_with(&d, &SaveOptions::default()).unwrap();
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::best());
gz.write_all(&raw).unwrap();
let bytes = gz.finish().unwrap();
assert!(is_compressed(&bytes) && bytes.len() < 1 << 12, "{}", bytes.len());
let json = unpack::Unpacked::new(&bytes).unwrap().json().len();
assert!(json < 1 << 15, "{json}");
assert!(matches!(load_file_capped(&bytes, (1 << 16) - 1), Err(FormatError::TooBig)));
assert_eq!(load_file_capped(&bytes, 3 << 15).unwrap().doc.images["k"].bytes.len(), 1 << 16);
assert_eq!(load_file(&bytes).unwrap().doc.images["k"].bytes.len(), 1 << 16);
}
/// #3587: objects are read within a budget. A compressed file of many small objects stays
/// small on disk while they take far more memory once read, so past the budget it is refused
/// with a message naming it, before the rest of the document is read.
+41 -13
View File
@@ -28,6 +28,10 @@ pub(crate) type Entry = Option<Arc<Vec<u8>>>;
pub(crate) struct Unpacked<'a> {
json: Json<'a>,
tail: Tail<'a>,
/// Most bytes the entries after the JSON may have together ([`MAX_BLOBS`] unless capped).
max_blobs: u64,
/// Most bytes unpacked to find them in a streamed file ([`MAX_WORK`] unless capped).
max_work: u64,
}
enum Json<'a> {
@@ -68,9 +72,23 @@ impl<'a> Unpacked<'a> {
/// a document several times its size, and the cap is what keeps that off the address space
/// (#3587).
fn within(bytes: &'a [u8], max_json: u64) -> Result<Self, FormatError> {
Self::within_capped(bytes, max_json, MAX_UNPACKED, MAX_BLOBS)
}
/// [`Self::within`], with a compressed file that lacks the marker after its JSON (unpacked
/// whole) capped at `max_whole` bytes instead of [`MAX_UNPACKED`], the entries after the JSON
/// at `max_blobs` bytes together instead of [`MAX_BLOBS`], and the bytes unpacked to find them
/// at `max_json` and `max_blobs` together instead of [`MAX_WORK`].
pub(crate) fn within_capped(bytes: &'a [u8], max_json: u64, max_whole: u64, max_blobs: u64) -> Result<Self, FormatError> {
let (json, tail) = Self::open(bytes, max_json, max_whole)?;
Ok(Self { json, tail, max_blobs, max_work: max_json.saturating_add(max_blobs).min(MAX_WORK) })
}
/// The JSON of `bytes` and where the data after it is, within `max_json` and `max_whole`.
fn open(bytes: &'a [u8], max_json: u64, max_whole: u64) -> Result<(Json<'a>, Tail<'a>), FormatError> {
if !is_compressed(bytes) {
let (json, tail) = split_tail(bytes);
return Ok(Self { json: Json::Borrowed(json), tail: Tail::Slice(tail) });
return Ok((Json::Borrowed(json), Tail::Slice(tail)));
}
let mut dec = GzDecoder::new(bytes);
let mut buf = Vec::new();
@@ -93,7 +111,7 @@ impl<'a> Unpacked<'a> {
if buf.len() as u64 > max_json {
return Err(FormatError::TooBig);
}
return Ok(Self { json: Json::Owned(buf), tail: Tail::None });
return Ok((Json::Owned(buf), Tail::None));
}
// The marker where the writer puts it, right after the JSON's last byte: what is unpacked
// so far reaches past it.
@@ -104,17 +122,17 @@ impl<'a> Unpacked<'a> {
let read = buf.split_off(end + BLOB_MAGIC.len());
buf.truncate(end);
let at = (end + BLOB_MAGIC.len()) as u64;
return Ok(Self { json: Json::Owned(buf), tail: Tail::Stream { file: bytes, at, read, rest: Box::new(dec) } });
return Ok((Json::Owned(buf), Tail::Stream { file: bytes, at, read, rest: Box::new(dec) }));
}
// Anything else (hand-made or damaged files) is unpacked whole and split as an
// uncompressed file is.
dec.take(MAX_UNPACKED + 1 - (buf.len() as u64).min(MAX_UNPACKED)).read_to_end(&mut buf).map_err(damaged)?;
if buf.len() as u64 > MAX_UNPACKED {
dec.take(max_whole + 1 - (buf.len() as u64).min(max_whole)).read_to_end(&mut buf).map_err(damaged)?;
if buf.len() as u64 > max_whole {
return Err(FormatError::TooBig);
}
let (json, tail) = split_tail(&buf);
let (json, tail) = (json.len(), buf.len() - tail.len());
Ok(Self { json: Json::Whole { buf, json }, tail: Tail::Whole(tail) })
Ok((Json::Whole { buf, json }, Tail::Whole(tail)))
}
/// The JSON text.
@@ -133,13 +151,16 @@ impl<'a> Unpacked<'a> {
/// of an uncompressed file larger together than both [`MAX_BLOBS`] and the data, or the
/// distinct entries found larger together than the data they are in (overlapping entries
/// would copy the same bytes once each, #3574). Entries are read in the order of their
/// offsets, each straight into its own buffer.
/// offsets, each straight into its own buffer. A capped file ([`Self::within_capped`]) takes
/// its own totals in place of [`MAX_BLOBS`] and [`MAX_WORK`].
pub(crate) fn entries(self, wanted: &[[u64; 2]]) -> Result<Vec<Entry>, FormatError> {
self.entries_within(wanted, MAX_WORK)
let work = self.max_work;
self.entries_within(wanted, work)
}
/// [`Self::entries`], unpacking at most `work` bytes of a streamed file to find them.
fn entries_within(self, wanted: &[[u64; 2]], work: u64) -> Result<Vec<Entry>, FormatError> {
let max_blobs = self.max_blobs;
let mut out: Vec<Entry> = vec![None; wanted.len()];
// Data in memory is read through the crate's `Entries`: entries naming the same bytes
// share them, and the bytes copied stay within the data.
@@ -155,11 +176,11 @@ impl<'a> Unpacked<'a> {
};
match self.tail {
Tail::Slice(tail) => {
within(wanted, u64::MAX, MAX_BLOBS.max(tail.len() as u64))?;
within(wanted, u64::MAX, max_blobs.max(tail.len() as u64))?;
in_memory(tail, &mut out)?;
}
Tail::Whole(at) => {
within(wanted, MAX_BLOB, MAX_BLOBS)?;
within(wanted, MAX_BLOB, max_blobs)?;
let buf = match &self.json {
Json::Whole { buf, .. } => buf.as_slice(),
_ => &[],
@@ -168,7 +189,7 @@ impl<'a> Unpacked<'a> {
}
Tail::None => in_memory(&[], &mut out)?,
Tail::Stream { file, at, read, rest } => {
within(wanted, MAX_BLOB, MAX_BLOBS)?;
within(wanted, MAX_BLOB, max_blobs)?;
drop(self.json);
// The entries read so far, by `[offset, length]`, and the bytes copied for them.
let mut done: HashMap<[u64; 2], Entry> = HashMap::new();
@@ -194,8 +215,8 @@ impl<'a> Unpacked<'a> {
done.insert(w, slot.clone());
}
// To the end, so the stream's checksum is verified as before.
s.left = MAX_BLOBS;
s.skip(MAX_BLOBS)?;
s.left = max_blobs;
s.skip(max_blobs)?;
}
}
Ok(out)
@@ -317,6 +338,13 @@ mod tests {
// An offset past the data is still just missing, and the default budget reads all of it.
assert_eq!(read(&[[1 << 40, 1]], 1 << 20).unwrap(), vec![None]);
assert_eq!(Unpacked::new(&file).unwrap().entries(&[[0, 4096]]).unwrap(), vec![Some(Arc::new(tail.clone()))]);
// A capped file finds its entries within its own caps: a small entry far into the data,
// or entries larger than the cap together, are refused.
let capped = || Unpacked::within_capped(&file, 64, MAX_UNPACKED, 16).unwrap();
assert_eq!(capped().entries(&[[0, 16]]).unwrap(), vec![Some(Arc::new(tail[..16].to_vec()))]);
assert!(matches!(capped().entries(&[[4000, 1]]), Err(FormatError::TooBig)));
assert!(matches!(capped().entries(&[[0, 17]]), Err(FormatError::TooBig)));
assert_eq!(Unpacked::new(&file).unwrap().entries(&[[4000, 1]]).unwrap(), vec![Some(Arc::new(vec![tail[4000]]))]);
}
/// #3587: a compressed file's JSON is capped wherever the cap runs, so a small gzip that
+34 -9
View File
@@ -367,22 +367,44 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
/// The document an arrowhead preview renders: a `w`×`h` pt line with head `a` at its left
/// (`start`) or right end, its tip on the end point, drawn white (the preview is tinted).
pub(super) fn arrow_doc(a: Option<Arrowhead>, start: bool, w: f64, h: f64) -> Option<Document> {
let mut doc = Document::new(w, h);
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::WHITE), 1.5);
let st = ap.stroke_mut()?;
/// (`start`) or right end, its tip on the end point, drawn white (the preview is tinted). A custom
/// head is made smaller than the built-in ones when it would not fit (below the smallest head
/// weight, its outline is scaled down), and the line's end moves in by as much as the head reaches
/// past its tip.
pub(super) fn arrow_doc(mut a: Option<Arrowhead>, start: bool, w: f64, h: f64) -> Option<Document> {
const WIDTH: f64 = 1.5;
// The smallest weight `StrokeLayer::arrow_weight` gives a head.
const LEAST: f64 = 0.25;
// Heads of weight 2.5 pt: 10 pt long and wide.
st.arrow_scale = (500.0 / 3.0, 500.0 / 3.0);
let mut scale: f64 = 500.0 / 3.0;
let mut inset = 0.0;
if let Some(Arrowhead::Custom(c)) = &a
&& let Some(b) = c.path.control_bounds()
{
let (long, half) = (b.x1.max(0.0) - b.x0.min(0.0), b.y0.abs().max(b.y1.abs()));
let fit = |room: f64, units: f64| if units > 1e-9 { room.max(0.0) / units } else { f64::INFINITY };
let weight = fit(w - 6.0, long).min(fit(h / 2.0 - 1.0, half)).min(2.5);
inset = -b.x0.min(0.0) * weight;
if weight < LEAST {
let path = c.path.transformed(vectorcraft_geom::Affine::scale(weight / LEAST));
a = Some(Arrowhead::Custom(std::sync::Arc::new(vectorcraft_doc::CustomArrowhead::new(c.name.clone(), path))));
}
scale = scale.min(weight.max(LEAST) / WIDTH * 100.0);
}
let mut doc = Document::new(w, h);
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::WHITE), WIDTH);
let st = ap.stroke_mut()?;
st.arrow_scale = (scale, scale);
st.arrow_align = ArrowAlign::Tip;
if start {
st.start_arrow = a
} else {
st.end_arrow = a
}
let (x0, x1) = if start { (3.0 + inset, w - 3.0) } else { (3.0, w - 3.0 - inset) };
let mut bp = vectorcraft_geom::BezPath::new();
bp.move_to((3.0, h / 2.0));
bp.line_to((w - 3.0, h / 2.0));
bp.move_to((x0, h / 2.0));
bp.line_to((x1, h / 2.0));
let (id, l) = (doc.alloc_id(), doc.layers[0].id);
doc.insert(Some(l), 0, Node::path(id, vectorcraft_geom::PathData::from_bezpath(&bp), ap)).ok()?;
Some(doc)
@@ -419,7 +441,10 @@ fn arrow_dropdown(ui: &mut Ui, id: &str, cur: Option<&Arrowhead>, custom: &[Arro
} else if rr.hovered() {
ui.painter().rect_filled(row, 0.0, t.hover);
}
paint_arrow(ui, Rect::from_min_size(row.min + vec2(4.0, 0.0), vec2(56.0, 22.0)), a, start, t.text_strong);
// Only the rows in view render their previews.
if ui.is_rect_visible(row) {
paint_arrow(ui, Rect::from_min_size(row.min + vec2(4.0, 0.0), vec2(56.0, 22.0)), a, start, t.text_strong);
}
ui.painter().text(
row.left_center() + vec2(66.0, 0.0),
egui::Align2::LEFT_CENTER,
@@ -111,6 +111,43 @@ fn every_arrowhead_preview_draws_its_head() {
assert!(ink(Some(custom), true) > plain.1 + 20);
}
#[test]
fn a_long_custom_arrowhead_preview_fits_in_its_box() {
use vectorcraft_doc::Arrowhead;
// 40 units long and 4 wide, reaching 4 units past its tip.
let pts = [(-4.0, -2.0), (36.0, -2.0), (36.0, 2.0), (-4.0, 2.0)].map(|(x, y)| vectorcraft_geom::Point::new(x, y));
let bar = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&pts, true));
// 120 units across: smaller than the smallest head weight can draw it.
let pts = [(0.0, -60.0), (4.0, -60.0), (4.0, 60.0), (0.0, 60.0)].map(|(x, y)| vectorcraft_geom::Point::new(x, y));
let wall = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&pts, true));
let heads = [bar, wall].map(|p| Arrowhead::Custom(std::sync::Arc::new(vectorcraft_doc::CustomArrowhead::new("Bar".into(), p))));
for (head, start) in heads.iter().flat_map(|h| [(h, false), (h, true)]) {
let d = stroke::arrow_doc(Some(head.clone()), start, 56.0, 22.0).unwrap();
// Rendered well beyond the 56 × 22 pt box, at 1 pixel a point: all the ink is inside it.
let img = vectorcraft_render::Renderer::new().render_region(&d, vectorcraft_geom::Rect::new(-100.0, -50.0, 156.0, 72.0), 1.0, false);
let ink: Vec<(u32, u32)> = (0..256).flat_map(|x| (0..122).map(move |y| (x, y))).filter(|&(x, y)| img.pixel(x, y)[3] > 64).collect();
assert!(ink.len() > 40, "start {start}: the line and head draw");
let outside: Vec<&(u32, u32)> = ink.iter().filter(|&&(x, y)| !(100..156).contains(&x) || !(50..72).contains(&y)).collect();
assert!(outside.is_empty(), "start {start}: {} pixels outside the box, e.g. {:?}", outside.len(), outside.first());
}
}
#[test]
fn the_arrowhead_menu_renders_only_the_previews_in_view() {
let ctx = egui::Context::default();
let mut app = app_with_line();
let t = frame_events(&ctx, &mut app, vec![], stroke::show);
let label = text_rect(&t, "Arrowheads:");
let sp = ctx.global_style().spacing.item_spacing.x;
click(&ctx, &mut app, egui::pos2(label.right() + 2.0 * sp + 64.0 + 32.0, label.center().y), stroke::show);
frame_events(&ctx, &mut app, vec![], stroke::show);
frame_events(&ctx, &mut app, vec![], stroke::show);
let previews = ctx.tex_manager().read().allocated().filter(|(_, m)| m.name.contains(":arrow:") && m.name.ends_with(":false")).count();
// The button's and the rows' in the 360 pt list, not one for each of the menu's heads.
let rows = vectorcraft_doc::Arrowhead::ALL.len() + 1;
assert!(previews > 5 && previews < rows / 2 + 5, "{previews} previews for {rows} rows");
}
#[test]
fn arrowhead_dropdown_offers_the_new_heads() {
let ctx = egui::Context::default();
+6 -5
View File
@@ -3960,17 +3960,18 @@ right, the symbol's registration point is its tip, and the art's height is drawn
head weights). The visible fills and the outlined visible strokes of its paths, compound paths and groups, and its
gradient meshes, make the head, with geometry effects applied and clip groups clipped, and the line stops halfway along its length. A head is
one solid shape in the stroke's paint: art at 0 % opacity is left out, and other opacities, blend modes and opacity
masks do not change the shape. The file is read when the app starts and again each time VectorCraft saves it;
`stroke.arrowheads.reload` reads it again. A file larger than 64 MB is not read, at most 256 symbols are read, and a
symbol that paints nothing, reaches more than 64 head weights from its tip, has more than 65,536 anchors in its art or
in the shapes it paints, or has more than 4,096 anchors once united is listed in `skipped`. `stroke.arrowheads.list` returns each head's `name`, `reach` and `halfWidth`, the `file`
masks do not change the shape. The file is read when the app starts and again from disk each time VectorCraft saves it;
`stroke.arrowheads.reload` reads it again. A file larger than 64 MB, or whose document or images unpack to more than 64 MB, is
not read, at most 256 symbols are read, and a symbol that paints nothing, reaches more than 64 head weights from its
tip, has more than 65,536 anchors in its art or in the shapes it paints, has shapes that cannot be combined into one,
or has more than 4,096 anchors once united is listed in `skipped`. `stroke.arrowheads.list` returns each head's `name`, `reach` and `halfWidth`, the `file`
path, the `skipped` symbols and any read `error`.
`stroke.set {endArrow: {"custom": name}}` puts a custom head on a stroke. The stroke keeps a copy of the head's art,
so the document draws it, saves it and exports it to SVG and PDF the same way without the arrowheads file.
`document.inspect` reports such a head as `{"Custom": {name, path}}`, which `stroke.set` takes back unchanged. It
refuses a `path` that fills no area (non-zero rule; parts thinner than a millionth of a head unit count as none) or
has more than 4,096 anchors or a point more than 64 head units from the tip. The help page
has more than 4,096 anchors, more than 4,096 subpaths or a point more than 64 head units from the tip. The help page
[Apply stroke on an object](https://helpx.adobe.com/illustrator/using/stroke-object.html) describes defining custom
arrowheads in a separate arrowheads file.