mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 08:29:14 +00:00
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:
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user