Merge remote-tracking branch 'upstream/main' into feat-4936-edit-custom-dictionary

# Conflicts:
#	docs/gaps.md
#	docs/roadmap.md
#	docs/target-app-parity.md
#	docs/type-parity.md
This commit is contained in:
HassanKhan
2026-11-10 21:13:30 +05:00
86 changed files with 1852 additions and 153 deletions
Generated
+5 -3
View File
@@ -975,12 +975,12 @@ source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7
[[package]]
name = "craft-fonts-build"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb"
source = "git+https://github.com/storytold/craft-libs?rev=bb8320f5fa5d20fa78a026d0de06fe4e2f08a099#bb8320f5fa5d20fa78a026d0de06fe4e2f08a099"
[[package]]
name = "craft-i18n"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb"
source = "git+https://github.com/storytold/craft-libs?rev=bb8320f5fa5d20fa78a026d0de06fe4e2f08a099#bb8320f5fa5d20fa78a026d0de06fe4e2f08a099"
dependencies = [
"fixed_decimal",
"icu_decimal",
@@ -1049,7 +1049,7 @@ dependencies = [
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb"
source = "git+https://github.com/storytold/craft-libs?rev=bb8320f5fa5d20fa78a026d0de06fe4e2f08a099#bb8320f5fa5d20fa78a026d0de06fe4e2f08a099"
dependencies = [
"craft-fonts-build",
"craft-i18n",
@@ -6652,6 +6652,8 @@ dependencies = [
"kurbo 0.13.1",
"log",
"md-5",
"objc2-core-foundation",
"objc2-core-graphics",
"roxmltree 0.21.2",
"serde",
"serde_json",
+1 -1
View File
@@ -14,7 +14,7 @@ repository = "https://github.com/storytold/vectorcraft"
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb", default-features = false, features = ["interpret", "write"] }
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" }
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb", features = ["serde"] }
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "bb8320f5fa5d20fa78a026d0de06fe4e2f08a099", features = ["serde"] }
craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" }
craft-proof = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" }
# Decides who draws the window frame (desktop vs. app) so the suite shares one native titlebar per
-1
View File
@@ -8,7 +8,6 @@ mod layers;
mod profile;
mod stroke_style;
pub(crate) use layers::HEAD_REACH;
pub use layers::{Effect, FillLayer, StrokeGradientMode, StrokeLayer, stroke_paint_bounds};
pub use profile::{AngleProfile, PressureProfile, ProfilePreset, SavedProfile, WidthProfile};
pub use stroke_style::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign};
+3 -6
View File
@@ -139,10 +139,6 @@ impl StrokeGradientMode {
}
}
/// How far any arrowhead reaches from its tip, in units of its weight (the open arrow's arms are
/// cut square a little behind its 4 × 4 box).
pub(crate) const HEAD_REACH: f64 = 4.6;
fn ten() -> f64 {
10.0
}
@@ -188,7 +184,8 @@ impl StrokeLayer {
}
}
/// Weight of the start (`end == false`) or end arrowhead: stroke weight × its scale, at least
/// a quarter point. A head of weight `hw` fits a box about `4·hw` long and wide.
/// a quarter point. A head of weight `hw` fits a box about `4·hw` long and wide (see
/// [`Arrowhead::reach`] and [`Arrowhead::half_width`]).
pub fn arrow_weight(&self, end: bool) -> f64 {
let pct = if end { self.arrow_scale.1 } else { self.arrow_scale.0 };
(self.width * pct / 100.0).max(0.25)
@@ -197,7 +194,7 @@ impl StrokeLayer {
/// without one): the head's diagonal, or with [`ArrowAlign::Extend`] its length, plus the cap.
pub fn head_reach(&self, end: bool) -> f64 {
let head = if end { self.end_arrow } else { self.start_arrow };
head.map_or(0.0, |_| HEAD_REACH * self.arrow_weight(end) + self.width / 2.0)
head.map_or(0.0, |k| k.reach() * self.arrow_weight(end) + self.width / 2.0)
}
/// How far the arrowheads can reach from the path's end points (0 without heads).
pub fn arrow_reach(&self) -> f64 {
+27 -1
View File
@@ -49,6 +49,10 @@ impl Dash {
}
}
/// How far the arrowheads drawn in the 4 × 4 box reach from their tip, in units of their weight
/// (the open arrow's arms are cut square a little behind the box).
const HEAD_REACH: f64 = 4.6;
/// An arrowhead shape. Saved by variant name: new shapes are appended, never renamed or reordered.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Arrowhead {
@@ -94,13 +98,18 @@ pub enum Arrowhead {
Kite,
TriangleBar,
Oval,
/// Two arms of the head's weight (the line's weight at 100 % scale, for lines of 0.25 pt or
/// more) meeting at right angles in a sharp tip, cut square at their ends. Twice as wide as
/// the other heads.
LineArrow,
}
impl Arrowhead {
/// Every arrowhead, in menu order (arrows, then outlined shapes, then bars and marks).
pub const ALL: [Arrowhead; 40] = [
pub const ALL: [Arrowhead; 41] = [
Arrowhead::Arrow,
Arrowhead::ArrowOpen,
Arrowhead::LineArrow,
Arrowhead::Barbed,
Arrowhead::Concave,
Arrowhead::DoubleArrow,
@@ -141,6 +150,22 @@ impl Arrowhead {
Arrowhead::Fork,
];
/// How far any part of the head can be from its tip, in units of its weight.
pub fn reach(self) -> f64 {
match self {
// Its farthest corners are 4 + ½√2 back and 4 − ½√2 out.
Arrowhead::LineArrow => 5.75,
_ => HEAD_REACH,
}
}
/// How far the head spreads either side of its line, in units of its weight.
pub fn half_width(self) -> f64 {
match self {
Arrowhead::LineArrow => 4.0,
_ => 2.0,
}
}
/// The name the arrowhead menus show.
pub fn label(self) -> &'static str {
match self {
@@ -184,6 +209,7 @@ impl Arrowhead {
Arrowhead::Kite => "Kite",
Arrowhead::TriangleBar => "Triangle to Bar",
Arrowhead::Oval => "Oval",
Arrowhead::LineArrow => "Line Arrow",
}
}
}
+10 -3
View File
@@ -219,7 +219,7 @@ fn profile_presets_round_trip_their_ids() {
#[test]
fn arrowhead_names_are_stable_and_every_one_is_listed_once() {
// Saved files store these names: they may only ever be appended to.
const SAVED: [&str; 40] = [
const SAVED: [&str; 41] = [
"Triangle",
"TriangleOpen",
"Circle",
@@ -260,6 +260,7 @@ fn arrowhead_names_are_stable_and_every_one_is_listed_once() {
"Kite",
"TriangleBar",
"Oval",
"LineArrow",
];
for name in SAVED {
let a: Arrowhead = serde_json::from_value(serde_json::json!(name)).unwrap();
@@ -293,9 +294,15 @@ fn outset_covers_arrowheads() {
st.arrow_scale = (100.0, 200.0);
assert_eq!(st.arrow_weight(false), 4.0);
assert_eq!(st.arrow_weight(true), 8.0);
assert_eq!((st.head_reach(false), st.head_reach(true)), (0.0, HEAD_REACH * 8.0 + 2.0));
let reach = Arrowhead::Triangle.reach();
assert_eq!((st.head_reach(false), st.head_reach(true)), (0.0, reach * 8.0 + 2.0));
// A 32 pt head whose tip sits up to its length (plus the cap) past the end point.
assert_eq!(a.outset(), HEAD_REACH * 8.0 + 2.0);
assert_eq!(a.outset(), reach * 8.0 + 2.0);
// The line arrow's arms spread twice as wide, so it reaches further from its tip.
a.stroke_mut().unwrap().end_arrow = Some(Arrowhead::LineArrow);
assert!(Arrowhead::LineArrow.reach() > reach);
assert_eq!(a.outset(), Arrowhead::LineArrow.reach() * 8.0 + 2.0);
a.stroke_mut().unwrap().end_arrow = Some(Arrowhead::Triangle);
a.stroke_mut().unwrap().width = 0.01;
assert_eq!(a.stroke().unwrap().arrow_weight(true), 0.25, "heads keep a minimum size");
}
+6 -6
View File
@@ -7,7 +7,6 @@ use vectorcraft_geom::hit::fill_contains;
use vectorcraft_geom::kurbo::{ParamCurveArclen, ParamCurveExtrema, ParamCurveNearest};
use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathSeg, Point, Rect, Shape, Vec2};
use crate::appearance::HEAD_REACH;
use crate::{Appearance, ArrowAlign, Dash, LineCap, LineJoin, StrokeAlign, StrokeLayer, WidthProfile};
/// Arc-length accuracy for placing a point along a profile (document points).
@@ -386,18 +385,19 @@ impl StrokeLayer {
}
/// Is `p` within `tol` of where the start (`end == false`) or end arrowhead at `at` (the path
/// leaving along `out`) can paint? Heads are at most about 4.6 weights long and 4 wide. With
/// leaving along `out`) can paint? A head reaches [`crate::Arrowhead::reach`] weights from its tip and
/// [`crate::Arrowhead::half_width`] either side of its line (4.6 and 2 for most). With
/// [`ArrowAlign::Extend`] a head points along `out` with its tip up to its length (or the cap)
/// past the end; with [`ArrowAlign::Tip`] its tip is on the end and it lies back along the
/// chord the path bends through under it, so anywhere within its length of the end.
fn head_contains(&self, at: Point, out: Vec2, end: bool, p: Point, tol: f64) -> bool {
let hw = self.arrow_weight(end);
let v = p - at;
let Some(kind) = (if end { self.end_arrow } else { self.start_arrow }) else { return false };
let (hw, v) = (self.arrow_weight(end), p - at);
match self.arrow_align {
ArrowAlign::Tip => v.hypot() <= HEAD_REACH * hw + tol,
ArrowAlign::Tip => v.hypot() <= kind.reach() * hw + tol,
ArrowAlign::Extend => {
let along = v.dot(out);
v.cross(out).abs() <= 2.0 * hw + tol && along <= 4.0 * hw + self.width / 2.0 + tol && along >= -HEAD_REACH * hw - tol
v.cross(out).abs() <= kind.half_width() * hw + tol && along <= 4.0 * hw + self.width / 2.0 + tol && along >= -kind.reach() * hw - tol
}
}
}
+33
View File
@@ -158,6 +158,7 @@ pub const STYLE_LIBRARIES: &[BuiltinStyleLibrary] = &[
BuiltinStyleLibrary { id: "gradient-finishes", name: "Gradient Finishes", make: gradient_finishes },
BuiltinStyleLibrary { id: "shape-effects", name: "Shape Effects", make: shape_effects },
BuiltinStyleLibrary { id: "blends-transparency", name: "Blends and Transparency", make: blends_transparency },
BuiltinStyleLibrary { id: "game-hud", name: "Game HUD", make: game_hud },
];
/// Built-in library `id`, made once.
@@ -453,6 +454,34 @@ fn blends_transparency() -> Vec<GraphicStyle> {
]
}
/// Game HUD: fills and glows for health, mana, damage and quest chrome (original arcade/HUD look).
fn game_hud() -> Vec<GraphicStyle> {
vec![
style("Health Fill", vec![F(fill("#e74c3c")), S(stroke("#5c1a1a", 1.25))], vec![]),
style(
"Mana Glow",
vec![F(fill("#3498db"))],
vec![fx("stylize.outerGlow", json!({"mode": "normal", "opacity": 85.0, "blur": 7.0, "color": "#5dade2"}))],
),
style(
"Damage Flash",
vec![F(fill("#ff6b35"))],
vec![fx("stylize.innerGlow", json!({"mode": "screen", "opacity": 80.0, "blur": 8.0, "color": "#ffe66d", "source": "edge"}))],
),
style("Quest Outline", vec![F(fill("#f7f3e8")), S(round("#2c3e50", 3.0))], vec![]),
style(
"Coin Shine",
vec![F(fill("#f1c40f")), S(stroke("#b7950b", 1.5))],
vec![fx("stylize.dropShadow", json!({"mode": "multiply", "opacity": 40.0, "x": 2.0, "y": 3.0, "blur": 4.0, "color": "#1d1b2a"}))],
),
style(
"Boss Alert",
vec![F(fill("#1a1a2e")), S(round("#e94560", 2.5))],
vec![fx("stylize.outerGlow", json!({"mode": "normal", "opacity": 75.0, "blur": 6.0, "color": "#e94560"}))],
),
]
}
#[cfg(test)]
mod tests {
use super::*;
@@ -475,6 +504,10 @@ mod tests {
}
assert!(Arc::ptr_eq(&builtin_style_library("hand-drawn").unwrap(), &builtin_style_library("hand-drawn").unwrap()), "made once");
assert!(builtin_style_library("nope").is_none());
let game = builtin_style_library("game-hud").unwrap();
assert_eq!(game.name, "Game HUD");
let names: Vec<&str> = game.styles.iter().map(|g| g.name.as_str()).collect();
assert_eq!(names, ["Health Fill", "Mana Glow", "Damage Flash", "Quest Outline", "Coin Shine", "Boss Alert"]);
}
#[test]
+18
View File
@@ -367,6 +367,24 @@ impl TextObject {
pub fn move_path_anchors(&mut self, refs: &[(usize, usize)], d: Vec2) -> bool {
matches!(self.kind, TextKind::OnPath { .. }) && self.reshape_local(|path, to_text| move_anchors(path, to_text, refs, d))
}
/// Move the out (`out`) or in direction handle of anchor `ai` of subpath `si` of type on a
/// path's path to `pos` in document space, as a path's anchor takes it
/// ([`vectorcraft_geom::Anchor::set_handle`]; `independent`: that side only, the anchor a
/// corner): a Direct Selection drag of a handle (#3565). The handle is set in document space,
/// so a smooth anchor keeps its other handle's length as drawn; the brackets keep their
/// fractions of the path. False, changing nothing, for other type, when the anchor doesn't
/// exist, a degenerate `xf` or a path that would not be finite.
pub fn set_path_handle(&mut self, (si, ai): (usize, usize), out: bool, pos: Point, independent: bool) -> bool {
let xf = self.xf;
matches!(self.kind, TextKind::OnPath { .. })
&& self.reshape_local(|path, to_text| {
let Some(a) = path.anchor_mut(si, ai) else { return false };
let mut drawn = a.transform(xf);
drawn.set_handle(out, pos, independent);
*a = drawn.transform(to_text);
true
})
}
/// Edit a copy of the area frame with `f` (given the document → text space map) and keep it
/// when `f` succeeds and the result is finite.
fn reshape_area(&mut self, f: impl FnOnce(&mut PathData, Affine) -> bool) -> bool {
+15 -5
View File
@@ -1,5 +1,5 @@
//! Arrowheads. A head of weight `hw` (stroke weight × scale) fits a box `4·hw` long and `4·hw`
//! wide. Hollow kinds are rings (`hw/2` walls) or, for the open arrow, a chevron with `hw` arms.
//! wide; the line arrow's arms reach twice as far out ([`Arrowhead::half_width`]). Hollow kinds are rings (`hw/2` walls) or, for the open arrow, a chevron with `hw` arms.
//! The stroke ends under the head at the head's *inset* from the tip: halfway into solid heads,
//! in the back wall of hollow ones (with the cap kept out of the hole) and at the chevron's inner
//! corner. With [`ArrowAlign::Tip`] the tip sits on the end point and the stroke is shortened by
@@ -8,7 +8,7 @@
use std::borrow::Cow;
use std::f64::consts::{FRAC_PI_2, PI};
use std::f64::consts::{FRAC_1_SQRT_2, FRAC_PI_2, PI};
use kurbo::{Affine, BezPath, ParamCurve, ParamCurveArclen, PathSeg, Point, Shape, Vec2};
use vectorcraft_doc::{ArrowAlign, Arrowhead, StrokeLayer};
@@ -252,6 +252,7 @@ const HEX: f64 = 2.0 * 1.732_050_807_568_877_2;
/// Flattening tolerance of the curved local heads, in units.
const LOCAL_TOL: f64 = 1e-3;
/// Where the stroke ends under a head drawn in local units ([`local`]), in those units.
fn local_stop(kind: Arrowhead) -> Stop {
match kind {
Arrowhead::Target | Arrowhead::TagOpen | Arrowhead::DiamondOpen => Stop::Wall(4.0),
@@ -261,7 +262,7 @@ fn local_stop(kind: Arrowhead) -> Stop {
Arrowhead::Fork => Stop::At(2.7),
Arrowhead::HalfArrowLeft | Arrowhead::HalfArrowRight | Arrowhead::Feather => Stop::At(1.3),
Arrowhead::Swallowtail => Stop::At(1.4),
Arrowhead::Chevron => Stop::At(1.0),
Arrowhead::Chevron | Arrowhead::LineArrow => Stop::At(1.0),
Arrowhead::Slash => Stop::At(1.2),
Arrowhead::DoubleSlash => Stop::At(1.05),
Arrowhead::DoubleArrow => Stop::At(1.7),
@@ -273,8 +274,9 @@ fn local_stop(kind: Arrowhead) -> Stop {
}
/// The outline of a head added after the first ten, in local units: x from the tip (0) back
/// along the line (to at most 4), y across it (−2 to 2, positive to the right of the direction
/// the head points). Every shape is generated here.
/// along the line (to at most 4; the line arrow's square-cut arms 4.71), y across it (−2 to 2,
/// the line arrow −4 to 4; positive to the right of the direction the head points). Every shape
/// is generated here.
fn local(kind: Arrowhead) -> BezPath {
let mut p = BezPath::new();
let poly = |p: &mut BezPath, pts: &[(f64, f64)]| polygon(p, &pts.iter().map(|&q| Point::from(q)).collect::<Vec<_>>());
@@ -363,6 +365,14 @@ fn local(kind: Arrowhead) -> BezPath {
poly(&mut p, &[(0.0, 2.0), (0.6, 2.0), (0.6, 0.25), (4.0, 1.8), (4.0, -1.8), (0.6, -0.25), (0.6, -2.0), (0.0, -2.0)])
}
Arrowhead::Oval => p.extend(kurbo::Ellipse::new((2.0, 0.0), (2.0, 1.2), 0.0).path_elements(LOCAL_TOL)),
Arrowhead::LineArrow => {
// Arms one unit thick (the head's weight: the line's at 100 % for lines of 0.25 pt or
// more) at right angles to each other, from a sharp tip to their outer corners at
// (4, ±4), cut square across: a unit across an arm at 45° is ½√2 along each axis, and
// the arms' inner edges meet √2 behind the tip.
let t = FRAC_1_SQRT_2;
poly(&mut p, &[(0.0, 0.0), (4.0, 4.0), (4.0 + t, 4.0 - t), (2.0 * t, 0.0), (4.0 + t, t - 4.0), (4.0, -4.0)]);
}
// The first ten are drawn by `shape`.
Arrowhead::Triangle
| Arrowhead::TriangleOpen
+35 -4
View File
@@ -1,6 +1,6 @@
//! Every arrowhead's outline: drawn, inside its box (4 × 4 weights; the open arrow's square-cut
//! arms reach a little further back), without self-intersections, and covering the end of the
//! stroke under it.
//! Every arrowhead's outline: drawn, inside its reach and half width (4 × 4 weights for most; the
//! open arrow's square-cut arms reach a little further back, the line arrow's twice as far out),
//! without self-intersections, and covering the end of the stroke under it.
use kurbo::{BezPath, PathEl, Point, Shape};
use vectorcraft_color::{Color, Paint};
@@ -53,7 +53,13 @@ fn every_outline_is_drawn_inside_its_box_without_self_intersections() {
let h = head(kind);
assert!(h.outline.area().abs() > 4.0, "{kind:?} is drawn");
let b = h.outline.bounding_box();
assert!(b.x0 >= 91.0 - 1e-6 && b.x1 <= 100.0 + 1e-6 && b.y0 >= -4.0 - 1e-6 && b.y1 <= 4.0 + 1e-6, "{kind:?} leaves its box: {b:?}");
let (reach, side) = (2.0 * kind.reach(), 2.0 * kind.half_width());
assert!(b.x1 <= 100.0 + 1e-6 && b.y0 >= -side - 1e-6 && b.y1 <= side + 1e-6, "{kind:?} leaves its box: {b:?}");
if kind.half_width() == 2.0 {
assert!(b.x0 >= 91.0 - 1e-6, "{kind:?} is longer than the 4 × 4 box: {b:?}");
}
let far = contours(&h.outline).iter().flatten().map(|p| p.distance(h.tip)).fold(0.0, f64::max);
assert!(far <= reach + 1e-6, "{kind:?} reaches {far} from its tip, past {reach}");
let edges: Vec<(Point, Point)> = contours(&h.outline).iter().flat_map(|c| (0..c.len()).map(|i| (c[i], c[(i + 1) % c.len()]))).collect();
assert!(edges.len() >= 3, "{kind:?}");
for (i, a) in edges.iter().enumerate() {
@@ -74,6 +80,31 @@ fn the_stroke_ends_inside_every_head() {
}
}
#[test]
fn the_line_arrow_is_a_right_angle_of_two_arms_as_thick_as_the_line() {
// Weight 2 at 100 %: a local unit (x back from the tip, y across) is 2 pt.
let h = head(Arrowhead::LineArrow);
let at = |x: f64, y: f64| Point::new(100.0 - 2.0 * x, 2.0 * y);
// A sharp tip on the end point; outer corners 4 units back and out, square-cut ends.
let pts: Vec<Point> = contours(&h.outline).concat();
for (corner, name) in [(at(0.0, 0.0), "tip"), (at(4.0, 4.0), "right corner"), (at(4.0, -4.0), "left corner")] {
assert!(pts.iter().any(|p| p.distance(corner) < 1e-9), "{name} {corner:?} in {pts:?}");
}
// Open between the arms, solid at the tip.
assert_eq!(h.outline.winding(at(2.5, 0.0)), 0);
assert_ne!(h.outline.winding(at(0.7, 0.0)), 0);
// Halfway along each arm, 45° off the line, it is one unit (the line's weight) across.
let s = std::f64::consts::FRAC_1_SQRT_2;
for side in [1.0, -1.0] {
let (mx, my) = (2.0 + 0.5 * s, side * (2.0 - 0.5 * s));
let (nx, ny) = (-side * s, s);
for (d, inside) in [(0.45, true), (-0.45, true), (0.55, false), (-0.55, false)] {
let w = h.outline.winding(at(mx + nx * d, my + ny * d));
assert_eq!(w != 0, inside, "arm {side} at {d} across");
}
}
}
#[test]
fn half_arrows_barb_on_their_own_side() {
// Pointing along +x on a y-down page, left is −y.
+31
View File
@@ -914,3 +914,34 @@ fn read_path_for_a_named_importer_stops_at_the_limit() {
assert!(e.to_string().contains("larger than") && e.to_string().contains("drawing.tuv"), "{e}");
let _ = std::fs::remove_dir_all(&dir);
}
/// #3084, #2096: Export for Screens writes JPGs a strict reader opens, in every colour model and at
/// the qualities and scales the reports used. A four-component (CMYK) file has no JFIF segment,
/// which describes only one- and three-component images (#3140); RGB and gray files keep it.
#[test]
fn export_for_screens_jpgs_open_in_every_colour_model() {
let mut s = session(100.0, 80.0, 1);
s.execute("paint.setFill", &json!({"color": "#cc3300"})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 60, "height": 40})).unwrap();
for (model, components) in [("rgb", 3), ("cmyk", 4), ("gray", 1)] {
for quality in [100, 80, 50] {
let r = s
.execute(
"document.exportForScreens",
&json!({"formats": [{"format": "jpg"}, {"format": "jpg", "scale": 2}], "settings": {"jpg": {"colorModel": model, "quality": quality}}}),
)
.unwrap();
let files = r["files"].as_array().unwrap();
assert_eq!(files.len(), 2, "{model} {quality}: {r}");
for (f, width) in files.iter().zip([100, 200]) {
let bytes = b64(f);
let what = format!("{model} q{quality} {}", f["name"]);
assert_eq!(bytes.get(..2), Some(&[0xFF, 0xD8][..]), "{what}: starts with SOI");
let jfif = bytes.get(2..4) == Some(&[0xFF, 0xE0][..]);
assert_eq!(jfif, components != 4, "{what}: JFIF only on one- and three-component files");
let image = image::load_from_memory(&bytes).unwrap_or_else(|e| panic!("{what}: {e}"));
assert_eq!(image.width(), width, "{what}");
}
}
}
}
+1 -1
View File
@@ -20,7 +20,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|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 (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 (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
),
+38
View File
@@ -115,6 +115,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
reshape_path
),
cmd!(
"text.setPathHandle",
"Move Type Path Handle",
[],
None,
"{id, subpath?, anchor, which: \"in\"|\"out\", x, y, independent?: bool} move a direction handle of an anchor of type on a path's path to (x, y), as path.setHandle moves a path's (independent: that side only, the anchor becomes a corner); the start and end brackets keep their fractions of the path and the text reflows along it (what Direct Selection does dragging a handle of the path)",
has_doc,
set_path_handle
),
cmd!(
"text.fitHeadline",
"Fit Headline",
@@ -1268,6 +1277,35 @@ fn reshape_path(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
fn set_path_handle(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.setPathHandle";
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
let si = super::draw2::usize_opt(p, "subpath", C)?.unwrap_or(0);
let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad(C, "missing anchor"))?;
let out = str_param(p, "which") != Some("in");
let pos = vectorcraft_geom::Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
let independent = switch_or(p, "independent", false, C)?;
let node = s.doc()?.doc.node(id);
let on_path = matches!(node.map(|n| &n.kind), Some(NodeKind::Text(t)) if matches!(t.kind, vectorcraft_doc::TextKind::OnPath { .. }));
if !on_path || node.is_some_and(|n| n.extras().perspective.is_some()) {
return Err(bad(C, "not type on a path outside perspective"));
}
s.edit("Reshape Type Path", |doc, _| {
let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
let before = t.kind.clone();
let within = |t: &TextObject| {
t.type_path().and_then(|p| p.bounds()).is_some_and(|b| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.abs() <= crate::MAX_COORD))
};
if !(t.set_path_handle((si, ai), out, pos, independent) && within(t)) {
t.kind = before;
return Err(bad(C, "no such path anchor, or the path would reach past the canvas"));
}
refresh_bounds(t);
Ok(())
})?;
ok()
}
#[cfg(test)]
mod area_tests;
@@ -2081,3 +2081,35 @@ fn fit_min_percent_for_several_frames_keeps_each_frames_fit() {
assert_eq!(fit(&s, plain), vectorcraft_doc::AreaFit::None, "the plain frame doesn't start shrinking");
assert_eq!(fit(&s, shrink), vectorcraft_doc::AreaFit::ShrinkText { min_percent: 30.0 });
}
/// #3565: `text.setPathHandle` moves a direction handle of type on a path's path, in document
/// space, as `path.setHandle` moves a path's: a smooth anchor's other handle turns with it at
/// its length, `independent` leaves it and makes the anchor a corner; one undo step.
#[test]
fn set_path_handle_reshapes_type_on_a_path() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap();
let path =
s.execute("path.create", &json!({"anchors": [{"x": 100, "y": 300, "in": [50, 300], "out": [150, 300]}, {"x": 400, "y": 300}]})).unwrap();
let text = s.execute("text.createInPath", &json!({"path": path["id"], "mode": "onPath", "text": "Along the curve"})).unwrap();
let id = NodeId(text["id"].as_u64().unwrap());
let first = |s: &Session| match &s.doc().unwrap().doc.node(id).unwrap().kind {
NodeKind::Text(t) => *t.type_path().unwrap().subpaths[0].anchors.first().unwrap(),
_ => panic!("not text"),
};
let smooth = first(&s).kind == vectorcraft_geom::AnchorKind::Smooth;
s.execute("text.setPathHandle", &json!({"id": id.0, "anchor": 0, "which": "out", "x": 100, "y": 250})).unwrap();
let a = first(&s);
assert!((a.h_out.x - 100.0).abs() < 1e-6 && (a.h_out.y - 250.0).abs() < 1e-6, "{a:?}");
if smooth {
assert!((a.h_in.x - 100.0).abs() < 1e-6 && (a.h_in.y - 350.0).abs() < 1e-6, "smooth: the in handle turns with it at its length: {a:?}");
}
s.execute("text.setPathHandle", &json!({"id": id.0, "anchor": 0, "which": "in", "x": 60, "y": 300, "independent": true})).unwrap();
let b = first(&s);
assert_eq!(b.h_out, a.h_out, "independent: the other handle stays");
assert_eq!(b.kind, vectorcraft_geom::AnchorKind::Corner);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(first(&s), a, "one undo step each");
assert!(s.execute("text.setPathHandle", &json!({"id": id.0, "anchor": 9, "which": "out", "x": 0, "y": 0})).is_err(), "no such anchor");
assert!(s.execute("text.setPathHandle", &json!({"id": path["id"], "anchor": 0, "which": "out", "x": 0, "y": 0})).is_err(), "not type");
}
+13 -2
View File
@@ -38,10 +38,11 @@ fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
#[test]
fn builtin_libraries_are_listed_with_original_styles_using_catalogued_effects() {
let mut s = Session::new();
let mut s = session();
let list = run(&mut s, "graphicStyle.libraries", json!({}));
let libs = list["libraries"].as_array().unwrap();
assert!(libs.len() >= 6 && libs.iter().all(|l| l["category"] == "builtIn"), "{libs:?}");
assert!(libs.len() >= 7 && libs.iter().all(|l| l["category"] == "builtIn"), "{libs:?}");
assert!(libs.iter().any(|l| l["id"] == "game-hud" && l["name"] == "Game HUD"), "{libs:?}");
for l in libs {
let id = l["id"].as_str().unwrap();
let g = run(&mut s, "graphicStyle.library", json!({"library": id}));
@@ -56,6 +57,16 @@ fn builtin_libraries_are_listed_with_original_styles_using_catalogued_effects()
}
}
}
let game = run(&mut s, "graphicStyle.library", json!({"library": "game-hud"}));
let game_names: Vec<&str> = game["styles"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect();
assert_eq!(game_names, ["Health Fill", "Mana Glow", "Damage Flash", "Quest Outline", "Coin Shine", "Boss Alert"]);
let id = rect(&mut s, 10.0, 20.0, 40.0, 30.0);
let r = run(&mut s, "graphicStyle.addFromLibrary", json!({"library": "game-hud", "name": "Mana Glow", "apply": true, "ids": [id.0]}));
assert_eq!((names(&r["added"]), r["applied"].as_str()), (vec!["Mana Glow"], Some("Mana Glow")));
let node = doc(&s).node(id).unwrap();
assert_eq!(node.appearance.fill_paint(), Paint::solid(vectorcraft_color::Color::from_hex("#3498db").unwrap()));
assert_eq!(node.appearance.stroke_paint(), Paint::None);
assert_eq!(node.appearance.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["stylize.outerGlow"]);
// By name in any case.
assert_eq!(run(&mut s, "graphicStyle.library", json!({"library": "shadows and glows"}))["id"], "shadows-glows");
assert!(s.execute("graphicStyle.library", &json!({"library": "nope"})).is_err());
+22 -5
View File
@@ -10,7 +10,8 @@
//! is left as it was and the save fails.
//!
//! On macOS `File::sync_all` is a full flush (`F_FULLFSYNC`), which SMB shares refuse with
//! `ENOTSUP` while a plain `fsync` works: [`sync_file`] falls back to the plain one there (#1620).
//! `ENOTSUP` and virtiofs shares (folders shared into a macOS VM) with `ENOTTY`, while a plain
//! `fsync` works: [`sync_file`] falls back to the plain one there (#1620).
use std::fs::{File, OpenOptions};
use std::io::{self, Write as _};
@@ -180,8 +181,9 @@ fn copy_new(from: &Path, path: &Path) -> io::Result<()> {
}
/// Flush `file` to stable storage (`sync_all`). On Apple platforms that is a full flush
/// (`F_FULLFSYNC`); where the volume refuses it with `ENOTSUP` (SMB shares, #1620) this falls back
/// to a plain `fsync`. Any other error, and a failed `fsync`, is returned as it is.
/// (`F_FULLFSYNC`); where the volume refuses it with `ENOTSUP` (SMB shares) or `ENOTTY` (virtiofs
/// shares, #1620) this falls back to a plain `fsync`. Any other error, and a failed `fsync`, is
/// returned as it is.
pub fn sync_file(file: &File) -> io::Result<()> {
match full_sync(file) {
Err(e) if full_flush_unsupported(&e) => plain_fsync(file),
@@ -190,9 +192,10 @@ pub fn sync_file(file: &File) -> io::Result<()> {
}
/// Whether `e` is a volume refusing a full flush. Never off Apple platforms: there `sync_all` is
/// already a plain `fsync`. Checked by its code because std maps `ENOTSUP` to `Uncategorized`.
/// already a plain `fsync`. Checked by its code because std maps `ENOTSUP` and `ENOTTY` to
/// `Uncategorized`.
fn full_flush_unsupported(e: &io::Error) -> bool {
cfg!(target_vendor = "apple") && e.raw_os_error() == Some(ENOTSUP)
cfg!(target_vendor = "apple") && matches!(e.raw_os_error(), Some(ENOTSUP | ENOTTY))
}
#[cfg(target_vendor = "apple")]
@@ -232,6 +235,9 @@ fn full_sync(file: &File) -> io::Result<()> {
const ENOTSUP: i32 = 45;
#[cfg(not(target_vendor = "apple"))]
const ENOTSUP: i32 = 95;
/// What macOS answers when a virtiofs share (a folder shared into a VM) is asked for a full flush.
/// The same number on every Unix.
const ENOTTY: i32 = 25;
#[cfg(test)]
thread_local! {
@@ -414,6 +420,17 @@ mod tests {
let _ = std::fs::remove_dir_all(&d);
}
/// A save to a virtiofs share (#1620: a folder shared into a macOS VM): macOS refuses the full
/// flush there with `ENOTTY` (os error 25), which falls back to a plain `fsync` as `ENOTSUP` does.
/// Elsewhere neither is a refused full flush, and other errors never are.
#[test]
fn a_full_flush_refused_with_enotty_falls_back_like_enotsup() {
let apple = cfg!(target_vendor = "apple");
assert_eq!(full_flush_unsupported(&io::Error::from_raw_os_error(25)), apple, "ENOTTY");
assert_eq!(full_flush_unsupported(&io::Error::from_raw_os_error(ENOTSUP)), apple, "ENOTSUP");
assert!(!full_flush_unsupported(&io::Error::other("the connection was lost")));
}
fn denied<T>() -> io::Result<T> {
Err(io::Error::new(io::ErrorKind::PermissionDenied, "refused by the share"))
}
+54
View File
@@ -1463,6 +1463,60 @@ fn pointer_gesture_forwards_backquote_on_each_event() {
assert_eq!(rect["bounds"]["x"], 100.0, "{doc}");
}
/// The documented Rotate View calls reach the desktop control methods, preserving document
/// coordinates and modifiers, and a successful null reset remains a successful MCP response.
#[test]
fn rotate_view_calls_forward_to_desktop_control() {
use std::cell::RefCell;
use std::rc::Rc;
struct Recording(Rc<RefCell<Vec<(String, Value)>>>);
impl Backend for Recording {
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
self.0.borrow_mut().push((method.into(), params));
Ok(Value::Null)
}
fn has_ui(&self) -> bool {
true
}
fn describe(&self) -> String {
"desktop control contract".into()
}
}
let seen = Rc::new(RefCell::new(Vec::new()));
let mut s = Server::new(Box::new(Recording(seen.clone())));
let selected = call(&mut s, 1, "select_tool", json!({"tool": "rotateView"}));
let turned = call(
&mut s,
2,
"pointer_gesture",
json!({"events": [
{"kind": "down", "x": 300, "y": 150}, {"kind": "drag", "x": 200, "y": 250}, {"kind": "up", "x": 200, "y": 250}
], "mods": {"shift": true}}),
);
let reset = call(&mut s, 3, "command_run", json!({"id": "view.rotateReset", "params": {}}));
for reply in [&selected, &turned, &reset] {
assert_eq!(reply["isError"], false, "{reply}");
}
assert_eq!(text_of(&reset), "null");
assert_eq!(
*seen.borrow(),
vec![
("ui.tool.select".into(), json!({"tool": "rotateView"})),
(
"ui.pointer".into(),
json!({"events": [
{"kind": "down", "x": 300, "y": 150, "space": "doc"},
{"kind": "drag", "x": 200, "y": 250, "space": "doc"},
{"kind": "up", "x": 200, "y": 250, "space": "doc"}
], "mods": {"shift": true}})
),
("engine.execute".into(), json!({"command": "view.rotateReset", "params": {}}))
]
);
}
/// A `backquote` that is not a boolean is an error, and none of the gesture's events runs.
#[test]
fn pointer_gesture_rejects_a_backquote_that_is_not_a_boolean() {
+6
View File
@@ -52,3 +52,9 @@ vectorcraft-text = { workspace = true, features = ["test-fonts"] }
serde_json = { workspace = true }
# `draw_text`, to write test files with live text as other writers do.
krilla = { workspace = true, features = ["simple-text"] }
# An independent raster oracle for gradient placement in PDF forms. These are test-only macOS
# bindings; the exporter stays portable Rust and does not depend on an OS PDF renderer.
[target.'cfg(target_os = "macos")'.dev-dependencies]
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFData"] }
objc2-core-graphics = { version = "0.3", default-features = false, features = ["std", "CGBitmapContext", "CGColorSpace", "CGContext", "CGDataProvider", "CGImage", "CGPDFDocument", "CGPDFPage"] }
+3 -3
View File
@@ -9,12 +9,11 @@ use craft_pdf::write::{self, Metadata};
use krilla::color::separation::{Color as SepColor, SeparationColorant, SeparationSpace};
use krilla::color::{cmyk, luma, rgb};
use krilla::configure::{Archival, PdfVersion};
use krilla::geom::{Path, PathBuilder, Size, Transform};
use krilla::geom::{Path, PathBuilder, Size};
use krilla::image::Image;
use krilla::num::NormalizedF32;
use krilla::page::PageSettings;
use krilla::paint::{Fill, LinearGradient, RadialGradient, SpreadMethod, Stop, Stroke, StrokeDash};
use krilla::surface::Surface;
use krilla::text::{Glyph, GlyphId, KrillaGlyph};
use kurbo::{PathEl, Shape, Vec2};
use vectorcraft_color::{BlendMode, Color, GradientKind, Paint};
@@ -28,6 +27,7 @@ use crate::forms::Mark;
use crate::lab_spot::{find, rfind};
use crate::marks::PageBoxes;
use crate::output::ColorOut;
use crate::surface::Surface;
use crate::{Compatibility, ExportReport, PdfError, PdfOptions, Standard};
mod entry;
@@ -131,7 +131,7 @@ mod tests {
fn writable_bounds_values_the_transform_and_values_through_it() {
let mut pdf = krilla::Document::new();
let mut page = pdf.start_page_with(PageSettings::new(Size::from_wh(100.0, 100.0).unwrap()));
let mut s = page.surface();
let mut s = Surface::page(page.surface(), 100.0);
let r = Rect::new(0.0, 0.0, 10.0, 10.0);
assert!(writable(&s, Affine::IDENTITY, r));
assert!(!writable(&s, Affine::IDENTITY, Rect::new(-2_147_483_649.0, 0.0, 10.0, 10.0)));
+7 -7
View File
@@ -3,9 +3,11 @@ use vectorcraft_geom::Point;
use super::entry::RgbInCmyk;
use super::{
Affine, Arc, BezPath, BlendMode, Color, Compatibility, Document, Exporter, Fill, FillRule, HashMap, HashSet, Node, NormalizedF32, Path,
PathBuilder, PathEl, Rect, Shape, Standard, Surface, Transform, cmyk, krilla, luma, rgb,
PathBuilder, PathEl, Rect, Shape, Standard, Surface, cmyk, krilla, luma, rgb,
};
pub(super) use crate::surface::xf;
/// How deep inline graphics nest before they are left out.
pub(super) const MAX_INLINE_DEPTH: u32 = 4;
@@ -96,11 +98,6 @@ pub(super) const MAX_GRADIENT_PX: f64 = 4096.0;
/// with overflow checks it panics on -2,147,483,648.
pub(super) const MAX_COORD: f64 = 1e9;
pub(super) fn xf(a: Affine) -> Transform {
let c = a.as_coeffs();
Transform::from_row(c[0] as f32, c[1] as f32, c[2] as f32, c[3] as f32, c[4] as f32, c[5] as f32)
}
/// The current transform of surface `s` in the content stream it draws into, without that stream's
/// root transform (a page's y flip).
pub(super) fn ctm(s: &Surface) -> Affine {
@@ -203,9 +200,12 @@ pub(super) fn cover(s: &mut Surface, page: Rect, level: u8, opacity: f32) {
/// group of that opacity that, unlike the writer's opacity groups, is not isolated.
/// The cover is white, so a reader that takes every soft mask as a luminosity mask shows the same.
pub(super) fn constant_mask(s: &mut Surface, page: Rect, alpha: f32) -> krilla::mask::Mask {
let transform = s.group_transform();
let mut sb = s.stream_builder();
let mut ms = sb.surface();
let mut ms = Surface::stream(sb.surface());
ms.push_transform(&transform);
cover(&mut ms, page, 255, alpha);
ms.pop();
ms.finish();
krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Alpha)
}
+14 -3
View File
@@ -22,8 +22,10 @@ impl Exporter<'_> {
/// Opacity mask → luminosity soft mask. Outside the art the backdrop is black (clip) or white;
/// invert is a white Difference rect on top (luminance is linear, so luma(1 − c) = 1 − luma(c)).
fn soft_mask(&mut self, s: &mut Surface, m: &vectorcraft_doc::OpacityMask, page: Rect) -> Option<krilla::mask::Mask> {
let transform = s.group_transform();
let mut sb = s.stream_builder();
let mut ms = sb.surface();
let mut ms = Surface::stream(sb.surface());
ms.push_transform(&transform);
// An opaque backdrop when it matters: white outside the art (no clip), or black for the
// inverting Difference pass below to turn white.
if !m.clip || m.invert {
@@ -39,6 +41,8 @@ impl Exporter<'_> {
self.image_left_out = image_left_out.map(|outer| outer || incomplete);
(self.knockout, self.overprint) = (knockout, overprint);
if incomplete {
ms.pop();
ms.finish();
return None;
}
if m.invert {
@@ -46,6 +50,7 @@ impl Exporter<'_> {
cover(&mut ms, page, 255, 1.0);
ms.pop();
}
ms.pop();
ms.finish();
Some(krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Luminosity))
}
@@ -96,21 +101,27 @@ impl Exporter<'_> {
s.opacity = 1.0;
s
};
let transform = s.group_transform();
let mut sb = s.stream_builder();
let mut ms = sb.surface();
let mut ms = Surface::stream(sb.surface());
ms.push_transform(&transform);
cover(&mut ms, page, 255, 1.0);
let alpha = {
let transform = ms.group_transform();
let mut ab = ms.stream_builder();
let mut als = ab.surface();
let mut als = Surface::stream(ab.surface());
als.push_transform(&transform);
let overprint = std::mem::take(&mut self.overprint);
self.measured_node(&mut als, &shape, page, false, Some(measured));
self.overprint = overprint;
als.pop();
als.finish();
krilla::mask::Mask::new(ab.finish(), krilla::mask::MaskType::Alpha)
};
ms.push_mask(alpha);
cover(&mut ms, page, 0, 1.0);
ms.pop();
ms.pop();
ms.finish();
krilla::mask::Mask::new(sb.finish(), krilla::mask::MaskType::Luminosity)
}
+1 -1
View File
@@ -351,7 +351,7 @@ impl Exporter<'_> {
let cell = Rect::new(0.0, 0.0, pw, ph);
let tiles = def.instances_in(Affine::IDENTITY, cell);
let mut sb = s.stream_builder();
let mut cs = sb.surface();
let mut cs = Surface::stream(sb.surface());
let reaches_out = tiles.iter().any(|t| t.visual_bounds().is_some_and(|b| cell.intersect(b) != b));
let clip = reaches_out.then(|| to_path(&cs, &cell.to_path(0.1))).flatten();
if let Some(c) = &clip {
+2 -2
View File
@@ -2,7 +2,7 @@ use super::entry::RgbInCmyk;
use super::exporter::{constant_mask, cover, to_path, writable, xf};
use super::{
Affine, Arc, Archival, ColorOut, Compatibility, Document, Exporter, HashSet, Image, Metadata, Node, PageSettings, PdfError, PdfVersion, Rect,
Shape, Size, Standard, Vec2, find, krilla, rfind, write,
Shape, Size, Standard, Surface, Vec2, find, krilla, rfind, write,
};
/// A PDF being written, shared by export and print: the file's configuration and metadata, then
@@ -125,7 +125,7 @@ impl Writer {
let size = Size::from_wh(w.max(least) as f32, h.max(least) as f32).ok_or_else(|| PdfError::BadSetting(format!("page size {w} × {h}")))?;
let at = |b: Rect| krilla::geom::Rect::from_ltrb(b.x0 as f32, b.y0 as f32, b.x1 as f32, b.y1 as f32);
let mut page = self.pdf.document_mut().start_page_with(PageSettings::new(size).with_trim_box(at(sheet.trim)).with_bleed_box(at(sheet.bleed)));
let mut s = page.surface();
let mut s = Surface::page(page.surface(), size.height());
let paper = Rect::new(0.0, 0.0, w, h);
if sheet.negative {
cover(&mut s, paper, 255, 1.0);
+10 -2
View File
@@ -13,7 +13,6 @@ use std::io::Read;
use craft_pdf::write::backend as krilla;
use krilla::geom::PathBuilder;
use krilla::graphic::Graphic;
use krilla::surface::Surface;
use std::sync::Arc;
use vectorcraft_doc::{Document, Node, NodeKind};
@@ -21,6 +20,7 @@ use vectorcraft_doc::{Document, Node, NodeKind};
use crate::encrypt::{Lexer, Obj, StreamSpans, Tok, int, stream_spans};
use crate::output::{deflate, text_string};
use crate::patch::{Patch, Xref};
use crate::surface::{Surface, affine};
use crate::{PdfError, PdfSettings, Standard};
/// What a marked form is.
@@ -87,6 +87,9 @@ pub(crate) fn form(s: &mut Surface, mark: Mark, draw: impl FnOnce(&mut Surface))
/// form's drawing space), rounded outwards to whole units: [`finish`] gives the form a box that
/// covers the paper and the art.
pub(crate) fn layer_form(s: &mut Surface, i: usize, paper: vectorcraft_geom::Rect, draw: impl FnOnce(&mut Surface)) {
// The art and its box move into page space together; keeping the old local paper here
// would clip layers on translated artboards or give the importer a spurious clipping group.
let paper = affine(s.group_transform()).transform_rect_bbox(paper);
let corner = |x: f64, y: f64| (x as f32, y as f32);
marked(s, Mark::Layer(i), [corner(paper.x0.floor(), paper.y0.floor()), corner(paper.x1.ceil(), paper.y1.ceil())], draw);
}
@@ -94,8 +97,9 @@ pub(crate) fn layer_form(s: &mut Surface, i: usize, paper: vectorcraft_geom::Rec
/// Draw `draw` as a form of its own, marked `mark`: an empty clip from the mark's first point
/// through `rest`.
fn marked(s: &mut Surface, mark: Mark, rest: [(f32, f32); 2], draw: impl FnOnce(&mut Surface)) {
let transform = s.group_transform();
let mut sb = s.stream_builder();
let mut fs = sb.surface();
let mut fs = Surface::stream(sb.surface());
let (x, y) = mark.at();
let mut pb = PathBuilder::new();
pb.move_to(x, y);
@@ -108,10 +112,14 @@ fn marked(s: &mut Surface, mark: Mark, rest: [(f32, f32); 2], draw: impl FnOnce(
fs.push_clip_path(&p, &krilla::paint::FillRule::NonZero);
fs.pop();
}
fs.push_transform(&transform);
draw(&mut fs);
fs.pop();
fs.finish();
let stream = sb.finish();
s.push_transform(&transform.invert().unwrap_or_default());
s.draw_graphic(Graphic::new(stream, false));
s.pop();
}
// ---------- finishing the written file ----------
+16
View File
@@ -865,6 +865,9 @@ impl<'p> Builder<'p> {
let vx = self.fonts.get(&key).and_then(|f| f.vertical.as_ref()).map_or(500.0, |v| v.centre(glyph));
m * kurbo::Point::new(vx, 880.0)
});
// A horizontal line is gathered in visual order and put back in logical order as a whole:
// a ligature's characters (lam-alef, lam-lam-heh) go in visual order too (#6072).
let text = if top.is_none() { visual_glyph_text(text) } else { text };
let place = |line: &mut TextLine| match top {
Some(top) => line.push_upright(&look, at, opacity, Upright { top }, &text),
None => line.push(&look, at, opacity, advance, &text),
@@ -894,3 +897,16 @@ impl<'p> Builder<'p> {
true
}
}
/// The characters of one glyph in the order they are drawn: a PDF names a ligature's characters
/// in logical order (lam then alef for لا), while a horizontal line of type is gathered glyph by
/// glyph in visual order and only then put back in logical order. Right-to-left characters are
/// turned round here so that putting the line back restores them.
fn visual_glyph_text(text: String) -> String {
if text.chars().nth(1).is_none() {
return text;
}
let Some((order, _)) = vectorcraft_text::logical_order(&text) else { return text };
let chars: Vec<char> = text.chars().collect();
order.iter().filter_map(|&i| chars.get(i)).collect()
}
+1
View File
@@ -70,6 +70,7 @@ mod presets;
mod print;
mod settings;
mod streams;
mod surface;
mod syntax;
pub use editing::{EDITING_FILE, Editing, LEGACY_EDITING_FILE, editing, editing_with};
+140
View File
@@ -0,0 +1,140 @@
//! Keep form XObjects in page space. Quartz resolves shading patterns inside a form against the
//! page's initial space, whereas other readers also apply the transform at the form's invocation.
//! Moving that transform inside the form makes both interpretations agree (including gradients
//! in opacity groups, masks, layers and overprinting art), without rasterizing the art.
//!
//! krilla's page surface has an implicit y flip which `ctm()` does not include. Remember that
//! root separately; a form's surface has an identity root. A group still counts as one push/pop
//! for the drawing code, even though moving its transform takes three krilla pushes.
use std::ops::{Deref, DerefMut};
use craft_pdf::write::backend as krilla;
use krilla::blend::BlendMode;
use krilla::geom::{Path, Transform};
use krilla::mask::Mask;
use krilla::num::NormalizedF32;
use krilla::paint::FillRule;
use vectorcraft_geom::Affine;
pub(crate) fn xf(a: Affine) -> Transform {
let c = a.as_coeffs();
Transform::from_row(c[0] as f32, c[1] as f32, c[2] as f32, c[3] as f32, c[4] as f32, c[5] as f32)
}
pub(crate) fn affine(t: Transform) -> Affine {
Affine::new([t.sx(), t.ky(), t.kx(), t.sy(), t.tx(), t.ty()].map(f64::from))
}
struct Push {
count: usize,
root: Affine,
}
/// A drawing surface whose form groups keep gradients and masks in the same page space.
pub(crate) struct Surface<'a> {
inner: krilla::surface::Surface<'a>,
root: Affine,
pushes: Vec<Push>,
}
impl<'a> Surface<'a> {
/// Wrap a page while recording the writer's implicit Y flip outside its reported CTM.
pub fn page(inner: krilla::surface::Surface<'a>, height: f32) -> Self {
Self { inner, root: Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, height.into()]), pushes: vec![] }
}
/// Wrap a form or pattern stream, which starts without a page's implicit Y flip.
pub fn stream(inner: krilla::surface::Surface<'a>) -> Self {
Self { inner, root: Affine::IDENTITY, pushes: vec![] }
}
/// Current drawing space → page space, for both a form's art and its mask. A singular
/// enclosing transform draws no area: keep that form local rather than trying to invert it.
pub fn group_transform(&self) -> Transform {
let t = xf(self.root * affine(self.inner.ctm()));
if t.invert().is_some() { t } else { Transform::identity() }
}
/// Move the enclosing transform inside a form and record its three pushes as one group.
fn group(&mut self, push: impl FnOnce(&mut krilla::surface::Surface)) {
let t = self.group_transform();
self.inner.push_transform(&t.invert().unwrap_or_default());
push(&mut self.inner);
self.inner.push_transform(&t);
self.pushes.push(Push { count: 3, root: self.root });
self.root = Affine::IDENTITY;
}
/// Apply opacity in page space when it creates a form; full opacity is a plain push.
pub fn push_opacity(&mut self, opacity: NormalizedF32) {
if opacity == NormalizedF32::ONE {
// krilla does not create a form for full opacity.
self.inner.push_opacity(opacity);
self.plain_push();
} else {
self.group(|s| s.push_opacity(opacity));
}
}
pub fn push_isolated(&mut self) {
self.group(|s| s.push_isolated());
}
/// The mask's stream is already in [`Self::group_transform`]'s space, like the group's art.
pub fn push_mask(&mut self, mask: Mask) {
self.group(|s| s.push_mask(mask));
}
fn plain_push(&mut self) {
self.pushes.push(Push { count: 1, root: self.root });
}
pub fn push_transform(&mut self, transform: &Transform) {
self.inner.push_transform(transform);
self.plain_push();
}
pub fn push_clip_path(&mut self, path: &Path, rule: &FillRule) {
self.inner.push_clip_path(path, rule);
self.plain_push();
}
pub fn push_blend_mode(&mut self, mode: BlendMode) {
self.inner.push_blend_mode(mode);
self.plain_push();
}
/// Restore the enclosing coordinate space, undoing every writer push for the last group.
/// An unmatched pop is logged rather than forwarded to the writer's stack.
pub fn pop(&mut self) {
let Some(push) = self.pushes.pop() else {
log::error!("PDF surface pop without a matching push");
return;
};
for _ in 0..push.count {
self.inner.pop();
}
self.root = push.root;
}
pub fn finish(self) {
self.inner.finish();
}
}
// Drawing operations use krilla directly; every push/pop above keeps our coordinate state in
// step. Stream builders are wrapped with `Surface::stream` when their surfaces are created.
impl<'a> Deref for Surface<'a> {
type Target = krilla::surface::Surface<'a>;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl DerefMut for Surface<'_> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.inner
}
}
+40
View File
@@ -477,3 +477,43 @@ fn hebrew_comes_back_in_logical_order() {
assert_eq!(shown(&t[0]), shown(&original), "{text}");
}
}
/// The text objects a PDF of `text` set in `face` imports as.
fn imported_texts(face: &vectorcraft_text::FontFace, text: &str) -> Vec<String> {
let mut pdf = krilla::Document::new();
let mut page = pdf.start_page_with(PageSettings::from_wh(300.0, 200.0).unwrap());
let mut s = page.surface();
let font = Font::new(face.file_data().to_vec().into(), face.face_index()).unwrap();
s.draw_text(KPoint::from_xy(20.0, 50.0), font, 24.0, text, false, TextDirection::Auto);
s.finish();
page.finish();
let r = import_with_report(&pdf.finish().unwrap(), &ImportOptions::default()).unwrap();
texts(&r.document).iter().map(|t| t.plain_text()).collect()
}
#[test]
fn arabic_ligatures_keep_their_letters_in_order() {
// A lam-alef ligature is one glyph whose text is lam then alef: it must not be turned round
// with the line it sits in (#6072).
let db = vectorcraft_text::FontDb::global();
let preferred = ["DejaVu Sans", "Arial", "Times New Roman", "Noto Sans Arabic", "Noto Naskh Arabic"].map(String::from);
// Whether `face` draws lam-alef as one glyph, the ligature the fix is about.
let ligates = |face: &vectorcraft_text::FontFace| {
let style = vectorcraft_doc::CharStyle { font_family: face.family.clone(), font_style: face.style.clone(), ..Default::default() };
let t = TextObject::point(kurbo::Point::ORIGIN, "لا", style);
vectorcraft_text::layout(db, &t).glyphs.len() == 1
};
// An installed Arabic font with a lam-alef ligature whose glyphs come back as live type.
let Some(face) = preferred
.iter()
.chain(&db.families())
.filter_map(|f| db.face(f, "Regular"))
.filter(|f| f.covers('\u{0644}') && f.covers('\u{0627}') && ligates(f))
.find(|f| !imported_texts(f, "بب").is_empty())
else {
return; // No such font on this host.
};
for text in ["لا", "بلال", "السلام عليكم", "لا إله إلا الله"] {
assert_eq!(imported_texts(&face, text), [text], "in {}", face.family);
}
}
+215
View File
@@ -0,0 +1,215 @@
//! Gradients in forms must follow the art, including on pages with a nonzero origin. Quartz
//! exposed a failure the import/render round trip did not: it resolved a form's shading pattern
//! in page space, leaving off-centre gradients painted entirely in their last stop's colour.
//! These fixtures are original, generated geometry; no user artwork or external files are used.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::sync::Arc;
use vectorcraft_color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_doc::{Appearance, ColorMode, Document, Knockout, Node, OpacityMask};
use vectorcraft_geom::{Affine, Point, Rect, shapes};
use vectorcraft_pdf::{PdfOptions, export, import};
use vectorcraft_testkit::raster::{assert_similar, render_artboard};
#[derive(Clone, Copy, Debug)]
enum Form {
Page,
Opacity,
Isolated,
Nested,
PassThrough,
Mask,
Knockout,
Layer,
Overprint,
}
const FORMS: [Form; 9] =
[Form::Page, Form::Opacity, Form::Isolated, Form::Nested, Form::PassThrough, Form::Mask, Form::Knockout, Form::Layer, Form::Overprint];
fn rect(d: &mut Document, bounds: Rect, paint: Paint) -> Node {
Node::path(d.alloc_id(), shapes::rectangle(bounds), Appearance::basic(paint, Paint::None, 0.0))
}
fn group(d: &mut Document, n: Node) -> Node {
Node::group(d.alloc_id(), vec![Arc::new(n)])
}
/// Generate off-centre gradient art in each form type, moving the art and artboard together.
fn fixture(form: Form, kind: GradientKind, mode: ColorMode, origin: Point) -> (Document, PdfOptions) {
let mut d = Document::new_with_mode(120.0, 180.0, mode);
let color = |k| if mode == ColorMode::Cmyk { Color::cmyk(0.0, 0.0, 0.0, k) } else { Color::gray(k) };
let mut gradient = GradientPaint::new(Gradient {
kind,
stops: vec![GradientStop::new(0.0, color(1.0)), GradientStop::new(0.6, color(0.4)), GradientStop::new(1.0, color(0.0))],
..Default::default()
});
let (start, end) = match kind {
GradientKind::Radial => (Point::new(32.0, 35.0), Point::new(60.0, 35.0)),
_ => (Point::new(10.0, 10.0), Point::new(70.0, 70.0)),
};
gradient.geom = Some(GradientGeom { start, end, aspect: 0.8, focal: None });
let mut n = rect(&mut d, Rect::new(10.0, 10.0, 70.0, 70.0), Paint::Gradient(Box::new(gradient)));
let mut opts = PdfOptions::default();
match form {
Form::Page => d.page_isolate = true,
Form::Opacity => n.opacity = 0.6,
Form::Isolated => {
n = group(&mut d, n);
n.isolate = true;
}
Form::Nested => {
n.opacity = 0.75;
n = group(&mut d, n);
n.opacity = 0.8;
}
Form::PassThrough => {
n.blend = BlendMode::Multiply;
n = group(&mut d, n);
n.opacity = 0.6;
}
Form::Mask => {
let mask = rect(&mut d, Rect::new(10.0, 10.0, 44.0, 70.0), Paint::solid(Color::WHITE));
n.extras_mut().mask = Some(Box::new(OpacityMask::new(mask, true)));
}
Form::Knockout => {
let top = rect(&mut d, Rect::new(50.0, 50.0, 80.0, 80.0), Paint::solid(Color::WHITE));
n = Node::group(d.alloc_id(), vec![Arc::new(n), Arc::new(top)]);
n.knockout = Knockout::On;
}
Form::Layer => opts.settings.create_layers = true,
Form::Overprint => n.appearance.fill_mut().unwrap().overprint = true,
}
n.transform(Affine::translate(origin.to_vec2()), false);
d.artboards[0].rect = d.artboards[0].rect + origin.to_vec2();
let layer = d.default_layer().unwrap();
d.insert(Some(layer), 0, n).unwrap();
(d, opts)
}
/// Round trips preserve gradient placement for the form types the importer supports.
#[test]
fn gradients_in_forms_keep_their_position_when_read_back() {
// The importer has a pre-existing mismatch on the knockout's synthesized nested masks;
// Quartz below checks its actual PDF output, along with the other forms.
for form in FORMS.into_iter().filter(|f| !matches!(f, Form::Knockout)) {
for kind in [GradientKind::Linear, GradientKind::Radial] {
for origin in [Point::ORIGIN, Point::new(125.0, -80.0)] {
let (d, opts) = fixture(form, kind, ColorMode::Rgb, origin);
let back = import(&export(&d, &opts).unwrap()).unwrap();
assert_similar(&render_artboard(&back), &render_artboard(&d), 5.0, 0.01);
}
}
}
}
/// Layer form bounds stay page-sized after translation, without extra imported clipping groups.
#[test]
fn translated_layer_forms_cover_the_paper_in_their_new_space() {
// The layer-box repair added upstream must move with the gradient fix's form transform.
// A local-space paper box would otherwise introduce a clipping group on import.
for origin in [Point::ORIGIN, Point::new(125.0, -80.0), Point::new(-250.0, 450.0)] {
let (d, mut opts) = fixture(Form::Layer, GradientKind::Radial, ColorMode::Rgb, origin);
opts.settings.compression.compress_text = false;
let bytes = export(&d, &opts).unwrap();
let text = String::from_utf8_lossy(&bytes);
let boxes: Vec<Vec<f64>> = text
.split("/Subtype/Form")
.skip(1)
.map(|form| {
form.split("/BBox[").nth(1).unwrap().split(']').next().unwrap().split_ascii_whitespace().map(|v| v.parse().unwrap()).collect()
})
.collect();
assert_eq!(boxes, [vec![0.0, 0.0, 120.0, 180.0]], "{origin:?}: the layer covers exactly the page");
let back = import(&bytes).unwrap();
let children = back.layers[0].children().unwrap();
assert_eq!(children.len(), 1);
assert!(children[0].path_data().is_some(), "{origin:?}: no clipping group wraps the layer's art");
}
}
// Test the renderer that exhibited the fault, not just our own importer (which accepted the
// old files). OS bindings are confined to this integration-test module.
#[cfg(target_os = "macos")]
#[allow(unsafe_code)]
mod quartz {
use objc2_core_foundation::CFData;
use objc2_core_graphics::{CGBitmapContextCreate, CGColorSpace, CGContext, CGDataProvider, CGImageAlphaInfo, CGPDFDocument};
use vectorcraft_testkit::raster::Image;
use super::*;
/// Render the first PDF page through Quartz as an oracle independent of our importer.
fn render(bytes: &[u8]) -> Image {
let data = CFData::from_bytes(bytes);
let provider = CGDataProvider::with_cf_data(Some(&data)).unwrap();
let doc = CGPDFDocument::with_provider(Some(&provider)).unwrap();
let page = CGPDFDocument::page(Some(&doc), 1).unwrap();
let space = CGColorSpace::new_device_rgb().unwrap();
let (width, height) = (120, 180);
let mut rgba = vec![255u8; width * height * 4];
// SAFETY: the buffer holds every RGBA row, stays allocated and is not accessed until the
// context is dropped. All inputs are the small, generated fixtures above.
let context = unsafe {
CGBitmapContextCreate(rgba.as_mut_ptr().cast(), width, height, 8, width * 4, Some(&space), CGImageAlphaInfo::PremultipliedLast.0)
}
.unwrap();
CGContext::draw_pdf_page(Some(&context), Some(&page));
drop(context);
Image { width: width as u32, height: height as u32, rgba }
}
/// Quartz places RGB and CMYK radial centres, edges and masks with their translated art.
#[test]
fn quartz_keeps_off_centre_radials_in_transparency_and_layer_forms() {
for mode in [ColorMode::Rgb, ColorMode::Cmyk] {
for form in FORMS {
for origin in [Point::ORIGIN, Point::new(125.0, -80.0)] {
let (d, opts) = fixture(form, GradientKind::Radial, mode, origin);
let img = render(&export(&d, &opts).unwrap());
let centre = img.over_white(32, 35)[0];
let edge = img.over_white(32, 55)[0];
assert!(centre < 150 && edge.saturating_sub(centre) > 40, "{mode:?} {form:?} {origin:?}: centre {centre}, edge {edge}");
assert_eq!(img.over_white(90, 100), [255; 3], "art stays in its bounds");
if matches!(form, Form::Mask) {
assert_eq!(img.over_white(46, 35), [255; 3], "the mask moves with the gradient");
}
}
}
}
}
/// Quartz retains the linear ramp's direction inside forms on a translated artboard.
#[test]
fn quartz_keeps_linear_gradient_directions_in_forms() {
for form in FORMS.into_iter().filter(|f| !matches!(f, Form::Mask | Form::Knockout)) {
let (d, opts) = fixture(form, GradientKind::Linear, ColorMode::Rgb, Point::new(-30.0, 45.0));
let img = render(&export(&d, &opts).unwrap());
let (start, end) = (img.over_white(20, 20)[0], img.over_white(60, 60)[0]);
assert!(end.saturating_sub(start) > 60, "{form:?}: start {start}, end {end}");
}
}
/// A gradient's stop-opacity mask remains aligned with its colour through nested forms.
#[test]
fn quartz_keeps_gradient_stop_opacity_aligned_inside_nested_forms() {
let (mut d, opts) = fixture(Form::Nested, GradientKind::Radial, ColorMode::Rgb, Point::ORIGIN);
let mut leaves = vec![];
d.walk(|n| {
if n.path_data().is_some() {
leaves.push(n.id);
}
});
for id in leaves {
let fill = d.node_mut(id).unwrap().appearance.fill_mut().unwrap();
let Paint::Gradient(g) = &mut fill.paint else { panic!("gradient fixture") };
for stop in &mut g.gradient.stops {
stop.opacity = 0.6 * (1.0 - stop.offset);
}
}
let img = render(&export(&d, &opts).unwrap());
let (centre, edge) = (img.over_white(32, 35)[0], img.over_white(32, 55)[0]);
assert!(centre > 145 && centre < 205 && edge > 240, "centre {centre}, edge {edge}");
}
}
+23
View File
@@ -645,3 +645,26 @@ fn nested_svg_images_past_count_cap_are_left_out() {
let (_d, warnings) = import_with(&svg, &ImportOptions::default()).unwrap();
assert!(warnings.iter().any(|w| w.contains(&format!("more than {MAX_NESTED_SVG_IMAGES} nested SVG images"))));
}
/// #3566: entity references in element content count toward the expansion limit, as in
/// attributes. A 10 KB entity, a second entity that refers to it 250 times, and eight references
/// to the second inside `<desc>` would expand to about 20 MB from a file of a few KB: the import
/// refuses it before the XML reader expands anything. The namespace entities drawing apps write
/// (`&ns_svg;`) still import.
#[test]
fn entities_repeated_in_element_content_are_refused_and_namespace_entities_import() {
let bomb = format!(
r#"<!DOCTYPE svg [<!ENTITY a "{}"><!ENTITY b "{}">]><svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><desc>{}</desc><rect width="5" height="5"/></svg>"#,
"x".repeat(10_000),
"&a;".repeat(250),
"&b;".repeat(8)
);
assert!(bomb.len() < 20_000, "{} bytes", bomb.len());
match import_with(&bomb, &ImportOptions::default()) {
Err(SvgError::Parse(e)) => assert!(e.contains("entities"), "{e}"),
other => panic!("refused as a parse error, not {:?}", other.map(|(_, w)| w)),
}
let ns = r#"<?xml version="1.0"?><!DOCTYPE svg [<!ENTITY ns_svg "http://www.w3.org/2000/svg">]><svg xmlns="&ns_svg;" width="10" height="10"><rect width="5" height="5"/></svg>"#;
let (d, _) = import_with(ns, &ImportOptions::default()).unwrap();
assert_eq!(art(&d).len(), 1, "the rectangle");
}
+41 -3
View File
@@ -512,6 +512,43 @@ fn assert_authored_ownership(t: &TextObject, text: &str, l: &TextLayout, what: &
}
}
/// Repeated glyphs in one cluster can belong to differently painted runs. Match each authored
/// occurrence in order, without needing an installed font, and still reject a changed owner.
#[test]
fn duplicate_authored_glyphs_keep_occurrence_order() {
let mut text = TextObject::point(Point::ZERO, "\u{0651}", style(FAMILY));
text.runs.push(vectorcraft_doc::TextRun::new("\u{0651}", CharStyle { fill: red(), ..style(FAMILY) }));
let glyph = |run| PositionedGlyph {
outline: Default::default(),
run,
byte: 0,
origin: Point::ZERO,
advance: 0.0,
len: 4,
angle: 0.0,
line: 0,
font_id: 0,
gid: 7,
xf: Affine::IDENTITY,
rtl: true,
scale: 1.0,
image: None,
colour: None,
bend: None,
unturned: Affine::IDENTITY,
pivot: Point::ZERO,
carets: Vec::new(),
width: 0.0,
width_start: 0.0,
};
let before = TextLayout { glyphs: vec![glyph(0), glyph(1)], ..TextLayout::default() };
assert_eq!(assert_kashida_ownership(&text, &before, &before, &red(), "duplicate glyphs", false), 0);
let mut wrong = before.clone();
wrong.glyphs[0].run = 1;
assert!(std::panic::catch_unwind(|| assert_kashida_ownership(&text, &before, &wrong, &red(), "changed owner", false)).is_err());
}
/// Compare authored glyph run indices before/after Insert Kashidas (matched by byte and gid;
/// layout order can differ between point and justified area), and assert every inserted tatweel
/// uses the base letter's paint at the cluster start—not `mark_fill`. When `require_mark_last`,
@@ -525,16 +562,17 @@ fn assert_kashida_ownership(
what: &str,
require_mark_last: bool,
) -> usize {
let mut before_runs: std::collections::HashMap<(usize, u32), Vec<usize>> = std::collections::HashMap::new();
let mut before_runs: std::collections::HashMap<(usize, u32), std::collections::VecDeque<usize>> = std::collections::HashMap::new();
for g in before.glyphs.iter().filter(|g| g.len > 0) {
before_runs.entry((g.byte, g.gid)).or_default().push(g.run);
before_runs.entry((g.byte, g.gid)).or_default().push_back(g.run);
}
let mut after_n = 0;
for g in with_kashida.glyphs.iter().filter(|g| g.len > 0) {
after_n += 1;
let runs =
before_runs.get_mut(&(g.byte, g.gid)).unwrap_or_else(|| panic!("{what}: authored glyph {} at {} missing before kashida", g.gid, g.byte));
let want_run = runs.pop().unwrap_or_else(|| panic!("{what}: no remaining before-run for {} at {}", g.gid, g.byte));
// Duplicate glyphs at the same cluster byte retain their occurrence order.
let want_run = runs.pop_front().unwrap_or_else(|| panic!("{what}: no remaining before-run for {} at {}", g.gid, g.byte));
assert_eq!(g.run, want_run, "{what}: authored run at {} gid {}", g.byte, g.gid);
let want_fill = coloured.runs.get(want_run).map(|r| &r.style.fill).unwrap_or_else(|| panic!("{what}: missing run {want_run}"));
assert_eq!(&run_of(coloured, g).style.fill, want_fill, "{what}: authored fill at {}", g.byte);
+31 -2
View File
@@ -557,7 +557,7 @@ fn hit_frame_edge(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, Vec<A
fn handle_anchors(cx: &ToolContext) -> Vec<(NodeId, usize, usize, Anchor)> {
let mut v = vec![];
for &id in &cx.selection.objects {
let Some(pd) = cx.doc.node(id).and_then(|n| n.path_data()) else { continue };
let Some(pd) = cx.doc.node(id).and_then(handle_path) else { continue };
let shown = cx.selection.partial(id);
v.extend(pd.anchors().filter(|(si, ai, _)| shown.is_none_or(|set| set.contains(&(*si, *ai)))).map(|(si, ai, a)| {
let mut a = *a;
@@ -579,6 +579,33 @@ fn handle_anchors(cx: &ToolContext) -> Vec<(NodeId, usize, usize, Anchor)> {
v
}
/// The path whose direction handles Direct Selection shows and drags on `n` once it is selected:
/// a path's, or type on a path's path (#3565), in document space.
fn handle_path(n: &Node) -> Option<Cow<'_, PathData>> {
match &n.kind {
NodeKind::Path { path, .. } => Some(Cow::Borrowed(path)),
NodeKind::Text(t) if is_path_type(n) => t.type_path().map(Cow::Owned),
_ => None,
}
}
/// The direction handles of selected type on a path ([`handle_anchors`]), drawn by the tool:
/// the canvas draws only a path's own (#3565).
fn type_path_handles(cx: &ToolContext) -> Vec<Overlay> {
let mut out = vec![];
for (id, _, _, a) in handle_anchors(cx) {
if !cx.doc.node(id).is_some_and(is_path_type) {
continue;
}
let color = cx.doc.layer_color(id);
for h in [a.h_in, a.h_out].into_iter().filter(|h| h.distance(a.p) > 1e-6) {
out.push(Overlay::Line { a: a.p, b: h, color, dashed: false });
out.push(Overlay::Handle { p: h, color });
}
}
out
}
/// The end of a shown direction handle ([`handle_anchors`]) nearest to `p` within `tol`, and
/// nearer to it than the handle's anchor (a short handle leaves its anchor to be picked): (path,
/// subpath, anchor, is_out).
@@ -897,8 +924,9 @@ impl Tool for DirectSelectionTool {
let (q, guides) = self.handle_snap.snap(cx, (id, si, ai), p, ev.mods.shift);
self.snapped = snapped(q.to_vec2(), p.to_vec2(), ev.mods.shift, &guides);
self.guides = guides;
let cmd = if cx.doc.node(id).is_some_and(is_path_type) { "text.setPathHandle" } else { "path.setHandle" };
vec![Action::Preview(
"path.setHandle".into(),
cmd.into(),
json!({"id": id.0, "subpath": si, "anchor": ai, "which": if out {"out"} else {"in"}, "x": q.x, "y": q.y, "independent": ev.mods.alt}),
)]
}
@@ -1001,6 +1029,7 @@ impl Tool for DirectSelectionTool {
out.push(Overlay::Path { path, color: cx.doc.layer_color(sp.instance), width: 1.0, dashed: false });
}
out.extend(frame_overlays(cx, &self.frame_anchors(cx)));
out.extend(type_path_handles(cx));
let drag = if let State::Bracket(b) = &self.state { Some(b) } else { None };
out.extend(pathtype::overlays(cx, self.bracket_hover, drag));
}
+35
View File
@@ -2334,3 +2334,38 @@ fn a_press_on_a_clipping_path_in_a_dynamic_instance_picks_it() {
outline.iter().find_map(|o| if let Overlay::Path { path, .. } = o { Some(vectorcraft_geom::kurbo::Shape::bounding_box(path)) } else { None });
assert_eq!(drawn, Some(Rect::new(310.0, 310.0, 320.0, 320.0)));
}
/// #3565: selected type on a path shows its path's direction handles, as a path selected whole
/// shows its own, and a press on one drags it with `text.setPathHandle` (Shift: 45° steps round
/// its anchor; Alt: that side only); none with Show handles when multiple anchors are selected off.
#[test]
fn type_on_a_path_shows_and_drags_its_paths_handles() {
let (mut d, text) = doc_with_path_type();
let Some(vectorcraft_doc::NodeKind::Text(t)) = d.node_mut(text).map(|n| &mut n.kind) else { panic!("type") };
let vectorcraft_doc::TextKind::OnPath { path, .. } = &mut t.kind else { panic!("on a path") };
path.subpaths[0].anchors[0].h_out = Point::new(100.0, 200.0);
path.subpaths[0].anchors[1].h_in = Point::new(400.0, 200.0);
let p = paint();
let mut s = Selection::default();
s.add(text);
let c = cx(&d, &s, &p);
let handles = |c: &ToolContext| -> Vec<Point> {
DirectSelectionTool::new(false).overlays(c).iter().filter_map(|o| if let Overlay::Handle { p, .. } = o { Some(*p) } else { None }).collect()
};
assert_eq!(handles(&c), vec![Point::new(100.0, 200.0), Point::new(400.0, 200.0)]);
let mut t = DirectSelectionTool::new(false);
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 101.0, 201.0)), vec![Action::Begin("Reshape".into())]);
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 130.0, 210.0).with_mods(Mods { alt: true, ..Mods::default() }));
assert!(
matches!(&a[..], [Action::Preview(n, v)] if n == "text.setPathHandle" && v["id"] == text.0 && v["subpath"] == 0 && v["anchor"] == 0 && v["which"] == "out" && v["independent"] == true),
"{a:?}"
);
assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 130.0, 210.0)), vec![Action::Commit]);
let mut t = DirectSelectionTool::new(false);
t.pointer(&c, &PointerEvent::new(PointerKind::Down, 399.0, 201.0));
let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 390.0, 120.0).with_mods(Mods { shift: true, ..Mods::default() }));
let Some(Action::Preview(n, v)) = a.first() else { panic!("{a:?}") };
assert_eq!((n.as_str(), &v["anchor"], &v["which"]), ("text.setPathHandle", &json!(1), &json!("in")));
assert!((v["x"].as_f64().unwrap() - 400.0).abs() < 1e-9, "Shift keeps it straight above its anchor: {v}");
assert!(handles(&ToolContext { handles_multiple: false, ..cx(&d, &s, &p) }).is_empty());
}
+6 -2
View File
@@ -428,8 +428,12 @@ impl HandleSnap {
/// Where the handle of anchor `ai` of subpath `si` of path `id` goes with the pointer at `p`,
/// and the guides showing why.
pub fn snap(&mut self, cx: &ToolContext, (id, si, ai): (NodeId, usize, usize), p: Point, shift: bool) -> (Point, Vec<Overlay>) {
let path = cx.doc.node(id).and_then(|n| n.path_data());
if shift && let Some(a) = path.and_then(|pd| pd.subpaths.get(si)?.anchors.get(ai)) {
// A path's anchor, or one of type on a path's path (#3565), in document space.
let anchor = cx.doc.node(id).and_then(|n| match &n.kind {
vectorcraft_doc::NodeKind::Text(t) => t.type_path()?.subpaths.get(si)?.anchors.get(ai).copied(),
_ => n.path_data()?.subpaths.get(si)?.anchors.get(ai).copied(),
});
if shift && let Some(a) = anchor {
return (a.p + constrain_angle_from(p - a.p, 45.0, cx.constrain_angle), vec![]);
}
if self.0.targets.is_none() {
+1
View File
@@ -126,6 +126,7 @@ impl VectorcraftApp {
updates_rt: Default::default(),
frame_rate: Default::default(),
canvas_rect: None,
control_rotate_press: None,
hover_doc: None,
titlebar_from_env: false,
custom_titlebar: false,
+4
View File
@@ -404,6 +404,10 @@ impl VectorcraftApp {
if let Err(e) = switched {
self.ui.status = e.to_string();
}
if before != self.session.tool_id() {
// A control gesture cannot resume after another tool has owned the pointer.
self.control_rotate_press = None;
}
// A newly chosen Artboard tool starts on the active artboard (the view's).
if before != "artboard"
&& self.session.tool_id() == "artboard"
+1
View File
@@ -77,6 +77,7 @@ use tool_overlays::{
print_tiling_overlay, rotate_view_dial, shaper_widget_overlay, slice_overlay, spelling_overlay, thread_overlays,
};
pub(crate) use view::animated_zoom;
pub(crate) use view::drag_rotation;
pub use view::fit;
use view::scroll_bars;
#[cfg(test)]
+1 -7
View File
@@ -337,14 +337,8 @@ fn drag_held(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, d: Drag, p: Pos2)
}
}
Drag::RotateView { start_angle, start_rot, snaps } => {
let c = rect.center();
let a = (p.y - c.y).atan2(p.x - c.x) as f64;
let mut deg = start_rot + (a - start_angle).to_degrees();
if snaps && m.shift {
deg = (deg / 15.0).round() * 15.0;
}
if let Some(vm) = app.view_mut() {
vm.rotation = vectorcraft_geom::normalize_deg(deg);
vm.rotation = super::view::drag_rotation(rect.center(), p, start_angle, start_rot, snaps && m.shift);
}
}
Drag::ZoomBox { start } => {
+10
View File
@@ -1,6 +1,16 @@
use super::drag::{drag_id, drop_press};
use super::{CornerRadius, Drag, Point, Pos2, Rect, SCROLL_BAR, Sense, Stroke, Tokens, Ui, VectorcraftApp, View, Xf, pos2, vec2};
/// The mouse and control-channel Rotate View gestures use the same angle and Shift snapping.
pub(crate) fn drag_rotation(center: Pos2, point: Pos2, start_angle: f64, start_rotation: f64, snap: bool) -> f64 {
let angle = (point.y - center.y).atan2(point.x - center.x) as f64;
let mut degrees = start_rotation + (angle - start_angle).to_degrees();
if snap {
degrees = (degrees / 15.0).round() * 15.0;
}
vectorcraft_geom::normalize_deg(degrees)
}
/// Fit the view (View → Fit Artboard / Fit All / Actual Size, Arrange Tools › Zoom to Selection).
pub fn fit(app: &mut VectorcraftApp, how: &str) {
let Some(rect) = app.canvas_rect else {
+113 -1
View File
@@ -610,10 +610,116 @@ pub(crate) fn save_screenshot_with_limit(
#[cfg(test)]
mod tests;
/// The window and coordinate mapping where a control-channel Rotate View drag began.
#[derive(Clone, Copy)]
pub(crate) struct RotateViewPress {
window: (u64, u32),
document_resets: u64,
xf: Xf,
start_angle: f64,
start_rotation: f64,
}
/// Rotate View belongs to the canvas, not the engine's placeholder tool. Keep its press mapping
/// across requests so document-coordinate drags do not move again as the view turns underneath.
fn rotate_view_pointer(
app: &mut VectorcraftApp,
kind: PointerKind,
position: vectorcraft_geom::Point,
screen: bool,
shift: bool,
) -> Result<(), String> {
let window = app.session.active().zip(crate::doc_windows::current(app)).map(|(d, w)| (d.uid, w.1)).ok_or("no document")?;
let document_resets = app.session.tool_resets();
let stored = if kind == PointerKind::Down { None } else { app.control_rotate_press };
if kind == PointerKind::Drag && stored.is_none() {
return Err("Rotate View needs a down event before a drag".into());
}
let mut press = match stored {
Some(press) => press,
None => {
let rect = app.canvas_rect.ok_or("no canvas yet")?;
let view = app.view().ok_or("no document")?;
RotateViewPress { window, document_resets, xf: Xf::new(rect, view), start_angle: 0.0, start_rotation: view.rotation }
}
};
// A gesture cannot turn a different document/view or resume after its document was reset.
if (press.window, press.document_resets) != (window, document_resets) {
return Err("the Rotate View gesture's window or document changed".into());
}
let point = if screen { egui::pos2(position.x as f32, position.y as f32) } else { press.xf.to_screen(position) };
let center = press.xf.rect.center();
if !point.is_finite() || !center.is_finite() {
return Err("Rotate View needs finite canvas coordinates".into());
}
match kind {
PointerKind::Down => {
press.start_angle = (point.y - center.y).atan2(point.x - center.x) as f64;
app.control_rotate_press = Some(press);
}
PointerKind::Drag => {
app.view_mut().ok_or("no document")?.rotation =
crate::canvas::drag_rotation(center, point, press.start_angle, press.start_rotation, shift);
}
PointerKind::Up => {
app.control_rotate_press = None;
}
PointerKind::Move | PointerKind::DoubleClick => {}
}
Ok(())
}
/// A Rotate View packet accepts only the documented boolean modifier fields.
fn validate_rotate_view_mods(value: Option<&Value>) -> Result<(), String> {
let Some(value) = value else { return Ok(()) };
let Some(fields) = value.as_object() else { return Err("Rotate View `mods` must be an object of booleans".into()) };
if fields.iter().any(|(key, value)| !matches!(key.as_str(), "shift" | "alt" | "cmd" | "ctrl" | "space") || !value.is_boolean()) {
return Err("Rotate View `mods` accepts only boolean shift, alt, cmd, ctrl and space".into());
}
Ok(())
}
/// Reject malformed coordinates and modifiers before any event can change the angle or press.
fn validate_rotate_view_events(params: &Value, events: &[Value]) -> Result<(), String> {
validate_rotate_view_mods(params.get("mods"))?;
for event in events {
for axis in ["x", "y"] {
if event.get(axis).and_then(Value::as_f64).filter(|value| value.is_finite()).is_none() {
return Err(format!("Rotate View `{axis}` must be a finite number"));
}
}
validate_rotate_view_mods(event.get("mods"))?;
if event.get("space").is_some_and(|space| !matches!(space.as_str(), Some("doc" | "screen"))) {
return Err("Rotate View `space` must be doc or screen".into());
}
}
Ok(())
}
/// `ui.pointer`: pointer events through the tools.
fn pointer_events(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
let rotating = app.session.tool_id() == "rotateView";
let before = if rotating { app.view().map(|view| view.rotation) } else { None };
let outcome = pointer_events_inner(app, ctx, req);
if rotating && matches!(&outcome, Outcome::Done(reply) if reply.get("ok").and_then(Value::as_bool) == Some(false)) {
// A failed packet must neither leave a partial turn nor allow a later drag to resume it.
app.control_rotate_press = None;
if let (Some(before), Some(view)) = (before, app.view_mut()) {
view.rotation = before;
}
ctx.request_repaint();
}
outcome
}
/// Apply a pointer packet after Rotate View validation; other tools retain their existing routing.
fn pointer_events_inner(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
let p = &req.params;
let Some(events) = p.get("events").and_then(Value::as_array) else { return err("missing `events`") };
let rotating = app.session.tool_id() == "rotateView";
if rotating && let Err(error) = validate_rotate_view_events(p, events) {
return err(error);
}
let base_mods: Mods = p.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or_default();
let view = app.view_info();
let xf = app.canvas_rect.zip(app.view().copied()).map(|(rect, v)| Xf::new(rect, &v));
@@ -636,6 +742,13 @@ fn pointer_events(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRe
let x = e.get("x").and_then(Value::as_f64).unwrap_or(0.0);
let y = e.get("y").and_then(Value::as_f64).unwrap_or(0.0);
let screen = e.get("space").and_then(Value::as_str) == Some("screen");
let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base_mods);
if rotating {
if let Err(e) = rotate_view_pointer(app, kind, vectorcraft_geom::Point::new(x, y), screen, mods.shift) {
return err(e);
}
continue;
}
let pos = if screen {
match xf {
Some(xf) => xf.to_doc(egui::pos2(x as f32, y as f32)),
@@ -644,7 +757,6 @@ fn pointer_events(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRe
} else {
vectorcraft_geom::Point::new(x, y)
};
let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base_mods);
// A tilt given in screen axes leans the same way on the page as on the screen.
let pen = PointerEvent::json_pen(e).map(|q| match xf {
Some(xf) if screen => crate::canvas::pose_on_page(q, &xf),
+7
View File
@@ -3052,6 +3052,13 @@ digits Malayalam Малаялам
Settings… Настройки…
Setup… Настройка…
Shadows and Glows Сенки и блясъци
Game HUD Игрови HUD
Health Fill Здраве
Mana Glow Мана
Damage Flash Щета
Quest Outline Квест
Coin Shine Монета
Boss Alert Бос
Shape Фигура
Shape Builder Създател на фигури
Shape Builder Regions Области на създателя на фигури
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -766,6 +766,13 @@
Other Library… Druga biblioteka…
Graphic Style Libraries Biblioteke grafičkih stilova
Shadows and Glows Sjene i sjajevi
Game HUD Igraći HUD
Health Fill Zdravlje
Mana Glow Mana
Damage Flash Šteta
Quest Outline Zadatak
Coin Shine Novčić
Boss Alert Bos
Outlines and Rules Obrisi i linije
Hand-Drawn Ručno crtano
Gradient Finishes Gradijentne završnice
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -3926,6 +3926,13 @@ digits Malayalam Malaiàlam
Shading Sombra
Shadow Intensity Intensitat de l’ombra
Shadows and Glows Ombres i resplendors
Game HUD HUD de joc
Health Fill Salut
Mana Glow Mana
Damage Flash Dany
Quest Outline Missió
Coin Shine Moneda
Boss Alert Cap
Shape Forma
Shape Builder Constructor de formes
Shape Builder Delete Suprimeix del constructor de formes
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -775,6 +775,13 @@
Other Library… Jiná knihovna…
Graphic Style Libraries Knihovny grafických stylů
Shadows and Glows Stíny a záře
Game HUD Herní HUD
Health Fill Život
Mana Glow Mana
Damage Flash Poškození
Quest Outline Úkol
Coin Shine Mince
Boss Alert Boss
Outlines and Rules Obrysy a linky
Hand-Drawn Ručně kreslené
Gradient Finishes Přechodové povrchy
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -712,6 +712,13 @@
Other Library… Andet bibliotek…
Graphic Style Libraries Biblioteker med grafiske stile
Shadows and Glows Skygger og gløder
Game HUD Spil-HUD
Health Fill Helbred
Mana Glow Mana
Damage Flash Skade
Quest Outline Quest
Coin Shine Mønt
Boss Alert Boss
Outlines and Rules Konturer og linjer
Hand-Drawn Håndtegnet
Gradient Finishes Gradientfinish
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -3099,6 +3099,13 @@ digits Malayalam Malayalam
Settings… Einstellungen…
Setup… Einrichten…
Shadows and Glows Schatten und Leuchteffekte
Game HUD Spiel-HUD
Health Fill Leben
Mana Glow Mana
Damage Flash Schaden
Quest Outline Quest
Coin Shine Münze
Boss Alert Boss
Shape Form
Shape Builder Formerstellung
Shape Builder Regions Formerstellungsbereiche
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -3601,6 +3601,13 @@ digits Malayalam Μαλαγιάλαμ
Set Zoom Ορισμός ζουμ
Setup… Ρύθμιση…
Shadows and Glows Σκιές και λάμψεις
Game HUD HUD παιχνιδιού
Health Fill Υγεία
Mana Glow Μάνα
Damage Flash Ζημιά
Quest Outline Αποστολή
Coin Shine Νόμισμα
Boss Alert Αρχηγός
Shape Builder Regions Περιοχές δημιουργίας σχημάτων
Shape Effects Εφέ σχημάτων
Shape Mode Λειτουργία σχήματος
Can't render this file because it is too large.
+7
View File
@@ -3139,6 +3139,13 @@ digits Malayalam Malayálam
Settings… Ajustes…
Setup… Configuración…
Shadows and Glows Sombras y resplandores
Game HUD HUD de juego
Health Fill Salud
Mana Glow Maná
Damage Flash Daño
Quest Outline Misión
Coin Shine Moneda
Boss Alert Jefe
Shape Forma
Shape Builder Generador de formas
Shape Builder Regions Regiones del generador de formas
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -766,6 +766,13 @@
Other Library… Muu kirjasto…
Graphic Style Libraries Grafiikkatyylikirjastot
Shadows and Glows Varjot ja hehkut
Game HUD Peli-HUD
Health Fill Terveys
Mana Glow Mana
Damage Flash Vahinko
Quest Outline Tehtävä
Coin Shine Kolikko
Boss Alert Bossi
Outlines and Rules Ääriviivat ja viivat
Hand-Drawn Käsin piirretyt
Gradient Finishes Liukuväriviimeistelyt
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -3131,6 +3131,13 @@ digits Malayalam Malayalam
Settings… Réglages…
Setup… Configuration…
Shadows and Glows Ombres et lueurs
Game HUD HUD de jeu
Health Fill Santé
Mana Glow Mana
Damage Flash Dégâts
Quest Outline Quête
Coin Shine Pièce
Boss Alert Boss
Shape Forme
Shape Builder Concepteur de forme
Shape Builder Regions Zones du Concepteur de forme
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -4238,6 +4238,13 @@ antiAlias Sharp शार्प
Shading शेडिंग
Shadow Intensity शैडो तीव्रता
Shadows and Glows छाया और चमक
Game HUD गेम HUD
Health Fill स्वास्थ्य
Mana Glow माना
Damage Flash क्षति
Quest Outline क्वेस्ट
Coin Shine सिक्का
Boss Alert बॉस
Shaft: दस्ता:
Shape शेप
Shape Builder शेप बिल्डर
Can't render this file because it is too large.
+7
View File
@@ -3753,6 +3753,13 @@ mark-style Japanese Japán
Shades of Gray Szürkeárnyalatok
Shading Árnyékolás
Shadows and Glows Árnyékok és ragyogások
Game HUD Játék HUD
Health Fill Életerő
Mana Glow Mana
Damage Flash Sebzés
Quest Outline Küldetés
Coin Shine Érme
Boss Alert Főellenség
Shape Alakzat
Shape Builder Alakzatépítő
Shape Builder Regions Alakzatépítő területei
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -712,6 +712,13 @@
Other Library… Pustaka lain…
Graphic Style Libraries Pustaka gaya grafis
Shadows and Glows Bayangan dan pendar
Game HUD HUD Game
Health Fill Nyawa
Mana Glow Mana
Damage Flash Damage
Quest Outline Quest
Coin Shine Koin
Boss Alert Boss
Outlines and Rules Garis luar dan garis
Hand-Drawn Gambar tangan
Gradient Finishes Finishing gradien
1 # Indonesian (id) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
712
713
714
715
716
717
718
719
720
721
722
723
724
+7
View File
@@ -3175,6 +3175,13 @@ digits Malayalam Malayalam
Settings… Impostazioni…
Setup… Configurazione…
Shadows and Glows Ombre e bagliori
Game HUD HUD di gioco
Health Fill Salute
Mana Glow Mana
Damage Flash Danno
Quest Outline Missione
Coin Shine Moneta
Boss Alert Boss
Shape Forma
Shape Builder Generatore forme
Shape Builder Regions Aree del Generatore forme
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -799,6 +799,13 @@ digits Malayalam マラヤーラム数字
Other Library… その他のライブラリ…
Graphic Style Libraries グラフィックスタイルライブラリ
Shadows and Glows シャドウと光彩
Game HUD ゲーム HUD
Health Fill 体力
Mana Glow マナ
Damage Flash ダメージ
Quest Outline クエスト
Coin Shine コイン
Boss Alert ボス
Outlines and Rules アウトラインと罫線
Hand-Drawn 手描き風
Gradient Finishes グラデーション仕上げ
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -662,6 +662,13 @@
Other Library… სხვა ბიბლიოთეკა…
Graphic Style Libraries გრაფიკული სტილების ბიბლიოთეკები
Shadows and Glows ჩრდილები და ნათებები
Game HUD თამაშის HUD
Health Fill სიცოცხლე
Mana Glow მანა
Damage Flash ზიანი
Quest Outline ქვესტი
Coin Shine მონეტა
Boss Alert ბოსი
Outlines and Rules კონტურები და ხაზები
Hand-Drawn ხელით დახატული
Gradient Finishes გრადიენტული ზედაპირები
Can't render this file because it is too large.
+7
View File
@@ -792,6 +792,13 @@ digits Malayalam 말라얄람어
Other Library… 다른 라이브러리…
Graphic Style Libraries 그래픽 스타일 라이브러리
Shadows and Glows 그림자와 광선
Game HUD 게임 HUD
Health Fill 체력
Mana Glow 마나
Damage Flash 피해
Quest Outline 퀘스트
Coin Shine 코인
Boss Alert 보스
Outlines and Rules 윤곽선과 괘선
Hand-Drawn 손그림
Gradient Finishes 그레이디언트 마감
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -726,6 +726,13 @@
Other Library… Andere bibliotheek…
Graphic Style Libraries Bibliotheken met grafische stijlen
Shadows and Glows Schaduwen en gloed
Game HUD Game-HUD
Health Fill Leven
Mana Glow Mana
Damage Flash Schade
Quest Outline Quest
Coin Shine Munt
Boss Alert Baas
Outlines and Rules Omtrekken en lijnen
Hand-Drawn Handgetekend
Gradient Finishes Verloopafwerkingen
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -722,6 +722,13 @@
Other Library… Inna biblioteka…
Graphic Style Libraries Biblioteki stylów graficznych
Shadows and Glows Cienie i blaski
Game HUD HUD gry
Health Fill Zdrowie
Mana Glow Mana
Damage Flash Obrażenia
Quest Outline Zadanie
Coin Shine Moneta
Boss Alert Boss
Outlines and Rules Kontury i linie
Hand-Drawn Rysowane odręcznie
Gradient Finishes Wykończenia gradientowe
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -2631,6 +2631,13 @@ digits Malayalam Malaiala
Set the selected color to the current color Definir a cor selecionada para a cor atual
Setup… Configuração…
Shadows and Glows Sombras e Brilhos
Game HUD HUD de jogo
Health Fill Vida
Mana Glow Mana
Damage Flash Dano
Quest Outline Missão
Coin Shine Moeda
Boss Alert Chefe
Shape Forma
Shape Builder Construtor de Formas
Shape Builder Regions Regiões do Construtor de Formas
Can't render this file because it contains an unexpected character in line 6 and column 47.
+7
View File
@@ -3170,6 +3170,13 @@ digits Malayalam Малаялам
Settings… Параметры…
Setup… Настройка…
Shadows and Glows Тени и свечения
Game HUD Игровой HUD
Health Fill Здоровье
Mana Glow Мана
Damage Flash Урон
Quest Outline Квест
Coin Shine Монета
Boss Alert Босс
Shape Фигура
Shape Builder Создание фигур
Shape Builder Regions Области создания фигур
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -3063,6 +3063,13 @@ digits Malayalam Malayalam
Settings… Inställningar…
Setup… Konfigurera…
Shadows and Glows Skuggor och glöd
Game HUD Spel-HUD
Health Fill Hälsa
Mana Glow Mana
Damage Flash Skada
Quest Outline Uppdrag
Coin Shine Mynt
Boss Alert Boss
Shape Form
Shape Builder Formbyggare
Shape Builder Regions Formbyggarområden
Can't render this file because it contains an unexpected character in line 5 and column 25.
+7
View File
@@ -2967,6 +2967,13 @@ digits Malayalam Malayalam
Settings… Ayarlar…
Setup… Kurulum…
Shadows and Glows Gölgeler ve Parıltılar
Game HUD Oyun HUD
Health Fill Can
Mana Glow Mana
Damage Flash Hasar
Quest Outline Görev
Coin Shine Jeton
Boss Alert Boss
Shape Şekil
Shape Builder Şekil Oluşturucu
Shape Builder Regions Şekil Oluşturucu Bölgeleri
Can't render this file because it contains an unexpected character in line 5 and column 25.
+7
View File
@@ -3167,6 +3167,13 @@ digits Malayalam Малаялам
Settings… Параметри…
Setup… Налаштування…
Shadows and Glows Тіні та свічення
Game HUD Ігровий HUD
Health Fill Здоров'я
Mana Glow Мана
Damage Flash Шкода
Quest Outline Квест
Coin Shine Монета
Boss Alert Бос
Shape Фігура
Shape Builder Створення фігур
Shape Builder Regions Області створення фігур
Can't render this file because it contains an unexpected character in line 182 and column 5.
+7
View File
@@ -3499,6 +3499,13 @@ mark-style Japanese Kiểu Nhật
Setup… Thiết lập…
Shading Tô bóng
Shadows and Glows Bóng và ánh sáng
Game HUD HUD trò chơi
Health Fill Máu
Mana Glow Mana
Damage Flash Sát thương
Quest Outline Nhiệm vụ
Coin Shine Xu
Boss Alert Trùm
Shape Hình dạng
Shape Builder Tạo hình dạng
Shape Builder Regions Vùng Tạo hình dạng
Can't render this file because it contains an unexpected character in line 204 and column 5.
+7
View File
@@ -3005,6 +3005,13 @@ digits Malayalam 马拉雅拉姆数字
Settings… 设置…
Setup… 设置…
Shadows and Glows 阴影与光晕
Game HUD 游戏 HUD
Health Fill 生命
Mana Glow 法力
Damage Flash 伤害
Quest Outline 任务
Coin Shine 金币
Boss Alert 首领
Shape 形状
Shape Builder 形状生成器
Shape Builder Regions 形状生成器区域
Can't render this file because it contains an unexpected character in line 2 and column 73.
+7
View File
@@ -4090,6 +4090,13 @@ digits Malayalam 馬來亞拉姆數字
Settings… 設定…
Setup… 設定…
Shadows and Glows 陰影與光暈
Game HUD 遊戲 HUD
Health Fill 生命
Mana Glow 魔力
Damage Flash 傷害
Quest Outline 任務
Coin Shine 金幣
Boss Alert 首領
Shape 形狀
Shape Builder 形狀建立
Shape Builder Regions 形狀建立區域
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -187,6 +187,8 @@ pub struct VectorcraftApp {
frame_rate: frame_rate::FrameRate,
/// Canvas rect of the last frame (screen points), for control-channel coordinate mapping.
pub canvas_rect: Option<egui::Rect>,
/// An unfinished control-channel Rotate View gesture, kept only for this app instance.
pub(crate) control_rotate_press: Option<control::RotateViewPress>,
/// Hover position in document coordinates.
pub hover_doc: Option<vectorcraft_geom::Point>,
/// System clipboard: SVG to publish next frame, the last SVG we published (so pasting it back
+22 -10
View File
@@ -949,6 +949,7 @@ impl Transaction {
}
if let Some(saved) = &location.group {
apply(&mut candidate, Action::ResizeGroup { panel: panel.clone(), height: saved.height })?;
apply(&mut candidate, Action::ResizeFlyoutWidth { panel: panel.clone(), width: saved.flyout_width })?;
apply(&mut candidate, Action::SetMinimized { panel: panel.clone(), minimized: saved.minimized })?;
}
self.workspace.layout = candidate;
@@ -1087,6 +1088,7 @@ fn known(action: &Action<String>) -> bool {
| Action::SetIconic { panel, .. }
| Action::ResizeColumn { panel, .. }
| Action::ResizeGroup { panel, .. }
| Action::ResizeFlyoutWidth { panel, .. }
| Action::ResizeIconic { panel, .. }
| Action::SetNative { panel, .. }
| Action::SetMonitor { panel, .. } => registered(panel),
@@ -1169,7 +1171,7 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
if let Some(panel) = app.dock_rt.raise.take() {
dock = dock.raise_panel(panel);
}
let mut output = dock.show_content(ui, &layout, &style, &mut PanelContent { app: &mut *app });
let mut output = dock.show_content(ui, &layout, &style, &mut PanelContent { app: &mut *app, layout: &layout });
app.dock_rt.space = craft_ui::docking::float_space(&ctx, area);
if let Some(panel) = output.focused.take() {
app.dock_rt.focused = Some(panel);
@@ -1343,9 +1345,11 @@ pub(crate) fn floating_size(panel: &str) -> egui::Vec2 {
/// The panels' content, labels and icons.
struct PanelContent<'a> {
app: &'a mut VectorcraftApp,
layout: &'a Layout<String>,
}
impl craft_ui::docking::DockContent<String> for PanelContent<'_> {
/// Draw a registered panel body, fitting untouched flyouts and pinning sized panels' footers.
fn body(&mut self, ui: &mut egui::Ui, id: &String) {
if id == CANVAS {
crate::toolbar::show_beside_canvas(self.app, ui);
@@ -1364,15 +1368,23 @@ impl craft_ui::docking::DockContent<String> for PanelContent<'_> {
}
let width = ui.available_width();
let height = (ui.available_height() - 20.0).max(0.0);
if DockTab::from_id(id).is_some() {
crate::dock::panel_body(self.app, ui, id, width, height);
} else {
// Shrinking to the content's height lets craft-ui fit a float or flyout to it.
crate::widgets::panel_scroll(ui, egui::ScrollArea::vertical().id_salt(("shared-panel-body", id)).auto_shrink([false, true]), |ui| {
let width = ui.available_width();
crate::dock::panel_body(self.app, ui, id, width, height)
});
}
let fitted = in_iconic_column(self.layout, id)
&& self.layout.location(id).ok().and_then(|location| location.group).is_none_or(|group| group.height.is_none());
// Properties embeds other panels as sections, so their local button rows
// must remain beside those sections rather than pinning to the outer panel.
let bottom = (!fitted && id != "properties").then_some(back.bottom() - 10.0);
let info = egui::UiStackInfo::default().with_tag_value(crate::widgets::PANEL_FOOTER, bottom);
ui.scope_builder(egui::UiBuilder::new().ui_stack_info(info), |ui| {
if DockTab::from_id(id).is_some() {
crate::dock::panel_body(self.app, ui, id, width, height);
} else {
// Shrinking to the content's height lets craft-ui fit a float or flyout to it.
crate::widgets::panel_scroll(ui, egui::ScrollArea::vertical().id_salt(("shared-panel-body", id)).auto_shrink([false, true]), |ui| {
let width = ui.available_width();
crate::dock::panel_body(self.app, ui, id, width, height)
});
}
});
if field_had_keyboard {
keep_field_ending_keys(ui);
}
+121 -1
View File
@@ -12,13 +12,16 @@ struct Harness {
app: VectorcraftApp,
ctx: egui::Context,
time: f64,
cursor: egui::CursorIcon,
nodes: Vec<(egui::accesskit::NodeId, egui::accesskit::Node)>,
}
impl Harness {
/// Create a blank document and settle the initial docking layout over three frames.
fn new() -> Self {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
let mut h = Self { app, ctx: egui::Context::default(), time: 0.0 };
let mut h = Self { app, ctx: egui::Context::default(), time: 0.0, cursor: egui::CursorIcon::Default, nodes: Vec::new() };
for _ in 0..3 {
h.frame(vec![]);
}
@@ -40,6 +43,7 @@ impl Harness {
self.run(input);
}
/// Draw one application frame and retain its cursor and accessibility output for assertions.
fn run(&mut self, input: egui::RawInput) {
let app = &mut self.app;
let mut out = self.ctx.run_ui(input, |ui| {
@@ -47,6 +51,8 @@ impl Harness {
app.ui(ui);
});
out.textures_delta.clear();
self.cursor = out.platform_output.cursor_icon;
self.nodes = out.platform_output.accesskit_update.map_or_else(Vec::new, |update| update.nodes);
}
fn hit(&self, hit: &Hit<String>) -> Option<Rect> {
@@ -138,6 +144,120 @@ fn an_icon_opens_its_flyout_and_open_panel_follows() {
assert_eq!(craft_ui::docking::flyout::<String>(&h.ctx, area_id()), None);
}
/// #5713: real Glyphs and Artboards bodies must retain a flyout resize through
/// content fitting, closing/reopening and serialization of the workspace layout.
#[test]
fn glyphs_and_artboards_flyouts_keep_user_sizes() {
for panel in ["glyphs", "artboards"] {
let mut h = Harness::new();
let icon = h.hit(&Hit::Icon(panel.into())).unwrap().center();
h.click(icon);
// Glyphs reserves space from the available height, so its untouched auto-fit
// converges over several frames before the first user resize.
for _ in 0..30 {
h.frame(vec![]);
}
// Area memory can include a panel body's minimum allocation beyond its clip.
// The frame's actual edge widgets describe the visible, resizable rectangle.
let rect = |h: &Harness| {
let id = area_id().with("flyout");
let left = h.ctx.read_response(id.with("resize-left")).unwrap().rect;
let bottom = h.ctx.read_response(id.with("resize-bottom")).unwrap().rect;
Rect::from_min_max(pos2(left.left(), left.top() - 12.0), pos2(bottom.right() + 12.0, bottom.bottom()))
};
let before = rect(&h);
for (at, cursor) in [
(before.left_center() + vec2(2.0, 0.0), egui::CursorIcon::ResizeHorizontal),
(before.center_bottom() - vec2(0.0, 2.0), egui::CursorIcon::ResizeVertical),
] {
h.frame(vec![Event::PointerMoved(at)]);
h.frame(vec![]);
assert_eq!(h.cursor, cursor, "{panel} at {at:?}, frame {before:?}");
}
let from = before.left_bottom() + vec2(2.0, -2.0);
h.drag(from, from + vec2(-100.0, 150.0));
for _ in 0..3 {
h.frame(vec![]);
}
let grown = rect(&h);
assert!((grown.width() - before.width() - 100.0).abs() < 1.0, "{panel}: {before:?} -> {grown:?}");
assert!((grown.height() - before.height() - 150.0).abs() < 1.0, "{panel}: {before:?} -> {grown:?}");
assert_eq!(grown.right(), before.right(), "the icon-column edge stays anchored");
h.click(icon);
assert_eq!(craft_ui::docking::flyout::<String>(&h.ctx, area_id()), None);
h.click(icon);
for _ in 0..3 {
h.frame(vec![]);
}
assert_eq!(rect(&h).size(), grown.size());
let saved = serde_json::to_string(&h.layout()).unwrap();
let mut restored = Harness::new();
restored.app.ui.docking = Some(serde_json::from_str(&saved).unwrap());
for _ in 0..3 {
restored.frame(vec![]);
}
restored.click(restored.hit(&Hit::Icon(panel.into())).unwrap().center());
for _ in 0..3 {
restored.frame(vec![]);
}
assert_eq!(rect(&restored).size(), grown.size(), "{panel} saved layout");
}
}
/// Footer controls stay at the resized panel's bottom, including empty or short lists.
/// The same shared footer is used by the other icon panels and library windows.
#[test]
fn expanded_panel_footers_stay_at_the_bottom_with_short_and_long_lists() {
for (panel, label, artboards) in [
("artboards", "New Artboard", 1),
("artboards", "New Artboard", 30),
("brushes", "New Brush", 1),
("swatches", "New Swatch", 1),
("symbols", "New Symbol", 1),
("graphicStyles", "New Graphic Style (Alt-click to name it)", 1),
("appearance", "Add New Stroke", 1),
("charStyles", "Create New Character Style", 1),
("paraStyles", "Create New Paragraph Style", 1),
("colorGuide", "Save color group to Swatch panel", 1),
("links", "Show Link Info", 1),
("actions", "Create New Action", 1),
("variables", "Capture Data Set", 1),
("assetExport", "Export for Screens…", 1),
("cssProperties", "Generate CSS: the whole document's CSS while nothing is selected", 1),
("svgInteractivity", "Link JavaScript Files…", 1),
("colorThemes", "Add to Swatches", 1),
("glyphs", "Zoom In", 1),
] {
let mut h = Harness::new();
h.ctx.enable_accesskit();
for _ in 1..artboards {
h.app.run("artboard.new", json!({})).unwrap();
}
h.app.run("window.panel.dock", json!({"panel":panel,"icon":true})).unwrap();
let mut layout = h.layout();
layout.apply(Action::ResizeGroup { panel: panel.into(), height: Some(540.0) }).unwrap();
layout.apply(Action::ResizeFlyoutWidth { panel: panel.into(), width: Some(420.0) }).unwrap();
h.app.ui.docking = Some(layout);
h.app.ui.open_panel = Some(panel.into());
for _ in 0..5 {
h.frame(vec![]);
}
let bottom = h.ctx.read_response(area_id().with("flyout").with("resize-bottom")).unwrap().rect.bottom();
let control = h
.nodes
.iter()
.find(|(_, node)| node.label() == Some(label))
.and_then(|(_, node)| node.bounds())
.unwrap_or_else(|| panic!("missing {label} in {panel}"));
assert!((control.y1 as f32 - (bottom - 10.0)).abs() < 2.0, "{panel}/{artboards}: footer {control:?}, bottom {bottom}");
if panel == "artboards" && artboards == 1 {
let at = pos2(((control.x0 + control.x1) * 0.5) as f32, ((control.y0 + control.y1) * 0.5) as f32);
h.click(at);
assert_eq!(h.app.session.active().unwrap().doc.artboards.len(), 2, "the pinned New button works");
}
}
}
#[test]
fn a_tab_dragged_out_floats_and_comes_back_where_it_was_after_its_window_closes() {
let mut h = Harness::new();
+58 -6
View File
@@ -1377,6 +1377,7 @@ enum InclusionChange {
Step(usize, usize, usize, bool),
}
/// Draw action sets, steps and playback controls, keeping the list above a pinned footer.
pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
// The selected action as (set, action). Read as the type it is stored as: egui keeps a
@@ -1398,7 +1399,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
let mut expanded: BTreeSet<u64> = pstate(ui.ctx(), "actions-expanded");
let mut inclusion_changes = Vec::new();
let mut play_req = None;
egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| {
let list_height = widgets::panel_list_height(ui, 260.0, 0.0).min(260.0);
egui::ScrollArea::vertical().max_height(list_height).min_scrolled_height(list_height.min(64.0)).show(ui, |ui| {
for (si, set) in app.ui.action_sets.iter().enumerate() {
let (r, resp) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 20.0), egui::Sense::click());
if sets_selected.contains(&si) && selected.is_none() {
@@ -1551,9 +1553,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
set_pstate(ui.ctx(), SELECTED_STEP, step_selected);
app.ui.action_set = current;
app.ui.action_sets_selected = sets_selected;
widgets::divider(ui);
if app.ui.action_sets.is_empty() {
dim_label(ui, tl!("No actions. Press ● to record."));
}
// Bottom bar: stop, record, play, new set, new action, delete.
ui.horizontal(|ui| {
widgets::bottom_bar(ui, |ui| {
let recording = app.ui.recording.is_some();
if widgets::icon_button(ui, "square", tl!("Stop Playing/Recording"), false, 24.0).clicked()
&& let Err(e) = app.run(STOP_RECORDING, json!({}))
@@ -1598,9 +1602,6 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
app.status(e);
}
let _ = icons::exists;
if app.ui.action_sets.is_empty() {
dim_label(ui, tl!("No actions. Press ● to record."));
}
}
#[cfg(test)]
@@ -1608,6 +1609,57 @@ mod tests {
use super::*;
use vectorcraft_engine::Session;
/// A short sized panel clips the list above its footer and can scroll to select its final row.
#[test]
fn a_short_actions_panel_keeps_its_last_row_reachable_above_the_footer() {
use egui::{Event, PointerButton, Rect, UiBuilder, UiStackInfo, pos2, vec2};
for height in [60.0, 120.0] {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let ctx = egui::Context::default();
let mut time = 0.0;
let mut frame = |app: &mut VectorcraftApp, events| {
time += 0.1;
let input = egui::RawInput {
time: Some(time),
screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(300.0, height))),
events,
..Default::default()
};
let mut output = ctx.run_ui(input, |ui| {
let info = UiStackInfo::default().with_tag_value(widgets::PANEL_FOOTER, Some(height));
ui.scope_builder(UiBuilder::new().ui_stack_info(info), |ui| show(app, ui));
});
output.textures_delta.clear();
output.shapes
};
let last_row = |shapes: &[egui::epaint::ClippedShape]| {
shapes
.iter()
.find_map(|shape| match &shape.shape {
egui::Shape::Text(text) if text.galley.text() == "Reflect Horizontal" => Some((text.visual_bounding_rect(), shape.clip_rect)),
_ => None,
})
.expect("the final action is drawn")
};
for _ in 0..3 {
let shapes = frame(&mut app, vec![]);
assert!(last_row(&shapes).1.bottom() <= height - 31.0, "height={height}: the list overlaps its footer");
}
for _ in 0..12 {
frame(&mut app, crate::toolbar::tests::wheel(pos2(100.0, 12.0), 1000.0));
}
let shapes = frame(&mut app, vec![]);
let (row, clip) = last_row(&shapes);
assert!(clip.contains_rect(row), "height={height}: the final row {row:?} is outside {clip:?}");
let at = row.center();
let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
for events in [vec![Event::PointerMoved(at)], vec![button(true)], vec![button(false)]] {
frame(&mut app, events);
}
assert_eq!(pstate::<Option<(usize, usize)>>(&ctx, "actions-selected"), Some((0, 5)));
}
}
#[test]
fn defaults_are_valid_commands() {
for set in default_sets() {
+10 -2
View File
@@ -503,6 +503,7 @@ fn select_copies(app: &mut VectorcraftApp, all: &[usize], r: &Value) {
}
}
/// Draw the artboard list and its commands, filling the space above a sized panel's footer.
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let abs: Vec<String> = app.session.active().map(|d| d.doc.artboards.iter().map(|a| a.name.clone()).collect()).unwrap_or_default();
@@ -517,10 +518,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let mut row_clicked = false;
// A row dropped on another row: (from, to), the index it lands at.
let mut dropped = None;
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);
let list_rect = widgets::list_box(ui, |ui| {
ui.set_min_height(110.0);
ui.set_min_height(if fixed { list_height.min(110.0) } else { 110.0 });
ui.spacing_mut().item_spacing.y = 0.0;
let scrolled = egui::ScrollArea::vertical().id_salt("ab-scroll").max_height(220.0).show(ui, |ui| {
let scroll = egui::ScrollArea::vertical()
.id_salt("ab-scroll")
.max_height(list_height)
.min_scrolled_height(list_height.min(64.0))
.auto_shrink([false, !fixed]);
let scrolled = scroll.show(ui, |ui| {
for (i, name) in abs.iter().enumerate() {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click_and_drag());
if resp.drag_started() {
+10 -4
View File
@@ -212,6 +212,7 @@ fn insert(app: &mut VectorcraftApp, ctx: &egui::Context, ch: char) {
}
}
/// Browse and insert glyphs from the chosen font, with font controls below the glyph grid.
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let db = FontDb::global();
@@ -244,14 +245,14 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let cols = ((w / cell).floor() as usize).max(1);
let rows = chars.len().div_ceil(cols);
let px = (cell * ui.ctx().pixels_per_point()).round().clamp(8.0, 256.0) as u32;
let list_h = (ui.available_height() - 64.0).max(cell * 3.0);
let list_h = widgets::panel_list_height(ui, (ui.available_height() - 64.0).max(cell * 3.0), 33.0);
let hover_id = egui::Id::unique("gl-hover");
let selected = selected_char(app);
// The selected character and the rows shown last frame: the grid scrolls to the selected glyph
// only when the selection moves to another character, so it still scrolls freely meanwhile.
let follow_id = egui::Id::unique("gl-follow");
let (followed, shown): (Option<char>, std::ops::Range<usize>) = ui.ctx().data(|d| d.get_temp(follow_id)).unwrap_or_default();
let mut area = egui::ScrollArea::vertical().max_height(list_h).auto_shrink([false, false]);
let mut area = egui::ScrollArea::vertical().max_height(list_h).min_scrolled_height(list_h.min(64.0)).auto_shrink([false, false]);
if selected != followed
&& let Some(c) = selected
&& let Ok(i) = chars.binary_search_by_key(&c, |x| x.0)
@@ -308,6 +309,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
/// The family and style menus of the font the panel browses, and Zoom Out and Zoom In.
fn font_row(app: &mut VectorcraftApp, ui: &mut Ui, family: &str, style: &str, w: f32) {
let db = FontDb::global();
widgets::panel_footer_space(ui, 24.0);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
// Picks the font the panel browses only: nothing is previewed on the document.
@@ -371,10 +373,14 @@ fn alternates(app: &mut VectorcraftApp, ui: &mut Ui, w: f32) {
let cols = ((w / cell).floor() as usize).max(1);
let rows = list.len().div_ceil(cols);
let px = (cell * ui.ctx().pixels_per_point()).round().clamp(8.0, 256.0) as u32;
let list_h = (ui.available_height() - 64.0).max(cell * 3.0);
let list_h = widgets::panel_list_height(ui, (ui.available_height() - 64.0).max(cell * 3.0), 33.0);
let mut pick = None;
// A scroll position of its own for each character, so the grid starts at the top for another.
let area = egui::ScrollArea::vertical().id_salt(("gl-alternates", id.0, byte)).max_height(list_h).auto_shrink([false, false]);
let area = egui::ScrollArea::vertical()
.id_salt(("gl-alternates", id.0, byte))
.max_height(list_h)
.min_scrolled_height(list_h.min(64.0))
.auto_shrink([false, false]);
area.show_rows(ui, cell, rows, |ui, range| {
for r in range {
ui.horizontal(|ui| {
@@ -928,6 +928,7 @@ mod tests {
thumbs.receive(ctx, done);
}
/// Prioritize the shown document’s thumbnails, requeue them when revisited, and retain cached rows.
#[test]
fn the_shown_documents_thumbnails_are_made_before_the_queued_ones_of_another() {
let ctx = egui::Context::default();
@@ -957,10 +958,15 @@ mod tests {
assert!(thumbs.texture(&ctx, &src_b, n, 24).is_some(), "the shown document's thumbnails are shown");
}
// Shown again, document 1's rows are asked for again and come back.
// Hold the worker so texture() cannot collect a completed row before the count.
let release = hold(&thumbs);
for n in &a_rows {
assert!(thumbs.texture(&ctx, &src_a, n, 24).is_none());
}
assert_eq!(thumbs.pending.keys().filter(|k| k.0 == 1).count(), 3);
release.send(()).expect("the held job is waiting");
let held = thumbs.worker.as_ref().and_then(ThumbWorker::wait).expect("the held job finishes");
assert_eq!(held.key.0, (0, NodeId(0)));
for _ in &a_rows {
receive_checked(&ctx, &mut thumbs, 1, &a, &a_rows);
}
+44 -40
View File
@@ -97,6 +97,7 @@ pub(crate) fn delete_active_dataset(app: &mut VectorcraftApp) {
app.run("dataset.delete", json!({})).ok();
}
/// Draw document variables, data sets and their two-row footer of capture and binding controls.
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let Some(st) = app.session.active() else {
widgets::dim_label(ui, tl!("No document"));
@@ -209,46 +210,49 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
// The bottom bar: make the selection dynamic, define and delete what the lists show, and
// lock the variables.
ui.separator();
ui.horizontal(|ui| {
if widgets::icon_button_enabled(ui, "type", tl!("Make Text Dynamic"), false, single_type && !locked, 24.0).clicked() {
app.run("variable.makeTextDynamic", json!({})).ok();
}
if widgets::icon_button_enabled(ui, "eye", tl!("Make Visibility Dynamic"), false, has_selection && !locked, 24.0).clicked() {
app.run("variable.makeVisibilityDynamic", json!({})).ok();
}
if widgets::icon_button_enabled(ui, "image", tl!("Make Image Dynamic"), false, single_image && !locked, 24.0).clicked() {
app.run("variable.makeImageDynamic", json!({})).ok();
}
if widgets::icon_button_enabled(ui, "chart-column", tl!("Make Graph Dynamic"), false, selection_graph && !locked, 24.0).clicked() {
app.run("variable.makeGraphDynamic", json!({})).ok();
}
if widgets::icon_button_enabled(ui, "plus", tl!("New Variable…"), false, !locked, 24.0).clicked() {
crate::menus::invoke(app, "variable.define", json!({}));
}
if widgets::icon_button_enabled(ui, "link-2-off", tl!("Unbind Variable"), false, selection_bound, 24.0).clicked() {
app.run("variable.unbind", json!({"ids": selected})).ok();
}
// The bin deletes the highlighted variable only, as the reference panel's does.
if widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Variable"), false, highlighted.is_some() && !locked, 24.0).clicked() {
delete_highlighted(app);
}
// The lock button is drawn pressed while the variables are locked.
let tip = if locked { tl!("Unlock Variables") } else { tl!("Lock Variables") };
if widgets::icon_button(ui, "lock", tip, locked, 24.0).clicked() {
app.run("variable.lock", json!({})).ok();
}
});
ui.horizontal(|ui| {
// Capture records what the art holds now as a new row (paste: take it in); Update
// writes the art back into the row that is active (save: write it out).
if widgets::icon_button(ui, "clipboard-paste", tl!("Capture Data Set"), false, 24.0).clicked() {
app.run("dataset.capture", json!({})).ok();
}
let can_update = active.is_some();
if widgets::icon_button_enabled(ui, "save", tl!("Update Data Set"), false, can_update, 24.0).clicked() {
app.run("dataset.update", json!({})).ok();
}
let footer_height = 48.0 + 2.0 * ui.spacing().item_spacing.y + 1.0;
widgets::panel_footer(ui, footer_height, |ui| {
ui.add(egui::Separator::default().spacing(1.0));
ui.horizontal(|ui| {
if widgets::icon_button_enabled(ui, "type", tl!("Make Text Dynamic"), false, single_type && !locked, 24.0).clicked() {
app.run("variable.makeTextDynamic", json!({})).ok();
}
if widgets::icon_button_enabled(ui, "eye", tl!("Make Visibility Dynamic"), false, has_selection && !locked, 24.0).clicked() {
app.run("variable.makeVisibilityDynamic", json!({})).ok();
}
if widgets::icon_button_enabled(ui, "image", tl!("Make Image Dynamic"), false, single_image && !locked, 24.0).clicked() {
app.run("variable.makeImageDynamic", json!({})).ok();
}
if widgets::icon_button_enabled(ui, "chart-column", tl!("Make Graph Dynamic"), false, selection_graph && !locked, 24.0).clicked() {
app.run("variable.makeGraphDynamic", json!({})).ok();
}
if widgets::icon_button_enabled(ui, "plus", tl!("New Variable…"), false, !locked, 24.0).clicked() {
crate::menus::invoke(app, "variable.define", json!({}));
}
if widgets::icon_button_enabled(ui, "link-2-off", tl!("Unbind Variable"), false, selection_bound, 24.0).clicked() {
app.run("variable.unbind", json!({"ids": selected})).ok();
}
// The bin deletes the highlighted variable only, as the reference panel's does.
if widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Variable"), false, highlighted.is_some() && !locked, 24.0).clicked() {
delete_highlighted(app);
}
// The lock button is drawn pressed while the variables are locked.
let tip = if locked { tl!("Unlock Variables") } else { tl!("Lock Variables") };
if widgets::icon_button(ui, "lock", tip, locked, 24.0).clicked() {
app.run("variable.lock", json!({})).ok();
}
});
ui.horizontal(|ui| {
// Capture records what the art holds now as a new row (paste: take it in); Update
// writes the art back into the row that is active (save: write it out).
if widgets::icon_button(ui, "clipboard-paste", tl!("Capture Data Set"), false, 24.0).clicked() {
app.run("dataset.capture", json!({})).ok();
}
let can_update = active.is_some();
if widgets::icon_button_enabled(ui, "save", tl!("Update Data Set"), false, can_update, 24.0).clicked() {
app.run("dataset.update", json!({})).ok();
}
});
});
}
+158
View File
@@ -30,6 +30,13 @@ fn with_document() -> VectorcraftApp {
app
}
/// Send a control request and keep its success/error value for protocol assertions.
fn control_response(app: &mut VectorcraftApp, ctx: &egui::Context, method: &str, params: Value) -> Value {
let (request, _) = crate::control::ControlRequest::new(method, params);
let crate::control::Outcome::Done(reply) = crate::control::handle(app, ctx, &request) else { panic!("not done") };
reply
}
/// Returns the labels of the View › Rotate View items that are checked.
fn checked(app: &VectorcraftApp) -> Vec<&'static str> {
ANGLES.iter().filter(|(_, a)| menus::checked(app, "view.rotate", &json!({ "angle": a })) == Some(true)).map(|(l, _)| *l).collect()
@@ -55,6 +62,157 @@ fn view_rotate_turns_the_view_to_any_angle() {
assert_eq!(empty.run("view.rotate", json!({"angle": 30})).unwrap_err(), "no document");
}
/// MCP's `pointer_gesture` reaches `ui.pointer`: view tools must turn the canvas there too,
/// rather than silently sending the gesture to the engine's no-op tool.
#[test]
fn control_rotate_view_gesture_turns_the_canvas_and_reset_restores_it() {
let mut app = with_document();
let ctx = egui::Context::default();
app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)));
let view = app.view_mut().unwrap();
view.center = vectorcraft_geom::Point::new(200.0, 150.0);
view.zoom = 1.0;
let revision = app.session.active().unwrap().revision;
let send = super::synthetic::control;
send(&mut app, &ctx, "ui.tool.select", json!({"tool": "rotateView"}));
send(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "down", "x": 300, "y": 150}]}));
let mut frame = ctx.run_ui(Default::default(), |_| {});
frame.textures_delta.clear();
send(
&mut app,
&ctx,
"ui.pointer",
json!({"events": [
{"kind": "drag", "x": 200, "y": 250},
{"kind": "up", "x": 200, "y": 250}
]}),
);
let view = app.view().unwrap();
assert!((view.rotation - 90.0).abs() < 1e-3, "{}", view.rotation);
assert_eq!(view.center, vectorcraft_geom::Point::new(200.0, 150.0));
assert_eq!(view.zoom, 1.0);
assert_eq!(app.session.active().unwrap().revision, revision, "navigation does not edit the document");
let reset = control_response(&mut app, &ctx, "engine.execute", json!({"command": "view.rotateReset", "params": {}}));
assert_eq!(reset["ok"], true);
assert!(reset["result"].is_null());
assert_eq!(app.view().unwrap().rotation, 0.0);
}
/// Repeated document points stay fixed across requests, including Shift snapping from a turned view.
#[test]
fn control_rotate_view_keeps_press_coordinates_and_shift_snapping_across_requests() {
let mut app = with_document();
let ctx = egui::Context::default();
app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)));
let view = app.view_mut().unwrap();
view.center = vectorcraft_geom::Point::new(200.0, 150.0);
view.zoom = 1.0;
view.rotation = 30.0;
let send = super::synthetic::control;
send(&mut app, &ctx, "ui.tool.select", json!({"tool": "rotateView"}));
send(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "down", "x": 300, "y": 150}]}));
let angle = 20_f64.to_radians();
let (x, y) = (200.0 + 100.0 * angle.cos(), 150.0 + 100.0 * angle.sin());
for _ in 0..2 {
send(&mut app, &ctx, "ui.pointer", json!({"mods": {"shift": true}, "events": [{"kind": "drag", "x": x, "y": y}]}));
assert_eq!(app.view().unwrap().rotation, 45.0, "the same document point does not turn again as the view changes");
}
send(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "up", "x": x, "y": y}]}));
assert_eq!(app.view().unwrap().rotation, 45.0);
}
/// Invalid coordinates and document changes cannot turn a different view.
#[test]
fn control_rotate_view_rejects_nonfinite_positions_and_a_changed_document() {
let mut app = with_document();
let ctx = egui::Context::default();
app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)));
let send = super::synthetic::control;
send(&mut app, &ctx, "ui.tool.select", json!({"tool": "rotateView"}));
let (req, _) = crate::control::ControlRequest::new("ui.pointer", json!({"events": [{"kind": "down", "x": 1e300, "y": 0}]}));
let crate::control::Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("not done") };
assert_eq!(reply["ok"], false);
assert_eq!(app.view().unwrap().rotation, 0.0);
send(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "down", "x": 300, "y": 150}]}));
app.run("file.new", json!({})).unwrap();
let (req, _) = crate::control::ControlRequest::new("ui.pointer", json!({"events": [{"kind": "drag", "x": 200, "y": 250}]}));
let crate::control::Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &req) else { panic!("not done") };
assert_eq!(reply["ok"], false);
assert_eq!(app.view().unwrap().rotation, 0.0);
}
/// An invalid packet leaves the angle unchanged and requires a fresh press, including errors
/// after an otherwise valid drag and malformed releases or hovers.
#[test]
fn control_rotate_view_rejects_malformed_packets_without_leaving_a_press() {
let drag = json!({"kind": "drag", "x": 200, "y": 250});
for packet in [
json!({"events": [drag.clone(), {"kind": "drag", "y": 250}]}),
json!({"events": [drag.clone(), {"kind": "drag", "x": "200", "y": 250}]}),
json!({"events": [drag.clone(), {"kind": "drag", "x": 200, "y": 250, "mods": {"shift": "yes"}}]}),
json!({"mods": "shift", "events": [drag.clone()]}),
json!({"events": [drag.clone(), {"kind": "drag", "x": 200, "y": 250, "mods": {"unknown": true}}]}),
json!({"events": [drag.clone(), {"kind": "drag", "x": 1e300, "y": 250}]}),
json!({"events": [drag.clone(), {"kind": "up", "x": 1e300, "y": 250}]}),
json!({"events": [drag.clone(), {"kind": "move", "x": 1e300, "y": 250}]}),
json!({"events": [drag.clone(), {"kind": "doubleclick", "x": 1e300, "y": 250}]}),
json!({"events": [drag.clone(), {"kind": "wiggle", "x": 200, "y": 250}]}),
json!({"events": [drag.clone(), {"kind": "drag", "x": 200, "y": 250, "space": "window"}]}),
json!({"events": null}),
] {
let mut app = with_document();
let ctx = egui::Context::default();
app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)));
let view = app.view_mut().unwrap();
view.center = vectorcraft_geom::Point::new(200.0, 150.0);
view.zoom = 1.0;
super::synthetic::control(&mut app, &ctx, "ui.tool.select", json!({"tool": "rotateView"}));
super::synthetic::control(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "down", "x": 300, "y": 150}]}));
let reply = control_response(&mut app, &ctx, "ui.pointer", packet.clone());
assert_eq!(reply["ok"], false, "{packet}: {reply}");
assert_eq!(app.view().unwrap().rotation, 0.0, "{packet}");
let resume = control_response(&mut app, &ctx, "ui.pointer", json!({"events": [drag.clone()]}));
assert_eq!(resume["ok"], false, "a failed packet cancels its press: {packet}");
assert_eq!(app.view().unwrap().rotation, 0.0);
}
}
/// Screen-space gestures survive frames, stop on release or a tool change, and start afresh
/// from the current angle. A press at the pivot uses the same finite convention as the mouse.
#[test]
fn control_rotate_view_screen_events_require_a_live_press() {
let mut app = with_document();
let ctx = egui::Context::default();
app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)));
let view = app.view_mut().unwrap();
view.center = vectorcraft_geom::Point::new(1200.0, 900.0);
view.zoom = 2.0;
let send = super::synthetic::control;
send(&mut app, &ctx, "ui.tool.select", json!({"tool": "rotateView"}));
let down = json!({"events": [{"kind": "down", "x": 500, "y": 300, "space": "screen"}]});
let drag = json!({"events": [{"kind": "drag", "x": 400, "y": 400, "space": "screen"}]});
assert_eq!(control_response(&mut app, &ctx, "ui.pointer", drag.clone())["ok"], false);
send(&mut app, &ctx, "ui.pointer", down.clone());
let mut frame = ctx.run_ui(Default::default(), |_| {});
frame.textures_delta.clear();
send(&mut app, &ctx, "ui.pointer", drag.clone());
assert!((app.view().unwrap().rotation - 90.0).abs() < 1e-3);
send(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "up", "x": 400, "y": 400, "space": "screen"}]}));
assert_eq!(control_response(&mut app, &ctx, "ui.pointer", drag.clone())["ok"], false, "release ends the press");
send(&mut app, &ctx, "ui.pointer", down.clone());
send(&mut app, &ctx, "ui.tool.select", json!({"tool": "selection"}));
send(&mut app, &ctx, "ui.tool.select", json!({"tool": "rotateView"}));
assert_eq!(control_response(&mut app, &ctx, "ui.pointer", drag.clone())["ok"], false, "a tool change ends the press");
send(&mut app, &ctx, "ui.pointer", down);
send(&mut app, &ctx, "ui.pointer", drag);
assert!((app.view().unwrap().rotation.abs() - 180.0).abs() < 1e-3, "a new press starts at the current angle");
let before = app.view().unwrap().rotation;
send(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "down", "x": 400, "y": 300, "space": "screen"}]}));
send(&mut app, &ctx, "ui.pointer", json!({"events": [{"kind": "drag", "x": 500, "y": 300, "space": "screen"}]}));
assert!(app.view().unwrap().rotation.is_finite());
assert!((app.view().unwrap().rotation - before).abs() < 1e-3);
}
#[test]
fn the_view_menu_lists_the_status_bar_angles_and_checks_the_current_one() {
let mut app = with_document();
+1
View File
@@ -63,6 +63,7 @@ pub use numeric::{
};
#[cfg(test)]
use numeric::{decimal_point, field_number, share_of, spinner_step, step_through, step_to_multiple, within_a_millionth};
pub(crate) use panel_chrome::{PANEL_FOOTER, panel_footer, panel_footer_bottom, panel_footer_space, panel_list_height};
pub use panel_chrome::{bottom_bar, layout_button, list_box, orientation_button, tab_bar};
pub(crate) use text::{
arm_field_focus, dialog_focus_flag, dialog_reject_commit_flag, disarm_field_focus, enter_modifiers, focus_field, own_field_shift_enter,
@@ -1,5 +1,41 @@
use super::{Color32, CornerRadius, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Tokens, Ui, Vec2, icons, pos2, theme, vec2};
pub(crate) const PANEL_FOOTER: &str = "vectorcraft-panel-footer";
const FOOTER_HEIGHT: f32 = 31.0;
/// The finite body edge supplied by a sized dock, float or user-resized flyout.
/// Untouched flyouts omit it so their content can still determine their height.
pub(crate) fn panel_footer_bottom(ui: &Ui) -> Option<f32> {
ui.stack().iter().find_map(|stack| stack.tags().get_downcast::<Option<f32>>(PANEL_FOOTER)).copied().flatten()
}
/// Space left for a list above the footer and any intervening controls, or its unfitted fallback.
pub(crate) fn panel_list_height(ui: &Ui, fallback: f32, after: f32) -> f32 {
panel_footer_bottom(ui).map_or(fallback, |bottom| (bottom - ui.next_widget_position().y - FOOTER_HEIGHT - after).max(0.0))
}
/// Pad short content so a footer of `height` ends at the panel's saved bottom edge.
pub(crate) fn panel_footer_space(ui: &mut Ui, height: f32) {
if let Some(bottom) = panel_footer_bottom(ui) {
ui.add_space((bottom - ui.next_widget_position().y - height).max(0.0));
}
}
/// Pin a custom footer (for example the Variables panel's two rows).
pub(crate) fn panel_footer(ui: &mut Ui, height: f32, add: impl FnOnce(&mut Ui)) {
let Some(bottom) = panel_footer_bottom(ui) else { return add(ui) };
let top = bottom - height;
if ui.next_widget_position().y > top {
ui.add_space(height);
}
let rect = Rect::from_min_size(pos2(ui.max_rect().left(), top), vec2(ui.available_width(), height));
ui.painter().rect_filled(rect, 0.0, Tokens::get(ui.ctx()).panel);
// Own the blank parts too, so a click cannot select a scrolled row behind the footer.
let _background = ui.interact(rect, ui.scope_id().with("panel-footer-background"), Sense::CLICK);
ui.scope_builder(egui::UiBuilder::new().max_rect(rect), add);
ui.advance_cursor_after_rect(rect);
}
/// A bordered list box (Swatches tiles, Appearance rows, Artboards list).
pub fn list_box<R>(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R {
let t = Tokens::get(ui.ctx());
@@ -9,6 +45,25 @@ pub fn list_box<R>(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R {
/// A bottom button bar separated from the content by a divider (panel footers).
pub fn bottom_bar(ui: &mut Ui, add: impl FnOnce(&mut Ui)) {
let t = Tokens::get(ui.ctx());
if let Some(bottom) = panel_footer_bottom(ui) {
// Reserve scrollable room after long content so its final row can move above
// the pinned footer instead of remaining hidden underneath its buttons.
let top = bottom - FOOTER_HEIGHT;
if ui.next_widget_position().y > top {
ui.add_space(FOOTER_HEIGHT);
}
let footer = Rect::from_min_size(pos2(ui.max_rect().left(), top), vec2(ui.available_width(), FOOTER_HEIGHT));
ui.painter().rect_filled(footer, 0.0, t.panel);
let _background = ui.interact(footer, ui.scope_id().with("panel-footer-background"), Sense::CLICK);
ui.painter().hline(footer.x_range(), top + 4.0, Stroke::new(1.0, t.divider));
let row = Rect::from_min_max(pos2(footer.left(), top + 7.0), footer.max);
ui.scope_builder(egui::UiBuilder::new().max_rect(row).layout(egui::Layout::left_to_right(egui::Align::Center)), |ui| {
ui.spacing_mut().item_spacing.x = 4.0;
add(ui);
});
ui.advance_cursor_after_rect(footer);
return;
}
ui.add_space(4.0);
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 1.0), Sense::hover());
ui.painter().rect_filled(r, 0.0, t.divider);
@@ -202,3 +257,40 @@ pub(super) fn chevron_cell(ui: &mut Ui, h: f32) -> Response {
dresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Presets")));
dresp
}
#[cfg(test)]
mod footer_tests {
use super::{PANEL_FOOTER, bottom_bar, panel_footer};
/// Blank footer space intercepts clicks instead of selecting list rows underneath it.
#[test]
fn blank_footer_space_does_not_click_a_row_scrolled_behind_it() {
use egui::{Event, PointerButton, Rect, Sense, UiBuilder, UiStackInfo, pos2};
for custom in [false, true] {
let ctx = egui::Context::default();
let mut clicked = false;
let at = pos2(150.0, 190.0);
let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
for events in [vec![], vec![], vec![Event::PointerMoved(at), button(true)], vec![button(false)]] {
let input =
egui::RawInput { screen_rect: Some(Rect::from_min_max(pos2(0.0, 0.0), pos2(200.0, 200.0))), events, ..Default::default() };
let mut output = ctx.run_ui(input, |ui| {
let info = UiStackInfo::default().with_tag_value(PANEL_FOOTER, Some(200.0_f32));
ui.scope_builder(UiBuilder::new().ui_stack_info(info), |ui| {
clicked |= ui.allocate_rect(Rect::from_min_max(pos2(0.0, 100.0), pos2(200.0, 200.0)), Sense::click()).clicked();
let contents = |ui: &mut egui::Ui| {
let _button = ui.button("Footer button");
};
if custom {
panel_footer(ui, 31.0, contents);
} else {
bottom_bar(ui, contents);
}
});
});
output.textures_delta.clear();
}
assert!(!clicked, "custom={custom}: a hidden row received the footer click");
}
}
}
+9
View File
@@ -311,6 +311,15 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors,
| Affinity `.af`, `.afdesign`, `.afpub` | no | yes (beyond Illustrator) | no (#665) | 51 pinned files, [affinity-validation.md](affinity-validation.md) |
| Clipboard: PDF, SVG, PNG, EMF, text | yes | yes | yes | copied files paste as art; cells copied from a spreadsheet (tab-separated text) paste as a table of point type over gridlines, at most 1000 rows, 256 columns and 20,000 cells (#5296) |
## PDF form gradients
PDF export's linear and radial gradients stay aligned inside transparency groups, opacity masks,
PDF layers and overprinting art. The original synthetic tests in
`crates/pdf/tests/gradient_forms.rs` check the output with macOS Quartz and an import/render
round trip. The three Quartz checks fail on main `65adb7b21` and pass with the form-space fix;
translated layer bounds also have a regression for the current writer's layer-box repair.
Stop opacity on translated artboards remains outside this regression coverage.
## PDF editable imports
- [x] Explicit `/ActualText` replaces covered glyph text once, including empty strings and nested spans (outer owner wins), while later glyphs retain pen geometry. Synthetic regressions in `crates/pdf/src/tests/actual_text.rs`; no added fonts/assets. UTF-16BE, BOM UTF-8 and PDFDocEncoding reuse the reader's text-string decoder.
+14 -5
View File
@@ -1,6 +1,8 @@
# Gaps
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; 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; estimate 1–2 h → 0; 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; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: 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; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; 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; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; 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; estimate 1–2 h → 0; 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; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: 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; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; 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; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Flyout update:** 2026-11-06 — resizable icon-strip flyouts and bottom-aligned panel footers (#5713); scores and estimates unchanged.
## Feature submission evidence
@@ -8,6 +10,8 @@
| 2026-11-06 | minor | Opening a file that takes longer than 0.4 s shows a progress window: the file name, a moving bar (reading reports no steps), how many more files wait, Cancel (Esc; the file doesn't open) and Continue in Background (`file.cancelOpen`, `file.openInBackground`); it never opens over another dialog or progress window (part of #5930); scores and estimates unchanged |
Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged.
Icon-strip flyouts resize from the canvas-side edge and bottom, keeping the icon-column edge anchored (#5713). Independent group heights and flyout widths persist in workspaces; saved heights override content fitting while untouched heights still fit content. Shared panel button bars and the Glyphs font row stay at the bottom after enlargement; the Artboards list grows into the extra space. Regressions: `panel_docking::tests::glyphs_and_artboards_flyouts_keep_user_sizes` and `expanded_panel_footers_stay_at_the_bottom_with_short_and_long_lists`. Scores and estimates unchanged.
**PR #3273 revision — 2026-11-04:** Thin curved stroke coverage ported to the current raster-only cache, cropped-long-stroke and effect paths with guarded flattening; vector export geometry unchanged. Scores and estimates unchanged.
Group Selection keeps an already selected component selected during a drag; a completed click still adds its next enclosing group. The current Direct Selection click/release machinery is shared, preserving compound-member picks, Shift deselection, isolation and masked-art dragging. Outline Stroke continues to preserve all original fills, including open contours; the change does not remove fills. Regressions: `group_selection_distinguishes_selected_leaf_drags_from_clicks` and `outline_fill_and_stroke_move_independently_with_group_selection` cover tool events, independent component movement and Undo/Redo. Scores and estimates unchanged.
@@ -51,6 +55,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
| 2026-11-05 | minor | G15: Selection skips building corner and pie widgets when the pointer is well outside a large selection; empty-canvas hover with 20,000 selected paths took 28.72 ms before and 16.41 ms after in local median runs. Other cursor targets remain active; estimate unchanged. |
| 2026-11-05 | minor | G15: Direct Selection reuses one document hit result when choosing the cursor over a path; after the path hit-test improvement, a 200,000-anchor path's segment cursor took 3.72 ms before and 2.66 ms after in local median runs. Cursor behavior is unchanged; estimate unchanged. |
| 2026-11-05 | minor | G15: large-selection transforms reuse object locations found during command validation; a 20,000-object nudge took 58.07 ms before and 51.26 ms after in paired median runs. Estimate unchanged. |
| 2026-11-06 | minor | Icon-strip flyouts resize on their free edges and retain sizes with workspaces (#5713); saved heights beat auto-fit; panel footers stay at the bottom after enlargement. Scores and estimates unchanged. |
| 2026-11-05 | minor | G9, G14: `.ai` paths with a Calligraphic brush open brushed (the brush on the stroke, at its weight) where the object's settings are the brush's own, and the brush's Tilt, Bearing and Rotation codes come in as those channels; Windows reads the pen's tilt and barrel rotation from Windows Ink (#372). Checked against a user's file of 6D Art Pen strokes, used locally; not tried with a pen. Scores and estimates unchanged. |
| 2026-11-04 | minor | Contour add-on (beyond Illustrator): drag away from a selected shape's edge to grow a live copy of it, or toward the inside to shrink it; the copy carries a live Offset Path effect, so it stays editable until Bake (Expand Appearance), a second drag changes the same Contour instead of nesting one, and a floating bar sets the distance and the Sharp, Round or Square corners. `addon.contour.set` is the command behind it. It is a built-in add-on: Object › Plug-ins › Contour, and its own heading in All Tools, with no default shortcut or toolbar slot. A VectorCraft design built on Offset Path; no other application was inspected. Scores and estimates unchanged. |
| 2026-11-04 | minor | Contour tool (beyond Illustrator): drag away from a selected shape's edge to grow a live copy of it, or toward the inside to shrink it; the copy carries a live Offset Path effect, so it stays editable until Bake (Expand Appearance), a second drag changes the same Contour instead of nesting one, and a floating bar sets the distance and the Sharp, Round or Square corners. `object.contour` is the command behind it. A VectorCraft design built on Offset Path; no other application was inspected. Scores and estimates unchanged. |
@@ -97,7 +102,7 @@ stage ([ROADMAP.md](../ROADMAP.md)).
| 8 | [G2 Photoshop-style raster effects and the Effect Gallery](#g2-photoshop-style-raster-effects-and-the-effect-gallery) | 0 | no | [effects-parity.md](effects-parity.md) |
| 9 | [G13 Illustrator 2026 (30.x) additions](#g13-illustrator-2026-30x-additions) | 13–23 | no | [target-app-parity.md](target-app-parity.md) |
| 10 | [G14 Pen hardware beyond Windows pressure](#g14-pen-hardware-beyond-windows-pressure) | 4–10 | Wayland pressure | [hardware-parity.md](hardware-parity.md) |
| 11 | [G5 Brushes, symbols and libraries](#g5-brushes-symbols-and-libraries) | 10–20 | no | [target-app-parity.md](target-app-parity.md) |
| 11 | [G5 Brushes, symbols and libraries](#g5-brushes-symbols-and-libraries) | 9–19 | no | [target-app-parity.md](target-app-parity.md) |
| 12 | [G3 3D and Materials](#g3-3d-and-materials) | 25–45 | no | [effects-parity.md](effects-parity.md) |
| 13 | [G7 Views and windows](#g7-views-and-windows) | 15–25 | no | [ui-parity.md](ui-parity.md) |
| 14 | [G6 Automation and scripting](#g6-automation-and-scripting) | 4–8 | no | [target-app-parity.md](target-app-parity.md) |
@@ -652,12 +657,15 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi
is on, a Shift-click on a highlighted match leaves it out (`select.globalEditExclude`, #3521 evidence);
Direct Selection of a path, compound path or type inside a dynamic instance also takes opacity and blend
(`transparency.set`) and a gradient or pattern fill or stroke (`paint.setFill` / `paint.setStroke`,
Eyedropper), for that instance only.
- **Missing:** more built-in brush and graphic-style libraries (and further symbol libraries beyond Basic
Eyedropper), for that instance only;
graphic style libraries (Window › Graphic Style Libraries; seven original libraries generated in code, including
Game HUD with Health Fill, Mana Glow, Damage Flash, Quest Outline, Coin Shine and Boss Alert; User Defined; Other
Library…; `graphicStyle.libraries`, `graphicStyle.addFromLibrary`).
- **Missing:** more built-in brush libraries (and further graphic-style libraries beyond the seven shipped, and further symbol libraries beyond Basic
Shapes, UI Frames and Map Markers; original, generated in code, never Adobe's); dynamic symbols' `.ai` read and write;
Global Edit of groups holding type or images, rotated copies as matches and highlighting matches before the
start; 9-slice guides outside the symbol's art (a dragged guide stops at the art's edge).
- **Estimate:** 10–20 h.
- **Estimate:** 9–19 h.
## G3 3D and Materials
@@ -856,6 +864,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv
| Date | Change | Summary |
|---|---|---|
| 2026-11-10 | minor | G4 type: Edit › Edit Custom Dictionary… adds, deletes and changes the custom dictionary's words, #4936; the Check Spelling dialog remains; scores and estimates unchanged. |
| 2026-11-10 | minor | G5: built-in Game HUD graphic style library (Health Fill, Mana Glow, Damage Flash, Quest Outline, Coin Shine, Boss Alert); Missing trimmed; estimate 10–20 → 9–19 h. |
| 2026-11-10 | minor | G5: built-in Map Markers symbol library (Pin, Flag, Crosshair, Checkpoint; no default 9-slice); Missing trimmed; estimate 11–21 → 10–20 h. |
| 2026-11-10 | minor | G4 type: a right-click on type has Spelling › Auto Spell Check, checked while it is on, #4936; the Check Spelling and Edit Custom Dictionary dialogs remain; scores and estimates unchanged. |
| 2026-11-10 | minor | G4 type: a right-click on a word Auto Spell Check underlined lists its suggestions, Ignore All and Add to Dictionary, #4936; the Check Spelling and Edit Custom Dictionary dialogs remain; scores and estimates unchanged. |
+24 -5
View File
@@ -2791,7 +2791,7 @@ as a value left out.
## Graphic style libraries
Graphic style libraries are read-only sets of graphic styles generated in code (Shadows and Glows, Outlines and
Rules, Hand-Drawn, Gradient Finishes, Shape Effects, Blends and Transparency). `graphicStyle.libraries` lists them
Rules, Hand-Drawn, Gradient Finishes, Shape Effects, Blends and Transparency, Game HUD). `graphicStyle.libraries` lists them
(`id`, `name`, `category`: `builtIn`, `user` or `loaded`, `count`) and `graphicStyle.library {library}` returns one's
styles in the `graphicStyle.list` shape (`library` is an id or a name). `graphicStyle.addFromLibrary {library, name? |
names?}` copies styles into the document's Graphic Styles as one undo step (no names: the whole library); styles the
@@ -3324,11 +3324,13 @@ and `stroke.widthProfile.reset` removes every saved one. `stroke.set {profile}`
## Arrowheads
`stroke.set {startArrow, endArrow}` takes any of 40 generated heads by name (the params doc lists them; `null` for
none): arrows (`Arrow`, `Barbed`, `Concave`, `DoubleArrow`, `HalfArrowLeft`/`Right`, `Chevron`, `Feather`,
`stroke.set {startArrow, endArrow}` takes any of 41 generated heads by name (the params doc lists them; `null` for
none): arrows (`Arrow`, `LineArrow`, `Barbed`, `Concave`, `DoubleArrow`, `HalfArrowLeft`/`Right`, `Chevron`, `Feather`,
`Swallowtail`…), filled and open shapes (`Triangle`, `Circle`, `Oval`, `Target`, `Tag`, `Diamond`, `Hexagon`, `Star`,
the `…Open` rings…) and marks (`Bar`, `DoubleBar`, `DotOnBar`, `Slash`, `DoubleSlash`, `Bracket`, `Fork`, `Cross`,
`Plus`). Each head fits a box about four times its weight (stroke weight × `arrowScale`) long and wide.
`Plus`). Each head fits a box about four times its weight long and wide. A head's weight is the stroke weight scaled by
`arrowScale`, at least 0.25 pt. `LineArrow` has two arms of the head's weight (the line's weight at 100 % for lines of
0.25 pt or more) at right angles and spreads twice as wide.
## Stroke bounds and clicks
@@ -4191,7 +4193,21 @@ every document they read except a native file and the native document that an SV
`view.rotate {angle}` (desktop app) turns the view to `angle` degrees about the center of the canvas; a positive angle
turns the art clockwise on screen. View › Rotate View and the status bar's angle menu run it. It returns `{angle}`, the
angle it set, normalized to (-180, 180], and `view.rotateReset` turns the view back to 0.
angle it set, normalized to (-180, 180], and `view.rotateReset` turns the view back to 0, returning `null` on success.
Select the Rotate View Tool with `select_tool` and the tool id `rotateView`. Its `pointer_gesture`
drags turn the canvas about its center; Shift snaps to 15°. Document coordinates for a rotation
gesture use the view at the initial down event, including when the gesture spans several calls.
The control channel rejects malformed rotation packets without changing the angle and cancels
the current press. Start a new down event after an error.
Reset Rotate View is the command `view.rotateReset`:
```json
{"name":"select_tool","arguments":{"tool":"rotateView"}}
{"name":"pointer_gesture","arguments":{"events":[{"kind":"down","x":300,"y":150},{"kind":"drag","x":200,"y":250},{"kind":"up","x":200,"y":250}]}}
{"name":"command_run","arguments":{"id":"view.rotateReset","params":{}}}
```
`view.rotateToSelection` (desktop app, View › Rotate View to Selection) turns the view about the center of the canvas to
the angle the selected objects share (`selectionRotation` in `document.inspect`, 0 when their angles differ), so their
bounding box is square to the window. It returns `{angle}`, and with nothing selected it returns "nothing selected".
@@ -5902,6 +5918,9 @@ undo step. Object › Transform › Scale, the Scale tool and the Transform pane
`text.reshapePath {id, anchors: [[subpath, anchor]…], dx, dy}` does the same for the path of type on a path
(Direct Selection dragging one of its anchors): the start and end brackets keep their fractions of the path and the
text reflows along it.
`text.setPathHandle {id, subpath?, anchor, which: "in"|"out", x, y, independent?}` moves a direction handle of one
of its anchors, as `path.setHandle` moves a path's (Direct Selection dragging a handle of the path, #3565), in one
undo step.
```json
{"name":"run_command","arguments":{"command":"text.reshapeArea","params":{"id":42,"anchors":[[0,2]],"dx":40,"dy":60}}}
+6 -2
View File
@@ -1,6 +1,6 @@
# Roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; 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-10 · **Change:** 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)
> Numeric spinner update (2026-11-10): held mouse arrows repeat past the click timeout; scores and estimates unchanged (#6307).
@@ -10,6 +10,8 @@ Numeric fields step on wheel hover without taking keyboard focus (#5949). Arrow
Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged.
Icon-strip flyouts resize from the canvas-side edge and bottom, keeping the icon-column edge anchored (#5713). Independent group heights and flyout widths persist in workspaces; saved heights override content fitting while untouched heights still fit content. Shared panel button bars and the Glyphs font row stay at the bottom after enlargement; the Artboards list grows into the extra space. Regressions: `panel_docking::tests::glyphs_and_artboards_flyouts_keep_user_sizes` and `expanded_panel_footers_stay_at_the_bottom_with_short_and_long_lists`. Scores and estimates unchanged.
**PR #3273 revision — 2026-11-04:** Thin curved stroke coverage ported to the current raster-only cache, cropped-long-stroke and effect paths with guarded flattening; vector export geometry unchanged. Scores and estimates unchanged.
Group Selection keeps an already selected component selected during a drag; a completed click still adds its next enclosing group. The current Direct Selection click/release machinery is shared, preserving compound-member picks, Shift deselection, isolation and masked-art dragging. Outline Stroke continues to preserve all original fills, including open contours; the change does not remove fills. Regressions: `group_selection_distinguishes_selected_leaf_drags_from_clicks` and `outline_fill_and_stroke_move_independently_with_group_selection` cover tool events, independent component movement and Undo/Redo. Scores and estimates unchanged.
@@ -50,6 +52,7 @@ M7 type: Touch Type moves a right-to-left character along its line and up from i
| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. |
| 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. |
| 2026-11-05 | minor | Effect dialogs: a colour swatch beside a hex colour field follows typed values, and a double-click opens the Color Picker over the dialog, whose OK fills the field (#5365); scores and estimates unchanged. |
| 2026-11-06 | minor | Icon-strip flyouts resize on their free edges and retain sizes with workspaces (#5713); saved heights beat auto-fit; panel footers stay at the bottom after enlargement. Scores and estimates unchanged. |
| 2026-11-05 | minor | Selection Tools add-on: Select Linked, Embedded, Missing and Modified select the images and placed documents with that link status (Object › Plug-ins › Selection Tools and the Links panel menu; Shift adds), and Select by Link Status… lists them by name, status and format to pick from, optionally zooming to them; what is hidden, locked, on a template layer or outside the isolated group is left out and the status bar says how many (#5526, a VectorCraft addition); scores and estimates unchanged (estimated). |
| 2026-11-05 | minor | Pencil and Paintbrush: a new stroke with Keep selected off leaves nothing selected, the earlier selection included, while Keep selected on selects only the new path (#5752); scores and estimates unchanged. |
| 2026-11-05 | minor | Layers panel: a layer whose Layer Options › Print is off shows its name italic, as the reference application shows non-printing layers, and keeps it while the row is dragged (#5739); scores and estimates unchanged. |
@@ -155,7 +158,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `\|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending | 6–10 |
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 MCP `add_text` exposes existing vertical point, area and path type; `script.run` and File › Scripts › Other Script… run JavaScript files; the script document model and console and batch work remain | 18–27 |
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 |
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) (status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; sample widths cached by language/unit/DPI; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) | 🟡 Interim macOS cursor delivery (#2292) uses existing artwork and host-owned AppKit cursors; headless coverage only, native menu/focus/handback and display validation pending. Dock groups collapse to their tabs by a double-click on a tab (UI.8); Preferences › General › Constrain Angle acts for the arrow keys, Shift drags and new objects (#394, 2026-10-21); numeric-field pointer selection and focus regression-tested (#1326, in part; short movements still select all); RPM runtime capabilities and glibc floor checked (#1275; real-distro install/start-up pending), Eyedropper cursor with precise-setting checks (#1269), Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed; other apps' text shortcut sets imported), workspaces (each keeps its layout changes until Reset <name>, UI.7), Window › Help Bar shown or hidden apart from the status bar and kept in saved workspaces, panels docked by dragging into any dock group (as tabs or a top/bottom split) or into the icon column, and closed from a dock tab's context menu (UI.6), a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); titled PDF exports set `DisplayDocTitle` for viewers and accessibility checkers (#3646); accessibility and broader Windows/Linux packaging/QA pending; floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part), with pointer and native-window regression coverage; estimates unchanged; numeric spinner arrows keep repeating while held with the mouse (#6307) | 18–28 |
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) (status messages and readouts use stable responsive allocations sized for localized labels and ordinary coordinates; sample widths cached by language/unit/DPI; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) | 🟡 Icon-strip flyouts resize on their free edges and retain sizes (#5713); interim macOS cursor delivery (#2292) uses existing artwork and host-owned AppKit cursors; headless coverage only, native menu/focus/handback and display validation pending. Dock groups collapse to their tabs by a double-click on a tab (UI.8); Preferences › General › Constrain Angle acts for the arrow keys, Shift drags and new objects (#394, 2026-10-21); numeric-field pointer selection and focus regression-tested (#1326, in part; short movements still select all); RPM runtime capabilities and glibc floor checked (#1275; real-distro install/start-up pending), Eyedropper cursor with precise-setting checks (#1269), Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed; other apps' text shortcut sets imported), workspaces (each keeps its layout changes until Reset <name>, UI.7), Window › Help Bar shown or hidden apart from the status bar and kept in saved workspaces, panels docked by dragging into any dock group (as tabs or a top/bottom split) or into the icon column, and closed from a dock tab's context menu (UI.6), a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); titled PDF exports set `DisplayDocTitle` for viewers and accessibility checkers (#3646); accessibility and broader Windows/Linux packaging/QA pending; floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part), with pointer and native-window regression coverage; estimates unchanged; numeric spinner arrows keep repeating while held with the mouse (#6307) | 18–28 |
| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed) | ⬜ (new ruler-guide Alt orientation implemented; existing-guide Escape cancellation regression-tested; broader pass remains) | 60–90 |
| — | Hardening at scale (big-file corpus, fuzzing, cross-platform + browser QA) | 🟡 PngSuite integrity and bounded fetching verified (#1729); real-world corpus/manual QA remain | 50–80 |
| M14 follow-up | Color Picker layout (#2008) | ✅ Compact controls, centered header, stacked actions, aligned fields and screen sampling; narrow windows scroll vertically | Included above |
@@ -193,6 +196,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
| Date | Change | Summary |
|---|---|---|
| 2026-11-10 | minor | M7: the Edit Custom Dictionary dialog (#4936); the Check Spelling dialog remains; scores and estimates unchanged. |
| 2026-11-10 | minor | M10/G5: Game HUD built-in graphic style library; estimates unchanged |
| 2026-11-10 | minor | M10/G5: Map Markers built-in symbol library (Pin, Flag, Crosshair, Checkpoint); estimates unchanged |
| 2026-11-10 | minor | M7: Spelling › Auto Spell Check in the context menu of type (#4936); the dialogs remain; scores and estimates unchanged. |
| 2026-11-10 | minor | M7: right-click suggestions, Ignore All and Add to Dictionary on a word Auto Spell Check underlined (#4936); the dialogs remain; scores and estimates unchanged. |
File diff suppressed because one or more lines are too long
+3 -2
View File
@@ -1,6 +1,6 @@
# Type parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; 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: Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas base-letter paint; scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; 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) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: Direct Selection shows and drags the direction handles of type on a path's path, #3565; 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: Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas base-letter paint; scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: right-aligned area type sets the text after a line's last tab against the right edge; a soft return in justified type no longer keeps its line from being justified; 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) · **Target:** Adobe Illustrator 2026 (30.x)
Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in
[target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the
@@ -25,7 +25,7 @@ against Illustrator's public documentation; **estimated** unless noted.
| Area Type Options (rows, columns, inset, first baseline, Text Flow by rows or by columns, Auto Size, Shrink Text to Fit) | done | Text Flow fills the cells down each column first (By Columns, the default, as files from before it open) or across each row first (By Rows), in the dialog and `text.areaOptions {flow}` (#4978); the commands that make or restyle type say when it does not fit its frame; MCP `add_text` takes Auto Size and Shrink Text to Fit (#1471) |
| Threaded text, Fit Headline, Text Wrap | done | Fit Headline sees wrapping in a frame one line tall (#1444); a thread holds area type and type on a path in any mix, as Illustrator links type in an area or along a path ([help](https://helpx.adobe.com/illustrator/using/manage-text-area.html)) |
| Threading from the ports: selected area type and type on a path show an in port and an out port, empty, with an arrow on a side where the frame is threaded, or with a red "+" in the out port when the text doesn't fit; a click on either port loads the text; a click on a closed path, area type or type on a path threads into it, a click on empty canvas makes a frame of the same kind, shape and size there, a drag draws a rectangular frame; the frame placed comes after the clicked one from its out port and before it from its in port (the story then starts there); Escape or another tool cancels; a double-click on a threaded port breaks the thread there | partial | #2572: the Selection tool, `text.thread.add {from, to?, rect?, at?, before?}`, as the reference app's help describes ports and threading ([archived help, Threading text between objects](https://web.archive.org/web/20210119051048/https://helpx.adobe.com/illustrator/using/creating-text.html), [create text threads](https://helpx.adobe.com/illustrator/desktop/design-with-text/add-manage-text/create-text-threads-between-objects.html)); area type's in port sits on the left side below the top-left corner, type on a path's just before its start bracket; Show Text Threads draws its lines between the same ports; with more than 1,000 such objects selected no ports are drawn. a double-click on a port where the frame is threaded breaks the thread there, the story flowing into the frames before the break and the frames after it left empty, still threaded with each other when two or more (`text.thread.break`, [archived help](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html)). Remaining: the ports only take clicks with the Selection tool, and the loaded cursor borrows the I-beam until it's drawn |
| Type on a Path effects and options; brackets | done | #429; Type on a Path Options has a Preview checkbox (#5360, as public tutorials of the dialog show) |
| Type on a Path effects and options; brackets | done | #429; Type on a Path Options has a Preview checkbox (#5360, as public tutorials of the dialog show); Direct Selection shows the anchors and direction handles of selected type on a path's path and drags both, the brackets keeping their fractions of the path and the text reflowing along it (#3980, #3565; `text.reshapePath`, `text.setPathHandle`), as the [Direct Selection page](https://helpx.adobe.com/illustrator/using/tool-techniques/direct-selection-tool.html) describes for a path |
| Character and Paragraph panels, styles with overrides | done | Character-style Auto-leading resets, partial definitions, local leading overrides, override markers, Undo/Redo and native serialization have regression coverage; differing paragraph alignments leave the shared alignment buttons unselected (headless regressions, full integration CI and visual review pass); mixed-value display for other paragraph fields remains missing. Double-clicking a user character style's row opens its Character Style Options, and the dialog keeps a fixed width while its sections scroll when the window is shorter, so its buttons stay on screen; a paragraph style's row opens its Paragraph Style Options, the rename dialog the panel menu opens. Evidence: `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options`, `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list` |
| Copy or Cut selected characters, then paste as separate text art | done | A Type-range copy keeps the selected characters' font family, size and color when pasted on the canvas after leaving text editing, in the same document or another. On desktop the clipboard offers plain text to text consumers and rich art for canvas Paste; Paste Without Formatting while editing uses the destination text's style. `tests::clipboard::type_range_copy_stays_styled_when_pasted_as_art` (#5636) |
| Character size and horizontal scale after object/group scaling | done | #1034: fields and setters compensate the stored affine; point size follows the transformed em-height axis, not the glyph ink bounds. Range edits, keyboard increments, leading and baseline shift use document points; rotation, reflection and shear remain editable. |
@@ -82,6 +82,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha
| Date | Change | Summary |
|---|---|---|
| 2026-11-10 | minor | Edit Custom Dictionary (#4936): Edit › Edit Custom Dictionary… adds, deletes and changes the custom dictionary's words, applied on Done; the Check Spelling dialog remains. Scores and estimates unchanged. |
| 2026-11-10 | minor | Type on a path (#3565): Direct Selection shows the direction handles of selected type on a path's path, as of a path selected whole, and drags them (`text.setPathHandle`; Alt: one side, Shift: 45° steps); the text reflows along the new curve. Scores and estimates unchanged. |
| 2026-11-10 | minor | Spelling in the canvas context menu (#4936): a right-click on type has Spelling › Auto Spell Check, checked while it is on; the Check Spelling and Edit Custom Dictionary dialogs remain. Scores and estimates unchanged. |
| 2026-11-10 | minor | Spelling in the canvas context menu (#4936): a right-click on a word Auto Spell Check underlined lists its suggestions, Ignore All and Add to Dictionary; the Check Spelling and Edit Custom Dictionary dialogs remain. Scores and estimates unchanged. |
| 2026-11-09 | minor | Auto Spell Check (#4936): Edit › Spelling › Auto Spell Check (`edit.autoSpellCheck`) underlines the unknown words of the visible type, without type with effects; the Check Spelling and Edit Custom Dictionary dialogs and the right-click Spelling menu remain. Scores and estimates unchanged. |
+6
View File
@@ -4,6 +4,8 @@
> Numeric spinner update (2026-11-10): held mouse arrows repeat past the click timeout; scores and estimates unchanged (#6307).
> **Flyout update:** 2026-11-06 — resizable icon-strip flyouts and bottom-aligned panel footers (#5713); scores and estimates unchanged.
## Feature submission evidence
Numeric fields step on wheel hover without taking keyboard focus (#5949). Arrow keys still require focus; modifiers, units, limits, mixed values and existing rounding stay in the shared stepping path. Headless regressions in `tests/numfields.rs` cover hovered unfocused fields, focused fields, trackpad accumulation and panel scroll exclusion. Scores and estimates unchanged.
@@ -11,6 +13,8 @@ Numeric fields step on wheel hover without taking keyboard focus (#5949). Arrow
| 2026-11-06 | minor | Opening a file that takes longer than 0.4 s shows a progress window: the file name, a moving bar (reading reports no steps), how many more files wait, Cancel (Esc; the file doesn't open) and Continue in Background (`file.cancelOpen`, `file.openInBackground`); it never opens over another dialog or progress window (part of #5930); scores and estimates unchanged |
Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged.
Icon-strip flyouts resize from the canvas-side edge and bottom, keeping the icon-column edge anchored (#5713). Independent group heights and flyout widths persist in workspaces; saved heights override content fitting while untouched heights still fit content. Shared panel button bars and the Glyphs font row stay at the bottom after enlargement; the Artboards list grows into the extra space. Regressions: `panel_docking::tests::glyphs_and_artboards_flyouts_keep_user_sizes` and `expanded_panel_footers_stay_at_the_bottom_with_short_and_long_lists`. Scores and estimates unchanged.
**PR #3273 revision — 2026-11-04:** Thin curved stroke coverage ported to the current raster-only cache, cropped-long-stroke and effect paths with guarded flattening; vector export geometry unchanged. Scores and estimates unchanged.
Group Selection keeps an already selected component selected during a drag; a completed click still adds its next enclosing group. The current Direct Selection click/release machinery is shared, preserving compound-member picks, Shift deselection, isolation and masked-art dragging. Outline Stroke continues to preserve all original fills, including open contours; the change does not remove fills. Regressions: `group_selection_distinguishes_selected_leaf_drags_from_clicks` and `outline_fill_and_stroke_move_independently_with_group_selection` cover tool events, independent component movement and Undo/Redo. Scores and estimates unchanged.
@@ -58,6 +62,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
| 2026-11-06 | minor | Shape tools: a press on the selection's centre point (Show Center) moves the selection, also where no art is painted, and a press in the rotate zone just outside a bounding-box corner turns it about the centre, both as the Selection tool's do (#5477); the Selection tool takes its centre point too, so an unfilled shape moves instead of starting a marquee; scores and estimates unchanged |
| 2026-11-06 | minor | Transform fields: one Tab moves from one numeric field to the next in the Transform panel, the Properties panel's Transform section and the Control bar — a field's scrub label was a second, invisible tab stop, and the W/H link between W and H is now made after the fields, so it stays a tab stop after them; scores and estimates unchanged |
| 2026-11-06 | minor | Scrubby labels: the Control bar's Stroke: and Opacity: and the Properties panel's Stroke, links to their panels, scrub the field after them as other labels do; a click still opens the panel (#4088); scores and estimates unchanged |
| 2026-11-06 | minor | Icon-strip flyouts resize on their free edges and retain sizes with workspaces (#5713); saved heights beat auto-fit; panel footers stay at the bottom after enlargement. Scores and estimates unchanged. |
| 2026-11-05 | minor | Direct Selection: Shift-click picks several points of a blend's spine and dragging one moves them all, with the key objects on them, in one undo step (#5285); scores and estimates unchanged |
| 2026-11-05 | minor | Direct Selection: Cmd/Ctrl held lends the Selection tool, whose bounding box shows while the key is down over objects selected whole; its handles scale and rotate, and a Cmd drag moves the whole object with its direct-selected anchors kept (#3901); scores and estimates unchanged |
| 2026-11-05 | minor | Join tool: scrubbing over the ends of two crossing open paths that run past the crossing trims those ends and joins the paths there with a corner, instead of drawing a segment between the ends (#5844); scores and estimates unchanged |
@@ -154,6 +159,7 @@ tools, never checked side by side) isn't captured row by row.
| Busy cursor during long operations | done | Background open, save and export show the system progress cursor (gap G1) |
| Each anchor snaps to Smart Guides and construction angles | done | `DrawSnap` |
| 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 |
| 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`) |
| Layers panel: a layer whose Layer Options › Print is off shows its name italic (#5739) | done | the panel row and its drag ghost; a printing layer stays upright, and Print on again restores it; `panels::layers` test `a_layer_that_does_not_print_has_its_name_italic` |
| Blend spine Anchor Point conversion and handle editing (#2544) | implemented | symmetric handles, independent drag/retraction, visible-handle policy and selection/stacking covered by tool and engine regressions; Windows native canvas checks and screenshots reviewed in PR #2643 |