Merge main into feat/plugin-call-commands

This commit is contained in:
dotCooCoo
2026-11-10 10:16:48 -08:00
104 changed files with 2986 additions and 281 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
+11 -15
View File
@@ -1,21 +1,8 @@
# VectorCraft Roadmap
- Contour add-on (beyond Illustrator): drag a selected shape's edge outward or inward for a live, editable offset copy, with a floating bar for distance and Sharp, Round or Square corners; Bake turns it into paths. Scores and estimates unchanged.
- Compound Shapes add-on (beyond Illustrator): a menu for Make, Release and Expand Compound Shape and a setting, off by default, that makes the Pathfinder panel's Shape Mode buttons create compound shapes on a click. Scores and estimates unchanged.
- Paint: new gradients use the document's color model for default stops, including Gradient tool creation. Scores and estimates unchanged.
- UI: Window → Help Bar toggles the tool-help strip separately from the status bar and saves visibility with workspaces. Scores and estimates unchanged.
- PDF.R1: editable PDF import uses the shared reader, scans page content in windows, and rejects known incomplete input before returning a document.
- PDF.W1: PDF export and print composition use the shared optional writer for configuration, metadata, font preparation and guarded finalization; drawing and output policy remain app-owned.
- UI.5: Shared docking adds tab reordering, docking into other groups, dock splits and resizable floating panel groups. Panel positions and return locations persist with workspaces; the native icon rail and Tools panel remain available.
- UI.4: Dock and floating-panel headers use shared accessible tabs with bounded overflow. Widget-state theme application uses `craft-ui`; all four palettes, font stacks and preferences remain app-owned.
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Raster effects 85% → 88%, gallery thumbnails and in-dialog preview in; headline numbers unchanged; Symbols area ~87% → ~88%: dynamic instance parts take opacity, blend, gradient and pattern on the canvas; G5 docs sync for Overlap and Pattern auto corners; previously minor: Tools stays beside the canvas with left-side panels; Hindi catalog draft complete under the translation tests; contributor loanword terminology reviewed; wider proofreading welcome; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: Raster effects 85% → 88%, gallery thumbnails and in-dialog preview in; headline numbers unchanged; Symbols area ~87% → ~88%: dynamic instance parts take opacity, blend, gradient and pattern on the canvas; G5 docs sync for Overlap and Pattern auto corners; previously minor: Tools stays beside the canvas with left-side panels; Hindi catalog draft complete under the translation tests; contributor loanword terminology reviewed; wider proofreading welcome; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
@@ -35,7 +22,6 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
| | Value | Kind |
|---|---|---|
| 2026-11-03 | minor | Docked Tools stays between left-side panels and the canvas; redocking accepts canvas and window edges. Requested layout choice; scores and estimates unchanged. |
| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 46/50, Photoshop effects 57/57, 3D 4/4 (initial types, no materials) |
| **Feature depth** (weighted by use, scored by behaviour) | **~75%** | estimated |
| **To beta** | **~170–270 h** one agent · ~50–80 h with 4–6 agents | estimated |
@@ -225,7 +211,13 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap
| Date | Change | Summary |
|---|---|---|
| 2026-11-10 | minor | Tidy-up, no content change: the unheaded change bullets at the top of the file and a revision row inside the headline-numbers table move to this table under their commit dates. Scores and estimates unchanged. |
| 2026-11-10 | minor | Raster effects: Effect Gallery thumbnails and an in-dialog preview (an original drawing through each filter). Features-table Raster effects 85% → 88%, hours 1–2 → none. Headline numbers unchanged, so the progress log is unchanged. |
| 2026-11-05 | minor | Compound Shapes add-on (beyond Illustrator): a menu for Make, Release and Expand Compound Shape and a setting, off by default, that makes the Pathfinder panel's Shape Mode buttons create compound shapes on a click. Scores and estimates unchanged. |
| 2026-11-04 | minor | Contour add-on (beyond Illustrator): drag a selected shape's edge outward or inward for a live, editable offset copy, with a floating bar for distance and Sharp, Round or Square corners; Bake turns it into paths. Scores and estimates unchanged. |
| 2026-11-03 | minor | Paint: new gradients use the document's color model for default stops, including Gradient tool creation. Scores and estimates unchanged. |
| 2026-11-03 | minor | UI: Window → Help Bar toggles the tool-help strip separately from the status bar and saves visibility with workspaces. Scores and estimates unchanged. |
| 2026-11-03 | minor | Docked Tools stays between left-side panels and the canvas; redocking accepts canvas and window edges. Requested layout choice; scores and estimates unchanged. |
| 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-06 | minor | Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged |
| 2026-11-05 | minor | Progress log row for the Type Tools add-on's Release Type on a Path (#5532); headline numbers unchanged. |
@@ -262,10 +254,14 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap
| 2026-10-13 | minor | Bounded synthetic control input (#1709): counts and queued events validated before mutation; control-channel bursts yield between frames. Scores and hours unchanged. |
| 2026-10-14 | minor | Progress log row for the merges of 2026-10-14 (#1641–#1808 as listed there) and Image Trace's #1647 and #1657; the 2026-10-13 row now says that a guide's position in the Transform panel (#1659) is not established by the documentation found and is kept as a convenience pending the owner's decision; headline numbers unchanged. |
| 2026-10-13 | minor | Control transport deadlines (#1726): idle and partial clients and blocked replies release connection slots; parity scores and estimates unchanged. |
| 2026-10-13 | minor | PDF.R1: editable PDF import uses the shared reader, scans page content in windows, and rejects known incomplete input before returning a document. |
| 2026-10-13 | minor | PDF.W1: PDF export and print composition use the shared optional writer for configuration, metadata, font preparation and guarded finalization; drawing and output policy remain app-owned. |
| 2026-10-10 | minor | Method changed, no new evidence: full ready for real work is the additive weighted sum, ~62%; hours per audience; beta ~13 points away |
| 2026-10-10 | minor | Added the essentials-user readiness score (~75%) |
| 2026-10-10 | minor | Ready for real work re-examined against the issue tracker and user feedback: 50% → 55% (full), 65% mainstream added; see target-app-parity.md |
| 2026-10-10 | minor | Applied the core-workflow gate (docs/roadmap.md › Alpha gate): passes, stage stays alpha |
| 2026-10-10 | major | Re-measured against Illustrator 2026 (30.x) from code counts, 71 open issues and public docs; stage set to alpha; moved the parity estimate, honest assessment and "Shipped so far" to `docs/target-app-parity.md`, the gap list to `docs/gaps.md`, milestones to `docs/roadmap.md`, the `.ai` scope to `docs/file-format-parity.md` |
| 2026-10-10 | minor | UI.5: Shared docking adds tab reordering, docking into other groups, dock splits and resizable floating panel groups. Panel positions and return locations persist with workspaces; the native icon rail and Tools panel remain available. |
| 2026-10-10 | minor | UI.4: Dock and floating-panel headers use shared accessible tabs with bounded overflow. Widget-state theme application uses `craft-ui`; all four palettes, font stacks and preferences remain app-owned. |
| 2026-10-05 | major | Honest assessment by dimension and the prioritized gap list |
| 2026-10-02 | major | First 22-area weighted parity estimate |
-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
),
+28
View File
@@ -157,6 +157,7 @@ pub struct BuiltinSymbolLibrary {
pub const BUILTIN: &[BuiltinSymbolLibrary] = &[
BuiltinSymbolLibrary { id: "basic-shapes", name: "Basic Shapes", make: basic_shapes },
BuiltinSymbolLibrary { id: "ui-frames", name: "UI Frames", make: ui_frames },
BuiltinSymbolLibrary { id: "map-markers", name: "Map Markers", make: map_markers },
];
/// Built-in library `id`, made once.
@@ -221,6 +222,33 @@ fn ui_frames() -> SymbolLibrary {
SymbolLibrary { name: "UI Frames".into(), doc }
}
/// Map Markers: pin, flag, crosshair and checkpoint for map/HUD art (original, no 9-slice).
fn map_markers() -> SymbolLibrary {
let c = Point::ZERO;
let pin = [(0.0, 22.0), (-9.0, 4.0), (-12.0, -6.0), (-8.0, -16.0), (0.0, -20.0), (8.0, -16.0), (12.0, -6.0), (9.0, 4.0)];
let flag = [(-16.0, 22.0), (-12.0, 22.0), (-12.0, -8.0), (18.0, -14.0), (-12.0, -20.0), (-12.0, -22.0), (-16.0, -22.0)];
let pin: Vec<Point> = pin.iter().map(|&(x, y)| Point::new(x, y)).collect();
let flag: Vec<Point> = flag.iter().map(|&(x, y)| Point::new(x, y)).collect();
let markers = [
("Pin", vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&pin, true)), "#e74c3c"),
("Flag", vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&flag, true)), "#3498db"),
("Crosshair", shapes::star(c, 20.0, 5.0, 4, 0.0), "#2c3e50"),
("Checkpoint", shapes::polygon(c, 18.0, 4, 0.0), "#27ae60"),
];
let mut doc = Document::new(100.0, 100.0);
for (name, path, hex) in markers {
let fill = Color::from_hex(hex).unwrap_or(Color::BLACK);
let edge = fill.lerp(&Color::BLACK, 0.35);
let id = doc.alloc_id();
let art = Node::path(id, path, Appearance::basic(Paint::solid(fill), Paint::solid(edge), 1.25));
let Ok((art, size, _)) = normalize(art) else { continue };
doc.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(art) });
set_natural_size(&mut doc, name, Some(size));
}
doc.title = "Map Markers".into();
SymbolLibrary { name: "Map Markers".into(), doc }
}
/// An instance of symbol `name` (what the resource gathering follows).
fn instance(name: &str) -> Node {
Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: name.into(), xf: Affine::IDENTITY })
+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());
+31 -1
View File
@@ -84,12 +84,16 @@ fn the_builtin_library_is_original_art_generated_in_code() {
let mut s = Session::new();
let list = run(&mut s, "symbol.libraries", json!({}));
let libs = list["libraries"].as_array().unwrap();
assert_eq!(libs.len(), 2, "{libs:?}");
assert_eq!(libs.len(), 3, "{libs:?}");
assert_eq!((libs[0]["id"].as_str(), libs[0]["category"].as_str()), (Some("basic-shapes"), Some("builtIn")));
assert_eq!(
(libs[1]["id"].as_str(), libs[1]["name"].as_str(), libs[1]["category"].as_str()),
(Some("ui-frames"), Some("UI Frames"), Some("builtIn"))
);
assert_eq!(
(libs[2]["id"].as_str(), libs[2]["name"].as_str(), libs[2]["category"].as_str()),
(Some("map-markers"), Some("Map Markers"), Some("builtIn"))
);
let lib = run(&mut s, "symbol.library", json!({"library": "basic shapes"}));
let names: Vec<&str> = lib["symbols"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect();
assert_eq!(names, ["Circle", "Square", "Rounded Square", "Triangle", "Diamond", "Hexagon", "Star", "Burst", "Arrow"]);
@@ -125,6 +129,32 @@ fn ui_frames_symbols_carry_default_nine_slice_when_added() {
));
}
#[test]
fn map_markers_symbols_add_without_nine_slice() {
let mut s = session();
let lib = run(&mut s, "symbol.library", json!({"library": "map-markers"}));
let names: Vec<&str> = lib["symbols"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect();
assert_eq!(names, ["Pin", "Flag", "Crosshair", "Checkpoint"]);
let mut placed = Vec::new();
for (i, name) in names.iter().enumerate() {
let r = run(
&mut s,
"symbol.addFromLibrary",
json!({"library": "map-markers", "name": name, "place": true, "x": 100.0 + 80.0 * i as f64, "y": 100.0}),
);
assert_eq!(r["added"], json!([name]));
placed.push(r["placed"].as_u64().unwrap());
}
let d = doc(&s);
for name in &names {
assert_eq!(d.symbol_nine_slice(name), None, "{name} should not carry 9-slice guides");
}
assert!(matches!(
d.node(NodeId(placed[0])).map(|n| &n.kind),
Some(NodeKind::SymbolInstance { symbol, .. }) if symbol == "Pin"
));
}
#[test]
fn a_saved_library_reopens_and_its_symbols_join_another_document_in_one_step() {
let dir = folder("user");
+1
View File
@@ -37,6 +37,7 @@ use crate::ps;
pub(crate) use envelope::DRAWN as ENVELOPE_DRAWN;
#[cfg(test)]
pub(crate) use envelope::MAX_CHECKED;
pub(crate) use envelope::TYPE_DRAWN as ENVELOPE_TYPE_DRAWN;
pub(crate) use envelope::{WARPED_TYPE_DRAWN, WarpedType};
pub(crate) use frames::text_frames;
pub use obj::{NameEncoding, Names};
+179 -5
View File
@@ -23,7 +23,7 @@
//! one-patch mesh. An envelope whose parts don't read, or that doesn't come out as drawn, keeps its
//! drawn art.
use std::collections::{BTreeMap, BTreeSet};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::sync::Arc;
use kurbo::{Affine, BezPath, Point, Rect, Vec2};
@@ -41,8 +41,17 @@ use crate::import::native::compare::{MAX_DIFFERENCE, MAX_SOLID_DIFFERENCE, compa
pub(super) const PLUGIN: &str = "Adobe Deform Plugin"; // brand-ok: the plugin name the files use
/// The note for an envelope that keeps its drawn art.
pub(crate) const DRAWN: &str = "an envelope VectorCraft couldn't read as a live envelope comes in as its drawn art";
/// The note for an envelope of type, which keeps its drawn art with its type hidden (unless the
/// text document gives the type nothing, which a note of its own then says).
pub(crate) const TYPE_DRAWN: &str = "type in an envelope comes in as the envelope's drawn art, not as editable type";
/// The note for warped type that keeps its drawn art, before how many.
pub(crate) const WARPED_TYPE_DRAWN: &str = "warped text objects that came in as drawn art, not as editable type";
/// The note for an envelope that keeps its drawn art: [`TYPE_DRAWN`] for an envelope of type
/// (`typed`), else [`DRAWN`].
pub(crate) fn drawn_note(typed: bool) -> &'static str {
if typed { TYPE_DRAWN } else { DRAWN }
}
/// The most envelopes waiting for their drawn art at once.
pub(super) const MAX_PENDING: usize = 64;
/// The most envelopes and warped text objects one import draws to check; more keep their drawn art.
@@ -368,18 +377,160 @@ pub(super) struct WarpedLook {
pub(super) left_out: Option<Rect>,
}
/// The text objects read with a Warp, and what the import has left of the budget for checks
/// ([`drawn_alike`]). Type is laid out after the layers are read, so these wait for that
/// ([`Self::settle`]).
/// An envelope of type read with its drawn art: its dictionary, the group on `%_` lines before it
/// and the art in its `AIDeformArtKey` block, as [`live`] takes them (their text slots still
/// empty), and the id of its drawn art in the document. Type is laid out after the layers are read,
/// so it waits for that ([`WarpedType::settle_envelopes`]).
#[derive(Debug)]
pub(crate) struct TypedEnvelope {
pub(super) obj: Obj,
pub(super) before: Option<Node>,
pub(super) inner: Vec<Node>,
pub(super) drawn: NodeId,
}
/// Why an envelope of type kept its drawn art.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum TypeKept {
/// Its type is set in a font that isn't available: the fallback font doesn't draw as the file.
Font,
/// A text slot of it has no type of its own (none made, or type a look draws, threaded type,
/// type bound to a variable or a wrap object).
Type,
/// Its parts don't make a live envelope, or its drawn art is gone.
Read,
/// The import has no check left.
Budget,
/// Its live envelope doesn't draw as its drawn art.
Differs,
}
/// What [`WarpedType::settle_envelopes`] did.
#[derive(Debug, Default)]
pub(crate) struct TypedSettled {
/// How many envelopes of type opened live.
pub(crate) live: usize,
/// Why each of the others kept its drawn art.
pub(crate) kept: Vec<TypeKept>,
/// Each live envelope's id, with what takes its place again when the layers with it don't draw
/// as the page: its hidden type, then its drawn art.
pub(crate) put_back: Vec<(NodeId, Vec<Node>)>,
}
/// The text objects read with a Warp and the envelopes of type, and what the import has left of the
/// budget for checks ([`drawn_alike`]). Type is laid out after the layers are read, so these wait
/// for that ([`Self::settle`], [`Self::settle_envelopes`]).
#[derive(Debug, Default)]
pub struct WarpedType {
pub(super) looks: Vec<WarpedLook>,
pub(super) envelopes: Vec<TypedEnvelope>,
pub(super) checks: usize,
pub(super) spent: Spent,
pub(super) page: Option<Rect>,
}
impl WarpedType {
/// Are envelopes of type waiting for their type?
pub(crate) fn has_envelopes(&self) -> bool {
!self.envelopes.is_empty()
}
/// These, for layers `compared` with a page. Layers that aren't leave the envelopes of type
/// their drawn art and their hidden type, with the note ([`TYPE_DRAWN`]) added to `notes`: a
/// live envelope opens only where the page chooses it.
pub(crate) fn compared(mut self, compared: bool, notes: &mut Vec<String>) -> Self {
if !compared && !std::mem::take(&mut self.envelopes).is_empty() && !notes.iter().any(|n| n == TYPE_DRAWN) {
notes.push(TYPE_DRAWN.into());
}
self
}
/// Make each envelope of type live where it draws as its drawn art, its contents' text slots
/// holding their type: `type_of` gives, for a slot's id, the hidden text object it became in
/// `doc` and the type the contents take in its place (the slot's visibility and lock are the
/// type's). The live envelope takes its drawn art's place and the hidden type goes. Each check
/// uses one of the import's checks and, for an envelope that shows, adds to what the page check
/// allows ([`drawn_alike`]). Elsewhere, and where the type is set in a font that isn't
/// available, the drawn art and the hidden type stay.
pub(crate) fn settle_envelopes(&mut self, doc: &mut Document, type_of: impl Fn(&Document, NodeId) -> Option<(NodeId, Node)>) -> TypedSettled {
let mut settled = TypedSettled::default();
if self.envelopes.is_empty() {
return settled;
}
let mut shown = BTreeSet::new();
doc.layers.iter().for_each(|l| collect_shown(l, &mut shown));
for envelope in std::mem::take(&mut self.envelopes) {
match self.typed_live(doc, envelope, &type_of, &shown) {
Ok(put_back) => {
settled.live += 1;
settled.put_back.push(put_back);
}
Err(why) => settled.kept.push(why),
}
}
settled
}
/// [`Self::settle_envelopes`] for `envelope`, the shown objects of `doc` being `shown`: the live
/// envelope's id and what takes its place again, or why its drawn art stays.
fn typed_live(
&mut self,
doc: &mut Document,
envelope: TypedEnvelope,
type_of: &impl Fn(&Document, NodeId) -> Option<(NodeId, Node)>,
shown: &BTreeSet<NodeId>,
) -> Result<(NodeId, Vec<Node>), TypeKept> {
let TypedEnvelope { obj, mut before, mut inner, drawn } = envelope;
let art = doc.node(drawn).cloned().ok_or(TypeKept::Read)?;
// The text slots in the contents, in the order the reader put their hidden copies in.
let mut slots = vec![];
before.iter().chain(&inner).for_each(|n| text_slots_in(n, &mut slots));
if slots.is_empty() {
return Err(TypeKept::Type);
}
let db = vectorcraft_text::FontDb::global();
let (mut texts, mut hidden) = (HashMap::new(), vec![]);
for (slot, look) in slots {
let (id, mut text) = type_of(doc, slot).filter(|_| !look).ok_or(TypeKept::Type)?;
let NodeKind::Text(t) = &text.kind else { return Err(TypeKept::Type) };
if t.runs.iter().any(|r| r.style.font_family.is_empty() || !db.has_family(&r.style.font_family)) {
return Err(TypeKept::Font);
}
text.id = doc.alloc_id();
texts.insert(slot, text);
hidden.push(id);
}
for n in before.iter_mut().chain(inner.iter_mut()) {
put_type(n, &texts);
}
let kind = live(&obj, before, inner).ok_or(TypeKept::Read)?;
if self.checks == 0 {
return Err(TypeKept::Budget);
}
self.checks -= 1;
let mut envelope = art.clone();
(envelope.id, envelope.kind) = (doc.alloc_id(), kind);
let layer = doc.alloc_id();
let shared = Shared { patterns: &doc.patterns, images: &doc.images };
let (mut live_one, mut plain) = (envelope.clone(), art.clone());
(live_one.visible, live_one.opacity) = (true, 1.0);
(plain.visible, plain.opacity) = (true, 1.0);
let page = self.page.filter(|_| shown.contains(&drawn));
if !drawn_alike(&live_one, &plain, layer, &shared, page, &mut self.spent) {
return Err(TypeKept::Differs);
}
if doc.node(drawn).is_none() {
return Err(TypeKept::Read);
}
// The type is the envelope's now: its hidden copy goes, kept for when the drawn art comes back.
let mut put_back: Vec<Node> = hidden.into_iter().filter_map(|id| doc.remove(id).ok()).map(Arc::unwrap_or_clone).collect();
let id = envelope.id;
let Some(slot) = doc.node_mut(drawn) else { return Err(TypeKept::Read) };
*slot = envelope;
put_back.push(art);
Ok((id, put_back))
}
/// Replace each warped text object of `doc` that doesn't draw as its saved look (or that the
/// budget has no check left for) with that look: `made` gives the text object a slot became. A
/// frame in `threaded` stays type, since the frames of a thread share one story. A replaced
@@ -392,7 +543,7 @@ impl WarpedType {
threaded: &BTreeSet<NodeId>,
restyled: &mut Vec<Rect>,
) -> usize {
let Self { looks, mut checks, mut spent, page } = self;
let Self { looks, mut checks, mut spent, page, .. } = self;
let mut shown_ids = BTreeSet::new();
doc.layers.iter().for_each(|l| collect_shown(l, &mut shown_ids));
let mut replaced = 0;
@@ -436,6 +587,29 @@ impl WarpedType {
}
}
/// The ids of the text slots in `n` and its groups, in order, each with whether it is a slot of type
/// that a look draws ([`super::is_look_slot`]).
fn text_slots_in(n: &Node, out: &mut Vec<(NodeId, bool)>) {
let look = super::is_look_slot(n.name.as_deref());
if look || super::slot_of(n.name.as_deref()).is_some() {
out.push((n.id, look));
} else if !matches!(n.kind, NodeKind::Compound { .. }) {
n.children().into_iter().flatten().for_each(|c| text_slots_in(c, out));
}
}
/// `n` with each text slot in it or its groups replaced by its type in `texts`, by the slot's id,
/// shown and locked as the slot is.
fn put_type(n: &mut Node, texts: &HashMap<NodeId, Node>) {
if let Some(text) = texts.get(&n.id) {
let (visible, locked) = (n.visible, n.locked);
*n = text.clone();
(n.visible, n.locked) = (visible, locked);
} else if !matches!(n.kind, NodeKind::Compound { .. }) {
n.children_mut().into_iter().flatten().for_each(|c| put_type(Arc::make_mut(c), texts));
}
}
/// Add to `out` the ids of `n` and what is in it, unless hidden.
fn collect_shown(n: &Node, out: &mut BTreeSet<NodeId>) {
if n.visible {
+4
View File
@@ -557,6 +557,9 @@ struct Reader<'a> {
envelope_spent: super::envelope::Spent,
/// The text objects read with a Warp ([`super::envelope::WarpedType`]).
warped_type: Vec<super::envelope::WarpedLook>,
/// The envelopes of type read with their drawn art, settled once the type is laid out
/// ([`super::envelope::TypedEnvelope`]).
typed_envelopes: Vec<super::envelope::TypedEnvelope>,
/// The live envelopes made so far and the drawn art each took the place of
/// ([`Structure::envelopes_drawn`]).
envelopes_drawn: Vec<(NodeId, Node)>,
@@ -769,6 +772,7 @@ impl<'a> Reader<'a> {
envelope_checks: super::envelope::MAX_CHECKED,
envelope_spent: super::envelope::Spent::default(),
warped_type: Vec::new(),
typed_envelopes: Vec::new(),
envelopes_drawn: Vec::new(),
clip_fills: HashSet::new(),
looks: HashSet::new(),
+42 -12
View File
@@ -516,16 +516,17 @@ impl<'a> Reader<'a> {
slot.visible = false;
self.add(slot, false)?;
}
// An envelope of type keeps its drawn art and its type, hidden, as above: the
// type is filled in after reading, which the envelope can't wait for.
if typed {
return Ok(());
}
if self.envelopes.len() >= crate::import::ai::envelope::MAX_PENDING {
self.warn(crate::import::ai::envelope::DRAWN);
// An envelope of type keeps its drawn art and its type, hidden, as above, until
// the type is filled in after reading: then it opens live where it draws as its
// drawn art ([`crate::import::ai::envelope::TypedEnvelope`]). In a symbol's or a
// pattern's art it keeps them.
let waits = !typed || (self.symbol.is_none() && self.pattern.is_none());
if !waits || self.envelopes.len() >= crate::import::ai::envelope::MAX_PENDING {
self.warn(crate::import::ai::envelope::drawn_note(typed));
} else {
let siblings = self.frames.last().map_or(0, |f| f.children.len());
self.envelopes.push(PendingEnvelope { obj: *o, before, inner: children, depth: self.frames.len(), siblings });
let depth = self.frames.len();
self.envelopes.push(PendingEnvelope { obj: *o, before, inner: children, depth, siblings, typed });
}
}
}
@@ -862,9 +863,8 @@ impl<'a> Reader<'a> {
/// in containers that closed keep their drawn art.
pub(super) fn end_envelope(&mut self) {
let depth = self.frames.len();
while self.envelopes.last().is_some_and(|p| p.depth > depth) {
self.envelopes.pop();
self.warn(crate::import::ai::envelope::DRAWN);
while let Some(p) = self.envelopes.pop_if(|p| p.depth > depth) {
self.warn(crate::import::ai::envelope::drawn_note(p.typed));
}
if self.envelopes.last().is_none_or(|p| p.depth != depth) {
return;
@@ -872,9 +872,13 @@ impl<'a> Reader<'a> {
let Some(p) = self.envelopes.pop() else { return };
let drawn = self.frames.last().filter(|f| f.children.len() == p.siblings + 1).and_then(|f| f.children.last());
let Some(drawn) = drawn else {
self.warn(crate::import::ai::envelope::DRAWN);
self.warn(crate::import::ai::envelope::drawn_note(p.typed));
return;
};
if p.typed {
self.wait_for_type(p);
return;
}
let live = (self.envelope_checks > 0).then(|| crate::import::ai::envelope::live(&p.obj, p.before, p.inner)).flatten();
let Some(kind) = live else {
self.warn(crate::import::ai::envelope::DRAWN);
@@ -931,6 +935,32 @@ impl<'a> Reader<'a> {
}
}
impl Reader<'_> {
/// An envelope of type `p` whose drawn art was just read (the last object of this container):
/// it waits for its type to be laid out ([`crate::import::ai::envelope::TypedEnvelope`]), unless
/// objects the reader couldn't read are in it, or too many already wait. Then it keeps its
/// drawn art and its type, hidden.
fn wait_for_type(&mut self, p: PendingEnvelope) {
let Some(drawn) = self.frames.last().and_then(|f| f.children.last()) else { return };
let mut ids = HashSet::new();
fn collect(n: &Node, ids: &mut HashSet<NodeId>) {
ids.insert(n.id);
for c in n.children().into_iter().flatten() {
collect(c, ids);
}
}
collect(drawn, &mut ids);
p.before.iter().chain(&p.inner).for_each(|n| collect(n, &mut ids));
let unread = self.unread.iter().any(|(id, _)| ids.contains(id));
if unread || self.typed_envelopes.len() >= crate::import::ai::envelope::MAX_CHECKED {
self.warn(crate::import::ai::envelope::TYPE_DRAWN);
return;
}
let drawn = drawn.id;
self.typed_envelopes.push(crate::import::ai::envelope::TypedEnvelope { obj: p.obj, before: p.before, inner: p.inner, drawn });
}
}
/// Does `node` hold a text slot?
fn has_text_slot(node: &Node) -> bool {
let mut slots = vec![];
+8 -3
View File
@@ -1165,9 +1165,8 @@ impl<'a> Reader<'a> {
self.warn(super::types::GRAPH_UNENDED);
}
// Envelopes whose plugin never closed them keep their drawn art.
if !self.envelopes.is_empty() {
self.envelopes.clear();
self.warn(crate::import::ai::envelope::DRAWN);
for p in std::mem::take(&mut self.envelopes) {
self.warn(crate::import::ai::envelope::drawn_note(p.typed));
}
if !self.unknown.is_empty() {
let ops: Vec<String> = self.unknown.iter().map(|o| format!("`{o}`")).collect();
@@ -1297,6 +1296,11 @@ impl<'a> Reader<'a> {
for w in &mut self.warped_type {
vectorcraft_doc::swatches::map_node_solid_paints(&mut w.look, &mut follow);
}
for e in &mut self.typed_envelopes {
for n in e.before.iter_mut().chain(e.inner.iter_mut()) {
vectorcraft_doc::swatches::map_node_solid_paints(n, &mut follow);
}
}
}
let mut warnings = self.warnings;
if let Some((lib, unread)) = brushes {
@@ -1341,6 +1345,7 @@ impl<'a> Reader<'a> {
envelopes_drawn: self.envelopes_drawn,
warped_type: super::super::envelope::WarpedType {
looks: self.warped_type,
envelopes: self.typed_envelopes,
checks: self.envelope_checks,
spent: self.envelope_spent,
page,
+3
View File
@@ -224,6 +224,9 @@ pub(super) struct PendingEnvelope {
/// had: its drawn art is the one child after them.
pub(super) depth: usize,
pub(super) siblings: usize,
/// Its contents hold type: it is settled once the type is laid out
/// ([`super::super::envelope::TypedEnvelope`]).
pub(super) typed: bool,
}
/// What a reader of a pattern's definition has of its head, `(name) llx lly urx ury`: the operands
+1 -1
View File
@@ -7992,7 +7992,7 @@ fn warped_type_on_the_page_spends_what_is_left_of_the_budget() {
let copy = |w: &super::envelope::WarpedLook| super::envelope::WarpedLook { slot: w.slot, look: w.look.clone(), left_out: None };
let page = Some(Rect::new(0.0, 60.0, 60.0, 100.0));
let settle = |w: &super::envelope::WarpedLook, spent| {
let warped = super::envelope::WarpedType { looks: vec![copy(w)], checks: 1, spent, page };
let warped = super::envelope::WarpedType { looks: vec![copy(w)], envelopes: vec![], checks: 1, spent, page };
warped.settle(&mut doc.clone(), Some, &Default::default(), &mut vec![])
};
let spent = || super::envelope::Spent { any: MAX_DIFFERENCE / 2.0, solid: MAX_SOLID_DIFFERENCE / 2.0 };
+116 -29
View File
@@ -104,8 +104,7 @@ pub(super) type Built = Result<(Document, Vec<String>), String>;
/// units ([`page_scale_alone`]). The document is drawn at that scale. Else `Err(structure)`, for
/// [`build_from`] with the page.
pub(super) fn build_without_page(structure: Structure, data: &[u8], pages: &[(f64, f64)], unit: f64) -> Result<Built, Box<Structure>> {
let Some(scale) = page_scale_alone(&structure, pages, unit).filter(|_| nothing_waits_on_page(&structure) && type_all_made(&structure, data))
else {
let Some(scale) = alone_scale(&structure, data, pages, unit) else {
return Err(Box::new(structure));
};
let styles = structure.styles_read;
@@ -120,6 +119,12 @@ pub(super) fn build_without_page(structure: Structure, data: &[u8], pages: &[(f6
Ok(built)
}
/// The scale [`build_without_page`] draws `structure` (the editing copy `data` read) at on `pages`
/// in units of `unit` points, when it builds it without the page.
pub(super) fn alone_scale(structure: &Structure, data: &[u8], pages: &[(f64, f64)], unit: f64) -> Option<f64> {
page_scale_alone(structure, pages, unit).filter(|_| nothing_waits_on_page(structure) && type_all_made(structure, data))
}
/// Is nothing in `structure` waiting on the page: no object unread (the page's art takes its
/// place), no placed file with another placement, no live envelope with the drawn art it replaced
/// (the comparison with the page chooses between them), no linked image showing a placeholder
@@ -424,8 +429,20 @@ pub(super) fn build_from(
}
// Area type flows around the wrap objects above it before it is compared with the page.
resolve_text_wrap(&mut doc)?;
let bound_text = text_bound_slots(&doc, &bindings, &xml_ids);
settle_warped_type(warped_type, &mut doc, &filled, &threaded_frames, &bound_text, &mut restyled, &mut notes)?;
let bound_text = bound_slots(&doc, &bindings, &xml_ids, VariableKind::Text);
let bound_visible = bound_slots(&doc, &bindings, &xml_ids, VariableKind::Visibility);
// Without a page to compare the layers with, envelopes of type keep their drawn art: like the
// other live envelopes, one opens live only where the layers with it draw as the page.
let warped_type = warped_type.compared(frame.is_some(), &mut notes);
let bound = [&bound_text, &bound_visible];
let typed = settle_warped_type(warped_type, &mut doc, &filled, &threaded_frames, bound, &mut restyled, &mut notes)?;
// The live envelopes and what takes the place of each again, when the layers with them don't
// draw as the page, with the note that says so.
let envelopes_drawn: Vec<(NodeId, Vec<Node>, &str)> = envelopes_drawn
.into_iter()
.map(|(id, art)| (id, vec![art], ai::ENVELOPE_DRAWN))
.chain(typed.into_iter().map(|(id, nodes)| (id, nodes, ai::ENVELOPE_TYPE_DRAWN)))
.collect();
if empty > 0 {
notes.push(format!("{empty} {}", if frame.is_some() { TEXT_LEFT_OUT } else { TYPE_LEFT_OUT }));
}
@@ -478,9 +495,14 @@ pub(super) fn build_from(
other => match drawn_or_trusted(&frame, &mut view, &mut doc, &envelopes_drawn, &notes, unseen, trust, why, reflow) {
Ok(more) => notes.extend(more),
// The objects that draw differently from the page come in as its art, from
// the placement that draws closer to it.
// the placement that draws closer to it. The layers don't draw as the page:
// envelopes of type keep their drawn art and their type, hidden.
Err(why) => {
let other = other.map(|(_, other, unseen)| (other, unseen));
let typed = type_drawn(&mut doc, &envelopes_drawn);
let other = other.map(|(_, mut other, unseen)| {
type_drawn(&mut other, &envelopes_drawn);
(other, unseen)
});
let placements = closer_first(&frame, &mut view, (doc, unseen), other, reflow);
let Some((kept, unseen, kept_notes)) = placements.into_iter().find_map(|(base, unseen)| {
keep_differing(&base, &frame, &mut view, &threaded_frames, &shown, reflow).map(|(kept, n)| (kept, unseen, n))
@@ -488,7 +510,8 @@ pub(super) fn build_from(
return Err(why);
};
doc = kept;
notes.extend(unseen_note(unseen).into_iter().chain(kept_notes));
let typed = typed.then(|| ai::ENVELOPE_TYPE_DRAWN.to_string()).filter(|n| !notes.contains(n));
notes.extend(typed.into_iter().chain(unseen_note(unseen)).chain(kept_notes));
}
},
},
@@ -616,40 +639,69 @@ fn bind_variables(
notes.extend(ai::bindings_note(skipped));
}
/// The text object slots of `doc` that `bindings` binds to a text variable, each named by its XML id
/// (`ids`).
fn text_bound_slots(doc: &Document, bindings: &[ai::BindingRef], ids: &std::collections::HashMap<String, Option<NodeId>>) -> BTreeSet<NodeId> {
/// The objects of `doc`, text object slots among them, that `bindings` binds to a variable of
/// `kind`, each named by its XML id (`ids`).
fn bound_slots(
doc: &Document,
bindings: &[ai::BindingRef],
ids: &std::collections::HashMap<String, Option<NodeId>>,
kind: VariableKind,
) -> BTreeSet<NodeId> {
bindings
.iter()
.filter(|b| doc.variables.variable(&b.variable).is_some_and(|v| v.kind == VariableKind::Text))
.filter(|b| doc.variables.variable(&b.variable).is_some_and(|v| v.kind == kind))
.filter_map(|b| b.target.as_ref().and_then(|t| ids.get(t).copied().flatten()))
.collect()
}
/// Warped type stays type where its live warp draws as its look, and where a text variable binds it
/// (`bound_text`, by slot). Elsewhere the look takes its place, and one note says how many did.
/// (`bound`'s first set, by slot). Elsewhere the look takes its place, and one note says how many did.
/// `filled` says what each slot became, and the comparisons with the page leave out the boxes in
/// `restyled`. Fails as [`resolve_text_wrap`] does when the looks give the wrap shapes too many
/// anchors.
/// anchors. Before that, the envelopes of type open live where they draw as their drawn art, with
/// the type of their text slots (made type, one text object for the slot, not threaded, bound to
/// no text variable and no visibility variable, `bound`'s second set, and no wrap object); the
/// others keep their drawn art, with a note. Returns each live envelope of type's id with what
/// takes its place again (its hidden type, then its drawn art).
fn settle_warped_type(
warped: ai::WarpedType,
mut warped: ai::WarpedType,
doc: &mut Document,
filled: &std::collections::HashMap<NodeId, Filled>,
threaded: &BTreeSet<NodeId>,
bound_text: &BTreeSet<NodeId>,
bound: [&BTreeSet<NodeId>; 2],
restyled: &mut Vec<Rect>,
notes: &mut Vec<String>,
) -> Result<(), String> {
) -> Result<Vec<(NodeId, Vec<Node>)>, String> {
let [bound_text, bound_visible] = bound;
let made = |slot| match filled.get(&slot).map(|f| f.text.as_slice()).filter(|_| !bound_text.contains(&slot)) {
Some(&[one]) => Some(one),
_ => None,
};
// An envelope's type: the one text object a slot became (hidden, with nothing else), named as
// the slot (made type is no longer weighed against the page: the envelope draws it). A variable
// binds the hidden text object a slot became, which the live envelope's type takes the place of
// with an id of its own: a slot bound to one keeps its type hidden, and the drawn art stays.
let type_of = |doc: &Document, slot: NodeId| {
if bound_visible.contains(&slot) {
return None;
}
let id = made(slot).filter(|id| !threaded.contains(id) && filled.get(&slot).is_some_and(|f| f.objects == [*id]))?;
let mut text = doc.node(id).filter(|n| matches!(n.kind, NodeKind::Text(_)) && n.extras().wrap.is_none())?.clone();
if let Some(name) = text.name.as_deref().and_then(|n| n.strip_prefix(MADE)) {
text.name = (!name.is_empty()).then(|| name.to_string());
}
Some((id, text))
};
let typed = warped.settle_envelopes(doc, type_of);
if !typed.kept.is_empty() && !notes.iter().any(|n| n == ai::ENVELOPE_TYPE_DRAWN) {
notes.push(ai::ENVELOPE_TYPE_DRAWN.into());
}
let replaced = warped.settle(doc, made, threaded, restyled);
if replaced > 0 {
notes.push(format!("{}: {replaced}", ai::WARPED_TYPE_DRAWN));
resolve_text_wrap(doc)?;
}
Ok(())
Ok(typed.put_back)
}
/// Most anchors copied into the wrap shapes of a file's area type ([`vectorcraft_doc::text::wrap_work`]).
@@ -1254,14 +1306,15 @@ fn slot_content(
/// The layers `doc` don't draw as the page (`why`), `unseen` text objects of them taken out: the
/// notes to add when `doc` with each live envelope `drawn` lists replaced by the drawn art it took
/// the place of (see [`ai::Structure::envelopes_drawn`]) draws as the page, `doc` then being that;
/// else, with `trust`, the notes for `doc` as it is. An error, `why`, otherwise. The envelope note
/// is added unless `notes` has it.
/// else, with `trust`, the notes for `doc` as it is but for its envelopes of type, which keep their
/// drawn art ([`type_drawn`]). An error, `why`, otherwise. The envelope note is added unless
/// `notes` has it.
#[allow(clippy::too_many_arguments)]
fn drawn_or_trusted(
frame: &Frame<'_>,
view: &mut PageView,
doc: &mut Document,
drawn: &[(NodeId, Node)],
drawn: &[(NodeId, Vec<Node>, &str)],
notes: &[String],
unseen: usize,
trust: bool,
@@ -1270,27 +1323,61 @@ fn drawn_or_trusted(
) -> Result<Vec<String>, String> {
let unseen_note = |n: usize| (n > 0).then(|| format!("{n} {UNSEEN_TEXT}"));
let mut put_back = doc.clone();
let mut any = false;
for (id, art) in drawn {
if let Some(n) = put_back.node_mut(*id) {
*n = art.clone();
any = true;
}
// Each envelope still there, in place of the art it took the place of.
let there: Vec<&(NodeId, Vec<Node>, &str)> = drawn.iter().filter(|(id, ..)| put_back.node(*id).is_some()).collect();
// One inside another's art goes with it: only the outermost are put back (a place inside
// another place fails the whole put-back).
let mut inside = std::collections::HashSet::new();
for n in there.iter().filter_map(|(id, ..)| put_back.node(*id)) {
n.walk(&mut |c| {
if c.id != n.id {
inside.insert(c.id);
}
});
}
let outermost = there.iter().filter(|(id, ..)| !inside.contains(id)).map(|(id, art, _)| (*id, art.clone())).collect();
let any = !there.is_empty() && put_back.insert_beside(outermost, true).is_ok();
if any {
let more = prune_unseen(frame, view, &mut put_back, reflow);
if let Ok(note) = compare(frame, view, &put_back, reflow) {
*doc = put_back;
let envelope = (!notes.iter().any(|n| n == ai::ENVELOPE_DRAWN)).then(|| ai::ENVELOPE_DRAWN.to_string());
let mut envelope: Vec<String> = vec![];
for (.., say) in there {
if !notes.iter().chain(&envelope).any(|n| n == say) {
envelope.push(say.to_string());
}
}
return Ok(envelope.into_iter().chain(unseen_note(unseen + more)).chain(note).collect());
}
}
if trust {
return Ok(unseen_note(unseen).into_iter().chain([format!("{why}: they come from its editing data all the same")]).collect());
let typed = type_drawn(doc, drawn).then(|| ai::ENVELOPE_TYPE_DRAWN.to_string()).filter(|n| !notes.contains(n));
let trusted = format!("{why}: they come from its editing data all the same");
return Ok(typed.into_iter().chain(unseen_note(unseen)).chain([trusted]).collect());
}
Err(why)
}
/// Put each live envelope of type in `drawn` (as [`drawn_or_trusted`] takes them) that `doc` still
/// has back as its hidden type and its drawn art: whether one was. An envelope of type opens live
/// only where the layers with it draw as the page, so where they don't, though they are used all
/// the same (the editing data trusted, or the objects that differ come in as the page's art), it
/// keeps its drawn art.
fn type_drawn(doc: &mut Document, drawn: &[(NodeId, Vec<Node>, &str)]) -> bool {
let typed: Vec<(NodeId, Vec<Node>)> = drawn
.iter()
.filter(|(id, _, say)| *say == ai::ENVELOPE_TYPE_DRAWN && doc.node(*id).is_some())
.map(|(id, art, _)| (*id, art.clone()))
.collect();
if typed.is_empty() || doc.insert_beside(typed, true).is_err() {
return false;
}
// The type put back is still marked as made from the text document, which the rest no longer
// is once the page has weighed it ([`prune_unseen`]).
unmark(&mut doc.layers, true, true, &mut vec![]);
true
}
/// `doc` with the placed files `placed` lists at their other placement (see
/// [`ai::Structure::placed_elsewhere`]): each image's transform followed by its move. `None` when
/// no listed image survives replacement.
@@ -2000,7 +2087,7 @@ mod candidate_tests {
let warped = ai::WarpedType::unchecked(vec![(id, (*look).clone())]);
let filled = std::collections::HashMap::from([(id, Filled { text: vec![id], objects: vec![id] })]);
let mut notes = vec![];
let settled = settle_warped_type(warped, &mut doc, &filled, &BTreeSet::new(), &BTreeSet::new(), &mut vec![], &mut notes);
let settled = settle_warped_type(warped, &mut doc, &filled, &BTreeSet::new(), [&BTreeSet::new(); 2], &mut vec![], &mut notes);
assert_eq!(settled.is_ok(), ok, "{texts} text objects");
assert_eq!(notes, ["warped text objects that came in as drawn art, not as editable type: 1"]);
}
+5 -2
View File
@@ -714,7 +714,8 @@ fn compared_with_solid(x: &vectorcraft_render::Rendered, y: &vectorcraft_render:
}
/// Where the type that shows in `nodes` is, in pixels of `region` drawn at `scale` (with a margin
/// for the strokes round it).
/// for the strokes round it). The type in a live envelope isn't: the envelope draws it bent, away
/// from its text object's box, and is weighed as the art it draws, as its drawn art would be.
fn type_areas(nodes: &[Arc<Node>], region: Rect, scale: f64, out: &mut Vec<Rect>) {
for n in nodes.iter().filter(|n| n.visible) {
if let NodeKind::Text(_) = n.kind
@@ -723,7 +724,9 @@ fn type_areas(nodes: &[Arc<Node>], region: Rect, scale: f64, out: &mut Vec<Rect>
let px =
|r: Rect| Rect::new((r.x0 - region.x0) * scale, (r.y0 - region.y0) * scale, (r.x1 - region.x0) * scale, (r.y1 - region.y0) * scale);
out.push(px(b.inflate(OUTLINE_REACH, OUTLINE_REACH)));
} else if let Some(c) = n.children() {
} else if !matches!(n.kind, NodeKind::Envelope { .. })
&& let Some(c) = n.children()
{
type_areas(c, region, scale, out);
}
}
+41 -8
View File
@@ -1,4 +1,4 @@
use super::build::{PAGE_MOVE, Page, build, build_from, build_on_page, build_without_page, reid_page_art};
use super::build::{PAGE_MOVE, Page, alone_scale, build, build_from, build_on_page, build_without_page, reid_page_art};
use super::slots::LAYERS_UNREAD;
use super::{Affine, Arc, Cow, Document, Imported, Node, NodeKind, PathData, Rect, Vec2, ai};
@@ -137,7 +137,7 @@ pub fn layered_ai_from(
all.push(format!("{LAYERS_UNREAD} from its editing data ({why}): they come from its PDF part"));
all
};
let AiLayers { data, structure } = layers;
let AiLayers { data, structure, alone } = layers;
let data = match data {
Ok(data) => data,
Err(why) => return (visible, unread(warnings, &why), false),
@@ -180,6 +180,18 @@ pub fn layered_ai_from(
let built = structure
.unwrap_or_else(|| ai::read_over_page(&data, false, names).map(Box::new))
.and_then(|structure| build_from(*structure, &data, Some(&visible), Page::Artboard, outlined, trust, unit));
// Layers that waited on the page only for their envelopes of type open alone, as they would
// have without them, each envelope keeping its drawn art, unless one opened live with the page.
let live = matches!(&built, Ok((done, _)) if holds_type_envelope(&done.layers));
if !live
&& let Some((mut done, notes)) = alone.and_then(|(pages, unit)| {
let structure = ai::read_over_page(&data, false, names).ok()?;
build_without_page(structure, &data, &pages, unit).ok()?.ok()
})
{
done.setup.bleed = visible.setup.bleed;
return (done, notes, true);
}
match built {
Ok((mut done, notes)) => {
done.setup.bleed = visible.setup.bleed;
@@ -191,6 +203,17 @@ pub fn layered_ai_from(
}
}
/// Is there an envelope of type (one whose contents hold type) in `nodes` or what they hold?
fn holds_type_envelope(nodes: &[Arc<Node>]) -> bool {
fn holds_type(n: &Node) -> bool {
matches!(n.kind, NodeKind::Text(_)) || n.children().is_some_and(|c| c.iter().any(|c| holds_type(c)))
}
nodes.iter().any(|n| match &n.kind {
NodeKind::Envelope { content, .. } => content.iter().any(|c| holds_type(c)),
_ => n.children().is_some_and(|c| holds_type_envelope(c)),
})
}
/// The `.ai` whose editing copy is `private` (as [`layered_ai`] takes it) from its layers alone,
/// without its PDF part, whose pages (each its trim box in points, width and height) are `pages`
/// and are in units of `unit` points (its first page's `UserUnit`): the document and the notes of
@@ -201,20 +224,27 @@ pub fn layered_ai_from(
pub fn ai_layers_alone(private: &[u8], pages: &[(f64, f64)], unit: f64, names: ai::NameEncoding) -> Result<(Document, Vec<String>), AiLayers> {
let data = match ai::decode_private(private) {
Ok(data) => data,
Err(why) => return Err(AiLayers { data: Err(why), structure: None }),
Err(why) => return Err(AiLayers { data: Err(why), structure: None, alone: None }),
};
if !has_layers(&data) {
return Err(AiLayers { data: Ok(data), structure: None });
return Err(AiLayers { data: Ok(data), structure: None, alone: None });
}
// An image past the hard limits is read as an unread box, which the PDF part's art replaces.
let structure = match ai::read_over_page(&data, false, names) {
Ok(structure) => structure,
Err(why) => return Err(AiLayers { data: Ok(data), structure: Some(Err(why)) }),
Err(why) => return Err(AiLayers { data: Ok(data), structure: Some(Err(why)), alone: None }),
};
// An envelope of type opens live only where the layers with it draw as the page, as the other
// live envelopes do: the PDF part is imported to compare them with. Should none open live, the
// layers open alone as they would have, the envelopes keeping their drawn art ([`layered_ai_from`]).
if structure.warped_type.has_envelopes() && alone_scale(&structure, &data, pages, unit).is_some() {
let alone = Some((pages.to_vec(), unit));
return Err(AiLayers { data: Ok(data), structure: Some(Ok(Box::new(structure))), alone });
}
match build_without_page(structure, &data, pages, unit) {
Ok(Ok((doc, notes))) => Ok((doc, notes)),
Ok(_) => Err(AiLayers { data: Ok(data), structure: None }),
Err(structure) => Err(AiLayers { data: Ok(data), structure: Some(Ok(structure)) }),
Ok(_) => Err(AiLayers { data: Ok(data), structure: None, alone: None }),
Err(structure) => Err(AiLayers { data: Ok(data), structure: Some(Ok(structure)), alone: None }),
}
}
@@ -226,12 +256,15 @@ pub struct AiLayers {
/// Its layers read with [`ai::read`] as [`layered_ai`] reads them, or why they can't be; `None`
/// when they are still to be read.
structure: Option<Result<Box<ai::Structure>, String>>,
/// The pages and their unit the layers open on without the PDF part, when they wait on it only
/// for their envelopes of type ([`ai_layers_alone`]).
alone: Option<(Vec<(f64, f64)>, f64)>,
}
impl AiLayers {
/// The editing copy `private` (as [`layered_ai`] takes it), decoded.
pub fn decode(private: &[u8]) -> Self {
Self { data: ai::decode_private(private), structure: None }
Self { data: ai::decode_private(private), structure: None, alone: None }
}
}
+391
View File
@@ -5222,9 +5222,400 @@ fn envelope_options_keep_the_source_text_hidden_under_its_outlined_appearance()
}
});
assert_eq!(texts, [("CHONKYCATS".to_string(), false)], "{:?}", r.warnings);
// The notes say the type isn't editable.
assert_eq!(super::tests::program_warnings(&r.warnings), [crate::import::ai::ENVELOPE_TYPE_DRAWN], "{:?}", r.warnings);
}
/// A family that is installed, for type that must draw as itself; `None` (the test is skipped)
/// when no font is available.
fn installed_family() -> Option<String> {
let db = vectorcraft_text::FontDb::global();
let plain = |f: &String| !f.is_empty() && f.chars().all(|c| c.is_ascii_alphanumeric() || c == ' ');
["DejaVu Sans", "Liberation Sans", "Noto Sans", "Source Sans 3", "Arial", "Helvetica"]
.iter()
.map(|f| f.to_string())
.find(|f| db.has_family(f))
.or_else(|| db.families().into_iter().find(|f| plain(f) && db.has_family(f)))
}
/// The art space of the 100 × 100 page of [`native_with_text`] has y up.
fn to_art(p: kurbo::Point) -> (f64, f64) {
(p.x, 100.0 - p.y)
}
/// What the live envelope `envelope` (on the page axes) draws, as VectorCraft draws it: the
/// format's paths (each subpath filled on its own, in the color its type is drawn in) and the
/// page's PostScript for them.
fn drawn_envelope(envelope: &Node) -> (String, String) {
fn walk(n: &Node, color: [f32; 3], art: &mut String, page: &mut String) {
let [r, g, b] = n.appearance.fill().and_then(|f| f.paint.color()).map_or(color, |c| c.to_rgb());
let NodeKind::Path { path, .. } = &n.kind else {
n.children().into_iter().flatten().for_each(|c| walk(c, [r, g, b], art, page));
return;
};
let pt = |p: kurbo::Point| {
let (x, y) = to_art(p);
format!("{x} {y}")
};
for el in path.to_bezpath().elements() {
match *el {
kurbo::PathEl::MoveTo(p) => {
*art += &format!("0 0 0 0 {r} {g} {b} Xa\n{} m\n", pt(p));
*page += &format!("{r} {g} {b} setrgbcolor newpath {} moveto ", pt(p));
}
kurbo::PathEl::LineTo(p) => {
*art += &format!("{} L\n", pt(p));
*page += &format!("{} lineto ", pt(p));
}
kurbo::PathEl::CurveTo(a, c, p) => {
*art += &format!("{} {} {} C\n", pt(a), pt(c), pt(p));
*page += &format!("{} {} {} curveto ", pt(a), pt(c), pt(p));
}
kurbo::PathEl::QuadTo(..) => {}
kurbo::PathEl::ClosePath => {
*art += "f\n";
*page += "closepath fill\n";
}
}
}
}
let (mut art, mut page) = (String::new(), String::new());
walk(&vectorcraft_effects::expand_live_deep(None, envelope), [0.0; 3], &mut art, &mut page);
(art, page)
}
/// An arc warp of `bend` (a fraction) in the editing data's keys, with the Envelope Options new
/// envelopes have and Fidelity 50.
fn arc_keys(bend: f64) -> String {
format!(
"131073 /Int (AIDeformOptionsKey) ,\n50 /Int (AIDeformFidelityKey) ,\n0 /Bool (AIDeformEditModeKey) ,\n1 /Int (DeformStyle) ,\n0 /Real (DeformHoriz) ,\n{bend} /Real (DeformValue) ,\n0 /Real (DeformVert) ,\n0 /Bool (Rotate) ,\n"
)
}
/// An envelope made with a warp (`keys`) around the type of story 0, as the editing data writes
/// it: on `%_` lines the group with its mesh, its dictionary with the text object in its contents,
/// then its drawn art `drawn` on plain lines and the plugin's `XP`.
fn type_envelope(keys: &str, drawn: &str) -> String {
type_envelope_with(keys, drawn, "")
}
/// [`type_envelope`], with `after` (on plain lines, written on `%_` ones) after the text object in
/// its contents, such as the text object's XML id.
fn type_envelope_with(keys: &str, drawn: &str, after: &str) -> String {
let hidden = |s: &str| -> String { s.lines().map(|l| format!("%_{l}\n")).collect() };
let corner = |x: f64, y: f64, nx: f64, ny: f64, px: f64, py: f64| format!("[0.8 1 {x} {y} {nx} {ny} 1 {px} {py} 1] /N X#\n");
let mesh = format!(
"/Mesh X!\n2 /Version X#\n/Cartesian /Type X#\n[1 1] /Size X#\n/Data X#\n/DeviceGray /CS X#\n[0 0] /P X#\n{}{}{}{}/E X#\n/End X!\n",
corner(20.0, 40.0, 20.0, 50.0, 30.0, 40.0),
corner(20.0, 70.0, 30.0, 70.0, 20.0, 60.0),
corner(80.0, 70.0, 80.0, 60.0, 70.0, 70.0),
corner(80.0, 40.0, 70.0, 40.0, 80.0, 50.0),
);
format!(
"{}{}%_X=\n{}%_X+\n%_ (AIDeformArtKey) ,\n%_1 /Int (AIDeformStyle) ,\n%_;\n%_\n%_9 () XW\nu\n{drawn}U\n9 () XW\n(Adobe Deform Plugin) 1 0 0 XP\n",
hidden(&format!("u\n{mesh}U\n")),
hidden(&format!("/ArtDictionary :\n{keys}")),
hidden(&format!("u\n{}{after}U\n9 () XW\n", text_object(0))),
)
}
/// The text document of the type in [`type_envelope`]: "FLUX" set in `family`, drawn in `paint` (a
/// text paint, the default's when `None`).
fn type_envelope_text(family: &str, paint: Option<&str>) -> String {
let doc = text_document(&[("FLUX\r", 0, (8170.0, 8180.0), (0.0, 0.0), 0.0)]).replace("(Helvetica)", &format!("({family})"));
let mut doc = match paint {
Some(p) => doc.replace("/0 << /0 2 /1 [ 1.0 0.0 1.0 0.0 0.0 ] >>", p),
None => doc,
};
// [`native_with_text`] writes 60 characters a line: the end of the data, `~>`, isn't split.
while ascii85(doc.as_bytes()).len() % 60 == 1 {
doc.push('\n');
}
doc
}
/// The live envelope an arc warp of `bend` (in percent) makes of `text`, as the reader makes it.
fn arc_envelope(text: &Node, bend: f64) -> Node {
let mut text = text.clone();
text.visible = true;
let kind = vectorcraft_doc::live::EnvelopeKind::Warp { style: "arc".into(), bend, h: 0.0, v: 0.0, horizontal: true };
Node::new(
vectorcraft_doc::NodeId(1_000_000),
NodeKind::Envelope {
content: vec![std::sync::Arc::new(text)],
kind: Box::new(kind),
fidelity: 50.0,
editing: false,
options: vectorcraft_doc::live::EnvelopeOptions::NEW,
frame: Box::default(),
},
)
}
/// The type of the file with an envelope of type whose drawn art is `drawn`, read with its text
/// document `doc`: the hidden text object it keeps, as the reader makes it.
fn made_envelope_type(doc: &str) -> Node {
let art = native_with_text(&layer("Art", true, &type_envelope(&arc_keys(0.3), &format!("{}\n", square(10, 10)))), doc);
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
let mut text = None;
r.document.walk(|n| {
if matches!(n.kind, NodeKind::Text(_)) {
text = Some(n.clone());
}
});
text.unwrap_or_else(|| panic!("no type: {:?}", r.warnings))
}
/// The envelopes and the type, shown or not, in the layers of `d`: each envelope's contents, and
/// the plain text of the type outside envelopes with whether it shows.
fn envelopes_and_type(d: &Document) -> (Vec<Vec<String>>, Vec<(String, bool)>) {
let (mut envelopes, mut loose) = (vec![], vec![]);
fn walk(n: &Node, envelopes: &mut Vec<Vec<String>>, loose: &mut Vec<(String, bool)>) {
match &n.kind {
NodeKind::Envelope { content, .. } => envelopes.push(
content
.iter()
.map(|c| {
if let NodeKind::Text(t) = &c.kind {
format!("{}{}", t.plain_text(), if c.visible { "" } else { " (hidden)" })
} else {
"art".into()
}
})
.collect(),
),
NodeKind::Text(t) => loose.push((t.plain_text(), n.visible)),
_ => n.children().into_iter().flatten().for_each(|c| walk(c, envelopes, loose)),
}
}
d.layers.iter().for_each(|l| walk(l, &mut envelopes, &mut loose));
(envelopes, loose)
}
/// An envelope of type whose live envelope, its type laid out from the text document, draws as its
/// drawn art opens live: the type is its contents, shown and editable, and the hidden copy of the
/// type goes (#3179).
#[test]
fn an_envelope_of_type_that_draws_as_its_drawn_art_opens_live() {
let Some(family) = installed_family() else { return };
let doc = type_envelope_text(&family, None);
let (drawn, page) = drawn_envelope(&arc_envelope(&made_envelope_type(&doc), 30.0));
let art = native_with_text(&layer("Art", true, &format!("{}\n{}", square(10, 10), type_envelope(&arc_keys(0.3), &drawn))), &doc);
let r = import(&eps(&format!("{}\n{page}", page_square(10, 10)), &art)).unwrap();
assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings);
let (envelopes, loose) = envelopes_and_type(&r.document);
assert_eq!(envelopes, [vec!["FLUX".to_string()]], "{:?}", r.warnings);
assert!(loose.is_empty(), "{loose:?}");
assert!(super::tests::program_warnings(&r.warnings).is_empty(), "{:?}", r.warnings);
}
/// An envelope of type whose live envelope draws apart from its drawn art (the file's art bends the
/// other way) keeps its drawn art and its type, hidden, with a note.
#[test]
fn an_envelope_of_type_that_draws_apart_from_its_drawn_art_keeps_it() {
let Some(family) = installed_family() else { return };
let doc = type_envelope_text(&family, None);
let (drawn, page) = drawn_envelope(&arc_envelope(&made_envelope_type(&doc), -30.0));
let art = native_with_text(&layer("Art", true, &format!("{}\n{}", square(10, 10), type_envelope(&arc_keys(0.3), &drawn))), &doc);
let r = import(&eps(&format!("{}\n{page}", page_square(10, 10)), &art)).unwrap();
assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings);
let (envelopes, loose) = envelopes_and_type(&r.document);
assert!(envelopes.is_empty(), "{envelopes:?}");
assert_eq!(loose, [("FLUX".to_string(), false)], "{:?}", r.warnings);
assert_eq!(super::tests::program_warnings(&r.warnings), [crate::import::ai::ENVELOPE_TYPE_DRAWN], "{:?}", r.warnings);
}
/// White type in an envelope that draws apart from its drawn art keeps the drawn art too: the
/// envelope and its drawn art are each drawn on their own, where white shows only on black.
#[test]
fn an_envelope_of_white_type_that_draws_apart_from_its_drawn_art_keeps_it() {
let Some(family) = installed_family() else { return };
let doc = type_envelope_text(&family, Some("/0 << /0 1 /1 [ 1.0 1.0 1.0 1.0 ] >>"));
let text = made_envelope_type(&doc);
assert_eq!(text_fill(&text), Some(vectorcraft_color::Color::rgb(1.0, 1.0, 1.0)));
let (drawn, page) = drawn_envelope(&arc_envelope(&text, -30.0));
// A dark square under it, so the page shows the white type.
let ground = "0 0 0 1 k\n0 0 m\n100 0 L\n100 100 L\n0 100 L\nf\n";
let art = native_with_text(&layer("Art", true, &format!("{ground}{}", type_envelope(&arc_keys(0.3), &drawn))), &doc);
let r = import(&eps(&format!("0 0 0 1 setcmykcolor 0 0 moveto 100 0 lineto 100 100 lineto 0 100 lineto closepath fill\n{page}"), &art)).unwrap();
assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings);
let (envelopes, loose) = envelopes_and_type(&r.document);
assert!(envelopes.is_empty(), "{envelopes:?}");
assert_eq!(loose, [("FLUX".to_string(), false)], "{:?}", r.warnings);
}
/// A `.ai` whose layers would open alone, without its PDF part, waits on the PDF part when an
/// envelope of type is in them, as for the other live envelopes: one opens live only where the
/// layers with it draw as the page. Where they don't, they open alone as before, the envelope
/// keeping its drawn art and its type, hidden, with the note, even though it would draw as that
/// drawn art; where they do, it opens live (#3179).
#[test]
fn an_envelope_of_type_waits_on_the_page_of_layers_that_would_open_alone() {
let Some(family) = installed_family() else { return };
let stories = [("FLUX\r", 0, (8170.0, 8180.0), (0.0, 0.0), 0.0), ("Note\r", 0, (8170.0, 8220.0), (0.0, 0.0), 0.0)];
let mut doc = text_document(&stories).replace("(Helvetica)", &format!("({family})"));
// [`native_with_text`] writes 60 characters a line: the end of the data, `~>`, isn't split.
while ascii85(doc.as_bytes()).len() % 60 == 1 {
doc.push('\n');
}
let (drawn, _) = drawn_envelope(&arc_envelope(&made_envelope_type(&doc), 30.0));
let art = format!("u\n{}U\n{}", text_object(1), type_envelope(&arc_keys(0.3), &drawn));
let file = private(&native_with_text(&layer("Art", true, &art), &doc));
let names = crate::NameEncoding::default();
let Err(layers) = crate::ai_layers_alone(&file, &[(100.0, 100.0)], 1.0, names) else { panic!("opened without the page") };
// A PDF part the layers don't draw as.
let (d, notes, read) = crate::layered_ai_from(layers, pdf_part(80.0, 80.0), vec![], false, false, 1.0, names);
assert!(read, "{notes:?}");
let (envelopes, loose) = envelopes_and_type(&d);
assert!(envelopes.is_empty(), "{envelopes:?}");
assert_eq!(loose, [("Note".to_string(), true), ("FLUX".to_string(), false)], "{notes:?}");
assert!(notes.iter().any(|n| n == crate::import::ai::ENVELOPE_TYPE_DRAWN), "{notes:?}");
// A PDF part that draws as those layers, with the drawn art: the envelope opens live.
let Err(layers) = crate::ai_layers_alone(&file, &[(100.0, 100.0)], 1.0, names) else { panic!("opened without the page") };
let (d, notes, read) = crate::layered_ai_from(layers, d, vec![], false, false, 1.0, names);
assert!(read, "{notes:?}");
let (envelopes, loose) = envelopes_and_type(&d);
assert_eq!(envelopes, [vec!["FLUX".to_string()]], "{notes:?}");
assert_eq!(loose, [("Note".to_string(), true)], "{notes:?}");
}
/// The hidden type outside envelopes in the layers of `d`: each text object's name.
fn loose_type_names(d: &Document) -> Vec<Option<String>> {
let mut out = vec![];
fn walk(n: &Node, out: &mut Vec<Option<String>>) {
match &n.kind {
NodeKind::Envelope { .. } => {}
NodeKind::Text(_) => out.push(n.name.clone()),
_ => n.children().into_iter().flatten().for_each(|c| walk(c, out)),
}
}
d.layers.iter().for_each(|l| walk(l, &mut out));
out
}
/// An envelope of type that draws as its drawn art doesn't open live where the page draws other art
/// there (the warp bent the other way): the page check weighs the live envelope as the art it draws,
/// not as type laid out again, so it sees the two differ. The file comes in as its page, as it does
/// with the drawn art (#3179).
#[test]
fn an_envelope_of_type_that_draws_apart_from_the_page_does_not_open_live() {
let Some(family) = installed_family() else { return };
let doc = type_envelope_text(&family, None);
let text = made_envelope_type(&doc);
let (drawn, _) = drawn_envelope(&arc_envelope(&text, 30.0));
let (_, page) = drawn_envelope(&arc_envelope(&text, -30.0));
let art = native_with_text(&layer("Art", true, &format!("{}\n{}", square(10, 10), type_envelope(&arc_keys(0.3), &drawn))), &doc);
let r = import(&eps(&format!("{}\n{page}", page_square(10, 10)), &art)).unwrap();
let (envelopes, _) = envelopes_and_type(&r.document);
assert!(envelopes.is_empty(), "{envelopes:?} {:?}", r.warnings);
assert!(r.warnings.iter().any(|n| n.contains("layers weren't read") && n.contains("differs from the page's")), "{:?}", r.warnings);
}
/// Layers used though they draw differently from the page (the editing data trusted) leave an
/// envelope of type its drawn art and its type, hidden, with the note, even where it draws as that
/// drawn art: one opens live only where the layers with it draw as the page. A `.ai` whose layers
/// would open alone then opens alone, as it would without the envelope (#3179).
#[test]
fn trusted_layers_that_draw_apart_from_the_page_leave_an_envelope_of_type_its_drawn_art() {
let Some(family) = installed_family() else { return };
let doc = type_envelope_text(&family, None);
let (drawn, _) = drawn_envelope(&arc_envelope(&made_envelope_type(&doc), 30.0));
let art = native_with_text(&layer("Art", true, &format!("{}\n{}", square(10, 10), type_envelope(&arc_keys(0.3), &drawn))), &doc);
// The page has the square only.
let r = crate::import_trusting(&eps(&page_square(10, 10), &art), true, true).unwrap();
assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings);
let (envelopes, loose) = envelopes_and_type(&r.document);
assert!(envelopes.is_empty(), "{envelopes:?}");
assert_eq!(loose, [("FLUX".to_string(), false)], "{:?}", r.warnings);
// The type put back is named as the file names it.
assert_eq!(loose_type_names(&r.document), [None]);
assert!(r.warnings.iter().any(|n| n == crate::import::ai::ENVELOPE_TYPE_DRAWN), "{:?}", r.warnings);
assert!(r.warnings.iter().any(|n| n.ends_with("they come from its editing data all the same")), "{:?}", r.warnings);
// A `.ai` whose layers would open alone, its PDF part drawn apart from them.
let stories = [("FLUX\r", 0, (8170.0, 8180.0), (0.0, 0.0), 0.0), ("Note\r", 0, (8170.0, 8220.0), (0.0, 0.0), 0.0)];
let mut doc = text_document(&stories).replace("(Helvetica)", &format!("({family})"));
while ascii85(doc.as_bytes()).len() % 60 == 1 {
doc.push('\n');
}
let art = format!("u\n{}U\n{}", text_object(1), type_envelope(&arc_keys(0.3), &drawn));
let file = private(&native_with_text(&layer("Art", true, &art), &doc));
let names = crate::NameEncoding::default();
let Err(layers) = crate::ai_layers_alone(&file, &[(100.0, 100.0)], 1.0, names) else { panic!("opened without the page") };
let (d, notes, read) = crate::layered_ai_from(layers, pdf_part(80.0, 80.0), vec![], false, true, 1.0, names);
assert!(read, "{notes:?}");
let (envelopes, loose) = envelopes_and_type(&d);
assert!(envelopes.is_empty(), "{envelopes:?}");
assert_eq!(loose, [("Note".to_string(), true), ("FLUX".to_string(), false)], "{notes:?}");
assert!(notes.iter().any(|n| n == crate::import::ai::ENVELOPE_TYPE_DRAWN), "{notes:?}");
assert!(!notes.iter().any(|n| n.ends_with("they come from its editing data all the same")), "{notes:?}");
}
/// Layers whose objects that draw differently from the page come in as its art leave an envelope
/// of type its drawn art and its type, hidden, with the note, even where the envelope draws as the
/// page: the layers with it don't (#3179).
#[test]
fn layers_with_objects_that_come_in_as_the_page_art_leave_an_envelope_of_type_its_drawn_art() {
let Some(family) = installed_family() else { return };
let doc = type_envelope_text(&family, None);
let (drawn, page) = drawn_envelope(&arc_envelope(&made_envelope_type(&doc), 30.0));
// A square black in the layers and cyan on the page, away from the envelope.
let body = format!("{}\n{}\n{}", square(10, 10), square_16(76, BLACK), type_envelope(&arc_keys(0.3), &drawn));
let art = native_with_text(&layer("Art", true, &body), &doc);
let cyan = "1 0 0 0 setcmykcolor 76 10 moveto 92 10 lineto 92 26 lineto 76 26 lineto closepath fill";
let r = import(&eps(&format!("{}\n{cyan}\n{page}", page_square(10, 10)), &art)).unwrap();
assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings);
assert!(
r.warnings.iter().any(|n| n.starts_with("objects that drew differently from the file's own page came in as its art")),
"{:?}",
r.warnings
);
let (envelopes, loose) = envelopes_and_type(&r.document);
assert!(envelopes.is_empty(), "{envelopes:?}");
assert_eq!(loose, [("FLUX".to_string(), false)], "{:?}", r.warnings);
assert_eq!(loose_type_names(&r.document), [None]);
assert!(r.warnings.iter().any(|n| n == crate::import::ai::ENVELOPE_TYPE_DRAWN), "{:?}", r.warnings);
}
/// An envelope of type whose type a visibility variable binds keeps its drawn art, as one whose type
/// a text variable binds does: the binding stays the hidden type's, which the live envelope would
/// take the place of (#3179).
#[test]
fn an_envelope_of_type_bound_to_a_visibility_variable_keeps_its_drawn_art() {
use vectorcraft_testkit::ai::{XmlValue, named, variable_library_data, xml_element, xml_node};
let Some(family) = installed_family() else { return };
let doc = type_envelope_text(&family, None);
let (drawn, page) = drawn_envelope(&arc_envelope(&made_envelope_type(&doc), 30.0));
let reference = "/XMLUIDREF : (type_x5F_1) ;";
let attributes = [("trait", XmlValue::Str("visibility")), ("varName", XmlValue::Unicode("v_shown")), ("docRef", XmlValue::Raw(reference))];
let declared = [xml_node(1, "variable", XmlValue::Str(""), &attributes, &[])];
let set = xml_element("variableSet", &[("locked", "none")], &[xml_element("variables", &[], &declared)]);
let library = variable_library_data(&xml_element("variableSets", &[], &[set]));
// The text object's XML id, on plain lines as `type_envelope_with` takes it.
let id = named("type_x5F_1").replace("%_", "");
let envelope = type_envelope_with(&arc_keys(0.3), &drawn, &id);
let art = native_with_text(&(library + &layer("Art", true, &format!("{}\n{envelope}", square(10, 10)))), &doc);
let r = import(&eps(&format!("{}\n{page}", page_square(10, 10)), &art)).unwrap();
assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings);
let (envelopes, loose) = envelopes_and_type(&r.document);
assert!(envelopes.is_empty(), "{envelopes:?}");
assert_eq!(loose, [("FLUX".to_string(), false)], "{:?}", r.warnings);
let mut text = vec![];
r.document.walk(|n| {
if matches!(n.kind, NodeKind::Text(_)) {
text.push(n.id);
}
});
assert_eq!(r.document.variables.objects_of("v_shown"), text, "{:?}", r.warnings);
assert!(r.warnings.iter().any(|n| n == crate::import::ai::ENVELOPE_TYPE_DRAWN), "{:?}", r.warnings);
assert!(!r.warnings.iter().any(|n| n.contains("variable bindings")), "{:?}", r.warnings);
}
/// The fill of the first run of type `n`.
fn text_fill(n: &Node) -> Option<vectorcraft_color::Color> {
let NodeKind::Text(t) = &n.kind else { return None };
t.runs.first().and_then(|r| r.style.fill.color())
}
/// A live envelope that draws a little apart from its drawn art, in layers that already differ
/// from the page almost as much as the page check takes, would make them differ too much: its
/// drawn art is put back, so the file still opens from its layers.
+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 } => {
+23 -18
View File
@@ -106,6 +106,7 @@ pub(super) fn mark_options(app: &VectorcraftApp, curve_points_from_tool: bool) -
}
}
/// Draw selection marks and widgets, keeping the box but hiding dot marks during Halftone Preview.
pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf, curve_points_from_tool: bool, marks: &mut SelectionMarks) {
// The Selection tool's bounding box (rotated with the objects after a rotation), also shown by
// the shape tools that keep a shape just drawn ready to adjust while they draw none (#5477). It
@@ -140,7 +141,8 @@ pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf:
None => b,
});
let st = app.session.active();
let big = st.is_some_and(|st| too_many_anchors(st.selection.objects.iter().filter_map(|id| st.doc.node(*id))));
let show_marks = !crate::dialogs::halftone::previewing(app);
let big = show_marks && st.is_some_and(|st| too_many_anchors(st.selection.objects.iter().filter_map(|id| st.doc.node(*id))));
let big_bounds = if big { app.selection_bounds() } else { None };
let app = &*app;
let Some(st) = app.session.active() else { return };
@@ -152,24 +154,27 @@ pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf:
let options = mark_options(app, curve_points_from_tool);
let grow = options.look.grow;
let anchor = |direct: bool| grow + if direct { 5.0 } else { 4.0 };
// Too many paths to outline: the selection's bounds stand for them.
if let Some((b, id)) = big_bounds.zip(st.selection.objects.first()) {
stroke_path(p, &vectorcraft_geom::shapes::rectangle(b).to_bezpath(), xf, Stroke::new(1.0, c32(st.doc.layer_color(*id))));
// The temporary halftone selects its dots; their marks obscure the art (#6369).
if show_marks {
// Too many paths to outline: the selection's bounds stand for them.
if let Some((b, id)) = big_bounds.zip(st.selection.objects.first()) {
stroke_path(p, &vectorcraft_geom::shapes::rectangle(b).to_bezpath(), xf, Stroke::new(1.0, c32(st.doc.layer_color(*id))));
}
// The key object (Align to Key Object): its outline drawn thicker, or its bounds when it has
// too many paths to outline.
let key = st.selection.key.and_then(|k| Some((k, st.doc.node(k)?)));
let (key_bounds, key_outline) = match key {
Some((k, n)) if too_many_anchors([n]) => (Some((k, n)), None),
Some((k, _)) => (None, Some(k)),
None => (None, None),
};
if let Some((k, n)) = key_bounds
&& let Some(b) = n.geometric_bounds_with(&symbols)
{
stroke_path(p, &vectorcraft_geom::shapes::rectangle(b).to_bezpath(), xf, Stroke::new(KEY_OUTLINE, c32(st.doc.layer_color(k))));
}
marks.paint(st, p, xf, &options, key_outline, if big { &[] } else { &st.selection.objects });
}
// The key object (Align to Key Object): its outline drawn thicker, or its bounds when it has
// too many paths to outline.
let key = st.selection.key.and_then(|k| Some((k, st.doc.node(k)?)));
let (key_bounds, key_outline) = match key {
Some((k, n)) if too_many_anchors([n]) => (Some((k, n)), None),
Some((k, _)) => (None, Some(k)),
None => (None, None),
};
if let Some((k, n)) = key_bounds
&& let Some(b) = n.geometric_bounds_with(&symbols)
{
stroke_path(p, &vectorcraft_geom::shapes::rectangle(b).to_bezpath(), xf, Stroke::new(KEY_OUTLINE, c32(st.doc.layer_color(k))));
}
marks.paint(st, p, xf, &options, key_outline, if big { &[] } else { &st.selection.objects });
// The spine of each selected blend (or of the blend a selected key object belongs to).
let mut spines = vec![];
for id in &st.selection.objects {
+67
View File
@@ -1567,6 +1567,73 @@ fn cmd_with_another_tool_drags_with_the_selection_tool_used_last() {
assert_eq!(cmd_drag(&mut app, inside, inside), "selection");
}
/// #6369: only the selection box remains over the temporary halftone, and the original
/// selection's marks come back when Preview is turned off or the dialog is cancelled.
#[test]
fn halftone_preview_hides_dot_outlines_and_keeps_selection_bounds() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app.ui.task_bar = false;
app.ui.view.rulers = false;
app.ui.view.bounding_box = true;
let original = app.run("shape.ellipse", json!({"x": 50, "y": 50, "width": 120, "height": 100})).unwrap()["id"].as_u64().unwrap();
let original = vectorcraft_doc::NodeId(original);
app.run("paint.setFill", json!({"color": "#000000"})).unwrap();
let canvas = egui::Context::default();
let dialog = egui::Context::default();
let dialog_frame = |app: &mut VectorcraftApp| {
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() };
let mut out = dialog.run_ui(raw, |ui| crate::dialogs::show(app, ui.ctx()));
out.textures_delta.clear();
};
let has_interior_marks = |app: &mut VectorcraftApp| {
frame(app, &canvas, vec![]);
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
let corners = app.selection_box().unwrap().corners().map(|p| xf.to_screen(p));
let inside = egui::Rect::from_points(&corners).shrink(10.0);
let st = app.session.active().unwrap();
let color = c32(st.doc.layer_color(st.selection.objects[0]));
let points = drawn_in(app, &canvas, color, 1.0);
assert!(corners.iter().all(|p| points.contains(p)), "the outer selection box is still drawn");
points.iter().any(|p| inside.contains(*p))
};
assert!(has_interior_marks(&mut app), "the selected ellipse shows its outline");
app.run("ui.menuDialog", json!({"command": "object.vectorHalftone"})).unwrap();
dialog_frame(&mut app);
assert!(app.session.in_interaction());
assert_ne!(app.session.active().unwrap().selection.objects, vec![original], "the halftone is previewed and selected");
assert!(!has_interior_marks(&mut app), "selection marks cover the halftone dots");
let set_preview = |app: &mut VectorcraftApp, on| {
crate::dialogs::set_field(app.ui.dialog.as_mut().unwrap(), "preview".into(), json!(on));
dialog_frame(app);
};
set_preview(&mut app, false);
assert_eq!(app.session.active().unwrap().selection.objects, vec![original]);
assert!(has_interior_marks(&mut app), "turning Preview off restores the outline");
set_preview(&mut app, true);
assert!(!has_interior_marks(&mut app), "turning Preview back on hides the dot marks again");
crate::dialogs::cancel(&mut app);
assert_eq!(app.session.active().unwrap().selection.objects, vec![original]);
assert!(has_interior_marks(&mut app), "Cancel restores the outline");
app.run("ui.menuDialog", json!({"command": "object.move"})).unwrap();
crate::dialogs::set_field(app.ui.dialog.as_mut().unwrap(), "preview".into(), json!(true));
dialog_frame(&mut app);
assert!(app.session.in_interaction());
assert!(has_interior_marks(&mut app), "another dialog's preview keeps selection marks");
crate::dialogs::cancel(&mut app);
app.run("ui.menuDialog", json!({"command": "object.vectorHalftone"})).unwrap();
dialog_frame(&mut app);
assert!(!has_interior_marks(&mut app));
crate::dialogs::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none() && !app.session.in_interaction());
assert!(has_interior_marks(&mut app), "OK restores ordinary selection marks on the applied dots");
}
/// #525: a selection with more anchors than the overlay draws (a traced photo) shows its bounds,
/// not every outline and anchor, which made a mesh larger than the GPU takes; an ordinary one
/// still shows its outline and anchors.
+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),
+6
View File
@@ -29,6 +29,12 @@ pub fn fields() -> Value {
json!({"shape": "circle", "frequency": 20, "angle": 45, "mode": "mono", "color": "#000000", "invert": false, "clip": true, "keepOriginal": false, "preview": true})
}
/// Whether the open Object → Vector Halftone dialog is showing its temporary dot group.
pub(crate) fn previewing(app: &VectorcraftApp) -> bool {
app.ui.dialog.as_ref().is_some_and(|d| d.kind == KIND)
&& app.session.active().and_then(|st| st.interaction.as_ref()?.preview.as_ref()).is_some_and(|(cmd, _)| cmd == CMD)
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
screen_fields(ui, d);
form::check(ui, d, "keepOriginal", tl!("Keep Original"));
+12
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 Области на създателя на фигури
@@ -5845,6 +5852,11 @@ axis Y Axis Y ос
Button Бутон
Panel Панел
UI Frames UI рамки
Map Markers Маркери на карта
Pin Карфица
Flag Флаг
Crosshair Мерник
Checkpoint Контролна точка
Open Symbol Library Отваряне на библиотека със символи
Save Symbol Library Запазване на библиотека със символи
Save Symbol Library… Запази библиотека със символи…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+12
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
@@ -5889,6 +5896,11 @@ axis Y Axis Y osa
Button Dugme
Panel Panel
UI Frames UI okviri
Map Markers Markeri mape
Pin Pin
Flag Zastava
Crosshair Nišan
Checkpoint Kontrolna tačka
Open Symbol Library Otvori biblioteku simbola
Save Symbol Library Sačuvaj biblioteku simbola
Save Symbol Library… Sačuvaj biblioteku simbola…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+12
View File
@@ -443,6 +443,11 @@ antiAlias Sharp Nítid
Button Botó
Panel Panell
UI Frames Marcs d’IU
Map Markers Marcadors de mapa
Pin Xinxeta
Flag Bandera
Crosshair Punt de mira
Checkpoint Punt de control
Batch Lot
Batch… Lot…
Bearing Orientació
@@ -3921,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.
+12
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
@@ -6066,6 +6073,11 @@ axis Y Axis osa Y
Button Tlačítko
Panel Panel
UI Frames Rámce UI
Map Markers Mapové značky
Pin Špendlík
Flag Vlajka
Crosshair Zaměřovač
Checkpoint Kontrolní bod
Open Symbol Library Otevřít knihovnu symbolů
Save Symbol Library Uložit knihovnu symbolů
Save Symbol Library… Uložit knihovnu symbolů…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+12
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
@@ -5860,6 +5867,11 @@ axis Y Axis Y-akse
Button Knap
Panel Panel
UI Frames UI-rammer
Map Markers Kortmarkører
Pin Nål
Flag Flag
Crosshair Sigtekorn
Checkpoint Kontrolpunkt
Open Symbol Library Åbn symbolbibliotek
Save Symbol Library Gem symbolbibliotek
Save Symbol Library… Gem symbolbibliotek…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+12
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
@@ -5649,6 +5656,11 @@ axis Y Axis Y-Achse
Button Schaltfläche
Panel Bedienfeld
UI Frames UI-Rahmen
Map Markers Kartenmarkierungen
Pin Stecknadel
Flag Flagge
Crosshair Fadenkreuz
Checkpoint Kontrollpunkt
Open Symbol Library Symbol-Bibliothek öffnen
Save Symbol Library Symbol-Bibliothek speichern
Save Symbol Library… Symbol-Bibliothek speichern…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+12
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 Λειτουργία σχήματος
@@ -6017,6 +6024,11 @@ axis Y Axis Άξονας Y
Button Κουμπί
Panel Πίνακας
UI Frames Πλαίσια UI
Map Markers Δείκτες χάρτη
Pin Καρφίτσα
Flag Σημαία
Crosshair Σταυρόνημα
Checkpoint Σημείο ελέγχου
Open Symbol Library Άνοιγμα βιβλιοθήκης συμβόλων
Save Symbol Library Αποθήκευση βιβλιοθήκης συμβόλων
Save Symbol Library… Αποθήκευση βιβλιοθήκης συμβόλων…
Can't render this file because it is too large.
+12
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
@@ -6008,6 +6015,11 @@ axis Y Axis Eje Y
Button Botón
Panel Panel
UI Frames Marcos de IU
Map Markers Marcadores de mapa
Pin Chincheta
Flag Bandera
Crosshair Punto de mira
Checkpoint Punto de control
Open Symbol Library Abrir biblioteca de símbolos
Save Symbol Library Guardar biblioteca de símbolos
Save Symbol Library… Guardar biblioteca de símbolos…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+12
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
@@ -6096,6 +6103,11 @@ axis Y Axis Y-akseli
Button Painike
Panel Paneeli
UI Frames UI-kehykset
Map Markers Karttamerkit
Pin Nasta
Flag Lippu
Crosshair Tähtäin
Checkpoint Tarkastuskohta
Open Symbol Library Avaa symbolikirjasto
Save Symbol Library Tallenna symbolikirjasto
Save Symbol Library… Tallenna symbolikirjasto…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+12
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
@@ -6061,6 +6068,11 @@ axis Y Axis Axe Y
Button Bouton
Panel Panneau
UI Frames Cadres d’IU
Map Markers Marqueurs de carte
Pin Épingle
Flag Drapeau
Crosshair Réticule
Checkpoint Point de contrôle
Open Symbol Library Ouvrir une bibliothèque de symboles
Save Symbol Library Enregistrer la bibliothèque de symboles
Save Symbol Library… Enregistrer la bibliothèque de symboles…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+12
View File
@@ -514,6 +514,11 @@ antiAlias Sharp शार्प
Button बटन
Panel पैनल
UI Frames UI फ़्रेम
Map Markers मानचित्र चिह्न
Pin पिन
Flag झंडा
Crosshair क्रॉसहेयर
Checkpoint चेकपॉइंट
Batch बैच
Batch Trace Folder फ़ोल्डर बैच ट्रेस करें
Batch… बैच…
@@ -4233,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.
+12
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
@@ -6049,6 +6056,11 @@ axis Y Axis Y tengely
Button Gomb
Panel Panel
UI Frames UI keretek
Map Markers Térképjelölők
Pin Gombostű
Flag Zászló
Crosshair Célkereszt
Checkpoint Ellenőrzőpont
Open Symbol Library Szimbólumkönyvtár megnyitása
Save Symbol Library Szimbólumkönyvtár mentése
Save Symbol Library… Szimbólumkönyvtár mentése…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+12
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
@@ -5860,6 +5867,11 @@ axis Y Axis Sumbu Y
Button Tombol
Panel Panel
UI Frames Bingkai UI
Map Markers Penanda Peta
Pin Pin
Flag Bendera
Crosshair Bidik silang
Checkpoint Pos pemeriksaan
Open Symbol Library Buka pustaka simbol
Save Symbol Library Simpan pustaka simbol
Save Symbol Library… Simpan pustaka simbol…
Can't render this file because it contains an unexpected character in line 2315 and column 5.
+12
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
@@ -5683,6 +5690,11 @@ axis Y Axis Asse Y
Button Pulsante
Panel Pannello
UI Frames Cornici UI
Map Markers Marcatori mappa
Pin Spillo
Flag Bandiera
Crosshair Mirino
Checkpoint Punto di controllo
Open Symbol Library Apri libreria simboli
Save Symbol Library Salva libreria simboli
Save Symbol Library… Salva libreria simboli…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+12
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 グラデーション仕上げ
@@ -5748,6 +5755,11 @@ axis Y Axis Y 軸
Button ボタン
Panel パネル
UI Frames UIフレーム
Map Markers マップマーカー
Pin ピン
Flag 旗
Crosshair クロスヘア
Checkpoint チェックポイント
Open Symbol Library シンボルライブラリを開く
Save Symbol Library シンボルライブラリを保存
Save Symbol Library… シンボルライブラリを保存…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+12
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 გრადიენტული ზედაპირები
@@ -5989,6 +5996,11 @@ axis Y Axis Y ღერძი
Button ღილაკი
Panel პანელი
UI Frames UI ჩარჩოები
Map Markers რუკის ნიშნულები
Pin ქინძისთავი
Flag დროშა
Crosshair სამიზნე
Checkpoint საკონტროლო წერტილი
Open Symbol Library სიმბოლოების ბიბლიოთეკის გახსნა
Save Symbol Library სიმბოლოების ბიბლიოთეკის შენახვა
Save Symbol Library… სიმბოლოების ბიბლიოთეკის შენახვა…
Can't render this file because it is too large.
+12
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 그레이디언트 마감
@@ -5644,6 +5651,11 @@ axis Y Axis Y축
Button 버튼
Panel 패널
UI Frames UI 프레임
Map Markers 지도 마커
Pin 핀
Flag 깃발
Crosshair 십자선
Checkpoint 체크포인트
Open Symbol Library 심볼 라이브러리 열기
Save Symbol Library 심볼 라이브러리 저장
Save Symbol Library… 심볼 라이브러리 저장…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+12
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
@@ -5911,6 +5918,11 @@ axis Y Axis Y-as
Button Knop
Panel Paneel
UI Frames UI-kaders
Map Markers Kaartmarkeringen
Pin Speld
Flag Vlag
Crosshair Vizier
Checkpoint Controlepunt
Open Symbol Library Symboolbibliotheek openen
Save Symbol Library Symboolbibliotheek opslaan
Save Symbol Library… Symboolbibliotheek opslaan…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+12
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
@@ -5920,6 +5927,11 @@ axis Y Axis Oś Y
Button Przycisk
Panel Panel
UI Frames Ramki UI
Map Markers Znaczniki mapy
Pin Pinezka
Flag Flaga
Crosshair Celownik
Checkpoint Punkt kontrolny
Open Symbol Library Otwórz bibliotekę symboli
Save Symbol Library Zapisz bibliotekę symboli
Save Symbol Library… Zapisz bibliotekę symboli…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+12
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
@@ -5981,6 +5988,11 @@ axis Y Axis Eixo Y
Button Botão
Panel Painel
UI Frames Molduras de IU
Map Markers Marcadores de mapa
Pin Alfinete
Flag Bandeira
Crosshair Mira
Checkpoint Ponto de verificação
Open Symbol Library Abrir Biblioteca de Símbolos
Save Symbol Library Salvar Biblioteca de Símbolos
Save Symbol Library… Salvar Biblioteca de Símbolos…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+12
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 Области создания фигур
@@ -5677,6 +5684,11 @@ axis Y Axis Ось Y
Button Кнопка
Panel Панель
UI Frames UI-рамки
Map Markers Маркеры карты
Pin Булавка
Flag Флаг
Crosshair Прицел
Checkpoint Контрольная точка
Open Symbol Library Открыть библиотеку символов
Save Symbol Library Сохранить библиотеку символов
Save Symbol Library… Сохранить библиотеку символов…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+12
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
@@ -6089,6 +6096,11 @@ axis Y Axis Y-axel
Button Knapp
Panel Panel
UI Frames UI-ramar
Map Markers Kartmarkörer
Pin Nål
Flag Flagga
Crosshair Hårkors
Checkpoint Kontrollpunkt
Open Symbol Library Öppna symbolbibliotek
Save Symbol Library Spara symbolbibliotek
Save Symbol Library… Spara symbolbibliotek…
Can't render this file because it contains an unexpected character in line 5 and column 25.
+12
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
@@ -6022,6 +6029,11 @@ axis Y Axis Y ekseni
Button Düğme
Panel Panel
UI Frames UI çerçeveleri
Map Markers Harita İşaretleri
Pin İğne
Flag Bayrak
Crosshair Nişangah
Checkpoint Kontrol noktası
Open Symbol Library Sembol Kitaplığını Aç
Save Symbol Library Sembol Kitaplığını Kaydet
Save Symbol Library… Sembol Kitaplığını Kaydet…
Can't render this file because it contains an unexpected character in line 5 and column 25.
+12
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 Області створення фігур
@@ -5663,6 +5670,11 @@ axis Y Axis Вісь Y
Button Кнопка
Panel Панель
UI Frames UI-рамки
Map Markers Маркери карти
Pin Шпилька
Flag Прапор
Crosshair Приціл
Checkpoint Контрольна точка
Open Symbol Library Відкрити бібліотеку символів
Save Symbol Library Зберегти бібліотеку символів
Save Symbol Library… Зберегти бібліотеку символів…
Can't render this file because it contains an unexpected character in line 182 and column 5.
+12
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
@@ -5664,6 +5671,11 @@ axis Y Axis Trục Y
Button Nút
Panel Bảng
UI Frames Khung UI
Map Markers Đánh dấu bản đồ
Pin Ghim
Flag Cờ
Crosshair Tâm ngắm
Checkpoint Điểm kiểm tra
Open Symbol Library Mở thư viện ký hiệu
Save Symbol Library Lưu thư viện ký hiệu
Save Symbol Library… Lưu thư viện ký hiệu…
Can't render this file because it contains an unexpected character in line 204 and column 5.
+12
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 形状生成器区域
@@ -5904,6 +5911,11 @@ axis Y Axis Y 轴
Button 按钮
Panel 面板
UI Frames UI 框架
Map Markers 地图标记
Pin 图钉
Flag 旗帜
Crosshair 准星
Checkpoint 检查点
Open Symbol Library 打开符号库
Save Symbol Library 保存符号库
Save Symbol Library… 保存符号库…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+12
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 形狀建立區域
@@ -5701,6 +5708,11 @@ axis Y Axis Y 軸
Button 按鈕
Panel 面板
UI Frames UI 框架
Map Markers 地圖標記
Pin 圖釘
Flag 旗幟
Crosshair 準星
Checkpoint 檢查點
Open Symbol Library 開啟符號資料庫
Save Symbol Library 儲存符號資料庫
Save Symbol Library… 儲存符號資料庫…
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);
}
@@ -113,7 +113,7 @@ fn the_window_menu_and_the_symbols_panel_open_symbol_libraries() {
let under = |e: &crate::menus::MenuEntry| e.path == ["Window", "Symbol Libraries"];
let opened: Vec<_> =
entries.iter().filter(|e| under(e) && e.command.as_deref() == Some("window.symbolLibrary")).map(|e| e.params["library"].clone()).collect();
assert_eq!(opened, [json!("basic-shapes"), json!("ui-frames")]);
assert_eq!(opened, [json!("basic-shapes"), json!("ui-frames"), json!("map-markers")]);
let has = |cmd: &str| entries.iter().find(|e| under(e) && e.command.as_deref() == Some(cmd)).map(|e| e.enabled);
assert_eq!(has("window.symbolLibrary.other"), Some(true));
assert_eq!(has("ui.saveSymbolLibrary"), Some(false), "no symbols to save yet");
+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");
}
}
}
+12 -1
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (`.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; 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; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (`.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; 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; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x)
Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`,
`OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public
@@ -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.
@@ -327,6 +336,8 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors,
| Date | Change | Summary |
|---|---|---|
| 2026-11-10 | minor | `.ai` and EPS import: an envelope of type (a warp, a mesh or a top object around type) kept its drawn art, its type hidden, because the type is laid out from the text document only after the layers are read. It now waits for that: its text slots get their type, and where VectorCraft's own envelope of that type draws as the drawn art the file saves (the same check and budget as other envelopes, seen on white and on black), it opens live in the drawn art's place, its type editable in Edit Contents, and the hidden copy goes. As with other live envelopes, the layers with one are then compared with the page, which weighs the envelope as the art it draws (not as type laid out again, which the comparison allows more for), and when they don't draw as it the drawn art and the hidden type are put back. Layers used though they don't draw as the page (the editing data trusted, or the objects that differ coming in as the page's art) leave envelopes of type their drawn art and hidden type too. A `.ai` whose layers would open alone, without its PDF part, imports the PDF part to compare them with, and should they still not draw as it, or should no envelope of type open live, they open alone as before, with the envelopes' drawn art. Type in a font that isn't available, slots without type of their own (threaded, bound to a text or a visibility variable, a wrap object, type a look draws), envelopes in a symbol's or a pattern's art, and envelopes that draw differently keep their drawn art, with the note (#3179). Tests `tests_native::an_envelope_of_type_that_draws_as_its_drawn_art_opens_live`, `an_envelope_of_type_that_draws_apart_from_its_drawn_art_keeps_it`, `an_envelope_of_white_type_that_draws_apart_from_its_drawn_art_keeps_it`, `an_envelope_of_type_waits_on_the_page_of_layers_that_would_open_alone`, `an_envelope_of_type_that_draws_apart_from_the_page_does_not_open_live`, `trusted_layers_that_draw_apart_from_the_page_leave_an_envelope_of_type_its_drawn_art`, `layers_with_objects_that_come_in_as_the_page_art_leave_an_envelope_of_type_its_drawn_art`, `an_envelope_of_type_bound_to_a_visibility_variable_keeps_its_drawn_art` |
| 2026-11-10 | minor | `.ai` and EPS import: an envelope of type, which keeps its drawn art with its type hidden, now says so in the import notes ("type in an envelope comes in as the envelope's drawn art, not as editable type"); nothing said it before, while the other envelopes that keep their drawn art have a note. The type is still not editable in the envelope (part of #3179). Test `tests_native::envelope_options_keep_the_source_text_hidden_under_its_outlined_appearance` |
| 2026-11-10 | minor | `.ai` and EPS import: the check that keeps a live envelope or warped type only where it draws as the art saved with it renders each on its own, over nothing, and compared the pixels seen on white, where white art looks like nothing. White contents that drew somewhere else passed it and opened live in the wrong place (white warped type off its banner, in users' files). Pixels now also differ when they look different seen on black, so such art keeps its saved art, as colored art does. Tests `import::ai::tests::a_white_envelope_that_doesnt_come_out_as_drawn_keeps_its_drawn_art`, `white_warped_type_that_draws_elsewhere_comes_in_as_its_look` |
| 2026-11-10 | minor | PDF import: an image in a DeviceN space of one colorant opens as a Separation image does (#6314): a grayscale image of its tints colorized by its ink. One of two or more colorants that are all process plates (Cyan, Magenta, Yellow, Black, and the ink `None`) opens as a CMYK image, each colorant's tint in its plate and its Decode array applied (8 or 16 bits a sample; one that can't be read, such as a JPEG, opens in RGB with the CMYK images' warning). Both used to open as RGB images, losing their inks. A DeviceN image that mixes a spot ink with other colorants, such as a duotone, still opens as RGB: an image holds one ink or the process plates, not both (#6348). Tests `colorize::a_devicen_image_of_one_ink_opens_colorized_by_it`, `colorize::a_devicen_image_of_process_plates_opens_in_cmyk`, `colorize::a_duotone_devicen_image_still_opens_as_rgb` |
| 2026-11-09 | minor | `.ai` import: an image whose color space `XC` sets to one ink (`n [c m y k /DeviceCMYK (name) /Separation] XC`, or an RGB alternate) opened as a gray RGB image: its samples, the ink's tints (255 at 100 %), were read as gray levels, so the tints drew inverted and the ink was lost. It now opens as a grayscale image of the tints, black at 100 %, colorized by the ink at 100 %: a spot swatch, a process plate's CMYK color (no swatch of its name), or Registration; the ink's color counts toward the document's color mode as a fill's does. Its alpha channel becomes its opacity mask, a reduced large image's too: a colorized image reduced to fit the limits (an image mask included) now keeps its alpha as an opacity mask rather than in its pixels, as at its full size. 8-bit and 1-bit samples, binary or in hex comment lines. A device space (`/DeviceGray XC` after a linked image's file name) and the ink `None` change nothing, as before; linked images (`XF`) keep their placeholder. Tests `import::ai::tests::separation_images_are_colorized_with_their_ink`, `separation_images_of_process_plates_and_registration`, `a_separation_image_with_hex_comment_lines_keeps_its_ink`, `a_reduced_separation_image_keeps_its_ink_and_its_alpha_as_a_mask` (#6350) |
+24 -25
View File
@@ -1,19 +1,17 @@
# Gaps
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** 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 (docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; 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)
## Feature submission evidence
**Feature evidence last updated:** 2026-11-02.
| 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 |
**Feature evidence last updated:** 2026-11-03.
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.
**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.
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.
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.
**PR #3272 revision — 2026-11-04:** Group Selection component drags defer ancestor selection until a completed click, using current Direct Selection release handling; original Outline Stroke fills and newer selection behavior retained. Scores and estimates unchanged.
Creating a gradient from solid or empty paint uses the document's color model for its default stops, including drawing its vector with the Gradient tool. Explicit `keepModel: true` retains RGB defaults, and existing gradients keep their own colors. Scores and estimates are unchanged.
Window → Help Bar toggles the bottom tool-help strip independently of the status bar. Its visibility is saved with workspaces and UI preferences; Presentation Mode hides both bars. Scores and estimates are unchanged.
@@ -24,12 +22,8 @@ adds one undo step; the list follows saved font filters, ignores search text, an
stops at its ends. These interaction details
are VectorCraft's choices. Scores and estimates are unchanged.
**Feature evidence last updated:** 2026-11-02.
**Feature evidence last updated:** 2026-11-03.
The shared assessment status line and revision history are current upstream; this dated section records the submission's feature evidence and revisions without rescoring upstream or claiming a new combined build or runtime measurement.
- Current-main conflict reconciliation retains upstream and feature behavior; scores and estimates unchanged
- Superseded 2026-10-20 behavior, retained as history: Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting uses the original target artboard and revision-scoped bounds caches
- Artboard Options puts Fit to Artwork Bounds and Fit to Selected Art in the Preset menu. Artwork bounds cover all drawn document art, including other artboards and off-board art; selection bounds may span artboards. Fit changes only the active artboard and keeps artwork stationary, including position-only fitting with Move Artwork enabled. Existing Background Color and presets remain available; scores and estimates unchanged.
@@ -43,6 +37,9 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
| 2026-11-08 | minor | #3380, deletion only: preserve gradient placement and exact surviving geometry; consume owned subpaths with a shared original-node snapshot; consolidate status/evidence and document command/helper contracts. Compound behavior and the existing M2 status entry are retained. Endpoint highlighting remains open; scores and estimates unchanged. |
| 2026-11-07 | minor | #3380 (deletion only): independent surviving path objects; transformed appearance, stacking, selection, cubic handles and Undo/Redo covered by `delete_path_pieces`; compound semantics retained. Endpoint highlighting remains open; scores and estimates unchanged. |
| 2026-11-06 | minor | G9: EPS import reads gradients a page paints as abutting strips as one linear gradient each (no seams); an Illustrator EPS whose page drew them that way keeps its layers. Estimate unchanged. |
| 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 |
| 2026-11-04 | minor | PR #3273: 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. |
| 2026-11-04 | minor | PR #3272: Group Selection component drags defer ancestor selection until a completed click, using current Direct Selection release handling; original Outline Stroke fills and newer selection behavior retained. Scores and estimates unchanged. |
| 2026-11-03 | minor | Docked Tools stays between left-side panels and the canvas; redocking accepts canvas and window edges. Requested layout choice; scores and estimates unchanged. |
| 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. |
@@ -51,6 +48,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. |
@@ -60,17 +58,11 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
| 2026-11-03 | minor | Help Bar has its own Window menu toggle and workspace visibility setting. Scores and estimates unchanged. |
| 2026-11-02 | minor | Font-family fields step selected text with the mouse wheel (#5253); no scored gap or estimate reassessed. |
| 2026-10-24 | minor | G12: General › Corner Radius is the Rounded Rectangle tool's radius, a change while the tool is active reaches it, and the radius ↑/↓ set while drawing (or a Tool Options value) becomes the preference, within 0–1296 pt (#394, `crates/engine/src/tests/constrain_tools.rs`); no entry closed, estimate unchanged. |
| 2026-10-22 | minor | Reconcile upstream documentation after review F-3; preserve every dated revision and the original scaleArt runtime evidence without repeating completed jobs. |
| 2026-10-22 | minor | Reconcile upstream documentation after review F-2; preserve every dated revision and the original scaleArt runtime evidence without repeating completed jobs. |
| 2026-10-22 | minor | Reconcile subsequent upstream progress updates without changing the runtime or repeating completed evidence; retain every dated upstream and feature revision. |
| 2026-10-22 | minor | Resynchronize newer upstream progress documentation using the same owned source/build cache; preserve every feature revision and the original scaleArt runtime evidence. |
| 2026-10-22 | minor | Retain the latest upstream shared assessment and complete revision history after newer documentation landed; scaleArt runtime and its original evidence remain unchanged. |
| 2026-10-22 | minor | Correct the scaleArt follow-up evidence description to match current upstream shared status and revision history; runtime, evidence, scores and estimates unchanged. |
| 2026-10-22 | minor | Documentation reconciled with newer upstream progress docs in six passes (after reviews F-2 and F-3), including a corrected description of the scaleArt follow-up evidence; every dated revision and the original scaleArt runtime evidence kept; runtime, scores and estimates unchanged. |
| 2026-10-22 | minor | Artboard Options honors Scale Artwork with Artboard for preset, orientation and typed-size changes; both Fits keep artwork stationary; scores and estimates unchanged. |
| 2026-10-21 | minor | Correct current global-all-art and document/revision cache descriptions; mark target-scoped fitting as superseded 2026-10-20 history. Runtime, tests, scores and estimates unchanged. |
| 2026-10-21 | minor | Artboard Options puts Fit to Artwork Bounds and Fit to Selected Art in the Preset menu. Artwork bounds cover all drawn document art, including other artboards and off-board art; selection bounds may span artboards. Fit changes only the active artboard and keeps artwork stationary, including position-only fitting with Move Artwork enabled. Existing Background Color and presets remain available; scores and estimates unchanged. |
| 2026-10-21 | minor | Reconcile feature status and revision evidence in a dated section; retain shared baseline metadata for independent upstream updates. Scores and estimates are not reassessed. |
| 2026-10-21 | minor | Reconcile this feature with current main, preserving both sets of behavior and progress evidence; scores and estimates unchanged. |
| 2026-10-21 | minor | Feature status and revision evidence reconciled with current main in a dated section, keeping both sets of behaviour and progress evidence and the shared baseline metadata for independent upstream updates; scores and estimates unchanged. |
| 2026-10-20 | minor | Review follow-up: artwork fitting uses the original target artboard and revision-scoped bounds caches; focused regression evidence and behavior documentation updated; scores and hours unchanged. |
| 2026-10-20 | minor | Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; scores and hours unchanged. |
@@ -97,7 +89,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) | 11–21 | 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) |
@@ -628,8 +620,9 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi
symbol static drops them; stored in the native format.
Color eyedropper for scatter, art and pattern brushes (Hue Shift colorization; samples canvas art);
symbol libraries ([public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html)): Window › Symbol Libraries and the Symbols panel's Symbol
Libraries Menu open each library in a library panel of its own (two original libraries generated in code,
Basic Shapes and UI Frames with default 9-slice guides on Panel, Button and Bar; User Defined; Other
Libraries Menu open each library in a library panel of its own (three original libraries generated in code,
Basic Shapes, UI Frames with default 9-slice guides on Panel, Button and Bar, and Map Markers with Pin,
Flag, Crosshair and Checkpoint; User Defined; Other
Library… reads the symbols of any document VectorCraft opens); a click
adds a symbol to the document's Symbols with its Symbol Options and the resources it uses, as one undo
step, and dragging one onto the canvas also places it; Save Symbols / Save Symbol Library writes the
@@ -651,12 +644,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
Shapes and UI Frames; original, generated in code, never Adobe's); dynamic symbols' `.ai` read and write;
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:** 11–21 h.
- **Estimate:** 9–19 h.
## G3 3D and Materials
@@ -854,6 +850,9 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv
| Date | Change | Summary |
|---|---|---|
| 2026-11-10 | minor | Tidy-up, no content change: in Feature submission evidence, a stray table row and two "PR #NNNN revision" notes move into Feature revisions, the three "Feature evidence last updated" lines become one, the duplicate Flyout update note goes (its row stays), and eight reconciliation rows become two. 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. |
| 2026-11-10 | minor | G2: the Effect Gallery shows a thumbnail of each filter and a preview area inside the dialog (an original landscape drawing passed through the filter). Estimate 1–2 h → 0. The canvas preview and one-undo OK are 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}}}

Some files were not shown because too many files have changed in this diff Show More