mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 23:35:10 +00:00
Merge remote-tracking branch 'upstream/main' into feat-6304-font-height-icons
# Conflicts: # docs/target-app-parity.md
This commit is contained in:
@@ -424,25 +424,45 @@ fn a_graphic_style_library_in_a_ai_file_without_layers_loads_its_styles() {
|
||||
/BasicFilter :\n(Fill Style Filter) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n/FillStyle : 0 O\n{xa}0 1 Xd\n/Def ;\n (FillStyle) ,\n; /Dict ;\n /Part ,\n;\n"
|
||||
)
|
||||
};
|
||||
// Styles of one paint (`/SimpleStyle`): a red 2 pt stroke and no fill, and a gradient fill,
|
||||
// which isn't read, so it is left out rather than listed with no paint.
|
||||
let one_paint = |name: &str, paint: &str| format!("/KnownStyle :\n({name}) /Name ,\n/SimpleStyle :\n{paint}/Paint ;\n/Def ;\n");
|
||||
let styles = format!(
|
||||
"%AI9_BeginArtStyles\n{}{}{}%AI9_EndArtStyles\n%AI9_BeginArtStyleList\n(Red)\n(Green)\n(Blue)\n%AI9_EndArtStyleList\n",
|
||||
"%AI9_BeginArtStyles\n{}{}{}{}{}%AI9_EndArtStyles\n\
|
||||
%AI9_BeginArtStyleList\n(Red)\n(Green)\n(Blue)\n(Red outline)\n(Sky fill)\n%AI9_EndArtStyleList\n",
|
||||
style("Blue", "0 0 0 0 0 0 1 Xa\n"),
|
||||
style("Green", "0 0 0 0 0 1 0 Xa\n"),
|
||||
style("Red", "0 0 0 0 1 0 0 Xa\n")
|
||||
style("Red", "0 0 0 0 1 0 0 Xa\n"),
|
||||
one_paint("Red outline", "0 R\n0 1 1 0 K\n0 J 0 j 2 w 10 M []0 d\n1 0 Xd\n"),
|
||||
one_paint("Sky fill", "0 O\n0 Bb\n2 (Sky) 0 0 0 100 1 0 0 1 0 0 Bg\n0 BB\n0 1 Xd\n")
|
||||
);
|
||||
let data = ai::editing_data(200.0, 100.0, &styles);
|
||||
let file = ai::ai(&compressed(data.as_bytes()), "1 0 0 rg 10 50 30 40 re f");
|
||||
let mut s = Session::new();
|
||||
let p = json!({"dataBase64": vectorcraft_format::base64_encode(&file), "name": "fills.ai"});
|
||||
let r = s.execute("graphicStyle.loadLibrary", &p).unwrap();
|
||||
assert_eq!((&r["name"], &r["count"]), (&json!("fills"), &json!(3)), "{r}");
|
||||
let lib = s.execute("graphicStyle.library", &json!({"library": r["library"]})).unwrap();
|
||||
assert_eq!((&r["name"], &r["count"]), (&json!("fills"), &json!(4)), "{r}");
|
||||
let library = r["library"].clone();
|
||||
let lib = s.execute("graphicStyle.library", &json!({"library": library})).unwrap();
|
||||
let names: Vec<&str> = lib["styles"].as_array().unwrap().iter().map(|g| g["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(names, ["Red", "Green", "Blue"], "{lib}");
|
||||
assert_eq!(names, ["Red", "Green", "Blue", "Red outline"], "{lib}");
|
||||
// The outline style strokes a rectangle red, 2 pt wide, and leaves it unfilled.
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
let id = NodeId(s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap());
|
||||
let r = s.execute("graphicStyle.addFromLibrary", &json!({"library": library, "names": ["Red outline"], "apply": true, "ids": [id.0]})).unwrap();
|
||||
assert_eq!(r["added"], json!(["Red outline"]), "{r}");
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let look = &doc.node(id).unwrap().appearance;
|
||||
assert!(look.fill().is_none(), "{look:?}");
|
||||
let strokes: Vec<_> = look.items.iter().filter(|i| matches!(i, vectorcraft_doc::AppearanceItem::Stroke(_))).collect();
|
||||
assert_eq!(strokes.len(), 1, "{look:?}");
|
||||
let stroke = look.stroke().unwrap();
|
||||
assert_eq!((stroke.paint.color(), stroke.width), (Some(vectorcraft_color::Color::cmyk(0.0, 1.0, 1.0, 0.0)), 2.0), "{look:?}");
|
||||
assert_eq!(doc.graphic_style("Red outline").map(|g| &g.appearance), Some(&**look));
|
||||
let r = open(&mut s, "fills.ai", &file, json!({})).unwrap();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let styles: Vec<(&str, u32)> = doc.graphic_styles.iter().map(|g| (g.name.as_str(), g.id)).collect();
|
||||
assert_eq!(styles, [("Red", 1), ("Green", 2), ("Blue", 3)], "{r}");
|
||||
assert_eq!(styles, [("Red", 1), ("Green", 2), ("Blue", 3), ("Red outline", 4)], "{r}");
|
||||
}
|
||||
|
||||
/// A `.ai` symbol library (symbols and their list, no layers) opens with its symbols, and Other
|
||||
|
||||
@@ -2009,15 +2009,21 @@ fn status_rank(status: &str) -> usize {
|
||||
/// holds them) or overlap an object that is, as Show DCS Transparency Interactions marks them.
|
||||
fn dcs_transparency(d: &Document) -> BTreeSet<NodeId> {
|
||||
/// Each visible DCS image's id, bounds and whether it is transparent, and the bounds of each
|
||||
/// other visible object with its own transparency.
|
||||
fn walk(d: &Document, n: &Node, held: bool, images: &mut Vec<(NodeId, Rect, bool)>, see_through: &mut Vec<Rect>) {
|
||||
/// other visible object with its own transparency; bounds are cut to `clip`, the bounds of the
|
||||
/// clipping paths of the clip groups and layers holding the object.
|
||||
fn walk(d: &Document, n: &Node, held: bool, clip: Option<Rect>, images: &mut Vec<(NodeId, Rect, bool)>, see_through: &mut Vec<Rect>) {
|
||||
if !n.visible {
|
||||
return;
|
||||
}
|
||||
let own = n.shows_own_transparency();
|
||||
// The part of the object that shows through `clip`; none when it is clipped away.
|
||||
let shown = n.geometric_bounds().and_then(|b| match clip {
|
||||
Some(c) => Some(c.intersect(b)).filter(|i| i.area() > 0.0),
|
||||
None => Some(b),
|
||||
});
|
||||
match &n.kind {
|
||||
NodeKind::Image(im) if d.images.get(&im.key).is_some_and(ImageBlob::is_separated) => {
|
||||
if let Some(b) = n.geometric_bounds() {
|
||||
if let Some(b) = shown {
|
||||
images.push((n.id, b, held || own));
|
||||
// A transparent DCS image is an object with its own transparency for the
|
||||
// others it overlaps.
|
||||
@@ -2026,16 +2032,25 @@ fn dcs_transparency(d: &Document) -> BTreeSet<NodeId> {
|
||||
}
|
||||
}
|
||||
}
|
||||
_ if own => see_through.extend(n.geometric_bounds()),
|
||||
_ if own => see_through.extend(shown),
|
||||
_ => {}
|
||||
}
|
||||
for c in n.children().into_iter().flatten() {
|
||||
walk(d, c, held || own, images, see_through);
|
||||
let Some(children) = n.children() else { return };
|
||||
// A clip group's or clipping layer's first child clips the others.
|
||||
let clip = match &n.kind {
|
||||
NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. } => match children.first().and_then(|c| c.geometric_bounds()) {
|
||||
Some(c) => Some(clip.map_or(c, |o| o.intersect(c))),
|
||||
None => clip,
|
||||
},
|
||||
_ => clip,
|
||||
};
|
||||
for c in children {
|
||||
walk(d, c, held || own, clip, images, see_through);
|
||||
}
|
||||
}
|
||||
let (mut images, mut see_through) = (vec![], vec![]);
|
||||
for l in &d.layers {
|
||||
walk(d, l, false, &mut images, &mut see_through);
|
||||
walk(d, l, false, None, &mut images, &mut see_through);
|
||||
}
|
||||
images
|
||||
.into_iter()
|
||||
|
||||
@@ -773,6 +773,88 @@ fn dcs_transparency_marks_transparent_and_overlapped_dcs_images() {
|
||||
assert!(error(&mut s, "links.list", json!({ "dcsTransparency": "yes" })).contains("dcsTransparency"), "a flag that isn't true or false");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dcs_transparency_looks_only_at_the_part_of_an_image_its_clipping_path_shows() {
|
||||
let dir = temp_dir("transparency-clipped");
|
||||
let path = write(&dir, "pic.eps", &two_plates());
|
||||
let mut s = session();
|
||||
let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap();
|
||||
let group = NodeId(r["ids"][0].as_u64().unwrap());
|
||||
let image = image_of(&s, &r["ids"][0]).id;
|
||||
let (clip, all) = {
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let NodeKind::Group { children, clip: true } = &doc.node(group).unwrap().kind else { panic!("a clipping group") };
|
||||
(children[0].geometric_bounds().unwrap(), doc.node(image).unwrap().geometric_bounds().unwrap())
|
||||
};
|
||||
assert!(all.x1 - clip.x1 > 2.0, "the file's clipping path shows the left half: {clip:?} of {all:?}");
|
||||
// A half-transparent square over the part of the image the clipping path hides.
|
||||
let x = (clip.x1 + all.x1) / 2.0 - 1.0;
|
||||
let over = s.execute("shape.rectangle", &json!({"x": x, "y": all.y0, "width": 2, "height": 2})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("transparency.set", &json!({"ids": [over], "opacity": 50})).unwrap();
|
||||
let marked = |s: &mut Session| s.execute("links.list", &json!({ "dcsTransparency": true })).unwrap()["links"][0]["dcsTransparency"] == true;
|
||||
assert!(!marked(&mut s), "the square is over the hidden part only");
|
||||
s.execute("transparency.set", &json!({"ids": [over], "opacity": 100})).unwrap();
|
||||
let shown = s.execute("shape.rectangle", &json!({"x": clip.x0, "y": all.y0, "width": 2, "height": 2})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("transparency.set", &json!({"ids": [shown], "opacity": 50})).unwrap();
|
||||
assert!(marked(&mut s), "a square over the part that shows");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dcs_transparency_skips_a_transparent_object_its_own_clipping_paths_hide() {
|
||||
let dir = temp_dir("transparency-clipped-over");
|
||||
let path = write(&dir, "pic.eps", &two_plates());
|
||||
// A session with the file placed, and the top-left corner of the image, inside the part its
|
||||
// clipping path shows.
|
||||
let placed = || {
|
||||
let mut s = session();
|
||||
let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap();
|
||||
let b = image_of(&s, &r["ids"][0]).geometric_bounds().unwrap();
|
||||
(s, b.x0, b.y0)
|
||||
};
|
||||
let rect = |s: &mut Session, x: f64, y: f64, width: f64| {
|
||||
s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": width, "height": 2})).unwrap()["id"].as_u64().unwrap()
|
||||
};
|
||||
let make = |s: &mut Session, ids: [u64; 2]| {
|
||||
s.execute("select.set", &json!({ "ids": ids })).unwrap();
|
||||
s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].as_u64().unwrap()
|
||||
};
|
||||
let marked = |s: &mut Session| s.execute("links.list", &json!({ "dcsTransparency": true })).unwrap()["links"][0]["dcsTransparency"] == true;
|
||||
// A half-transparent square over the part of the image that shows.
|
||||
let square = |s: &mut Session, x: f64, y: f64| {
|
||||
let id = rect(s, x, y, 2.0);
|
||||
s.execute("transparency.set", &json!({"ids": [id], "opacity": 50})).unwrap();
|
||||
assert!(marked(s), "the square over the image");
|
||||
id
|
||||
};
|
||||
|
||||
// Its own clip group's clipping path is beside it.
|
||||
let (mut s, x, y) = placed();
|
||||
let over = square(&mut s, x, y);
|
||||
let beside = rect(&mut s, x + 5.0, y, 2.0);
|
||||
make(&mut s, [over, beside]);
|
||||
assert!(!marked(&mut s), "a clip group that hides the square");
|
||||
|
||||
// Its layer's clipping path is beside it.
|
||||
let (mut s, x, y) = placed();
|
||||
s.execute("layer.new", &json!({})).unwrap();
|
||||
square(&mut s, x, y);
|
||||
rect(&mut s, x + 5.0, y, 2.0);
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
assert!(s.execute("layer.clippingMask.toggle", &json!({})).unwrap()["clip"].as_bool().unwrap());
|
||||
assert!(!marked(&mut s), "a clipping layer that hides the square");
|
||||
|
||||
// Each of two nested clipping paths shows part of the inner group, and only the inner one
|
||||
// covers the square.
|
||||
let (mut s, x, y) = placed();
|
||||
let over = square(&mut s, x, y);
|
||||
let inner = rect(&mut s, x, y, 40.0);
|
||||
let group = make(&mut s, [over, inner]);
|
||||
assert!(marked(&mut s), "the inner clipping path shows the square");
|
||||
let outer = rect(&mut s, x + 10.0, y, 20.0);
|
||||
make(&mut s, [group, outer]);
|
||||
assert!(!marked(&mut s), "the two clipping paths together hide the square");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dcs_transparency_counts_a_transparent_dcs_image_over_another() {
|
||||
let dir = temp_dir("transparency-two");
|
||||
|
||||
@@ -4318,6 +4318,63 @@ proptest! {
|
||||
survive("style library in an EPS", || vectorcraft_engine::cmd::fileio::load("x.eps", &eps).ok().map(|l| l.doc))?;
|
||||
}
|
||||
|
||||
/// Graphic style libraries whose styles are one paint (`/SimpleStyle`): any paint operators in
|
||||
/// any order, with any operands or none, `Xd` missing or repeated, and the paint left open. The
|
||||
/// library is also loaded in the Graphic Style Libraries panel and its styles applied to artwork.
|
||||
#[test]
|
||||
fn ai_style_libraries_of_one_paint_styles_never_panic(
|
||||
// Each operator, and whether it is a paint operator rather than any text.
|
||||
ops in prop::collection::vec(prop_oneof![
|
||||
prop::sample::select(vec![
|
||||
"0 R", "0 O", "0 1 1 0 K", "1 0 0 0 k", "0 0 0 0 1 0 0 Xa", "0 0 0 0 1 0 0 XA", "0.5 G", "0 g", "K", "Xa",
|
||||
"0 J 0 j 2 w 10 M []0 d", "-1 w", "1e400 w", "0 M", "[3 -1]0 d", "[]0 d",
|
||||
"1 0 Xd", "0 1 Xd", "1 1 Xd", "0 0 Xd", "1 Xd", "Xd", "0 1 0 0 0 Xy", "1 0.5 1 1 0 Xy", "Xy",
|
||||
"0 Bb", "2 (Sky) 0 0 0 100 1 0 0 1 0 0 Bg", "0 BB", "(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p",
|
||||
"(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] P", "0 XR",
|
||||
]).prop_map(|op| (op.to_string(), true)),
|
||||
"[ -~]{0,12}".prop_map(|text| (text, false)),
|
||||
], 0..12),
|
||||
closed in any::<bool>(),
|
||||
) {
|
||||
use vectorcraft_testkit::ai;
|
||||
let close = if closed { "/Paint ;\n/Def ;\n" } else { "" };
|
||||
// C, a red 2 pt stroke, comes first, so that it reads whatever A holds.
|
||||
let header = format!(
|
||||
"%AI9_BeginArtStyles\n/KnownStyle :\n(C) /Name ,\n/SimpleStyle :\n0 R\n0 1 1 0 K\n2 w\n1 0 Xd\n/Paint ;\n/Def ;\n\
|
||||
/KnownStyle :\n(A) /Name ,\n/SimpleStyle :\n{}\n{close}/KnownStyle :\n(B) /Name ,\n/SimpleStyle :\n1 0 Xd\n/Paint ;\n/Def ;\n\
|
||||
%AI9_EndArtStyles\n%AI9_BeginArtStyleList\n(A)\n(B)\n(C)\n%AI9_EndArtStyleList\n",
|
||||
ops.iter().map(|(op, _)| op.as_str()).collect::<Vec<_>>().join("\n")
|
||||
);
|
||||
let library = ai::editing_data(200.0, 100.0, &header);
|
||||
survive("one-paint style library", || Some(vectorcraft_eps::layered_ai(library.as_bytes(), Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?;
|
||||
let file = ai::ai(library.as_bytes(), ai::page_pdf());
|
||||
// How many objects C's look reached, what applying it answered, and whether that look is
|
||||
// C's red 2 pt stroke with no fill.
|
||||
let (mut styled, mut applied, mut red_stroke) = (0, Value::Null, false);
|
||||
survive("one-paint styles applied", || {
|
||||
let mut s = rich_session();
|
||||
let p = json!({"dataBase64": vectorcraft_format::base64_encode(&file), "name": "fuzz.ai"});
|
||||
let library = no_panic(s.execute("graphicStyle.loadLibrary", &p)).ok()?["library"].clone();
|
||||
no_panic(s.execute("select.all", &json!({}))).ok()?;
|
||||
// Every style is added and the first one, A when it reads, applied; then C is applied.
|
||||
let _ = no_panic(s.execute("graphicStyle.addFromLibrary", &json!({"library": library, "apply": true})));
|
||||
applied = no_panic(s.execute("graphicStyle.addFromLibrary", &json!({"library": library, "names": ["C"], "apply": true}))).ok()?;
|
||||
let d = (*s.doc().ok()?.doc).clone();
|
||||
if let Some(look) = applied["applied"].as_str().and_then(|name| d.graphic_style(name)).map(|g| &g.appearance) {
|
||||
let strokes = look.items.iter().filter(|i| matches!(i, vectorcraft_doc::AppearanceItem::Stroke(_))).count();
|
||||
red_stroke = look.fill().is_none()
|
||||
&& strokes == 1
|
||||
&& look.stroke().is_some_and(|s| (s.paint.color(), s.width) == (Some(vectorcraft_color::Color::cmyk(0.0, 1.0, 1.0, 0.0)), 2.0));
|
||||
d.walk(|n| styled += usize::from(n.appearance == *look));
|
||||
}
|
||||
Some(d)
|
||||
})?;
|
||||
// Text that isn't a paint operator can leave a string or a comment open over the style list.
|
||||
if ops.iter().all(|(_, paint)| *paint) {
|
||||
prop_assert!(applied["applied"].is_string() && red_stroke && styled > 0, "the stroke style wasn't applied: {applied}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Objects on Path records of any shape: ids in any order, repeated or missing, any number of
|
||||
/// objects, pivots and angles out of range or not finite, spacings, order tags that disagree,
|
||||
/// groups nested or cut short, and versioned content left open.
|
||||
|
||||
@@ -1231,13 +1231,18 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p
|
||||
|
||||
/// A graphic style's appearance from art style `st`, on no object: `None` when the style isn't one
|
||||
/// this reads, or paints with a gradient or a pattern (placed on each object as its drawn look has them).
|
||||
/// A style that is one paint is its fill and stroke ([`crate::import::ai::style::ArtStyle::one_paint`]).
|
||||
pub(super) fn appearance_from_art_style(st: &crate::import::ai::style::ArtStyle) -> Option<Appearance> {
|
||||
use crate::import::ai::style::StyleItem;
|
||||
if !st.plain {
|
||||
return None;
|
||||
}
|
||||
let style_items = match &st.one_paint {
|
||||
Some(one) => one.as_ref().ok()?,
|
||||
None => &st.items,
|
||||
};
|
||||
let mut items = vec![];
|
||||
for (n, item) in st.items.iter().enumerate() {
|
||||
for (n, item) in style_items.iter().enumerate() {
|
||||
let mut layer = match item {
|
||||
StyleItem::Fill(paint) => AppearanceItem::Fill(FillLayer::new(paint.clone())),
|
||||
// A graphic style's brush isn't checked against the file's brushes: it is left out.
|
||||
|
||||
@@ -32,6 +32,10 @@
|
||||
//! gradient (`Bb` … `flag (name) x y angle length … Bg` … `BB`); `stroke fill Xd` says whether it
|
||||
//! paints at all. A blend filter's `mode opacity isolate knockout shape Xy` is its transparency.
|
||||
//!
|
||||
//! A style with no filters is one paint (`/SimpleStyle :` … `/Paint ;` in place of `/ActiveStyle`):
|
||||
//! the fill's and stroke's colors and the stroke's style as an object's own paint operators write
|
||||
//! them, `stroke fill Xd` turning each on, and an `Xy` for its transparency.
|
||||
//!
|
||||
//! A stroke with a Calligraphic or Art brush is its own basic filter, `(Adobe Calligraphic Brush
|
||||
//! Tool)` or `(Adobe ArtOnPath Brush Tool)`, with its stroke's style and the brush's settings for
|
||||
//! the object as entries (see [`super::brush`]).
|
||||
@@ -206,6 +210,11 @@ pub(super) struct ArtStyle {
|
||||
/// paint is a pattern: its [`StyleItem::Pattern`]. A text document's characters name such
|
||||
/// styles for their fills and strokes (`ate`). `None` for any other paint or style.
|
||||
pub simple_pattern: Option<StyleItem>,
|
||||
/// A style that is one paint: its fill and stroke, bottom first, each there when its
|
||||
/// `stroke fill Xd` turns it on (none when neither is on). `Err` when it turns on a fill or
|
||||
/// stroke whose paint isn't read here (a gradient, a pattern, a color that isn't read) or has
|
||||
/// no `Xd`. `None` for a style with filters, whose paint is [`Self::items`].
|
||||
pub one_paint: Option<Result<Vec<StyleItem>, ()>>,
|
||||
}
|
||||
|
||||
impl ArtStyle {
|
||||
@@ -412,12 +421,22 @@ pub(super) fn art_styles_in(data: &[u8], names: &mut Names) -> HashMap<String, A
|
||||
let mut in_basic = false;
|
||||
// A style that is one paint being read, and its pattern so far.
|
||||
let mut simple: Option<Option<StyleItem>> = None;
|
||||
let mut finish = |name: Option<String>, filters: &mut Vec<Filter>, simple_pattern: Option<StyleItem>| {
|
||||
// The same style's paint operators so far.
|
||||
let mut one_paint: Option<OnePaint> = None;
|
||||
let mut finish = |name: Option<String>, filters: &mut Vec<Filter>, simple_pattern: Option<StyleItem>, one_paint: Option<OnePaint>| {
|
||||
if let Some(name) = name {
|
||||
// Only a style that is one paint holds a pattern for type: one with filters as well
|
||||
// paints with more than that.
|
||||
let simple_pattern = simple_pattern.filter(|_| filters.is_empty());
|
||||
styles.insert(name, ArtStyle { simple_pattern, ..style(filters) });
|
||||
let mut st = ArtStyle { simple_pattern, ..style(filters) };
|
||||
if let Some(one) = one_paint.filter(|_| filters.is_empty()) {
|
||||
if let Some((mode, opacity)) = one.paint.xy {
|
||||
let (isolate, knockout) = one.paint.xy_group.unwrap_or((false, Knockout::Off));
|
||||
st.transparency = Some((opacity, super::read::blend_mode(mode), isolate, knockout));
|
||||
}
|
||||
st.one_paint = Some(one.items());
|
||||
}
|
||||
styles.insert(name, st);
|
||||
}
|
||||
filters.clear();
|
||||
};
|
||||
@@ -454,12 +473,12 @@ pub(super) fn art_styles_in(data: &[u8], names: &mut Names) -> HashMap<String, A
|
||||
}
|
||||
Tok::Name(n) => match n {
|
||||
b"KnownStyle" => {
|
||||
finish(name.take(), &mut filters, simple.take().flatten());
|
||||
finish(name.take(), &mut filters, simple.take().flatten(), one_paint.take());
|
||||
operands.clear();
|
||||
open.clear();
|
||||
(pending, in_basic, style_entry, other_entry) = (None, false, None, None);
|
||||
}
|
||||
b"SimpleStyle" if filters.is_empty() => simple = Some(None),
|
||||
b"SimpleStyle" if filters.is_empty() => (simple, one_paint) = (Some(None), Some(OnePaint::default())),
|
||||
b"FillStyle" => style_entry = Some(Kind::Fill),
|
||||
b"StrokeStyle" | b"BlendStyle" => style_entry = Some(Kind::Other),
|
||||
b"Name" if name.is_none() && filters.is_empty() => name = operands.iter().rev().find_map(|v| names.text(v)),
|
||||
@@ -559,7 +578,7 @@ pub(super) fn art_styles_in(data: &[u8], names: &mut Names) -> HashMap<String, A
|
||||
f.repeated |= f.kinds.insert(key, kind).is_some();
|
||||
}
|
||||
}
|
||||
style_paint_op(&mut filters, &mut simple, op, &operands, names);
|
||||
style_paint_op(&mut filters, &mut simple, &mut one_paint, op, &operands, names);
|
||||
if op == "," || op == ";" || op.starts_with('X') || op.starts_with('B') || op.len() <= 2 {
|
||||
operands.clear();
|
||||
}
|
||||
@@ -571,7 +590,7 @@ pub(super) fn art_styles_in(data: &[u8], names: &mut Names) -> HashMap<String, A
|
||||
operands.clear();
|
||||
}
|
||||
}
|
||||
finish(name, &mut filters, simple.take().flatten());
|
||||
finish(name, &mut filters, simple.take().flatten(), one_paint.take());
|
||||
styles
|
||||
}
|
||||
|
||||
@@ -729,13 +748,99 @@ fn pattern_operands(vals: &[V], names: &mut Names) -> Option<(String, Option<[f6
|
||||
}
|
||||
|
||||
/// A paint operator in a style being read: one of the last filter's (`filters`), else one of a style
|
||||
/// that is one paint (`simple`, while one is read).
|
||||
fn style_paint_op(filters: &mut [Filter], simple: &mut Option<Option<StyleItem>>, op: &str, vals: &[V], names: &mut Names) {
|
||||
/// that is one paint (`simple` and `one_paint`, while one is read).
|
||||
fn style_paint_op(
|
||||
filters: &mut [Filter],
|
||||
simple: &mut Option<Option<StyleItem>>,
|
||||
one_paint: &mut Option<OnePaint>,
|
||||
op: &str,
|
||||
vals: &[V],
|
||||
names: &mut Names,
|
||||
) {
|
||||
if let Some(f) = filters.last_mut() {
|
||||
paint_op(f, op, vals, names);
|
||||
} else if let Some(paint) = simple.as_mut() {
|
||||
return;
|
||||
}
|
||||
if let Some(paint) = simple.as_mut() {
|
||||
simple_paint_op(paint, op, vals, names);
|
||||
}
|
||||
if let Some(one) = one_paint.as_mut() {
|
||||
one.op(op, vals, names);
|
||||
}
|
||||
}
|
||||
|
||||
/// The paint operators of a style that is one paint (`/SimpleStyle :` … `/Paint ;`), read as a
|
||||
/// fill's or stroke's style reads them.
|
||||
#[derive(Default)]
|
||||
struct OnePaint {
|
||||
paint: Filter,
|
||||
/// Its `stroke fill Xd` was read.
|
||||
xd: bool,
|
||||
/// Its last fill paint is a pattern (`p`) or may be a gradient (`Bg`).
|
||||
fill_unread: bool,
|
||||
/// Its last stroke paint is a pattern (`P`) or may be a gradient (`Bg`).
|
||||
stroke_unread: bool,
|
||||
/// Inside a gradient instance (`flag Bb` … `BB`): whether it paints the stroke (flag 1).
|
||||
instance: Option<bool>,
|
||||
}
|
||||
|
||||
impl OnePaint {
|
||||
fn op(&mut self, op: &str, vals: &[V], names: &mut Names) {
|
||||
match op {
|
||||
"Xd" => self.xd |= vals.iter().filter_map(V::num).count() >= 2,
|
||||
"p" => self.fill_unread = true,
|
||||
"P" => self.stroke_unread = true,
|
||||
// The flag of the instance it is in says which one it paints, as an object's does
|
||||
// (`Instance::begin` in `paint.rs`); outside one it is read as either one's.
|
||||
"Bb" => self.instance = Some(vals.iter().filter_map(V::num).next_back() == Some(1.0)),
|
||||
"BB" => self.instance = None,
|
||||
"Bg" => match self.instance {
|
||||
Some(true) => self.stroke_unread = true,
|
||||
Some(false) => self.fill_unread = true,
|
||||
None => (self.fill_unread, self.stroke_unread) = (true, true),
|
||||
},
|
||||
// A color replaces what came before it; one that isn't read leaves none.
|
||||
"Xa" | "k" | "g" | "x" | "Xx" | "Xk" | "Xs" | "Xz" | "xa" | "xx" | "xk" | "xs" | "xz" => {
|
||||
(self.fill_unread, self.paint.fill) = (false, None);
|
||||
}
|
||||
"XA" | "K" | "G" | "X" | "XX" | "XK" | "XS" | "XZ" => (self.stroke_unread, self.paint.stroke) = (false, None),
|
||||
_ => {}
|
||||
}
|
||||
paint_op(&mut self.paint, op, vals, names);
|
||||
}
|
||||
|
||||
/// Its fill and stroke ([`ArtStyle::one_paint`]).
|
||||
fn items(&self) -> Result<Vec<StyleItem>, ()> {
|
||||
let f = &self.paint;
|
||||
if !self.xd {
|
||||
return Err(());
|
||||
}
|
||||
let (stroked, filled) = f.paints;
|
||||
let mut items = vec![];
|
||||
if filled {
|
||||
match &f.fill {
|
||||
Some(p) if !self.fill_unread => items.push(StyleItem::Fill(p.clone())),
|
||||
_ => return Err(()),
|
||||
}
|
||||
}
|
||||
if stroked {
|
||||
match &f.stroke {
|
||||
Some(p) if !self.stroke_unread => items.push(StyleItem::Stroke {
|
||||
paint: p.clone(),
|
||||
width: f.width,
|
||||
cap: f.cap,
|
||||
join: f.join,
|
||||
miter: f.miter,
|
||||
dash: f.dash.clone(),
|
||||
profile: None,
|
||||
align: StrokeAlign::Center,
|
||||
brush: None,
|
||||
}),
|
||||
_ => return Err(()),
|
||||
}
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
}
|
||||
|
||||
/// A paint operator of a style that is one paint: a pattern (`p`) is kept as its
|
||||
|
||||
@@ -1512,3 +1512,48 @@ fn only_a_style_of_one_pattern_paint_holds_a_pattern_for_type() {
|
||||
assert!(styles(&body).get("S").is_none_or(|s| s.simple_pattern.is_none()), "{body}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A style that is one paint reads as the fill and stroke its `Xd` turns on, in the paint's colors
|
||||
/// and the stroke's style; one that turns on a paint not read here, or has no `Xd`, isn't read.
|
||||
#[test]
|
||||
fn a_style_of_one_paint_reads_as_the_fill_and_stroke_its_xd_turns_on() {
|
||||
let one = |paint: &str| {
|
||||
let data = format!("/KnownStyle :\n(S) /Name ,\n/SimpleStyle :\n{paint}/Paint ;\n/Def ;\n");
|
||||
art_styles_in(data.as_bytes(), &mut Names::default()).remove("S").and_then(|s| s.one_paint)
|
||||
};
|
||||
let stroke = "0 R\n0 1 1 0 K\n1 J 2 j 3 w 4 M [2 1]0 d\n";
|
||||
let Some(Ok(items)) = one(&format!("0 O\n0 0 1 0 k\n{stroke}1 1 Xd\n")) else { panic!("{:?}", one(stroke)) };
|
||||
let [StyleItem::Fill(fill), StyleItem::Stroke { paint, width, cap, join, miter, dash, profile: None, align: StrokeAlign::Center, brush: None }] =
|
||||
items.as_slice()
|
||||
else {
|
||||
panic!("{items:?}")
|
||||
};
|
||||
assert_eq!((fill.color(), paint.color()), (Some(Color::cmyk(0.0, 0.0, 1.0, 0.0)), Some(Color::cmyk(0.0, 1.0, 1.0, 0.0))));
|
||||
assert_eq!((*width, *cap, *join, *miter), (3.0, LineCap::Round, LineJoin::Bevel, 4.0));
|
||||
assert_eq!(dash.as_ref().map(|d| d.pattern.clone()), Some(vec![2.0, 1.0]));
|
||||
// Only what `Xd` turns on; nothing when it turns on neither.
|
||||
assert!(matches!(one(&format!("0 0 1 0 k\n{stroke}1 0 Xd\n")), Some(Ok(i)) if matches!(i.as_slice(), [StyleItem::Stroke { .. }])));
|
||||
assert_eq!(one(&format!("0 0 1 0 k\n{stroke}0 0 Xd\n")), Some(Ok(vec![])));
|
||||
// The last paint of each one counts: a solid color after a pattern or a gradient replaces it.
|
||||
let solid_fill = |before: &str| one(&format!("{before}\n0 0 1 0 k\n0 1 Xd\n"));
|
||||
for before in ["(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p", "0 Bb\n2 (Sky) 0 0 0 100 1 0 0 1 0 0 Bg\n0 BB"] {
|
||||
assert!(matches!(solid_fill(before), Some(Ok(i)) if matches!(i.as_slice(), [StyleItem::Fill(_)])), "{before}");
|
||||
}
|
||||
let solid_stroke = one("(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] P\n0 1 1 0 K\n1 0 Xd\n");
|
||||
assert!(matches!(solid_stroke, Some(Ok(i)) if matches!(i.as_slice(), [StyleItem::Stroke { .. }])));
|
||||
// A gradient instance's flag says which one it paints: a stroke gradient leaves a solid fill read.
|
||||
let fill_and_stroke_gradient = one("0 0 1 0 k\n1 Bb\n2 (Ramp) 0 0 90 1 1 0 0 1 0 0 1 Bg\n0 BB\n0 1 Xd\n");
|
||||
assert!(matches!(fill_and_stroke_gradient, Some(Ok(i)) if matches!(i.as_slice(), [StyleItem::Fill(_)])));
|
||||
// Not read: no `Xd`, a gradient or pattern fill, a pattern stroke, a gradient stroke after a solid one, a paint
|
||||
// turned on with no color.
|
||||
for paint in [
|
||||
format!("0 0 1 0 k\n{stroke}"),
|
||||
"0 Bb\n2 (Sky) 0 0 0 100 1 0 0 1 0 0 Bg\n0 BB\n0 1 Xd\n".to_string(),
|
||||
"(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p\n0 1 Xd\n".to_string(),
|
||||
"(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] P\n1 0 Xd\n".to_string(),
|
||||
"0 R\n0 1 1 0 K\n1 Bb\n0 0 0 0 Bh\n2 (Ramp) 0 0 90 1 1 0 0 1 0 0 1 Bg\n0 BB\n2 w\n1 0 Xd\n".to_string(),
|
||||
"0 0 1 0 k\n1 1 Xd\n".to_string(),
|
||||
] {
|
||||
assert_eq!(one(&paint), Some(Err(())), "{paint}");
|
||||
}
|
||||
}
|
||||
|
||||
+142
-12
@@ -4,7 +4,10 @@
|
||||
//! 1. **Distance**: an exact Euclidean distance transform in each blob crop gives each pixel
|
||||
//! its distance to the nearest pixel outside the blob.
|
||||
//! 2. **Lines**: an 8-connected component whose widest point (twice its largest distance, less
|
||||
//! one) is within the stroke width is a line; wider ones stay filled areas.
|
||||
//! one) is within the stroke width is a line. A wider one keeps as its area every disc wider
|
||||
//! than the stroke width that fits in it; each piece outside those discs that reaches out of
|
||||
//! them at least one and a half times its own width (and 3 px) is a thin feature of it, and a
|
||||
//! line, whose end at the area goes to the cut, its cap reaching into the area.
|
||||
//! 3. **Skeleton**: Guo–Hall thins the crop to one pixel, preserving diagonal ends, then
|
||||
//! staircase corners that don't connect anything are dropped.
|
||||
//! 4. **Graph**: runs of skeleton pixels between ends and junctions (touching junction pixels
|
||||
@@ -93,20 +96,111 @@ pub(crate) fn lines(
|
||||
cells_left = left;
|
||||
let Some(dt) = distance::squared(&g.on, g.w, g.h) else { continue };
|
||||
let widest = 2.0 * f64::from(dt.iter().copied().fold(0.0f32, f32::max)).sqrt() - 1.0;
|
||||
if widest > max_width {
|
||||
continue;
|
||||
}
|
||||
let Some(line) = trace_line(g, &dt, comp.len(), max_width, img, ink, style) else { continue };
|
||||
for &i in &comp {
|
||||
if let Some(v) = thin.get_mut(i) {
|
||||
*v = true;
|
||||
let found = if widest > max_width {
|
||||
// A wider shape keeps its wide part as an area; its thin features are lines.
|
||||
arm_lines(&g, &dt, w, max_width, img, ink, style, &mut cells_left)
|
||||
} else {
|
||||
trace_line(g, &dt, comp.len(), max_width, img, ink, style, None).map(|line| (comp, line)).into_iter().collect()
|
||||
};
|
||||
for (pixels, line) in found {
|
||||
for &i in &pixels {
|
||||
if let Some(v) = thin.get_mut(i) {
|
||||
*v = true;
|
||||
}
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
(thin, out)
|
||||
}
|
||||
|
||||
/// The thin features of component `g` (wider than `max_width` somewhere; `dt` its distances), traced
|
||||
/// as lines, each with its pixels (indices of the `w`-wide image). A feature that cannot be traced, or
|
||||
/// past the work left in `cells_left`, stays part of the area.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn arm_lines(
|
||||
g: &Grid,
|
||||
dt: &[f32],
|
||||
w: usize,
|
||||
max_width: f64,
|
||||
img: &Raster,
|
||||
ink: [f64; 3],
|
||||
style: &Style,
|
||||
cells_left: &mut usize,
|
||||
) -> Vec<(Vec<usize>, Line)> {
|
||||
let mut out = vec![];
|
||||
for arm in arms(g, dt, w, max_width).unwrap_or_default() {
|
||||
let pixels: Vec<usize> = arm.iter().map(|&(i, _)| i).collect();
|
||||
let Some(ag) = Grid::from_component(&pixels, w) else { continue };
|
||||
let Some(left) = cells_left.checked_sub(ag.w.saturating_mul(ag.h)) else { break };
|
||||
*cells_left = left;
|
||||
let mut cut = vec![false; ag.on.len()];
|
||||
for &(i, at_cut) in &arm {
|
||||
if let Some(c) = ag.index(i, w).and_then(|k| cut.get_mut(k)) {
|
||||
*c = at_cut;
|
||||
}
|
||||
}
|
||||
let Some(adt) = distance::squared(&ag.on, ag.w, ag.h) else { continue };
|
||||
if 2.0 * f64::from(adt.iter().copied().fold(0.0f32, f32::max)).sqrt() - 1.0 > max_width {
|
||||
continue;
|
||||
}
|
||||
if let Some(line) = trace_line(ag, &adt, pixels.len(), max_width, img, ink, style, Some(&cut)) {
|
||||
out.push((pixels, line));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The thin features of component `g`, which is wider than `max_width` somewhere (`dt` its squared
|
||||
/// distances to the paper): the pieces of it that lie outside every disc wider than `max_width`
|
||||
/// that fits in it (the component opened by such a disc) and reach out of those discs at least one
|
||||
/// and a half times their own width (and 3 px). The rest, with the slivers and corners the discs
|
||||
/// leave, is the area. Each piece is a list of its pixels (indices of the `w`-wide image), each
|
||||
/// marked when it touches the area (where the line is cut from it).
|
||||
fn arms(g: &Grid, dt: &[f32], w: usize, max_width: f64) -> Option<Vec<Vec<(usize, bool)>>> {
|
||||
let r = (max_width + 1.0) / 2.0;
|
||||
let seeds: Vec<bool> = dt.iter().map(|&d| f64::from(d) > r * r).collect();
|
||||
let near = distance::to_seeds(&seeds, g.w, g.h)?;
|
||||
drop(seeds);
|
||||
// (Half a pixel more than the discs' radius, so pixels the discs cut through are area.)
|
||||
let cover = r + 0.5;
|
||||
let rest = |i: usize| g.on.get(i) == Some(&true) && near.get(i).is_some_and(|&d| f64::from(d) > cover * cover);
|
||||
let mut seen = vec![false; g.on.len()];
|
||||
let (mut out, mut stack) = (vec![], vec![]);
|
||||
for start in g.interior() {
|
||||
if !rest(start) || seen.get(start).copied().unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let mut piece = vec![];
|
||||
*seen.get_mut(start)? = true;
|
||||
stack.push(start);
|
||||
while let Some(i) = stack.pop() {
|
||||
let mut at_cut = false;
|
||||
for j in g.neighbours(i) {
|
||||
if !rest(j) {
|
||||
at_cut = true;
|
||||
} else if !seen.get(j).copied().unwrap_or(true) {
|
||||
*seen.get_mut(j)? = true;
|
||||
stack.push(j);
|
||||
}
|
||||
}
|
||||
piece.push((i, at_cut));
|
||||
}
|
||||
let deepest = piece.iter().filter_map(|&(i, _)| dt.get(i)).copied().fold(0.0f32, f32::max);
|
||||
let width = (2.0 * f64::from(deepest).sqrt() - 1.0).max(1.0);
|
||||
let farthest = piece.iter().filter_map(|&(i, _)| near.get(i)).copied().fold(0.0f32, f32::max);
|
||||
if f64::from(farthest).sqrt() - cover < (1.5 * width).max(3.0) {
|
||||
continue;
|
||||
}
|
||||
let image = |i: usize| {
|
||||
let (x, y) = (usize::try_from(g.x0 + (i % g.w) as i64).ok()?, usize::try_from(g.y0 + (i / g.w) as i64).ok()?);
|
||||
y.checked_mul(w)?.checked_add(x)
|
||||
};
|
||||
out.push(piece.into_iter().map(|(i, at_cut)| Some((image(i)?, at_cut))).collect::<Option<Vec<_>>>()?);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The 8 neighbours of pixel `i` inside the `w` × `h` image.
|
||||
fn neighbours(i: usize, w: usize, h: usize) -> impl Iterator<Item = usize> {
|
||||
let (x, y) = ((i % w) as i64, (i / w) as i64);
|
||||
@@ -163,6 +257,23 @@ impl Grid {
|
||||
let comp: Vec<_> = mask.iter().enumerate().filter_map(|(i, &on)| on.then_some(i)).collect();
|
||||
Self::from_component(&comp, w)
|
||||
}
|
||||
/// The grid index of pixel `i` of the `image_width`-wide image, when the grid holds it.
|
||||
fn index(&self, i: usize, image_width: usize) -> Option<usize> {
|
||||
let (x, y) = (i64::try_from(i % image_width.max(1)).ok()? - self.x0, i64::try_from(i / image_width.max(1)).ok()? - self.y0);
|
||||
let (x, y) = (usize::try_from(x).ok()?, usize::try_from(y).ok()?);
|
||||
(x < self.w && y < self.h).then(|| y * self.w + x)
|
||||
}
|
||||
/// Whether a pixel `cut` marks (`cut` is laid out as the grid) has its center within `radius` of
|
||||
/// `p` (image coordinates).
|
||||
fn near_cut(&self, cut: &[bool], p: Point, radius: f64) -> bool {
|
||||
if !p.x.is_finite() || !p.y.is_finite() || !radius.is_finite() {
|
||||
return false;
|
||||
}
|
||||
let (cx, cy) = (p.x - self.x0 as f64 - 0.5, p.y - self.y0 as f64 - 0.5);
|
||||
let span = |c: f64, n: usize| ((c - radius).floor().max(0.0) as usize, ((c + radius).ceil().max(0.0) as usize).min(n.saturating_sub(1)));
|
||||
let ((x0, x1), (y0, y1)) = (span(cx, self.w), span(cy, self.h));
|
||||
(y0..=y1).any(|y| (x0..=x1).any(|x| (x as f64 - cx).hypot(y as f64 - cy) <= radius && cut.get(y * self.w + x).copied().unwrap_or(false)))
|
||||
}
|
||||
fn offsets(&self) -> [isize; 8] {
|
||||
let w = self.w as isize;
|
||||
[-w, -w + 1, 1, w + 1, w, w - 1, -1, -w - 1]
|
||||
@@ -193,8 +304,20 @@ impl Grid {
|
||||
}
|
||||
}
|
||||
|
||||
/// The centre lines of one line component (pixel indices of the `w`-wide image).
|
||||
fn trace_line(mut g: Grid, dist: &[f32], pixels: usize, max_width: f64, img: &Raster, ink: [f64; 3], style: &Style) -> Option<Line> {
|
||||
/// The center lines of one line component (pixel indices of the `w`-wide image). `cut`, for a thin
|
||||
/// feature of a wider shape, marks (in `g`) its pixels that touch the shape's area: an end there is
|
||||
/// not an end of the ink, and goes to the cut, its cap reaching into the area.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn trace_line(
|
||||
mut g: Grid,
|
||||
dist: &[f32],
|
||||
pixels: usize,
|
||||
max_width: f64,
|
||||
img: &Raster,
|
||||
ink: [f64; 3],
|
||||
style: &Style,
|
||||
cut: Option<&[bool]>,
|
||||
) -> Option<Line> {
|
||||
thin::thin(&mut g);
|
||||
let mut graph = graph::build(&g, dist)?;
|
||||
graph.simplify(max_width)?;
|
||||
@@ -268,7 +391,9 @@ fn trace_line(mut g: Grid, dist: &[f32], pixels: usize, max_width: f64, img: &Ra
|
||||
// to a few degrees at best, less without anti-aliasing, and an end already on it is
|
||||
// better left where it is.)
|
||||
let before = inner - *r.pts.get(at(2 * back))?;
|
||||
let wide = width >= 3.0 && n > 2 * back && before.cross(tip - inner).abs() > (0.25 * width).max(1.0) * before.hypot();
|
||||
// (At a cut from a wider shape, where the skeleton turns along the cut.)
|
||||
let joined = cut.is_some_and(|cut| g.near_cut(cut, tip, width / 2.0 + 1.5));
|
||||
let wide = width >= 3.0 && n > 2 * back && (joined || before.cross(tip - inner).abs() > (0.25 * width).max(1.0) * before.hypot());
|
||||
let d = if wide { before } else { tip - inner };
|
||||
if d.hypot() < 1e-9 {
|
||||
continue;
|
||||
@@ -282,6 +407,11 @@ fn trace_line(mut g: Grid, dist: &[f32], pixels: usize, max_width: f64, img: &Ra
|
||||
}
|
||||
}
|
||||
let tip = on_line(tip);
|
||||
if joined {
|
||||
// The skeleton stops about half a width short of the cut.
|
||||
*r.pts.get_mut(at(0))? = tip + along * (width / 2.0 + 0.5);
|
||||
continue;
|
||||
}
|
||||
let reach = ink.beyond(tip, along);
|
||||
if width >= 3.0 {
|
||||
let aside = kurbo::Vec2::new(-along.y, along.x) * (0.35 * width);
|
||||
|
||||
@@ -40,7 +40,21 @@ pub(super) fn squared(mask: &[bool], w: usize, h: usize) -> Option<Vec<f32>> {
|
||||
if mask.len() != size || mask.iter().enumerate().any(|(i, &on)| on && (i < w || i >= size - w || i % w == 0 || i % w + 1 == w)) {
|
||||
return None;
|
||||
}
|
||||
let mut grid: Vec<f64> = mask.iter().map(|&on| if on { FAR } else { 0.0 }).collect();
|
||||
two_passes(mask.iter().map(|&on| if on { FAR } else { 0.0 }).collect(), w, h)
|
||||
}
|
||||
|
||||
/// Squared distances from each pixel of a validated crop to the nearest `seeds` pixel (a very large
|
||||
/// value everywhere when there is none).
|
||||
pub(super) fn to_seeds(seeds: &[bool], w: usize, h: usize) -> Option<Vec<f32>> {
|
||||
let size = super::crop_size(w, h)?;
|
||||
if seeds.len() != size {
|
||||
return None;
|
||||
}
|
||||
two_passes(seeds.iter().map(|&seed| if seed { 0.0 } else { FAR }).collect(), w, h)
|
||||
}
|
||||
|
||||
/// The column pass and then the row pass over `grid` (0 at a seed, `FAR` elsewhere).
|
||||
fn two_passes(mut grid: Vec<f64>, w: usize, h: usize) -> Option<Vec<f32>> {
|
||||
let n = w.max(h);
|
||||
let (mut f, mut d, mut envelope) = (vec![0.0; n], vec![0.0; n], Vec::with_capacity(n));
|
||||
for x in 0..w {
|
||||
@@ -97,6 +111,38 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distances_to_seeds_equal_brute_force() {
|
||||
let mut seed = 7u64;
|
||||
let mut rnd = || {
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
|
||||
(seed >> 33) as usize
|
||||
};
|
||||
for case in 0..100 {
|
||||
let (w, h) = (3 + rnd() % 20, 3 + rnd() % 20);
|
||||
let seeds: Vec<bool> = (0..w * h).map(|_| rnd() % 9 == 0).collect();
|
||||
let d = to_seeds(&seeds, w, h).unwrap();
|
||||
for (i, &actual) in d.iter().enumerate() {
|
||||
let expected = seeds
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|&(_, &s)| s)
|
||||
.map(|(j, _)| {
|
||||
let dx = (i % w).abs_diff(j % w);
|
||||
let dy = (i / w).abs_diff(j / w);
|
||||
(dx * dx + dy * dy) as f32
|
||||
})
|
||||
.min_by(f32::total_cmp);
|
||||
match expected {
|
||||
Some(e) => assert_eq!(actual, e, "case {case}, pixel {i}"),
|
||||
None => assert!(actual > 1e18, "case {case}: no seed, pixel {i} at {actual}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(to_seeds(&[true; 8], 3, 3).is_none());
|
||||
assert!(to_seeds(&[], 2, 2).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_crops_are_refused() {
|
||||
assert!(squared(&[], 0, 0).is_none());
|
||||
|
||||
@@ -114,7 +114,8 @@ pub struct TraceParams {
|
||||
pub gradients: bool,
|
||||
/// Create: trace areas as filled shapes.
|
||||
pub fills: bool,
|
||||
/// Create: trace lines no wider than `stroke_width` as stroked centre lines.
|
||||
/// Create: trace lines no wider than `stroke_width`, and such thin features of wider shapes, as
|
||||
/// stroked center lines.
|
||||
pub strokes: bool,
|
||||
/// Stroke: the widest feature (px) traced as a stroke.
|
||||
pub stroke_width: f64,
|
||||
|
||||
@@ -932,6 +932,66 @@ fn wide_areas_stay_filled_and_lines_cross_at_junctions() {
|
||||
assert!(res.paths.iter().any(|p| p.stroke == Some(1.0) && p.path.subpaths[0].closed), "the disc's outline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thin_features_of_a_wide_shape_are_strokes_and_its_body_a_fill() {
|
||||
// A disc (r = 20) with a 3 px wide arm from it out to x = 140 and a 5 px wide one up to y = 5.
|
||||
let img = Raster::from_fn(160, 100, |x, y| {
|
||||
let (px, py) = (x as f64 + 0.5, y as f64 + 0.5);
|
||||
let disc = (px - 50.0).hypot(py - 60.0) <= 20.0;
|
||||
let across = (py - 60.0).abs() <= 1.5 && (50.0..=140.0).contains(&px);
|
||||
let up = (px - 50.0).abs() <= 2.5 && (5.0..=60.0).contains(&py);
|
||||
if disc || across || up { BLACK } else { WHITE }
|
||||
});
|
||||
let res = trace(&img, &strokes(true));
|
||||
let fills: Vec<_> = res.paths.iter().filter(|p| p.stroke.is_none()).collect();
|
||||
assert_eq!(fills.len(), 1, "the disc");
|
||||
let a = net_area(&fills[0].path);
|
||||
assert!((a - std::f64::consts::PI * 400.0).abs() < 90.0, "disc area {a}");
|
||||
let b = fills[0].path.bounds().unwrap();
|
||||
assert!(b.x1 < 76.0 && b.y0 > 34.0, "the fill stays the disc: {b:?}");
|
||||
let lines = stroked(&res);
|
||||
assert_eq!(lines.len(), 2, "{:?}", lines.iter().map(|(p, s)| (p.stroke, s.anchors.len())).collect::<Vec<_>>());
|
||||
let ends = |s: &vectorcraft_geom::SubPath| (s.anchors[0].p, s.anchors[s.anchors.len() - 1].p);
|
||||
let (across, up) = if ends(lines[0].1).0.y.min(ends(lines[0].1).1.y) > 50.0 { (lines[0], lines[1]) } else { (lines[1], lines[0]) };
|
||||
// The arm across: its free end where the bar ends (less its cap), its other end at the disc, the
|
||||
// cap closing the join.
|
||||
let w = across.0.stroke.unwrap();
|
||||
assert!((2.0..=4.0).contains(&w), "width {w}");
|
||||
let (p, q) = ends(across.1);
|
||||
let (near, far) = if p.x < q.x { (p, q) } else { (q, p) };
|
||||
assert!(far.distance(Point::new(140.0, 60.0)) < 4.0, "free end {far:?}");
|
||||
assert!((60.0..=72.5).contains(&near.x) && (near.y - 60.0).abs() < 1.5 && near.x - w / 2.0 <= 70.5, "joined end {near:?}");
|
||||
// The arm up, 5 px wide.
|
||||
let w = up.0.stroke.unwrap();
|
||||
assert!((4.0..=6.0).contains(&w), "width {w}");
|
||||
let (p, q) = ends(up.1);
|
||||
let (top, low) = if p.y < q.y { (p, q) } else { (q, p) };
|
||||
assert!(top.distance(Point::new(50.0, 5.0)) < 4.0, "free end {top:?}");
|
||||
assert!((38.0..=48.0).contains(&low.y) && (low.x - 50.0).abs() < 1.5 && low.y + w / 2.0 >= 40.0 - 0.5, "joined end {low:?}");
|
||||
// Strokes only: the disc is outlined and both arms stay strokes.
|
||||
let res = trace(&img, &strokes(false));
|
||||
assert!(res.paths.iter().all(|p| p.stroke.is_some()));
|
||||
assert_eq!(res.paths.iter().filter(|p| p.stroke == Some(1.0) && p.path.subpaths[0].closed).count(), 1, "the disc's outline");
|
||||
assert_eq!(stroked(&res).iter().filter(|(p, _)| p.stroke.is_some_and(|w| w > 1.5)).count(), 2, "the two arms");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_shapes_without_thin_features_stay_one_fill() {
|
||||
// A square, a diamond and a disc: their corners and edges are not thin features.
|
||||
let img = Raster::from_fn(240, 90, |x, y| {
|
||||
let (px, py) = (x as f64 + 0.5, y as f64 + 0.5);
|
||||
let square = (10.0..=70.0).contains(&px) && (15.0..=75.0).contains(&py);
|
||||
let diamond = (px - 120.0).abs() + (py - 45.0).abs() <= 35.0;
|
||||
let disc = (px - 200.0).hypot(py - 45.0) <= 30.0;
|
||||
if square || diamond || disc { BLACK } else { WHITE }
|
||||
});
|
||||
for width in [3.0, 6.0, 12.0] {
|
||||
let res = trace(&img, &TraceParams { stroke_width: width, ..strokes(true) });
|
||||
assert_eq!(res.paths.len(), 3, "width {width}: {:?}", res.paths.iter().map(|p| p.stroke).collect::<Vec<_>>());
|
||||
assert!(res.paths.iter().all(|p| p.stroke.is_none()), "width {width}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_settings_parse_with_defaults() {
|
||||
let p: TraceParams = serde_json::from_value(serde_json::json!({ "strokes": true, "strokeWidth": 4 })).unwrap();
|
||||
|
||||
@@ -3538,11 +3538,11 @@ digits Malayalam Malayalam
|
||||
Tate-chu-yoko Tate-chu-yoko
|
||||
Tate-chu-yoko Settings… Inställningar för Tate-chu-yoko…
|
||||
Tate-chu-yoko Settings Inställningar för Tate-chu-yoko
|
||||
Composite Fonts… Sammansatta teckensnitt…
|
||||
Composite Fonts Sammansatta teckensnitt
|
||||
Export Composite Fonts Exportera sammansatta teckensnitt
|
||||
Import Composite Fonts Importera sammansatta teckensnitt
|
||||
Delete Font Ta bort teckensnitt
|
||||
Composite Fonts… Sammansatta typsnitt…
|
||||
Composite Fonts Sammansatta typsnitt
|
||||
Export Composite Fonts Exportera sammansatta typsnitt
|
||||
Import Composite Fonts Importera sammansatta typsnitt
|
||||
Delete Font Ta bort typsnitt
|
||||
Kanji Kanji
|
||||
Kana Kana
|
||||
Numerals Siffror
|
||||
@@ -5386,7 +5386,7 @@ lighting Light Ljus
|
||||
@msg {_1}: the clipping path is too complex to cut the art with {_1}: urklippsbanan är för komplex för att klippa teckningen med
|
||||
@msg {_1} takes at most {MAX_MEMBERS} objects {_1} tar högst {MAX_MEMBERS} objekt
|
||||
@msg {_1} can't build solids from art nested more than {MAX_DEPTH} levels deep {_1} kan inte bygga solider av teckning som är kapslad mer än {MAX_DEPTH} nivåer djupt
|
||||
@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient Färgen för Rotera runt axel är för komplex för den här ytan; minska Segment eller förenkla övertoningen
|
||||
@msg Revolve paint is too complex for this surface; reduce Segments or simplify the gradient Färgen för Rotera runt axel är för komplex för den här ytan; minska Segment eller förenkla gradienten
|
||||
@msg Revolve edge must be left or right Rotera runt axel: edge måste vara left eller right
|
||||
@msg Revolve cap must be a boolean Rotera runt axel: cap måste vara ett booleskt värde
|
||||
@msg the revolved object has no fill or stroke det roterade objektet har ingen fyllning eller linje
|
||||
@@ -6473,8 +6473,8 @@ axis Y Axis Y-axel
|
||||
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it behörigheten `{shown}` är reserverad för ett senare plugin-API; den här värden beviljar den inte
|
||||
@msg unknown permission `{shown}` okänd behörighet `{shown}`
|
||||
@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants ”{name}” begär behörigheterna {asked}: en person måste godkänna dem i skrivbordsappen, eller skicka `grant` i en session som startats med --allow-plugin-grants
|
||||
@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` och `allowHeavy` gäller bara i en session som startats med --allow-plugin-grants: godkänn insticksprogrammet i skrivbordsappen
|
||||
@msg Only the person at the keyboard can approve installing a plug-in Bara personen vid tangentbordet kan godkänna installationen av ett insticksprogram
|
||||
@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` och `allowHeavy` gäller bara i en session som startats med --allow-plugin-grants: godkänn pluginet i skrivbordsappen
|
||||
@msg Only the person at the keyboard can approve installing a plug-in Bara personen vid tangentbordet kan godkänna installationen av ett plugin
|
||||
@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed ”{name}” beviljades inte `{p}`, som dess utdata behöver: ingenting ändrades
|
||||
@msg a heavy filter runs in the background, so it can't declare `commands` ett tungt filter körs i bakgrunden, så det kan inte deklarera `commands`
|
||||
@msg at most {MAX_COMMANDS} `commands` högst {MAX_COMMANDS} `commands`
|
||||
@@ -6579,7 +6579,7 @@ axis Y Axis Y-axel
|
||||
Skill Icons Färdighetsikoner
|
||||
Attack Attack
|
||||
Defend Försvar
|
||||
Heal Läkning
|
||||
Heal Retuschering
|
||||
Buff Buff
|
||||
Ult Ult
|
||||
Sword Svärd
|
||||
@@ -8802,3 +8802,40 @@ digits Hindi Hindi
|
||||
Margin Marginal
|
||||
Box Ram
|
||||
Swatch Färgruta
|
||||
Duplicate Effects Duplicera effekter
|
||||
Remove Effects Ta bort effekter
|
||||
Add art with Add as Single Graphic, or drop art here. Lägg till grafik med Lägg till som enkel grafik, eller släpp grafik här.
|
||||
Add as Single Graphic Lägg till som enkel grafik
|
||||
Add to Custom Dictionary Lägg till i egen ordlista
|
||||
Assamese assamesiska
|
||||
Bulgarian bulgariska
|
||||
Cancel Opening Avbryt öppning
|
||||
Change in Custom Dictionary Ändra i egen ordlista
|
||||
Delete from Custom Dictionary Radera från egen ordlista
|
||||
Delete SVG Filter Radera SVG-filter
|
||||
Edit SVG Filter Redigera SVG-filter
|
||||
Expand as Wireframes Expandera som trådmodeller
|
||||
Face Yta
|
||||
Graphics map onto planar faces: pick the Plane or Extrude object type. Grafik mappas på plana ytor: välj objekttypen Plan eller Extrudera.
|
||||
Import SVG Filter Importera SVG-filter
|
||||
Import SVG Filter… Importera SVG-filter…
|
||||
Line Gap Radmellanrum
|
||||
List Installed Scripts Lista installerade skript
|
||||
Manage Profiles Hantera profiler
|
||||
Mapped graphics Mappad grafik
|
||||
Materials Material
|
||||
Move Type Path Handle Flytta handtag för textbana
|
||||
NEW DOCUMENT PROFILES PROFILER FÖR NYTT DOKUMENT
|
||||
New SVG Filter Nytt SVG-filter
|
||||
Pick a graphic above to map it onto the object. Välj en grafik ovan för att mappa den på objektet.
|
||||
Reading the file… Läser filen…
|
||||
Remove Graphic Ta bort grafik
|
||||
Replace the selected 3D objects with the edges of their faces, as stroked paths. Ersätt de markerade 3D-objekten med kanterna på deras ytor, som banor med kontur.
|
||||
Sanskrit sanskrit
|
||||
Select a 3D object to map the graphic onto it. Markera ett 3D-objekt för att mappa grafiken på det.
|
||||
Select Symbol Instance Part Markera del av symbolinstans
|
||||
Spelling Suggestions Stavningsförslag
|
||||
Stroke Weight Down Minska konturtjocklek
|
||||
Stroke Weight Up Öka konturtjocklek
|
||||
The selected objects have different graphics. De markerade objekten har olika grafik.
|
||||
Word Gap Ordmellanrum
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -465,9 +465,18 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let sel = selected(ui.ctx(), &doc);
|
||||
let view: View = pstate(ui.ctx(), "gs-view");
|
||||
let mut ev = Events::default();
|
||||
// In a sized panel the list fills the space above the footer (#2992), as the Artboards list
|
||||
// does; elsewhere it keeps its 220 pt.
|
||||
let fixed = widgets::panel_footer_bottom(ui).is_some();
|
||||
let list_height = widgets::panel_list_height(ui, 220.0, 2.0 + ui.spacing().item_spacing.y);
|
||||
widgets::list_box(ui, |ui| {
|
||||
let out = egui::ScrollArea::vertical().id_salt("gs-scroll").max_height(220.0).show(ui, |ui| {
|
||||
ui.set_min_height(120.0);
|
||||
let scroll = egui::ScrollArea::vertical()
|
||||
.id_salt("gs-scroll")
|
||||
.max_height(list_height)
|
||||
.min_scrolled_height(list_height.min(64.0))
|
||||
.auto_shrink([false, !fixed]);
|
||||
let out = scroll.show(ui, |ui| {
|
||||
ui.set_min_height(if fixed { list_height.min(120.0) } else { 120.0 });
|
||||
ui.set_width(ui.available_width());
|
||||
if doc.graphic_styles.is_empty() {
|
||||
super::empty_state(ui, "dc-graphic-styles", tl!("No graphic styles"), tl!("Select styled art and click New Graphic Style."));
|
||||
@@ -847,6 +856,36 @@ mod tests {
|
||||
app.session.active().unwrap().doc.graphic_styles.iter().map(|g| g.name.clone()).collect()
|
||||
}
|
||||
|
||||
/// In a panel made taller the list fills the space above its footer instead of staying 220 pt
|
||||
/// tall with blank space below it (#2992); outside a sized panel it keeps that height.
|
||||
#[test]
|
||||
fn the_list_fills_a_tall_panel_above_its_footer() {
|
||||
let mut app = app();
|
||||
for i in 0..30 {
|
||||
app.session.execute("graphicStyle.new", &json!({"name": format!("Style {i:02}")})).unwrap();
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
set_pstate(&ctx, "gs-view", View::LargeList);
|
||||
// The clip rect of the first style's name: the list's visible area.
|
||||
let mut list = |bottom: Option<f32>| {
|
||||
let mut clip = None;
|
||||
for _ in 0..3 {
|
||||
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(260.0, 700.0))), ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
let info = egui::UiStackInfo::default().with_tag_value(widgets::PANEL_FOOTER, bottom);
|
||||
ui.scope_builder(egui::UiBuilder::new().ui_stack_info(info), |ui| show(&mut app, ui));
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
clip = out.shapes.iter().find(|c| matches!(&c.shape, egui::Shape::Text(t) if t.galley.text() == "Style 00")).map(|c| c.clip_rect);
|
||||
}
|
||||
clip.expect("the first style's row is drawn")
|
||||
};
|
||||
let tall = list(Some(650.0));
|
||||
assert!(tall.height() > 400.0 && tall.bottom() <= 650.0 - 31.0, "the list fills the panel above its footer: {tall:?}");
|
||||
let loose = list(None);
|
||||
assert!(loose.height() <= 220.5, "unsized, the list keeps its height: {loose:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_view_names_the_styles_and_deletes_the_selected_ones_after_asking() {
|
||||
let mut app = app();
|
||||
|
||||
+2
-1
@@ -6817,7 +6817,8 @@ DCS Transparency Interactions, off by default) adds `dcsTransparency: true` to t
|
||||
and the panel shows a warning mark on them. VectorCraft's rule for it: the image, or a group or layer holding it, has
|
||||
an opacity below 100%, a blend mode other than Normal, an opacity mask or an effect, or its bounds overlap the
|
||||
bounds of another visible object that has one of those or a gradient with a transparent stop (in a fill, a stroke or
|
||||
a character's fill or stroke); a DCS image with transparency of its own counts as such an object. The check looks at
|
||||
a character's fill or stroke); a DCS image with transparency of its own counts as such an object. Bounds are cut to the
|
||||
bounds of the clipping paths of the clip groups and clipping layers holding an object. The check looks at
|
||||
every object in the layers each time the list is read. A `dcsTransparency` that is not true or false is an error.
|
||||
`links.goTo {id}` selects an image; `links.embed {ids?}` keeps linked files' pixels in the document (an image whose
|
||||
file is missing stays linked: relink it first); `links.unembed {id, path}` writes an embedded image to a file
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# Roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: M10/G5: Energy Beams brush library; MCP docs list Energy Beams; previously minor: M10/G5: Skill Icons symbol library; MCP docs list Skill Icons; previously minor: M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: M10/G5: Energy Beams brush library; MCP docs list Energy Beams; previously minor: M10/G5: Skill Icons symbol library; MCP docs list Skill Icons; previously minor: M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
>
|
||||
> Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865).
|
||||
>
|
||||
@@ -52,6 +52,7 @@ M7 type: Touch Type moves a right-to-left character along its line and up from i
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-13 | minor | Graphic Styles: in a panel made taller the list fills the space above the footer instead of stopping at 220 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/graphic_styles.rs`). Scores and estimates unchanged. |
|
||||
| 2026-11-13 | minor | PDF import: glyphs inside a marked-content span with an empty `ActualText` come in as outlines in their fill and stroke instead of being left out, adding no text (#6971; `empty_actual_text_keeps_the_glyphs_art` in `crates/pdf/src/tests/actual_text.rs`). Scores and estimates unchanged. |
|
||||
| 2026-11-13 | minor | Library panels (swatch, Color Book, brush, symbol and graphic style libraries): in a panel made taller the list fills the space above the footer instead of scrolling inside 220 or 280 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/library_panel/tests.rs`). Scores and estimates unchanged. |
|
||||
| 2026-11-13 | minor | Swatches: in a panel made taller the swatch list fills the space above the footer instead of scrolling inside 150 or 200 pt (#1638; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/swatches/tests.rs`). Scores and estimates unchanged. |
|
||||
@@ -150,7 +151,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l
|
||||
| M0 | Skeleton + vertical slice | ✅ done | — |
|
||||
| M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (document-tab artwork drag with lossless resource transfer and one target Undo; malformed MCP app replies discard the connection without replay, #1761; bounded control socket waits and connection-slot recovery, #1726; bounded synthetic control input, #1709; no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending; the Transform fields take one Tab each, the W/H link after them) | 4–6 |
|
||||
| M2 | Drawing tools + smart guides | ✅ mostly done (#3380 deletion creates independent selected path pieces and keeps bounds-fitted gradients in place; endpoint highlighting remains open; Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); a shape just drawn moves by its centre point and turns from the zone outside a corner with its tool still active (#5477); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537), including the existing Snap to Glyph and Alignment Guides toggles (#6164); no View submenu; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123) | 12–18 |
|
||||
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper copies transformed character dimensions (#6404) and styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls; Library panels' lists (swatch, Color Book, brush, symbol and graphic style libraries) fill a taller panel above its footer (#2992; scores and estimates unchanged); | Swatches list fills a taller panel above its footer (#1638; `the_list_fills_a_tall_panel_above_its_footer`; scores and estimates unchanged); 4–8 |
|
||||
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper copies transformed character dimensions (#6404) and styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls; Library panels' lists (swatch, Color Book, brush, symbol and graphic style libraries) fill a taller panel above its footer (#2992; scores and estimates unchanged); Graphic Styles list fills a taller panel above its footer (#2992; scores and estimates unchanged); Swatches list fills a taller panel above its footer (#1638; `the_list_fills_a_tall_panel_above_its_footer`; scores and estimates unchanged); | 4–8 |
|
||||
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | Explicit editing-data import keeps oversized capped-page artboards (#6865; estimates unchanged); Save refusal continuation (#6146): ordinary Save offers Save As; unchanged format keeps options, changed format opens its Save options; cancellation/automation isolation; focused UI regressions passed, native acceptance pending; PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; PDF import: glyphs inside a marked-content span with an empty `ActualText` come in as outlines in their fill and stroke, adding no text (#6971; scores and estimates unchanged); | 3–6 (existing scope; Affinity export unestimated) |
|
||||
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
|
||||
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog | 3–6 |
|
||||
|
||||
File diff suppressed because one or more lines are too long
+3
-1
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar; previously minor: Ruler origin: the corner drag snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (Graphic Styles list fills a taller panel, #2992; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Remove Background in the Contextual Task Bar, Properties › Quick Actions and the Control bar; previously minor: Ruler origin: the corner drag snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Outline view: images and gradient meshes are picked on what Outline draws, so a click inside them reaches the path behind, #6437; previously minor: a middle-click on a document tab closes it, #6506; previously minor: Blob Brush: a round tip's preview is the outline of the area it adds, round at both ends, as the stroke it commits (#4343); scores and estimates unchanged; previously:; ruler double-click adds a guide at the pointer, #5239; G5: Inventory Icons built-in symbol library (four built-ins); previously minor: files that record no units open in Preferences › Units › General, #5820; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Layers: Rename (`ui.rename`) opens the name field of the selected layer, group or object, #5324; Properties in Edit Artboards: Transform section above the Artboards section, #5219; scrubby labels: the Control bar's Stroke: and Opacity: links and the Properties panel's Stroke link scrub their fields, #4088; Properties: Quick Actions has Repeat Options for one selected repeat, #5181; Direct Selection: Shift-click picks several blend spine points, which drag together, #5285; Direct Selection: Cmd/Ctrl held lends the Selection tool and shows its bounding box, #3901; Join tool trims two crossing open paths and joins them at the crossing, #5844; Control bar: Paragraph: link and Align left/center/right while type is selected, #5372; shape tools: a press on the selection's centre point moves it and one in the rotate zone outside a box corner turns it, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged)
|
||||
>
|
||||
> Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369).
|
||||
>
|
||||
@@ -51,6 +51,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-13 | minor | Graphic Styles: in a panel made taller the list fills the space above the footer instead of stopping at 220 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/graphic_styles.rs`). Scores and estimates unchanged. |
|
||||
| 2026-11-13 | minor | Library panels (swatch, Color Book, brush, symbol and graphic style libraries): in a panel made taller the list fills the space above the footer instead of scrolling inside 220 or 280 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/library_panel/tests.rs`). Scores and estimates unchanged. |
|
||||
| 2026-11-13 | minor | Swatches: in a panel made taller the swatch list fills the space above the footer instead of scrolling inside 150 or 200 pt (#1638). Scores unchanged. |
|
||||
| 2026-11-08 | minor | Style panels: a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), and on a paragraph style's row its Paragraph Style Options (rename); the Character Style Options dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. |
|
||||
@@ -167,6 +168,7 @@ tools, never checked side by side) isn't captured row by row.
|
||||
| Curvature tool: click/drag points, double-click or Alt-click for a corner, temporary Direct Selection, point conversion | partial | circular point markers without direction handles or duplicate markers, Anchor Size and Hide Edges regression-tested (#1236, in part); curve geometry: each stretch of three points follows the circle through them (handles along its tangent, chord/(3·cos²(α/2)) long, shorter in a turn tighter than 30°), open ends curving in, the same rule for the rubber band, dragged, inserted and converted points (#1236, in part); neighbours of a point dragged on an existing path keep their tangents, and modifiers requested in #975 remain |
|
||||
| Icon-strip flyout resizing (#5713) | done | Canvas-side edge and bottom; user size persists after reopening and workspace restore; Glyphs and Artboards pointer/cursor regression |
|
||||
| Icon-strip flyout resizing from the top (storytold/craft-libs#1094) | done | Top edge (a 4 pt band over the tab strip) and the canvas-side top corner; the bottom and the icon-column edge stay; the moved top persists after reopening and workspace restore; `an_icon_flyout_grows_upward_from_its_top_edge_and_free_corner` |
|
||||
| Graphic Styles list fills a taller panel (#2992) | done | Docked, floating or in a resized flyout, the list takes the height above the panel's footer, as the Artboards list does; outside a sized panel it stays 220 pt; `the_list_fills_a_tall_panel_above_its_footer` |
|
||||
| Library panels' lists fill a taller panel (#2992) | done | Swatch (Color Books among them), brush, symbol and graphic style library panels: docked, floating or in a resized flyout, the list takes the height above the panel's footer; outside a sized panel it stays 220 pt (280 pt for large thumbnails); `the_list_fills_a_tall_panel_above_its_footer` in `panels/library_panel/tests.rs` |
|
||||
| Swatches list fills a taller panel (#1638) | done | Docked, floating or in a resized flyout, the swatch list takes the height above the panel's footer, as the Artboards list does; outside a sized panel it stays 150 pt (200 pt for large thumbnails); `the_list_fills_a_tall_panel_above_its_footer` in `panels/swatches/tests.rs` |
|
||||
| Layers panel lists a blend's spine (#4820) | done | an open blend shows a `<Path>` row below its key objects, where Release leaves the spine; a click selects the blend (Shift/Cmd toggles it) so Direct Selection edits the spine; a locked blend's row selects nothing; no row while searching or filtering (`a_blends_spine_is_a_row_below_its_keys_that_selects_the_blend`) |
|
||||
|
||||
Reference in New Issue
Block a user